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:
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
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
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
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
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
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
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.
| Instruction | Accounts |
|---|---|
shield_token | payer, pool, mint, source token account (the payer's), token vault, token program, system program |
unshield_token | payer, pool, mint, token vault, nullifier record, recipient token account, token program, system program |
attest_token | payer, 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
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
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
payerand 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:
#[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.
| Error | Meaning |
|---|---|
BadField | a field element at or above the modulus |
UnknownRoot | root outside the 64-entry window |
system program: account already in use | the nullifier record exists — double spend |
InvalidProof | verification failed; on unshield, also a swapped recipient; on claim, also a signer who is not the owner |
NotYet | reclaim before the note's not_before |
RateMoved | shield_stake whose note claims more tokens than the stake pool minted |
ZeroAmount | shield or unshield of 0 |
TreeFull | 2^26 notes |
system program: insufficient lamports | shield beyond the payer's balance |
Cross-language pins
| Pinned | Program side | TypeScript side |
|---|---|---|
| empty tree root, three-leaf root | src/tree.rs tests | sdk/test/merkle.test.ts |
| empty leaf tag | tree.rs::EMPTY_LEAF_TAG | merkle.ts::EMPTY_LEAF |
| instruction bytes + account order | tests/sdk_compat.rs | sdk/test/emit-fixture.mjs |
NoteAdded encoding | tests/sdk_compat.rs | sdk/test/instruction.test.ts |
| proof + vk encoding, input order | tests/snarkjs_crosstest.rs | snarkjs output |
| all of it at once, on the compiled program | programs/tests/onchain | sdk/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.