diff --git a/packages/app/d.ts b/packages/app/d.ts index 78ed95f..a03d3b2 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/package.json b/packages/app/package.json index 4f9cc05..35deb4c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -7,6 +7,7 @@ "@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/react-fontawesome": "^0.2.0", "@jukben/emoji-search": "^2.0.1", + "@lightninglabs/lnc-web": "^0.2.3-alpha", "@noble/hashes": "^1.2.0", "@noble/secp256k1": "^1.7.0", "@protobufjs/base64": "^1.1.2", diff --git a/packages/app/public/index.html b/packages/app/public/index.html index 7da8ed6..c45a433 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 85539bf..9aa2570 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 f4b3b9b..f63a784 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 f4c76ba..3b96dfb 100644 --- a/packages/app/src/Element/SendSats.tsx +++ b/packages/app/src/Element/SendSats.tsx @@ -14,11 +14,11 @@ 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"; import messages from "./messages"; +import { useWallet } from "Wallet"; enum ZapType { PublicZap = 1, @@ -76,10 +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 walletState = useWallet(); + const wallet = walletState.wallet; useEffect(() => { if (props.show) { @@ -154,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)); @@ -204,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 ?? {}); } @@ -306,7 +307,12 @@ export default function SendSats(props: SendSatsProps) { {props.notice && {props.notice}} {paying ? (

