Trigger the push on claimable (pending) instead of settled (#3)

This commit is contained in:
Marco Argentieri 2026-06-09 21:56:56 +02:00 committed by GitHub
parent dfa3f8957e
commit 5c0731ebbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 151 additions and 67 deletions

View File

@ -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`.

View File

@ -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<void> => 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<PaymentService> {
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);
}
}
};

View File

@ -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(
() => {

View File

@ -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);

View File

@ -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);