From c32a06341b2a5f29981c96de3f1580b3a98af0c5 Mon Sep 17 00:00:00 2001 From: steven Date: Tue, 18 Aug 2026 12:32:43 -0600 Subject: [PATCH 01/11] feat(cast): add events command --- .changelog/cast-events.md | 7 + crates/cast/src/args.rs | 1 + crates/cast/src/cmd/events.rs | 533 +++++++++++++++++++++++++++ crates/cast/src/cmd/logs.rs | 63 ++-- crates/cast/src/cmd/mod.rs | 1 + crates/cast/src/lib.rs | 9 +- crates/cast/src/opts.rs | 9 + crates/cast/tests/cli/main.rs | 68 ++++ crates/common/src/abi.rs | 2 +- crates/evm/traces/src/decoder/mod.rs | 16 +- 10 files changed, 679 insertions(+), 30 deletions(-) create mode 100644 .changelog/cast-events.md create mode 100644 crates/cast/src/cmd/events.rs diff --git a/.changelog/cast-events.md b/.changelog/cast-events.md new file mode 100644 index 0000000000000..17fe5d090341e --- /dev/null +++ b/.changelog/cast-events.md @@ -0,0 +1,7 @@ +--- +cast: minor +foundry-common: patch +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..23827bd296975 --- /dev/null +++ b/crates/cast/src/cmd/events.rs @@ -0,0 +1,533 @@ +use super::logs::LogQueryArgs; +use crate::{ + Cast, + traces::{CallTraceDecoderBuilder, identifier::SignaturesIdentifier}, +}; +use alloy_network::AnyNetwork; +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::{abi::find_source, shell}; +use foundry_config::{Chain, Config}; +use futures::{StreamExt, stream}; +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, query, etherscan: _, rpc: _ } = self; + 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 logs = fetch_logs(&provider, tx_hash, query).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 fetch_logs

( + provider: &P, + tx_hash: Option, + query: LogQueryArgs, +) -> Result> +where + P: Provider + Clone + Unpin, +{ + if let Some(tx_hash) = tx_hash { + return Ok(provider + .get_transaction_receipt(tx_hash) + .await? + .ok_or_else(|| eyre::eyre!("tx receipt not found: {tx_hash}"))? + .inner + .logs() + .to_vec()); + } + + let (filter, query_size) = query.resolve(provider).await?; + let cast = Cast::new(provider); + match query_size { + Some(chunk_size) => cast.get_logs_chunked(&filter, chunk_size).await, + None => cast.get_logs(&filter).await, + } +} + +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 !config.offline && config.get_etherscan_config_with_chain(Some(explorer_chain))?.is_some() { + let addresses = logs.iter().map(Log::address).collect::>(); + let abis = stream::iter(addresses) + .map(|address| async move { + let result = async { + let client = config + .get_etherscan_config_with_chain(Some(explorer_chain))? + .ok_or_else(|| { + eyre::eyre!( + "No Etherscan API key configured for chain {explorer_chain}" + ) + })? + .into_client_with_no_proxy(config.eth_rpc_no_proxy)?; + let source = find_source(client, address).await?; + source + .items + .into_iter() + .map(|item| item.abi().map_err(Into::into)) + .collect::>>() + } + .await; + (address, result) + }) + .buffer_unordered(5) + .collect::>() + .await; + for (address, abis) in abis { + if let Ok(abis) = abis { + for abi in abis { + builder = builder.with_address_abi(address, &abi); + } + } + } + } + + let decoder = builder.build(); + let mut events = Vec::with_capacity(logs.len()); + for log in logs { + let decoded = decoder.decode_event_with_address(log.address(), log.data()).await; + events.push(EventOutput::new(log, decoded.name, decoded.params)); + } + Ok(events) +} + +#[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, +} + +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 { + for (index, param) in params.iter().enumerate() { + if index > 0 { + output.push_str(", "); + } + let _ = write!(output, "{}: {}", param.name, param.value); + } + } + output.push_str(")\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::*; + use alloy_json_abi::{Event, JsonAbi}; + use alloy_primitives::{LogData, U256}; + use alloy_provider::{ProviderBuilder, mock::Asserter}; + use alloy_sol_types::SolValue; + + #[test] + fn requires_event_source() { + assert!(EventsArgs::try_parse_from(["events"]).is_err()); + } + + #[test] + fn transaction_and_filter_modes_conflict() { + assert!( + EventsArgs::try_parse_from([ + "events", + "--tx-hash", + &TxHash::ZERO.to_string(), + "--address", + &Address::ZERO.to_string(), + ]) + .is_err() + ); + } + + #[test] + fn accepts_transaction_and_filter_modes_separately() { + assert!( + EventsArgs::try_parse_from(["events", "--tx-hash", &TxHash::ZERO.to_string()]).is_ok() + ); + assert!( + EventsArgs::try_parse_from(["events", "--txhash", &TxHash::ZERO.to_string()]).is_ok() + ); + 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); + } + + #[tokio::test] + async fn fetches_receipt_logs_and_reports_missing_receipts() { + let tx_hash = TxHash::repeat_byte(0x44); + let log_address = Address::repeat_byte(0xaa); + let receipt = serde_json::json!({ + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x5208", + "logs": [{ + "address": log_address, + "topics": [], + "data": "0x", + "blockNumber": "0x7", + "transactionHash": tx_hash, + "transactionIndex": "0x0", + "blockHash": B256::repeat_byte(0x33), + "logIndex": "0x3", + "removed": false + }], + "transactionHash": tx_hash, + "transactionIndex": "0x0", + "blockHash": B256::repeat_byte(0x33), + "blockNumber": "0x7", + "logsBloom": format!("0x{}", "0".repeat(512)), + "gasUsed": "0x5208", + "effectiveGasPrice": "0x1", + "from": Address::ZERO, + "to": Address::ZERO, + "contractAddress": null + }); + let asserter = Asserter::new(); + asserter.push_success(&receipt); + let provider = + ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(asserter.clone()); + let EventsArgs { query, .. } = + EventsArgs::try_parse_from(["events", "--tx-hash", &tx_hash.to_string()]).unwrap(); + + let logs = fetch_logs(&provider, Some(tx_hash), query).await.unwrap(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].address(), log_address); + assert_eq!(logs[0].block_number, Some(7)); + assert_eq!(logs[0].log_index, Some(3)); + + let missing: Option = None; + asserter.push_success(&missing); + let EventsArgs { query, .. } = + EventsArgs::try_parse_from(["events", "--tx-hash", &tx_hash.to_string()]).unwrap(); + let err = fetch_logs(&provider, Some(tx_hash), query).await.unwrap_err(); + assert!(err.to_string().contains("tx receipt not found")); + } + + #[tokio::test] + async fn fetches_filtered_logs_in_chunk_order() { + let asserter = Asserter::new(); + asserter + .push_success(&vec![Log:: { block_number: Some(1), ..Default::default() }]); + asserter + .push_success(&vec![Log:: { block_number: Some(2), ..Default::default() }]); + let provider = + ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(asserter); + let EventsArgs { query, .. } = EventsArgs::try_parse_from([ + "events", + "--from-block", + "1", + "--to-block", + "2", + "--query-size", + "1", + ]) + .unwrap(); + + let logs = fetch_logs(&provider, None, query).await.unwrap(); + assert_eq!(logs.iter().map(|log| log.block_number).collect::>(), [Some(1), Some(2)]); + } + + #[tokio::test] + async fn decodes_known_event_and_preserves_metadata() { + let event = Event::parse( + "event WidgetMoved(address indexed from, address indexed to, uint256 value)", + ) + .unwrap(); + let from = Address::repeat_byte(0x11); + let to = Address::repeat_byte(0x22); + let data = LogData::new_unchecked( + vec![event.selector(), from.into_word(), to.into_word()], + (U256::from(42),).abi_encode().into(), + ); + let log = Log { + inner: alloy_primitives::Log { address: Address::repeat_byte(0xaa), data }, + 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: true, + }; + let signature = event.full_signature(); + let abi = JsonAbi::parse([signature.as_str()]).unwrap(); + let decoder = CallTraceDecoderBuilder::new().with_address_abi(log.address(), &abi).build(); + let decoded = decoder.decode_event_with_address(log.address(), log.data()).await; + let output = EventOutput::new(log, decoded.name, decoded.params); + + assert_eq!(output.event.as_deref(), Some("WidgetMoved")); + assert_eq!(output.params.as_ref().unwrap().len(), 3); + assert_eq!(output.block_hash, Some(B256::repeat_byte(0x33))); + assert_eq!(output.block_number, Some(7)); + assert_eq!(output.block_timestamp, Some(123)); + assert_eq!(output.transaction_hash, Some(TxHash::repeat_byte(0xbb))); + assert_eq!(output.transaction_index, Some(2)); + assert_eq!(output.log_index, Some(3)); + assert!(output.removed); + } + + #[tokio::test] + async fn unknown_event_falls_back_to_raw_log() { + let topic = B256::repeat_byte(0x11); + let log = Log { + inner: alloy_primitives::Log { + address: Address::repeat_byte(0xaa), + data: LogData::new_unchecked(vec![topic], Bytes::from_static(&[1, 2, 3])), + }, + ..Default::default() + }; + let decoded = CallTraceDecoderBuilder::new() + .build() + .decode_event_with_address(log.address(), log.data()) + .await; + let output = EventOutput::new(log, decoded.name, decoded.params); + + assert!(output.event.is_none()); + assert_eq!(output.topics, vec![topic]); + assert_eq!(output.data, Bytes::from_static(&[1, 2, 3])); + } + + #[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: None, + log_index: Some(3), + removed: false, + event: Some("Transfer".to_string()), + params: Some(vec![EventParam { name: "value".to_string(), value: "42".to_string() }]), + topics: vec![], + data: Bytes::new(), + }; + 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]), + }; + + assert_eq!( + format_events(&[decoded, raw]), + concat!( + "[block 7, tx 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb, log 3] ", + "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa::Transfer(value: 42)\n", + "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\n", + " topic 0: 0x1111111111111111111111111111111111111111111111111111111111111111\n", + " data: 0x22\n", + ) + ); + } + + #[test] + fn serializes_structured_json_output() { + let event = 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".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 value = serde_json::to_value(event).unwrap(); + assert_eq!(value["blockHash"], B256::repeat_byte(0x33).to_string()); + assert_eq!(value["blockNumber"], 7); + assert_eq!(value["blockTimestamp"], 123); + assert_eq!(value["event"], "Transfer"); + assert_eq!(value["params"][0]["name"], "value"); + assert_eq!(value["topics"][0], B256::repeat_byte(0x11).to_string()); + assert_eq!(value["data"], "0x22"); + } +} diff --git a/crates/cast/src/cmd/logs.rs b/crates/cast/src/cmd/logs.rs index 5c0fde27c9106..a0d370b4cc674 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_network::{AnyNetwork, Network}; use alloy_primitives::{Address, B256, 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,26 +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 config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; +impl LogQueryArgs { + /// 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 { @@ -92,9 +93,21 @@ impl LogsArgs { 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)) + } +} + +impl LogsArgs { + pub async fn run(self) -> Result<()> { + 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); + if !subscribe { let logs = match query_size { Some(chunk_size) => cast.filter_logs_chunked(filter, chunk_size).await?, 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..2e742f0bb39d5 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, { diff --git a/crates/cast/src/opts.rs b/crates/cast/src/opts.rs index 9a5b0c6aa7a4e..ef873b5e6f0e2 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,14 @@ 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 $TX_HASH + /// - cast events --address $TOKEN --from-block 21000000 --to-block latest + /// - cast events --address $TOKEN "Transfer(address indexed,address indexed,uint256)" + #[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 9095f0dfbca33..b15e69f8bfc06 100644 --- a/crates/cast/tests/cli/main.rs +++ b/crates/cast/tests/cli/main.rs @@ -3470,6 +3470,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", tx_hash, "--rpc-url", &endpoint]) + .assert_success() + .stdout_eq(str![[r#" +[block 2, tx 0x[..], log 0] 0x5FbDB2315678afecb367f032d93F642f64180aa3::Transfer(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/common/src/abi.rs b/crates/common/src/abi.rs index 349dbb7e4ed25..dda111751916e 100644 --- a/crates/common/src/abi.rs +++ b/crates/common/src/abi.rs @@ -179,7 +179,7 @@ pub fn find_source( Ok(source) } else { let implementation = metadata.implementation.unwrap(); - sh_println!( + sh_status!( "Contract at {address} is a proxy, trying to fetch source at {implementation}..." )?; match find_source(client, implementation).await { diff --git a/crates/evm/traces/src/decoder/mod.rs b/crates/evm/traces/src/decoder/mod.rs index 51f19dc2ddde9..c6607ebe8368c 100644 --- a/crates/evm/traces/src/decoder/mod.rs +++ b/crates/evm/traces/src/decoder/mod.rs @@ -92,6 +92,13 @@ impl CallTraceDecoderBuilder { self } + /// Add an ABI for a specific contract address. + #[inline] + pub fn with_address_abi(mut self, address: Address, abi: &JsonAbi) -> Self { + self.decoder.collect_abi(abi, Some(address), false); + self + } + /// Add known contracts to the decoder. #[inline] pub fn with_known_contracts(mut self, contracts: &ContractsByArtifact) -> Self { @@ -786,10 +793,15 @@ impl CallTraceDecoder { { self.constructors_by_address.entry(address).or_insert_with(|| constructor.clone()); } - if global { - for event in abi.events() { + for event in abi.events() { + if let Some(address) = address { + self.push_address_event(address, event.clone()); + } + if global { self.push_event(event.clone()); } + } + if global { for error in abi.errors() { self.push_error(error.clone()); } From 659df67cc8679afa113c2766bd3125d61e6d6d68 Mon Sep 17 00:00:00 2001 From: steven Date: Tue, 18 Aug 2026 13:07:23 -0600 Subject: [PATCH 02/11] fix(traces): preserve global event metadata --- crates/evm/traces/src/decoder/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/evm/traces/src/decoder/mod.rs b/crates/evm/traces/src/decoder/mod.rs index c6607ebe8368c..f1646bd46dd78 100644 --- a/crates/evm/traces/src/decoder/mod.rs +++ b/crates/evm/traces/src/decoder/mod.rs @@ -794,7 +794,9 @@ impl CallTraceDecoder { self.constructors_by_address.entry(address).or_insert_with(|| constructor.clone()); } for event in abi.events() { - if let Some(address) = address { + if let Some(address) = address + && !global + { self.push_address_event(address, event.clone()); } if global { From 30a846de5296573d06308e179e6484652ce10ae7 Mon Sep 17 00:00:00 2001 From: steven Date: Tue, 18 Aug 2026 20:41:54 -0600 Subject: [PATCH 03/11] fix(cast): handle proxy and anonymous events --- crates/cast/src/cmd/events.rs | 76 +++++++++++++ crates/common/src/abi.rs | 160 ++++++++++++++++++++++++++- crates/evm/traces/src/decoder/mod.rs | 79 +++++++++---- 3 files changed, 288 insertions(+), 27 deletions(-) diff --git a/crates/cast/src/cmd/events.rs b/crates/cast/src/cmd/events.rs index 23827bd296975..6b583c5bbe04e 100644 --- a/crates/cast/src/cmd/events.rs +++ b/crates/cast/src/cmd/events.rs @@ -440,6 +440,82 @@ mod tests { assert!(output.removed); } + #[tokio::test] + async fn decodes_address_scoped_anonymous_events() { + let address = Address::repeat_byte(0xaa); + let unindexed = Event::parse("event AnonymousValue(uint256 value) anonymous").unwrap(); + let indexed = + Event::parse("event AnonymousTransfer(address indexed from, uint256 value) anonymous") + .unwrap(); + let abi = JsonAbi::parse([ + "event AnonymousValue(uint256 value) anonymous", + "event AnonymousTransfer(address indexed from, uint256 value) anonymous", + ]) + .unwrap(); + let decoder = CallTraceDecoderBuilder::new().with_address_abi(address, &abi).build(); + + let decoded = decoder + .decode_event_with_address( + address, + &LogData::new_unchecked(Vec::new(), (U256::from(7),).abi_encode().into()), + ) + .await; + assert_eq!(decoded.name.as_deref(), Some(unindexed.name.as_str())); + assert_eq!(decoded.params.unwrap(), [("value".to_string(), "7".to_string())]); + + let from = Address::repeat_byte(0x11); + let decoded = decoder + .decode_event_with_address( + address, + &LogData::new_unchecked( + vec![from.into_word()], + (U256::from(42),).abi_encode().into(), + ), + ) + .await; + assert_eq!(decoded.name.as_deref(), Some(indexed.name.as_str())); + assert_eq!(decoded.params.as_ref().unwrap()[0], ("from".to_string(), from.to_string())); + assert_eq!(decoded.params.as_ref().unwrap()[1], ("value".to_string(), "42".to_string())); + } + + #[tokio::test] + async fn decodes_proxy_and_implementation_events_at_proxy_address() { + let address = Address::repeat_byte(0xaa); + let proxy_abi = JsonAbi::parse(["event Upgraded(address indexed implementation)"]).unwrap(); + let implementation_abi = JsonAbi::parse(["event ValueChanged(uint256 value)"]).unwrap(); + let decoder = CallTraceDecoderBuilder::new() + .with_address_abi(address, &implementation_abi) + .with_address_abi(address, &proxy_abi) + .build(); + + let upgraded = proxy_abi.events().next().unwrap(); + let implementation = Address::repeat_byte(0x22); + let decoded = decoder + .decode_event_with_address( + address, + &LogData::new_unchecked( + vec![upgraded.selector(), implementation.into_word()], + Bytes::new(), + ), + ) + .await; + assert_eq!(decoded.name.as_deref(), Some("Upgraded")); + assert_eq!(decoded.params.unwrap()[0].1, implementation.to_string()); + + let changed = implementation_abi.events().next().unwrap(); + let decoded = decoder + .decode_event_with_address( + address, + &LogData::new_unchecked( + vec![changed.selector()], + (U256::from(9),).abi_encode().into(), + ), + ) + .await; + assert_eq!(decoded.name.as_deref(), Some("ValueChanged")); + assert_eq!(decoded.params.unwrap()[0].1, "9"); + } + #[tokio::test] async fn unknown_event_falls_back_to_raw_log() { let topic = B256::repeat_byte(0x11); diff --git a/crates/common/src/abi.rs b/crates/common/src/abi.rs index dda111751916e..c5adfd4ef473a 100644 --- a/crates/common/src/abi.rs +++ b/crates/common/src/abi.rs @@ -3,11 +3,13 @@ use alloy_chains::Chain; use alloy_dyn_abi::{DynSolType, DynSolValue, FunctionExt, JsonAbiExt}; use alloy_json_abi::{Error, Event, Function, Param}; -use alloy_primitives::{Address, LogData, hex}; +use alloy_primitives::{Address, LogData, hex, map::HashSet}; use eyre::{Context, ContextCompat, Result}; use foundry_block_explorers::{Client, contract::ContractMetadata, errors::EtherscanError}; use std::pin::Pin; +const MAX_PROXY_DEPTH: usize = 16; + pub fn encode_args(inputs: &[Param], args: I) -> Result> where I: IntoIterator, @@ -166,26 +168,48 @@ pub async fn get_func_etherscan( Err(eyre::eyre!("Function not found in abi")) } -/// If the code at `address` is a proxy, recurse until we find the implementation. +/// If the code at `address` is a proxy, recurse through its implementations and return metadata for +/// the full chain, with the final implementation first. pub fn find_source( client: Client, address: Address, +) -> Pin>>> { + find_source_inner(client, address, HashSet::default(), 0) +} + +fn find_source_inner( + client: Client, + address: Address, + mut visited: HashSet

