mirror of
https://github.com/Routstr/routstrd.git
synced 2026-07-30 15:46:14 +00:00
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:
+190
-192
@@ -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
@@ -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();
|
||||
Reference in New Issue
Block a user