Gas and Fees
This document defines the policy for distributing transaction fees and provides implementation guidance for the execution layer.
Overview
Section titled “Overview”When a transaction executes, the payer is debited fees based on gas consumption. This policy defines where those fees go after execution completes.
Current State
Section titled “Current State”- Fee Debit: Payer is debited
gas_limitunits at transaction start. - Gas Refund: Unused gas (
gas_limit - gas_used) is refunded to payer. - Consumed Fees:
gas_usedunits are currently implicitly burned (credited nowhere).
Existing Infrastructure (Unused)
Section titled “Existing Infrastructure (Unused)”Validator.fee_recipient: Address- per-validator fee destinationBlockHeader.proposer: Address- block producer identityFeeIntentV1.priority_tip- optional tip field (currently ignored)
Policy Decision
Section titled “Policy Decision”Phase 1: Proposer-Takes-All
Section titled “Phase 1: Proposer-Takes-All”All consumed fees go to the block proposer.
fee_distribution = gas_used -> proposer.fee_recipientRationale
Section titled “Rationale”- Simplicity: Single destination, no splitting logic.
- Validator Incentive: Directly rewards block producers.
- Uses Existing Fields:
fee_recipientandproposerare already in place. - Greenfield Flexibility: Can evolve to burn/split model before mainnet.
Implementation
Section titled “Implementation”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 proposerlet 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.
Phase 2 (Future): EIP-1559 Style Planned
Section titled “Phase 2 (Future): EIP-1559 Style ”PlannedWhen ready for mainnet, consider:
base_fee -> BURN (or treasury)priority_tip -> proposer.fee_recipientThis 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.
Configuration
Section titled “Configuration”Fee Recipient Resolution (Phase 1)
Section titled “Fee Recipient Resolution (Phase 1)”Simple approach: Use BlockHeader.proposer directly as the fee destination.
- Read
block.header.proposer(available during block execution). - Credit consumed fees to that address.
- 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.
Multi-Asset Fees
Section titled “Multi-Asset Fees”Fee distribution respects the fee asset specified in FeeIntentV1:
- Default: native token (asset ID 0)
- Custom: any registered asset ID
Metrics and Events
Section titled “Metrics and Events”Metrics
Section titled “Metrics”| Metric | Description |
|---|---|
fees_distributed_total | Total fees credited to proposers |
fees_burned_total | Total fees with no recipient (burned) |
fee_distribution_count | Number of transactions with fee distribution |
Events
Section titled “Events”FeeDistributed { recipient: Address, amount: u64, asset_id: AssetId, tx_hash: Hash }FeeBurned { amount: u64, asset_id: AssetId, tx_hash: Hash }Test Coverage
Section titled “Test Coverage”- Happy Path: Tx executes, proposer receives
gas_usedunits. - Full Refund: Tx uses 0 gas, proposer receives 0 (all refunded).
- No Recipient: Validator has no
fee_recipient, fees burned. - Multi-Asset: Fee paid in non-native asset, distributed correctly.
- Revert: Tx reverts, gas still consumed, fees still distributed.
Security Considerations
Section titled “Security Considerations”- No Double-Credit: Ensure fee distribution happens exactly once per tx.
- Overflow: Use saturating arithmetic for fee calculations.
- Asset Consistency: Distribute same asset that was debited.
- Validator Lookup: Cache validator set per block to avoid inconsistency.
Migration Path
Section titled “Migration Path”- Pre-Mainnet: Proposer-takes-all, simple accounting.
- Post-Mainnet: Governance vote to enable EIP-1559 style.
- Treasury Option: Add treasury address for protocol funding.
Client Access: TxFeeBreakdown
Section titled “Client Access: TxFeeBreakdown”After transaction execution (included or simulated), clients receive a TxFeeBreakdown in the ExecutionOutcome.fee field. This provides full visibility into gas/fee accounting:
Core Type (src/core/mod.rs)
Section titled “Core Type (src/core/mod.rs)”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>,}RPC Type (src/rpc_client.rs)
Section titled “RPC Type (src/rpc_client.rs)”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>,}Usage Example
Section titled “Usage Example”// Via RPC clientif 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 );}Behavior Notes
Section titled “Behavior Notes”- VM transactions:
gas_usedreflects actual consumption;total_fee_paid = gas_used * effective_gas_price - Simple transfers:
gas_usedmay be 0;total_fee_paid = max_fee(flat fee, no refund) - v1 engine:
effective_gas_priceis always 1 (1:1 unit pricing) - Simulations: Fee breakdown is populated but no actual fee is charged
Related
Section titled “Related”- Execution - Transaction processing
- RPC API - Fee estimation endpoints
- Gas Schedule - Operation costs
- Ashen SDK - Gas metering in contracts
References
Section titled “References”- EIP-1559 - Fee market change
- Solana Fee Distribution
src/core/execution/mod.rs- Fee handling implementationsrc/core/validator_set.rs- Validator fee_recipient field