diff --git a/packages/app/src/Icons/Bitcoin.tsx b/packages/app/src/Icons/Bitcoin.tsx new file mode 100644 index 00000000..807496fb --- /dev/null +++ b/packages/app/src/Icons/Bitcoin.tsx @@ -0,0 +1,21 @@ +const Bitcoin = () => { + return ( + + + + ); +}; + +export default Bitcoin; diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index f30033ca..dfa95165 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(); @@ -44,6 +45,9 @@ export default function Layout() { const [pageClass, setPageClass] = useState("page"); const pub = useEventPublisher(); useLoginFeed(); + useEffect(() => { + System.nip42Auth = pub.nip42Auth; + }, [pub]); const shouldHideNoteCreator = useMemo(() => { const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/p/"]; @@ -197,6 +201,9 @@ export default function Layout() { function accountHeader() { return (
+
navigate("/wallet")}> + +
navigate("/search")}>
diff --git a/packages/app/src/Pages/WalletPage.css b/packages/app/src/Pages/WalletPage.css new file mode 100644 index 00000000..b6cf0090 --- /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 00000000..64c148fb --- /dev/null +++ b/packages/app/src/Pages/WalletPage.tsx @@ -0,0 +1,88 @@ +import "./WalletPage.css"; + +import { useEffect, useState } from "react"; +import { RouteObject } from "react-router-dom"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCheck, faClock, faXmark } from "@fortawesome/free-solid-svg-icons"; + +import NoteTime from "Element/NoteTime"; +import { + openWallet, + WalletInvoice, + Sats, + WalletInfo, + WalletInvoiceState, +} from "Wallet"; + +export const WalletRoutes: RouteObject[] = [ + { + path: "/wallet", + element: , + }, +]; + +export default function WalletPage() { + const [info, setInfo] = useState(); + const [balance, setBalance] = useState(); + const [history, setHistory] = useState(); + + async function loadWallet() { + let cfg = window.localStorage.getItem("wallet-lndhub"); + if (cfg) { + let wallet = await openWallet(cfg); + let i = await wallet.getInfo(); + if ("error" in i) { + return; + } + setInfo(i as WalletInfo); + let b = await wallet.getBalance(); + setBalance(b as Sats); + let h = await wallet.getInvoices(); + setHistory( + (h as WalletInvoice[]).sort((a, b) => b.timestamp - a.timestamp) + ); + } + } + + useEffect(() => { + loadWallet().catch(console.warn); + }, []); + + function stateIcon(s: WalletInvoiceState) { + switch (s) { + case WalletInvoiceState.Pending: + return ; + case WalletInvoiceState.Paid: + return ; + case WalletInvoiceState.Expired: + return ; + } + } + 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 +
+
+ ))} + + ); +} diff --git a/packages/app/src/Pages/messages.js b/packages/app/src/Pages/messages.js new file mode 100644 index 00000000..87a38ad9 --- /dev/null +++ b/packages/app/src/Pages/messages.js @@ -0,0 +1,8 @@ +import { defineMessages } from "react-intl"; +import { addIdAndDefaultMessageToMessages } from "Util"; + +const messages = defineMessages({ + Login: "Login", +}); + +export default addIdAndDefaultMessageToMessages(messages, "Pages"); diff --git a/packages/app/src/Wallet/LNDHub.ts b/packages/app/src/Wallet/LNDHub.ts new file mode 100644 index 00000000..0a35dd5a --- /dev/null +++ b/packages/app/src/Wallet/LNDHub.ts @@ -0,0 +1,134 @@ +import { + InvoiceRequest, + LNWallet, + Sats, + UnknownWalletError, + WalletError, + WalletInfo, + WalletInvoice, + WalletInvoiceState, +} from "Wallet"; + +const defaultHeaders = { + Accept: "application/json", + "Content-Type": "application/json", +}; + +export default class LNDHubWallet implements LNWallet { + url: string; + user: string; + password: string; + auth?: AuthResponse; + + constructor(url: string) { + const regex = /^lndhub:\/\/([\S-]+):([\S-]+)@(.*)$/i; + const parsedUrl = url.match(regex); + 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]; + } + + async createAccount() { + return Promise.resolve(UnknownWalletError); + } + + async getInfo() { + return await this.getJson("GET", "/getinfo"); + } + + async login() { + const rsp = await this.getJson("POST", "/auth?type=auth", { + login: this.user, + password: this.password, + }); + + if ("error" in rsp) { + return rsp as WalletError; + } + this.auth = rsp as AuthResponse; + return true; + } + + async getBalance(): Promise { + let rsp = await this.getJson("GET", "/balance"); + if ("error" in rsp) { + return rsp as WalletError; + } + let bal = Math.floor((rsp as GetBalanceResponse).BTC.AvailableBalance); + return bal as Sats; + } + + async createInvoice(req: InvoiceRequest) { + return Promise.resolve(UnknownWalletError); + } + + async payInvoice(pr: string) { + return Promise.resolve(UnknownWalletError); + } + + async getInvoices(): Promise { + let rsp = await this.getJson( + "GET", + "/getuserinvoices" + ); + if ("error" in rsp) { + return rsp as WalletError; + } + return (rsp as GetUserInvoicesResponse[]).map((a) => { + return { + memo: a.description, + amount: Math.floor(a.amt), + timestamp: a.timestamp, + state: a.ispaid ? WalletInvoiceState.Paid : WalletInvoiceState.Pending, + pr: a.payment_request, + paymentHash: a.payment_hash, + } as WalletInvoice; + }); + } + + private async getJson( + method: "GET" | "POST", + path: string, + body?: any + ): Promise { + const rsp = await fetch(`${this.url}${path}`, { + method: method, + body: body ? JSON.stringify(body) : undefined, + headers: { + ...defaultHeaders, + Authorization: `Bearer ${this.auth?.access_token}`, + }, + }); + const json = await rsp.json(); + if ("error" in json) { + return json as WalletError; + } + return json as T; + } +} + +interface AuthResponse { + refresh_token?: string; + access_token?: string; + token_type?: string; +} + +interface GetBalanceResponse { + BTC: { + AvailableBalance: number; + }; +} + +interface GetUserInvoicesResponse { + amt: number; + description: string; + ispaid: boolean; + type: string; + timestamp: number; + pay_req: string; + payment_hash: string; + payment_request: string; +} diff --git a/packages/app/src/Wallet/index.ts b/packages/app/src/Wallet/index.ts new file mode 100644 index 00000000..101d3097 --- /dev/null +++ b/packages/app/src/Wallet/index.ts @@ -0,0 +1,81 @@ +import LNDHubWallet from "./LNDHub"; + +export enum WalletErrorCode { + BadAuth = 1, + NotEnoughBalance = 2, + BadPartner = 3, + InvalidInvoice = 4, + RouteNotFound = 5, + GeneralError = 6, + NodeFailure = 7, +} + +export interface WalletError { + code: WalletErrorCode; + message: string; +} + +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: number; + memo?: string; + expiry?: number; +} + +export enum WalletInvoiceState { + Pending = 0, + Paid = 1, + Expired = 2, +} + +export interface WalletInvoice { + pr: string; + paymentHash: string; + memo: string; + amount: number; + fees: number; + timestamp: number; + state: WalletInvoiceState; +} + +export type Sats = number; + +export interface LNWallet { + createAccount: () => Promise; + getInfo: () => Promise; + login: () => Promise; + getBalance: () => Promise; + createInvoice: (req: InvoiceRequest) => Promise; + payInvoice: (pr: string) => Promise; + getInvoices: () => Promise; +} + +export async function openWallet(config: string) { + let wallet = new LNDHubWallet(config); + await wallet.login(); + return wallet; +} diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index 585ff3e8..f15f79c5 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"; @@ -28,6 +29,7 @@ import { NewUserRoutes } from "Pages/new"; import NostrLinkHandler from "Pages/NostrLinkHandler"; import { IntlProvider } from "./IntlProvider"; import { unwrap } from "Util"; +import { WalletRoutes } from "Pages/WalletPage"; /** * HTTP query provider @@ -99,6 +101,7 @@ export const router = createBrowserRouter([ element: , }, ...NewUserRoutes, + ...WalletRoutes, ], }, ]);