cashu redeem #551
@ -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",
|
||||
|
@ -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;
|
||||
|
68
packages/app/src/Element/CashuNuts.tsx
Normal file
68
packages/app/src/Element/CashuNuts.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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>;
|
||||
|
71
packages/app/src/Pages/settings/wallet/Cashu.tsx
Normal file
71
packages/app/src/Pages/settings/wallet/Cashu.tsx
Normal 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;
|
75
packages/app/src/Wallet/Cashu.ts
Normal file
75
packages/app/src/Wallet/Cashu.ts
Normal 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;
|
||||
}
|
||||
];
|
||||
}
|
@ -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
BIN
packages/app/src/cashu.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.9 KiB |
@ -422,6 +422,10 @@ body.scroll-lock {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
small.xs {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user