From a3f987675bafdac8458978bd78c966cc655b9bd5 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 9 Apr 2026 23:15:51 +0000 Subject: [PATCH 01/18] fix: receive screen camera placeholder + pending invoice lifecycle - Lightning receive: camera/scan placeholder replaces blank QR area before invoice generation; transforms into QR code once invoice is created - Pending Lightning invoices (receive + LNURL-withdraw) now store invoice, quoteId, and expiresAt on the transaction record for QR re-display - Activity list: 'awaiting payment' badge + Show QR for active invoices; 'expired' badge + Delete for expired invoices; completed = immutable - completeTransaction clears invoice/quoteId fields on completion - deleteTransaction for removing expired pending invoices from DWN - InvoiceQrDialog for re-displaying pending invoice QR from activity - Consistent pending/expired badges in transaction history view - Added missing @radix-ui/react-visually-hidden dependency --- bun.lock | 7 +- package.json | 1 + pnpm-lock.yaml | 100 +++++++++-------- src/App.tsx | 82 ++++++++++++++ src/components/wallet/transaction-history.tsx | 27 ++++- .../wallet/transaction-list-card.tsx | 103 +++++++++++++++++- .../wallet/unified-receive-dialog.tsx | 43 ++++++-- src/hooks/use-wallet.ts | 36 ++++++ src/protocol/cashu-wallet-protocol.ts | 9 ++ 9 files changed, 347 insertions(+), 61 deletions(-) diff --git a/bun.lock b/bun.lock index aa33aa8..2f7ca2f 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", + "@radix-ui/react-visually-hidden": "^1.2.4", "@types/qrcode": "^1.5.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -472,7 +473,7 @@ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "optionalDependencies": { "@types/react": "18.3.28" }, "peerDependencies": { "react": "18.3.1" } }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], - "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "optionalDependencies": { "@types/react": "18.3.28", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "18.3.1", "react-dom": "18.3.1" } }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg=="], "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], @@ -1876,6 +1877,10 @@ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "optionalDependencies": { "@types/react": "18.3.28" }, "peerDependencies": { "react": "18.3.1" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-tooltip/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "optionalDependencies": { "@types/react": "18.3.28", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "18.3.1", "react-dom": "18.3.1" } }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + + "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "optionalDependencies": { "@types/react": "18.3.28", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "18.3.1", "react-dom": "18.3.1" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@rollup/plugin-babel/@rollup/pluginutils": ["@rollup/pluginutils@3.1.0", "", { "dependencies": { "@types/estree": "0.0.39", "estree-walker": "1.0.1", "picomatch": "2.3.2" }, "peerDependencies": { "rollup": "2.80.0" } }, "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg=="], "@rollup/plugin-babel/rollup": ["rollup@2.80.0", "", { "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ=="], diff --git a/package.json b/package.json index dbfdfc9..f4dcb16 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", + "@radix-ui/react-visually-hidden": "^1.2.4", "@types/qrcode": "^1.5.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93773c4..71a8d84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,21 +15,9 @@ importers: '@cashu/cashu-ts': specifier: ^3.6.2 version: 3.6.2 - '@enbox/agent': - specifier: ^0.5.16 - version: 0.5.16 - '@enbox/api': - specifier: ^0.6.10 - version: 0.6.11 - '@enbox/auth': - specifier: ^0.6.18 - version: 0.6.19 '@enbox/browser': - specifier: ^0.1.22 - version: 0.1.23 - '@enbox/dwn-sdk-js': - specifier: ^0.3.2 - version: 0.3.2 + specifier: ^0.3.6 + version: 0.3.6 '@radix-ui/react-dialog': specifier: ^1.1.1 version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -51,6 +39,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.1.2 version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': + specifier: ^1.2.4 + version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 @@ -751,20 +742,20 @@ packages: resolution: {integrity: sha512-WXTuFvL3G+74SchFAtz3FgIYVOe196ycvGsMgvSH/8Goptb1qpIQtIuM4SOK9G9lhMWYpHxnXyy544ZhluFOew==} engines: {node: '>=6'} - '@enbox/agent@0.5.16': - resolution: {integrity: sha512-SOstajLmrIEkf+NJC5EjXI1o3zJmMOGv4VglMxaEBq8/HFQsDZG1hJ64TNAoMDfID2L/TnCnZLTBVB5D/mttGA==} + '@enbox/agent@0.6.5': + resolution: {integrity: sha512-3+jAUO7J1u6TzM+RGUD+QAe72nIKyzPVPKnHVqCqbIHykZSwS0Iu5FNbmUfYqQbizHHmgnpZGuPrYDn0JqQs0g==} engines: {bun: '>=1.0.0'} - '@enbox/api@0.6.11': - resolution: {integrity: sha512-QvIUKOFVPZEtgiVNqcTYTtfVn5nnKxO1HhnyQRsNpB6txp4jLmOkmthM5qRUmCq/PeEtWO02vxfV8rizFUIB8Q==} + '@enbox/api@0.6.20': + resolution: {integrity: sha512-+z6fAAQw5XkK2HXMTnW4A43P+pHdfFUcSoyjmt6B4cf1REjqj9eM1mkJH0PTmDBeUrPfP9gnsBXsFtRDRnyssw==} engines: {bun: '>=1.0.0'} - '@enbox/auth@0.6.19': - resolution: {integrity: sha512-kCknNjR1xHGHxziWrnoDaXygKxZJCp3sNkLAttLn3AuNqJtYAx2d8Mnpug8jQ+0x65ZDsEzC+S7wpFLV28dOUQ==} + '@enbox/auth@0.6.28': + resolution: {integrity: sha512-ZAesgoURmUd+jXQRd8S8OKL6oXnd6CfY0zyz9UXBUaiA6Rh45rPF209XdkBDxTFlZns8tn+pmIoFiyaTOVSYqA==} engines: {bun: '>=1.0.0'} - '@enbox/browser@0.1.23': - resolution: {integrity: sha512-5075hjXZAkPqVCIPqsBp+CKRcRHL/BntXc8ZlHbfPjSoSkpzlvo30XzWbXIQrhl5zDSAKcqYpc6hQS4OFaN4tA==} + '@enbox/browser@0.3.6': + resolution: {integrity: sha512-LyNjAcWGIQ6AMnx5d2/HsUye+WOlO5/mX9v/TSuHqpue+NQuBEcuLEx3+kxRQO5Hg9VtIA8Nj0ExXnpajHZLRA==} '@enbox/common@0.1.0': resolution: {integrity: sha512-rb4Fpz+vrPhDCFm93DfufV1PMztzNRztVeY/4DkXd0mUY3/5AE5EsCChmGL042m1GDlUtUL8O0jcOt+Zg5VbSA==} @@ -778,12 +769,12 @@ packages: resolution: {integrity: sha512-Agiw2NQw+CS2vYsoay/haq4ORsDCRHWcrYnDGigSiHrNckS0y0su0gPF1shZ/rXEdFj+vmSeToZPw5by+P/wCg==} engines: {bun: '>=1.0.0'} - '@enbox/dwn-clients@0.2.6': - resolution: {integrity: sha512-5MiuiNsZsnCV7dz5SEElWUNsymuXrw0EsnjJOMC0ivvOU4uPABnERrSHFm+RAX7J4RjRGSqlO8hIYVhkOPuUVQ==} + '@enbox/dwn-clients@0.3.2': + resolution: {integrity: sha512-1VDm53olFZ3TEAejgdzzIIZkFEWvx1OEsiOOMdXa5rSoCbtPsaK4/NJZQAtAzADUmTAwwOEvvGWd05ym6DOBgQ==} engines: {bun: '>=1.0.0'} - '@enbox/dwn-sdk-js@0.3.2': - resolution: {integrity: sha512-AC7kfq8sfjCxgiQ38f+nDnw2OwnLuZqaoK451CUb6VlR+5iPdyjNcDfwjiVYkaCh7l6pNx3TKccvUMB2dJ+d+w==} + '@enbox/dwn-sdk-js@0.3.4': + resolution: {integrity: sha512-33o4edGNSny5acpm2Wosz4qHyAd5kwxLoXRFAzEZUs36fxBcr2+l5BQilA/JUMZ/RHk5FeDbhrb1sz1RRd+Lyw==} engines: {bun: '>= 1.0'} '@esbuild/aix-ppc64@0.21.5': @@ -1437,6 +1428,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-visually-hidden@1.2.4': + resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} @@ -5122,13 +5126,13 @@ snapshots: '@leichtgewicht/ip-codec': 2.0.5 utf8-codec: 1.0.0 - '@enbox/agent@0.5.16': + '@enbox/agent@0.6.5': dependencies: '@enbox/common': 0.1.0 '@enbox/crypto': 0.1.0 '@enbox/dids': 0.1.0 - '@enbox/dwn-clients': 0.2.6 - '@enbox/dwn-sdk-js': 0.3.2 + '@enbox/dwn-clients': 0.3.2 + '@enbox/dwn-sdk-js': 0.3.4 '@scure/bip39': 1.2.2 abstract-level: 1.0.4 ed25519-keygen: 0.4.11 @@ -5139,33 +5143,34 @@ snapshots: - encoding - supports-color - '@enbox/api@0.6.11': + '@enbox/api@0.6.20': dependencies: - '@enbox/agent': 0.5.16 - '@enbox/auth': 0.6.19 + '@enbox/agent': 0.6.5 + '@enbox/auth': 0.6.28 '@enbox/common': 0.1.0 - '@enbox/dwn-clients': 0.2.6 + '@enbox/dwn-clients': 0.3.2 transitivePeerDependencies: - encoding - supports-color - '@enbox/auth@0.6.19': + '@enbox/auth@0.6.28': dependencies: - '@enbox/agent': 0.5.16 + '@enbox/agent': 0.6.5 '@enbox/common': 0.1.0 '@enbox/crypto': 0.1.0 '@enbox/dids': 0.1.0 - '@enbox/dwn-clients': 0.2.6 - '@enbox/dwn-sdk-js': 0.3.2 + '@enbox/dwn-clients': 0.3.2 + '@enbox/dwn-sdk-js': 0.3.4 level: 8.0.1 transitivePeerDependencies: - encoding - supports-color - '@enbox/browser@0.1.23': + '@enbox/browser@0.3.6': dependencies: - '@enbox/agent': 0.5.16 - '@enbox/auth': 0.6.19 + '@enbox/agent': 0.6.5 + '@enbox/api': 0.6.20 + '@enbox/auth': 0.6.28 '@enbox/dids': 0.1.0 transitivePeerDependencies: - encoding @@ -5196,17 +5201,17 @@ snapshots: level: 8.0.1 ms: 2.1.3 - '@enbox/dwn-clients@0.2.6': + '@enbox/dwn-clients@0.3.2': dependencies: '@enbox/common': 0.1.0 '@enbox/crypto': 0.1.0 - '@enbox/dwn-sdk-js': 0.3.2 + '@enbox/dwn-sdk-js': 0.3.4 ms: 2.1.3 transitivePeerDependencies: - encoding - supports-color - '@enbox/dwn-sdk-js@0.3.2': + '@enbox/dwn-sdk-js@0.3.4': dependencies: '@enbox/crypto': 0.1.0 '@enbox/dids': 0.1.0 @@ -5791,6 +5796,15 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/rect@1.1.1': {} '@rolldown/pluginutils@1.0.0-beta.27': {} diff --git a/src/App.tsx b/src/App.tsx index 1279a1d..3624088 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,8 @@ import { Toaster } from 'sonner'; import { receiveToken, getMintInfo, checkTokenSpent } from '@/cashu/wallet-ops'; import { executeCrossMintSwap } from '@/cashu/cross-mint-swap'; import { acquireWalletLock } from '@/lib/wallet-mutex'; +import { QRCodeDisplay } from '@/components/qr-code'; +import { DialogWrapper } from '@/components/ui/dialog-wrapper'; import { toastError, toastSuccess, formatAmount, truncateMintUrl, truncateMiddle } from '@/lib/utils'; import { brand } from '@/lib/brand'; @@ -40,10 +42,63 @@ import { MoonIcon, SunIcon, AlertTriangleIcon, + CheckIcon, + CopyIcon, + XIcon, DownloadIcon, SettingsIcon, } from 'lucide-react'; +// --------------------------------------------------------------------------- +// Invoice QR dialog — for re-displaying a pending invoice from activity +// --------------------------------------------------------------------------- + +function InvoiceQrDialog({ invoice, amount, unit, onClose }: { + invoice: string; + amount: number; + unit: string; + onClose: () => void; +}) { + const [copied, setCopied] = useState(false); + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(invoice); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toastError('Copy failed', new Error('Clipboard access denied')); + } + }; + + return ( + +
+
+

Pending Invoice

+ +
+

+ Waiting for {formatAmount(amount, unit)} payment +

+
+
+ +
+
+ +
+
+ ); +} + // --------------------------------------------------------------------------- // Wallet app (connected) // --------------------------------------------------------------------------- @@ -80,6 +135,7 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP deleteProofs, addTransaction, completeTransaction, + deleteTransaction, markTransactionClaimed, getUnspentProofsForMint, getUnspentProofsByContext, @@ -349,6 +405,22 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP return isSpent; }, [markTransactionClaimed]); + /** Delete a transaction (only allowed for expired pending invoices). */ + const handleDeleteTransaction = useCallback(async (tx: Transaction) => { + if (tx.status !== 'pending') return; + if (!tx.expiresAt || new Date(tx.expiresAt).getTime() >= Date.now()) return; + await deleteTransaction(tx.id); + toastSuccess('Invoice removed'); + }, [deleteTransaction]); + + /** Show the QR code for a pending invoice. */ + const [invoiceQrTx, setInvoiceQrTx] = useState<{ invoice: string; amount: number; unit: string } | null>(null); + const handleShowInvoiceQr = useCallback((tx: Transaction) => { + if (tx.invoice) { + setInvoiceQrTx({ invoice: tx.invoice, amount: tx.amount, unit: tx.unit }); + } + }, []); + // ── Unified dialog switchers ── /** The Send dialog detected a cashu token → hand off to Receive in claim mode. */ @@ -578,6 +650,8 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP onViewAll={() => setShowHistory(true)} onCheckTokenSpent={handleCheckTokenSpent} onReclaimToken={handleReclaimToken} + onShowInvoiceQr={handleShowInvoiceQr} + onDeleteTransaction={handleDeleteTransaction} /> )} @@ -657,6 +731,14 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP onClose={() => setShowSettings(false)} /> )} + {invoiceQrTx && ( + setInvoiceQrTx(null)} + /> + )} {trustMintState && ( = ({ const color = TX_COLORS[tx.type] ?? 'text-muted-foreground'; const isIncoming = ['mint', 'receive', 'p2p-receive'].includes(tx.type); const sign = isIncoming ? '+' : '-'; + const isPending = tx.type === 'mint' && tx.status === 'pending' && !!tx.invoice; + const isExpired = isPending && !!tx.expiresAt && new Date(tx.expiresAt).getTime() < Date.now(); return (
-
+
- {label} +
+ {label} + {isPending && !isExpired && ( + + + awaiting payment + + )} + {isPending && isExpired && ( + + + expired + + )} +
{tx.memo &&

{tx.memo}

}
{truncateMintUrl(tx.mintUrl)} · {formatDate(tx.createdAt)}
-
+
{sign}{formatAmount(tx.amount, tx.unit)}
diff --git a/src/components/wallet/transaction-list-card.tsx b/src/components/wallet/transaction-list-card.tsx index 14028dc..aee7f7f 100644 --- a/src/components/wallet/transaction-list-card.tsx +++ b/src/components/wallet/transaction-list-card.tsx @@ -12,6 +12,9 @@ import { ClockIcon, Loader2Icon, RotateCcwIcon, + QrCodeIcon, + Trash2Icon, + AlertCircleIcon, } from 'lucide-react'; import { formatAmount, formatDate, truncateMintUrl, toastSuccess, toastError } from '@/lib/utils'; import type { Transaction } from '@/hooks/use-wallet'; @@ -21,6 +24,10 @@ interface TransactionListCardProps { onViewAll?: () => void; onCheckTokenSpent?: (tx: Transaction) => Promise; onReclaimToken?: (tx: Transaction) => Promise; + /** Show the QR code for a pending invoice. */ + onShowInvoiceQr?: (tx: Transaction) => void; + /** Delete an expired pending invoice from history. */ + onDeleteTransaction?: (tx: Transaction) => Promise; } const TX_ICONS: Record> = { @@ -53,17 +60,32 @@ const TX_COLORS: Record = { 'p2p-receive': 'text-[var(--color-info)]', }; +/** Check whether a pending invoice is expired based on its expiresAt timestamp. */ +function isPendingInvoiceExpired(tx: Transaction): boolean { + return tx.status === 'pending' && !!tx.expiresAt && new Date(tx.expiresAt).getTime() < Date.now(); +} + +/** Check whether a transaction is a pending invoice (pending mint with invoice). */ +function isPendingInvoice(tx: Transaction): boolean { + return tx.type === 'mint' && tx.status === 'pending' && !!tx.invoice; +} + function TransactionRow({ tx, onCheckSpent, onReclaimToken, + onShowInvoiceQr, + onDeleteTransaction, }: { tx: Transaction; onCheckSpent?: (tx: Transaction) => Promise; onReclaimToken?: (tx: Transaction) => Promise; + onShowInvoiceQr?: (tx: Transaction) => void; + onDeleteTransaction?: (tx: Transaction) => Promise; }) { const [copied, setCopied] = useState(false); const [reclaiming, setReclaiming] = useState(false); + const [deleting, setDeleting] = useState(false); // Derive initial spent state from the persisted claim status on the transaction. // Background sweep in use-wallet.ts updates tx.claimStatus to 'claimed' automatically. const initialSpentState: 'unknown' | 'checking' | 'pending' | 'spent' | 'reclaimed' = @@ -82,6 +104,10 @@ function TransactionRow({ const sign = isIncoming ? '+' : '-'; const hasCopyableToken = (tx.type === 'send' || tx.type === 'p2p-send') && !!tx.cashuToken; + // Pending invoice state + const pendingInvoice = isPendingInvoice(tx); + const expired = isPendingInvoiceExpired(tx); + const handleCopy = async (e: React.MouseEvent) => { e.stopPropagation(); if (!tx.cashuToken) return; @@ -128,15 +154,46 @@ function TransactionRow({ } }; + const handleShowQr = (e: React.MouseEvent) => { + e.stopPropagation(); + onShowInvoiceQr?.(tx); + }; + + const handleDelete = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!onDeleteTransaction || deleting) return; + setDeleting(true); + try { + await onDeleteTransaction(tx); + } catch (err) { + console.warn('[nutsd] Transaction delete failed:', err); + toastError('Delete failed', err instanceof Error ? err : new Error('Failed to delete')); + setDeleting(false); + } + }; + return (
-
+
{label} + {/* Pending invoice badges */} + {pendingInvoice && !expired && ( + + + awaiting payment + + )} + {pendingInvoice && expired && ( + + + expired + + )} {/* Claim status badge — uses persisted claimStatus or manual check */} {(tx.type === 'send' || tx.type === 'p2p-send') && spentState === 'spent' && ( @@ -164,10 +221,38 @@ function TransactionRow({
+ {/* Action buttons for pending invoices */} + {pendingInvoice && ( +
+ {/* Show QR — only for active (non-expired) invoices */} + {!expired && onShowInvoiceQr && ( + + )} + {/* Delete — only for expired invoices */} + {expired && onDeleteTransaction && ( + + )} +
+ )} {/* Action buttons for sent tokens */} {hasCopyableToken && (
- {/* Reclaim unclaimed token */} {onReclaimToken && spentState === 'pending' && (
)} -
+
{sign}{formatAmount(tx.amount, tx.unit)}
@@ -220,6 +307,8 @@ export const TransactionListCard: React.FC = ({ onViewAll, onCheckTokenSpent, onReclaimToken, + onShowInvoiceQr, + onDeleteTransaction, }) => { const recent = transactions.slice(0, 10); @@ -243,7 +332,7 @@ export const TransactionListCard: React.FC = ({ No transactions yet.

- Tap Deposit to add funds via Lightning, or Receive to claim a Cashu token. + Tap Receive to get started.

) : ( @@ -254,6 +343,8 @@ export const TransactionListCard: React.FC = ({ tx={tx} onCheckSpent={onCheckTokenSpent} onReclaimToken={onReclaimToken} + onShowInvoiceQr={onShowInvoiceQr} + onDeleteTransaction={onDeleteTransaction} /> ))}
diff --git a/src/components/wallet/unified-receive-dialog.tsx b/src/components/wallet/unified-receive-dialog.tsx index 4b35910..4f242fd 100644 --- a/src/components/wallet/unified-receive-dialog.tsx +++ b/src/components/wallet/unified-receive-dialog.tsx @@ -39,6 +39,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ArrowLeftIcon, + CameraIcon, CheckCircleIcon, CheckIcon, CopyIcon, @@ -428,10 +429,16 @@ const ChannelsReceiveInner: React.FC<{ amount: amt, unit: mintUnit, expiry: quoteExpiry, source: 'lightning', }; + const expiresAt = quoteExpiry + ? new Date(quoteExpiry * 1000).toISOString() + : undefined; const pendingTxId = await onTransactionCreated({ type: 'mint', amount: amt, unit: mintUnit, mintUrl, status: 'pending', memo: serializePendingMintState(pendingState), + invoice: quote.request, + quoteId, + expiresAt, }); stopPollingRef.current = subscribeToQuote({ @@ -625,17 +632,31 @@ const ChannelsReceiveInner: React.FC<{ {/* Main channel UI */} {!isSuccessView && !isErrorView && (
- {/* QR code */} + {/* QR code / camera placeholder */}
-
- {qrValue ? ( + {qrValue ? ( +
- ) : ( -
- {channel === 'lightning' ? 'Enter an amount to generate' : ''} +
+ ) : channel === 'lightning' ? ( +
+
+

Tap to scan a QR code

+

or create an invoice below

+
+ + ) : ( +
+
+
+ )}
{/* Amount display under the QR */} @@ -1165,10 +1186,16 @@ const LnurlWithdrawPane: React.FC<{ amount: amt, unit: mintUnit, expiry: quoteExpiry, source: 'lnurl-withdraw', }; + const lnurlExpiresAt = quoteExpiry + ? new Date(quoteExpiry * 1000).toISOString() + : undefined; const pendingTxId = await onTransactionCreated({ type: 'mint', amount: amt, unit: mintUnit, mintUrl, status: 'pending', memo: serializePendingMintState(pendingState), + invoice: quote.request, + quoteId, + expiresAt: lnurlExpiresAt, }); // Step 3: Submit the invoice to the LNURL-withdraw service. diff --git a/src/hooks/use-wallet.ts b/src/hooks/use-wallet.ts index e09b8ad..3c5640d 100644 --- a/src/hooks/use-wallet.ts +++ b/src/hooks/use-wallet.ts @@ -113,6 +113,12 @@ export interface Transaction { senderDid?: string; memo?: string; createdAt: string; + /** BOLT-11 invoice for pending mint transactions. Cleared on completion. */ + invoice?: string; + /** Mint quote ID for status checks. Cleared on completion. */ + quoteId?: string; + /** ISO timestamp when the invoice expires. */ + expiresAt?: string; } export interface WalletPreferences { @@ -360,6 +366,9 @@ export function useWallet() { senderDid : data.senderDid, memo : data.memo, createdAt : data.createdAt, + invoice : data.invoice, + quoteId : data.quoteId, + expiresAt : data.expiresAt, } satisfies Transaction; })); // Sort newest first @@ -1624,6 +1633,9 @@ export function useWallet() { senderDid : data.senderDid, memo : data.memo, createdAt : data.createdAt, + invoice : data.invoice, + quoteId : data.quoteId, + expiresAt : data.expiresAt, }; setTransactions(prev => [tx, ...prev]); return tx; @@ -1650,6 +1662,9 @@ export function useWallet() { status: 'completed', amount: opts?.amount ?? data.amount, memo: opts?.memo ?? undefined, + // Clear pending invoice fields on completion + invoice: undefined, + quoteId: undefined, }, }); setTransactions(prev => @@ -1658,6 +1673,8 @@ export function useWallet() { status: 'completed' as const, amount: opts?.amount ?? t.amount, memo: opts?.memo ?? undefined, + invoice: undefined, + quoteId: undefined, } : t), ); } @@ -1666,6 +1683,24 @@ export function useWallet() { } }, [repo]); + /** + * Delete a transaction record from DWN and local state. + * Only allowed for expired pending invoices — callers should enforce policy. + */ + const deleteTransaction = useCallback(async (txId: string) => { + if (!repo) return; + try { + const { records } = await repo.transaction.query(); + const record = records.find((r: { id: string }) => r.id === txId); + if (record) { + await record.delete(); + setTransactions(prev => prev.filter(t => t.id !== txId)); + } + } catch (err) { + console.warn('[nutsd] Failed to delete transaction:', err); + } + }, [repo]); + /** * Mark a sent transaction as claimed by the recipient. * Clears the cashuToken (no longer needed) and sets claimStatus/claimedAt. @@ -1924,6 +1959,7 @@ export function useWallet() { // Transaction operations addTransaction, completeTransaction, + deleteTransaction, markTransactionClaimed, refreshTransactions, diff --git a/src/protocol/cashu-wallet-protocol.ts b/src/protocol/cashu-wallet-protocol.ts index c6d3398..8ba9b5f 100644 --- a/src/protocol/cashu-wallet-protocol.ts +++ b/src/protocol/cashu-wallet-protocol.ts @@ -157,6 +157,15 @@ export type TransactionData = { memo?: string; /** ISO timestamp. */ createdAt: string; + /** + * BOLT-11 Lightning invoice for pending `mint` transactions. + * Cleared once the invoice is paid and tokens are minted. + */ + invoice?: string; + /** Mint quote ID for checking payment status. Cleared on completion. */ + quoteId?: string; + /** ISO timestamp when the invoice/quote expires. */ + expiresAt?: string; }; /** From b2b83fc2d08597910f66252d072307f639abcf0c Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 9 Apr 2026 23:32:28 +0000 Subject: [PATCH 02/18] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20error=20propagation,=20touch=20UX,=20expiry=20timer,=20memo?= =?UTF-8?q?=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deleteTransaction now throws on failure; App.tsx catches and shows error toast - handleShowInvoiceQr guards against expired invoices before opening dialog - Pending-invoice action buttons always visible (not hover-only) for touch/mobile - TransactionListCard schedules a re-render at the nearest pending invoice expiry - Transaction history hides serialized recovery JSON memo for pending mints --- src/App.tsx | 15 ++++++++----- src/components/wallet/transaction-history.tsx | 2 +- .../wallet/transaction-list-card.tsx | 22 ++++++++++++++++--- src/hooks/use-wallet.ts | 17 +++++--------- 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3624088..278037c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -409,16 +409,21 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP const handleDeleteTransaction = useCallback(async (tx: Transaction) => { if (tx.status !== 'pending') return; if (!tx.expiresAt || new Date(tx.expiresAt).getTime() >= Date.now()) return; - await deleteTransaction(tx.id); - toastSuccess('Invoice removed'); + try { + await deleteTransaction(tx.id); + toastSuccess('Invoice removed'); + } catch (err) { + toastError('Failed to remove invoice', err instanceof Error ? err : new Error('Delete failed')); + } }, [deleteTransaction]); /** Show the QR code for a pending invoice. */ const [invoiceQrTx, setInvoiceQrTx] = useState<{ invoice: string; amount: number; unit: string } | null>(null); const handleShowInvoiceQr = useCallback((tx: Transaction) => { - if (tx.invoice) { - setInvoiceQrTx({ invoice: tx.invoice, amount: tx.amount, unit: tx.unit }); - } + // Guard: don't show QR for expired invoices + if (!tx.invoice) return; + if (tx.expiresAt && new Date(tx.expiresAt).getTime() < Date.now()) return; + setInvoiceQrTx({ invoice: tx.invoice, amount: tx.amount, unit: tx.unit }); }, []); // ── Unified dialog switchers ── diff --git a/src/components/wallet/transaction-history.tsx b/src/components/wallet/transaction-history.tsx index f5b9414..6a618d7 100644 --- a/src/components/wallet/transaction-history.tsx +++ b/src/components/wallet/transaction-history.tsx @@ -175,7 +175,7 @@ export const TransactionHistory: React.FC = ({ )}
- {tx.memo &&

{tx.memo}

} + {tx.memo && !isPending &&

{tx.memo}

}
{truncateMintUrl(tx.mintUrl)} · {formatDate(tx.createdAt)}
diff --git a/src/components/wallet/transaction-list-card.tsx b/src/components/wallet/transaction-list-card.tsx index aee7f7f..aeff577 100644 --- a/src/components/wallet/transaction-list-card.tsx +++ b/src/components/wallet/transaction-list-card.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { ArrowUpIcon, ArrowDownIcon, @@ -221,9 +221,9 @@ function TransactionRow({
- {/* Action buttons for pending invoices */} + {/* Action buttons for pending invoices — always visible (touch-friendly) */} {pendingInvoice && ( -
+
{/* Show QR — only for active (non-expired) invoices */} {!expired && onShowInvoiceQr && ( - Page {page + 1} of {totalPages} + Page {safePage + 1} of {totalPages}
+ {expanded && tx.memo && !unfulfilled &&

{tx.memo}

}
{truncateMintUrl(tx.mintUrl)} · {formatDate(tx.createdAt)}
@@ -258,7 +262,7 @@ export function TransactionRow({ )} {/* Action buttons for sent tokens */} {hasCopyableToken && ( -
+
{onReclaimToken && spentState === 'pending' && (
-

- Waiting for {formatAmount(amount, unit)} payment -

-
-
- + {expired ? ( +
+ +

+ This invoice has expired and can no longer be paid. +

+
-
- + ) : ( + <> +

+ Waiting for {formatAmount(amount, unit)} payment +

+
+
+ +
+
+ + + )}
); @@ -415,12 +446,11 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP }, [deleteTransaction]); /** Show the QR code for a pending invoice. */ - const [invoiceQrTx, setInvoiceQrTx] = useState<{ invoice: string; amount: number; unit: string } | null>(null); + const [invoiceQrTx, setInvoiceQrTx] = useState<{ invoice: string; amount: number; unit: string; expiresAt?: string } | null>(null); const handleShowInvoiceQr = useCallback((tx: Transaction) => { - // Guard: don't show QR for expired invoices if (!tx.invoice) return; - if (tx.expiresAt && new Date(tx.expiresAt).getTime() < Date.now()) return; - setInvoiceQrTx({ invoice: tx.invoice, amount: tx.amount, unit: tx.unit }); + // Allow opening even if expired — the dialog itself shows the expired state. + setInvoiceQrTx({ invoice: tx.invoice, amount: tx.amount, unit: tx.unit, expiresAt: tx.expiresAt }); }, []); // ── Unified dialog switchers ── @@ -742,6 +772,7 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP invoice={invoiceQrTx.invoice} amount={invoiceQrTx.amount} unit={invoiceQrTx.unit} + expiresAt={invoiceQrTx.expiresAt} onClose={() => setInvoiceQrTx(null)} /> )} diff --git a/src/components/wallet/transaction-history.tsx b/src/components/wallet/transaction-history.tsx index ce6b0f4..eb50f22 100644 --- a/src/components/wallet/transaction-history.tsx +++ b/src/components/wallet/transaction-history.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useMemo } from 'react'; import { XIcon, SearchIcon, FilterIcon } from 'lucide-react'; import { truncateMintUrl } from '@/lib/utils'; -import { TransactionRow, isUnfulfilledInvoice } from '@/components/wallet/transaction-list-card'; +import { TransactionRow } from '@/components/wallet/transaction-list-card'; +import { isUnfulfilledInvoice } from '@/lib/transaction-helpers'; import type { Transaction, Mint } from '@/hooks/use-wallet'; interface TransactionHistoryProps { diff --git a/src/components/wallet/transaction-list-card.tsx b/src/components/wallet/transaction-list-card.tsx index 48ba342..8796ce9 100644 --- a/src/components/wallet/transaction-list-card.tsx +++ b/src/components/wallet/transaction-list-card.tsx @@ -17,6 +17,7 @@ import { AlertCircleIcon, } from 'lucide-react'; import { formatAmount, formatDate, truncateMintUrl, toastSuccess, toastError } from '@/lib/utils'; +import { isUnfulfilledInvoice, isExpiredInvoice } from '@/lib/transaction-helpers'; import type { Transaction } from '@/hooks/use-wallet'; interface TransactionListCardProps { @@ -60,21 +61,6 @@ const TX_COLORS: Record = { 'p2p-receive': 'text-[var(--color-info)]', }; -/** - * Check whether a transaction represents a Lightning invoice that hasn't completed. - * Covers both pre-expiry (`status: 'pending'`) and post-restart expired - * invoices that startup recovery rewrites to `status: 'failed'`. - */ -export function isUnfulfilledInvoice(tx: Transaction): boolean { - return tx.type === 'mint' && (tx.status === 'pending' || tx.status === 'failed') && !!tx.invoice; -} - -/** An unfulfilled invoice whose expiry has passed (or was marked failed by recovery). */ -function isExpiredInvoice(tx: Transaction): boolean { - if (!isUnfulfilledInvoice(tx)) return false; - if (tx.status === 'failed') return true; // recovery already confirmed expiry - return !!tx.expiresAt && new Date(tx.expiresAt).getTime() < Date.now(); -} export function TransactionRow({ tx, diff --git a/src/components/wallet/unified-receive-dialog.tsx b/src/components/wallet/unified-receive-dialog.tsx index 4f242fd..9ff0cc2 100644 --- a/src/components/wallet/unified-receive-dialog.tsx +++ b/src/components/wallet/unified-receive-dialog.tsx @@ -39,7 +39,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ArrowLeftIcon, - CameraIcon, CheckCircleIcon, CheckIcon, CopyIcon, @@ -632,31 +631,17 @@ const ChannelsReceiveInner: React.FC<{ {/* Main channel UI */} {!isSuccessView && !isErrorView && (
- {/* QR code / camera placeholder */} + {/* QR code */}
- {qrValue ? ( -
+
+ {qrValue ? ( -
- ) : channel === 'lightning' ? ( - - ) : ( -
-
-
- )} + )} +
{/* Amount display under the QR */} diff --git a/src/lib/transaction-helpers.ts b/src/lib/transaction-helpers.ts new file mode 100644 index 0000000..b48803f --- /dev/null +++ b/src/lib/transaction-helpers.ts @@ -0,0 +1,17 @@ +import type { Transaction } from '@/hooks/use-wallet'; + +/** + * Check whether a transaction represents a Lightning invoice that hasn't completed. + * Covers both pre-expiry (`status: 'pending'`) and post-restart expired + * invoices that startup recovery rewrites to `status: 'failed'`. + */ +export function isUnfulfilledInvoice(tx: Transaction): boolean { + return tx.type === 'mint' && (tx.status === 'pending' || tx.status === 'failed') && !!tx.invoice; +} + +/** An unfulfilled invoice whose expiry has passed (or was marked failed by recovery). */ +export function isExpiredInvoice(tx: Transaction): boolean { + if (!isUnfulfilledInvoice(tx)) return false; + if (tx.status === 'failed') return true; // recovery already confirmed expiry + return !!tx.expiresAt && new Date(tx.expiresAt).getTime() < Date.now(); +} From c5da3fb97405621523279d1136c3e37cb34794c6 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 10 Apr 2026 00:52:20 +0000 Subject: [PATCH 06/18] fix: background invoice settlement, WAL-safe QR display, ISSUED tx completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add background pending-invoice sweep (15s interval) in use-wallet.ts that runs resumePendingMint for active pending invoices. Covers the case where the user closes the receive dialog and the payer pays while the app is open — proofs are minted, balance updated, and toast shown without restart. - Reorder Lightning invoice flow: DWN pending transaction is persisted BEFORE setLnInvoice/setLnStep, so the QR is never visible without a durable recovery record. A crash or tab close in the old window was a fund-loss risk for paid-but-untracked invoices. - onIssued callbacks in both Lightning and LNURL-withdraw flows now call onTransactionCompleted to mark the pending tx as completed. Previously they only showed a dialog error, leaving the tx as pending/unfulfilled in the activity list with a stale Show QR action. - Add _markTransactionFailed helper for the background sweep to transition expired invoices to failed status. --- .../wallet/unified-receive-dialog.tsx | 28 +++++- src/hooks/use-wallet.ts | 99 +++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/src/components/wallet/unified-receive-dialog.tsx b/src/components/wallet/unified-receive-dialog.tsx index 9ff0cc2..c24c960 100644 --- a/src/components/wallet/unified-receive-dialog.tsx +++ b/src/components/wallet/unified-receive-dialog.tsx @@ -411,8 +411,6 @@ const ChannelsReceiveInner: React.FC<{ try { const quote = await createMintQuote(selectedMint.url, n, selectedMint.unit); if (!mountedRef.current) return; - setLnInvoice(quote.request); - setLnStep('invoice'); // Capture values in closure; the state object may change by the time callbacks fire. const mintUrl = selectedMint.url; @@ -422,7 +420,9 @@ const ChannelsReceiveInner: React.FC<{ const quoteExpiry = quote.expiry ?? null; const amt = n; - // Persist pending state so a page refresh can resume. + // IMPORTANT: Persist the pending transaction BEFORE showing the QR. + // This ensures the recovery record exists in DWN before the invoice + // can be copied/shared. A crash or tab close after this point is safe. const pendingState: PendingMintState = { quoteId, mintUrl, mintContextId: mintCtx, amount: amt, unit: mintUnit, @@ -439,6 +439,11 @@ const ChannelsReceiveInner: React.FC<{ quoteId, expiresAt, }); + if (!mountedRef.current) return; + + // Now safe to show the QR — the recovery record is durable. + setLnInvoice(quote.request); + setLnStep('invoice'); stopPollingRef.current = subscribeToQuote({ mintUrl, @@ -490,7 +495,15 @@ const ChannelsReceiveInner: React.FC<{ setLnStep('error'); } }, - onIssued: () => { + onIssued: async () => { + // Mark the transaction completed — tokens were already minted + // in another session/tab. The background sweep or next startup + // will reconcile proofs if needed. + if (pendingTxId && onTransactionCompleted) { + await onTransactionCompleted(pendingTxId, { + memo: 'Lightning receive (already minted in another session)', + }); + } if (mountedRef.current) { setLnError('These tokens were already minted (possibly in another session).'); setLnStep('error'); @@ -1243,7 +1256,12 @@ const LnurlWithdrawPane: React.FC<{ setStep('error'); } }, - onIssued: () => { + onIssued: async () => { + if (pendingTxId && onTransactionCompleted) { + await onTransactionCompleted(pendingTxId, { + memo: 'LNURL withdraw (already minted in another session)', + }); + } if (mountedRef.current) { setErrorMsg('These tokens were already minted (possibly in another session).'); setStep('error'); diff --git a/src/hooks/use-wallet.ts b/src/hooks/use-wallet.ts index 47fcca7..ad17283 100644 --- a/src/hooks/use-wallet.ts +++ b/src/hooks/use-wallet.ts @@ -52,6 +52,7 @@ import { recoverStashes, type RecoveryDeps } from '@/cashu/proof-stash-recovery' import { resumePendingSwap, type PendingSwapState } from '@/cashu/cross-mint-swap'; import { resumePendingMint, parsePendingMintState } from '@/cashu/pending-mint-recovery'; import { acquireWalletLock } from '@/lib/wallet-mutex'; +import { formatAmount, toastSuccess } from '@/lib/utils'; // --------------------------------------------------------------------------- // Domain types — flattened from TypedRecord for the UI layer @@ -1696,6 +1697,28 @@ export function useWallet() { setTransactions(prev => prev.filter(t => t.id !== txId)); }, [repo]); + /** Mark a pending transaction as failed (e.g. expired invoice). Internal helper. */ + const _markTransactionFailed = useCallback(async (txId: string, memo: string) => { + if (!repo) return; + try { + const { records } = await repo.transaction.query(); + const record = records.find((r: { id: string }) => r.id === txId); + if (record) { + const data: TransactionData = await record.data.json(); + await record.update({ + data: { ...data, status: 'failed', memo, invoice: undefined, quoteId: undefined }, + }); + setTransactions(prev => + prev.map(t => t.id === txId + ? { ...t, status: 'failed' as const, memo, invoice: undefined, quoteId: undefined } + : t), + ); + } + } catch (err) { + console.warn('[nutsd] Failed to mark transaction failed:', err); + } + }, [repo]); + /** * Mark a sent transaction as claimed by the recipient. * Clears the cashuToken (no longer needed) and sets claimStatus/claimedAt. @@ -1762,6 +1785,82 @@ export function useWallet() { return () => { cancelled = true; clearInterval(timer); clearTimeout(initialTimer); }; }, [repo, transactions, markTransactionClaimed]); + // ========================================================================= + // Background pending-invoice sweep + // ========================================================================= + // Periodically check if pending Lightning invoices have been paid. This + // covers the case where a user generates an invoice, closes the receive + // dialog, and the payer pays while the app is still open. Without this, + // settlement only happens on the next app restart. + + useEffect(() => { + if (!repo || transactions.length === 0) return; + // Only sweep if there are active pending invoices + const pendingInvoices = transactions.filter( + tx => tx.type === 'mint' && tx.status === 'pending' && tx.quoteId && tx.memo, + ); + if (pendingInvoices.length === 0) return; + let cancelled = false; + + const sweep = async (): Promise => { + for (const tx of pendingInvoices) { + if (cancelled) break; + // Skip expired invoices — no need to poll + if (tx.expiresAt && new Date(tx.expiresAt).getTime() < Date.now()) continue; + + const state = parsePendingMintState(tx.memo!); + if (!state) continue; + + try { + const result = await resumePendingMint(state); + if (cancelled) break; + + switch (result.status) { + case 'minted': { + const mint = mints.find(m => m.contextId === state.mintContextId) + ?? mints.find(m => m.url === state.mintUrl); + if (!mint) { + console.warn(`[nutsd] Background sweep: unknown mint ${state.mintUrl}`); + break; + } + await safeStoreReceivedProofs( + mint.contextId, mint.url, mint.unit, + result.proofs.map(p => ({ + amount: p.amount, id: p.id, secret: p.secret, C: p.C, state: 'unspent' as const, + })), + ); + const total = result.proofs.reduce((s, p) => s + p.amount, 0); + await completeTransaction(tx.id, { amount: total, memo: `Lightning receive` }); + await refreshProofs(); + toastSuccess('Payment received!', `+${formatAmount(total, mint.unit)}`); + console.log(`[nutsd] Background sweep settled invoice: ${total} ${mint.unit}`); + break; + } + case 'issued': + await completeTransaction(tx.id, { memo: 'Lightning receive (already minted)' }); + console.warn(`[nutsd] Background sweep: invoice ISSUED (already minted): ${state.quoteId}`); + break; + case 'expired': + // Update status so UI shows it as failed + await _markTransactionFailed(tx.id, 'Quote expired before payment'); + break; + case 'pending': + break; // still waiting + case 'error': + break; // skip, try again next sweep + } + } catch { + // skip — mint may be offline + } + } + }; + + const timer = setInterval(sweep, 15_000); // 15 seconds — invoices are time-sensitive + const initialTimer = setTimeout(sweep, 5_000); + + return () => { cancelled = true; clearInterval(timer); clearTimeout(initialTimer); }; + }, [repo, transactions, mints, safeStoreReceivedProofs, completeTransaction, _markTransactionFailed, refreshProofs]); + // ========================================================================= // Incoming P2P transfers // ========================================================================= From 938290861566124fb02cede5df5e9eb25095b8a0 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 10 Apr 2026 00:58:55 +0000 Subject: [PATCH 07/18] fix: sweep wallet lock, partial-persistence guard, preserve expired invoice metadata - Background invoice sweep now acquires the wallet lock before settling. If the lock is held (e.g. by an open receive dialog), the invoice is skipped for this cycle. This prevents concurrent settlement where the sweep and dialog both call mintTokens/safeStoreReceivedProofs for the same quote, which would cause the loser to hit ISSUED and show a false failure after a successful payment. - Sweep now checks safeStoreReceivedProofs() return value: only completes the transaction if fullyPersisted is true. Partial writes (stash-only) leave the transaction pending for stash recovery on next startup, matching the safety guarantees of the startup recovery path. - _markTransactionFailed no longer clears invoice or expiresAt. Only quoteId is cleared (no longer needed for polling). Preserving invoice and expiresAt ensures isUnfulfilledInvoice/isExpiredInvoice still classify the transaction correctly for the expired badge and delete action in the activity list. --- src/hooks/use-wallet.ts | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/src/hooks/use-wallet.ts b/src/hooks/use-wallet.ts index ad17283..6b80894 100644 --- a/src/hooks/use-wallet.ts +++ b/src/hooks/use-wallet.ts @@ -1697,7 +1697,11 @@ export function useWallet() { setTransactions(prev => prev.filter(t => t.id !== txId)); }, [repo]); - /** Mark a pending transaction as failed (e.g. expired invoice). Internal helper. */ + /** + * Mark a pending transaction as failed (e.g. expired invoice). + * Preserves invoice and expiresAt so the expired-invoice UI can still + * show the expired badge and delete action. + */ const _markTransactionFailed = useCallback(async (txId: string, memo: string) => { if (!repo) return; try { @@ -1706,11 +1710,11 @@ export function useWallet() { if (record) { const data: TransactionData = await record.data.json(); await record.update({ - data: { ...data, status: 'failed', memo, invoice: undefined, quoteId: undefined }, + data: { ...data, status: 'failed', memo, quoteId: undefined }, }); setTransactions(prev => prev.map(t => t.id === txId - ? { ...t, status: 'failed' as const, memo, invoice: undefined, quoteId: undefined } + ? { ...t, status: 'failed' as const, memo, quoteId: undefined } : t), ); } @@ -1811,6 +1815,16 @@ export function useWallet() { const state = parsePendingMintState(tx.memo!); if (!state) continue; + // Non-blocking lock attempt: if the dialog (or another operation) + // holds the wallet lock, skip this invoice for now — the dialog + // is actively settling it. + let releaseLock: (() => void) | undefined; + try { + releaseLock = await acquireWalletLock('invoice-sweep'); + } catch { + continue; // wallet busy — skip this cycle + } + try { const result = await resumePendingMint(state); if (cancelled) break; @@ -1823,17 +1837,23 @@ export function useWallet() { console.warn(`[nutsd] Background sweep: unknown mint ${state.mintUrl}`); break; } - await safeStoreReceivedProofs( + const fullyPersisted = await safeStoreReceivedProofs( mint.contextId, mint.url, mint.unit, result.proofs.map(p => ({ amount: p.amount, id: p.id, secret: p.secret, C: p.C, state: 'unspent' as const, })), ); - const total = result.proofs.reduce((s, p) => s + p.amount, 0); - await completeTransaction(tx.id, { amount: total, memo: `Lightning receive` }); - await refreshProofs(); - toastSuccess('Payment received!', `+${formatAmount(total, mint.unit)}`); - console.log(`[nutsd] Background sweep settled invoice: ${total} ${mint.unit}`); + if (fullyPersisted) { + const total = result.proofs.reduce((s, p) => s + p.amount, 0); + await completeTransaction(tx.id, { amount: total, memo: `Lightning receive` }); + await refreshProofs(); + toastSuccess('Payment received!', `+${formatAmount(total, mint.unit)}`); + console.log(`[nutsd] Background sweep settled invoice: ${total} ${mint.unit}`); + } else { + // Proofs stashed but not fully written — leave pending for + // stash recovery on next startup. Do NOT complete the tx. + console.warn(`[nutsd] Background sweep: proof persistence partial for ${state.quoteId}, deferring`); + } break; } case 'issued': @@ -1851,6 +1871,8 @@ export function useWallet() { } } catch { // skip — mint may be offline + } finally { + releaseLock?.(); } } }; From 4111465aa433f117ca232f5aeed42a399563af25 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 10 Apr 2026 03:11:53 +0000 Subject: [PATCH 08/18] fix: eliminate dialog/sweep settlement race, narrow lock scope, fix proof-loss on unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Active-quote coordination: - New src/lib/active-quotes.ts: module-level Set registry for quoteIds being actively monitored by open dialogs - Dialog registers quoteId before subscribing, unregisters on teardown (wrapped in stopPollingRef cleanup function) - Catch blocks in both Lightning and LNURL-withdraw clean up the registration if an error occurs before stopPollingRef is assigned, preventing permanent active-quote leaks Sweep restructured into two phases: - Check phase (no lock): skips active quotes, then calls checkMintQuote directly. Network round-trips no longer hold the wallet lock or trigger the beforeunload guard - Settlement phase (PAID only): uses tryAcquireWalletLock (new non-blocking variant) — returns null if lock is held, so sweep never blocks behind a dialog. Lock scope narrowed to just mintTokens/safeStoreReceivedProofs/ completeTransaction. Re-checks isQuoteActive after acquiring lock. Handles mintTokens throwing ISSUED gracefully (dialog won the race) Critical proof-loss fix (pre-existing): - onPaid callbacks in both Lightning and LNURL-withdraw flows no longer bail on !mountedRef.current after mintTokens succeeds. Once proofs are minted at the Cashu mint, they MUST be persisted via onProofsReceived unconditionally — even if the dialog was dismissed during the await. Only UI state updates (setLnStep, toastSuccess) are guarded by mountedRef. Previously, dismissing the dialog during minting would silently drop proofs (fund loss). --- .../wallet/unified-receive-dialog.tsx | 47 +++++-- src/hooks/use-wallet.ts | 124 +++++++++++------- src/lib/active-quotes.ts | 27 ++++ src/lib/wallet-mutex.ts | 15 +++ 4 files changed, 154 insertions(+), 59 deletions(-) create mode 100644 src/lib/active-quotes.ts diff --git a/src/components/wallet/unified-receive-dialog.tsx b/src/components/wallet/unified-receive-dialog.tsx index c24c960..317f021 100644 --- a/src/components/wallet/unified-receive-dialog.tsx +++ b/src/components/wallet/unified-receive-dialog.tsx @@ -57,6 +57,7 @@ import type { TransactionData } from '@/protocol/cashu-wallet-protocol'; import { DialogWrapper } from '@/components/ui/dialog-wrapper'; import { QRCodeDisplay } from '@/components/qr-code'; +import { registerActiveQuote, unregisterActiveQuote } from '@/lib/active-quotes'; import { AmountInput } from '@/components/wallet/amount-input'; import { ChannelSegmented, type SegmentOption } from '@/components/wallet/channel-segmented'; @@ -408,6 +409,7 @@ const ChannelsReceiveInner: React.FC<{ setLnLoading(true); setLnError(''); + let registeredQuoteId: string | undefined; try { const quote = await createMintQuote(selectedMint.url, n, selectedMint.unit); if (!mountedRef.current) return; @@ -445,24 +447,29 @@ const ChannelsReceiveInner: React.FC<{ setLnInvoice(quote.request); setLnStep('invoice'); - stopPollingRef.current = subscribeToQuote({ + // Register this quote so the background sweep skips it while we're monitoring. + registerActiveQuote(quoteId); + registeredQuoteId = quoteId; + const stopSubscription = subscribeToQuote({ mintUrl, quoteId, quoteType : 'bolt11_mint_quote', callbacks : { onPaid: async () => { - if (!mountedRef.current) return; - setLnStep('waiting'); + if (mountedRef.current) setLnStep('waiting'); let releaseLock: (() => void) | undefined; try { releaseLock = await acquireWalletLock('mint'); const proofs = await mintTokens(mintUrl, amt, quoteId, mintUnit); - if (!mountedRef.current) return; if (!(await isDleqValid(mintUrl, proofs))) { console.warn('[nutsd:financial] DLEQ verification failed on minted proofs'); } + // CRITICAL: persist proofs unconditionally — even if the + // dialog unmounted while we awaited mintTokens/lock. Bailing + // here would drop minted proofs (fund loss). The UI updates + // below are guarded by mountedRef, but persistence is not. await onProofsReceived(mintCtx, proofs, mintUrl); // Complete the pending transaction (or create a new one if pending write failed). @@ -517,7 +524,16 @@ const ChannelsReceiveInner: React.FC<{ })), expiry: quoteExpiry, }); + stopPollingRef.current = () => { + unregisterActiveQuote(quoteId); + stopSubscription(); + }; } catch (err) { + // Clean up active-quote registration if we registered but didn't + // reach the stopPollingRef assignment (which owns the cleanup). + if (registeredQuoteId && !stopPollingRef.current) { + unregisterActiveQuote(registeredQuoteId); + } toastError('Failed to create invoice', err); setLnError(err instanceof Error ? err.message : String(err)); setLnStep('error'); @@ -1166,6 +1182,7 @@ const LnurlWithdrawPane: React.FC<{ const mintUrl = selectedMint.url; const mintUnit = 'sat'; // LNURL-withdraw is always sats const mintCtx = selectedMint.contextId; + let registeredQuoteId: string | undefined; try { // Step 1: Create a mint quote (Lightning invoice) at our mint. @@ -1206,23 +1223,26 @@ const LnurlWithdrawPane: React.FC<{ // Step 4: Wait for payment. The wallet lock is NOT held here — // only acquired briefly inside onPaid for the mint/store critical // section (same pattern as Lightning receive). - stopPollingRef.current = subscribeToQuote({ + registerActiveQuote(quoteId); + registeredQuoteId = quoteId; + const stopLnurlSubscription = subscribeToQuote({ mintUrl, quoteId, quoteType: 'bolt11_mint_quote', callbacks: { onPaid: async () => { - if (!mountedRef.current) return; + // Note: step is already 'waiting'. Do NOT bail on !mountedRef — + // proofs must be persisted even if the dialog was dismissed. let releaseLock: (() => void) | undefined; try { releaseLock = await acquireWalletLock('lnurl-withdraw-mint'); const proofs = await mintTokens(mintUrl, amt, quoteId, mintUnit); - if (!mountedRef.current) return; if (!(await isDleqValid(mintUrl, proofs))) { console.warn('[nutsd:financial] DLEQ verification failed on LNURL-withdraw proofs'); } + // CRITICAL: persist proofs unconditionally (see Lightning onPaid comment). await onProofsReceived(mintCtx, proofs, mintUrl); // Complete the pending transaction. @@ -1275,9 +1295,18 @@ const LnurlWithdrawPane: React.FC<{ })), expiry: quoteExpiry, }); + stopPollingRef.current = () => { + unregisterActiveQuote(quoteId); + stopLnurlSubscription(); + }; } catch (err) { - setErrorMsg(err instanceof Error ? err.message : String(err)); - setStep('error'); + if (registeredQuoteId && !stopPollingRef.current) { + unregisterActiveQuote(registeredQuoteId); + } + if (mountedRef.current) { + setErrorMsg(err instanceof Error ? err.message : String(err)); + setStep('error'); + } } }; diff --git a/src/hooks/use-wallet.ts b/src/hooks/use-wallet.ts index 6b80894..e7018b2 100644 --- a/src/hooks/use-wallet.ts +++ b/src/hooks/use-wallet.ts @@ -41,6 +41,8 @@ import type { TransferData } from '@/protocol/cashu-transfer-protocol'; import type { Proof } from '@cashu/cashu-ts'; import { groupProofsByState, + checkMintQuote, + mintTokens, getKeysetInfos, getMintInfo, clearWalletCache, @@ -51,7 +53,8 @@ import { generateP2pkKeyPair, receiveP2pkLocked, type P2pkKeyPair } from '@/cash import { recoverStashes, type RecoveryDeps } from '@/cashu/proof-stash-recovery'; import { resumePendingSwap, type PendingSwapState } from '@/cashu/cross-mint-swap'; import { resumePendingMint, parsePendingMintState } from '@/cashu/pending-mint-recovery'; -import { acquireWalletLock } from '@/lib/wallet-mutex'; +import { acquireWalletLock, tryAcquireWalletLock } from '@/lib/wallet-mutex'; +import { isQuoteActive } from '@/lib/active-quotes'; import { formatAmount, toastSuccess } from '@/lib/utils'; // --------------------------------------------------------------------------- @@ -1815,64 +1818,85 @@ export function useWallet() { const state = parsePendingMintState(tx.memo!); if (!state) continue; - // Non-blocking lock attempt: if the dialog (or another operation) - // holds the wallet lock, skip this invoice for now — the dialog - // is actively settling it. - let releaseLock: (() => void) | undefined; + // Skip if a dialog is actively monitoring this quote. + if (isQuoteActive(state.quoteId)) continue; + + // ── Check phase (no lock, no mutation) ── + // Only a network round-trip to see the quote status. Does not + // block other wallet operations or trigger the unload guard. + let quoteState: string; try { - releaseLock = await acquireWalletLock('invoice-sweep'); + const quote = await checkMintQuote(state.mintUrl, state.quoteId, state.unit); + quoteState = quote.state as string; } catch { - continue; // wallet busy — skip this cycle + continue; // mint may be offline — try next cycle + } + if (cancelled) break; + + // Re-check: a dialog may have opened since the network call. + if (isQuoteActive(state.quoteId)) continue; + + // ── Handle non-PAID states (no lock needed) ── + if (quoteState === 'ISSUED') { + await completeTransaction(tx.id, { memo: 'Lightning receive (already minted)' }); + console.warn(`[nutsd] Background sweep: invoice ISSUED (already minted): ${state.quoteId}`); + continue; } + if (quoteState !== 'PAID') { + // UNPAID: check expiry + const expiry = state.expiry; + if (expiry && expiry < Math.floor(Date.now() / 1000)) { + await _markTransactionFailed(tx.id, 'Quote expired before payment'); + } + continue; + } + + // ── Settlement phase (PAID — needs wallet lock) ── + // Use tryAcquire so we never block behind a dialog or other operation. + const releaseLock = tryAcquireWalletLock('invoice-sweep'); + if (!releaseLock) continue; // lock held — dialog or other op will handle it try { - const result = await resumePendingMint(state); + // Final guard: re-check the active set after acquiring the lock. + // A dialog could have started between our check and lock acquisition. + if (isQuoteActive(state.quoteId)) continue; + + const mint = mints.find(m => m.contextId === state.mintContextId) + ?? mints.find(m => m.url === state.mintUrl); + if (!mint) { + console.warn(`[nutsd] Background sweep: unknown mint ${state.mintUrl}`); + continue; + } + + const proofs = await mintTokens(state.mintUrl, state.amount, state.quoteId, state.unit); if (cancelled) break; - switch (result.status) { - case 'minted': { - const mint = mints.find(m => m.contextId === state.mintContextId) - ?? mints.find(m => m.url === state.mintUrl); - if (!mint) { - console.warn(`[nutsd] Background sweep: unknown mint ${state.mintUrl}`); - break; - } - const fullyPersisted = await safeStoreReceivedProofs( - mint.contextId, mint.url, mint.unit, - result.proofs.map(p => ({ - amount: p.amount, id: p.id, secret: p.secret, C: p.C, state: 'unspent' as const, - })), - ); - if (fullyPersisted) { - const total = result.proofs.reduce((s, p) => s + p.amount, 0); - await completeTransaction(tx.id, { amount: total, memo: `Lightning receive` }); - await refreshProofs(); - toastSuccess('Payment received!', `+${formatAmount(total, mint.unit)}`); - console.log(`[nutsd] Background sweep settled invoice: ${total} ${mint.unit}`); - } else { - // Proofs stashed but not fully written — leave pending for - // stash recovery on next startup. Do NOT complete the tx. - console.warn(`[nutsd] Background sweep: proof persistence partial for ${state.quoteId}, deferring`); - } - break; - } - case 'issued': - await completeTransaction(tx.id, { memo: 'Lightning receive (already minted)' }); - console.warn(`[nutsd] Background sweep: invoice ISSUED (already minted): ${state.quoteId}`); - break; - case 'expired': - // Update status so UI shows it as failed - await _markTransactionFailed(tx.id, 'Quote expired before payment'); - break; - case 'pending': - break; // still waiting - case 'error': - break; // skip, try again next sweep + const fullyPersisted = await safeStoreReceivedProofs( + mint.contextId, mint.url, mint.unit, + proofs.map(p => ({ + amount: p.amount, id: p.id, secret: p.secret, C: p.C, state: 'unspent' as const, + })), + ); + if (fullyPersisted) { + const total = proofs.reduce((s, p) => s + p.amount, 0); + await completeTransaction(tx.id, { amount: total, memo: `Lightning receive` }); + await refreshProofs(); + toastSuccess('Payment received!', `+${formatAmount(total, mint.unit)}`); + console.log(`[nutsd] Background sweep settled invoice: ${total} ${mint.unit}`); + } else { + // Proofs stashed but not fully written — leave pending for + // stash recovery on next startup. Do NOT complete the tx. + console.warn(`[nutsd] Background sweep: proof persistence partial for ${state.quoteId}, deferring`); } - } catch { - // skip — mint may be offline + } catch (err) { + // mintTokens can return ISSUED if a race slipped through — handle gracefully + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('already issued') || msg.includes('ISSUED')) { + await completeTransaction(tx.id, { memo: 'Lightning receive (already minted)' }); + } + // Other errors: skip, try next cycle } finally { - releaseLock?.(); + releaseLock(); } } }; diff --git a/src/lib/active-quotes.ts b/src/lib/active-quotes.ts new file mode 100644 index 0000000..c40ccf3 --- /dev/null +++ b/src/lib/active-quotes.ts @@ -0,0 +1,27 @@ +/** + * Registry of quoteIds that are actively being monitored by an open dialog. + * + * The background invoice sweep in use-wallet.ts checks this set before + * attempting to settle an invoice. If a dialog is already subscribed to + * a quote (via WS or polling), the sweep skips it to avoid concurrent + * settlement races. + * + * Dialogs call `registerActiveQuote` when they start subscribing and + * `unregisterActiveQuote` when they tear down (unmount or stop polling). + * + * @module + */ + +const activeQuotes = new Set(); + +export function registerActiveQuote(quoteId: string): void { + activeQuotes.add(quoteId); +} + +export function unregisterActiveQuote(quoteId: string): void { + activeQuotes.delete(quoteId); +} + +export function isQuoteActive(quoteId: string): boolean { + return activeQuotes.has(quoteId); +} diff --git a/src/lib/wallet-mutex.ts b/src/lib/wallet-mutex.ts index c12423f..8f17419 100644 --- a/src/lib/wallet-mutex.ts +++ b/src/lib/wallet-mutex.ts @@ -78,6 +78,21 @@ export async function acquireWalletLock( }); } +/** + * Try to acquire the wallet lock without waiting. + * + * Returns a release function if the lock was free, or `null` if it's + * currently held. Unlike `acquireWalletLock`, this never queues — + * callers should skip or retry later. + */ +export function tryAcquireWalletLock(operation: string): (() => void) | null { + if (locked) return null; + locked = true; + lockHolder = operation; + installBeforeUnload(); + return createRelease(operation); +} + /** * Check if the wallet lock is currently held. */ From a14c07790a96fc8b5f48614c4ed152b71fc0ae9a Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 10 Apr 2026 03:21:19 +0000 Subject: [PATCH 09/18] fix: sweep proof-loss on cancellation, DLEQ verification, retry quote leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed the post-mintTokens cancelled bail-out in the background sweep. Once mintTokens succeeds, safeStoreReceivedProofs and completeTransaction run unconditionally — same 'persist unconditionally' rule as the dialog. The pre-mintTokens cancelled check (after checkMintQuote) is retained since no mutation has occurred at that point. - Added isDleqValid verification to the sweep settlement path, matching the dialog (unified-receive-dialog.tsx:465) and startup recovery (pending-mint-recovery.ts:100) verification guarantees. - Lightning 'Try again' button now calls stopPollingRef.current?.() before resetting UI state. This unregisters the old quoteId from the active-quote set and stops the subscription, preventing a permanent leak where the sweep would skip the stale quoteId indefinitely. --- src/components/wallet/unified-receive-dialog.tsx | 2 +- src/hooks/use-wallet.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/wallet/unified-receive-dialog.tsx b/src/components/wallet/unified-receive-dialog.tsx index 317f021..aca73a8 100644 --- a/src/components/wallet/unified-receive-dialog.tsx +++ b/src/components/wallet/unified-receive-dialog.tsx @@ -642,7 +642,7 @@ const ChannelsReceiveInner: React.FC<{