Add Bitcoin Lightning payment push notification service

A sample service that pushes a phone notification when a Bitcoin Lightning
payment is received in an Arkade-enabled wallet, built on the official
@arkade-os/sdk and @arkade-os/boltz-swap packages.

Receiving Lightning uses a Boltz reverse submarine swap; the wallet registers
each invoice's pending swap per-payment (opt-in, privacy-friendly), and the
service notifies when it reaches invoice.settled.

- swapWatcher.ts: monitor-only @arkade-os/boltz-swap SwapManager
  (enableAutoActions: false) — needs no wallet keys. SwapManager owns the
  multiplexed Boltz websocket, polling fallback, and reconnect/backoff.
- paymentService.ts: wires SwapManager events to pushes via
  isReverseSuccessStatus (invoice.settled).
- registry.ts: persisted swapId -> {topic, swap} map, resubscribed on restart;
  the wallet may redact the preimage since monitoring never claims.
- notifier/: pluggable Notifier interface with an ntfy.sh implementation.
- server.ts: POST/GET/DELETE /register, /health, /simulate (Fastify + zod).
- scripts/demo-receive.ts: wallet side — creates an invoice via
  ArkadeSwaps.createLightningInvoice and registers the swap (no raw REST).
- Tests: registry persistence, plus a component test driving the REAL
  SwapManager with a mocked globalThis.WebSocket through
  register -> settle -> push.

https://claude.ai/code/session_018HhVgswGG7LynM25qTpejq
This commit is contained in:
Claude 2026-06-05 13:17:09 +00:00
parent 9061456bd0
commit 03e76240bc
No known key found for this signature in database
20 changed files with 3087 additions and 0 deletions

25
.env.example Normal file
View File

@ -0,0 +1,25 @@
# Arkade network. SDK network literals: bitcoin | testnet | signet | mutinynet | regtest
NETWORK=mutinynet
# Boltz REST base for the Arkade mutinynet deployment. The @arkade-os/boltz-swap
# SwapManager derives its websocket URL from this automatically.
BOLTZ_API_URL=https://api.boltz.mutinynet.arkade.sh
# Used only by the demo script (scripts/demo-receive.ts) when it creates an invoice.
ARK_SERVER_URL=https://mutinynet.arkade.sh
ESPLORA_URL=https://mutinynet.com/api
# HTTP server
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.
NTFY_BASE_URL=https://ntfy.sh
# Where registrations are persisted (so they survive restarts and get re-subscribed).
DATA_FILE=./data/registrations.json
# pino log level
LOG_LEVEL=info

3
.gitignore vendored
View File

@ -141,3 +141,6 @@ dist
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
# Runtime data
/data/

125
README.md Normal file
View File

