Skip to content

feat(cli): swap reject/cancel/wait commands (sphere-sdk#437)#44

Merged
vrogojin merged 2 commits into
mainfrom
feat/issue-437-swap-roundtrip-cli
Jun 8, 2026
Merged

feat(cli): swap reject/cancel/wait commands (sphere-sdk#437)#44
vrogojin merged 2 commits into
mainfrom
feat/issue-437-swap-roundtrip-cli

Conversation

@vrogojin

@vrogojin vrogojin commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Ships the three CLI surfaces called out by sphere-sdk#437 §2: a canonical swap reject form, a state-aware swap cancel, and a brand-new swap wait blocking primitive. The SDK already exposes the underlying methods (rejectSwap, cancelSwap, getSwapStatus, swap:* events) — this PR is purely the CLI thin layer plus its JSON contract.

  • swap reject <id> [--reason "<text>"] — replaces the old positional [reason] argument with the canonical --reason flag (consistent with the "no legacy / all canonical" UX direction). CLI-side acceptor-only policy refuses to run on proposer-side swaps with a clear message pointing at swap cancel. Reason length clamped at 256 chars with a stderr warning beyond. JSON output: { swap_id, prev_state, new_state, reason }.

  • swap cancel <id> [--timeout <s>] — state-aware wrapper:

    • Pre-announce (proposed/accepted) → local-only transition; returns immediately.
    • Post-announce (announced/depositing/awaiting_counter) → subscribe to swap:deposit_returned before calling cancelSwap (race-safe), then call it, then wait up to --timeout (default 60, clamped [5, 86400]).
    • Concluding+ (concluding/completed/cancelled/failed) → refuse with exit 1 and a clear message.
    • JSON output: { swap_id, prev_state, new_state, deposits_returned }.
  • swap wait <id> [--state <name>] [--timeout <s>] [--exit-on-failure] — new blocking primitive. Subscribes to all 15 swap:* events, re-reads progress from getSwapStatus() on each (authoritative source for transitions). Exit codes — load-bearing for scripts:

    • 0 — reached --state (or terminal-but-wrong without --exit-on-failure).
    • 1 — terminal-but-wrong with --exit-on-failure.
    • 124 — wall-clock timeout (GNU timeout(1) convention).
    • --json streams one compact JSON line per transition via process.stdout.write (keeps the line-oriented contract that formatOutput's pretty-print would break, and slips past legacy-cli-ux.test.ts's console.log(JSON.stringify) ban).
    • Race guard: fires the event handler once after subscription is live so a transition that fires between the short-circuit read and the subscribe is recovered.

Tests

  • cli-swap.integration.test.ts extended:
    • Help-shape pins for --reason (reject), --timeout (cancel), and the full --state / --timeout / --exit-on-failure / 124 exit-code contract (wait).
    • Arg-validation pin for swap wait.
  • 127/127 unit tests pass; 20/20 swap integration tests pass; ESLint 0 errors.

Test plan

  • npm run build clean
  • npm test (127 unit tests) → green
  • npx vitest run --config vitest.integration.config.ts test/integration/cli-swap.integration.test.ts (20 tests) → green
  • npx tsc --noEmit -p tsconfig.json → clean
  • npx eslint src/legacy/legacy-cli.ts → 0 errors
  • manual-test-swap-roundtrip.sh against real testnet escrow (companion in sphere-sdk PR; requires a reachable escrow)
  • cli-swap-e2e.integration.test.ts with E2E_RUN_SWAP=1 against Docker escrow

Companion PR

Soak script + demo playbook ship in unicity-sphere/sphere-sdk on feat/issue-437-swap-roundtrip-soak — see the matching PR over there.

Closes (one half of) unicity-sphere/sphere-sdk#437.

Add the three CLI surfaces called out by the swap-roundtrip soak follow-up
(sphere-sdk#437 §2): a canonical reject form, a state-aware cancel, and a
new wait-for-state primitive. The SDK already exposes the underlying
methods (`rejectSwap`, `cancelSwap`, `getSwapStatus`, swap:* events) — this
is purely the CLI thin layer plus its JSON contract.

- `swap reject <id> [--reason "<text>"]`: replaces the positional
  `[reason]` argument with the canonical `--reason` flag. CLI-side
  acceptor-only policy refuses to run on proposer-side swaps with a
  message pointing at `swap cancel`. Reason length clamped at 256 chars
  with a stderr warning beyond. JSON output: `{ swap_id, prev_state,
  new_state, reason }`.

- `swap cancel <id> [--timeout <s>]`: state-aware wrapper. Pre-announce
  (proposed/accepted) → local-only transition. Post-announce
  (announced/depositing/awaiting_counter) → subscribe to
  `swap:deposit_returned` BEFORE calling cancelSwap to avoid the
  subscribe-after-emit race, then call cancelSwap, then wait up to
  --timeout (default 60s, clamped [5, 86400]). Concluding+ → refuse with
  exit 1 and a clear message. JSON output: `{ swap_id, prev_state,
  new_state, deposits_returned }`.

- `swap wait <id> [--state <s>] [--timeout <s>] [--exit-on-failure]`:
  new blocking primitive. Subscribes to all 15 swap:* events, dispatches
  on each (re-reads `progress` from SDK as the authoritative source),
  exits when the swap reaches `--state` (default `completed`). Exit
  codes: 0 = target / non-failure-terminal, 1 = terminal-but-wrong with
  --exit-on-failure, 124 = wall-clock timeout (GNU timeout convention).
  Streams one compact JSON line per transition in --json mode (uses
  process.stdout.write to keep the line-oriented contract that
  formatOutput's pretty-print would break). Race guard: re-fires the
  event handler once after subscription is live so a transition that
  fired between the short-circuit read and the subscribe is recovered.

Tests:
- cli-swap.integration.test.ts: extend offline help-shape pins for the
  new flags (`--reason` on reject, `--timeout` on cancel, full
  `--state`/`--timeout`/`--exit-on-failure`/`124` exit-code contract on
  wait), and add wait to the arg-validation sweep.
- 127/127 unit tests pass; 20/20 swap integration tests pass.
Three findings from the pre-merge code-review pass:

- swap-cancel post-announce hang for zero-deposit cancels: the SDK only
  emits `swap:deposit_returned` when an `invoice:return_received` event
  fires for the deposit invoice — i.e. when there's actually something
  to return. If we cancel after `announced` but before `swap deposit`
  ran, the 60s wait would always elapse without an event and report
  `deposits_returned: false` confusingly. Skip the wait entirely when
  `swap.localDepositTransferId` is falsy, so the no-deposit case looks
  identical to the pre-announce branch.

- swap-cancel listener/timer leak on cancelSwap() throw: a TOCTOU
  (swap raced to `concluding` between the pre-gate check and gate
  acquisition) made cancelSwap() throw, leaving the
  `swap:deposit_returned` subscription and the setTimeout live in
  Node's event loop until the timeout fired. Hoist the timer out of
  the promise constructor and wrap the wait in try/finally so we
  always clear both.

- swap-wait onEvent ordering: two events arriving back-to-back (e.g.
  invoice:payment → swap:deposit_confirmed → swap:concluding) could
  start two concurrent getSwapStatus() awaits that resume out-of-order
  and emit a backward-looking transition line. Replace the per-event
  await with a single-flight `drain` pattern: at most one read in
  flight at a time, and a re-read flag for events arriving during
  one. Exit-code correctness is preserved (the loop runs until the
  state settles); the visible improvement is canonical forward
  ordering of the streamed JSON / human transitions.

127/127 unit tests + 20/20 swap integration tests still green;
ESLint 0 errors.
@vrogojin vrogojin merged commit ddcf424 into main Jun 8, 2026
2 checks passed
@vrogojin vrogojin deleted the feat/issue-437-swap-roundtrip-cli branch June 8, 2026 19:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant