Skip to main content

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 PoolBalancePerAsset drops 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.

ParameterTypeDescription
asset_idu320 (ORB), the only asset the pool moves
amountu128Any 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:

  1. checks the asset can move — it exists, is verified (an unverified asset is frozen, fees included), and is the native asset;
  2. rejects a zero amount;
  3. checks the pool holds at least amount;
  4. checks amount does not exceed the caller's PendingFees, and decrements it;
  5. transfers amount from the pool account to the caller and decrements PoolBalancePerAsset;
  6. 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 itemChange
NullifierSetThe nullifier is added
PoolBalancePerAssetDecremented by amount; the fee stays
PendingFees[validator][asset_id]Incremented by fee
Recipient's public balanceIncremented by amount
MerkleLeaves, PoseidonRoot, CommitmentMemosPartial unshield only: the change commitment is inserted with its memo

After a private_transfer:

Storage itemChange
NullifierSetEach real input's nullifier is added
MerkleLeaves, PoseidonRootTwo commitments inserted; the root updates
CommitmentMemosOne memo per new commitment
PoolBalancePerAssetUnchanged: 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​

ParameterValue
MinSpendFee0.001 ORB (10^15 planck)
BlockAuthorThe session validator at the block's Aura authority index; none only outside an authored block, such as an RPC simulation
ErrorRaised byCause
FeeTooLowspendsfee < MinSpendFee (InvalidTransaction::Payment at pool admission)
FeeRecipientUnavailablespendsThe block has no known author
ProofVerificationFailedspendsThe proof does not match the submitted inputs, fee included
BadOriginspends, claim_feesA spend that is not unsigned; a claim that is not signed
InvalidAssetIdclaim_feesThe asset does not exist
AssetNotVerifiedclaim_feesThe asset is not verified, so its balances are frozen
AssetNotSupportedclaim_feesThe asset is not the native asset
InvalidAmountclaim_feesamount is zero
InsufficientPoolBalanceclaim_feesThe pool holds less than amount
InsufficientPendingFeesclaim_feesamount 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 MinSpendFee is 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.