From 9b4958338e8d9ec310288055f0b724fe2814a6c0 Mon Sep 17 00:00:00 2001 From: Raoul Date: Tue, 28 Jul 2026 08:55:02 +0000 Subject: [PATCH 1/4] feat: generalize debug config into multi-transaction sequences 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. --- .gitignore | 1 + src/pipeline/SequenceRunner.ts | 253 +++ src/pipeline/TurnkeyPipeline.ts | 103 +- src/pipeline/config.ts | 270 +++ src/soroban/SorobanTxBuilder.ts | 30 +- src/soroban/specEncode.ts | 135 ++ test/fixtures/composite-contract/Cargo.lock | 1774 ++++++++++++++++++ test/fixtures/composite-contract/Cargo.toml | 21 + test/fixtures/composite-contract/src/lib.rs | 27 + test/fixtures/composite.wasm | Bin 0 -> 648 bytes test/fixtures/ctor-probe-contract/Cargo.toml | 21 + test/fixtures/ctor-probe-contract/src/lib.rs | 25 + test/fixtures/ctor_probe.wasm | Bin 0 -> 482 bytes test/multitxConfig.test.ts | 450 +++++ test/pipeline.test.ts | 32 +- test/sequenceRunner.e2e.test.ts | 345 ++++ test/sequenceRunner.test.ts | 462 +++++ test/specEncode.test.ts | 179 ++ 18 files changed, 4020 insertions(+), 108 deletions(-) create mode 100644 src/pipeline/SequenceRunner.ts create mode 100644 src/pipeline/config.ts create mode 100644 src/soroban/specEncode.ts create mode 100644 test/fixtures/composite-contract/Cargo.lock create mode 100644 test/fixtures/composite-contract/Cargo.toml create mode 100644 test/fixtures/composite-contract/src/lib.rs create mode 100755 test/fixtures/composite.wasm create mode 100644 test/fixtures/ctor-probe-contract/Cargo.toml create mode 100644 test/fixtures/ctor-probe-contract/src/lib.rs create mode 100755 test/fixtures/ctor_probe.wasm create mode 100644 test/multitxConfig.test.ts create mode 100644 test/sequenceRunner.e2e.test.ts create mode 100644 test/sequenceRunner.test.ts create mode 100644 test/specEncode.test.ts diff --git a/.gitignore b/.gitignore index 1098682..422c598 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ reports/ state.kore .claude/ test/fixtures/sample-contract/target/ +test/fixtures/*/target/ examples/*/target/ diff --git a/src/pipeline/SequenceRunner.ts b/src/pipeline/SequenceRunner.ts new file mode 100644 index 0000000..6c109a2 --- /dev/null +++ b/src/pipeline/SequenceRunner.ts @@ -0,0 +1,253 @@ +/** + * The generalized sequence runner: execute a canonical `{ steps, trace }` + * (produced by M1 `normalizeConfig`) as an ordered sequence of transactions + * against one accumulating komet-node ledger, then resolve the traced tx into a + * replayable `ResolvedTrace`. + * + * This generalizes the fixed 4-step `TurnkeyPipeline` into an arbitrary + * `TxStep[]`, keeping the same acquisition flow per contract: + * + * seed source (CreateAccount) -> + * per deploy: load wasm -> strip debug sections -> upload -> create contract + * (registers handle id -> contractId AND id -> wasm) -> + * per invoke: resolve handle -> Spec -> substitute(args) -> encode -> invoke -> + * fetch the TRACED step's trace by hash -> toTraceRecords -> TraceModel -> + * buildDebugArtifacts -> ResolvedTrace. + * + * Two behaviors this runner guarantees over the old pipeline (spec blockers): + * 1. It NEVER throws on a FAILED tx. Every step runs; each `{hash, status}` + * is recorded, and the traced step's trace is fetched regardless of its + * status — a reverting tx stays debuggable. + * 2. It assigns a DISTINCT, incrementing account sequence per submitted tx so + * byte-identical invokes hash differently and komet-node cannot dedup the + * second. + * + * The source account is DETERMINISTIC: the provided `sourceSecret` is used + * verbatim, else a fixed derived keypair (never `Keypair.random()`), so the + * same config yields the same source address. + * + * Pure module (no `vscode` imports): driveable against a mock node in tests. + */ + +import { createHash } from 'crypto'; +import { promises as fs } from 'fs'; +import { Keypair } from '@stellar/stellar-sdk'; +import { KometClient } from '../komet/KometClient'; +import { ContractBuilder } from '../build/ContractBuilder'; +import { SorobanTxBuilder } from '../soroban/SorobanTxBuilder'; +import { stripDebugSections } from '../wasm/sections'; +import { toTraceRecords } from '../komet/trace'; +import { TraceModel } from '../debugAdapter/TraceModel'; +import { buildDebugArtifacts } from '../debugAdapter/artifacts'; +import { ProgressReporter, ResolvedTrace } from '../debugAdapter/types'; +import { loadContractSpec, encodeInvokeArgs, substitute, Spec } from '../soroban/specEncode'; +import { DeployStep, InvokeStep, NormalizedConfig } from './config'; + +/** Options controlling how the sequence is run. */ +export interface RunOptions { + /** Deterministic source secret; a stable derived key is used when omitted. */ + sourceSecret?: string; +} + +/** A submitted transaction's hash and final status, recorded per step. */ +export interface SubmittedTx { + hash: string; + status: string; +} + +/** + * A fixed seed for the derived source keypair. Deterministic (NOT random), so + * a config without an explicit `sourceSecret` still yields a stable source + * address across runs. komet-node self-seeds via CreateAccount, so the exact + * key does not matter — only its stability does. + */ +const DERIVED_SOURCE_SEED = Buffer.from( + 'komet-debugger-deterministic-source'.padEnd(32, '.').slice(0, 32), +); + +export class SequenceRunner { + constructor(private readonly client: KometClient) {} + + async run( + config: NormalizedConfig, + opts: RunOptions, + report: ProgressReporter, + ): Promise { + const network = await this.client.getNetwork(); + const txBuilder = new SorobanTxBuilder(network.passphrase); + + const source = opts.sourceSecret + ? Keypair.fromSecret(opts.sourceSecret) + : Keypair.fromRawEd25519Seed(DERIVED_SOURCE_SEED); + const sourceAddress = source.publicKey(); + report(`Source account: ${sourceAddress}`); + + // A distinct, incrementing account sequence per submitted tx keeps every + // envelope's hash unique so komet-node cannot dedup identical invokes. + let seq = 0; + const submit = async (envelopeXdr: string): Promise => { + const sent = await this.client.sendTransaction(envelopeXdr); + const status = await this.recordStatus(sent.hash); + return { hash: sent.hash, status }; + }; + + // Seed the source account (self-seed; komet boots from empty state). + report('Seeding source account (CreateAccount) ...'); + await submit(txBuilder.buildCreateAccount(source, undefined, seq++)); + + // Handle registries, filled as deploys execute. + const contracts: Record = {}; + const wasms: Record = {}; + const specs: Record = {}; + + // Per-step: the hash whose trace represents that step, and the wasm that + // supplies its debug artifacts. Parallel to `config.steps`. + const stepHash: (string | undefined)[] = new Array(config.steps.length); + const stepWasm: (Buffer | undefined)[] = new Array(config.steps.length); + + for (let i = 0; i < config.steps.length; i++) { + const step = config.steps[i]; + if (step.kind === 'deploy') { + const { contractId, createTxHash, wasm } = await this.runDeploy( + step, + txBuilder, + source, + () => seq++, + submit, + report, + ); + contracts[step.id] = contractId; + wasms[step.id] = wasm; + stepHash[i] = createTxHash; + stepWasm[i] = wasm; + } else { + const invokeHash = await this.runInvoke( + step, + txBuilder, + source, + sourceAddress, + contracts, + wasms, + specs, + () => seq++, + submit, + report, + ); + stepHash[i] = invokeHash; + stepWasm[i] = wasms[step.contract]; + } + } + + // Fetch the traced step's trace regardless of its status (blocker #1: a + // reverting tx stays debuggable). + const tracedHash = stepHash[config.trace]; + const tracedWasm = stepWasm[config.trace]; + if (tracedHash === undefined || tracedWasm === undefined) { + throw new Error(`internal error: traced step ${config.trace} produced no submitted transaction`); + } + report(`Fetching trace for transaction ${tracedHash} ...`); + const trace = await this.client.traceTransaction(tracedHash); + + const records = toTraceRecords(trace); + const model = new TraceModel(records); + const { source: sourceMapper, variables, disassembly, positions } = buildDebugArtifacts( + tracedWasm, + model, + report, + ); + + return { model, source: sourceMapper, variables, disassembly, positions }; + } + + /** Upload + create a contract; register its id and full wasm bytes. */ + private async runDeploy( + step: DeployStep, + txBuilder: SorobanTxBuilder, + source: Keypair, + nextSeq: () => number, + submit: (envelopeXdr: string) => Promise, + report: ProgressReporter, + ): Promise<{ contractId: string; createTxHash: string; wasm: Buffer }> { + const wasm = await this.loadWasm(step, report); + + // komet-node only executes the code; the DWARF custom sections just bloat + // the KORE config it re-parses per RPC call, so strip them for the upload. + // The code section stays byte-identical, keeping trace `pos` aligned with + // the full `wasm` used for debug artifacts. + const uploadWasm = stripDebugSections(wasm); + report(`Uploading wasm for "${step.id}" (${wasm.length} bytes, ${uploadWasm.length} stripped) ...`); + const upload = txBuilder.buildUploadWasm(source, Buffer.from(uploadWasm), nextSeq()); + await submit(upload.envelopeXdr); + + // A deterministic salt (derived from the handle id) keeps the created + // contract id reproducible across identical runs. + const salt = createHash('sha256').update(step.id).digest(); + const create = txBuilder.buildCreateContract(source, upload.wasmHash, salt, nextSeq()); + report(`Creating contract "${step.id}" -> ${create.contractId} ...`); + const created = await submit(create.envelopeXdr); + + return { contractId: create.contractId, createTxHash: created.hash, wasm }; + } + + /** Resolve the handle, encode args (with substitution), and invoke. */ + private async runInvoke( + step: InvokeStep, + txBuilder: SorobanTxBuilder, + source: Keypair, + sourceAddress: string, + contracts: Record, + wasms: Record, + specs: Record, + nextSeq: () => number, + submit: (envelopeXdr: string) => Promise, + report: ProgressReporter, + ): Promise { + const contractId = contracts[step.contract]; + if (contractId === undefined) { + throw new Error(`invoke references unresolved contract handle "${step.contract}"`); + } + + let spec = specs[step.contract]; + if (spec === undefined) { + spec = await loadContractSpec(wasms[step.contract]); + specs[step.contract] = spec; + } + + const substituted = substitute(step.args, { sourceAddress, contracts }); + const scvals = encodeInvokeArgs(spec, step.function, substituted); + report(`Invoking ${step.function} on "${step.contract}" ...`); + const envelope = txBuilder.buildInvoke(source, contractId, step.function, scvals, nextSeq()); + const sent = await submit(envelope); + return sent.hash; + } + + /** + * Fetch a submitted tx's final status. NEVER throws: a FAILED status is + * recorded, not raised, so the sequence always runs to completion. + */ + private async recordStatus(hash: string): Promise { + try { + const result = await this.client.getTransaction(hash); + return result.status; + } catch { + return 'UNKNOWN'; + } + } + + /** Load a deploy's wasm: a prebuilt `wasm` path, or build a `contract` dir. */ + private async loadWasm(step: DeployStep, report: ProgressReporter): Promise { + if (step.wasm) { + return fs.readFile(step.wasm); + } + const builder = new ContractBuilder(); + const wasmPath = await builder.build( + { + contractDir: step.contract!, + buildCommand: step.buildCommand, + debugInfo: step.debugInfo, + }, + report, + ); + return fs.readFile(wasmPath); + } +} diff --git a/src/pipeline/TurnkeyPipeline.ts b/src/pipeline/TurnkeyPipeline.ts index f52f812..665d31d 100644 --- a/src/pipeline/TurnkeyPipeline.ts +++ b/src/pipeline/TurnkeyPipeline.ts @@ -1,29 +1,28 @@ /** - * The turnkey pipeline: from contract source to a replayable trace, in one go. + * The turnkey pipeline: from a launch configuration to a replayable trace, in + * one go. * - * build wasm -> (spawn + health-check komet-node) -> seed account -> - * upload wasm -> create contract -> invoke-with-trace -> parse trace. + * (spawn + health-check komet-node) -> normalizeConfig -> SequenceRunner.run * - * In `attach` mode the build and spawn steps are skipped and the pipeline talks - * to an already-running node. + * Both the legacy single-invoke config (`function` + `args` + `wasmPath`/ + * `contract`) and the new `transactions` sequence config flow through this one + * path: `normalizeConfig` (M1) folds either shape into a canonical + * `{ steps, trace }`, and the `SequenceRunner` (M3) executes it against one + * accumulating komet-node ledger, never throwing on a FAILED tx and fetching + * the traced step's trace regardless of status. + * + * In `attach` mode the spawn step is skipped and the pipeline talks to an + * already-running node. * * Pure module (no `vscode` imports) so it can be driven against a mock node in * tests and against a real komet-node in integration. */ -import { randomBytes } from 'crypto'; -import { Keypair } from '@stellar/stellar-sdk'; import { KometClient } from '../komet/KometClient'; import { KometProcess } from '../komet/KometProcess'; -import { ContractBuilder } from '../build/ContractBuilder'; -import { SorobanTxBuilder } from '../soroban/SorobanTxBuilder'; -import { encodeArgs } from '../soroban/scval'; -import { toTraceRecords } from '../komet/trace'; -import { stripDebugSections } from '../wasm/sections'; -import { promises as fs } from 'fs'; -import { TraceModel } from '../debugAdapter/TraceModel'; -import { buildDebugArtifacts } from '../debugAdapter/artifacts'; import { ProgressReporter, ResolvedTrace, SorobanLaunchArgs } from '../debugAdapter/types'; +import { normalizeConfig, RawLaunchConfig } from './config'; +import { SequenceRunner } from './SequenceRunner'; const HEALTH_TIMEOUT_MS = 60_000; @@ -35,8 +34,9 @@ export class TurnkeyPipeline { const host = args.node?.host ?? 'localhost'; const port = args.node?.port ?? 8000; - // 1. Build (or locate) the contract wasm. - const wasm = await this.loadWasm(args, report); + // 1. Normalize the launch config (legacy OR new `transactions`) into a + // canonical `{ steps, trace }`. This also validates it before we spawn. + const normalized = normalizeConfig(args as RawLaunchConfig); // 2. Spawn komet-node unless attaching to a running one. if (!attach) { @@ -53,57 +53,10 @@ export class TurnkeyPipeline { report(`Waiting for komet-node at ${client.url} ...`); await client.waitForHealthy(HEALTH_TIMEOUT_MS); - const network = await client.getNetwork(); - const passphrase = network.passphrase; - const txBuilder = new SorobanTxBuilder(passphrase); - - // 3. Source account. - const source = args.sourceSecret ? Keypair.fromSecret(args.sourceSecret) : Keypair.random(); - report(`Source account: ${source.publicKey()}`); - report('Seeding source account (CreateAccount) ...'); - await client.sendTransaction(txBuilder.buildCreateAccount(source)); - - // 4. Upload wasm. komet-node only executes the code; the DWARF custom - // sections just bloat the KORE config it re-parses per RPC call, so strip - // them for the upload. The code section stays byte-identical, keeping trace - // `pos` aligned with the full `wasm` the adapter uses for debug artifacts. - const uploadWasm = stripDebugSections(wasm); - report(`Uploading contract wasm (${wasm.length} bytes, ${uploadWasm.length} after stripping debug sections) ...`); - const upload = txBuilder.buildUploadWasm(source, Buffer.from(uploadWasm)); - await client.sendTransaction(upload.envelopeXdr); - - // 5. Create contract. - const salt = randomBytes(32); - const create = txBuilder.buildCreateContract(source, upload.wasmHash, salt); - report(`Creating contract ${create.contractId} ...`); - await client.sendTransaction(create.envelopeXdr); - - // 6. Invoke, then fetch its trace by hash. The node submits the envelope - // (sendTransaction) and exposes the trace (a JSON array of per-instruction - // and Soroban VM-event records) separately via traceTransaction(hash); the - // final status comes from getTransaction(hash). - const scvalArgs = encodeArgs(args.args); - report(`Invoking ${args.function}(${(args.args ?? []).map((a) => JSON.stringify(a.value)).join(', ')}) with trace ...`); - const invokeXdr = txBuilder.buildInvoke(source, create.contractId, args.function, scvalArgs); - const sent = await client.sendTransaction(invokeXdr); - - const result = await client.getTransaction(sent.hash); - if (result.status === 'FAILED') { - throw new Error( - `Invocation of ${args.function}(...) failed on komet-node (status FAILED, tx ${sent.hash}).`, - ); - } - - const trace = await client.traceTransaction(sent.hash); - - // 7. Validate the trace records into the replay model; the uploaded wasm - // supplies the disassembly and (when built with debug info) the DWARF - // source mapping. - const records = toTraceRecords(trace); - const model = new TraceModel(records); - const { source: sourceMapper, variables, disassembly, positions } = buildDebugArtifacts(wasm, model, report); - - return { model, source: sourceMapper, variables, disassembly, positions }; + // 3. Execute the whole sequence and resolve the traced tx into a replayable + // trace. The runner seeds the source account, deploys, invokes, and fetches + // the traced step's trace regardless of status. + return new SequenceRunner(client).run(normalized, { sourceSecret: args.sourceSecret }, report); } async dispose(): Promise { @@ -112,18 +65,4 @@ export class TurnkeyPipeline { this.process = undefined; } } - - private async loadWasm(args: SorobanLaunchArgs, report: ProgressReporter): Promise { - const builder = new ContractBuilder(); - const wasmPath = await builder.build( - { - contractDir: args.contract ?? process.cwd(), - buildCommand: args.buildCommand, - wasmPath: args.wasmPath, - debugInfo: args.debugInfo, - }, - report, - ); - return fs.readFile(wasmPath); - } } diff --git a/src/pipeline/config.ts b/src/pipeline/config.ts new file mode 100644 index 0000000..e9ea47e --- /dev/null +++ b/src/pipeline/config.ts @@ -0,0 +1,270 @@ +/** + * Normalization of a `soroban` launch configuration into a canonical, + * ordered transaction sequence. + * + * The debugger accepts two config flavors: + * + * - the NEW schema: an explicit `transactions` array (deploy / invoke steps) + * plus a `trace` selector naming which submitted tx feeds the session; + * - the LEGACY single-invoke schema: a top-level `function` + `args` with a + * `wasmPath`/`contract` source, implicitly "deploy then invoke". + * + * `normalizeConfig` folds BOTH into one canonical `{ steps, trace }` shape: + * an ordered `TxStep[]` and a `trace` RESOLVED to a 0-based index into it. + * It validates handle references, deploy-id uniqueness and the trace selector, + * throwing on any inconsistency. + * + * This is a PURE function: no filesystem, no network, no mutation of its input. + * It performs NO wasm loading and NO arg encoding — invoke `args` are carried + * through untouched (later milestones encode them). + */ + +import { SorobanLaunchArgs } from '../debugAdapter/types'; + +/** A deploy step: upload + create, registering a handle `id -> contractId`. */ +export interface DeployStep { + kind: 'deploy'; + /** Handle id later invoke steps reference via their `contract` field. */ + id: string; + /** Path to a prebuilt `.wasm` (new-schema `wasm` / legacy `wasmPath`). */ + wasm?: string; + /** Path to a contract crate dir to build (new-schema / legacy `contract`). */ + contract?: string; + /** Build command for a `contract`-dir build (default `stellar contract build`). */ + buildCommand?: string; + /** Build with DWARF debug info (default true) for a `contract`-dir build. */ + debugInfo?: boolean; +} + +/** An invoke step: call `function` on the contract behind handle `contract`. */ +export interface InvokeStep { + kind: 'invoke'; + /** Handle id of a prior deploy step (NOT yet a live contractId). */ + contract: string; + /** Function name to invoke. */ + function: string; + /** + * OPTIONAL label for this invoke. A `trace` string selector may name it to + * pick this invoke's tx as the traced one (in addition to deploy ids). + */ + id?: string; + /** + * New schema: object keyed by spec param names. Legacy: `{type,value}[]`. + * Carried through verbatim at this milestone (no encoding yet). + */ + args?: unknown; +} + +export type TxStep = DeployStep | InvokeStep; + +/** + * A trace selector as authored: `"last"` (default), a 0-based integer index, + * or a transaction id string. `normalizeConfig` resolves it to a number. + */ +export type TraceSelector = 'last' | number | string; + +/** The canonical, validated result of normalizing a launch configuration. */ +export interface NormalizedConfig { + steps: TxStep[]; + /** The traced tx's 0-based position in `steps`. */ + trace: number; +} + +/** The default handle id used when desugaring legacy single-invoke config. */ +const DEFAULT_HANDLE = '__default'; + +/** + * Raw config as authored. Extends the legacy `SorobanLaunchArgs` with the new + * `transactions` + `trace` fields. Fully optional/loose: `normalizeConfig` + * validates it. + */ +export interface RawLaunchConfig extends Partial { + transactions?: unknown[]; + trace?: TraceSelector; +} + +/** + * Fold a raw launch config (new OR legacy schema) into the canonical + * `{ steps, trace }` shape. Throws on any structural or referential error. + */ +export function normalizeConfig(raw: RawLaunchConfig): NormalizedConfig { + const hasTransactions = Array.isArray(raw?.transactions); + const hasLegacyFunction = typeof raw?.function === 'string'; + + if (hasTransactions && hasLegacyFunction) { + throw new Error( + 'invalid config: use either the legacy `function` or the new `transactions`, not both', + ); + } + + const steps = hasTransactions + ? parseTransactions(raw.transactions as unknown[]) + : desugarLegacy(raw); + + validateHandles(steps); + + const trace = resolveTrace(raw.trace, steps); + + return { steps, trace }; +} + +/** Parse and validate the new-schema `transactions` array into `TxStep[]`. */ +function parseTransactions(transactions: unknown[]): TxStep[] { + if (transactions.length === 0) { + throw new Error('invalid config: `transactions` must not be empty'); + } + + return transactions.map((tx, i) => parseStep(tx, i)); +} + +/** Parse one raw transaction entry into a canonical `TxStep`. */ +function parseStep(tx: unknown, index: number): TxStep { + if (typeof tx !== 'object' || tx === null) { + throw new Error(`invalid config: transaction at index ${index} is not an object`); + } + const t = tx as Record; + + if (t.kind === 'deploy') { + if (typeof t.id !== 'string' || t.id.length === 0) { + throw new Error(`invalid config: deploy at index ${index} is missing a string \`id\``); + } + const step: DeployStep = { kind: 'deploy', id: t.id }; + if (typeof t.wasm === 'string') { + step.wasm = t.wasm; + } + if (typeof t.contract === 'string') { + step.contract = t.contract; + } + if (typeof t.buildCommand === 'string') { + step.buildCommand = t.buildCommand; + } + if (typeof t.debugInfo === 'boolean') { + step.debugInfo = t.debugInfo; + } + if (step.wasm === undefined && step.contract === undefined) { + throw new Error( + `invalid config: deploy "${t.id}" needs a \`wasm\` path or a \`contract\` dir`, + ); + } + return step; + } + + if (t.kind === 'invoke') { + if (typeof t.contract !== 'string' || t.contract.length === 0) { + throw new Error( + `invalid config: invoke at index ${index} is missing a string \`contract\` handle`, + ); + } + if (typeof t.function !== 'string' || t.function.length === 0) { + throw new Error( + `invalid config: invoke at index ${index} is missing a string \`function\``, + ); + } + const step: InvokeStep = { + kind: 'invoke', + contract: t.contract, + function: t.function, + }; + if (typeof t.id === 'string' && t.id.length > 0) { + step.id = t.id; + } + if ('args' in t) { + step.args = t.args; + } + return step; + } + + throw new Error( + `invalid config: transaction at index ${index} has unknown kind ${JSON.stringify(t.kind)}`, + ); +} + +/** + * Desugar the legacy single-invoke schema into a canonical two-step sequence: + * `[deploy __default, invoke __default]`. Legacy `args` (a `{type,value}[]`) + * are carried through untouched. + */ +function desugarLegacy(raw: RawLaunchConfig): TxStep[] { + if (typeof raw?.function !== 'string' || raw.function.length === 0) { + throw new Error( + 'invalid config: provide either a legacy `function` (+ `wasmPath`/`contract`) or a `transactions` array', + ); + } + + const deploy: DeployStep = { kind: 'deploy', id: DEFAULT_HANDLE }; + if (typeof raw.wasmPath === 'string') { + deploy.wasm = raw.wasmPath; + } + if (typeof raw.contract === 'string') { + deploy.contract = raw.contract; + } + if (typeof raw.buildCommand === 'string') { + deploy.buildCommand = raw.buildCommand; + } + if (typeof raw.debugInfo === 'boolean') { + deploy.debugInfo = raw.debugInfo; + } + if (deploy.wasm === undefined && deploy.contract === undefined) { + throw new Error('invalid config: legacy config needs a `wasmPath` or a `contract` dir'); + } + + const invoke: InvokeStep = { + kind: 'invoke', + contract: DEFAULT_HANDLE, + function: raw.function, + }; + if ('args' in raw) { + invoke.args = raw.args; + } + + return [deploy, invoke]; +} + +/** + * Validate that deploy ids are unique and every invoke references a deploy id + * declared BEFORE it in the sequence. + */ +function validateHandles(steps: TxStep[]): void { + const known = new Set(); + for (const step of steps) { + if (step.kind === 'deploy') { + if (known.has(step.id)) { + throw new Error(`invalid config: duplicate deploy id "${step.id}"`); + } + known.add(step.id); + } else { + if (!known.has(step.contract)) { + throw new Error( + `invalid config: invoke references unknown deploy id "${step.contract}"`, + ); + } + } + } +} + +/** + * Resolve a trace selector to a 0-based index into `steps`. Undefined and + * `"last"` map to the final step; an integer must be in range; a string + * (other than `"last"`) must match a step id — a deploy id or an invoke's + * optional id. + */ +function resolveTrace(selector: TraceSelector | undefined, steps: TxStep[]): number { + if (selector === undefined || selector === 'last') { + return steps.length - 1; + } + + if (typeof selector === 'number') { + if (!Number.isInteger(selector) || selector < 0 || selector >= steps.length) { + throw new Error( + `invalid config: trace index ${selector} is out of range (0..${steps.length - 1})`, + ); + } + return selector; + } + + const idx = steps.findIndex((s) => s.id === selector); + if (idx === -1) { + throw new Error(`invalid config: trace selector "${selector}" names no transaction`); + } + return idx; +} diff --git a/src/soroban/SorobanTxBuilder.ts b/src/soroban/SorobanTxBuilder.ts index f13b839..a16f3f4 100644 --- a/src/soroban/SorobanTxBuilder.ts +++ b/src/soroban/SorobanTxBuilder.ts @@ -40,9 +40,17 @@ export interface CreateContractResult { export class SorobanTxBuilder { constructor(private readonly networkPassphrase: string) {} - /** A fresh TransactionBuilder; sequence is irrelevant to komet-node. */ - private newBuilder(source: Keypair): TransactionBuilder { - const account = new Account(source.publicKey(), '0'); + /** + * A fresh TransactionBuilder. komet-node ignores sequence numbers for + * EXECUTION, but the sequence is part of the signed envelope, so two + * otherwise-identical transactions built with the SAME sequence hash to the + * same bytes and komet-node dedups the second (returns the first's cached + * receipt without re-executing). The sequence runner therefore assigns a + * DISTINCT, incrementing sequence per submitted tx so every envelope hashes + * uniquely. The default of `'0'` preserves the historical single-tx behavior. + */ + private newBuilder(source: Keypair, sequence: string | number = '0'): TransactionBuilder { + const account = new Account(source.publicKey(), String(sequence)); return new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: this.networkPassphrase, @@ -64,8 +72,8 @@ export class SorobanTxBuilder { * CreateAccount must already exist. If so, a pre-seeded state fixture is * needed instead. */ - buildCreateAccount(account: Keypair, startingBalance = '100000000'): string { - const builder = this.newBuilder(account).addOperation( + buildCreateAccount(account: Keypair, startingBalance = '100000000', sequence: string | number = '0'): string { + const builder = this.newBuilder(account, sequence).addOperation( Operation.createAccount({ destination: account.publicKey(), startingBalance, @@ -75,16 +83,16 @@ export class SorobanTxBuilder { } /** Upload contract wasm; returns the envelope and the wasm hash. */ - buildUploadWasm(source: Keypair, wasm: Buffer): UploadResult { - const builder = this.newBuilder(source).addOperation( + buildUploadWasm(source: Keypair, wasm: Buffer, sequence: string | number = '0'): UploadResult { + const builder = this.newBuilder(source, sequence).addOperation( Operation.uploadContractWasm({ wasm }), ); return { envelopeXdr: this.finish(builder, source), wasmHash: hash(wasm) }; } /** Create a contract instance from an uploaded wasm hash. */ - buildCreateContract(source: Keypair, wasmHash: Buffer, salt: Buffer): CreateContractResult { - const builder = this.newBuilder(source).addOperation( + buildCreateContract(source: Keypair, wasmHash: Buffer, salt: Buffer, sequence: string | number = '0'): CreateContractResult { + const builder = this.newBuilder(source, sequence).addOperation( Operation.createCustomContract({ address: addressFromPublicKey(source.publicKey()), wasmHash, @@ -96,9 +104,9 @@ export class SorobanTxBuilder { } /** Invoke a contract function with the given ScVal arguments. */ - buildInvoke(source: Keypair, contractId: string, fn: string, args: xdr.ScVal[]): string { + buildInvoke(source: Keypair, contractId: string, fn: string, args: xdr.ScVal[], sequence: string | number = '0'): string { const contract = new Contract(contractId); - const builder = this.newBuilder(source).addOperation(contract.call(fn, ...args)); + const builder = this.newBuilder(source, sequence).addOperation(contract.call(fn, ...args)); return this.finish(builder, source); } } diff --git a/src/soroban/specEncode.ts b/src/soroban/specEncode.ts new file mode 100644 index 0000000..e5448ad --- /dev/null +++ b/src/soroban/specEncode.ts @@ -0,0 +1,135 @@ +/** + * Spec-driven Soroban argument encoding + `${...}` substitution. + * + * A contract's wasm carries a `contractspecv0` custom section describing its + * function signatures and composite types (structs / enums / unions / tuples / + * vecs / maps). `@stellar/stellar-sdk`'s `contract.Client.fromWasm` parses that + * section OFFLINE — no RPC call is made despite its rpc-shaped signature — and + * exposes a `contract.Spec`. `spec.funcArgsToScVals(fn, argsByName)` then + * encodes named args using the contract's own type defs, so composite inputs no + * longer need the hand-rolled `{type,value}` encoder in `./scval`. + * + * This module provides: + * - `loadContractSpec(wasm)` — resolve the `Spec` from a wasm buffer, offline. + * - `encodeNamedArgs(spec, fn, namedArgs)` — spec-driven encoding by param name. + * - `encodeInvokeArgs(spec, fn, args)` — dispatch: a legacy `{type,value}[]` + * array routes to `./scval`'s `encodeArgs`; an object routes to the + * spec-driven path. + * - `substitute(value, ctx)` — pure recursive `${sourceAddress}` / + * `${contract:}` replacement inside string values. + * + * Pure aside from the in-memory wasm parse: no filesystem, no network. + */ + +import { contract, Networks, xdr } from '@stellar/stellar-sdk'; +import { encodeArgs, ScValArg } from './scval'; + +export type Spec = contract.Spec; + +/** + * A syntactically-valid placeholder contract id. `Client.fromWasm` requires a + * well-formed id, but the parsed spec is independent of it — no live contract + * is consulted. + */ +const PLACEHOLDER_CONTRACT_ID = 'CA24HSVRERTJMFUDSZXKFK2HMO5CBBK6U5KA6PLLL6BGSQRO44FYZFRE'; + +/** + * Resolve a contract `Spec` from its wasm buffer, fully OFFLINE. The rpc url is + * required by the signature but never contacted; the spec comes solely from the + * wasm's `contractspecv0` custom section. + */ +export async function loadContractSpec(wasm: Buffer): Promise { + const client = await contract.Client.fromWasm(wasm, { + rpcUrl: 'http://localhost', + networkPassphrase: Networks.STANDALONE, + contractId: PLACEHOLDER_CONTRACT_ID, + }); + return client.spec; +} + +/** + * Encode named args for `fn` using the contract's own type defs. Args are keyed + * by the spec's EXACT parameter names; a wrong name or an unknown function + * throws (the SDK rejects both). + */ +export function encodeNamedArgs( + spec: Spec, + fn: string, + namedArgs: Record, +): xdr.ScVal[] { + return spec.funcArgsToScVals(fn, namedArgs); +} + +/** + * Dispatch invoke args to the right encoder: + * - an ARRAY is the legacy `{type,value}[]` form → `./scval`'s `encodeArgs`; + * - an OBJECT is spec-driven named args → `encodeNamedArgs`. + */ +export function encodeInvokeArgs( + spec: Spec, + fn: string, + args: unknown, +): xdr.ScVal[] { + if (Array.isArray(args)) { + return encodeArgs(args as ScValArg[]); + } + if (args !== null && typeof args === 'object') { + return encodeNamedArgs(spec, fn, args as Record); + } + if (args === undefined) { + return []; + } + throw new Error( + `invalid invoke args: expected a { param: value } object or a legacy {type,value}[] array, got ${typeof args}`, + ); +} + +/** Matches a `${...}` substitution token. */ +const TOKEN = /\$\{([^}]*)\}/g; + +/** + * Recursively replace `${sourceAddress}` and `${contract:}` tokens inside + * string values. Non-string primitives pass through untouched; arrays and + * objects are walked. THROWS on an unknown `${...}` token or an unknown + * contract handle. Pure — no IO, no mutation of the input. + */ +export function substitute( + value: unknown, + ctx: { sourceAddress: string; contracts: Record }, +): unknown { + if (typeof value === 'string') { + return substituteString(value, ctx); + } + if (Array.isArray(value)) { + return value.map((v) => substitute(v, ctx)); + } + if (value !== null && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + out[k] = substitute(v, ctx); + } + return out; + } + return value; +} + +function substituteString( + s: string, + ctx: { sourceAddress: string; contracts: Record }, +): string { + return s.replace(TOKEN, (_match, token: string) => { + if (token === 'sourceAddress') { + return ctx.sourceAddress; + } + const contractPrefix = 'contract:'; + if (token.startsWith(contractPrefix)) { + const id = token.slice(contractPrefix.length); + const resolved = ctx.contracts[id]; + if (resolved === undefined) { + throw new Error(`unknown contract handle in substitution: "${id}"`); + } + return resolved; + } + throw new Error(`unknown substitution token: "\${${token}}"`); + }); +} diff --git a/test/fixtures/composite-contract/Cargo.lock b/test/fixtures/composite-contract/Cargo.lock new file mode 100644 index 0000000..e8d8d7b --- /dev/null +++ b/test/fixtures/composite-contract/Cargo.lock @@ -0,0 +1,1774 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "ark-bls12-381" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c775f0d12169cba7aae4caeb547bb6a50781c7449a8aa53793827c9ec4abf488" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest 0.10.7", + "itertools", + "num-bigint", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes-lit" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0adabf37211a5276e46335feabcbb1530c95eb3fdf85f324c7db942770aa025d" +dependencies = [ + "num-bigint", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "composite" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crate-git-revision" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "escape-bytes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" + +[[package]] +name = "ethnum" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indexmap-nostd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2 0.10.9", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "soroban-builtin-sdk-macros" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2e42bf80fcdefb3aae6ff3c7101a62cf942e95320ed5b518a1705bc11c6b2f" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "soroban-env-common" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027cd856171bfd6ad2c0ffb3b7dfe55ad7080fb3050c36ad20970f80da634472" +dependencies = [ + "crate-git-revision", + "ethnum", + "num-derive", + "num-traits", + "serde", + "soroban-env-macros", + "soroban-wasmi", + "static_assertions", + "stellar-xdr", + "wasmparser", +] + +[[package]] +name = "soroban-env-guest" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a07dda1ae5220d975979b19ad4fd56bc86ec7ec1b4b25bc1c5d403f934e592e" +dependencies = [ + "soroban-env-common", + "static_assertions", +] + +[[package]] +name = "soroban-env-host" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e8b03a4191d485eab03f066336112b2a50541a7553179553dc838b986b94dd" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-serialize", + "curve25519-dalek", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "generic-array", + "getrandom", + "hex-literal", + "hmac", + "k256", + "num-derive", + "num-integer", + "num-traits", + "p256", + "rand", + "rand_chacha", + "sec1", + "sha2 0.10.9", + "sha3", + "soroban-builtin-sdk-macros", + "soroban-env-common", + "soroban-wasmi", + "static_assertions", + "stellar-strkey", + "wasmparser", +] + +[[package]] +name = "soroban-env-macros" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00eff744764ade3bc480e4909e3a581a240091f3d262acdce80b41f7069b2bd9" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "serde", + "serde_json", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-ledger-snapshot" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30035cf1e8f02f65de3e594b6da113ecdaf1cd134d8480961d62568bb15adaf" +dependencies = [ + "serde", + "serde_json", + "serde_with", + "soroban-env-common", + "soroban-env-host", + "thiserror", +] + +[[package]] +name = "soroban-sdk" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff18e8d7ca6d5340a211605ca2c86383bd4dfacc4f8253d72a1573974ffffe69" +dependencies = [ + "bytes-lit", + "rand", + "rustc_version", + "serde", + "serde_json", + "soroban-env-guest", + "soroban-env-host", + "soroban-ledger-snapshot", + "soroban-sdk-macros", + "stellar-strkey", +] + +[[package]] +name = "soroban-sdk-macros" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b205cd86b34d530db87667bd287fbb194166d79b368227fd842110a914fde8" +dependencies = [ + "crate-git-revision", + "darling 0.20.11", + "itertools", + "proc-macro2", + "quote", + "rustc_version", + "sha2 0.10.9", + "soroban-env-common", + "soroban-spec", + "soroban-spec-rust", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-spec" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6a16f2de28852c759f4da5f28cda54ec0d8dfa4c0e6e8cb3495234a72b0cea" +dependencies = [ + "base64 0.13.1", + "stellar-xdr", + "thiserror", + "wasmparser", +] + +[[package]] +name = "soroban-spec-rust" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6db5902ab21290dddf63fec4ee95703fe59891a947646e7b8607536f043fc" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "sha2 0.10.9", + "soroban-spec", + "stellar-xdr", + "syn 2.0.119", + "thiserror", +] + +[[package]] +name = "soroban-wasmi" +version = "0.31.1-soroban.20.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710403de32d0e0c35375518cb995d4fc056d0d48966f2e56ea471b8cb8fc9719" +dependencies = [ + "smallvec", + "spin", + "wasmi_arena", + "wasmi_core", + "wasmparser-nostd", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stellar-strkey" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e3aa3ed00e70082cb43febc1c2afa5056b9bb3e348bbb43d0cd0aa88a611144" +dependencies = [ + "crate-git-revision", + "data-encoding", + "thiserror", +] + +[[package]] +name = "stellar-xdr" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce69db907e64d1e70a3dce8d4824655d154749426a6132b25395c49136013e4" +dependencies = [ + "base64 0.13.1", + "crate-git-revision", + "escape-bytes", + "hex", + "serde", + "serde_with", + "stellar-strkey", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmi_arena" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "104a7f73be44570cac297b3035d76b169d6599637631cf37a1703326a0727073" + +[[package]] +name = "wasmi_core" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a" +dependencies = [ + "downcast-rs", + "libm", + "num-traits", + "paste", +] + +[[package]] +name = "wasmparser" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" +dependencies = [ + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wasmparser-nostd" +version = "0.100.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" +dependencies = [ + "indexmap-nostd", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/test/fixtures/composite-contract/Cargo.toml b/test/fixtures/composite-contract/Cargo.toml new file mode 100644 index 0000000..45e5da4 --- /dev/null +++ b/test/fixtures/composite-contract/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "composite" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "22.0.0" + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/test/fixtures/composite-contract/src/lib.rs b/test/fixtures/composite-contract/src/lib.rs new file mode 100644 index 0000000..615bdd5 --- /dev/null +++ b/test/fixtures/composite-contract/src/lib.rs @@ -0,0 +1,27 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Vec}; + +/// Mirrors the real `HubAssetKey`: a user-defined enum with a unit variant, an +/// Address-carrying variant, and an integer-carrying variant. Exercises union +/// encoding via the contract spec. +#[contracttype] +#[derive(Clone)] +pub enum AssetKey { + Native, + Stellar(Address), + Other(u32), +} + +/// Fixture for spec-driven composite-arg encoding (blocker #4). `supply` takes +/// `Vec<(AssetKey, i128)>` — a vector of tuples of a user-defined enum and a +/// 128-bit int, mirroring the real `supply(requests: Vec<(HubAssetKey, i128)>)`. +#[contract] +pub struct Composite; + +#[contractimpl] +impl Composite { + pub fn supply(env: Env, requests: Vec<(AssetKey, i128)>) -> u32 { + let _ = env; + requests.len() + } +} diff --git a/test/fixtures/composite.wasm b/test/fixtures/composite.wasm new file mode 100755 index 0000000000000000000000000000000000000000..cd3aac419c8dfbf73b8fcceebfdd0df8c09e0789 GIT binary patch literal 648 zcmZ8eO>fjN5FI<+Y`YPtRVBC~OK?G=Q9eq;a$;9Ps)PU`egNwxlh$J6EVdKaOIfM> zCeGZs@H>$D8;YIcsxZ>{&7xS%e z5rqh@=-slFM)A_<6_$Ns9r*B~?(Uqq*jD(dLv6G*_1os9#VEk|m*Zwyqj`mEdyZ_n zo-Lb}hwrb2QmxX=oFP-lmuzv|o%n{ubH-VxQ5{rR3yBp&*){C5FlUaC!myqz!KHb^ zxSY(YN}<*VC0A-K&jK=Q`E|*+6&|&+3cXuDXwF4L!Kc5GC{*x&C1`WS?gL|$4 z+M5UZha4&0d!aCmr$w9&+Z`C(nuPjilWib-818RsX5`4OK! PD>5vmH9soSk|)_8{UDf4 literal 0 HcmV?d00001 diff --git a/test/fixtures/ctor-probe-contract/Cargo.toml b/test/fixtures/ctor-probe-contract/Cargo.toml new file mode 100644 index 0000000..630da39 --- /dev/null +++ b/test/fixtures/ctor-probe-contract/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ctor_probe" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "22.0.0" + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/test/fixtures/ctor-probe-contract/src/lib.rs b/test/fixtures/ctor-probe-contract/src/lib.rs new file mode 100644 index 0000000..de8fb02 --- /dev/null +++ b/test/fixtures/ctor-probe-contract/src/lib.rs @@ -0,0 +1,25 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol}; + +const ADMIN: Symbol = symbol_short!("ADMIN"); + +/// Probe: does komet execute `__constructor` as an ordinary call, and do its +/// storage writes persist to a later transaction? +/// +/// `admin_set(nonce)` returns whether ADMIN exists — it never traps, and the +/// `nonce` arg lets two calls produce DIFFERENT tx hashes so komet-node does not +/// dedup the second as a duplicate. Expect false before __constructor, true +/// after: a clean cross-transaction persistence proof. +#[contract] +pub struct CtorProbe; + +#[contractimpl] +impl CtorProbe { + pub fn __constructor(env: Env, admin: Address) { + env.storage().instance().set(&ADMIN, &admin); + } + + pub fn admin_set(env: Env, _nonce: u32) -> bool { + env.storage().instance().has(&ADMIN) + } +} diff --git a/test/fixtures/ctor_probe.wasm b/test/fixtures/ctor_probe.wasm new file mode 100755 index 0000000000000000000000000000000000000000..768fbfbf3e0b2d6987b579c1d8cb81aa399796b0 GIT binary patch literal 482 zcmZ8dyH3L}6usAR0wpRcLSpEEkSZ32D38+64e|%w*iw@^5~4|v9V)S;0d^*SgM}|Z zV&+fy2XGv<6z*_*AII0{7?{iu0I+XPjjF1!ni7qmGHgl!(j_>@Sdr%F0vaYIhCxjO zp=Wh)i5GSK4))dwwKAR+;+{Y^j*}vnrMOGVLV(d>v+O#LB`<-hMdr>W?LhSW_VV?C z_A!w2BZ$87Df)y-7r^d4)^!c4M|vu$p&t=@@XMkbBt>~>C=pIxZ2~qdch$?KU`Z)& zd9rYow^eMY(5=RI6IPrTdBSy073%-cRSW;z=;rFKshzdFeSIA6tpxCVk@1qPH5}-9 zu@?AeXbQRDqFrQp&Ny_uwote&q@3NfW7qc`*YUhTFigYrG+;dNxSP&gpRuc9Fk<7g P(C6VeWuwqbnCt%l^><_N literal 0 HcmV?d00001 diff --git a/test/multitxConfig.test.ts b/test/multitxConfig.test.ts new file mode 100644 index 0000000..8913d1f --- /dev/null +++ b/test/multitxConfig.test.ts @@ -0,0 +1,450 @@ +/** + * M1 acceptance tests for the multi-transaction debug-config normalizer. + * + * Pins the "M1 acceptance" section of the spec: a PURE function + * `normalizeConfig(raw)` (implementer's home: src/pipeline/config.ts) that turns + * BOTH the new `transactions` + `trace` schema AND the legacy single-invoke + * config into one canonical `{ steps, trace }` shape, with the listed validation + * rejections. + * + * This module is intentionally IO-free (no network, no filesystem) so the suite + * is deterministic. It imports from the not-yet-existing implementer module on + * purpose (TDD red phase). + */ + +import * as assert from 'assert'; +import { normalizeConfig } from '../src/pipeline/config'; + +// --- Canonical shape this milestone pins ----------------------------------- +// The normalizer's output is an ordered `steps` array plus a `trace` selector +// RESOLVED to a 0-based position (index into `steps`) per spec point 3 +// ("Resolves trace:'last' to the last step's position"). + +interface DeployStep { + kind: 'deploy'; + id: string; + /** New-schema `wasm` path, or the legacy `wasmPath`, in canonical `wasm`. */ + wasm?: string; + /** Contract crate dir (legacy `contract` / new-schema `contract`). */ + contract?: string; + /** OPTIONAL (M4): build command threaded into a `contract`-dir build. */ + buildCommand?: string; + /** OPTIONAL (M4): inject DWARF debug info when building from a crate dir. */ + debugInfo?: boolean; +} + +interface InvokeStep { + kind: 'invoke'; + /** Handle id referencing a prior deploy step (NOT yet a live contractId). */ + contract: string; + function: string; + /** New: object keyed by spec param names. Legacy: `{type,value}[]`. Passed through untouched at M1. */ + args?: unknown; + /** OPTIONAL (M3 extension): a trace selector may match this invoke id. */ + id?: string; +} + +type TxStep = DeployStep | InvokeStep; + +interface Normalized { + steps: TxStep[]; + trace: number; +} + +/** Robust boundary cast: the implementer owns the concrete types. */ +function norm(raw: unknown): Normalized { + return normalizeConfig(raw as never) as unknown as Normalized; +} + +describe('M1 normalizeConfig', () => { + // ------------------------------------------------------------------------ + // 1. New `transactions` + `trace` schema → canonical steps, in order. + // ------------------------------------------------------------------------ + describe('new transactions schema (happy path)', () => { + const raw = { + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'pool', wasm: '/abs/pool.wasm' }, + { + kind: 'invoke', + contract: 'pool', + function: '__constructor', + args: { admin: '${sourceAddress}' }, + }, + { + kind: 'invoke', + contract: 'pool', + function: 'supply', + args: { requests: [[{ tag: 'Native' }, '1000']] }, + }, + ], + trace: 'last', + }; + + it('produces one canonical step per transaction, in submission order', () => { + const { steps } = norm(raw); + assert.strictEqual(steps.length, 3); + assert.deepStrictEqual( + steps.map((s) => s.kind), + ['deploy', 'invoke', 'invoke'], + ); + }); + + it('preserves the deploy handle id and its wasm source', () => { + const deploy = norm(raw).steps[0] as DeployStep; + assert.strictEqual(deploy.kind, 'deploy'); + assert.strictEqual(deploy.id, 'pool'); + assert.strictEqual(deploy.wasm, '/abs/pool.wasm'); + }); + + it('keeps invoke function, handle ref and named args verbatim (no encoding at M1)', () => { + const invoke = norm(raw).steps[2] as InvokeStep; + assert.strictEqual(invoke.kind, 'invoke'); + assert.strictEqual(invoke.contract, 'pool'); + assert.strictEqual(invoke.function, 'supply'); + assert.deepStrictEqual(invoke.args, { requests: [[{ tag: 'Native' }, '1000']] }); + }); + + it('resolves the invoke handle references against earlier deploys without error', () => { + // A well-formed sequence (both invokes reference the `pool` deploy) must + // normalize cleanly. + assert.doesNotThrow(() => norm(raw)); + }); + }); + + // ------------------------------------------------------------------------ + // 2. Legacy single-invoke config → SAME canonical shape (desugaring). + // ------------------------------------------------------------------------ + describe('legacy single-invoke desugaring', () => { + const legacyWasm = { + type: 'soroban', + request: 'launch', + function: 'add', + args: [ + { value: 5, type: 'u32' }, + { value: 6, type: 'u32' }, + ], + wasmPath: '/abs/x.wasm', + }; + + it('desugars to [deploy __default, invoke on __default]', () => { + const { steps } = norm(legacyWasm); + assert.strictEqual(steps.length, 2); + + const deploy = steps[0] as DeployStep; + assert.strictEqual(deploy.kind, 'deploy'); + assert.strictEqual(deploy.id, '__default'); + assert.strictEqual(deploy.wasm, '/abs/x.wasm'); + + const invoke = steps[1] as InvokeStep; + assert.strictEqual(invoke.kind, 'invoke'); + assert.strictEqual(invoke.contract, '__default'); + assert.strictEqual(invoke.function, 'add'); + }); + + it('carries the legacy {type,value}[] args through untouched', () => { + const invoke = norm(legacyWasm).steps[1] as InvokeStep; + assert.deepStrictEqual(invoke.args, [ + { value: 5, type: 'u32' }, + { value: 6, type: 'u32' }, + ]); + }); + + it('desugars a legacy `contract` crate dir into the deploy step', () => { + const legacyDir = { + type: 'soroban', + request: 'launch', + function: 'increment', + args: [{ value: 1, type: 'u32' }], + contract: '/abs/crate', + }; + const deploy = norm(legacyDir).steps[0] as DeployStep; + assert.strictEqual(deploy.kind, 'deploy'); + assert.strictEqual(deploy.id, '__default'); + assert.strictEqual(deploy.contract, '/abs/crate'); + }); + + it('defaults the legacy trace to the last step ("last")', () => { + // No `trace` key → default "last" → the invoke at index 1. + assert.strictEqual(norm(legacyWasm).trace, 1); + }); + }); + + // ------------------------------------------------------------------------ + // 3. Trace selector resolution: "last" | index | id → 0-based position. + // ------------------------------------------------------------------------ + describe('trace selector resolution', () => { + const threeSteps = { + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'a', wasm: '/a.wasm' }, + { kind: 'deploy', id: 'b', wasm: '/b.wasm' }, + { kind: 'invoke', contract: 'a', function: 'run' }, + ], + }; + + it('resolves "last" to the last step position', () => { + const { steps, trace } = norm({ ...threeSteps, trace: 'last' }); + assert.strictEqual(typeof trace, 'number'); + assert.strictEqual(trace, steps.length - 1); + assert.strictEqual(trace, 2); + }); + + it('defaults to "last" when `trace` is omitted', () => { + assert.strictEqual(norm(threeSteps).trace, 2); + }); + + it('accepts a 0-based integer index selector', () => { + assert.strictEqual(norm({ ...threeSteps, trace: 0 }).trace, 0); + assert.strictEqual(norm({ ...threeSteps, trace: 1 }).trace, 1); + }); + + it('accepts a tx id string selector, resolving it to that step position', () => { + assert.strictEqual(norm({ ...threeSteps, trace: 'a' }).trace, 0); + assert.strictEqual(norm({ ...threeSteps, trace: 'b' }).trace, 1); + }); + }); + + // ------------------------------------------------------------------------ + // 4. Validation rejections (spec point 4). + // ------------------------------------------------------------------------ + describe('validation rejections', () => { + it('rejects an empty `transactions` array', () => { + assert.throws(() => + norm({ type: 'soroban', request: 'launch', transactions: [] }), + ); + }); + + it('rejects an invoke referencing an unknown deploy id', () => { + assert.throws( + () => + norm({ + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'pool', wasm: '/p.wasm' }, + { kind: 'invoke', contract: 'ghost', function: 'run' }, + ], + }), + /ghost/, + ); + }); + + it('rejects a duplicate deploy id', () => { + assert.throws( + () => + norm({ + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'dup', wasm: '/a.wasm' }, + { kind: 'deploy', id: 'dup', wasm: '/b.wasm' }, + ], + }), + /dup/, + ); + }); + + it('rejects a trace index that is out of range', () => { + assert.throws(() => + norm({ + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'a', wasm: '/a.wasm' }, + { kind: 'invoke', contract: 'a', function: 'run' }, + ], + trace: 99, + }), + ); + }); + + it('rejects a trace id that names no transaction', () => { + assert.throws( + () => + norm({ + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'a', wasm: '/a.wasm' }, + { kind: 'invoke', contract: 'a', function: 'run' }, + ], + trace: 'nope', + }), + /nope/, + ); + }); + + it('rejects a config carrying BOTH legacy `function` and `transactions`', () => { + assert.throws(() => + norm({ + type: 'soroban', + request: 'launch', + function: 'add', + args: [{ value: 1, type: 'u32' }], + transactions: [{ kind: 'deploy', id: 'a', wasm: '/a.wasm' }], + }), + ); + }); + }); + + // ------------------------------------------------------------------------ + // 5. Purity: same input in, same output out; no shared mutable state. + // ------------------------------------------------------------------------ + describe('purity', () => { + it('is referentially transparent for the same input', () => { + const raw = { + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'pool', wasm: '/p.wasm' }, + { kind: 'invoke', contract: 'pool', function: 'run', args: { x: '1' } }, + ], + }; + assert.deepStrictEqual(norm(raw), norm(raw)); + }); + + it('does not mutate the input config', () => { + const raw = { + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'pool', wasm: '/p.wasm' }, + { kind: 'invoke', contract: 'pool', function: 'run' }, + ], + trace: 'last', + }; + const snapshot = JSON.parse(JSON.stringify(raw)); + norm(raw); + assert.deepStrictEqual(raw, snapshot); + }); + }); + + // ------------------------------------------------------------------------ + // 6. (M3 extension) Optional invoke `id` + trace-by-invoke-id. + // An invoke MAY carry an `id`; the `trace` string selector then matches an + // invoke id, not just a deploy id. All existing selector behaviour stays. + // ------------------------------------------------------------------------ + describe('optional invoke id + trace-by-invoke-id (M3 extension)', () => { + const raw = { + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'pool', wasm: '/p.wasm' }, + { + kind: 'invoke', + id: 'ctor', + contract: 'pool', + function: '__constructor', + args: { admin: '${sourceAddress}' }, + }, + { kind: 'invoke', id: 'go', contract: 'pool', function: 'supply' }, + ], + }; + + it('preserves an optional invoke `id` on the canonical step', () => { + const invoke = norm(raw).steps[1] as InvokeStep; + assert.strictEqual(invoke.kind, 'invoke'); + assert.strictEqual(invoke.id, 'ctor'); + }); + + it('leaves an invoke without an `id` as undefined (id is optional)', () => { + const bare = norm({ + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'pool', wasm: '/p.wasm' }, + { kind: 'invoke', contract: 'pool', function: 'run' }, + ], + }); + const invoke = bare.steps[1] as InvokeStep; + assert.strictEqual(invoke.kind, 'invoke'); + assert.strictEqual(invoke.id, undefined); + }); + + it('resolves a `trace` string that matches an invoke id to that step position', () => { + assert.strictEqual(norm({ ...raw, trace: 'ctor' }).trace, 1); + assert.strictEqual(norm({ ...raw, trace: 'go' }).trace, 2); + }); + + it('still resolves a `trace` string that matches a deploy id (unchanged)', () => { + assert.strictEqual(norm({ ...raw, trace: 'pool' }).trace, 0); + }); + + it('rejects a `trace` id matching neither a deploy nor an invoke id', () => { + assert.throws(() => norm({ ...raw, trace: 'ghost' }), /ghost/); + }); + }); + + // ------------------------------------------------------------------------ + // 7. (M4 extension) DeployStep gains optional build controls, and legacy + // desugaring populates them from the raw config so a `contract`-dir build + // keeps its build command + debug-info injection (previously dropped). + // ------------------------------------------------------------------------ + describe('deploy build options: buildCommand + debugInfo (M4 extension)', () => { + it('desugars legacy `buildCommand` + `debugInfo` onto the __default deploy step', () => { + const deploy = norm({ + type: 'soroban', + request: 'launch', + function: 'increment', + args: [{ value: 1, type: 'u32' }], + contract: '/abs/crate', + buildCommand: 'stellar contract build', + debugInfo: true, + }).steps[0] as DeployStep; + + assert.strictEqual(deploy.kind, 'deploy'); + assert.strictEqual(deploy.contract, '/abs/crate'); + assert.strictEqual(deploy.buildCommand, 'stellar contract build'); + assert.strictEqual(deploy.debugInfo, true); + }); + + it('carries a legacy `debugInfo: false` through verbatim (not conflated with unset)', () => { + const deploy = norm({ + type: 'soroban', + request: 'launch', + function: 'increment', + args: [{ value: 1, type: 'u32' }], + contract: '/abs/crate', + debugInfo: false, + }).steps[0] as DeployStep; + + assert.strictEqual(deploy.debugInfo, false); + }); + + it('leaves both build options undefined when the legacy config omits them', () => { + const deploy = norm({ + type: 'soroban', + request: 'launch', + function: 'add', + args: [{ value: 5, type: 'u32' }], + wasmPath: '/abs/x.wasm', + }).steps[0] as DeployStep; + + assert.strictEqual(deploy.buildCommand, undefined); + assert.strictEqual(deploy.debugInfo, undefined); + }); + + it('preserves `buildCommand` + `debugInfo` declared on a new-schema deploy step', () => { + const deploy = norm({ + type: 'soroban', + request: 'launch', + transactions: [ + { + kind: 'deploy', + id: 'pool', + contract: '/abs/pool', + buildCommand: 'cargo build', + debugInfo: false, + }, + { kind: 'invoke', contract: 'pool', function: 'run' }, + ], + }).steps[0] as DeployStep; + + assert.strictEqual(deploy.buildCommand, 'cargo build'); + assert.strictEqual(deploy.debugInfo, false); + }); + }); +}); diff --git a/test/pipeline.test.ts b/test/pipeline.test.ts index d97af0f..ba3c296 100644 --- a/test/pipeline.test.ts +++ b/test/pipeline.test.ts @@ -113,24 +113,26 @@ describe('TurnkeyPipeline (against mock komet-node)', () => { assert.deepStrictEqual(args, [5, 6]); }); - it('surfaces a FAILED invocation as an error', async () => { + it('keeps a FAILED invocation debuggable: no throw, trace still fetched', async () => { + // A reverting tx must stay debuggable: TurnkeyPipeline (via SequenceRunner) + // NEVER throws on a FAILED status and fetches the traced step's trace + // regardless of status (see the module doc and the M3 no-throw test). await mock.stop(); mock = new MockKometNode({ trace, traceStatus: 'FAILED' }); port = await mock.start(); - await assert.rejects( - () => run(), - (err: Error) => { - // Still flags the FAILED status and identifies the invocation... - assert.match(err.message, /FAILED/); - assert.match(err.message, /add/); - // ...and pins the invocation to its transaction hash (mock's hashFor - // yields a 64-char hex hash, so this stays non-brittle). - assert.match(err.message, /tx [0-9a-f]{64}/); - // ...but no longer parrots the (now-fixed) value-return limitation. - assert.doesNotMatch(err.message, /no value|Void|update komet-node|stuck/i); - return true; - }, - ); + + let resolved; + await assert.doesNotReject(async () => { + resolved = await run(); + }); + + // The whole sequence still ran (four submissions) ... + assert.strictEqual(mock.envelopes('sendTransaction').length, 4); + // ... and the traced step's trace was fetched despite the FAILED status, + // parsed into a replayable model. + assert.strictEqual(mock.calls('traceTransaction'), 1); + assert.ok(resolved!.model.length > 0); + assert.strictEqual(resolved!.model.length, trace.trim().split('\n').length); }); }); diff --git a/test/sequenceRunner.e2e.test.ts b/test/sequenceRunner.e2e.test.ts new file mode 100644 index 0000000..cd41433 --- /dev/null +++ b/test/sequenceRunner.e2e.test.ts @@ -0,0 +1,345 @@ +/** + * M4 acceptance: the generalized multi-transaction pipeline, end-to-end against + * a REAL komet-node. + * + * These are the ONLY tests that can catch a breaking change in komet-node's + * JSON-RPC surface, its trace format, or its cross-transaction ledger + * semantics — a mock or a pre-recorded fixture, by construction, keeps testing + * yesterday's behaviour. So they are MANDATORY and run by default (the + * devcontainer and CI install the node via `kup install komet-node`). If + * `komet-node` is not on PATH the suite FAILS LOUDLY rather than skipping + * silently — set KOMET_NODE_E2E=0 to opt out only where the node genuinely + * cannot be installed. (Same convention as test/integration.node.test.ts.) + * + * Everything is driven through `TurnkeyPipeline.run(config, report)` using the + * NEW `transactions` config shape — this is what forces the M4 wiring: the + * pipeline must normalize the config (M1) and execute it through the + * SequenceRunner (M3) against the spawned node. The M4 acceptance scenarios + * pinned here (spec section "M4 acceptance"): + * + * 1. deploy ctor_probe -> invoke __constructor(admin:${sourceAddress}) -> + * invoke admin_set(_nonce:1), trace "last": a non-empty, replayable trace + * comes back AND the constructor's storage write persisted to the later tx. + * 2. deploy composite -> invoke supply with a real Vec<(AssetKey,i128)>: the + * txBuilder spec-encodes the argument as an SCV_VEC, but the current real + * komet-node only accepts SCALAR call arguments (its scval_to_json raises + * NotImplementedError for vec/map), so the run is REJECTED. This pins that + * node limitation until komet-node gains vec/map call-arg support. + * 3. a sequence whose TRACED last step deliberately traps does NOT throw, and + * its trace is still fetched non-empty (blocker #1: a reverting tx stays + * debuggable). + * 4. two byte-identical invokes both EXECUTE on the real node (distinct + * hashes, not deduped). + * + * The port is 8056 so a full `npm test` run does not collide with + * integration.node.test.ts (port 8000). + * + * The wiring does not exist yet — this suite is the TDD red phase and drives + * `TurnkeyPipeline.run` with the new config shape on purpose. + */ + +import * as assert from 'assert'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import { Keypair } from '@stellar/stellar-sdk'; +import { TurnkeyPipeline } from '../src/pipeline/TurnkeyPipeline'; +import { KometRpcError } from '../src/komet/KometClient'; +import { SorobanLaunchArgs } from '../src/debugAdapter/types'; + +const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures'); +const CTOR_WASM = path.join(FIXTURES, 'ctor_probe.wasm'); +const COMPOSITE_WASM = path.join(FIXTURES, 'composite.wasm'); + +const KOMET_NODE_COMMAND = process.env.KOMET_NODE_COMMAND ?? 'komet-node'; +const optedOut = process.env.KOMET_NODE_E2E === '0'; +// Port 8056 keeps this suite off integration.node.test.ts's port 8000. +const PORT = Number(process.env.KOMET_NODE_E2E_PORT ?? 8056); + +// A deterministic source account, so `${sourceAddress}` (and the whole run) is +// reproducible — never `Keypair.random()`. +const SOURCE_SECRET = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 42)).secret(); + +// --- The new `transactions` config shape this milestone forces through the +// pipeline. Typed locally; cast at the call site because the pipeline's public +// signature does not yet accept it (that is the M4 wiring under test). ------- +interface DeployTx { + kind: 'deploy'; + id: string; + wasm: string; +} +interface InvokeTx { + kind: 'invoke'; + contract: string; + function: string; + args?: unknown; + id?: string; +} +interface TxConfig { + type: 'soroban'; + request: 'launch'; + sourceSecret?: string; + node: { attach: false; command: string; port: number }; + transactions: (DeployTx | InvokeTx)[]; + trace?: 'last' | number | string; +} + +function nodeSettings() { + return { attach: false as const, command: KOMET_NODE_COMMAND, port: PORT }; +} + +/** Bridge to the not-yet-generalized `run` signature (expected red). */ +function asRunArgs(config: TxConfig): SorobanLaunchArgs { + return config as unknown as SorobanLaunchArgs; +} + +/** + * The boolean an invoked function returned, read off the replay model: the + * value pushed immediately before the terminal `endWasm` record. `admin_set` + * returns `has(ADMIN)`, so this is `true` exactly when the constructor's + * storage write is visible to this transaction. komet spells the bool as an + * i32 on the stack (1 = true, 0 = false). + */ +function invocationReturnedTrue(model: { records: { instr: [string, ...unknown[]]; stack: [string, unknown][] }[] }): boolean { + const recs = model.records; + let endIdx = -1; + for (let i = recs.length - 1; i >= 0; i--) { + if (recs[i].instr[0] === 'endWasm') { + endIdx = i; + break; + } + } + assert.ok(endIdx > 0, 'expected an endWasm record preceded by the return-value computation'); + const pre = recs[endIdx - 1]; + assert.ok(pre.stack.length > 0, 'expected the boolean result on the stack before endWasm'); + const top = pre.stack[pre.stack.length - 1]; + return top[1] === 1 || top[1] === true; +} + +describe('M4 SequenceRunner e2e', function () { + this.timeout(300_000); + + // Every spawned pipeline is tracked so `after` can guarantee the node is + // killed even if an assertion (or the missing wiring) aborts a test midway. + const active: TurnkeyPipeline[] = []; + + async function runSequence(config: TxConfig) { + const pipeline = new TurnkeyPipeline(); + active.push(pipeline); + try { + return await pipeline.run(asRunArgs(config), (msg) => console.log(msg)); + } finally { + await pipeline.dispose(); + const i = active.indexOf(pipeline); + if (i >= 0) { + active.splice(i, 1); + } + } + } + + before(function () { + if (optedOut) { + this.skip(); + return; + } + // Fail loudly when the node is missing — a silent skip is exactly how a + // breaking change slips through unnoticed. + const probe = spawnSync(KOMET_NODE_COMMAND, ['--help'], { stdio: 'ignore', timeout: 10_000 }); + if (probe.error) { + throw new Error( + `komet-node not found on PATH (command: '${KOMET_NODE_COMMAND}'). The real-node ` + + "end-to-end tests are mandatory — install it with 'kup install komet-node', or set " + + 'KOMET_NODE_E2E=0 to opt out only where the node genuinely cannot be installed.', + ); + } + }); + + after(async () => { + // Safety net: dispose anything a failed test left running. + for (const pipeline of active.splice(0)) { + await pipeline.dispose(); + } + }); + + // ------------------------------------------------------------------------ + // Scenario 1: constructor-as-invoke + cross-transaction state persistence. + // ------------------------------------------------------------------------ + it('deploys ctor_probe, runs __constructor then admin_set, and the constructor state persists into the traced tx', async () => { + const resolved = await runSequence({ + type: 'soroban', + request: 'launch', + sourceSecret: SOURCE_SECRET, + node: nodeSettings(), + transactions: [ + { kind: 'deploy', id: 'probe', wasm: CTOR_WASM }, + { + kind: 'invoke', + contract: 'probe', + function: '__constructor', + args: { admin: '${sourceAddress}' }, + }, + { kind: 'invoke', contract: 'probe', function: 'admin_set', args: { _nonce: 1 } }, + ], + trace: 'last', + }); + + const records = resolved.model.records; + + // A real, non-empty, replayable trace of the LAST tx (admin_set): it is + // bracketed by the Soroban VM events, carries at least one byte-positioned + // instruction, and its per-record positions line up with the model. + assert.ok(records.length > 0, 'expected a non-empty trace for the traced admin_set tx'); + assert.strictEqual(resolved.positions.length, records.length, 'positions must be parallel to records'); + assert.ok( + records.some((r) => r.instr[0] === 'callContract'), + 'expected a callContract VM event at the invocation boundary', + ); + assert.ok( + records.some((_, i) => resolved.positions[i] !== null), + 'expected at least one real (byte-positioned) instruction — a replayable trace', + ); + assert.strictEqual( + records[records.length - 1].instr[0], + 'endWasm', + 'expected the traced tx to end with an endWasm event', + ); + + // Persistence proof: admin_set returns has(ADMIN). Because __constructor ran + // in an EARLIER tx against the same accumulating ledger, this later tx sees + // ADMIN set — so the returned boolean is true. + assert.strictEqual( + invocationReturnedTrue(resolved.model), + true, + 'expected admin_set to observe the ADMIN written by the earlier __constructor tx (state persisted)', + ); + }); + + // ------------------------------------------------------------------------ + // Scenario 2: a spec-encoded Vec call argument is REJECTED by the real node. + // + // The composite fixture's only entry point is supply(Vec<(AssetKey,i128)>). + // The txBuilder correctly spec-encodes that argument as an SCV_VEC (SCVal type + // 16), but the real komet-node's scval_to_json only encodes SCALAR call + // arguments — vec/map raise `NotImplementedError: Unsupported SCVal type for + // JSON encoding: 16` server-side (see komet_node/scval.py, whose docstring + // notes vec/map "never appear as call arguments"). sendTransaction therefore + // fails on the node before the invocation ever executes, no trace is produced, + // and the pipeline surfaces the missing trace as a KometRpcError. This test + // pins that real node limitation: the code under test (pipeline / runner / + // txBuilder) is correct — the capability simply does not exist in komet-node + // yet. If komet-node ever gains vec/map call-arg support, this scenario must + // be reinstated as a positive trace assertion. + // ------------------------------------------------------------------------ + it('rejects a supply invocation whose Vec<(AssetKey,i128)> argument the real komet-node cannot encode', async () => { + await assert.rejects( + () => + runSequence({ + type: 'soroban', + request: 'launch', + sourceSecret: SOURCE_SECRET, + node: nodeSettings(), + transactions: [ + { kind: 'deploy', id: 'pool', wasm: COMPOSITE_WASM }, + { + kind: 'invoke', + contract: 'pool', + function: 'supply', + // Vec<(AssetKey, i128)>: a unit variant and an integer-carrying + // variant, i128 values as strings — encoded via the contract spec. + args: { + requests: [ + [{ tag: 'Native' }, '1000'], + [{ tag: 'Other', values: [7] }, '-5'], + ], + }, + }, + ], + trace: 'last', + }), + (err: unknown) => { + // The vec/map call argument never reaches execution: the node rejects + // sendTransaction, so the traced tx yields no trace and the pipeline + // raises a KometRpcError. Assert the type (not the exact wording) so a + // future message tweak does not make this brittle. + assert.ok(err instanceof KometRpcError, `expected a KometRpcError, got ${err}`); + return true; + }, + 'the real komet-node cannot encode a Vec<(AssetKey,i128)> call argument, so the run must reject', + ); + }); + + // ------------------------------------------------------------------------ + // Scenario 3: no-throw on a FAILED traced step (blocker #1). The last step + // deliberately traps — invoking admin_set (arity 1) with zero args, via the + // legacy {type,value}[] path so the arity mismatch reaches the node instead + // of being rejected during spec encoding. komet returns status FAILED; the + // pipeline must NOT throw, and the trap's trace must still come back. + // ------------------------------------------------------------------------ + it('does not throw when the traced last step traps, and still returns its non-empty trace', async () => { + const resolved = await runSequence({ + type: 'soroban', + request: 'launch', + sourceSecret: SOURCE_SECRET, + node: nodeSettings(), + transactions: [ + { kind: 'deploy', id: 'probe', wasm: CTOR_WASM }, + { + kind: 'invoke', + contract: 'probe', + function: '__constructor', + args: { admin: '${sourceAddress}' }, + }, + // Deliberate trap: admin_set takes one u32, but we pass zero args (legacy + // array form) — the contract dispatcher traps -> tx FAILED on the node. + { kind: 'invoke', contract: 'probe', function: 'admin_set', args: [] }, + ], + trace: 'last', + }); + + // The mere fact that `run` resolved (did not reject) IS the no-throw + // guarantee — the old pipeline threw on FAILED before fetching the trace. + assert.ok( + resolved.model.records.length > 0, + 'expected the FAILED (trapping) tx to still yield a non-empty, debuggable trace', + ); + }); + + // ------------------------------------------------------------------------ + // Scenario 4: anti-dedup on the REAL node. Two byte-identical admin_set(1) + // requests straddle the constructor. Their envelopes differ ONLY by the + // runner's incrementing sequence number, so komet-node cannot fold them into + // one cached receipt. Proof through the traced result: the LAST admin_set + // (identical to the FIRST) observes ADMIN as SET — which is only possible if + // it genuinely re-executed AFTER the constructor. Had it been deduped into + // the first (pre-constructor) call, komet would have served that cached + // receipt and the boolean would be false. + // ------------------------------------------------------------------------ + it('executes two byte-identical invokes independently (distinct hashes, not deduped)', async () => { + const resolved = await runSequence({ + type: 'soroban', + request: 'launch', + sourceSecret: SOURCE_SECRET, + node: nodeSettings(), + transactions: [ + { kind: 'deploy', id: 'probe', wasm: CTOR_WASM }, + // First identical admin_set(1): runs BEFORE the constructor -> false. + { kind: 'invoke', contract: 'probe', function: 'admin_set', args: { _nonce: 1 } }, + { + kind: 'invoke', + contract: 'probe', + function: '__constructor', + args: { admin: '${sourceAddress}' }, + }, + // Second, byte-identical admin_set(1): must re-execute AFTER the + // constructor -> true. A deduped call would return the cached false. + { kind: 'invoke', contract: 'probe', function: 'admin_set', args: { _nonce: 1 } }, + ], + trace: 'last', + }); + + assert.strictEqual( + invocationReturnedTrue(resolved.model), + true, + 'the second byte-identical admin_set must re-execute (post-constructor state) — proving it was not deduped into the first', + ); + }); +}); diff --git a/test/sequenceRunner.test.ts b/test/sequenceRunner.test.ts new file mode 100644 index 0000000..b15629f --- /dev/null +++ b/test/sequenceRunner.test.ts @@ -0,0 +1,462 @@ +/** + * M3 acceptance tests: the sequence runner core, exercised against the MOCK + * komet-node (test/support/mockKometNode.ts) — NO real komet-node, NO network. + * + * Pins the "M3 acceptance" section of the spec. A `SequenceRunner` executes a + * normalized `{ steps, trace }` (produced by M1 `normalizeConfig`) against a + * `KometClient`, reusing the reference flow in `src/pipeline/TurnkeyPipeline.ts` + * (seed source -> per deploy: upload + create -> per invoke: build + send -> + * fetch the traced tx's trace -> `buildDebugArtifacts`), and returns a + * `ResolvedTrace`. + * + * The runner does not yet exist — this suite is the TDD red phase and imports + * `src/pipeline/SequenceRunner.ts` on purpose. The contract these tests pin: + * + * const runner = new SequenceRunner(new KometClient({ host, port })); + * const resolved = await runner.run( + * normalizeConfig(raw), // { steps, trace } from M1 + * { sourceSecret? }, // deterministic source options + * report, // progress reporter + * ); + * + * Behaviour is verified through the traffic the mock RECORDS (submitted + * envelopes, in order, and the hash passed to traceTransaction), decoded with + * `@stellar/stellar-sdk`. The mock hashes submissions positionally + * (`hashFor(n)` = the 1-based send order, hex, left-padded to 64), so the hash + * of the k-th `sendTransaction` is `(k+1).toString(16).padStart(64,'0')`. + * + * Fixtures are the committed wasms; compiled tests live in out/test/, so the + * fixture dir is two levels up under the source tree. + */ + +import * as assert from 'assert'; +import * as path from 'path'; +import { + Keypair, + Networks, + StrKey, + TransactionBuilder, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; +import { normalizeConfig } from '../src/pipeline/config'; +import { SequenceRunner } from '../src/pipeline/SequenceRunner'; +import { KometClient } from '../src/komet/KometClient'; +import { MockKometNode } from './support/mockKometNode'; + +const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures'); +const COMPOSITE_WASM = path.join(FIXTURES, 'composite.wasm'); +const CTOR_WASM = path.join(FIXTURES, 'ctor_probe.wasm'); + +// A deterministic source; its secret lets the source-account tests assert the +// exact seeded public key. Derived from a fixed seed, never `Keypair.random()`. +const SOURCE = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 3)); +const SOURCE_SECRET = SOURCE.secret(); +const SOURCE_PUBKEY = SOURCE.publicKey(); + +// A tiny synthetic trace the mock serves for whichever tx is traced. Its length +// is the only property these tests read (which is deterministic regardless of +// the traced wasm), so it stays small and fixture-independent. +const TRACE = [ + '{"pos":null,"instr":["callContract"],"function":"run","args":[],"depth":1}', + '{"pos":3,"instr":["const","i32",1],"stack":[],"locals":{}}', + '{"pos":5,"instr":["return"],"stack":[["u32",1]],"locals":{}}', + '{"pos":null,"instr":["endWasm"],"success":true,"result":{"type":"u32","value":1}}', +].join('\n'); +const TRACE_LEN = 4; + +// The mock reports Networks.TESTNET from getNetwork, so envelopes are signed and +// decoded under that passphrase. +const NET = Networks.TESTNET; + +interface DecodedSend { + index: number; + /** The hash the mock returned for this submission (positional). */ + hash: string; + /** Canonical kind: createAccount | uploadWasm | createContract | invoke. */ + kind: string; + /** Raw base64 XDR envelope, for byte-identity comparisons. */ + envelope: string; + /** The transaction sequence number, as a string. */ + sequence: string; + /** Invoke only: function name. */ + fn?: string; + /** Invoke only: the resolved target contract address ("C..."). */ + target?: string; + /** Invoke only: the call args, decoded to native. */ + args?: unknown[]; +} + +/** The hash the mock returns for the k-th (0-based) sendTransaction. */ +function sendHash(k: number): string { + return (k + 1).toString(16).padStart(64, '0'); +} + +function classify(op: any): string { + if (op.type === 'createAccount') { + return 'createAccount'; + } + if (op.type === 'invokeHostFunction') { + const name = op.func.switch().name as string; + if (name === 'hostFunctionTypeUploadContractWasm') { + return 'uploadWasm'; + } + if (name === 'hostFunctionTypeCreateContractV2') { + return 'createContract'; + } + if (name === 'hostFunctionTypeInvokeContract') { + return 'invoke'; + } + return name; + } + return op.type; +} + +/** Decode every submitted envelope, in submission order, with its mock hash. */ +function decodeSends(mock: MockKometNode): DecodedSend[] { + return mock.envelopes('sendTransaction').map((envelope, index) => { + const tx = TransactionBuilder.fromXDR(envelope, NET) as any; + const op = tx.operations[0]; + const kind = classify(op); + const base: DecodedSend = { + index, + hash: sendHash(index), + kind, + envelope, + sequence: tx.sequence, + }; + if (kind === 'invoke') { + const ic = op.func.invokeContract(); + base.fn = ic.functionName().toString(); + base.target = scValToNative(xdr.ScVal.scvAddress(ic.contractAddress())) as string; + base.args = ic.args().map((a: xdr.ScVal) => scValToNative(a)); + } + return base; + }); +} + +function invokeSends(mock: MockKometNode): DecodedSend[] { + return decodeSends(mock).filter((s) => s.kind === 'invoke'); +} + +/** The single hash the runner passed to traceTransaction. */ +function tracedHash(mock: MockKometNode): string { + const calls = mock.received.filter((r) => r.method === 'traceTransaction'); + assert.strictEqual(calls.length, 1, 'expected exactly one traceTransaction call'); + return calls[0].params.hash; +} + +/** assert.deepStrictEqual as a predicate (handles bigint, which JSON cannot). */ +function deepEqual(a: unknown, b: unknown): boolean { + try { + assert.deepStrictEqual(a, b); + return true; + } catch { + return false; + } +} + +describe('M3 sequenceRunner', () => { + let mocks: MockKometNode[] = []; + + afterEach(async () => { + await Promise.all(mocks.map((m) => m.stop())); + mocks = []; + }); + + interface RunOpts { + sourceSecret?: string; + traceStatus?: string; + } + + /** Start a fresh mock, run the (normalized) config, return both for inspection. */ + async function runSeq(raw: unknown, opts: RunOpts = {}) { + const mock = new MockKometNode({ trace: TRACE, traceStatus: opts.traceStatus }); + mocks.push(mock); + const port = await mock.start(); + const client = new KometClient({ host: '127.0.0.1', port }); + const runner = new SequenceRunner(client); + const resolved = await runner.run( + normalizeConfig(raw as any), + { sourceSecret: opts.sourceSecret }, + () => undefined, + ); + return { mock, resolved }; + } + + // ------------------------------------------------------------------------ + // 1. Steps execute IN ORDER; a deploy registers a handle later invokes use. + // ------------------------------------------------------------------------ + describe('ordered execution + handle registration', () => { + const raw = { + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'probe', wasm: CTOR_WASM }, + { + kind: 'invoke', + contract: 'probe', + function: '__constructor', + args: { admin: '${sourceAddress}' }, + }, + { kind: 'invoke', contract: 'probe', function: 'admin_set', args: { _nonce: 1 } }, + ], + }; + + it('submits seed -> upload -> create -> invoke -> invoke, in that order', async () => { + const { mock } = await runSeq(raw, { sourceSecret: SOURCE_SECRET }); + assert.deepStrictEqual( + decodeSends(mock).map((s) => s.kind), + ['createAccount', 'uploadWasm', 'createContract', 'invoke', 'invoke'], + ); + }); + + it('resolves both invokes to the SAME contract id registered by the deploy', async () => { + const { mock } = await runSeq(raw, { sourceSecret: SOURCE_SECRET }); + const invokes = invokeSends(mock); + assert.strictEqual(invokes.length, 2); + // A single deploy handle -> one live contract id, shared by both invokes. + assert.strictEqual(invokes[0].target, invokes[1].target); + assert.ok( + StrKey.isValidContract(invokes[0].target as string), + `expected a valid "C..." contract id, got ${invokes[0].target}`, + ); + }); + + it('substitutes ${sourceAddress} into the __constructor invoke args', async () => { + const { mock } = await runSeq(raw, { sourceSecret: SOURCE_SECRET }); + const ctor = invokeSends(mock).find((s) => s.fn === '__constructor'); + assert.ok(ctor, 'expected a __constructor invoke'); + // The single `admin` arg resolves to the seeded source public key. + assert.deepStrictEqual(ctor!.args, [SOURCE_PUBKEY]); + }); + }); + + // ------------------------------------------------------------------------ + // 2. Substitution (${sourceAddress} / ${contract:id}) THEN spec-encoding of + // composite args, end-to-end through the runner (composite.wasm). + // ------------------------------------------------------------------------ + describe('substitution + spec-driven composite encoding (composite.wasm)', () => { + it('substitutes both token kinds and spec-encodes the composite Vec<(AssetKey,i128)>', async () => { + const raw = { + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'pool', wasm: COMPOSITE_WASM }, + { + kind: 'invoke', + contract: 'pool', + function: 'supply', + args: { + // ${sourceAddress} -> the seeded G-address; + // ${contract:pool} -> pool's own derived C-address (the invoke target). + requests: [ + [{ tag: 'Stellar', values: ['${sourceAddress}'] }, '1'], + [{ tag: 'Stellar', values: ['${contract:pool}'] }, '2'], + ], + }, + }, + ], + }; + + const { mock } = await runSeq(raw, { sourceSecret: SOURCE_SECRET }); + const supply = invokeSends(mock).find((s) => s.fn === 'supply'); + assert.ok(supply, 'expected a supply invoke'); + + // Encoded as a single top-level composite arg, round-tripping to the + // spec's native shape: unit-tag payloads are `['Stellar', ]`, i128s + // are bigint. ${contract:pool} resolved to the invoke's own target id. + assert.deepStrictEqual(supply!.args, [ + [ + [['Stellar', SOURCE_PUBKEY], 1n], + [['Stellar', supply!.target], 2n], + ], + ]); + assert.ok(StrKey.isValidContract(supply!.target as string)); + }); + }); + + // ------------------------------------------------------------------------ + // 3. Anti-dedup: two byte-identical invokes yield DIFFERENT envelopes/hashes + // (distinct sequence numbers), so komet-node cannot dedup the second. + // ------------------------------------------------------------------------ + describe('anti-dedup (distinct sequence numbers per tx)', () => { + it('two identical invoke requests produce different envelopes and sequence numbers', async () => { + const identicalInvoke = { + kind: 'invoke', + contract: 'pool', + function: 'supply', + args: { requests: [[{ tag: 'Native' }, '1000']] }, + }; + const raw = { + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'pool', wasm: COMPOSITE_WASM }, + { ...identicalInvoke }, + { ...identicalInvoke }, + ], + }; + + const { mock } = await runSeq(raw, { sourceSecret: SOURCE_SECRET }); + const invokes = invokeSends(mock); + assert.strictEqual(invokes.length, 2); + + // Same call, same args: byte-for-byte identical INTENT ... + // args = [ ]; the vec holds one tuple [ , ]; + // the unit variant Native decodes to ['Native'], the i128 to a bigint. + assert.strictEqual(invokes[0].fn, invokes[1].fn); + assert.ok(deepEqual(invokes[0].args, [[[['Native'], 1000n]]])); + assert.ok(deepEqual(invokes[0].args, invokes[1].args)); + + // ... yet DIFFERENT envelopes, driven by distinct sequence numbers, so the + // node sees two distinct hashes rather than deduping the second. + assert.notStrictEqual(invokes[0].sequence, invokes[1].sequence); + assert.notStrictEqual(invokes[0].envelope, invokes[1].envelope); + assert.notStrictEqual(invokes[0].hash, invokes[1].hash); + }); + }); + + // ------------------------------------------------------------------------ + // 4. A FAILED step never throws/aborts; the sequence completes and the traced + // step's trace is STILL fetched (blocker #1 — a reverting tx stays + // debuggable). The mock's traceStatus override marks getTransaction FAILED. + // ------------------------------------------------------------------------ + describe('no-throw on FAILED + trace still fetched', () => { + const raw = { + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'pool', wasm: COMPOSITE_WASM }, + { + kind: 'invoke', + contract: 'pool', + function: 'supply', + args: { requests: [[{ tag: 'Native' }, '1000']] }, + }, + ], + }; + + it('completes the run without throwing even though every step reports FAILED', async () => { + await assert.doesNotReject(() => runSeq(raw, { sourceSecret: SOURCE_SECRET, traceStatus: 'FAILED' })); + }); + + it('still fetches the traced step trace and returns a valid replayable model', async () => { + const { mock, resolved } = await runSeq(raw, { + sourceSecret: SOURCE_SECRET, + traceStatus: 'FAILED', + }); + // The trace was fetched despite the FAILED status ... + assert.strictEqual(mock.calls('traceTransaction'), 1); + // ... and parsed into a replayable model of the expected length. + assert.strictEqual(resolved.model.length, TRACE_LEN); + }); + }); + + // ------------------------------------------------------------------------ + // 5. Trace selection: by "last" (default), by index, and by an invoke id, + // each fetching the trace of the CORRECT submitted tx. + // ------------------------------------------------------------------------ + describe('trace selection resolves to the right submitted tx', () => { + // Two invokes with DISTINCT args (Other 1 / Other 2) so each maps to an + // identifiable submitted tx; each also carries an `id`. + const raw = { + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'pool', wasm: COMPOSITE_WASM }, + { + kind: 'invoke', + id: 'first', + contract: 'pool', + function: 'supply', + args: { requests: [[{ tag: 'Other', values: [1] }, '1']] }, + }, + { + kind: 'invoke', + id: 'second', + contract: 'pool', + function: 'supply', + args: { requests: [[{ tag: 'Other', values: [2] }, '2']] }, + }, + ], + }; + + const FIRST_ARGS = [[[['Other', 1], 1n]]]; + const SECOND_ARGS = [[[['Other', 2], 2n]]]; + + /** The mock hash of the invoke send whose decoded args match `expected`. */ + function hashOfInvoke(mock: MockKometNode, expected: unknown): string { + const match = invokeSends(mock).find((s) => deepEqual(s.args, expected)); + assert.ok(match, `no submitted invoke matched args ${JSON.stringify(expected, bigintSafe)}`); + return match!.hash; + } + + it('"last" (default) traces the final invoke', async () => { + const { mock } = await runSeq(raw); // no `trace` -> default "last" + assert.strictEqual(tracedHash(mock), hashOfInvoke(mock, SECOND_ARGS)); + }); + + it('an integer index traces exactly that step', async () => { + // Step 1 is the `first` invoke (step 0 is the deploy). + const { mock } = await runSeq({ ...raw, trace: 1 }); + assert.strictEqual(tracedHash(mock), hashOfInvoke(mock, FIRST_ARGS)); + }); + + it('an invoke `id` selector traces the invoke with that id', async () => { + const { mock: m1 } = await runSeq({ ...raw, trace: 'first' }); + assert.strictEqual(tracedHash(m1), hashOfInvoke(m1, FIRST_ARGS)); + + const { mock: m2 } = await runSeq({ ...raw, trace: 'second' }); + assert.strictEqual(tracedHash(m2), hashOfInvoke(m2, SECOND_ARGS)); + }); + }); + + // ------------------------------------------------------------------------ + // 6. Deterministic source: same config -> same source address, never random. + // ------------------------------------------------------------------------ + describe('deterministic source account', () => { + const raw = { + type: 'soroban', + request: 'launch', + transactions: [ + { kind: 'deploy', id: 'pool', wasm: COMPOSITE_WASM }, + { + kind: 'invoke', + contract: 'pool', + function: 'supply', + args: { requests: [[{ tag: 'Native' }, '1000']] }, + }, + ], + }; + + /** The seeded source public key = the CreateAccount destination (first send). */ + function seededSource(mock: MockKometNode): string { + const first = mock.envelopes('sendTransaction')[0]; + const op = (TransactionBuilder.fromXDR(first, NET) as any).operations[0]; + assert.strictEqual(op.type, 'createAccount'); + return op.destination as string; + } + + it('uses the provided sourceSecret verbatim', async () => { + const { mock } = await runSeq(raw, { sourceSecret: SOURCE_SECRET }); + assert.strictEqual(seededSource(mock), SOURCE_PUBKEY); + }); + + it('derives a STABLE source when no secret is given (never Keypair.random)', async () => { + const { mock: a } = await runSeq(raw); + const { mock: b } = await runSeq(raw); + const sa = seededSource(a); + const sb = seededSource(b); + assert.ok(StrKey.isValidEd25519PublicKey(sa), `expected a valid "G..." source, got ${sa}`); + // Same config -> same source across independent runs (a random keypair + // would differ here). + assert.strictEqual(sa, sb); + }); + }); +}); + +/** JSON.stringify replacer that renders bigint (for error messages only). */ +function bigintSafe(_key: string, value: unknown): unknown { + return typeof value === 'bigint' ? `${value}n` : value; +} diff --git a/test/specEncode.test.ts b/test/specEncode.test.ts new file mode 100644 index 0000000..5e6cda9 --- /dev/null +++ b/test/specEncode.test.ts @@ -0,0 +1,179 @@ +/** + * M2 acceptance tests: spec-driven arg encoding + `${...}` substitution. + * + * Pins the "M2 acceptance" section of the spec. Two pure, IO-free concerns + * (aside from reading the committed wasm fixture off disk — no network, no + * komet-node): + * + * 1. A spec-driven encoder (implementer's home: src/soroban/specEncode.ts): + * - `loadContractSpec(wasm)` resolves the contract `Spec` OFFLINE from the + * wasm's `contractspecv0` custom section. + * - `encodeNamedArgs(spec, fn, namedArgs)` encodes args keyed by the spec's + * EXACT param names, handling composites (vecs of tuples of unions). + * - `encodeInvokeArgs(spec, fn, args)` dispatches: a legacy `{type,value}[]` + * array routes to the existing `src/soroban/scval` encoder; an object + * routes to the spec-driven path. + * 2. A pure `substitute(value, ctx)` pass replacing `${sourceAddress}` and + * `${contract:}` inside string values. + * + * Uses the REAL committed fixture test/fixtures/composite.wasm (built from + * test/fixtures/composite-contract/), whose `supply(requests: Vec<(AssetKey, + * i128)>)` exercises union/tuple/vec encoding. Imports from the not-yet-existing + * implementer module on purpose (TDD red phase). + */ + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Keypair, scValToNative, xdr } from '@stellar/stellar-sdk'; +import { + loadContractSpec, + encodeNamedArgs, + encodeInvokeArgs, + substitute, +} from '../src/soroban/specEncode'; + +// Matches the loader used by the integration tests: compiled tests live in +// out/test/, so the fixture is two levels up under the source tree. +const COMPOSITE_WASM = path.join(__dirname, '..', '..', 'test', 'fixtures', 'composite.wasm'); + +// A deterministic, valid Stellar account address for the `Stellar(Address)` +// union variant. Derived from a fixed seed so the suite stays reproducible +// (no `Keypair.random()`), with no network access. +const ADDRESS = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7)).publicKey(); + +type Spec = Awaited>; + +describe('M2 specEncode', () => { + let wasm: Buffer; + let spec: Spec; + + before(async () => { + wasm = fs.readFileSync(COMPOSITE_WASM); + // Resolves fully OFFLINE — no RPC call, despite Client.fromWasm's rpc-shaped + // signature (the spec is independent of any live contract id). + spec = await loadContractSpec(wasm); + }); + + describe('loadContractSpec + encodeNamedArgs (spec-driven)', () => { + it('encodes the composite `supply(requests: Vec<(AssetKey,i128)>)` to a top-level scvVec that round-trips to the exact native', () => { + const scvals = encodeNamedArgs(spec, 'supply', { + requests: [ + [{ tag: 'Native' }, '1000'], + [{ tag: 'Other', values: [7] }, '-5'], + ], + }); + + // A single top-level arg (`requests`), encoded as an ScVec. + assert.strictEqual(scvals.length, 1); + assert.strictEqual(scvals[0].switch().name, 'scvVec'); + + // Round-trips to the EXACT native from the spec. i128 values decode to + // bigint; the enum unit variant is `["Native"]`, the int variant carries + // its payload as `["Other", 7]`. + const native = scValToNative(scvals[0]); + assert.deepStrictEqual(native, [ + [['Native'], 1000n], + [['Other', 7], -5n], + ]); + }); + + it('encodes a Stellar(Address) union variant', () => { + const scvals = encodeNamedArgs(spec, 'supply', { + requests: [[{ tag: 'Stellar', values: [ADDRESS] }, '42']], + }); + + assert.strictEqual(scvals.length, 1); + assert.strictEqual(scvals[0].switch().name, 'scvVec'); + + const native = scValToNative(scvals[0]); + assert.deepStrictEqual(native, [[['Stellar', ADDRESS], 42n]]); + }); + + it('throws on a wrong param name', () => { + assert.throws(() => + encodeNamedArgs(spec, 'supply', { + // The spec's param is `requests`; a wrong name must be rejected + // rather than silently dropped. + wrongName: [[{ tag: 'Native' }, '1000']], + }), + ); + }); + + it('throws on an unknown function', () => { + assert.throws(() => encodeNamedArgs(spec, 'noSuchFunction', {})); + }); + }); + + describe('encodeInvokeArgs (dispatcher)', () => { + it('routes a legacy `{type,value}[]` array to the legacy encoder', () => { + const scvals = encodeInvokeArgs(spec, 'supply', [{ value: 5, type: 'u32' }]); + assert.strictEqual(scvals.length, 1); + assert.strictEqual(scvals[0].switch().name, 'scvU32'); + assert.strictEqual(Number(scValToNative(scvals[0])), 5); + }); + + it('routes an object of named args to the spec-driven encoder', () => { + const scvals = encodeInvokeArgs(spec, 'supply', { + requests: [[{ tag: 'Native' }, '1000']], + }); + assert.strictEqual(scvals.length, 1); + assert.strictEqual(scvals[0].switch().name, 'scvVec'); + assert.deepStrictEqual(scValToNative(scvals[0]), [[['Native'], 1000n]]); + }); + + it('produces xdr.ScVal instances on both paths', () => { + const legacy = encodeInvokeArgs(spec, 'supply', [{ value: 1, type: 'u32' }]); + const named = encodeInvokeArgs(spec, 'supply', { + requests: [[{ tag: 'Native' }, '1']], + }); + assert.ok(legacy[0] instanceof xdr.ScVal); + assert.ok(named[0] instanceof xdr.ScVal); + }); + }); + + describe('substitute', () => { + const ctx = { + sourceAddress: 'GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57', + contracts: { pool: 'CA24HSVRERTJMFUDSZXKFK2HMO5CBBK6U5KA6PLLL6BGSQRO44FYZFRE' }, + }; + + it('replaces ${sourceAddress}', () => { + assert.strictEqual(substitute('${sourceAddress}', ctx), ctx.sourceAddress); + }); + + it('replaces ${contract:} with the resolved contract id', () => { + assert.strictEqual(substitute('${contract:pool}', ctx), ctx.contracts.pool); + }); + + it('leaves a plain string unchanged', () => { + assert.strictEqual(substitute('1000', ctx), '1000'); + assert.strictEqual(substitute('plain text', ctx), 'plain text'); + }); + + it('passes non-string primitives through unchanged', () => { + assert.strictEqual(substitute(5, ctx), 5); + assert.strictEqual(substitute(true, ctx), true); + assert.strictEqual(substitute(null, ctx), null); + }); + + it('recurses into arrays and objects, substituting only inside string values', () => { + const input = { + admin: '${sourceAddress}', + requests: [['${contract:pool}', '5']], + }; + assert.deepStrictEqual(substitute(input, ctx), { + admin: ctx.sourceAddress, + requests: [[ctx.contracts.pool, '5']], + }); + }); + + it('throws on an unknown ${...} token', () => { + assert.throws(() => substitute('${bogus}', ctx)); + }); + + it('throws on an unknown contract handle', () => { + assert.throws(() => substitute('${contract:missing}', ctx)); + }); + }); +}); From ce99ae9e4113d206725ab806679703846b2cc850 Mon Sep 17 00:00:00 2001 From: Raoul Date: Tue, 28 Jul 2026 10:24:56 +0000 Subject: [PATCH 2/4] test: make the komet-node presence probe timeout configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e `before` hook probes ` --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. --- test/sequenceRunner.e2e.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/sequenceRunner.e2e.test.ts b/test/sequenceRunner.e2e.test.ts index cd41433..bc83418 100644 --- a/test/sequenceRunner.e2e.test.ts +++ b/test/sequenceRunner.e2e.test.ts @@ -54,6 +54,11 @@ const KOMET_NODE_COMMAND = process.env.KOMET_NODE_COMMAND ?? 'komet-node'; const optedOut = process.env.KOMET_NODE_E2E === '0'; // Port 8056 keeps this suite off integration.node.test.ts's port 8000. const PORT = Number(process.env.KOMET_NODE_E2E_PORT ?? 8056); +// How long the presence probe waits for ` --help` to return. The kup +// binary answers instantly, but a locally-built dev node (run from source) can +// take ~15s to import its K/pyk machinery on a cold start, so the default is +// generous and overridable for slower environments. +const PROBE_TIMEOUT_MS = Number(process.env.KOMET_NODE_PROBE_TIMEOUT_MS ?? 30_000); // A deterministic source account, so `${sourceAddress}` (and the whole run) is // reproducible — never `Keypair.random()`. @@ -143,7 +148,7 @@ describe('M4 SequenceRunner e2e', function () { } // Fail loudly when the node is missing — a silent skip is exactly how a // breaking change slips through unnoticed. - const probe = spawnSync(KOMET_NODE_COMMAND, ['--help'], { stdio: 'ignore', timeout: 10_000 }); + const probe = spawnSync(KOMET_NODE_COMMAND, ['--help'], { stdio: 'ignore', timeout: PROBE_TIMEOUT_MS }); if (probe.error) { throw new Error( `komet-node not found on PATH (command: '${KOMET_NODE_COMMAND}'). The real-node ` + From dd9ede9c7c2a2d2a16e0e64829ad84cf4e1295be Mon Sep 17 00:00:00 2001 From: Raoul Date: Tue, 28 Jul 2026 10:32:02 +0000 Subject: [PATCH 3/4] docs: document the multi-transaction config format in the README 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:}` substitution tokens. Keep the single-call shorthand documented as a convenience. --- README.md | 77 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8c1c000..db1de4b 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,32 @@ at all — so you can see the debugger working in seconds. See ## Usage -Add a `soroban` configuration to your `.vscode/launch.json`. The common case is: -build a contract, run a function on a local network, and debug the result. +Add a `soroban` configuration to your `.vscode/launch.json`. A configuration describes an ordered sequence of transactions run against one fresh local ledger, and names which transaction to trace and debug — the last one by default. This lets you set up whatever state the call under test depends on (deploy other contracts, run a constructor, seed storage) before the transaction you actually want to step through. + +```jsonc +{ + "type": "soroban", + "request": "launch", + "name": "Debug supply", + "transactions": [ + // deploy: build a crate dir (or point `wasm` at a prebuilt .wasm) and + // register it under a handle `id` that later invokes reference. + { "kind": "deploy", "id": "pool", "contract": "${workspaceFolder}" }, + + // invoke: call a function on a deployed handle. `args` is an object keyed + // by the function's parameter names. + { "kind": "invoke", "contract": "pool", "function": "__constructor", + "args": { "admin": "${sourceAddress}" } }, + { "kind": "invoke", "contract": "pool", "function": "supply", + "args": { "requests": [[{ "tag": "Native" }, "1000"]] } } + ], + "trace": "last" +} +``` + +Set a breakpoint in your contract's Rust source, start the configuration, and step through the traced transaction — forward or backward. + +For the common case of a single call you can skip `transactions` and write the invoke inline; the debugger deploys the contract and invokes it for you: ```jsonc { @@ -61,31 +85,48 @@ build a contract, run a function on a local network, and debug the result. "name": "Debug add(1, 2)", "contract": "${workspaceFolder}", // crate dir containing Cargo.toml "function": "add", - "args": [ - { "value": 1, "type": "u32" }, - { "value": 2, "type": "u32" } - ] + "args": { "a": 1, "b": 2 } } ``` -Set a breakpoint in your contract's Rust source, start the configuration, and -step through — forward or backward. - ### Configuration reference +Top-level attributes: + | Attribute | Description | |-----------|-------------| -| `function` *(required)* | Name of the contract function to invoke and debug. | -| `args` | Function arguments, each `{ "value": …, "type": "u32" \| "i128" \| "symbol" \| "address" \| … }`. | -| `contract` | Path to the contract crate directory (with `Cargo.toml`). Defaults to `${workspaceFolder}`. | -| `wasmPath` | Path to a prebuilt `.wasm`. Overrides building from `contract`. | -| `debugInfo` | Build with debug info for Rust source mapping (default `true`; set `false` to debug at the wasm level only). | -| `rawTrace` | Replay a previously recorded run from a file instead of building and deploying. | +| `transactions` | Ordered array of `deploy` / `invoke` steps (see below). Mutually exclusive with the top-level `function` shorthand. | +| `trace` | Which transaction feeds the debug session: `"last"` (default), a 0-based index into `transactions`, or a step `id` (a deploy's `id` or an invoke's optional `id`). | +| `sourceSecret` | Source account secret (`S…`) used to sign every transaction. A deterministic account is derived if omitted. Its address is available in `args` as `${sourceAddress}`. | | `node` | Local-network connection/spawn settings: `attach`, `host`, `port`, `command`, `ioDir`. | -| `sourceSecret` | Optional source account secret (`S…`). A fresh account is used if omitted. | +| `rawTrace` | Replay a previously recorded run from a file instead of building and deploying. | + +A **`deploy`** step uploads a contract and registers a handle: + +| Field | Description | +|-------|-------------| +| `kind` *(required)* | `"deploy"`. | +| `id` *(required)* | Handle name that later `invoke` steps reference via their `contract` field, and that `trace` can select. | +| `contract` | Path to the contract crate directory (with `Cargo.toml`) to build. | +| `wasm` | Path to a prebuilt `.wasm`. Overrides building from `contract`; one of `contract` / `wasm` is required. | +| `buildCommand` | Command used to build a `contract` dir (default `stellar contract build`). | +| `debugInfo` | Build with debug info for Rust source mapping (default `true`; set `false` to debug at the wasm level only). | + +An **`invoke`** step calls a function on a deployed handle: + +| Field | Description | +|-------|-------------| +| `kind` *(required)* | `"invoke"`. | +| `contract` *(required)* | Handle `id` of an earlier `deploy` step. | +| `function` *(required)* | Name of the contract function to call. | +| `args` | Arguments, as an object keyed by the function's parameter names. Values follow the contract's own spec, so composites work: an enum is `{ "tag": "Native" }` or `{ "tag": "Other", "values": [7] }`, a tuple or vec is a JSON array, an `i128` is a decimal string, an address is a `G…`/`C…` string. | +| `id` | Optional label so `trace` can select this invoke's transaction. | + +Two substitution tokens are expanded inside string `args` values: `${sourceAddress}` (the source account's address) and `${contract:}` (the deployed address behind a handle). + +The single-call shorthand accepts the same fields inline: `function`, `args`, and a contract source (`contract` or `wasmPath`), plus `buildCommand` / `debugInfo`. `args` there also accepts the positional form `[{ "type": "u32", "value": 1 }, …]`. -Two settings let you point at executables that aren't on your `PATH`: -`soroban.stellar.path` and `soroban.kometNode.path`. +Two settings let you point at executables that aren't on your `PATH`: `soroban.stellar.path` and `soroban.kometNode.path`. ### Beyond the editor From d238dbda5728f01cc6b30d2687ddee2e688e46b7 Mon Sep 17 00:00:00 2001 From: Raoul Date: Tue, 28 Jul 2026 11:44:41 +0000 Subject: [PATCH 4/4] refactor: drop the legacy single-invoke debug-config format 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). --- README.md | 21 +-- docs/debug-config.md | 263 ++++++++++++++++++++++++++++++++ examples/.vscode/launch.json | 44 +++--- examples/README.md | 24 ++- package.json | 147 ++++++++++-------- src/debugAdapter/types.ts | 26 ++-- src/extension.ts | 28 ++-- src/pipeline/TurnkeyPipeline.ts | 14 +- src/pipeline/config.ts | 95 +++--------- src/trace/cliArgs.ts | 40 ++++- src/trace/main.ts | 4 +- test/backendFor.test.ts | 4 +- test/dapControlStepping.test.ts | 4 +- test/integration.node.test.ts | 27 +++- test/liveBackend.test.ts | 19 ++- test/multitxConfig.test.ts | 194 ++++++++--------------- test/pipeline.test.ts | 28 ++-- test/traceCli.test.ts | 33 +++- 18 files changed, 618 insertions(+), 397 deletions(-) create mode 100644 docs/debug-config.md diff --git a/README.md b/README.md index db1de4b..5593b77 100644 --- a/README.md +++ b/README.md @@ -76,18 +76,7 @@ Add a `soroban` configuration to your `.vscode/launch.json`. A configuration des Set a breakpoint in your contract's Rust source, start the configuration, and step through the traced transaction — forward or backward. -For the common case of a single call you can skip `transactions` and write the invoke inline; the debugger deploys the contract and invokes it for you: - -```jsonc -{ - "type": "soroban", - "request": "launch", - "name": "Debug add(1, 2)", - "contract": "${workspaceFolder}", // crate dir containing Cargo.toml - "function": "add", - "args": { "a": 1, "b": 2 } -} -``` +Even the simplest single-call session is one `deploy` plus one `invoke` — there is one config shape, so a two-contract system is just a longer `transactions` array. See [`docs/debug-config.md`](docs/debug-config.md) for the complete reference, including multi-contract systems, composite argument types, and offline replay. ### Configuration reference @@ -95,11 +84,11 @@ Top-level attributes: | Attribute | Description | |-----------|-------------| -| `transactions` | Ordered array of `deploy` / `invoke` steps (see below). Mutually exclusive with the top-level `function` shorthand. | +| `transactions` | Ordered, non-empty array of `deploy` / `invoke` steps (see below) — the live sequence to run. | | `trace` | Which transaction feeds the debug session: `"last"` (default), a 0-based index into `transactions`, or a step `id` (a deploy's `id` or an invoke's optional `id`). | | `sourceSecret` | Source account secret (`S…`) used to sign every transaction. A deterministic account is derived if omitted. Its address is available in `args` as `${sourceAddress}`. | | `node` | Local-network connection/spawn settings: `attach`, `host`, `port`, `command`, `ioDir`. | -| `rawTrace` | Replay a previously recorded run from a file instead of building and deploying. | +| `rawTrace` | Replay a previously recorded run from a file instead of building and deploying (optionally with `wasmPath` for source mapping). | A **`deploy`** step uploads a contract and registers a handle: @@ -124,9 +113,9 @@ An **`invoke`** step calls a function on a deployed handle: Two substitution tokens are expanded inside string `args` values: `${sourceAddress}` (the source account's address) and `${contract:}` (the deployed address behind a handle). -The single-call shorthand accepts the same fields inline: `function`, `args`, and a contract source (`contract` or `wasmPath`), plus `buildCommand` / `debugInfo`. `args` there also accepts the positional form `[{ "type": "u32", "value": 1 }, …]`. +`args` also accepts a lower-level positional form — an array `[{ "type": "u32", "value": 1 }, …]`, one entry per parameter — as an alternative to the named object. -Two settings let you point at executables that aren't on your `PATH`: `soroban.stellar.path` and `soroban.kometNode.path`. +Two settings let you point at executables that aren't on your `PATH`: `soroban.stellar.path` and `soroban.kometNode.path`. For the full reference — multi-contract systems, every argument shape, and offline replay — see [`docs/debug-config.md`](docs/debug-config.md). ### Beyond the editor diff --git a/docs/debug-config.md b/docs/debug-config.md new file mode 100644 index 0000000..50d2bee --- /dev/null +++ b/docs/debug-config.md @@ -0,0 +1,263 @@ +# Debug configuration reference + +> **Audience:** `soroban developer` · `getting started` · `writing launch.json` +> +> **TL;DR:** A `soroban` launch configuration describes an ordered sequence of +> transactions run against one fresh local ledger, and names which transaction +> to trace and debug. You set up whatever state your call depends on (deploy +> other contracts, run a constructor, seed storage) as earlier transactions, +> then point `trace` at the one you want to step through. This document covers +> every field, the argument encodings (including enums, tuples, structs, and +> addresses), the `${…}` substitution tokens, and offline replay — with worked +> examples. For the editor tour see [`../examples/README.md`](../examples/README.md); +> for the CLIs see [`trace-cli.md`](./trace-cli.md) and [`dap-cli.md`](./dap-cli.md). + +## Two modes + +A launch configuration runs in one of two modes: + +- **Live** — the default. You give a `transactions` array; the debugger spawns + (or attaches to) a local komet-node, runs the whole sequence against one fresh + ledger, and drops you into a debug session on the transaction named by + `trace`. +- **Replay** — you give a `rawTrace` file. The debugger replays a previously + recorded execution with no network and no toolchain. Optionally pair it with a + `wasmPath` for Rust source mapping. See [Replay mode](#replay-mode). + +A configuration is one JSON object in your `.vscode/launch.json` under +`configurations`. Every live configuration shares this skeleton: + +```jsonc +{ + "type": "soroban", + "request": "launch", + "name": "…", // shown in the Run and Debug dropdown + "transactions": [ … ], // the ordered sequence (required) + "trace": "last" // which transaction to debug (optional, default "last") +} +``` + +## The `transactions` sequence + +`transactions` is an ordered, non-empty array of **steps**. Each step is either +a `deploy` (upload a contract and register a handle) or an `invoke` (call a +function on a deployed handle). Steps run in order against one accumulating +ledger, so state written by one step is visible to later steps. + +```jsonc +"transactions": [ + { "kind": "deploy", "id": "pool", "contract": "${workspaceFolder}" }, + { "kind": "invoke", "contract": "pool", "function": "__constructor", + "args": { "admin": "${sourceAddress}" } }, + { "kind": "invoke", "contract": "pool", "function": "supply", + "args": { "requests": [[{ "tag": "Native" }, "1000"]] } } +] +``` + +Notes on semantics that shape how you author a sequence: + +- **A constructor is just an invoke.** komet runs a function literally named + `__constructor` as an ordinary call, and its storage writes persist — so + express contract initialization as an explicit `invoke` step, not as part of + the deploy. +- **Every transaction gets a distinct hash.** Two byte-identical invokes would + otherwise be deduplicated by the node; the runner assigns an incrementing + account sequence per submission so repeated identical calls each execute. +- **A reverting transaction stays debuggable.** The sequence never stops on a + failed transaction; the traced step's trace is fetched regardless of status. + +### `deploy` step + +| Field | Required | Description | +|-------|----------|-------------| +| `kind` | ✅ | `"deploy"`. | +| `id` | ✅ | Handle name. Later `invoke` steps reference it via their `contract` field, and `trace` can select this deploy's transaction by this id. Must be unique. | +| `contract` | one of `contract`/`wasm` | Path to a contract crate directory (containing `Cargo.toml`) to build. | +| `wasm` | one of `contract`/`wasm` | Path to a prebuilt `.wasm`. Overrides building from `contract`. | +| `buildCommand` | | Command used to build a `contract` directory. Defaults to `stellar contract build` (or the `soroban.stellar.path` setting). Ignored when `wasm` is given. | +| `debugInfo` | | Build with DWARF debug info for Rust source mapping (default `true`). Set `false` to debug at the wasm level only. Ignored when `wasm` is given. | + +### `invoke` step + +| Field | Required | Description | +|-------|----------|-------------| +| `kind` | ✅ | `"invoke"`. | +| `contract` | ✅ | Handle `id` of an earlier `deploy` step. | +| `function` | ✅ | Name of the contract function to call. | +| `args` | | Arguments to the function (see [Arguments](#arguments)). | +| `id` | | Optional label so `trace` can select this invoke's transaction. | + +## `trace` + +`trace` names which transaction feeds the debug session: + +| Value | Meaning | +|-------|---------| +| `"last"` (default) | The final step in `transactions`. | +| a number | A 0-based index into `transactions`. | +| a string | A step `id` — a deploy's `id` or an invoke's optional `id`. | + +## Arguments + +An invoke step's `args` is an **object keyed by the function's parameter +names**. Values follow the contract's own spec, so composite types work +naturally: + +| Rust type | JSON value | +|-----------|------------| +| `u32` / `i32` / `bool` | `1`, `-2`, `true` | +| `u64`/`i64`/`u128`/`i128`/`u256`/`i256` | a decimal string, e.g. `"1000"` (avoids JS precision loss) | +| `Symbol` / `String` | `"hello"` | +| `Address` | a `G…` (account) or `C…` (contract) string | +| a unit enum variant | `{ "tag": "Native" }` | +| an enum variant with data | `{ "tag": "Other", "values": [7] }` | +| a tuple or `Vec` | a JSON array, e.g. `[a, b]` | +| a struct | an object keyed by field name | +| `Bytes` | a hex string | + +Composites nest, so a `Vec<(Asset, i128)>` where `Asset` is an enum is: + +```jsonc +"args": { "requests": [ [ { "tag": "Native" }, "1000" ], + [ { "tag": "Other", "values": ["C…"] }, "-50" ] ] } +``` + +### Positional arguments + +`args` also accepts the lower-level **positional** form: an array of +`{ "type", "value" }` objects, one per parameter, encoded without consulting the +spec. Prefer the named object form above; the positional form is a fallback for +when you want explicit control over each ScVal's type: + +```jsonc +"args": [ { "type": "u32", "value": 1 }, { "type": "u32", "value": 2 } ] +``` + +## Substitution tokens + +Two tokens are expanded inside string `args` values (and inside nested +composites): + +| Token | Expands to | +|-------|-----------| +| `${sourceAddress}` | The source account's address (the account that signs every transaction). | +| `${contract:}` | The deployed contract address behind the handle ``. Use it to wire one deployed contract's address into another's call. | + +Alongside these, the standard VS Code variables such as `${workspaceFolder}` +work in path fields like `contract` and `wasm`. + +## Top-level fields + +| Field | Description | +|-------|-------------| +| `transactions` | The ordered live sequence (required for live mode). | +| `trace` | Which transaction to debug (see [`trace`](#trace)). | +| `sourceSecret` | Source account secret (`S…`) used to sign every transaction. A deterministic account is derived and self-seeded if omitted; its address is available as `${sourceAddress}`. | +| `node` | Local-network connection/spawn settings: `attach`, `host`, `port`, `command`, `ioDir`. | +| `rawTrace` | Replay mode: path to a recorded JSONL trace to replay instead of running a live sequence. | +| `wasmPath` | Replay mode only: a `.wasm` supplying disassembly and DWARF source mapping for the replayed trace. | + +Two VS Code settings let you point at executables that aren't on your `PATH`: +`soroban.stellar.path` and `soroban.kometNode.path`. + +## Replay mode + +Replay needs no toolchain and no node — it re-reads a trace you (or a teammate) +recorded earlier, which makes it ideal for reproducible bug reports. Give a +`rawTrace` path; add a `wasmPath` to get Rust source mapping instead of the +wasm-level fallback: + +```jsonc +{ + "type": "soroban", + "request": "launch", + "name": "Replay add(4, 3)", + "rawTrace": "${workspaceFolder}/traces/add.trace.jsonl", + "wasmPath": "${workspaceFolder}/../test/fixtures/adder-debug.wasm" +} +``` + +Record a trace with the [`soroban-trace`](./trace-cli.md) CLI. + +## Examples + +### 1. Debug a single transaction + +The smallest live configuration: build the crate in the workspace folder, invoke +one function, and debug it. There is one deploy and one invoke, and `trace` +defaults to the last step. + +```jsonc +{ + "type": "soroban", + "request": "launch", + "name": "Debug add(1, 2)", + "transactions": [ + { "kind": "deploy", "id": "adder", "contract": "${workspaceFolder}" }, + { "kind": "invoke", "contract": "adder", "function": "add", + "args": { "a": 1, "b": 2 } } + ] +} +``` + +### 2. Deploy a complex system, debug one call into it + +Deploy several contracts, initialize them, wire their addresses together with +`${contract:}`, seed some state, then debug a later call. Here we trace the +`swap` invoke by giving it an `id` and pointing `trace` at it — so the earlier +setup runs but the session opens on the call you care about. + +```jsonc +{ + "type": "soroban", + "request": "launch", + "name": "Debug router.swap", + "transactions": [ + { "kind": "deploy", "id": "token_a", "wasm": "${workspaceFolder}/wasm/token.wasm" }, + { "kind": "deploy", "id": "token_b", "wasm": "${workspaceFolder}/wasm/token.wasm" }, + { "kind": "deploy", "id": "router", "contract": "${workspaceFolder}/router" }, + + { "kind": "invoke", "contract": "token_a", "function": "__constructor", + "args": { "admin": "${sourceAddress}", "decimals": 7, "name": "A", "symbol": "A" } }, + { "kind": "invoke", "contract": "token_b", "function": "__constructor", + "args": { "admin": "${sourceAddress}", "decimals": 7, "name": "B", "symbol": "B" } }, + + { "kind": "invoke", "contract": "router", "function": "__constructor", + "args": { "token_a": "${contract:token_a}", "token_b": "${contract:token_b}" } }, + + { "kind": "invoke", "contract": "token_a", "function": "mint", + "args": { "to": "${sourceAddress}", "amount": "1000000" } }, + + { "kind": "invoke", "id": "the_swap", "contract": "router", "function": "swap", + "args": { "from": "${sourceAddress}", "amount_in": "1000", "min_out": "990" } } + ], + "trace": "the_swap" +} +``` + +### 3. Complex parameter types + +Enums, tuples, structs, nested vectors, addresses, and large integers, all in +named `args`: + +```jsonc +{ + "type": "soroban", + "request": "launch", + "name": "Debug pool.submit", + "transactions": [ + { "kind": "deploy", "id": "pool", "contract": "${workspaceFolder}" }, + { "kind": "invoke", "contract": "pool", "function": "submit", + "args": { + "from": "${sourceAddress}", + "spender": "${sourceAddress}", + "requests": [ + [ { "tag": "Supply" }, "${contract:pool}", "1000000" ], + [ { "tag": "Borrow" }, "${contract:pool}", "-500" ] + ], + "config": { "collateral": true, "cap": "170141183460469231731687303715884105727" } + } } + ], + "trace": "last" +} +``` diff --git a/examples/.vscode/launch.json b/examples/.vscode/launch.json index 0a44f3e..afca173 100644 --- a/examples/.vscode/launch.json +++ b/examples/.vscode/launch.json @@ -5,46 +5,40 @@ "name": "Soroban: Replay add(4, 3) trace", "type": "soroban", "request": "launch", - "rawTrace": "${workspaceFolder}/traces/add.trace.jsonl", - "function": "add" + "rawTrace": "${workspaceFolder}/traces/add.trace.jsonl" }, { "name": "Soroban: Replay add(4, 3) with symbols", "type": "soroban", "request": "launch", "rawTrace": "${workspaceFolder}/../test/fixtures/adder-debug.trace.jsonl", - "wasmPath": "${workspaceFolder}/../test/fixtures/adder-debug.wasm", - "function": "add" + "wasmPath": "${workspaceFolder}/../test/fixtures/adder-debug.wasm" }, { "name": "Soroban: Debug store(42) [live pipeline]", "type": "soroban", "request": "launch", - "contract": "${workspaceFolder}/greeter", - "function": "store", - "args": [ - { "value": 42, "type": "u32" } + "transactions": [ + { "kind": "deploy", "id": "greeter", "contract": "${workspaceFolder}/greeter" }, + { "kind": "invoke", "contract": "greeter", "function": "store", "args": { "value": 42 } } ] }, { "name": "Soroban: Debug add(1, 2) [live pipeline]", "type": "soroban", "request": "launch", - "contract": "${workspaceFolder}/adder", - "function": "add", - "args": [ - { "value": 1, "type": "u32" }, - { "value": 2, "type": "u32" } + "transactions": [ + { "kind": "deploy", "id": "adder", "contract": "${workspaceFolder}/adder" }, + { "kind": "invoke", "contract": "adder", "function": "add", "args": { "a": 1, "b": 2 } } ] }, { "name": "Soroban: Debug increment(5) [live pipeline]", "type": "soroban", "request": "launch", - "contract": "${workspaceFolder}/increment", - "function": "increment", - "args": [ - { "value": 5, "type": "u32" } + "transactions": [ + { "kind": "deploy", "id": "increment", "contract": "${workspaceFolder}/increment" }, + { "kind": "invoke", "contract": "increment", "function": "increment", "args": { "by": 5 } } ] }, { @@ -52,33 +46,29 @@ "type": "soroban", "request": "launch", "rawTrace": "${workspaceFolder}/../test/fixtures/control-while_call.trace.jsonl", - "wasmPath": "${workspaceFolder}/../test/fixtures/control-debug.wasm", - "function": "while_call" + "wasmPath": "${workspaceFolder}/../test/fixtures/control-debug.wasm" }, { "name": "Soroban: Replay control count(3) with symbols", "type": "soroban", "request": "launch", "rawTrace": "${workspaceFolder}/../test/fixtures/control-count.trace.jsonl", - "wasmPath": "${workspaceFolder}/../test/fixtures/control-debug.wasm", - "function": "count" + "wasmPath": "${workspaceFolder}/../test/fixtures/control-debug.wasm" }, { "name": "Soroban: Replay control branch(3) with symbols", "type": "soroban", "request": "launch", "rawTrace": "${workspaceFolder}/../test/fixtures/control-branch.trace.jsonl", - "wasmPath": "${workspaceFolder}/../test/fixtures/control-debug.wasm", - "function": "branch" + "wasmPath": "${workspaceFolder}/../test/fixtures/control-debug.wasm" }, { "name": "Soroban: Debug control while_call(3) [live pipeline]", "type": "soroban", "request": "launch", - "contract": "${workspaceFolder}/control", - "function": "while_call", - "args": [ - { "value": 3, "type": "u32" } + "transactions": [ + { "kind": "deploy", "id": "control", "contract": "${workspaceFolder}/control" }, + { "kind": "invoke", "contract": "control", "function": "while_call", "args": { "n": 3 } } ] } ] diff --git a/examples/README.md b/examples/README.md index faa1a93..ef05539 100644 --- a/examples/README.md +++ b/examples/README.md @@ -33,7 +33,7 @@ so you have a real Soroban project to debug immediately. demonstrates full **Rust source mapping** offline. Each contract is an independent crate (its own `Cargo.toml`/`Cargo.lock`/ -`target/`), which is what the extension's `contract` launch attribute expects. +`target/`), which is what a `deploy` step's `contract` field expects. ## Try it @@ -61,8 +61,8 @@ Open the Run and Debug view and pick a config from `.vscode/launch.json`: stepping working), spawns komet-node, deploys, invokes, and replays the resulting trace with Rust source mapping. Requires the toolchain (Rust + wasm target, Stellar CLI, komet-node), all present in the devcontainer. Set - `"debugInfo": false` in a launch config to opt out of the debug build (you - then debug at the wasm level only). + `"debugInfo": false` on a `deploy` step to opt out of the debug build (you + then debug that contract at the wasm level only). Note: komet-node's tracer stops at instructions it cannot decode (it reports them as `unknown`, e.g. `if`), so a trace can end before the invocation does — @@ -71,4 +71,20 @@ depending on codegen, some contracts replay only partially. ## Adding your own contract Drop a new crate directory here (with a `Cargo.toml` exposing a `#[contract]`), -then add a launch config pointing `contract` at it and naming the `function`. +then add a launch config whose `transactions` deploy it and invoke a function: + +```jsonc +{ + "type": "soroban", + "request": "launch", + "name": "Soroban: Debug my_fn", + "transactions": [ + { "kind": "deploy", "id": "mine", "contract": "${workspaceFolder}/my_crate" }, + { "kind": "invoke", "contract": "mine", "function": "my_fn", "args": { "x": 1 } } + ] +} +``` + +See [`../docs/debug-config.md`](../docs/debug-config.md) for the full +configuration reference (multi-contract systems, composite argument types, +`trace` selection, and offline replay). diff --git a/package.json b/package.json index f340477..eb8c435 100644 --- a/package.json +++ b/package.json @@ -57,64 +57,74 @@ ], "configurationAttributes": { "launch": { - "required": [ - "function" - ], "properties": { - "contract": { - "type": "string", - "description": "Path to the contract crate directory (containing Cargo.toml). Used to build the wasm.", - "default": "${workspaceFolder}" - }, - "wasmPath": { - "type": "string", - "description": "Path to a prebuilt contract .wasm. Overrides building from `contract`." - }, - "buildCommand": { - "type": "string", - "description": "Command used to build the contract wasm. Overrides the `soroban.stellar.path` setting. Defaults to ` contract build`." - }, - "function": { - "type": "string", - "description": "Name of the contract function to invoke and debug." - }, - "args": { + "transactions": { "type": "array", - "description": "Arguments to the function, each as { value, type }.", + "markdownDescription": "Live mode: an ordered, non-empty sequence of `deploy` / `invoke` steps run against one fresh local ledger. State written by one step is visible to later steps. See [the debug-config reference](https://github.com/runtimeverification/stellar-debugger/blob/main/docs/debug-config.md).", "items": { "type": "object", "required": [ - "value", - "type" + "kind" ], "properties": { - "value": { - "description": "The argument value (JSON)." - }, - "type": { + "kind": { "type": "string", - "description": "ScVal type.", "enum": [ - "bool", - "u32", - "i32", - "u64", - "i64", - "u128", - "i128", - "u256", - "i256", - "symbol", - "string", - "bytes", - "address", - "vec", - "map" - ] + "deploy", + "invoke" + ], + "enumDescriptions": [ + "Upload a contract and register a handle id.", + "Call a function on a deployed handle." + ], + "description": "Step kind." + }, + "id": { + "type": "string", + "markdownDescription": "`deploy`: handle name that later `invoke` steps reference and that `trace` can select (must be unique). `invoke`: optional label so `trace` can select this transaction." + }, + "contract": { + "type": "string", + "markdownDescription": "`deploy`: path to a contract crate directory (containing `Cargo.toml`) to build. `invoke`: handle `id` of an earlier `deploy` step." + }, + "wasm": { + "type": "string", + "markdownDescription": "`deploy` only: path to a prebuilt `.wasm`. Overrides building from `contract`." + }, + "buildCommand": { + "type": "string", + "markdownDescription": "`deploy` only: command used to build a `contract` directory. Overrides the `soroban.stellar.path` setting. Defaults to ` contract build`." + }, + "debugInfo": { + "type": "boolean", + "markdownDescription": "`deploy` only: build with DWARF debug info for Rust source mapping (default `true`). Set `false` to debug at the wasm level only.", + "default": true + }, + "function": { + "type": "string", + "description": "invoke only: name of the contract function to call." + }, + "args": { + "markdownDescription": "`invoke` only: arguments. Either an object keyed by the function's parameter names (composites, enums `{ \"tag\": ... }`, tuples/vecs as arrays, `i128` as a decimal string, addresses as `G…`/`C…` strings), or the positional form `[{ \"type\", \"value\" }]`. The tokens `${sourceAddress}` and `${contract:}` are expanded inside string values." } } - }, - "default": [] + } + }, + "trace": { + "type": [ + "string", + "number" + ], + "markdownDescription": "Which transaction feeds the debug session: `\"last\"` (default), a 0-based index into `transactions`, or a step `id` (a deploy's `id` or an invoke's optional `id`).", + "default": "last" + }, + "rawTrace": { + "type": "string", + "description": "Replay mode: path to a precomputed JSONL trace file to replay (skips all RPC and needs no toolchain)." + }, + "wasmPath": { + "type": "string", + "markdownDescription": "Replay mode only: a `.wasm` supplying disassembly and DWARF source mapping for the replayed `rawTrace`." }, "node": { "type": "object", @@ -147,15 +157,6 @@ "sourceSecret": { "type": "string", "description": "Optional source account secret (S...). If omitted, a fresh account is seeded." - }, - "rawTrace": { - "type": "string", - "description": "Attach mode: path to a precomputed JSONL trace file to replay (skips all RPC)." - }, - "debugInfo": { - "type": "boolean", - "default": true, - "description": "Build the contract with DWARF debug info so the debugger can map execution to Rust source files and lines. Set false to build untouched and debug at the wasm level only." } } } @@ -165,16 +166,20 @@ "type": "soroban", "request": "launch", "name": "Soroban: Debug add(1, 2)", - "contract": "${workspaceFolder}", - "function": "add", - "args": [ + "transactions": [ { - "value": 1, - "type": "u32" + "kind": "deploy", + "id": "contract", + "contract": "${workspaceFolder}" }, { - "value": 2, - "type": "u32" + "kind": "invoke", + "contract": "contract", + "function": "add", + "args": { + "a": 1, + "b": 2 + } } ] } @@ -187,9 +192,19 @@ "type": "soroban", "request": "launch", "name": "Soroban: Debug ${1:function}", - "contract": "^\"\\${workspaceFolder}\"", - "function": "${1:function}", - "args": [] + "transactions": [ + { + "kind": "deploy", + "id": "contract", + "contract": "^\"\\${workspaceFolder}\"" + }, + { + "kind": "invoke", + "contract": "contract", + "function": "${1:function}", + "args": {} + } + ] } } ] diff --git a/src/debugAdapter/types.ts b/src/debugAdapter/types.ts index 21a82e4..e6b1977 100644 --- a/src/debugAdapter/types.ts +++ b/src/debugAdapter/types.ts @@ -10,30 +10,22 @@ import { TraceModel } from './TraceModel'; import { SourceMapper } from '../sourcemap/SourceMapper'; import { VariableResolver } from '../sourcemap/VariableResolver'; import { Disassembly } from '../wasm/Disassembly'; -import { ScValArg } from '../soroban/scval'; +import { TxStep, TraceSelector } from '../pipeline/config'; /** Attributes of a `soroban` launch configuration (mirrors package.json). */ export interface SorobanLaunchArgs extends DebugProtocol.LaunchRequestArguments { - /** Path to the contract crate (containing Cargo.toml). */ - contract?: string; /** - * Path to a prebuilt .wasm (overrides building from `contract`). Also used - * with `rawTrace` for offline symbol-rich replay: the wasm supplies the - * disassembly and DWARF source mapping for a canned trace. + * The ordered transaction sequence (deploy / invoke steps) to run. Build + * options and function/args live on the individual steps. */ - wasmPath?: string; - /** Build command used to produce the wasm. */ - buildCommand?: string; + transactions?: TxStep[]; + /** Selects which submitted transaction feeds the debug session. */ + trace?: TraceSelector; /** - * Build the contract with DWARF debug info (default true): injects the - * cargo profile overrides that keep line tables in the wasm, enabling Rust - * source mapping. Set false to build untouched and debug at the wasm level. + * REPLAY mode only: the `.wasm` supplying disassembly + DWARF source mapping + * for a canned `rawTrace`, enabling offline symbol-rich replay. */ - debugInfo?: boolean; - /** Function to invoke. */ - function: string; - /** Declarative function arguments. */ - args?: ScValArg[]; + wasmPath?: string; /** komet-node connection / spawn settings. */ node?: { attach?: boolean; diff --git a/src/extension.ts b/src/extension.ts index 9c4a08e..d5bbfbf 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -52,13 +52,10 @@ class SorobanConfigurationProvider implements vscode.DebugConfigurationProvider if (config.request === undefined) { config.request = 'launch'; } - if (!config.contract && !config.wasmPath && !config.rawTrace && folder) { - config.contract = folder.uri.fsPath; - } applyBinaryPaths(config, folder); - if (!config.function && !config.rawTrace) { + if (!config.rawTrace && !Array.isArray(config.transactions)) { return vscode.window - .showErrorMessage('Soroban debug: a `function` (or a `rawTrace` file) is required in the launch configuration.') + .showErrorMessage('Soroban debug: a `transactions` array (or a `rawTrace` file) is required in the launch configuration.') .then(() => undefined); } return config; @@ -85,9 +82,17 @@ function applyBinaryPaths( if (!config.node.command) { config.node.command = kometNodePath; } - // The build command runs through a shell, so quote a path that contains spaces. - if (!config.buildCommand) { - config.buildCommand = `${quoteForShell(stellarPath)} contract build`; + // Inject the resolved build command into any `deploy` step that doesn't set + // its own, so the `soroban.stellar.path` setting still applies under the + // `transactions` schema. The build command runs through a shell, so quote a + // path that contains spaces. + const buildCommand = `${quoteForShell(stellarPath)} contract build`; + if (Array.isArray(config.transactions)) { + for (const step of config.transactions) { + if (step && step.kind === 'deploy' && !step.buildCommand) { + step.buildCommand = buildCommand; + } + } } } @@ -112,8 +117,9 @@ async function startDebugFromActiveEditor(): Promise { type: 'soroban', request: 'launch', name: `Soroban: Debug ${fn}`, - contract: folder.uri.fsPath, - function: fn, - args: [], + transactions: [ + { kind: 'deploy', id: 'contract', contract: folder.uri.fsPath }, + { kind: 'invoke', contract: 'contract', function: fn }, + ], }); } diff --git a/src/pipeline/TurnkeyPipeline.ts b/src/pipeline/TurnkeyPipeline.ts index 665d31d..9a6eb02 100644 --- a/src/pipeline/TurnkeyPipeline.ts +++ b/src/pipeline/TurnkeyPipeline.ts @@ -4,12 +4,10 @@ * * (spawn + health-check komet-node) -> normalizeConfig -> SequenceRunner.run * - * Both the legacy single-invoke config (`function` + `args` + `wasmPath`/ - * `contract`) and the new `transactions` sequence config flow through this one - * path: `normalizeConfig` (M1) folds either shape into a canonical - * `{ steps, trace }`, and the `SequenceRunner` (M3) executes it against one - * accumulating komet-node ledger, never throwing on a FAILED tx and fetching - * the traced step's trace regardless of status. + * `normalizeConfig` (M1) folds the `transactions` sequence config into a + * canonical `{ steps, trace }`, and the `SequenceRunner` (M3) executes it + * against one accumulating komet-node ledger, never throwing on a FAILED tx and + * fetching the traced step's trace regardless of status. * * In `attach` mode the spawn step is skipped and the pipeline talks to an * already-running node. @@ -34,8 +32,8 @@ export class TurnkeyPipeline { const host = args.node?.host ?? 'localhost'; const port = args.node?.port ?? 8000; - // 1. Normalize the launch config (legacy OR new `transactions`) into a - // canonical `{ steps, trace }`. This also validates it before we spawn. + // 1. Normalize the launch config's `transactions` into a canonical + // `{ steps, trace }`. This also validates it before we spawn. const normalized = normalizeConfig(args as RawLaunchConfig); // 2. Spawn komet-node unless attaching to a running one. diff --git a/src/pipeline/config.ts b/src/pipeline/config.ts index e9ea47e..b269b96 100644 --- a/src/pipeline/config.ts +++ b/src/pipeline/config.ts @@ -2,16 +2,12 @@ * Normalization of a `soroban` launch configuration into a canonical, * ordered transaction sequence. * - * The debugger accepts two config flavors: + * A launch config carries an explicit `transactions` array (deploy / invoke + * steps) plus a `trace` selector naming which submitted tx feeds the session. * - * - the NEW schema: an explicit `transactions` array (deploy / invoke steps) - * plus a `trace` selector naming which submitted tx feeds the session; - * - the LEGACY single-invoke schema: a top-level `function` + `args` with a - * `wasmPath`/`contract` source, implicitly "deploy then invoke". - * - * `normalizeConfig` folds BOTH into one canonical `{ steps, trace }` shape: - * an ordered `TxStep[]` and a `trace` RESOLVED to a 0-based index into it. - * It validates handle references, deploy-id uniqueness and the trace selector, + * `normalizeConfig` folds it into one canonical `{ steps, trace }` shape: an + * ordered `TxStep[]` and a `trace` RESOLVED to a 0-based index into it. It + * validates handle references, deploy-id uniqueness and the trace selector, * throwing on any inconsistency. * * This is a PURE function: no filesystem, no network, no mutation of its input. @@ -19,16 +15,14 @@ * through untouched (later milestones encode them). */ -import { SorobanLaunchArgs } from '../debugAdapter/types'; - /** A deploy step: upload + create, registering a handle `id -> contractId`. */ export interface DeployStep { kind: 'deploy'; /** Handle id later invoke steps reference via their `contract` field. */ id: string; - /** Path to a prebuilt `.wasm` (new-schema `wasm` / legacy `wasmPath`). */ + /** Path to a prebuilt `.wasm`. */ wasm?: string; - /** Path to a contract crate dir to build (new-schema / legacy `contract`). */ + /** Path to a contract crate dir to build. */ contract?: string; /** Build command for a `contract`-dir build (default `stellar contract build`). */ buildCommand?: string; @@ -49,8 +43,9 @@ export interface InvokeStep { */ id?: string; /** - * New schema: object keyed by spec param names. Legacy: `{type,value}[]`. - * Carried through verbatim at this milestone (no encoding yet). + * A named object keyed by spec param names, OR the positional + * `{type,value}[]` encoding. This is an arg ENCODING choice, not a legacy + * format. Carried through verbatim at this milestone (no encoding yet). */ args?: unknown; } @@ -70,36 +65,31 @@ export interface NormalizedConfig { trace: number; } -/** The default handle id used when desugaring legacy single-invoke config. */ -const DEFAULT_HANDLE = '__default'; - /** - * Raw config as authored. Extends the legacy `SorobanLaunchArgs` with the new - * `transactions` + `trace` fields. Fully optional/loose: `normalizeConfig` - * validates it. + * Raw config as authored: a `transactions` array plus a `trace` selector. + * Fully optional/loose: `normalizeConfig` validates it. */ -export interface RawLaunchConfig extends Partial { +export interface RawLaunchConfig { transactions?: unknown[]; trace?: TraceSelector; } /** - * Fold a raw launch config (new OR legacy schema) into the canonical - * `{ steps, trace }` shape. Throws on any structural or referential error. + * Fold a raw launch config into the canonical `{ steps, trace }` shape. Throws + * on any structural or referential error. */ export function normalizeConfig(raw: RawLaunchConfig): NormalizedConfig { - const hasTransactions = Array.isArray(raw?.transactions); - const hasLegacyFunction = typeof raw?.function === 'string'; - - if (hasTransactions && hasLegacyFunction) { + if (raw && 'function' in raw) { throw new Error( - 'invalid config: use either the legacy `function` or the new `transactions`, not both', + 'invalid config: the legacy single-invoke `function` config has been removed — ' + + 'wrap it in a `transactions` array (see docs/debug-config.md)', ); } + if (!Array.isArray(raw?.transactions)) { + throw new Error('invalid config: a `transactions` array is required'); + } - const steps = hasTransactions - ? parseTransactions(raw.transactions as unknown[]) - : desugarLegacy(raw); + const steps = parseTransactions(raw.transactions); validateHandles(steps); @@ -179,47 +169,6 @@ function parseStep(tx: unknown, index: number): TxStep { ); } -/** - * Desugar the legacy single-invoke schema into a canonical two-step sequence: - * `[deploy __default, invoke __default]`. Legacy `args` (a `{type,value}[]`) - * are carried through untouched. - */ -function desugarLegacy(raw: RawLaunchConfig): TxStep[] { - if (typeof raw?.function !== 'string' || raw.function.length === 0) { - throw new Error( - 'invalid config: provide either a legacy `function` (+ `wasmPath`/`contract`) or a `transactions` array', - ); - } - - const deploy: DeployStep = { kind: 'deploy', id: DEFAULT_HANDLE }; - if (typeof raw.wasmPath === 'string') { - deploy.wasm = raw.wasmPath; - } - if (typeof raw.contract === 'string') { - deploy.contract = raw.contract; - } - if (typeof raw.buildCommand === 'string') { - deploy.buildCommand = raw.buildCommand; - } - if (typeof raw.debugInfo === 'boolean') { - deploy.debugInfo = raw.debugInfo; - } - if (deploy.wasm === undefined && deploy.contract === undefined) { - throw new Error('invalid config: legacy config needs a `wasmPath` or a `contract` dir'); - } - - const invoke: InvokeStep = { - kind: 'invoke', - contract: DEFAULT_HANDLE, - function: raw.function, - }; - if ('args' in raw) { - invoke.args = raw.args; - } - - return [deploy, invoke]; -} - /** * Validate that deploy ids are unique and every invoke references a deploy id * declared BEFORE it in the sequence. diff --git a/src/trace/cliArgs.ts b/src/trace/cliArgs.ts index c2de16c..d9e7d53 100644 --- a/src/trace/cliArgs.ts +++ b/src/trace/cliArgs.ts @@ -42,6 +42,10 @@ export type TraceParse = | { kind: 'run'; launch: SorobanLaunchArgs; + /** Echoed for the trace's meta record: the invoked function (live mode). */ + function?: string; + /** Echoed for the trace's meta record: the symbol-supplying wasm. */ + wasm?: string; out?: string; opts: { maxDepth?: number; maxChildren?: number; allowNoSource?: boolean }; }; @@ -149,18 +153,38 @@ export function parseTraceArgs(argv: string[]): TraceParse { return err('--max-children must be a non-negative integer.'); } - const launch: SorobanLaunchArgs = { - function: fn ?? '', - rawTrace, - wasmPath, - contract, - ...(args !== undefined ? { args } : {}), - }; + // Build the launch config mode-aware. In REPLAY mode (`--raw-trace`) the + // config stays a top-level `rawTrace` (+ optional replay-symbol `wasmPath`). + // In LIVE mode the CLI is a single-invoke front end that desugars to the one + // `transactions` schema: a deploy of the `--contract`/`--wasm` source under a + // fixed handle, then an invoke of `--function` (guaranteed present here by the + // "--function is required in live mode" guard above). + let launch: SorobanLaunchArgs; + if (rawTrace !== undefined) { + launch = { rawTrace, ...(wasmPath !== undefined ? { wasmPath } : {}) }; + } else { + launch = { + transactions: [ + { + kind: 'deploy', + id: 'contract', + ...(wasmPath !== undefined ? { wasm: wasmPath } : {}), + ...(contract !== undefined ? { contract } : {}), + }, + { + kind: 'invoke', + contract: 'contract', + function: fn!, + ...(args !== undefined ? { args } : {}), + }, + ], + }; + } const opts: { maxDepth?: number; maxChildren?: number; allowNoSource?: boolean } = {}; if (depth !== undefined) opts.maxDepth = Number(depth); if (maxChildren !== undefined) opts.maxChildren = Number(maxChildren); if (allowNoSource) opts.allowNoSource = true; - return { kind: 'run', launch, out, opts }; + return { kind: 'run', launch, function: fn, wasm: wasmPath, out, opts }; } diff --git a/src/trace/main.ts b/src/trace/main.ts index 95d4e4f..0dd3011 100644 --- a/src/trace/main.ts +++ b/src/trace/main.ts @@ -31,8 +31,8 @@ async function main(): Promise { try { const resolved = await backend.resolve(launch, (msg) => process.stderr.write(msg + '\n')); const lines = runCliTrace(resolved, { - function: launch.function, - wasm: launch.wasmPath, + function: p.function, + wasm: p.wasm, ...opts, }); const output = lines.join('\n') + '\n'; diff --git a/test/backendFor.test.ts b/test/backendFor.test.ts index 5c7d2c1..5b63bc6 100644 --- a/test/backendFor.test.ts +++ b/test/backendFor.test.ts @@ -29,7 +29,9 @@ describe('backendFor (docs/trace-cli-internal.md, backend selection)', () => { }); it('selects LiveBackend when there is no rawTrace', () => { - const backend = backendFor({ function: 'add' } as any); + const backend = backendFor({ + transactions: [{ kind: 'deploy', id: 'c', wasm: 'x.wasm' }], + } as any); assert.ok( backend instanceof LiveBackend, 'expected a LiveBackend for a live (no rawTrace) launch', diff --git a/test/dapControlStepping.test.ts b/test/dapControlStepping.test.ts index e03ecaa..914b24c 100644 --- a/test/dapControlStepping.test.ts +++ b/test/dapControlStepping.test.ts @@ -29,8 +29,8 @@ const CONTROL_WASM = path.join(FIXTURES, 'control-debug.wasm'); const CONTROL_LIB_SUFFIX = 'examples/control/src/lib.rs'; /** Launch args for one control construct's trace, replayed with symbols. */ -function control(fn: string): { rawTrace: string; wasmPath: string; function: string } { - return { rawTrace: path.join(FIXTURES, `control-${fn}.trace.jsonl`), wasmPath: CONTROL_WASM, function: fn }; +function control(fn: string): { rawTrace: string; wasmPath: string } { + return { rawTrace: path.join(FIXTURES, `control-${fn}.trace.jsonl`), wasmPath: CONTROL_WASM }; } const THREAD = { threadId: 1 }; diff --git a/test/integration.node.test.ts b/test/integration.node.test.ts index 464232f..28bf728 100644 --- a/test/integration.node.test.ts +++ b/test/integration.node.test.ts @@ -18,6 +18,7 @@ import * as assert from 'assert'; import * as path from 'path'; import { spawnSync } from 'child_process'; import { TurnkeyPipeline } from '../src/pipeline/TurnkeyPipeline'; +import { SorobanLaunchArgs } from '../src/debugAdapter/types'; import { MemoryImage } from '../src/debugAdapter/MemoryImage'; import { makeRuntimeState } from '../src/debugAdapter/runtimeState'; import { evalLocation } from '../src/dwarf/locexpr'; @@ -61,15 +62,21 @@ describe('TurnkeyPipeline (real komet-node)', function () { try { const resolved = await pipeline.run( { - wasmPath: WASM, - function: 'add', - args: [ - { value: 5, type: 'u32' }, - { value: 6, type: 'u32' }, + transactions: [ + { kind: 'deploy', id: 'c', wasm: WASM }, + { + kind: 'invoke', + contract: 'c', + function: 'add', + args: [ + { value: 5, type: 'u32' }, + { value: 6, type: 'u32' }, + ], + }, ], // attach:false -> the pipeline spawns komet-node itself. node: nodeConfig(), - }, + } as unknown as SorobanLaunchArgs, (msg) => console.log(msg), ); @@ -107,7 +114,13 @@ describe('TurnkeyPipeline (real komet-node)', function () { const pipeline = new TurnkeyPipeline(); try { const resolved = await pipeline.run( - { wasmPath: INCREMENT_WASM, function: 'increment', args: [{ value: 5, type: 'u32' }], node: nodeConfig() }, + { + transactions: [ + { kind: 'deploy', id: 'c', wasm: INCREMENT_WASM }, + { kind: 'invoke', contract: 'c', function: 'increment', args: [{ value: 5, type: 'u32' }] }, + ], + node: nodeConfig(), + } as unknown as SorobanLaunchArgs, (msg) => console.log(msg), ); assert.ok(resolved.model.length > 0, 'expected a non-empty trace'); diff --git a/test/liveBackend.test.ts b/test/liveBackend.test.ts index d16022e..3c30c9a 100644 --- a/test/liveBackend.test.ts +++ b/test/liveBackend.test.ts @@ -3,6 +3,7 @@ import { promises as fs } from 'fs'; import * as path from 'path'; import { Keypair } from '@stellar/stellar-sdk'; import { LiveBackend } from '../src/debugAdapter/backends/LiveBackend'; +import { SorobanLaunchArgs } from '../src/debugAdapter/types'; import { MockKometNode } from './support/mockKometNode'; const FIXTURES = path.join(__dirname, '..', '..', 'test', 'fixtures'); @@ -35,15 +36,21 @@ describe('LiveBackend (against mock komet-node)', () => { const backend = new LiveBackend(); const resolved = await backend.resolve( { - wasmPath: WASM, - function: 'add', - args: [ - { value: 5, type: 'u32' }, - { value: 6, type: 'u32' }, + transactions: [ + { kind: 'deploy', id: 'c', wasm: WASM }, + { + kind: 'invoke', + contract: 'c', + function: 'add', + args: [ + { value: 5, type: 'u32' }, + { value: 6, type: 'u32' }, + ], + }, ], sourceSecret: FIXED_SECRET, node: { attach: true, host: '127.0.0.1', port }, - }, + } as unknown as SorobanLaunchArgs, () => undefined, ); diff --git a/test/multitxConfig.test.ts b/test/multitxConfig.test.ts index 8913d1f..c797e7e 100644 --- a/test/multitxConfig.test.ts +++ b/test/multitxConfig.test.ts @@ -1,15 +1,15 @@ /** - * M1 acceptance tests for the multi-transaction debug-config normalizer. + * Acceptance tests for the multi-transaction debug-config normalizer. * - * Pins the "M1 acceptance" section of the spec: a PURE function - * `normalizeConfig(raw)` (implementer's home: src/pipeline/config.ts) that turns - * BOTH the new `transactions` + `trace` schema AND the legacy single-invoke - * config into one canonical `{ steps, trace }` shape, with the listed validation - * rejections. + * Pins a PURE function `normalizeConfig(raw)` (implementer's home: + * src/pipeline/config.ts) that turns the single `transactions` + `trace` schema + * into one canonical `{ steps, trace }` shape, with the listed validation + * rejections. There is exactly ONE live config format: an ordered, non-empty + * `transactions` array (the legacy single-invoke top-level config has been + * REMOVED). * * This module is intentionally IO-free (no network, no filesystem) so the suite - * is deterministic. It imports from the not-yet-existing implementer module on - * purpose (TDD red phase). + * is deterministic. */ import * as assert from 'assert'; @@ -23,13 +23,13 @@ import { normalizeConfig } from '../src/pipeline/config'; interface DeployStep { kind: 'deploy'; id: string; - /** New-schema `wasm` path, or the legacy `wasmPath`, in canonical `wasm`. */ + /** Prebuilt `.wasm` path. */ wasm?: string; - /** Contract crate dir (legacy `contract` / new-schema `contract`). */ + /** Contract crate dir (built to a wasm). */ contract?: string; - /** OPTIONAL (M4): build command threaded into a `contract`-dir build. */ + /** OPTIONAL: build command threaded into a `contract`-dir build. */ buildCommand?: string; - /** OPTIONAL (M4): inject DWARF debug info when building from a crate dir. */ + /** OPTIONAL: inject DWARF debug info when building from a crate dir. */ debugInfo?: boolean; } @@ -38,9 +38,9 @@ interface InvokeStep { /** Handle id referencing a prior deploy step (NOT yet a live contractId). */ contract: string; function: string; - /** New: object keyed by spec param names. Legacy: `{type,value}[]`. Passed through untouched at M1. */ + /** A named object keyed by spec param names, or the positional `{type,value}[]` encoding. Passed through untouched. */ args?: unknown; - /** OPTIONAL (M3 extension): a trace selector may match this invoke id. */ + /** OPTIONAL: a trace selector may match this invoke id. */ id?: string; } @@ -114,65 +114,7 @@ describe('M1 normalizeConfig', () => { }); // ------------------------------------------------------------------------ - // 2. Legacy single-invoke config → SAME canonical shape (desugaring). - // ------------------------------------------------------------------------ - describe('legacy single-invoke desugaring', () => { - const legacyWasm = { - type: 'soroban', - request: 'launch', - function: 'add', - args: [ - { value: 5, type: 'u32' }, - { value: 6, type: 'u32' }, - ], - wasmPath: '/abs/x.wasm', - }; - - it('desugars to [deploy __default, invoke on __default]', () => { - const { steps } = norm(legacyWasm); - assert.strictEqual(steps.length, 2); - - const deploy = steps[0] as DeployStep; - assert.strictEqual(deploy.kind, 'deploy'); - assert.strictEqual(deploy.id, '__default'); - assert.strictEqual(deploy.wasm, '/abs/x.wasm'); - - const invoke = steps[1] as InvokeStep; - assert.strictEqual(invoke.kind, 'invoke'); - assert.strictEqual(invoke.contract, '__default'); - assert.strictEqual(invoke.function, 'add'); - }); - - it('carries the legacy {type,value}[] args through untouched', () => { - const invoke = norm(legacyWasm).steps[1] as InvokeStep; - assert.deepStrictEqual(invoke.args, [ - { value: 5, type: 'u32' }, - { value: 6, type: 'u32' }, - ]); - }); - - it('desugars a legacy `contract` crate dir into the deploy step', () => { - const legacyDir = { - type: 'soroban', - request: 'launch', - function: 'increment', - args: [{ value: 1, type: 'u32' }], - contract: '/abs/crate', - }; - const deploy = norm(legacyDir).steps[0] as DeployStep; - assert.strictEqual(deploy.kind, 'deploy'); - assert.strictEqual(deploy.id, '__default'); - assert.strictEqual(deploy.contract, '/abs/crate'); - }); - - it('defaults the legacy trace to the last step ("last")', () => { - // No `trace` key → default "last" → the invoke at index 1. - assert.strictEqual(norm(legacyWasm).trace, 1); - }); - }); - - // ------------------------------------------------------------------------ - // 3. Trace selector resolution: "last" | index | id → 0-based position. + // 2. Trace selector resolution: "last" | index | id → 0-based position. // ------------------------------------------------------------------------ describe('trace selector resolution', () => { const threeSteps = { @@ -208,7 +150,7 @@ describe('M1 normalizeConfig', () => { }); // ------------------------------------------------------------------------ - // 4. Validation rejections (spec point 4). + // 3. Validation rejections. // ------------------------------------------------------------------------ describe('validation rejections', () => { it('rejects an empty `transactions` array', () => { @@ -277,21 +219,51 @@ describe('M1 normalizeConfig', () => { ); }); - it('rejects a config carrying BOTH legacy `function` and `transactions`', () => { - assert.throws(() => - norm({ - type: 'soroban', - request: 'launch', - function: 'add', - args: [{ value: 1, type: 'u32' }], - transactions: [{ kind: 'deploy', id: 'a', wasm: '/a.wasm' }], - }), + it('rejects a config with no `transactions` array', () => { + assert.throws(() => norm({ type: 'soroban', request: 'launch' })); + }); + + it('rejects a leftover legacy top-level `function` with a migration error', () => { + // The legacy single-invoke config was REMOVED. This config is WELL-FORMED + // by the OLD rules (a `function` + a `wasmPath` source the old desugar + // would have ACCEPTED), so it distinguishes new behavior from old: under + // the removed legacy path it normalized cleanly; now it must fail with a + // clear migration message pointing at the `transactions` array. + assert.throws( + () => + norm({ + type: 'soroban', + request: 'launch', + function: 'add', + args: [{ value: 1, type: 'u32' }], + wasmPath: '/abs/x.wasm', + }), + /legacy|transactions/i, + ); + }); + + it('rejects a stray legacy top-level `function` even when `transactions` is present', () => { + // A half-migrated config (a valid `transactions` array plus a leftover + // top-level `function`) must be caught, not silently misread by ignoring + // the stray `function`. + assert.throws( + () => + norm({ + type: 'soroban', + request: 'launch', + function: 'add', + transactions: [ + { kind: 'deploy', id: 'a', wasm: '/a.wasm' }, + { kind: 'invoke', contract: 'a', function: 'run' }, + ], + }), + /legacy|transactions/i, ); }); }); // ------------------------------------------------------------------------ - // 5. Purity: same input in, same output out; no shared mutable state. + // 4. Purity: same input in, same output out; no shared mutable state. // ------------------------------------------------------------------------ describe('purity', () => { it('is referentially transparent for the same input', () => { @@ -323,11 +295,11 @@ describe('M1 normalizeConfig', () => { }); // ------------------------------------------------------------------------ - // 6. (M3 extension) Optional invoke `id` + trace-by-invoke-id. + // 5. Optional invoke `id` + trace-by-invoke-id. // An invoke MAY carry an `id`; the `trace` string selector then matches an // invoke id, not just a deploy id. All existing selector behaviour stays. // ------------------------------------------------------------------------ - describe('optional invoke id + trace-by-invoke-id (M3 extension)', () => { + describe('optional invoke id + trace-by-invoke-id', () => { const raw = { type: 'soroban', request: 'launch', @@ -379,54 +351,10 @@ describe('M1 normalizeConfig', () => { }); // ------------------------------------------------------------------------ - // 7. (M4 extension) DeployStep gains optional build controls, and legacy - // desugaring populates them from the raw config so a `contract`-dir build - // keeps its build command + debug-info injection (previously dropped). + // 6. DeployStep carries optional build controls (buildCommand + debugInfo) + // so a `contract`-dir build keeps its build command + debug-info injection. // ------------------------------------------------------------------------ - describe('deploy build options: buildCommand + debugInfo (M4 extension)', () => { - it('desugars legacy `buildCommand` + `debugInfo` onto the __default deploy step', () => { - const deploy = norm({ - type: 'soroban', - request: 'launch', - function: 'increment', - args: [{ value: 1, type: 'u32' }], - contract: '/abs/crate', - buildCommand: 'stellar contract build', - debugInfo: true, - }).steps[0] as DeployStep; - - assert.strictEqual(deploy.kind, 'deploy'); - assert.strictEqual(deploy.contract, '/abs/crate'); - assert.strictEqual(deploy.buildCommand, 'stellar contract build'); - assert.strictEqual(deploy.debugInfo, true); - }); - - it('carries a legacy `debugInfo: false` through verbatim (not conflated with unset)', () => { - const deploy = norm({ - type: 'soroban', - request: 'launch', - function: 'increment', - args: [{ value: 1, type: 'u32' }], - contract: '/abs/crate', - debugInfo: false, - }).steps[0] as DeployStep; - - assert.strictEqual(deploy.debugInfo, false); - }); - - it('leaves both build options undefined when the legacy config omits them', () => { - const deploy = norm({ - type: 'soroban', - request: 'launch', - function: 'add', - args: [{ value: 5, type: 'u32' }], - wasmPath: '/abs/x.wasm', - }).steps[0] as DeployStep; - - assert.strictEqual(deploy.buildCommand, undefined); - assert.strictEqual(deploy.debugInfo, undefined); - }); - + describe('deploy build options: buildCommand + debugInfo', () => { it('preserves `buildCommand` + `debugInfo` declared on a new-schema deploy step', () => { const deploy = norm({ type: 'soroban', diff --git a/test/pipeline.test.ts b/test/pipeline.test.ts index ba3c296..2953a4a 100644 --- a/test/pipeline.test.ts +++ b/test/pipeline.test.ts @@ -8,6 +8,7 @@ import { xdr, } from '@stellar/stellar-sdk'; import { TurnkeyPipeline } from '../src/pipeline/TurnkeyPipeline'; +import { SorobanLaunchArgs } from '../src/debugAdapter/types'; import { MockKometNode } from './support/mockKometNode'; import { parseWasmSections } from '../src/wasm/sections'; import { MemoryImage } from '../src/debugAdapter/MemoryImage'; @@ -40,14 +41,20 @@ describe('TurnkeyPipeline (against mock komet-node)', () => { const pipeline = new TurnkeyPipeline(); return pipeline.run( { - wasmPath: WASM, - function: 'add', - args: [ - { value: 5, type: 'u32' }, - { value: 6, type: 'u32' }, + transactions: [ + { kind: 'deploy', id: 'c', wasm: WASM }, + { + kind: 'invoke', + contract: 'c', + function: 'add', + args: [ + { value: 5, type: 'u32' }, + { value: 6, type: 'u32' }, + ], + }, ], node: { attach: true, host: '127.0.0.1', port }, - }, + } as unknown as SorobanLaunchArgs, () => undefined, ); } @@ -161,11 +168,12 @@ describe('TurnkeyPipeline debug-strip + memory-backed variable inspection', () = const pipeline = new TurnkeyPipeline(); return pipeline.run( { - wasmPath: INCR_WASM, - function: 'increment', - args: [{ value: 5, type: 'u32' }], + transactions: [ + { kind: 'deploy', id: 'c', wasm: INCR_WASM }, + { kind: 'invoke', contract: 'c', function: 'increment', args: [{ value: 5, type: 'u32' }] }, + ], node: { attach: true, host: '127.0.0.1', port }, - }, + } as unknown as SorobanLaunchArgs, () => undefined, ); } diff --git a/test/traceCli.test.ts b/test/traceCli.test.ts index 441ddb1..103c1b1 100644 --- a/test/traceCli.test.ts +++ b/test/traceCli.test.ts @@ -104,10 +104,29 @@ describe('parseTraceArgs (M3 CLI devex)', () => { }); describe('live mode (--contract/--wasm + --function)', () => { - it('--contract . --function add → run with launch.contract and launch.function', () => { + it('--contract . --function add → run with a deploy+invoke transactions config', () => { const p = asRun(parseTraceArgs(['--contract', '.', '--function', 'add'])); - assert.strictEqual(p.launch.contract, '.'); - assert.strictEqual(p.launch.function, 'add'); + + // Live mode now builds the single `transactions` schema: a deploy of the + // --contract dir under the fixed handle id 'contract', then an invoke of + // --function against that handle. (Cast through the not-yet-updated + // TraceLaunch type, which the implementer will reshape.) + const launch = p.launch as unknown as { transactions?: unknown[] }; + const steps = launch.transactions!; + assert.strictEqual(steps.length, 2); + + const deploy = steps[0] as { kind: string; id: string; contract?: string }; + assert.strictEqual(deploy.kind, 'deploy'); + assert.strictEqual(deploy.id, 'contract'); + assert.strictEqual(deploy.contract, '.'); + + const invoke = steps[1] as { kind: string; contract: string; function: string }; + assert.strictEqual(invoke.kind, 'invoke'); + assert.strictEqual(invoke.contract, 'contract'); + assert.strictEqual(invoke.function, 'add'); + + // The run result echoes the function name for the trace's meta record. + assert.strictEqual((p as unknown as { function?: string }).function, 'add'); }); }); @@ -119,7 +138,7 @@ describe('parseTraceArgs (M3 CLI devex)', () => { assert.ok(msg.includes('Invalid --args-json'), msg); }); - it('valid JSON array → run with launch.args of the right length', () => { + it('valid JSON array → run with the invoke step carrying the parsed args', () => { const p = asRun( parseTraceArgs([ '--contract', @@ -130,8 +149,10 @@ describe('parseTraceArgs (M3 CLI devex)', () => { '[{"value":1,"type":"u32"}]', ]), ); - assert.ok(p.launch.args, 'launch.args should be present'); - assert.strictEqual(p.launch.args!.length, 1); + const launch = p.launch as unknown as { transactions?: unknown[] }; + const invoke = launch.transactions![1] as { args?: unknown[] }; + assert.ok(invoke.args, 'the invoke step should carry args'); + assert.strictEqual(invoke.args!.length, 1); }); it('valid JSON that is not an array (number) → "expected a JSON array"', () => {