diff --git a/src/__tests__/wallet-manager.test.ts b/src/__tests__/wallet-manager.test.ts index 45578b3..c87e969 100644 --- a/src/__tests__/wallet-manager.test.ts +++ b/src/__tests__/wallet-manager.test.ts @@ -7,15 +7,19 @@ import * as walletModule from "../wallet.js"; import * as transactionModule from "../transaction.js"; // Mock data for tests -// BLS12381 public key is 48 bytes = 96 hex characters +// BLS12381 public keys are 48 bytes = 96 hex characters const mockBls12381PublicKey = "2f469f1af9a0419c09f11e6609a36a27806d2ba201eb0982d150433c7fad68c579a42789cbbb70c8063909a0f85e4233"; +const mockBls12381Address = "ef3b75eb336323222ed6f4ca64cffbf696f9fb63"; +const mockSecondaryBls12381PublicKey = + "3f469f1af9a0419c09f11e6609a36a27806d2ba201eb0982d150433c7fad68c579a42789cbbb70c8063909a0f85e4233"; +const mockSecondaryBls12381Address = "9db5a855ce499d55eb7a01c0c24822aca00dc6ce"; const mockGoKeystoreEntry = { publicKey: mockBls12381PublicKey, salt: "b4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9", encrypted: "d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", - keyAddress: "0x1234567890abcdef1234567890abcdef12345678", + keyAddress: mockBls12381Address, keyNickname: "test-key", }; @@ -35,10 +39,10 @@ const mockKeystoreJson = { entries: [ mockGoKeystoreEntry, { - publicKey: mockBls12381PublicKey, + publicKey: mockSecondaryBls12381PublicKey, salt: "c5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", encrypted: "e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", - keyAddress: "0xabcdef0123456789abcdef0123456789abcdef01", + keyAddress: mockSecondaryBls12381Address, keyNickname: "secondary-key", }, ], @@ -113,7 +117,7 @@ describe("WalletManager", () => { entries: [ { ...mockGoKeystoreEntry, - keyAddress: "0x1234567890ABCDEF1234567890ABCDEF12345678", + keyAddress: mockBls12381Address.toUpperCase(), }, ], }; @@ -121,7 +125,7 @@ describe("WalletManager", () => { walletManager.loadKeystoreJson(mixedCaseEntry); const accounts = walletManager.getAccounts(); - expect(accounts[0]).toBe("0x1234567890abcdef1234567890abcdef12345678"); + expect(accounts[0]).toBe(mockBls12381Address); }); it("should throw error on invalid keystore format - missing entries", () => { @@ -198,7 +202,7 @@ describe("WalletManager", () => { }); it("should handle mixed case address lookup", () => { - const mixedCaseAddress = "0x1234567890ABCDEF1234567890ABCDEF12345678"; + const mixedCaseAddress = mockBls12381Address.toUpperCase(); const account = walletManager.getAccount(mixedCaseAddress); expect(account).toBeDefined(); @@ -273,7 +277,7 @@ describe("WalletManager", () => { mockPrivateKeyHex ); - const mixedCaseAddress = "0x1234567890ABCDEF1234567890ABCDEF12345678"; + const mixedCaseAddress = mockBls12381Address.toUpperCase(); const privateKey = await walletManager.unlockAccount( mixedCaseAddress, "password" @@ -356,7 +360,7 @@ describe("WalletManager", () => { mockSignedTransaction as any ); - const mixedCaseAddress = "0x1234567890ABCDEF1234567890ABCDEF12345678"; + const mixedCaseAddress = mockBls12381Address.toUpperCase(); const tx = walletManager.buildTransaction( mixedCaseAddress, mockTransactionParams, @@ -456,7 +460,7 @@ describe("WalletManager", () => { }); it("should handle mixed case address", () => { - const mixedCaseAddress = "0x1234567890ABCDEF1234567890ABCDEF12345678"; + const mixedCaseAddress = mockBls12381Address.toUpperCase(); const isValid = walletManager.isValidAccount(mixedCaseAddress); expect(isValid).toBe(true); }); @@ -484,7 +488,7 @@ describe("WalletManager", () => { }); it("should handle mixed case address", () => { - const mixedCaseAddress = "0x1234567890ABCDEF1234567890ABCDEF12345678"; + const mixedCaseAddress = mockBls12381Address.toUpperCase(); const curveType = walletManager.getCurveType(mixedCaseAddress); expect(curveType).toBe(CurveTypeEnum.BLS12381); }); @@ -570,7 +574,7 @@ describe("WalletManager", () => { it("should handle multiple accounts independently", async () => { vi.spyOn(keystoreModule, "decryptEntry").mockImplementation( async (entry) => { - return entry.address.includes("1234") + return entry.address === mockBls12381Address ? "privatekey1" : "privatekey2"; } 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.` ); } diff --git a/src/provably-fair.ts b/src/provably-fair.ts index a60371d..f849c84 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; } /** @@ -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; @@ -83,4 +110,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