UNPKG

applesauce-wallet-connect

Version:

NIP-47 Nostr Wallet Connect implementation for both clients and services.

332 lines (331 loc) 17 kB
import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import { simpleTimeout } from "applesauce-core"; import { create } from "applesauce-factory"; import { finalizeEvent, getPublicKey, nip04, nip44, verifyEvent } from "nostr-tools"; import { BehaviorSubject, defer, filter, firstValueFrom, from, fromEvent, identity, ignoreElements, lastValueFrom, map, merge, mergeMap, ReplaySubject, share, switchMap, takeUntil, tap, timer, toArray, } from "rxjs"; import { WalletRequestBlueprint } from "./blueprints/index.js"; import { createWalletError } from "./helpers/error.js"; import { createWalletAuthURI, getPreferredEncryption, getWalletNotification, getWalletRequestEncryption, getWalletResponse, getWalletResponseRequestId, getWalletSupport, isWalletNotificationLocked, isWalletResponseLocked, parseWalletConnectURI, supportsMethod, supportsNotifications, supportsNotificationType, unlockWalletNotification, unlockWalletResponse, WALLET_INFO_KIND, WALLET_LEGACY_NOTIFICATION_KIND, WALLET_NOTIFICATION_KIND, WALLET_RESPONSE_KIND, } from "./helpers/index.js"; export class WalletConnect { /** A fallback method to use for subscriptionMethod if none is passed in when creating the client */ static subscriptionMethod = undefined; /** A fallback method to use for publishMethod if none is passed in when creating the client */ static publishMethod = undefined; /** A fallback pool to use if none is pass in when creating the signer */ static pool = undefined; /** A method that is called when an event needs to be published */ publishMethod; /** The active nostr subscription method */ subscriptionMethod; /** The local client signer */ secret; signer; /** The relays to use for the connection */ relays; /** The wallet service public key ( unset if waiting for service ) */ service$ = new BehaviorSubject(undefined); get service() { return this.service$.value; } /** Default timeout for requests */ defaultTimeout; /** Observable for wallet info updates */ support$; /** The preferred encryption method for the wallet */ encryption$; /** Shared observable for all wallet response events and notifications */ events$; /** Shared observable for all wallet notifications */ notifications$; /** An internal observable for listening for the wallet service to connect */ waitForService$; constructor(options) { this.secret = options.secret; this.relays = options.relays; this.service$.next(options.service); this.defaultTimeout = options.timeout || 30000; // 30 second default timeout // Create a signer for the factory this.signer = { getPublicKey: async () => getPublicKey(this.secret), signEvent: async (draft) => finalizeEvent(draft, this.secret), nip04: { encrypt: async (pubkey, plaintext) => nip04.encrypt(this.secret, pubkey, plaintext), decrypt: async (pubkey, ciphertext) => nip04.decrypt(this.secret, pubkey, ciphertext), }, nip44: { encrypt: async (pubkey, plaintext) => nip44.encrypt(plaintext, nip44.getConversationKey(this.secret, pubkey)), decrypt: async (pubkey, ciphertext) => nip44.decrypt(ciphertext, nip44.getConversationKey(this.secret, pubkey)), }, }; // Get the subscription and publish methods const subscriptionMethod = options.subscriptionMethod || options.pool?.subscription || WalletConnect.subscriptionMethod || WalletConnect.pool?.subscription; if (!subscriptionMethod) throw new Error("Missing subscriptionMethod, either pass a method or set WalletConnect.subscriptionMethod"); const publishMethod = options.publishMethod || options.pool?.publish || WalletConnect.publishMethod || WalletConnect.pool?.publish; if (!publishMethod) throw new Error("Missing publishMethod, either pass a method or set WalletConnect.publishMethod"); // Use arrow functions so "this" isn't bound to the signer this.subscriptionMethod = (relays, filters) => subscriptionMethod(relays, filters); this.publishMethod = (relays, event) => publishMethod(relays, event); // Create shared observable for all wallet events this.events$ = this.service$.pipe(switchMap((service) => { const client = getPublicKey(this.secret); // If the service is not known yet, subscribe to a wallet info event tagging the client if (!service) return this.subscriptionMethod(this.relays, [{ kinds: [WALLET_INFO_KIND], "#p": [client] }]).pipe( // Ignore strings (support for applesauce-relay) filter((event) => typeof event !== "string")); return this.subscriptionMethod(this.relays, [ // Subscribe to response events { kinds: [WALLET_RESPONSE_KIND, WALLET_NOTIFICATION_KIND, WALLET_LEGACY_NOTIFICATION_KIND], "#p": [client], authors: [service], }, // Subscribe to wallet info events { kinds: [WALLET_INFO_KIND], authors: [service] }, ]).pipe( // Ignore strings (support for applesauce-relay) filter((event) => typeof event !== "string"), // Only include events from the wallet service filter((event) => event.pubkey === service)); }), // Only create a single subscription to the relays share({ resetOnRefCountZero: () => timer(60000), // Keep subscription open for 1 minute after last unsubscribe })); this.support$ = this.events$.pipe(filter((event) => event.kind === WALLET_INFO_KIND), map((event) => getWalletSupport(event)), share({ connector: () => new ReplaySubject(1), resetOnRefCountZero: () => timer(60000), // Keep info observable around for 1 minute after last unsubscribe })); this.encryption$ = this.support$.pipe(map((info) => (info ? getPreferredEncryption(info) : "nip04"))); this.notifications$ = this.events$.pipe(filter((event) => event.kind === WALLET_NOTIFICATION_KIND), mergeMap((event) => this.handleNotificationEvent(event))); this.waitForService$ = this.events$.pipe( // Complete when the service is set takeUntil(this.service$), // Only listen for wallet info events filter((event) => event.kind === WALLET_INFO_KIND && !this.service), // Set the service to the pubkey of the wallet info event tap((event) => { // Set the service to the pubkey of the wallet info event this.service$.next(event.pubkey); }), // Get the service pubkey from the event map((event) => event.pubkey), // Only create a single subscription to avoid multiple side effects share()); } /** Process response events and return WalletResponse or throw error */ async handleResponseEvent(event, encryption) { if (!verifyEvent(event)) throw new Error("Invalid response event signature"); const requestId = getWalletResponseRequestId(event); if (!requestId) throw new Error("Response missing request ID"); let response; if (isWalletResponseLocked(event)) response = await unlockWalletResponse(event, this.signer, encryption); else response = getWalletResponse(event); if (!response) throw new Error("Failed to decrypt or parse response"); return response; } /** Handle notification events */ async handleNotificationEvent(event) { if (!verifyEvent(event)) throw new Error("Invalid notification event signature"); let notification; if (isWalletNotificationLocked(event)) notification = await unlockWalletNotification(event, this.signer); else notification = getWalletNotification(event); if (!notification) throw new Error("Failed to decrypt or parse notification"); return notification; } /** Core RPC method that makes a request and returns the response */ request(request, options = {}) { if (!this.service) throw new Error("WalletConnect is not connected to a service"); // Create the request evnet return defer(async () => { // Get the preferred encryption method for the wallet const encryption = await firstValueFrom(this.encryption$); // Create the request event const draft = await create({ signer: this.signer }, WalletRequestBlueprint, this.service, request, encryption); // Sign the request event return await this.signer.signEvent(draft); }).pipe( // Then switch to the request observable switchMap((requestEvent) => { const encryption = getWalletRequestEncryption(requestEvent) === "nip44_v2" ? "nip44" : "nip04"; // Create an observable that publishes the request event when subscribed to const request$ = defer(() => from(this.publishMethod(this.relays, requestEvent))).pipe(ignoreElements()); // Create an observable that listens for response events const responses$ = this.events$.pipe(filter((response) => response.kind === WALLET_RESPONSE_KIND && getWalletResponseRequestId(response) === requestEvent.id), mergeMap((response) => this.handleResponseEvent(response, encryption)), // Set timeout for response events simpleTimeout(options.timeout || this.defaultTimeout)); return merge(request$, responses$); })); } /** * Listen for a type of notification * @returns a method to unsubscribe the listener */ notification(type, listener) { return this.notifications$.subscribe((notification) => { if (notification.notification_type === type) listener(notification.notification); }); } /** Gets the nostr+walletauth URI for the connection */ getAuthURI(parts) { return createWalletAuthURI({ ...parts, client: getPublicKey(this.secret), relays: this.relays }); } /** Wait for the wallet service to connect */ async waitForService(abortSignal) { if (this.service) return this.service; return await firstValueFrom(this.waitForService$.pipe( // Listen for abort signal abortSignal ? takeUntil(fromEvent(abortSignal, "abort")) : identity)); } // Convenience methods that return promises for easy API usage /** Get the wallet support info */ getSupport() { return firstValueFrom(this.support$); } /** Check if the wallet supports a method */ async supportsMethod(method) { const support = await this.getSupport(); return support ? supportsMethod(support, method) : false; } /** Check if the wallet supports notifications */ async supportsNotifications() { const support = await this.getSupport(); return support ? supportsNotifications(support) : false; } /** Check if the wallet supports a notification type */ async supportsNotificationType(type) { const support = await this.getSupport(); return support ? supportsNotificationType(support, type) : false; } /** Pay a lightning invoice */ async payInvoice(invoice, amount) { const response = await firstValueFrom(this.request({ method: "pay_invoice", params: { invoice, amount } })); if (response.result_type !== "pay_invoice") throw new Error(`Unexpected response type: ${response.result_type}`); if (response.error) throw createWalletError(response.error.type, response.error.message); return response.result; } /** Pay multiple lightning invoices */ async payMultipleInvoices(invoices) { return await lastValueFrom(this.request({ method: "multi_pay_invoice", params: { invoices } }) .pipe(map((response) => { if (response.result_type !== "multi_pay_invoice") throw new Error(`Unexpected response type: ${response.result_type}`); if (response.error) throw createWalletError(response.error.type, response.error.message); return response.result; })) .pipe(toArray())); } /** Send a keysend payment */ async payKeysend(pubkey, amount, preimage, tlv_records) { const response = await firstValueFrom(this.request({ method: "pay_keysend", params: { pubkey, amount, preimage, tlv_records } })); if (response.result_type !== "pay_keysend") throw new Error(`Unexpected response type: ${response.result_type}`); if (response.error) throw createWalletError(response.error.type, response.error.message); return response.result; } /** Send multiple keysend payments */ async payMultipleKeysend(keysends) { return lastValueFrom(this.request({ method: "multi_pay_keysend", params: { keysends } }).pipe(map((response) => { if (response.result_type !== "multi_pay_keysend") throw new Error(`Unexpected response type: ${response.result_type}`); if (response.error) throw createWalletError(response.error.type, response.error.message); return response.result; }), toArray())); } /** Create a new invoice */ async makeInvoice(amount, options) { const response = await firstValueFrom(this.request({ method: "make_invoice", params: { amount, ...options } })); if (response.result_type !== "make_invoice") throw new Error(`Unexpected response type: ${response.result_type}`); if (response.error) throw createWalletError(response.error.type, response.error.message); return response.result; } /** Look up an invoice by payment hash or invoice string */ async lookupInvoice(payment_hash, invoice) { const response = await firstValueFrom(this.request({ method: "lookup_invoice", params: { payment_hash, invoice } })); if (response.result_type !== "lookup_invoice") throw new Error(`Unexpected response type: ${response.result_type}`); if (response.error) throw createWalletError(response.error.type, response.error.message); return response.result; } /** List transactions */ async listTransactions(params) { const response = await firstValueFrom(this.request({ method: "list_transactions", params: params || {} })); if (response.result_type !== "list_transactions") throw new Error(`Unexpected response type: ${response.result_type}`); if (response.error) throw createWalletError(response.error.type, response.error.message); return response.result; } /** Get wallet balance */ async getBalance() { const response = await firstValueFrom(this.request({ method: "get_balance", params: {} })); if (response.result_type !== "get_balance") throw new Error(`Unexpected response type: ${response.result_type}`); if (response.error) throw createWalletError(response.error.type, response.error.message); return response.result; } /** Get wallet info */ async getInfo() { const response = await firstValueFrom(this.request({ method: "get_info", params: {} })); if (response.result_type !== "get_info") throw new Error(`Unexpected response type: ${response.result_type}`); if (response.error) throw createWalletError(response.error.type, response.error.message); return response.result; } /** Serialize the WalletConnect instance */ toJSON() { if (!this.service) throw new Error("WalletConnect is not connected to a service"); return { secret: bytesToHex(this.secret), service: this.service, relays: this.relays, }; } /** Create a new WalletConnect instance from a serialized object */ static fromJSON(json, options) { return new WalletConnect({ ...options, secret: hexToBytes(json.secret), service: json.service, relays: json.relays, }); } /** Create a new WalletConnect instance from a connection string */ static fromConnectURI(connectionString, options) { const { secret, service, relays } = parseWalletConnectURI(connectionString); return new WalletConnect({ ...options, secret: hexToBytes(secret), service, relays, }); } }