Goal
Add a fourth manual-test soak + demo-playbook pair covering the swap module so the same trio of artifacts that already exist for transfers, accounting, and full-recovery also exists for swap. Close out the small set of CLI gaps that block a clean, deterministic soak.
Target scenario (the soak's "ALL GREEN" path):
SETUP alice faucet 100 UCT, bob faucet 100 ETH
PROPOSE alice → @bob: offer 50 UCT, want 5 ETH
ACCEPT bob accepts + deposits 5 ETH into escrow
DEPOSIT alice deposits 50 UCT into escrow
COMPLETE escrow pays out: alice receives 5 ETH, bob receives 50 UCT
NET alice -50 UCT +5 ETH bob +50 UCT -5 ETH
This issue is purely planning — implementation comes after. The issue is structured so an implementer can pick it up cold and ship in one sitting.
1. What we already have
The trio for an existing module is consistently:
| Module |
Soak script |
Walkthrough md |
Demo playbook |
| transfer |
manual-test-roundtrip-391.sh |
— |
docs/DEMO-PLAYBOOK-PAYMENT-ROUNDTRIP.md |
| accounting |
manual-test-accounting-roundtrip.sh |
— |
docs/DEMO-PLAYBOOK-ACCOUNTING-ROUNDTRIP.md |
| recovery |
manual-test-full-recovery.sh |
manual-test-full-recovery.md |
docs/DEMO-PLAYBOOK.md |
| swap |
MISSING |
MISSING |
MISSING |
CLI surface today (sphere swap … → legacy bridge):
- ✅
sphere swap propose --to @<r> --offer <amount> <coin> --want <amount> <coin> [--escrow ...] [--timeout ...] [--message ...]
- ✅
sphere swap list [--all] [--role proposer|acceptor] [--progress <state>]
- ✅
sphere swap accept <id> [--deposit] [--no-wait]
- ✅
sphere swap deposit <id>
- ✅
sphere swap status <id> [--query-escrow]
- ✅
sphere swap ping <addr>
SDK surface (already wired in modules/swap/SwapModule.ts):
proposeSwap(deal, options?) → SwapProposalResult
acceptSwap(swapId) → void
rejectSwap(swapId, reason?) → void
deposit(swapId) → TransferResult
cancelSwap(swapId) → void
verifyPayout(swapId) → boolean
getSwapStatus(swapId, options?) → SwapRef
getSwaps(filter?) → SwapRef[]
- Events:
swap:proposed, swap:proposal_received, swap:accepted, swap:rejected, swap:announced, swap:awaiting_counter, swap:depositing, swap:deposit_sent, swap:deposit_confirmed, swap:deposits_covered, swap:concluding, swap:payout_received, swap:completed, swap:cancelled, swap:failed, swap:bounce_received, swap:deposit_returned
Faucet supports per-coin amounts: sphere faucet 100 UCT, sphere faucet 100 ETH — required by the scenario.
2. CLI gaps that block the soak
The SDK methods exist; the CLI just doesn't expose them yet. Implement these three commands as thin wrappers over the existing SDK methods.
2.1 sphere swap reject <id> [--reason "<text>"]
Why: the canonical "soak section B" for any soak is the negative path — proposer offers, acceptor rejects, no funds move. Today the only way to test that is swap accept followed by swap cancel (which doesn't exist either). The SDK already has rejectSwap(swapId, reason?).
Behavior:
- Acceptor-only command. Errors if invoked by the proposer side.
- Transitions the local swap record to
cancelled with reason: 'rejected'.
- Sends rejection DM to the proposer; proposer's swap also transitions to
cancelled on DM receipt (already wired in SwapModule.handleIncomingDM).
--reason is propagated into the rejection DM (max 256 chars; truncate + warn beyond).
2.2 sphere swap cancel <id>
Why: the proposer needs an out between "I sent a proposal" and "deposits are locked at escrow." The SDK already has cancelSwap(swapId) which handles the state-machine details (refund-before-announce vs request-escrow-cancel-after-announce). The CLI just has to expose it.
Behavior:
- Available to either party at any non-terminal state.
- Before escrow
announce: pure-local transition to cancelled (no on-chain action).
- After
announce, before concluding: sends cancel DM to escrow; escrow returns deposits. Wait for swap:deposit_returned (or timeout per --timeout, default 60s) before exiting.
- After
concluding: rejects with a clear "cannot cancel — escrow is executing payouts" message.
2.3 sphere swap wait <id> [--state <name>] [--timeout <seconds>] [--exit-on-failure]
Why: today every soak has to spin a polling loop around sphere swap status with sleeps, which is brittle. The SDK already emits the right events; the CLI just needs to surface a blocking-wait primitive so soaks/scripts can write:
sphere swap propose --to @bob --offer 50 UCT --want 5 ETH | tee propose.log
SWAP_ID=$(jq -r .swap_id propose.log)
sphere swap wait $SWAP_ID --state completed --timeout 300 --exit-on-failure
Behavior:
- Subscribes to
swap:* events for the given <id>.
- Default
--state is completed; values: any of the swap states above.
- Polls
getSwapStatus() once at start to short-circuit if already in target state.
- Exits 0 on reaching target state. Exits non-zero on terminal-but-wrong state (e.g.,
cancelled / failed) iff --exit-on-failure set; otherwise prints the terminal state and exits 0.
- Honors
--timeout (clamped to [5, 86400]). Exits 124 on timeout.
--json emits one JSON line per state transition while waiting; default emits human-readable [hh:mm:ss] swap <prefix> → <state>.
Optional, lower-priority
sphere swap accept --no-wait should already work; the soak should verify that. If it currently blocks, file a follow-up.
- Daemon event support for
swap:completed etc. — verify sphere daemon start --event 'swap:completed' --action 'log:./events.log' actually subscribes to the swap event bus (the daemon's event-routing already supports any <module>:<event>, but a runtime test is cheap to write and may surface a regression).
3. New artifacts
3.1 manual-test-swap-roundtrip.sh
Follows the manual-test-accounting-roundtrip.sh pattern. Single peer (no peer2/daemons needed — the soak's job is to exercise the swap protocol, not cross-device sync).
Sections:
Section 1 Create alice + bob (testnet)
Section 2 Faucet alice 100 UCT; faucet bob 100 ETH; capture baselines
Section 3 Alice proposes: sphere swap propose --to @bob --offer 50 UCT --want 5 ETH
Section 4 Bob lists incoming proposals: sphere swap list --role acceptor --progress proposed
(asserts the proposal landed and captures $SWAP_ID)
Section 5 Bob accepts + deposits: sphere swap accept $SWAP_ID --deposit
Section 6 Alice deposits: sphere swap deposit $SWAP_ID
Section 7 Wait for completion: sphere swap wait $SWAP_ID --state completed --timeout 300 --exit-on-failure
(BOTH parties run wait in parallel; both must exit 0)
Section 8 Verify net deltas (integer-only smallest-unit):
alice net UCT = -50 × 10^18
alice net ETH = +5 × 10^18
bob net UCT = +50 × 10^18
bob net ETH = -5 × 10^18
Section 9 Verify final state: sphere swap status $SWAP_ID → progress: completed
Section 10 Cross-hop / poison-pill scan:
grep -c "SERIALIZATION_ERROR\|VERIFICATION_FAILED\|DUPLICATE_BUNDLE_MEMBERSHIP" \
$SNAP/*.log → must be 0
Section 11 ALL GREEN banner
Optional Scenario B (negative-path) — same script, after Section 11:
Section 12 Alice proposes: sphere swap propose --to @bob --offer 5 UCT --want 0.1 ETH
(smaller stake — leftover faucet balance after Scenario A)
Section 13 Bob rejects: sphere swap reject <id> --reason "demo: declining"
Section 14 Wait + assert both sides see `cancelled` with reason 'rejected'
Section 15 Assert no balance change between Section 11 final balances and post-reject balances
Optional Scenario C (cancel-before-deposit):
Section 16 Alice proposes; bob does NOT accept
Section 17 Alice cancels: sphere swap cancel <id>
Section 18 Assert local state moved to `cancelled`; no DM races; no balance change
Soak conventions to follow (already used by other soaks):
set -euo pipefail + trap teardown EXIT INT TERM
SWAP_TEST_DIR env var for workspace (default /tmp/swap-roundtrip-$$)
KEEP=1 keeps the workspace
SUFFIX env override for nametags
- All payments in smallest units; UI-unit assertions only via the CLI's
--json parser
- Per-step
tee $SNAP/<step>.log so the final pass/fail scan has logs to grep
ASSERT OK (<label>): <message> for green; ASSERT FAIL (...) aborts via exit 1
- Final
ALL GREEN — swap round-trip succeeded banner on success
Wall-time budget: 4–8 min on a healthy testnet (one propose + two deposits + escrow finalize is roughly the cost of two send round-trips).
3.2 docs/DEMO-PLAYBOOK-SWAP-ROUNDTRIP.md
Follows the DEMO-PLAYBOOK-ACCOUNTING-ROUNDTRIP.md pattern (single peer, presenter-narrative). Sections:
At a glance
| Phase | What you show | Time |
| §0 | Prereqs & setup | 2 m |
| §1 | Create alice + bob | 2 m |
| §2 | Faucet alice 100 UCT + bob 100 ETH | 2 m |
| §3 | Alice proposes: 50 UCT for 5 ETH | 3 m |
| §4 | Bob sees the proposal in his swap list | 1 m |
| §5 | Bob accepts + deposits 5 ETH | 2 m |
| §6 | Alice deposits 50 UCT | 2 m |
| §7 | Watch both sides receive their payouts | 3 m |
| §8 | Show final balances + swap status `completed` | 1 m |
| Wrap | Q&A | 2 m |
| | **Total** | ~20m |
(then per-section presenter narrative with talk tracks for what to show on screen)
Include presenter cheat-sheet table at the bottom mirroring the other playbooks:
| When you want to… |
Run |
| Propose a swap |
sphere swap propose --to @<r> --offer <a> <coin> --want <a> <coin> |
| List inbound proposals |
sphere swap list --role acceptor --progress proposed |
| Accept + deposit in one shot |
sphere swap accept <id> --deposit |
| Just accept (deposit later) |
sphere swap accept <id> |
| Reject a proposal |
sphere swap reject <id> [--reason "…"] |
| Deposit your side |
sphere swap deposit <id> |
| Cancel your own proposal |
sphere swap cancel <id> |
| Block until terminal state |
sphere swap wait <id> --state completed [--timeout <s>] |
| Show swap detail |
sphere swap status <id> |
| Live escrow query |
sphere swap status <id> --query-escrow |
Include the standard "Common live-demo failure modes" section — at minimum:
- Escrow service unreachable (
sphere swap ping <escrow-addr> to diagnose).
- Either party missing the deposit coin (faucet a top-up; restart from Section 3 with a smaller deal).
- Long escrow finalize →
swap wait times out — show how to extend --timeout.
- Alice and Bob accidentally swap roles in the deal direction — explain
--offer = what YOU give up.
3.3 Optional: manual-test-swap-roundtrip.md
Only if §D-style assertion-heavy QA walkthrough is desired. The pattern for accounting/transfer skips this and lets the .sh script be the assertion document — recommend the same for swap.
4. Implementation checklist
[ ] sphere swap reject <id> [--reason "<text>"]
[ ] CLI command + flag parsing
[ ] thin wrapper around sphere.swap.rejectSwap(id, reason)
[ ] --json output: { swap_id, prev_state, new_state, reason }
[ ] --help block
[ ] unit test against a mocked SwapModule
[ ] integration test on testnet (cheap — no funds move)
[ ] sphere swap cancel <id> [--timeout <seconds>]
[ ] CLI command + flag parsing
[ ] state-aware wrapper:
- if state < announced: just call cancelSwap()
- if announced <= state < concluding: cancelSwap() + wait for swap:deposit_returned or swap:cancelled
- if state >= concluding: refuse with clear error
[ ] --json output: { swap_id, prev_state, new_state, deposits_returned: bool }
[ ] --help block
[ ] unit tests covering all three state branches
[ ] integration test on testnet (one happy-path cancel-before-announce, one cancel-after-announce)
[ ] sphere swap wait <id> [--state <name>] [--timeout <seconds>] [--exit-on-failure]
[ ] CLI command + flag parsing
[ ] event subscription via Sphere event bus (filter by swap_id)
[ ] short-circuit poll at startup
[ ] terminal-state exit semantics (exit code 0 / 1 / 124)
[ ] --json streaming output (one line per transition)
[ ] --help block
[ ] unit tests with a stubbed event bus
[ ] integration test: spawn propose → wait → cancel; verify wait exits with the right code
[ ] manual-test-swap-roundtrip.sh (Scenario A only first; B & C optional)
[ ] script outline above
[ ] integer-only delta assertions
[ ] poison-pill scan
[ ] teardown trap
[ ] env-var contract (SWAP_TEST_DIR, KEEP, SUFFIX)
[ ] docs/DEMO-PLAYBOOK-SWAP-ROUNDTRIP.md
[ ] presenter narrative
[ ] presenter cheat sheet
[ ] failure-mode section
[ ] CI / soak harness wiring
[ ] Add the new soak to whatever nightly runner exists (mirror what
manual-test-accounting-roundtrip.sh gets)
[ ] No CI cost unless an escrow service is provisioned for CI; if not,
the soak is operator-run only (same posture as the others)
5. Acceptance criteria
sphere swap reject <id> exits 0 with both local + remote sides transitioned to cancelled (reason='rejected').
sphere swap cancel <id> works correctly across all three state-branch cases above; deposits returned (if any) before exit on the announced-but-not-concluding branch.
sphere swap wait <id> --state completed --timeout 300 --exit-on-failure exits 0 on completed, exits 1 on cancelled / failed, exits 124 on timeout.
manual-test-swap-roundtrip.sh exits 0 against real testnet with ALL GREEN — swap round-trip succeeded on the happy path; integer-only delta assertions for both UCT and ETH match expectation.
docs/DEMO-PLAYBOOK-SWAP-ROUNDTRIP.md is a self-contained presenter narrative for a ~20 min live demo. Cheat sheet covers every command used in the script.
- No regressions in the existing three soaks (
manual-test-roundtrip-391.sh, manual-test-accounting-roundtrip.sh, manual-test-full-recovery.sh) — re-run them after the new CLI commands land.
6. Out of scope (deliberately)
- NFT swaps — v1 swap is coin-only by SDK design (see
docs/SWAP-ARCHITECTURE.md header). Future protocol revision.
- Multi-asset swap deals — single coin per party today; multi-asset reserved for future.
- Escrow-service-side changes — this issue is purely client-side. The escrow service at
/home/vrogojin/escrow-service is out of scope.
- Cross-device sync of swap state — the existing
manual-test-full-recovery.sh already covers Profile-backed state recovery; swap records ride the same storage layer and will recover incidentally. A dedicated cross-device swap soak is a separate follow-up.
- Multi-hop / atomic-swap chains — single-hop only.
7. References
- Existing soaks:
manual-test-roundtrip-391.sh, manual-test-accounting-roundtrip.sh, manual-test-full-recovery.sh
- Existing playbooks:
docs/DEMO-PLAYBOOK.md, docs/DEMO-PLAYBOOK-PAYMENT-ROUNDTRIP.md, docs/DEMO-PLAYBOOK-ACCOUNTING-ROUNDTRIP.md
- Swap docs:
docs/SWAP-ARCHITECTURE.md, docs/SWAP-SPEC.md, docs/SWAP-PROTOCOL-V2.md
- Swap module:
modules/swap/SwapModule.ts, modules/swap/types.ts
- Existing CLI swap commands:
src/legacy/legacy-cli.ts (search for 'swap-*' keys)
- Swap event names (for the daemon wiring + the
wait command): the 17 swap:* events listed in §1 above.
Goal
Add a fourth manual-test soak + demo-playbook pair covering the swap module so the same trio of artifacts that already exist for
transfers,accounting, andfull-recoveryalso exists forswap. Close out the small set of CLI gaps that block a clean, deterministic soak.Target scenario (the soak's "ALL GREEN" path):
This issue is purely planning — implementation comes after. The issue is structured so an implementer can pick it up cold and ship in one sitting.
1. What we already have
The trio for an existing module is consistently:
manual-test-roundtrip-391.shdocs/DEMO-PLAYBOOK-PAYMENT-ROUNDTRIP.mdmanual-test-accounting-roundtrip.shdocs/DEMO-PLAYBOOK-ACCOUNTING-ROUNDTRIP.mdmanual-test-full-recovery.shmanual-test-full-recovery.mddocs/DEMO-PLAYBOOK.mdCLI surface today (
sphere swap …→ legacy bridge):sphere swap propose --to @<r> --offer <amount> <coin> --want <amount> <coin> [--escrow ...] [--timeout ...] [--message ...]sphere swap list [--all] [--role proposer|acceptor] [--progress <state>]sphere swap accept <id> [--deposit] [--no-wait]sphere swap deposit <id>sphere swap status <id> [--query-escrow]sphere swap ping <addr>SDK surface (already wired in
modules/swap/SwapModule.ts):proposeSwap(deal, options?)→SwapProposalResultacceptSwap(swapId)→voidrejectSwap(swapId, reason?)→voiddeposit(swapId)→TransferResultcancelSwap(swapId)→voidverifyPayout(swapId)→booleangetSwapStatus(swapId, options?)→SwapRefgetSwaps(filter?)→SwapRef[]swap:proposed,swap:proposal_received,swap:accepted,swap:rejected,swap:announced,swap:awaiting_counter,swap:depositing,swap:deposit_sent,swap:deposit_confirmed,swap:deposits_covered,swap:concluding,swap:payout_received,swap:completed,swap:cancelled,swap:failed,swap:bounce_received,swap:deposit_returnedFaucet supports per-coin amounts:
sphere faucet 100 UCT,sphere faucet 100 ETH— required by the scenario.2. CLI gaps that block the soak
The SDK methods exist; the CLI just doesn't expose them yet. Implement these three commands as thin wrappers over the existing SDK methods.
2.1
sphere swap reject <id> [--reason "<text>"]Why: the canonical "soak section B" for any soak is the negative path — proposer offers, acceptor rejects, no funds move. Today the only way to test that is
swap acceptfollowed byswap cancel(which doesn't exist either). The SDK already hasrejectSwap(swapId, reason?).Behavior:
cancelledwithreason: 'rejected'.cancelledon DM receipt (already wired inSwapModule.handleIncomingDM).--reasonis propagated into the rejection DM (max 256 chars; truncate + warn beyond).2.2
sphere swap cancel <id>Why: the proposer needs an out between "I sent a proposal" and "deposits are locked at escrow." The SDK already has
cancelSwap(swapId)which handles the state-machine details (refund-before-announce vs request-escrow-cancel-after-announce). The CLI just has to expose it.Behavior:
announce: pure-local transition tocancelled(no on-chain action).announce, beforeconcluding: sendscancelDM to escrow; escrow returns deposits. Wait forswap:deposit_returned(or timeout per--timeout, default 60s) before exiting.concluding: rejects with a clear "cannot cancel — escrow is executing payouts" message.2.3
sphere swap wait <id> [--state <name>] [--timeout <seconds>] [--exit-on-failure]Why: today every soak has to spin a polling loop around
sphere swap statuswith sleeps, which is brittle. The SDK already emits the right events; the CLI just needs to surface a blocking-wait primitive so soaks/scripts can write:Behavior:
swap:*events for the given<id>.--stateiscompleted; values: any of the swap states above.getSwapStatus()once at start to short-circuit if already in target state.cancelled/failed) iff--exit-on-failureset; otherwise prints the terminal state and exits 0.--timeout(clamped to [5, 86400]). Exits 124 on timeout.--jsonemits one JSON line per state transition while waiting; default emits human-readable[hh:mm:ss] swap <prefix> → <state>.Optional, lower-priority
sphere swap accept --no-waitshould already work; the soak should verify that. If it currently blocks, file a follow-up.swap:completedetc. — verifysphere daemon start --event 'swap:completed' --action 'log:./events.log'actually subscribes to the swap event bus (the daemon's event-routing already supports any<module>:<event>, but a runtime test is cheap to write and may surface a regression).3. New artifacts
3.1
manual-test-swap-roundtrip.shFollows the
manual-test-accounting-roundtrip.shpattern. Single peer (no peer2/daemons needed — the soak's job is to exercise the swap protocol, not cross-device sync).Sections:
Optional Scenario B (negative-path) — same script, after Section 11:
Optional Scenario C (cancel-before-deposit):
Soak conventions to follow (already used by other soaks):
set -euo pipefail+trap teardown EXIT INT TERMSWAP_TEST_DIRenv var for workspace (default/tmp/swap-roundtrip-$$)KEEP=1keeps the workspaceSUFFIXenv override for nametags--jsonparsertee $SNAP/<step>.logso the final pass/fail scan has logs to grepASSERT OK (<label>): <message>for green;ASSERT FAIL (...)aborts viaexit 1ALL GREEN — swap round-trip succeededbanner on successWall-time budget: 4–8 min on a healthy testnet (one propose + two deposits + escrow finalize is roughly the cost of two send round-trips).
3.2
docs/DEMO-PLAYBOOK-SWAP-ROUNDTRIP.mdFollows the
DEMO-PLAYBOOK-ACCOUNTING-ROUNDTRIP.mdpattern (single peer, presenter-narrative). Sections:Include presenter cheat-sheet table at the bottom mirroring the other playbooks:
sphere swap propose --to @<r> --offer <a> <coin> --want <a> <coin>sphere swap list --role acceptor --progress proposedsphere swap accept <id> --depositsphere swap accept <id>sphere swap reject <id> [--reason "…"]sphere swap deposit <id>sphere swap cancel <id>sphere swap wait <id> --state completed [--timeout <s>]sphere swap status <id>sphere swap status <id> --query-escrowInclude the standard "Common live-demo failure modes" section — at minimum:
sphere swap ping <escrow-addr>to diagnose).swap waittimes out — show how to extend--timeout.--offer= what YOU give up.3.3 Optional:
manual-test-swap-roundtrip.mdOnly if §D-style assertion-heavy QA walkthrough is desired. The pattern for accounting/transfer skips this and lets the
.shscript be the assertion document — recommend the same for swap.4. Implementation checklist
5. Acceptance criteria
sphere swap reject <id>exits 0 with both local + remote sides transitioned tocancelled (reason='rejected').sphere swap cancel <id>works correctly across all three state-branch cases above; deposits returned (if any) before exit on the announced-but-not-concluding branch.sphere swap wait <id> --state completed --timeout 300 --exit-on-failureexits 0 oncompleted, exits 1 oncancelled/failed, exits 124 on timeout.manual-test-swap-roundtrip.shexits 0 against real testnet withALL GREEN — swap round-trip succeededon the happy path; integer-only delta assertions for both UCT and ETH match expectation.docs/DEMO-PLAYBOOK-SWAP-ROUNDTRIP.mdis a self-contained presenter narrative for a ~20 min live demo. Cheat sheet covers every command used in the script.manual-test-roundtrip-391.sh,manual-test-accounting-roundtrip.sh,manual-test-full-recovery.sh) — re-run them after the new CLI commands land.6. Out of scope (deliberately)
docs/SWAP-ARCHITECTURE.mdheader). Future protocol revision./home/vrogojin/escrow-serviceis out of scope.manual-test-full-recovery.shalready covers Profile-backed state recovery; swap records ride the same storage layer and will recover incidentally. A dedicated cross-device swap soak is a separate follow-up.7. References
manual-test-roundtrip-391.sh,manual-test-accounting-roundtrip.sh,manual-test-full-recovery.shdocs/DEMO-PLAYBOOK.md,docs/DEMO-PLAYBOOK-PAYMENT-ROUNDTRIP.md,docs/DEMO-PLAYBOOK-ACCOUNTING-ROUNDTRIP.mddocs/SWAP-ARCHITECTURE.md,docs/SWAP-SPEC.md,docs/SWAP-PROTOCOL-V2.mdmodules/swap/SwapModule.ts,modules/swap/types.tssrc/legacy/legacy-cli.ts(search for'swap-*'keys)waitcommand): the 17swap:*events listed in §1 above.