Trigger the push on claimable (pending) instead of settled (#3)
This commit is contained in:
parent
dfa3f8957e
commit
5c0731ebbd
50
README.md
50
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`.
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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(
|
||||
() => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user