From 229593cab096485901dd4b7504d0573ddbe75b84 Mon Sep 17 00:00:00 2001 From: AMATH <116212274+amathxbt@users.noreply.github.com> Date: Sat, 4 Jul 2026 08:12:51 +0100 Subject: [PATCH 1/5] fix(keystore): throw on address mismatch instead of silently warning (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit importFromGoKeystore() compared the stored keyAddress against the address derived from the public key, but only issued a console.warn on mismatch and then silently returned the stored (wrong) address. A corrupted or tampered keystore entry would be accepted without error, causing outgoing transactions to be signed with a key that does not match the on-chain address — resulting in fund loss or permanently rejected txs. Fix: throw an Error with a clear message so callers can detect and reject integrity failures before any funds are at risk. --- src/keystore.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/keystore.ts b/src/keystore.ts index 193bde3..120ffc1 100644 --- a/src/keystore.ts +++ b/src/keystore.ts @@ -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.` ); } From 2011e4d1d4104c5ad10f999b74c697ec4572b49f Mon Sep 17 00:00:00 2001 From: AMATH <116212274+amathxbt@users.noreply.github.com> Date: Sat, 4 Jul 2026 08:12:53 +0100 Subject: [PATCH 2/5] fix(provably-fair): use rejection sampling to eliminate modulo bias (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeDiceRoll() computed raw % 10000 on a uint32. Because 2^32 (4,294,967,296) is not evenly divisible by 10,000, values 0–7,295 appear slightly more often than values 7,296–9,999 — a systematic bias of ~1.7 per million per value in the favoured range. In a high-volume casino context this is not negligible: over millions of rolls the house retains a hidden edge on top of the declared house edge. Replace modulo with rejection sampling over successive 4-byte HMAC windows using a rejection threshold of floor(2^32 / 10000) * 10000 (4,294,960,000). Values at or above the threshold are discarded; the next 4-byte window is used. Average iterations per roll < 2. --- src/provably-fair.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/provably-fair.ts b/src/provably-fair.ts index a60371d..bffb5b0 100644 --- a/src/provably-fair.ts +++ b/src/provably-fair.ts @@ -36,7 +36,15 @@ 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, @@ -44,9 +52,16 @@ export function computeDiceRoll( 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; } /** @@ -83,4 +98,4 @@ export function computeCrashPoint( if (crashPoint < 1.01) crashPoint = 1.01; if (crashPoint > 100.0) crashPoint = 100.0; return crashPoint; -} +} \ No newline at end of file From 49d676e9f1b772f2686f79641c02bc1e50321e23 Mon Sep 17 00:00:00 2001 From: AMATH <116212274+amathxbt@users.noreply.github.com> Date: Sat, 4 Jul 2026 08:13:04 +0100 Subject: [PATCH 3/5] fix(provably-fair): add clientSeed param to computeCrashPoint (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeCrashPoint() called computeHMAC(serverSeed, "", nonce) — always passing an empty string as the client seed. The client seed is the player-supplied entropy that lets them independently verify outcomes are not predetermined. With an empty seed, the crash point is solely a function of the server seed and nonce; the player contributes nothing and cannot verify the game was not rigged against them. Add clientSeed as a required parameter and throw if it is empty. Update the JSDoc comment accordingly. --- src/provably-fair.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/provably-fair.ts b/src/provably-fair.ts index bffb5b0..23708d8 100644 --- a/src/provably-fair.ts +++ b/src/provably-fair.ts @@ -78,14 +78,22 @@ 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 From 455804001dae30a0edc11858e3fecb46a307574a Mon Sep 17 00:00:00 2001 From: AMATH <116212274+amathxbt@users.noreply.github.com> Date: Sat, 4 Jul 2026 08:13:26 +0100 Subject: [PATCH 4/5] fix(provably-fair): return 1.01 (not 1.0) for instant-crash outcome (#5) When result % 33 === 0 the function returned 1.0, but the JSDoc and the clamp at the bottom of the function both state the output range is [1.01, 100.0]. Returning 1.0 contradicts the advertised minimum and gives players a payout that is 1% worse than the published house-edge formula implies for the instant-crash case. Change the early return to 1.01 so every code path respects the advertised floor. --- src/provably-fair.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/provably-fair.ts b/src/provably-fair.ts index 23708d8..f849c84 100644 --- a/src/provably-fair.ts +++ b/src/provably-fair.ts @@ -97,7 +97,11 @@ export function computeCrashPoint( 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; From 8817ef8b286b0a70f2c9fdb2914bdfcc9edd8f77 Mon Sep 17 00:00:00 2001 From: giwaov Date: Mon, 6 Jul 2026 11:26:14 +0100 Subject: [PATCH 5/5] Handle pre-aborted request signals --- src/__tests__/http.test.ts | 17 +++++++++++++++++ src/http.ts | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/src/__tests__/http.test.ts b/src/__tests__/http.test.ts index ef0cb1b..2e2fefc 100644 --- a/src/__tests__/http.test.ts +++ b/src/__tests__/http.test.ts @@ -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) => { diff --git a/src/http.ts b/src/http.ts index 4f5b56b..81e5bbc 100644 --- a/src/http.ts +++ b/src/http.ts @@ -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 {