Tracing execution
Tracing brings together programs, pointers, and types to show what's happening at each step of EVM execution. This page is the reference for how trace data maps onto ethdebug/format. To compile and step through real BUG programs interactively, see the Trace playground.
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:
- Look up the program counter: Find the instruction record for the current PC
- Read the context: Get variables, source ranges, and other metadata
- Resolve pointers: For each variable, resolve its pointer to get the current value
- Decode values: Use the type information to interpret raw bytes
Call contexts across function boundaries
When execution crosses a function boundary, the JUMP instructions at the boundary carry invoke and return contexts, so a debugger can follow the call and maintain a call stack.
Entering a function
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, and can show add(3, 4) in the call stack.
Inside the function, instructions carry their own code and variables
contexts as usual — the debugger shows the source range within the function
body, and parameters appear as in-scope variables.
Returning a result
When the function 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.
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
}
}
Optimized code: the tailcall transform
When the compiler optimizes tail-recursive calls, it turns the recursion into a loop: the recursive call becomes a back-edge — a single JUMP that ends one iteration and begins the next without pushing a frame. That one JUMP carries three facts at once, composed as sibling keys on a single context (the flat form described on the transform context page):
{
"return": {
"identifier": "sum"
},
"invoke": {
"jump": true,
"identifier": "sum",
"target": {
"pointer": { "location": "code", "offset": "0x33", "length": 1 }
}
},
"transform": ["tailcall"]
}
The return and invoke state the source-level facts — the previous
iteration returned, the next was invoked — and transform: ["tailcall"]
explains how the compiler realized that pair as one JUMP. Because no value
crosses a frame boundary here, the return carries no data and the
invoke no arguments: the accumulator is threaded through the loop
directly, and the invoke target points at the loop header the JUMP
re-enters. A debugger that ignores the transform still reads a coherent
invoke/return sequence; one that understands it can show that the call stack
isn't really growing.
To watch this happen — flipping the optimizer between O0 and O2 and stepping through the back-edge — see the tail-call optimization examples in the Trace playground.
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
countis in scope - We can resolve its value using the pointer
Variable resolution during tracing
To show variable values, trace viewers:
- Get the variable's pointer from the program context
- Create machine state from the trace step (stack, storage, memory)
- Resolve the pointer to get concrete byte regions
- Decode using the type to get the display value
For a full walkthrough of wiring these steps into a working tool, see Building a trace viewer.
Learn more
- Trace playground to compile and step through programs interactively
- Function context schemas for the invoke, return, and revert specifications
- Instructions documentation for understanding instruction records
- Variables documentation for variable structure and lifetime
- Pointers for resolving variable locations