UNPKG

applesauce-wallet-connect

Version:

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

370 lines (369 loc) 18.3 kB
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { simpleTimeout } from "applesauce-core"; import { finalizeEvent, getPublicKey, verifyEvent } from "applesauce-core/helpers"; import { nip04, nip44 } from "applesauce-core/helpers/encryption"; import { BehaviorSubject, combineLatest, defer, filter, firstValueFrom, from, fromEvent, identity, ignoreElements, interval, lastValueFrom, map, merge, mergeMap, of, repeat, ReplaySubject, retry, share, switchMap, take, takeUntil, tap, timer, toArray, } from "rxjs"; import { WalletRequestFactory } from "./factories/index.js"; import { createWalletError } from "./helpers/error.js"; import { createWalletAuthURI, createWalletConnectURI, getPreferredEncryption, getWalletRequestEncryption, getWalletResponseRequestId, getWalletSupport, isValidWalletNotification, isValidWalletResponse, parseWalletConnectURI, supportsMethod, supportsNotifications, supportsNotificationType, unlockWalletNotification, unlockWalletResponse, WALLET_INFO_KIND, WALLET_LEGACY_NOTIFICATION_KIND, WALLET_NOTIFICATION_KIND, WALLET_RESPONSE_KIND, } from "./helpers/index.js"; import { getConnectionMethods, } from "./interop.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$ = new BehaviorSubject([]); get relays() { return this.relays$.value; } /** Whether to accept the relay hint from the wallet service */ acceptRelayHint; /** The wallet service public key ( unset if waiting for service ) */ service$ = new BehaviorSubject(undefined); get service() { return this.service$.value; } /** The wallet connect URI for this connection, or undefined if the service pubkey is not yet known */ get connectURI() { if (!this.service) return undefined; return createWalletConnectURI({ service: this.service, relays: this.relays, secret: bytesToHex(this.secret) }); } /** 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$.next(options.relays); this.acceptRelayHint = options.acceptRelayHint ?? true; 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, publishMethod } = getConnectionMethods(options, WalletConnect); // 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$ = combineLatest([this.service$, this.relays$]).pipe(switchMap(([service, relays]) => { const client = getPublicKey(this.secret); // If the service is not known yet, subscribe to a wallet info event tagging the client if (!service) return from(this.subscriptionMethod(relays, [{ kinds: [WALLET_INFO_KIND], "#p": [client] }])).pipe( // Keep the connection open indefinitely repeat(), // Retry on connection failure retry(), // Ignore strings (support for applesauce-relay) filter((event) => typeof event !== "string")); return from(this.subscriptionMethod(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( // Keep the connection open indefinitely repeat(), // Retry on connection failure retry(), // 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) => isValidWalletNotification(event)), mergeMap((event) => this.handleNotificationEvent(event))); this.waitForService$ = defer(() => // If service is already set, return it this.service$.value ? of(this.service$.value) : // Otherwise listen for new wallet info events this.events$.pipe( // Only listen for wallet info events filter((event) => event.kind === WALLET_INFO_KIND), // 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); // Switch to the relay from the service if its set if (this.acceptRelayHint) { const relay = event.tags.find((t) => t[0] === "p" && t[2])?.[2]; if (relay) this.relays$.next([relay]); } }), // Get the service pubkey from the event map((event) => event.pubkey), // Complete after the first value take(1))).pipe( // 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"); const response = await unlockWalletResponse(event, this.signer, encryption); 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"); const notification = await unlockWalletNotification(event, this.signer); if (!notification) throw new Error("Failed to decrypt or parse notification"); return notification; } /** Generic call method with generic type */ genericCall(method, params, options = {}) { if (!this.service) throw new Error("WalletConnect is not connected to a service"); // Create the request event return defer(async () => { // Get the preferred encryption method for the wallet const encryption = await firstValueFrom(this.encryption$); // Create and sign the request event return await WalletRequestFactory.create(this.service, { method, params }, encryption).as(this.signer).sign(); }).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(isValidWalletResponse), filter((response) => getWalletResponseRequestId(response) === requestEvent.id), mergeMap((response) => this.handleResponseEvent(response, encryption)), // Set timeout for response events simpleTimeout(options.timeout || this.defaultTimeout)); return merge(request$, responses$); })); } /** Request method with generic type */ async genericRequest(method, params, options = {}) { const result = await firstValueFrom(this.genericCall(method, params, options)); if (result.result_type !== method) throw new Error(`Unexpected response type: ${result.result_type}`); if (result.error) throw createWalletError(result.error.type, result.error.message); return result.result; } /** Call a method and return an observable of results */ call(method, params, options = {}) { return this.genericCall(method, params, options); } /** Typed request method, returns the result or throws and error */ async request(method, params, options = {}) { return this.genericRequest(method, params, options); } /** * 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; } // Methods for common types /** Pay a lightning invoice */ async payInvoice(invoice, amount) { return await this.genericRequest("pay_invoice", { invoice, amount }); } /** Pay multiple lightning invoices */ async payMultipleInvoices(invoices) { return await lastValueFrom(this.genericCall("multi_pay_invoice", { 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) { return await this.genericRequest("pay_keysend", { pubkey, amount, preimage, tlv_records }); } /** Send multiple keysend payments */ async payMultipleKeysend(keysends) { return lastValueFrom(this.genericCall("multi_pay_keysend", { 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) { return await this.genericRequest("make_invoice", { amount, ...options }); } /** Wait for an invoice to be paid, or reject when it expires */ async waitForPaid(invoice, options = {}) { const transaction = "state" in invoice ? invoice : await this.lookupInvoice(invoice.payment_hash, invoice.invoice); if (transaction.state === "settled") return Promise.resolve(transaction); if (!transaction.payment_hash) return Promise.reject(new Error("Invoice is missing payment hash")); const expiresAt = transaction.expires_at ? transaction.expires_at * 1000 : undefined; const now = Date.now(); if (expiresAt && expiresAt <= now) return Promise.reject(new Error("Invoice expired")); const supportsNotifications = await this.supportsNotificationType("payment_received"); if (!supportsNotifications) { return await firstValueFrom(interval(options.pollInterval ?? 5000).pipe(switchMap(() => this.lookupInvoice(transaction.payment_hash, transaction.invoice)), mergeMap((result) => { if (result.state === "settled") return of(result); if (result.state === "expired") throw new Error("Invoice expired"); return []; }), simpleTimeout(expiresAt ? expiresAt - now : Infinity))); } return new Promise((resolve, reject) => { const subscription = this.notification("payment_received", (payment) => { if (payment.payment_hash !== transaction.payment_hash) return; cleanup(); resolve(payment); }); const timeout = expiresAt ? setTimeout(() => { cleanup(); reject(new Error("Invoice expired")); }, expiresAt - now) : undefined; function cleanup() { subscription.unsubscribe(); if (timeout) clearTimeout(timeout); } }); } /** Look up an invoice by payment hash or invoice string */ async lookupInvoice(payment_hash, invoice) { return await this.genericRequest("lookup_invoice", { payment_hash, invoice }); } /** List transactions */ async listTransactions(params) { return await this.genericRequest("list_transactions", params || {}); } /** Get wallet balance */ async getBalance() { return await this.genericRequest("get_balance", {}); } /** Get wallet info */ async getInfo() { return await this.genericRequest("get_info", {}); } /** 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, }); } }