How the Relay Flow Works
A gasless private transaction travels through three stages before reaching finality. There is no relay service or intermediary: the user generates the proof, submits the unsigned extrinsic directly, and the block author (an Aura validator) includes it and receives the embedded fee.
Stage overview
Stage 1 — User wallet: proof generation
The user's wallet (or SDK) generates the ZK proof off-chain. The fee is included as a public input signal at this point — it becomes part of the arithmetic constraints that the circuit enforces.
The wallet chooses a fee value that meets or exceeds MinRelayFee. This is a planck-denominated value deducted from the note's total amount.
The wallet calls the circuit's WASM binary with the note's private inputs and the fee as a public input. The resulting proof is only valid for those exact public signals — including the fee value.
The wallet ABI-encodes the proof and all public signals into EVM calldata for the ShieldedPool precompile selector (unshield or privateTransfer).
The circuit-level constraints that enforce the fee:
// Unshield circuit:
note_value === amount + fee
// Transfer circuit (2 inputs, 2 outputs):
input_values[0] + input_values[1] === output_values[0] + output_values[1] + fee
Both circuits also apply Num2Bits(128) to fee, preventing field-overflow attacks.
Stage 2 — Direct submission as unsigned extrinsic
The wallet submits the extrinsic directly to the Substrate node. No relay service, no HTTP intermediary. The extrinsic uses ensure_none — it carries no signing account.
The SDK encodes the proof and all public signals into the extrinsic call data and sends it via the standard Substrate RPC (author_submitExtrinsic or the SCALE-encoded equivalent).
The fee is a public signal inside the ZK proof. It cannot be modified after proof generation — any alteration causes on-chain verification to fail. The node accepts the extrinsic as-is.
Stage 3 — pallet-shielded-pool: execution
pallet-shielded-pool receives the unsigned extrinsic and processes it:
Asset registered, Merkle root is in HistoricRoots, nullifiers not yet spent, fee ≥ MinRelayFee. These run before the expensive proof verification.
pallet-zk-verifier verifies the Groth16 proof against the active verification key for the circuit. Public signals include [merkle_root, nullifier(s), amount/commitments, asset_id, fee, relayer_h160].
Nullifiers are marked spent, output commitments are inserted into the Merkle tree, and (for unshield) tokens are transferred to the recipient's public account.
The pallet identifies the current block author's AccountId and calls T::Relayer::accumulate_relay_fee(block_author, asset_id, fee). This increments PendingRelayerFees[AccountId][asset_id]. The fee tokens remain physically inside pool_account_id — only the accounting counter changes.
What gets written to chain
After a successful unshield relay:
| Storage item | Change |
|---|---|
Nullifiers | Spent nullifier added |
PoolBalance[asset_id] | Decremented by amount (fee stays in 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 |
Security properties
No one can modify the fee after the proof is generated — the altered extrinsic would fail on-chain verification.
The validator includes the transaction but does not control the private note. Spending requires knowledge of the note's spending key, which never leaves the user's wallet.
The extrinsic is unsigned — there is no from-address. The proof's private inputs (sender identity, note contents) remain hidden inside the ZK circuit.
Related
- Fee Lifecycle — how fees accumulate and are claimed
- Registering as Relayer — how validators register and start accumulating fees
- Gasless Fees — circuit-level fee enforcement details