feat: groundcontrol as notifier (#2)
This commit is contained in:
parent
9a85fac528
commit
dfa3f8957e
@ -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
35
.github/workflows/ci.yml
vendored
Normal 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
|
||||
@ -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>;
|
||||
|
||||
42
src/notifier/groundControlNotifier.ts
Normal file
42
src/notifier/groundControlNotifier.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
83
test/groundControlNotifier.test.ts
Normal file
83
test/groundControlNotifier.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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
80
test/ntfyNotifier.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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
162
test/paymentService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user