Skip to content

Commit

Permalink
refactor: split compact nonces into sponsor and fragment
Browse files Browse the repository at this point in the history
- Split nonces table to store sponsor and nonceFragment separately
- Update unique constraint to (chain_id, sponsor, nonceFragment)
- Ensure consistent lowercase handling for hex values
- Update tests for split columns and case sensitivity
- Maintain backward compatibility with compacts table

This change improves data organization by explicitly separating the
sponsor address (first 20 bytes) from the nonce fragment (last 12 bytes)
in the database schema.
  • Loading branch information
0age committed Dec 8, 2024
1 parent 849cb4e commit bb3ef06
Show file tree
Hide file tree
Showing 4 changed files with 77 additions and 32 deletions.
8 changes: 6 additions & 2 deletions src/__tests__/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 33 additions & 8 deletions src/__tests__/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
`);
});
Expand Down Expand Up @@ -248,11 +249,14 @@ describe('Validation', () => {

it('should reject a used nonce', async (): Promise<void> => {
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);
Expand All @@ -272,16 +276,37 @@ describe('Validation', () => {

it('should allow same nonce in different chains', async (): Promise<void> => {
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<void> => {
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', () => {
Expand Down Expand Up @@ -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
},
],
},
Expand Down Expand Up @@ -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
},
],
},
Expand Down
7 changes: 4 additions & 3 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
Expand Down Expand Up @@ -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)',
Expand Down
53 changes: 34 additions & 19 deletions src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -167,19 +168,21 @@ export async function validateNonce(
db: PGlite
): Promise<ValidationResult> {
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',
Expand All @@ -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) {
Expand Down Expand Up @@ -376,8 +379,20 @@ export async function storeNonce(
chainId: string,
db: PGlite
): Promise<void> {
// 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]
);
}

0 comments on commit bb3ef06

Please sign in to comment.