Skip to content

Commit

Permalink
optional nonce generation
Browse files Browse the repository at this point in the history
  • Loading branch information
0age committed Dec 7, 2024
1 parent 3155330 commit 849cb4e
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 70 deletions.
6 changes: 3 additions & 3 deletions src/__tests__/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { generateClaimHash } from '../crypto';
import { type CompactMessage } from '../validation';
import { type StoredCompactMessage } from '../compact';

// Test suite for cryptographic functions used in the Smallocator
describe('crypto', () => {
describe('generateClaimHash', () => {
it('should generate consistent hash for a compact message', async () => {
const testCompact: CompactMessage = {
const testCompact: StoredCompactMessage = {
arbiter: '0x1234567890123456789012345678901234567890',
sponsor: '0x2345678901234567890123456789012345678901',
nonce: 1n,
nonce: BigInt(1),
expires: BigInt(1234567890),
id: BigInt(1),
amount: '1000000000000000000',
Expand Down
60 changes: 51 additions & 9 deletions src/__tests__/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,45 @@ describe('API Routes', () => {
}

expect(response.statusCode).toBe(200);
const result = JSON.parse(response.payload);
expect(result).toHaveProperty('hash');
expect(result).toHaveProperty('signature');
expect(result).toHaveProperty('nonce');
expect(result.nonce).toBe(
'0x' + freshCompact.nonce.toString(16).padStart(64, '0')
);
});

it('should handle null nonce by generating one', async () => {
const freshCompact = getFreshCompact();
const compactPayload = {
chainId: '1',
compact: {
...compactToAPI(freshCompact),
nonce: null,
},
};

compactPayload.compact.nonce = null;

const response = await server.inject({
method: 'POST',
url: '/compact',
headers: {
'x-session-id': sessionId,
},
payload: compactPayload,
});

expect(response.statusCode).toBe(200);
const result = JSON.parse(response.payload);
expect(result).toHaveProperty('hash');
expect(result).toHaveProperty('signature');
expect(result).toHaveProperty('nonce');
// Verify nonce format: first 20 bytes should match sponsor address
const nonceHex = BigInt(result.nonce).toString(16).padStart(64, '0');
const sponsorHex = freshCompact.sponsor.toLowerCase().slice(2);
expect(nonceHex.slice(0, 40)).toBe(sponsorHex);
});

it('should reject request without session', async () => {
Expand Down Expand Up @@ -465,13 +504,14 @@ describe('API Routes', () => {
});

expect(response.statusCode).toBe(200);
const result = JSON.parse(response.payload);

// Verify nonce was stored
const result = await server.db.query<{ count: number }>(
const dbResult = await server.db.query<{ count: number }>(
'SELECT COUNT(*) as count FROM nonces WHERE chain_id = $1 AND nonce = $2',
[chainId, freshCompact.nonce.toString()]
[chainId, result.nonce]
);
expect(result.rows[0].count).toBe(1);
expect(dbResult.rows[0].count).toBe(1);
} finally {
// Restore original function
graphqlClient.request = originalRequest;
Expand Down Expand Up @@ -522,16 +562,13 @@ describe('API Routes', () => {
payload: compactPayload,
});

const submitResponseData = JSON.parse(submitResponse.payload);
if (
submitResponse.statusCode !== 200 ||
!submitResponseData.result?.hash
) {
const submitResult = JSON.parse(submitResponse.payload);
if (submitResponse.statusCode !== 200 || !submitResult?.hash) {
console.error('Failed to submit compact:', submitResponse.payload);
throw new Error('Failed to submit compact');
}

const { hash } = submitResponseData.result;
const { hash } = submitResult;

const response = await server.inject({
method: 'GET',
Expand All @@ -550,6 +587,11 @@ describe('API Routes', () => {
}

expect(response.statusCode).toBe(200);
const result = JSON.parse(response.payload);
expect(result).toHaveProperty('chainId', '1');
expect(result).toHaveProperty('hash', hash);
expect(result).toHaveProperty('compact');
expect(result.compact).toHaveProperty('nonce');
});

it('should return error for non-existent compact', async () => {
Expand Down
17 changes: 11 additions & 6 deletions src/__tests__/utils/test-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { randomUUID } from 'crypto';
import { setupRoutes } from '../../routes';
import { dbManager } from '../setup';
import { signMessage } from 'viem/accounts';
import { getAddress } from 'viem/utils';

// Helper to generate test data
const defaultBaseUrl = 'https://smallocator.example';
Expand Down Expand Up @@ -193,10 +194,12 @@ let compactCounter = BigInt(0);
export function getFreshCompact(): typeof validCompact {
const counter = compactCounter++;
// Create nonce as 32-byte hex where first 20 bytes are sponsor address
const sponsorAddress = validCompact.sponsor.toLowerCase().replace('0x', '');
const sponsorAddress = getAddress(validCompact.sponsor)
.toLowerCase()
.slice(2);
const counterHex = counter.toString(16).padStart(24, '0'); // 12 bytes for counter
const nonceHex = '0x' + sponsorAddress + counterHex;
const nonce = BigInt(nonceHex);
const nonceHex = sponsorAddress + counterHex;
const nonce = BigInt('0x' + nonceHex);

return {
...validCompact,
Expand All @@ -207,13 +210,15 @@ export function getFreshCompact(): typeof validCompact {

// Helper to convert BigInt values to strings for API requests
export function compactToAPI(
compact: typeof validCompact
): Record<string, string | number> {
compact: typeof validCompact,
options: { nullNonce?: boolean } = {}
): Record<string, string | number | null> {
const nonce = options.nullNonce ? null : compact.nonce;
return {
...compact,
id: compact.id.toString(),
expires: compact.expires.toString(),
nonce: '0x' + compact.nonce.toString(16).padStart(64, '0'),
nonce: nonce === null ? null : '0x' + nonce.toString(16).padStart(64, '0'),
chainId: compact.chainId.toString(), // Convert chainId to string
};
}
Expand Down
97 changes: 75 additions & 22 deletions src/compact.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { FastifyInstance } from 'fastify';
import { type Hex } from 'viem';
import { getAddress } from 'viem/utils';
import { validateCompact, type CompactMessage, storeNonce } from './validation';
import {
validateCompact,
type CompactMessage,
storeNonce,
generateNonce,
} from './validation';
import { generateClaimHash, signCompact } from './crypto';
import { randomUUID } from 'crypto';

Expand All @@ -10,17 +15,47 @@ export interface CompactSubmission {
compact: CompactMessage;
}

export interface CompactRecord extends CompactSubmission {
// Separate interface for stored compacts where nonce is always present
export interface StoredCompactMessage {
id: bigint;
arbiter: string;
sponsor: string;
nonce: bigint; // This is non-null
expires: bigint;
amount: string;
witnessTypeString: string | null;
witnessHash: string | null;
}

export interface CompactRecord {
chainId: string;
compact: StoredCompactMessage;
hash: string;
signature: string;
createdAt: string;
}

// Helper to convert CompactMessage to StoredCompactMessage
function toStoredCompact(
compact: CompactMessage & { nonce: bigint }
): StoredCompactMessage {
return {
id: compact.id,
arbiter: compact.arbiter,
sponsor: compact.sponsor,
nonce: compact.nonce,
expires: compact.expires,
amount: compact.amount,
witnessTypeString: compact.witnessTypeString,
witnessHash: compact.witnessHash,
};
}

export async function submitCompact(
server: FastifyInstance,
submission: CompactSubmission,
sponsorAddress: string
): Promise<{ hash: string; signature: string }> {
): Promise<{ hash: string; signature: string; nonce: string }> {
// Validate sponsor matches the session
if (getAddress(submission.compact.sponsor) !== getAddress(sponsorAddress)) {
throw new Error('Sponsor address does not match session');
Expand All @@ -33,42 +68,59 @@ export async function submitCompact(
typeof submission.compact.id === 'string'
? BigInt(submission.compact.id)
: submission.compact.id,
nonce:
typeof submission.compact.nonce === 'string'
? BigInt(submission.compact.nonce)
: submission.compact.nonce,
expires:
typeof submission.compact.expires === 'string'
? BigInt(submission.compact.expires)
: submission.compact.expires,
};

// Validate the compact
// Generate nonce if not provided (do this before validation)
const nonce =
submission.compact.nonce === null
? await generateNonce(sponsorAddress, submission.chainId, server.db)
: submission.compact.nonce;

// Update compact with final nonce
const finalCompact = {
...compactForValidation,
nonce,
};

// Validate the compact (including nonce validation)
const validationResult = await validateCompact(
compactForValidation,
finalCompact,
submission.chainId,
server.db
);
if (!validationResult.isValid) {
throw new Error(validationResult.error || 'Invalid compact');
}

// Store the nonce as used
await storeNonce(compactForValidation.nonce, submission.chainId, server.db);
// Convert to StoredCompactMessage for crypto operations
const storedCompact = toStoredCompact(finalCompact);

// Generate the claim hash
const hash = await generateClaimHash(
compactForValidation,
storedCompact,
BigInt(submission.chainId)
);

// Sign the compact
const signature = await signCompact(hash, BigInt(submission.chainId));

// Store the nonce as used
await storeNonce(nonce, submission.chainId, server.db);

// Store the compact
await storeCompact(server, submission, hash, signature);
await storeCompact(
server,
storedCompact,
submission.chainId,
hash,
signature
);

return { hash, signature };
return { hash, signature, nonce: nonce.toString() };
}

export async function getCompactsByAddress(
Expand Down Expand Up @@ -180,7 +232,8 @@ export async function getCompactByHash(

async function storeCompact(
server: FastifyInstance,
submission: CompactSubmission,
compact: StoredCompactMessage,
chainId: string,
hash: Hex,
signature: Hex
): Promise<void> {
Expand All @@ -201,14 +254,14 @@ async function storeCompact(
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CURRENT_TIMESTAMP)`,
[
id,
submission.chainId,
chainId,
hash,
submission.compact.arbiter,
submission.compact.sponsor,
submission.compact.nonce.toString(),
submission.compact.expires.toString(),
submission.compact.id.toString(),
submission.compact.amount,
compact.arbiter,
compact.sponsor,
compact.nonce.toString(),
compact.expires.toString(),
compact.id.toString(),
compact.amount,
signature,
]
);
Expand Down
16 changes: 8 additions & 8 deletions src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
getAddress,
} from 'viem/utils';
import { privateKeyToAccount, signMessage } from 'viem/accounts';
import { type CompactMessage } from './validation';
import { type StoredCompactMessage } from './compact';
import { type SessionPayload } from './session';

// EIP-712 domain for The Compact
Expand Down Expand Up @@ -49,7 +49,7 @@ if (!privateKey) {
const account = privateKeyToAccount(privateKey);

export async function generateClaimHash(
compact: CompactMessage,
compact: StoredCompactMessage,
chainId: bigint
): Promise<Hex> {
// Normalize addresses
Expand All @@ -65,9 +65,9 @@ export async function generateClaimHash(
message: {
arbiter: normalizedArbiter,
sponsor: normalizedSponsor,
nonce: BigInt(compact.nonce),
expires: BigInt(compact.expires),
id: BigInt(compact.id),
nonce: compact.nonce,
expires: compact.expires,
id: compact.id,
amount: BigInt(compact.amount),
},
});
Expand Down Expand Up @@ -121,9 +121,9 @@ export async function generateClaimHash(
typeHash,
normalizedArbiter,
normalizedSponsor,
BigInt(compact.nonce),
BigInt(compact.expires),
BigInt(compact.id),
compact.nonce,
compact.expires,
compact.id,
BigInt(compact.amount),
compact.witnessHash as Hex,
]
Expand Down
Loading

0 comments on commit 849cb4e

Please sign in to comment.