Note Management
Source: ~/workspace/source/src/lib/privacy/note.ts
generateNote
Generate a shielded note for private deposits.
async function generateNote(
denomination: string,
vaultId?: string
): Promise<ShieldedNote>
Parameters:
denomination: Pool denomination key (e.g., "sentinel_1x", "dn_2x", "stable_50")
vaultId: Vault identifier (defaults to denomination)
Returns:
interface ShieldedNote {
nullifier: string; // Random 31-byte felt252
secret: string; // Random 31-byte felt252
commitment: string; // Poseidon(nullifier, secret)
nullifierHash: string; // Poseidon(nullifier)
denomination: string; // Pool denomination key
vaultId: string; // Vault identifier
leafIndex: number; // Position in Merkle tree (-1 until deposited)
batchId: number; // Batch this deposit belongs to (-1 until deployed)
batchStart: number; // First leafIndex in batch (-1 until deployed)
batchSize: number; // Deposits in batch (-1 until deployed)
timestamp: number; // Note creation time (unix ms)
spent: boolean; // True if withdrawn
}
Example:
import { generateNote, addNote } from '@/lib/privacy/note';
// Generate note for 0.0002 BTC deposit
const note = await generateNote('sentinel_1x', 'sentinel');
console.log('Commitment (public):', note.commitment);
console.log('Nullifier (KEEP SECRET):', note.nullifier);
console.log('Secret (KEEP SECRET):', note.secret);
// Save to localStorage
addNote(note);
Security Critical: Never share the nullifier or secret. Anyone with access to these values can withdraw your funds. The commitment is public and safe to share.
poseidonHash2 / poseidonHash1
Compute Poseidon hashes (matches Circom circuit implementation).
async function poseidonHash2(a: string, b: string): Promise<string>
async function poseidonHash1(a: string): Promise<string>
Example:
import { poseidonHash2, poseidonHash1 } from '@/lib/privacy/note';
const nullifier = '0x123...';
const secret = '0x456...';
// Commitment = Poseidon(nullifier, secret)
const commitment = await poseidonHash2(nullifier, secret);
// Nullifier hash = Poseidon(nullifier)
const nullifierHash = await poseidonHash1(nullifier);
Note Storage Functions
Manage notes in localStorage.
// Load all notes
function loadNotes(): ShieldedNote[]
// Save notes
function saveNotes(notes: ShieldedNote[]): void
// Add new note
function addNote(note: ShieldedNote): void
// Update leaf index after deposit
function updateNoteLeafIndex(commitment: string, leafIndex: number): void
// Update batch info after deployment
function updateNoteBatchInfo(
leafIndex: number,
batchId: number,
batchStart: number,
batchSize: number
): void
// Mark note as spent after withdrawal
function markNoteSpent(nullifierHash: string): void
// Get unspent notes
function getUnspentNotes(denomination?: string): ShieldedNote[]
function getUnspentNotesByVaultId(vaultId: string): ShieldedNote[]
Example:
import { loadNotes, getUnspentNotes, markNoteSpent } from '@/lib/privacy/note';
// Get all unspent notes
const unspent = getUnspentNotes();
console.log(`You have ${unspent.length} unspent notes`);
// Filter by denomination
const sentinel1x = getUnspentNotes('sentinel_1x');
// Mark as spent after withdrawal
markNoteSpent(note.nullifierHash);
Export / Import Notes
Backup and restore notes.
function exportNotes(): string
function importNotes(json: string): void
Example:
import { exportNotes, importNotes } from '@/lib/privacy/note';
// Backup notes
const backup = exportNotes();
localStorage.setItem('note_backup', backup);
// Restore notes
const backup = localStorage.getItem('note_backup');
if (backup) importNotes(backup);
Proof Generation
Source: ~/workspace/source/src/lib/privacy/prover.ts
generateWithdrawalProof
Generate a Groth16 ZK proof for private withdrawal.
async function generateWithdrawalProof(
note: ShieldedNote,
merkle: MerkleProof,
recipient: string,
relayer: string,
fee: string,
batchStart: number,
batchSize: number
): Promise<ProofResult>
Parameters:
note: Shielded note to withdraw (must have batchStart/batchSize set)
merkle: Merkle proof for the note’s commitment
recipient: Starknet address receiving funds (felt252)
relayer: Relayer address (felt252)
fee: Relayer fee in WBTC sats (string)
batchStart: First leafIndex in batch
batchSize: Number of deposits in batch (always 3 for V4)
Returns:
interface ProofResult {
proof: {
pi_a: string[];
pi_b: string[][];
pi_c: string[];
protocol: string;
curve: string;
};
publicSignals: string[]; // [root, nullifierHash, recipient, relayer, fee, batchStart, batchSize]
}
Example:
import { generateWithdrawalProof } from '@/lib/privacy/prover';
import { buildTreeFromEvents, getMerkleProof } from '@/lib/privacy/merkle';
// Build Merkle tree from deposit events
const tree = await buildTreeFromEvents(depositEvents);
const merkleProof = getMerkleProof(tree, note.leafIndex);
// Generate proof (takes 10-30 seconds)
const { proof, publicSignals } = await generateWithdrawalProof(
note,
merkleProof,
'0x0123...', // recipient
'0x0007...', // relayer
'450', // 450 sats fee
note.batchStart,
note.batchSize
);
console.log('Public signals:', publicSignals);
Circuit Details:
- Circuit:
withdraw_v4.circom
- WASM:
/circuits-groth16-v4/withdraw_v4.wasm
- Proving key:
/circuits-groth16-v4/circuit_final.zkey
- Verification key:
/circuits-groth16-v4/verification_key.json
- Public inputs: 7 (root, nullifierHash, recipient, relayer, fee, batchStart, batchSize)
- Curve: BN254
Proof generation runs entirely in the browser using snarkjs. No sensitive data (nullifier, secret) leaves the client.
fetchRelayerFee
Get current relayer fee estimate.
async function fetchRelayerFee(poolAddress?: string): Promise<{
feeSats: number;
feeBreakdown: {
depositGasStrk: number;
deployBatchGasStrk: number;
withdrawGasStrk: number;
totalGasStrk: number;
totalGasUsd: number;
};
}>
Example:
import { fetchRelayerFee } from '@/lib/privacy/prover';
const feeInfo = await fetchRelayerFee(poolAddress);
console.log(`Relayer fee: ${feeInfo.feeSats} sats (~$${feeInfo.feeBreakdown.totalGasUsd.toFixed(2)})`);
Merkle Tree Operations
Source: ~/workspace/source/src/lib/privacy/merkle.ts
buildTreeFromEvents
Reconstruct Merkle tree from deposit events.
async function buildTreeFromEvents(
events: Array<{ commitment: string; leafIndex: number }>
): Promise<MerkleTree>
Example:
import { buildTreeFromEvents, getRoot } from '@/lib/privacy/merkle';
import { useDepositEvents } from '@/hooks/use-shielded-vault';
const { events } = useDepositEvents(poolAddress);
const tree = await buildTreeFromEvents(events);
const root = getRoot(tree);
console.log('Merkle root:', root);
getMerkleProof
Generate Merkle proof for a leaf.
function getMerkleProof(tree: MerkleTree, leafIndex: number): MerkleProof
Returns:
interface MerkleProof {
pathElements: string[]; // Sibling hashes (hex strings)
pathIndices: number[]; // 0 = left, 1 = right at each level
root: string; // Merkle root (hex string)
}
Example:
import { getMerkleProof } from '@/lib/privacy/merkle';
const proof = getMerkleProof(tree, note.leafIndex);
console.log('Proof has', proof.pathElements.length, 'siblings');
console.log('Root:', proof.root);
Tree Structure
interface MerkleTree {
depth: number; // Always 10 for V4
leaves: bigint[]; // 2^10 = 1024 leaves
layers: bigint[][]; // layers[0] = leaves, layers[10] = [root]
zeros: bigint[]; // Pre-computed zero hashes
}
Tree Details:
- Depth: 10
- Max leaves: 1,024
- Hash function: Poseidon (circomlib implementation)
- Empty leaf:
0
Calldata Serialization
Source: ~/workspace/source/src/lib/privacy/calldata.ts
generateVerifyCalldata
Convert Groth16 proof to Starknet calldata.
async function generateVerifyCalldata(
proof: Record<string, unknown>,
publicSignals: string[]
): Promise<string[]>
Example:
import { generateVerifyCalldata } from '@/lib/privacy/calldata';
const { proof, publicSignals } = await generateWithdrawalProof(...);
const calldata = await generateVerifyCalldata(proof, publicSignals);
// Submit to relayer
await fetch('/api/relayer/withdraw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ poolAddress, calldata })
});
Process:
- Load verification key from
/circuits-groth16-v4/verification_key.json
- Convert snarkjs proof to Garaga format (G1/G2 points on BN254)
- Pack proof + VK + hints into felt252 array
- Return as hex strings for Starknet calldata
Garaga Integration:
Calldata is generated using the Garaga library which provides efficient BN254 operations and Groth16 verification on Starknet.
Security Considerations
Private Data
Never expose or transmit:
nullifier - Anyone with this can compute nullifierHash and track withdrawals
secret - Combined with nullifier, allows full note control
Public Data
Safe to share:
commitment - Public identifier for deposits
nullifierHash - Only revealed during withdrawal to prevent double-spend
leafIndex, batchStart, batchSize - Public tree metadata
Note Storage
Notes are stored in localStorage:
- Only accessible by the same origin (domain)
- Not synced across devices
- Cleared if browser data is wiped
Backup Recommendation: Use exportNotes() regularly and store backup in a secure location (encrypted file, password manager, hardware wallet).
Proof Generation
- Runs entirely in browser (no server-side proving)
- Uses official snarkjs library
- Circuit WASM and zkey are publicly auditable
- Takes 10-30 seconds depending on device
Relayer Trust Model
Relayer can:
- See withdrawal recipient address
- Censor transactions (refuse to submit)
- Charge dynamic fees
Relayer cannot:
- Steal funds (proof validates on-chain)
- Link deposits to withdrawals (zero-knowledge proof)
- Spend notes without nullifier+secret
For maximum privacy, wait before withdrawing and withdraw to a fresh address. This breaks timing analysis and on-chain graph clustering.