diff --git a/.changelog/cast-events.md b/.changelog/cast-events.md new file mode 100644 index 0000000000000..e519632086876 --- /dev/null +++ b/.changelog/cast-events.md @@ -0,0 +1,6 @@ +--- +cast: minor +foundry-evm-traces: minor +--- + +Added `cast events` to fetch and decode events from transaction receipts or filtered log queries. diff --git a/crates/cast/src/args.rs b/crates/cast/src/args.rs index 180c742be3af3..52e3d99eb4a2b 100644 --- a/crates/cast/src/args.rs +++ b/crates/cast/src/args.rs @@ -947,6 +947,7 @@ pub async fn run_command(args: CastArgs) -> Result<()> { generate(shell, &mut CastArgs::command(), "cast", &mut std::io::stdout()) } CastSubcommand::Logs(cmd) => cmd.run().await?, + CastSubcommand::Events(cmd) => cmd.run().await?, CastSubcommand::DecodeTransaction { tx, network } => { let tx = stdin::unwrap_line(tx)?; let decoded_tx = match network { diff --git a/crates/cast/src/cmd/events.rs b/crates/cast/src/cmd/events.rs new file mode 100644 index 0000000000000..9d5c45901e530 --- /dev/null +++ b/crates/cast/src/cmd/events.rs @@ -0,0 +1,357 @@ +use super::logs::LogQueryArgs; +use crate::{ + Cast, MAX_CONCURRENT_RPC_REQUESTS, + traces::{ + CallTraceDecoderBuilder, + identifier::{ExternalIdentifier, SignaturesIdentifier}, + }, +}; +use alloy_primitives::{Address, B256, Bytes, TxHash}; +use alloy_provider::Provider; +use alloy_rpc_types::Log; +use clap::{ArgGroup, Parser}; +use eyre::Result; +use foundry_cli::{ + json::print_json_object, + opts::{EtherscanOpts, RpcOpts}, + utils::{self, LoadConfig}, +}; +use foundry_common::shell; +use foundry_config::{Chain, Config}; +use futures::StreamExt; +use serde::Serialize; +use std::{collections::BTreeSet, fmt::Write as _}; + +foundry_config::impl_figment_convert!(EventsArgs, etherscan, rpc); + +/// CLI arguments for `cast events`. +#[derive(Debug, Parser)] +#[command(group( + ArgGroup::new("event_source") + .required(true) + .multiple(true) + .args(["tx_hash", "address", "from_block", "to_block", "sig_or_topic"]) +))] +pub struct EventsArgs { + /// Get events emitted by this transaction. + #[arg( + long, + alias = "txhash", + value_name = "TX_HASH", + conflicts_with_all = [ + "from_block", + "to_block", + "address", + "sig_or_topic", + "topics_or_args", + "query_size" + ] + )] + tx_hash: Option, + + #[command(flatten)] + query: LogQueryArgs, + + #[command(flatten)] + etherscan: EtherscanOpts, + + #[command(flatten)] + rpc: RpcOpts, +} + +impl EventsArgs { + pub async fn run(self) -> Result<()> { + let mut config = self.load_config()?; + let Self { tx_hash, mut query, etherscan: _, rpc: _ } = self; + let tx_hash = tx_hash.or_else(|| query.take_transaction_hash()); + let provider = utils::get_provider(&config)?; + let chain_id = provider.get_chain_id().await?; + let (rpc_chain, explorer_chain) = resolve_chains(config.chain, Chain::from(chain_id)); + config.chain = Some(rpc_chain); + + let cast = Cast::new(&provider); + let logs = if let Some(tx_hash) = tx_hash { + cast.get_transaction_logs(tx_hash).await? + } else { + let (filter, query_size) = query.resolve(&provider).await?; + match query_size { + Some(chunk_size) => cast.get_logs_chunked(&filter, chunk_size).await?, + None => cast.get_logs(&filter).await?, + } + }; + + let events = decode_logs(logs, &config, explorer_chain).await?; + if shell::is_json() { + print_json_object(events)?; + } else { + // Bypass the shell verbosity layer so `--quiet` does not suppress the primary result. + let mut shell = shell::Shell::get(); + let out = shell.out(); + write!(out, "{}", format_events(&events))?; + out.flush()?; + } + Ok(()) + } +} + +fn resolve_chains(configured_chain: Option, rpc_chain: Chain) -> (Chain, Chain) { + (rpc_chain, configured_chain.unwrap_or(rpc_chain)) +} + +async fn decode_logs( + logs: Vec, + config: &Config, + explorer_chain: Chain, +) -> Result> { + let signature_identifier = SignaturesIdentifier::from_config(config)?; + let mut builder = CallTraceDecoderBuilder::new() + .with_signature_identifier(signature_identifier) + .with_networks(config.networks) + .with_chain_id(config.chain.map(|chain| chain.id())); + + if let Some(mut identifier) = ExternalIdentifier::new(config, Some(explorer_chain))? { + let addresses = + logs.iter().map(Log::address).collect::>().into_iter().collect::>(); + for (address, result) in identifier.get_abis(&addresses).await { + match result { + Ok((abis, complete)) => { + if !complete { + sh_warn!("Only partially resolved proxy ABI chain for {address}")?; + } + for abi in abis { + builder = builder.with_address_events(address, &abi); + } + } + Err(err) => sh_warn!("Failed to fetch ABI for {address}: {err}")?, + } + } + } + + let decoder = builder.build(); + Ok(futures::stream::iter(logs) + .map(|log| async { + let decoded = + decoder.decode_event_with_address_signature(log.address(), log.data()).await; + EventOutput::new(log, decoded.name, decoded.params) + }) + .buffered(MAX_CONCURRENT_RPC_REQUESTS) + .collect() + .await) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct EventOutput { + address: Address, + block_hash: Option, + block_number: Option, + block_timestamp: Option, + transaction_hash: Option, + transaction_index: Option, + log_index: Option, + removed: bool, + event: Option, + params: Option>, + topics: Vec, + data: Bytes, +} + +impl EventOutput { + fn new(log: Log, event: Option, params: Option>) -> Self { + let params = params.map(|params| { + params + .into_iter() + .enumerate() + .map(|(index, (name, value))| EventParam { + name: if name.is_empty() { format!("param{index}") } else { name }, + value, + }) + .collect() + }); + Self { + address: log.address(), + block_hash: log.block_hash, + block_number: log.block_number, + block_timestamp: log.block_timestamp, + transaction_hash: log.transaction_hash, + transaction_index: log.transaction_index, + log_index: log.log_index, + removed: log.removed, + event, + params, + topics: log.topics().to_vec(), + data: log.data().data.clone(), + } + } +} + +#[derive(Debug, Serialize)] +struct EventParam { + name: String, + value: String, +} + +/// Formats decoded and raw events for human-readable output. +/// +/// # Example +/// +/// ```text +/// [block 1, tx 0xabc..., log 0] 0x123...::Transfer(address,uint256) { from: 0x456..., value: 1 } +/// 0x789... +/// topic 0: 0xdef... +/// data: 0x +/// ``` +fn format_events(events: &[EventOutput]) -> String { + let mut output = String::new(); + for event in events { + if event.block_number.is_some() + || event.transaction_hash.is_some() + || event.log_index.is_some() + { + output.push('['); + if let Some(block_number) = event.block_number { + let _ = write!(output, "block {block_number}"); + } + if let Some(transaction_hash) = event.transaction_hash { + if event.block_number.is_some() { + output.push_str(", "); + } + let _ = write!(output, "tx {transaction_hash}"); + } + if let Some(log_index) = event.log_index { + if event.block_number.is_some() || event.transaction_hash.is_some() { + output.push_str(", "); + } + let _ = write!(output, "log {log_index}"); + } + output.push_str("] "); + } + if let Some(name) = &event.event { + let _ = write!(output, "{}::{name}", event.address); + if let Some(params) = &event.params { + output.push_str(" { "); + for (index, param) in params.iter().enumerate() { + if index > 0 { + output.push_str(", "); + } + let _ = write!(output, "{}: {}", param.name, param.value); + } + output.push_str(" }"); + } + output.push('\n'); + } else { + let _ = writeln!(output, "{}", event.address); + for (index, topic) in event.topics.iter().enumerate() { + let _ = writeln!(output, " topic {index}: {topic}"); + } + let _ = writeln!(output, " data: {}", event.data); + } + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_event_sources() { + assert!(EventsArgs::try_parse_from(["events"]).is_err()); + assert!( + EventsArgs::try_parse_from(["events", "--tx-hash", &TxHash::ZERO.to_string()]).is_ok() + ); + let EventsArgs { tx_hash, mut query, .. } = + EventsArgs::try_parse_from(["events", &TxHash::ZERO.to_string()]).unwrap(); + assert_eq!(tx_hash.or_else(|| query.take_transaction_hash()), Some(TxHash::ZERO)); + let EventsArgs { mut query, .. } = EventsArgs::try_parse_from([ + "events", + &TxHash::ZERO.to_string(), + "--address", + &Address::ZERO.to_string(), + ]) + .unwrap(); + assert!(query.take_transaction_hash().is_none()); + assert!( + EventsArgs::try_parse_from([ + "events", + "--tx-hash", + &TxHash::ZERO.to_string(), + "--address", + &Address::ZERO.to_string(), + ]) + .is_err() + ); + assert!( + EventsArgs::try_parse_from([ + "events", + "--address", + &Address::ZERO.to_string(), + "--from-block", + "1", + "--to-block", + "2", + ]) + .is_ok() + ); + } + + #[test] + fn configured_chain_controls_explorer_lookup() { + let rpc_chain = Chain::from(31337); + let (decoder_chain, explorer_chain) = resolve_chains(Some(Chain::mainnet()), rpc_chain); + assert_eq!(decoder_chain, rpc_chain); + assert_eq!(explorer_chain, Chain::mainnet()); + + let (_, explorer_chain) = resolve_chains(None, rpc_chain); + assert_eq!(explorer_chain, rpc_chain); + } + + #[test] + fn formats_decoded_and_raw_events() { + let decoded = EventOutput { + address: Address::repeat_byte(0xaa), + block_hash: Some(B256::repeat_byte(0x33)), + block_number: Some(7), + block_timestamp: Some(123), + transaction_hash: Some(TxHash::repeat_byte(0xbb)), + transaction_index: Some(2), + log_index: Some(3), + removed: false, + event: Some("Transfer(address,address,uint256)".to_string()), + params: Some(vec![EventParam { name: "value".to_string(), value: "42".to_string() }]), + topics: vec![B256::repeat_byte(0x11)], + data: Bytes::from_static(&[0x22]), + }; + let raw = EventOutput { + address: Address::repeat_byte(0xbb), + block_hash: None, + block_number: None, + block_timestamp: None, + transaction_hash: None, + transaction_index: None, + log_index: None, + removed: false, + event: None, + params: None, + topics: vec![B256::repeat_byte(0x11)], + data: Bytes::from_static(&[0x22]), + }; + + let value = serde_json::to_value(&decoded).unwrap(); + assert_eq!(value["blockNumber"], 7); + assert_eq!(value["event"], "Transfer(address,address,uint256)"); + assert_eq!(value["params"][0]["name"], "value"); + assert_eq!(value["data"], "0x22"); + + assert_eq!( + format_events(&[decoded, raw]), + concat!( + "[block 7, tx 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb, log 3] ", + "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa::Transfer(address,address,uint256) { value: 42 }\n", + "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\n", + " topic 0: 0x1111111111111111111111111111111111111111111111111111111111111111\n", + " data: 0x22\n", + ) + ); + } +} diff --git a/crates/cast/src/cmd/logs.rs b/crates/cast/src/cmd/logs.rs index 5c0fde27c9106..836641beeb58e 100644 --- a/crates/cast/src/cmd/logs.rs +++ b/crates/cast/src/cmd/logs.rs @@ -2,8 +2,9 @@ use crate::Cast; use alloy_dyn_abi::{DynSolType, DynSolValue, Specifier}; use alloy_ens::NameOrAddress; use alloy_json_abi::Event; -use alloy_network::AnyNetwork; -use alloy_primitives::{Address, B256, hex::FromHex}; +use alloy_network::{AnyNetwork, Network}; +use alloy_primitives::{Address, B256, TxHash, hex::FromHex}; +use alloy_provider::Provider; use alloy_rpc_types::{BlockId, BlockNumberOrTag, Filter, FilterBlockOption, FilterSet, Topic}; use clap::Parser; use eyre::Result; @@ -17,6 +18,21 @@ use std::{io, str::FromStr}; /// CLI arguments for `cast logs`. #[derive(Debug, Parser)] pub struct LogsArgs { + #[command(flatten)] + query: LogQueryArgs, + + /// If the RPC type and endpoints supports `eth_subscribe` stream logs instead of printing and + /// exiting. Will continue until interrupted or TO_BLOCK is reached. + #[arg(long)] + subscribe: bool, + + #[command(flatten)] + rpc: RpcOpts, +} + +/// Arguments shared by commands that query logs with `eth_getLogs`. +#[derive(Debug, Parser)] +pub struct LogQueryArgs { /// The block height to start query at. /// /// Can also be the tags earliest, finalized, safe, latest, or pending. @@ -43,11 +59,6 @@ pub struct LogsArgs { #[arg(value_name = "TOPICS_OR_ARGS")] topics_or_args: Vec, - /// If the RPC type and endpoints supports `eth_subscribe` stream logs instead of printing and - /// exiting. Will continue until interrupted or TO_BLOCK is reached. - #[arg(long)] - subscribe: bool, - /// Split the query into chunks of this many blocks to work around provider range/result /// limits. /// @@ -55,45 +66,16 @@ pub struct LogsArgs { /// fetch the logs in `query-size`-block chunks instead. #[arg(long, value_name = "BLOCKS")] query_size: Option, - - #[command(flatten)] - rpc: RpcOpts, } impl LogsArgs { pub async fn run(self) -> Result<()> { - let Self { - from_block, - to_block, - address, - sig_or_topic, - topics_or_args, - subscribe, - query_size, - rpc, - } = self; + let Self { query, subscribe, rpc } = self; let config = rpc.load_config()?; let provider = utils::get_provider(&config)?; - + let (filter, query_size) = query.resolve(&provider).await?; let cast = Cast::new(&provider); - let addresses = match address { - Some(addresses) => Some( - futures::future::try_join_all(addresses.into_iter().map(|address| { - let provider = provider.clone(); - async move { address.resolve(&provider).await } - })) - .await?, - ), - None => None, - }; - - let from_block = - cast.convert_block_number(Some(from_block.unwrap_or_else(BlockId::earliest))).await?; - let to_block = - cast.convert_block_number(Some(to_block.unwrap_or_else(BlockId::latest))).await?; - - let filter = build_filter(from_block, to_block, addresses, sig_or_topic, topics_or_args)?; if !subscribe { let logs = match query_size { @@ -121,6 +103,52 @@ impl LogsArgs { } } +impl LogQueryArgs { + /// Takes a lone positional transaction hash, if present. + pub(super) fn take_transaction_hash(&mut self) -> Option { + if self.from_block.is_none() + && self.to_block.is_none() + && self.address.is_none() + && self.topics_or_args.is_empty() + && self.query_size.is_none() + && let Some(tx_hash) = self.sig_or_topic.as_deref().and_then(|value| value.parse().ok()) + { + self.sig_or_topic = None; + return Some(tx_hash); + } + None + } + + /// Resolves names and block tags and builds the RPC filter. + pub async fn resolve(self, provider: &P) -> Result<(Filter, Option)> + where + P: Provider + Clone + Unpin, + N: Network, + { + let Self { from_block, to_block, address, sig_or_topic, topics_or_args, query_size } = self; + + let cast = Cast::new(&provider); + let addresses = match address { + Some(addresses) => Some( + futures::future::try_join_all(addresses.into_iter().map(|address| { + let provider = provider.clone(); + async move { address.resolve(&provider).await } + })) + .await?, + ), + None => None, + }; + + let from_block = + cast.convert_block_number(Some(from_block.unwrap_or_else(BlockId::earliest))).await?; + let to_block = + cast.convert_block_number(Some(to_block.unwrap_or_else(BlockId::latest))).await?; + let filter = build_filter(from_block, to_block, addresses, sig_or_topic, topics_or_args)?; + + Ok((filter, query_size)) + } +} + /// Builds a Filter by first trying to parse the `sig_or_topic` as an event signature. If /// successful, `topics_or_args` is parsed as indexed inputs and converted to topics. Otherwise, /// `sig_or_topic` is prepended to `topics_or_args` and used as raw topics. diff --git a/crates/cast/src/cmd/mod.rs b/crates/cast/src/cmd/mod.rs index 335d5869ce9e7..e4fd210b0eb63 100644 --- a/crates/cast/src/cmd/mod.rs +++ b/crates/cast/src/cmd/mod.rs @@ -34,6 +34,7 @@ pub mod creation_code; pub mod da_estimate; pub mod erc20; pub mod estimate; +pub mod events; pub mod find_block; pub mod interface; pub mod keychain; diff --git a/crates/cast/src/lib.rs b/crates/cast/src/lib.rs index 9cb47193b64cf..5246f30a03ea2 100644 --- a/crates/cast/src/lib.rs +++ b/crates/cast/src/lib.rs @@ -629,10 +629,15 @@ impl + Clone + Unpin, N: Network> Cast { } pub async fn filter_logs(&self, filter: Filter) -> Result { - let logs = self.provider.get_logs(&filter).await?; + let logs = self.get_logs(&filter).await?; Self::format_logs(logs) } + /// Retrieves logs matching the filter. + pub async fn get_logs(&self, filter: &Filter) -> Result> { + self.provider.get_logs(filter).await.map_err(Into::into) + } + /// Retrieves logs using chunked requests to handle large block ranges. /// /// Automatically divides large block ranges into smaller chunks to avoid provider limits @@ -703,7 +708,7 @@ impl + Clone + Unpin, N: Network> Cast { } /// Retrieves logs, splitting the request into fixed-size block chunks when needed. - async fn get_logs_chunked(&self, filter: &Filter, chunk_size: u64) -> Result> + pub async fn get_logs_chunked(&self, filter: &Filter, chunk_size: u64) -> Result> where P: Clone + Unpin, { @@ -942,6 +947,20 @@ impl + Clone + Unpin, N: Network> Cast { } } +impl + Clone + Unpin> Cast { + /// Retrieves all logs from a transaction receipt. + pub async fn get_transaction_logs(&self, tx_hash: TxHash) -> Result> { + Ok(self + .provider + .get_transaction_receipt(tx_hash) + .await? + .ok_or_else(|| eyre::eyre!("tx receipt not found: {tx_hash}"))? + .inner + .logs() + .to_vec()) + } +} + /// Returns `true` if `err` is a provider range/result-size limit that retrying over a smaller /// range can fix. Network, auth, rate-limit, and malformed-response errors return `false`. fn is_range_limit_error(err: &RpcError) -> bool { diff --git a/crates/cast/src/opts.rs b/crates/cast/src/opts.rs index 9a5b0c6aa7a4e..28bdfbdab9b4d 100644 --- a/crates/cast/src/opts.rs +++ b/crates/cast/src/opts.rs @@ -14,6 +14,7 @@ use crate::cmd::{ creation_code::CreationCodeArgs, erc20::Erc20Subcommand, estimate::EstimateArgs, + events::EventsArgs, find_block::FindBlockArgs, interface::InterfaceArgs, keychain::{KeyAuthorizationSubcommand, KeychainSubcommand}, @@ -415,6 +416,18 @@ pub enum CastSubcommand { /// - cast logs --address $TOKEN --from-block 21000000 --to-block latest $TOPIC_0 #[command(verbatim_doc_comment, visible_alias = "l")] Logs(LogsArgs), + /// Fetch and decode events from a transaction receipt or log filter. + /// + /// Examples: + /// - cast events $TX_HASH + /// - cast events --tx-hash $TX_HASH + /// - cast events --address $TOKEN --from-block 21000000 --to-block latest + /// - cast events --address $TOKEN "Transfer(address indexed,address indexed,uint256)" + /// + /// A lone 32-byte positional value is treated as a transaction hash. Qualify a raw topic with + /// an address, block range, additional topic, or query size. + #[command(verbatim_doc_comment, visible_alias = "ev")] + Events(EventsArgs), /// Get information about a block /// /// Examples: diff --git a/crates/cast/tests/cli/main.rs b/crates/cast/tests/cli/main.rs index aa7f7cddaa110..731cacbcf8607 100644 --- a/crates/cast/tests/cli/main.rs +++ b/crates/cast/tests/cli/main.rs @@ -3500,6 +3500,74 @@ casttest!(logs_chunked, |_prj, cmd| { assert!(chunked.contains("12454418"), "missing log from the last chunk"); }); +forgetest_async!(events_quiet_preserves_output, |prj, cmd| { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + prj.add_source( + "EventEmitter", + r#" +contract EventEmitter { + event Transfer(address indexed from, address indexed to, uint256 value); + + function emitTransfer() external { + emit Transfer(msg.sender, address(this), 42); + } +} +"#, + ); + cmd.forge_fuse().args(["build"]).assert_success(); + + let artifact = prj.root().join("out/EventEmitter.sol/EventEmitter.json"); + let contract: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(artifact).unwrap()).unwrap(); + let bytecode = contract["bytecode"]["object"].as_str().unwrap(); + let deployment = cmd + .cast_fuse() + .args([ + "send", + "--json", + "--private-key", + private_key, + "--rpc-url", + &endpoint, + "--create", + bytecode, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let deployment: serde_json::Value = serde_json::from_str(&deployment).unwrap(); + let address = deployment["contractAddress"].as_str().unwrap(); + + let receipt = cmd + .cast_fuse() + .args([ + "send", + "--json", + "--private-key", + private_key, + "--rpc-url", + &endpoint, + address, + "emitTransfer()", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let receipt: serde_json::Value = serde_json::from_str(&receipt).unwrap(); + let tx_hash = receipt["transactionHash"].as_str().unwrap(); + + cmd.cast_fuse() + .args(["--quiet", "events", tx_hash, "--rpc-url", &endpoint]) + .assert_success() + .stdout_eq(str![[r#" +[block 2, tx 0x[..], log 0] 0x5FbDB2315678afecb367f032d93F642f64180aa3::Transfer(address,address,uint256) { from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, to: 0x5FbDB2315678afecb367f032d93F642f64180aa3, amount: 42 } + +"#]]); +}); + // tests that `cast create2` writes `address\tsalt` to stdout and prose to stderr casttest!(create2_output_channels, |_prj, cmd| { cmd.args([ diff --git a/crates/evm/traces/src/decoder/mod.rs b/crates/evm/traces/src/decoder/mod.rs index 51f19dc2ddde9..d5daed2a992a0 100644 --- a/crates/evm/traces/src/decoder/mod.rs +++ b/crates/evm/traces/src/decoder/mod.rs @@ -11,8 +11,8 @@ use alloy_primitives::{ }; use alloy_sol_types::SolValue; use foundry_common::{ - ContractsByArtifact, SELECTOR_LEN, abi::get_indexed_event, fmt::format_token, - get_contract_name, selectors::SelectorKind, + ContractsByArtifact, SELECTOR_LEN, fmt::format_token, get_contract_name, + selectors::SelectorKind, }; use foundry_config::TracingConfig; #[cfg(feature = "monad")] @@ -63,6 +63,7 @@ use monad::{IMonadStaking, IMonadStakingSyscalls, IReserveBalance}; #[cfg(not(feature = "monad"))] type MonadHardfork = (); type AddressEvents = HashMap>>; +type AddressAnonymousEvents = HashMap>>; /// Build a new [CallTraceDecoder]. #[derive(Default)] @@ -92,6 +93,15 @@ impl CallTraceDecoderBuilder { self } + /// Add events from an ABI for a specific contract address. + #[inline] + pub fn with_address_events(mut self, address: Address, abi: &JsonAbi) -> Self { + for event in abi.events() { + self.decoder.push_address_event(address, event.clone()); + } + self + } + /// Add known contracts to the decoder. #[inline] pub fn with_known_contracts(mut self, contracts: &ContractsByArtifact) -> Self { @@ -240,6 +250,8 @@ pub struct CallTraceDecoder { pub events: BTreeMap<(B256, usize), Vec>, /// Events identified for a specific contract address. events_by_address: Option>, + /// Anonymous events identified for a specific contract address, keyed by topic count. + anonymous_events_by_address: Option>, /// Revert decoder. Contains all known custom errors. pub revert_decoder: RevertDecoder, @@ -461,6 +473,7 @@ impl CallTraceDecoder { constructor_args_offsets: Default::default(), events, events_by_address: None, + anonymous_events_by_address: None, // Decode Tempo precompile custom errors by name in traces. revert_decoder: RevertDecoder::new().with_abis(tempo_abis.iter()), @@ -498,6 +511,7 @@ impl CallTraceDecoder { self.non_fallback_contracts.clear(); self.functions_by_address.clear(); self.events_by_address = None; + self.anonymous_events_by_address = None; self.constructors_by_address.clear(); self.constructor_args_offsets.clear(); @@ -576,6 +590,19 @@ impl CallTraceDecoder { /// Adds a single event to the decoder for a specific contract address. pub fn push_address_event(&mut self, address: Address, event: Event) { + if event.anonymous { + let events = self + .anonymous_events_by_address + .get_or_insert_with(Default::default) + .entry(address) + .or_default() + .entry(indexed_inputs(&event)) + .or_default(); + if !events.contains(&event) { + events.push(event); + } + return; + } let events = self .events_by_address .get_or_insert_with(Default::default) @@ -1346,7 +1373,7 @@ impl CallTraceDecoder { /// Decodes an event. pub async fn decode_event(&self, log: &LogData) -> DecodedCallLog { - self.decode_event_inner(None, log).await + self.decode_event_inner(None, log, false).await } /// Decodes an event emitted by a known address. @@ -1355,33 +1382,88 @@ impl CallTraceDecoder { address: Address, log: &LogData, ) -> DecodedCallLog { - self.decode_event_inner(Some(address), log).await - } - - async fn decode_event_inner(&self, address: Option
, log: &LogData) -> DecodedCallLog { - let &[t0, ..] = log.topics() else { return DecodedCallLog { name: None, params: None } }; - - let mut events = Vec::new(); - let key = (t0, log.topics().len() - 1); - let address_events = address - .and_then(|address| self.events_by_address.as_deref()?.get(&address)) - .and_then(|events| events.get(&key)); - let events = match address_events.or_else(|| self.events.get(&key)) { - Some(es) => es, - None => { - if let Some(identifier) = &self.signature_identifier - && let Some(event) = identifier.identify_event(t0).await - { - events.push(get_indexed_event(event, log)); - } - &events - } + self.decode_event_inner(Some(address), log, false).await + } + + /// Decodes an event emitted by a known address, using its canonical signature as the name. + pub async fn decode_event_with_address_signature( + &self, + address: Address, + log: &LogData, + ) -> DecodedCallLog { + self.decode_event_inner(Some(address), log, true).await + } + + async fn decode_event_inner( + &self, + address: Option
, + log: &LogData, + canonical_signature: bool, + ) -> DecodedCallLog { + let regular_events = log.topics().first().and_then(|&topic| { + let key = (topic, log.topics().len() - 1); + address + .and_then(|address| self.events_by_address.as_deref()?.get(&address)) + .and_then(|events| events.get(&key)) + .or_else(|| self.events.get(&key)) + }); + let anonymous_events = address + .and_then(|address| self.anonymous_events_by_address.as_deref()?.get(&address)) + .and_then(|events| events.get(&log.topics().len())); + + let decoded = if canonical_signature || anonymous_events.is_some() { + self.decode_unique_event_candidates( + address, + log, + regular_events.into_iter().flatten().chain(anonymous_events.into_iter().flatten()), + canonical_signature, + ) + } else { + self.decode_event_candidates(address, log, regular_events.into_iter().flatten(), false) }; + if let Some(decoded) = decoded { + return decoded; + } + + if regular_events.is_some() || anonymous_events.is_some() { + return DecodedCallLog { name: None, params: None }; + } + + if let Some(&topic) = log.topics().first() + && let Some(identifier) = &self.signature_identifier + && let Some(event) = identifier.identify_event(topic).await + { + let mut decoded = indexed_event_candidates(event, log).filter_map(|event| { + self.decode_event_candidates(address, log, [&event], canonical_signature) + }); + let event = decoded.next(); + if event.is_some() && decoded.next().is_some() { + return DecodedCallLog { name: None, params: None }; + } + if let Some(decoded) = event { + return decoded; + } + } + + DecodedCallLog { name: None, params: None } + } + + fn decode_event_candidates<'a>( + &self, + address: Option
, + log: &LogData, + events: impl IntoIterator, + canonical_signature: bool, + ) -> Option { for event in events { if let Ok(decoded) = event.decode_log(log) { let params = reconstruct_params(event, &decoded); - return DecodedCallLog { - name: Some(event.name.clone()), + return Some(DecodedCallLog { + name: Some(if canonical_signature { + event.signature() + } else { + event.name.clone() + }), params: Some( params .into_iter() @@ -1402,11 +1484,24 @@ impl CallTraceDecoder { }) .collect(), ), - }; + }); } } + None + } - DecodedCallLog { name: None, params: None } + fn decode_unique_event_candidates<'a>( + &self, + address: Option
, + log: &LogData, + events: impl IntoIterator, + canonical_signature: bool, + ) -> Option { + let mut decoded = events.into_iter().filter_map(|event| { + self.decode_event_candidates(address, log, [event], canonical_signature) + }); + let event = decoded.next()?; + decoded.next().is_none().then_some(event) } /// Prefetches function and event signatures into the identifier cache @@ -1598,6 +1693,22 @@ fn indexed_inputs(event: &Event) -> usize { event.inputs.iter().filter(|param| param.indexed).count() } +fn indexed_event_candidates(mut event: Event, log: &LogData) -> impl Iterator { + for (index, input) in event.inputs.iter_mut().enumerate() { + if input.name.is_empty() { + input.name = format!("param{index}"); + } + input.indexed = false; + } + (0..event.inputs.len()).combinations(log.topics().len() - 1).map(move |indexed| { + let mut event = event.clone(); + for index in indexed { + event.inputs[index].indexed = true; + } + event + }) +} + fn constructor_signature(constructor: &Constructor) -> String { format!( "constructor({})", @@ -1809,6 +1920,114 @@ mod tests { assert_eq!(decoded.params.unwrap()[0].0, "val"); } + #[tokio::test] + async fn address_scoped_abis_decode_all_registered_events() { + let address = Address::from([0x12; 20]); + let proxy = JsonAbi::parse(["event ProxyEvent()"]).unwrap(); + let implementation = JsonAbi::parse(["event ImplementationEvent()"]).unwrap(); + let decoder = CallTraceDecoderBuilder::new() + .with_address_events(address, &implementation) + .with_address_events(address, &proxy) + .build(); + + for event in proxy.events().chain(implementation.events()) { + let log = LogData::new_unchecked(vec![event.selector()], Default::default()); + let decoded = decoder.decode_event_with_address(address, &log).await; + assert_eq!(decoded.name.as_deref(), Some(event.name.as_str())); + assert!(decoder.decode_event_with_address(Address::ZERO, &log).await.name.is_none()); + } + } + + #[tokio::test] + async fn canonical_address_events_require_a_unique_match() { + let address = Address::from([0x12; 20]); + let implementation = + JsonAbi::parse(["event Value(address indexed who, uint256 amount)"]).unwrap(); + let proxy = JsonAbi::parse(["event Value(address who, uint256 indexed amount)"]).unwrap(); + let event = proxy.events().next().unwrap(); + let log = LogData::new_unchecked( + vec![event.selector(), U256::from(42).into()], + (Address::from([0x34; 20]),).abi_encode().into(), + ); + let decoder = CallTraceDecoderBuilder::new() + .with_address_events(address, &implementation) + .with_address_events(address, &proxy) + .build(); + + let decoded = decoder.decode_event_with_address_signature(address, &log).await; + assert!(decoded.name.is_none()); + assert!(decoded.params.is_none()); + } + + #[test] + fn identified_events_require_a_unique_indexed_placement() { + let event = Event::parse("event Ambiguous(address owner, uint256 id)").unwrap(); + let log = LogData::new_unchecked( + vec![event.selector(), U256::from(42).into()], + (Address::from([0x34; 20]),).abi_encode().into(), + ); + let candidates = indexed_event_candidates(event, &log).collect::>(); + let decoder = CallTraceDecoder::new(); + + let decoded = decoder.decode_unique_event_candidates(None, &log, &candidates, false); + + assert!(decoded.is_none()); + } + + #[tokio::test] + async fn address_scoped_anonymous_events_require_a_unique_match() { + let address = Address::from([0x12; 20]); + let abi = JsonAbi::parse(["event AnonymousValue(uint256 value) anonymous"]).unwrap(); + let log = LogData::new_unchecked(Vec::new(), (U256::from(7),).abi_encode().into()); + let decoded = CallTraceDecoderBuilder::new() + .with_address_events(address, &abi) + .build() + .decode_event_with_address(address, &log) + .await; + assert_eq!(decoded.name.as_deref(), Some("AnonymousValue")); + + let abi = JsonAbi::parse([ + "event AnonymousValue(uint256 value) anonymous", + "event AnonymousAmount(uint256 amount) anonymous", + ]) + .unwrap(); + let decoder = CallTraceDecoderBuilder::new().with_address_events(address, &abi).build(); + + let decoded = decoder.decode_event_with_address(address, &log).await; + + assert!(decoded.name.is_none()); + assert!(decoded.params.is_none()); + + let abi = JsonAbi::parse([ + "event RegularValue(uint256 value)", + "event AnonymousValue(uint256 indexed value) anonymous", + ]) + .unwrap(); + let regular = abi.events().find(|event| !event.anonymous).unwrap(); + let log = LogData::new_unchecked(vec![regular.selector()], Default::default()); + let decoded = CallTraceDecoderBuilder::new() + .with_address_events(address, &abi) + .build() + .decode_event_with_address_signature(address, &log) + .await; + assert_eq!(decoded.name.as_deref(), Some("AnonymousValue(uint256)")); + + let abi = JsonAbi::parse([ + "event RegularValue()", + "event AnonymousValue(bytes32 indexed value) anonymous", + ]) + .unwrap(); + let regular = abi.events().find(|event| !event.anonymous).unwrap(); + let log = LogData::new_unchecked(vec![regular.selector()], Default::default()); + let decoder = CallTraceDecoderBuilder::new().with_address_events(address, &abi).build(); + let decoded = decoder.decode_event_with_address_signature(address, &log).await; + assert!(decoded.name.is_none()); + assert!(decoded.params.is_none()); + let decoded = decoder.decode_event_with_address(address, &log).await; + assert!(decoded.name.is_none()); + assert!(decoded.params.is_none()); + } + #[test] fn compact_labels_hide_address_in_trace_parameters() { let address = address!("0x0000000000000000000000000000000000000001"); diff --git a/crates/evm/traces/src/identifier/external.rs b/crates/evm/traces/src/identifier/external.rs index 790de33f4270e..36263781e3e21 100644 --- a/crates/evm/traces/src/identifier/external.rs +++ b/crates/evm/traces/src/identifier/external.rs @@ -1,5 +1,6 @@ use super::{IdentifiedAddress, TraceIdentifier}; use crate::debug::ContractSources; +use alloy_json_abi::JsonAbi; use alloy_primitives::{ Address, map::{Entry, HashMap, HashSet}, @@ -208,6 +209,84 @@ impl ExternalIdentifier { warn!(target: "evm::traces::external", "external identification timed out; disabling it for the remainder of this session"); } } + + /// Fetches all verified ABIs and whether each proxy chain was fully resolved. + pub async fn get_abis( + &mut self, + addresses: &[Address], + ) -> Vec<(Address, eyre::Result<(Vec, bool)>)> { + const MAX_PROXY_DEPTH: usize = 16; + + struct Chain { + current: Option
, + visited: HashSet
, + abis: Vec, + complete: bool, + } + + let mut chains = addresses + .iter() + .map(|&address| Chain { + current: Some(address), + visited: HashSet::default(), + abis: Vec::new(), + complete: true, + }) + .collect::>(); + + for _ in 0..MAX_PROXY_DEPTH { + let to_fetch = chains + .iter() + .filter_map(|chain| chain.current) + .filter(|address| !self.contracts.contains_key(address)) + .collect::>() + .into_iter() + .collect::>(); + self.fetch_addresses_async(&to_fetch).await; + + let mut has_next = false; + for chain in &mut chains { + let Some(current) = chain.current else { continue }; + if !chain.visited.insert(current) { + chain.current = None; + chain.complete = false; + continue; + } + let Some((_, Some(metadata))) = self.contracts.get(¤t) else { + chain.current = None; + chain.complete = false; + continue; + }; + if let Ok(abi) = metadata.abi() { + chain.abis.push(abi); + } else { + chain.complete = false; + } + chain.current = (metadata.proxy != 0).then_some(metadata.implementation).flatten(); + if metadata.proxy != 0 && chain.current.is_none() { + chain.complete = false; + } + has_next |= chain.current.is_some(); + } + if !has_next { + break; + } + } + + chains + .into_iter() + .zip(addresses.iter().copied()) + .map(|(mut chain, address)| { + chain.complete &= chain.current.is_none(); + let result = if chain.abis.is_empty() { + Err(eyre::eyre!("external ABI lookup failed")) + } else { + Ok((chain.abis.into_iter().rev().collect(), chain.complete)) + }; + (address, result) + }) + .collect() + } } impl TraceIdentifier for ExternalIdentifier { @@ -841,4 +920,41 @@ mod tests { "EtherscanResult" ); } + + #[tokio::test] + async fn proxy_metadata_preserves_address_identity_and_all_abis() { + let proxy = Address::with_last_byte(1); + let implementation_address = Address::with_last_byte(2); + let mut proxy_metadata = metadata("Proxy"); + proxy_metadata.abi = + r#"[{"anonymous":false,"inputs":[],"name":"ProxyEvent","type":"event"}]"#.to_string(); + proxy_metadata.proxy = 1; + proxy_metadata.implementation = Some(implementation_address); + let mut implementation = metadata("Implementation"); + implementation.abi = + r#"[{"anonymous":false,"inputs":[],"name":"ImplementationEvent","type":"event"}]"# + .to_string(); + let mut identifier = test_identifier(Vec::new(), Duration::from_secs(1)); + let identity = identifier.identify_from_metadata(proxy, &proxy_metadata); + assert_eq!(identity.contract.as_deref(), Some("Proxy")); + identifier.cache_fetched(proxy, (FetcherKind::Etherscan, Some(proxy_metadata))); + identifier + .cache_fetched(implementation_address, (FetcherKind::Etherscan, Some(implementation))); + + let mut results = identifier.get_abis(&[proxy]).await; + let (result_address, result) = results.pop().unwrap(); + let (abis, complete) = result.unwrap(); + let event_names = + abis.into_iter().map(|abi| abi.events.into_keys().next().unwrap()).collect::>(); + + assert_eq!(result_address, proxy); + assert!(complete); + assert_eq!(event_names, ["ImplementationEvent", "ProxyEvent"]); + + identifier.contracts.remove(&implementation_address); + let (_, result) = identifier.get_abis(&[proxy]).await.pop().unwrap(); + let (abis, complete) = result.unwrap(); + assert_eq!(abis.len(), 1); + assert!(!complete); + } } diff --git a/crates/evm/traces/src/identifier/signatures.rs b/crates/evm/traces/src/identifier/signatures.rs index 9e0f7a33e89e2..e5093dee9da5a 100644 --- a/crates/evm/traces/src/identifier/signatures.rs +++ b/crates/evm/traces/src/identifier/signatures.rs @@ -341,20 +341,25 @@ impl SignaturesIdentifier { trace!(target: "evm::traces", ?selectors, "identifying selectors"); let mut cache_r = self.0.cache.read().await; - if let Some(client) = &self.0.client { - let query = - selectors.iter().copied().filter(|v| !cache_r.contains_key(v)).collect::>(); - if !query.is_empty() { - drop(cache_r); - let mut cache_w = self.0.cache.write().await; - if let Ok(res) = client.decode_selectors(&query).await { - for (selector, signatures) in std::iter::zip(query, res) { - cache_w.signatures.insert(selector, signatures.into_iter().next()); - } + if let Some(client) = &self.0.client + && selectors.iter().any(|selector| !cache_r.contains_key(selector)) + { + drop(cache_r); + let mut cache_w = self.0.cache.write().await; + let query = selectors + .iter() + .copied() + .filter(|selector| !cache_w.contains_key(selector)) + .collect::>(); + if !query.is_empty() + && let Ok(res) = client.decode_selectors(&query).await + { + for (selector, signatures) in std::iter::zip(query, res) { + cache_w.signatures.insert(selector, signatures.into_iter().next()); } - drop(cache_w); - cache_r = self.0.cache.read().await; } + drop(cache_w); + cache_r = self.0.cache.read().await; } selectors.iter().map(|selector| cache_r.get(selector).unwrap_or_default()).collect() }