Building a client against @zksvm/sdk. Everything below runs in the browser:
the wallet holds the keys, mirrors the tree, and generates the proofs. No
server sees a note.
Install
pnpm add @zksvm/sdk @solana/web3.js
You also need the proving artifacts from the ceremony — the .zkey and
.wasm per circuit. Serve them from a CDN with published hashes and check
what you downloaded. They are multi-megabyte, so fetch once and cache.
Every builder and address helper takes the program id as an optional last
argument. The default is the placeholder from Anchor.toml; pass your
deployment's id.
Keys
One secret. The spending key is a field element; the X25519 encryption key is derived from it, so backup is a single value.
import { randomFr, ownerPk, encryptionKeypair } from "@zksvm/sdk";
const spendingKey = randomFr();
const pk = await ownerPk(spendingKey); // field-element identity
const enc = encryptionKeypair(spendingKey); // { publicKey, secret }
(pk, enc.publicKey) is the shielded address you hand out. spendingKey never
leaves the device.
Back it up at creation, not after the first deposit. Notes are bearer instruments — losing the key loses the funds, permanently and with no recourse.
Shield
import { buildShield } from "@zksvm/sdk";
import { Transaction } from "@solana/web3.js";
const { instruction, note, commitment } = await buildShield(
{
payer: payer.publicKey,
amount: 10_000_000_000n,
recipientPk: pk,
recipientEncPk: enc.publicKey,
},
PROGRAM_ID,
);
await conn.sendTransaction(new Transaction().add(instruction), [payer]);
The instruction arrives complete — program id, accounts, data. It is an ordinary Solana instruction, so it composes with others in one transaction.
Shield and earn
To shield SOL as a stake pool's token — jitoSOL — in one instruction, read the pool's account, work out what the deposit will mint, and say so:
import { JITO_STAKE_POOL, buildShieldStake, parseStakePool, tokensForDeposit } from "@zksvm/sdk";
const stakePool = parseStakePool((await conn.getAccountInfo(JITO_STAKE_POOL))!.data);
const { instruction, note } = await buildShieldStake(
{
payer: payer.publicKey,
lamports: 2_000_000_000n,
tokens: tokensForDeposit(stakePool, 2_000_000_000n),
stakePool: {
stakePool: JITO_STAKE_POOL,
reserveStake: stakePool.reserveStake,
poolMint: stakePool.poolMint,
managerFeeAccount: stakePool.managerFeeAccount,
},
recipientPk: pk,
recipientEncPk: enc.publicKey,
},
PROGRAM_ID,
);
Read the account just before building: if the rate moves first, the program
refuses the shield with RateMoved and nothing is spent. note.assetId is
assetIdForMint(poolMint); from here it is spent like any note. The builders
take the asset from their inputs, buildUnshield and buildAttest take a
mint, and an unshield's recipient is then a token account.
lamportsForTokens and stakePoolApy give a wallet what it needs to show
value and yield.
To leave as SOL, give buildUnshield the stake pool as well as the mint:
{ …, mint, stakePool, recipient }, where recipient is now the address the
lamports land in. Exit whole notes — the instruction has no room for a change
ciphertext — and use lamportsForWithdrawal to show what will arrive.
An existing SPL balance is shielded with buildShield({ …, token: { mint, source } }).
Reading the pool
Every commitment the program appends is announced in a NoteAdded event:
leaf index, commitment, and the ciphertext. parseNoteEvents pulls them out
of a transaction's logs:
import { parseNoteEvents, poolAddress } from "@zksvm/sdk";
const signatures = await conn.getSignaturesForAddress(poolAddress(PROGRAM_ID));
for (const { signature, err } of signatures.reverse()) { // oldest first
if (err) continue;
const tx = await conn.getTransaction(signature, { maxSupportedTransactionVersion: 0 });
for (const event of parseNoteEvents(tx.meta.logMessages)) {
// feed the tree and the scanner, below
}
}
Every pool transaction touches the pool account, so its signature history is
the pool's history. Page through it with before / until, and remember that
public RPC nodes prune old transactions — a production wallet wants an RPC
provider with full history, or its own archive of the events.
The tree
Proofs need Merkle paths, so the wallet mirrors the tree locally:
import { MerkleTree, frFromBytes } from "@zksvm/sdk";
const tree = new MerkleTree();
await tree.append(frFromBytes(event.commitment)); // for every event, in order
Order matters absolutely — leaf index is part of every nullifier. Append
every commitment, not only your own, in the order the program appended them,
without gaps. event.leafIndex tells you if you skipped one.
The SDK's tree keeps full layers, while the program's keeps only filled left
siblings. Same roots, different memory profile; tree.rs and
sdk/test/merkle.test.ts assert the same constants from both sides, and the
program's onchain test proves it the hard way — an SDK-built proof verifying
against the program-built root.
Scanning
Find your notes by trial decryption:
import { NoteStore, scanNoteEvent, NATIVE_ASSET_ID } from "@zksvm/sdk";
const store = new NoteStore();
await scanNoteEvent(store, spendingKey, event.leafIndex, frFromBytes(event.commitment), event.encryptedNote);
store.balance(NATIVE_ASSET_ID);
store.unspent(NATIVE_ASSET_ID);
scanNoteEvent verifies more than decryption succeeding: the recovered
ownerPk must match yours and the recommitment must reproduce the on-chain
commitment. Ciphertexts that happen to decrypt to garbage are rejected rather
than stored as phantom balance.
Cost is linear in total pool activity, not in yours. On a busy pool, first
sync on a new device is slow — and no server-side index can fix it without
undoing the privacy the design exists for. Persist with
store.toJSON() / NoteStore.fromJSON() and scan incrementally.
Transfer
import { buildTransfer, computeBudgetInstruction } from "@zksvm/sdk";
const { instruction, outputNotes, nullifiers } = await buildTransfer(
{
payer: payer.publicKey,
spendingKey,
inputs: [noteA, noteB], // exactly two
outputs: [
{ amount: 3_000_000_000n, recipientPk: theirPk, recipientEncPk: theirEncPk },
{ amount: 6_999_995_000n, recipientPk: pk, recipientEncPk: enc.publicKey },
],
fee: 5_000n,
tree,
artifacts: { wasm: transferWasm, zkey: transferZkey },
},
PROGRAM_ID,
);
const tx = new Transaction().add(computeBudgetInstruction(), instruction);
The compute budget instruction is not optional in spirit. A transfer costs
about 172k compute units and an unshield about 143k, against a default limit
of 200k. computeBudgetInstruction() asks for 250k.
Always two in and two out. Pad with a zero-amount dummy note when you have only one real input — the circuit switches off the Merkle check for zero-amount inputs, so the dummy needs no valid path. Give each dummy a fresh random blinding: its nullifier gets recorded like any other, and a repeated one is a double spend.
Value must balance: in₀ + in₁ = out₀ + out₁ + fee. The second output is
normally your change; sending everything and taking none back means the
recipient can tell exactly what you had.
Who pays, and who is seen paying
payer signs the transaction, pays the network fee, pays rent on the two
nullifier records (~0.0009 SOL each, never reclaimed), and receives fee out
of the pool.
If payer is the user's own wallet, that wallet is publicly attached to the
transfer — which undoes most of what the transfer hides
(05-limitations.md). The design answer is a relayer: the
user builds the instruction with the relayer's key as payer and a fee
that covers the relayer's costs, and hands it over to be signed and sent. The
user needs no public balance and leaves no public trace. fee is not bound to
a particular payer, so relayers can race each other for it; the user's
transfer goes through identically whoever wins.
Unshield
import { buildUnshield } from "@zksvm/sdk";
const { instruction, changeNote } = await buildUnshield(
{
payer: payer.publicKey,
spendingKey,
input: noteToSpend,
amount: 3_000_000_000n,
recipient: recipientPubkey,
changePk: pk,
changeEncPk: enc.publicKey,
tree,
artifacts: { wasm: unshieldWasm, zkey: unshieldZkey },
},
PROGRAM_ID,
);
recipient is bound into the proof: the program checks the proof against the
address it is about to pay, so a copied proof cannot be pointed elsewhere.
A recipient address that does not exist yet must receive at least the rent-exempt minimum (~0.00089 SOL), or the transaction fails. Check before proving; proofs are expensive to throw away.
Paying a Solana address
Any output — of a shield, a transfer or a claim — can go to a plain address instead of a shielded one:
import { addressRecipient } from "@zksvm/sdk";
outputs: [
{ amount: 2_000_000_000n, ...(await addressRecipient(theirPubkey)) },
{ amount: change, recipientPk: pk, recipientEncPk: enc.publicKey },
],
The address needs no setup and no lamports, and need not exist. Nothing about
the transaction shows that an output went to an address, let alone which. The
note is encrypted to the address's own key; if the owner is a PDA, or a key
held in a wallet that cannot do X25519, pass the owner's published encryption
key as the second argument, or deliver the note's (amount, blinding)
yourself (05-limitations.md).
Claim
The owning address finds its notes with its Solana secret key and spends them by signing:
import { buildClaim, scanAddressNoteEvent } from "@zksvm/sdk";
await scanAddressNoteEvent(store, keypair.secretKey, event.leafIndex, frFromBytes(event.commitment), event.encryptedNote);
const { instruction } = await buildClaim(
{
payer: keypair.publicKey,
owner: keypair.publicKey,
input: noteToClaim,
outputs: [
{ amount: noteToClaim.amount, recipientPk: pk, recipientEncPk: enc.publicKey },
{ amount: 0n, recipientPk: pk, recipientEncPk: enc.publicKey },
],
fee: 0n,
tree,
artifacts: { wasm: claimWasm, zkey: claimZkey },
},
PROGRAM_ID,
);
There is no spending key in a claim. The outputs are ordinary: move the value
to your own shielded address, as here, or pay someone directly out of the
claimed note — including another address. owner must sign the transaction;
it may also be payer, which costs no privacy since a claim shows its owner
either way. A claim is one note at a time; merge afterwards with a transfer.
A program claims by CPI, signing for its PDA with invoke_signed. The proof
is built off-chain by whoever operates the program and passed through.
Returnable payments
To pay an address so that the value comes back if nobody claims it, make the
output with returnableRecipient instead of addressRecipient:
import { returnableRecipient, buildTransfer } from "@zksvm/sdk";
const DAY = 86_400n;
const notBefore = ((now + 7n * DAY) / DAY + 1n) * DAY; // a UTC midnight: see 05-limitations
const toAddress = await returnableRecipient(address, notBefore, { spendingKey: s, encPk: enc.publicKey });
await buildTransfer({ /* … */ outputs: [{ amount, ...toAddress }, change] }, PROGRAM_ID);
Hand the claimant amount and toAddress.returnable — blinding, refund pk,
notBefore. There is no other way for them to learn the note: its ciphertext
is a memo to you. They rebuild it with returnableNote(address, opening),
find its commitment among the leaves, and spend it with buildRedeem, which
takes what buildClaim takes.
You find what you sent by scanning, like any other note:
const sent = await scanReturnableEvent(s, event.leafIndex, frFromBytes(event.commitment), event.encryptedNote);
and once sent.notBefore has passed, buildReclaim({ payer, spendingKey: s, input: sent, outputs, fee, tree, artifacts }) takes it back. Both exits use
the nullifier Poseidon3(commitment, leaf_index, blinding); check its record
to learn whether the note is gone, whichever way it went.
Proving without opening
import { buildAttest, parseAttestedEvents, textToFr } from "@zksvm/sdk";
const { instruction, note, tag } = await buildAttest(
{ payer, spendingKey: s, input, threshold, scope: textToFr("example.org login 8F3K"), encPk: enc.publicKey, tree, artifacts },
PROGRAM_ID,
);
input is spent and note — same amount, same owner — takes its place; it
arrives through NoteAdded like any other. A verifier fetches the transaction,
checks it succeeded, and reads parseAttestedEvents(logs, PROGRAM_ID), which
ignores lines the pool program did not write itself. It should choose the
scope, and put a nonce in it.
A receipt is built and checked without the chain:
import { buildReceipt, verifyReceipt } from "@zksvm/sdk";
const receipt = await buildReceipt(sentNote, minAmount, "invoice 1042", artifacts);
const sound = await verifyReceipt(receipt, verificationKeyJson);
verifyReceipt checks the proof only. The verifier still has to find
receipt.commitment among the pool's leaves and confirm that receipt.pk is
the recipient it expects. Keep the notes you send if you mean to write
receipts for them: the chain does not.
Tracking spends
Mark inputs spent as soon as you submit, not when the transaction lands:
store.markSpent(noteA.commitment);
store.markSpent(noteB.commitment);
Otherwise a user clicking twice builds a second proof against a nullifier already in flight, and the second transaction is rejected as a double spend — correct behaviour, confusing failure. If a transaction ultimately fails, unmark them.
To ask the chain directly whether a note is spent:
import { nullifierAddress, frToBytes } from "@zksvm/sdk";
const spent = (await conn.getAccountInfo(nullifierAddress(frToBytes(nf), PROGRAM_ID))) !== null;
Handling rejections
A failed transaction still lands, with the reason in its logs. Simulate first
(simulateTransaction) — a failed transaction costs the network fee.
| Error | What to tell the user |
|---|---|
UnknownRoot | The tree moved on. Re-sync and rebuild the proof. |
system program already in use (custom error 0x0) on a nullifier record | Already spent, possibly by your own retry. |
InvalidProof | Artifact mismatch with the deployed keys — or, on unshield, a recipient other than the one proven. |
BadField | A malformed field element. A bug in the client, not user error. |
system program insufficient lamports | Not enough public balance to shield, or to pay rent. |
UnknownRoot is the one users hit legitimately: the root window is 64 deep,
and every shield, transfer and unshield by anyone advances it. On a busy pool
a proof left sitting falls out of the window in seconds. Sync, prove, send —
in that order, without pausing.
Proving cost
Seconds, and a few hundred megabytes of memory. Run it in a Web Worker or the UI freezes. Low-end mobile devices struggle — measure on real hardware before promising a mobile experience.
What to tell users
Privacy comes from other people using the pool, not from the proofs alone. If your interface lets someone shield and immediately unshield the same amount to the same address, it has handed them a false sense of privacy — which is worse than none, because they will act on it.
Say plainly in the interface: shield and unshield amounts are public, only transfers are private, and waiting helps. Read 05-limitations.md and put the parts that affect users where they will see them.