snort/packages/wallet/src/NostrWalletConnect.ts

343 lines
9.4 KiB
TypeScript

/* eslint-disable max-lines */
import { dedupe } from "@snort/shared";
import { Connection, EventBuilder, EventKind, NostrEvent, PrivateKeySigner } from "@snort/system";
import debug from "debug";
import {
InvoiceRequest,
LNWallet,
WalletError,
WalletErrorCode,
WalletEvents,
WalletInfo,
WalletInvoice,
WalletInvoiceState,
} from ".";
import EventEmitter from "eventemitter3";
interface WalletConnectConfig {
relayUrl: string;
walletPubkey: string;
secret: string;
}
interface QueueObj {
resolve: (o: string) => void;
reject: (e: Error) => void;
}
interface WalletConnectResponse<T> {
result_type?: string;
result?: T;
error?: {
code:
| "RATE_LIMITED"
| "NOT_IMPLEMENTED"
| "INSUFFICIENT_BALANCE"
| "QUOTA_EXCEEDED"
| "RESTRICTED"
| "UNAUTHORIZED"
| "INTERNAL"
| "OTHER";
message: string;
};
}
interface GetInfoResponse {
alias?: string;
color?: string;
pubkey?: string;
network?: string;
block_height?: number;
block_hash?: string;
methods?: Array<string>;
}
interface ListTransactionsResponse {
transactions: Array<{
type: "incoming" | "outgoing";
invoice: string;
description?: string;
description_hash?: string;
preimage?: string;
payment_hash?: string;
amount: number;
feed_paid: number;
settled_at?: number;
created_at: number;
expires_at: number;
metadata?: object;
}>;
}
interface MakeInvoiceResponse {
invoice: string;
payment_hash: string;
}
const DefaultSupported = ["get_info", "pay_invoice"];
export class NostrConnectWallet extends EventEmitter<WalletEvents> implements LNWallet {
#log = debug("NWC");
#config: WalletConnectConfig;
#conn?: Connection;
#commandQueue: Map<string, QueueObj>;
#info?: WalletInfo;
#supported_methods: Array<string> = DefaultSupported;
constructor(cfg: string) {
super();
this.#config = NostrConnectWallet.parseConfigUrl(cfg);
this.#commandQueue = new Map();
}
static parseConfigUrl(url: string) {
const uri = new URL(url.replace("nostrwalletconnect://", "http://").replace("nostr+walletconnect://", "http://"));
return {
relayUrl: uri.searchParams.get("relay"),
walletPubkey: uri.host,
secret: uri.searchParams.get("secret"),
} as WalletConnectConfig;
}
canAutoLogin(): boolean {
return true;
}
isReady(): boolean {
return this.#conn !== undefined;
}
canGetInvoices() {
return this.#supported_methods.includes("list_transactions");
}
canGetBalance() {
return this.#supported_methods.includes("get_balance");
}
canCreateInvoice() {
return this.#supported_methods.includes("make_invoice");
}
canPayInvoice() {
return this.#supported_methods.includes("pay_invoice");
}
async getInfo() {
await this.login();
if (this.#info) return this.#info;
const rsp = await this.#rpc<WalletConnectResponse<GetInfoResponse>>("get_info", {});
if (!rsp.error) {
this.#supported_methods = dedupe(["get_info", ...(rsp.result?.methods ?? DefaultSupported)]);
this.#log("Supported methods: %o", this.#supported_methods);
const info = {
nodePubKey: rsp.result?.pubkey,
alias: rsp.result?.alias,
blockHeight: rsp.result?.block_height,
blockHash: rsp.result?.block_hash,
chains: rsp.result?.network ? [rsp.result.network] : undefined,
} as WalletInfo;
this.#info = info;
return info;
} else if (rsp.error.code === "NOT_IMPLEMENTED") {
// legacy get_info uses event kind 13_194
return await new Promise<WalletInfo>((resolve, reject) => {
this.#commandQueue.set("info", {
resolve: (o: string) => {
this.#supported_methods = dedupe(["get_info", ...o.split(",")]);
this.#log("Supported methods: %o", this.#supported_methods);
const info = {
alias: "NWC",
} as WalletInfo;
this.#info = info;
resolve(info);
},
reject,
});
this.#conn?.request(["REQ", "info", { kinds: [13194], limit: 1 }]);
});
} else {
throw new WalletError(WalletErrorCode.GeneralError, rsp.error.message);
}
}
async login() {
if (this.#conn) return true;
await new Promise<void>(resolve => {
this.#conn = new Connection(this.#config.relayUrl, { read: true, write: true });
this.#conn.on("connected", () => resolve());
this.#conn.on("auth", async (c, r, cb) => {
const eb = new EventBuilder();
eb.kind(EventKind.Auth).tag(["relay", r]).tag(["challenge", c]);
const ev = await eb.buildAndSign(this.#config.secret);
cb(ev);
});
this.#conn.on("event", (s, e) => {
this.#onReply(s, e);
});
this.#conn.connect();
});
await this.getInfo();
this.emit("change");
return true;
}
async close() {
this.#conn?.close();
return true;
}
async getBalance() {
await this.login();
const rsp = await this.#rpc<WalletConnectResponse<{ balance: number }>>("get_balance", {});
if (!rsp.error) {
return (rsp.result?.balance ?? 0) / 1000;
} else {
throw new WalletError(WalletErrorCode.GeneralError, rsp.error.message);
}
}
async createInvoice(req: InvoiceRequest) {
await this.login();
const rsp = await this.#rpc<WalletConnectResponse<MakeInvoiceResponse>>("make_invoice", {
amount: req.amount * 1000,
description: req.memo,
expiry: req.expiry,
});
if (!rsp.error) {
return {
pr: rsp.result?.invoice,
paymentHash: rsp.result?.payment_hash,
memo: req.memo,
amount: req.amount * 1000,
state: WalletInvoiceState.Pending,
} as WalletInvoice;
} else {
throw new WalletError(WalletErrorCode.GeneralError, rsp.error.message);
}
}
async payInvoice(pr: string) {
await this.login();
const rsp = await this.#rpc<WalletConnectResponse<WalletInvoice>>("pay_invoice", {
invoice: pr,
});
if (!rsp.error) {
return {
...rsp.result,
pr,
state: WalletInvoiceState.Paid,
} as WalletInvoice;
} else {
throw new WalletError(WalletErrorCode.GeneralError, rsp.error.message);
}
}
async getInvoices() {
await this.login();
const rsp = await this.#rpc<WalletConnectResponse<ListTransactionsResponse>>("list_transactions", {
limit: 50,
});
if (!rsp.error) {
return (
rsp.result?.transactions.map(
a =>
({
pr: a.invoice,
paymentHash: a.payment_hash,
memo: a.description,
amount: a.amount,
fees: a.feed_paid,
timestamp: typeof a.created_at === "string" ? new Date(a.created_at).getTime() / 1000 : a.created_at,
preimage: a.preimage,
state: WalletInvoiceState.Paid,
direction: a.type === "incoming" ? "in" : "out",
}) as WalletInvoice,
) ?? []
);
} else {
throw new WalletError(WalletErrorCode.GeneralError, rsp.error.message);
}
}
async #onReply(sub: string, e: NostrEvent) {
if (sub === "info") {
const pending = this.#commandQueue.get("info");
if (!pending) {
throw new WalletError(WalletErrorCode.GeneralError, "No pending info command found");
}
pending.resolve(e.content);
this.#commandQueue.delete("info");
return;
}
if (e.kind !== 23195) {
throw new WalletError(WalletErrorCode.GeneralError, "Unknown event kind");
}
const replyTo = e.tags.find(a => a[0] === "e");
if (!replyTo) {
throw new WalletError(WalletErrorCode.GeneralError, "Missing e-tag in command response");
}
const pending = this.#commandQueue.get(replyTo[1]);
if (!pending) {
throw new WalletError(WalletErrorCode.GeneralError, "No pending command found");
}
pending.resolve(e.content);
this.#commandQueue.delete(replyTo[1]);
this.#conn?.closeRequest(sub);
}
async #rpc<T>(method: string, params: Record<string, string | number | undefined>) {
if (!this.#conn) throw new WalletError(WalletErrorCode.GeneralError, "Not implemented");
this.#log("> %o", { method, params });
if (!this.#supported_methods.includes(method)) {
const ret = {
error: {
code: "NOT_IMPLEMENTED",
message: `get_info claims the method "${method}" is not supported`,
},
} as T;
this.#log("< %o", ret);
return ret;
}
const payload = JSON.stringify({
method,
params,
});
const signer = new PrivateKeySigner(this.#config.secret);
const eb = new EventBuilder();
eb.kind(23194 as EventKind)
.content(await signer.nip4Encrypt(payload, this.#config.walletPubkey))
.tag(["p", this.#config.walletPubkey]);
const evCommand = await eb.buildAndSign(this.#config.secret);
this.#conn.request([
"REQ",
evCommand.id.slice(0, 12),
{
kinds: [23195 as EventKind],
authors: [this.#config.walletPubkey],
["#e"]: [evCommand.id],
},
]);
await this.#conn.publish(evCommand);
return await new Promise<T>((resolve, reject) => {
this.#commandQueue.set(evCommand.id, {
resolve: async (o: string) => {
const reply = JSON.parse(await signer.nip4Decrypt(o, this.#config.walletPubkey));
this.#log("< %o", reply);
resolve(reply);
},
reject,
});
});
}
}