ethdebug in depth

The details behind our post introducing ethdebug: the demos, pointers in full, optimized code, where each compiler stands, contracts on mainnet, and our measurements.

Part of the ethdebug post appendix, with Storage, by name and Step through a transaction.

ethdebug is an open specification for the debug data a compiler writes next to its EVM bytecode: what each instruction means in the source, and where each value lives. We, the ethdebug team at Argot, maintain it. This page holds what the post leaves out. Facts are as of October 2026.

Key: plan means a stage that is planned or in progress, not done; we give no dates for those.

1. Try it

Two pages run in your browser with nothing to install, and one small script runs in Node.

Step through a transaction

This page steps one contract, Arcade (a small game: players join with a name, and each play() rolls a hit or a miss), and one transaction, alice's combo-3 hit, in four ways, in your browser tab. Each tab names the debugger engine that does the work; the page only shows the source range the engine reports for each step. Use the slider or the line buttons to step.

soldb in the browser, highlighting the source span of one step in a Solidity contract
soldb highlights the source span of one step of a real Solidity transaction (2,228 steps).
The same page with the Fe tab selected, stepping through a Fe contract
The same page, switched to Fe.

See storage by name

This page shows a contract's storage by Solidity name, before and after a transaction, and marks what changed. Click any value ("show how") to see the rule solc wrote for it, each expression evaluated, and the bytes it read. @ethdebug/pointers, our TypeScript reference library, finds each value's bytes from the pointer templates in solc's ethdebug output (see Pointers, in full). The page then decodes the values itself, with its own small decoder for each type: a stand-in for the format's planned interpretation layer (see Placement, interpretation, resolution). The contracts are compiled with Walnut's next solc stage, open on their fork (walnuthq/solidity #10). Each variable's base slot comes from the program context it writes, so the page needs no storageLayout. The page lists the remaining gaps at the bottom.

The storage inspector, showing a mapping entry and the steps that located its nonce field
"Show how" for accounts[sender].nonce: the mapping rule hashes the key and slot, then the struct rule places nonce in the next slot, at bytes 24 to 31.

Ten lines that step through Solidity and Fe

This script prints the source lines a transaction ran. It takes an ethdebug program, a transaction trace and the source file. The trace comes from debug_traceTransaction on anvil, Foundry's local node, started with --steps-tracing. The script needs only Node's standard library. The core is 10 lines (lines 13 to 22); two more lines pick out Fe's program. Source: step.mjs.

import { readFileSync as read } from "fs";
const [programFile, traceFile, sourceFile] = process.argv.slice(2);
const json = (f) => JSON.parse(read(f, "utf8"));
const source = read(sourceFile, "utf8");
const trace = json(traceFile).structLogs;

// Input step. solc: the file is the program. Fe's file wraps programs
// differently today: pick its "call" program.
let program = json(programFile);
if (program.programs) program = program.programs.find((p) => p.environment === "call");

// Core: pc -> source range, then print each new source line.
const range = new Map(program.instructions.map((i) => [i.offset, i.context?.code]));
let last;
for (const { pc } of trace) {
  const code = range.get(pc);
  if (code?.source.id !== 0) continue; // not in this file (Fe: std library)
  if (code.range.length > source.length * 0.8) continue; // compiler glue
  const line = source.slice(0, code.range.offset).split("\n").length;
  if (line !== last) console.log(line + ": " + source.split("\n")[line - 1].trim());
  last = line;
}

Solidity

The contract has an external run that calls two internal functions:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Calls {
  uint256 public total;
  function add(uint256 a, uint256 b) internal pure returns (uint256) {
    require(a < 1000, "too big");
    return a + b;
  }
  function twice(uint256 x) internal pure returns (uint256) {
    return add(x, x);
  }
  function run(uint256 x) external {
    total = twice(x) + add(x, 1);
  }
}

We compiled it with via-IR, the optimizer off and ethdebug turned on, using the #16990 build. Released solc 0.8.37 writes the same program, and the script prints the same lines. The full output for run(5):

