From 2a42c023158346a6e366f14d59a3a3f765d74471 Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Mon, 16 Feb 2026 17:13:43 +0000 Subject: [PATCH 01/16] feat!(evm): split intrinsic gas between regular and state gas for T2+ (TIP-1016) --- crates/evm/src/lib.rs | 109 +++++++- crates/revm/src/handler.rs | 515 ++++++++++++++++++++++++++++++++++++- 2 files changed, 608 insertions(+), 16 deletions(-) diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 307d6b85af..e6242e49ce 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -131,6 +131,8 @@ impl ConfigureEvm for TempoEvmConfig { // Apply TIP-1000 gas params for T1 hardfork. let mut cfg_env = cfg_env.with_spec_and_gas_params(spec, tempo_gas_params(spec)); cfg_env.tx_gas_limit_cap = spec.tx_gas_limit_cap(); + // TIP-1016: Enable state gas tracking for T2+ + cfg_env.enable_state_gas = spec.is_t2(); Ok(EvmEnv { cfg_env, @@ -168,6 +170,8 @@ impl ConfigureEvm for TempoEvmConfig { // Apply TIP-1000 gas params for T1 hardfork. let mut cfg_env = cfg_env.with_spec_and_gas_params(spec, tempo_gas_params(spec)); cfg_env.tx_gas_limit_cap = spec.tx_gas_limit_cap(); + // TIP-1016: Enable state gas tracking for T2+ + cfg_env.enable_state_gas = spec.is_t2(); Ok(EvmEnv { cfg_env, @@ -252,7 +256,7 @@ mod tests { use alloy_rlp::{Encodable, bytes::BytesMut}; use reth_evm::{ConfigureEvm, NextBlockEnvAttributes}; use std::collections::HashMap; - use tempo_chainspec::hardfork::TempoHardfork; + use tempo_chainspec::{hardfork::TempoHardfork, spec::DEV}; use tempo_primitives::{ BlockBody, SubBlockMetadata, subblock::SubBlockVersion, transaction::envelope::TEMPO_SYSTEM_TX_SIGNATURE, @@ -306,8 +310,6 @@ mod tests { /// Test that evm_env sets 30M gas limit cap for T1 hardfork as per TIP-1000. #[test] fn test_evm_env_t1_gas_cap() { - use tempo_chainspec::spec::DEV; - // DEV chainspec has T1 activated at timestamp 0 let chainspec = DEV.clone(); let evm_config = TempoEvmConfig::new_with_default_factory(chainspec.clone()); @@ -338,6 +340,107 @@ mod tests { ); } + /// TIP-1016: enable_state_gas must be set when T2 hardfork is active. + /// This gates the reservoir model, tx cap bypass, and state gas tracking. + #[test] + fn test_evm_env_t2_enable_state_gas() { + let chainspec = DEV.clone(); + let evm_config = TempoEvmConfig::new_with_default_factory(chainspec.clone()); + + let header = TempoHeader { + inner: alloy_consensus::Header { + number: 100, + timestamp: 1000, // After T2 activation in DEV + gas_limit: 30_000_000, + base_fee_per_gas: Some(1000), + ..Default::default() + }, + general_gas_limit: 10_000_000, + timestamp_millis_part: 0, + shared_gas_limit: 3_000_000, + }; + + // Verify we're in T2 + assert!(chainspec.tempo_hardfork_at(header.timestamp()).is_t2()); + + let evm_env = evm_config.evm_env(&header).unwrap(); + + assert!( + evm_env.cfg_env.enable_state_gas, + "TIP-1016: enable_state_gas must be true when T2 hardfork is active" + ); + } + + /// TIP-1016: enable_state_gas must NOT be set for pre-T2 hardforks. + #[test] + fn test_evm_env_pre_t2_no_state_gas() { + let chainspec = DEV.clone(); + let _evm_config = TempoEvmConfig::new_with_default_factory(chainspec); + + // Use a header that would be T1 but not T2 + // We need a chainspec where T1 is active but T2 is not + // DEV has all forks at 0, so we need to check with a custom chainspec + // For now, verify that a T1-only spec does NOT enable state gas + let t1_spec = TempoHardfork::T1; + let gas_params = tempo_gas_params(t1_spec); + let cfg = revm::context::CfgEnv::new_with_spec_and_gas_params(t1_spec, gas_params); + + assert!( + !cfg.enable_state_gas, + "enable_state_gas must be false for pre-T2 hardforks" + ); + } + + /// TIP-1016: next_evm_env must also set enable_state_gas for T2. + #[test] + fn test_next_evm_env_t2_enable_state_gas() { + let chainspec = DEV.clone(); + let evm_config = TempoEvmConfig::new_with_default_factory(chainspec.clone()); + + let parent = TempoHeader { + inner: alloy_consensus::Header { + number: 99, + timestamp: 900, + gas_limit: 30_000_000, + base_fee_per_gas: Some(1000), + ..Default::default() + }, + general_gas_limit: 10_000_000, + timestamp_millis_part: 0, + shared_gas_limit: 3_000_000, + }; + + let attributes = TempoNextBlockEnvAttributes { + inner: NextBlockEnvAttributes { + timestamp: 1000, // After T2 activation + suggested_fee_recipient: Address::repeat_byte(0x02), + prev_randao: B256::repeat_byte(0x03), + gas_limit: 30_000_000, + parent_beacon_block_root: Some(B256::ZERO), + withdrawals: None, + extra_data: Default::default(), + }, + general_gas_limit: 10_000_000, + shared_gas_limit: 3_000_000, + timestamp_millis_part: 0, + subblock_fee_recipients: HashMap::new(), + }; + + // Verify we're in T2 + assert!( + chainspec + .tempo_hardfork_at(attributes.inner.timestamp) + .is_t2() + ); + + let evm_env = evm_config.next_evm_env(&parent, &attributes).unwrap(); + + assert!( + evm_env.cfg_env.enable_state_gas, + "TIP-1016: next_evm_env must set enable_state_gas for T2" + ); + } + #[test] fn test_next_evm_env() { let evm_config = TempoEvmConfig::new_with_default_factory(test_chainspec()); diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 750bd2ca23..a5e0edee66 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -164,7 +164,11 @@ fn adjusted_initial_gas( init_and_floor_gas: &InitialAndFloorGas, ) -> InitialAndFloorGas { if spec.is_t1() { - InitialAndFloorGas::new(evm_initial_gas, init_and_floor_gas.floor_gas) + InitialAndFloorGas::new_with_state_gas( + evm_initial_gas, + init_and_floor_gas.initial_state_gas, + init_and_floor_gas.floor_gas, + ) } else { *init_and_floor_gas } @@ -309,6 +313,7 @@ where let gas_limit = evm.ctx().tx().gas_limit(); let mut remaining_gas = gas_limit - init_and_floor_gas.initial_total_gas; let mut accumulated_gas_refund = 0i64; + let mut accumulated_state_gas_spent = 0u64; // Store original TxEnv values to restore after batch execution let original_kind = evm.ctx().tx().kind(); @@ -376,27 +381,32 @@ where // Include gas from all previous successful calls + failed call let gas_spent_by_failed_call = frame_result.gas().spent(); + let failed_call_state_gas = frame_result.gas().state_gas_spent(); let total_gas_spent = (gas_limit - remaining_gas) + gas_spent_by_failed_call; + let total_state_gas = + accumulated_state_gas_spent.saturating_add(failed_call_state_gas); - // Create new Gas with correct limit, because Gas does not have a set_limit method - // (the frame_result has the limit from just the last call) - let mut corrected_gas = Gas::new(gas_limit); + // Use flattened gas reconstruction (Gas::new_spent + erase_cost) for robustness + // under the EIP-8037 reservoir model. This avoids ambiguity from Gas::new's + // reservoir initialization. + let mut corrected_gas = Gas::new_spent(gas_limit); if instruction_result.is_revert() { - corrected_gas.set_spent(total_gas_spent); - } else { - corrected_gas.spend_all(); + corrected_gas.erase_cost(gas_limit - total_gas_spent); } corrected_gas.set_refund(0); // No refunds when batch fails and all state is reverted + corrected_gas.set_state_gas_spent(total_state_gas); *frame_result.gas_mut() = corrected_gas; return Ok(frame_result); } - // Call succeeded - accumulate gas usage and refunds + // Call succeeded - accumulate gas usage, refunds, and state gas let gas_spent = frame_result.gas().spent(); let gas_refunded = frame_result.gas().refunded(); accumulated_gas_refund = accumulated_gas_refund.saturating_add(gas_refunded); + accumulated_state_gas_spent = + accumulated_state_gas_spent.saturating_add(frame_result.gas().state_gas_spent()); // Subtract only execution gas (intrinsic gas already deducted upfront) remaining_gas = remaining_gas.saturating_sub(gas_spent); @@ -412,11 +422,12 @@ where let total_gas_spent = gas_limit - remaining_gas; - // Create new Gas with correct limit, because Gas does not have a set_limit method - // (the frame_result has the limit from just the last call) - let mut corrected_gas = Gas::new(gas_limit); - corrected_gas.set_spent(total_gas_spent); + // Use flattened gas reconstruction (Gas::new_spent + erase_cost) for robustness + // under the EIP-8037 reservoir model, and preserve accumulated state_gas_spent. + let mut corrected_gas = Gas::new_spent(gas_limit); + corrected_gas.erase_cost(gas_limit - total_gas_spent); corrected_gas.set_refund(accumulated_gas_refund); + corrected_gas.set_state_gas_spent(accumulated_state_gas_spent); *result.gas_mut() = corrected_gas; Ok(result) @@ -1384,6 +1395,10 @@ pub fn calculate_aa_batch_intrinsic_gas<'a>( // EIP-3860: Initcode analysis gas using revm helper gas.initial_total_gas += gas_params.tx_initcode_cost(call.input.len()); + + // TIP-1016: Track predictable state gas for CREATE calls + gas.initial_state_gas += + gas_params.new_account_state_gas() + gas_params.create_state_gas(); } // Note: Transaction value is not allowed in AA transactions as there is no balances in accounts yet. @@ -1661,7 +1676,10 @@ pub fn validate_time_window( #[cfg(test)] mod tests { use super::*; - use crate::{TempoBlockEnv, TempoTxEnv, evm::TempoEvm, tx::TempoBatchCallEnv}; + use crate::{ + TempoBlockEnv, TempoTxEnv, evm::TempoEvm, gas_params::tempo_gas_params, + tx::TempoBatchCallEnv, + }; use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; use proptest::prelude::*; use revm::{ @@ -3263,4 +3281,475 @@ mod tests { "Difference between existing 2D nonce and regular nonce should be EXISTING_NONCE_KEY_GAS ({EXISTING_NONCE_KEY_GAS})" ); } + + /// TIP-1016: Standard CREATE tx should populate initial_state_gas with + /// new_account_state_gas + create_state_gas when state gas is enabled (T2+). + #[test] + fn test_state_gas_standard_create_tx_populates_initial_state_gas() { + let gas_params = tempo_gas_params(TempoHardfork::T2); + let initcode = Bytes::from(vec![0x60, 0x80]); + + let init_gas = gas_params.initial_tx_gas( + &initcode, true, // is_create + 0, 0, 0, + ); + + let expected_state_gas = gas_params.new_account_state_gas() + gas_params.create_state_gas(); + + assert!( + expected_state_gas > 0, + "State gas constants should be non-zero" + ); + assert_eq!( + init_gas.initial_state_gas, + expected_state_gas, + "CREATE tx should have initial_state_gas = new_account_state_gas ({}) + create_state_gas ({})", + gas_params.new_account_state_gas(), + gas_params.create_state_gas() + ); + } + + /// TIP-1016: Standard CALL tx should have zero initial_state_gas. + #[test] + fn test_state_gas_standard_call_tx_zero_initial_state_gas() { + let gas_params = tempo_gas_params(TempoHardfork::T2); + let calldata = Bytes::from(vec![1, 2, 3]); + + let init_gas = gas_params.initial_tx_gas( + &calldata, false, // not create + 0, 0, 0, + ); + + assert_eq!( + init_gas.initial_state_gas, 0, + "CALL tx should have zero initial_state_gas" + ); + } + + /// TIP-1016: AA CREATE tx should populate initial_state_gas. + #[test] + fn test_state_gas_aa_create_tx_populates_initial_state_gas() { + let gas_params = tempo_gas_params(TempoHardfork::T2); + let initcode = Bytes::from(vec![0x60, 0x80]); + + let call = Call { + to: TxKind::Create, + value: U256::ZERO, + input: initcode, + }; + + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + aa_calls: vec![call], + key_authorization: None, + signature_hash: B256::ZERO, + ..Default::default() + }; + + let gas = calculate_aa_batch_intrinsic_gas( + &aa_env, + &gas_params, + None::>, + ) + .unwrap(); + + let expected_state_gas = gas_params.new_account_state_gas() + gas_params.create_state_gas(); + + assert_eq!( + gas.initial_state_gas, expected_state_gas, + "AA CREATE tx should have initial_state_gas = new_account_state_gas + create_state_gas" + ); + } + + /// TIP-1016: AA CALL tx should have zero initial_state_gas. + #[test] + fn test_state_gas_aa_call_tx_zero_initial_state_gas() { + let gas_params = tempo_gas_params(TempoHardfork::T2); + let calldata = Bytes::from(vec![1, 2, 3]); + + let call = Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: calldata, + }; + + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + aa_calls: vec![call], + key_authorization: None, + signature_hash: B256::ZERO, + ..Default::default() + }; + + let gas = calculate_aa_batch_intrinsic_gas( + &aa_env, + &gas_params, + None::>, + ) + .unwrap(); + + assert_eq!( + gas.initial_state_gas, 0, + "AA CALL tx should have zero initial_state_gas" + ); + } + + /// TIP-1016: validate_initial_tx_gas for standard CREATE tx should set + /// initial_state_gas when T2 is active and state gas is enabled. + #[test] + fn test_state_gas_validate_initial_tx_gas_create_t2() { + let mut cfg = CfgEnv::::default(); + cfg.spec = TempoHardfork::T2; + cfg.gas_params = tempo_gas_params(TempoHardfork::T2); + cfg.enable_state_gas = true; + + let initcode = Bytes::from(vec![0x60, 0x80]); + + let journal = Journal::new(CacheDB::new(EmptyDB::default())); + let tx_env = TempoTxEnv { + inner: revm::context::TxEnv { + gas_limit: 60_000_000, // Above cap to test cap bypass + kind: TxKind::Create, + data: initcode, + ..Default::default() + }, + ..Default::default() + }; + + let ctx = Context::mainnet() + .with_db(CacheDB::new(EmptyDB::default())) + .with_block(TempoBlockEnv::default()) + .with_cfg(cfg.clone()) + .with_tx(tx_env) + .with_new_journal(journal); + let mut evm = TempoEvm::<_, ()>::new(ctx, ()); + let handler: TempoEvmHandler, ()> = TempoEvmHandler::new(); + + let init_gas = handler.validate_initial_tx_gas(&mut evm).unwrap(); + + let expected_state_gas = + cfg.gas_params.new_account_state_gas() + cfg.gas_params.create_state_gas(); + + assert_eq!( + init_gas.initial_state_gas, expected_state_gas, + "T2 CREATE tx should track initial_state_gas" + ); + } + + /// TIP-1016: When enable_state_gas is true, tx gas limit can exceed the cap + /// (upstream revm validation skips the cap check). + #[test] + fn test_state_gas_tx_gas_limit_above_cap_allowed() { + let mut cfg = CfgEnv::::default(); + cfg.spec = TempoHardfork::T2; + cfg.gas_params = tempo_gas_params(TempoHardfork::T2); + cfg.enable_state_gas = true; + cfg.tx_gas_limit_cap = Some(30_000_000); + + let calldata = Bytes::from(vec![1, 2, 3]); + + let journal = Journal::new(CacheDB::new(EmptyDB::default())); + let tx_env = TempoTxEnv { + inner: revm::context::TxEnv { + gas_limit: 60_000_000, // Double the cap + kind: TxKind::Call(Address::random()), + data: calldata, + ..Default::default() + }, + ..Default::default() + }; + + let ctx = Context::mainnet() + .with_db(CacheDB::new(EmptyDB::default())) + .with_block(TempoBlockEnv::default()) + .with_cfg(cfg) + .with_tx(tx_env) + .with_new_journal(journal); + let mut evm = TempoEvm::<_, ()>::new(ctx, ()); + let handler: TempoEvmHandler, ()> = TempoEvmHandler::new(); + + // validate_env should pass even though gas_limit > cap + let result = handler.validate_env(&mut evm); + assert!( + result.is_ok(), + "With enable_state_gas=true, tx gas limit above cap should be allowed, got: {:?}", + result.err() + ); + } + + /// TIP-1016: When enable_state_gas is false (pre-T2), tx gas limit above cap is rejected. + #[test] + fn test_state_gas_tx_gas_limit_above_cap_rejected_pre_t2() { + let mut cfg = CfgEnv::::default(); + cfg.spec = TempoHardfork::T1; + cfg.gas_params = tempo_gas_params(TempoHardfork::T1); + cfg.enable_state_gas = false; + cfg.tx_gas_limit_cap = Some(30_000_000); + + let calldata = Bytes::from(vec![1, 2, 3]); + + let journal = Journal::new(CacheDB::new(EmptyDB::default())); + let tx_env = TempoTxEnv { + inner: revm::context::TxEnv { + gas_limit: 60_000_000, // Double the cap + kind: TxKind::Call(Address::random()), + data: calldata, + ..Default::default() + }, + ..Default::default() + }; + + let ctx = Context::mainnet() + .with_db(CacheDB::new(EmptyDB::default())) + .with_block(TempoBlockEnv::default()) + .with_cfg(cfg) + .with_tx(tx_env) + .with_new_journal(journal); + let mut evm = TempoEvm::<_, ()>::new(ctx, ()); + let handler: TempoEvmHandler, ()> = TempoEvmHandler::new(); + + // validate_env should reject: gas_limit > cap with state gas disabled + let result = handler.validate_env(&mut evm); + assert!( + result.is_err(), + "With enable_state_gas=false, tx gas limit above cap should be rejected" + ); + } + + /// TIP-1016: Pre-T2 behavior unchanged - initial_state_gas is still populated + /// by upstream revm for CREATE txs (it's a property of gas_params, not gating). + /// But enable_state_gas=false means the reservoir won't be used. + #[test] + fn test_state_gas_backward_compat_t1_no_state_gas_enabled() { + let mut cfg = CfgEnv::::default(); + cfg.spec = TempoHardfork::T1; + cfg.gas_params = tempo_gas_params(TempoHardfork::T1); + + assert!( + !cfg.enable_state_gas, + "Pre-T2 should NOT have enable_state_gas" + ); + + let calldata = Bytes::from(vec![1, 2, 3]); + + let journal = Journal::new(CacheDB::new(EmptyDB::default())); + let tx_env = TempoTxEnv { + inner: revm::context::TxEnv { + gas_limit: 1_000_000, + kind: TxKind::Call(Address::random()), + data: calldata, + ..Default::default() + }, + ..Default::default() + }; + + let ctx = Context::mainnet() + .with_db(CacheDB::new(EmptyDB::default())) + .with_block(TempoBlockEnv::default()) + .with_cfg(cfg) + .with_tx(tx_env) + .with_new_journal(journal); + let mut evm = TempoEvm::<_, ()>::new(ctx, ()); + let handler: TempoEvmHandler, ()> = TempoEvmHandler::new(); + + let init_gas = handler.validate_initial_tx_gas(&mut evm).unwrap(); + + // CALL tx - no state gas in either case + assert_eq!(init_gas.initial_state_gas, 0); + } + + /// TIP-1016: AA batch with multiple calls including CREATE should track + /// state gas for the CREATE call only. + #[test] + fn test_state_gas_aa_mixed_batch_create_and_call() { + let gas_params = tempo_gas_params(TempoHardfork::T2); + let calldata = Bytes::from(vec![1, 2, 3]); + let initcode = Bytes::from(vec![0x60, 0x80]); + + let calls = vec![ + Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: calldata, + }, + Call { + to: TxKind::Create, + value: U256::ZERO, + input: initcode, + }, + ]; + + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + aa_calls: calls, + key_authorization: None, + signature_hash: B256::ZERO, + ..Default::default() + }; + + let gas = calculate_aa_batch_intrinsic_gas( + &aa_env, + &gas_params, + None::>, + ) + .unwrap(); + + // Only the CREATE call contributes state gas + let expected_state_gas = gas_params.new_account_state_gas() + gas_params.create_state_gas(); + + assert_eq!( + gas.initial_state_gas, expected_state_gas, + "Mixed batch should have state gas only from CREATE call" + ); + } + + /// TIP-1016: adjusted_initial_gas must preserve initial_state_gas. + #[test] + fn test_state_gas_adjusted_initial_gas_preserves_state_gas() { + let init = InitialAndFloorGas::new_with_state_gas(100_000, 57_000, 21_000); + + // T2: adjusted_initial_gas should preserve initial_state_gas + let adjusted = adjusted_initial_gas(TempoHardfork::T2, 100_000, &init); + assert_eq!( + adjusted.initial_state_gas, 57_000, + "adjusted_initial_gas must preserve initial_state_gas for T2" + ); + + // T1: adjusted_initial_gas should also preserve initial_state_gas + let adjusted_t1 = adjusted_initial_gas(TempoHardfork::T1, 100_000, &init); + assert_eq!( + adjusted_t1.initial_state_gas, 57_000, + "adjusted_initial_gas must preserve initial_state_gas for T1" + ); + } + + /// TIP-1016: AA batch with multiple CREATE calls accumulates state gas. + #[test] + fn test_state_gas_aa_multiple_create_calls() { + let gas_params = tempo_gas_params(TempoHardfork::T2); + let initcode = Bytes::from(vec![0x60, 0x80]); + + let calls = vec![ + Call { + to: TxKind::Create, + value: U256::ZERO, + input: initcode.clone(), + }, + Call { + to: TxKind::Create, + value: U256::ZERO, + input: initcode, + }, + ]; + + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + aa_calls: calls, + key_authorization: None, + signature_hash: B256::ZERO, + ..Default::default() + }; + + let gas = calculate_aa_batch_intrinsic_gas( + &aa_env, + &gas_params, + None::>, + ) + .unwrap(); + + // Two CREATE calls should accumulate state gas + let per_create_state_gas = + gas_params.new_account_state_gas() + gas_params.create_state_gas(); + + assert_eq!( + gas.initial_state_gas, + per_create_state_gas * 2, + "Multiple CREATE calls should accumulate initial_state_gas" + ); + } + + /// TIP-1016: In multi-call execution, per-call init gas uses + /// `InitialAndFloorGas::new(0, 0)` so state gas is only deducted once + /// upfront via `calculate_aa_batch_intrinsic_gas`, not per call. + #[test] + fn test_state_gas_multi_call_per_call_init_has_zero_state_gas() { + let zero_init_gas = InitialAndFloorGas::new(0, 0); + assert_eq!( + zero_init_gas.initial_state_gas, 0, + "Per-call init gas in multi-call must have zero initial_state_gas; \ + state gas is deducted once upfront, not per call" + ); + } + + /// TIP-1016: Multi-call corrected gas (success path) must use flattened + /// reconstruction (Gas::new_spent + erase_cost) to be robust under the + /// EIP-8037 reservoir model, and must preserve accumulated state_gas_spent. + #[test] + fn test_state_gas_multi_call_corrected_gas_success_preserves_state_gas() { + let gas_limit: u64 = 1_000_000; + let total_gas_spent: u64 = 400_000; + let accumulated_state_gas: u64 = 150_000; + let accumulated_refund: i64 = 5_000; + + // Simulate flattened gas reconstruction (same pattern as execute_multi_call_with) + let mut corrected_gas = Gas::new_spent(gas_limit); + corrected_gas.erase_cost(gas_limit - total_gas_spent); + corrected_gas.set_refund(accumulated_refund); + corrected_gas.set_state_gas_spent(accumulated_state_gas); + + assert_eq!( + corrected_gas.spent(), + total_gas_spent, + "Flattened gas must have correct spent" + ); + assert_eq!( + corrected_gas.used(), + total_gas_spent - accumulated_refund as u64, + "Flattened gas must have correct used (spent - refunded)" + ); + assert_eq!( + corrected_gas.state_gas_spent(), + accumulated_state_gas, + "Corrected gas must preserve accumulated state_gas_spent" + ); + assert_eq!( + corrected_gas.reservoir(), + 0, + "Flattened gas must have zero reservoir" + ); + } + + /// TIP-1016: Multi-call corrected gas (failure path) must preserve + /// state_gas_spent from all calls up to and including the failed one. + #[test] + fn test_state_gas_multi_call_corrected_gas_failure_preserves_state_gas() { + let gas_limit: u64 = 1_000_000; + let total_gas_spent: u64 = 600_000; + let accumulated_state_gas: u64 = 200_000; + + // Simulate flattened gas reconstruction on failure (revert case) + let mut corrected_gas = Gas::new_spent(gas_limit); + corrected_gas.erase_cost(gas_limit - total_gas_spent); + corrected_gas.set_refund(0); // No refunds on batch failure + corrected_gas.set_state_gas_spent(accumulated_state_gas); + + assert_eq!( + corrected_gas.spent(), + total_gas_spent, + "Failure path: flattened gas must have correct spent" + ); + assert_eq!( + corrected_gas.state_gas_spent(), + accumulated_state_gas, + "Failure path: corrected gas must preserve state_gas_spent" + ); + } } From 4c393c698f3a291a21dfab1ca63b6877e82d58d7 Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Mon, 16 Feb 2026 20:00:29 +0000 Subject: [PATCH 02/16] feat(evm): add T2 gas params splitting storage creation costs into exec + state gas --- crates/revm/src/gas_params.rs | 166 ++++++++++++++++++++++++++++++---- crates/revm/src/handler.rs | 13 ++- 2 files changed, 158 insertions(+), 21 deletions(-) diff --git a/crates/revm/src/gas_params.rs b/crates/revm/src/gas_params.rs index 558e8d2157..37455409a4 100644 --- a/crates/revm/src/gas_params.rs +++ b/crates/revm/src/gas_params.rs @@ -18,33 +18,163 @@ impl TempoGasParams for GasParams { } } +// TIP-1000 total gas costs (used by both T1 and T2) +const SSTORE_SET_COST: u64 = 250_000; +const CREATE_COST: u64 = 500_000; +const NEW_ACCOUNT_COST: u64 = 250_000; +const CODE_DEPOSIT_COST_T1: u64 = 1_000; +const CODE_DEPOSIT_COST_T2: u64 = 2_500; +const AUTH_ACCOUNT_CREATION_COST: u64 = 250_000; +const EIP7702_PER_EMPTY_ACCOUNT_COST: u64 = 12_500; + +// T2 execution gas (computational overhead only) +const T2_EXEC_GAS: u64 = 5_000; +const T2_CODE_DEPOSIT_EXEC_GAS: u64 = 200; + /// Tempo gas params override. #[inline] pub fn tempo_gas_params(spec: TempoHardfork) -> GasParams { let mut gas_params = GasParams::new_spec(spec.into()); let mut overrides = vec![]; - if spec.is_t1() { + if spec.is_t2() { + // TIP-1016: Split storage creation costs into execution gas + state gas. + // Execution gas (computational overhead) stays in regular params. + // Storage creation gas (permanent storage burden) moves to state gas params. + overrides.extend([ + (GasId::sstore_set_without_load_cost(), T2_EXEC_GAS), + (GasId::sstore_set_state_gas(), SSTORE_SET_COST - T2_EXEC_GAS), + (GasId::tx_create_cost(), T2_EXEC_GAS), + (GasId::create(), T2_EXEC_GAS), + (GasId::create_state_gas(), CREATE_COST - T2_EXEC_GAS), + (GasId::new_account_cost(), T2_EXEC_GAS), + ( + GasId::new_account_state_gas(), + NEW_ACCOUNT_COST - T2_EXEC_GAS, + ), + (GasId::new_account_cost_for_selfdestruct(), T2_EXEC_GAS), + (GasId::code_deposit_cost(), T2_CODE_DEPOSIT_EXEC_GAS), + ( + GasId::code_deposit_state_gas(), + CODE_DEPOSIT_COST_T2 - T2_CODE_DEPOSIT_EXEC_GAS, + ), + ( + GasId::tx_eip7702_per_empty_account_cost(), + EIP7702_PER_EMPTY_ACCOUNT_COST, + ), + (GasId::new(255), T2_EXEC_GAS), + ]); + } else if spec.is_t1() { + // TIP-1000: All storage creation costs in regular gas (no state gas split). overrides.extend([ - // storage set with SSTORE opcode. - (GasId::sstore_set_without_load_cost(), 250_000), - // Base cost of Create kind transaction. - (GasId::tx_create_cost(), 500_000), - // create cost for CREATE/CREATE2 opcodes. - (GasId::create(), 500_000), - // new account cost for new accounts. - (GasId::new_account_cost(), 250_000), - // Selfdestruct will not be possible to create new account as this can only be - // done when account value is not zero. - (GasId::new_account_cost_for_selfdestruct(), 250_000), - // code deposit cost is 1000 per byte. - (GasId::code_deposit_cost(), 1_000), - // The base cost per authorization is reduced to 12,500 gas - (GasId::tx_eip7702_per_empty_account_cost(), 12500), - // Auth account creation cost. - (GasId::new(255), 250_000), + (GasId::sstore_set_without_load_cost(), SSTORE_SET_COST), + (GasId::tx_create_cost(), CREATE_COST), + (GasId::create(), CREATE_COST), + (GasId::new_account_cost(), NEW_ACCOUNT_COST), + (GasId::new_account_cost_for_selfdestruct(), NEW_ACCOUNT_COST), + (GasId::code_deposit_cost(), CODE_DEPOSIT_COST_T1), + ( + GasId::tx_eip7702_per_empty_account_cost(), + EIP7702_PER_EMPTY_ACCOUNT_COST, + ), + (GasId::new(255), AUTH_ACCOUNT_CREATION_COST), ]); } gas_params.override_gas(overrides); gas_params } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_t1_gas_params_no_state_gas_split() { + let gas_params = tempo_gas_params(TempoHardfork::T1); + + // T1 has full 250k costs in regular gas, no state gas split + assert_eq!( + gas_params.get(GasId::sstore_set_without_load_cost()), + 250_000 + ); + assert_eq!(gas_params.get(GasId::new_account_cost()), 250_000); + assert_eq!(gas_params.get(GasId::tx_create_cost()), 500_000); + assert_eq!(gas_params.get(GasId::create()), 500_000); + assert_eq!(gas_params.get(GasId::code_deposit_cost()), 1_000); + + // State gas params should remain at upstream defaults (not Tempo-bumped) + let upstream = GasParams::new_spec(TempoHardfork::T1.into()); + assert_eq!( + gas_params.get(GasId::sstore_set_state_gas()), + upstream.get(GasId::sstore_set_state_gas()), + "T1 should not override state gas params" + ); + assert_eq!( + gas_params.get(GasId::new_account_state_gas()), + upstream.get(GasId::new_account_state_gas()), + ); + assert_eq!( + gas_params.get(GasId::create_state_gas()), + upstream.get(GasId::create_state_gas()), + ); + } + + #[test] + fn test_t2_gas_params_splits_storage_costs() { + let gas_params = tempo_gas_params(TempoHardfork::T2); + + // T2 execution gas (computational overhead only) + assert_eq!(gas_params.get(GasId::sstore_set_without_load_cost()), 5_000); + assert_eq!(gas_params.get(GasId::new_account_cost()), 5_000); + assert_eq!( + gas_params.get(GasId::new_account_cost_for_selfdestruct()), + 5_000 + ); + assert_eq!(gas_params.get(GasId::tx_create_cost()), 5_000); + assert_eq!(gas_params.get(GasId::create()), 5_000); + assert_eq!(gas_params.get(GasId::code_deposit_cost()), 200); + + // T2 state gas (storage creation burden) + assert_eq!(gas_params.get(GasId::sstore_set_state_gas()), 245_000); + assert_eq!(gas_params.get(GasId::new_account_state_gas()), 245_000); + assert_eq!(gas_params.get(GasId::create_state_gas()), 495_000); + assert_eq!(gas_params.get(GasId::code_deposit_state_gas()), 2_300); + + // Auth account creation also split + assert_eq!(gas_params.get(GasId::new(255)), 5_000); + } + + #[test] + fn test_t2_total_gas_matches_t1() { + let t1 = tempo_gas_params(TempoHardfork::T1); + let t2 = tempo_gas_params(TempoHardfork::T2); + + // SSTORE set: exec + state should equal T1 total + assert_eq!( + t2.get(GasId::sstore_set_without_load_cost()) + t2.get(GasId::sstore_set_state_gas()), + t1.get(GasId::sstore_set_without_load_cost()), + "SSTORE set: T2 exec + state must equal T1 total" + ); + + // New account: exec + state should equal T1 total + assert_eq!( + t2.get(GasId::new_account_cost()) + t2.get(GasId::new_account_state_gas()), + t1.get(GasId::new_account_cost()), + "new_account: T2 exec + state must equal T1 total" + ); + + // CREATE: exec + state should equal T1 total + assert_eq!( + t2.get(GasId::create()) + t2.get(GasId::create_state_gas()), + t1.get(GasId::create()), + "CREATE: T2 exec + state must equal T1 total" + ); + + // Code deposit: T2 total is higher (2,500/byte vs 1,000/byte) per TIP-1016 + assert_eq!( + t2.get(GasId::code_deposit_cost()) + t2.get(GasId::code_deposit_state_gas()), + 2_500, + "code_deposit: T2 total should be 2,500/byte per TIP-1016" + ); + } +} diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index a5e0edee66..f8bd60799c 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -2516,11 +2516,18 @@ mod tests { }; let cases = if spec.is_t0() { - vec![ - (BASE_INTRINSIC_GAS + 10_000, 0u64, false), // Insufficient for nonce==0 + let mut cases = vec![ (BASE_INTRINSIC_GAS + nonce_zero_gas, 0, true), // Exactly sufficient for nonce==0 (BASE_INTRINSIC_GAS + spec.gas_existing_nonce_key(), 1, true), // Exactly sufficient for existing key - ] + ]; + // Only test "insufficient" case when nonce_zero_gas > 10k (T1 has 250k, T2 has 5k) + if nonce_zero_gas > 10_000 { + cases.push((BASE_INTRINSIC_GAS + 10_000, 0u64, false)); // Insufficient for nonce==0 + } else { + // T2: nonce_zero_gas is 5k, so BASE + nonce_zero_gas - 1 is insufficient + cases.push((BASE_INTRINSIC_GAS + nonce_zero_gas - 1, 0u64, false)); + } + cases } else { // Genesis: nonce gas is added AFTER validation, so lower gas_limit still passes vec![ From 499e2c80c6312ef3c9383d8c418cff319d009d40 Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Mon, 16 Feb 2026 20:03:39 +0000 Subject: [PATCH 03/16] fix(evm): don't charge state gas for failed calls on revert/halt --- crates/revm/src/handler.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index f8bd60799c..6d8745e831 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -381,10 +381,11 @@ where // Include gas from all previous successful calls + failed call let gas_spent_by_failed_call = frame_result.gas().spent(); - let failed_call_state_gas = frame_result.gas().state_gas_spent(); let total_gas_spent = (gas_limit - remaining_gas) + gas_spent_by_failed_call; - let total_state_gas = - accumulated_state_gas_spent.saturating_add(failed_call_state_gas); + // State gas only applies to successful calls that create state. + // On revert/halt no new state is created, so the failed call's + // state gas is not charged. + let total_state_gas = accumulated_state_gas_spent; // Use flattened gas reconstruction (Gas::new_spent + erase_cost) for robustness // under the EIP-8037 reservoir model. This avoids ambiguity from Gas::new's From 8695ba66f18b0ff6ac99018a11c3f90fa3589a7c Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Mon, 16 Feb 2026 20:27:06 +0000 Subject: [PATCH 04/16] fix(evm): track initial_state_gas for all state-creating operations --- crates/revm/src/evm.rs | 6 + crates/revm/src/gas_params.rs | 13 ++ crates/revm/src/handler.rs | 279 ++++++++++++++++++++++++++++++++-- 3 files changed, 289 insertions(+), 9 deletions(-) diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index b067f340ed..e06646c2fa 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -39,6 +39,11 @@ pub struct TempoEvm { /// /// Additional initial gas cost is added for authorization_key setting in pre execution. pub(crate) initial_gas: u64, + /// TIP-1016: Additional initial state gas accumulated after validate_initial_tx_gas. + /// + /// Tracks state gas from runtime checks in validate_against_state_and_deduct_caller + /// (e.g., 2D nonce + CREATE + caller nonce == 0) that can't be determined upfront. + pub(crate) initial_state_gas: u64, } impl TempoEvm { @@ -73,6 +78,7 @@ impl TempoEvm { logs: Vec::new(), collected_fee: U256::ZERO, initial_gas: 0, + initial_state_gas: 0, } } } diff --git a/crates/revm/src/gas_params.rs b/crates/revm/src/gas_params.rs index 37455409a4..7c533a24d1 100644 --- a/crates/revm/src/gas_params.rs +++ b/crates/revm/src/gas_params.rs @@ -10,6 +10,10 @@ pub trait TempoGasParams { fn tx_tip1000_auth_account_creation_cost(&self) -> u64 { self.gas_params().get(GasId::new(255)) } + + fn tx_tip1000_auth_account_creation_state_gas(&self) -> u64 { + self.gas_params().get(GasId::new(254)) + } } impl TempoGasParams for GasParams { @@ -62,6 +66,7 @@ pub fn tempo_gas_params(spec: TempoHardfork) -> GasParams { EIP7702_PER_EMPTY_ACCOUNT_COST, ), (GasId::new(255), T2_EXEC_GAS), + (GasId::new(254), AUTH_ACCOUNT_CREATION_COST - T2_EXEC_GAS), ]); } else if spec.is_t1() { // TIP-1000: All storage creation costs in regular gas (no state gas split). @@ -142,6 +147,7 @@ mod tests { // Auth account creation also split assert_eq!(gas_params.get(GasId::new(255)), 5_000); + assert_eq!(gas_params.get(GasId::new(254)), 245_000); } #[test] @@ -176,5 +182,12 @@ mod tests { 2_500, "code_deposit: T2 total should be 2,500/byte per TIP-1016" ); + + // Auth account creation: exec + state should equal T1 total + assert_eq!( + t2.get(GasId::new(255)) + t2.get(GasId::new(254)), + t1.get(GasId::new(255)), + "auth_account_creation: T2 exec + state must equal T1 total" + ); } } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 6d8745e831..86a3d4fcb9 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -157,16 +157,20 @@ fn calculate_key_authorization_gas( /// For pre-T1: Uses `init_and_floor_gas` directly to maintain backward compatibility, /// since pre-T1 doesn't have key_authorization gas tracking and Genesis has special /// handling where nonce_2d_gas is added to init_and_floor_gas but not to evm.initial_gas. +/// +/// `evm_initial_state_gas` captures additional state gas from runtime checks in +/// `validate_against_state_and_deduct_caller` (e.g., 2D nonce + CREATE + caller nonce == 0). #[inline] fn adjusted_initial_gas( spec: tempo_chainspec::hardfork::TempoHardfork, evm_initial_gas: u64, + evm_initial_state_gas: u64, init_and_floor_gas: &InitialAndFloorGas, ) -> InitialAndFloorGas { if spec.is_t1() { InitialAndFloorGas::new_with_state_gas( evm_initial_gas, - init_and_floor_gas.initial_state_gas, + init_and_floor_gas.initial_state_gas + evm_initial_state_gas, init_and_floor_gas.floor_gas, ) } else { @@ -521,7 +525,12 @@ where init_and_floor_gas: &InitialAndFloorGas, ) -> Result { let spec = evm.ctx_ref().cfg().spec(); - let adjusted_gas = adjusted_initial_gas(*spec, evm.initial_gas, init_and_floor_gas); + let adjusted_gas = adjusted_initial_gas( + *spec, + evm.initial_gas, + evm.initial_state_gas, + init_and_floor_gas, + ); let tx = evm.tx(); if let Some(oog) = check_gas_limit(*spec, tx, &adjusted_gas) { @@ -666,6 +675,10 @@ where // This case would create a new account for caller. if !nonce_key.is_zero() && tx.kind().is_create() && caller_account.nonce() == 0 { evm.initial_gas += cfg.gas_params().get(GasId::new_account_cost()); + // TIP-1016: Track state gas for new account creation (T2+ only) + if spec.is_t2() { + evm.initial_state_gas += cfg.gas_params().new_account_state_gas(); + } // do the gas limit check again. if tx.gas_limit() < evm.initial_gas { @@ -1249,6 +1262,9 @@ where if auth.nonce == 0 { init_gas.initial_total_gas += gas_params.tx_tip1000_auth_account_creation_cost(); + // TIP-1016: Track state gas for auth account creation + init_gas.initial_state_gas += + gas_params.tx_tip1000_auth_account_creation_state_gas(); } } @@ -1256,6 +1272,10 @@ where // Transactions with any `nonce_key` and `nonce == 0` require an additional 250,000 gas. if spec.is_t1() && tx.nonce == 0 { init_gas.initial_total_gas += gas_params.get(GasId::new_account_cost()); + // TIP-1016: Track state gas for new account creation (T2+ only) + if spec.is_t2() { + init_gas.initial_state_gas += gas_params.new_account_state_gas(); + } } if evm.ctx.cfg.is_eip7623_disabled() { @@ -1373,6 +1393,8 @@ pub fn calculate_aa_batch_intrinsic_gas<'a>( // EIP-7702 authorisation list entries with `auth_list.nonce == 0` require an additional 250,000 gas. if auth.nonce == 0 { gas.initial_total_gas += gas_params.tx_tip1000_auth_account_creation_cost(); + // TIP-1016: Track state gas for auth account creation + gas.initial_state_gas += gas_params.tx_tip1000_auth_account_creation_state_gas(); } } @@ -1482,6 +1504,10 @@ where // TIP-1000: Storage pricing updates for launch // Tempo transactions with any `nonce_key` and `nonce == 0` require an additional 250,000 gas batch_gas.initial_total_gas += gas_params.get(GasId::new_account_cost()); + // TIP-1016: Track state gas for new account creation (T2+ only) + if spec.is_t2() { + batch_gas.initial_state_gas += gas_params.new_account_state_gas(); + } } else if !aa_env.nonce_key.is_zero() { // Existing 2D nonce key usage (nonce > 0) // TIP-1000 Invariant 3: existing state updates must charge +5,000 gas @@ -1589,7 +1615,12 @@ where init_and_floor_gas: &InitialAndFloorGas, ) -> Result { let spec = evm.ctx_ref().cfg().spec(); - let adjusted_gas = adjusted_initial_gas(*spec, evm.initial_gas, init_and_floor_gas); + let adjusted_gas = adjusted_initial_gas( + *spec, + evm.initial_gas, + evm.initial_state_gas, + init_and_floor_gas, + ); let tx = evm.tx(); @@ -1695,7 +1726,7 @@ mod tests { use tempo_contracts::precompiles::DEFAULT_FEE_TOKEN; use tempo_precompiles::{PATH_USD_ADDRESS, TIP_FEE_MANAGER_ADDRESS}; use tempo_primitives::transaction::{ - Call, TempoSignature, + Call, RecoveredTempoAuthorization, TempoSignature, TempoSignedAuthorization, tt_signature::{P256SignatureWithPreHash, WebAuthnSignature}, }; @@ -3439,12 +3470,14 @@ mod tests { let init_gas = handler.validate_initial_tx_gas(&mut evm).unwrap(); - let expected_state_gas = - cfg.gas_params.new_account_state_gas() + cfg.gas_params.create_state_gas(); + // CREATE state gas + nonce==0 new account state gas (two different accounts) + let expected_state_gas = cfg.gas_params.new_account_state_gas() + + cfg.gas_params.create_state_gas() + + cfg.gas_params.new_account_state_gas(); assert_eq!( init_gas.initial_state_gas, expected_state_gas, - "T2 CREATE tx should track initial_state_gas" + "T2 CREATE tx with nonce==0 should track state gas for both CREATE target and caller" ); } @@ -3623,18 +3656,26 @@ mod tests { let init = InitialAndFloorGas::new_with_state_gas(100_000, 57_000, 21_000); // T2: adjusted_initial_gas should preserve initial_state_gas - let adjusted = adjusted_initial_gas(TempoHardfork::T2, 100_000, &init); + let adjusted = adjusted_initial_gas(TempoHardfork::T2, 100_000, 0, &init); assert_eq!( adjusted.initial_state_gas, 57_000, "adjusted_initial_gas must preserve initial_state_gas for T2" ); // T1: adjusted_initial_gas should also preserve initial_state_gas - let adjusted_t1 = adjusted_initial_gas(TempoHardfork::T1, 100_000, &init); + let adjusted_t1 = adjusted_initial_gas(TempoHardfork::T1, 100_000, 0, &init); assert_eq!( adjusted_t1.initial_state_gas, 57_000, "adjusted_initial_gas must preserve initial_state_gas for T1" ); + + // T2: evm_initial_state_gas should be added to init_and_floor_gas.initial_state_gas + let adjusted_with_extra = adjusted_initial_gas(TempoHardfork::T2, 100_000, 245_000, &init); + assert_eq!( + adjusted_with_extra.initial_state_gas, + 57_000 + 245_000, + "adjusted_initial_gas must add evm_initial_state_gas for T2" + ); } /// TIP-1016: AA batch with multiple CREATE calls accumulates state gas. @@ -3760,4 +3801,224 @@ mod tests { "Failure path: corrected gas must preserve state_gas_spent" ); } + + /// TIP-1016: AA auth list entries with nonce==0 should track state gas. + #[test] + fn test_state_gas_aa_auth_list_nonce_zero() { + let gas_params = tempo_gas_params(TempoHardfork::T2); + + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::from(vec![1, 2, 3]), + }], + tempo_authorization_list: vec![RecoveredTempoAuthorization::new( + TempoSignedAuthorization::new_unchecked( + alloy_eips::eip7702::Authorization { + chain_id: U256::from(1), + address: Address::random(), + nonce: 0, + }, + TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + ), + )], + ..Default::default() + }; + + let gas = calculate_aa_batch_intrinsic_gas( + &aa_env, + &gas_params, + None::>, + ) + .unwrap(); + + assert_eq!( + gas.initial_state_gas, + gas_params.tx_tip1000_auth_account_creation_state_gas(), + "Auth list entry with nonce==0 should track state gas" + ); + } + + /// TIP-1016: AA nonce==0 new account should track state gas in T2. + #[test] + fn test_state_gas_aa_nonce_zero_new_account() { + let gas_params = tempo_gas_params(TempoHardfork::T2); + + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::from(vec![1, 2, 3]), + }], + nonce_key: U256::from(1), + ..Default::default() + }; + + let tx_env = TempoTxEnv { + inner: revm::context::TxEnv { + gas_limit: 60_000_000, + nonce: 0, + ..Default::default() + }, + tempo_tx_env: Some(Box::new(aa_env)), + ..Default::default() + }; + + let mut cfg = CfgEnv::::default(); + cfg.spec = TempoHardfork::T2; + cfg.gas_params = gas_params.clone(); + cfg.enable_state_gas = true; + + let journal = Journal::new(CacheDB::new(EmptyDB::default())); + let ctx = Context::mainnet() + .with_db(CacheDB::new(EmptyDB::default())) + .with_block(TempoBlockEnv::default()) + .with_cfg(cfg) + .with_tx(tx_env) + .with_new_journal(journal); + let mut evm = TempoEvm::<_, ()>::new(ctx, ()); + let handler: TempoEvmHandler, ()> = TempoEvmHandler::new(); + + let init_gas = handler.validate_initial_tx_gas(&mut evm).unwrap(); + + assert_eq!( + init_gas.initial_state_gas, + gas_params.new_account_state_gas(), + "AA tx with nonce==0 should track new_account_state_gas in T2" + ); + } + + /// TIP-1016: Auth list state gas (GasId 254) must be zero on T1. + #[test] + fn test_state_gas_auth_list_zero_on_t1() { + let gas_params = tempo_gas_params(TempoHardfork::T1); + assert_eq!( + gas_params.tx_tip1000_auth_account_creation_state_gas(), + 0, + "Auth account creation state gas must be zero on T1" + ); + + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::from(vec![1, 2, 3]), + }], + tempo_authorization_list: vec![RecoveredTempoAuthorization::new( + TempoSignedAuthorization::new_unchecked( + alloy_eips::eip7702::Authorization { + chain_id: U256::from(1), + address: Address::random(), + nonce: 0, + }, + TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + ), + )], + ..Default::default() + }; + + let gas = calculate_aa_batch_intrinsic_gas( + &aa_env, + &gas_params, + None::>, + ) + .unwrap(); + + assert_eq!( + gas.initial_state_gas, 0, + "T1 auth list nonce==0 should have zero initial_state_gas" + ); + } + + /// TIP-1016: Standard tx with nonce==0 should track state gas on T2 only. + #[test] + fn test_state_gas_standard_tx_nonce_zero_t2() { + let mut cfg = CfgEnv::::default(); + cfg.spec = TempoHardfork::T2; + cfg.gas_params = tempo_gas_params(TempoHardfork::T2); + cfg.enable_state_gas = true; + + let calldata = Bytes::from(vec![1, 2, 3]); + + let journal = Journal::new(CacheDB::new(EmptyDB::default())); + let tx_env = TempoTxEnv { + inner: revm::context::TxEnv { + gas_limit: 60_000_000, + kind: TxKind::Call(Address::random()), + nonce: 0, + data: calldata, + ..Default::default() + }, + ..Default::default() + }; + + let ctx = Context::mainnet() + .with_db(CacheDB::new(EmptyDB::default())) + .with_block(TempoBlockEnv::default()) + .with_cfg(cfg.clone()) + .with_tx(tx_env) + .with_new_journal(journal); + let mut evm = TempoEvm::<_, ()>::new(ctx, ()); + let handler: TempoEvmHandler, ()> = TempoEvmHandler::new(); + + let init_gas = handler.validate_initial_tx_gas(&mut evm).unwrap(); + + assert_eq!( + init_gas.initial_state_gas, + cfg.gas_params.new_account_state_gas(), + "T2 standard tx with nonce==0 should track new_account_state_gas" + ); + } + + /// TIP-1016: Standard tx with nonce==0 should NOT track state gas on T1. + #[test] + fn test_state_gas_standard_tx_nonce_zero_t1_no_state_gas() { + let mut cfg = CfgEnv::::default(); + cfg.spec = TempoHardfork::T1; + cfg.gas_params = tempo_gas_params(TempoHardfork::T1); + + let calldata = Bytes::from(vec![1, 2, 3]); + + let journal = Journal::new(CacheDB::new(EmptyDB::default())); + let tx_env = TempoTxEnv { + inner: revm::context::TxEnv { + gas_limit: 60_000_000, + kind: TxKind::Call(Address::random()), + nonce: 0, + data: calldata, + ..Default::default() + }, + ..Default::default() + }; + + let ctx = Context::mainnet() + .with_db(CacheDB::new(EmptyDB::default())) + .with_block(TempoBlockEnv::default()) + .with_cfg(cfg) + .with_tx(tx_env) + .with_new_journal(journal); + let mut evm = TempoEvm::<_, ()>::new(ctx, ()); + let handler: TempoEvmHandler, ()> = TempoEvmHandler::new(); + + let init_gas = handler.validate_initial_tx_gas(&mut evm).unwrap(); + + assert_eq!( + init_gas.initial_state_gas, 0, + "T1 standard tx with nonce==0 must NOT track state gas" + ); + } } From 1093a07a185535e9cc6477ca8ad950836a538efe Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Sat, 21 Feb 2026 13:22:35 +0000 Subject: [PATCH 05/16] fix(precompiles): override MODEXP to use Berlin pricing to avoid EIP-7883 Osaka minimum gas increase --- crates/precompiles/src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 3b0acf1a61..ae75c0e60a 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -71,6 +71,15 @@ pub trait Precompile { } pub fn extend_tempo_precompiles(precompiles: &mut PrecompilesMap, cfg: &CfgEnv) { + // Override MODEXP (0x05) to use Berlin pricing instead of Osaka (EIP-7883). + // Tempo maps all hardforks to SpecId::OSAKA, which would use the Osaka MODEXP + // with a higher minimum gas (500 vs 200). This override keeps Berlin pricing + // for all current Tempo hardforks. + let berlin_modexp = revm::precompile::modexp::BERLIN; + let modexp_dyn: DynPrecompile = + (berlin_modexp.id().clone(), *berlin_modexp.precompile()).into(); + precompiles.extend_precompiles([(*berlin_modexp.address(), modexp_dyn)]); + let cfg = cfg.clone(); precompiles.set_precompile_lookup(move |address: &Address| { From 36d910f9562445d2735c46734a9ae43697535773 Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Tue, 17 Feb 2026 16:30:32 +0000 Subject: [PATCH 06/16] feat!(txpool,payload): track intrinsic state gas in pool and exclude it from protocol gas limits (TIP-1016) --- crates/payload/builder/src/lib.rs | 11 +- crates/transaction-pool/src/test_utils.rs | 8 +- crates/transaction-pool/src/transaction.rs | 60 ++++++- crates/transaction-pool/src/validator.rs | 181 ++++++++++++++++++++- 4 files changed, 253 insertions(+), 7 deletions(-) diff --git a/crates/payload/builder/src/lib.rs b/crates/payload/builder/src/lib.rs index 2de73839f3..b1d477db4d 100644 --- a/crates/payload/builder/src/lib.rs +++ b/crates/payload/builder/src/lib.rs @@ -369,16 +369,21 @@ where let execution_start = Instant::now(); while let Some(pool_tx) = best_txs.next() { + // TIP-1016: For T2+ transactions, use execution_gas_limit (gas_limit minus state gas) + // for protocol limit checks. State gas does NOT count against protocol limits. + // For pre-T2, execution_gas_limit() == gas_limit() (state gas is 0). + let tx_execution_gas = pool_tx.transaction.execution_gas_limit(); + // Ensure we still have capacity for this transaction within the non-shared gas limit. // The remaining `shared_gas_limit` is reserved for validator subblocks and must not // be consumed by proposer's pool transactions. - if cumulative_gas_used + pool_tx.gas_limit() > non_shared_gas_limit { + if cumulative_gas_used + tx_execution_gas > non_shared_gas_limit { // Mark this transaction as invalid since it doesn't fit // The iterator will handle lane switching internally when appropriate best_txs.mark_invalid( &pool_tx, &InvalidPoolTransactionError::ExceedsGasLimit( - pool_tx.gas_limit(), + tx_execution_gas, non_shared_gas_limit - cumulative_gas_used, ), ); @@ -388,7 +393,7 @@ where // If the tx is not a payment and will exceed the general gas limit // mark the tx as invalid and continue if !pool_tx.transaction.is_payment() - && non_payment_gas_used + pool_tx.gas_limit() > general_gas_limit + && non_payment_gas_used + tx_execution_gas > general_gas_limit { best_txs.mark_invalid( &pool_tx, diff --git a/crates/transaction-pool/src/test_utils.rs b/crates/transaction-pool/src/test_utils.rs index 0e444e2332..0c84286d0e 100644 --- a/crates/transaction-pool/src/test_utils.rs +++ b/crates/transaction-pool/src/test_utils.rs @@ -132,6 +132,12 @@ impl TxBuilder { self } + /// Set the chain ID. + pub(crate) fn chain_id(mut self, chain_id: u64) -> Self { + self.chain_id = chain_id; + self + } + /// Set the max fee per gas. pub(crate) fn max_fee(mut self, fee: u128) -> Self { self.max_fee_per_gas = fee; @@ -189,7 +195,7 @@ impl TxBuilder { }); let tx = TempoTransaction { - chain_id: 1, + chain_id: self.chain_id, max_priority_fee_per_gas: self.max_priority_fee_per_gas, max_fee_per_gas: self.max_fee_per_gas, gas_limit: self.gas_limit, diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index 42fe2d648b..14ff37e176 100644 --- a/crates/transaction-pool/src/transaction.rs +++ b/crates/transaction-pool/src/transaction.rs @@ -44,6 +44,12 @@ pub struct TempoPooledTransaction { /// `Some(expiry)` for keychain transactions where expiry < u64::MAX (finite expiry). /// `None` for non-keychain transactions or keys that never expire. key_expiry: OnceLock>, + /// Intrinsic state gas (TIP-1016 storage creation gas component). + /// + /// Set during validation for T2+ transactions. The state gas portion does NOT count + /// against protocol limits (block/transaction execution gas caps), only against + /// the user's `gas_limit`. + intrinsic_state_gas: OnceLock, } impl TempoPooledTransaction { @@ -70,6 +76,7 @@ impl TempoPooledTransaction { nonce_key_slot: OnceLock::new(), tx_env: OnceLock::new(), key_expiry: OnceLock::new(), + intrinsic_state_gas: OnceLock::new(), } } @@ -199,6 +206,27 @@ impl TempoPooledTransaction { pub fn key_expiry(&self) -> Option { self.key_expiry.get().copied().flatten() } + + /// Sets the intrinsic state gas for this transaction (TIP-1016). + /// + /// Called during validation for T2+ transactions. State gas is the storage creation + /// gas component that does NOT count against protocol limits. + pub fn set_intrinsic_state_gas(&self, state_gas: u64) { + let _ = self.intrinsic_state_gas.set(state_gas); + } + + /// Returns the intrinsic state gas, or 0 if not set (pre-T2 or not yet validated). + pub fn intrinsic_state_gas(&self) -> u64 { + self.intrinsic_state_gas.get().copied().unwrap_or(0) + } + + /// Returns the execution gas limit (gas_limit minus state gas). + /// + /// For T2+ this is the portion that counts against protocol limits. + /// For pre-T2, state gas is 0, so this equals `gas_limit()`. + pub fn execution_gas_limit(&self) -> u64 { + self.gas_limit().saturating_sub(self.intrinsic_state_gas()) + } } #[derive(Debug, Error)] @@ -931,7 +959,7 @@ mod tests { .build(); // Test various Transaction trait methods - assert_eq!(tx.chain_id(), Some(1)); + assert_eq!(tx.chain_id(), Some(42431)); assert_eq!(tx.nonce(), 0); assert_eq!(tx.gas_limit(), 1_000_000); assert_eq!(tx.max_fee_per_gas(), 20_000_000_000); @@ -950,6 +978,36 @@ mod tests { // PoolTransaction::cost() returns &U256::ZERO for Tempo assert_eq!(*tx.cost(), U256::ZERO); } + + #[test] + fn test_intrinsic_state_gas_defaults_to_zero() { + let tx = TxBuilder::aa(Address::random()) + .gas_limit(1_000_000) + .build(); + + assert_eq!(tx.intrinsic_state_gas(), 0); + assert_eq!(tx.execution_gas_limit(), 1_000_000); + } + + #[test] + fn test_set_intrinsic_state_gas() { + let tx = TxBuilder::aa(Address::random()) + .gas_limit(1_000_000) + .build(); + + tx.set_intrinsic_state_gas(245_000); + assert_eq!(tx.intrinsic_state_gas(), 245_000); + assert_eq!(tx.execution_gas_limit(), 755_000); + } + + #[test] + fn test_execution_gas_limit_saturates() { + let tx = TxBuilder::aa(Address::random()).gas_limit(100).build(); + + // State gas larger than gas_limit should saturate to 0 + tx.set_intrinsic_state_gas(200); + assert_eq!(tx.execution_gas_limit(), 0); + } } // ======================================== diff --git a/crates/transaction-pool/src/validator.rs b/crates/transaction-pool/src/validator.rs index 8ab50f546e..c2197ce055 100644 --- a/crates/transaction-pool/src/validator.rs +++ b/crates/transaction-pool/src/validator.rs @@ -415,6 +415,9 @@ where // TIP-1000: Storage pricing updates for launch // Tempo transactions with any `nonce_key` and `nonce == 0` require an additional 250,000 gas init_and_floor_gas.initial_total_gas += gas_params.get(GasId::new_account_cost()); + // TIP-1016: Track state gas for new account creation + init_and_floor_gas.initial_state_gas += + gas_params.get(GasId::new_account_state_gas()); } else if !tx.nonce_key.is_zero() { // Existing 2D nonce key (nonce > 0): cold SLOAD + warm SSTORE reset // TIP-1000 Invariant 3: existing state updates charge 5,000 gas @@ -433,6 +436,9 @@ where == 0 { init_and_floor_gas.initial_total_gas += gas_params.get(GasId::new_account_cost()); + // TIP-1016: Track state gas for caller account creation + init_and_floor_gas.initial_state_gas += + gas_params.get(GasId::new_account_state_gas()); } } else if !tx.nonce_key.is_zero() { // Pre-T1: Add 2D nonce gas if nonce_key is non-zero @@ -445,6 +451,16 @@ where } } + // TIP-1016: Cache intrinsic state gas for T2+ transactions. + // init_and_floor_gas.initial_state_gas accumulates state gas from: + // - calculate_aa_batch_intrinsic_gas (CREATE calls, auth list) + // - nonce=0 new account creation (above) + // - 2D nonce + CREATE caller account creation (above) + // For pre-T2, initial_state_gas is 0 (state gas GasIds are not overridden). + if spec.is_t2() { + transaction.set_intrinsic_state_gas(init_and_floor_gas.initial_state_gas); + } + let gas_limit = tx.gas_limit; // Check if gas limit is sufficient for initial gas @@ -683,6 +699,30 @@ where if let Err(err) = ensure_intrinsic_gas_tempo_tx(&transaction, spec) { return TransactionValidationOutcome::Invalid(transaction, err); } + + // TIP-1016: Compute intrinsic state gas for non-AA T2+ transactions + if spec.is_t2() { + let gas_params = tempo_gas_params(spec); + let mut non_aa_state_gas = 0u64; + // nonce == 0 with non-expiring nonce: potential new account creation + if transaction.nonce() == 0 + && transaction.nonce_key() != Some(TEMPO_EXPIRING_NONCE_KEY) + { + non_aa_state_gas += gas_params.get(GasId::new_account_state_gas()); + } + // CREATE transaction state gas (new account + contract metadata) + if transaction.is_create() { + non_aa_state_gas += gas_params.get(GasId::new_account_state_gas()) + + gas_params.get(GasId::create_state_gas()); + } + // EIP-7702 auth list account creation state gas + for auth in transaction.authorization_list().unwrap_or_default() { + if auth.nonce == 0 { + non_aa_state_gas += gas_params.tx_tip1000_auth_account_creation_state_gas(); + } + } + transaction.set_intrinsic_state_gas(non_aa_state_gas); + } } // Validate AA transaction field limits (calls, access list, token limits). @@ -1055,7 +1095,7 @@ mod tests { PoolTransaction, blobstore::InMemoryBlobStore, validate::EthTransactionValidatorBuilder, }; use std::sync::Arc; - use tempo_chainspec::spec::{MODERATO, TEMPO_T1_TX_GAS_LIMIT_CAP}; + use tempo_chainspec::spec::{DEV, MODERATO, TEMPO_T1_TX_GAS_LIMIT_CAP}; use tempo_evm::TempoEvmConfig; use tempo_precompiles::{ PATH_USD_ADDRESS, TIP403_REGISTRY_ADDRESS, @@ -1063,7 +1103,7 @@ mod tests { tip403_registry::{ITIP403Registry, PolicyData, TIP403Registry}, }; use tempo_primitives::{ - Block, TempoHeader, TempoTxEnvelope, + Block, TempoHeader, TempoTxEnvelope, TempoTxType, transaction::{ TempoTransaction, envelope::TEMPO_SYSTEM_TX_SIGNATURE, @@ -1170,6 +1210,8 @@ mod tests { let evm_config = TempoEvmConfig::new_with_default_factory(MODERATO.clone()); let inner = EthTransactionValidatorBuilder::new(provider.clone(), evm_config) .disable_balance_check() + .with_custom_tx_type(TempoTxType::AA as u8) + .no_eip4844() .build(InMemoryBlobStore::default()); let amm_cache = AmmLiquidityCache::new(provider).expect("failed to setup AmmLiquidityCache"); @@ -1187,6 +1229,76 @@ mod tests { validator } + /// Like `setup_validator` but uses the DEV chain spec (which has T2 active at timestamp 0). + fn setup_validator_dev( + transaction: &TempoPooledTransaction, + tip_timestamp: u64, + ) -> TempoTransactionValidator< + MockEthProvider, + TempoEvmConfig, + > { + let chain_spec = DEV.clone(); + let provider = + MockEthProvider::::new() + .with_chain_spec(Arc::unwrap_or_clone(chain_spec.clone())); + provider.add_account( + transaction.sender(), + ExtendedAccount::new(transaction.nonce(), alloy_primitives::U256::ZERO), + ); + let block_with_gas = Block { + header: TempoHeader { + inner: alloy_consensus::Header { + gas_limit: TEMPO_T1_TX_GAS_LIMIT_CAP, + ..Default::default() + }, + ..Default::default() + }, + body: Default::default(), + }; + provider.add_block(B256::random(), block_with_gas); + + let usd_currency_value = + uint!(0x5553440000000000000000000000000000000000000000000000000000000006_U256); + let transfer_policy_id_packed = + uint!(0x0000000000000000000000010000000000000000000000000000000000000000_U256); + let balance_slot = TIP20Token::from_address(PATH_USD_ADDRESS) + .expect("PATH_USD_ADDRESS is a valid TIP20 token") + .balances[transaction.sender()] + .slot(); + let fee_payer_balance = U256::from(1_000_000_000_000u64); + provider.add_account( + PATH_USD_ADDRESS, + ExtendedAccount::new(0, U256::ZERO).extend_storage([ + (tip20_slots::CURRENCY.into(), usd_currency_value), + ( + tip20_slots::TRANSFER_POLICY_ID.into(), + transfer_policy_id_packed, + ), + (balance_slot.into(), fee_payer_balance), + ]), + ); + + let evm_config = TempoEvmConfig::new_with_default_factory(chain_spec); + let inner = EthTransactionValidatorBuilder::new(provider.clone(), evm_config) + .disable_balance_check() + .with_custom_tx_type(TempoTxType::AA as u8) + .no_eip4844() + .build(InMemoryBlobStore::default()); + let amm_cache = + AmmLiquidityCache::new(provider).expect("failed to setup AmmLiquidityCache"); + let validator = TempoTransactionValidator::new( + inner, + DEFAULT_AA_VALID_AFTER_MAX_SECS, + DEFAULT_MAX_TEMPO_AUTHORIZATIONS, + amm_cache, + ); + + let mock_block = create_mock_block(tip_timestamp); + validator.on_new_head_block(&mock_block); + + validator + } + #[tokio::test] async fn test_some_balance() { let transaction = TxBuilder::eip1559(Address::random()) @@ -4474,4 +4586,69 @@ mod tests { "Gas limit one below intrinsic gas should fail, got: {result:?}" ); } + + /// Test that T2 validation caches intrinsic state gas on the pooled transaction. + #[tokio::test] + async fn test_t2_intrinsic_state_gas_cached_on_aa_tx() { + // DEV chain spec has T2 active at timestamp 0, chain_id=1337 + let pooled = TxBuilder::aa(Address::random()) + .chain_id(1337) + .fee_token(PATH_USD_ADDRESS) + .gas_limit(1_000_000) + .build(); + + let validator = setup_validator_dev(&pooled, 100); + let outcome = validator + .validate_transaction(TransactionOrigin::External, pooled) + .await; + + match &outcome { + TransactionValidationOutcome::Valid { transaction, .. } => { + let state_gas = transaction.transaction().intrinsic_state_gas(); + assert_eq!( + state_gas, 245_000, + "state gas should be 245k for nonce=0 T2 tx" + ); + assert_eq!( + transaction.transaction().execution_gas_limit(), + 1_000_000 - 245_000, + "execution gas limit should be gas_limit - state_gas" + ); + } + other => panic!("Expected Valid outcome, got: {other:?}"), + } + } + + /// Test that T1 validation does NOT set intrinsic state gas (remains 0). + #[tokio::test] + async fn test_t1_no_intrinsic_state_gas() { + // MODERATO has T1 active but no T2 + let t1_timestamp = 1770303600u64 + 100; + + let pooled = TxBuilder::aa(Address::random()) + .fee_token(PATH_USD_ADDRESS) + .gas_limit(1_000_000) + .build(); + + let validator = setup_validator(&pooled, t1_timestamp); + let outcome = validator + .validate_transaction(TransactionOrigin::External, pooled) + .await; + + match &outcome { + TransactionValidationOutcome::Valid { transaction, .. } => { + assert_eq!( + transaction.transaction().intrinsic_state_gas(), + 0, + "T1 should not set state gas" + ); + assert_eq!( + transaction.transaction().execution_gas_limit(), + 1_000_000, + "T1 execution_gas_limit should equal gas_limit" + ); + } + other => panic!("Expected Valid outcome, got: {other:?}"), + } + } } From 037690479bf63650e3361406ede1549737748d61 Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Wed, 18 Feb 2026 10:13:33 +0000 Subject: [PATCH 07/16] tx_execution_gas -> tx_execution_gas_limit --- crates/payload/builder/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/payload/builder/src/lib.rs b/crates/payload/builder/src/lib.rs index b1d477db4d..42a0ed237b 100644 --- a/crates/payload/builder/src/lib.rs +++ b/crates/payload/builder/src/lib.rs @@ -372,18 +372,18 @@ where // TIP-1016: For T2+ transactions, use execution_gas_limit (gas_limit minus state gas) // for protocol limit checks. State gas does NOT count against protocol limits. // For pre-T2, execution_gas_limit() == gas_limit() (state gas is 0). - let tx_execution_gas = pool_tx.transaction.execution_gas_limit(); + let tx_execution_gas_limit = pool_tx.transaction.execution_gas_limit(); // Ensure we still have capacity for this transaction within the non-shared gas limit. // The remaining `shared_gas_limit` is reserved for validator subblocks and must not // be consumed by proposer's pool transactions. - if cumulative_gas_used + tx_execution_gas > non_shared_gas_limit { + if cumulative_gas_used + tx_execution_gas_limit > non_shared_gas_limit { // Mark this transaction as invalid since it doesn't fit // The iterator will handle lane switching internally when appropriate best_txs.mark_invalid( &pool_tx, &InvalidPoolTransactionError::ExceedsGasLimit( - tx_execution_gas, + tx_execution_gas_limit, non_shared_gas_limit - cumulative_gas_used, ), ); @@ -393,7 +393,7 @@ where // If the tx is not a payment and will exceed the general gas limit // mark the tx as invalid and continue if !pool_tx.transaction.is_payment() - && non_payment_gas_used + tx_execution_gas > general_gas_limit + && non_payment_gas_used + tx_execution_gas_limit > general_gas_limit { best_txs.mark_invalid( &pool_tx, From 94a7aef34702ce74d74ee490fe4a5cfbb35967de Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Wed, 18 Feb 2026 10:42:24 +0000 Subject: [PATCH 08/16] fix(txpool,evm): ensure gas_limit covers both execution and state gas after TIP-1016 computation --- crates/revm/src/handler.rs | 32 ++++++----- crates/transaction-pool/src/validator.rs | 67 ++++++++++++++---------- 2 files changed, 56 insertions(+), 43 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 86a3d4fcb9..6b770f4801 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1282,11 +1282,12 @@ where init_gas.floor_gas = 0u64; } - // Validate gas limit is sufficient for initial gas - if gas_limit < init_gas.initial_total_gas { + // Validate gas limit is sufficient for initial gas (execution + state) + let total_intrinsic = init_gas.initial_total_gas + init_gas.initial_state_gas; + if gas_limit < total_intrinsic { return Err(TempoInvalidTransaction::InsufficientGasForIntrinsicCost { gas_limit, - intrinsic_gas: init_gas.initial_total_gas, + intrinsic_gas: total_intrinsic, } .into()); } @@ -1535,11 +1536,12 @@ where batch_gas.initial_total_gas += nonce_2d_gas; } - // Validate gas limit is sufficient for initial gas - if gas_limit < batch_gas.initial_total_gas { + // Validate gas limit is sufficient for initial gas (execution + state) + let total_intrinsic = batch_gas.initial_total_gas + batch_gas.initial_state_gas; + if gas_limit < total_intrinsic { return Err(TempoInvalidTransaction::InsufficientGasForIntrinsicCost { gas_limit, - intrinsic_gas: batch_gas.initial_total_gas, + intrinsic_gas: total_intrinsic, } .into()); } @@ -2546,19 +2548,21 @@ mod tests { } else { spec.gas_new_nonce_key() }; + // TIP-1016: For T2+, state gas must also fit within gas_limit + let nonce_zero_state_gas = if spec.is_t2() { + gas_params.new_account_state_gas() + } else { + 0 + }; + let nonce_zero_total = nonce_zero_gas + nonce_zero_state_gas; let cases = if spec.is_t0() { let mut cases = vec![ - (BASE_INTRINSIC_GAS + nonce_zero_gas, 0, true), // Exactly sufficient for nonce==0 + (BASE_INTRINSIC_GAS + nonce_zero_total, 0, true), // Exactly sufficient for nonce==0 (exec + state) (BASE_INTRINSIC_GAS + spec.gas_existing_nonce_key(), 1, true), // Exactly sufficient for existing key ]; - // Only test "insufficient" case when nonce_zero_gas > 10k (T1 has 250k, T2 has 5k) - if nonce_zero_gas > 10_000 { - cases.push((BASE_INTRINSIC_GAS + 10_000, 0u64, false)); // Insufficient for nonce==0 - } else { - // T2: nonce_zero_gas is 5k, so BASE + nonce_zero_gas - 1 is insufficient - cases.push((BASE_INTRINSIC_GAS + nonce_zero_gas - 1, 0u64, false)); - } + // Insufficient: below total required for nonce==0 + cases.push((BASE_INTRINSIC_GAS + nonce_zero_total - 1, 0u64, false)); cases } else { // Genesis: nonce gas is added AFTER validation, so lower gas_limit still passes diff --git a/crates/transaction-pool/src/validator.rs b/crates/transaction-pool/src/validator.rs index c2197ce055..39dd499aa9 100644 --- a/crates/transaction-pool/src/validator.rs +++ b/crates/transaction-pool/src/validator.rs @@ -463,12 +463,14 @@ where let gas_limit = tx.gas_limit; - // Check if gas limit is sufficient for initial gas - if gas_limit < init_and_floor_gas.initial_total_gas { + // Check if gas limit is sufficient for initial gas (execution + state) + let total_intrinsic = + init_and_floor_gas.initial_total_gas + init_and_floor_gas.initial_state_gas; + if gas_limit < total_intrinsic { return Err( TempoPoolTransactionError::InsufficientGasForAAIntrinsicCost { gas_limit, - intrinsic_gas: init_and_floor_gas.initial_total_gas, + intrinsic_gas: total_intrinsic, }, ); } @@ -695,34 +697,12 @@ where ); } } else { - // validate intrinsic gas with additional TIP-1000 and T1 checks + // validate intrinsic gas with additional TIP-1000 and T1 checks. + // For T2+, this also computes/caches intrinsic state gas and validates that + // gas_limit covers both execution gas and state gas. if let Err(err) = ensure_intrinsic_gas_tempo_tx(&transaction, spec) { return TransactionValidationOutcome::Invalid(transaction, err); } - - // TIP-1016: Compute intrinsic state gas for non-AA T2+ transactions - if spec.is_t2() { - let gas_params = tempo_gas_params(spec); - let mut non_aa_state_gas = 0u64; - // nonce == 0 with non-expiring nonce: potential new account creation - if transaction.nonce() == 0 - && transaction.nonce_key() != Some(TEMPO_EXPIRING_NONCE_KEY) - { - non_aa_state_gas += gas_params.get(GasId::new_account_state_gas()); - } - // CREATE transaction state gas (new account + contract metadata) - if transaction.is_create() { - non_aa_state_gas += gas_params.get(GasId::new_account_state_gas()) - + gas_params.get(GasId::create_state_gas()); - } - // EIP-7702 auth list account creation state gas - for auth in transaction.authorization_list().unwrap_or_default() { - if auth.nonce == 0 { - non_aa_state_gas += gas_params.tx_tip1000_auth_account_creation_state_gas(); - } - } - transaction.set_intrinsic_state_gas(non_aa_state_gas); - } } // Validate AA transaction field limits (calls, access list, token limits). @@ -953,6 +933,9 @@ where } /// Ensures that gas limit of the transaction exceeds the intrinsic gas of the transaction. +/// +/// For T2+ transactions, also computes and caches the intrinsic state gas, and validates +/// that gas_limit covers both execution gas and state gas. pub fn ensure_intrinsic_gas_tempo_tx( tx: &TempoPooledTransaction, spec: TempoHardfork, @@ -990,10 +973,36 @@ pub fn ensure_intrinsic_gas_tempo_tx( } } + // TIP-1016: For T2+, compute state gas and validate gas_limit covers both execution + state gas. + // State gas is tracked separately via initial_state_gas and does not count against protocol + // limits, but must still fit within gas_limit. + // For pre-T2, state gas is bundled into initial_total_gas (no split), so we clear + // initial_state_gas to avoid double-counting from upstream defaults in initial_tx_gas. + if !spec.is_t2() { + gas.initial_state_gas = 0; + } else { + // nonce == 0 with non-expiring nonce: potential new account creation + if tx.nonce() == 0 && tx.nonce_key() != Some(TEMPO_EXPIRING_NONCE_KEY) { + gas.initial_state_gas += gas_params.get(GasId::new_account_state_gas()); + } + // EIP-7702 auth list account creation state gas + for auth in tx.authorization_list().unwrap_or_default() { + if auth.nonce == 0 { + gas.initial_state_gas += gas_params.tx_tip1000_auth_account_creation_state_gas(); + } + } + } + let gas_limit = tx.gas_limit(); - if gas_limit < gas.initial_total_gas || gas_limit < gas.floor_gas { + // TIP-1016: gas_limit must cover both execution gas and state gas + let total_intrinsic = gas.initial_total_gas + gas.initial_state_gas; + if gas_limit < total_intrinsic || gas_limit < gas.floor_gas { Err(InvalidPoolTransactionError::IntrinsicGasTooLow) } else { + // TIP-1016: Cache intrinsic state gas for T2+ transactions + if spec.is_t2() { + tx.set_intrinsic_state_gas(gas.initial_state_gas); + } Ok(()) } } From 234f58980ae61d1fdc1159493a0067f979a6c673 Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Wed, 18 Feb 2026 12:26:27 +0000 Subject: [PATCH 09/16] fix(payload-builder): clarify gas capacity comment --- crates/payload/builder/src/lib.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/payload/builder/src/lib.rs b/crates/payload/builder/src/lib.rs index 42a0ed237b..31743ce007 100644 --- a/crates/payload/builder/src/lib.rs +++ b/crates/payload/builder/src/lib.rs @@ -369,9 +369,8 @@ where let execution_start = Instant::now(); while let Some(pool_tx) = best_txs.next() { - // TIP-1016: For T2+ transactions, use execution_gas_limit (gas_limit minus state gas) - // for protocol limit checks. State gas does NOT count against protocol limits. - // For pre-T2, execution_gas_limit() == gas_limit() (state gas is 0). + // TIP-1016: State gas does not count toward block gas capacity, so use + // execution_gas_limit (= gas_limit - state_gas) for the block-level checks below. let tx_execution_gas_limit = pool_tx.transaction.execution_gas_limit(); // Ensure we still have capacity for this transaction within the non-shared gas limit. From a2d288cf8e08ba5c749251d72c0e7f84fcb3a2d7 Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Wed, 18 Feb 2026 12:53:57 +0000 Subject: [PATCH 10/16] added comment about intrisic_state_gas being hardfork specific --- crates/transaction-pool/src/transaction.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index 14ff37e176..f8a1286edf 100644 --- a/crates/transaction-pool/src/transaction.rs +++ b/crates/transaction-pool/src/transaction.rs @@ -49,6 +49,12 @@ pub struct TempoPooledTransaction { /// Set during validation for T2+ transactions. The state gas portion does NOT count /// against protocol limits (block/transaction execution gas caps), only against /// the user's `gas_limit`. + /// + /// NOTE: This value is hardfork-specific (gas parameters vary by fork). If a + /// transaction survives a hardfork boundary, the cached value may be stale. + /// Future fork transitions that change state gas parameters should add pool + /// maintenance logic (like `evict_underpriced_transactions_for_t1`) to + /// re-validate or evict affected transactions. intrinsic_state_gas: OnceLock, } From 1a2ec1d1d45f9fe4d93cacd7071be01090064bdd Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Thu, 19 Feb 2026 20:20:42 +0000 Subject: [PATCH 11/16] properly gate total_intrinsic calculation --- crates/revm/src/handler.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 6b770f4801..0dbe91e37c 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -1282,8 +1282,15 @@ where init_gas.floor_gas = 0u64; } - // Validate gas limit is sufficient for initial gas (execution + state) - let total_intrinsic = init_gas.initial_total_gas + init_gas.initial_state_gas; + // Validate gas limit is sufficient for initial gas + // State gas is only included in the intrinsic check for T2+, since pre-T2 + // transactions were never validated against state gas. + let total_intrinsic = init_gas.initial_total_gas + + if spec.is_t2() { + init_gas.initial_state_gas + } else { + 0 + }; if gas_limit < total_intrinsic { return Err(TempoInvalidTransaction::InsufficientGasForIntrinsicCost { gas_limit, @@ -1536,8 +1543,15 @@ where batch_gas.initial_total_gas += nonce_2d_gas; } - // Validate gas limit is sufficient for initial gas (execution + state) - let total_intrinsic = batch_gas.initial_total_gas + batch_gas.initial_state_gas; + // Validate gas limit is sufficient for initial gas + // State gas is only included in the intrinsic check for T2+, since pre-T2 + // transactions were never validated against state gas. + let total_intrinsic = batch_gas.initial_total_gas + + if spec.is_t2() { + batch_gas.initial_state_gas + } else { + 0 + }; if gas_limit < total_intrinsic { return Err(TempoInvalidTransaction::InsufficientGasForIntrinsicCost { gas_limit, From ca0f2e337c7c41f4b4c45bf5f1e33ced329775b2 Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Tue, 17 Feb 2026 10:45:12 +0000 Subject: [PATCH 12/16] feat(evm): split gas tracking into execution vs storage creation gas (TIP-1016) --- crates/evm/src/block.rs | 200 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 198 insertions(+), 2 deletions(-) diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index 75d2b611c3..18e2154a33 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -114,6 +114,13 @@ pub(crate) struct TempoBlockExecutor<'a, DB: Database, I> { non_shared_gas_left: u64, non_payment_gas_left: u64, incentive_gas_used: u64, + + /// Tracks full cumulative gas used (execution + storage creation) for receipts. + /// This differs from block gas limit accounting which only counts execution gas. + cumulative_full_gas_used: u64, + /// Tracks cumulative storage creation gas used (TIP-1016). + /// Used to derive execution-only gas for the block header. + cumulative_storage_creation_gas: u64, } impl<'a, DB, I> TempoBlockExecutor<'a, DB, I> @@ -141,6 +148,8 @@ where section: BlockSection::StartOfBlock, seen_subblocks: Vec::new(), subblock_fee_recipients: ctx.subblock_fee_recipients, + cumulative_full_gas_used: 0, + cumulative_storage_creation_gas: 0, } } @@ -402,7 +411,17 @@ where let TempoTxResult { inner, tx } = output; let next_section = self.validate_tx(&tx, inner.result.result.gas_used())?; - let gas_used = self.inner.commit_transaction(inner)?; + // TODO(TIP-1016): extract storage creation gas from EVM context once + // the EVM-level tracking is implemented. + let storage_creation_gas: u64 = 0; + + let full_gas_used = self.inner.commit_transaction(inner)?; + self.cumulative_full_gas_used += full_gas_used; + self.cumulative_storage_creation_gas += storage_creation_gas; + + // Execution gas excludes storage creation gas (TIP-1016). + // Only execution gas counts toward protocol limits (block gas limit). + let gas_used = full_gas_used - storage_creation_gas; // TODO: remove once revm supports emitting logs for reverted transactions // @@ -456,7 +475,7 @@ where } fn finish( - self, + mut self, ) -> Result<(Self::Evm, BlockExecutionResult), BlockExecutionError> { // Check that we ended in the System section with all end-of-block system txs seen if self.section @@ -468,6 +487,12 @@ where BlockValidationError::msg("end-of-block system transactions not seen").into(), ); } + + // The inner executor's gas_used tracks full gas (execution + storage) + // because that's what goes into receipt cumulative_gas_used. For the + // block header, we need execution gas only (TIP-1016). + self.inner.gas_used -= self.cumulative_storage_creation_gas; + self.inner.finish() } @@ -514,6 +539,16 @@ where pub(crate) fn section(&self) -> BlockSection { self.section } + + /// Get the cumulative full gas used (execution + storage) for assertions. + pub(crate) fn cumulative_full_gas_used(&self) -> u64 { + self.cumulative_full_gas_used + } + + /// Get the non-shared gas left for assertions. + pub(crate) fn non_shared_gas_left(&self) -> u64 { + self.non_shared_gas_left + } } #[cfg(test)] @@ -1111,6 +1146,167 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_commit_transaction_tracks_full_cumulative_gas() { + // commit_transaction should track cumulative full gas (for receipts) + let chainspec = test_chainspec(); + let mut db = State::builder().with_bundle_update().build(); + let mut executor = TestExecutorBuilder::default() + .with_general_gas_limit(30_000_000) + .with_parent_beacon_block_root(B256::ZERO) + .build(&mut db, &chainspec); + + executor.apply_pre_execution_changes().unwrap(); + + let tx = create_legacy_tx(); + let output = TempoTxResult { + inner: EthTxResult { + result: ResultAndState { + result: revm::context::result::ExecutionResult::Success { + reason: revm::context::result::SuccessReason::Return, + gas: ResultGas::new(21000, 21000, 0, 0, 0, 0), + logs: vec![], + output: revm::context::result::Output::Call(Bytes::new()), + }, + state: Default::default(), + }, + blob_gas_used: 0, + tx_type: tx.tx_type(), + }, + tx, + }; + + let exec_gas = executor.commit_transaction(output).unwrap(); + + // With zero storage creation gas, execution gas equals total gas + assert_eq!(exec_gas, 21000); + assert_eq!(executor.cumulative_full_gas_used(), 21000); + } + + #[test] + fn test_cumulative_full_gas_accumulates_across_transactions() { + let chainspec = test_chainspec(); + let mut db = State::builder().with_bundle_update().build(); + let mut executor = TestExecutorBuilder::default() + .with_general_gas_limit(30_000_000) + .with_parent_beacon_block_root(B256::ZERO) + .build(&mut db, &chainspec); + + executor.apply_pre_execution_changes().unwrap(); + + // Commit first transaction (21000 gas) + let tx1 = create_legacy_tx(); + let output1 = TempoTxResult { + inner: EthTxResult { + result: ResultAndState { + result: revm::context::result::ExecutionResult::Success { + reason: revm::context::result::SuccessReason::Return, + gas: ResultGas::new(21000, 21000, 0, 0, 0, 0), + logs: vec![], + output: revm::context::result::Output::Call(Bytes::new()), + }, + state: Default::default(), + }, + blob_gas_used: 0, + tx_type: tx1.tx_type(), + }, + tx: tx1, + }; + executor.commit_transaction(output1).unwrap(); + + // Commit second transaction (50000 gas) + let tx2 = create_legacy_tx(); + let output2 = TempoTxResult { + inner: EthTxResult { + result: ResultAndState { + result: revm::context::result::ExecutionResult::Success { + reason: revm::context::result::SuccessReason::Return, + gas: ResultGas::new(50000, 50000, 0, 0, 0, 0), + logs: vec![], + output: revm::context::result::Output::Call(Bytes::new()), + }, + state: Default::default(), + }, + blob_gas_used: 0, + tx_type: tx2.tx_type(), + }, + tx: tx2, + }; + executor.commit_transaction(output2).unwrap(); + + assert_eq!(executor.cumulative_full_gas_used(), 71000); + + // Receipts should have cumulative full gas + let receipts = executor.receipts(); + assert_eq!(receipts[0].cumulative_gas_used, 21000); + assert_eq!(receipts[1].cumulative_gas_used, 71000); + } + + #[test] + fn test_finish_returns_execution_gas_for_block_header() { + // BlockExecutionResult.gas_used (used for block header) should be + // execution gas only, not full gas including storage creation. + // For now these are equal, but the plumbing ensures correctness + // when the EVM starts reporting storage gas separately. + let chainspec = test_chainspec(); + let mut db = State::builder().with_bundle_update().build(); + let mut executor = TestExecutorBuilder::default() + .with_general_gas_limit(30_000_000) + .with_parent_beacon_block_root(B256::ZERO) + .with_section(BlockSection::NonShared) + .build(&mut db, &chainspec); + + executor.apply_pre_execution_changes().unwrap(); + + // Manually set state to simulate a committed transaction + executor.section = BlockSection::System { + seen_subblocks_signatures: true, + }; + executor.inner.gas_used += 21000; + executor.cumulative_full_gas_used += 21000; + + let (_, result) = executor.finish().unwrap(); + // Block header gas_used should be execution gas + assert_eq!(result.gas_used, 21000); + } + + #[test] + fn test_non_shared_gas_uses_execution_gas_only() { + // non_shared_gas_left should be decremented by execution gas, + // which currently equals full gas since storage_creation_gas is 0. + let chainspec = test_chainspec(); + let mut db = State::builder().with_bundle_update().build(); + let mut executor = TestExecutorBuilder::default() + .with_general_gas_limit(30_000_000) + .with_parent_beacon_block_root(B256::ZERO) + .build(&mut db, &chainspec); + + executor.apply_pre_execution_changes().unwrap(); + + let initial_non_shared = executor.non_shared_gas_left(); + + let tx = create_legacy_tx(); + let output = TempoTxResult { + inner: EthTxResult { + result: ResultAndState { + result: revm::context::result::ExecutionResult::Success { + reason: revm::context::result::SuccessReason::Return, + gas: ResultGas::new(50_000, 50_000, 0, 0, 0, 0), + logs: vec![], + output: revm::context::result::Output::Call(Bytes::new()), + }, + state: Default::default(), + }, + blob_gas_used: 0, + tx_type: tx.tx_type(), + }, + tx, + }; + executor.commit_transaction(output).unwrap(); + + assert_eq!(executor.non_shared_gas_left(), initial_non_shared - 50_000); + } + #[test] fn test_finish_system_tx_not_seen() { let chainspec = test_chainspec(); From 1c2f7d17ac652ddd7418b3a38194ff4c90efb1da Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Tue, 17 Feb 2026 18:15:12 +0000 Subject: [PATCH 13/16] full_gas -> total_gas --- crates/evm/src/block.rs | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index 18e2154a33..2a6ec4b581 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -115,9 +115,9 @@ pub(crate) struct TempoBlockExecutor<'a, DB: Database, I> { non_payment_gas_left: u64, incentive_gas_used: u64, - /// Tracks full cumulative gas used (execution + storage creation) for receipts. + /// Tracks total cumulative gas used (execution + storage creation) for receipts. /// This differs from block gas limit accounting which only counts execution gas. - cumulative_full_gas_used: u64, + cumulative_total_gas_used: u64, /// Tracks cumulative storage creation gas used (TIP-1016). /// Used to derive execution-only gas for the block header. cumulative_storage_creation_gas: u64, @@ -148,7 +148,7 @@ where section: BlockSection::StartOfBlock, seen_subblocks: Vec::new(), subblock_fee_recipients: ctx.subblock_fee_recipients, - cumulative_full_gas_used: 0, + cumulative_total_gas_used: 0, cumulative_storage_creation_gas: 0, } } @@ -415,13 +415,13 @@ where // the EVM-level tracking is implemented. let storage_creation_gas: u64 = 0; - let full_gas_used = self.inner.commit_transaction(inner)?; - self.cumulative_full_gas_used += full_gas_used; + let total_gas_used = self.inner.commit_transaction(inner)?; + self.cumulative_total_gas_used += total_gas_used; self.cumulative_storage_creation_gas += storage_creation_gas; // Execution gas excludes storage creation gas (TIP-1016). // Only execution gas counts toward protocol limits (block gas limit). - let gas_used = full_gas_used - storage_creation_gas; + let gas_used = total_gas_used - storage_creation_gas; // TODO: remove once revm supports emitting logs for reverted transactions // @@ -488,7 +488,7 @@ where ); } - // The inner executor's gas_used tracks full gas (execution + storage) + // The inner executor's gas_used tracks total gas (execution + storage) // because that's what goes into receipt cumulative_gas_used. For the // block header, we need execution gas only (TIP-1016). self.inner.gas_used -= self.cumulative_storage_creation_gas; @@ -540,9 +540,9 @@ where self.section } - /// Get the cumulative full gas used (execution + storage) for assertions. - pub(crate) fn cumulative_full_gas_used(&self) -> u64 { - self.cumulative_full_gas_used + /// Get the cumulative total gas used (execution + storage) for assertions. + pub(crate) fn cumulative_total_gas_used(&self) -> u64 { + self.cumulative_total_gas_used } /// Get the non-shared gas left for assertions. @@ -1147,8 +1147,8 @@ mod tests { } #[test] - fn test_commit_transaction_tracks_full_cumulative_gas() { - // commit_transaction should track cumulative full gas (for receipts) + fn test_commit_transaction_tracks_total_cumulative_gas() { + // commit_transaction should track cumulative total gas (for receipts) let chainspec = test_chainspec(); let mut db = State::builder().with_bundle_update().build(); let mut executor = TestExecutorBuilder::default() @@ -1180,11 +1180,11 @@ mod tests { // With zero storage creation gas, execution gas equals total gas assert_eq!(exec_gas, 21000); - assert_eq!(executor.cumulative_full_gas_used(), 21000); + assert_eq!(executor.cumulative_total_gas_used(), 21000); } #[test] - fn test_cumulative_full_gas_accumulates_across_transactions() { + fn test_cumulative_total_gas_accumulates_across_transactions() { let chainspec = test_chainspec(); let mut db = State::builder().with_bundle_update().build(); let mut executor = TestExecutorBuilder::default() @@ -1234,9 +1234,9 @@ mod tests { }; executor.commit_transaction(output2).unwrap(); - assert_eq!(executor.cumulative_full_gas_used(), 71000); + assert_eq!(executor.cumulative_total_gas_used(), 71000); - // Receipts should have cumulative full gas + // Receipts should have cumulative total gas let receipts = executor.receipts(); assert_eq!(receipts[0].cumulative_gas_used, 21000); assert_eq!(receipts[1].cumulative_gas_used, 71000); @@ -1245,7 +1245,7 @@ mod tests { #[test] fn test_finish_returns_execution_gas_for_block_header() { // BlockExecutionResult.gas_used (used for block header) should be - // execution gas only, not full gas including storage creation. + // execution gas only, not total gas including storage creation. // For now these are equal, but the plumbing ensures correctness // when the EVM starts reporting storage gas separately. let chainspec = test_chainspec(); @@ -1263,7 +1263,7 @@ mod tests { seen_subblocks_signatures: true, }; executor.inner.gas_used += 21000; - executor.cumulative_full_gas_used += 21000; + executor.cumulative_total_gas_used += 21000; let (_, result) = executor.finish().unwrap(); // Block header gas_used should be execution gas @@ -1273,7 +1273,7 @@ mod tests { #[test] fn test_non_shared_gas_uses_execution_gas_only() { // non_shared_gas_left should be decremented by execution gas, - // which currently equals full gas since storage_creation_gas is 0. + // which currently equals total gas since storage_creation_gas is 0. let chainspec = test_chainspec(); let mut db = State::builder().with_bundle_update().build(); let mut executor = TestExecutorBuilder::default() From a95aace00bd68859f8d48c10106a00e54842495b Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Wed, 18 Feb 2026 20:33:28 +0000 Subject: [PATCH 14/16] extract storage creation gas tracked by the evm --- crates/evm/src/block.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index 2a6ec4b581..031c86c047 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -411,9 +411,8 @@ where let TempoTxResult { inner, tx } = output; let next_section = self.validate_tx(&tx, inner.result.result.gas_used())?; - // TODO(TIP-1016): extract storage creation gas from EVM context once - // the EVM-level tracking is implemented. - let storage_creation_gas: u64 = 0; + // Extract storage creation gas tracked by the EVM (TIP-1016). + let storage_creation_gas = inner.result.result.gas().state_gas_spent(); let total_gas_used = self.inner.commit_transaction(inner)?; self.cumulative_total_gas_used += total_gas_used; From 6023dace689b3d7de1331e52695e53548532eb88 Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Thu, 19 Feb 2026 15:22:31 +0000 Subject: [PATCH 15/16] fix(consensus): allow header gas_used <= receipt cumulative_gas_used for TIP-1016 storage creation gas exemption --- crates/consensus/src/lib.rs | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/consensus/src/lib.rs b/crates/consensus/src/lib.rs index 2a21fd812d..0910b7224d 100644 --- a/crates/consensus/src/lib.rs +++ b/crates/consensus/src/lib.rs @@ -3,7 +3,7 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg))] -use alloy_consensus::{BlockHeader, Transaction, transaction::TxHashRef}; +use alloy_consensus::{BlockHeader, Transaction, TxReceipt, transaction::TxHashRef}; use alloy_evm::block::BlockExecutionResult; use reth_chainspec::EthChainSpec; use reth_consensus::{Consensus, ConsensusError, FullConsensus, HeaderValidator}; @@ -12,7 +12,7 @@ use reth_consensus_common::validation::{ validate_against_parent_gas_limit, validate_against_parent_hash_number, }; use reth_ethereum_consensus::EthBeaconConsensus; -use reth_primitives_traits::{RecoveredBlock, SealedBlock, SealedHeader}; +use reth_primitives_traits::{GotExpected, RecoveredBlock, SealedBlock, SealedHeader}; use std::sync::Arc; use tempo_chainspec::{ hardfork::TempoHardforks, @@ -190,10 +190,39 @@ impl FullConsensus for TempoConsensus { result: &BlockExecutionResult, receipt_root_bloom: Option, ) -> Result<(), ConsensusError> { + // TIP-1016: block header gas_used tracks execution gas only, while receipt + // cumulative_gas_used tracks total gas (execution + storage creation). The + // standard Ethereum check requires strict equality, but TIP-1016 allows + // header gas_used <= last receipt cumulative_gas_used. + let cumulative_gas_used = result + .receipts + .last() + .map(|r| r.cumulative_gas_used()) + .unwrap_or(0); + if block.header().gas_used() > cumulative_gas_used { + return Err(ConsensusError::BlockGasUsed { + gas: GotExpected { + got: cumulative_gas_used, + expected: block.header().gas_used(), + }, + gas_spent_by_tx: reth_primitives_traits::receipt::gas_spent_by_transactions( + &result.receipts, + ), + }); + } + + // Delegate receipt root, logs bloom, and requests hash validation to the + // inner Ethereum consensus. We construct a temporary result with gas_used + // matching the header so the inner gas check passes, while the actual + // TIP-1016 gas invariant (header <= receipts) is checked above. + let mut patched_result = result.clone(); + if let Some(last) = patched_result.receipts.last_mut() { + last.cumulative_gas_used = block.header().gas_used(); + } FullConsensus::::validate_block_post_execution( &self.inner, block, - result, + &patched_result, receipt_root_bloom, ) } From 5b1895dbe46d55a3ff0fb156c6e220e51cc4c6ae Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Thu, 19 Feb 2026 15:35:21 +0000 Subject: [PATCH 16/16] test(tip1016): add integration tests for storage gas exemption from block gas limits --- crates/node/tests/it/main.rs | 1 + crates/node/tests/it/tip1016_storage_gas.rs | 376 ++++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 crates/node/tests/it/tip1016_storage_gas.rs diff --git a/crates/node/tests/it/main.rs b/crates/node/tests/it/main.rs index bca5542165..0901320985 100644 --- a/crates/node/tests/it/main.rs +++ b/crates/node/tests/it/main.rs @@ -9,6 +9,7 @@ mod payment_lane; mod pool; mod stablecoin_dex; mod tempo_transaction; +mod tip1016_storage_gas; mod tip20; mod tip20_factory; mod tip20_gas_fees; diff --git a/crates/node/tests/it/tip1016_storage_gas.rs b/crates/node/tests/it/tip1016_storage_gas.rs new file mode 100644 index 0000000000..ca70d4de8f --- /dev/null +++ b/crates/node/tests/it/tip1016_storage_gas.rs @@ -0,0 +1,376 @@ +//! Tests for TIP-1016: Exempt Storage Creation from Gas Limits. +//! +//! TIP-1016 splits storage creation costs into two components: +//! - **Execution gas**: computational cost (writing, hashing) -- counts toward protocol limits +//! - **Storage creation gas**: permanent storage burden -- does NOT count toward protocol limits +//! +//! Key invariants tested: +//! 1. Block header gas_used reflects only execution gas (storage creation gas excluded) +//! 2. Receipt gas_used includes ALL gas (execution + storage creation) +//! 3. Therefore: sum of receipt gas_used > block header gas_used when storage is created +//! 4. Transactions that only touch existing storage have no difference + +use alloy::{ + consensus::{SignableTransaction, Transaction, TxEip1559, TxEnvelope}, + primitives::{Address, Bytes, U256}, + providers::{Provider, ProviderBuilder}, + signers::local::MnemonicBuilder, +}; +use alloy_eips::{BlockId, BlockNumberOrTag, eip2718::Encodable2718}; +use alloy_network::TxSignerSync; +use tempo_chainspec::spec::TEMPO_T1_BASE_FEE; +use tempo_contracts::{CREATEX_ADDRESS, CreateX}; + +use crate::utils::{TEST_MNEMONIC, TestNodeBuilder}; + +/// Builds and encodes a signed EIP-1559 CALL transaction. +fn build_call_tx( + signer: &alloy::signers::local::PrivateKeySigner, + chain_id: u64, + nonce: u64, + gas_limit: u64, + to: Address, + input: Bytes, +) -> Bytes { + let mut tx = TxEip1559 { + chain_id, + nonce, + gas_limit, + to: to.into(), + max_fee_per_gas: TEMPO_T1_BASE_FEE as u128, + max_priority_fee_per_gas: TEMPO_T1_BASE_FEE as u128, + input, + ..Default::default() + }; + let signature = signer.sign_transaction_sync(&mut tx).unwrap(); + TxEnvelope::Eip1559(tx.into_signed(signature)) + .encoded_2718() + .into() +} + +/// Gets the deployed contract address from CreateX's ContractCreation event, polling until +/// receipts are available. +async fn get_createx_deployed_address( + provider: &P, + block_number: u64, +) -> eyre::Result
{ + let block_id = BlockId::Number(BlockNumberOrTag::Number(block_number)); + for _ in 0..50 { + if let Some(receipts) = provider.get_block_receipts(block_id).await? { + let receipt = receipts + .iter() + .find(|r| !r.inner.logs().is_empty()) + .expect("should have a receipt with logs"); + assert!(receipt.status(), "deployment should succeed"); + let addr = Address::from_slice( + &receipt.inner.logs()[0].inner.data.topics()[1].as_slice()[12..], + ); + return Ok(addr); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + eyre::bail!("timed out waiting for deploy receipts at block {block_number}") +} + +/// Returns the total gas_used from all receipts in a block, polling until the RPC catches up. +async fn total_receipt_gas_for_block( + provider: &P, + block_number: u64, +) -> eyre::Result { + let block_id = BlockId::Number(BlockNumberOrTag::Number(block_number)); + for _ in 0..50 { + if let Some(receipts) = provider.get_block_receipts(block_id).await? { + return Ok(receipts.iter().map(|r| r.gas_used).sum()); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + eyre::bail!("timed out waiting for receipts at block {block_number}") +} + +/// Happy path: deploying a contract via CreateX creates new storage (account creation + +/// code storage), so block header gas_used should be less than the sum of receipt gas_used. +/// +/// The difference is the storage creation gas that TIP-1016 exempts from protocol limits. +#[tokio::test(flavor = "multi_thread")] +async fn test_tip1016_contract_deployment_exempts_storage_gas() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let mut setup = TestNodeBuilder::new().build_with_node_access().await?; + let signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC) + .index(0)? + .build()?; + let provider = ProviderBuilder::new().connect_http(setup.node.rpc_url()); + let chain_id = provider.get_chain_id().await?; + + // Simple contract init code: PUSH1 0x2a PUSH1 0x00 MSTORE PUSH1 0x20 PUSH1 0x00 RETURN + // Deploys a contract that returns 42, creating new account + code storage. + let init_code = + Bytes::from_static(&[0x60, 0x2a, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3]); + + let createx = CreateX::new(CREATEX_ADDRESS, &provider); + let deploy_calldata: Bytes = createx.deployCreate(init_code).calldata().clone(); + + let raw_tx = build_call_tx( + &signer, + chain_id, + 0, + 5_000_000, + CREATEX_ADDRESS, + deploy_calldata, + ); + setup.node.rpc.inject_tx(raw_tx.clone()).await?; + + let payload = setup.node.advance_block().await?; + let block = payload.block(); + let block_number = block.header().inner.number; + let block_gas_used = block.header().inner.gas_used; + + // Verify user tx was included (non-system txs have gas_limit > 0) + let user_tx_count = block + .body() + .transactions() + .filter(|tx| (*tx).gas_limit() > 0) + .count(); + assert!(user_tx_count > 0, "deploy tx should be included in block"); + + let receipts_total_gas = total_receipt_gas_for_block(&provider, block_number).await?; + + // TIP-1016: block header gas_used should be LESS than the sum of all receipt gas_used + // because storage creation gas is exempted from the block header but charged to users. + // + // The deployed contract is 32 bytes of runtime code. Storage creation gas exempted: + // code_deposit_state_gas = 32 bytes x 2,300 gas/byte = 73,600 + let storage_creation_gas = receipts_total_gas - block_gas_used; + assert_eq!( + storage_creation_gas, 73_600, + "storage creation gas should be exactly 73,600 (32 bytes x 2,300 code_deposit_state_gas), \ + got {storage_creation_gas} (block_gas_used={block_gas_used}, receipts_total_gas={receipts_total_gas})" + ); + + Ok(()) +} + +/// Happy path: a SSTORE (zero -> non-zero) via a CALL to an existing contract +/// triggers the storage creation gas exemption. +/// +/// SSTORE zero->non-zero costs 250,000 gas total (5,000 exec + 245,000 storage). +#[tokio::test(flavor = "multi_thread")] +async fn test_tip1016_sstore_zero_to_nonzero_exempts_storage_gas() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let mut setup = TestNodeBuilder::new().build_with_node_access().await?; + let signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC) + .index(0)? + .build()?; + let provider = ProviderBuilder::new().connect_http(setup.node.rpc_url()); + let chain_id = provider.get_chain_id().await?; + + // Step 1: Deploy a contract whose runtime code does SSTORE(calldataload(0), 1) + // + // Runtime bytecode (7 bytes): + // PUSH1 0x01 PUSH1 0x00 CALLDATALOAD SSTORE STOP + // + // Init code wraps runtime via CODECOPY + RETURN + let init_code = Bytes::from_static(&[ + // Init code (12 bytes) + 0x60, 0x07, // PUSH1 7 (runtime length) + 0x60, 0x0c, // PUSH1 12 (runtime offset) + 0x60, 0x00, // PUSH1 0 (memory dest) + 0x39, // CODECOPY + 0x60, 0x07, // PUSH1 7 (return length) + 0x60, 0x00, // PUSH1 0 (return offset) + 0xf3, // RETURN + // Runtime code (7 bytes) + 0x60, 0x01, // PUSH1 1 (value) + 0x60, 0x00, // PUSH1 0 (calldata offset) + 0x35, // CALLDATALOAD (slot) + 0x55, // SSTORE + 0x00, // STOP + ]); + + let createx = CreateX::new(CREATEX_ADDRESS, &provider); + let deploy_calldata: Bytes = createx.deployCreate(init_code).calldata().clone(); + + let deploy_raw = build_call_tx( + &signer, + chain_id, + 0, + 5_000_000, + CREATEX_ADDRESS, + deploy_calldata, + ); + setup.node.rpc.inject_tx(deploy_raw).await?; + let deploy_payload = setup.node.advance_block().await?; + + // Get deployed contract address from the CreateX ContractCreation event + let deploy_block_number = deploy_payload.block().header().inner.number; + let contract_addr = get_createx_deployed_address(&provider, deploy_block_number).await?; + + // Step 2: Call the deployed contract to trigger SSTORE zero->non-zero at slot 42 + let calldata: Bytes = alloy_primitives::B256::left_padding_from(&42u64.to_be_bytes()) + .as_slice() + .to_vec() + .into(); + let call_raw = build_call_tx(&signer, chain_id, 1, 5_000_000, contract_addr, calldata); + setup.node.rpc.inject_tx(call_raw).await?; + let call_payload = setup.node.advance_block().await?; + + let call_block_number = call_payload.block().header().inner.number; + let block_gas_used = call_payload.block().header().inner.gas_used; + let receipts_total_gas = total_receipt_gas_for_block(&provider, call_block_number).await?; + + // TIP-1016: block gas_used should be less than receipt gas because + // the SSTORE zero->non-zero has 245,000 storage creation gas exempted. + // + // sstore_set_state_gas = 250,000 - 5,000 = 245,000 + let storage_creation_gas = receipts_total_gas - block_gas_used; + assert_eq!( + storage_creation_gas, 245_000, + "storage creation gas should be exactly 245,000 (sstore_set_state_gas), \ + got {storage_creation_gas} (block_gas_used={block_gas_used}, receipts_total_gas={receipts_total_gas})" + ); + + Ok(()) +} + +/// Happy path: a SSTORE that modifies an existing slot (non-zero -> non-zero) should +/// NOT have any storage creation gas component, so block gas_used and total receipt gas +/// should be equal. +#[tokio::test(flavor = "multi_thread")] +async fn test_tip1016_sstore_nonzero_to_nonzero_no_exemption() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let mut setup = TestNodeBuilder::new().build_with_node_access().await?; + let signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC) + .index(0)? + .build()?; + let provider = ProviderBuilder::new().connect_http(setup.node.rpc_url()); + let chain_id = provider.get_chain_id().await?; + + // Deploy a contract that does: SSTORE(slot=calldataload(0), value=calldataload(32)) + // + // Runtime (8 bytes): + // PUSH1 0x20 CALLDATALOAD PUSH1 0x00 CALLDATALOAD SSTORE STOP + let init_code = Bytes::from_static(&[ + // Init code (12 bytes) + 0x60, 0x08, 0x60, 0x0c, 0x60, 0x00, 0x39, 0x60, 0x08, 0x60, 0x00, 0xf3, + // Runtime code (8 bytes) + 0x60, 0x20, 0x35, 0x60, 0x00, 0x35, 0x55, 0x00, + ]); + + let createx = CreateX::new(CREATEX_ADDRESS, &provider); + let deploy_calldata: Bytes = createx.deployCreate(init_code).calldata().clone(); + + let deploy_raw = build_call_tx( + &signer, + chain_id, + 0, + 5_000_000, + CREATEX_ADDRESS, + deploy_calldata, + ); + setup.node.rpc.inject_tx(deploy_raw).await?; + let deploy_payload = setup.node.advance_block().await?; + + let deploy_blk = deploy_payload.block().header().inner.number; + let contract_addr = get_createx_deployed_address(&provider, deploy_blk).await?; + + // First call: SSTORE zero->non-zero at slot 0 + let mut calldata1 = [0u8; 64]; + calldata1[63] = 1; // value = 1 + let call1_raw = build_call_tx( + &signer, + chain_id, + 1, + 5_000_000, + contract_addr, + calldata1.to_vec().into(), + ); + setup.node.rpc.inject_tx(call1_raw).await?; + setup.node.advance_block().await?; + + // Second call: SSTORE non-zero->non-zero at slot 0 (value 1->2) + let mut calldata2 = [0u8; 64]; + calldata2[63] = 2; // value = 2 + let call2_raw = build_call_tx( + &signer, + chain_id, + 2, + 5_000_000, + contract_addr, + calldata2.to_vec().into(), + ); + setup.node.rpc.inject_tx(call2_raw).await?; + let call2_payload = setup.node.advance_block().await?; + + let blk_number = call2_payload.block().header().inner.number; + let block_gas_used = call2_payload.block().header().inner.gas_used; + let receipts_total_gas = total_receipt_gas_for_block(&provider, blk_number).await?; + + // For non-zero->non-zero SSTORE, there's no storage creation gas. + // Block gas_used and total receipt gas should be equal. + assert_eq!( + block_gas_used, receipts_total_gas, + "block gas_used ({block_gas_used}) should equal total receipt gas \ + ({receipts_total_gas}) for non-zero->non-zero SSTORE (no storage creation)" + ); + + Ok(()) +} + +/// Happy path: a TIP-20 transfer to an existing account (no new storage slots created) +/// should have identical block gas_used and total receipt gas. +#[tokio::test(flavor = "multi_thread")] +async fn test_tip1016_tip20_transfer_existing_no_storage_creation() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let mut setup = TestNodeBuilder::new().build_with_node_access().await?; + let signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC) + .index(0)? + .build()?; + let provider = ProviderBuilder::new().connect_http(setup.node.rpc_url()); + let chain_id = provider.get_chain_id().await?; + + let sender = signer.address(); + let token = + tempo_precompiles::tip20::ITIP20::new(tempo_precompiles::PATH_USD_ADDRESS, &provider); + + // Mint tokens to sender + let mint_calldata: Bytes = token.mint(sender, U256::from(1_000_000)).calldata().clone(); + let mint_raw = build_call_tx( + &signer, + chain_id, + 0, + 5_000_000, + tempo_precompiles::PATH_USD_ADDRESS, + mint_calldata, + ); + setup.node.rpc.inject_tx(mint_raw).await?; + setup.node.advance_block().await?; + + // Transfer to self (existing account, existing balance slot) -- no storage creation + let transfer_calldata: Bytes = token.transfer(sender, U256::from(100)).calldata().clone(); + let transfer_raw = build_call_tx( + &signer, + chain_id, + 1, + 5_000_000, + tempo_precompiles::PATH_USD_ADDRESS, + transfer_calldata, + ); + setup.node.rpc.inject_tx(transfer_raw).await?; + let transfer_payload = setup.node.advance_block().await?; + + let blk_number = transfer_payload.block().header().inner.number; + let block_gas_used = transfer_payload.block().header().inner.gas_used; + let receipts_total_gas = total_receipt_gas_for_block(&provider, blk_number).await?; + + // No storage creation -> block gas_used should equal total receipt gas + assert_eq!( + block_gas_used, receipts_total_gas, + "block gas_used ({block_gas_used}) should equal total receipt gas \ + ({receipts_total_gas}) for TIP-20 transfer to existing account (no storage creation)" + ); + + Ok(()) +}