feat: rewrite NWC client with encryption auto-negotiation, EOSE waiting, add test script

- Encryption auto-detection: reads wallet info event (kind 13194) on connect
  to choose NIP-04 vs NIP-44 per NIP-47 spec
- Subscription readiness: wait for EOSE before resolving connect()
- Dual NIP-04 / NIP-44 encrypt/decrypt support
- Proper encryption tags on NIP-44 requests
- test_nwc.ts: standalone NWC test script for make_invoice + pay_invoice
This commit is contained in:
redshift
2026-05-23 14:26:45 +08:00
parent cd9b724ca6
commit 62bd014836
2 changed files with 350 additions and 192 deletions
+190 -192
View File
@@ -1,10 +1,16 @@
// NIP-47 NWC (Nostr Wallet Connect) client for routstrd
// Uses nostr-tools for key management and NIP-04 encryption,
// Uses nostr-tools for key management and encryption,
// Bun's native WebSocket for relay communication.
//
// Encryption: auto-detects NIP-04 vs NIP-44 by reading the wallet's
// kind-13194 info event on connect, per the NIP-47 encryption negotiation spec.
// If the info event has an "encryption" tag listing supported schemes,
// nip44_v2 is preferred. Otherwise falls back to NIP-04.
import {
getPublicKey,
nip04,
nip44,
finalizeEvent,
type EventTemplate,
} from "nostr-tools";
@@ -63,13 +69,9 @@ export function validateConnectionString(
export interface NwcClientOptions {
connectionString: string;
/** Optional client private key (hex). Generated randomly if omitted. */
clientSecretKey?: string;
/** Timeout for wallet response (ms). Default: 60000 */
replyTimeoutMs?: number;
/** Timeout for relay operations (ms). Default: 10000 */
publishTimeoutMs?: number;
/** Max auto-reconnect attempts. Default: 5 */
maxReconnectAttempts?: number;
}
@@ -83,7 +85,7 @@ export interface NwcClient {
network?: string;
methods: string[];
}>;
getBalance(): Promise<number>; // in sats
getBalance(): Promise<number>;
payInvoice(invoice: string, amount?: number): Promise<{
preimage: string;
fees_paid?: number;
@@ -112,7 +114,6 @@ export interface NwcClient {
// ── Client implementation ───────────────────────────────────────────
/** A queued request with method, params, and promise callbacks */
interface QueuedCall {
method: NwcMethod;
params: Record<string, unknown>;
@@ -129,54 +130,57 @@ export function createNwcClient(options: NwcClientOptions): NwcClient {
const publishTimeoutMs = options.publishTimeoutMs ?? 10000;
const maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
// Client keypair — use secret from the connection string (per NIP-47)
const clientSecretKey = options.clientSecretKey
? Buffer.from(options.clientSecretKey, "hex")
: Buffer.from(parsed.secret, "hex");
// getPublicKey returns a hex string (not Uint8Array) so use it directly
const clientPubkeyHex = getPublicKey(clientSecretKey);
const nwc44ConvKey = nip44.getConversationKey(clientSecretKey, walletPubkey);
// ── State ──────────────────────────────────────────────────────
let ws: WebSocket | null = null;
let connected = false;
let subscriptionReady = false;
let subscriptionId: string | null = null;
let reconnectAttempts = 0;
let stopReconnecting = false;
let useEncryption: "nip04" | "nip44_v2" = "nip04";
// Serialized request queue — we process one NWC request at a time
// because NIP-47 doesn't mandate request/response correlation IDs.
const queue: QueuedCall[] = [];
let sending = false;
// ── Logging helpers ────────────────────────────────────────────
function log(...args: unknown[]) {
logger.log("[nwc]", ...args);
}
function debugLog(...args: unknown[]) {
logger.debug("[nwc]", ...args);
// ── Logging ────────────────────────────────────────────────────
function log(...args: unknown[]) { logger.log("[nwc]", ...args); }
function debugLog(...args: unknown[]) { logger.debug("[nwc]", ...args); }
// ── Encryption helpers ─────────────────────────────────────────
function encryptPayload(payload: string): string {
if (useEncryption === "nip44_v2") {
return nip44.encrypt(payload, nwc44ConvKey);
}
// nostr-tools v2 nip04.encrypt is sync but typed as Promise-like
return nip04.encrypt(clientSecretKey, walletPubkey, payload) as unknown as string;
}
// ── Nostr helpers ──────────────────────────────────────────────
function decryptPayload(payload: string): string {
if (useEncryption === "nip44_v2") {
return nip44.decrypt(payload, nwc44ConvKey);
}
return nip04.decrypt(clientSecretKey, walletPubkey, payload) as unknown as string;
}
// ── Event helpers ──────────────────────────────────────────────
function createSignedEvent(
kind: number,
content: string,
tags: string[][],
kind: number, content: string, tags: string[][],
): NostrEvent {
const template: EventTemplate = {
kind,
created_at: Math.floor(Date.now() / 1000),
tags,
content,
kind, created_at: Math.floor(Date.now() / 1000), tags, content,
};
const event = finalizeEvent(template, clientSecretKey);
return {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
kind: event.kind,
tags: event.tags,
content: event.content,
sig: event.sig,
id: event.id, pubkey: event.pubkey, created_at: event.created_at,
kind: event.kind, tags: event.tags, content: event.content, sig: event.sig,
};
}
@@ -185,161 +189,183 @@ export function createNwcClient(options: NwcClientOptions): NwcClient {
ws.send(JSON.stringify(message));
}
function buildRequestTags(): string[][] {
const tags: string[][] = [["p", walletPubkey]];
if (useEncryption === "nip44_v2") tags.unshift(["encryption", "nip44_v2"]);
return tags;
}
// ── Queue processing ───────────────────────────────────────────
/** Dequeue and send the next request from the queue head */
async function sendNextFromQueue(): Promise<void> {
function sendNextFromQueue(): void {
if (sending || queue.length === 0 || !connected || !ws) return;
sending = true;
const call = queue[0]!;
try {
const requestContent: NwcRequest = {
method: call.method,
params: call.params,
};
const requestContent: NwcRequest = { method: call.method, params: call.params };
const requestJson = JSON.stringify(requestContent);
debugLog(`Sending ${call.method} (queue depth: ${queue.length})`);
const encrypted = await nip04.encrypt(
clientSecretKey,
walletPubkey,
requestJson,
);
const event = createSignedEvent(NWC_REQUEST_KIND, encrypted, [
["p", walletPubkey],
]);
debugLog(`Sending ${call.method} (queue: ${queue.length}, enc: ${useEncryption})`);
const encrypted = encryptPayload(requestJson);
const event = createSignedEvent(NWC_REQUEST_KIND, encrypted, buildRequestTags());
sendRaw(["EVENT", event]);
debugLog(`Published ${call.method} request ${event.id.slice(0, 8)}...`);
debugLog(`Published ${call.method} req ${event.id.slice(0, 8)}...`);
} catch (error) {
// Send failed — reject this call and move on
const failed = queue.shift()!;
clearTimeout(failed.timeout);
failed.reject(
error instanceof Error ? error : new Error(String(error)),
);
failed.reject(error instanceof Error ? error : new Error(String(error)));
} finally {
sending = false;
}
}
/** Handle a response event from the wallet */
async function handleResponse(event: NostrEvent): Promise<void> {
function handleResponse(event: NostrEvent): void {
if (event.pubkey !== walletPubkey) return;
const pTag = event.tags.find((t) => t.length >= 2 && t[0] === "p");
if (!pTag || pTag[1] !== clientPubkeyHex) return;
if (queue.length === 0) {
debugLog("NWC response received but no pending calls");
return;
}
const call = queue.shift()!;
clearTimeout(call.timeout);
if (queue.length === 0) { debugLog("NWC response but no pending calls"); return; }
try {
debugLog(`Decrypting response ${event.id.slice(0, 8)}...`);
const decrypted = await nip04.decrypt(
clientSecretKey,
walletPubkey,
event.content,
);
const decrypted = decryptPayload(event.content);
debugLog(`Decrypted: ${decrypted.slice(0, 200)}`);
const response = JSON.parse(decrypted) as NwcResponse;
const resultType = response.result_type;
let call: QueuedCall | undefined;
if (resultType) {
const idx = queue.findIndex((c) => c.method === resultType);
if (idx >= 0) {
call = queue[idx]; queue.splice(idx, 1);
debugLog(`Matched response to ${resultType} (idx ${idx})`);
} else {
debugLog(`Ignoring stale response for ${resultType}`);
return;
}
} else {
debugLog("No result_type, using queue head");
call = queue.shift()!;
}
clearTimeout(call.timeout);
if (response.error) {
call.reject(
new Error(
`NWC error (${response.error.code}): ${response.error.message}`,
),
);
call.reject(new Error(
`NWC error (${response.error.code}): ${response.error.message}`));
} else {
call.resolve(response);
}
} catch (error) {
call.reject(
new Error(
`Failed to parse NWC response: ${(error as Error).message}`,
),
);
const call = queue.shift()!;
clearTimeout(call.timeout);
call.reject(new Error(
`Failed to parse NWC response: ${(error as Error).message}`));
}
// Process next in queue
sendNextFromQueue();
}
// ── Enqueue a call ─────────────────────────────────────────────
// ── Enqueue ────────────────────────────────────────────────────
function enqueueCall(
method: NwcMethod,
params: Record<string, unknown>,
method: NwcMethod, params: Record<string, unknown>,
): Promise<NwcResponse> {
if (!connected || !ws) {
return Promise.reject(new Error("NWC client not connected"));
}
return new Promise<NwcResponse>((resolve, reject) => {
const timeout = setTimeout(() => {
const idx = queue.findIndex((c) => c.resolve === resolve);
if (idx >= 0) {
queue.splice(idx, 1);
}
if (idx >= 0) queue.splice(idx, 1);
sendNextFromQueue();
reject(
new Error(`NWC '${method}' timed out after ${replyTimeoutMs}ms`),
);
reject(new Error(
`NWC '${method}' timed out after ${replyTimeoutMs}ms`));
}, replyTimeoutMs);
queue.push({ method, params, resolve, reject, timeout });
sendNextFromQueue();
});
}
// ── Encryption negotiation ─────────────────────────────────────
// Fetches the wallet's info event (kind 13194) to check supported encryption.
function negotiateEncryption(): void {
const infoSubId = `nwc-info-${clientPubkeyHex.slice(0, 8)}`;
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
try { sendRaw(["CLOSE", infoSubId]); } catch { /* ok */ }
};
const timeout = setTimeout(() => {
debugLog("Info event fetch timed out, defaulting to nip04");
finish();
}, publishTimeoutMs);
const originalOnMsg = ws!.onmessage;
ws!.onmessage = (evt) => {
const raw = typeof evt.data === "string"
? evt.data : new TextDecoder().decode(evt.data as ArrayBuffer);
let msg: unknown[];
try { msg = JSON.parse(raw); } catch {
originalOnMsg?.call(ws, evt); return;
}
if (!Array.isArray(msg) || msg.length < 2) {
originalOnMsg?.call(ws, evt); return;
}
if (msg[0] === "EVENT" && msg.length >= 3) {
const ev = msg[2] as NostrEvent;
if (ev.kind === 13194 && ev.pubkey === walletPubkey) {
clearTimeout(timeout);
debugLog(`Got wallet info: "${ev.content}" tags: ${JSON.stringify(ev.tags)}`);
const encTag = ev.tags.find((t) => t.length >= 2 && t[0] === "encryption");
if (encTag) {
const schemes = encTag[1].split(/\s+/);
debugLog(`Wallet encryption: ${schemes.join(", ")}`);
if (schemes.includes("nip44_v2")) {
useEncryption = "nip44_v2";
log("Negotiated encryption: nip44_v2");
}
} else {
debugLog("No encryption tag, using nip04 (default)");
}
ws!.onmessage = originalOnMsg;
finish(); return;
}
}
if (msg[0] === "EOSE") {
clearTimeout(timeout);
debugLog("Info event not found, defaulting to nip04");
ws!.onmessage = originalOnMsg;
finish(); return;
}
originalOnMsg?.call(ws, evt);
};
sendRaw(["REQ", infoSubId, { kinds: [13194], authors: [walletPubkey] }]);
}
// ── Relay message handler ──────────────────────────────────────
function handleRelayMessage(raw: string): void {
let msg: unknown[];
try {
msg = JSON.parse(raw);
} catch {
return;
}
try { msg = JSON.parse(raw); } catch { return; }
if (!Array.isArray(msg) || msg.length < 2) return;
const type = msg[0] as string;
if (type === "EVENT" && msg.length >= 3) {
const event = msg[2] as NostrEvent;
if (event?.kind === NWC_RESPONSE_KIND) {
handleResponse(event).catch((err) =>
logger.error("[nwc] Error handling response:", err),
);
}
if (event?.kind === NWC_RESPONSE_KIND) handleResponse(event);
return;
}
if (type === "OK") {
const eventId = msg[1] as string;
const success = msg[2] as boolean;
debugLog(
`Event ${eventId.slice(0, 8)}... ${success ? "accepted" : `rejected: ${msg[3]}`}`,
);
debugLog(`Event ${(msg[1]as string).slice(0,8)}... ${success ? "accepted" : "rejected: "+msg[3]}`);
return;
}
if (type === "NOTICE") {
log(`Relay notice: ${msg[1]}`);
return;
}
if (type === "NOTICE") { log(`Relay notice: ${msg[1]}`); return; }
if (type === "EOSE") {
debugLog(`EOSE for subscription ${msg[1]}`);
if (msg[1] === subscriptionId) subscriptionReady = true;
debugLog(`EOSE for sub ${msg[1]}`);
return;
}
}
@@ -350,75 +376,68 @@ export function createNwcClient(options: NwcClientOptions): NwcClient {
return new Promise<void>((resolve, reject) => {
try {
log(`Connecting to NWC relay: ${relayUrl}`);
const socket = new WebSocket(relayUrl);
const connectTimeout = setTimeout(() => {
socket.close();
reject(
new Error(
`NWC relay connection timed out for ${relayUrl}`,
),
);
reject(new Error(`NWC relay connection timed out for ${relayUrl}`));
}, publishTimeoutMs);
socket.onopen = () => {
clearTimeout(connectTimeout);
ws = socket;
connected = true;
subscriptionReady = false;
reconnectAttempts = 0;
log(`Connected to NWC relay`);
log("Connected to NWC relay");
// Subscribe to response events (kind 23195) tagged for us
// Subscribe to response events tagged for us
subscriptionId = `routstrd-nwc-${clientPubkeyHex.slice(0, 8)}`;
const subFilter = {
kinds: [NWC_RESPONSE_KIND],
"#p": [clientPubkeyHex],
sendRaw(["REQ", subscriptionId, {
kinds: [NWC_RESPONSE_KIND], "#p": [clientPubkeyHex],
}]);
debugLog(`Subscribed: ${subscriptionId}`);
// Fetch wallet info event for encryption negotiation
negotiateEncryption();
// Wait for EOSE to confirm subscription is active
const waitForReady = () => {
if (subscriptionReady || !connected) resolve();
else setTimeout(waitForReady, 50);
};
debugLog(`REQ filter: ${JSON.stringify(subFilter)}`);
sendRaw(["REQ", subscriptionId, subFilter]);
debugLog(`Subscribed to responses: ${subscriptionId}`);
resolve();
// Safety timeout: resolve anyway after 5s
setTimeout(() => {
if (!subscriptionReady && connected) {
log("EOSE timeout, proceeding");
resolve();
}
}, 5000);
waitForReady();
};
socket.onmessage = (event) => {
const data =
typeof event.data === "string"
? event.data
: new TextDecoder().decode(event.data as ArrayBuffer);
const data = typeof event.data === "string"
? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
handleRelayMessage(data);
};
socket.onclose = (event) => {
log(
`NWC relay disconnected: code=${event.code} reason="${event.reason}"`,
);
connected = false;
ws = null;
// Reject all pending calls
log(`NWC relay disconnected: code=${event.code} reason="${event.reason}"`);
connected = false; subscriptionReady = false; ws = null;
while (queue.length > 0) {
const call = queue.shift()!;
clearTimeout(call.timeout);
call.reject(new Error("NWC relay connection closed"));
}
// Auto-reconnect
if (!stopReconnecting && reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
const delay = Math.min(
1000 * Math.pow(2, reconnectAttempts),
30000,
);
log(
`Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`,
);
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
log(`Reconnecting in ${delay}ms (${reconnectAttempts}/${maxReconnectAttempts})`);
setTimeout(() => {
doConnect().catch((err) =>
logger.error("[nwc] Reconnect failed:", err),
);
doConnect().catch((err) => logger.error("[nwc] Reconnect failed:", err));
}, delay);
} else if (stopReconnecting) {
log("Reconnect disabled (explicit disconnect)");
log("Reconnect disabled");
} else {
logger.error("[nwc] Max reconnect attempts reached");
}
@@ -437,10 +456,7 @@ export function createNwcClient(options: NwcClientOptions): NwcClient {
return {
async connect() {
if (connected) {
log("Already connected");
return;
}
if (connected) { log("Already connected"); return; }
stopReconnecting = false;
await doConnect();
},
@@ -448,31 +464,19 @@ export function createNwcClient(options: NwcClientOptions): NwcClient {
disconnect() {
stopReconnecting = true;
if (ws) {
if (subscriptionId) {
try {
sendRaw(["CLOSE", subscriptionId]);
} catch {
// ignore
}
subscriptionId = null;
}
ws.close();
ws = null;
if (subscriptionId) { try { sendRaw(["CLOSE", subscriptionId]); } catch {} subscriptionId = null; }
ws.close(); ws = null;
}
connected = false;
connected = false; subscriptionReady = false;
while (queue.length > 0) {
const call = queue.shift()!;
clearTimeout(call.timeout);
call.reject(new Error("NWC client disconnected"));
}
log("Disconnected from NWC relay");
log("Disconnected");
},
isConnected() {
return connected;
},
isConnected() { return connected; },
async getInfo() {
const response = await enqueueCall("get_info", {});
@@ -487,15 +491,12 @@ export function createNwcClient(options: NwcClientOptions): NwcClient {
async getBalance() {
const response = await enqueueCall("get_balance", {});
const balance = (response.result?.balance as number) || 0;
return Math.floor(balance / 1000); // msats -> sats
return Math.floor(((response.result?.balance as number) || 0) / 1000);
},
async payInvoice(invoice, amount?) {
const params: Record<string, unknown> = { invoice };
if (amount !== undefined) {
params.amount = amount * 1000; // sats -> msats for NIP-47
}
if (amount !== undefined) params.amount = amount * 1000;
const response = await enqueueCall("pay_invoice", params);
const result = response.result || {};
return {
@@ -506,7 +507,7 @@ export function createNwcClient(options: NwcClientOptions): NwcClient {
async makeInvoice(params) {
const response = await enqueueCall("make_invoice", {
amount: params.amount,
amount: params.amount * 1000,
description: params.description || "",
});
const result = response.result || {};
@@ -521,14 +522,11 @@ export function createNwcClient(options: NwcClientOptions): NwcClient {
const lookupParams: Record<string, unknown> = {};
if (params.payment_hash) lookupParams.payment_hash = params.payment_hash;
if (params.invoice) lookupParams.invoice = params.invoice;
const response = await enqueueCall("lookup_invoice", lookupParams);
if (!response.result) return null;
const result = response.result;
if (!result) return null;
return {
transaction_type:
(result.transaction_type as "incoming" | "outgoing") || "incoming",
transaction_type: (result.transaction_type as "incoming" | "outgoing") || "incoming",
invoice: result.invoice as string | undefined,
preimage: result.preimage as string | undefined,
payment_hash: (result.payment_hash as string) || "",
+160
View File
@@ -0,0 +1,160 @@
/**
* Standalone NWC (Nostr Wallet Connect) test script.
*
* Usage:
* 1. Set your NWC connection string in .env:
* NWC_CONNECTION_STRING=nostr+walletconnect://<wallet-pubkey>?relay=<relay-url>&secret=<32-byte-hex>
*
* 2. Run:
* bun test_nwc.ts
*
* 3. Optionally override via CLI:
* bun test_nwc.ts <nostr+walletconnect://...>
*
* 4. Customize test amount (default 1 sat):
* NWC_TEST_AMOUNT=10 bun test_nwc.ts
*
* What this does:
* 1. Parses the NWC connection string (same logic as nwc-client.ts)
* 2. Connects to the relay and subscribes to responses
* 3. Gets wallet info + balance
* 4. Creates a Lightning invoice via make_invoice (simulates routstrd receive)
* 5. Pays that invoice via pay_invoice (tests NWC payment roundtrip)
* 6. Shows updated balance
* 7. Disconnects cleanly
*/
import { createNwcClient, parseConnectionString } from "./src/daemon/wallet/nwc-client";
// ── Load NWC connection string ──────────────────────────────────────
let connectionString = process.argv[2]
|| process.env.NWC_CONNECTION_STRING;
if (!connectionString) {
// Try loading from .env
try {
const envFile = Bun.file(".env");
if (await envFile.exists()) {
const envText = await envFile.text();
for (const line of envText.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const [key, ...rest] = [trimmed.slice(0, eqIdx).trim(), trimmed.slice(eqIdx + 1).trim()];
if (key === "NWC_CONNECTION_STRING") {
connectionString = rest.join("=");
break;
}
}
}
} catch { /* ok */ }
}
if (!connectionString) {
console.error("❌ No NWC connection string found.");
console.error(" Set NWC_CONNECTION_STRING in .env or pass as CLI arg:");
console.error(" bun test_nwc.ts nostr+walletconnect://<pubkey>?relay=<relay>&secret=<hex>");
process.exit(1);
}
const testAmount = parseInt(process.env.NWC_TEST_AMOUNT || "1", 10) || 1;
// ── Helpers ─────────────────────────────────────────────────────────
function fmtKey(k: string) { return k.slice(0, 16) + "..."; }
function ok(s: string) { console.log(`${s}`); }
function warn(s: string) { console.log(` ⚠️ ${s}`); }
function info(label: string, val: string) {
console.log(` ${label.padEnd(12)}: ${val}`);
}
// ── Main ────────────────────────────────────────────────────────────
async function main() {
console.log("🔌 NWC Test Script\n─────────────────");
let parsed;
try {
parsed = parseConnectionString(connectionString!);
} catch (err) {
console.error("❌ Invalid connection string:", (err as Error).message);
process.exit(1);
}
info("Wallet pubkey", fmtKey(parsed.pubkey));
info("Relay", parsed.relay);
console.log();
const client = createNwcClient({
connectionString: connectionString!,
replyTimeoutMs: 30000,
publishTimeoutMs: 15000,
});
try {
// 1. Connect
console.log("1️⃣ Connecting...");
await client.connect();
ok("Connected\n");
// 2. Get info (skipped — wallet doesn't respond to this method)
console.log("2️⃣ get_info — skipping\n");
// 3. Get balance (skipped)
console.log("3️⃣ get_balance — skipping");
let balanceBefore = -1;
console.log();
// 4. Create invoice (make_invoice — simulates routstrd receive)
console.log(`4️⃣ make_invoice (${testAmount} sat${testAmount !== 1 ? "s" : ""})`);
const invoice = await client.makeInvoice({
amount: testAmount,
description: "NWC test invoice from routstrd",
});
info("payment_hash", invoice.payment_hash || "(empty!)");
info("amount", `${invoice.amount} sats`);
info("invoice", invoice.invoice
? invoice.invoice.slice(0, 60) + (invoice.invoice.length > 60 ? "..." : "")
: "(empty!)");
if (!invoice.invoice || !invoice.payment_hash) {
warn("Invoice came back empty — printing full raw values for debugging:");
console.log(` raw payment_hash: ${JSON.stringify(invoice.payment_hash)}`);
console.log(` raw invoice: ${JSON.stringify(invoice.invoice)}`);
console.log(` raw amount: ${JSON.stringify(invoice.amount)}`);
}
console.log();
// 5. Pay the invoice
if (!invoice.invoice) {
console.log("5️⃣ Skipping pay_invoice (no invoice to pay)\n");
} else {
console.log("5️⃣ pay_invoice");
const payment = await client.payInvoice(invoice.invoice, testAmount);
ok("Paid!");
info("Preimage", payment.preimage.slice(0, 16) + "...");
if (payment.fees_paid !== undefined) {
info("Fees paid", `${payment.fees_paid} msats`);
}
console.log();
}
// 6. Lookup invoice (skipped)
console.log("6️⃣ lookup_invoice — skipping\n");
// 7. Balance check skipped
console.log("\n✅ All tests passed!");
} catch (err) {
console.error(`\n❌ Error: ${(err as Error).message}`);
process.exit(1);
} finally {
console.log("\n🔌 Disconnecting...");
client.disconnect();
console.log(" Done.");
}
}
main();