Spend Fees
A private transfer or unshield has no signer and no account to charge gas to. Its fee is a public input of the proof, paid out of the note being spent, and credited to the validator that authored the block, which claims it later in a separate public step.
Why a spend cannot pay gas
- No signer. A spend is accepted only as an unsigned extrinsic, authorised by its proof; a signed or Root origin fails with
BadOrigin. A signature would link the signer to the spend's nullifiers for good. - No public balance. A user whose funds are all shielded has nothing public to pay gas with.
- Charging would deanonymise. Any public deduction tied to a spend names a participant.
So the fee is part of the statement the proof attests to: fixed when the proof is generated, checked by the runtime, and impossible to change without proving again.
The fee in the circuits
The fee is a public input of both spend circuits, and value conservation includes it.
Unshield spends one note:
note_value === amount + fee + change_value
public inputs: merkle_root, nullifier, amount, recipient, asset_id, fee, change_commitment, memo_hash
The recipient receives amount; the fee comes out of the note on top of it, and change_value returns to the pool as a change note (zero for a total unshield). recipient enters the circuit as blake2_256(recipient) mod r, so an alias R ± r cannot stand in for R. note_value, amount, change_value and fee are each range-checked to 128 bits, so the sum cannot wrap around the field.
Transfer spends up to two notes into two outputs:
input_values[0] + input_values[1] === output_values[0] + output_values[1] + fee
public inputs: merkle_roots[0], merkle_roots[1], nullifiers[0], nullifiers[1],
commitments[0], commitments[1], asset_id, fee, memo_hash
All four values and the fee are range-checked to 128 bits. Each input is proven against its own root, so the two inputs may come from different trees.
Because fee is a public input, a submitted extrinsic whose fee differs from the proven one fails with ProofVerificationFailed. Exact signal layouts are in the transfer and unshield circuit pages.
Minimum fee
Every spend must carry at least MinSpendFee = 0.001 ORB (10^15 planck), a runtime constant that keeps spam from filling blocks with free proofs. The check runs before the proof is verified: inside a block a lower fee fails with FeeTooLow, and at pool admission it is refused as InvalidTransaction::Payment. Above the minimum, the fee is the spend's priority in the transaction pool. The app reads the chain minimum before proving and uses 0.001 ORB.
Who the fee is credited to
The validator that authored the block: the session validator at the Aura authority index named in the block's pre-digest — its validator account, not the Aura key read as an account. The pallet adds the fee to that account's PendingFees and emits FeeCredited { validator, asset_id, amount }.
Nothing in the calldata names the beneficiary. A copier who resubmits someone else's propagated proof gains nothing: the fee goes to whoever includes the spend in a block, not to whoever sent it. If a block has no known author, the spend fails with FeeRecipientUnavailable rather than stranding the tokens.
Tokens stay in the pool
Crediting a fee moves no tokens. For unshield(amount = 900, fee = 100) from a 1,000 ORB note:
- 900 ORB are transferred from the pool account to the recipient, and
PoolBalancePerAssetdrops by 900; - 100 ORB stay in the pool account;
PendingFees[validator][asset_id]grows by 100.
PoolBalancePerAsset therefore counts both the value backing notes and the unclaimed fees. Pending fees accumulate across blocks, never expire, and survive the validator leaving the set.
Claiming
claim_fees(asset_id, amount) (call index 19 of the shielded pool) pays pending fees out of the pool to the validator's public balance. It takes no proof: the pending balance the chain recorded is the authority.
| Parameter | Type | Description |
|---|---|---|
asset_id | u32 | 0 (ORB), the only asset the pool moves |
amount | u128 | Any non-zero amount up to PendingFees[caller][asset_id]; the rest stays for a later claim |
The call must be signed: it pays the signer's own pending balance to the signer. Root and unsigned origins fail with BadOrigin, so sudo cannot move another party's fees. The pallet:
- checks the asset can move — it exists, is verified (an unverified asset is frozen, fees included), and is the native asset;
- rejects a zero
amount; - checks the pool holds at least
amount; - checks
amountdoes not exceed the caller'sPendingFees, and decrements it; - transfers
amountfrom the pool account to the caller and decrementsPoolBalancePerAsset; - emits
FeesClaimed { validator, asset_id, amount }.
The claim is public: it reveals the account and the amount, and the funds land in a public balance. Validators already author blocks under known accounts, so a proof would add cost without privacy; a validator that wants private holdings can shield the proceeds. The operator steps are in Becoming a Validator.
What changes on-chain
After an unshield:
| Storage item | Change |
|---|---|
NullifierSet | The nullifier is added |
PoolBalancePerAsset | Decremented by amount; the fee stays |
PendingFees[validator][asset_id] | Incremented by fee |
| Recipient's public balance | Incremented by amount |
MerkleLeaves, PoseidonRoot, CommitmentMemos | Partial unshield only: the change commitment is inserted with its memo |
After a private_transfer:
| Storage item | Change |
|---|---|
NullifierSet | Each real input's nullifier is added |
MerkleLeaves, PoseidonRoot | Two commitments inserted; the root updates |
CommitmentMemos | One memo per new commitment |
PoolBalancePerAsset | Unchanged: no value leaves the pool, the fee included |
PendingFees[validator][asset_id] | Incremented by fee |
A transfer announces its nullifiers and its new commitments as separate events, NullifiersSpent and CommitmentsInserted, so the events do not pair a spend with the outputs it produced.
Parameters and errors
| Parameter | Value |
|---|---|
MinSpendFee | 0.001 ORB (10^15 planck) |
BlockAuthor | The session validator at the block's Aura authority index; none only outside an authored block, such as an RPC simulation |
| Error | Raised by | Cause |
|---|---|---|
FeeTooLow | spends | fee < MinSpendFee (InvalidTransaction::Payment at pool admission) |
FeeRecipientUnavailable | spends | The block has no known author |
ProofVerificationFailed | spends | The proof does not match the submitted inputs, fee included |
BadOrigin | spends, claim_fees | A spend that is not unsigned; a claim that is not signed |
InvalidAssetId | claim_fees | The asset does not exist |
AssetNotVerified | claim_fees | The asset is not verified, so its balances are frozen |
AssetNotSupported | claim_fees | The asset is not the native asset |
InvalidAmount | claim_fees | amount is zero |
InsufficientPoolBalance | claim_fees | The pool holds less than amount |
InsufficientPendingFees | claim_fees | amount exceeds the caller's pending fees |
Every other check a spend goes through is listed in Spend Validity.
Security properties
- The fee cannot be changed after proving. It is a public input, so a different value invalidates the proof.
- The beneficiary cannot be forged. It is the block author, which no calldata field and no submitter controls; a copier gains nothing by resubmitting.
- No account is linked to a spend. Spends are accepted only unsigned, so no signature, gas payment or EVM transaction ties an address to their nullifiers.
- A claim cannot mint value. It is bounded by
PendingFees, which only executed spends write, and checked before any transfer. - Note holders stay fully backed. The pool holds every unclaimed fee, and a validator can claim only its own.
Limits
- The fee is fixed at proof time. A proof cannot be reused with a different fee; a fee below
MinSpendFeeis rejected. - The fee is paid in the note's asset. Only ORB moves through the pool, so every fee is paid in ORB.
- Validators must claim. Pending fees do not compound or pay out on their own.
- Claiming is public. Shielding the proceeds is a separate step.
Related
- Private Transfer — the fee as the app shows it
- Becoming a Validator — checking and claiming spend fees
- Consensus — what a validator earns
- Spend Validity — every check a spend must pass
- Transaction Lifecycle — a spend end to end