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/interface/src/journaled_state.rs b/crates/context/interface/src/journaled_state.rs index 33c085a705..fcf54933fd 100644 --- a/crates/context/interface/src/journaled_state.rs +++ b/crates/context/interface/src/journaled_state.rs @@ -80,6 +80,46 @@ 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> { + let _ = (address, key); + unimplemented!("sload_no_warm not implemented — required for SGT support") + } + + /// 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> { + let _ = (address, key, value); + unimplemented!("sstore_no_warm not implemented — required for SGT support") + } + + /// 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> { + let _ = address; + unimplemented!("load_account_mut_no_warm not implemented — required for SGT support") + } + /// Loads transient storage value. fn tload(&mut self, address: Address, key: StorageKey) -> StorageValue; @@ -161,6 +201,18 @@ 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> { + let _ = address; + unimplemented!("load_account_no_warm not implemented — required for SGT support") + } + /// 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..99fdf2d108 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 @@ -207,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)); @@ -224,6 +234,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 +243,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 +477,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 +489,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/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/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..80941c5fca 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,28 +763,33 @@ 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 .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. - if is_cold { + if is_cold && !no_warm { self.journal.push(ENTRY::account_warmed(address)); } @@ -810,7 +820,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 +840,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 +857,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 +878,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/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 ada5a49a0f..194b7d1cab 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, collect_native_balance, deduct_sgt_balance, read_sgt_balance}, transaction::{deposit::DEPOSIT_TRANSACTION_TYPE, OpTransactionError, OpTxTr}, L1BlockInfo, OpHaltReason, OpSpecId, }; @@ -66,6 +67,61 @@ 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, 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( + 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 + // 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; + + eprintln!("[SGT] reimburse_caller: gas_refund={} native_refund={} sgt_refund={}", gas_refund, native_refund, sgt_refund); + + // 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, is_native_backed)?; + + Ok(()) + } +} + impl Handler for OpHandler where EVM: EvmTr, @@ -161,6 +217,84 @@ where "[OPTIMISM] Failed to load enveloped transaction.".into(), )); }; + + // NEW: SGT-aware gas deduction path (early return to preserve original code below) + if cfg.is_sgt_enabled() { + eprintln!("[SGT] validate_and_deduct: caller={:?} native_balance={} additional_cost={}", tx.caller(), balance, additional_cost); + // 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())?; + eprintln!("[SGT] validate_and_deduct: sgt_balance={} total_cost={}", sgt_balance, total_cost); + + // Check total balance (native + SGT) >= total_cost + let total_balance = balance.saturating_add(sgt_balance); + if total_cost > total_balance { + eprintln!("[SGT] validate_and_deduct: REJECTED total_cost={} > total_balance={}", 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 + // 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() { + 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 + let is_native_backed = cfg.is_sgt_native_backed(); + eprintln!("[SGT] validate_and_deduct: sgt_deduct={} native_deduct={} is_native_backed={}", sgt_to_deduct, native_to_deduct, is_native_backed); + deduct_sgt_balance(journal, tx.caller(), sgt_to_deduct, is_native_backed)?; + + eprintln!("[SGT] validate_and_deduct: DONE"); + 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 +396,12 @@ where .operator_fee_refund(frame_result.gas(), spec); } + // NEW: SGT-aware refund logic (early return to preserve original code) + 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); + } + + // ORIGINAL: Standard refund (unchanged) reimburse_caller(evm.ctx(), frame_result.gas(), additional_refund).map_err(From::from) } @@ -301,7 +441,34 @@ 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 { + eprintln!("[SGT] reward_beneficiary: coinbase_fee={}", coinbase_fee); + 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); + eprintln!("[SGT] reward_beneficiary: actual_native={} burned={}", actual, burned); + 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 @@ -318,6 +485,7 @@ where }; let l1_cost = l1_block_info.calculate_tx_l1_cost(enveloped_tx, spec); + eprintln!("[SGT] reward_beneficiary: l1_cost={}", l1_cost); let operator_fee_cost = if spec.is_enabled_in(OpSpecId::ISTHMUS) { l1_block_info.operator_fee_charge( enveloped_tx, @@ -329,15 +497,28 @@ 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 + }; + eprintln!("[SGT] reward_beneficiary: recipient={:?} amount={} actual={}", recipient, amount, actual); + ctx.journal_mut().balance_incr(recipient, actual)?; } + eprintln!("[SGT] reward_beneficiary: DONE"); Ok(()) } @@ -750,7 +931,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, } ); } @@ -845,6 +1028,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, } ); } diff --git a/crates/op-revm/src/l1block.rs b/crates/op-revm/src/l1block.rs index eff2ab0a53..c0974307f0 100644 --- a/crates/op-revm/src/l1block.rs +++ b/crates/op-revm/src/l1block.rs @@ -53,6 +53,10 @@ 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, + /// Amount of native balance deducted for SGT gas payment (for refund calculation) + pub sgt_native_deducted: U256, + /// Amount of SGT balance deducted for gas payment (for refund calculation) + pub sgt_amount_deducted: U256, } impl L1BlockInfo { @@ -251,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. 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..d897dff8cf --- /dev/null +++ b/crates/op-revm/src/sgt.rs @@ -0,0 +1,130 @@ +//! 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::journaled_state::account::JournaledAccountTr; +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. +/// +/// 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_no_warm(SGT_CONTRACT)?; + let sgt_slot = sgt_balance_slot(account); + journal.sload_no_warm(SGT_CONTRACT, sgt_slot.into()) +} + +/// Deduct amount from SGT balance in contract storage. +/// +/// 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, +{ + if amount.is_zero() { + return Ok(()); + } + + journal.load_account_no_warm(SGT_CONTRACT)?; + let sgt_slot = sgt_balance_slot(account); + let sgt_balance = journal.sload_no_warm(SGT_CONTRACT, sgt_slot.into())?; + let new_sgt = sgt_balance.saturating_sub(amount); + journal.sstore_no_warm(SGT_CONTRACT, sgt_slot.into(), new_sgt)?; + + if is_native_backed { + journal.load_account_mut_no_warm(SGT_CONTRACT)?.decr_balance(amount); + } + + 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. +/// 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, +{ + if amount.is_zero() { + return Ok(()); + } + + journal.load_account_no_warm(SGT_CONTRACT)?; + let sgt_slot = sgt_balance_slot(account); + let current_sgt = journal.sload_no_warm(SGT_CONTRACT, sgt_slot.into())?; + let new_sgt = current_sgt.saturating_add(amount); + journal.sstore_no_warm(SGT_CONTRACT, sgt_slot.into(), new_sgt)?; + + if is_native_backed { + journal.load_account_mut_no_warm(SGT_CONTRACT)?.incr_balance(amount); + } + + Ok(()) +}