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
28 changes: 16 additions & 12 deletions src/__tests__/wallet-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};

Expand All @@ -35,10 +39,10 @@ const mockKeystoreJson = {
entries: [
mockGoKeystoreEntry,
{
publicKey: mockBls12381PublicKey,
publicKey: mockSecondaryBls12381PublicKey,
salt: "c5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
encrypted: "e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
keyAddress: "0xabcdef0123456789abcdef0123456789abcdef01",
keyAddress: mockSecondaryBls12381Address,
keyNickname: "secondary-key",
},
],
Expand Down Expand Up @@ -113,15 +117,15 @@ describe("WalletManager", () => {
entries: [
{
...mockGoKeystoreEntry,
keyAddress: "0x1234567890ABCDEF1234567890ABCDEF12345678",
keyAddress: mockBls12381Address.toUpperCase(),
},
],
};

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", () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -273,7 +277,7 @@ describe("WalletManager", () => {
mockPrivateKeyHex
);

const mixedCaseAddress = "0x1234567890ABCDEF1234567890ABCDEF12345678";
const mixedCaseAddress = mockBls12381Address.toUpperCase();
const privateKey = await walletManager.unlockAccount(
mixedCaseAddress,
"password"
Expand Down Expand Up @@ -356,7 +360,7 @@ describe("WalletManager", () => {
mockSignedTransaction as any
);

const mixedCaseAddress = "0x1234567890ABCDEF1234567890ABCDEF12345678";
const mixedCaseAddress = mockBls12381Address.toUpperCase();
const tx = walletManager.buildTransaction(
mixedCaseAddress,
mockTransactionParams,
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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";
}
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;
}
}