@ -0,0 +1,125 @@
# bitcoin-payment-push-service
A small sample service that sends a **push notification to your phone when a
Bitcoin Lightning payment is received** in an [Arkade](https://docs.arkadeos.com)-enabled wallet.
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.
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) —
and uses the SDK's `SwapManager` for monitoring. There is **no hand-rolled Boltz
client and no raw REST**: `SwapManager` owns the single multiplexed Boltz websocket,
the polling fallback, and the reconnect/backoff logic.
## How it works
```
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
ntfy.sh topic ──▶ 📱 your phone
```
- **Per-payment, opt-in registration.** Each time the wallet creates an invoice it
registers that one reverse swap. Nothing is monitored wallet-wide — more private
and a natural fit for Lightning's interactive invoice flow.
- **Monitor-only.** The service runs `SwapManager` with `enableAutoActions: false`,
so it needs **no wallet keys** — it only watches. The wallet keeps the preimage and
claims the swap itself; the registered swap can have its `preimage` redacted.
- **Push delivery:** pluggable `Notifier` interface; ships with
[`ntfy.sh`](https://ntfy.sh) (no account/keys — install the app, subscribe to a
topic). Swap in FCM / Expo / Web-Push later.
### Key modules
| 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/registry.ts` | persisted `swapId → {topic, swap}` map; resubscribed on restart |
| `src/notifier/ntfyNotifier.ts` | `Notifier` implementation for ntfy.sh |
| `src/server.ts` | HTTP API |
| `scripts/demo-receive.ts` | wallet side: creates an invoice via `ArkadeSwaps` and registers it |
## Setup
```bash
pnpm install
cp .env.example .env # defaults target the Arkade mutinynet deployment
```
| var | default | meaning |
|-----|---------|---------|
| `NETWORK` | `mutinynet` | Arkade network (`NetworkName`) |
| `BOLTZ_API_URL` | `https://api.boltz.mutinynet.arkade.sh` | Boltz REST base; ws is derived from it |
| `ARK_SERVER_URL` | `https://mutinynet.arkade.sh` | Arkade server (demo script only) |
| `PORT` | `3000` | HTTP port |
| `NTFY_BASE_URL` | `https://ntfy.sh` | push provider base URL |
## Run
```bash
pnpm dev # watch mode (tsx)
# or
pnpm build && pnpm start
```
## HTTP API
| method | path | body | purpose |
|--------|------|------|---------|
| `POST` | `/register` | `{ swap, topic, label? }` | watch a reverse swap (`swap` = the `pendingSwap` from `createLightningInvoice`) |
| `GET` | `/register` | — | list registrations |
| `DELETE` | `/register/:swapId` | — | stop watching |
| `GET` | `/health` | — | status, ws connectivity, monitored count |
| `POST` | `/simulate` | `{ swapId, status }` | inject a status update for a registered swap (manual testing) |
## Try it end-to-end
1. Install the **ntfy** app on your phone and subscribe to a unique topic, e.g.
`arkade-demo-7f3a`.
2. Start the service: `pnpm dev`.
3. **Quick push smoke test** (no payment needed) — register a swap, then simulate the
settle event:
```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"}'
```
Your phone should buzz with "Payment received ⚡".
4. **Full flow** against mutinynet — create a real invoice and pay it:
```bash
pnpm demo -- --topic arkade-demo-7f3a --amount 1000
```
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.)
## Tests
```bash
pnpm test
```
- `test/registry.test.ts` — registration persistence/reload.
- `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 unwatched. Also covers duplicate-settle de-duplication and
the `/simulate` path.
## Notes & extension points
- Because monitoring needs no keys, the wallet can **redact the `preimage`** before
registering — the secret never leaves the wallet. The demo does this.
- Add an `FcmNotifier` / `ExpoNotifier` / Web-Push behind the `Notifier` interface
without touching the monitor.
- For a non-Boltz / wallet-wide path, the same idea maps onto the arkd indexer stream
(`@arkade-os/sdk` `waitForIncomingFunds` / `SubscribeForScripts`); out of scope here.

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "bitcoin-payment-push-service",
"version": "0.1.0",
"description": "Sample service that sends a phone push notification when a Bitcoin Lightning payment is received in an Arkade-enabled wallet (Boltz reverse swap).",
"type": "module",
"packageManager": "pnpm@10.33.0",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"demo": "tsx scripts/demo-receive.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@arkade-os/boltz-swap": "^0.3.38",
"@arkade-os/sdk": "^0.4.0",
"dotenv": "^16.4.5",
"fastify": "^5.2.0",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"ws": "^8.18.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/ws": "^8.5.13",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}

1982
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

108
scripts/demo-receive.ts Normal file
View File

@ -0,0 +1,108 @@
/**
* Demo: use the real @arkade-os SDK to create a Lightning invoice (Boltz reverse
* swap) on mutinynet, then register the resulting pending swap with the local push
* service so a phone push fires when it settles.
*
* pnpm demo -- --topic <ntfy-topic> [--amount 1000] [--service http://localhost:3000]
*
* This is the wallet side of the flow: it owns the keys, creates the invoice via
* `ArkadeSwaps.createLightningInvoice`, and hands the (preimage-redacted) pending
* swap to the monitor-only push service. Requires connectivity to the Arkade
* mutinynet server + Boltz. Set ARKADE_PRIVATE_KEY to reuse a wallet across runs.
*/
import { randomBytes } from "node:crypto";
import {
Wallet,
SingleKey,
InMemoryWalletRepository,
InMemoryContractRepository,
} from "@arkade-os/sdk";
import {
ArkadeSwaps,
BoltzSwapProvider,
InMemorySwapRepository,
type Network,
} from "@arkade-os/boltz-swap";
interface Args {
topic: string;
amount: number;
service: string;
network: Network;
arkServerUrl: string;
boltzApiUrl: string;
label?: string;
}
function parseArgs(argv: string[]): Args {
const map = new Map<string, string>();
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a?.startsWith("--")) map.set(a.slice(2), argv[i + 1] ?? "");
}
const topic = map.get("topic");
if (!topic) {
console.error("Missing --topic <ntfy-topic>");
process.exit(1);
}
return {
topic,
amount: Number(map.get("amount") ?? 1000),
service: map.get("service") ?? "http://localhost:3000",
network: (map.get("network") ?? process.env.NETWORK ?? "mutinynet") as Network,
arkServerUrl: map.get("ark") ?? process.env.ARK_SERVER_URL ?? "https://mutinynet.arkade.sh",
boltzApiUrl:
map.get("boltz") ?? process.env.BOLTZ_API_URL ?? "https://api.boltz.mutinynet.arkade.sh",
label: map.get("label"),
};
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const privHex = process.env.ARKADE_PRIVATE_KEY ?? Buffer.from(randomBytes(32)).toString("hex");
const wallet = await Wallet.create({
identity: SingleKey.fromHex(privHex),
arkServerUrl: args.arkServerUrl,
storage: {
walletRepository: new InMemoryWalletRepository(),
contractRepository: new InMemoryContractRepository(),
},
});
// SwapManager disabled here: the push service does the monitoring, not the demo wallet.
const swaps = await ArkadeSwaps.create({
wallet,
swapProvider: new BoltzSwapProvider({ network: args.network, apiUrl: args.boltzApiUrl }),
swapManager: false,
swapRepository: new InMemorySwapRepository(),
});
console.log(`Creating a ${args.amount} sat Lightning invoice on ${args.network} ...`);
const result = await swaps.createLightningInvoice({ amount: args.amount });
console.log("\nSwap id:", result.pendingSwap.id);
console.log("\nPay this Lightning invoice:\n");
console.log(result.invoice, "\n");
// Hand the pending swap to the push service. Redact the preimage — monitoring
// never needs it, and it stays on the wallet that will actually claim.
const swap = { ...result.pendingSwap, preimage: "" };
console.log(`Registering swap ${swap.id} with ${args.service} ...`);
const reg = await fetch(`${args.service.replace(/\/$/, "")}/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ swap, topic: args.topic, label: args.label ?? `${args.amount} sats` }),
});
if (!reg.ok) {
console.error(`Registration failed: ${reg.status} ${reg.statusText}`);
console.error(await reg.text().catch(() => ""));
process.exit(1);
}
console.log("Registered. Pay the invoice and watch for a push on ntfy topic:", args.topic);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

