cashu redeem #551

Merged
Kieran merged 10 commits from cashu into main 2023-05-09 16:25:45 +00:00
14 changed files with 2273 additions and 1897 deletions

View File

@ -6,6 +6,7 @@
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@cashu/cashu-ts": "^0.6.1",
"@jukben/emoji-search": "^2.0.1",
"@lightninglabs/lnc-web": "^0.2.3-alpha",
"@noble/hashes": "^1.2.0",
@ -68,9 +69,7 @@
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
">0.5%"
],
"development": [
"last 1 chrome version",

View File

@ -179,3 +179,8 @@ export const MagnetRegex = /(magnet:[\S]+)/i;
*/
export const WavlakeRegex =
/https?:\/\/(?:player\.|www\.)?wavlake\.com\/(?!top|new|artists|account|activity|login|preferences|feed)(?:(?:track|album)\/[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}|[a-z-]+)/i;
/*
* Regex to match any base64 string
*/
export const CashuRegex = /(cashuA[A-Za-z0-9_-]{0,10000}={0,3})/i;

View File

@ -0,0 +1,68 @@
import { getDecodedToken } from "@cashu/cashu-ts";
import { useMemo } from "react";
import { FormattedMessage } from "react-intl";
import useLogin from "Hooks/useLogin";
import { useUserProfile } from "Hooks/useUserProfile";
export default function CashuNuts({ token }: { token: string }) {
const login = useLogin();
const profile = useUserProfile(login.publicKey);
async function copyToken(e: React.MouseEvent<HTMLButtonElement>, token: string) {
e.stopPropagation();
await navigator.clipboard.writeText(token);
}
async function redeemToken(e: React.MouseEvent<HTMLButtonElement>, token: string) {
e.stopPropagation();
const lnurl = profile?.lud16 ?? "";
const url = `https://redeem.cashu.me?token=${encodeURIComponent(token)}&lightning=${encodeURIComponent(
lnurl
)}&autopay=yes`;
window.open(url, "_blank");
}
const cashu = useMemo(() => {
try {
if (!token.startsWith("cashuA") || token.length < 10) {
return;
}
return getDecodedToken(token);
} catch {
// ignored
}
}, [token]);
if (!cashu) return <>{token}</>;
return (
<div className="note-invoice">
<div className="flex f-between">
<div>
<h4>
<FormattedMessage defaultMessage="Cashu token" />
</h4>
<p>
<FormattedMessage
defaultMessage="Amount: {amount} sats"
values={{
amount: cashu.token[0].proofs.reduce((acc, v) => acc + v.amount, 0),
}}
/>
</p>
<small className="xs">
<FormattedMessage defaultMessage="Mint: {url}" values={{ url: cashu.token[0].mint }} />
</small>
</div>
<div>
<button onClick={e => copyToken(e, token)} className="mr5">
<FormattedMessage defaultMessage="Copy" description="Button: Copy Cashu token" />
</button>
<button onClick={e => redeemToken(e, token)}>
<FormattedMessage defaultMessage="Redeem" description="Button: Redeem Cashu token" />
</button>
</div>
</div>
</div>
);
}

View File

@ -6,12 +6,13 @@ import { visit, SKIP } from "unist-util-visit";
import * as unist from "unist";
import { HexKey, NostrPrefix } from "@snort/nostr";
import { MentionRegex, InvoiceRegex, HashtagRegex } from "Const";
import { MentionRegex, InvoiceRegex, HashtagRegex, CashuRegex } from "Const";
import { eventLink, hexToBech32, splitByUrl, unwrap, validateNostrLink } from "Util";
import Invoice from "Element/Invoice";
import Hashtag from "Element/Hashtag";
import Mention from "Element/Mention";
import HyperText from "Element/HyperText";
import CashuNuts from "Element/CashuNuts";
export type Fragment = string | React.ReactNode;
@ -68,6 +69,19 @@ export default function Text({ content, tags, creator, disableMedia, depth }: Te
.flat();
}
function extractCashuTokens(fragments: Fragment[]) {
return fragments
.map(f => {
if (typeof f === "string" && f.includes("cashuA")) {
return f.split(CashuRegex).map(a => {
return <CashuNuts token={a} />;
});
}
return f;
})
.flat();
}
function extractMentions(frag: TextFragment) {
return frag.body
.map(f => {
@ -163,6 +177,7 @@ export default function Text({ content, tags, creator, disableMedia, depth }: Te
fragments = extractLinks(fragments);
fragments = extractInvoices(fragments);
fragments = extractHashtags(fragments);
fragments = extractCashuTokens(fragments);
return fragments;
}

View File

@ -220,7 +220,7 @@ export default function LoginPage() {
/>
</div>
{error.length > 0 ? <b className="error">{error}</b> : null}
<p className="login-note">
<p>
<FormattedMessage
defaultMessage="Only the secret key can be used to publish (sign events), everything else logs you in read-only mode."
description="Explanation for public key only login is read-only"

View File

@ -7,7 +7,10 @@ import BlueWallet from "Icons/BlueWallet";
import ConnectLNC from "Pages/settings/wallet/LNC";
import ConnectLNDHub from "Pages/settings/wallet/LNDHub";
import ConnectNostrWallet from "Pages/settings/wallet/NWC";
import ConnectCashu from "Pages/settings/wallet/Cashu";
import NostrIcon from "Icons/Nostrich";
import CashuLogo from "cashu.png";
const WalletSettings = () => {
const navigate = useNavigate();
@ -29,6 +32,10 @@ const WalletSettings = () => {
<NostrIcon width={100} height={100} />
<h3 className="f-end">Nostr Wallet Connect</h3>
</div>
{/*<div className="card" onClick={() => navigate("/settings/wallet/cashu")}>
<img src={CashuLogo} width={100} />
<h3 className="f-end">Cashu</h3>
</div>*/}
</div>
</>
);
@ -53,4 +60,8 @@ export const WalletSettingsRoutes = [
path: "/settings/wallet/nwc",
element: <ConnectNostrWallet />,
},
{
path: "/settings/wallet/cashu",
element: <ConnectCashu />,
},
] as Array<RouteObject>;

View File

@ -0,0 +1,71 @@
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 { CashuWallet } from "Wallet/Cashu";
import { WalletConfig, WalletKind, Wallets } from "Wallet";
import { useNavigate } from "react-router-dom";
const ConnectCashu = () => {
const navigate = useNavigate();
const { formatMessage } = useIntl();
const [mintUrl, setMintUrl] = useState<string>();
const [error, setError] = useState<string>();
async function tryConnect(config: string) {
try {
if (!mintUrl) {
throw new Error("Mint URL is required");
}
const connection = new CashuWallet(config);
await connection.login();
const info = await connection.getInfo();
const newWallet = {
id: uuid(),
kind: WalletKind.Cashu,
active: true,
info,
data: mintUrl,
} 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 mint URL" />
</h4>
<div className="flex">
<div className="f-grow mr10">
<input
type="text"
placeholder="Mint URL"
className="w-max"
value={mintUrl}
onChange={e => setMintUrl(e.target.value)}
/>
</div>
<AsyncButton onClick={() => tryConnect(unwrap(mintUrl))} disabled={!mintUrl}>
<FormattedMessage defaultMessage="Connect" />
</AsyncButton>
</div>
{error && <b className="error p10">{error}</b>}
</>
);
};
export default ConnectCashu;

View File

@ -0,0 +1,75 @@
import {
InvoiceRequest,
LNWallet,
prToWalletInvoice,
Sats,
WalletError,
WalletErrorCode,
WalletInfo,
WalletInvoice,
WalletInvoiceState,
} from "Wallet";
import { CashuMint, CashuWallet as TheCashuWallet, getEncodedToken, Proof } from "@cashu/cashu-ts";
export class CashuWallet implements LNWallet {
#mint: string;
#wallet?: TheCashuWallet;
constructor(mint: string) {
this.#mint = mint;
}
isReady(): boolean {
return this.#wallet !== undefined;
}
async getInfo(): Promise<WalletInfo> {
if (!this.#wallet) {
throw new WalletError(WalletErrorCode.GeneralError, "Wallet not initialized");
}
const keysets = await this.#wallet.mint.getKeySets();
return {
nodePubKey: "asdd",
alias: "Cashu mint: " + this.#mint,
} as WalletInfo;
}
async login(_?: string | undefined): Promise<boolean> {
const m = new CashuMint(this.#mint);
const keys = await m.getKeys();
this.#wallet = new TheCashuWallet(keys, m);
return true;
}
close(): Promise<boolean> {
return Promise.resolve(true);
}
getBalance(): Promise<Sats> {
// return dummy balance of 1337 sats
return Promise.resolve(1337);
}
createInvoice(req: InvoiceRequest): Promise<WalletInvoice> {
throw new Error("Method not implemented.");
}
payInvoice(pr: string): Promise<WalletInvoice> {
throw new Error("Method not implemented.");
}
getInvoices(): Promise<WalletInvoice[]> {
return Promise.resolve([]);
}
}
interface NutBank {
proofs: Array<Proof>;
}
export interface NutStashBackup {
proofs: Array<Proof>;
mints: [
{
mintURL: string;
}
];
}

View File

@ -4,6 +4,7 @@ import { decodeInvoice, unwrap } from "Util";
import { LNCWallet } from "./LNCWallet";
import LNDHubWallet from "./LNDHub";
import { NostrConnectWallet } from "./NostrWalletConnect";
import { CashuWallet } from "./Cashu";
import { setupWebLNWalletConfig, WebLNWallet } from "./WebLN";
export enum WalletKind {
@ -11,6 +12,7 @@ export enum WalletKind {
LNC = 2,
WebLN = 3,
NWC = 4,
Cashu = 5,
}
export enum WalletErrorCode {
@ -251,6 +253,9 @@ export class WalletStore {
case WalletKind.NWC: {
return new NostrConnectWallet(unwrap(cfg.data));
}
case WalletKind.Cashu: {
return new CashuWallet(unwrap(cfg.data));
}
}
}
}

BIN
packages/app/src/cashu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@ -422,6 +422,10 @@ body.scroll-lock {
height: 100vh;
}
small.xs {
font-size: small;
}
.pointer {
cursor: pointer;
}

View File

@ -455,6 +455,9 @@
"KahimY": {
"defaultMessage": "Unknown event kind: {kind}"
},
"KoFlZg": {
"defaultMessage": "Enter mint URL"
},
"L7SZPr": {
"defaultMessage": "For more information about donations see {link}."
},
@ -595,6 +598,10 @@
"SOqbe9": {
"defaultMessage": "Update Lightning Address"
},
"SX58hM": {
"defaultMessage": "Copy",
"description": "Button: Copy Cashu token"
},
"SYQtZ7": {
"defaultMessage": "LN Address Proxy"
},
@ -604,6 +611,9 @@
"Ss0sWu": {
"defaultMessage": "Pay Now"
},
"TMfYfY": {
"defaultMessage": "Cashu token"
},
"TpgeGw": {
"defaultMessage": "Hex Salt..",
"description": "Hexidecimal 'salt' input for imgproxy"
@ -614,6 +624,9 @@
"UDYlxu": {
"defaultMessage": "Pending Subscriptions"
},
"ULotH9": {
"defaultMessage": "Amount: {amount} sats"
},
"UQ3pOC": {
"defaultMessage": "On Nostr, many people have the same username. User names and identity are separate things. You can get a unique identifier in the next step."
},
@ -669,6 +682,10 @@
"XgWvGA": {
"defaultMessage": "Reactions"
},
"XrSk2j": {
"defaultMessage": "Redeem",
"description": "Button: Redeem Cashu token"
},
"XzF0aC": {
"defaultMessage": "Key manager extensions are more secure and allow you to easily login to any Nostr client, here are some well known extensions:"
},
@ -852,6 +869,9 @@
"iNWbVV": {
"defaultMessage": "Handle"
},
"iUsU2x": {
"defaultMessage": "Mint: {url}"
},
"iXPL0Z": {
"defaultMessage": "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead"
},

View File

@ -149,6 +149,7 @@
"KQvWvD": "Deleted",
"KWuDfz": "I have saved my keys, continue",
"KahimY": "Unknown event kind: {kind}",
"KoFlZg": "Enter mint URL",
"L7SZPr": "For more information about donations see {link}.",
"LF5kYT": "Other Connections",
"LXxsbk": "Anonymous",
@ -195,12 +196,15 @@
"RoOyAh": "Relays",
"Rs4kCE": "Bookmark",
"SOqbe9": "Update Lightning Address",
"SX58hM": "Copy",
"SYQtZ7": "LN Address Proxy",
"Sjo1P4": "Custom",
"Ss0sWu": "Pay Now",
"TMfYfY": "Cashu token",
"TpgeGw": "Hex Salt..",
"TwyMau": "Account",
"UDYlxu": "Pending Subscriptions",
"ULotH9": "Amount: {amount} sats",
"UQ3pOC": "On Nostr, many people have the same username. User names and identity are separate things. You can get a unique identifier in the next step.",
"UUPFlt": "Users must accept the content warning to show the content of your note.",
"Up5U7K": "Block",
@ -219,6 +223,7 @@
"WxthCV": "e.g. Jack",
"X7xU8J": "nsec, npub, nip-05, hex, mnemonic",
"XgWvGA": "Reactions",
"XrSk2j": "Redeem",
"XzF0aC": "Key manager extensions are more secure and allow you to easily login to any Nostr client, here are some well known extensions:",
"Y31HTH": "Help fund the development of Snort",
"YDURw6": "Service URL",
@ -279,6 +284,7 @@
"iEoXYx": "DeepL translations",
"iGT1eE": "Prevent fake accounts from imitating you",
"iNWbVV": "Handle",
"iUsU2x": "Mint: {url}",
"iXPL0Z": "Can't login with private key on an insecure connection, please use a Nostr key manager extension instead",
"ieGrWo": "Follow",
"itPgxd": "Profile",

3881
yarn.lock

File diff suppressed because it is too large Load Diff