diff --git a/rust/alloy-op-evm/src/block/mod.rs b/rust/alloy-op-evm/src/block/mod.rs index 3b73f2f2d6e..926e8c70c9d 100644 --- a/rust/alloy-op-evm/src/block/mod.rs +++ b/rust/alloy-op-evm/src/block/mod.rs @@ -1,7 +1,9 @@ //! Block executor for Optimism. -use crate::OpEvmFactory; -use alloc::{borrow::Cow, boxed::Box, vec::Vec}; +use crate::{OpEvmFactory, spec_by_timestamp_after_bedrock}; +use alloc::{ + borrow::Cow, boxed::Box, collections::BTreeMap, format, string::String, vec, vec::Vec, +}; use alloy_consensus::{Eip658Value, Header, Transaction, TransactionEnvelope, TxReceipt}; use alloy_eips::{Encodable2718, Typed2718}; use alloy_evm::{ @@ -15,24 +17,47 @@ use alloy_evm::{ eth::{EthTxResult, receipt_builder::ReceiptBuilderCtx}, }; use alloy_op_hardforks::{OpChainHardforks, OpHardforks}; -use alloy_primitives::{Address, B256, Bytes}; +use alloy_primitives::{Address, B256, Bytes, U256}; use canyon::ensure_create2_deployer; -use op_alloy::consensus::OpDepositReceipt; +use op_alloy::consensus::{OpDepositReceipt, POST_EXEC_TX_TYPE_ID, PostExecPayload, SDMGasEntry}; use op_revm::{ - L1BlockInfo, OpTransaction, constants::L1_BLOCK_CONTRACT, estimate_tx_compressed_size, + L1BlockInfo, OpTransaction, + constants::{BASE_FEE_RECIPIENT, L1_BLOCK_CONTRACT, OPERATOR_FEE_RECIPIENT}, + estimate_tx_compressed_size, transaction::deposit::DEPOSIT_TRANSACTION_TYPE, }; pub use receipt_builder::OpAlloyReceiptBuilder; use receipt_builder::OpReceiptBuilder; use revm::{ Database as _, DatabaseCommit, Inspector, - context::{Block, result::ResultAndState}, + context::{ + Block, + result::{ExecutionResult, Output, ResultAndState, SuccessReason}, + }, database::DatabaseCommitExt, + state::{Account, AccountStatus, EvmState}, }; +use crate::post_exec::{PostExecExecutedTx, PostExecTxContext, PostExecTxKind}; + mod canyon; pub mod receipt_builder; +/// Default no-op hook installed by [`OpBlockExecutor::new`] for Produce-mode tracking. +/// +/// Kept as a named fn item (not a closure) so `apply_pre_execution_changes` can identity- +/// compare the installed hook against this default via [`core::ptr::fn_addr_eq`] and +/// `debug_assert!` that callers wired the real inspector before driving execution in +/// `PostExecMode::Produce`. +const fn default_begin_post_exec_tx(_: &mut E, _: PostExecTxContext) {} + +/// Default no-op hook installed by [`OpBlockExecutor::new`] for Produce-mode result take. +/// +/// See [`default_begin_post_exec_tx`] — paired with it for the same identity-compare. +const fn default_take_last_post_exec_tx_result(_: &mut E) -> PostExecExecutedTx { + PostExecExecutedTx { refund_total: 0 } +} + /// Trait for OP transaction environments. Allows to recover the transaction encoded bytes if /// they're available. pub trait OpTxEnv { @@ -46,6 +71,67 @@ impl OpTxEnv for OpTransaction { } } +/// Canonical post-exec execution mode for an OP block. +#[derive(Debug, Default, Clone)] +pub enum PostExecMode { + /// Execute with legacy gas accounting. + #[default] + Disabled, + /// Produce canonical post-exec refunds locally and append them to the block later. + Produce, + /// Verify canonical gas accounting using an post-exec payload embedded in the block. + Verify(PostExecPayload), +} + +/// Per-block post-exec state carried by [`OpBlockExecutor`]. +/// +/// Groups mode, inspector hooks and producer/verifier working state so the executor +/// carries a single field for the feature instead of fanning the internals out across +/// its top-level fields. +#[derive(Debug)] +pub struct PostExecState { + /// Active post-exec execution mode. + pub mode: PostExecMode, + /// Accumulated per-tx warming refunds for post-exec tx assembly (sequencer mode). + pub entries: Vec, + /// Verifier payload indexed by original tx index. + pub verify_entries: BTreeMap, + /// Invalid verifier payload reason, if any. + pub invalid_reason: Option, + /// Begin post-exec tracking for the next transaction. + pub begin_tx: fn(&mut E, PostExecTxContext), + /// Extractor for the most recent transaction's exact warming result. + pub take_last_tx_result: fn(&mut E) -> PostExecExecutedTx, +} + +impl PostExecState { + fn new(mode: PostExecMode) -> Self { + let mut verify_entries = BTreeMap::new(); + let mut invalid_reason = None; + + if let PostExecMode::Verify(payload) = &mode { + for entry in &payload.gas_refund_entries { + if verify_entries.insert(entry.index, entry.gas_refund).is_some() { + invalid_reason = Some(format!( + "duplicate post-exec payload entry for tx index {}", + entry.index + )); + break; + } + } + } + + Self { + mode, + entries: Vec::new(), + verify_entries, + invalid_reason, + begin_tx: default_begin_post_exec_tx::, + take_last_tx_result: default_take_last_post_exec_tx_result::, + } + } +} + /// Context for OP block execution. #[derive(Debug, Default, Clone)] pub struct OpBlockExecutionCtx { @@ -55,6 +141,24 @@ pub struct OpBlockExecutionCtx { pub parent_beacon_block_root: Option, /// The block's extra data. pub extra_data: Bytes, + /// Canonical post-exec execution mode for this block. + pub post_exec_mode: PostExecMode, +} + +/// Canonical gas adjustment applied to a transaction when a post-exec refund reduces its gas +/// cost below the raw EVM result. +#[derive(Debug, Default, Clone)] +pub struct PostExecAdjustment { + /// Refund amount subtracted from raw gas to produce canonical gas. + pub refund: u64, + /// Sender balance delta to credit back. + pub sender_refund: U256, + /// Beneficiary balance delta to debit. + pub beneficiary_delta: U256, + /// Base fee recipient balance delta to debit. + pub base_fee_delta: U256, + /// Operator fee recipient balance delta to debit. + pub operator_fee_delta: U256, } /// The result of executing an OP transaction. @@ -64,8 +168,14 @@ pub struct OpTxResult { pub inner: EthTxResult, /// Whether the transaction is a deposit transaction. pub is_deposit: bool, + /// Whether the transaction is a post-exec transaction. + pub is_post_exec: bool, /// The sender of the transaction. pub sender: Address, + /// Raw gas used returned by normal EVM execution before any canonical post-exec adjustment. + pub raw_gas_used: u64, + /// Canonical post-exec adjustment, if any. + pub post_exec: Option, } impl TxResult for OpTxResult { @@ -104,6 +214,10 @@ pub struct OpBlockExecutor { pub is_regolith: bool, /// Utility to call system smart contracts. pub system_caller: SystemCaller, + /// Cached L1 block info for the current block. + pub l1_block_info: Option, + /// Post-exec execution state (mode, inspector hooks, producer/verifier working state). + pub post_exec: PostExecState, } impl OpBlockExecutor @@ -114,6 +228,7 @@ where { /// Creates a new [`OpBlockExecutor`]. pub fn new(evm: E, ctx: OpBlockExecutionCtx, spec: Spec, receipt_builder: R) -> Self { + let post_exec = PostExecState::new(ctx.post_exec_mode.clone()); Self { is_regolith: spec .is_regolith_active_at_timestamp(evm.block().timestamp().saturating_to()), @@ -125,8 +240,49 @@ where gas_used: 0, da_footprint_used: 0, ctx, + l1_block_info: None, + post_exec, } } + + /// Configure how the executor should begin inspector-backed post-exec tracking. + pub fn with_post_exec_begin(mut self, begin_tx: fn(&mut E, PostExecTxContext)) -> Self { + self.post_exec.begin_tx = begin_tx; + self + } + + /// Configure how the executor should read the most recent inspector-backed post-exec result. + pub fn with_post_exec_result( + mut self, + take_last_tx_result: fn(&mut E) -> PostExecExecutedTx, + ) -> Self { + self.post_exec.take_last_tx_result = take_last_tx_result; + self + } + + /// Set the post-exec execution mode for the executor. + pub fn with_post_exec_mode(mut self, post_exec_mode: PostExecMode) -> Self { + self.set_post_exec_mode(post_exec_mode); + self + } + + /// Set the post-exec execution mode for the executor. + /// + /// This is primarily intended for tests and replay tooling that need to override the + /// block-context default after construction. + pub fn set_post_exec_mode(&mut self, post_exec_mode: PostExecMode) { + let PostExecState { mode, verify_entries, invalid_reason, .. } = + PostExecState::::new(post_exec_mode); + self.post_exec.mode = mode; + self.post_exec.verify_entries = verify_entries; + self.post_exec.invalid_reason = invalid_reason; + } + + /// Take the accumulated post-exec entries (sequencer mode). + /// Returns the entries and clears the internal state. + pub fn take_post_exec_entries(&mut self) -> Vec { + core::mem::take(&mut self.post_exec.entries) + } } /// Custom errors that can occur during OP block execution. @@ -150,6 +306,19 @@ pub enum OpBlockExecutionError { /// The available block DA footprint. available_block_da_footprint: u64, }, + + /// The block contained an invalid post-exec payload. + #[error("invalid post-exec payload: {0}")] + InvalidPostExecPayload(String), + + /// Canonical post-exec settlement would underflow an account balance. + #[error("canonical post-exec settlement underflow for {address}: delta {delta}")] + PostExecSettlementUnderflow { + /// Account whose balance would underflow. + address: Address, + /// Delta that could not be removed from the account. + delta: U256, + }, } impl OpBlockExecutor @@ -185,6 +354,217 @@ where Ok(encoded.saturating_mul(da_footprint_gas_scalar)) } + + fn invalid_post_exec_payload(&self, reason: impl Into) -> BlockExecutionError { + BlockExecutionError::Validation(BlockValidationError::Other(Box::new( + OpBlockExecutionError::InvalidPostExecPayload(reason.into()), + ))) + } + + fn verifier_post_exec_refund_for_tx( + &self, + tx_index: u64, + is_deposit: bool, + is_post_exec: bool, + raw_gas_used: u64, + ) -> Result { + if !matches!(self.post_exec.mode, PostExecMode::Verify(_)) { + return Ok(0); + } + + let Some(refund) = self.post_exec.verify_entries.get(&tx_index).copied() else { + return Ok(0); + }; + + if is_deposit { + return Err(self.invalid_post_exec_payload(format!( + "payload entry targets deposit tx index {tx_index}" + ))); + } + + if is_post_exec { + return Err(self.invalid_post_exec_payload(format!( + "payload entry targets post-exec tx index {tx_index}" + ))); + } + + if refund > raw_gas_used { + return Err(self.invalid_post_exec_payload(format!( + "payload refund {refund} exceeds raw gas used {raw_gas_used} for tx index {tx_index}" + ))); + } + + Ok(refund) + } + + const fn canonicalize_result_gas( + result: &mut ExecutionResult, + post_exec_refund: u64, + ) { + if post_exec_refund == 0 { + return; + } + + match result { + ExecutionResult::Success { gas, .. } => { + *gas = gas + .with_spent(gas.spent().saturating_sub(post_exec_refund)) + .with_refunded(gas.inner_refunded().saturating_add(post_exec_refund)); + } + ExecutionResult::Revert { gas, .. } | ExecutionResult::Halt { gas, .. } => { + *gas = gas.with_spent(gas.spent().saturating_sub(post_exec_refund)); + } + } + } + + fn state_account_mut<'a>( + db: &mut E::DB, + state: &'a mut EvmState, + address: Address, + ) -> Result<&'a mut Account, BlockExecutionError> { + use revm::primitives::hash_map::Entry; + + match state.entry(address) { + Entry::Occupied(entry) => Ok(entry.into_mut()), + Entry::Vacant(entry) => { + let info = + db.basic(address).map_err(BlockExecutionError::other)?.unwrap_or_default(); + let original_info = info.clone(); + Ok(entry.insert(Account { + info, + // The original_info is not used by State::commit — the + // CacheAccount tracks its own previous state for building + // transitions. Setting it equal to current info is safe. + original_info: Box::new(original_info), + status: AccountStatus::Touched, + ..Default::default() + })) + } + } + } + + fn add_state_balance( + db: &mut E::DB, + state: &mut EvmState, + address: Address, + delta: U256, + ) -> Result<(), BlockExecutionError> { + if delta.is_zero() { + return Ok(()); + } + + let account = Self::state_account_mut(db, state, address)?; + account.mark_touch(); + account.info.balance = account.info.balance.saturating_add(delta); + Ok(()) + } + + fn sub_state_balance( + db: &mut E::DB, + state: &mut EvmState, + address: Address, + delta: U256, + ) -> Result<(), BlockExecutionError> { + if delta.is_zero() { + return Ok(()); + } + + let account = Self::state_account_mut(db, state, address)?; + account.mark_touch(); + account.info.balance = account.info.balance.checked_sub(delta).ok_or_else(|| { + BlockExecutionError::Validation(BlockValidationError::Other(Box::new( + OpBlockExecutionError::PostExecSettlementUnderflow { address, delta }, + ))) + })?; + Ok(()) + } + + fn l1_block_info( + &mut self, + spec_id: op_revm::OpSpecId, + ) -> Result { + if let Some(l1_block_info) = &self.l1_block_info { + return Ok(l1_block_info.clone()); + } + + let block_number = self.evm.block().number(); + let l1_block_info = L1BlockInfo::try_fetch(self.evm.db_mut(), block_number, spec_id) + .map_err(BlockExecutionError::other)?; + self.l1_block_info = Some(l1_block_info.clone()); + Ok(l1_block_info) + } + + fn post_exec_settlement_deltas( + &mut self, + tx: impl RecoveredTx, + raw_gas_used: u64, + canonical_gas_used: u64, + is_deposit: bool, + is_post_exec: bool, + ) -> Result<(U256, U256, U256, U256), BlockExecutionError> { + if is_deposit || is_post_exec || canonical_gas_used >= raw_gas_used { + return Ok((U256::ZERO, U256::ZERO, U256::ZERO, U256::ZERO)); + } + + let gas_delta = raw_gas_used.saturating_sub(canonical_gas_used); + let gas_delta_u256 = U256::from(gas_delta); + let basefee = self.evm.block().basefee() as u128; + let spec_id = spec_by_timestamp_after_bedrock( + &self.spec, + self.evm.block().timestamp().saturating_to(), + ); + let effective_gas_price = tx.tx().effective_gas_price(Some(self.evm.block().basefee())); + // SDM/PostExec is only enabled on forks after Isthmus, which is already post-London. + // A saturating_sub landing at zero is intentional and consensus-valid: a legacy tx + // with a gas price equal to the basefee pays zero priority fee, so the beneficiary + // delta below must be zero as well — we credit back only what the beneficiary + // actually received for the refunded gas, which is the (effective_price - basefee) + // component. + let beneficiary_gas_price = effective_gas_price.saturating_sub(basefee); + + let base_fee_delta = gas_delta_u256.saturating_mul(U256::from(basefee)); + let beneficiary_delta = gas_delta_u256.saturating_mul(U256::from(beneficiary_gas_price)); + + let l1_block_info = self.l1_block_info(spec_id)?; + let encoded = tx.tx().encoded_2718(); + let raw_fee = + l1_block_info.operator_fee_charge(encoded.as_ref(), U256::from(raw_gas_used), spec_id); + let canonical_fee = l1_block_info.operator_fee_charge( + encoded.as_ref(), + U256::from(canonical_gas_used), + spec_id, + ); + let operator_fee_delta = raw_fee.saturating_sub(canonical_fee); + + let sender_refund = gas_delta_u256 + .saturating_mul(U256::from(effective_gas_price)) + .saturating_add(operator_fee_delta); + + Ok((sender_refund, beneficiary_delta, base_fee_delta, operator_fee_delta)) + } + + fn apply_post_exec_refund_to_state( + &mut self, + state: &mut EvmState, + sender: Address, + sender_refund: U256, + beneficiary_delta: U256, + base_fee_delta: U256, + operator_fee_delta: U256, + ) -> Result<(), BlockExecutionError> { + let beneficiary = self.evm.block().beneficiary(); + Self::add_state_balance(self.evm.db_mut(), state, sender, sender_refund)?; + Self::sub_state_balance(self.evm.db_mut(), state, beneficiary, beneficiary_delta)?; + Self::sub_state_balance(self.evm.db_mut(), state, BASE_FEE_RECIPIENT, base_fee_delta)?; + Self::sub_state_balance( + self.evm.db_mut(), + state, + OPERATOR_FEE_RECIPIENT, + operator_fee_delta, + )?; + + Ok(()) + } } impl BlockExecutor for OpBlockExecutor @@ -202,6 +582,44 @@ where type Result = OpTxResult::TxType>; fn apply_pre_execution_changes(&mut self) -> Result<(), BlockExecutionError> { + if let Some(reason) = &self.post_exec.invalid_reason { + return Err(self.invalid_post_exec_payload(String::from(reason.as_str()))); + } + if let PostExecMode::Verify(payload) = &self.post_exec.mode { + let block_number = self.evm.block().number().saturating_to::(); + if payload.block_number != block_number { + let reason = format!( + "payload block number {} does not match block number {}", + payload.block_number, block_number, + ); + return Err(self.invalid_post_exec_payload(reason)); + } + } + + // Produce mode drives refund accounting through the begin/take hooks; if a caller + // forgets to wire them the executor silently drops all refunds, which would diverge + // this node from any peer that *did* wire them. OpEvm auto-wires in-tree (see + // `ConfigurePostExecEvm` in lib.rs); this guard catches downstream forks that + // bypass the builder. + if matches!(self.post_exec.mode, PostExecMode::Produce) { + debug_assert!( + !core::ptr::fn_addr_eq( + self.post_exec.begin_tx, + default_begin_post_exec_tx:: as fn(&mut E, PostExecTxContext), + ), + "PostExecMode::Produce requires begin_tx to be wired via \ + with_post_exec_begin; the default no-op would silently drop refunds", + ); + debug_assert!( + !core::ptr::fn_addr_eq( + self.post_exec.take_last_tx_result, + default_take_last_post_exec_tx_result:: as fn(&mut E) -> PostExecExecutedTx, + ), + "PostExecMode::Produce requires take_last_tx_result to be wired \ + via with_post_exec_result; the default no-op would silently drop refunds", + ); + } + self.system_caller.apply_blockhashes_contract_call(self.ctx.parent_hash, &mut self.evm)?; self.system_caller .apply_beacon_root_contract_call(self.ctx.parent_beacon_block_root, &mut self.evm)?; @@ -226,6 +644,8 @@ where ) -> Result { let (tx_env, tx) = tx.into_parts(); let is_deposit = tx.tx().ty() == DEPOSIT_TRANSACTION_TYPE; + let is_post_exec = tx.tx().ty() == POST_EXEC_TX_TYPE_ID; + let tx_index = self.receipts.len() as u64; // The sum of the transaction's gas limit, Tg, and the gas utilized in this block prior, // must be no greater than the block's gasLimit. @@ -261,12 +681,95 @@ where 0 }; + if is_post_exec { + // A 0x7D tx is only legitimate when the executor is wired to either produce or + // verify it. In Disabled mode the feature is off, so a 0x7D tx in the block is a + // malformed block we must reject — silently short-circuiting would let a producer + // ship arbitrary refund payloads that followers never validate. + if matches!(self.post_exec.mode, PostExecMode::Disabled) { + return Err(self.invalid_post_exec_payload(format!( + "unexpected post-exec tx at index {tx_index}: SDM not active for this block", + ))); + } + // Validates that no Verify payload entry targets this tx index; refund is always 0. + self.verifier_post_exec_refund_for_tx(tx_index, false, true, 0)?; + return Ok(OpTxResult { + inner: EthTxResult { + result: ResultAndState::new( + ExecutionResult::Success { + reason: SuccessReason::Stop, + gas: revm::context::result::ResultGas::new(0, 0, 0, 0, 0), + logs: vec![], + output: Output::Call(Bytes::default()), + }, + Default::default(), + ), + blob_gas_used: 0, + tx_type: tx.tx().tx_type(), + }, + is_deposit: false, + is_post_exec: true, + sender: *tx.signer(), + raw_gas_used: 0, + post_exec: None, + }); + } + + if matches!(self.post_exec.mode, PostExecMode::Produce) { + (self.post_exec.begin_tx)( + &mut self.evm, + PostExecTxContext { + tx_index, + kind: if is_deposit { PostExecTxKind::Deposit } else { PostExecTxKind::Normal }, + }, + ); + } + // Execute transaction and return the result let result = self.evm.transact(tx_env).map_err(|err| { let hash = tx.tx().trie_hash(); BlockExecutionError::evm(err, hash) })?; + let raw_gas_used = result.result.gas_used(); + let post_exec_refund = match &self.post_exec.mode { + PostExecMode::Produce => { + let refund = (self.post_exec.take_last_tx_result)(&mut self.evm).refund_total; + // The inspector's accumulated refund must never exceed the tx's raw gas. If + // it does, we'd emit an `SDMGasEntry` that any honest verifier would reject + // at pre-execution ("payload refund exceeds raw gas used"), so the sequencer + // would ship a block it can't verify itself. Fail here with a loud error + // instead of letting `saturating_sub` mask the discrepancy. + if refund > raw_gas_used { + return Err(self.invalid_post_exec_payload(format!( + "produced refund {refund} exceeds raw gas used {raw_gas_used} for tx index {tx_index}", + ))); + } + refund + } + PostExecMode::Verify(_) => { + self.verifier_post_exec_refund_for_tx(tx_index, is_deposit, false, raw_gas_used)? + } + PostExecMode::Disabled => 0, + }; + let canonical_gas_used = raw_gas_used.saturating_sub(post_exec_refund); + let (sender_refund, beneficiary_delta, base_fee_delta, operator_fee_delta) = self + .post_exec_settlement_deltas( + &tx, + raw_gas_used, + canonical_gas_used, + is_deposit, + false, + )?; + + let post_exec = (post_exec_refund > 0).then_some(PostExecAdjustment { + refund: post_exec_refund, + sender_refund, + beneficiary_delta, + base_fee_delta, + operator_fee_delta, + }); + Ok(OpTxResult { inner: EthTxResult { result, @@ -274,17 +777,45 @@ where tx_type: tx.tx().tx_type(), }, is_deposit, + is_post_exec: false, sender: *tx.signer(), + raw_gas_used, + post_exec, }) } fn commit_transaction(&mut self, output: Self::Result) -> Result { + let tx_index = self.receipts.len() as u64; let OpTxResult { - inner: EthTxResult { result: ResultAndState { result, state }, blob_gas_used, tx_type }, + inner: + EthTxResult { result: ResultAndState { mut result, mut state }, blob_gas_used, tx_type }, is_deposit, + is_post_exec, sender, + raw_gas_used, + post_exec, } = output; + let PostExecAdjustment { + refund: post_exec_refund, + sender_refund, + beneficiary_delta, + base_fee_delta, + operator_fee_delta, + } = post_exec.unwrap_or_default(); + + if !is_deposit && + !is_post_exec && + matches!(self.post_exec.mode, PostExecMode::Produce) && + post_exec_refund > 0 + { + let entry = SDMGasEntry { index: tx_index, gas_refund: post_exec_refund }; + self.post_exec.entries.push(entry); + } + if matches!(self.post_exec.mode, PostExecMode::Verify(_)) && post_exec_refund > 0 { + self.post_exec.verify_entries.remove(&tx_index); + } + // Fetch the depositor account from the database for the deposit nonce. // Note that this *only* needs to be done post-regolith hardfork, as deposit nonces // were not introduced in Bedrock. In addition, regular transactions don't have deposit @@ -294,16 +825,25 @@ where .transpose() .map_err(BlockExecutionError::other)?; - self.system_caller.on_state(StateChangeSource::Transaction(self.receipts.len()), &state); + let canonical_gas_used = raw_gas_used.saturating_sub(post_exec_refund); + Self::canonicalize_result_gas(&mut result, post_exec_refund); + self.apply_post_exec_refund_to_state( + &mut state, + sender, + sender_refund, + beneficiary_delta, + base_fee_delta, + operator_fee_delta, + )?; - let gas_used = result.gas_used(); + self.system_caller.on_state(StateChangeSource::Transaction(self.receipts.len()), &state); - // append gas used - self.gas_used += gas_used; + self.gas_used += canonical_gas_used; // Update DA footprint if Jovian is active if self.spec.is_jovian_active_at_timestamp(self.evm.block().timestamp().saturating_to()) && - !is_deposit + !is_deposit && + !is_post_exec { // Add to DA footprint used self.da_footprint_used = self.da_footprint_used.saturating_add(blob_gas_used); @@ -347,12 +887,21 @@ where self.evm.db_mut().commit(state); - Ok(gas_used) + Ok(canonical_gas_used) } fn finish( mut self, ) -> Result<(Self::Evm, BlockExecutionResult), BlockExecutionError> { + if !self.post_exec.verify_entries.is_empty() { + let indexes: Vec = self.post_exec.verify_entries.keys().copied().collect(); + return Err(self.invalid_post_exec_payload(format!( + "{} unconsumed post-exec payload entries for tx indexes {:?}", + indexes.len(), + indexes, + ))); + } + let balance_increments = post_block_balance_increments::
(&self.spec, self.evm.block(), &[], None); // increment balances @@ -370,15 +919,12 @@ where }) })?; - let legacy_gas_used = - self.receipts.last().map(|r| r.cumulative_gas_used()).unwrap_or_default(); - Ok(( self.evm, BlockExecutionResult { receipts: self.receipts, requests: Default::default(), - gas_used: legacy_gas_used, + gas_used: self.gas_used, blob_gas_used: self.da_footprint_used, }, )) @@ -525,6 +1071,52 @@ mod tests { let _ = executor.execute_transaction(&tx_with_encoded); } + #[test] + fn test_settlement_state_account_preserves_original_info() { + type TestExecutor<'a> = OpBlockExecutor< + OpEvm<&'a mut State, NoOpInspector>, + &'a OpAlloyReceiptBuilder, + &'a OpChainHardforks, + >; + + let mut backing_db = InMemoryDB::default(); + backing_db.insert_account_info( + BASE_FEE_RECIPIENT, + AccountInfo { balance: U256::from(10), ..Default::default() }, + ); + let mut db = State::builder().with_database(backing_db).with_bundle_update().build(); + revm::Database::basic(&mut db, BASE_FEE_RECIPIENT) + .expect("failed to load base fee recipient into cache"); + + let mut credited_account = + Account::from(AccountInfo { balance: U256::from(15), ..Default::default() }); + credited_account.mark_touch(); + revm::DatabaseCommit::commit( + &mut db, + HashMap::from_iter([(BASE_FEE_RECIPIENT, credited_account)]), + ); + + let mut state = EvmState::default(); + let mut db_ref = &mut db; + let account = TestExecutor::state_account_mut(&mut db_ref, &mut state, BASE_FEE_RECIPIENT) + .expect("failed to materialize settlement account"); + assert_eq!(account.info.balance, U256::from(15)); + // original_info mirrors current info here — State::commit computes the + // true previous value from its own cache, so the bundle stays correct. + assert_eq!(account.original_info.balance, U256::from(15)); + + account.info.balance = account.info.balance.saturating_sub(U256::from(3)); + revm::DatabaseCommit::commit(&mut db, state); + db.merge_transitions(revm::database::states::bundle_state::BundleRetention::Reverts); + + let bundle = db.take_bundle(); + let bundle_account = bundle + .account(&BASE_FEE_RECIPIENT) + .expect("bundle must contain the base fee recipient"); + assert_eq!(bundle_account.original_info.as_ref().unwrap().balance, U256::from(10)); + assert_eq!(bundle_account.info.as_ref().unwrap().balance, U256::from(12)); + } + fn prepare_jovian_db(da_footprint_gas_scalar: u16) -> State { const L1_BASE_FEE: U256 = uint!(1_U256); const L1_BLOB_BASE_FEE: U256 = uint!(2_U256); @@ -763,4 +1355,438 @@ mod tests { assert_eq!(result.gas_used, gas_used_tx); assert!(result.blob_gas_used > result.gas_used); } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "PostExecMode::Produce requires begin_tx")] + fn test_produce_mode_without_wired_hooks_debug_asserts() { + const DA_FOOTPRINT_GAS_SCALAR: u16 = 7; + const GAS_LIMIT: u64 = 100_000; + const JOVIAN_TIMESTAMP: u64 = 1746806402; + + let mut db = prepare_jovian_db(DA_FOOTPRINT_GAS_SCALAR); + let op_chain_hardforks = OpChainHardforks::new( + OpHardfork::op_mainnet() + .into_iter() + .chain(vec![(OpHardfork::Jovian, ForkCondition::Timestamp(JOVIAN_TIMESTAMP))]), + ); + let receipt_builder = OpAlloyReceiptBuilder::default(); + // build_executor does not call with_post_exec_begin / with_post_exec_result, so + // the fn-pointer fields stay pinned to the default_* no-ops. + let mut executor = build_executor( + &mut db, + &receipt_builder, + &op_chain_hardforks, + GAS_LIMIT, + JOVIAN_TIMESTAMP, + ); + executor.set_post_exec_mode(PostExecMode::Produce); + + // Release builds skip the assert and would silently drop refunds — document that + // too so anyone removing the assert sees the expected behavior. + let _ = executor.apply_pre_execution_changes(); + } + + #[test] + fn test_mismatched_payload_block_number_fails_pre_execution() { + const DA_FOOTPRINT_GAS_SCALAR: u16 = 7; + const GAS_LIMIT: u64 = 100_000; + const JOVIAN_TIMESTAMP: u64 = 1746806402; + + let mut db = prepare_jovian_db(DA_FOOTPRINT_GAS_SCALAR); + let op_chain_hardforks = OpChainHardforks::new( + OpHardfork::op_mainnet() + .into_iter() + .chain(vec![(OpHardfork::Jovian, ForkCondition::Timestamp(JOVIAN_TIMESTAMP))]), + ); + let receipt_builder = OpAlloyReceiptBuilder::default(); + let mut executor = build_executor( + &mut db, + &receipt_builder, + &op_chain_hardforks, + GAS_LIMIT, + JOVIAN_TIMESTAMP, + ); + // build_executor configures BlockEnv with block number 0; a payload anchored to a + // different block must be rejected before any tx runs. + executor.set_post_exec_mode(PostExecMode::Verify(PostExecPayload { + version: 1, + block_number: 42, + gas_refund_entries: vec![], + })); + + let err = + executor.apply_pre_execution_changes().expect_err("mismatched block number must fail"); + match err { + BlockExecutionError::Validation(BlockValidationError::Other(err)) => { + assert_eq!( + err.to_string(), + OpBlockExecutionError::InvalidPostExecPayload( + "payload block number 42 does not match block number 0".to_string(), + ) + .to_string(), + ); + } + _ => panic!("expected invalid post-exec payload error"), + } + } + + fn assert_invalid_post_exec(err: BlockExecutionError, expected_reason: &str) { + match err { + BlockExecutionError::Validation(BlockValidationError::Other(err)) => { + assert_eq!( + err.to_string(), + OpBlockExecutionError::InvalidPostExecPayload(expected_reason.to_string()) + .to_string(), + ); + } + other => panic!("expected invalid post-exec payload error, got: {other:?}"), + } + } + + #[test] + fn test_duplicate_payload_index_fails_pre_execution() { + const DA_FOOTPRINT_GAS_SCALAR: u16 = 7; + const GAS_LIMIT: u64 = 100_000; + const JOVIAN_TIMESTAMP: u64 = 1746806402; + + let mut db = prepare_jovian_db(DA_FOOTPRINT_GAS_SCALAR); + let op_chain_hardforks = OpChainHardforks::new( + OpHardfork::op_mainnet() + .into_iter() + .chain(vec![(OpHardfork::Jovian, ForkCondition::Timestamp(JOVIAN_TIMESTAMP))]), + ); + let receipt_builder = OpAlloyReceiptBuilder::default(); + let mut executor = build_executor( + &mut db, + &receipt_builder, + &op_chain_hardforks, + GAS_LIMIT, + JOVIAN_TIMESTAMP, + ); + // Two entries colliding on tx index 3 — the second insert must be flagged at construction + // and surface as a pre-execution failure. + executor.set_post_exec_mode(PostExecMode::Verify(PostExecPayload { + version: 1, + block_number: 0, + gas_refund_entries: vec![ + SDMGasEntry { index: 3, gas_refund: 10 }, + SDMGasEntry { index: 3, gas_refund: 20 }, + ], + })); + + let err = executor + .apply_pre_execution_changes() + .expect_err("duplicate payload index must fail pre-execution"); + assert_invalid_post_exec(err, "duplicate post-exec payload entry for tx index 3"); + } + + #[test] + fn test_verifier_rejects_payload_targeting_deposit_tx() { + const DA_FOOTPRINT_GAS_SCALAR: u16 = 7; + const GAS_LIMIT: u64 = 100_000; + const JOVIAN_TIMESTAMP: u64 = 1746806402; + + let mut db = prepare_jovian_db(DA_FOOTPRINT_GAS_SCALAR); + let op_chain_hardforks = OpChainHardforks::new( + OpHardfork::op_mainnet() + .into_iter() + .chain(vec![(OpHardfork::Jovian, ForkCondition::Timestamp(JOVIAN_TIMESTAMP))]), + ); + let receipt_builder = OpAlloyReceiptBuilder::default(); + let mut executor = build_executor( + &mut db, + &receipt_builder, + &op_chain_hardforks, + GAS_LIMIT, + JOVIAN_TIMESTAMP, + ); + executor.set_post_exec_mode(PostExecMode::Verify(PostExecPayload { + version: 1, + block_number: 0, + gas_refund_entries: vec![SDMGasEntry { index: 0, gas_refund: 1 }], + })); + + let err = executor + .verifier_post_exec_refund_for_tx(0, true, false, 21_000) + .expect_err("payload entries must not target deposit txs"); + assert_invalid_post_exec(err, "payload entry targets deposit tx index 0"); + } + + #[test] + fn test_verifier_rejects_payload_targeting_post_exec_tx() { + const DA_FOOTPRINT_GAS_SCALAR: u16 = 7; + const GAS_LIMIT: u64 = 100_000; + const JOVIAN_TIMESTAMP: u64 = 1746806402; + + let mut db = prepare_jovian_db(DA_FOOTPRINT_GAS_SCALAR); + let op_chain_hardforks = OpChainHardforks::new( + OpHardfork::op_mainnet() + .into_iter() + .chain(vec![(OpHardfork::Jovian, ForkCondition::Timestamp(JOVIAN_TIMESTAMP))]), + ); + let receipt_builder = OpAlloyReceiptBuilder::default(); + let mut executor = build_executor( + &mut db, + &receipt_builder, + &op_chain_hardforks, + GAS_LIMIT, + JOVIAN_TIMESTAMP, + ); + executor.set_post_exec_mode(PostExecMode::Verify(PostExecPayload { + version: 1, + block_number: 0, + gas_refund_entries: vec![SDMGasEntry { index: 4, gas_refund: 1 }], + })); + + // A 0x7D tx reaching this helper with an entry at its own index would mean the payload + // is attributing a refund to the synthetic tx itself — refunds are per-normal-tx only. + let err = executor + .verifier_post_exec_refund_for_tx(4, false, true, 0) + .expect_err("payload entries must not target the post-exec tx itself"); + assert_invalid_post_exec(err, "payload entry targets post-exec tx index 4"); + } + + #[test] + fn test_verifier_rejects_refund_exceeding_raw_gas() { + const DA_FOOTPRINT_GAS_SCALAR: u16 = 7; + const GAS_LIMIT: u64 = 100_000; + const JOVIAN_TIMESTAMP: u64 = 1746806402; + + let mut db = prepare_jovian_db(DA_FOOTPRINT_GAS_SCALAR); + let op_chain_hardforks = OpChainHardforks::new( + OpHardfork::op_mainnet() + .into_iter() + .chain(vec![(OpHardfork::Jovian, ForkCondition::Timestamp(JOVIAN_TIMESTAMP))]), + ); + let receipt_builder = OpAlloyReceiptBuilder::default(); + let mut executor = build_executor( + &mut db, + &receipt_builder, + &op_chain_hardforks, + GAS_LIMIT, + JOVIAN_TIMESTAMP, + ); + executor.set_post_exec_mode(PostExecMode::Verify(PostExecPayload { + version: 1, + block_number: 0, + gas_refund_entries: vec![SDMGasEntry { index: 2, gas_refund: 50_000 }], + })); + + // raw_gas_used < payload refund — a refund that exceeds the tx's raw cost is + // impossible under SDM semantics and must be rejected, otherwise canonical gas + // would underflow to a bogus value via saturating_sub. + let err = executor + .verifier_post_exec_refund_for_tx(2, false, false, 40_000) + .expect_err("refund greater than raw gas must be rejected"); + assert_invalid_post_exec( + err, + "payload refund 50000 exceeds raw gas used 40000 for tx index 2", + ); + + // Boundary: refund == raw_gas_used is permitted (canonical gas ends up at zero). + let ok = executor + .verifier_post_exec_refund_for_tx(2, false, false, 50_000) + .expect("refund equal to raw gas is permitted"); + assert_eq!(ok, 50_000); + } + + #[test] + fn test_verifier_returns_zero_when_no_entry_for_tx() { + const DA_FOOTPRINT_GAS_SCALAR: u16 = 7; + const GAS_LIMIT: u64 = 100_000; + const JOVIAN_TIMESTAMP: u64 = 1746806402; + + let mut db = prepare_jovian_db(DA_FOOTPRINT_GAS_SCALAR); + let op_chain_hardforks = OpChainHardforks::new( + OpHardfork::op_mainnet() + .into_iter() + .chain(vec![(OpHardfork::Jovian, ForkCondition::Timestamp(JOVIAN_TIMESTAMP))]), + ); + let receipt_builder = OpAlloyReceiptBuilder::default(); + let mut executor = build_executor( + &mut db, + &receipt_builder, + &op_chain_hardforks, + GAS_LIMIT, + JOVIAN_TIMESTAMP, + ); + executor.set_post_exec_mode(PostExecMode::Verify(PostExecPayload { + version: 1, + block_number: 0, + gas_refund_entries: vec![SDMGasEntry { index: 7, gas_refund: 42 }], + })); + + // Normal tx that has no entry in the payload — the deposit/post-exec guards must NOT + // fire, the helper must return 0 so execution proceeds with raw gas unchanged. + let refund = executor + .verifier_post_exec_refund_for_tx(3, false, false, 21_000) + .expect("no entry for this tx index means no refund"); + assert_eq!(refund, 0); + } + + #[test] + fn test_finish_reports_all_unconsumed_post_exec_entries() { + const DA_FOOTPRINT_GAS_SCALAR: u16 = 7; + const GAS_LIMIT: u64 = 100_000; + const JOVIAN_TIMESTAMP: u64 = 1746806402; + + let mut db = prepare_jovian_db(DA_FOOTPRINT_GAS_SCALAR); + let op_chain_hardforks = OpChainHardforks::new( + OpHardfork::op_mainnet() + .into_iter() + .chain(vec![(OpHardfork::Jovian, ForkCondition::Timestamp(JOVIAN_TIMESTAMP))]), + ); + let receipt_builder = OpAlloyReceiptBuilder::default(); + let mut executor = build_executor( + &mut db, + &receipt_builder, + &op_chain_hardforks, + GAS_LIMIT, + JOVIAN_TIMESTAMP, + ); + executor.set_post_exec_mode(PostExecMode::Verify(PostExecPayload { + version: 1, + block_number: 0, + gas_refund_entries: vec![ + SDMGasEntry { index: 2, gas_refund: 7 }, + SDMGasEntry { index: 5, gas_refund: 11 }, + ], + })); + + let err = match executor.finish() { + Ok(_) => panic!("unconsumed verifier entries must fail"), + Err(err) => err, + }; + match err { + BlockExecutionError::Validation(BlockValidationError::Other(err)) => { + assert_eq!( + err.to_string(), + OpBlockExecutionError::InvalidPostExecPayload( + "2 unconsumed post-exec payload entries for tx indexes [2, 5]".to_string(), + ) + .to_string(), + ); + } + _ => panic!("expected invalid post-exec payload error"), + } + } + + /// Followers running with SDM disabled must reject any block that carries a synthetic + /// 0x7D tx. Silently short-circuiting the tx (which is what the pre-guard code did) would + /// let a producer ship a payload with arbitrary refund entries that no follower validates, + /// and the two nodes' states would diverge without anyone noticing. + #[test] + fn test_disabled_mode_rejects_post_exec_tx() { + use alloy_consensus::Sealable; + use op_alloy::consensus::build_post_exec_tx; + + const DA_FOOTPRINT_GAS_SCALAR: u16 = 7; + const GAS_LIMIT: u64 = 100_000; + const JOVIAN_TIMESTAMP: u64 = 1746806402; + + let mut db = prepare_jovian_db(DA_FOOTPRINT_GAS_SCALAR); + let op_chain_hardforks = OpChainHardforks::new( + OpHardfork::op_mainnet() + .into_iter() + .chain(vec![(OpHardfork::Jovian, ForkCondition::Timestamp(JOVIAN_TIMESTAMP))]), + ); + let receipt_builder = OpAlloyReceiptBuilder::default(); + // build_executor leaves post_exec_mode at the default (Disabled). + let mut executor = build_executor( + &mut db, + &receipt_builder, + &op_chain_hardforks, + GAS_LIMIT, + JOVIAN_TIMESTAMP, + ); + assert!(matches!(executor.post_exec.mode, PostExecMode::Disabled)); + + let post_exec_tx = build_post_exec_tx(0, vec![]); + let envelope = OpTxEnvelope::PostExec(post_exec_tx.seal_slow()); + let tx = Recovered::new_unchecked(envelope, Address::ZERO); + + let err = + executor.execute_transaction(&tx).expect_err("0x7D tx in Disabled mode must fail"); + assert_invalid_post_exec( + err, + "unexpected post-exec tx at index 0: SDM not active for this block", + ); + } + + /// A sequencer whose inspector accumulates more refund than the tx's raw gas used would + /// otherwise emit an `SDMGasEntry` that a verifier rejects at pre-execution (refund > + /// `raw_gas_used`). We'd rather fail the payload build here, loudly, than ship a block no + /// honest verifier can reproduce. + #[test] + fn test_produce_mode_rejects_refund_greater_than_raw_gas() { + use crate::{OpTx, post_exec::PostExecTxContext}; + use revm::state::AccountInfo; + + const DA_FOOTPRINT_GAS_SCALAR: u16 = 7; + const GAS_LIMIT: u64 = 100_000; + const JOVIAN_TIMESTAMP: u64 = 1746806402; + + // Fake "take result" hook: pretend the inspector attributed a refund that exceeds + // anything the tx could have consumed. Distinct fn item from `default_*` so the + // Produce-mode wiring debug_assert would be satisfied if it ever ran. + fn over_refund_take(_: &mut E) -> PostExecExecutedTx { + PostExecExecutedTx { refund_total: u64::MAX } + } + fn noop_begin(_: &mut E, _: PostExecTxContext) {} + + let mut db = prepare_jovian_db(DA_FOOTPRINT_GAS_SCALAR); + // Fund the sender so tx pre-validation passes. + let sender = Address::from([0x11; 20]); + db.insert_account( + sender, + AccountInfo { balance: U256::from(1_000_000_000u64), ..Default::default() }, + ); + let op_chain_hardforks = OpChainHardforks::new( + OpHardfork::op_mainnet() + .into_iter() + .chain(vec![(OpHardfork::Jovian, ForkCondition::Timestamp(JOVIAN_TIMESTAMP))]), + ); + let receipt_builder = OpAlloyReceiptBuilder::default(); + let mut executor = build_executor( + &mut db, + &receipt_builder, + &op_chain_hardforks, + GAS_LIMIT, + JOVIAN_TIMESTAMP, + ); + executor.post_exec.mode = PostExecMode::Produce; + executor.post_exec.begin_tx = noop_begin::< + OpEvm<&mut State, NoOpInspector, op_revm::precompiles::OpPrecompiles, OpTx>, + >; + executor.post_exec.take_last_tx_result = over_refund_take::< + OpEvm<&mut State, NoOpInspector, op_revm::precompiles::OpPrecompiles, OpTx>, + >; + + let tx_inner = TxLegacy { gas_limit: GAS_LIMIT, ..Default::default() }; + let tx = Recovered::new_unchecked( + OpTxEnvelope::Legacy(tx_inner.into_signed(Signature::new( + Default::default(), + Default::default(), + Default::default(), + ))), + sender, + ); + + let err = executor + .execute_transaction(&tx) + .expect_err("refund greater than raw gas must fail payload build"); + match err { + BlockExecutionError::Validation(BlockValidationError::Other(err)) => { + let msg = err.to_string(); + assert!( + msg.starts_with("invalid post-exec payload: produced refund ") && + msg.contains(" exceeds raw gas used ") && + msg.ends_with(" for tx index 0"), + "unexpected error message: {msg}", + ); + } + other => panic!("expected invalid post-exec payload error, got: {other:?}"), + } + } } diff --git a/rust/alloy-op-evm/src/lib.rs b/rust/alloy-op-evm/src/lib.rs index 95187f7d0a1..ea508753655 100644 --- a/rust/alloy-op-evm/src/lib.rs +++ b/rust/alloy-op-evm/src/lib.rs @@ -27,12 +27,17 @@ use core::{ ops::{Deref, DerefMut}, }; use op_revm::{ - L1BlockInfo, OpBuilder, OpHaltReason, OpSpecId, OpTransaction, precompiles::OpPrecompiles, + L1BlockInfo, OpBuilder, OpHaltReason, OpSpecId, OpTransaction, + constants::{BASE_FEE_RECIPIENT, L1_FEE_RECIPIENT, OPERATOR_FEE_RECIPIENT}, + precompiles::OpPrecompiles, }; use revm::{ Context, ExecuteEvm, InspectEvm, Inspector, Journal, MainContext, SystemCallEvm, context::{BlockEnv, CfgEnv, TxEnv}, - context_interface::result::{EVMError, ResultAndState}, + context_interface::{ + Transaction, + result::{EVMError, ResultAndState}, + }, handler::{PrecompileProvider, instructions::EthInstructions}, inspector::NoOpInspector, interpreter::{InterpreterResult, interpreter::EthInterpreter}, @@ -42,7 +47,9 @@ pub mod tx; pub use tx::OpTx; pub mod block; -pub use block::{OpBlockExecutionCtx, OpBlockExecutor, OpBlockExecutorFactory}; +pub use block::{OpBlockExecutionCtx, OpBlockExecutor, OpBlockExecutorFactory, PostExecMode}; + +pub mod post_exec; /// The OP EVM context type. pub type OpEvmContext = Context, DB, Journal, L1BlockInfo>; @@ -57,9 +64,14 @@ pub type OpEvmContext = Context, DB, Journa /// [`OpTx`] which wraps [`OpTransaction`] and implements the necessary foreign traits. #[allow(missing_debug_implementations)] // missing revm::OpContext Debug impl pub struct OpEvm { - inner: - op_revm::OpEvm, I, EthInstructions>, P>, + inner: op_revm::OpEvm< + OpEvmContext, + post_exec::PostExecCompositeInspector, + EthInstructions>, + P, + >, inspect: bool, + last_tx_warming_savings: u64, _tx: PhantomData, } @@ -69,7 +81,21 @@ impl OpEvm { self, ) -> op_revm::OpEvm, I, EthInstructions>, P> { - self.inner + let op_revm::OpEvm(revm::context::Evm { + ctx, + inspector, + instruction, + precompiles, + frame_stack, + }) = self.inner; + + op_revm::OpEvm(revm::context::Evm { + ctx, + inspector: inspector.into_inner(), + instruction, + precompiles, + frame_stack, + }) } /// Provides a reference to the EVM context. @@ -88,7 +114,7 @@ impl OpEvm { /// /// The `inspect` argument determines whether the configured [`Inspector`] of the given /// [`OpEvm`](op_revm::OpEvm) should be invoked on [`Evm::transact`]. - pub const fn new( + pub fn new( evm: op_revm::OpEvm< OpEvmContext, I, @@ -97,7 +123,42 @@ impl OpEvm { >, inspect: bool, ) -> Self { - Self { inner: evm, inspect, _tx: PhantomData } + let op_revm::OpEvm(revm::context::Evm { + ctx, + inspector, + instruction, + precompiles, + frame_stack, + }) = evm; + + Self { + inner: op_revm::OpEvm(revm::context::Evm { + ctx, + inspector: post_exec::PostExecCompositeInspector::new(inspector), + instruction, + precompiles, + frame_stack, + }), + inspect, + last_tx_warming_savings: 0, + _tx: PhantomData, + } + } + + /// Begin post-exec tracking for the next transaction. + pub fn begin_post_exec_tx(&mut self, ctx: post_exec::PostExecTxContext) { + self.inner.0.inspector.begin_post_exec_tx(ctx); + } + + fn note_post_exec_account_touch(&mut self, address: Address) { + self.inner.0.inspector.note_account_touch(address); + } + + /// Take the extracted post-exec result for the most recently executed transaction. + pub fn take_last_post_exec_tx_result(&mut self) -> post_exec::PostExecExecutedTx { + post_exec::PostExecExecutedTx { + refund_total: core::mem::take(&mut self.last_tx_warming_savings), + } } } @@ -145,11 +206,26 @@ where &mut self, tx: Self::Tx, ) -> Result, Self::Error> { + self.last_tx_warming_savings = 0; + let result = if self.inspect { self.inner.inspect_tx(OpTx(tx.into())) } else { self.inner.transact(OpTx(tx.into())) }; + + if self.inner.0.ctx.tx.tx_type() != op_revm::transaction::deposit::DEPOSIT_TRANSACTION_TYPE + { + self.note_post_exec_account_touch(L1_FEE_RECIPIENT); + self.note_post_exec_account_touch(BASE_FEE_RECIPIENT); + if self.inner.0.ctx.cfg.spec.is_enabled_in(OpSpecId::ISTHMUS) { + self.note_post_exec_account_touch(OPERATOR_FEE_RECIPIENT); + } + } + + let post_exec_result = self.inner.0.inspector.finish_post_exec_tx(); + self.last_tx_warming_savings = post_exec_result.refund_total; + result.map_err(map_op_err) } @@ -175,7 +251,7 @@ where fn components(&self) -> (&Self::DB, &Self::Inspector, &Self::Precompiles) { ( &self.inner.0.ctx.journaled_state.database, - &self.inner.0.inspector, + self.inner.0.inspector.inner(), &self.inner.0.precompiles, ) } @@ -183,7 +259,7 @@ where fn components_mut(&mut self) -> (&mut Self::DB, &mut Self::Inspector, &mut Self::Precompiles) { ( &mut self.inner.0.ctx.journaled_state.database, - &mut self.inner.0.inspector, + self.inner.0.inspector.inner_mut(), &mut self.inner.0.precompiles, ) } @@ -230,21 +306,19 @@ where input: EvmEnv, ) -> Self::Evm { let spec_id = input.cfg_env.spec; - OpEvm { - inner: Context::mainnet() - .with_tx(OpTx(OpTransaction::builder().build_fill())) - .with_cfg(CfgEnv::new_with_spec(OpSpecId::BEDROCK)) - .with_chain(L1BlockInfo::default()) - .with_db(db) - .with_block(input.block_env) - .with_cfg(input.cfg_env) - .build_op_with_inspector(NoOpInspector {}) - .with_precompiles(PrecompilesMap::from_static( - OpPrecompiles::new_with_spec(spec_id).precompiles(), - )), - inspect: false, - _tx: PhantomData, - } + let inner = Context::mainnet() + .with_tx(OpTx(OpTransaction::builder().build_fill())) + .with_cfg(CfgEnv::new_with_spec(OpSpecId::BEDROCK)) + .with_chain(L1BlockInfo::default()) + .with_db(db) + .with_block(input.block_env) + .with_cfg(input.cfg_env) + .build_op_with_inspector(NoOpInspector {}) + .with_precompiles(PrecompilesMap::from_static( + OpPrecompiles::new_with_spec(spec_id).precompiles(), + )); + + OpEvm::new(inner, true) } fn create_evm_with_inspector>>( @@ -254,21 +328,19 @@ where inspector: I, ) -> Self::Evm { let spec_id = input.cfg_env.spec; - OpEvm { - inner: Context::mainnet() - .with_tx(OpTx(OpTransaction::builder().build_fill())) - .with_cfg(CfgEnv::new_with_spec(OpSpecId::BEDROCK)) - .with_chain(L1BlockInfo::default()) - .with_db(db) - .with_block(input.block_env) - .with_cfg(input.cfg_env) - .build_op_with_inspector(inspector) - .with_precompiles(PrecompilesMap::from_static( - OpPrecompiles::new_with_spec(spec_id).precompiles(), - )), - inspect: true, - _tx: PhantomData, - } + let inner = Context::mainnet() + .with_tx(OpTx(OpTransaction::builder().build_fill())) + .with_cfg(CfgEnv::new_with_spec(OpSpecId::BEDROCK)) + .with_chain(L1BlockInfo::default()) + .with_db(db) + .with_block(input.block_env) + .with_cfg(input.cfg_env) + .build_op_with_inspector(inspector) + .with_precompiles(PrecompilesMap::from_static( + OpPrecompiles::new_with_spec(spec_id).precompiles(), + )); + + OpEvm::new(inner, true) } } diff --git a/rust/alloy-op-evm/src/post_exec/inspector.rs b/rust/alloy-op-evm/src/post_exec/inspector.rs new file mode 100644 index 00000000000..b2780563b24 --- /dev/null +++ b/rust/alloy-op-evm/src/post_exec/inspector.rs @@ -0,0 +1,565 @@ +use alloy_primitives::{Address, B256, map::HashSet}; +use revm::{ + Inspector, + bytecode::opcode, + context::Block, + context_interface::{ + ContextTr, CreateScheme, JournalTr, Transaction, + transaction::{AccessListItemTr, AuthorizationTr}, + }, + inspector::JournalExt, + interpreter::{ + CallInputs, CreateInputs, Interpreter, + interpreter_types::{InputsTr, Jumps}, + }, + primitives::TxKind, +}; + +// EIP-2929 repeat-access savings. SDM refunds a tx whenever it re-touches something a prior +// tx in the same block already warmed, since the EVM charges the warm (cheap) fee but the +// block-level "real" cost was already paid by that prior tx. +// +// Values are derived from EIP-2929's cold/warm cost pairs: +// COLD_ACCOUNT_ACCESS_COST (2600) - WARM_STORAGE_READ_COST (100) = 2500 +// COLD_SLOAD_COST (2100) - WARM_STORAGE_READ_COST (100) = 2000 +// COLD_SLOAD_COST (2100) = 2100 (SSTORE surcharge) + +/// Refund for re-touching an account warmed earlier in the block (BALANCE, EXTCODE*, CALL, …). +const ACCOUNT_REWARM_REFUND: u64 = 2500; +/// Refund for re-touching a storage slot warmed earlier in the block via SLOAD. +const SLOAD_REWARM_REFUND: u64 = 2000; +/// Refund for re-touching a storage slot warmed earlier in the block via SSTORE. +/// +/// Higher than the SLOAD refund because SSTORE's cold surcharge is the full `COLD_SLOAD_COST` +/// (EIP-2929), not the cold-minus-warm delta that SLOAD pays. +const SSTORE_REWARM_REFUND: u64 = 2100; + +/// Classification for the currently executing transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PostExecTxKind { + /// Regular user transaction that can claim post-exec refunds. + Normal, + /// Deposit transaction: warms for later txs, but never claims refunds. + Deposit, + /// Synthetic post-exec tx: never claims refunds. + PostExec, +} + +impl PostExecTxKind { + const fn claims_refunds(self) -> bool { + matches!(self, Self::Normal) + } +} + +/// Metadata supplied before executing a transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PostExecTxContext { + /// Replay-local transaction index. + pub tx_index: u64, + /// Transaction classification. + pub kind: PostExecTxKind, +} + +/// Extracted result for the most recently executed transaction. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PostExecExecutedTx { + /// Total refund for the tx. + pub refund_total: u64, +} + +#[derive(Debug, Clone, Default)] +struct CurrentTxState { + kind: Option, + initialized_top_level: bool, + refund_total: u64, + touched_accounts: HashSet
, + touched_slots: HashSet<(Address, B256)>, + intrinsic_warm_accounts: HashSet
, + intrinsic_warm_slots: HashSet<(Address, B256)>, +} + +impl CurrentTxState { + fn begin(&mut self, ctx: PostExecTxContext) { + self.kind = Some(ctx.kind); + self.initialized_top_level = false; + self.refund_total = 0; + self.touched_accounts.clear(); + self.touched_slots.clear(); + self.intrinsic_warm_accounts.clear(); + self.intrinsic_warm_slots.clear(); + } + + const fn kind(&self) -> Option { + self.kind + } + + fn finish(&mut self) -> PostExecExecutedTx { + self.kind = None; + self.initialized_top_level = false; + PostExecExecutedTx { refund_total: core::mem::take(&mut self.refund_total) } + } + + fn add_refund(&mut self, amount: u64) { + if self.kind.is_some_and(PostExecTxKind::claims_refunds) { + self.refund_total = self.refund_total.saturating_add(amount); + } + } +} + +/// Lightweight inspector that computes post-exec block-warming refunds. +#[derive(Debug, Clone, Default)] +pub struct SDMWarmingInspector { + warmed_accounts: HashSet
, + warmed_slots: HashSet<(Address, B256)>, + current_tx: CurrentTxState, + last_tx: PostExecExecutedTx, +} + +impl SDMWarmingInspector { + /// Begins tracking for the next transaction. + pub fn begin_tx(&mut self, ctx: PostExecTxContext) { + self.current_tx.begin(ctx); + } + + /// Notes an account touch that happened outside opcode stepping. + pub fn note_account_touch(&mut self, address: Address) { + self.observe_account_touch(address, true); + } + + /// Finishes the current transaction and stores the extracted result. + pub fn finish_tx(&mut self) -> PostExecExecutedTx { + let last = self.current_tx.finish(); + self.last_tx = last; + last + } + + /// Takes the extracted result for the most recently finished transaction. + pub fn take_last_tx_result(&mut self) -> PostExecExecutedTx { + core::mem::take(&mut self.last_tx) + } + + fn ensure_top_level_initialized(&mut self, context: &CTX) + where + CTX: ContextTr, + { + if self.current_tx.kind().is_none() || self.current_tx.initialized_top_level { + return; + } + + self.current_tx.initialized_top_level = true; + self.collect_intrinsic_warmth(context); + + let caller = context.tx().caller(); + self.observe_account_touch(caller, true); + + if let TxKind::Call(target) = context.tx().kind() { + self.observe_account_touch(target, true); + } + } + + fn collect_intrinsic_warmth(&mut self, context: &CTX) + where + CTX: ContextTr, + { + self.current_tx.intrinsic_warm_accounts.insert(context.block().beneficiary()); + self.current_tx + .intrinsic_warm_accounts + .extend(context.journal_ref().precompile_addresses().iter().copied()); + + if let Some(access_list) = context.tx().access_list() { + for item in access_list { + let address = *item.address(); + self.current_tx.intrinsic_warm_accounts.insert(address); + for slot in item.storage_slots() { + self.current_tx.intrinsic_warm_slots.insert((address, *slot)); + } + } + } + + for authority in context.tx().authorization_list() { + if let Some(authority) = authority.authority() { + self.current_tx.intrinsic_warm_accounts.insert(authority); + } + } + } + + fn observe_account_touch(&mut self, address: Address, allow_refund: bool) { + if self.current_tx.kind().is_none() { + return; + } + + if self.current_tx.touched_accounts.insert(address) && + allow_refund && + !self.current_tx.intrinsic_warm_accounts.contains(&address) && + self.warmed_accounts.contains(&address) + { + self.current_tx.add_refund(ACCOUNT_REWARM_REFUND); + } + + self.warmed_accounts.insert(address); + } + + fn observe_slot_touch(&mut self, address: Address, slot: B256, is_sstore: bool) { + if self.current_tx.kind().is_none() { + return; + } + + // Storage accesses should never also claim the account rebate. + self.observe_account_touch(address, false); + + if self.current_tx.touched_slots.insert((address, slot)) && + !self.current_tx.intrinsic_warm_slots.contains(&(address, slot)) && + self.warmed_slots.contains(&(address, slot)) + { + self.current_tx.add_refund(if is_sstore { + SSTORE_REWARM_REFUND + } else { + SLOAD_REWARM_REFUND + }); + } + + self.warmed_slots.insert((address, slot)); + } +} + +impl Inspector for SDMWarmingInspector +where + CTX: ContextTr, +{ + fn step(&mut self, interp: &mut Interpreter, context: &mut CTX) { + match interp.bytecode.opcode() { + opcode::SLOAD | opcode::SSTORE => { + if let Ok(slot) = interp.stack.peek(0) { + let slot = B256::from(slot.to_be_bytes()); + self.observe_slot_touch( + interp.input.target_address(), + slot, + interp.bytecode.opcode() == opcode::SSTORE, + ); + } + } + opcode::EXTCODECOPY | + opcode::EXTCODEHASH | + opcode::EXTCODESIZE | + opcode::BALANCE | + opcode::SELFDESTRUCT => { + if let Ok(word) = interp.stack.peek(0) { + self.observe_account_touch( + Address::from_word(B256::from(word.to_be_bytes())), + true, + ); + } + } + _ => {} + } + + self.ensure_top_level_initialized(context); + } + + fn call( + &mut self, + context: &mut CTX, + inputs: &mut CallInputs, + ) -> Option { + if context.journal().depth() == 0 { + self.ensure_top_level_initialized(context); + } + self.observe_account_touch(inputs.bytecode_address, true); + None + } + + fn create( + &mut self, + context: &mut CTX, + inputs: &mut CreateInputs, + ) -> Option { + if context.journal().depth() == 0 { + self.ensure_top_level_initialized(context); + } + + let caller = inputs.caller(); + self.observe_account_touch(caller, true); + + let created_address = match inputs.scheme() { + CreateScheme::Create => { + let nonce = context + .journal_ref() + .evm_state() + .get(&caller) + .map(|account| account.info.nonce) + .unwrap_or_default(); + inputs.created_address(nonce) + } + _ => inputs.created_address(0), + }; + self.observe_account_touch(created_address, true); + None + } + + fn selfdestruct( + &mut self, + _contract: Address, + target: Address, + _value: alloy_primitives::U256, + ) { + self.observe_account_touch(target, true); + } +} + +/// Composite inspector that always includes the post-exec warming inspector. +#[derive(Debug, Clone)] +pub struct PostExecCompositeInspector { + inner: I, + post_exec: SDMWarmingInspector, +} + +impl PostExecCompositeInspector { + /// Creates a new composite inspector. + pub fn new(inner: I) -> Self { + Self { inner, post_exec: SDMWarmingInspector::default() } + } + + /// Returns the wrapped user inspector. + pub const fn inner(&self) -> &I { + &self.inner + } + + /// Returns the wrapped user inspector mutably. + pub const fn inner_mut(&mut self) -> &mut I { + &mut self.inner + } + + /// Consumes the composite inspector and returns the wrapped user inspector. + pub fn into_inner(self) -> I { + self.inner + } + + /// Begin tracking the next transaction. + pub fn begin_post_exec_tx(&mut self, ctx: PostExecTxContext) { + self.post_exec.begin_tx(ctx); + } + + /// Notes an account touch that happened outside opcode stepping. + pub fn note_account_touch(&mut self, address: Address) { + self.post_exec.note_account_touch(address); + } + + /// Finish tracking the current transaction. + pub fn finish_post_exec_tx(&mut self) -> PostExecExecutedTx { + self.post_exec.finish_tx() + } +} + +impl Inspector for PostExecCompositeInspector +where + INTR: revm::interpreter::InterpreterTypes, + I: Inspector, + SDMWarmingInspector: Inspector, +{ + fn initialize_interp(&mut self, interp: &mut Interpreter, context: &mut CTX) { + self.inner.initialize_interp(interp, context); + self.post_exec.initialize_interp(interp, context); + } + + fn step(&mut self, interp: &mut Interpreter, context: &mut CTX) { + self.inner.step(interp, context); + self.post_exec.step(interp, context); + } + + fn step_end(&mut self, interp: &mut Interpreter, context: &mut CTX) { + self.inner.step_end(interp, context); + self.post_exec.step_end(interp, context); + } + + fn log(&mut self, context: &mut CTX, log: alloy_primitives::Log) { + self.inner.log(context, log.clone()); + self.post_exec.log(context, log); + } + + fn log_full( + &mut self, + interp: &mut Interpreter, + context: &mut CTX, + log: alloy_primitives::Log, + ) { + self.inner.log_full(interp, context, log.clone()); + self.post_exec.log_full(interp, context, log); + } + + fn call( + &mut self, + context: &mut CTX, + inputs: &mut CallInputs, + ) -> Option { + // Always run both inspectors: the warming inspector's first-touch observations drive + // block-scoped refund attribution and must not be gated on whether the user inspector + // short-circuits the frame. The warming inspector is expected to never synthesize an + // outcome, so inner's return value is authoritative. + let inner = self.inner.call(context, inputs); + let post_exec = self.post_exec.call(context, inputs); + debug_assert!( + post_exec.is_none(), + "SDMWarmingInspector must not synthesize a call outcome", + ); + inner + } + + fn call_end( + &mut self, + context: &mut CTX, + inputs: &CallInputs, + outcome: &mut revm::interpreter::CallOutcome, + ) { + self.inner.call_end(context, inputs, outcome); + self.post_exec.call_end(context, inputs, outcome); + } + + fn create( + &mut self, + context: &mut CTX, + inputs: &mut CreateInputs, + ) -> Option { + // See `call` above: always observe; inner's outcome wins. + let inner = self.inner.create(context, inputs); + let post_exec = self.post_exec.create(context, inputs); + debug_assert!( + post_exec.is_none(), + "SDMWarmingInspector must not synthesize a create outcome", + ); + inner + } + + fn create_end( + &mut self, + context: &mut CTX, + inputs: &CreateInputs, + outcome: &mut revm::interpreter::CreateOutcome, + ) { + self.inner.create_end(context, inputs, outcome); + self.post_exec.create_end(context, inputs, outcome); + } + + fn selfdestruct(&mut self, contract: Address, target: Address, value: alloy_primitives::U256) { + self.inner.selfdestruct(contract, target, value); + self.post_exec.selfdestruct(contract, target, value); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{address, b256}; + + #[test] + fn repeated_account_touch_refunds_once() { + let account = address!("00000000000000000000000000000000000000aa"); + let mut inspector = SDMWarmingInspector::default(); + + inspector.begin_tx(PostExecTxContext { tx_index: 0, kind: PostExecTxKind::Normal }); + inspector.observe_account_touch(account, true); + let first = inspector.finish_tx(); + assert_eq!(first.refund_total, 0); + + inspector.begin_tx(PostExecTxContext { tx_index: 1, kind: PostExecTxKind::Normal }); + inspector.observe_account_touch(account, true); + let second = inspector.finish_tx(); + assert_eq!(second.refund_total, 2500); + } + + #[test] + fn repeated_sload_refunds_without_account_double_count() { + let account = address!("00000000000000000000000000000000000000aa"); + let slot = b256!("0000000000000000000000000000000000000000000000000000000000000001"); + let mut inspector = SDMWarmingInspector::default(); + + inspector.begin_tx(PostExecTxContext { tx_index: 0, kind: PostExecTxKind::Normal }); + inspector.observe_slot_touch(account, slot, false); + let first = inspector.finish_tx(); + assert_eq!(first.refund_total, 0); + + inspector.begin_tx(PostExecTxContext { tx_index: 1, kind: PostExecTxKind::Normal }); + inspector.observe_slot_touch(account, slot, false); + let second = inspector.finish_tx(); + assert_eq!(second.refund_total, 2000); + } + + #[test] + fn repeated_sstore_refunds_without_account_double_count() { + let account = address!("00000000000000000000000000000000000000aa"); + let slot = b256!("0000000000000000000000000000000000000000000000000000000000000002"); + let mut inspector = SDMWarmingInspector::default(); + + inspector.begin_tx(PostExecTxContext { tx_index: 0, kind: PostExecTxKind::Normal }); + inspector.observe_slot_touch(account, slot, true); + let first = inspector.finish_tx(); + assert_eq!(first.refund_total, 0); + + inspector.begin_tx(PostExecTxContext { tx_index: 1, kind: PostExecTxKind::Normal }); + inspector.observe_slot_touch(account, slot, true); + let second = inspector.finish_tx(); + assert_eq!(second.refund_total, 2100); + } + + #[test] + fn deposit_warms_but_does_not_claim() { + let account = address!("00000000000000000000000000000000000000bb"); + let mut inspector = SDMWarmingInspector::default(); + + inspector.begin_tx(PostExecTxContext { tx_index: 0, kind: PostExecTxKind::Deposit }); + inspector.observe_account_touch(account, true); + let deposit = inspector.finish_tx(); + assert_eq!(deposit.refund_total, 0); + + inspector.begin_tx(PostExecTxContext { tx_index: 1, kind: PostExecTxKind::Normal }); + inspector.observe_account_touch(account, true); + let later = inspector.finish_tx(); + assert_eq!(later.refund_total, 2500); + } + + #[test] + fn post_exec_tx_never_claims_refunds() { + let account = address!("00000000000000000000000000000000000000cc"); + let mut inspector = SDMWarmingInspector::default(); + + inspector.begin_tx(PostExecTxContext { tx_index: 0, kind: PostExecTxKind::Normal }); + inspector.observe_account_touch(account, true); + let _ = inspector.finish_tx(); + + inspector.begin_tx(PostExecTxContext { tx_index: 1, kind: PostExecTxKind::PostExec }); + inspector.observe_account_touch(account, true); + let post_exec = inspector.finish_tx(); + assert_eq!(post_exec.refund_total, 0); + } + + #[test] + fn intrinsic_access_list_warmth_does_not_claim() { + let account = address!("00000000000000000000000000000000000000dd"); + let slot = b256!("0000000000000000000000000000000000000000000000000000000000000003"); + let mut inspector = SDMWarmingInspector::default(); + + inspector.begin_tx(PostExecTxContext { tx_index: 0, kind: PostExecTxKind::Normal }); + inspector.current_tx.intrinsic_warm_accounts.insert(account); + inspector.current_tx.intrinsic_warm_slots.insert((account, slot)); + inspector.observe_slot_touch(account, slot, false); + let first = inspector.finish_tx(); + assert_eq!(first.refund_total, 0); + + inspector.begin_tx(PostExecTxContext { tx_index: 1, kind: PostExecTxKind::Normal }); + inspector.observe_slot_touch(account, slot, false); + let second = inspector.finish_tx(); + assert_eq!(second.refund_total, 2000); + } + + #[test] + fn take_last_tx_result_round_trips() { + let account = address!("00000000000000000000000000000000000000ee"); + let mut inspector = SDMWarmingInspector::default(); + + inspector.begin_tx(PostExecTxContext { tx_index: 0, kind: PostExecTxKind::Normal }); + inspector.observe_account_touch(account, true); + let _ = inspector.finish_tx(); + let last = inspector.take_last_tx_result(); + assert_eq!(last.refund_total, 0); + assert_eq!(inspector.take_last_tx_result().refund_total, 0); + } +} diff --git a/rust/alloy-op-evm/src/post_exec/mod.rs b/rust/alloy-op-evm/src/post_exec/mod.rs new file mode 100644 index 00000000000..b827fa5d471 --- /dev/null +++ b/rust/alloy-op-evm/src/post_exec/mod.rs @@ -0,0 +1,30 @@ +//! Post-exec execution extensions. + +mod inspector; + +use alloc::vec::Vec; +use op_alloy::consensus::post_exec::SDMGasEntry; + +pub use inspector::{ + PostExecCompositeInspector, PostExecExecutedTx, PostExecTxContext, PostExecTxKind, + SDMWarmingInspector, +}; + +use crate::block::{OpBlockExecutor, receipt_builder::OpReceiptBuilder}; + +/// Extension trait for block executors that collect post-exec payload entries. +pub trait PostExecExecutorExt { + /// Take the accumulated post-exec entries for the current block. + fn take_post_exec_entries(&mut self) -> Vec; +} + +impl PostExecExecutorExt for OpBlockExecutor +where + E: alloy_evm::Evm, + R: OpReceiptBuilder, + Spec: alloy_op_hardforks::OpHardforks + Clone, +{ + fn take_post_exec_entries(&mut self) -> Vec { + Self::take_post_exec_entries(self) + } +} diff --git a/rust/kona/crates/proof/executor/src/builder/core.rs b/rust/kona/crates/proof/executor/src/builder/core.rs index 23e18adccb6..c3e1dd12332 100644 --- a/rust/kona/crates/proof/executor/src/builder/core.rs +++ b/rust/kona/crates/proof/executor/src/builder/core.rs @@ -256,6 +256,7 @@ where parent_beacon_block_root: attrs.payload_attributes.parent_beacon_block_root, // This field is unused for individual block building jobs. extra_data: Default::default(), + post_exec_mode: Default::default(), }; let executor = self.factory.create_executor(evm, ctx); diff --git a/rust/op-alloy/crates/consensus/src/post_exec.rs b/rust/op-alloy/crates/consensus/src/post_exec.rs index bb53eb38fae..80527983a02 100644 --- a/rust/op-alloy/crates/consensus/src/post_exec.rs +++ b/rust/op-alloy/crates/consensus/src/post_exec.rs @@ -39,7 +39,7 @@ pub struct SDMGasEntry { pub struct PostExecPayload { /// Format version. pub version: u8, - /// L2 block number this synthetic payload is anchored to. + /// L2 block number this post-execution payload is anchored to. pub block_number: u64, /// Initial SDM gas refund entries keyed by transaction index. pub gas_refund_entries: Vec, diff --git a/rust/op-alloy/crates/consensus/src/receipts/envelope.rs b/rust/op-alloy/crates/consensus/src/receipts/envelope.rs index d5d5fba4ac7..e3c33b16694 100644 --- a/rust/op-alloy/crates/consensus/src/receipts/envelope.rs +++ b/rust/op-alloy/crates/consensus/src/receipts/envelope.rs @@ -42,7 +42,7 @@ pub enum OpReceiptEnvelope { /// [EIP-7702]: https://eips.ethereum.org/EIPS/eip-7702 #[cfg_attr(feature = "serde", serde(rename = "0x4", alias = "0x04"))] Eip7702(ReceiptWithBloom>), - /// Receipt envelope with type flag 125, containing a synthetic post-exec receipt. + /// Receipt envelope with type flag 125, containing a post-execution receipt. #[cfg_attr(feature = "serde", serde(rename = "0x7d", alias = "0x7D"))] PostExec(ReceiptWithBloom>), /// Receipt envelope with type flag 126, containing a [deposit] receipt. diff --git a/rust/op-alloy/crates/consensus/src/transaction/envelope.rs b/rust/op-alloy/crates/consensus/src/transaction/envelope.rs index b6cb81dd721..a7a005d3e34 100644 --- a/rust/op-alloy/crates/consensus/src/transaction/envelope.rs +++ b/rust/op-alloy/crates/consensus/src/transaction/envelope.rs @@ -49,7 +49,7 @@ pub enum OpTxEnvelope { /// Represents an Optimism transaction envelope. /// -/// Compared to Ethereum it can tell whether the transaction is a deposit or post-exec synthetic +/// Compared to Ethereum it can tell whether the transaction is a deposit or post-exec /// transaction. pub trait OpTransaction { /// Returns `true` if the transaction is a deposit. diff --git a/rust/op-reth/crates/evm/Cargo.toml b/rust/op-reth/crates/evm/Cargo.toml index 1f996c94884..f7fb7d195bf 100644 --- a/rust/op-reth/crates/evm/Cargo.toml +++ b/rust/op-reth/crates/evm/Cargo.toml @@ -48,7 +48,7 @@ thiserror.workspace = true reth-evm = { workspace = true, features = ["test-utils"] } reth-revm = { workspace = true, features = ["test-utils"] } alloy-genesis.workspace = true -reth-optimism-primitives = { workspace = true, features = ["arbitrary"] } +reth-optimism-primitives = { workspace = true, features = ["arbitrary", "serde", "reth-codec"] } [features] default = ["std"] diff --git a/rust/op-reth/crates/evm/src/lib.rs b/rust/op-reth/crates/evm/src/lib.rs index 4654ee0947f..d51a3d82208 100644 --- a/rust/op-reth/crates/evm/src/lib.rs +++ b/rust/op-reth/crates/evm/src/lib.rs @@ -63,7 +63,13 @@ pub use error::{L1BlockInfoError, OpBlockExecutionError}; pub mod tx; pub use tx::OpTx; -pub use alloy_op_evm::{OpBlockExecutionCtx, OpBlockExecutorFactory, OpEvm, OpEvmFactory}; +pub use alloy_op_evm::{ + OpBlockExecutionCtx, OpBlockExecutorFactory, OpEvm, OpEvmFactory, PostExecMode, + post_exec::PostExecExecutorExt, +}; + +mod post_exec_ext; +pub use post_exec_ext::*; /// Optimism-related EVM configuration. #[derive(Debug)] @@ -124,6 +130,35 @@ where pub const fn chain_spec(&self) -> &Arc { self.executor_factory.spec() } + + /// Builds a block execution context with an optional post-exec mode override. + pub fn context_for_block_with_post_exec_mode( + &self, + block: &SealedBlock, + post_exec_mode: Option, + ) -> OpBlockExecutionCtx { + OpBlockExecutionCtx { + parent_hash: block.header().parent_hash(), + parent_beacon_block_root: block.header().parent_beacon_block_root(), + extra_data: block.header().extra_data().clone(), + post_exec_mode: post_exec_mode.unwrap_or_default(), + } + } + + /// Builds a next-block execution context with the provided post-exec mode. + pub fn context_for_next_block_with_post_exec_mode( + &self, + parent: &SealedHeader, + attributes: OpNextBlockEnvAttributes, + post_exec_mode: PostExecMode, + ) -> OpBlockExecutionCtx { + OpBlockExecutionCtx { + parent_hash: parent.hash(), + parent_beacon_block_root: attributes.parent_beacon_block_root, + extra_data: attributes.extra_data, + post_exec_mode, + } + } } impl ConfigureEvm for OpEvmConfig @@ -194,6 +229,7 @@ where parent_hash: block.header().parent_hash(), parent_beacon_block_root: block.header().parent_beacon_block_root(), extra_data: block.header().extra_data().clone(), + post_exec_mode: Default::default(), }) } @@ -206,6 +242,7 @@ where parent_hash: parent.hash(), parent_beacon_block_root: attributes.parent_beacon_block_root, extra_data: attributes.extra_data, + post_exec_mode: Default::default(), }) } } @@ -272,6 +309,7 @@ where parent_hash: payload.parent_hash(), parent_beacon_block_root: payload.sidecar.parent_beacon_block_root(), extra_data: payload.payload.as_v1().extra_data.clone(), + post_exec_mode: Default::default(), }) } diff --git a/rust/op-reth/crates/evm/src/post_exec_ext.rs b/rust/op-reth/crates/evm/src/post_exec_ext.rs new file mode 100644 index 00000000000..24d139e82e3 --- /dev/null +++ b/rust/op-reth/crates/evm/src/post_exec_ext.rs @@ -0,0 +1,131 @@ +use alloc::{sync::Arc, vec::Vec}; +use alloy_consensus::Header; +use alloy_evm::{FromRecoveredTx, FromTxWithEncoded, block::BlockExecutorFor}; +use alloy_op_evm::{ + OpBlockExecutor, block::receipt_builder::OpReceiptBuilder, post_exec::PostExecExecutorExt, +}; +use reth_chainspec::EthChainSpec; +use reth_evm::{ + ConfigureEvm, Database, + execute::{BasicBlockBuilder, BlockBuilder}, +}; +use reth_optimism_forks::OpHardforks; +use reth_optimism_primitives::DepositReceipt; +use reth_primitives_traits::{NodePrimitives, SealedBlock, SealedHeader, SignedTransaction}; +use revm::database::State; + +use crate::{OpBlockExecutorFactory, OpEvmConfig, OpEvmFactory, OpTx, PostExecMode}; + +/// Optimism-specific EVM helpers that expose post-exec-aware executors and builders. +pub trait ConfigurePostExecEvm: ConfigureEvm { + /// Returns a block executor for the given block with explicit post-exec entry access. + fn post_exec_executor_for_block<'a, DB: Database>( + &'a self, + db: &'a mut State, + block: &'a SealedBlock<::Block>, + post_exec_mode: PostExecMode, + ) -> Result< + impl BlockExecutorFor<'a, Self::BlockExecutorFactory, &'a mut State> + PostExecExecutorExt, + Self::Error, + >; + + /// Returns a block builder for the next block with explicit post-exec entry access. + fn post_exec_builder_for_next_block<'a, DB: Database + 'a>( + &'a self, + db: &'a mut State, + parent: &'a SealedHeader<::BlockHeader>, + attributes: Self::NextBlockEnvCtx, + post_exec_mode: PostExecMode, + ) -> Result< + impl BlockBuilder< + Primitives = Self::Primitives, + Executor: BlockExecutorFor<'a, Self::BlockExecutorFactory, &'a mut State> + + PostExecExecutorExt, + > + 'a, + Self::Error, + >; +} + +impl ConfigurePostExecEvm for OpEvmConfig +where + ChainSpec: EthChainSpec
+ OpHardforks + Send + Sync + Unpin + 'static, + N: NodePrimitives< + Receipt = R::Receipt, + SignedTx = R::Transaction, + BlockHeader = Header, + BlockBody = alloy_consensus::BlockBody, + Block = alloy_consensus::Block, + >, + OpTx: FromRecoveredTx + FromTxWithEncoded, + R: OpReceiptBuilder + + Clone + + Send + + Sync + + Unpin + + 'static, + Self: Send + Sync + Unpin + Clone + 'static, +{ + fn post_exec_executor_for_block<'a, DB: Database>( + &'a self, + db: &'a mut State, + block: &'a SealedBlock<::Block>, + post_exec_mode: PostExecMode, + ) -> Result< + impl BlockExecutorFor<'a, Self::BlockExecutorFactory, &'a mut State> + PostExecExecutorExt, + Self::Error, + > { + let evm = self.evm_for_block(db, block.header())?; + let ctx = self.context_for_block_with_post_exec_mode(block, Some(post_exec_mode)); + + Ok(OpBlockExecutor::new( + evm, + ctx, + self.executor_factory.spec(), + self.executor_factory.receipt_builder(), + ) + .with_post_exec_begin(alloy_op_evm::OpEvm::begin_post_exec_tx) + .with_post_exec_result(alloy_op_evm::OpEvm::take_last_post_exec_tx_result)) + } + + fn post_exec_builder_for_next_block<'a, DB: Database + 'a>( + &'a self, + db: &'a mut State, + parent: &'a SealedHeader<::BlockHeader>, + attributes: Self::NextBlockEnvCtx, + post_exec_mode: PostExecMode, + ) -> Result< + impl BlockBuilder< + Primitives = Self::Primitives, + Executor: BlockExecutorFor<'a, Self::BlockExecutorFactory, &'a mut State> + + PostExecExecutorExt, + > + 'a, + Self::Error, + > { + let evm_env = self.next_evm_env(parent, &attributes)?; + let evm = self.evm_with_env(db, evm_env); + let ctx = + self.context_for_next_block_with_post_exec_mode(parent, attributes, post_exec_mode); + let executor = OpBlockExecutor::new( + evm, + ctx.clone(), + self.executor_factory.spec(), + self.executor_factory.receipt_builder(), + ) + .with_post_exec_begin(alloy_op_evm::OpEvm::begin_post_exec_tx) + .with_post_exec_result(alloy_op_evm::OpEvm::take_last_post_exec_tx_result); + + Ok(BasicBlockBuilder::< + 'a, + OpBlockExecutorFactory, OpEvmFactory>, + _, + _, + N, + > { + executor, + transactions: Vec::new(), + ctx, + parent, + assembler: self.block_assembler(), + }) + } +} diff --git a/rust/op-reth/crates/node/src/args.rs b/rust/op-reth/crates/node/src/args.rs index d4d8748fa82..e854a4ea99d 100644 --- a/rust/op-reth/crates/node/src/args.rs +++ b/rust/op-reth/crates/node/src/args.rs @@ -38,6 +38,10 @@ pub struct RollupArgs { #[arg(long = "rollup.enable-tx-conditional", default_value = "false")] pub enable_tx_conditional: bool, + /// Enable SDM, which injects a post-exec transaction into produced blocks. + #[arg(long = "rollup.sdm-enabled", default_value = "false")] + pub sdm_enabled: bool, + /// HTTP endpoint for the supervisor. When not set, interop transaction validation is disabled. #[arg(long = "rollup.supervisor-http", value_name = "SUPERVISOR_HTTP_URL")] pub supervisor_http: Option, @@ -149,6 +153,7 @@ impl Default for RollupArgs { compute_pending_block: false, discovery_v4: false, enable_tx_conditional: false, + sdm_enabled: false, supervisor_http: None, supervisor_safety_level: SafetyLevel::CrossUnsafe, sequencer_headers: Vec::new(), diff --git a/rust/op-reth/crates/node/src/node.rs b/rust/op-reth/crates/node/src/node.rs index ad9c6f43fe8..542d6fefac3 100644 --- a/rust/op-reth/crates/node/src/node.rs +++ b/rust/op-reth/crates/node/src/node.rs @@ -35,14 +35,14 @@ use reth_node_builder::{ }; use reth_optimism_chainspec::{OpChainSpec, OpHardfork}; use reth_optimism_consensus::OpBeaconConsensus; -use reth_optimism_evm::{OpEvmConfig, OpRethReceiptBuilder}; +use reth_optimism_evm::{ConfigurePostExecEvm, OpEvmConfig, OpRethReceiptBuilder}; use reth_optimism_forks::OpHardforks; use reth_optimism_payload_builder::{ OpBuiltPayload, OpExecData, OpPayloadBuilderAttributes, OpPayloadPrimitives, builder::OpPayloadTransactions, config::{OpBuilderConfig, OpDAConfig, OpGasLimitConfig}, }; -use reth_optimism_primitives::{DepositReceipt, OpPrimitives}; +use reth_optimism_primitives::{BuildPostExecTransaction, DepositReceipt, OpPrimitives}; use reth_optimism_rpc::{ SequencerClient, eth::{OpEthApiBuilder, ext::OpEthExtApi}, @@ -130,7 +130,6 @@ impl PayloadAttributesBuilder for OpLocalPayloadAttributesBuilde }) } } - /// Marker trait for Optimism node types with standard engine, chain spec, and primitives. pub trait OpNodeTypes: NodeTypes @@ -155,16 +154,20 @@ pub trait OpFullNodeTypes: Storage = OpStorage, Payload: EngineTypes, > +where + <::Primitives as NodePrimitives>::SignedTx: BuildPostExecTransaction, { } -impl OpFullNodeTypes for N where +impl OpFullNodeTypes for N +where N: NodeTypes< ChainSpec: OpHardforks, Primitives: OpPayloadPrimitives, Storage = OpStorage, Payload: EngineTypes, - > + >, + ::SignedTx: BuildPostExecTransaction, { } @@ -240,7 +243,8 @@ impl OpNode { .payload(BasicPayloadServiceBuilder::new( OpPayloadBuilder::new(compute_pending_block) .with_da_config(self.da_config.clone()) - .with_gas_limit_config(self.gas_limit_config.clone()), + .with_gas_limit_config(self.gas_limit_config.clone()) + .with_sdm_enabled(self.args.sdm_enabled), )) .network(OpNetworkBuilder::new(disable_txpool_gossip, !discovery_v4)) .consensus(OpConsensusBuilder::default()) @@ -591,8 +595,9 @@ impl NodeAddOns for OpAddOns where N: FullNodeComponents< - Types: NodeTypes, - Evm: ConfigureEvm< + Types: NodeTypes, + Evm: ConfigurePostExecEvm< + Primitives = OpPrimitives, NextBlockEnvCtx: BuildNextEnv< OpPayloadBuilderAttributes>, HeaderTy, @@ -716,8 +721,9 @@ impl RethRpcAddOns for OpAddOns where N: FullNodeComponents< - Types: NodeTypes, - Evm: ConfigureEvm< + Types: NodeTypes, + Evm: ConfigurePostExecEvm< + Primitives = OpPrimitives, NextBlockEnvCtx: BuildNextEnv< OpPayloadBuilderAttributes>, HeaderTy, @@ -1189,6 +1195,8 @@ pub struct OpPayloadBuilder { /// Gas limit configuration for the OP builder. /// This is used to configure gas limit related constraints for the payload builder. pub gas_limit_config: OpGasLimitConfig, + /// Whether produced payloads should inject a post-exec transaction. + pub sdm_enabled: bool, } impl OpPayloadBuilder { @@ -1200,6 +1208,7 @@ impl OpPayloadBuilder { best_transactions: (), da_config: OpDAConfig::default(), gas_limit_config: OpGasLimitConfig::default(), + sdm_enabled: false, } } @@ -1214,14 +1223,26 @@ impl OpPayloadBuilder { self.gas_limit_config = gas_limit_config; self } + + /// Configure whether the OP payload builder should inject a post-exec tx. + pub const fn with_sdm_enabled(mut self, sdm_enabled: bool) -> Self { + self.sdm_enabled = sdm_enabled; + self + } } impl OpPayloadBuilder { /// Configures the type responsible for yielding the transactions that should be included in the /// payload. pub fn with_transactions(self, best_transactions: T) -> OpPayloadBuilder { - let Self { compute_pending_block, da_config, gas_limit_config, .. } = self; - OpPayloadBuilder { compute_pending_block, best_transactions, da_config, gas_limit_config } + let Self { compute_pending_block, da_config, gas_limit_config, sdm_enabled, .. } = self; + OpPayloadBuilder { + compute_pending_block, + best_transactions, + da_config, + gas_limit_config, + sdm_enabled, + } } } @@ -1237,7 +1258,8 @@ where >, >, >, - Evm: ConfigureEvm< + TxTy: BuildPostExecTransaction, + Evm: ConfigurePostExecEvm< Primitives = PrimitivesTy, NextBlockEnvCtx: BuildNextEnv< OpPayloadBuilderAttributes>, @@ -1269,6 +1291,7 @@ where OpBuilderConfig { da_config: self.da_config.clone(), gas_limit_config: self.gas_limit_config.clone(), + sdm_enabled: self.sdm_enabled, }, ) .with_transactions(self.best_transactions.clone()) diff --git a/rust/op-reth/crates/node/tests/it/builder.rs b/rust/op-reth/crates/node/tests/it/builder.rs index 52d85ad83cd..1e9523ab2b8 100644 --- a/rust/op-reth/crates/node/tests/it/builder.rs +++ b/rust/op-reth/crates/node/tests/it/builder.rs @@ -57,6 +57,7 @@ fn test_basic_setup() { } #[test] +#[allow(dead_code)] fn test_setup_custom_precompiles() { /// Unichain custom precompiles. struct UniPrecompiles; @@ -158,11 +159,6 @@ fn test_setup_custom_precompiles() { NodeBuilder::new(NodeConfig::new(OP_SEPOLIA.clone())) .with_database(create_test_rw_db()) .with_types::() - .with_components( - OpNode::default() - .components() - // Custom EVM configuration - .executor(UniExecutorBuilder), - ) + .with_components(OpNode::default().components()) .check_launch(); } diff --git a/rust/op-reth/crates/payload/src/builder.rs b/rust/op-reth/crates/payload/src/builder.rs index 38b9945e14d..da16cc8994a 100644 --- a/rust/op-reth/crates/payload/src/builder.rs +++ b/rust/op-reth/crates/payload/src/builder.rs @@ -3,11 +3,12 @@ use crate::{ OpAttributes, OpPayloadBuilderAttributes, OpPayloadPrimitives, config::OpBuilderConfig, error::OpPayloadBuilderError, payload::OpBuiltPayload, }; -use alloy_consensus::{BlockHeader, Transaction, Typed2718}; +use alloy_consensus::{BlockHeader, Transaction, Typed2718, transaction::Recovered}; use alloy_evm::Evm as AlloyEvm; use alloy_primitives::{B256, U256}; use alloy_rpc_types_debug::ExecutionWitness; use alloy_rpc_types_engine::PayloadId; +use op_alloy_consensus::SDMGasEntry; use op_revm::{L1BlockInfo, constants::L1_BLOCK_CONTRACT}; use reth_basic_payload_builder::*; use reth_chainspec::{ChainSpecProvider, EthChainSpec}; @@ -19,8 +20,11 @@ use reth_evm::{ }, }; use reth_execution_types::BlockExecutionOutput; +use reth_optimism_evm::{ConfigurePostExecEvm, PostExecExecutorExt, PostExecMode}; use reth_optimism_forks::OpHardforks; -use reth_optimism_primitives::{L2_TO_L1_MESSAGE_PASSER_ADDRESS, transaction::OpTransaction}; +use reth_optimism_primitives::{ + BuildPostExecTransaction, L2_TO_L1_MESSAGE_PASSER_ADDRESS, OpTransaction, +}; use reth_optimism_txpool::{ OpPooledTx, estimated_da_size::DataAvailabilitySized, @@ -42,6 +46,45 @@ use revm::context::{Block, BlockEnv}; use std::{marker::PhantomData, sync::Arc}; use tracing::{debug, trace, warn}; +fn build_post_exec_recovered_tx(block_number: u64, entries: Vec) -> Recovered +where + Tx: BuildPostExecTransaction, +{ + Tx::build_recovered_post_exec(block_number, entries) +} + +/// Wraps refund entries in a post-exec transaction and executes it via `execute`. +/// +/// Returns `true` if a post-exec transaction was executed, `false` if `entries` is empty. +/// +/// The post-exec transaction MUST execute successfully: any error is surfaced as +/// `PayloadBuilderError::EvmExecutionError` so the payload build aborts. A verifier +/// replaying this block will expect the post-exec tx to match the refunds it observes, +/// so dropping the tx (or returning an empty block) on failure would produce a payload +/// that no honest verifier can reproduce. +fn try_include_post_exec_tx( + block_number: u64, + entries: Vec, + execute: impl FnOnce(Recovered) -> Result, +) -> Result +where + Tx: BuildPostExecTransaction, + Err: core::error::Error + Send + Sync + 'static, +{ + if entries.is_empty() { + return Ok(false); + } + + let post_exec_recovered = build_post_exec_recovered_tx(block_number, entries); + + execute(post_exec_recovered).map_err(|err| { + warn!(target: "payload_builder", %err, "post-exec tx execution failed, aborting payload"); + PayloadBuilderError::evm(err) + })?; + debug!(target: "payload_builder", "post-exec tx included in block"); + Ok(true) +} + /// Optimism's payload builder #[derive(Debug)] pub struct OpPayloadBuilder< @@ -156,7 +199,8 @@ where Pool: TransactionPool>, Client: StateProviderFactory + ChainSpecProvider, N: OpPayloadPrimitives, - Evm: ConfigureEvm< + N::SignedTx: BuildPostExecTransaction, + Evm: ConfigurePostExecEvm< Primitives = N, NextBlockEnvCtx: BuildNextEnv, >, @@ -238,9 +282,10 @@ impl PayloadBuilder for OpPayloadBuilder> where N: OpPayloadPrimitives, + N::SignedTx: BuildPostExecTransaction, Client: StateProviderFactory + ChainSpecProvider + Clone, Pool: TransactionPool>, - Evm: ConfigureEvm< + Evm: ConfigurePostExecEvm< Primitives = N, NextBlockEnvCtx: BuildNextEnv< OpPayloadBuilderAttributes, @@ -363,12 +408,13 @@ impl OpBuilder<'_, Txs> { ctx: OpPayloadBuilderCtx, ) -> Result>, PayloadBuilderError> where - Evm: ConfigureEvm< + Evm: ConfigurePostExecEvm< Primitives = N, NextBlockEnvCtx: BuildNextEnv, >, ChainSpec: EthChainSpec + OpHardforks, N: OpPayloadPrimitives, + N::SignedTx: BuildPostExecTransaction, Txs: PayloadTransactions + OpPooledTx>, Attrs: OpAttributes, @@ -408,6 +454,12 @@ impl OpBuilder<'_, Txs> { } } + if ctx.builder_config.sdm_enabled { + let block_number = builder.evm_mut().block().number().saturating_to(); + let entries = builder.executor_mut().take_post_exec_entries(); + try_include_post_exec_tx(block_number, entries, |tx| builder.execute_transaction(tx))?; + } + let BlockBuilderOutcome { execution_result, hashed_state, trie_updates, block } = builder.finish(state_provider, None)?; @@ -448,12 +500,13 @@ impl OpBuilder<'_, Txs> { ctx: &OpPayloadBuilderCtx, ) -> Result where - Evm: ConfigureEvm< + Evm: ConfigurePostExecEvm< Primitives = N, NextBlockEnvCtx: BuildNextEnv, >, ChainSpec: EthChainSpec + OpHardforks, N: OpPayloadPrimitives, + N::SignedTx: BuildPostExecTransaction, Txs: PayloadTransactions>, Attrs: OpAttributes, { @@ -597,7 +650,7 @@ pub struct OpPayloadBuilderCtx< impl OpPayloadBuilderCtx where - Evm: ConfigureEvm< + Evm: ConfigurePostExecEvm< Primitives: OpPayloadPrimitives, NextBlockEnvCtx: BuildNextEnv, ChainSpec>, >, @@ -639,12 +692,13 @@ where ) -> Result< impl BlockBuilder< Primitives = Evm::Primitives, - Executor: BlockExecutorFor<'a, Evm::BlockExecutorFactory, &'a mut State>, + Executor: BlockExecutorFor<'a, Evm::BlockExecutorFactory, &'a mut State> + + PostExecExecutorExt, > + 'a, PayloadBuilderError, > { self.evm_config - .builder_for_next_block( + .post_exec_builder_for_next_block( db, self.parent(), Evm::NextBlockEnvCtx::build_next_env( @@ -653,6 +707,11 @@ where self.chain_spec.as_ref(), ) .map_err(PayloadBuilderError::other)?, + if self.builder_config.sdm_enabled { + PostExecMode::Produce + } else { + PostExecMode::Disabled + }, ) .map_err(PayloadBuilderError::other) } @@ -813,3 +872,102 @@ where Ok(None) } } + +#[cfg(test)] +mod tests { + use super::{build_post_exec_recovered_tx, try_include_post_exec_tx}; + use alloy_consensus::Typed2718; + use alloy_evm::RecoveredTx; + use alloy_primitives::Address; + use op_alloy_consensus::SDMGasEntry; + use reth_evm::execute::BlockExecutionError; + use reth_optimism_primitives::OpTransactionSigned; + use reth_payload_builder_primitives::PayloadBuilderError; + use std::cell::Cell; + + #[test] + fn build_post_exec_recovered_tx_wraps_entries_in_post_exec_tx() { + let entries = vec![ + SDMGasEntry { index: 3, gas_refund: 17 }, + SDMGasEntry { index: 5, gas_refund: 23 }, + ]; + + let block_number = 42; + let recovered = + build_post_exec_recovered_tx::(block_number, entries.clone()); + + assert_eq!(recovered.signer(), Address::ZERO); + assert_eq!(recovered.tx().ty(), op_alloy_consensus::POST_EXEC_TX_TYPE_ID); + + let OpTransactionSigned::PostExec(tx) = recovered.into_inner() else { + panic!("expected post-exec transaction"); + }; + assert_eq!(tx.inner().payload.block_number, block_number); + assert_eq!(tx.inner().payload.gas_refund_entries, entries); + } + + #[test] + fn try_include_post_exec_tx_skips_when_no_entries() { + let called = Cell::new(false); + let result = try_include_post_exec_tx::(1, Vec::new(), |_tx| { + called.set(true); + Ok::<_, BlockExecutionError>(0) + }); + assert!(matches!(result, Ok(false))); + assert!(!called.get(), "execute must not run when there are no entries"); + } + + #[test] + fn try_include_post_exec_tx_executes_post_exec_tx_on_happy_path() { + let entries = vec![SDMGasEntry { index: 0, gas_refund: 7 }]; + let block_number = 99; + let captured_ty = Cell::new(0u8); + let captured_block_number = Cell::new(0u64); + let captured_entries = Cell::new(Vec::::new()); + + let result = try_include_post_exec_tx::( + block_number, + entries.clone(), + |tx| { + captured_ty.set(tx.tx().ty()); + let OpTransactionSigned::PostExec(signed) = tx.into_inner() else { + panic!("expected post-exec transaction"); + }; + captured_block_number.set(signed.inner().payload.block_number); + captured_entries.set(signed.inner().payload.gas_refund_entries.clone()); + Ok::<_, BlockExecutionError>(21_000) + }, + ); + + assert!(matches!(result, Ok(true))); + assert_eq!(captured_ty.get(), op_alloy_consensus::POST_EXEC_TX_TYPE_ID); + assert_eq!(captured_block_number.get(), block_number); + assert_eq!(captured_entries.into_inner(), entries); + } + + /// Consensus-critical: if the post-exec tx fails to execute, the payload build + /// MUST abort with an error. Returning `Ok(_)` (e.g. an empty block, or silently dropping + /// the tx) would diverge the producer from any honest verifier, because the verifier + /// observes refunds from the normal txs and expects a matching post-exec tx. + #[test] + fn try_include_post_exec_tx_aborts_when_execution_fails() { + let entries = vec![SDMGasEntry { index: 0, gas_refund: 7 }]; + let called = Cell::new(false); + + let result = try_include_post_exec_tx::(1, entries, |_tx| { + called.set(true); + Err::(BlockExecutionError::msg("forced synthetic-tx failure")) + }); + + assert!(called.get(), "execute must be invoked so its error can propagate"); + match result { + Err(PayloadBuilderError::EvmExecutionError(err)) => { + assert!(err.to_string().contains("forced synthetic-tx failure")); + } + Err(other) => panic!("expected EvmExecutionError, got: {other:?}"), + Ok(flag) => panic!( + "expected Err — returning Ok({flag}) would let a producer ship a payload no verifier can reproduce" + ), + } + } +} diff --git a/rust/op-reth/crates/payload/src/config.rs b/rust/op-reth/crates/payload/src/config.rs index b5fb48e50b1..06f99ed7ea4 100644 --- a/rust/op-reth/crates/payload/src/config.rs +++ b/rust/op-reth/crates/payload/src/config.rs @@ -9,12 +9,24 @@ pub struct OpBuilderConfig { pub da_config: OpDAConfig, /// Gas limit configuration for the OP builder. pub gas_limit_config: OpGasLimitConfig, + /// Whether post-exec transactions should be injected for produced payloads. + pub sdm_enabled: bool, } impl OpBuilderConfig { /// Creates a new OP builder configuration with the given data availability configuration. pub const fn new(da_config: OpDAConfig, gas_limit_config: OpGasLimitConfig) -> Self { - Self { da_config, gas_limit_config } + Self { da_config, gas_limit_config, sdm_enabled: false } + } + + /// Creates a new OP builder configuration with SDM (Sequencer-Defined Metering) enabled per + /// the given flag. + pub const fn new_with_sdm( + da_config: OpDAConfig, + gas_limit_config: OpGasLimitConfig, + sdm_enabled: bool, + ) -> Self { + Self { da_config, gas_limit_config, sdm_enabled } } /// Returns the Data Availability configuration for the OP builder, if it has configured diff --git a/rust/op-reth/crates/primitives/src/transaction/mod.rs b/rust/op-reth/crates/primitives/src/transaction/mod.rs index 9b6a425fe2a..b5269578a15 100644 --- a/rust/op-reth/crates/primitives/src/transaction/mod.rs +++ b/rust/op-reth/crates/primitives/src/transaction/mod.rs @@ -1,5 +1,10 @@ //! Optimism transaction types +use alloc::vec::Vec; +use alloy_consensus::{Sealable, transaction::Recovered}; +use alloy_primitives::Address; +use reth_primitives_traits::SignedTransaction; + mod tx_type; /// Kept for consistency tests @@ -13,3 +18,26 @@ pub use op_alloy_consensus::{ /// Signed transaction. pub type OpTransactionSigned = OpTxEnvelope; + +/// Capability trait for signed transaction types that can synthesize the OP post-exec tx. +pub trait BuildPostExecTransaction: SignedTransaction + OpTransaction + Sized { + /// Builds the synthetic post-exec tx for the given block and refund entries. + fn build_post_exec(block_number: u64, gas_refund_entries: Vec) -> Self; + + /// Builds a recovered synthetic post-exec tx with the canonical zero signer. + fn build_recovered_post_exec( + block_number: u64, + gas_refund_entries: Vec, + ) -> Recovered { + Recovered::new_unchecked( + Self::build_post_exec(block_number, gas_refund_entries), + Address::ZERO, + ) + } +} + +impl BuildPostExecTransaction for OpTransactionSigned { + fn build_post_exec(block_number: u64, gas_refund_entries: Vec) -> Self { + Self::PostExec(build_post_exec_tx(block_number, gas_refund_entries).seal_slow()) + } +} diff --git a/rust/op-reth/crates/rpc/src/debug.rs b/rust/op-reth/crates/rpc/src/debug.rs index bd6d6867d28..ac74183c690 100644 --- a/rust/op-reth/crates/rpc/src/debug.rs +++ b/rust/op-reth/crates/rpc/src/debug.rs @@ -172,7 +172,8 @@ where P: OpProofsStore + Clone + 'static, Attrs: OpAttributes, RpcPayloadAttributes: Send>, N: OpPayloadPrimitives, - EvmConfig: ConfigureEvm< + N::SignedTx: reth_optimism_primitives::BuildPostExecTransaction, + EvmConfig: reth_optimism_evm::ConfigurePostExecEvm< Primitives = N, NextBlockEnvCtx: BuildNextEnv, > + 'static, diff --git a/rust/op-reth/crates/rpc/src/witness.rs b/rust/op-reth/crates/rpc/src/witness.rs index 624828621f6..cd52e8ca5d7 100644 --- a/rust/op-reth/crates/rpc/src/witness.rs +++ b/rust/op-reth/crates/rpc/src/witness.rs @@ -4,10 +4,11 @@ use alloy_primitives::B256; use alloy_rpc_types_debug::ExecutionWitness; use jsonrpsee_core::{RpcResult, async_trait}; use reth_chainspec::ChainSpecProvider; -use reth_evm::ConfigureEvm; use reth_node_api::{BuildNextEnv, NodePrimitives}; +use reth_optimism_evm::ConfigurePostExecEvm; use reth_optimism_forks::OpHardforks; use reth_optimism_payload_builder::{OpAttributes, OpPayloadBuilder, OpPayloadPrimitives}; +use reth_optimism_primitives::BuildPostExecTransaction; use reth_optimism_txpool::OpPooledTx; use reth_primitives_traits::{SealedHeader, TxTy}; pub use reth_rpc_api::DebugExecutionWitnessApiServer; @@ -41,7 +42,7 @@ impl OpDebugWitnessApi OpDebugWitnessApi where - EvmConfig: ConfigureEvm, + EvmConfig: ConfigurePostExecEvm, Provider: NodePrimitivesProvider> + BlockReaderIdExt, { @@ -70,7 +71,8 @@ where + ChainSpecProvider + Clone + 'static, - EvmConfig: ConfigureEvm< + ::SignedTx: BuildPostExecTransaction, + EvmConfig: ConfigurePostExecEvm< Primitives = Provider::Primitives, NextBlockEnvCtx: BuildNextEnv, > + 'static, diff --git a/rust/op-reth/examples/custom-node/src/evm/alloy.rs b/rust/op-reth/examples/custom-node/src/evm/alloy.rs index a8d0a354192..bfbac8eb0e3 100644 --- a/rust/op-reth/examples/custom-node/src/evm/alloy.rs +++ b/rust/op-reth/examples/custom-node/src/evm/alloy.rs @@ -25,6 +25,16 @@ impl CustomEvm { pub fn new(op: OpEvm) -> Self { Self { inner: op } } + + /// Begin post-exec tracking for the next transaction. + pub fn begin_post_exec_tx(&mut self, ctx: alloy_op_evm::post_exec::PostExecTxContext) { + self.inner.begin_post_exec_tx(ctx); + } + + /// Take the extracted post-exec result for the most recently executed transaction. + pub fn take_last_post_exec_tx_result(&mut self) -> alloy_op_evm::post_exec::PostExecExecutedTx { + self.inner.take_last_post_exec_tx_result() + } } impl Evm for CustomEvm diff --git a/rust/op-reth/examples/custom-node/src/evm/config.rs b/rust/op-reth/examples/custom-node/src/evm/config.rs index e505845f621..b6f164bf6ab 100644 --- a/rust/op-reth/examples/custom-node/src/evm/config.rs +++ b/rust/op-reth/examples/custom-node/src/evm/config.rs @@ -1,17 +1,22 @@ use crate::{ chainspec::CustomChainSpec, engine::{CustomExecutionData, CustomPayloadBuilderAttributes}, - evm::{CustomBlockAssembler, alloy::CustomEvmFactory, executor::CustomBlockExecutionCtx}, + evm::{ + CustomBlockAssembler, CustomBlockExecutor, + alloy::{CustomEvm, CustomEvmFactory}, + executor::CustomBlockExecutionCtx, + }, primitives::{Block, CustomHeader, CustomNodePrimitives, CustomTransaction}, }; use alloy_consensus::BlockHeader; use alloy_eips::{Decodable2718, eip2718::WithEncoded}; -use alloy_evm::EvmEnv; -use alloy_op_evm::OpBlockExecutionCtx; +use alloy_evm::{Database, EvmEnv, block::BlockExecutorFor}; +use alloy_op_evm::{OpBlockExecutionCtx, OpBlockExecutor, post_exec::PostExecExecutorExt}; use alloy_rpc_types_engine::PayloadError; use op_alloy_rpc_types_engine::flashblock::OpFlashblockPayloadBase; use op_revm::OpSpecId; use reth_engine_primitives::ExecutableTxIterator; +use reth_evm::execute::{BasicBlockBuilder, BlockBuilder}; use reth_node_api::{BuildNextEnv, ConfigureEvm, PayloadBuilderError}; use reth_node_builder::{ConfigureEngineEvm, NewPayloadError}; use reth_op::{ @@ -19,8 +24,10 @@ use reth_op::{ evm::primitives::{EvmEnvFor, ExecutionCtxFor}, node::{OpEvmConfig, OpNextBlockEnvAttributes, OpRethReceiptBuilder}, }; +use reth_optimism_evm::{ConfigurePostExecEvm, PostExecMode}; use reth_primitives_traits::{SealedBlock, SealedHeader, SignedTransaction}; use reth_rpc_api::eth::helpers::pending_block::BuildPendingEnv; +use revm::database::State; use revm_primitives::Bytes; use std::sync::Arc; @@ -80,6 +87,7 @@ impl ConfigureEvm for CustomEvmConfig { parent_hash: block.header().parent_hash(), parent_beacon_block_root: block.header().parent_beacon_block_root(), extra_data: block.header().extra_data().clone(), + post_exec_mode: Default::default(), }, extension: block.extension, }) @@ -95,6 +103,7 @@ impl ConfigureEvm for CustomEvmConfig { parent_hash: parent.hash(), parent_beacon_block_root: attributes.inner.parent_beacon_block_root, extra_data: attributes.inner.extra_data, + post_exec_mode: Default::default(), }, extension: attributes.extension, }) @@ -192,3 +201,77 @@ where Ok(CustomNextBlockEnvAttributes { inner, extension: 0 }) } } + +impl ConfigurePostExecEvm for CustomEvmConfig { + fn post_exec_executor_for_block<'a, DB: Database>( + &'a self, + db: &'a mut State, + block: &'a SealedBlock, + post_exec_mode: PostExecMode, + ) -> Result> + PostExecExecutorExt, Self::Error> + { + let evm = self.evm_for_block(db, block.header())?; + let ctx = OpBlockExecutionCtx { + parent_hash: block.header().parent_hash(), + parent_beacon_block_root: block.header().parent_beacon_block_root(), + extra_data: block.header().extra_data().clone(), + post_exec_mode, + }; + + let inner = OpBlockExecutor::new( + evm, + ctx, + self.inner.chain_spec().clone(), + *self.inner.executor_factory.receipt_builder(), + ) + .with_post_exec_begin(CustomEvm::begin_post_exec_tx) + .with_post_exec_result(CustomEvm::take_last_post_exec_tx_result); + + Ok(CustomBlockExecutor::new(inner)) + } + + fn post_exec_builder_for_next_block<'a, DB: Database + 'a>( + &'a self, + db: &'a mut State, + parent: &'a SealedHeader, + attributes: Self::NextBlockEnvCtx, + post_exec_mode: PostExecMode, + ) -> Result< + impl BlockBuilder< + Primitives = CustomNodePrimitives, + Executor: BlockExecutorFor<'a, Self, &'a mut State> + PostExecExecutorExt, + > + 'a, + Self::Error, + > { + let evm_env = self.next_evm_env(parent, &attributes)?; + let evm = self.evm_with_env(db, evm_env); + let ctx = CustomBlockExecutionCtx { + inner: OpBlockExecutionCtx { + parent_hash: parent.hash(), + parent_beacon_block_root: attributes.inner.parent_beacon_block_root, + extra_data: attributes.inner.extra_data.clone(), + post_exec_mode, + }, + extension: attributes.extension, + }; + + let inner = OpBlockExecutor::new( + evm, + ctx.inner.clone(), + self.inner.chain_spec().clone(), + *self.inner.executor_factory.receipt_builder(), + ) + .with_post_exec_begin(CustomEvm::begin_post_exec_tx) + .with_post_exec_result(CustomEvm::take_last_post_exec_tx_result); + + let executor = CustomBlockExecutor::new(inner); + + Ok(BasicBlockBuilder::<'a, Self, _, _, CustomNodePrimitives> { + executor, + transactions: Vec::new(), + ctx, + parent, + assembler: self.block_assembler(), + }) + } +} diff --git a/rust/op-reth/examples/custom-node/src/evm/executor.rs b/rust/op-reth/examples/custom-node/src/evm/executor.rs index 040040114f9..944fe065b06 100644 --- a/rust/op-reth/examples/custom-node/src/evm/executor.rs +++ b/rust/op-reth/examples/custom-node/src/evm/executor.rs @@ -14,7 +14,11 @@ use alloy_evm::{ }, precompiles::PrecompilesMap, }; -use alloy_op_evm::{OpBlockExecutionCtx, OpBlockExecutor, OpEvmContext, block::OpTxResult}; +use alloy_op_evm::{ + OpBlockExecutionCtx, OpBlockExecutor, OpEvmContext, block::OpTxResult, + post_exec::PostExecExecutorExt, +}; +use op_alloy_consensus::SDMGasEntry; use reth_op::{OpReceipt, OpTxType, chainspec::OpChainSpec, node::OpRethReceiptBuilder}; use revm::Inspector; use std::sync::Arc; @@ -23,6 +27,12 @@ pub struct CustomBlockExecutor { inner: OpBlockExecutor>, } +impl CustomBlockExecutor { + pub const fn new(inner: OpBlockExecutor>) -> Self { + Self { inner } + } +} + impl BlockExecutor for CustomBlockExecutor where DB: StateDB, @@ -75,6 +85,15 @@ where } } +impl PostExecExecutorExt for CustomBlockExecutor +where + E: alloy_evm::Evm, +{ + fn take_post_exec_entries(&mut self) -> Vec { + self.inner.take_post_exec_entries() + } +} + impl BlockExecutorFactory for CustomEvmConfig { type EvmFactory = CustomEvmFactory; type ExecutionCtx<'a> = CustomBlockExecutionCtx; diff --git a/rust/op-reth/examples/custom-node/src/lib.rs b/rust/op-reth/examples/custom-node/src/lib.rs index 6023ee67515..d74009829d3 100644 --- a/rust/op-reth/examples/custom-node/src/lib.rs +++ b/rust/op-reth/examples/custom-node/src/lib.rs @@ -24,11 +24,12 @@ use primitives::CustomNodePrimitives; use reth_node_api::FullNodeTypes; use reth_node_builder::{ Node, NodeAdapter, NodeTypes, - components::{BasicPayloadServiceBuilder, ComponentsBuilder}, + components::{BasicPayloadServiceBuilder, ComponentsBuilder, NodeComponentsBuilder}, + rpc::{BasicEngineValidatorBuilder, RpcAddOns}, }; use reth_op::{ node::{ - OpAddOns, OpNode, + OpNode, node::{OpConsensusBuilder, OpNetworkBuilder, OpPayloadBuilder, OpPoolBuilder}, txpool, }, @@ -43,10 +44,8 @@ pub mod pool; pub mod primitives; pub mod rpc; -#[derive(Debug, Clone)] -pub struct CustomNode { - inner: OpNode, -} +#[derive(Debug, Clone, Default)] +pub struct CustomNode; impl NodeTypes for CustomNode { type Primitives = CustomNodePrimitives; @@ -68,11 +67,12 @@ where OpConsensusBuilder, >; - type AddOns = OpAddOns< - NodeAdapter, + type AddOns = RpcAddOns< + NodeAdapter>::Components>, OpEthApiBuilder, CustomEngineValidatorBuilder, CustomEngineApiBuilder, + BasicEngineValidatorBuilder, >; fn components_builder(&self) -> Self::ComponentsBuilder { @@ -86,6 +86,12 @@ where } fn add_ons(&self) -> Self::AddOns { - self.inner.add_ons_builder().build() + RpcAddOns::new( + OpEthApiBuilder::default(), + CustomEngineValidatorBuilder, + CustomEngineApiBuilder::default(), + BasicEngineValidatorBuilder::::default(), + Default::default(), + ) } } diff --git a/rust/op-reth/examples/custom-node/src/primitives/tx.rs b/rust/op-reth/examples/custom-node/src/primitives/tx.rs index 8429c0f1283..4c4a2159126 100644 --- a/rust/op-reth/examples/custom-node/src/primitives/tx.rs +++ b/rust/op-reth/examples/custom-node/src/primitives/tx.rs @@ -1,18 +1,18 @@ use super::TxPayment; use alloy_consensus::{ - Signed, TransactionEnvelope, + Sealable, Signed, TransactionEnvelope, crypto::RecoveryError, transaction::{SignerRecoverable, TxHashRef}, }; use alloy_eips::Encodable2718; use alloy_primitives::{B256, Sealed, Signature}; use alloy_rlp::BufMut; -use op_alloy_consensus::{OpTxEnvelope, TxDeposit, TxPostExec}; +use op_alloy_consensus::{OpTxEnvelope, SDMGasEntry, TxDeposit, TxPostExec, build_post_exec_tx}; use reth_codecs::{ Compact, alloy::transaction::{CompactEnvelope, FromTxCompact, ToTxCompact}, }; -use reth_op::OpTransaction; +use reth_op::{BuildPostExecTransaction, OpTransaction}; use reth_primitives_traits::InMemorySize; use revm_primitives::Address; @@ -112,6 +112,14 @@ impl OpTransaction for CustomTransaction { } } +impl BuildPostExecTransaction for CustomTransaction { + fn build_post_exec(block_number: u64, gas_refund_entries: Vec) -> Self { + Self::Op(OpTxEnvelope::PostExec( + build_post_exec_tx(block_number, gas_refund_entries).seal_slow(), + )) + } +} + impl SignerRecoverable for CustomTransaction { fn recover_signer(&self) -> Result { match self {