45
src/config.ts Normal file
View File

@ -0,0 +1,45 @@
import "dotenv/config";
import { z } from "zod";
/**
* SDK network literals accepted by @arkade-os/sdk (its `NetworkName` type).
* Verify against the installed package's Network type if it changes.
*/
const networkSchema = z.enum([
"bitcoin",
"testnet",
"signet",
"mutinynet",
"regtest",
]);
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"),
ARK_SERVER_URL: z.string().url().default("https://mutinynet.arkade.sh"),
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"),
DATA_FILE: z.string().default("./data/registrations.json"),
LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
.default("info"),
});
export type Network = z.infer<typeof networkSchema>;
export type Config = z.infer<typeof envSchema>;
function load(): Config {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`Invalid environment configuration:\n${issues}`);
}
return parsed.data;
}
export const config = load();

43
src/index.ts Normal file
View File

@ -0,0 +1,43 @@
import { config } from "./config.js";
import { logger } from "./logger.js";
import { Registry } from "./registry.js";
import { createSwapWatcher } from "./swapWatcher.js";
import { createNotifier } from "./notifierFactory.js";
import { attachPaymentNotifications } from "./paymentService.js";
async function main(): Promise<void> {
const registry = new Registry(config.DATA_FILE, logger);
registry.load();
const manager = createSwapWatcher(
{ network: config.NETWORK, apiUrl: config.BOLTZ_API_URL, pollIntervalMs: config.POLL_INTERVAL_MS },
logger,
);
const notifier = createNotifier();
const { onSwapUpdate } = await attachPaymentNotifications({ manager, registry, notifier, logger });
// Resume monitoring everything we were watching before a restart.
const pending = registry.active().map((r) => r.swap);
await manager.start(pending);
logger.info({ resumed: pending.length }, "SwapManager started");
const { buildServer } = await import("./server.js");
const app = buildServer({ registry, manager, simulate: onSwapUpdate, logger });
await app.listen({ host: "0.0.0.0", port: config.PORT });
logger.info({ port: config.PORT, network: config.NETWORK }, "service listening");
const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, "shutting down");
await manager.stop();
await app.close();
process.exit(0);
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
}
main().catch((err) => {
logger.error({ err }, "fatal startup error");
process.exit(1);
});

