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_idto 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:
- If the submitting relayer's EVM address resolves to a registered relayer, that account is credited.
- 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,
)
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:
- Verifies the value proof against the VK for
circuit_version - Checks
public_signals[0..32]matchescommitment, the revealed value matchesamount, and the revealed asset matchesasset_id - Verifies
PendingRelayerFees[caller][asset_id] ≥ amount - Calls
consume_relay_fee— decrements the counter - Inserts
commitmentinto the active Merkle tree and records the encrypted memo - 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.
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 item | Change |
|---|---|
Nullifiers | Spent nullifier added |
PoolBalancePerAsset | Decremented by amount — the fee stays in the pool |
PendingRelayerFees[AccountId][asset_id] | Incremented by fee |
| Recipient's public balance | Incremented by amount |
After a successful private_transfer relay:
| Storage item | Change |
|---|---|
Nullifiers | Both input nullifiers added |
MerkleLeaves | Two new commitments inserted |
MerkleRoot | Updated |
PendingRelayerFees[AccountId][asset_id] | Incremented by fee |
The chain announces those two writes as separate events —
NullifiersSpent 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
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.
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.