zkSVM

App

Litepaper

Documentation

Links

DocsBuild

Protocol reference

The wire format and the state machine.

13 min read

The wire format and the state machine. Enough to reimplement a wallet, or to audit one.

Every construction below is mirrored in three places — the program, the circom circuits, and the TypeScript SDK — and pinned by tests that assert the same constants from both ends. Change one, change all three.

Field and encoding

BN254's scalar field. Wire encoding is 32-byte little-endian, everywhere: instructions, account state, SDK. Values must be canonical — at or above the modulus is rejected rather than reduced, so there is exactly one encoding per field element. For nullifiers that is a soundness requirement, not tidiness: the encoding seeds the record that marks a note spent (01-overview.md).

The one exception is the unshield recipient, a 32-byte pubkey that may exceed the modulus: it is read little-endian and reduced mod p, on both sides.

Proofs and verifying keys are big-endian, in the layout snarkjs and the EVM precompiles use — which is also what the alt_bn128 syscalls take.

FIELD_MODULUS = 21888242871839275222246405745257275088548364400416034343698204186575808495617

Notes

note       = (asset_id, amount, owner_pk, blinding)

pk         = Poseidon1(s)                          s = spending key
secret     = Poseidon2(pk, blinding)
commitment = Poseidon3(asset_id, amount, secret)
nullifier  = Poseidon3(commitment, leaf_index, s)
tree_node  = Poseidon2(left, right)

A note owned by a Solana address replaces the first and fourth lines:

pk         = Poseidon2(hi, lo)       hi, lo = address bytes [0..16], [16..32],
                                     each read big-endian
nullifier  = Poseidon3(commitment, leaf_index, blinding)

The halves are exact — no reduction — so distinct addresses never share a pk. The arity differs from Poseidon1(s), so no spending key owns an address's notes or the reverse. secret and commitment are unchanged, which is why shield and transfer create such notes without knowing they have.

A returnable note is owned by a condition over both kinds of owner:

pk         = Poseidon4(hi, lo, refund_pk, not_before)
                                     hi, lo    = the claimant's address, as above
                                     refund_pk = Poseidon1(refund_s)
                                     not_before = unix seconds
nullifier  = Poseidon3(commitment, leaf_index, blinding)

The claimant spends it whenever it likes (redeem); whoever knows refund_s spends it once the clock has passed not_before (reclaim). Arity 4 appears nowhere else. The nullifier is keyed by the blinding on both paths, and that is load-bearing: two paths to two nullifiers would be one note spent twice.

asset_id is 0 for native SOL, the only asset the program moves today. The encoding reserved for an SPL asset is the mint pubkey read little-endian and reduced mod p.

Two structural choices carry the weight:

Two-level commitment. secret hides owner and blinding, so a depositor can hand it over and let the program compute the commitment from the public amount. That is what makes shield proofless and sound — see 01-overview.md.

Nullifier includes leaf_index. Two notes with identical (asset, amount, pk, blinding) land at different indices and therefore have different nullifiers. Without the index, an accidental duplicate note would be unspendable after the first spend.

Tree

Incremental, depth 26 (~67M notes), Poseidon2 nodes. Only filled left siblings are stored, so an insert is O(depth) and the fixed-size pool account never grows.

The empty leaf is a fixed non-zero tag:

Rust
pub const EMPTY_LEAF_TAG: u64 = u64::from_be_bytes(*b"svml2:mt");   // 8319957673080155508

Any fixed non-zero element works, but it must be identical in the program and in every wallet — it determines every root the pool will ever have. Treat it as part of the pool's identity, not a tunable. (The tag's spelling is inherited from the engine this pool was ported from; changing it would change nothing but every root.)

The pool account holds zeros[26] (the empty-subtree root per level), filled_subtrees[26], a 64-entry ring buffer of recent roots, current_root_index, and next_leaf_index. Fixed size, zero-copy.

Root history. A proof may target any of the last 64 roots. Without that window every wallet that built a proof one block before submitting would fail whenever anyone else shielded in between — a race every user hits under load. Every operation advances the window by the leaves it appends, so 64 roots is between 32 and 64 operations of slack — while still refusing arbitrarily old state.

