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