use std::fs::File;
use std::io::{self, BufWriter, Read, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use clap::{Args, Parser, Subcommand};
use evm_state_diff::{
Address, B256, BlockId, Filter, Format, Kind, Report, RpcClient, StateDiff, TracerOptions, TxDiff, U256,
Unit, diffs_from_traces, parse_traces_str, render,
};
#[derive(Parser, Debug)]
#[command(name = "evm-state-diff", version)]
struct Cli {
#[command(subcommand)]
command: Command,
#[command(flatten)]
output: OutputArgs,
#[command(flatten)]
filter: FilterArgs,
}
#[derive(Subcommand, Debug)]
enum Command {
Tx {
hash: B256,
#[command(flatten)]
rpc: RpcArgs,
},
Block {
block: BlockId,
#[command(flatten)]
rpc: RpcArgs,
#[arg(long)]
per_tx: bool,
},
Range {
from: u64,
to: u64,
#[command(flatten)]
rpc: RpcArgs,
#[arg(long)]
per_tx: bool,
},
File {
path: PathBuf,
#[arg(long)]
per_tx: bool,
},
}
#[derive(Args, Debug)]
struct RpcArgs {
#[arg(long, env = "ETH_RPC_URL")]
rpc: String,
#[arg(long, default_value_t = 60)]
timeout: u64,
#[arg(long)]
no_code: bool,
#[arg(long, default_value_t = 2)]
retries: u32,
#[arg(long = "header", short = 'H', value_parser = parse_header)]
headers: Vec<(String, String)>,
}
fn parse_header(s: &str) -> Result<(String, String), String> {
let (name, value) =
s.split_once(':').ok_or_else(|| format!("header `{s}` must look like `Name: value`"))?;
let name = name.trim();
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') {
return Err(format!("invalid header name `{name}`"));
}
Ok((name.to_owned(), value.trim().to_owned()))
}
#[derive(Args, Debug)]
struct OutputArgs {
#[arg(long, short, global = true, value_enum, default_value_t = Format::Text)]
format: Format,
#[arg(long, global = true, value_enum, default_value_t = Unit::Ether)]
unit: Unit,
#[arg(long, short, global = true)]
output: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct FilterArgs {
#[arg(long = "address", global = true)]
addresses: Vec<Address>,
#[arg(long, global = true)]
exclude: Vec<Address>,
#[arg(long = "kind", global = true)]
kinds: Vec<Kind>,
#[arg(long = "slot", global = true, value_parser = parse_slot)]
slots: Vec<B256>,
#[arg(long, global = true)]
min_balance_delta: Option<U256>,
}
fn parse_slot(s: &str) -> Result<B256, String> {
B256::from_word(s).map_err(|e| e.to_string())
}
impl FilterArgs {
fn build(&self) -> Filter {
Filter {
include: self.addresses.iter().copied().collect(),
exclude: self.exclude.iter().copied().collect(),
kinds: self.kinds.iter().copied().collect(),
slots: self.slots.iter().copied().collect(),
min_balance_delta: self.min_balance_delta,
}
}
}
fn client(args: &RpcArgs) -> (RpcClient, TracerOptions) {
let opts = TracerOptions { disable_code: args.no_code, disable_storage: false };
let mut builder = RpcClient::builder(&args.rpc)
.timeout(Duration::from_secs(args.timeout))
.retries(args.retries, Duration::from_millis(500));
for (name, value) in &args.headers {
builder = builder.header(name, value);
}
(builder.build(), opts)
}
fn build_report(diffs: Vec<TxDiff>, label: String, per_tx: bool, filter: &Filter) -> Report {
if per_tx {
Report::PerTx(
diffs.into_iter().map(|t| TxDiff { tx_hash: t.tx_hash, diff: filter.apply(&t.diff) }).collect(),
)
} else {
let net = StateDiff::compose(diffs.iter().map(|t| &t.diff));
Report::Net { label, transactions: diffs.len(), diff: filter.apply(&net) }
}
}
const MAX_RANGE: u64 = 10_000;
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
let filter = cli.filter.build();
let report = match &cli.command {
Command::Tx { hash, rpc } => {
let (client, opts) = client(rpc);
let trace = client.trace_transaction(hash, opts)?;
build_report(diffs_from_traces(&[trace]), format!("tx {hash}"), false, &filter)
}
Command::Block { block, rpc, per_tx } => {
let (client, opts) = client(rpc);
let traces = client.trace_block(block, opts)?;
build_report(diffs_from_traces(&traces), format!("block {block}"), *per_tx, &filter)
}
Command::Range { from, to, rpc, per_tx } => {
if from > to {
return Err(format!("empty range: {from} > {to}").into());
}
if to - from >= MAX_RANGE {
return Err(
format!("range of {} blocks exceeds the limit of {MAX_RANGE}", to - from + 1).into()
);
}
let (client, opts) = client(rpc);
let traces = client.trace_range(*from, *to, opts)?;
build_report(diffs_from_traces(&traces), format!("blocks {from}..={to}"), *per_tx, &filter)
}
Command::File { path, per_tx } => {
let mut text = String::new();
if path.as_os_str() == "-" {
io::stdin().read_to_string(&mut text)?;
} else {
File::open(path)
.map_err(|e| format!("{}: {e}", path.display()))?
.read_to_string(&mut text)?;
}
let traces = parse_traces_str(&text)?;
build_report(diffs_from_traces(&traces), path.display().to_string(), *per_tx, &filter)
}
};
let mut out: Box<dyn Write> = match &cli.output.output {
Some(path) => {
Box::new(BufWriter::new(File::create(path).map_err(|e| format!("{}: {e}", path.display()))?))
}
None => Box::new(BufWriter::new(io::stdout().lock())),
};
render(&report, cli.output.format, cli.output.unit, &mut out)?;
out.flush()?;
Ok(())
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
if e.downcast_ref::<io::Error>().is_some_and(|e| e.kind() == io::ErrorKind::BrokenPipe) {
return ExitCode::SUCCESS;
}
eprintln!("evm-state-diff: {e}");
ExitCode::FAILURE
}
}
}