From eec85c441e6162136dd9d7b24704681ed227e769 Mon Sep 17 00:00:00 2001 From: Kieran Date: Thu, 2 Mar 2023 15:23:53 +0000 Subject: [PATCH] complete basic wallet --- packages/app/d.ts | 5 + packages/app/public/index.html | 2 +- packages/app/src/Element/Invoice.tsx | 43 +--- packages/app/src/Element/NoteFooter.tsx | 19 +- packages/app/src/Element/SendSats.tsx | 33 +-- packages/app/src/Element/Zap.tsx | 20 +- packages/app/src/Hooks/useWebln.ts | 90 -------- packages/app/src/Icons/BlueWallet.tsx | 61 +++++ packages/app/src/Pages/SettingsPage.tsx | 2 + packages/app/src/Pages/WalletPage.tsx | 206 +++++++++++++---- packages/app/src/Pages/settings/Index.tsx | 10 + .../app/src/Pages/settings/WalletSettings.css | 14 ++ .../app/src/Pages/settings/WalletSettings.tsx | 48 ++++ .../app/src/Pages/settings/wallet/LNC.tsx | 121 ++++++++++ .../app/src/Pages/settings/wallet/LNDHub.tsx | 70 ++++++ packages/app/src/Util.ts | 36 +++ packages/app/src/Wallet/LNCWallet.ts | 110 +++++++-- packages/app/src/Wallet/LNDHub.ts | 54 ++--- packages/app/src/Wallet/WebLN.ts | 181 +++++++++++++++ packages/app/src/Wallet/index.ts | 213 ++++++++++++++++-- packages/app/src/lnd-logo.png | Bin 0 -> 5540 bytes 21 files changed, 1066 insertions(+), 272 deletions(-) delete mode 100644 packages/app/src/Hooks/useWebln.ts create mode 100644 packages/app/src/Icons/BlueWallet.tsx create mode 100644 packages/app/src/Pages/settings/WalletSettings.css create mode 100644 packages/app/src/Pages/settings/WalletSettings.tsx create mode 100644 packages/app/src/Pages/settings/wallet/LNC.tsx create mode 100644 packages/app/src/Pages/settings/wallet/LNDHub.tsx create mode 100644 packages/app/src/Wallet/WebLN.ts create mode 100644 packages/app/src/lnd-logo.png diff --git a/packages/app/d.ts b/packages/app/d.ts index 78ed95f0..a03d3b27 100644 --- a/packages/app/d.ts +++ b/packages/app/d.ts @@ -13,6 +13,11 @@ declare module "*.webp" { export default value; } +declare module "*.png" { + const value: string; + export default value; +} + declare module "translations/*.json" { const value: Record; export default value; diff --git a/packages/app/public/index.html b/packages/app/public/index.html index 7da8ed6f..c45a4333 100644 --- a/packages/app/public/index.html +++ b/packages/app/public/index.html @@ -8,7 +8,7 @@ + content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src *; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' 'wasm-unsafe-eval' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" /> diff --git a/packages/app/src/Element/Invoice.tsx b/packages/app/src/Element/Invoice.tsx index 85539bf1..9aa2570f 100644 --- a/packages/app/src/Element/Invoice.tsx +++ b/packages/app/src/Element/Invoice.tsx @@ -1,11 +1,12 @@ import "./Invoice.css"; import { useState } from "react"; import { useIntl, FormattedMessage } from "react-intl"; -import { decode as invoiceDecode } from "light-bolt11-decoder"; import { useMemo } from "react"; + import SendSats from "Element/SendSats"; import ZapCircle from "Icons/ZapCircle"; -import useWebln from "Hooks/useWebln"; +import { useWallet } from "Wallet"; +import { decodeInvoice } from "Util"; import messages from "./messages"; @@ -15,38 +16,12 @@ export interface InvoiceProps { export default function Invoice(props: InvoiceProps) { const invoice = props.invoice; - const webln = useWebln(); - const [showInvoice, setShowInvoice] = useState(false); const { formatMessage } = useIntl(); + const [showInvoice, setShowInvoice] = useState(false); + const walletState = useWallet(); + const wallet = walletState.wallet; - const info = useMemo(() => { - try { - const parsed = invoiceDecode(invoice); - - const amountSection = parsed.sections.find(a => a.name === "amount"); - const amount = amountSection ? (amountSection.value as number) : NaN; - - const timestampSection = parsed.sections.find(a => a.name === "timestamp"); - const timestamp = timestampSection ? (timestampSection.value as number) : NaN; - - const expirySection = parsed.sections.find(a => a.name === "expiry"); - const expire = expirySection ? (expirySection.value as number) : NaN; - const descriptionSection = parsed.sections.find(a => a.name === "description")?.value; - const ret = { - amount: !isNaN(amount) ? amount / 1000 : 0, - expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null, - description: descriptionSection as string | undefined, - expired: false, - }; - if (ret.expire) { - ret.expired = ret.expire < new Date().getTime() / 1000; - } - return ret; - } catch (e) { - console.error(e); - } - }, [invoice]); - + const info = useMemo(() => decodeInvoice(invoice), [invoice]); const [isPaid, setIsPaid] = useState(false); const isExpired = info?.expired; const amount = info?.amount ?? 0; @@ -71,9 +46,9 @@ export default function Invoice(props: InvoiceProps) { async function payInvoice(e: React.MouseEvent) { e.stopPropagation(); - if (webln?.enabled) { + if (wallet?.isReady) { try { - await webln.sendPayment(invoice); + await wallet.payInvoice(invoice); setIsPaid(true); } catch (error) { setShowInvoice(true); diff --git a/packages/app/src/Element/NoteFooter.tsx b/packages/app/src/Element/NoteFooter.tsx index f4b3b9bd..f63a7840 100644 --- a/packages/app/src/Element/NoteFooter.tsx +++ b/packages/app/src/Element/NoteFooter.tsx @@ -3,6 +3,7 @@ import { useSelector, useDispatch } from "react-redux"; import { useIntl, FormattedMessage } from "react-intl"; import { Menu, MenuItem } from "@szhsin/react-menu"; import { useLongPress } from "use-long-press"; +import { Event as NEvent, TaggedRawEvent, HexKey } from "@snort/nostr"; import Bookmark from "Icons/Bookmark"; import Pin from "Icons/Pin"; @@ -19,6 +20,9 @@ import Heart from "Icons/Heart"; import Dots from "Icons/Dots"; import Zap from "Icons/Zap"; import Reply from "Icons/Reply"; +import Spinner from "Icons/Spinner"; +import ZapFast from "Icons/ZapFast"; + import { formatShort } from "Number"; import useEventPublisher from "Feed/EventPublisher"; import { hexToBech32, normalizeReaction, unwrap } from "Util"; @@ -27,15 +31,12 @@ import Reactions from "Element/Reactions"; import SendSats from "Element/SendSats"; import { ParsedZap, ZapsSummary } from "Element/Zap"; import { useUserProfile } from "Feed/ProfileFeed"; -import { Event as NEvent, TaggedRawEvent, HexKey } from "@snort/nostr"; import { RootState } from "State/Store"; import { UserPreferences, setPinned, setBookmarked } from "State/Login"; import useModeration from "Hooks/useModeration"; import { TranslateHost } from "Const"; -import useWebln from "Hooks/useWebln"; import { LNURL } from "LNURL"; -import Spinner from "Icons/Spinner"; -import ZapFast from "Icons/ZapFast"; +import { useWallet } from "Wallet"; import messages from "./messages"; @@ -69,7 +70,9 @@ export default function NoteFooter(props: NoteFooterProps) { const [reply, setReply] = useState(false); const [tip, setTip] = useState(false); const [zapping, setZapping] = useState(false); - const webln = useWebln(); + const walletState = useWallet(); + const wallet = walletState.wallet; + const isMine = ev.RootPubKey === login; const lang = window.navigator.language; const langNames = new Intl.DisplayNames([...window.navigator.languages], { @@ -122,14 +125,14 @@ export default function NoteFooter(props: NoteFooterProps) { if (zapping || e.isPropagationStopped()) return; const lnurl = author?.lud16 || author?.lud06; - if (webln?.enabled && lnurl) { + if (wallet?.isReady() && lnurl) { setZapping(true); try { const handler = new LNURL(lnurl); await handler.load(); const zap = handler.canZap ? await publisher.zap(prefs.defaultZapAmount * 1000, ev.PubKey, ev.Id) : undefined; const invoice = await handler.getInvoice(prefs.defaultZapAmount, undefined, zap); - await await webln.sendPayment(unwrap(invoice.pr)); + await wallet.payInvoice(unwrap(invoice.pr)); } catch (e) { console.warn("Fast zap failed", e); if (!(e instanceof Error) || e.message !== "User rejected") { @@ -149,7 +152,7 @@ export default function NoteFooter(props: NoteFooterProps) { return ( <>
fastZap(e)}> -
{zapping ? : webln?.enabled ? : }
+
{zapping ? : wallet?.isReady ? : }
{zapTotal > 0 &&
{formatShort(zapTotal)}
}
diff --git a/packages/app/src/Element/SendSats.tsx b/packages/app/src/Element/SendSats.tsx index ccb5961c..3b96dfb0 100644 --- a/packages/app/src/Element/SendSats.tsx +++ b/packages/app/src/Element/SendSats.tsx @@ -14,7 +14,6 @@ import ProfileImage from "Element/ProfileImage"; import Modal from "Element/Modal"; import QrCode from "Element/QrCode"; import Copy from "Element/Copy"; -import useWebln from "Hooks/useWebln"; import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL"; import { debounce } from "Util"; @@ -77,11 +76,11 @@ export default function SendSats(props: SendSatsProps) { const [zapType, setZapType] = useState(ZapType.PublicZap); const [paying, setPaying] = useState(false); - const webln = useWebln(props.show); const { formatMessage } = useIntl(); const publisher = useEventPublisher(); const canComment = handler ? (handler.canZap && zapType !== ZapType.NonZap) || handler.maxCommentLength > 0 : false; - const wallet = useWallet(); + const walletState = useWallet(); + const wallet = walletState.wallet; useEffect(() => { if (props.show) { @@ -156,7 +155,7 @@ export default function SendSats(props: SendSatsProps) { const rsp = await handler.getInvoice(amount, comment, zap); if (rsp.pr) { setInvoice(rsp.pr); - await payWebLNIfEnabled(rsp); + await payWithWallet(rsp); } } catch (e) { handleLNURLError(e, formatMessage(messages.InvoiceFail)); @@ -206,11 +205,11 @@ export default function SendSats(props: SendSatsProps) { ); } - async function payWebLNIfEnabled(invoice: LNURLInvoice) { + async function payWithWallet(invoice: LNURLInvoice) { try { - if (webln?.enabled) { + if (wallet?.isReady) { setPaying(true); - const res = await webln.sendPayment(invoice?.pr ?? ""); + const res = await wallet.payInvoice(invoice?.pr ?? ""); console.log(res); setSuccess(invoice?.successAction ?? {}); } @@ -300,14 +299,6 @@ export default function SendSats(props: SendSatsProps) { ); } - async function payWithWallet(pr: string) { - if (wallet) { - const rsp = await wallet.payInvoice(pr); - console.debug(rsp); - setSuccess(rsp as LNURLSuccessAction); - } - } - function payInvoice() { if (success || !invoice) return null; return ( @@ -316,7 +307,12 @@ export default function SendSats(props: SendSatsProps) { {props.notice && {props.notice}} {paying ? (

