diff --git a/src/__tests__/routes.test.ts b/src/__tests__/routes.test.ts index 4c38eab..0b3ed00 100644 --- a/src/__tests__/routes.test.ts +++ b/src/__tests__/routes.test.ts @@ -508,8 +508,12 @@ describe('API Routes', () => { // Verify nonce was stored const dbResult = await server.db.query<{ count: number }>( - 'SELECT COUNT(*) as count FROM nonces WHERE chain_id = $1 AND nonce = $2', - [chainId, result.nonce] + 'SELECT COUNT(*) as count FROM nonces WHERE chain_id = $1 AND sponsor = $2 AND nonceFragment = $3', + [ + chainId, + freshCompact.sponsor.slice(2).toLowerCase(), + result.nonce.slice(42).toLowerCase(), + ] ); expect(dbResult.rows[0].count).toBe(1); } finally { diff --git a/src/__tests__/validation.test.ts b/src/__tests__/validation.test.ts index f0a969e..e630da6 100644 --- a/src/__tests__/validation.test.ts +++ b/src/__tests__/validation.test.ts @@ -42,9 +42,10 @@ describe('Validation', () => { CREATE TABLE IF NOT EXISTS nonces ( id TEXT PRIMARY KEY, chain_id TEXT NOT NULL, - nonce TEXT NOT NULL, + sponsor TEXT NOT NULL, + nonceFragment TEXT NOT NULL, consumed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - UNIQUE(chain_id, nonce) + UNIQUE(chain_id, sponsor, nonceFragment) ) `); }); @@ -248,11 +249,14 @@ describe('Validation', () => { it('should reject a used nonce', async (): Promise => { const compact = getFreshCompact(); + const nonceHex = compact.nonce.toString(16).padStart(64, '0'); + const sponsorPart = nonceHex.slice(0, 40); + const fragmentPart = nonceHex.slice(40); // Insert nonce as used await db.query( - 'INSERT INTO nonces (id, chain_id, nonce) VALUES ($1, $2, $3)', - ['test-id', chainId, compact.nonce.toString()] + 'INSERT INTO nonces (id, chain_id, sponsor, nonceFragment) VALUES ($1, $2, $3, $4)', + ['test-id', chainId, sponsorPart, fragmentPart] ); const result = await validateCompact(compact, chainId, db); @@ -272,16 +276,37 @@ describe('Validation', () => { it('should allow same nonce in different chains', async (): Promise => { const compact = getFreshCompact(); + const nonceHex = compact.nonce.toString(16).padStart(64, '0'); + const sponsorPart = nonceHex.slice(0, 40); + const fragmentPart = nonceHex.slice(40); // Insert nonce as used in a different chain await db.query( - 'INSERT INTO nonces (id, chain_id, nonce) VALUES ($1, $2, $3)', - ['test-id', '10', compact.nonce.toString()] + 'INSERT INTO nonces (id, chain_id, sponsor, nonceFragment) VALUES ($1, $2, $3, $4)', + ['test-id', '10', sponsorPart, fragmentPart] ); const result = await validateCompact(compact, chainId, db); expect(result.isValid).toBe(true); }); + + it('should handle mixed case nonces consistently', async (): Promise => { + const compact = getFreshCompact(); + const nonceHex = compact.nonce.toString(16).padStart(64, '0'); + const sponsorPart = nonceHex.slice(0, 40); + const fragmentPart = nonceHex.slice(40).toUpperCase(); // Use uppercase + + // Insert nonce with uppercase fragment + await db.query( + 'INSERT INTO nonces (id, chain_id, sponsor, nonceFragment) VALUES ($1, $2, $3, $4)', + ['test-id', chainId, sponsorPart, fragmentPart] + ); + + // Try to validate same nonce with uppercase + const result = await validateCompact(compact, chainId, db); + expect(result.isValid).toBe(false); + expect(result.error).toContain('Nonce has already been used'); + }); }); describe('validateAllocation', () => { @@ -357,7 +382,7 @@ describe('Validation', () => { items: [ { withdrawalStatus: 0, - balance: (BigInt(compact.amount) / 2n).toString(), // Half the compact amount + balance: (BigInt(compact.amount) / BigInt(2)).toString(), // Half the compact amount }, ], }, @@ -416,7 +441,7 @@ describe('Validation', () => { items: [ { withdrawalStatus: 0, - balance: (BigInt(compact.amount) * 2n).toString(), // Enough for two compacts + balance: (BigInt(compact.amount) * BigInt(2)).toString(), // Enough for two compacts }, ], }, diff --git a/src/schema.ts b/src/schema.ts index 50d6506..38e11d0 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -28,9 +28,10 @@ export const schemas = { CREATE TABLE IF NOT EXISTS nonces ( id TEXT PRIMARY KEY, chain_id TEXT NOT NULL, - nonce TEXT NOT NULL, + sponsor TEXT NOT NULL, + nonceFragment TEXT NOT NULL, consumed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - UNIQUE(chain_id, nonce) + UNIQUE(chain_id, sponsor, nonceFragment) ) `, compacts: ` @@ -63,7 +64,7 @@ export const indexes = { 'CREATE INDEX IF NOT EXISTS idx_compacts_chain_claim ON compacts(chain_id, claim_hash)', ], nonces: [ - 'CREATE INDEX IF NOT EXISTS idx_nonces_chain_nonce ON nonces(chain_id, nonce)', + 'CREATE INDEX IF NOT EXISTS idx_nonces_chain_sponsor_fragment ON nonces(chain_id, sponsor, nonceFragment)', ], session_requests: [ 'CREATE INDEX IF NOT EXISTS idx_session_requests_address ON session_requests(address)', diff --git a/src/validation.ts b/src/validation.ts index 514dac1..8888006 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -28,21 +28,22 @@ export async function generateNonce( // Get sponsor address without 0x prefix and lowercase const sponsorAddress = getAddress(sponsor).toLowerCase().slice(2); - // Get the highest nonce used for this sponsor - const result = await db.query<{ nonce: string }>( - `SELECT nonce FROM nonces + // Get the highest nonce fragment used for this sponsor + const result = await db.query<{ nonceFragment: string }>( + `SELECT nonceFragment FROM nonces WHERE chain_id = $1 - AND SUBSTRING(nonce FROM 1 FOR 40) = $2 - ORDER BY nonce::numeric DESC + AND sponsor = $2 + ORDER BY nonceFragment::numeric DESC LIMIT 1`, [chainId, sponsorAddress] ); let lastNonceCounter = BigInt(0); if (result.rows.length > 0) { - // Extract the counter part (last 12 bytes) of the last nonce - const lastNonce = BigInt(result.rows[0].nonce); - lastNonceCounter = lastNonce & ((BigInt(1) << BigInt(96)) - BigInt(1)); // Get last 12 bytes + // Extract the counter part from the last nonce fragment + lastNonceCounter = BigInt( + '0x' + result.rows[0].nonceFragment.toLowerCase() + ); } // Create new nonce: sponsor address (20 bytes) + incremented counter (12 bytes) @@ -167,19 +168,21 @@ export async function validateNonce( db: PGlite ): Promise { try { - // Convert nonce to 32-byte hex string (without 0x prefix) + // Convert nonce to 32-byte hex string (without 0x prefix) and lowercase let nonceHex; if (nonce.toString(16).startsWith('0x')) { - nonceHex = nonce.toString(16).slice(2).padStart(64, '0'); + nonceHex = nonce.toString(16).slice(2).padStart(64, '0').toLowerCase(); } else { - nonceHex = nonce.toString(16).padStart(64, '0'); + nonceHex = nonce.toString(16).padStart(64, '0').toLowerCase(); } - // Check that the first 20 bytes of the nonce match the sponsor's address - const sponsorAddress = getAddress(sponsor).toLowerCase().slice(2); - const noncePrefix = nonceHex.slice(0, 40); // first 20 bytes = 42 chars + // Split nonce into sponsor and fragment parts + const sponsorPart = nonceHex.slice(0, 40); // first 20 bytes = 40 hex chars + const fragmentPart = nonceHex.slice(40); // remaining 12 bytes = 24 hex chars - if (noncePrefix !== sponsorAddress) { + // Check that the sponsor part matches the sponsor's address (both lowercase) + const sponsorAddress = getAddress(sponsor).toLowerCase().slice(2); + if (sponsorPart !== sponsorAddress) { return { isValid: false, error: 'Nonce does not match sponsor address', @@ -188,8 +191,8 @@ export async function validateNonce( // Check if nonce has been used before in this domain const result = await db.query<{ count: number }>( - 'SELECT COUNT(*) as count FROM nonces WHERE chain_id = $1 AND nonce = $2', - [chainId, nonce.toString()] + 'SELECT COUNT(*) as count FROM nonces WHERE chain_id = $1 AND sponsor = $2 AND LOWER(nonceFragment) = $3', + [chainId, sponsorAddress, fragmentPart] ); if (result.rows[0].count > 0) { @@ -376,8 +379,20 @@ export async function storeNonce( chainId: string, db: PGlite ): Promise { + // Convert nonce to 32-byte hex string (without 0x prefix) and lowercase + let nonceHex; + if (nonce.toString(16).startsWith('0x')) { + nonceHex = nonce.toString(16).slice(2).padStart(64, '0').toLowerCase(); + } else { + nonceHex = nonce.toString(16).padStart(64, '0').toLowerCase(); + } + + // Split nonce into sponsor and fragment parts + const sponsorPart = nonceHex.slice(0, 40); // first 20 bytes = 40 hex chars + const fragmentPart = nonceHex.slice(40); // remaining 12 bytes = 24 hex chars + await db.query( - 'INSERT INTO nonces (id, chain_id, nonce, consumed_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP)', - [randomUUID(), chainId, nonce.toString()] + 'INSERT INTO nonces (id, chain_id, sponsor, nonceFragment, consumed_at) VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)', + [randomUUID(), chainId, sponsorPart, fragmentPart] ); }