ethrex-common 17.0.0

Core Ethereum data types and block validation for the ethrex Ethereum execution client
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
//! Trace data types and their wire-format serializers.
//!
//! ## Architecture
//!
//! Capture, data, and output format are separated:
//!
//! - **Capture** lives in `ethrex-levm` (`LevmOpcodeTracer`, the dispatch-loop hook).
//!   It runs once per tx and produces a [`Vec<OpcodeStep>`] plus the trailing
//!   metadata in [`OpcodeTraceResult`].
//! - **Data** are the bare structs [`OpcodeStep`] and [`OpcodeTraceResult`] in this
//!   module. They carry no `Serialize` impl — they're consumer-agnostic. The same
//!   captured data feeds every downstream wire format.
//! - **Wire format** is a newtype wrapper around one of those data structs with its
//!   own `Serialize` impl. Two shapes coexist:
//!     - [`StructLoggerStep`] / [`StructLoggerResult`] — the geth-RPC `debug_traceTransaction`
//!       structLogger shape: `op` as string mnemonic, no `opName`, decimal `gas`, etc.
//!       Used by the RPC handler and matches what every major client (geth, besu, …) emits
//!       from this endpoint. Consumers: Blockscout, Foundry, Tenderly, anything reading
//!       `debug_traceTransaction`.
//!     - [`Eip3155Step`] — strict [EIP-3155](https://eips.ethereum.org/EIPS/eip-3155)
//!       shape: numeric `op` byte + separate `opName`, `"0xN"` hex `gas`/`gasCost`/`refund`,
//!       `stack:[]` (never null) when disabled. Used by streaming sinks that want
//!       spec-conformant per-step JSONL — e.g. the `ef-tests-statev2 statetest` subcommand
//!       feeding goevmlab.
//!
//! Adding a third format (Parity-style flat call, opcode-count tracers, …) means another
//! newtype with its own `Serialize` impl. No changes to the data types or capture layer.
//!
//! ## Why not match geth-RPC everywhere
//!
//! `debug_traceTransaction` predates EIP-3155 by years and its de-facto shape diverges
//! from the spec on three points: `op` is a string, `opName` is absent, and `gas`/`gasCost`
//! are decimal numbers instead of `"0xN"` hex strings. Every major client matches geth's
//! shape there for tooling compat, not EIP-3155. So:
//! - RPC consumer expects structLogger → use [`StructLoggerStep`]/[`StructLoggerResult`].
//! - EIP-3155-conformant CLI consumer (goevmlab, fuzzers) → use [`Eip3155Step`].

use bytes::Bytes;
use ethereum_types::H256;
use ethereum_types::{Address, U256};
use serde::Serialize;
use std::collections::BTreeMap;

/// Collection of traces of each call frame as defined in geth's `callTracer` output
/// https://geth.ethereum.org/docs/developers/evm-tracing/built-in-tracers#call-tracer
pub type CallTrace = Vec<CallTraceFrame>;

/// Trace of each call frame as defined in geth's `callTracer` output
/// https://geth.ethereum.org/docs/developers/evm-tracing/built-in-tracers#call-tracer
#[derive(Debug, Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct CallTraceFrame {
    /// Type of the Call
    #[serde(rename = "type")]
    pub call_type: CallType,
    /// Address that initiated the call
    pub from: Address,
    /// Address that received the call
    pub to: Address,
    /// Amount transfered
    pub value: U256,
    /// Gas provided for the call
    #[serde(with = "crate::serde_utils::u64::hex_str")]
    pub gas: u64,
    /// Gas used by the call
    #[serde(with = "crate::serde_utils::u64::hex_str")]
    pub gas_used: u64,
    /// Call data
    #[serde(with = "crate::serde_utils::bytes")]
    pub input: Bytes,
    /// Return data
    #[serde(with = "crate::serde_utils::bytes")]
    pub output: Bytes,
    /// Error returned if the call failed
    pub error: Option<String>,
    /// Revert reason if the call reverted
    pub revert_reason: Option<String>,
    /// List of nested sub-calls
    pub calls: Vec<CallTraceFrame>,
    /// Logs (if enabled)
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub logs: Vec<CallLog>,
}