Instructions

Anchor wire format: an 8-byte discriminator, sha256("global:<name>")[..8], then the arguments borsh-encoded in the order below. Accounts are load-bearing and ordered; the SDK's instruction.ts is the reference, and the program's sdk_compat test holds the two together.

Every operation opens with the same three accounts: payer (signer, writable), the pool PDA, the vault PDA. The system program comes last.

initialize

No arguments. Creates the pool account with the empty tree, and funds the vault with its own rent-exempt minimum so that every lamport above it belongs to a note. Permissionless; succeeds once.

shield

Rust
shield(amount: u64, secret: FieldBytes, encrypted_note: Vec<u8>)

Moves amount lamports from payer to the vault and appends Poseidon3(0, amount, secret). No proof. Rejected for a zero amount.

transfer

Rust
transfer(
    nullifiers: [FieldBytes; 2],
    root: FieldBytes,
    commitments: [FieldBytes; 2],
    fee: u64,
    proof: [u8; 256],
    encrypted_notes: [Vec<u8>; 2],
)

Accounts, after the common three: the two nullifier records, in order.

Public signals, in this exact order:

[root, nullifier0, nullifier1, commitment0, commitment1, fee]

Checks: both nullifier records are created (so neither existed), root in history, every field element canonical, proof verifies. Then both commitments are appended and fee, if non-zero, goes from the vault to payer. No other public value moves.

Nullifiers come first in the argument list because the account constraints need them to derive the record addresses, and Anchor decodes arguments in order up to the last one a constraint names.

unshield

Rust
unshield(
    nullifier: FieldBytes,
    root: FieldBytes,
    change_commitment: FieldBytes,
    amount: u64,
    proof: [u8; 256],
    encrypted_change_note: Vec<u8>,
)

Accounts, after the common three: the nullifier record, then recipient (writable).

Public signals:

[root, nullifier, change_commitment, asset_id, amount, recipient]

recipient is not an argument. The program takes the address of the account it is about to pay, reduces it into the field, and verifies the proof against that — so there is no way to prove one recipient and pay another. asset_id is the constant 0.

Checks as above, then the change commitment is appended and the vault pays amount to recipient.

claim

Rust
claim(
    nullifier: FieldBytes,
    root: FieldBytes,
    commitments: [FieldBytes; 2],
    fee: u64,
    proof: [u8; 256],
    encrypted_notes: [Vec<u8>; 2],
)

Accounts, after the common three: the nullifier record, then owner (signer, read-only). owner may be the same account as payer, or a PDA signing through a CPI from the program that derives it.

Public signals:

[root, nullifier, commitment0, commitment1, fee, owner_hi, owner_lo]

Like the unshield recipient, owner is not an argument: the program splits the key of the account that signed and verifies the proof against that. The proof ties the note to an address; the signature is the address consenting. Neither is worth anything alone.

Checks as for transfer, then both commitments are appended and fee, if non-zero, goes from the vault to payer.

redeem

Arguments, accounts and public signals are those of claim, exactly; only the instruction name and the circuit differ. The circuit builds the note's pk as Poseidon4(owner_hi, owner_lo, refund_pk, not_before) with the last two private, so a redeem shows that an address claimed something and nothing about who would have got it back, or when.

reclaim

Rust
reclaim(
    nullifier: FieldBytes,
    root: FieldBytes,
    commitments: [FieldBytes; 2],
    fee: u64,
    not_before: u64,
    proof: [u8; 256],
    encrypted_notes: [Vec<u8>; 2],
)

Accounts: the common three, then the nullifier record. Nobody signs but the payer.

Public signals:

[root, nullifier, commitment0, commitment1, fee, not_before]

The program first requires Clock.unix_timestamp >= not_before, then verifies. The proof shows knowledge of refund_s and ties not_before to the note; the claimant stays private. not_before is public here and nowhere else, which is why wallets round it to a UTC midnight: it should name a day's worth of notes, not one.

A refund key is per note — the reference wallet derives refund_s = Poseidon3(s, blinding, "refund") — so two claimants comparing claim codes cannot tell they were paid by the same sender, and the sender needs nothing but the note to reclaim it.

