use alloy_primitives::{Address, Bytes, TxHash};
use eyre::Result;
use foundry_block_explorers::Client;
use foundry_compilers::{
artifacts::{Contract, SolcInput},
solc::Solc,
};
use indicatif::{ProgressBar, ProgressStyle};
use revm::{
context::{
result::{ExecutionResult, HaltReason},
Host, TxEnv,
},
database::CacheDB,
Database, DatabaseCommit, DatabaseRef, InspectEvm, MainBuilder,
};
use semver::Version;
use std::{
collections::{HashMap, HashSet},
fs,
path::PathBuf,
time::Duration,
};
use tracing::{debug, error, info, warn};
use edb_common::{
relax_evm_constraints, types::Trace, CachePath, EdbCachePath, EdbContext, ForkResult,
DEFAULT_ETHERSCAN_CACHE_TTL,
};
use crate::{
analysis::AnalysisResult,
analyze,
inspector::{CallTracer, TraceReplayResult},
instrument,
rpc::RpcServerHandle,
start_debug_server,
utils::{next_etherscan_api_key, Artifact, OnchainCompiler},
CodeTweaker, EngineContext, HookSnapshotInspector, HookSnapshots, OpcodeSnapshotInspector,
OpcodeSnapshots, SnapshotAnalysis, Snapshots,
};
#[derive(Debug, Clone)]
pub struct EngineConfig {
pub rpc_proxy_url: String,
pub etherscan_api_key: Option<String>,
pub quick: bool,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
rpc_proxy_url: "http://localhost:8545".into(),
etherscan_api_key: None,
quick: false,
}
}
}
impl EngineConfig {
pub fn with_etherscan_api_key(mut self, key: String) -> Self {
self.etherscan_api_key = Some(key);
self
}
pub fn with_quick_mode(mut self, quick: bool) -> Self {
self.quick = quick;
self
}
pub fn with_rpc_proxy_url(mut self, url: String) -> Self {
self.rpc_proxy_url = url;
self
}
}
#[derive(Debug)]
pub struct Engine {
pub rpc_proxy_url: String,
pub host_port: Option<u16>,
pub etherscan_api_key: Option<String>,
pub quick: bool,
}
impl Default for Engine {
fn default() -> Self {
Self::new(EngineConfig::default())
}
}
impl Engine {
pub fn new(config: EngineConfig) -> Self {
let EngineConfig { rpc_proxy_url, etherscan_api_key, quick } = config;
Self { rpc_proxy_url, host_port: None, etherscan_api_key, quick }
}
pub async fn prepare<DB>(&self, fork_result: ForkResult<DB>) -> Result<RpcServerHandle>
where
DB: Database + DatabaseCommit + DatabaseRef + Clone + Send + Sync + 'static,
<CacheDB<DB> as Database>::Error: Clone + Send + Sync,
<DB as Database>::Error: Clone + Send + Sync,
{
info!("Starting engine preparation for transaction: {:?}", fork_result.target_tx_hash);
let ForkResult { context: mut ctx, target_tx_env: tx, target_tx_hash: tx_hash, fork_info } =
fork_result;
info!("Replaying transaction to collect call trace and touched contracts");
let replay_result = self.replay_and_collect_trace(ctx.clone(), tx.clone())?;
info!("Downloading verified source code for each contract");
let artifacts =
self.download_verified_source_code(&replay_result, ctx.chain_id().to::<u64>()).await?;
info!("Analyzing source code");
let analysis_results = self.analyze_source_code(&artifacts)?;
info!("Instrumenting source code");
let recompiled_artifacts =
self.instrument_and_recompile_source_code(&artifacts, &analysis_results)?;
info!("Collecting opcode-level step execution results");
let opcode_snapshots = self.capture_opcode_level_snapshots(
ctx.clone(),
tx.clone(),
artifacts.keys().cloned().collect(),
&replay_result.execution_trace,
)?;
info!("Tweaking bytecode");
let contracts_in_tx =
self.tweak_bytecode(&mut ctx, &artifacts, &recompiled_artifacts, tx_hash).await?;
info!("Re-executing transaction with snapshot collection");
let hook_creation =
self.collect_creation_hooks(&artifacts, &recompiled_artifacts, contracts_in_tx)?;
let hook_snapshots = self.capture_hook_snapshots(
ctx.clone(),
tx.clone(),
hook_creation,
&replay_result.execution_trace,
&analysis_results,
)?;
info!("Starting RPC server with analysis results and snapshots");
let mut snapshots = self.get_time_travel_snapshots(opcode_snapshots, hook_snapshots)?;
snapshots.analyze(&replay_result.execution_trace, &analysis_results)?;
let context = EngineContext::build(
fork_info,
ctx.cfg.clone(),
ctx.block.clone(),
tx,
tx_hash,
snapshots,
artifacts,
recompiled_artifacts,
analysis_results,
replay_result.execution_trace,
)?;
let rpc_handle = start_debug_server(context).await?;
info!("Debug RPC server started on port {}", rpc_handle.port());
Ok(rpc_handle)
}
fn replay_and_collect_trace<DB>(
&self,
ctx: EdbContext<DB>,
tx: TxEnv,
) -> Result<TraceReplayResult>
where
DB: Database + DatabaseCommit + DatabaseRef + Clone,
<CacheDB<DB> as Database>::Error: Clone,
<DB as Database>::Error: Clone,
{
info!("Replaying transaction to collect call trace and touched addresses");
let mut tracer = CallTracer::new();
let mut evm = ctx.build_mainnet_with_inspector(&mut tracer);
let result = evm
.inspect_one_tx(tx)
.map_err(|e| eyre::eyre!("Failed to inspect the target transaction: {:?}", e))?;
if let ExecutionResult::Halt { reason, .. } = result {
if matches!(reason, HaltReason::OutOfGas { .. }) {
error!("EDB cannot debug out-of-gas errors. Proceed at your own risk.")
}
}
let result = tracer.into_replay_result();
for (address, deployed) in &result.visited_addresses {
if *deployed {
debug!("Contract {} was deployed during transaction replay", address);
} else {
debug!("Address {} was touched during transaction replay", address);
}
}
result.execution_trace.print_trace_tree();
Ok(result)
}
async fn download_verified_source_code(
&self,
replay_result: &TraceReplayResult,
chain_id: u64,
) -> Result<HashMap<Address, Artifact>> {
info!("Downloading verified source code for touched contracts");
let compiler = OnchainCompiler::new(None)?;
let compiler_cache_root =
EdbCachePath::new(None as Option<PathBuf>).compiler_chain_cache_dir(chain_id);
let addresses: Vec<_> = replay_result.visited_addresses.keys().copied().collect();
let total_contracts = addresses.len();
let console_bar = std::sync::Arc::new(ProgressBar::new(total_contracts as u64));
console_bar.set_style(
ProgressStyle::with_template(
"{spinner:.green} 📜 Downloading & compiling contracts [{bar:40.cyan/blue}] {pos:>3}/{len:3} 🔧 {msg}"
)?
.progress_chars("🟩🟦⬜")
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
);
let mut artifacts = HashMap::new();
for (i, address) in addresses.iter().enumerate() {
let short_addr = &address.to_string()[2..10]; console_bar.set_message(format!("contract {}: 0x{}...", i + 1, short_addr));
let api_key = self.get_etherscan_api_key();
let etherscan = Client::builder()
.with_api_key(api_key)
.with_cache(
compiler_cache_root.clone(),
Duration::from_secs(DEFAULT_ETHERSCAN_CACHE_TTL),
) .chain(chain_id.into())?
.build()?;
match compiler.compile(ðerscan, *address).await {
Ok(Some(artifact)) => {
console_bar.set_message(format!("✅ 0x{short_addr}... compiled"));
artifacts.insert(*address, artifact);
}
Ok(None) => {
console_bar.set_message(format!("⚠️ 0x{short_addr}... no source"));
debug!("No source code available for contract {}", address);
}
Err(e) => {
console_bar.set_message(format!("❌ 0x{short_addr}... failed"));
warn!("Failed to compile contract {}: {:?}", address, e);
}
}
console_bar.inc(1);
}
console_bar.finish_with_message(format!(
"✨ Done! Compiled {} out of {} contracts",
artifacts.len(),
total_contracts
));
Ok(artifacts)
}
fn analyze_source_code(
&self,
artifacts: &HashMap<Address, Artifact>,
) -> Result<HashMap<Address, AnalysisResult>> {
info!("Analyzing source code to identify instrumentation points");
let mut analysis_result = HashMap::new();
for (address, artifact) in artifacts {
debug!("Analyzing contract at address: {}", address);
let analysis = analyze(artifact)?;
analysis_result.insert(*address, analysis);
}
Ok(analysis_result)
}
fn capture_hook_snapshots<'a, DB>(
&self,
mut ctx: EdbContext<DB>,
mut tx: TxEnv,
creation_hooks: Vec<(&'a Contract, &'a Contract, &'a Bytes)>,
trace: &Trace,
analysis_results: &HashMap<Address, AnalysisResult>,
) -> Result<HookSnapshots<DB>>
where
DB: Database + DatabaseCommit + DatabaseRef + Clone,
<CacheDB<DB> as Database>::Error: Clone,
<DB as Database>::Error: Clone,
{
relax_evm_constraints(&mut ctx, &mut tx);
info!("Collecting hook snapshots for source code contracts");
let mut inspector = HookSnapshotInspector::new(trace, analysis_results);
inspector.with_creation_hooks(creation_hooks)?;
let mut evm = ctx.build_mainnet_with_inspector(&mut inspector);
evm.inspect_one_tx(tx)
.map_err(|e| eyre::eyre!("Failed to inspect the target transaction: {:?}", e))?;
let snapshots = inspector.into_snapshots();
snapshots.print_summary();
Ok(snapshots)
}
fn capture_opcode_level_snapshots<DB>(
&self,
ctx: EdbContext<DB>,
tx: TxEnv,
excluded_addresses: HashSet<Address>,
trace: &Trace,
) -> Result<OpcodeSnapshots<DB>>
where
DB: Database + DatabaseCommit + DatabaseRef + Clone,
<CacheDB<DB> as Database>::Error: Clone,
<DB as Database>::Error: Clone,
{
info!("Collecting opcode-level step execution results");
let mut inspector = OpcodeSnapshotInspector::new(&ctx, trace);
inspector.with_excluded_addresses(excluded_addresses);
let mut evm = ctx.build_mainnet_with_inspector(&mut inspector);
evm.inspect_one_tx(tx)
.map_err(|e| eyre::eyre!("Failed to inspect the target transaction: {:?}", e))?;
let snapshots = inspector.into_snapshots();
snapshots.print_summary();
Ok(snapshots)
}
fn instrument_and_recompile_source_code(
&self,
artifacts: &HashMap<Address, Artifact>,
analysis_result: &HashMap<Address, AnalysisResult>,
) -> Result<HashMap<Address, Artifact>> {
info!("Instrumenting source code based on analysis results");
let mut recompiled_artifacts = HashMap::new();
for (address, artifact) in artifacts {
let compiler_version =
Version::parse(artifact.compiler_version().trim_start_matches('v'))?;
let analysis = analysis_result
.get(address)
.ok_or_else(|| eyre::eyre!("No analysis result found for address {}", address))?;
let input = instrument(&compiler_version, &artifact.input, analysis)?;
let meta = artifact.meta.clone();
let version = meta.compiler_version()?;
let compiler = Solc::find_or_install(&version)?;
let output = match compiler.compile_exact(&input) {
Ok(output) => output,
Err(e) => {
let (original_dir, instrumented_dir) =
dump_source_for_debugging(address, &artifact.input, &input)?;
return Err(eyre::eyre!(
"Failed to recompile contract {}\n\nCompiler error: {}\n\nDebug info:\n Original source: {}\n Instrumented source: {}",
address,
e,
original_dir.display(),
instrumented_dir.display()
));
}
};
if output.errors.iter().any(|e| e.is_error()) {
let (original_dir, instrumented_dir) =
dump_source_for_debugging(address, &artifact.input, &input)?;
let formatted_errors = format_compiler_errors(&output.errors, &instrumented_dir);
return Err(eyre::eyre!(
"Recompilation failed for contract {}\n\nCompilation errors:{}\n\nDebug info:\n Original source: {}\n Instrumented source: {}",
address,
formatted_errors,
original_dir.display(),
instrumented_dir.display()
));
}
debug!(
"Recompiled Contract {}: {} vs {}",
address,
artifact.output.contracts.len(),
output.contracts.len()
);
recompiled_artifacts.insert(*address, Artifact { meta, input, output });
}
Ok(recompiled_artifacts)
}
async fn tweak_bytecode<DB>(
&self,
ctx: &mut EdbContext<DB>,
artifacts: &HashMap<Address, Artifact>,
recompiled_artifacts: &HashMap<Address, Artifact>,
tx_hash: TxHash,
) -> Result<Vec<Address>>
where
DB: Database + DatabaseCommit + DatabaseRef + Clone,
<CacheDB<DB> as Database>::Error: Clone,
<DB as Database>::Error: Clone,
{
let mut tweaker =
CodeTweaker::new(ctx, self.rpc_proxy_url.clone(), self.etherscan_api_key.clone());
let mut contracts_in_tx = Vec::new();
for (address, recompiled_artifact) in recompiled_artifacts {
let creation_tx_hash = tweaker.get_creation_tx(address).await?;
if creation_tx_hash == tx_hash {
debug!("Skip tweaking contract {}, since it was created by the transaction under investigation", address);
contracts_in_tx.push(*address);
continue;
}
let artifact = artifacts
.get(address)
.ok_or_else(|| eyre::eyre!("No original artifact found for address {}", address))?;
tweaker.tweak(address, artifact, recompiled_artifact, self.quick).await.map_err(
|e| eyre::eyre!("Failed to tweak bytecode for contract {}: {}", address, e),
)?;
}
Ok(contracts_in_tx)
}
fn collect_creation_hooks<'a>(
&self,
artifacts: &'a HashMap<Address, Artifact>,
recompiled_artifacts: &'a HashMap<Address, Artifact>,
contracts_in_tx: Vec<Address>,
) -> Result<Vec<(&'a Contract, &'a Contract, &'a Bytes)>> {
info!("Collecting creation hooks for contracts in transaction");
let mut hook_creation = Vec::new();
for address in contracts_in_tx {
let Some(artifact) = artifacts.get(&address) else {
eyre::bail!("No original artifact found for address {}", address);
};
let Some(recompiled_artifact) = recompiled_artifacts.get(&address) else {
eyre::bail!("No recompiled artifact found for address {}", address);
};
hook_creation.extend(artifact.find_creation_hooks(recompiled_artifact));
}
Ok(hook_creation)
}
fn get_time_travel_snapshots<DB>(
&self,
opcode_snapshots: OpcodeSnapshots<DB>,
hook_snapshots: HookSnapshots<DB>,
) -> Result<Snapshots<DB>>
where
DB: Database + DatabaseCommit + DatabaseRef + Clone,
<CacheDB<DB> as Database>::Error: Clone,
<DB as Database>::Error: Clone,
{
let snapshots = Snapshots::merge(opcode_snapshots, hook_snapshots);
snapshots.print_summary();
Ok(snapshots)
}
}
impl Engine {
fn get_etherscan_api_key(&self) -> String {
self.etherscan_api_key.clone().unwrap_or(next_etherscan_api_key())
}
}
fn sanitize_path(path: &std::path::Path) -> PathBuf {
use std::path::Component;
let mut sanitized = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(name) => {
sanitized.push(name);
}
Component::CurDir => {
}
Component::ParentDir => {
warn!("Skipping parent directory component in path: {:?}", path);
}
Component::RootDir => {
warn!("Skipping root directory component in path: {:?}", path);
}
Component::Prefix(_) => {
warn!("Skipping prefix component in path: {:?}", path);
}
}
}
if sanitized.as_os_str().is_empty() {
sanitized.push("unnamed_source");
}
sanitized
}
fn extract_code_context(
file_path: &std::path::Path,
start_pos: i32,
end_pos: i32,
context_lines: usize,
) -> Option<String> {
use std::io::{BufRead, BufReader};
let file = fs::File::open(file_path).ok()?;
let reader = BufReader::new(file);
let lines: Vec<String> = reader.lines().map_while(Result::ok).collect();
let mut current_pos = 0i32;
let mut start_line = 0;
let mut start_col = 0;
let mut end_line = 0;
let mut end_col = 0;
for (line_num, line) in lines.iter().enumerate() {
let line_start = current_pos;
let line_end = current_pos + line.len() as i32 + 1;
if start_pos >= line_start && start_pos < line_end {
start_line = line_num;
start_col = (start_pos - line_start) as usize;
}
if end_pos >= line_start && end_pos <= line_end {
end_line = line_num;
end_col = (end_pos - line_start) as usize;
}
current_pos = line_end;
}
let mut context = String::new();
let context_start = start_line.saturating_sub(context_lines);
let context_end = (end_line + context_lines + 1).min(lines.len());
for line_num in context_start..context_end {
if line_num >= lines.len() {
break;
}
let line_number = line_num + 1; let line = &lines[line_num];
if line_num >= start_line && line_num <= end_line {
context.push_str(&format!(" {line_number} | {line}\n"));
if line_num == start_line {
let padding = format!(" {line_number} | ").len();
let mut underline = " ".repeat(padding + start_col);
let underline_len = if start_line == end_line {
(end_col - start_col).max(1)
} else {
line.len() - start_col
};
underline.push_str(&"^".repeat(underline_len));
context.push_str(&format!("{underline}\n"));
}
} else {
context.push_str(&format!(" {line_number} | {line}\n"));
}
}
Some(context)
}
fn format_compiler_errors(
errors: &[foundry_compilers::artifacts::Error],
dump_dir: &std::path::Path,
) -> String {
let mut formatted = String::new();
for error in errors.iter().filter(|e| e.is_error()) {
formatted.push_str("\n\n");
if let Some(error_code) = &error.error_code {
formatted.push_str(&format!("Error [{error_code}]: "));
} else {
formatted.push_str("Error: ");
}
formatted.push_str(&error.message);
if let Some(loc) = &error.source_location {
formatted.push_str(&format!("\n --> {}:{}:{}", loc.file.as_str(), loc.start, loc.end));
let sanitized_path = sanitize_path(std::path::Path::new(&loc.file));
let source_file = dump_dir.join(&sanitized_path);
if let Some(context) = extract_code_context(&source_file, loc.start, loc.end, 5) {
formatted.push_str("\n\n");
formatted.push_str(&context);
}
}
if let Some(formatted_msg) = &error.formatted_message {
if !formatted_msg.trim().is_empty() {
formatted.push_str("\n\nCompiler's formatted output:\n");
formatted.push_str(formatted_msg);
}
}
if !error.secondary_source_locations.is_empty() {
for sec_loc in &error.secondary_source_locations {
if let Some(msg) = &sec_loc.message {
formatted.push_str(&format!("\n Note: {msg}"));
}
if let Some(file) = &sec_loc.file {
formatted.push_str(&format!(
"\n --> {}:{}:{}",
file,
sec_loc.start.map(|s| s.to_string()).unwrap_or_else(|| "?".to_string()),
sec_loc.end.map(|e| e.to_string()).unwrap_or_else(|| "?".to_string())
));
if let (Some(start), Some(end)) = (sec_loc.start, sec_loc.end) {
let sanitized_path = sanitize_path(std::path::Path::new(file));
let source_file = dump_dir.join(&sanitized_path);
if let Some(context) = extract_code_context(&source_file, start, end, 1) {
formatted.push('\n');
formatted.push_str(&context);
}
}
}
}
}
}
if formatted.is_empty() {
formatted.push_str("\nNo specific error details available");
}
formatted
}
fn dump_source_for_debugging(
address: &Address,
original_input: &SolcInput,
instrumented_input: &SolcInput,
) -> Result<(PathBuf, PathBuf)> {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let debug_dir = temp_dir.join(format!("edb_debug_{address}"));
let original_dir = debug_dir.join("original");
let instrumented_dir = debug_dir.join("instrumented");
fs::create_dir_all(&original_dir)?;
fs::create_dir_all(&instrumented_dir)?;
for (path_str, source) in &original_input.sources {
let path = std::path::Path::new(path_str);
let sanitized_path = sanitize_path(path);
let file_path = original_dir.join(&sanitized_path);
if !file_path.starts_with(&original_dir) {
return Err(eyre::eyre!(
"Path traversal detected in source path: {}",
path_str.display()
));
}
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = fs::File::create(&file_path)?;
file.write_all(source.content.as_bytes())?;
}
let settings_path = original_dir.join("settings.json");
let mut settings_file = fs::File::create(&settings_path)?;
settings_file.write_all(serde_json::to_string_pretty(&original_input.settings)?.as_bytes())?;
for (path_str, source) in &instrumented_input.sources {
let path = std::path::Path::new(path_str);
let sanitized_path = sanitize_path(path);
let file_path = instrumented_dir.join(&sanitized_path);
if !file_path.starts_with(&instrumented_dir) {
return Err(eyre::eyre!(
"Path traversal detected in source path: {}",
path_str.display()
));
}
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = fs::File::create(&file_path)?;
file.write_all(source.content.as_bytes())?;
}
let settings_path = instrumented_dir.join("settings.json");
let mut settings_file = fs::File::create(&settings_path)?;
settings_file
.write_all(serde_json::to_string_pretty(&instrumented_input.settings)?.as_bytes())?;
Ok((original_dir, instrumented_dir))
}