Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/__tests__/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,23 @@ describe("request", () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("does not fetch when the caller signal is already aborted", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const controller = new AbortController();
const reason = new Error("cancelled");
controller.abort(reason);

const err = await request(
"op",
"/p",
{ method: "GET" },
{ signal: controller.signal, retry: false },
).catch((e) => e);
expect(err).toBe(reason);
expect(fetchMock).not.toHaveBeenCalled();
});

it("throws TimeoutError when the request exceeds timeoutMs", async () => {
// fetch that rejects with an abort-style error when its signal aborts
const fetchMock = vi.fn().mockImplementation((_url, init: RequestInit) => {
Expand Down
5 changes: 5 additions & 0 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ export async function request(
// Abort if either the caller's signal or our timeout fires.
const onCallerAbort = () => timeoutController.abort(opts.signal?.reason);
opts.signal?.addEventListener("abort", onCallerAbort, { once: true });
if (opts.signal?.aborted) {
clearTimeout(timer);
opts.signal.removeEventListener("abort", onCallerAbort);
throw opts.signal.reason;
}

let res: Response;
try {
Expand Down
7 changes: 5 additions & 2 deletions src/keystore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ export function importFromGoKeystore(
const curveType = detectPublicKeyCurve(entry.publicKey);

const derivedAddress = deriveAddress(entry.publicKey, curveType);
// Throw instead of warn: a mismatch means the entry is tampered or corrupted.
// Silently importing the wrong address would cause funds to be sent to the
// wrong destination or transactions to be rejected by the network.
if (derivedAddress.toLowerCase() !== entry.keyAddress.toLowerCase()) {
console.warn(
`Address mismatch: expected ${entry.keyAddress}, derived ${derivedAddress}`
throw new Error(
`Keystore integrity check failed: stored address ${entry.keyAddress} does not match address derived from public key ${derivedAddress}. The keystore entry may be corrupted or tampered.`
);
}

Expand Down
43 changes: 35 additions & 8 deletions src/provably-fair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,32 @@ export function computeHMAC(

/**
* Computes a dice roll in the range [0, 9999] from the given seeds and nonce.
* Maps the first 4 bytes of HMAC-SHA256 to a uint32, then takes modulo 10000.
*
* Uses rejection sampling over successive 4-byte HMAC windows to eliminate
* modulo bias. A naive `uint32 % 10000` is biased because 2^32 (4 294 967 296)
* is not evenly divisible by 10000 — values 0–7295 appear once more often than
* values 7296–9999, giving the house a hidden systematic edge on those outcomes.
*
* Rejection threshold: 4 294 960 000 (= floor(2^32 / 10000) * 10000).
* Values at or above the threshold are discarded and the next 4-byte window is
* tried. In practice fewer than 2 iterations are needed on average.
*/
export function computeDiceRoll(
serverSeed: string,
clientSeed: string,
nonce: number
): number {
const h = computeHMAC(serverSeed, clientSeed, nonce);
const view = new DataView(h.buffer, h.byteOffset, h.byteLength);
const raw = view.getUint32(0, false); // big-endian
return raw % 10000;
const RANGE = 10000;
const THRESHOLD = Math.floor(0x100000000 / RANGE) * RANGE; // 4_294_960_000
for (let offset = 0; offset + 4 <= h.length; offset += 4) {
const view = new DataView(h.buffer, h.byteOffset + offset, 4);
const raw = view.getUint32(0, false); // big-endian
if (raw < THRESHOLD) return raw % RANGE;
}
// Fallback (astronomically unlikely): use modulo on last window
const view = new DataView(h.buffer, h.byteOffset, 4);
return view.getUint32(0, false) % RANGE;
}

/**
Expand All @@ -63,18 +78,30 @@ export function verifyDiceRoll(

/**
* Computes the crash point for a rocket round.
* Uses HMAC-SHA256(serverSeed, nonce) with 3% house edge.
* Uses HMAC-SHA256(serverSeed, clientSeed:nonce) with 3% house edge.
* Result is clamped to [1.01, 100.0].
*
* @param clientSeed - Player-supplied seed that contributes entropy to the outcome.
* Must not be empty: passing "" removes the client's ability to independently
* influence and verify the result, breaking the provably-fair guarantee.
*/
export function computeCrashPoint(
serverSeed: string,
clientSeed: string,
nonce: number
): number {
const h = computeHMAC(serverSeed, "", nonce);
if (!clientSeed) {
throw new Error("clientSeed must not be empty — an empty client seed removes player entropy from the provably-fair computation");
}
const h = computeHMAC(serverSeed, clientSeed, nonce);
const hex8 = bytesToHex(h).slice(0, 8);
const result = parseInt(hex8, 16) >>> 0; // unsigned 32-bit

if (result % 33 === 0) return 1.0;
// Instant-crash case: return the advertised floor (1.01), not 1.0.
// The docstring and UI both promise outcomes in [1.01, 100.0]; returning
// 1.0 violates the stated minimum and gives players a worse payout than
// the published house-edge formula implies.
if (result % 33 === 0) return 1.01;

const houseEdge = 0.03;
const e = 0x100000000;
Expand All @@ -83,4 +110,4 @@ export function computeCrashPoint(
if (crashPoint < 1.01) crashPoint = 1.01;
if (crashPoint > 100.0) crashPoint = 100.0;
return crashPoint;
}
}