feat: generalize the debug config into multi-transaction sequences - #10
Merged
Conversation
Replace the fixed 4-step TurnkeyPipeline with a config that defines an
ordered sequence of transactions, tracing the last by default.
- config.ts: normalizeConfig folds a new `transactions`+`trace` schema and
the legacy single-invoke config into a canonical `{ steps, trace }`, with
handle validation and trace selection by index, invoke id, or "last".
- specEncode.ts: spec-driven argument encoding from the wasm's own contract
spec (composites/enums/structs/tuples) plus ${sourceAddress}/${contract:id}
substitution.
- SequenceRunner.ts: executes N transactions against one accumulating ledger;
never throws on a FAILED tx (a reverting tx stays traceable), assigns a
distinct incrementing sequence per tx to defeat komet-node dedup, and uses
a deterministic source account.
- SorobanTxBuilder/TurnkeyPipeline: thread per-tx sequence numbers; route both
legacy and new configs through the runner.
- Fixtures + unit and real-komet-node e2e suites (constructor-as-invoke,
composite args, state persistence, revert tracing).
Constructor initialization is expressed as an explicit invoke tx, since komet
drops CreateContractV2 constructor args.
The e2e `before` hook probes `<command> --help` to fail loudly when the node is missing. The 10s ceiling is fine for the kup binary (which answers instantly) but too tight for a locally-built dev node run from source, whose cold start imports ~15s of K/pyk machinery before `--help` returns — the probe would time out and abort the suite even though the node is present and healthy. Default the probe timeout to 30s and allow overriding it via KOMET_NODE_PROBE_TIMEOUT_MS. The value is only a ceiling, so a fast binary is unaffected.
Replace the single-invoke-only configuration reference with the generalized
`transactions` + `trace` schema: deploy/invoke steps, handle references, the
trace selector, named `args` (with composite/enum/tuple shapes), and the
`${sourceAddress}` / `${contract:<id>}` substitution tokens. Keep the
single-call shorthand documented as a convenience.
The debugger accepted two live launch-config shapes: a legacy top-level `function`/`args`/`contract`/`wasmPath` single-invoke config, and the newer `transactions` sequence. Collapse to one: `transactions` (+ an optional `trace` selector) is now the only live format. Offline replay via `rawTrace` (+ an optional `wasmPath` for symbols) is unchanged. - normalizeConfig now requires a `transactions` array; the desugaring of the legacy shape is gone. A leftover top-level `function` throws a migration error pointing at the new format. - SorobanLaunchArgs drops the top-level `function`/`args`/`contract`/ `buildCommand`/`debugInfo` fields; build options live on deploy steps. - The soroban-trace CLI keeps its `--contract`/`--function`/`--args` flags but now builds a `transactions` config internally. - The extension provider requires `transactions` or `rawTrace`, and injects the resolved build command into each deploy step. - Update the launch schema, examples, and README; add a full config reference at docs/debug-config.md (single tx, multi-contract system, composite argument types).
Protocol 23 added the SC_SPEC_ENTRY_EVENT_V0 (kind 5) spec entry. The pre-14 SDK threw "XDR Read Error: unknown ScSpecEntryKind member for value 5" when parsing any contract whose spec section held one. The 14.x SDK parses it, so loadContractSpec drops the Client/placeholder-rpc workaround and reduces to contract.Spec.fromWasm.
Large contracts can take longer than the previous fixed request deadline to upload or execute, aborting the debug session mid-launch. node.timeoutMs sets the per-RPC timeout in milliseconds (default 600000, i.e. 10 minutes) passed through to KometClient.
sendErrorResponse shows only a one-line, non-copyable modal. Mirror the full error and its stack into the debug console first, so the details land in the same copyable log as the rest of the launch output.
Optimization-level-0 debug builds map deep, un-inlined call chains into std/core and crates.io dependency sources, so stepping kept resting in library internals (core/result.rs, option.rs, ...) the user never wrote. justMyCode (default true) drops statement stops whose file lives under a Rust toolchain or dependency-registry path (/.rustup/, /.cargo/, /rustc/), so stepIn/next/continue rest only in workspace code and step over a foreign frame in one press. Instruction granularity, the instruction pointer, and breakpoints are unaffected; the trace CLI gains --no-just-my-code.
A traced transaction's records include the root contract plus its cross-contract sub-calls, but the adapter's disassembly and DWARF are the root contract's only. A sub-call's small pos values collide with the root's low code offsets, so those records mapped to bogus source lines. Parse komet's per-record executingContract tag and, in validatedPositions, make any record tagged with a contract other than the trace root invisible, so foreign frames are neither shown nor mis-mapped.
The debugger could show the code but not the chain it runs against: a contract's storage, the balances it moves, and the calls it has open were all invisible unless the trace happened to write them somewhere the adapter already read. Add two scopes. Globals lists the executing module's wasm globals by module-relative index. Ledger shows contract storage across all three durabilities with their TTLs, account balances, the ledger sequence and close time, the executing contract's wasm hash and instance TTL, the host object table, and the open contract-call stack — all reconstructed at the current cursor, so they time-travel with backward stepping. The reconstruction is a LedgerImage, shaped after MemoryImage: fold the trace's Soroban VM event records once at load into immutable versions, then answer any cursor by lookup, so stepping back costs what stepping forward costs. Two subtleties drive its design. A failed sub-call unwinds the semantics' world state, so the scan keeps a stack of saved worlds and restores on an unsuccessful endWasm; and the tracer logs each record before the operation it describes, so snapshots apply at their record while mutations apply from the next one on. Event payloads get their own tolerant parser. Unlike the core record fields, ledger state is auxiliary — stepping and breakpoints do not depend on it — so an unknown tag or a malformed payload degrades that view rather than failing the session, and a komet release that reshapes an event cannot break debugging. strictParseTraceEvent keeps the contract pinned in the tests. Rendering trace ScVals needed Stellar address encoding, and importing @stellar/stellar-sdk for it put ~6 seconds of module load in front of every session — past the DAP handshake timeout. A local strkey encoder replaces it. The soroban-trace CLI reports the same state per stop, with a changed flag on the storage entries that moved since the previous stop. docs/state-inspection.md specifies the behaviour as numbered rules (G1-G4, L1-L15) that the test suite pins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t-node komet-node stamped every served trace record with an executingContract field naming the contract running at it, which the cross-contract gate then read. That put the derivation on the wrong side: komet-node is a thin stateful wrapper around komet, and the field is not information — a callContract record names its callee and an endWasm closes it, so the contract is a fold over boundaries this adapter already walks. Paying for it on the wire meant ~87 bytes of derivable data per record, on traces that run to hundreds of megabytes, added by the one code path whose whole purpose is to keep komet-node's memory proportional to the trace. Fold it here instead. komet/executingContract.ts is that fold, over the typed events rather than the wire shape, so a komet trace-format change lands in the event parser and not in the gate. validatedPositions derives the array and picks the root from the first record that resolves to a contract; a trace with no call boundaries yields all nulls and leaves the gate inert, which is the same backward compatibility the absent-tag case gave before. LedgerImage already answers the same question per cursor off its richer frame stack, and the boundary timing has to agree with it: a callContract record belongs to the callee it opens, an endWasm record to the callee it closes. A test now runs one nested trace through both and asserts they match at every record, so the two cannot drift. TraceRecord drops executingContract; nothing parses or reads it anymore. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
That release gave every trace record a top-level `kind` and moved the operands that used to ride inside `instr` into named fields, so a `contractData` record went from {"instr": ["contractData", "put", "temporary"], ...} to {"kind": "contractData", "operation": "put", "durability": "temporary", ...}. The VM records dropped `instr` and `pos` entirely, and toTraceRecord requires `instr` — so the first contract call in any v0.1.88 trace threw a TraceParseError and took the whole debug session with it.
Read both formats and normalize them onto the one model. A record with no `kind` is the older format and parses as before; a `kind` other than "instr" has its `instr` array rebuilt in the older spelling, which is exactly the two records whose operands moved plus the tag alone for the rest. Everything downstream — the event parser, the ledger reconstruction, the cross-contract gate — keeps reading one shape and never learns which komet produced the trace. Recorded traces from before the change keep replaying, which matters because a trace on disk does not get reformatted by a backend upgrade.
Fixtures in both formats parse to deep-equal records, pinning the normalization. Those two are equivalent by construction, though, which cannot catch a format detail nobody thought to write down, so there is also a real trace: komet-node v0.1.88 tracing the foo() invocation from its own README quickstart, asserted end to end down to the globals appearing one per initializer that has run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hape The parser read both komet trace formats and normalized them, so a trace recorded against a pre-v0.1.87 komet kept replaying. Pre-release, that compatibility is not worth its price: it kept a second wire shape alive in the one module every trace passes through, and it meant `instr` had to be reconstructed in the old spelling for records that no longer carry one. Require `kind` instead. A record without one is rejected, and only `kind: "instr"` carries `pos`/`instr`; a VM record's operands are read from its named fields, so `traceEvents.ts` switches on the kind directly and the reconstruction shim is gone. `instr` still holds a VM record's kind, which is what keeps every consumer of a TraceRecord reading one shape. Failing loudly on a missing `kind` is the point: the event parser is deliberately tolerant, so a legacy trace would otherwise open a session with every state view mysteriously empty rather than saying what was wrong. Every trace fixture is regenerated in the new format, and the two `endWasm-error` accommodations are gone — komet emits one `endWasm` record and distinguishes a trap by `success`. Two things the live tests surfaced, both from komet-node catching up: - The Vec<(AssetKey,i128)> scenario asserted that the real node REJECTS a composite call argument, and said in its own comment to reinstate it as a positive assertion if komet-node ever gained the capability. It has, so the test now traces the invocation and checks the callContract frame echoes the vec with both entries. - integration.node.test.ts hardcoded a 10s presence probe. A komet-node run from source loads pyk and the K bindings first and answers `--help` in ~18s, which the probe reported as "not found". It now takes the same KOMET_NODE_PROBE_TIMEOUT_MS knob as its sibling e2e file, defaulting to 30s. Verified against a komet-node built from the komet v0.1.88 bump: 833 passing, including the real-node end-to-end tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RaoulSchaffranek
force-pushed
the
feature/multi-tx-debug-config
branch
from
August 12, 2026 14:40
7cefa7b to
72b442c
Compare
Review follow-up on this PR: every change here removes code by removing a
duplicate, a dead path, or a second way to say the same thing.
- The executing contract is derived once. LedgerImage's call-frame stack was
already answering "which contract runs here" for the Ledger view, so the
parallel fold in komet/executingContract.ts is gone and the cross-contract
gate reads the image instead — along with the test that existed only to keep
the two folds in agreement.
- TraceModel owns both whole-trace reconstructions (memory, ledger) as lazy
cached getters. The session, the CLI projection and the artifact builder each
built their own; the `opts.memory`/`opts.ledger` plumbing and the
`?? new LedgerImage(...)` fallback go with them.
- The ledger presentation lives in debugAdapter/ledgerView.ts: one snapshot per
stop, rendered as a lazy ChildVar tree the DAP session hands straight to
toDapVariable. The session's ledger code drops from ~135 lines to 7 and the
CLI keeps its typed JSON schema, reading the same snapshot.
- Invoke arguments have one form. The positional {type,value}[] path and the
hand-rolled encoder behind it (soroban/scval.ts) are removed now that the
contract's own contractspecv0 spec encodes everything; the spec is parsed
only when there are named args to encode.
- SorobanTxBuilder assigns each envelope its own account sequence, so
anti-dedup is structural rather than a parameter every call site must
remember to pass.
- SequenceRunner threads one RunContext instead of six-to-ten positional
parameters, and holds one handle registry instead of three registries plus
two index-parallel arrays. Per-tx statuses are now reported to the debug
console rather than fetched and discarded.
- traceEvents models exactly what the views consume: hostCall and the storage
read ops move nothing and are no longer parsed, while endWasm's result now
feeds returnValue — which had consumers in both the session and the CLI but
no producer. parseTraceEvent is strict, with the single tolerant call site
in trace.ts.
- Drops the 1,774-line fixture Cargo.lock, unreferenced and absent from the
sibling fixture.
The e2e trap scenario used the positional form to smuggle an arity mismatch
past encoding, so the ctor-probe fixture gains a boom() that panics and its
wasm is rebuilt.
Docs, CHANGELOG and the launch-config schema follow the argument change; the
CHANGELOG also gains the entries this PR's own features never got.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Generalizes the debugger's launch config from a single fixed invoke into an ordered sequence of transactions executed against one accumulating komet-node ledger, with the last transaction traced by default. This unblocks debugging contracts that need setup transactions — constructors, seeded state, and multi-step flows — not just a bare single call.
It also carries the trace-format migration and the state views that ride on it: komet v0.1.87's kind-tagged records, a Ledger and a Globals scope, a cross-contract gate on source mapping, and just-my-code stepping.
What's new
config.ts—normalizeConfig: folds thetransactions+traceschema into one canonical{ steps, trace }. Validates handle references, duplicate ids, and the trace selector (by index, invokeid, or"last"). The legacy single-invoke config (top-levelfunction/args/contract) is rejected with a message pointing at the new shape.specEncode.ts: spec-driven argument encoding straight from the wasm's owncontractspecv0(composites / enums / structs / tuples), plus${sourceAddress}/${contract:id}substitution. This is now the only argument form — the positional{type,value}[]encoder is gone.SequenceRunner.ts: executes N transactions against one ledger. It never throws on a FAILED tx (a reverting / trapping tx stays traceable — the trace is fetched regardless of status, and every tx's status is reported to the debug console).SorobanTxBuildergives each envelope its own account sequence, so byte-identical invokes are not deduped by komet-node, and the source account is deterministic (neverKeypair.random()).traceEvents.tsparses the VM event records,LedgerImagereconstructs the ledger at any cursor (including rolling back a trapped sub-call), andledgerView.tsrenders it once for both the DAP Ledger scope and the CLI's per-stop projection.justMyCode(default true) keeps source stepping in workspace files.Testing
Unit + integration suites for each milestone (
multitxConfig,specEncode,sequenceRunner,ledgerImage,traceEvents,crossContract,justMyCode) plus a real-komet-node e2e (sequenceRunner.e2e.test.ts) covering constructor-as-invoke + state persistence, compositeVec<(AssetKey,i128)>call arguments, no-throw-on-trap, and anti-dedup.Also makes the e2e's node presence-probe timeout configurable (
KOMET_NODE_PROBE_TIMEOUT_MS, default 30s) so the suite can run against a locally-built dev node whose cold start exceeds the old 10s ceiling.Requires
komet v0.1.87 or newer for the kind-tagged trace records. The composite
Vec/Mapcall-argument path additionally needs the server-side fix in runtimeverification/komet-node#52.