diff --git a/Cargo.lock b/Cargo.lock index 18041e5f..827aa690 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" @@ -1650,6 +1659,7 @@ dependencies = [ "bincode", "clap", "serde", + "serde_json", ] [[package]] @@ -1712,6 +1722,8 @@ dependencies = [ name = "heimdall-core" version = "0.6.5" dependencies = [ + "async-convert", + "async-recursion", "backtrace", "clap", "clap-verbosity-flag", @@ -1720,6 +1732,7 @@ dependencies = [ "derive_builder", "ethers", "fancy-regex", + "futures", "heimdall-cache", "heimdall-common", "heimdall-config", 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/cli/src/main.rs b/cli/src/main.rs index 427cd7f6..d710d85f 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,9 +66,16 @@ 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 + about = "Infer function information from bytecode, including access control, gas consumption, storage accesses, event emissions, and more" )] Snapshot(SnapshotArgs), @@ -225,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; @@ -330,6 +338,50 @@ 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; + } + + // 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); + } + + // 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); + } + + // 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" { + 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) => { 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 74aee27e..8ff61491 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::{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 /// @@ -27,7 +29,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(['.', ':'], "-")); @@ -39,7 +41,7 @@ debug_max!(&format!("checking chain id for rpc url: '{}'", &rpc_url)); // 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 +49,7 @@ debug_max!(&format!("checking chain id for rpc url: '{}'", &rpc_url)); Ok(provider) => provider, Err(_) => { logger.error(&format!("failed to connect to RPC provider '{}' .", &rpc_url)); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; @@ -61,7 +63,8 @@ debug_max!(&format!("checking chain id for rpc url: '{}'", &rpc_url)); }; // 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,21 +96,20 @@ 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."); - std::process::exit(1); + return Err(backoff::Error::Permanent(())) } // create new provider @@ -115,7 +117,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 +126,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(())) } }; @@ -138,11 +140,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()) }) @@ -158,6 +161,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 +175,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 )); @@ -179,7 +183,7 @@ debug_max!(&format!( // 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 @@ -187,34 +191,276 @@ debug_max!(&format!( Ok(provider) => provider, Err(_) => { logger.error(&format!("failed to connect to RPC provider '{}' .", &rpc_url)); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; // 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)); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }; - // 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 => { logger.error(&format!("transaction '{}' doesn't exist.", &transaction_hash)); - std::process::exit(1) + return Err(backoff::Error::Permanent(())) } }, Err(_) => { 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 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)); + return Err(backoff::Error::Permanent(())) + } + }; + + // 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 + )); + return Err(backoff::Error::Permanent(())) + } + }; + + // 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}' .")); + return Err(backoff::Error::Permanent(())) + } + }; + + // write the state diff to the cache + let _ = store_cache( + &format!("diff.{}.{}", &chain_id, &transaction_hash), + &state_diff, + None, + ) + .map_err(|_| { + logger.error(&format!( + "failed to cache state diff for transaction: {:?}", + &transaction_hash + )) + }); + + debug_max!("fetched state diff for transaction '{}' .", &transaction_hash); + + Ok(state_diff) + }, + ) + .await + .map_err(|_| Box::from("failed to get storage diff")) } + +/// 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(); + + 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)); + return Err(backoff::Error::Permanent(())) + } + }; + + // 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 + )); + return Err(backoff::Error::Permanent(())) + } + }; + + // 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}' .")); + return Err(backoff::Error::Permanent(())) + } + }; + + debug_max!("fetched trace for transaction '{}' .", &transaction_hash); + + Ok(block_trace) + }, + ) + .await + .map_err(|_| Box::from("failed to get trace")) +} + +/// Get all logs for the given block number +/// +/// ```no_run +/// use heimdall_common::ether::rpc::get_block_logs; +/// +/// // let logs = get_block_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/common/src/ether/signatures.rs b/common/src/ether/signatures.rs index c5a5cce6..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 @@ -106,7 +112,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, @@ -118,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 @@ -184,7 +197,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, @@ -196,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 @@ -263,7 +283,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/resources/transpose.rs b/common/src/resources/transpose.rs index bcd87606..91192131 100644 --- a/common/src/resources/transpose.rs +++ b/common/src/resources/transpose.rs @@ -4,7 +4,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)] @@ -247,7 +247,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 { + // 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 => { + debug_max!(&format!("failed to get label from Transpose for address: {}", address)); + return None; + } + }; + + // 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 => { + debug_max!(&format!( + "failed to parse label from Transpose for address: {}", + address + )); + return None; + } + }, + None => { + debug_max!(&format!( + "failed to fetch label from Transpose response for address: {}", + address + )); + 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..e66d7a62 --- /dev/null +++ b/common/src/utils/hex.rs @@ -0,0 +1,49 @@ +use ethers::types::{Bloom, Bytes, H160, H256, H64, U256}; + +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 U256 { + fn to_lower_hex(&self) -> String { + format!("{:#0x}", 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..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. @@ -134,9 +135,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,20 +148,15 @@ 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 ); } println!( "{} {}: {}", - replace_last(prefix, "│ ", " │ ").bold().blue(), + replace_last(prefix, "│ ", " │ ").bold().bright_white(), "data".purple(), trace.message.last().expect("Failed to build trace.") ); @@ -229,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.") + ); + } } } @@ -255,6 +261,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, @@ -268,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, @@ -343,6 +389,7 @@ impl Trace { "call" => TraceCategory::Call, "create" => TraceCategory::Create, "empty" => TraceCategory::Empty, + "suicide" => TraceCategory::Suicide, _ => TraceCategory::Message, }, instruction, @@ -375,6 +422,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/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/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/core/Cargo.toml b/core/Cargo.toml index f5621d5e..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"]} @@ -28,3 +29,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/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 d9947a3e..afd9a1ed 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, @@ -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/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..a7827801 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -8,6 +8,10 @@ pub enum Error { BoundsError, #[error("DecodeError")] DecodeError, + #[error("RPCError: {0}")] + 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..58cd8b84 --- /dev/null +++ b/core/src/inspect/core/contracts.rs @@ -0,0 +1,76 @@ +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, +} + +#[allow(dead_code)] +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/logs.rs b/core/src/inspect/core/logs.rs new file mode 100644 index 00000000..52f1ebf7 --- /dev/null +++ b/core/src/inspect/core/logs.rs @@ -0,0 +1,108 @@ +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)] +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, + + /// 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")] + 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 { + 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, + 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, + resolved_event: resolved_logs.first().cloned(), + }) + } +} diff --git a/core/src/inspect/core/mod.rs b/core/src/inspect/core/mod.rs new file mode 100644 index 00000000..d45bca5d --- /dev/null +++ b/core/src/inspect/core/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod contracts; +pub mod logs; +pub mod tracing; diff --git a/core/src/inspect/core/tracing.rs b/core/src/inspect/core/tracing.rs new file mode 100644 index 00000000..5ef0c680 --- /dev/null +++ b/core/src/inspect/core/tracing.rs @@ -0,0 +1,525 @@ +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, + ExecutedInstruction, Reward, StorageDiff, Suicide, TransactionTrace, VMTrace, U256, + }, +}; +use heimdall_common::{ + ether::signatures::ResolvedFunction, + utils::{ + hex::ToLowerHex, + io::{logging::TraceFactory, types::Parameterize}, + }, +}; +use serde::{Deserialize, Serialize}; + +use crate::{decode::DecodeArgsBuilder, error::Error}; +use async_convert::{async_trait, TryFrom}; +use futures::future::try_join_all; + +use super::{contracts::Contracts, logs::DecodedLog}; + +/// 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, + pub logs: Vec, + pub diff: 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)?; + } + + // 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 + } + } + + 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 + logs: Vec::new(), // we will build this later + diff: 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()); + } + } + + 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, + }); + } + 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); + + if include_outputs { + let _ = self.result.iter().map(|result| { + if let DecodedRes::Create(create_result) = result { + addresses.insert(create_result.address); + } + }); + } + } + DecodedAction::Suicide(suicide) => { + addresses.insert(suicide.address); + addresses.insert(suicide.refund_address); + } + DecodedAction::Reward(reward) => { + addresses.insert(reward.author); + } + }; + + // add all addresses found in subtraces + for subtrace in &self.subtraces { + addresses.extend(subtrace.addresses(include_inputs, include_outputs)) + } + + 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(()) + } + + #[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, + 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::>(); + + 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(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(), + ); + } + } + + // 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) + } + } +} + +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 new file mode 100644 index 00000000..a9c2f1b6 --- /dev/null +++ b/core/src/inspect/mod.rs @@ -0,0 +1,192 @@ +mod core; + +use std::collections::VecDeque; + +use clap::{AppSettings, Parser}; + +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::{ + hex::ToLowerHex, + io::logging::{Logger, TraceFactory}, + }, +}; + +use crate::error::Error; + +use self::core::{contracts::Contracts, logs::DecodedLog, tracing::DecodedTransactionTrace}; + +#[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, + + /// 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 { + 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), + transpose_api_key: None, + name: Some(String::new()), + output: Some(String::from("output")), + } + } +} + +#[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 { + // 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()))?; + 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()))?; + + // 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); + + debug_max!(&format!("resolving event signatures for {} 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) => , + >>::try_from(trace) + .await + .ok(), + None => None, + }; + if let Some(decoded_trace) = decoded_trace.as_mut() { + debug_max!("resolving address contract labels"); + + // 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()))?; + + // 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"); + } + + debug_max!(&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.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"); + } + + 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(&contracts, &mut trace, inspect_call); + + trace.display(); + } else { + logger.warn("no trace found for transaction"); + } + + Ok(InspectResult { decoded_trace }) +} 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; 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; } 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); + } +} 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" 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(()) +}