12
src/logger.ts Normal file
View File

@ -0,0 +1,12 @@
import { pino } from "pino";
import { config } from "./config.js";
export const logger = pino({
level: config.LOG_LEVEL,
transport:
process.env.NODE_ENV === "production"
? undefined
: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } },
});
export type Logger = typeof logger;

View File

@ -0,0 +1,40 @@
import type { Logger } from "../logger.js";
import type { Notifier, NotifyPayload, NotifyTarget } from "./types.js";
const PRIORITY_MAP: Record<NonNullable<NotifyPayload["priority"]>, string> = {
min: "1",
low: "2",
default: "3",
high: "4",
max: "5",
};
/**
* Sends a push by POSTing to an ntfy server (https://ntfy.sh by default).
* Install the ntfy app on the phone and subscribe to a topic; that topic is the
* NotifyTarget. No account or API key required.
*/
export class NtfyNotifier implements Notifier {
constructor(
private readonly baseUrl: string,
private readonly logger: Logger,
) {}
async notify(target: NotifyTarget, payload: NotifyPayload): Promise<void> {
const url = `${this.baseUrl.replace(/\/$/, "")}/${encodeURIComponent(target.topic)}`;
// HTTP header values are Latin-1; emit the (possibly UTF-8/emoji) title as raw
// UTF-8 bytes mapped into a Latin-1 string. ntfy decodes header bytes as UTF-8.
const headers: Record<string, string> = {
Title: Buffer.from(payload.title, "utf8").toString("latin1"),
};
if (payload.tags?.length) headers["Tags"] = payload.tags.join(",");
if (payload.priority) headers["Priority"] = PRIORITY_MAP[payload.priority];
const res = await fetch(url, { method: "POST", headers, body: payload.body });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`ntfy push failed: ${res.status} ${res.statusText} ${text}`.trim());
}
this.logger.info({ topic: target.topic, title: payload.title }, "push sent");
}
}

20
src/notifier/types.ts Normal file
View File

@ -0,0 +1,20 @@
export interface NotifyTarget {
/** ntfy topic. For other providers this would carry a device token instead. */
topic: string;
}
export interface NotifyPayload {
title: string;
body: string;
/** Optional tags/emoji (ntfy supports these); ignored by providers that can't use them. */
tags?: string[];
priority?: "min" | "low" | "default" | "high" | "max";
}
/**
* Pluggable push delivery. The sample ships NtfyNotifier; FCM / Expo / Web-Push
* implementations can be added without touching the monitor.
*/
export interface Notifier {
notify(target: NotifyTarget, payload: NotifyPayload): Promise<void>;
}

12
src/notifierFactory.ts Normal file
View File

@ -0,0 +1,12 @@
import { config } from "./config.js";
import { logger } from "./logger.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.
*/
export function createNotifier(): Notifier {
return new NtfyNotifier(config.NTFY_BASE_URL, logger);
}

75
src/paymentService.ts Normal file
View File

