diff --git a/crates/rpc/src/debug/endpoints.rs b/crates/rpc/src/debug/endpoints.rs index b2c6f200..033a37a0 100644 --- a/crates/rpc/src/debug/endpoints.rs +++ b/crates/rpc/src/debug/endpoints.rs @@ -10,15 +10,73 @@ use crate::{ }; use ajj::HandlerCtx; use alloy::{ - consensus::BlockHeader, - eips::BlockId, - rpc::types::trace::geth::{GethTrace, TraceResult}, + consensus::{ + BlockHeader, Receipt, ReceiptEnvelope, ReceiptWithBloom, TxReceipt, + transaction::SignerRecoverable, + }, + eips::{BlockId, eip2718::Encodable2718}, + primitives::{B256, Bytes, Log}, + rpc::types::trace::geth::{GethDebugTracingOptions, GethTrace, TraceResult}, }; use itertools::Itertools; use signet_hot::{HotKv, model::HotKvRead}; -use signet_types::MagicSig; +use signet_types::{MagicSig, constants::SignetSystemConstants}; use tracing::Instrument; -use trevm::revm::database::DBErrorMarker; +use trevm::revm::{ + Database, DatabaseRef, + database::{DBErrorMarker, State}, + primitives::hardfork::SpecId, +}; + +/// Shared tracing loop used by block-level debug handlers. +/// +/// Sets up the EVM from pre-resolved components, iterates through +/// transactions (stopping at the first magic-signature tx), and traces +/// each one according to the provided [`GethDebugTracingOptions`]. +#[allow(clippy::too_many_arguments)] +fn trace_block_inner( + ctx_chain_id: u64, + constants: SignetSystemConstants, + spec_id: SpecId, + header: &alloy::consensus::Header, + block_hash: B256, + txs: &[signet_storage_types::RecoveredTx], + db: State, + opts: &GethDebugTracingOptions, +) -> Result, DebugError> +where + Db: Database + DatabaseRef, + ::Error: DBErrorMarker, + ::Error: DBErrorMarker, +{ + let mut evm = signet_evm::signet_evm(db, constants); + evm.set_spec_id(spec_id); + let mut trevm = evm.fill_cfg(&CfgFiller(ctx_chain_id)).fill_block(header); + + let mut frames = Vec::with_capacity(txs.len()); + let mut txns = txs.iter().enumerate().peekable(); + for (idx, tx) in txns + .by_ref() + .peeking_take_while(|(_, t)| MagicSig::try_from_signature(t.signature()).is_none()) + { + let tx_info = alloy::rpc::types::TransactionInfo { + hash: Some(*tx.tx_hash()), + index: Some(idx as u64), + block_hash: Some(block_hash), + block_number: Some(header.number), + base_fee: header.base_fee_per_gas(), + }; + + let t = trevm.fill_tx(tx); + let frame; + (frame, trevm) = crate::debug::tracer::trace(t, opts, tx_info)?; + frames.push(TraceResult::Success { result: frame, tx_hash: Some(*tx.tx_hash()) }); + + tracing::debug!(tx_index = idx, tx_hash = ?tx.tx_hash(), "Traced transaction"); + } + + Ok(frames) +} /// `debug_traceBlockByNumber` and `debug_traceBlockByHash` handler. pub(super) async fn trace_block( @@ -67,8 +125,6 @@ where tracing::debug!(number = header.number, "Loaded block"); - let mut frames = Vec::with_capacity(txs.len()); - // State BEFORE this block. let db = ctx.revm_state_at_height(header.number.saturating_sub(1)).map_err(|e| { tracing::warn!(error = %e, block_num, "hot storage read failed"); @@ -76,32 +132,16 @@ where })?; let spec_id = ctx.spec_id_for_header(&header); - let mut evm = signet_evm::signet_evm(db, ctx.constants().clone()); - evm.set_spec_id(spec_id); - let mut trevm = evm.fill_cfg(&CfgFiller(ctx.chain_id())).fill_block(&header); - - let mut txns = txs.iter().enumerate().peekable(); - for (idx, tx) in txns - .by_ref() - .peeking_take_while(|(_, t)| MagicSig::try_from_signature(t.signature()).is_none()) - { - let tx_info = alloy::rpc::types::TransactionInfo { - hash: Some(*tx.tx_hash()), - index: Some(idx as u64), - block_hash: Some(block_hash), - block_number: Some(header.number), - base_fee: header.base_fee_per_gas(), - }; - - let t = trevm.fill_tx(tx); - let frame; - (frame, trevm) = crate::debug::tracer::trace(t, &opts, tx_info)?; - frames.push(TraceResult::Success { result: frame, tx_hash: Some(*tx.tx_hash()) }); - - tracing::debug!(tx_index = idx, tx_hash = ?tx.tx_hash(), "Traced transaction"); - } - - Ok(frames) + trace_block_inner( + ctx.chain_id(), + ctx.constants().clone(), + spec_id, + &header, + block_hash, + &txs, + db, + &opts, + ) } .instrument(span); @@ -204,3 +244,314 @@ where DebugError::EvmHalt { reason: "task panicked or cancelled".into() } ) } + +/// `debug_traceBlock` — trace all transactions in a raw RLP-encoded block. +pub(super) async fn trace_block_rlp( + hctx: HandlerCtx, + (rlp_bytes, opts): (Bytes, Option), + ctx: StorageRpcCtx, +) -> Result, DebugError> +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let opts = opts.ok_or(DebugError::InvalidTracerConfig)?; + let _permit = ctx.acquire_tracing_permit().await; + + let span = tracing::debug_span!("traceBlock(RLP)", bytes_len = rlp_bytes.len()); + + let fut = async move { + let block: alloy::consensus::Block = + alloy::rlp::Decodable::decode(&mut rlp_bytes.as_ref()) + .map_err(|e| DebugError::RlpDecode(e.to_string()))?; + + let block_hash = block.header.hash_slow(); + + let txs = block + .body + .transactions + .into_iter() + .map(|tx| tx.try_into_recovered().map_err(|_| DebugError::SenderRecovery)) + .collect::, _>>()?; + + let db = ctx.revm_state_at_height(block.header.number.saturating_sub(1)).map_err(|e| { + tracing::warn!(error = %e, number = block.header.number, "hot storage read failed"); + DebugError::from(e) + })?; + + let spec_id = ctx.spec_id_for_header(&block.header); + + trace_block_inner( + ctx.chain_id(), + ctx.constants().clone(), + spec_id, + &block.header, + block_hash, + &txs, + db, + &opts, + ) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + DebugError::EvmHalt { reason: "task panicked or cancelled".into() } + ) +} + +/// `debug_getRawBlock` handler. +/// +/// Resolves the given [`BlockId`], fetches header and transactions from cold +/// storage, assembles them into an [`alloy::consensus::Block`], and returns +/// the RLP-encoded bytes. +pub(super) async fn get_raw_block( + hctx: HandlerCtx, + (id,): (BlockId,), + ctx: StorageRpcCtx, +) -> Result +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let span = tracing::debug_span!("getRawBlock", ?id); + + let fut = async move { + let cold = ctx.cold(); + let block_num = ctx.resolve_block_id(id).map_err(|e| { + tracing::warn!(error = %e, ?id, "block resolution failed"); + DebugError::Resolve(e) + })?; + + let sealed = ctx.resolve_header(BlockId::Number(block_num.into())).map_err(|e| { + tracing::warn!(error = %e, block_num, "header resolution failed"); + DebugError::Resolve(e) + })?; + + let Some(sealed) = sealed else { + return Err(DebugError::BlockNotFound(id)); + }; + + let txs = cold.get_transactions_in_block(block_num).await.map_err(|e| { + tracing::warn!(error = %e, block_num, "cold storage read failed"); + DebugError::from(e) + })?; + + let header = sealed.into_inner(); + let tx_bodies: Vec<_> = txs.into_iter().map(|tx| tx.into_inner()).collect(); + let block = alloy::consensus::Block { + header, + body: alloy::consensus::BlockBody { + transactions: tx_bodies, + ommers: vec![], + withdrawals: None, + }, + }; + + Ok(Bytes::from(alloy::rlp::encode(&block))) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + DebugError::EvmHalt { reason: "task panicked or cancelled".into() } + ) +} + +/// `debug_getRawReceipts` handler. +/// +/// Fetches all receipts for the given [`BlockId`] and returns a list of +/// EIP-2718 encoded consensus receipt envelopes (one per transaction). +pub(super) async fn get_raw_receipts( + hctx: HandlerCtx, + (id,): (BlockId,), + ctx: StorageRpcCtx, +) -> Result, DebugError> +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let span = tracing::debug_span!("getRawReceipts", ?id); + + let fut = async move { + let block_num = ctx.resolve_block_id(id).map_err(|e| { + tracing::warn!(error = %e, ?id, "block resolution failed"); + DebugError::Resolve(e) + })?; + + let receipts = ctx.cold().get_receipts_in_block(block_num).await.map_err(|e| { + tracing::warn!(error = %e, block_num, "cold storage read failed"); + DebugError::from(e) + })?; + + let encoded = receipts + .into_iter() + .map(|cr| { + // Compute bloom before moving logs out. `status` and + // `cumulative_gas_used` are Copy, so they remain + // accessible after the partial move of `logs`. + let logs_bloom = cr.receipt.bloom(); + let logs: Vec = cr.receipt.logs.into_iter().map(|l| l.inner).collect(); + let receipt = Receipt { + status: cr.receipt.status, + cumulative_gas_used: cr.receipt.cumulative_gas_used, + logs, + }; + let rwb = ReceiptWithBloom { receipt, logs_bloom }; + let envelope: ReceiptEnvelope = match cr.tx_type { + alloy::consensus::TxType::Legacy => ReceiptEnvelope::Legacy(rwb), + alloy::consensus::TxType::Eip2930 => ReceiptEnvelope::Eip2930(rwb), + alloy::consensus::TxType::Eip1559 => ReceiptEnvelope::Eip1559(rwb), + alloy::consensus::TxType::Eip4844 => ReceiptEnvelope::Eip4844(rwb), + alloy::consensus::TxType::Eip7702 => ReceiptEnvelope::Eip7702(rwb), + }; + Bytes::from(envelope.encoded_2718()) + }) + .collect(); + + Ok(encoded) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + DebugError::EvmHalt { reason: "task panicked or cancelled".into() } + ) +} + +/// `debug_getRawHeader` handler. +/// +/// Resolves the given [`BlockId`] and returns the RLP-encoded block header. +pub(super) async fn get_raw_header( + hctx: HandlerCtx, + (id,): (BlockId,), + ctx: StorageRpcCtx, +) -> Result +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let span = tracing::debug_span!("getRawHeader", ?id); + + let fut = async move { + let block_num = ctx.resolve_block_id(id).map_err(|e| { + tracing::warn!(error = %e, ?id, "block resolution failed"); + DebugError::Resolve(e) + })?; + + let sealed = ctx.resolve_header(BlockId::Number(block_num.into())).map_err(|e| { + tracing::warn!(error = %e, block_num, "header resolution failed"); + DebugError::Resolve(e) + })?; + + let Some(sealed) = sealed else { + return Err(DebugError::BlockNotFound(id)); + }; + + let header = sealed.into_inner(); + Ok(Bytes::from(alloy::rlp::encode(&header))) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + DebugError::EvmHalt { reason: "task panicked or cancelled".into() } + ) +} + +/// `debug_traceCall` — trace a call without submitting a transaction. +/// +/// Resolves EVM state at the target block, prepares the transaction +/// from a [`alloy::rpc::types::TransactionRequest`], then routes through +/// the tracer. State overrides are not supported in this initial +/// implementation. +pub(super) async fn debug_trace_call( + hctx: HandlerCtx, + (request, block_id, opts): ( + alloy::rpc::types::TransactionRequest, + Option, + Option, + ), + ctx: StorageRpcCtx, +) -> Result +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let opts = opts.ok_or(DebugError::InvalidTracerConfig)?; + let _permit = ctx.acquire_tracing_permit().await; + + let id = block_id.unwrap_or(BlockId::latest()); + let span = tracing::debug_span!("traceCall", ?id, tracer = ?opts.tracer.as_ref()); + + let fut = async move { + use crate::config::EvmBlockContext; + + let EvmBlockContext { header, db, spec_id } = + ctx.resolve_evm_block(id).map_err(|e| match e { + crate::eth::EthError::BlockNotFound(id) => DebugError::BlockNotFound(id), + other => DebugError::EvmHalt { reason: other.to_string() }, + })?; + + let mut evm = signet_evm::signet_evm(db, ctx.constants().clone()); + evm.set_spec_id(spec_id); + let trevm = evm.fill_cfg(&CfgFiller(ctx.chain_id())).fill_block(&header); + + let trevm = trevm.fill_tx(&request); + + let tx_info = alloy::rpc::types::TransactionInfo { + hash: None, + index: None, + block_hash: None, + block_number: Some(header.number), + base_fee: header.base_fee_per_gas(), + }; + + let res = crate::debug::tracer::trace(trevm, &opts, tx_info)?.0; + + Ok(res) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + DebugError::EvmHalt { reason: "task panicked or cancelled".into() } + ) +} + +/// `debug_getRawTransaction` handler. +/// +/// Fetches the transaction by hash from cold storage and returns the +/// EIP-2718 encoded bytes. +pub(super) async fn get_raw_transaction( + hctx: HandlerCtx, + (hash,): (B256,), + ctx: StorageRpcCtx, +) -> Result +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let span = tracing::debug_span!("getRawTransaction", %hash); + + let fut = async move { + let confirmed = ctx + .cold() + .get_tx_by_hash(hash) + .await + .map_err(|e| { + tracing::warn!(error = %e, %hash, "cold storage read failed"); + DebugError::from(e) + })? + .ok_or(DebugError::TransactionNotFound(hash))?; + + let tx = confirmed.into_inner().into_inner(); + Ok(Bytes::from(tx.encoded_2718())) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + DebugError::EvmHalt { reason: "task panicked or cancelled".into() } + ) +} diff --git a/crates/rpc/src/debug/error.rs b/crates/rpc/src/debug/error.rs index 5de761bc..a8f1d568 100644 --- a/crates/rpc/src/debug/error.rs +++ b/crates/rpc/src/debug/error.rs @@ -33,6 +33,12 @@ pub enum DebugError { /// Transaction not found. #[error("transaction not found: {0}")] TransactionNotFound(B256), + /// RLP decoding failed (malformed input). + #[error("RLP decode: {0}")] + RlpDecode(String), + /// Transaction sender recovery failed. + #[error("sender recovery failed")] + SenderRecovery, } impl ajj::IntoErrorPayload for DebugError { @@ -42,9 +48,10 @@ impl ajj::IntoErrorPayload for DebugError { match self { Self::Cold(_) | Self::Hot(_) | Self::EvmHalt { .. } => -32000, Self::Resolve(r) => crate::eth::error::resolve_error_code(r), - Self::InvalidTracerConfig => -32602, + Self::InvalidTracerConfig | Self::RlpDecode(_) => -32602, Self::Unsupported(_) => -32601, Self::BlockNotFound(_) | Self::TransactionNotFound(_) => -32001, + Self::SenderRecovery => -32000, } } @@ -57,6 +64,8 @@ impl ajj::IntoErrorPayload for DebugError { Self::EvmHalt { reason } => format!("execution halted: {reason}").into(), Self::BlockNotFound(id) => format!("block not found: {id}").into(), Self::TransactionNotFound(h) => format!("transaction not found: {h}").into(), + Self::RlpDecode(msg) => format!("RLP decode error: {msg}").into(), + Self::SenderRecovery => "sender recovery failed".into(), } } @@ -64,3 +73,23 @@ impl ajj::IntoErrorPayload for DebugError { None } } + +#[cfg(test)] +mod tests { + use super::DebugError; + + #[test] + fn rlp_decode_error_code() { + use ajj::IntoErrorPayload; + let err = DebugError::RlpDecode("invalid block RLP".into()); + assert_eq!(err.error_code(), -32602); + assert!(err.error_message().contains("RLP")); + } + + #[test] + fn sender_recovery_error_code() { + use ajj::IntoErrorPayload; + let err = DebugError::SenderRecovery; + assert_eq!(err.error_code(), -32000); + } +} diff --git a/crates/rpc/src/debug/mod.rs b/crates/rpc/src/debug/mod.rs index 11e373d0..5825bfa9 100644 --- a/crates/rpc/src/debug/mod.rs +++ b/crates/rpc/src/debug/mod.rs @@ -1,7 +1,10 @@ //! Debug namespace RPC router backed by storage. mod endpoints; -use endpoints::{trace_block, trace_transaction}; +use endpoints::{ + debug_trace_call, get_raw_block, get_raw_header, get_raw_receipts, get_raw_transaction, + trace_block, trace_block_rlp, trace_transaction, +}; mod error; pub use error::DebugError; pub(crate) mod tracer; @@ -22,4 +25,10 @@ where .route("traceBlockByNumber", trace_block::) .route("traceBlockByHash", trace_block::) .route("traceTransaction", trace_transaction::) + .route("traceBlock", trace_block_rlp::) + .route("getRawBlock", get_raw_block::) + .route("getRawHeader", get_raw_header::) + .route("getRawReceipts", get_raw_receipts::) + .route("getRawTransaction", get_raw_transaction::) + .route("traceCall", debug_trace_call::) } diff --git a/crates/rpc/src/eth/endpoints.rs b/crates/rpc/src/eth/endpoints.rs index 3c9b4406..1ce6604b 100644 --- a/crates/rpc/src/eth/endpoints.rs +++ b/crates/rpc/src/eth/endpoints.rs @@ -79,6 +79,14 @@ pub(crate) async fn uncle_block() -> Result, ()> { Ok(None) } +/// `eth_protocolVersion` — returns the Ethereum wire protocol version. +/// +/// Signet does not implement devp2p. Returns a fixed value corresponding +/// to eth/68. +pub(crate) async fn protocol_version() -> Result { + Ok("0x44".to_owned()) +} + // --------------------------------------------------------------------------- // Simple Queries // --------------------------------------------------------------------------- diff --git a/crates/rpc/src/eth/mod.rs b/crates/rpc/src/eth/mod.rs index fc68c41c..89cf680b 100644 --- a/crates/rpc/src/eth/mod.rs +++ b/crates/rpc/src/eth/mod.rs @@ -4,7 +4,7 @@ mod endpoints; use endpoints::{ addr_tx_count, balance, block, block_number, block_receipts, block_tx_count, call, chain_id, code_at, create_access_list, estimate_gas, fee_history, gas_price, get_filter_changes, - get_logs, header_by, max_priority_fee_per_gas, new_block_filter, new_filter, + get_logs, header_by, max_priority_fee_per_gas, new_block_filter, new_filter, protocol_version, raw_transaction_by_block_and_index, raw_transaction_by_hash, send_raw_transaction, storage_at, subscribe, syncing, transaction_by_block_and_index, transaction_by_hash, transaction_receipt, uncle_block, uncle_count, uninstall_filter, unsubscribe, @@ -79,8 +79,9 @@ where .route("getUncleCountByBlockNumber", uncle_count) .route("getUncleByBlockHashAndIndex", uncle_block) .route("getUncleByBlockNumberAndIndex", uncle_block) + .route("protocolVersion", protocol_version) // Unsupported methods (return method_not_found by default): - // - protocolVersion, coinbase, accounts, blobBaseFee + // - coinbase, accounts, blobBaseFee // - getWork, hashrate, mining, submitHashrate, submitWork // - sendTransaction, sign, signTransaction, signTypedData // - getProof, newPendingTransactionFilter diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index e687c916..91621eac 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -28,11 +28,14 @@ pub use debug::DebugError; mod signet; pub use signet::error::SignetError; +mod net; +mod web3; + pub mod serve; pub use serve::{RpcServerGuard, ServeConfig, ServeConfigEnv, ServeError}; -/// Instantiate a combined router with `eth`, `debug`, and `signet` -/// namespaces. +/// Instantiate a combined router with `eth`, `debug`, `signet`, `web3`, and +/// `net` namespaces. pub fn router() -> ajj::Router> where H: signet_hot::HotKv + Send + Sync + 'static, @@ -42,4 +45,6 @@ where .nest("eth", eth::eth()) .nest("debug", debug::debug()) .nest("signet", signet::signet()) + .nest("web3", web3::web3()) + .nest("net", net::net()) } diff --git a/crates/rpc/src/net/mod.rs b/crates/rpc/src/net/mod.rs new file mode 100644 index 00000000..44d45f02 --- /dev/null +++ b/crates/rpc/src/net/mod.rs @@ -0,0 +1,24 @@ +//! `net` namespace RPC handlers. + +use crate::config::StorageRpcCtx; +use signet_hot::{HotKv, model::HotKvRead}; +use trevm::revm::database::DBErrorMarker; + +/// Instantiate the `net` API router. +pub(crate) fn net() -> ajj::Router> +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + ajj::Router::new().route("version", version::).route("listening", listening) +} + +/// `net_version` — returns the chain ID as a decimal string. +pub(crate) async fn version(ctx: StorageRpcCtx) -> Result { + Ok(ctx.chain_id().to_string()) +} + +/// `net_listening` — always returns true (the server is listening). +pub(crate) async fn listening() -> Result { + Ok(true) +} diff --git a/crates/rpc/src/web3/mod.rs b/crates/rpc/src/web3/mod.rs new file mode 100644 index 00000000..f5e68cd0 --- /dev/null +++ b/crates/rpc/src/web3/mod.rs @@ -0,0 +1,50 @@ +//! `web3` namespace RPC handlers. + +use crate::config::StorageRpcCtx; +use alloy::primitives::{B256, Bytes, keccak256}; +use signet_hot::{HotKv, model::HotKvRead}; +use trevm::revm::database::DBErrorMarker; + +/// Instantiate the `web3` API router. +pub(crate) fn web3() -> ajj::Router> +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + ajj::Router::new().route("clientVersion", client_version).route("sha3", sha3) +} + +/// `web3_clientVersion` — returns the signet client version string. +pub(crate) async fn client_version() -> Result { + Ok(format!("signet/v{}/{}", env!("CARGO_PKG_VERSION"), std::env::consts::OS,)) +} + +/// `web3_sha3` — returns the keccak256 hash of the given data. +pub(crate) async fn sha3((data,): (Bytes,)) -> Result { + Ok(keccak256(&data)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn client_version_format() { + let version = client_version().await.unwrap(); + assert!(version.starts_with("signet/v"), "got: {version}"); + assert!(version.contains('/'), "expected platform suffix, got: {version}"); + } + + #[tokio::test] + async fn sha3_empty_input() { + let result = sha3((Bytes::new(),)).await.unwrap(); + assert_eq!(result, keccak256(b"")); + } + + #[tokio::test] + async fn sha3_nonempty_input() { + let input = Bytes::from_static(b"hello"); + let result = sha3((input.clone(),)).await.unwrap(); + assert_eq!(result, keccak256(&input)); + } +} diff --git a/crates/rpc/tests/eth_rpc.rs b/crates/rpc/tests/eth_rpc.rs index 81e386b1..c4bff04a 100644 --- a/crates/rpc/tests/eth_rpc.rs +++ b/crates/rpc/tests/eth_rpc.rs @@ -598,12 +598,10 @@ async fn test_get_logs_empty() { // --------------------------------------------------------------------------- #[tokio::test] -async fn test_not_supported() { +async fn test_protocol_version() { let h = TestHarness::new(0).await; let resp = rpc_call_raw(&h.app, "eth_protocolVersion", json!([])).await; - assert!(resp.get("error").is_some()); - let msg = resp["error"]["message"].as_str().unwrap(); - assert!(msg.contains("not found"), "unexpected error: {msg}"); + assert_eq!(resp["result"].as_str().unwrap(), "0x44"); } #[tokio::test] diff --git a/docs/superpowers/plans/2026-03-25-parity-trace-namespace.md b/docs/superpowers/plans/2026-03-25-parity-trace-namespace.md new file mode 100644 index 00000000..503b6073 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-parity-trace-namespace.md @@ -0,0 +1,1552 @@ +# Parity `trace_` Namespace Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Parity/OpenEthereum `trace_` JSON-RPC namespace (9 methods) to signet-rpc for Blockscout and general tooling compatibility. + +**Architecture:** New `trace` module mirroring the `debug` module structure. Two new Parity tracer functions in `debug/tracer.rs` (shared inspector setup, different output builder). Two shared block replay helpers in `trace/endpoints.rs`. All handlers semaphore-gated. No block reward traces (Signet is post-merge L2). + +**Tech Stack:** Rust, ajj 0.7.0, alloy (parity trace types, filter types), revm-inspectors (ParityTraceBuilder, TracingInspector), trevm, signet-evm + +**Spec:** `docs/superpowers/specs/2026-03-25-parity-trace-namespace-design.md` + +**Prerequisite:** Branch off PR #120 (namespace completeness) which depends on PR #119 (structured error codes). Verify `IntoErrorPayload` exists and `response_tri!` is gone before starting. + +--- + +### Task 1: Create `TraceError` + +**Files:** +- Create: `crates/rpc/src/trace/error.rs` + +Model directly after `crates/rpc/src/debug/error.rs`. + +- [ ] **Step 1: Create the error enum with tests** + +```rust +//! Error types for the `trace` namespace. + +use alloy::{eips::BlockId, primitives::B256}; +use std::borrow::Cow; + +/// Errors that can occur in the `trace` namespace. +#[derive(Debug, thiserror::Error)] +pub enum TraceError { + /// Cold storage error. + #[error("cold storage error")] + Cold(#[from] signet_cold::ColdStorageError), + /// Hot storage error. + #[error("hot storage error")] + Hot(#[from] signet_storage::StorageError), + /// Block resolution error. + #[error("resolve: {0}")] + Resolve(crate::config::resolve::ResolveError), + /// EVM execution halted. + #[error("execution halted: {reason}")] + EvmHalt { + /// Debug-formatted halt reason. + reason: String, + }, + /// Block not found. + #[error("block not found: {0}")] + BlockNotFound(BlockId), + /// Transaction not found. + #[error("transaction not found: {0}")] + TransactionNotFound(B256), + /// RLP decoding failed. + #[error("RLP decode: {0}")] + RlpDecode(String), + /// Transaction sender recovery failed. + #[error("sender recovery failed")] + SenderRecovery, + /// Block range too large for trace_filter. + #[error("block range too large: {requested} blocks (max {max})")] + BlockRangeExceeded { + /// Requested range size. + requested: u64, + /// Maximum allowed range. + max: u64, + }, +} + +impl ajj::IntoErrorPayload for TraceError { + type ErrData = (); + + fn error_code(&self) -> i64 { + match self { + Self::Cold(_) + | Self::Hot(_) + | Self::EvmHalt { .. } + | Self::SenderRecovery => -32000, + Self::Resolve(r) => crate::eth::error::resolve_error_code(r), + Self::BlockNotFound(_) | Self::TransactionNotFound(_) => -32001, + Self::RlpDecode(_) | Self::BlockRangeExceeded { .. } => -32602, + } + } + + fn error_message(&self) -> Cow<'static, str> { + match self { + Self::Cold(_) | Self::Hot(_) => "server error".into(), + Self::Resolve(r) => crate::eth::error::resolve_error_message(r), + Self::EvmHalt { reason } => { + format!("execution halted: {reason}").into() + } + Self::BlockNotFound(id) => { + format!("block not found: {id}").into() + } + Self::TransactionNotFound(h) => { + format!("transaction not found: {h}").into() + } + Self::RlpDecode(msg) => { + format!("RLP decode error: {msg}").into() + } + Self::SenderRecovery => "sender recovery failed".into(), + Self::BlockRangeExceeded { requested, max } => { + format!( + "block range too large: {requested} blocks (max {max})" + ) + .into() + } + } + } + + fn error_data(self) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::TraceError; + use ajj::IntoErrorPayload; + use alloy::{eips::BlockId, primitives::B256}; + + #[test] + fn cold_error_code() { + // Cold/Hot/EvmHalt/SenderRecovery all map to -32000 + let err = TraceError::SenderRecovery; + assert_eq!(err.error_code(), -32000); + } + + #[test] + fn block_not_found_code() { + let err = TraceError::BlockNotFound(BlockId::latest()); + assert_eq!(err.error_code(), -32001); + } + + #[test] + fn transaction_not_found_code() { + let err = TraceError::TransactionNotFound(B256::ZERO); + assert_eq!(err.error_code(), -32001); + } + + #[test] + fn rlp_decode_code() { + let err = TraceError::RlpDecode("bad".into()); + assert_eq!(err.error_code(), -32602); + } + + #[test] + fn block_range_exceeded_code() { + let err = TraceError::BlockRangeExceeded { + requested: 200, + max: 100, + }; + assert_eq!(err.error_code(), -32602); + assert!(err.error_message().contains("200")); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo t -p signet-rpc -- trace::error::tests` +Note: the module won't be wired yet, so you may need to add a temporary +`mod trace;` in `lib.rs` with just `mod error; pub use error::TraceError;` +to make the tests compile. Or run tests after Task 11 wires everything. + +- [ ] **Step 3: Lint and commit** + +Run: `cargo clippy -p signet-rpc --all-features --all-targets` +Run: `cargo +nightly fmt` + +```bash +git add crates/rpc/src/trace/error.rs +git commit -m "feat(rpc): add TraceError for Parity trace namespace" +``` + +--- + +### Task 2: Create param types + +**Files:** +- Create: `crates/rpc/src/trace/types.rs` + +Follow the tuple struct pattern from `debug/types.rs`. + +- [ ] **Step 1: Create the param types** + +```rust +//! Parameter types for the `trace` namespace. + +use alloy::{ + eips::BlockId, + primitives::{Bytes, B256}, + rpc::types::{ + state::StateOverride, BlockNumberOrTag, BlockOverrides, + TransactionRequest, + trace::{filter::TraceFilter, parity::TraceType}, + }, +}; +use std::collections::HashSet; + +/// Params for `trace_block`. +#[derive(Debug, serde::Deserialize)] +pub(crate) struct TraceBlockParams(pub(crate) BlockNumberOrTag); + +/// Params for `trace_transaction`. +#[derive(Debug, serde::Deserialize)] +pub(crate) struct TraceTransactionParams(pub(crate) B256); + +/// Params for `trace_replayBlockTransactions`. +#[derive(Debug, serde::Deserialize)] +pub(crate) struct ReplayBlockParams( + pub(crate) BlockNumberOrTag, + pub(crate) HashSet, +); + +/// Params for `trace_replayTransaction`. +#[derive(Debug, serde::Deserialize)] +pub(crate) struct ReplayTransactionParams( + pub(crate) B256, + pub(crate) HashSet, +); + +/// Params for `trace_call`. +#[derive(Debug, serde::Deserialize)] +pub(crate) struct TraceCallParams( + pub(crate) TransactionRequest, + pub(crate) HashSet, + #[serde(default)] + pub(crate) Option, + #[serde(default)] + pub(crate) Option, + #[serde(default)] + pub(crate) Option>, +); + +/// Params for `trace_callMany`. +#[derive(Debug, serde::Deserialize)] +pub(crate) struct TraceCallManyParams( + pub(crate) Vec<(TransactionRequest, HashSet)>, + #[serde(default)] + pub(crate) Option, +); + +/// Params for `trace_rawTransaction`. +#[derive(Debug, serde::Deserialize)] +pub(crate) struct TraceRawTransactionParams( + pub(crate) Bytes, + pub(crate) HashSet, + #[serde(default)] + pub(crate) Option, +); + +/// Params for `trace_get`. +#[derive(Debug, serde::Deserialize)] +pub(crate) struct TraceGetParams( + pub(crate) B256, + pub(crate) Vec, +); + +/// Params for `trace_filter`. +#[derive(Debug, serde::Deserialize)] +pub(crate) struct TraceFilterParams(pub(crate) TraceFilter); +``` + +Note: check whether `HashSet` deserializes correctly from +JSON arrays. The alloy `TraceType` implements `Deserialize` and `Hash`. +If `std::collections::HashSet` doesn't work, use +`alloy::primitives::map::HashSet` instead. + +- [ ] **Step 2: Lint and commit** + +```bash +git add crates/rpc/src/trace/types.rs +git commit -m "feat(rpc): add param types for Parity trace namespace" +``` + +--- + +### Task 3: Add `max_trace_filter_blocks` config + +**Files:** +- Modify: `crates/rpc/src/config/rpc_config.rs` + +- [ ] **Step 1: Add field to `StorageRpcConfig`** + +Add after the existing `max_tracing_requests` field: + +```rust +/// Maximum block range for `trace_filter` queries. +/// +/// Default: `100`. +pub max_trace_filter_blocks: u64, +``` + +- [ ] **Step 2: Add to `Default` impl** + +```rust +max_trace_filter_blocks: 100, +``` + +- [ ] **Step 3: Add to builder** + +```rust +/// Set the max block range for trace_filter. +pub const fn max_trace_filter_blocks(mut self, max: u64) -> Self { + self.inner.max_trace_filter_blocks = max; + self +} +``` + +- [ ] **Step 4: Add to `StorageRpcConfigEnv`** + +Add field with env var annotation (follow existing pattern): + +```rust +#[from_env( + var = "SIGNET_RPC_MAX_TRACE_FILTER_BLOCKS", + desc = "Maximum block range for trace_filter queries", + optional +)] +max_trace_filter_blocks: Option, +``` + +- [ ] **Step 5: Add to `From` impl** + +```rust +max_trace_filter_blocks: env + .max_trace_filter_blocks + .unwrap_or(defaults.max_trace_filter_blocks), +``` + +- [ ] **Step 6: Lint and commit** + +Run: `cargo clippy -p signet-rpc --all-features --all-targets` +Run: `cargo +nightly fmt` + +```bash +git add crates/rpc/src/config/rpc_config.rs +git commit -m "feat(rpc): add max_trace_filter_blocks config" +``` + +--- + +### Task 4: Add Parity tracer functions + +**Files:** +- Modify: `crates/rpc/src/debug/tracer.rs` + +Add two `pub(crate)` functions alongside the existing Geth tracers. + +- [ ] **Step 1: Add `trace_parity_localized`** + +Add after the existing tracer functions. This follows the exact pattern +of `trace_flat_call` (which already uses `into_parity_builder()`): + +```rust +/// Trace a transaction and return Parity-format localized traces. +/// +/// Used by `trace_block`, `trace_transaction`, `trace_get`, +/// `trace_filter`. +pub(crate) fn trace_parity_localized( + trevm: EvmReady, + tx_info: TransactionInfo, +) -> Result<(Vec, EvmNeedsTx), DebugError> +where + Db: Database + DatabaseCommit + DatabaseRef, + Insp: Inspector>, +{ + let gas_limit = trevm.gas_limit(); + let mut inspector = TracingInspector::new( + TracingInspectorConfig::default_parity(), + ); + let trevm = trevm + .try_with_inspector(&mut inspector, |trevm| trevm.run()) + .map_err(|err| DebugError::EvmHalt { + reason: err.into_error().to_string(), + })?; + + let traces = inspector + .with_transaction_gas_limit(gas_limit) + .into_parity_builder() + .into_localized_transaction_traces(tx_info); + + Ok((traces, trevm.accept_state())) +} +``` + +Note: check whether `TracingInspector` has +`with_transaction_gas_limit()`. If not, use +`into_parity_builder().with_transaction_gas_used(trevm.gas_used())` +instead. The existing `trace_flat_call` (line 161) shows the exact +pattern — follow it. + +- [ ] **Step 2: Add `trace_parity_replay`** + +This is the more complex function — handles `TraceType` selection and +`StateDiff` enrichment: + +```rust +/// Trace a transaction and return Parity-format `TraceResults`. +/// +/// When `StateDiff` is in `trace_types`, the state diff is enriched +/// with pre-transaction balance/nonce from the database. Requires +/// `Db: DatabaseRef` for this enrichment. +/// +/// Used by `trace_replayBlockTransactions`, `trace_call`, +/// `trace_callMany`, `trace_rawTransaction`. +pub(crate) fn trace_parity_replay( + trevm: EvmReady, + trace_types: &HashSet, +) -> Result<(TraceResults, EvmNeedsTx), DebugError> +where + Db: Database + DatabaseCommit + DatabaseRef, + ::Error: std::fmt::Debug, + Insp: Inspector>, +{ + let mut inspector = TracingInspector::new( + TracingInspectorConfig::from_parity_config(trace_types), + ); + let trevm = trevm + .try_with_inspector(&mut inspector, |trevm| trevm.run()) + .map_err(|err| DebugError::EvmHalt { + reason: err.into_error().to_string(), + })?; + + // Follow the take_result_and_state pattern from trace_pre_state + // (debug/tracer.rs line ~124). This gives us the ExecutionResult + // and state map while keeping trevm alive for DB access. + let (result, mut trevm) = trevm.take_result_and_state(); + + let mut trace_res = inspector + .into_parity_builder() + .into_trace_results(&result.result, trace_types); + + // If StateDiff was requested, enrich with pre-tx balance/nonce. + if let Some(ref mut state_diff) = trace_res.state_diff { + // populate_state_diff reads pre-tx state from db and overlays + // the committed changes. Check revm-inspectors for the exact + // import path and function signature. + revm_inspectors::tracing::builder::parity::populate_state_diff( + state_diff, + trevm.inner_mut_unchecked().db_mut(), + result.state.iter(), + ) + .map_err(|e| DebugError::EvmHalt { + reason: format!("state diff: {e:?}"), + })?; + } + + // Commit the state changes. + trevm.inner_mut_unchecked().db_mut().commit(result.state); + Ok((trace_res, trevm)) +} +``` + +**IMPORTANT:** The code uses `take_result_and_state()` which follows +the pattern from `trace_pre_state` in `debug/tracer.rs` (~line 124). +Verify the exact API during implementation: +- `take_result_and_state()` returns `(ResultAndState, EvmNeedsTx)` or similar +- `inner_mut_unchecked().db_mut()` for `&mut Db` (DatabaseRef access) +- Check `populate_state_diff` import path — may be at + `revm_inspectors::tracing::parity::populate_state_diff` or + `revm_inspectors::tracing::builder::parity::populate_state_diff` + +Build docs: `cargo doc -p revm-inspectors --no-deps` and +`cargo doc -p trevm --no-deps` to find exact paths. + +- [ ] **Step 3: Add required imports** + +At the top of `tracer.rs`, add: + +```rust +use alloy::rpc::types::trace::parity::{ + LocalizedTransactionTrace, TraceResults, TraceType, +}; +use std::collections::HashSet; +``` + +- [ ] **Step 4: Lint and commit** + +Run: `cargo clippy -p signet-rpc --all-features --all-targets` +Run: `cargo +nightly fmt` + +```bash +git add crates/rpc/src/debug/tracer.rs +git commit -m "feat(rpc): add Parity tracer functions (localized + replay)" +``` + +--- + +### Task 5: Create block replay helpers + +**Files:** +- Create: `crates/rpc/src/trace/endpoints.rs` (initial skeleton) + +These parallel `debug::trace_block_inner` but produce Parity output. + +- [ ] **Step 1: Create endpoints.rs with imports and localized helper** + +```rust +//! Parity `trace` namespace RPC endpoint implementations. + +use crate::{ + config::StorageRpcCtx, + eth::helpers::{CfgFiller, await_handler}, + trace::{ + TraceError, + types::{ + ReplayBlockParams, ReplayTransactionParams, TraceBlockParams, + TraceCallManyParams, TraceCallParams, TraceFilterParams, + TraceGetParams, TraceRawTransactionParams, TraceTransactionParams, + }, + }, +}; +use ajj::HandlerCtx; +use alloy::{ + consensus::BlockHeader, + eips::BlockId, + primitives::{B256, Bytes}, + rpc::types::trace::parity::{ + LocalizedTransactionTrace, TraceResults, + TraceResultsWithTransactionHash, TraceType, + }, +}; +use signet_hot::{HotKv, model::HotKvRead}; +use signet_types::{MagicSig, constants::SignetSystemConstants}; +use std::collections::HashSet; +use tracing::Instrument; +use trevm::revm::{ + Database, DatabaseRef, + database::{DBErrorMarker, State}, + primitives::hardfork::SpecId, +}; + +/// Shared localized tracing loop for Parity `trace_block` and +/// `trace_filter`. +/// +/// Replays all transactions in a block (stopping at the first +/// magic-signature tx) and returns localized Parity traces. +#[allow(clippy::too_many_arguments)] +fn trace_block_localized( + ctx_chain_id: u64, + constants: SignetSystemConstants, + spec_id: SpecId, + header: &alloy::consensus::Header, + block_hash: B256, + txs: &[signet_storage_types::RecoveredTx], + db: State, +) -> Result, TraceError> +where + Db: Database + DatabaseRef, + ::Error: DBErrorMarker, + ::Error: DBErrorMarker, +{ + use itertools::Itertools; + + let mut evm = signet_evm::signet_evm(db, constants); + evm.set_spec_id(spec_id); + let mut trevm = evm + .fill_cfg(&CfgFiller(ctx_chain_id)) + .fill_block(header); + + let mut all_traces = Vec::new(); + let mut txns = txs.iter().enumerate().peekable(); + for (idx, tx) in txns + .by_ref() + .peeking_take_while(|(_, t)| { + MagicSig::try_from_signature(t.signature()).is_none() + }) + { + let tx_info = alloy::rpc::types::TransactionInfo { + hash: Some(*tx.tx_hash()), + index: Some(idx as u64), + block_hash: Some(block_hash), + block_number: Some(header.number), + base_fee: header.base_fee_per_gas(), + }; + + let t = trevm.fill_tx(tx); + let (traces, next); + // Convert DebugError from tracer into TraceError. + (traces, next) = crate::debug::tracer::trace_parity_localized( + t, tx_info, + ) + .map_err(|e| TraceError::EvmHalt { + reason: e.to_string(), + })?; + trevm = next; + all_traces.extend(traces); + } + + Ok(all_traces) +} +``` + +- [ ] **Step 2: Add the replay helper** + +```rust +/// Shared replay tracing loop for Parity `trace_replayBlockTransactions`. +/// +/// Replays all transactions and returns per-tx `TraceResults` with +/// the caller's `TraceType` selection. +#[allow(clippy::too_many_arguments)] +fn trace_block_replay( + ctx_chain_id: u64, + constants: SignetSystemConstants, + spec_id: SpecId, + header: &alloy::consensus::Header, + block_hash: B256, + txs: &[signet_storage_types::RecoveredTx], + db: State, + trace_types: &HashSet, +) -> Result, TraceError> +where + Db: Database + DatabaseRef, + ::Error: DBErrorMarker, + ::Error: std::fmt::Debug + DBErrorMarker, +{ + use itertools::Itertools; + + let mut evm = signet_evm::signet_evm(db, constants); + evm.set_spec_id(spec_id); + let mut trevm = evm + .fill_cfg(&CfgFiller(ctx_chain_id)) + .fill_block(header); + + let mut results = Vec::with_capacity(txs.len()); + let mut txns = txs.iter().enumerate().peekable(); + for (idx, tx) in txns + .by_ref() + .peeking_take_while(|(_, t)| { + MagicSig::try_from_signature(t.signature()).is_none() + }) + { + let t = trevm.fill_tx(tx); + let (trace_res, next); + (trace_res, next) = crate::debug::tracer::trace_parity_replay( + t, trace_types, + ) + .map_err(|e| TraceError::EvmHalt { + reason: e.to_string(), + })?; + trevm = next; + + results.push(TraceResultsWithTransactionHash { + full_trace: trace_res, + transaction_hash: *tx.tx_hash(), + }); + } + + Ok(results) +} +``` + +- [ ] **Step 3: Lint and commit** + +```bash +git add crates/rpc/src/trace/endpoints.rs +git commit -m "feat(rpc): add Parity block replay helpers" +``` + +--- + +### Task 6: Implement `trace_block` and `trace_transaction` + +**Files:** +- Modify: `crates/rpc/src/trace/endpoints.rs` + +- [ ] **Step 1: Add `trace_block` handler** + +```rust +/// `trace_block` — return Parity traces for all transactions in a block. +pub(super) async fn trace_block( + hctx: HandlerCtx, + TraceBlockParams(id): TraceBlockParams, + ctx: StorageRpcCtx, +) -> Result>, TraceError> +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let _permit = ctx.acquire_tracing_permit().await; + let id = BlockId::Number(id); + let span = tracing::debug_span!("trace_block", ?id); + + let fut = async move { + let cold = ctx.cold(); + let block_num = ctx.resolve_block_id(id).map_err(|e| { + tracing::warn!(error = %e, ?id, "block resolution failed"); + TraceError::Resolve(e) + })?; + + let sealed = ctx + .resolve_header(BlockId::Number(block_num.into())) + .map_err(|e| { + tracing::warn!(error = %e, block_num, "header resolution failed"); + TraceError::Resolve(e) + })?; + + let Some(sealed) = sealed else { + return Ok(None); + }; + + let block_hash = sealed.hash(); + let header = sealed.into_inner(); + + let txs = cold + .get_transactions_in_block(block_num) + .await + .map_err(TraceError::from)?; + + let db = ctx + .revm_state_at_height(header.number.saturating_sub(1)) + .map_err(TraceError::from)?; + + let spec_id = ctx.spec_id_for_header(&header); + let traces = trace_block_localized( + ctx.chain_id(), + ctx.constants().clone(), + spec_id, + &header, + block_hash, + &txs, + db, + )?; + + Ok(Some(traces)) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + TraceError::EvmHalt { + reason: "task panicked or cancelled".into() + } + ) +} +``` + +- [ ] **Step 2: Add `trace_transaction` handler** + +Follow the pattern of `debug::trace_transaction` — replay preceding txs +without tracing, trace only the target tx: + +```rust +/// `trace_transaction` — return Parity traces for a single transaction. +pub(super) async fn trace_transaction( + hctx: HandlerCtx, + TraceTransactionParams(tx_hash): TraceTransactionParams, + ctx: StorageRpcCtx, +) -> Result>, TraceError> +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let _permit = ctx.acquire_tracing_permit().await; + let span = tracing::debug_span!("trace_transaction", %tx_hash); + + let fut = async move { + let cold = ctx.cold(); + + let confirmed = cold + .get_tx_by_hash(tx_hash) + .await + .map_err(TraceError::from)?; + + let Some(confirmed) = confirmed else { + return Ok(None); + }; + let (_tx, meta) = confirmed.into_parts(); + let block_num = meta.block_number(); + let block_hash = meta.block_hash(); + + let block_id = BlockId::Number(block_num.into()); + let sealed = ctx + .resolve_header(block_id) + .map_err(|e| { + tracing::warn!(error = %e, block_num, "header resolution failed"); + TraceError::Resolve(e) + })?; + let header = sealed + .ok_or(TraceError::BlockNotFound(block_id))? + .into_inner(); + + let txs = cold + .get_transactions_in_block(block_num) + .await + .map_err(TraceError::from)?; + + let db = ctx + .revm_state_at_height(block_num.saturating_sub(1)) + .map_err(TraceError::from)?; + + let spec_id = ctx.spec_id_for_header(&header); + let mut evm = signet_evm::signet_evm(db, ctx.constants().clone()); + evm.set_spec_id(spec_id); + let mut trevm = evm + .fill_cfg(&CfgFiller(ctx.chain_id())) + .fill_block(&header); + + // Replay preceding txs without tracing. + use itertools::Itertools; + let mut txns = txs.iter().enumerate().peekable(); + for (_idx, tx) in txns + .by_ref() + .peeking_take_while(|(_, t)| t.tx_hash() != &tx_hash) + { + if MagicSig::try_from_signature(tx.signature()).is_some() { + return Ok(None); + } + trevm = trevm + .run_tx(tx) + .map_err(|e| TraceError::EvmHalt { + reason: e.into_error().to_string(), + })? + .accept_state(); + } + + let Some((index, tx)) = txns.next() else { + return Ok(None); + }; + + let tx_info = alloy::rpc::types::TransactionInfo { + hash: Some(*tx.tx_hash()), + index: Some(index as u64), + block_hash: Some(block_hash), + block_number: Some(header.number), + base_fee: header.base_fee_per_gas(), + }; + + let trevm = trevm.fill_tx(tx); + let (traces, _) = + crate::debug::tracer::trace_parity_localized(trevm, tx_info) + .map_err(|e| TraceError::EvmHalt { + reason: e.to_string(), + })?; + + Ok(Some(traces)) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + TraceError::EvmHalt { + reason: "task panicked or cancelled".into() + } + ) +} +``` + +- [ ] **Step 3: Lint and commit** + +```bash +git add crates/rpc/src/trace/endpoints.rs +git commit -m "feat(rpc): add trace_block and trace_transaction" +``` + +--- + +### Task 7: Implement `trace_replayBlockTransactions` and `trace_replayTransaction` + +**Files:** +- Modify: `crates/rpc/src/trace/endpoints.rs` + +- [ ] **Step 1: Add `replay_block_transactions`** + +```rust +/// `trace_replayBlockTransactions` — replay all block txs with trace type selection. +pub(super) async fn replay_block_transactions( + hctx: HandlerCtx, + ReplayBlockParams(id, trace_types): ReplayBlockParams, + ctx: StorageRpcCtx, +) -> Result>, TraceError> +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let _permit = ctx.acquire_tracing_permit().await; + let id = BlockId::Number(id); + let span = tracing::debug_span!("trace_replayBlockTransactions", ?id); + + let fut = async move { + let cold = ctx.cold(); + let block_num = ctx.resolve_block_id(id).map_err(|e| { + tracing::warn!(error = %e, ?id, "block resolution failed"); + TraceError::Resolve(e) + })?; + + let sealed = ctx + .resolve_header(BlockId::Number(block_num.into())) + .map_err(|e| TraceError::Resolve(e))?; + + let Some(sealed) = sealed else { + return Ok(None); + }; + + let block_hash = sealed.hash(); + let header = sealed.into_inner(); + + let txs = cold + .get_transactions_in_block(block_num) + .await + .map_err(TraceError::from)?; + + let db = ctx + .revm_state_at_height(header.number.saturating_sub(1)) + .map_err(TraceError::from)?; + + let spec_id = ctx.spec_id_for_header(&header); + let results = trace_block_replay( + ctx.chain_id(), + ctx.constants().clone(), + spec_id, + &header, + block_hash, + &txs, + db, + &trace_types, + )?; + + Ok(Some(results)) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + TraceError::EvmHalt { + reason: "task panicked or cancelled".into() + } + ) +} +``` + +- [ ] **Step 2: Add `replay_transaction`** + +This one uses `into_trace_results_with_state` (different from +`replay_block_transactions`), matching reth's divergent pattern: + +```rust +/// `trace_replayTransaction` — replay a single tx with trace type selection. +/// +/// Uses `into_trace_results_with_state` (different from +/// `replayBlockTransactions` which uses `into_trace_results` + +/// `populate_state_diff`). Matches reth's divergent pattern. +pub(super) async fn replay_transaction( + hctx: HandlerCtx, + ReplayTransactionParams(tx_hash, trace_types): ReplayTransactionParams, + ctx: StorageRpcCtx, +) -> Result +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let _permit = ctx.acquire_tracing_permit().await; + let span = tracing::debug_span!("trace_replayTransaction", %tx_hash); + + let fut = async move { + // Same tx lookup + block replay as trace_transaction, but use + // trace_parity_replay for the target tx instead of + // trace_parity_localized. + // + // HOWEVER: this handler needs into_trace_results_with_state, + // not into_trace_results + populate_state_diff. The spec notes + // this divergence. For the initial implementation, use + // trace_parity_replay which uses into_trace_results + + // populate_state_diff. If reth compatibility requires the + // exact into_trace_results_with_state path, refactor later. + // + // The practical difference is minimal — both produce correct + // state diffs, just through different internal paths. + + let cold = ctx.cold(); + let confirmed = cold + .get_tx_by_hash(tx_hash) + .await + .map_err(TraceError::from)? + .ok_or(TraceError::TransactionNotFound(tx_hash))?; + + let (_tx, meta) = confirmed.into_parts(); + let block_num = meta.block_number(); + + let block_id = BlockId::Number(block_num.into()); + let sealed = ctx + .resolve_header(block_id) + .map_err(|e| TraceError::Resolve(e))?; + let header = sealed + .ok_or(TraceError::BlockNotFound(block_id))? + .into_inner(); + + let txs = cold + .get_transactions_in_block(block_num) + .await + .map_err(TraceError::from)?; + + let db = ctx + .revm_state_at_height(block_num.saturating_sub(1)) + .map_err(TraceError::from)?; + + let spec_id = ctx.spec_id_for_header(&header); + let mut evm = signet_evm::signet_evm(db, ctx.constants().clone()); + evm.set_spec_id(spec_id); + let mut trevm = evm + .fill_cfg(&CfgFiller(ctx.chain_id())) + .fill_block(&header); + + // Replay preceding txs. + use itertools::Itertools; + let mut txns = txs.iter().enumerate().peekable(); + for (_idx, tx) in txns + .by_ref() + .peeking_take_while(|(_, t)| t.tx_hash() != &tx_hash) + { + if MagicSig::try_from_signature(tx.signature()).is_some() { + return Err(TraceError::TransactionNotFound(tx_hash)); + } + trevm = trevm + .run_tx(tx) + .map_err(|e| TraceError::EvmHalt { + reason: e.into_error().to_string(), + })? + .accept_state(); + } + + let (_index, tx) = txns + .next() + .ok_or(TraceError::TransactionNotFound(tx_hash))?; + + let trevm = trevm.fill_tx(tx); + let (results, _) = + crate::debug::tracer::trace_parity_replay(trevm, &trace_types) + .map_err(|e| TraceError::EvmHalt { + reason: e.to_string(), + })?; + + Ok(results) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + TraceError::EvmHalt { + reason: "task panicked or cancelled".into() + } + ) +} +``` + +- [ ] **Step 3: Lint and commit** + +```bash +git add crates/rpc/src/trace/endpoints.rs +git commit -m "feat(rpc): add trace_replayBlockTransactions and trace_replayTransaction" +``` + +--- + +### Task 8: Implement `trace_call` and `trace_callMany` + +**Files:** +- Modify: `crates/rpc/src/trace/endpoints.rs` + +- [ ] **Step 1: Add `trace_call`** + +Follows `debug_trace_call` pattern but with state/block overrides +(matching reth) and Parity output: + +```rust +/// `trace_call` — trace a call with Parity output and state overrides. +pub(super) async fn trace_call( + hctx: HandlerCtx, + TraceCallParams(request, trace_types, block_id, state_overrides, block_overrides): TraceCallParams, + ctx: StorageRpcCtx, +) -> Result +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let _permit = ctx.acquire_tracing_permit().await; + let id = block_id.unwrap_or(BlockId::latest()); + let span = tracing::debug_span!("trace_call", ?id); + + let fut = async move { + use crate::config::EvmBlockContext; + + let EvmBlockContext { header, db, spec_id } = + ctx.resolve_evm_block(id).map_err(|e| match e { + crate::eth::EthError::BlockNotFound(id) => { + TraceError::BlockNotFound(id) + } + other => TraceError::EvmHalt { + reason: other.to_string(), + }, + })?; + + let mut evm = signet_evm::signet_evm(db, ctx.constants().clone()); + evm.set_spec_id(spec_id); + let trevm = evm + .fill_cfg(&CfgFiller(ctx.chain_id())) + .fill_block(&header); + + // Apply state and block overrides (matching reth trace_call). + let trevm = trevm + .maybe_apply_state_overrides(state_overrides.as_ref()) + .map_err(|e| TraceError::EvmHalt { + reason: e.to_string(), + })? + .maybe_apply_block_overrides(block_overrides.as_deref()) + .fill_tx(&request); + + let (results, _) = + crate::debug::tracer::trace_parity_replay(trevm, &trace_types) + .map_err(|e| TraceError::EvmHalt { + reason: e.to_string(), + })?; + + Ok(results) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + TraceError::EvmHalt { + reason: "task panicked or cancelled".into() + } + ) +} +``` + +- [ ] **Step 2: Add `trace_call_many`** + +Sequential calls with state committed between each: + +```rust +/// `trace_callMany` — trace sequential calls with accumulated state. +/// +/// Each call sees state changes from prior calls. Per-call trace +/// types. Defaults to `BlockId::pending()` (matching reth). +pub(super) async fn trace_call_many( + hctx: HandlerCtx, + TraceCallManyParams(calls, block_id): TraceCallManyParams, + ctx: StorageRpcCtx, +) -> Result, TraceError> +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let _permit = ctx.acquire_tracing_permit().await; + let id = block_id.unwrap_or(BlockId::pending()); + let span = tracing::debug_span!("trace_callMany", ?id, count = calls.len()); + + let fut = async move { + use crate::config::EvmBlockContext; + + let EvmBlockContext { header, db, spec_id } = + ctx.resolve_evm_block(id).map_err(|e| match e { + crate::eth::EthError::BlockNotFound(id) => { + TraceError::BlockNotFound(id) + } + other => TraceError::EvmHalt { + reason: other.to_string(), + }, + })?; + + let mut evm = signet_evm::signet_evm(db, ctx.constants().clone()); + evm.set_spec_id(spec_id); + let mut trevm = evm + .fill_cfg(&CfgFiller(ctx.chain_id())) + .fill_block(&header); + + let mut results = Vec::with_capacity(calls.len()); + let mut calls = calls.into_iter().peekable(); + + while let Some((request, trace_types)) = calls.next() { + let filled = trevm.fill_tx(&request); + let (trace_res, next) = + crate::debug::tracer::trace_parity_replay( + filled, + &trace_types, + ) + .map_err(|e| TraceError::EvmHalt { + reason: e.to_string(), + })?; + + results.push(trace_res); + + // accept_state commits the tx's state changes so + // subsequent calls see them. + trevm = next.accept_state(); + } + + Ok(results) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + TraceError::EvmHalt { + reason: "task panicked or cancelled".into() + } + ) +} +``` + +Note: the `accept_state()` call may or may not commit to the +underlying DB. Check trevm docs. The key requirement is that call N+1 +sees state from call N. In the debug namespace, `run_tx().accept_state()` +is used for this purpose. + +- [ ] **Step 3: Lint and commit** + +```bash +git add crates/rpc/src/trace/endpoints.rs +git commit -m "feat(rpc): add trace_call and trace_callMany" +``` + +--- + +### Task 9: Implement `trace_rawTransaction` and `trace_get` + +**Files:** +- Modify: `crates/rpc/src/trace/endpoints.rs` + +- [ ] **Step 1: Add `trace_raw_transaction`** + +```rust +/// `trace_rawTransaction` — trace a transaction from raw RLP bytes. +pub(super) async fn trace_raw_transaction( + hctx: HandlerCtx, + TraceRawTransactionParams(rlp_bytes, trace_types, block_id): TraceRawTransactionParams, + ctx: StorageRpcCtx, +) -> Result +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let _permit = ctx.acquire_tracing_permit().await; + let id = block_id.unwrap_or(BlockId::latest()); + let span = tracing::debug_span!("trace_rawTransaction", ?id); + + let fut = async move { + use alloy::consensus::transaction::SignerRecoverable; + use crate::config::EvmBlockContext; + + // Decode and recover sender. + let tx: signet_storage_types::TransactionSigned = + alloy::rlp::Decodable::decode(&mut rlp_bytes.as_ref()) + .map_err(|e| TraceError::RlpDecode(e.to_string()))?; + let recovered = tx + .try_into_recovered() + .map_err(|_| TraceError::SenderRecovery)?; + + let EvmBlockContext { header, db, spec_id } = + ctx.resolve_evm_block(id).map_err(|e| match e { + crate::eth::EthError::BlockNotFound(id) => { + TraceError::BlockNotFound(id) + } + other => TraceError::EvmHalt { + reason: other.to_string(), + }, + })?; + + let mut evm = signet_evm::signet_evm(db, ctx.constants().clone()); + evm.set_spec_id(spec_id); + let trevm = evm + .fill_cfg(&CfgFiller(ctx.chain_id())) + .fill_block(&header) + .fill_tx(&recovered); + + let (results, _) = + crate::debug::tracer::trace_parity_replay(trevm, &trace_types) + .map_err(|e| TraceError::EvmHalt { + reason: e.to_string(), + })?; + + Ok(results) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + TraceError::EvmHalt { + reason: "task panicked or cancelled".into() + } + ) +} +``` + +- [ ] **Step 2: Add `trace_get`** + +```rust +/// `trace_get` — get a specific trace by tx hash and index. +/// +/// Returns `None` if `indices.len() != 1` (Erigon compatibility, +/// matching reth). +pub(super) async fn trace_get( + hctx: HandlerCtx, + TraceGetParams(tx_hash, indices): TraceGetParams, + ctx: StorageRpcCtx, +) -> Result, TraceError> +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + if indices.len() != 1 { + return Ok(None); + } + + let traces = trace_transaction( + hctx, + TraceTransactionParams(tx_hash), + ctx, + ) + .await?; + + Ok(traces.and_then(|t| t.into_iter().nth(indices[0]))) +} +``` + +- [ ] **Step 3: Lint and commit** + +```bash +git add crates/rpc/src/trace/endpoints.rs +git commit -m "feat(rpc): add trace_rawTransaction and trace_get" +``` + +--- + +### Task 10: Implement `trace_filter` + +**Files:** +- Modify: `crates/rpc/src/trace/endpoints.rs` + +- [ ] **Step 1: Add `trace_filter`** + +```rust +/// `trace_filter` — filter traces across a block range. +/// +/// Brute-force replay with configurable block range limit (default +/// 100 blocks). Matches reth's approach. +pub(super) async fn trace_filter( + hctx: HandlerCtx, + TraceFilterParams(filter): TraceFilterParams, + ctx: StorageRpcCtx, +) -> Result, TraceError> +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + let _permit = ctx.acquire_tracing_permit().await; + let span = tracing::debug_span!("trace_filter"); + + let fut = async move { + let latest = ctx.tags().latest(); + let start = filter.from_block.unwrap_or(0); + let end = filter.to_block.unwrap_or(latest); + + if start > latest || end > latest { + return Err(TraceError::BlockNotFound(BlockId::latest())); + } + if start > end { + return Err(TraceError::EvmHalt { + reason: "fromBlock cannot be greater than toBlock".into(), + }); + } + + let max = ctx.config().max_trace_filter_blocks; + let distance = end.saturating_sub(start); + if distance > max { + return Err(TraceError::BlockRangeExceeded { + requested: distance, + max, + }); + } + + let matcher = filter.matcher(); + let mut all_traces = Vec::new(); + + for block_num in start..=end { + let cold = ctx.cold(); + let block_id = BlockId::Number(block_num.into()); + + let sealed = ctx + .resolve_header(block_id) + .map_err(|e| TraceError::Resolve(e))?; + + let Some(sealed) = sealed else { + continue; + }; + + let block_hash = sealed.hash(); + let header = sealed.into_inner(); + + let txs = cold + .get_transactions_in_block(block_num) + .await + .map_err(TraceError::from)?; + + let db = ctx + .revm_state_at_height(header.number.saturating_sub(1)) + .map_err(TraceError::from)?; + + let spec_id = ctx.spec_id_for_header(&header); + let mut traces = trace_block_localized( + ctx.chain_id(), + ctx.constants().clone(), + spec_id, + &header, + block_hash, + &txs, + db, + )?; + + // Apply filter matcher. + traces.retain(|t| matcher.matches(&t.trace)); + all_traces.extend(traces); + } + + // Apply pagination: skip `after`, limit `count`. + if let Some(after) = filter.after { + let after = after as usize; + if after >= all_traces.len() { + return Ok(vec![]); + } + all_traces.drain(..after); + } + if let Some(count) = filter.count { + all_traces.truncate(count as usize); + } + + Ok(all_traces) + } + .instrument(span); + + await_handler!( + hctx.spawn(fut), + TraceError::EvmHalt { + reason: "task panicked or cancelled".into() + } + ) +} +``` + +- [ ] **Step 2: Lint and commit** + +```bash +git add crates/rpc/src/trace/endpoints.rs +git commit -m "feat(rpc): add trace_filter with configurable block range limit" +``` + +--- + +### Task 11: Wire the router + +**Files:** +- Create: `crates/rpc/src/trace/mod.rs` +- Modify: `crates/rpc/src/lib.rs` + +- [ ] **Step 1: Create `trace/mod.rs`** + +```rust +//! Parity `trace` namespace RPC router backed by storage. + +mod endpoints; +use endpoints::{ + replay_block_transactions, replay_transaction, trace_block, + trace_call, trace_call_many, trace_filter, trace_get, + trace_raw_transaction, trace_transaction, +}; +mod error; +pub use error::TraceError; +mod types; + +use crate::config::StorageRpcCtx; +use signet_hot::{HotKv, model::HotKvRead}; +use trevm::revm::database::DBErrorMarker; + +/// Instantiate a `trace` API router backed by storage. +pub(crate) fn trace() -> ajj::Router> +where + H: HotKv + Send + Sync + 'static, + ::Error: DBErrorMarker, +{ + ajj::Router::new() + .route("block", trace_block::) + .route("transaction", trace_transaction::) + .route("replayBlockTransactions", replay_block_transactions::) + .route("replayTransaction", replay_transaction::) + .route("call", trace_call::) + .route("callMany", trace_call_many::) + .route("rawTransaction", trace_raw_transaction::) + .route("get", trace_get::) + .route("filter", trace_filter::) +} +``` + +- [ ] **Step 2: Wire into `lib.rs`** + +Add `mod trace;` and `pub use trace::TraceError;` alongside the +existing module declarations. + +Add `.nest("trace", trace::trace())` to the router function. + +Update the docstring to mention the `trace` namespace. + +- [ ] **Step 3: Lint and verify** + +Run: `cargo clippy -p signet-rpc --all-features --all-targets` +Run: `cargo +nightly fmt` + +- [ ] **Step 4: Commit** + +```bash +git add crates/rpc/src/trace/mod.rs crates/rpc/src/lib.rs +git commit -m "feat(rpc): wire Parity trace namespace into router" +``` + +--- + +### Task 12: Final verification + +- [ ] **Step 1: Run all tests** + +Run: `cargo t -p signet-rpc` +Expected: All pass (existing + new TraceError tests). + +- [ ] **Step 2: Full lint** + +Run: `cargo clippy -p signet-rpc --all-features --all-targets` +Expected: Clean. + +- [ ] **Step 3: Format** + +Run: `cargo +nightly fmt` + +- [ ] **Step 4: Verify route count** + +Count `.route(` calls across all namespace modules. Expected: +eth 41 + debug 9 + trace 9 + signet 2 + web3 2 + net 2 = 65 total. + +- [ ] **Step 5: Workspace-wide lint** + +Run: `cargo clippy --all-features --all-targets` +Verify no other crates broke. + +- [ ] **Step 6: Commit any remaining fixes** + +```bash +git add -A +git commit -m "chore(rpc): final cleanup for Parity trace namespace" +``` diff --git a/docs/superpowers/specs/2026-03-25-parity-trace-namespace-design.md b/docs/superpowers/specs/2026-03-25-parity-trace-namespace-design.md new file mode 100644 index 00000000..4ba88ff7 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-parity-trace-namespace-design.md @@ -0,0 +1,242 @@ +# Parity `trace_` Namespace + +Add the Parity/OpenEthereum `trace_` JSON-RPC namespace to signet-rpc for +Blockscout and general tooling compatibility. Driven by ENG-1064 and ENG-1065. + +## Scope + +9 methods in a new `trace` namespace. No block/uncle reward traces — Signet +is a post-merge L2. No `debug_storageRangeAt` — Geth's hashed-key pagination +format doesn't match Signet's plain-key storage, and reth hasn't implemented +it either. + +## Methods + +| Method | Input | Output | +|--------|-------|--------| +| `trace_block` | `BlockNumberOrTag` | `Option>` | +| `trace_transaction` | `B256` | `Option>` | +| `trace_replayBlockTransactions` | `BlockNumberOrTag, HashSet` | `Option>` | +| `trace_replayTransaction` | `B256, HashSet` | `TraceResults` | +| `trace_call` | `TransactionRequest, HashSet, Option` | `TraceResults` | +| `trace_callMany` | `Vec<(TransactionRequest, HashSet)>, Option` | `Vec` | +| `trace_rawTransaction` | `Bytes, HashSet, Option` | `TraceResults` | +| `trace_get` | `B256, Vec` | `Option` | +| `trace_filter` | `TraceFilter` | `Vec` | + +All types from `alloy::rpc::types::trace::parity` and +`alloy::rpc::types::trace::filter`. + +## Architecture + +### New files + +- `crates/rpc/src/trace/mod.rs` — router (9 routes) + re-exports +- `crates/rpc/src/trace/endpoints.rs` — 9 handlers + 2 shared replay helpers +- `crates/rpc/src/trace/error.rs` — `TraceError` with `IntoErrorPayload` +- `crates/rpc/src/trace/types.rs` — param tuple structs for ajj positional + param deserialization (following `debug/types.rs` pattern) + +### Modified files + +- `crates/rpc/src/debug/tracer.rs` — add 2 `pub(crate)` Parity tracer + functions +- `crates/rpc/src/config/rpc_config.rs` — add `max_trace_filter_blocks` +- `crates/rpc/src/lib.rs` — add `mod trace`, export `TraceError`, nest router + +### No new dependencies + +`revm-inspectors` 0.34.2 already has `ParityTraceBuilder`. `alloy` 1.7.3 +already has all Parity trace types. The existing `trace_flat_call` in +`debug/tracer.rs` already uses `inspector.into_parity_builder()`. + +## Param Types + +Tuple structs in `trace/types.rs` for ajj positional param deserialization, +following the pattern in `debug/types.rs`: + +``` +TraceBlockParams(BlockNumberOrTag) +TraceTransactionParams(B256) +ReplayBlockParams(BlockNumberOrTag, HashSet) +ReplayTransactionParams(B256, HashSet) +TraceCallParams(TransactionRequest, HashSet, Option, + Option, Option>) +TraceCallManyParams(Vec<(TransactionRequest, HashSet)>, + Option) +TraceRawTransactionParams(Bytes, HashSet, Option) +TraceGetParams(B256, Vec) +TraceFilterParams(TraceFilter) +``` + +`trace_call` includes state and block override fields to support reth's +`TraceCallRequest` semantics via positional params. + +## Parity Tracer Functions + +Two new `pub(crate)` functions in `debug/tracer.rs`: + +**`trace_parity_localized(trevm, tx_info)`** +Returns `(Vec, EvmNeedsTx)`. Creates +`TracingInspector` with `TracingInspectorConfig::default_parity()`, runs the +tx via `try_with_inspector`, extracts `gas_used` from the result (matching +`trace_flat_call` pattern), converts via +`into_parity_builder().with_transaction_gas_used(gas).into_localized_transaction_traces(tx_info)`. + +Used by: `trace_block`, `trace_transaction`, `trace_get`, `trace_filter`. + +**`trace_parity_replay(trevm, trace_types)`** +Returns `(TraceResults, EvmNeedsTx)`. Requires `Db: Database + DatabaseRef` +(the `DatabaseRef` bound is needed for `populate_state_diff`). Creates +`TracingInspector` with +`TracingInspectorConfig::from_parity_config(&trace_types)`. Runs the tx, +then: + +1. Takes result WITHOUT committing state (holds uncommitted state). +2. Converts via `into_parity_builder().into_trace_results(&result, &trace_types)`. +3. If `StateDiff` is requested: calls `populate_state_diff(&mut state_diff, &db, state.iter())` to enrich with pre-tx balance/nonce from the DB. +4. Then commits state. + +This matches reth's `replayBlockTransactions` pattern. + +Used by: `trace_replayBlockTransactions`, `trace_call`, `trace_callMany`, +`trace_rawTransaction`. + +**Exception:** `trace_replayTransaction` uses +`into_trace_results_with_state(&res, &trace_types, &db)` instead. This +method takes `&ResultAndState` (a different type) and handles state diff +population internally. Matches reth's divergent pattern for single-tx replay. +The `DB::Error` from this call must be mapped into `TraceError`. + +## Shared Block Replay Helpers + +Two inner functions in `trace/endpoints.rs`: + +**`trace_block_localized()`** — replays block txs, calls +`trace_parity_localized` for each. Stops at the first magic-sig tx (using +`peeking_take_while`, same as `debug::trace_block_inner`). Returns +`Vec`. No reward traces (Signet is post-merge). + +**`trace_block_replay()`** — same replay loop but calls +`trace_parity_replay` with the caller's `HashSet`. Returns +`Vec`. + +Both follow the same EVM setup pattern as `debug::trace_block_inner`: +`signet_evm::signet_evm()`, `fill_cfg`, `fill_block`, iterate txs with +`peeking_take_while`. All handlers use `tracing::debug_span!` + +`.instrument(span)` for instrumentation (matching existing debug endpoints). + +## Method Details + +### `trace_block` + +Semaphore-gated. Resolves block, delegates to `trace_block_localized`. +Returns `None` if block not found. No reward traces. + +### `trace_transaction` + +Semaphore-gated. Finds tx by hash in cold storage, resolves containing block, +replays preceding txs without tracing (using `run_tx` + `accept_state`), +traces target tx with `trace_parity_localized`. Returns `None` if tx not +found. + +### `trace_replayBlockTransactions` + +Semaphore-gated. Resolves block, delegates to `trace_block_replay` with the +caller's `HashSet`. For each tx, wraps result in +`TraceResultsWithTransactionHash`. Returns `None` if block not found. + +### `trace_replayTransaction` + +Semaphore-gated. Replays preceding txs, traces target tx. Uses +`into_trace_results_with_state(&res, &trace_types, &db)` — different from +`replayBlockTransactions` which uses `into_trace_results()` + +`populate_state_diff()`. This matches reth's divergent pattern. Returns error +(not `None`) if tx not found. + +### `trace_call` + +Semaphore-gated. Resolves EVM state at block via `resolve_evm_block`. +Supports state overrides and block overrides (matching reth). Fills tx from +`TransactionRequest`, traces with `trace_parity_replay`. Defaults to latest +block if unspecified. + +### `trace_callMany` + +Semaphore-gated. Resolves EVM state once, then processes calls sequentially. +Each call can have different `HashSet`. State is committed between +calls via `db.commit(res.state)` — each subsequent call sees prior state +changes. Last call's state is not committed. Defaults to `BlockId::pending()` +if unspecified (matching reth). + +### `trace_rawTransaction` + +Semaphore-gated. Decodes RLP bytes into a transaction, recovers sender. Takes +optional `block_id` (defaults to latest). Traces with `trace_parity_replay`. + +### `trace_get` + +Semaphore-gated. Returns `None` if `indices.len() != 1` (Erigon +compatibility, matching reth). Delegates to `trace_transaction` to get all +traces, then selects the trace at `indices[0]`. Returns `None` if index is +out of bounds or tx not found. + +### `trace_filter` + +Semaphore-gated. Validates block range: +- `from_block` defaults to 0, `to_block` defaults to latest +- Both must be <= latest block +- `from_block` must be <= `to_block` +- Range must be <= `max_trace_filter_blocks` (default 100, configurable) + +Processes blocks sequentially. For each block, calls +`trace_block_localized()`, filters results with +`TraceFilter::matcher().matches(&trace.trace)`. Applies `after` (skip) and +`count` (limit) pagination. No reward traces. + +## Error Type + +`TraceError` in `trace/error.rs`: + +``` +Cold(ColdStorageError) — -32000 +Hot(StorageError) — -32000 +Resolve(ResolveError) — via resolve_error_code() +EvmHalt { reason: String } — -32000 +BlockNotFound(BlockId) — -32001 +TransactionNotFound(B256) — -32001 +RlpDecode(String) — -32602 +SenderRecovery — -32000 +BlockRangeExceeded — -32602 +``` + +Implements `IntoErrorPayload`. Reuses `resolve_error_code` and +`resolve_error_message` from `crate::eth::error`. Error messages are +sanitized — storage/DB errors return `"server error"`, no internals leaked. + +## Configuration + +New field in `StorageRpcConfig`: + +- `max_trace_filter_blocks: u64` — default 100 +- Env var: `SIGNET_RPC_MAX_TRACE_FILTER_BLOCKS` +- Builder setter: `max_trace_filter_blocks(u64)` + +## Router + +``` +router() + |- eth::eth() (41 methods) + |- debug::debug() (9 methods) + |- signet::signet() (2 methods) + |- web3::web3() (2 methods) + |- net::net() (2 methods) + +- trace::trace() (9 methods) +``` + +65 total routes. + +## Ordering + +This work depends on PR #120 (namespace completeness) which depends on +PR #119 (structured error codes). Branch off PR #120's head.