- + ...

) : ( @@ -331,11 +327,6 @@ export default function SendSats(props: SendSatsProps) { - {wallet && ( - - )} )} diff --git a/packages/app/src/Element/Zap.tsx b/packages/app/src/Element/Zap.tsx index a2ede41f..823c20db 100644 --- a/packages/app/src/Element/Zap.tsx +++ b/packages/app/src/Element/Zap.tsx @@ -2,12 +2,10 @@ import "./Zap.css"; import { useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useSelector } from "react-redux"; -import { decode as invoiceDecode } from "light-bolt11-decoder"; -import { bytesToHex } from "@noble/hashes/utils"; -import { sha256, unwrap } from "Util"; +import { Event, HexKey, TaggedRawEvent } from "@snort/nostr"; + +import { decodeInvoice, sha256, unwrap } from "Util"; import { formatShort } from "Number"; -import { HexKey, TaggedRawEvent } from "@snort/nostr"; -import { Event } from "@snort/nostr"; import Text from "Element/Text"; import ProfileImage from "Element/ProfileImage"; import { RootState } from "State/Store"; @@ -27,15 +25,9 @@ function getInvoice(zap: TaggedRawEvent) { console.debug("Invalid zap: ", zap); return {}; } - try { - const decoded = invoiceDecode(bolt11); - - const amount = decoded.sections.find(section => section.name === "amount")?.value; - const hash = decoded.sections.find(section => section.name === "description_hash")?.value; - - return { amount, hash: hash ? bytesToHex(hash as Uint8Array) : undefined }; - } catch { - // ignore + const decoded = decodeInvoice(bolt11); + if (decoded) { + return { amount: decoded?.amount, hash: decoded?.descriptionHash }; } return {}; } diff --git a/packages/app/src/Hooks/useWebln.ts b/packages/app/src/Hooks/useWebln.ts deleted file mode 100644 index b7b5717f..00000000 --- a/packages/app/src/Hooks/useWebln.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect } from "react"; -import { delay, unwrap } from "Util"; - -let isWebLnBusy = false; -export const barrierWebLn = async (then: () => Promise): Promise => { - while (isWebLnBusy) { - await delay(10); - } - isWebLnBusy = true; - try { - return await then(); - } finally { - isWebLnBusy = false; - } -}; - -interface SendPaymentResponse { - paymentHash?: string; - preimage: string; - route?: { - total_amt: number; - total_fees: number; - }; -} - -interface RequestInvoiceArgs { - amount?: string | number; - defaultAmount?: string | number; - minimumAmount?: string | number; - maximumAmount?: string | number; - defaultMemo?: string; -} - -interface RequestInvoiceResponse { - paymentRequest: string; -} - -interface GetInfoResponse { - node: { - alias: string; - pubkey: string; - color?: string; - }; -} - -interface SignMessageResponse { - message: string; - signature: string; -} - -interface WebLN { - enabled: boolean; - getInfo(): Promise; - enable: () => Promise; - makeInvoice(args: RequestInvoiceArgs): Promise; - signMessage(message: string): Promise; - verifyMessage(signature: string, message: string): Promise; - sendPayment: (pr: string) => Promise; -} - -declare global { - interface Window { - webln?: WebLN; - } -} - -export default function useWebln(enable = true) { - const maybeWebLn = - "webln" in window && window.webln - ? ({ - enabled: unwrap(window.webln).enabled, - getInfo: () => barrierWebLn(() => unwrap(window.webln).getInfo()), - enable: () => barrierWebLn(() => unwrap(window.webln).enable()), - makeInvoice: args => barrierWebLn(() => unwrap(window.webln).makeInvoice(args)), - signMessage: msg => barrierWebLn(() => unwrap(window.webln).signMessage(msg)), - verifyMessage: (sig, msg) => barrierWebLn(() => unwrap(window.webln).verifyMessage(sig, msg)), - sendPayment: pr => barrierWebLn(() => unwrap(window.webln).sendPayment(pr)), - } as WebLN) - : null; - - useEffect(() => { - if (maybeWebLn && !maybeWebLn.enabled && enable) { - maybeWebLn.enable().catch(() => { - console.debug("Couldn't enable WebLN"); - }); - } - }, [maybeWebLn, enable]); - - return maybeWebLn; -} diff --git a/packages/app/src/Icons/BlueWallet.tsx b/packages/app/src/Icons/BlueWallet.tsx new file mode 100644 index 00000000..75b9e69d --- /dev/null +++ b/packages/app/src/Icons/BlueWallet.tsx @@ -0,0 +1,61 @@ +import IconProps from "./IconProps"; + +export const BlueWallet = (props: IconProps) => { + return ( + + logo-bluewallet + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; +export default BlueWallet; diff --git a/packages/app/src/Pages/SettingsPage.tsx b/packages/app/src/Pages/SettingsPage.tsx index 50207973..aa77ee92 100644 --- a/packages/app/src/Pages/SettingsPage.tsx +++ b/packages/app/src/Pages/SettingsPage.tsx @@ -5,6 +5,7 @@ import Profile from "Pages/settings/Profile"; import Relay from "Pages/settings/Relays"; import Preferences from "Pages/settings/Preferences"; import RelayInfo from "Pages/settings/RelayInfo"; +import { WalletSettingsRoutes } from "Pages/settings/WalletSettings"; import messages from "./messages"; @@ -42,4 +43,5 @@ export const SettingsRoutes: RouteObject[] = [ path: "preferences", element: , }, + ...WalletSettingsRoutes, ]; diff --git a/packages/app/src/Pages/WalletPage.tsx b/packages/app/src/Pages/WalletPage.tsx index e336aafe..93011886 100644 --- a/packages/app/src/Pages/WalletPage.tsx +++ b/packages/app/src/Pages/WalletPage.tsx @@ -1,12 +1,16 @@ import "./WalletPage.css"; import { useEffect, useState } from "react"; -import { RouteObject } from "react-router-dom"; +import { RouteObject, useNavigate } from "react-router-dom"; +import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCheck, faClock, faXmark } from "@fortawesome/free-solid-svg-icons"; import NoteTime from "Element/NoteTime"; -import { WalletInvoice, Sats, WalletInfo, WalletInvoiceState, useWallet, LNWallet } from "Wallet"; +import { WalletInvoice, Sats, WalletInfo, WalletInvoiceState, useWallet, LNWallet, Wallets, WalletKind } from "Wallet"; +import AsyncButton from "Element/AsyncButton"; +import { unwrap } from "Util"; +import { WebLNWallet } from "Wallet/WebLN"; export const WalletRoutes: RouteObject[] = [ { @@ -16,26 +20,43 @@ export const WalletRoutes: RouteObject[] = [ ]; export default function WalletPage() { + const navigate = useNavigate(); + const { formatMessage } = useIntl(); const [info, setInfo] = useState(); const [balance, setBalance] = useState(); const [history, setHistory] = useState(); - const wallet = useWallet(); + const [walletPassword, setWalletPassword] = useState(); + const [error, setError] = useState(); + const walletState = useWallet(); + const wallet = walletState.wallet; async function loadWallet(wallet: LNWallet) { - const i = await wallet.getInfo(); - if ("error" in i) { - return; + 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)); + } catch (e) { + if (e instanceof Error) { + setError((e as Error).message); + } else { + setError(formatMessage({ defaultMessage: "Unknown error" })); + } } - setInfo(i as WalletInfo); - 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)); } useEffect(() => { if (wallet) { - loadWallet(wallet).catch(console.warn); + if (wallet.isReady()) { + loadWallet(wallet).catch(console.warn); + } else if (walletState.config?.kind !== WalletKind.LNC) { + wallet + .login() + .then(async () => await loadWallet(wallet)) + .catch(console.warn); + } } }, [wallet]); @@ -50,37 +71,146 @@ export default function WalletPage() { } } - async function createInvoice() { + async function loginWallet(pw: string) { if (wallet) { - const rsp = await wallet.createInvoice({ - memo: "test", - amount: 100, - }); - console.debug(rsp); + await wallet.login(pw); + await loadWallet(wallet); + setWalletPassword(undefined); } } - return ( - <> -

{info?.alias}

- Balance: {(balance ?? 0).toLocaleString()} sats -
- - -
-

History

- {history?.map(a => ( -
-
- -
{(a.memo ?? "").length === 0 ? <>  : a.memo}
-
-
- {stateIcon(a.state)} - {a.amount.toLocaleString()} sats + function unlockWallet() { + if (!wallet || wallet.isReady()) return null; + return ( + <> +

+ +

+
+
+ setWalletPassword(e.target.value)} + />
+ loginWallet(unwrap(walletPassword))} disabled={(walletPassword?.length ?? 0) < 8}> + +
- ))} - + + ); + } + + function walletList() { + if (walletState.configs.length === 0) { + return ( + + ); + } + return ( +
+

+ +

+
+ +
+
+ ); + } + + function walletHistory() { + if (wallet instanceof WebLNWallet) return null; + + return ( + <> +

+ +

+ {history?.map(a => ( +
+
+ +
{(a.memo ?? "").length === 0 ? <>  : a.memo}
+
+
{ + switch (a.state) { + case WalletInvoiceState.Paid: + return "success"; + case WalletInvoiceState.Expired: + return "expired"; + case WalletInvoiceState.Failed: + return "failed"; + default: + return "pending"; + } + })()}> + {stateIcon(a.state)} + , + }} + /> +
+
+ ))} + + ); + } + + function walletBalance() { + if (wallet instanceof WebLNWallet) return null; + return ( + + , + }} + /> + + ); + } + + function walletInfo() { + if (!wallet?.isReady()) return null; + return ( + <> +

{info?.alias}

+ {walletBalance()} + {/*
+ + + +
*/} + {walletHistory()} + + + ); + } + + return ( +
+ {error && {error}} + {walletList()} + {unlockWallet()} + {walletInfo()} +
); } diff --git a/packages/app/src/Pages/settings/Index.tsx b/packages/app/src/Pages/settings/Index.tsx index 595f9ff4..8fffe871 100644 --- a/packages/app/src/Pages/settings/Index.tsx +++ b/packages/app/src/Pages/settings/Index.tsx @@ -8,6 +8,7 @@ import Profile from "Icons/Profile"; import Relay from "Icons/Relay"; import Heart from "Icons/Heart"; import Logout from "Icons/Logout"; +import Bitcoin from "Icons/Bitcoin"; import { logout } from "State/Login"; import messages from "./messages"; @@ -53,6 +54,15 @@ const SettingsIndex = () => {
+
navigate("wallet")}> +
+ +
+ +
+ +
+
navigate("/donate")}>
diff --git a/packages/app/src/Pages/settings/WalletSettings.css b/packages/app/src/Pages/settings/WalletSettings.css new file mode 100644 index 00000000..d24cef79 --- /dev/null +++ b/packages/app/src/Pages/settings/WalletSettings.css @@ -0,0 +1,14 @@ +.wallet-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + text-align: center; + grid-gap: 10px; +} + +.wallet-grid .card { + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; +} diff --git a/packages/app/src/Pages/settings/WalletSettings.tsx b/packages/app/src/Pages/settings/WalletSettings.tsx new file mode 100644 index 00000000..3c391499 --- /dev/null +++ b/packages/app/src/Pages/settings/WalletSettings.tsx @@ -0,0 +1,48 @@ +import "./WalletSettings.css"; +import LndLogo from "lnd-logo.png"; +import { FormattedMessage } from "react-intl"; +import { RouteObject, useNavigate } from "react-router-dom"; + +import BlueWallet from "Icons/BlueWallet"; +import ConnectLNC from "Pages/settings/wallet/LNC"; +import ConnectLNDHub from "./wallet/LNDHub"; + +const WalletSettings = () => { + const navigate = useNavigate(); + return ( + <> +

+ +

+
+
navigate("/settings/wallet/lnc")}> + +

LND with LNC

+
+ { +
navigate("/settings/wallet/lndhub")}> + +

LNDHub

+
+ } +
+ + ); +}; + +export default WalletSettings; + +export const WalletSettingsRoutes = [ + { + path: "/settings/wallet", + element: , + }, + { + path: "/settings/wallet/lnc", + element: , + }, + { + path: "/settings/wallet/lndhub", + element: , + }, +] as Array; diff --git a/packages/app/src/Pages/settings/wallet/LNC.tsx b/packages/app/src/Pages/settings/wallet/LNC.tsx new file mode 100644 index 00000000..0f3ebbee --- /dev/null +++ b/packages/app/src/Pages/settings/wallet/LNC.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useNavigate } from "react-router-dom"; +import { v4 as uuid } from "uuid"; + +import AsyncButton from "Element/AsyncButton"; +import { LNCWallet } from "Wallet/LNCWallet"; +import { WalletInfo, WalletKind, Wallets } from "Wallet"; +import { unwrap } from "Util"; + +const ConnectLNC = () => { + const { formatMessage } = useIntl(); + const navigate = useNavigate(); + const [pairingPhrase, setPairingPhrase] = useState(); + const [error, setError] = useState(); + const [connectedLNC, setConnectedLNC] = useState(); + const [walletInfo, setWalletInfo] = useState(); + const [walletPassword, setWalletPassword] = useState(); + + async function tryConnect(cfg: string) { + try { + const lnc = await LNCWallet.Initialize(cfg); + const info = await lnc.getInfo(); + + // prompt password + setConnectedLNC(lnc); + setWalletInfo(info as WalletInfo); + } catch (e) { + if (e instanceof Error) { + setError(e.message); + } else { + setError( + formatMessage({ + defaultMessage: "Unknown error", + }) + ); + } + } + } + + function setLNCPassword(pw: string) { + connectedLNC?.setPassword(pw); + Wallets.add({ + id: uuid(), + kind: WalletKind.LNC, + active: true, + info: unwrap(walletInfo), + }); + navigate("/wallet"); + } + + function flowConnect() { + if (connectedLNC) return null; + return ( + <> +

+ +

+
+
+ setPairingPhrase(e.target.value)} + /> +
+ tryConnect(unwrap(pairingPhrase))} disabled={!pairingPhrase}> + + +
+ {error && {error}} + + ); + } + + function flowSetPassword() { + if (!connectedLNC) return null; + return ( +
+

+ +

+

+ +

+
+
+ setWalletPassword(e.target.value)} + /> +
+ setLNCPassword(unwrap(walletPassword))} + disabled={(walletPassword?.length ?? 0) < 8}> + + +
+
+ ); + } + + return ( + <> + {flowConnect()} + {flowSetPassword()} + + ); +}; + +export default ConnectLNC; diff --git a/packages/app/src/Pages/settings/wallet/LNDHub.tsx b/packages/app/src/Pages/settings/wallet/LNDHub.tsx new file mode 100644 index 00000000..869b1271 --- /dev/null +++ b/packages/app/src/Pages/settings/wallet/LNDHub.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { v4 as uuid } from "uuid"; + +import AsyncButton from "Element/AsyncButton"; +import { unwrap } from "Util"; +import LNDHubWallet from "Wallet/LNDHub"; +import { WalletConfig, WalletKind, Wallets } from "Wallet"; +import { useNavigate } from "react-router-dom"; + +const ConnectLNDHub = () => { + const navigate = useNavigate(); + const { formatMessage } = useIntl(); + const [config, setConfig] = useState(); + const [error, setError] = useState(); + + async function tryConnect(config: string) { + try { + const connection = new LNDHubWallet(config); + await connection.login(); + const info = await connection.getInfo(); + + const newWallet = { + id: uuid(), + kind: WalletKind.LNDHub, + active: true, + info, + data: config, + } as WalletConfig; + Wallets.add(newWallet); + + navigate("/wallet"); + } catch (e) { + if (e instanceof Error) { + setError((e as Error).message); + } else { + setError( + formatMessage({ + defaultMessage: "Unknown error", + }) + ); + } + } + } + + return ( + <> +

+ +

+
+
+ setConfig(e.target.value)} + /> +
+ tryConnect(unwrap(config))} disabled={!config}> + + +
+ {error && {error}} + + ); +}; + +export default ConnectLNDHub; diff --git a/packages/app/src/Util.ts b/packages/app/src/Util.ts index c5fdab63..cca139c8 100644 --- a/packages/app/src/Util.ts +++ b/packages/app/src/Util.ts @@ -1,7 +1,10 @@ import * as secp from "@noble/secp256k1"; import { sha256 as hash } from "@noble/hashes/sha256"; +import { bytesToHex } from "@noble/hashes/utils"; +import { decode as invoiceDecode } from "light-bolt11-decoder"; import { bech32 } from "bech32"; import { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix } from "@snort/nostr"; + import { MetadataCache } from "State/Users"; export const sha256 = (str: string) => { @@ -235,3 +238,36 @@ export const delay = (t: number) => { setTimeout(resolve, t); }); }; + +export function decodeInvoice(pr: string) { + try { + const parsed = invoiceDecode(pr); + + const amountSection = parsed.sections.find(a => a.name === "amount"); + const amount = amountSection ? (amountSection.value as number) : NaN; + + const timestampSection = parsed.sections.find(a => a.name === "timestamp"); + const timestamp = timestampSection ? (timestampSection.value as number) : NaN; + + const expirySection = parsed.sections.find(a => a.name === "expiry"); + const expire = expirySection ? (expirySection.value as number) : NaN; + const descriptionSection = parsed.sections.find(a => a.name === "description")?.value; + const descriptionHashSection = parsed.sections.find(a => a.name === "description_hash")?.value; + const paymentHashSection = parsed.sections.find(a => a.name === "payment_hash")?.value; + const ret = { + amount: !isNaN(amount) ? amount : undefined, + expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : undefined, + timestamp: !isNaN(timestamp) ? timestamp : undefined, + description: descriptionSection as string | undefined, + descriptionHash: descriptionHashSection ? bytesToHex(descriptionHashSection as Uint8Array) : undefined, + paymentHash: paymentHashSection ? bytesToHex(paymentHashSection as Uint8Array) : undefined, + expired: false, + }; + if (ret.expire) { + ret.expired = ret.expire < new Date().getTime() / 1000; + } + return ret; + } catch (e) { + console.error(e); + } +} diff --git a/packages/app/src/Wallet/LNCWallet.ts b/packages/app/src/Wallet/LNCWallet.ts index 23eded20..b00dd987 100644 --- a/packages/app/src/Wallet/LNCWallet.ts +++ b/packages/app/src/Wallet/LNCWallet.ts @@ -1,6 +1,25 @@ -import { InvoiceRequest, LNWallet, Login, WalletError, WalletInfo, WalletInvoice, WalletInvoiceState } from "Wallet"; - import LNC from "@lightninglabs/lnc-web"; +import { unwrap } from "Util"; +import { + InvoiceRequest, + LNWallet, + Login, + prToWalletInvoice, + WalletError, + WalletErrorCode, + WalletInfo, + WalletInvoice, + WalletInvoiceState, +} from "Wallet"; + +enum Payment_PaymentStatus { + UNKNOWN = "UNKNOWN", + IN_FLIGHT = "IN_FLIGHT", + SUCCEEDED = "SUCCEEDED", + FAILED = "FAILED", + UNRECOGNIZED = "UNRECOGNIZED", +} + export class LNCWallet implements LNWallet { #lnc: LNC; @@ -11,23 +30,32 @@ export class LNCWallet implements LNWallet { }); } - static async Initialize(pairingPhrase: string, password: string) { - const lnc = new LNCWallet(pairingPhrase, password); + isReady(): boolean { + return this.#lnc.isReady; + } + + static async Initialize(pairingPhrase: string) { + const lnc = new LNCWallet(pairingPhrase); await lnc.login(); return lnc; } - static async Connect(password: string) { - const lnc = new LNCWallet(undefined, password); - 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 { throw new Error("Not implemented"); } - async getInfo(): Promise { + async getInfo(): Promise { const nodeInfo = await this.#lnc.lnd.lightning.getInfo(); return { nodePubKey: nodeInfo.identityPubkey, @@ -35,14 +63,18 @@ export class LNCWallet implements LNWallet { } as WalletInfo; } - close(): Promise { + close(): Promise { if (this.#lnc.isConnected) { this.#lnc.disconnect(); } return Promise.resolve(true); } - async login(): Promise { + async login(password?: string): Promise { + if (password) { + this.setPassword(password); + this.#lnc.run(); + } await this.#lnc.connect(); while (!this.#lnc.isConnected) { await new Promise(resolve => { @@ -52,32 +84,68 @@ export class LNCWallet implements LNWallet { return true; } - async getBalance(): Promise { + async getBalance(): Promise { const rsp = await this.#lnc.lnd.lightning.channelBalance(); console.debug(rsp); return parseInt(rsp.localBalance?.sat ?? "0"); } - createInvoice(req: InvoiceRequest): Promise { - throw new Error("Not implemented"); + async createInvoice(req: InvoiceRequest): Promise { + const rsp = await this.#lnc.lnd.lightning.addInvoice({ + memo: req.memo, + value: req.amount.toString(), + expiry: req.expiry?.toString(), + }); + return unwrap(prToWalletInvoice(rsp.paymentRequest)); } - payInvoice(pr: string): Promise { - throw new Error("Not implemented"); + async payInvoice(pr: string): Promise { + return new Promise((resolve, reject) => { + this.#lnc.lnd.router.sendPaymentV2( + { + paymentRequest: pr, + timeoutSeconds: 60, + feeLimitSat: "100", + }, + msg => { + console.debug(msg); + if (msg.status === Payment_PaymentStatus.SUCCEEDED) { + resolve({ + preimage: msg.paymentPreimage, + state: WalletInvoiceState.Paid, + timestamp: parseInt(msg.creationTimeNs) / 1e9, + } as WalletInvoice); + } + }, + err => { + console.debug(err); + reject(err); + } + ); + }); } - async getInvoices(): Promise { + async getInvoices(): Promise { const invoices = await this.#lnc.lnd.lightning.listPayments({ - includeIncomplete: true, maxPayments: "10", reversed: true, }); return invoices.payments.map(a => { + const parsedInvoice = prToWalletInvoice(a.paymentRequest); return { - amount: parseInt(a.valueSat), - state: a.status === "SUCCEEDED" ? WalletInvoiceState.Paid : WalletInvoiceState.Pending, - timestamp: parseInt(a.creationTimeNs) / 1e9, + ...parsedInvoice, + state: (() => { + switch (a.status) { + case Payment_PaymentStatus.SUCCEEDED: + return; + case Payment_PaymentStatus.FAILED: + return WalletInvoiceState.Failed; + default: + return WalletInvoiceState.Pending; + } + })(), + preimage: a.paymentPreimage, } as WalletInvoice; }); } diff --git a/packages/app/src/Wallet/LNDHub.ts b/packages/app/src/Wallet/LNDHub.ts index 52d2993d..a649866d 100644 --- a/packages/app/src/Wallet/LNDHub.ts +++ b/packages/app/src/Wallet/LNDHub.ts @@ -2,9 +2,10 @@ import { EventPublisher } from "Feed/EventPublisher"; import { InvoiceRequest, LNWallet, + prToWalletInvoice, Sats, - UnknownWalletError, WalletError, + WalletErrorCode, WalletInfo, WalletInvoice, WalletInvoiceState, @@ -47,12 +48,12 @@ export default class LNDHubWallet implements LNWallet { } } - close(): Promise { - throw new Error("Not implemented"); + isReady(): boolean { + return this.auth !== undefined; } - async createAccount() { - return Promise.resolve(UnknownWalletError); + close(): Promise { + return Promise.resolve(true); } async getInfo() { @@ -66,19 +67,12 @@ export default class LNDHubWallet implements LNWallet { login: this.user, password: this.password, }); - - if ("error" in rsp) { - return rsp as WalletError; - } this.auth = rsp as AuthResponse; return true; } - async getBalance(): Promise { + async getBalance(): Promise { const rsp = await this.getJson("GET", "/balance"); - if ("error" in rsp) { - return rsp as WalletError; - } const bal = Math.floor((rsp as GetBalanceResponse).BTC.AvailableBalance); return bal as Sats; } @@ -88,9 +82,6 @@ export default class LNDHubWallet implements LNWallet { amt: req.amount, memo: req.memo, }); - if ("error" in rsp) { - return rsp as WalletError; - } const pRsp = rsp as UserInvoicesResponse; return { @@ -107,10 +98,6 @@ export default class LNDHubWallet implements LNWallet { invoice: pr, }); - if ("error" in rsp) { - return rsp as WalletError; - } - const pRsp = rsp as PayInvoiceResponse; return { pr: pr, @@ -119,24 +106,23 @@ export default class LNDHubWallet implements LNWallet { } as WalletInvoice; } - async getInvoices(): Promise { + async getInvoices(): Promise { const rsp = await this.getJson("GET", "/getuserinvoices"); - if ("error" in rsp) { - return rsp as WalletError; - } return (rsp as UserInvoicesResponse[]).map(a => { + const decodedInvoice = prToWalletInvoice(a.payment_request); + if (!decodedInvoice) { + throw new WalletError(WalletErrorCode.InvalidInvoice, "Failed to parse invoice"); + } return { - memo: a.description, - amount: Math.floor(a.amt), - timestamp: a.timestamp, - state: a.ispaid ? WalletInvoiceState.Paid : WalletInvoiceState.Pending, - pr: a.payment_request, + ...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?: any): Promise { + private async getJson(method: "GET" | "POST", path: string, body?: unknown): Promise { let auth = `Bearer ${this.auth?.access_token}`; if (this.type === "snort") { const ev = await this.publisher?.generic(`${new URL(this.url).pathname}${path}`, 30_000); @@ -152,7 +138,8 @@ export default class LNDHubWallet implements LNWallet { }); const json = await rsp.json(); if ("error" in json) { - return json as WalletError; + const err = json as ErrorResponse; + throw new WalletError(err.code, err.message); } return json as T; } @@ -188,3 +175,8 @@ interface PayInvoiceResponse { payment_preimage: string; payment_route?: { total_amt: number; total_fees: number }; } + +interface ErrorResponse { + code: number; + message: string; +} diff --git a/packages/app/src/Wallet/WebLN.ts b/packages/app/src/Wallet/WebLN.ts new file mode 100644 index 00000000..ce7bc7ce --- /dev/null +++ b/packages/app/src/Wallet/WebLN.ts @@ -0,0 +1,181 @@ +import { + InvoiceRequest, + LNWallet, + prToWalletInvoice, + Sats, + WalletConfig, + WalletError, + WalletErrorCode, + WalletInfo, + WalletInvoice, + WalletInvoiceState, + WalletKind, + WalletStore, +} from "Wallet"; +import { delay } from "Util"; + +let isWebLnBusy = false; +export const barrierWebLn = async (then: () => Promise): Promise => { + while (isWebLnBusy) { + await delay(10); + } + isWebLnBusy = true; + try { + return await then(); + } finally { + isWebLnBusy = false; + } +}; + +interface SendPaymentResponse { + paymentHash?: string; + preimage: string; + route?: { + total_amt: number; + total_fees: number; + }; +} + +interface RequestInvoiceArgs { + amount?: string | number; + defaultAmount?: string | number; + minimumAmount?: string | number; + maximumAmount?: string | number; + defaultMemo?: string; +} + +interface RequestInvoiceResponse { + paymentRequest: string; +} + +interface GetInfoResponse { + node: { + alias: string; + pubkey: string; + color?: string; + }; +} + +interface SignMessageResponse { + message: string; + signature: string; +} + +interface WebLN { + enabled: boolean; + getInfo(): Promise; + enable(): Promise; + makeInvoice(args: RequestInvoiceArgs): Promise; + signMessage(message: string): Promise; + verifyMessage(signature: string, message: string): Promise; + sendPayment: (pr: string) => Promise; +} + +declare global { + interface Window { + webln?: WebLN; + } +} + +/** + * Adds a wallet config for WebLN if detected + */ +export function setupWebLNWalletConfig(store: WalletStore) { + const wallets = store.list(); + if (window.webln && !wallets.some(a => a.kind === WalletKind.WebLN)) { + const newConfig = { + id: "webln", + kind: WalletKind.WebLN, + active: wallets.length === 0, + info: { + alias: "WebLN", + }, + } as WalletConfig; + store.add(newConfig); + } +} + +export class WebLNWallet implements LNWallet { + isReady(): boolean { + if (window.webln) { + return true; + } + throw new WalletError(WalletErrorCode.GeneralError, "WebLN not available"); + } + + async getInfo(): Promise { + await this.login(); + if (this.isReady()) { + const rsp = await barrierWebLn(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 { + if (window.webln && !window.webln.enabled) { + await window.webln.enable(); + } + return true; + } + + close(): Promise { + return Promise.resolve(true); + } + + getBalance(): Promise { + return Promise.resolve(0); + } + + async createInvoice(req: InvoiceRequest): Promise { + await this.login(); + if (this.isReady()) { + const rsp = await barrierWebLn( + 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 { + await this.login(); + if (this.isReady()) { + const invoice = prToWalletInvoice(pr); + if (!invoice) { + throw new WalletError(WalletErrorCode.InvalidInvoice, "Could not parse invoice"); + } + const rsp = await barrierWebLn(async () => await window.webln?.sendPayment(pr)); + if (rsp) { + invoice.state = WalletInvoiceState.Paid; + invoice.preimage = rsp.preimage; + return invoice; + } else { + invoice.state = WalletInvoiceState.Failed; + return invoice; + } + } + throw new WalletError(WalletErrorCode.GeneralError, "WebLN not available"); + } + + getInvoices(): Promise { + return Promise.resolve([]); + } +} diff --git a/packages/app/src/Wallet/index.ts b/packages/app/src/Wallet/index.ts index cc94f8b3..a53377e0 100644 --- a/packages/app/src/Wallet/index.ts +++ b/packages/app/src/Wallet/index.ts @@ -1,3 +1,16 @@ +import { useSyncExternalStore } from "react"; + +import { decodeInvoice, unwrap } from "Util"; +import { LNCWallet } from "./LNCWallet"; +import LNDHubWallet from "./LNDHub"; +import { setupWebLNWalletConfig, WebLNWallet } from "./WebLN"; + +export enum WalletKind { + LNDHub = 1, + LNC = 2, + WebLN = 3, +} + export enum WalletErrorCode { BadAuth = 1, NotEnoughBalance = 2, @@ -8,9 +21,13 @@ export enum WalletErrorCode { NodeFailure = 7, } -export interface WalletError { +export class WalletError extends Error { code: WalletErrorCode; - message: string; + + constructor(c: WalletErrorCode, msg: string) { + super(msg); + this.code = c; + } } export const UnknownWalletError = { @@ -39,7 +56,7 @@ export interface Login { } export interface InvoiceRequest { - amount: number; + amount: Sats; memo?: string; expiry?: number; } @@ -48,31 +65,199 @@ export enum WalletInvoiceState { Pending = 0, Paid = 1, Expired = 2, + Failed = 3, } export interface WalletInvoice { pr: string; paymentHash: string; memo: string; - amount: number; + amount: MilliSats; fees: number; timestamp: number; + preimage?: string; state: WalletInvoiceState; } +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, + state: parsedInvoice.expired ? WalletInvoiceState.Expired : WalletInvoiceState.Pending, + pr, + } as WalletInvoice; + } +} + export type Sats = number; +export type MilliSats = number; export interface LNWallet { - createAccount: () => Promise; - getInfo: () => Promise; - login: () => Promise; - close: () => Promise; - getBalance: () => Promise; - createInvoice: (req: InvoiceRequest) => Promise; - payInvoice: (pr: string) => Promise; - getInvoices: () => Promise; + isReady(): boolean; + getInfo: () => Promise; + login: (password?: string) => Promise; + close: () => Promise; + getBalance: () => Promise; + createInvoice: (req: InvoiceRequest) => Promise; + payInvoice: (pr: string) => Promise; + getInvoices: () => Promise; } -export function useWallet(): LNWallet | undefined { - return undefined; +export interface WalletConfig { + id: string; + kind: WalletKind; + active: boolean; + info: WalletInfo; + data?: string; +} + +export interface WalletStoreSnapshot { + configs: Array; + config?: WalletConfig; + wallet?: LNWallet; +} + +type WalletStateHook = (state: WalletStoreSnapshot) => void; + +export class WalletStore { + #configs: Array; + #instance: Map; + + #hooks: Array; + #snapshot: Readonly; + + constructor() { + this.#configs = []; + this.#instance = new Map(); + this.#hooks = []; + this.#snapshot = Object.freeze({ + configs: [], + }); + this.load(false); + setupWebLNWalletConfig(this); + this.snapshotState(); + } + + hook(fn: WalletStateHook) { + this.#hooks.push(fn); + return () => { + const idx = this.#hooks.findIndex(a => a === fn); + this.#hooks = this.#hooks.splice(idx, 1); + }; + } + + list() { + return Object.freeze([...this.#configs]); + } + + get() { + const activeConfig = this.#configs.find(a => a.active); + if (!activeConfig) { + if (this.#configs.length === 0) { + return undefined; + } + throw new Error("No active wallet config"); + } + if (this.#instance.has(activeConfig.id)) { + return unwrap(this.#instance.get(activeConfig.id)); + } else { + const w = this.#activateWallet(activeConfig); + if (w) { + this.#instance.set(activeConfig.id, w); + return w; + } else { + throw new Error("Unable to activate wallet config"); + } + } + } + + add(cfg: WalletConfig) { + this.#configs.push(cfg); + this.save(); + } + + remove(id: string) { + const idx = this.#configs.findIndex(a => a.id === id); + if (idx === -1) { + throw new Error("Wallet not found"); + } + const [removed] = this.#configs.splice(idx, 1); + if (removed.active && this.#configs.length > 0) { + this.#configs[0].active = true; + } + this.save(); + } + + switch(id: string) { + this.#configs.forEach(a => (a.active = a.id === id)); + this.save(); + } + + save() { + const json = JSON.stringify(this.#configs); + window.localStorage.setItem("wallet-config", json); + this.snapshotState(); + } + + load(snapshot = true) { + const cfg = window.localStorage.getItem("wallet-config"); + if (cfg) { + this.#configs = JSON.parse(cfg); + } + if (snapshot) { + this.snapshotState(); + } + } + + free() { + this.#instance.forEach(w => w.close()); + } + + getSnapshot() { + return this.#snapshot; + } + + snapshotState() { + const newState = { + configs: [...this.#configs], + config: this.#configs.find(a => a.active), + wallet: this.get(), + } as WalletStoreSnapshot; + this.#snapshot = Object.freeze(newState); + for (const hook of this.#hooks) { + console.debug(this.#snapshot); + hook(this.#snapshot); + } + } + + #activateWallet(cfg: WalletConfig): LNWallet | undefined { + switch (cfg.kind) { + case WalletKind.LNC: { + const w = LNCWallet.Empty(); + return w; + } + case WalletKind.WebLN: { + return new WebLNWallet(); + } + case WalletKind.LNDHub: { + return new LNDHubWallet(unwrap(cfg.data)); + } + } + } +} + +export const Wallets = new WalletStore(); +window.document.addEventListener("close", () => { + Wallets.free(); +}); + +export function useWallet() { + return useSyncExternalStore( + h => Wallets.hook(h), + () => Wallets.getSnapshot() + ); } diff --git a/packages/app/src/lnd-logo.png b/packages/app/src/lnd-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ab7f04668d3e39c203da451b9e1dadd32d508e15 GIT binary patch literal 5540 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