Skip to main content

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​

StageWhenWhat runs
Pool admission (validate_unsigned)A node receives the transactionCircuit version, every dispatch check, then the proof
Block inclusion (pre_dispatch)An author puts it in a blockCircuit version and every dispatch check, without the proof
DispatchThe extrinsic executesEvery 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:

#CheckDispatch errorPool rejection
1Exactly two nullifiers and two commitmentsTooManyInputsOrOutputsCustom(13)
2fee is at least MinSpendFee (0.001 ORB)FeeTooLowPayment
3If an input is a dummy, both roots are equalInvalidPublicSignalsCustom(13)
4Every root is known (current, retained or sealed)UnknownMerkleRootCustom(1)
5Each real nullifier is a canonical field elementInvalidPublicSignalsCustom(13)
6Each real nullifier is not in NullifierSetNullifierAlreadyUsedStale
7Two real nullifiers differNullifierAlreadyUsedStale
8At least one input is realInvalidAmountCustom(2)
9One memo per commitmentMemoCommitmentMismatchCustom(11)
10Each memo is exactly 180 bytesInvalidMemoSizeCustom(11)
11The asset exists, is verified and is nativeInvalidAssetId, AssetNotVerified, AssetNotSupportedCustom(13)
12Each commitment is canonical and non-zeroInvalidPublicSignalsCustom(13)
13Each commitment is not already in the forestCommitmentAlreadyExistsCustom(13)
14The two commitments differCommitmentAlreadyExistsCustom(13)
15The proof verifiesProofVerificationFailedCustom(12)
16Real nullifiers are spent, commitments insertedMerkleTreeFull—
17The fee is credited to the block authorFeeRecipientUnavailable—

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:

#CheckDispatch errorPool rejection
1fee is at least MinSpendFeeFeeTooLowPayment
2The root is known (current, retained or sealed)UnknownMerkleRootCustom(1)
3The nullifier is a canonical field elementInvalidPublicSignalsCustom(13)
4The nullifier is not in NullifierSetNullifierAlreadyUsedStale
5amount is non-zeroInvalidAmountCustom(13)
6amount + fee does not overflowInvalidAmountCustom(4)
7The pool holds at least amount + fee of the assetInsufficientPoolBalanceCustom(3)
8Memo is empty for a total unshield, exactly 180 bytes for a partial oneInvalidMemoSizeCustom(11)
9The asset exists, is verified and is nativeInvalidAssetId, AssetNotVerified, AssetNotSupportedCustom(13)
10The recipient is a 32-byte account, not the zero account, not the pool accountInvalidRecipientCustom(13)
11A non-zero change commitment is canonicalInvalidPublicSignalsCustom(13)
12A non-zero change commitment is not already in the forestCommitmentAlreadyExistsCustom(13)
13The proof verifiesProofVerificationFailedCustom(12)
14amount moves to the recipient, change inserted, nullifier spenta balances error if the payout fails; MerkleTreeFull—
15The fee is credited to the block authorFeeRecipientUnavailable—

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_hash is blake2_256 of 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 mod r. See Memo binding.
  • An unshield's recipient is blake2_256(account bytes) mod r. See Recipient encoding.
  • amount, fee and asset_id are 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​

  1. Circuit version supported: an in-memory lookup.
  2. Dispatch checks: the extrinsic's own validation, cheapest first, as listed above.
  3. Proof: one pairing, run at admission only, not in pre_dispatch, since the extrinsic verifies it and its weight covers one verification.
  4. 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.

CodeNameMeaning
1UNKNOWN_ROOTA root is neither retained nor a sealed tree's root
2ALL_INPUTS_DUMMYEvery transfer input is a dummy; the transfer spends nothing
3INSUFFICIENT_POOL_BALANCEThe pool cannot cover the unshield's amount + fee
4AMOUNT_OVERFLOWThe unshield's amount + fee overflows the balance type
10UNSUPPORTED_CIRCUIT_VERSIONThe version has no key, is retired, or holds a denied key
11INVALID_MEMOThe memos have the wrong count or size for this spend
12INVALID_PROOFThe proof does not verify against the statement
13INVALID_SPENDAny 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​

PropertyValue
ProvidesOne tag per real nullifier and one per created note (each transfer output, an unshield's change note), in the namespace ShieldedPoolSpend
RequiresNothing
PriorityThe spend fee, in planck
Longevity64 blocks
PropagateYes

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:

ErrorMeaningRaised by
CommitmentAlreadyExistsA commitment is already in the forest, or a transfer's two outputs are equalshield, transfer, unshield
NullifierAlreadyUsedA nullifier is already spent, or a transfer names the same note twicetransfer, unshield
UnknownMerkleRootA root is not current, retained or sealedtransfer, unshield
MerkleTreeFullThe forest's leaf-index space is exhaustedshield, transfer, unshield
InsufficientPoolBalanceThe pool does not hold enough of the assetunshield, claim_fees
InvalidAmountZero or overflowing amount; a transfer with only dummy inputsshield, transfer, unshield, claim_fees
TooManyInputsOrOutputsA transfer without exactly two nullifiers and two commitmentstransfer
ProofVerificationFailedThe proof does not verifyshield, transfer, unshield
InvalidMemoSizeA memo has the wrong sizeshield, transfer, unshield
MemoCommitmentMismatchMemo count differs from commitment counttransfer
InvalidAssetIdThe asset is not registeredevery pool operation
AssetNotVerifiedThe asset is registered but not verifiedevery pool operation
AssetNotSupportedThe asset is verified but is not the native assetevery pool operation
InvalidRecipientThe recipient is the zero account, the pool account, or not 32 bytesunshield
FeeTooLowThe fee is below MinSpendFeetransfer, unshield
InvalidPublicSignalsA value is not a canonical field element, a commitment is zero, or a dummy root differsshield, transfer, unshield
InsufficientPendingFeesA claim exceeds the caller's pending feesclaim_fees
FeeRecipientUnavailableA non-zero fee and no known block authortransfer, unshield
EmptyBatchshield_batch with no entriesshield_batch
AssetIdAlreadyExistsThe asset id counter collided with an existing assetregister_asset