, + depth: usize, ) -> Pin>>> { Box::pin(async move { + if depth >= MAX_PROXY_DEPTH { + eyre::bail!("proxy chain exceeds maximum depth of {MAX_PROXY_DEPTH}"); + } + if !visited.insert(address) { + eyre::bail!("proxy cycle detected at {address}"); + } + trace!(%address, "find Etherscan source"); let source = client.contract_source_code(address).await?; let metadata = source.items.first().wrap_err("Etherscan returned no data")?; if metadata.proxy == 0 { Ok(source) } else { - let implementation = metadata.implementation.unwrap(); + let implementation = metadata + .implementation + .ok_or_else(|| eyre::eyre!("proxy at {address} has no implementation address"))?; sh_status!( "Contract at {address} is a proxy, trying to fetch source at {implementation}..." )?; - match find_source(client, implementation).await { - impl_source @ Ok(_) => impl_source, + match find_source_inner(client, implementation, visited, depth + 1).await { + Ok(mut impl_source) => { + impl_source.items.extend(source.items); + Ok(impl_source) + } Err(e) => { - let err = EtherscanError::ContractCodeNotVerified(address).to_string(); + let err = EtherscanError::ContractCodeNotVerified(implementation).to_string(); if e.to_string() == err { error!(%err); Ok(source) @@ -209,6 +233,50 @@ mod tests { use super::*; use alloy_dyn_abi::EventExt; use alloy_primitives::{B256, U256}; + use axum::{ + Json, Router, + extract::{Query, State}, + routing::get, + }; + use serde_json::{Value, json}; + use std::{collections::HashMap as StdHashMap, sync::Arc}; + use tokio::task::JoinHandle; + + fn source_response(name: &str, abi: Value, implementation: Option
) -> Value { + let mut metadata = json!({ + "SourceCode": "", + "ABI": abi.to_string(), + "ContractName": name, + "CompilerVersion": "v0.8.26+commit.8a97fa7a", + "OptimizationUsed": "0", + "OptimizationRuns": "0", + "ConstructorArguments": "", + "EVMVersion": "Default", + "IsProxy": if implementation.is_some() { "1" } else { "0" } + }); + if let Some(implementation) = implementation { + metadata["Implementation"] = json!(implementation); + } + json!({ "status": "1", "message": "OK", "result": [metadata] }) + } + + async fn explorer_client(responses: StdHashMap) -> (Client, JoinHandle<()>) { + async fn handler( + State(responses): State>>, + Query(query): Query>, + ) -> Json { + let address = query["address"].parse::
().unwrap(); + Json(responses[&address].clone()) + } + + let app = Router::new().route("/", get(handler)).with_state(Arc::new(responses)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = + Client::builder().with_api_url(&url).unwrap().with_url(&url).unwrap().build().unwrap(); + (client, handle) + } #[test] fn test_get_func() { @@ -314,4 +382,84 @@ mod tests { assert!(res.is_err()); assert!(format!("{}", res.unwrap_err()).contains("encode length mismatch")); } + + #[tokio::test] + async fn find_source_accumulates_proxy_chain_metadata() { + let proxy = Address::repeat_byte(0x11); + let implementation = Address::repeat_byte(0x22); + let responses = StdHashMap::from([ + ( + proxy, + source_response( + "Proxy", + json!([{ + "anonymous": false, + "inputs": [], + "name": "ProxyEvent", + "type": "event" + }]), + Some(implementation), + ), + ), + ( + implementation, + source_response( + "Implementation", + json!([{ + "anonymous": false, + "inputs": [], + "name": "ImplementationEvent", + "type": "event" + }]), + None, + ), + ), + ]); + let (client, server) = explorer_client(responses).await; + + let source = find_source(client, proxy).await.unwrap(); + server.abort(); + + assert_eq!(source.items.len(), 2); + assert_eq!(source.items[0].contract_name, "Implementation"); + assert_eq!(source.items[1].contract_name, "Proxy"); + assert!(source.items.iter().all(|item| item.abi().unwrap().events().count() == 1)); + } + + #[tokio::test] + async fn find_source_retains_proxy_metadata_for_unverified_implementation() { + let proxy = Address::repeat_byte(0x11); + let implementation = Address::repeat_byte(0x22); + let responses = StdHashMap::from([ + (proxy, source_response("Proxy", json!([]), Some(implementation))), + ( + implementation, + json!({ + "status": "0", + "message": "NOTOK", + "result": "Contract source code not verified" + }), + ), + ]); + let (client, server) = explorer_client(responses).await; + + let source = find_source(client, proxy).await.unwrap(); + server.abort(); + + assert_eq!(source.items.len(), 1); + assert_eq!(source.items[0].contract_name, "Proxy"); + } + + #[tokio::test] + async fn find_source_rejects_proxy_without_implementation() { + let proxy = Address::repeat_byte(0x11); + let mut response = source_response("Proxy", json!([]), None); + response["result"][0]["IsProxy"] = json!("1"); + let (client, server) = explorer_client(StdHashMap::from([(proxy, response)])).await; + + let error = find_source(client, proxy).await.unwrap_err(); + server.abort(); + + assert!(error.to_string().contains("has no implementation address")); + } } diff --git a/crates/evm/traces/src/decoder/mod.rs b/crates/evm/traces/src/decoder/mod.rs index f1646bd46dd78..61e054f346a55 100644 --- a/crates/evm/traces/src/decoder/mod.rs +++ b/crates/evm/traces/src/decoder/mod.rs @@ -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)] @@ -247,6 +248,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, @@ -468,6 +471,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()), @@ -505,6 +509,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(); @@ -583,6 +588,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) @@ -1373,28 +1391,48 @@ impl CallTraceDecoder { } 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 + 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())); + + if let Some(decoded) = self.decode_event_candidates( + address, + log, + regular_events.into_iter().flatten().chain(anonymous_events.into_iter().flatten()), + ) { + return decoded; + } + + if let Some(&topic) = log.topics().first() + && let Some(identifier) = &self.signature_identifier + && let Some(event) = identifier.identify_event(topic).await + { + let event = get_indexed_event(event, log); + if let Some(decoded) = self.decode_event_candidates(address, log, [&event]) { + return decoded; } - }; + } + + DecodedCallLog { name: None, params: None } + } + + fn decode_event_candidates<'a>( + &self, + address: Option
, + log: &LogData, + events: impl IntoIterator, + ) -> Option { for event in events { if let Ok(decoded) = event.decode_log(log) { let params = reconstruct_params(event, &decoded); - return DecodedCallLog { + return Some(DecodedCallLog { name: Some(event.name.clone()), params: Some( params @@ -1416,11 +1454,10 @@ impl CallTraceDecoder { }) .collect(), ), - }; + }); } } - - DecodedCallLog { name: None, params: None } + None } /// Prefetches function and event signatures into the identifier cache From 6f6dd1c952e0fe11cdb01dc2be4e26c47de60ddf Mon Sep 17 00:00:00 2001 From: steven Date: Wed, 19 Aug 2026 09:22:23 -0600 Subject: [PATCH 04/11] fix(cast): address event decoding edge cases --- crates/cast/src/cmd/events.rs | 48 ++----- crates/common/src/abi.rs | 60 +++++--- crates/evm/traces/src/decoder/mod.rs | 69 ++++++++- crates/evm/traces/src/identifier/external.rs | 143 +++++++++++++++---- 4 files changed, 236 insertions(+), 84 deletions(-) diff --git a/crates/cast/src/cmd/events.rs b/crates/cast/src/cmd/events.rs index 6b583c5bbe04e..9706a5530eab6 100644 --- a/crates/cast/src/cmd/events.rs +++ b/crates/cast/src/cmd/events.rs @@ -1,7 +1,10 @@ use super::logs::LogQueryArgs; use crate::{ Cast, - traces::{CallTraceDecoderBuilder, identifier::SignaturesIdentifier}, + traces::{ + CallTraceDecoderBuilder, + identifier::{ExternalIdentifier, SignaturesIdentifier}, + }, }; use alloy_network::AnyNetwork; use alloy_primitives::{Address, B256, Bytes, TxHash}; @@ -14,9 +17,8 @@ use foundry_cli::{ opts::{EtherscanOpts, RpcOpts}, utils::{self, LoadConfig}, }; -use foundry_common::{abi::find_source, shell}; +use foundry_common::shell; use foundry_config::{Chain, Config}; -use futures::{StreamExt, stream}; use serde::Serialize; use std::{collections::BTreeSet, fmt::Write as _}; @@ -123,37 +125,17 @@ async fn decode_logs( .with_networks(config.networks) .with_chain_id(config.chain.map(|chain| chain.id())); - if !config.offline && config.get_etherscan_config_with_chain(Some(explorer_chain))?.is_some() { - let addresses = logs.iter().map(Log::address).collect::>(); - let abis = stream::iter(addresses) - .map(|address| async move { - let result = async { - let client = config - .get_etherscan_config_with_chain(Some(explorer_chain))? - .ok_or_else(|| { - eyre::eyre!( - "No Etherscan API key configured for chain {explorer_chain}" - ) - })? - .into_client_with_no_proxy(config.eth_rpc_no_proxy)?; - let source = find_source(client, address).await?; - source - .items - .into_iter() - .map(|item| item.abi().map_err(Into::into)) - .collect::>>() - } - .await; - (address, result) - }) - .buffer_unordered(5) - .collect::>() - .await; - for (address, abis) in abis { - if let Ok(abis) = abis { - for abi in abis { - builder = builder.with_address_abi(address, &abi); + 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) => { + for abi in abis { + builder = builder.with_address_abi(address, &abi); + } } + Err(err) => sh_warn!("Failed to fetch ABI for {address}: {err}")?, } } } diff --git a/crates/common/src/abi.rs b/crates/common/src/abi.rs index c5adfd4ef473a..ec535b2b36854 100644 --- a/crates/common/src/abi.rs +++ b/crates/common/src/abi.rs @@ -173,8 +173,18 @@ pub async fn get_func_etherscan( pub fn find_source( client: Client, address: Address, -) -> Pin>>> { - find_source_inner(client, address, HashSet::default(), 0) +) -> Pin> + Send>> { + Box::pin(async move { + find_source_inner(client, address, HashSet::default(), 0, true).await.map_err(Into::into) + }) +} + +/// The same as [`find_source`], but does not report proxy traversal status to the user. +pub fn find_source_quiet( + client: Client, + address: Address, +) -> Pin> + Send>> { + find_source_inner(client, address, HashSet::default(), 0, false) } fn find_source_inner( @@ -182,41 +192,49 @@ fn find_source_inner( address: Address, mut visited: HashSet
, depth: usize, -) -> Pin>>> { + report_proxy: bool, +) -> Pin> + Send>> { Box::pin(async move { if depth >= MAX_PROXY_DEPTH { - eyre::bail!("proxy chain exceeds maximum depth of {MAX_PROXY_DEPTH}"); + return Err(EtherscanError::Unknown(format!( + "proxy chain exceeds maximum depth of {MAX_PROXY_DEPTH}" + ))); } if !visited.insert(address) { - eyre::bail!("proxy cycle detected at {address}"); + return Err(EtherscanError::Unknown(format!("proxy cycle detected at {address}"))); } trace!(%address, "find Etherscan source"); let source = client.contract_source_code(address).await?; - let metadata = source.items.first().wrap_err("Etherscan returned no data")?; + let metadata = source + .items + .first() + .ok_or_else(|| EtherscanError::Unknown("Etherscan returned no data".to_string()))?; if metadata.proxy == 0 { Ok(source) } else { - let implementation = metadata - .implementation - .ok_or_else(|| eyre::eyre!("proxy at {address} has no implementation address"))?; - sh_status!( - "Contract at {address} is a proxy, trying to fetch source at {implementation}..." - )?; - match find_source_inner(client, implementation, visited, depth + 1).await { + let implementation = metadata.implementation.ok_or_else(|| { + EtherscanError::Unknown(format!("proxy at {address} has no implementation address")) + })?; + if report_proxy { + sh_status!( + "Contract at {address} is a proxy, trying to fetch source at {implementation}..." + ) + .map_err(|err| EtherscanError::Unknown(err.to_string()))?; + } + match find_source_inner(client, implementation, visited, depth + 1, report_proxy).await + { Ok(mut impl_source) => { impl_source.items.extend(source.items); Ok(impl_source) } - Err(e) => { - let err = EtherscanError::ContractCodeNotVerified(implementation).to_string(); - if e.to_string() == err { - error!(%err); - Ok(source) - } else { - Err(e) - } + Err(EtherscanError::ContractCodeNotVerified(unverified)) + if unverified == implementation => + { + error!(%implementation, "implementation source code not verified"); + Ok(source) } + Err(err) => Err(err), } } }) diff --git a/crates/evm/traces/src/decoder/mod.rs b/crates/evm/traces/src/decoder/mod.rs index 61e054f346a55..498a76cd4457a 100644 --- a/crates/evm/traces/src/decoder/mod.rs +++ b/crates/evm/traces/src/decoder/mod.rs @@ -64,6 +64,7 @@ use monad::{IMonadStaking, IMonadStakingSyscalls, IReserveBalance}; type MonadHardfork = (); type AddressEvents = HashMap>>; type AddressAnonymousEvents = HashMap>>; +type AnonymousEvents = BTreeMap>; /// Build a new [CallTraceDecoder]. #[derive(Default)] @@ -248,6 +249,8 @@ pub struct CallTraceDecoder { pub events: BTreeMap<(B256, usize), Vec>, /// Events identified for a specific contract address. events_by_address: Option>, + /// All known anonymous events, keyed by topic count. + anonymous_events: AnonymousEvents, /// Anonymous events identified for a specific contract address, keyed by topic count. anonymous_events_by_address: Option>, /// Revert decoder. Contains all known custom errors. @@ -471,6 +474,7 @@ impl CallTraceDecoder { constructor_args_offsets: Default::default(), events, events_by_address: None, + anonymous_events: Default::default(), anonymous_events_by_address: None, // Decode Tempo precompile custom errors by name in traces. revert_decoder: RevertDecoder::new().with_abis(tempo_abis.iter()), @@ -583,6 +587,13 @@ impl CallTraceDecoder { /// Adds a single event to the decoder. pub fn push_event(&mut self, event: Event) { + if event.anonymous { + let events = self.anonymous_events.entry(indexed_inputs(&event)).or_default(); + if !events.contains(&event) { + events.push(event); + } + return; + } self.events.entry((event.selector(), indexed_inputs(&event))).or_default().push(event); } @@ -1400,16 +1411,26 @@ impl CallTraceDecoder { }); let anonymous_events = address .and_then(|address| self.anonymous_events_by_address.as_deref()?.get(&address)) - .and_then(|events| events.get(&log.topics().len())); + .and_then(|events| events.get(&log.topics().len())) + .or_else(|| self.anonymous_events.get(&log.topics().len())); - if let Some(decoded) = self.decode_event_candidates( + if let Some(decoded) = + self.decode_event_candidates(address, log, regular_events.into_iter().flatten()) + { + return decoded; + } + if let Some(decoded) = self.decode_unique_event_candidates( address, log, - regular_events.into_iter().flatten().chain(anonymous_events.into_iter().flatten()), + anonymous_events.into_iter().flatten(), ) { 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 @@ -1460,6 +1481,19 @@ impl CallTraceDecoder { None } + fn decode_unique_event_candidates<'a>( + &self, + address: Option
, + log: &LogData, + events: impl IntoIterator, + ) -> Option { + let mut decoded = events + .into_iter() + .filter_map(|event| self.decode_event_candidates(address, log, [event])); + let event = decoded.next()?; + decoded.next().is_none().then_some(event) + } + /// Prefetches function and event signatures into the identifier cache pub async fn prefetch_signatures(&self, nodes: &[CallTraceNode]) { let Some(identifier) = &self.signature_identifier else { return }; @@ -1860,6 +1894,35 @@ mod tests { assert_eq!(decoded.params.unwrap()[0].0, "val"); } + #[tokio::test] + async fn globally_registered_anonymous_event_decodes() { + let abi = JsonAbi::parse(["event AnonymousValue(uint256 value) anonymous"]).unwrap(); + let decoder = CallTraceDecoderBuilder::new().with_abi(&abi).build(); + let log = LogData::new_unchecked(Vec::new(), (U256::from(7),).abi_encode().into()); + + let decoded = decoder.decode_event(&log).await; + + assert_eq!(decoded.name.as_deref(), Some("AnonymousValue")); + assert_eq!(decoded.params.unwrap(), [("value".to_string(), "7".to_string())]); + } + + #[tokio::test] + async fn ambiguous_anonymous_events_do_not_decode() { + let address = Address::from([0x12; 20]); + let abi = JsonAbi::parse([ + "event AnonymousValue(uint256 value) anonymous", + "event AnonymousAmount(uint256 amount) anonymous", + ]) + .unwrap(); + let decoder = CallTraceDecoderBuilder::new().with_address_abi(address, &abi).build(); + let log = LogData::new_unchecked(Vec::new(), (U256::from(7),).abi_encode().into()); + + 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..d0f43b04b21d6 100644 --- a/crates/evm/traces/src/identifier/external.rs +++ b/crates/evm/traces/src/identifier/external.rs @@ -1,12 +1,16 @@ use super::{IdentifiedAddress, TraceIdentifier}; use crate::debug::ContractSources; +use alloy_json_abi::JsonAbi; use alloy_primitives::{ Address, map::{Entry, HashMap, HashSet}, }; use eyre::WrapErr; -use foundry_block_explorers::{contract::Metadata, errors::EtherscanError}; -use foundry_common::compile::etherscan_project; +use foundry_block_explorers::{ + contract::{ContractMetadata, Metadata}, + errors::EtherscanError, +}; +use foundry_common::{abi::find_source_quiet, compile::etherscan_project}; use foundry_config::{Chain, Config}; use futures::{ future::join_all, @@ -29,7 +33,7 @@ use tokio::time::{Duration, Interval}; pub struct ExternalIdentifier { fetchers: Vec>, /// Cached contracts. - contracts: HashMap)>, + contracts: HashMap)>, /// Remaining time external identification may block trace rendering. remaining_budget: Duration, } @@ -94,8 +98,8 @@ impl ExternalIdentifier { .contracts .iter() // filter out vyper files and contracts without metadata - .filter_map(|(addr, (_, metadata))| { - if let Some(metadata) = metadata.as_ref() + .filter_map(|(addr, (_, source))| { + if let Some(metadata) = source.as_ref().and_then(|source| source.items.last()) && !metadata.is_vyper() { Some((*addr, metadata)) @@ -143,8 +147,9 @@ impl ExternalIdentifier { fn identify_from_metadata( &self, address: Address, - metadata: &Metadata, + source: &ContractMetadata, ) -> IdentifiedAddress<'static> { + let metadata = source.items.last().expect("fetched source has metadata"); let label = metadata.contract_name.clone(); let abi = metadata.abi().ok().map(Cow::Owned); IdentifiedAddress { @@ -157,7 +162,7 @@ impl ExternalIdentifier { } } - fn cache_fetched(&mut self, address: Address, value: (FetcherKind, Option)) { + fn cache_fetched(&mut self, address: Address, value: (FetcherKind, Option)) { match self.contracts.entry(address) { Entry::Occupied(mut occupied_entry) => { let old = occupied_entry.get(); @@ -208,6 +213,29 @@ impl ExternalIdentifier { warn!(target: "evm::traces::external", "external identification timed out; disabling it for the remainder of this session"); } } + + /// Fetches all verified ABIs for each address using the configured external sources. + pub async fn get_abis( + &mut self, + addresses: &[Address], + ) -> Vec<(Address, eyre::Result>)> { + self.fetch_addresses_async(addresses).await; + addresses + .iter() + .map(|&address| { + let result = match self.contracts.get(&address) { + Some((_, Some(source))) => source + .items + .iter() + .map(|metadata| metadata.abi().map_err(Into::into)) + .collect(), + Some((_, None)) => Err(eyre::eyre!("contract source code not verified")), + None => Err(eyre::eyre!("external ABI lookup failed")), + }; + (address, result) + }) + .collect() + } } impl TraceIdentifier for ExternalIdentifier { @@ -257,7 +285,7 @@ impl TraceIdentifier for ExternalIdentifier { } type FetchFuture = - Pin, EtherscanError>)>>>; + Pin, EtherscanError>)>>>; /// Maximum number of times a single address is retried through a transient Cloudflare /// block before we give up on it. Bounded so a persistent block can't loop forever. @@ -314,7 +342,7 @@ impl ExternalFetcher { } impl Stream for ExternalFetcher { - type Item = (Address, (FetcherKind, Option)); + type Item = (Address, (FetcherKind, Option)); fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let pin = self.get_mut(); @@ -409,7 +437,7 @@ trait ExternalFetcherT: Send + Sync { fn timeout(&self) -> Duration; fn concurrency(&self) -> usize; fn invalid_api_key(&self) -> &AtomicBool; - async fn fetch(&self, address: Address) -> Result, EtherscanError>; + async fn fetch(&self, address: Address) -> Result, EtherscanError>; } struct EtherscanFetcher { @@ -441,8 +469,8 @@ impl ExternalFetcherT for EtherscanFetcher { &self.invalid_api_key } - async fn fetch(&self, address: Address) -> Result, EtherscanError> { - self.client.contract_source_code(address).await.map(|mut metadata| metadata.items.pop()) + async fn fetch(&self, address: Address) -> Result, EtherscanError> { + find_source_quiet(self.client.clone(), address).await.map(Some) } } @@ -480,7 +508,7 @@ impl ExternalFetcherT for SourcifyFetcher { &self.invalid_api_key } - async fn fetch(&self, address: Address) -> Result, EtherscanError> { + async fn fetch(&self, address: Address) -> Result, EtherscanError> { let url = format!("{url}/{address}?fields=abi,compilation", url = self.url); let response = self .client @@ -500,7 +528,9 @@ impl ExternalFetcherT for SourcifyFetcher { response.json().await.map_err(|e| EtherscanError::Unknown(e.to_string()))?; trace!(target: "evm::traces::external", "Sourcify response for {address}: {response:#?}"); match response { - SourcifyResponse::Success(metadata) => Ok(Some(metadata.into())), + SourcifyResponse::Success(metadata) => { + Ok(Some(ContractMetadata { items: vec![metadata.into()] })) + } SourcifyResponse::Error(error) => Err(EtherscanError::Unknown(format!("{error:#?}"))), } } @@ -606,13 +636,16 @@ mod tests { &self.invalid } - async fn fetch(&self, _address: Address) -> Result, EtherscanError> { + async fn fetch( + &self, + _address: Address, + ) -> Result, EtherscanError> { self.calls.fetch_add(1, AtomicOrdering::Relaxed); let Some(delay) = self.delay else { return pending().await }; if !delay.is_zero() { tokio::time::sleep(delay).await; } - Ok(self.contract_name.map(metadata)) + Ok(self.contract_name.map(contract_metadata)) } } @@ -639,20 +672,42 @@ mod tests { &self.invalid } - async fn fetch(&self, _address: Address) -> Result, EtherscanError> { + async fn fetch( + &self, + _address: Address, + ) -> Result, EtherscanError> { self.calls.fetch_add(1, AtomicOrdering::Relaxed); Err(EtherscanError::RateLimitExceeded) } } - fn metadata(contract_name: &str) -> Metadata { - SourcifyMetadata { + fn contract_metadata(contract_name: &str) -> ContractMetadata { + let metadata = SourcifyMetadata { abi: None, compilation: Some(Compilation { compiler_version: String::new(), name: contract_name.to_string(), }), } + .into(); + ContractMetadata { items: vec![metadata] } + } + + fn metadata_with_event(contract_name: &str, event_name: &str) -> Metadata { + let abi = serde_json::value::to_raw_value(&serde_json::json!([{ + "anonymous": false, + "inputs": [], + "name": event_name, + "type": "event" + }])) + .unwrap(); + SourcifyMetadata { + abi: Some(abi), + compilation: Some(Compilation { + compiler_version: String::new(), + name: contract_name.to_string(), + }), + } .into() } @@ -692,7 +747,10 @@ mod tests { fn invalid_api_key(&self) -> &AtomicBool { &self.invalid } - async fn fetch(&self, address: Address) -> Result, EtherscanError> { + async fn fetch( + &self, + address: Address, + ) -> Result, EtherscanError> { let first_time = self.seen.lock().unwrap().insert(address); if first_time { Err(EtherscanError::BlockedByCloudflare) } else { Ok(None) } } @@ -743,7 +801,7 @@ mod tests { assert!(identifier.remaining_budget.is_zero()); assert_eq!( - identifier.contracts[&address].1.as_ref().unwrap().contract_name, + identifier.contracts[&address].1.as_ref().unwrap().items[0].contract_name, "PartialResult" ); assert_eq!(successful_calls.load(AtomicOrdering::Relaxed), 1); @@ -826,19 +884,50 @@ mod tests { let address = Address::with_last_byte(1); let mut identifier = test_identifier(Vec::new(), Duration::ZERO); - identifier - .cache_fetched(address, (FetcherKind::Sourcify, Some(metadata("SourcifyResult")))); + identifier.cache_fetched( + address, + (FetcherKind::Sourcify, Some(contract_metadata("SourcifyResult"))), + ); identifier.cache_fetched(address, (FetcherKind::Etherscan, None)); assert_eq!( - identifier.contracts[&address].1.as_ref().unwrap().contract_name, + identifier.contracts[&address].1.as_ref().unwrap().items[0].contract_name, "SourcifyResult" ); - identifier - .cache_fetched(address, (FetcherKind::Etherscan, Some(metadata("EtherscanResult")))); + identifier.cache_fetched( + address, + (FetcherKind::Etherscan, Some(contract_metadata("EtherscanResult"))), + ); assert_eq!( - identifier.contracts[&address].1.as_ref().unwrap().contract_name, + identifier.contracts[&address].1.as_ref().unwrap().items[0].contract_name, "EtherscanResult" ); } + + #[tokio::test] + async fn get_abis_returns_all_proxy_metadata() { + let address = Address::with_last_byte(1); + let source = ContractMetadata { + items: vec![ + metadata_with_event("Implementation", "ImplementationEvent"), + metadata_with_event("Proxy", "ProxyEvent"), + ], + }; + let mut identifier = test_identifier(Vec::new(), Duration::from_secs(1)); + identifier.cache_fetched(address, (FetcherKind::Etherscan, Some(source))); + + let mut results = identifier.get_abis(&[address]).await; + let (result_address, abis) = results.pop().unwrap(); + let event_names = abis + .unwrap() + .into_iter() + .flat_map(|abi| abi.events.into_keys()) + .collect::>(); + + assert_eq!(result_address, address); + assert_eq!( + event_names, + StdHashSet::from(["ImplementationEvent".to_string(), "ProxyEvent".to_string()]) + ); + } } From e03e5fac13844ff581c56f0e117667310443175f Mon Sep 17 00:00:00 2001 From: steven Date: Wed, 19 Aug 2026 11:21:33 -0600 Subject: [PATCH 05/11] fix(traces): prefer proxy implementation metadata --- crates/evm/traces/src/identifier/external.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/evm/traces/src/identifier/external.rs b/crates/evm/traces/src/identifier/external.rs index d0f43b04b21d6..a7b3b4cc8aa9e 100644 --- a/crates/evm/traces/src/identifier/external.rs +++ b/crates/evm/traces/src/identifier/external.rs @@ -38,6 +38,10 @@ pub struct ExternalIdentifier { remaining_budget: Duration, } +fn implementation_metadata(source: &ContractMetadata) -> Option<&Metadata> { + source.items.first() +} + impl ExternalIdentifier { /// Creates a new external identifier with the given client pub fn new(config: &Config, mut chain: Option) -> eyre::Result> { @@ -99,7 +103,7 @@ impl ExternalIdentifier { .iter() // filter out vyper files and contracts without metadata .filter_map(|(addr, (_, source))| { - if let Some(metadata) = source.as_ref().and_then(|source| source.items.last()) + if let Some(metadata) = source.as_ref().and_then(implementation_metadata) && !metadata.is_vyper() { Some((*addr, metadata)) @@ -149,7 +153,7 @@ impl ExternalIdentifier { address: Address, source: &ContractMetadata, ) -> IdentifiedAddress<'static> { - let metadata = source.items.last().expect("fetched source has metadata"); + let metadata = implementation_metadata(source).expect("fetched source has metadata"); let label = metadata.contract_name.clone(); let abi = metadata.abi().ok().map(Cow::Owned); IdentifiedAddress { @@ -905,7 +909,7 @@ mod tests { } #[tokio::test] - async fn get_abis_returns_all_proxy_metadata() { + async fn proxy_metadata_uses_implementation_identity_and_all_abis() { let address = Address::with_last_byte(1); let source = ContractMetadata { items: vec![ @@ -914,6 +918,8 @@ mod tests { ], }; let mut identifier = test_identifier(Vec::new(), Duration::from_secs(1)); + let identity = identifier.identify_from_metadata(address, &source); + assert_eq!(identity.contract.as_deref(), Some("Implementation")); identifier.cache_fetched(address, (FetcherKind::Etherscan, Some(source))); let mut results = identifier.get_abis(&[address]).await; From 7c13301e95d8f9a097291e40c9f9ca56965e48ef Mon Sep 17 00:00:00 2001 From: steven Date: Wed, 19 Aug 2026 17:49:07 -0600 Subject: [PATCH 06/11] fix(cast): address events review feedback --- crates/cast/src/cmd/events.rs | 79 +++++++++----------- crates/cast/src/cmd/logs.rs | 62 +++++++-------- crates/cast/src/lib.rs | 14 ++++ crates/evm/traces/src/identifier/external.rs | 13 ++-- 4 files changed, 87 insertions(+), 81 deletions(-) diff --git a/crates/cast/src/cmd/events.rs b/crates/cast/src/cmd/events.rs index 9706a5530eab6..eeaad8077b1d2 100644 --- a/crates/cast/src/cmd/events.rs +++ b/crates/cast/src/cmd/events.rs @@ -1,12 +1,11 @@ use super::logs::LogQueryArgs; use crate::{ - Cast, + Cast, MAX_CONCURRENT_RPC_REQUESTS, traces::{ CallTraceDecoderBuilder, identifier::{ExternalIdentifier, SignaturesIdentifier}, }, }; -use alloy_network::AnyNetwork; use alloy_primitives::{Address, B256, Bytes, TxHash}; use alloy_provider::Provider; use alloy_rpc_types::Log; @@ -19,6 +18,7 @@ use foundry_cli::{ }; use foundry_common::shell; use foundry_config::{Chain, Config}; +use futures::StreamExt; use serde::Serialize; use std::{collections::BTreeSet, fmt::Write as _}; @@ -68,7 +68,16 @@ impl EventsArgs { let (rpc_chain, explorer_chain) = resolve_chains(config.chain, Chain::from(chain_id)); config.chain = Some(rpc_chain); - let logs = fetch_logs(&provider, tx_hash, query).await?; + 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() { @@ -88,32 +97,6 @@ fn resolve_chains(configured_chain: Option, rpc_chain: Chain) -> (Chain, (rpc_chain, configured_chain.unwrap_or(rpc_chain)) } -async fn fetch_logs

( - provider: &P, - tx_hash: Option, - query: LogQueryArgs, -) -> Result> -where - P: Provider + Clone + Unpin, -{ - if let Some(tx_hash) = tx_hash { - return Ok(provider - .get_transaction_receipt(tx_hash) - .await? - .ok_or_else(|| eyre::eyre!("tx receipt not found: {tx_hash}"))? - .inner - .logs() - .to_vec()); - } - - let (filter, query_size) = query.resolve(provider).await?; - let cast = Cast::new(provider); - match query_size { - Some(chunk_size) => cast.get_logs_chunked(&filter, chunk_size).await, - None => cast.get_logs(&filter).await, - } -} - async fn decode_logs( logs: Vec, config: &Config, @@ -141,12 +124,14 @@ async fn decode_logs( } let decoder = builder.build(); - let mut events = Vec::with_capacity(logs.len()); - for log in logs { - let decoded = decoder.decode_event_with_address(log.address(), log.data()).await; - events.push(EventOutput::new(log, decoded.name, decoded.params)); - } - Ok(events) + Ok(futures::stream::iter(logs) + .map(|log| async { + let decoded = decoder.decode_event_with_address(log.address(), log.data()).await; + EventOutput::new(log, decoded.name, decoded.params) + }) + .buffered(MAX_CONCURRENT_RPC_REQUESTS) + .collect() + .await) } #[derive(Debug, Serialize)] @@ -201,6 +186,16 @@ struct EventParam { value: String, } +/// Formats decoded and raw events for human-readable output. +/// +/// # Example +/// +/// ```text +/// [block 1, tx 0xabc..., log 0] 0x123...::Transfer(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 { @@ -252,6 +247,7 @@ fn format_events(events: &[EventOutput]) -> String { mod tests { use super::*; use alloy_json_abi::{Event, JsonAbi}; + use alloy_network::AnyNetwork; use alloy_primitives::{LogData, U256}; use alloy_provider::{ProviderBuilder, mock::Asserter}; use alloy_sol_types::SolValue; @@ -342,10 +338,9 @@ mod tests { asserter.push_success(&receipt); let provider = ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(asserter.clone()); - let EventsArgs { query, .. } = - EventsArgs::try_parse_from(["events", "--tx-hash", &tx_hash.to_string()]).unwrap(); + let cast = Cast::new(&provider); - let logs = fetch_logs(&provider, Some(tx_hash), query).await.unwrap(); + let logs = cast.get_transaction_logs(tx_hash).await.unwrap(); assert_eq!(logs.len(), 1); assert_eq!(logs[0].address(), log_address); assert_eq!(logs[0].block_number, Some(7)); @@ -353,9 +348,7 @@ mod tests { let missing: Option = None; asserter.push_success(&missing); - let EventsArgs { query, .. } = - EventsArgs::try_parse_from(["events", "--tx-hash", &tx_hash.to_string()]).unwrap(); - let err = fetch_logs(&provider, Some(tx_hash), query).await.unwrap_err(); + let err = cast.get_transaction_logs(tx_hash).await.unwrap_err(); assert!(err.to_string().contains("tx receipt not found")); } @@ -379,7 +372,9 @@ mod tests { ]) .unwrap(); - let logs = fetch_logs(&provider, None, query).await.unwrap(); + let (filter, query_size) = query.resolve(&provider).await.unwrap(); + let logs = + Cast::new(&provider).get_logs_chunked(&filter, query_size.unwrap()).await.unwrap(); assert_eq!(logs.iter().map(|log| log.block_number).collect::>(), [Some(1), Some(2)]); } diff --git a/crates/cast/src/cmd/logs.rs b/crates/cast/src/cmd/logs.rs index a0d370b4cc674..6f736660d35d0 100644 --- a/crates/cast/src/cmd/logs.rs +++ b/crates/cast/src/cmd/logs.rs @@ -68,37 +68,6 @@ pub struct LogQueryArgs { query_size: Option, } -impl LogQueryArgs { - /// 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)) - } -} - impl LogsArgs { pub async fn run(self) -> Result<()> { let Self { query, subscribe, rpc } = self; @@ -134,6 +103,37 @@ impl LogsArgs { } } +impl LogQueryArgs { + /// 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/lib.rs b/crates/cast/src/lib.rs index 2e742f0bb39d5..5246f30a03ea2 100644 --- a/crates/cast/src/lib.rs +++ b/crates/cast/src/lib.rs @@ -947,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/evm/traces/src/identifier/external.rs b/crates/evm/traces/src/identifier/external.rs index a7b3b4cc8aa9e..20b6a727628ea 100644 --- a/crates/evm/traces/src/identifier/external.rs +++ b/crates/evm/traces/src/identifier/external.rs @@ -38,10 +38,6 @@ pub struct ExternalIdentifier { remaining_budget: Duration, } -fn implementation_metadata(source: &ContractMetadata) -> Option<&Metadata> { - source.items.first() -} - impl ExternalIdentifier { /// Creates a new external identifier with the given client pub fn new(config: &Config, mut chain: Option) -> eyre::Result> { @@ -103,7 +99,7 @@ impl ExternalIdentifier { .iter() // filter out vyper files and contracts without metadata .filter_map(|(addr, (_, source))| { - if let Some(metadata) = source.as_ref().and_then(implementation_metadata) + if let Some(metadata) = source.as_ref().and_then(|source| source.items.first()) && !metadata.is_vyper() { Some((*addr, metadata)) @@ -153,7 +149,8 @@ impl ExternalIdentifier { address: Address, source: &ContractMetadata, ) -> IdentifiedAddress<'static> { - let metadata = implementation_metadata(source).expect("fetched source has metadata"); + // Proxy-chain metadata is ordered final implementation first and queried address last. + let metadata = source.items.last().expect("fetched source has metadata"); let label = metadata.contract_name.clone(); let abi = metadata.abi().ok().map(Cow::Owned); IdentifiedAddress { @@ -909,7 +906,7 @@ mod tests { } #[tokio::test] - async fn proxy_metadata_uses_implementation_identity_and_all_abis() { + async fn proxy_metadata_preserves_address_identity_and_all_abis() { let address = Address::with_last_byte(1); let source = ContractMetadata { items: vec![ @@ -919,7 +916,7 @@ mod tests { }; let mut identifier = test_identifier(Vec::new(), Duration::from_secs(1)); let identity = identifier.identify_from_metadata(address, &source); - assert_eq!(identity.contract.as_deref(), Some("Implementation")); + assert_eq!(identity.contract.as_deref(), Some("Proxy")); identifier.cache_fetched(address, (FetcherKind::Etherscan, Some(source))); let mut results = identifier.get_abis(&[address]).await; From 60804ce86b111da459af93235a90b4d8e85582bf Mon Sep 17 00:00:00 2001 From: steven Date: Wed, 19 Aug 2026 20:05:54 -0600 Subject: [PATCH 07/11] refactor(cast): streamline events command --- .changelog/cast-events.md | 1 - crates/cast/src/cmd/events.rs | 304 +++---------------- crates/cast/src/cmd/logs.rs | 17 +- crates/cast/src/opts.rs | 4 + crates/cast/tests/cli/main.rs | 4 +- crates/common/src/abi.rs | 202 ++---------- crates/evm/traces/src/decoder/mod.rs | 117 ++++--- crates/evm/traces/src/identifier/external.rs | 197 ++++++------ 8 files changed, 249 insertions(+), 597 deletions(-) diff --git a/.changelog/cast-events.md b/.changelog/cast-events.md index 17fe5d090341e..e519632086876 100644 --- a/.changelog/cast-events.md +++ b/.changelog/cast-events.md @@ -1,6 +1,5 @@ --- cast: minor -foundry-common: patch foundry-evm-traces: minor --- diff --git a/crates/cast/src/cmd/events.rs b/crates/cast/src/cmd/events.rs index eeaad8077b1d2..cf9530be5f8bf 100644 --- a/crates/cast/src/cmd/events.rs +++ b/crates/cast/src/cmd/events.rs @@ -62,7 +62,8 @@ pub struct EventsArgs { impl EventsArgs { pub async fn run(self) -> Result<()> { let mut config = self.load_config()?; - let Self { tx_hash, query, etherscan: _, rpc: _ } = self; + 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)); @@ -115,7 +116,7 @@ async fn decode_logs( match result { Ok(abis) => { for abi in abis { - builder = builder.with_address_abi(address, &abi); + builder = builder.with_address_events(address, &abi); } } Err(err) => sh_warn!("Failed to fetch ABI for {address}: {err}")?, @@ -126,7 +127,8 @@ async fn decode_logs( let decoder = builder.build(); Ok(futures::stream::iter(logs) .map(|log| async { - let decoded = decoder.decode_event_with_address(log.address(), log.data()).await; + 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) @@ -191,7 +193,7 @@ struct EventParam { /// # Example /// /// ```text -/// [block 1, tx 0xabc..., log 0] 0x123...::Transfer(from: 0x456..., value: 1) +/// [block 1, tx 0xabc..., log 0] 0x123...::Transfer(address,uint256) { from: 0x456..., value: 1 } /// 0x789... /// topic 0: 0xdef... /// data: 0x @@ -222,16 +224,18 @@ fn format_events(events: &[EventOutput]) -> String { output.push_str("] "); } if let Some(name) = &event.event { - let _ = write!(output, "{}::{name}(", event.address); + 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_str(")\n"); + output.push('\n'); } else { let _ = writeln!(output, "{}", event.address); for (index, topic) in event.topics.iter().enumerate() { @@ -246,19 +250,24 @@ fn format_events(events: &[EventOutput]) -> String { #[cfg(test)] mod tests { use super::*; - use alloy_json_abi::{Event, JsonAbi}; - use alloy_network::AnyNetwork; - use alloy_primitives::{LogData, U256}; - use alloy_provider::{ProviderBuilder, mock::Asserter}; - use alloy_sol_types::SolValue; #[test] - fn requires_event_source() { + fn validates_event_sources() { assert!(EventsArgs::try_parse_from(["events"]).is_err()); - } - - #[test] - fn transaction_and_filter_modes_conflict() { + 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", @@ -269,16 +278,6 @@ mod tests { ]) .is_err() ); - } - - #[test] - fn accepts_transaction_and_filter_modes_separately() { - assert!( - EventsArgs::try_parse_from(["events", "--tx-hash", &TxHash::ZERO.to_string()]).is_ok() - ); - assert!( - EventsArgs::try_parse_from(["events", "--txhash", &TxHash::ZERO.to_string()]).is_ok() - ); assert!( EventsArgs::try_parse_from([ "events", @@ -304,216 +303,6 @@ mod tests { assert_eq!(explorer_chain, rpc_chain); } - #[tokio::test] - async fn fetches_receipt_logs_and_reports_missing_receipts() { - let tx_hash = TxHash::repeat_byte(0x44); - let log_address = Address::repeat_byte(0xaa); - let receipt = serde_json::json!({ - "type": "0x2", - "status": "0x1", - "cumulativeGasUsed": "0x5208", - "logs": [{ - "address": log_address, - "topics": [], - "data": "0x", - "blockNumber": "0x7", - "transactionHash": tx_hash, - "transactionIndex": "0x0", - "blockHash": B256::repeat_byte(0x33), - "logIndex": "0x3", - "removed": false - }], - "transactionHash": tx_hash, - "transactionIndex": "0x0", - "blockHash": B256::repeat_byte(0x33), - "blockNumber": "0x7", - "logsBloom": format!("0x{}", "0".repeat(512)), - "gasUsed": "0x5208", - "effectiveGasPrice": "0x1", - "from": Address::ZERO, - "to": Address::ZERO, - "contractAddress": null - }); - let asserter = Asserter::new(); - asserter.push_success(&receipt); - let provider = - ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(asserter.clone()); - let cast = Cast::new(&provider); - - let logs = cast.get_transaction_logs(tx_hash).await.unwrap(); - assert_eq!(logs.len(), 1); - assert_eq!(logs[0].address(), log_address); - assert_eq!(logs[0].block_number, Some(7)); - assert_eq!(logs[0].log_index, Some(3)); - - let missing: Option = None; - asserter.push_success(&missing); - let err = cast.get_transaction_logs(tx_hash).await.unwrap_err(); - assert!(err.to_string().contains("tx receipt not found")); - } - - #[tokio::test] - async fn fetches_filtered_logs_in_chunk_order() { - let asserter = Asserter::new(); - asserter - .push_success(&vec![Log:: { block_number: Some(1), ..Default::default() }]); - asserter - .push_success(&vec![Log:: { block_number: Some(2), ..Default::default() }]); - let provider = - ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(asserter); - let EventsArgs { query, .. } = EventsArgs::try_parse_from([ - "events", - "--from-block", - "1", - "--to-block", - "2", - "--query-size", - "1", - ]) - .unwrap(); - - let (filter, query_size) = query.resolve(&provider).await.unwrap(); - let logs = - Cast::new(&provider).get_logs_chunked(&filter, query_size.unwrap()).await.unwrap(); - assert_eq!(logs.iter().map(|log| log.block_number).collect::>(), [Some(1), Some(2)]); - } - - #[tokio::test] - async fn decodes_known_event_and_preserves_metadata() { - let event = Event::parse( - "event WidgetMoved(address indexed from, address indexed to, uint256 value)", - ) - .unwrap(); - let from = Address::repeat_byte(0x11); - let to = Address::repeat_byte(0x22); - let data = LogData::new_unchecked( - vec![event.selector(), from.into_word(), to.into_word()], - (U256::from(42),).abi_encode().into(), - ); - let log = Log { - inner: alloy_primitives::Log { address: Address::repeat_byte(0xaa), data }, - 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: true, - }; - let signature = event.full_signature(); - let abi = JsonAbi::parse([signature.as_str()]).unwrap(); - let decoder = CallTraceDecoderBuilder::new().with_address_abi(log.address(), &abi).build(); - let decoded = decoder.decode_event_with_address(log.address(), log.data()).await; - let output = EventOutput::new(log, decoded.name, decoded.params); - - assert_eq!(output.event.as_deref(), Some("WidgetMoved")); - assert_eq!(output.params.as_ref().unwrap().len(), 3); - assert_eq!(output.block_hash, Some(B256::repeat_byte(0x33))); - assert_eq!(output.block_number, Some(7)); - assert_eq!(output.block_timestamp, Some(123)); - assert_eq!(output.transaction_hash, Some(TxHash::repeat_byte(0xbb))); - assert_eq!(output.transaction_index, Some(2)); - assert_eq!(output.log_index, Some(3)); - assert!(output.removed); - } - - #[tokio::test] - async fn decodes_address_scoped_anonymous_events() { - let address = Address::repeat_byte(0xaa); - let unindexed = Event::parse("event AnonymousValue(uint256 value) anonymous").unwrap(); - let indexed = - Event::parse("event AnonymousTransfer(address indexed from, uint256 value) anonymous") - .unwrap(); - let abi = JsonAbi::parse([ - "event AnonymousValue(uint256 value) anonymous", - "event AnonymousTransfer(address indexed from, uint256 value) anonymous", - ]) - .unwrap(); - let decoder = CallTraceDecoderBuilder::new().with_address_abi(address, &abi).build(); - - let decoded = decoder - .decode_event_with_address( - address, - &LogData::new_unchecked(Vec::new(), (U256::from(7),).abi_encode().into()), - ) - .await; - assert_eq!(decoded.name.as_deref(), Some(unindexed.name.as_str())); - assert_eq!(decoded.params.unwrap(), [("value".to_string(), "7".to_string())]); - - let from = Address::repeat_byte(0x11); - let decoded = decoder - .decode_event_with_address( - address, - &LogData::new_unchecked( - vec![from.into_word()], - (U256::from(42),).abi_encode().into(), - ), - ) - .await; - assert_eq!(decoded.name.as_deref(), Some(indexed.name.as_str())); - assert_eq!(decoded.params.as_ref().unwrap()[0], ("from".to_string(), from.to_string())); - assert_eq!(decoded.params.as_ref().unwrap()[1], ("value".to_string(), "42".to_string())); - } - - #[tokio::test] - async fn decodes_proxy_and_implementation_events_at_proxy_address() { - let address = Address::repeat_byte(0xaa); - let proxy_abi = JsonAbi::parse(["event Upgraded(address indexed implementation)"]).unwrap(); - let implementation_abi = JsonAbi::parse(["event ValueChanged(uint256 value)"]).unwrap(); - let decoder = CallTraceDecoderBuilder::new() - .with_address_abi(address, &implementation_abi) - .with_address_abi(address, &proxy_abi) - .build(); - - let upgraded = proxy_abi.events().next().unwrap(); - let implementation = Address::repeat_byte(0x22); - let decoded = decoder - .decode_event_with_address( - address, - &LogData::new_unchecked( - vec![upgraded.selector(), implementation.into_word()], - Bytes::new(), - ), - ) - .await; - assert_eq!(decoded.name.as_deref(), Some("Upgraded")); - assert_eq!(decoded.params.unwrap()[0].1, implementation.to_string()); - - let changed = implementation_abi.events().next().unwrap(); - let decoded = decoder - .decode_event_with_address( - address, - &LogData::new_unchecked( - vec![changed.selector()], - (U256::from(9),).abi_encode().into(), - ), - ) - .await; - assert_eq!(decoded.name.as_deref(), Some("ValueChanged")); - assert_eq!(decoded.params.unwrap()[0].1, "9"); - } - - #[tokio::test] - async fn unknown_event_falls_back_to_raw_log() { - let topic = B256::repeat_byte(0x11); - let log = Log { - inner: alloy_primitives::Log { - address: Address::repeat_byte(0xaa), - data: LogData::new_unchecked(vec![topic], Bytes::from_static(&[1, 2, 3])), - }, - ..Default::default() - }; - let decoded = CallTraceDecoderBuilder::new() - .build() - .decode_event_with_address(log.address(), log.data()) - .await; - let output = EventOutput::new(log, decoded.name, decoded.params); - - assert!(output.event.is_none()); - assert_eq!(output.topics, vec![topic]); - assert_eq!(output.data, Bytes::from_static(&[1, 2, 3])); - } - #[test] fn formats_decoded_and_raw_events() { let decoded = EventOutput { @@ -522,13 +311,13 @@ mod tests { block_number: Some(7), block_timestamp: Some(123), transaction_hash: Some(TxHash::repeat_byte(0xbb)), - transaction_index: None, + transaction_index: Some(2), log_index: Some(3), removed: false, - event: Some("Transfer".to_string()), + event: Some("Transfer(address,address,uint256)".to_string()), params: Some(vec![EventParam { name: "value".to_string(), value: "42".to_string() }]), - topics: vec![], - data: Bytes::new(), + topics: vec![B256::repeat_byte(0x11)], + data: Bytes::from_static(&[0x22]), }; let raw = EventOutput { address: Address::repeat_byte(0xbb), @@ -545,42 +334,21 @@ mod tests { 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(value: 42)\n", + "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa::Transfer(address,address,uint256) { value: 42 }\n", "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\n", " topic 0: 0x1111111111111111111111111111111111111111111111111111111111111111\n", " data: 0x22\n", ) ); } - - #[test] - fn serializes_structured_json_output() { - let event = 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".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 value = serde_json::to_value(event).unwrap(); - assert_eq!(value["blockHash"], B256::repeat_byte(0x33).to_string()); - assert_eq!(value["blockNumber"], 7); - assert_eq!(value["blockTimestamp"], 123); - assert_eq!(value["event"], "Transfer"); - assert_eq!(value["params"][0]["name"], "value"); - assert_eq!(value["topics"][0], B256::repeat_byte(0x11).to_string()); - assert_eq!(value["data"], "0x22"); - } } diff --git a/crates/cast/src/cmd/logs.rs b/crates/cast/src/cmd/logs.rs index 6f736660d35d0..836641beeb58e 100644 --- a/crates/cast/src/cmd/logs.rs +++ b/crates/cast/src/cmd/logs.rs @@ -3,7 +3,7 @@ use alloy_dyn_abi::{DynSolType, DynSolValue, Specifier}; use alloy_ens::NameOrAddress; use alloy_json_abi::Event; use alloy_network::{AnyNetwork, Network}; -use alloy_primitives::{Address, B256, hex::FromHex}; +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; @@ -104,6 +104,21 @@ 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 diff --git a/crates/cast/src/opts.rs b/crates/cast/src/opts.rs index ef873b5e6f0e2..28bdfbdab9b4d 100644 --- a/crates/cast/src/opts.rs +++ b/crates/cast/src/opts.rs @@ -419,9 +419,13 @@ pub enum CastSubcommand { /// 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 diff --git a/crates/cast/tests/cli/main.rs b/crates/cast/tests/cli/main.rs index 96c6b49c91097..731cacbcf8607 100644 --- a/crates/cast/tests/cli/main.rs +++ b/crates/cast/tests/cli/main.rs @@ -3560,10 +3560,10 @@ contract EventEmitter { let tx_hash = receipt["transactionHash"].as_str().unwrap(); cmd.cast_fuse() - .args(["--quiet", "events", "--tx-hash", tx_hash, "--rpc-url", &endpoint]) + .args(["--quiet", "events", tx_hash, "--rpc-url", &endpoint]) .assert_success() .stdout_eq(str![[r#" -[block 2, tx 0x[..], log 0] 0x5FbDB2315678afecb367f032d93F642f64180aa3::Transfer(from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, to: 0x5FbDB2315678afecb367f032d93F642f64180aa3, amount: 42) +[block 2, tx 0x[..], log 0] 0x5FbDB2315678afecb367f032d93F642f64180aa3::Transfer(address,address,uint256) { from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, to: 0x5FbDB2315678afecb367f032d93F642f64180aa3, amount: 42 } "#]]); }); diff --git a/crates/common/src/abi.rs b/crates/common/src/abi.rs index ec535b2b36854..349dbb7e4ed25 100644 --- a/crates/common/src/abi.rs +++ b/crates/common/src/abi.rs @@ -3,13 +3,11 @@ use alloy_chains::Chain; use alloy_dyn_abi::{DynSolType, DynSolValue, FunctionExt, JsonAbiExt}; use alloy_json_abi::{Error, Event, Function, Param}; -use alloy_primitives::{Address, LogData, hex, map::HashSet}; +use alloy_primitives::{Address, LogData, hex}; use eyre::{Context, ContextCompat, Result}; use foundry_block_explorers::{Client, contract::ContractMetadata, errors::EtherscanError}; use std::pin::Pin; -const MAX_PROXY_DEPTH: usize = 16; - pub fn encode_args(inputs: &[Param], args: I) -> Result> where I: IntoIterator, @@ -168,73 +166,33 @@ pub async fn get_func_etherscan( Err(eyre::eyre!("Function not found in abi")) } -/// If the code at `address` is a proxy, recurse through its implementations and return metadata for -/// the full chain, with the final implementation first. +/// If the code at `address` is a proxy, recurse until we find the implementation. pub fn find_source( client: Client, address: Address, -) -> Pin> + Send>> { - Box::pin(async move { - find_source_inner(client, address, HashSet::default(), 0, true).await.map_err(Into::into) - }) -} - -/// The same as [`find_source`], but does not report proxy traversal status to the user. -pub fn find_source_quiet( - client: Client, - address: Address, -) -> Pin> + Send>> { - find_source_inner(client, address, HashSet::default(), 0, false) -} - -fn find_source_inner( - client: Client, - address: Address, - mut visited: HashSet

, - depth: usize, - report_proxy: bool, -) -> Pin> + Send>> { +) -> Pin>>> { Box::pin(async move { - if depth >= MAX_PROXY_DEPTH { - return Err(EtherscanError::Unknown(format!( - "proxy chain exceeds maximum depth of {MAX_PROXY_DEPTH}" - ))); - } - if !visited.insert(address) { - return Err(EtherscanError::Unknown(format!("proxy cycle detected at {address}"))); - } - trace!(%address, "find Etherscan source"); let source = client.contract_source_code(address).await?; - let metadata = source - .items - .first() - .ok_or_else(|| EtherscanError::Unknown("Etherscan returned no data".to_string()))?; + let metadata = source.items.first().wrap_err("Etherscan returned no data")?; if metadata.proxy == 0 { Ok(source) } else { - let implementation = metadata.implementation.ok_or_else(|| { - EtherscanError::Unknown(format!("proxy at {address} has no implementation address")) - })?; - if report_proxy { - sh_status!( - "Contract at {address} is a proxy, trying to fetch source at {implementation}..." - ) - .map_err(|err| EtherscanError::Unknown(err.to_string()))?; - } - match find_source_inner(client, implementation, visited, depth + 1, report_proxy).await - { - Ok(mut impl_source) => { - impl_source.items.extend(source.items); - Ok(impl_source) + let implementation = metadata.implementation.unwrap(); + sh_println!( + "Contract at {address} is a proxy, trying to fetch source at {implementation}..." + )?; + match find_source(client, implementation).await { + impl_source @ Ok(_) => impl_source, + Err(e) => { + let err = EtherscanError::ContractCodeNotVerified(address).to_string(); + if e.to_string() == err { + error!(%err); + Ok(source) + } else { + Err(e) + } } - Err(EtherscanError::ContractCodeNotVerified(unverified)) - if unverified == implementation => - { - error!(%implementation, "implementation source code not verified"); - Ok(source) - } - Err(err) => Err(err), } } }) @@ -251,50 +209,6 @@ mod tests { use super::*; use alloy_dyn_abi::EventExt; use alloy_primitives::{B256, U256}; - use axum::{ - Json, Router, - extract::{Query, State}, - routing::get, - }; - use serde_json::{Value, json}; - use std::{collections::HashMap as StdHashMap, sync::Arc}; - use tokio::task::JoinHandle; - - fn source_response(name: &str, abi: Value, implementation: Option
) -> Value { - let mut metadata = json!({ - "SourceCode": "", - "ABI": abi.to_string(), - "ContractName": name, - "CompilerVersion": "v0.8.26+commit.8a97fa7a", - "OptimizationUsed": "0", - "OptimizationRuns": "0", - "ConstructorArguments": "", - "EVMVersion": "Default", - "IsProxy": if implementation.is_some() { "1" } else { "0" } - }); - if let Some(implementation) = implementation { - metadata["Implementation"] = json!(implementation); - } - json!({ "status": "1", "message": "OK", "result": [metadata] }) - } - - async fn explorer_client(responses: StdHashMap) -> (Client, JoinHandle<()>) { - async fn handler( - State(responses): State>>, - Query(query): Query>, - ) -> Json { - let address = query["address"].parse::
().unwrap(); - Json(responses[&address].clone()) - } - - let app = Router::new().route("/", get(handler)).with_state(Arc::new(responses)); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - let client = - Client::builder().with_api_url(&url).unwrap().with_url(&url).unwrap().build().unwrap(); - (client, handle) - } #[test] fn test_get_func() { @@ -400,84 +314,4 @@ mod tests { assert!(res.is_err()); assert!(format!("{}", res.unwrap_err()).contains("encode length mismatch")); } - - #[tokio::test] - async fn find_source_accumulates_proxy_chain_metadata() { - let proxy = Address::repeat_byte(0x11); - let implementation = Address::repeat_byte(0x22); - let responses = StdHashMap::from([ - ( - proxy, - source_response( - "Proxy", - json!([{ - "anonymous": false, - "inputs": [], - "name": "ProxyEvent", - "type": "event" - }]), - Some(implementation), - ), - ), - ( - implementation, - source_response( - "Implementation", - json!([{ - "anonymous": false, - "inputs": [], - "name": "ImplementationEvent", - "type": "event" - }]), - None, - ), - ), - ]); - let (client, server) = explorer_client(responses).await; - - let source = find_source(client, proxy).await.unwrap(); - server.abort(); - - assert_eq!(source.items.len(), 2); - assert_eq!(source.items[0].contract_name, "Implementation"); - assert_eq!(source.items[1].contract_name, "Proxy"); - assert!(source.items.iter().all(|item| item.abi().unwrap().events().count() == 1)); - } - - #[tokio::test] - async fn find_source_retains_proxy_metadata_for_unverified_implementation() { - let proxy = Address::repeat_byte(0x11); - let implementation = Address::repeat_byte(0x22); - let responses = StdHashMap::from([ - (proxy, source_response("Proxy", json!([]), Some(implementation))), - ( - implementation, - json!({ - "status": "0", - "message": "NOTOK", - "result": "Contract source code not verified" - }), - ), - ]); - let (client, server) = explorer_client(responses).await; - - let source = find_source(client, proxy).await.unwrap(); - server.abort(); - - assert_eq!(source.items.len(), 1); - assert_eq!(source.items[0].contract_name, "Proxy"); - } - - #[tokio::test] - async fn find_source_rejects_proxy_without_implementation() { - let proxy = Address::repeat_byte(0x11); - let mut response = source_response("Proxy", json!([]), None); - response["result"][0]["IsProxy"] = json!("1"); - let (client, server) = explorer_client(StdHashMap::from([(proxy, response)])).await; - - let error = find_source(client, proxy).await.unwrap_err(); - server.abort(); - - assert!(error.to_string().contains("has no implementation address")); - } } diff --git a/crates/evm/traces/src/decoder/mod.rs b/crates/evm/traces/src/decoder/mod.rs index 498a76cd4457a..553d1937ea3e3 100644 --- a/crates/evm/traces/src/decoder/mod.rs +++ b/crates/evm/traces/src/decoder/mod.rs @@ -64,7 +64,6 @@ use monad::{IMonadStaking, IMonadStakingSyscalls, IReserveBalance}; type MonadHardfork = (); type AddressEvents = HashMap>>; type AddressAnonymousEvents = HashMap>>; -type AnonymousEvents = BTreeMap>; /// Build a new [CallTraceDecoder]. #[derive(Default)] @@ -94,10 +93,12 @@ impl CallTraceDecoderBuilder { self } - /// Add an ABI for a specific contract address. + /// Add events from an ABI for a specific contract address. #[inline] - pub fn with_address_abi(mut self, address: Address, abi: &JsonAbi) -> Self { - self.decoder.collect_abi(abi, Some(address), false); + 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 } @@ -249,8 +250,6 @@ pub struct CallTraceDecoder { pub events: BTreeMap<(B256, usize), Vec>, /// Events identified for a specific contract address. events_by_address: Option>, - /// All known anonymous events, keyed by topic count. - anonymous_events: AnonymousEvents, /// Anonymous events identified for a specific contract address, keyed by topic count. anonymous_events_by_address: Option>, /// Revert decoder. Contains all known custom errors. @@ -474,7 +473,6 @@ impl CallTraceDecoder { constructor_args_offsets: Default::default(), events, events_by_address: None, - anonymous_events: Default::default(), anonymous_events_by_address: None, // Decode Tempo precompile custom errors by name in traces. revert_decoder: RevertDecoder::new().with_abis(tempo_abis.iter()), @@ -587,13 +585,6 @@ impl CallTraceDecoder { /// Adds a single event to the decoder. pub fn push_event(&mut self, event: Event) { - if event.anonymous { - let events = self.anonymous_events.entry(indexed_inputs(&event)).or_default(); - if !events.contains(&event) { - events.push(event); - } - return; - } self.events.entry((event.selector(), indexed_inputs(&event))).or_default().push(event); } @@ -822,17 +813,10 @@ impl CallTraceDecoder { { self.constructors_by_address.entry(address).or_insert_with(|| constructor.clone()); } - for event in abi.events() { - if let Some(address) = address - && !global - { - self.push_address_event(address, event.clone()); - } - if global { + if global { + for event in abi.events() { self.push_event(event.clone()); } - } - if global { for error in abi.errors() { self.push_error(error.clone()); } @@ -1389,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. @@ -1398,10 +1382,24 @@ impl CallTraceDecoder { address: Address, log: &LogData, ) -> DecodedCallLog { - self.decode_event_inner(Some(address), log).await + 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) -> DecodedCallLog { + 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 @@ -1411,18 +1409,21 @@ impl CallTraceDecoder { }); let anonymous_events = address .and_then(|address| self.anonymous_events_by_address.as_deref()?.get(&address)) - .and_then(|events| events.get(&log.topics().len())) - .or_else(|| self.anonymous_events.get(&log.topics().len())); + .and_then(|events| events.get(&log.topics().len())); - if let Some(decoded) = - self.decode_event_candidates(address, log, regular_events.into_iter().flatten()) - { + if let Some(decoded) = self.decode_event_candidates( + address, + log, + regular_events.into_iter().flatten(), + canonical_signature, + ) { return decoded; } if let Some(decoded) = self.decode_unique_event_candidates( address, log, anonymous_events.into_iter().flatten(), + canonical_signature, ) { return decoded; } @@ -1436,7 +1437,9 @@ impl CallTraceDecoder { && let Some(event) = identifier.identify_event(topic).await { let event = get_indexed_event(event, log); - if let Some(decoded) = self.decode_event_candidates(address, log, [&event]) { + if let Some(decoded) = + self.decode_event_candidates(address, log, [&event], canonical_signature) + { return decoded; } } @@ -1449,12 +1452,17 @@ impl CallTraceDecoder { 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 Some(DecodedCallLog { - name: Some(event.name.clone()), + name: Some(if canonical_signature { + event.signature() + } else { + event.name.clone() + }), params: Some( params .into_iter() @@ -1486,10 +1494,11 @@ impl CallTraceDecoder { 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])); + 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) } @@ -1895,27 +1904,41 @@ mod tests { } #[tokio::test] - async fn globally_registered_anonymous_event_decodes() { - let abi = JsonAbi::parse(["event AnonymousValue(uint256 value) anonymous"]).unwrap(); - let decoder = CallTraceDecoderBuilder::new().with_abi(&abi).build(); - let log = LogData::new_unchecked(Vec::new(), (U256::from(7),).abi_encode().into()); - - let decoded = decoder.decode_event(&log).await; + 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(); - assert_eq!(decoded.name.as_deref(), Some("AnonymousValue")); - assert_eq!(decoded.params.unwrap(), [("value".to_string(), "7".to_string())]); + 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 ambiguous_anonymous_events_do_not_decode() { + 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_abi(address, &abi).build(); - let log = LogData::new_unchecked(Vec::new(), (U256::from(7),).abi_encode().into()); + let decoder = CallTraceDecoderBuilder::new().with_address_events(address, &abi).build(); let decoded = decoder.decode_event_with_address(address, &log).await; diff --git a/crates/evm/traces/src/identifier/external.rs b/crates/evm/traces/src/identifier/external.rs index 20b6a727628ea..54e3d354d5c66 100644 --- a/crates/evm/traces/src/identifier/external.rs +++ b/crates/evm/traces/src/identifier/external.rs @@ -6,11 +6,8 @@ use alloy_primitives::{ map::{Entry, HashMap, HashSet}, }; use eyre::WrapErr; -use foundry_block_explorers::{ - contract::{ContractMetadata, Metadata}, - errors::EtherscanError, -}; -use foundry_common::{abi::find_source_quiet, compile::etherscan_project}; +use foundry_block_explorers::{contract::Metadata, errors::EtherscanError}; +use foundry_common::compile::etherscan_project; use foundry_config::{Chain, Config}; use futures::{ future::join_all, @@ -33,7 +30,7 @@ use tokio::time::{Duration, Interval}; pub struct ExternalIdentifier { fetchers: Vec>, /// Cached contracts. - contracts: HashMap)>, + contracts: HashMap)>, /// Remaining time external identification may block trace rendering. remaining_budget: Duration, } @@ -98,8 +95,8 @@ impl ExternalIdentifier { .contracts .iter() // filter out vyper files and contracts without metadata - .filter_map(|(addr, (_, source))| { - if let Some(metadata) = source.as_ref().and_then(|source| source.items.first()) + .filter_map(|(addr, (_, metadata))| { + if let Some(metadata) = metadata.as_ref() && !metadata.is_vyper() { Some((*addr, metadata)) @@ -147,10 +144,8 @@ impl ExternalIdentifier { fn identify_from_metadata( &self, address: Address, - source: &ContractMetadata, + metadata: &Metadata, ) -> IdentifiedAddress<'static> { - // Proxy-chain metadata is ordered final implementation first and queried address last. - let metadata = source.items.last().expect("fetched source has metadata"); let label = metadata.contract_name.clone(); let abi = metadata.abi().ok().map(Cow::Owned); IdentifiedAddress { @@ -163,7 +158,7 @@ impl ExternalIdentifier { } } - fn cache_fetched(&mut self, address: Address, value: (FetcherKind, Option)) { + fn cache_fetched(&mut self, address: Address, value: (FetcherKind, Option)) { match self.contracts.entry(address) { Entry::Occupied(mut occupied_entry) => { let old = occupied_entry.get(); @@ -220,18 +215,63 @@ impl ExternalIdentifier { &mut self, addresses: &[Address], ) -> Vec<(Address, eyre::Result>)> { - self.fetch_addresses_async(addresses).await; - addresses + const MAX_PROXY_DEPTH: usize = 16; + + struct Chain { + current: Option
, + visited: HashSet
, + abis: Vec, + } + + let mut chains = addresses .iter() - .map(|&address| { - let result = match self.contracts.get(&address) { - Some((_, Some(source))) => source - .items - .iter() - .map(|metadata| metadata.abi().map_err(Into::into)) - .collect(), - Some((_, None)) => Err(eyre::eyre!("contract source code not verified")), - None => Err(eyre::eyre!("external ABI lookup failed")), + .map(|&address| Chain { + current: Some(address), + visited: HashSet::default(), + abis: Vec::new(), + }) + .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; + continue; + } + let Some((_, Some(metadata))) = self.contracts.get(¤t) else { + chain.current = None; + continue; + }; + if let Ok(abi) = metadata.abi() { + chain.abis.push(abi); + } + chain.current = (metadata.proxy != 0).then_some(metadata.implementation).flatten(); + has_next |= chain.current.is_some(); + } + if !has_next { + break; + } + } + + chains + .into_iter() + .zip(addresses.iter().copied()) + .map(|(chain, address)| { + let result = if chain.abis.is_empty() { + Err(eyre::eyre!("external ABI lookup failed")) + } else { + Ok(chain.abis.into_iter().rev().collect()) }; (address, result) }) @@ -286,7 +326,7 @@ impl TraceIdentifier for ExternalIdentifier { } type FetchFuture = - Pin, EtherscanError>)>>>; + Pin, EtherscanError>)>>>; /// Maximum number of times a single address is retried through a transient Cloudflare /// block before we give up on it. Bounded so a persistent block can't loop forever. @@ -343,7 +383,7 @@ impl ExternalFetcher { } impl Stream for ExternalFetcher { - type Item = (Address, (FetcherKind, Option)); + type Item = (Address, (FetcherKind, Option)); fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let pin = self.get_mut(); @@ -438,7 +478,7 @@ trait ExternalFetcherT: Send + Sync { fn timeout(&self) -> Duration; fn concurrency(&self) -> usize; fn invalid_api_key(&self) -> &AtomicBool; - async fn fetch(&self, address: Address) -> Result, EtherscanError>; + async fn fetch(&self, address: Address) -> Result, EtherscanError>; } struct EtherscanFetcher { @@ -470,8 +510,8 @@ impl ExternalFetcherT for EtherscanFetcher { &self.invalid_api_key } - async fn fetch(&self, address: Address) -> Result, EtherscanError> { - find_source_quiet(self.client.clone(), address).await.map(Some) + async fn fetch(&self, address: Address) -> Result, EtherscanError> { + self.client.contract_source_code(address).await.map(|mut metadata| metadata.items.pop()) } } @@ -509,7 +549,7 @@ impl ExternalFetcherT for SourcifyFetcher { &self.invalid_api_key } - async fn fetch(&self, address: Address) -> Result, EtherscanError> { + async fn fetch(&self, address: Address) -> Result, EtherscanError> { let url = format!("{url}/{address}?fields=abi,compilation", url = self.url); let response = self .client @@ -529,9 +569,7 @@ impl ExternalFetcherT for SourcifyFetcher { response.json().await.map_err(|e| EtherscanError::Unknown(e.to_string()))?; trace!(target: "evm::traces::external", "Sourcify response for {address}: {response:#?}"); match response { - SourcifyResponse::Success(metadata) => { - Ok(Some(ContractMetadata { items: vec![metadata.into()] })) - } + SourcifyResponse::Success(metadata) => Ok(Some(metadata.into())), SourcifyResponse::Error(error) => Err(EtherscanError::Unknown(format!("{error:#?}"))), } } @@ -637,16 +675,13 @@ mod tests { &self.invalid } - async fn fetch( - &self, - _address: Address, - ) -> Result, EtherscanError> { + async fn fetch(&self, _address: Address) -> Result, EtherscanError> { self.calls.fetch_add(1, AtomicOrdering::Relaxed); let Some(delay) = self.delay else { return pending().await }; if !delay.is_zero() { tokio::time::sleep(delay).await; } - Ok(self.contract_name.map(contract_metadata)) + Ok(self.contract_name.map(metadata)) } } @@ -673,37 +708,15 @@ mod tests { &self.invalid } - async fn fetch( - &self, - _address: Address, - ) -> Result, EtherscanError> { + async fn fetch(&self, _address: Address) -> Result, EtherscanError> { self.calls.fetch_add(1, AtomicOrdering::Relaxed); Err(EtherscanError::RateLimitExceeded) } } - fn contract_metadata(contract_name: &str) -> ContractMetadata { - let metadata = SourcifyMetadata { - abi: None, - compilation: Some(Compilation { - compiler_version: String::new(), - name: contract_name.to_string(), - }), - } - .into(); - ContractMetadata { items: vec![metadata] } - } - - fn metadata_with_event(contract_name: &str, event_name: &str) -> Metadata { - let abi = serde_json::value::to_raw_value(&serde_json::json!([{ - "anonymous": false, - "inputs": [], - "name": event_name, - "type": "event" - }])) - .unwrap(); + fn metadata(contract_name: &str) -> Metadata { SourcifyMetadata { - abi: Some(abi), + abi: None, compilation: Some(Compilation { compiler_version: String::new(), name: contract_name.to_string(), @@ -748,10 +761,7 @@ mod tests { fn invalid_api_key(&self) -> &AtomicBool { &self.invalid } - async fn fetch( - &self, - address: Address, - ) -> Result, EtherscanError> { + async fn fetch(&self, address: Address) -> Result, EtherscanError> { let first_time = self.seen.lock().unwrap().insert(address); if first_time { Err(EtherscanError::BlockedByCloudflare) } else { Ok(None) } } @@ -802,7 +812,7 @@ mod tests { assert!(identifier.remaining_budget.is_zero()); assert_eq!( - identifier.contracts[&address].1.as_ref().unwrap().items[0].contract_name, + identifier.contracts[&address].1.as_ref().unwrap().contract_name, "PartialResult" ); assert_eq!(successful_calls.load(AtomicOrdering::Relaxed), 1); @@ -885,52 +895,51 @@ mod tests { let address = Address::with_last_byte(1); let mut identifier = test_identifier(Vec::new(), Duration::ZERO); - identifier.cache_fetched( - address, - (FetcherKind::Sourcify, Some(contract_metadata("SourcifyResult"))), - ); + identifier + .cache_fetched(address, (FetcherKind::Sourcify, Some(metadata("SourcifyResult")))); identifier.cache_fetched(address, (FetcherKind::Etherscan, None)); assert_eq!( - identifier.contracts[&address].1.as_ref().unwrap().items[0].contract_name, + identifier.contracts[&address].1.as_ref().unwrap().contract_name, "SourcifyResult" ); - identifier.cache_fetched( - address, - (FetcherKind::Etherscan, Some(contract_metadata("EtherscanResult"))), - ); + identifier + .cache_fetched(address, (FetcherKind::Etherscan, Some(metadata("EtherscanResult")))); assert_eq!( - identifier.contracts[&address].1.as_ref().unwrap().items[0].contract_name, + identifier.contracts[&address].1.as_ref().unwrap().contract_name, "EtherscanResult" ); } #[tokio::test] async fn proxy_metadata_preserves_address_identity_and_all_abis() { - let address = Address::with_last_byte(1); - let source = ContractMetadata { - items: vec![ - metadata_with_event("Implementation", "ImplementationEvent"), - metadata_with_event("Proxy", "ProxyEvent"), - ], - }; + 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(address, &source); + let identity = identifier.identify_from_metadata(proxy, &proxy_metadata); assert_eq!(identity.contract.as_deref(), Some("Proxy")); - identifier.cache_fetched(address, (FetcherKind::Etherscan, Some(source))); + identifier.cache_fetched(proxy, (FetcherKind::Etherscan, Some(proxy_metadata))); + identifier + .cache_fetched(implementation_address, (FetcherKind::Etherscan, Some(implementation))); - let mut results = identifier.get_abis(&[address]).await; + let mut results = identifier.get_abis(&[proxy]).await; let (result_address, abis) = results.pop().unwrap(); let event_names = abis .unwrap() .into_iter() - .flat_map(|abi| abi.events.into_keys()) - .collect::>(); + .map(|abi| abi.events.into_keys().next().unwrap()) + .collect::>(); - assert_eq!(result_address, address); - assert_eq!( - event_names, - StdHashSet::from(["ImplementationEvent".to_string(), "ProxyEvent".to_string()]) - ); + assert_eq!(result_address, proxy); + assert_eq!(event_names, ["ImplementationEvent", "ProxyEvent"]); } } From cf248229d826c13cd8d7cfbc6d6e1d8de68a40c9 Mon Sep 17 00:00:00 2001 From: steven Date: Wed, 19 Aug 2026 21:10:52 -0600 Subject: [PATCH 08/11] fix(cast): reject ambiguous event decoding --- crates/evm/traces/src/decoder/mod.rs | 41 ++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/evm/traces/src/decoder/mod.rs b/crates/evm/traces/src/decoder/mod.rs index 553d1937ea3e3..3ea309bf12b42 100644 --- a/crates/evm/traces/src/decoder/mod.rs +++ b/crates/evm/traces/src/decoder/mod.rs @@ -1411,14 +1411,22 @@ impl CallTraceDecoder { .and_then(|address| self.anonymous_events_by_address.as_deref()?.get(&address)) .and_then(|events| events.get(&log.topics().len())); - if let Some(decoded) = self.decode_event_candidates( - address, - log, - regular_events.into_iter().flatten(), - canonical_signature, - ) { + let decoded = if canonical_signature { + self.decode_unique_event_candidates( + address, + log, + regular_events.into_iter().flatten(), + true, + ) + } else { + self.decode_event_candidates(address, log, regular_events.into_iter().flatten(), false) + }; + if let Some(decoded) = decoded { return decoded; } + if canonical_signature && regular_events.is_some() { + return DecodedCallLog { name: None, params: None }; + } if let Some(decoded) = self.decode_unique_event_candidates( address, log, @@ -1921,6 +1929,27 @@ mod tests { } } + #[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()); + } + #[tokio::test] async fn address_scoped_anonymous_events_require_a_unique_match() { let address = Address::from([0x12; 20]); From bf6b624364e2774c285b8216f8d4c5636c76946b Mon Sep 17 00:00:00 2001 From: steven Date: Thu, 20 Aug 2026 09:25:59 -0600 Subject: [PATCH 09/11] fix(cast): harden event decoding --- crates/cast/src/cmd/events.rs | 5 ++- crates/evm/traces/src/decoder/mod.rs | 31 +++++++++++----- crates/evm/traces/src/identifier/external.rs | 35 +++++++++++++------ .../evm/traces/src/identifier/signatures.rs | 13 ++++--- 4 files changed, 60 insertions(+), 24 deletions(-) diff --git a/crates/cast/src/cmd/events.rs b/crates/cast/src/cmd/events.rs index cf9530be5f8bf..9d5c45901e530 100644 --- a/crates/cast/src/cmd/events.rs +++ b/crates/cast/src/cmd/events.rs @@ -114,7 +114,10 @@ async fn decode_logs( logs.iter().map(Log::address).collect::>().into_iter().collect::>(); for (address, result) in identifier.get_abis(&addresses).await { match result { - Ok(abis) => { + 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); } diff --git a/crates/evm/traces/src/decoder/mod.rs b/crates/evm/traces/src/decoder/mod.rs index 3ea309bf12b42..7c95d90465af7 100644 --- a/crates/evm/traces/src/decoder/mod.rs +++ b/crates/evm/traces/src/decoder/mod.rs @@ -1412,21 +1412,20 @@ impl CallTraceDecoder { .and_then(|events| events.get(&log.topics().len())); let decoded = if canonical_signature { - self.decode_unique_event_candidates( - address, - log, - regular_events.into_iter().flatten(), - true, - ) + let mut decoded = regular_events.into_iter().flatten().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 }; + } + event } else { self.decode_event_candidates(address, log, regular_events.into_iter().flatten(), false) }; if let Some(decoded) = decoded { return decoded; } - if canonical_signature && regular_events.is_some() { - return DecodedCallLog { name: None, params: None }; - } if let Some(decoded) = self.decode_unique_event_candidates( address, log, @@ -1973,6 +1972,20 @@ mod tests { 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)")); } #[test] diff --git a/crates/evm/traces/src/identifier/external.rs b/crates/evm/traces/src/identifier/external.rs index 54e3d354d5c66..36263781e3e21 100644 --- a/crates/evm/traces/src/identifier/external.rs +++ b/crates/evm/traces/src/identifier/external.rs @@ -210,17 +210,18 @@ impl ExternalIdentifier { } } - /// Fetches all verified ABIs for each address using the configured external sources. + /// 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<(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 @@ -229,6 +230,7 @@ impl ExternalIdentifier { current: Some(address), visited: HashSet::default(), abis: Vec::new(), + complete: true, }) .collect::>(); @@ -247,16 +249,23 @@ impl ExternalIdentifier { 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 { @@ -267,11 +276,12 @@ impl ExternalIdentifier { chains .into_iter() .zip(addresses.iter().copied()) - .map(|(chain, address)| { + .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()) + Ok((chain.abis.into_iter().rev().collect(), chain.complete)) }; (address, result) }) @@ -932,14 +942,19 @@ mod tests { .cache_fetched(implementation_address, (FetcherKind::Etherscan, Some(implementation))); let mut results = identifier.get_abis(&[proxy]).await; - let (result_address, abis) = results.pop().unwrap(); - let event_names = abis - .unwrap() - .into_iter() - .map(|abi| abi.events.into_keys().next().unwrap()) - .collect::>(); + 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..d6f1c9888e597 100644 --- a/crates/evm/traces/src/identifier/signatures.rs +++ b/crates/evm/traces/src/identifier/signatures.rs @@ -342,12 +342,17 @@ impl SignaturesIdentifier { 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() { + if selectors.iter().any(|selector| !cache_r.contains_key(selector)) { drop(cache_r); let mut cache_w = self.0.cache.write().await; - if let Ok(res) = client.decode_selectors(&query).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()); } From 3b2ce552a1c1277b4d6b6eea92f0d81a489db20a Mon Sep 17 00:00:00 2001 From: steven Date: Thu, 20 Aug 2026 09:33:51 -0600 Subject: [PATCH 10/11] clippy --- .../evm/traces/src/identifier/signatures.rs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/evm/traces/src/identifier/signatures.rs b/crates/evm/traces/src/identifier/signatures.rs index d6f1c9888e597..e5093dee9da5a 100644 --- a/crates/evm/traces/src/identifier/signatures.rs +++ b/crates/evm/traces/src/identifier/signatures.rs @@ -341,25 +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 { - if 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()); - } + 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() } From 1410797a96dd47b73d62fcb31237c947ce2aaa0e Mon Sep 17 00:00:00 2001 From: steven Date: Thu, 20 Aug 2026 10:03:41 -0600 Subject: [PATCH 11/11] fix(cast): reject ambiguous event layouts --- crates/evm/traces/src/decoder/mod.rs | 84 ++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 22 deletions(-) diff --git a/crates/evm/traces/src/decoder/mod.rs b/crates/evm/traces/src/decoder/mod.rs index 7c95d90465af7..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")] @@ -1411,29 +1411,19 @@ impl CallTraceDecoder { .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 { - let mut decoded = regular_events.into_iter().flatten().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 }; - } - event + 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 let Some(decoded) = self.decode_unique_event_candidates( - address, - log, - anonymous_events.into_iter().flatten(), - canonical_signature, - ) { - return decoded; - } if regular_events.is_some() || anonymous_events.is_some() { return DecodedCallLog { name: None, params: None }; @@ -1443,10 +1433,14 @@ impl CallTraceDecoder { && let Some(identifier) = &self.signature_identifier && let Some(event) = identifier.identify_event(topic).await { - let event = get_indexed_event(event, log); - if let Some(decoded) = + 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; } } @@ -1699,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({})", @@ -1949,6 +1959,21 @@ mod tests { 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]); @@ -1986,6 +2011,21 @@ mod tests { .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]