forked from Kieran/snort
feat: basic wallet
This commit is contained in:
parent
dc31ba9ad3
commit
7db8960914
21
packages/app/src/Icons/Bitcoin.tsx
Normal file
21
packages/app/src/Icons/Bitcoin.tsx
Normal 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;
|
@ -23,6 +23,7 @@ import Plus from "Icons/Plus";
|
|||||||
import { RelaySettings } from "@snort/nostr";
|
import { RelaySettings } from "@snort/nostr";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
import Bitcoin from "Icons/Bitcoin";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -44,6 +45,9 @@ export default function Layout() {
|
|||||||
const [pageClass, setPageClass] = useState("page");
|
const [pageClass, setPageClass] = useState("page");
|
||||||
const pub = useEventPublisher();
|
const pub = useEventPublisher();
|
||||||
useLoginFeed();
|
useLoginFeed();
|
||||||
|
useEffect(() => {
|
||||||
|
System.nip42Auth = pub.nip42Auth;
|
||||||
|
}, [pub]);
|
||||||
|
|
||||||
const shouldHideNoteCreator = useMemo(() => {
|
const shouldHideNoteCreator = useMemo(() => {
|
||||||
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/p/"];
|
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/p/"];
|
||||||
@ -197,6 +201,9 @@ export default function Layout() {
|
|||||||
function accountHeader() {
|
function accountHeader() {
|
||||||
return (
|
return (
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
|
<div className="btn btn-rnd" onClick={() => navigate("/wallet")}>
|
||||||
|
<Bitcoin />
|
||||||
|
</div>
|
||||||
<div className="btn btn-rnd" onClick={() => navigate("/search")}>
|
<div className="btn btn-rnd" onClick={() => navigate("/search")}>
|
||||||
<Search />
|
<Search />
|
||||||
</div>
|
</div>
|
||||||
|
16
packages/app/src/Pages/WalletPage.css
Normal file
16
packages/app/src/Pages/WalletPage.css
Normal 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;
|
||||||
|
}
|
88
packages/app/src/Pages/WalletPage.tsx
Normal file
88
packages/app/src/Pages/WalletPage.tsx
Normal 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 ? <> </> : a.memo}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
a.state === WalletInvoiceState.Paid ? "success" : "pending"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{stateIcon(a.state)}
|
||||||
|
{a.amount.toLocaleString()} sats
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
8
packages/app/src/Pages/messages.js
Normal file
8
packages/app/src/Pages/messages.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { defineMessages } from "react-intl";
|
||||||
|
import { addIdAndDefaultMessageToMessages } from "Util";
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
Login: "Login",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default addIdAndDefaultMessageToMessages(messages, "Pages");
|
134
packages/app/src/Wallet/LNDHub.ts
Normal file
134
packages/app/src/Wallet/LNDHub.ts
Normal 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;
|
||||||
|
}
|
81
packages/app/src/Wallet/index.ts
Normal file
81
packages/app/src/Wallet/index.ts
Normal 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;
|
||||||
|
}
|
@ -8,6 +8,7 @@ import { Provider } from "react-redux";
|
|||||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
|
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
|
||||||
|
import { IntlProvider } from "IntlProvider";
|
||||||
import Store from "State/Store";
|
import Store from "State/Store";
|
||||||
import EventPage from "Pages/EventPage";
|
import EventPage from "Pages/EventPage";
|
||||||
import Layout from "Pages/Layout";
|
import Layout from "Pages/Layout";
|
||||||
@ -28,6 +29,7 @@ import { NewUserRoutes } from "Pages/new";
|
|||||||
import NostrLinkHandler from "Pages/NostrLinkHandler";
|
import NostrLinkHandler from "Pages/NostrLinkHandler";
|
||||||
import { IntlProvider } from "./IntlProvider";
|
import { IntlProvider } from "./IntlProvider";
|
||||||
import { unwrap } from "Util";
|
import { unwrap } from "Util";
|
||||||
|
import { WalletRoutes } from "Pages/WalletPage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP query provider
|
* HTTP query provider
|
||||||
@ -99,6 +101,7 @@ export const router = createBrowserRouter([
|
|||||||
element: <NostrLinkHandler />,
|
element: <NostrLinkHandler />,
|
||||||
},
|
},
|
||||||
...NewUserRoutes,
|
...NewUserRoutes,
|
||||||
|
...WalletRoutes,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
Loading…
Reference in New Issue
Block a user