343 lines
9.4 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
}
|
|
}
|