import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { generateSecretKey, getPublicKey } from "nostr-tools"; import { Logger, noopLogger } from "../logger"; import { BudgetRenewalPeriod, Nip47Method, Nip47NetworkError, Nip47NotificationType, } from "./types"; import { NWCClient } from "./NWCClient"; import { ReconnectingPool } from "./ReconnectingPool"; export type NWAOptions = { relayUrls: string[]; appPubkey: string; requestMethods: Nip47Method[]; name?: string; icon?: string; notificationTypes?: Nip47NotificationType[]; maxAmount?: number; budgetRenewal?: BudgetRenewalPeriod; expiresAt?: number; isolated?: boolean; returnTo?: string; metadata?: unknown; }; export type NewNWAClientOptions = Omit & { appSecretKey?: string; logger?: Logger; }; export class NWAClient { options: NWAOptions; appSecretKey: string; pool: ReconnectingPool; logger: Logger; constructor(options: NewNWAClientOptions) { this.appSecretKey = options.appSecretKey || bytesToHex(generateSecretKey()); this.options = { ...options, appPubkey: getPublicKey(hexToBytes(this.appSecretKey)), }; if (!this.options.relayUrls) { throw new Error("Missing relay urls"); } if (!this.options.requestMethods) { throw new Error("Missing request methods"); } this.pool = new ReconnectingPool(); this.logger = options.logger || noopLogger; } /** * returns the NWA connection URI which should be given to the wallet */ get connectionUri() { return this.getConnectionUri(); } /** * returns the NWA connection URI which should be given to the wallet * @param nwaSchemeSuffix open a specific wallet. e.g. "alby" will set the scheme to * nostr+walletauth+alby to ensure the link will be opened in an Alby wallet */ getConnectionUri(nwaSchemeSuffix = "") { const searchParams = new URLSearchParams({ request_methods: this.options.requestMethods.join(" "), ...(this.options.name ? { name: this.options.name } : {}), ...(this.options.icon ? { icon: this.options.icon } : {}), ...(this.options.returnTo ? { return_to: this.options.returnTo } : {}), ...(this.options.notificationTypes ? { notification_types: this.options.notificationTypes.join(" "), } : {}), ...(this.options.maxAmount ? { max_amount: this.options.maxAmount.toString() } : {}), ...(this.options.budgetRenewal ? { budget_renewal: this.options.budgetRenewal } : {}), ...(this.options.expiresAt ? { expires_at: this.options.expiresAt.toString() } : {}), ...(this.options.isolated ? { isolated: this.options.isolated.toString() } : {}), ...(this.options.metadata ? { metadata: JSON.stringify(this.options.metadata) } : {}), }); for (const relay of this.options.relayUrls) { searchParams.append("relay", relay); } return `nostr+walletauth${nwaSchemeSuffix ? `+${nwaSchemeSuffix}` : ""}://${this.options.appPubkey}?${searchParams .toString() .replace(/\+/g, "%20")}`; } static parseWalletAuthUrl(walletAuthUrl: string): NWAOptions { if (!walletAuthUrl.startsWith("nostr+walletauth")) { throw new Error( "Unexpected scheme. Should be nostr+walletauth:// or nostr+walletauth+specificapp://", ); } // makes it possible to parse with URL in the different environments (browser/node/...) // parses with or without "//" const colonIndex = walletAuthUrl.indexOf(":"); walletAuthUrl = walletAuthUrl.substring(colonIndex + 1); if (walletAuthUrl.startsWith("//")) { walletAuthUrl = walletAuthUrl.substring(2); } walletAuthUrl = "http://" + walletAuthUrl; const url = new URL(walletAuthUrl); const appPubkey = url.host; if (appPubkey?.length !== 64) { throw new Error("Incorrect app pubkey found in auth string"); } const relayUrls = url.searchParams.getAll("relay"); if (!relayUrls) { throw new Error("No relay URL found in auth string"); } const requestMethods = url.searchParams .get("request_methods") ?.split(" ") as Nip47Method[] | undefined; if (!requestMethods?.length) { throw new Error("No request methods found in auth string"); } const notificationTypes = url.searchParams .get("notification_types") ?.split(" ") as Nip47NotificationType[] | undefined; const maxAmountString = url.searchParams.get("max_amount"); const expiresAtString = url.searchParams.get("expires_at"); const metadataString = url.searchParams.get("metadata"); return { name: url.searchParams.get("name") || undefined, icon: url.searchParams.get("icon") || undefined, returnTo: url.searchParams.get("return_to") || undefined, relayUrls, appPubkey, requestMethods, notificationTypes, budgetRenewal: url.searchParams.get("budget_renewal") as | BudgetRenewalPeriod | undefined, expiresAt: expiresAtString ? parseInt(expiresAtString) : undefined, maxAmount: maxAmountString ? parseInt(maxAmountString) : undefined, isolated: url.searchParams.get("isolated") === "true", metadata: metadataString ? JSON.parse(metadataString) : undefined, }; } /** * Waits for a new app connection to be created via NWA (https://github.com/nostr-protocol/nips/pull/851) * * @returns a new NWCClient */ async subscribe(args: { onSuccess: (nwcClient: NWCClient) => void; }): Promise<{ unsub: () => void; }> { this.logger.debug("checking connection to relays"); await this._checkConnected(); this.logger.debug("subscribing to info event"); const sub = this.pool.subscribe( this.options.relayUrls, { kinds: [13194], // NIP-47 info event "#p": [this.options.appPubkey], }, { onevent: async (event) => { const client = new NWCClient({ relayUrls: this.options.relayUrls, secret: this.appSecretKey, walletPubkey: event.pubkey, }); // try to fetch the lightning address try { const info = await client.getInfo(); client.options.lud16 = info.lud16; client.lud16 = info.lud16; } catch (error) { console.error("failed to fetch get_info", error); } args.onSuccess(client); sub?.close(); }, onconnect: (url) => { this.logger.debug("relay connected", url); }, ondisconnect: (url, reason) => { this.logger.debug("relay disconnected", url, reason); }, }, ); return { unsub: () => { sub?.close(); }, }; } close() { return this.pool.close(this.options.relayUrls); } private async _checkConnected() { if (!this.appSecretKey) { throw new Error("Missing secret key"); } if (!this.options.relayUrls) { throw new Error("Missing relay urls"); } try { await Promise.any( this.options.relayUrls.map((relayUrl) => this.pool.ensureRelay(relayUrl), ), ); } catch (error) { console.error("failed to connect to any relay", error); throw new Nip47NetworkError( "Failed to connect to " + this.options.relayUrls.join(","), "OTHER", ); } } }