From 1b4a74f3f5c2c3b2f6ea9fc1f54738a82f186b4d Mon Sep 17 00:00:00 2001 From: Onyeka Obi Date: Tue, 7 Jul 2026 20:29:43 -0700 Subject: [PATCH] feat(core): record used state during execution Add a UsedStateInspector EVM inspector that records the storage slots, account balances and contract lifecycle changes an execution reads and writes, exposed as a UsedStateTrace on ExecutionResult. Executing a transaction or a bundle now attaches the inspector by default, so builders can read ExecutionResult::used_state() to detect conflicts between orders and parallelize block construction across orders that do not touch the same state. The signer nonce is modelled as a read and write of slot zero of the signer account so two transactions from the same signer are detected as conflicting. For a bundle the trace is the aggregate over the transactions kept in the bundle. Refs #50 Signed-off-by: Onyeka Obi --- crates/core/src/payload/exec.rs | 102 +++++++++- crates/core/src/payload/mod.rs | 2 + crates/core/src/payload/used_state.rs | 279 ++++++++++++++++++++++++++ 3 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 crates/core/src/payload/used_state.rs diff --git a/crates/core/src/payload/exec.rs b/crates/core/src/payload/exec.rs index e071cbd..cd94209 100644 --- a/crates/core/src/payload/exec.rs +++ b/crates/core/src/payload/exec.rs @@ -1,4 +1,5 @@ use { + super::used_state::{UsedStateInspector, UsedStateTrace}, crate::{alloy, prelude::*, reth, revm::database::bal::EvmDatabaseError}, alloy::{ consensus::{crypto::RecoveryError, transaction::TxHashRef}, @@ -117,9 +118,17 @@ impl Executable

{ .with_bundle_update() .build(); + let mut used_state = UsedStateTrace::default(); + let mut inspector = UsedStateInspector::new(&mut used_state); + inspector.use_tx_nonce(&tx); + let result = block .evm_config() - .evm_with_env(&mut state, block.evm_env().clone()) + .evm_with_env_and_inspector( + &mut state, + block.evm_env().clone(), + inspector, + ) .transact_commit(&tx)?; state.merge_transitions(BundleRetention::Reverts); @@ -128,6 +137,7 @@ impl Executable

{ source: Executable::Transaction(tx), results: vec![result], state: state.take_bundle(), + used_state: Box::new(used_state), }) } @@ -215,14 +225,19 @@ impl Executable