#[derive(Serialize, Debug, Default)]
pub enum CallType {
    #[default]
    CALL,
    CALLCODE,
    STATICCALL,
    DELEGATECALL,
    CREATE,
    CREATE2,
    SELFDESTRUCT,
}

#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CallLog {
    pub address: Address,
    pub topics: Vec<H256>,
    #[serde(with = "crate::serde_utils::bytes")]
    pub data: Bytes,
    pub position: u64,
}

/// Per-account state entry emitted by the prestateTracer.
///
/// `balance` is `Option<U256>`: `None` means "field absent from output",
/// `Some(0)` still serializes (lets diff post emit a balance that became zero).
#[derive(Debug, Serialize, Default, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PrestateAccountState {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub balance: Option<U256>,
    #[serde(default, skip_serializing_if = "is_zero_nonce")]
    pub nonce: u64,
    #[serde(
        default,
        skip_serializing_if = "Bytes::is_empty",
        with = "crate::serde_utils::bytes"
    )]
    pub code: Bytes,
    #[serde(default, skip_serializing_if = "H256::is_zero")]
    pub code_hash: H256,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub storage: BTreeMap<H256, H256>,
}

impl PrestateAccountState {
    /// True when no field conveys information; `Some(0)` balance counts as empty.
    pub fn is_empty(&self) -> bool {
        self.balance.unwrap_or_default().is_zero()
            && self.nonce == 0
            && self.code.is_empty()
            && self.code_hash.is_zero()
            && self.storage.is_empty()
    }
}

/// Per-transaction prestate trace (non-diff mode). `BTreeMap` keeps JSON output
/// deterministic via sorted keys.
pub type PrestateTrace = BTreeMap<Address, PrestateAccountState>;

/// Result of a prestateTracer execution — either a plain prestate map or a diff.
#[derive(Debug, Clone)]
pub enum PrestateResult {
    /// Non-diff mode: map of address → pre-tx account state.
    Prestate(PrestateTrace),
    /// Diff mode: pre-tx and post-tx state for all touched accounts.
    Diff(PrePostState),
}

/// Per-transaction prestate trace (diff mode).
/// Contains the pre-tx and post-tx state for all touched accounts.
#[derive(Debug, Serialize, Default, Clone)]
pub struct PrePostState {
    pub pre: BTreeMap<Address, PrestateAccountState>,
    pub post: BTreeMap<Address, PrestateAccountState>,
}

fn is_zero_nonce(n: &u64) -> bool {
    *n == 0
}

// ─── OpcodeTracer types ──────────────────────────────────────────────────────

/// Per-opcode trace entry — pure data, no `Serialize` impl.
///
/// To get this on the wire, wrap in one of the format newtypes:
/// - [`StructLoggerStep`] for geth-RPC `debug_traceTransaction` shape.
/// - [`Eip3155Step`] for EIP-3155 spec shape.
///
/// See the module-level doc for why both formats coexist.
#[derive(Debug)]
pub struct OpcodeStep {
    pub pc: u64,
    /// Raw opcode byte value (e.g. 0x60 for PUSH1). Each format serializer decides
    /// how to render this (numeric byte, hex string, mnemonic string).
    pub op: u8,
    pub gas: u64,
    pub gas_cost: u64,
    /// Current memory size in bytes.
    pub mem_size: u64,
    pub depth: u32,
    /// Return data from the previous sub-call.
    pub return_data: bytes::Bytes,
    /// Gas refund counter.
    pub refund: u64,
    /// `Some(vec)` when stack capture is enabled (bottom-first); `None` when disabled.
    /// Each format serializer decides how to render `None`: structLogger emits JSON null,
    /// EIP-3155 emits `[]` (per spec's "MUST initialize to empty array" rule).
    pub stack: Option<Vec<U256>>,
    /// `Some(chunks)` when memory capture is enabled; `None` when disabled (field omitted).
    pub memory: Option<Vec<MemoryChunk>>,
    /// `Some(map)` at SLOAD/SSTORE steps when storage capture is enabled; `None`
    /// otherwise. The map is a cumulative snapshot of every slot touched by an
    /// SLOAD/SSTORE so far in the transaction — matching geth's structLogger.
    /// The tracer maintains this in `LevmOpcodeTracer::cumulative_storage`.
    pub storage: Option<BTreeMap<H256, H256>>,
    pub error: Option<String>,
}

