Skip to main content

evm_state_diff/
render.rs

1use std::io::{self, Write};
2
3use serde_json::{Map, Value, json};
4
5use crate::diff::{AccountDiff, AccountStatus, StateDiff, Summary, TxDiff};
6use crate::primitives::{B256, Delta, U256, encode_hex};
7
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
10pub enum Format {
11    #[default]
12    Text,
13    Json,
14    Ndjson,
15    Csv,
16}
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
20pub enum Unit {
21    #[default]
22    Ether,
23    Wei,
24}
25
26/// What to print: one net diff, or one diff per transaction.
27#[derive(Clone, Debug)]
28pub enum Report {
29    Net { label: String, transactions: usize, diff: StateDiff },
30    PerTx(Vec<TxDiff>),
31}
32
33pub fn render(report: &Report, format: Format, unit: Unit, out: &mut impl Write) -> io::Result<()> {
34    match format {
35        Format::Text => text(report, unit, out),
36        Format::Json => {
37            serde_json::to_writer_pretty(&mut *out, &json_report(report))?;
38            writeln!(out)
39        }
40        Format::Ndjson => {
41            for row in rows(report) {
42                serde_json::to_writer(&mut *out, &row.to_json())?;
43                writeln!(out)?;
44            }
45            Ok(())
46        }
47        Format::Csv => {
48            writeln!(out, "tx,address,status,kind,slot,from,to,delta")?;
49            for row in rows(report) {
50                writeln!(
51                    out,
52                    "{},{},{},{},{},{},{},{}",
53                    row.tx.unwrap_or_default(),
54                    row.address,
55                    row.status,
56                    row.kind,
57                    row.slot.unwrap_or_default(),
58                    row.from,
59                    row.to,
60                    row.delta.unwrap_or_default()
61                )?;
62            }
63            Ok(())
64        }
65    }
66}
67
68fn status_name(s: AccountStatus) -> &'static str {
69    match s {
70        AccountStatus::Created => "created",
71        AccountStatus::Destroyed => "destroyed",
72        AccountStatus::Modified => "modified",
73    }
74}
75
76/// A 32-byte word as a minimal hex quantity, for readable storage output.
77fn compact(word: &B256) -> String {
78    U256::from_be_slice(&word.0).expect("32 bytes").to_hex_quantity()
79}
80
81fn amount(v: U256, unit: Unit) -> String {
82    match unit {
83        Unit::Ether => format!("{} ETH", v.format_ether()),
84        Unit::Wei => format!("{v} wei"),
85    }
86}
87
88fn signed(d: Delta, unit: Unit) -> String {
89    match unit {
90        Unit::Ether => format!("{} ETH", d.format_ether()),
91        Unit::Wei => format!("{d} wei"),
92    }
93}
94
95fn text(report: &Report, unit: Unit, out: &mut impl Write) -> io::Result<()> {
96    match report {
97        Report::Net { label, transactions, diff } => {
98            let plural = if *transactions == 1 { "" } else { "s" };
99            writeln!(out, "== {label} ({transactions} transaction{plural})")?;
100            text_diff(diff, unit, out)
101        }
102        Report::PerTx(txs) => {
103            for (i, tx) in txs.iter().enumerate() {
104                if i > 0 {
105                    writeln!(out)?;
106                }
107                match tx.tx_hash {
108                    Some(h) => writeln!(out, "== tx {h}")?,
109                    None => writeln!(out, "== tx #{i}")?,
110                }
111                text_diff(&tx.diff, unit, out)?;
112            }
113            Ok(())
114        }
115    }
116}
117
118fn text_diff(diff: &StateDiff, unit: Unit, out: &mut impl Write) -> io::Result<()> {
119    if diff.is_empty() {
120        return writeln!(out, "no state changes");
121    }
122    for (addr, acct) in &diff.accounts {
123        writeln!(out, "{addr}  {}", status_name(acct.status))?;
124        if let (Some(c), Some(d)) = (acct.balance, acct.balance_delta()) {
125            writeln!(
126                out,
127                "  balance  {} -> {}  ({})",
128                amount(c.from, unit),
129                amount(c.to, unit),
130                signed(d, unit)
131            )?;
132        }
133        if let Some(c) = acct.nonce {
134            writeln!(out, "  nonce    {} -> {}", c.from, c.to)?;
135        }
136        if let Some(c) = &acct.code {
137            writeln!(out, "  code     {} bytes -> {} bytes", c.from.len(), c.to.len())?;
138        } else if let Some(c) = acct.code_hash {
139            writeln!(out, "  codehash {} -> {}", c.from, c.to)?;
140        }
141        for (slot, c) in &acct.storage {
142            writeln!(out, "  storage  {}: {} -> {}", compact(slot), compact(&c.from), compact(&c.to))?;
143        }
144    }
145    let s = diff.summary();
146    writeln!(
147        out,
148        "summary: {} accounts ({} created, {} destroyed); {} balance, {} nonce, {} code, {} storage changes",
149        s.accounts,
150        s.created,
151        s.destroyed,
152        s.balance_changes,
153        s.nonce_changes,
154        s.code_changes,
155        s.storage_changes
156    )
157}
158
159fn summary_json(s: &Summary) -> Value {
160    json!({
161        "accounts": s.accounts,
162        "created": s.created,
163        "destroyed": s.destroyed,
164        "balance_changes": s.balance_changes,
165        "nonce_changes": s.nonce_changes,
166        "code_changes": s.code_changes,
167        "storage_changes": s.storage_changes,
168        "total_credited_wei": s.total_credited.to_string(),
169        "total_debited_wei": s.total_debited.to_string(),
170    })
171}
172
173fn account_json(acct: &AccountDiff) -> Value {
174    let mut o = Map::new();
175    o.insert("status".into(), json!(status_name(acct.status)));
176    if let (Some(c), Some(d)) = (acct.balance, acct.balance_delta()) {
177        o.insert(
178            "balance".into(),
179            json!({"from": c.from.to_string(), "to": c.to.to_string(), "delta": d.to_string()}),
180        );
181    }
182    if let Some(c) = acct.nonce {
183        o.insert("nonce".into(), json!({"from": c.from, "to": c.to}));
184    }
185    if let Some(c) = &acct.code {
186        o.insert(
187            "code".into(),
188            json!({"from": c.from.to_string(), "to": c.to.to_string(), "from_len": c.from.len(), "to_len": c.to.len()}),
189        );
190    }
191    if let Some(c) = acct.code_hash {
192        o.insert("code_hash".into(), json!({"from": c.from.to_string(), "to": c.to.to_string()}));
193    }
194    if !acct.storage.is_empty() {
195        let slots: Map<String, Value> = acct
196            .storage
197            .iter()
198            .map(|(slot, c)| (slot.to_string(), json!({"from": c.from.to_string(), "to": c.to.to_string()})))
199            .collect();
200        o.insert("storage".into(), Value::Object(slots));
201    }
202    Value::Object(o)
203}
204
205fn diff_json(diff: &StateDiff) -> Value {
206    let accounts: Map<String, Value> =
207        diff.accounts.iter().map(|(a, acct)| (a.to_string(), account_json(acct))).collect();
208    json!({"summary": summary_json(&diff.summary()), "accounts": accounts})
209}
210
211fn json_report(report: &Report) -> Value {
212    match report {
213        Report::Net { label, transactions, diff } => {
214            let mut v = diff_json(diff);
215            v["source"] = json!(label);
216            v["transactions"] = json!(transactions);
217            v
218        }
219        Report::PerTx(txs) => json!({
220            "transactions": txs.iter().map(|t| {
221                let mut v = diff_json(&t.diff);
222                v["tx_hash"] = t.tx_hash.map_or(Value::Null, |h| json!(h.to_string()));
223                v
224            }).collect::<Vec<_>>()
225        }),
226    }
227}
228
229/// One flattened change, for NDJSON and CSV.
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct Row {
232    pub tx: Option<String>,
233    pub address: String,
234    pub status: &'static str,
235    pub kind: &'static str,
236    pub slot: Option<String>,
237    pub from: String,
238    pub to: String,
239    pub delta: Option<String>,
240}
241
242impl Row {
243    fn to_json(&self) -> Value {
244        let mut v = json!({
245            "tx": self.tx,
246            "address": self.address,
247            "status": self.status,
248            "kind": self.kind,
249            "from": self.from,
250            "to": self.to,
251        });
252        if let Some(slot) = &self.slot {
253            v["slot"] = json!(slot);
254        }
255        if let Some(delta) = &self.delta {
256            v["delta"] = json!(delta);
257        }
258        v
259    }
260}
261
262fn diff_rows(tx: Option<String>, diff: &StateDiff, out: &mut Vec<Row>) {
263    for (addr, acct) in &diff.accounts {
264        let row = |kind, slot, from: String, to: String, delta| Row {
265            tx: tx.clone(),
266            address: addr.to_string(),
267            status: status_name(acct.status),
268            kind,
269            slot,
270            from,
271            to,
272            delta,
273        };
274        if let (Some(c), Some(d)) = (acct.balance, acct.balance_delta()) {
275            out.push(row("balance", None, c.from.to_string(), c.to.to_string(), Some(d.to_string())));
276        }
277        if let Some(c) = acct.nonce {
278            out.push(row("nonce", None, c.from.to_string(), c.to.to_string(), None));
279        }
280        if let Some(c) = &acct.code {
281            out.push(row("code", None, encode_hex(&c.from.0), encode_hex(&c.to.0), None));
282        }
283        if let Some(c) = acct.code_hash {
284            out.push(row("code_hash", None, c.from.to_string(), c.to.to_string(), None));
285        }
286        for (slot, c) in &acct.storage {
287            out.push(row("storage", Some(slot.to_string()), c.from.to_string(), c.to.to_string(), None));
288        }
289        if !acct.has_changes() {
290            out.push(row("account", None, String::new(), String::new(), None));
291        }
292    }
293}
294
295pub fn rows(report: &Report) -> Vec<Row> {
296    let mut out = Vec::new();
297    match report {
298        Report::Net { diff, .. } => diff_rows(None, diff, &mut out),
299        Report::PerTx(txs) => {
300            for t in txs {
301                diff_rows(t.tx_hash.map(|h| h.to_string()), &t.diff, &mut out);
302            }
303        }
304    }
305    out
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::trace::parse_traces_str;
312
313    fn report() -> Report {
314        let json = r#"{"pre":{
315            "0x0101010101010101010101010101010101010101":{"balance":"0xde0b6b3a7640000","nonce":1,"storage":{"0x1":"0x7"}}},
316          "post":{
317            "0x0101010101010101010101010101010101010101":{"balance":"0x6f05b59d3b20000","nonce":2,"storage":{"0x2":"0x1"}},
318            "0x0202020202020202020202020202020202020202":{"balance":"0x6f05b59d3b20000"}}}"#;
319        let diff = StateDiff::from_prestate(&parse_traces_str(json).unwrap()[0].diff);
320        Report::Net { label: "example".into(), transactions: 1, diff }
321    }
322
323    fn render_str(format: Format, unit: Unit) -> String {
324        let mut buf = Vec::new();
325        render(&report(), format, unit, &mut buf).unwrap();
326        String::from_utf8(buf).unwrap()
327    }
328
329    #[test]
330    fn text_output() {
331        let s = render_str(Format::Text, Unit::Ether);
332        assert!(s.starts_with("== example (1 transaction)\n"));
333        assert!(s.contains("  balance  1 ETH -> 0.5 ETH  (-0.5 ETH)\n"));
334        assert!(s.contains("  nonce    1 -> 2\n"));
335        assert!(s.contains("  storage  0x1: 0x7 -> 0x0\n"));
336        assert!(s.contains("  storage  0x2: 0x0 -> 0x1\n"));
337        assert!(s.contains("0x0202020202020202020202020202020202020202  created\n"));
338        assert!(s.ends_with(
339            "summary: 2 accounts (1 created, 0 destroyed); 2 balance, 1 nonce, 0 code, 2 storage changes\n"
340        ));
341        assert!(render_str(Format::Text, Unit::Wei).contains("(-500000000000000000 wei)"));
342    }
343
344    #[test]
345    fn json_output() {
346        let v: Value = serde_json::from_str(&render_str(Format::Json, Unit::Ether)).unwrap();
347        assert_eq!(v["transactions"], 1);
348        assert_eq!(v["summary"]["accounts"], 2);
349        assert_eq!(v["summary"]["total_credited_wei"], "500000000000000000");
350        let a = &v["accounts"]["0x0101010101010101010101010101010101010101"];
351        assert_eq!(a["balance"]["delta"], "-500000000000000000");
352        assert_eq!(a["nonce"]["to"], 2);
353        assert_eq!(
354            a["storage"]["0x0000000000000000000000000000000000000000000000000000000000000001"]["to"],
355            "0x0000000000000000000000000000000000000000000000000000000000000000"
356        );
357    }
358
359    #[test]
360    fn ndjson_and_csv_rows() {
361        let nd = render_str(Format::Ndjson, Unit::Ether);
362        let lines: Vec<Value> = nd.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
363        assert_eq!(lines.len(), 5);
364        assert!(lines.iter().all(|l| l["tx"].is_null()));
365        let csv = render_str(Format::Csv, Unit::Ether);
366        let mut it = csv.lines();
367        assert_eq!(it.next(), Some("tx,address,status,kind,slot,from,to,delta"));
368        assert_eq!(it.count(), 5);
369        assert!(csv.contains(",balance,,1000000000000000000,500000000000000000,-500000000000000000"));
370    }
371
372    #[test]
373    fn empty_diff() {
374        let r = Report::PerTx(vec![TxDiff { tx_hash: None, diff: StateDiff::default() }]);
375        let mut buf = Vec::new();
376        render(&r, Format::Text, Unit::Ether, &mut buf).unwrap();
377        assert_eq!(String::from_utf8(buf).unwrap(), "== tx #0\nno state changes\n");
378    }
379}