refactor: extract wallet system

This commit is contained in:
2024-04-11 13:26:50 +01:00
parent d1095847d8
commit 8137317bfe
33 changed files with 5252 additions and 4916 deletions

View File

@ -0,0 +1 @@
# wallet

View File

@ -0,0 +1,35 @@
{
"name": "@snort/wallet",
"version": "0.1.0",
"description": "Snort wallet system package",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"module": "src/index.ts",
"repository": "https://git.v0l.io/Kieran/snort",
"author": "v0l",
"license": "MIT",
"scripts": {
"build": "rm -rf dist && tsc",
"test": "jest --runInBand"
},
"files": [
"src",
"dist"
],
"packageManager": "yarn@4.1.1",
"dependencies": {
"@cashu/cashu-ts": "^1.0.0-rc.3",
"@lightninglabs/lnc-web": "^0.3.1-alpha",
"@scure/base": "^1.1.6",
"@snort/shared": "workspace:^",
"@snort/system": "workspace:^",
"debug": "^4.3.4",
"eventemitter3": "^5.0.1"
},
"devDependencies": {
"@types/debug": "^4.1.12",
"@webbtc/webln-types": "^3.0.0",
"typescript": "^5.4.5"
}
}

View File

@ -0,0 +1,202 @@
import { base64 } from "@scure/base";
import { unixNow, unwrap } from "@snort/shared";
import {
InvoiceRequest,
LNWallet,
prToWalletInvoice,
WalletError,
WalletErrorCode,
WalletEvents,
WalletInfo,
WalletInvoice,
WalletInvoiceState,
} from ".";
import EventEmitter from "eventemitter3";
export interface OAuthToken {
access_token: string;
created_at: number;
expires_in: number;
refresh_token: string;
scope: string;
token_type: string;
clientId: string;
clientSecret: string;
}
export default class AlbyWallet extends EventEmitter<WalletEvents> implements LNWallet {
#token: OAuthToken;
constructor(token: OAuthToken) {
super();
this.#token = token;
}
isReady() {
return true;
}
canAutoLogin() {
return true;
}
canGetInvoices() {
return this.#token.scope.includes("invoices:read");
}
canGetBalance() {
return this.#token.scope.includes("balance:read");
}
canCreateInvoice() {
return true;
}
canPayInvoice() {
return true;
}
async getInfo() {
const me = await this.#fetch<GetUserResponse>("/user/me");
return { alias: me.lightning_address } as WalletInfo;
}
async login() {
return true;
}
close() {
return Promise.resolve(true);
}
async getBalance() {
await this.#refreshToken();
const bal = await this.#fetch<GetBalanceResponse>("/balance");
return bal.balance;
}
async createInvoice(req: InvoiceRequest) {
const inv = await this.#fetch<CreateInvoiceResponse>("/invoices", "POST", {
amount: req.amount,
memo: req.memo,
});
return unwrap(prToWalletInvoice(inv.payment_request));
}
async payInvoice(pr: string) {
const pay = await this.#fetch<PayInvoiceResponse>("/payments/bolt11", "POST", {
invoice: pr,
});
return {
...prToWalletInvoice(pay.payment_request),
fees: pay.fee,
preimage: pay.payment_preimage,
state: WalletInvoiceState.Paid,
direction: "out",
} as WalletInvoice;
}
async getInvoices() {
const invoices = await this.#fetch<Array<GetInvoiceResponse>>("/invoices?page=1&items=20");
return invoices.map(a => {
return {
...prToWalletInvoice(a.payment_request),
memo: a.comment,
preimage: a.preimage,
state: a.settled ? WalletInvoiceState.Paid : WalletInvoiceState.Pending,
direction: a.type === "incoming" ? "in" : "out",
} as WalletInvoice;
});
}
async #fetch<T>(path: string, method: "GET" | "POST" = "GET", body?: object) {
const req = await fetch(`https://api.getalby.com${path}`, {
method: method,
body: body ? JSON.stringify(body) : undefined,
headers: {
accept: "application/json",
authorization: `Bearer ${this.#token.access_token}`,
...(body ? { "content-type": "application/json" } : {}),
},
});
const json = await req.text();
if (req.ok) {
return JSON.parse(json) as T;
} else {
if (json.length > 0) {
throw new WalletError(WalletErrorCode.GeneralError, JSON.parse(json).message as string);
} else {
throw new WalletError(WalletErrorCode.GeneralError, `Error: ${json} (${req.status})`);
}
}
}
async #refreshToken() {
if (this.#token.created_at + this.#token.expires_in < unixNow()) {
const params = new URLSearchParams();
params.set("refresh_token", this.#token.refresh_token);
params.set("grant_type", "refresh_token");
const req = await fetch("https://api.getalby.com/oauth/token", {
method: "POST",
body: params,
headers: {
accept: "application/json",
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${base64.encode(
new TextEncoder().encode(`${this.#token.clientId}:${this.#token.clientSecret}`),
)}`,
},
});
const json = await req.json();
if (req.ok) {
this.#token = {
...(json as OAuthToken),
created_at: unixNow(),
};
this.emit("change", JSON.stringify(this.#token));
}
}
}
}
interface GetBalanceResponse {
balance: number;
currency: string;
unit: string;
}
interface CreateInvoiceResponse {
expires_at: string;
payment_hash: string;
payment_request: string;
}
interface PayInvoiceResponse {
amount: number;
description?: string;
destination: string;
fee: number;
payment_hash: string;
payment_preimage: string;
payment_request: string;
}
interface GetInvoiceResponse {
amount: number;
comment?: string;
created_at: string;
creation_date: number;
currency: string;
expires_at: string;
preimage: string;
payment_request: string;
settled: boolean;
settled_at: string;
type: "incoming" | "outgoing";
}
interface GetUserResponse {
lightning_address: string;
}

View File

@ -0,0 +1,104 @@
import { CashuMint, Proof } from "@cashu/cashu-ts";
import { InvoiceRequest, LNWallet, WalletEvents, WalletInfo, WalletInvoice } from ".";
import EventEmitter from "eventemitter3";
export type CashuWalletConfig = {
url: string;
keys: Record<string, string>;
keysets: Array<string>;
proofs: Array<Proof>;
};
export class CashuWallet extends EventEmitter<WalletEvents> implements LNWallet {
#wallet: CashuWalletConfig;
#mint: CashuMint;
constructor(wallet: CashuWalletConfig) {
super();
this.#wallet = wallet;
this.#mint = new CashuMint(this.#wallet.url);
}
getConfig() {
return { ...this.#wallet };
}
canGetInvoices() {
return false;
}
canGetBalance() {
return true;
}
canAutoLogin() {
return true;
}
isReady() {
return true;
}
canCreateInvoice() {
return true;
}
canPayInvoice() {
return true;
}
async getInfo() {
return {
alias: "Cashu mint: " + this.#wallet.url,
} as WalletInfo;
}
async login(): Promise<boolean> {
if (this.#wallet.keysets.length === 0) {
const keys = await this.#mint.getKeys();
this.#wallet.keys = keys;
this.#wallet.keysets = [""];
this.onChange(this.#wallet);
}
await this.#checkProofs();
return true;
}
close(): Promise<boolean> {
return Promise.resolve(true);
}
async getBalance() {
return this.#wallet.proofs.reduce((acc, v) => (acc += v.amount), 0);
}
async createInvoice(req: InvoiceRequest) {
const rsp = await this.#mint.requestMint(req.amount);
return {
pr: rsp.pr,
} as WalletInvoice;
}
payInvoice(): Promise<WalletInvoice> {
throw new Error("Method not implemented.");
}
getInvoices(): Promise<WalletInvoice[]> {
return Promise.resolve([]);
}
async #checkProofs() {
if (this.#wallet.proofs.length == 0) return;
const checks = await this.#mint.check({
proofs: this.#wallet.proofs.map(a => ({ secret: a.secret })),
});
const filteredProofs = this.#wallet.proofs.filter((_, i) => checks.spendable[i]);
this.#wallet.proofs = filteredProofs;
if (filteredProofs.length !== checks.spendable.length) {
this.emit("change", JSON.stringify(this.#wallet));
}
}
}

View File

@ -0,0 +1,182 @@
import LNC from "@lightninglabs/lnc-web";
import debug from "debug";
import {
InvoiceRequest,
LNWallet,
Login,
prToWalletInvoice,
WalletError,
WalletErrorCode,
WalletEvents,
WalletInfo,
WalletInvoice,
WalletInvoiceState,
} from ".";
import { unwrap } from "@snort/shared";
import EventEmitter from "eventemitter3";
enum Payment_PaymentStatus {
UNKNOWN = "UNKNOWN",
IN_FLIGHT = "IN_FLIGHT",
SUCCEEDED = "SUCCEEDED",
FAILED = "FAILED",
UNRECOGNIZED = "UNRECOGNIZED",
}
export class LNCWallet extends EventEmitter<WalletEvents> implements LNWallet {
#lnc: LNC;
readonly #log = debug("LNC");
private constructor(pairingPhrase?: string, password?: string) {
super();
this.#lnc = new LNC({
pairingPhrase,
password,
});
}
canAutoLogin() {
return false;
}
canGetInvoices() {
return true;
}
canGetBalance() {
return true;
}
canCreateInvoice() {
return true;
}
canPayInvoice() {
return true;
}
isReady(): boolean {
return this.#lnc.isReady;
}
static async Initialize(pairingPhrase: string) {
const lnc = new LNCWallet(pairingPhrase);
await lnc.login();
return lnc;
}
static Empty() {
return new LNCWallet();
}
setPassword(pw: string) {
if (this.#lnc.credentials.password && pw !== this.#lnc.credentials.password) {
throw new WalletError(WalletErrorCode.GeneralError, "Password is already set, cannot update password");
}
this.#lnc.credentials.password = pw;
}
createAccount(): Promise<WalletError | Login> {
throw new Error("Not implemented");
}
async getInfo(): Promise<WalletInfo> {
const nodeInfo = await this.#lnc.lnd.lightning.getInfo();
return {
nodePubKey: nodeInfo.identityPubkey,
alias: nodeInfo.alias,
} as WalletInfo;
}
close(): Promise<boolean> {
if (this.#lnc.isConnected) {
this.#lnc.disconnect();
}
return Promise.resolve(true);
}
async login(password?: string): Promise<boolean> {
if (password) {
this.setPassword(password);
this.#lnc.run();
}
await this.#lnc.connect();
while (!this.#lnc.isConnected) {
await new Promise(resolve => {
setTimeout(resolve, 100);
});
}
return true;
}
async getBalance(): Promise<number> {
const rsp = await this.#lnc.lnd.lightning.channelBalance();
this.#log(rsp);
return parseInt(rsp.localBalance?.sat ?? "0");
}
async createInvoice(req: InvoiceRequest): Promise<WalletInvoice> {
const rsp = await this.#lnc.lnd.lightning.addInvoice({
memo: req.memo,
value: req.amount.toString(),
expiry: req.expiry?.toString(),
});
return unwrap(prToWalletInvoice(rsp.paymentRequest));
}
async payInvoice(pr: string): Promise<WalletInvoice> {
return new Promise((resolve, reject) => {
this.#lnc.lnd.router.sendPaymentV2(
{
paymentRequest: pr,
timeoutSeconds: 60,
feeLimitSat: "100",
},
msg => {
this.#log(msg);
if (msg.status === Payment_PaymentStatus.SUCCEEDED) {
resolve({
preimage: msg.paymentPreimage,
state: WalletInvoiceState.Paid,
timestamp: parseInt(msg.creationTimeNs) / 1e9,
} as WalletInvoice);
}
},
err => {
this.#log(err);
reject(err);
},
);
});
}
async getInvoices(): Promise<WalletInvoice[]> {
const invoices = await this.#lnc.lnd.lightning.listPayments({
maxPayments: "10",
reversed: true,
});
this.#log(invoices);
return invoices.payments.map(a => {
const parsedInvoice = prToWalletInvoice(a.paymentRequest);
if (!parsedInvoice) {
throw new WalletError(WalletErrorCode.InvalidInvoice, `Could not parse ${a.paymentRequest}`);
}
return {
...parsedInvoice,
state: (() => {
switch (a.status) {
case Payment_PaymentStatus.SUCCEEDED:
return WalletInvoiceState.Paid;
case Payment_PaymentStatus.FAILED:
return WalletInvoiceState.Failed;
default:
return WalletInvoiceState.Pending;
}
})(),
preimage: a.paymentPreimage,
} as WalletInvoice;
});
}
}

View File

@ -0,0 +1,209 @@
import { throwIfOffline } from "@snort/shared";
import {
InvoiceRequest,
LNWallet,
prToWalletInvoice,
Sats,
WalletError,
WalletErrorCode,
WalletEvents,
WalletInfo,
WalletInvoice,
WalletInvoiceState,
} from ".";
import EventEmitter from "eventemitter3";
const defaultHeaders = {
Accept: "application/json",
"Content-Type": "application/json",
};
export default class LNDHubWallet extends EventEmitter<WalletEvents> implements LNWallet {
type: "lndhub";
url: URL;
user: string;
password: string;
auth?: AuthResponse;
constructor(url: string) {
super();
if (url.startsWith("lndhub://")) {
const regex = /^lndhub:\/\/([\S-]+):([\S-]+)@(.*)$/i;
const parsedUrl = url.match(regex);
if (!parsedUrl || parsedUrl.length !== 4) {
throw new Error("Invalid LNDHUB config");
}
this.url = new URL(parsedUrl[3]);
this.user = parsedUrl[1];
this.password = parsedUrl[2];
this.type = "lndhub";
} else {
throw new Error("Invalid config");
}
}
isReady(): boolean {
return this.auth !== undefined;
}
canAutoLogin() {
return true;
}
canGetInvoices() {
return true;
}
canGetBalance() {
return true;
}
canCreateInvoice() {
return true;
}
canPayInvoice() {
return true;
}
close(): Promise<boolean> {
return Promise.resolve(true);
}
async getInfo() {
await this.login();
return await this.getJson<WalletInfo>("GET", "/getinfo");
}
async login() {
if (this.auth) return true;
const rsp = await this.getJson<AuthResponse>("POST", "/auth?type=auth", {
login: this.user,
password: this.password,
});
this.auth = rsp as AuthResponse;
this.emit("change");
return true;
}
async getBalance(): Promise<Sats> {
await this.login();
const rsp = await this.getJson<GetBalanceResponse>("GET", "/balance");
const bal = Math.floor((rsp as GetBalanceResponse).BTC.AvailableBalance);
return bal as Sats;
}
async createInvoice(req: InvoiceRequest) {
await this.login();
const rsp = await this.getJson<UserInvoicesResponse>("POST", "/addinvoice", {
amt: req.amount,
memo: req.memo,
});
const pRsp = rsp as UserInvoicesResponse;
return {
pr: pRsp.payment_request,
memo: req.memo,
amount: req.amount,
paymentHash: pRsp.payment_hash,
timestamp: pRsp.timestamp,
} as WalletInvoice;
}
async payInvoice(pr: string) {
await this.login();
const rsp = await this.getJson<PayInvoiceResponse>("POST", "/payinvoice", {
invoice: pr,
});
const pRsp = rsp as PayInvoiceResponse;
return {
pr: pr,
paymentHash: pRsp.payment_hash,
preimage: pRsp.payment_preimage,
state: pRsp.payment_error
? WalletInvoiceState.Failed
: pRsp.payment_preimage
? WalletInvoiceState.Paid
: WalletInvoiceState.Pending,
} as WalletInvoice;
}
async getInvoices(): Promise<WalletInvoice[]> {
await this.login();
const rsp = await this.getJson<UserInvoicesResponse[]>("GET", "/getuserinvoices");
return (rsp as UserInvoicesResponse[])
.sort((a, b) => (a.timestamp > b.timestamp ? -1 : 1))
.slice(0, 50)
.map(a => {
const decodedInvoice = prToWalletInvoice(a.payment_request);
if (!decodedInvoice) {
throw new WalletError(WalletErrorCode.InvalidInvoice, "Failed to parse invoice");
}
return {
...decodedInvoice,
state: a.ispaid ? WalletInvoiceState.Paid : decodedInvoice.state,
paymentHash: a.payment_hash,
memo: a.description,
} as WalletInvoice;
});
}
private async getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
throwIfOffline();
const auth = `Bearer ${this.auth?.access_token}`;
const url = `${this.url.pathname === "/" ? this.url.toString().slice(0, -1) : this.url.toString()}${path}`;
const rsp = await fetch(url, {
method: method,
body: body ? JSON.stringify(body) : undefined,
headers: {
...defaultHeaders,
Authorization: auth,
},
});
const json = await rsp.json();
if ("code" in json && !rsp.ok) {
const err = json as ErrorResponse;
throw new WalletError(err.code, err.message);
}
return json as T;
}
}
interface AuthResponse {
refresh_token?: string;
access_token?: string;
token_type?: string;
}
interface GetBalanceResponse {
BTC: {
AvailableBalance: number;
};
}
interface UserInvoicesResponse {
amt: number;
description: string;
ispaid: boolean;
type: string;
timestamp: number;
pay_req: string;
payment_hash: string;
payment_request: string;
r_hash: string;
}
interface PayInvoiceResponse {
payment_error?: string;
payment_hash: string;
payment_preimage: string;
payment_route?: { total_amt: number; total_fees: number };
}
interface ErrorResponse {
code: number;
message: string;
}

View File

@ -0,0 +1,349 @@
/* 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?.queueReq(["REQ", "info", { kinds: [13194], limit: 1 }], () => {
// ignored
});
});
} 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?.closeReq(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.queueReq(
[
"REQ",
evCommand.id.slice(0, 12),
{
kinds: [23195 as EventKind],
authors: [this.#config.walletPubkey],
["#e"]: [evCommand.id],
},
],
() => {
// ignored
},
);
await this.#conn.sendEventAsync(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,
});
});
}
}

View File

@ -0,0 +1,127 @@
import { barrierQueue, processWorkQueue, unwrap, WorkQueueItem } from "@snort/shared";
import {
InvoiceRequest,
LNWallet,
prToWalletInvoice,
Sats,
WalletError,
WalletErrorCode,
WalletEvents,
WalletInfo,
WalletInvoice,
WalletInvoiceState,
} from ".";
import EventEmitter from "eventemitter3";
const WebLNQueue: Array<WorkQueueItem> = [];
processWorkQueue(WebLNQueue);
export class WebLNWallet extends EventEmitter<WalletEvents> implements LNWallet {
isReady(): boolean {
return window.webln !== undefined && window.webln !== null;
}
canCreateInvoice() {
return true;
}
canPayInvoice() {
return true;
}
canGetInvoices() {
return false;
}
canGetBalance() {
return window.webln?.getBalance !== undefined;
}
canAutoLogin(): boolean {
return true;
}
async getInfo(): Promise<WalletInfo> {
await this.login();
if (this.isReady()) {
const rsp = await barrierQueue(WebLNQueue, async () => await window.webln?.getInfo());
if (rsp) {
return {
nodePubKey: rsp.node.pubkey,
alias: rsp.node.alias,
} as WalletInfo;
} else {
throw new WalletError(WalletErrorCode.GeneralError, "Could not load wallet info");
}
}
throw new WalletError(WalletErrorCode.GeneralError, "WebLN not available");
}
async login(): Promise<boolean> {
if (window.webln) {
await window.webln.enable();
}
return true;
}
close(): Promise<boolean> {
return Promise.resolve(true);
}
async getBalance(): Promise<Sats> {
await this.login();
if (window.webln?.getBalance) {
const rsp = await barrierQueue(WebLNQueue, async () => await unwrap(window.webln?.getBalance).call(window.webln));
return rsp.balance;
}
return 0;
}
async createInvoice(req: InvoiceRequest): Promise<WalletInvoice> {
await this.login();
if (this.isReady()) {
const rsp = await barrierQueue(
WebLNQueue,
async () =>
await window.webln?.makeInvoice({
amount: req.amount,
defaultMemo: req.memo,
}),
);
if (rsp) {
const invoice = prToWalletInvoice(rsp.paymentRequest);
if (!invoice) {
throw new WalletError(WalletErrorCode.InvalidInvoice, "Could not parse invoice");
}
return invoice;
}
}
throw new WalletError(WalletErrorCode.GeneralError, "WebLN not available");
}
async payInvoice(pr: string): Promise<WalletInvoice> {
await this.login();
if (this.isReady()) {
const invoice = prToWalletInvoice(pr);
if (!invoice) {
throw new WalletError(WalletErrorCode.InvalidInvoice, "Could not parse invoice");
}
const rsp = await barrierQueue(WebLNQueue, async () => await window.webln?.sendPayment(pr));
if (rsp) {
invoice.state = WalletInvoiceState.Paid;
invoice.preimage = rsp.preimage;
invoice.fees = "route" in rsp ? (rsp.route as { total_fees: number }).total_fees : 0;
return invoice;
} else {
invoice.state = WalletInvoiceState.Failed;
return invoice;
}
}
throw new WalletError(WalletErrorCode.GeneralError, "WebLN not available");
}
getInvoices(): Promise<WalletInvoice[]> {
return Promise.resolve([]);
}
}

1
packages/wallet/src/custom.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="@webbtc/webln-types" />

View File

@ -0,0 +1,155 @@
import { decodeInvoice, unwrap } from "@snort/shared";
import AlbyWallet from "./AlbyWallet";
import { CashuWallet } from "./Cashu";
import { LNCWallet } from "./LNCWallet";
import LNDHubWallet from "./LNDHub";
import { NostrConnectWallet } from "./NostrWalletConnect";
import { WebLNWallet } from "./WebLN";
import EventEmitter from "eventemitter3";
export enum WalletKind {
LNDHub = 1,
LNC = 2,
WebLN = 3,
NWC = 4,
Cashu = 5,
Alby = 6,
}
export enum WalletErrorCode {
BadAuth = 1,
NotEnoughBalance = 2,
BadPartner = 3,
InvalidInvoice = 4,
RouteNotFound = 5,
GeneralError = 6,
NodeFailure = 7,
}
export class WalletError extends Error {
code: WalletErrorCode;
constructor(c: WalletErrorCode, msg: string) {
super(msg);
this.code = c;
}
}
export const UnknownWalletError = {
code: WalletErrorCode.GeneralError,
message: "Unknown error",
} as WalletError;
export interface WalletInfo {
fee: number;
nodePubKey: string;
alias: string;
pendingChannels: number;
activeChannels: number;
peers: number;
blockHeight: number;
blockHash: string;
synced: boolean;
chains: string[];
version: string;
}
export interface Login {
service: string;
save: () => Promise<void>;
load: () => Promise<void>;
}
export interface InvoiceRequest {
amount: Sats;
memo?: string;
expiry?: number;
}
export enum WalletInvoiceState {
Pending = 0,
Paid = 1,
Expired = 2,
Failed = 3,
}
export interface WalletInvoice {
pr: string;
paymentHash: string;
memo: string;
amount: MilliSats;
fees: number;
timestamp: number;
preimage?: string;
state: WalletInvoiceState;
direction: "in" | "out";
}
export function prToWalletInvoice(pr: string) {
const parsedInvoice = decodeInvoice(pr);
if (parsedInvoice) {
return {
amount: parsedInvoice.amount ?? 0,
memo: parsedInvoice.description,
paymentHash: parsedInvoice.paymentHash ?? "",
timestamp: parsedInvoice.timestamp ?? 0,
state: parsedInvoice.expired ? WalletInvoiceState.Expired : WalletInvoiceState.Pending,
pr,
direction: "in",
} as WalletInvoice;
}
}
export type Sats = number;
export type MilliSats = number;
export interface WalletEvents {
change: (data?: string) => void
}
export type LNWallet = EventEmitter<WalletEvents> & {
isReady(): boolean;
getInfo: () => Promise<WalletInfo>;
login: (password?: string) => Promise<boolean>;
close: () => Promise<boolean>;
getBalance: () => Promise<Sats>;
createInvoice: (req: InvoiceRequest) => Promise<WalletInvoice>;
payInvoice: (pr: string) => Promise<WalletInvoice>;
getInvoices: () => Promise<WalletInvoice[]>;
canAutoLogin: () => boolean;
canGetInvoices: () => boolean;
canGetBalance: () => boolean;
canCreateInvoice: () => boolean;
canPayInvoice: () => boolean;
}
/**
* Load wallet by kind
* @param kind The wallet kind to create
* @param data Opaque data
*/
export function loadWallet(kind: WalletKind, data: string | undefined) {
switch (kind) {
case WalletKind.LNC: {
return LNCWallet.Empty();
}
case WalletKind.WebLN: {
return new WebLNWallet();
}
case WalletKind.LNDHub: {
return new LNDHubWallet(unwrap(data));
}
case WalletKind.NWC: {
return new NostrConnectWallet(unwrap(data));
}
case WalletKind.Alby: {
return new AlbyWallet(JSON.parse(unwrap(data)));
}
case WalletKind.Cashu: {
return new CashuWallet(JSON.parse(unwrap(data)));
}
}
}
export { LNCWallet, WebLNWallet, LNDHubWallet, NostrConnectWallet, AlbyWallet, CashuWallet }

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"baseUrl": "src",
"target": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"noImplicitOverride": true,
"module": "ESNext",
"strict": true,
"declaration": true,
"declarationMap": true,
"inlineSourceMap": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["./src/**/*.ts"],
"exclude": ["**/*.test.ts"]
}

View File

@ -0,0 +1,3 @@
{
"entryPoints": ["src/index.ts"]
}