Skip to content

Gas and Fees

This document defines the policy for distributing transaction fees and provides implementation guidance for the execution layer.

When a transaction executes, the payer is debited fees based on gas consumption. This policy defines where those fees go after execution completes.

  • Fee Debit: Payer is debited gas_limit units at transaction start.
  • Gas Refund: Unused gas (gas_limit - gas_used) is refunded to payer.
  • Consumed Fees: gas_used units are currently implicitly burned (credited nowhere).
  • Validator.fee_recipient: Address - per-validator fee destination
  • BlockHeader.proposer: Address - block producer identity
  • FeeIntentV1.priority_tip - optional tip field (currently ignored)

All consumed fees go to the block proposer.

fee_distribution = gas_used -> proposer.fee_recipient
  1. Simplicity: Single destination, no splitting logic.
  2. Validator Incentive: Directly rewards block producers.
  3. Uses Existing Fields: fee_recipient and proposer are already in place.
  4. Greenfield Flexibility: Can evolve to burn/split model before mainnet.

Phase 1 Simplification: Use BlockHeader.proposer directly as fee destination. No validator set lookup required - validators can set their proposer address to any address they control.

Requires adding proposer to VmBlockEnv and passing through apply_transaction:

// In VmBlockEnv (mod.rs:462)
pub(crate) struct VmBlockEnv {
// ... existing fields ...
pub(crate) proposer: Address, // Fee recipient for this block
}
// In apply_transaction_inner, after gas refund (line ~2569):
// 6. Credit consumed gas to block proposer
let consumed = used.gas_used;
if consumed > 0 {
self.state.credit_asset(&block_env.proposer, fee_asset_id, consumed);
}

Note: Proposer address comes from block.header.proposer in apply_block and from ExecContext or parent proposer logic in build_block.

When ready for mainnet, consider:

base_fee -> BURN (or treasury)
priority_tip -> proposer.fee_recipient

This requires:

  • Base fee calculation per block
  • Priority tip extraction from FeeIntentV1
  • Burn destination (null address or explicit)

Deferred until DEPLOYED flag is set in CLAUDE.md.

Simple approach: Use BlockHeader.proposer directly as the fee destination.

  1. Read block.header.proposer (available during block execution).
  2. Credit consumed fees to that address.
  3. No validator set lookup required.

Why this works: Validators choose their proposer address when producing blocks. If they want fees sent to a different address, they set that address as proposer.

Future Phase 2: Add Validator.fee_recipient lookup if needed for separation of signing identity vs payment destination.

Fee distribution respects the fee asset specified in FeeIntentV1:

  • Default: native token (asset ID 0)
  • Custom: any registered asset ID
MetricDescription
fees_distributed_totalTotal fees credited to proposers
fees_burned_totalTotal fees with no recipient (burned)
fee_distribution_countNumber of transactions with fee distribution
FeeDistributed { recipient: Address, amount: u64, asset_id: AssetId, tx_hash: Hash }
FeeBurned { amount: u64, asset_id: AssetId, tx_hash: Hash }
  1. Happy Path: Tx executes, proposer receives gas_used units.
  2. Full Refund: Tx uses 0 gas, proposer receives 0 (all refunded).
  3. No Recipient: Validator has no fee_recipient, fees burned.
  4. Multi-Asset: Fee paid in non-native asset, distributed correctly.
  5. Revert: Tx reverts, gas still consumed, fees still distributed.
  1. No Double-Credit: Ensure fee distribution happens exactly once per tx.
  2. Overflow: Use saturating arithmetic for fee calculations.
  3. Asset Consistency: Distribute same asset that was debited.
  4. Validator Lookup: Cache validator set per block to avoid inconsistency.
  1. Pre-Mainnet: Proposer-takes-all, simple accounting.
  2. Post-Mainnet: Governance vote to enable EIP-1559 style.
  3. Treasury Option: Add treasury address for protocol funding.

After transaction execution (included or simulated), clients receive a TxFeeBreakdown in the ExecutionOutcome.fee field. This provides full visibility into gas/fee accounting:

pub struct TxFeeBreakdown {
/// Gas units the sender authorized (from TxBodyV1.gas_limit).
pub gas_limit: u64,
/// Gas units actually consumed by execution.
pub gas_used: u64,
/// Effective gas price applied (native units per gas unit).
pub effective_gas_price: u128,
/// Maximum fee the sender was willing to pay (from FeeIntent).
pub max_fee: u128,
/// Priority tip offered above base fee (from FeeIntent).
pub priority_tip: u128,
/// Total fee actually charged to the payer.
pub total_fee_paid: u128,
/// Asset used for fee payment (e.g. "native").
pub fee_asset: Option<String>,
}

The RPC layer converts u128 fields to strings for JSON compatibility:

pub struct TxFeeBreakdown {
pub gas_limit: u64,
pub gas_used: u64,
pub effective_gas_price: String, // string-encoded u128
pub max_fee: String,
pub priority_tip: String,
pub total_fee_paid: String,
pub fee_asset: Option<String>,
}
// Via RPC client
if let Some(receipt) = client.get_transaction_receipt(tx_hash)? {
if let Some(fee) = receipt.fee.as_ref() {
println!("Gas used: {} / {}", fee.gas_used, fee.gas_limit);
println!(
"Total fee: {} {}",
fee.total_fee_paid,
fee.fee_asset.clone().unwrap_or_default()
);
}
// Via TransactionReceipt convenience fields
println!("Gas: {}/{}", receipt.gas_used, receipt.gas_limit);
println!(
"Fee: {} at {} per gas",
receipt.total_fee_paid, receipt.effective_gas_price
);
}
  • VM transactions: gas_used reflects actual consumption; total_fee_paid = gas_used * effective_gas_price
  • Simple transfers: gas_used may be 0; total_fee_paid = max_fee (flat fee, no refund)
  • v1 engine: effective_gas_price is always 1 (1:1 unit pricing)
  • Simulations: Fee breakdown is populated but no actual fee is charged
  • EIP-1559 - Fee market change
  • Solana Fee Distribution
  • src/core/execution/mod.rs - Fee handling implementation
  • src/core/validator_set.rs - Validator fee_recipient field