zkSVM

App

Litepaper

Documentation

Links

DocsBuild

Wallet integration

Everything a wallet needs, operation by operation.

10 min read

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

Shell
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.

TypeScript
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

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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

TypeScript
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

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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.

ErrorWhat to tell the user
UnknownRootThe tree moved on. Re-sync and rebuild the proof.
system program already in use (custom error 0x0) on a nullifier recordAlready spent, possibly by your own retry.
InvalidProofArtifact mismatch with the deployed keys — or, on unshield, a recipient other than the one proven.
BadFieldA malformed field element. A bug in the client, not user error.
system program insufficient lamportsNot 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.