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
Empty file removed .codex
Empty file.
51 changes: 32 additions & 19 deletions sdk/src/deposit.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,45 @@
import { Note } from './note';
import { fieldToHex, bufferToField } from './encoding';

export interface DepositRequest {
poolId: string;
amount: bigint;
note?: Note;
}

/**
* DepositPayload
*
* Contains the note material (to be saved by the user) and the commitment
* (to be submitted to the Soroban contract).
*/
export interface DepositPayload {
/** The private note material. Must be backed up by the user. */
note: Note;
poolId: string;
amount: bigint;
commitment: Buffer;
/** The note commitment (Hash(nullifier, secret, poolId)) as a hex field element. */
commitment: string;
}

/**
* Creates deposit payload data from either a supplied note or a new note.
* generateDepositPayload
*
* Orchestrates the creation of a new shielded note for a deposit.
* It generates the random secrets, computes the on-chain commitment,
* and packages them for the caller.
*
* @param poolId The 32-byte hex identifier for the target shielded pool.
* @param amount The bigint amount (in stroops/base units) being deposited.
* @returns A promise resolving to the deposit payload.
*/
export function createDeposit(request: DepositRequest): DepositPayload {
const note = request.note ?? Note.generate(request.poolId, request.amount);
export async function generateDepositPayload(
poolId: string,
amount: bigint
): Promise<DepositPayload> {
// 1. Generate the note material (random nullifier and secret)
const note = Note.generate(poolId, amount);

// 2. Compute the commitment
// Note.getCommitment() returns a 32-byte Buffer.
// We convert it to a canonical field hex string for the ZK circuit/contract.
const commitmentBuffer = note.getCommitment();
const commitment = fieldToHex(bufferToField(commitmentBuffer));

return {
note,
poolId: note.poolId,
amount: note.amount,
commitment: note.getCommitment()
commitment,
};
}

export function createBatchCommitments(notes: Note[]): Buffer[] {
return notes.map((note) => note.getCommitment());
}
8 changes: 6 additions & 2 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@ export * from './proof';
export * from './gas';
export * from './stealth';
export * from './withdraw';
export * from './deposit';
export * from './merkle';
export {
assertValidGroth16ProofBytes,
assertValidPreparedWithdrawalWitness,
assertValidStellarAccountId,
GROTH16_PROOF_BYTE_LENGTH,
} from './witness';
258 changes: 48 additions & 210 deletions sdk/src/merkle.ts
Original file line number Diff line number Diff line change
@@ -1,219 +1,57 @@
import { MerkleProof } from './proof';
import { normalizeHex, stableHash32 } from './stable';

export type CommitmentLike = Buffer | Uint8Array | string;

export interface MerkleCheckpoint {
version: 1;
depth: number;
nextIndex: number;
root: string;
frontier: Array<string | null>;
leaves?: string[];
}

export interface BatchSyncResult {
insertedLeafIndices: number[];
checkpoint: MerkleCheckpoint;
root: Buffer;
}

function toLeaf(commitment: CommitmentLike): Buffer {
if (Buffer.isBuffer(commitment) || commitment instanceof Uint8Array) {
const bytes = Buffer.from(commitment);
return bytes.length === 32 ? bytes : stableHash32('leaf-bytes', bytes);
}

const normalized = normalizeHex(commitment);
if (/^[0-9a-f]+$/i.test(normalized) && normalized.length % 2 === 0) {
const bytes = Buffer.from(normalized, 'hex');
return bytes.length === 32 ? bytes : stableHash32('leaf-hex', bytes);
}

return stableHash32('leaf-text', commitment);
}

export class LocalMerkleTree {
readonly depth: number;
private readonly zeroes: Buffer[];
private readonly frontier: Array<Buffer | null>;
private trackedLeaves: Buffer[];
private nextIndex: number;
private root: Buffer;

constructor(depth: number = 20) {
if (!Number.isInteger(depth) || depth <= 0 || depth > 31) {
throw new Error(`Merkle depth must be an integer in [1, 31], received ${depth}`);
}

this.depth = depth;
this.zeroes = this.buildZeroes(depth);
this.frontier = new Array<Buffer | null>(depth).fill(null);
this.trackedLeaves = [];
this.nextIndex = 0;
this.root = Buffer.from(this.zeroes[depth]);
}

static fromCheckpoint(checkpoint: MerkleCheckpoint): LocalMerkleTree {
if (checkpoint.frontier.length !== checkpoint.depth) {
throw new Error(
`Invalid checkpoint: frontier length ${checkpoint.frontier.length} does not match depth ${checkpoint.depth}`
import type { MerkleProof } from './proof';
import { WitnessValidationError } from './errors';

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

/**
* 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) {
throw new WitnessValidationError(
`Merkle root must be 32 bytes, got ${merkleProof.root.length}`,
'MERKLE_PATH',
'structure'
);
}
if (merkleProof.pathElements.length !== depth) {
throw new WitnessValidationError(
`Merkle path must have ${depth} elements, got ${merkleProof.pathElements.length}`,
'MERKLE_PATH',
'structure'
);
}
for (let i = 0; i < merkleProof.pathElements.length; i++) {
const el = merkleProof.pathElements[i];
if (el.length !== 32) {
throw new WitnessValidationError(
`Merkle path element at index ${i} must be 32 bytes, got ${el.length}`,
'MERKLE_PATH',
'structure'
);
}

const tree = new LocalMerkleTree(checkpoint.depth);
tree.nextIndex = checkpoint.nextIndex;
tree.root = Buffer.from(normalizeHex(checkpoint.root), 'hex');

for (let i = 0; i < checkpoint.frontier.length; i += 1) {
const entry = checkpoint.frontier[i];
tree.frontier[i] = entry ? Buffer.from(normalizeHex(entry), 'hex') : null;
}

if (checkpoint.leaves) {
tree.trackedLeaves = checkpoint.leaves.map((leaf) => Buffer.from(normalizeHex(leaf), 'hex'));
}

return tree;
}

get leafCount(): number {
return this.nextIndex;
}

getRoot(): Buffer {
return Buffer.from(this.root);
}

insert(leaf: CommitmentLike): number {
const capacity = 2 ** this.depth;
if (this.nextIndex >= capacity) {
throw new Error(`Merkle tree is full at depth ${this.depth}`);
}

const normalizedLeaf = toLeaf(leaf);
this.trackedLeaves.push(normalizedLeaf);

let index = this.nextIndex;
let current = normalizedLeaf;

for (let level = 0; level < this.depth; level += 1) {
if ((index & 1) === 0) {
this.frontier[level] = current;
current = this.hashPair(current, this.zeroes[level]);
} else {
const left = this.frontier[level] ?? this.zeroes[level];
current = this.hashPair(left, current);
}
index >>= 1;
}

const insertedAt = this.nextIndex;
this.nextIndex += 1;
this.root = current;
return insertedAt;
if (!Number.isInteger(merkleProof.leafIndex) || merkleProof.leafIndex < 0) {
throw new WitnessValidationError('leafIndex must be a non-negative integer', 'LEAF_INDEX', 'structure');
}

insertBatch(leaves: CommitmentLike[]): number[] {
const indices: number[] = [];
for (const leaf of leaves) {
indices.push(this.insert(leaf));
}
return indices;
if (merkleProof.leafIndex > MERKLE_MAX_LEAF_INDEX) {
throw new WitnessValidationError(
`leafIndex out of range for tree depth (max ${MERKLE_MAX_LEAF_INDEX})`,
'LEAF_INDEX',
'structure'
);
}

/**
* Generates a Merkle proof for a tracked leaf index.
* This requires that leaves are available in memory.
*/
generateProof(leafIndex: number): MerkleProof {
if (!Number.isInteger(leafIndex) || leafIndex < 0 || leafIndex >= this.nextIndex) {
throw new Error(`Leaf index ${leafIndex} is out of range for tree size ${this.nextIndex}`);
}
if (this.trackedLeaves.length < this.nextIndex) {
throw new Error(
'Cannot generate Merkle proof from checkpoint-only tree state; tracked leaves are unavailable.'
const pidx = merkleProof.pathIndices;
if (pidx !== undefined && pidx.length > 0) {
if (pidx.length !== merkleProof.pathElements.length) {
throw new WitnessValidationError(
'pathIndices length does not match path length',
'MERKLE_PATH',
'structure'
);
}

const pathElements: Buffer[] = [];
const pathIndices: number[] = [];
const memo = new Map<string, Buffer>();

let index = leafIndex;
for (let level = 0; level < this.depth; level += 1) {
const siblingIndex = index ^ 1;
pathElements.push(this.nodeAt(level, siblingIndex, memo));
pathIndices.push(index & 1);
index >>= 1;
}

return {
root: this.getRoot(),
pathElements,
pathIndices,
leafIndex
};
}

createCheckpoint(options: { includeLeaves?: boolean } = {}): MerkleCheckpoint {
return {
version: 1,
depth: this.depth,
nextIndex: this.nextIndex,
root: this.root.toString('hex'),
frontier: this.frontier.map((entry) => (entry ? entry.toString('hex') : null)),
leaves: options.includeLeaves ? this.trackedLeaves.map((leaf) => leaf.toString('hex')) : undefined
};
}

private hashPair(left: Buffer, right: Buffer): Buffer {
return stableHash32('merkle-node', left, right);
}

private buildZeroes(depth: number): Buffer[] {
const zeroes: Buffer[] = [Buffer.alloc(32, 0)];
for (let i = 0; i < depth; i += 1) {
zeroes.push(this.hashPair(zeroes[i], zeroes[i]));
}
return zeroes;
}

private nodeAt(level: number, index: number, memo: Map<string, Buffer>): Buffer {
const key = `${level}:${index}`;
const existing = memo.get(key);
if (existing) {
return existing;
}

const span = 2 ** level;
const startLeaf = index * span;

if (startLeaf >= this.trackedLeaves.length) {
return this.zeroes[level];
}

if (level === 0) {
return this.trackedLeaves[index] ?? this.zeroes[0];
}

const left = this.nodeAt(level - 1, index * 2, memo);
const right = this.nodeAt(level - 1, index * 2 + 1, memo);
const node = this.hashPair(left, right);
memo.set(key, node);
return node;
}
}

export function syncCommitmentBatch(
tree: LocalMerkleTree,
commitments: CommitmentLike[],
checkpointOptions: { includeLeaves?: boolean } = {}
): BatchSyncResult {
const insertedLeafIndices = tree.insertBatch(commitments);
return {
insertedLeafIndices,
checkpoint: tree.createCheckpoint(checkpointOptions),
root: tree.getRoot()
};
}
Loading
Loading