diff --git a/README.md b/README.md index db4b8da..38410ce 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,11 @@ Bitcoin Lightning payment is received** in an [Arkade](https://docs.arkadeos.com Receiving Lightning in Arkade works via a **Boltz reverse submarine swap**: the wallet generates a BOLT11 invoice (a reverse swap identified by a `swapId`); when the -payer pays, Boltz locks the funds and the swap progresses to **`invoice.settled`** — -that's the "payment received" signal this service watches for. +payer pays, Boltz funds/locks the VTXO (**`transaction.mempool`**, "claimable") and +the phone then claims it. This service pushes at the *claimable* stage — Boltz has +already been paid on Lightning, so the money is the user's; the push **wakes the +wallet app** (phones can't run reliable background jobs) so it can finalize the +claim. We push once, at claimable — not again on `invoice.settled`. It is built on the official Arkade packages — [`@arkade-os/sdk`](https://www.npmjs.com/package/@arkade-os/sdk) and [`@arkade-os/boltz-swap`](https://www.npmjs.com/package/@arkade-os/boltz-swap) — @@ -19,7 +22,7 @@ the polling fallback, and the reconnect/backoff logic. ``` wallet ──POST /register {swap, topic}──▶ service ── @arkade-os/boltz-swap SwapManager ──▶ Boltz (creates invoice via │ (one ws, swap.update, polling fallback) - ArkadeSwaps.createLightningInvoice) │ onSwapUpdate → invoice.settled + ArkadeSwaps.createLightningInvoice) │ onSwapUpdate → claimable (transaction.mempool) ▼ ntfy.sh topic ──▶ 📱 your phone ``` @@ -39,7 +42,7 @@ wallet ──POST /register {swap, topic}──▶ service ── @arkade-os/bol | file | responsibility | |------|----------------| | `src/swapWatcher.ts` | builds the `@arkade-os/boltz-swap` `SwapManager` (monitor-only) | -| `src/paymentService.ts` | wires `SwapManager` events → push on `invoice.settled` (via `isReverseSuccessStatus`) | +| `src/paymentService.ts` | wires `SwapManager` events → push when claimable (via `isReverseClaimableStatus`); prunes on delivery/terminal | | `src/registry.ts` | persisted `swapId → {topic, swap}` map; resubscribed on restart | | `src/notifier/ntfyNotifier.ts` | `Notifier` implementation for ntfy.sh | | `src/server.ts` | HTTP API | @@ -80,16 +83,18 @@ pnpm build && pnpm start ## Try it end-to-end -1. Install the **ntfy** app on your phone and subscribe to a unique topic, e.g. - `arkade-demo-7f3a`. +1. For local testing, install the **ntfy** app on your phone and subscribe to a unique + topic, e.g. `arkade-demo-7f3a` (ntfy needs no account/keys). The production provider + is [BlueWallet GroundControl](https://github.com/BlueWallet/GroundControl); set + exactly one of `NTFY_BASE_URL` / `GROUNDCONTROL_BASE_URL`. 2. Start the service: `pnpm dev`. -3. **Quick push smoke test** (no payment needed) — register a swap, then simulate the - settle event: +3. **Quick push smoke test** (no payment needed) — register a swap, then simulate Boltz + funding it (`transaction.mempool`): ```bash curl -X POST localhost:3000/register -H 'content-type: application/json' \ -d '{"topic":"arkade-demo-7f3a","swap":{"id":"demo","type":"reverse","status":"swap.created"}}' curl -X POST localhost:3000/simulate -H 'content-type: application/json' \ - -d '{"swapId":"demo","status":"invoice.settled"}' + -d '{"swapId":"demo","status":"transaction.mempool"}' ``` Your phone should buzz with "Payment received ⚡". 4. **Full flow** against mutinynet — create a real invoice and pay it: @@ -98,8 +103,8 @@ pnpm build && pnpm start ``` The demo uses `ArkadeSwaps.createLightningInvoice` to mint a BOLT11 invoice, prints it, and registers the (preimage-redacted) pending swap. Pay the invoice from any - mutinynet Lightning wallet → `SwapManager` sees `invoice.settled` → push fires. - (Requires connectivity to the Arkade mutinynet server + Boltz.) + mutinynet Lightning wallet → `SwapManager` sees `transaction.mempool` (funded) → + push fires. (Requires connectivity to the Arkade mutinynet server + Boltz.) ## Tests @@ -111,22 +116,23 @@ pnpm test - `test/paymentFlow.test.ts` — a **component test that drives the real `@arkade-os/boltz-swap` `SwapManager`** with a mocked `globalThis.WebSocket`, feeding mocked Boltz `swap.update` events through the whole pipeline: register → - subscribe → `transaction.mempool`/`confirmed` (no push) → `invoice.settled` - (exactly one push) → swap pruned. Also covers duplicate-settle de-duplication and - the `/simulate` path. + subscribe → `transaction.mempool` (exactly one push) → swap pruned. Also covers the + `mempool → confirmed` de-duplication, terminal/failure pruning, and the `/simulate` + path. - `test/deliveryRetry.test.ts` — proves a transient `notify` failure is **not** - lost: the reconciliation sweep redelivers a settled-but-undelivered swap and then + lost: the reconciliation sweep redelivers a claimable-but-undelivered swap and then prunes it. ## Reliability -- **Never lose the one push.** Delivery is retried with backoff, and a periodic - reconciliation sweep re-attempts any swap that is settled but still registered — - so a transient ntfy outage (or settling while the process was down) is recovered, - not dropped. A synchronous in-flight guard prevents a re-entrant event from - double-sending. -- **Bounded state.** A delivered or terminally-failed swap is pruned from both the - registry and the manager, so the persisted store stays small. +- **Never lose the wake-up.** Delivery is retried with backoff, and a periodic + reconciliation sweep re-attempts any swap that is claimable but still registered — + so a transient push-provider outage (or becoming claimable while the process was + down) is recovered, not dropped. A synchronous in-flight guard prevents a + re-entrant event (`mempool → confirmed`) from double-sending. +- **Bounded state.** A delivered swap, or one that reaches a terminal state + (settled/failed/expired) without us pushing, is pruned from both the registry and + the manager, so the persisted store stays small. - **Crash-safe persistence.** The registry writes to a temp file then `rename()`s, so a crash mid-write can't corrupt `registrations.json`. diff --git a/src/paymentService.ts b/src/paymentService.ts index 46594d3..da46ebc 100644 --- a/src/paymentService.ts +++ b/src/paymentService.ts @@ -1,6 +1,7 @@ import { + isReverseClaimableStatus, + isReverseFinalStatus, isReverseSuccessStatus, - isReverseFailedStatus, type BoltzSwap, type BoltzSwapStatus, type SwapManagerClient, @@ -14,9 +15,9 @@ export interface PaymentServiceDeps { registry: Registry; notifier: Notifier; logger: Logger; - /** How often to retry delivery for settled-but-undelivered swaps. Default 60s. */ + /** How often to retry delivery for claimable-but-undelivered swaps. Default 60s. */ sweepIntervalMs?: number; - /** Delivery attempts before giving up for this round. Default 3. */ + /** Delivery attempts before leaving the swap for the next sweep. Default 3. */ deliveryAttempts?: number; } @@ -35,19 +36,22 @@ const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms /** * Wires the Boltz SwapManager's lifecycle events to push notifications. * - * Flow: the wallet registers a reverse swap → SwapManager streams its status over - * the Boltz websocket → on `invoice.settled` (reverse success) we push to the - * registered ntfy topic exactly once, then stop watching the swap. + * We push when the reverse swap becomes **claimable** (`transaction.mempool`): + * Boltz has funded/locked the VTXO and been paid on Lightning, but the phone hasn't + * claimed it yet. The push wakes the wallet app so it can finalize the claim. * - * Delivery is made robust on three fronts: - * - an in-flight guard (set synchronously) prevents a re-entrant event from - * sending a duplicate push before the first send resolves; - * - each send is retried with backoff; - * - a periodic reconciliation sweep re-attempts any swap that is settled but - * still registered (i.e. a previous delivery failed, or settled while the - * process was down), so a transient ntfy outage never loses the one push. - * A delivered or terminally-failed swap is pruned from the registry and the - * manager, keeping both bounded. + * We *also* push on the success-terminal state (`invoice.settled`) when we haven't + * already delivered for that swap. This future-proofs against an offline claimer + * finalizing the receive before the wallet — or this service — ever observed the + * claimable window: the swap can jump straight to settled, and the receiver still + * needs to be woken that they were paid. (The mempool → settled fast path is + * deduped: the first delivery prunes the swap, so settled finds nothing to send.) + * + * Delivery is robust: an in-flight guard collapses the `mempool → confirmed` + * transition into a single send, each send is retried with backoff, and a periodic + * sweep re-attempts claimable-or-settled-but-undelivered swaps. A delivered swap — or + * one that reaches a *failed* terminal state without a push — is pruned from the + * registry and the manager. */ export async function attachPaymentNotifications(deps: PaymentServiceDeps): Promise { const { manager, registry, notifier, logger } = deps; @@ -66,16 +70,18 @@ export async function attachPaymentNotifications(deps: PaymentServiceDeps): Prom for (let attempt = 0; attempt < deliveryAttempts; attempt++) { try { const swap = reg.swap; + // `swap.request`/`response` may be absent: the /register schema accepts a + // minimal swap ({ id, type, status }), so read these defensively. await notifier.notify( { topic: reg.topic }, { title: "Payment received", - body: `⚡ Lightning payment settled${suffix}.`, + body: `⚡ Lightning payment received${suffix}.`, tags: ["zap", "moneybag"], priority: "high", - memo: reg.label ?? swap.request.description ?? "", - preimage: swap.preimage, - amtPaidSat: swap.request.invoiceAmount, + memo: reg.label ?? swap.request?.description ?? "", + preimage: swap.preimage ?? "", + amtPaidSat: swap.request?.invoiceAmount ?? swap.response?.onchainAmount ?? 0, }, ); // Delivered: stop watching and prune. @@ -87,7 +93,7 @@ export async function attachPaymentNotifications(deps: PaymentServiceDeps): Prom if (attempt < deliveryAttempts - 1) await sleep(500 * 2 ** attempt); } } - // Left registered & settled — the sweep will retry it later. + // Left registered & claimable — the sweep will retry it later. logger.error( { err: lastErr, swapId: reg.swapId }, "failed to deliver push after retries; will retry on next sweep", @@ -102,25 +108,34 @@ export async function attachPaymentNotifications(deps: PaymentServiceDeps): Prom const reg = registry.markStatus(swap.id, swap.status); if (!reg) return; - if (isReverseSuccessStatus(swap.status)) { + // Funded by Boltz and not yet claimed, OR already settled (e.g. an offline + // claimer finalized the receive before we ever saw the claimable window) → + // either way the receiver was paid, so wake the phone. `deliver` prunes on + // success, so a normal mempool → settled run only sends once. + if (isReverseClaimableStatus(swap.status) || isReverseSuccessStatus(swap.status)) { void deliver(reg); return; } - if (isReverseFailedStatus(swap.status)) { + // Failed terminal (expired/refunded) → nothing to notify, just stop tracking it. + if (isReverseFinalStatus(swap.status)) { logger.info({ swapId: swap.id, status: swap.status }, "reverse swap failed; pruning"); registry.remove(swap.id); void manager.removeSwap(swap.id); } }; - // Reconciliation: re-attempt any settled-but-still-registered swap. Catches - // deliveries that failed all retries, and swaps that settled while we were down - // (the SwapManager dedupes an unchanged status, so it won't re-emit on restart). + // Reconciliation: re-attempt any claimable-but-still-registered swap (delivery + // that failed all retries, or that became claimable while we were down — the + // SwapManager dedupes an unchanged status, so it won't re-emit on restart), and + // prune any swap that has since reached a terminal state. const sweep = (): void => { for (const reg of registry.all()) { - if (isReverseSuccessStatus(reg.swap.status) && !inFlight.has(reg.swapId)) { - void deliver(reg); + if (isReverseClaimableStatus(reg.swap.status) || isReverseSuccessStatus(reg.swap.status)) { + if (!inFlight.has(reg.swapId)) void deliver(reg); + } else if (isReverseFinalStatus(reg.swap.status)) { + registry.remove(reg.swapId); + void manager.removeSwap(reg.swapId); } } }; diff --git a/test/deliveryRetry.test.ts b/test/deliveryRetry.test.ts index 350ef85..3271e28 100644 --- a/test/deliveryRetry.test.ts +++ b/test/deliveryRetry.test.ts @@ -27,7 +27,7 @@ describe("delivery reliability", () => { rmSync(dir, { recursive: true, force: true }); }); - it("keeps a settled swap registered after inline retries exhaust, then delivers on sweep", async () => { + it("keeps a claimable swap registered after inline retries exhaust, then delivers on sweep", async () => { dir = mkdtempSync(join(tmpdir(), "deliv-")); let calls = 0; @@ -48,7 +48,7 @@ describe("delivery reliability", () => { deliveryAttempts: 1, }); - registry.add({ swap: mockReverseSwap("s1", "invoice.settled"), topic: "t1" }); + registry.add({ swap: mockReverseSwap("s1", "transaction.mempool"), topic: "t1" }); await vi.waitFor( () => { diff --git a/test/paymentFlow.test.ts b/test/paymentFlow.test.ts index 1edce4c..8f54c47 100644 --- a/test/paymentFlow.test.ts +++ b/test/paymentFlow.test.ts @@ -100,7 +100,7 @@ describe("payment flow (real SwapManager, mocked Boltz events)", () => { rmSync(dir, { recursive: true, force: true }); }); - it("end-to-end: register → Boltz settle → one push → registry and manager pruned", async () => { + it("end-to-end: register → Boltz funds (claimable) → one wake push → registry and manager pruned", async () => { const ws = FakeWebSocket.instances[0]!; const hash = "11".repeat(32); const swap = mockReverseSwap("reverse-swap-1", "swap.created", { preimageHash: hash }); @@ -115,12 +115,12 @@ describe("payment flow (real SwapManager, mocked Boltz events)", () => { expect(ws.subscribedIds()).toContain("reverse-swap-1"); expect(await manager.hasSwap("reverse-swap-1")).toBe(true); - await ws.emitUpdate("reverse-swap-1", "transaction.mempool"); - await ws.emitUpdate("reverse-swap-1", "transaction.confirmed"); + // Nothing has been funded yet → no push. await flush(); expect(notify).not.toHaveBeenCalled(); - await ws.emitUpdate("reverse-swap-1", "invoice.settled"); + // Boltz funds/locks the VTXO (claimable, not yet claimed) → wake the phone. + await ws.emitUpdate("reverse-swap-1", "transaction.mempool"); await flush(); expect(notify).toHaveBeenCalledOnce(); @@ -128,7 +128,7 @@ describe("payment flow (real SwapManager, mocked Boltz events)", () => { expect(target).toEqual({ topic: hash }); expect(payload).toMatchObject({ title: "Payment received", - body: "⚡ Lightning payment settled (1000 sats).", + body: "⚡ Lightning payment received (1000 sats).", memo: "1000 sats", preimage: "", amtPaidSat: 10_000, @@ -139,6 +139,49 @@ describe("payment flow (real SwapManager, mocked Boltz events)", () => { expect(await manager.hasSwap("reverse-swap-1")).toBe(false); }); + it("offline claimer: jumps straight to settled (never saw mempool) → still wakes once", async () => { + const ws = FakeWebSocket.instances[0]!; + const hash = "22".repeat(32); + const swap = mockReverseSwap("reverse-swap-offline", "swap.created", { preimageHash: hash }); + + await app.inject({ + method: "POST", + url: "/register", + payload: { swap, topic: hash, label: "offline" }, + }); + + // No claimable update is ever observed — an offline claimer finalized the + // receive and the swap reports settled directly. + await ws.emitUpdate("reverse-swap-offline", "invoice.settled"); + await flush(); + + expect(notify).toHaveBeenCalledOnce(); + const [, payload] = notify.mock.calls[0]! as [NotifyTarget, NotifyPayload]; + expect(payload).toMatchObject({ title: "Payment received", memo: "offline" }); + + const list = await app.inject({ method: "GET", url: "/register" }); + expect(list.json().registrations).toHaveLength(0); + expect(await manager.hasSwap("reverse-swap-offline")).toBe(false); + }); + + it("does not double-notify across mempool → settled", async () => { + const ws = FakeWebSocket.instances[0]!; + await app.inject({ + method: "POST", + url: "/register", + payload: { swap: mockReverseSwap("reverse-swap-settle"), topic: "phone-topic" }, + }); + + // Normal path: claimable wake prunes the swap, so the later settled update + // finds nothing to send. + await ws.emitUpdate("reverse-swap-settle", "transaction.mempool"); + await flush(); + await ws.emitUpdate("reverse-swap-settle", "invoice.settled"); + await flush(); + + expect(notify).toHaveBeenCalledTimes(1); + }); + it("does not notify on Boltz failure statuses", async () => { const ws = FakeWebSocket.instances[0]!; await app.inject({ @@ -197,7 +240,7 @@ describe("payment flow (real SwapManager, mocked Boltz events)", () => { expect(notify).not.toHaveBeenCalled(); }); - it("does not double-notify on a duplicate settled event", async () => { + it("does not double-notify across the mempool → confirmed transition", async () => { const ws = FakeWebSocket.instances[0]!; await app.inject({ method: "POST", @@ -205,9 +248,11 @@ describe("payment flow (real SwapManager, mocked Boltz events)", () => { payload: { swap: mockReverseSwap("reverse-swap-2"), topic: "phone-topic" }, }); - await ws.emitUpdate("reverse-swap-2", "invoice.settled"); + // Both statuses are claimable; the first delivery prunes the swap so the second + // never reaches the handler. + await ws.emitUpdate("reverse-swap-2", "transaction.mempool"); await flush(); - await ws.emitUpdate("reverse-swap-2", "invoice.settled"); + await ws.emitUpdate("reverse-swap-2", "transaction.confirmed"); await flush(); expect(notify).toHaveBeenCalledTimes(1); diff --git a/test/paymentService.test.ts b/test/paymentService.test.ts index 9bc873b..0db4ac2 100644 --- a/test/paymentService.test.ts +++ b/test/paymentService.test.ts @@ -49,7 +49,7 @@ describe("attachPaymentNotifications", () => { }); } - it("builds the settlement notify payload from the registration and swap", async () => { + it("builds the notify payload from the registration and swap on the claimable status", async () => { await start(); const hash = "fe".repeat(32); registry.add({ @@ -62,7 +62,8 @@ describe("attachPaymentNotifications", () => { label: "42k sats", }); - payments.onSwapUpdate(mockReverseSwap("s1", "invoice.settled"), "transaction.confirmed"); + // transaction.mempool = Boltz funded the VTXO, not yet claimed → wake the phone. + payments.onSwapUpdate(mockReverseSwap("s1", "transaction.mempool"), "swap.created"); await flush(); expect(notify).toHaveBeenCalledOnce(); @@ -70,7 +71,7 @@ describe("attachPaymentNotifications", () => { expect(target).toEqual({ topic: hash }); expect(payload).toMatchObject({ title: "Payment received", - body: "⚡ Lightning payment settled (42k sats).", + body: "⚡ Lightning payment received (42k sats).", memo: "42k sats", preimage: "", amtPaidSat: 42_000, @@ -98,8 +99,8 @@ describe("attachPaymentNotifications", () => { sweepIntervalMs: 3_600_000, }); - registry.add({ swap: mockReverseSwap("s1", "invoice.settled"), topic: "t1" }); - payments.onSwapUpdate(mockReverseSwap("s1", "invoice.settled"), "swap.created"); + registry.add({ swap: mockReverseSwap("s1", "transaction.mempool"), topic: "t1" }); + payments.onSwapUpdate(mockReverseSwap("s1", "transaction.mempool"), "swap.created"); // Inline retries back off (500ms, 1s) between attempts. await vi.waitFor(() => expect(attempts).toBe(3), { timeout: 3000 }); @@ -119,10 +120,25 @@ describe("attachPaymentNotifications", () => { expect(removeSwap).toHaveBeenCalledWith("s1"); }); + it("wakes once on settled when the claimable window was never observed (offline claimer)", async () => { + await start(); + registry.add({ swap: mockReverseSwap("s1"), topic: "t1" }); + + // We never saw the claimable window (e.g. an offline claimer finalized the + // receive, or the process was down) and the next update we observe is already + // invoice.settled — the receiver was still paid, so wake them, then prune. + payments.onSwapUpdate(mockReverseSwap("s1", "invoice.settled"), "swap.created"); + await flush(); + + expect(notify).toHaveBeenCalledOnce(); + expect(registry.get("s1")).toBeUndefined(); + expect(removeSwap).toHaveBeenCalledWith("s1"); + }); + it("ignores updates for swaps that were never registered", async () => { await start(); - payments.onSwapUpdate(mockReverseSwap("unknown", "invoice.settled"), "swap.created"); + payments.onSwapUpdate(mockReverseSwap("unknown", "transaction.mempool"), "swap.created"); await flush(); expect(notify).not.toHaveBeenCalled(); @@ -149,8 +165,10 @@ describe("attachPaymentNotifications", () => { }); registry.add({ swap: mockReverseSwap("s1"), topic: "t1" }); - payments.onSwapUpdate(mockReverseSwap("s1", "invoice.settled"), "swap.created"); - payments.onSwapUpdate(mockReverseSwap("s1", "invoice.settled"), "invoice.settled"); + // mempool then confirmed are both claimable; the in-flight guard must collapse + // them into a single send. + payments.onSwapUpdate(mockReverseSwap("s1", "transaction.mempool"), "swap.created"); + payments.onSwapUpdate(mockReverseSwap("s1", "transaction.confirmed"), "transaction.mempool"); await flush(); expect(notify).toHaveBeenCalledTimes(1);