applesauce-wallet-connect
Version:
NIP-47 Nostr Wallet Connect implementation for both clients and services.
294 lines (293 loc) • 12.1 kB
JavaScript
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,
});
}
}