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/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",

View File

@ -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: <WalletPage />,
},
];
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 <Icon name="clock" className="mr5" size={15} />;
return <Icon name="clock" size={15} />;
case WalletInvoiceState.Paid:
return <Icon name="check" className="mr5" size={15} />;
return <Icon name="check" size={15} />;
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() {
if (wallet instanceof WebLNWallet) return null;
if (!wallet?.canGetInvoices()) return;
return (
<>
@ -141,12 +137,12 @@ export default function WalletPage() {
</h3>
{history?.map(a => (
<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" })} />
<div>{(a.memo ?? "").length === 0 ? <>&nbsp;</> : a.memo}</div>
</div>
<div
className={`nowrap ${(() => {
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)}
<FormattedMessage
defaultMessage="{amount} sats"
id="vrTOHJ"
values={{
amount: <FormattedNumber value={a.amount / 1e3} />,
}}
/>
<div>{stateIcon(a.state)}</div>
<div>
<FormattedMessage
defaultMessage="{amount} sats"
id="vrTOHJ"
values={{
amount: <FormattedNumber value={a.amount / 1e3} />,
}}
/>
</div>
</div>
</div>
))}
@ -174,7 +172,7 @@ export default function WalletPage() {
}
function walletBalance() {
if (wallet instanceof WebLNWallet) return null;
if (!wallet?.canGetBalance()) return;
return (
<small>
<FormattedMessage
@ -189,18 +187,13 @@ export default function WalletPage() {
}
function walletInfo() {
if (!wallet?.isReady()) return null;
if (!wallet?.isReady()) return;
return (
<>
<div className="card">
<h3>{info?.alias}</h3>
<div className="p br b">
<div>{info?.alias}</div>
{walletBalance()}
</div>
{/*<div className="flex wallet-buttons">
<AsyncButton onClick={createInvoice}>
<FormattedMessage defaultMessage="Receive" description="Receive sats by generating LN invoice" />
</AsyncButton>
</div>*/}
{walletHistory()}
</>
);
@ -208,8 +201,8 @@ export default function WalletPage() {
return (
<div className="main-content p">
{error && <b className="error">{error}</b>}
{walletList()}
{error && <b className="warning">{error}</b>}
{unlockWallet()}
{walletInfo()}
</div>

View File

@ -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 (
<>
<Link to="/wallet">
<button type="button">
<FormattedMessage defaultMessage="View Wallets" id="VvaJst" />
</button>
</Link>
<WalletPage />
<h3>
<FormattedMessage defaultMessage="Connect Wallet" id="cg1VJ2" />
</h3>

View File

@ -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;
}

View File

@ -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<WalletInvoice[]> {
const rsp = await this.getJson<UserInvoicesResponse[]>("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<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {

View File

@ -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<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 {
#log = debug("NWC");
#config: WalletConnectConfig;
#conn?: Connection;
#commandQueue: Map<string, QueueObj>;
#info?: WalletInfo;
#supported_methods: Array<string> = 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<WalletInfo>((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<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
});
});
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<WalletConnectResponse<{ balance: number }>>("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<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) {
@ -123,8 +212,38 @@ export class NostrConnectWallet implements LNWallet {
}
}
getInvoices() {
return Promise.resolve([]);
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: 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<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");
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,

View File

@ -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<WorkQueueItem> = [];
processWorkQueue(WebLNQueue);
@ -75,8 +75,12 @@ export class WebLNWallet implements LNWallet {
return Promise.resolve(true);
}
getBalance(): Promise<Sats> {
return Promise.resolve(0);
async getBalance(): Promise<Sats> {
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> {
@ -124,4 +128,12 @@ export class WebLNWallet implements LNWallet {
getInvoices(): Promise<WalletInvoice[]> {
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 {
isReady(): boolean;
canAutoLogin(): boolean;
getInfo: () => Promise<WalletInfo>;
login: (password?: string) => Promise<boolean>;
close: () => Promise<boolean>;
@ -109,6 +108,10 @@ export interface LNWallet {
createInvoice: (req: InvoiceRequest) => Promise<WalletInvoice>;
payInvoice: (pr: string) => Promise<WalletInvoice>;
getInvoices: () => Promise<WalletInvoice[]>;
canAutoLogin: () => boolean;
canGetInvoices: () => boolean;
canGetBalance: () => boolean;
}
export interface WalletConfig {

View File

@ -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: <NetworkGraph />,
},
...OnboardingRoutes,
...WalletRoutes,
] as Array<RouteObject>;
if (CONFIG.features.zapPool) {

View File

@ -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}"
},

View File

@ -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",

View File

@ -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