From b41e8a919acd839fe45db005dc9b262ad82598d0 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 12 Dec 2023 13:22:15 +0000 Subject: [PATCH] feat: upgrade wallet support --- packages/app/package.json | 2 +- packages/app/src/Pages/WalletPage.tsx | 67 +++---- .../app/src/Pages/settings/WalletSettings.tsx | 9 +- packages/app/src/Wallet/LNCWallet.ts | 10 +- packages/app/src/Wallet/LNDHub.ts | 37 ++-- packages/app/src/Wallet/NostrWalletConnect.ts | 172 +++++++++++++++--- packages/app/src/Wallet/WebLN.ts | 18 +- packages/app/src/Wallet/index.ts | 5 +- packages/app/src/index.tsx | 2 - packages/app/src/lang.json | 3 - packages/app/src/translations/en.json | 1 - yarn.lock | 10 +- 12 files changed, 242 insertions(+), 94 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index 347c41f3d..47f036676 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -91,7 +91,7 @@ "@typescript-eslint/eslint-plugin": "^6.1.0", "@typescript-eslint/parser": "^6.1.0", "@vitejs/plugin-react": "^4.2.0", - "@webbtc/webln-types": "^1.0.10", + "@webbtc/webln-types": "^2.1.0", "@webscopeio/react-textarea-autocomplete": "^4.9.2", "autoprefixer": "^10.4.16", "config": "^3.3.9", diff --git a/packages/app/src/Pages/WalletPage.tsx b/packages/app/src/Pages/WalletPage.tsx index 927504643..197ce017b 100644 --- a/packages/app/src/Pages/WalletPage.tsx +++ b/packages/app/src/Pages/WalletPage.tsx @@ -1,23 +1,15 @@ import "./WalletPage.css"; import { useEffect, useState } from "react"; -import { RouteObject, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; import NoteTime from "@/Element/Event/NoteTime"; import { WalletInvoice, Sats, WalletInfo, WalletInvoiceState, useWallet, LNWallet, Wallets } from "@/Wallet"; import AsyncButton from "@/Element/Button/AsyncButton"; import { unwrap } from "@/SnortUtils"; -import { WebLNWallet } from "@/Wallet/WebLN"; import Icon from "@/Icons/Icon"; -export const WalletRoutes: RouteObject[] = [ - { - path: "/wallet", - element: , - }, -]; - export default function WalletPage() { const navigate = useNavigate(); const { formatMessage } = useIntl(); @@ -33,10 +25,14 @@ export default function WalletPage() { try { const i = await wallet.getInfo(); setInfo(i); - const b = await wallet.getBalance(); - setBalance(b as Sats); - const h = await wallet.getInvoices(); - setHistory((h as WalletInvoice[]).sort((a, b) => b.timestamp - a.timestamp)); + if (wallet.canGetBalance()) { + const b = await wallet.getBalance(); + setBalance(b as Sats); + } + if (wallet.canGetInvoices()) { + const h = await wallet.getInvoices(); + setHistory((h as WalletInvoice[]).sort((a, b) => b.timestamp - a.timestamp)); + } } catch (e) { if (e instanceof Error) { setError((e as Error).message); @@ -62,11 +58,11 @@ export default function WalletPage() { function stateIcon(s: WalletInvoiceState) { switch (s) { case WalletInvoiceState.Pending: - return ; + return ; case WalletInvoiceState.Paid: - return ; + return ; case WalletInvoiceState.Expired: - return ; + return ; } } @@ -132,7 +128,7 @@ export default function WalletPage() { } function walletHistory() { - if (wallet instanceof WebLNWallet) return null; + if (!wallet?.canGetInvoices()) return; return ( <> @@ -141,12 +137,12 @@ export default function WalletPage() { {history?.map(a => (
-
+
{(a.memo ?? "").length === 0 ? <>  : a.memo}
{ + className={`flex gap-2 items-center ${(() => { switch (a.state) { case WalletInvoiceState.Paid: return "success"; @@ -158,14 +154,16 @@ export default function WalletPage() { return "pending"; } })()}`}> - {stateIcon(a.state)} - , - }} - /> +
{stateIcon(a.state)}
+
+ , + }} + /> +
))} @@ -174,7 +172,7 @@ export default function WalletPage() { } function walletBalance() { - if (wallet instanceof WebLNWallet) return null; + if (!wallet?.canGetBalance()) return; return ( -
-

{info?.alias}

+
+
{info?.alias}
{walletBalance()}
- {/*
- - - -
*/} {walletHistory()} ); @@ -208,8 +201,8 @@ export default function WalletPage() { return (
- {error && {error}} {walletList()} + {error && {error}} {unlockWallet()} {walletInfo()}
diff --git a/packages/app/src/Pages/settings/WalletSettings.tsx b/packages/app/src/Pages/settings/WalletSettings.tsx index ef735a53a..f9112b123 100644 --- a/packages/app/src/Pages/settings/WalletSettings.tsx +++ b/packages/app/src/Pages/settings/WalletSettings.tsx @@ -1,7 +1,7 @@ import "./WalletSettings.css"; import LndLogo from "@/lnd-logo.png"; import { FormattedMessage } from "react-intl"; -import { Link, RouteObject, useNavigate } from "react-router-dom"; +import { RouteObject, useNavigate } from "react-router-dom"; import BlueWallet from "@/Icons/BlueWallet"; import ConnectLNC from "@/Pages/settings/wallet/LNC"; @@ -10,16 +10,13 @@ import ConnectNostrWallet from "@/Pages/settings/wallet/NWC"; import ConnectCashu from "@/Pages/settings/wallet/Cashu"; import NostrIcon from "@/Icons/Nostrich"; +import WalletPage from "../WalletPage"; const WalletSettings = () => { const navigate = useNavigate(); return ( <> - - - +

diff --git a/packages/app/src/Wallet/LNCWallet.ts b/packages/app/src/Wallet/LNCWallet.ts index e67825b2e..6f0184cc6 100644 --- a/packages/app/src/Wallet/LNCWallet.ts +++ b/packages/app/src/Wallet/LNCWallet.ts @@ -32,10 +32,18 @@ export class LNCWallet implements LNWallet { }); } - canAutoLogin(): boolean { + canAutoLogin() { return false; } + canGetInvoices() { + return true; + } + + canGetBalance() { + return true; + } + isReady(): boolean { return this.#lnc.isReady; } diff --git a/packages/app/src/Wallet/LNDHub.ts b/packages/app/src/Wallet/LNDHub.ts index c6bf0f108..79a3f23d7 100644 --- a/packages/app/src/Wallet/LNDHub.ts +++ b/packages/app/src/Wallet/LNDHub.ts @@ -43,7 +43,15 @@ export default class LNDHubWallet implements LNWallet { return this.auth !== undefined; } - canAutoLogin(): boolean { + canAutoLogin() { + return true; + } + + canGetInvoices() { + return true; + } + + canGetBalance() { return true; } @@ -106,18 +114,21 @@ export default class LNDHubWallet implements LNWallet { async getInvoices(): Promise { const rsp = await this.getJson("GET", "/getuserinvoices"); - return (rsp as UserInvoicesResponse[]).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; - }); + 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(method: "GET" | "POST", path: string, body?: unknown): Promise { diff --git a/packages/app/src/Wallet/NostrWalletConnect.ts b/packages/app/src/Wallet/NostrWalletConnect.ts index e62c152ab..708099712 100644 --- a/packages/app/src/Wallet/NostrWalletConnect.ts +++ b/packages/app/src/Wallet/NostrWalletConnect.ts @@ -1,6 +1,15 @@ import { Connection, EventKind, NostrEvent, EventBuilder, PrivateKeySigner } from "@snort/system"; -import { LNWallet, WalletError, WalletErrorCode, WalletInfo, WalletInvoice, WalletInvoiceState } from "@/Wallet"; +import { + InvoiceRequest, + LNWallet, + WalletError, + WalletErrorCode, + WalletInfo, + WalletInvoice, + WalletInvoiceState, +} from "@/Wallet"; import debug from "debug"; +import { dedupe } from "@snort/shared"; interface WalletConnectConfig { relayUrl: string; @@ -30,10 +39,45 @@ interface WalletConnectResponse { }; } +interface GetInfoResponse { + alias?: string; + color?: string; + pubkey?: string; + network?: string; + block_height?: number; + block_hash?: string; + methods?: Array; +} + +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; + metadata?: object; + }>; +} + +interface MakeInvoiceResponse { + invoice: string; + payment_hash: string; +} + +const DefaultSupported = ["get_info", "pay_invoice"]; + export class NostrConnectWallet implements LNWallet { + #log = debug("NWC"); #config: WalletConnectConfig; #conn?: Connection; #commandQueue: Map; + #info?: WalletInfo; + #supported_methods: Array = DefaultSupported; constructor(cfg: string) { this.#config = NostrConnectWallet.parseConfigUrl(cfg); @@ -59,20 +103,43 @@ export class NostrConnectWallet implements LNWallet { async getInfo() { await this.login(); - return await new Promise((resolve, reject) => { - this.#commandQueue.set("info", { - resolve: (o: string) => { - resolve({ - alias: "NWC", - chains: o.split(" "), - } as WalletInfo); - }, - reject, + if (this.#info) return this.#info; + + const rsp = await this.#rpc>("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((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 + }); }); - this.#conn?.QueueReq(["REQ", "info", { kinds: [13194], limit: 1 }], () => { - // ignored - }); - }); + } else { + throw new WalletError(WalletErrorCode.GeneralError, rsp.error.message); + } } async login() { @@ -100,11 +167,33 @@ export class NostrConnectWallet implements LNWallet { } async getBalance() { - return 0; + await this.login(); + const rsp = await this.#rpc>("get_balance", {}); + if (!rsp.error) { + return (rsp.result?.balance ?? 0) / 1000; + } else { + throw new WalletError(WalletErrorCode.GeneralError, rsp.error.message); + } } - createInvoice() { - return Promise.reject(new WalletError(WalletErrorCode.GeneralError, "Not implemented")); + async createInvoice(req: InvoiceRequest) { + await this.login(); + const rsp = await this.#rpc>("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) { @@ -123,8 +212,38 @@ export class NostrConnectWallet implements LNWallet { } } - getInvoices() { - return Promise.resolve([]); + async getInvoices() { + await this.login(); + const rsp = await this.#rpc>("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: a.settled_at, + preimage: a.preimage, + state: WalletInvoiceState.Paid, + }) as WalletInvoice, + ) ?? [] + ); + } else { + throw new WalletError(WalletErrorCode.GeneralError, rsp.error.message); + } + } + + canGetInvoices() { + return this.#supported_methods.includes("list_transactions"); + } + + canGetBalance() { + return this.#supported_methods.includes("get_balance"); } async #onReply(sub: string, e: NostrEvent) { @@ -157,8 +276,19 @@ export class NostrConnectWallet implements LNWallet { this.#conn?.CloseReq(sub); } - async #rpc(method: string, params: Record) { + async #rpc(method: string, params: Record) { 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, @@ -190,7 +320,7 @@ export class NostrConnectWallet implements LNWallet { this.#commandQueue.set(evCommand.id, { resolve: async (o: string) => { const reply = JSON.parse(await signer.nip4Decrypt(o, this.#config.walletPubkey)); - debug("NWC")("%o", reply); + this.#log("< %o", reply); resolve(reply); }, reject, diff --git a/packages/app/src/Wallet/WebLN.ts b/packages/app/src/Wallet/WebLN.ts index 0ef1c2434..6014b09af 100644 --- a/packages/app/src/Wallet/WebLN.ts +++ b/packages/app/src/Wallet/WebLN.ts @@ -12,7 +12,7 @@ import { WalletKind, WalletStore, } from "@/Wallet"; -import { barrierQueue, processWorkQueue, WorkQueueItem } from "@snort/shared"; +import { barrierQueue, processWorkQueue, unwrap, WorkQueueItem } from "@snort/shared"; const WebLNQueue: Array = []; processWorkQueue(WebLNQueue); @@ -75,8 +75,12 @@ export class WebLNWallet implements LNWallet { return Promise.resolve(true); } - getBalance(): Promise { - return Promise.resolve(0); + async getBalance(): Promise { + 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 { @@ -124,4 +128,12 @@ export class WebLNWallet implements LNWallet { getInvoices(): Promise { return Promise.resolve([]); } + + canGetInvoices() { + return false; + } + + canGetBalance() { + return window.webln?.getBalance !== undefined; + } } diff --git a/packages/app/src/Wallet/index.ts b/packages/app/src/Wallet/index.ts index 50d124101..1ca9ffd63 100644 --- a/packages/app/src/Wallet/index.ts +++ b/packages/app/src/Wallet/index.ts @@ -101,7 +101,6 @@ export type MilliSats = number; export interface LNWallet { isReady(): boolean; - canAutoLogin(): boolean; getInfo: () => Promise; login: (password?: string) => Promise; close: () => Promise; @@ -109,6 +108,10 @@ export interface LNWallet { createInvoice: (req: InvoiceRequest) => Promise; payInvoice: (pr: string) => Promise; getInvoices: () => Promise; + + canAutoLogin: () => boolean; + canGetInvoices: () => boolean; + canGetBalance: () => boolean; } export interface WalletConfig { diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index 37e80305b..7d78fc6f5 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -39,7 +39,6 @@ import MessagesPage from "@/Pages/Messages/MessagesPage"; import DonatePage from "@/Pages/DonatePage"; import SearchPage from "@/Pages/SearchPage"; import HelpPage from "@/Pages/HelpPage"; -import { WalletRoutes } from "@/Pages/WalletPage"; import NostrLinkHandler from "@/Pages/NostrLinkHandler"; import { ThreadRoute } from "@/Element/Event/Thread"; import { SubscribeRoutes } from "@/Pages/subscribe"; @@ -275,7 +274,6 @@ const mainRoutes = [ element: , }, ...OnboardingRoutes, - ...WalletRoutes, ] as Array; if (CONFIG.features.zapPool) { diff --git a/packages/app/src/lang.json b/packages/app/src/lang.json index d3d0407d0..59cdba512 100644 --- a/packages/app/src/lang.json +++ b/packages/app/src/lang.json @@ -894,9 +894,6 @@ "VnXp8Z": { "defaultMessage": "Avatar" }, - "VvaJst": { - "defaultMessage": "View Wallets" - }, "W1yoZY": { "defaultMessage": "It looks like you dont have any subscriptions, you can get one {link}" }, diff --git a/packages/app/src/translations/en.json b/packages/app/src/translations/en.json index 0714743af..7d943be08 100644 --- a/packages/app/src/translations/en.json +++ b/packages/app/src/translations/en.json @@ -294,7 +294,6 @@ "VfhYxG": "To see a full list of changes you can view the changelog {here}", "VlJkSk": "{n} muted", "VnXp8Z": "Avatar", - "VvaJst": "View Wallets", "W1yoZY": "It looks like you dont have any subscriptions, you can get one {link}", "W2PiAr": "{n} Blocked", "W9355R": "Unmute", diff --git a/yarn.lock b/yarn.lock index 107a6d2dc..af8ea47d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2911,7 +2911,7 @@ __metadata: "@uidotdev/usehooks": ^2.4.1 "@vitejs/plugin-react": ^4.2.0 "@void-cat/api": ^1.0.10 - "@webbtc/webln-types": ^1.0.10 + "@webbtc/webln-types": ^2.1.0 "@webscopeio/react-textarea-autocomplete": ^4.9.2 autoprefixer: ^10.4.16 classnames: ^2.3.2 @@ -3961,10 +3961,10 @@ __metadata: languageName: node linkType: hard -"@webbtc/webln-types@npm:^1.0.10": - version: 1.0.14 - resolution: "@webbtc/webln-types@npm:1.0.14" - checksum: eaa363bf3a9c278be51c93487904c04518e8812d97449d8d7866089aae74756451a48245a31b1a0fd591bc4798a96a29516ad395b8828c9f2af920cf65a305ac +"@webbtc/webln-types@npm:^2.1.0": + version: 2.1.0 + resolution: "@webbtc/webln-types@npm:2.1.0" + checksum: 71c8ae3fc4e163dfa2271f19216b603f53a6910e65fdb115b1920ef9be4b7fa990d9c1c644900a7e88c69e8a8a8cc2e273aa490903e6064e2f296498cdca53ff languageName: node linkType: hard