@ -0,0 +1,75 @@
import {
isReverseSuccessStatus,
isReverseFailedStatus,
type BoltzSwap,
type BoltzSwapStatus,
type SwapManagerClient,
} from "@arkade-os/boltz-swap";
import type { Logger } from "./logger.js";
import type { Registry } from "./registry.js";
import type { Notifier } from "./notifier/types.js";
export interface PaymentServiceDeps {
manager: SwapManagerClient;
registry: Registry;
notifier: Notifier;
logger: Logger;
}
export interface PaymentService {
/** The single status-update handler. Returned so /simulate can drive it too. */
onSwapUpdate: (swap: BoltzSwap, oldStatus: BoltzSwapStatus) => void;
}
/**
* 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.
*/
export async function attachPaymentNotifications(deps: PaymentServiceDeps): Promise<PaymentService> {
const { manager, registry, notifier, logger } = deps;
const onSwapUpdate = (swap: BoltzSwap, _oldStatus: BoltzSwapStatus): void => {
if (swap.type !== "reverse") return; // this service notifies on receives only
const reg = registry.markStatus(swap.id, swap.status);
if (!reg) return;
if (isReverseSuccessStatus(swap.status) && !reg.notifiedSettled) {
const suffix = reg.label ? ` (${reg.label})` : "";
notifier
.notify(
{ topic: reg.topic },
{
title: "Payment received",
body: `⚡ Lightning payment settled${suffix}.`,
tags: ["zap", "moneybag"],
priority: "high",
},
)
.then(() => {
registry.markNotified(reg.swapId);
return manager.removeSwap(reg.swapId);
})
.catch((err) => logger.error({ err, swapId: reg.swapId }, "failed to send push"));
return;
}
if (isReverseFailedStatus(swap.status)) {
logger.info({ swapId: swap.id, status: swap.status }, "reverse swap failed; unwatching");
void manager.removeSwap(swap.id);
}
};
await manager.onSwapUpdate(onSwapUpdate);
await manager.onSwapFailed((swap, error) => {
logger.warn({ swapId: swap.id, err: error.message }, "swap failed");
});
await manager.onWebSocketConnected(() => logger.info("Boltz websocket connected"));
await manager.onWebSocketDisconnected((err) =>
logger.warn({ err: err?.message }, "Boltz websocket disconnected"),
);
return { onSwapUpdate };
}

119
src/registry.ts Normal file
View File

@ -0,0 +1,119 @@
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { dirname } from "node:path";
import type { BoltzReverseSwap, BoltzSwapStatus } from "@arkade-os/boltz-swap";
import type { Logger } from "./logger.js";
export interface Registration {
swapId: string;
/** ntfy topic to push to (extensible to device tokens for other providers). */
topic: string;
label?: string;
/**
* The pending reverse swap as supplied by the wallet at registration time.
* Re-fed to the SwapManager on restart so monitoring resumes. The wallet may
* redact `preimage` (the service never claims), keeping the secret off this box.
*/
swap: BoltzReverseSwap;
status: BoltzSwapStatus;
createdAt: number;
updatedAt: number;
/** True once we have sent the "payment received" push, so we never double-notify. */
notifiedSettled: boolean;
}
export interface RegisterInput {
topic: string;
label?: string;
swap: BoltzReverseSwap;
}
/**
* Stores swap-id -> registration mappings, persisted to a JSON file so that
* registrations survive restarts and can be re-subscribed on boot.
*/
export class Registry {
private readonly byId = new Map<string, Registration>();
constructor(
private readonly filePath: string,
private readonly logger: Logger,
) {}
load(): void {
if (!existsSync(this.filePath)) return;
try {
const raw = readFileSync(this.filePath, "utf8");
const items = JSON.parse(raw) as Registration[];
for (const item of items) this.byId.set(item.swapId, item);
this.logger.info({ count: this.byId.size }, "loaded registrations from disk");
} catch (err) {
this.logger.error({ err, filePath: this.filePath }, "failed to load registrations");
}
}
private persist(): void {
try {
mkdirSync(dirname(this.filePath), { recursive: true });
writeFileSync(this.filePath, JSON.stringify([...this.byId.values()], null, 2));
} catch (err) {
this.logger.error({ err, filePath: this.filePath }, "failed to persist registrations");
}
}
add(input: RegisterInput): Registration {
const now = Date.now();
const swapId = input.swap.id;
const existing = this.byId.get(swapId);
const reg: Registration = {
swapId,
topic: input.topic,
label: input.label,
swap: input.swap,
status: existing?.status ?? input.swap.status,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
notifiedSettled: existing?.notifiedSettled ?? false,
};
this.byId.set(swapId, reg);
this.persist();
return reg;
}
get(swapId: string): Registration | undefined {
return this.byId.get(swapId);
}
remove(swapId: string): boolean {
const removed = this.byId.delete(swapId);
if (removed) this.persist();
return removed;
}
/** All registrations. */
all(): Registration[] {
return [...this.byId.values()];
}
/** Registrations still awaiting settlement (the ones worth re-subscribing). */
active(): Registration[] {
return this.all().filter((r) => !r.notifiedSettled);
}
markStatus(swapId: string, status: BoltzSwapStatus): Registration | undefined {
const reg = this.byId.get(swapId);
if (!reg) return undefined;
reg.status = status;
reg.swap.status = status;
reg.updatedAt = Date.now();
this.persist();
return reg;
}
markNotified(swapId: string): void {
const reg = this.byId.get(swapId);
if (!reg) return;
reg.notifiedSettled = true;
reg.updatedAt = Date.now();
this.persist();
}
}

