Audience:
contributor·maintainerTL;DR: How to hack on the extension — set up a dev environment (devcontainer or by hand), the everyday build/lint/test commands, the test-first convention and how to regenerate fixtures, and a tour of how the trace-replay adapter works internally (architecture map included).
Thanks for your interest in improving the Soroban Debugger! This document covers how to get a development environment running and the conventions we follow.
The quickest path is the included devcontainer, which installs the full
toolchain — Rust with the wasm targets, the Stellar CLI, and komet-node (via
kup install komet-node, which also pulls the K toolchain and prebuilt
semantics). Open the repo in VSCode and choose Reopen in Container, or use the
GitHub Codespaces button. See .devcontainer/Dockerfile.
To set things up by hand instead, you need:
- Node.js ≥ 22
- For the live pipeline only: a Rust toolchain with the
wasm32v1-none(orwasm32-unknown-unknown) target, the Stellar CLI, andkomet-node.
Then:
npm install~/.ssh is a named Docker volume, so keys persist across container rebuilds
— but the volume starts empty on first build, so add a key once. The volume
lives outside /workspace, so keys can't be committed to the repo, and Docker
excludes volumes from docker commit / image builds. (Anyone with root or
docker-group access on the host can still read the volume, so prefer a
passphrase or a dedicated, revocable key.)
Inside the container, generate a key:
ssh-keygen -t ed25519 -C "you@example.com" # writes ~/.ssh/id_ed25519[.pub]
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pubThen add the public key (~/.ssh/id_ed25519.pub) to your GitHub account at
https://github.com/settings/ssh/new. To reuse an existing host key instead,
docker cp it into the container's ~/.ssh and apply the same chmod.
npm run build # bundle to dist/extension.js (esbuild)
npm run watch # rebuild on change
npm run check-types # tsc --noEmit
npm run lint # eslint
npm test # tsc -p tsconfig.test.json, then mocha (~2 min)Press F5 (Run Extension) to open an Extension Development Host with the
extension loaded and the examples/ workspace open. Pick a
configuration from the Run and Debug view — the Replay … with symbols
configs need no toolchain at all.
The debugger consumes an upstream chain,
komet-node → komet → wasm-semantics, but you rarely touch it. Pick your mode:
1. Just debugging (almost always). Do nothing special — the devcontainer
already has the komet-node binary (from kup). Press F5. You never need
nix, uv, or any of the tooling below.
2. Changing the semantics and seeing it in the debugger. Use the single
front-end scripts/dev.sh:
./scripts/dev.sh setup # check out the chain into .deps/ and wire it up
# …edit .deps/wasm-semantics or .deps/komet…
./scripts/dev.sh build # fast incremental rebuild
./scripts/dev.sh use # make it the debugger's komet-node, then press F53. Upstreaming the change as PRs. Once you have commits on a shared branch in
the checkouts, dev.sh pr open <branch> opens a cross-linked PR in each
repo (pr status <branch> / --dry-run to preview first).
That's the whole developer surface. Everything else is plumbing the script drives for you:
- You never run
nix,uv, orkdistby hand —setup/builddo. .deps/(leading dot) is generated and gitignored — throwaway local checkouts; delete it any time to reset. Not to be confused with the committeddeps/folders in komet/komet-node, which hold version pins (below).- This repo carries no version-pin files. The pins that connect the chain
(
deps/*_release,uv.lock, flake inputs) live upstream in komet/komet-node and are bumped by CI, not by you.
Why it needs checkouts (background)
The chain is pinned by uv git dependencies, not Nix flake inputs, so
kup --override can't reach komet/wasm-semantics. setup therefore checks
the repos out side-by-side (at the versions currently pinned, or --tip for
latest) and wires them with uv path sources so an edit flows up the chain
with no version bumps. build/shell are the fast inner loop (kdist caches
per target, toolchain from komet-node's Nix dev shell); use does one Nix
realize so the debugger gets the exact release build — doubling as a parity
check. Revert with kup install komet-node.
- Write tests first. New behavior should arrive with a failing test that
describes it, then the implementation that makes it pass. The suite is the
contract; keep it green (
npm test) before opening a PR. - Replay logic is deliberately free of the
vscodeAPI so it can be unit-tested in plain Node. Keepvscode-only code inextension.ts. - Tests run automatically in CI on every push and pull request (Node 22): type-check, lint, build, and test.
The DWARF/trace fixtures under test/fixtures/ are real build + trace outputs
and must stay matched (the wasm's DWARF and the trace's positions are checked
against each other). Regenerate them as a pair:
scripts/make-fixtures.sh # rebuild the debug wasms + capture matching traces
node scripts/verify-addresses.mjs # re-derive the address-space ground truth vs a live komet-nodeThese need the full toolchain (Rust + Stellar CLI + komet-node).
The debug adapter is a trace-replay cursor machine. komet-node
executes a whole transaction and returns the entire execution trace — one
record per WebAssembly instruction — and the adapter loads that into an
in-memory model and services every DAP stepping request by moving a cursor.
Because the whole recording is in memory, stepping backward is just as cheap
as stepping forward. The adapter runs in-process in the extension host
(DebugAdapterInlineImplementation).
flowchart TB
subgraph build["Build (LiveBackend)"]
CRATE["contract crate"] -->|"CARGO_PROFILE_RELEASE_DEBUG=true<br/>STRIP=none, OPT_LEVEL=0"| WASM["wasm + DWARF<br/>(pristine linker output)"]
end
WASM --> KOMET["komet-node<br/>executes the whole transaction"]
KOMET --> TRACE["entire trace<br/>one record per wasm instruction"]
subgraph model["In-memory model — vscode-free"]
TRACE --> VAL["validate positions<br/>vs static disassembly"]
WASM -. "DWARF" .-> MAP["map code offset → Rust file:line"]
VAL --> CUR["cursor machine<br/>(forward == backward cost)"]
MAP --> CUR
end
CUR -->|"each DAP request<br/>just moves the cursor"| DAP["SorobanDebugSession<br/>StoppedEvents / frames / disassembly"]
A rawTrace replay skips the Build and komet-node stages entirely — the
JSONL trace is loaded straight into the model (and a paired wasmPath still
feeds the DWARF/disassembly seams).
- The build injects debug info without touching your
Cargo.toml. It setsCARGO_PROFILE_RELEASE_DEBUG=true/CARGO_PROFILE_RELEASE_STRIP=noneforstellar contract build, so the wasm carries DWARF. The pristine linker output (target/…/release/deps/*.wasm) is what gets uploaded, because the Stellar CLI's metadata-injection step rewrites the wasm and strips the DWARF line programs. - DWARF → Rust. An in-repo DWARF v4/v5 line-table parser (
src/dwarf/) maps wasm code offsets to Rustfile:line. Breakpoints set in Rust source verify against the executed trace (sliding forward to the nearest executed line). - No-DWARF fallback. A prebuilt wasm without debug info — or a
rawTracereplay withoutwasmPath— degrades gracefully to disassembly-only debugging: frames carry an instruction pointer but no source. - Positions are validated. komet-node's
posis relative to the section being executed (e.g. the code section for function code, the globals section for global initializers), so every record is cross-checked against the static disassembly and only trusted when the mnemonics agree. komet-node's tracer stops at instructions it cannot decode (printing them asunknown, e.g.if), so a trace can be a prefix of the full execution.
extension.ts VSCode glue: config provider + inline adapter factory
debugAdapter/
SorobanDebugSession DAP handlers (cursor moves + StoppedEvents, disassembly)
TraceModel records, cursor, call-depth, line + instruction stepping
artifacts.ts wasm bytes -> { mapper, disassembly, validated positions }
backends/
RawTraceBackend replay a JSONL trace file (+ optional wasmPath for symbols)
LiveBackend turnkey build + spawn + deploy + trace
komet/
trace.ts JSONL -> TraceRecord[] (K-style mnemonics, section-relative pos)
mnemonics.ts K-style instr arrays -> wasm mnemonics ('i64.const 255')
KometClient.ts JSON-RPC client (getHealth/sendTransaction/traceTransaction/...)
soroban/scval.ts launch args -> ScVals (@stellar/stellar-sdk)
wasm/
sections.ts wasm section walker (offsets, custom-section lookup)
Disassembly.ts static disassembly (wasmparser), code-offset addressed
dwarf/ DWARF v4/v5 .debug_line/.debug_info parser -> LineTable
sourcemap/
SourceMapper the mapping seam the adapter talks to
DwarfSourceMapper trace index / code offset -> Rust file:line (+ breakpoints)
NullSourceMapper no-DWARF fallback (disassembly-only)
All replay logic is free of the vscode API, so it can be unit-tested in plain
Node; the vscode-only glue lives in extension.ts. For a deep dive on the
stepping model, see docs/stepping.md.
- Branch off
mainand keep PRs focused on a single change. - Make sure
npm run check-types,npm run lint, andnpm testall pass. - Write clear commit messages that explain the why, not just the what.
- Update the CHANGELOG under Unreleased for user-facing changes.
Because the debugger replays a captured trace, a JSONL trace file is often the
most useful thing to attach to a bug report — it reproduces a session with no
toolchain or node required (rawTrace in a launch config). See the issue
templates when you open an issue.