diff --git a/package.json b/package.json index d4c516365..5bd52a0bd 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "postinstall": "patch-package" }, "dependencies": { - "@cashu/cashu-ts": "^0.8.2-rc.7", + "@cashu/cashu-ts": "^0.9.0", "@chakra-ui/anatomy": "^2.2.2", "@chakra-ui/breakpoint-utils": "^2.0.8", "@chakra-ui/icons": "^2.1.1", @@ -30,6 +30,7 @@ "@getalby/bitcoin-connect": "^3.2.1", "@getalby/bitcoin-connect-react": "^3.2.1", "@noble/hashes": "^1.3.2", + "@noble/curves": "^1.3.0", "@noble/secp256k1": "^1.7.0", "@react-three/drei": "^9.92.5", "@react-three/fiber": "^8.15.12", diff --git a/src/components/inline-cashu-card.tsx b/src/components/inline-cashu-card.tsx index 03fe091e6..6e2bfb22b 100644 --- a/src/components/inline-cashu-card.tsx +++ b/src/components/inline-cashu-card.tsx @@ -1,11 +1,13 @@ +import { useAsync } from "react-use"; import { useEffect, useState } from "react"; import { Box, Button, ButtonGroup, Card, CardProps, Heading, IconButton, Link } from "@chakra-ui/react"; -import { getDecodedToken, Token } from "@cashu/cashu-ts"; +import { getDecodedToken, Token, CashuMint } from "@cashu/cashu-ts"; import { CopyIconButton } from "./copy-icon-button"; import { useUserMetadata } from "../hooks/use-user-metadata"; import useCurrentAccount from "../hooks/use-current-account"; import { ECashIcon, WalletIcon } from "./icons"; +import { getMint } from "../services/cashu-mints"; function RedeemButton({ token }: { token: string }) { const account = useCurrentAccount()!; @@ -26,6 +28,15 @@ export default function InlineCachuCard({ token, ...props }: Omit(); + const { value: spendable } = useAsync(async () => { + if (!cashu) return; + for (const token of cashu.token) { + const mint = await getMint(token.mint); + const spent = await mint.check({ proofs: token.proofs.map((p) => ({ secret: p.secret })) }); + if (spent.spendable.some((v) => v === false)) return false; + } + return true; + }, [cashu]); useEffect(() => { if (!token.startsWith("cashuA") || token.length < 10) return; @@ -42,7 +53,9 @@ export default function InlineCachuCard({ token, ...props }: Omit - {amount} Cashu sats + + {amount} Cashu sats{spendable === false ? " (Spent)" : ""} + {cashu && Mint: {new URL(cashu.token[0].mint).hostname}} {cashu.memo && Memo: {cashu.memo}} diff --git a/src/components/post-modal/index.tsx b/src/components/post-modal/index.tsx index 9638d39a9..1c59de053 100644 --- a/src/components/post-modal/index.tsx +++ b/src/components/post-modal/index.tsx @@ -56,6 +56,7 @@ import { useTextAreaUploadFileWithForm } from "../../hooks/use-textarea-upload-f import { useThrottle } from "react-use"; import MinePOW from "../mine-pow"; import useAppSettings from "../../hooks/use-app-settings"; +import { ErrorBoundary } from "../error-boundary"; type FormValues = { subject: string; @@ -213,9 +214,11 @@ export default function PostModal({ Preview: - - - + + + + + )} diff --git a/src/components/relay-management-drawer/index.tsx b/src/components/relay-management-drawer/index.tsx index 2cdd60744..8aa321456 100644 --- a/src/components/relay-management-drawer/index.tsx +++ b/src/components/relay-management-drawer/index.tsx @@ -89,7 +89,7 @@ function AddRelayForm() { const submit = handleSubmit((values) => { const url = safeRelayUrl(values.url); if (!url) return; - clientRelaysService.addRelay(url, RelayMode.READ); + clientRelaysService.addRelay(url, RelayMode.ALL); reset(); }); diff --git a/src/components/timeline-page/generic-note-timeline/index.tsx b/src/components/timeline-page/generic-note-timeline/index.tsx index 28bbe7c32..d76b3de78 100644 --- a/src/components/timeline-page/generic-note-timeline/index.tsx +++ b/src/components/timeline-page/generic-note-timeline/index.tsx @@ -64,8 +64,6 @@ function GenericNoteTimeline({ timeline }: { timeline: TimelineLoader }) { setMinDate(getCachedNumber("min") ?? timeline.timeline.value[NOTE_BUFFER]?.created_at ?? Infinity); }, [timeline, setPinDate, setMinDate, setMaxDate, setLatest, getCachedNumber]); - console.log(minDate, pinDate); - const updateNoteMinHeight = useCallback( (id: string, element: Element) => { const rect = element.getBoundingClientRect(); diff --git a/src/helpers/regexp.ts b/src/helpers/regexp.ts index 6a4b9b933..18a782632 100644 --- a/src/helpers/regexp.ts +++ b/src/helpers/regexp.ts @@ -4,7 +4,7 @@ export const getMatchHashtag = () => /(^|[^\p{L}])#([\p{L}\p{N}\p{M}]+)/gu; export const getMatchLink = () => /https?:\/\/([a-zA-Z0-9\.\-]+\.[a-zA-Z]+)([\p{L}\p{N}\p{M}&\.-\/\?=#\-@%\+_,:!~*]*)/gu; export const getMatchEmoji = () => /:([a-zA-Z0-9_-]+):/gi; -export const getMatchCashu = () => /cashuA[A-z0-9]+/g; +export const getMatchCashu = () => /(cashuA[A-Za-z0-9_-]{0,10000}={0,3})/gi; // read more https://www.regular-expressions.info/unicode.html#category export function stripInvisibleChar(str?: string) { diff --git a/src/services/cashu-mints.ts b/src/services/cashu-mints.ts new file mode 100644 index 000000000..9fab91450 --- /dev/null +++ b/src/services/cashu-mints.ts @@ -0,0 +1,127 @@ +import { secp256k1 } from "@noble/curves/secp256k1"; +import { ProjPointType } from "@noble/curves/abstract/weierstrass"; +import { bytesToHex, randomBytes } from "@noble/hashes/utils"; +import { + BlindedMessageData, + CashuMint, + CashuWallet, + MintKeys, + Proof, + SerializedBlindedMessage, + getEncodedToken, + getDecodedToken, + SerializedBlindedSignature, +} from "@cashu/cashu-ts"; +import { bytesToNumber, splitAmount } from "@cashu/cashu-ts/dist/lib/es6/utils"; +import { hashToCurve, pointFromHex, unblindSignature } from "@cashu/cashu-ts/dist/lib/es6/DHKE"; +import { BlindedMessage } from "@cashu/cashu-ts/dist/lib/es6/model/BlindedMessage"; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +/** Copied from @cashu/cashu-ts/src/DHKE and modified to use textDecoder instead of encodeUint8toBase64 */ +function blindMessage(secret: Uint8Array, r?: bigint): { B_: ProjPointType; r: bigint } { + const secretMessageBase64 = textDecoder.decode(secret); //encodeUint8toBase64(secret); + const secretMessage = new TextEncoder().encode(secretMessageBase64); + const Y = hashToCurve(secretMessage); + if (!r) { + r = bytesToNumber(secp256k1.utils.randomPrivateKey()); + } + const rG = secp256k1.ProjectivePoint.BASE.multiply(r); + const B_ = Y.add(rG); + return { B_, r }; +} + +/** Copied from @cashu/cashu-ts/src/DHKE and modified to use textDecoder instead of encodeUint8toBase64 */ +function constructProofs( + promises: Array, + rs: Array, + secrets: Array, + keys: MintKeys, +): Array { + return promises.map((p: SerializedBlindedSignature, i: number) => { + const C_ = pointFromHex(p.C_); + const A = pointFromHex(keys[p.amount]); + const C = unblindSignature(C_, rs[i], A); + const proof = { + id: p.id, + amount: p.amount, + secret: textDecoder.decode(secrets[i]), // encodeUint8toBase64(secrets[i]), + C: C.toHex(true), + }; + return proof; + }); +} + +class P2PKCashuWallet extends CashuWallet { + p2pkCreateRandomBlindedMessages(amount: number, pubkey: string): BlindedMessageData & { amounts: Array } { + const amounts = splitAmount(amount); + return this.p2pkCreateBlindedMessages(amounts, pubkey); + } + p2pkCreateBlindedMessages(amounts: Array, pubkey: string): BlindedMessageData & { amounts: Array } { + const blindedMessages: Array = []; + const secrets: Array = []; + const rs: Array = []; + for (let i = 0; i < amounts.length; i++) { + let deterministicR = undefined; + let secret = undefined; + secret = textEncoder.encode( + JSON.stringify([ + "P2PK", + { + nonce: bytesToHex(randomBytes(16)), + data: pubkey, + }, + ]), + ); + secrets.push(secret); + const { B_, r } = blindMessage(secret, deterministicR); + rs.push(r); + const blindedMessage = new BlindedMessage(amounts[i], B_); + blindedMessages.push(blindedMessage.getSerializedBlindedMessage()); + } + return { blindedMessages, secrets, rs, amounts }; + } + + async p2pkRequestTokens( + amount: number, + id: string, + pubkey: string, + ): Promise<{ proofs: Array; newKeys?: MintKeys }> { + const { blindedMessages, secrets, rs } = this.p2pkCreateRandomBlindedMessages(amount, pubkey); + const payloads = { outputs: blindedMessages }; + const { promises } = await this.mint.mint(payloads, id); + return { + proofs: constructProofs( + promises, + rs, + secrets, + //@ts-ignore + await this.getKeys(promises), + ), + //@ts-ignore + newKeys: await this.changedKeys(promises), + }; + } +} + +//@ts-ignore +const wallet = new P2PKCashuWallet(new CashuMint("https://8333.space:3338")); + +//@ts-ignore +window.wallet = wallet; +//@ts-ignore +window.getDecodedToken = getDecodedToken; +//@ts-ignore +window.getEncodedToken = getEncodedToken; + +const mints = new Map(); + +export async function getMint(url: string) { + const formatted = new URL(url).toString(); + if (!mints.has(formatted)) { + const mint = new CashuMint(formatted); + mints.set(formatted, mint); + } + return mints.get(formatted)!; +} diff --git a/src/views/mailboxes/index.tsx b/src/views/mailboxes/index.tsx index bbc571cbf..16c54b57c 100644 --- a/src/views/mailboxes/index.tsx +++ b/src/views/mailboxes/index.tsx @@ -87,7 +87,7 @@ function AddRelayForm({ onSubmit }: { onSubmit: (url: string) => void }) { function MailboxesPage() { const toast = useToast(); const account = useCurrentAccount()!; - const { inbox, outbox, event } = useUserMailboxes(account.pubkey) || {}; + const { inbox, outbox, event } = useUserMailboxes(account.pubkey, { alwaysRequest: true }) || {}; const { requestSignature } = useSigningContext(); const addRelay = useCallback( diff --git a/yarn.lock b/yarn.lock index 1bb36e8a4..6c8ad20e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -976,10 +976,10 @@ "@babel/helper-validator-identifier" "^7.22.20" to-fast-properties "^2.0.0" -"@cashu/cashu-ts@^0.8.2-rc.7": - version "0.8.2-rc.7" - resolved "https://registry.yarnpkg.com/@cashu/cashu-ts/-/cashu-ts-0.8.2-rc.7.tgz#810109fc5aca8f210cb6865de1b3ab7e245e0771" - integrity sha512-Csvs8BQGdCAqMuoDPYVMAH1h1Z+3qXZfc8V2bxIaMaFWq4O9y/kMIx6uvB1D82+hrjHywpVihMPp9TYCCs3Vjw== +"@cashu/cashu-ts@^0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@cashu/cashu-ts/-/cashu-ts-0.9.0.tgz#425b60282d257a243d5a77e024a1a370dab2bd1d" + integrity sha512-DacSnpv3dJIGzo6A1nHIp2guFuDcmoPB5CX9m0SXA60bQxoIa4srHKLkjxUZ8GFCD9RaI+60UZ2+hZS635Ro2w== dependencies: "@gandlaf21/bolt11-decode" "^3.0.6" "@noble/curves" "^1.0.0" @@ -2382,7 +2382,7 @@ dependencies: "@noble/hashes" "1.3.2" -"@noble/curves@^1.0.0", "@noble/curves@~1.3.0": +"@noble/curves@^1.0.0", "@noble/curves@^1.3.0", "@noble/curves@~1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.3.0.tgz#01be46da4fd195822dab821e72f71bf4aeec635e" integrity sha512-t01iSXPuN+Eqzb4eBX0S5oubSqXbK/xXa1Ne18Hj8f9pStxztHCE2gfboSp/dZRLSqfuLpRK2nDXDK+W9puocA==