From 62bd014836a5ca619a5c20a1135201ff697f0eb5 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sat, 23 May 2026 14:26:45 +0800 Subject: [PATCH] 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 --- src/daemon/wallet/nwc-client.ts | 382 ++++++++++++++++---------------- test_nwc.ts | 160 +++++++++++++ 2 files changed, 350 insertions(+), 192 deletions(-) create mode 100644 test_nwc.ts diff --git a/src/daemon/wallet/nwc-client.ts b/src/daemon/wallet/nwc-client.ts index 22ae7dc..22ed6a8 100644 --- a/src/daemon/wallet/nwc-client.ts +++ b/src/daemon/wallet/nwc-client.ts @@ -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; // in sats + getBalance(): Promise; 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; @@ -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 { + 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 { + 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, + method: NwcMethod, params: Record, ): Promise { if (!connected || !ws) { return Promise.reject(new Error("NWC client not connected")); } - return new Promise((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((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 = { 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 = {}; 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) || "", diff --git a/test_nwc.ts b/test_nwc.ts new file mode 100644 index 0000000..6247ecf --- /dev/null +++ b/test_nwc.ts @@ -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://?relay=&secret=<32-byte-hex> + * + * 2. Run: + * bun test_nwc.ts + * + * 3. Optionally override via CLI: + * bun test_nwc.ts + * + * 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://?relay=&secret="); + 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();