Skip to main content

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

No signer on the extrinsic

Standard Substrate fee deduction requires a signed origin. private_transfer and unshield use ensure_none — there is no account to charge.

Any signing account leaks identity

If the submitter paid gas from a public account, that account could be correlated with the private operation, breaking the privacy guarantee.

Fee must be unforgeably committed

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.

Fee stays inside the shielded pool

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.

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.


Pallet-Level Flow

Step-by-step: fee lifecycle

1
User selects a fee and generates the proof

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.

2
Extrinsic is submitted (unsigned)

The private_transfer or unshield extrinsic is submitted without a signer. The pallet rejects any call where fee < MinGaslessFee before proof verification runs.

3
ZK proof is verified on-chain

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.

4
Fee credited to block author

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.

5
Validator claims fees as a private note

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.

ParameterTypeDescription
commitment[u8; 32]Off-chain computed Poseidon(amount, asset_id, ownerPk, blinding)
amountu128Amount to claim. Must be ≤ PendingValidatorFees[caller][asset_id]
asset_idu32Asset to claim fees in
memoEncryptedMemoEncrypted note metadata for wallet scanning and recovery

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 PendingValidatorFees and can be claimed in a single note or split across multiple claims.


Runtime Parameters

ParameterTypeDescription
MinGaslessFeeu128Minimum fee required. Calls with fee < MinGaslessFee 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

Fee is arithmetically enforced

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.

Fee is range-checked to u128

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.

Sender privacy is preserved

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.

Pool balance accounting is preserved

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

WIP — MVP constraints

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