Skip to main content

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 origin to 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.

Proof binding

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_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 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:

  1. The named EVM address resolves to a registered relayer → that account is credited.
  2. 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.
  3. No address was named → the block author is credited, no event. A call that named nobody was never asking to credit anyone else.
  4. 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,
)
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.

Parameters

Validators call this extrinsic to convert their pending fee credits into a spendable private note.

ParameterTypeDescription
commitment[u8; 32]Off-chain computed Poseidon4(amount, asset_id, ownerPk, blinding)
amountu128Amount to claim. Must be ≤ PendingRelayerFees[caller][asset_id]
asset_idu32Asset to claim fees in
memoEncryptedMemoEncrypted note metadata for wallet scanning and recovery
proofBoundedVec<u8, 512>Groth16 value proof (circuit ID 6)
public_signalsBoundedVec<u8, 128>76 bytes: commitment[0..32] | value[32..40] | asset_id[40..44] | owner_hash[44..76]
circuit_versionu32Circuit version the proof was generated under
A value proof is required

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.

Partial claims

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


Runtime parameters

ParameterTypeDescription
MinRelayFeeu128Minimum fee required. Calls with fee < MinRelayFee are rejected before proof verification.
BlockAuthorGet<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.

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.


Known limitations

Design constraints

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 = 0 will 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_fees leaves credits unclaimed indefinitely.
  • claim_shielded_fees is signed. The claim extrinsic requires a standard signed origin. This reveals the validator's account, which is expected — validators are already public participants.