{ let mut discarded = Vec::new(); let mut results = Vec::with_capacity(bundle.transactions().len()); + let mut used_state = UsedStateTrace::default(); for transaction in bundle.transactions_encoded() { let tx_hash = *transaction.tx_hash(); let optional = bundle.is_optional(&tx_hash); let allowed_to_fail = bundle.is_allowed_to_fail(&tx_hash); + let mut tx_used_state = UsedStateTrace::default(); + let mut inspector = UsedStateInspector::new(&mut tx_used_state); + inspector.use_tx_nonce(transaction.1); + let result = evm_config - .evm_with_env(&mut db, evm_env.clone()) + .evm_with_env_and_inspector(&mut db, evm_env.clone(), inspector) .transact(&transaction); match result { @@ -232,6 +247,9 @@ impl Executable

{ { results.push(result); db.commit(state); + // Only fold in the state of a transaction that is kept in the + // bundle. The state of a discarded transaction is rolled back. + used_state.append_trace(&tx_used_state); } // Optional failing transaction, not allowed to fail // or optional invalid transaction: discard it @@ -276,6 +294,7 @@ impl Executable

{ source: Executable::Bundle(bundle), results, state, + used_state: Box::new(used_state), }) } } @@ -403,6 +422,12 @@ pub struct ExecutionResult { /// The aggregated state executing all transactions from the source. pub(crate) state: BundleState, + + /// The state read and written while executing the source, recorded by an + /// EVM inspector. For a bundle this is the aggregate over the transactions + /// that were kept in the bundle. Boxed to keep `ExecutionResult` compact, + /// since it is embedded in a checkpoint history enum. + pub(crate) used_state: Box, } impl ExecutionResult

{ @@ -417,6 +442,14 @@ impl ExecutionResult

{ &self.state } + /// Returns the state read and written while executing this transaction or + /// bundle, as recorded by an EVM inspector. Builders can use this to detect + /// conflicts between orders and to parallelize block construction across + /// orders that do not touch the same state. + pub fn used_state(&self) -> &UsedStateTrace { + &self.used_state + } + /// Access to the individual transaction results that make up this execution /// result. /// @@ -745,4 +778,69 @@ mod tests { assert_eq!(result, cloned); } + + #[test] + fn test_execute_transaction_records_signer_nonce_in_used_state() { + use crate::{alloy::primitives::U256, payload::SlotKey}; + + let block = BlockContext::::mocked(); + let checkpoint = block.start(); + let tx = test_tx::(0, 0); + let signer = tx.signer(); + + let result = Executable::execute_transaction( + tx, + &block, + &checkpoint, + checkpoint.context(), + ) + .unwrap(); + + // The signer nonce is modelled as a read of the pre-value and a write of + // the post-value at slot zero of the signer account. + let slot = SlotKey { + address: signer, + key: B256::default(), + }; + let used = result.used_state(); + assert_eq!( + used.read_slot_values.get(&slot), + Some(&B256::from(U256::from(0u64))) + ); + assert_eq!( + used.written_slot_values.get(&slot), + Some(&B256::from(U256::from(1u64))) + ); + } + + #[test] + fn test_execute_bundle_aggregates_used_state() { + use crate::{alloy::primitives::U256, payload::SlotKey}; + + let block = BlockContext::::mocked(); + let checkpoint = block.start(); + let (bundle, txs) = test_bundle::(0, 0); + let signer = txs[0].signer(); + + let result = Executable::Bundle(bundle) + .execute(&block, &checkpoint, checkpoint.context()) + .unwrap(); + + // The three bundle transactions share a signer and consume nonces 0..=2, + // so the aggregate keeps the first read (nonce 0) and the last write + // (nonce 3) at slot zero of the signer account. + let slot = SlotKey { + address: signer, + key: B256::default(), + }; + let used = result.used_state(); + assert_eq!( + used.read_slot_values.get(&slot), + Some(&B256::from(U256::from(0u64))) + ); + assert_eq!( + used.written_slot_values.get(&slot), + Some(&B256::from(U256::from(3u64))) + ); + } } diff --git a/crates/core/src/payload/mod.rs b/crates/core/src/payload/mod.rs index 7600cb4..e9c678a 100644 --- a/crates/core/src/payload/mod.rs +++ b/crates/core/src/payload/mod.rs @@ -7,6 +7,7 @@ mod checkpoint; mod exec; mod ext; mod span; +mod used_state; pub use { block::{BlockContext, Error as BlockError}, @@ -20,4 +21,5 @@ pub use { SpanExt, }, span::{Error as SpanError, Span}, + used_state::{SlotKey, UsedStateInspector, UsedStateTrace}, }; diff --git a/crates/core/src/payload/used_state.rs b/crates/core/src/payload/used_state.rs new file mode 100644 index 0000000..b3e6fe7 --- /dev/null +++ b/crates/core/src/payload/used_state.rs @@ -0,0 +1,279 @@ +//! An EVM inspector that records the state an execution reads and writes. +//! +//! The recorded [`UsedStateTrace`] captures the storage slots, account balances +//! and contract lifecycle changes touched while executing a transaction or a +//! bundle. Builders can use it to detect conflicts between orders and to +//! parallelize block construction across orders that do not touch the same +//! state. + +use { + crate::{ + alloy::{ + consensus::Transaction, + primitives::{Address, B256, U256}, + }, + reth::{ + evm::revm::{ + Inspector, + bytecode::opcode, + context::ContextTr, + inspector::JournalExt, + interpreter::{ + CallInputs, + CallOutcome, + CreateInputs, + CreateOutcome, + Interpreter, + interpreter_types::Jumps, + }, + }, + primitives::Recovered, + }, + }, + std::collections::HashMap, +}; + +/// Identifies a single storage slot by the account that owns it and the slot +/// key within that account. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SlotKey { + pub address: Address, + pub key: B256, +} + +/// A trace of the state read and written while executing an order. +/// +/// Limitations: `written_slot_values`, `received_amount` and `sent_amount` are +/// not accurate for a transaction that reverts, because writes performed before +/// the revert are still observed by the interpreter step hook. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UsedStateTrace { + /// First value read from each storage slot. + pub read_slot_values: HashMap, + /// Last value written to each storage slot. + pub written_slot_values: HashMap, + /// First balance read for each account. + pub read_balances: HashMap, + /// Amount of wei received by each account during execution. + pub received_amount: HashMap, + /// Amount of wei sent by each account during execution. + pub sent_amount: HashMap, + /// Contracts created during execution. + pub created_contracts: Vec

, + /// Contracts self destructed during execution. + pub destructed_contracts: Vec
, +} + +impl UsedStateTrace { + /// Merges `other` into `self`, assuming `other` was produced by an execution + /// that ran after the executions already folded in. The first read and the + /// last write of each slot are preserved, and transferred amounts are + /// accumulated. + pub fn append_trace(&mut self, other: &UsedStateTrace) { + for (read_slot, read_value) in &other.read_slot_values { + self + .read_slot_values + .entry(read_slot.clone()) + .or_insert(*read_value); + } + + self + .written_slot_values + .extend(other.written_slot_values.clone()); + + for (address, balance) in &other.read_balances { + self.read_balances.entry(*address).or_insert(*balance); + } + + for (address, received_amount) in &other.received_amount { + *self.received_amount.entry(*address).or_default() += received_amount; + } + + for (address, sent_amount) in &other.sent_amount { + *self.sent_amount.entry(*address).or_default() += sent_amount; + } + + self + .created_contracts + .extend(other.created_contracts.iter().copied()); + + for address in &other.destructed_contracts { + if !self.destructed_contracts.contains(address) { + self.destructed_contracts.push(*address); + } + } + } + + /// Clears all recorded state. + pub fn clear(&mut self) { + self.read_slot_values.clear(); + self.written_slot_values.clear(); + self.read_balances.clear(); + self.received_amount.clear(); + self.sent_amount.clear(); + self.created_contracts.clear(); + self.destructed_contracts.clear(); + } +} + +/// A read that must be resolved on the interpreter step following the opcode +/// that requested it, once the result is available on the stack. +#[derive(Debug, Clone, Default)] +enum NextStepAction { + #[default] + None, + ReadSloadKeyResult(B256), + ReadBalanceResult(Address), +} + +/// An EVM [`Inspector`] that records the read and written state of an execution +/// into a [`UsedStateTrace`]. +#[derive(Debug)] +pub struct UsedStateInspector<'a> { + next_step_action: NextStepAction, + trace: &'a mut UsedStateTrace, +} + +impl<'a> UsedStateInspector<'a> { + /// Creates a new inspector that records into `trace`. + pub fn new(trace: &'a mut UsedStateTrace) -> Self { + Self { + next_step_action: NextStepAction::None, + trace, + } + } + + /// Records the transaction nonce as a read and write of slot zero of the + /// signer account. A signer account is an externally owned account with no + /// storage, so modelling the nonce this way lets two transactions from the + /// same signer be detected as conflicting. + pub fn use_tx_nonce(&mut self, tx: &Recovered) + where + T: Transaction, + { + let signer = tx.signer(); + self.trace.read_slot_values.insert( + SlotKey { + address: signer, + key: B256::default(), + }, + U256::from(tx.nonce()).into(), + ); + self.trace.written_slot_values.insert( + SlotKey { + address: signer, + key: B256::default(), + }, + U256::from(tx.nonce().saturating_add(1)).into(), + ); + } +} + +impl Inspector for UsedStateInspector<'_> +where + CTX: ContextTr, +{ + fn step(&mut self, interpreter: &mut Interpreter, _context: &mut CTX) { + match core::mem::take(&mut self.next_step_action) { + NextStepAction::ReadSloadKeyResult(slot) => { + if let Ok(value) = interpreter.stack.peek(0) { + let value = B256::from(value.to_be_bytes()); + let key = SlotKey { + address: interpreter.input.target_address, + key: slot, + }; + self.trace.read_slot_values.entry(key).or_insert(value); + } + } + NextStepAction::ReadBalanceResult(addr) => { + if let Ok(value) = interpreter.stack.peek(0) { + self.trace.read_balances.entry(addr).or_insert(value); + } + } + NextStepAction::None => {} + } + match interpreter.bytecode.opcode() { + opcode::SLOAD => { + if let Ok(slot) = interpreter.stack.peek(0) { + let slot = B256::from(slot.to_be_bytes()); + self.next_step_action = NextStepAction::ReadSloadKeyResult(slot); + } + } + opcode::SSTORE => { + if let (Ok(slot), Ok(value)) = + (interpreter.stack.peek(0), interpreter.stack.peek(1)) + { + let written_value = B256::from(value.to_be_bytes()); + let key = SlotKey { + address: interpreter.input.target_address, + key: B256::from(slot.to_be_bytes()), + }; + // If we write the value we first read then there is no net + // write, so drop any previously recorded write to this slot. + if self.trace.read_slot_values.get(&key) == Some(&written_value) { + self.trace.written_slot_values.remove(&key); + return; + } + self.trace.written_slot_values.insert(key, written_value); + } + } + opcode::BALANCE => { + if let Ok(addr) = interpreter.stack.peek(0) { + let addr = Address::from_word(B256::from(addr.to_be_bytes())); + self.next_step_action = NextStepAction::ReadBalanceResult(addr); + } + } + opcode::SELFBALANCE => { + let addr = interpreter.input.target_address; + self.next_step_action = NextStepAction::ReadBalanceResult(addr); + } + _ => {} + } + } + + fn call( + &mut self, + _context: &mut CTX, + inputs: &mut CallInputs, + ) -> Option { + if let Some(transfer_value) = inputs.transfer_value() + && !transfer_value.is_zero() + { + *self + .trace + .sent_amount + .entry(inputs.transfer_from()) + .or_default() += transfer_value; + *self + .trace + .received_amount + .entry(inputs.transfer_to()) + .or_default() += transfer_value; + } + None + } + + fn create_end( + &mut self, + _context: &mut CTX, + _inputs: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + if let Some(addr) = outcome.address { + self.trace.created_contracts.push(addr); + } + } + + fn selfdestruct(&mut self, contract: Address, target: Address, value: U256) { + // selfdestruct can be reported more than once for the same contract + // during execution, so only record the first occurrence. + if self.trace.destructed_contracts.contains(&contract) { + return; + } + self.trace.destructed_contracts.push(contract); + if !value.is_zero() { + *self.trace.sent_amount.entry(contract).or_default() += value; + *self.trace.received_amount.entry(target).or_default() += value; + } + } +}