- + ...

) : ( diff --git a/packages/app/src/Element/Zap.tsx b/packages/app/src/Element/Zap.tsx index a2ede41..823c20d 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/Feed/EventPublisher.ts b/packages/app/src/Feed/EventPublisher.ts index 7234e33..ce8c94f 100644 --- a/packages/app/src/Feed/EventPublisher.ts +++ b/packages/app/src/Feed/EventPublisher.ts @@ -8,6 +8,7 @@ import { HexKey, RawEvent, u256, UserMetadata, Lists } from "@snort/nostr"; import { bech32ToHex, delay, unwrap } from "Util"; import { DefaultRelays, HashtagRegex } from "Const"; import { System } from "System"; +import { useMemo } from "react"; declare global { interface Window { @@ -23,6 +24,8 @@ declare global { } } +export type EventPublisher = ReturnType; + export default function useEventPublisher() { const pubKey = useSelector(s => s.login.publicKey); const privKey = useSelector(s => s.login.privateKey); @@ -78,7 +81,7 @@ export default function useEventPublisher() { ev.Content = content; } - return { + const ret = { nip42Auth: async (challenge: string, relay: string) => { if (pubKey) { const ev = NEvent.ForPubKey(pubKey); @@ -393,7 +396,17 @@ export default function useEventPublisher() { publicKey: pubKey, }; }, + generic: async (content: string, kind: EventKind) => { + if (pubKey) { + const ev = NEvent.ForPubKey(pubKey); + ev.Kind = kind; + ev.Content = content; + return await signEvent(ev); + } + }, }; + + return useMemo(() => ret, [pubKey, relays, follows]); } let isNip07Busy = false; diff --git a/packages/app/src/Hooks/useWebln.ts b/packages/app/src/Hooks/useWebln.ts deleted file mode 100644 index b7b5717..0000000 --- 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/Bitcoin.tsx b/packages/app/src/Icons/Bitcoin.tsx new file mode 100644 index 0000000..f05ccf3 --- /dev/null +++ b/packages/app/src/Icons/Bitcoin.tsx @@ -0,0 +1,15 @@ +const Bitcoin = () => { + return ( + + + + ); +}; + +export default Bitcoin; diff --git a/packages/app/src/Icons/BlueWallet.tsx b/packages/app/src/Icons/BlueWallet.tsx new file mode 100644 index 0000000..75b9e69 --- /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/Layout.tsx b/packages/app/src/Pages/Layout.tsx index f30033c..c1758da 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -23,6 +23,7 @@ import Plus from "Icons/Plus"; import { RelaySettings } from "@snort/nostr"; import { FormattedMessage } from "react-intl"; import messages from "./messages"; +import Bitcoin from "Icons/Bitcoin"; export default function Layout() { const location = useLocation(); @@ -197,6 +198,9 @@ export default function Layout() { function accountHeader() { return (
+
navigate("/wallet")}> + +
navigate("/search")}>
diff --git a/packages/app/src/Pages/SettingsPage.tsx b/packages/app/src/Pages/SettingsPage.tsx index 5020797..aa77ee9 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.css b/packages/app/src/Pages/WalletPage.css new file mode 100644 index 0000000..b6cf009 --- /dev/null +++ b/packages/app/src/Pages/WalletPage.css @@ -0,0 +1,16 @@ +.wallet-history-item { +} + +.wallet-history-item time { + font-size: small; + color: var(--font-tertiary-color); + line-height: 1.5em; +} + +.pending { + color: var(--font-tertiary-color); +} + +.wallet-buttons > button { + margin: 10px; +} diff --git a/packages/app/src/Pages/WalletPage.tsx b/packages/app/src/Pages/WalletPage.tsx new file mode 100644 index 0000000..9301188 --- /dev/null +++ b/packages/app/src/Pages/WalletPage.tsx @@ -0,0 +1,216 @@ +import "./WalletPage.css"; + +import { useEffect, useState } from "react"; +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, Wallets, WalletKind } from "Wallet"; +import AsyncButton from "Element/AsyncButton"; +import { unwrap } from "Util"; +import { WebLNWallet } from "Wallet/WebLN"; + +export const WalletRoutes: RouteObject[] = [ + { + path: "/wallet", + element: , + }, +]; + +export default function WalletPage() { + const navigate = useNavigate(); + const { formatMessage } = useIntl(); + const [info, setInfo] = useState(); + const [balance, setBalance] = useState(); + const [history, setHistory] = useState(); + const [walletPassword, setWalletPassword] = useState(); + const [error, setError] = useState(); + const walletState = useWallet(); + const wallet = walletState.wallet; + + async function loadWallet(wallet: LNWallet) { + 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" })); + } + } + } + + useEffect(() => { + if (wallet) { + 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]); + + function stateIcon(s: WalletInvoiceState) { + switch (s) { + case WalletInvoiceState.Pending: + return ; + case WalletInvoiceState.Paid: + return ; + case WalletInvoiceState.Expired: + return ; + } + } + + async function loginWallet(pw: string) { + if (wallet) { + await wallet.login(pw); + await loadWallet(wallet); + setWalletPassword(undefined); + } + } + + 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 595f9ff..8fffe87 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 0000000..d24cef7 --- /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 0000000..3c39149 --- /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 0000000..0f3ebbe --- /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 0000000..869b127 --- /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 c5fdab6..cca139c 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 new file mode 100644 index 0000000..b00dd98 --- /dev/null +++ b/packages/app/src/Wallet/LNCWallet.ts @@ -0,0 +1,152 @@ +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; + + private constructor(pairingPhrase?: string, password?: string) { + this.#lnc = new LNC({ + pairingPhrase, + password, + }); + } + + isReady(): boolean { + return this.#lnc.isReady; + } + + static async Initialize(pairingPhrase: string) { + const lnc = new LNCWallet(pairingPhrase); + await lnc.login(); + return lnc; + } + + static Empty() { + return new LNCWallet(); + } + + setPassword(pw: string) { + if (this.#lnc.credentials.password && pw !== this.#lnc.credentials.password) { + throw new WalletError(WalletErrorCode.GeneralError, "Password is already set, cannot update password"); + } + this.#lnc.credentials.password = pw; + } + + createAccount(): Promise { + throw new Error("Not implemented"); + } + + async getInfo(): Promise { + const nodeInfo = await this.#lnc.lnd.lightning.getInfo(); + return { + nodePubKey: nodeInfo.identityPubkey, + alias: nodeInfo.alias, + } as WalletInfo; + } + + close(): Promise { + if (this.#lnc.isConnected) { + this.#lnc.disconnect(); + } + return Promise.resolve(true); + } + + 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 => { + setTimeout(resolve, 100); + }); + } + return true; + } + + async getBalance(): Promise { + const rsp = await this.#lnc.lnd.lightning.channelBalance(); + console.debug(rsp); + return parseInt(rsp.localBalance?.sat ?? "0"); + } + + 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)); + } + + 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 { + const invoices = await this.#lnc.lnd.lightning.listPayments({ + maxPayments: "10", + reversed: true, + }); + + return invoices.payments.map(a => { + const parsedInvoice = prToWalletInvoice(a.paymentRequest); + return { + ...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 new file mode 100644 index 0000000..a649866 --- /dev/null +++ b/packages/app/src/Wallet/LNDHub.ts @@ -0,0 +1,182 @@ +import { EventPublisher } from "Feed/EventPublisher"; +import { + InvoiceRequest, + LNWallet, + prToWalletInvoice, + Sats, + WalletError, + WalletErrorCode, + WalletInfo, + WalletInvoice, + WalletInvoiceState, +} from "Wallet"; + +const defaultHeaders = { + Accept: "application/json", + "Content-Type": "application/json", +}; + +export default class LNDHubWallet implements LNWallet { + type: "lndhub" | "snort"; + url: string; + user: string; + password: string; + auth?: AuthResponse; + publisher?: EventPublisher; + + constructor(url: string, publisher?: EventPublisher) { + if (url.startsWith("lndhub://")) { + const regex = /^lndhub:\/\/([\S-]+):([\S-]+)@(.*)$/i; + const parsedUrl = url.match(regex); + console.debug(parsedUrl); + if (!parsedUrl || parsedUrl.length !== 4) { + throw new Error("Invalid LNDHUB config"); + } + this.url = new URL(parsedUrl[3]).toString(); + this.user = parsedUrl[1]; + this.password = parsedUrl[2]; + this.type = "lndhub"; + } else if (url.startsWith("snort://")) { + const u = new URL(url); + this.url = `https://${u.host}${u.pathname}`; + this.user = ""; + this.password = ""; + this.type = "snort"; + this.publisher = publisher; + } else { + throw new Error("Invalid config"); + } + } + + isReady(): boolean { + return this.auth !== undefined; + } + + close(): Promise { + return Promise.resolve(true); + } + + async getInfo() { + return await this.getJson("GET", "/getinfo"); + } + + async login() { + if (this.type === "snort") return true; + + const rsp = await this.getJson("POST", "/auth?type=auth", { + login: this.user, + password: this.password, + }); + this.auth = rsp as AuthResponse; + return true; + } + + async getBalance(): Promise { + const rsp = await this.getJson("GET", "/balance"); + const bal = Math.floor((rsp as GetBalanceResponse).BTC.AvailableBalance); + return bal as Sats; + } + + async createInvoice(req: InvoiceRequest) { + const rsp = await this.getJson("POST", "/addinvoice", { + amt: req.amount, + memo: req.memo, + }); + + const pRsp = rsp as UserInvoicesResponse; + return { + pr: pRsp.payment_request, + memo: req.memo, + amount: req.amount, + paymentHash: pRsp.payment_hash, + timestamp: pRsp.timestamp, + } as WalletInvoice; + } + + async payInvoice(pr: string) { + const rsp = await this.getJson("POST", "/payinvoice", { + invoice: pr, + }); + + const pRsp = rsp as PayInvoiceResponse; + return { + pr: pr, + paymentHash: pRsp.payment_hash, + state: pRsp.payment_error === undefined ? WalletInvoiceState.Paid : WalletInvoiceState.Pending, + } as WalletInvoice; + } + + 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; + }); + } + + 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); + auth = JSON.stringify(ev?.ToObject()); + } + const rsp = await fetch(`${this.url}${path}`, { + method: method, + body: body ? JSON.stringify(body) : undefined, + headers: { + ...defaultHeaders, + Authorization: auth, + }, + }); + const json = await rsp.json(); + if ("error" in json) { + const err = json as ErrorResponse; + throw new WalletError(err.code, err.message); + } + return json as T; + } +} + +interface AuthResponse { + refresh_token?: string; + access_token?: string; + token_type?: string; +} + +interface GetBalanceResponse { + BTC: { + AvailableBalance: number; + }; +} + +interface UserInvoicesResponse { + amt: number; + description: string; + ispaid: boolean; + type: string; + timestamp: number; + pay_req: string; + payment_hash: string; + payment_request: string; + r_hash: string; +} + +interface PayInvoiceResponse { + payment_error?: string; + payment_hash: string; + payment_preimage: string; + payment_route?: { total_amt: number; total_fees: number }; +} + +interface ErrorResponse { + code: number; + message: string; +} diff --git a/packages/app/src/Wallet/WebLN.ts b/packages/app/src/Wallet/WebLN.ts new file mode 100644 index 0000000..ce7bc7c --- /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 new file mode 100644 index 0000000..a53377e --- /dev/null +++ b/packages/app/src/Wallet/index.ts @@ -0,0 +1,263 @@ +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, + BadPartner = 3, + InvalidInvoice = 4, + RouteNotFound = 5, + GeneralError = 6, + NodeFailure = 7, +} + +export class WalletError extends Error { + code: WalletErrorCode; + + constructor(c: WalletErrorCode, msg: string) { + super(msg); + this.code = c; + } +} + +export const UnknownWalletError = { + code: WalletErrorCode.GeneralError, + message: "Unknown error", +} as WalletError; + +export interface WalletInfo { + fee: number; + nodePubKey: string; + alias: string; + pendingChannels: number; + activeChannels: number; + peers: number; + blockHeight: number; + blockHash: string; + synced: boolean; + chains: string[]; + version: string; +} + +export interface Login { + service: string; + save: () => Promise; + load: () => Promise; +} + +export interface InvoiceRequest { + amount: Sats; + memo?: string; + expiry?: number; +} + +export enum WalletInvoiceState { + Pending = 0, + Paid = 1, + Expired = 2, + Failed = 3, +} + +export interface WalletInvoice { + pr: string; + paymentHash: string; + memo: string; + amount: MilliSats; + fees: number; + timestamp: number; + preimage?: string; + state: WalletInvoiceState; +} + +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 { + isReady(): boolean; + getInfo: () => Promise; + login: (password?: string) => Promise; + close: () => Promise; + getBalance: () => Promise; + createInvoice: (req: InvoiceRequest) => Promise; + payInvoice: (pr: string) => Promise; + getInvoices: () => Promise; +} + +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/index.tsx b/packages/app/src/index.tsx index 585ff3e..f5bb266 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -8,6 +8,7 @@ import { Provider } from "react-redux"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; import * as serviceWorkerRegistration from "serviceWorkerRegistration"; +import { IntlProvider } from "IntlProvider"; import Store from "State/Store"; import EventPage from "Pages/EventPage"; import Layout from "Pages/Layout"; @@ -25,8 +26,8 @@ import HashTagsPage from "Pages/HashTagsPage"; import SearchPage from "Pages/SearchPage"; import HelpPage from "Pages/HelpPage"; import { NewUserRoutes } from "Pages/new"; +import { WalletRoutes } from "Pages/WalletPage"; import NostrLinkHandler from "Pages/NostrLinkHandler"; -import { IntlProvider } from "./IntlProvider"; import { unwrap } from "Util"; /** @@ -99,6 +100,7 @@ export const router = createBrowserRouter([ element: , }, ...NewUserRoutes, + ...WalletRoutes, ], }, ]); diff --git a/packages/app/src/lnd-logo.png b/packages/app/src/lnd-logo.png new file mode 100644 index 0000000..ab7f046 Binary files /dev/null and b/packages/app/src/lnd-logo.png differ diff --git a/packages/nostr/src/legacy/EventKind.ts b/packages/nostr/src/legacy/EventKind.ts index 2ab6297..5b4bb0d 100644 --- a/packages/nostr/src/legacy/EventKind.ts +++ b/packages/nostr/src/legacy/EventKind.ts @@ -9,6 +9,7 @@ enum EventKind { Repost = 6, // NIP-18 Reaction = 7, // NIP-25 Relays = 10002, // NIP-65 + Ephemeral = 20_000, Auth = 22242, // NIP-42 PubkeyLists = 30000, // NIP-51a NoteLists = 30001, // NIP-51b diff --git a/yarn.lock b/yarn.lock index 044d804..3ff2f76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1681,6 +1681,19 @@ resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A== +"@lightninglabs/lnc-core@0.2.3-alpha": + version "0.2.3-alpha" + resolved "https://registry.yarnpkg.com/@lightninglabs/lnc-core/-/lnc-core-0.2.3-alpha.tgz#e1b92a9071d1dfb92e2d565710b56f28f57cbbd4" + integrity sha512-93D/uU64ayAaJv5kv4Pqwvkt+uT7yCtmHD08aUzvql+lbWm6U7m5loZLxz7tACFLXVPOQ8OHJL25W+3QMEYthg== + +"@lightninglabs/lnc-web@^0.2.3-alpha": + version "0.2.3-alpha" + resolved "https://registry.yarnpkg.com/@lightninglabs/lnc-web/-/lnc-web-0.2.3-alpha.tgz#d41184d815034c15fc9966da390222babc4eb19c" + integrity sha512-Pr02Ti9a0YzEIP2FTJT+IuoE02xgXqhMKoo8lK+Y6kSf3xk8/wJXJssFMA96iWV5Phf1Ra9ynmLVIQjD176BxA== + dependencies: + "@lightninglabs/lnc-core" "0.2.3-alpha" + crypto-js "4.1.1" + "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": version "5.1.1-v1" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129" @@ -3716,6 +3729,11 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypto-js@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.1.1.tgz#9e485bcf03521041bd85844786b83fb7619736cf" + integrity sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw== + crypto-random-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"