Gasless Fees
private_transfer and unshield are unsigned transactions. There is no public signing key on the call — the proof itself is the authorization. This creates a specific problem: if there is no signer, who pays the block author for including the transaction?
Orbinum solves this with a fee signal embedded directly into the ZK proof. The user commits to a fee amount at proof-generation time. The circuit enforces that the fee is deducted from the note's value. After the transaction is included, the block author accumulates that fee as a private credit and later converts it into a new shielded note via claim_shielded_fees.
The sender's identity is never exposed. The fee is cryptographically enforced.
Why Standard Gas Does Not Work Here
Standard Substrate fee deduction requires a signed origin. private_transfer and unshield use ensure_none — there is no account to charge.
If the submitter paid gas from a public account, that account could be correlated with the private operation, breaking the privacy guarantee.
Without a signature, a relay could alter the claimed fee at submission time. Embedding it as a ZK public signal prevents this — the proof is only valid for that exact fee value.
The fee is not transferred out as public tokens. It remains inside the pool as a credit. The validator later redeems it as a new private note, preserving pool accounting integrity.
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
// Public signals:
// [merkle_root, nullifier, amount, recipient, asset_id, fee]
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.
Pallet-Level Flow
Step-by-step: fee lifecycle
The wallet chooses a fee value meeting or exceeding MinGaslessFee. The fee is included as a public input when generating the Groth16 proof. The proof is only valid for this exact fee.
The private_transfer or unshield extrinsic is submitted without a signer. The pallet rejects any call where fee < MinGaslessFee before proof verification runs.
pallet-zk-verifier verifies the Groth16 proof against the public signals, which include the fee value. If any signal is tampered — including the fee — verification fails.
After successful verification, the pallet reads the current block author from T::BlockAuthor and increments PendingValidatorFees[block_author][asset_id] by the fee amount. The pool's total balance is unchanged — the fee stays inside the pool.
The validator calls claim_shielded_fees(commitment, amount, asset_id, memo) with a commitment computed off-chain. The pallet deducts amount from PendingValidatorFees and inserts the commitment into the Merkle tree. No ZK proof is required for the claim — the commitment's spendability is self-enforcing.
claim_shielded_fees Extrinsic
Validators call this extrinsic to convert their pending fee credits into a spendable private note.
| Parameter | Type | Description |
|---|---|---|
commitment | [u8; 32] | Off-chain computed Poseidon(amount, asset_id, ownerPk, blinding) |
amount | u128 | Amount to claim. Must be ≤ PendingValidatorFees[caller][asset_id] |
asset_id | u32 | Asset to claim fees in |
memo | EncryptedMemo | Encrypted note metadata for wallet scanning and recovery |
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 PendingValidatorFees and can be claimed in a single note or split across multiple claims.
Runtime Parameters
| Parameter | Type | Description |
|---|---|---|
MinGaslessFee | u128 | Minimum fee required. Calls with fee < MinGaslessFee 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 circuit constraint note_value === amount + fee (unshield) or input_sum === output_sum + fee (transfer) cannot be satisfied if the fee is incorrectly declared. A dishonest submitter cannot reduce the fee after the proof is generated.
Both circuits include a Num2Bits(128) constraint on fee. This prevents field-wraparound attacks where a value larger than 2^128 could satisfy the conservation constraint while being invalid at the runtime type boundary.
The fee deduction happens inside the circuit, against private note values. No public account is debited. The on-chain extrinsic is unsigned. There is no correlation between the fee payment and any external identity.
The fee stays inside the pool. PoolBalancePerAsset is not reduced when a fee is processed. When a validator calls claim_shielded_fees, a new Merkle leaf is inserted but no tokens leave the pool — the fee was already accounted for in the original shield operation.
Known Limitations
The gasless fee mechanism is functional as of v0.5.0 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.