+ {
+ 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 (
+
+ )
+ })
+
+ 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 (
+
+ )
+ })
+
+ 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])
}