feat: basic wallet

This commit is contained in:
Kieran 2023-01-31 18:56:31 +00:00
parent dc31ba9ad3
commit 7db8960914
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
8 changed files with 358 additions and 0 deletions

View File

@ -0,0 +1,21 @@
const Bitcoin = () => {
return (
<svg
width="16"
height="22"
viewBox="0 0 16 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.5 1V3M5.5 19V21M9.5 1V3M9.5 19V21M3.5 3H10C12.2091 3 14 4.79086 14 7C14 9.20914 12.2091 11 10 11H3.5H11C13.2091 11 15 12.7909 15 15C15 17.2091 13.2091 19 11 19H3.5M3.5 3H1.5M3.5 3V19M3.5 19H1.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export default Bitcoin;

View File

@ -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 (
<div className="header-actions">
<div className="btn btn-rnd" onClick={() => navigate("/wallet")}>
<Bitcoin />
</div>
<div className="btn btn-rnd" onClick={() => navigate("/search")}>
<Search />
</div>

View File

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

View File

@ -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: <WalletPage />,
},
];
export default function WalletPage() {
const [info, setInfo] = useState<WalletInfo>();
const [balance, setBalance] = useState<Sats>();
const [history, setHistory] = useState<WalletInvoice[]>();
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 <FontAwesomeIcon icon={faClock} className="mr5" />;
case WalletInvoiceState.Paid:
return <FontAwesomeIcon icon={faCheck} className="mr5" />;
case WalletInvoiceState.Expired:
return <FontAwesomeIcon icon={faXmark} className="mr5" />;
}
}
return (
<>
<h3>{info?.alias}</h3>
<b>Balance: {(balance ?? 0).toLocaleString()} sats</b>
<div className="flex wallet-buttons">
<button>Send</button>
<button>Receive</button>
</div>
<h3>History</h3>
{history?.map((a) => (
<div className="card flex wallet-history-item" key={a.timestamp}>
<div className="f-grow f-col">
<NoteTime from={a.timestamp * 1000} />
<div>{(a.memo ?? "").length === 0 ? <>&nbsp;</> : a.memo}</div>
</div>
<div
className={`${
a.state === WalletInvoiceState.Paid ? "success" : "pending"
}`}
>
{stateIcon(a.state)}
{a.amount.toLocaleString()} sats
</div>
</div>
))}
</>
);
}

View File

@ -0,0 +1,8 @@
import { defineMessages } from "react-intl";
import { addIdAndDefaultMessageToMessages } from "Util";
const messages = defineMessages({
Login: "Login",
});
export default addIdAndDefaultMessageToMessages(messages, "Pages");

View File

@ -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<WalletInfo>("GET", "/getinfo");
}
async login() {
const rsp = await this.getJson<AuthResponse>("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<Sats | WalletError> {
let rsp = await this.getJson<GetBalanceResponse>("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<WalletInvoice[] | WalletError> {
let rsp = await this.getJson<GetUserInvoicesResponse[]>(
"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<T>(
method: "GET" | "POST",
path: string,
body?: any
): Promise<T | WalletError> {
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;
}

View File

@ -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<void>;
load: () => Promise<void>;
}
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<Login | WalletError>;
getInfo: () => Promise<WalletInfo | WalletError>;
login: () => Promise<boolean | WalletError>;
getBalance: () => Promise<Sats | WalletError>;
createInvoice: (req: InvoiceRequest) => Promise<WalletInvoice | WalletError>;
payInvoice: (pr: string) => Promise<WalletInvoice | WalletError>;
getInvoices: () => Promise<WalletInvoice[] | WalletError>;
}
export async function openWallet(config: string) {
let wallet = new LNDHubWallet(config);
await wallet.login();
return wallet;
}

View File

@ -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: <NostrLinkHandler />,
},
...NewUserRoutes,
...WalletRoutes,
],
},
]);