diff --git a/docs/DEMO-PLAYBOOK-SWAP-ROUNDTRIP.md b/docs/DEMO-PLAYBOOK-SWAP-ROUNDTRIP.md new file mode 100644 index 00000000..4dbe9009 --- /dev/null +++ b/docs/DEMO-PLAYBOOK-SWAP-ROUNDTRIP.md @@ -0,0 +1,646 @@ +# Sphere CLI Demo Playbook — Swap Round-Trip + +A presenter-friendly run-through of the **swap module lifecycle** on real testnet — proposal, acceptance, escrow-mediated deposits, and atomic payout. The demo exercises: + +1. **Scenario A (§1-§8) — Happy path.** Alice proposes 50 UCT for 5 ETH, bob accepts + deposits, alice deposits, both sides receive payouts. Net delta: alice `-50 UCT +5 ETH`, bob `+50 UCT -5 ETH`. +2. **Scenario B (§9) — Acceptor declines.** Alice proposes 5 UCT for 0.1 ETH, bob runs `sphere swap reject --reason "…"`, both sides observe `cancelled` with no balance change. (Optional — adds ~3 min.) +3. **Scenario C (§10) — Proposer rescinds.** Alice proposes a tiny swap, then `sphere swap cancel` before bob accepts — pre-announce branch, local-only transition, no escrow round-trip. (Optional — adds ~2 min.) + +The load-bearing payoff is **§7** — `sphere swap wait` is the new blocking primitive. Before this PR, soaks and demo scripts had to sit in a polling loop around `sphere swap status` with sleeps. The new command subscribes to swap events and exits when local progress reaches the target state, so a script can write `sphere swap wait $ID --state completed --timeout 300 --exit-on-failure` and trust the exit code. + +This is the companion to the soak script [`manual-test-swap-roundtrip.sh`](../manual-test-swap-roundtrip.sh) — that script asserts the same thing programmatically; this playbook walks the same flow live in front of an audience. + +--- + +## At a glance + +``` +SETUP alice + bob wallets on testnet, asymmetric faucet + alice 100 UCT, bob 100 ETH + +SCENARIO A — full swap round-trip +§3 Alice proposes: sphere swap propose --to @bob --offer 50 UCT --want 5 ETH + → SWAP_ID captured from --json +§4 Bob lists incoming proposals → SWAP_ID visible +§5 Bob accepts + deposits 5 ETH → sphere swap accept $ID --deposit +§6 Alice deposits 50 UCT → sphere swap deposit $ID +§7 Both block on swap wait → sphere swap wait $ID --state completed + --timeout 300 --exit-on-failure +§8 Verify balances + final status + alice -50 UCT +5 ETH + bob +50 UCT -5 ETH + both sides: progress: completed + +SCENARIO B (optional) — acceptor declines +§9 Alice proposes 5 UCT for 0.1 ETH + Bob: sphere swap reject $ID --reason "Price too high" + Both sides observe `cancelled`, no balance change + +SCENARIO C (optional) — proposer rescinds before announce +§10 Alice proposes 1 UCT for 0.01 ETH + Alice (immediately): sphere swap cancel $ID + Local-only transition; deposits_returned: false +``` + +Total run time: +- Scenario A only: ~8-12 min on a healthy testnet +- Scenario A + B: ~12-16 min +- Scenario A + B + C: ~14-18 min + +--- + +## §0 Before you start + +### Prerequisites + +- `sphere` CLI on `PATH` (`which sphere` should resolve). +- Outbound HTTPS to: `faucet.unicity.network`, `goggregator-test.unicity.network`, Unicity IPFS gateways. +- Outbound WSS to: `wss://nostr-relay.testnet.unicity.network`. +- An escrow service reachable on the same testnet relay set as the wallets. The default is `@escrow-testnet`; override with `--escrow @your-escrow` or `--escrow DIRECT://…` on `swap propose`. +- A clean workspace (the script wipes its own scratch dir on exit unless `KEEP=1`). + +### Dependency check (one-time setup) + +This playbook exercises three new CLI commands shipped with **sphere-sdk#437** (in sphere-cli): `swap reject` (`--reason` flag), `swap cancel` (state-aware + `--timeout`), and `swap wait` (new). Confirm the running CLI binary has them: + +```bash +sphere swap reject --help | grep -c -- '--reason' # should be 1 +sphere swap cancel --help | grep -c -- '--timeout' # should be 1 +sphere swap wait --help | grep -c -- '--exit-on-failure' # should be 1 +``` + +If any of those return `0`, the binary on `PATH` is behind the #437 cut — rebuild before demoing. The SDK side (`rejectSwap` / `cancelSwap` / `getSwapStatus`) is unchanged — these are pure CLI additions on top of the existing `SwapModule`. + +### Versions to confirm + +```bash +sphere --help | head -3 +node --version # >= 18 +``` + +### Suggested terminal layout + +- **T1** — alice's peer. +- **T2** — bob's peer. +- **T3** — log tail (optional; useful for showing swap event flow if anything stalls). + +### Workspace + +```bash +ROOT="/tmp/demo-swap-$$" +mkdir -p "$ROOT/peer-alice" "$ROOT/peer-bob" +SUFFIX="$(date +%s | tail -c 5)$(printf '%04x' $((RANDOM % 65536)))" +ALICE_TAG="alice-$SUFFIX" +BOB_TAG="bob-$SUFFIX" +echo "ALICE_TAG=$ALICE_TAG" +echo "BOB_TAG=$BOB_TAG" + +# Default escrow. Override if your environment uses a different one. +ESCROW="${ESCROW:-@escrow-testnet}" +echo "ESCROW=$ESCROW" + +# CLI emits mnemonic on stdout in non-TTY when --no-encrypt-mnemonic +# is implied. Allowing this makes the live walkthrough scriptable. +export SPHERE_ALLOW_MNEMONIC_NON_TTY=1 +``` + +--- + +## §1 Create the two wallets + +### Alice — T1 + +```bash +cd "$ROOT/peer-alice" +sphere wallet create alice +sphere wallet use alice +sphere init --network testnet --nametag "$ALICE_TAG" +``` + +### Bob — T2 + +```bash +cd "$ROOT/peer-bob" +sphere wallet create bob +sphere wallet use bob +sphere init --network testnet --nametag "$BOB_TAG" +``` + +**Talk track:** "Both wallets are minted on real testnet — alice and bob each have an on-chain nametag. The swap protocol's nametag-binding proofs use these on-chain identifiers, so the wallets must be fully provisioned before the proposal can be signed." + +--- + +## §2 Faucet — asymmetric so the demo can catch cross-talk + +### T1 — alice gets UCT only + +```bash +cd "$ROOT/peer-alice" +sphere wallet use alice +sphere faucet 100 UCT +sphere payments sync +sphere payments receive --finalize +sphere balance +``` + +### T2 — bob gets ETH only + +```bash +cd "$ROOT/peer-bob" +sphere wallet use bob +sphere faucet 100 ETH +sphere payments sync +sphere payments receive --finalize +sphere balance +``` + +Expected: +- alice: `UCT: 100 (1 token)` and no ETH row. +- bob: `ETH: 100 (1 token)` and no UCT row. + +**Snapshot now.** This is the "before" state. The asymmetric setup is intentional — every net-delta assertion in §8 has to come out of the swap, not an existing pool of both coins. + +**Talk track:** "Each side has only the coin it's giving up. The only way alice can finish with ETH (and bob with UCT) is for the swap to actually pay out. There's no fallback liquidity to mask a bug." + +--- + +# Scenario A — Full swap round-trip + +## §3 Alice proposes — 50 UCT for 5 ETH + +### T1 + +```bash +sphere wallet use alice +sphere swap propose \ + --to "@${BOB_TAG}" \ + --offer 50 UCT \ + --want 5 ETH \ + --escrow "$ESCROW" \ + --message "Demo: half my UCT for some of your ETH" \ + --json +``` + +Expected output excerpt: + +```text +Swap proposed: + { + "swap_id": "0000...", // 64 hex chars + "counterparty": "@bob-XXXXX", + "escrow": "@escrow-testnet", + ... + } +``` + +Capture the ID: + +```bash +SWAP_ID=$(sphere swap propose ... --json 2>&1 | grep -Eo '"swap_id":[[:space:]]*"[0-9a-f]{64}"' | head -1 | sed -E 's/.*"([0-9a-f]+)".*/\1/') +# Or, if you ran it once already, copy the id from the prior output: +SWAP_ID= +echo "SWAP_ID=$SWAP_ID" +``` + +**Talk track:** "The proposal carries a signed manifest — proposer signature over `swap_consent:{swap_id}:{escrow_address}` plus a nametag binding proof. The escrow won't even look at the deal until both signatures match the manifest. The swap_id is content-addressed: SHA-256 over the manifest fields. Bob can recompute it and verify before he agrees." + +--- + +## §4 Bob sees the proposal + +### T2 + +```bash +cd "$ROOT/peer-bob" +sphere wallet use bob +sphere swap list --role acceptor +``` + +Expected — bob's list now shows an entry with `swapId: 0000…` (the first 16 hex chars of `$SWAP_ID`) and `progress: proposed`. If it doesn't appear immediately, poll for ~30s — the proposal DM is a NIP-17 gift-wrap and may take a few seconds to land: + +```bash +# T2 — poll-and-wait pattern +for i in {1..20}; do + sphere swap list --role acceptor | grep -q "${SWAP_ID:0:8}" && break + sleep 3 +done +sphere swap list --role acceptor +``` + +**Talk track:** "Bob's wallet picked up the proposal DM, decoded the manifest, verified alice's nametag binding, and registered the swap in his local SwapModule. He hasn't sent anything back yet — accepting is an explicit step." + +--- + +## §5 Bob accepts + deposits 5 ETH (one shot) + +### T2 + +```bash +sphere swap accept "$SWAP_ID" --deposit --no-wait +``` + +Expected: + +```text +Swap accepted. Announced to escrow. Waiting for deposit invoice... +[swap] swap reached 'announced' — running deposit +Deposit sent: +Run 'swap wait ' to block until completion. +``` + +What this single command did: +1. Sent the acceptance DM to alice (acceptor signature added to the manifest). +2. Sent the announce DM to the escrow (with both signatures now present). +3. Waited for the escrow's `announce_result` reply. +4. Paid the resulting deposit invoice with 5 ETH. + +**Talk track:** "`--deposit --no-wait` is the one-shot 'accept and pay my side' UX. Without `--deposit`, bob would have to run `sphere swap deposit $SWAP_ID` later. `--no-wait` makes the command return as soon as the deposit transfer is sent, instead of blocking until the whole swap finishes — we use `sphere swap wait` later for the blocking phase, which is the canonical pattern." + +--- + +## §6 Alice deposits 50 UCT + +### T1 + +```bash +cd "$ROOT/peer-alice" +sphere wallet use alice + +# Wait for alice's wallet to see the escrow's announce_result (so the +# deposit invoice is locally known). Polling here is normal — the +# announce_result DM is async and can take 10-30s on a slow relay. +for i in {1..40}; do + state=$(sphere swap status "$SWAP_ID" 2>/dev/null \ + | grep -oE 'progress[[:space:]]*:[[:space:]]*[a-z_]+' | head -1 | awk '{print $3}') + echo " alice's swap progress: $state" + case "$state" in announced|depositing|awaiting_counter) break ;; esac + sleep 3 +done + +sphere swap deposit "$SWAP_ID" +``` + +Expected: + +```text +Deposit result: + id : + status : submitted +``` + +**Talk track:** "The deposit invoice was created by the escrow when bob sent the announce. Both parties' wallets receive it via DM and import it locally. Alice's `swap deposit` is just `sphere invoice pay` under the hood — the deposit invoice is a regular invoice token. The escrow validates the payment against the manifest and only releases when both deposits cover the required amounts." + +--- + +## §7 Both parties block on `swap wait` ← the new primitive + +### T1 (run in background) + +```bash +cd "$ROOT/peer-alice" +sphere wallet use alice +sphere swap wait "$SWAP_ID" \ + --state completed \ + --timeout 300 \ + --exit-on-failure & +ALICE_WAIT_PID=$! +echo "alice swap wait pid=$ALICE_WAIT_PID" +``` + +### T2 (block in foreground) + +```bash +cd "$ROOT/peer-bob" +sphere wallet use bob +sphere swap wait "$SWAP_ID" \ + --state completed \ + --timeout 300 \ + --exit-on-failure +# bob's wait returns first (or simultaneously); then: +wait "$ALICE_WAIT_PID" +``` + +Expected — both commands stream state transitions while waiting, then exit 0: + +```text +[14:32:11] swap 0000abcd → depositing +[14:32:14] swap 0000abcd → awaiting_counter +[14:32:24] swap 0000abcd → concluding +[14:32:31] swap 0000abcd → completed +``` + +In `--json` mode, each transition is one compact JSON line: + +```json +{"swap_id":"0000abcd...","state":"depositing","ts":1747839131456} +``` + +**Exit-code contract** (load-bearing for soaks and CI): + +| Exit | Meaning | +|---|---| +| `0` | Reached `--state` (or terminal-but-wrong without `--exit-on-failure`). | +| `1` | Reached a terminal-but-wrong state (`cancelled`/`failed`) and `--exit-on-failure` was set. | +| `124` | Wall-clock timeout. Matches GNU `timeout(1)`. | + +**Talk track:** "This is the payoff of #437. Before this command, every soak script that called `sphere swap propose` had to wrap the result in a polling loop around `sphere swap status` with sleeps. Now you spell 'wait until this swap settles' as one command, and the exit code tells you what happened. The 124 timeout maps to `timeout`'s convention so existing shell idioms (`if !$cmd; then …; fi`) work the way operators expect." + +--- + +## §8 Verify balances + final state + +### T1 — alice + +```bash +cd "$ROOT/peer-alice" +sphere wallet use alice +sphere payments sync +sphere payments receive --finalize +sphere balance +sphere swap status "$SWAP_ID" +``` + +Expected: +- alice's `UCT: 50 (1 or more tokens)` (100 − 50 = 50) +- alice's `ETH: 5 (1 token)` (0 + 5 = 5) +- swap status: `progress: completed`, `role: proposer` + +### T2 — bob + +```bash +cd "$ROOT/peer-bob" +sphere wallet use bob +sphere payments sync +sphere payments receive --finalize +sphere balance +sphere swap status "$SWAP_ID" +``` + +Expected: +- bob's `UCT: 50 (1 token)` (0 + 50 = 50) +- bob's `ETH: 95 (1 or more tokens)` (100 − 5 = 95) +- swap status: `progress: completed`, `role: acceptor` + +### Scenario A net flow + +| Wallet | Baseline (§2) | Final (§8) | Δ | +|---|---|---|---| +| alice | 100 UCT, 0 ETH | 50 UCT, 5 ETH | **−50 UCT, +5 ETH** | +| bob | 0 UCT, 100 ETH | 50 UCT, 95 ETH | **+50 UCT, −5 ETH** | + +In smallest-unit integers (both coins have 18 decimals): +- alice UCT: `100·10¹⁸ → 50·10¹⁸` (Δ = `−50·10¹⁸`) +- alice ETH: `0 → 5·10¹⁸` (Δ = `+5·10¹⁸`) +- bob UCT: `0 → 50·10¹⁸` (Δ = `+50·10¹⁸`) +- bob ETH: `100·10¹⁸ → 95·10¹⁸` (Δ = `−5·10¹⁸`) + +All four match. The 50 UCT / 5 ETH atomic swap is real, on-chain, and accounted for at every level — token-level balances, the escrow's deposit/payout invoice ledger, and both wallets' local SwapRef records (`progress: completed`). + +**Talk track:** "Atomic — both sides moved or neither. Cryptographically: each payout invoice was created by the escrow with the receiving party's address as the target. The escrow's payout transfer is on-chain; the wallets' `swap:completed` event fires only after `verifyPayout` confirms the payout invoice's terms match what was promised in the manifest." + +--- + +# Scenario B — Acceptor declines (optional, ~3 min) + +A clean negative-path demo: bob doesn't like the terms and rejects. No funds move. + +## §9 Alice proposes, bob rejects + +### T1 + +```bash +sphere wallet use alice +sphere balance | tee /tmp/alice-pre-B.txt # snapshot for the no-change check +sphere swap propose \ + --to "@${BOB_TAG}" \ + --offer 5 UCT \ + --want 0.1 ETH \ + --escrow "$ESCROW" \ + --message "Demo: smaller test deal" \ + --json +# capture the new swap_id: +SWAP_B= +echo "SWAP_B=$SWAP_B" +``` + +### T2 + +```bash +sphere wallet use bob +sphere balance | tee /tmp/bob-pre-B.txt +# Poll until bob sees the new proposal: +for i in {1..20}; do + sphere swap list --role acceptor | grep -q "${SWAP_B:0:8}" && break + sleep 3 +done +# Reject with an explanatory reason: +sphere swap reject "$SWAP_B" --reason "Price too high for this slot" --json +``` + +Expected: + +```text +Swap rejected: + { + "swap_id": "", + "prev_state": "proposed", + "new_state": "cancelled", + "reason": "Price too high for this slot" + } +``` + +### T1 — alice observes the rejection + +```bash +sphere wallet use alice +# Poll for state transition: +for i in {1..30}; do + state=$(sphere swap status "$SWAP_B" 2>/dev/null \ + | grep -oE 'progress[[:space:]]*:[[:space:]]*[a-z_]+' | head -1 | awk '{print $3}') + echo " alice's view of SWAP_B: $state" + [[ "$state" == "cancelled" ]] && break + sleep 3 +done +sphere swap status "$SWAP_B" +``` + +Expected: +- `progress: cancelled` +- `cancelReason: rejected` (or `error: Rejected by user` per the SDK's record shape) + +### No balance change check + +```bash +# T1 +sphere balance | diff -q /tmp/alice-pre-B.txt - # exit 0 → identical +# T2 +sphere balance | diff -q /tmp/bob-pre-B.txt - # exit 0 → identical +``` + +**Talk track:** "`swap reject` is acceptor-only by CLI policy — running it on a proposal you SENT exits with a helpful error pointing you at `swap cancel`. The rejection DM is best-effort: even if the network drops it, the local state flip on bob's side is the canonical signal that the proposal is dead. Alice's wallet picks up the rejection over Nostr a few seconds later and mirrors the state." + +--- + +# Scenario C — Proposer rescinds before announce (optional, ~2 min) + +The pre-announce branch of `swap cancel` — local-only, no escrow round-trip. + +## §10 Alice proposes, then cancels immediately + +### T1 + +```bash +sphere wallet use alice +sphere balance | tee /tmp/alice-pre-C.txt + +sphere swap propose \ + --to "@${BOB_TAG}" \ + --offer 1 UCT \ + --want 0.01 ETH \ + --escrow "$ESCROW" \ + --message "Demo: pre-announce cancel" \ + --json +SWAP_C= +echo "SWAP_C=$SWAP_C" + +# IMMEDIATELY cancel — before bob has a chance to accept. +sphere swap cancel "$SWAP_C" --json +``` + +Expected: + +```text +Swap cancelled: + { + "swap_id": "", + "prev_state": "proposed", + "new_state": "cancelled", + "deposits_returned": false + } +``` + +`deposits_returned: false` here means "no escrow round-trip happened" — the CLI saw the swap was still at `proposed` and took the pure-local pre-announce branch. No escrow DM was sent. + +### Confirm no balance change + +```bash +sphere balance | diff -q /tmp/alice-pre-C.txt - # exit 0 +``` + +**Talk track:** "The state-aware cancel matters because the SDK can't always tell from a single decision point whether deposits exist. The CLI snapshots `progress` at cancel-time and picks the right branch: pre-announce = local-only; post-announce = subscribe to `swap:deposit_returned` and wait. The `deposits_returned: false` in the JSON output is the operator-readable proof that no escrow involvement was needed." + +--- + +## §11 What to do if a section fails live + +| Symptom | What it means | Demo recovery | +|---|---|---| +| `swap propose: --escrow ` resolution fails | The escrow nametag doesn't resolve on the relay set. | Use a DIRECT://… form: ask the escrow operator for its direct address. | +| `Escrow ping failed` from `sphere swap ping $ESCROW` (sanity check) | The escrow service is unreachable. | Restart the escrow container or point at a different one via `ESCROW=…`. | +| Proposal never appears in bob's `swap list` after 90s | Either the relay is slow, or alice's wallet exited before the gift-wrap was actually published. | Run `sphere payments sync` on alice's peer to flush. If still empty after another 60s, restart from §3. | +| `swap accept --deposit` errors with "Swap did not reach 'announced' state" | The escrow didn't reply to the announce. Either escrow is down, or its nametag binding doesn't include bob's relay. | Skip `--deposit`, run `sphere swap accept` (no `--deposit`) and check `sphere swap status $SWAP_ID --query-escrow` to query the escrow directly. | +| `swap wait` times out (exit 124) | One of: testnet aggregator is slow, escrow finalization is slow, or your `--timeout` is too tight. | Re-run `sphere swap wait $SWAP_ID --state completed --timeout 600` with a larger budget. | +| `swap wait` exits 1 with terminal state `cancelled` | The escrow returned the deposits — usually because one party's deposit didn't cover the expected amount or arrived after the escrow timeout. | Check `sphere swap status $SWAP_ID --query-escrow` for the escrow's perspective on which leg failed. | +| Both `swap wait` invocations exit 0 but bob's balance shows 0 UCT | The payout invoice was paid but `payments receive --finalize` hasn't run. | Run `sphere payments sync && sphere payments receive --finalize`. The balance should appear within ~10s. | +| `swap reject` exits 1 with "Cannot reject: 'swap reject' is acceptor-only" | You ran it on the proposer side (probably switched terminals by mistake). | Run `sphere swap cancel $SWAP_ID` instead — it's the proposer's analog. | +| `swap cancel` exits 1 with "Cannot cancel: payouts are already in progress" | The swap is already at `concluding` — the escrow is mid-payout and there's no safe way to abort. | Wait for the swap to finish naturally (either `completed` or escrow timeout → `cancelled`). | +| `[Nostr] [AT-LEAST-ONCE] TOKEN_TRANSFER … not durable — leaving 'since' at ` | Background durability verifier couldn't confirm a previous event landed durably on the relay. Independent of the current step. | Continue the demo. | + +--- + +## §12 Cleanup + +If you didn't use `KEEP=1`: + +```bash +rm -rf "$ROOT" +``` + +If you used `KEEP=1` and want to inspect post-mortem: + +```bash +ls -la "$ROOT/peer-alice/.sphere-cli-alice/" "$ROOT/peer-bob/.sphere-cli-bob/" +``` + +The wallet directories contain the OrbitDB-backed Profile storage and the swap-record store; re-attach to either wallet later with `sphere wallet use alice` (from `$ROOT/peer-alice`). + +--- + +## §13 Optional — the automated soak + +Everything in this playbook is the script [`manual-test-swap-roundtrip.sh`](../manual-test-swap-roundtrip.sh) in the SDK repo: + +```bash +cd +bash manual-test-swap-roundtrip.sh # default: Scenario A + B +KEEP=1 bash manual-test-swap-roundtrip.sh # preserve workspace +SCENARIO=A bash manual-test-swap-roundtrip.sh # happy-path only +SCENARIO=ABC bash manual-test-swap-roundtrip.sh # all three scenarios +SWAP_TEST_DIR=/tmp/sw bash manual-test-swap-roundtrip.sh +ESCROW=@my-escrow bash manual-test-swap-roundtrip.sh # custom escrow +``` + +A green run prints `ALL GREEN — swap round-trip soak succeeded ()` and exits 0. + +--- + +## Presenter cheat sheet + +```text + §0 $ROOT, $ALICE_TAG, $BOB_TAG, $ESCROW, SPHERE_ALLOW_MNEMONIC_NON_TTY=1 + §1 sphere wallet create / use / init --nametag ×2 wallets + §2 sphere faucet 100 UCT (alice) + sphere faucet 100 ETH (bob) ← asymmetric on purpose + + SCENARIO A — full round-trip + §3 sphere swap propose --to @bob --offer 50 UCT --want 5 ETH + --escrow @escrow-testnet --json + → SWAP_ID + §4 sphere swap list --role acceptor ← bob sees the proposal + §5 sphere swap accept $SWAP_ID --deposit --no-wait ← bob accepts + deposits + §6 sphere swap deposit $SWAP_ID ← alice deposits + §7 sphere swap wait $SWAP_ID --state completed ← BOTH parties block + --timeout 300 --exit-on-failure + §8 alice -50 UCT +5 ETH, bob +50 UCT -5 ETH ✓ + + SCENARIO B — acceptor declines (optional) + §9 alice proposes 5 UCT for 0.1 ETH + sphere swap reject $SWAP_B --reason "…" ← bob rejects (acceptor-only) + both sides → progress: cancelled + no balance change + + SCENARIO C — proposer rescinds pre-announce (optional) + §10 alice proposes 1 UCT for 0.01 ETH + sphere swap cancel $SWAP_C ← alice cancels immediately + deposits_returned: false (local-only) + no balance change +``` + +### Command quick reference + +| When you want to… | Run | +|---|---| +| Propose a swap | `sphere swap propose --to @ --offer --want --escrow @` | +| List inbound proposals | `sphere swap list --role acceptor --progress proposed` | +| Accept + deposit in one shot | `sphere swap accept --deposit` | +| Accept + deposit, return early | `sphere swap accept --deposit --no-wait` | +| Just accept (deposit later) | `sphere swap accept ` | +| Reject a proposal (acceptor) | `sphere swap reject [--reason "…"]` | +| Deposit your side | `sphere swap deposit ` | +| Cancel your own swap (proposer or pre-concluding acceptor) | `sphere swap cancel [--timeout ]` | +| Block until terminal state | `sphere swap wait --state completed [--timeout ] [--exit-on-failure]` | +| Show swap detail | `sphere swap status ` | +| Live escrow query | `sphere swap status --query-escrow` | +| Ping the escrow for liveness | `sphere swap ping <@escrow-or-direct>` | + +### Exit codes that matter + +| Command | Exit | Meaning | +|---|---|---| +| `swap reject` | 0 | rejected, both sides → `cancelled` | +| `swap reject` | 1 | not acceptor (use `swap cancel` instead) | +| `swap cancel` | 0 | cancelled (`new_state: cancelled`) | +| `swap cancel` | 1 | refused (already concluding/terminal) | +| `swap wait` | 0 | reached `--state` or terminal-but-wrong without `--exit-on-failure` | +| `swap wait` | 1 | terminal-but-wrong with `--exit-on-failure` | +| `swap wait` | 124 | wall-clock timeout (GNU `timeout` convention) | diff --git a/manual-test-swap-roundtrip.sh b/manual-test-swap-roundtrip.sh new file mode 100755 index 00000000..28a42114 --- /dev/null +++ b/manual-test-swap-roundtrip.sh @@ -0,0 +1,650 @@ +#!/usr/bin/env bash +# +# manual-test-swap-roundtrip.sh — swap module roundtrip soak +# (sphere-sdk#437). +# +# Scenario A — happy path: +# 1. Alice tops up via faucet to 100 UCT; bob tops up to 100 ETH. +# 2. Alice proposes a swap to @bob: give 50 UCT, receive 5 ETH. +# 3. Bob lists incoming proposals, captures the swap ID. +# 4. Bob accepts + deposits 5 ETH into escrow. +# 5. Alice deposits 50 UCT into escrow. +# 6. Both parties block on `sphere swap wait` until the escrow pays +# out and the swap reaches `completed` locally. +# 7. Verify integer-only net deltas: +# alice -50 UCT +5 ETH +# bob +50 UCT -5 ETH +# 8. Verify both sides observe progress: completed. +# 9. Cross-hop poison-pill scan (no SERIALIZATION_ERROR / +# VERIFICATION_FAILED / DUPLICATE_BUNDLE_MEMBERSHIP across logs). +# +# Optional Scenario B (acceptor declines): +# After §A succeeds, alice proposes a smaller swap (5 UCT for 0.1 ETH), +# bob runs `sphere swap reject` with a reason, both sides observe +# `cancelled` and no balance changes. +# +# Optional Scenario C (proposer rescinds before counterparty accepts): +# Alice proposes a swap, bob does NOT accept, alice runs +# `sphere swap cancel`. Local state transitions to `cancelled`; +# no DMs to escrow (pre-announce branch). +# +# This soak is the SWAP analog of: +# - manual-test-roundtrip-391.sh (transfer roundtrip) +# - manual-test-accounting-roundtrip.sh (invoice roundtrip) +# - manual-test-full-recovery.sh (Profile recovery) +# +# Run: +# bash manual-test-swap-roundtrip.sh +# KEEP=1 bash manual-test-swap-roundtrip.sh # preserve workspace +# SWAP_TEST_DIR=/tmp/sw bash manual-test-swap-roundtrip.sh +# SCENARIO=A bash manual-test-swap-roundtrip.sh # happy-path only +# SCENARIO=AB bash manual-test-swap-roundtrip.sh # default: A + B +# SCENARIO=ABC bash manual-test-swap-roundtrip.sh # add cancel-before-accept +# +# Required env: +# ESCROW — escrow @nametag or DIRECT:// address +# (default: @escrow-testnet) +# +# Requires `sphere` on PATH, outbound HTTPS+WSS to testnet, and a +# reachable escrow service. + +set -euo pipefail + +# ---- workspace ---- +ROOT="${SWAP_TEST_DIR:-/tmp/swap-roundtrip-$$}" +SNAP="$ROOT/snapshots" +mkdir -p "$SNAP" + +SUFFIX="${SUFFIX:-$(date +%s | tail -c 5)$(printf '%04x' $((RANDOM % 65536)))}" +ALICE_TAG="alice-$SUFFIX" +BOB_TAG="bob-$SUFFIX" +echo "ALICE_TAG=$ALICE_TAG" +echo "BOB_TAG=$BOB_TAG" + +PEER_ALICE="$ROOT/alice-peer" +PEER_BOB="$ROOT/bob-peer" +mkdir -p "$PEER_ALICE" "$PEER_BOB" + +ESCROW="${ESCROW:-@escrow-testnet}" +echo "ESCROW=$ESCROW" + +SCENARIO="${SCENARIO:-AB}" +echo "SCENARIO=$SCENARIO" + +export SPHERE_ALLOW_MNEMONIC_NON_TTY=1 + +cleanup() { + local rc=$? + if [[ "${KEEP:-0}" != "1" ]]; then + rm -rf "$ROOT" 2>/dev/null || true + else + echo "=== KEEP=1: workspace preserved at $ROOT ===" + fi + return "$rc" +} +trap cleanup EXIT INT TERM + +banner() { + echo + echo "================================================================" + echo "$@" + echo "================================================================" +} + +# --------------------------------------------------------------------------- +# Integer-only confirmed balance extractor. +# +# Same convention as manual-test-accounting-roundtrip.sh: both UCT and +# ETH have 18 decimals in the production testnet registry, so we pad +# fractional parts to 18 chars to get a smallest-unit integer. +# +# Args: $1 = symbol (e.g. "UCT", "ETH") +# Stdin: contents of `sphere balance` output. +# Stdout: confirmed balance as smallest-unit integer string. +# --------------------------------------------------------------------------- +extract_confirmed_smallest_units() { + local symbol="$1" + local line decimal int_part frac_part + line=$(grep -E "^${symbol}:" || true) + if [[ -z "$line" ]]; then + echo "0" + return + fi + decimal=$(echo "$line" | sed -E -e "s/^${symbol}:[[:space:]]+//" -e 's/[[:space:]]+\(.+$//') + if [[ "$decimal" == *.* ]]; then + int_part="${decimal%.*}" + frac_part="${decimal#*.}" + else + int_part="$decimal" + frac_part="" + fi + while (( ${#frac_part} < 18 )); do frac_part="${frac_part}0"; done + if (( ${#frac_part} > 18 )); then + echo "ERROR: ${symbol} fractional part >18 digits ($decimal)" >&2 + return 1 + fi + local combined="${int_part}${frac_part}" + combined=$(echo "$combined" | sed -E 's/^0+//') + [[ -z "$combined" ]] && combined="0" + echo "$combined" +} + +# Helper for grep-based assertions that keep the ASSERT lines uniform. +assert_grep() { + local label="$1" pattern="$2" file="$3" + if grep -qE "$pattern" "$file"; then + echo "ASSERT OK ($label): pattern matched in $file" + return 0 + fi + echo "ASSERT FAIL ($label): pattern '$pattern' NOT found in $file" >&2 + echo "--- $(basename "$file") tail ---" >&2 + tail -20 "$file" >&2 || true + return 1 +} + +# Capture the swap_id from a `sphere swap propose --json` log. +extract_swap_id() { + local log="$1" + grep -Eo '"swap_id":[[:space:]]*"[0-9a-fA-F]{64}"' "$log" | head -1 \ + | sed -E 's/.*"([0-9a-fA-F]{64})".*/\1/' +} + +# --------------------------------------------------------------------------- +# Section 1 — Create alice + bob (testnet) +# --------------------------------------------------------------------------- +banner "Section 1: Create alice + bob (testnet)" + +cd "$PEER_ALICE" +sphere wallet create alice +sphere wallet use alice +sphere init --network testnet --nametag "$ALICE_TAG" 2>&1 | tee "$SNAP/alice-init.log" + +cd "$PEER_BOB" +sphere wallet create bob +sphere wallet use bob +sphere init --network testnet --nametag "$BOB_TAG" 2>&1 | tee "$SNAP/bob-init.log" +sphere status | tee "$SNAP/bob-status.log" +grep -qE "Nametag:.*$BOB_TAG" "$SNAP/bob-status.log" \ + || { echo "FAIL: bob's nametag '$BOB_TAG' not visible in status" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# Section 2 — Faucet alice → 100 UCT, bob → 100 ETH; capture baselines +# +# The swap soak's payoff is "alice gives UCT, bob gives ETH" → each side +# needs ONLY its half. We deliberately faucet asymmetrically here so the +# net-delta assertions in §8 catch any UCT/ETH cross-talk. +# --------------------------------------------------------------------------- +banner "Section 2: Faucet alice 100 UCT + bob 100 ETH; baselines" + +cd "$PEER_ALICE" +sphere wallet use alice +sphere faucet 100 UCT 2>&1 | tee "$SNAP/alice-faucet.log" +sphere payments sync 2>&1 | tee "$SNAP/alice-sync-0.log" +sphere payments receive --finalize 2>&1 | tee "$SNAP/alice-faucet-receive.log" +sphere balance | tee "$SNAP/alice-balance-0.txt" + +cd "$PEER_BOB" +sphere wallet use bob +sphere faucet 100 ETH 2>&1 | tee "$SNAP/bob-faucet.log" +sphere payments sync 2>&1 | tee "$SNAP/bob-sync-0.log" +sphere payments receive --finalize 2>&1 | tee "$SNAP/bob-faucet-receive.log" +sphere balance | tee "$SNAP/bob-balance-0.txt" + +alice_uct_0=$(extract_confirmed_smallest_units UCT < "$SNAP/alice-balance-0.txt") +alice_eth_0=$(extract_confirmed_smallest_units ETH < "$SNAP/alice-balance-0.txt") +bob_uct_0=$(extract_confirmed_smallest_units UCT < "$SNAP/bob-balance-0.txt") +bob_eth_0=$(extract_confirmed_smallest_units ETH < "$SNAP/bob-balance-0.txt") +echo "BASELINE alice UCT=$alice_uct_0 ETH=$alice_eth_0" +echo "BASELINE bob UCT=$bob_uct_0 ETH=$bob_eth_0" + +EXPECTED_50_UCT=50000000000000000000 # 50 × 10^18 +EXPECTED_5_ETH=5000000000000000000 # 5 × 10^18 + +# --------------------------------------------------------------------------- +# Section 2.5 — Escrow liveness pre-flight +# +# Without this, an unreachable escrow surfaces as +# `FAIL: couldn't extract swap_id from alice-propose-A.log` at §3, which +# misdirects operators to debug the propose command. A direct ping +# narrows the failure to "escrow not online" before we burn any swap +# state. +# --------------------------------------------------------------------------- +banner "Section 2.5: Escrow liveness pre-flight ($ESCROW)" + +cd "$PEER_ALICE" +sphere wallet use alice +if ! sphere swap ping "$ESCROW" 2>&1 | tee "$SNAP/alice-escrow-ping.log"; then + echo "ASSERT FAIL (escrow-unreachable): $ESCROW did not respond to swap ping" >&2 + echo "Hint: set ESCROW= to point at a different escrow service." >&2 + exit 1 +fi +echo "ASSERT OK (escrow-reachable): $ESCROW responded" + +# =========================================================================== +# Scenario A — Full swap roundtrip +# =========================================================================== +banner "Scenario A — propose → accept → deposit → completed" + +# --------------------------------------------------------------------------- +# Section 3 — Alice proposes 50 UCT for 5 ETH to @bob +# --------------------------------------------------------------------------- +banner "Section 3: Alice proposes 50 UCT for 5 ETH to @${BOB_TAG}" + +cd "$PEER_ALICE" +sphere wallet use alice +sphere swap propose \ + --to "@${BOB_TAG}" \ + --offer 50 UCT \ + --want 5 ETH \ + --escrow "$ESCROW" \ + --message "soak: A=50 UCT for 5 ETH" \ + --json \ + 2>&1 | tee "$SNAP/alice-propose-A.log" + +SWAP_A=$(extract_swap_id "$SNAP/alice-propose-A.log") +[[ -n "$SWAP_A" ]] || { echo "FAIL: couldn't extract swap_id from alice-propose-A.log" >&2; exit 1; } +echo "SWAP_A=$SWAP_A" + +# --------------------------------------------------------------------------- +# Section 4 — Bob polls until the proposal lands in his wallet +# +# Cross-process Nostr delivery: alice's CLI exits as soon as the +# propose DM is sent; bob's wallet needs to boot, subscribe with its +# persisted `since` cursor, and pull the DM from the relay backlog. +# Each `swap list` boot performs that subscription, so polling-with-tee +# is the right shape. Practical median latency is 5-15s; we cap at 90s. +# --------------------------------------------------------------------------- +banner "Section 4: Bob waits for the proposal to appear in `swap list`" + +cd "$PEER_BOB" +sphere wallet use bob + +DEADLINE=$(( $(date +%s) + 90 )) +PROPOSAL_SEEN=0 +while (( $(date +%s) < DEADLINE )); do + sphere payments sync > "$SNAP/bob-pre-list-A.log" 2>&1 || true + sphere swap list --role acceptor 2>&1 | tee "$SNAP/bob-swap-list-A.log" || true + # swap-list prints only the first 8 hex chars in its SWAP ID column. + if grep -qE "${SWAP_A:0:8}" "$SNAP/bob-swap-list-A.log"; then + echo "INFO: bob saw proposal ${SWAP_A:0:16}" + PROPOSAL_SEEN=1 + break + fi + echo " proposal not yet visible — sleeping 3s and retrying…" + sleep 3 +done +if (( PROPOSAL_SEEN == 0 )); then + echo "ASSERT FAIL (proposal-ingest-timeout): bob did NOT receive proposal $SWAP_A within 90s" >&2 + exit 1 +fi +echo "ASSERT OK (proposal-ingest): bob ingested proposal $SWAP_A" + +# --------------------------------------------------------------------------- +# Section 5 — Bob accepts + deposits 5 ETH +# +# `swap accept --deposit` waits for `swap:announced` from the escrow, +# then immediately calls `deposit` against the escrow's deposit +# invoice. Without `--deposit` bob would have to call `swap deposit` +# separately; we exercise the one-shot form here because it's the +# canonical happy-path UX. +# --------------------------------------------------------------------------- +banner "Section 5: Bob accepts + deposits 5 ETH" + +sphere swap accept "$SWAP_A" --deposit --no-wait 2>&1 | tee "$SNAP/bob-accept-A.log" + +# --------------------------------------------------------------------------- +# Section 6 — Alice deposits 50 UCT +# +# Alice's wallet needs to have ingested the escrow's `announce_result` +# DM before `swap deposit` can succeed (the deposit invoice ID is only +# known after that DM lands). We poll `swap status` until alice sees +# the swap progress at >= 'announced'. +# --------------------------------------------------------------------------- +banner "Section 6: Alice waits for announce → deposits 50 UCT" + +cd "$PEER_ALICE" +sphere wallet use alice + +# Poll until alice's local SwapRef has advanced past 'proposed'. +DEADLINE=$(( $(date +%s) + 120 )) +ANNOUNCED=0 +while (( $(date +%s) < DEADLINE )); do + sphere payments sync > "$SNAP/alice-pre-deposit-sync.log" 2>&1 || true + sphere swap status "$SWAP_A" 2>&1 | tee "$SNAP/alice-swap-status-pre-deposit.log" || true + # `progress: announced`, `progress: depositing`, or `progress: awaiting_counter` + # all indicate the escrow's announce_result has been processed. + if grep -qE 'progress[[:space:]]*:[[:space:]]*(announced|depositing|awaiting_counter)' \ + "$SNAP/alice-swap-status-pre-deposit.log"; then + echo "INFO: alice's swap reached announced/depositing/awaiting_counter" + ANNOUNCED=1 + break + fi + echo " swap not yet announced for alice — sleeping 3s and retrying…" + sleep 3 +done +if (( ANNOUNCED == 0 )); then + echo "ASSERT FAIL (announce-ingest-timeout): alice did not see swap announced within 120s" >&2 + exit 1 +fi + +sphere swap deposit "$SWAP_A" 2>&1 | tee "$SNAP/alice-deposit-A.log" + +# --------------------------------------------------------------------------- +# Section 7 — Both parties block on `swap wait --state completed` +# +# This is the load-bearing pin for the new wait primitive: each side's +# wait subscribes to swap:* events for SWAP_A, dispatches on each +# transition, and exits 0 only when local progress reaches 'completed'. +# We run alice in the background and bob in the foreground so the +# script blocks until BOTH return. +# +# Subshell exit-code semantics (load-bearing): `set -euo pipefail` is +# inherited by the subshell. `sphere swap wait | tee` is a 2-stage +# pipeline. With `pipefail`, the pipeline's exit code is the first +# non-zero stage — so a non-zero exit from sphere swap wait (terminal +# state with --exit-on-failure → 1, or timeout → 124) propagates +# through tee (which is always 0) to the subshell's exit. `wait $PID` +# then captures it correctly. If a future edit drops the subshell or +# replaces `tee` with a write that can fail, this contract breaks. +# --------------------------------------------------------------------------- +banner "Section 7: Both parties wait for swap completion" + +( + cd "$PEER_ALICE" + sphere wallet use alice + sphere swap wait "$SWAP_A" --state completed --timeout 300 --exit-on-failure \ + 2>&1 | tee "$SNAP/alice-wait-A.log" +) & +ALICE_WAIT_PID=$! + +cd "$PEER_BOB" +sphere wallet use bob +set +e +sphere swap wait "$SWAP_A" --state completed --timeout 300 --exit-on-failure \ + 2>&1 | tee "$SNAP/bob-wait-A.log" +BOB_WAIT_RC=$? +set -e + +set +e +wait "$ALICE_WAIT_PID" +ALICE_WAIT_RC=$? +set -e + +echo "alice swap wait rc: $ALICE_WAIT_RC" +echo "bob swap wait rc: $BOB_WAIT_RC" +[[ "$BOB_WAIT_RC" -eq 0 ]] \ + || { echo "ASSERT FAIL (bob-wait): exit $BOB_WAIT_RC (expected 0)" >&2; exit 1; } +[[ "$ALICE_WAIT_RC" -eq 0 ]] \ + || { echo "ASSERT FAIL (alice-wait): exit $ALICE_WAIT_RC (expected 0)" >&2; exit 1; } +echo "ASSERT OK (both-wait-rc-0): both sides reached 'completed' within budget" + +# --------------------------------------------------------------------------- +# Section 8 — Verify integer-only net deltas +# +# Expected: +# alice -50 UCT +5 ETH +# bob +50 UCT -5 ETH +# --------------------------------------------------------------------------- +banner "Section 8: Verify net deltas (smallest-unit integers)" + +cd "$PEER_ALICE" +sphere wallet use alice +sphere payments sync 2>&1 | tee "$SNAP/alice-post-sync.log" +sphere payments receive --finalize 2>&1 | tee "$SNAP/alice-post-receive.log" || true +sphere balance | tee "$SNAP/alice-balance-A.txt" + +cd "$PEER_BOB" +sphere wallet use bob +sphere payments sync 2>&1 | tee "$SNAP/bob-post-sync.log" +sphere payments receive --finalize 2>&1 | tee "$SNAP/bob-post-receive.log" || true +sphere balance | tee "$SNAP/bob-balance-A.txt" + +alice_uct_A=$(extract_confirmed_smallest_units UCT < "$SNAP/alice-balance-A.txt") +alice_eth_A=$(extract_confirmed_smallest_units ETH < "$SNAP/alice-balance-A.txt") +bob_uct_A=$(extract_confirmed_smallest_units UCT < "$SNAP/bob-balance-A.txt") +bob_eth_A=$(extract_confirmed_smallest_units ETH < "$SNAP/bob-balance-A.txt") + +alice_uct_delta=$(python3 -c "print($alice_uct_0 - $alice_uct_A)") # POSITIVE = paid +alice_eth_delta=$(python3 -c "print($alice_eth_A - $alice_eth_0)") # POSITIVE = received +bob_uct_delta=$(python3 -c "print($bob_uct_A - $bob_uct_0)") # POSITIVE = received +bob_eth_delta=$(python3 -c "print($bob_eth_0 - $bob_eth_A)") # POSITIVE = paid + +echo "alice UCT delta (paid): $alice_uct_delta (expected $EXPECTED_50_UCT)" +echo "alice ETH delta (received): $alice_eth_delta (expected $EXPECTED_5_ETH)" +echo "bob UCT delta (received): $bob_uct_delta (expected $EXPECTED_50_UCT)" +echo "bob ETH delta (paid): $bob_eth_delta (expected $EXPECTED_5_ETH)" + +rc=0 +[[ "$alice_uct_delta" == "$EXPECTED_50_UCT" ]] \ + || { echo "ASSERT FAIL (alice-uct-minus-50): expected $EXPECTED_50_UCT, got $alice_uct_delta" >&2; rc=1; } +[[ "$alice_eth_delta" == "$EXPECTED_5_ETH" ]] \ + || { echo "ASSERT FAIL (alice-eth-plus-5): expected $EXPECTED_5_ETH, got $alice_eth_delta" >&2; rc=1; } +[[ "$bob_uct_delta" == "$EXPECTED_50_UCT" ]] \ + || { echo "ASSERT FAIL (bob-uct-plus-50): expected $EXPECTED_50_UCT, got $bob_uct_delta" >&2; rc=1; } +[[ "$bob_eth_delta" == "$EXPECTED_5_ETH" ]] \ + || { echo "ASSERT FAIL (bob-eth-minus-5): expected $EXPECTED_5_ETH, got $bob_eth_delta" >&2; rc=1; } +(( rc == 0 )) && echo "ASSERT OK (deltas): all four legs match" + +# --------------------------------------------------------------------------- +# Section 9 — Final state: both sides show progress: completed +# --------------------------------------------------------------------------- +banner "Section 9: Verify final swap status on both sides" + +cd "$PEER_ALICE" +sphere wallet use alice +sphere swap status "$SWAP_A" 2>&1 | tee "$SNAP/alice-swap-status-final.log" +assert_grep "alice-status-completed" 'progress[[:space:]]*:[[:space:]]*completed' \ + "$SNAP/alice-swap-status-final.log" || rc=1 + +cd "$PEER_BOB" +sphere wallet use bob +sphere swap status "$SWAP_A" 2>&1 | tee "$SNAP/bob-swap-status-final.log" +assert_grep "bob-status-completed" 'progress[[:space:]]*:[[:space:]]*completed' \ + "$SNAP/bob-swap-status-final.log" || rc=1 + +# --------------------------------------------------------------------------- +# Section 10 — Cross-hop poison-pill scan +# --------------------------------------------------------------------------- +banner "Section 10: Poison-pill scan across all logs" + +POISON_HITS=$(grep -cE "SERIALIZATION_ERROR|VERIFICATION_FAILED|DUPLICATE_BUNDLE_MEMBERSHIP" \ + "$SNAP"/*.log 2>/dev/null | grep -vE ':0$' | wc -l) +if [[ "$POISON_HITS" -gt 0 ]]; then + echo "ASSERT FAIL (poison-pill): found $POISON_HITS log(s) with poison-pill errors" >&2 + grep -lE "SERIALIZATION_ERROR|VERIFICATION_FAILED|DUPLICATE_BUNDLE_MEMBERSHIP" "$SNAP"/*.log >&2 || true + rc=1 +else + echo "ASSERT OK (poison-pill-clean): no SERIALIZATION_ERROR / VERIFICATION_FAILED / DUPLICATE_BUNDLE_MEMBERSHIP across any log" +fi + +if (( rc != 0 )); then + banner "FAIL Scenario A — see ASSERT FAIL lines above" + exit "$rc" +fi + +# --------------------------------------------------------------------------- +# Section 11 — ALL GREEN (Scenario A) +# --------------------------------------------------------------------------- +banner "ALL GREEN — swap round-trip succeeded (Scenario A)" + +if [[ "$SCENARIO" != *"B"* && "$SCENARIO" != *"C"* ]]; then + exit 0 +fi + +# =========================================================================== +# Scenario B — Acceptor declines (negative path) +# =========================================================================== +if [[ "$SCENARIO" == *"B"* ]]; then + banner "Scenario B — propose → reject → no balance change" + + # Snapshot balances before scenario B so we can prove no funds moved. + cd "$PEER_ALICE"; sphere wallet use alice + sphere balance | tee "$SNAP/alice-balance-pre-B.txt" + alice_uct_pre_B=$(extract_confirmed_smallest_units UCT < "$SNAP/alice-balance-pre-B.txt") + alice_eth_pre_B=$(extract_confirmed_smallest_units ETH < "$SNAP/alice-balance-pre-B.txt") + + cd "$PEER_BOB"; sphere wallet use bob + sphere balance | tee "$SNAP/bob-balance-pre-B.txt" + bob_uct_pre_B=$(extract_confirmed_smallest_units UCT < "$SNAP/bob-balance-pre-B.txt") + bob_eth_pre_B=$(extract_confirmed_smallest_units ETH < "$SNAP/bob-balance-pre-B.txt") + + banner "Section B.1: Alice proposes 5 UCT for 0.1 ETH (smaller stake)" + cd "$PEER_ALICE"; sphere wallet use alice + sphere swap propose \ + --to "@${BOB_TAG}" \ + --offer 5 UCT \ + --want 0.1 ETH \ + --escrow "$ESCROW" \ + --message "soak: B=5 UCT for 0.1 ETH (will be declined)" \ + --json \ + 2>&1 | tee "$SNAP/alice-propose-B.log" + + SWAP_B=$(extract_swap_id "$SNAP/alice-propose-B.log") + [[ -n "$SWAP_B" ]] || { echo "FAIL: couldn't extract swap_id (B)" >&2; exit 1; } + echo "SWAP_B=$SWAP_B" + + banner "Section B.2: Bob waits for the proposal then rejects" + cd "$PEER_BOB"; sphere wallet use bob + + DEADLINE=$(( $(date +%s) + 90 )) + PROPOSAL_SEEN=0 + while (( $(date +%s) < DEADLINE )); do + sphere payments sync >/dev/null 2>&1 || true + sphere swap list --role acceptor 2>&1 | tee "$SNAP/bob-swap-list-B.log" || true + if grep -qE "${SWAP_B:0:8}" "$SNAP/bob-swap-list-B.log"; then + PROPOSAL_SEEN=1 + break + fi + sleep 3 + done + (( PROPOSAL_SEEN == 1 )) \ + || { echo "ASSERT FAIL (B-proposal-ingest): bob did not see proposal B within 90s" >&2; exit 1; } + + # NOTE: deliberately NOT using --json — assert_grep below targets the + # human renderer's unquoted "key : value" form rather than the + # double-quoted JSON shape that formatOutput emits in --json mode. + sphere swap reject "$SWAP_B" --reason "soak: declining for B" \ + 2>&1 | tee "$SNAP/bob-reject-B.log" + assert_grep "B-reject-state" 'new_state[[:space:]]*:[[:space:]]*cancelled' \ + "$SNAP/bob-reject-B.log" || rc=1 + assert_grep "B-reject-reason" 'reason[[:space:]]*:[[:space:]]*soak: declining for B' \ + "$SNAP/bob-reject-B.log" || rc=1 + + banner "Section B.3: Alice observes 'cancelled' state for the rejected swap" + cd "$PEER_ALICE"; sphere wallet use alice + + DEADLINE=$(( $(date +%s) + 90 )) + CANCEL_SEEN=0 + while (( $(date +%s) < DEADLINE )); do + sphere payments sync >/dev/null 2>&1 || true + sphere swap status "$SWAP_B" 2>&1 | tee "$SNAP/alice-swap-status-B.log" || true + if grep -qE 'progress[[:space:]]*:[[:space:]]*cancelled' "$SNAP/alice-swap-status-B.log"; then + CANCEL_SEEN=1 + break + fi + sleep 3 + done + (( CANCEL_SEEN == 1 )) \ + || { echo "ASSERT FAIL (B-alice-cancel-ingest): alice did not see swap B cancelled within 90s" >&2; rc=1; } + [[ "$rc" -eq 0 ]] && echo "ASSERT OK (B-alice-cancel): alice observed swap B cancelled" + + banner "Section B.4: No balance changes from Scenario B" + cd "$PEER_ALICE"; sphere wallet use alice + sphere payments sync >/dev/null 2>&1 || true + sphere balance | tee "$SNAP/alice-balance-post-B.txt" + cd "$PEER_BOB"; sphere wallet use bob + sphere payments sync >/dev/null 2>&1 || true + sphere balance | tee "$SNAP/bob-balance-post-B.txt" + + alice_uct_post_B=$(extract_confirmed_smallest_units UCT < "$SNAP/alice-balance-post-B.txt") + alice_eth_post_B=$(extract_confirmed_smallest_units ETH < "$SNAP/alice-balance-post-B.txt") + bob_uct_post_B=$(extract_confirmed_smallest_units UCT < "$SNAP/bob-balance-post-B.txt") + bob_eth_post_B=$(extract_confirmed_smallest_units ETH < "$SNAP/bob-balance-post-B.txt") + + for pair in \ + "alice-UCT $alice_uct_pre_B $alice_uct_post_B" \ + "alice-ETH $alice_eth_pre_B $alice_eth_post_B" \ + "bob-UCT $bob_uct_pre_B $bob_uct_post_B" \ + "bob-ETH $bob_eth_pre_B $bob_eth_post_B"; do + # shellcheck disable=SC2086 + set -- $pair + label="$1" pre="$2" post="$3" + if [[ "$pre" == "$post" ]]; then + echo "ASSERT OK (B-no-balance-change-$label): $pre == $post" + else + echo "ASSERT FAIL (B-no-balance-change-$label): $pre != $post (delta $(python3 -c "print($post - $pre)"))" >&2 + rc=1 + fi + done + + if (( rc != 0 )); then + banner "FAIL Scenario B — see ASSERT FAIL lines above" + exit "$rc" + fi + banner "ALL GREEN — Scenario B (reject) succeeded" +fi + +# =========================================================================== +# Scenario C — Proposer rescinds before counterparty accepts +# =========================================================================== +if [[ "$SCENARIO" == *"C"* ]]; then + banner "Scenario C — propose → (no accept) → proposer cancels pre-announce" + + cd "$PEER_ALICE"; sphere wallet use alice + sphere balance | tee "$SNAP/alice-balance-pre-C.txt" + alice_uct_pre_C=$(extract_confirmed_smallest_units UCT < "$SNAP/alice-balance-pre-C.txt") + alice_eth_pre_C=$(extract_confirmed_smallest_units ETH < "$SNAP/alice-balance-pre-C.txt") + + banner "Section C.1: Alice proposes a tiny swap (will be cancelled pre-announce)" + sphere swap propose \ + --to "@${BOB_TAG}" \ + --offer 1 UCT \ + --want 0.01 ETH \ + --escrow "$ESCROW" \ + --message "soak: C=1 UCT for 0.01 ETH (will be cancelled)" \ + --json \ + 2>&1 | tee "$SNAP/alice-propose-C.log" + + SWAP_C=$(extract_swap_id "$SNAP/alice-propose-C.log") + [[ -n "$SWAP_C" ]] || { echo "FAIL: couldn't extract swap_id (C)" >&2; exit 1; } + echo "SWAP_C=$SWAP_C" + + # Cancel immediately, before bob has a chance to accept. The CLI's + # state-aware cancel takes the pre-announce branch and exits without + # waiting for any escrow round-trip. + banner "Section C.2: Alice cancels the swap (pre-announce)" + # See note in §B.2 — human renderer's "key : value" lines are what + # assert_grep targets. + sphere swap cancel "$SWAP_C" 2>&1 | tee "$SNAP/alice-cancel-C.log" + assert_grep "C-cancel-state" 'new_state[[:space:]]*:[[:space:]]*cancelled' \ + "$SNAP/alice-cancel-C.log" || rc=1 + assert_grep "C-cancel-prev-state" 'prev_state[[:space:]]*:[[:space:]]*(proposed|accepted)' \ + "$SNAP/alice-cancel-C.log" || rc=1 + + # If the pre-announce branch was taken correctly, the JSON output's + # deposits_returned will be `false` (no escrow involvement) — this is + # the cleanest signal that the cancel went through the local-only path. + assert_grep "C-cancel-pre-announce" 'deposits_returned[[:space:]]*:[[:space:]]*false' \ + "$SNAP/alice-cancel-C.log" || rc=1 + + banner "Section C.3: No balance changes from Scenario C" + sphere payments sync >/dev/null 2>&1 || true + sphere balance | tee "$SNAP/alice-balance-post-C.txt" + alice_uct_post_C=$(extract_confirmed_smallest_units UCT < "$SNAP/alice-balance-post-C.txt") + alice_eth_post_C=$(extract_confirmed_smallest_units ETH < "$SNAP/alice-balance-post-C.txt") + + [[ "$alice_uct_pre_C" == "$alice_uct_post_C" ]] \ + || { echo "ASSERT FAIL (C-no-balance-change-alice-UCT): $alice_uct_pre_C != $alice_uct_post_C" >&2; rc=1; } + [[ "$alice_eth_pre_C" == "$alice_eth_post_C" ]] \ + || { echo "ASSERT FAIL (C-no-balance-change-alice-ETH): $alice_eth_pre_C != $alice_eth_post_C" >&2; rc=1; } + (( rc == 0 )) && echo "ASSERT OK (C-no-balance-change): alice's balances unchanged" + + if (( rc != 0 )); then + banner "FAIL Scenario C — see ASSERT FAIL lines above" + exit "$rc" + fi + banner "ALL GREEN — Scenario C (proposer cancel) succeeded" +fi + +banner "ALL GREEN — swap round-trip soak succeeded ($SCENARIO)" +exit 0