Skip to main content

Fee Lifecycle

Relay fees do not leave the shielded pool. After each relayed operation, the fee amount stays physically inside pool_account_id while an on-chain counter (PendingRelayerFees) tracks how much each relayer is owed. The relayer later converts that credit into a private note.


Tokens never move at fee time

When a user calls unshield(amount=900, fee=100):

  • 900 tokens are transferred from pool_account_id to the recipient's public account
  • 100 tokens remain inside pool_account_id — they are not moved at all
  • PendingRelayerFees[relayer][asset_id] is incremented by 100

The fee is an accounting entry, not a token transfer. This keeps pool accounting consistent and avoids two separate token movements per operation.


Who the fee is credited to

The pallet resolves the recipient in two steps:

  1. If the submitting relayer's EVM address resolves to a registered relayer, that account is credited.
  2. Otherwise the fee falls back to the current block author.

If neither resolves, the call fails with FeeRecipientUnavailable rather than stranding the tokens.

When the operation arrives through the ShieldedPool precompile, the relayer address is taken from the EVM caller, so it cannot be forged by the submitter.


Where fees accumulate

PendingRelayerFees is a StorageDoubleMap<AccountId, u32, u128> in pallet-relayer. Keys are (relayer_account_id, asset_id); the value is the accumulated fee in planck.

It is incremented by accumulate_relay_fee (called from pallet-shielded-pool after each successful relayed operation) and decremented by consume_relay_fee when the relayer claims.

Fees from many operations accumulate until the relayer decides to claim. They do not expire.


Claiming: claim_shielded_fees

Claiming converts pending credits into a spendable private note. Call index 16 in pallet-shielded-pool.

claim_shielded_fees(
commitment, // Poseidon4(amount, asset_id, owner_pk, blinding)
amount,
asset_id,
memo,
proof, // Groth16 value proof — circuit ID 6
public_signals, // 76 bytes
circuit_version,
)
A value proof is required

Without it, a relayer could mint a commitment encoding more than they actually earned. The proof binds the commitment to the claimed amount and asset_id, and the pallet cross-checks the revealed signals against the extrinsic arguments.

Generate it with generateFeeClaimProof from the wallet SDK.

The pallet then:

  1. Verifies the value proof against the VK for circuit_version
  2. Checks public_signals[0..32] matches commitment, the revealed value matches amount, and the revealed asset matches asset_id
  3. Verifies PendingRelayerFees[caller][asset_id] ≥ amount
  4. Calls consume_relay_fee — decrements the counter
  5. Inserts commitment into the active Merkle tree and records the encrypted memo
  6. Emits ValidatorFeesClaimed { validator, asset_id, amount, commitment, leaf_index }

The relayer now holds a private note worth amount, spendable via private_transfer or unshield with no on-chain link to their relayer identity.

Pool balance is unchanged

Unlike an unshield, claiming does not decrement PoolBalancePerAsset. The tokens were already inside the pool and stay there — they simply change from an accounting credit into a note backed by a Merkle commitment.


Partial claims

Any amount up to the full pending balance is accepted. The counter is not reset; the remainder stays in PendingRelayerFees for a future claim.

The claimed amount must fit in a u64. The extrinsic takes u128, but the runtime rejects anything above u64::MAX with InvalidAmount — that is the circuit's signal width, not an accounting limit. Very large accumulated balances are claimed across several notes.


What changes on-chain

After a successful unshield relay:

Storage itemChange
NullifiersSpent nullifier added
PoolBalancePerAssetDecremented by amount — the fee stays in the pool
PendingRelayerFees[AccountId][asset_id]Incremented by fee
Recipient's public balanceIncremented by amount

After a successful private_transfer relay:

Storage itemChange
NullifiersBoth input nullifiers added
MerkleLeavesTwo new commitments inserted
MerkleRootUpdated
PendingRelayerFees[AccountId][asset_id]Incremented by fee

The chain announces those two writes as separate eventsNullifiersSpent and CommitmentsInserted — and that separation is deliberate. Emitting them together would let an observer pair a spend with the commitments it produced, which is the linkage the whole design exists to avoid.


Security considerations

Pool balance integrity

PoolBalancePerAsset tracks the tokens owed to note holders. A relayer cannot claim more than their registered pending fees, so note holders are never under-collateralized.

Claiming is a signed call

claim_shielded_fees requires a standard signed origin, which reveals the relayer's account. That is expected — relayers are already public participants. The note it produces is private, and spending it later carries no link back to this claim.