diff --git a/examples/nwc/client/get-balance.js b/examples/nwc/client/get-balance.js new file mode 100644 index 0000000..9bcbacc --- /dev/null +++ b/examples/nwc/client/get-balance.js @@ -0,0 +1,24 @@ +import * as crypto from "node:crypto"; // required in node.js +global.crypto = crypto; // required in node.js +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 { nwc } from "../../../dist/index.module.js"; + +const rl = readline.createInterface({ input, output }); + +const nwcUrl = + process.env.NWC_URL || + (await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): ")); +rl.close(); + +const client = new nwc.NWCClient({ + nostrWalletConnectUrl: nwcUrl, +}); +const response = await client.getBalance(); + +console.info(response); + +client.close(); diff --git a/examples/nwc/client/get-info.js b/examples/nwc/client/get-info.js new file mode 100644 index 0000000..f901e16 --- /dev/null +++ b/examples/nwc/client/get-info.js @@ -0,0 +1,24 @@ +import * as crypto from "node:crypto"; // required in node.js +global.crypto = crypto; // required in node.js +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 { nwc } from "../../../dist/index.module.js"; + +const rl = readline.createInterface({ input, output }); + +const nwcUrl = + process.env.NWC_URL || + (await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): ")); +rl.close(); + +const client = new nwc.NWCClient({ + nostrWalletConnectUrl: nwcUrl, +}); +const response = await client.getInfo(); + +console.info(response); + +client.close(); diff --git a/examples/nwc/client/list-transactions.js b/examples/nwc/client/list-transactions.js new file mode 100644 index 0000000..e173e16 --- /dev/null +++ b/examples/nwc/client/list-transactions.js @@ -0,0 +1,37 @@ +import * as crypto from "node:crypto"; // required in node.js +global.crypto = crypto; // required in node.js +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 { nwc } from "../../../dist/index.module.js"; + +const rl = readline.createInterface({ input, output }); + +const nwcUrl = + process.env.NWC_URL || + (await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): ")); +rl.close(); + +const client = new nwc.NWCClient({ + nostrWalletConnectUrl: nwcUrl, +}); + +const ONE_WEEK_IN_SECONDS = 60 * 60 * 24 * 7; +const response = await client.listTransactions({ + from: Math.floor(new Date().getTime() / 1000 - ONE_WEEK_IN_SECONDS), + until: Math.ceil(new Date().getTime() / 1000), + limit: 30, + // type: "incoming", + // unpaid: true, +}); + +console.info( + response.transactions.length + " transactions, ", + response.transactions.filter((t) => t.type === "incoming").length + + " incoming", + response, +); + +client.close(); diff --git a/examples/nwc/client/lookup-invoice.js b/examples/nwc/client/lookup-invoice.js new file mode 100644 index 0000000..5b5f9e5 --- /dev/null +++ b/examples/nwc/client/lookup-invoice.js @@ -0,0 +1,35 @@ +import * as crypto from "node:crypto"; // required in node.js +global.crypto = crypto; // required in node.js +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 { nwc } from "../../../dist/index.module.js"; + +const rl = readline.createInterface({ input, output }); + +const nwcUrl = + process.env.NWC_URL || + (await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): ")); + +const invoiceOrPaymentHash = await rl.question("Invoice or payment hash: "); +rl.close(); + +const client = new nwc.NWCClient({ + nostrWalletConnectUrl: nwcUrl, +}); + +const response = await client.lookupInvoice({ + // provide one of the below + invoice: invoiceOrPaymentHash.startsWith("ln") + ? invoiceOrPaymentHash + : undefined, + payment_hash: !invoiceOrPaymentHash.startsWith("ln") + ? invoiceOrPaymentHash + : undefined, +}); + +console.info(response); + +client.close(); diff --git a/examples/nwc/client/make-invoice.js b/examples/nwc/client/make-invoice.js new file mode 100644 index 0000000..923de1b --- /dev/null +++ b/examples/nwc/client/make-invoice.js @@ -0,0 +1,28 @@ +import * as crypto from "node:crypto"; // required in node.js +global.crypto = crypto; // required in node.js +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 { nwc } from "../../../dist/index.module.js"; + +const rl = readline.createInterface({ input, output }); + +const nwcUrl = + process.env.NWC_URL || + (await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): ")); +rl.close(); + +const client = new nwc.NWCClient({ + nostrWalletConnectUrl: nwcUrl, +}); + +const response = await client.makeInvoice({ + amount: 1000, // in millisats + description: "NWC Client example", +}); + +console.info(response); + +client.close(); diff --git a/examples/nwc/client/multi-pay-invoice.js b/examples/nwc/client/multi-pay-invoice.js new file mode 100644 index 0000000..c6e6e8a --- /dev/null +++ b/examples/nwc/client/multi-pay-invoice.js @@ -0,0 +1,52 @@ +import * as crypto from "node:crypto"; // required in node.js +global.crypto = crypto; // required in node.js +import "websocket-polyfill"; // required in node.js + +import { LightningAddress } from "@getalby/lightning-tools"; + +import * as readline from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; + +import { nwc } from "../../../dist/index.module.js"; + +const rl = readline.createInterface({ input, output }); + +const ln = new LightningAddress(process.env.LN_ADDRESS || "hello@getalby.com"); +// fetch the LNURL data +await ln.fetch(); + +// generate 2 invoices to pay +const invoices = ( + await Promise.all( + [1, 2].map((v) => + ln.requestInvoice({ + satoshi: 1, + comment: `Multi-pay invoice #${v}`, + }), + ), + ) +).map((invoice) => invoice.paymentRequest); + +console.info("Generated two invoices", invoices); + +const nwcUrl = + process.env.NWC_URL || + (await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): ")); +rl.close(); + +const client = new nwc.NWCClient({ + nostrWalletConnectUrl: nwcUrl, +}); + +try { + const response = await client.multiPayInvoice({ + invoices: invoices.map((invoice) => ({ + invoice, + })), + }); + console.info(response); +} catch (error) { + console.error("multi_pay_invoice failed", error); +} + +client.close(); diff --git a/examples/nwc/client/multi-pay-keysend.js b/examples/nwc/client/multi-pay-keysend.js new file mode 100644 index 0000000..75907de --- /dev/null +++ b/examples/nwc/client/multi-pay-keysend.js @@ -0,0 +1,61 @@ +import * as crypto from "node:crypto"; // required in node.js +global.crypto = crypto; // required in node.js +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 { nwc } from "../../../dist/index.module.js"; + +const rl = readline.createInterface({ input, output }); + +const nwcUrl = + process.env.NWC_URL || + (await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): ")); +rl.close(); + +const client = new nwc.NWCClient({ + nostrWalletConnectUrl: nwcUrl, +}); + +const keysends = [ + { + pubkey: + "030a58b8653d32b99200a2334cfe913e51dc7d155aa0116c176657a4f1722677a3", + amount: 1000, // millisats + tlv_records: [ + { + type: 696969, + value: "017rsl75kNnSke4mMHYE", // hello@getalby.com + }, + { + type: 34349334, + value: "first keysend message", + }, + ], + }, + { + pubkey: + "030a58b8653d32b99200a2334cfe913e51dc7d155aa0116c176657a4f1722677a3", + amount: 1000, // millisats + tlv_records: [ + { + type: 696969, + value: "1KOZHzhLs2U7JIx3BmEY", // another Alby account + }, + { + type: 34349334, + value: "second keysend message", + }, + ], + }, +]; + +try { + const response = await client.multiPayKeysend({ keysends }); + console.info(JSON.stringify(response)); +} catch (error) { + console.error("multi_pay_keysend failed", error); +} + +client.close(); diff --git a/examples/nwc/client/pay-invoice.js b/examples/nwc/client/pay-invoice.js new file mode 100644 index 0000000..147b0cd --- /dev/null +++ b/examples/nwc/client/pay-invoice.js @@ -0,0 +1,26 @@ +import * as crypto from "node:crypto"; // required in node.js +global.crypto = crypto; // required in node.js +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 { nwc } from "../../../dist/index.module.js"; + +const rl = readline.createInterface({ input, output }); + +const nwcUrl = + process.env.NWC_URL || + (await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): ")); +const invoice = await rl.question("Lightning invoice: "); +rl.close(); + +const client = new nwc.NWCClient({ + nostrWalletConnectUrl: nwcUrl, +}); + +const response = await client.payInvoice({ invoice }); + +console.info(response); + +client.close(); diff --git a/examples/nwc/client/pay-keysend.js b/examples/nwc/client/pay-keysend.js new file mode 100644 index 0000000..f2b52f2 --- /dev/null +++ b/examples/nwc/client/pay-keysend.js @@ -0,0 +1,38 @@ +import * as crypto from "node:crypto"; // required in node.js +global.crypto = crypto; // required in node.js +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 { nwc } from "../../../dist/index.module.js"; + +const rl = readline.createInterface({ input, output }); + +const nwcUrl = + process.env.NWC_URL || + (await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): ")); + +rl.close(); + +const client = new nwc.NWCClient({ + nostrWalletConnectUrl: nwcUrl, +}); +const response = await client.payKeysend({ + amount: 1000, // millisats + pubkey: "030a58b8653d32b99200a2334cfe913e51dc7d155aa0116c176657a4f1722677a3", + tlv_records: [ + { + type: 696969, + value: "017rsl75kNnSke4mMHYE", // hello@getalby.com + }, + { + type: 34349334, + value: "example keysend message", + }, + ], +}); + +console.info(response); + +client.close(); diff --git a/examples/nwc/keysend.js b/examples/nwc/keysend.js index 9de6213..9dfe6ef 100644 --- a/examples/nwc/keysend.js +++ b/examples/nwc/keysend.js @@ -12,10 +12,7 @@ const rl = readline.createInterface({ input, output }); const nwcUrl = process.env.NWC_URL || (await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): ")); -const destination = - (await rl.question("Enter destination pubkey: ")) || - "030a58b8653d32b99200a2334cfe913e51dc7d155aa0116c176657a4f1722677a3"; -const amount = await rl.question("Enter amount: "); + rl.close(); const webln = new providers.NostrWebLNProvider({ @@ -23,10 +20,12 @@ const webln = new providers.NostrWebLNProvider({ }); await webln.enable(); const response = await webln.keysend({ - amount, - destination, + amount: 1, + destination: + "030a58b8653d32b99200a2334cfe913e51dc7d155aa0116c176657a4f1722677a3", customRecords: { - 696969: "1KOZHzhLs2U7JIx3BmEY", + 696969: "017rsl75kNnSke4mMHYE", // hello@getalby.com + 34349334: "example keysend message", }, }); diff --git a/examples/nwc/multi-keysend.js b/examples/nwc/multi-keysend.js new file mode 100644 index 0000000..ab27a9e --- /dev/null +++ b/examples/nwc/multi-keysend.js @@ -0,0 +1,50 @@ +import * as crypto from "node:crypto"; // required in node.js +global.crypto = crypto; // required in node.js +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 { webln as providers } from "../../dist/index.module.js"; + +const rl = readline.createInterface({ input, output }); + +const nwcUrl = + process.env.NWC_URL || + (await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): ")); +rl.close(); + +const webln = new providers.NostrWebLNProvider({ + nostrWalletConnectUrl: nwcUrl, +}); +await webln.enable(); + +const keysends = [ + { + destination: + "030a58b8653d32b99200a2334cfe913e51dc7d155aa0116c176657a4f1722677a3", + amount: 1, + customRecords: { + 696969: "017rsl75kNnSke4mMHYE", // hello@getalby.com + 34349334: "First keysend", + }, + }, + { + destination: + "030a58b8653d32b99200a2334cfe913e51dc7d155aa0116c176657a4f1722677a3", + amount: 1, + customRecords: { + 696969: "1KOZHzhLs2U7JIx3BmEY", // another Alby account + 34349334: "second keysend", + }, + }, +]; + +try { + const response = await webln.multiKeysend(keysends); + console.info(JSON.stringify(response)); +} catch (error) { + console.error("multiKeysend failed", error); +} + +webln.close(); diff --git a/package.json b/package.json index 5426139..dad9575 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@getalby/sdk", - "version": "3.2.3", + "version": "3.3.0", "description": "The SDK to integrate with Nostr Wallet Connect and the Alby API", "repository": "https://github.com/getAlby/js-sdk.git", "bugs": "https://github.com/getAlby/js-sdk/issues", diff --git a/src/NWCClient.ts b/src/NWCClient.ts new file mode 100644 index 0000000..9d92afb --- /dev/null +++ b/src/NWCClient.ts @@ -0,0 +1,767 @@ +import { + nip04, + relayInit, + getEventHash, + nip19, + generatePrivateKey, + getPublicKey, + Relay, + Event, + UnsignedEvent, + finishEvent, +} from "nostr-tools"; +import { NWCAuthorizationUrlOptions } from "./types"; + +type WithDTag = { + dTag: string; +}; + +type WithOptionalId = { + id?: string; +}; + +type Nip47SingleMethod = + | "get_info" + | "get_balance" + | "make_invoice" + | "pay_invoice" + | "pay_keysend" + | "lookup_invoice" + | "list_transactions"; + +type Nip47MultiMethod = "multi_pay_invoice" | "multi_pay_keysend"; + +export type Nip47Method = Nip47SingleMethod | Nip47MultiMethod; + +export type Nip47GetInfoResponse = { + alias: string; + color: string; + pubkey: string; + network: string; + block_height: number; + block_hash: string; + methods: string[]; +}; + +export type Nip47GetBalanceResponse = { + balance: number; // msats +}; + +export type Nip47PayResponse = { + preimage: string; +}; + +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; + type?: "incoming" | "outgoing"; +} + +export type Nip47ListTransactionsResponse = { + transactions: Nip47Transaction[]; +}; + +export type Nip47Transaction = { + type: string; + 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 Nip47PayInvoiceRequest = { + invoice: string; + 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 +}; + +export type Nip47LookupInvoiceRequest = { + payment_hash?: string; + invoice?: string; +}; + +export interface NWCOptions { + authorizationUrl?: string; // the URL to the NWC interface for the user to confirm the session + relayUrl: string; + walletPubkey: string; + secret?: string; +} + +export const NWCs: Record = { + alby: { + authorizationUrl: "https://nwc.getalby.com/apps/new", + relayUrl: "wss://relay.getalby.com/v1", + walletPubkey: + "69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", + }, +}; + +export type NewNWCClientOptions = { + providerName?: string; + authorizationUrl?: string; + relayUrl?: string; + secret?: string; + walletPubkey?: string; + nostrWalletConnectUrl?: string; +}; + +export class NWCClient { + relay: Relay; + relayUrl: string; + secret: string | undefined; + walletPubkey: string; + options: NWCOptions; + + static parseWalletConnectUrl(walletConnectUrl: string): NWCOptions { + walletConnectUrl = walletConnectUrl + .replace("nostrwalletconnect://", "http://") + .replace("nostr+walletconnect://", "http://"); // makes it possible to parse with URL in the different environments (browser/node/...) + const url = new URL(walletConnectUrl); + const relayUrl = url.searchParams.get("relay"); + if (!relayUrl) { + throw new Error("No relay URL found in connection string"); + } + + const options: NWCOptions = { + walletPubkey: url.host, + relayUrl, + }; + const secret = url.searchParams.get("secret"); + if (secret) { + options.secret = secret; + } + return options; + } + + static withNewSecret(options?: ConstructorParameters[0]) { + options = options || {}; + options.secret = generatePrivateKey(); + return new NWCClient(options); + } + + constructor(options?: NewNWCClientOptions) { + if (options && options.nostrWalletConnectUrl) { + options = { + ...NWCClient.parseWalletConnectUrl(options.nostrWalletConnectUrl), + ...options, + }; + } + const providerOptions = NWCs[options?.providerName || "alby"] as NWCOptions; + this.options = { + ...providerOptions, + ...(options || {}), + } as NWCOptions; + + this.relayUrl = this.options.relayUrl; + this.relay = relayInit(this.relayUrl); + if (this.options.secret) { + this.secret = ( + this.options.secret.toLowerCase().startsWith("nsec") + ? nip19.decode(this.options.secret).data + : this.options.secret + ) as string; + } + this.walletPubkey = ( + this.options.walletPubkey.toLowerCase().startsWith("npub") + ? nip19.decode(this.options.walletPubkey).data + : this.options.walletPubkey + ) as string; + // this.subscribers = {}; + + if (globalThis.WebSocket === undefined) { + console.error( + "WebSocket is undefined. Make sure to `import websocket-polyfill` for nodejs environments", + ); + } + } + + get nostrWalletConnectUrl() { + return this.getNostrWalletConnectUrl(); + } + + getNostrWalletConnectUrl(includeSecret = true) { + let url = `nostr+walletconnect://${this.walletPubkey}?relay=${this.relayUrl}&pubkey=${this.publicKey}`; + if (includeSecret) { + url = `${url}&secret=${this.secret}`; + } + return url; + } + + get connected() { + return this.relay.status === 1; + } + + get publicKey() { + if (!this.secret) { + throw new Error("Missing secret key"); + } + return getPublicKey(this.secret); + } + + getPublicKey(): Promise { + return Promise.resolve(this.publicKey); + } + + signEvent(event: UnsignedEvent): Promise { + if (!this.secret) { + throw new Error("Missing secret key"); + } + + return Promise.resolve(finishEvent(event, this.secret)); + } + + getEventHash(event: Event) { + return getEventHash(event); + } + + close() { + return this.relay.close(); + } + + async encrypt(pubkey: string, content: string) { + if (!this.secret) { + throw new Error("Missing secret"); + } + const encrypted = await nip04.encrypt(this.secret, pubkey, content); + return encrypted; + } + + async decrypt(pubkey: string, content: string) { + if (!this.secret) { + throw new Error("Missing secret"); + } + const decrypted = await nip04.decrypt(this.secret, pubkey, content); + return decrypted; + } + + getAuthorizationUrl(options?: NWCAuthorizationUrlOptions): URL { + if (!this.options.authorizationUrl) { + throw new Error("Missing authorizationUrl option"); + } + const url = new URL(this.options.authorizationUrl); + if (options?.name) { + url.searchParams.set("name", options?.name); + } + url.searchParams.set("pubkey", this.publicKey); + if (options?.returnTo) { + url.searchParams.set("return_to", options.returnTo); + } + + if (options?.budgetRenewal) { + url.searchParams.set("budget_renewal", options.budgetRenewal); + } + if (options?.expiresAt) { + url.searchParams.set( + "expires_at", + Math.floor(options.expiresAt.getTime() / 1000).toString(), + ); + } + if (options?.maxAmount) { + url.searchParams.set("max_amount", options.maxAmount.toString()); + } + if (options?.editable !== undefined) { + url.searchParams.set("editable", options.editable.toString()); + } + + if (options?.requestMethods) { + url.searchParams.set("request_methods", options.requestMethods.join(" ")); + } + + return url; + } + + initNWC(options: NWCAuthorizationUrlOptions = {}) { + // here we assume an browser context and window/document is available + // we set the location.host as a default name if none is given + if (!options.name) { + options.name = document.location.host; + } + const url = this.getAuthorizationUrl(options); + const height = 600; + const width = 400; + const top = window.outerHeight / 2 + window.screenY - height / 2; + const left = window.outerWidth / 2 + window.screenX - width / 2; + + return new Promise((resolve, reject) => { + const popup = window.open( + url.toString(), + `${document.title} - Wallet Connect`, + `height=${height},width=${width},top=${top},left=${left}`, + ); + if (!popup) { + reject(); + return; + } // only for TS? + + const checkForPopup = () => { + if (popup && popup.closed) { + reject(); + clearInterval(popupChecker); + window.removeEventListener("message", onMessage); + } + }; + + const onMessage = (message: { + data?: { type: "nwc:success" | unknown }; + origin: string; + }) => { + const data = message.data; + if ( + data && + data.type === "nwc:success" && + message.origin === `${url.protocol}//${url.host}` + ) { + resolve(data); + clearInterval(popupChecker); + window.removeEventListener("message", onMessage); + if (popup) { + popup.close(); // close the popup + } + } + }; + const popupChecker = setInterval(checkForPopup, 500); + window.addEventListener("message", onMessage); + }); + } + + async getInfo(): Promise { + try { + const result = await this.executeNip47Request( + "get_info", + undefined, + (result) => !!result.methods, + ); + return result; + } catch (error) { + console.error("Failed to request get_info", error); + throw error; + } + } + + async getBalance(): Promise { + try { + const result = await this.executeNip47Request( + "get_balance", + undefined, + (result) => result.balance !== undefined, + ); + return result; + } catch (error) { + console.error("Failed to request get_balance", error); + throw error; + } + } + + async payInvoice(request: Nip47PayInvoiceRequest): Promise { + try { + const result = await this.executeNip47Request( + "pay_invoice", + request, + (result) => !!result.preimage, + ); + return result; + } catch (error) { + console.error("Failed to request pay_invoice", error); + throw error; + } + } + + async payKeysend(request: Nip47PayKeysendRequest): Promise { + try { + const result = await this.executeNip47Request( + "pay_keysend", + request, + (result) => !!result.preimage, + ); + + return result; + } catch (error) { + console.error("Failed to request pay_keysend", error); + throw error; + } + } + + async multiPayInvoice( + request: Nip47MultiPayInvoiceRequest, + ): Promise { + try { + const results = await this.executeMultiNip47Request< + { invoice: Nip47PayInvoiceRequest } & Nip47PayResponse + >( + "multi_pay_invoice", + request, + request.invoices.length, + (result) => !!result.preimage, + ); + + return { + invoices: results, + // TODO: error handling + errors: [], + }; + } catch (error) { + console.error("Failed to request multi_pay_keysend", error); + throw error; + } + } + + async multiPayKeysend( + request: Nip47MultiPayKeysendRequest, + ): Promise { + try { + const results = await this.executeMultiNip47Request< + { keysend: Nip47PayKeysendRequest } & Nip47PayResponse + >( + "multi_pay_keysend", + request, + request.keysends.length, + (result) => !!result.preimage, + ); + + return { + keysends: results, + // TODO: error handling + errors: [], + }; + } catch (error) { + console.error("Failed to request multi_pay_keysend", error); + throw error; + } + } + + async makeInvoice( + request: Nip47MakeInvoiceRequest, + ): Promise { + try { + if (!request.amount) { + throw new Error("No amount specified"); + } + + const result = await this.executeNip47Request( + "make_invoice", + request, + (result) => !!result.invoice, + ); + + return result; + } catch (error) { + console.error("Failed to request make_invoice", error); + throw error; + } + } + + async lookupInvoice( + request: Nip47LookupInvoiceRequest, + ): Promise { + try { + const result = await this.executeNip47Request( + "lookup_invoice", + request, + (result) => !!result.invoice, + ); + + return result; + } catch (error) { + console.error("Failed to request lookup_invoice", error); + throw error; + } + } + + async listTransactions( + request: Nip47ListTransactionsRequest, + ): Promise { + try { + // maybe we can tailor the response to our needs + const result = + await this.executeNip47Request( + "list_transactions", + request, + (response) => !!response.transactions, + ); + + return result; + } catch (error) { + console.error("Failed to request list_transactions", error); + throw error; + } + } + + private async executeNip47Request( + nip47Method: Nip47SingleMethod, + params: unknown, + resultValidator: (result: T) => boolean, + ): Promise { + await this._checkConnected(); + return new Promise((resolve, reject) => { + (async () => { + const command = { + method: nip47Method, + params, + }; + const encryptedCommand = await this.encrypt( + this.walletPubkey, + JSON.stringify(command), + ); + const unsignedEvent: UnsignedEvent = { + kind: 23194, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", this.walletPubkey]], + content: encryptedCommand, + pubkey: this.publicKey, + }; + + const event = await this.signEvent(unsignedEvent); + // subscribe to NIP_47_SUCCESS_RESPONSE_KIND and NIP_47_ERROR_RESPONSE_KIND + // that reference the request event (NIP_47_REQUEST_KIND) + const sub = this.relay.sub([ + { + kinds: [23195], + authors: [this.walletPubkey], + "#e": [event.id], + }, + ]); + + function replyTimeout() { + sub.unsub(); + //console.error(`Reply timeout: event ${event.id} `); + reject({ + error: `reply timeout: event ${event.id}`, + code: "INTERNAL", + }); + } + + const replyTimeoutCheck = setTimeout(replyTimeout, 60000); + + sub.on("event", async (event) => { + // console.log(`Received reply event: `, event); + clearTimeout(replyTimeoutCheck); + sub.unsub(); + const decryptedContent = await this.decrypt( + this.walletPubkey, + event.content, + ); + // console.log(`Decrypted content: `, decryptedContent); + let response; + try { + response = JSON.parse(decryptedContent); + } catch (e) { + reject({ error: "invalid response", code: "INTERNAL" }); + return; + } + if (event.kind == 23195 && response.result) { + // console.info("NIP-47 result", response.result); + if (resultValidator(response.result)) { + resolve(response.result); + } else { + reject({ + error: + "Response from NWC failed validation: " + + JSON.stringify(response.result), + code: "INTERNAL", + }); + } + } else { + reject({ + error: response.error?.message, + code: response.error?.code, + }); + } + }); + + function publishTimeout() { + //console.error(`Publish timeout: event ${event.id}`); + reject({ error: `Publish timeout: event ${event.id}` }); + } + const publishTimeoutCheck = setTimeout(publishTimeout, 5000); + + try { + await this.relay.publish(event); + clearTimeout(publishTimeoutCheck); + //console.debug(`Event ${event.id} for ${invoice} published`); + } catch (error) { + //console.error(`Failed to publish to ${this.relay.url}`, error); + clearTimeout(publishTimeoutCheck); + reject({ error: `Failed to publish request: ${error}` }); + } + })(); + }); + } + + // TODO: this method currently fails if any payment fails. + // this could be improved in the future. + // TODO: reduce duplication between executeNip47Request and executeMultiNip47Request + private async executeMultiNip47Request( + nip47Method: Nip47MultiMethod, + params: unknown, + numPayments: number, + resultValidator: (result: T) => boolean, + ): Promise<(T & { dTag: string })[]> { + await this._checkConnected(); + const results: (T & { dTag: string })[] = []; + return new Promise<(T & { dTag: string })[]>((resolve, reject) => { + (async () => { + const command = { + method: nip47Method, + params, + }; + const encryptedCommand = await this.encrypt( + this.walletPubkey, + JSON.stringify(command), + ); + const unsignedEvent: UnsignedEvent = { + kind: 23194, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", this.walletPubkey]], + content: encryptedCommand, + pubkey: this.publicKey, + }; + + const event = await this.signEvent(unsignedEvent); + // subscribe to NIP_47_SUCCESS_RESPONSE_KIND and NIP_47_ERROR_RESPONSE_KIND + // that reference the request event (NIP_47_REQUEST_KIND) + const sub = this.relay.sub([ + { + kinds: [23195], + authors: [this.walletPubkey], + "#e": [event.id], + }, + ]); + + function replyTimeout() { + sub.unsub(); + //console.error(`Reply timeout: event ${event.id} `); + reject({ + error: `reply timeout: event ${event.id}`, + code: "INTERNAL", + }); + } + + const replyTimeoutCheck = setTimeout(replyTimeout, 60000); + + sub.on("event", async (event) => { + // console.log(`Received reply event: `, event); + + const decryptedContent = await this.decrypt( + this.walletPubkey, + event.content, + ); + // console.log(`Decrypted content: `, decryptedContent); + let response; + try { + response = JSON.parse(decryptedContent); + } catch (e) { + console.error(e); + clearTimeout(replyTimeoutCheck); + sub.unsub(); + reject({ error: "invalid response", code: "INTERNAL" }); + return; + } + if (event.kind == 23195 && response.result) { + // console.info("NIP-47 result", response.result); + try { + if (!resultValidator(response.result)) { + throw new Error( + "Response from NWC failed validation: " + + JSON.stringify(response.result), + ); + } + const dTag = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (dTag === undefined) { + throw new Error("No d tag found in response event"); + } + results.push({ + ...response.result, + dTag, + }); + if (results.length === numPayments) { + clearTimeout(replyTimeoutCheck); + sub.unsub(); + //console.log("Received results", results); + resolve(results); + } + } catch (error) { + console.error(error); + clearTimeout(replyTimeoutCheck); + sub.unsub(); + reject({ + error: (error as Error).message, + code: "INTERNAL", + }); + } + } else { + clearTimeout(replyTimeoutCheck); + sub.unsub(); + reject({ + error: response.error?.message, + code: response.error?.code, + }); + } + }); + + function publishTimeout() { + //console.error(`Publish timeout: event ${event.id}`); + reject({ error: `Publish timeout: event ${event.id}` }); + } + const publishTimeoutCheck = setTimeout(publishTimeout, 5000); + + try { + await this.relay.publish(event); + clearTimeout(publishTimeoutCheck); + //console.debug(`Event ${event.id} for ${invoice} published`); + } catch (error) { + //console.error(`Failed to publish to ${this.relay.url}`, error); + clearTimeout(publishTimeoutCheck); + reject({ error: `Failed to publish request: ${error}` }); + } + })(); + }); + } + private async _checkConnected() { + if (!this.secret) { + throw new Error("Missing secret key"); + } + await this.relay.connect(); + } +} diff --git a/src/client.ts b/src/client.ts index c58c4f8..ead7729 100644 --- a/src/client.ts +++ b/src/client.ts @@ -240,7 +240,7 @@ export class Client { } /** - * @deprecated please use sendBoostagramToAlbyAccount. Deprecated since v2.7.0. Will be removed in v3.0.0. + * @deprecated please use sendBoostagramToAlbyAccount. Deprecated since v2.7.0. Will be removed in v4.0.0. */ sendToAlbyAccount( args: SendBoostagramToAlbyRequestParams, diff --git a/src/index.ts b/src/index.ts index f8e7ed0..234fcc8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,4 @@ export * as auth from "./auth"; export * as types from "./types"; export * as webln from "./webln"; export { Client } from "./client"; +export * as nwc from "./NWCClient"; diff --git a/src/types.ts b/src/types.ts index ea32871..dff06d1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -133,7 +133,7 @@ export type SendBoostagramToAlbyRequestParams = { }; /** - * @deprecated please use SendBoostagramToAlbyRequestParams + * @deprecated please use SendBoostagramToAlbyRequestParams. Deprecated since v3.2.3. Will be removed in v4.0.0. */ export type SendToAlbyRequestParams = SendBoostagramToAlbyRequestParams; @@ -230,7 +230,7 @@ export type Invoice = { } & Record; /** - * @deprecated please use NWCAuthorizationUrlOptions + * @deprecated please use NWCAuthorizationUrlOptions. Deprecated since v3.2.3. Will be removed in v4.0.0. */ export type GetNWCAuthorizationUrlOptions = NWCAuthorizationUrlOptions; diff --git a/src/webln/NostrWeblnProvider.ts b/src/webln/NostrWeblnProvider.ts index c3fe7d9..afe4dd3 100644 --- a/src/webln/NostrWeblnProvider.ts +++ b/src/webln/NostrWeblnProvider.ts @@ -1,20 +1,8 @@ -import { - nip04, - relayInit, - getEventHash, - nip19, - generatePrivateKey, - getPublicKey, - Relay, - Event, - UnsignedEvent, - finishEvent, -} from "nostr-tools"; +import { generatePrivateKey, Relay, Event, UnsignedEvent } from "nostr-tools"; import { GetBalanceResponse, KeysendArgs, RequestInvoiceArgs, - MakeInvoiceResponse, SendPaymentResponse, SignMessageResponse, WebLNNode, @@ -22,20 +10,22 @@ import { WebLNRequestMethod, LookupInvoiceArgs, LookupInvoiceResponse, + WebLNMethod, + MakeInvoiceResponse, } from "@webbtc/webln-types"; import { GetInfoResponse } from "@webbtc/webln-types"; import { NWCAuthorizationUrlOptions } from "../types"; - -const NWCs: Record = { - alby: { - authorizationUrl: "https://nwc.getalby.com/apps/new", - relayUrl: "wss://relay.getalby.com/v1", - walletPubkey: - "69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", - }, -}; - -// TODO: review fields (replace with camelCase) and consider move to webln-types package +import { + NWCClient, + NWCOptions, + NewNWCClientOptions, + Nip47Method, + Nip47PayKeysendRequest, + Nip47Transaction, +} from "../NWCClient"; + +// TODO: review fields (replace with camelCase) +// TODO: consider move to webln-types package export type Transaction = Nip47Transaction; // TODO: consider moving to webln-types package @@ -44,69 +34,35 @@ export type ListTransactionsResponse = { }; // TODO: consider moving to webln-types package -export type ListTransactionsArgs = Nip47ListTransactionsArgs; - -// TODO: consider moving to webln-types package -export type SendMultiPaymentResponse = { - payments: ({ paymentRequest: string } & SendPaymentResponse)[]; - errors: { paymentRequest: string; message: string }[]; -}; - -interface Nip47ListTransactionsArgs { +export type ListTransactionsArgs = { from?: number; until?: number; limit?: number; offset?: number; unpaid?: boolean; type?: "incoming" | "outgoing"; -} +}; -type Nip47ListTransactionsResponse = { - transactions: Nip47Transaction[]; +// TODO: consider moving to webln-types package +export type SendMultiPaymentResponse = { + payments: ({ paymentRequest: string } & SendPaymentResponse)[]; + errors: { paymentRequest: string; message: string }[]; }; -type Nip47Transaction = { - type: string; - 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; +// TODO: consider moving to webln-types package +export type MultiKeysendResponse = { + keysends: ({ keysend: KeysendArgs } & SendPaymentResponse)[]; + errors: { keysend: KeysendArgs; message: string }[]; }; -interface NostrWebLNOptions { - authorizationUrl?: string; // the URL to the NWC interface for the user to confirm the session - relayUrl: string; - walletPubkey: string; - secret?: string; -} +type NostrWebLNOptions = NWCOptions; type Nip07Provider = { getPublicKey(): Promise; signEvent(event: UnsignedEvent): Promise; }; -type Nip47GetInfoResponse = { - alias: string; - color: string; - pubkey: string; - network: string; - block_height: number; - block_hash: string; - methods: string[]; -}; - -type Nip47PayResponse = { - preimage: string; -}; - -const nip47ToWeblnRequestMap = { +const nip47ToWeblnRequestMap: Record = { get_info: "getInfo", get_balance: "getBalance", make_invoice: "makeInvoice", @@ -114,36 +70,47 @@ const nip47ToWeblnRequestMap = { pay_keysend: "payKeysend", lookup_invoice: "lookupInvoice", list_transactions: "listTransactions", -}; -const nip47ToWeblnMultiRequestMap = { multi_pay_invoice: "sendMultiPayment", + multi_pay_keysend: "multiKeysend", }; export class NostrWebLNProvider implements WebLNProvider, Nip07Provider { - relay: Relay; - relayUrl: string; - secret: string | undefined; - walletPubkey: string; - options: NostrWebLNOptions; - subscribers: Record void>; private _enabled = false; - - static parseWalletConnectUrl(walletConnectUrl: string) { - walletConnectUrl = walletConnectUrl - .replace("nostrwalletconnect://", "http://") - .replace("nostr+walletconnect://", "http://"); // makes it possible to parse with URL in the different environments (browser/node/...) - const url = new URL(walletConnectUrl); - const options = {} as NostrWebLNOptions; - options.walletPubkey = url.host; - const secret = url.searchParams.get("secret"); - const relayUrl = url.searchParams.get("relay"); - if (secret) { - options.secret = secret; - } - if (relayUrl) { - options.relayUrl = relayUrl; - } - return options; + readonly client: NWCClient; + readonly subscribers: Record void>; + + /** + * @deprecated please use client.relay. Deprecated since v3.2.3. Will be removed in v4.0.0. + */ + get relay(): Relay { + console.warn("relay is deprecated. Please use client.relay instead."); + return this.client.relay; + } + /** + * @deprecated please use client.relayUrl. Deprecated since v3.2.3. Will be removed in v4.0.0. + */ + get relayUrl(): string { + console.warn("relayUrl is deprecated. Please use client.relayUrl instead."); + return this.client.relayUrl; + } + /** + * @deprecated please use client.walletPubkey. Deprecated since v3.2.3. Will be removed in v4.0.0. + */ + get walletPubkey(): string { + console.warn( + "walletPubkey is deprecated. Please use client.walletPubkey instead.", + ); + return this.client.walletPubkey; + } + get options(): NostrWebLNOptions { + return this.client.options; + } + /** + * @deprecated please use client.secret. Deprecated since v3.2.3. Will be removed in v4.0.0. + */ + get secret(): string | undefined { + console.warn("secret is deprecated. Please use client.secret instead."); + return this.client.secret; } static withNewSecret( @@ -154,100 +121,79 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider { return new NostrWebLNProvider(options); } - constructor(options?: { - providerName?: string; - authorizationUrl?: string; - relayUrl?: string; - secret?: string; - walletPubkey?: string; - nostrWalletConnectUrl?: string; - }) { - if (options && options.nostrWalletConnectUrl) { - options = { - ...NostrWebLNProvider.parseWalletConnectUrl( - options.nostrWalletConnectUrl, - ), - ...options, - }; - } - const providerOptions = NWCs[ - options?.providerName || "alby" - ] as NostrWebLNOptions; - this.options = { - ...providerOptions, - ...(options || {}), - } as NostrWebLNOptions; - this.relayUrl = this.options.relayUrl; - this.relay = relayInit(this.relayUrl); - if (this.options.secret) { - this.secret = ( - this.options.secret.toLowerCase().startsWith("nsec") - ? nip19.decode(this.options.secret).data - : this.options.secret - ) as string; - } - this.walletPubkey = ( - this.options.walletPubkey.toLowerCase().startsWith("npub") - ? nip19.decode(this.options.walletPubkey).data - : this.options.walletPubkey - ) as string; - this.subscribers = {}; + constructor(options?: NewNWCClientOptions) { + this.client = new NWCClient(options); - if (globalThis.WebSocket === undefined) { - console.error( - "WebSocket is undefined. Make sure to `import websocket-polyfill` for nodejs environments", - ); - } + this.subscribers = {}; } on(name: string, callback: () => void) { this.subscribers[name] = callback; } - notify(name: string, payload?: unknown) { + notify(name: WebLNMethod, payload?: unknown) { const callback = this.subscribers[name]; if (callback) { callback(payload); } } + /** + * @deprecated please use client.getNostrWalletConnectUrl. Deprecated since v3.2.3. Will be removed in v4.0.0. + */ getNostrWalletConnectUrl(includeSecret = true) { - let url = `nostr+walletconnect://${this.walletPubkey}?relay=${this.relayUrl}&pubkey=${this.publicKey}`; - if (includeSecret) { - url = `${url}&secret=${this.secret}`; - } - return url; + console.warn( + "getNostrWalletConnectUrl is deprecated. Please use client.getNostrWalletConnectUrl instead.", + ); + return this.client.getNostrWalletConnectUrl(includeSecret); } + /** + * @deprecated please use client.nostrWalletConnectUrl. Deprecated since v3.2.3. Will be removed in v4.0.0. + */ get nostrWalletConnectUrl() { - return this.getNostrWalletConnectUrl(); + console.warn( + "nostrWalletConnectUrl is deprecated. Please use client.nostrWalletConnectUrl instead.", + ); + return this.client.nostrWalletConnectUrl; } + /** + * @deprecated please use client.connected. Deprecated since v3.2.3. Will be removed in v4.0.0. + */ get connected() { - return this.relay.status === 1; + console.warn( + "connected is deprecated. Please use client.connected instead.", + ); + return this.client.connected; } + /** + * @deprecated please use getPublicKey(). Deprecated since v3.2.3. Will be removed in v4.0.0. + */ get publicKey() { - if (!this.secret) { - throw new Error("Missing secret key"); - } - return getPublicKey(this.secret); + console.warn( + "publicKey is deprecated. Please use client.publicKey instead.", + ); + return this.client.publicKey; } getPublicKey(): Promise { - return Promise.resolve(this.publicKey); + return this.client.getPublicKey(); } signEvent(event: UnsignedEvent): Promise { - if (!this.secret) { - throw new Error("Missing secret key"); - } - - return Promise.resolve(finishEvent(event, this.secret)); + return this.client.signEvent(event); } + /** + * @deprecated please use client.getEventHash. Deprecated since v3.2.3. Will be removed in v4.0.0. + */ getEventHash(event: Event) { - return getEventHash(event); + console.warn( + "getEventHash is deprecated. Please use client.getEventHash instead.", + ); + return this.client.getEventHash(event); } async enable() { @@ -255,60 +201,70 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider { } close() { - return this.relay.close(); + return this.client.close(); } + /** + * @deprecated please use client.encrypt. Deprecated since v3.2.3. Will be removed in v4.0.0. + */ async encrypt(pubkey: string, content: string) { - if (!this.secret) { - throw new Error("Missing secret"); - } - const encrypted = await nip04.encrypt(this.secret, pubkey, content); - return encrypted; + console.warn("encrypt is deprecated. Please use client.encrypt instead."); + return this.client.encrypt(pubkey, content); } + /** + * @deprecated please use client.decrypt. Deprecated since v3.2.3. Will be removed in v4.0.0. + */ async decrypt(pubkey: string, content: string) { - if (!this.secret) { - throw new Error("Missing secret"); - } - const decrypted = await nip04.decrypt(this.secret, pubkey, content); - return decrypted; + console.warn("decrypt is deprecated. Please use client.decrypt instead."); + return this.client.decrypt(pubkey, content); + } + + /** + * @deprecated please use client.getAuthorizationUrl. Deprecated since v3.2.3. Will be removed in v4.0.0. + */ + getAuthorizationUrl(options?: NWCAuthorizationUrlOptions) { + console.warn( + "getAuthorizationUrl is deprecated. Please use client.getAuthorizationUrl instead.", + ); + return this.client.getAuthorizationUrl(options); + } + + /** + * @deprecated please use client.initNWC. Deprecated since v3.2.3. Will be removed in v4.0.0. + */ + initNWC(options: NWCAuthorizationUrlOptions = {}) { + console.warn("initNWC is deprecated. Please use client.initNWC instead."); + return this.client.initNWC(options); } - // WebLN compatible response - // TODO: use NIP-47 get_info call async getInfo(): Promise { - await this.checkConnected(); + await this.checkEnabled(); const supports = ["lightning", "nostr"]; const version = "Alby JS SDK"; try { - const result = await this.executeNip47Request< - GetInfoResponse, - Nip47GetInfoResponse - >( - "get_info", - undefined, - (result) => !!result.methods, - (result) => ({ - methods: result.methods.map( - (key) => - nip47ToWeblnRequestMap[ - key as keyof typeof nip47ToWeblnRequestMap - ], - ), - node: { - alias: result.alias, - pubkey: result.pubkey, - color: result.color, - } as WebLNNode, - supports, - version, - }), - ); + const nip47Result = await this.client.getInfo(); + + const result = { + methods: nip47Result.methods.map( + (key) => + nip47ToWeblnRequestMap[key as keyof typeof nip47ToWeblnRequestMap], + ), + node: { + alias: nip47Result.alias, + pubkey: nip47Result.pubkey, + color: nip47Result.color, + } as WebLNNode, + supports, + version, + }; + + this.notify("getInfo", result); return result; } catch (error) { - console.error("Failed to request get_info", error); + console.error("Using minimal getInfo", error); return { methods: ["sendPayment"], node: {} as WebLNNode, @@ -318,55 +274,124 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider { } } - async getBalance() { - await this.checkConnected(); + async getBalance(): Promise { + await this.checkEnabled(); + const nip47Result = await this.client.getBalance(); - return this.executeNip47Request( - "get_balance", - undefined, - (result) => result.balance !== undefined, - (result) => ({ - // NWC uses msats - convert to sats for webln - balance: Math.floor(result.balance / 1000), - currency: "sats", - }), - ); + const result = { + // NWC uses msats - convert to sats for webln + balance: Math.floor(nip47Result.balance / 1000), + currency: "sats", + }; + this.notify("getBalance", result); + return result; } - async sendPayment(invoice: string) { - await this.checkConnected(); + async sendPayment(invoice: string): Promise { + await this.checkEnabled(); - return this.executeNip47Request( - "pay_invoice", - { - invoice, - }, - (result) => !!result.preimage, - (result) => ({ preimage: result.preimage }), + const nip47Result = await this.client.payInvoice({ invoice }); + + const result = { preimage: nip47Result.preimage }; + this.notify("sendPayment", result); + + return result; + } + + async keysend(args: KeysendArgs): Promise { + await this.checkEnabled(); + + const nip47Result = await this.client.payKeysend( + mapKeysendToNip47Keysend(args), ); + + const result = { preimage: nip47Result.preimage }; + this.notify("keysend", result); + + return result; + } + + async makeInvoice( + args: string | number | RequestInvoiceArgs, + ): Promise { + await this.checkEnabled(); + + const requestInvoiceArgs: RequestInvoiceArgs | undefined = + typeof args === "object" ? (args as RequestInvoiceArgs) : undefined; + const amount = +(requestInvoiceArgs?.amount ?? (args as string | number)); + + if (!amount) { + throw new Error("No amount specified"); + } + + const nip47Result = await this.client.makeInvoice({ + amount: amount * 1000, // NIP-47 uses msat + description: requestInvoiceArgs?.defaultMemo, + // TODO: support additional fields below + //expiry: 86500, + //description_hash: "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + }); + + const result = { paymentRequest: nip47Result.invoice }; + + this.notify("makeInvoice", result); + + return result; + } + + async lookupInvoice(args: LookupInvoiceArgs): Promise { + await this.checkEnabled(); + + const nip47Result = await this.client.lookupInvoice({ + invoice: args.paymentRequest, + payment_hash: args.paymentHash, + }); + + const result: LookupInvoiceResponse = { + preimage: nip47Result.preimage, + paymentRequest: nip47Result.invoice, + paid: !!nip47Result.settled_at, + }; + + this.notify("lookupInvoice", result); + + return result; + } + + async listTransactions( + args: ListTransactionsArgs, + ): Promise { + await this.checkEnabled(); + + const nip47Result = await this.client.listTransactions(args); + + const result = { + transactions: nip47Result.transactions.map( + mapNip47TransactionToTransaction, + ), + }; + + this.notify("listTransactions", result); + + return result; } // NOTE: this method may change - it has not been proposed to be added to the WebLN spec yet. async sendMultiPayment( paymentRequests: string[], ): Promise { - await this.checkConnected(); - - const results = await this.executeMultiNip47Request< - { preimage: string; paymentRequest: string }, - Nip47PayResponse - >( - "multi_pay_invoice", - { - invoices: paymentRequests.map((paymentRequest, index) => ({ - invoice: paymentRequest, - id: index.toString(), - })), - }, - paymentRequests.length, - (result) => !!result.preimage, - (result) => { - const paymentRequest = paymentRequests[parseInt(result.dTag)]; + await this.checkEnabled(); + + const nip47Result = await this.client.multiPayInvoice({ + invoices: paymentRequests.map((paymentRequest, index) => ({ + invoice: paymentRequest, + id: index.toString(), + })), + }); + + const result = { + payments: nip47Result.invoices.map((invoice) => { + const paymentRequest = paymentRequests[parseInt(invoice.dTag)]; if (!paymentRequest) { throw new Error( "Could not find paymentRequest matching response d tag", @@ -374,37 +399,44 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider { } return { paymentRequest, - preimage: result.preimage, + preimage: invoice.preimage, }; - }, - ); - - return { - payments: results, + }), + // TODO: error handling errors: [], }; + this.notify("sendMultiPayment", result); + return result; } - async keysend(args: KeysendArgs) { - await this.checkConnected(); - - return this.executeNip47Request( - "pay_keysend", - { - amount: +args.amount * 1000, // NIP-47 uses msat - pubkey: args.destination, - tlv_records: args.customRecords - ? Object.entries(args.customRecords).map((v) => ({ - type: parseInt(v[0]), - value: v[1], - })) - : [], - // TODO: support optional preimage - // preimage?: "123", - }, - (result) => !!result.preimage, - (result) => ({ preimage: result.preimage }), - ); + // NOTE: this method may change - it has not been proposed to be added to the WebLN spec yet. + async multiKeysend(keysends: KeysendArgs[]): Promise { + await this.checkEnabled(); + + const nip47Result = await this.client.multiPayKeysend({ + keysends: keysends.map((keysend, index) => ({ + ...mapKeysendToNip47Keysend(keysend), + id: index.toString(), + })), + }); + + const result: MultiKeysendResponse = { + keysends: nip47Result.keysends.map((result) => { + const keysend = keysends[parseInt(result.dTag)]; + if (!keysend) { + throw new Error("Could not find keysend matching response d tag"); + } + return { + keysend, + preimage: result.preimage, + }; + }), + // TODO: error handling + errors: [], + }; + + this.notify("multiKeysend", result); + return result; } // not-yet implemented WebLN interface methods @@ -414,68 +446,6 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider { throw new Error("Method not implemented."); } - async makeInvoice(args: string | number | RequestInvoiceArgs) { - await this.checkConnected(); - - const requestInvoiceArgs: RequestInvoiceArgs | undefined = - typeof args === "object" ? (args as RequestInvoiceArgs) : undefined; - const amount = +(requestInvoiceArgs?.amount ?? (args as string | number)); - - if (!amount) { - throw new Error("No amount specified"); - } - - return this.executeNip47Request( - "make_invoice", - { - amount: amount * 1000, // NIP-47 uses msat - description: requestInvoiceArgs?.defaultMemo, - // TODO: support additional fields below - //expiry: 86500, - //description_hash: "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" - }, - (result) => !!result.invoice, - (result) => ({ paymentRequest: result.invoice }), - ); - } - - async lookupInvoice(args: LookupInvoiceArgs) { - await this.checkConnected(); - - return this.executeNip47Request( - "lookup_invoice", - { - invoice: args.paymentRequest, - payment_hash: args.paymentHash, - }, - (result) => !!result.invoice, - (result) => ({ - preimage: result.preimage, - paymentRequest: result.invoice, - paid: !!result.settled_at, - }), - ); - } - - async listTransactions(args: ListTransactionsArgs) { - await this.checkConnected(); - - // maybe we can tailor the response to our needs - return this.executeNip47Request< - ListTransactionsResponse, - Nip47ListTransactionsResponse - >( - "list_transactions", - args, - (response) => !!response.transactions, - (response) => ({ - transactions: response.transactions.map( - mapNip47TransactionToTransaction, - ), - }), - ); - } - request(method: WebLNRequestMethod, args?: unknown): Promise { throw new Error("Method not implemented."); } @@ -486,344 +456,14 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider { throw new Error("Method not implemented."); } - getAuthorizationUrl(options?: NWCAuthorizationUrlOptions) { - if (!this.options.authorizationUrl) { - throw new Error("Missing authorizationUrl option"); - } - const url = new URL(this.options.authorizationUrl); - if (options?.name) { - url.searchParams.set("name", options?.name); - } - url.searchParams.set("pubkey", this.publicKey); - if (options?.returnTo) { - url.searchParams.set("return_to", options.returnTo); - } - - if (options?.budgetRenewal) { - url.searchParams.set("budget_renewal", options.budgetRenewal); - } - if (options?.expiresAt) { - url.searchParams.set( - "expires_at", - Math.floor(options.expiresAt.getTime() / 1000).toString(), - ); - } - if (options?.maxAmount) { - url.searchParams.set("max_amount", options.maxAmount.toString()); - } - if (options?.editable !== undefined) { - url.searchParams.set("editable", options.editable.toString()); - } - - if (options?.requestMethods) { - url.searchParams.set("request_methods", options.requestMethods.join(" ")); - } - - return url; - } - - initNWC(options: NWCAuthorizationUrlOptions = {}) { - // here we assume an browser context and window/document is available - // we set the location.host as a default name if none is given - if (!options.name) { - options.name = document.location.host; - } - const url = this.getAuthorizationUrl(options); - const height = 600; - const width = 400; - const top = window.outerHeight / 2 + window.screenY - height / 2; - const left = window.outerWidth / 2 + window.screenX - width / 2; - - return new Promise((resolve, reject) => { - const popup = window.open( - url.toString(), - `${document.title} - Wallet Connect`, - `height=${height},width=${width},top=${top},left=${left}`, - ); - if (!popup) { - reject(); - return; - } // only for TS? - - const checkForPopup = () => { - if (popup && popup.closed) { - reject(); - clearInterval(popupChecker); - window.removeEventListener("message", onMessage); - } - }; - - const onMessage = (message: { - data?: { type: "nwc:success" | unknown }; - origin: string; - }) => { - const data = message.data; - if ( - data && - data.type === "nwc:success" && - message.origin === `${url.protocol}//${url.host}` - ) { - resolve(data); - clearInterval(popupChecker); - window.removeEventListener("message", onMessage); - if (popup) { - popup.close(); // close the popup - } - } - }; - const popupChecker = setInterval(checkForPopup, 500); - window.addEventListener("message", onMessage); - }); - } - - private async checkConnected() { + private async checkEnabled() { if (!this._enabled) { throw new Error( "please call enable() and await the promise before calling this function", ); } - if (!this.secret) { - throw new Error("Missing secret key"); - } - await this.relay.connect(); - } - - private executeNip47Request( - nip47Method: keyof typeof nip47ToWeblnRequestMap, - params: unknown, - resultValidator: (result: R) => boolean, - resultMapper: (result: R) => T, - ) { - const weblnMethod = nip47ToWeblnRequestMap[nip47Method]; - return new Promise((resolve, reject) => { - (async () => { - const command = { - method: nip47Method, - params, - }; - const encryptedCommand = await this.encrypt( - this.walletPubkey, - JSON.stringify(command), - ); - const unsignedEvent: UnsignedEvent = { - kind: 23194, - created_at: Math.floor(Date.now() / 1000), - tags: [["p", this.walletPubkey]], - content: encryptedCommand, - pubkey: this.publicKey, - }; - - const event = await this.signEvent(unsignedEvent); - // subscribe to NIP_47_SUCCESS_RESPONSE_KIND and NIP_47_ERROR_RESPONSE_KIND - // that reference the request event (NIP_47_REQUEST_KIND) - const sub = this.relay.sub([ - { - kinds: [23195], - authors: [this.walletPubkey], - "#e": [event.id], - }, - ]); - - function replyTimeout() { - sub.unsub(); - //console.error(`Reply timeout: event ${event.id} `); - reject({ - error: `reply timeout: event ${event.id}`, - code: "INTERNAL", - }); - } - - const replyTimeoutCheck = setTimeout(replyTimeout, 60000); - - sub.on("event", async (event) => { - // console.log(`Received reply event: `, event); - clearTimeout(replyTimeoutCheck); - sub.unsub(); - const decryptedContent = await this.decrypt( - this.walletPubkey, - event.content, - ); - // console.log(`Decrypted content: `, decryptedContent); - let response; - try { - response = JSON.parse(decryptedContent); - } catch (e) { - reject({ error: "invalid response", code: "INTERNAL" }); - return; - } - if (event.kind == 23195 && response.result) { - // console.info("NIP-47 result", response.result); - if (resultValidator(response.result)) { - resolve(resultMapper(response.result)); - this.notify(weblnMethod, response.result); - } else { - reject({ - error: - "Response from NWC failed validation: " + - JSON.stringify(response.result), - code: "INTERNAL", - }); - } - } else { - reject({ - error: response.error?.message, - code: response.error?.code, - }); - } - }); - - function publishTimeout() { - //console.error(`Publish timeout: event ${event.id}`); - reject({ error: `Publish timeout: event ${event.id}` }); - } - const publishTimeoutCheck = setTimeout(publishTimeout, 5000); - - try { - await this.relay.publish(event); - clearTimeout(publishTimeoutCheck); - //console.debug(`Event ${event.id} for ${invoice} published`); - } catch (error) { - //console.error(`Failed to publish to ${this.relay.url}`, error); - clearTimeout(publishTimeoutCheck); - reject({ error: `Failed to publish request: ${error}` }); - } - })(); - }); - } - - // TODO: this method currently fails if any payment fails. - // this could be improved in the future. - // TODO: reduce duplication between executeNip47Request and executeMultiNip47Request - private executeMultiNip47Request( - nip47Method: keyof typeof nip47ToWeblnMultiRequestMap, - params: unknown, - numPayments: number, - resultValidator: (result: R) => boolean, - resultMapper: (result: R & { dTag: string }) => T, - ) { - const weblnMethod = nip47ToWeblnMultiRequestMap[nip47Method]; - const results: (R & { dTag: string })[] = []; - return new Promise((resolve, reject) => { - (async () => { - const command = { - method: nip47Method, - params, - }; - const encryptedCommand = await this.encrypt( - this.walletPubkey, - JSON.stringify(command), - ); - const unsignedEvent: UnsignedEvent = { - kind: 23194, - created_at: Math.floor(Date.now() / 1000), - tags: [["p", this.walletPubkey]], - content: encryptedCommand, - pubkey: this.publicKey, - }; - - const event = await this.signEvent(unsignedEvent); - // subscribe to NIP_47_SUCCESS_RESPONSE_KIND and NIP_47_ERROR_RESPONSE_KIND - // that reference the request event (NIP_47_REQUEST_KIND) - const sub = this.relay.sub([ - { - kinds: [23195], - authors: [this.walletPubkey], - "#e": [event.id], - }, - ]); - - function replyTimeout() { - sub.unsub(); - //console.error(`Reply timeout: event ${event.id} `); - reject({ - error: `reply timeout: event ${event.id}`, - code: "INTERNAL", - }); - } - - const replyTimeoutCheck = setTimeout(replyTimeout, 60000); - - sub.on("event", async (event) => { - // console.log(`Received reply event: `, event); - - const decryptedContent = await this.decrypt( - this.walletPubkey, - event.content, - ); - // console.log(`Decrypted content: `, decryptedContent); - let response; - try { - response = JSON.parse(decryptedContent); - } catch (e) { - console.error(e); - clearTimeout(replyTimeoutCheck); - sub.unsub(); - reject({ error: "invalid response", code: "INTERNAL" }); - return; - } - if (event.kind == 23195 && response.result) { - // console.info("NIP-47 result", response.result); - try { - if (!resultValidator(response.result)) { - throw new Error( - "Response from NWC failed validation: " + - JSON.stringify(response.result), - ); - } - const dTag = event.tags.find((tag) => tag[0] === "d")?.[1]; - if (dTag === undefined) { - throw new Error("No d tag found in response event"); - } - results.push({ - ...response.result, - dTag, - }); - if (results.length === numPayments) { - clearTimeout(replyTimeoutCheck); - sub.unsub(); - //console.log("Received results", results); - resolve(results.map(resultMapper)); - this.notify(weblnMethod, response.result); - } - } catch (error) { - console.error(error); - clearTimeout(replyTimeoutCheck); - sub.unsub(); - reject({ - error: (error as Error).message, - code: "INTERNAL", - }); - } - } else { - clearTimeout(replyTimeoutCheck); - sub.unsub(); - reject({ - error: response.error?.message, - code: response.error?.code, - }); - } - }); - - function publishTimeout() { - //console.error(`Publish timeout: event ${event.id}`); - reject({ error: `Publish timeout: event ${event.id}` }); - } - const publishTimeoutCheck = setTimeout(publishTimeout, 5000); - - try { - await this.relay.publish(event); - clearTimeout(publishTimeoutCheck); - //console.debug(`Event ${event.id} for ${invoice} published`); - } catch (error) { - //console.error(`Failed to publish to ${this.relay.url}`, error); - clearTimeout(publishTimeoutCheck); - reject({ error: `Failed to publish request: ${error}` }); - } - })(); - }); } } - function mapNip47TransactionToTransaction( transaction: Nip47Transaction, ): Transaction { @@ -837,4 +477,19 @@ function mapNip47TransactionToTransaction( }; } +function mapKeysendToNip47Keysend(args: KeysendArgs): Nip47PayKeysendRequest { + return { + amount: +args.amount * 1000, // NIP-47 uses msat + pubkey: args.destination, + tlv_records: args.customRecords + ? Object.entries(args.customRecords).map((v) => ({ + type: parseInt(v[0]), + value: v[1], + })) + : [], + // TODO: support optional preimage + // preimage?: "123", + }; +} + export const NWC = NostrWebLNProvider;