The SDK
Install, init, methods, in-browser proof generation, and swapping the data source between the node and the indexer.
@null/sdk is the TypeScript SDK a browser app imports to use the shielded pool:
generate and store notes, mirror the pool's Merkle tree, generate Groth16 proofs,
and build the exact shield / transfer / unshield transactions the chain
expects. It runs in the browser and in Node.
Install, build, test
npm install
npm run build # tsc -> dist/ (ESM + .d.ts)
npm test # vitest: parity + lifecycleModules
| File | What |
|---|---|
note.ts | keys, notes, commitment/nullifier Poseidon, (de)serialize, encrypt-at-rest |
tree.ts | client mirror of the incremental depth-20 Poseidon tree; membership paths |
prover.ts | snarkjs proof gen, chain byte encoding/decoding, zkey caching, Web-Worker prover |
tx.ts | PoolClient — note selection, change/dummies, proof, and the ready-to-sign tx |
rpc.ts | PoolDataSource interface + StubDataSource + IndexerDataSource |
High-level usage
const client = new PoolClient({ dataSource, prover, wasm, zkey, keypair });
await client.sync(); // rebuild tree, index own notes
const shield = await client.shield(1_000_000n, payer); // deposit
const transfer = await client.transfer(bobPubKey, 400_000n, payer); // private send
const unshield = await client.unshield(300_000n, recipientPk, payer); // withdraw (self-pay)
// gasless / unlinkable withdraw via a relayer:
const relayed = await client.unshield(300_000n, recipientPk, relayerPk, {
relayer: relayerPk,
fee: 2_000_000n,
});
// each returns { tx, proof, data, nullifiers, outCommitments, changeNotes, ... }PoolClient selects up to two input notes covering the amount, creates the
recipient + change outputs (padding with dummies), enforces value conservation
client-side before proving (never generate an invalid proof), reads the
canonical nullifiers/commitments from the proof's public signals, and assembles
the 505-byte instruction targeting POOL_PROGRAM_ID.
In-browser proof generation
Run proving off the UI thread in a Web Worker, and load the ~10.9 MB proving key once through a pluggable cache:
const worker = new Worker(new URL('@null/sdk/worker', import.meta.url), { type: 'module' });
const prover = createWorkerProver(worker);
// fetch + cache the zkey/wasm bytes once (back the cache with IndexedDB in-app)
const zkey = await loadCached('/circuits/joinsplit_final.zkey', cache);
const wasm = await loadCached('/circuits/joinsplit.wasm', cache);Observed proof-gen time in-browser is ~340–850 ms (warm worker ~340–470 ms; cold first proof ~720–860 ms). The proving key is the bottleneck to fetch, not to prove — cache it aggressively.
Injectable data source: node vs. indexer
The PoolDataSource interface abstracts where pool state comes from. The SDK
ships two implementations; the app picks one by config, with node-direct as the
fallback.
// node-direct: reads the whole tree from the chain node on each sync
const ds = new ChainDataSource(); // app-side implementation over the node RPC
// indexer-backed (Phase 5): reads commitments / nullifiers / roots from the
// indexer's API; the blockhash still comes from the node
const ds = new IndexerDataSource('http://127.0.0.1:8921', 'http://127.0.0.1:8899');Both produce identical ordered leaves, Merkle roots, inclusion paths, and spent-state — this equivalence is cross-checked in the repo. Prefer the indexer path at scale so clients don't each re-pull the full tree.
Poseidon parity (SDK === chain)
Notes use circomlibjs Poseidon, which is bit-identical to the on-chain
solana-poseidon Bn254X5. The SDK's parity tests assert Poseidon(1,2) and the
empty-tree root equal the chain-validated fixture, that the real fixture proofs
verify against the committed vkey, and that an SDK-generated proof verifies — so
the SDK's public-signal order and byte encoding match the chain end-to-end.