From 5f3f538f0cb47c98353b560271271cfd33b77b7f Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Sat, 29 Mar 2025 11:50:04 +0700 Subject: [PATCH 1/7] feat: add NWC Wallet Service --- examples/nwc/wallet-service/example.js | 57 +++++ src/index.ts | 4 +- src/{ => nwc}/NWAClient.test.ts | 0 src/{ => nwc}/NWAClient.ts | 0 src/{ => nwc}/NWCClient.test.ts | 0 src/{ => nwc}/NWCClient.ts | 15 +- src/nwc/NWCWalletService.ts | 294 ++++++++++++++++++++++ src/nwc/NWCWalletServiceRequestHandler.ts | 28 +++ src/nwc/index.ts | 2 + src/nwc/types.ts | 1 + src/types.ts | 2 +- src/webln/NostrWeblnProvider.ts | 2 +- 12 files changed, 393 insertions(+), 12 deletions(-) create mode 100644 examples/nwc/wallet-service/example.js rename src/{ => nwc}/NWAClient.test.ts (100%) rename src/{ => nwc}/NWAClient.ts (100%) rename src/{ => nwc}/NWCClient.test.ts (100%) rename src/{ => nwc}/NWCClient.ts (98%) create mode 100644 src/nwc/NWCWalletService.ts create mode 100644 src/nwc/NWCWalletServiceRequestHandler.ts create mode 100644 src/nwc/index.ts create mode 100644 src/nwc/types.ts diff --git a/examples/nwc/wallet-service/example.js b/examples/nwc/wallet-service/example.js new file mode 100644 index 00000000..12d210bc --- /dev/null +++ b/examples/nwc/wallet-service/example.js @@ -0,0 +1,57 @@ +import "websocket-polyfill"; // required in node.js + +import { generateSecretKey, getPublicKey } from "nostr-tools"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; + +const walletServiceSecretKey = bytesToHex(generateSecretKey()); +const walletServicePubkey = getPublicKey(hexToBytes(walletServiceSecretKey)); + +const clientSecretKey = bytesToHex(generateSecretKey()); +const clientPubkey = getPublicKey(hexToBytes(clientSecretKey)); + +const relayUrl = "wss://relay.getalby.com/v1"; + +const nwcUrl = `nostr+walletconnect://${walletServicePubkey}?relay=${relayUrl}&secret=${clientSecretKey}`; + +console.info("enter this NWC URL in a client: ", nwcUrl); + +import { nwc } from "../../../dist/index.module.js"; + +const walletService = new nwc.NWCWalletService({ + relayUrl, +}); + +await walletService.publishWalletServiceInfoEvent( + walletServiceSecretKey, + ["get_info"], + [], +); + +const keypair = new nwc.NWCWalletServiceKeyPair( + walletServiceSecretKey, + clientPubkey, +); + +const unsub = await walletService.subscribe(keypair, { + getInfo: () => { + return Promise.resolve({ + result: { + methods: ["get_info"], + alias: "Alby Hub", + //... add other fields here + }, + error: undefined, + }); + }, + // ... handle other NIP-47 methods here +}); + +console.info("Waiting for events..."); +process.on("SIGINT", function () { + console.info("Caught interrupt signal"); + + unsub(); + walletService.close(); + + process.exit(); +}); diff --git a/src/index.ts b/src/index.ts index b46e97b2..77a94b22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,5 +2,5 @@ export * as auth from "./auth"; export * as types from "./types"; export * as webln from "./webln"; export { Client } from "./client"; -export * as nwc from "./NWCClient"; -export * as nwa from "./NWAClient"; +export * as nwa from "./nwc/NWAClient"; +export * as nwc from "./nwc"; diff --git a/src/NWAClient.test.ts b/src/nwc/NWAClient.test.ts similarity index 100% rename from src/NWAClient.test.ts rename to src/nwc/NWAClient.test.ts diff --git a/src/NWAClient.ts b/src/nwc/NWAClient.ts similarity index 100% rename from src/NWAClient.ts rename to src/nwc/NWAClient.ts diff --git a/src/NWCClient.test.ts b/src/nwc/NWCClient.test.ts similarity index 100% rename from src/NWCClient.test.ts rename to src/nwc/NWCClient.test.ts diff --git a/src/NWCClient.ts b/src/nwc/NWCClient.ts similarity index 98% rename from src/NWCClient.ts rename to src/nwc/NWCClient.ts index ba917d26..fb7cfc6e 100644 --- a/src/NWCClient.ts +++ b/src/nwc/NWCClient.ts @@ -10,9 +10,10 @@ import { EventTemplate, Relay, } from "nostr-tools"; -import { NWCAuthorizationUrlOptions } from "./types"; +import { NWCAuthorizationUrlOptions } from "../types"; import { hexToBytes, bytesToHex } from "@noble/hashes/utils"; import { Subscription } from "nostr-tools/lib/types/abstract-relay"; +import { Nip47EncryptionType } from "./types"; type WithDTag = { dTag: string; @@ -243,8 +244,6 @@ export type NewNWCClientOptions = { lud16?: string; }; -type EncryptionType = "nip04" | "nip44_v2"; - export class NWCClient { relay: Relay; relayUrl: string; @@ -252,7 +251,7 @@ export class NWCClient { lud16: string | undefined; walletPubkey: string; options: NWCOptions; - private _encryptionType: EncryptionType | undefined; + private _encryptionType: Nip47EncryptionType | undefined; static parseWalletConnectUrl(walletConnectUrl: string): NWCOptions { // makes it possible to parse with URL in the different environments (browser/node/...) @@ -578,13 +577,13 @@ export class NWCClient { const versionsTag = events[0].tags.find((t) => t[0] === "v"); const encryptionTag = events[0].tags.find((t) => t[0] === "encryption"); - let encryptions: string[] = ["nip04" satisfies EncryptionType]; + let encryptions: string[] = ["nip04" satisfies Nip47EncryptionType]; // TODO: Remove version tag after 01-06-2025 if (versionsTag && versionsTag[1].includes("1.0")) { - encryptions.push("nip44_v2" satisfies EncryptionType); + encryptions.push("nip44_v2" satisfies Nip47EncryptionType); } if (encryptionTag) { - encryptions = encryptionTag[1].split(" ") as EncryptionType[]; + encryptions = encryptionTag[1].split(" ") as Nip47EncryptionType[]; } return { encryptions, @@ -1246,7 +1245,7 @@ export class NWCClient { private _findPreferredEncryptionType( encryptions: string[], - ): EncryptionType | null { + ): Nip47EncryptionType | null { if (encryptions.includes("nip44_v2")) { return "nip44_v2"; } diff --git a/src/nwc/NWCWalletService.ts b/src/nwc/NWCWalletService.ts new file mode 100644 index 00000000..996ab5df --- /dev/null +++ b/src/nwc/NWCWalletService.ts @@ -0,0 +1,294 @@ +import { + nip04, + nip44, + finalizeEvent, + getPublicKey, + Event, + EventTemplate, + Relay, +} from "nostr-tools"; +import { hexToBytes } from "@noble/hashes/utils"; +import { Subscription } from "nostr-tools/lib/types/abstract-relay"; + +// TODO: move these imports to nwc/types +import { + Nip47MakeInvoiceRequest, + Nip47Method, + Nip47NetworkError, + Nip47NotificationType, +} from "./NWCClient"; +import { + NWCWalletServiceRequestHandler, + NWCWalletServiceResponse, + NWCWalletServiceResponsePromise, +} from "./NWCWalletServiceRequestHandler"; +import { Nip47EncryptionType } from "./types"; + +type NewNWCWalletServiceOptions = { + relayUrl: string; +}; + +export class NWCWalletServiceKeyPair { + walletSecret: string; + walletPubkey: string; + clientPubkey: string; + constructor(walletSecret: string, clientPubkey: string) { + this.walletSecret = walletSecret; + this.clientPubkey = clientPubkey; + if (!this.walletSecret) { + throw new Error("Missing wallet secret key"); + } + if (!this.clientPubkey) { + throw new Error("Missing client pubkey"); + } + this.walletPubkey = getPublicKey(hexToBytes(this.walletSecret)); + } +} + +export class NWCWalletService { + relay: Relay; + relayUrl: string; + + constructor(options: NewNWCWalletServiceOptions) { + this.relayUrl = options.relayUrl; + + this.relay = new Relay(this.relayUrl); + + if (globalThis.WebSocket === undefined) { + console.error( + "WebSocket is undefined. Make sure to `import websocket-polyfill` for nodejs environments", + ); + } + } + + async publishWalletServiceInfoEvent( + walletSecret: string, + supportedMethods: Nip47Method[], + supportedNotifications: Nip47NotificationType[], + ) { + try { + await this._checkConnected(); + const eventTemplate: EventTemplate = { + kind: 13194, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["encryption", "nip04 nip44_v2"], + ["notifications", supportedNotifications.join(" ")], + ], + content: supportedMethods.join(" "), + }; + + const event = await this.signEvent(eventTemplate, walletSecret); + await this.relay.publish(event); + } catch (error) { + console.error("failed to publish wallet service info event", error); + throw error; + } + } + + async subscribe( + 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 { + await this._checkConnected(); + sub = this.relay.subscribe( + [ + { + kinds: [23194], + authors: [keypair.clientPubkey], + "#p": [keypair.walletPubkey], + }, + ], + {}, + ); + // console.info("subscribed to relay"); + + sub.onevent = async (event) => { + try { + // console.info("Got event", event); + const encryptionType = (event.tags.find( + (t) => t[0] === "encryption", + )?.[1] || "nip04") as Nip47EncryptionType; + + const decryptedContent = await this.decrypt( + keypair, + event.content, + encryptionType, + ); + const request = JSON.parse(decryptedContent) as { + method: Nip47Method; + params: unknown; + }; + + let responsePromise: + | NWCWalletServiceResponsePromise + | undefined; + + switch (request.method) { + case "get_info": + responsePromise = handler.getInfo?.(); + break; + case "make_invoice": + responsePromise = handler.makeInvoice?.( + request.params as Nip47MakeInvoiceRequest, + ); + break; + } + + let response: NWCWalletServiceResponse | undefined = + await responsePromise; + + 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 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, + ), + }; + + 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.info("relay disconnected"); + endPromise?.(); + }; + this.relay.onclose = onRelayDisconnect; + }); + if (onRelayDisconnect !== undefined) { + this.relay.onclose = null; + } + } 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)); + } + } + })(); + + return () => { + subscribed = false; + endPromise?.(); + sub?.close(); + }; + } + + get connected() { + return this.relay.connected; + } + + signEvent(event: EventTemplate, secretKey: string): Promise { + return Promise.resolve(finalizeEvent(event, hexToBytes(secretKey))); + } + + close() { + return this.relay.close(); + } + + async encrypt( + keypair: NWCWalletServiceKeyPair, + content: string, + encryptionType: Nip47EncryptionType, + ) { + let encrypted; + // console.info("encrypting with" + encryptionType); + if (encryptionType === "nip04") { + encrypted = await nip04.encrypt( + keypair.walletSecret, + keypair.clientPubkey, + content, + ); + } else { + const key = nip44.getConversationKey( + hexToBytes(keypair.walletSecret), + keypair.clientPubkey, + ); + encrypted = nip44.encrypt(content, key); + } + return encrypted; + } + + async decrypt( + keypair: NWCWalletServiceKeyPair, + content: string, + encryptionType: Nip47EncryptionType, + ) { + let decrypted; + // console.info("decrypting with" + encryptionType); + if (encryptionType === "nip04") { + decrypted = await nip04.decrypt( + keypair.walletSecret, + keypair.clientPubkey, + content, + ); + } else { + const key = nip44.getConversationKey( + hexToBytes(keypair.walletSecret), + keypair.clientPubkey, + ); + decrypted = nip44.decrypt(content, key); + } + return decrypted; + } + + private async _checkConnected() { + if (!this.relayUrl) { + throw new Error("Missing relay url"); + } + try { + if (!this.relay.connected) { + await this.relay.connect(); + } + } catch (_ /* error is always undefined */) { + console.error("failed to connect to relay", this.relayUrl); + throw new Nip47NetworkError( + "Failed to connect to " + this.relayUrl, + "OTHER", + ); + } + } +} diff --git a/src/nwc/NWCWalletServiceRequestHandler.ts b/src/nwc/NWCWalletServiceRequestHandler.ts new file mode 100644 index 00000000..261df570 --- /dev/null +++ b/src/nwc/NWCWalletServiceRequestHandler.ts @@ -0,0 +1,28 @@ +import { + Nip47GetInfoResponse, + Nip47MakeInvoiceRequest, + Nip47Transaction, +} from "./NWCClient"; + +export type NWCWalletServiceRequestHandlerError = + | { + code: string; + message: string; + } + | undefined; + +export type NWCWalletServiceResponse = { + result: T | undefined; + error: NWCWalletServiceRequestHandlerError; +}; +export type NWCWalletServiceResponsePromise = Promise<{ + result: T | undefined; + error: NWCWalletServiceRequestHandlerError; +}>; + +export interface NWCWalletServiceRequestHandler { + getInfo?(): NWCWalletServiceResponsePromise; + makeInvoice?( + request: Nip47MakeInvoiceRequest, + ): NWCWalletServiceResponsePromise; +} diff --git a/src/nwc/index.ts b/src/nwc/index.ts new file mode 100644 index 00000000..e803f4d6 --- /dev/null +++ b/src/nwc/index.ts @@ -0,0 +1,2 @@ +export * from "./NWCClient"; +export * from "./NWCWalletService"; diff --git a/src/nwc/types.ts b/src/nwc/types.ts new file mode 100644 index 00000000..95bb3351 --- /dev/null +++ b/src/nwc/types.ts @@ -0,0 +1 @@ +export type Nip47EncryptionType = "nip04" | "nip44_v2"; diff --git a/src/types.ts b/src/types.ts index 280ce3db..24b8e3e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import { AlbyResponseError } from "./AlbyResponseError"; -import { Nip47Method, Nip47NotificationType } from "./NWCClient"; +import { Nip47Method, Nip47NotificationType } from "./nwc/NWCClient"; import { RequestOptions } from "./request"; export type SuccessStatus = 200 | 201; diff --git a/src/webln/NostrWeblnProvider.ts b/src/webln/NostrWeblnProvider.ts index df7adefa..c57fd561 100644 --- a/src/webln/NostrWeblnProvider.ts +++ b/src/webln/NostrWeblnProvider.ts @@ -21,7 +21,7 @@ import { Nip47Method, Nip47PayKeysendRequest, Nip47Transaction, -} from "../NWCClient"; +} from "../nwc/NWCClient"; import { toHexString } from "../utils"; import { NWCAuthorizationUrlOptions } from "../types"; From a9328cd0576e39b26845d6c49b49682b47b61b81 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Sat, 29 Mar 2025 21:25:17 +0700 Subject: [PATCH 2/7] chore: add remaining single methods to NWCWalletService class, move shared types out of NWCClient --- src/nwc/NWAClient.test.ts | 2 +- src/nwc/NWAClient.ts | 4 +- src/nwc/NWCClient.ts | 253 ++++------------------ src/nwc/NWCWalletService.ts | 42 +++- src/nwc/NWCWalletServiceRequestHandler.ts | 29 ++- src/nwc/types.ts | 215 ++++++++++++++++++ src/types.ts | 2 +- src/webln/NostrWeblnProvider.ts | 6 +- 8 files changed, 323 insertions(+), 230 deletions(-) diff --git a/src/nwc/NWAClient.test.ts b/src/nwc/NWAClient.test.ts index 9ac3a80f..f2ffd8a4 100644 --- a/src/nwc/NWAClient.test.ts +++ b/src/nwc/NWAClient.test.ts @@ -2,7 +2,7 @@ import "websocket-polyfill"; import { NWAClient } from "./NWAClient"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import { generateSecretKey, getPublicKey } from "nostr-tools"; -import { Nip47Method, Nip47NotificationType } from "./NWCClient"; +import { Nip47Method, Nip47NotificationType } from "./types"; describe("NWA URI", () => { test("constructs correct connection URI with custom app secret key", () => { diff --git a/src/nwc/NWAClient.ts b/src/nwc/NWAClient.ts index 5e46e32d..94e3e280 100644 --- a/src/nwc/NWAClient.ts +++ b/src/nwc/NWAClient.ts @@ -5,8 +5,8 @@ import { Nip47Method, Nip47NetworkError, Nip47NotificationType, - NWCClient, -} from "./NWCClient"; +} from "./types"; +import { NWCClient } from "./NWCClient"; import { Subscription } from "nostr-tools/lib/types/abstract-relay"; export type NWAOptions = { diff --git a/src/nwc/NWCClient.ts b/src/nwc/NWCClient.ts index fb7cfc6e..12d38278 100644 --- a/src/nwc/NWCClient.ts +++ b/src/nwc/NWCClient.ts @@ -13,198 +13,44 @@ import { import { NWCAuthorizationUrlOptions } from "../types"; import { hexToBytes, bytesToHex } from "@noble/hashes/utils"; import { Subscription } from "nostr-tools/lib/types/abstract-relay"; -import { Nip47EncryptionType } from "./types"; - -type WithDTag = { - dTag: string; -}; - -type WithOptionalId = { - id?: string; -}; - -type Nip47SingleMethod = - | "get_info" - | "get_balance" - | "get_budget" - | "make_invoice" - | "pay_invoice" - | "pay_keysend" - | "lookup_invoice" - | "list_transactions" - | "sign_message" - | "create_connection"; - -type Nip47MultiMethod = "multi_pay_invoice" | "multi_pay_keysend"; - -export type Nip47Method = Nip47SingleMethod | Nip47MultiMethod; -export type Nip47Capability = Nip47Method | "notifications"; -export type BudgetRenewalPeriod = - | "daily" - | "weekly" - | "monthly" - | "yearly" - | "never"; - -export type Nip47GetInfoResponse = { - alias: string; - color: string; - pubkey: string; - network: string; - block_height: number; - block_hash: string; - methods: Nip47Method[]; - notifications?: Nip47NotificationType[]; - metadata?: unknown; - lud16?: string; -}; - -export type Nip47GetBudgetResponse = - | { - used_budget: number; // msats - total_budget: number; // msats - renews_at?: number; // timestamp - renewal_period: BudgetRenewalPeriod; - } - // eslint-disable-next-line @typescript-eslint/ban-types - | {}; - -export type Nip47GetBalanceResponse = { - balance: number; // msats -}; - -export type Nip47PayResponse = { - preimage: string; - fees_paid: number; -}; - -type Nip47TimeoutValues = { - replyTimeout?: number; - publishTimeout?: number; -}; - -export type Nip47MultiPayInvoiceRequest = { - invoices: (Nip47PayInvoiceRequest & WithOptionalId)[]; -}; - -export type Nip47MultiPayKeysendRequest = { - keysends: (Nip47PayKeysendRequest & WithOptionalId)[]; -}; - -export type Nip47MultiPayInvoiceResponse = { - invoices: ({ invoice: Nip47PayInvoiceRequest } & Nip47PayResponse & - WithDTag)[]; - errors: []; // TODO: add error handling -}; -export type Nip47MultiPayKeysendResponse = { - keysends: ({ keysend: Nip47PayKeysendRequest } & Nip47PayResponse & - WithDTag)[]; - errors: []; // TODO: add error handling -}; - -export interface Nip47ListTransactionsRequest { - from?: number; - until?: number; - limit?: number; - offset?: number; - unpaid?: boolean; - /** - * NOTE: non-NIP-47 spec compliant - */ - unpaid_outgoing?: boolean; - /** - * NOTE: non-NIP-47 spec compliant - */ - unpaid_incoming?: boolean; - type?: "incoming" | "outgoing"; -} - -export type Nip47ListTransactionsResponse = { - transactions: Nip47Transaction[]; - total_count: number; -}; - -export type Nip47Transaction = { - type: string; - /** - * NOTE: non-NIP-47 spec compliant - */ - state: "settled" | "pending" | "failed"; - invoice: string; - description: string; - description_hash: string; - preimage: string; - payment_hash: string; - amount: number; - fees_paid: number; - settled_at: number; - created_at: number; - expires_at: number; - metadata?: Record; -}; - -export type Nip47NotificationType = Nip47Notification["notification_type"]; - -export type Nip47Notification = - | { - notification_type: "payment_received"; - notification: Nip47Transaction; - } - | { - notification_type: "payment_sent"; - notification: Nip47Transaction; - }; - -export type Nip47PayInvoiceRequest = { - invoice: string; - metadata?: unknown; - amount?: number; // msats -}; - -export type Nip47PayKeysendRequest = { - amount: number; //msat - pubkey: string; - preimage?: string; - tlv_records?: { type: number; value: string }[]; -}; - -export type Nip47MakeInvoiceRequest = { - amount: number; //msat - description?: string; - description_hash?: string; - expiry?: number; // in seconds - metadata?: unknown; // TODO: update to also include known keys (payerData, nostr, comment) -}; - -export type Nip47LookupInvoiceRequest = { - payment_hash?: string; - invoice?: string; -}; - -export type Nip47SignMessageRequest = { - message: string; -}; - -export type Nip47CreateConnectionRequest = { - pubkey: string; - name: string; - request_methods: Nip47Method[]; - notification_types?: Nip47NotificationType[]; - max_amount?: number; - budget_renewal?: BudgetRenewalPeriod; - expires_at?: number; - isolated?: boolean; - metadata?: unknown; -}; - -export type Nip47CreateConnectionResponse = { - wallet_pubkey: string; -}; - -export type Nip47SignMessageResponse = { - message: string; - signature: string; -}; +import { + Nip47EncryptionType, + Nip47NetworkError, + Nip47SingleMethod, + Nip47Method, + Nip47Capability, + Nip47GetInfoResponse, + Nip47GetBudgetResponse, + Nip47GetBalanceResponse, + Nip47PayResponse, + Nip47TimeoutValues, + Nip47MultiPayInvoiceRequest, + Nip47MultiPayKeysendRequest, + Nip47MultiPayInvoiceResponse, + Nip47MultiPayKeysendResponse, + Nip47ListTransactionsRequest, + Nip47ListTransactionsResponse, + Nip47Transaction, + Nip47NotificationType, + Nip47Notification, + Nip47PayInvoiceRequest, + Nip47PayKeysendRequest, + Nip47MakeInvoiceRequest, + Nip47LookupInvoiceRequest, + Nip47SignMessageRequest, + Nip47CreateConnectionRequest, + Nip47CreateConnectionResponse, + Nip47SignMessageResponse, + Nip47PublishError, + Nip47PublishTimeoutError, + Nip47ReplyTimeoutError, + Nip47ResponseDecodingError, + Nip47ResponseValidationError, + Nip47UnexpectedResponseError, + Nip47UnsupportedEncryptionError, + Nip47WalletError, + Nip47MultiMethod, +} from "./types"; export interface NWCOptions { relayUrl: string; @@ -213,29 +59,6 @@ export interface NWCOptions { lud16?: string; } -export class Nip47Error extends Error { - code: string; - constructor(message: string, code: string) { - super(message); - this.code = code; - } -} - -/** - * A NIP-47 response was received, but with an error code (see https://github.com/nostr-protocol/nips/blob/master/47.md#error-codes) - */ -export class Nip47WalletError extends Nip47Error {} - -export class Nip47TimeoutError extends Nip47Error {} -export class Nip47PublishTimeoutError extends Nip47TimeoutError {} -export class Nip47ReplyTimeoutError extends Nip47TimeoutError {} -export class Nip47PublishError extends Nip47Error {} -export class Nip47ResponseDecodingError extends Nip47Error {} -export class Nip47ResponseValidationError extends Nip47Error {} -export class Nip47UnexpectedResponseError extends Nip47Error {} -export class Nip47NetworkError extends Nip47Error {} -export class Nip47UnsupportedEncryptionError extends Nip47Error {} - export type NewNWCClientOptions = { relayUrl?: string; secret?: string; diff --git a/src/nwc/NWCWalletService.ts b/src/nwc/NWCWalletService.ts index 996ab5df..87946273 100644 --- a/src/nwc/NWCWalletService.ts +++ b/src/nwc/NWCWalletService.ts @@ -10,19 +10,24 @@ import { import { hexToBytes } from "@noble/hashes/utils"; import { Subscription } from "nostr-tools/lib/types/abstract-relay"; -// TODO: move these imports to nwc/types import { Nip47MakeInvoiceRequest, Nip47Method, Nip47NetworkError, Nip47NotificationType, -} from "./NWCClient"; + Nip47PayInvoiceRequest, + Nip47PayKeysendRequest, + Nip47LookupInvoiceRequest, + Nip47ListTransactionsRequest, + Nip47SignMessageRequest, + Nip47SingleMethod, + Nip47EncryptionType, +} from "./types"; import { NWCWalletServiceRequestHandler, NWCWalletServiceResponse, NWCWalletServiceResponsePromise, } from "./NWCWalletServiceRequestHandler"; -import { Nip47EncryptionType } from "./types"; type NewNWCWalletServiceOptions = { relayUrl: string; @@ -63,7 +68,7 @@ export class NWCWalletService { async publishWalletServiceInfoEvent( walletSecret: string, - supportedMethods: Nip47Method[], + supportedMethods: Nip47SingleMethod[], supportedNotifications: Nip47NotificationType[], ) { try { @@ -140,6 +145,35 @@ export class NWCWalletService { 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 } let response: NWCWalletServiceResponse | undefined = diff --git a/src/nwc/NWCWalletServiceRequestHandler.ts b/src/nwc/NWCWalletServiceRequestHandler.ts index 261df570..a21c9869 100644 --- a/src/nwc/NWCWalletServiceRequestHandler.ts +++ b/src/nwc/NWCWalletServiceRequestHandler.ts @@ -1,9 +1,16 @@ -import { +import type { + Nip47GetBalanceResponse, Nip47GetInfoResponse, + Nip47ListTransactionsRequest, + Nip47ListTransactionsResponse, + Nip47LookupInvoiceRequest, Nip47MakeInvoiceRequest, + Nip47PayInvoiceRequest, + Nip47PayKeysendRequest, + Nip47SignMessageRequest, + Nip47SignMessageResponse, Nip47Transaction, -} from "./NWCClient"; - +} from "./types"; export type NWCWalletServiceRequestHandlerError = | { code: string; @@ -25,4 +32,20 @@ export interface NWCWalletServiceRequestHandler { makeInvoice?( request: Nip47MakeInvoiceRequest, ): NWCWalletServiceResponsePromise; + payInvoice?( + request: Nip47PayInvoiceRequest, + ): NWCWalletServiceResponsePromise; + payKeysend?( + request: Nip47PayKeysendRequest, + ): NWCWalletServiceResponsePromise; + getBalance?(): NWCWalletServiceResponsePromise; + lookupInvoice?( + request: Nip47LookupInvoiceRequest, + ): NWCWalletServiceResponsePromise; + listTransactions?( + request: Nip47ListTransactionsRequest, + ): NWCWalletServiceResponsePromise; + signMessage?( + request: Nip47SignMessageRequest, + ): NWCWalletServiceResponsePromise; } diff --git a/src/nwc/types.ts b/src/nwc/types.ts index 95bb3351..7fdbff0b 100644 --- a/src/nwc/types.ts +++ b/src/nwc/types.ts @@ -1 +1,216 @@ export type Nip47EncryptionType = "nip04" | "nip44_v2"; + +export class Nip47Error extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.code = code; + } +} + +export class Nip47NetworkError extends Nip47Error {} + +/** + * A NIP-47 response was received, but with an error code (see https://github.com/nostr-protocol/nips/blob/master/47.md#error-codes) + */ +export class Nip47WalletError extends Nip47Error {} + +export class Nip47TimeoutError extends Nip47Error {} +export class Nip47PublishTimeoutError extends Nip47TimeoutError {} +export class Nip47ReplyTimeoutError extends Nip47TimeoutError {} +export class Nip47PublishError extends Nip47Error {} +export class Nip47ResponseDecodingError extends Nip47Error {} +export class Nip47ResponseValidationError extends Nip47Error {} +export class Nip47UnexpectedResponseError extends Nip47Error {} +export class Nip47UnsupportedEncryptionError extends Nip47Error {} + +type WithDTag = { + dTag: string; +}; + +type WithOptionalId = { + id?: string; +}; + +export type Nip47SingleMethod = + | "get_info" + | "get_balance" + | "get_budget" + | "make_invoice" + | "pay_invoice" + | "pay_keysend" + | "lookup_invoice" + | "list_transactions" + | "sign_message" + | "create_connection"; + +export type Nip47MultiMethod = "multi_pay_invoice" | "multi_pay_keysend"; + +export type Nip47Method = Nip47SingleMethod | Nip47MultiMethod; +export type Nip47Capability = Nip47Method | "notifications"; +export type BudgetRenewalPeriod = + | "daily" + | "weekly" + | "monthly" + | "yearly" + | "never"; + +export type Nip47GetInfoResponse = { + alias: string; + color: string; + pubkey: string; + network: string; + block_height: number; + block_hash: string; + methods: Nip47Method[]; + notifications?: Nip47NotificationType[]; + metadata?: unknown; + lud16?: string; +}; + +export type Nip47GetBudgetResponse = + | { + used_budget: number; // msats + total_budget: number; // msats + renews_at?: number; // timestamp + renewal_period: BudgetRenewalPeriod; + } + // eslint-disable-next-line @typescript-eslint/ban-types + | {}; + +export type Nip47GetBalanceResponse = { + balance: number; // msats +}; + +export type Nip47PayResponse = { + preimage: string; + fees_paid: number; +}; + +export type Nip47MultiPayInvoiceRequest = { + invoices: (Nip47PayInvoiceRequest & WithOptionalId)[]; +}; + +export type Nip47MultiPayKeysendRequest = { + keysends: (Nip47PayKeysendRequest & WithOptionalId)[]; +}; + +export type Nip47MultiPayInvoiceResponse = { + invoices: ({ invoice: Nip47PayInvoiceRequest } & Nip47PayResponse & + WithDTag)[]; + errors: []; // TODO: add error handling +}; +export type Nip47MultiPayKeysendResponse = { + keysends: ({ keysend: Nip47PayKeysendRequest } & Nip47PayResponse & + WithDTag)[]; + errors: []; // TODO: add error handling +}; + +export interface Nip47ListTransactionsRequest { + from?: number; + until?: number; + limit?: number; + offset?: number; + unpaid?: boolean; + /** + * NOTE: non-NIP-47 spec compliant + */ + unpaid_outgoing?: boolean; + /** + * NOTE: non-NIP-47 spec compliant + */ + unpaid_incoming?: boolean; + type?: "incoming" | "outgoing"; +} + +export type Nip47ListTransactionsResponse = { + transactions: Nip47Transaction[]; + total_count: number; +}; + +export type Nip47Transaction = { + type: string; + /** + * NOTE: non-NIP-47 spec compliant + */ + state: "settled" | "pending" | "failed"; + invoice: string; + description: string; + description_hash: string; + preimage: string; + payment_hash: string; + amount: number; + fees_paid: number; + settled_at: number; + created_at: number; + expires_at: number; + metadata?: Record; +}; + +export type Nip47NotificationType = Nip47Notification["notification_type"]; + +export type Nip47Notification = + | { + notification_type: "payment_received"; + notification: Nip47Transaction; + } + | { + notification_type: "payment_sent"; + notification: Nip47Transaction; + }; + +export type Nip47PayInvoiceRequest = { + invoice: string; + metadata?: unknown; + amount?: number; // msats +}; + +export type Nip47PayKeysendRequest = { + amount: number; //msat + pubkey: string; + preimage?: string; + tlv_records?: { type: number; value: string }[]; +}; + +export type Nip47MakeInvoiceRequest = { + amount: number; //msat + description?: string; + description_hash?: string; + expiry?: number; // in seconds + metadata?: unknown; // TODO: update to also include known keys (payerData, nostr, comment) +}; + +export type Nip47LookupInvoiceRequest = { + payment_hash?: string; + invoice?: string; +}; + +export type Nip47SignMessageRequest = { + message: string; +}; + +export type Nip47CreateConnectionRequest = { + pubkey: string; + name: string; + request_methods: Nip47Method[]; + notification_types?: Nip47NotificationType[]; + max_amount?: number; + budget_renewal?: BudgetRenewalPeriod; + expires_at?: number; + isolated?: boolean; + metadata?: unknown; +}; + +export type Nip47CreateConnectionResponse = { + wallet_pubkey: string; +}; + +export type Nip47SignMessageResponse = { + message: string; + signature: string; +}; + +export type Nip47TimeoutValues = { + replyTimeout?: number; + publishTimeout?: number; +}; diff --git a/src/types.ts b/src/types.ts index 24b8e3e4..b18290ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import { AlbyResponseError } from "./AlbyResponseError"; -import { Nip47Method, Nip47NotificationType } from "./nwc/NWCClient"; +import { Nip47Method, Nip47NotificationType } from "./nwc/types"; import { RequestOptions } from "./request"; export type SuccessStatus = 200 | 201; diff --git a/src/webln/NostrWeblnProvider.ts b/src/webln/NostrWeblnProvider.ts index c57fd561..f6089608 100644 --- a/src/webln/NostrWeblnProvider.ts +++ b/src/webln/NostrWeblnProvider.ts @@ -14,14 +14,12 @@ import { MakeInvoiceResponse, } from "@webbtc/webln-types"; import { GetInfoResponse } from "@webbtc/webln-types"; +import { NWCClient, NWCOptions, NewNWCClientOptions } from "../nwc/NWCClient"; import { - NWCClient, - NWCOptions, - NewNWCClientOptions, Nip47Method, Nip47PayKeysendRequest, Nip47Transaction, -} from "../nwc/NWCClient"; +} from "../nwc/types"; import { toHexString } from "../utils"; import { NWCAuthorizationUrlOptions } from "../types"; From 6b5dbbfec4b4454ea48602eeac0f5de1bc1fa10b Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Sat, 29 Mar 2025 22:46:01 +0700 Subject: [PATCH 3/7] fix: exports --- src/nwc/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/nwc/index.ts b/src/nwc/index.ts index e803f4d6..959daf22 100644 --- a/src/nwc/index.ts +++ b/src/nwc/index.ts @@ -1,2 +1,4 @@ +export * from "./types"; export * from "./NWCClient"; export * from "./NWCWalletService"; +export * from "./NWCWalletServiceRequestHandler"; From c97019be899b7700769204fea30a844f9d1e6c64 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Sun, 30 Mar 2025 22:47:08 +0700 Subject: [PATCH 4/7] fix: payInvoice response type --- src/nwc/NWCWalletServiceRequestHandler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/nwc/NWCWalletServiceRequestHandler.ts b/src/nwc/NWCWalletServiceRequestHandler.ts index a21c9869..928cafd8 100644 --- a/src/nwc/NWCWalletServiceRequestHandler.ts +++ b/src/nwc/NWCWalletServiceRequestHandler.ts @@ -7,6 +7,7 @@ import type { Nip47MakeInvoiceRequest, Nip47PayInvoiceRequest, Nip47PayKeysendRequest, + Nip47PayResponse, Nip47SignMessageRequest, Nip47SignMessageResponse, Nip47Transaction, @@ -34,7 +35,7 @@ export interface NWCWalletServiceRequestHandler { ): NWCWalletServiceResponsePromise; payInvoice?( request: Nip47PayInvoiceRequest, - ): NWCWalletServiceResponsePromise; + ): NWCWalletServiceResponsePromise; payKeysend?( request: Nip47PayKeysendRequest, ): NWCWalletServiceResponsePromise; From a1905f98587cc84f98ca4a1b6f1301ad488dfbc4 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Mon, 31 Mar 2025 15:03:28 +0700 Subject: [PATCH 5/7] chore: add subscription connection / disconnection logging to nwc wallet service --- src/nwc/NWCWalletService.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/nwc/NWCWalletService.ts b/src/nwc/NWCWalletService.ts index 87946273..dce654ef 100644 --- a/src/nwc/NWCWalletService.ts +++ b/src/nwc/NWCWalletService.ts @@ -102,7 +102,9 @@ export class NWCWalletService { (async () => { while (subscribed) { try { + console.info("checking connection to relay"); await this._checkConnected(); + console.info("subscribing to relay"); sub = this.relay.subscribe( [ { @@ -113,7 +115,7 @@ export class NWCWalletService { ], {}, ); - // console.info("subscribed to relay"); + console.info("subscribed to relay"); sub.onevent = async (event) => { try { @@ -221,7 +223,7 @@ export class NWCWalletService { resolve(); }; onRelayDisconnect = () => { - // console.info("relay disconnected"); + console.error("relay disconnected"); endPromise?.(); }; this.relay.onclose = onRelayDisconnect; From a58b36a84353fe7342649cfcddff1646a90a1acf Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Mon, 31 Mar 2025 16:02:36 +0700 Subject: [PATCH 6/7] fix: nip47 transaction type --- src/nwc/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nwc/types.ts b/src/nwc/types.ts index 7fdbff0b..53e33b57 100644 --- a/src/nwc/types.ts +++ b/src/nwc/types.ts @@ -129,7 +129,7 @@ export type Nip47ListTransactionsResponse = { }; export type Nip47Transaction = { - type: string; + type: "incoming" | "outgoing"; /** * NOTE: non-NIP-47 spec compliant */ From 7bb865a0baec07bac47c6c0f274b792f50c51ff8 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Tue, 1 Apr 2025 16:52:21 +0700 Subject: [PATCH 7/7] chore: allow entering amount in nwc make invoice example --- examples/nwc/client/make-invoice.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/nwc/client/make-invoice.js b/examples/nwc/client/make-invoice.js index f2312c32..43c99ee9 100644 --- a/examples/nwc/client/make-invoice.js +++ b/examples/nwc/client/make-invoice.js @@ -10,6 +10,11 @@ const rl = readline.createInterface({ input, output }); const nwcUrl = process.env.NWC_URL || (await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): ")); + +const amount = + parseInt((await rl.question("Amount in sats (default 1 sat): ")) || "1") * + 1000; + rl.close(); const client = new nwc.NWCClient({ @@ -17,7 +22,7 @@ const client = new nwc.NWCClient({ }); const response = await client.makeInvoice({ - amount: 1000, // in millisats + amount, // in millisats description: "NWC Client example", });