diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 353cd8d9..d7a3bc0e 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -71,11 +71,37 @@ try { The lower-level primitives (`backoffWithJitter`, `CircuitBreaker`, `parseRetryAfter`, `withRetry`) are exported directly for reuse. +### Dry-run status events + +`TransactionQueue` emits status events via `queue.on("status", ...)`. When run +in **dry-run** mode (`run({ dryRun: true })` or a queue-level `dryRun: true`), +each step is simulated but never submitted. The queue still emits a `confirmed` +event at the end of each step to mark it complete, but that event: + +- carries `dryRun: true`, and +- has **no** `hash` (nothing was broadcast). + +Consumers listening for on-chain confirmations should check the `dryRun` flag to +avoid mistaking a simulated flow for a real submission: + +```ts +queue.on("status", (e) => { + if (e.status === "confirmed") { + if (e.dryRun) { + // Simulated — do not show as "submitted to network". + } else { + // Real confirmation — e.hash is present. + } + } +}); +``` + ## API Semantics The SDK exposes two distinct paths for mutative (write) operations: ### 1. `prepare*Tx` (Submittable) + Methods like `prepareCreatePostTx`, `prepareFollowTx`, and `prepareDmKeyTx` are the **intended path for client-side applications**. They fetch the actual account sequence from Horizon, simulate the transaction to discover footprint/fees, and return a base64-encoded `TransactionEnvelope` XDR that is fully ready to be signed (e.g. by Freighter) and submitted to the network. @@ -85,6 +111,7 @@ const txXdr = await client.prepareCreatePostTx("GBFOY...", "Hello!"); ``` ### 2. Base Write Methods (Throwaway XDR) + Methods like `createPost`, `follow`, and `tip` **do not fetch sequence numbers** and return XDR built using a throwaway `Keypair`. **These are not directly submittable.** They exist primarily to easily extract the Soroban `Operation` for batching (e.g., passing to `buildMultiOpTx`) or for server-side queueing where sequence management is handled by a background worker (like `TransactionQueue`). diff --git a/packages/sdk/src/__tests__/queue.test.ts b/packages/sdk/src/__tests__/queue.test.ts index e64f20fb..430d921a 100644 --- a/packages/sdk/src/__tests__/queue.test.ts +++ b/packages/sdk/src/__tests__/queue.test.ts @@ -362,7 +362,26 @@ describe("TransactionQueue", () => { const statuses = events.map((e) => e.status); expect(statuses).toEqual(["pending", "simulated", "confirmed"]); - expect(events.find((e) => e.status === "confirmed")?.resourceFee).toBe("2000"); + + const confirmedEvent = events.find((e) => e.status === "confirmed"); + expect(confirmedEvent?.resourceFee).toBe("2000"); + // Dry-run "confirmed" must be distinguishable from a real confirmation: + expect(confirmedEvent?.dryRun).toBe(true); + expect(confirmedEvent?.hash).toBeUndefined(); + }); + + it("marks real confirmations with dryRun:false (or undefined) and a hash", async () => { + const rpc = makeRpc(); + const queue = new TransactionQueue({ signer: makeSigner(), rpc, pollIntervalMs: 0 }); + const events: TxStatusEvent[] = []; + queue.on("status", (e) => events.push(e)); + queue.enqueue("XDR_LIVE"); + + await queue.run({ dryRun: false }); + + const confirmedEvent = events.find((e) => e.status === "confirmed"); + expect(confirmedEvent?.dryRun).not.toBe(true); + expect(confirmedEvent?.hash).toBeDefined(); }); it("dryRun queue-level default is honoured", async () => { diff --git a/packages/sdk/src/queue.ts b/packages/sdk/src/queue.ts index 809b2db9..a542665b 100644 --- a/packages/sdk/src/queue.ts +++ b/packages/sdk/src/queue.ts @@ -12,6 +12,11 @@ * every step is simulated but never submitted. This is useful for preflight * checks and fee estimation without consuming sequence numbers or fees. * + * In dry-run mode the queue still emits a `confirmed` event at the end of each + * step (as a completion marker), but the event carries `dryRun: true` and no + * `hash`. Consumers can therefore distinguish a simulated `confirmed` from a + * real on-chain confirmation by checking the `dryRun` flag. + * * ### Per-step timeout * `stepTimeoutMs` (config or per-`run()` override) caps the total wall-clock * time spent on a single step (signing + submission + confirmation). When the @@ -32,6 +37,13 @@ export interface TxStatusEvent { error?: string; /** Resource fee returned by simulation (present when status is "simulated" or later). */ resourceFee?: string; + /** + * When `true`, the event reflects a dry-run (simulate-only) execution rather + * than a real on-chain submission. A dry-run `confirmed` event carries no + * `hash`; consumers can use this flag to distinguish simulated success from an + * actual broadcast. + */ + dryRun?: boolean; } export type TxStatusListener = (event: TxStatusEvent) => void; @@ -252,7 +264,8 @@ export class TransactionQueue { * 2. Signs the XDR via the configured signer. * 3. Simulates the signed transaction via `rpc.simulateTransaction` (unless * `skipSimulation` is true). Emits `simulated` on success. - * 4. In `dryRun` mode, stops here and does not submit. + * 4. In `dryRun` mode, stops here and does not submit. Emits a `confirmed` + * event with `dryRun: true` and no hash to mark the step complete. * 5. Submits via `rpc.sendTransaction`. Emits `submitted` with the hash. * 6. Polls `rpc.getTransaction` until `SUCCESS` or failure. Emits `confirmed`. * @@ -365,8 +378,10 @@ export class TransactionQueue { // ── 3. Dry-run exit ────────────────────────────────────────────────────── if (isDryRun) { - // Simulation succeeded; mark as confirmed for tracking purposes (no hash). - this.emit({ index: i, xdr: step.xdr, status: "confirmed", resourceFee }); + // Simulation succeeded; report a dry-run "confirmed" (no hash was + // produced) so callers can track the step without mistaking it for a + // real on-chain confirmation. + this.emit({ index: i, xdr: step.xdr, status: "confirmed", resourceFee, dryRun: true }); completed.push(i); return; }