feat: groundcontrol as notifier (#2)

This commit is contained in:
Overtorment 2026-06-09 14:19:54 +01:00 committed by GitHub
parent 9a85fac528
commit dfa3f8957e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 578 additions and 66 deletions

View File

@ -15,8 +15,12 @@ PORT=3000
# Polling fallback interval (ms) used to reconcile statuses missed while the websocket was down.
POLL_INTERVAL_MS=30000
# ntfy.sh push provider. Install the ntfy app, subscribe to a topic, and pass that topic in /register.
# Push notifier — set exactly one of the following:
# ntfy.sh: install the app, subscribe to a topic, and pass that topic in /register.
NTFY_BASE_URL=https://ntfy.sh
# BlueWallet GroundControl: wallet subscribes the invoice preimage hash via
# /majorTomToGroundControl; this service posts to /lightningInvoiceGotSettled on settle.
# GROUNDCONTROL_BASE_URL=https://groundcontrol.bluewallet.io
# Where registrations are persisted (so they survive restarts and get re-subscribed).
DATA_FILE=./data/registrations.json

35
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: CI
on:
push:
branches:
- master
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm run typecheck
- name: Test
run: pnpm run test

View File

@ -13,7 +13,8 @@ const networkSchema = z.enum([
"regtest",
]);
const envSchema = z.object({
const envSchema = z
.object({
NETWORK: networkSchema.default("mutinynet"),
// Boltz REST base. The boltz-swap SwapManager derives its websocket URL from this.
BOLTZ_API_URL: z.string().url().default("https://api.boltz.mutinynet.arkade.sh"),
@ -21,12 +22,23 @@ const envSchema = z.object({
ESPLORA_URL: z.string().url().default("https://mutinynet.com/api"),
PORT: z.coerce.number().int().positive().default(3000),
POLL_INTERVAL_MS: z.coerce.number().int().positive().default(30_000),
NTFY_BASE_URL: z.string().url().default("https://ntfy.sh"),
NTFY_BASE_URL: z.string().url().optional(),
GROUNDCONTROL_BASE_URL: z.string().url().optional(),
DATA_FILE: z.string().default("./data/registrations.json"),
LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
.default("info"),
});
})
.superRefine((data, ctx) => {
const hasNtfy = Boolean(data.NTFY_BASE_URL);
const hasGroundControl = Boolean(data.GROUNDCONTROL_BASE_URL);
if (hasNtfy === hasGroundControl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Set exactly one of NTFY_BASE_URL or GROUNDCONTROL_BASE_URL",
});
}
});
export type Network = z.infer<typeof networkSchema>;
export type Config = z.infer<typeof envSchema>;

View File

@ -0,0 +1,42 @@
import type { Logger } from "../logger.js";
import type { Notifier, NotifyPayload, NotifyTarget } from "./types.js";
/** Body for GroundControl `POST /lightningInvoiceGotSettled`. */
export interface LightningInvoiceSettledNotification {
memo: string;
preimage: string;
hash: string;
amt_paid_sat: number;
}
/**
* Notifies [GroundControl](https://github.com/BlueWallet/GroundControl/) that a
* Lightning invoice was paid. GroundControl looks up devices subscribed to the
* preimage hash (via `/majorTomToGroundControl`) and enqueues FCM/APNS pushes.
*/
export class GroundControlNotifier implements Notifier {
constructor(
private readonly baseUrl: string,
private readonly logger: Logger,
) {}
async notify(target: NotifyTarget, payload: NotifyPayload): Promise<void> {
const body: LightningInvoiceSettledNotification = {
memo: payload.memo ?? payload.title,
preimage: payload.preimage ?? "",
hash: target.topic,
amt_paid_sat: payload.amtPaidSat ?? 0,
};
const res = await fetch(`${this.baseUrl.replace(/\/$/, "")}/lightningInvoiceGotSettled`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`GroundControl push failed: ${res.status} ${res.statusText} ${text}`.trim());
}
this.logger.info({ hash: body.hash, amtPaidSat: body.amt_paid_sat }, "GroundControl settlement notified");
}
}

View File

