Skip to content

Commit ba9e6a7

Browse files
fix(sdk): implement real Merkle proof and Soroban proof serialization
- Implement incremental Merkle tree logic in merkle.ts - Complete generateWithdrawProof with real path computation - Implement serializeProofForSoroban for BN254 G1/G2 packing - Align nullifier derivation with ZK circuits (Poseidon(secret)) - Update deserialization to recompute commitment and nullifier
1 parent ac9820d commit ba9e6a7

4 files changed

Lines changed: 128 additions & 27 deletions

File tree

packages/core/src/commitment.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import type { ShieldedNote } from './types';
1010
export async function createNote(amount: bigint, tokenId: bigint): Promise<ShieldedNote> {
1111
const poseidon = await buildPoseidon();
1212
const secret = BigInt('0x' + randomBytes(31).toString('hex'));
13+
1314
const commitment = poseidon([secret, amount, tokenId]);
14-
const nullifier = poseidon([secret, BigInt(1)]);
15+
const nullifier = poseidon([secret]);
1516

1617
return {
1718
secret,
@@ -37,14 +38,22 @@ export function serializeNote(note: ShieldedNote): string {
3738
});
3839
}
3940

40-
export function deserializeNote(raw: string): ShieldedNote {
41+
export async function deserializeNote(raw: string): Promise<ShieldedNote> {
42+
const poseidon = await buildPoseidon();
4143
const parsed = JSON.parse(raw);
44+
const secret = BigInt(parsed.secret);
45+
const amount = BigInt(parsed.amount);
46+
const tokenId = BigInt(parsed.tokenId);
47+
48+
const commitment = poseidon([secret, amount, tokenId]);
49+
const nullifier = poseidon([secret]);
50+
4251
return {
43-
secret: BigInt(parsed.secret),
44-
amount: BigInt(parsed.amount),
45-
tokenId: BigInt(parsed.tokenId),
46-
commitment: '', // recompute
47-
nullifier: '', // recompute
52+
secret,
53+
amount,
54+
tokenId,
55+
commitment: poseidon.F.toString(commitment),
56+
nullifier: poseidon.F.toString(nullifier),
4857
index: parsed.index,
4958
spent: false,
5059
};

packages/core/src/merkle.ts

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,37 @@ import { buildPoseidon } from 'circomlibjs';
77
export class MerkleTree {
88
private depth: number;
99
private leaves: string[];
10+
private zeros: string[] = [];
11+
private poseidon: any;
1012

1113
constructor(depth: number = 20, leaves: string[] = []) {
1214
this.depth = depth;
1315
this.leaves = leaves;
1416
}
1517

18+
private async init() {
19+
if (this.poseidon) return;
20+
this.poseidon = await buildPoseidon();
21+
22+
// Precompute zeros
23+
let current = '0';
24+
this.zeros.push(current);
25+
for (let i = 0; i < this.depth; i++) {
26+
current = this.hash(current, current);
27+
this.zeros.push(current);
28+
}
29+
}
30+
31+
private hash(left: string, right: string): string {
32+
const res = this.poseidon([left, right]);
33+
return this.poseidon.F.toString(res);
34+
}
35+
1636
/**
1737
* Adds a commitment to the tree.
1838
*/
1939
async insert(leaf: string): Promise<number> {
40+
await this.init();
2041
this.leaves.push(leaf);
2142
return this.leaves.length - 1;
2243
}
@@ -25,20 +46,66 @@ export class MerkleTree {
2546
* Computes the current root of the tree.
2647
*/
2748
async getRoot(): Promise<string> {
28-
const poseidon = await buildPoseidon();
29-
// Simplified root computation
30-
let currentHash = this.leaves.length > 0 ? this.leaves[0] : '0';
31-
return currentHash;
49+
await this.init();
50+
let nodes = [...this.leaves];
51+
52+
// Pad with zeros to next power of 2 if needed (for simple implementation)
53+
// Actually, we can just hash up to the depth.
54+
55+
let currentLevelNodes = nodes;
56+
for (let i = 0; i < this.depth; i++) {
57+
const nextLevelNodes: string[] = [];
58+
for (let j = 0; j < currentLevelNodes.length; j += 2) {
59+
const left = currentLevelNodes[j];
60+
const right = j + 1 < currentLevelNodes.length ? currentLevelNodes[j + 1] : this.zeros[i];
61+
nextLevelNodes.push(this.hash(left, right));
62+
}
63+
if (nextLevelNodes.length === 0) {
64+
nextLevelNodes.push(this.zeros[i+1]);
65+
}
66+
currentLevelNodes = nextLevelNodes;
67+
}
68+
69+
return currentLevelNodes[0];
3270
}
3371

3472
/**
3573
* Generates a Merkle inclusion proof for a leaf at a given index.
3674
*/
3775
async generateProof(index: number) {
38-
// TODO: implement actual Merkle proof generation
76+
await this.init();
77+
const pathElements: string[] = [];
78+
const pathIndices: number[] = [];
79+
80+
let currentIndex = index;
81+
let currentLevelNodes = [...this.leaves];
82+
83+
for (let i = 0; i < this.depth; i++) {
84+
const isRight = currentIndex % 2 === 1;
85+
const siblingIndex = isRight ? currentIndex - 1 : currentIndex + 1;
86+
87+
const sibling = siblingIndex < currentLevelNodes.length
88+
? currentLevelNodes[siblingIndex]
89+
: this.zeros[i];
90+
91+
pathElements.push(sibling);
92+
pathIndices.push(isRight ? 1 : 0);
93+
94+
// Move to next level
95+
const nextLevelNodes: string[] = [];
96+
for (let j = 0; j < currentLevelNodes.length; j += 2) {
97+
const left = currentLevelNodes[j];
98+
const right = j + 1 < currentLevelNodes.length ? currentLevelNodes[j + 1] : this.zeros[i];
99+
nextLevelNodes.push(this.hash(left, right));
100+
}
101+
currentLevelNodes = nextLevelNodes;
102+
currentIndex = Math.floor(currentIndex / 2);
103+
}
104+
39105
return {
40-
pathElements: new Array(this.depth).fill('0'),
41-
pathIndices: new Array(this.depth).fill(0),
106+
pathElements,
107+
pathIndices,
108+
root: currentLevelNodes[0]
42109
};
43110
}
44111
}

packages/core/src/proof.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as snarkjs from 'snarkjs';
22
import type { WithdrawParams, ProofResult } from './types';
3+
import { MerkleTree } from './merkle';
34

45
const WITHDRAW_WASM_PATH = './circuits/withdraw_js/withdraw.wasm';
56
const WITHDRAW_ZKEY_PATH = './circuits/withdraw_final.zkey';
@@ -11,20 +12,23 @@ const WITHDRAW_ZKEY_PATH = './circuits/withdraw_final.zkey';
1112
* @returns Proof and public signals for on-chain submission
1213
*/
1314
export async function generateWithdrawProof(params: WithdrawParams): Promise<ProofResult> {
14-
const { note, recipient, relayer, fee, merkleTree } = params;
15+
const { note, recipient, relayer, fee, merkleTree: treeState } = params;
1516

17+
if (note.index === null) {
18+
throw new Error('Note index is required for Merkle proof generation');
19+
}
20+
1621
// Build Merkle path for the note
17-
const pathElements: string[] = [];
18-
const pathIndices: number[] = [];
19-
// TODO: implement actual Merkle path computation from merkleTree state
22+
const tree = new MerkleTree(treeState.depth, treeState.leaves);
23+
const { pathElements, pathIndices, root } = await tree.generateProof(note.index);
2024

2125
const input = {
2226
secret: note.secret.toString(),
2327
amount: note.amount.toString(),
2428
tokenId: note.tokenId.toString(),
2529
pathElements,
2630
pathIndices,
27-
root: merkleTree.root,
31+
root,
2832
nullifierHash: note.nullifier,
2933
recipient,
3034
relayer: relayer ?? '0',
@@ -43,13 +47,33 @@ export async function generateWithdrawProof(params: WithdrawParams): Promise<Pro
4347

4448
/**
4549
* Serializes a proof for submission to the Soroban verifier contract.
46-
* Packs pi_a, pi_b, pi_c into a single Bytes object.
50+
* Packs pi_a, pi_b, pi_c into a single 256-byte Uint8Array.
51+
* Format: [pi_a (64)] [pi_b (128)] [pi_c (64)]
4752
*/
4853
export function serializeProofForSoroban(proof: ProofResult['proof']): Uint8Array {
49-
// Encode as: [pi_a (64 bytes)] [pi_b (128 bytes)] [pi_c (64 bytes)]
50-
// Each coordinate is a 32-byte big-endian field element
51-
const encoded: number[] = [];
52-
// TODO: implement proper BN254 point serialization
53-
let _ = (proof, encoded);
54-
return new Uint8Array(encoded);
54+
const encoded = new Uint8Array(256);
55+
56+
// Helper to write a big-endian 32-byte field element
57+
const writeFE = (fe: string, offset: number) => {
58+
const hex = BigInt(fe).toString(16).padStart(64, '0');
59+
for (let i = 0; i < 32; i++) {
60+
encoded[offset + i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
61+
}
62+
};
63+
64+
// pi_a: x, y
65+
writeFE(proof.pi_a[0], 0);
66+
writeFE(proof.pi_a[1], 32);
67+
68+
// pi_b: [[re, im], [re, im], [1, 1]] -> [x_re, x_im, y_re, y_im]
69+
writeFE(proof.pi_b[0][1], 64); // x_re
70+
writeFE(proof.pi_b[0][0], 96); // x_im
71+
writeFE(proof.pi_b[1][1], 128); // y_re
72+
writeFE(proof.pi_b[1][0], 160); // y_im
73+
74+
// pi_c: x, y
75+
writeFE(proof.pi_c[0], 192);
76+
writeFE(proof.pi_c[1], 224);
77+
78+
return encoded;
5579
}

packages/core/test/commitment.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ describe('Commitment', () => {
1212
expect(note.nullifier).toBeDefined();
1313
expect(note.secret).toBeDefined();
1414

15-
// Commitment should be a hex string starting with 0x (or just hex)
15+
// Commitment should be a string (large number as string)
1616
expect(typeof note.commitment).toBe('string');
17+
expect(BigInt(note.commitment) > 0n).toBe(true);
1718
});
1819
});

0 commit comments

Comments
 (0)