diff --git a/Cargo.lock b/Cargo.lock index e6e9924..e930799 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1327,6 +1327,7 @@ dependencies = [ "alloy-eips", "alloy-network", "alloy-primitives", + "alloy-rlp", "alloy-rpc-types-engine", "alloy-rpc-types-eth", "alloy-rpc-types-trace", @@ -1335,6 +1336,7 @@ dependencies = [ "arc-execution-config", "arc-execution-txpool", "arc-precompiles", + "debank-rpc", "eyre", "futures-util", "jsonrpsee", diff --git a/crates/debank-rpc/src/debank_trace.rs b/crates/debank-rpc/src/debank_trace.rs index 12855bd..43651e4 100644 --- a/crates/debank-rpc/src/debank_trace.rs +++ b/crates/debank-rpc/src/debank_trace.rs @@ -9,20 +9,21 @@ use alloy_consensus::constants::KECCAK_EMPTY; use alloy_primitives::{ - hex, keccak256, Address, BlockHash, BlockNumber, Bytes, B256 as H256, U256, + hex, keccak256, Address, BlockHash, BlockNumber, Bytes, Log, B256 as H256, U256, }; use alloy_rlp::{RlpDecodable, RlpEncodable}; use alloy_rpc_types_eth::Header; -use reth_revm::db::{AccountState, Cache}; -use revm::DatabaseRef; +use revm::{database::BundleState, DatabaseRef}; use revm_inspectors::tracing::{ - types::{CallKind, CallLog, CallTraceNode, TraceMemberOrder}, + types::{CallKind, CallTraceNode}, CallTraceArena, }; use serde::{Deserialize, Serialize}; use sha1::{Digest as Sha1Digest, Sha1}; use std::str::FromStr; +use crate::event_inspector::{CapturedEvents, CapturedMember}; + // --------------------------------------------------------------------------- // State diff types (RLP-encoded for S3 storage) // --------------------------------------------------------------------------- @@ -338,19 +339,7 @@ pub(crate) fn fmt_error_msg(res: revm::interpreter::InstructionResult) -> Option impl From<&CallTraceNode> for DebankTrace { fn from(call_trace: &CallTraceNode) -> Self { let trace = &call_trace.trace; - let call_create_type = match trace.kind { - CallKind::Call - | CallKind::StaticCall - | CallKind::CallCode - | CallKind::DelegateCall - | CallKind::AuthCall => "call".to_string(), - CallKind::Create => "create".to_string(), - CallKind::Create2 => "create2".to_string(), - }; - let mut call_type = String::new(); - if call_create_type == "call" { - call_type = trace.kind.to_string().to_lowercase(); - } + let (call_create_type, call_type) = debank_call_types(trace.kind); let error = trace.status.and_then(fmt_error_msg); let mut debank_trace = Self { from_addr: trace.caller, @@ -360,8 +349,8 @@ impl From<&CallTraceNode> for DebankTrace { value: trace.value, gas_used: trace.gas_used, output: trace.output.clone(), - call_create_type, - call_type, + call_create_type: call_create_type.to_string(), + call_type: call_type.to_string(), subtraces: call_trace.children.len(), error: error.unwrap_or_default(), ..Default::default() @@ -377,26 +366,34 @@ impl From<&CallTraceNode> for DebankTrace { } } -impl From<&CallLog> for DebankEvent { - fn from(log: &CallLog) -> Self { +fn debank_call_types(kind: CallKind) -> (&'static str, &'static str) { + match kind { + CallKind::Call => ("call", "call"), + CallKind::StaticCall => ("call", "staticcall"), + CallKind::CallCode => ("call", "callcode"), + CallKind::DelegateCall => ("call", "delegatecall"), + CallKind::AuthCall => ("call", "authcall"), + CallKind::Create | CallKind::Create2 => ("create", ""), + } +} + +impl From<&Log> for DebankEvent { + fn from(log: &Log) -> Self { let selector = log - .raw_log .topics() .first() .map(|h| h.to_string()) .unwrap_or_default(); - let topics = if log.raw_log.topics().len() > 1 { - log.raw_log.topics()[1..] - .iter() - .map(|h| h.to_string()) - .collect() + let topics = if log.topics().len() > 1 { + log.topics()[1..].iter().map(|h| h.to_string()).collect() } else { vec![] }; Self { + contract_id: log.address, selector, topics, - data: log.raw_log.data.clone(), + data: log.data.data.clone(), ..Default::default() } } @@ -411,12 +408,15 @@ impl From<&CallLog> for DebankEvent { #[allow(clippy::large_enum_variant)] enum DebankTraceOrLog { Trace(DebankTraceNode), - Log(DebankEvent), + Log { event: DebankEvent, success: bool }, } +const PARENT_CALL_FAILED_ERROR: &str = "parent call failed"; + struct DebankTraceNode { trace: DebankTrace, children: Vec, + frame_success: bool, success: bool, } @@ -425,15 +425,35 @@ fn build_trace_node( tx_id: String, parent_trace_id: String, pos_in_parent_trace: usize, - node: &CallTraceNode, + node_id: usize, nodes: &[CallTraceNode], + captured: &CapturedEvents, + visible: &[bool], parent_success: bool, trace_address: Vec, log_index: &mut usize, -) -> DebankTraceNode { +) -> Result { + let node = nodes + .get(node_id) + .ok_or_else(|| format!("missing trace node {node_id}"))?; + let frame = captured + .frames + .get(node_id) + .ok_or_else(|| format!("missing captured frame {node_id}"))?; + if node.trace.success != frame.success { + return Err(format!( + "trace and event inspectors disagree on frame {node_id} success" + )); + } + + let mut trace: DebankTrace = node.into(); + if !parent_success && trace.error.is_empty() { + trace.error = PARENT_CALL_FAILED_ERROR.to_string(); + } let mut debank_node = DebankTraceNode { - trace: node.into(), + trace, children: Vec::new(), + frame_success: node.trace.success, success: node.trace.success && parent_success, }; debank_node.trace.trace_address = trace_address.clone(); @@ -443,66 +463,30 @@ fn build_trace_node( debank_node.trace.id = debank_node.trace.debank_id(); let id = debank_node.trace.id.clone(); - let contract_id = node.execution_address(); - - let mut child_trace_address = Vec::new(); - for pos in &node.ordering { - match pos { - TraceMemberOrder::Call(i) => { - let child_node = &nodes[node.children[*i]]; - let mut ta = trace_address.clone(); - ta.push(*i); - child_trace_address = ta.clone(); - let child_trace = build_trace_node( - tx_id.clone(), - id.clone(), - debank_node.children.len(), - child_node, - nodes, - parent_success && debank_node.success, - ta, - log_index, - ); - if child_trace.trace.storage_change && child_node.trace.success { - debank_node.trace.storage_change = true; - } - debank_node - .children - .push(DebankTraceOrLog::Trace(child_trace)); - } - TraceMemberOrder::Log(i) => { - let mut child_event: DebankEvent = (&node.logs[*i]).into(); - child_event.pos_in_parent_trace = debank_node.children.len(); - child_event.contract_id = contract_id; - child_event.parent_trace_id = id.clone(); - child_event.id = child_event.debank_id(); - child_event.idx = *log_index; - // Always increment log_index regardless of trace success, - // because final success/error classification is based on - // receipt status (not CallTraceArena success). See trace_block.rs. - *log_index += 1; - debank_node - .children - .push(DebankTraceOrLog::Log(child_event)); - } - _ => {} - } - } + let mut next_trace_index = 0; + append_captured_members( + &mut debank_node, + node_id, + &tx_id, + &id, + nodes, + captured, + visible, + parent_success, + &trace_address, + &mut next_trace_index, + log_index, + )?; + debank_node.trace.subtraces = debank_node + .children + .iter() + .filter(|child| matches!(child, DebankTraceOrLog::Trace(_))) + .count(); + // selfdestruct handling if node.is_selfdestruct() { - // Build trace_address for selfdestruct: parent's trace_address + next child index. - // child_trace_address tracks the last child call's address, but if there are no - // child calls it stays empty. Fall back to parent trace_address + child count. - let selfdestruct_ta = if child_trace_address.is_empty() { - let mut ta = trace_address; - ta.push(node.children.len()); - ta - } else { - if let Some(last) = child_trace_address.last_mut() { - *last += 1; - } - child_trace_address - }; + let mut selfdestruct_ta = trace_address; + selfdestruct_ta.push(next_trace_index); debank_node.trace.subtraces += 1; let mut selfdestruct_trace = DebankTrace { from_addr: node.trace.selfdestruct_address.unwrap_or_default(), @@ -518,16 +502,113 @@ fn build_trace_node( call_create_type: "suicide".to_string(), ..Default::default() }; + if debank_node.frame_success && !debank_node.success { + selfdestruct_trace.error = PARENT_CALL_FAILED_ERROR.to_string(); + } selfdestruct_trace.id = selfdestruct_trace.debank_id(); debank_node .children .push(DebankTraceOrLog::Trace(DebankTraceNode { trace: selfdestruct_trace, children: vec![], - success: parent_success && debank_node.success, + frame_success: debank_node.frame_success, + success: debank_node.success, })); } - debank_node + Ok(debank_node) +} + +#[allow(clippy::too_many_arguments)] +fn append_captured_members( + debank_node: &mut DebankTraceNode, + frame_id: usize, + tx_id: &str, + parent_trace_id: &str, + nodes: &[CallTraceNode], + captured: &CapturedEvents, + visible: &[bool], + parent_success: bool, + trace_address: &[usize], + next_trace_index: &mut usize, + log_index: &mut usize, +) -> Result<(), String> { + let frame = captured + .frames + .get(frame_id) + .ok_or_else(|| format!("missing captured frame {frame_id}"))?; + let frame_success = parent_success && frame.success; + + for member in &frame.members { + match member { + CapturedMember::Call(child_id) => { + if visible.get(*child_id).copied().unwrap_or(false) { + let mut child_trace_address = trace_address.to_vec(); + child_trace_address.push(*next_trace_index); + *next_trace_index += 1; + let child_trace = build_trace_node( + tx_id.to_string(), + parent_trace_id.to_string(), + debank_node.children.len(), + *child_id, + nodes, + captured, + visible, + frame_success, + child_trace_address, + log_index, + )?; + // `storage_change` describes what this frame executed. Keep + // propagating a successful child's SSTORE even if an ancestor + // later reverts the transaction. + if child_trace.trace.storage_change && child_trace.frame_success { + debank_node.trace.storage_change = true; + } + debank_node + .children + .push(DebankTraceOrLog::Trace(child_trace)); + } else { + append_captured_members( + debank_node, + *child_id, + tx_id, + parent_trace_id, + nodes, + captured, + visible, + frame_success, + trace_address, + next_trace_index, + log_index, + )?; + } + } + CapturedMember::Event(event_id) => { + let captured_event = captured + .events + .get(*event_id) + .ok_or_else(|| format!("missing captured event {event_id}"))?; + if captured_event.frame != frame_id { + return Err(format!( + "captured event {event_id} belongs to frame {}, expected {frame_id}", + captured_event.frame + )); + } + let mut event: DebankEvent = (&captured_event.log).into(); + event.parent_trace_id = parent_trace_id.to_string(); + event.pos_in_parent_trace = debank_node.children.len(); + event.id = event.debank_id(); + if frame_success { + event.idx = *log_index; + *log_index += 1; + } + debank_node.children.push(DebankTraceOrLog::Log { + event, + success: frame_success, + }); + } + } + } + Ok(()) } fn finish_build_traces( @@ -548,11 +629,11 @@ fn finish_build_traces( trace.trace.parent_trace_id = node.trace.id.clone(); finish_build_traces(trace, traces, error_traces, events, error_events); } - DebankTraceOrLog::Log(log) => { - if node.success { - events.push(log.clone()); + DebankTraceOrLog::Log { event, success } => { + if *success { + events.push(event.clone()); } else { - error_events.push(log.clone()); + error_events.push(event.clone()); } } } @@ -562,30 +643,45 @@ fn finish_build_traces( /// Build DeBank traces and events from a revm `CallTraceArena`. /// /// Returns `(traces, error_traces, events, error_events)`. -pub fn build_debank_traces( - tx_id: H256, - traces: CallTraceArena, - log_index: &std::cell::RefCell, -) -> ( +pub(crate) type DebankTraceResult = ( Vec, Vec, Vec, Vec, -) { +); + +pub(crate) fn build_debank_traces( + tx_id: H256, + traces: CallTraceArena, + captured: CapturedEvents, + log_index: &std::cell::RefCell, +) -> Result { let nodes = traces.into_nodes(); + captured.validate(nodes.len())?; if nodes.is_empty() { - return (vec![], vec![], vec![], vec![]); + return Ok((vec![], vec![], vec![], vec![])); + } + let mut visible = vec![false; nodes.len()]; + visible[0] = true; + for node in &nodes { + for child in &node.children { + if let Some(is_visible) = visible.get_mut(*child) { + *is_visible = true; + } + } } let mut top = build_trace_node( tx_id.to_string(), String::new(), 0, - &nodes[0], + 0, &nodes, + &captured, + &visible, true, vec![], &mut log_index.borrow_mut(), - ); + )?; let mut traces = vec![]; let mut error_traces = vec![]; let mut events = vec![]; @@ -597,48 +693,53 @@ pub fn build_debank_traces( &mut events, &mut error_events, ); - (traces, error_traces, events, error_events) + Ok((traces, error_traces, events, error_events)) } // --------------------------------------------------------------------------- -// State diff extraction from execution cache +// State diff extraction from the canonical execution bundle // --------------------------------------------------------------------------- -pub fn get_storage_contracts_from_cache(cache: &Cache) -> Vec
{ - cache - .accounts +pub fn get_storage_contracts_from_bundle(bundle: &BundleState) -> Vec
{ + let mut storage_contracts: Vec<_> = bundle + .state .iter() .filter(|(_, account)| !account.storage.is_empty()) .map(|(address, _)| *address) - .collect() + .collect(); + storage_contracts.sort_unstable(); + storage_contracts } -pub fn get_storage_diffs_from_cache(cache: Cache, pre_db: DB) -> BlockStorageDiff { +pub fn get_storage_diffs_from_bundle( + bundle: BundleState, + pre_db: DB, +) -> BlockStorageDiff { let mut new_accounts = Vec::new(); let mut deleted_accounts = Vec::new(); let mut storage_diffs = Vec::new(); let mut new_codes = Vec::new(); - for (address, db_account) in cache.accounts { - if db_account.account_state == AccountState::NotExisting { + for (address, account) in bundle.state { + let Some(info) = account.info else { deleted_accounts.push(keccak256(address.0)); continue; - } + }; new_accounts.push(NewAccount { address: keccak256(address.0), - balance: db_account.info.balance, - nonce: db_account.info.nonce, - code_hash: db_account.info.code_hash, + balance: info.balance, + nonce: info.nonce, + code_hash: info.code_hash, }); - if !db_account.storage.is_empty() { - let diffs: Vec = db_account + if !account.storage.is_empty() { + let diffs: Vec = account .storage .into_iter() - .map(|(key, value)| IndexValuePair { + .map(|(key, slot)| IndexValuePair { index: keccak256::<[u8; 32]>(key.to_be_bytes()), - value, + value: slot.present_value, }) .collect(); if !diffs.is_empty() { @@ -649,8 +750,8 @@ pub fn get_storage_diffs_from_cache(cache: Cache, pre_db: DB) - } } - if let Some(code) = db_account.info.code { - let code_hash = db_account.info.code_hash; + if let Some(code) = info.code { + let code_hash = info.code_hash; if let Ok(Some(account)) = pre_db.basic_ref(address) && account.code_hash == code_hash { @@ -663,14 +764,32 @@ pub fn get_storage_diffs_from_cache(cache: Cache, pre_db: DB) - } } - BlockStorageDiff { + let mut state_diff = BlockStorageDiff { hash: H256::ZERO, parent_hash: H256::ZERO, new_accounts, deleted_accounts, storage_diffs, new_codes, + }; + sort_block_storage_diff(&mut state_diff); + state_diff +} + +fn sort_block_storage_diff(state_diff: &mut BlockStorageDiff) { + state_diff + .new_accounts + .sort_unstable_by_key(|account| account.address); + state_diff.deleted_accounts.sort_unstable(); + for account in &mut state_diff.storage_diffs { + account.diffs.sort_unstable_by_key(|diff| diff.index); } + state_diff + .storage_diffs + .sort_unstable_by_key(|account| account.address); + state_diff + .new_codes + .sort_unstable_by_key(|code| code.code_hash); } // --------------------------------------------------------------------------- @@ -678,12 +797,14 @@ pub fn get_storage_diffs_from_cache(cache: Cache, pre_db: DB) - // --------------------------------------------------------------------------- pub fn get_storage_contracts_from_genesis(genesis: &alloy_genesis::Genesis) -> Vec
{ - genesis + let mut storage_contracts: Vec<_> = genesis .alloc .iter() .filter(|(_, account)| account.storage.is_some()) .map(|(address, _)| *address) - .collect() + .collect(); + storage_contracts.sort_unstable(); + storage_contracts } impl From<&alloy_genesis::Genesis> for BlockStorageDiff { @@ -728,14 +849,16 @@ impl From<&alloy_genesis::Genesis> for BlockStorageDiff { } } - Self { + let mut state_diff = Self { hash: H256::ZERO, parent_hash: alloy_consensus::constants::EMPTY_ROOT_HASH, new_accounts, deleted_accounts: vec![], storage_diffs, new_codes, - } + }; + sort_block_storage_diff(&mut state_diff); + state_diff } } @@ -841,6 +964,145 @@ pub fn build_genesis_txs_and_traces( #[cfg(test)] mod tests { use super::*; + use revm::{ + database::{states::bundle_state::BundleRetention, EmptyDB}, + state::{Account, AccountInfo}, + DatabaseCommit, + }; + + #[test] + fn create_and_create2_use_the_protocol_create_type() { + assert_eq!(debank_call_types(CallKind::Create), ("create", "")); + assert_eq!(debank_call_types(CallKind::Create2), ("create", "")); + assert_eq!( + debank_call_types(CallKind::DelegateCall), + ("call", "delegatecall") + ); + } + + #[test] + fn state_diff_omits_touched_empty_nonexistent_account() { + let address = Address::repeat_byte(0x11); + let mut account = Account::new_not_existing(0); + account.mark_touch(); + let mut changes = revm::state::EvmState::default(); + changes.insert(address, account); + + let mut state = reth_revm::State::builder() + .with_database(EmptyDB::default()) + .with_bundle_update() + .build(); + state.insert_not_existing(address); + state.commit(changes); + state.merge_transitions(BundleRetention::PlainState); + let diff = get_storage_diffs_from_bundle(state.take_bundle(), EmptyDB::default()); + + assert!(diff.new_accounts.is_empty()); + assert!(diff.deleted_accounts.is_empty()); + assert!(diff.storage_diffs.is_empty()); + assert!(diff.new_codes.is_empty()); + } + + #[test] + fn state_diff_deletes_touched_existing_empty_account() { + let address = Address::repeat_byte(0x22); + let info = AccountInfo::default(); + let mut account = Account::from(info.clone()); + account.mark_touch(); + let mut changes = revm::state::EvmState::default(); + changes.insert(address, account); + + let mut state = reth_revm::State::builder() + .with_database(EmptyDB::default()) + .with_bundle_update() + .build(); + state.insert_account(address, info); + state.commit(changes); + state.merge_transitions(BundleRetention::PlainState); + let diff = get_storage_diffs_from_bundle(state.take_bundle(), EmptyDB::default()); + + assert!(diff.new_accounts.is_empty()); + assert_eq!(diff.deleted_accounts, vec![keccak256(address)]); + assert!(diff.storage_diffs.is_empty()); + assert!(diff.new_codes.is_empty()); + } + + #[test] + fn state_diff_sorting_is_stable_at_every_level() { + let account_1 = H256::repeat_byte(0x11); + let account_2 = H256::repeat_byte(0x22); + let slot_1 = H256::repeat_byte(0x33); + let slot_2 = H256::repeat_byte(0x44); + let code_1 = H256::repeat_byte(0x55); + let code_2 = H256::repeat_byte(0x66); + + let mut forward = BlockStorageDiff { + new_accounts: vec![ + NewAccount { + address: account_2, + balance: U256::from(2), + nonce: 2, + code_hash: code_2, + }, + NewAccount { + address: account_1, + balance: U256::from(1), + nonce: 1, + code_hash: code_1, + }, + ], + deleted_accounts: vec![account_2, account_1], + storage_diffs: vec![ + AccountStorageDiff { + address: account_2, + diffs: vec![ + IndexValuePair { + index: slot_2, + value: U256::from(2), + }, + IndexValuePair { + index: slot_1, + value: U256::from(1), + }, + ], + }, + AccountStorageDiff { + address: account_1, + diffs: vec![], + }, + ], + new_codes: vec![ + NewCode { + code_hash: code_2, + code: Bytes::from_static(&[0x02]), + }, + NewCode { + code_hash: code_1, + code: Bytes::from_static(&[0x01]), + }, + ], + ..Default::default() + }; + let mut reverse = forward.clone(); + reverse.new_accounts.reverse(); + reverse.deleted_accounts.reverse(); + reverse.storage_diffs.reverse(); + for account in &mut reverse.storage_diffs { + account.diffs.reverse(); + } + reverse.new_codes.reverse(); + + sort_block_storage_diff(&mut forward); + sort_block_storage_diff(&mut reverse); + + assert_eq!(forward, reverse); + assert_eq!(alloy_rlp::encode(&forward), alloy_rlp::encode(&reverse)); + assert_eq!(forward.new_accounts[0].address, account_1); + assert_eq!(forward.deleted_accounts[0], account_1); + assert_eq!(forward.storage_diffs[0].address, account_1); + assert_eq!(forward.storage_diffs[1].diffs[0].index, slot_1); + assert_eq!(forward.new_codes[0].code_hash, code_1); + } #[test] fn test_debank_tx_aa_fields_serialization() { diff --git a/crates/debank-rpc/src/event_inspector.rs b/crates/debank-rpc/src/event_inspector.rs new file mode 100644 index 0000000..8649bb8 --- /dev/null +++ b/crates/debank-rpc/src/event_inspector.rs @@ -0,0 +1,893 @@ +//! Captures full Arc logs and their execution-frame ordering. +//! +//! `revm-inspectors` 0.34 stores only `LogData` in `CallLog`, which loses the +//! emitter address. Arc also emits EIP-7708 logs directly through the journal +//! during frame initialization and SELFDESTRUCT handling. This inspector runs +//! alongside `TracingInspector` and records the complete logs without changing +//! execution. + +use alloy_primitives::Log; +use revm::{ + context_interface::{ContextTr, JournalTr}, + inspector::Inspector, + interpreter::{CallInputs, CallOutcome, CreateInputs, CreateOutcome, Interpreter}, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CapturedMember { + Call(usize), + Event(usize), +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct CapturedFrame { + pub parent: Option, + pub members: Vec, + pub success: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CapturedEvent { + pub frame: usize, + pub log: Log, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct CapturedEvents { + pub frames: Vec, + pub events: Vec, + valid: bool, +} + +impl CapturedEvents { + pub fn validate(&self, expected_frames: usize) -> Result<(), String> { + if !self.valid { + return Err("event inspector observed an invalid frame stack".to_string()); + } + if self.frames.len() != expected_frames { + return Err(format!( + "event inspector captured {} frames, tracing inspector captured {expected_frames}", + self.frames.len() + )); + } + Ok(()) + } + + pub fn successful_logs(&self) -> Vec { + let mut frame_success = Vec::with_capacity(self.frames.len()); + for frame in &self.frames { + let parent_success = frame + .parent + .and_then(|parent| frame_success.get(parent).copied()) + .unwrap_or(true); + frame_success.push(parent_success && frame.success); + } + + self.events + .iter() + .filter(|event| frame_success.get(event.frame).copied().unwrap_or(false)) + .map(|event| event.log.clone()) + .collect() + } +} + +#[derive(Clone, Debug)] +pub(crate) struct ArcEventInspector { + captured: CapturedEvents, + frame_stack: Vec, + journal_log_count: usize, +} + +impl Default for ArcEventInspector { + fn default() -> Self { + Self { + captured: CapturedEvents { + valid: true, + ..Default::default() + }, + frame_stack: Vec::new(), + journal_log_count: 0, + } + } +} + +impl ArcEventInspector { + pub fn into_captured(mut self) -> CapturedEvents { + if !self.frame_stack.is_empty() { + self.captured.valid = false; + } + self.captured + } + + fn push_frame(&mut self) { + let parent = self.frame_stack.last().copied(); + let frame_id = self.captured.frames.len(); + if let Some(parent_id) = parent { + if let Some(parent_frame) = self.captured.frames.get_mut(parent_id) { + parent_frame.members.push(CapturedMember::Call(frame_id)); + } else { + self.captured.valid = false; + } + } + self.captured.frames.push(CapturedFrame { + parent, + ..Default::default() + }); + self.frame_stack.push(frame_id); + } + + fn finish_frame(&mut self, success: bool) { + let Some(frame_id) = self.frame_stack.pop() else { + self.captured.valid = false; + return; + }; + if let Some(frame) = self.captured.frames.get_mut(frame_id) { + frame.success = success; + } else { + self.captured.valid = false; + } + } + + fn push_event(&mut self, log: Log) { + let Some(frame_id) = self.frame_stack.last().copied() else { + self.captured.valid = false; + return; + }; + let event_id = self.captured.events.len(); + self.captured.events.push(CapturedEvent { + frame: frame_id, + log, + }); + if let Some(frame) = self.captured.frames.get_mut(frame_id) { + frame.members.push(CapturedMember::Event(event_id)); + } else { + self.captured.valid = false; + } + } + + fn sync_journal(&mut self, context: &CTX) { + let logs = context.journal_ref().logs(); + if logs.len() < self.journal_log_count { + self.journal_log_count = logs.len(); + return; + } + let new_logs = logs[self.journal_log_count..].to_vec(); + self.journal_log_count = logs.len(); + for log in new_logs { + self.push_event(log); + } + } + + fn capture_callback_log(&mut self, context: &CTX, log: Log) { + self.push_event(log); + // Precompile execution reports all of its logs after the frame returns, + // so the journal may already contain more than the current callback. + // Those entries are delivered by subsequent callbacks and must not be + // copied again by `sync_journal`. + self.journal_log_count = context.journal_ref().logs().len(); + } +} + +impl Inspector for ArcEventInspector +where + CTX: ContextTr, +{ + fn initialize_interp(&mut self, _interp: &mut Interpreter, context: &mut CTX) { + self.sync_journal(context); + } + + fn step_end(&mut self, _interp: &mut Interpreter, context: &mut CTX) { + self.sync_journal(context); + } + + fn log(&mut self, context: &mut CTX, log: Log) { + self.capture_callback_log(context, log); + } + + fn call(&mut self, context: &mut CTX, _inputs: &mut CallInputs) -> Option { + self.sync_journal(context); + self.push_frame(); + None + } + + fn call_end(&mut self, context: &mut CTX, _inputs: &CallInputs, outcome: &mut CallOutcome) { + self.sync_journal(context); + self.finish_frame(outcome.instruction_result().is_ok()); + } + + fn create(&mut self, context: &mut CTX, _inputs: &mut CreateInputs) -> Option { + self.sync_journal(context); + self.push_frame(); + None + } + + fn create_end( + &mut self, + context: &mut CTX, + _inputs: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + self.sync_journal(context); + self.finish_frame(outcome.instruction_result().is_ok()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Address, LogData, B256}; + use revm::interpreter::InstructionResult; + use revm::{context::Context, MainContext}; + use revm_inspectors::tracing::{ + types::{CallKind, CallTrace, CallTraceNode, CallTraceStep}, + CallTraceArena, + }; + + use crate::debank_trace::{build_debank_traces, DebankTrace}; + + fn log(byte: u8) -> Log { + Log { + address: Address::repeat_byte(byte), + data: LogData::empty(), + } + } + + fn call_node( + idx: usize, + parent: Option, + address: Address, + success: bool, + status: InstructionResult, + children: Vec, + ) -> CallTraceNode { + CallTraceNode { + parent, + children, + idx, + trace: CallTrace { + success, + status: Some(status), + kind: CallKind::Call, + address, + ..Default::default() + }, + ..Default::default() + } + } + + fn call_arena(nodes: Vec) -> CallTraceArena { + let mut arena = CallTraceArena::default(); + *arena.nodes_mut() = nodes; + arena + } + + fn trace_for_address(traces: &[DebankTrace], address: Address) -> &DebankTrace { + traces + .iter() + .find(|trace| trace.to_addr == address) + .unwrap() + } + + #[test] + fn failed_subtree_does_not_taint_successful_sibling() { + let arena = call_arena(vec![ + call_node( + 0, + None, + Address::repeat_byte(1), + true, + InstructionResult::Stop, + vec![1, 4], + ), + call_node( + 1, + Some(0), + Address::repeat_byte(2), + false, + InstructionResult::Revert, + vec![2, 3], + ), + call_node( + 2, + Some(1), + Address::repeat_byte(3), + true, + InstructionResult::Stop, + vec![], + ), + call_node( + 3, + Some(1), + Address::repeat_byte(4), + false, + InstructionResult::OutOfGas, + vec![], + ), + call_node( + 4, + Some(0), + Address::repeat_byte(5), + true, + InstructionResult::Stop, + vec![], + ), + ]); + let logs = [log(2), log(3), log(5)]; + let captured = CapturedEvents { + frames: vec![ + CapturedFrame { + parent: None, + members: vec![CapturedMember::Call(1), CapturedMember::Call(4)], + success: true, + }, + CapturedFrame { + parent: Some(0), + members: vec![ + CapturedMember::Event(0), + CapturedMember::Call(2), + CapturedMember::Call(3), + ], + success: false, + }, + CapturedFrame { + parent: Some(1), + members: vec![CapturedMember::Event(1)], + success: true, + }, + CapturedFrame { + parent: Some(1), + members: vec![], + success: false, + }, + CapturedFrame { + parent: Some(0), + members: vec![CapturedMember::Event(2)], + success: true, + }, + ], + events: logs + .iter() + .cloned() + .enumerate() + .map(|(event, log)| CapturedEvent { + frame: [1, 2, 4][event], + log, + }) + .collect(), + valid: true, + }; + + let (traces, error_traces, events, error_events) = build_debank_traces( + B256::repeat_byte(0xaa), + arena, + captured, + &std::cell::RefCell::new(0), + ) + .unwrap(); + + assert_eq!(traces.len(), 2); + assert!(trace_for_address(&traces, Address::repeat_byte(1)) + .error + .is_empty()); + assert!(trace_for_address(&traces, Address::repeat_byte(5)) + .error + .is_empty()); + assert_eq!(error_traces.len(), 3); + assert_eq!( + trace_for_address(&error_traces, Address::repeat_byte(2)).error, + "Reverted" + ); + assert_eq!( + trace_for_address(&error_traces, Address::repeat_byte(3)).error, + "parent call failed" + ); + assert_eq!( + trace_for_address(&error_traces, Address::repeat_byte(4)).error, + "Out of gas" + ); + assert_eq!(events.len(), 1); + assert_eq!(events[0].contract_id, logs[2].address); + assert_eq!( + events[0].parent_trace_id, + trace_for_address(&traces, Address::repeat_byte(5)).id + ); + assert_eq!(events[0].idx, 0); + assert_eq!(error_events.len(), 2); + assert_eq!(error_events[0].contract_id, logs[0].address); + assert_eq!( + error_events[0].parent_trace_id, + trace_for_address(&error_traces, Address::repeat_byte(2)).id + ); + assert_eq!(error_events[1].contract_id, logs[1].address); + assert_eq!( + error_events[1].parent_trace_id, + trace_for_address(&error_traces, Address::repeat_byte(3)).id + ); + assert!(error_events.iter().all(|event| event.idx == 0)); + } + + // Defensive coverage for any arena node that is not attached to its parent. Standard + // precompiles are attached in the current Arc configuration, so this shape is synthetic. + #[test] + fn hidden_failed_frame_taints_visible_descendant() { + let root = call_node( + 0, + None, + Address::repeat_byte(1), + true, + InstructionResult::Stop, + vec![], + ); + let mut hidden = call_node( + 1, + Some(0), + Address::repeat_byte(2), + false, + InstructionResult::Revert, + vec![2], + ); + hidden.trace.maybe_precompile = Some(true); + let descendant = call_node( + 2, + Some(1), + Address::repeat_byte(3), + true, + InstructionResult::Stop, + vec![], + ); + let arena = call_arena(vec![root, hidden, descendant]); + let descendant_log = log(3); + let captured = CapturedEvents { + frames: vec![ + CapturedFrame { + parent: None, + members: vec![CapturedMember::Call(1)], + success: true, + }, + CapturedFrame { + parent: Some(0), + members: vec![CapturedMember::Call(2)], + success: false, + }, + CapturedFrame { + parent: Some(1), + members: vec![CapturedMember::Event(0)], + success: true, + }, + ], + events: vec![CapturedEvent { + frame: 2, + log: descendant_log.clone(), + }], + valid: true, + }; + + let (traces, error_traces, events, error_events) = build_debank_traces( + B256::repeat_byte(0xaa), + arena, + captured, + &std::cell::RefCell::new(0), + ) + .unwrap(); + + assert_eq!(traces.len(), 1); + assert_eq!(traces[0].to_addr, Address::repeat_byte(1)); + assert_eq!(error_traces.len(), 1); + assert_eq!(error_traces[0].to_addr, Address::repeat_byte(3)); + assert_eq!(error_traces[0].error, "parent call failed"); + assert_eq!(error_traces[0].trace_address, vec![0]); + assert_eq!(error_traces[0].parent_trace_id, traces[0].id); + assert!(events.is_empty()); + assert_eq!(error_events.len(), 1); + assert_eq!(error_events[0].contract_id, descendant_log.address); + assert_eq!(error_events[0].parent_trace_id, error_traces[0].id); + assert_eq!(error_events[0].idx, 0); + } + + #[test] + fn successful_logs_follow_emission_order_and_filter_failed_frames() { + let mut inspector = ArcEventInspector::default(); + inspector.push_frame(); + inspector.push_event(log(1)); + inspector.push_frame(); + inspector.push_event(log(2)); + inspector.finish_frame(false); + inspector.push_event(log(3)); + inspector.finish_frame(true); + + let captured = inspector.into_captured(); + assert!(captured.validate(2).is_ok()); + assert_eq!(captured.successful_logs(), vec![log(1), log(3)]); + } + + #[test] + fn incomplete_frame_stack_is_rejected() { + let mut inspector = ArcEventInspector::default(); + inspector.push_frame(); + assert!(inspector.into_captured().validate(1).is_err()); + } + + #[test] + fn journal_and_callback_logs_are_captured_once_and_keep_duplicates() { + let mut context = Context::mainnet(); + let mut inspector = ArcEventInspector::default(); + let duplicate = log(1); + let direct = log(2); + inspector.push_frame(); + + // Precompile callbacks arrive after all returned logs have already been + // appended to the journal. + context.journal_mut().log(duplicate.clone()); + context.journal_mut().log(duplicate.clone()); + inspector.capture_callback_log(&context, duplicate.clone()); + inspector.capture_callback_log(&context, duplicate.clone()); + inspector.sync_journal(&context); + + // Arc frame-init and SELFDESTRUCT logs have no inspector callback. + context.journal_mut().log(direct.clone()); + inspector.sync_journal(&context); + inspector.finish_frame(true); + + let captured = inspector.into_captured(); + assert_eq!( + captured.successful_logs(), + vec![duplicate.clone(), duplicate, direct] + ); + assert_eq!( + captured.frames[0].members, + vec![ + CapturedMember::Event(0), + CapturedMember::Event(1), + CapturedMember::Event(2), + ] + ); + } + + #[test] + fn builder_flattens_hidden_precompile_events_without_losing_order_or_emitter() { + let mut arena = CallTraceArena::default(); + arena.nodes_mut()[0] = CallTraceNode { + children: vec![2], + trace: CallTrace { + success: true, + status: Some(InstructionResult::Return), + kind: CallKind::Call, + address: Address::repeat_byte(0x10), + ..Default::default() + }, + ..Default::default() + }; + arena.nodes_mut().push(CallTraceNode { + parent: Some(0), + idx: 1, + trace: CallTrace { + success: true, + status: Some(InstructionResult::Return), + kind: CallKind::Call, + address: Address::repeat_byte(0x20), + maybe_precompile: Some(true), + ..Default::default() + }, + ..Default::default() + }); + arena.nodes_mut().push(CallTraceNode { + parent: Some(0), + idx: 2, + trace: CallTrace { + success: false, + status: Some(InstructionResult::Revert), + kind: CallKind::Call, + address: Address::repeat_byte(0x30), + ..Default::default() + }, + ..Default::default() + }); + + let logs = [log(1), log(2), log(3), log(4)]; + let captured = CapturedEvents { + frames: vec![ + CapturedFrame { + parent: None, + members: vec![ + CapturedMember::Event(0), + CapturedMember::Call(1), + CapturedMember::Call(2), + CapturedMember::Event(3), + ], + success: true, + }, + CapturedFrame { + parent: Some(0), + members: vec![CapturedMember::Event(1)], + success: true, + }, + CapturedFrame { + parent: Some(0), + members: vec![CapturedMember::Event(2)], + success: false, + }, + ], + events: logs + .iter() + .cloned() + .enumerate() + .map(|(frame, log)| CapturedEvent { + frame: [0, 1, 2, 0][frame], + log, + }) + .collect(), + valid: true, + }; + + let (traces, error_traces, events, error_events) = build_debank_traces( + B256::repeat_byte(0xaa), + arena, + captured, + &std::cell::RefCell::new(0), + ) + .unwrap(); + + assert_eq!(traces.len(), 1); + assert_eq!(error_traces.len(), 1); + assert_eq!(error_traces[0].trace_address, vec![0]); + assert_eq!(error_traces[0].pos_in_parent_trace, 2); + assert_eq!(events.len(), 3); + assert_eq!(events[0].contract_id, logs[0].address); + assert_eq!(events[0].pos_in_parent_trace, 0); + assert_eq!(events[1].contract_id, logs[1].address); + assert_eq!(events[1].pos_in_parent_trace, 1); + assert_eq!(events[2].contract_id, logs[3].address); + assert_eq!(events[2].pos_in_parent_trace, 3); + assert_eq!( + events.iter().map(|event| event.idx).collect::>(), + vec![0, 1, 2] + ); + assert_eq!(error_events.len(), 1); + assert_eq!(error_events[0].contract_id, logs[2].address); + assert_eq!(error_events[0].idx, 0); + } + + #[test] + fn builder_puts_hidden_failure_before_event_and_selfdestruct() { + let selfdestruct_address = Address::repeat_byte(0x11); + let refund_target = Address::repeat_byte(0x22); + let transferred_value = alloy_primitives::U256::from(3); + let mut arena = CallTraceArena::default(); + arena.nodes_mut()[0] = CallTraceNode { + trace: CallTrace { + success: true, + status: Some(InstructionResult::SelfDestruct), + kind: CallKind::Call, + address: Address::repeat_byte(0x10), + selfdestruct_address: Some(selfdestruct_address), + selfdestruct_refund_target: Some(refund_target), + selfdestruct_transferred_value: Some(transferred_value), + ..Default::default() + }, + ..Default::default() + }; + arena.nodes_mut().push(CallTraceNode { + parent: Some(0), + idx: 1, + trace: CallTrace { + success: false, + status: Some(InstructionResult::Revert), + kind: CallKind::Call, + address: Address::repeat_byte(0x20), + maybe_precompile: Some(true), + ..Default::default() + }, + ..Default::default() + }); + + let failed_log = log(1); + let root_log = log(2); + let captured = CapturedEvents { + frames: vec![ + CapturedFrame { + parent: None, + members: vec![CapturedMember::Call(1), CapturedMember::Event(1)], + success: true, + }, + CapturedFrame { + parent: Some(0), + members: vec![CapturedMember::Event(0)], + success: false, + }, + ], + events: vec![ + CapturedEvent { + frame: 1, + log: failed_log, + }, + CapturedEvent { + frame: 0, + log: root_log, + }, + ], + valid: true, + }; + + let (traces, error_traces, events, error_events) = build_debank_traces( + B256::repeat_byte(0xaa), + arena, + captured, + &std::cell::RefCell::new(0), + ) + .unwrap(); + + assert!(error_traces.is_empty()); + assert_eq!(traces.len(), 2); + assert_eq!(traces[0].subtraces, 1); + assert_eq!(events.len(), 1); + assert_eq!(events[0].pos_in_parent_trace, 1); + assert_eq!(error_events.len(), 1); + assert_eq!(error_events[0].pos_in_parent_trace, 0); + assert_eq!(error_events[0].idx, 0); + + let suicide = &traces[1]; + assert_eq!(suicide.call_create_type, "suicide"); + assert_eq!(suicide.from_addr, selfdestruct_address); + assert_eq!(suicide.to_addr, refund_target); + assert_eq!(suicide.value, transferred_value); + assert_eq!(suicide.pos_in_parent_trace, 2); + assert_eq!(suicide.trace_address, vec![0]); + } + + #[test] + fn successful_child_storage_change_propagates_through_failed_parent() { + let mut arena = CallTraceArena::default(); + arena.nodes_mut()[0] = CallTraceNode { + children: vec![1], + trace: CallTrace { + success: false, + status: Some(InstructionResult::Revert), + kind: CallKind::Call, + ..Default::default() + }, + ..Default::default() + }; + arena.nodes_mut().push(CallTraceNode { + parent: Some(0), + children: vec![2], + idx: 1, + trace: CallTrace { + success: true, + status: Some(InstructionResult::Return), + kind: CallKind::Call, + steps: vec![CallTraceStep { + pc: 0, + op: revm::bytecode::opcode::OpCode::SSTORE, + stack: None, + push_stack: None, + memory: None, + returndata: Default::default(), + gas_remaining: 0, + gas_refund_counter: 0, + gas_used: 0, + gas_cost: 0, + storage_change: None, + status: None, + immediate_bytes: None, + decoded: None, + }], + ..Default::default() + }, + ..Default::default() + }); + arena.nodes_mut().push(CallTraceNode { + parent: Some(1), + idx: 2, + trace: CallTrace { + success: false, + status: Some(InstructionResult::OutOfGas), + kind: CallKind::Call, + ..Default::default() + }, + ..Default::default() + }); + let captured = CapturedEvents { + frames: vec![ + CapturedFrame { + parent: None, + members: vec![CapturedMember::Call(1)], + success: false, + }, + CapturedFrame { + parent: Some(0), + members: vec![CapturedMember::Call(2)], + success: true, + }, + CapturedFrame { + parent: Some(1), + members: vec![], + success: false, + }, + ], + events: vec![], + valid: true, + }; + + let (traces, error_traces, events, error_events) = build_debank_traces( + B256::repeat_byte(0xaa), + arena, + captured, + &std::cell::RefCell::new(0), + ) + .unwrap(); + + assert!(traces.is_empty()); + assert!(events.is_empty()); + assert!(error_events.is_empty()); + assert_eq!(error_traces.len(), 3); + assert_eq!(error_traces[0].error, "Reverted"); + assert_eq!(error_traces[1].error, "parent call failed"); + assert_eq!(error_traces[2].error, "Out of gas"); + assert!(error_traces[0].storage_change); + assert!(error_traces[1].self_storage_change); + } + + #[test] + fn selfdestruct_under_failed_parent_has_parent_error() { + let refund_target = Address::repeat_byte(0x22); + let mut arena = CallTraceArena::default(); + arena.nodes_mut()[0] = CallTraceNode { + children: vec![1], + trace: CallTrace { + success: false, + status: Some(InstructionResult::Revert), + kind: CallKind::Call, + ..Default::default() + }, + ..Default::default() + }; + arena.nodes_mut().push(CallTraceNode { + parent: Some(0), + idx: 1, + trace: CallTrace { + success: true, + status: Some(InstructionResult::SelfDestruct), + kind: CallKind::Call, + selfdestruct_address: Some(Address::repeat_byte(0x11)), + selfdestruct_refund_target: Some(refund_target), + ..Default::default() + }, + ..Default::default() + }); + let captured = CapturedEvents { + frames: vec![ + CapturedFrame { + parent: None, + members: vec![CapturedMember::Call(1)], + success: false, + }, + CapturedFrame { + parent: Some(0), + members: vec![], + success: true, + }, + ], + events: vec![], + valid: true, + }; + + let (traces, error_traces, events, error_events) = build_debank_traces( + B256::repeat_byte(0xaa), + arena, + captured, + &std::cell::RefCell::new(0), + ) + .unwrap(); + + assert!(traces.is_empty()); + assert!(events.is_empty()); + assert!(error_events.is_empty()); + assert_eq!(error_traces.len(), 3); + let suicide = error_traces + .iter() + .find(|trace| trace.call_create_type == "suicide") + .unwrap(); + assert_eq!(suicide.to_addr, refund_target); + assert_eq!(suicide.error, "parent call failed"); + } +} diff --git a/crates/debank-rpc/src/lib.rs b/crates/debank-rpc/src/lib.rs index 9dfb659..798b957 100644 --- a/crates/debank-rpc/src/lib.rs +++ b/crates/debank-rpc/src/lib.rs @@ -16,9 +16,9 @@ pub mod debank_trace; pub mod erc20_handle; +mod event_inspector; pub mod multi_call; pub mod pre; -pub mod state_diff_db; pub mod trace_block; pub mod types; diff --git a/crates/debank-rpc/src/state_diff_db.rs b/crates/debank-rpc/src/state_diff_db.rs deleted file mode 100644 index a433b90..0000000 --- a/crates/debank-rpc/src/state_diff_db.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! A database wrapper that captures all state changes for diff extraction. -//! -//! Ported from reth-x `crates/rpc/rpc-eth-types/src/cache/db.rs`. - -use alloy_primitives::{map::HashMap, Address, B256, U256}; -use revm::{ - database::InMemoryDB, - state::{Account, AccountInfo, Bytecode}, - Database, DatabaseCommit, DatabaseRef, -}; - -/// A database that wraps an external database and an in-memory diff database. -/// -/// - `Database` (mutable) reads/writes go through `db` (typically a `StateCacheDb` -/// which has an internal cache). During block replay, subsequent txs see prior -/// commits via this cache. -/// - `DatabaseRef` (immutable) reads also delegate to `db`, but `State`'s -/// `DatabaseRef` impl bypasses its cache and reads the underlying provider -/// directly. This means `basic_ref()` returns the original parent-block state, -/// not state modified by prior commits. This is fine for our use case — only -/// `pre_db` uses `DatabaseRef` for diff comparison. -/// - `DatabaseCommit` writes to both `diff` (captures all changes for state-diff -/// extraction) and `db` (updates state for subsequent txs). -#[derive(Debug, Clone)] -pub struct StateDiffTraceDB { - /// The diff that stores all state changes. - pub diff: InMemoryDB, - /// The underlying database (read+write). - pub db: ExtDB, -} - -impl StateDiffTraceDB { - pub fn new(db: ExtDB) -> Self { - Self { - diff: InMemoryDB::default(), - db, - } - } -} - -impl Database for StateDiffTraceDB { - type Error = ExtDB::Error; - - fn basic(&mut self, address: Address) -> Result, Self::Error> { - self.db.basic(address) - } - - fn code_by_hash(&mut self, code_hash: B256) -> Result { - self.db.code_by_hash(code_hash) - } - - fn storage(&mut self, address: Address, index: U256) -> Result { - self.db.storage(address, index) - } - - fn block_hash(&mut self, number: u64) -> Result { - self.db.block_hash(number) - } -} - -impl DatabaseRef for StateDiffTraceDB { - type Error = ExtDB::Error; - - fn basic_ref(&self, address: Address) -> Result, Self::Error> { - self.db.basic_ref(address) - } - - fn code_by_hash_ref(&self, code_hash: B256) -> Result { - self.db.code_by_hash_ref(code_hash) - } - - fn storage_ref(&self, address: Address, index: U256) -> Result { - self.db.storage_ref(address, index) - } - - fn block_hash_ref(&self, number: u64) -> Result { - self.db.block_hash_ref(number) - } -} - -impl DatabaseCommit for StateDiffTraceDB { - // D16 (revm 34 vs revm 36 divergence): revm 34's `DatabaseCommit::commit` takes - // `HashMap` (default hasher); revm 36 takes `AddressMap` - // (FbBuildHasher<20>). Tempo's source uses the latter; adapted here. - fn commit(&mut self, changes: HashMap) { - self.diff.commit(changes.clone()); - self.db.commit(changes); - } -} diff --git a/crates/debank-rpc/src/trace_block.rs b/crates/debank-rpc/src/trace_block.rs index 7223668..38b46b3 100644 --- a/crates/debank-rpc/src/trace_block.rs +++ b/crates/debank-rpc/src/trace_block.rs @@ -5,11 +5,11 @@ use alloy_consensus::{transaction::TxHashRef, BlockHeader, Transaction}; use alloy_eips::BlockId; -use alloy_primitives::B256; +use alloy_primitives::{Address, Bytes, Log, B256}; use alloy_rpc_types_eth::Header; use jsonrpsee::core::RpcResult; use reth_chainspec::{EthChainSpec, EthereumHardforks}; -use reth_evm::ConfigureEvm; +use reth_evm::{execute::BlockExecutor, ConfigureEvm, Evm}; use reth_primitives_traits::BlockBody; use reth_provider::ChainSpecProvider; use reth_revm::{database::StateProviderDatabase, State}; @@ -20,12 +20,53 @@ use reth_rpc_eth_api::{ EthApiTypes, }; use reth_rpc_eth_types::{cache::db::StateProviderTraitObjWrapper, EthApiError}; -use revm::bytecode::opcode::OpCode; -use revm::DatabaseCommit; +use revm::{bytecode::opcode::OpCode, database::states::bundle_state::BundleRetention}; use revm_inspectors::tracing::{OpcodeFilter, TracingInspector, TracingInspectorConfig}; use crate::debank_trace::*; -use crate::state_diff_db::StateDiffTraceDB; +use crate::event_inspector::ArcEventInspector; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TxExecutionOutcome { + success: bool, + gas_used: u64, + output: Option, + created_address: Option
, + logs: Vec, +} + +impl TxExecutionOutcome { + fn from_result(result: &revm::context::result::ExecutionResult) -> Self { + Self { + success: result.is_success(), + gas_used: result.gas_used(), + output: result.output().cloned(), + created_address: result.created_address(), + logs: result.logs().to_vec(), + } + } +} + +fn new_debank_inspector() -> (TracingInspector, ArcEventInspector) { + let mut config = TracingInspectorConfig::default_parity() + .set_steps(true) + .set_record_logs(true) + .set_exclude_precompile_calls(false); + config.record_opcodes_filter = Some(OpcodeFilter::new().enabled(OpCode::SSTORE)); + (TracingInspector::new(config), ArcEventInspector::default()) +} + +fn receipt_logs(receipt: &T) -> Result, EthApiError> { + let value = serde_json::to_value(receipt) + .map_err(|err| EthApiError::EvmCustom(format!("failed to serialize receipt: {err}")))?; + let logs = value + .get("logs") + .cloned() + .ok_or_else(|| EthApiError::EvmCustom("receipt is missing logs".to_string()))?; + let logs: Vec = serde_json::from_value(logs) + .map_err(|err| EthApiError::EvmCustom(format!("failed to decode receipt logs: {err}")))?; + Ok(logs.into_iter().map(|log| log.inner).collect()) +} /// `trace` namespace API implementation for `debankBlock`. #[derive(Clone)] @@ -134,6 +175,15 @@ where }; let block_txs = block.body().transactions(); + if receipts.len() != block_txs.len() { + return Err(EthApiError::EvmCustom(format!( + "block {} has {} transactions but {} receipts", + block.number(), + block_txs.len(), + receipts.len(), + )) + .into()); + } let mut debank_txs: Vec = Vec::with_capacity(block_txs.len()); for index in 0..block_txs.len() { @@ -147,7 +197,7 @@ where let dtx = DebankTransaction { id: receipt.transaction_hash().to_string(), from: receipt.from(), - to: receipt.to().unwrap_or_default(), + to: debank_transaction_target(receipt.to(), receipt.contract_address()), gas_limit: tx.gas_limit(), gas_price: receipt.effective_gas_price(), gas_used: receipt.gas_used(), @@ -167,42 +217,13 @@ where // Per-tx receipt status drives the failed-tx classification below. let tx_statuses: Vec = receipts.iter().map(|r| r.status()).collect(); - // Per-tx receipt logs — needed for the precompile-emitted log - // reconciliation below (D20). `ReceiptResponse` trait does not expose - // logs(); serde-deserialize each receipt's logs into `alloy_rpc_types_eth::Log` - // (a stable type) and convert to `DebankEvent` placeholders. Final - // contract_id / selector / topics / data are reused as-is; `idx` is - // re-assigned at the attach site so it lines up with block-global order. - let receipt_logs_per_tx: Vec> = receipts + // `ReceiptResponse` does not expose logs directly. Decode its stable RPC + // representation once and retain the complete consensus logs for exact + // comparison with both execution passes and the event inspector. + let receipt_logs_per_tx: Vec> = receipts .iter() - .map(|receipt| { - let logs: Vec = serde_json::to_value(receipt) - .ok() - .and_then(|v| v.get("logs").cloned()) - .and_then(|v| serde_json::from_value(v).ok()) - .unwrap_or_default(); - logs.iter() - .enumerate() - .map(|(log_idx, log)| { - let selector = log - .topics() - .first() - .map(|h| h.to_string()) - .unwrap_or_default(); - let topics: Vec = - log.topics().iter().skip(1).map(|h| h.to_string()).collect(); - DebankEvent { - contract_id: log.address(), - selector, - topics, - data: log.data().data.clone(), - idx: log_idx, - ..Default::default() - } - }) - .collect() - }) - .collect(); + .map(receipt_logs) + .collect::>()?; let parent_hash = block.parent_hash(); let parent_block = self.eth_api.recovered_block(parent_hash.into()).await?; @@ -230,255 +251,189 @@ where let parent_block_id = BlockId::hash(parent_hash); let tx_statuses_clone = tx_statuses.clone(); + let block_number = block.number(); let (traces_result, state_diff, change_addresses) = self .eth_api .spawn_blocking_io_fut(move |eth_api| async move { - // Two independent state providers from the same parent block: - // pre_db for diff comparison, db for tx execution - let state1 = eth_api.state_at_block_id(parent_block_id).await?; - let state2 = eth_api.state_at_block_id(parent_block_id).await?; + // Three independent views of the same parent state: one for + // comparing the final diff, one for canonical block execution, + // and one for trace replay. + let pre_state = eth_api.state_at_block_id(parent_block_id).await?; + let state_pass_provider = eth_api.state_at_block_id(parent_block_id).await?; + let trace_pass_provider = eth_api.state_at_block_id(parent_block_id).await?; let pre_db = State::builder() .with_database(StateProviderDatabase::new(StateProviderTraitObjWrapper( - state1, + pre_state, ))) .build(); - let cache_db = State::builder() + + // State pass: execute the complete block through ArcBlockExecutor, + // including pre- and post-execution changes. Bundle updates preserve + // the canonical EIP-161 and net-storage-change semantics applied by State. + let mut canonical_outcomes = Vec::with_capacity(tx_hashes.len()); + let bundle = { + let mut state_db = State::builder() + .with_database(StateProviderDatabase::new(StateProviderTraitObjWrapper( + state_pass_provider, + ))) + .with_bundle_update() + .build(); + let mut executor = eth_api + .evm_config() + .executor_for_block(&mut state_db, block.sealed_block()) + .map_err(|err| EthApiError::EvmCustom(err.to_string()))?; + + executor + .apply_pre_execution_changes() + .map_err(EthApiError::from)?; + for tx in block.transactions_recovered() { + executor + .execute_transaction_with_result_closure(tx, |result| { + canonical_outcomes.push(TxExecutionOutcome::from_result(result)); + }) + .map_err(EthApiError::from)?; + } + let (evm, execution_result) = executor.finish().map_err(EthApiError::from)?; + if execution_result.gas_used != block.gas_used() + || execution_result.receipts.len() != receipts.len() + { + return Err(EthApiError::EvmCustom(format!( + "canonical replay mismatch for block {}: gas {}/{}, receipts {}/{}", + block.number(), + execution_result.gas_used, + block.gas_used(), + execution_result.receipts.len(), + receipts.len(), + )) + .into()); + } + for (idx, outcome) in canonical_outcomes.iter().enumerate() { + let receipt = receipts.get(idx).ok_or_else(|| { + EthApiError::EvmCustom(format!( + "missing receipt {idx} for block {}", + block.number() + )) + })?; + if outcome.success != tx_statuses_clone.get(idx).copied().unwrap_or(false) + || outcome.gas_used != receipt.gas_used() + || outcome.logs + != receipt_logs_per_tx.get(idx).cloned().unwrap_or_default() + { + return Err(EthApiError::EvmCustom(format!( + "canonical replay differs from receipt {idx} for block {}", + block.number() + )) + .into()); + } + } + let (state_db, _) = evm.finish(); + state_db.merge_transitions(BundleRetention::PlainState); + state_db.take_bundle() + }; + + let change_addresses = get_storage_contracts_from_bundle(&bundle); + let state_diff = get_storage_diffs_from_bundle(bundle, pre_db); + + let mut trace_db = State::builder() .with_database(StateProviderDatabase::new(StateProviderTraitObjWrapper( - state2, + trace_pass_provider, ))) .build(); - let mut diff_db = StateDiffTraceDB::new(cache_db); + let evm_config = eth_api.evm_config(); + let trace_context = evm_config + .context_for_block(block.sealed_block()) + .map_err(|err| EthApiError::EvmCustom(err.to_string()))?; + let trace_evm = evm_config.evm_with_env_and_inspector( + &mut trace_db, + evm_env, + new_debank_inspector(), + ); + let mut trace_executor = evm_config.create_executor(trace_evm, trace_context); + trace_executor + .apply_pre_execution_changes() + .map_err(EthApiError::from)?; + // Pre-execution system calls are block-level state changes, not + // transaction traces. Start the first transaction with a fresh arena. + *trace_executor.evm_mut().inspector_mut() = new_debank_inspector(); let log_index = std::cell::RefCell::new(0usize); // (traces, error_traces, events, error_events) - type PerTxResult = ( - Vec, - Vec, - Vec, - Vec, - ); - let mut all_results: Vec = Vec::new(); + let mut all_results: Vec = Vec::new(); for (idx, tx) in block.transactions_recovered().enumerate() { let tx_hash = tx_hashes[idx]; + let mut trace_outcome = None; + trace_executor + .execute_transaction_with_result_closure(tx, |result| { + trace_outcome = Some(TxExecutionOutcome::from_result(result)); + }) + .map_err(EthApiError::from)?; + let trace_outcome = trace_outcome.ok_or_else(|| { + EthApiError::EvmCustom(format!( + "missing trace result for transaction {tx_hash}" + )) + })?; + if canonical_outcomes.get(idx) != Some(&trace_outcome) { + return Err(EthApiError::EvmCustom(format!( + "state and trace replay differ for transaction {tx_hash}" + )) + .into()); + } - // D6: keep `exclude_precompile_calls(true)`. Arc's `0x1800...` - // custom precompiles are injected via reth's precompile lookup, - // not via `warm_addresses()`, so they are not affected. - let mut trace_cfg = TracingInspectorConfig::default_parity() - .set_steps(true) - .set_record_logs(true) - .set_exclude_precompile_calls(true); - trace_cfg.record_opcodes_filter = - Some(OpcodeFilter::new().enabled(OpCode::SSTORE)); - let mut inspector = TracingInspector::new(trace_cfg); - - let tx_env = eth_api.evm_config().tx_env(tx); - - let revm::context::result::ResultAndState { - result: exec_result, - state, - } = eth_api.inspect(&mut diff_db, evm_env.clone(), tx_env, &mut inspector)?; - diff_db.commit(state); - - // `ExecutionResult::into_logs()` returns `Vec` for - // `Success`, empty for `Revert`/`Halt` (revm 34). Used below - // to recover precompile-emitted logs the inspector missed. - let exec_logs = exec_result.into_logs(); - - let arena = inspector.into_traces(); + let exec_logs = trace_outcome.logs; + let (tracing_inspector, event_inspector) = std::mem::replace( + trace_executor.evm_mut().inspector_mut(), + new_debank_inspector(), + ); + let arena = tracing_inspector.into_traces(); + let captured_events = event_inspector.into_captured(); + if captured_events.successful_logs() != exec_logs { + return Err(EthApiError::EvmCustom(format!( + "event inspector logs differ from execution for transaction {tx_hash}" + )) + .into()); + } let (traces, error_traces, events, error_events) = - build_debank_traces(tx_hash, arena, &log_index); - - // D19 (reverted; restored as D20): reconcile against receipt logs - // to recover Arc NCA precompile-emitted logs that bypass EVM call - // frames (same problem class as Tempo's TempoEvmHandler fee logs — - // the inspector cannot capture them). - // - // For successful txs: `exec_logs` (from `ExecutionResult::Success`) - // contains all logs including precompile-emitted ones. Extra logs - // beyond what the inspector captured are the precompile entries. - // - // For reverted txs: `ExecutionResult::Revert` has NO logs - // (`exec_logs` is empty). ALL receipt logs are precompile-emitted - // (EVM logs would have been reverted and never enter the receipt). - // Use receipt logs directly — do NOT compare with - // `evm_event_count`, since the inspector may have captured N - // error_events from pre-revert emits, and receipt_log_count - // (precompile only) < N would cause log loss. - let evm_event_count = events.len() + error_events.len(); - let tx_reverted = !tx_statuses_clone.get(idx).copied().unwrap_or(true); - let receipt_logs = receipt_logs_per_tx.get(idx).cloned().unwrap_or_default(); + build_debank_traces(tx_hash, arena, captured_events, &log_index) + .map_err(EthApiError::EvmCustom)?; all_results.push((traces, error_traces, events, error_events)); - - // Determine extra-log source: exec_logs for success, receipt - // for revert. Use block-global log_index for idx (not tx-local). - let extra_log_source: Vec = if tx_reverted { - // Revert path: all receipt logs are precompile-emitted - receipt_logs - .iter() - .map(|rl| { - let current_idx = *log_index.borrow(); - *log_index.borrow_mut() += 1; - DebankEvent { - contract_id: rl.contract_id, - selector: rl.selector.clone(), - topics: rl.topics.clone(), - data: rl.data.clone(), - idx: current_idx, - ..Default::default() - } - }) - .collect() - } else if exec_logs.len() > evm_event_count { - // Success path: use exec_logs beyond inspector-captured events - exec_logs[evm_event_count..] - .iter() - .map(|log| { - let selector = log - .topics() - .first() - .map(|h| h.to_string()) - .unwrap_or_default(); - let topics = if log.topics().len() > 1 { - log.topics()[1..].iter().map(|h| h.to_string()).collect() - } else { - vec![] - }; - let current_idx = *log_index.borrow(); - *log_index.borrow_mut() += 1; - DebankEvent { - contract_id: log.address, - selector, - topics, - data: log.data.data.clone(), - idx: current_idx, - ..Default::default() - } - }) - .collect() - } else { - vec![] - }; - - if !extra_log_source.is_empty() { - // Safe: just pushed to `all_results` above, so it's non-empty. - let last = all_results - .last() - .expect("all_results pushed in the line above"); - let root_trace_id = last - .0 - .first() - .or(last.1.first()) - .map(|t| t.id.clone()) - .unwrap_or_default(); - // Compute base pos from root trace's subtraces + all events - // already attached to it, to avoid pos collision with EVM events. - let root_subtraces = last - .0 - .first() - .or(last.1.first()) - .map(|t| t.subtraces) - .unwrap_or(0); - let existing_events_on_root = last - .2 - .iter() - .chain(last.3.iter()) - .filter(|e| e.parent_trace_id == root_trace_id) - .count(); - let mut extra_pos = root_subtraces + existing_events_on_root; - - for mut extra_event in extra_log_source { - extra_event.parent_trace_id = root_trace_id.clone(); - extra_event.pos_in_parent_trace = extra_pos; - extra_event.id = extra_event.debank_id(); - all_results - .last_mut() - .expect("all_results pushed earlier in this iteration") - .2 - .push(extra_event); - extra_pos += 1; - } - } } - let change_addresses = get_storage_contracts_from_cache(&diff_db.diff.cache); - let state_diff = get_storage_diffs_from_cache(diff_db.diff.cache, pre_db); Ok((all_results, state_diff, change_addresses)) }) .await?; - // Assemble block file. - // D18: Classification uses per-node success from build_debank_traces, with - // receipt status as override. The `root_misclassified` AA merge branch is - // kept structurally identical to Tempo so the two ports stay diff-friendly, - // even though Arc has no AA wrapper traces and the branch never fires. - // - // 1. Successful tx, root trace correctly classified (in traces): - // Keep per-node classification. Internal revert sub-calls - // (try/catch) stay in error lists. Matches reth-x behavior. - // - // 2. Successful tx, root trace misclassified (in error_traces): - // AA-only path (dead on Arc) — `CallTraceArena` marks the handler - // wrapper and its children as success=false even though the tx - // succeeds. The arena's success flags are unreliable for the - // entire tree, so merge all error_traces/events into success lists. - // - // 3. Failed tx: all traces/events go to error lists. - for (idx, (mut trace, mut error_trace, mut event, mut error_event)) in - traces_result.into_iter().enumerate() + // Assemble the block file without rewriting inspector classifications. + // Arc has no Tempo AA wrapper transactions, so a receipt/root mismatch + // indicates an inspector regression and must fail instead of moving traces + // between success and error buckets. + for (idx, (trace, error_trace, event, error_event)) in traces_result.into_iter().enumerate() { - let tx_success = tx_statuses.get(idx).copied().unwrap_or(true); - if tx_success { - let root_misclassified = error_trace.iter().any(|t| t.trace_address.is_empty()); - if root_misclassified { - // AA tx: arena success flags unreliable, merge all - trace.extend(error_trace); - event.extend(error_event); - } else { - // Normal tx: keep per-node classification (try/catch) - block_file.error_traces.extend(error_trace); - block_file.error_events.extend(error_event); - } - block_file.traces.extend(trace); - block_file.events.extend(event); - } else { - // Tx failed: all traces/events go to error lists - error_trace.extend(trace); - error_event.extend(event); - block_file.error_traces.extend(error_trace); - block_file.error_events.extend(error_event); - } - } - - // Reassign event idx to ensure block-global continuity (no gaps). - // build_debank_traces always increments log_index, but classification - // may split events between events/error_events, leaving gaps in idx. - // Sort by current idx (preserves original per-block order) and - // reassign [0, 1, 2, ...] sequentially. - let mut idx_map: Vec<(usize, bool, usize)> = block_file - .events - .iter() - .enumerate() - .map(|(pos, e)| (e.idx, false, pos)) - .chain( - block_file - .error_events - .iter() - .enumerate() - .map(|(pos, e)| (e.idx, true, pos)), + let tx_success = tx_statuses.get(idx).copied().ok_or_else(|| { + EthApiError::EvmCustom(format!( + "missing receipt status for transaction {idx} in block {block_number}" + )) + })?; + validate_transaction_classification( + tx_success, + &trace, + &error_trace, + &event, + &error_event, ) - .collect(); - idx_map.sort_by_key(|(old_idx, _, _)| *old_idx); - for (new_idx, (_, is_error, pos)) in idx_map.into_iter().enumerate() { - if is_error { - block_file.error_events[pos].idx = new_idx; - } else { - block_file.events[pos].idx = new_idx; - } + .map_err(|error| { + EthApiError::EvmCustom(format!( + "invalid trace classification for transaction {idx} in block {}: {error}", + block_number + )) + })?; + block_file.traces.extend(trace); + block_file.error_traces.extend(error_trace); + block_file.events.extend(event); + block_file.error_events.extend(error_event); } let mut state_diff = state_diff; @@ -496,6 +451,49 @@ where } } +fn validate_transaction_classification( + tx_success: bool, + traces: &[DebankTrace], + error_traces: &[DebankTrace], + events: &[DebankEvent], + error_events: &[DebankEvent], +) -> Result<(), &'static str> { + if traces.iter().any(|trace| !trace.error.is_empty()) { + return Err("successful trace has an error"); + } + if error_traces.iter().any(|trace| trace.error.is_empty()) { + return Err("error trace has no error"); + } + if error_events.iter().any(|event| event.idx != 0) { + return Err("error event has a non-zero index"); + } + + let success_roots = traces + .iter() + .filter(|trace| trace.parent_trace_id.is_empty()) + .count(); + let error_roots = error_traces + .iter() + .filter(|trace| trace.parent_trace_id.is_empty()) + .count(); + if tx_success { + if success_roots != 1 || error_roots != 0 { + return Err("successful receipt does not have exactly one successful root trace"); + } + } else if success_roots != 0 || error_roots != 1 || !traces.is_empty() || !events.is_empty() { + return Err("failed receipt has successful traces or events"); + } + + Ok(()) +} + +fn debank_transaction_target( + recipient: Option, + contract_address: Option, +) -> alloy_primitives::Address { + recipient.or(contract_address).unwrap_or_default() +} + // --------------------------------------------------------------------------- // jsonrpsee server trait implementation // --------------------------------------------------------------------------- @@ -526,3 +524,229 @@ impl std::fmt::Debug for DebankTraceBlock { f.debug_struct("DebankTraceBlock").finish_non_exhaustive() } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Address, TxKind, U256}; + use revm::{ + context::TxEnv, + database::{EmptyDB, InMemoryDB}, + inspector::InspectorEvmTr, + primitives::hardfork::SpecId, + state::{Account, AccountInfo, Bytecode, EvmState, EvmStorageSlot}, + DatabaseCommit, InspectEvm, MainBuilder, MainContext, + }; + + fn state_change(address: Address, balance: u64, slots: &[(u64, u64, u64)]) -> EvmState { + let mut account = Account::from(AccountInfo { + balance: U256::from(balance), + ..Default::default() + }); + account.mark_touch(); + for (key, old, new) in slots { + account.storage.insert( + U256::from(*key), + EvmStorageSlot::new_changed(U256::from(*old), U256::from(*new), 0), + ); + } + let mut state = EvmState::default(); + state.insert(address, account); + state + } + + #[test] + fn transaction_target_uses_created_address_for_create() { + let created = Address::repeat_byte(0x11); + assert_eq!(debank_transaction_target(None, Some(created)), created); + } + + #[test] + fn trace_classification_rejects_receipt_or_error_mismatches() { + let success_root = DebankTrace::default(); + let internal_error = DebankTrace { + parent_trace_id: "root".to_string(), + error: "Reverted".to_string(), + ..Default::default() + }; + assert!(validate_transaction_classification( + true, + std::slice::from_ref(&success_root), + std::slice::from_ref(&internal_error), + &[], + &[DebankEvent::default()] + ) + .is_ok()); + + let error_root = DebankTrace { + error: "Reverted".to_string(), + ..Default::default() + }; + let parent_failed_child = DebankTrace { + parent_trace_id: "root".to_string(), + error: "parent call failed".to_string(), + ..Default::default() + }; + assert!(validate_transaction_classification( + false, + &[], + &[error_root.clone(), parent_failed_child], + &[], + &[] + ) + .is_ok()); + assert!(validate_transaction_classification( + true, + &[], + std::slice::from_ref(&error_root), + &[], + &[] + ) + .is_err()); + assert!(validate_transaction_classification( + false, + std::slice::from_ref(&success_root), + &[], + &[], + &[] + ) + .is_err()); + assert!(validate_transaction_classification( + false, + &[], + &[DebankTrace::default()], + &[], + &[] + ) + .is_err()); + let success_with_error = DebankTrace { + error: "Reverted".to_string(), + ..Default::default() + }; + assert!( + validate_transaction_classification(true, &[success_with_error], &[], &[], &[]) + .is_err() + ); + let nonzero_error_event = DebankEvent { + idx: 1, + ..Default::default() + }; + assert!(validate_transaction_classification( + true, + &[success_root], + &[internal_error], + &[], + &[nonzero_error_event] + ) + .is_err()); + } + + #[test] + fn debank_inspector_attaches_standard_precompile_to_call_tree() { + let caller = Address::repeat_byte(0xaa); + let contract = Address::repeat_byte(0xbb); + let ecrecover = Address::with_last_byte(1); + let mut db = InMemoryDB::default(); + db.insert_account_info( + caller, + AccountInfo { + balance: U256::from(1_000_000_000u64), + ..Default::default() + }, + ); + db.insert_account_info( + contract, + AccountInfo { + // STATICCALL ecrecover with 128 zero bytes of input, then discard the result. + code: Some(Bytecode::new_legacy(Bytes::from_static(&[ + 0x60, 0x20, 0x60, 0x00, 0x60, 0x80, 0x60, 0x00, 0x60, 0x01, 0x61, 0xff, 0xff, + 0xfa, 0x50, 0x00, + ]))), + ..Default::default() + }, + ); + + let mut evm = revm::Context::mainnet() + .modify_cfg_chained(|cfg| cfg.spec = SpecId::CANCUN) + .with_db(db) + .build_mainnet_with_inspector(new_debank_inspector()); + let result = evm + .inspect_tx(TxEnv { + caller, + kind: TxKind::Call(contract), + gas_limit: 1_000_000, + gas_price: 0, + ..Default::default() + }) + .expect("precompile fixture should execute"); + assert!(result.result.is_success()); + + let (_, inspectors) = evm.ctx_inspector(); + let nodes = inspectors.0.traces().nodes(); + assert!(nodes[0] + .children + .iter() + .any(|&child| nodes[child].trace.address == ecrecover)); + } + + #[test] + fn transaction_target_prefers_recipient_for_call() { + let recipient = Address::repeat_byte(0x22); + let unexpected_contract = Address::repeat_byte(0x33); + assert_eq!( + debank_transaction_target(Some(recipient), Some(unexpected_contract)), + recipient + ); + } + + #[test] + fn canonical_bundle_merges_pre_transaction_and_post_changes() { + let address = Address::repeat_byte(0x11); + let mut state = State::builder() + .with_database(EmptyDB::default()) + .with_bundle_update() + .build(); + state.insert_not_existing(address); + state.commit(state_change(address, 1, &[(0, 0, 1)])); + state.commit(state_change(address, 2, &[(1, 0, 2)])); + state.commit(state_change(address, 3, &[(0, 1, 3)])); + state.merge_transitions(BundleRetention::PlainState); + + let bundle = state.take_bundle(); + let account = bundle.state.get(&address).unwrap(); + assert_eq!(account.info.as_ref().unwrap().balance, U256::from(3)); + assert_eq!( + account.storage.get(&U256::from(0)).unwrap().present_value, + U256::from(3) + ); + assert_eq!( + account.storage.get(&U256::from(1)).unwrap().present_value, + U256::from(2) + ); + } + + #[test] + fn canonical_bundle_omits_storage_restored_to_parent_value() { + let address = Address::repeat_byte(0x11); + let mut state = State::builder() + .with_database(EmptyDB::default()) + .with_bundle_update() + .build(); + state.insert_account( + address, + AccountInfo { + balance: U256::from(1), + ..Default::default() + }, + ); + state.commit(state_change(address, 1, &[(0, 0, 7)])); + state.commit(state_change(address, 1, &[(0, 7, 0)])); + state.merge_transitions(BundleRetention::PlainState); + + let diff = get_storage_diffs_from_bundle(state.take_bundle(), EmptyDB::default()); + assert!(diff.new_accounts.is_empty()); + assert!(diff.deleted_accounts.is_empty()); + assert!(diff.storage_diffs.is_empty()); + assert!(diff.new_codes.is_empty()); + } +} diff --git a/crates/execution-e2e/Cargo.toml b/crates/execution-e2e/Cargo.toml index 18f7c03..bb51297 100644 --- a/crates/execution-e2e/Cargo.toml +++ b/crates/execution-e2e/Cargo.toml @@ -56,7 +56,9 @@ tokio = { workspace = true, features = ["full"] } tracing.workspace = true [dev-dependencies] +alloy-rlp.workspace = true arc-precompiles = { path = "../precompiles", features = ["test-utils"] } +debank-rpc.workspace = true reth-tracing.workspace = true revm-bytecode.workspace = true rstest.workspace = true diff --git a/crates/execution-e2e/tests/debank_eip7702.rs b/crates/execution-e2e/tests/debank_eip7702.rs new file mode 100644 index 0000000..5c0392b --- /dev/null +++ b/crates/execution-e2e/tests/debank_eip7702.rs @@ -0,0 +1,331 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Launched-node `trace_debankBlock` coverage for an EIP-7702 set-code transaction. + +mod helpers; + +use alloy_consensus::TxEnvelope; +use alloy_eips::{ + eip7702::constants::{EIP7702_DELEGATION_DESIGNATOR, EIP7702_TX_TYPE_ID}, + BlockId, Typed2718, +}; +use alloy_network::eip2718::{Decodable2718, Encodable2718}; +use alloy_primitives::{bytes, keccak256, Address, Bytes, TxKind, U256}; +use alloy_rpc_types_eth::{TransactionInput, TransactionRequest}; +use arc_execution_e2e::{ + actions::{AssertTxIncluded, AssertTxLogs, ProduceBlocks, SendTransaction, TxStatus}, + Action, ArcEnvironment, ArcSetup, ArcTestBuilder, +}; +use eyre::{ensure, Result}; +use futures_util::future::BoxFuture; +use jsonrpsee::{core::client::ClientT, rpc_params}; +use reth_e2e_test_utils::transaction::TransactionTestContext; +use reth_ethereum_primitives::TransactionSigned; +use reth_primitives_traits::SignerRecoverable; +use reth_transaction_pool::{TransactionOrigin, TransactionPool}; + +use helpers::{ + constants::WALLET_FIRST_ADDRESS, + contracts::payable_contract_deploy_code, + debank::{ + assert_eip7708_transfer, current_mainnet_test_chain_spec, transaction_view, + AssertDebankBlock, + }, +}; + +const SET_CODE_TX: &str = "set_code"; +const AUTHORITY_ADDRESS: &str = "eip7702_authority"; + +#[derive(Debug)] +struct SendSponsoredEip7702 { + delegate: Address, + input: Bytes, + value: U256, +} + +impl Action for SendSponsoredEip7702 { + fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, Result<()>> { + Box::pin(async move { + let (sponsor, authority, chain_id) = { + let wallet = env.wallet_mut()?; + let wallets = wallet.wallet_gen(); + let sponsor = wallets + .first() + .ok_or_else(|| eyre::eyre!("sponsor wallet is unavailable"))? + .clone(); + let authority = wallets + .get(1) + .ok_or_else(|| eyre::eyre!("authority wallet is unavailable"))? + .clone(); + (sponsor, authority, wallet.chain_id) + }; + let sponsor_nonce = env.next_nonce_for_wallet(0)?; + ensure!( + sponsor_nonce == 1, + "set-code sponsor nonce must follow the delegate deployment" + ); + + // Reth's test utility signs the authorization with `authority`. We discard its + // dummy outer transaction and sponsor the same authorization from wallet 0 below. + let authority_envelope = + TransactionTestContext::set_code_tx(chain_id, self.delegate, authority.clone()) + .await; + let signed_authorization = match authority_envelope { + TxEnvelope::Eip7702(transaction) => transaction + .tx() + .authorization_list + .first() + .cloned() + .ok_or_else(|| eyre::eyre!("set-code helper returned no authorization"))?, + other => { + return Err(eyre::eyre!( + "set-code helper returned transaction type {}", + other.ty() + )); + } + }; + let authority_address = authority.address(); + ensure!( + signed_authorization.recover_authority()? == authority_address, + "authorization recovered the wrong authority" + ); + ensure!( + *signed_authorization.chain_id() == U256::from(chain_id), + "authorization chain id mismatch" + ); + ensure!( + *signed_authorization.address() == self.delegate, + "authorization delegate mismatch" + ); + ensure!( + signed_authorization.nonce() == 0, + "authorization nonce mismatch" + ); + + let request = TransactionRequest { + nonce: Some(sponsor_nonce), + value: Some(self.value), + to: Some(TxKind::Call(authority_address)), + gas: Some(100_000), + max_fee_per_gas: Some(1_000_000_000_000), + max_priority_fee_per_gas: Some(1_000_000_000), + chain_id: Some(chain_id), + transaction_type: Some(EIP7702_TX_TYPE_ID), + input: TransactionInput { + input: None, + data: Some(self.input.clone()), + }, + authorization_list: Some(vec![signed_authorization]), + ..Default::default() + }; + let signed_transaction = TransactionTestContext::sign_tx(sponsor, request).await; + ensure!( + matches!(signed_transaction, TxEnvelope::Eip7702(_)), + "transaction builder did not produce an EIP-7702 envelope" + ); + let tx_hash = *signed_transaction.tx_hash(); + let raw_transaction: Bytes = signed_transaction.encoded_2718().into(); + let transaction = TransactionSigned::decode_2718(&mut raw_transaction.as_ref()) + .map_err(|error| eyre::eyre!("failed to decode set-code transaction: {error}"))?; + let recovered = transaction + .try_into_recovered() + .map_err(|error| eyre::eyre!("failed to recover set-code sponsor: {error}"))?; + + env.node() + .inner + .pool + .add_consensus_transaction(recovered, TransactionOrigin::Local) + .await + .map_err(|error| eyre::eyre!("failed to submit set-code transaction: {error}"))?; + env.insert_tx_hash(SET_CODE_TX.to_string(), tx_hash)?; + env.insert_address(AUTHORITY_ADDRESS.to_string(), authority_address) + }) + } +} + +#[derive(Debug)] +struct AssertAuthorityCode { + expected: Bytes, +} + +impl Action for AssertAuthorityCode { + fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, Result<()>> { + Box::pin(async move { + let authority = *env + .get_address(AUTHORITY_ADDRESS) + .ok_or_else(|| eyre::eyre!("authority address is unavailable"))?; + let client = env + .node() + .rpc_client() + .ok_or_else(|| eyre::eyre!("RPC client not available"))?; + let actual: Bytes = client + .request( + "eth_getCode", + rpc_params![authority, BlockId::number(env.block_number())], + ) + .await?; + ensure!( + actual == self.expected, + "authority delegation code mismatch" + ); + Ok(()) + }) + } +} + +fn delegation_code(delegate: Address) -> Bytes { + let mut code = Vec::with_capacity(23); + code.extend_from_slice(&EIP7702_DELEGATION_DESIGNATOR); + code.extend_from_slice(delegate.as_slice()); + code.into() +} + +#[tokio::test] +async fn debank_block_eip7702_records_execution_and_delegation_state() -> Result<()> { + reth_tracing::init_test_tracing(); + + let delegate = WALLET_FIRST_ADDRESS.create(0); + let input = bytes!("deadbeef"); + let value = U256::from(12_345); + let expected_code = delegation_code(delegate); + let expected_code_hash = keccak256(&expected_code); + let expected_state_code = expected_code.clone(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(current_mainnet_test_chain_spec())) + .with_action( + SendTransaction::new("deploy_delegate") + .with_create() + .with_data(payable_contract_deploy_code()) + .with_value(U256::ZERO) + .with_gas_limit(100_000), + ) + .with_action(SendSponsoredEip7702 { + delegate, + input: input.clone(), + value, + }) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy_delegate").expect(TxStatus::Success)) + .with_action(AssertTxIncluded::new(SET_CODE_TX).expect(TxStatus::Success)) + .with_action(AssertTxLogs::new(SET_CODE_TX).expect_log_count(1)) + .with_action(AssertDebankBlock::new( + SET_CODE_TX, + move |env, output, state_diff, transaction| { + let authority = *env + .get_address(AUTHORITY_ADDRESS) + .ok_or_else(|| eyre::eyre!("authority address is unavailable"))?; + ensure!(transaction.status, "set-code transaction failed"); + ensure!( + transaction.from == WALLET_FIRST_ADDRESS, + "unexpected set-code sponsor" + ); + ensure!(transaction.to == authority, "unexpected set-code target"); + ensure!(transaction.input == input, "set-code input mismatch"); + ensure!(transaction.value == value, "set-code value mismatch"); + ensure!(transaction.nonce == 1, "set-code nonce mismatch"); + ensure!( + transaction.transaction_index == 1, + "set-code transaction must follow delegate deployment" + ); + + let view = transaction_view(output, transaction); + ensure!(view.traces.len() == 1, "expected one set-code root trace"); + ensure!( + view.error_traces.is_empty(), + "set-code transaction produced an error trace" + ); + ensure!(view.events.len() == 1, "expected one native Transfer event"); + ensure!( + view.error_events.is_empty(), + "set-code transaction produced an error event" + ); + let root = view + .traces + .first() + .ok_or_else(|| eyre::eyre!("set-code root trace is missing"))?; + ensure!( + root.parent_trace_id.is_empty(), + "set-code root has a parent" + ); + ensure!( + root.trace_address.is_empty(), + "set-code root has a trace address" + ); + ensure!(root.from_addr == transaction.from, "root caller mismatch"); + ensure!(root.to_addr == authority, "root target mismatch"); + ensure!(root.input == input, "root input mismatch"); + ensure!(root.value == value, "root value mismatch"); + ensure!(root.output.is_empty(), "no-op delegate returned data"); + ensure!(root.call_create_type == "call", "root is not a CALL"); + ensure!(root.call_type == "call", "unexpected root call type"); + ensure!(root.subtraces == 0, "set-code call produced child traces"); + ensure!(root.error.is_empty(), "set-code root is marked failed"); + let event = view + .events + .first() + .ok_or_else(|| eyre::eyre!("native Transfer event is missing"))?; + assert_eip7708_transfer(event, transaction.from, authority, value, &root.id, 0, 0)?; + + let authority_account = state_diff + .new_accounts + .iter() + .find(|account| account.address == keccak256(authority.as_slice())) + .ok_or_else(|| eyre::eyre!("authority is missing from StateDiff"))?; + ensure!(authority_account.nonce == 1, "authority nonce mismatch"); + ensure!( + authority_account.code_hash == expected_code_hash, + "authority code hash mismatch" + ); + let matching_codes: Vec<_> = state_diff + .new_codes + .iter() + .filter(|code| code.code_hash == expected_code_hash) + .collect(); + ensure!( + matching_codes.len() == 1, + "expected exactly one delegation NewCode" + ); + let delegation = matching_codes + .first() + .ok_or_else(|| eyre::eyre!("delegation NewCode is missing"))?; + ensure!( + delegation.code == expected_state_code, + "delegation NewCode bytes mismatch" + ); + + // BlockFile has no standard EIP-7702 authorization-list field. In particular, + // the Tempo AA field must remain unset for a standard type-4 transaction. + ensure!( + transaction.aa_authorization_list.is_none(), + "standard authorization was projected into the Tempo AA field" + ); + let transaction_json = serde_json::to_value(transaction)?; + ensure!( + transaction_json.get("authorization_list").is_none() + && transaction_json.get("authorizationList").is_none(), + "BlockFile unexpectedly carries a standard authorization list" + ); + Ok(()) + }, + )) + .with_action(AssertAuthorityCode { + expected: expected_code, + }) + .run() + .await +} diff --git a/crates/execution-e2e/tests/debank_precompiles.rs b/crates/execution-e2e/tests/debank_precompiles.rs new file mode 100644 index 0000000..24a8475 --- /dev/null +++ b/crates/execution-e2e/tests/debank_precompiles.rs @@ -0,0 +1,365 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Canonical-transaction `trace_debankBlock` coverage for stateless Arc precompiles. + +mod helpers; + +use alloy_consensus::constants::KECCAK_EMPTY; +use alloy_eips::eip2935::HISTORY_STORAGE_ADDRESS; +use alloy_primitives::{address, bytes, keccak256, Address, Bytes, B256, U256}; +use alloy_sol_types::SolCall; +use arc_execution_e2e::{ + actions::{ProduceBlocks, SendTransaction}, + ArcSetup, ArcTestBuilder, +}; +use arc_precompiles::{ + helpers::revert_message_to_bytes, + pq::{IPQ, PQ_ADDRESS}, + pq_test_vectors::{self, MSG_HELLO_WORLD}, + system_accounting::SYSTEM_ACCOUNTING_ADDRESS, +}; +use debank_rpc::debank_trace::{BlockStorageDiff, DebankOutPut, DebankTrace, DebankTransaction}; +use eyre::{ensure, Result}; +use helpers::debank::{current_mainnet_test_chain_spec, AssertDebankBlock}; + +const P256_PRECOMPILE_ADDRESS: Address = address!("0000000000000000000000000000000000000100"); +const RETURN_TRUE: Bytes = + bytes!("0000000000000000000000000000000000000000000000000000000000000001"); +const P256_VALID_INPUT: Bytes = bytes!( + "4cee90eb86eaa050036147a12d49004b6b9c72bd725d39d4785011fe190f0b4d\ + a73bd4903f0ce3b639bbbf6e8e80d16931ff4bcf5993d58468e8fb19086e8cac\ + 36dbcd03009df8c59286b162af3bd7fcc0450c9aa81be5d10d312af6c66b1d604\ + aebd3099c618202fcfe16ae7770b0c49ab5eadf74b754204a3bb6060e44eff376\ + 18b065f9832de4ca6ca971a7a1adc826d0f7c00181a5fb2ddf79ae00b4e10e" +); + +#[derive(Clone, Debug)] +enum ExpectedOutcome { + Success(Bytes), + Revert(Bytes), +} + +#[derive(Clone, Debug)] +struct PrecompileFixture { + target: Address, + input: Bytes, + outcome: ExpectedOutcome, +} + +fn assert_precompile_block(tx_name: &'static str, fixture: PrecompileFixture) -> AssertDebankBlock { + AssertDebankBlock::new(tx_name, move |_env, output, state_diff, transaction| { + assert_transaction(transaction, &fixture)?; + assert_trace_buckets(output, transaction, &fixture)?; + assert_no_business_events_or_state(output, state_diff, transaction, fixture.target) + }) +} + +fn assert_transaction(transaction: &DebankTransaction, fixture: &PrecompileFixture) -> Result<()> { + let expected_status = matches!(&fixture.outcome, ExpectedOutcome::Success(_)); + ensure!( + transaction.to == fixture.target, + "unexpected transaction target" + ); + ensure!( + transaction.input == fixture.input, + "unexpected transaction input" + ); + ensure!( + transaction.value == U256::ZERO, + "precompile transaction transferred value" + ); + ensure!( + transaction.status == expected_status, + "unexpected transaction status" + ); + ensure!( + transaction.transaction_index == 0, + "unexpected transaction index" + ); + Ok(()) +} + +fn assert_trace_buckets( + output: &DebankOutPut, + transaction: &DebankTransaction, + fixture: &PrecompileFixture, +) -> Result<()> { + ensure!( + output.block_file.transactions.len() == 1, + "precompile fixture block must contain exactly one transaction" + ); + let (traces, other_bucket, expected_output, expected_error) = match &fixture.outcome { + ExpectedOutcome::Success(expected_output) => ( + &output.block_file.traces, + &output.block_file.error_traces, + expected_output, + "", + ), + ExpectedOutcome::Revert(expected_output) => ( + &output.block_file.error_traces, + &output.block_file.traces, + expected_output, + "Reverted", + ), + }; + + ensure!( + traces.len() == 1, + "expected exactly one trace in the selected bucket" + ); + ensure!( + other_bucket.is_empty(), + "trace was also emitted in the wrong bucket" + ); + let trace = &traces[0]; + assert_root_precompile_trace(trace, transaction, fixture, expected_output, expected_error) +} + +fn assert_root_precompile_trace( + trace: &DebankTrace, + transaction: &DebankTransaction, + fixture: &PrecompileFixture, + expected_output: &Bytes, + expected_error: &str, +) -> Result<()> { + ensure!( + trace.tx_id == transaction.id, + "trace belongs to another transaction" + ); + ensure!( + trace.parent_trace_id.is_empty(), + "precompile root trace has a parent" + ); + ensure!( + trace.trace_address.is_empty(), + "precompile root has a non-root trace address" + ); + ensure!( + trace.pos_in_parent_trace == 0, + "unexpected root trace position" + ); + ensure!( + trace.from_addr == transaction.from, + "unexpected trace caller" + ); + ensure!(trace.to_addr == fixture.target, "unexpected trace target"); + ensure!(trace.input == fixture.input, "unexpected trace input"); + ensure!(trace.output == *expected_output, "unexpected trace output"); + ensure!( + trace.value == U256::ZERO, + "precompile trace transferred value" + ); + ensure!( + trace.call_create_type == "call", + "precompile root is not a call" + ); + ensure!(trace.call_type == "call", "unexpected precompile call type"); + ensure!(trace.error == expected_error, "unexpected trace error"); + ensure!( + trace.subtraces == 0, + "stateless precompile emitted child traces" + ); + ensure!( + !trace.self_storage_change, + "precompile trace reports its own storage change" + ); + ensure!( + !trace.storage_change, + "precompile trace reports a storage change" + ); + Ok(()) +} + +fn assert_no_business_events_or_state( + output: &DebankOutPut, + state_diff: &BlockStorageDiff, + transaction: &DebankTransaction, + target: Address, +) -> Result<()> { + ensure!( + output.block_file.events.is_empty(), + "stateless precompile emitted an event" + ); + ensure!( + output.block_file.error_events.is_empty(), + "stateless precompile emitted an error event" + ); + + // Every non-genesis Arc block updates exactly the EIP-2935 history contract and + // SystemAccounting. Those block-level hooks are not precompile business state. + let system_contracts = vec![HISTORY_STORAGE_ADDRESS, SYSTEM_ACCOUNTING_ADDRESS]; + ensure!( + output.block_file.storage_contracts == system_contracts, + "stateless precompile changed business storage: {:?}", + output.block_file.storage_contracts + ); + let mut expected_storage_addresses: Vec<_> = system_contracts + .iter() + .map(|address| keccak256(address.as_slice())) + .collect(); + expected_storage_addresses.sort_unstable(); + let storage_addresses: Vec<_> = state_diff + .storage_diffs + .iter() + .map(|account| account.address) + .collect(); + ensure!( + storage_addresses == expected_storage_addresses, + "StateDiff contains non-hook storage changes" + ); + ensure!( + state_diff + .storage_diffs + .iter() + .all(|account| account.diffs.len() == 1), + "each block hook must change exactly one slot" + ); + ensure!( + state_diff.deleted_accounts.is_empty(), + "precompile deleted an account" + ); + // The localdev fee recipient is absent from genesis, so block 1 may represent + // its account creation with empty bytecode. Non-empty code is business state. + ensure!( + state_diff.new_codes.len() <= 1 + && state_diff + .new_codes + .iter() + .all(|code| code.code_hash == KECCAK_EMPTY && code.code.is_empty()), + "precompile installed non-empty code: {:?}", + state_diff.new_codes + ); + + // Account summaries may contain transaction bookkeeping (sender nonce/balance and + // beneficiary fees) plus the two block hooks. No other account, especially the + // stateless precompile address, may enter the diff. + let mut allowed_account_hashes = vec![ + keccak256(transaction.from.as_slice()), + keccak256(output.block_file.block.miner.as_slice()), + keccak256(HISTORY_STORAGE_ADDRESS.as_slice()), + keccak256(SYSTEM_ACCOUNTING_ADDRESS.as_slice()), + ]; + allowed_account_hashes.sort_unstable(); + allowed_account_hashes.dedup(); + let unexpected_accounts: Vec = state_diff + .new_accounts + .iter() + .map(|account| account.address) + .filter(|address| !allowed_account_hashes.contains(address)) + .collect(); + ensure!( + unexpected_accounts.is_empty(), + "StateDiff contains unexpected account summaries: {unexpected_accounts:?}" + ); + let target_hash = keccak256(target.as_slice()); + ensure!( + state_diff + .new_accounts + .iter() + .all(|account| account.address != target_hash), + "stateless precompile target appears in account StateDiff" + ); + ensure!( + state_diff + .new_accounts + .iter() + .any(|account| { account.address == keccak256(transaction.from.as_slice()) }), + "sender nonce/balance bookkeeping is missing from StateDiff" + ); + Ok(()) +} + +async fn run_fixture(tx_name: &'static str, fixture: PrecompileFixture) -> Result<()> { + let chain_spec = current_mainnet_test_chain_spec(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(chain_spec)) + .with_action( + SendTransaction::new(tx_name) + .with_to(fixture.target) + .with_value(U256::ZERO) + .with_data(fixture.input.clone()) + .with_gas_limit(750_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(assert_precompile_block(tx_name, fixture)) + .run() + .await +} + +#[tokio::test] +async fn debank_pq_valid_canonical_transaction() -> Result<()> { + reth_tracing::init_test_tracing(); + + let vectors = pq_test_vectors::cached_vectors(); + let input = IPQ::verifySlhDsaSha2128sCall { + vk: vectors.verifying_key.clone().into(), + message: Bytes::copy_from_slice(MSG_HELLO_WORLD), + sig: vectors.sig_hello_world.clone().into(), + } + .abi_encode() + .into(); + run_fixture( + "pq_valid", + PrecompileFixture { + target: PQ_ADDRESS, + input, + outcome: ExpectedOutcome::Success(RETURN_TRUE.clone()), + }, + ) + .await +} + +#[tokio::test] +async fn debank_pq_malformed_canonical_transaction() -> Result<()> { + reth_tracing::init_test_tracing(); + + let vectors = pq_test_vectors::cached_vectors(); + let input = IPQ::verifySlhDsaSha2128sCall { + vk: Bytes::from(vec![0u8; 100]), + message: Bytes::copy_from_slice(MSG_HELLO_WORLD), + sig: vectors.sig_hello_world.clone().into(), + } + .abi_encode() + .into(); + run_fixture( + "pq_malformed", + PrecompileFixture { + target: PQ_ADDRESS, + input, + outcome: ExpectedOutcome::Revert(revert_message_to_bytes( + "Invalid verifying key length", + )), + }, + ) + .await +} + +#[tokio::test] +async fn debank_p256_valid_canonical_transaction() -> Result<()> { + reth_tracing::init_test_tracing(); + + run_fixture( + "p256_valid", + PrecompileFixture { + target: P256_PRECOMPILE_ADDRESS, + input: P256_VALID_INPUT.clone(), + outcome: ExpectedOutcome::Success(RETURN_TRUE.clone()), + }, + ) + .await +} diff --git a/crates/execution-e2e/tests/debank_system_contracts.rs b/crates/execution-e2e/tests/debank_system_contracts.rs new file mode 100644 index 0000000..f7b7585 --- /dev/null +++ b/crates/execution-e2e/tests/debank_system_contracts.rs @@ -0,0 +1,819 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Launched-node `trace_debankBlock` coverage for Arc system-contract mutations. + +mod helpers; + +use alloy_eips::eip2935::HISTORY_STORAGE_ADDRESS; +use alloy_primitives::{address, b256, keccak256, Address, Bytes, B256, U256}; +use alloy_sol_types::{sol, SolCall, SolEvent, SolValue}; +use arc_execution_config::{ + addresses_denylist::DEFAULT_DENYLIST_ADDRESS, protocol_config::PROTOCOL_CONFIG_ADDRESS, +}; +use arc_execution_e2e::{ + actions::{AssertTxIncluded, AssertTxLogs, ProduceBlocks, SendTransaction, TxStatus}, + ArcSetup, ArcTestBuilder, +}; +use arc_precompiles::{system_accounting::SYSTEM_ACCOUNTING_ADDRESS, NATIVE_COIN_CONTROL_ADDRESS}; +use debank_rpc::debank_trace::{ + BlockStorageDiff, DebankEvent, DebankOutPut, DebankTrace, DebankTransaction, +}; +use eyre::{ensure, Result}; + +use helpers::debank::{ + current_mainnet_test_chain_spec, transaction_view, AssertDebankBlock, DebankTxView, +}; + +const NATIVE_FIAT_TOKEN_ADDRESS: Address = address!("0x3600000000000000000000000000000000000000"); +const VALIDATOR_REGISTRY_ADDRESS: Address = address!("0x3600000000000000000000000000000000000002"); +const PERMISSIONED_VALIDATOR_MANAGER_ADDRESS: Address = + address!("0x3600000000000000000000000000000000000003"); +const DENYLIST_STORAGE_LOCATION: B256 = + b256!("1d7e1388d3ae56f3d9c18b1ce8d2b3b1a238a0edf682d2053af5d8a1d2f12f00"); +const NATIVE_COIN_CONTROL_BLOCKLIST_MAPPING_SLOT: B256 = + b256!("0000000000000000000000000000000000000000000000000000000000000002"); +const PROTOCOL_CONFIG_BLOCK_GAS_LIMIT_SLOT: B256 = + b256!("668f09ce856848ead6cb1ddee963f15ef833cea8958030868f867aec84385203"); +const VALIDATOR_REGISTRY_STORAGE_LOCATION: B256 = + b256!("b58da0dce03316992faea3e12c60705b8ac05a309e27e3bc8421e5b271c9d200"); +const VALIDATOR_REGISTERED_PUBLIC_KEYS_SLOT: B256 = + b256!("b58da0dce03316992faea3e12c60705b8ac05a309e27e3bc8421e5b271c9d203"); +const VALIDATOR_NEXT_REGISTRATION_ID_SLOT: B256 = + b256!("b58da0dce03316992faea3e12c60705b8ac05a309e27e3bc8421e5b271c9d204"); +const WALLET_OPERATOR_INDEX: usize = 7; +const WALLET_ADMIN_INDEX: usize = 8; + +sol! { + interface IProtocolConfig { + struct FeeParams { + uint64 alpha; + uint64 kRate; + uint64 inverseElasticityMultiplier; + uint256 minBaseFee; + uint256 maxBaseFee; + uint256 blockGasLimit; + } + + function updateBlockGasLimit(uint256 newBlockGasLimit) external; + event FeeParamsUpdated(FeeParams params); + } + + interface IDenylist { + function denylist(address[] accounts) external; + event Denylisted(address indexed account); + } + + interface INativeFiatToken { + function blacklist(address account) external; + event Blacklisted(address indexed account); + } + + interface INativeCoinControl { + event Blocklisted(address indexed account); + } + + interface IPermissionedValidatorManager { + function registerValidator(bytes publicKey) external returns (uint256 registrationId); + } + + interface IValidatorRegistry { + event ValidatorRegistered(uint256 indexed registrationId, uint64 votingPower, bytes publicKey); + } +} + +fn successful_call<'a>( + output: &'a DebankOutPut, + transaction: &'a DebankTransaction, + target: Address, + input: &Bytes, +) -> Result<(DebankTxView<'a>, &'a DebankTrace)> { + ensure!(transaction.status, "system-contract transaction failed"); + ensure!(transaction.to == target, "transaction target mismatch"); + ensure!(transaction.input == *input, "transaction calldata mismatch"); + ensure!(transaction.value == U256::ZERO, "transaction sent value"); + + let view = transaction_view(output, transaction); + ensure!( + view.error_traces.is_empty(), + "successful transaction contains error traces" + ); + ensure!( + view.error_events.is_empty(), + "successful transaction contains error events" + ); + + let mut roots = view + .traces + .iter() + .copied() + .filter(|trace| trace.parent_trace_id.is_empty()); + let root = roots + .next() + .ok_or_else(|| eyre::eyre!("root trace is missing"))?; + ensure!( + roots.next().is_none(), + "transaction has multiple root traces" + ); + ensure!(root.from_addr == transaction.from, "root caller mismatch"); + ensure!(root.to_addr == target, "root target mismatch"); + ensure!(root.input == *input, "root calldata mismatch"); + ensure!(root.value == U256::ZERO, "root transferred value"); + ensure!(root.call_create_type == "call", "root is not a call"); + ensure!(root.call_type == "call", "unexpected root call type"); + ensure!(root.trace_address.is_empty(), "root has a trace address"); + ensure!(root.pos_in_parent_trace == 0, "unexpected root position"); + ensure!(root.error.is_empty(), "root trace is marked failed"); + Ok((view, root)) +} + +fn single_direct_child<'a>( + view: &DebankTxView<'a>, + parent: &DebankTrace, + predicate: impl Fn(&DebankTrace) -> bool, + label: &str, +) -> Result<&'a DebankTrace> { + let mut matches = view + .traces + .iter() + .copied() + .filter(|trace| trace.parent_trace_id == parent.id && predicate(trace)); + let trace = matches + .next() + .ok_or_else(|| eyre::eyre!("{label} trace is missing"))?; + ensure!( + matches.next().is_none(), + "transaction has multiple {label} traces" + ); + Ok(trace) +} + +fn assert_child_position( + child: &DebankTrace, + parent: &DebankTrace, + trace_index: usize, + member_index: usize, +) -> Result<()> { + let mut expected_address = parent.trace_address.clone(); + expected_address.push(trace_index); + ensure!( + child.trace_address == expected_address, + "child trace address mismatch" + ); + ensure!( + child.pos_in_parent_trace == member_index, + "child position mismatch" + ); + Ok(()) +} + +fn assert_event( + event: &DebankEvent, + emitter: Address, + signature: B256, + topics: Vec, + parent: &DebankTrace, + member_index: usize, + log_index: usize, +) -> Result<()> { + ensure!(event.contract_id == emitter, "event emitter mismatch"); + ensure!( + event.selector == signature.to_string(), + "event selector mismatch" + ); + ensure!(event.topics == topics, "event topics mismatch"); + ensure!( + event.parent_trace_id == parent.id, + "event parent trace mismatch" + ); + ensure!( + event.pos_in_parent_trace == member_index, + "event position mismatch" + ); + ensure!(event.idx == log_index, "event log index mismatch"); + Ok(()) +} + +fn address_topic(address: Address) -> String { + B256::left_padding_from(address.as_slice()).to_string() +} + +fn storage_slot_offset(slot: B256, offset: u64) -> B256 { + let value = U256::from_be_slice(slot.as_slice()).saturating_add(U256::from(offset)); + value.to_be_bytes::<32>().into() +} + +fn address_mapping_slot(key: Address, slot: B256) -> B256 { + keccak256((key, slot).abi_encode()) +} + +fn assert_storage_changes( + output: &DebankOutPut, + state_diff: &BlockStorageDiff, + business_contract: Address, + expected_slots: &[(B256, U256)], +) -> Result<()> { + let mut expected_contracts = vec![ + HISTORY_STORAGE_ADDRESS, + SYSTEM_ACCOUNTING_ADDRESS, + business_contract, + ]; + expected_contracts.sort_unstable(); + ensure!( + output.block_file.storage_contracts == expected_contracts, + "unexpected storage_contracts: {:?}", + output.block_file.storage_contracts + ); + + let mut expected_storage_addresses: Vec<_> = expected_contracts + .iter() + .map(|address| keccak256(address.as_slice())) + .collect(); + expected_storage_addresses.sort_unstable(); + let actual_storage_addresses: Vec<_> = state_diff + .storage_diffs + .iter() + .map(|storage| storage.address) + .collect(); + ensure!( + actual_storage_addresses == expected_storage_addresses, + "StateDiff contains an unexpected storage account" + ); + + let business_storage = state_diff + .storage_diffs + .iter() + .find(|storage| storage.address == keccak256(business_contract.as_slice())) + .ok_or_else(|| eyre::eyre!("{business_contract} is missing from StateDiff"))?; + let actual_slots: Vec<_> = business_storage + .diffs + .iter() + .map(|diff| (diff.index, diff.value)) + .collect(); + let mut expected_hashed_slots: Vec<_> = expected_slots + .iter() + // BlockStorageDiff stores keccak256(raw Solidity slot), not the raw slot. + .map(|(raw_slot, value)| (keccak256(raw_slot.as_slice()), *value)) + .collect(); + expected_hashed_slots.sort_unstable_by_key(|(slot, _)| *slot); + ensure!( + actual_slots == expected_hashed_slots, + "{business_contract} StateDiff slot/value mismatch" + ); + Ok(()) +} + +#[tokio::test] +async fn debank_block_protocol_config_update_tracks_proxy_storage_and_event() -> Result<()> { + reth_tracing::init_test_tracing(); + + let new_block_gas_limit = U256::from(31_000_000); + let input = Bytes::from( + IProtocolConfig::updateBlockGasLimitCall { + newBlockGasLimit: new_block_gas_limit, + } + .abi_encode(), + ); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(current_mainnet_test_chain_spec())) + .with_action( + SendTransaction::new("update_block_gas_limit") + .with_to(PROTOCOL_CONFIG_ADDRESS) + .with_data(input.clone()) + .with_value(U256::ZERO) + .with_gas_limit(200_000) + .with_wallet_index(WALLET_ADMIN_INDEX), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("update_block_gas_limit").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("update_block_gas_limit").expect_log_count(1)) + .with_action(AssertDebankBlock::new( + "update_block_gas_limit", + move |_env, output, state_diff, transaction| { + let (view, root) = + successful_call(output, transaction, PROTOCOL_CONFIG_ADDRESS, &input)?; + ensure!( + view.traces.len() == 2, + "unexpected ProtocolConfig trace count" + ); + ensure!( + view.events.len() == 1, + "unexpected ProtocolConfig event count" + ); + ensure!( + root.subtraces == 1, + "ProtocolConfig root child count mismatch" + ); + ensure!( + root.storage_change, + "ProtocolConfig root missed storage change" + ); + ensure!( + !root.self_storage_change, + "ProtocolConfig proxy root reports its own SSTORE" + ); + + let implementation = single_direct_child( + &view, + root, + |trace| trace.call_type == "delegatecall", + "ProtocolConfig implementation", + )?; + assert_child_position(implementation, root, 0, 0)?; + ensure!( + implementation.self_storage_change, + "ProtocolConfig implementation missed its SSTORE" + ); + ensure!( + implementation.storage_change, + "ProtocolConfig implementation missed storage change" + ); + ensure!( + implementation.subtraces == 0, + "ProtocolConfig implementation has unexpected children" + ); + + let event = view.events[0]; + assert_event( + event, + PROTOCOL_CONFIG_ADDRESS, + IProtocolConfig::FeeParamsUpdated::SIGNATURE_HASH, + vec![], + implementation, + 0, + 0, + )?; + ensure!( + event.data.len() == 6 * 32, + "FeeParamsUpdated data length mismatch" + ); + ensure!( + &event.data[event.data.len() - 32..] + == new_block_gas_limit.to_be_bytes::<32>().as_slice(), + "FeeParamsUpdated block gas limit mismatch" + ); + assert_storage_changes( + output, + state_diff, + PROTOCOL_CONFIG_ADDRESS, + &[(PROTOCOL_CONFIG_BLOCK_GAS_LIMIT_SLOT, new_block_gas_limit)], + ) + }, + )) + .run() + .await +} + +#[tokio::test] +async fn debank_block_denylist_tracks_proxy_storage_and_event() -> Result<()> { + reth_tracing::init_test_tracing(); + + let denied = Address::repeat_byte(0xd1); + let input = Bytes::from( + IDenylist::denylistCall { + accounts: vec![denied], + } + .abi_encode(), + ); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(current_mainnet_test_chain_spec())) + .with_action( + SendTransaction::new("denylist_account") + .with_to(DEFAULT_DENYLIST_ADDRESS) + .with_data(input.clone()) + .with_value(U256::ZERO) + .with_gas_limit(200_000) + .with_wallet_index(WALLET_OPERATOR_INDEX), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("denylist_account").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("denylist_account").expect_log_count(1)) + .with_action(AssertDebankBlock::new( + "denylist_account", + move |_env, output, state_diff, transaction| { + let (view, root) = + successful_call(output, transaction, DEFAULT_DENYLIST_ADDRESS, &input)?; + ensure!(view.traces.len() == 2, "unexpected Denylist trace count"); + ensure!(view.events.len() == 1, "unexpected Denylist event count"); + ensure!(root.subtraces == 1, "Denylist root child count mismatch"); + ensure!(root.storage_change, "Denylist root missed storage change"); + + let implementation = single_direct_child( + &view, + root, + |trace| trace.call_type == "delegatecall", + "Denylist implementation", + )?; + assert_child_position(implementation, root, 0, 0)?; + ensure!( + implementation.self_storage_change, + "Denylist implementation missed its SSTORE" + ); + ensure!( + implementation.storage_change, + "Denylist implementation missed storage change" + ); + ensure!( + implementation.subtraces == 0, + "Denylist implementation has unexpected children" + ); + + let event = view.events[0]; + assert_event( + event, + DEFAULT_DENYLIST_ADDRESS, + IDenylist::Denylisted::SIGNATURE_HASH, + vec![address_topic(denied)], + implementation, + 0, + 0, + )?; + ensure!(event.data.is_empty(), "Denylisted event has data"); + assert_storage_changes( + output, + state_diff, + DEFAULT_DENYLIST_ADDRESS, + &[( + address_mapping_slot(denied, DENYLIST_STORAGE_LOCATION), + U256::from(1), + )], + ) + }, + )) + .run() + .await +} + +#[tokio::test] +async fn debank_block_native_blacklist_tracks_nested_precompile_state_and_events() -> Result<()> { + reth_tracing::init_test_tracing(); + + let blacklisted = Address::repeat_byte(0xb1); + let input = Bytes::from( + INativeFiatToken::blacklistCall { + account: blacklisted, + } + .abi_encode(), + ); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(current_mainnet_test_chain_spec())) + .with_action( + SendTransaction::new("blacklist_account") + .with_to(NATIVE_FIAT_TOKEN_ADDRESS) + .with_data(input.clone()) + .with_value(U256::ZERO) + .with_gas_limit(250_000) + .with_wallet_index(WALLET_OPERATOR_INDEX), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("blacklist_account").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("blacklist_account").expect_log_count(2)) + .with_action(AssertDebankBlock::new( + "blacklist_account", + move |_env, output, state_diff, transaction| { + let (view, root) = + successful_call(output, transaction, NATIVE_FIAT_TOKEN_ADDRESS, &input)?; + ensure!( + view.traces.len() == 5, + "unexpected NativeFiatToken trace count: {:?}", + view.traces + ); + ensure!( + view.events.len() == 2, + "unexpected NativeFiatToken event count" + ); + ensure!( + root.subtraces == 1, + "NativeFiatToken root child count mismatch" + ); + ensure!( + !root.storage_change, + "NativeFiatToken root unexpectedly reports an EVM SSTORE" + ); + + let implementation = single_direct_child( + &view, + root, + |trace| trace.call_type == "delegatecall", + "NativeFiatToken implementation", + )?; + assert_child_position(implementation, root, 0, 0)?; + ensure!( + !implementation.self_storage_change, + "NativeFiatToken implementation unexpectedly executed SSTORE" + ); + ensure!( + !implementation.storage_change, + "NativeFiatToken implementation unexpectedly reports an EVM SSTORE" + ); + ensure!( + implementation.subtraces == 2, + "NativeFiatToken implementation child count mismatch" + ); + + let owner_lookup = single_direct_child( + &view, + implementation, + |trace| { + trace.to_addr == NATIVE_FIAT_TOKEN_ADDRESS + && trace.call_type == "staticcall" + }, + "NativeFiatToken owner lookup", + )?; + assert_child_position(owner_lookup, implementation, 0, 0)?; + ensure!( + owner_lookup.subtraces == 1, + "owner lookup proxy child count mismatch" + ); + let owner_implementation = single_direct_child( + &view, + owner_lookup, + |trace| trace.call_type == "delegatecall", + "NativeFiatToken owner implementation", + )?; + assert_child_position(owner_implementation, owner_lookup, 0, 0)?; + ensure!( + owner_implementation.subtraces == 0, + "owner implementation has unexpected children" + ); + + let native_control = single_direct_child( + &view, + implementation, + |trace| trace.to_addr == NATIVE_COIN_CONTROL_ADDRESS, + "NativeCoinControl", + )?; + assert_child_position(native_control, implementation, 1, 1)?; + ensure!( + native_control.from_addr == NATIVE_FIAT_TOKEN_ADDRESS, + "NativeCoinControl caller mismatch" + ); + ensure!( + native_control.call_type == "call", + "NativeCoinControl call type mismatch" + ); + ensure!( + native_control.subtraces == 0, + "NativeCoinControl has unexpected children" + ); + ensure!( + native_control.output.as_ref() == U256::from(1).to_be_bytes::<32>().as_slice(), + "NativeCoinControl did not return true" + ); + + let native_event = view.events[0]; + assert_event( + native_event, + NATIVE_COIN_CONTROL_ADDRESS, + INativeCoinControl::Blocklisted::SIGNATURE_HASH, + vec![address_topic(blacklisted)], + native_control, + 0, + 0, + )?; + ensure!(native_event.data.is_empty(), "Blocklisted event has data"); + + let token_event = view.events[1]; + assert_event( + token_event, + NATIVE_FIAT_TOKEN_ADDRESS, + INativeFiatToken::Blacklisted::SIGNATURE_HASH, + vec![address_topic(blacklisted)], + implementation, + 2, + 1, + )?; + ensure!(token_event.data.is_empty(), "Blacklisted event has data"); + + ensure!( + !output + .block_file + .storage_contracts + .contains(&NATIVE_FIAT_TOKEN_ADDRESS), + "NativeFiatToken duplicated NativeCoinControl blacklist state" + ); + assert_storage_changes( + output, + state_diff, + NATIVE_COIN_CONTROL_ADDRESS, + &[( + address_mapping_slot( + blacklisted, + NATIVE_COIN_CONTROL_BLOCKLIST_MAPPING_SLOT, + ), + U256::from(1), + )], + ) + }, + )) + .run() + .await +} + +#[tokio::test] +async fn debank_block_validator_registration_tracks_nested_registry_state_and_event() -> Result<()> +{ + reth_tracing::init_test_tracing(); + + let public_key = Bytes::from(vec![0xa5; 32]); + let input = Bytes::from( + IPermissionedValidatorManager::registerValidatorCall { + publicKey: public_key.clone(), + } + .abi_encode(), + ); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(current_mainnet_test_chain_spec())) + .with_action( + SendTransaction::new("register_validator") + .with_to(PERMISSIONED_VALIDATOR_MANAGER_ADDRESS) + .with_data(input.clone()) + .with_value(U256::ZERO) + .with_gas_limit(800_000) + .with_wallet_index(WALLET_ADMIN_INDEX), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("register_validator").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("register_validator").expect_log_count(1)) + .with_action(AssertDebankBlock::new( + "register_validator", + move |_env, output, state_diff, transaction| { + let (view, root) = successful_call( + output, + transaction, + PERMISSIONED_VALIDATOR_MANAGER_ADDRESS, + &input, + )?; + ensure!( + view.traces.len() == 4, + "unexpected validator registration trace count" + ); + ensure!( + view.events.len() == 1, + "unexpected validator registration event count" + ); + ensure!(root.subtraces == 1, "manager root child count mismatch"); + ensure!( + root.storage_change, + "manager root missed nested storage change" + ); + ensure!( + !root.self_storage_change, + "manager proxy root reports its own SSTORE" + ); + + let manager_implementation = single_direct_child( + &view, + root, + |trace| trace.call_type == "delegatecall", + "manager implementation", + )?; + assert_child_position(manager_implementation, root, 0, 0)?; + ensure!( + !manager_implementation.self_storage_change, + "manager implementation unexpectedly changed manager storage" + ); + ensure!( + manager_implementation.storage_change, + "manager implementation missed nested registry change" + ); + ensure!( + manager_implementation.subtraces == 1, + "manager implementation child count mismatch" + ); + + let registry = single_direct_child( + &view, + manager_implementation, + |trace| trace.to_addr == VALIDATOR_REGISTRY_ADDRESS, + "ValidatorRegistry proxy", + )?; + assert_child_position(registry, manager_implementation, 0, 0)?; + ensure!( + registry.call_type == "call", + "registry proxy call type mismatch" + ); + ensure!( + registry.subtraces == 1, + "registry proxy child count mismatch" + ); + ensure!( + registry.storage_change, + "registry proxy missed storage change" + ); + ensure!( + !registry.self_storage_change, + "registry proxy reports its own SSTORE" + ); + + let registry_implementation = single_direct_child( + &view, + registry, + |trace| trace.call_type == "delegatecall", + "ValidatorRegistry implementation", + )?; + assert_child_position(registry_implementation, registry, 0, 0)?; + ensure!( + registry_implementation.self_storage_change, + "registry implementation missed its SSTORE" + ); + ensure!( + registry_implementation.storage_change, + "registry implementation missed storage change" + ); + ensure!( + registry_implementation.subtraces == 0, + "registry implementation has unexpected children" + ); + + ensure!( + root.output.len() == 32, + "registration return length mismatch" + ); + ensure!( + manager_implementation.output == root.output + && registry.output == root.output + && registry_implementation.output == root.output, + "registration id was not forwarded through the call tree" + ); + let registration_id = U256::from_be_slice(root.output.as_ref()); + let registration_id_topic = B256::from_slice(root.output.as_ref()).to_string(); + + let event = view.events[0]; + assert_event( + event, + VALIDATOR_REGISTRY_ADDRESS, + IValidatorRegistry::ValidatorRegistered::SIGNATURE_HASH, + vec![registration_id_topic], + registry_implementation, + 0, + 0, + )?; + ensure!( + event.data.len() == 4 * 32, + "ValidatorRegistered data length mismatch" + ); + ensure!( + event.data[0..32] == [0u8; 32], + "new validator voting power is not zero" + ); + ensure!( + event.data[32..64] == U256::from(64).to_be_bytes::<32>(), + "ValidatorRegistered bytes offset mismatch" + ); + ensure!( + event.data[64..96] == U256::from(32).to_be_bytes::<32>(), + "ValidatorRegistered public-key length mismatch" + ); + ensure!( + event.data[96..128] == public_key[..], + "ValidatorRegistered public key mismatch" + ); + + let validator_slot = + keccak256((registration_id, VALIDATOR_REGISTRY_STORAGE_LOCATION).abi_encode()); + let public_key_slot = storage_slot_offset(validator_slot, 1); + let public_key_hash = keccak256(public_key.as_ref()); + let registered_public_key_slot = keccak256( + (public_key_hash, VALIDATOR_REGISTERED_PUBLIC_KEYS_SLOT).abi_encode(), + ); + assert_storage_changes( + output, + state_diff, + VALIDATOR_REGISTRY_ADDRESS, + &[ + (validator_slot, U256::from(1)), + (public_key_slot, U256::from(65)), + ( + keccak256(public_key_slot.as_slice()), + U256::from_be_slice(public_key.as_ref()), + ), + (registered_public_key_slot, U256::from(1)), + ( + VALIDATOR_NEXT_REGISTRATION_ID_SLOT, + registration_id.saturating_add(U256::from(1)), + ), + ], + ) + }, + )) + .run() + .await +} diff --git a/crates/execution-e2e/tests/debank_trace.rs b/crates/execution-e2e/tests/debank_trace.rs new file mode 100644 index 0000000..314f0ea --- /dev/null +++ b/crates/execution-e2e/tests/debank_trace.rs @@ -0,0 +1,168 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! End-to-end coverage for the canonical `trace_debankBlock` state pass. + +use alloy_consensus::constants::KECCAK_EMPTY; +use alloy_eips::{eip2935::HISTORY_STORAGE_ADDRESS, BlockId}; +use alloy_primitives::{keccak256, Address, U256}; +use arc_execution_e2e::{ + actions::{ProduceBlocks, SendTransaction}, + Action, ArcEnvironment, ArcSetup, ArcTestBuilder, +}; +use arc_precompiles::system_accounting::SYSTEM_ACCOUNTING_ADDRESS; +use debank_rpc::debank_trace::{BlockStorageDiff, DebankOutPut}; +use eyre::{ensure, Result}; +use futures_util::future::BoxFuture; +use jsonrpsee::{core::client::ClientT, rpc_params}; + +#[derive(Debug)] +struct AssertDebankBlock { + changed_address: Option
, +} + +impl AssertDebankBlock { + const fn empty() -> Self { + Self { + changed_address: None, + } + } + + const fn with_changed_address(changed_address: Address) -> Self { + Self { + changed_address: Some(changed_address), + } + } +} + +impl Action for AssertDebankBlock { + fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, Result<()>> { + Box::pin(async move { + let block_number = env.block_number(); + let client = env + .node() + .rpc_client() + .ok_or_else(|| eyre::eyre!("RPC client not available"))?; + let output: DebankOutPut = client + .request( + "trace_debankBlock", + rpc_params![BlockId::number(block_number)], + ) + .await?; + let state_diff = alloy_rlp::decode_exact::(&output.state_diff)?; + + ensure!( + output.block_file.block.height == block_number, + "trace_debankBlock returned height {} for requested block {block_number}", + output.block_file.block.height + ); + ensure!( + state_diff.hash == output.header.state_root, + "StateDiff root does not match the block header" + ); + let parent: Option = client + .request( + "eth_getBlockByHash", + rpc_params![output.block_file.block.parent_id, false], + ) + .await?; + let parent = parent.ok_or_else(|| eyre::eyre!("parent block is unavailable"))?; + ensure!( + state_diff.parent_hash == parent.header.state_root, + "StateDiff parent root does not match the parent block header" + ); + + let system_contracts = vec![HISTORY_STORAGE_ADDRESS, SYSTEM_ACCOUNTING_ADDRESS]; + ensure!( + output.block_file.storage_contracts == system_contracts, + "block hooks produced unexpected storage contracts: {:?}", + output.block_file.storage_contracts + ); + let storage_diff_addresses: Vec<_> = state_diff + .storage_diffs + .iter() + .map(|diff| diff.address) + .collect(); + let mut expected_storage_diff_addresses: Vec<_> = system_contracts + .iter() + .map(|address| keccak256(address.as_slice())) + .collect(); + expected_storage_diff_addresses.sort_unstable(); + ensure!( + storage_diff_addresses == expected_storage_diff_addresses, + "StateDiff does not contain both Arc block hooks" + ); + ensure!( + state_diff + .storage_diffs + .iter() + .all(|account| account.diffs.len() == 1), + "each Arc block hook must write exactly one storage slot" + ); + + if let Some(changed_address) = self.changed_address { + ensure!( + output.block_file.transactions.len() == 1, + "transaction block did not contain exactly one transaction" + ); + let changed_account = state_diff + .new_accounts + .iter() + .find(|account| account.address == keccak256(changed_address.as_slice())) + .ok_or_else(|| { + eyre::eyre!("transaction recipient is missing from StateDiff") + })?; + ensure!(changed_account.balance == U256::from(1)); + ensure!(changed_account.nonce == 0); + ensure!(changed_account.code_hash == KECCAK_EMPTY); + } else { + ensure!(output.block_file.transactions.is_empty()); + ensure!(output.block_file.traces.is_empty()); + ensure!(output.block_file.error_traces.is_empty()); + ensure!(output.block_file.events.is_empty()); + ensure!(output.block_file.error_events.is_empty()); + } + + Ok(()) + }) + } +} + +#[tokio::test] +async fn debank_state_pass_captures_pre_and_post_changes_in_empty_block() -> Result<()> { + reth_tracing::init_test_tracing(); + + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertDebankBlock::empty()) + .run() + .await +} + +#[tokio::test] +async fn debank_state_pass_captures_transaction_changes() -> Result<()> { + reth_tracing::init_test_tracing(); + + let recipient = Address::repeat_byte(0xa5); + ArcTestBuilder::new() + .with_setup(ArcSetup::new()) + .with_action(SendTransaction::new("transfer").with_to(recipient)) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertDebankBlock::with_changed_address(recipient)) + .run() + .await +} diff --git a/crates/execution-e2e/tests/debank_trace_edge_cases.rs b/crates/execution-e2e/tests/debank_trace_edge_cases.rs new file mode 100644 index 0000000..38d4a24 --- /dev/null +++ b/crates/execution-e2e/tests/debank_trace_edge_cases.rs @@ -0,0 +1,610 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Launched-node coverage for rare `trace_debankBlock` trace and rollback shapes. + +mod helpers; + +use alloy_consensus::constants::KECCAK_EMPTY; +use alloy_primitives::{address, keccak256, U256}; +use arc_execution_e2e::{ + actions::{AssertTxIncluded, AssertTxLogs, ProduceBlocks, SendTransaction, TxStatus}, + ArcSetup, ArcTestBuilder, +}; +use eyre::{ensure, Result}; + +use helpers::{ + constants::WALLET_FIRST_ADDRESS, + contracts::{ + create2_balance_probe_calldata, create2_with_balance_probe, + log1_then_revert_contract_deploy_code, reverting_constructor_code, + reverting_contract_deploy_code, right_pad_address, selfdestruct_contract_deploy_code, + selfdestruct_contract_runtime_code, REVERT_LOG_DATA, REVERT_LOG_TOPIC, + }, + debank::{ + assert_eip7708_transfer, current_mainnet_test_chain_spec, transaction_view, + AssertDebankBlock, + }, +}; + +#[tokio::test] +async fn debank_block_failed_create_keeps_trace_and_event_in_error_arrays() -> Result<()> { + reth_tracing::init_test_tracing(); + + let endowment = U256::from(1_000_000); + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(current_mainnet_test_chain_spec())) + .with_action( + SendTransaction::new("failed_create") + .with_create() + .with_data(reverting_constructor_code()) + .with_value(endowment) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("failed_create").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("failed_create").expect_no_logs()) + .with_action(AssertDebankBlock::new( + "failed_create", + move |_env, output, state_diff, transaction| { + ensure!(!transaction.status, "failed CREATE is marked successful"); + let created = transaction.from.create(transaction.nonce); + ensure!( + transaction.to == created, + "failed CREATE transaction target mismatch" + ); + + let view = transaction_view(output, transaction); + ensure!( + view.traces.is_empty(), + "failed CREATE leaked a successful trace" + ); + ensure!( + view.events.is_empty(), + "failed CREATE leaked a successful event" + ); + ensure!( + view.error_traces.len() == 1, + "expected one failed CREATE trace" + ); + ensure!( + view.error_events.len() == 1, + "expected one reverted Transfer event" + ); + + let root = view.error_traces[0]; + ensure!(root.parent_trace_id.is_empty(), "CREATE root has a parent"); + ensure!( + root.trace_address.is_empty(), + "CREATE root has a trace address" + ); + ensure!( + root.pos_in_parent_trace == 0, + "unexpected CREATE root position" + ); + ensure!( + root.call_create_type == "create", + "root is not a CREATE trace" + ); + ensure!(root.call_type.is_empty(), "CREATE trace has a call type"); + ensure!( + root.from_addr == transaction.from, + "unexpected CREATE caller" + ); + ensure!( + root.to_addr == created, + "unexpected would-be CREATE address" + ); + ensure!(root.value == endowment, "unexpected CREATE endowment"); + ensure!(root.subtraces == 0, "failed CREATE has child traces"); + ensure!(root.error == "Reverted", "unexpected CREATE error"); + assert_eip7708_transfer( + view.error_events[0], + transaction.from, + created, + endowment, + &root.id, + 0, + 0, + )?; + + let created_hash = keccak256(created.as_slice()); + ensure!( + state_diff + .new_accounts + .iter() + .all(|account| account.address != created_hash), + "failed CREATE leaked its account into StateDiff" + ); + ensure!( + state_diff + .new_codes + .iter() + .all(|code| code.code_hash == KECCAK_EMPTY && code.code.is_empty()), + "failed CREATE leaked non-empty runtime code" + ); + Ok(()) + }, + )) + .run() + .await +} + +#[tokio::test] +async fn debank_block_internal_create2_has_child_trace_event_and_state() -> Result<()> { + reth_tracing::init_test_tracing(); + + let probe = WALLET_FIRST_ADDRESS.create(0); + let child = probe.create2([0u8; 32], keccak256([0u8])); + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(current_mainnet_test_chain_spec())) + .with_action( + SendTransaction::new("deploy_probe") + .with_create() + .with_data(create2_with_balance_probe()) + .with_value(U256::from(1)) + .with_gas_limit(200_000), + ) + .with_action( + SendTransaction::new("run_probe") + .with_to(probe) + .with_data(create2_balance_probe_calldata(probe)) + .with_value(U256::ZERO) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy_probe").expect(TxStatus::Success)) + .with_action(AssertTxIncluded::new("run_probe").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("deploy_probe").expect_log_count(1)) + .with_action(AssertTxLogs::new("run_probe").expect_log_count(1)) + .with_action(AssertDebankBlock::new( + "run_probe", + move |_env, output, state_diff, transaction| { + ensure!(transaction.status, "CREATE2 probe transaction failed"); + ensure!(transaction.to == probe, "CREATE2 probe target mismatch"); + + let view = transaction_view(output, transaction); + ensure!( + view.traces.len() == 2, + "expected CALL root and CREATE2 child" + ); + ensure!( + view.error_traces.is_empty(), + "CREATE2 produced an error trace" + ); + ensure!( + view.events.len() == 1, + "expected one CREATE2 Transfer event" + ); + ensure!( + view.error_events.is_empty(), + "CREATE2 produced an error event" + ); + + let root = view + .traces + .iter() + .copied() + .find(|trace| trace.parent_trace_id.is_empty()) + .ok_or_else(|| eyre::eyre!("CREATE2 root trace is missing"))?; + let create = view + .traces + .iter() + .copied() + .find(|trace| trace.call_create_type == "create") + .ok_or_else(|| eyre::eyre!("internal CREATE2 trace is missing"))?; + ensure!(root.call_create_type == "call", "root is not a CALL trace"); + ensure!(root.call_type == "call", "unexpected root call type"); + ensure!(root.to_addr == probe, "root trace target mismatch"); + ensure!(root.subtraces == 1, "root subtrace count mismatch"); + ensure!( + root.self_storage_change, + "probe SSTORE was not attributed to root" + ); + ensure!( + root.storage_change, + "probe storage change did not propagate" + ); + ensure!(create.parent_trace_id == root.id, "CREATE2 parent mismatch"); + ensure!( + create.trace_address == [0], + "CREATE2 trace address mismatch" + ); + ensure!(create.pos_in_parent_trace == 0, "CREATE2 position mismatch"); + ensure!(create.call_type.is_empty(), "CREATE2 trace has a call type"); + ensure!(create.from_addr == probe, "CREATE2 caller mismatch"); + ensure!(create.to_addr == child, "CREATE2 child address mismatch"); + ensure!(create.value == U256::from(1), "CREATE2 value mismatch"); + ensure!(create.error.is_empty(), "CREATE2 trace is marked failed"); + assert_eip7708_transfer( + view.events[0], + probe, + child, + U256::from(1), + &create.id, + 0, + 1, + )?; + + ensure!( + output.block_file.storage_contracts.contains(&probe), + "probe is missing from storage_contracts" + ); + let child_account = state_diff + .new_accounts + .iter() + .find(|account| account.address == keccak256(child.as_slice())) + .ok_or_else(|| eyre::eyre!("CREATE2 child is missing from StateDiff"))?; + ensure!( + child_account.balance == U256::from(1), + "CREATE2 child balance mismatch" + ); + ensure!(child_account.nonce == 1, "CREATE2 child nonce mismatch"); + ensure!( + child_account.code_hash == KECCAK_EMPTY, + "CREATE2 child code mismatch" + ); + let probe_storage = state_diff + .storage_diffs + .iter() + .find(|account| account.address == keccak256(probe.as_slice())) + .ok_or_else(|| eyre::eyre!("probe storage diff is missing"))?; + let slot_zero = keccak256([0u8; 32]); + ensure!( + probe_storage.diffs.iter().any(|diff| { + diff.index == slot_zero + && diff.value == U256::from_be_slice(child.as_slice()) + }), + "probe did not persist the CREATE2 child address" + ); + Ok(()) + }, + )) + .run() + .await +} + +#[tokio::test] +async fn debank_block_selfdestruct_has_suicide_trace_without_account_deletion() -> Result<()> { + reth_tracing::init_test_tracing(); + + let source = WALLET_FIRST_ADDRESS.create(0); + let beneficiary = address!("0x000000000000000000000000000000000000BEEF"); + let endowment = U256::from(1_000_000); + let source_runtime = selfdestruct_contract_runtime_code(); + let source_code_hash = keccak256(&source_runtime); + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(current_mainnet_test_chain_spec())) + .with_action( + SendTransaction::new("deploy_selfdestruct") + .with_create() + .with_data(selfdestruct_contract_deploy_code()) + .with_value(endowment) + .with_gas_limit(200_000), + ) + .with_action( + SendTransaction::new("selfdestruct") + .with_to(source) + .with_data(right_pad_address(beneficiary)) + .with_value(U256::ZERO) + .with_gas_limit(200_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy_selfdestruct").expect(TxStatus::Success)) + .with_action(AssertTxIncluded::new("selfdestruct").expect(TxStatus::Success)) + .with_action(AssertTxLogs::new("deploy_selfdestruct").expect_log_count(1)) + .with_action(AssertTxLogs::new("selfdestruct").expect_log_count(1)) + .with_action(AssertDebankBlock::new( + "selfdestruct", + move |_env, output, state_diff, transaction| { + ensure!(transaction.status, "SELFDESTRUCT transaction failed"); + ensure!(transaction.to == source, "SELFDESTRUCT target mismatch"); + + let view = transaction_view(output, transaction); + ensure!( + view.traces.len() == 2, + "expected CALL root and suicide trace" + ); + ensure!( + view.error_traces.is_empty(), + "SELFDESTRUCT produced an error trace" + ); + ensure!( + view.events.len() == 1, + "expected one SELFDESTRUCT Transfer event" + ); + ensure!( + view.error_events.is_empty(), + "SELFDESTRUCT produced an error event" + ); + + let root = view + .traces + .iter() + .copied() + .find(|trace| trace.parent_trace_id.is_empty()) + .ok_or_else(|| eyre::eyre!("SELFDESTRUCT root trace is missing"))?; + let suicide = view + .traces + .iter() + .copied() + .find(|trace| trace.call_create_type == "suicide") + .ok_or_else(|| eyre::eyre!("suicide trace is missing"))?; + ensure!(root.call_create_type == "call", "root is not a CALL trace"); + ensure!(root.call_type == "call", "unexpected root call type"); + ensure!(root.to_addr == source, "root trace target mismatch"); + ensure!(root.subtraces == 1, "root subtrace count mismatch"); + ensure!( + suicide.parent_trace_id == root.id, + "suicide parent mismatch" + ); + ensure!( + suicide.trace_address == [0], + "suicide trace address mismatch" + ); + ensure!( + suicide.pos_in_parent_trace == 1, + "suicide position mismatch" + ); + ensure!( + suicide.call_type.is_empty(), + "suicide trace has a call type" + ); + ensure!(suicide.from_addr == source, "suicide source mismatch"); + ensure!( + suicide.to_addr == beneficiary, + "suicide beneficiary mismatch" + ); + ensure!(suicide.value == endowment, "suicide value mismatch"); + ensure!(suicide.error.is_empty(), "suicide trace is marked failed"); + assert_eip7708_transfer( + view.events[0], + source, + beneficiary, + endowment, + &root.id, + 0, + 1, + )?; + + let source_hash = keccak256(source.as_slice()); + ensure!( + state_diff + .deleted_accounts + .iter() + .all(|address| *address != source_hash), + "pre-existing SELFDESTRUCT account was deleted" + ); + let source_account = state_diff + .new_accounts + .iter() + .find(|account| account.address == source_hash) + .ok_or_else(|| eyre::eyre!("SELFDESTRUCT source is missing from StateDiff"))?; + ensure!( + source_account.balance.is_zero(), + "SELFDESTRUCT source kept its balance" + ); + ensure!( + source_account.nonce == 1, + "SELFDESTRUCT source nonce mismatch" + ); + ensure!( + source_account.code_hash == source_code_hash, + "SELFDESTRUCT source code hash mismatch" + ); + let matching_codes: Vec<_> = state_diff + .new_codes + .iter() + .filter(|code| code.code_hash == source_code_hash) + .collect(); + ensure!( + matching_codes.len() == 1, + "expected exactly one SELFDESTRUCT source NewCode" + ); + let source_code = matching_codes + .first() + .ok_or_else(|| eyre::eyre!("SELFDESTRUCT source NewCode is missing"))?; + ensure!( + source_code.code == source_runtime, + "SELFDESTRUCT source NewCode bytes mismatch" + ); + let beneficiary_account = state_diff + .new_accounts + .iter() + .find(|account| account.address == keccak256(beneficiary.as_slice())) + .ok_or_else(|| { + eyre::eyre!("SELFDESTRUCT beneficiary is missing from StateDiff") + })?; + ensure!( + beneficiary_account.balance == endowment, + "SELFDESTRUCT beneficiary balance mismatch" + ); + Ok(()) + }, + )) + .run() + .await +} + +#[tokio::test] +async fn debank_block_reverted_value_call_preserves_event_only_in_error_events() -> Result<()> { + reth_tracing::init_test_tracing(); + + let target = WALLET_FIRST_ADDRESS.create(0); + let value = U256::from(1_000_000); + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(current_mainnet_test_chain_spec())) + .with_action( + SendTransaction::new("deploy_reverter") + .with_create() + .with_data(reverting_contract_deploy_code()) + .with_value(U256::ZERO) + .with_gas_limit(100_000), + ) + .with_action( + SendTransaction::new("reverted_call") + .with_to(target) + .with_value(value) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy_reverter").expect(TxStatus::Success)) + .with_action(AssertTxIncluded::new("reverted_call").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("deploy_reverter").expect_no_logs()) + .with_action(AssertTxLogs::new("reverted_call").expect_no_logs()) + .with_action(AssertDebankBlock::new( + "reverted_call", + move |_env, output, state_diff, transaction| { + ensure!(!transaction.status, "reverted CALL is marked successful"); + ensure!(transaction.to == target, "reverted CALL target mismatch"); + + let view = transaction_view(output, transaction); + ensure!( + view.traces.is_empty(), + "reverted CALL leaked a successful trace" + ); + ensure!( + view.events.is_empty(), + "reverted CALL leaked a successful event" + ); + ensure!( + view.error_traces.len() == 1, + "expected one reverted CALL trace" + ); + ensure!( + view.error_events.len() == 1, + "expected one reverted Transfer event" + ); + + let root = view.error_traces[0]; + ensure!(root.parent_trace_id.is_empty(), "CALL root has a parent"); + ensure!( + root.trace_address.is_empty(), + "CALL root has a trace address" + ); + ensure!( + root.pos_in_parent_trace == 0, + "unexpected CALL root position" + ); + ensure!(root.call_create_type == "call", "root is not a CALL trace"); + ensure!(root.call_type == "call", "unexpected root call type"); + ensure!(root.from_addr == transaction.from, "unexpected CALL sender"); + ensure!(root.to_addr == target, "unexpected CALL target"); + ensure!(root.value == value, "unexpected CALL value"); + ensure!(root.subtraces == 0, "reverted CALL has child traces"); + ensure!(root.error == "Reverted", "unexpected CALL error"); + assert_eip7708_transfer( + view.error_events[0], + transaction.from, + target, + value, + &root.id, + 0, + 0, + )?; + + let target_account = state_diff + .new_accounts + .iter() + .find(|account| account.address == keccak256(target.as_slice())) + .ok_or_else(|| eyre::eyre!("reverter deployment is missing from StateDiff"))?; + ensure!( + target_account.balance.is_zero(), + "reverted CALL leaked value" + ); + ensure!( + target_account.code_hash != KECCAK_EMPTY, + "reverter code is missing" + ); + Ok(()) + }, + )) + .run() + .await +} + +#[tokio::test] +async fn debank_block_reverted_contract_log_is_only_in_error_events() -> Result<()> { + reth_tracing::init_test_tracing(); + + let target = WALLET_FIRST_ADDRESS.create(0); + ArcTestBuilder::new() + .with_setup(ArcSetup::new().with_chain_spec(current_mainnet_test_chain_spec())) + .with_action( + SendTransaction::new("deploy_log_reverter") + .with_create() + .with_data(log1_then_revert_contract_deploy_code()) + .with_value(U256::ZERO) + .with_gas_limit(150_000), + ) + .with_action( + SendTransaction::new("log_then_revert") + .with_to(target) + .with_value(U256::ZERO) + .with_gas_limit(100_000), + ) + .with_action(ProduceBlocks::new(1)) + .with_action(AssertTxIncluded::new("deploy_log_reverter").expect(TxStatus::Success)) + .with_action(AssertTxIncluded::new("log_then_revert").expect(TxStatus::Reverted)) + .with_action(AssertTxLogs::new("deploy_log_reverter").expect_no_logs()) + .with_action(AssertTxLogs::new("log_then_revert").expect_no_logs()) + .with_action(AssertDebankBlock::new( + "log_then_revert", + move |_env, output, _state_diff, transaction| { + let view = transaction_view(output, transaction); + ensure!(view.traces.is_empty(), "reverted CALL leaked a trace"); + ensure!(view.events.is_empty(), "reverted LOG leaked an event"); + ensure!(view.error_traces.len() == 1, "expected one error trace"); + ensure!( + view.error_events.len() == 1, + "expected one transaction error event" + ); + ensure!( + output.block_file.events.is_empty(), + "block contains an unexpected successful event" + ); + ensure!( + output.block_file.error_events.len() == 1, + "block must contain exactly one error event" + ); + + let root = view.error_traces[0]; + let event = view + .error_events + .first() + .ok_or_else(|| eyre::eyre!("LOG1 error event is missing"))?; + ensure!(event.contract_id == target, "LOG1 emitter mismatch"); + ensure!( + event.selector == REVERT_LOG_TOPIC.to_string(), + "LOG1 topic mismatch" + ); + ensure!(event.topics.is_empty(), "LOG1 has extra topics"); + ensure!( + event.data.as_ref() == REVERT_LOG_DATA.as_slice(), + "LOG1 data mismatch" + ); + ensure!( + event.parent_trace_id == root.id, + "LOG1 parent trace mismatch" + ); + ensure!(event.pos_in_parent_trace == 0, "LOG1 position mismatch"); + ensure!(event.idx == 0, "error event idx must remain zero"); + Ok(()) + }, + )) + .run() + .await +} diff --git a/crates/execution-e2e/tests/helpers/contracts.rs b/crates/execution-e2e/tests/helpers/contracts.rs index 19b8fd7..e092a34 100644 --- a/crates/execution-e2e/tests/helpers/contracts.rs +++ b/crates/execution-e2e/tests/helpers/contracts.rs @@ -19,7 +19,7 @@ //! Each test binary only uses a subset of these; unused items are expected. #![allow(dead_code)] -use alloy_primitives::{keccak256, Address, Bytes}; +use alloy_primitives::{b256, keccak256, Address, Bytes, B256}; use revm_bytecode::opcode::*; /// Contract with `receive() external payable {}` — accepts value, does nothing. @@ -49,6 +49,35 @@ pub fn reverting_contract_deploy_code() -> Bytes { deploy_code(&runtime) } +pub const REVERT_LOG_TOPIC: B256 = + b256!("1111111111111111111111111111111111111111111111111111111111111111"); +pub const REVERT_LOG_DATA: B256 = + b256!("2222222222222222222222222222222222222222222222222222222222222222"); + +/// Contract that emits one LOG1 with a fixed topic and 32-byte data word, then reverts. +pub fn log1_then_revert_contract_deploy_code() -> Bytes { + #[rustfmt::skip] + let runtime = [ + PUSH32, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + PUSH1, 0x00, + MSTORE, + PUSH32, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + PUSH1, 0x20, + PUSH1, 0x00, + LOG1, + PUSH1, 0x00, + PUSH1, 0x00, + REVERT, + ]; + deploy_code(&runtime) +} + /// Constructor that reverts during deployment (no contract is created). /// This is NOT wrapped in deploy_code — it IS the initcode that reverts. /// @@ -78,13 +107,18 @@ pub fn reverting_constructor_code() -> Bytes { /// selfdestruct ;; [] — send balance to addr and destroy /// ``` pub fn selfdestruct_contract_deploy_code() -> Bytes { + deploy_code(&selfdestruct_contract_runtime_code()) +} + +/// Runtime bytecode installed by [`selfdestruct_contract_deploy_code`]. +pub fn selfdestruct_contract_runtime_code() -> Bytes { #[rustfmt::skip] let runtime = [ - PUSH1, 0x00, CALLDATALOAD, // calldataload(0) → 32-byte word - PUSH1, 0x60, SHR, // shr(96) → target address - SELFDESTRUCT, // selfdestruct(addr) + PUSH1, 0x00, CALLDATALOAD, + PUSH1, 0x60, SHR, + SELFDESTRUCT, ]; - deploy_code(&runtime) + Bytes::copy_from_slice(&runtime) } /// Contract that forwards received value to a target address via CALL. diff --git a/crates/execution-e2e/tests/helpers/debank.rs b/crates/execution-e2e/tests/helpers/debank.rs new file mode 100644 index 0000000..b49a544 --- /dev/null +++ b/crates/execution-e2e/tests/helpers/debank.rs @@ -0,0 +1,310 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Reusable `trace_debankBlock` assertions for launched-node e2e tests. +#![allow(dead_code)] + +use std::{collections::HashSet, sync::Arc}; + +use alloy_eips::BlockId; +use alloy_primitives::{Address, B256, U256}; +use arc_execution_config::{ + chainspec::{ArcChainSpec, LOCAL_DEV}, + hardforks::{ArcHardfork, ARC_MAINNET_HARDFORKS}, +}; +use arc_execution_e2e::{Action, ArcEnvironment}; +use debank_rpc::debank_trace::{ + BlockStorageDiff, DebankEvent, DebankID, DebankOutPut, DebankTrace, DebankTransaction, +}; +use eyre::{ensure, Result}; +use futures_util::future::BoxFuture; +use jsonrpsee::{core::client::ClientT, rpc_params}; +use reth_chainspec::EthereumHardforks; + +use super::constants::{SYSTEM_ADDRESS, TRANSFER_EVENT_SIGNATURE}; + +/// Localdev genesis with the hardfork set currently active on Arc mainnet. +pub fn current_mainnet_test_chain_spec() -> Arc { + let mut chain_spec = LOCAL_DEV.as_ref().clone(); + chain_spec.inner.hardforks = ARC_MAINNET_HARDFORKS.clone(); + + let flags = chain_spec.get_hardfork_flags(0, 0); + assert!(flags.is_active(ArcHardfork::Zero3)); + assert!(flags.is_active(ArcHardfork::Zero4)); + assert!(flags.is_active(ArcHardfork::Zero5)); + assert!(flags.is_active(ArcHardfork::Zero6)); + assert!(!flags.is_active(ArcHardfork::Zero7)); + assert!(!flags.is_active(ArcHardfork::Zero8)); + assert!(chain_spec.is_osaka_active_at_timestamp(0)); + + Arc::new(chain_spec) +} + +type BlockAssertion = Box< + dyn Fn(&ArcEnvironment, &DebankOutPut, &BlockStorageDiff, &DebankTransaction) -> Result<()> + + Send + + Sync, +>; + +/// Fetches the current block through `trace_debankBlock`, checks invariants shared by every +/// scenario, then runs a scenario-specific assertion. +pub struct AssertDebankBlock { + tx_name: String, + assertion: BlockAssertion, +} + +impl AssertDebankBlock { + pub fn new( + tx_name: impl Into, + assertion: impl Fn(&ArcEnvironment, &DebankOutPut, &BlockStorageDiff, &DebankTransaction) -> Result<()> + + Send + + Sync + + 'static, + ) -> Self { + Self { + tx_name: tx_name.into(), + assertion: Box::new(assertion), + } + } +} + +impl Action for AssertDebankBlock { + fn execute<'a>(&'a mut self, env: &'a mut ArcEnvironment) -> BoxFuture<'a, Result<()>> { + Box::pin(async move { + let block_number = env.block_number(); + let tx_hash = *env.get_tx_hash(&self.tx_name).ok_or_else(|| { + eyre::eyre!( + "transaction '{}' is not in the test environment", + self.tx_name + ) + })?; + let client = env + .node() + .rpc_client() + .ok_or_else(|| eyre::eyre!("RPC client not available"))?; + let output: DebankOutPut = client + .request( + "trace_debankBlock", + rpc_params![BlockId::number(block_number)], + ) + .await?; + let state_diff = alloy_rlp::decode_exact::(&output.state_diff)?; + + ensure!( + output.block_file.block.height == block_number, + "trace_debankBlock returned height {} for requested block {block_number}", + output.block_file.block.height + ); + ensure!( + state_diff.hash == output.header.state_root, + "StateDiff root does not match the block header" + ); + let parent: Option = client + .request( + "eth_getBlockByHash", + rpc_params![output.block_file.block.parent_id, false], + ) + .await?; + let parent = parent.ok_or_else(|| eyre::eyre!("parent block is unavailable"))?; + ensure!( + state_diff.parent_hash == parent.header.state_root, + "StateDiff parent root does not match the parent block header" + ); + ensure!( + output.validation_hash == output.block_file.validation().validation_hash, + "BlockFile validation hash mismatch" + ); + let tx_id = tx_hash.to_string(); + let mut matching_transactions = output + .block_file + .transactions + .iter() + .filter(|transaction| transaction.id == tx_id); + let transaction = matching_transactions.next().ok_or_else(|| { + eyre::eyre!("BlockFile does not contain transaction '{}'", self.tx_name) + })?; + ensure!( + matching_transactions.next().is_none(), + "BlockFile contains transaction '{}' more than once", + self.tx_name + ); + + let trace_ids: HashSet<_> = output + .block_file + .traces + .iter() + .chain(&output.block_file.error_traces) + .map(|trace| trace.id.as_str()) + .collect(); + let trace_count = output + .block_file + .traces + .iter() + .chain(&output.block_file.error_traces) + .count(); + ensure!( + trace_ids.len() == trace_count, + "BlockFile contains duplicate trace ids" + ); + let transaction_ids: HashSet<_> = output + .block_file + .transactions + .iter() + .map(|transaction| transaction.id.as_str()) + .collect(); + ensure!( + transaction_ids.len() == output.block_file.transactions.len(), + "BlockFile contains duplicate transaction ids" + ); + for trace in output + .block_file + .traces + .iter() + .chain(&output.block_file.error_traces) + { + ensure!( + transaction_ids.contains(trace.tx_id.as_str()), + "trace references a missing transaction" + ); + ensure!(trace.id == trace.debank_id(), "trace id is not canonical"); + ensure!( + trace.parent_trace_id.is_empty() + || trace_ids.contains(trace.parent_trace_id.as_str()), + "trace references a missing parent" + ); + } + let event_ids: HashSet<_> = output + .block_file + .events + .iter() + .chain(&output.block_file.error_events) + .map(|event| event.id.as_str()) + .collect(); + let event_count = output + .block_file + .events + .iter() + .chain(&output.block_file.error_events) + .count(); + ensure!( + event_ids.len() == event_count, + "BlockFile contains duplicate event ids" + ); + for event in output + .block_file + .events + .iter() + .chain(&output.block_file.error_events) + { + ensure!(event.id == event.debank_id(), "event id is not canonical"); + ensure!( + trace_ids.contains(event.parent_trace_id.as_str()), + "event references a missing parent trace" + ); + } + + (self.assertion)(env, &output, &state_diff, transaction) + }) + } +} + +/// Transaction-scoped slices of the block-wide trace and event arrays. +pub struct DebankTxView<'a> { + pub traces: Vec<&'a DebankTrace>, + pub error_traces: Vec<&'a DebankTrace>, + pub events: Vec<&'a DebankEvent>, + pub error_events: Vec<&'a DebankEvent>, +} + +pub fn transaction_view<'a>( + output: &'a DebankOutPut, + transaction: &DebankTransaction, +) -> DebankTxView<'a> { + let traces: Vec<_> = output + .block_file + .traces + .iter() + .filter(|trace| trace.tx_id == transaction.id) + .collect(); + let error_traces: Vec<_> = output + .block_file + .error_traces + .iter() + .filter(|trace| trace.tx_id == transaction.id) + .collect(); + let trace_ids: HashSet<_> = traces + .iter() + .chain(&error_traces) + .map(|trace| trace.id.as_str()) + .collect(); + let events = output + .block_file + .events + .iter() + .filter(|event| trace_ids.contains(event.parent_trace_id.as_str())) + .collect(); + let error_events = output + .block_file + .error_events + .iter() + .filter(|event| trace_ids.contains(event.parent_trace_id.as_str())) + .collect(); + DebankTxView { + traces, + error_traces, + events, + error_events, + } +} + +/// Verifies the BlockFile representation of an Arc EIP-7708 native Transfer event. +pub fn assert_eip7708_transfer( + event: &DebankEvent, + from: Address, + to: Address, + value: U256, + parent_trace_id: &str, + pos_in_parent_trace: usize, + idx: usize, +) -> Result<()> { + let expected_topics = vec![ + B256::left_padding_from(from.as_slice()).to_string(), + B256::left_padding_from(to.as_slice()).to_string(), + ]; + ensure!( + event.contract_id == SYSTEM_ADDRESS, + "unexpected event emitter" + ); + ensure!( + event.selector == TRANSFER_EVENT_SIGNATURE.to_string(), + "unexpected event selector" + ); + ensure!(event.topics == expected_topics, "unexpected event topics"); + ensure!( + event.data.as_ref() == value.to_be_bytes::<32>().as_slice(), + "unexpected event data" + ); + ensure!( + event.parent_trace_id == parent_trace_id, + "event is attached to the wrong trace" + ); + ensure!( + event.pos_in_parent_trace == pos_in_parent_trace, + "unexpected event position" + ); + ensure!(event.idx == idx, "unexpected event log index"); + Ok(()) +} diff --git a/crates/execution-e2e/tests/helpers/mod.rs b/crates/execution-e2e/tests/helpers/mod.rs index e7fce07..2560851 100644 --- a/crates/execution-e2e/tests/helpers/mod.rs +++ b/crates/execution-e2e/tests/helpers/mod.rs @@ -16,3 +16,4 @@ pub mod constants; pub mod contracts; +pub mod debank; diff --git a/docs/debank-rpc-notes.md b/docs/debank-rpc-notes.md index 07b0bd9..bb754fc 100644 --- a/docs/debank-rpc-notes.md +++ b/docs/debank-rpc-notes.md @@ -63,11 +63,9 @@ Port from Tempo's revm 36 form back to Arc's revm 34: | AA root-trace classification fix (CallTraceArena `success` unreliable) | Remove merge-back logic | Arc has no AA wrapper traces | | TempoEvm handler / TIP-20 fee Transfer comments | Remove | Arc uses stock EIP-1559 path | -### D6. `exclude_precompile_calls = true` [decided, deferred verification] +### D6. `exclude_precompile_calls = false` [decided] -Keep at `true`. Arc's custom precompiles at `0x1800...` segment are injected via reth's precompile lookup, not via `warm_addresses()` — same pattern Tempo uses for TIP-20. - -**Open verification:** confirm during Phase 2 that Arc's precompile registration does not put `0x1800...` addresses into `warm_addresses()`. If it does, this setting would hide their traces. +Keep standard precompile frames. Tempo's `true` setting was introduced to match Parity `trace_transaction`; Arc's official Reth `debug_*` callTracer uses `false`, and the canonical pipeline producer also retains these frames. Arc's dynamic precompiles and CallFrom trace override are verified separately. ### D7. `unwrap_used = "deny"` workspace lint [decided] diff --git a/docs/debank-rpc-plan.md b/docs/debank-rpc-plan.md index e2766e6..a285250 100644 --- a/docs/debank-rpc-plan.md +++ b/docs/debank-rpc-plan.md @@ -58,7 +58,7 @@ Listed per file in the corresponding Phase 2 tasks. Summary: | Revert-tx fee log reconstruction via `serde_json::from_value::>(receipt.logs)` | Arc has no handler-emitted fee logs; revert tx receipts have only EVM-captured logs | Remove the receipt-replay branch entirely | | AA tx (type=0x76) `to_addr` / `input` unpacking from receipt | Arc has no AA tx type | Use `tx.to()` / `tx.input()` directly from the standard alloy `Transaction` trait | | AA root-trace classification fix (CallTraceArena `success` unreliable) | Arc has no AA wrapper traces | Use `CallTraceNode.trace.success` directly; final classification still governed by `receipt.status` | -| `exclude_precompile_calls=true` discussion re: TIP-20 / FeeManager | Arc's precompiles at `0x1800...` segment, but they're injected via reth's `set_precompile_lookup` like Tempo's — same warm_addresses behavior; setting stays `true` | No code change; document in plan only | +| `exclude_precompile_calls=true` inherited from Tempo | Tempo used Parity `trace_transaction` semantics, while the canonical pipeline producer and Arc `debug_*` callTracer retain standard precompile frames | Set `false`; Arc's dynamic precompile behavior remains covered separately | ## Native token sentinel (`0xeeee…`) behavior @@ -726,9 +726,9 @@ Expected match clusters (Tempo source line refs): Search for any `ExecutionResult::Halt { gas, .. }` or `ExecutionResult::Revert { gas, output, logs }` style destructuring. Convert to revm 34 form: `gas_used: u64` field; no `logs` on Halt/Revert. Drop any code reading logs from Halt/Revert. -- [ ] **Step 4: Verify `exclude_precompile_calls=true` remains appropriate** +- [x] **Step 4: Retain standard precompile call frames** -The `TracingInspectorConfig::default_parity().set_exclude_precompile_calls(true)` (or equivalent) call should stay `true`. Arc's custom precompiles at `0x1800...` segment are injected via reth's `set_precompile_lookup` like Tempo's TIP-20; they don't appear in `warm_addresses()`. Add a one-line comment if the original Tempo comment is too Tempo-specific. +Use `TracingInspectorConfig::default_parity().set_exclude_precompile_calls(false)`. Tempo's `true` setting matched Parity `trace_transaction`, but Arc's official Reth `debug_*` callTracer and the canonical pipeline producer retain standard precompile frames. Arc's custom precompiles and CallFrom trace override remain covered by their own tests. - [ ] **Step 5: Add module declaration, re-export, and trait to lib.rs** diff --git a/docs/debank-rpc-test-plan.md b/docs/debank-rpc-test-plan.md index fe65198..21a855f 100644 --- a/docs/debank-rpc-test-plan.md +++ b/docs/debank-rpc-test-plan.md @@ -33,7 +33,7 @@ OFF=https://rpc.testnet.arc.network | genesis | block 0 | section 11.1 | | 空块 | tx count == 0 | section 11.2 | | 标准交易块 | 含 ≥3 笔 EIP-1559 tx | section 3-7 主测 | -| EIP-2930 tx 块 | tx 含 access list | 3.2 类型覆盖 | +| EIP-2930 tx 块 | canonical tx 含 access list | 3.2 类型覆盖;BlockFile 当前不承载 access list | | Legacy tx 块 | tx type=0x0 | 3.2 类型覆盖 | | EIP-7702 tx 块(可选) | tx type=0x4,链上若有 | 3.2 类型覆盖 | | Revert tx 块 | receipt status=0x0,revert 前无 EVM log | 6.1, 6.6 | @@ -58,13 +58,13 @@ OFF=https://rpc.testnet.arc.network | 7. storage_contracts | 4 | – | – | – | pending | | 8. state_diff (RLP) | 14 | – | – | – | pending | | 9. header | 18 | – | – | – | pending | -| 10. validation_hash | 4 | – | – | – | pending | +| 10. validation / determinism | 7 | – | – | – | pending | | 11. 特殊区块 | 11 | – | – | – | pending | | 12. background-tracer 兼容 | 4 | – | – | – | pending | | 13. 批量回归 | 5 | – | – | – | pending | | 14. pre_traceMany | 8 | – | – | – | pending | | 15. eth_multiCall | 11 | – | – | – | pending | -| **合计** | **141** | – | – | – | pending | +| **合计** | **144** | – | – | – | pending | ### Trace 类型覆盖 @@ -84,7 +84,7 @@ OFF=https://rpc.testnet.arc.network 4. **USDC 作 native**:`eth_getBalance` 返回 18-dec native USDC。`block_file.txs[*].value` 单位是 native wei(18-dec),消费方按 USDC 解读。 5. **`Header` 字段集**:Arc 用 alloy-consensus 1.7.3,无 `block_access_list_hash` / `slot_number`(Tempo 2.0.4 才有),section 9 不要列这两项。 6. **AA tx 不存在**:`DebankTransaction.calls` / `fee_token` / `fee_payer_signature` 在 JSON 中始终为 `null`(保留 schema 兼容 background-tracer Go 消费方,D3)。section 3 不测 AA 路径。 -7. **NCA precompile emit log(D20)**:Arc 的 `NATIVE_COIN_AUTHORITY` (`0x1800...0000`) 在每笔 native USDC 操作时直接 emit log 到 receipt,绕过 EVM call frame。inspector 抓不到,需要从 `ExecutionResult::Success.logs[N..]` 或 receipt 补回——与 Tempo handler-level fee log 同机制。所以 events 公式与 Tempo 完全一致:`events + sum(revert_tx_receipt_logs) == total_receipt_logs`(revert tx 的 receipt 只剩 NCA log,inspector 抓的 revert 前 logs 进 error_events 但 receipt 中被回滚)。 +7. **Arc 直接写 journal 的 event**:Zero5+ native value transfer 和 SELFDESTRUCT 会绕过 Solidity `LOG*` opcode,NCA/custom precompile 还可能没有可见 call-trace node。`trace_debankBlock` 使用本地 event inspector 保存完整 `Log.address/topics/data`、frame 和 emission order;成功 event 必须逐项等于 `ExecutionResult.logs` 和 receipt logs。禁止再用 `exec_logs[N..]`、按内容匹配或统一挂到 root 的 fallback。EIP-7708 event emitter 固定为 `0xfffffffffffffffffffffffffffffffffffffffe`。 8. **Arc base fee 写在 parent extraData**:与 RPC 输出无关,不在 debankBlock 中暴露。 9. **5 个自定义 precompile `0x1800...0000-0004`**:调用它们的 trace 显示 to_addr = precompile 地址;per-trace `self_storage_change=false`(与 Tempo TIP-20 同因——precompile 不走 SSTORE opcode),block 级 `storage_contracts` 不受影响。section 7.3-7.4 验证。 @@ -125,7 +125,7 @@ OFF=https://rpc.testnet.arc.network |---|---|---|---|---| | 3.1.1 | id | string | receipt.transactionHash | pending | | 3.1.2 | from_addr | string(address) | receipt.from | pending | -| 3.1.3 | to_addr | string(address) | tx.to | pending | +| 3.1.3 | to_addr | string(address) | CALL 用 receipt.to;CREATE 用 receipt.contractAddress(成功和失败都不能写零地址) | pending | | 3.1.4 | gas_limit | number | tx.gas | pending | | 3.1.5 | gas_price | number | receipt.effectiveGasPrice | pending | | 3.1.6 | gas_used | number | receipt.gasUsed | pending | @@ -136,16 +136,16 @@ OFF=https://rpc.testnet.arc.network | 3.1.11 | nonce | number | tx.nonce | pending | | 3.1.12 | idx | number | receipt.transactionIndex, 从 0 递增 | pending | | 3.1.13 | value | string(hex U256) | tx.value | pending | -| 3.1.14 | access_list | array 或 null | tx.accessList (EIP-2930/1559) | pending | +| 3.1.14 | access_list | null 或字段缺失 | 当前基础 BlockFile schema 不承载 EIP-2930/1559 access list;精确 warmup 另列 TODO | pending | ### 3.2 tx 类型覆盖 | # | 测试项 | 验证内容 | 状态 | |---|---|---|---| | 3.2.1 | Legacy tx (type=0x0) | gas_price>0, max_fee_per_gas=gas_price, max_priority_fee_per_gas=0 | pending | -| 3.2.2 | EIP-2930 tx (type=0x1) | access_list 非空 | pending(链上若有) | +| 3.2.2 | EIP-2930 tx (type=0x1) | gas/status/input 等基础字段正确;记录 access list 未承载的已知限制 | pending(链上若有) | | 3.2.3 | EIP-1559 tx (type=0x2) | max_fee_per_gas>0, max_priority_fee_per_gas≥0 | pending | -| 3.2.4 | EIP-7702 tx (type=0x4) | 若链上存在,验证 access_list / authorizationList | pending(链上若有) | +| 3.2.4 | EIP-7702 tx (type=0x4) | 若链上存在,基础字段正确;记录 access list / authorizationList 未承载的已知限制 | pending(链上若有) | | 3.2.5 | 成功 tx | status=true | pending | | 3.2.6 | Revert tx | status=false | pending | | 3.2.7 | txs 数量 | 与 eth_getBlockByNumber.transactions 数量一致 | pending | @@ -196,7 +196,8 @@ OFF=https://rpc.testnet.arc.network | 4.2.3 | staticcall 类型 | type="call", call_type="staticcall" | pending | | 4.2.4 | create 类型 | type="create", call_type="", to_addr=新地址 | pending | | 4.2.5 | 深层嵌套 | trace_address 至少 ≥3 层 | pending | -| 4.2.6 | storage_change 传播 | 子 trace 有 SSTORE → 父 storage_change=true | pending | +| 4.2.6 | storage_change 传播 | 子 frame 成功执行 SSTORE → 父 storage_change=true;即使祖先随后 revert 也保留执行信号 | pending | +| 4.2.7 | CREATE2 类型 | 高层 type="create"、call_type="";CREATE2 不使用非标准 type="create2" | pending | ### 4.3 ID 计算 & 唯一性 @@ -226,7 +227,7 @@ OFF=https://rpc.testnet.arc.network | 5.1.3 | selector | string(hex, topic[0]) | receipt.logs[].topics[0] | pending | | 5.1.4 | topics | array[string] | receipt.logs[].topics[1:] | pending | | 5.1.5 | data | string(hex) | receipt.logs[].data | pending | -| 5.1.6 | parent_trace_id | string | 必须指向同区块内真实 trace id;event 的 parent trace.to_addr == event.contract_id | pending | +| 5.1.6 | parent_trace_id | string | 必须指向实际产生 event 的可见 trace;EIP-7708 emitter 与执行地址不同,不能用 to_addr 推导 | pending | | 5.1.7 | pos_in_parent_trace | number | 同 parent 下 positions 无重复且按序 | pending | | 5.1.8 | idx | number | 区块内全局 log index,从 0 递增 | pending | @@ -234,9 +235,9 @@ OFF=https://rpc.testnet.arc.network | # | 测试项 | 验证内容 | 状态 | |---|---|---|---| -| 5.2.1 | `events + sum(revert_tx.receipt.logs.length) == total_receipt_logs` | Tempo 公式(D20 修正 6.6 表述):成功 tx 的 events 含 NCA log;revert tx 的 receipt 只有 NCA log,inspector 抓的 revert 前 EVM logs 进 error_events 但 receipt 中被回滚 | **PASS** (97==97 on 0x2813738; 101/101 on 100-block sample) | -| 5.2.2 | 无重复 idx | events + error_events 排序后 unique == 总数 | pending | -| 5.2.3 | idx 连续 | 排序后等于 `[0, 1, ..., N-1]` | pending | +| 5.2.1 | success events 等于 receipt logs | 按 block/tx emission order 逐项比较 address/topics/data,数量与内容都相同 | pending | +| 5.2.2 | success event idx 唯一 | events 的 idx 无重复;error_events 不参与,因为协议规定其 idx=0 | pending | +| 5.2.3 | success event idx 连续 | events 的 idx 等于 `[0, 1, ..., N-1]`;所有 error_events.idx=0 | pending | | 5.2.4 | 多 tx 跨 tx 连续 | tx0 idx=[0..a], tx1 idx=[a+1..b],无间隔无重叠 | pending | --- @@ -250,7 +251,7 @@ OFF=https://rpc.testnet.arc.network | 6.3 | error_traces 字段完整 | 与 traces[0] 同 18 个字段 | pending | | 6.4 | error_events 字段完整 | 与 events[0] 同 8 个字段 | pending | | 6.5 | traces + error_traces = trace_transaction 总数 | per-tx 验证 | pending | -| 6.6 | events + sum(revert_tx.receipt.logs) = receipt logs 总数 | per-block 验证(Tempo 同公式,含 D20 NCA log 补回) | **PASS** (100/100 sample) | +| 6.6 | success events = receipt logs | per-block 逐项验证;error_events 是 reverted frame 的执行记录,不属于 receipt | pending | | 6.7 | error 字段非空 | error_traces 中 error 字段 == "Reverted" 或类似非空字符串 | pending | | 6.8 | revert tx with EVM events 行为 | revert 前 emit 的 EVM events → 全部出现在 error_events(inspector 捕获)。receipt 中这些 log 被回滚不存在。Arc:`error_events == inspector_captured_events`(无 fee log 修正) | pending | | 6.9 | 内部 revert(try/catch) | 成功 tx 中失败的子调用 traces 进 error_traces,其余进 traces;与 reth-x 行为一致 | pending | @@ -355,14 +356,17 @@ OFF=https://rpc.testnet.arc.network --- -## 10. validation_hash +## 10. validation_hash 与确定性 | # | 测试项 | 验证 | 状态 | |---|---|---|---| | 10.1 | 类型 | jq `type == "number"` | pending | | 10.2 | 非零 | ≠ 0 | pending | -| 10.3 | 算法 | SHA1(所有 id 拼接) 取末 6 hex chars 转十进制 — 与代码对照 | pending | +| 10.3 | 算法 | 对每个 id 求 SHA1 并按整数求和,取十进制和的末 6 位 — 与代码对照 | pending | | 10.4 | 幂等 | 同 block 调两次返回相同 validation_hash | pending | +| 10.5 | BlockFile 确定性 | 同 block 重放两次,将 `process_start_timestamp` 归一化后逐字段相同 | pending | +| 10.6 | StateDiff RLP 确定性 | 同 block 重放两次,`state_diff` 原始 bytes 完全相同 | pending | +| 10.7 | 集合排序 | `storage_contracts`、account/code/storage diff 外层及每个 storage slots 内层均按协议字段升序 | pending | --- @@ -374,9 +378,9 @@ OFF=https://rpc.testnet.arc.network | 11.2 | 空区块 | 任一 tx 数为 0 的块:txs=[], traces=[], events=[], state_diff 仅含 base fee 相关 storage(若 SYSTEM_ACCOUNTING 触发) | pending | | 11.3 | Base fee 计算块 | 检查相邻 5 块的 `baseFeePerGas` 变化平滑(EWMA) | pending | | 11.4 | 多 tx 区块 | 检查 txs[].idx 严格递增 [0,1,2,...] | pending | -| 11.5 | CREATE 区块 | traces 含 type="create"; state_diff.new_codes ≥1 | pending | +| 11.5 | CREATE 区块 | root trace type="create";tx.to_addr 等于 receipt.contractAddress;state_diff.new_codes ≥1;成功/失败 CREATE 都覆盖 | pending | | 11.6 | 自定义 precompile 调用块 | trace 5 个 `0x1800...` precompile 之一被调用的块:traces 含 to_addr in {NCA, NCC, SYSACCT, CALLFROM, PQ};输出 result 非空 | pending | -| 11.7 | USDC native 转账块 | EOA→EOA native USDC 转账:traces 顶层 type="call" value > 0;events 含统一 Transfer log(contract_id = `0x3600...0000`) | pending | +| 11.7 | USDC native 转账块 | EOA→EOA native USDC 转账:traces 顶层 type="call" value > 0;events 含统一 Transfer log(contract_id = `0xfffffffffffffffffffffffffffffffffffffffe`) | pending | | 11.8 | USDC ERC-20 转账块 | 调用 `0x3600...0000.transfer(...)`:traces 含 to_addr=USDC 合约;events 含 Transfer log;两种接口产生的 events 等价 | pending | | 11.9 | 不存在的区块 | `trace_debankBlock("0xffffffff")` → JSON-RPC error,message 含 "not found" | pending | | 11.10 | 最新区块 | `trace_debankBlock("latest")` → 当前链头,height 与 eth_blockNumber 一致 | pending | @@ -403,9 +407,9 @@ OFF=https://rpc.testnet.arc.network |---|---|---|---| | 13.1 | tx 数量一致 | 200 blocks | pending | | 13.2 | block hash 一致 | 200 blocks | pending | -| 13.3 | event idx 全局递增无重复 | 200 blocks | pending | +| 13.3 | event idx 语义 | success events 按 block-global receipt order 从 0 连续递增;所有 error_events.idx=0 | pending | | 13.4 | trace 数量一致(per-tx vs trace_transaction) | 200 blocks, 估 ~500 txs | pending | -| 13.5 | events + sum(revert_tx_receipt_logs) == total_receipt_logs | 200 blocks | **PASS** (100/100 sample after D20 fix) | +| 13.5 | success events 的 address/topics/data 按顺序逐项等于所有 receipt logs;error_events 单独验证 frame/position | 200 blocks | pending | --- @@ -482,8 +486,9 @@ OFF=https://rpc.testnet.arc.network | `eth_multiCall` not found | EL `--http.api` 含 `eth`(默认)即可 | | 老块查 null | mdbx archive 同步状态:`eth_syncing` | | 大块性能慢 | 单块 > 5s:检查 `parallel_requests` / inspector 配置 | -| events 与 receipt.logs 数量不一致 | Arc 应严格 events+error_events == receipt.logs,若不等则代码侧异常 | -| validation_hash 跨次调用不一致 | 算法或 trace 顺序不确定,必须排查 | +| success events 与 receipt.logs 不一致 | 检查 event inspector 的 journal/callback 去重、hidden precompile frame 和 emitter address;禁止按数量补尾 | +| validation_hash 相同但响应不同 | validation_hash 不覆盖 storage_contracts/state_diff;归一化 process_start_timestamp 后逐字段比较,并单独比较 RLP bytes | +| validation_hash 跨次调用不一致 | trace/event 顺序或 ID 不确定,必须排查 | --- diff --git a/docs/todo.md b/docs/todo.md index f53964d..68e2b28 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -36,7 +36,7 @@ Open items, listed by priority/phase. Subagents append here during execution; us - [ ] `background-tracer` sidecar integration for Arc: configure Kafka topics + S3 bucket layout (per `~/code/task_tempo/docs/generic-node.md` deployment section) - [ ] `leafage-evm` pipeline ingestion config for Arc - [ ] Performance benchmarks (target Tempo's 12ms / call baseline) -- [ ] Verify `exclude_precompile_calls=true` doesn't hide Arc's `0x1800...` custom precompile traces (see notes D6) +- [x] Set `exclude_precompile_calls=false` to match the canonical pipeline producer and Arc `debug_*` callTracer (see notes D6) - [ ] Verify genesis handling on Arc — does Arc inject synthetic genesis txs? (see notes D8) ## Phase 1 polish (do before Task 10 commit) diff --git a/scripts/test-debank-blockfile.sh b/scripts/test-debank-blockfile.sh new file mode 100755 index 0000000..787c31c --- /dev/null +++ b/scripts/test-debank-blockfile.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +# Copyright 2026 Circle Internet Group, Inc. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# +# Runs the reusable unit and launched-node tests for trace_debankBlock output. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$REPO_ROOT" + +cargo test --locked -p debank-rpc --lib +cargo test --locked -p arc-execution-e2e \ + --test debank_eip7702 \ + --test debank_trace \ + --test debank_trace_edge_cases \ + --test debank_precompiles \ + --test debank_system_contracts \ + -- --test-threads=1