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:
parent
9061456bd0
commit
03e76240bc
25
.env.example
Normal file
25
.env.example
Normal 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
3
.gitignore
vendored
@ -141,3 +141,6 @@ dist
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
|
||||
# Runtime data
|
||||
/data/
|
||||
|
||||
125
README.md
Normal file
125
README.md
Normal 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
36
package.json
Normal 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
1982
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
108
scripts/demo-receive.ts
Normal file
108
scripts/demo-receive.ts
Normal 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
45
src/config.ts
Normal 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
43
src/index.ts
Normal 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
12
src/logger.ts
Normal 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;
|
||||
40
src/notifier/ntfyNotifier.ts
Normal file
40
src/notifier/ntfyNotifier.ts
Normal 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
20
src/notifier/types.ts
Normal 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
12
src/notifierFactory.ts
Normal 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
75
src/paymentService.ts
Normal 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
119
src/registry.ts
Normal 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
92
src/server.ts
Normal 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
51
src/swapWatcher.ts
Normal 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
46
test/helpers.ts
Normal 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
168
test/paymentFlow.test.ts
Normal 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
64
test/registry.test.ts
Normal 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
21
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user