Gasless Fees
A private transfer has no signer. There is no account to charge gas to, and deliberately so: requiring one would tie every shielded operation to a public identity and undo the privacy it exists to provide.
So the fee travels inside the proof. The circuit enforces it, the pool credits it, and the earner claims it later as a private note. No public balance is touched at any point.
Why standard gas does not work here
Three properties of a private transaction each rule out the normal model:
- No signer on the extrinsic. A shielded transfer arrives unsigned — the
proof authorises the spend. There is no
originto deduct from. - No public balance to charge. A user whose funds are entirely shielded has nothing to pay gas with. Requiring a funded public account would exclude exactly the users the pool is for.
- Charging would deanonymise. Any public deduction linked to a shielded operation names a participant, which is the one thing the design must not do.
The fee is therefore a public signal of the proof: committed at proof-generation time, verifiable by the runtime, and impossible to alter without regenerating the proof.
Circuit-level enforcement
The fee is a public input signal in both the unshield and transfer circuits. The circuit constrains it arithmetically — there is no trusted runtime path that bypasses verification.
Unshield Circuit
The note's full value must be covered by the withdrawal amount plus the fee:
// Constraint 1
note_value === amount + fee + change_value
// Public signals:
// [merkle_root, nullifier, amount, recipient, asset_id, fee, change_commitment]
change_value is 0 for a total unshield, reducing the constraint to note_value === amount + fee.
For a partial unshield the remainder returns to the pool as a change note.
The circuit also range-checks both note_value and fee to u128 using Num2Bits(128), preventing field-wraparound attacks where an overflow value could satisfy conservation while being semantically invalid at the runtime level.
Transfer Circuit
For a 2-input, 2-output transfer, the sum of input values must equal the sum of output values plus the fee:
// Constraint 5
input_sum === output_sum + fee
// Where:
// input_sum = input_values[0] + input_values[1]
// output_sum = output_values[0] + output_values[1]
// Public signals:
// [merkle_root, nullifiers[2], commitments[2], asset_id, fee]
All four note values and the fee are individually range-checked to u128.
Once a proof is generated, the fee is cryptographically bound to it. Changing the fee parameter in the submitted extrinsic would cause proof verification to fail at the InvalidProof error.
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 recipient comes from the dispatch origin, never from calldata — a calldata field would be an unauthenticated claim, and anyone could take a propagated proof, resubmit it naming themselves, and collect a fee they never paid for.
Four branches:
- The named EVM address resolves to a registered relayer → that account is credited.
- An address was named but resolves to nobody → the block author is
credited, and
shieldedPool.RelayFeeDiverted { requested, credited }is emitted so the misconfiguration is visible rather than silent. - No address was named → the block author is credited, no event. A call that named nobody was never asking to credit anyone else.
- No block author available → the call fails with
FeeRecipientUnavailable.
Cutting across those: when the resolved relayer is the block author,
shieldedPool.SelfRelayedFee is emitted as well.
Relaying is deliberately not gated on registration. Failing when an address does not resolve would reject a user's transaction over someone else's misconfiguration.
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
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.
Parameters
Validators call this extrinsic to convert their pending fee credits into a spendable private note.
| Parameter | Type | Description |
|---|---|---|
commitment | [u8; 32] | Off-chain computed Poseidon4(amount, asset_id, ownerPk, blinding) |
amount | u128 | Amount to claim. Must be ≤ PendingRelayerFees[caller][asset_id] |
asset_id | u32 | Asset to claim fees in |
memo | EncryptedMemo | Encrypted note metadata for wallet scanning and recovery |
proof | BoundedVec<u8, 512> | Groth16 value proof (circuit ID 6) |
public_signals | BoundedVec<u8, 128> | 76 bytes: commitment[0..32] | value[32..40] | asset_id[40..44] | owner_hash[44..76] |
circuit_version | u32 | Circuit version the proof was generated under |
The claim is not self-enforcing. Without the value proof a validator could mint a commitment
encoding more than the fees they actually earned, so the proof binds the commitment to the claimed
amount and asset_id.
Generate it with generateFeeClaimProof from the wallet SDK.
The pallet emits ValidatorFeesClaimed { validator, asset_id, amount, commitment, leaf_index } upon success.
Validators can claim any amount up to their full pending balance. Fees from multiple blocks accumulate in PendingRelayerFees and can be claimed in a single note or split across multiple claims.
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.
Runtime parameters
| Parameter | Type | Description |
|---|---|---|
MinRelayFee | u128 | Minimum fee required. Calls with fee < MinRelayFee are rejected before proof verification. |
BlockAuthor | Get<Option<AccountId>> | Provides the current block's author. Returns None in tests without an authored block, in which case no fee is credited. |
Security properties
The fee cannot be inflated after the fact. It is a public input to the proof, so changing it invalidates the proof.
The recipient cannot be forged. It comes from the dispatch origin, which the submitter cannot rewrite — the party credited is the one that actually bore the cost of submitting.
A claim cannot mint value. claim_shielded_fees requires a value_proof
showing the commitment encodes exactly the amount being claimed. Without it a
relayer could craft a commitment encoding an inflated amount and drain the pool
on unshield.
Over-claiming fails closed. The pending balance is checked before the note is
created; claiming more than you earned returns InsufficientPendingFees.
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.
Known limitations
The gasless fee mechanism is functional but has the following known limitations:
- No automatic fee relay. The user must know to set a fee before generating the proof. Submitting a proof with
fee = 0will be rejected by the pallet (FeeTooLow) and the proof cannot be reused with a different fee value. - Fee is declared in the same asset as the note. Cross-asset fee payment is not supported. A note denominated in USDT pays fees in USDT.
- Validators must actively claim. Pending fees do not auto-compound or expire. A validator that never calls
claim_shielded_feesleaves credits unclaimed indefinitely. claim_shielded_feesis signed. The claim extrinsic requires a standard signed origin. This reveals the validator's account, which is expected — validators are already public participants.
Related
- Relay Setup & Fees — registering an address and claiming, as an operator
- pallet-relayer Reference — extrinsics, events and errors
- Private Transfer — where the fee is deducted