Spend Validity
A spend (private_transfer or unshield) is an unsigned transaction that carries a zero-knowledge
proof. This page lists every check it must pass, in the order the runtime runs them, and how the
transaction pool admits it before it reaches a block.
Where the checks run
| Stage | When | What runs |
|---|---|---|
Pool admission (validate_unsigned) | A node receives the transaction | Circuit version, every dispatch check, then the proof |
Block inclusion (pre_dispatch) | An author puts it in a block | Circuit version and every dispatch check, without the proof |
| Dispatch | The extrinsic executes | Every dispatch check, the proof, then settlement |
Admission runs the same checks as the extrinsic, so nothing admitted can fail after its proof is verified. A spend that failed inside a block would fill it for free without spending its nullifier. Every admission check is repeated at dispatch, because a block author can skip admission: admission may reject more than execution, never less.
Origin
Both calls accept only the unsigned origin (ensure_none). A signed or Root origin fails with
BadOrigin. No account signs a spend and no transaction fee is charged; the spend fee inside the
proof pays for it. Any other call that reaches the pool's unsigned validation is rejected with
InvalidTransaction::Call.
Circuit version
The circuit_version must have a registered verification key that is neither retired nor
denied. On the live chain
that is transfer version 3 and unshield version 3. Admission and block inclusion check it first,
because it is the cheapest gate (rejection code 10). At dispatch the verifier checks it when it loads
the key, before any pairing, and fails with its own UnsupportedCircuitVersion error. See
On-Chain Verification.
private_transfer checks
In execution order:
| # | Check | Dispatch error | Pool rejection |
|---|---|---|---|
| 1 | Exactly two nullifiers and two commitments | TooManyInputsOrOutputs | Custom(13) |
| 2 | fee is at least MinSpendFee (0.001 ORB) | FeeTooLow | Payment |
| 3 | If an input is a dummy, both roots are equal | InvalidPublicSignals | Custom(13) |
| 4 | Every root is known (current, retained or sealed) | UnknownMerkleRoot | Custom(1) |
| 5 | Each real nullifier is a canonical field element | InvalidPublicSignals | Custom(13) |
| 6 | Each real nullifier is not in NullifierSet | NullifierAlreadyUsed | Stale |
| 7 | Two real nullifiers differ | NullifierAlreadyUsed | Stale |
| 8 | At least one input is real | InvalidAmount | Custom(2) |
| 9 | One memo per commitment | MemoCommitmentMismatch | Custom(11) |
| 10 | Each memo is exactly 180 bytes | InvalidMemoSize | Custom(11) |
| 11 | The asset exists, is verified and is native | InvalidAssetId, AssetNotVerified, AssetNotSupported | Custom(13) |
| 12 | Each commitment is canonical and non-zero | InvalidPublicSignals | Custom(13) |
| 13 | Each commitment is not already in the forest | CommitmentAlreadyExists | Custom(13) |
| 14 | The two commitments differ | CommitmentAlreadyExists | Custom(13) |
| 15 | The proof verifies | ProofVerificationFailed | Custom(12) |
| 16 | Real nullifiers are spent, commitments inserted | MerkleTreeFull | — |
| 17 | The fee is credited to the block author | FeeRecipientUnavailable | — |
A zero nullifier marks a dummy input; it is skipped by checks 5–7 and never written to
NullifierSet. The dummy root rule exists because the circuit leaves a dummy's root free. See
Transfer Circuit.
unshield checks
In execution order:
| # | Check | Dispatch error | Pool rejection |
|---|---|---|---|
| 1 | fee is at least MinSpendFee | FeeTooLow | Payment |
| 2 | The root is known (current, retained or sealed) | UnknownMerkleRoot | Custom(1) |
| 3 | The nullifier is a canonical field element | InvalidPublicSignals | Custom(13) |
| 4 | The nullifier is not in NullifierSet | NullifierAlreadyUsed | Stale |
| 5 | amount is non-zero | InvalidAmount | Custom(13) |
| 6 | amount + fee does not overflow | InvalidAmount | Custom(4) |
| 7 | The pool holds at least amount + fee of the asset | InsufficientPoolBalance | Custom(3) |
| 8 | Memo is empty for a total unshield, exactly 180 bytes for a partial one | InvalidMemoSize | Custom(11) |
| 9 | The asset exists, is verified and is native | InvalidAssetId, AssetNotVerified, AssetNotSupported | Custom(13) |
| 10 | The recipient is a 32-byte account, not the zero account, not the pool account | InvalidRecipient | Custom(13) |
| 11 | A non-zero change commitment is canonical | InvalidPublicSignals | Custom(13) |
| 12 | A non-zero change commitment is not already in the forest | CommitmentAlreadyExists | Custom(13) |
| 13 | The proof verifies | ProofVerificationFailed | Custom(12) |
| 14 | amount moves to the recipient, change inserted, nullifier spent | a balances error if the payout fails; MerkleTreeFull | — |
| 15 | The fee is credited to the block author | FeeRecipientUnavailable | — |
A zero change_commitment means a total unshield. The pool-balance check at admission is advisory:
the balance can move before execution, which checks it again.
The proof statement
The pallet never takes public inputs from the caller as field elements. It builds the statement from the extrinsic's own arguments:
memo_hashisblake2_256of the SCALE-encoded memo list (the two output memos of a transfer; the single change memo of an unshield, empty for a total one), read little-endian modr. See Memo binding.- An unshield's
recipientisblake2_256(account bytes)modr. See Recipient encoding. amount,feeandasset_idare encoded from the call's integers.
The verifier encodes them in circuit order and checks the proof against the version's prepared key. A proof built for other memos, another recipient or another fee fails.
Fee recipient
The fee is credited to PendingFees of the validator that authored the block. A block without a known
author fails a spend with a non-zero fee with FeeRecipientUnavailable, rather than stranding the
tokens. See Spend Fees.
Transaction-pool admission
Order
- Circuit version supported: an in-memory lookup.
- Dispatch checks: the extrinsic's own validation, cheapest first, as listed above.
- Proof: one pairing, run at admission only, not in
pre_dispatch, since the extrinsic verifies it and its weight covers one verification. - Tags: built from the nullifiers and the notes the spend creates.
Rejection codes
InvalidTransaction::Custom carries a bare number; these are its meanings. A number is never reused
for a different meaning.
| Code | Name | Meaning |
|---|---|---|
| 1 | UNKNOWN_ROOT | A root is neither retained nor a sealed tree's root |
| 2 | ALL_INPUTS_DUMMY | Every transfer input is a dummy; the transfer spends nothing |
| 3 | INSUFFICIENT_POOL_BALANCE | The pool cannot cover the unshield's amount + fee |
| 4 | AMOUNT_OVERFLOW | The unshield's amount + fee overflows the balance type |
| 10 | UNSUPPORTED_CIRCUIT_VERSION | The version has no key, is retired, or holds a denied key |
| 11 | INVALID_MEMO | The memos have the wrong count or size for this spend |
| 12 | INVALID_PROOF | The proof does not verify against the statement |
| 13 | INVALID_SPEND | Any other dispatch check failed |
Two standard variants complete the mapping: FeeTooLow becomes InvalidTransaction::Payment, and
NullifierAlreadyUsed (an input already spent, or the same note twice) becomes
InvalidTransaction::Stale.
Tags, priority and longevity
| Property | Value |
|---|---|
| Provides | One tag per real nullifier and one per created note (each transfer output, an unshield's change note), in the namespace ShieldedPoolSpend |
| Requires | Nothing |
| Priority | The spend fee, in planck |
| Longevity | 64 blocks |
| Propagate | Yes |
A nullifier identifies a note, not an operation, so transfers and unshields share one namespace, and
a dummy's zero nullifier gets no tag. Two transactions that spend the same note, in either input
order, paired with any other note, or one as a transfer and one as an unshield, provide the same tag
and cannot both be in the pool. The pool keeps the one with the higher priority. Because the proof is
verified at admission, a copy of a pending spend with swapped memos or a higher fee cannot displace
the original: it fails with Custom(12) before it is compared.
A created note is tagged as ("output", commitment), which never encodes like a nullifier. A
commitment is inserted once, so two spends that create the same note are exclusive in the pool too:
only one of them could execute.
Why root retention exceeds longevity
A replaced root of the active tree stays known for 300 blocks
(retention); an admitted spend stays valid in
the pool for 64.
The runtime's integrity test requires retention to exceed longevity, so every root a pooled spend
names outlives the spend. Otherwise a spend could pass admission, propagate, and only then revert
with UnknownMerkleRoot once included. See Merkle Forest.
Dispatch errors
Every error of the shielded pool, and which calls raise it:
| Error | Meaning | Raised by |
|---|---|---|
CommitmentAlreadyExists | A commitment is already in the forest, or a transfer's two outputs are equal | shield, transfer, unshield |
NullifierAlreadyUsed | A nullifier is already spent, or a transfer names the same note twice | transfer, unshield |
UnknownMerkleRoot | A root is not current, retained or sealed | transfer, unshield |
MerkleTreeFull | The forest's leaf-index space is exhausted | shield, transfer, unshield |
InsufficientPoolBalance | The pool does not hold enough of the asset | unshield, claim_fees |
InvalidAmount | Zero or overflowing amount; a transfer with only dummy inputs | shield, transfer, unshield, claim_fees |
TooManyInputsOrOutputs | A transfer without exactly two nullifiers and two commitments | transfer |
ProofVerificationFailed | The proof does not verify | shield, transfer, unshield |
InvalidMemoSize | A memo has the wrong size | shield, transfer, unshield |
MemoCommitmentMismatch | Memo count differs from commitment count | transfer |
InvalidAssetId | The asset is not registered | every pool operation |
AssetNotVerified | The asset is registered but not verified | every pool operation |
AssetNotSupported | The asset is verified but is not the native asset | every pool operation |
InvalidRecipient | The recipient is the zero account, the pool account, or not 32 bytes | unshield |
FeeTooLow | The fee is below MinSpendFee | transfer, unshield |
InvalidPublicSignals | A value is not a canonical field element, a commitment is zero, or a dummy root differs | shield, transfer, unshield |
InsufficientPendingFees | A claim exceeds the caller's pending fees | claim_fees |
FeeRecipientUnavailable | A non-zero fee and no known block author | transfer, unshield |
EmptyBatch | shield_batch with no entries | shield_batch |
AssetIdAlreadyExists | The asset id counter collided with an existing asset | register_asset |
Related
- Transfer Circuit and Unshield Circuit
- Transaction Lifecycle
- Merkle Forest: roots, retention and sealing
- Spend Fees
- ZK Verifier Reference
- Private Transfer: how it is sent