/// A 32-byte chunk of EVM memory, serialized as `"0x" + 64 lowercase hex chars`.
/// The *caller* zero-pads the last partial chunk before constructing this type.
#[derive(Debug)]
pub struct MemoryChunk(pub [u8; 32]);

/// Top-level result of one opcode-traced transaction — pure data, no `Serialize` impl.
///
/// Wrap in [`StructLoggerResult`] to get the geth-RPC `{failed, gas, returnValue, structLogs}`
/// wire shape. EIP-3155-conformant CLI consumers stream per-step [`OpcodeStep`]s
/// directly (via [`Eip3155Step`]) and emit their own summary line, so there's no
/// EIP-3155 wrapper newtype for the result.
#[derive(Debug)]
pub struct OpcodeTraceResult {
    pub gas_used: u64,
    /// True iff the transaction completed without error.
    pub pass: bool,
    pub output: bytes::Bytes,
    pub steps: Vec<OpcodeStep>,
}

// ─── Helpers ──────────────────────────────────────────────────────────────

/// Returns the opcode mnemonic for `byte`.
///
/// Known opcodes → their uppercase name (`"PUSH1"`, `"ADD"`, `"INVALID"` for
/// 0xFE). Unassigned bytes → `None`; callers wanting the conventional unknown
/// string should fall back to `format!("opcode 0x{:02x} not defined", byte)`.
///
/// The table is **fork-agnostic by design**, matching geth's
/// `core/vm/opcodes.go::opCodeToString` (also a flat 256-entry table). Fork
/// validity is enforced at *dispatch* via the VM's per-fork opcode table:
/// e.g. byte `0x5F` (PUSH0) halts pre-Shanghai with `InvalidOpcode` before
/// the tracer ever emits a step for it, so the name lookup never fires for
/// invalid-for-this-fork bytes in practice.
pub fn opcode_name(byte: u8) -> Option<&'static str> {
    match byte {
        0x00 => Some("STOP"),
        0x01 => Some("ADD"),
        0x02 => Some("MUL"),
        0x03 => Some("SUB"),
        0x04 => Some("DIV"),
        0x05 => Some("SDIV"),
        0x06 => Some("MOD"),
        0x07 => Some("SMOD"),
        0x08 => Some("ADDMOD"),
        0x09 => Some("MULMOD"),
        0x0A => Some("EXP"),
        0x0B => Some("SIGNEXTEND"),
        0x10 => Some("LT"),
        0x11 => Some("GT"),
        0x12 => Some("SLT"),
        0x13 => Some("SGT"),
        0x14 => Some("EQ"),
        0x15 => Some("ISZERO"),
        0x16 => Some("AND"),
        0x17 => Some("OR"),
        0x18 => Some("XOR"),
        0x19 => Some("NOT"),
        0x1A => Some("BYTE"),
        0x1B => Some("SHL"),
        0x1C => Some("SHR"),
        0x1D => Some("SAR"),
        0x1E => Some("CLZ"),
        0x20 => Some("KECCAK256"),
        0x30 => Some("ADDRESS"),
        0x31 => Some("BALANCE"),
        0x32 => Some("ORIGIN"),
        0x33 => Some("CALLER"),
        0x34 => Some("CALLVALUE"),
        0x35 => Some("CALLDATALOAD"),
        0x36 => Some("CALLDATASIZE"),
        0x37 => Some("CALLDATACOPY"),
        0x38 => Some("CODESIZE"),
        0x39 => Some("CODECOPY"),
        0x3A => Some("GASPRICE"),
        0x3B => Some("EXTCODESIZE"),
        0x3C => Some("EXTCODECOPY"),
        0x3D => Some("RETURNDATASIZE"),
        0x3E => Some("RETURNDATACOPY"),
        0x3F => Some("EXTCODEHASH"),
        0x40 => Some("BLOCKHASH"),
        0x41 => Some("COINBASE"),
        0x42 => Some("TIMESTAMP"),
        0x43 => Some("NUMBER"),
        0x44 => Some("PREVRANDAO"),
        0x45 => Some("GASLIMIT"),
        0x46 => Some("CHAINID"),
        0x47 => Some("SELFBALANCE"),
        0x48 => Some("BASEFEE"),
        0x49 => Some("BLOBHASH"),
        0x4A => Some("BLOBBASEFEE"),
        0x4B => Some("SLOTNUM"),
        0x50 => Some("POP"),
        0x51 => Some("MLOAD"),
        0x52 => Some("MSTORE"),
        0x53 => Some("MSTORE8"),
        0x54 => Some("SLOAD"),
        0x55 => Some("SSTORE"),
        0x56 => Some("JUMP"),
        0x57 => Some("JUMPI"),
        0x58 => Some("PC"),
        0x59 => Some("MSIZE"),
        0x5A => Some("GAS"),
        0x5B => Some("JUMPDEST"),
        0x5C => Some("TLOAD"),
        0x5D => Some("TSTORE"),
        0x5E => Some("MCOPY"),
        0x5F => Some("PUSH0"),
        0x60 => Some("PUSH1"),
        0x61 => Some("PUSH2"),
        0x62 => Some("PUSH3"),
        0x63 => Some("PUSH4"),
        0x64 => Some("PUSH5"),
        0x65 => Some("PUSH6"),
        0x66 => Some("PUSH7"),
        0x67 => Some("PUSH8"),
        0x68 => Some("PUSH9"),
        0x69 => Some("PUSH10"),
        0x6A => Some("PUSH11"),
        0x6B => Some("PUSH12"),
        0x6C => Some("PUSH13"),
        0x6D => Some("PUSH14"),
        0x6E => Some("PUSH15"),
        0x6F => Some("PUSH16"),
        0x70 => Some("PUSH17"),
        0x71 => Some("PUSH18"),
        0x72 => Some("PUSH19"),
        0x73 => Some("PUSH20"),
        0x74 => Some("PUSH21"),
        0x75 => Some("PUSH22"),
        0x76 => Some("PUSH23"),
        0x77 => Some("PUSH24"),
        0x78 => Some("PUSH25"),
        0x79 => Some("PUSH26"),
        0x7A => Some("PUSH27"),
        0x7B => Some("PUSH28"),
        0x7C => Some("PUSH29"),
        0x7D => Some("PUSH30"),
        0x7E => Some("PUSH31"),
        0x7F => Some("PUSH32"),
        0x80 => Some("DUP1"),
        0x81 => Some("DUP2"),
        0x82 => Some("DUP3"),
        0x83 => Some("DUP4"),
        0x84 => Some("DUP5"),
        0x85 => Some("DUP6"),
        0x86 => Some("DUP7"),
        0x87 => Some("DUP8"),
        0x88 => Some("DUP9"),
        0x89 => Some("DUP10"),
        0x8A => Some("DUP11"),
        0x8B => Some("DUP12"),
        0x8C => Some("DUP13"),
        0x8D => Some("DUP14"),
        0x8E => Some("DUP15"),
        0x8F => Some("DUP16"),
        0x90 => Some("SWAP1"),
        0x91 => Some("SWAP2"),
        0x92 => Some("SWAP3"),
        0x93 => Some("SWAP4"),
        0x94 => Some("SWAP5"),
        0x95 => Some("SWAP6"),
        0x96 => Some("SWAP7"),
        0x97 => Some("SWAP8"),
        0x98 => Some("SWAP9"),
        0x99 => Some("SWAP10"),
        0x9A => Some("SWAP11"),
        0x9B => Some("SWAP12"),
        0x9C => Some("SWAP13"),
        0x9D => Some("SWAP14"),
        0x9E => Some("SWAP15"),
        0x9F => Some("SWAP16"),
        0xA0 => Some("LOG0"),
        0xA1 => Some("LOG1"),
        0xA2 => Some("LOG2"),
        0xA3 => Some("LOG3"),
        0xA4 => Some("LOG4"),
        0xE6 => Some("DUPN"),
        0xE7 => Some("SWAPN"),
        0xE8 => Some("EXCHANGE"),
        0xF0 => Some("CREATE"),
        0xF1 => Some("CALL"),
        0xF2 => Some("CALLCODE"),
        0xF3 => Some("RETURN"),
        0xF4 => Some("DELEGATECALL"),
        0xF5 => Some("CREATE2"),
        0xFA => Some("STATICCALL"),
        0xFD => Some("REVERT"),
        0xFE => Some("INVALID"),
        0xFF => Some("SELFDESTRUCT"),
        _ => None,
    }
}