92
src/server.ts Normal file
View File

@ -0,0 +1,92 @@
import Fastify from "fastify";
import { z } from "zod";
import type { BoltzReverseSwap, BoltzSwapStatus, SwapManagerClient } from "@arkade-os/boltz-swap";
import type { Logger } from "./logger.js";
import type { Registry } from "./registry.js";
/**
* A reverse swap as handed over by the wallet. Validated loosely: we only require
* the fields the SwapManager needs to monitor it (id, reverse discriminator,
* status). `preimage` may be redacted by the wallet since we never claim.
*/
const reverseSwapSchema = z
.object({
id: z.string().min(1),
type: z.literal("reverse"),
status: z.string().min(1),
createdAt: z.number().optional(),
preimage: z.string().optional(),
request: z.unknown().optional(),
response: z.unknown().optional(),
})
.passthrough();
const registerSchema = z.object({
topic: z.string().min(1),
label: z.string().optional(),
swap: reverseSwapSchema,
});
export interface ServerDeps {
registry: Registry;
manager: SwapManagerClient;
/** Inject a synthetic swap update through the same pipeline (for manual testing). */
simulate: (swap: BoltzReverseSwap, oldStatus: BoltzSwapStatus) => void;
logger: Logger;
}
export function buildServer(deps: ServerDeps) {
const { registry, manager, simulate, logger } = deps;
const app = Fastify({ loggerInstance: logger });
app.get("/health", async () => {
const stats = await manager.getStats();
return {
status: "ok",
wsConnected: stats.websocketConnected,
monitoredSwaps: stats.monitoredSwaps,
usePollingFallback: stats.usePollingFallback,
registrations: registry.all().length,
};
});
// Opt-in, per-payment registration: the wallet posts each invoice's reverse swap.
app.post("/register", async (request, reply) => {
const parsed = registerSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: "invalid body", issues: parsed.error.issues });
}
const swap = parsed.data.swap as unknown as BoltzReverseSwap;
const reg = registry.add({ swap, topic: parsed.data.topic, label: parsed.data.label });
await manager.addSwap(swap);
return reply.code(201).send({ ok: true, registration: reg });
});
app.get("/register", () => ({ registrations: registry.all() }));
app.delete<{ Params: { swapId: string } }>("/register/:swapId", async (request, reply) => {
const { swapId } = request.params;
await manager.removeSwap(swapId);
const removed = registry.remove(swapId);
return reply.code(removed ? 200 : 404).send({ ok: removed });
});
// Manual testing helper: pretend a status update arrived for a registered swap.
// curl -X POST localhost:3000/simulate -d '{"swapId":"x","status":"invoice.settled"}'
app.post("/simulate", (request, reply) => {
const parsed = z
.object({ swapId: z.string().min(1), status: z.string().min(1) })
.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: "invalid body", issues: parsed.error.issues });
}
const reg = registry.get(parsed.data.swapId);
if (!reg) return reply.code(404).send({ error: "unknown swapId" });
const oldStatus = reg.swap.status;
const swap: BoltzReverseSwap = { ...reg.swap, status: parsed.data.status as BoltzSwapStatus };
simulate(swap, oldStatus);
return reply.send({ ok: true });
});
return app;
}

51
src/swapWatcher.ts Normal file
View File

