From e38e6f6ee02e910bd00c5b90a1f0e6d33ad915c7 Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Sun, 3 Dec 2023 13:34:21 -0500 Subject: [PATCH 01/15] feat(inspect): init inspect --- cli/src/main.rs | 17 +++ common/src/ether/rpc.rs | 205 +++++++++++++++++++++++++- core/src/decode/mod.rs | 2 +- core/src/dump/util/mod.rs | 82 +---------- core/src/dump/util/threads/indexer.rs | 13 +- core/src/error.rs | 2 + core/src/inspect/mod.rs | 85 +++++++++++ core/src/lib.rs | 1 + 8 files changed, 316 insertions(+), 91 deletions(-) create mode 100644 core/src/inspect/mod.rs diff --git a/cli/src/main.rs b/cli/src/main.rs index 427cd7f6..14aeb727 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -27,6 +27,7 @@ use heimdall_core::{ decompile::{decompile, out::abi::ABIStructure, DecompilerArgs}, disassemble::{disassemble, DisassemblerArgs}, dump::{dump, DumpArgs}, + inspect::{inspect, InspectArgs}, snapshot::{snapshot, util::csv::generate_csv, SnapshotArgs}, }; use tui::{backend::CrosstermBackend, Terminal}; @@ -65,6 +66,13 @@ pub enum Subcommands { #[clap(name = "dump", about = "Dump the value of all storage slots accessed by a contract")] Dump(DumpArgs), + + #[clap( + name = "inspect", + about = "Detailed inspection of Ethereum transactions, including calldata & trace decoding, log visualization, and more" + )] + Inspect(InspectArgs), + #[clap( name = "snapshot", about = "Infer functiogn information from bytecode, including access control, gas @@ -330,6 +338,15 @@ async fn main() -> Result<(), Box> { } } + Subcommands::Inspect(mut cmd) => { + // if the user has not specified a rpc url, use the default + if cmd.rpc_url.as_str() == "" { + cmd.rpc_url = configuration.rpc_url; + } + + inspect(cmd).await?; + } + Subcommands::Config(cmd) => { config(cmd); } diff --git a/common/src/ether/rpc.rs b/common/src/ether/rpc.rs index 74aee27e..731a2bc1 100644 --- a/common/src/ether/rpc.rs +++ b/common/src/ether/rpc.rs @@ -5,7 +5,7 @@ use backoff::ExponentialBackoff; use ethers::{ core::types::Address, providers::{Http, Middleware, Provider}, - types::{Transaction, H256}, + types::{BlockTrace, StateDiff, TraceType, Transaction, H256}, }; use heimdall_cache::{read_cache, store_cache}; @@ -27,7 +27,7 @@ pub async fn chain_id(rpc_url: &str) -> Result> // get a new logger let logger = Logger::default(); -debug_max!(&format!("checking chain id for rpc url: '{}'", &rpc_url)); + debug_max!(&format!("checking chain id for rpc url: '{}'", &rpc_url)); // check the cache for a matching rpc url let cache_key = format!("chain_id.{}", &rpc_url.replace('/', "").replace(['.', ':'], "-")); @@ -158,6 +158,7 @@ pub async fn get_code( /// // let bytecode = get_code("0x0", "https://eth.llamarpc.com").await; /// // assert!(bytecode.is_ok()); /// ``` +/// TODO: check for caching pub async fn get_transaction( transaction_hash: &str, rpc_url: &str, @@ -171,7 +172,7 @@ pub async fn get_transaction( // get a new logger let logger = Logger::default(); -debug_max!(&format!( + debug_max!(&format!( "fetching calldata from node for transaction: '{}' .", &transaction_hash )); @@ -216,5 +217,201 @@ debug_max!(&format!( }) }) .await - .map_err(|_| Box::from("failed to fetch calldata")) + .map_err(|_| Box::from("failed to get transaction")) } + +/// Get the storage diff of the provided transaction hash +/// +/// ```no_run +/// use heimdall_common::ether::rpc::get_storage_diff; +/// +/// // let storage_diff = get_storage_diff("0x0", "https://eth.llamarpc.com").await; +/// // assert!(storage_diff.is_ok()); +/// ``` +pub async fn get_storage_diff( + transaction_hash: &str, + rpc_url: &str, +) -> Result, Box> { + backoff::future::retry( + ExponentialBackoff { + max_elapsed_time: Some(Duration::from_secs(10)), + ..ExponentialBackoff::default() + }, + || async { + // create new logger + let logger = Logger::default(); + + // get chain_id + let chain_id = chain_id(rpc_url).await.unwrap(); + + // check the cache for a matching address + if let Some(state_diff) = + read_cache(&format!("diff.{}.{}", &chain_id, &transaction_hash)) + { + debug_max!("found cached state diff for transaction '{}' .", &transaction_hash); + return Ok(state_diff); + } + + debug_max!(&format!( + "fetching storage diff from node for transaction: '{}' .", + &transaction_hash + )); + + // create new provider + let provider = match Provider::::try_from(rpc_url) { + Ok(provider) => provider, + Err(_) => { + logger.error(&format!("failed to connect to RPC provider '{}' .", &rpc_url)); + std::process::exit(1) + } + }; + + // safely unwrap the transaction hash + let transaction_hash_hex = match H256::from_str(transaction_hash) { + Ok(transaction_hash) => transaction_hash, + Err(_) => { + logger.error(&format!( + "failed to parse transaction hash '{}' .", + &transaction_hash + )); + std::process::exit(1) + } + }; + + // fetch the state diff for the transaction + let state_diff = match provider + .trace_replay_transaction(transaction_hash_hex, vec![TraceType::StateDiff]) + .await + { + Ok(traces) => traces.state_diff, + Err(e) => { + logger.error(&format!( + "failed to replay and trace transaction '{}' . does your RPC provider support it?", + &transaction_hash + )); + logger.error(&format!("error: '{e}' .")); + std::process::exit(1) + } + }; + + // write the state diff to the cache + let expiry = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 60 * 60 * 24 * 7; + store_cache( + &format!("diff.{}.{}", &chain_id, &transaction_hash), + &state_diff, + Some(expiry), + ); + + debug_max!("fetched state diff for transaction '{}' .", &transaction_hash); + + Ok(state_diff) + }, + ) + .await +} + +/// Get the raw trace data of the provided transaction hash +/// +/// ```no_run +/// use heimdall_common::ether::rpc::get_trace; +/// +/// // let trace = get_trace("0x0", "https://eth.llamarpc.com").await; +/// // assert!(trace.is_ok()); +/// ``` +/// TODO: check for caching +pub async fn get_trace( + transaction_hash: &str, + rpc_url: &str, +) -> Result> { + backoff::future::retry( + ExponentialBackoff { + max_elapsed_time: Some(Duration::from_secs(10)), + ..ExponentialBackoff::default() + }, + || async { + // create new logger + let logger = Logger::default(); + + // get chain_id + let chain_id = chain_id(rpc_url).await.unwrap(); + + // check the cache for a matching address + if let Some(block_trace) = + read_cache(&format!("trace.{}.{}", &chain_id, &transaction_hash)) + { + debug_max!("found cached trace for transaction '{}' .", &transaction_hash); + return Ok(block_trace); + } + + debug_max!(&format!( + "fetching trace from node for transaction: '{}' .", + &transaction_hash + )); + + // create new provider + let provider = match Provider::::try_from(rpc_url) { + Ok(provider) => provider, + Err(_) => { + logger.error(&format!("failed to connect to RPC provider '{}' .", &rpc_url)); + std::process::exit(1) + } + }; + + // safely unwrap the transaction hash + let transaction_hash_hex = match H256::from_str(transaction_hash) { + Ok(transaction_hash) => transaction_hash, + Err(_) => { + logger.error(&format!( + "failed to parse transaction hash '{}' .", + &transaction_hash + )); + std::process::exit(1) + } + }; + + // fetch the trace for the transaction + let block_trace = match provider + .trace_replay_transaction( + transaction_hash_hex, + vec![TraceType::StateDiff, TraceType::VmTrace, TraceType::Trace], + ) + .await + { + Ok(traces) => traces, + Err(e) => { + logger.error(&format!( + "failed to replay and trace transaction '{}' . does your RPC provider support it?", + &transaction_hash + )); + logger.error(&format!("error: '{e}' .")); + std::process::exit(1) + } + }; + + // write the trace to the cache + let expiry = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 60 * 60 * 24 * 7; + store_cache( + &format!("trace.{}.{}", &chain_id, &transaction_hash), + &block_trace, + Some(expiry), + ); + + debug_max!("fetched trace for transaction '{}' .", &transaction_hash); + + Ok(block_trace) + }, + ) + .await +} + +// TODO: add tests +#[cfg(test)] +pub mod tests {} diff --git a/core/src/decode/mod.rs b/core/src/decode/mod.rs index d9947a3e..871d19af 100644 --- a/core/src/decode/mod.rs +++ b/core/src/decode/mod.rs @@ -50,7 +50,7 @@ pub struct DecodeArgs { #[clap(flatten)] pub verbose: clap_verbosity_flag::Verbosity, - /// The RPC provider to use for fetching target bytecode. + /// The RPC provider to use for fetching target calldata. #[clap(long = "rpc-url", short, default_value = "", hide_default_value = true)] pub rpc_url: String, diff --git a/core/src/dump/util/mod.rs b/core/src/dump/util/mod.rs index 15ff7164..2ed01db3 100644 --- a/core/src/dump/util/mod.rs +++ b/core/src/dump/util/mod.rs @@ -2,23 +2,15 @@ pub mod csv; pub mod table; pub mod threads; -use std::{io, str::FromStr}; +use std::io; use crossterm::{ event::DisableMouseCapture, execute, terminal::{disable_raw_mode, LeaveAlternateScreen}, }; -use ethers::{ - providers::{Http, Middleware, Provider}, - types::{StateDiff, TraceType, H256}, -}; -use heimdall_cache::{read_cache, store_cache}; -use heimdall_common::utils::io::logging::Logger; use tui::{backend::CrosstermBackend, Terminal}; -use super::{structures::transaction::Transaction, DumpArgs}; - /// cleanup the terminal, disable raw mode, and leave the alternate screen pub fn cleanup_terminal() { let stdout = io::stdout(); @@ -28,75 +20,3 @@ pub fn cleanup_terminal() { execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture).unwrap(); terminal.show_cursor().unwrap(); } - -/// get the state diff for the given transaction -pub async fn get_storage_diff(tx: &Transaction, args: &DumpArgs) -> Option { - // create new logger - let (logger, _) = Logger::new(match args.verbose.log_level() { - Some(level) => level.as_str(), - None => "SILENT", - }); - - // get chain_id - let chain_id = heimdall_common::ether::rpc::chain_id(&args.rpc_url).await.unwrap(); - - // check the cache for a matching address - if let Some(state_diff) = read_cache(&format!("diff.{}.{}", &chain_id, &tx.hash)) { - debug_max!("found cached state diff for transaction '{}' .", &tx.hash); - return state_diff; - } - - // make sure the RPC provider isn't empty - if args.rpc_url.is_empty() { - cleanup_terminal(); - logger.error("fetching an on-chain transaction requires an RPC provider. Use `heimdall dump --help` for more information."); - std::process::exit(1); - } - - // create new provider - let provider = match Provider::::try_from(&args.rpc_url) { - Ok(provider) => provider, - Err(_) => { - cleanup_terminal(); - logger.error(&format!("failed to connect to RPC provider '{}' .", &args.rpc_url)); - std::process::exit(1) - } - }; - - // safely unwrap the transaction hash - let transaction_hash = match H256::from_str(&tx.hash) { - Ok(transaction_hash) => transaction_hash, - Err(_) => { - cleanup_terminal(); - logger.error(&format!("failed to parse transaction hash '{}' .", &tx.hash)); - std::process::exit(1) - } - }; - - // fetch the state diff for the transaction - let state_diff = - match provider.trace_replay_transaction(transaction_hash, vec![TraceType::StateDiff]).await - { - Ok(traces) => traces.state_diff, - Err(e) => { - cleanup_terminal(); - logger.error(&format!( - "failed to replay and trace transaction '{}' . does your RPC provider support it?", - &tx.hash - )); - logger.error(&format!("error: '{e}' .")); - std::process::exit(1) - } - }; - - // write the state diff to the cache - let expiry = - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + - 60 * 60 * 24 * 7; - store_cache(&format!("diff.{}.{}", &chain_id, &tx.hash), &state_diff, Some(expiry)); - - use heimdall_common::debug_max; - debug_max!("fetched state diff for transaction '{}' .", &tx.hash); - - state_diff -} diff --git a/core/src/dump/util/threads/indexer.rs b/core/src/dump/util/threads/indexer.rs index a9ccc22d..a7c32d9c 100644 --- a/core/src/dump/util/threads/indexer.rs +++ b/core/src/dump/util/threads/indexer.rs @@ -1,12 +1,13 @@ use std::time::Duration; use ethers::types::{Diff, H160}; -use heimdall_common::utils::{io::logging::Logger, threading::task_pool}; +use heimdall_common::{ + ether::rpc::get_storage_diff, + utils::{io::logging::Logger, threading::task_pool}, +}; use indicatif::ProgressBar; -use crate::dump::{ - constants::DUMP_STATE, structures::storage_slot::StorageSlot, util::get_storage_diff, -}; +use crate::dump::{constants::DUMP_STATE, structures::storage_slot::StorageSlot}; /// The main function for indexing storage slots. Will fetch the storage diff for each transaction /// in a threaded task pool, updating the state accordingly. @@ -39,7 +40,9 @@ pub async fn handle(addr_hash: H160) { let rt = tokio::runtime::Runtime::new().unwrap(); // get the storage diff for this transaction - let state_diff = rt.block_on(get_storage_diff(&tx, &args)); + let state_diff = rt + .block_on(get_storage_diff(&tx.hash, &args.rpc_url)) + .expect("Could not get storage diff."); // unlock state let mut state = DUMP_STATE.lock().unwrap(); diff --git a/core/src/error.rs b/core/src/error.rs index 755fd66c..5ad3021e 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -8,6 +8,8 @@ pub enum Error { BoundsError, #[error("DecodeError")] DecodeError, + #[error("RPCError: {0}")] + RpcError(String), #[error("Error: {0}")] GenericError(String), } diff --git a/core/src/inspect/mod.rs b/core/src/inspect/mod.rs new file mode 100644 index 00000000..336f6d00 --- /dev/null +++ b/core/src/inspect/mod.rs @@ -0,0 +1,85 @@ +use clap::{AppSettings, Parser}; +use derive_builder::Builder; + +use heimdall_common::{ + ether::rpc::{get_trace, get_transaction}, + utils::io::logging::Logger, +}; + +use crate::error::Error; + +#[derive(Debug, Clone, Parser, Builder)] +#[clap( + about = "Detailed inspection of Ethereum transactions, including calldata & trace decoding, log visualization, and more.", + after_help = "For more information, read the wiki: https://jbecker.dev/r/heimdall-rs/wiki", + global_setting = AppSettings::DeriveDisplayOrder, + override_usage = "heimdall inspect [OPTIONS]" +)] +pub struct InspectArgs { + /// The target transaction hash to inspect. + #[clap(required = true)] + pub target: String, + + /// Set the output verbosity level, 1 - 5. + #[clap(flatten)] + pub verbose: clap_verbosity_flag::Verbosity, + + /// The RPC provider to use for fetching target calldata. + #[clap(long = "rpc-url", short, default_value = "", hide_default_value = true)] + pub rpc_url: String, + + /// When prompted, always select the default value. + #[clap(long, short)] + pub default: bool, +} + +impl InspectArgsBuilder { + pub fn new() -> Self { + Self { + target: Some(String::new()), + verbose: Some(clap_verbosity_flag::Verbosity::new(0, 1)), + rpc_url: Some(String::new()), + default: Some(true), + } + } +} + +/// The entrypoint for the inspect module. This function will analyze the given transaction and +/// provide a detailed inspection of the transaction, including calldata & trace decoding, log +/// visualization, and more. +#[allow(deprecated)] +pub async fn inspect(args: InspectArgs) -> Result<(), Error> { + // set logger environment variable if not already set + // TODO: abstract this to a heimdall_common util + if std::env::var("RUST_LOG").is_err() { + std::env::set_var( + "RUST_LOG", + match args.verbose.log_level() { + Some(level) => level.as_str(), + None => "SILENT", + }, + ); + } + + // get a new logger and trace + let (_logger, _trace) = Logger::new(match args.verbose.log_level() { + Some(level) => level.as_str(), + None => "SILENT", + }); + + // get calldata from RPC + let transaction = get_transaction(&args.target, &args.rpc_url) + .await + .map_err(|e| Error::RpcError(e.to_string()))?; + + // get trace + let block_trace = + get_trace(&args.target, &args.rpc_url).await.map_err(|e| Error::RpcError(e.to_string()))?; + + println!("transaction: {:?}", transaction); + println!("output: {:?}", block_trace.output); + println!("trace: {:#?}", block_trace.trace); + println!("state diff: {:#?}", block_trace.state_diff); + + Ok(()) +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 23a2a4f3..dc3580f6 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -4,4 +4,5 @@ pub mod decompile; pub mod disassemble; pub mod dump; pub mod error; +pub mod inspect; pub mod snapshot; From 13b012d62146ee388a60f4d78c2986f8719f050f Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Wed, 6 Dec 2023 10:30:24 -0500 Subject: [PATCH 02/15] feat(inspect): init transaction tracing (and decoding) --- Cargo.lock | 1 + cache/Cargo.toml | 1 + cache/src/lib.rs | 20 +++--- common/src/ether/rpc.rs | 69 +++++++------------- common/src/ether/signatures.rs | 9 ++- common/src/utils/io/macros.rs | 2 +- common/src/utils/strings.rs | 29 +++++++++ core/src/inspect/mod.rs | 116 +++++++++++++++++++++++++++++---- 8 files changed, 180 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a494a03c..8d94667f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1650,6 +1650,7 @@ dependencies = [ "bincode", "clap", "serde", + "serde_json", ] [[package]] diff --git a/cache/Cargo.toml b/cache/Cargo.toml index 6fe491b3..5fb992a4 100644 --- a/cache/Cargo.toml +++ b/cache/Cargo.toml @@ -11,3 +11,4 @@ keywords = ["ethereum", "web3", "decompiler", "evm", "crypto"] clap = { version = "3.1.18", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } bincode = "1.3.3" +serde_json = "1.0.108" diff --git a/cache/src/lib.rs b/cache/src/lib.rs index ae8e575e..3a821270 100644 --- a/cache/src/lib.rs +++ b/cache/src/lib.rs @@ -194,13 +194,9 @@ where None => return None, }; - let binary_vec = decode_hex(&binary_string); + let binary_vec = decode_hex(&binary_string).ok()?; - if binary_vec.is_err() { - return None; - } - - let cache: Cache = match bincode::deserialize::>(&binary_vec.unwrap()) { + let cache: Cache = match bincode::deserialize::>(&binary_vec) { Ok(c) => { // check if the cache has expired, if so, delete it and return None if c.expiry < @@ -233,7 +229,11 @@ where /// store_cache("store_cache_key2", "value", Some(60 * 60 * 24)); /// ``` #[allow(deprecated)] -pub fn store_cache(key: &str, value: T, expiry: Option) +pub fn store_cache( + key: &str, + value: T, + expiry: Option, +) -> Result<(), Box> where T: Serialize, { let home = home_dir().unwrap(); @@ -247,9 +247,12 @@ where ); let cache = Cache { value, expiry }; - let encoded: Vec = bincode::serialize(&cache).unwrap(); + let encoded: Vec = bincode::serialize(&cache) + .map_err(|e| format!("Failed to serialize cache object: {:?}", e))?; let binary_string = encode_hex(encoded); write_file(cache_file.to_str().unwrap(), &binary_string); + + Ok(()) } /// Cache subcommand handler @@ -289,6 +292,7 @@ pub fn cache(args: CacheArgs) -> Result<(), Box> { } #[allow(deprecated)] +#[allow(unused_must_use)] #[cfg(test)] mod tests { use crate::{delete_cache, exists, keys, read_cache, store_cache}; diff --git a/common/src/ether/rpc.rs b/common/src/ether/rpc.rs index 731a2bc1..4f507064 100644 --- a/common/src/ether/rpc.rs +++ b/common/src/ether/rpc.rs @@ -61,7 +61,8 @@ pub async fn chain_id(rpc_url: &str) -> Result> }; // cache the results - store_cache(&cache_key, chain_id.as_u64(), None); + let _ = store_cache(&cache_key, chain_id.as_u64(), None) + .map_err(|_| logger.error(&format!("failed to cache chain id for rpc url: {:?}", &rpc_url))); debug_max!(&format!("chain_id is '{}'", &chain_id)); @@ -93,17 +94,16 @@ pub async fn get_code( let logger = Logger::default(); // get chain_id - let _chain_id = chain_id(rpc_url).await.unwrap_or(1); - - logger - .debug_max(&format!("fetching bytecode from node for contract: '{}' .", &contract_address)); + let chain_id = chain_id(rpc_url).await.unwrap_or(1); // check the cache for a matching address - if let Some(bytecode) = read_cache(&format!("contract.{}.{}", &_chain_id, &contract_address)) { + if let Some(bytecode) = read_cache(&format!("contract.{}.{}", &chain_id, &contract_address)) { logger.debug(&format!("found cached bytecode for '{}' .", &contract_address)); return Ok(bytecode) } + debug_max!("fetching bytecode from node for contract: '{}' .", &contract_address); + // make sure the RPC provider isn't empty if rpc_url.is_empty() { logger.error("reading on-chain data requires an RPC provider. Use `heimdall --help` for more information."); @@ -138,11 +138,12 @@ pub async fn get_code( }; // cache the results - store_cache( - &format!("contract.{}.{}", &_chain_id, &contract_address), + let _ = store_cache( + &format!("contract.{}.{}", &chain_id, &contract_address), bytecode_as_bytes.to_string().replacen("0x", "", 1), None, - ); + ) + .map_err(|_| logger.error(&format!("failed to cache bytecode for contract: {:?}", &contract_address))); Ok(bytecode_as_bytes.to_string()) }) @@ -193,7 +194,7 @@ pub async fn get_transaction( }; // safely unwrap the transaction hash - let transaction_hash = match H256::from_str(transaction_hash) { + let transaction_hash_hex = match H256::from_str(transaction_hash) { Ok(transaction_hash) => transaction_hash, Err(_) => { logger.error(&format!("failed to parse transaction hash '{}' .", &transaction_hash)); @@ -201,8 +202,8 @@ pub async fn get_transaction( } }; - // fetch the transaction from the node - Ok(match provider.get_transaction(transaction_hash).await { + // get the transaction + let tx = match provider.get_transaction(transaction_hash_hex).await { Ok(tx) => match tx { Some(tx) => tx, None => { @@ -214,7 +215,9 @@ pub async fn get_transaction( logger.error(&format!("failed to fetch calldata from '{}' .", &transaction_hash)); return Err(backoff::Error::Transient { err: (), retry_after: Some(Duration::from_secs(1)) }) } - }) + }; + + Ok(tx) }) .await .map_err(|_| Box::from("failed to get transaction")) @@ -295,16 +298,17 @@ pub async fn get_storage_diff( }; // write the state diff to the cache - let expiry = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() + - 60 * 60 * 24 * 7; - store_cache( + let _ = store_cache( &format!("diff.{}.{}", &chain_id, &transaction_hash), &state_diff, - Some(expiry), - ); + None, + ) + .map_err(|_| { + logger.error(&format!( + "failed to cache state diff for transaction: {:?}", + &transaction_hash + )) + }); debug_max!("fetched state diff for transaction '{}' .", &transaction_hash); @@ -336,17 +340,6 @@ pub async fn get_trace( // create new logger let logger = Logger::default(); - // get chain_id - let chain_id = chain_id(rpc_url).await.unwrap(); - - // check the cache for a matching address - if let Some(block_trace) = - read_cache(&format!("trace.{}.{}", &chain_id, &transaction_hash)) - { - debug_max!("found cached trace for transaction '{}' .", &transaction_hash); - return Ok(block_trace); - } - debug_max!(&format!( "fetching trace from node for transaction: '{}' .", &transaction_hash @@ -392,18 +385,6 @@ pub async fn get_trace( } }; - // write the trace to the cache - let expiry = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - + 60 * 60 * 24 * 7; - store_cache( - &format!("trace.{}.{}", &chain_id, &transaction_hash), - &block_trace, - Some(expiry), - ); - debug_max!("fetched trace for transaction '{}' .", &transaction_hash); Ok(block_trace) diff --git a/common/src/ether/signatures.rs b/common/src/ether/signatures.rs index c5a5cce6..7c5cbed7 100644 --- a/common/src/ether/signatures.rs +++ b/common/src/ether/signatures.rs @@ -106,7 +106,8 @@ impl ResolveSelector for ResolvedError { } // cache the results - store_cache(&format!("selector.{selector}"), signature_list.clone(), None); + let _ = store_cache(&format!("selector.{selector}"), signature_list.clone(), None) + .map_err(|e| debug_max!("error storing signatures in cache: {}", e)); match signature_list.len() { 0 => None, @@ -184,7 +185,8 @@ impl ResolveSelector for ResolvedLog { } // cache the results - store_cache(&format!("selector.{selector}"), signature_list.clone(), None); + let _ = store_cache(&format!("selector.{selector}"), signature_list.clone(), None) + .map_err(|e| debug_max!("error storing signatures in cache: {}", e)); match signature_list.len() { 0 => None, @@ -263,7 +265,8 @@ impl ResolveSelector for ResolvedFunction { } // cache the results - store_cache(&format!("selector.{selector}"), signature_list.clone(), None); + let _ = store_cache(&format!("selector.{selector}"), signature_list.clone(), None) + .map_err(|e| debug_max!("error storing signatures in cache: {}", e)); match signature_list.len() { 0 => None, diff --git a/common/src/utils/io/macros.rs b/common/src/utils/io/macros.rs index 3f70adc7..632e4ecf 100644 --- a/common/src/utils/io/macros.rs +++ b/common/src/utils/io/macros.rs @@ -4,6 +4,6 @@ macro_rules! debug_max { $crate::utils::io::logging::Logger::default().debug_max($message); }; ($message:expr, $($arg:tt)*) => { - $crate::utils::io::logging::Logger::default().debug_max(&format!($message, $($arg)*)); + $crate::utils::io::logging::Logger::default().debug_max(&format!($message, $($arg)*)) }; } diff --git a/common/src/utils/strings.rs b/common/src/utils/strings.rs index 168093d4..81820ae6 100644 --- a/common/src/utils/strings.rs +++ b/common/src/utils/strings.rs @@ -3,6 +3,7 @@ use std::{fmt::Write, num::ParseIntError}; use ethers::{ abi::AbiEncode, prelude::{I256, U256}, + types::{Bloom, H160, H256, H64}, }; use fancy_regex::Regex; @@ -309,6 +310,34 @@ pub fn tokenize(s: &str) -> Vec { tokens } +pub trait ToLowerHex { + fn to_lower_hex(&self) -> String; +} + +impl ToLowerHex for H256 { + fn to_lower_hex(&self) -> String { + format!("{:#032x}", self) + } +} + +impl ToLowerHex for H160 { + fn to_lower_hex(&self) -> String { + format!("{:#020x}", self) + } +} + +impl ToLowerHex for H64 { + fn to_lower_hex(&self) -> String { + format!("{:#016x}", self) + } +} + +impl ToLowerHex for Bloom { + fn to_lower_hex(&self) -> String { + format!("{:#064x}", self) + } +} + #[derive(Debug, PartialEq)] pub enum TokenType { Control, diff --git a/core/src/inspect/mod.rs b/core/src/inspect/mod.rs index 336f6d00..7d332368 100644 --- a/core/src/inspect/mod.rs +++ b/core/src/inspect/mod.rs @@ -1,12 +1,15 @@ +use std::collections::HashMap; + use clap::{AppSettings, Parser}; +use clap_verbosity_flag::Verbosity; use derive_builder::Builder; use heimdall_common::{ - ether::rpc::{get_trace, get_transaction}, - utils::io::logging::Logger, + ether::rpc::get_trace, + utils::{io::logging::Logger, strings::ToLowerHex}, }; -use crate::error::Error; +use crate::{decode::DecodeArgsBuilder, error::Error}; #[derive(Debug, Clone, Parser, Builder)] #[clap( @@ -62,24 +65,115 @@ pub async fn inspect(args: InspectArgs) -> Result<(), Error> { } // get a new logger and trace - let (_logger, _trace) = Logger::new(match args.verbose.log_level() { + let (_logger, mut trace) = Logger::new(match args.verbose.log_level() { Some(level) => level.as_str(), None => "SILENT", }); // get calldata from RPC - let transaction = get_transaction(&args.target, &args.rpc_url) - .await - .map_err(|e| Error::RpcError(e.to_string()))?; + // let transaction = get_transaction(&args.target, &args.rpc_url) + // .await + // .map_err(|e| Error::RpcError(e.to_string()))?; // get trace let block_trace = get_trace(&args.target, &args.rpc_url).await.map_err(|e| Error::RpcError(e.to_string()))?; - println!("transaction: {:?}", transaction); - println!("output: {:?}", block_trace.output); - println!("trace: {:#?}", block_trace.trace); - println!("state diff: {:#?}", block_trace.state_diff); + let decode_call = trace.add_call( + 0, + line!(), + "heimdall".to_string(), + "inspect".to_string(), + vec![args.target.clone()], + "()".to_string(), + ); + + if let Some(transaction_traces) = block_trace.trace { + let mut trace_indices = HashMap::new(); + + for transaction_trace in transaction_traces { + println!("trace: {:?}", transaction_trace); + let trace_address = transaction_trace + .trace_address + .iter() + .map(|address| address.to_string()) + .collect::>() + .join("."); + let parent_address = trace_address + .split('.') + .take(trace_address.split('.').count() - 1) + .collect::>() + .join("."); + + // get trace index from parent_address + let parent_index = trace_indices.get(&parent_address).unwrap_or(&decode_call); + + // get action + match transaction_trace.action { + ethers::types::Action::Call(call) => { + // attempt to decode calldata + let calldata = call.input.to_string(); + + if !calldata.replacen("0x", "", 1).is_empty() { + let result = crate::decode::decode( + DecodeArgsBuilder::new() + .target(calldata) + .rpc_url("https://eth.llamarpc.com".to_string()) + .verbose(Verbosity::new(2, 0)) + .build() + .map_err(|_e| Error::DecodeError)?, + ) + .await?; + + // get first result + if let Some(resolved_function) = result.first() { + // convert decoded inputs Option> to Vec + let decoded_inputs = + resolved_function.decoded_inputs.clone().unwrap_or_default(); + + // get index of parent + let parent_index = trace.add_call( + *parent_index, + line!(), + call.to.to_lower_hex(), + resolved_function.name.clone(), + decoded_inputs + .iter() + .map(|token| format!("{:?}", token)) + .collect::>(), + "()".to_string(), + ); + + // add trace_address to trace_indices + trace_indices.insert(trace_address.clone(), parent_index); + trace.add_info( + parent_index, + line!(), + &format!("trace_address: {:?}", trace_address), + ); + } else { + unimplemented!(); + } + } else { + // value transfer + trace.add_call( + *parent_index, + line!(), + call.to.to_lower_hex(), + "transfer".to_string(), + vec![format!("{} wei", call.value)], + "()".to_string(), + ); + } + } + ethers::types::Action::Create(_) => todo!(), + ethers::types::Action::Suicide(_) => todo!(), + ethers::types::Action::Reward(_) => todo!(), + } + } + } + + trace.display(); Ok(()) } From 8b651184def82c8b23663e282694ecc6302b237c Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Fri, 8 Dec 2023 20:36:12 -0500 Subject: [PATCH 03/15] feat(inspect): tracing, `Parameterize` trait --- cli/src/main.rs | 5 + common/src/ether/rpc.rs | 32 +++-- common/src/resources/transpose.rs | 59 +++++++- common/src/utils/hex.rs | 43 ++++++ common/src/utils/io/file.rs | 9 +- common/src/utils/io/logging.rs | 42 ++++++ common/src/utils/io/types.rs | 192 ++++++++++++++++++++++++++ common/src/utils/mod.rs | 1 + common/src/utils/strings.rs | 29 ---- core/src/decode/core/abi.rs | 1 + core/src/decode/mod.rs | 31 +++-- core/src/inspect/core/mod.rs | 1 + core/src/inspect/core/storage.rs | 3 + core/src/inspect/core/tracing.rs | 219 ++++++++++++++++++++++++++++++ core/src/inspect/mod.rs | 131 ++++-------------- core/tests/test_decode.rs | 6 + 16 files changed, 640 insertions(+), 164 deletions(-) create mode 100644 common/src/utils/hex.rs create mode 100644 core/src/inspect/core/mod.rs create mode 100644 core/src/inspect/core/storage.rs create mode 100644 core/src/inspect/core/tracing.rs diff --git a/cli/src/main.rs b/cli/src/main.rs index 14aeb727..246cb3e3 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -344,6 +344,11 @@ async fn main() -> Result<(), Box> { cmd.rpc_url = configuration.rpc_url; } + // if the user has not specified a transpose api key, use the default + if cmd.transpose_api_key.is_none() { + cmd.transpose_api_key = Some(configuration.transpose_api_key); + } + inspect(cmd).await?; } diff --git a/common/src/ether/rpc.rs b/common/src/ether/rpc.rs index 4f507064..7d0a8f17 100644 --- a/common/src/ether/rpc.rs +++ b/common/src/ether/rpc.rs @@ -39,7 +39,7 @@ pub async fn chain_id(rpc_url: &str) -> Result> // make sure the RPC provider isn't empty if rpc_url.is_empty() { logger.error("reading on-chain data requires an RPC provider. Use `heimdall --help` for more information."); - std::process::exit(1); + return Err(backoff::Error::Permanent(())) } // create new provider @@ -47,7 +47,7 @@ pub async fn chain_id(rpc_url: &str) -> Result> Ok(provider) => provider, Err(_) => { logger.error(&format!("failed to connect to RPC provider '{}' .", &rpc_url)); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; @@ -107,7 +107,7 @@ pub async fn get_code( // make sure the RPC provider isn't empty if rpc_url.is_empty() { logger.error("reading on-chain data requires an RPC provider. Use `heimdall --help` for more information."); - std::process::exit(1); + return Err(backoff::Error::Permanent(())) } // create new provider @@ -115,7 +115,7 @@ pub async fn get_code( Ok(provider) => provider, Err(_) => { logger.error(&format!("failed to connect to RPC provider '{}' .", &rpc_url)); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; @@ -124,7 +124,7 @@ pub async fn get_code( Ok(address) => address, Err(_) => { logger.error(&format!("failed to parse address '{}' .", &contract_address)); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; @@ -181,7 +181,7 @@ pub async fn get_transaction( // make sure the RPC provider isn't empty if rpc_url.is_empty() { logger.error("reading on-chain data requires an RPC provider. Use `heimdall --help` for more information."); - std::process::exit(1); + return Err(backoff::Error::Permanent(())); } // create new provider @@ -189,7 +189,7 @@ pub async fn get_transaction( Ok(provider) => provider, Err(_) => { logger.error(&format!("failed to connect to RPC provider '{}' .", &rpc_url)); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; @@ -198,7 +198,7 @@ pub async fn get_transaction( Ok(transaction_hash) => transaction_hash, Err(_) => { logger.error(&format!("failed to parse transaction hash '{}' .", &transaction_hash)); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; @@ -208,7 +208,7 @@ pub async fn get_transaction( Some(tx) => tx, None => { logger.error(&format!("transaction '{}' doesn't exist.", &transaction_hash)); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }, Err(_) => { @@ -265,7 +265,7 @@ pub async fn get_storage_diff( Ok(provider) => provider, Err(_) => { logger.error(&format!("failed to connect to RPC provider '{}' .", &rpc_url)); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; @@ -277,7 +277,7 @@ pub async fn get_storage_diff( "failed to parse transaction hash '{}' .", &transaction_hash )); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; @@ -293,7 +293,7 @@ pub async fn get_storage_diff( &transaction_hash )); logger.error(&format!("error: '{e}' .")); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; @@ -316,6 +316,7 @@ pub async fn get_storage_diff( }, ) .await + .map_err(|_| Box::from("failed to get storage diff")) } /// Get the raw trace data of the provided transaction hash @@ -350,7 +351,7 @@ pub async fn get_trace( Ok(provider) => provider, Err(_) => { logger.error(&format!("failed to connect to RPC provider '{}' .", &rpc_url)); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; @@ -362,7 +363,7 @@ pub async fn get_trace( "failed to parse transaction hash '{}' .", &transaction_hash )); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; @@ -381,7 +382,7 @@ pub async fn get_trace( &transaction_hash )); logger.error(&format!("error: '{e}' .")); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; @@ -391,6 +392,7 @@ pub async fn get_trace( }, ) .await + .map_err(|_| Box::from("failed to get trace")) } // TODO: add tests diff --git a/common/src/resources/transpose.rs b/common/src/resources/transpose.rs index 930d9c77..4688cc1d 100644 --- a/common/src/resources/transpose.rs +++ b/common/src/resources/transpose.rs @@ -21,7 +21,7 @@ struct TransposeResponse { } /// executes a transpose SQL query and returns the response -async fn _call_transpose(query: &str, api_key: &str) -> Option { +async fn call_transpose(query: &str, api_key: &str) -> Option { // get a new logger let logger = Logger::default(); @@ -115,7 +115,7 @@ pub async fn get_transaction_list( bounds.1 ); - let response = match _call_transpose(&query, api_key).await { + let response = match call_transpose(&query, api_key).await { Some(response) => response, None => { logger.error("failed to get transaction list from Transpose"); @@ -197,7 +197,7 @@ pub async fn get_contract_creation( "{{\"sql\":\"SELECT block_number, transaction_hash FROM {chain}.transactions WHERE TIMESTAMP = ( SELECT created_timestamp FROM {chain}.accounts WHERE address = '{address}' ) AND contract_address = '{address}'\",\"parameters\":{{}},\"options\":{{\"timeout\": 999999999}}}}", ); - let response = match _call_transpose(&query, api_key).await { + let response = match call_transpose(&query, api_key).await { Some(response) => response, None => { logger.error("failed to get creation tx from Transpose"); @@ -237,7 +237,58 @@ pub async fn get_contract_creation( } }; - return Some((block_number, transaction_hash)) + return Some((block_number, transaction_hash)); + }; + + None +} + +/// Get the label for the given address. +/// +/// ``` +/// use heimdall_common::resources::transpose::get_label; +/// +/// let address = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"; +/// let api_key = "YOUR_API_KEY"; +/// +/// // let label = get_label(address, api_key).await; +/// ``` +pub async fn get_label(address: &str, api_key: &str) -> Option { + // get a new logger + let logger = Logger::default(); + let start_time = Instant::now(); + + // build the SQL query + let query = format!( + "{{\"sql\":\"SELECT COALESCE( (SELECT name FROM ethereum.contract_labels WHERE contract_address = '{address}' ), (SELECT ens_name FROM ethereum.ens_names WHERE primary_address = '{address}' LIMIT 1), (SELECT protocol_name FROM ethereum.protocols WHERE contract_address = '{address}' ), (SELECT symbol FROM ethereum.tokens WHERE contract_address = '{address}' ), (SELECT symbol FROM ethereum.collections WHERE contract_address = '{address}' ) ) as label\",\"parameters\":{{}},\"options\":{{\"timeout\": 999999999}}}}", + ); + + let response = match call_transpose(&query, api_key).await { + Some(response) => response, + None => { + logger.error("failed to get contract label from Transpose"); + return None; + } + }; + + logger.debug(&format!("fetching contract creation took {:?}", start_time.elapsed())); + + // parse the results + if let Some(result) = response.results.into_iter().next() { + let label: String = match result.get("label") { + Some(label) => match label.as_str() { + Some(label) => label.to_string(), + None => { + logger.error("failed to parse label from Transpose"); + return None; + } + }, + None => { + logger.error("failed to fetch label from Transpose response"); + return None; + } + }; + return Some(label); }; None diff --git a/common/src/utils/hex.rs b/common/src/utils/hex.rs new file mode 100644 index 00000000..e0ef2b71 --- /dev/null +++ b/common/src/utils/hex.rs @@ -0,0 +1,43 @@ +use ethers::types::{Bloom, Bytes, H160, H256, H64}; + +use super::strings::encode_hex; + +pub trait ToLowerHex { + fn to_lower_hex(&self) -> String; +} + +impl ToLowerHex for H256 { + fn to_lower_hex(&self) -> String { + format!("{:#032x}", self) + } +} + +impl ToLowerHex for H160 { + fn to_lower_hex(&self) -> String { + format!("{:#020x}", self) + } +} + +impl ToLowerHex for H64 { + fn to_lower_hex(&self) -> String { + format!("{:#016x}", self) + } +} + +impl ToLowerHex for Bloom { + fn to_lower_hex(&self) -> String { + format!("{:#064x}", self) + } +} + +impl ToLowerHex for Bytes { + fn to_lower_hex(&self) -> String { + format!("{:#0x}", self) + } +} + +impl ToLowerHex for Vec { + fn to_lower_hex(&self) -> String { + encode_hex(self.to_vec()) + } +} diff --git a/common/src/utils/io/file.rs b/common/src/utils/io/file.rs index 4395c07c..bb5edf64 100644 --- a/common/src/utils/io/file.rs +++ b/common/src/utils/io/file.rs @@ -17,11 +17,10 @@ use std::{ /// assert_eq!(short_path, "./something.json"); /// ``` pub fn short_path(path: &str) -> String { - let current_dir = match env::current_dir() { - Ok(dir) => dir.into_os_string().into_string().unwrap(), - Err(_) => std::process::exit(1), - }; - path.replace(¤t_dir, ".") + match env::current_dir() { + Ok(dir) => path.replace(&dir.into_os_string().into_string().unwrap(), "."), + Err(_) => path.to_owned(), + } } /// Write contents to a file on the disc diff --git a/common/src/utils/io/logging.rs b/common/src/utils/io/logging.rs index 71023abb..20438c4f 100644 --- a/common/src/utils/io/logging.rs +++ b/common/src/utils/io/logging.rs @@ -255,6 +255,26 @@ impl TraceFactory { self.add("call", parent_index, instruction, vec![title, returns]) } + pub fn add_call_with_extra( + &mut self, + parent_index: u32, + instruction: u32, + origin: String, + function_name: String, + args: Vec, + returns: String, + extra: Vec, + ) -> u32 { + let title = format!( + "{}::{}({}) {}", + origin.bright_cyan(), + function_name.bright_cyan(), + args.join(", "), + extra.iter().map(|s| format!("[{}]", s)).collect::>().join(" ").dimmed() + ); + self.add("call", parent_index, instruction, vec![title, returns]) + } + /// adds a contract creation trace pub fn add_creation( &mut self, @@ -375,6 +395,28 @@ impl Default for Logger { } } +impl Default for TraceFactory { + fn default() -> Self { + // get the environment variable RUST_LOG and parse it + let level = match std::env::var("RUST_LOG") { + Ok(level) => match level.to_lowercase().as_str() { + "silent" => -1, + "error" => 0, + "warn" => 1, + "info" => 2, + "debug" => 3, + "trace" => 4, + "all" => 5, + "max" => 6, + _ => 1, + }, + Err(_) => 2, + }; + + TraceFactory::new(level) + } +} + impl Logger { /// create a new logger with the given verbosity pub fn new(verbosity: &str) -> (Logger, TraceFactory) { diff --git a/common/src/utils/io/types.rs b/common/src/utils/io/types.rs index 41e8e3d7..2a999471 100644 --- a/common/src/utils/io/types.rs +++ b/common/src/utils/io/types.rs @@ -1,6 +1,8 @@ use colored::Colorize; use ethers::abi::Token; +use crate::utils::hex::ToLowerHex; + /// A helper function used by the decode module to pretty format decoded tokens. pub fn display(inputs: Vec, prefix: &str) -> Vec { let mut output = Vec::new(); @@ -59,3 +61,193 @@ pub fn display(inputs: Vec, prefix: &str) -> Vec { output } + +pub trait Parameterize { + fn parameterize(&self) -> String; + fn to_type(&self) -> String; +} + +/// A helper function used by the decode module to pretty format decoded tokens. +/// +/// ``` +/// use ethers::abi::Token; +/// use ethers::types::Address; +/// use heimdall_common::utils::io::types::Parameterize; +/// +/// let output = Token::Address(Address::zero()).parameterize(); +/// assert_eq!(output, "address: 0x0000000000000000000000000000000000000000".to_string()); +/// ``` +impl Parameterize for Token { + fn parameterize(&self) -> String { + match self { + Token::Address(val) => format!("address: {}", val.to_lower_hex()), + Token::Int(val) => format!("int: {}", val), + Token::Uint(val) => format!("uint: {}", val), + Token::String(val) => format!("string: {}", val), + Token::Bool(val) => format!("bool: {}", val), + Token::Bytes(val) => format!("bytes: 0x{}", val.to_lower_hex()), + Token::FixedBytes(val) => format!("bytes{}: 0x{}", val.len(), val.to_lower_hex()), + Token::Array(val) => { + // get type of array + let array_type = match val.first() { + Some(token) => token.to_type(), + None => String::new(), + }; + + // parametrize all array elements, remove their `type: ` prefix, and join them + let elements = val + .iter() + .map(|token| token.parameterize().replace(&format!("{}: ", array_type), "")) + .collect::>() + .join(", "); + + // return array type and elements + format!("{}[]: [{}]", array_type, elements) + } + Token::FixedArray(val) => { + // get type of array + let array_type = match val.first() { + Some(token) => token.to_type(), + None => String::new(), + }; + + // parametrize all array elements, remove their `type: ` prefix, and join them + let elements = val + .iter() + .map(|token| token.parameterize().replace(&format!("{}: ", array_type), "")) + .collect::>() + .join(", "); + + // return array type and elements + format!("{}[{}]: [{}]", array_type, val.len(), elements) + } + Token::Tuple(val) => { + // return tuple type and elements + format!( + "({})", + val.iter() + .map(|token| token.parameterize()) + .collect::>() + .join(", ") + ) + } + } + } + + fn to_type(&self) -> String { + match self { + Token::Address(_) => "address".to_string(), + Token::Int(_) => "int".to_string(), + Token::Uint(_) => "uint".to_string(), + Token::String(_) => "string".to_string(), + Token::Bool(_) => "bool".to_string(), + Token::Bytes(_) => "bytes".to_string(), + Token::FixedBytes(val) => format!("bytes{}", val.len()), + Token::Array(val) => { + // get type of array + let array_type = match val.first() { + Some(token) => token.to_type(), + None => String::new(), + }; + format!("{}[]", array_type) + } + Token::FixedArray(val) => { + // get type of array + let array_type = match val.first() { + Some(token) => token.to_type(), + None => String::new(), + }; + format!("{}[{}]", array_type, val.len()) + } + Token::Tuple(val) => { + // get all internal types + let types = + val.iter().map(|token| token.to_type()).collect::>().join(", "); + + // return tuple type + format!("({})", types) + } + } + } +} + +#[cfg(test)] +mod tests { + use ethers::types::Address; + + use super::*; + + #[test] + fn test_parameterize_address() { + let output = Token::Address(Address::zero()).parameterize(); + assert_eq!(output, "address: 0x0000000000000000000000000000000000000000".to_string()); + } + + #[test] + fn test_parameterize_int() { + let output = Token::Int(1.into()).parameterize(); + assert_eq!(output, "int: 1".to_string()); + } + + #[test] + fn test_parameterize_uint() { + let output = Token::Uint(1.into()).parameterize(); + assert_eq!(output, "uint: 1".to_string()); + } + + #[test] + fn test_parameterize_string() { + let output = Token::String("test".to_string()).parameterize(); + assert_eq!(output, "string: test".to_string()); + } + + #[test] + fn test_parameterize_bool() { + let output = Token::Bool(true).parameterize(); + assert_eq!(output, "bool: true".to_string()); + } + + #[test] + fn test_parameterize_bytes() { + let output = Token::Bytes(vec![0x01, 0x02, 0x03]).parameterize(); + assert_eq!(output, "bytes: 0x010203".to_string()); + } + + #[test] + fn test_parameterize_fixed_bytes() { + let output = Token::FixedBytes(vec![0x01, 0x02, 0x03]).parameterize(); + assert_eq!(output, "bytes3: 0x010203".to_string()); + } + + #[test] + fn test_parameterize_array() { + let output = + Token::Array(vec![Token::Uint(1.into()), Token::Uint(2.into())]).parameterize(); + assert_eq!(output, "uint[]: [1, 2]".to_string()); + } + + #[test] + fn test_parameterize_fixed_array() { + let output = + Token::FixedArray(vec![Token::Uint(1.into()), Token::Uint(2.into())]).parameterize(); + assert_eq!(output, "uint[2]: [1, 2]".to_string()); + } + + #[test] + fn test_parameterize_tuple() { + let output = + Token::Tuple(vec![Token::Uint(1.into()), Token::Uint(2.into()), Token::Uint(3.into())]) + .parameterize(); + assert_eq!(output, "(uint: 1, uint: 2, uint: 3)".to_string()); + } + + #[test] + fn test_parameterize_nested_array() { + let output = Token::Array(vec![ + Token::Array(vec![Token::Uint(1.into()), Token::Uint(2.into())]), + Token::Array(vec![Token::Uint(3.into()), Token::Uint(4.into())]), + ]) + .parameterize(); + assert_eq!(output, "uint[][]: [[1, 2], [3, 4]]".to_string()); + } +} diff --git a/common/src/utils/mod.rs b/common/src/utils/mod.rs index bfa2992f..c817f031 100644 --- a/common/src/utils/mod.rs +++ b/common/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub mod hex; pub mod http; pub mod integers; pub mod io; diff --git a/common/src/utils/strings.rs b/common/src/utils/strings.rs index 81820ae6..168093d4 100644 --- a/common/src/utils/strings.rs +++ b/common/src/utils/strings.rs @@ -3,7 +3,6 @@ use std::{fmt::Write, num::ParseIntError}; use ethers::{ abi::AbiEncode, prelude::{I256, U256}, - types::{Bloom, H160, H256, H64}, }; use fancy_regex::Regex; @@ -310,34 +309,6 @@ pub fn tokenize(s: &str) -> Vec { tokens } -pub trait ToLowerHex { - fn to_lower_hex(&self) -> String; -} - -impl ToLowerHex for H256 { - fn to_lower_hex(&self) -> String { - format!("{:#032x}", self) - } -} - -impl ToLowerHex for H160 { - fn to_lower_hex(&self) -> String { - format!("{:#020x}", self) - } -} - -impl ToLowerHex for H64 { - fn to_lower_hex(&self) -> String { - format!("{:#016x}", self) - } -} - -impl ToLowerHex for Bloom { - fn to_lower_hex(&self) -> String { - format!("{:#064x}", self) - } -} - #[derive(Debug, PartialEq)] pub enum TokenType { Control, diff --git a/core/src/decode/core/abi.rs b/core/src/decode/core/abi.rs index f6c7da94..f71a2928 100644 --- a/core/src/decode/core/abi.rs +++ b/core/src/decode/core/abi.rs @@ -21,6 +21,7 @@ pub fn is_parameter_abi_encoded( parameter_index: usize, calldata_words: &[&str], ) -> Result, Error> { + debug_max!("calldata_words: {:#?}", calldata_words); let mut coverages = HashSet::from([parameter_index]); // convert this word to a U256 diff --git a/core/src/decode/mod.rs b/core/src/decode/mod.rs index 871d19af..afd9a1ed 100644 --- a/core/src/decode/mod.rs +++ b/core/src/decode/mod.rs @@ -69,6 +69,10 @@ pub struct DecodeArgs { /// Whether to truncate nonstandard sized calldata. #[clap(long, short)] pub truncate_calldata: bool, + + /// Whether to skip resolving selectors. Heimdall will attempt to guess types. + #[clap(long = "skip-resolving")] + pub skip_resolving: bool, } impl DecodeArgsBuilder { @@ -81,6 +85,7 @@ impl DecodeArgsBuilder { explain: Some(false), default: Some(true), truncate_calldata: Some(false), + skip_resolving: Some(false), } } } @@ -109,7 +114,9 @@ pub async fn decode(args: DecodeArgs) -> Result, Error> { // check if we require an OpenAI API key if args.explain && args.openai_api_key.is_empty() { logger.error("OpenAI API key is required for explaining calldata. Use `heimdall decode --help` for more information."); - std::process::exit(1); + return Err(Error::GenericError( + "OpenAI API key is required for explaining calldata.".to_string(), + )); } // init variables @@ -128,13 +135,15 @@ pub async fn decode(args: DecodeArgs) -> Result, Error> { calldata = args.target.to_string().replacen("0x", "", 1); } else { logger.error("invalid target. must be a transaction hash or calldata (bytes)."); - std::process::exit(1); + return Err(Error::GenericError( + "invalid target. must be a transaction hash or calldata (bytes).".to_string(), + )); } // check if the calldata length is a standard length if calldata.len() % 2 != 0 || calldata.len() < 8 { logger.error("calldata is not a valid hex string."); - std::process::exit(1); + return Err(Error::GenericError("calldata is not a valid hex string.".to_string())); } // if calldata isn't a multiple of 64, it may be harder to decode. @@ -156,16 +165,20 @@ pub async fn decode(args: DecodeArgs) -> Result, Error> { Ok(byte_args) => byte_args, Err(_) => { logger.error("failed to parse bytearray from calldata."); - std::process::exit(1) + return Err(Error::DecodeError); } }; // get the function signature possibilities - let potential_matches = match ResolvedFunction::resolve(&function_selector).await { - Some(signatures) => signatures, - None => Vec::new(), + let potential_matches = if !args.skip_resolving { + match ResolvedFunction::resolve(&function_selector).await { + Some(signatures) => signatures, + None => Vec::new(), + } + } else { + Vec::new() }; - if potential_matches.is_empty() { + if potential_matches.is_empty() && !args.skip_resolving { logger.warn("couldn't resolve potential matches for the given function selector."); } @@ -359,7 +372,7 @@ pub async fn decode(args: DecodeArgs) -> Result, Error> { Some(selected_match) => selected_match, None => { logger.error("invalid selection."); - std::process::exit(1) + return Err(Error::GenericError("invalid selection.".to_string())); } }; diff --git a/core/src/inspect/core/mod.rs b/core/src/inspect/core/mod.rs new file mode 100644 index 00000000..5ee45fbc --- /dev/null +++ b/core/src/inspect/core/mod.rs @@ -0,0 +1 @@ +pub mod tracing; diff --git a/core/src/inspect/core/storage.rs b/core/src/inspect/core/storage.rs new file mode 100644 index 00000000..968dd9f1 --- /dev/null +++ b/core/src/inspect/core/storage.rs @@ -0,0 +1,3 @@ + + +/// Converts a raw [`StateDiff`] to a human-readable diff. diff --git a/core/src/inspect/core/tracing.rs b/core/src/inspect/core/tracing.rs new file mode 100644 index 00000000..cbfb854a --- /dev/null +++ b/core/src/inspect/core/tracing.rs @@ -0,0 +1,219 @@ +use std::collections::HashMap; + +use ethers::types::{Transaction, TransactionTrace}; +use heimdall_common::{ + resources::transpose::get_label, + utils::{ + hex::ToLowerHex, + io::{logging::TraceFactory, types::Parameterize}, + }, +}; + +use crate::{decode::DecodeArgsBuilder, error::Error, inspect::InspectArgs}; + +/// Converts raw [`TransactionTrace`]s to human-readable [`TraceFactory`] +pub async fn build_trace_display( + args: &InspectArgs, + transaction: &Transaction, + transaction_traces: Vec, + address_labels: &mut HashMap>, +) -> Result { + let mut trace = TraceFactory::default(); + let decode_call = trace.add_call_with_extra( + 0, + transaction.gas.as_u32(), // panicky + "heimdall".to_string(), + "inspect".to_string(), + vec![transaction.hash.to_lower_hex()], + "()".to_string(), + vec![format!("{} wei", transaction.value)], + ); + + let mut trace_indices = HashMap::new(); + + for transaction_trace in transaction_traces { + let trace_address = transaction_trace + .trace_address + .iter() + .map(|address| address.to_string()) + .collect::>() + .join("."); + let parent_address = trace_address + .split('.') + .take(trace_address.split('.').count() - 1) + .collect::>() + .join("."); + + // get trace index from parent_address + let parent_index = trace_indices.get(&parent_address).unwrap_or(&decode_call); + + // get result + let mut result_str = "()".to_string(); + if let Some(result) = transaction_trace.result { + result_str = match result { + ethers::types::Res::Call(res) => { + // we can attempt to decode this as if it is calldata, we just need to add some + // 4byte prefix. + let output = + format!("0x00000000{}", res.output.to_string().replacen("0x", "", 1)); + let result = crate::decode::decode( + DecodeArgsBuilder::new() + .target(output) + .skip_resolving(true) + .build() + .map_err(|_e| Error::DecodeError)?, + ) + .await?; + + // get first result + if let Some(resolved_function) = result.first() { + resolved_function + .decoded_inputs + .clone() + .unwrap_or_default() + .iter() + .map(|token| token.parameterize()) + .collect::>() + .join(", ") + } else { + res.output.to_string() + } + } + ethers::types::Res::Create(res) => res.address.to_lower_hex(), + ethers::types::Res::None => "()".to_string(), + } + } + if result_str.replacen("0x", "", 1).is_empty() { + result_str = "()".to_string(); + } + + // get action + match transaction_trace.action { + ethers::types::Action::Call(call) => { + // add address label. we will use this to display the address in the trace, if + // available. (requires `transpose_api_key`) + if let Some(transpose_api_key) = &args.transpose_api_key { + if let std::collections::hash_map::Entry::Vacant(e) = + address_labels.entry(call.to.to_lower_hex()) + { + e.insert(get_label(&call.to.to_lower_hex(), transpose_api_key).await); + } + } + let address_label = address_labels + .get(&call.to.to_lower_hex()) + .unwrap_or(&None) + .clone() + .unwrap_or(call.to.to_lower_hex()) + .to_string(); + + // build extra_data, which will be used to display the call type and value transfer + // information + let mut extra_data = vec![]; + let call_type = match call.call_type { + ethers::types::CallType::Call => "call", + ethers::types::CallType::DelegateCall => "delegatecall", + ethers::types::CallType::StaticCall => "staticcall", + ethers::types::CallType::CallCode => "callcode", + ethers::types::CallType::None => "none", + } + .to_string(); + extra_data.push(call_type.clone()); + if !call.value.is_zero() { + extra_data.push(format!("{} wei", call.value)); + } + + // attempt to decode calldata + let calldata = call.input.to_string(); + if !calldata.replacen("0x", "", 1).is_empty() { + let result = crate::decode::decode( + DecodeArgsBuilder::new() + .target(calldata) + .build() + .map_err(|_e| Error::DecodeError)?, + ) + .await?; + + // get first result + if let Some(resolved_function) = result.first() { + // convert decoded inputs Option> to Vec + let decoded_inputs = + resolved_function.decoded_inputs.clone().unwrap_or_default(); + + // get index of parent + let parent_index = trace.add_call_with_extra( + *parent_index, + call.gas.as_u32(), // panicky + address_label, + resolved_function.name.clone(), + vec![decoded_inputs + .iter() + .map(|token| token.parameterize()) + .collect::>() + .join(", ")], + result_str, + extra_data, + ); + + // add trace_address to trace_indices + trace_indices.insert(trace_address.clone(), parent_index); + } else { + // get index of parent + let parent_index = trace.add_call_with_extra( + *parent_index, + call.gas.as_u32(), // panicky + address_label, + "unknown".to_string(), + vec![format!("bytes: {}", call.input.to_string())], + result_str, + extra_data, + ); + + // add trace_address to trace_indices + trace_indices.insert(trace_address.clone(), parent_index); + } + } else { + // value transfer + trace.add_call_with_extra( + *parent_index, + call.gas.as_u32(), // panicky + call.to.to_lower_hex(), + "fallback".to_string(), + vec![], + result_str, + extra_data, + ); + } + } + ethers::types::Action::Create(create) => { + // add address label. we will use this to display the address in the trace, if + // available. (requires `transpose_api_key`) + if let Some(transpose_api_key) = &args.transpose_api_key { + if !address_labels.contains_key(&result_str) { + address_labels.insert( + result_str.clone(), + get_label(&result_str, transpose_api_key).await, + ); + } + } + let address_label = address_labels + .get(&result_str) + .unwrap_or(&None) + .clone() + .unwrap_or("NewContract".to_string()) + .to_string(); + + trace.add_creation( + *parent_index, + create.gas.as_u32(), + address_label, + result_str, + create.init.len().try_into().map_err(|_e| Error::DecodeError)?, + ); + } + ethers::types::Action::Suicide(_suicide) => {} + ethers::types::Action::Reward(_) => todo!(), + } + } + + Ok(trace) +} diff --git a/core/src/inspect/mod.rs b/core/src/inspect/mod.rs index 7d332368..0a5181fc 100644 --- a/core/src/inspect/mod.rs +++ b/core/src/inspect/mod.rs @@ -1,15 +1,17 @@ +mod core; + use std::collections::HashMap; use clap::{AppSettings, Parser}; -use clap_verbosity_flag::Verbosity; + use derive_builder::Builder; use heimdall_common::{ - ether::rpc::get_trace, - utils::{io::logging::Logger, strings::ToLowerHex}, + ether::rpc::{get_trace, get_transaction}, + utils::io::logging::Logger, }; -use crate::{decode::DecodeArgsBuilder, error::Error}; +use crate::{error::Error, inspect::core::tracing::build_trace_display}; #[derive(Debug, Clone, Parser, Builder)] #[clap( @@ -34,6 +36,10 @@ pub struct InspectArgs { /// When prompted, always select the default value. #[clap(long, short)] pub default: bool, + + /// Your OPTIONAL Transpose.io API Key, used for labeling contract addresses. + #[clap(long = "transpose-api-key", short, hide_default_value = true)] + pub transpose_api_key: Option, } impl InspectArgsBuilder { @@ -43,6 +49,7 @@ impl InspectArgsBuilder { verbose: Some(clap_verbosity_flag::Verbosity::new(0, 1)), rpc_url: Some(String::new()), default: Some(true), + transpose_api_key: None, } } } @@ -52,6 +59,9 @@ impl InspectArgsBuilder { /// visualization, and more. #[allow(deprecated)] pub async fn inspect(args: InspectArgs) -> Result<(), Error> { + // define + let mut address_labels = HashMap::new(); + // set logger environment variable if not already set // TODO: abstract this to a heimdall_common util if std::env::var("RUST_LOG").is_err() { @@ -65,115 +75,32 @@ pub async fn inspect(args: InspectArgs) -> Result<(), Error> { } // get a new logger and trace - let (_logger, mut trace) = Logger::new(match args.verbose.log_level() { + let (_logger, _trace) = Logger::new(match args.verbose.log_level() { Some(level) => level.as_str(), None => "SILENT", }); // get calldata from RPC - // let transaction = get_transaction(&args.target, &args.rpc_url) - // .await - // .map_err(|e| Error::RpcError(e.to_string()))?; + let transaction = get_transaction(&args.target, &args.rpc_url) + .await + .map_err(|e| Error::RpcError(e.to_string()))?; // get trace let block_trace = get_trace(&args.target, &args.rpc_url).await.map_err(|e| Error::RpcError(e.to_string()))?; - let decode_call = trace.add_call( - 0, - line!(), - "heimdall".to_string(), - "inspect".to_string(), - vec![args.target.clone()], - "()".to_string(), - ); - - if let Some(transaction_traces) = block_trace.trace { - let mut trace_indices = HashMap::new(); - - for transaction_trace in transaction_traces { - println!("trace: {:?}", transaction_trace); - let trace_address = transaction_trace - .trace_address - .iter() - .map(|address| address.to_string()) - .collect::>() - .join("."); - let parent_address = trace_address - .split('.') - .take(trace_address.split('.').count() - 1) - .collect::>() - .join("."); - - // get trace index from parent_address - let parent_index = trace_indices.get(&parent_address).unwrap_or(&decode_call); - - // get action - match transaction_trace.action { - ethers::types::Action::Call(call) => { - // attempt to decode calldata - let calldata = call.input.to_string(); - - if !calldata.replacen("0x", "", 1).is_empty() { - let result = crate::decode::decode( - DecodeArgsBuilder::new() - .target(calldata) - .rpc_url("https://eth.llamarpc.com".to_string()) - .verbose(Verbosity::new(2, 0)) - .build() - .map_err(|_e| Error::DecodeError)?, - ) - .await?; - - // get first result - if let Some(resolved_function) = result.first() { - // convert decoded inputs Option> to Vec - let decoded_inputs = - resolved_function.decoded_inputs.clone().unwrap_or_default(); - - // get index of parent - let parent_index = trace.add_call( - *parent_index, - line!(), - call.to.to_lower_hex(), - resolved_function.name.clone(), - decoded_inputs - .iter() - .map(|token| format!("{:?}", token)) - .collect::>(), - "()".to_string(), - ); - - // add trace_address to trace_indices - trace_indices.insert(trace_address.clone(), parent_index); - trace.add_info( - parent_index, - line!(), - &format!("trace_address: {:?}", trace_address), - ); - } else { - unimplemented!(); - } - } else { - // value transfer - trace.add_call( - *parent_index, - line!(), - call.to.to_lower_hex(), - "transfer".to_string(), - vec![format!("{} wei", call.value)], - "()".to_string(), - ); - } - } - ethers::types::Action::Create(_) => todo!(), - ethers::types::Action::Suicide(_) => todo!(), - ethers::types::Action::Reward(_) => todo!(), - } - } - } + // build displayable trace + let transaction_trace_display = build_trace_display( + &args, + &transaction, + block_trace.trace.unwrap_or_default(), + &mut address_labels, + ) + .await?; + + transaction_trace_display.display(); - trace.display(); + println!("{:#?}", block_trace.state_diff); Ok(()) } diff --git a/core/tests/test_decode.rs b/core/tests/test_decode.rs index 0566a366..79548ff2 100644 --- a/core/tests/test_decode.rs +++ b/core/tests/test_decode.rs @@ -16,6 +16,7 @@ mod benchmark { explain: false, default: true, truncate_calldata: false, + skip_resolving: false, }; let _ = heimdall_core::decode::decode(args).await; } @@ -34,6 +35,7 @@ mod benchmark { explain: false, default: true, truncate_calldata: false, + skip_resolving: false, }; let _ = heimdall_core::decode::decode(args).await; } @@ -52,6 +54,7 @@ mod benchmark { explain: false, default: true, truncate_calldata: false, + skip_resolving: false, }; let _ = heimdall_core::decode::decode(args).await; } @@ -70,6 +73,7 @@ mod benchmark { explain: false, default: true, truncate_calldata: false, + skip_resolving: false, }; let _ = heimdall_core::decode::decode(args).await; } @@ -93,6 +97,7 @@ mod tests { explain: false, default: true, truncate_calldata: false, + skip_resolving: false, }; let _ = heimdall_core::decode::decode(args).await; } @@ -107,6 +112,7 @@ mod tests { explain: false, default: true, truncate_calldata: false, + skip_resolving: false, }; let _ = heimdall_core::decode::decode(args).await; } From cc7c9fda0cced82e830676b48511e87aac07216f Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Sat, 9 Dec 2023 23:26:32 -0500 Subject: [PATCH 04/15] feat(inspect): implement `DecodedTransactionTrace` --- Cargo.lock | 11 + common/src/resources/transpose.rs | 1 + core/Cargo.toml | 2 + core/src/error.rs | 2 + core/src/inspect/core/contracts.rs | 75 +++++ core/src/inspect/core/mod.rs | 1 + core/src/inspect/core/tracing.rs | 479 +++++++++++++++++------------ core/src/inspect/mod.rs | 53 ++-- 8 files changed, 401 insertions(+), 223 deletions(-) create mode 100644 core/src/inspect/core/contracts.rs diff --git a/Cargo.lock b/Cargo.lock index 8d94667f..e84a7c7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,6 +77,15 @@ dependencies = [ "term", ] +[[package]] +name = "async-convert" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d416feee97712e43152cd42874de162b8f9b77295b1c85e5d92725cc8310bae" +dependencies = [ + "async-trait", +] + [[package]] name = "async-openai" version = "0.10.3" @@ -1713,6 +1722,7 @@ dependencies = [ name = "heimdall-core" version = "0.6.5" dependencies = [ + "async-convert", "backtrace", "clap", "clap-verbosity-flag", @@ -1721,6 +1731,7 @@ dependencies = [ "derive_builder", "ethers", "fancy-regex", + "futures", "heimdall-cache", "heimdall-common", "heimdall-config", diff --git a/common/src/resources/transpose.rs b/common/src/resources/transpose.rs index 4688cc1d..7afc5987 100644 --- a/common/src/resources/transpose.rs +++ b/common/src/resources/transpose.rs @@ -21,6 +21,7 @@ struct TransposeResponse { } /// executes a transpose SQL query and returns the response +/// TODO: exponential backoff async fn call_transpose(query: &str, api_key: &str) -> Option { // get a new logger let logger = Logger::default(); diff --git a/core/Cargo.toml b/core/Cargo.toml index f5621d5e..6f63cff9 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -28,3 +28,5 @@ strsim = "0.10.0" tokio = {version = "1", features = ["full"]} tui = "0.19" derive_builder = "0.12.0" +async-convert = "1.0.0" +futures = "0.3.28" diff --git a/core/src/error.rs b/core/src/error.rs index 5ad3021e..a7827801 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -12,4 +12,6 @@ pub enum Error { RpcError(String), #[error("Error: {0}")] GenericError(String), + #[error("TransposeError: {0}")] + TransposeError(String), } diff --git a/core/src/inspect/core/contracts.rs b/core/src/inspect/core/contracts.rs new file mode 100644 index 00000000..c487bd71 --- /dev/null +++ b/core/src/inspect/core/contracts.rs @@ -0,0 +1,75 @@ +use std::collections::{HashMap, HashSet}; + +use ethers::types::Address; +use futures::future::try_join_all; + +use crate::{error::Error, inspect::InspectArgs}; +use heimdall_common::{resources::transpose::get_label, utils::hex::ToLowerHex}; + +#[derive(Debug, Clone)] +pub struct Contracts { + pub contracts: HashMap, + transpose_api_key: Option, +} + +impl Contracts { + pub fn new(args: &InspectArgs) -> Self { + Self { contracts: HashMap::new(), transpose_api_key: args.transpose_api_key.clone() } + } + + pub async fn add(&mut self, address: Address) -> Result<(), Error> { + // if alias already exists, don't overwrite + if self.contracts.contains_key(&address) { + return Ok(()); + } + + if let Some(transpose_api_key) = &self.transpose_api_key { + self.contracts.insert( + address, + get_label(&address.to_lower_hex(), transpose_api_key) + .await + .unwrap_or(address.to_lower_hex()), + ); + } else { + self.contracts.insert(address, address.to_lower_hex()); + } + + Ok(()) + } + + pub async fn extend(&mut self, addresses: HashSet
) -> Result<(), Error> { + // for each address, get the label + if let Some(transpose_api_key) = &self.transpose_api_key { + let handles: Vec<_> = addresses + .clone() + .into_iter() + .map(move |address| { + let transpose_api_key = transpose_api_key.clone(); + tokio::spawn(async move { + get_label(&address.to_lower_hex(), &transpose_api_key).await + }) + }) + .collect(); + + let labels = + try_join_all(handles).await.map_err(|e| Error::TransposeError(e.to_string()))?; + + self.contracts.extend( + addresses + .into_iter() + .zip(labels.into_iter()) + .map(|(address, label)| (address, label.unwrap_or(address.to_lower_hex()))), + ); + // replace None + } else { + self.contracts + .extend(addresses.into_iter().map(|address| (address, address.to_lower_hex()))); + } + + Ok(()) + } + + pub fn get(&self, address: Address) -> Option<&String> { + self.contracts.get(&address) + } +} diff --git a/core/src/inspect/core/mod.rs b/core/src/inspect/core/mod.rs index 5ee45fbc..ed583ff9 100644 --- a/core/src/inspect/core/mod.rs +++ b/core/src/inspect/core/mod.rs @@ -1 +1,2 @@ +pub(crate) mod contracts; pub mod tracing; diff --git a/core/src/inspect/core/tracing.rs b/core/src/inspect/core/tracing.rs index cbfb854a..d4da4517 100644 --- a/core/src/inspect/core/tracing.rs +++ b/core/src/inspect/core/tracing.rs @@ -1,219 +1,292 @@ -use std::collections::HashMap; - -use ethers::types::{Transaction, TransactionTrace}; -use heimdall_common::{ - resources::transpose::get_label, - utils::{ - hex::ToLowerHex, - io::{logging::TraceFactory, types::Parameterize}, +use std::collections::{HashSet, VecDeque}; + +use ethers::{ + abi::Token, + types::{ + ActionType, Address, Bytes, Call, CallResult, CallType, Create, CreateResult, Reward, + Suicide, TransactionTrace, U256, }, }; +use heimdall_common::ether::signatures::ResolvedFunction; +use serde::{Deserialize, Serialize}; -use crate::{decode::DecodeArgsBuilder, error::Error, inspect::InspectArgs}; - -/// Converts raw [`TransactionTrace`]s to human-readable [`TraceFactory`] -pub async fn build_trace_display( - args: &InspectArgs, - transaction: &Transaction, - transaction_traces: Vec, - address_labels: &mut HashMap>, -) -> Result { - let mut trace = TraceFactory::default(); - let decode_call = trace.add_call_with_extra( - 0, - transaction.gas.as_u32(), // panicky - "heimdall".to_string(), - "inspect".to_string(), - vec![transaction.hash.to_lower_hex()], - "()".to_string(), - vec![format!("{} wei", transaction.value)], - ); - - let mut trace_indices = HashMap::new(); - - for transaction_trace in transaction_traces { - let trace_address = transaction_trace - .trace_address - .iter() - .map(|address| address.to_string()) - .collect::>() - .join("."); - let parent_address = trace_address - .split('.') - .take(trace_address.split('.').count() - 1) - .collect::>() - .join("."); - - // get trace index from parent_address - let parent_index = trace_indices.get(&parent_address).unwrap_or(&decode_call); - - // get result - let mut result_str = "()".to_string(); - if let Some(result) = transaction_trace.result { - result_str = match result { - ethers::types::Res::Call(res) => { - // we can attempt to decode this as if it is calldata, we just need to add some - // 4byte prefix. - let output = - format!("0x00000000{}", res.output.to_string().replacen("0x", "", 1)); - let result = crate::decode::decode( - DecodeArgsBuilder::new() - .target(output) - .skip_resolving(true) - .build() - .map_err(|_e| Error::DecodeError)?, - ) - .await?; - - // get first result - if let Some(resolved_function) = result.first() { - resolved_function - .decoded_inputs - .clone() - .unwrap_or_default() - .iter() - .map(|token| token.parameterize()) - .collect::>() - .join(", ") - } else { - res.output.to_string() - } - } - ethers::types::Res::Create(res) => res.address.to_lower_hex(), - ethers::types::Res::None => "()".to_string(), +use crate::{decode::DecodeArgsBuilder, error::Error}; +use async_convert::{async_trait, TryFrom}; +use futures::future::try_join_all; + +/// Decoded Trace +#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] +pub struct DecodedTransactionTrace { + pub trace_address: Vec, + pub action: DecodedAction, + pub action_type: ActionType, + pub result: Option, + pub error: Option, + pub subtraces: Vec, +} + +/// Decoded Action +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[serde(untagged, rename_all = "lowercase")] +pub enum DecodedAction { + /// Decoded Call + Call(DecodedCall), + /// Create + Create(Create), + /// Suicide + Suicide(Suicide), + /// Reward + Reward(Reward), +} + +/// Decoded Call +#[derive(Debug, Clone, PartialEq, Default, Deserialize, Serialize)] +pub struct DecodedCall { + /// Sender + pub from: Address, + /// Recipient + pub to: Address, + /// Transferred Value + pub value: U256, + /// Gas + pub gas: U256, + /// Input data + pub input: Bytes, + /// The type of the call. + #[serde(rename = "callType")] + pub call_type: CallType, + /// Potential resolved function + #[serde(rename = "resolvedFunction")] + pub resolved_function: Option, + /// Decoded inputs + #[serde(rename = "decodedInputs")] + pub decoded_inputs: Vec, +} + +/// Decoded Response +#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum DecodedRes { + /// Call + Call(DecodedCallResult), + /// Create + Create(CreateResult), + /// None + #[default] + None, +} + +/// Call Result +#[derive(Debug, Clone, PartialEq, Default, Deserialize, Serialize)] +pub struct DecodedCallResult { + /// Gas used + #[serde(rename = "gasUsed")] + pub gas_used: U256, + /// Output bytes + pub output: Bytes, + /// Decoded outputs + #[serde(rename = "decodedOutputs")] + pub decoded_outputs: Vec, +} + +#[async_trait] +impl TryFrom> for DecodedTransactionTrace { + type Error = crate::error::Error; + + async fn try_from(value: Vec) -> Result { + // convert each [`TransactionTrace`] to a [`DecodedTransactionTrace`] + let handles = value.into_iter().map(|trace| { + >::try_from(trace) + }); + let mut decoded_transaction_traces = VecDeque::from(try_join_all(handles).await?); + + // get the first trace, this will be the one we are building. + let mut decoded_transaction_trace = + decoded_transaction_traces.pop_front().ok_or(Error::DecodeError)?; + assert!(decoded_transaction_trace.trace_address.is_empty()); // sanity check + + for decoded_trace in decoded_transaction_traces { + // trace_address is the index of the trace in the decoded_transaction_trace. for + // example, if trace_address is `[0]`, it'll be added to + // `decoded_transaction_trace.subtraces` at index 0. if trace_address is `[0, 0]`, it'll + // be added to `decoded_transaction_trace.subtraces[0].subtraces` at index 0. + let mut current_trace = &mut decoded_transaction_trace; + let trace_address = &decoded_trace.trace_address; + + // Iterate through the trace address, navigating through subtraces + for &index in trace_address.iter().take(trace_address.len() - 1) { + current_trace = current_trace.subtraces.get_mut(index).ok_or(Error::DecodeError)?; + // You might need to define this error + } + + // Insert the decoded trace into the correct position + if let Some(last_index) = trace_address.last() { + current_trace.subtraces.insert(*last_index, decoded_trace); + } else { + return Err(Error::DecodeError); // Trace address cannot be empty here } } - if result_str.replacen("0x", "", 1).is_empty() { - result_str = "()".to_string(); + + Ok(decoded_transaction_trace) + } +} + +#[async_trait] +impl TryFrom for DecodedTransactionTrace { + type Error = crate::error::Error; + + async fn try_from(value: TransactionTrace) -> Result { + let action = match value.action { + ethers::types::Action::Call(call) => DecodedAction::Call( + >::try_from(call).await?, + ), + ethers::types::Action::Create(create) => DecodedAction::Create(create), + ethers::types::Action::Suicide(suicide) => DecodedAction::Suicide(suicide), + ethers::types::Action::Reward(reward) => DecodedAction::Reward(reward), + }; + + let result = match value.result { + Some(res) => match res { + ethers::types::Res::Call(call) => Some(DecodedRes::Call( + >::try_from(call) + .await?, + )), + ethers::types::Res::Create(create) => Some(DecodedRes::Create(create)), + ethers::types::Res::None => Some(DecodedRes::None), + }, + None => None, + }; + + Ok(Self { + trace_address: value.trace_address, + action, + action_type: value.action_type, + result, + error: value.error, + subtraces: Vec::new(), // we will build this later + }) + } +} + +#[async_trait] +impl TryFrom for DecodedCall { + type Error = crate::error::Error; + + async fn try_from(value: Call) -> Result { + let calldata = value.input.to_string().replacen("0x", "", 1); + let mut decoded_inputs = Vec::new(); + let mut resolved_function = None; + + if !calldata.is_empty() { + let result = crate::decode::decode( + DecodeArgsBuilder::new() + .target(calldata) + .build() + .map_err(|_e| Error::DecodeError)?, + ) + .await?; + + if let Some(first_result) = result.first() { + decoded_inputs = first_result.decoded_inputs.clone().unwrap_or_default(); + resolved_function = Some(first_result.clone()); + } } - // get action - match transaction_trace.action { - ethers::types::Action::Call(call) => { - // add address label. we will use this to display the address in the trace, if - // available. (requires `transpose_api_key`) - if let Some(transpose_api_key) = &args.transpose_api_key { - if let std::collections::hash_map::Entry::Vacant(e) = - address_labels.entry(call.to.to_lower_hex()) - { - e.insert(get_label(&call.to.to_lower_hex(), transpose_api_key).await); - } - } - let address_label = address_labels - .get(&call.to.to_lower_hex()) - .unwrap_or(&None) - .clone() - .unwrap_or(call.to.to_lower_hex()) - .to_string(); - - // build extra_data, which will be used to display the call type and value transfer - // information - let mut extra_data = vec![]; - let call_type = match call.call_type { - ethers::types::CallType::Call => "call", - ethers::types::CallType::DelegateCall => "delegatecall", - ethers::types::CallType::StaticCall => "staticcall", - ethers::types::CallType::CallCode => "callcode", - ethers::types::CallType::None => "none", + Ok(Self { + from: value.from, + to: value.to, + value: value.value, + gas: value.gas, + input: value.input, + call_type: value.call_type, + resolved_function, + decoded_inputs, + }) + } +} + +#[async_trait] +impl TryFrom for DecodedCallResult { + type Error = crate::error::Error; + + async fn try_from(value: CallResult) -> Result { + // we can attempt to decode this as if it is calldata, we just need to add some + // 4byte prefix. + let output = format!("0x00000000{}", value.output.to_string().replacen("0x", "", 1)); + let result = crate::decode::decode( + DecodeArgsBuilder::new() + .target(output) + .skip_resolving(true) + .build() + .map_err(|_e| Error::DecodeError)?, + ) + .await?; + + // get first result + let decoded_outputs = if let Some(resolved_function) = result.first() { + resolved_function.decoded_inputs.clone().unwrap_or_default() + } else { + vec![] + }; + + Ok(Self { gas_used: value.gas_used, output: value.output, decoded_outputs }) + } +} + +impl DecodedTransactionTrace { + /// Returns a [`HashSet`] of all addresses involved in the traced transaction. if + /// `include_inputs`/`include_outputs` is true, the [`HashSet`] will also include the + /// addresses of the inputs/outputs of the transaction. + pub fn addresses(&self, include_inputs: bool, include_outputs: bool) -> HashSet
{ + let mut addresses = HashSet::new(); + + match &self.action { + DecodedAction::Call(call) => { + addresses.insert(call.from); + addresses.insert(call.to); + + if include_inputs { + let _ = call.decoded_inputs.iter().map(|token| match token { + Token::Address(address) => addresses.insert(*address), + _ => false, + }); } - .to_string(); - extra_data.push(call_type.clone()); - if !call.value.is_zero() { - extra_data.push(format!("{} wei", call.value)); + if include_outputs { + let _ = self.result.iter().map(|result| { + if let DecodedRes::Call(call_result) = result { + let _ = call_result.decoded_outputs.iter().map(|token| match token { + Token::Address(address) => addresses.insert(*address), + _ => false, + }); + } + }); } + } + DecodedAction::Create(create) => { + addresses.insert(create.from); - // attempt to decode calldata - let calldata = call.input.to_string(); - if !calldata.replacen("0x", "", 1).is_empty() { - let result = crate::decode::decode( - DecodeArgsBuilder::new() - .target(calldata) - .build() - .map_err(|_e| Error::DecodeError)?, - ) - .await?; - - // get first result - if let Some(resolved_function) = result.first() { - // convert decoded inputs Option> to Vec - let decoded_inputs = - resolved_function.decoded_inputs.clone().unwrap_or_default(); - - // get index of parent - let parent_index = trace.add_call_with_extra( - *parent_index, - call.gas.as_u32(), // panicky - address_label, - resolved_function.name.clone(), - vec![decoded_inputs - .iter() - .map(|token| token.parameterize()) - .collect::>() - .join(", ")], - result_str, - extra_data, - ); - - // add trace_address to trace_indices - trace_indices.insert(trace_address.clone(), parent_index); - } else { - // get index of parent - let parent_index = trace.add_call_with_extra( - *parent_index, - call.gas.as_u32(), // panicky - address_label, - "unknown".to_string(), - vec![format!("bytes: {}", call.input.to_string())], - result_str, - extra_data, - ); - - // add trace_address to trace_indices - trace_indices.insert(trace_address.clone(), parent_index); - } - } else { - // value transfer - trace.add_call_with_extra( - *parent_index, - call.gas.as_u32(), // panicky - call.to.to_lower_hex(), - "fallback".to_string(), - vec![], - result_str, - extra_data, - ); + if include_outputs { + let _ = self.result.iter().map(|result| { + if let DecodedRes::Create(create_result) = result { + addresses.insert(create_result.address); + } + }); } } - ethers::types::Action::Create(create) => { - // add address label. we will use this to display the address in the trace, if - // available. (requires `transpose_api_key`) - if let Some(transpose_api_key) = &args.transpose_api_key { - if !address_labels.contains_key(&result_str) { - address_labels.insert( - result_str.clone(), - get_label(&result_str, transpose_api_key).await, - ); - } - } - let address_label = address_labels - .get(&result_str) - .unwrap_or(&None) - .clone() - .unwrap_or("NewContract".to_string()) - .to_string(); - - trace.add_creation( - *parent_index, - create.gas.as_u32(), - address_label, - result_str, - create.init.len().try_into().map_err(|_e| Error::DecodeError)?, - ); + DecodedAction::Suicide(suicide) => { + addresses.insert(suicide.address); + addresses.insert(suicide.refund_address); + } + DecodedAction::Reward(reward) => { + addresses.insert(reward.author); } - ethers::types::Action::Suicide(_suicide) => {} - ethers::types::Action::Reward(_) => todo!(), + }; + + // add all addresses found in subtraces + for subtrace in &self.subtraces { + addresses.extend(subtrace.addresses(include_inputs, include_outputs)) } - } - Ok(trace) + addresses + } } diff --git a/core/src/inspect/mod.rs b/core/src/inspect/mod.rs index 0a5181fc..daf4a19f 100644 --- a/core/src/inspect/mod.rs +++ b/core/src/inspect/mod.rs @@ -1,17 +1,18 @@ mod core; -use std::collections::HashMap; - use clap::{AppSettings, Parser}; use derive_builder::Builder; +use ethers::types::TransactionTrace; use heimdall_common::{ ether::rpc::{get_trace, get_transaction}, utils::io::logging::Logger, }; -use crate::{error::Error, inspect::core::tracing::build_trace_display}; +use crate::error::Error; + +use self::core::{contracts::Contracts, tracing::DecodedTransactionTrace}; #[derive(Debug, Clone, Parser, Builder)] #[clap( @@ -54,14 +55,15 @@ impl InspectArgsBuilder { } } +#[derive(Debug, Clone)] +pub struct InspectResult { + pub decoded_trace: Option, +} /// The entrypoint for the inspect module. This function will analyze the given transaction and /// provide a detailed inspection of the transaction, including calldata & trace decoding, log /// visualization, and more. #[allow(deprecated)] -pub async fn inspect(args: InspectArgs) -> Result<(), Error> { - // define - let mut address_labels = HashMap::new(); - +pub async fn inspect(args: InspectArgs) -> Result { // set logger environment variable if not already set // TODO: abstract this to a heimdall_common util if std::env::var("RUST_LOG").is_err() { @@ -75,13 +77,13 @@ pub async fn inspect(args: InspectArgs) -> Result<(), Error> { } // get a new logger and trace - let (_logger, _trace) = Logger::new(match args.verbose.log_level() { + let (logger, _trace) = Logger::new(match args.verbose.log_level() { Some(level) => level.as_str(), None => "SILENT", }); // get calldata from RPC - let transaction = get_transaction(&args.target, &args.rpc_url) + let _transaction = get_transaction(&args.target, &args.rpc_url) .await .map_err(|e| Error::RpcError(e.to_string()))?; @@ -89,18 +91,29 @@ pub async fn inspect(args: InspectArgs) -> Result<(), Error> { let block_trace = get_trace(&args.target, &args.rpc_url).await.map_err(|e| Error::RpcError(e.to_string()))?; - // build displayable trace - let transaction_trace_display = build_trace_display( - &args, - &transaction, - block_trace.trace.unwrap_or_default(), - &mut address_labels, - ) - .await?; + let decoded_trace = + match block_trace.trace { + Some(trace) => , + >>::try_from(trace) + .await + .ok(), + None => None, + }; + if decoded_trace.is_none() { + logger.warn("no trace found for transaction"); + } - transaction_trace_display.display(); + // get contracts client and extend with addresses from trace + let mut contracts = Contracts::new(&args); + if let Some(decoded_trace) = decoded_trace.clone() { + contracts + .extend(decoded_trace.addresses(true, true).into_iter().collect()) + .await + .map_err(|e| Error::GenericError(e.to_string()))?; + }; - println!("{:#?}", block_trace.state_diff); + println!("{:#?}", contracts); - Ok(()) + Ok(InspectResult { decoded_trace }) } From 806aca0d6d7f04ae8c38cbdd2db0721cfceae258 Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Sun, 10 Dec 2023 20:49:05 -0500 Subject: [PATCH 05/15] feat(inspect): log decoding, joining to trace at correct addresses --- Cargo.lock | 1 + cli/src/main.rs | 29 ++++++++++- cli/src/output.rs | 7 ++- common/src/ether/rpc.rs | 72 +++++++++++++++++++++++++-- core/Cargo.toml | 1 + core/src/inspect/core/logs.rs | 83 ++++++++++++++++++++++++++++++++ core/src/inspect/core/mod.rs | 2 + core/src/inspect/core/storage.rs | 4 +- core/src/inspect/core/tracing.rs | 64 ++++++++++++++++++++++-- core/src/inspect/mod.rs | 79 +++++++++++++++++++++++++----- 10 files changed, 316 insertions(+), 26 deletions(-) create mode 100644 core/src/inspect/core/logs.rs diff --git a/Cargo.lock b/Cargo.lock index e84a7c7d..36ebf6f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1723,6 +1723,7 @@ name = "heimdall-core" version = "0.6.5" dependencies = [ "async-convert", + "async-recursion", "backtrace", "clap", "clap-verbosity-flag", diff --git a/cli/src/main.rs b/cli/src/main.rs index 246cb3e3..08f9cb0e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -349,7 +349,34 @@ async fn main() -> Result<(), Box> { cmd.transpose_api_key = Some(configuration.transpose_api_key); } - inspect(cmd).await?; + // if the user has passed an output filename, override the default filename + let mut filename = "decoded_trace.json".to_string(); + let given_name = cmd.name.as_str(); + + if !given_name.is_empty() { + filename = format!("{}-{}", given_name, filename); + } + + let inspect_result = inspect(cmd.clone()).await?; + + if cmd.output == "print" { + let mut output_str = String::new(); + + if let Some(decoded_trace) = inspect_result.decoded_trace { + output_str.push_str(&format!( + "Decoded Trace:\n\n{}\n", + serde_json::to_string_pretty(&decoded_trace).unwrap() + )); + } + + print_with_less(&output_str).await?; + } else if let Some(decoded_trace) = inspect_result.decoded_trace { + // write decoded trace with serde + let output_path = + build_output_path(&cmd.output, &cmd.target, &cmd.rpc_url, &filename).await?; + + write_file(&output_path, &serde_json::to_string_pretty(&decoded_trace).unwrap()); + } } Subcommands::Config(cmd) => { diff --git a/cli/src/output.rs b/cli/src/output.rs index 1eac0dc0..d4d12657 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1,6 +1,9 @@ use std::{env, io::Write}; -use heimdall_common::{constants::ADDRESS_REGEX, ether::rpc}; +use heimdall_common::{ + constants::{ADDRESS_REGEX, TRANSACTION_HASH_REGEX}, + ether::rpc, +}; /// build a standardized output path for the given parameters. follows the following cases: /// - if `output` is `print`, return `None` @@ -19,7 +22,7 @@ pub async fn build_output_path( // get the current working directory let cwd = env::current_dir()?.into_os_string().into_string().unwrap(); - if ADDRESS_REGEX.is_match(target)? { + if ADDRESS_REGEX.is_match(target)? || TRANSACTION_HASH_REGEX.is_match(target)? { let chain_id = rpc::chain_id(rpc_url).await?; return Ok(format!("{}/output/{}/{}/{}", cwd, chain_id, target, filename)); } else { diff --git a/common/src/ether/rpc.rs b/common/src/ether/rpc.rs index 7d0a8f17..20216adf 100644 --- a/common/src/ether/rpc.rs +++ b/common/src/ether/rpc.rs @@ -1,13 +1,15 @@ -use std::{str::FromStr, time::Duration}; - use crate::{debug_max, utils::io::logging::Logger}; use backoff::ExponentialBackoff; use ethers::{ core::types::Address, providers::{Http, Middleware, Provider}, - types::{BlockTrace, StateDiff, TraceType, Transaction, H256}, + types::{ + BlockNumber::{self}, + BlockTrace, Filter, FilterBlockOption, StateDiff, TraceType, Transaction, H256, + }, }; use heimdall_cache::{read_cache, store_cache}; +use std::{str::FromStr, time::Duration}; /// Get the chainId of the provided RPC URL /// @@ -395,6 +397,70 @@ pub async fn get_trace( .map_err(|_| Box::from("failed to get trace")) } +/// Get all logs for the given block number +/// +/// ```no_run +/// use heimdall_common::ether::rpc::get_logs; +/// +/// // let logs = get_logs(1, "https://eth.llamarpc.com").await; +/// // assert!(logs.is_ok()); +/// ``` +pub async fn get_block_logs( + block_number: u64, + rpc_url: &str, +) -> Result, Box> { + backoff::future::retry( + ExponentialBackoff { + max_elapsed_time: Some(Duration::from_secs(10)), + ..ExponentialBackoff::default() + }, + || async { + // create new logger + let logger = Logger::default(); + + debug_max!(&format!("fetching logs from node for block: '{}' .", &block_number)); + + // create new provider + let provider = match Provider::::try_from(rpc_url) { + Ok(provider) => provider, + Err(_) => { + logger.error(&format!("failed to connect to RPC provider '{}' .", &rpc_url)); + return Err(backoff::Error::Permanent(())) + } + }; + + // fetch the logs for the block + let logs = match provider + .get_logs(&Filter { + block_option: FilterBlockOption::Range { + from_block: Some(BlockNumber::from(block_number)), + to_block: Some(BlockNumber::from(block_number)), + }, + address: None, + topics: [None, None, None, None], + }) + .await + { + Ok(logs) => logs, + Err(e) => { + logger.error(&format!( + "failed to fetch logs for block '{}' . does your RPC provider support it?", + &block_number + )); + logger.error(&format!("error: '{e}' .")); + return Err(backoff::Error::Permanent(())) + } + }; + + debug_max!("fetched logs for block '{}' .", &block_number); + + Ok(logs) + }, + ) + .await + .map_err(|_| Box::from("failed to get logs")) +} + // TODO: add tests #[cfg(test)] pub mod tests {} diff --git a/core/Cargo.toml b/core/Cargo.toml index 6f63cff9..4e090ea1 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -8,6 +8,7 @@ readme = "README.md" version = "0.6.5" [dependencies] +async-recursion = "1.0.5" thiserror = "1.0.50" backtrace = "0.3" clap = {version = "3.1.18", features = ["derive"]} diff --git a/core/src/inspect/core/logs.rs b/core/src/inspect/core/logs.rs new file mode 100644 index 00000000..dcf6eafb --- /dev/null +++ b/core/src/inspect/core/logs.rs @@ -0,0 +1,83 @@ +// TODO: impl decodedlog for log + +use async_convert::{async_trait, TryFrom}; +use ethers::types::{Address, Bytes, Log, H256, U256, U64}; +use serde::{Deserialize, Serialize}; + +/// Represents a decoded log +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)] +pub struct DecodedLog { + /// H160. the contract that emitted the log + pub address: Address, + + /// topics: Array of 0 to 4 32 Bytes of indexed log arguments. + /// (In solidity: The first topic is the hash of the signature of the event + /// (e.g. `Deposit(address,bytes32,uint256)`), except you declared the event + /// with the anonymous specifier.) + pub topics: Vec, + + /// Data + pub data: Bytes, + + /// Block Hash + #[serde(rename = "blockHash")] + #[serde(skip_serializing_if = "Option::is_none")] + pub block_hash: Option, + + /// Block Number + #[serde(rename = "blockNumber")] + #[serde(skip_serializing_if = "Option::is_none")] + pub block_number: Option, + + /// Transaction Hash + #[serde(rename = "transactionHash")] + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction_hash: Option, + + /// Transaction Index + #[serde(rename = "transactionIndex")] + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction_index: Option, + + /// Integer of the log index position in the block. None if it's a pending log. + #[serde(rename = "logIndex")] + #[serde(skip_serializing_if = "Option::is_none")] + pub log_index: Option, + + /// Integer of the transactions index position log was created from. + /// None when it's a pending log. + #[serde(rename = "transactionLogIndex")] + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction_log_index: Option, + + /// Log Type + #[serde(rename = "logType")] + #[serde(skip_serializing_if = "Option::is_none")] + pub log_type: Option, + + /// True when the log was removed, due to a chain reorganization. + /// false if it's a valid log. + #[serde(skip_serializing_if = "Option::is_none")] + pub removed: Option, +} + +#[async_trait] +impl TryFrom for DecodedLog { + type Error = crate::error::Error; + + async fn try_from(value: Log) -> Result { + Ok(Self { + address: value.address, + topics: value.topics, + data: value.data, + block_hash: value.block_hash, + block_number: value.block_number, + transaction_hash: value.transaction_hash, + transaction_index: value.transaction_index, + log_index: value.log_index, + transaction_log_index: value.transaction_log_index, + log_type: value.log_type, + removed: value.removed, + }) + } +} diff --git a/core/src/inspect/core/mod.rs b/core/src/inspect/core/mod.rs index ed583ff9..3f256a7c 100644 --- a/core/src/inspect/core/mod.rs +++ b/core/src/inspect/core/mod.rs @@ -1,2 +1,4 @@ pub(crate) mod contracts; +pub mod logs; +pub mod storage; pub mod tracing; diff --git a/core/src/inspect/core/storage.rs b/core/src/inspect/core/storage.rs index 968dd9f1..8337712e 100644 --- a/core/src/inspect/core/storage.rs +++ b/core/src/inspect/core/storage.rs @@ -1,3 +1 @@ - - -/// Converts a raw [`StateDiff`] to a human-readable diff. +// diff --git a/core/src/inspect/core/tracing.rs b/core/src/inspect/core/tracing.rs index d4da4517..cd7720a8 100644 --- a/core/src/inspect/core/tracing.rs +++ b/core/src/inspect/core/tracing.rs @@ -1,10 +1,14 @@ -use std::collections::{HashSet, VecDeque}; +use std::{ + borrow::BorrowMut, + collections::{HashSet, VecDeque}, +}; +use async_recursion::async_recursion; use ethers::{ abi::Token, types::{ - ActionType, Address, Bytes, Call, CallResult, CallType, Create, CreateResult, Reward, - Suicide, TransactionTrace, U256, + ActionType, Address, Bytes, Call, CallResult, CallType, Create, CreateResult, + ExecutedInstruction, Reward, Suicide, TransactionTrace, VMTrace, U256, }, }; use heimdall_common::ether::signatures::ResolvedFunction; @@ -14,6 +18,8 @@ use crate::{decode::DecodeArgsBuilder, error::Error}; use async_convert::{async_trait, TryFrom}; use futures::future::try_join_all; +use super::logs::DecodedLog; + /// Decoded Trace #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] pub struct DecodedTransactionTrace { @@ -23,6 +29,7 @@ pub struct DecodedTransactionTrace { pub result: Option, pub error: Option, pub subtraces: Vec, + pub logs: Vec, } /// Decoded Action @@ -116,7 +123,6 @@ impl TryFrom> for DecodedTransactionTrace { // Iterate through the trace address, navigating through subtraces for &index in trace_address.iter().take(trace_address.len() - 1) { current_trace = current_trace.subtraces.get_mut(index).ok_or(Error::DecodeError)?; - // You might need to define this error } // Insert the decoded trace into the correct position @@ -164,6 +170,7 @@ impl TryFrom for DecodedTransactionTrace { result, error: value.error, subtraces: Vec::new(), // we will build this later + logs: Vec::new(), // we will build this later }) } } @@ -289,4 +296,53 @@ impl DecodedTransactionTrace { addresses } + + #[async_recursion] + pub async fn join_logs( + &mut self, + decoded_logs: &mut VecDeque, + vm_trace: VMTrace, + parent_address: Vec, + ) -> Result<(), Error> { + // Track the current depth using trace_address. Initialize with the trace_address of self. + let mut current_address = parent_address.clone(); + let mut relative_index = 0; + + // Iterate over vm_trace.ops + for op in vm_trace.ops { + match op.op { + // Check if the operation is one of the LOG operations + ExecutedInstruction::Known(ethers::types::Opcode::LOG0) | + ExecutedInstruction::Known(ethers::types::Opcode::LOG1) | + ExecutedInstruction::Known(ethers::types::Opcode::LOG2) | + ExecutedInstruction::Known(ethers::types::Opcode::LOG3) | + ExecutedInstruction::Known(ethers::types::Opcode::LOG4) => { + // Pop the first decoded log, this is the log that corresponds to the current + // operation + let decoded_log = decoded_logs.pop_front().ok_or(Error::DecodeError)?; + + // add the log to the correct position in the trace + let mut current_trace = self.borrow_mut(); + for &index in current_address.iter() { + current_trace = + current_trace.subtraces.get_mut(index).ok_or(Error::DecodeError)?; + } + + // push decoded log into current_trace.logs + current_trace.logs.push(decoded_log); + } + _ => {} + } + + // Handle subtraces if present + if let Some(sub) = op.sub { + current_address.push(relative_index); + let _ = &self.join_logs(decoded_logs, sub, current_address.clone()).await?; + current_address.pop(); + relative_index += 1; + } + } + + Ok(()) + } } diff --git a/core/src/inspect/mod.rs b/core/src/inspect/mod.rs index daf4a19f..68a3873c 100644 --- a/core/src/inspect/mod.rs +++ b/core/src/inspect/mod.rs @@ -1,18 +1,21 @@ mod core; +use std::collections::VecDeque; + use clap::{AppSettings, Parser}; use derive_builder::Builder; -use ethers::types::TransactionTrace; +use ethers::types::{Log, TransactionTrace, U256, U64}; +use futures::future::try_join_all; use heimdall_common::{ - ether::rpc::{get_trace, get_transaction}, + ether::rpc::{get_block_logs, get_trace, get_transaction}, utils::io::logging::Logger, }; use crate::error::Error; -use self::core::{contracts::Contracts, tracing::DecodedTransactionTrace}; +use self::core::{contracts::Contracts, logs::DecodedLog, tracing::DecodedTransactionTrace}; #[derive(Debug, Clone, Parser, Builder)] #[clap( @@ -41,6 +44,14 @@ pub struct InspectArgs { /// Your OPTIONAL Transpose.io API Key, used for labeling contract addresses. #[clap(long = "transpose-api-key", short, hide_default_value = true)] pub transpose_api_key: Option, + + /// Name for the output files. + #[clap(long, short, default_value = "", hide_default_value = true)] + pub name: String, + + /// The output directory to write the output to, or 'print' to print to the console. + #[clap(long = "output", short = 'o', default_value = "output", hide_default_value = true)] + pub output: String, } impl InspectArgsBuilder { @@ -51,6 +62,8 @@ impl InspectArgsBuilder { rpc_url: Some(String::new()), default: Some(true), transpose_api_key: None, + name: Some(String::new()), + output: Some(String::from("output")), } } } @@ -83,15 +96,37 @@ pub async fn inspect(args: InspectArgs) -> Result { }); // get calldata from RPC - let _transaction = get_transaction(&args.target, &args.rpc_url) + let transaction = get_transaction(&args.target, &args.rpc_url) .await .map_err(|e| Error::RpcError(e.to_string()))?; + let block_number = transaction.block_number.unwrap_or(U64::zero()).as_u64(); // get trace let block_trace = get_trace(&args.target, &args.rpc_url).await.map_err(|e| Error::RpcError(e.to_string()))?; - let decoded_trace = + // get logs for this transaction + let transaction_logs = get_block_logs(block_number, &args.rpc_url) + .await + .map_err(|e| Error::RpcError(e.to_string()))? + .into_iter() + .filter(|log| log.transaction_hash == Some(transaction.hash)) + .collect::>(); + + // convert Vec to Vec + let handles = + transaction_logs.into_iter().map(>::try_from); + + logger.debug(&format!("decoding {} logs", handles.len())); + + // sort logs by log index + let mut decoded_logs = try_join_all(handles).await?; + decoded_logs.sort_by(|a, b| { + a.log_index.unwrap_or(U256::zero()).cmp(&b.log_index.unwrap_or(U256::zero())) + }); + let mut decoded_logs = VecDeque::from(decoded_logs); + + let mut decoded_trace = match block_trace.trace { Some(trace) => , @@ -100,20 +135,38 @@ pub async fn inspect(args: InspectArgs) -> Result { .ok(), None => None, }; - if decoded_trace.is_none() { - logger.warn("no trace found for transaction"); - } + if let Some(decoded_trace) = decoded_trace.as_mut() { + logger.debug("resolving address contract labels"); - // get contracts client and extend with addresses from trace - let mut contracts = Contracts::new(&args); - if let Some(decoded_trace) = decoded_trace.clone() { + // get contracts client + let mut contracts = Contracts::new(&args); contracts .extend(decoded_trace.addresses(true, true).into_iter().collect()) .await .map_err(|e| Error::GenericError(e.to_string()))?; - }; - println!("{:#?}", contracts); + // extend with addresses from state diff + if let Some(state_diff) = block_trace.state_diff { + contracts + .extend(state_diff.0.keys().cloned().collect()) + .await + .map_err(|e| Error::GenericError(e.to_string()))?; + } else { + logger + .warn("no state diff found for transaction. skipping state diff label resolution"); + } + + logger.debug(&format!("joining {} decoded logs to trace", decoded_logs.len())); + + if let Some(vm_trace) = block_trace.vm_trace { + // join logs to trace + let _ = decoded_trace.join_logs(&mut decoded_logs, vm_trace, Vec::new()).await; + } else { + logger.warn("no vm trace found for transaction. skipping joining logs"); + } + } else { + logger.warn("no trace found for transaction"); + } Ok(InspectResult { decoded_trace }) } From 43d4b24cdb9e791c715969ec94ff4ae27b938ea6 Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Sun, 10 Dec 2023 21:21:22 -0500 Subject: [PATCH 06/15] feat(inspect): impl `TryFrom for DecodedLog` --- common/src/ether/signatures.rs | 18 ++++++++++++++++++ core/src/inspect/core/contracts.rs | 1 + core/src/inspect/core/logs.rs | 29 ++++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/common/src/ether/signatures.rs b/common/src/ether/signatures.rs index 7c5cbed7..82e1c5f5 100644 --- a/common/src/ether/signatures.rs +++ b/common/src/ether/signatures.rs @@ -40,6 +40,12 @@ pub trait ResolveSelector { #[async_trait] impl ResolveSelector for ResolvedError { async fn resolve(selector: &str) -> Option> { + // normalize selector + let selector = match selector.strip_prefix("0x") { + Some(selector) => selector, + None => selector, + }; + debug_max!("resolving error selector {}", &selector); // get cached results @@ -119,6 +125,12 @@ impl ResolveSelector for ResolvedError { #[async_trait] impl ResolveSelector for ResolvedLog { async fn resolve(selector: &str) -> Option> { + // normalize selector + let selector = match selector.strip_prefix("0x") { + Some(selector) => selector, + None => selector, + }; + debug_max!("resolving event selector {}", &selector); // get cached results @@ -198,6 +210,12 @@ impl ResolveSelector for ResolvedLog { #[async_trait] impl ResolveSelector for ResolvedFunction { async fn resolve(selector: &str) -> Option> { + // normalize selector + let selector = match selector.strip_prefix("0x") { + Some(selector) => selector, + None => selector, + }; + debug_max!("resolving event selector {}", &selector); // get cached results diff --git a/core/src/inspect/core/contracts.rs b/core/src/inspect/core/contracts.rs index c487bd71..58cd8b84 100644 --- a/core/src/inspect/core/contracts.rs +++ b/core/src/inspect/core/contracts.rs @@ -12,6 +12,7 @@ pub struct Contracts { transpose_api_key: Option, } +#[allow(dead_code)] impl Contracts { pub fn new(args: &InspectArgs) -> Self { Self { contracts: HashMap::new(), transpose_api_key: args.transpose_api_key.clone() } diff --git a/core/src/inspect/core/logs.rs b/core/src/inspect/core/logs.rs index dcf6eafb..8124f33c 100644 --- a/core/src/inspect/core/logs.rs +++ b/core/src/inspect/core/logs.rs @@ -2,10 +2,15 @@ use async_convert::{async_trait, TryFrom}; use ethers::types::{Address, Bytes, Log, H256, U256, U64}; +use heimdall_common::{ + debug_max, + ether::signatures::{ResolveSelector, ResolvedLog}, + utils::hex::ToLowerHex, +}; use serde::{Deserialize, Serialize}; /// Represents a decoded log -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct DecodedLog { /// H160. the contract that emitted the log pub address: Address, @@ -19,6 +24,11 @@ pub struct DecodedLog { /// Data pub data: Bytes, + /// Resolved Event + #[serde(rename = "resolvedEvent")] + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved_event: Option, + /// Block Hash #[serde(rename = "blockHash")] #[serde(skip_serializing_if = "Option::is_none")] @@ -66,6 +76,22 @@ impl TryFrom for DecodedLog { type Error = crate::error::Error; async fn try_from(value: Log) -> Result { + let signature = match value.topics.first() { + Some(topic) => { + let topic = topic.to_lower_hex(); + Some(topic) + } + None => None, + }; + + let resolved_logs = match signature { + Some(signature) => { + debug_max!("resolving signature: {}", signature.to_string().to_lowercase()); + ResolvedLog::resolve(&signature).await.unwrap_or(Vec::new()) + } + None => Vec::new(), + }; + Ok(Self { address: value.address, topics: value.topics, @@ -78,6 +104,7 @@ impl TryFrom for DecodedLog { transaction_log_index: value.transaction_log_index, log_type: value.log_type, removed: value.removed, + resolved_event: resolved_logs.first().cloned(), }) } } From c69346ac111298fa21f6c1c140259f1bf448c274 Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Sun, 10 Dec 2023 23:32:07 -0500 Subject: [PATCH 07/15] feat(resources): add trace display --- cli/src/main.rs | 5 +- common/src/resources/transpose.rs | 20 +++---- common/src/utils/io/logging.rs | 14 ++--- core/src/inspect/core/tracing.rs | 92 ++++++++++++++++++++++++++++++- core/src/inspect/mod.rs | 26 +++++++-- 5 files changed, 132 insertions(+), 25 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 08f9cb0e..f6c14ce0 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -233,7 +233,7 @@ async fn main() -> Result<(), Box> { cmd.openai_api_key = configuration.openai_api_key; } - // set cmd.verbose to 6 + // set cmd.verbose to 5 cmd.verbose = clap_verbosity_flag::Verbosity::new(5, 0); let _ = decode(cmd).await; @@ -357,6 +357,9 @@ async fn main() -> Result<(), Box> { filename = format!("{}-{}", given_name, filename); } + // set cmd.verbose to 5 + cmd.verbose = clap_verbosity_flag::Verbosity::new(5, 0); + let inspect_result = inspect(cmd.clone()).await?; if cmd.output == "print" { diff --git a/common/src/resources/transpose.rs b/common/src/resources/transpose.rs index 7afc5987..eaa7f92a 100644 --- a/common/src/resources/transpose.rs +++ b/common/src/resources/transpose.rs @@ -3,7 +3,7 @@ use reqwest::header::HeaderMap; use serde_json::Value; use std::time::{Duration, Instant}; -use crate::utils::io::logging::Logger; +use crate::{debug_max, utils::io::logging::Logger}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -255,10 +255,6 @@ pub async fn get_contract_creation( /// // let label = get_label(address, api_key).await; /// ``` pub async fn get_label(address: &str, api_key: &str) -> Option { - // get a new logger - let logger = Logger::default(); - let start_time = Instant::now(); - // build the SQL query let query = format!( "{{\"sql\":\"SELECT COALESCE( (SELECT name FROM ethereum.contract_labels WHERE contract_address = '{address}' ), (SELECT ens_name FROM ethereum.ens_names WHERE primary_address = '{address}' LIMIT 1), (SELECT protocol_name FROM ethereum.protocols WHERE contract_address = '{address}' ), (SELECT symbol FROM ethereum.tokens WHERE contract_address = '{address}' ), (SELECT symbol FROM ethereum.collections WHERE contract_address = '{address}' ) ) as label\",\"parameters\":{{}},\"options\":{{\"timeout\": 999999999}}}}", @@ -267,25 +263,29 @@ pub async fn get_label(address: &str, api_key: &str) -> Option { let response = match call_transpose(&query, api_key).await { Some(response) => response, None => { - logger.error("failed to get contract label from Transpose"); + debug_max!(&format!("failed to get label from Transpose for address: {}", address)); return None; } }; - logger.debug(&format!("fetching contract creation took {:?}", start_time.elapsed())); - // parse the results if let Some(result) = response.results.into_iter().next() { let label: String = match result.get("label") { Some(label) => match label.as_str() { Some(label) => label.to_string(), None => { - logger.error("failed to parse label from Transpose"); + debug_max!(&format!( + "failed to parse label from Transpose for address: {}", + address + )); return None; } }, None => { - logger.error("failed to fetch label from Transpose response"); + debug_max!(&format!( + "failed to fetch label from Transpose response for address: {}", + address + )); return None; } }; diff --git a/common/src/utils/io/logging.rs b/common/src/utils/io/logging.rs index 20438c4f..265e93ba 100644 --- a/common/src/utils/io/logging.rs +++ b/common/src/utils/io/logging.rs @@ -134,9 +134,10 @@ impl TraceFactory { } TraceCategory::Log => { println!( - "{} emit {}", + "{} emit {} {}", replace_last(prefix, "│ ", " ├─").bold().bright_white(), - trace.message.first().expect("Failed to build trace.") + trace.message.first().expect("Failed to build trace."), + format!("[log index: {}]", trace.instruction).dimmed(), ); } TraceCategory::LogUnknown => { @@ -146,13 +147,8 @@ impl TraceFactory { let message = trace.message.get(message_index).expect("Failed to build trace."); println!( - "{} {} {}: {}", - if message_index == 0 { - replace_last(prefix, "│ ", " ├─").bold().bright_white() - } else { - replace_last(prefix, "│ ", " │ ").bold().bright_white() - }, - if message_index == 0 { "emit" } else { " " }, + "{} {}: {}", + replace_last(prefix, "│ ", " │ ").bold().bright_white(), format!("topic {message_index}").purple(), message ); diff --git a/core/src/inspect/core/tracing.rs b/core/src/inspect/core/tracing.rs index cd7720a8..461b3d1b 100644 --- a/core/src/inspect/core/tracing.rs +++ b/core/src/inspect/core/tracing.rs @@ -11,7 +11,13 @@ use ethers::{ ExecutedInstruction, Reward, Suicide, TransactionTrace, VMTrace, U256, }, }; -use heimdall_common::ether::signatures::ResolvedFunction; +use heimdall_common::{ + ether::signatures::ResolvedFunction, + utils::{ + hex::ToLowerHex, + io::{logging::TraceFactory, types::Parameterize}, + }, +}; use serde::{Deserialize, Serialize}; use crate::{decode::DecodeArgsBuilder, error::Error}; @@ -345,4 +351,88 @@ impl DecodedTransactionTrace { Ok(()) } + + pub fn add_to_trace(&self, trace: &mut TraceFactory, parent_trace_index: u32) { + // iterate over traces + for decoded_trace in self.subtraces.iter() { + let parent_trace_index = match &decoded_trace.action { + DecodedAction::Call(call) => trace.add_call_with_extra( + parent_trace_index, + call.gas.as_u32(), + call.to.to_lower_hex(), + match call.resolved_function.as_ref() { + Some(f) => f.name.clone(), + None => "unknown".to_string(), + }, + match call.resolved_function.as_ref() { + Some(f) => f + .decoded_inputs + .clone() + .unwrap_or_default() + .iter() + .map(|token| token.parameterize()) + .collect(), + None => vec![], + }, + match decoded_trace.result.as_ref() { + Some(DecodedRes::Call(call_result)) => { + let outputs = call_result + .decoded_outputs + .iter() + .map(|token| token.parameterize()) + .collect::>(); + + if outputs.is_empty() { + [call_result.output.to_lower_hex()].join(", ") + } else { + outputs.join(", ") + } + } + _ => "".to_string(), + }, + vec![ + format!("{:?}", call.call_type).to_lowercase(), + format!("value: {} wei", wei_to_ether(call.value)), + ], + ), + DecodedAction::Create(_) => todo!(), + DecodedAction::Suicide(_) => todo!(), + DecodedAction::Reward(_) => todo!(), + }; + + // for each log, add to trace + for log in &decoded_trace.logs { + if let Some(event) = &log.resolved_event { + // TODO: ResolveLog should decode raw data + trace.add_emission( + parent_trace_index, + log.log_index.unwrap_or(U256::zero()).as_u32(), + event.name.clone(), + event.inputs.clone(), + ); + trace.add_raw_emission( + parent_trace_index, + log.log_index.unwrap_or(U256::zero()).as_u32(), + log.topics.iter().map(|topic| topic.to_lower_hex()).collect(), + log.data.to_lower_hex(), + ); + } else { + trace.add_raw_emission( + parent_trace_index, + log.log_index.unwrap_or(U256::zero()).as_u32(), + log.topics.iter().map(|topic| topic.to_lower_hex()).collect(), + log.data.to_lower_hex(), + ); + } + } + + decoded_trace.add_to_trace(trace, parent_trace_index) + } + } +} + +fn wei_to_ether(wei: U256) -> f64 { + // convert U256 to u64 + let wei = wei.as_u64() as f64; + wei / 10f64.powf(18.0) } diff --git a/core/src/inspect/mod.rs b/core/src/inspect/mod.rs index 68a3873c..2050556a 100644 --- a/core/src/inspect/mod.rs +++ b/core/src/inspect/mod.rs @@ -9,8 +9,12 @@ use derive_builder::Builder; use ethers::types::{Log, TransactionTrace, U256, U64}; use futures::future::try_join_all; use heimdall_common::{ + debug_max, ether::rpc::{get_block_logs, get_trace, get_transaction}, - utils::io::logging::Logger, + utils::{ + hex::ToLowerHex, + io::logging::{Logger, TraceFactory}, + }, }; use crate::error::Error; @@ -117,7 +121,7 @@ pub async fn inspect(args: InspectArgs) -> Result { let handles = transaction_logs.into_iter().map(>::try_from); - logger.debug(&format!("decoding {} logs", handles.len())); + debug_max!(&format!("resolving event signatures for {} logs", handles.len())); // sort logs by log index let mut decoded_logs = try_join_all(handles).await?; @@ -136,7 +140,7 @@ pub async fn inspect(args: InspectArgs) -> Result { None => None, }; if let Some(decoded_trace) = decoded_trace.as_mut() { - logger.debug("resolving address contract labels"); + debug_max!("resolving address contract labels"); // get contracts client let mut contracts = Contracts::new(&args); @@ -156,7 +160,7 @@ pub async fn inspect(args: InspectArgs) -> Result { .warn("no state diff found for transaction. skipping state diff label resolution"); } - logger.debug(&format!("joining {} decoded logs to trace", decoded_logs.len())); + debug_max!(&format!("joining {} decoded logs to trace", decoded_logs.len())); if let Some(vm_trace) = block_trace.vm_trace { // join logs to trace @@ -164,6 +168,20 @@ pub async fn inspect(args: InspectArgs) -> Result { } else { logger.warn("no vm trace found for transaction. skipping joining logs"); } + + let mut trace = TraceFactory::default(); + let inspect_call = trace.add_call( + 0, + transaction.gas.as_u32(), + "heimdall".to_string(), + "inspect".to_string(), + vec![transaction.hash.to_lower_hex()], + "()".to_string(), + ); + + decoded_trace.add_to_trace(&mut trace, inspect_call); + + trace.display(); } else { logger.warn("no trace found for transaction"); } From 1c1b25d674741165c487ad0f6c9f439b1e873b26 Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Mon, 11 Dec 2023 09:00:48 -0500 Subject: [PATCH 08/15] feat(inspect): finalize trace builder with aliases --- common/src/utils/io/logging.rs | 33 +++++- core/src/inspect/core/tracing.rs | 176 ++++++++++++++++++------------- core/src/inspect/mod.rs | 2 +- 3 files changed, 138 insertions(+), 73 deletions(-) diff --git a/common/src/utils/io/logging.rs b/common/src/utils/io/logging.rs index 265e93ba..af325b19 100644 --- a/common/src/utils/io/logging.rs +++ b/common/src/utils/io/logging.rs @@ -31,6 +31,7 @@ pub enum TraceCategory { Call, Create, Empty, + Suicide, } /// Individual trace, which is added to the trace factory. @@ -155,7 +156,7 @@ impl TraceFactory { } println!( "{} {}: {}", - replace_last(prefix, "│ ", " │ ").bold().blue(), + replace_last(prefix, "│ ", " │ ").bold().bright_white(), "data".purple(), trace.message.last().expect("Failed to build trace.") ); @@ -225,6 +226,15 @@ impl TraceFactory { trace.message.get(1).expect("Failed to build trace.").bold().green() ) } + TraceCategory::Suicide => { + println!( + "{} {} {} selfdestruct → {}", + replace_last(prefix, "│ ", " ├─").bold().bright_white(), + format!("[{}]", trace.instruction).bold().bright_white(), + trace.message.first().expect("Failed to build trace."), + trace.message.get(1).expect("Failed to build trace.") + ); + } } } @@ -284,6 +294,26 @@ impl TraceFactory { self.add("create", parent_index, instruction, vec![contract, format!("{size} bytes")]) } + /// adds a suicide event + pub fn add_suicide( + &mut self, + parent_index: u32, + instruction: u32, + address: String, + refund_address: String, + refund_amount: f64, + ) -> u32 { + self.add( + "suicide", + parent_index, + instruction, + vec![ + address, + format!("{} {}", refund_address, format!("[{} ether]", refund_amount).dimmed()), + ], + ) + } + /// adds a known log trace pub fn add_emission( &mut self, @@ -359,6 +389,7 @@ impl Trace { "call" => TraceCategory::Call, "create" => TraceCategory::Create, "empty" => TraceCategory::Empty, + "suicide" => TraceCategory::Suicide, _ => TraceCategory::Message, }, instruction, diff --git a/core/src/inspect/core/tracing.rs b/core/src/inspect/core/tracing.rs index 461b3d1b..18c27b21 100644 --- a/core/src/inspect/core/tracing.rs +++ b/core/src/inspect/core/tracing.rs @@ -24,7 +24,7 @@ use crate::{decode::DecodeArgsBuilder, error::Error}; use async_convert::{async_trait, TryFrom}; use futures::future::try_join_all; -use super::logs::DecodedLog; +use super::{contracts::Contracts, logs::DecodedLog}; /// Decoded Trace #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] @@ -352,81 +352,115 @@ impl DecodedTransactionTrace { Ok(()) } - pub fn add_to_trace(&self, trace: &mut TraceFactory, parent_trace_index: u32) { - // iterate over traces - for decoded_trace in self.subtraces.iter() { - let parent_trace_index = match &decoded_trace.action { - DecodedAction::Call(call) => trace.add_call_with_extra( - parent_trace_index, - call.gas.as_u32(), - call.to.to_lower_hex(), - match call.resolved_function.as_ref() { - Some(f) => f.name.clone(), - None => "unknown".to_string(), - }, - match call.resolved_function.as_ref() { - Some(f) => f - .decoded_inputs - .clone() - .unwrap_or_default() + pub fn add_to_trace( + &self, + contracts: &Contracts, + trace: &mut TraceFactory, + parent_trace_index: u32, + ) { + let parent_trace_index = match &self.action { + DecodedAction::Call(call) => trace.add_call_with_extra( + parent_trace_index, + call.gas.as_u32(), + contracts.get(call.to).unwrap_or(&call.to.to_lower_hex()).clone(), + match call.resolved_function.as_ref() { + Some(f) => f.name.clone(), + None => "fallback".to_string(), + }, + match call.resolved_function.as_ref() { + Some(f) => f + .decoded_inputs + .clone() + .unwrap_or_default() + .iter() + .map(|token| token.parameterize()) + .collect(), + None => vec![], + }, + match &self.result.as_ref() { + Some(DecodedRes::Call(call_result)) => { + let outputs = call_result + .decoded_outputs .iter() .map(|token| token.parameterize()) - .collect(), - None => vec![], - }, - match decoded_trace.result.as_ref() { - Some(DecodedRes::Call(call_result)) => { - let outputs = call_result - .decoded_outputs - .iter() - .map(|token| token.parameterize()) - .collect::>(); - - if outputs.is_empty() { - [call_result.output.to_lower_hex()].join(", ") - } else { - outputs.join(", ") - } + .collect::>(); + + if outputs.is_empty() { + [call_result.output.to_lower_hex()].join(", ") + } else { + outputs.join(", ") } - _ => "".to_string(), - }, - vec![ - format!("{:?}", call.call_type).to_lowercase(), - format!("value: {} wei", wei_to_ether(call.value)), - ], - ), - DecodedAction::Create(_) => todo!(), - DecodedAction::Suicide(_) => todo!(), - DecodedAction::Reward(_) => todo!(), - }; - - // for each log, add to trace - for log in &decoded_trace.logs { - if let Some(event) = &log.resolved_event { - // TODO: ResolveLog should decode raw data - trace.add_emission( - parent_trace_index, - log.log_index.unwrap_or(U256::zero()).as_u32(), - event.name.clone(), - event.inputs.clone(), - ); - trace.add_raw_emission( - parent_trace_index, - log.log_index.unwrap_or(U256::zero()).as_u32(), - log.topics.iter().map(|topic| topic.to_lower_hex()).collect(), - log.data.to_lower_hex(), - ); - } else { - trace.add_raw_emission( - parent_trace_index, - log.log_index.unwrap_or(U256::zero()).as_u32(), - log.topics.iter().map(|topic| topic.to_lower_hex()).collect(), - log.data.to_lower_hex(), - ); - } + } + _ => "".to_string(), + }, + vec![ + format!("{:?}", call.call_type).to_lowercase(), + format!("value: {} wei", wei_to_ether(call.value)), + ], + ), + DecodedAction::Create(create) => trace.add_creation( + parent_trace_index, + create.gas.as_u32(), + "NewContract".to_string(), + match &self.result.as_ref() { + Some(DecodedRes::Create(create_result)) => contracts + .get(create_result.address) + .unwrap_or(&create_result.address.to_lower_hex()) + .clone(), + _ => "".to_string(), + }, + create.init.len().try_into().unwrap_or(0), + ), + DecodedAction::Suicide(suicide) => trace.add_suicide( + parent_trace_index, + 0, + suicide.address.to_lower_hex(), + suicide.refund_address.to_lower_hex(), + wei_to_ether(suicide.balance), + ), + DecodedAction::Reward(reward) => trace.add_call_with_extra( + parent_trace_index, + 0, + Address::zero().to_lower_hex(), + "reward".to_string(), + vec![ + reward.author.to_lower_hex(), + format!("{:?}", reward.reward_type).to_lowercase(), + ], + "()".to_string(), + vec![format!("value: {} ether", wei_to_ether(reward.value))], + ), + }; + + // for each log, add to trace + for log in &self.logs { + if let Some(event) = &log.resolved_event { + // TODO: ResolveLog should decode raw data + trace.add_emission( + parent_trace_index, + log.log_index.unwrap_or(U256::zero()).as_u32(), + event.name.clone(), + event.inputs.clone(), + ); + trace.add_raw_emission( + parent_trace_index, + log.log_index.unwrap_or(U256::zero()).as_u32(), + log.topics.iter().map(|topic| topic.to_lower_hex()).collect(), + log.data.to_lower_hex(), + ); + } else { + trace.add_raw_emission( + parent_trace_index, + log.log_index.unwrap_or(U256::zero()).as_u32(), + log.topics.iter().map(|topic| topic.to_lower_hex()).collect(), + log.data.to_lower_hex(), + ); } + } - decoded_trace.add_to_trace(trace, parent_trace_index) + // iterate over traces + for decoded_trace in self.subtraces.iter() { + decoded_trace.add_to_trace(contracts, trace, parent_trace_index) } } } diff --git a/core/src/inspect/mod.rs b/core/src/inspect/mod.rs index 2050556a..5cb4b1c5 100644 --- a/core/src/inspect/mod.rs +++ b/core/src/inspect/mod.rs @@ -179,7 +179,7 @@ pub async fn inspect(args: InspectArgs) -> Result { "()".to_string(), ); - decoded_trace.add_to_trace(&mut trace, inspect_call); + decoded_trace.add_to_trace(&contracts, &mut trace, inspect_call); trace.display(); } else { From 300aeceabd8160c64062b71b187e115335965a46 Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Mon, 11 Dec 2023 09:05:33 -0500 Subject: [PATCH 09/15] fix(doctests): typo --- common/src/ether/rpc.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/ether/rpc.rs b/common/src/ether/rpc.rs index 20216adf..8ff61491 100644 --- a/common/src/ether/rpc.rs +++ b/common/src/ether/rpc.rs @@ -400,9 +400,9 @@ pub async fn get_trace( /// Get all logs for the given block number /// /// ```no_run -/// use heimdall_common::ether::rpc::get_logs; +/// use heimdall_common::ether::rpc::get_block_logs; /// -/// // let logs = get_logs(1, "https://eth.llamarpc.com").await; +/// // let logs = get_block_logs(1, "https://eth.llamarpc.com").await; /// // assert!(logs.is_ok()); /// ``` pub async fn get_block_logs( From bbf1126daa69888e2fa4659063bb09db8a926f1b Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Mon, 11 Dec 2023 09:26:05 -0500 Subject: [PATCH 10/15] feat(inspect): storage diff in trace --- common/src/utils/hex.rs | 8 ++++- core/src/inspect/core/logs.rs | 2 -- core/src/inspect/core/mod.rs | 1 - core/src/inspect/core/storage.rs | 1 - core/src/inspect/core/tracing.rs | 55 +++++++++++++++++++++++++++++++- core/src/inspect/mod.rs | 4 ++- 6 files changed, 64 insertions(+), 7 deletions(-) delete mode 100644 core/src/inspect/core/storage.rs diff --git a/common/src/utils/hex.rs b/common/src/utils/hex.rs index e0ef2b71..1db57408 100644 --- a/common/src/utils/hex.rs +++ b/common/src/utils/hex.rs @@ -1,4 +1,4 @@ -use ethers::types::{Bloom, Bytes, H160, H256, H64}; +use ethers::types::{Bloom, Bytes, H160, H256, H64, U256}; use super::strings::encode_hex; @@ -12,6 +12,12 @@ impl ToLowerHex for H256 { } } +impl ToLowerHex for U256 { + fn to_lower_hex(&self) -> String { + format!("{:#032x}", self) + } +} + impl ToLowerHex for H160 { fn to_lower_hex(&self) -> String { format!("{:#020x}", self) diff --git a/core/src/inspect/core/logs.rs b/core/src/inspect/core/logs.rs index 8124f33c..52f1ebf7 100644 --- a/core/src/inspect/core/logs.rs +++ b/core/src/inspect/core/logs.rs @@ -1,5 +1,3 @@ -// TODO: impl decodedlog for log - use async_convert::{async_trait, TryFrom}; use ethers::types::{Address, Bytes, Log, H256, U256, U64}; use heimdall_common::{ diff --git a/core/src/inspect/core/mod.rs b/core/src/inspect/core/mod.rs index 3f256a7c..d45bca5d 100644 --- a/core/src/inspect/core/mod.rs +++ b/core/src/inspect/core/mod.rs @@ -1,4 +1,3 @@ pub(crate) mod contracts; pub mod logs; -pub mod storage; pub mod tracing; diff --git a/core/src/inspect/core/storage.rs b/core/src/inspect/core/storage.rs deleted file mode 100644 index 8337712e..00000000 --- a/core/src/inspect/core/storage.rs +++ /dev/null @@ -1 +0,0 @@ -// diff --git a/core/src/inspect/core/tracing.rs b/core/src/inspect/core/tracing.rs index 18c27b21..5ef0c680 100644 --- a/core/src/inspect/core/tracing.rs +++ b/core/src/inspect/core/tracing.rs @@ -8,7 +8,7 @@ use ethers::{ abi::Token, types::{ ActionType, Address, Bytes, Call, CallResult, CallType, Create, CreateResult, - ExecutedInstruction, Reward, Suicide, TransactionTrace, VMTrace, U256, + ExecutedInstruction, Reward, StorageDiff, Suicide, TransactionTrace, VMTrace, U256, }, }; use heimdall_common::{ @@ -36,6 +36,7 @@ pub struct DecodedTransactionTrace { pub error: Option, pub subtraces: Vec, pub logs: Vec, + pub diff: Vec, } /// Decoded Action @@ -177,6 +178,7 @@ impl TryFrom for DecodedTransactionTrace { error: value.error, subtraces: Vec::new(), // we will build this later logs: Vec::new(), // we will build this later + diff: Vec::new(), // we will build this later }) } } @@ -352,6 +354,44 @@ impl DecodedTransactionTrace { Ok(()) } + #[async_recursion] + pub async fn build_state_diffs( + &mut self, + vm_trace: VMTrace, + parent_address: Vec, + ) -> Result<(), Error> { + // Track the current depth using trace_address. Initialize with the trace_address of self. + let mut current_address = parent_address.clone(); + let mut relative_index = 0; + + // Iterate over vm_trace.ops + for op in vm_trace.ops { + if let Some(ex) = op.ex { + if let Some(store) = ex.store { + // add the diff to the correct position in the trace + let mut current_trace = self.borrow_mut(); + for &index in current_address.iter() { + current_trace = + current_trace.subtraces.get_mut(index).ok_or(Error::DecodeError)?; + } + + // push decoded log into current_trace.diff + current_trace.diff.push(store); + } + } + + // Handle subtraces if present + if let Some(sub) = op.sub { + current_address.push(relative_index); + let _ = &self.build_state_diffs(sub, current_address.clone()).await?; + current_address.pop(); + relative_index += 1; + } + } + + Ok(()) + } + pub fn add_to_trace( &self, contracts: &Contracts, @@ -458,6 +498,19 @@ impl DecodedTransactionTrace { } } + // for each diff, add to trace + for diff in &self.diff { + trace.add_message( + parent_trace_index, + line!(), + vec![format!( + "store '{}' in slot '{}'", + diff.val.to_lower_hex(), + diff.key.to_lower_hex() + )], + ); + } + // iterate over traces for decoded_trace in self.subtraces.iter() { decoded_trace.add_to_trace(contracts, trace, parent_trace_index) diff --git a/core/src/inspect/mod.rs b/core/src/inspect/mod.rs index 5cb4b1c5..a9c2f1b6 100644 --- a/core/src/inspect/mod.rs +++ b/core/src/inspect/mod.rs @@ -164,7 +164,9 @@ pub async fn inspect(args: InspectArgs) -> Result { if let Some(vm_trace) = block_trace.vm_trace { // join logs to trace - let _ = decoded_trace.join_logs(&mut decoded_logs, vm_trace, Vec::new()).await; + let _ = decoded_trace.join_logs(&mut decoded_logs, vm_trace.clone(), Vec::new()).await; + // build state diffs within trace + let _ = decoded_trace.build_state_diffs(vm_trace, Vec::new()).await; } else { logger.warn("no vm trace found for transaction. skipping joining logs"); } From bc1bf1604d516d08224c25763de4359565360340 Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Mon, 11 Dec 2023 09:27:13 -0500 Subject: [PATCH 11/15] fix(hex): `U256::to_lower_hex()` padding fix --- common/src/utils/hex.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/utils/hex.rs b/common/src/utils/hex.rs index 1db57408..e66d7a62 100644 --- a/common/src/utils/hex.rs +++ b/common/src/utils/hex.rs @@ -14,7 +14,7 @@ impl ToLowerHex for H256 { impl ToLowerHex for U256 { fn to_lower_hex(&self) -> String { - format!("{:#032x}", self) + format!("{:#0x}", self) } } From 0a15da042e524c8eb67e06ab2a10afbebc35a188 Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Mon, 11 Dec 2023 09:36:06 -0500 Subject: [PATCH 12/15] chore(inspect): add inspect example --- examples/inspect/Cargo.toml | 11 +++++++++++ examples/inspect/src/main.rs | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 examples/inspect/Cargo.toml create mode 100644 examples/inspect/src/main.rs diff --git a/examples/inspect/Cargo.toml b/examples/inspect/Cargo.toml new file mode 100644 index 00000000..c8ca14e4 --- /dev/null +++ b/examples/inspect/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "heimdall-rs-inspect-example" +version = "0.6.5" +edition = "2021" +description = "Heimdall is an advanced Ethereum smart contract toolkit for forensic and heuristic analysis." +keywords = ["ethereum", "web3", "decompiler", "evm", "crypto"] +license = "MIT" + +[dependencies] +heimdall-core = { git = "https://github.com/Jon-Becker/heimdall-rs.git", branch = "main" } +tokio = {version = "1", features = ["full"]} diff --git a/examples/inspect/src/main.rs b/examples/inspect/src/main.rs new file mode 100644 index 00000000..f1a5e5a3 --- /dev/null +++ b/examples/inspect/src/main.rs @@ -0,0 +1,16 @@ +use heimdall_core::inspect::InspectArgsBuilder; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let result = heimdall_core::inspect::inspect( + InspectArgsBuilder::new() + .target("0xa5f676d0ee4c23cc1ccb0b802be5aaead5827a3337c06e9da8b0a85dfa3e7dd5".to_string()) + .rpc_url("https://eth.llamarpc.com".to_string()) + .build()?, + ) + .await?; + + println!("InspectResult: {:#?}", result); + + Ok(()) +} From ee916b955b06daa6d48f5910651d78443b3bb09e Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Mon, 11 Dec 2023 09:48:15 -0500 Subject: [PATCH 13/15] chore(inspect): add tests --- core/tests/test_inspect.rs | 100 +++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 core/tests/test_inspect.rs diff --git a/core/tests/test_inspect.rs b/core/tests/test_inspect.rs new file mode 100644 index 00000000..28689534 --- /dev/null +++ b/core/tests/test_inspect.rs @@ -0,0 +1,100 @@ +#[cfg(test)] +mod integration_tests { + use clap_verbosity_flag::Verbosity; + use heimdall_common::utils::{sync::blocking_await, threading::task_pool}; + use heimdall_core::inspect::{InspectArgs, InspectArgsBuilder}; + use serde_json::Value; + + #[tokio::test] + async fn test_inspect_simple() { + let args = InspectArgs { + target: String::from( + "0xa5f676d0ee4c23cc1ccb0b802be5aaead5827a3337c06e9da8b0a85dfa3e7dd5", + ), + verbose: Verbosity::new(0, 0), + rpc_url: String::from("https://eth.llamarpc.com"), + default: true, + transpose_api_key: None, + name: String::from(""), + output: String::from("output"), + }; + + let _ = heimdall_core::inspect::inspect(args).await.unwrap(); + } + + #[tokio::test] + async fn test_inspect_create() { + let args = InspectArgs { + target: String::from( + "0x37321f192623002fc4b398b90ea825c37f81e29526fd355cff93ef6962fc0fba", + ), + verbose: Verbosity::new(0, 0), + rpc_url: String::from("https://eth.llamarpc.com"), + default: true, + transpose_api_key: None, + name: String::from(""), + output: String::from("output"), + }; + + let _ = heimdall_core::inspect::inspect(args).await.unwrap(); + } + + /// Thorough testing for inspect across a large number of transactions. + #[test] + #[ignore] + fn heavy_test_inspect_thorough() { + // load ./tests/testdata/txids.json into a vector using serde + let txids = serde_json::from_str::( + &std::fs::read_to_string("./tests/testdata/txids.json").unwrap(), + ) + .unwrap() + .get("txids") + .unwrap() + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect::>(); + let total = txids.len(); + + // task_pool(items, num_threads, f) + let results = task_pool(txids, 10, |txid: String| { + let args = InspectArgsBuilder::new() + .target(txid.to_string()) + .verbose(Verbosity::new(-1, 0)) + .rpc_url("https://eth.llamarpc.com".to_string()) + .build() + .unwrap(); + + blocking_await(move || { + // get new blocking runtime + let rt = tokio::runtime::Runtime::new().unwrap(); + + // get the storage diff for this transaction + println!("inspecting txid: {}", txid); + match rt.block_on(heimdall_core::inspect::inspect(args)) { + Ok(_) => { + println!("inspecting txid: {} ... succeeded", txid); + 1 + } + Err(_) => { + println!("inspecting txid: {} ... failed", txid); + 0 + } + } + }) + }); + let success_count = results.iter().filter(|r| **r == 1).count(); + + // assert 95% of the transactions were successful + let success_rate = (success_count as f64) / (total as f64); + println!( + "heavy_test_inspect_thorough:\n * total: {}\n * failed: {}\n * success rate: {}", + total, + total - success_count, + success_rate * 100.0 + ); + + assert!(success_rate >= 0.93); + } +} From a4005dc471f3b0a128cb44644a80fa399c534619 Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Mon, 11 Dec 2023 09:49:36 -0500 Subject: [PATCH 14/15] chore(inspect): add ex to workspace --- examples/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/Cargo.toml b/examples/Cargo.toml index a7de198c..d3219359 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -6,5 +6,6 @@ members = [ "snapshot", "decompile", "disassemble", + "inspect" ] version = "0.6.5" From bc07ac305c3a6b587a297baf52580a4c2b99bf7d Mon Sep 17 00:00:00 2001 From: Jon-Becker Date: Mon, 11 Dec 2023 10:00:11 -0500 Subject: [PATCH 15/15] chore: fix typo --- cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index f6c14ce0..d710d85f 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -75,7 +75,7 @@ pub enum Subcommands { #[clap( name = "snapshot", - about = "Infer functiogn information from bytecode, including access control, gas + about = "Infer function information from bytecode, including access control, gas consumption, storage accesses, event emissions, and more" )] Snapshot(SnapshotArgs),