From 2d6234d3c99639c0eb3c2943c71bd3afc99ae81d Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Tue, 17 Feb 2026 16:30:32 +0000 Subject: [PATCH 1/6] 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 d8775c89212077108dba2b40d8e681944e74d689 Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Wed, 18 Feb 2026 10:13:33 +0000 Subject: [PATCH 2/6] 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 f108538e9b8b22fccc28482d4a2bc58e694dfb27 Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Wed, 18 Feb 2026 10:42:24 +0000 Subject: [PATCH 3/6] 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 d27d8c3488749bd9afc5459ae6f7a57032d9dd08 Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Wed, 18 Feb 2026 12:26:27 +0000 Subject: [PATCH 4/6] 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 4ec69e1a85191e387017a76132d8426b367f7273 Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Wed, 18 Feb 2026 12:53:57 +0000 Subject: [PATCH 5/6] 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 6dcbb988e1b5665f57588e20c61c291a5411e5db Mon Sep 17 00:00:00 2001 From: Federico Gimenez Date: Thu, 19 Feb 2026 20:20:42 +0000 Subject: [PATCH 6/6] 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,