StackFlow
Overview
StackFlow is a control-flow graph (CFG) explorer and opcode-level debugger for EVM programs (i.e. “smart contracts”). It grew from two projects I wanted to combine: evm.codes, which provides execution state at every step, and Bytegraph, which visualizes a contract’s execution paths. StackFlow combines the live execution state of evm.codes with the path-level visualization of Bytegraph, letting you step through a transaction while the active opcode and the full execution path stay anchored in the CFG.
The project builds on the evm.codes foundation and keeps its opcode reference and in-browser EVM model, but adds a CFG generator, and the front-end uses node react to display and interact with it.
Why a linear bytecode scan is not enough
The EVM is a stack-based virtual machine, so the opcode and program counter alone do not fully describe the machine state. The stack, memory, and storage are all mutable, and can only truly be known at runtime.
This matters most at the two opcode instructions:
JUMP (0x56)pulls its destination from the stack.JUMPI (0x57)also pulls its destination from the stack as well as a true/false condition.
As mentioned, the destination program counter (PC) is a runtime value. Now, often bytecode is compiled such that the destination is pushed to the stack one or two opcodes prior to the JUMP/JUMPI opcode, so a static scan can identify some JUMPDEST instructions, but not always. StackFlow resolves this by running the real opcode handlers and recording the program counter and opcode for every visited instruction.
Recursive depth-first search
The mapper performs a recursive depth-first search over execution paths. It simulates the opcode executions, with and records each visited program counter. On a JUMPI, we manually pop the destination and condition from the stack, copy the execution state, and explore both branches.
The core logic is:
explore(state, path):
while the exploration budget remains:
record (state.pc, opcode) in path
if opcode terminates execution:
save path and return
if opcode is JUMP:
pop destination
state.pc = destination
continue
if opcode is JUMPI:
pop destination and condition
explore(clone(state, pc = destination), copy(path))
explore(clone(state, pc = next instruction), copy(path))
return
execute the opcode handler and continue
Exploring the jump branch before the fall-through branch gives the algorithm its depth-first behavior: it follows one route to termination or a cutoff before returning to the sibling route. With this, we go down the ‘true’ branches first.
Bounding the DFS
This was one of the hardest parts of the project. When the DFS is running, the execution tree is not yet known, so it is very difficult to tell whether you are stuck in an infinite loop, following a legitimate path, or converging with another branch. I ended up adding a few blunt heuristics to keep the search bounded:
- PC → destination: a count of jump-to-
JUMPDESTcombinations. It is a rough signal for “this looks like a loop,” but not a reliable one. - Path budget: a hard step cap to stop the browser from spending forever on a complex contract or an unusually large execution tree.
It took weeks to tune this. It is not elegant, and large or unusual contracts can still be under-mapped, but it keeps the mapper and the JavaScript runtime from running indefinitely.
Building the CFG
Once the DFS is finished, I group each explored (program counter, opcode) path into basic blocks, convert it to a block-ID path, and insert those paths into an execution trie. Shared prefixes stay together while divergent paths branch separately, preserving cases where the same PC is reached with different stack or memory state. The CFG builder then walks that tree to create the React Flow nodes and edges, labels true and false branches using the destinations recorded during exploration, and patches detected loop-back edges so they can be styled and laid out separately.
The remaining problem is branch convergence. Two paths can reach the same PC with different machine state, so StackFlow currently keeps those contexts separate. It is safer than merging them blindly, but it also means downstream blocks are duplicated and the graph can grow very quickly. I haven’t really found a convergence rule I fully trust yet.