From 79e76f2770e355ba007f6259d8f8b61a7b6ed5af Mon Sep 17 00:00:00 2001 From: blockchaindevsh Date: Sun, 15 Mar 2026 00:53:41 +0800 Subject: [PATCH 01/11] feat(op-revm): add Soul Gas Token (SGT) support for OP Stack Implements SGT-aware gas payment for OP Stack chains: - SGT balance reading from predeploy contract (0x4200...0800) - Deduction priority: SGT first, then native balance - Refund priority: native first, then SGT (reverse order) - Full test coverage with op-acceptance-tests - Preserves L1BlockInfo fetch behavior with SGT config Co-authored-by: Claude Code --- crates/op-revm/src/handler.rs | 140 ++++++++++++++++++++++++++++++++++ crates/op-revm/src/l1block.rs | 8 ++ crates/op-revm/src/lib.rs | 1 + crates/op-revm/src/sgt.rs | 96 +++++++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 crates/op-revm/src/sgt.rs diff --git a/crates/op-revm/src/handler.rs b/crates/op-revm/src/handler.rs index ada5a49a0f..4dce277c34 100644 --- a/crates/op-revm/src/handler.rs +++ b/crates/op-revm/src/handler.rs @@ -2,6 +2,7 @@ use crate::{ api::exec::OpContextTr, constants::{BASE_FEE_RECIPIENT, L1_FEE_RECIPIENT, OPERATOR_FEE_RECIPIENT}, + sgt::{add_sgt_balance, deduct_sgt_balance, read_sgt_balance}, transaction::{deposit::DEPOSIT_TRANSACTION_TYPE, OpTransactionError, OpTxTr}, L1BlockInfo, OpHaltReason, OpSpecId, }; @@ -66,6 +67,54 @@ impl IsTxError for EVMError { } } +// Helper methods for OpHandler +impl OpHandler +where + EVM: EvmTr, + ERROR: EvmTrError + From + FromStringError + IsTxError, + FRAME: FrameTr, +{ + /// SGT-aware gas refund logic + /// Refunds gas in reverse priority: native first, then SGT + fn reimburse_caller_sgt( + &self, + evm: &mut EVM, + frame_result: &mut <::Frame as FrameTr>::FrameResult, + additional_refund: U256, + ) -> Result<(), ERROR> { + let gas = frame_result.gas(); + let (block, tx, _, journal, chain, _) = evm.ctx().all_mut(); + let basefee = block.basefee() as u128; + let caller = tx.caller(); + let effective_gas_price = tx.effective_gas_price(basefee); + + // Calculate total refund amount + let gas_refund = U256::from( + effective_gas_price.saturating_mul((gas.remaining() + gas.refunded() as u64) as u128), + ) + additional_refund; + + if gas_refund.is_zero() { + return Ok(()); + } + + // Refund in REVERSE priority: native first (up to what was deducted), then SGT + let native_refund = gas_refund.min(chain.sgt_native_deducted); + let sgt_refund = gas_refund.saturating_sub(native_refund).min(chain.sgt_amount_deducted); + + // Refund to native balance + if !native_refund.is_zero() { + journal + .load_account_mut(caller)? + .incr_balance(native_refund); + } + + // Refund to SGT balance + add_sgt_balance(journal, caller, sgt_refund)?; + + Ok(()) + } +} + impl Handler for OpHandler where EVM: EvmTr, @@ -144,7 +193,15 @@ where // L1 block info is stored in the context for later use. // and it will be reloaded from the database if it is not for the current block. if chain.l2_block != Some(block.number()) { + // Preserve SGT configuration from the current chain context + let sgt_enabled = chain.sgt_enabled; + let sgt_is_native_backed = chain.sgt_is_native_backed; + *chain = L1BlockInfo::try_fetch(journal.db_mut(), block.number(), spec)?; + + // Restore SGT configuration after fetching L1 block info + chain.sgt_enabled = sgt_enabled; + chain.sgt_is_native_backed = sgt_is_native_backed; } let mut caller_account = journal.load_account_with_code_mut(tx.caller())?.data; @@ -161,6 +218,82 @@ where "[OPTIMISM] Failed to load enveloped transaction.".into(), )); }; + + // NEW: SGT-aware gas deduction path (early return to preserve original code below) + if chain.sgt_enabled { + // Calculate L2 gas cost (gas_limit × gas_price + blob fees) + let basefee = block.basefee() as u128; + let blob_price = block.blob_gasprice().unwrap_or_default(); + let effective_balance_spending = tx + .effective_balance_spending(basefee, blob_price) + .expect("effective balance is always smaller than max balance"); + let l2_gas_cost = effective_balance_spending - tx.value(); + + // TOTAL cost = L2 + L1 + operator + let total_cost = l2_gas_cost.saturating_add(additional_cost); + + // Read SGT balance (requires dropping caller_account to release journal borrow) + drop(caller_account); + + let sgt_balance = read_sgt_balance(journal, tx.caller()); + + // Check total balance (native + SGT) >= total_cost + let total_balance = balance.saturating_add(sgt_balance); + if total_cost > total_balance { + return Err(InvalidTransaction::LackOfFundForMaxFee { + fee: Box::new(total_cost), + balance: Box::new(total_balance), + } + .into()); + } + + // Deduct from SGT first, then native (op-geth priority) + let sgt_to_deduct = sgt_balance.min(total_cost); + let native_to_deduct = total_cost.saturating_sub(sgt_to_deduct); + + // Store deduction amounts for refund calculation + chain.sgt_amount_deducted = sgt_to_deduct; + chain.sgt_native_deducted = native_to_deduct; + + // Deduct native portion + let Some(new_balance) = balance.checked_sub(native_to_deduct) else { + return Err(InvalidTransaction::LackOfFundForMaxFee { + fee: Box::new(native_to_deduct), + balance: Box::new(balance), + } + .into()); + }; + balance = new_balance; + + // Check value transfer can be covered by remaining native balance + if !cfg.is_balance_check_disabled() { + if balance < tx.value() { + return Err(InvalidTransaction::LackOfFundForMaxFee { + fee: Box::new(tx.value()), + balance: Box::new(balance), + } + .into()); + } + } else { + // Balance check disabled: ensure balance is at least tx.value (matches calculate_caller_fee behavior) + balance = balance.max(tx.value()); + } + + // Re-load caller account and update balance + let mut caller_account = journal.load_account_with_code_mut(tx.caller())?.data; + caller_account.set_balance(balance); + if tx.kind().is_call() { + caller_account.bump_nonce(); + } + drop(caller_account); + + // Write SGT deduction to storage + deduct_sgt_balance(journal, tx.caller(), sgt_to_deduct)?; + + return Ok(()); // Early return - SGT path complete + } + + // ORIGINAL: Standard gas deduction path (completely unchanged below) let Some(new_balance) = balance.checked_sub(additional_cost) else { return Err(InvalidTransaction::LackOfFundForMaxFee { fee: Box::new(additional_cost), @@ -262,6 +395,13 @@ where .operator_fee_refund(frame_result.gas(), spec); } + // NEW: SGT-aware refund logic (early return to preserve original code) + let sgt_enabled = evm.ctx().chain().sgt_enabled; + if sgt_enabled && evm.ctx().tx().tx_type() != DEPOSIT_TRANSACTION_TYPE { + return self.reimburse_caller_sgt(evm, frame_result, additional_refund); + } + + // ORIGINAL: Standard refund (unchanged) reimburse_caller(evm.ctx(), frame_result.gas(), additional_refund).map_err(From::from) } diff --git a/crates/op-revm/src/l1block.rs b/crates/op-revm/src/l1block.rs index eff2ab0a53..8f90f97c63 100644 --- a/crates/op-revm/src/l1block.rs +++ b/crates/op-revm/src/l1block.rs @@ -53,6 +53,14 @@ pub struct L1BlockInfo { pub empty_ecotone_scalars: bool, /// Last calculated l1 fee cost. Uses as a cache between validation and pre execution stages. pub tx_l1_cost: Option, + /// Whether Soul Gas Token (SGT) is enabled for gas payment + pub sgt_enabled: bool, + /// Whether SGT is native-backed (1:1 with native token) + pub sgt_is_native_backed: bool, + /// Amount of native balance deducted for gas (for refund calculation) + pub sgt_native_deducted: U256, + /// Amount of SGT balance deducted for gas (for refund calculation) + pub sgt_amount_deducted: U256, } impl L1BlockInfo { diff --git a/crates/op-revm/src/lib.rs b/crates/op-revm/src/lib.rs index 485d5adc17..dab5add1d4 100644 --- a/crates/op-revm/src/lib.rs +++ b/crates/op-revm/src/lib.rs @@ -13,6 +13,7 @@ pub mod handler; pub mod l1block; pub mod precompiles; pub mod result; +pub mod sgt; pub mod spec; pub mod transaction; diff --git a/crates/op-revm/src/sgt.rs b/crates/op-revm/src/sgt.rs new file mode 100644 index 0000000000..81b27416ad --- /dev/null +++ b/crates/op-revm/src/sgt.rs @@ -0,0 +1,96 @@ +//! Soul Gas Token (SGT) support for OP Stack +//! +//! This module provides SGT balance reading functionality for gas payment. + +use revm::primitives::{Address, B256, U256, keccak256}; +use revm::context_interface::JournalTr; +use revm::database_interface::Database; + +/// SGT contract predeploy address +pub const SGT_CONTRACT: Address = Address::new([ + 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x08, 0x00, +]); + +/// Balance mapping base slot (must match Solidity contract) +pub const SGT_BALANCE_SLOT: u64 = 51; + +/// Calculate storage slot for account's SGT balance +/// +/// Formula: `keccak256(abi.encode(account, 51))` +pub fn sgt_balance_slot(account: Address) -> B256 { + let mut data = [0u8; 64]; + // Address (20 bytes) left-padded to 32 bytes + data[12..32].copy_from_slice(account.as_slice()); + // Slot 51 as U256 (32 bytes big-endian) + data[32..64].copy_from_slice(&U256::from(SGT_BALANCE_SLOT).to_be_bytes::<32>()); + keccak256(data) +} + +/// Read SGT balance from contract storage +/// +/// Returns U256::ZERO if the contract or storage slot cannot be loaded (e.g., cold loads during gas estimation) +pub fn read_sgt_balance(journal: &mut JOURNAL, account: Address) -> U256 +where + JOURNAL: JournalTr, +{ + match journal.load_account(SGT_CONTRACT) { + Ok(_) => { + let sgt_slot = sgt_balance_slot(account); + match journal.sload(SGT_CONTRACT, sgt_slot.into()) { + Ok(state_load) => state_load.data, + Err(_) => U256::ZERO, + } + }, + Err(_) => U256::ZERO, + } +} + +/// Deduct amount from SGT balance in contract storage +/// +/// This performs: `balance[account] -= amount` in SGT contract storage. +/// Ignores errors from cold loads (returns Ok in that case). +pub fn deduct_sgt_balance(journal: &mut JOURNAL, account: Address, amount: U256) -> Result<(), ::Error> +where + JOURNAL: JournalTr, +{ + if amount.is_zero() { + return Ok(()); + } + + if let Ok(_) = journal.load_account(SGT_CONTRACT) { + let sgt_slot = sgt_balance_slot(account); + if let Ok(state_load) = journal.sload(SGT_CONTRACT, sgt_slot.into()) { + let sgt_balance = state_load.data; + let new_sgt = sgt_balance.saturating_sub(amount); + let _ = journal.sstore(SGT_CONTRACT, sgt_slot.into(), new_sgt); + } + } + + Ok(()) +} + +/// Add amount to SGT balance in contract storage +/// +/// This performs: `balance[account] += amount` in SGT contract storage. +/// Ignores errors from cold loads (returns Ok in that case). +pub fn add_sgt_balance(journal: &mut JOURNAL, account: Address, amount: U256) -> Result<(), ::Error> +where + JOURNAL: JournalTr, +{ + if amount.is_zero() { + return Ok(()); + } + + if let Ok(_) = journal.load_account(SGT_CONTRACT) { + let sgt_slot = sgt_balance_slot(account); + if let Ok(state_load) = journal.sload(SGT_CONTRACT, sgt_slot.into()) { + let current_sgt = state_load.data; + let new_sgt = current_sgt.saturating_add(amount); + let _ = journal.sstore(SGT_CONTRACT, sgt_slot.into(), new_sgt); + } + } + + Ok(()) +} From 3a6aa08522a25ef18f91f36ce97b9e5423dea2a3 Mon Sep 17 00:00:00 2001 From: blockchaindevsh Date: Fri, 20 Mar 2026 17:56:57 +0800 Subject: [PATCH 02/11] refactor(op-revm): move SGT config from L1BlockInfo to CfgEnv Move sgt_enabled and sgt_is_native_backed from L1BlockInfo to CfgEnv, accessed via Cfg trait methods (is_sgt_enabled, is_sgt_native_backed). This eliminates the need to: - Mutate the EVM after creation (no configure_sgt on Evm trait) - Preserve/restore SGT flags around L1BlockInfo::try_fetch - Fork alloy-evm SGT config now flows through CfgEnv at EVM creation time, matching how OpSpecId and other hardfork config is passed. Runtime deduction tracking (sgt_native_deducted, sgt_amount_deducted) remains on L1BlockInfo as it is per-transaction mutable state. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/context/interface/src/cfg.rs | 12 ++++++++++++ crates/context/src/cfg.rs | 17 +++++++++++++++++ crates/op-revm/src/handler.rs | 13 ++----------- crates/op-revm/src/l1block.rs | 8 ++------ 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/crates/context/interface/src/cfg.rs b/crates/context/interface/src/cfg.rs index cb107693d6..ae54971ca5 100644 --- a/crates/context/interface/src/cfg.rs +++ b/crates/context/interface/src/cfg.rs @@ -75,6 +75,18 @@ pub trait Cfg { /// Returns the gas params for the EVM. fn gas_params(&self) -> &GasParams; + + /// Whether Soul Gas Token (SGT) is enabled for gas payment. + /// Default: false. Only used by OP Stack chains with SGT. + fn is_sgt_enabled(&self) -> bool { + false + } + + /// Whether SGT is backed 1:1 by native token. + /// Default: true. + fn is_sgt_native_backed(&self) -> bool { + true + } } /// What bytecode analysis to perform diff --git a/crates/context/src/cfg.rs b/crates/context/src/cfg.rs index 5a320ea660..aab4769b2f 100644 --- a/crates/context/src/cfg.rs +++ b/crates/context/src/cfg.rs @@ -48,6 +48,11 @@ pub struct CfgEnv { pub limit_contract_initcode_size: Option, /// Skips the nonce validation against the account's nonce pub disable_nonce_check: bool, + /// Whether Soul Gas Token (SGT) is enabled for gas payment. + /// Only used by OP Stack chains with SGT deployed. Default: false. + pub sgt_enabled: bool, + /// Whether SGT is backed 1:1 by native token. Default: true. + pub sgt_is_native_backed: bool, /// Blob max count. EIP-7840 Add blob schedule to EL config files. /// /// If this config is not set, the check for max blobs will be skipped. @@ -149,6 +154,8 @@ impl CfgEnv { limit_contract_initcode_size: None, spec, disable_nonce_check: false, + sgt_enabled: false, + sgt_is_native_backed: true, max_blobs_per_tx: None, tx_gas_limit_cap: None, blob_base_fee_update_fraction: None, @@ -251,6 +258,8 @@ impl CfgEnv { limit_contract_initcode_size: self.limit_contract_initcode_size, spec, disable_nonce_check: self.disable_nonce_check, + sgt_enabled: self.sgt_enabled, + sgt_is_native_backed: self.sgt_is_native_backed, tx_gas_limit_cap: self.tx_gas_limit_cap, max_blobs_per_tx: self.max_blobs_per_tx, blob_base_fee_update_fraction: self.blob_base_fee_update_fraction, @@ -502,6 +511,14 @@ impl + Clone> Cfg for CfgEnv { fn gas_params(&self) -> &GasParams { &self.gas_params } + + fn is_sgt_enabled(&self) -> bool { + self.sgt_enabled + } + + fn is_sgt_native_backed(&self) -> bool { + self.sgt_is_native_backed + } } impl> Default for CfgEnv { diff --git a/crates/op-revm/src/handler.rs b/crates/op-revm/src/handler.rs index 4dce277c34..420bccd905 100644 --- a/crates/op-revm/src/handler.rs +++ b/crates/op-revm/src/handler.rs @@ -193,15 +193,7 @@ where // L1 block info is stored in the context for later use. // and it will be reloaded from the database if it is not for the current block. if chain.l2_block != Some(block.number()) { - // Preserve SGT configuration from the current chain context - let sgt_enabled = chain.sgt_enabled; - let sgt_is_native_backed = chain.sgt_is_native_backed; - *chain = L1BlockInfo::try_fetch(journal.db_mut(), block.number(), spec)?; - - // Restore SGT configuration after fetching L1 block info - chain.sgt_enabled = sgt_enabled; - chain.sgt_is_native_backed = sgt_is_native_backed; } let mut caller_account = journal.load_account_with_code_mut(tx.caller())?.data; @@ -220,7 +212,7 @@ where }; // NEW: SGT-aware gas deduction path (early return to preserve original code below) - if chain.sgt_enabled { + if cfg.is_sgt_enabled() { // Calculate L2 gas cost (gas_limit × gas_price + blob fees) let basefee = block.basefee() as u128; let blob_price = block.blob_gasprice().unwrap_or_default(); @@ -396,8 +388,7 @@ where } // NEW: SGT-aware refund logic (early return to preserve original code) - let sgt_enabled = evm.ctx().chain().sgt_enabled; - if sgt_enabled && evm.ctx().tx().tx_type() != DEPOSIT_TRANSACTION_TYPE { + if evm.ctx().cfg().is_sgt_enabled() && evm.ctx().tx().tx_type() != DEPOSIT_TRANSACTION_TYPE { return self.reimburse_caller_sgt(evm, frame_result, additional_refund); } diff --git a/crates/op-revm/src/l1block.rs b/crates/op-revm/src/l1block.rs index 8f90f97c63..70e5cc7591 100644 --- a/crates/op-revm/src/l1block.rs +++ b/crates/op-revm/src/l1block.rs @@ -53,13 +53,9 @@ pub struct L1BlockInfo { pub empty_ecotone_scalars: bool, /// Last calculated l1 fee cost. Uses as a cache between validation and pre execution stages. pub tx_l1_cost: Option, - /// Whether Soul Gas Token (SGT) is enabled for gas payment - pub sgt_enabled: bool, - /// Whether SGT is native-backed (1:1 with native token) - pub sgt_is_native_backed: bool, - /// Amount of native balance deducted for gas (for refund calculation) + /// Amount of native balance deducted for SGT gas payment (for refund calculation) pub sgt_native_deducted: U256, - /// Amount of SGT balance deducted for gas (for refund calculation) + /// Amount of SGT balance deducted for gas payment (for refund calculation) pub sgt_amount_deducted: U256, } From 1744019b68540b3a6a041c72dbdfe3e93ff8b26d Mon Sep 17 00:00:00 2001 From: blockchaindevsh Date: Mon, 30 Mar 2026 19:34:16 +0800 Subject: [PATCH 03/11] fix(op-revm): propagate DB errors in SGT balance operations instead of silently ignoring Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/op-revm/src/handler.rs | 2 +- crates/op-revm/src/sgt.rs | 48 +++++++++++++---------------------- 2 files changed, 18 insertions(+), 32 deletions(-) diff --git a/crates/op-revm/src/handler.rs b/crates/op-revm/src/handler.rs index 420bccd905..9c682581df 100644 --- a/crates/op-revm/src/handler.rs +++ b/crates/op-revm/src/handler.rs @@ -227,7 +227,7 @@ where // Read SGT balance (requires dropping caller_account to release journal borrow) drop(caller_account); - let sgt_balance = read_sgt_balance(journal, tx.caller()); + let sgt_balance = read_sgt_balance(journal, tx.caller())?; // Check total balance (native + SGT) >= total_cost let total_balance = balance.saturating_add(sgt_balance); diff --git a/crates/op-revm/src/sgt.rs b/crates/op-revm/src/sgt.rs index 81b27416ad..9ff0c134c4 100644 --- a/crates/op-revm/src/sgt.rs +++ b/crates/op-revm/src/sgt.rs @@ -29,28 +29,19 @@ pub fn sgt_balance_slot(account: Address) -> B256 { } /// Read SGT balance from contract storage -/// -/// Returns U256::ZERO if the contract or storage slot cannot be loaded (e.g., cold loads during gas estimation) -pub fn read_sgt_balance(journal: &mut JOURNAL, account: Address) -> U256 +pub fn read_sgt_balance(journal: &mut JOURNAL, account: Address) -> Result::Error> where JOURNAL: JournalTr, { - match journal.load_account(SGT_CONTRACT) { - Ok(_) => { - let sgt_slot = sgt_balance_slot(account); - match journal.sload(SGT_CONTRACT, sgt_slot.into()) { - Ok(state_load) => state_load.data, - Err(_) => U256::ZERO, - } - }, - Err(_) => U256::ZERO, - } + journal.load_account(SGT_CONTRACT)?; + let sgt_slot = sgt_balance_slot(account); + let state_load = journal.sload(SGT_CONTRACT, sgt_slot.into())?; + Ok(state_load.data) } /// Deduct amount from SGT balance in contract storage /// /// This performs: `balance[account] -= amount` in SGT contract storage. -/// Ignores errors from cold loads (returns Ok in that case). pub fn deduct_sgt_balance(journal: &mut JOURNAL, account: Address, amount: U256) -> Result<(), ::Error> where JOURNAL: JournalTr, @@ -59,14 +50,12 @@ where return Ok(()); } - if let Ok(_) = journal.load_account(SGT_CONTRACT) { - let sgt_slot = sgt_balance_slot(account); - if let Ok(state_load) = journal.sload(SGT_CONTRACT, sgt_slot.into()) { - let sgt_balance = state_load.data; - let new_sgt = sgt_balance.saturating_sub(amount); - let _ = journal.sstore(SGT_CONTRACT, sgt_slot.into(), new_sgt); - } - } + journal.load_account(SGT_CONTRACT)?; + let sgt_slot = sgt_balance_slot(account); + let state_load = journal.sload(SGT_CONTRACT, sgt_slot.into())?; + let sgt_balance = state_load.data; + let new_sgt = sgt_balance.saturating_sub(amount); + journal.sstore(SGT_CONTRACT, sgt_slot.into(), new_sgt)?; Ok(()) } @@ -74,7 +63,6 @@ where /// Add amount to SGT balance in contract storage /// /// This performs: `balance[account] += amount` in SGT contract storage. -/// Ignores errors from cold loads (returns Ok in that case). pub fn add_sgt_balance(journal: &mut JOURNAL, account: Address, amount: U256) -> Result<(), ::Error> where JOURNAL: JournalTr, @@ -83,14 +71,12 @@ where return Ok(()); } - if let Ok(_) = journal.load_account(SGT_CONTRACT) { - let sgt_slot = sgt_balance_slot(account); - if let Ok(state_load) = journal.sload(SGT_CONTRACT, sgt_slot.into()) { - let current_sgt = state_load.data; - let new_sgt = current_sgt.saturating_add(amount); - let _ = journal.sstore(SGT_CONTRACT, sgt_slot.into(), new_sgt); - } - } + journal.load_account(SGT_CONTRACT)?; + let sgt_slot = sgt_balance_slot(account); + let state_load = journal.sload(SGT_CONTRACT, sgt_slot.into())?; + let current_sgt = state_load.data; + let new_sgt = current_sgt.saturating_add(amount); + journal.sstore(SGT_CONTRACT, sgt_slot.into(), new_sgt)?; Ok(()) } From e78df8a4f4066e5885c1a4500093eadfba83b80c Mon Sep 17 00:00:00 2001 From: blockchaindevsh Date: Tue, 31 Mar 2026 22:22:39 +0800 Subject: [PATCH 04/11] fix(op-revm): reset SGT deduction state between transactions and remove unreachable check Reset sgt_native_deducted and sgt_amount_deducted in clear_tx_l1_cost() to prevent stale values persisting across transactions within a block. Remove unreachable checked_sub since the prior balance validation guarantees native_to_deduct <= balance. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/op-revm/src/handler.rs | 12 ++++-------- crates/op-revm/src/l1block.rs | 4 +++- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/op-revm/src/handler.rs b/crates/op-revm/src/handler.rs index 9c682581df..ee1adcad5b 100644 --- a/crates/op-revm/src/handler.rs +++ b/crates/op-revm/src/handler.rs @@ -248,14 +248,10 @@ where chain.sgt_native_deducted = native_to_deduct; // Deduct native portion - let Some(new_balance) = balance.checked_sub(native_to_deduct) else { - return Err(InvalidTransaction::LackOfFundForMaxFee { - fee: Box::new(native_to_deduct), - balance: Box::new(balance), - } - .into()); - }; - balance = new_balance; + // Safety: total_cost <= total_balance (checked above) and + // native_to_deduct = total_cost - sgt_to_deduct where sgt_to_deduct <= sgt_balance, + // so native_to_deduct <= balance. + balance -= native_to_deduct; // Check value transfer can be covered by remaining native balance if !cfg.is_balance_check_disabled() { diff --git a/crates/op-revm/src/l1block.rs b/crates/op-revm/src/l1block.rs index 70e5cc7591..c0974307f0 100644 --- a/crates/op-revm/src/l1block.rs +++ b/crates/op-revm/src/l1block.rs @@ -255,9 +255,11 @@ impl L1BlockInfo { U256::from(estimate_tx_compressed_size(input)) } - /// Clears the cached L1 cost of the transaction. + /// Clears the cached L1 cost and SGT deduction state of the transaction. pub fn clear_tx_l1_cost(&mut self) { self.tx_l1_cost = None; + self.sgt_native_deducted = U256::ZERO; + self.sgt_amount_deducted = U256::ZERO; } /// Calculate additional transaction cost with OpTxTr. From c1638ca456431e5b67ad27374d58ca33b2f597a9 Mon Sep 17 00:00:00 2001 From: blockchaindevsh Date: Wed, 1 Apr 2026 00:20:10 +0800 Subject: [PATCH 05/11] fix(op-revm): sync SGT contract native balance when native-backed When is_native_backed is true, deducting/adding SGT balance must also update the SGT contract's native account balance, matching op-geth's subSoulBalance/addSoulBalance behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/op-revm/src/handler.rs | 8 +++++--- crates/op-revm/src/sgt.rs | 21 +++++++++++++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/op-revm/src/handler.rs b/crates/op-revm/src/handler.rs index ee1adcad5b..3894a0ff24 100644 --- a/crates/op-revm/src/handler.rs +++ b/crates/op-revm/src/handler.rs @@ -83,10 +83,11 @@ where additional_refund: U256, ) -> Result<(), ERROR> { let gas = frame_result.gas(); - let (block, tx, _, journal, chain, _) = evm.ctx().all_mut(); + let (block, tx, cfg, journal, chain, _) = evm.ctx().all_mut(); let basefee = block.basefee() as u128; let caller = tx.caller(); let effective_gas_price = tx.effective_gas_price(basefee); + let is_native_backed = cfg.is_sgt_native_backed(); // Calculate total refund amount let gas_refund = U256::from( @@ -109,7 +110,7 @@ where } // Refund to SGT balance - add_sgt_balance(journal, caller, sgt_refund)?; + add_sgt_balance(journal, caller, sgt_refund, is_native_backed)?; Ok(()) } @@ -276,7 +277,8 @@ where drop(caller_account); // Write SGT deduction to storage - deduct_sgt_balance(journal, tx.caller(), sgt_to_deduct)?; + let is_native_backed = cfg.is_sgt_native_backed(); + deduct_sgt_balance(journal, tx.caller(), sgt_to_deduct, is_native_backed)?; return Ok(()); // Early return - SGT path complete } diff --git a/crates/op-revm/src/sgt.rs b/crates/op-revm/src/sgt.rs index 9ff0c134c4..ddb569a135 100644 --- a/crates/op-revm/src/sgt.rs +++ b/crates/op-revm/src/sgt.rs @@ -3,6 +3,7 @@ //! This module provides SGT balance reading functionality for gas payment. use revm::primitives::{Address, B256, U256, keccak256}; +use revm::context::journaled_state::account::JournaledAccountTr; use revm::context_interface::JournalTr; use revm::database_interface::Database; @@ -39,10 +40,12 @@ where Ok(state_load.data) } -/// Deduct amount from SGT balance in contract storage +/// Deduct amount from SGT balance in contract storage. /// /// This performs: `balance[account] -= amount` in SGT contract storage. -pub fn deduct_sgt_balance(journal: &mut JOURNAL, account: Address, amount: U256) -> Result<(), ::Error> +/// When `is_native_backed` is true, also deducts from SGT contract's native balance +/// (matching op-geth's `subSoulBalance` behavior). +pub fn deduct_sgt_balance(journal: &mut JOURNAL, account: Address, amount: U256, is_native_backed: bool) -> Result<(), ::Error> where JOURNAL: JournalTr, { @@ -57,13 +60,19 @@ where let new_sgt = sgt_balance.saturating_sub(amount); journal.sstore(SGT_CONTRACT, sgt_slot.into(), new_sgt)?; + if is_native_backed { + journal.load_account_mut(SGT_CONTRACT)?.decr_balance(amount); + } + Ok(()) } -/// Add amount to SGT balance in contract storage +/// Add amount to SGT balance in contract storage. /// /// This performs: `balance[account] += amount` in SGT contract storage. -pub fn add_sgt_balance(journal: &mut JOURNAL, account: Address, amount: U256) -> Result<(), ::Error> +/// When `is_native_backed` is true, also adds to SGT contract's native balance +/// (matching op-geth's `addSoulBalance` behavior). +pub fn add_sgt_balance(journal: &mut JOURNAL, account: Address, amount: U256, is_native_backed: bool) -> Result<(), ::Error> where JOURNAL: JournalTr, { @@ -78,5 +87,9 @@ where let new_sgt = current_sgt.saturating_add(amount); journal.sstore(SGT_CONTRACT, sgt_slot.into(), new_sgt)?; + if is_native_backed { + journal.load_account_mut(SGT_CONTRACT)?.incr_balance(amount); + } + Ok(()) } From b1a98a95454867e9bd6a58d08e085b6e1c20df34 Mon Sep 17 00:00:00 2001 From: blockchaindevsh Date: Wed, 1 Apr 2026 11:22:24 +0800 Subject: [PATCH 06/11] feat(op-revm): add SGT fee burning via collect_native_balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When SGT is enabled and not native-backed, fee recipients should only receive the native portion of fees — the SGT portion is burned. This matches op-geth's collectNativeBalance behavior. - Add collect_native_balance to sgt.rs that splits fees between SGT (burned) and native (paid) pools - Change reward_beneficiary return type to Result so the OP handler can capture the coinbase fee amount - Apply collect_native_balance to coinbase fee and all OP-specific fees (L1, base fee, operator fee) Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/handler/src/handler.rs | 3 +- crates/handler/src/post_execution.rs | 10 +++++-- crates/op-revm/src/handler.rs | 44 +++++++++++++++++++++++++--- crates/op-revm/src/sgt.rs | 31 ++++++++++++++++++++ 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/crates/handler/src/handler.rs b/crates/handler/src/handler.rs index 6530e1e64d..fc78b55dcc 100644 --- a/crates/handler/src/handler.rs +++ b/crates/handler/src/handler.rs @@ -451,7 +451,8 @@ pub trait Handler { evm: &mut Self::Evm, exec_result: &mut <::Frame as FrameTr>::FrameResult, ) -> Result<(), Self::Error> { - post_execution::reward_beneficiary(evm.ctx(), exec_result.gas()).map_err(From::from) + post_execution::reward_beneficiary(evm.ctx(), exec_result.gas())?; + Ok(()) } /// Processes the final execution output. diff --git a/crates/handler/src/post_execution.rs b/crates/handler/src/post_execution.rs index b5a8bef35d..ece1025f7b 100644 --- a/crates/handler/src/post_execution.rs +++ b/crates/handler/src/post_execution.rs @@ -54,11 +54,13 @@ pub fn reimburse_caller( } /// Rewards the beneficiary with transaction fees. +/// +/// Returns the coinbase fee amount that was credited to the beneficiary. #[inline] pub fn reward_beneficiary( context: &mut CTX, gas: &Gas, -) -> Result<(), ::Error> { +) -> Result::Error> { let (block, tx, cfg, journal, _, _) = context.all_mut(); let basefee = block.basefee() as u128; let effective_gas_price = tx.effective_gas_price(basefee); @@ -71,12 +73,14 @@ pub fn reward_beneficiary( effective_gas_price }; + let coinbase_fee = U256::from(coinbase_gas_price * gas.used() as u128); + // reward beneficiary journal .load_account_mut(block.beneficiary())? - .incr_balance(U256::from(coinbase_gas_price * gas.used() as u128)); + .incr_balance(coinbase_fee); - Ok(()) + Ok(coinbase_fee) } /// Calculate last gas spent and transform internal reason to external. diff --git a/crates/op-revm/src/handler.rs b/crates/op-revm/src/handler.rs index 3894a0ff24..025addfbd4 100644 --- a/crates/op-revm/src/handler.rs +++ b/crates/op-revm/src/handler.rs @@ -2,7 +2,7 @@ use crate::{ api::exec::OpContextTr, constants::{BASE_FEE_RECIPIENT, L1_FEE_RECIPIENT, OPERATOR_FEE_RECIPIENT}, - sgt::{add_sgt_balance, deduct_sgt_balance, read_sgt_balance}, + sgt::{add_sgt_balance, collect_native_balance, deduct_sgt_balance, read_sgt_balance}, transaction::{deposit::DEPOSIT_TRANSACTION_TYPE, OpTransactionError, OpTxTr}, L1BlockInfo, OpHaltReason, OpSpecId, }; @@ -430,7 +430,32 @@ where return Ok(()); } - self.mainnet.reward_beneficiary(evm, frame_result)?; + // Call post_execution::reward_beneficiary directly to get the coinbase fee amount + let coinbase_fee = post_execution::reward_beneficiary(evm.ctx(), frame_result.gas()) + .map_err(|e| ERROR::from(ContextError::Db(e)))?; + + let is_sgt = evm.ctx().cfg().is_sgt_enabled(); + let is_native_backed = evm.ctx().cfg().is_sgt_native_backed(); + + // SGT: burn the non-native portion of the coinbase fee + if is_sgt { + let chain = evm.ctx().chain_mut(); + let actual = collect_native_balance( + coinbase_fee, + is_native_backed, + &mut chain.sgt_amount_deducted, + &mut chain.sgt_native_deducted, + ); + let burned = coinbase_fee.saturating_sub(actual); + if !burned.is_zero() { + let beneficiary = evm.ctx().block().beneficiary(); + evm.ctx() + .journal_mut() + .load_account_mut(beneficiary)? + .decr_balance(burned); + } + } + let basefee = evm.ctx().block().basefee() as u128; // If the transaction is not a deposit transaction, fees are paid out @@ -458,13 +483,24 @@ where }; let base_fee_amount = U256::from(basefee.saturating_mul(frame_result.gas().used() as u128)); - // Send fees to their respective recipients + // Send fees to their respective recipients, applying SGT burning if enabled for (recipient, amount) in [ (L1_FEE_RECIPIENT, l1_cost), (BASE_FEE_RECIPIENT, base_fee_amount), (OPERATOR_FEE_RECIPIENT, operator_fee_cost), ] { - ctx.journal_mut().balance_incr(recipient, amount)?; + let actual = if is_sgt { + let chain = ctx.chain_mut(); + collect_native_balance( + amount, + is_native_backed, + &mut chain.sgt_amount_deducted, + &mut chain.sgt_native_deducted, + ) + } else { + amount + }; + ctx.journal_mut().balance_incr(recipient, actual)?; } Ok(()) diff --git a/crates/op-revm/src/sgt.rs b/crates/op-revm/src/sgt.rs index ddb569a135..4ff1135399 100644 --- a/crates/op-revm/src/sgt.rs +++ b/crates/op-revm/src/sgt.rs @@ -67,6 +67,37 @@ where Ok(()) } +/// Collect native balance from a fee amount, burning the SGT portion. +/// +/// When SGT is enabled and not native-backed, fees are split between SGT and native pools. +/// The SGT portion is burned (not paid to recipient), while the native portion goes to the +/// recipient. This matches op-geth's `collectNativeBalance`. +/// +/// Deducts from `sgt_remaining` first (burned), then from `native_remaining` (to recipient). +/// Both pools are mutated in place. Returns the native amount that should be paid to the recipient. +/// +/// Returns `amount` unchanged when `sgt_remaining == 0` or `is_native_backed`. +pub fn collect_native_balance( + amount: U256, + is_native_backed: bool, + sgt_remaining: &mut U256, + native_remaining: &mut U256, +) -> U256 { + if is_native_backed || sgt_remaining.is_zero() { + return amount; + } + + // Burn from SGT pool first + let sgt_burn = amount.min(*sgt_remaining); + *sgt_remaining = sgt_remaining.saturating_sub(sgt_burn); + + // Remainder comes from native pool + let native_part = amount.saturating_sub(sgt_burn).min(*native_remaining); + *native_remaining = native_remaining.saturating_sub(native_part); + + native_part +} + /// Add amount to SGT balance in contract storage. /// /// This performs: `balance[account] += amount` in SGT contract storage. From 3f5854a11ac1cbcf8883a2ac7b19bbdfd2a036ca Mon Sep 17 00:00:00 2001 From: blockchaindevsh Date: Wed, 1 Apr 2026 19:23:03 +0800 Subject: [PATCH 07/11] fix(op-revm): update SGT deduction tracking after refund Update sgt_native_deducted and sgt_amount_deducted in place during reimburse_caller_sgt, matching op-geth's deductGasFrom which mutates usedNativeBalance/usedSGTBalance in place. This ensures reward_beneficiary sees post-refund pool amounts when calling collect_native_balance. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/op-revm/src/handler.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/op-revm/src/handler.rs b/crates/op-revm/src/handler.rs index 025addfbd4..15077d2c78 100644 --- a/crates/op-revm/src/handler.rs +++ b/crates/op-revm/src/handler.rs @@ -99,8 +99,12 @@ where } // Refund in REVERSE priority: native first (up to what was deducted), then SGT + // Update chain tracking in place so reward_beneficiary sees post-refund amounts + // (matching op-geth's deductGasFrom which mutates pools in place). let native_refund = gas_refund.min(chain.sgt_native_deducted); let sgt_refund = gas_refund.saturating_sub(native_refund).min(chain.sgt_amount_deducted); + chain.sgt_native_deducted -= native_refund; + chain.sgt_amount_deducted -= sgt_refund; // Refund to native balance if !native_refund.is_zero() { From 14c22d007259b79d60b0076555fdd8b1889def27 Mon Sep 17 00:00:00 2001 From: blockchaindevsh Date: Fri, 3 Apr 2026 11:25:52 +0800 Subject: [PATCH 08/11] fix cargo test -p op-revm --lib --- crates/op-revm/src/handler.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/op-revm/src/handler.rs b/crates/op-revm/src/handler.rs index 15077d2c78..633ff50667 100644 --- a/crates/op-revm/src/handler.rs +++ b/crates/op-revm/src/handler.rs @@ -919,7 +919,9 @@ mod tests { operator_fee_scalar: Some(U256::from(OPERATOR_FEE_SCALAR)), operator_fee_constant: Some(U256::from(OPERATOR_FEE_CONST)), tx_l1_cost: Some(U256::ZERO), - da_footprint_gas_scalar: None + da_footprint_gas_scalar: None, + sgt_amount_deducted: U256::ZERO, + sgt_native_deducted: U256::ZERO, } ); } @@ -1014,6 +1016,8 @@ mod tests { operator_fee_constant: Some(U256::from(OPERATOR_FEE_CONST)), tx_l1_cost: Some(U256::ZERO), da_footprint_gas_scalar: Some(DA_FOOTPRINT_GAS_SCALAR as u16), + sgt_amount_deducted: U256::ZERO, + sgt_native_deducted: U256::ZERO, } ); } From bb815527759b4979c6b6e568850c5581f27ebde5 Mon Sep 17 00:00:00 2001 From: blockchaindevsh Date: Fri, 3 Apr 2026 15:51:50 +0800 Subject: [PATCH 09/11] fix(op-revm): avoid warming SGT contract during pre/post execution SGT balance operations (read, deduct, add) use journal APIs that mark accounts and storage slots as warm. This causes the SGT predeploy and its balance slots to appear warm during EVM execution, diverging from op-geth where GetState/SetState don't affect the EIP-2929 access list. Add `no_warm` parameter to `load_account_mut_optional`, `sload_concrete_error`, and `sstore_concrete_error` that skips warming side-effects (mark_warm_with_transaction_id, account_warmed/ storage_warmed journal entries) while still loading state and computing is_cold accurately. Expose `_no_warm` convenience methods on JournalTr (load_account_no_warm, sload_no_warm, sstore_no_warm, load_account_mut_no_warm) and update all SGT functions to use them. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../context/interface/src/journaled_state.rs | 40 ++++++++ .../interface/src/journaled_state/account.rs | 17 +++- crates/context/src/journal.rs | 35 ++++++- crates/context/src/journal/inner.rs | 95 ++++++++++++++++--- crates/op-revm/src/sgt.rs | 32 ++++--- 5 files changed, 187 insertions(+), 32 deletions(-) diff --git a/crates/context/interface/src/journaled_state.rs b/crates/context/interface/src/journaled_state.rs index 33c085a705..fd1adaf075 100644 --- a/crates/context/interface/src/journaled_state.rs +++ b/crates/context/interface/src/journaled_state.rs @@ -80,6 +80,37 @@ pub trait JournalTr { _skip_cold_load: bool, ) -> Result, JournalLoadError<::Error>>; + /// Loads storage value without affecting warm/cold status. + /// + /// Used for protocol-level operations (e.g., SGT gas payment) that should not + /// influence EIP-2929 gas metering during execution. + fn sload_no_warm( + &mut self, + address: Address, + key: StorageKey, + ) -> Result::Error>; + + /// Stores storage value without affecting warm/cold status. + /// + /// Used for protocol-level operations (e.g., SGT gas payment) that should not + /// influence EIP-2929 gas metering during execution. Still journals the storage + /// change so reverts work correctly. + fn sstore_no_warm( + &mut self, + address: Address, + key: StorageKey, + value: StorageValue, + ) -> Result<(), ::Error>; + + /// Loads account mutably without affecting warm/cold status. + /// + /// Used for protocol-level balance modifications (e.g., SGT native-backed balance + /// sync) that should not influence EIP-2929 gas metering during execution. + fn load_account_mut_no_warm( + &mut self, + address: Address, + ) -> Result, ::Error>; + /// Loads transient storage value. fn tload(&mut self, address: Address, key: StorageKey) -> StorageValue; @@ -161,6 +192,15 @@ pub trait JournalTr { address: Address, ) -> Result, ::Error>; + /// Loads the account without affecting warm/cold status. + /// + /// Used for protocol-level operations (e.g., SGT) that should not influence + /// EIP-2929 gas metering during execution. + fn load_account_no_warm( + &mut self, + address: Address, + ) -> Result, ::Error>; + /// Loads the account code, use `load_account_with_code` instead. #[inline] #[deprecated(note = "Use `load_account_with_code` instead")] diff --git a/crates/context/interface/src/journaled_state/account.rs b/crates/context/interface/src/journaled_state/account.rs index ecba313060..f7fc5090ac 100644 --- a/crates/context/interface/src/journaled_state/account.rs +++ b/crates/context/interface/src/journaled_state/account.rs @@ -160,6 +160,9 @@ impl<'a, DB: Database, ENTRY: JournalEntryTr> JournaledAccount<'a, DB, ENTRY> { /// Loads the storage slot. /// /// If storage is cold and skip_cold_load is true, it will return [`JournalLoadError::ColdLoadSkipped`] error. + /// If `no_warm` is true, the slot is loaded without marking it warm or pushing warming + /// journal entries (used for protocol-level operations like SGT that should not + /// influence EIP-2929 gas metering). /// /// Does not erase the db error. #[inline(never)] @@ -167,6 +170,7 @@ impl<'a, DB: Database, ENTRY: JournalEntryTr> JournaledAccount<'a, DB, ENTRY> { &mut self, key: StorageKey, skip_cold_load: bool, + no_warm: bool, ) -> Result, JournalLoadError> { let is_newly_created = self.account.is_created(); let (slot, is_cold) = match self.account.storage.entry(key) { @@ -186,7 +190,9 @@ impl<'a, DB: Database, ENTRY: JournalEntryTr> JournaledAccount<'a, DB, ENTRY> { return Err(JournalLoadError::ColdLoadSkipped); } } - slot.mark_warm_with_transaction_id(self.transaction_id); + if !no_warm { + slot.mark_warm_with_transaction_id(self.transaction_id); + } (slot, is_cold) } Entry::Vacant(vac) => { @@ -200,6 +206,7 @@ impl<'a, DB: Database, ENTRY: JournalEntryTr> JournaledAccount<'a, DB, ENTRY> { if is_cold && skip_cold_load { return Err(JournalLoadError::ColdLoadSkipped); } + // if storage was cleared, we don't need to ping db. let value = if is_newly_created { StorageValue::ZERO @@ -224,6 +231,7 @@ impl<'a, DB: Database, ENTRY: JournalEntryTr> JournaledAccount<'a, DB, ENTRY> { /// Stores the storage slot. /// /// If storage is cold and skip_cold_load is true, it will return [`JournalLoadError::ColdLoadSkipped`] error. + /// If `no_warm` is true, storage is accessed without affecting warm/cold status. /// /// Does not erase the db error. #[inline] @@ -232,12 +240,13 @@ impl<'a, DB: Database, ENTRY: JournalEntryTr> JournaledAccount<'a, DB, ENTRY> { key: StorageKey, new: StorageValue, skip_cold_load: bool, + no_warm: bool, ) -> Result, JournalLoadError> { // touch the account so changes are tracked. self.touch(); // assume that acc exists and load the slot. - let slot = self.sload_concrete_error(key, skip_cold_load)?; + let slot = self.sload_concrete_error(key, skip_cold_load, no_warm)?; let ret = Ok(StateLoad::new( SStoreResult { @@ -465,7 +474,7 @@ impl<'a, DB: Database, ENTRY: JournalEntryTr> JournaledAccountTr key: StorageKey, skip_cold_load: bool, ) -> Result, JournalLoadErasedError> { - self.sload_concrete_error(key, skip_cold_load) + self.sload_concrete_error(key, skip_cold_load, false) .map_err(|i| i.map(ErasedError::new)) } @@ -477,7 +486,7 @@ impl<'a, DB: Database, ENTRY: JournalEntryTr> JournaledAccountTr new: StorageValue, skip_cold_load: bool, ) -> Result, JournalLoadErasedError> { - self.sstore_concrete_error(key, new, skip_cold_load) + self.sstore_concrete_error(key, new, skip_cold_load, false) .map_err(|i| i.map(ErasedError::new)) } diff --git a/crates/context/src/journal.rs b/crates/context/src/journal.rs index 6814627d10..bbb59860a3 100644 --- a/crates/context/src/journal.rs +++ b/crates/context/src/journal.rs @@ -134,6 +134,32 @@ impl JournalTr for Journal { .map_err(JournalLoadError::unwrap_db_error) } + fn sload_no_warm( + &mut self, + address: Address, + key: StorageKey, + ) -> Result::Error> { + self.inner.sload_no_warm(&mut self.database, address, key) + } + + fn sstore_no_warm( + &mut self, + address: Address, + key: StorageKey, + value: StorageValue, + ) -> Result<(), ::Error> { + self.inner + .sstore_no_warm(&mut self.database, address, key, value) + } + + fn load_account_mut_no_warm( + &mut self, + address: Address, + ) -> Result, ::Error> { + self.inner + .load_account_mut_no_warm(&mut self.database, address) + } + fn tload(&mut self, address: Address, key: StorageKey) -> StorageValue { self.inner.tload(address, key) } @@ -256,6 +282,13 @@ impl JournalTr for Journal { self.inner.load_account(&mut self.database, address) } + fn load_account_no_warm(&mut self, address: Address) -> Result, DB::Error> { + self.inner + .load_account_mut_optional(&mut self.database, address, false, true) + .map_err(JournalLoadError::unwrap_db_error) + .map(|s| s.map(|j| j.into_account())) + } + #[inline] fn load_account_mut_skip_cold_load( &mut self, @@ -263,7 +296,7 @@ impl JournalTr for Journal { skip_cold_load: bool, ) -> Result>, DB::Error> { self.inner - .load_account_mut_optional(&mut self.database, address, skip_cold_load) + .load_account_mut_optional(&mut self.database, address, skip_cold_load, false) .map_err(JournalLoadError::unwrap_db_error) } diff --git a/crates/context/src/journal/inner.rs b/crates/context/src/journal/inner.rs index a8755aa274..18c3fc0570 100644 --- a/crates/context/src/journal/inner.rs +++ b/crates/context/src/journal/inner.rs @@ -651,7 +651,7 @@ impl JournalInner { where 'db: 'a, { - let mut load = self.load_account_mut_optional(db, address, skip_cold_load)?; + let mut load = self.load_account_mut_optional(db, address, skip_cold_load, false)?; if load_code { load.data.load_code_preserve_error()?; } @@ -668,7 +668,7 @@ impl JournalInner { where 'db: 'a, { - self.load_account_mut_optional(db, address, false) + self.load_account_mut_optional(db, address, false, false) .map_err(JournalLoadError::unwrap_db_error) } @@ -684,7 +684,7 @@ impl JournalInner { where 'db: 'a, { - let mut load = self.load_account_mut_optional(db, address, skip_cold_load)?; + let mut load = self.load_account_mut_optional(db, address, skip_cold_load, false)?; if load_code { load.data.load_code_preserve_error()?; } @@ -727,6 +727,7 @@ impl JournalInner { db: &'db mut DB, address: Address, skip_cold_load: bool, + no_warm: bool, ) -> Result>, JournalLoadError> where 'db: 'a, @@ -743,9 +744,13 @@ impl JournalInner { .warm_addresses .check_is_cold(&address, skip_cold_load)?; - // mark it warm. - account.mark_warm_with_transaction_id(self.transaction_id); + if !no_warm { + // mark it warm. + account.mark_warm_with_transaction_id(self.transaction_id); + } + } + if is_cold { // if it is cold loaded and we have selfdestructed locally it means that // account was selfdestructed in previous transaction and we need to clear its information and storage. if account.is_selfdestructed_locally() { @@ -758,13 +763,15 @@ impl JournalInner { // unmark locally created account.unmark_created_locally(); - // journal loading of cold account. - self.journal.push(ENTRY::account_warmed(address)); + if !no_warm { + // journal loading of cold account. + self.journal.push(ENTRY::account_warmed(address)); + } } (account, is_cold) } Entry::Vacant(vac) => { - // Precompiles, among some other account(access list and coinbase included) + // Precompiles, among some other accounts (access list and coinbase included) // are warm loaded so we need to take that into account let is_cold = self .warm_addresses @@ -779,7 +786,7 @@ impl JournalInner { }; // journal loading of cold account. - if is_cold { + if is_cold && !no_warm { self.journal.push(ENTRY::account_warmed(address)); } @@ -810,7 +817,7 @@ impl JournalInner { skip_cold_load: bool, ) -> Result, JournalLoadError> { self.load_account_mut(db, address)? - .sload_concrete_error(key, skip_cold_load) + .sload_concrete_error(key, skip_cold_load, false) .map(|s| s.map(|s| s.present_value)) } @@ -830,7 +837,7 @@ impl JournalInner { }; account - .sload_concrete_error(key, skip_cold_load) + .sload_concrete_error(key, skip_cold_load, false) .map(|s| s.map(|s| s.present_value)) } @@ -847,7 +854,7 @@ impl JournalInner { skip_cold_load: bool, ) -> Result, JournalLoadError> { self.load_account_mut(db, address)? - .sstore_concrete_error(key, new, skip_cold_load) + .sstore_concrete_error(key, new, skip_cold_load, false) } /// Stores storage slot. @@ -868,7 +875,69 @@ impl JournalInner { return Err(JournalLoadError::ColdLoadSkipped); }; - account.sstore_concrete_error(key, new, skip_cold_load) + account.sstore_concrete_error(key, new, skip_cold_load, false) + } + + /// Loads storage slot without affecting warm/cold status. + /// + /// Used for protocol-level operations (e.g., SGT) that should not influence + /// EIP-2929 gas metering during execution. + #[inline] + pub fn sload_no_warm( + &mut self, + db: &mut DB, + address: Address, + key: StorageKey, + ) -> Result { + let Some(mut account) = self.get_account_mut(db, address) else { + panic!("sload_no_warm: account {address} not loaded; call load_account_no_warm first"); + }; + account + .sload_concrete_error(key, false, true) + .map_err(JournalLoadError::unwrap_db_error) + .map(|s| s.data.present_value) + } + + /// Stores storage slot without affecting warm/cold status. + /// + /// Used for protocol-level operations (e.g., SGT) that should not influence + /// EIP-2929 gas metering during execution. Still journals storage changes + /// so reverts work correctly. + /// + /// Account must already be loaded via `load_account_no_warm`. + #[inline] + pub fn sstore_no_warm( + &mut self, + db: &mut DB, + address: Address, + key: StorageKey, + new: StorageValue, + ) -> Result<(), DB::Error> { + let Some(mut account) = self.get_account_mut(db, address) else { + panic!("sstore_no_warm: account {address} not loaded; call load_account_no_warm first"); + }; + account + .sstore_concrete_error(key, new, false, true) + .map_err(JournalLoadError::unwrap_db_error)?; + Ok(()) + } + + /// Loads account mutably without affecting warm/cold status. + /// + /// Used for protocol-level balance modifications (e.g., SGT) that should not + /// influence EIP-2929 gas metering during execution. + #[inline] + pub fn load_account_mut_no_warm<'a, 'db, DB: Database>( + &'a mut self, + db: &'db mut DB, + address: Address, + ) -> Result, DB::Error> + where + 'db: 'a, + { + self.load_account_mut_optional(db, address, false, true) + .map_err(JournalLoadError::unwrap_db_error) + .map(|s| s.data) } /// Read transient storage tied to the account. diff --git a/crates/op-revm/src/sgt.rs b/crates/op-revm/src/sgt.rs index 4ff1135399..d897dff8cf 100644 --- a/crates/op-revm/src/sgt.rs +++ b/crates/op-revm/src/sgt.rs @@ -29,15 +29,17 @@ pub fn sgt_balance_slot(account: Address) -> B256 { keccak256(data) } -/// Read SGT balance from contract storage +/// Read SGT balance from contract storage. +/// +/// Uses `_no_warm` journal methods to avoid affecting EIP-2929 warm/cold status, +/// matching op-geth's `GetSoulBalance` which uses `GetState` (no access list warming). pub fn read_sgt_balance(journal: &mut JOURNAL, account: Address) -> Result::Error> where JOURNAL: JournalTr, { - journal.load_account(SGT_CONTRACT)?; + journal.load_account_no_warm(SGT_CONTRACT)?; let sgt_slot = sgt_balance_slot(account); - let state_load = journal.sload(SGT_CONTRACT, sgt_slot.into())?; - Ok(state_load.data) + journal.sload_no_warm(SGT_CONTRACT, sgt_slot.into()) } /// Deduct amount from SGT balance in contract storage. @@ -45,6 +47,8 @@ where /// This performs: `balance[account] -= amount` in SGT contract storage. /// When `is_native_backed` is true, also deducts from SGT contract's native balance /// (matching op-geth's `subSoulBalance` behavior). +/// +/// Uses `_no_warm` journal methods to avoid affecting EIP-2929 warm/cold status. pub fn deduct_sgt_balance(journal: &mut JOURNAL, account: Address, amount: U256, is_native_backed: bool) -> Result<(), ::Error> where JOURNAL: JournalTr, @@ -53,15 +57,14 @@ where return Ok(()); } - journal.load_account(SGT_CONTRACT)?; + journal.load_account_no_warm(SGT_CONTRACT)?; let sgt_slot = sgt_balance_slot(account); - let state_load = journal.sload(SGT_CONTRACT, sgt_slot.into())?; - let sgt_balance = state_load.data; + let sgt_balance = journal.sload_no_warm(SGT_CONTRACT, sgt_slot.into())?; let new_sgt = sgt_balance.saturating_sub(amount); - journal.sstore(SGT_CONTRACT, sgt_slot.into(), new_sgt)?; + journal.sstore_no_warm(SGT_CONTRACT, sgt_slot.into(), new_sgt)?; if is_native_backed { - journal.load_account_mut(SGT_CONTRACT)?.decr_balance(amount); + journal.load_account_mut_no_warm(SGT_CONTRACT)?.decr_balance(amount); } Ok(()) @@ -103,6 +106,8 @@ pub fn collect_native_balance( /// This performs: `balance[account] += amount` in SGT contract storage. /// When `is_native_backed` is true, also adds to SGT contract's native balance /// (matching op-geth's `addSoulBalance` behavior). +/// +/// Uses `_no_warm` journal methods to avoid affecting EIP-2929 warm/cold status. pub fn add_sgt_balance(journal: &mut JOURNAL, account: Address, amount: U256, is_native_backed: bool) -> Result<(), ::Error> where JOURNAL: JournalTr, @@ -111,15 +116,14 @@ where return Ok(()); } - journal.load_account(SGT_CONTRACT)?; + journal.load_account_no_warm(SGT_CONTRACT)?; let sgt_slot = sgt_balance_slot(account); - let state_load = journal.sload(SGT_CONTRACT, sgt_slot.into())?; - let current_sgt = state_load.data; + let current_sgt = journal.sload_no_warm(SGT_CONTRACT, sgt_slot.into())?; let new_sgt = current_sgt.saturating_add(amount); - journal.sstore(SGT_CONTRACT, sgt_slot.into(), new_sgt)?; + journal.sstore_no_warm(SGT_CONTRACT, sgt_slot.into(), new_sgt)?; if is_native_backed { - journal.load_account_mut(SGT_CONTRACT)?.incr_balance(amount); + journal.load_account_mut_no_warm(SGT_CONTRACT)?.incr_balance(amount); } Ok(()) From b82be887a337ae50953bbcd5de6e14abbd147b35 Mon Sep 17 00:00:00 2001 From: blockchaindevsh Date: Thu, 9 Apr 2026 11:00:05 +0800 Subject: [PATCH 10/11] fix(revm): fix no_warm transaction_id leak and add default JournalTr impls Newly inserted accounts and storage slots in Vacant branches were getting transaction_id = current, making them appear warm to later normal accesses via is_cold_transaction_id(). Also storage_warmed journal entries were still pushed when no_warm was true. Fix by using transaction_id = 0 when no_warm, and guarding storage_warmed push with !no_warm. Add default implementations for _no_warm methods on JournalTr that delegate to the warming variants, avoiding breaking external impls. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/context/interface/src/journaled_state.rs | 17 +++++++++++++---- .../interface/src/journaled_state/account.rs | 7 +++++-- crates/context/src/journal/inner.rs | 7 +++++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/context/interface/src/journaled_state.rs b/crates/context/interface/src/journaled_state.rs index fd1adaf075..280d1a0b6a 100644 --- a/crates/context/interface/src/journaled_state.rs +++ b/crates/context/interface/src/journaled_state.rs @@ -88,7 +88,9 @@ pub trait JournalTr { &mut self, address: Address, key: StorageKey, - ) -> Result::Error>; + ) -> Result::Error> { + self.sload(address, key).map(|s| s.data) + } /// Stores storage value without affecting warm/cold status. /// @@ -100,7 +102,10 @@ pub trait JournalTr { address: Address, key: StorageKey, value: StorageValue, - ) -> Result<(), ::Error>; + ) -> Result<(), ::Error> { + self.sstore(address, key, value)?; + Ok(()) + } /// Loads account mutably without affecting warm/cold status. /// @@ -109,7 +114,9 @@ pub trait JournalTr { fn load_account_mut_no_warm( &mut self, address: Address, - ) -> Result, ::Error>; + ) -> Result, ::Error> { + self.load_account_mut(address).map(|s| s.data) + } /// Loads transient storage value. fn tload(&mut self, address: Address, key: StorageKey) -> StorageValue; @@ -199,7 +206,9 @@ pub trait JournalTr { fn load_account_no_warm( &mut self, address: Address, - ) -> Result, ::Error>; + ) -> Result, ::Error> { + self.load_account(address) + } /// Loads the account code, use `load_account_with_code` instead. #[inline] diff --git a/crates/context/interface/src/journaled_state/account.rs b/crates/context/interface/src/journaled_state/account.rs index f7fc5090ac..99fdf2d108 100644 --- a/crates/context/interface/src/journaled_state/account.rs +++ b/crates/context/interface/src/journaled_state/account.rs @@ -214,12 +214,15 @@ impl<'a, DB: Database, ENTRY: JournalEntryTr> JournaledAccount<'a, DB, ENTRY> { self.db.storage(self.address, key)? }; - let slot = vac.insert(EvmStorageSlot::new(value, self.transaction_id)); + // When no_warm, don't set transaction_id so the slot stays + // cold to later normal accesses (is_cold_transaction_id). + let tid = if no_warm { 0 } else { self.transaction_id }; + let slot = vac.insert(EvmStorageSlot::new(value, tid)); (slot, is_cold) } }; - if is_cold { + if is_cold && !no_warm { // add it to journal as cold loaded. self.journal_entries .push(ENTRY::storage_warmed(self.address, key)); diff --git a/crates/context/src/journal/inner.rs b/crates/context/src/journal/inner.rs index 18c3fc0570..80941c5fca 100644 --- a/crates/context/src/journal/inner.rs +++ b/crates/context/src/journal/inner.rs @@ -777,12 +777,15 @@ impl JournalInner { .warm_addresses .check_is_cold(&address, skip_cold_load)?; + // When no_warm, don't set transaction_id so the account stays + // cold to later normal accesses (is_cold_transaction_id). + let tid = if no_warm { 0 } else { self.transaction_id }; let account = if let Some(account) = db.basic(address)? { let mut account: Account = account.into(); - account.transaction_id = self.transaction_id; + account.transaction_id = tid; account } else { - Account::new_not_existing(self.transaction_id) + Account::new_not_existing(tid) }; // journal loading of cold account. From 5d00d0145ced62e3175bb1dd41aa0a5236a22fcc Mon Sep 17 00:00:00 2001 From: blockchaindevsh Date: Thu, 9 Apr 2026 21:14:25 +0800 Subject: [PATCH 11/11] fix(revm): change default no_warm JournalTr impls to panic The _no_warm methods are only called from SGT/Optimism code paths. Chains that don't use SGT never hit the defaults, and chains that do must provide proper implementations. Silently delegating to warming variants hid misconfigurations as subtle gas mismatches; panicking surfaces them immediately. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/context/interface/src/journaled_state.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/context/interface/src/journaled_state.rs b/crates/context/interface/src/journaled_state.rs index 280d1a0b6a..fcf54933fd 100644 --- a/crates/context/interface/src/journaled_state.rs +++ b/crates/context/interface/src/journaled_state.rs @@ -89,7 +89,8 @@ pub trait JournalTr { address: Address, key: StorageKey, ) -> Result::Error> { - self.sload(address, key).map(|s| s.data) + let _ = (address, key); + unimplemented!("sload_no_warm not implemented — required for SGT support") } /// Stores storage value without affecting warm/cold status. @@ -103,8 +104,8 @@ pub trait JournalTr { key: StorageKey, value: StorageValue, ) -> Result<(), ::Error> { - self.sstore(address, key, value)?; - Ok(()) + let _ = (address, key, value); + unimplemented!("sstore_no_warm not implemented — required for SGT support") } /// Loads account mutably without affecting warm/cold status. @@ -115,7 +116,8 @@ pub trait JournalTr { &mut self, address: Address, ) -> Result, ::Error> { - self.load_account_mut(address).map(|s| s.data) + let _ = address; + unimplemented!("load_account_mut_no_warm not implemented — required for SGT support") } /// Loads transient storage value. @@ -207,7 +209,8 @@ pub trait JournalTr { &mut self, address: Address, ) -> Result, ::Error> { - self.load_account(address) + let _ = address; + unimplemented!("load_account_no_warm not implemented — required for SGT support") } /// Loads the account code, use `load_account_with_code` instead.