@ -1,5 +1,5 @@
export interface NotifyTarget {
/** ntfy topic. For other providers this would carry a device token instead. */
/** ntfy topic, or preimage hash (hex) for GroundControl. */
topic: string;
}
@ -9,6 +9,10 @@ export interface NotifyPayload {
/** Optional tags/emoji (ntfy supports these); ignored by providers that can't use them. */
tags?: string[];
priority?: "min" | "low" | "default" | "high" | "max";
memo?: string;
/** Empty when the wallet redacts the preimage at /register. */
preimage?: string;
amtPaidSat?: number;
}
/**

View File

@ -1,12 +1,13 @@
import { config } from "./config.js";
import { logger } from "./logger.js";
import { GroundControlNotifier } from "./notifier/groundControlNotifier.js";
import { NtfyNotifier } from "./notifier/ntfyNotifier.js";
import type { Notifier } from "./notifier/types.js";
/**
* Selects the push provider. Only ntfy ships in this sample; add cases here for
* FCM / Expo / Web-Push and they slot in behind the same Notifier interface.
*/
/** Selects the push provider from whichever base URL is configured. */
export function createNotifier(): Notifier {
return new NtfyNotifier(config.NTFY_BASE_URL, logger);
if (config.GROUNDCONTROL_BASE_URL) {
return new GroundControlNotifier(config.GROUNDCONTROL_BASE_URL, logger);
}
return new NtfyNotifier(config.NTFY_BASE_URL!, logger);
}

View File

