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
17 changes: 17 additions & 0 deletions circuits/lib/src/constants.nr
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions circuits/lib/src/lib.nr
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// - validation : Input validation and assertion helpers
// ============================================================

pub mod constants;
pub mod hash;
pub mod merkle;
pub mod validation;
6 changes: 4 additions & 2 deletions circuits/lib/src/merkle/config.nr
Original file line number Diff line number Diff line change
@@ -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;
30 changes: 30 additions & 0 deletions sdk/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './constants';
export * from './backends';
export * from './benchmark';
export * from './encoding';
Expand Down
13 changes: 7 additions & 6 deletions sdk/src/merkle.ts
Original file line number Diff line number Diff line change
@@ -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'
);
Expand All @@ -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'
);
Expand Down
91 changes: 54 additions & 37 deletions sdk/src/note.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
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
// secret 31 bytes
// 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
Expand Down Expand Up @@ -56,16 +64,16 @@ 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`);
}
}

/**
* 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);
}

/**
Expand All @@ -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);
}

// ---------------------------------------------------------------------------
Expand All @@ -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');
}

/**
Expand All @@ -131,50 +139,54 @@ 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');
} catch {
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);
}
Expand All @@ -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')}`;
}

Expand All @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion sdk/src/proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<PreparedWitness> {
validateMerkleProof(merkleProof);
Expand Down
73 changes: 73 additions & 0 deletions sdk/test/constants.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});