Skip to main content

Tracing execution

Tracing brings together programs, pointers, and types to show what's happening at each step of EVM execution. Click "Try it" on any example to open the Trace Playground, where you can compile and step through real BUG code.

What tracing provides

With ethdebug/format data, a trace viewer can show:

  • Current source location: Which line of source code corresponds to the current bytecode instruction
  • Variables in scope: What identifiers are valid and their current values
  • Call context: Function name, parameters, and return expectations
  • Data inspection: Drill into complex types (structs, arrays, mappings)

The tracing process

At each instruction in a transaction trace:

  1. Look up the program counter: Find the instruction record for the current PC
  2. Read the context: Get variables, source ranges, and other metadata
  3. Resolve pointers: For each variable, resolve its pointer to get the current value
  4. Decode values: Use the type information to interpret raw bytes

Try it yourself

Click "Try it" on any example below to load it into the Trace Playground. The drawer will open at the bottom of the screen where you can compile the code and step through the execution trace.

Simple counter increment

This example shows a basic counter that increments a storage variable:

Counter increment
Increments count from 0 to 1, storing the result
name Counter;

storage {
[0] count: uint256;
}

create {
count = 0;
}

code {
count = count + 1;
}

Storage with threshold check

This example demonstrates conditional logic with storage variables:

Threshold check
Increments counter and resets when reaching threshold
name ThresholdCounter;

storage {
[0] count: uint256;
[1] threshold: uint256;
}

create {
count = 0;
threshold = 5;
}

code {
count = count + 1;

if (count >= threshold) {
count = 0;
}
}

Multiple storage slots

This example shows working with multiple storage locations:

Multiple storage slots
Updates multiple storage values in sequence
name MultiSlot;

storage {
[0] a: uint256;
[1] b: uint256;
[2] sum: uint256;
}

create {
a = 10;
b = 20;
sum = 0;
}

code {
sum = a + b;
a = a + 1;
b = b + 1;
}

Tracing through a function call

The examples above trace simple straight-line code. Real programs make function calls. invoke and return contexts let a debugger follow execution across function boundaries.

Click "Try it" on the example below, then step through the trace. Watch for invoke contexts on the JUMP into add and return contexts on the JUMP back to the caller:

Function call and return
Calls an internal add function and stores the result
name Adder;

define {
function add(a: uint256, b: uint256) -> uint256 {
return a + b;
};
}

storage {
[0] result: uint256;
}

create {
result = 0;
}

code {
result = add(3, 4);
}

As you step through, three phases are visible:

Before the call — setting up arguments

At the call site, the compiler pushes arguments onto the stack and prepares the jump. The JUMP instruction carries an invoke context identifying the function, its target, and the argument locations:

{
"invoke": {
"identifier": "add",
"jump": true,
"target": {
"pointer": { "location": "stack", "slot": 0 }
},
"arguments": {
"pointer": {
"group": [
{ "name": "a", "location": "stack", "slot": 2 },
{ "name": "b", "location": "stack", "slot": 3 }
]
}
}
}
}

The debugger now knows it's entering add with arguments at stack slots 2 and 3. A trace viewer can show add(3, 4) in the call stack.

Inside the function — normal tracing

Inside add, instructions carry their own code and variables contexts as usual. The debugger shows the source range within the function body, and parameters a and b appear as in-scope variables.

Returning — the result

When add finishes, the JUMP back to the caller carries a return context with a pointer to the result:

{
"return": {
"identifier": "add",
"data": {
"pointer": { "location": "stack", "slot": 0 }
}
}
}

The debugger pops add from the call stack and can display the return value (7).

External calls and reverts

The same pattern applies to external message calls, but with additional fields. An external CALL instruction carries gas, value, and input data pointers:

{
"invoke": {
"identifier": "balanceOf",
"message": true,
"target": {
"pointer": { "location": "stack", "slot": 1 }
},
"gas": {
"pointer": { "location": "stack", "slot": 0 }
},
"input": {
"pointer": {
"group": [
{ "name": "selector", "location": "memory",
"offset": "0x80", "length": 4 },
{ "name": "arguments", "location": "memory",
"offset": "0x84", "length": "0x20" }
]
}
}
}
}

If the call reverts, a revert context captures the reason:

{
"revert": {
"identifier": "transfer",
"reason": {
"pointer": {
"location": "memory",
"offset": "0x80",
"length": "0x64"
}
}
}
}

For built-in assertion failures, the compiler can provide a panic code instead of (or alongside) a reason pointer:

{
"revert": {
"panic": 17
}
}

Trace data structure

A trace step captures the EVM state at a single point:

interface TraceStep {
pc: number; // Program counter
opcode: string; // Mnemonic (SLOAD, ADD, etc.)
stack: bigint[]; // Stack contents (top first)
memory?: Uint8Array; // Memory contents
storage?: Record<string, string>; // Changed slots
}

Combined with the program annotation, this gives us complete visibility.

Mapping trace to program

The program's instruction list maps each PC to its context:

{
"offset": 18,
"operation": { "mnemonic": "SLOAD" },
"context": {
"gather": [
{
"code": {
"source": { "id": "main" },
"range": { "offset": 120, "length": 5 }
}
},
{
"variables": [
{
"identifier": "count",
"type": { "kind": "uint", "bits": 256 },
"pointer": { "location": "storage", "slot": 0 }
}
]
}
]
}
}

This tells us:

  • The SLOAD at PC 18 corresponds to source at offset 120
  • The variable count is in scope
  • We can resolve its value using the pointer

Variable resolution during tracing

To show variable values, trace viewers:

  1. Get the variable's pointer from the program context
  2. Create machine state from the trace step (stack, storage, memory)
  3. Resolve the pointer to get concrete byte regions
  4. Decode using the type to get the display value

Building a trace viewer

The key components for trace integration:

1. Trace source

Get transaction traces from:

  • JSON-RPC debug_traceTransaction
  • Local simulation (Ganache, Anvil, Hardhat)
  • Historical archive nodes

2. Program loader

Load compiled program data containing:

  • Instruction list with contexts
  • Source materials
  • Type definitions

3. Pointer resolver

Use @ethdebug/pointers to resolve variable locations:

import { dereference } from "@ethdebug/pointers";

// For each variable in scope
const cursor = await dereference(variable.pointer, { state: machineState });
const view = await cursor.view(machineState);
const value = await view.read(view.regions[0]);

4. Type decoder

Interpret raw bytes according to type:

function decodeValue(bytes: Data, type: Type): string {
switch (type.kind) {
case "uint":
return bytes.asUint().toString();
case "bool":
return bytes.asUint() !== 0n ? "true" : "false";
case "address":
return "0x" + bytes.toHex().slice(-40);
// ... other types
}
}

Learn more