complete basic wallet
This commit is contained in:
parent
cff605b188
commit
eec85c441e
@ -13,6 +13,11 @@ declare module "*.webp" {
|
|||||||
export default value;
|
export default value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "*.png" {
|
||||||
|
const value: string;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
||||||
declare module "translations/*.json" {
|
declare module "translations/*.json" {
|
||||||
const value: Record<string, string>;
|
const value: Record<string, string>;
|
||||||
export default value;
|
export default value;
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
<meta name="description" content="Fast nostr web ui" />
|
<meta name="description" content="Fast nostr web ui" />
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
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' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" />
|
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;" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import "./Invoice.css";
|
import "./Invoice.css";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useIntl, FormattedMessage } from "react-intl";
|
import { useIntl, FormattedMessage } from "react-intl";
|
||||||
import { decode as invoiceDecode } from "light-bolt11-decoder";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import SendSats from "Element/SendSats";
|
import SendSats from "Element/SendSats";
|
||||||
import ZapCircle from "Icons/ZapCircle";
|
import ZapCircle from "Icons/ZapCircle";
|
||||||
import useWebln from "Hooks/useWebln";
|
import { useWallet } from "Wallet";
|
||||||
|
import { decodeInvoice } from "Util";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
|
||||||
@ -15,38 +16,12 @@ export interface InvoiceProps {
|
|||||||
|
|
||||||
export default function Invoice(props: InvoiceProps) {
|
export default function Invoice(props: InvoiceProps) {
|
||||||
const invoice = props.invoice;
|
const invoice = props.invoice;
|
||||||
const webln = useWebln();
|
|
||||||
const [showInvoice, setShowInvoice] = useState(false);
|
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
const [showInvoice, setShowInvoice] = useState(false);
|
||||||
|
const walletState = useWallet();
|
||||||
|
const wallet = walletState.wallet;
|
||||||
|
|
||||||
const info = useMemo(() => {
|
const info = useMemo(() => decodeInvoice(invoice), [invoice]);
|
||||||
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 [isPaid, setIsPaid] = useState(false);
|
const [isPaid, setIsPaid] = useState(false);
|
||||||
const isExpired = info?.expired;
|
const isExpired = info?.expired;
|
||||||
const amount = info?.amount ?? 0;
|
const amount = info?.amount ?? 0;
|
||||||
@ -71,9 +46,9 @@ export default function Invoice(props: InvoiceProps) {
|
|||||||
|
|
||||||
async function payInvoice(e: React.MouseEvent<HTMLButtonElement>) {
|
async function payInvoice(e: React.MouseEvent<HTMLButtonElement>) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (webln?.enabled) {
|
if (wallet?.isReady) {
|
||||||
try {
|
try {
|
||||||
await webln.sendPayment(invoice);
|
await wallet.payInvoice(invoice);
|
||||||
setIsPaid(true);
|
setIsPaid(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setShowInvoice(true);
|
setShowInvoice(true);
|
||||||
|
@ -3,6 +3,7 @@ import { useSelector, useDispatch } from "react-redux";
|
|||||||
import { useIntl, FormattedMessage } from "react-intl";
|
import { useIntl, FormattedMessage } from "react-intl";
|
||||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||||
import { useLongPress } from "use-long-press";
|
import { useLongPress } from "use-long-press";
|
||||||
|
import { Event as NEvent, TaggedRawEvent, HexKey } from "@snort/nostr";
|
||||||
|
|
||||||
import Bookmark from "Icons/Bookmark";
|
import Bookmark from "Icons/Bookmark";
|
||||||
import Pin from "Icons/Pin";
|
import Pin from "Icons/Pin";
|
||||||
@ -19,6 +20,9 @@ import Heart from "Icons/Heart";
|
|||||||
import Dots from "Icons/Dots";
|
import Dots from "Icons/Dots";
|
||||||
import Zap from "Icons/Zap";
|
import Zap from "Icons/Zap";
|
||||||
import Reply from "Icons/Reply";
|
import Reply from "Icons/Reply";
|
||||||
|
import Spinner from "Icons/Spinner";
|
||||||
|
import ZapFast from "Icons/ZapFast";
|
||||||
|
|
||||||
import { formatShort } from "Number";
|
import { formatShort } from "Number";
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
import useEventPublisher from "Feed/EventPublisher";
|
||||||
import { hexToBech32, normalizeReaction, unwrap } from "Util";
|
import { hexToBech32, normalizeReaction, unwrap } from "Util";
|
||||||
@ -27,15 +31,12 @@ import Reactions from "Element/Reactions";
|
|||||||
import SendSats from "Element/SendSats";
|
import SendSats from "Element/SendSats";
|
||||||
import { ParsedZap, ZapsSummary } from "Element/Zap";
|
import { ParsedZap, ZapsSummary } from "Element/Zap";
|
||||||
import { useUserProfile } from "Feed/ProfileFeed";
|
import { useUserProfile } from "Feed/ProfileFeed";
|
||||||
import { Event as NEvent, TaggedRawEvent, HexKey } from "@snort/nostr";
|
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { UserPreferences, setPinned, setBookmarked } from "State/Login";
|
import { UserPreferences, setPinned, setBookmarked } from "State/Login";
|
||||||
import useModeration from "Hooks/useModeration";
|
import useModeration from "Hooks/useModeration";
|
||||||
import { TranslateHost } from "Const";
|
import { TranslateHost } from "Const";
|
||||||
import useWebln from "Hooks/useWebln";
|
|
||||||
import { LNURL } from "LNURL";
|
import { LNURL } from "LNURL";
|
||||||
import Spinner from "Icons/Spinner";
|
import { useWallet } from "Wallet";
|
||||||
import ZapFast from "Icons/ZapFast";
|
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
|
||||||
@ -69,7 +70,9 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
const [reply, setReply] = useState(false);
|
const [reply, setReply] = useState(false);
|
||||||
const [tip, setTip] = useState(false);
|
const [tip, setTip] = useState(false);
|
||||||
const [zapping, setZapping] = useState(false);
|
const [zapping, setZapping] = useState(false);
|
||||||
const webln = useWebln();
|
const walletState = useWallet();
|
||||||
|
const wallet = walletState.wallet;
|
||||||
|
|
||||||
const isMine = ev.RootPubKey === login;
|
const isMine = ev.RootPubKey === login;
|
||||||
const lang = window.navigator.language;
|
const lang = window.navigator.language;
|
||||||
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
|
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
|
||||||
@ -122,14 +125,14 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
if (zapping || e.isPropagationStopped()) return;
|
if (zapping || e.isPropagationStopped()) return;
|
||||||
|
|
||||||
const lnurl = author?.lud16 || author?.lud06;
|
const lnurl = author?.lud16 || author?.lud06;
|
||||||
if (webln?.enabled && lnurl) {
|
if (wallet?.isReady() && lnurl) {
|
||||||
setZapping(true);
|
setZapping(true);
|
||||||
try {
|
try {
|
||||||
const handler = new LNURL(lnurl);
|
const handler = new LNURL(lnurl);
|
||||||
await handler.load();
|
await handler.load();
|
||||||
const zap = handler.canZap ? await publisher.zap(prefs.defaultZapAmount * 1000, ev.PubKey, ev.Id) : undefined;
|
const zap = handler.canZap ? await publisher.zap(prefs.defaultZapAmount * 1000, ev.PubKey, ev.Id) : undefined;
|
||||||
const invoice = await handler.getInvoice(prefs.defaultZapAmount, undefined, zap);
|
const invoice = await handler.getInvoice(prefs.defaultZapAmount, undefined, zap);
|
||||||
await await webln.sendPayment(unwrap(invoice.pr));
|
await wallet.payInvoice(unwrap(invoice.pr));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Fast zap failed", e);
|
console.warn("Fast zap failed", e);
|
||||||
if (!(e instanceof Error) || e.message !== "User rejected") {
|
if (!(e instanceof Error) || e.message !== "User rejected") {
|
||||||
@ -149,7 +152,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`reaction-pill ${didZap ? "reacted" : ""}`} {...longPress()} onClick={e => fastZap(e)}>
|
<div className={`reaction-pill ${didZap ? "reacted" : ""}`} {...longPress()} onClick={e => fastZap(e)}>
|
||||||
<div className="reaction-pill-icon">{zapping ? <Spinner /> : webln?.enabled ? <ZapFast /> : <Zap />}</div>
|
<div className="reaction-pill-icon">{zapping ? <Spinner /> : wallet?.isReady ? <ZapFast /> : <Zap />}</div>
|
||||||
{zapTotal > 0 && <div className="reaction-pill-number">{formatShort(zapTotal)}</div>}
|
{zapTotal > 0 && <div className="reaction-pill-number">{formatShort(zapTotal)}</div>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -14,7 +14,6 @@ import ProfileImage from "Element/ProfileImage";
|
|||||||
import Modal from "Element/Modal";
|
import Modal from "Element/Modal";
|
||||||
import QrCode from "Element/QrCode";
|
import QrCode from "Element/QrCode";
|
||||||
import Copy from "Element/Copy";
|
import Copy from "Element/Copy";
|
||||||
import useWebln from "Hooks/useWebln";
|
|
||||||
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL";
|
import { LNURL, LNURLError, LNURLErrorCode, LNURLInvoice, LNURLSuccessAction } from "LNURL";
|
||||||
import { debounce } from "Util";
|
import { debounce } from "Util";
|
||||||
|
|
||||||
@ -77,11 +76,11 @@ export default function SendSats(props: SendSatsProps) {
|
|||||||
const [zapType, setZapType] = useState(ZapType.PublicZap);
|
const [zapType, setZapType] = useState(ZapType.PublicZap);
|
||||||
const [paying, setPaying] = useState<boolean>(false);
|
const [paying, setPaying] = useState<boolean>(false);
|
||||||
|
|
||||||
const webln = useWebln(props.show);
|
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const publisher = useEventPublisher();
|
const publisher = useEventPublisher();
|
||||||
const canComment = handler ? (handler.canZap && zapType !== ZapType.NonZap) || handler.maxCommentLength > 0 : false;
|
const canComment = handler ? (handler.canZap && zapType !== ZapType.NonZap) || handler.maxCommentLength > 0 : false;
|
||||||
const wallet = useWallet();
|
const walletState = useWallet();
|
||||||
|
const wallet = walletState.wallet;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.show) {
|
if (props.show) {
|
||||||
@ -156,7 +155,7 @@ export default function SendSats(props: SendSatsProps) {
|
|||||||
const rsp = await handler.getInvoice(amount, comment, zap);
|
const rsp = await handler.getInvoice(amount, comment, zap);
|
||||||
if (rsp.pr) {
|
if (rsp.pr) {
|
||||||
setInvoice(rsp.pr);
|
setInvoice(rsp.pr);
|
||||||
await payWebLNIfEnabled(rsp);
|
await payWithWallet(rsp);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleLNURLError(e, formatMessage(messages.InvoiceFail));
|
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 {
|
try {
|
||||||
if (webln?.enabled) {
|
if (wallet?.isReady) {
|
||||||
setPaying(true);
|
setPaying(true);
|
||||||
const res = await webln.sendPayment(invoice?.pr ?? "");
|
const res = await wallet.payInvoice(invoice?.pr ?? "");
|
||||||
console.log(res);
|
console.log(res);
|
||||||
setSuccess(invoice?.successAction ?? {});
|
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() {
|
function payInvoice() {
|
||||||
if (success || !invoice) return null;
|
if (success || !invoice) return null;
|
||||||
return (
|
return (
|
||||||
@ -316,7 +307,12 @@ export default function SendSats(props: SendSatsProps) {
|
|||||||
{props.notice && <b className="error">{props.notice}</b>}
|
{props.notice && <b className="error">{props.notice}</b>}
|
||||||
{paying ? (
|
{paying ? (
|
||||||
<h4>
|
<h4>
|
||||||
<FormattedMessage defaultMessage="Paying with WebLN" />
|
<FormattedMessage
|
||||||
|
defaultMessage="Paying with {wallet}"
|
||||||
|
values={{
|
||||||
|
wallet: walletState.config?.info.alias,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
...
|
...
|
||||||
</h4>
|
</h4>
|
||||||
) : (
|
) : (
|
||||||
@ -331,11 +327,6 @@ export default function SendSats(props: SendSatsProps) {
|
|||||||
<button className="wallet-action" type="button" onClick={() => window.open(`lightning:${invoice}`)}>
|
<button className="wallet-action" type="button" onClick={() => window.open(`lightning:${invoice}`)}>
|
||||||
<FormattedMessage {...messages.OpenWallet} />
|
<FormattedMessage {...messages.OpenWallet} />
|
||||||
</button>
|
</button>
|
||||||
{wallet && (
|
|
||||||
<button className="wallet-action" type="button" onClick={() => payWithWallet(invoice)}>
|
|
||||||
<FormattedMessage defaultMessage="Pay with Wallet" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,12 +2,10 @@ import "./Zap.css";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { decode as invoiceDecode } from "light-bolt11-decoder";
|
import { Event, HexKey, TaggedRawEvent } from "@snort/nostr";
|
||||||
import { bytesToHex } from "@noble/hashes/utils";
|
|
||||||
import { sha256, unwrap } from "Util";
|
import { decodeInvoice, sha256, unwrap } from "Util";
|
||||||
import { formatShort } from "Number";
|
import { formatShort } from "Number";
|
||||||
import { HexKey, TaggedRawEvent } from "@snort/nostr";
|
|
||||||
import { Event } from "@snort/nostr";
|
|
||||||
import Text from "Element/Text";
|
import Text from "Element/Text";
|
||||||
import ProfileImage from "Element/ProfileImage";
|
import ProfileImage from "Element/ProfileImage";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
@ -27,15 +25,9 @@ function getInvoice(zap: TaggedRawEvent) {
|
|||||||
console.debug("Invalid zap: ", zap);
|
console.debug("Invalid zap: ", zap);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
try {
|
const decoded = decodeInvoice(bolt11);
|
||||||
const decoded = invoiceDecode(bolt11);
|
if (decoded) {
|
||||||
|
return { amount: decoded?.amount, hash: decoded?.descriptionHash };
|
||||||
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
|
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
@ -1,90 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { delay, unwrap } from "Util";
|
|
||||||
|
|
||||||
let isWebLnBusy = false;
|
|
||||||
export const barrierWebLn = async <T>(then: () => Promise<T>): Promise<T> => {
|
|
||||||
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<GetInfoResponse>;
|
|
||||||
enable: () => Promise<void>;
|
|
||||||
makeInvoice(args: RequestInvoiceArgs): Promise<RequestInvoiceResponse>;
|
|
||||||
signMessage(message: string): Promise<SignMessageResponse>;
|
|
||||||
verifyMessage(signature: string, message: string): Promise<void>;
|
|
||||||
sendPayment: (pr: string) => Promise<SendPaymentResponse>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
61
packages/app/src/Icons/BlueWallet.tsx
Normal file
61
packages/app/src/Icons/BlueWallet.tsx
Normal file
File diff suppressed because one or more lines are too long
@ -5,6 +5,7 @@ import Profile from "Pages/settings/Profile";
|
|||||||
import Relay from "Pages/settings/Relays";
|
import Relay from "Pages/settings/Relays";
|
||||||
import Preferences from "Pages/settings/Preferences";
|
import Preferences from "Pages/settings/Preferences";
|
||||||
import RelayInfo from "Pages/settings/RelayInfo";
|
import RelayInfo from "Pages/settings/RelayInfo";
|
||||||
|
import { WalletSettingsRoutes } from "Pages/settings/WalletSettings";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
|
||||||
@ -42,4 +43,5 @@ export const SettingsRoutes: RouteObject[] = [
|
|||||||
path: "preferences",
|
path: "preferences",
|
||||||
element: <Preferences />,
|
element: <Preferences />,
|
||||||
},
|
},
|
||||||
|
...WalletSettingsRoutes,
|
||||||
];
|
];
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
import "./WalletPage.css";
|
import "./WalletPage.css";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faCheck, faClock, faXmark } from "@fortawesome/free-solid-svg-icons";
|
import { faCheck, faClock, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
import NoteTime from "Element/NoteTime";
|
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[] = [
|
export const WalletRoutes: RouteObject[] = [
|
||||||
{
|
{
|
||||||
@ -16,26 +20,43 @@ export const WalletRoutes: RouteObject[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function WalletPage() {
|
export default function WalletPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
const [info, setInfo] = useState<WalletInfo>();
|
const [info, setInfo] = useState<WalletInfo>();
|
||||||
const [balance, setBalance] = useState<Sats>();
|
const [balance, setBalance] = useState<Sats>();
|
||||||
const [history, setHistory] = useState<WalletInvoice[]>();
|
const [history, setHistory] = useState<WalletInvoice[]>();
|
||||||
const wallet = useWallet();
|
const [walletPassword, setWalletPassword] = useState<string>();
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const walletState = useWallet();
|
||||||
|
const wallet = walletState.wallet;
|
||||||
|
|
||||||
async function loadWallet(wallet: LNWallet) {
|
async function loadWallet(wallet: LNWallet) {
|
||||||
|
try {
|
||||||
const i = await wallet.getInfo();
|
const i = await wallet.getInfo();
|
||||||
if ("error" in i) {
|
setInfo(i);
|
||||||
return;
|
|
||||||
}
|
|
||||||
setInfo(i as WalletInfo);
|
|
||||||
const b = await wallet.getBalance();
|
const b = await wallet.getBalance();
|
||||||
setBalance(b as Sats);
|
setBalance(b as Sats);
|
||||||
const h = await wallet.getInvoices();
|
const h = await wallet.getInvoices();
|
||||||
setHistory((h as WalletInvoice[]).sort((a, b) => b.timestamp - a.timestamp));
|
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(() => {
|
useEffect(() => {
|
||||||
if (wallet) {
|
if (wallet) {
|
||||||
|
if (wallet.isReady()) {
|
||||||
loadWallet(wallet).catch(console.warn);
|
loadWallet(wallet).catch(console.warn);
|
||||||
|
} else if (walletState.config?.kind !== WalletKind.LNC) {
|
||||||
|
wallet
|
||||||
|
.login()
|
||||||
|
.then(async () => await loadWallet(wallet))
|
||||||
|
.catch(console.warn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [wallet]);
|
}, [wallet]);
|
||||||
|
|
||||||
@ -50,37 +71,146 @@ export default function WalletPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createInvoice() {
|
async function loginWallet(pw: string) {
|
||||||
if (wallet) {
|
if (wallet) {
|
||||||
const rsp = await wallet.createInvoice({
|
await wallet.login(pw);
|
||||||
memo: "test",
|
await loadWallet(wallet);
|
||||||
amount: 100,
|
setWalletPassword(undefined);
|
||||||
});
|
|
||||||
console.debug(rsp);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unlockWallet() {
|
||||||
|
if (!wallet || wallet.isReady()) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3>{info?.alias}</h3>
|
<h3>
|
||||||
<b>Balance: {(balance ?? 0).toLocaleString()} sats</b>
|
<FormattedMessage defaultMessage="Enter wallet password" />
|
||||||
<div className="flex wallet-buttons">
|
</h3>
|
||||||
<button>Send</button>
|
<div className="flex w-max">
|
||||||
<button onClick={() => createInvoice()}>Receive</button>
|
<div className="f-grow mr10">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder={formatMessage({
|
||||||
|
defaultMessage: "Wallet password",
|
||||||
|
description: "Wallet password input placeholder",
|
||||||
|
})}
|
||||||
|
className="w-max"
|
||||||
|
value={walletPassword}
|
||||||
|
onChange={e => setWalletPassword(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3>History</h3>
|
<AsyncButton onClick={() => loginWallet(unwrap(walletPassword))} disabled={(walletPassword?.length ?? 0) < 8}>
|
||||||
|
<FormattedMessage defaultMessage="Unlock" description="Unlock wallet" />
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function walletList() {
|
||||||
|
if (walletState.configs.length === 0) {
|
||||||
|
return (
|
||||||
|
<button onClick={() => navigate("/settings/wallet")}>
|
||||||
|
<FormattedMessage defaultMessage="Connect Wallet" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex w-max">
|
||||||
|
<h4 className="f-1">
|
||||||
|
<FormattedMessage defaultMessage="Select Wallet" />
|
||||||
|
</h4>
|
||||||
|
<div className="f-1">
|
||||||
|
<select className="w-max" onChange={e => Wallets.switch(e.target.value)} value={walletState.config?.id}>
|
||||||
|
{Wallets.list().map(a => {
|
||||||
|
return <option value={a.id}>{a.info.alias}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function walletHistory() {
|
||||||
|
if (wallet instanceof WebLNWallet) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>
|
||||||
|
<FormattedMessage defaultMessage="History" description="Wallet transation history" />
|
||||||
|
</h3>
|
||||||
{history?.map(a => (
|
{history?.map(a => (
|
||||||
<div className="card flex wallet-history-item" key={a.timestamp}>
|
<div className="card flex wallet-history-item" key={a.timestamp}>
|
||||||
<div className="f-grow f-col">
|
<div className="f-grow f-col">
|
||||||
<NoteTime from={a.timestamp * 1000} />
|
<NoteTime from={a.timestamp * 1000} fallback={formatMessage({ defaultMessage: "now" })} />
|
||||||
<div>{(a.memo ?? "").length === 0 ? <> </> : a.memo}</div>
|
<div>{(a.memo ?? "").length === 0 ? <> </> : a.memo}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${a.state === WalletInvoiceState.Paid ? "success" : "pending"}`}>
|
<div
|
||||||
|
className={(() => {
|
||||||
|
switch (a.state) {
|
||||||
|
case WalletInvoiceState.Paid:
|
||||||
|
return "success";
|
||||||
|
case WalletInvoiceState.Expired:
|
||||||
|
return "expired";
|
||||||
|
case WalletInvoiceState.Failed:
|
||||||
|
return "failed";
|
||||||
|
default:
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
})()}>
|
||||||
{stateIcon(a.state)}
|
{stateIcon(a.state)}
|
||||||
{a.amount.toLocaleString()} sats
|
<FormattedMessage
|
||||||
|
defaultMessage="{amount} sats"
|
||||||
|
values={{
|
||||||
|
amount: <FormattedNumber value={a.amount / 1e3} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function walletBalance() {
|
||||||
|
if (wallet instanceof WebLNWallet) return null;
|
||||||
|
return (
|
||||||
|
<b>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Balance: {amount} sats"
|
||||||
|
values={{
|
||||||
|
amount: <FormattedNumber value={balance ?? 0} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</b>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function walletInfo() {
|
||||||
|
if (!wallet?.isReady()) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>{info?.alias}</h3>
|
||||||
|
{walletBalance()}
|
||||||
|
{/*<div className="flex wallet-buttons">
|
||||||
|
<AsyncButton onClick={createInvoice}>
|
||||||
|
<FormattedMessage defaultMessage="Receive" description="Receive sats by generating LN invoice" />
|
||||||
|
</AsyncButton>
|
||||||
|
</div>*/}
|
||||||
|
{walletHistory()}
|
||||||
|
<button onClick={() => Wallets.remove(unwrap(walletState.config).id)}>
|
||||||
|
<FormattedMessage defaultMessage="Delete Account" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="main-content">
|
||||||
|
{error && <b className="error">{error}</b>}
|
||||||
|
{walletList()}
|
||||||
|
{unlockWallet()}
|
||||||
|
{walletInfo()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ import Profile from "Icons/Profile";
|
|||||||
import Relay from "Icons/Relay";
|
import Relay from "Icons/Relay";
|
||||||
import Heart from "Icons/Heart";
|
import Heart from "Icons/Heart";
|
||||||
import Logout from "Icons/Logout";
|
import Logout from "Icons/Logout";
|
||||||
|
import Bitcoin from "Icons/Bitcoin";
|
||||||
import { logout } from "State/Login";
|
import { logout } from "State/Login";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
@ -53,6 +54,15 @@ const SettingsIndex = () => {
|
|||||||
<ArrowFront />
|
<ArrowFront />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="settings-row" onClick={() => navigate("wallet")}>
|
||||||
|
<div className="mr10">
|
||||||
|
<Bitcoin />
|
||||||
|
</div>
|
||||||
|
<FormattedMessage defaultMessage="Wallet" />
|
||||||
|
<div className="align-end">
|
||||||
|
<ArrowFront />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="settings-row" onClick={() => navigate("/donate")}>
|
<div className="settings-row" onClick={() => navigate("/donate")}>
|
||||||
<div className="mr10">
|
<div className="mr10">
|
||||||
<Heart />
|
<Heart />
|
||||||
|
14
packages/app/src/Pages/settings/WalletSettings.css
Normal file
14
packages/app/src/Pages/settings/WalletSettings.css
Normal file
@ -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;
|
||||||
|
}
|
48
packages/app/src/Pages/settings/WalletSettings.tsx
Normal file
48
packages/app/src/Pages/settings/WalletSettings.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<h3>
|
||||||
|
<FormattedMessage defaultMessage="Connect Wallet" />
|
||||||
|
</h3>
|
||||||
|
<div className="wallet-grid">
|
||||||
|
<div className="card" onClick={() => navigate("/settings/wallet/lnc")}>
|
||||||
|
<img src={LndLogo} width={100} />
|
||||||
|
<h3 className="f-end">LND with LNC</h3>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
<div className="card" onClick={() => navigate("/settings/wallet/lndhub")}>
|
||||||
|
<BlueWallet width={100} height={100} />
|
||||||
|
<h3 className="f-end">LNDHub</h3>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WalletSettings;
|
||||||
|
|
||||||
|
export const WalletSettingsRoutes = [
|
||||||
|
{
|
||||||
|
path: "/settings/wallet",
|
||||||
|
element: <WalletSettings />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/settings/wallet/lnc",
|
||||||
|
element: <ConnectLNC />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/settings/wallet/lndhub",
|
||||||
|
element: <ConnectLNDHub />,
|
||||||
|
},
|
||||||
|
] as Array<RouteObject>;
|
121
packages/app/src/Pages/settings/wallet/LNC.tsx
Normal file
121
packages/app/src/Pages/settings/wallet/LNC.tsx
Normal file
@ -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<string>();
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [connectedLNC, setConnectedLNC] = useState<LNCWallet>();
|
||||||
|
const [walletInfo, setWalletInfo] = useState<WalletInfo>();
|
||||||
|
const [walletPassword, setWalletPassword] = useState<string>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<h4>
|
||||||
|
<FormattedMessage defaultMessage="Enter pairing phrase" />
|
||||||
|
</h4>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="f-grow mr10">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={formatMessage({ defaultMessage: "Pairing phrase" })}
|
||||||
|
className="w-max"
|
||||||
|
value={pairingPhrase}
|
||||||
|
onChange={e => setPairingPhrase(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AsyncButton onClick={() => tryConnect(unwrap(pairingPhrase))} disabled={!pairingPhrase}>
|
||||||
|
<FormattedMessage defaultMessage="Connect" />
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
{error && <b className="error p10">{error}</b>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowSetPassword() {
|
||||||
|
if (!connectedLNC) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex f-col">
|
||||||
|
<h3>
|
||||||
|
<FormattedMessage
|
||||||
|
defaultMessage="Connected to: {node} 🎉"
|
||||||
|
values={{
|
||||||
|
node: walletInfo?.alias,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
|
<h4>
|
||||||
|
<FormattedMessage defaultMessage="Enter password" />
|
||||||
|
</h4>
|
||||||
|
<div className="flex w-max">
|
||||||
|
<div className="f-grow mr10">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder={formatMessage({ defaultMessage: "Wallet password" })}
|
||||||
|
className="w-max"
|
||||||
|
value={walletPassword}
|
||||||
|
onChange={e => setWalletPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AsyncButton
|
||||||
|
onClick={() => setLNCPassword(unwrap(walletPassword))}
|
||||||
|
disabled={(walletPassword?.length ?? 0) < 8}>
|
||||||
|
<FormattedMessage defaultMessage="Save" />
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{flowConnect()}
|
||||||
|
{flowSetPassword()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConnectLNC;
|
70
packages/app/src/Pages/settings/wallet/LNDHub.tsx
Normal file
70
packages/app/src/Pages/settings/wallet/LNDHub.tsx
Normal file
@ -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<string>();
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<h4>
|
||||||
|
<FormattedMessage defaultMessage="Enter LNDHub config" />
|
||||||
|
</h4>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="f-grow mr10">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="lndhub://username:password@lndhub.io"
|
||||||
|
className="w-max"
|
||||||
|
value={config}
|
||||||
|
onChange={e => setConfig(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AsyncButton onClick={() => tryConnect(unwrap(config))} disabled={!config}>
|
||||||
|
<FormattedMessage defaultMessage="Connect" />
|
||||||
|
</AsyncButton>
|
||||||
|
</div>
|
||||||
|
{error && <b className="error p10">{error}</b>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConnectLNDHub;
|
@ -1,7 +1,10 @@
|
|||||||
import * as secp from "@noble/secp256k1";
|
import * as secp from "@noble/secp256k1";
|
||||||
import { sha256 as hash } from "@noble/hashes/sha256";
|
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 { bech32 } from "bech32";
|
||||||
import { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix } from "@snort/nostr";
|
import { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix } from "@snort/nostr";
|
||||||
|
|
||||||
import { MetadataCache } from "State/Users";
|
import { MetadataCache } from "State/Users";
|
||||||
|
|
||||||
export const sha256 = (str: string) => {
|
export const sha256 = (str: string) => {
|
||||||
@ -235,3 +238,36 @@ export const delay = (t: number) => {
|
|||||||
setTimeout(resolve, t);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,6 +1,25 @@
|
|||||||
import { InvoiceRequest, LNWallet, Login, WalletError, WalletInfo, WalletInvoice, WalletInvoiceState } from "Wallet";
|
|
||||||
|
|
||||||
import LNC from "@lightninglabs/lnc-web";
|
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 {
|
export class LNCWallet implements LNWallet {
|
||||||
#lnc: LNC;
|
#lnc: LNC;
|
||||||
|
|
||||||
@ -11,23 +30,32 @@ export class LNCWallet implements LNWallet {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Initialize(pairingPhrase: string, password: string) {
|
isReady(): boolean {
|
||||||
const lnc = new LNCWallet(pairingPhrase, password);
|
return this.#lnc.isReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Initialize(pairingPhrase: string) {
|
||||||
|
const lnc = new LNCWallet(pairingPhrase);
|
||||||
await lnc.login();
|
await lnc.login();
|
||||||
return lnc;
|
return lnc;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Connect(password: string) {
|
static Empty() {
|
||||||
const lnc = new LNCWallet(undefined, password);
|
return new LNCWallet();
|
||||||
await lnc.login();
|
}
|
||||||
return lnc;
|
|
||||||
|
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<WalletError | Login> {
|
createAccount(): Promise<WalletError | Login> {
|
||||||
throw new Error("Not implemented");
|
throw new Error("Not implemented");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInfo(): Promise<WalletInfo | WalletError> {
|
async getInfo(): Promise<WalletInfo> {
|
||||||
const nodeInfo = await this.#lnc.lnd.lightning.getInfo();
|
const nodeInfo = await this.#lnc.lnd.lightning.getInfo();
|
||||||
return {
|
return {
|
||||||
nodePubKey: nodeInfo.identityPubkey,
|
nodePubKey: nodeInfo.identityPubkey,
|
||||||
@ -35,14 +63,18 @@ export class LNCWallet implements LNWallet {
|
|||||||
} as WalletInfo;
|
} as WalletInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): Promise<boolean | WalletError> {
|
close(): Promise<boolean> {
|
||||||
if (this.#lnc.isConnected) {
|
if (this.#lnc.isConnected) {
|
||||||
this.#lnc.disconnect();
|
this.#lnc.disconnect();
|
||||||
}
|
}
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(): Promise<boolean | WalletError> {
|
async login(password?: string): Promise<boolean> {
|
||||||
|
if (password) {
|
||||||
|
this.setPassword(password);
|
||||||
|
this.#lnc.run();
|
||||||
|
}
|
||||||
await this.#lnc.connect();
|
await this.#lnc.connect();
|
||||||
while (!this.#lnc.isConnected) {
|
while (!this.#lnc.isConnected) {
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
@ -52,32 +84,68 @@ export class LNCWallet implements LNWallet {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBalance(): Promise<number | WalletError> {
|
async getBalance(): Promise<number> {
|
||||||
const rsp = await this.#lnc.lnd.lightning.channelBalance();
|
const rsp = await this.#lnc.lnd.lightning.channelBalance();
|
||||||
console.debug(rsp);
|
console.debug(rsp);
|
||||||
return parseInt(rsp.localBalance?.sat ?? "0");
|
return parseInt(rsp.localBalance?.sat ?? "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
createInvoice(req: InvoiceRequest): Promise<WalletInvoice | WalletError> {
|
async createInvoice(req: InvoiceRequest): Promise<WalletInvoice> {
|
||||||
throw new Error("Not implemented");
|
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<WalletInvoice | WalletError> {
|
async payInvoice(pr: string): Promise<WalletInvoice> {
|
||||||
throw new Error("Not implemented");
|
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<WalletInvoice[] | WalletError> {
|
async getInvoices(): Promise<WalletInvoice[]> {
|
||||||
const invoices = await this.#lnc.lnd.lightning.listPayments({
|
const invoices = await this.#lnc.lnd.lightning.listPayments({
|
||||||
includeIncomplete: true,
|
|
||||||
maxPayments: "10",
|
maxPayments: "10",
|
||||||
reversed: true,
|
reversed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return invoices.payments.map(a => {
|
return invoices.payments.map(a => {
|
||||||
|
const parsedInvoice = prToWalletInvoice(a.paymentRequest);
|
||||||
return {
|
return {
|
||||||
amount: parseInt(a.valueSat),
|
...parsedInvoice,
|
||||||
state: a.status === "SUCCEEDED" ? WalletInvoiceState.Paid : WalletInvoiceState.Pending,
|
state: (() => {
|
||||||
timestamp: parseInt(a.creationTimeNs) / 1e9,
|
switch (a.status) {
|
||||||
|
case Payment_PaymentStatus.SUCCEEDED:
|
||||||
|
return;
|
||||||
|
case Payment_PaymentStatus.FAILED:
|
||||||
|
return WalletInvoiceState.Failed;
|
||||||
|
default:
|
||||||
|
return WalletInvoiceState.Pending;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
preimage: a.paymentPreimage,
|
||||||
} as WalletInvoice;
|
} as WalletInvoice;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -2,9 +2,10 @@ import { EventPublisher } from "Feed/EventPublisher";
|
|||||||
import {
|
import {
|
||||||
InvoiceRequest,
|
InvoiceRequest,
|
||||||
LNWallet,
|
LNWallet,
|
||||||
|
prToWalletInvoice,
|
||||||
Sats,
|
Sats,
|
||||||
UnknownWalletError,
|
|
||||||
WalletError,
|
WalletError,
|
||||||
|
WalletErrorCode,
|
||||||
WalletInfo,
|
WalletInfo,
|
||||||
WalletInvoice,
|
WalletInvoice,
|
||||||
WalletInvoiceState,
|
WalletInvoiceState,
|
||||||
@ -47,12 +48,12 @@ export default class LNDHubWallet implements LNWallet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): Promise<boolean | WalletError> {
|
isReady(): boolean {
|
||||||
throw new Error("Not implemented");
|
return this.auth !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAccount() {
|
close(): Promise<boolean> {
|
||||||
return Promise.resolve(UnknownWalletError);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInfo() {
|
async getInfo() {
|
||||||
@ -66,19 +67,12 @@ export default class LNDHubWallet implements LNWallet {
|
|||||||
login: this.user,
|
login: this.user,
|
||||||
password: this.password,
|
password: this.password,
|
||||||
});
|
});
|
||||||
|
|
||||||
if ("error" in rsp) {
|
|
||||||
return rsp as WalletError;
|
|
||||||
}
|
|
||||||
this.auth = rsp as AuthResponse;
|
this.auth = rsp as AuthResponse;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBalance(): Promise<Sats | WalletError> {
|
async getBalance(): Promise<Sats> {
|
||||||
const rsp = await this.getJson<GetBalanceResponse>("GET", "/balance");
|
const rsp = await this.getJson<GetBalanceResponse>("GET", "/balance");
|
||||||
if ("error" in rsp) {
|
|
||||||
return rsp as WalletError;
|
|
||||||
}
|
|
||||||
const bal = Math.floor((rsp as GetBalanceResponse).BTC.AvailableBalance);
|
const bal = Math.floor((rsp as GetBalanceResponse).BTC.AvailableBalance);
|
||||||
return bal as Sats;
|
return bal as Sats;
|
||||||
}
|
}
|
||||||
@ -88,9 +82,6 @@ export default class LNDHubWallet implements LNWallet {
|
|||||||
amt: req.amount,
|
amt: req.amount,
|
||||||
memo: req.memo,
|
memo: req.memo,
|
||||||
});
|
});
|
||||||
if ("error" in rsp) {
|
|
||||||
return rsp as WalletError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pRsp = rsp as UserInvoicesResponse;
|
const pRsp = rsp as UserInvoicesResponse;
|
||||||
return {
|
return {
|
||||||
@ -107,10 +98,6 @@ export default class LNDHubWallet implements LNWallet {
|
|||||||
invoice: pr,
|
invoice: pr,
|
||||||
});
|
});
|
||||||
|
|
||||||
if ("error" in rsp) {
|
|
||||||
return rsp as WalletError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pRsp = rsp as PayInvoiceResponse;
|
const pRsp = rsp as PayInvoiceResponse;
|
||||||
return {
|
return {
|
||||||
pr: pr,
|
pr: pr,
|
||||||
@ -119,24 +106,23 @@ export default class LNDHubWallet implements LNWallet {
|
|||||||
} as WalletInvoice;
|
} as WalletInvoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInvoices(): Promise<WalletInvoice[] | WalletError> {
|
async getInvoices(): Promise<WalletInvoice[]> {
|
||||||
const rsp = await this.getJson<UserInvoicesResponse[]>("GET", "/getuserinvoices");
|
const rsp = await this.getJson<UserInvoicesResponse[]>("GET", "/getuserinvoices");
|
||||||
if ("error" in rsp) {
|
|
||||||
return rsp as WalletError;
|
|
||||||
}
|
|
||||||
return (rsp as UserInvoicesResponse[]).map(a => {
|
return (rsp as UserInvoicesResponse[]).map(a => {
|
||||||
|
const decodedInvoice = prToWalletInvoice(a.payment_request);
|
||||||
|
if (!decodedInvoice) {
|
||||||
|
throw new WalletError(WalletErrorCode.InvalidInvoice, "Failed to parse invoice");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
memo: a.description,
|
...decodedInvoice,
|
||||||
amount: Math.floor(a.amt),
|
state: a.ispaid ? WalletInvoiceState.Paid : decodedInvoice.state,
|
||||||
timestamp: a.timestamp,
|
|
||||||
state: a.ispaid ? WalletInvoiceState.Paid : WalletInvoiceState.Pending,
|
|
||||||
pr: a.payment_request,
|
|
||||||
paymentHash: a.payment_hash,
|
paymentHash: a.payment_hash,
|
||||||
|
memo: a.description,
|
||||||
} as WalletInvoice;
|
} as WalletInvoice;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getJson<T>(method: "GET" | "POST", path: string, body?: any): Promise<T | WalletError> {
|
private async getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||||
let auth = `Bearer ${this.auth?.access_token}`;
|
let auth = `Bearer ${this.auth?.access_token}`;
|
||||||
if (this.type === "snort") {
|
if (this.type === "snort") {
|
||||||
const ev = await this.publisher?.generic(`${new URL(this.url).pathname}${path}`, 30_000);
|
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();
|
const json = await rsp.json();
|
||||||
if ("error" in 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;
|
return json as T;
|
||||||
}
|
}
|
||||||
@ -188,3 +175,8 @@ interface PayInvoiceResponse {
|
|||||||
payment_preimage: string;
|
payment_preimage: string;
|
||||||
payment_route?: { total_amt: number; total_fees: number };
|
payment_route?: { total_amt: number; total_fees: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ErrorResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
181
packages/app/src/Wallet/WebLN.ts
Normal file
181
packages/app/src/Wallet/WebLN.ts
Normal file
@ -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 <T>(then: () => Promise<T>): Promise<T> => {
|
||||||
|
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<GetInfoResponse>;
|
||||||
|
enable(): Promise<void>;
|
||||||
|
makeInvoice(args: RequestInvoiceArgs): Promise<RequestInvoiceResponse>;
|
||||||
|
signMessage(message: string): Promise<SignMessageResponse>;
|
||||||
|
verifyMessage(signature: string, message: string): Promise<void>;
|
||||||
|
sendPayment: (pr: string) => Promise<SendPaymentResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<WalletInfo> {
|
||||||
|
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<boolean> {
|
||||||
|
if (window.webln && !window.webln.enabled) {
|
||||||
|
await window.webln.enable();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): Promise<boolean> {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBalance(): Promise<Sats> {
|
||||||
|
return Promise.resolve(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvoice(req: InvoiceRequest): Promise<WalletInvoice> {
|
||||||
|
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<WalletInvoice> {
|
||||||
|
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<WalletInvoice[]> {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
}
|
@ -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 {
|
export enum WalletErrorCode {
|
||||||
BadAuth = 1,
|
BadAuth = 1,
|
||||||
NotEnoughBalance = 2,
|
NotEnoughBalance = 2,
|
||||||
@ -8,9 +21,13 @@ export enum WalletErrorCode {
|
|||||||
NodeFailure = 7,
|
NodeFailure = 7,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WalletError {
|
export class WalletError extends Error {
|
||||||
code: WalletErrorCode;
|
code: WalletErrorCode;
|
||||||
message: string;
|
|
||||||
|
constructor(c: WalletErrorCode, msg: string) {
|
||||||
|
super(msg);
|
||||||
|
this.code = c;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UnknownWalletError = {
|
export const UnknownWalletError = {
|
||||||
@ -39,7 +56,7 @@ export interface Login {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceRequest {
|
export interface InvoiceRequest {
|
||||||
amount: number;
|
amount: Sats;
|
||||||
memo?: string;
|
memo?: string;
|
||||||
expiry?: number;
|
expiry?: number;
|
||||||
}
|
}
|
||||||
@ -48,31 +65,199 @@ export enum WalletInvoiceState {
|
|||||||
Pending = 0,
|
Pending = 0,
|
||||||
Paid = 1,
|
Paid = 1,
|
||||||
Expired = 2,
|
Expired = 2,
|
||||||
|
Failed = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WalletInvoice {
|
export interface WalletInvoice {
|
||||||
pr: string;
|
pr: string;
|
||||||
paymentHash: string;
|
paymentHash: string;
|
||||||
memo: string;
|
memo: string;
|
||||||
amount: number;
|
amount: MilliSats;
|
||||||
fees: number;
|
fees: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
preimage?: string;
|
||||||
state: WalletInvoiceState;
|
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 Sats = number;
|
||||||
|
export type MilliSats = number;
|
||||||
|
|
||||||
export interface LNWallet {
|
export interface LNWallet {
|
||||||
createAccount: () => Promise<Login | WalletError>;
|
isReady(): boolean;
|
||||||
getInfo: () => Promise<WalletInfo | WalletError>;
|
getInfo: () => Promise<WalletInfo>;
|
||||||
login: () => Promise<boolean | WalletError>;
|
login: (password?: string) => Promise<boolean>;
|
||||||
close: () => Promise<boolean | WalletError>;
|
close: () => Promise<boolean>;
|
||||||
getBalance: () => Promise<Sats | WalletError>;
|
getBalance: () => Promise<Sats>;
|
||||||
createInvoice: (req: InvoiceRequest) => Promise<WalletInvoice | WalletError>;
|
createInvoice: (req: InvoiceRequest) => Promise<WalletInvoice>;
|
||||||
payInvoice: (pr: string) => Promise<WalletInvoice | WalletError>;
|
payInvoice: (pr: string) => Promise<WalletInvoice>;
|
||||||
getInvoices: () => Promise<WalletInvoice[] | WalletError>;
|
getInvoices: () => Promise<WalletInvoice[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWallet(): LNWallet | undefined {
|
export interface WalletConfig {
|
||||||
return undefined;
|
id: string;
|
||||||
|
kind: WalletKind;
|
||||||
|
active: boolean;
|
||||||
|
info: WalletInfo;
|
||||||
|
data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletStoreSnapshot {
|
||||||
|
configs: Array<WalletConfig>;
|
||||||
|
config?: WalletConfig;
|
||||||
|
wallet?: LNWallet;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WalletStateHook = (state: WalletStoreSnapshot) => void;
|
||||||
|
|
||||||
|
export class WalletStore {
|
||||||
|
#configs: Array<WalletConfig>;
|
||||||
|
#instance: Map<string, LNWallet>;
|
||||||
|
|
||||||
|
#hooks: Array<WalletStateHook>;
|
||||||
|
#snapshot: Readonly<WalletStoreSnapshot>;
|
||||||
|
|
||||||
|
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<WalletStoreSnapshot>(
|
||||||
|
h => Wallets.hook(h),
|
||||||
|
() => Wallets.getSnapshot()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
BIN
packages/app/src/lnd-logo.png
Normal file
BIN
packages/app/src/lnd-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.4 KiB |
Loading…
Reference in New Issue
Block a user