$ node step.mjs solc-program.json solc-trace.json Calls.sol
12: function run(uint256 x) external {
13: total = twice(x) + add(x, 1);
12: function run(uint256 x) external {
13: total = twice(x) + add(x, 1);
9: function twice(uint256 x) internal pure returns (uint256) {
10: return add(x, x);
9: function twice(uint256 x) internal pure returns (uint256) {
10: return add(x, x);
5: function add(uint256 a, uint256 b) internal pure returns (uint256) {
7: return a + b;
5: function add(uint256 a, uint256 b) internal pure returns (uint256) {
6: require(a < 1000, "too big");
7: return a + b;
10: return add(x, x);
13: total = twice(x) + add(x, 1);
5: function add(uint256 a, uint256 b) internal pure returns (uint256) {
7: return a + b;
5: function add(uint256 a, uint256 b) internal pure returns (uint256) {
6: require(a < 1000, "too big");
7: return a + b;
13: total = twice(x) + add(x, 1);
12: function run(uint256 x) external {

It enters twice, then add, returns, then calls add again, in source order.

Fe

The Fe contract (Fe v26.4.1, default optimizer level -O1):

msg CounterMsg {
    #[selector = 0x01]
    Bump { n: u256 } -> u256,
    #[selector = 0x02]
    Total -> u256,
}

pub struct Store {
    total: u256,
    last: u256,
}

fn scale(_ x: u256) -> u256 {
    let doubled: u256 = x * 2
    return doubled + 1
}

pub contract Counter {
    mut store: Store

    recv CounterMsg {
        Bump { n } -> u256 uses (mut store) {
            let step: u256 = scale(n)
            if step > 10 {
                store.total = store.total + step
            } else {
                store.total = store.total + 1
            }
            store.last = step
            return store.total
        }

        Total -> u256 uses (store) {
            store.total
        }
    }
}

The full output for Bump { n: 7 }:

$ node step.mjs counter.ethdebug.json fe-trace.json counter.fe
14: let doubled: u256 = x * 2
15: return doubled + 1
24: if step > 10 {
25: store.total = store.total + step
29: store.last = step
30: return store.total

It takes the if branch, as it should for 7. Line 23, the call to scale, does not appear: at -O1 Fe inlines scale, and its output does not mark inlined calls yet (see Optimized code).

Why each extra line is there

2. Pointers, in full

A pointer is the compiler's own statement of where a value lives, and how to find it. In the docs' words: "Pointers are recipes for finding bytes." On the EVM, "where" is often not a fixed number. A mapping entry's slot is a hash of its key; an array's items start where a length stored elsewhere says. So a pointer can hold a small calculation, and a tool runs it against the machine state at one step of a transaction. A pointer is plain JSON data, checked by JSON Schema. It is not code a tool must trust and run.

Placement, interpretation, resolution

To show you a variable's value, a tool must answer three questions. We use these names for them in the discussion of ethdebug's type descriptions (ethdebug/format issue #282, with the Solidity team):

Interpretation is the format's next big piece: a rewrite of its type descriptions (#282), planned right after this release plan. The decoding step of our planned @ethdebug/codec library (a type plus the bytes read gives a value) is designed, not built. So today there is no compiler-agnostic way to turn ethdebug data into displayed values. The storage inspector above decodes values with its own small decoder, by hand: exactly the missing layer. Local variables also need compilers to emit them: bugc does, at every optimizer level (ethdebug/format pull request #328, merged 2026-10-06); solc in a later stage plan.

The parts

Inside a slot, offset counts from the most significant byte. solc's storageLayout counts from the other end, so a converter must flip it.

The expression keys start with ~ (ethdebug/format switched from $ in pull request #323, because $ can collide with identifiers). solc still writes $ (issue #324), so the solc output quoted on this page is shown with ~ keys; nothing else in it is changed.

solc's mapping template

This is real output from the #16990 build, complete and with its values unedited, for mapping(address => Account) accounts at slot 0, where struct Account { uint256 balance; uint64 nonce; bool frozen; }. The mapping's template refers only to the struct's, so these two are the whole set. They sit in the resources file, under pointers, keyed by solc's internal type ids.

{
  "t_mapping$_t_address_$_t_struct$_Account_$8_storage_$": {
    "expect": ["slot", "key"],
    "for": {
      "define": {
        "slot": {
          "~keccak256": [
            { "~wordsized": "key" },
            { "~wordsized": "slot" }
          ]
        }
      },
      "in": {
        "template": "t_struct$_Account_$8_storage",
        "yields": {
          "balance": "value-balance",
          "frozen": "value-frozen",
          "nonce": "value-nonce"
        }
      }
    }
  },
  "t_struct$_Account_$8_storage": {
    "expect": ["slot"],
    "for": {
      "group": [
        { "location": "storage", "name": "balance", "slot": "slot" },
        {
          "location": "storage", "name": "nonce",
          "slot": { "~sum": ["slot", "0x01"] },
          "offset": "0x18", "length": "0x08"
        },
        {
          "location": "storage", "name": "frozen",
          "slot": { "~sum": ["slot", "0x01"] },
          "offset": "0x17", "length": "0x01"
        }
      ]
    }
  }
}

Next to them, under types, solc writes what each type is:

{
  "t_mapping$_t_address_$_t_struct$_Account_$8_storage_$": {
    "kind": "mapping",
    "contains": {
      "key":   { "type": { "id": "t_address" } },
      "value": { "type": { "id": "t_struct$_Account_$8_storage" } }
    }
  },
  "t_struct$_Account_$8_storage": {
    "kind": "struct",
    "contains": [
      { "name": "balance", "type": { "id": "t_uint256" } },
      { "name": "nonce",   "type": { "id": "t_uint64" } },
      { "name": "frozen",  "type": { "id": "t_bool" } }
    ],
    "definition": {
      "name": "Account",
      "location": {
        "source": { "id": 0 },
        "range": { "offset": 78, "length": 90 }
      }
    }
  }
}

Walkthrough

  1. To read accounts[k], a tool sets slot = 0 (from the program context, or storageLayout on released solc) and key = k, and evaluates the mapping template.
  2. The template redefines slot as the keccak256 hash of the key and the old slot, each as a 32-byte word. That is the entry's slot.
  3. It hands that slot to the Account template, and renames its regions (balance becomes value-balance, and so on).
  4. The struct template puts balance in the entry's slot. It packs nonce (8 bytes) and frozen (1 byte) into the next slot, at offsets 0x18 and 0x17 from the top.
  5. The tool reads those bytes, and uses the types to show them as numbers and a boolean.

The tool needs no Solidity knowledge: no hash order, no padding, no packing direction. Against a local anvil chain, @ethdebug/pointers decoded accounts[0xf39f…] as { balance: 975, nonce: 1, frozen: false }. A draft reader in soldb (walnuthq/soldb #181) decoded the same layout for another key. That is two independent readers that agree on the same compiler output.

solc's string template

Solidity stores a string of 31 bytes or less inside its slot, with 2 × length in the last byte. A longer string stores 2 × length + 1 in the slot, and its data at keccak256(slot). solc's t_string_storage template says this as data. This is real #16990 output for string public name, with unedited values and the keys reordered to read if, then, else:

"t_string_storage": {
  "expect": ["slot"],
  "for": {
    "group": [
      {
        "name": "length-flag", "location": "storage", "slot": "slot",
        "offset": { "~difference": ["~wordsize", "0x01"] },
        "length": "0x01"
      },
      {
        "if": {
          "~remainder": [
            { "~sum": [{ "~read": "length-flag" }, "0x01"] },
            "0x02"
          ]
        },
        "then": {
          "define": {
            "length": { "~quotient": [{ "~read": "length-flag" }, "0x02"] }
          },
          "in": {
            "name": "data", "location": "storage", "slot": "slot",
            "length": "length"
          }
        },
        "else": {
          "group": [
            { "name": "long-length", "location": "storage", "slot": "slot" },
            {
              "define": {
                "length": {
                  "~quotient": [
                    { "~difference": [{ "~read": "long-length" }, "0x01"] },
                    "0x02"
                  ]
                }
              },
              "in": {
                "define": {
                  "start": { "~keccak256": [{ "~wordsized": "slot" }] }
                },
                "in": {
                  "name": "data", "location": "storage", "slot": "start",
                  "length": "length"
                }
              }
            }
          ]
        }
      }
    ]
  }
}

Walkthrough

  1. length-flag is the last byte of the slot (offset ~wordsize − 1, where ~wordsize is 32).
  2. The if reads that byte, adds 1 and takes the remainder by 2. For an even byte (short string) the result is 1, so then applies; for an odd byte (long string) it is 0, so else applies.
  3. Short: the length is the byte divided by 2, and the data starts at the top of the same slot.
  4. Long: the whole slot holds 2 × length + 1, so the length is (word − 1) / 2. The data starts at keccak256 of the slot, and runs as one region across as many slots as it needs.

The type entry for this template is just { "kind": "string" }. With @ethdebug/pointers, this template decoded a long string correctly; the if chose the long form. It is nearly the same text as the string example in our own schema. A single region that spans slots became legal in the spec in mid-September 2026 (ethdebug/format #284), and Walnut's build used it about two weeks later.

Compared with storageLayout, source maps and DWARF

solc's storageLayout

storageLayout lists each state variable's slot, offset and type, and each type's encoding (inplace, mapping, dynamic_array, bytes), struct members and sizes. It is mature, simple and complete for state variables. For storage, today, a tool built on it does as well as one built on ethdebug.

What it does not say is how to find a mapping entry or an array item. "encoding": "mapping" names the rule, and the reader must know it: the hash order, the key padding, string keys, short and long strings, the packing direction. It covers storage only, so nothing for memory, calldata or the stack, and so nothing for local variables. And it is solc's own output; other compilers have their own, or none.

Source maps

solc's source map gives each instruction a source byte range, a file index and a jump marker. It is compact and works today. On solc, ethdebug's source ranges match it on every step we checked (six transactions). It says nothing about data: no variables and no locations. Pointers answer a different question.

Why not DWARF?

DWARF is the debug format of native compilers (C, C++, Rust). It is a good format, and we learn from it. Its location expressions are a small stack-machine language for "where is this variable". Here is how it fits the EVM, case by case, with section numbers from DWARF 5:

EVM caseDWARF 5ethdebug pointer
Dynamic array in memoryPossible (§5.18, the machinery for Fortran arrays)~read the length, then a list
Packed struct fieldsPossible (§5.7.6)offset and length in a slot
Hashed storage: mappings, storage arrays, long stringsNo operation hashes. A vendor operation (§7.7.1) would work only in tools that add it; gdb, lldb and gimli (the Rust DWARF library) do not. No type for "the value at key k".~keccak256
Short-or-long stringsVariant parts (§5.7.10) fit only in part, since the long case needs a hashif / then / else
Several items packed per slotOne stride per array; cannot express itlist with computed offsets
Several data locationsOnly DW_OP_xderef, with numbered address spaces that DWARF does not define (§2.5.1.3)Seven named locations
256-bit wordsThe generic type is address-sized (§2.5.1); gimli handles at most 64 bitsValues of any width

What DWARF does better. DWARF is compact and mature. Its location lists (where a variable lives over a range of code) and call-frame data are proven. It also has DW_OP_stack_value: "the value is this expression, not at an address". ethdebug has no equivalent of that yet (ethdebug/format issue #291). DWARF's type entries also give reuse similar to our templates. And pointers are more verbose: solc's string template is about 100 lines of pretty-printed JSON.

Credit where due. solx, an LLVM-based Solidity compiler now maintained by Nomic Foundation, emits DWARF, and Hardhat's EDR reads it for stack traces since July 2026. It uses line tables and inlined-call records; solx writes no variable entries. It shows DWARF works for stack traces on the EVM. Where storage and memory values live is the open part, and that is what pointers are for.

Background. Our repository's README (2022) names DWARF and says no existing format covers smart contracts' differences, and the docs' challenges page (2023) explains the storage-packing gap.

In short: storageLayout, source maps and solc's immutableReferences each give one kind of fact. DWARF gives a general mechanism, but not the operations the EVM needs. A pointer lets the compiler state the rule, and a generic evaluator apply it.

Another language's layout

Vyper, another contract language, hashes a mapping's key and slot in the opposite order, a perfectly good choice. As a pointer, that is just the two operands swapped:

{ "~keccak256": [{ "~wordsized": "slot" }, { "~wordsized": "key" }] }

Vyper does not pack values into slots either, so its regions would simply have no offset. The tool reading them would not change. Vyper does not emit ethdebug; we wrote this from its rule, and checked the rule on a deployed Vyper contract: Solidity's order finds an empty slot, Vyper's finds the value.

Who writes and reads pointers today

Known gaps

3. Optimized code

An optimizer rewrites code to be smaller or cheaper. The result does what the source says, but the simple link "this instruction comes from that line" breaks. Inlining copies a function's body into its caller, so no call happens. Shared code, such as one block that reverts on overflow, has several possible origins. And one instruction can belong to two places at once. Most contracts on mainnet are optimized: 81% of verified Solidity deployments, from Sourcify's export of 2026-01-05.

So for years, debuggers have asked you to turn the optimizer off. That means debugging code that is not what runs on chain.

What the format says

In ethdebug, each instruction has a context: what holds in the source after it runs. Contexts are per instruction, so no fact assumes code runs in source order. Three keys exist for what an optimizer does:

Two more help. An inlined call keeps an invoke marker with no jump target, and a matching return, so the debugger can still show the function on its call stack (the spec calls this a virtual activation). And frame says which level a fact belongs to, such as the source or the compiler's intermediate code; no compiler emits it yet.

Precision, not accuracy. Under optimization, debug data may lose precision but never accuracy. A range may gather several statements, and a variable the optimizer folded away may be listed with its type but no pointer; but a pointer never reads the wrong bytes, and a range never names code an instruction did not come from. bugc holds to this: its optimizer gathers colliding ranges and never drops one; in its local variables (ethdebug/format pull request #328, merged 2026-10-06), a folded local has no pointer, and tests at every optimizer level check each listed pointer against the program's real value at every step; a missing value is now a compile error, not a wrong value (#327, merged).

bugc inlining, as data

bugc compiles BUG, a small language we built to teach and test the format. It is our reference compiler. Take:

function dbl(x: uint256) -> uint256 { return x + x; }
code { r = dbl(src); }

At optimizer level 2, bugc inlines dbl, and the program shrinks from 85 to 33 runtime instructions. The ADD at offset 29 still says where it came from (trimmed):

{ "gather": [
    { "code": { "source": { "id": "0" },
                "range": { "offset": 150, "length": 8 } } },
    { "code": { "source": { "id": "0" },
                "range": { "offset": 70, "length": 5 } },
      "transform": ["inline"] } ] }

Range 150+8 is dbl(src), the call site; 70+5 is x + x, in dbl. The first inlined instruction (offset 23) carries an invoke of dbl with no target, and the last (offset 34) its return. A tail-recursive function shows the same idea: the jump back carries a return, an invoke and transform: ["tailcall"], so a debugger keeps one frame for the loop instead of adding one per iteration.

bugc's tests check this: 63 tests in four files pass. They check that calls and returns survive every optimizer level, that each transform name appears at the right levels, and that the code still runs correctly. BUG is our own language, so this shows the format works, not that others use it.

bugc also shows what variables look like in optimized code. Since ethdebug/format pull request #328 (merged 2026-10-06), it lists its local variables at every optimizer level, 0 to 3: each local is located where its value lives, or listed by type where the optimizer folded it. An inlined function's locals appear in its inlined code, under transform: ["inline"]. Tests read every listed pointer against the real machine state at every step, and the tracking does not change the bytecode. This is where the bytes are, not what they mean: no values yet.

Solar and Fe

Solar, a second Solidity compiler written by Paradigm, emits ethdebug with its optimizer on. Its bytecode is identical with or without the debug data, so there is no special debug build. In its optimized test contracts it emits 106 pick contexts; for example, its overflow revert lists the three arithmetic statements that can reach it. Its invoke markers do not fire yet; the fix needs no format change.

Fe steps through optimized code at its default level, -O1, at expression level. Inlining hides the call site there: line 23 of the counter example above has no instruction. Fe's compiler records inlining internally (caller, callee and call site), but its export writes only source ranges today. It could use transform and invoke with no format change. What to export is the Fe team's call.

solc's status

solc refuses ethdebug with the optimizer on: "Optimization is not yet supported with ethdebug." So today it has the same limit as every earlier debugger. No open pull request lifts it.

solc's design page defers the optimizer on purpose, as a staging limit. It also plans how: every Yul optimizer pass must declare how it keeps the debug data right, with six strategies. plan

StrategyMeaningWhere it would land in ethdebug
PreserveCopy unchangedThe same contexts
MergeTwo values become one; both names stay visiblegather
RemapRewrite the location when a value is renamed or movedA different variable pointer on later instructions
RecomputeThe value is recomputed where it is usedNeeds issue #291
CloneCopy the entry for each generated copy (inlining)One virtual invoke per site, with transform: ["inline"]
DropMark the value unavailableLeave the variable out

A pass with no declared rule counts as Drop: marking a value unavailable is always sound. The right-hand column is our reading; the design page itself covers variables, not calls.

The open gap is values in optimized code. When a variable has no location and is only an expression (a constant, or a + 1 while a is live), ethdebug has no form for it yet. That is ethdebug/format issue #291, and settling it is our work. No production compiler emits local variables yet; bugc, our reference compiler, does (see "bugc inlining, as data").

4. Where each compiler stands

As of October 2026. Four compilers emit ethdebug, for three source languages: Solidity (solc and Solar), Fe and BUG.

solc

solc is the main Solidity compiler. Walnut builds its ethdebug support, one stage at a time, in public pull requests.

StageStatusWhat it adds
Source rangesReleased, experimental (first in 0.8.29; 0.8.37 is the latest release)A code range on nearly every instruction (all but one or two per contract). Needs via-IR, the optimizer off, and from 0.8.35 the experimental setting.
Explicit settings (#16961)Merged, on the development branchNothing new in the output. Listing ethdebug in debugInfo now also requires ast-id.
Types and storage templates (#16990)Open, in review, CI greenType documents, and one pointer template per composite storage type. plan
Schema test cleanup (#17055)OpenTests only.
Every state variable (walnuthq/solidity #5, and #7–#10 on top)Open on Walnut's fork of solc; not yet proposed upstream. We built #10 and ran it: its slots and offsets match storageLayout for every variable we tested, and soldb (pull request #181) reads state through it.Every state variable and immutable listed as data: name, declaration, type and pointer. No storageLayout needed to find the bytes; showing values still needs the format's interpretation layer (above). plan
Design and Yul sidecar (#16954, and the design page #16780)#16780 merged (docs); #16954 a draftThe design for later stages, including local variables and the optimizer rules above.
Local variablesDesigned; no pull request yetPointers for variables in memory and on the stack. plan
Function call markersIn no plan we foundinvoke and return.
Optimized codeDeferred; planned after local variablesethdebug for optimized builds. plan

The post's settings work unchanged on 0.8.37 and the development branch; only ethdebug without ast-id differs. Three facts about today's output: 77% to 93% of runtime instructions carry a range over the whole contract (compiler-generated code); every instruction gets the source id of the contract's own file, even code from imported files (#329); and neither writes the new stamp field that names a file's schema and version (Walnut's #10 build does). Output from both the nightly and the #16990 build validates against our schemas, 45 checks of 45.

Fe

Fe is a separate contract language, also run by Argot. ethdebug is its only debug output. With Fe v26.4.1, three commands build a contract and write its debug data:

fe build counter.fe --out-dir out -e bytecode,runtime-bytecode,abi
fe dev trace emit counter.fe --out counter.trace.jsonl
fe dev debug emit --format ethdebug --from counter.trace.jsonl --out counter.ethdebug.json

The second command records the compiler's own facts (no transaction is needed); the third turns them into ethdebug. Fe's docs call the format experimental.

The adapter. Fe's file is a Fe-specific view, and not stock ethdebug yet. It wraps its programs in its own list, adds three Fe-only fields to each instruction (fe_origin_key, confidence, classification), has no contract definition, and lists sources by path and hash, without their text. Dropping the three fields and adding a stub definition makes each program valid. For stepping, none of that matters: the stepper above only picks the program, and the soldb demo also supplies the source files (their hashes match Fe's).

Coverage, at -O1: 64% of executed steps carry a source range, at expression level. The rest are dispatcher code (which Fe marks as synthetic), jump targets and creation code, which has no ranges. Inlined calls hide their call site. Fe exports no variables, pointers or call markers yet, though its compiler models variables, scopes and inlining inside. Two of its ideas are not in the format yet, and we are looking at them: marking instructions as compiler-generated, and naming sources by hash instead of including their text.

Solar

Solar is a second Solidity compiler, written in Rust by Paradigm. Its main branch emits ethdebug; Djordje of Walnut (djolertrk on GitHub), who also leads solc's ethdebug work, wrote that support. It is not in a release yet: the latest, v0.2.0, has none. You ask for it with --emit=ethdebug,ethdebug-runtime,ethdebug-resources, with no via-IR or experimental setting.

bugc

bugc is our reference compiler for BUG. It is where the format's features are tried first: per-variable storage pointers, and the optimizer contexts above, at four optimizer levels. You can write BUG and see its ethdebug output in the playground on the ethdebug site, with nothing to install. Since ethdebug/format #328 (merged 2026-10-06), bugc also emits local variables at every optimizer level: a pointer to the exact bytes in memory or on the stack, composed pointers for dynamic arrays, string and bytes, and the type alone where the optimizer folded a value. Its tests read every listed pointer against the real machine state at every step. It does not show values: pull request #270 decodes them in the trace playground only as a display hack, not for merge.

5. Contracts already on mainnet

Can you use ethdebug on contracts that are already deployed? With solc today, only on a small part of them.

Fe and Solar already emit ethdebug for optimized code. For solc, optimized code comes later plan, and it will apply to new compilations; old contracts would need a recompile that matches.

6. Measurements

soldb's numbers are from Playwright engine builds (Chromium 153, Firefox 155, WebKit 26.6, and an iPhone 15 emulation), three runs each, medians, with no console errors.

soldb in the browser

The lean build steps a saved trace: Arcade's play() transaction, 2,228 steps, a 1.5 MB trace (32 KB gzipped). The saved trace leaves out the flat copies of each step's state, which soldb drops when it reads the trace (SOURCE.md). soldb maps all 2,228 steps to a line in Arcade.sol; 1,865 of them map to the whole contract (compiler-generated code).

StageChromiumFirefox
Fetch everything, compile the WebAssembly7.8 ms16 ms
Parse the trace15 ms15 ms
Read the ethdebug program29 ms26 ms
Map all 2,228 steps24 ms30 ms
Solidity tab usable, from navigation start122 ms171 ms
Whole page (all tabs, both builds)354 ms466 ms

Parse, read and map together take about 70 ms. The page loads only the Solidity tab at first; each other tab, and the replay build below, loads when it first opens, or in idle time after the Solidity tab is ready. The replay build re-runs a transaction itself, with no node contacted, from the command-line tool's own 6.5 KB replay file (Token.transfer, 960 steps):

StageChromiumFirefox
Compile the WebAssembly1.2 ms6 ms
Run the EVM8.4 ms5 ms
Map 960 steps9.8 ms9 ms

The Fe data set (4,549 steps): fetch 37 to 53 ms, load the transaction 8 to 13 ms, read the ethdebug program 62 to 63 ms, map all steps 52 to 81 ms.

Build sizes

soldb WebAssembly buildSizeGzippedCan do
Lean531 KB226 KBStep a trace a node already made
Replay1,092 KB466 KBAlso re-run the transaction offline

Other costs on the demo page: the syntax highlighter adds 16 requests (95 KB) and 145 to 155 ms (display only). It starts after the Solidity tab's files arrive, and the source shows as plain text until it is ready. The page's files are gzipped on the wire: the Solidity tab needs 417 KB, the Fe tab 291 KB more, the BUG tab 312 KB more and the old way 30 KB more. On a slow link (400 kbps, 400 ms round trip, Chromium), the Solidity tab is usable after 9.5 s, with a progress bar while it loads. WebAssembly memory is 25.6 MB after that trace.

The stepper's line counts

WhatLines of code
Core: instruction offset → source range → printed line10
Input step to pick Fe's program2 (plus 2 comment lines)
Filters (inside the core): other files, compiler glue1 each
Minimal stepper on solc's ethdebug8
The same on solc's classic source map19

The classic source map must be decompressed and matched to program counters through the bytecode, where an off-by-one silently shifts every later mapping. Both steppers gave the same source range on every step of six transactions (472 to 20,405 steps each, three of them reverts). On solc today the gain is shape, not information: the classic map also marks function jumps, which solc's ethdebug output does not do yet.