Trace playground
See ethdebug/format in motion. Each example below compiles a small BUG program and lets you step through its execution trace — watching the source line, in-scope variables, call stack, and instruction context light up at every step.
Click "Try it" on an example to open the trace drawer, then use the step controls to walk through execution. The examples build on each other: start at the top and work down, picking up one idea at a time.
A storage write
Counter increments a single storage slot. Step through it to get a feel
for the drawer — the highlighted source line, the value in storage, and
the context attached to each instruction.
name Counter;
storage {
[0] count: uint256;
}
create {
count = 0;
}
code {
count = count + 1;
}
A function call
Real programs call functions, and the debugger follows them.
SimpleFunctions calls an add helper directly, then calls addThree —
which itself calls add twice — so the call stack nests two frames deep.
Each JUMP into a function pushes a frame (an invoke context); each
JUMP back pops it (a return context).
name SimpleFunctions;
define {
// Simple arithmetic function
function add(a: uint256, b: uint256) -> uint256 {
return a + b;
};
// Function that calls another function
function addThree(x: uint256, y: uint256, z: uint256) -> uint256 {
let sum1 = add(x, y);
let sum2 = add(sum1, z);
return sum2;
};
}
storage {
[0] result: uint256;
}
code {
// Test function calls
let a = 10;
let b = 20;
let c = 30;
// Call add function
let sum = add(a, b);
// Call addThree function
let total = addThree(a, b, c);
// Store result
result = total;
}
For the exact shape of invoke, return, and revert contexts, see the function call spec and the tracing reference.
Values, decoded by type
The drawer reads each in-scope variable from memory and decodes it by its
static type — a uint256 reads as a decimal, an address as its
EIP-55 checksummed 0x… form, not raw bytes. Identity passes an
address through a function; step into idn and watch owner render as a
checksummed address in the variables panel.
name Identity;
define {
function idn(owner: address) -> address {
return owner;
};
}
storage {
[0] stored: address;
}
create {}
code {
stored = idn(0x52908400098527886e0f7030069857d2e4169ee7);
}
Watching the optimizer
Compilers rewrite code as they optimize, and transform contexts
record what they did. Set the Opt selector to O2 and step through
the examples below: each instruction the optimizer rewrote carries a
transform array naming the passes responsible.
Tail-call optimization
In TailFactorial the recursive call folds into a loop. The back-edge
JUMP carries transform: ["tailcall"] next to its invoke and return, and
the call stack stays flat instead of growing one frame per iteration.
name TailFactorial;
define {
function fact(n: uint256, acc: uint256) -> uint256 {
if (n == 0) { return acc; }
else { return fact(n - 1, acc * n); }
};
}
storage {
[0] result: uint256;
}
create {
result = 0;
}
code {
result = fact(5, 1);
}
Inlining
In InlineDemo the leaf helper square is small enough that at O2
the compiler splices its body straight into each call site.
sumOfSquares = square(a) + square(b) calls it twice, and after inlining
neither call JUMPs into square — each multiply runs inline, and those
instructions carry transform: ["inline"] pointing back to the square
body they came from. The call stack marks the inlined square as a
virtual activation, not a real pushed frame.
name InlineDemo;
define {
function square(x: uint256) -> uint256 {
return x * x;
};
}
storage {
[0] a: uint256;
[1] b: uint256;
[2] sumOfSquares: uint256;
}
create {
a = 3;
b = 4;
sumOfSquares = 0;
}
code {
sumOfSquares = square(a) + square(b);
}
Flip the Opt selector between O0 and O2 to see the difference: at O0 each program calls its helper the ordinary way; at O2 the transforms above take over. For how these transforms compose with invoke/return, see the transform context spec.