@ -0,0 +1,51 @@
import {
BoltzSwapProvider,
SwapManager,
type SwapManagerCallbacks,
type SwapManagerClient,
type SwapManagerConfig,
type Network,
} from "@arkade-os/boltz-swap";
import type { Logger } from "./logger.js";
export interface SwapWatcherOptions {
network: Network;
apiUrl: string;
pollIntervalMs: number;
}
/**
* Builds a monitoring-only Boltz SwapManager from the official @arkade-os/boltz-swap
* package. The SwapManager owns a single multiplexed websocket to Boltz (subscribed
* to `swap.update`), with built-in polling fallback and exponential reconnect backoff.
*
* We run it with `enableAutoActions: false` because this is a *notification* service:
* it watches swaps created by the wallet and never claims/refunds them, so it needs no
* wallet keys. No-op callbacks are wired only to keep the manager's internal logging
* quiet (their guarded paths are never exercised when auto-actions are off).
*/
export function createSwapWatcher(opts: SwapWatcherOptions, logger: Logger): SwapManagerClient {
const provider = new BoltzSwapProvider({ network: opts.network, apiUrl: opts.apiUrl });
const config: SwapManagerConfig = {
enableAutoActions: false,
pollInterval: opts.pollIntervalMs,
};
const manager = new SwapManager(provider, config);
const noop = async (): Promise<void> => {};
const noopTxid = async (): Promise<{ txid: string }> => ({ txid: "" });
const callbacks: SwapManagerCallbacks = {
claim: noop,
refund: noop,
claimArk: noopTxid,
claimBtc: noopTxid,
refundArk: async () => ({ swept: 0, skipped: 0 }),
saveSwap: noop,
};
manager.setCallbacks(callbacks);
logger.info({ network: opts.network, apiUrl: opts.apiUrl }, "Boltz SwapManager created (monitor-only)");
return manager;
}

46
test/helpers.ts Normal file
View File

@ -0,0 +1,46 @@
import type { BoltzReverseSwap, BoltzSwapStatus } from "@arkade-os/boltz-swap";
import type { Logger } from "../src/logger.js";
export const silentLogger = {
fatal: () => {},
error: () => {},
warn: () => {},
info: () => {},
debug: () => {},
trace: () => {},
silent: () => {},
level: "silent",
child: () => silentLogger,
} as unknown as Logger;
/** A minimal but type-complete pending reverse swap, as a wallet would register. */
export function mockReverseSwap(
id = "reverse-swap-1",
status: BoltzSwapStatus = "swap.created",
): BoltzReverseSwap {
return {
id,
type: "reverse",
createdAt: Math.floor(Date.now() / 1000),
preimage: "", // redacted by the wallet; monitoring never needs it
status,
request: {
claimPublicKey: "0".repeat(66),
invoiceAmount: 10_000,
preimageHash: "0".repeat(64),
},
response: {
id,
invoice: "lnbc100n1ptest",
lockupAddress: "ark1test",
onchainAmount: 10_000,
refundPublicKey: "0".repeat(66),
timeoutBlockHeights: {
refund: 100,
unilateralClaim: 200,
unilateralRefund: 300,
unilateralRefundWithoutReceiver: 400,
},
},
} as unknown as BoltzReverseSwap;
}

168
test/paymentFlow.test.ts Normal file
View File

