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