Skip to main content

Unshield Circuit

Unshield spends one private note and pays part or all of it to a public account. The recipient and the amount become public; which note was spent, and its history inside the pool, do not.


What the circuit proves​

Unshield is circuit id 2; version 3 is active. Its proof attests that:

  1. The note sits in a tree of the Merkle forest behind merkle_root.
  2. The prover knows the note's spending key: it derives the owner key inside the circuit and is canonical, exactly as in the transfer circuit (see Canonical spending key).
  3. nullifier is the note's nullifier, Poseidon2(commitment, spending_key).
  4. Value is conserved: note_value = amount + fee + change_value.
  5. note_value, amount, fee and change_value each fit in 128 bits.
  6. The note's asset equals the public asset_id.
  7. change_commitment is the change note's commitment when there is change, and 0 when there is none.
  8. The proof is bound to the exact change memo submitted and to the recipient.

Public inputs​

In circuit order, which is also the order the verifier encodes them in:

#SignalDescription
0merkle_rootRoot the note is proven against
1nullifierNullifier of the spent note
2amountWhat the recipient receives (u128)
3recipientblake2_256(recipient) mod r, see Recipient encoding
4asset_idAsset being withdrawn (u32)
5feeSpend fee deducted from the note (u128)
6change_commitmentCommitment of the change note; 0 for a total unshield
7memo_hashDigest of the change memo, recomputed by the pallet

Every value is a 32-byte little-endian field element. The pallet builds amount, recipient, asset_id, fee and memo_hash from the extrinsic's own arguments.


Recipient encoding​

The recipient is an AccountId32, which is wider than the BN254 scalar field. Taken mod r directly, an account R and the alias R ± r (a different account nobody holds a key for) would be the same public input, and a copy of a pending withdrawal could redirect the funds to that alias. The verifier therefore hashes it first:

recipient_input = blake2_256(recipient_account_bytes) read little-endian, mod r

Producing an alias now needs a blake2 collision. Inside the circuit, recipient carries a square constraint of its own, so it is bound by the proof rather than left as an unused input. The pallet also refuses the zero account and the pool's own account as recipients.


Value conservation and the fee​

note_value = amount + fee + change_value
TermMeaning
amountTokens paid out of the pool to the recipient
feeSpend fee, credited inside the pool to the validator that authored the block; see Spend Fees
change_valueThe remainder, returned to the pool as a new private note; 0 for a total unshield

The four range checks keep every term below 2^128, so the sum cannot wrap around the field whatever the prover passes. The recipient receives exactly amount; the fee comes out of the note on top of it.

An unshield is accepted only unsigned, so no transaction names the withdrawer: it reveals the recipient and amount, not who submitted it. See How it is sent.


Partial and total unshield​

Total unshieldPartial unshield
change_value0> 0
change_commitmentmust be 0must equal Poseidon4(change_value, note_asset_id, change_owner_pubkey, change_blinding)
Change memoemptyexactly 180 bytes
Leaf insertednonethe change note

The circuit computes the change commitment either way, since constraints always run, and binds it only when there is change: (change - change_commitment) · (1 - has_no_change) = 0 and change_commitment · has_no_change = 0. A tampered commitment, wrong blinding, wrong owner or wrong asset fails the proof. The change note takes the spent note's asset.

The change memo is the only on-chain copy of the change note's secrets. It is bound through memo_hash exactly as transfer binds its outputs, over a list holding that one memo (empty for a total unshield); see Memo binding.


Private inputs​

SignalDescription
note_valueValue of the spent note
note_asset_idAsset of the spent note; must equal asset_id
note_blindingBlinding factor of the spent note's commitment
spending_keyKey that derives the owner key and the nullifier
path_elements[20]Sibling hashes of the Merkle path
path_indices[20]Direction at each level (0 left, 1 right), constrained to be binary
change_valueValue of the change note; 0 for a total unshield
change_blindingBlinding factor of the change commitment
change_owner_pubkeyBaby JubJub Ax of the change note's owner

Checks outside the circuit​

The pallet runs its own checks around the proof: the call is unsigned, the circuit version is supported, the fee meets the minimum, the root is known, the nullifier is canonical and unused, the amount is non-zero, the pool holds amount + fee, the memo has the right size, the recipient is allowed, and the change commitment is canonical and new. The full list, in order, with every error and pool-rejection code, is in Spend Validity.


Circuit parameters​

ParameterValue
Circuit id2
Active version3
Constraints17,290
Tree depth20 (1,048,576 notes per tree)
Public inputs8
Private inputs47 field elements (7 scalars, 20 path elements, 20 path indices)
Proving schemeGroth16 over BN254
VerificationA few milliseconds on-chain, independent of the constraint count; see Prepared keys
Single-contributor setup

The unshield keys come from a single-contributor trusted setup; a multi-party ceremony is planned before mainnet. See Trusted Setup.


Known limitations​

  • The amount, recipient, asset and fee of a withdrawal are public.
  • The root used to build the proof must still be known when the transaction executes. A sealed tree's root is kept permanently; a root of the active tree is kept for 300 blocks (about 30 minutes) after a later insert replaces it. Rebuild the proof if submission is delayed past that. See Merkle Forest.
  • One unshield spends one note. Withdrawing more than one note holds takes several unshields, or a private transfer that merges notes first.