diff --git a/examples/nwc/client/subscribe.ts b/examples/nwc/client/subscribe.ts index 3e97c9b6..3a3139d8 100644 --- a/examples/nwc/client/subscribe.ts +++ b/examples/nwc/client/subscribe.ts @@ -3,7 +3,7 @@ import "websocket-polyfill"; // required in node.js import * as readline from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; -import { NWCClient } from "@getalby/sdk/nwc"; +import { NWCClient, Nip47Notification } from "@getalby/sdk/nwc"; const rl = readline.createInterface({ input, output }); @@ -16,10 +16,12 @@ const client = new NWCClient({ nostrWalletConnectUrl: nwcUrl, }); -const onNotification = (notification) => +const onNotification = (notification: Nip47Notification) => console.info("Got notification", notification); -const unsub = await client.subscribeNotifications(onNotification); +const unsub = await client.subscribeNotifications((n) => { + onNotification(n); +}); console.info("Waiting for notifications..."); process.on("SIGINT", function () { diff --git a/package.json b/package.json index c2726284..088fdc31 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ }, "dependencies": { "@getalby/lightning-tools": "^6.0.0", - "nostr-tools": "^2.17.0" + "nostr-tools": "^2.19.4" }, "devDependencies": { "@commitlint/cli": "^20.1.0", diff --git a/src/nwc/NWAClient.ts b/src/nwc/NWAClient.ts index 72301c59..a95f8126 100644 --- a/src/nwc/NWAClient.ts +++ b/src/nwc/NWAClient.ts @@ -7,7 +7,6 @@ import { Nip47NotificationType, } from "./types"; import { NWCClient } from "./NWCClient"; -import { SubCloser } from "nostr-tools/lib/types/abstract-pool"; export type NWAOptions = { relayUrls: string[]; @@ -29,7 +28,6 @@ export type NewNWAClientOptions = Omit & { appSecretKey?: string; }; -// TODO: add support for multiple relay URLs export class NWAClient { options: NWAOptions; appSecretKey: string; @@ -48,7 +46,9 @@ export class NWAClient { if (!this.options.requestMethods) { throw new Error("Missing request methods"); } - this.pool = new SimplePool(); + this.pool = new SimplePool({ + enableReconnect: true, + }); if (globalThis.WebSocket === undefined) { console.error( @@ -175,77 +175,44 @@ export class NWAClient { }): Promise<{ unsub: () => void; }> { - let subscribed = true; - let endPromise: (() => void) | undefined; - let sub: SubCloser | undefined; - (async () => { - while (subscribed) { - try { - await this._checkConnected(); - - sub = this.pool.subscribe( - this.options.relayUrls, - { - kinds: [13194], // NIP-47 info event - "#p": [this.options.appPubkey], - }, - { - onevent: async (event) => { - const client = new NWCClient({ - relayUrls: this.options.relayUrls, - secret: this.appSecretKey, - walletPubkey: event.pubkey, - }); - - // try to fetch the lightning address - try { - const info = await client.getInfo(); - client.options.lud16 = info.lud16; - client.lud16 = info.lud16; - } catch (error) { - console.error("failed to fetch get_info", error); - } + await this._checkConnected(); + console.info("subscribing to info event"); + + const sub = this.pool.subscribe( + this.options.relayUrls, + { + kinds: [13194], // NIP-47 info event + "#p": [this.options.appPubkey], + }, + { + onevent: async (event) => { + const client = new NWCClient({ + relayUrls: this.options.relayUrls, + secret: this.appSecretKey, + walletPubkey: event.pubkey, + }); - args.onSuccess(client); + // try to fetch the lightning address + try { + const info = await client.getInfo(); + client.options.lud16 = info.lud16; + client.lud16 = info.lud16; + } catch (error) { + console.error("failed to fetch get_info", error); + } - subscribed = false; - endPromise?.(); - sub?.close(); - }, - onclose: (reasons) => { - // NOTE: this fires when all relays were closed once. There is no reconnect logic in nostr-tools - // See https://github.com/nbd-wtf/nostr-tools/issues/513 - console.info("relay connection closed", reasons); - endPromise?.(); - }, - }, - ); - console.info("subscribed to relays"); + args.onSuccess(client); - await new Promise((resolve) => { - endPromise = () => { - resolve(); - }; - }); - } catch (error) { - console.error( - "error subscribing to info event", - error || "unknown relay error", - ); - } - if (subscribed) { - // wait a second and try re-connecting - // any events during this period will be lost - // unless using a relay that keeps events until client reconnect - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } - })(); + sub?.close(); + }, + onclose: (reasons) => { + console.info("subscription closed", reasons); + }, + }, + ); return { unsub: () => { - subscribed = false; - endPromise?.(); sub?.close(); }, }; diff --git a/src/nwc/NWCClient.ts b/src/nwc/NWCClient.ts index cc171ffa..b47b97c2 100644 --- a/src/nwc/NWCClient.ts +++ b/src/nwc/NWCClient.ts @@ -55,7 +55,6 @@ import { Nip47CancelHoldInvoiceResponse, Nip47NetworkError, } from "./types"; -import { SubCloser } from "nostr-tools/lib/types/abstract-pool"; export interface NWCOptions { relayUrls: string[]; @@ -122,8 +121,7 @@ export class NWCClient { } as NWCOptions; this.relayUrls = this.options.relayUrls; - this.pool = new SimplePool({ - }); + this.pool = new SimplePool({ enableReconnect: true }); if (this.options.secret) { this.secret = ( this.options.secret.toLowerCase().startsWith("nsec") @@ -710,91 +708,58 @@ export class NWCClient { onNotification: (notification: Nip47Notification) => void, notificationTypes?: Nip47NotificationType[], ): Promise<() => void> { - let subscribed = true; - let endPromise: (() => void) | undefined; - let sub: SubCloser | undefined; - (async () => { - while (subscribed) { - try { - await this._checkConnected(); - await this._selectEncryptionType(); - console.info("subscribing to relays"); - sub = this.pool.subscribe( - this.relayUrls, - { - kinds: [...(this.encryptionType === "nip04" ? [23196] : [23197])], - authors: [this.walletPubkey], - "#p": [this.publicKey], - }, - { - onevent: async (event) => { - let decryptedContent; - try { - decryptedContent = await this.decrypt( - this.walletPubkey, - event.content, - ); - } catch (error) { - console.error( - "failed to decrypt request event content", - error, - ); - return; - } - let notification; - try { - notification = JSON.parse( - decryptedContent, - ) as Nip47Notification; - } catch (e) { - console.error("Failed to parse decrypted event content", e); - return; - } - if (notification.notification) { - if ( - !notificationTypes || - notificationTypes.indexOf(notification.notification_type) > - -1 - ) { - onNotification(notification); - } - } else { - console.error("No notification in response", notification); - } - }, - onclose: (reasons) => { - // NOTE: this fires when all relays were closed once. There is no reconnect logic in nostr-tools - // See https://github.com/nbd-wtf/nostr-tools/issues/513 - console.info("relay connection closed", reasons); - endPromise?.(); - }, - }, - ); - console.info("subscribed to relays"); + await this._checkConnected(); + await this._selectEncryptionType(); - await new Promise((resolve) => { - endPromise = () => { - resolve(); - }; - }); - } catch (error) { - console.error( - "error subscribing to notifications", - error || "unknown relay error", - ); - } - if (subscribed) { - // wait a second and try re-connecting - // any notifications during this period will be lost - // unless using a relay that keeps events until client reconnect - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } - })(); + console.info("subscribing to relays"); + + const sub = this.pool.subscribe( + this.relayUrls, + { + kinds: [...(this.encryptionType === "nip04" ? [23196] : [23197])], + authors: [this.walletPubkey], + "#p": [this.publicKey], + }, + { + onevent: async (event) => { + let decryptedContent; + try { + decryptedContent = await this.decrypt( + this.walletPubkey, + event.content, + ); + } catch (error) { + console.error("failed to decrypt request event content", error); + return; + } + let notification; + try { + notification = JSON.parse(decryptedContent) as Nip47Notification; + } catch (e) { + console.error("Failed to parse decrypted event content", e); + return; + } + if (notification.notification) { + if ( + !notificationTypes || + notificationTypes.indexOf(notification.notification_type) > -1 + ) { + onNotification(notification); + } + } else { + console.error("No notification in response", notification); + } + }, + onclose: (reasons) => { + // Since we have auto-reconnect, this usually only fires on fatal errors, + // all relays were closed once or explicit closure, not temp disconnects. + console.warn("subscription closed", reasons); + }, + }, + ); + console.info("subscribed to relays"); return () => { - subscribed = false; - endPromise?.(); sub?.close(); }; } diff --git a/src/nwc/NWCWalletService.ts b/src/nwc/NWCWalletService.ts index cbdf3264..0b222f8e 100644 --- a/src/nwc/NWCWalletService.ts +++ b/src/nwc/NWCWalletService.ts @@ -5,10 +5,9 @@ import { getPublicKey, Event, EventTemplate, - Relay, + SimplePool, } from "nostr-tools"; import { hexToBytes } from "@noble/hashes/utils"; -import { Subscription } from "nostr-tools/lib/types/abstract-relay"; import { Nip47MakeInvoiceRequest, @@ -30,7 +29,8 @@ import { } from "./NWCWalletServiceRequestHandler"; export type NewNWCWalletServiceOptions = { - relayUrl: string; + relayUrl?: string; + relayUrls?: string[]; }; export class NWCWalletServiceKeyPair { @@ -51,13 +51,19 @@ export class NWCWalletServiceKeyPair { } export class NWCWalletService { - relay: Relay; - relayUrl: string; + pool: SimplePool; + relayUrls: string[]; constructor(options: NewNWCWalletServiceOptions) { - this.relayUrl = options.relayUrl; + if (options.relayUrls && options.relayUrls.length > 0) { + this.relayUrls = options.relayUrls; + } else if (options.relayUrl) { + this.relayUrls = [options.relayUrl]; + } else { + throw new Error("Missing relayUrl or relayUrls"); + } - this.relay = new Relay(this.relayUrl); + this.pool = new SimplePool({ enableReconnect: true }); if (globalThis.WebSocket === undefined) { console.error( @@ -84,7 +90,7 @@ export class NWCWalletService { }; const event = await this.signEvent(eventTemplate, walletSecret); - await this.relay.publish(event); + await Promise.allSettled(this.pool.publish(this.relayUrls, event)); } catch (error) { console.error("failed to publish wallet service info event", error); throw error; @@ -95,166 +101,137 @@ export class NWCWalletService { keypair: NWCWalletServiceKeyPair, handler: NWCWalletServiceRequestHandler, ): Promise<() => void> { - let subscribed = true; - let endPromise: (() => void) | undefined; - let onRelayDisconnect: (() => void) | undefined; - let sub: Subscription | undefined; - (async () => { - while (subscribed) { - try { - console.info("checking connection to relay"); - await this._checkConnected(); - console.info("subscribing to relay"); - sub = this.relay.subscribe( - [ - { - kinds: [23194], - authors: [keypair.clientPubkey], - "#p": [keypair.walletPubkey], - }, - ], - {}, - ); - console.info("subscribed to relays"); + console.info("checking connection to relay"); + await this._checkConnected(); - sub.onevent = async (event) => { - try { - // console.info("Got event", event); - const encryptionType = (event.tags.find( - (t) => t[0] === "encryption", - )?.[1] || "nip04") as Nip47EncryptionType; + console.info("subscribing to relay"); + const sub = this.pool.subscribe( + this.relayUrls, - const decryptedContent = await this.decrypt( - keypair, - event.content, - encryptionType, - ); - const request = JSON.parse(decryptedContent) as { - method: Nip47Method; - params: unknown; - }; + { + kinds: [23194], + authors: [keypair.clientPubkey], + "#p": [keypair.walletPubkey], + }, - let responsePromise: - | NWCWalletServiceResponsePromise - | undefined; + { + onevent: async (event) => { + try { + // console.info("Got event", event); + const encryptionType = (event.tags.find( + (t) => t[0] === "encryption", + )?.[1] || "nip04") as Nip47EncryptionType; - switch (request.method) { - case "get_info": - responsePromise = handler.getInfo?.(); - break; - case "make_invoice": - responsePromise = handler.makeInvoice?.( - request.params as Nip47MakeInvoiceRequest, - ); - break; - case "pay_invoice": - responsePromise = handler.payInvoice?.( - request.params as Nip47PayInvoiceRequest, - ); - break; - case "pay_keysend": - responsePromise = handler.payKeysend?.( - request.params as Nip47PayKeysendRequest, - ); - break; - case "get_balance": - responsePromise = handler.getBalance?.(); - break; - case "lookup_invoice": - responsePromise = handler.lookupInvoice?.( - request.params as Nip47LookupInvoiceRequest, - ); - break; - case "list_transactions": - responsePromise = handler.listTransactions?.( - request.params as Nip47ListTransactionsRequest, - ); - break; - case "sign_message": - responsePromise = handler.signMessage?.( - request.params as Nip47SignMessageRequest, - ); - break; - // TODO: handle multi_* methods - } + const decryptedContent = await this.decrypt( + keypair, + event.content, + encryptionType, + ); + const request = JSON.parse(decryptedContent) as { + method: Nip47Method; + params: unknown; + }; + + let responsePromise: + | NWCWalletServiceResponsePromise + | undefined; - let response: NWCWalletServiceResponse | undefined = - await responsePromise; + switch (request.method) { + case "get_info": + responsePromise = handler.getInfo?.(); + break; + case "make_invoice": + responsePromise = handler.makeInvoice?.( + request.params as Nip47MakeInvoiceRequest, + ); + break; + case "pay_invoice": + responsePromise = handler.payInvoice?.( + request.params as Nip47PayInvoiceRequest, + ); + break; + case "pay_keysend": + responsePromise = handler.payKeysend?.( + request.params as Nip47PayKeysendRequest, + ); + break; + case "get_balance": + responsePromise = handler.getBalance?.(); + break; + case "lookup_invoice": + responsePromise = handler.lookupInvoice?.( + request.params as Nip47LookupInvoiceRequest, + ); + break; + case "list_transactions": + responsePromise = handler.listTransactions?.( + request.params as Nip47ListTransactionsRequest, + ); + break; + case "sign_message": + responsePromise = handler.signMessage?.( + request.params as Nip47SignMessageRequest, + ); + break; + // TODO: handle multi_* methods + } - if (!response) { - console.warn("received unsupported method", request.method); - response = { - error: { - code: "NOT_IMPLEMENTED", - message: - "This method is not supported by the wallet service", - }, - result: undefined, - }; - } + let response: NWCWalletServiceResponse | undefined = + await responsePromise; - const responseEventTemplate: EventTemplate = { - kind: 23195, - created_at: Math.floor(Date.now() / 1000), - tags: [["e", event.id]], - content: await this.encrypt( - keypair, - JSON.stringify({ - result_type: request.method, - ...response, - }), - encryptionType, - ), + if (!response) { + console.warn("received unsupported method", request.method); + response = { + error: { + code: "NOT_IMPLEMENTED", + message: "This method is not supported by the wallet service", + }, + result: undefined, }; - - const responseEvent = await this.signEvent( - responseEventTemplate, - keypair.walletSecret, - ); - await this.relay.publish(responseEvent); - } catch (e) { - console.error("Failed to parse decrypted event content", e); - return; } - }; - await new Promise((resolve) => { - endPromise = () => { - resolve(); - }; - onRelayDisconnect = () => { - console.error("relay disconnected"); - endPromise?.(); + const responseEventTemplate: EventTemplate = { + kind: 23195, + created_at: Math.floor(Date.now() / 1000), + tags: [["e", event.id]], + content: await this.encrypt( + keypair, + JSON.stringify({ + result_type: request.method, + ...response, + }), + encryptionType, + ), }; - this.relay.onclose = onRelayDisconnect; - }); - if (onRelayDisconnect !== undefined) { - this.relay.onclose = null; + + const responseEvent = await this.signEvent( + responseEventTemplate, + keypair.walletSecret, + ); + + // Tries to publish, but ignores failures if relay is dead + Promise.allSettled( + this.pool.publish(this.relayUrls, responseEvent), + ); + } catch (e) { + console.error("Failed to parse decrypted event content", e); + return; } - } catch (error) { - console.error( - "error subscribing to requests", - error || "unknown relay error", - ); - } - if (subscribed) { - // wait a second and try re-connecting - // any notifications during this period will be lost - // unless using a relay that keeps events until client reconnect - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } - })(); + }, + onclose: (reasons) => { + console.warn("Subscription closed:", reasons); + }, + }, + ); return () => { - subscribed = false; - endPromise?.(); sub?.close(); }; } get connected() { - return this.relay.connected; + const statuses = Array.from(this.pool.listConnectionStatus().values()); + return statuses.some((status) => status === true); } signEvent(event: EventTemplate, secretKey: string): Promise { @@ -262,7 +239,7 @@ export class NWCWalletService { } close() { - return this.relay.close(); + return this.pool.close(this.relayUrls); } async encrypt( @@ -312,17 +289,15 @@ export class NWCWalletService { } private async _checkConnected() { - if (!this.relayUrl) { - throw new Error("Missing relay url"); - } + // Waits for the socket to open, then proceeds try { - if (!this.relay.connected) { - await this.relay.connect(); - } - } catch (_ /* error is always undefined */) { - console.error("failed to connect to relay", this.relayUrl); + await Promise.any( + this.relayUrls.map((relayUrl) => this.pool.ensureRelay(relayUrl)), + ); + } catch (error) { + console.error("failed to connect to relay", this.relayUrls, error); throw new Nip47NetworkError( - "Failed to connect to " + this.relayUrl, + "Failed to connect to " + this.relayUrls.join(","), "OTHER", ); } diff --git a/yarn.lock b/yarn.lock index ae506dd8..598b2218 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4118,10 +4118,10 @@ normalize-path@^3.0.0: resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== -nostr-tools@^2.17.0: - version "2.17.0" - resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.17.0.tgz#ae357479b957ff897ae404761d5cd88f8064d713" - integrity sha512-lrvHM7cSaGhz7F0YuBvgHMoU2s8/KuThihDoOYk8w5gpVHTy0DeUCAgCN8uLGeuSl5MAWekJr9Dkfo5HClqO9w== +nostr-tools@^2.19.4: + version "2.19.4" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.19.4.tgz#c4e2b56914db7f3b91e5fbef250e7ce65754ed74" + integrity sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg== dependencies: "@noble/ciphers" "^0.5.1" "@noble/curves" "1.2.0"