@ -65,6 +65,7 @@ export async function attachPaymentNotifications(deps: PaymentServiceDeps): Prom
let lastErr: unknown;
for (let attempt = 0; attempt < deliveryAttempts; attempt++) {
try {
const swap = reg.swap;
await notifier.notify(
{ topic: reg.topic },
{
@ -72,6 +73,9 @@ export async function attachPaymentNotifications(deps: PaymentServiceDeps): Prom
body: `⚡ Lightning payment settled${suffix}.`,
tags: ["zap", "moneybag"],
priority: "high",
memo: reg.label ?? swap.request.description ?? "",
preimage: swap.preimage,
amtPaidSat: swap.request.invoiceAmount,
},
);
// Delivered: stop watching and prune.

View File

@ -5,7 +5,7 @@ import type { Logger } from "./logger.js";
export interface Registration {
swapId: string;
/** ntfy topic to push to (extensible to device tokens for other providers). */
/** ntfy topic, or preimage hash when using GroundControl. */
topic: string;
label?: string;
/**

View File

@ -6,9 +6,8 @@ import type { SwapManagerClient } from "@arkade-os/boltz-swap";
import { Registry } from "../src/registry.js";
import { attachPaymentNotifications, type PaymentService } from "../src/paymentService.js";
import type { Notifier } from "../src/notifier/types.js";
import { silentLogger, mockReverseSwap } from "./helpers.js";
import { mockReverseSwap, silentLogger } from "./helpers.js";
/** Minimal SwapManagerClient — only the methods attachPaymentNotifications uses. */
function fakeManager(removeSwap: () => Promise<void>): SwapManagerClient {
return {
onSwapUpdate: async () => () => {},
@ -28,13 +27,13 @@ describe("delivery reliability", () => {
rmSync(dir, { recursive: true, force: true });
});
it("recovers a settled swap via the sweep when the first delivery fails (no lost push)", async () => {
it("keeps a settled swap registered after inline retries exhaust, then delivers on sweep", async () => {
dir = mkdtempSync(join(tmpdir(), "deliv-"));
let calls = 0;
const notify = vi.fn(async () => {
calls += 1;
if (calls === 1) throw new Error("ntfy transiently down");
if (calls === 1) throw new Error("push provider down");
});
const notifier = { notify } as unknown as Notifier;
@ -46,18 +45,19 @@ describe("delivery reliability", () => {
notifier,
logger: silentLogger,
sweepIntervalMs: 25,
deliveryAttempts: 1, // one shot per round, so the failure must be recovered by the sweep
deliveryAttempts: 1,
});
// A swap that is already settled but not yet delivered (e.g. settled while the
// process was down, or a prior delivery failed). The sweep must deliver it.
registry.add({ swap: mockReverseSwap("s1", "invoice.settled"), topic: "t1" });
// The first delivery throws, but the swap is not lost: a later sweep
// redelivers successfully → push lands, swap is pruned. If a failure dropped
// the push, it would stay at 1 call and never be pruned.
await vi.waitFor(() => expect(registry.get("s1")).toBeUndefined(), { timeout: 1500 });
expect(calls).toBeGreaterThanOrEqual(2);
await vi.waitFor(
() => {
expect(registry.get("s1")).toBeUndefined();
expect(calls).toBeGreaterThanOrEqual(2);
},
{ timeout: 1500 },
);
expect(removed).toHaveBeenCalledWith("s1");
expect(notify).toHaveBeenCalledTimes(calls);
});
});

View File

@ -0,0 +1,83 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { GroundControlNotifier } from "../src/notifier/groundControlNotifier.js";
import { lastFetchRequest, silentLogger, stubFetch, stubFetchOk } from "./helpers.js";
describe("GroundControlNotifier", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("completes without error when GroundControl accepts the settlement", async () => {
stubFetchOk();
const notifier = new GroundControlNotifier("https://gc.example", silentLogger);
await expect(
notifier.notify(
{ topic: "ab".repeat(32) },
{ title: "Payment received", body: "settled", amtPaidSat: 5000 },
),
).resolves.toBeUndefined();
});
it("strips a trailing slash from the configured base URL", async () => {
const fetchMock = stubFetchOk();
const notifier = new GroundControlNotifier("https://gc.example/", silentLogger);
await notifier.notify({ topic: "hash" }, { title: "t", body: "b" });
expect(lastFetchRequest(fetchMock).url).toBe("https://gc.example/lightningInvoiceGotSettled");
});
it("uses title as memo when memo is omitted", async () => {
const fetchMock = stubFetchOk();
const notifier = new GroundControlNotifier("https://gc.example", silentLogger);
await notifier.notify({ topic: "hash" }, { title: "Invoice paid", body: "ignored by GC" });
const body = JSON.parse(lastFetchRequest(fetchMock).init.body as string);
expect(body.memo).toBe("Invoice paid");
});
it("routes preimage hash via target.topic, not the payload body", async () => {
const fetchMock = stubFetchOk();
const notifier = new GroundControlNotifier("https://gc.example", silentLogger);
const hash = "cd".repeat(32);
await notifier.notify(
{ topic: hash },
{ title: "t", body: "b", memo: "m", preimage: "ee".repeat(32), amtPaidSat: 99 },
);
const body = JSON.parse(lastFetchRequest(fetchMock).init.body as string);
expect(body.hash).toBe(hash);
expect(body).not.toHaveProperty("topic");
});
it("surfaces the GroundControl error body in the thrown message", async () => {
stubFetch(
async () =>
({
ok: false,
status: 500,
statusText: "Internal Server Error",
text: async () => "preimage doesnt match hash",
}) as Response,
);
const notifier = new GroundControlNotifier("https://gc.example", silentLogger);
await expect(
notifier.notify({ topic: "hash" }, { title: "t", body: "b" }),
).rejects.toThrow("preimage doesnt match hash");
});
it("propagates network failures from fetch", async () => {
stubFetch(async () => {
throw new Error("connection reset");
});
const notifier = new GroundControlNotifier("https://gc.example", silentLogger);
await expect(
notifier.notify({ topic: "hash" }, { title: "t", body: "b" }),
).rejects.toThrow("connection reset");
});
});

View File

@ -1,3 +1,4 @@
import { vi } from "vitest";
import type { BoltzReverseSwap, BoltzSwapStatus } from "@arkade-os/boltz-swap";
import type { Logger } from "../src/logger.js";
@ -13,27 +14,36 @@ export const silentLogger = {
child: () => silentLogger,
} as unknown as Logger;
export interface MockReverseSwapOptions {
preimage?: string;
preimageHash?: string;
invoiceAmount?: number;
description?: string;
}
/** A minimal but type-complete pending reverse swap, as a wallet would register. */
export function mockReverseSwap(
id = "reverse-swap-1",
status: BoltzSwapStatus = "swap.created",
opts: MockReverseSwapOptions = {},
): BoltzReverseSwap {
return {
id,
type: "reverse",
createdAt: Math.floor(Date.now() / 1000),
preimage: "", // redacted by the wallet; monitoring never needs it
preimage: opts.preimage ?? "",
status,
request: {
claimPublicKey: "0".repeat(66),
invoiceAmount: 10_000,
preimageHash: "0".repeat(64),
invoiceAmount: opts.invoiceAmount ?? 10_000,
preimageHash: opts.preimageHash ?? "ab".repeat(32),
...(opts.description ? { description: opts.description } : {}),
},
response: {
id,
invoice: "lnbc100n1ptest",
lockupAddress: "ark1test",
onchainAmount: 10_000,
onchainAmount: opts.invoiceAmount ?? 10_000,
refundPublicKey: "0".repeat(66),
timeoutBlockHeights: {
refund: 100,
@ -44,3 +54,27 @@ export function mockReverseSwap(
},
} as unknown as BoltzReverseSwap;
}
/** Stub global fetch with a controllable response factory. */
export function stubFetch(
impl: (...args: Parameters<typeof fetch>) => ReturnType<typeof fetch>,
): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn(impl);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
export function stubFetchOk(): ReturnType<typeof vi.fn> {
return stubFetch(async () => ({ ok: true, status: 200, text: async () => "" }) as Response);
}
export function lastFetchRequest(fetchMock: ReturnType<typeof vi.fn>): {
url: string;
init: RequestInit;
} {
const call = fetchMock.mock.calls.at(-1) as unknown as [string, RequestInit] | undefined;
if (!call) throw new Error("fetch was not called");
return { url: call[0], init: call[1] };
}
export const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));

80
test/ntfyNotifier.test.ts Normal file
View File

@ -0,0 +1,80 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { NtfyNotifier } from "../src/notifier/ntfyNotifier.js";
import { lastFetchRequest, silentLogger, stubFetch, stubFetchOk } from "./helpers.js";
describe("NtfyNotifier", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("completes without error when ntfy accepts the push", async () => {
stubFetchOk();
const notifier = new NtfyNotifier("https://ntfy.example", silentLogger);
await expect(
notifier.notify(
{ topic: "my-phone" },
{ title: "Payment received", body: "⚡ settled" },
),
).resolves.toBeUndefined();
});
it("posts to the topic path under the configured base URL", async () => {
const fetchMock = stubFetchOk();
const notifier = new NtfyNotifier("https://ntfy.example/", silentLogger);
await notifier.notify({ topic: "my phone" }, { title: "t", body: "hello" });
const { url, init } = lastFetchRequest(fetchMock);
expect(url).toBe("https://ntfy.example/my%20phone");
expect(init.method).toBe("POST");
expect(init.body).toBe("hello");
});
it("encodes emoji titles for ntfy's Title header", async () => {
const fetchMock = stubFetchOk();
const notifier = new NtfyNotifier("https://ntfy.example", silentLogger);
const title = "Payment ⚡";
await notifier.notify({ topic: "t" }, { title, body: "b" });
const headers = lastFetchRequest(fetchMock).init.headers as Record<string, string>;
expect(headers.Title).toBe(Buffer.from(title, "utf8").toString("latin1"));
expect(headers.Title).not.toBe(title);
});
it("sends optional tags and priority only when provided", async () => {
const fetchMock = stubFetchOk();
const notifier = new NtfyNotifier("https://ntfy.example", silentLogger);
await notifier.notify({ topic: "t" }, { title: "t", body: "b" });
let headers = lastFetchRequest(fetchMock).init.headers as Record<string, string>;
expect(headers.Tags).toBeUndefined();
expect(headers.Priority).toBeUndefined();
await notifier.notify(
{ topic: "t" },
{ title: "t", body: "b", tags: ["zap"], priority: "high" },
);
headers = lastFetchRequest(fetchMock).init.headers as Record<string, string>;
expect(headers.Tags).toBe("zap");
expect(headers.Priority).toBe("4");
});
it("surfaces the ntfy error body in the thrown message", async () => {
stubFetch(
async () =>
({
ok: false,
status: 429,
statusText: "Too Many Requests",
text: async () => "rate limited",
}) as Response,
);
const notifier = new NtfyNotifier("https://ntfy.example", silentLogger);
await expect(
notifier.notify({ topic: "t" }, { title: "t", body: "b" }),
).rejects.toThrow("rate limited");
});
});

View File

@ -7,8 +7,8 @@ import { Registry } from "../src/registry.js";
import { createSwapWatcher } from "../src/swapWatcher.js";
import { attachPaymentNotifications, type PaymentService } from "../src/paymentService.js";
import { buildServer } from "../src/server.js";
import type { Notifier } from "../src/notifier/types.js";
import { silentLogger, mockReverseSwap } from "./helpers.js";
import type { Notifier, NotifyPayload, NotifyTarget } from "../src/notifier/types.js";
import { flush, mockReverseSwap, silentLogger } from "./helpers.js";
/**
* Controllable stand-in for `globalThis.WebSocket`. The real boltz-swap
@ -37,7 +37,6 @@ class FakeWebSocket {
this.onclose?.();
}
// test helpers
emitUpdate(id: string, status: string): Promise<void> | void {
return this.onmessage?.({ data: JSON.stringify({ event: "update", args: [{ id, status }] }) });
}
@ -49,8 +48,6 @@ class FakeWebSocket {
}
}
const flush = () => new Promise((r) => setTimeout(r, 0));
describe("payment flow (real SwapManager, mocked Boltz events)", () => {
let dir: string;
let manager: SwapManagerClient;
@ -65,7 +62,6 @@ describe("payment flow (real SwapManager, mocked Boltz events)", () => {
FakeWebSocket.instances = [];
originalWebSocket = (globalThis as Record<string, unknown>).WebSocket;
(globalThis as Record<string, unknown>).WebSocket = FakeWebSocket;
// Polling fallback hits fetch; keep it benign and offline.
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ status: "swap.created" }) })),
@ -85,16 +81,14 @@ describe("payment flow (real SwapManager, mocked Boltz events)", () => {
registry,
notifier,
logger: silentLogger,
sweepIntervalMs: 3_600_000, // effectively disabled for these tests
sweepIntervalMs: 3_600_000,
});
app = buildServer({ registry, manager, simulate: payments.onSwapUpdate, logger: silentLogger });
await app.ready();
// Start the manager and bring the (fake) websocket up.
await manager.start([]);
const ws = FakeWebSocket.instances[0]!;
ws.onopen?.();
FakeWebSocket.instances[0]!.onopen?.();
});
afterEach(async () => {
@ -106,41 +100,103 @@ describe("payment flow (real SwapManager, mocked Boltz events)", () => {
rmSync(dir, { recursive: true, force: true });
});
it("pushes exactly once when a registered reverse swap reaches invoice.settled", async () => {
it("end-to-end: register → Boltz settle → one push → registry and manager pruned", async () => {
const ws = FakeWebSocket.instances[0]!;
const swap = mockReverseSwap("reverse-swap-1");
const hash = "11".repeat(32);
const swap = mockReverseSwap("reverse-swap-1", "swap.created", { preimageHash: hash });
// 1. Wallet registers the pending reverse swap with a phone topic.
const res = await app.inject({
method: "POST",
url: "/register",
payload: { swap, topic: "phone-topic", label: "1000 sats" },
payload: { swap, topic: hash, label: "1000 sats" },
});
expect(res.statusCode).toBe(201);
// 2. SwapManager subscribed this swap id over the websocket.
expect(ws.subscribedIds()).toContain("reverse-swap-1");
expect(await manager.hasSwap("reverse-swap-1")).toBe(true);
// 3. Boltz streams the lifecycle; no push until the terminal settle.
await ws.emitUpdate("reverse-swap-1", "transaction.mempool");
await ws.emitUpdate("reverse-swap-1", "transaction.confirmed");
await flush();
expect(notify).not.toHaveBeenCalled();
// 4. invoice.settled => payment received => one push to the registered topic.
await ws.emitUpdate("reverse-swap-1", "invoice.settled");
await flush();
expect(notify).toHaveBeenCalledTimes(1);
expect(notify.mock.calls[0]![0]).toEqual({ topic: "phone-topic" });
expect(notify.mock.calls[0]![1]).toMatchObject({ title: "Payment received" });
// 5. Once delivered, the swap is pruned from the registry and unwatched.
expect(notify).toHaveBeenCalledOnce();
const [target, payload] = notify.mock.calls[0]! as [NotifyTarget, NotifyPayload];
expect(target).toEqual({ topic: hash });
expect(payload).toMatchObject({
title: "Payment received",
body: "⚡ Lightning payment settled (1000 sats).",
memo: "1000 sats",
preimage: "",
amtPaidSat: 10_000,
});
const list = await app.inject({ method: "GET", url: "/register" });
expect(list.json().registrations).toHaveLength(0);
expect(await manager.hasSwap("reverse-swap-1")).toBe(false);
});
it("does not notify on Boltz failure statuses", async () => {
const ws = FakeWebSocket.instances[0]!;
await app.inject({
method: "POST",
url: "/register",
payload: { swap: mockReverseSwap("reverse-swap-fail"), topic: "topic" },
});
await ws.emitUpdate("reverse-swap-fail", "invoice.expired");
await flush();
expect(notify).not.toHaveBeenCalled();
const list = await app.inject({ method: "GET", url: "/register" });
expect(list.json().registrations).toHaveLength(0);
expect(await manager.hasSwap("reverse-swap-fail")).toBe(false);
});
it("rejects invalid registration bodies", async () => {
const res = await app.inject({
method: "POST",
url: "/register",
payload: { topic: "t" },
});
expect(res.statusCode).toBe(400);
expect(notify).not.toHaveBeenCalled();
});
it("DELETE /register/:swapId stops monitoring without notifying", async () => {
await app.inject({
method: "POST",
url: "/register",
payload: { swap: mockReverseSwap("reverse-swap-del"), topic: "topic" },
});
const del = await app.inject({ method: "DELETE", url: "/register/reverse-swap-del" });
expect(del.statusCode).toBe(200);
await app.inject({
method: "POST",
url: "/simulate",
payload: { swapId: "reverse-swap-del", status: "invoice.settled" },
});
await flush();
expect(notify).not.toHaveBeenCalled();
expect(await manager.hasSwap("reverse-swap-del")).toBe(false);
});
it("/simulate returns 404 for unknown swap ids", async () => {
const res = await app.inject({
method: "POST",
url: "/simulate",
payload: { swapId: "missing", status: "invoice.settled" },
});
expect(res.statusCode).toBe(404);
expect(notify).not.toHaveBeenCalled();
});
it("does not double-notify on a duplicate settled event", async () => {
const ws = FakeWebSocket.instances[0]!;
await app.inject({
@ -151,26 +207,9 @@ describe("payment flow (real SwapManager, mocked Boltz events)", () => {
await ws.emitUpdate("reverse-swap-2", "invoice.settled");
await flush();
await ws.emitUpdate("reverse-swap-2", "invoice.settled"); // swap already removed -> ignored
await ws.emitUpdate("reverse-swap-2", "invoice.settled");
await flush();
expect(notify).toHaveBeenCalledTimes(1);
});
it("supports the /simulate endpoint for manual phone testing", async () => {
await app.inject({
method: "POST",
url: "/register",
payload: { swap: mockReverseSwap("reverse-swap-3"), topic: "phone-topic" },
});
const sim = await app.inject({
method: "POST",
url: "/simulate",
payload: { swapId: "reverse-swap-3", status: "invoice.settled" },
});
expect(sim.statusCode).toBe(200);
await flush();
expect(notify).toHaveBeenCalledTimes(1);
});
});

162
test/paymentService.test.ts Normal file
View File

@ -0,0 +1,162 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SwapManagerClient } from "@arkade-os/boltz-swap";
import { Registry } from "../src/registry.js";
import { attachPaymentNotifications, type PaymentService } from "../src/paymentService.js";
import type { Notifier, NotifyPayload, NotifyTarget } from "../src/notifier/types.js";
import { flush, mockReverseSwap, silentLogger } from "./helpers.js";
function fakeManager(removeSwap = vi.fn(async () => {})): SwapManagerClient {
return {
onSwapUpdate: async () => () => {},
onSwapFailed: async () => () => {},
onWebSocketConnected: async () => () => {},
onWebSocketDisconnected: async () => () => {},
removeSwap,
} as unknown as SwapManagerClient;
}
describe("attachPaymentNotifications", () => {
let dir: string;
let payments: PaymentService;
let registry: Registry;
let notify: ReturnType<typeof vi.fn>;
let notifier: Notifier;
let removeSwap: ReturnType<typeof vi.fn>;
afterEach(() => {
payments?.stop();
rmSync(dir, { recursive: true, force: true });
});
async function start(
opts: { sweepIntervalMs?: number; deliveryAttempts?: number } = {},
): Promise<void> {
dir = mkdtempSync(join(tmpdir(), "pay-"));
registry = new Registry(join(dir, "reg.json"), silentLogger);
removeSwap = vi.fn(async () => {});
notify = vi.fn(async () => {});
notifier = { notify } as unknown as Notifier;
payments = await attachPaymentNotifications({
manager: fakeManager(removeSwap),
registry,
notifier,
logger: silentLogger,
sweepIntervalMs: opts.sweepIntervalMs ?? 3_600_000,
deliveryAttempts: opts.deliveryAttempts ?? 3,
});
}
it("builds the settlement notify payload from the registration and swap", async () => {
await start();
const hash = "fe".repeat(32);
registry.add({
swap: mockReverseSwap("s1", "swap.created", {
preimageHash: hash,
invoiceAmount: 42_000,
description: "latte",
}),
topic: hash,
label: "42k sats",
});
payments.onSwapUpdate(mockReverseSwap("s1", "invoice.settled"), "transaction.confirmed");
await flush();
expect(notify).toHaveBeenCalledOnce();
const [target, payload] = notify.mock.calls[0]! as [NotifyTarget, NotifyPayload];
expect(target).toEqual({ topic: hash });
expect(payload).toMatchObject({
title: "Payment received",
body: "⚡ Lightning payment settled (42k sats).",
memo: "42k sats",
preimage: "",
amtPaidSat: 42_000,
tags: ["zap", "moneybag"],
priority: "high",
});
});
it("retries inline before leaving the swap registered for the sweep", async () => {
dir = mkdtempSync(join(tmpdir(), "pay-"));
registry = new Registry(join(dir, "reg.json"), silentLogger);
removeSwap = vi.fn(async () => {});
let attempts = 0;
notify = vi.fn(async () => {
attempts += 1;
if (attempts < 3) throw new Error("transient");
});
notifier = { notify } as unknown as Notifier;
payments = await attachPaymentNotifications({
manager: fakeManager(removeSwap),
registry,
notifier,
logger: silentLogger,
deliveryAttempts: 3,
sweepIntervalMs: 3_600_000,
});
registry.add({ swap: mockReverseSwap("s1", "invoice.settled"), topic: "t1" });
payments.onSwapUpdate(mockReverseSwap("s1", "invoice.settled"), "swap.created");
// Inline retries back off (500ms, 1s) between attempts.
await vi.waitFor(() => expect(attempts).toBe(3), { timeout: 3000 });
expect(registry.get("s1")).toBeUndefined();
expect(removeSwap).toHaveBeenCalledWith("s1");
});
it("prunes failed reverse swaps without notifying", async () => {
await start();
registry.add({ swap: mockReverseSwap("s1"), topic: "t1" });
payments.onSwapUpdate(mockReverseSwap("s1", "invoice.expired"), "swap.created");
await flush();
expect(notify).not.toHaveBeenCalled();
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");
await flush();
expect(notify).not.toHaveBeenCalled();
});
it("does not start a second delivery while the first is still in flight", async () => {
dir = mkdtempSync(join(tmpdir(), "pay-"));
registry = new Registry(join(dir, "reg.json"), silentLogger);
removeSwap = vi.fn(async () => {});
let unblock!: () => void;
const gate = new Promise<void>((resolve) => {
unblock = resolve;
});
notify = vi.fn(async () => {
await gate;
});
notifier = { notify } as unknown as Notifier;
payments = await attachPaymentNotifications({
manager: fakeManager(removeSwap),
registry,
notifier,
logger: silentLogger,
sweepIntervalMs: 3_600_000,
});
registry.add({ swap: mockReverseSwap("s1"), topic: "t1" });
payments.onSwapUpdate(mockReverseSwap("s1", "invoice.settled"), "swap.created");
payments.onSwapUpdate(mockReverseSwap("s1", "invoice.settled"), "invoice.settled");
await flush();
expect(notify).toHaveBeenCalledTimes(1);
unblock();
await flush();
expect(notify).toHaveBeenCalledTimes(1);
expect(registry.get("s1")).toBeUndefined();
});
});

View File

@ -60,4 +60,16 @@ describe("Registry", () => {
expect(reg.remove("s1")).toBe(false);
expect(reg.all()).toHaveLength(0);
});
it("load is a no-op when the persistence file does not exist yet", () => {
const reg = makeRegistry(file);
reg.load();
expect(reg.all()).toHaveLength(0);
});
it("markStatus returns undefined for unknown swap ids", () => {
const reg = makeRegistry(file);
expect(reg.markStatus("missing", "invoice.settled")).toBeUndefined();
});
});