diff --git a/circuits/lib/src/constants.nr b/circuits/lib/src/constants.nr new file mode 100644 index 0000000..1eef442 --- /dev/null +++ b/circuits/lib/src/constants.nr @@ -0,0 +1,17 @@ +/// Shared ZK protocol constants used by PrivacyLayer circuits. +/// Keep these values in sync with sdk/src/constants.ts. + +/// Tree depth constant - must match the Soroban contract and SDK. +pub global TREE_DEPTH: u32 = 20; + +/// BN254-safe byte width for note nullifier and secret scalars. +pub global NOTE_SCALAR_BYTES: u32 = 31; + +/// Canonical byte width for field/node identifiers. +pub global FIELD_BYTES: u32 = 32; + +/// Zero leaf/sibling value used by empty Merkle tree paths. +pub global ZERO_LEAF: Field = 0; + +/// Relayer field value required when fee is zero. +pub global ZERO_RELAYER: Field = 0; diff --git a/circuits/lib/src/lib.nr b/circuits/lib/src/lib.nr index a750a2a..138ac78 100644 --- a/circuits/lib/src/lib.nr +++ b/circuits/lib/src/lib.nr @@ -9,6 +9,7 @@ // - validation : Input validation and assertion helpers // ============================================================ +pub mod constants; pub mod hash; pub mod merkle; pub mod validation; diff --git a/circuits/lib/src/merkle/config.nr b/circuits/lib/src/merkle/config.nr index 4c9c08e..c63017a 100644 --- a/circuits/lib/src/merkle/config.nr +++ b/circuits/lib/src/merkle/config.nr @@ -1,2 +1,4 @@ -/// Tree depth constant - must match the Soroban contract. -pub global TREE_DEPTH: u32 = 20; +use crate::constants; + +/// Tree depth constant - must match the Soroban contract and SDK. +pub global TREE_DEPTH: u32 = constants::TREE_DEPTH; diff --git a/sdk/src/constants.ts b/sdk/src/constants.ts new file mode 100644 index 0000000..b949560 --- /dev/null +++ b/sdk/src/constants.ts @@ -0,0 +1,30 @@ +// Shared ZK protocol constants used by the TypeScript SDK. +// Keep these values in sync with circuits/lib/src/constants.nr. + +/** Number of levels in the shielded-pool Merkle tree. */ +export const ZK_TREE_DEPTH = 20; + +/** Maximum 0-based leaf index accepted for the configured tree depth. */ +export const ZK_MAX_LEAF_INDEX = (1 << ZK_TREE_DEPTH) - 1; + +/** Byte length for note nullifier and secret scalars that must fit BN254. */ +export const ZK_NOTE_SCALAR_BYTES = 31; + +/** Byte length for canonical field/node/pool identifiers. */ +export const ZK_FIELD_BYTES = 32; +export const ZK_POOL_ID_BYTES = ZK_FIELD_BYTES; + +/** Portable note backup format layout. */ +export const NOTE_BACKUP_VERSION = 0x01; +export const NOTE_BACKUP_PREFIX = 'privacylayer-note:'; +export const NOTE_AMOUNT_BYTES = 8; +export const NOTE_CHECKSUM_BYTES = 4; +export const NOTE_BACKUP_PAYLOAD_LENGTH = + 1 + ZK_NOTE_SCALAR_BYTES + ZK_NOTE_SCALAR_BYTES + ZK_POOL_ID_BYTES + NOTE_AMOUNT_BYTES + NOTE_CHECKSUM_BYTES; +export const NOTE_BACKUP_CHECKSUM_OFFSET = NOTE_BACKUP_PAYLOAD_LENGTH - NOTE_CHECKSUM_BYTES; + +/** Legacy note serialization reserves 16 bytes for the amount field. */ +export const LEGACY_NOTE_AMOUNT_SLOT_BYTES = 16; + +/** A valid Stellar zero account used only when no relayer fee is charged. */ +export const ZERO_RELAYER_STELLAR_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 3687fc9..51326ca 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -1,3 +1,4 @@ +export * from './constants'; export * from './backends'; export * from './benchmark'; export * from './encoding'; diff --git a/sdk/src/merkle.ts b/sdk/src/merkle.ts index 8117a1d..d8f1890 100644 --- a/sdk/src/merkle.ts +++ b/sdk/src/merkle.ts @@ -1,18 +1,19 @@ import type { MerkleProof } from './proof'; import { WitnessValidationError } from './errors'; +import { ZK_FIELD_BYTES, ZK_MAX_LEAF_INDEX, ZK_TREE_DEPTH } from './constants'; /** Matches `hash_path: [Field; 20]` in `circuits/withdraw/src/main.nr`. */ -export const MERKLE_TREE_DEPTH = 20; -export const MERKLE_MAX_LEAF_INDEX = (1 << MERKLE_TREE_DEPTH) - 1; +export const MERKLE_TREE_DEPTH = ZK_TREE_DEPTH; +export const MERKLE_MAX_LEAF_INDEX = ZK_MAX_LEAF_INDEX; /** * Validate the Merkle proof object before it is encoded for the prover. * Catches truncated / overlong paths and invalid index range early. */ export function validateMerkleProof(merkleProof: MerkleProof, depth: number = MERKLE_TREE_DEPTH): void { - if (merkleProof.root.length !== 32) { + if (merkleProof.root.length !== ZK_FIELD_BYTES) { throw new WitnessValidationError( - `Merkle root must be 32 bytes, got ${merkleProof.root.length}`, + `Merkle root must be ${ZK_FIELD_BYTES} bytes, got ${merkleProof.root.length}`, 'MERKLE_PATH', 'structure' ); @@ -26,9 +27,9 @@ export function validateMerkleProof(merkleProof: MerkleProof, depth: number = ME } for (let i = 0; i < merkleProof.pathElements.length; i++) { const el = merkleProof.pathElements[i]; - if (el.length !== 32) { + if (el.length !== ZK_FIELD_BYTES) { throw new WitnessValidationError( - `Merkle path element at index ${i} must be 32 bytes, got ${el.length}`, + `Merkle path element at index ${i} must be ${ZK_FIELD_BYTES} bytes, got ${el.length}`, 'MERKLE_PATH', 'structure' ); diff --git a/sdk/src/note.ts b/sdk/src/note.ts index 613ccbd..ea2c4ac 100644 --- a/sdk/src/note.ts +++ b/sdk/src/note.ts @@ -1,12 +1,21 @@ import { createHash, randomBytes } from 'crypto'; +import { + LEGACY_NOTE_AMOUNT_SLOT_BYTES, + NOTE_AMOUNT_BYTES, + NOTE_BACKUP_CHECKSUM_OFFSET, + NOTE_BACKUP_PAYLOAD_LENGTH, + NOTE_BACKUP_PREFIX, + NOTE_BACKUP_VERSION, + NOTE_CHECKSUM_BYTES, + ZK_FIELD_BYTES, + ZK_NOTE_SCALAR_BYTES, + ZK_POOL_ID_BYTES, +} from './constants'; // --------------------------------------------------------------------------- // Backup format constants // --------------------------------------------------------------------------- -const BACKUP_VERSION = 0x01; -const BACKUP_PREFIX = 'privacylayer-note:'; - // Payload layout (107 bytes): // version 1 byte // nullifier 31 bytes @@ -14,7 +23,6 @@ const BACKUP_PREFIX = 'privacylayer-note:'; // poolId 32 bytes // amount 8 bytes (BigUInt64BE) // checksum 4 bytes (first 4 bytes of SHA-256 over all preceding bytes) -const BACKUP_PAYLOAD_LENGTH = 107; // --------------------------------------------------------------------------- // Error types @@ -56,8 +64,8 @@ export class Note { public readonly poolId: string, public readonly amount: bigint ) { - if (nullifier.length !== 31 || secret.length !== 31) { - throw new Error('Nullifier and secret must be 31 bytes to fit BN254 field'); + if (nullifier.length !== ZK_NOTE_SCALAR_BYTES || secret.length !== ZK_NOTE_SCALAR_BYTES) { + throw new Error(`Nullifier and secret must be ${ZK_NOTE_SCALAR_BYTES} bytes to fit BN254 field`); } } @@ -65,7 +73,7 @@ export class Note { * Create a new random note for a specific pool. */ static generate(poolId: string, amount: bigint): Note { - return new Note(randomBytes(31), randomBytes(31), poolId, amount); + return new Note(randomBytes(ZK_NOTE_SCALAR_BYTES), randomBytes(ZK_NOTE_SCALAR_BYTES), poolId, amount); } /** @@ -75,7 +83,7 @@ export class Note { getCommitment(): Buffer { // Placeholder: In production, use @noir-lang/barretenberg or similar // for Poseidon(nullifier, secret) - return Buffer.alloc(32); + return Buffer.alloc(ZK_FIELD_BYTES); } // --------------------------------------------------------------------------- @@ -95,29 +103,29 @@ export class Note { * [103..106] SHA-256 checksum over bytes [0..102] (first 4 bytes) */ exportBackup(): string { - const payload = Buffer.alloc(BACKUP_PAYLOAD_LENGTH); + const payload = Buffer.alloc(NOTE_BACKUP_PAYLOAD_LENGTH); let offset = 0; - payload[offset++] = BACKUP_VERSION; + payload[offset++] = NOTE_BACKUP_VERSION; note_nullifier: { this.nullifier.copy(payload, offset); - offset += 31; + offset += ZK_NOTE_SCALAR_BYTES; } note_secret: { this.secret.copy(payload, offset); - offset += 31; + offset += ZK_NOTE_SCALAR_BYTES; } note_poolid: { Buffer.from(this.poolId, 'hex').copy(payload, offset); - offset += 32; + offset += ZK_POOL_ID_BYTES; } payload.writeBigUInt64BE(this.amount, offset); - offset += 8; + offset += NOTE_AMOUNT_BYTES; const checksum = createHash('sha256').update(payload.subarray(0, offset)).digest(); - checksum.copy(payload, offset, 0, 4); + checksum.copy(payload, offset, 0, NOTE_CHECKSUM_BYTES); - return BACKUP_PREFIX + payload.toString('hex'); + return NOTE_BACKUP_PREFIX + payload.toString('hex'); } /** @@ -131,14 +139,14 @@ export class Note { * - `CORRUPT_DATA` — the hex payload could not be parsed */ static importBackup(backup: string): Note { - if (!backup.startsWith(BACKUP_PREFIX)) { + if (!backup.startsWith(NOTE_BACKUP_PREFIX)) { throw new NoteBackupError( - `Note backup must start with "${BACKUP_PREFIX}"`, + `Note backup must start with "${NOTE_BACKUP_PREFIX}"`, 'INVALID_PREFIX' ); } - const hex = backup.slice(BACKUP_PREFIX.length); + const hex = backup.slice(NOTE_BACKUP_PREFIX.length); let payload: Buffer; try { payload = Buffer.from(hex, 'hex'); @@ -146,35 +154,39 @@ export class Note { throw new NoteBackupError('Note backup contains invalid hex data', 'CORRUPT_DATA'); } - if (payload.length !== BACKUP_PAYLOAD_LENGTH) { + if (payload.length !== NOTE_BACKUP_PAYLOAD_LENGTH) { throw new NoteBackupError( - `Note backup payload must be ${BACKUP_PAYLOAD_LENGTH} bytes, got ${payload.length}`, + `Note backup payload must be ${NOTE_BACKUP_PAYLOAD_LENGTH} bytes, got ${payload.length}`, 'INVALID_LENGTH' ); } const version = payload[0]; - if (version !== BACKUP_VERSION) { + if (version !== NOTE_BACKUP_VERSION) { throw new NoteBackupError( - `Unsupported note backup version: ${version} (expected ${BACKUP_VERSION})`, + `Unsupported note backup version: ${version} (expected ${NOTE_BACKUP_VERSION})`, 'INVALID_VERSION' ); } - // Verify checksum over bytes [0..102] - const storedChecksum = payload.subarray(103, 107); - const computed = createHash('sha256').update(payload.subarray(0, 103)).digest(); - if (!computed.subarray(0, 4).equals(storedChecksum)) { + // Verify checksum over all bytes before the checksum suffix. + const storedChecksum = payload.subarray(NOTE_BACKUP_CHECKSUM_OFFSET, NOTE_BACKUP_PAYLOAD_LENGTH); + const computed = createHash('sha256').update(payload.subarray(0, NOTE_BACKUP_CHECKSUM_OFFSET)).digest(); + if (!computed.subarray(0, NOTE_CHECKSUM_BYTES).equals(storedChecksum)) { throw new NoteBackupError( 'Note backup checksum mismatch: data may be corrupt or truncated', 'CHECKSUM_MISMATCH' ); } - const nullifier = Buffer.from(payload.subarray(1, 32)); - const secret = Buffer.from(payload.subarray(32, 63)); - const poolId = payload.subarray(63, 95).toString('hex'); - const amount = payload.readBigUInt64BE(95); + let offset = 1; + const nullifier = Buffer.from(payload.subarray(offset, offset + ZK_NOTE_SCALAR_BYTES)); + offset += ZK_NOTE_SCALAR_BYTES; + const secret = Buffer.from(payload.subarray(offset, offset + ZK_NOTE_SCALAR_BYTES)); + offset += ZK_NOTE_SCALAR_BYTES; + const poolId = payload.subarray(offset, offset + ZK_POOL_ID_BYTES).toString('hex'); + offset += ZK_POOL_ID_BYTES; + const amount = payload.readBigUInt64BE(offset); return new Note(nullifier, secret, poolId, amount); } @@ -191,9 +203,10 @@ export class Note { this.nullifier, this.secret, Buffer.from(this.poolId, 'hex'), - Buffer.alloc(16), // amount padding + Buffer.alloc(LEGACY_NOTE_AMOUNT_SLOT_BYTES), // amount padding ]); - data.writeBigUInt64BE(this.amount, 31 + 31 + 32); + const amountOffset = ZK_NOTE_SCALAR_BYTES + ZK_NOTE_SCALAR_BYTES + ZK_POOL_ID_BYTES; + data.writeBigUInt64BE(this.amount, amountOffset); return `privacylayer-note-${data.toString('hex')}`; } @@ -207,10 +220,14 @@ export class Note { const hex = noteStr.replace('privacylayer-note-', ''); const data = Buffer.from(hex, 'hex'); - const nullifier = data.subarray(0, 31); - const secret = data.subarray(31, 62); - const poolId = data.subarray(62, 94).toString('hex'); - const amount = data.readBigUInt64BE(94); + let offset = 0; + const nullifier = data.subarray(offset, offset + ZK_NOTE_SCALAR_BYTES); + offset += ZK_NOTE_SCALAR_BYTES; + const secret = data.subarray(offset, offset + ZK_NOTE_SCALAR_BYTES); + offset += ZK_NOTE_SCALAR_BYTES; + const poolId = data.subarray(offset, offset + ZK_POOL_ID_BYTES).toString('hex'); + offset += ZK_POOL_ID_BYTES; + const amount = data.readBigUInt64BE(offset); return new Note(nullifier, secret, poolId, amount); } diff --git a/sdk/src/proof.ts b/sdk/src/proof.ts index 557dc46..f314c6d 100644 --- a/sdk/src/proof.ts +++ b/sdk/src/proof.ts @@ -7,6 +7,7 @@ import { stellarAddressToField, } from './encoding'; import { validateMerkleProof } from './merkle'; +import { ZERO_RELAYER_STELLAR_ADDRESS } from './constants'; import { assertValidGroth16ProofBytes, assertValidPreparedWithdrawalWitness, assertValidStellarAccountId } from './witness'; export interface MerkleProof { @@ -124,7 +125,7 @@ export class ProofGenerator { note: Note, merkleProof: MerkleProof, recipient: string, - relayer: string = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + relayer: string = ZERO_RELAYER_STELLAR_ADDRESS, fee: bigint = 0n ): Promise { validateMerkleProof(merkleProof); diff --git a/sdk/test/constants.test.ts b/sdk/test/constants.test.ts new file mode 100644 index 0000000..a4c59df --- /dev/null +++ b/sdk/test/constants.test.ts @@ -0,0 +1,73 @@ +import { randomBytes } from 'crypto'; +import { + LEGACY_NOTE_AMOUNT_SLOT_BYTES, + MERKLE_MAX_LEAF_INDEX, + MERKLE_TREE_DEPTH, + NOTE_AMOUNT_BYTES, + NOTE_BACKUP_CHECKSUM_OFFSET, + NOTE_BACKUP_PAYLOAD_LENGTH, + NOTE_BACKUP_PREFIX, + NOTE_BACKUP_VERSION, + NOTE_CHECKSUM_BYTES, + Note, + ZERO_RELAYER_STELLAR_ADDRESS, + ZK_FIELD_BYTES, + ZK_MAX_LEAF_INDEX, + ZK_NOTE_SCALAR_BYTES, + ZK_POOL_ID_BYTES, + ZK_TREE_DEPTH, +} from '../src'; +import { ProofGenerator } from '../src/proof'; + +const RECIPIENT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + +describe('shared ZK constants', () => { + test('central constants define SDK Merkle aliases', () => { + expect(MERKLE_TREE_DEPTH).toBe(ZK_TREE_DEPTH); + expect(MERKLE_MAX_LEAF_INDEX).toBe(ZK_MAX_LEAF_INDEX); + expect(ZK_TREE_DEPTH).toBe(20); + expect(ZK_MAX_LEAF_INDEX).toBe(2 ** ZK_TREE_DEPTH - 1); + }); + + test('note backup layout is derived from protocol byte widths', () => { + expect(NOTE_BACKUP_VERSION).toBe(1); + expect(NOTE_BACKUP_PREFIX).toBe('privacylayer-note:'); + expect(NOTE_BACKUP_CHECKSUM_OFFSET).toBe( + 1 + ZK_NOTE_SCALAR_BYTES + ZK_NOTE_SCALAR_BYTES + ZK_POOL_ID_BYTES + NOTE_AMOUNT_BYTES + ); + expect(NOTE_BACKUP_PAYLOAD_LENGTH).toBe(NOTE_BACKUP_CHECKSUM_OFFSET + NOTE_CHECKSUM_BYTES); + expect(LEGACY_NOTE_AMOUNT_SLOT_BYTES).toBeGreaterThanOrEqual(NOTE_AMOUNT_BYTES); + }); + + test('note generation and backup import use shared byte widths', () => { + const note = Note.generate('11'.repeat(ZK_POOL_ID_BYTES), 42n); + expect(note.nullifier).toHaveLength(ZK_NOTE_SCALAR_BYTES); + expect(note.secret).toHaveLength(ZK_NOTE_SCALAR_BYTES); + expect(note.getCommitment()).toHaveLength(ZK_FIELD_BYTES); + + const backup = note.exportBackup(); + const encodedPayload = backup.slice(NOTE_BACKUP_PREFIX.length); + expect(Buffer.from(encodedPayload, 'hex')).toHaveLength(NOTE_BACKUP_PAYLOAD_LENGTH); + + const imported = Note.importBackup(backup); + expect(imported.nullifier).toHaveLength(ZK_NOTE_SCALAR_BYTES); + expect(imported.secret).toHaveLength(ZK_NOTE_SCALAR_BYTES); + expect(imported.poolId).toBe(note.poolId); + expect(imported.amount).toBe(note.amount); + }); + + test('default zero relayer constant is used for zero-fee witnesses', async () => { + const note = new Note(randomBytes(ZK_NOTE_SCALAR_BYTES), randomBytes(ZK_NOTE_SCALAR_BYTES), '22'.repeat(ZK_POOL_ID_BYTES), 7n); + const merkleProof = { + root: Buffer.alloc(ZK_FIELD_BYTES), + pathElements: Array.from({ length: ZK_TREE_DEPTH }, () => Buffer.alloc(ZK_FIELD_BYTES)), + leafIndex: 0, + }; + + const explicit = await ProofGenerator.prepareWitness(note, merkleProof, RECIPIENT, ZERO_RELAYER_STELLAR_ADDRESS, 0n); + const implicit = await ProofGenerator.prepareWitness(note, merkleProof, RECIPIENT); + + expect(implicit.relayer).toBe(explicit.relayer); + expect(implicit.fee).toBe('0'); + }); +});