/// Converts a `U256` to geth's `uint256.Int.Hex()` form: `"0x"` followed by
/// lowercase hex with leading zeros stripped.  Zero → `"0x0"` (not `"0x"`).
pub fn geth_uint256_hex(v: &U256) -> String {
    if v.is_zero() {
        return "0x0".to_string();
    }
    // U256 words are little-endian; convert to big-endian bytes.
    let bytes = crate::utils::u256_to_big_endian(*v);
    let hex_str = hex::encode(bytes);
    let stripped = hex_str.trim_start_matches('0');
    format!("0x{}", stripped)
}

// ─── Serialize impls ──────────────────────────────────────────────────────

impl serde::Serialize for MemoryChunk {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(&format!("0x{}", hex::encode(self.0)))
    }
}

// Shared utilities used by both wire-format serializers below.

fn serialize_storage_map<S: serde::Serializer>(
    serializer: S,
    storage: &BTreeMap<H256, H256>,
) -> Result<S::Ok, S::Error> {
    use serde::ser::SerializeMap;
    let mut m = serializer.serialize_map(Some(storage.len()))?;
    for (k, v) in storage {
        let k_str = format!("0x{}", hex::encode(k.as_bytes()));
        let v_str = format!("0x{}", hex::encode(v.as_bytes()));
        m.serialize_entry(&k_str, &v_str)?;
    }
    m.end()
}

/// Mnemonic string for an opcode byte, falling back to `"opcode 0xNN not defined"`
/// for bytes outside the assigned table.
fn opcode_name_or_fallback(byte: u8) -> String {
    opcode_name(byte)
        .map(str::to_owned)
        .unwrap_or_else(|| format!("opcode 0x{byte:02x} not defined"))
}

// ─── Wire format: geth-RPC structLogger ───────────────────────────────────
//
// The de-facto `debug_traceTransaction` response shape, emitted by every major
// execution client (geth, besu, reth, erigon, nethermind). Predates EIP-3155
// and diverges from it on three per-step fields:
//
//   - `op`: string mnemonic (`"PUSH1"`), not the numeric opcode byte.
//   - No separate `opName` field.
//   - `gas`, `gasCost`, `refund`: decimal JSON numbers, not `"0xN"` hex strings.
//
// `stack` is serialized as JSON `null` when capture is disabled — also a divergence
// from EIP-3155, which mandates `[]` — but it matches geth's RPC behavior so we
// preserve it on this code path.
//
// Verified against geth and besu on a kurtosis localnet via `debug_traceTransaction`:
// byte-for-byte identical to the StructLogger output.

/// Controls which always-populated per-step fields the structLogger wire format emits.
///
/// `mem_size`, `return_data`, and `refund` are always present in the captured
/// [`OpcodeStep`] (the capture layer just defaults them to zero/empty when the
/// corresponding capture config is off). geth's `debug_traceTransaction` *suppresses*
/// these fields unless their data is actually captured. To match geth byte-for-byte
/// we honor the caller's intent explicitly here.
///
/// Typical mapping at the RPC layer:
///
/// ```ignore
/// let emit = StructLoggerEmit {
///     mem_size: cfg.enable_memory,        // memSize travels with memory
///     return_data: cfg.enable_return_data,
///     refund: false,                      // no equivalent geth flag; off by default
/// };
/// ```
#[derive(Debug, Clone, Copy, Default)]
pub struct StructLoggerEmit {
    /// Emit `memSize` even when its value is meaningful at every step.
    /// Geth ties this to memory capture; default `false` matches geth's default config.
    pub mem_size: bool,
    /// Emit `returnData` (as `"0x..."` hex). Default `false` matches geth.
    pub return_data: bool,
    /// Force-emit `refund` even when it's zero. Default `false` matches geth's
    /// `omitempty` behavior — non-zero refund is always emitted regardless of this flag.
    pub refund: bool,
}

/// Wraps an [`OpcodeStep`] to serialize in the geth-RPC `structLogger` shape used by
/// `debug_traceTransaction`. See module-level docs and the comment above this type
/// for the field-shape divergences from EIP-3155.
pub struct StructLoggerStep<'a> {
    pub step: &'a OpcodeStep,
    pub emit: StructLoggerEmit,
}

