feat: upgrade wallet support

This commit is contained in:
Kieran 2023-12-12 13:22:15 +00:00
parent 6951383045
commit b41e8a919a
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
12 changed files with 242 additions and 94 deletions

View File

@ -91,7 +91,7 @@
"@typescript-eslint/eslint-plugin": "^6.1.0", "@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0", "@typescript-eslint/parser": "^6.1.0",
"@vitejs/plugin-react": "^4.2.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", "@webscopeio/react-textarea-autocomplete": "^4.9.2",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"config": "^3.3.9", "config": "^3.3.9",

View File

@ -1,23 +1,15 @@
import "./WalletPage.css"; import "./WalletPage.css";
import { useEffect, useState } from "react"; 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 { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import NoteTime from "@/Element/Event/NoteTime"; import NoteTime from "@/Element/Event/NoteTime";
import { WalletInvoice, Sats, WalletInfo, WalletInvoiceState, useWallet, LNWallet, Wallets } from "@/Wallet"; import { WalletInvoice, Sats, WalletInfo, WalletInvoiceState, useWallet, LNWallet, Wallets } from "@/Wallet";
import AsyncButton from "@/Element/Button/AsyncButton"; import AsyncButton from "@/Element/Button/AsyncButton";
import { unwrap } from "@/SnortUtils"; import { unwrap } from "@/SnortUtils";
import { WebLNWallet } from "@/Wallet/WebLN";
import Icon from "@/Icons/Icon"; import Icon from "@/Icons/Icon";
export const WalletRoutes: RouteObject[] = [
{
path: "/wallet",
element: <WalletPage />,
},
];
export default function WalletPage() { export default function WalletPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
@ -33,10 +25,14 @@ export default function WalletPage() {
try { try {
const i = await wallet.getInfo(); const i = await wallet.getInfo();
setInfo(i); setInfo(i);
if (wallet.canGetBalance()) {
const b = await wallet.getBalance(); const b = await wallet.getBalance();
setBalance(b as Sats); setBalance(b as Sats);
}
if (wallet.canGetInvoices()) {
const h = await wallet.getInvoices(); const h = await wallet.getInvoices();
setHistory((h as WalletInvoice[]).sort((a, b) => b.timestamp - a.timestamp)); setHistory((h as WalletInvoice[]).sort((a, b) => b.timestamp - a.timestamp));
}
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
setError((e as Error).message); setError((e as Error).message);
@ -62,11 +58,11 @@ export default function WalletPage() {
function stateIcon(s: WalletInvoiceState) { function stateIcon(s: WalletInvoiceState) {
switch (s) { switch (s) {
case WalletInvoiceState.Pending: case WalletInvoiceState.Pending:
return <Icon name="clock" className="mr5" size={15} />; return <Icon name="clock" size={15} />;
case WalletInvoiceState.Paid: case WalletInvoiceState.Paid:
return <Icon name="check" className="mr5" size={15} />; return <Icon name="check" size={15} />;
case WalletInvoiceState.Expired: case WalletInvoiceState.Expired:
return <Icon name="close" className="mr5" size={15} />; return <Icon name="close" size={15} />;
} }
} }
@ -132,7 +128,7 @@ export default function WalletPage() {
} }
function walletHistory() { function walletHistory() {
if (wallet instanceof WebLNWallet) return null; if (!wallet?.canGetInvoices()) return;
return ( return (
<> <>
@ -141,12 +137,12 @@ export default function WalletPage() {
</h3> </h3>
{history?.map(a => ( {history?.map(a => (
<div className="card flex wallet-history-item" key={a.timestamp}> <div className="card flex wallet-history-item" key={a.timestamp}>
<div className="grow flex-col"> <div className="grow">
<NoteTime from={a.timestamp * 1000} fallback={formatMessage({ defaultMessage: "now", id: "kaaf1E" })} /> <NoteTime from={a.timestamp * 1000} fallback={formatMessage({ defaultMessage: "now", id: "kaaf1E" })} />
<div>{(a.memo ?? "").length === 0 ? <>&nbsp;</> : a.memo}</div> <div>{(a.memo ?? "").length === 0 ? <>&nbsp;</> : a.memo}</div>
</div> </div>
<div <div
className={`nowrap ${(() => { className={`flex gap-2 items-center ${(() => {
switch (a.state) { switch (a.state) {
case WalletInvoiceState.Paid: case WalletInvoiceState.Paid:
return "success"; return "success";
@ -158,7 +154,8 @@ export default function WalletPage() {
return "pending"; return "pending";
} }
})()}`}> })()}`}>
{stateIcon(a.state)} <div>{stateIcon(a.state)}</div>
<div>
<FormattedMessage <FormattedMessage
defaultMessage="{amount} sats" defaultMessage="{amount} sats"
id="vrTOHJ" id="vrTOHJ"
@ -168,13 +165,14 @@ export default function WalletPage() {
/> />
</div> </div>
</div> </div>
</div>
))} ))}
</> </>
); );
} }
function walletBalance() { function walletBalance() {
if (wallet instanceof WebLNWallet) return null; if (!wallet?.canGetBalance()) return;
return ( return (
<small> <small>
<FormattedMessage <FormattedMessage
@ -189,18 +187,13 @@ export default function WalletPage() {
} }
function walletInfo() { function walletInfo() {
if (!wallet?.isReady()) return null; if (!wallet?.isReady()) return;
return ( return (
<> <>
<div className="card"> <div className="p br b">
<h3>{info?.alias}</h3> <div>{info?.alias}</div>
{walletBalance()} {walletBalance()}
</div> </div>
{/*<div className="flex wallet-buttons">
<AsyncButton onClick={createInvoice}>
<FormattedMessage defaultMessage="Receive" description="Receive sats by generating LN invoice" />
</AsyncButton>
</div>*/}
{walletHistory()} {walletHistory()}
</> </>
); );
@ -208,8 +201,8 @@ export default function WalletPage() {
return ( return (
<div className="main-content p"> <div className="main-content p">
{error && <b className="error">{error}</b>}
{walletList()} {walletList()}
{error && <b className="warning">{error}</b>}
{unlockWallet()} {unlockWallet()}
{walletInfo()} {walletInfo()}
</div> </div>

View File

@ -1,7 +1,7 @@
import "./WalletSettings.css"; import "./WalletSettings.css";
import LndLogo from "@/lnd-logo.png"; import LndLogo from "@/lnd-logo.png";
import { FormattedMessage } from "react-intl"; 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 BlueWallet from "@/Icons/BlueWallet";
import ConnectLNC from "@/Pages/settings/wallet/LNC"; 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 ConnectCashu from "@/Pages/settings/wallet/Cashu";
import NostrIcon from "@/Icons/Nostrich"; import NostrIcon from "@/Icons/Nostrich";
import WalletPage from "../WalletPage";
const WalletSettings = () => { const WalletSettings = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<> <>
<Link to="/wallet"> <WalletPage />
<button type="button">
<FormattedMessage defaultMessage="View Wallets" id="VvaJst" />
</button>
</Link>
<h3> <h3>
<FormattedMessage defaultMessage="Connect Wallet" id="cg1VJ2" /> <FormattedMessage defaultMessage="Connect Wallet" id="cg1VJ2" />
</h3> </h3>

View File

@ -32,10 +32,18 @@ export class LNCWallet implements LNWallet {
}); });
} }
canAutoLogin(): boolean { canAutoLogin() {
return false; return false;
} }
canGetInvoices() {
return true;
}
canGetBalance() {
return true;
}
isReady(): boolean { isReady(): boolean {
return this.#lnc.isReady; return this.#lnc.isReady;
} }

View File

@ -43,7 +43,15 @@ export default class LNDHubWallet implements LNWallet {
return this.auth !== undefined; return this.auth !== undefined;
} }
canAutoLogin(): boolean { canAutoLogin() {
return true;
}
canGetInvoices() {
return true;
}
canGetBalance() {
return true; return true;
} }
@ -106,7 +114,10 @@ export default class LNDHubWallet implements LNWallet {
async getInvoices(): Promise<WalletInvoice[]> { async getInvoices(): Promise<WalletInvoice[]> {
const rsp = await this.getJson<UserInvoicesResponse[]>("GET", "/getuserinvoices"); const rsp = await this.getJson<UserInvoicesResponse[]>("GET", "/getuserinvoices");
return (rsp as UserInvoicesResponse[]).map(a => { return (rsp as UserInvoicesResponse[])
.sort((a, b) => (a.timestamp > b.timestamp ? -1 : 1))
.slice(0, 50)
.map(a => {
const decodedInvoice = prToWalletInvoice(a.payment_request); const decodedInvoice = prToWalletInvoice(a.payment_request);
if (!decodedInvoice) { if (!decodedInvoice) {
throw new WalletError(WalletErrorCode.InvalidInvoice, "Failed to parse invoice"); throw new WalletError(WalletErrorCode.InvalidInvoice, "Failed to parse invoice");

View File

@ -1,6 +1,15 @@
import { Connection, EventKind, NostrEvent, EventBuilder, PrivateKeySigner } from "@snort/system"; 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 debug from "debug";
import { dedupe } from "@snort/shared";
interface WalletConnectConfig { interface WalletConnectConfig {
relayUrl: string; relayUrl: string;
@ -30,10 +39,45 @@ interface WalletConnectResponse<T> {
}; };
} }
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;
metadata?: object;
}>;
}
interface MakeInvoiceResponse {
invoice: string;
payment_hash: string;
}
const DefaultSupported = ["get_info", "pay_invoice"];
export class NostrConnectWallet implements LNWallet { export class NostrConnectWallet implements LNWallet {
#log = debug("NWC");
#config: WalletConnectConfig; #config: WalletConnectConfig;
#conn?: Connection; #conn?: Connection;
#commandQueue: Map<string, QueueObj>; #commandQueue: Map<string, QueueObj>;
#info?: WalletInfo;
#supported_methods: Array<string> = DefaultSupported;
constructor(cfg: string) { constructor(cfg: string) {
this.#config = NostrConnectWallet.parseConfigUrl(cfg); this.#config = NostrConnectWallet.parseConfigUrl(cfg);
@ -59,13 +103,33 @@ export class NostrConnectWallet implements LNWallet {
async getInfo() { async getInfo() {
await this.login(); 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) => { return await new Promise<WalletInfo>((resolve, reject) => {
this.#commandQueue.set("info", { this.#commandQueue.set("info", {
resolve: (o: string) => { resolve: (o: string) => {
resolve({ this.#supported_methods = dedupe(["get_info", ...o.split(",")]);
this.#log("Supported methods: %o", this.#supported_methods);
const info = {
alias: "NWC", alias: "NWC",
chains: o.split(" "), } as WalletInfo;
} as WalletInfo); this.#info = info;
resolve(info);
}, },
reject, reject,
}); });
@ -73,6 +137,9 @@ export class NostrConnectWallet implements LNWallet {
// ignored // ignored
}); });
}); });
} else {
throw new WalletError(WalletErrorCode.GeneralError, rsp.error.message);
}
} }
async login() { async login() {
@ -100,11 +167,33 @@ export class NostrConnectWallet implements LNWallet {
} }
async getBalance() { async getBalance() {
return 0; 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);
}
} }
createInvoice() { async createInvoice(req: InvoiceRequest) {
return Promise.reject(new WalletError(WalletErrorCode.GeneralError, "Not implemented")); 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) { async payInvoice(pr: string) {
@ -123,8 +212,38 @@ export class NostrConnectWallet implements LNWallet {
} }
} }
getInvoices() { async getInvoices() {
return Promise.resolve([]); 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: 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) { async #onReply(sub: string, e: NostrEvent) {
@ -157,8 +276,19 @@ export class NostrConnectWallet implements LNWallet {
this.#conn?.CloseReq(sub); this.#conn?.CloseReq(sub);
} }
async #rpc<T>(method: string, params: Record<string, string>) { async #rpc<T>(method: string, params: Record<string, string | number | undefined>) {
if (!this.#conn) throw new WalletError(WalletErrorCode.GeneralError, "Not implemented"); 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({ const payload = JSON.stringify({
method, method,
@ -190,7 +320,7 @@ export class NostrConnectWallet implements LNWallet {
this.#commandQueue.set(evCommand.id, { this.#commandQueue.set(evCommand.id, {
resolve: async (o: string) => { resolve: async (o: string) => {
const reply = JSON.parse(await signer.nip4Decrypt(o, this.#config.walletPubkey)); const reply = JSON.parse(await signer.nip4Decrypt(o, this.#config.walletPubkey));
debug("NWC")("%o", reply); this.#log("< %o", reply);
resolve(reply); resolve(reply);
}, },
reject, reject,

View File

@ -12,7 +12,7 @@ import {
WalletKind, WalletKind,
WalletStore, WalletStore,
} from "@/Wallet"; } from "@/Wallet";
import { barrierQueue, processWorkQueue, WorkQueueItem } from "@snort/shared"; import { barrierQueue, processWorkQueue, unwrap, WorkQueueItem } from "@snort/shared";
const WebLNQueue: Array<WorkQueueItem> = []; const WebLNQueue: Array<WorkQueueItem> = [];
processWorkQueue(WebLNQueue); processWorkQueue(WebLNQueue);
@ -75,8 +75,12 @@ export class WebLNWallet implements LNWallet {
return Promise.resolve(true); return Promise.resolve(true);
} }
getBalance(): Promise<Sats> { async getBalance(): Promise<Sats> {
return Promise.resolve(0); 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> { async createInvoice(req: InvoiceRequest): Promise<WalletInvoice> {
@ -124,4 +128,12 @@ export class WebLNWallet implements LNWallet {
getInvoices(): Promise<WalletInvoice[]> { getInvoices(): Promise<WalletInvoice[]> {
return Promise.resolve([]); return Promise.resolve([]);
} }
canGetInvoices() {
return false;
}
canGetBalance() {
return window.webln?.getBalance !== undefined;
}
} }

View File

@ -101,7 +101,6 @@ export type MilliSats = number;
export interface LNWallet { export interface LNWallet {
isReady(): boolean; isReady(): boolean;
canAutoLogin(): boolean;
getInfo: () => Promise<WalletInfo>; getInfo: () => Promise<WalletInfo>;
login: (password?: string) => Promise<boolean>; login: (password?: string) => Promise<boolean>;
close: () => Promise<boolean>; close: () => Promise<boolean>;
@ -109,6 +108,10 @@ export interface LNWallet {
createInvoice: (req: InvoiceRequest) => Promise<WalletInvoice>; createInvoice: (req: InvoiceRequest) => Promise<WalletInvoice>;
payInvoice: (pr: string) => Promise<WalletInvoice>; payInvoice: (pr: string) => Promise<WalletInvoice>;
getInvoices: () => Promise<WalletInvoice[]>; getInvoices: () => Promise<WalletInvoice[]>;
canAutoLogin: () => boolean;
canGetInvoices: () => boolean;
canGetBalance: () => boolean;
} }
export interface WalletConfig { export interface WalletConfig {

View File

@ -39,7 +39,6 @@ import MessagesPage from "@/Pages/Messages/MessagesPage";
import DonatePage from "@/Pages/DonatePage"; import DonatePage from "@/Pages/DonatePage";
import SearchPage from "@/Pages/SearchPage"; import SearchPage from "@/Pages/SearchPage";
import HelpPage from "@/Pages/HelpPage"; import HelpPage from "@/Pages/HelpPage";
import { WalletRoutes } from "@/Pages/WalletPage";
import NostrLinkHandler from "@/Pages/NostrLinkHandler"; import NostrLinkHandler from "@/Pages/NostrLinkHandler";
import { ThreadRoute } from "@/Element/Event/Thread"; import { ThreadRoute } from "@/Element/Event/Thread";
import { SubscribeRoutes } from "@/Pages/subscribe"; import { SubscribeRoutes } from "@/Pages/subscribe";
@ -275,7 +274,6 @@ const mainRoutes = [
element: <NetworkGraph />, element: <NetworkGraph />,
}, },
...OnboardingRoutes, ...OnboardingRoutes,
...WalletRoutes,
] as Array<RouteObject>; ] as Array<RouteObject>;
if (CONFIG.features.zapPool) { if (CONFIG.features.zapPool) {

View File

@ -894,9 +894,6 @@
"VnXp8Z": { "VnXp8Z": {
"defaultMessage": "Avatar" "defaultMessage": "Avatar"
}, },
"VvaJst": {
"defaultMessage": "View Wallets"
},
"W1yoZY": { "W1yoZY": {
"defaultMessage": "It looks like you dont have any subscriptions, you can get one {link}" "defaultMessage": "It looks like you dont have any subscriptions, you can get one {link}"
}, },

View File

@ -294,7 +294,6 @@
"VfhYxG": "To see a full list of changes you can view the changelog {here}", "VfhYxG": "To see a full list of changes you can view the changelog {here}",
"VlJkSk": "{n} muted", "VlJkSk": "{n} muted",
"VnXp8Z": "Avatar", "VnXp8Z": "Avatar",
"VvaJst": "View Wallets",
"W1yoZY": "It looks like you dont have any subscriptions, you can get one {link}", "W1yoZY": "It looks like you dont have any subscriptions, you can get one {link}",
"W2PiAr": "{n} Blocked", "W2PiAr": "{n} Blocked",
"W9355R": "Unmute", "W9355R": "Unmute",

View File

@ -2911,7 +2911,7 @@ __metadata:
"@uidotdev/usehooks": ^2.4.1 "@uidotdev/usehooks": ^2.4.1
"@vitejs/plugin-react": ^4.2.0 "@vitejs/plugin-react": ^4.2.0
"@void-cat/api": ^1.0.10 "@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 "@webscopeio/react-textarea-autocomplete": ^4.9.2
autoprefixer: ^10.4.16 autoprefixer: ^10.4.16
classnames: ^2.3.2 classnames: ^2.3.2
@ -3961,10 +3961,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@webbtc/webln-types@npm:^1.0.10": "@webbtc/webln-types@npm:^2.1.0":
version: 1.0.14 version: 2.1.0
resolution: "@webbtc/webln-types@npm:1.0.14" resolution: "@webbtc/webln-types@npm:2.1.0"
checksum: eaa363bf3a9c278be51c93487904c04518e8812d97449d8d7866089aae74756451a48245a31b1a0fd591bc4798a96a29516ad395b8828c9f2af920cf65a305ac checksum: 71c8ae3fc4e163dfa2271f19216b603f53a6910e65fdb115b1920ef9be4b7fa990d9c1c644900a7e88c69e8a8a8cc2e273aa490903e6064e2f296498cdca53ff
languageName: node languageName: node
linkType: hard linkType: hard