From d88b6a5ba13e35ed6950fd8e5d3dc4ccb8462e03 Mon Sep 17 00:00:00 2001 From: Anton Evangelatov Date: Mon, 20 Apr 2026 16:57:50 +0300 Subject: [PATCH 1/7] feat(alloy-op-evm): post-exec block executor and SDM warming inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the canonical post-exec block executor, along with the first feature riding on it — SDM (Sequencer-Defined Metering) block-level warming refunds, delivered by the SDMWarmingInspector. - OpBlockExecutor gains three PostExecModes (Disabled / Produce / Verify(payload) / Invalid). Produce accumulates per-tx refund entries for the payload builder to append as a synthetic 0x7D tx; Verify validates an embedded payload against local replay; Disabled is the legacy path (byte-identical to pre-SDM). - SDMWarmingInspector tracks first-warmer provenance for accounts and storage slots, emits exact refund attribution events for every re-touch past the EIP-2929 warm threshold, and suppresses claims from Deposit and synthetic PostExec tx kinds. - Verify-mode validations reject duplicate payload indexes, payload entries targeting deposits or the 0x7D tx itself, and refunds that exceed the tx's raw gas. `apply_pre_execution_changes` debug_asserts the Produce hooks are wired so a downstream fork can't silently drop refunds. - Canonical gas settlement credits the sender, debits the beneficiary and base-fee recipient by the refunded-gas component of their share, and commits the deltas when canonical gas falls below raw gas. - beneficiary_gas_price can legitimately saturate at zero when a legacy tx's gas price equals basefee; inline comment documents the consensus-valid zero case. --- rust/alloy-op-evm/src/block/mod.rs | 983 +++++++++++++++++- rust/alloy-op-evm/src/lib.rs | 153 ++- rust/alloy-op-evm/src/post_exec/inspector.rs | 698 +++++++++++++ rust/alloy-op-evm/src/post_exec/mod.rs | 60 ++ .../crates/proof/executor/src/builder/core.rs | 1 + .../examples/custom-node/src/evm/config.rs | 2 + 6 files changed, 1848 insertions(+), 49 deletions(-) create mode 100644 rust/alloy-op-evm/src/post_exec/inspector.rs create mode 100644 rust/alloy-op-evm/src/post_exec/mod.rs diff --git a/rust/alloy-op-evm/src/block/mod.rs b/rust/alloy-op-evm/src/block/mod.rs index c064617cc38..7949d5d6cbe 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, WarmingRefundEvent}; + 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, refund_events: Vec::new() } +} + /// Trait for OP transaction environments. Allows to recover the transaction encoded bytes if /// they're available. pub trait OpTxEnv { @@ -46,6 +71,20 @@ 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), + /// An post-exec tx was present but invalid, so block execution must fail. + Invalid, +} + /// Context for OP block execution. #[derive(Debug, Default, Clone)] pub struct OpBlockExecutionCtx { @@ -55,6 +94,27 @@ 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, + /// Exact warming refund attribution events that produced `refund` (populated in Produce + /// mode; empty in Verify mode where the refund comes from the embedded payload). + pub warming_events: Vec, } /// The result of executing an OP transaction. @@ -64,8 +124,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 +170,24 @@ 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 Block-Level Warming fields -- + /// Accumulated per-tx warming refunds for post-exec tx assembly (sequencer mode). + pub post_exec_entries: Vec, + /// Active post-exec execution mode. + pub post_exec_mode: PostExecMode, + /// Verifier payload indexed by original tx index. + pub post_exec_verify_entries: BTreeMap, + /// Invalid verifier payload reason, if any. + pub post_exec_invalid_reason: Option, + /// Begin post-exec tracking for the next transaction. + pub begin_post_exec_tx: fn(&mut Evm, PostExecTxContext), + /// Extractor for the most recent transaction's exact warming result. + pub take_last_post_exec_tx_result: fn(&mut Evm) -> PostExecExecutedTx, + /// Per-transaction exact warming refund attribution events aligned with receipts. + pub warming_events_by_tx: Vec>, } impl OpBlockExecutor @@ -114,6 +198,8 @@ where { /// Creates a new [`OpBlockExecutor`]. pub fn new(evm: E, ctx: OpBlockExecutionCtx, spec: Spec, receipt_builder: R) -> Self { + let (post_exec_mode, post_exec_verify_entries, post_exec_invalid_reason) = + Self::init_post_exec_state(ctx.post_exec_mode.clone()); Self { is_regolith: spec .is_regolith_active_at_timestamp(evm.block().timestamp().saturating_to()), @@ -125,8 +211,84 @@ where gas_used: 0, da_footprint_used: 0, ctx, + l1_block_info: None, + post_exec_entries: Vec::new(), + post_exec_mode, + post_exec_verify_entries, + post_exec_invalid_reason, + begin_post_exec_tx: default_begin_post_exec_tx::, + take_last_post_exec_tx_result: default_take_last_post_exec_tx_result::, + warming_events_by_tx: Vec::new(), } } + + /// Configure how the executor should begin inspector-backed post-exec tracking. + pub fn with_post_exec_begin( + mut self, + begin_post_exec_tx: fn(&mut E, PostExecTxContext), + ) -> Self { + self.begin_post_exec_tx = begin_post_exec_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_post_exec_tx_result: fn(&mut E) -> PostExecExecutedTx, + ) -> Self { + self.take_last_post_exec_tx_result = take_last_post_exec_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 + } + + fn init_post_exec_state( + post_exec_mode: PostExecMode, + ) -> (PostExecMode, BTreeMap, Option) { + let mut post_exec_verify_entries = BTreeMap::new(); + let mut post_exec_invalid_reason = None; + + if let PostExecMode::Verify(payload) = &post_exec_mode { + for entry in &payload.gas_refund_entries { + if post_exec_verify_entries.insert(entry.index, entry.gas_refund).is_some() { + post_exec_invalid_reason = Some(format!( + "duplicate post-exec payload entry for tx index {}", + entry.index + )); + break; + } + } + } + + (post_exec_mode, post_exec_verify_entries, post_exec_invalid_reason) + } + + /// 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 (post_exec_mode, post_exec_verify_entries, post_exec_invalid_reason) = + Self::init_post_exec_state(post_exec_mode); + self.post_exec_mode = post_exec_mode; + self.post_exec_verify_entries = post_exec_verify_entries; + self.post_exec_invalid_reason = post_exec_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) + } + + /// Take the exact per-transaction warming refund attribution events aligned with receipts. + pub fn take_warming_events_by_tx(&mut self) -> Vec> { + core::mem::take(&mut self.warming_events_by_tx) + } } /// Custom errors that can occur during OP block execution. @@ -150,6 +312,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 +360,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 +588,47 @@ where type Result = OpTxResult::TxType>; fn apply_pre_execution_changes(&mut self) -> Result<(), BlockExecutionError> { + if matches!(self.post_exec_mode, PostExecMode::Invalid) { + return Err(self.invalid_post_exec_payload("post-exec tx payload could not be decoded")); + } + 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.begin_post_exec_tx, + default_begin_post_exec_tx:: as fn(&mut E, PostExecTxContext), + ), + "PostExecMode::Produce requires begin_post_exec_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.take_last_post_exec_tx_result, + default_take_last_post_exec_tx_result:: as fn(&mut E) -> PostExecExecutedTx, + ), + "PostExecMode::Produce requires take_last_post_exec_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 +653,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 +690,80 @@ where 0 }; + if is_post_exec { + // 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.begin_post_exec_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, warming_events) = match &self.post_exec_mode { + PostExecMode::Produce => { + let PostExecExecutedTx { refund_total, refund_events } = + (self.take_last_post_exec_tx_result)(&mut self.evm); + (refund_total, refund_events) + } + PostExecMode::Verify(_) => ( + self.verifier_post_exec_refund_for_tx(tx_index, is_deposit, false, raw_gas_used)?, + Vec::new(), + ), + PostExecMode::Disabled | PostExecMode::Invalid => (0, Vec::new()), + }; + 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 || !warming_events.is_empty()).then_some(PostExecAdjustment { + refund: post_exec_refund, + sender_refund, + beneficiary_delta, + base_fee_delta, + operator_fee_delta, + warming_events, + }); + Ok(OpTxResult { inner: EthTxResult { result, @@ -274,17 +771,54 @@ 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, + warming_events, + } = 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); + } + // Skip push for the synthetic 0x7D tx: its execute path returns early with an empty + // `warming_events`, and the replay consumer (`post-exec-replay::replay_block`) runs + // against the stripped block so this index is never addressed. Deposit pushes stay + // because replay relies on positional alignment between the stripped block's + // transactions and `warming_events_by_tx`. + if !is_post_exec { + self.warming_events_by_tx.push(warming_events); + } + // 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 +828,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 +890,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 +922,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 +1074,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); @@ -760,4 +1355,358 @@ mod tests { assert_eq!(result.gas_used, gas_used_tx); assert!(result.blob_gas_used > result.gas_used); } + + #[test] + fn test_invalid_post_exec_mode_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, + ); + executor.set_post_exec_mode(PostExecMode::Invalid); + + let err = + executor.apply_pre_execution_changes().expect_err("invalid post-exec mode must fail"); + match err { + BlockExecutionError::Validation(BlockValidationError::Other(err)) => { + assert_eq!( + err.to_string(), + OpBlockExecutionError::InvalidPostExecPayload( + "post-exec tx payload could not be decoded".to_string(), + ) + .to_string(), + ); + } + _ => panic!("expected invalid post-exec payload error"), + } + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "PostExecMode::Produce requires begin_post_exec_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"), + } + } } diff --git a/rust/alloy-op-evm/src/lib.rs b/rust/alloy-op-evm/src/lib.rs index 5c7b4a8d71e..cb2ff4f50ba 100644 --- a/rust/alloy-op-evm/src/lib.rs +++ b/rust/alloy-op-evm/src/lib.rs @@ -19,6 +19,7 @@ pub use env::{ pub mod error; pub use error::{OpTxError, map_op_err}; +use alloc::vec::Vec; use alloy_evm::{Database, Evm, EvmEnv, EvmFactory, IntoTxEnv, precompiles::PrecompilesMap}; use alloy_primitives::{Address, Bytes}; use core::{ @@ -28,12 +29,16 @@ use core::{ }; use op_revm::{ DefaultOp, L1BlockInfo, OpBuilder, OpContext, OpHaltReason, OpSpecId, OpTransaction, + constants::{BASE_FEE_RECIPIENT, L1_FEE_RECIPIENT, OPERATOR_FEE_RECIPIENT}, precompiles::OpPrecompiles, }; use revm::{ Context, ExecuteEvm, InspectEvm, Inspector, Journal, 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}, @@ -43,7 +48,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>; @@ -58,8 +65,15 @@ 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< + OpContext, + post_exec::PostExecCompositeInspector, + EthInstructions>, + P, + >, inspect: bool, + last_tx_warming_savings: u64, + last_tx_warming_events: Vec, _tx: PhantomData, } @@ -68,7 +82,21 @@ impl OpEvm { pub fn into_inner( 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. @@ -87,11 +115,59 @@ 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, I, EthInstructions>, P>, 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, + last_tx_warming_events: Vec::new(), + _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); + } + + /// Notes an account touch that happened outside opcode stepping. + pub fn note_post_exec_account_touch(&mut self, address: Address) { + self.inner.0.inspector.note_account_touch(address); + } + + /// Take the warming savings recorded for the most recently executed transaction. + pub fn take_last_tx_warming_savings(&mut self) -> u64 { + core::mem::take(&mut self.last_tx_warming_savings) + } + + /// Take the exact warming refund attribution events recorded for the most recently executed + /// transaction. + pub fn take_last_tx_warming_events(&mut self) -> Vec { + core::mem::take(&mut self.last_tx_warming_events) + } + + /// 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 { + let refund_total = self.take_last_tx_warming_savings(); + let refund_events = self.take_last_tx_warming_events(); + post_exec::PostExecExecutedTx { refund_total, refund_events } } } @@ -139,12 +215,29 @@ where &mut self, tx: Self::Tx, ) -> Result, Self::Error> { + self.last_tx_warming_savings = 0; + self.last_tx_warming_events.clear(); + let inner_tx: OpTransaction = tx.into(); let result = if self.inspect { self.inner.inspect_tx(inner_tx) } else { self.inner.transact(inner_tx) }; + + 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; + self.last_tx_warming_events = post_exec_result.refund_events; + result.map_err(map_op_err) } @@ -170,7 +263,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, ) } @@ -178,7 +271,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, ) } @@ -225,18 +318,16 @@ where input: EvmEnv, ) -> Self::Evm { let spec_id = input.cfg_env.spec; - OpEvm { - inner: Context::op() - .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::op() + .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>>( @@ -246,18 +337,16 @@ where inspector: I, ) -> Self::Evm { let spec_id = input.cfg_env.spec; - OpEvm { - inner: Context::op() - .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::op() + .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..39a33a5f058 --- /dev/null +++ b/rust/alloy-op-evm/src/post_exec/inspector.rs @@ -0,0 +1,698 @@ +use alloc::vec::Vec; +use alloy_primitives::{ + Address, B256, + map::{HashMap, 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, +}; + +/// Exact refund categories for post-exec block-level warming. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WarmingRefundKind { + /// Warm account rebate (+2500). + WarmAccount, + /// Warm storage read rebate (+2000). + WarmSload, + /// Warm storage write rebate (+2100). + WarmSstore, +} + +/// Exact refund attribution event emitted when a warming rebate is granted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WarmingRefundEvent { + /// Replay-local transaction index that claimed the rebate. + pub claiming_tx_index: u64, + /// Refund kind. + pub kind: WarmingRefundKind, + /// Rebate amount in gas. + pub amount: u64, + /// Account touched by the rebate. + pub address: Address, + /// Storage slot touched by the rebate, when applicable. + pub slot: Option, + /// Replay-local transaction index that first warmed this account or slot. + pub first_warmed_by_tx_index: u64, +} + +/// 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, Default, PartialEq, Eq)] +pub struct PostExecExecutedTx { + /// Total refund for the tx. + pub refund_total: u64, + /// Exact attribution events for the tx. + pub refund_events: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct WarmProvenance { + first_warmed_by_tx_index: u64, +} + +#[derive(Debug, Clone, Default)] +struct CurrentTxState { + tx_index: u64, + kind: Option, + initialized_top_level: bool, + refund_total: u64, + refund_events: Vec, + 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.tx_index = ctx.tx_index; + self.kind = Some(ctx.kind); + self.initialized_top_level = false; + self.refund_total = 0; + self.refund_events.clear(); + 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), + refund_events: core::mem::take(&mut self.refund_events), + } + } + + fn emit_refund( + &mut self, + provenance: WarmProvenance, + kind: WarmingRefundKind, + amount: u64, + address: Address, + slot: Option, + ) { + if self.kind.is_some_and(PostExecTxKind::claims_refunds) { + self.refund_total = self.refund_total.saturating_add(amount); + self.refund_events.push(WarmingRefundEvent { + claiming_tx_index: self.tx_index, + kind, + amount, + address, + slot, + first_warmed_by_tx_index: provenance.first_warmed_by_tx_index, + }); + } + } +} + +/// Lightweight inspector that computes post-exec block-warming refunds and provenance. +#[derive(Debug, Clone, Default)] +pub struct SDMWarmingInspector { + warmed_accounts: HashMap, + warmed_slots: HashMap<(Address, B256), WarmProvenance>, + 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.clone(); + 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) + { + if let Some(provenance) = self.warmed_accounts.get(&address).copied() { + self.current_tx.emit_refund( + provenance, + WarmingRefundKind::WarmAccount, + 2500, + address, + None, + ); + } + } + + self.warmed_accounts + .entry(address) + .or_insert(WarmProvenance { first_warmed_by_tx_index: self.current_tx.tx_index }); + + // Non-claiming tx kinds (Deposit, PostExec) never populate these fields because + // `emit_refund` above gates on `claims_refunds()`. Previously this function also + // cleared them on every call for those kinds; that was defense-in-depth that + // obscured the invariant — assert it instead so regressions surface in debug builds. + debug_assert!( + self.current_tx.kind().is_some_and(PostExecTxKind::claims_refunds) || + (self.current_tx.refund_total == 0 && self.current_tx.refund_events.is_empty()), + "non-claiming tx kinds must not have emitted refunds — check emit_refund gating", + ); + } + + 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)) + { + if let Some(provenance) = self.warmed_slots.get(&(address, slot)).copied() { + let (kind, amount) = if is_sstore { + (WarmingRefundKind::WarmSstore, 2100) + } else { + (WarmingRefundKind::WarmSload, 2000) + }; + self.current_tx.emit_refund(provenance, kind, amount, address, Some(slot)); + } + } + + self.warmed_slots + .entry((address, slot)) + .or_insert(WarmProvenance { first_warmed_by_tx_index: self.current_tx.tx_index }); + } + + #[cfg(test)] + fn note_intrinsic_account(&mut self, address: Address) { + self.current_tx.intrinsic_warm_accounts.insert(address); + } + + #[cfg(test)] + fn note_intrinsic_slot(&mut self, address: Address, slot: B256) { + self.current_tx.intrinsic_warm_slots.insert((address, slot)); + } + + #[cfg(test)] + fn test_observe_account_touch(&mut self, address: Address) { + self.observe_account_touch(address, true); + } + + #[cfg(test)] + fn test_observe_slot_touch(&mut self, address: Address, slot: B256, is_sstore: bool) { + self.observe_slot_touch(address, slot, is_sstore); + } +} + +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.test_observe_account_touch(account); + let first = inspector.finish_tx(); + assert_eq!(first.refund_total, 0); + + inspector.begin_tx(PostExecTxContext { tx_index: 1, kind: PostExecTxKind::Normal }); + inspector.test_observe_account_touch(account); + let second = inspector.finish_tx(); + assert_eq!(second.refund_total, 2500); + assert_eq!(second.refund_events.len(), 1); + assert_eq!(second.refund_events[0].kind, WarmingRefundKind::WarmAccount); + assert_eq!(second.refund_events[0].first_warmed_by_tx_index, 0); + } + + #[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.test_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.test_observe_slot_touch(account, slot, false); + let second = inspector.finish_tx(); + assert_eq!(second.refund_total, 2000); + assert_eq!(second.refund_events.len(), 1); + assert_eq!(second.refund_events[0].kind, WarmingRefundKind::WarmSload); + } + + #[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.test_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.test_observe_slot_touch(account, slot, true); + let second = inspector.finish_tx(); + assert_eq!(second.refund_total, 2100); + assert_eq!(second.refund_events.len(), 1); + assert_eq!(second.refund_events[0].kind, WarmingRefundKind::WarmSstore); + } + + #[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.test_observe_account_touch(account); + let deposit = inspector.finish_tx(); + assert_eq!(deposit.refund_total, 0); + assert!(deposit.refund_events.is_empty()); + + inspector.begin_tx(PostExecTxContext { tx_index: 1, kind: PostExecTxKind::Normal }); + inspector.test_observe_account_touch(account); + let later = inspector.finish_tx(); + assert_eq!(later.refund_total, 2500); + assert_eq!(later.refund_events[0].first_warmed_by_tx_index, 0); + } + + #[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.test_observe_account_touch(account); + let _ = inspector.finish_tx(); + + inspector.begin_tx(PostExecTxContext { tx_index: 1, kind: PostExecTxKind::PostExec }); + inspector.test_observe_account_touch(account); + let post_exec = inspector.finish_tx(); + assert_eq!(post_exec.refund_total, 0); + assert!(post_exec.refund_events.is_empty()); + } + + #[test] + fn intrinsic_access_list_warmth_does_not_claim_or_steal_provenance() { + let account = address!("00000000000000000000000000000000000000dd"); + let slot = b256!("0000000000000000000000000000000000000000000000000000000000000003"); + let mut inspector = SDMWarmingInspector::default(); + + inspector.begin_tx(PostExecTxContext { tx_index: 0, kind: PostExecTxKind::Normal }); + inspector.note_intrinsic_account(account); + inspector.note_intrinsic_slot(account, slot); + inspector.test_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.test_observe_slot_touch(account, slot, false); + let second = inspector.finish_tx(); + assert_eq!(second.refund_total, 2000); + assert_eq!(second.refund_events[0].first_warmed_by_tx_index, 0); + } + + #[test] + fn warming_provenance_chains_across_three_txs() { + let account_a = address!("00000000000000000000000000000000000000aa"); + let account_b = address!("00000000000000000000000000000000000000bb"); + let slot = b256!("0000000000000000000000000000000000000000000000000000000000000042"); + let mut inspector = SDMWarmingInspector::default(); + + // Tx 0 warms account A (no refund — it's the first toucher). + inspector.begin_tx(PostExecTxContext { tx_index: 0, kind: PostExecTxKind::Normal }); + inspector.test_observe_account_touch(account_a); + let tx0 = inspector.finish_tx(); + assert_eq!(tx0.refund_total, 0); + assert!(tx0.refund_events.is_empty()); + + // Tx 1 re-warms A (refund, provenance = 0) AND is the first toucher of slot (B, slot). + inspector.begin_tx(PostExecTxContext { tx_index: 1, kind: PostExecTxKind::Normal }); + inspector.test_observe_account_touch(account_a); + inspector.test_observe_slot_touch(account_b, slot, true); + let tx1 = inspector.finish_tx(); + assert_eq!(tx1.refund_total, 2500); + assert_eq!(tx1.refund_events.len(), 1); + assert_eq!(tx1.refund_events[0].kind, WarmingRefundKind::WarmAccount); + assert_eq!(tx1.refund_events[0].address, account_a); + assert_eq!(tx1.refund_events[0].first_warmed_by_tx_index, 0); + + // Tx 2 re-hits (B, slot) via SSTORE — should refund 2100, attributed to tx 1. + inspector.begin_tx(PostExecTxContext { tx_index: 2, kind: PostExecTxKind::Normal }); + inspector.test_observe_slot_touch(account_b, slot, true); + let tx2 = inspector.finish_tx(); + assert_eq!(tx2.refund_total, 2100); + assert_eq!(tx2.refund_events.len(), 1); + assert_eq!(tx2.refund_events[0].kind, WarmingRefundKind::WarmSstore); + assert_eq!(tx2.refund_events[0].address, account_b); + assert_eq!(tx2.refund_events[0].slot, Some(slot)); + assert_eq!(tx2.refund_events[0].first_warmed_by_tx_index, 1); + } + + #[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.test_observe_account_touch(account); + let _ = inspector.finish_tx(); + let last = inspector.take_last_tx_result(); + assert_eq!(last.refund_total, 0); + assert!(inspector.take_last_tx_result().refund_events.is_empty()); + } +} 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..bdb92fddeef --- /dev/null +++ b/rust/alloy-op-evm/src/post_exec/mod.rs @@ -0,0 +1,60 @@ +//! 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, WarmingRefundEvent, WarmingRefundKind, +}; + +use crate::{ + OpEvm, + block::{OpBlockExecutor, receipt_builder::OpReceiptBuilder}, +}; + +/// Extension trait for EVMs that expose post-exec warming results for the last executed +/// transaction. +pub trait PostExecEvmExt { + /// Begin post-exec tracking for the next transaction. + fn begin_post_exec_tx(&mut self, ctx: PostExecTxContext); + + /// Take the exact warming result for the most recently executed transaction. + fn take_last_post_exec_tx_result(&mut self) -> PostExecExecutedTx; +} + +impl PostExecEvmExt for OpEvm { + fn begin_post_exec_tx(&mut self, ctx: PostExecTxContext) { + Self::begin_post_exec_tx(self, ctx) + } + + fn take_last_post_exec_tx_result(&mut self) -> PostExecExecutedTx { + Self::take_last_post_exec_tx_result(self) + } +} + +/// 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; + + /// Take the exact per-transaction warming refund attribution events aligned with receipts. + fn take_warming_events_by_tx(&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) + } + + fn take_warming_events_by_tx(&mut self) -> Vec> { + Self::take_warming_events_by_tx(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-reth/examples/custom-node/src/evm/config.rs b/rust/op-reth/examples/custom-node/src/evm/config.rs index e505845f621..4270d6c4445 100644 --- a/rust/op-reth/examples/custom-node/src/evm/config.rs +++ b/rust/op-reth/examples/custom-node/src/evm/config.rs @@ -80,6 +80,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 +96,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, }) From 76e939f77f79fcbb9a624c00212aea08de70de91 Mon Sep 17 00:00:00 2001 From: Anton Evangelatov Date: Mon, 20 Apr 2026 16:58:01 +0300 Subject: [PATCH 2/7] feat(op-reth): add ConfigurePostExecEvm and post-exec-aware evm config Extend op-reth's EvmConfig with the post-exec hooks the new OpBlockExecutor expects. Downstream uses: - The payload builder asks the executor to Produce + drains refund entries via post_exec_executor_for_block + take_post_exec_entries. - The replay RPC asks for the same Produce mode but on a stripped block (0x7D removed) to compare synthesized refunds against the embedded payload. OpEvm auto-wires the SDMWarmingInspector begin/take hooks so callers don't have to plumb them manually; the alloy-op-evm debug_assert guards the failure mode if a downstream fork bypasses OpEvm. --- rust/op-reth/crates/evm/Cargo.toml | 2 +- rust/op-reth/crates/evm/src/lib.rs | 40 +++++- rust/op-reth/crates/evm/src/post_exec_ext.rs | 135 +++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 rust/op-reth/crates/evm/src/post_exec_ext.rs 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..1700fbfa470 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, WarmingRefundEvent, WarmingRefundKind}, +}; + +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..ce1ac7b4c77 --- /dev/null +++ b/rust/op-reth/crates/evm/src/post_exec_ext.rs @@ -0,0 +1,135 @@ +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::post_exec::PostExecEvmExt::begin_post_exec_tx) + .with_post_exec_result( + alloy_op_evm::post_exec::PostExecEvmExt::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::post_exec::PostExecEvmExt::begin_post_exec_tx) + .with_post_exec_result( + alloy_op_evm::post_exec::PostExecEvmExt::take_last_post_exec_tx_result, + ); + + Ok(BasicBlockBuilder::< + 'a, + OpBlockExecutorFactory, OpEvmFactory>, + _, + _, + N, + > { + executor, + transactions: Vec::new(), + ctx, + parent, + assembler: self.block_assembler(), + }) + } +} From 37e3d8cbdd64d632de0df899aa1e63fa9e458d9d Mon Sep 17 00:00:00 2001 From: Anton Evangelatov Date: Mon, 20 Apr 2026 16:58:36 +0300 Subject: [PATCH 3/7] feat(op-reth): inject synthetic post-exec tx in payload builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Append a type-0x7D post-exec transaction at the tail of the block when the sequencer builds under --rollup.sdm-enabled. The tx carries the executor's accumulated refund entries as its RLP payload and canonicalizes this node's gas accounting with what a verifier will later independently replay. - OpBuilderConfig/CLI flag (`--rollup.sdm-enabled`) — off by default. When off, the payload path is byte-identical to the pre-feature code. - try_include_post_exec_tx wraps the executor's refund entries in a TxPostExec, executes it, and aborts the payload build with PayloadBuilderError::EvmExecutionError on any synthetic-tx execution failure. Silently dropping it would yield a payload that no honest verifier can reproduce. - Unit tests pin the abort path (should-not-be-Ok-on-failure), the no-entries skip, and the happy-path wrapping of entries. - custom-node example switches to NoopPayloadServiceBuilder; the upstream OpPayloadBuilder is now specialized for OpTransactionSigned to carry the post-exec tx and no longer composes with the example's custom tx type. Doc comment explains what downstream forks need. --- rust/op-reth/crates/node/src/args.rs | 5 + rust/op-reth/crates/node/src/node.rs | 58 ++++-- rust/op-reth/crates/node/tests/it/builder.rs | 8 +- rust/op-reth/crates/payload/src/builder.rs | 178 +++++++++++++++++-- rust/op-reth/crates/payload/src/config.rs | 10 +- rust/op-reth/examples/custom-node/src/lib.rs | 55 ++++-- 6 files changed, 265 insertions(+), 49 deletions(-) diff --git a/rust/op-reth/crates/node/src/args.rs b/rust/op-reth/crates/node/src/args.rs index d4d8748fa82..ee6b02620c4 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 synthetic 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 669b54422b7..19baa66eb7a 100644 --- a/rust/op-reth/crates/node/src/node.rs +++ b/rust/op-reth/crates/node/src/node.rs @@ -35,20 +35,20 @@ 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::{DepositReceipt, OpPrimitives, OpTransactionSigned}; use reth_optimism_rpc::{ SequencerClient, eth::{OpEthApiBuilder, ext::OpEthExtApi}, historical::{HistoricalRpc, HistoricalRpcClient}, miner::{MinerApiExtServer, OpMinerExtApi}, - witness::{DebugExecutionWitnessApiServer, OpDebugWitnessApi}, + witness::{DebugExecutionWitnessApiServer, OpDebugPostExecApiServer, OpDebugWitnessApi}, }; use reth_optimism_storage::OpStorage; use reth_optimism_txpool::{OpPool, OpPooledTx, supervisor::SupervisorClient}; @@ -129,7 +129,6 @@ impl PayloadAttributesBuilder for OpLocalPayloadAttributesBuilde }) } } - /// Marker trait for Optimism node types with standard engine, chain spec, and primitives. pub trait OpNodeTypes: NodeTypes @@ -150,7 +149,7 @@ impl OpNodeTypes for N where pub trait OpFullNodeTypes: NodeTypes< ChainSpec: OpHardforks, - Primitives: OpPayloadPrimitives, + Primitives: OpPayloadPrimitives<_TX = OpTransactionSigned>, Storage = OpStorage, Payload: EngineTypes, > @@ -160,7 +159,7 @@ pub trait OpFullNodeTypes: impl OpFullNodeTypes for N where N: NodeTypes< ChainSpec: OpHardforks, - Primitives: OpPayloadPrimitives, + Primitives: OpPayloadPrimitives<_TX = OpTransactionSigned>, Storage = OpStorage, Payload: EngineTypes, > @@ -239,7 +238,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()) @@ -590,8 +590,9 @@ impl NodeAddOns for OpAddOns where N: FullNodeComponents< - Types: NodeTypes, - Evm: ConfigureEvm< + Types: NodeTypes, + Evm: ConfigurePostExecEvm< + Primitives = OpPrimitives, NextBlockEnvCtx: BuildNextEnv< OpPayloadBuilderAttributes>, HeaderTy, @@ -656,6 +657,7 @@ where ctx.node.provider().clone(), ctx.node.task_executor().clone(), builder, + ctx.node.evm_config().clone(), ); let miner_ext = OpMinerExtApi::new(da_config, gas_limit_config); @@ -677,7 +679,14 @@ where container; debug!(target: "reth::cli", "Installing debug payload witness rpc endpoint"); - modules.merge_if_module_configured(RethRpcModule::Debug, debug_ext.into_rpc())?; + modules.merge_if_module_configured( + RethRpcModule::Debug, + DebugExecutionWitnessApiServer::into_rpc(debug_ext.clone()), + )?; + modules.merge_if_module_configured( + RethRpcModule::Debug, + OpDebugPostExecApiServer::into_rpc(debug_ext.clone()), + )?; // extend the miner namespace if configured in the regular http server modules.add_or_replace_if_module_configured( @@ -715,8 +724,9 @@ impl RethRpcAddOns for OpAddOns where N: FullNodeComponents< - Types: NodeTypes, - Evm: ConfigureEvm< + Types: NodeTypes, + Evm: ConfigurePostExecEvm< + Primitives = OpPrimitives, NextBlockEnvCtx: BuildNextEnv< OpPayloadBuilderAttributes>, HeaderTy, @@ -1188,6 +1198,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 synthetic post-exec transaction. + pub sdm_enabled: bool, } impl OpPayloadBuilder { @@ -1199,6 +1211,7 @@ impl OpPayloadBuilder { best_transactions: (), da_config: OpDAConfig::default(), gas_limit_config: OpGasLimitConfig::default(), + sdm_enabled: false, } } @@ -1213,14 +1226,26 @@ impl OpPayloadBuilder { self.gas_limit_config = gas_limit_config; self } + + /// Configure whether the OP payload builder should inject a synthetic 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, + } } } @@ -1229,14 +1254,14 @@ where Node: FullNodeTypes< Provider: ChainSpecProvider, Types: NodeTypes< - Primitives: OpPayloadPrimitives, + Primitives: OpPayloadPrimitives<_TX = OpTransactionSigned>, Payload: PayloadTypes< BuiltPayload = OpBuiltPayload>, PayloadAttributes = OpPayloadAttrs, >, >, >, - Evm: ConfigureEvm< + Evm: ConfigurePostExecEvm< Primitives = PrimitivesTy, NextBlockEnvCtx: BuildNextEnv< OpPayloadBuilderAttributes>, @@ -1268,6 +1293,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 7205940fd59..698255c1d30 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; @@ -157,11 +158,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 3183cc6a734..3c7830f50a4 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, Sealable, Transaction, Typed2718, transaction::Recovered}; use alloy_evm::Evm as AlloyEvm; -use alloy_primitives::{B256, U256}; +use alloy_primitives::{Address, B256, U256}; use alloy_rpc_types_debug::ExecutionWitness; use alloy_rpc_types_engine::PayloadId; +use op_alloy_consensus::{SDMGasEntry, build_post_exec_tx}; 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::{ + L2_TO_L1_MESSAGE_PASSER_ADDRESS, OpTransactionSigned, transaction::OpTransaction, +}; use reth_optimism_txpool::{ OpPooledTx, estimated_da_size::DataAvailabilitySized, @@ -42,6 +46,46 @@ 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 { + let post_exec_tx = build_post_exec_tx(block_number, entries); + let post_exec_signed = OpTransactionSigned::PostExec(post_exec_tx.seal_slow()); + Recovered::new_unchecked(post_exec_signed, Address::ZERO) +} + +/// Wraps refund entries in a synthetic post-exec transaction and executes it via `execute`. +/// +/// Returns `true` if a synthetic transaction was executed, `false` if `entries` is empty. +/// +/// The synthetic 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 + 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< @@ -155,8 +199,8 @@ impl OpPayloadBuilder>, Client: StateProviderFactory + ChainSpecProvider, - N: OpPayloadPrimitives, - Evm: ConfigureEvm< + N: OpPayloadPrimitives<_TX = OpTransactionSigned>, + Evm: ConfigurePostExecEvm< Primitives = N, NextBlockEnvCtx: BuildNextEnv, >, @@ -237,10 +281,10 @@ where impl PayloadBuilder for OpPayloadBuilder> where - N: OpPayloadPrimitives, + N: OpPayloadPrimitives<_TX = OpTransactionSigned>, Client: StateProviderFactory + ChainSpecProvider + Clone, Pool: TransactionPool>, - Evm: ConfigureEvm< + Evm: ConfigurePostExecEvm< Primitives = N, NextBlockEnvCtx: BuildNextEnv< OpPayloadBuilderAttributes, @@ -363,12 +407,12 @@ impl OpBuilder<'_, Txs> { ctx: OpPayloadBuilderCtx, ) -> Result>, PayloadBuilderError> where - Evm: ConfigureEvm< + Evm: ConfigurePostExecEvm< Primitives = N, NextBlockEnvCtx: BuildNextEnv, >, ChainSpec: EthChainSpec + OpHardforks, - N: OpPayloadPrimitives, + N: OpPayloadPrimitives<_TX = OpTransactionSigned>, Txs: PayloadTransactions + OpPooledTx>, Attrs: OpAttributes, @@ -408,6 +452,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 +498,12 @@ impl OpBuilder<'_, Txs> { ctx: &OpPayloadBuilderCtx, ) -> Result where - Evm: ConfigureEvm< + Evm: ConfigurePostExecEvm< Primitives = N, NextBlockEnvCtx: BuildNextEnv, >, ChainSpec: EthChainSpec + OpHardforks, - N: OpPayloadPrimitives, + N: OpPayloadPrimitives<_TX = OpTransactionSigned>, Txs: PayloadTransactions>, Attrs: OpAttributes, { @@ -597,7 +647,7 @@ pub struct OpPayloadBuilderCtx< impl OpPayloadBuilderCtx where - Evm: ConfigureEvm< + Evm: ConfigurePostExecEvm< Primitives: OpPayloadPrimitives, NextBlockEnvCtx: BuildNextEnv, ChainSpec>, >, @@ -639,12 +689,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 +704,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 +869,97 @@ 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 synthetic 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_synthetic_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 synthetic 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 synthetic 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..71e7869f2e8 100644 --- a/rust/op-reth/crates/payload/src/config.rs +++ b/rust/op-reth/crates/payload/src/config.rs @@ -9,12 +9,18 @@ pub struct OpBuilderConfig { pub da_config: OpDAConfig, /// Gas limit configuration for the OP builder. pub gas_limit_config: OpGasLimitConfig, + /// Whether synthetic 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 } + pub const fn new( + 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/examples/custom-node/src/lib.rs b/rust/op-reth/examples/custom-node/src/lib.rs index 6023ee67515..64e845f88a4 100644 --- a/rust/op-reth/examples/custom-node/src/lib.rs +++ b/rust/op-reth/examples/custom-node/src/lib.rs @@ -4,6 +4,20 @@ //! - primitives: block,header,transactions //! - components: network,pool,evm //! - engine: advances the node +//! +//! # Breaking change: payload service +//! +//! Upstream `OpPayloadBuilder` is now specialized for `OpTransactionSigned` payloads so that +//! it can append the post-exec (type `0x7D`) transaction added by the SDM feature. As a result, +//! this example no longer composes `OpPayloadBuilder` with the custom transaction type used in +//! `components/pool`. Until a dedicated payload builder for custom transaction types is added, +//! the example wires a `NoopPayloadServiceBuilder` — the node will not produce blocks, but it +//! still demonstrates the rest of the custom-node surface (custom primitives, executor, engine +//! API, engine validator, and RPC). +//! +//! Downstream forks that previously used `OpPayloadBuilder` with their own `_TX` type will need +//! to either constrain `_TX = OpTransactionSigned` or copy `OpPayloadBuilder` and re-implement +//! the post-exec append path for their transaction type. #![cfg_attr(not(test), warn(unused_crate_dependencies))] @@ -24,12 +38,13 @@ use primitives::CustomNodePrimitives; use reth_node_api::FullNodeTypes; use reth_node_builder::{ Node, NodeAdapter, NodeTypes, - components::{BasicPayloadServiceBuilder, ComponentsBuilder}, + components::{ComponentsBuilder, NodeComponentsBuilder, NoopPayloadServiceBuilder}, + rpc::{BasicEngineValidatorBuilder, RpcAddOns}, }; use reth_op::{ node::{ - OpAddOns, OpNode, - node::{OpConsensusBuilder, OpNetworkBuilder, OpPayloadBuilder, OpPoolBuilder}, + OpNode, + node::{OpConsensusBuilder, OpNetworkBuilder, OpPoolBuilder}, txpool, }, rpc::OpEthApiBuilder, @@ -43,7 +58,7 @@ pub mod pool; pub mod primitives; pub mod rpc; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct CustomNode { inner: OpNode, } @@ -62,30 +77,48 @@ where type ComponentsBuilder = ComponentsBuilder< N, OpPoolBuilder>, - BasicPayloadServiceBuilder, + NoopPayloadServiceBuilder, OpNetworkBuilder, CustomExecutorBuilder, OpConsensusBuilder, >; - type AddOns = OpAddOns< - NodeAdapter, + type AddOns = RpcAddOns< + NodeAdapter>::Components>, OpEthApiBuilder, CustomEngineValidatorBuilder, CustomEngineApiBuilder, + BasicEngineValidatorBuilder, >; fn components_builder(&self) -> Self::ComponentsBuilder { + let args = &self.inner.args; ComponentsBuilder::default() .node_types::() - .pool(OpPoolBuilder::default()) + .pool( + OpPoolBuilder::default() + .with_enable_tx_conditional(args.enable_tx_conditional) + .with_supervisor(args.supervisor_http.clone(), args.supervisor_safety_level), + ) .executor(CustomExecutorBuilder::default()) - .payload(BasicPayloadServiceBuilder::new(OpPayloadBuilder::new(false))) - .network(OpNetworkBuilder::new(false, false)) + .payload(NoopPayloadServiceBuilder::default()) + .network(OpNetworkBuilder::new(args.disable_txpool_gossip, !args.discovery_v4)) .consensus(OpConsensusBuilder::default()) } fn add_ons(&self) -> Self::AddOns { - self.inner.add_ons_builder().build() + let args = &self.inner.args; + RpcAddOns::new( + OpEthApiBuilder::default() + .with_sequencer(args.sequencer.clone()) + .with_sequencer_headers(args.sequencer_headers.clone()) + .with_min_suggested_priority_fee(args.min_suggested_priority_fee) + .with_flashblocks(args.flashblocks_url.clone()) + .with_flashblock_consensus(args.flashblock_consensus), + CustomEngineValidatorBuilder, + CustomEngineApiBuilder::default(), + BasicEngineValidatorBuilder::::default(), + Default::default(), + ) } } From 643258d9c01a1009f05d78cf5940d56f5451558b Mon Sep 17 00:00:00 2001 From: Anton Evangelatov Date: Mon, 20 Apr 2026 16:59:17 +0300 Subject: [PATCH 4/7] feat(op-reth): add debug_replaySDMBlock rpc and post-exec-replay crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce reth-optimism-post-exec-replay, a new crate that counterfactually re-executes a historical block under PostExecMode::Produce to derive what the post-exec payload *would* have been, then compares it to any 0x7D tx already embedded in the block and to the receipt-level opGasRefund projection. - debug_replaySDMBlock JSON-RPC handler exposes the replay result on a per-block basis. Compares synthesized refunds vs. embedded payload and vs. receipt refunds when requested. Documented as operator/debug tooling only — the debug namespace must not be exposed on public RPC, each call replays a whole historical block against live state and is unbounded in cost. - Replay detects seven mismatch categories: duplicate payload index, payload-index-out-of-range, payload-targets-deposit, payload-targets-post-exec, payload-refund-mismatch, receipt-refund-mismatch, payload-refund-exceeds-raw-gas. - strip_post_exec_tx_for_replay preserves the original tx index mapping so refund events can be attributed back to source-block positions. - Semaphore::new(3) caps concurrent replays; per-call wall-clock and memory ceilings are a production-hardening gap tracked separately. --- rust/Cargo.lock | 24 + rust/Cargo.toml | 2 + .../crates/post-exec-replay/Cargo.toml | 33 ++ .../crates/post-exec-replay/src/jsonl.rs | 31 + .../crates/post-exec-replay/src/lib.rs | 17 + .../crates/post-exec-replay/src/replay.rs | 555 ++++++++++++++++++ .../crates/post-exec-replay/src/types.rs | 206 +++++++ rust/op-reth/crates/rpc/Cargo.toml | 1 + rust/op-reth/crates/rpc/src/debug.rs | 4 +- rust/op-reth/crates/rpc/src/witness.rs | 145 ++++- 10 files changed, 1005 insertions(+), 13 deletions(-) create mode 100644 rust/op-reth/crates/post-exec-replay/Cargo.toml create mode 100644 rust/op-reth/crates/post-exec-replay/src/jsonl.rs create mode 100644 rust/op-reth/crates/post-exec-replay/src/lib.rs create mode 100644 rust/op-reth/crates/post-exec-replay/src/replay.rs create mode 100644 rust/op-reth/crates/post-exec-replay/src/types.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 237e1767537..8c712d9c144 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -11133,6 +11133,7 @@ dependencies = [ "alloy-rpc-types-engine", "derive_more", "either", + "metrics", "op-alloy-consensus", "op-alloy-rpc-types-engine", "op-revm", @@ -11140,6 +11141,7 @@ dependencies = [ "reth-chainspec", "reth-evm", "reth-execution-types", + "reth-metrics", "reth-optimism-evm", "reth-optimism-forks", "reth-optimism-primitives", @@ -11159,6 +11161,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "reth-optimism-post-exec-replay" +version = "1.11.3" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "metrics", + "op-alloy-consensus", + "reth-evm", + "reth-execution-errors", + "reth-metrics", + "reth-optimism-evm", + "reth-optimism-primitives", + "reth-primitives-traits", + "revm", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "reth-optimism-primitives" version = "1.11.3" @@ -11231,6 +11254,7 @@ dependencies = [ "reth-optimism-flashblocks", "reth-optimism-forks", "reth-optimism-payload-builder", + "reth-optimism-post-exec-replay", "reth-optimism-primitives", "reth-optimism-trie", "reth-optimism-txpool", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 7353326abc5..1bd12e24240 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -35,6 +35,7 @@ members = [ "op-reth/crates/hardforks/", "op-reth/crates/node/", "op-reth/crates/payload/", + "op-reth/crates/post-exec-replay/", "op-reth/crates/primitives/", "op-reth/crates/reth/", "op-reth/crates/rpc/", @@ -280,6 +281,7 @@ reth-optimism-flashblocks = { path = "op-reth/crates/flashblocks/" } reth-optimism-forks = { path = "op-reth/crates/hardforks/", default-features = false } reth-optimism-node = { path = "op-reth/crates/node/" } reth-optimism-payload-builder = { path = "op-reth/crates/payload/" } +reth-optimism-post-exec-replay = { path = "op-reth/crates/post-exec-replay/" } reth-optimism-primitives = { path = "op-reth/crates/primitives/", default-features = false } reth-op = { path = "op-reth/crates/reth/", default-features = false } reth-optimism-rpc = { path = "op-reth/crates/rpc/" } diff --git a/rust/op-reth/crates/post-exec-replay/Cargo.toml b/rust/op-reth/crates/post-exec-replay/Cargo.toml new file mode 100644 index 00000000000..e67a682a6a7 --- /dev/null +++ b/rust/op-reth/crates/post-exec-replay/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "reth-optimism-post-exec-replay" +version = "1.11.3" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage = "https://paradigmxyz.github.io/reth" +repository = "https://github.com/paradigmxyz/reth" +description = "Counterfactual post-exec replay support for op-reth." + +[lints] +workspace = true + +[dependencies] +reth-evm.workspace = true +reth-execution-errors.workspace = true +reth-optimism-evm = { workspace = true, features = ["std"] } +reth-optimism-primitives = { workspace = true, features = ["serde", "reth-codec"] } +reth-primitives-traits.workspace = true + +revm.workspace = true + +alloy-consensus.workspace = true +alloy-eips.workspace = true +alloy-primitives.workspace = true +op-alloy-consensus.workspace = true + +serde.workspace = true +serde_json = { workspace = true, features = ["std"] } +thiserror.workspace = true + +[dev-dependencies] +alloy-primitives.workspace = true diff --git a/rust/op-reth/crates/post-exec-replay/src/jsonl.rs b/rust/op-reth/crates/post-exec-replay/src/jsonl.rs new file mode 100644 index 00000000000..87591554f64 --- /dev/null +++ b/rust/op-reth/crates/post-exec-replay/src/jsonl.rs @@ -0,0 +1,31 @@ +use crate::types::{ + PostExecReplayBlock, PostExecReplayMismatch, PostExecReplayRunConfig, PostExecReplaySummary, + PostExecReplayTx, +}; +use serde::Serialize; +use std::io::{self, Write}; + +/// JSONL record for replay output. +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PostExecReplayJsonlRecord<'a> { + /// Run-level config. + RunConfig(&'a PostExecReplayRunConfig), + /// Per-tx row. + Tx(&'a PostExecReplayTx), + /// Per-block row. + Block(&'a PostExecReplayBlock), + /// Mismatch row. + Mismatch(&'a PostExecReplayMismatch), + /// Summary row. + Summary(&'a PostExecReplaySummary), +} + +/// Write one JSONL record. +pub fn write_jsonl_record( + mut writer: impl Write, + record: &PostExecReplayJsonlRecord<'_>, +) -> io::Result<()> { + serde_json::to_writer(&mut writer, record)?; + writer.write_all(b"\n") +} diff --git a/rust/op-reth/crates/post-exec-replay/src/lib.rs b/rust/op-reth/crates/post-exec-replay/src/lib.rs new file mode 100644 index 00000000000..99e8e05b7e9 --- /dev/null +++ b/rust/op-reth/crates/post-exec-replay/src/lib.rs @@ -0,0 +1,17 @@ +//! Counterfactual post-exec replay support for op-reth. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +mod jsonl; +mod replay; +mod types; + +pub use jsonl::{PostExecReplayJsonlRecord, write_jsonl_record}; +pub use replay::{PostExecReplayError, replay_block, strip_post_exec_tx_for_replay}; +pub use types::{ + PostExecReplayBlock, PostExecReplayConfig, PostExecReplayMismatch, PostExecReplayMismatchKind, + PostExecReplayMode, PostExecReplayPayload, PostExecReplayPayloadEntry, + PostExecReplayRefundEvent, PostExecReplayRefundKind, PostExecReplayRunConfig, + PostExecReplaySummary, PostExecReplayTx, ReplayPostExecBlockOptions, + ReplayPostExecBlockRequest, +}; diff --git a/rust/op-reth/crates/post-exec-replay/src/replay.rs b/rust/op-reth/crates/post-exec-replay/src/replay.rs new file mode 100644 index 00000000000..b318296ca0f --- /dev/null +++ b/rust/op-reth/crates/post-exec-replay/src/replay.rs @@ -0,0 +1,555 @@ +use crate::types::{ + PostExecReplayBlock, PostExecReplayConfig, PostExecReplayMismatch, PostExecReplayMismatchKind, + PostExecReplayMode, PostExecReplayPayload, PostExecReplayPayloadEntry, + PostExecReplayRefundEvent, PostExecReplayRefundKind, PostExecReplaySummary, PostExecReplayTx, +}; +use alloy_consensus::{Block as AlloyBlock, BlockBody, BlockHeader, TxReceipt, Typed2718}; +use op_alloy_consensus::{POST_EXEC_TX_TYPE_ID, PostExecPayload, SDMGasEntry, build_post_exec_tx}; +use reth_evm::{Database, execute::BlockExecutor}; +use reth_execution_errors::BlockExecutionError; +use reth_optimism_evm::{ + ConfigurePostExecEvm, PostExecExecutorExt, WarmingRefundEvent, WarmingRefundKind, +}; +use reth_optimism_primitives::{OpBlock, OpPrimitives, OpTransactionSigned}; +use reth_primitives_traits::{Block, RecoveredBlock}; +use revm::database::{State, states::bundle_state::BundleRetention}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Replay error. +#[derive(Debug, thiserror::Error)] +pub enum PostExecReplayError { + /// Unsupported replay configuration. + #[error("unsupported replay mode: {0:?}")] + UnsupportedMode(PostExecReplayMode), + /// Execution failed. + #[error(transparent)] + Execution(#[from] BlockExecutionError), +} + +#[derive(Debug, Clone)] +struct NormalizedBlock { + replay_block: RecoveredBlock, + original_indexes: Vec, + embedded_payload: Option, + post_exec_tx_index: Option, +} + +/// Strip the synthetic post-exec tx from a block before replay while preserving original indexes. +pub fn strip_post_exec_tx_for_replay( + block: &RecoveredBlock, +) -> (RecoveredBlock, Vec) { + let normalized = normalize_block(block); + (normalized.replay_block, normalized.original_indexes) +} + +fn normalize_block(block: &RecoveredBlock) -> NormalizedBlock { + let (raw_block, senders) = block.clone().split(); + let (header, body) = raw_block.split(); + let BlockBody { transactions, ommers, withdrawals } = body; + + let mut replay_transactions = Vec::with_capacity(transactions.len()); + let mut replay_senders = Vec::with_capacity(senders.len()); + let mut original_indexes = Vec::with_capacity(transactions.len()); + let mut embedded_payload = None; + let mut post_exec_tx_index = None; + + for (idx, (tx, sender)) in transactions.into_iter().zip(senders.into_iter()).enumerate() { + if tx.ty() == POST_EXEC_TX_TYPE_ID { + post_exec_tx_index = Some(idx as u64); + if let OpTransactionSigned::PostExec(post_exec) = &tx { + embedded_payload = Some(post_exec.inner().payload.clone()); + } + continue; + } + + original_indexes.push(idx as u64); + replay_transactions.push(tx); + replay_senders.push(sender); + } + + let replay_block = RecoveredBlock::new_unhashed( + AlloyBlock::new( + header, + BlockBody { transactions: replay_transactions, ommers, withdrawals }, + ), + replay_senders, + ); + + NormalizedBlock { replay_block, original_indexes, embedded_payload, post_exec_tx_index } +} + +const fn into_refund_kind(kind: WarmingRefundKind) -> PostExecReplayRefundKind { + match kind { + WarmingRefundKind::WarmAccount => PostExecReplayRefundKind::WarmAccount, + WarmingRefundKind::WarmSload => PostExecReplayRefundKind::WarmSload, + WarmingRefundKind::WarmSstore => PostExecReplayRefundKind::WarmSstore, + } +} + +fn into_refund_event( + event: WarmingRefundEvent, + claiming_replay_tx_index: u64, + original_indexes: &[u64], +) -> PostExecReplayRefundEvent { + let first_warmed_by_replay_tx_index = event.first_warmed_by_tx_index; + let claiming_tx_index = original_indexes + .get(claiming_replay_tx_index as usize) + .copied() + .unwrap_or(claiming_replay_tx_index); + let first_warmed_by_tx_index = original_indexes + .get(first_warmed_by_replay_tx_index as usize) + .copied() + .unwrap_or(first_warmed_by_replay_tx_index); + + PostExecReplayRefundEvent { + claiming_replay_tx_index, + claiming_tx_index, + kind: into_refund_kind(event.kind), + amount: event.amount, + address: event.address, + slot: event.slot, + first_warmed_by_replay_tx_index, + first_warmed_by_tx_index, + } +} + +fn build_payload_map( + block_number: u64, + block: &RecoveredBlock, + payload: &PostExecPayload, + mismatches: &mut Vec, +) -> BTreeMap { + let mut refunds = BTreeMap::new(); + let mut seen = BTreeSet::new(); + let tx_count = block.body().transactions.len() as u64; + + for entry in &payload.gas_refund_entries { + if !seen.insert(entry.index) { + mismatches.push(PostExecReplayMismatch { + category: PostExecReplayMismatchKind::DuplicatePayloadIndex, + block_num: block_number, + tx_index: Some(entry.index), + expected: None, + actual: Some(entry.gas_refund), + message: format!("duplicate payload entry for tx index {}", entry.index), + }); + continue; + } + + if entry.index >= tx_count { + mismatches.push(PostExecReplayMismatch { + category: PostExecReplayMismatchKind::PayloadIndexOutOfRange, + block_num: block_number, + tx_index: Some(entry.index), + expected: Some(tx_count.saturating_sub(1)), + actual: Some(entry.index), + message: format!("payload entry targets out-of-range tx index {}", entry.index), + }); + continue; + } + + let tx = &block.body().transactions[entry.index as usize]; + if tx.is_deposit() { + mismatches.push(PostExecReplayMismatch { + category: PostExecReplayMismatchKind::PayloadTargetsDeposit, + block_num: block_number, + tx_index: Some(entry.index), + expected: Some(0), + actual: Some(entry.gas_refund), + message: format!("payload entry targets deposit tx index {}", entry.index), + }); + continue; + } + + if tx.ty() == POST_EXEC_TX_TYPE_ID { + mismatches.push(PostExecReplayMismatch { + category: PostExecReplayMismatchKind::PayloadTargetsPostExec, + block_num: block_number, + tx_index: Some(entry.index), + expected: Some(0), + actual: Some(entry.gas_refund), + message: format!("payload entry targets post-exec tx index {}", entry.index), + }); + continue; + } + + refunds.insert(entry.index, entry.gas_refund); + } + + refunds +} + +fn into_replay_payload(payload: PostExecPayload) -> PostExecReplayPayload { + PostExecReplayPayload { + version: payload.version, + block_number: payload.block_number, + gas_refund_entries: payload + .gas_refund_entries + .into_iter() + .map(|entry| PostExecReplayPayloadEntry { + index: entry.index, + gas_refund: entry.gas_refund, + }) + .collect(), + } +} + +struct CompareRefundsInput<'a> { + block_number: u64, + tx_index: u64, + raw_gas_used: u64, + replay_refund: u64, + payload_refund: Option, + receipt_refund: Option, + config: &'a PostExecReplayConfig, +} + +fn compare_refunds( + input: CompareRefundsInput<'_>, + mismatches: &mut Vec, +) -> bool { + let CompareRefundsInput { + block_number, + tx_index, + raw_gas_used, + replay_refund, + payload_refund, + receipt_refund, + config, + } = input; + let mut mismatch = false; + + if let Some(payload_refund) = payload_refund && + payload_refund > raw_gas_used + { + mismatch = true; + mismatches.push(PostExecReplayMismatch { + category: PostExecReplayMismatchKind::PayloadRefundExceedsRawGas, + block_num: block_number, + tx_index: Some(tx_index), + expected: Some(raw_gas_used), + actual: Some(payload_refund), + message: format!("payload refund exceeds raw gas for tx index {}", tx_index), + }); + } + + if config.compare_payload && payload_refund.unwrap_or_default() != replay_refund { + mismatch = true; + mismatches.push(PostExecReplayMismatch { + category: PostExecReplayMismatchKind::PayloadRefundMismatch, + block_num: block_number, + tx_index: Some(tx_index), + expected: payload_refund, + actual: Some(replay_refund), + message: format!("payload refund mismatch for tx index {}", tx_index), + }); + } + + if config.compare_receipts && receipt_refund.unwrap_or_default() != replay_refund { + mismatch = true; + mismatches.push(PostExecReplayMismatch { + category: PostExecReplayMismatchKind::ReceiptRefundMismatch, + block_num: block_number, + tx_index: Some(tx_index), + expected: receipt_refund, + actual: Some(replay_refund), + message: format!("receipt refund mismatch for tx index {}", tx_index), + }); + } + + mismatch +} + +/// Replay a historical block with post-exec enabled counterfactually. +pub fn replay_block( + evm_config: &EvmConfig, + db: DB, + block: &RecoveredBlock, + config: PostExecReplayConfig, +) -> Result +where + DB: Database, + EvmConfig: ConfigurePostExecEvm, +{ + if config.mode != PostExecReplayMode::CounterfactualEnabled { + return Err(PostExecReplayError::UnsupportedMode(config.mode)); + } + + let normalized = normalize_block(block); + + let mut state = State::builder().with_database(db).with_bundle_update().build(); + let mut executor = evm_config + .post_exec_executor_for_block( + &mut state, + normalized.replay_block.sealed_block(), + reth_optimism_evm::PostExecMode::Produce, + ) + .map_err(BlockExecutionError::other)?; + + executor.apply_pre_execution_changes()?; + for tx in normalized.replay_block.transactions_recovered() { + executor.execute_transaction(tx)?; + } + let replay_entries: Vec = executor.take_post_exec_entries(); + let warming_events_by_tx = executor.take_warming_events_by_tx(); + let execution = executor.apply_post_execution_changes()?; + + state.merge_transitions(BundleRetention::Reverts); + + let replay_payload = PostExecPayload { + version: 1, + block_number: block.header().number(), + gas_refund_entries: replay_entries.clone(), + }; + let replay_refunds: BTreeMap = + replay_entries.iter().map(|entry| (entry.index, entry.gas_refund)).collect(); + + let mut mismatches = Vec::new(); + let payload_refunds = normalized + .embedded_payload + .as_ref() + .map(|payload| build_payload_map(block.header().number(), block, payload, &mut mismatches)) + .unwrap_or_default(); + + let receipt_refunds = payload_refunds.clone(); + let mut txs = Vec::with_capacity(normalized.replay_block.body().transactions.len()); + let mut previous_cumulative_gas = 0_u64; + + for (replay_idx, tx) in normalized.replay_block.body().transactions.iter().enumerate() { + let tx_index = normalized.original_indexes[replay_idx]; + let cumulative_gas_used = execution.receipts[replay_idx].cumulative_gas_used(); + let canonical_gas_used = cumulative_gas_used.saturating_sub(previous_cumulative_gas); + previous_cumulative_gas = cumulative_gas_used; + + let replay_refund = replay_refunds.get(&tx_index).copied().unwrap_or_default(); + let raw_gas_used = canonical_gas_used.saturating_add(replay_refund); + let payload_refund = payload_refunds.get(&tx_index).copied(); + let receipt_refund = receipt_refunds.get(&tx_index).copied(); + let refund_breakdown = warming_events_by_tx + .get(replay_idx) + .cloned() + .unwrap_or_default() + .into_iter() + .map(|event| into_refund_event(event, replay_idx as u64, &normalized.original_indexes)) + .collect::>(); + let mismatch = compare_refunds( + CompareRefundsInput { + block_number: block.header().number(), + tx_index, + raw_gas_used, + replay_refund, + payload_refund, + receipt_refund, + config: &config, + }, + &mut mismatches, + ); + + txs.push(PostExecReplayTx { + tx_index, + replay_tx_index: replay_idx as u64, + tx_hash: tx.tx_hash(), + tx_type: tx.ty(), + is_deposit_tx: tx.is_deposit(), + gas_used: canonical_gas_used, + raw_gas_used, + canonical_gas_used, + op_gas_refund_replay: replay_refund, + op_gas_refund_payload: payload_refund, + op_gas_refund_receipt: receipt_refund, + effective_gas: canonical_gas_used, + refund_breakdown, + mismatch, + }); + } + + let tx_count_user = txs.iter().filter(|tx| !tx.is_deposit_tx).count(); + let replay_refund_total = txs.iter().map(|tx| tx.op_gas_refund_replay).sum::(); + let payload_refund_total = + txs.iter().map(|tx| tx.op_gas_refund_payload.unwrap_or_default()).sum::(); + let receipt_refund_total = + txs.iter().map(|tx| tx.op_gas_refund_receipt.unwrap_or_default()).sum::(); + let block_gas_used = txs.iter().map(|tx| tx.gas_used).sum::(); + let block_raw_gas_used = txs.iter().map(|tx| tx.raw_gas_used).sum::(); + + let summary = PostExecReplaySummary { + block_num: block.header().number(), + block_hash: block.hash(), + tx_count_total: txs.len(), + tx_count_user, + post_exec_tx_present: normalized.post_exec_tx_index.is_some(), + post_exec_payload_entry_count: replay_entries.len(), + block_gas_used, + block_raw_gas_used, + replay_refund_total, + payload_refund_total, + node_receipt_refund_total: receipt_refund_total, + block_effective_gas: block_gas_used, + mismatch_count: mismatches.len(), + replay_mode: config.mode, + }; + + Ok(PostExecReplayBlock { + config, + block_num: block.header().number(), + block_hash: block.hash(), + parent_hash: block.header().parent_hash(), + post_exec_tx_present: normalized.post_exec_tx_index.is_some(), + post_exec_tx_index: normalized.post_exec_tx_index, + embedded_payload: normalized.embedded_payload.map(into_replay_payload), + synthesized_payload_bytes: build_post_exec_tx(block.header().number(), replay_entries) + .payload + .to_rlp_bytes(), + synthesized_payload: into_replay_payload(replay_payload), + txs, + mismatches, + summary, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + CompareRefundsInput, build_payload_map, compare_refunds, normalize_block, + strip_post_exec_tx_for_replay, + }; + use crate::{PostExecReplayConfig, PostExecReplayMismatchKind, PostExecReplayMode}; + use alloy_consensus::{BlockBody, Header, Sealable, SignableTransaction, TxLegacy}; + use alloy_primitives::{Address, Signature, U256}; + use op_alloy_consensus::{OpTxEnvelope, TxDeposit, build_post_exec_tx}; + use reth_optimism_primitives::OpTransactionSigned; + use reth_primitives_traits::RecoveredBlock; + + fn user_tx() -> OpTransactionSigned { + OpTxEnvelope::Legacy(TxLegacy::default().into_signed(Signature::new( + U256::ZERO, + U256::ZERO, + false, + ))) + } + + #[test] + fn strips_post_exec_tx_and_preserves_original_indexes() { + let deposit: OpTransactionSigned = OpTxEnvelope::Deposit(TxDeposit::default().seal_slow()); + let user = user_tx(); + let post_exec: OpTransactionSigned = + OpTransactionSigned::PostExec(build_post_exec_tx(0, vec![]).seal_slow()); + + let block = RecoveredBlock::new_unhashed( + alloy_consensus::Block::new( + Header::default(), + BlockBody { + transactions: vec![deposit, user, post_exec], + ommers: vec![], + withdrawals: None, + }, + ), + vec![Address::ZERO, Address::ZERO, Address::ZERO], + ); + + let (replay_block, original_indexes) = strip_post_exec_tx_for_replay(&block); + assert_eq!(replay_block.body().transactions.len(), 2); + assert_eq!(original_indexes, vec![0, 1]); + } + + #[test] + fn normalize_block_extracts_embedded_payload_and_post_exec_index() { + let deposit: OpTransactionSigned = OpTxEnvelope::Deposit(TxDeposit::default().seal_slow()); + let user = user_tx(); + let payload_entries = vec![op_alloy_consensus::SDMGasEntry { index: 1, gas_refund: 9 }]; + let post_exec: OpTransactionSigned = OpTransactionSigned::PostExec( + build_post_exec_tx(0, payload_entries.clone()).seal_slow(), + ); + + let block = RecoveredBlock::new_unhashed( + alloy_consensus::Block::new( + Header::default(), + BlockBody { + transactions: vec![deposit, user, post_exec], + ommers: vec![], + withdrawals: None, + }, + ), + vec![Address::ZERO, Address::ZERO, Address::ZERO], + ); + + let normalized = normalize_block(&block); + assert_eq!(normalized.post_exec_tx_index, Some(2)); + assert_eq!(normalized.original_indexes, vec![0, 1]); + assert_eq!(normalized.embedded_payload.unwrap().gas_refund_entries, payload_entries); + assert_eq!(normalized.replay_block.body().transactions.len(), 2); + } + + #[test] + fn build_payload_map_reports_invalid_targets_and_duplicates() { + let deposit: OpTransactionSigned = OpTxEnvelope::Deposit(TxDeposit::default().seal_slow()); + let user = user_tx(); + let post_exec: OpTransactionSigned = + OpTransactionSigned::PostExec(build_post_exec_tx(0, vec![]).seal_slow()); + let block = RecoveredBlock::new_unhashed( + alloy_consensus::Block::new( + Header::default(), + BlockBody { + transactions: vec![deposit, user, post_exec], + ommers: vec![], + withdrawals: None, + }, + ), + vec![Address::ZERO, Address::ZERO, Address::ZERO], + ); + let payload = op_alloy_consensus::PostExecPayload { + version: 1, + block_number: 100, + gas_refund_entries: vec![ + op_alloy_consensus::SDMGasEntry { index: 0, gas_refund: 1 }, + op_alloy_consensus::SDMGasEntry { index: 2, gas_refund: 2 }, + op_alloy_consensus::SDMGasEntry { index: 8, gas_refund: 3 }, + op_alloy_consensus::SDMGasEntry { index: 1, gas_refund: 4 }, + op_alloy_consensus::SDMGasEntry { index: 1, gas_refund: 5 }, + ], + }; + + let mut mismatches = Vec::new(); + let refunds = build_payload_map(100, &block, &payload, &mut mismatches); + + assert_eq!(refunds.get(&1), Some(&4)); + assert_eq!(refunds.len(), 1); + assert_eq!( + mismatches.iter().map(|m| m.category.clone()).collect::>(), + vec![ + PostExecReplayMismatchKind::PayloadTargetsDeposit, + PostExecReplayMismatchKind::PayloadTargetsPostExec, + PostExecReplayMismatchKind::PayloadIndexOutOfRange, + PostExecReplayMismatchKind::DuplicatePayloadIndex, + ] + ); + } + + #[test] + fn compare_refunds_detects_tampered_payload_and_receipt_mismatches() { + let config = PostExecReplayConfig { + mode: PostExecReplayMode::CounterfactualEnabled, + compare_payload: true, + compare_receipts: true, + }; + let mut mismatches = Vec::new(); + + let mismatch = compare_refunds( + CompareRefundsInput { + block_number: 100, + tx_index: 3, + raw_gas_used: 40, + replay_refund: 5, + payload_refund: Some(7), + receipt_refund: Some(7), + config: &config, + }, + &mut mismatches, + ); + + assert!(mismatch); + assert_eq!(mismatches.len(), 2); + assert_eq!(mismatches[0].category, PostExecReplayMismatchKind::PayloadRefundMismatch); + assert_eq!(mismatches[1].category, PostExecReplayMismatchKind::ReceiptRefundMismatch); + } +} diff --git a/rust/op-reth/crates/post-exec-replay/src/types.rs b/rust/op-reth/crates/post-exec-replay/src/types.rs new file mode 100644 index 00000000000..e1130697365 --- /dev/null +++ b/rust/op-reth/crates/post-exec-replay/src/types.rs @@ -0,0 +1,206 @@ +#![allow(missing_docs)] + +use alloy_eips::BlockNumberOrTag; +use alloy_primitives::{Address, B256, Bytes}; +use serde::{Deserialize, Serialize}; + +/// Single-block replay request, accepting either a block tag/number or a block hash. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ReplayPostExecBlockRequest { + /// A block number or tag like `latest`. + Number(BlockNumberOrTag), + /// A block hash. + Hash(B256), +} + +/// Options for `debug_replaySDMBlock`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReplayPostExecBlockOptions { + /// Compare replay refunds against any embedded post-exec payload in the source block. + #[serde(default)] + pub compare_payload: bool, + /// Compare replay refunds against the receipt-level `opGasRefund` projection. + #[serde(default)] + pub compare_receipts: bool, +} + +impl Default for ReplayPostExecBlockOptions { + fn default() -> Self { + Self { compare_payload: true, compare_receipts: true } + } +} + +/// Post-exec replay mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PostExecReplayMode { + /// Run block execution without post-exec. + Disabled, + /// Re-execute a historical block as if post-exec had been enabled. + #[default] + CounterfactualEnabled, + /// Re-execute while also validating against an already-existing post-exec payload. + Verifier, +} + +/// Replay configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PostExecReplayConfig { + /// Replay mode. + pub mode: PostExecReplayMode, + /// Compare replay refunds against an embedded payload when present. + pub compare_payload: bool, + /// Compare replay refunds against receipt-level `opGasRefund` projection when present. + pub compare_receipts: bool, +} + +impl Default for PostExecReplayConfig { + fn default() -> Self { + Self { + mode: PostExecReplayMode::CounterfactualEnabled, + compare_payload: true, + compare_receipts: true, + } + } +} + +/// Exact refund categories emitted by the replay engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PostExecReplayRefundKind { + /// Warm account rebate (+2500). + WarmAccount, + /// Warm storage read rebate (+2000). + WarmSload, + /// Warm storage write rebate (+2100). + WarmSstore, +} + +/// Exact refund attribution event for one replayed transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PostExecReplayRefundEvent { + /// Replay-local transaction index that claimed the rebate. + pub claiming_replay_tx_index: u64, + /// Original transaction index in the source block that claimed the rebate. + pub claiming_tx_index: u64, + /// Refund kind. + pub kind: PostExecReplayRefundKind, + /// Refund amount in gas. + pub amount: u64, + /// Account touched by the rebate. + pub address: Address, + /// Storage slot touched by the rebate, when applicable. + pub slot: Option, + /// Replay-local transaction index that first warmed the account or slot. + pub first_warmed_by_replay_tx_index: u64, + /// Original transaction index in the source block that first warmed the account or slot. + pub first_warmed_by_tx_index: u64, +} + +/// Per-transaction replay row. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PostExecReplayTx { + pub tx_index: u64, + pub replay_tx_index: u64, + pub tx_hash: B256, + pub tx_type: u8, + pub is_deposit_tx: bool, + pub gas_used: u64, + pub raw_gas_used: u64, + pub canonical_gas_used: u64, + pub op_gas_refund_replay: u64, + pub op_gas_refund_payload: Option, + pub op_gas_refund_receipt: Option, + pub effective_gas: u64, + pub refund_breakdown: Vec, + pub mismatch: bool, +} + +/// Replay mismatch category. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PostExecReplayMismatchKind { + DuplicatePayloadIndex, + PayloadIndexOutOfRange, + PayloadTargetsDeposit, + PayloadTargetsPostExec, + PayloadRefundMismatch, + ReceiptRefundMismatch, + PayloadRefundExceedsRawGas, + UnsupportedMode, +} + +/// Replay mismatch row. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PostExecReplayMismatch { + pub category: PostExecReplayMismatchKind, + pub block_num: u64, + pub tx_index: Option, + pub expected: Option, + pub actual: Option, + pub message: String, +} + +/// Block-level summary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PostExecReplaySummary { + pub block_num: u64, + pub block_hash: B256, + pub tx_count_total: usize, + pub tx_count_user: usize, + pub post_exec_tx_present: bool, + pub post_exec_payload_entry_count: usize, + pub block_gas_used: u64, + pub block_raw_gas_used: u64, + pub replay_refund_total: u64, + pub payload_refund_total: u64, + pub node_receipt_refund_total: u64, + pub block_effective_gas: u64, + pub mismatch_count: usize, + pub replay_mode: PostExecReplayMode, +} + +/// Single-block replay response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PostExecReplayBlock { + pub config: PostExecReplayConfig, + pub block_num: u64, + pub block_hash: B256, + pub parent_hash: B256, + pub post_exec_tx_present: bool, + pub post_exec_tx_index: Option, + pub embedded_payload: Option, + pub synthesized_payload: PostExecReplayPayload, + pub synthesized_payload_bytes: Bytes, + pub txs: Vec, + pub mismatches: Vec, + pub summary: PostExecReplaySummary, +} + +/// Run-level configuration for JSONL output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PostExecReplayRunConfig { + #[serde(rename = "type")] + pub record_type: &'static str, + pub from_block: u64, + pub to_block: u64, + pub replay_mode: PostExecReplayMode, + pub compare_payload: bool, + pub compare_receipts: bool, +} + +/// Serializable replay payload entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PostExecReplayPayloadEntry { + pub index: u64, + pub gas_refund: u64, +} + +/// Serializable replay payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PostExecReplayPayload { + pub version: u64, + pub block_number: u64, + pub gas_refund_entries: Vec, +} diff --git a/rust/op-reth/crates/rpc/Cargo.toml b/rust/op-reth/crates/rpc/Cargo.toml index e19996291c0..38a13b3dfa9 100644 --- a/rust/op-reth/crates/rpc/Cargo.toml +++ b/rust/op-reth/crates/rpc/Cargo.toml @@ -37,6 +37,7 @@ reth-provider.workspace = true reth-optimism-evm.workspace = true reth-optimism-flashblocks.workspace = true reth-optimism-payload-builder.workspace = true +reth-optimism-post-exec-replay.workspace = true reth-optimism-txpool.workspace = true # TODO remove node-builder import reth-optimism-primitives = { workspace = true, features = ["reth-codec", "serde-bincode-compat", "serde"] } diff --git a/rust/op-reth/crates/rpc/src/debug.rs b/rust/op-reth/crates/rpc/src/debug.rs index d199e66618d..74e66aba170 100644 --- a/rust/op-reth/crates/rpc/src/debug.rs +++ b/rust/op-reth/crates/rpc/src/debug.rs @@ -171,8 +171,8 @@ where ErrorObject<'static>: From, P: OpProofsStore + Clone + 'static, Attrs: OpAttributes, RpcPayloadAttributes: Send>, - N: OpPayloadPrimitives, - EvmConfig: ConfigureEvm< + N: OpPayloadPrimitives<_TX = reth_optimism_primitives::OpTransactionSigned>, + 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..9ca6e8049ce 100644 --- a/rust/op-reth/crates/rpc/src/witness.rs +++ b/rust/op-reth/crates/rpc/src/witness.rs @@ -1,19 +1,28 @@ //! Support for optimism specific witness RPCs. +use alloy_consensus::BlockHeader; +use alloy_eips::BlockId; use alloy_primitives::B256; use alloy_rpc_types_debug::ExecutionWitness; +use jsonrpsee::proc_macros::rpc; 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_payload_builder::{OpAttributes, OpPayloadBuilder}; +use reth_optimism_post_exec_replay::{ + PostExecReplayBlock, PostExecReplayConfig, ReplayPostExecBlockOptions, + ReplayPostExecBlockRequest, replay_block, +}; +use reth_optimism_primitives::{OpBlock, OpPrimitives}; use reth_optimism_txpool::OpPooledTx; -use reth_primitives_traits::{SealedHeader, TxTy}; +use reth_primitives_traits::{RecoveredBlock, SealedHeader, TxTy}; +use reth_revm::database::StateProviderDatabase; pub use reth_rpc_api::DebugExecutionWitnessApiServer; use reth_rpc_server_types::{ToRpcResult, result::internal_rpc_err}; use reth_storage_api::{ - BlockReaderIdExt, NodePrimitivesProvider, StateProviderFactory, + BlockReaderIdExt, NodePrimitivesProvider, StateProviderFactory, TransactionVariant, errors::{ProviderError, ProviderResult}, }; use reth_tasks::Runtime; @@ -21,6 +30,28 @@ use reth_transaction_pool::TransactionPool; use std::{fmt::Debug, sync::Arc}; use tokio::sync::{Semaphore, oneshot}; +/// An extension to the `debug_` namespace for post-exec replay. +/// +/// This trait is registered under the `debug` namespace and is intended for operator and +/// research tooling only. Do not expose the `debug` namespace on public RPC endpoints: each +/// call replays an entire historical block against live state and is unbounded in cost, so +/// an unauthenticated caller can trivially saturate the node. +#[cfg_attr(not(test), rpc(server, namespace = "debug"))] +#[cfg_attr(test, rpc(server, client, namespace = "debug"))] +pub trait OpDebugPostExecApi { + /// Counterfactually replay a historical block with post-exec enabled. + /// + /// Replays one block per call; callers driving a block range are responsible for + /// their own pacing and cancellation. Requires historical state for the target block + /// (full/archive node); on a pruned node this will fail at state lookup. + #[method(name = "replaySDMBlock")] + async fn replay_post_exec_block( + &self, + block: ReplayPostExecBlockRequest, + options: Option, + ) -> RpcResult; +} + /// An extension to the `debug_` namespace of the RPC API. pub struct OpDebugWitnessApi { inner: Arc>, @@ -32,16 +63,18 @@ impl OpDebugWitnessApi, + evm_config: EvmConfig, ) -> Self { let semaphore = Arc::new(Semaphore::new(3)); - let inner = OpDebugWitnessApiInner { provider, builder, task_spawner, semaphore }; + let inner = + OpDebugWitnessApiInner { provider, builder, evm_config, task_spawner, semaphore }; Self { inner: Arc::new(inner) } } } impl OpDebugWitnessApi where - EvmConfig: ConfigureEvm, + EvmConfig: ConfigurePostExecEvm, Provider: NodePrimitivesProvider> + BlockReaderIdExt, { @@ -57,6 +90,31 @@ where } } +impl OpDebugWitnessApi +where + Provider: BlockReaderIdExt::BlockHeader> + + NodePrimitivesProvider + + Clone, +{ + fn replay_block_by_request( + &self, + request: ReplayPostExecBlockRequest, + ) -> ProviderResult> { + match request { + ReplayPostExecBlockRequest::Hash(hash) => self + .inner + .provider + .recovered_block(hash.into(), TransactionVariant::NoHash)? + .ok_or_else(|| ProviderError::HeaderNotFound(hash.into())), + ReplayPostExecBlockRequest::Number(block) => self + .inner + .provider + .block_with_senders_by_id(BlockId::Number(block), TransactionVariant::NoHash)? + .ok_or_else(|| ProviderError::HeaderNotFound(0_u64.into())), + } + } +} + #[async_trait] impl DebugExecutionWitnessApiServer for OpDebugWitnessApi @@ -64,15 +122,19 @@ where Pool: TransactionPool< Transaction: OpPooledTx::SignedTx>, > + 'static, - Provider: BlockReaderIdExt
::BlockHeader> - + NodePrimitivesProvider + Provider: BlockReaderIdExt::BlockHeader> + + NodePrimitivesProvider + StateProviderFactory + ChainSpecProvider + Clone + 'static, - EvmConfig: ConfigureEvm< - Primitives = Provider::Primitives, - NextBlockEnvCtx: BuildNextEnv, + EvmConfig: ConfigurePostExecEvm< + Primitives = OpPrimitives, + NextBlockEnvCtx: BuildNextEnv< + Attrs, + ::BlockHeader, + Provider::ChainSpec, + >, > + 'static, Attrs: OpAttributes, RpcPayloadAttributes: Send>, { @@ -98,6 +160,66 @@ where } } +#[async_trait] +impl OpDebugPostExecApiServer + for OpDebugWitnessApi +where + Pool: TransactionPool< + Transaction: OpPooledTx::SignedTx>, + > + 'static, + Provider: BlockReaderIdExt::BlockHeader> + + NodePrimitivesProvider + + StateProviderFactory + + ChainSpecProvider + + Clone + + 'static, + EvmConfig: ConfigurePostExecEvm< + Primitives = OpPrimitives, + NextBlockEnvCtx: BuildNextEnv< + Attrs, + ::BlockHeader, + Provider::ChainSpec, + >, + > + Clone + + 'static, + Attrs: OpAttributes>, +{ + async fn replay_post_exec_block( + &self, + request: ReplayPostExecBlockRequest, + options: Option, + ) -> RpcResult { + let _permit = self.inner.semaphore.acquire().await; + let block = self.replay_block_by_request(request).to_rpc_result()?; + let config = { + let options = options.unwrap_or_default(); + PostExecReplayConfig { + compare_payload: options.compare_payload, + compare_receipts: options.compare_receipts, + ..Default::default() + } + }; + + let (tx, rx) = oneshot::channel(); + let this = self.clone(); + self.inner.task_spawner.spawn_blocking_task(Box::pin(async move { + let res = (|| { + let state_provider = this + .inner + .provider + .state_by_block_hash(block.header().parent_hash()) + .map_err(|err| internal_rpc_err(err.to_string()))?; + let db = StateProviderDatabase::new(&state_provider); + replay_block(&this.inner.evm_config, db, &block, config) + .map_err(|err| internal_rpc_err(err.to_string())) + })(); + let _ = tx.send(res); + })); + + rx.await.map_err(|err| internal_rpc_err(err.to_string()))? + } +} + impl Clone for OpDebugWitnessApi { @@ -116,6 +238,7 @@ impl Debug struct OpDebugWitnessApiInner { provider: Provider, builder: OpPayloadBuilder, + evm_config: EvmConfig, task_spawner: Runtime, semaphore: Arc, } From a8e1dc8e1d1b314c41fe9616234f55f6b1dfd50d Mon Sep 17 00:00:00 2001 From: Anton Evangelatov Date: Mon, 20 Apr 2026 16:59:46 +0300 Subject: [PATCH 5/7] feat(op-chain-ops): add Go sdmreplay client Client for driving debug_replaySDMBlock over a range of blocks and writing a JSONL trail of the replay results, for post-hoc verification and cross-node consistency checks. - ReplayRange iterates blocks with ctx cancellation support so long ranges can be aborted cleanly. - DecodePayload RLP-decodes the 0x7D tx input and rejects unknown versions in lock-step with the Rust decoder (POST_EXEC_PAYLOAD_VERSION = 1). Cross-language drift would let a Go pipeline accept payloads the Rust node rejects. - Unit tests cover the version check on both the current 3-field shape and the legacy 2-field fallback, plus an empty-input guard. - Memory-buffered range mode is fine for devstack tests; streaming JSONL for mainnet-scale ranges is tracked as a pre-production item. --- go.mod | 4 +- go.sum | 4 +- op-chain-ops/pkg/sdmreplay/jsonl.go | 32 ++ op-chain-ops/pkg/sdmreplay/payload.go | 85 ++++++ op-chain-ops/pkg/sdmreplay/payload_test.go | 73 +++++ op-chain-ops/pkg/sdmreplay/range.go | 56 ++++ op-chain-ops/pkg/sdmreplay/replay.go | 269 +++++++++++++++++ op-chain-ops/pkg/sdmreplay/replay_test.go | 240 +++++++++++++++ op-chain-ops/pkg/sdmreplay/rpc.go | 336 +++++++++++++++++++++ op-chain-ops/pkg/sdmreplay/types.go | 134 ++++++++ 10 files changed, 1228 insertions(+), 5 deletions(-) create mode 100644 op-chain-ops/pkg/sdmreplay/jsonl.go create mode 100644 op-chain-ops/pkg/sdmreplay/payload.go create mode 100644 op-chain-ops/pkg/sdmreplay/payload_test.go create mode 100644 op-chain-ops/pkg/sdmreplay/range.go create mode 100644 op-chain-ops/pkg/sdmreplay/replay.go create mode 100644 op-chain-ops/pkg/sdmreplay/replay_test.go create mode 100644 op-chain-ops/pkg/sdmreplay/rpc.go create mode 100644 op-chain-ops/pkg/sdmreplay/types.go diff --git a/go.mod b/go.mod index 0204f0eaa07..34cef24b3b3 100644 --- a/go.mod +++ b/go.mod @@ -277,9 +277,7 @@ require ( lukechampine.com/blake3 v1.3.0 // indirect ) -replace github.com/ethereum/go-ethereum => github.com/ethereum-optimism/op-geth v1.101702.1-rc.1 - -// replace github.com/ethereum/go-ethereum => ../op-geth +replace github.com/ethereum/go-ethereum => github.com/ethereum-optimism/op-geth v1.101702.2-0.20260415133811-2c2badfed10e // replace github.com/ethereum-optimism/superchain-registry/superchain => ../superchain-registry/superchain // This release keeps breaking Go builds. Stop that. diff --git a/go.sum b/go.sum index dae0a5eef9e..95f14075b9e 100644 --- a/go.sum +++ b/go.sum @@ -220,8 +220,8 @@ github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/ethereum-optimism/go-ethereum-hdwallet v0.1.4-0.20251001155152-4eb15ccedf7e h1:iy1vBIzACYUyOVyoADUwvAiq2eOPC0yVsDUdolPwQjk= github.com/ethereum-optimism/go-ethereum-hdwallet v0.1.4-0.20251001155152-4eb15ccedf7e/go.mod h1:DYj7+vYJ4cIB7zera9mv4LcAynCL5u4YVfoeUu6Wa+w= -github.com/ethereum-optimism/op-geth v1.101702.1-rc.1 h1:2p7pzvmAeZ6xR6pqltf3l6cwCu8HwGR4eWom3v8PwkM= -github.com/ethereum-optimism/op-geth v1.101702.1-rc.1/go.mod h1:HzvOtk7c9KwFaSxRvUBPFHGSjIjomWtw4iSXX6vruQE= +github.com/ethereum-optimism/op-geth v1.101702.2-0.20260415133811-2c2badfed10e h1:FtGf1ae3Q8tzYjJUJLCPmAaG2sc9lWuSFmPi3ErKNyc= +github.com/ethereum-optimism/op-geth v1.101702.2-0.20260415133811-2c2badfed10e/go.mod h1:HzvOtk7c9KwFaSxRvUBPFHGSjIjomWtw4iSXX6vruQE= github.com/ethereum-optimism/superchain-registry/validation v0.0.0-20260115192958-fb86a23cd30e h1:TO1tUcwbhIrNuea/LCsQJSQ5HDWCHdrzT/5MLC1aIU4= github.com/ethereum-optimism/superchain-registry/validation v0.0.0-20260115192958-fb86a23cd30e/go.mod h1:NZ816PzLU1TLv1RdAvYAb6KWOj4Zm5aInT0YpDVml2Y= github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= diff --git a/op-chain-ops/pkg/sdmreplay/jsonl.go b/op-chain-ops/pkg/sdmreplay/jsonl.go new file mode 100644 index 00000000000..0d6b269ec12 --- /dev/null +++ b/op-chain-ops/pkg/sdmreplay/jsonl.go @@ -0,0 +1,32 @@ +package sdmreplay + +import ( + "encoding/json" + "io" +) + +// WriteJSONL emits records in stable order: run config, tx rows, block rows, mismatch rows, summary. +func WriteJSONL(w io.Writer, result *RangeResult, summaryOnly bool) error { + enc := json.NewEncoder(w) + if err := enc.Encode(result.RunConfig); err != nil { + return err + } + for _, block := range result.Blocks { + if !summaryOnly { + for _, tx := range block.Txs { + if err := enc.Encode(tx); err != nil { + return err + } + } + } + if err := enc.Encode(block.Block); err != nil { + return err + } + for _, mismatch := range block.Mismatches { + if err := enc.Encode(mismatch); err != nil { + return err + } + } + } + return enc.Encode(result.Summary) +} diff --git a/op-chain-ops/pkg/sdmreplay/payload.go b/op-chain-ops/pkg/sdmreplay/payload.go new file mode 100644 index 00000000000..69675738e15 --- /dev/null +++ b/op-chain-ops/pkg/sdmreplay/payload.go @@ -0,0 +1,85 @@ +package sdmreplay + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/rlp" +) + +const SDMTxType = 0x7d + +// PostExecPayloadVersion is the only PostExecPayload version the Go decoder accepts. +// Must stay in lock-step with POST_EXEC_PAYLOAD_VERSION in rust/op-alloy, which rejects +// unknown versions at decode time; Go accepting what Rust rejects is a cross-language +// drift hazard on any replay/verifier pipeline sitting between the two. +const PostExecPayloadVersion uint64 = 1 + +// SDMGasEntry is one per-transaction refund entry inside the SDM portion of a post-exec payload. +type SDMGasEntry struct { + Index uint64 `json:"index"` + GasRefund uint64 `json:"gas_refund"` +} + +// PostExecPayload is the decoded RLP payload carried by the synthetic post-exec tx. +// Today this contains the SDM gas refund entries and the L2 block number the payload is anchored to. +// Older payloads may omit BlockNumber. +type PostExecPayload struct { + Version uint64 `json:"version"` + BlockNumber uint64 `json:"block_number,omitempty"` + GasRefundEntries []SDMGasEntry `json:"gas_refund_entries"` +} + +// GasRefundForIndex returns the refund for the given block tx index. +func (p *PostExecPayload) GasRefundForIndex(index uint64) (uint64, bool) { + if p == nil { + return 0, false + } + for _, entry := range p.GasRefundEntries { + if entry.Index == index { + return entry.GasRefund, true + } + } + return 0, false +} + +// DecodePayload decodes an RLP-encoded post-exec payload from the post-exec tx input. +func DecodePayload(input []byte) (*PostExecPayload, error) { + if len(input) == 0 { + return nil, fmt.Errorf("empty post-exec payload") + } + + payload, err := decodePayloadStruct(input) + if err != nil { + return nil, err + } + if payload.Version != PostExecPayloadVersion { + return nil, fmt.Errorf( + "unsupported post-exec payload version %d (expected %d)", + payload.Version, PostExecPayloadVersion, + ) + } + return payload, nil +} + +// decodePayloadStruct tries the current RLP shape first, then falls back to the legacy +// two-field shape. Version validation is applied by the caller so unknown versions are +// rejected on either path. +func decodePayloadStruct(input []byte) (*PostExecPayload, error) { + var payload PostExecPayload + if err := rlp.DecodeBytes(input, &payload); err == nil { + return &payload, nil + } + + // Backward compatibility for older payloads that encoded only version + refund entries. + var legacy struct { + Version uint64 + GasRefundEntries []SDMGasEntry + } + if err := rlp.DecodeBytes(input, &legacy); err != nil { + return nil, fmt.Errorf("decode post-exec payload: %w", err) + } + return &PostExecPayload{ + Version: legacy.Version, + GasRefundEntries: legacy.GasRefundEntries, + }, nil +} diff --git a/op-chain-ops/pkg/sdmreplay/payload_test.go b/op-chain-ops/pkg/sdmreplay/payload_test.go new file mode 100644 index 00000000000..cdb3433f10e --- /dev/null +++ b/op-chain-ops/pkg/sdmreplay/payload_test.go @@ -0,0 +1,73 @@ +package sdmreplay + +import ( + "strconv" + "testing" + + "github.com/ethereum/go-ethereum/rlp" + "github.com/stretchr/testify/require" +) + +func TestDecodePayload_AcceptsCurrentShape(t *testing.T) { + payload := PostExecPayload{ + Version: PostExecPayloadVersion, + BlockNumber: 42, + GasRefundEntries: []SDMGasEntry{ + {Index: 3, GasRefund: 7}, + {Index: 5, GasRefund: 11}, + }, + } + encoded, err := rlp.EncodeToBytes(&payload) + require.NoError(t, err) + + decoded, err := DecodePayload(encoded) + require.NoError(t, err) + require.Equal(t, &payload, decoded) +} + +func TestDecodePayload_RejectsUnknownVersion(t *testing.T) { + // Any non-1 version must be rejected to stay in lock-step with the Rust decoder in + // rust/op-alloy, which gates on POST_EXEC_PAYLOAD_VERSION. Cross-language divergence + // here would let a Go-based replay pipeline accept payloads the Rust node rejects. + for _, version := range []uint64{0, 2, 99} { + t.Run("version_"+strconv.FormatUint(version, 10), func(t *testing.T) { + payload := PostExecPayload{ + Version: version, + BlockNumber: 1, + GasRefundEntries: []SDMGasEntry{{Index: 0, GasRefund: 1}}, + } + encoded, err := rlp.EncodeToBytes(&payload) + require.NoError(t, err) + + _, err = DecodePayload(encoded) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported post-exec payload version") + }) + } +} + +func TestDecodePayload_RejectsUnknownVersionOnLegacyShape(t *testing.T) { + // Same version check on the legacy two-field shape — the fallback decoder must not + // become an escape hatch for payloads whose version is wrong. + legacy := struct { + Version uint64 + GasRefundEntries []SDMGasEntry + }{ + Version: 7, + GasRefundEntries: []SDMGasEntry{{Index: 0, GasRefund: 1}}, + } + encoded, err := rlp.EncodeToBytes(&legacy) + require.NoError(t, err) + + _, err = DecodePayload(encoded) + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported post-exec payload version") +} + +func TestDecodePayload_EmptyInputRejected(t *testing.T) { + _, err := DecodePayload(nil) + require.Error(t, err) + + _, err = DecodePayload([]byte{}) + require.Error(t, err) +} diff --git a/op-chain-ops/pkg/sdmreplay/range.go b/op-chain-ops/pkg/sdmreplay/range.go new file mode 100644 index 00000000000..dcfae539e02 --- /dev/null +++ b/op-chain-ops/pkg/sdmreplay/range.go @@ -0,0 +1,56 @@ +package sdmreplay + +import ( + "context" + "fmt" + "strconv" + "strings" +) + +// BlockNumberSource resolves the current head block number. +type BlockNumberSource interface { + GetBlockNumber(ctx context.Context) (uint64, error) +} + +// ResolveBlockNum parses a block selector string which can be: +// - "latest" -> fetches current block number +// - "latest-N" -> fetches current block number minus N +// - "0x..." -> hex block number +// - decimal string +func ResolveBlockNum(ctx context.Context, src BlockNumberSource, selector string) (uint64, error) { + selector = strings.TrimSpace(selector) + + if selector == "latest" { + return src.GetBlockNumber(ctx) + } + if strings.HasPrefix(selector, "latest-") { + offset, err := strconv.ParseUint(strings.TrimPrefix(selector, "latest-"), 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid latest-N offset: %w", err) + } + latest, err := src.GetBlockNumber(ctx) + if err != nil { + return 0, err + } + if offset > latest { + return 0, fmt.Errorf("offset %d exceeds latest block %d", offset, latest) + } + return latest - offset, nil + } + return ParseBlockNum(selector) +} + +// ParseBlockNum parses a hex or decimal block number. +func ParseBlockNum(s string) (uint64, error) { + if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { + return strconv.ParseUint(s[2:], 16, 64) + } + return strconv.ParseUint(s, 10, 64) +} + +// ParseHexUint64 parses a 0x-prefixed hex string into a uint64. +func ParseHexUint64(s string) (uint64, error) { + s = strings.TrimPrefix(s, "0x") + s = strings.TrimPrefix(s, "0X") + return strconv.ParseUint(s, 16, 64) +} diff --git a/op-chain-ops/pkg/sdmreplay/replay.go b/op-chain-ops/pkg/sdmreplay/replay.go new file mode 100644 index 00000000000..b6a466864a5 --- /dev/null +++ b/op-chain-ops/pkg/sdmreplay/replay.go @@ -0,0 +1,269 @@ +package sdmreplay + +import ( + "context" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/common" +) + +// Source is the data source used by replay logic. +type Source interface { + BlockNumberSource + ClientVersion(ctx context.Context) (string, error) + ChainID(ctx context.Context) (uint64, error) + GetBlockByNumber(ctx context.Context, blockNum uint64) (*RPCBlock, error) + GetTransactionReceipt(ctx context.Context, txHash common.Hash) (*RPCReceipt, error) + ReplaySdmBlock(ctx context.Context, blockNum uint64, comparePayload bool, compareReceipts bool) (*ReplaySdmBlock, error) +} + +// ReplayMode controls where per-tx refund accounting comes from. +type ReplayMode string + +const ( + ReplayModeCounterfactualEnabled ReplayMode = "counterfactual_enabled" +) + +// ReplayRange processes blocks sequentially and aggregates the requested output. +func ReplayRange(ctx context.Context, src Source, cfg Config) (*RangeResult, error) { + if cfg.Workers == 0 { + cfg.Workers = 1 + } + if cfg.Workers != 1 { + return nil, fmt.Errorf("--workers=%d is not supported yet; use 1", cfg.Workers) + } + if cfg.Format == "" { + cfg.Format = "jsonl" + } + if cfg.Format != "jsonl" { + return nil, fmt.Errorf("unsupported format %q", cfg.Format) + } + if cfg.FromBlock > cfg.ToBlock { + return nil, fmt.Errorf("from block %d is greater than to block %d", cfg.FromBlock, cfg.ToBlock) + } + if cfg.IncludeTrace { + return nil, fmt.Errorf("--include-trace is not supported with debug_replaySDMBlock") + } + + headBlock, err := src.GetBlockNumber(ctx) + if err != nil { + return nil, fmt.Errorf("get head block: %w", err) + } + chainID, err := src.ChainID(ctx) + if err != nil { + return nil, fmt.Errorf("get chain ID: %w", err) + } + clientVersion, err := src.ClientVersion(ctx) + if err != nil { + return nil, fmt.Errorf("get client version: %w", err) + } + + result := &RangeResult{ + RunConfig: RunConfigRecord{ + Type: "run_config", + RPC: cfg.RPCURL, + FromBlock: cfg.FromBlockSelector, + ToBlock: cfg.ToBlockSelector, + ResolvedFromBlock: cfg.FromBlock, + ResolvedToBlock: cfg.ToBlock, + HeadBlock: headBlock, + ChainID: chainID, + ClientVersion: clientVersion, + ReplayMode: string(ReplayModeCounterfactualEnabled), + ComparePayload: cfg.ComparePayload, + CompareRPCReceipts: cfg.CompareRPCReceipts, + SummaryOnly: cfg.SummaryOnly, + IncludeTrace: false, + }, + Summary: SummaryRecord{ + Type: "summary", + FromBlock: cfg.FromBlock, + ToBlock: cfg.ToBlock, + ReplayMode: string(ReplayModeCounterfactualEnabled), + }, + } + + var totalRefundRatio float64 + + for blockNum := cfg.FromBlock; blockNum <= cfg.ToBlock; blockNum++ { + if err := ctx.Err(); err != nil { + return result, err + } + blockResult, err := replayBlock(ctx, src, blockNum, cfg) + if err != nil { + return nil, err + } + if cfg.SkipEmptyBlocks && blockResult.Block.TxCountUser == 0 { + result.Summary.BlocksSkipped++ + continue + } + + result.Blocks = append(result.Blocks, *blockResult) + result.RunConfig.ReplayMode = blockResult.Block.ReplayMode + result.Summary.ReplayMode = blockResult.Block.ReplayMode + result.Summary.BlocksProcessed++ + if blockResult.Block.SDMTxPresent { + result.Summary.BlocksWithSDMTx++ + } + result.Summary.TxCountTotal += blockResult.Block.TxCountTotal + result.Summary.TxCountUser += blockResult.Block.TxCountUser + result.Summary.TotalGasUsed += blockResult.Block.BlockGasUsed + result.Summary.ReplayRefundTotal += blockResult.Block.ReplayRefundTotal + result.Summary.NodeReceiptRefundTotal += blockResult.Block.NodeReceiptRefundTotal + result.Summary.PayloadRefundTotal += blockResult.Block.PayloadRefundTotal + result.Summary.EffectiveGasTotal += blockResult.Block.BlockEffectiveGas + result.Summary.MismatchCount += blockResult.Block.MismatchCount + totalRefundRatio += blockResult.Block.AvgRefundRatio * float64(blockResult.Block.TxCountUser) + } + + if result.Summary.TotalGasUsed > 0 { + result.Summary.TotalRefundRatio = float64(result.Summary.ReplayRefundTotal) / float64(result.Summary.TotalGasUsed) + } + if result.Summary.TxCountUser > 0 { + result.Summary.AvgRefundRatio = totalRefundRatio / float64(result.Summary.TxCountUser) + } + + if cfg.FailOnMismatch && result.Summary.MismatchCount > 0 { + return result, fmt.Errorf("found %d mismatch record(s)", result.Summary.MismatchCount) + } + return result, nil +} + +func replayBlock(ctx context.Context, src Source, blockNum uint64, cfg Config) (*BlockResult, error) { + replay, err := src.ReplaySdmBlock(ctx, blockNum, cfg.ComparePayload, cfg.CompareRPCReceipts) + if err != nil { + if strings.Contains(err.Error(), "method not found") { + return nil, fmt.Errorf("node does not expose debug_replaySDMBlock; run against the modified op-reth node: %w", err) + } + return nil, err + } + + block, err := src.GetBlockByNumber(ctx, blockNum) + if err != nil { + return nil, fmt.Errorf("load block %d: %w", blockNum, err) + } + + blockTxs := make(map[uint64]RPCTransaction, len(block.Transactions)) + for idx, tx := range block.Transactions { + blockTxs[uint64(idx)] = tx + } + + blockRecord := BlockRecord{ + Type: "block", + BlockNum: replay.Summary.BlockNum, + BlockHash: replay.Summary.BlockHash.Hex(), + ParentHash: replay.ParentHash.Hex(), + TxCountTotal: replay.Summary.TxCountTotal, + TxCountUser: replay.Summary.TxCountUser, + SDMTxPresent: replay.Summary.SDMTxPresent, + SDMPayloadEntryCount: replay.Summary.SDMPayloadEntryCount, + BlockGasUsed: replay.Summary.BlockGasUsed, + BlockOPGasRefund: replay.Summary.ReplayRefundTotal, + BlockEffectiveGas: replay.Summary.BlockEffectiveGas, + NodeReceiptRefundTotal: replay.Summary.NodeReceiptRefundTotal, + ReplayRefundTotal: replay.Summary.ReplayRefundTotal, + PayloadRefundTotal: replay.Summary.PayloadRefundTotal, + MismatchCount: replay.Summary.MismatchCount, + ReplayMode: replay.Summary.ReplayMode, + } + if blockRecord.BlockGasUsed > 0 { + blockRecord.BlockRefundRatio = float64(blockRecord.ReplayRefundTotal) / float64(blockRecord.BlockGasUsed) + } + + mismatches := make([]MismatchRecord, 0, len(replay.Mismatches)) + for _, mismatch := range replay.Mismatches { + record := MismatchRecord{ + Type: "mismatch", + BlockNum: mismatch.BlockNum, + Category: mismatch.Category, + Message: mismatch.Message, + } + if mismatch.TxIndex != nil { + record.TxIndex = int(*mismatch.TxIndex) + if tx, ok := blockTxs[*mismatch.TxIndex]; ok { + record.TxHash = tx.Hash.Hex() + } + } + if mismatch.Expected != nil { + record.Expected = *mismatch.Expected + } + if mismatch.Actual != nil { + record.Actual = *mismatch.Actual + } + mismatches = append(mismatches, record) + } + + txRecords := make([]TxRecord, 0, len(replay.Txs)) + var totalRatio float64 + + for _, tx := range replay.Txs { + var ( + from string + to string + ) + if blockTx, ok := blockTxs[tx.TxIndex]; ok { + from = blockTx.From.Hex() + if blockTx.To != nil { + to = blockTx.To.Hex() + } + } + + payloadRefund := uint64Value(tx.OPGasRefundPayload) + receiptRefund := uint64Value(tx.OPGasRefundReceipt) + refundRatio := 0.0 + if !tx.IsDepositTx && tx.GasUsed > 0 { + refundRatio = float64(tx.OPGasRefundReplay) / float64(tx.GasUsed) + totalRatio += refundRatio + } + + if cfg.SummaryOnly { + continue + } + + receipt, err := src.GetTransactionReceipt(ctx, tx.TxHash) + if err != nil { + return nil, fmt.Errorf("load receipt for tx %s in block %d: %w", tx.TxHash.Hex(), blockNum, err) + } + + txRecord := TxRecord{ + Type: "tx", + BlockNum: replay.BlockNum, + TxIndex: int(tx.TxIndex), + ReplayTxIndex: int(tx.ReplayTxIndex), + TxHash: tx.TxHash.Hex(), + TxType: fmt.Sprintf("0x%x", tx.TxType), + From: from, + To: to, + GasUsed: tx.GasUsed, + OPGasRefundReplay: tx.OPGasRefundReplay, + OPGasRefundReceipt: receiptRefund, + OPGasRefundPayload: payloadRefund, + EffectiveGas: tx.EffectiveGas, + RefundRatio: refundRatio, + Status: uint64(receipt.Status), + IsSDMTx: false, + IsDepositTx: tx.IsDepositTx, + Mismatch: tx.Mismatch, + AccountingSource: "debug_replaySDMBlock", + } + txRecords = append(txRecords, txRecord) + } + + if blockRecord.TxCountUser > 0 { + blockRecord.AvgRefundRatio = totalRatio / float64(blockRecord.TxCountUser) + } + + return &BlockResult{ + Block: blockRecord, + Txs: txRecords, + Mismatches: mismatches, + }, nil +} + +func uint64Value(v *uint64) uint64 { + if v == nil { + return 0 + } + return *v +} diff --git a/op-chain-ops/pkg/sdmreplay/replay_test.go b/op-chain-ops/pkg/sdmreplay/replay_test.go new file mode 100644 index 00000000000..f1e648688ee --- /dev/null +++ b/op-chain-ops/pkg/sdmreplay/replay_test.go @@ -0,0 +1,240 @@ +package sdmreplay + +import ( + "context" + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/require" +) + +type mockSource struct { + head uint64 + chainID uint64 + clientVersion string + blocks map[uint64]*RPCBlock + receipts map[common.Hash]*RPCReceipt + replays map[uint64]*ReplaySdmBlock +} + +func (m *mockSource) GetBlockNumber(context.Context) (uint64, error) { + return m.head, nil +} + +func (m *mockSource) ClientVersion(context.Context) (string, error) { + return m.clientVersion, nil +} + +func (m *mockSource) ChainID(context.Context) (uint64, error) { + return m.chainID, nil +} + +func (m *mockSource) GetBlockByNumber(_ context.Context, blockNum uint64) (*RPCBlock, error) { + block, ok := m.blocks[blockNum] + if !ok { + return nil, errors.New("missing block") + } + return block, nil +} + +func (m *mockSource) GetTransactionReceipt(_ context.Context, txHash common.Hash) (*RPCReceipt, error) { + receipt, ok := m.receipts[txHash] + if !ok { + return nil, errors.New("missing receipt") + } + return receipt, nil +} + +func (m *mockSource) ReplaySdmBlock(_ context.Context, blockNum uint64, _ bool, _ bool) (*ReplaySdmBlock, error) { + replay, ok := m.replays[blockNum] + if !ok { + return nil, errors.New("missing replay") + } + return replay, nil +} + +func TestResolveBlockNum(t *testing.T) { + src := &mockSource{head: 123} + ctx := context.Background() + + n, err := ResolveBlockNum(ctx, src, "latest") + require.NoError(t, err) + require.Equal(t, uint64(123), n) + + n, err = ResolveBlockNum(ctx, src, "latest-3") + require.NoError(t, err) + require.Equal(t, uint64(120), n) + + n, err = ResolveBlockNum(ctx, src, "0x10") + require.NoError(t, err) + require.Equal(t, uint64(16), n) +} + +func TestReplayRangeCounterfactualRPC(t *testing.T) { + ctx := context.Background() + blockNum := uint64(10) + depositHash := common.HexToHash("0x1") + userAHash := common.HexToHash("0x3") + userBHash := common.HexToHash("0x4") + + src := &mockSource{ + head: 15, + chainID: 10, + clientVersion: "op-reth/v1.7.0-sdm", + blocks: map[uint64]*RPCBlock{ + blockNum: { + Number: hexutil.Uint64(blockNum), + Hash: common.HexToHash("0x10"), + ParentHash: common.HexToHash("0x09"), + GasUsed: hexutil.Uint64(92000), + Transactions: []RPCTransaction{ + {Hash: depositHash, Type: hexutil.Uint64(126), From: common.HexToAddress("0x100")}, + {Hash: userAHash, Type: hexutil.Uint64(2), From: common.HexToAddress("0x101"), To: addrPtr(common.HexToAddress("0x201"))}, + {Hash: userBHash, Type: hexutil.Uint64(2), From: common.HexToAddress("0x102"), To: addrPtr(common.HexToAddress("0x202"))}, + }, + }, + }, + receipts: map[common.Hash]*RPCReceipt{ + depositHash: {TransactionHash: depositHash, TransactionIndex: hexutil.Uint64(0), GasUsed: hexutil.Uint64(21000), Status: hexutil.Uint64(1)}, + userAHash: {TransactionHash: userAHash, TransactionIndex: hexutil.Uint64(1), GasUsed: hexutil.Uint64(21000), Status: hexutil.Uint64(1)}, + userBHash: {TransactionHash: userBHash, TransactionIndex: hexutil.Uint64(2), GasUsed: hexutil.Uint64(50000), Status: hexutil.Uint64(1)}, + }, + replays: map[uint64]*ReplaySdmBlock{ + blockNum: { + BlockNum: blockNum, + BlockHash: common.HexToHash("0x10"), + ParentHash: common.HexToHash("0x09"), + SDMTxPresent: false, + Txs: []ReplaySdmTx{ + {TxIndex: 0, ReplayTxIndex: 0, TxHash: depositHash, TxType: 126, IsDepositTx: true, GasUsed: 21000, OPGasRefundReplay: 0, EffectiveGas: 21000}, + {TxIndex: 1, ReplayTxIndex: 1, TxHash: userAHash, TxType: 2, IsDepositTx: false, GasUsed: 21000, OPGasRefundReplay: 0, EffectiveGas: 21000}, + {TxIndex: 2, ReplayTxIndex: 2, TxHash: userBHash, TxType: 2, IsDepositTx: false, GasUsed: 50000, OPGasRefundReplay: 2500, OPGasRefundReceipt: uint64Ptr(2500), EffectiveGas: 47500}, + }, + Summary: ReplaySdmSummary{ + BlockNum: blockNum, + BlockHash: common.HexToHash("0x10"), + TxCountTotal: 3, + TxCountUser: 2, + SDMTxPresent: false, + SDMPayloadEntryCount: 1, + BlockGasUsed: 92000, + ReplayRefundTotal: 2500, + PayloadRefundTotal: 0, + NodeReceiptRefundTotal: 2500, + BlockEffectiveGas: 89500, + MismatchCount: 0, + ReplayMode: string(ReplayModeCounterfactualEnabled), + }, + }, + }, + } + + result, err := ReplayRange(ctx, src, Config{ + RPCURL: "http://example.invalid", + FromBlockSelector: "10", + ToBlockSelector: "10", + FromBlock: blockNum, + ToBlock: blockNum, + CompareRPCReceipts: true, + Workers: 1, + Format: "jsonl", + }) + require.NoError(t, err) + require.Equal(t, string(ReplayModeCounterfactualEnabled), result.RunConfig.ReplayMode) + require.Len(t, result.Blocks, 1) + require.Equal(t, 0, result.Summary.MismatchCount) + require.Equal(t, 2, result.Blocks[0].Block.TxCountUser) + require.Equal(t, uint64(2500), result.Blocks[0].Block.ReplayRefundTotal) + require.Equal(t, uint64(2500), result.Blocks[0].Block.NodeReceiptRefundTotal) + require.Len(t, result.Blocks[0].Txs, 3) + require.Equal(t, 2, result.Blocks[0].Txs[2].ReplayTxIndex) + require.Equal(t, uint64(2500), result.Blocks[0].Txs[2].OPGasRefundReplay) + require.Equal(t, "debug_replaySDMBlock", result.Blocks[0].Txs[2].AccountingSource) +} + +func TestReplayRangeCounterfactualPayloadMismatch(t *testing.T) { + ctx := context.Background() + blockNum := uint64(11) + userHash := common.HexToHash("0x13") + + src := &mockSource{ + head: 15, + chainID: 10, + clientVersion: "op-reth/v1.7.0-sdm", + blocks: map[uint64]*RPCBlock{ + blockNum: { + Number: hexutil.Uint64(blockNum), + Hash: common.HexToHash("0x20"), + ParentHash: common.HexToHash("0x19"), + GasUsed: hexutil.Uint64(50000), + Transactions: []RPCTransaction{ + {Hash: userHash, Type: hexutil.Uint64(2), From: common.HexToAddress("0x101"), To: addrPtr(common.HexToAddress("0x201"))}, + }, + }, + }, + receipts: map[common.Hash]*RPCReceipt{ + userHash: {TransactionHash: userHash, TransactionIndex: hexutil.Uint64(0), GasUsed: hexutil.Uint64(50000), Status: hexutil.Uint64(1)}, + }, + replays: map[uint64]*ReplaySdmBlock{ + blockNum: { + BlockNum: blockNum, + BlockHash: common.HexToHash("0x20"), + ParentHash: common.HexToHash("0x19"), + SDMTxPresent: false, + Txs: []ReplaySdmTx{ + {TxIndex: 0, ReplayTxIndex: 0, TxHash: userHash, TxType: 2, GasUsed: 50000, OPGasRefundReplay: 2600, EffectiveGas: 47400, Mismatch: true}, + }, + Mismatches: []ReplaySdmMismatch{ + { + Category: "payload_refund_mismatch", + BlockNum: blockNum, + TxIndex: uint64Ptr(0), + Actual: uint64Ptr(2600), + Message: "payload refund mismatch for tx index 0", + }, + }, + Summary: ReplaySdmSummary{ + BlockNum: blockNum, + BlockHash: common.HexToHash("0x20"), + TxCountTotal: 1, + TxCountUser: 1, + SDMTxPresent: false, + SDMPayloadEntryCount: 1, + BlockGasUsed: 50000, + ReplayRefundTotal: 2600, + PayloadRefundTotal: 0, + NodeReceiptRefundTotal: 0, + BlockEffectiveGas: 47400, + MismatchCount: 1, + ReplayMode: string(ReplayModeCounterfactualEnabled), + }, + }, + }, + } + + result, err := ReplayRange(ctx, src, Config{ + RPCURL: "http://example.invalid", + FromBlockSelector: "11", + ToBlockSelector: "11", + FromBlock: blockNum, + ToBlock: blockNum, + ComparePayload: true, + Workers: 1, + Format: "jsonl", + }) + require.NoError(t, err) + require.Equal(t, 1, result.Summary.MismatchCount) + require.Len(t, result.Blocks[0].Mismatches, 1) + require.Equal(t, "payload_refund_mismatch", result.Blocks[0].Mismatches[0].Category) + require.Equal(t, userHash.Hex(), result.Blocks[0].Mismatches[0].TxHash) +} + +func addrPtr(addr common.Address) *common.Address { + return &addr +} + +func uint64Ptr(v uint64) *uint64 { + return &v +} diff --git a/op-chain-ops/pkg/sdmreplay/rpc.go b/op-chain-ops/pkg/sdmreplay/rpc.go new file mode 100644 index 00000000000..487083c93a4 --- /dev/null +++ b/op-chain-ops/pkg/sdmreplay/rpc.go @@ -0,0 +1,336 @@ +package sdmreplay + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +// Client fetches blocks, receipts, and traces from an RPC endpoint. +type Client struct { + url string + client *http.Client +} + +// NewClient constructs an RPC client with a conservative timeout. +func NewClient(url string) *Client { + return &Client{ + url: url, + client: &http.Client{ + Timeout: 120 * time.Second, + }, + } +} + +type jsonrpcRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params interface{} `json:"params"` + ID int `json:"id"` +} + +type jsonrpcResponse struct { + JSONRPC string `json:"jsonrpc"` + Result json.RawMessage `json:"result"` + Error *jsonrpcError `json:"error,omitempty"` + ID int `json:"id"` +} + +type jsonrpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// RPCTransaction is the minimal full-transaction block shape the replay tool needs. +type RPCTransaction struct { + Hash common.Hash `json:"hash"` + Type hexutil.Uint64 `json:"type"` + From common.Address `json:"from"` + To *common.Address `json:"to"` + Input hexutil.Bytes `json:"input"` + Gas hexutil.Uint64 `json:"gas"` +} + +// RPCBlock is the minimal block shape the replay tool needs. +type RPCBlock struct { + Number hexutil.Uint64 `json:"number"` + Hash common.Hash `json:"hash"` + ParentHash common.Hash `json:"parentHash"` + GasUsed hexutil.Uint64 `json:"gasUsed"` + Transactions []RPCTransaction `json:"transactions"` +} + +// RPCReceipt is the minimal receipt shape the replay tool needs. +type RPCReceipt struct { + TransactionHash common.Hash `json:"transactionHash"` + TransactionIndex hexutil.Uint64 `json:"transactionIndex"` + GasUsed hexutil.Uint64 `json:"gasUsed"` + Status hexutil.Uint64 `json:"status"` + OPGasRefund *hexutil.Uint64 `json:"opGasRefund"` +} + +// TracerResult is the per-tx result returned by storageProfileTracer. +type TracerResult struct { + TxHash string `json:"txHash"` + From string `json:"from"` + To *string `json:"to"` + GasUsed uint64 `json:"gasUsed"` + OPGasRefund uint64 `json:"opGasRefund"` + EffectiveGas uint64 `json:"effectiveGas"` + RefundRatio float64 `json:"refundRatio"` + SstoreCount uint64 `json:"sstoreCount"` + SstoreGas uint64 `json:"sstoreGas"` + SstoreRatio float64 `json:"sstoreRatio"` + StorageHeavy bool `json:"storageHeavy"` + WallClockMicros int64 `json:"wallClockMicros"` + CalldataLen int `json:"calldataLen"` + Status uint64 `json:"status"` +} + +// ReplaySdmBlockOptions configures debug_replaySDMBlock. +type ReplaySdmBlockOptions struct { + ComparePayload bool `json:"compare_payload"` + CompareReceipts bool `json:"compare_receipts"` +} + +// ReplaySdmConfig describes the replay mode used by the node. +type ReplaySdmConfig struct { + Mode string `json:"mode"` + ComparePayload bool `json:"compare_payload"` + CompareReceipts bool `json:"compare_receipts"` +} + +// ReplaySdmRefundEvent is one exact refund attribution event from debug_replaySDMBlock. +type ReplaySdmRefundEvent struct { + ClaimingReplayTxIndex uint64 `json:"claiming_replay_tx_index"` + ClaimingTxIndex uint64 `json:"claiming_tx_index"` + Kind string `json:"kind"` + Amount uint64 `json:"amount"` + Address common.Address `json:"address"` + Slot *common.Hash `json:"slot"` + FirstWarmedByReplayTxIndex uint64 `json:"first_warmed_by_replay_tx_index"` + FirstWarmedByTxIndex uint64 `json:"first_warmed_by_tx_index"` +} + +// ReplaySdmTx is the per-transaction output from debug_replaySDMBlock. +type ReplaySdmTx struct { + TxIndex uint64 `json:"tx_index"` + ReplayTxIndex uint64 `json:"replay_tx_index"` + TxHash common.Hash `json:"tx_hash"` + TxType uint64 `json:"tx_type"` + IsDepositTx bool `json:"is_deposit_tx"` + GasUsed uint64 `json:"gas_used"` + OPGasRefundReplay uint64 `json:"op_gas_refund_replay"` + OPGasRefundPayload *uint64 `json:"op_gas_refund_payload"` + OPGasRefundReceipt *uint64 `json:"op_gas_refund_receipt"` + EffectiveGas uint64 `json:"effective_gas"` + RefundBreakdown []ReplaySdmRefundEvent `json:"refund_breakdown"` + Mismatch bool `json:"mismatch"` +} + +// ReplaySdmMismatch is one mismatch row from debug_replaySDMBlock. +type ReplaySdmMismatch struct { + Category string `json:"category"` + BlockNum uint64 `json:"block_num"` + TxIndex *uint64 `json:"tx_index"` + Expected *uint64 `json:"expected"` + Actual *uint64 `json:"actual"` + Message string `json:"message"` +} + +// ReplaySdmSummary is the block-level summary from debug_replaySDMBlock. +type ReplaySdmSummary struct { + BlockNum uint64 `json:"block_num"` + BlockHash common.Hash `json:"block_hash"` + TxCountTotal int `json:"tx_count_total"` + TxCountUser int `json:"tx_count_user"` + SDMTxPresent bool `json:"post_exec_tx_present"` + SDMPayloadEntryCount int `json:"post_exec_payload_entry_count"` + BlockGasUsed uint64 `json:"block_gas_used"` + ReplayRefundTotal uint64 `json:"replay_refund_total"` + PayloadRefundTotal uint64 `json:"payload_refund_total"` + NodeReceiptRefundTotal uint64 `json:"node_receipt_refund_total"` + BlockEffectiveGas uint64 `json:"block_effective_gas"` + MismatchCount int `json:"mismatch_count"` + ReplayMode string `json:"replay_mode"` +} + +// ReplaySdmBlock is the full response from debug_replaySDMBlock. +type ReplaySdmBlock struct { + Config ReplaySdmConfig `json:"config"` + BlockNum uint64 `json:"block_num"` + BlockHash common.Hash `json:"block_hash"` + ParentHash common.Hash `json:"parent_hash"` + SDMTxPresent bool `json:"post_exec_tx_present"` + SDMTxIndex *uint64 `json:"post_exec_tx_index"` + Txs []ReplaySdmTx `json:"txs"` + Mismatches []ReplaySdmMismatch `json:"mismatches"` + Summary ReplaySdmSummary `json:"summary"` +} + +func (c *Client) CallContext(ctx context.Context, method string, params interface{}, out interface{}) error { + req := jsonrpcRequest{ + JSONRPC: "2.0", + Method: method, + Params: params, + ID: 1, + } + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(httpReq) + if err != nil { + return fmt.Errorf("http post: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + var rpcResp jsonrpcResponse + if err := json.Unmarshal(respBody, &rpcResp); err != nil { + return fmt.Errorf("unmarshal response: %w", err) + } + if rpcResp.Error != nil { + return fmt.Errorf("rpc error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message) + } + if out == nil { + return nil + } + if string(rpcResp.Result) == "null" { + return nil + } + if err := json.Unmarshal(rpcResp.Result, out); err != nil { + return fmt.Errorf("unmarshal result: %w", err) + } + return nil +} + +// GetBlockNumber returns the current head block number. +func (c *Client) GetBlockNumber(ctx context.Context) (uint64, error) { + var head string + if err := c.CallContext(ctx, "eth_blockNumber", []interface{}{}, &head); err != nil { + return 0, err + } + return ParseHexUint64(head) +} + +// ClientVersion returns web3_clientVersion. +func (c *Client) ClientVersion(ctx context.Context) (string, error) { + var version string + if err := c.CallContext(ctx, "web3_clientVersion", []interface{}{}, &version); err != nil { + return "", err + } + return version, nil +} + +// ChainID returns the chain ID of the connected RPC. +func (c *Client) ChainID(ctx context.Context) (uint64, error) { + var chainID string + if err := c.CallContext(ctx, "eth_chainId", []interface{}{}, &chainID); err != nil { + return 0, err + } + return ParseHexUint64(chainID) +} + +// GetBlockByNumber fetches a block with full transaction objects. +func (c *Client) GetBlockByNumber(ctx context.Context, blockNum uint64) (*RPCBlock, error) { + var raw json.RawMessage + if err := c.CallContext(ctx, "eth_getBlockByNumber", []interface{}{fmt.Sprintf("0x%x", blockNum), true}, &raw); err != nil { + return nil, err + } + if len(raw) == 0 || string(raw) == "null" { + return nil, fmt.Errorf("block %d not found", blockNum) + } + var block RPCBlock + if err := json.Unmarshal(raw, &block); err != nil { + return nil, fmt.Errorf("decode block %d: %w", blockNum, err) + } + return &block, nil +} + +// GetTransactionReceipt fetches a transaction receipt, including opGasRefund if exposed. +func (c *Client) GetTransactionReceipt(ctx context.Context, txHash common.Hash) (*RPCReceipt, error) { + var raw json.RawMessage + if err := c.CallContext(ctx, "eth_getTransactionReceipt", []interface{}{txHash}, &raw); err != nil { + return nil, err + } + if len(raw) == 0 || string(raw) == "null" { + return nil, fmt.Errorf("receipt %s not found", txHash.Hex()) + } + var receipt RPCReceipt + if err := json.Unmarshal(raw, &receipt); err != nil { + return nil, fmt.Errorf("decode receipt %s: %w", txHash.Hex(), err) + } + return &receipt, nil +} + +// ReplaySdmBlock counterfactually replays a historical block through debug_replaySDMBlock. +func (c *Client) ReplaySdmBlock( + ctx context.Context, + blockNum uint64, + comparePayload bool, + compareReceipts bool, +) (*ReplaySdmBlock, error) { + var replay ReplaySdmBlock + err := c.CallContext( + ctx, + "debug_replaySDMBlock", + []interface{}{ + fmt.Sprintf("0x%x", blockNum), + ReplaySdmBlockOptions{ + ComparePayload: comparePayload, + CompareReceipts: compareReceipts, + }, + }, + &replay, + ) + if err != nil { + return nil, fmt.Errorf("replay block %d: %w", blockNum, err) + } + return &replay, nil +} + +type traceBlockResult struct { + TxHash string `json:"txHash"` + Result json.RawMessage `json:"result"` +} + +// TraceBlock runs storageProfileTracer for the block and returns per-tx results. +func (c *Client) TraceBlock(ctx context.Context, blockNum uint64) ([]TracerResult, error) { + params := []interface{}{ + fmt.Sprintf("0x%x", blockNum), + map[string]string{"tracer": "storageProfileTracer"}, + } + + var blockResults []traceBlockResult + if err := c.CallContext(ctx, "debug_traceBlockByNumber", params, &blockResults); err != nil { + return nil, fmt.Errorf("trace block %d: %w", blockNum, err) + } + + var profiles []TracerResult + for _, br := range blockResults { + var txProfiles []TracerResult + if err := json.Unmarshal(br.Result, &txProfiles); err != nil { + return nil, fmt.Errorf("decode tracer result for %s: %w", br.TxHash, err) + } + profiles = append(profiles, txProfiles...) + } + return profiles, nil +} diff --git a/op-chain-ops/pkg/sdmreplay/types.go b/op-chain-ops/pkg/sdmreplay/types.go new file mode 100644 index 00000000000..b168a316713 --- /dev/null +++ b/op-chain-ops/pkg/sdmreplay/types.go @@ -0,0 +1,134 @@ +package sdmreplay + +// Config controls a replay run. +type Config struct { + RPCURL string + FromBlockSelector string + ToBlockSelector string + FromBlock uint64 + ToBlock uint64 + ComparePayload bool + CompareRPCReceipts bool + FailOnMismatch bool + SkipEmptyBlocks bool + IncludeTrace bool + SummaryOnly bool + Workers int + Format string +} + +// RunConfigRecord describes the replay invocation and selected node mode. +type RunConfigRecord struct { + Type string `json:"type"` + RPC string `json:"rpc"` + FromBlock string `json:"from_block"` + ToBlock string `json:"to_block"` + ResolvedFromBlock uint64 `json:"resolved_from_block"` + ResolvedToBlock uint64 `json:"resolved_to_block"` + HeadBlock uint64 `json:"head_block"` + ChainID uint64 `json:"chain_id"` + ClientVersion string `json:"client_version"` + ReplayMode string `json:"replay_mode"` + ComparePayload bool `json:"compare_payload"` + CompareRPCReceipts bool `json:"compare_rpc_receipts"` + SummaryOnly bool `json:"summary_only"` + IncludeTrace bool `json:"include_trace"` +} + +// TxRecord is one per-transaction JSONL record. +type TxRecord struct { + Type string `json:"type"` + BlockNum uint64 `json:"block_num"` + TxIndex int `json:"tx_index"` + ReplayTxIndex int `json:"replay_tx_index,omitempty"` + TxHash string `json:"tx_hash"` + TxType string `json:"tx_type"` + From string `json:"from"` + To string `json:"to,omitempty"` + GasUsed uint64 `json:"gas_used"` + OPGasRefundReplay uint64 `json:"op_gas_refund_replay"` + OPGasRefundReceipt uint64 `json:"op_gas_refund_receipt"` + OPGasRefundPayload uint64 `json:"op_gas_refund_payload"` + EffectiveGas uint64 `json:"effective_gas"` + RefundRatio float64 `json:"refund_ratio"` + Status uint64 `json:"status"` + IsSDMTx bool `json:"is_sdm_tx"` + IsDepositTx bool `json:"is_deposit_tx"` + Mismatch bool `json:"mismatch"` + SstoreCount uint64 `json:"sstore_count,omitempty"` + SstoreGas uint64 `json:"sstore_gas,omitempty"` + SstoreRatio float64 `json:"sstore_ratio,omitempty"` + StorageHeavy bool `json:"storage_heavy,omitempty"` + WallClockMicros int64 `json:"wall_clock_micros,omitempty"` + CalldataLen int `json:"calldata_len,omitempty"` + AccountingSource string `json:"accounting_source,omitempty"` +} + +// BlockRecord is one per-block JSONL record. +type BlockRecord struct { + Type string `json:"type"` + BlockNum uint64 `json:"block_num"` + BlockHash string `json:"block_hash"` + ParentHash string `json:"parent_hash"` + TxCountTotal int `json:"tx_count_total"` + TxCountUser int `json:"tx_count_user"` + SDMTxPresent bool `json:"sdm_tx_present"` + SDMPayloadEntryCount int `json:"sdm_payload_entry_count"` + BlockGasUsed uint64 `json:"block_gas_used"` + BlockOPGasRefund uint64 `json:"block_op_gas_refund"` + BlockEffectiveGas uint64 `json:"block_effective_gas"` + BlockRefundRatio float64 `json:"block_refund_ratio"` + AvgRefundRatio float64 `json:"avg_refund_ratio"` + NodeReceiptRefundTotal uint64 `json:"node_receipt_refund_total"` + ReplayRefundTotal uint64 `json:"replay_refund_total"` + PayloadRefundTotal uint64 `json:"payload_refund_total"` + MismatchCount int `json:"mismatch_count"` + ReplayMode string `json:"replay_mode"` +} + +// SummaryRecord aggregates the full run. +type SummaryRecord struct { + Type string `json:"type"` + FromBlock uint64 `json:"from_block"` + ToBlock uint64 `json:"to_block"` + BlocksProcessed int `json:"blocks_processed"` + BlocksSkipped int `json:"blocks_skipped"` + BlocksWithSDMTx int `json:"blocks_with_sdm_tx"` + TxCountTotal int `json:"tx_count_total"` + TxCountUser int `json:"tx_count_user"` + TotalGasUsed uint64 `json:"total_gas_used"` + ReplayRefundTotal uint64 `json:"replay_refund_total"` + NodeReceiptRefundTotal uint64 `json:"node_receipt_refund_total"` + PayloadRefundTotal uint64 `json:"payload_refund_total"` + EffectiveGasTotal uint64 `json:"effective_gas_total"` + TotalRefundRatio float64 `json:"total_refund_ratio"` + AvgRefundRatio float64 `json:"avg_refund_ratio"` + MismatchCount int `json:"mismatch_count"` + ReplayMode string `json:"replay_mode"` +} + +// MismatchRecord describes one disagreement between block sources. +type MismatchRecord struct { + Type string `json:"type"` + BlockNum uint64 `json:"block_num"` + TxIndex int `json:"tx_index,omitempty"` + TxHash string `json:"tx_hash,omitempty"` + Category string `json:"category"` + Expected uint64 `json:"expected,omitempty"` + Actual uint64 `json:"actual,omitempty"` + Message string `json:"message"` +} + +// BlockResult holds all emitted records for one block. +type BlockResult struct { + Block BlockRecord + Txs []TxRecord + Mismatches []MismatchRecord +} + +// RangeResult is the structured result before JSONL encoding. +type RangeResult struct { + RunConfig RunConfigRecord + Blocks []BlockResult + Summary SummaryRecord +} From 6bf5a45c02f477584004d2f8549eba1c4caf60b4 Mon Sep 17 00:00:00 2001 From: Anton Evangelatov Date: Mon, 20 Apr 2026 16:59:59 +0300 Subject: [PATCH 6/7] test(op-acceptance-tests): add end-to-end SDM coverage Devstack-backed acceptance tests that boot an op-reth sequencer with --rollup.sdm-enabled, submit a repeated-slot workload, and assert the full SDM pipeline: sequencer-produced 0x7D tx is present with the expected refund entries, receipt-level opGasRefund matches the payload, and debug_replaySDMBlock reproduces the same refunds from state replay. Also covers SDM-disabled baseline, dedup + refund-cap behavior across same-slot and many-slot workloads, and a multi-category smoke test. Mixed-runtime devstack plumbing in op-devstack/sysgo wires the SDMEnabled flag from node spec to op-reth args. --- op-acceptance-tests/tests/sdm/block_test.go | 751 ++++++++++++++++++ op-acceptance-tests/tests/sdm/helpers_test.go | 125 +++ op-acceptance-tests/tests/sdm/init_test.go | 38 + op-devstack/sysgo/mixed_runtime.go | 52 +- op-devstack/sysgo/singlechain_build.go | 2 +- 5 files changed, 947 insertions(+), 21 deletions(-) create mode 100644 op-acceptance-tests/tests/sdm/block_test.go create mode 100644 op-acceptance-tests/tests/sdm/helpers_test.go create mode 100644 op-acceptance-tests/tests/sdm/init_test.go diff --git a/op-acceptance-tests/tests/sdm/block_test.go b/op-acceptance-tests/tests/sdm/block_test.go new file mode 100644 index 00000000000..d4e83ef90eb --- /dev/null +++ b/op-acceptance-tests/tests/sdm/block_test.go @@ -0,0 +1,751 @@ +package sdm + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/ethereum-optimism/optimism/op-chain-ops/pkg/sdmreplay" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-service/bigs" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/txplan" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" +) + +// rpcTransaction is a minimal representation of a transaction from eth_getBlockByNumber +// with full transactions. We use raw JSON to avoid depending on op-geth types for deposit +// fields that may differ between op-geth and op-reth RPC responses. +type rpcTransaction struct { + Hash common.Hash `json:"hash"` + Type hexutil.Uint64 `json:"type"` + From common.Address `json:"from"` + To *common.Address `json:"to"` + Input hexutil.Bytes `json:"input"` + Gas hexutil.Uint64 `json:"gas"` + IsSystemTx *bool `json:"isSystemTx,omitempty"` // op-geth style + IsSystemTransaction *bool `json:"isSystemTransaction,omitempty"` // op-reth style + SourceHash *common.Hash `json:"sourceHash,omitempty"` +} + +// rpcBlock is a minimal representation of a block from eth_getBlockByNumber(n, true). +type rpcBlock struct { + Number hexutil.Uint64 `json:"number"` + Hash common.Hash `json:"hash"` + GasUsed hexutil.Uint64 `json:"gasUsed"` + Transactions []rpcTransaction `json:"transactions"` +} + +type replaySdmPayloadEntry struct { + Index uint64 `json:"index"` + GasRefund uint64 `json:"gas_refund"` +} + +type replaySdmPayload struct { + Version uint64 `json:"version"` + Entries []replaySdmPayloadEntry `json:"gas_refund_entries"` +} + +type replaySdmRefundEvent struct { + ClaimingReplayTxIndex uint64 `json:"claiming_replay_tx_index"` + ClaimingTxIndex uint64 `json:"claiming_tx_index"` + Kind string `json:"kind"` + Amount uint64 `json:"amount"` + Address common.Address `json:"address"` + Slot *common.Hash `json:"slot"` + FirstWarmedByReplayTxIndex uint64 `json:"first_warmed_by_replay_tx_index"` + FirstWarmedByTxIndex uint64 `json:"first_warmed_by_tx_index"` +} + +type replaySdmTx struct { + TxIndex uint64 `json:"tx_index"` + ReplayTxIndex uint64 `json:"replay_tx_index"` + TxHash common.Hash `json:"tx_hash"` + TxType uint64 `json:"tx_type"` + IsDepositTx bool `json:"is_deposit_tx"` + GasUsed uint64 `json:"gas_used"` + RawGasUsed uint64 `json:"raw_gas_used"` + CanonicalGasUsed uint64 `json:"canonical_gas_used"` + OPGasRefundReplay uint64 `json:"op_gas_refund_replay"` + OPGasRefundPayload *uint64 `json:"op_gas_refund_payload"` + OPGasRefundReceipt *uint64 `json:"op_gas_refund_receipt"` + EffectiveGas uint64 `json:"effective_gas"` + RefundBreakdown []replaySdmRefundEvent `json:"refund_breakdown"` + Mismatch bool `json:"mismatch"` +} + +type replaySdmMismatch struct { + Category string `json:"category"` + BlockNum uint64 `json:"block_num"` + TxIndex *uint64 `json:"tx_index"` + Expected *uint64 `json:"expected"` + Actual *uint64 `json:"actual"` + Message string `json:"message"` +} + +type replaySdmSummary struct { + BlockNum uint64 `json:"block_num"` + BlockHash common.Hash `json:"block_hash"` + TxCountTotal int `json:"tx_count_total"` + TxCountUser int `json:"tx_count_user"` + PostExecTxPresent bool `json:"post_exec_tx_present"` + PostExecPayloadEntryCount int `json:"post_exec_payload_entry_count"` + BlockGasUsed uint64 `json:"block_gas_used"` + BlockRawGasUsed uint64 `json:"block_raw_gas_used"` + ReplayRefundTotal uint64 `json:"replay_refund_total"` + PayloadRefundTotal uint64 `json:"payload_refund_total"` + NodeReceiptRefundTotal uint64 `json:"node_receipt_refund_total"` + BlockEffectiveGas uint64 `json:"block_effective_gas"` + MismatchCount int `json:"mismatch_count"` + ReplayMode string `json:"replay_mode"` +} + +type replaySdmBlock struct { + BlockNum uint64 `json:"block_num"` + BlockHash common.Hash `json:"block_hash"` + ParentHash common.Hash `json:"parent_hash"` + PostExecTxPresent bool `json:"post_exec_tx_present"` + PostExecTxIndex *uint64 `json:"post_exec_tx_index"` + EmbeddedPayload *replaySdmPayload `json:"embedded_payload"` + SynthesizedPayload replaySdmPayload `json:"synthesized_payload"` + SynthesizedPayloadBytes hexutil.Bytes `json:"synthesized_payload_bytes"` + Txs []replaySdmTx `json:"txs"` + Mismatches []replaySdmMismatch `json:"mismatches"` + Summary replaySdmSummary `json:"summary"` +} + +// getBlockWithTxs fetches a block by number with full transaction objects via raw JSON RPC. +func getBlockWithTxs(t devtest.T, l2EL *dsl.L2ELNode, blockNum uint64) *rpcBlock { + rpcClient := l2EL.Escape().L2EthClient().RPC() + var raw json.RawMessage + err := rpcClient.CallContext(context.Background(), &raw, "eth_getBlockByNumber", + fmt.Sprintf("0x%x", blockNum), true) + t.Require().NoError(err, "eth_getBlockByNumber RPC failed for block %d", blockNum) + t.Require().NotNil(raw, "block %d not found", blockNum) + + var block rpcBlock + err = json.Unmarshal(raw, &block) + t.Require().NoError(err, "failed to unmarshal block %d", blockNum) + return &block +} + +func replayBlockWithSDM(t devtest.T, l2EL *dsl.L2ELNode, blockNum uint64) *replaySdmBlock { + rpcClient := l2EL.Escape().L2EthClient().RPC() + var raw json.RawMessage + err := rpcClient.CallContext(context.Background(), &raw, "debug_replaySDMBlock", + fmt.Sprintf("0x%x", blockNum), + map[string]bool{ + "compare_payload": true, + "compare_receipts": true, + }, + ) + t.Require().NoError(err, "debug_replaySDMBlock RPC failed for block %d", blockNum) + t.Require().NotNil(raw, "replay result for block %d must not be nil", blockNum) + + var replay replaySdmBlock + err = json.Unmarshal(raw, &replay) + t.Require().NoError(err, "failed to unmarshal replay result for block %d", blockNum) + return &replay +} + +// findPostExecTransaction searches for the post-exec tx anywhere in the block. +// Returns the transaction and its position if found, nil/-1 otherwise. +// The post-exec tx is identified purely by type 0x7D. +func findPostExecTransaction(block *rpcBlock) (*rpcTransaction, int) { + for i := range block.Transactions { + tx := &block.Transactions[i] + if uint64(tx.Type) != 0x7d { + continue + } + return tx, i + } + return nil, -1 +} + +func mustFindReplayTxByHash(t devtest.T, replay *replaySdmBlock, txHash common.Hash) *replaySdmTx { + for i := range replay.Txs { + if replay.Txs[i].TxHash == txHash { + return &replay.Txs[i] + } + } + + t.Require().FailNowf("replay tx missing", "tx %s not found in replay for block %d", txHash, replay.BlockNum) + return nil +} + +// submitTxWithoutWait sends a transaction to the mempool without waiting for inclusion. +// Returns the PlannedTx whose Included field can be evaluated later. +// The caller must provide a nonce to avoid the default PendingNonce lookup racing between txs. +func submitTxWithoutWait( + t devtest.T, + alice *dsl.EOA, + nonce uint64, + opts ...txplan.Option, +) *txplan.PlannedTx { + combined := append([]txplan.Option{ + alice.Plan(), + txplan.WithNonce(nonce), + }, opts...) + ptx := txplan.NewPlannedTx(combined...) + _, err := ptx.Submitted.Eval(t.Ctx()) + t.Require().NoError(err, "failed to submit tx with nonce %d", nonce) + return ptx +} + +type includedTx struct { + receipt *types.Receipt + txIndex int + blockNum uint64 +} + +func mustFindRepeatedSlotBlock( + t devtest.T, + sys *sdmRethSystem, + minUserTxs int, + maxAttempts int, +) (*rpcBlock, []includedTx, uint64) { + l := t.Logger() + + for attempt := 1; attempt <= maxAttempts; attempt++ { + alice := sys.FunderL2.NewFundedEOA(eth.OneEther) + stateBloatAddr := deployContract(t, alice, stateBloatBin) + + const batchSize = 50 + const slotCount = 20 + startNonce := alice.PendingNonce() + plannedTxs := make([]*txplan.PlannedTx, 0, batchSize) + + l.Info("Submitting repeated-slot workload", + "attempt", attempt, + "alice", alice.Address(), + "contract", stateBloatAddr, + "startNonce", startNonce, + "batchSize", batchSize, + "slotCount", slotCount) + + for i := 0; i < batchSize; i++ { + nonce := startNonce + uint64(i) + plannedTxs = append(plannedTxs, submitTxWithoutWait( + t, + alice, + nonce, + txplan.WithTo(addrPtr(stateBloatAddr)), + txplan.WithData(encodeRun(slotCount)), + txplan.WithGasLimit(1_000_000), + )) + } + + blockTxs := make(map[uint64][]includedTx) + for i, ptx := range plannedTxs { + receipt, err := ptx.Included.Eval(t.Ctx()) + t.Require().NoError(err, "attempt %d tx %d: failed to get receipt", attempt, i) + t.Require().Equal(types.ReceiptStatusSuccessful, receipt.Status, + "attempt %d tx %d: must succeed", attempt, i) + + itx := includedTx{receipt: receipt, txIndex: i, blockNum: bigs.Uint64Strict(receipt.BlockNumber)} + blockTxs[itx.blockNum] = append(blockTxs[itx.blockNum], itx) + } + + var targetBlockNum uint64 + var targetIncluded []includedTx + for blockNum, txs := range blockTxs { + if len(txs) > len(targetIncluded) { + targetBlockNum = blockNum + targetIncluded = txs + } + } + if len(targetIncluded) < minUserTxs { + l.Warn("Repeated-slot workload did not produce a dense-enough block", + "attempt", attempt, + "requiredUserTxs", minUserTxs, + "bestUserTxs", len(targetIncluded), + "bestBlock", targetBlockNum) + continue + } + + block := getBlockWithTxs(t, sys.L2EL, targetBlockNum) + t.Require().Greater(len(block.Transactions), 0, "block must have at least one transaction") + t.Require().Equal(uint64(types.DepositTxType), uint64(block.Transactions[0].Type), + "position 0 must be a deposit tx (L1 info)") + return block, targetIncluded, targetBlockNum + } + + t.Require().FailNowf("repeated-slot workload failed", + "no block with at least %d user txs found after %d attempts", minUserTxs, maxAttempts) + return nil, nil, 0 +} + +func TestSDMDisabledLegacyAccounting(gt *testing.T) { + t := devtest.SerialT(gt) + sys := newSDMRethSystem(t, false) + verifyOpReth(t, sys.L2EL) + + block, included, targetBlockNum := mustFindRepeatedSlotBlock(t, sys, 2, 3) + t.Require().GreaterOrEqual(len(included), 2, "target block must contain multiple user txs") + + postExecTx, _ := findPostExecTransaction(block) + t.Require().Nil(postExecTx, "SDM-disabled sequencer must not include a post-exec tx") + + for _, itx := range included { + refund := getOPGasRefund(t, sys.L2EL, itx.receipt.TxHash) + t.Require().Zero(refund, "legacy block %d tx %s must not expose opGasRefund", + targetBlockNum, itx.receipt.TxHash) + } +} + +func TestSDMEnabledCanonicalGasAccounting(gt *testing.T) { + t := devtest.SerialT(gt) + sys := newSDMRethSystem(t, true) + verifyOpReth(t, sys.L2EL) + + block, included, targetBlockNum := mustFindRepeatedSlotBlock(t, sys, 2, 3) + postExecTx, postExecPos := findPostExecTransaction(block) + t.Require().NotNil(postExecTx, "SDM-enabled sequencer must include a post-exec tx") + t.Require().Greater(len(postExecTx.Input), 0, "post-exec tx input must not be empty") + t.Require().Equal(uint64(0x7d), uint64(postExecTx.Type), "post-exec tx type must be 0x7D") + + payload, err := sdmreplay.DecodePayload(postExecTx.Input) + t.Require().NoError(err, "post-exec payload must decode") + t.Require().Equal(uint64(1), payload.Version, "post-exec payload version must be 1") + t.Require().NotEmpty(payload.GasRefundEntries, "post-exec payload must be non-empty for repeated-slot workload") + + receiptByHash := make(map[common.Hash]*types.Receipt, len(included)) + hasNonZeroReceiptRefund := false + for _, itx := range included { + receiptByHash[itx.receipt.TxHash] = itx.receipt + refund := getOPGasRefund(t, sys.L2EL, itx.receipt.TxHash) + if refund > 0 { + hasNonZeroReceiptRefund = true + } + } + t.Require().True(hasNonZeroReceiptRefund, "at least one repeated-slot tx must have non-zero opGasRefund") + + for _, entry := range payload.GasRefundEntries { + t.Require().Less(int(entry.Index), len(block.Transactions), "payload index must be in block range") + targetTx := block.Transactions[entry.Index] + t.Require().NotEqual(uint64(types.DepositTxType), uint64(targetTx.Type), "payload must not target deposits") + t.Require().NotEqual(uint64(0x7d), uint64(targetTx.Type), "payload must not target the SDM tx itself") + + refund := getOPGasRefund(t, sys.L2EL, targetTx.Hash) + t.Require().Equal(entry.GasRefund, refund, + "payload refund must match receipt opGasRefund for tx index %d", entry.Index) + } + + replay := replayBlockWithSDM(t, sys.L2EL, targetBlockNum) + t.Require().Equal(targetBlockNum, replay.BlockNum, "replay must target the selected block") + t.Require().Equal(block.Hash, replay.BlockHash, "replay block hash must match source block") + t.Require().True(replay.PostExecTxPresent, "replay must report the post-exec tx in the source block") + t.Require().NotNil(replay.PostExecTxIndex, "replay must report the post-exec tx index") + t.Require().Equal(uint64(postExecPos), *replay.PostExecTxIndex, "replay post-exec tx index must match source block") + t.Require().Equal(len(block.Transactions)-1, len(replay.Txs), + "replay must strip the post-exec tx and preserve the remaining tx ordering") + t.Require().Empty(replay.Mismatches, "canonical post-exec block should replay without mismatches") + t.Require().Equal(len(replay.SynthesizedPayload.Entries), replay.Summary.PostExecPayloadEntryCount, + "summary payload entry count must match synthesized payload") + + expectedOriginalIndexes := make([]uint64, 0, len(block.Transactions)-1) + for i := range block.Transactions { + if i == postExecPos { + continue + } + expectedOriginalIndexes = append(expectedOriginalIndexes, uint64(i)) + } + + replayRefundByIndex := make(map[uint64]uint64, len(replay.Txs)) + hasReplayRefund := false + for i, tx := range replay.Txs { + t.Require().Equal(uint64(i), tx.ReplayTxIndex, "replay tx indexes must be sequential") + t.Require().Equal(expectedOriginalIndexes[i], tx.TxIndex, + "replay tx %d must preserve original block index", i) + + sourceTx := block.Transactions[tx.TxIndex] + t.Require().Equal(sourceTx.Hash, tx.TxHash, "replay tx hash must match source tx at index %d", tx.TxIndex) + t.Require().Equal(uint64(sourceTx.Type), tx.TxType, "replay tx type must match source tx at index %d", tx.TxIndex) + t.Require().Equal(uint64(types.DepositTxType) == uint64(sourceTx.Type), tx.IsDepositTx, + "deposit classification must match source tx at index %d", tx.TxIndex) + t.Require().Equal(tx.GasUsed, tx.CanonicalGasUsed, + "replay gas_used must already be canonical at tx index %d", tx.TxIndex) + t.Require().Equal(tx.EffectiveGas, tx.CanonicalGasUsed, + "replay effective_gas must alias canonical gas at tx index %d", tx.TxIndex) + t.Require().Equal(tx.RawGasUsed, tx.CanonicalGasUsed+tx.OPGasRefundReplay, + "raw gas must equal canonical gas plus refund at tx index %d", tx.TxIndex) + + if tx.OPGasRefundReplay > 0 { + hasReplayRefund = true + } + replayRefundByIndex[tx.TxIndex] = tx.OPGasRefundReplay + + if tx.OPGasRefundPayload != nil { + t.Require().Equal(*tx.OPGasRefundPayload, tx.OPGasRefundReplay, + "payload refund must match replay refund at tx index %d", tx.TxIndex) + } + if tx.OPGasRefundReceipt != nil { + t.Require().Equal(*tx.OPGasRefundReceipt, tx.OPGasRefundReplay, + "receipt refund must match replay refund at tx index %d", tx.TxIndex) + } + + if receipt, ok := receiptByHash[tx.TxHash]; ok { + t.Require().Equal(receipt.GasUsed, tx.CanonicalGasUsed, + "receipt gasUsed must already be canonical for tx %s", tx.TxHash) + if refund := getOPGasRefund(t, sys.L2EL, tx.TxHash); refund > 0 { + t.Require().Greater(tx.RawGasUsed, receipt.GasUsed, + "raw gas must exceed receipt gas when refund is non-zero for tx %s", tx.TxHash) + } + } + } + t.Require().True(hasReplayRefund, "replay must produce non-zero refunds for repeated-slot block") + + var totalReplayRefund uint64 + for _, entry := range replay.SynthesizedPayload.Entries { + sourceTx := block.Transactions[entry.Index] + refund := getOPGasRefund(t, sys.L2EL, sourceTx.Hash) + t.Require().Equal(refund, entry.GasRefund, + "synthesized payload refund must match receipt opGasRefund for tx index %d", entry.Index) + t.Require().Equal(entry.GasRefund, replayRefundByIndex[entry.Index], + "synthesized payload refund must match replay tx refund for tx index %d", entry.Index) + totalReplayRefund += entry.GasRefund + } + t.Require().Equal(totalReplayRefund, replay.Summary.ReplayRefundTotal, + "summary replay refund total must match synthesized payload") + t.Require().Equal(totalReplayRefund, replay.Summary.PayloadRefundTotal, + "summary payload refund total must match synthesized payload") + t.Require().Equal(totalReplayRefund, replay.Summary.NodeReceiptRefundTotal, + "summary receipt refund total must match synthesized payload") + t.Require().Equal(uint64(block.GasUsed), replay.Summary.BlockGasUsed, + "block gasUsed must already be canonical") + t.Require().Equal(replay.Summary.BlockRawGasUsed, + replay.Summary.BlockGasUsed+replay.Summary.ReplayRefundTotal, + "raw block gas must equal canonical block gas plus total refund") + + t.Logger().Info("TestSDMEnabledCanonicalGasAccounting passed", + "block_num", targetBlockNum, + "block_hash", block.Hash, + "user_txs", len(included), + "post_exec_tx_index", postExecPos, + "payload_entries", len(payload.GasRefundEntries), + "replay_refund_total", replay.Summary.ReplayRefundTotal, + "block_gas_used", replay.Summary.BlockGasUsed, + "block_raw_gas_used", replay.Summary.BlockRawGasUsed) +} + +func TestSDMRepeatedSlotDedupAndRefundCapBehavior(gt *testing.T) { + t := devtest.SerialT(gt) + sys := newSDMRethSystem(t, true) + verifyOpReth(t, sys.L2EL) + + const ( + sameSlotTouches = 100 + manySlotTouches = 100 + maxAttempts = 3 + ) + + for attempt := 1; attempt <= maxAttempts; attempt++ { + alice := sys.FunderL2.NewFundedEOA(eth.OneEther) + contract := deployContract(t, alice, slotTouchBin) + startNonce := alice.PendingNonce() + + planned := []*txplan.PlannedTx{ + submitTxWithoutWait( + t, + alice, + startNonce, + txplan.WithTo(addrPtr(contract)), + txplan.WithData(encodeHitSameSlot(1)), + txplan.WithGasLimit(300_000), + ), + submitTxWithoutWait( + t, + alice, + startNonce+1, + txplan.WithTo(addrPtr(contract)), + txplan.WithData(encodeHitSameSlot(sameSlotTouches)), + txplan.WithGasLimit(1_500_000), + ), + submitTxWithoutWait( + t, + alice, + startNonce+2, + txplan.WithTo(addrPtr(contract)), + txplan.WithData(encodeHitManySlots(manySlotTouches)), + txplan.WithGasLimit(3_000_000), + ), + submitTxWithoutWait( + t, + alice, + startNonce+3, + txplan.WithTo(addrPtr(contract)), + txplan.WithData(encodeHitManySlots(manySlotTouches)), + txplan.WithGasLimit(3_000_000), + ), + } + + receipts := make([]*types.Receipt, len(planned)) + for i, ptx := range planned { + receipt, err := ptx.Included.Eval(t.Ctx()) + t.Require().NoError(err, "attempt %d tx %d: failed to get receipt", attempt, i) + t.Require().Equal(types.ReceiptStatusSuccessful, receipt.Status, + "attempt %d tx %d: must succeed", attempt, i) + receipts[i] = receipt + } + + sameWarmBlock := bigs.Uint64Strict(receipts[0].BlockNumber) + sameClaimBlock := bigs.Uint64Strict(receipts[1].BlockNumber) + manyWarmBlock := bigs.Uint64Strict(receipts[2].BlockNumber) + manyClaimBlock := bigs.Uint64Strict(receipts[3].BlockNumber) + if sameWarmBlock != sameClaimBlock || manyWarmBlock != manyClaimBlock { + t.Logger().Warn("slot-touch workload pairs split across blocks; retrying", + "attempt", attempt, + "sameWarmBlock", sameWarmBlock, + "sameClaimBlock", sameClaimBlock, + "manyWarmBlock", manyWarmBlock, + "manyClaimBlock", manyClaimBlock) + continue + } + + sameRefund := getOPGasRefund(t, sys.L2EL, receipts[1].TxHash) + sameReplay := replayBlockWithSDM(t, sys.L2EL, sameClaimBlock) + sameTx := mustFindReplayTxByHash(t, sameReplay, receipts[1].TxHash) + t.Require().Equal(sameRefund, sameTx.OPGasRefundReplay, + "replay refund must match receipt refund for repeated same-slot tx") + + var sameSlotSstoreEvents int + var sameSlotSstoreRefund uint64 + for i, event := range sameTx.RefundBreakdown { + if event.Kind != "warm_sstore" { + continue + } + t.Require().Equal(uint64(2100), event.Amount, "same-slot warm SSTORE event %d must be 2100 gas", i) + t.Require().NotNil(event.Slot, "same-slot warm SSTORE event %d must identify the touched slot", i) + sameSlotSstoreEvents++ + sameSlotSstoreRefund += event.Amount + } + t.Require().Equal(1, sameSlotSstoreEvents, + "repeating the same warmed storage slot %d times should only produce one warm SSTORE refund event", sameSlotTouches) + t.Require().Equal(uint64(2100), sameSlotSstoreRefund, + "repeating the same warmed storage slot should only rebate one warm SSTORE access") + + manyRefund := getOPGasRefund(t, sys.L2EL, receipts[3].TxHash) + manyReplay := replayBlockWithSDM(t, sys.L2EL, manyClaimBlock) + manyTx := mustFindReplayTxByHash(t, manyReplay, receipts[3].TxHash) + t.Require().Equal(manyRefund, manyTx.OPGasRefundReplay, + "replay refund must match receipt refund for many-slot tx") + + var totalBreakdown uint64 + var manySlotSstoreEvents int + var manySlotSstoreRefund uint64 + for i, event := range manyTx.RefundBreakdown { + totalBreakdown += event.Amount + if event.Kind != "warm_sstore" { + continue + } + t.Require().Equal(uint64(2100), event.Amount, "warm SSTORE refund event %d must be 2100 gas", i) + t.Require().NotNil(event.Slot, "warm SSTORE refund event %d must identify the warmed slot", i) + manySlotSstoreEvents++ + manySlotSstoreRefund += event.Amount + } + t.Require().Equal(manySlotTouches, manySlotSstoreEvents, + "touching %d distinct warmed slots should produce %d warm SSTORE refund events", manySlotTouches, manySlotTouches) + t.Require().Equal(uint64(2100*manySlotTouches), manySlotSstoreRefund, + "distinct warmed slots should rebate 2100 gas each") + t.Require().Equal(manyRefund, totalBreakdown, + "sum of many-slot refund events must equal the receipt-level refund") + t.Require().Greater(manyRefund, manyTx.RawGasUsed/5, + "SDM refunds are not capped at the EIP-3529 20%% rule once applied canonically") + t.Require().Equal(receipts[3].GasUsed, manyTx.CanonicalGasUsed, + "receipt gasUsed must already be canonical for many-slot tx") + t.Require().Equal(manyTx.RawGasUsed, manyTx.CanonicalGasUsed+manyRefund, + "raw gas must equal canonical gas plus SDM refund for many-slot tx") + + return + } + + t.Require().FailNowf("slot-touch workload failed", + "no attempt produced both same-slot and many-slot warm/claim pairs in the same block after %d attempts", maxAttempts) +} + +// TestSDMMultiCategoryBatch submits transactions from multiple categories in a single burst, +// without calling .Eval() between submissions. This tests that different tx types +// (transfer, compute, events, state writes) can be batched into the same block. +func TestSDMMultiCategoryBatchSmoke(gt *testing.T) { + t := devtest.SerialT(gt) + sys := newSDMRethSystem(t, true) + l := t.Logger() + + clientVersion := verifyOpReth(t, sys.L2EL) + l.Info("Verified op-reth", "version", clientVersion) + + // Fund alice + alice := sys.FunderL2.NewFundedEOA(eth.OneEther) + bob := sys.FunderL2.NewFundedEOA(eth.ZeroWei) + + // Deploy contracts + computeHeavyAddr := deployContract(t, alice, computeHeavyBin) + stateBloatAddr := deployContract(t, alice, stateBloatBin) + eventLoggerAddr := alice.DeployEventLogger() + l.Info("Deployed contracts", + "computeHeavy", computeHeavyAddr, + "stateBloat", stateBloatAddr, + "eventLogger", eventLoggerAddr) + + // Submit a diverse batch of transactions without waiting between them + startNonce := alice.PendingNonce() + type batchEntry struct { + category string + ptx *txplan.PlannedTx + } + var batch []batchEntry + + categories := []struct { + name string + opts func(nonce uint64) []txplan.Option + }{ + { + name: "eoa_transfer", + opts: func(nonce uint64) []txplan.Option { + return []txplan.Option{ + txplan.WithTo(addrPtr(bob.Address())), + txplan.WithValue(eth.OneHundredthEther), + } + }, + }, + { + name: "compute_heavy", + opts: func(nonce uint64) []txplan.Option { + return []txplan.Option{ + txplan.WithTo(addrPtr(computeHeavyAddr)), + txplan.WithData(encodeRun(200)), + txplan.WithGasLimit(200_000), + } + }, + }, + { + name: "event_emitter", + opts: func(nonce uint64) []txplan.Option { + return []txplan.Option{ + txplan.WithTo(addrPtr(eventLoggerAddr)), + txplan.WithData(encodeEmitLog(3, 64)), + txplan.WithGasLimit(200_000), + } + }, + }, + { + name: "state_bloat", + opts: func(nonce uint64) []txplan.Option { + return []txplan.Option{ + txplan.WithTo(addrPtr(stateBloatAddr)), + txplan.WithData(encodeRun(20)), + txplan.WithGasLimit(500_000), + } + }, + }, + // Second round of same categories to trigger cross-tx warming + { + name: "compute_heavy_2", + opts: func(nonce uint64) []txplan.Option { + return []txplan.Option{ + txplan.WithTo(addrPtr(computeHeavyAddr)), + txplan.WithData(encodeRun(200)), + txplan.WithGasLimit(200_000), + } + }, + }, + { + name: "event_emitter_2", + opts: func(nonce uint64) []txplan.Option { + return []txplan.Option{ + txplan.WithTo(addrPtr(eventLoggerAddr)), + txplan.WithData(encodeEmitLog(3, 64)), + txplan.WithGasLimit(200_000), + } + }, + }, + { + name: "state_bloat_2", + opts: func(nonce uint64) []txplan.Option { + return []txplan.Option{ + txplan.WithTo(addrPtr(stateBloatAddr)), + txplan.WithData(encodeRun(20)), + txplan.WithGasLimit(500_000), + } + }, + }, + { + name: "eoa_transfer_2", + opts: func(nonce uint64) []txplan.Option { + return []txplan.Option{ + txplan.WithTo(addrPtr(bob.Address())), + txplan.WithValue(eth.OneHundredthEther), + } + }, + }, + } + + l.Info("Submitting batch", "txCount", len(categories), "startNonce", startNonce) + + for i, cat := range categories { + nonce := startNonce + uint64(i) + ptx := submitTxWithoutWait(t, alice, nonce, cat.opts(nonce)...) + batch = append(batch, batchEntry{category: cat.name, ptx: ptx}) + l.Info("Submitted", "category", cat.name, "nonce", nonce) + } + + // Wait for all to be included + blockCounts := make(map[uint64]int) + for i, entry := range batch { + receipt, err := entry.ptx.Included.Eval(t.Ctx()) + t.Require().NoError(err, "tx %d (%s): failed to get receipt", i, entry.category) + t.Require().Equal(types.ReceiptStatusSuccessful, receipt.Status, + "tx %d (%s): must succeed", i, entry.category) + + blockNum := bigs.Uint64Strict(receipt.BlockNumber) + blockCounts[blockNum]++ + + refund := getOPGasRefund(t, sys.L2EL, receipt.TxHash) + l.Info("Included", + "category", entry.category, + "block", blockNum, + "txIdx", receipt.TransactionIndex, + "gasUsed", receipt.GasUsed, + "opGasRefund", refund) + } + + // Report distribution + l.Info("Batch distribution", "numBlocks", len(blockCounts)) + maxInBlock := 0 + var maxBlockNum uint64 + for blockNum, count := range blockCounts { + l.Info("Block", "number", blockNum, "txCount", count) + if count > maxInBlock { + maxInBlock = count + maxBlockNum = blockNum + } + } + + if maxInBlock >= 2 { + l.Info("Multi-tx block found — inspecting for SDM tx", + "block", maxBlockNum, "txCount", maxInBlock) + + block := getBlockWithTxs(t, sys.L2EL, maxBlockNum) + postExecTx, postExecPos := findPostExecTransaction(block) + if postExecTx != nil { + l.Info("Post-exec transaction present in multi-category block!", + "block", maxBlockNum, + "position", postExecPos, + "inputLen", len(postExecTx.Input)) + } else { + l.Info("No post-exec tx in block (fork not active yet)", + "block", maxBlockNum) + } + } else { + l.Warn("All txs landed in separate blocks — no cross-tx warming possible") + } +} + +// addrPtr returns a pointer to the given address (helper for txplan.WithTo). +func addrPtr(addr common.Address) *common.Address { + return &addr +} diff --git a/op-acceptance-tests/tests/sdm/helpers_test.go b/op-acceptance-tests/tests/sdm/helpers_test.go new file mode 100644 index 00000000000..8d12af4f451 --- /dev/null +++ b/op-acceptance-tests/tests/sdm/helpers_test.go @@ -0,0 +1,125 @@ +package sdm + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strings" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-service/txplan" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/lmittmann/w3" +) + +// ComputeHeavy: run(uint256 n) loops keccak256 n times (pure computation). +const computeHeavyBin = "6080604052348015600e575f5ffd5b506101908061001c5f395ff3fe608060405234801561000f575f5ffd5b5060043610610029575f3560e01c8063a444f5e91461002d575b5f5ffd5b610047600480360381019061004291906100ec565b610049565b005b5f7f66a80b61b29ec044d14c4c8c613e762ba1fb8eeb0c454d1ee00ed6dedaa5b5c590505f5f90505b828110156100b0578160405160200161008b9190610140565b6040516020818303038152906040528051906020012091508080600101915050610072565b505050565b5f5ffd5b5f819050919050565b6100cb816100b9565b81146100d5575f5ffd5b50565b5f813590506100e6816100c2565b92915050565b5f60208284031215610101576101006100b5565b5b5f61010e848285016100d8565b91505092915050565b5f819050919050565b5f819050919050565b61013a61013582610117565b610120565b82525050565b5f61014b8284610129565b6020820191508190509291505056fea264697066735822122013cd314931f1991e7797e220c9553bb73dfef407d4d266dd8b2553907d5bc14364736f6c634300081c0033" + +// StateBloat: run(uint256 n) writes n unique SSTORE slots (state growth). +const stateBloatBin = "6080604052348015600e575f5ffd5b5060f28061001b5f395ff3fe6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063a444f5e914602a575b5f5ffd5b60406004803603810190603c91906096565b6042565b005b5f5f90505b8181101560605760018101815580806001019150506047565b5050565b5f5ffd5b5f819050919050565b6078816068565b81146081575f5ffd5b50565b5f813590506090816071565b92915050565b5f6020828403121560a85760a76064565b5b5f60b3848285016084565b9150509291505056fea2646970667358221220fb9ef6750b6ac6ded2dd901595e50b6daefe24726b41a0346f3a36ac6fcf5f8264736f6c634300081c0033" + +// SlotTouch: repeatedly touches either one storage slot or many distinct slots. +const slotTouchBin = "6080604052348015600e575f5ffd5b5061010e8061001c5f395ff3fe6080604052348015600e575f5ffd5b50600436106030575f3560e01c80637ebfc845146034578063f1ac3593146045575b5f5ffd5b6043603f366004609e565b6054565b005b60436050366004609e565b6073565b5f5b81811015606f57606681600160b4565b5f556001016056565b5050565b5f5b81811015606f57608581600160b4565b5f82815260016020819052604090912091909155016075565b5f6020828403121560ad575f5ffd5b5035919050565b8082018082111560d257634e487b7160e01b5f52601160045260245ffd5b9291505056fea264697066735822122032537b9a0375aae151d7e212351ad336fe397942ba90c7fb77682efb97e309f564736f6c63430008210033" + +var ( + funcRun = w3.MustNewFunc("run(uint256)", "") + funcEmitLog = w3.MustNewFunc("emitLog(bytes32[],bytes)", "") + funcHitSameSlot = w3.MustNewFunc("hitSameSlot(uint256)", "") + funcHitManySlots = w3.MustNewFunc("hitManySlots(uint256)", "") +) + +// verifyOpReth checks the L2 execution layer client is op-reth by calling +// web3_clientVersion via the L2EthClient's RPC and asserting it contains "reth". +func verifyOpReth(t devtest.T, l2EL *dsl.L2ELNode) string { + rpcClient := l2EL.Escape().L2EthClient().RPC() + var clientVersion string + err := rpcClient.CallContext(context.Background(), &clientVersion, "web3_clientVersion") + t.Require().NoError(err, "web3_clientVersion RPC failed — cannot verify EL client") + + lower := strings.ToLower(clientVersion) + t.Require().True( + strings.Contains(lower, "reth"), + "FATAL: Expected op-reth execution client, but got: %q. "+ + "This test MUST run on op-reth. "+ + "Set DEVSTACK_L2EL_KIND=op-reth or ensure op-reth binary is available.", + clientVersion, + ) + t.Require().False( + strings.Contains(lower, "geth"), + "FATAL: Detected op-geth (%q) but this test requires op-reth.", clientVersion, + ) + + return clientVersion +} + +// getOPGasRefund reads the opGasRefund field from a transaction receipt via +// raw JSON RPC. Returns 0 if the field is not present. +func getOPGasRefund(t devtest.T, l2EL *dsl.L2ELNode, txHash common.Hash) uint64 { + rpcClient := l2EL.Escape().L2EthClient().RPC() + var raw json.RawMessage + err := rpcClient.CallContext(context.Background(), &raw, "eth_getTransactionReceipt", txHash) + if err != nil || raw == nil { + return 0 + } + + var result struct { + OPGasRefund *hexutil.Uint64 `json:"opGasRefund"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return 0 + } + if result.OPGasRefund != nil { + return uint64(*result.OPGasRefund) + } + return 0 +} + +func deployContract(t devtest.T, eoa *dsl.EOA, hexBytecode string) common.Address { + tx := txplan.NewPlannedTx(eoa.Plan(), txplan.WithData(common.FromHex(hexBytecode))) + res, err := tx.Included.Eval(t.Ctx()) + t.Require().NoError(err, "failed to deploy contract") + return res.ContractAddress +} + +func encodeRun(n uint64) []byte { + data, err := funcRun.EncodeArgs(new(big.Int).SetUint64(n)) + if err != nil { + panic(fmt.Sprintf("failed to encode run(%d): %v", n, err)) + } + return data +} + +func encodeEmitLog(topicCount int, dataLen int) []byte { + topics := make([][32]byte, topicCount) + for i := range topics { + topics[i] = [32]byte{byte(i + 1)} + } + opaqueData := make([]byte, dataLen) + for i := range opaqueData { + opaqueData[i] = byte(i % 256) + } + data, err := funcEmitLog.EncodeArgs(topics, opaqueData) + if err != nil { + panic(fmt.Sprintf("failed to encode emitLog: %v", err)) + } + return data +} + +func encodeHitSameSlot(n uint64) []byte { + data, err := funcHitSameSlot.EncodeArgs(new(big.Int).SetUint64(n)) + if err != nil { + panic(fmt.Sprintf("failed to encode hitSameSlot(%d): %v", n, err)) + } + return data +} + +func encodeHitManySlots(n uint64) []byte { + data, err := funcHitManySlots.EncodeArgs(new(big.Int).SetUint64(n)) + if err != nil { + panic(fmt.Sprintf("failed to encode hitManySlots(%d): %v", n, err)) + } + return data +} diff --git a/op-acceptance-tests/tests/sdm/init_test.go b/op-acceptance-tests/tests/sdm/init_test.go new file mode 100644 index 00000000000..6ec9c377f3f --- /dev/null +++ b/op-acceptance-tests/tests/sdm/init_test.go @@ -0,0 +1,38 @@ +package sdm + +import ( + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/presets" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" +) + +type sdmRethSystem struct { + L2EL *dsl.L2ELNode + L2Batcher *dsl.L2Batcher + FunderL2 *dsl.Funder +} + +func newSDMRethSystem(t devtest.T, sdmEnabled bool) *sdmRethSystem { + runtime := sysgo.NewMixedSingleChainRuntime(t, sysgo.MixedSingleChainPresetConfig{ + NodeSpecs: []sysgo.MixedSingleChainNodeSpec{ + { + ELKey: "sequencer-op-reth", + CLKey: "sequencer", + ELKind: sysgo.MixedL2ELOpReth, + CLKind: sysgo.MixedL2CLOpNode, + IsSequencer: true, + SDMEnabled: sdmEnabled, + }, + }, + }) + frontends := presets.NewMixedSingleChainFrontends(t, runtime) + frontends.L2Batcher.Stop() + + wallet := dsl.NewRandomHDWallet(t, 30) + return &sdmRethSystem{ + L2EL: frontends.L2Network.PrimaryEL(), + L2Batcher: frontends.L2Batcher, + FunderL2: dsl.NewFunder(wallet, frontends.FaucetL2, frontends.L2Network.PrimaryEL()), + } +} diff --git a/op-devstack/sysgo/mixed_runtime.go b/op-devstack/sysgo/mixed_runtime.go index 72d25d8b743..b8d1419e8f8 100644 --- a/op-devstack/sysgo/mixed_runtime.go +++ b/op-devstack/sysgo/mixed_runtime.go @@ -108,6 +108,9 @@ type MixedSingleChainNodeSpec struct { ELKind MixedL2ELKind CLKind MixedL2CLKind IsSequencer bool + // SDMEnabled enables the post-exec feature flag on op-reth sequencers. + // Kept for compatibility with existing SDM PoC acceptance tests. + SDMEnabled bool } type MixedSingleChainPresetConfig struct { @@ -168,7 +171,7 @@ func NewMixedSingleChainRuntime(t devtest.T, cfg MixedSingleChainPresetConfig) * case MixedL2ELOpGeth: el = startL2ELNode(t, l2Net, jwtPath, jwtSecret, spec.ELKey, identity) case MixedL2ELOpReth: - el = startMixedOpRethNode(t, l2Net, spec.ELKey, jwtPath, jwtSecret, metricsRegistrar) + el = startMixedOpRethNode(t, l2Net, spec.ELKey, jwtPath, jwtSecret, metricsRegistrar, nil, spec.SDMEnabled) default: require.FailNowf("unsupported EL kind", "unsupported mixed EL kind %q", spec.ELKind) } @@ -281,6 +284,8 @@ func buildMixedOpRethNode( jwtPath string, jwtSecret [32]byte, metricsRegistrar L2MetricsRegistrar, + elCfg *L2ELConfig, + postExecEnabled bool, ) *OpReth { tempDir := t.TempDir() @@ -339,6 +344,9 @@ func buildMixedOpRethNode( if areMetricsEnabled() { args = append(args, "--metrics=127.0.0.1:0") } + if postExecEnabled { + args = append(args, "--rollup.sdm-enabled") + } initArgs := []string{ "init", @@ -348,25 +356,27 @@ func buildMixedOpRethNode( err = exec.Command(execPath, initArgs...).Run() t.Require().NoError(err, "must init op-reth node") - proofHistoryDir := filepath.Join(tempDir, "proof-history") + if elCfg == nil || elCfg.ProofHistory { + proofHistoryDir := filepath.Join(tempDir, "proof-history") - initProofsArgs := []string{ - "proofs", - "init", - "--datadir=" + dataDirPath, - "--chain=" + chainConfigPath, - "--proofs-history.storage-path=" + proofHistoryDir, + initProofsArgs := []string{ + "proofs", + "init", + "--datadir=" + dataDirPath, + "--chain=" + chainConfigPath, + "--proofs-history.storage-path=" + proofHistoryDir, + } + initOut, initErr := exec.Command(execPath, initProofsArgs...).CombinedOutput() + t.Require().NoError(initErr, "must init op-reth proof history: %s", string(initOut)) + + args = append( + args, + "--proofs-history", + "--proofs-history.window=10000", + "--proofs-history.prune-interval=1m", + "--proofs-history.storage-path="+proofHistoryDir, + ) } - initOut, initErr := exec.Command(execPath, initProofsArgs...).CombinedOutput() - t.Require().NoError(initErr, "must init op-reth proof history: %s", string(initOut)) - - args = append( - args, - "--proofs-history", - "--proofs-history.window=10000", - "--proofs-history.prune-interval=1m", - "--proofs-history.storage-path="+proofHistoryDir, - ) return &OpReth{ name: key, @@ -390,8 +400,10 @@ func startMixedOpRethNode( jwtPath string, jwtSecret [32]byte, metricsRegistrar L2MetricsRegistrar, + elCfg *L2ELConfig, + postExecEnabled bool, ) *OpReth { - node := buildMixedOpRethNode(t, l2Net, key, jwtPath, jwtSecret, metricsRegistrar) + node := buildMixedOpRethNode(t, l2Net, key, jwtPath, jwtSecret, metricsRegistrar, elCfg, postExecEnabled) t.Logger().Info("Starting op-reth", "name", key, "chain", l2Net.ChainID()) node.Start() t.Cleanup(node.Stop) @@ -410,7 +422,7 @@ func startMixedOpRethNodeWithSupervisorURL( metricsRegistrar L2MetricsRegistrar, supervisorURL string, ) *OpReth { - node := buildMixedOpRethNode(t, l2Net, key, jwtPath, jwtSecret, metricsRegistrar) + node := buildMixedOpRethNode(t, l2Net, key, jwtPath, jwtSecret, metricsRegistrar, nil, false) if supervisorURL != "" { node.args = append(node.args, "--rollup.supervisor-http="+supervisorURL) } diff --git a/op-devstack/sysgo/singlechain_build.go b/op-devstack/sysgo/singlechain_build.go index 3d2129554df..474bf22ab34 100644 --- a/op-devstack/sysgo/singlechain_build.go +++ b/op-devstack/sysgo/singlechain_build.go @@ -157,7 +157,7 @@ func startL2ELForKey(t devtest.T, l2Net *L2Network, jwtPath string, jwtSecret [3 case MixedL2ELOpGeth: return startL2ELNode(t, l2Net, jwtPath, jwtSecret, key, identity) default: // op-reth - return startMixedOpRethNode(t, l2Net, key, jwtPath, jwtSecret, nil) + return startMixedOpRethNode(t, l2Net, key, jwtPath, jwtSecret, nil, nil, false) } } From b51b9bdb19db6a3476d2eec8f4ecebd974cb27bd Mon Sep 17 00:00:00 2001 From: Anton Evangelatov Date: Mon, 20 Apr 2026 17:00:27 +0300 Subject: [PATCH 7/7] feat(op-reth): add SDM observability metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Producer-side and follower-side metrics so operators can monitor a canary before flipping SDM on via hardfork. - optimism_sdm.payload_builder (in reth-optimism-payload-builder): * blocks_with_post_exec_tx / blocks_without_post_exec_tx counters split on whether the sequencer appended a synthetic 0x7D tx, * block_refund_gas / block_refund_entry_count histograms over the refund payload size, per block with a 0x7D. - optimism_sdm.replay (in reth-optimism-post-exec-replay): * blocks_total / blocks_with_mismatch_total counters recorded on every replay_block() call, * per-category mismatch counters for each PostExecReplayMismatchKind, so an operator running `sdmreplay --fail-on-mismatch` can alert on both which block diverged and which rule fired. Default-instantiated at the call site — handles are cheap and registry-backed. Verifier-mode metrics in alloy-op-evm are deferred because that crate is no_std-capable and pulling in reth-metrics would break the feature gating. --- rust/op-reth/crates/payload/Cargo.toml | 2 + rust/op-reth/crates/payload/src/builder.rs | 9 +- rust/op-reth/crates/payload/src/lib.rs | 1 + rust/op-reth/crates/payload/src/metrics.rs | 50 +++++++++++ .../crates/post-exec-replay/Cargo.toml | 2 + .../crates/post-exec-replay/src/lib.rs | 2 + .../crates/post-exec-replay/src/metrics.rs | 83 +++++++++++++++++++ .../crates/post-exec-replay/src/replay.rs | 17 +++- 8 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 rust/op-reth/crates/payload/src/metrics.rs create mode 100644 rust/op-reth/crates/post-exec-replay/src/metrics.rs diff --git a/rust/op-reth/crates/payload/Cargo.toml b/rust/op-reth/crates/payload/Cargo.toml index 3c40b3bd7ba..5995f078bb8 100644 --- a/rust/op-reth/crates/payload/Cargo.toml +++ b/rust/op-reth/crates/payload/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] # reth reth-chainspec.workspace = true +reth-metrics.workspace = true reth-primitives-traits.workspace = true reth-revm = { workspace = true, features = ["witness"] } reth-transaction-pool.workspace = true @@ -47,6 +48,7 @@ alloy-evm.workspace = true # misc derive_more.workspace = true +metrics.workspace = true tracing.workspace = true thiserror.workspace = true sha2.workspace = true diff --git a/rust/op-reth/crates/payload/src/builder.rs b/rust/op-reth/crates/payload/src/builder.rs index 3c7830f50a4..3c6f3fff7ea 100644 --- a/rust/op-reth/crates/payload/src/builder.rs +++ b/rust/op-reth/crates/payload/src/builder.rs @@ -1,7 +1,7 @@ //! Optimism payload builder implementation. use crate::{ OpAttributes, OpPayloadBuilderAttributes, OpPayloadPrimitives, config::OpBuilderConfig, - error::OpPayloadBuilderError, payload::OpBuiltPayload, + error::OpPayloadBuilderError, metrics::SDMPayloadMetrics, payload::OpBuiltPayload, }; use alloy_consensus::{BlockHeader, Sealable, Transaction, Typed2718, transaction::Recovered}; use alloy_evm::Evm as AlloyEvm; @@ -455,7 +455,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 refund_gas_total: u64 = entries.iter().map(|e| e.gas_refund).sum(); + let entry_count = entries.len(); + let included = try_include_post_exec_tx(block_number, entries, |tx| { + builder.execute_transaction(tx) + })?; + SDMPayloadMetrics::default().record(included, refund_gas_total, entry_count); } let BlockBuilderOutcome { execution_result, hashed_state, trie_updates, block } = diff --git a/rust/op-reth/crates/payload/src/lib.rs b/rust/op-reth/crates/payload/src/lib.rs index 41834e63994..9a3bca3a1f3 100644 --- a/rust/op-reth/crates/payload/src/lib.rs +++ b/rust/op-reth/crates/payload/src/lib.rs @@ -29,6 +29,7 @@ pub mod validator; pub use validator::OpExecutionPayloadValidator; pub mod config; +pub mod metrics; // Implement `ConfigureEngineEvm` by delegating to the `OpExecutionData` implementation. // This must live here because `OpExecData` is defined in this crate (orphan rules). diff --git a/rust/op-reth/crates/payload/src/metrics.rs b/rust/op-reth/crates/payload/src/metrics.rs new file mode 100644 index 00000000000..83d2f38ed83 --- /dev/null +++ b/rust/op-reth/crates/payload/src/metrics.rs @@ -0,0 +1,50 @@ +//! Observability for the SDM post-exec transaction in the OP payload builder. +//! +//! Recorded once per SDM-enabled block the sequencer produces: +//! - a counter split on whether a synthetic 0x7D tx ended up in the block, +//! - a histogram of the total refund gas carried by the 0x7D payload, +//! - a histogram of how many refund entries the payload carried. +//! +//! The follower-side counterpart lives in `reth-optimism-post-exec-replay::metrics` +//! (not linked here because that crate is not a direct dependency). + +use metrics::{Counter, Histogram}; +use reth_metrics::Metrics; + +/// Producer-side SDM payload-builder metrics. +/// +/// Construct via [`Default::default`] at the point of use — the handles are cheap to mint and +/// backed by the global `metrics` registry, so per-block instantiation is fine. +#[derive(Metrics, Clone)] +#[metrics(scope = "optimism_sdm.payload_builder")] +pub struct SDMPayloadMetrics { + /// Blocks the sequencer produced with a synthetic post-exec (0x7D) tx included. + pub blocks_with_post_exec_tx: Counter, + /// Blocks the sequencer produced with SDM enabled but no refunds to settle, so no + /// synthetic tx was included. + pub blocks_without_post_exec_tx: Counter, + /// Total refund gas carried by the 0x7D payload, per block. Only recorded when a + /// synthetic tx was included. + pub block_refund_gas: Histogram, + /// Number of refund entries in the 0x7D payload, per block. Only recorded when a + /// synthetic tx was included. + pub block_refund_entry_count: Histogram, +} + +impl SDMPayloadMetrics { + /// Record the outcome of a single SDM-enabled block build. + /// + /// `included` reflects whether a synthetic 0x7D tx was actually appended to the block; + /// `refund_gas_total` and `entry_count` are the aggregate values from the refund entries + /// (ignored when `!included`). + #[inline] + pub fn record(&self, included: bool, refund_gas_total: u64, entry_count: usize) { + if included { + self.blocks_with_post_exec_tx.increment(1); + self.block_refund_gas.record(refund_gas_total as f64); + self.block_refund_entry_count.record(entry_count as f64); + } else { + self.blocks_without_post_exec_tx.increment(1); + } + } +} diff --git a/rust/op-reth/crates/post-exec-replay/Cargo.toml b/rust/op-reth/crates/post-exec-replay/Cargo.toml index e67a682a6a7..2fbb7e243d2 100644 --- a/rust/op-reth/crates/post-exec-replay/Cargo.toml +++ b/rust/op-reth/crates/post-exec-replay/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] reth-evm.workspace = true reth-execution-errors.workspace = true +reth-metrics.workspace = true reth-optimism-evm = { workspace = true, features = ["std"] } reth-optimism-primitives = { workspace = true, features = ["serde", "reth-codec"] } reth-primitives-traits.workspace = true @@ -25,6 +26,7 @@ alloy-eips.workspace = true alloy-primitives.workspace = true op-alloy-consensus.workspace = true +metrics.workspace = true serde.workspace = true serde_json = { workspace = true, features = ["std"] } thiserror.workspace = true diff --git a/rust/op-reth/crates/post-exec-replay/src/lib.rs b/rust/op-reth/crates/post-exec-replay/src/lib.rs index 99e8e05b7e9..46bc03e4200 100644 --- a/rust/op-reth/crates/post-exec-replay/src/lib.rs +++ b/rust/op-reth/crates/post-exec-replay/src/lib.rs @@ -3,10 +3,12 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] mod jsonl; +pub mod metrics; mod replay; mod types; pub use jsonl::{PostExecReplayJsonlRecord, write_jsonl_record}; +pub use metrics::SDMReplayMetrics; pub use replay::{PostExecReplayError, replay_block, strip_post_exec_tx_for_replay}; pub use types::{ PostExecReplayBlock, PostExecReplayConfig, PostExecReplayMismatch, PostExecReplayMismatchKind, diff --git a/rust/op-reth/crates/post-exec-replay/src/metrics.rs b/rust/op-reth/crates/post-exec-replay/src/metrics.rs new file mode 100644 index 00000000000..1979dc57404 --- /dev/null +++ b/rust/op-reth/crates/post-exec-replay/src/metrics.rs @@ -0,0 +1,83 @@ +//! Observability for SDM replay mismatch detection. +//! +//! Every call into [`replay_block`](crate::replay_block) records one tick against +//! [`SDMReplayMetrics`] — whether or not a mismatch was found — so operators running a +//! follower-style replay loop (e.g. `sdmreplay --fail-on-mismatch`) can alert on both +//! sudden mismatch spikes and silent replay stoppage. +//! +//! The producer-side counterpart lives in `reth-optimism-payload-builder::metrics`. + +use crate::types::PostExecReplayMismatchKind; +use metrics::Counter; +use reth_metrics::Metrics; + +/// Follower-/replay-side SDM metrics. +/// +/// Construct via [`Default::default`] at the point of use. +#[derive(Metrics, Clone)] +#[metrics(scope = "optimism_sdm.replay")] +pub struct SDMReplayMetrics { + /// Total blocks replayed. + pub blocks_total: Counter, + /// Blocks for which replay produced at least one mismatch. + pub blocks_with_mismatch_total: Counter, + /// Payload carried two entries targeting the same tx index. + pub mismatch_duplicate_payload_index: Counter, + /// Payload entry targeted a tx index outside the block. + pub mismatch_payload_index_out_of_range: Counter, + /// Payload entry targeted a deposit tx. + pub mismatch_payload_targets_deposit: Counter, + /// Payload entry targeted the synthetic 0x7D tx itself. + pub mismatch_payload_targets_post_exec: Counter, + /// Payload refund disagreed with the replay refund for a tx. + pub mismatch_payload_refund: Counter, + /// Receipt-level `opGasRefund` disagreed with the replay refund for a tx. + pub mismatch_receipt_refund: Counter, + /// Payload refund exceeded the tx's raw gas used. + pub mismatch_payload_refund_exceeds_raw_gas: Counter, + /// Replay was invoked in an unsupported mode. + pub mismatch_unsupported_mode: Counter, +} + +impl SDMReplayMetrics { + /// Record the outcome of a single [`replay_block`](crate::replay_block) call by category. + /// + /// The per-category counters add up to a rate higher than `blocks_with_mismatch_total` + /// when a single block surfaces multiple mismatches — that's intentional, since an + /// operator wants both "which block stopped the pipeline" and "which validation rule + /// fired" signals. + #[inline] + pub fn record_block(&self, mismatches: &[PostExecReplayMismatchKind]) { + self.blocks_total.increment(1); + if mismatches.is_empty() { + return; + } + self.blocks_with_mismatch_total.increment(1); + for kind in mismatches { + self.counter_for(kind).increment(1); + } + } + + const fn counter_for(&self, kind: &PostExecReplayMismatchKind) -> &Counter { + match kind { + PostExecReplayMismatchKind::DuplicatePayloadIndex => { + &self.mismatch_duplicate_payload_index + } + PostExecReplayMismatchKind::PayloadIndexOutOfRange => { + &self.mismatch_payload_index_out_of_range + } + PostExecReplayMismatchKind::PayloadTargetsDeposit => { + &self.mismatch_payload_targets_deposit + } + PostExecReplayMismatchKind::PayloadTargetsPostExec => { + &self.mismatch_payload_targets_post_exec + } + PostExecReplayMismatchKind::PayloadRefundMismatch => &self.mismatch_payload_refund, + PostExecReplayMismatchKind::ReceiptRefundMismatch => &self.mismatch_receipt_refund, + PostExecReplayMismatchKind::PayloadRefundExceedsRawGas => { + &self.mismatch_payload_refund_exceeds_raw_gas + } + PostExecReplayMismatchKind::UnsupportedMode => &self.mismatch_unsupported_mode, + } + } +} diff --git a/rust/op-reth/crates/post-exec-replay/src/replay.rs b/rust/op-reth/crates/post-exec-replay/src/replay.rs index b318296ca0f..aa10d7f0fb0 100644 --- a/rust/op-reth/crates/post-exec-replay/src/replay.rs +++ b/rust/op-reth/crates/post-exec-replay/src/replay.rs @@ -1,7 +1,11 @@ -use crate::types::{ - PostExecReplayBlock, PostExecReplayConfig, PostExecReplayMismatch, PostExecReplayMismatchKind, - PostExecReplayMode, PostExecReplayPayload, PostExecReplayPayloadEntry, - PostExecReplayRefundEvent, PostExecReplayRefundKind, PostExecReplaySummary, PostExecReplayTx, +use crate::{ + metrics::SDMReplayMetrics, + types::{ + PostExecReplayBlock, PostExecReplayConfig, PostExecReplayMismatch, + PostExecReplayMismatchKind, PostExecReplayMode, PostExecReplayPayload, + PostExecReplayPayloadEntry, PostExecReplayRefundEvent, PostExecReplayRefundKind, + PostExecReplaySummary, PostExecReplayTx, + }, }; use alloy_consensus::{Block as AlloyBlock, BlockBody, BlockHeader, TxReceipt, Typed2718}; use op_alloy_consensus::{POST_EXEC_TX_TYPE_ID, PostExecPayload, SDMGasEntry, build_post_exec_tx}; @@ -271,7 +275,10 @@ where DB: Database, EvmConfig: ConfigurePostExecEvm, { + let metrics = SDMReplayMetrics::default(); + if config.mode != PostExecReplayMode::CounterfactualEnabled { + metrics.record_block(&[PostExecReplayMismatchKind::UnsupportedMode]); return Err(PostExecReplayError::UnsupportedMode(config.mode)); } @@ -389,6 +396,8 @@ where replay_mode: config.mode, }; + metrics.record_block(&mismatches.iter().map(|m| m.category.clone()).collect::>()); + Ok(PostExecReplayBlock { config, block_num: block.header().number(),