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..b048d37 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useEffect, useMemo } from 'react'; import { ThemeProvider, useTheme } from '@/components/theme-provider'; import { ErrorBoundary } from '@/components/error-boundary'; @@ -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,94 @@ 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, expiresAt, onClose }: { + invoice: string; + amount: number; + unit: string; + expiresAt?: string; + onClose: () => void; +}) { + const [copied, setCopied] = useState(false); + const [expired, setExpired] = useState( + () => !!expiresAt && new Date(expiresAt).getTime() < Date.now(), + ); + + // Auto-close when the invoice expires while the dialog is open. + useEffect(() => { + if (!expiresAt || expired) return; + const ms = new Date(expiresAt).getTime() - Date.now(); + if (ms <= 0) { setExpired(true); return; } + const timer = setTimeout(() => setExpired(true), ms); + return () => clearTimeout(timer); + }, [expiresAt, expired]); + + 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 ( + + + + {expired ? 'Invoice Expired' : 'Pending Invoice'} + + + + + {expired ? ( + + + + This invoice has expired and can no longer be paid. + + + Close + + + ) : ( + <> + + Waiting for {formatAmount(amount, unit)} payment + + + + + + + + {copied ? : } + {copied ? 'Copied' : 'Copy Invoice'} + + > + )} + + + ); +} + // --------------------------------------------------------------------------- // Wallet app (connected) // --------------------------------------------------------------------------- @@ -80,6 +166,7 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP deleteProofs, addTransaction, completeTransaction, + deleteTransaction, markTransactionClaimed, getUnspentProofsForMint, getUnspentProofsByContext, @@ -136,7 +223,7 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP * stash write and cleanup, `recoverProofStashes()` on next startup fills * in any missing proofs from the stash. */ - const storeNewProofs = useCallback(async (mintContextId: string, cashuProofs: Proof[]) => { + const storeNewProofs = useCallback(async (mintContextId: string, cashuProofs: Proof[]): Promise => { const mint = mints.find(m => m.contextId === mintContextId); const proofDataList: ProofData[] = cashuProofs.map(proof => { const data: ProofData = { @@ -160,7 +247,7 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP } return data; }); - await safeStoreReceivedProofs( + return safeStoreReceivedProofs( mintContextId, mint?.url ?? '', mint?.unit ?? 'sat', @@ -173,7 +260,7 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP mintContextId: string, cashuProofs: Proof[], mintUrl: string, - ) => { + ): Promise => { let ctx = mintContextId; if (!ctx) { const knownMint = mints.find(m => m.url === mintUrl); @@ -185,7 +272,7 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP } } if (!ctx) throw new Error(`Could not resolve mint context for ${mintUrl}`); - await storeNewProofs(ctx, cashuProofs); + return storeNewProofs(ctx, cashuProofs); }, [mints, addMint, storeNewProofs]); /** @@ -349,6 +436,23 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP return isSpent; }, [markTransactionClaimed]); + /** Delete a transaction (only allowed for expired pending/failed invoices). */ + const handleDeleteTransaction = useCallback(async (tx: Transaction) => { + const isExpiredPending = tx.status === 'pending' && tx.expiresAt && new Date(tx.expiresAt).getTime() < Date.now(); + const isFailedInvoice = tx.status === 'failed' && tx.type === 'mint' && !!tx.invoice; + if (!isExpiredPending && !isFailedInvoice) 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; expiresAt?: string } | null>(null); + const handleShowInvoiceQr = useCallback((tx: Transaction) => { + if (!tx.invoice) return; + // 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 ── /** The Send dialog detected a cashu token → hand off to Receive in claim mode. */ @@ -578,6 +682,8 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP onViewAll={() => setShowHistory(true)} onCheckTokenSpent={handleCheckTokenSpent} onReclaimToken={handleReclaimToken} + onShowInvoiceQr={handleShowInvoiceQr} + onDeleteTransaction={handleDeleteTransaction} /> > )} @@ -641,6 +747,10 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP transactions={transactions} mints={mints} onClose={() => setShowHistory(false)} + onCheckTokenSpent={handleCheckTokenSpent} + onReclaimToken={handleReclaimToken} + onShowInvoiceQr={handleShowInvoiceQr} + onDeleteTransaction={handleDeleteTransaction} /> )} {showSettings && ( @@ -657,6 +767,15 @@ function WalletHome({ isPinEnabled, onSetPin, onRemovePin, onLock }: WalletHomeP onClose={() => setShowSettings(false)} /> )} + {invoiceQrTx && ( + setInvoiceQrTx(null)} + /> + )} {trustMintState && ( { + // Registry is module-level state — clean up after each test + const tracked: string[] = []; + beforeEach(() => { + for (const id of tracked) unregisterActiveQuote(id); + tracked.length = 0; + }); + + function register(id: string) { + registerActiveQuote(id); + tracked.push(id); + } + + it('registers and queries a quote', () => { + expect(isQuoteActive('q1')).toBe(false); + register('q1'); + expect(isQuoteActive('q1')).toBe(true); + }); + + it('unregisters a quote', () => { + register('q1'); + unregisterActiveQuote('q1'); + tracked.pop(); + expect(isQuoteActive('q1')).toBe(false); + }); + + it('handles multiple quotes independently', () => { + register('q1'); + register('q2'); + expect(isQuoteActive('q1')).toBe(true); + expect(isQuoteActive('q2')).toBe(true); + + unregisterActiveQuote('q1'); + tracked.shift(); + expect(isQuoteActive('q1')).toBe(false); + expect(isQuoteActive('q2')).toBe(true); + }); + + it('unregister is idempotent', () => { + register('q1'); + unregisterActiveQuote('q1'); + unregisterActiveQuote('q1'); // no-op, no throw + tracked.pop(); + expect(isQuoteActive('q1')).toBe(false); + }); + + it('register is idempotent (Set semantics)', () => { + register('q1'); + registerActiveQuote('q1'); // duplicate + unregisterActiveQuote('q1'); + tracked.pop(); + expect(isQuoteActive('q1')).toBe(false); + }); +}); diff --git a/src/__tests__/sweep-settlement.test.ts b/src/__tests__/sweep-settlement.test.ts new file mode 100644 index 0000000..a278787 --- /dev/null +++ b/src/__tests__/sweep-settlement.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + handleIssuedQuote, + handlePaidSettlement, + decideSweepAction, + type SweepDeps, + type PaidSettlementDeps, + type SweepQuoteAction, +} from '../lib/transaction-helpers'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeSweepDeps(overrides?: Partial): SweepDeps { + return { + recoverProofStashes: vi.fn().mockResolvedValue({ proofsRecovered: 0, proofsFailed: 0 }), + refreshProofs: vi.fn().mockResolvedValue(undefined), + completeTransaction: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function makePaidDeps(overrides?: Partial): PaidSettlementDeps { + return { + ...makeSweepDeps(overrides), + mintTokens: vi.fn().mockResolvedValue([ + { amount: 500, id: 'ks1', secret: 's1', C: 'C1' }, + { amount: 500, id: 'ks1', secret: 's2', C: 'C2' }, + ]), + isDleqValid: vi.fn().mockResolvedValue(true), + safeStoreReceivedProofs: vi.fn().mockResolvedValue(true), + ...overrides, + }; +} + +const issuedAction: SweepQuoteAction & { type: 'complete' } = { + type: 'complete', + memo: 'Lightning receive (already minted)', + needsStashRecovery: true, +}; + +const state = { + mintUrl: 'https://mint.example', + amount: 1000, + quoteId: 'q-123', + unit: 'sat', + source: 'lightning' as const, +}; + +const mint = { contextId: 'ctx-1', url: 'https://mint.example', unit: 'sat' }; + +// --------------------------------------------------------------------------- +// handleIssuedQuote +// --------------------------------------------------------------------------- + +describe('handleIssuedQuote', () => { + it('completes when stash recovery succeeds with no failures', async () => { + const deps = makeSweepDeps({ + recoverProofStashes: vi.fn().mockResolvedValue({ proofsRecovered: 2, proofsFailed: 0 }), + }); + + const outcome = await handleIssuedQuote('tx-1', issuedAction, deps); + + expect(outcome).toEqual({ result: 'completed', memo: issuedAction.memo }); + expect(deps.recoverProofStashes).toHaveBeenCalled(); + expect(deps.refreshProofs).toHaveBeenCalled(); + expect(deps.completeTransaction).toHaveBeenCalledWith('tx-1', { memo: issuedAction.memo }); + }); + + it('defers when stash recovery has failed proof writes', async () => { + const deps = makeSweepDeps({ + recoverProofStashes: vi.fn().mockResolvedValue({ proofsRecovered: 1, proofsFailed: 2 }), + }); + + const outcome = await handleIssuedQuote('tx-1', issuedAction, deps); + + expect(outcome.result).toBe('deferred'); + expect((outcome as { result: 'deferred'; reason: string }).reason).toContain('incomplete'); + expect(deps.completeTransaction).not.toHaveBeenCalled(); + }); + + it('completes when stash recovery returns null (no repo)', async () => { + const deps = makeSweepDeps({ + recoverProofStashes: vi.fn().mockResolvedValue(null), + }); + + const outcome = await handleIssuedQuote('tx-1', issuedAction, deps); + + // null means repo not ready — but no failures either, so complete + expect(outcome).toEqual({ result: 'completed', memo: issuedAction.memo }); + expect(deps.completeTransaction).toHaveBeenCalled(); + }); + + it('completes when no stash recovery needed', async () => { + const noRecoveryAction: SweepQuoteAction & { type: 'complete' } = { + type: 'complete', + memo: 'Some memo', + needsStashRecovery: false, + }; + const deps = makeSweepDeps(); + + const outcome = await handleIssuedQuote('tx-1', noRecoveryAction, deps); + + expect(outcome).toEqual({ result: 'completed', memo: 'Some memo' }); + expect(deps.recoverProofStashes).not.toHaveBeenCalled(); + expect(deps.completeTransaction).toHaveBeenCalled(); + }); + + it('skips refreshProofs when no proofs were recovered', async () => { + const deps = makeSweepDeps({ + recoverProofStashes: vi.fn().mockResolvedValue({ proofsRecovered: 0, proofsFailed: 0 }), + }); + + await handleIssuedQuote('tx-1', issuedAction, deps); + + expect(deps.refreshProofs).not.toHaveBeenCalled(); + expect(deps.completeTransaction).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// handlePaidSettlement +// --------------------------------------------------------------------------- + +describe('handlePaidSettlement', () => { + it('completes when proofs fully persisted', async () => { + const deps = makePaidDeps(); + + const outcome = await handlePaidSettlement('tx-1', state, mint, deps); + + expect(outcome).toEqual({ result: 'completed', total: 1000, memo: 'Lightning receive' }); + expect(deps.mintTokens).toHaveBeenCalledWith(state.mintUrl, state.amount, state.quoteId, state.unit); + expect(deps.isDleqValid).toHaveBeenCalled(); + expect(deps.safeStoreReceivedProofs).toHaveBeenCalled(); + expect(deps.completeTransaction).toHaveBeenCalledWith('tx-1', { amount: 1000, memo: 'Lightning receive' }); + expect(deps.refreshProofs).toHaveBeenCalled(); + }); + + it('defers when proof persistence is partial', async () => { + const deps = makePaidDeps({ + safeStoreReceivedProofs: vi.fn().mockResolvedValue(false), + }); + + const outcome = await handlePaidSettlement('tx-1', state, mint, deps); + + expect(outcome.result).toBe('deferred'); + expect((outcome as { result: 'deferred'; reason: string }).reason).toContain('partial'); + expect(deps.completeTransaction).not.toHaveBeenCalled(); + expect(deps.refreshProofs).not.toHaveBeenCalled(); + }); + + it('still persists proofs even when DLEQ verification fails', async () => { + const deps = makePaidDeps({ + isDleqValid: vi.fn().mockResolvedValue(false), + }); + + const outcome = await handlePaidSettlement('tx-1', state, mint, deps); + + expect(outcome.result).toBe('completed'); + expect(deps.safeStoreReceivedProofs).toHaveBeenCalled(); + expect(deps.completeTransaction).toHaveBeenCalled(); + }); + + it('includes LNURL description in memo', async () => { + const lnurlState = { ...state, source: 'lnurl-withdraw' as const, description: 'My Service' }; + const deps = makePaidDeps(); + + const outcome = await handlePaidSettlement('tx-1', lnurlState, mint, deps); + + expect(outcome).toEqual({ result: 'completed', total: 1000, memo: 'LNURL withdraw: My Service' }); + }); + + it('uses generic LNURL memo when no description', async () => { + const lnurlState = { ...state, source: 'lnurl-withdraw' as const }; + const deps = makePaidDeps(); + + const outcome = await handlePaidSettlement('tx-1', lnurlState, mint, deps); + + expect(outcome).toEqual({ result: 'completed', total: 1000, memo: 'LNURL withdraw' }); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: decideSweepAction → handleIssuedQuote pipeline +// --------------------------------------------------------------------------- + +describe('decideSweepAction → handleIssuedQuote pipeline', () => { + it('ISSUED with successful stash recovery completes the transaction', async () => { + const action = decideSweepAction('ISSUED', 'lightning', null); + expect(action.type).toBe('complete'); + + const deps = makeSweepDeps({ + recoverProofStashes: vi.fn().mockResolvedValue({ proofsRecovered: 3, proofsFailed: 0 }), + }); + + const outcome = await handleIssuedQuote('tx-1', action as SweepQuoteAction & { type: 'complete' }, deps); + + expect(outcome.result).toBe('completed'); + expect(deps.completeTransaction).toHaveBeenCalled(); + }); + + it('ISSUED with partial stash recovery defers', async () => { + const action = decideSweepAction('ISSUED', 'lnurl-withdraw', null, 'Service'); + expect(action.type).toBe('complete'); + + const deps = makeSweepDeps({ + recoverProofStashes: vi.fn().mockResolvedValue({ proofsRecovered: 1, proofsFailed: 1 }), + }); + + const outcome = await handleIssuedQuote('tx-1', action as SweepQuoteAction & { type: 'complete' }, deps); + + expect(outcome.result).toBe('deferred'); + expect(deps.completeTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/transaction-helpers.test.ts b/src/__tests__/transaction-helpers.test.ts new file mode 100644 index 0000000..27ec5f6 --- /dev/null +++ b/src/__tests__/transaction-helpers.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import { isUnfulfilledInvoice, isExpiredInvoice, decideMintSettlement, decideSweepAction } from '../lib/transaction-helpers'; +import type { Transaction } from '../hooks/use-wallet'; + +function makeTx(overrides: Partial): Transaction { + return { + id: 'tx-1', + type: 'mint', + amount: 1000, + unit: 'sat', + mintUrl: 'https://mint.example', + status: 'pending', + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +describe('isUnfulfilledInvoice', () => { + it('returns true for pending mint with invoice', () => { + expect(isUnfulfilledInvoice(makeTx({ status: 'pending', invoice: 'lnbc...' }))).toBe(true); + }); + + it('returns true for failed mint with invoice (post-restart expired)', () => { + expect(isUnfulfilledInvoice(makeTx({ status: 'failed', invoice: 'lnbc...' }))).toBe(true); + }); + + it('returns false for completed mint', () => { + expect(isUnfulfilledInvoice(makeTx({ status: 'completed', invoice: 'lnbc...' }))).toBe(false); + }); + + it('returns false for pending mint without invoice', () => { + expect(isUnfulfilledInvoice(makeTx({ status: 'pending', invoice: undefined }))).toBe(false); + }); + + it('returns false for non-mint types', () => { + expect(isUnfulfilledInvoice(makeTx({ type: 'send', status: 'pending', invoice: 'lnbc...' }))).toBe(false); + expect(isUnfulfilledInvoice(makeTx({ type: 'receive', status: 'pending', invoice: 'lnbc...' }))).toBe(false); + }); +}); + +describe('isExpiredInvoice', () => { + it('returns true for failed mint with invoice (recovery marked it failed)', () => { + expect(isExpiredInvoice(makeTx({ status: 'failed', invoice: 'lnbc...' }))).toBe(true); + }); + + it('returns true for pending mint with past expiresAt', () => { + const past = new Date(Date.now() - 60_000).toISOString(); + expect(isExpiredInvoice(makeTx({ status: 'pending', invoice: 'lnbc...', expiresAt: past }))).toBe(true); + }); + + it('returns false for pending mint with future expiresAt', () => { + const future = new Date(Date.now() + 60_000).toISOString(); + expect(isExpiredInvoice(makeTx({ status: 'pending', invoice: 'lnbc...', expiresAt: future }))).toBe(false); + }); + + it('returns false for pending mint with no expiresAt', () => { + expect(isExpiredInvoice(makeTx({ status: 'pending', invoice: 'lnbc...' }))).toBe(false); + }); + + it('returns false for completed mint', () => { + expect(isExpiredInvoice(makeTx({ status: 'completed', invoice: 'lnbc...' }))).toBe(false); + }); + + it('returns false for non-mint types even if failed with invoice', () => { + expect(isExpiredInvoice(makeTx({ type: 'send', status: 'failed', invoice: 'lnbc...' }))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// decideMintSettlement — dialog/sweep settlement decision +// --------------------------------------------------------------------------- + +describe('decideMintSettlement', () => { + it('returns complete with Lightning memo when fully persisted', () => { + const action = decideMintSettlement(true, 'lightning'); + expect(action).toEqual({ type: 'complete', memo: 'Lightning receive' }); + }); + + it('returns complete with LNURL memo when fully persisted', () => { + const action = decideMintSettlement(true, 'lnurl-withdraw'); + expect(action).toEqual({ type: 'complete', memo: 'LNURL withdraw' }); + }); + + it('includes LNURL description in memo when provided', () => { + const action = decideMintSettlement(true, 'lnurl-withdraw', 'My Service'); + expect(action).toEqual({ type: 'complete', memo: 'LNURL withdraw: My Service' }); + }); + + it('returns defer when not fully persisted (Lightning)', () => { + const action = decideMintSettlement(false, 'lightning'); + expect(action.type).toBe('defer'); + expect((action as { type: 'defer'; reason: string }).reason).toContain('partial'); + }); + + it('returns defer when not fully persisted (LNURL)', () => { + const action = decideMintSettlement(false, 'lnurl-withdraw', 'Service'); + expect(action.type).toBe('defer'); + }); +}); + +// --------------------------------------------------------------------------- +// decideSweepAction — background sweep quote-state decision +// --------------------------------------------------------------------------- + +describe('decideSweepAction', () => { + it('returns complete with stash recovery for ISSUED (Lightning)', () => { + const action = decideSweepAction('ISSUED', 'lightning', null); + expect(action).toEqual({ + type: 'complete', + memo: 'Lightning receive (already minted)', + needsStashRecovery: true, + }); + }); + + it('returns complete with stash recovery for ISSUED (LNURL)', () => { + const action = decideSweepAction('ISSUED', 'lnurl-withdraw', null); + expect(action).toEqual({ + type: 'complete', + memo: 'LNURL withdraw (already minted)', + needsStashRecovery: true, + }); + }); + + it('includes LNURL description in ISSUED memo', () => { + const action = decideSweepAction('ISSUED', 'lnurl-withdraw', null, 'My Service'); + expect(action).toEqual({ + type: 'complete', + memo: 'LNURL withdraw: My Service (already minted)', + needsStashRecovery: true, + }); + }); + + it('returns skip for PAID (caller handles settlement)', () => { + expect(decideSweepAction('PAID', 'lightning', null)).toEqual({ type: 'skip' }); + }); + + it('returns markFailed for UNPAID with past expiry', () => { + const pastExpiry = Math.floor(Date.now() / 1000) - 60; + const action = decideSweepAction('UNPAID', 'lightning', pastExpiry); + expect(action).toEqual({ type: 'markFailed', memo: 'Quote expired before payment' }); + }); + + it('returns skip for UNPAID with future expiry', () => { + const futureExpiry = Math.floor(Date.now() / 1000) + 3600; + expect(decideSweepAction('UNPAID', 'lightning', futureExpiry)).toEqual({ type: 'skip' }); + }); + + it('returns skip for UNPAID with no expiry', () => { + expect(decideSweepAction('UNPAID', 'lightning', null)).toEqual({ type: 'skip' }); + }); + + it('returns skip for unknown states', () => { + expect(decideSweepAction('UNKNOWN', 'lightning', null)).toEqual({ type: 'skip' }); + }); +}); diff --git a/src/__tests__/wallet-mutex.test.ts b/src/__tests__/wallet-mutex.test.ts index 17dc987..81f5124 100644 --- a/src/__tests__/wallet-mutex.test.ts +++ b/src/__tests__/wallet-mutex.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { acquireWalletLock, isWalletLocked, getWalletLockHolder, _resetMutex } from '../lib/wallet-mutex'; +import { acquireWalletLock, tryAcquireWalletLock, isWalletLocked, getWalletLockHolder, _resetMutex } from '../lib/wallet-mutex'; describe('wallet-mutex', () => { beforeEach(() => { @@ -60,4 +60,43 @@ describe('wallet-mutex', () => { release(); expect(getWalletLockHolder()).toBe(null); }); + + describe('tryAcquireWalletLock', () => { + it('acquires lock when free', () => { + const release = tryAcquireWalletLock('try-op'); + expect(release).not.toBeNull(); + expect(isWalletLocked()).toBe(true); + expect(getWalletLockHolder()).toBe('try-op'); + release!(); + expect(isWalletLocked()).toBe(false); + }); + + it('returns null when lock is held', async () => { + const release = await acquireWalletLock('blocker'); + const result = tryAcquireWalletLock('try-op'); + expect(result).toBeNull(); + expect(getWalletLockHolder()).toBe('blocker'); + release(); + }); + + it('does not jump the wait queue', async () => { + const release1 = await acquireWalletLock('op1'); + + // Queue a waiter + const p2 = acquireWalletLock('op2', 5000); + + // tryAcquire should fail — lock held by op1 + expect(tryAcquireWalletLock('try-op')).toBeNull(); + + // Release op1 — op2 should get the lock, not a future tryAcquire + release1(); + const release2 = await p2; + expect(getWalletLockHolder()).toBe('op2'); + + // tryAcquire should still fail — lock held by op2 + expect(tryAcquireWalletLock('try-op')).toBeNull(); + + release2(); + }); + }); }); diff --git a/src/cashu/pending-mint-recovery.ts b/src/cashu/pending-mint-recovery.ts index 539b3c6..3be3327 100644 --- a/src/cashu/pending-mint-recovery.ts +++ b/src/cashu/pending-mint-recovery.ts @@ -40,6 +40,8 @@ export type PendingMintState = { expiry: number | null; /** Origin of the receive ('lightning' or 'lnurl-withdraw'). */ source: 'lightning' | 'lnurl-withdraw'; + /** LNURL-withdraw service description (for memo reconstruction). */ + description?: string; }; /** Marker prefix so we can quickly identify PendingMintState memos. */ diff --git a/src/components/mint/mint-detail.tsx b/src/components/mint/mint-detail.tsx index 62eca4d..dea8b7a 100644 --- a/src/components/mint/mint-detail.tsx +++ b/src/components/mint/mint-detail.tsx @@ -29,7 +29,7 @@ interface MintDetailProps { onUpdateMint?: (id: string, updates: { name?: string }) => Promise; /** Props for swap/consolidation dialog */ getUnspentProofs?: (mintUrl: string) => StoredProof[]; - onNewProofs?: (mintContextId: string, proofs: Proof[]) => Promise; + onNewProofs?: (mintContextId: string, proofs: Proof[]) => Promise; onOldProofsSpent?: (ids: string[]) => Promise; onMarkPending?: (ids: string[]) => Promise; onTransactionCreated?: (data: Omit) => Promise; diff --git a/src/components/mint/swap-consolidate-dialog.tsx b/src/components/mint/swap-consolidate-dialog.tsx index 8f5e5c2..f631890 100644 --- a/src/components/mint/swap-consolidate-dialog.tsx +++ b/src/components/mint/swap-consolidate-dialog.tsx @@ -13,7 +13,7 @@ interface SwapConsolidateDialogProps { mint: Mint; getUnspentProofs: (mintUrl: string) => StoredProof[]; onClose: () => void; - onNewProofs: (mintContextId: string, proofs: Proof[]) => Promise; + onNewProofs: (mintContextId: string, proofs: Proof[]) => Promise; onOldProofsSpent: (ids: string[]) => Promise; onMarkPending: (ids: string[]) => Promise; onTransactionCreated: (data: Omit) => Promise; diff --git a/src/components/wallet/detect-confirm-card.tsx b/src/components/wallet/detect-confirm-card.tsx index 9e000ff..4439b77 100644 --- a/src/components/wallet/detect-confirm-card.tsx +++ b/src/components/wallet/detect-confirm-card.tsx @@ -83,7 +83,7 @@ export interface SendContext { getUnspentProofs: (mintUrl: string) => StoredProof[]; /** Used by payment-request matching which needs per-context proofs. */ getUnspentProofsByContext: (contextId: string) => StoredProof[]; - onNewProofs: (mintContextId: string, proofs: Proof[]) => Promise; + onNewProofs: (mintContextId: string, proofs: Proof[]) => Promise; onOldProofsSpent: (ids: string[]) => Promise; onMarkPending: (ids: string[]) => Promise; onRevertPending: (ids: string[]) => Promise; diff --git a/src/components/wallet/transaction-history.tsx b/src/components/wallet/transaction-history.tsx index 00d0509..eb50f22 100644 --- a/src/components/wallet/transaction-history.tsx +++ b/src/components/wallet/transaction-history.tsx @@ -1,36 +1,20 @@ -import { useState, useMemo } from 'react'; -import { - ArrowUpIcon, ArrowDownIcon, SendIcon, DownloadIcon, - RefreshCwIcon, UsersIcon, XIcon, SearchIcon, FilterIcon, -} from 'lucide-react'; -import { formatAmount, formatDate, truncateMintUrl } from '@/lib/utils'; +import { useState, useEffect, useMemo } from 'react'; +import { XIcon, SearchIcon, FilterIcon } from 'lucide-react'; +import { truncateMintUrl } from '@/lib/utils'; +import { TransactionRow } from '@/components/wallet/transaction-list-card'; +import { isUnfulfilledInvoice } from '@/lib/transaction-helpers'; import type { Transaction, Mint } from '@/hooks/use-wallet'; interface TransactionHistoryProps { transactions: Transaction[]; mints: Mint[]; onClose: () => void; + onCheckTokenSpent?: (tx: Transaction) => Promise; + onReclaimToken?: (tx: Transaction) => Promise; + onShowInvoiceQr?: (tx: Transaction) => void; + onDeleteTransaction?: (tx: Transaction) => Promise; } -const TX_ICONS: Record> = { - 'mint': ArrowDownIcon, 'melt': ArrowUpIcon, 'send': SendIcon, - 'receive': DownloadIcon, 'swap': RefreshCwIcon, - 'p2p-send': UsersIcon, 'p2p-receive': UsersIcon, -}; - -const TX_LABELS: Record = { - 'mint': 'Deposit', 'melt': 'Withdraw', 'send': 'Sent', - 'receive': 'Received', 'swap': 'Swap', - 'p2p-send': 'Sent to DID', 'p2p-receive': 'Received from DID', -}; - -const TX_COLORS: Record = { - 'mint': 'text-[var(--color-success)]', 'melt': 'text-[var(--color-warning)]', - 'send': 'text-primary', 'receive': 'text-[var(--color-info)]', - 'swap': 'text-muted-foreground', - 'p2p-send': 'text-primary', 'p2p-receive': 'text-[var(--color-info)]', -}; - const PAGE_SIZE = 25; const TYPE_FILTERS = [ @@ -45,6 +29,10 @@ export const TransactionHistory: React.FC = ({ transactions, mints, onClose, + onCheckTokenSpent, + onReclaimToken, + onShowInvoiceQr, + onDeleteTransaction, }) => { const [typeFilter, setTypeFilter] = useState('all'); const [mintFilter, setMintFilter] = useState('all'); @@ -72,8 +60,30 @@ export const TransactionHistory: React.FC = ({ return result; }, [transactions, typeFilter, mintFilter, search]); - const totalPages = Math.ceil(filtered.length / PAGE_SIZE); - const pageItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + + // Clamp page when the filtered list shrinks (e.g. after deleting an item). + useEffect(() => { + setPage(p => Math.min(p, totalPages - 1)); + }, [totalPages]); + + const safePage = Math.min(page, totalPages - 1); + const pageItems = filtered.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE); + + // Re-render when the nearest pending invoice on the current page expires. + const [, setTick] = useState(0); + useEffect(() => { + const now = Date.now(); + const nextExpiry = pageItems.reduce((earliest, tx) => { + if (!isUnfulfilledInvoice(tx) || !tx.expiresAt || tx.status === 'failed') return earliest; + const exp = new Date(tx.expiresAt).getTime(); + if (exp <= now) return earliest; + return earliest === null ? exp : Math.min(earliest, exp); + }, null); + if (nextExpiry === null) return; + const timer = setTimeout(() => setTick(t => t + 1), nextExpiry - now + 500); + return () => clearTimeout(timer); + }, [pageItems]); return ( @@ -143,33 +153,17 @@ export const TransactionHistory: React.FC = ({ No transactions match your filters. - ) : pageItems.map(tx => { - const Icon = TX_ICONS[tx.type] ?? RefreshCwIcon; - const label = TX_LABELS[tx.type] ?? tx.type; - const color = TX_COLORS[tx.type] ?? 'text-muted-foreground'; - const isIncoming = ['mint', 'receive', 'p2p-receive'].includes(tx.type); - const sign = isIncoming ? '+' : '-'; - - return ( - - - - - - - {label} - {tx.memo && {tx.memo}} - - {truncateMintUrl(tx.mintUrl)} · {formatDate(tx.createdAt)} - - - - - {sign}{formatAmount(tx.amount, tx.unit)} - - - ); - })} + ) : pageItems.map(tx => ( + + ))} {/* Pagination */} @@ -177,17 +171,17 @@ export const TransactionHistory: React.FC = ({ setPage(p => Math.max(0, p - 1))} - disabled={page === 0} + disabled={safePage === 0} className="px-3 py-1.5 rounded-lg border border-border text-xs hover:bg-muted disabled:opacity-50 transition-colors" > Previous - Page {page + 1} of {totalPages} + Page {safePage + 1} of {totalPages} setPage(p => Math.min(totalPages - 1, p + 1))} - disabled={page >= totalPages - 1} + disabled={safePage >= totalPages - 1} className="px-3 py-1.5 rounded-lg border border-border text-xs hover:bg-muted disabled:opacity-50 transition-colors" > Next diff --git a/src/components/wallet/transaction-list-card.tsx b/src/components/wallet/transaction-list-card.tsx index 14028dc..8796ce9 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, @@ -12,8 +12,12 @@ import { ClockIcon, Loader2Icon, RotateCcwIcon, + QrCodeIcon, + Trash2Icon, + 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 { @@ -21,6 +25,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 +61,26 @@ const TX_COLORS: Record = { 'p2p-receive': 'text-[var(--color-info)]', }; -function TransactionRow({ + +export function TransactionRow({ tx, onCheckSpent, onReclaimToken, + onShowInvoiceQr, + onDeleteTransaction, + expanded, }: { tx: Transaction; onCheckSpent?: (tx: Transaction) => Promise; onReclaimToken?: (tx: Transaction) => Promise; + onShowInvoiceQr?: (tx: Transaction) => void; + onDeleteTransaction?: (tx: Transaction) => Promise; + /** When true, action buttons are always visible and memo is shown (used in full history view). */ + expanded?: boolean; }) { 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 +99,10 @@ function TransactionRow({ const sign = isIncoming ? '+' : '-'; const hasCopyableToken = (tx.type === 'send' || tx.type === 'p2p-send') && !!tx.cashuToken; + // Unfulfilled invoice state (pending or failed-after-restart) + const unfulfilled = isUnfulfilledInvoice(tx); + const expired = isExpiredInvoice(tx); + const handleCopy = async (e: React.MouseEvent) => { e.stopPropagation(); if (!tx.cashuToken) return; @@ -128,15 +149,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 */} + {unfulfilled && !expired && ( + + + awaiting payment + + )} + {unfulfilled && expired && ( + + + expired + + )} {/* Claim status badge — uses persisted claimStatus or manual check */} {(tx.type === 'send' || tx.type === 'p2p-send') && spentState === 'spent' && ( @@ -157,6 +209,7 @@ function TransactionRow({ )} + {expanded && tx.memo && !unfulfilled && {tx.memo}} {truncateMintUrl(tx.mintUrl)} · {formatDate(tx.createdAt)} @@ -164,10 +217,38 @@ function TransactionRow({ + {/* Action buttons for pending invoices — always visible (touch-friendly) */} + {unfulfilled && ( + + {/* Show QR — only for active (non-expired) invoices */} + {!expired && onShowInvoiceQr && ( + + + + )} + {/* Delete — only for expired invoices */} + {expired && onDeleteTransaction && ( + + {deleting + ? + : + } + + )} + + )} {/* Action buttons for sent tokens */} {hasCopyableToken && ( - - {/* Reclaim unclaimed token */} + {onReclaimToken && spentState === 'pending' && ( )} - {/* Check spent status */} {onCheckSpent && spentState !== 'spent' && spentState !== 'reclaimed' && ( )} - {/* Copy token */} )} - + {sign}{formatAmount(tx.amount, tx.unit)} @@ -220,9 +303,27 @@ export const TransactionListCard: React.FC = ({ onViewAll, onCheckTokenSpent, onReclaimToken, + onShowInvoiceQr, + onDeleteTransaction, }) => { const recent = transactions.slice(0, 10); + // Schedule a re-render when the nearest pending invoice expires so the UI + // transitions from "awaiting payment" → "expired" without user interaction. + const [, setTick] = useState(0); + useEffect(() => { + const now = Date.now(); + const nextExpiry = recent.reduce((earliest, tx) => { + if (!isUnfulfilledInvoice(tx) || !tx.expiresAt || tx.status === 'failed') return earliest; + const exp = new Date(tx.expiresAt).getTime(); + if (exp <= now) return earliest; // already expired + return earliest === null ? exp : Math.min(earliest, exp); + }, null); + if (nextExpiry === null) return; + const timer = setTimeout(() => setTick(t => t + 1), nextExpiry - now + 500); + return () => clearTimeout(timer); + }, [recent]); + return ( @@ -243,7 +344,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 +355,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..bcdd708 100644 --- a/src/components/wallet/unified-receive-dialog.tsx +++ b/src/components/wallet/unified-receive-dialog.tsx @@ -57,6 +57,8 @@ 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 { decideMintSettlement } from '@/lib/transaction-helpers'; import { AmountInput } from '@/components/wallet/amount-input'; import { ChannelSegmented, type SegmentOption } from '@/components/wallet/channel-segmented'; @@ -119,7 +121,7 @@ export interface UnifiedReceiveDialogProps { onUnknownMint?: (mintUrl: string, amount: number, unit: string, token: string) => void; onClose: () => void; - onProofsReceived: (mintContextId: string, proofs: Proof[], mintUrl: string) => Promise; + onProofsReceived: (mintContextId: string, proofs: Proof[], mintUrl: string) => Promise; onTransactionCreated: (data: Omit) => Promise; /** Update a pending transaction to completed (used for recovery-safe receive flows). */ onTransactionCompleted?: (txId: string, opts?: { amount?: number; memo?: string }) => Promise; @@ -202,7 +204,7 @@ const ChannelsReceive: React.FC<{ mintHealth?: Map; did?: string; onClose: () => void; - onProofsReceived: (mintContextId: string, proofs: Proof[], mintUrl: string) => Promise; + onProofsReceived: (mintContextId: string, proofs: Proof[], mintUrl: string) => Promise; onTransactionCreated: (data: Omit) => Promise; onTransactionCompleted?: (txId: string, opts?: { amount?: number; memo?: string }) => Promise; }> = ({ mints, mintHealth, did, onClose, onProofsReceived, onTransactionCreated, onTransactionCompleted }) => { @@ -292,7 +294,7 @@ const ChannelsReceiveInner: React.FC<{ onCameraToggle: (active: boolean) => void; onScanOrPaste: (raw: string) => void; onClose: () => void; - onProofsReceived: (mintContextId: string, proofs: Proof[], mintUrl: string) => Promise; + onProofsReceived: (mintContextId: string, proofs: Proof[], mintUrl: string) => Promise; onTransactionCreated: (data: Omit) => Promise; onTransactionCompleted?: (txId: string, opts?: { amount?: number; memo?: string }) => Promise; }> = ({ mints, mintHealth, did, cameraActive, resolving, onCameraToggle, onScanOrPaste, onClose, onProofsReceived, onTransactionCreated, onTransactionCompleted }) => { @@ -408,11 +410,10 @@ 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; - setLnInvoice(quote.request); - setLnStep('invoice'); // Capture values in closure; the state object may change by the time callbacks fire. const mintUrl = selectedMint.url; @@ -422,52 +423,74 @@ 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, 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, }); + if (!mountedRef.current) return; + + // Now safe to show the QR — the recovery record is durable. + 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'); } - await onProofsReceived(mintCtx, proofs, mintUrl); - - // Complete the pending transaction (or create a new one if pending write failed). - if (pendingTxId && onTransactionCompleted) { - await onTransactionCompleted(pendingTxId, { amount: amt, memo: 'Lightning receive' }); + // 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. + const fullyPersisted = await onProofsReceived(mintCtx, proofs, mintUrl); + const action = decideMintSettlement(fullyPersisted, 'lightning'); + + if (action.type === 'complete') { + if (pendingTxId && onTransactionCompleted) { + await onTransactionCompleted(pendingTxId, { amount: amt, memo: action.memo }); + } else { + await onTransactionCreated({ + type: 'mint', amount: amt, unit: mintUnit, mintUrl, + status: 'completed', memo: action.memo, + }); + } } else { - await onTransactionCreated({ - type: 'mint', amount: amt, unit: mintUnit, mintUrl, - status: 'completed', memo: 'Lightning receive', - }); + console.warn(`[nutsd] Lightning receive: ${action.reason}`); } if (mountedRef.current) { setLnReceivedAmount(amt); setLnStep('done'); - toastSuccess('Received!', `+${formatAmount(amt, mintUnit)}`); + toastSuccess('Received!', `+${formatAmount(amt, mintUnit)}${action.type === 'defer' ? ' (syncing…)' : ''}`); } } catch (err) { if (mountedRef.current) { @@ -484,10 +507,19 @@ const ChannelsReceiveInner: React.FC<{ setLnStep('error'); } }, - onIssued: () => { + onIssued: async () => { + // Tokens were already minted — another tab/session settled this + // invoice, or the background sweep did. Mark completed and show + // a success-like message rather than a scary error. + if (pendingTxId && onTransactionCompleted) { + await onTransactionCompleted(pendingTxId, { + memo: 'Lightning receive (settled in another session)', + }); + } if (mountedRef.current) { - setLnError('These tokens were already minted (possibly in another session).'); - setLnStep('error'); + setLnReceivedAmount(amt); + setLnStep('done'); + toastSuccess('Payment already received', 'Tokens were minted in another session.'); } }, isActive: () => mountedRef.current, @@ -498,7 +530,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'); @@ -607,7 +648,7 @@ const ChannelsReceiveInner: React.FC<{ { setLnStep('amount'); setLnError(''); setLnInvoice(''); }} + onClick={() => { stopPollingRef.current?.(); stopPollingRef.current = undefined; setLnStep('amount'); setLnError(''); setLnInvoice(''); }} className="flex-1 px-4 py-2.5 rounded-full border border-border text-sm font-medium hover:bg-muted" > Try again @@ -799,7 +840,7 @@ const ClaimTokenPane: React.FC<{ p2pkPrivateKey?: string; onUnknownMint?: (mintUrl: string, amount: number, unit: string, token: string) => void; onClose: () => void; - onProofsReceived: (mintContextId: string, proofs: Proof[], mintUrl: string) => Promise; + onProofsReceived: (mintContextId: string, proofs: Proof[], mintUrl: string) => Promise; onTransactionCreated: (data: Omit) => Promise; }> = ({ mints, @@ -908,7 +949,12 @@ const ClaimTokenPane: React.FC<{ } const total = newProofs.reduce((s, p) => s + p.amount, 0); - await onProofsReceived(known.contextId, newProofs, mintUrl); + const fullyPersisted = await onProofsReceived(known.contextId, newProofs, mintUrl); + + // Always record the transaction — the token is already spent at + // the mint regardless of local proof persistence status. On partial + // persistence the stash holds the proofs and startup recovery will + // write them. Without a tx record the receive would be invisible. await onTransactionCreated({ type : 'receive', amount : total, @@ -917,9 +963,13 @@ const ClaimTokenPane: React.FC<{ status : 'completed', }); + if (!fullyPersisted) { + console.warn('[nutsd] Token claim: proof persistence partial, stash recovery will finish on restart'); + } + setReceivedAmount(total); setStep('done'); - toastSuccess('Token received', `+${formatAmount(total, known.unit ?? 'sat')}`); + toastSuccess('Token received', `+${formatAmount(total, known.unit ?? 'sat')}${fullyPersisted ? '' : ' (syncing…)'}`); } catch (err) { setErrorMsg(err instanceof Error ? err.message : String(err)); setStep('error'); @@ -1064,7 +1114,7 @@ const LnurlWithdrawPane: React.FC<{ mintHealth?: Map; lnurl: string; onClose: () => void; - onProofsReceived: (mintContextId: string, proofs: Proof[], mintUrl: string) => Promise; + onProofsReceived: (mintContextId: string, proofs: Proof[], mintUrl: string) => Promise; onTransactionCreated: (data: Omit) => Promise; onTransactionCompleted?: (txId: string, opts?: { amount?: number; memo?: string }) => Promise; }> = ({ mints, mintHealth, lnurl, onClose, onProofsReceived, onTransactionCreated, onTransactionCompleted }) => { @@ -1147,6 +1197,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. @@ -1164,11 +1215,18 @@ const LnurlWithdrawPane: React.FC<{ quoteId, mintUrl, mintContextId: mintCtx, amount: amt, unit: mintUnit, expiry: quoteExpiry, source: 'lnurl-withdraw', + description: withdrawInfo.description || undefined, }; + 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. @@ -1181,40 +1239,46 @@ 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'); } - await onProofsReceived(mintCtx, proofs, mintUrl); - - // Complete the pending transaction. - const memo = `LNURL withdraw${withdrawInfo.description ? `: ${withdrawInfo.description}` : ''}`; - if (pendingTxId && onTransactionCompleted) { - await onTransactionCompleted(pendingTxId, { amount: amt, memo }); + // CRITICAL: persist proofs unconditionally (see Lightning onPaid comment). + const fullyPersisted = await onProofsReceived(mintCtx, proofs, mintUrl); + const action = decideMintSettlement(fullyPersisted, 'lnurl-withdraw', withdrawInfo.description); + + if (action.type === 'complete') { + if (pendingTxId && onTransactionCompleted) { + await onTransactionCompleted(pendingTxId, { amount: amt, memo: action.memo }); + } else { + await onTransactionCreated({ + type: 'mint', amount: amt, unit: mintUnit, mintUrl, + status: 'completed', memo: action.memo, + }); + } } else { - await onTransactionCreated({ - type: 'mint', amount: amt, unit: mintUnit, mintUrl, - status: 'completed', memo, - }); + console.warn(`[nutsd] LNURL withdraw: ${action.reason}`); } if (mountedRef.current) { setReceivedAmount(amt); setStep('done'); - toastSuccess('Received!', `+${formatAmount(amt, mintUnit)}`); + toastSuccess('Received!', `+${formatAmount(amt, mintUnit)}${action.type === 'defer' ? ' (syncing…)' : ''}`); } } catch (err) { if (mountedRef.current) { @@ -1231,10 +1295,16 @@ const LnurlWithdrawPane: React.FC<{ setStep('error'); } }, - onIssued: () => { + onIssued: async () => { + if (pendingTxId && onTransactionCompleted) { + await onTransactionCompleted(pendingTxId, { + memo: 'LNURL withdraw (settled in another session)', + }); + } if (mountedRef.current) { - setErrorMsg('These tokens were already minted (possibly in another session).'); - setStep('error'); + setReceivedAmount(amt); + setStep('done'); + toastSuccess('Payment already received', 'Tokens were minted in another session.'); } }, isActive: () => mountedRef.current, @@ -1245,9 +1315,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/components/wallet/unified-send-dialog.tsx b/src/components/wallet/unified-send-dialog.tsx index f81f2fd..0dde82f 100644 --- a/src/components/wallet/unified-send-dialog.tsx +++ b/src/components/wallet/unified-send-dialog.tsx @@ -71,7 +71,7 @@ export interface UnifiedSendDialogProps { senderDid?: string; enbox: any; onClose: () => void; - onNewProofs: (mintContextId: string, proofs: Proof[]) => Promise; + onNewProofs: (mintContextId: string, proofs: Proof[]) => Promise; onOldProofsSpent: (ids: string[]) => Promise; onMarkPending: (ids: string[]) => Promise; onRevertPending: (ids: string[]) => Promise; diff --git a/src/hooks/use-wallet.ts b/src/hooks/use-wallet.ts index e09b8ad..f0101ae 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, @@ -48,10 +50,14 @@ import { type KeysetInfo, } from '@/cashu/wallet-ops'; import { generateP2pkKeyPair, receiveP2pkLocked, type P2pkKeyPair } from '@/cashu/p2pk'; -import { recoverStashes, type RecoveryDeps } from '@/cashu/proof-stash-recovery'; +import { recoverStashes, type RecoveryDeps, type RecoveryResult } 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 { isDleqValid } from '@/cashu/dleq-verify'; +import { acquireWalletLock, tryAcquireWalletLock } from '@/lib/wallet-mutex'; +import { isQuoteActive } from '@/lib/active-quotes'; +import { decideSweepAction, handleIssuedQuote, handlePaidSettlement, type SweepDeps, type PaidSettlementDeps } from '@/lib/transaction-helpers'; +import { formatAmount, toastSuccess } from '@/lib/utils'; // --------------------------------------------------------------------------- // Domain types — flattened from TypedRecord for the UI layer @@ -113,6 +119,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 +372,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 @@ -586,6 +601,8 @@ export function useWallet() { // Proofs/keysets load after mints; startup recovery runs once proofs are loaded. const startupRecoveryDone = useRef(false); + /** True once startup recovery (stash replay, pending receives, etc.) has finished. */ + const startupRecoveryComplete = useRef(false); const startupRecoveryRef = useRef<(freshProofs: StoredProof[]) => Promise>(async () => {}); useEffect(() => { @@ -609,6 +626,8 @@ export function useWallet() { await startupRecoveryRef.current(freshProofs); } catch (err) { console.error('[nutsd] Startup recovery failed:', err); + } finally { + startupRecoveryComplete.current = true; } } })(); @@ -1317,9 +1336,12 @@ export function useWallet() { * that is tested in proof-stash-recovery.test.ts), wiring DWN access as * injected deps. This ensures the tested code IS the production code. */ - /** @returns true if any proofs were recovered (caller should re-load proofs). */ - const recoverProofStashes = useCallback(async (): Promise => { - if (!repo) return false; + /** + * @returns The full RecoveryResult, or null if repo not ready. + * Callers that only need a boolean can check `result.proofsRecovered > 0`. + */ + const recoverProofStashes = useCallback(async (): Promise => { + if (!repo) return null; const deps: RecoveryDeps = { getStashes: async () => { @@ -1379,7 +1401,7 @@ export function useWallet() { `${result.stashesCompleted}/${result.stashesFound} stashes completed`, ); } - return result.proofsRecovered > 0; + return result; }, [repo, mints, addMint, addProof]); /** @@ -1522,15 +1544,18 @@ export function useWallet() { if (fullyPersisted) { const total = result.proofs.reduce((s, p) => s + p.amount, 0); + const sourceLabel = state.source === 'lnurl-withdraw' + ? `LNURL withdraw${state.description ? `: ${state.description}` : ''}` + : 'Lightning receive'; await record.update({ - data: { ...tx, status: 'completed', amount: total, memo: `Recovered ${state.source} receive` }, + data: { ...tx, status: 'completed', amount: total, memo: sourceLabel }, }); console.log(`[nutsd] Pending ${state.source} receive completed: ${total} ${state.unit}`); recovered = true; } break; } - case 'issued': + case 'issued': { // ISSUED = tokens were already minted by a previous attempt. // // IMPORTANT: This does NOT guarantee the proofs are in the @@ -1545,11 +1570,14 @@ export function useWallet() { // window and are lost. Keeping this pending would just hit // ISSUED forever with no recovery path — marking completed // (with a warning) is the honest outcome. + const issuedLabel = state.source === 'lnurl-withdraw' + ? `LNURL withdraw${state.description ? `: ${state.description}` : ''}` + : 'Lightning receive'; await record.update({ data: { ...tx, status: 'completed', - memo: 'Recovered (ISSUED — proofs may have been recovered via stash, or lost in pre-stash crash window)', + memo: `${issuedLabel} (recovered — proofs may have been restored via stash)`, }, }); console.warn( @@ -1559,6 +1587,7 @@ export function useWallet() { ); recovered = true; break; + } case 'expired': await record.update({ data: { ...tx, status: 'failed', memo: 'Quote expired before payment' } }); console.log(`[nutsd] Pending receive expired: ${state.quoteId}`); @@ -1586,12 +1615,13 @@ export function useWallet() { useEffect(() => { startupRecoveryRef.current = async (freshProofs: StoredProof[]) => { const stashResult = await recoverProofStashes(); + const stashRecoveredProofs = stashResult != null && stashResult.proofsRecovered > 0; // Resume any pending cross-mint swaps (second leg). await resumePendingSwaps(); // Resume any pending Lightning / LNURL-withdraw receives. const receiveResult = await resumePendingReceives(); // If any recovery wrote new proofs, re-load. - const proofsForReconciliation = (stashResult || receiveResult) + const proofsForReconciliation = (stashRecoveredProofs || receiveResult) ? await refreshProofs() : freshProofs; await reconcilePendingProofs(proofsForReconciliation); @@ -1624,6 +1654,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 +1683,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 +1694,8 @@ export function useWallet() { status: 'completed' as const, amount: opts?.amount ?? t.amount, memo: opts?.memo ?? undefined, + invoice: undefined, + quoteId: undefined, } : t), ); } @@ -1666,6 +1704,45 @@ 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) throw new Error('Wallet not initialized'); + const { records } = await repo.transaction.query(); + const record = records.find((r: { id: string }) => r.id === txId); + if (!record) throw new Error('Transaction not found'); + await record.delete(); + setTransactions(prev => prev.filter(t => t.id !== txId)); + }, [repo]); + + /** + * 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 { + 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, quoteId: undefined }, + }); + setTransactions(prev => + prev.map(t => t.id === txId + ? { ...t, status: 'failed' as const, memo, 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. @@ -1732,6 +1809,139 @@ 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; + let sweepInFlight = false; + + const sweep = async (): Promise => { + if (sweepInFlight) return; // previous iteration still running + // Wait for startup recovery to finish before sweeping. Startup + // recovery runs recoverProofStashes() and resumePendingReceives() + // without the wallet lock — sweeping concurrently could duplicate + // proof writes via overlapping stash replay. + if (!startupRecoveryComplete.current) return; + sweepInFlight = true; + try { + 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; + + // 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 { + const quote = await checkMintQuote(state.mintUrl, state.quoteId, state.unit); + quoteState = quote.state as string; + } catch { + continue; // mint may be offline — try next cycle + } + // Safe to bail here — no mutation happened yet (just a status check). + 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) ── + const source: 'lightning' | 'lnurl-withdraw' = state.source === 'lnurl-withdraw' ? 'lnurl-withdraw' : 'lightning'; + const sweepAction = decideSweepAction(quoteState, source, state.expiry, state.description); + + if (sweepAction.type === 'complete') { + // ISSUED — stash recovery writes proofs, so we need the wallet lock. + const issuedLock = tryAcquireWalletLock('invoice-sweep-issued'); + if (!issuedLock) continue; // lock held — defer to next cycle + try { + const sweepDeps: SweepDeps = { recoverProofStashes, refreshProofs, completeTransaction }; + const outcome = await handleIssuedQuote(tx.id, sweepAction, sweepDeps); + if (outcome.result === 'deferred') { + console.warn(`[nutsd] Background sweep: ${outcome.reason} for ${state.quoteId}`); + } else { + console.warn(`[nutsd] Background sweep: invoice ISSUED: ${state.quoteId}`); + } + } finally { + issuedLock(); + } + continue; + } + if (sweepAction.type === 'markFailed') { + await _markTransactionFailed(tx.id, sweepAction.memo); + continue; + } + if (quoteState !== 'PAID') 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 { + // Final guard: re-check the active set after acquiring the lock. + 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 paidDeps: PaidSettlementDeps = { + recoverProofStashes, refreshProofs, completeTransaction, + mintTokens, isDleqValid, safeStoreReceivedProofs, + }; + const outcome = await handlePaidSettlement(tx.id, state, mint, paidDeps); + if (outcome.result === 'completed') { + toastSuccess('Payment received!', `+${formatAmount(outcome.total, mint.unit)}`); + console.log(`[nutsd] Background sweep settled ${state.source} invoice: ${outcome.total} ${mint.unit}`); + } else { + console.warn(`[nutsd] Background sweep: ${outcome.reason} for ${state.quoteId}`); + } + } catch (err) { + // mintTokens can return ISSUED if a race slipped through + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('already issued') || msg.includes('ISSUED')) { + const issuedAction = decideSweepAction('ISSUED', source, state.expiry, state.description); + if (issuedAction.type === 'complete') { + const sweepDeps: SweepDeps = { recoverProofStashes, refreshProofs, completeTransaction }; + await handleIssuedQuote(tx.id, issuedAction, sweepDeps); + } + } + } finally { + releaseLock(); + } + } + } finally { + sweepInFlight = false; + } + }; + + 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, recoverProofStashes, refreshProofs]); + // ========================================================================= // Incoming P2P transfers // ========================================================================= @@ -1924,6 +2134,7 @@ export function useWallet() { // Transaction operations addTransaction, completeTransaction, + deleteTransaction, markTransactionClaimed, refreshTransactions, 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/transaction-helpers.ts b/src/lib/transaction-helpers.ts new file mode 100644 index 0000000..86bda79 --- /dev/null +++ b/src/lib/transaction-helpers.ts @@ -0,0 +1,185 @@ +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(); +} + +// --------------------------------------------------------------------------- +// Settlement decision logic +// --------------------------------------------------------------------------- +// Extracted from dialog onPaid callbacks and background sweep so the +// branching can be tested without React/DWN infrastructure. + +/** What the caller should do after minting + proof persistence. */ +export type SettlementAction = + | { type: 'complete'; memo: string } + | { type: 'defer'; reason: string }; + +/** + * Decide whether to mark a pending mint transaction as completed after + * proofs have been minted and a persistence attempt made. + * + * @param fullyPersisted - Return value of safeStoreReceivedProofs + * @param source - 'lightning' | 'lnurl-withdraw' — determines the memo + * @param description - Optional LNURL description to include in the memo + */ +export function decideMintSettlement( + fullyPersisted: boolean, + source: 'lightning' | 'lnurl-withdraw', + description?: string, +): SettlementAction { + if (!fullyPersisted) { + return { + type: 'defer', + reason: 'Proof persistence partial — stash recovery will finish on restart', + }; + } + const memo = source === 'lnurl-withdraw' + ? `LNURL withdraw${description ? `: ${description}` : ''}` + : 'Lightning receive'; + return { type: 'complete', memo }; +} + +/** What the sweep should do when it encounters a non-PAID quote state. */ +export type SweepQuoteAction = + | { type: 'complete'; memo: string; needsStashRecovery: boolean } + | { type: 'markFailed'; memo: string } + | { type: 'skip' }; + +/** + * Decide what the background sweep should do for a given quote state. + * + * @param quoteState - The mint quote state string (PAID, ISSUED, UNPAID, etc.) + * @param source - 'lightning' | 'lnurl-withdraw' + * @param expiry - Unix seconds expiry, if known + * @param description - Optional LNURL description for memo + */ +export function decideSweepAction( + quoteState: string, + source: 'lightning' | 'lnurl-withdraw', + expiry: number | null, + description?: string, +): SweepQuoteAction { + const sourceLabel = source === 'lnurl-withdraw' + ? `LNURL withdraw${description ? `: ${description}` : ''}` + : 'Lightning receive'; + + if (quoteState === 'ISSUED') { + return { + type: 'complete', + memo: `${sourceLabel} (already minted)`, + needsStashRecovery: true, + }; + } + + if (quoteState === 'PAID') { + // Caller handles PAID — this function is for non-PAID states. + // Returning skip signals the caller to proceed to the settlement phase. + return { type: 'skip' }; + } + + // UNPAID — check expiry + if (expiry && expiry < Math.floor(Date.now() / 1000)) { + return { type: 'markFailed', memo: 'Quote expired before payment' }; + } + + return { type: 'skip' }; +} + +// --------------------------------------------------------------------------- +// Sweep handler functions (extracted for testability) +// --------------------------------------------------------------------------- + +/** Dependencies injected into sweep handlers. */ +export interface SweepDeps { + recoverProofStashes: () => Promise<{ proofsRecovered: number; proofsFailed: number } | null>; + refreshProofs: () => Promise; + completeTransaction: (txId: string, opts?: { amount?: number; memo?: string }) => Promise; +} + +/** Outcome of handleIssuedQuote. */ +export type IssuedOutcome = + | { result: 'completed'; memo: string } + | { result: 'deferred'; reason: string }; + +/** + * Handle an ISSUED quote in the background sweep. + * + * Runs stash recovery first if the action requires it. Only completes the + * transaction if stash recovery had zero failed proof writes — otherwise + * defers to the next sweep cycle. + */ +export async function handleIssuedQuote( + txId: string, + action: SweepQuoteAction & { type: 'complete' }, + deps: SweepDeps, +): Promise { + if (action.needsStashRecovery) { + const stashResult = await deps.recoverProofStashes(); + if (stashResult && stashResult.proofsRecovered > 0) { + await deps.refreshProofs(); + } + if (stashResult && stashResult.proofsFailed > 0) { + return { result: 'deferred', reason: 'Stash recovery incomplete — proofs still missing' }; + } + } + await deps.completeTransaction(txId, { memo: action.memo }); + return { result: 'completed', memo: action.memo }; +} + +/** Outcome of handlePaidSettlement. */ +export type PaidOutcome = + | { result: 'completed'; total: number; memo: string } + | { result: 'deferred'; reason: string }; + +/** Dependencies for the PAID settlement handler. */ +export interface PaidSettlementDeps extends SweepDeps { + mintTokens: (mintUrl: string, amount: number, quoteId: string, unit: string) => Promise>; + isDleqValid: (mintUrl: string, proofs: Array<{ amount: number; id: string; secret: string; C: string }>) => Promise; + safeStoreReceivedProofs: (contextId: string, mintUrl: string, unit: string, proofs: Array<{ amount: number; id: string; secret: string; C: string; state: 'unspent' }>) => Promise; +} + +/** + * Handle a PAID quote in the background sweep. + * + * Mints tokens, verifies DLEQ, persists proofs, and completes the + * transaction — but only if persistence fully succeeded. + */ +export async function handlePaidSettlement( + txId: string, + state: { mintUrl: string; amount: number; quoteId: string; unit: string; source: 'lightning' | 'lnurl-withdraw'; description?: string }, + mint: { contextId: string; url: string; unit: string }, + deps: PaidSettlementDeps, +): Promise { + const proofs = await deps.mintTokens(state.mintUrl, state.amount, state.quoteId, state.unit); + + if (!(await deps.isDleqValid(state.mintUrl, proofs))) { + console.warn('[nutsd:financial] DLEQ verification failed on sweep-minted proofs'); + } + + const fullyPersisted = await deps.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 })), + ); + + const action = decideMintSettlement(fullyPersisted, state.source, state.description); + if (action.type === 'complete') { + const total = proofs.reduce((s, p) => s + p.amount, 0); + await deps.completeTransaction(txId, { amount: total, memo: action.memo }); + await deps.refreshProofs(); + return { result: 'completed', total, memo: action.memo }; + } + return { result: 'deferred', reason: action.reason }; +} 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. */ 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; }; /**
+ This invoice has expired and can no longer be paid. +
+ Waiting for {formatAmount(amount, unit)} payment +
{tx.memo}
- Tap Deposit to add funds via Lightning, or Receive to claim a Cashu token. + Tap Receive to get started.