feat(cli): swap reject/cancel/wait commands (sphere-sdk#437)#44
Merged
Conversation
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.
4 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ships the three CLI surfaces called out by sphere-sdk#437 §2: a canonical
swap rejectform, a state-awareswap cancel, and a brand-newswap waitblocking 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--reasonflag (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 atswap 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:proposed/accepted) → local-only transition; returns immediately.announced/depositing/awaiting_counter) → subscribe toswap:deposit_returnedbefore callingcancelSwap(race-safe), then call it, then wait up to--timeout(default 60, clamped[5, 86400]).concluding/completed/cancelled/failed) → refuse with exit 1 and a clear message.{ swap_id, prev_state, new_state, deposits_returned }.swap wait <id> [--state <name>] [--timeout <s>] [--exit-on-failure]— new blocking primitive. Subscribes to all 15swap:*events, re-readsprogressfromgetSwapStatus()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 (GNUtimeout(1)convention).--jsonstreams one compact JSON line per transition viaprocess.stdout.write(keeps the line-oriented contract thatformatOutput's pretty-print would break, and slips pastlegacy-cli-ux.test.ts'sconsole.log(JSON.stringify)ban).Tests
cli-swap.integration.test.tsextended:--reason(reject),--timeout(cancel), and the full--state/--timeout/--exit-on-failure/124exit-code contract (wait).swap wait.Test plan
npm run buildcleannpm test(127 unit tests) → greennpx vitest run --config vitest.integration.config.ts test/integration/cli-swap.integration.test.ts(20 tests) → greennpx tsc --noEmit -p tsconfig.json→ cleannpx eslint src/legacy/legacy-cli.ts→ 0 errorsmanual-test-swap-roundtrip.shagainst real testnet escrow (companion in sphere-sdk PR; requires a reachable escrow)cli-swap-e2e.integration.test.tswithE2E_RUN_SWAP=1against Docker escrowCompanion PR
Soak script + demo playbook ship in
unicity-sphere/sphere-sdkonfeat/issue-437-swap-roundtrip-soak— see the matching PR over there.Closes (one half of) unicity-sphere/sphere-sdk#437.