Skip to content
LogoLogo

Tracing (EIP-3155)

Tracing is how you find out where your EVM and a reference implementation stop agreeing. Guillotine Mini emits EIP-3155 entries: one per executed instruction, with PC, opcode, gas, gas cost, stack, memory, depth, and the refund counter.

Capturing a trace

const std = @import("std");
const guillotine = @import("guillotine_mini");
 
const Evm = guillotine.Evm(guillotine.EvmConfig{});
 
pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();
 
    var vm: Evm = undefined;
    try vm.init(allocator, null, .CANCUN, null, guillotine.ZERO_ADDRESS, 0, null);
    defer vm.deinit();
 
    var tracer = guillotine.Tracer.init(allocator);
    defer tracer.deinit();
    tracer.enable();
    vm.setTracer(&tracer);
 
    const target = try guillotine.Address.fromHex(
        "0x0000000000000000000000000000000000000044",
    );
    // PUSH1 0x01  PUSH1 0x02  ADD  STOP
    try vm.code.put(target, &[_]u8{ 0x60, 0x01, 0x60, 0x02, 0x01, 0x00 });
 
    _ = vm.call(.{ .call = .{
        .caller = guillotine.ZERO_ADDRESS,
        .to = target,
        .value = 0,
        .input = &.{},
        .gas = 100_000,
    } });
 
    std.debug.print("steps={}\n", .{tracer.entries.items.len});
    for (tracer.entries.items) |e| {
        std.debug.print("  pc={d} op={s} gas={d} cost={d}\n", .{
            e.pc, e.opName, e.gas, e.gasCost,
        });
    }
}
$ zig build run
trace: steps=4
  pc=0 op=PUSH1 gas=100000 cost=3
  pc=2 op=PUSH1 gas=99997 cost=3
  pc=4 op=ADD gas=99994 cost=3
  pc=5 op=STOP gas=99991 cost=0

gas is the gas remaining before the instruction executes and gasCost is what that instruction charges — the same convention geth's --debug tracer uses, so the two are directly comparable line by line.

Forgetting tracer.enable() is the usual reason a trace comes back empty: Tracer.init leaves enabled = false.

A trace entry

pub const TraceEntry = struct {
    pc: u64,
    op: u8,
    gas: u64,
    gasCost: u64,
    memory: ?[]const u8,
    memSize: usize,
    stack: []const u256,
    returnData: ?[]const u8,
    depth: usize,
    refund: i64,
    opName: []const u8,
    error_msg: ?[]const u8 = null,
 
    pub fn toJson(self: *const TraceEntry, allocator: std.mem.Allocator) !std.json.Value;
};

toJson produces EIP-3155 shapes: gas and gasCost as hex strings, memory and returnData as 0x… strings or null, stack as an array of hex strings. That means a captured trace can be diffed against evm t8n --trace output or an execution-specs trace with an ordinary JSON diff.

The tracer owns its allocations and frees them in deinit(); if you keep entries past that point, copy them.

Also note that memory and returnData capture is governed by the tracer's config (a voltaire.TraceConfig). Full memory capture on a long execution is expensive — leave it off unless you are actually diffing memory.

Getting a trace out of a CallResult

CallResult.trace is an ?ExecutionTrace, populated on the paths that build one (notably the FFI/C entry points). For Zig callers, the Tracer above is the direct route and gives you the same data.

Stepping by hand

For a debugger you often want control rather than a log:

vm.setBytecode(bytecode);
while (true) {
    try vm.step();                   // exactly one instruction
    const pc = vm.getPC();
    const frame = vm.getCurrentFrame() orelse break;
    std.debug.print("pc={d} stack_len={d} gas={d}\n", .{
        pc, frame.stack.items.len, frame.gas_remaining,
    });
}

getCurrentFrame() returns the innermost Frame, which is where the stack, memory, and gas actually live. vm.getBytecode() returns the code the current frame is executing, which is not necessarily the code you set — inside a DELEGATECALL it is the callee's.

Comparing against the reference implementation

The repository ships tooling for exactly this loop:

bun scripts/isolate-test.ts "transStorageReset"

It runs one spec test with maximum debug output, captures both traces, and prints the first divergence with PC, opcode, gas, and stack context, along with the Python reference file to read next. See Testing and Debugging.