UNPKG

applesauce-wallet-connect

Version:

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

287 lines (286 loc) 11.9 kB
import { logger } from "applesauce-core"; import { verifyEvent } from "applesauce-core/helpers/event"; import { generateSecretKey, getPublicKey } from "applesauce-core/helpers/keys"; import { filter, from, mergeMap, repeat, retry, share, tap } from "rxjs"; import { bytesToHex } from "@noble/hashes/utils.js"; import { WalletLegacyNotificationFactory, WalletNotificationFactory } from "./factories/notification.js"; import { WalletResponseFactory } from "./factories/response.js"; import { WalletInfoFactory } from "./factories/support.js"; import { parseWalletAuthURI } from "./helpers/auth-uri.js"; import { NotImplementedError, WalletBaseError } from "./helpers/error.js"; import { getWalletRequest, isValidWalletRequest, isWalletRequestExpired, unlockWalletRequest, WALLET_REQUEST_KIND, } from "./helpers/request.js"; import { getConnectionMethods, } from "./interop.js"; /** NIP-47 Wallet Service implementation */ export class WalletService { /** 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 for subscribing to relays */ subscriptionMethod; /** A method for publishing events */ publishMethod; log = logger.extend("WalletService"); /** A special method for getting the generic wallet information */ getInfo; /** The relays to use for the service */ relays; /** The signer used for creating and unlocking events */ signer; /** Map of method handlers */ handlers; /** Wallet support information */ support; /** The service's public key */ pubkey = null; /** The client's secret key */ secret; /** The client's public key */ client; /** Shared observable for all wallet request events */ events$ = null; /** Subscription to the events observable */ subscription = null; /** Whether the service is currently running */ running = false; constructor(options) { this.relays = options.relays; this.signer = options.signer; this.handlers = options.handlers; // Set the client's secret and public key if (options.secret) { // Service was created with a custom secret this.secret = options.secret; this.client = getPublicKey(this.secret); } else if (options.client) { // Service was restored with only the clients pubkey this.client = options.client; } else { // Generate secret and client pubkey this.secret = generateSecretKey(); this.client = getPublicKey(this.secret); } // Get the subscription and publish methods const { subscriptionMethod, publishMethod } = getConnectionMethods(options, WalletService); // 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); const encryption = []; if (options.signer.nip04) encryption.push("nip04"); if (options.signer.nip44) encryption.push("nip44_v2"); // Ensure there is at least one encryption method if (!encryption.length) throw new Error("No encryption methods supported by signer"); // Build the support infomation based on options this.support = { methods: Object.keys(options.handlers), notifications: options.notifications, encryption, }; } /** Start the wallet service */ async start() { if (this.running) return; this.running = true; // Get our public key this.pubkey = await this.signer.getPublicKey(); // Create shared request observable with ref counting and timer this.events$ = from(this.subscriptionMethod(this.relays, [ { kinds: [WALLET_REQUEST_KIND], "#p": [this.pubkey], // Only requests directed to us authors: [this.client], // Only requests from the client }, ])).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 valid wallet request events filter(isValidWalletRequest), // Ensure they are to our pubkey filter((event) => event.pubkey === this.client), // Verify event signature filter((event) => verifyEvent(event)), // Only create a single subscription to the relays share()); // Subscribe to request events and handle them this.subscription = this.events$ .pipe(mergeMap((requestEvent) => this.handleRequestEvent(requestEvent)), tap({ error: (error) => this.log("Error handling wallet request:", error) }), // Keep listening even if an error is thrown retry(Infinity)) .subscribe(); // Publish wallet support event await this.publishSupportEvent(); } /** Stop the wallet service */ stop() { if (!this.running) return; this.running = false; if (this.subscription) { this.subscription.unsubscribe(); this.subscription = null; } this.events$ = null; } /** Check if the service is running */ isRunning() { return this.running; } /** Get the connection URI for the service */ getConnectURI() { if (!this.secret) throw new Error("Service was not created with a secret"); if (!this.pubkey) throw new Error("Service is not running"); if (!this.relays.length) throw new Error("No relays configured"); const url = new URL(`nostr+walletconnect://${this.pubkey}`); for (const relay of this.relays) { url.searchParams.append("relay", relay); } url.searchParams.set("secret", bytesToHex(this.secret)); return url.toString(); } /** Send a notification to the client */ async notify(type, notification, legacy = false) { const NotificationFactory = legacy ? WalletLegacyNotificationFactory : WalletNotificationFactory; const event = await NotificationFactory.create(this.client, { notification_type: type, notification, }) .as(this.signer) .sign(); await this.publishMethod(this.relays, event); } /** Publish the wallet support event */ async publishSupportEvent() { try { // Tell the client which relay to use if there is only one (for nostr+walletauth URI connections) const overrideRelay = this.relays.length === 1 ? this.relays[0] : undefined; const event = await WalletInfoFactory.create(this.support, this.client, overrideRelay) .as(this.signer) .sign(); await this.publishMethod(this.relays, event); } catch (error) { this.log("Failed to publish wallet support event:", error); throw error; } } /** Handle a wallet request event */ async handleRequestEvent(requestEvent) { try { // Check if the request has expired if (isWalletRequestExpired(requestEvent)) return await this.sendErrorResponse(requestEvent, "OTHER", "Request has expired"); // Unlock the request if needed const request = await unlockWalletRequest(requestEvent, this.signer); if (!request) return await this.sendErrorResponse(requestEvent, "OTHER", "Failed to decrypt or parse request"); // Handle the request based on its method await this.processRequest(requestEvent, request); } catch (error) { this.log("Error processing wallet request:", error); await this.sendErrorResponse(requestEvent, "INTERNAL", "Internal server error"); } } /** Process a decrypted wallet request */ async processRequest(requestEvent, request) { const handler = this.handlers[request.method]; try { let result = undefined; // If the user has not implemented the method if (!handler) { // If its the get_info try to use the builtin getInfo method if (request.method === "get_info") { result = { ...(this.getInfo?.() ?? {}), ...this.support }; } else { // Else throw not supported error throw new NotImplementedError(`Method ${request.method} not supported`); } } // Otherwise use the user provided handler if (!result && handler) result = await handler(request.params); // Throw if failed to get result if (!result) throw new NotImplementedError(`Method ${request.method} not supported`); // Send success response await this.sendSuccessResponse(requestEvent, request.method, result); } catch (error) { this.log(`Error executing ${request.method}:`, error); // Determine error type and message let errorCode = "OTHER"; let errorMessage = "Unknown error"; if (error instanceof WalletBaseError) { errorCode = error.code; errorMessage = error.message; } else if (error instanceof Error) { errorMessage = error.message; } await this.sendErrorResponse(requestEvent, errorCode, errorMessage); } } /** Send a success response */ async sendSuccessResponse(requestEvent, method, result) { const response = { result_type: method, error: null, result, }; await this.sendResponse(requestEvent, response); } /** Send an error response */ async sendErrorResponse(requestEvent, errorType, errorMessage) { const request = getWalletRequest(requestEvent); if (!request) throw new Error("Cant respond to a locked request"); const response = { result_type: request.method, error: { type: errorType, message: errorMessage, }, result: null, }; await this.sendResponse(requestEvent, response); } /** Send a response event */ async sendResponse(requestEvent, response) { try { const event = await WalletResponseFactory.create(requestEvent, response).as(this.signer).sign(); await this.publishMethod(this.relays, event); } catch (error) { this.log("Failed to send response:", error); throw error; } } /** Creates a service for a nostr+walletauth URI */ static fromAuthURI(uri, options) { const authURI = typeof uri === "string" ? parseWalletAuthURI(uri) : uri; const relays = options.overrideRelay ? [typeof options.overrideRelay === "function" ? options.overrideRelay(authURI.relays) : options.overrideRelay] : authURI.relays; return new WalletService({ ...options, client: authURI.client, relays, }); } }