UNPKG

applesauce-wallet-connect

Version:

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

294 lines (293 loc) 12.1 kB
import { logger } from "applesauce-core"; import { create } from "applesauce-factory"; import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; import { filter, mergeMap, share } from "rxjs"; import { WalletLegacyNotificationBlueprint, WalletNotificationBlueprint } from "./blueprints/notification.js"; import { WalletResponseBlueprint } from "./blueprints/response.js"; import { WalletSupportBlueprint } from "./blueprints/support.js"; import { WalletBaseError } from "./helpers/error.js"; import { getWalletRequest, isWalletRequestExpired, isWalletRequestLocked, unlockWalletRequest, WALLET_REQUEST_KIND, } from "./helpers/request.js"; import { bytesToHex } from "@noble/hashes/utils"; import { parseWalletAuthURI } from "./helpers/auth-uri.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"); /** 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) { this.secret = options.secret; this.client = getPublicKey(this.secret); } else { this.secret = generateSecretKey(); this.client = getPublicKey(this.secret); } // Get the subscription and publish methods const subscriptionMethod = options.subscriptionMethod || options.pool?.subscription || WalletService.subscriptionMethod || WalletService.pool?.subscription; if (!subscriptionMethod) throw new Error("Missing subscriptionMethod, either pass a method or set WalletService.subscriptionMethod"); const publishMethod = options.publishMethod || options.pool?.publish || WalletService.publishMethod || WalletService.pool?.publish; if (!publishMethod) throw new Error("Missing publishMethod, either pass a method or set WalletService.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); 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$ = 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( // Ignore strings (support for applesauce-relay) filter((event) => typeof event !== "string"), // Only include valid wallet request events filter((event) => event.kind === WALLET_REQUEST_KIND && 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))).subscribe({ error: (error) => { this.log("Error handling wallet request:", error); }, }); // 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.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 draft = await create({ signer: this.signer }, legacy ? WalletLegacyNotificationBlueprint : WalletNotificationBlueprint, this.client, { notification_type: type, notification, }); const event = await this.signer.signEvent(draft); await this.publishMethod(this.relays, event); } /** Publish the wallet support event */ async publishSupportEvent() { try { const draft = await create({ signer: this.signer }, WalletSupportBlueprint, this.support, this.client); const event = await this.signer.signEvent(draft); 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 let request; if (isWalletRequestLocked(requestEvent)) { request = await unlockWalletRequest(requestEvent, this.signer); } else { request = getWalletRequest(requestEvent); } 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]; if (!handler) { await this.sendErrorResponse(requestEvent, "NOT_IMPLEMENTED", `Method ${request.method} not supported`); return; } try { let result; const method = request.method; // Store method for use in catch block switch (method) { case "pay_invoice": result = await handler(request.params); break; case "multi_pay_invoice": result = await handler(request.params); break; case "pay_keysend": result = await handler(request.params); break; case "multi_pay_keysend": result = await handler(request.params); break; case "make_invoice": result = await handler(request.params); break; case "lookup_invoice": result = await handler(request.params); break; case "list_transactions": result = await handler(request.params); break; case "get_balance": result = await handler(request.params); break; case "get_info": result = await handler(request.params); break; } // Send success response await this.sendSuccessResponse(requestEvent, 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 draft = await create({ signer: this.signer }, WalletResponseBlueprint, requestEvent, response); const event = await this.signer.signEvent(draft); 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 { client, relays } = typeof uri === "string" ? parseWalletAuthURI(uri) : uri; return new WalletService({ ...options, client, relays, }); } }