diff --git a/components/use-indexeddb.js b/components/use-indexeddb.js index 0afeeeeb5..e43bb3166 100644 --- a/components/use-indexeddb.js +++ b/components/use-indexeddb.js @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback, useRef, useMemo } from 'react' export function getDbName (userId, name) { return `app:storage:${userId ?? ''}${name ? `:${name}` : ''}` @@ -294,7 +294,7 @@ function useIndexedDB ({ dbName, storeName, options = DEFAULT_OPTIONS, indices = }) }, [queueOperation, storeName]) - return { add, get, getAll, set, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported } + return useMemo(() => ({ add, get, getAll, set, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported }), [add, get, getAll, set, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported]) } export default useIndexedDB diff --git a/docker-compose.yml b/docker-compose.yml index 31825444e..0f47ad104 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -821,6 +821,34 @@ services: CONNECT: "localhost:${LNBITS_WEB_PORT}" TORDIR: "/app/.tor" cpu_shares: "${CPU_SHARES_LOW}" + cashu: + image: cashubtc/nutshell:0.16.4 + container_name: cashu + profiles: + - wallets + restart: unless-stopped + ports: + - "3338:3338" + depends_on: + lnd: + condition: service_healthy + restart: true + environment: + - MINT_LISTEN_HOST=0.0.0.0 + - MINT_LISTEN_PORT=3338 + - MINT_INFO_NAME="SN Cashu Test Mint" + - MINT_PRIVATE_KEY=TEST_PRIVATE_KEY + - MINT_BACKEND_BOLT11_SAT=LndRPCWallet + - MINT_LND_RPC_ENDPOINT=lnd:10009 + - MINT_LND_RPC_CERT=/app/.lnd/tls.cert + - MINT_LND_RPC_MACAROON=/app/.lnd/data/chain/bitcoin/regtest/admin.macaroon + command: ["poetry", "run", "mint"] + volumes: + - lnd:/app/.lnd + - cashu:/app/.cashu + labels: + CONNECT: "localhost:3338" + cpu_shares: "${CPU_SHARES_LOW}" volumes: db: os: @@ -833,4 +861,5 @@ volumes: nwc_send: nwc_recv: tordata: - eclair: \ No newline at end of file + eclair: + cashu: \ No newline at end of file diff --git a/lib/validate.js b/lib/validate.js index 12bce77a8..6b235f183 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -515,3 +515,7 @@ export const deviceSyncSchema = object().shape({ return true }) }) + +export const mintQuoteSchema = object({ + amount: intValidator.required('required').positive('must be positive') +}) diff --git a/package-lock.json b/package-lock.json index d373b8eb0..139b52739 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@apollo/server": "^4.11.0", "@as-integrations/next": "^3.1.0", "@auth/prisma-adapter": "^2.7.0", + "@cashu/cashu-ts": "^2.1.0", "@graphql-tools/schema": "^10.0.6", "@lightninglabs/lnc-web": "^0.3.2-alpha", "@noble/curves": "^1.6.0", @@ -2429,6 +2430,115 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@cashu/cashu-ts": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.1.0.tgz", + "integrity": "sha512-qFfFz1dx9keJxumjk5FyTvI1j0Yp/P5LXDy0cGO4Xlp3WYKOI1nykNOTPd+bTY9vSkvIM+xuXRer9BtQxqHtwA==", + "license": "MIT", + "dependencies": { + "@cashu/crypto": "^0.3.4", + "@noble/curves": "^1.3.0", + "@noble/hashes": "^1.3.3", + "@scure/bip32": "^1.3.3", + "buffer": "^6.0.3" + } + }, + "node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", + "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@cashu/cashu-ts/node_modules/@scure/base": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.1.tgz", + "integrity": "sha512-DGmGtC8Tt63J5GfHgfl5CuAXh96VF/LD8K9Hr/Gv0J2lAoRGlPOMpqMpMbCTOoOJMZCk2Xt+DskdDyn6dEFdzQ==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@cashu/cashu-ts/node_modules/@scure/bip32": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.6.1.tgz", + "integrity": "sha512-jSO+5Ud1E588Y+LFo8TaB8JVPNAZw/lGGao+1SepHDeTs2dFLurdNIAgUuDlwezqEjRjElkCJajVrtrZaBxvaQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.8.0", + "@noble/hashes": "~1.7.0", + "@scure/base": "~1.2.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@cashu/crypto": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cashu/crypto/-/crypto-0.3.4.tgz", + "integrity": "sha512-mfv1Pj4iL1PXzUj9NKIJbmncCLMqYfnEDqh/OPxAX0nNBt6BOnVJJLjLWFlQeYxlnEfWABSNkrqPje1t5zcyhA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "buffer": "^6.0.3" + } + }, + "node_modules/@cashu/crypto/node_modules/@noble/hashes": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", + "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@cashu/crypto/node_modules/@scure/base": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.1.tgz", + "integrity": "sha512-DGmGtC8Tt63J5GfHgfl5CuAXh96VF/LD8K9Hr/Gv0J2lAoRGlPOMpqMpMbCTOoOJMZCk2Xt+DskdDyn6dEFdzQ==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@cashu/crypto/node_modules/@scure/bip32": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.6.1.tgz", + "integrity": "sha512-jSO+5Ud1E588Y+LFo8TaB8JVPNAZw/lGGao+1SepHDeTs2dFLurdNIAgUuDlwezqEjRjElkCJajVrtrZaBxvaQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.8.0", + "@noble/hashes": "~1.7.0", + "@scure/base": "~1.2.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@cashu/crypto/node_modules/@scure/bip39": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.5.1.tgz", + "integrity": "sha512-GnlufVSP9UdAo/H2Patfv22VTtpNTyfi+I3qCKpvuB5l1KWzEYx+l2TNpBy9Ksh4xTs3Rn06tBlpWCi/1Vz8gw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.7.0", + "@scure/base": "~1.2.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", @@ -4337,11 +4447,12 @@ } }, "node_modules/@noble/curves": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.6.0.tgz", - "integrity": "sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.0.tgz", + "integrity": "sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==", + "license": "MIT", "dependencies": { - "@noble/hashes": "1.5.0" + "@noble/hashes": "1.7.0" }, "engines": { "node": "^14.21.3 || >=16" @@ -4351,9 +4462,10 @@ } }, "node_modules/@noble/curves/node_modules/@noble/hashes": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.5.0.tgz", - "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", + "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", + "license": "MIT", "engines": { "node": "^14.21.3 || >=16" }, @@ -7340,6 +7452,30 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-compare": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-compare/-/buffer-compare-1.1.1.tgz", @@ -11175,6 +11311,26 @@ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", diff --git a/package.json b/package.json index f4e7f2c5a..86915722b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@apollo/server": "^4.11.0", "@as-integrations/next": "^3.1.0", "@auth/prisma-adapter": "^2.7.0", + "@cashu/cashu-ts": "^2.1.0", "@graphql-tools/schema": "^10.0.6", "@lightninglabs/lnc-web": "^0.3.2-alpha", "@noble/curves": "^1.6.0", diff --git a/pages/_app.js b/pages/_app.js index 9c540d557..38b8a7c8a 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -23,6 +23,7 @@ import { HasNewNotesProvider } from '@/components/use-has-new-notes' import { WebLnProvider } from '@/wallets/webln/client' import { AccountProvider } from '@/components/account' import { WalletsProvider } from '@/wallets/index' +import { CashuProvider } from '@/wallets/cashu/client' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) @@ -117,26 +118,28 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - - - - {!router?.query?.disablePrompt && } - - - - - - - - - + + + + + + + + + + + + {!router?.query?.disablePrompt && } + + + + + + + + + + diff --git a/pages/wallets/[wallet].js b/pages/wallets/[wallet].js index ff5830a6d..2bced2b5b 100644 --- a/pages/wallets/[wallet].js +++ b/pages/wallets/[wallet].js @@ -13,7 +13,7 @@ import { canReceive, canSend, isConfigured } from '@/wallets/common' import { SSR } from '@/lib/constants' import WalletButtonBar from '@/components/wallet-buttonbar' import { useWalletConfigurator } from '@/wallets/config' -import { useCallback, useMemo } from 'react' +import { Fragment, useCallback, useMemo } from 'react' import { useMe } from '@/components/me' import validateWallet from '@/wallets/validate' import { ValidationError } from 'yup' @@ -101,6 +101,7 @@ export default function WalletSettings () { > {wallet && } + {wallet && } + { + wallet.def.components?.map((Component, i) => { + return () + }) + } + + ) +} diff --git a/pages/withdraw.js b/pages/withdraw.js index 1a39386e3..614b63932 100644 --- a/pages/withdraw.js +++ b/pages/withdraw.js @@ -149,7 +149,7 @@ export function InvWithdrawal () { ) } -function InvoiceScanner ({ fieldName }) { +export function InvoiceScanner ({ fieldName }) { const showModal = useShowModal() const [,, helpers] = useField(fieldName) const toaster = useToast() diff --git a/prisma/migrations/20250109022428_wallet_cashu/migration.sql b/prisma/migrations/20250109022428_wallet_cashu/migration.sql new file mode 100644 index 000000000..52cd6e9b5 --- /dev/null +++ b/prisma/migrations/20250109022428_wallet_cashu/migration.sql @@ -0,0 +1,23 @@ +-- AlterEnum +ALTER TYPE "WalletType" ADD VALUE 'CASHU'; + +-- CreateTable +CREATE TABLE "WalletCashu" ( + "id" SERIAL NOT NULL, + "walletId" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "mintUrl" TEXT NOT NULL, + + CONSTRAINT "WalletCashu_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletCashu_walletId_key" ON "WalletCashu"("walletId"); + +-- AddForeignKey +ALTER TABLE "WalletCashu" ADD CONSTRAINT "WalletCashu_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TRIGGER wallet_cashu_as_jsonb +AFTER INSERT OR UPDATE ON "WalletCashu" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dcdb3b938..923acd493 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -193,6 +193,7 @@ enum WalletType { BLINK LNC WEBLN + CASHU } model Wallet { @@ -220,6 +221,7 @@ model Wallet { walletNWC WalletNWC? walletPhoenixd WalletPhoenixd? walletBlink WalletBlink? + walletCashu WalletCashu? vaultEntries VaultEntry[] @relation("VaultEntries") withdrawals Withdrawl[] @@ -329,6 +331,15 @@ model WalletPhoenixd { secondaryPassword String? } +model WalletCashu { + id Int @id @default(autoincrement()) + walletId Int @unique + wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + mintUrl String +} + model Mute { muterId Int mutedId Int diff --git a/public/wallets/cashu-dark.png b/public/wallets/cashu-dark.png new file mode 100644 index 000000000..c36680148 Binary files /dev/null and b/public/wallets/cashu-dark.png differ diff --git a/public/wallets/cashu.png b/public/wallets/cashu.png new file mode 100644 index 000000000..32763a4cf Binary files /dev/null and b/public/wallets/cashu.png differ diff --git a/wallets/cashu/client.js b/wallets/cashu/client.js new file mode 100644 index 000000000..0c5822dcc --- /dev/null +++ b/wallets/cashu/client.js @@ -0,0 +1,49 @@ +import { CashuMint, CashuWallet } from '@cashu/cashu-ts' +import dynamic from 'next/dynamic' +import { Mutex } from 'async-mutex' + +const mutex = new Mutex() +const Balance = dynamic(() => import('@/wallets/cashu/components/balance')) +const Deposit = dynamic(() => import('@/wallets/cashu/components/deposit')) +const Withdraw = dynamic(() => import('@/wallets/cashu/components/withdraw')) +const CashuProvider = dynamic(() => import('@/wallets/cashu/components/context').then(mod => mod.CashuProvider)) + +export * from '@/wallets/cashu/index' + +export async function testSendPayment ({ mintUrl }, { logger, signal }) { + const mint = new CashuMint(mintUrl) + const wallet = new CashuWallet(mint) + try { + await wallet.loadMint() + } catch (err) { + throw new Error('failed to load mint info: ' + err.message) + } + const info = await mint.getInfo() + logger.info(`connected to ${info.name} running ${info.version}`) +} + +export async function sendPayment (bolt11, config, { cashu: { proofs, setProofs }, logger, signal }) { + const { mintUrl } = config + const mint = new CashuMint(mintUrl) + const wallet = new CashuWallet(mint) + await wallet.loadMint() + + // run this in a mutex such that multiple in-flight payments + // don't overwrite each other's call to store the new set of proofs + return mutex.runExclusive(async () => { + const quote = await wallet.createMeltQuote(bolt11) + + const amountToSend = quote.amount + quote.fee_reserve + const { keep, send } = await wallet.send(amountToSend, proofs.current, { includeFees: true }) + const meltProof = await wallet.meltProofs(quote, send) + + const newProofs = [...keep, ...meltProof.change] + await setProofs(newProofs) + + return meltProof.quote.payment_preimage + }) +} + +export const components = [Balance, Deposit, Withdraw] + +export { CashuProvider } diff --git a/wallets/cashu/components/balance.js b/wallets/cashu/components/balance.js new file mode 100644 index 000000000..963fcedac --- /dev/null +++ b/wallets/cashu/components/balance.js @@ -0,0 +1,11 @@ +import { numWithUnits } from '@/lib/format' +import { useCashuBalance } from '@/wallets/cashu/components/context' + +export default function Balance (wallet, { showModal }) { + const balance = useCashuBalance() + return ( +
+ {numWithUnits(balance, { abbreviate: false })} +
+ ) +} diff --git a/wallets/cashu/components/context.js b/wallets/cashu/components/context.js new file mode 100644 index 000000000..a954ef47e --- /dev/null +++ b/wallets/cashu/components/context.js @@ -0,0 +1,52 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import useIndexedDB, { getDbName } from '@/components/use-indexeddb' +import { useMe } from '@/components/me' + +function useCashuDb () { + const { me } = useMe() + const idbConfig = useMemo(() => ({ dbName: getDbName(me?.id, 'cashu'), storeName: 'cashu', options: {} }), [me?.id]) + return useIndexedDB(idbConfig) +} + +const CashuContext = createContext({}) + +export function CashuProvider ({ children }) { + const { get, set } = useCashuDb() + const proofs = useRef([]) + const [balance, setBalance] = useState(0) + + useEffect(() => { + (async () => { + const initialProofs = await get('proofs') ?? [] + proofs.current = initialProofs + setBalance(initialProofs.reduce((acc, proof) => acc + proof.amount, 0)) + })() + }, []) + + const setProofs = useCallback(async (newProofs) => { + proofs.current = newProofs + await set('proofs', newProofs) + setBalance(newProofs.reduce((acc, proof) => acc + proof.amount, 0)) + }, [set]) + + const addProofs = useCallback(async (newProofs) => { + await setProofs([...proofs.current, ...newProofs]) + }, [setProofs]) + + const value = useMemo(() => ({ balance, proofs, setProofs, addProofs }), [balance, setProofs, addProofs]) + return ( + + {children} + + ) +} + +export function useCashuBalance () { + const { balance } = useContext(CashuContext) + return balance +} + +export function useCashuProofs () { + const { proofs, addProofs, setProofs } = useContext(CashuContext) + return { proofs, addProofs, setProofs } +} diff --git a/wallets/cashu/components/deposit.js b/wallets/cashu/components/deposit.js new file mode 100644 index 000000000..03a2813b9 --- /dev/null +++ b/wallets/cashu/components/deposit.js @@ -0,0 +1,66 @@ +import { useState, useCallback, useEffect } from 'react' +import { useShowModal } from '@/components/modal' +import { CashuMint, CashuWallet } from '@cashu/cashu-ts' +import { mintQuoteSchema } from '@/lib/validate' +import { Button, InputGroup } from 'react-bootstrap' + +import dynamic from 'next/dynamic' +import { useWalletLogger } from '@/wallets/logger' +import { numWithUnits } from '@/lib/format' +const Form = dynamic(() => import('@/components/form').then(mod => mod.Form)) +const Input = dynamic(() => import('@/components/form').then(mod => mod.Input)) +const SubmitButton = dynamic(() => import('@/components/form').then(mod => mod.SubmitButton)) +const CashuQr = dynamic(() => import('@/wallets/cashu/components/qr'), { ssr: false }) + +export default function Deposit ({ wallet }) { + const logger = useWalletLogger(wallet) + const showModal = useShowModal() + const { mintUrl } = wallet.config + const [mint] = useState(new CashuMint(mintUrl)) + const [cashuWallet] = useState(new CashuWallet(mint)) + + useEffect(() => { + cashuWallet.loadMint() + }, [cashuWallet]) + + const onSubmit = useCallback(async ({ amount }) => { + const mintQuote = await cashuWallet.createMintQuote(amount) + logger.info(`created mint invoice for ${numWithUnits(amount, { abbreviate: false })}`, { quote: mintQuote.quote, request: mintQuote.request }) + showModal(onClose => { + return ( + + ) + }) + }, [logger, cashuWallet]) + + const onClick = () => showModal(onClose => { + return ( +
+ sats} + /> +
+ + confirm + +
+
+ ) + }) + + return ( + + ) +} diff --git a/wallets/cashu/components/qr.js b/wallets/cashu/components/qr.js new file mode 100644 index 000000000..ce92c8668 --- /dev/null +++ b/wallets/cashu/components/qr.js @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' +import { useWalletLogger } from '@/wallets/logger' +import { numWithUnits } from '@/lib/format' +import { CompactLongCountdown } from '@/components/countdown' +import Qr from '@/components/qr' +import { useCashuProofs } from '@/wallets/cashu/components/context' + +export default function CashuQr ({ wallet, cashu, mintQuote, amount, onClose }) { + const logger = useWalletLogger(wallet) + const [paid, setPaid] = useState(false) + const { addProofs } = useCashuProofs() + + const amt = numWithUnits(amount, { abbreviate: false }) + const expiresAt = new Date(mintQuote.expiry * 1000).toISOString() + + useEffect(() => { + const interval = setInterval(async () => { + const { paid } = await cashu.checkMintQuote(mintQuote.quote) + setPaid(paid) + if (paid) { + const newProofs = await cashu.mintProofs(amount, mintQuote.quote) + await addProofs(newProofs) + logger.ok(`minted ${amt} of tokens`, { quote: mintQuote.quote, request: mintQuote.request }) + clearInterval(interval) + onClose() + } + }, 1000) + return () => clearInterval(interval) + }, [mintQuote.id, addProofs, logger]) + + let statusVariant = 'pending' + let status = + + if (paid) { + statusVariant = 'confirmed' + status = <>{amt} received + } + + return ( + + ) +} diff --git a/wallets/cashu/components/withdraw.js b/wallets/cashu/components/withdraw.js new file mode 100644 index 000000000..99c610f79 --- /dev/null +++ b/wallets/cashu/components/withdraw.js @@ -0,0 +1,97 @@ +import { Form, Input, SubmitButton } from '@/components/form' +import { useMe } from '@/components/me' +import { useShowModal } from '@/components/modal' +import { withdrawlSchema } from '@/lib/validate' +import { InvoiceScanner } from '@/pages/withdraw' +import { useWalletLogger } from '@/wallets/logger' +import { CashuMint, CashuWallet } from '@cashu/cashu-ts' +import { useCallback, useState } from 'react' +import { Button, InputGroup } from 'react-bootstrap' +import { useCashuProofs } from './context' +import { numWithUnits } from '@/lib/format' +import { useToast } from '@/components/toast' + +export default function Withdraw ({ wallet }) { + const logger = useWalletLogger(wallet) + const showModal = useShowModal() + const { mintUrl } = wallet.config + const [mint] = useState(new CashuMint(mintUrl)) + const [cashu] = useState(new CashuWallet(mint)) + const { me } = useMe() + const maxFeeDefault = me?.privates?.withdrawMaxFeeDefault + const { proofs, setProofs } = useCashuProofs() + const toaster = useToast() + + const onSubmit = useCallback(async ({ invoice, maxFee, onClose }) => { + let meltQuote + try { + await cashu.loadMint() + + meltQuote = await cashu.createMeltQuote(invoice) + if (maxFee < meltQuote.fee_reserve) { + throw new Error(`max fee must be at least ${numWithUnits(meltQuote.fee_reserve, { abbreviate: false })}`) + } + const amt = numWithUnits(meltQuote.amount, { abbreviate: false }) + logger.info(`created melt invoice for ${amt}`, { + quote: meltQuote.quote, + fee_reserve: numWithUnits(meltQuote.fee_reserve, { abbreviate: false }), + max_fee: numWithUnits(maxFee, { abbreviate: false }) + }) + + const amountToSend = meltQuote.amount + maxFee + const { keep, send } = await cashu.send(amountToSend, proofs.current, { includeFees: true }) + const meltProof = await cashu.meltProofs(meltQuote, send) + + const fees = maxFee - meltProof.change.reduce((acc, change) => acc + change.amount, 0) + logger.ok(`melted ${amt} of tokens`, { quote: meltQuote.quote, fee: numWithUnits(fees, { abbreviate: false }) }) + + const newProofs = [...keep, ...meltProof.change] + await setProofs(newProofs) + + onClose() + return meltProof.quote.payment_preimage + } catch (err) { + logger.error('withdrawal failed: ' + err.message, meltQuote) + toaster.danger('withdrawal failed') + } + }, [logger, cashu, setProofs, toaster]) + + // TODO: also support withdrawing to lightning address + const onClick = () => showModal(onClose => { + return ( +
{ + await onSubmit({ invoice, maxFee, onClose }) + }} + > + } + /> + sats} + /> +
+ withdraw +
+
+ ) + }) + + return ( + + ) +} diff --git a/wallets/cashu/index.js b/wallets/cashu/index.js new file mode 100644 index 000000000..0beeaafb6 --- /dev/null +++ b/wallets/cashu/index.js @@ -0,0 +1,24 @@ +import { string } from 'yup' + +export const name = 'cashu' +export const walletType = 'CASHU' +export const walletField = 'walletCashu' + +export const fields = [ + { + name: 'mintUrl', + label: 'mint url', + clientOnly: true, + type: 'text', + // TODO: add mint suggestions + validate: process.env.NODE_ENV === 'development' + ? string().url().required('required').trim() + : string().https().required('required').trim() + } +] + +export const card = { + title: 'Cashu', + subtitle: 'use [Cashu](https://cashu.space) for payments', + image: { src: '/wallets/cashu.png' } +} diff --git a/wallets/client.js b/wallets/client.js index 8bd44698f..897d13e40 100644 --- a/wallets/client.js +++ b/wallets/client.js @@ -7,5 +7,6 @@ import * as lnd from '@/wallets/lnd/client' import * as webln from '@/wallets/webln/client' import * as blink from '@/wallets/blink/client' import * as phoenixd from '@/wallets/phoenixd/client' +import * as cashu from '@/wallets/cashu/client' -export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd] +export default [nwc, lnbits, lnc, lnAddr, cln, lnd, webln, blink, phoenixd, cashu] diff --git a/wallets/payment.js b/wallets/payment.js index 043d57f89..6d07b8b06 100644 --- a/wallets/payment.js +++ b/wallets/payment.js @@ -10,6 +10,7 @@ import { import { canSend } from './common' import { useWalletLoggerFactory } from './logger' import { timeoutSignal, withTimeout } from '@/lib/time' +import { useCashuProofs } from '@/wallets/cashu/components/context' export function useWalletPayment () { const wallets = useSendWallets() @@ -140,6 +141,9 @@ function invoiceController (inv, isInvoice) { } function useSendPayment () { + // TODO: refactor this in a generic way to provide specific wallet context + const { proofs, setProofs } = useCashuProofs() + return useCallback(async (wallet, logger, invoice) => { if (!wallet.config.enabled) { throw new WalletNotEnabledError(wallet.def.name) @@ -156,7 +160,11 @@ function useSendPayment () { const preimage = await withTimeout( wallet.def.sendPayment(bolt11, wallet.config, { logger, - signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) + signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS), + cashu: { + proofs, + setProofs + } }), WALLET_SEND_PAYMENT_TIMEOUT_MS) logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage }) @@ -165,5 +173,5 @@ function useSendPayment () { const message = err.message || err.toString?.() throw new WalletSenderError(wallet.def.name, invoice, message) } - }, []) + }, [proofs]) }