impl serde::Serialize for StructLoggerStep<'_> {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        use serde::ser::SerializeMap;
        let step = self.step;
        let emit = self.emit;

        // pc, op, gas, gasCost, depth, stack are always emitted (6 base fields).
        let mut field_count = 6;
        if emit.mem_size {
            field_count += 1;
        }
        if emit.return_data {
            field_count += 1;
        }
        if emit.refund || step.refund != 0 {
            field_count += 1;
        }
        if step.error.is_some() {
            field_count += 1;
        }
        if step.memory.is_some() {
            field_count += 1;
        }
        if step.storage.is_some() {
            field_count += 1;
        }

        let mut map = serializer.serialize_map(Some(field_count))?;

        map.serialize_entry("pc", &step.pc)?;
        // op: string mnemonic, matching geth's wire output (NOT EIP-3155's numeric form).
        map.serialize_entry("op", &opcode_name_or_fallback(step.op))?;
        // gas/gasCost/refund: decimal JSON numbers, matching geth's wire output.
        map.serialize_entry("gas", &step.gas)?;
        map.serialize_entry("gasCost", &step.gas_cost)?;
        map.serialize_entry("depth", &step.depth)?;

        // stack: JSON null when disabled, array of `"0xN"` hex strings when enabled.
        // Matches geth's RPC behavior; diverges from EIP-3155's "MUST be []" rule.
        struct StackSerializer<'a>(&'a Option<Vec<U256>>);
        impl serde::Serialize for StackSerializer<'_> {
            fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
                use serde::ser::SerializeSeq;
                match self.0 {
                    None => serializer.serialize_none(),
                    Some(vec) => {
                        let mut seq = serializer.serialize_seq(Some(vec.len()))?;
                        for v in vec {
                            seq.serialize_element(&geth_uint256_hex(v))?;
                        }
                        seq.end()
                    }
                }
            }
        }
        map.serialize_entry("stack", &StackSerializer(&step.stack))?;

        if emit.mem_size {
            map.serialize_entry("memSize", &step.mem_size)?;
        }
        if emit.return_data {
            map.serialize_entry(
                "returnData",
                &format!("0x{}", hex::encode(&step.return_data)),
            )?;
        }
        // `refund` is omitempty-for-zero in geth's wire output: always emitted when
        // non-zero; emitted-when-zero only when the caller forces it via `emit.refund`.
        if emit.refund || step.refund != 0 {
            map.serialize_entry("refund", &step.refund)?;
        }

        if let Some(err) = &step.error {
            map.serialize_entry("error", err)?;
        }
        if let Some(mem) = &step.memory {
            map.serialize_entry("memory", mem)?;
        }
        if let Some(storage) = &step.storage {
            struct Wrap<'a>(&'a BTreeMap<H256, H256>);
            impl serde::Serialize for Wrap<'_> {
                fn serialize<S: serde::Serializer>(
                    &self,
                    serializer: S,
                ) -> Result<S::Ok, S::Error> {
                    serialize_storage_map(serializer, self.0)
                }
            }
            map.serialize_entry("storage", &Wrap(storage))?;
        }

        map.end()
    }
}

/// Wraps an [`OpcodeTraceResult`] to serialize as the geth-RPC `debug_traceTransaction`
/// response: `{failed, gas, returnValue, structLogs: [...]}`. Each step inside
/// `structLogs` is itself serialized via [`StructLoggerStep`] using the same `emit` flags.
pub struct StructLoggerResult<'a> {
    pub result: &'a OpcodeTraceResult,
    pub emit: StructLoggerEmit,
}