attest

Rust
attest(
    nullifier: FieldBytes,
    root: FieldBytes,
    commitment: FieldBytes,
    threshold: u64,
    scope: FieldBytes,
    tag: FieldBytes,
    proof: [u8; 256],
    encrypted_note: Vec<u8>,
)

Accounts: payer, the pool, the nullifier record, the system program. No vault: no lamports move.

Public signals:

[root, nullifier, commitment, asset_id, threshold, scope, tag]

The circuit shows that the spent note holds at least threshold, that commitment is the same asset and amount under the same key with a fresh blinding, and that tag = Poseidon2(s, scope). The program appends the commitment and emits Attested { asset, scope, tag, threshold }.

Why it spends: whether a note is unspent cannot be read from inside a circuit, since nullifier records are accounts, not a tree. A proof of membership alone would be satisfied by a note that left the pool long ago. Spending the note and recreating it puts its nullifier on record, which is what turns "held once" into "holds now".

What it is not: a way to count funds. The tag is a pseudonym of the key under one scope; value moved to another key attests again under another tag. And the fee payer is public, so without a relayer an attestation ties "holds at least this much" to the address that paid for it.

Receipts (off-chain)

The fourth new circuit, receipt, never reaches the program. Public signals:

[commitment, asset_id, pk, min_amount, context]

It shows that commitment opens to a note of asset_id owned by pk holding at least min_amount. context = sha256(text) mod p names what the receipt is for and is bound into the proof. A verifier checks the proof with snarkjs, finds commitment among the pool's leaves, and derives pk for the recipient it expects. The prover is whoever knows the opening — payer or payee — and a receipt for a returnable note does not show that the note was ever redeemed.

shield_token, unshield_token, attest_token

The token forms of the edges. Arguments are those of shield, unshield and attest.

