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. +

+ +
+ ) : ( + <> +

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

+
+
+ +
+
+ + + )} +
+
+ ); +} + // --------------------------------------------------------------------------- // 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 = ({
- Page {page + 1} of {totalPages} + Page {safePage + 1} of {totalPages} + )} + {/* 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,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<{