@ -0,0 +1,168 @@
import { describe, it, expect, beforeEach, afterEach, vi } 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 { createSwapWatcher } from "../src/swapWatcher.js";
import { attachPaymentNotifications } from "../src/paymentService.js";
import { buildServer } from "../src/server.js";
import type { Notifier } from "../src/notifier/types.js";
import { silentLogger, mockReverseSwap } from "./helpers.js";
/**
* Controllable stand-in for `globalThis.WebSocket`. The real boltz-swap
* SwapManager constructs `new globalThis.WebSocket(url)` and drives it via
* `onopen` / `onmessage` / `send` / `readyState`, so swapping this in lets us
* exercise the REAL SwapManager with mocked Boltz events.
*/
class FakeWebSocket {
static OPEN = 1;
static instances: FakeWebSocket[] = [];
readyState = FakeWebSocket.OPEN;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: ((err: unknown) => void) | null = null;
onmessage: ((msg: { data: string }) => void | Promise<void>) | null = null;
sent: string[] = [];
constructor(public url: string) {
FakeWebSocket.instances.push(this);
}
send(data: string): void {
this.sent.push(data);
}
close(): void {
this.onclose?.();
}
// test helpers
emitUpdate(id: string, status: string): Promise<void> | void {
return this.onmessage?.({ data: JSON.stringify({ event: "update", args: [{ id, status }] }) });
}
subscribedIds(): string[] {
return this.sent
.map((s) => JSON.parse(s) as { op?: string; args?: string[] })
.filter((m) => m.op === "subscribe")
.flatMap((m) => m.args ?? []);
}
}
const flush = () => new Promise((r) => setTimeout(r, 0));
describe("payment flow (real SwapManager, mocked Boltz events)", () => {
let dir: string;
let manager: SwapManagerClient;
let app: ReturnType<typeof buildServer>;
let notify: ReturnType<typeof vi.fn>;
let notifier: Notifier;
let originalWebSocket: unknown;
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), "flow-"));
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" }) })),
);
notify = vi.fn(async () => {});
notifier = { notify } as unknown as Notifier;
const registry = new Registry(join(dir, "reg.json"), silentLogger);
registry.load();
manager = createSwapWatcher(
{ network: "mutinynet", apiUrl: "https://api.boltz.mutinynet.arkade.sh", pollIntervalMs: 600_000 },
silentLogger,
);
const { onSwapUpdate } = await attachPaymentNotifications({ manager, registry, notifier, logger: silentLogger });
app = buildServer({ registry, manager, simulate: 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?.();
});
afterEach(async () => {
await manager.stop();
await app.close();
(globalThis as Record<string, unknown>).WebSocket = originalWebSocket;
vi.unstubAllGlobals();
rmSync(dir, { recursive: true, force: true });
});
it("pushes exactly once when a registered reverse swap reaches invoice.settled", async () => {
const ws = FakeWebSocket.instances[0]!;
const swap = mockReverseSwap("reverse-swap-1");
// 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" },
});
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. The swap is marked settled and unwatched.
const list = await app.inject({ method: "GET", url: "/register" });
expect(list.json().registrations[0]).toMatchObject({ status: "invoice.settled", notifiedSettled: true });
expect(await manager.hasSwap("reverse-swap-1")).toBe(false);
});
it("does not double-notify on a duplicate settled event", async () => {
const ws = FakeWebSocket.instances[0]!;
await app.inject({
method: "POST",
url: "/register",
payload: { swap: mockReverseSwap("reverse-swap-2"), topic: "phone-topic" },
});
await ws.emitUpdate("reverse-swap-2", "invoice.settled");
await flush();
await ws.emitUpdate("reverse-swap-2", "invoice.settled"); // swap already removed -> ignored
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);
});
});

64
test/registry.test.ts Normal file
View File

@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Registry } from "../src/registry.js";
import { silentLogger, mockReverseSwap } from "./helpers.js";
function makeRegistry(file: string): Registry {
return new Registry(file, silentLogger);
}
describe("Registry", () => {
let dir: string;
let file: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "reg-"));
file = join(dir, "registrations.json");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("adds and retrieves a registration keyed by swap id", () => {
const reg = makeRegistry(file);
const added = reg.add({ swap: mockReverseSwap("s1"), topic: "t1", label: "lbl" });
expect(added.swapId).toBe("s1");
expect(added.notifiedSettled).toBe(false);
expect(reg.get("s1")?.topic).toBe("t1");
});
it("persists and reloads (including the swap object) across instances", () => {
const a = makeRegistry(file);
a.add({ swap: mockReverseSwap("s1"), topic: "t1" });
a.add({ swap: mockReverseSwap("s2"), topic: "t2" });
const b = makeRegistry(file);
b.load();
expect(b.all()).toHaveLength(2);
expect(b.get("s2")?.topic).toBe("t2");
expect(b.get("s2")?.swap.type).toBe("reverse");
});
it("tracks status and notified flag; active() excludes settled", () => {
const reg = makeRegistry(file);
reg.add({ swap: mockReverseSwap("s1"), topic: "t1" });
reg.add({ swap: mockReverseSwap("s2"), topic: "t2" });
reg.markStatus("s1", "transaction.mempool");
expect(reg.get("s1")?.status).toBe("transaction.mempool");
reg.markNotified("s1");
expect(reg.active().map((r) => r.swapId)).toEqual(["s2"]);
});
it("removes a registration", () => {
const reg = makeRegistry(file);
reg.add({ swap: mockReverseSwap("s1"), topic: "t1" });
expect(reg.remove("s1")).toBe(true);
expect(reg.remove("s1")).toBe(false);
expect(reg.all()).toHaveLength(0);
});
});

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true
},
"include": ["src/**/*.ts", "scripts/**/*.ts", "test/**/*.ts"],
"exclude": ["node_modules", "dist"]
}