InstructionAccounts
shield_tokenpayer, pool, mint, source token account (the payer's), token vault, token program, system program
unshield_tokenpayer, pool, mint, token vault, nullifier record, recipient token account, token program, system program
attest_tokenpayer, pool, nullifier record, mint, system program

The token vault is the PDA ["token_vault", mint], a token account that is its own authority, created by the first shield of that mint. Only the classic token program is accepted: it cannot deliver less than it was asked to, so the amount transferred is the amount committed.

unshield_token reuses the unshield circuit. Its public asset_id is the mint's — the mint bytes little-endian, reduced mod r — and the recipient it binds is the token account being paid. attest_token likewise passes the mint's asset id; the Attested event carries it.

shield_stake

Rust
shield_stake(lamports: u64, tokens: u64, secret: FieldBytes, encrypted_note: Vec<u8>)

Accounts: payer, pool, the stake pool's mint, the token vault for that mint, then the stake pool, its withdraw authority, its reserve stake account, its manager fee account, the stake pool program, the token program, the system program.

The program CPIs DepositSol(lamports) into the SPL stake pool program (SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy), with the vault as both the destination and the referrer, reads the vault's balance before and after, and requires minted >= tokens. The commitment is Poseidon3(asset_id(mint), tokens, secret).

tokens comes from the caller because the wallet must encrypt the note before it knows what the stake pool will mint. Bounding it by what was minted keeps the vault solvent: a note can be worth less than its deposit by a rounding unit, never more. The stake pool is any account of that program; the note's asset is that pool's mint, so assets stay apart.

unshield_stake

Rust
unshield_stake(nullifier, root, change_commitment, tokens: u64, proof, encrypted_change_note)

Accounts: payer, pool, mint, token vault, nullifier record, recipient, then the stake pool, its withdraw authority, reserve stake and manager fee accounts, the clock and stake-history sysvars, the stake program, the stake pool program, the token program, the system program.

The unshield circuit again: public asset_id is the mint's, amount is tokens, and recipient is the account the lamports land in. After verification the program CPIs WithdrawSol(tokens) with the vault as both the token source and — being its own authority — the signer. The stake pool keeps its withdrawal fee in tokens and pays the rest from its reserve at its rate.

Sixteen accounts leave no room for a ciphertext in a legacy transaction, so the reference wallet exits whole notes — a change note worth nothing needs no ciphertext — and splits a note privately first when part of it is leaving. About 183,000 compute units.

Fees and rent

The program takes no fee of its own. A pool transaction costs what any Solana transaction costs, plus:

  • rent on each nullifier record — an 8-byte account, about 0.00095 SOL, paid by payer and never reclaimed. Closing a record would make its note spendable again, so there is deliberately no instruction that does it.
  • compute: about 172k units for transfer, 167k for claim and redeem, 162k for reclaim, 143k for attest and 150k for unshield, give or take a few thousand for PDA bump searches. The default limit is 200k; ask for 250k.

The in-circuit fee of transfer and claim is a different thing: paid from inside the pool, out of the note value, to whoever submits the transaction. It exists so a relayer can carry a transfer for a user who holds no public balance, and be made whole for the rent and network fee. It is not bound to a particular payer — anyone may submit the instruction and collect — which costs the user nothing: the transfer is identical either way.

Transaction size

Solana caps a transaction at 1232 bytes, and transfer is close to it: 256 bytes of proof, five field elements, and two 176-byte note ciphertexts. With the compute-budget instruction it serializes to roughly 1170 bytes (sdk/test/instruction.test.ts asserts the bound). There is no room for a larger ciphertext, and little for a second signer.

A returnable output is the exception that fits: its ciphertext is a memo the sender encrypts to itself — asset id, amount, claimant address, blinding, not_before; 184 bytes — so that the right to reclaim can be rebuilt from the chain alone. The claimant learns the note from a claim code instead.

Note encryption

Recipients need the note's contents, and they only ever see the commitment. Each operation carries an encrypted payload for its recipient:

enc_sk     = sha256("svm-l2:enc" || s)      // X25519 secret from the spending key
enc_pk     = X25519(enc_sk)
shared     = X25519(ephemeral_sk, recipient_enc_pk)
ciphertext = XChaCha20-Poly1305(shared, note)

One key pair, two uses: the same spending key s gives both the field-element identity and the X25519 encryption key, so a wallet is one secret.

Scanning is trial decryption: for each new note event, attempt decryption with your key; success means the note is yours. Linear in pool activity, which is the standard cost of this design.

The program does not interpret encrypted_note at all — it re-emits the bytes in the NoteAdded event and stores nothing. A wallet could use a different scheme entirely, and only that wallet's users would be affected.

Events

Every appended commitment is announced, in order:

Rust
#[event]
pub struct NoteAdded {
    pub leaf_index: u64,
    pub commitment: FieldBytes,
    pub encrypted_note: Vec<u8>,
}

This is the pool's only output channel for wallets: the log is how a client mirrors the tree and finds its notes. parseNoteEvents in the SDK decodes it.

Rejections

A rejected operation is a failed Solana transaction: it lands, it costs the network fee, and it changes nothing.

ErrorMeaning
BadFielda field element at or above the modulus
UnknownRootroot outside the 64-entry window
system program: account already in usethe nullifier record exists — double spend
InvalidProofverification failed; on unshield, also a swapped recipient; on claim, also a signer who is not the owner
NotYetreclaim before the note's not_before
RateMovedshield_stake whose note claims more tokens than the stake pool minted
ZeroAmountshield or unshield of 0
TreeFull2^26 notes
system program: insufficient lamportsshield beyond the payer's balance

Cross-language pins

PinnedProgram sideTypeScript side
empty tree root, three-leaf rootsrc/tree.rs testssdk/test/merkle.test.ts
empty leaf tagtree.rs::EMPTY_LEAF_TAGmerkle.ts::EMPTY_LEAF
instruction bytes + account ordertests/sdk_compat.rssdk/test/emit-fixture.mjs
NoteAdded encodingtests/sdk_compat.rssdk/test/instruction.test.ts
proof + vk encoding, input ordertests/snarkjs_crosstest.rssnarkjs output
all of it at once, on the compiled programprograms/tests/onchainsdk/scripts/gen-scenario.mjs

These exist because the SDK builds the witnesses the program verifies. A divergence in any of them means every browser-built proof fails — a total outage of the private path, discovered by users rather than by CI. Run both test suites when you touch either side.