impl serde::Serialize for StructLoggerResult<'_> {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        use serde::ser::{SerializeMap, SerializeSeq};
        let r = self.result;
        let emit = self.emit;

        // structLogs uses StructLoggerStep for each entry, with the same emit options.
        struct Steps<'a> {
            steps: &'a [OpcodeStep],
            emit: StructLoggerEmit,
        }
        impl serde::Serialize for Steps<'_> {
            fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
                let mut seq = serializer.serialize_seq(Some(self.steps.len()))?;
                for s in self.steps {
                    seq.serialize_element(&StructLoggerStep {
                        step: s,
                        emit: self.emit,
                    })?;
                }
                seq.end()
            }
        }

        let mut map = serializer.serialize_map(Some(4))?;
        // `failed` is the inverse of `pass` — matches the geth wire shape.
        map.serialize_entry("failed", &!r.pass)?;
        map.serialize_entry("gas", &r.gas_used)?;
        map.serialize_entry("returnValue", &format!("0x{}", hex::encode(&r.output)))?;
        map.serialize_entry(
            "structLogs",
            &Steps {
                steps: &r.steps,
                emit,
            },
        )?;
        map.end()
    }
}

// ─── Wire format: EIP-3155 ────────────────────────────────────────────────
//
// The shape defined by EIP-3155 §"Required Fields":
//
//   - `op`: numeric opcode byte (e.g. `96` for PUSH1).
//   - `opName`: separate string mnemonic, always emitted (technically optional per spec).
//   - `gas`, `gasCost`, `refund`: `"0xN"` hex strings ("Hex-Number" per spec).
//   - `stack`: always an array, never null (spec: "All array attributes MUST be
//     initialized to empty arrays NOT to null").
//
// Field order matches the spec's listed order. Used by streaming sinks that feed
// EIP-3155-conformant tooling (goevmlab, fuzzers). NOT used by `debug_traceTransaction`,
// where existing tooling expects the structLogger shape above.

/// Wraps an [`OpcodeStep`] to serialize in strict EIP-3155 shape. See module-level
/// docs and the comment above this type for the field-shape choices.
pub struct Eip3155Step<'a>(pub &'a OpcodeStep);

impl serde::Serialize for Eip3155Step<'_> {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        use serde::ser::SerializeMap;
        let step = self.0;

        let mut field_count = 10; // required 9 + always-emitted opName
        if step.error.is_some() {
            field_count += 1;
        }
        if step.memory.is_some() {
            field_count += 1;
        }
        if step.storage.is_some() {
            field_count += 1;
        }

        let mut map = serializer.serialize_map(Some(field_count))?;

        // Required fields in spec order.
        map.serialize_entry("pc", &step.pc)?;
        map.serialize_entry("op", &step.op)?;
        map.serialize_entry("gas", &format!("{:#x}", step.gas))?;
        map.serialize_entry("gasCost", &format!("{:#x}", step.gas_cost))?;
        map.serialize_entry("memSize", &step.mem_size)?;

        // stack: always an array; `None` (disabled) becomes `[]`.
        struct StackSerializer<'a>(&'a Option<Vec<U256>>);
        impl serde::Serialize for StackSerializer<'_> {
            fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
                use serde::ser::SerializeSeq;
                let vec_ref: &[U256] = self.0.as_deref().unwrap_or(&[]);
                let mut seq = serializer.serialize_seq(Some(vec_ref.len()))?;
                for v in vec_ref {
                    seq.serialize_element(&geth_uint256_hex(v))?;
                }
                seq.end()
            }
        }
        map.serialize_entry("stack", &StackSerializer(&step.stack))?;

        map.serialize_entry("depth", &step.depth)?;
        map.serialize_entry(
            "returnData",
            &format!("0x{}", hex::encode(&step.return_data)),
        )?;
        map.serialize_entry("refund", &format!("{:#x}", step.refund))?;

        // Optional fields in spec order: opName, error, memory, storage.
        // opName always emitted (covers both known and unknown opcode bytes).
        map.serialize_entry("opName", &opcode_name_or_fallback(step.op))?;

        if let Some(err) = &step.error {
            map.serialize_entry("error", err)?;
        }
        if let Some(mem) = &step.memory {
            map.serialize_entry("memory", mem)?;
        }
        if let Some(storage) = &step.storage {
            struct Wrap<'a>(&'a BTreeMap<H256, H256>);
            impl serde::Serialize for Wrap<'_> {
                fn serialize<S: serde::Serializer>(
                    &self,
                    serializer: S,
                ) -> Result<S::Ok, S::Error> {
                    serialize_storage_map(serializer, self.0)
                }
            }
            map.serialize_entry("storage", &Wrap(storage))?;
        }

        map.end()
    }
}