fix: receive screen redesign + pending invoice tracking#39
Merged
Conversation
d868eb7 to
ea856a6
Compare
- Lightning receive: camera/scan placeholder replaces blank QR area before invoice generation; transforms into QR code once invoice is created - Pending Lightning invoices (receive + LNURL-withdraw) now store invoice, quoteId, and expiresAt on the transaction record for QR re-display - Activity list: 'awaiting payment' badge + Show QR for active invoices; 'expired' badge + Delete for expired invoices; completed = immutable - completeTransaction clears invoice/quoteId fields on completion - deleteTransaction for removing expired pending invoices from DWN - InvoiceQrDialog for re-displaying pending invoice QR from activity - Consistent pending/expired badges in transaction history view - Added missing @radix-ui/react-visually-hidden dependency
ea856a6 to
a3f9876
Compare
…emo leak - deleteTransaction now throws on failure; App.tsx catches and shows error toast - handleShowInvoiceQr guards against expired invoices before opening dialog - Pending-invoice action buttons always visible (not hover-only) for touch/mobile - TransactionListCard schedules a re-render at the nearest pending invoice expiry - Transaction history hides serialized recovery JSON memo for pending mints
…tions - handleDeleteTransaction no longer catches internally — errors propagate to TransactionRow.handleDelete which resets the spinner and toasts - Also allows deleting status:'failed' invoices (set by startup recovery) - isUnfulfilledInvoice/isExpiredInvoice now cover both pending+expired and failed+invoice states so badges/colors/actions work after restart - TransactionHistory reuses TransactionRow from transaction-list-card with full QR/delete/check-spent/reclaim handlers wired from App.tsx - Removed duplicate icon/label/color maps from transaction-history.tsx
…memo display - TransactionRow gains 'expanded' prop: always-visible action buttons and memo display; TransactionHistory passes expanded=true - Sent-token actions (copy/check/reclaim) now touch-accessible in history - Pagination clamps page when filtered list shrinks (e.g. after delete); safePage prevents 'Page 2 of 1' state - TransactionHistory gets its own expiry re-render timer for current page - Export isUnfulfilledInvoice for reuse in history timer
…x lint - Remove camera placeholder from unified-receive-dialog QR area; restore original 'Enter an amount to generate' text. InlineQrScanner already provides the single scan entry point below the QR area. - InvoiceQrDialog now accepts expiresAt and auto-transitions to an expired state with a warning when the invoice expires while the dialog is open. handleShowInvoiceQr passes expiresAt through to the dialog. - Move isUnfulfilledInvoice/isExpiredInvoice to src/lib/transaction-helpers.ts so transaction-list-card.tsx only exports React components, fixing the react-refresh/only-export-components lint rule (--max-warnings 0).
…mpletion - Add background pending-invoice sweep (15s interval) in use-wallet.ts that runs resumePendingMint for active pending invoices. Covers the case where the user closes the receive dialog and the payer pays while the app is open — proofs are minted, balance updated, and toast shown without restart. - Reorder Lightning invoice flow: DWN pending transaction is persisted BEFORE setLnInvoice/setLnStep, so the QR is never visible without a durable recovery record. A crash or tab close in the old window was a fund-loss risk for paid-but-untracked invoices. - onIssued callbacks in both Lightning and LNURL-withdraw flows now call onTransactionCompleted to mark the pending tx as completed. Previously they only showed a dialog error, leaving the tx as pending/unfulfilled in the activity list with a stale Show QR action. - Add _markTransactionFailed helper for the background sweep to transition expired invoices to failed status.
…nvoice metadata - Background invoice sweep now acquires the wallet lock before settling. If the lock is held (e.g. by an open receive dialog), the invoice is skipped for this cycle. This prevents concurrent settlement where the sweep and dialog both call mintTokens/safeStoreReceivedProofs for the same quote, which would cause the loser to hit ISSUED and show a false failure after a successful payment. - Sweep now checks safeStoreReceivedProofs() return value: only completes the transaction if fullyPersisted is true. Partial writes (stash-only) leave the transaction pending for stash recovery on next startup, matching the safety guarantees of the startup recovery path. - _markTransactionFailed no longer clears invoice or expiresAt. Only quoteId is cleared (no longer needed for polling). Preserving invoice and expiresAt ensures isUnfulfilledInvoice/isExpiredInvoice still classify the transaction correctly for the expired badge and delete action in the activity list.
…roof-loss on unmount Active-quote coordination: - New src/lib/active-quotes.ts: module-level Set<string> registry for quoteIds being actively monitored by open dialogs - Dialog registers quoteId before subscribing, unregisters on teardown (wrapped in stopPollingRef cleanup function) - Catch blocks in both Lightning and LNURL-withdraw clean up the registration if an error occurs before stopPollingRef is assigned, preventing permanent active-quote leaks Sweep restructured into two phases: - Check phase (no lock): skips active quotes, then calls checkMintQuote directly. Network round-trips no longer hold the wallet lock or trigger the beforeunload guard - Settlement phase (PAID only): uses tryAcquireWalletLock (new non-blocking variant) — returns null if lock is held, so sweep never blocks behind a dialog. Lock scope narrowed to just mintTokens/safeStoreReceivedProofs/ completeTransaction. Re-checks isQuoteActive after acquiring lock. Handles mintTokens throwing ISSUED gracefully (dialog won the race) Critical proof-loss fix (pre-existing): - onPaid callbacks in both Lightning and LNURL-withdraw flows no longer bail on !mountedRef.current after mintTokens succeeds. Once proofs are minted at the Cashu mint, they MUST be persisted via onProofsReceived unconditionally — even if the dialog was dismissed during the await. Only UI state updates (setLnStep, toastSuccess) are guarded by mountedRef. Previously, dismissing the dialog during minting would silently drop proofs (fund loss).
… leak - Removed the post-mintTokens cancelled bail-out in the background sweep. Once mintTokens succeeds, safeStoreReceivedProofs and completeTransaction run unconditionally — same 'persist unconditionally' rule as the dialog. The pre-mintTokens cancelled check (after checkMintQuote) is retained since no mutation has occurred at that point. - Added isDleqValid verification to the sweep settlement path, matching the dialog (unified-receive-dialog.tsx:465) and startup recovery (pending-mint-recovery.ts:100) verification guarantees. - Lightning 'Try again' button now calls stopPollingRef.current?.() before resetting UI state. This unregisters the old quoteId from the active-quote set and stops the subscription, preventing a permanent leak where the sweep would skip the stale quoteId indefinitely.
- Background sweep now derives memo from PendingMintState.source: uses 'LNURL withdraw' for lnurl-withdraw invoices and 'Lightning receive' for standard Lightning, matching the dialog's memo behavior. Previously all sweep-settled invoices were labeled 'Lightning receive' regardless of source. - Both dialog onIssued callbacks (Lightning and LNURL-withdraw) now show the success screen (done step) with a toast instead of the error screen. ISSUED means tokens were already minted in another session — this is a successful outcome, not a failure. The old error messaging was confusing when a second tab or the background sweep settled the invoice first.
- storeNewProofs and storeNewProofsForMintUrl in App.tsx now return the boolean from safeStoreReceivedProofs instead of discarding it - onProofsReceived prop type changed from Promise<void> to Promise<boolean> across all sub-components in unified-receive-dialog.tsx - Lightning onPaid: only calls onTransactionCompleted (marking the pending tx as completed) when fullyPersisted is true. On partial persistence, the tx stays pending so stash recovery on next startup can finish the job. The user still sees the success screen with a '(syncing…)' hint. - LNURL-withdraw onPaid: same partial-persistence handling - Matches the safety guarantees already in place for the background sweep (use-wallet.ts:1891-1901) and startup recovery (use-wallet.ts:1532-1540)
…persistence - Updated onNewProofs prop type from Promise<void> to Promise<boolean> in unified-send-dialog.tsx, detect-confirm-card.tsx, mint-detail.tsx, and swap-consolidate-dialog.tsx to match storeNewProofs return type change. Fixes TS2322 build errors at App.tsx:535 and App.tsx:713. - ClaimTokenPane now checks the fullyPersisted return value from onProofsReceived. On partial persistence, the transaction record is deferred (token is already spent at the mint, stash recovery handles it on restart) and the toast includes a '(syncing…)' hint.
… add tests ISSUED sweep path: - Both ISSUED handlers in the background sweep (check-phase and catch) now call recoverProofStashes() + refreshProofs() before completing the transaction. This ensures proofs from a partial-persistence dialog path are recovered into the wallet within the current session, not just on restart. Prevents the balance from staying short while the tx shows as completed. Claim-token partial persistence: - Always creates a completed transaction record regardless of fullyPersisted return value. The token is already spent at the mint — not recording the tx would leave a gap in transaction history that no recovery path fills (stash recovery only restores proofs, not tx records). Toast shows '(syncing…)' hint on partial write. Tests (19 new, 287 total): - src/__tests__/active-quotes.test.ts: register, unregister, idempotency, multi-quote independence - src/__tests__/transaction-helpers.test.ts: isUnfulfilledInvoice and isExpiredInvoice for pending/failed/completed, with/without invoice, past/future/missing expiresAt, non-mint types - src/__tests__/wallet-mutex.test.ts: tryAcquireWalletLock — acquires when free, returns null when held, does not jump the wait queue
Extract decideMintSettlement() and decideSweepAction() into
transaction-helpers.ts. Both dialog onPaid paths and the background
sweep now call these functions instead of inlining the branching logic.
decideMintSettlement(fullyPersisted, source, description?):
- fullyPersisted=true → { type: 'complete', memo } with source-aware memo
- fullyPersisted=false → { type: 'defer', reason }
Used by Lightning onPaid, LNURL-withdraw onPaid, and sweep PAID handler.
decideSweepAction(quoteState, source, expiry):
- ISSUED → { type: 'complete', needsStashRecovery: true }
- UNPAID + expired → { type: 'markFailed' }
- PAID / UNPAID+active / unknown → { type: 'skip' }
Used by the background sweep check-phase and catch handler.
Tests (12 new, 299 total):
- decideMintSettlement: complete vs defer for both sources, LNURL
description propagation, partial persistence deferral
- decideSweepAction: ISSUED with stash recovery flag, PAID skip,
UNPAID with past/future/no expiry, unknown states
…escription ISSUED stash recovery completeness: - recoverProofStashes() now returns the full RecoveryResult (or null) instead of a boolean, exposing proofsFailed count to callers. - Both ISSUED handlers in the sweep (check-phase and catch) now check result.proofsFailed > 0 and defer completion if stash recovery was incomplete. The tx stays pending for the next sweep cycle or restart. - Startup recovery caller adapted to check proofsRecovered > 0. LNURL description persistence: - Added optional 'description' field to PendingMintState. - LNURL-withdraw dialog now persists withdrawInfo.description in the pending state serialized to the memo field. - decideSweepAction() accepts description and includes it in the ISSUED memo (e.g. 'LNURL withdraw: My Service (already minted)'). - decideMintSettlement() already accepted description — sweep PAID path now passes state.description through. - New test: ISSUED with LNURL description propagated to memo. 300 tests pass (25 files).
Extract handleIssuedQuote() and handlePaidSettlement() from the inline sweep logic into transaction-helpers.ts with dependency injection. The sweep in use-wallet.ts now calls these functions, passing wallet ops as deps. This makes the stash-recovery and partial-persistence branches testable without mocking React hooks or DWN. New test file: src/__tests__/sweep-settlement.test.ts (12 tests) handleIssuedQuote: - Completes when stash recovery succeeds with 0 failed proof writes - Defers when stash recovery has failed writes (proofsFailed > 0) - Completes when recoverProofStashes returns null (no repo) - Completes when needsStashRecovery is false (skips recovery) - Skips refreshProofs when no proofs were actually recovered handlePaidSettlement: - Completes with correct total/memo when fully persisted - Defers when safeStoreReceivedProofs returns false (partial) - Still persists proofs even when DLEQ verification fails - Includes LNURL description in memo - Uses generic LNURL memo when no description Integration pipeline (decideSweepAction → handleIssuedQuote): - ISSUED + successful stash recovery → completed - ISSUED + partial stash recovery → deferred 312 tests pass (26 files).
…NURL memos - ISSUED handler in the sweep check-phase now acquires tryAcquireWalletLock before calling handleIssuedQuote (which runs recoverProofStashes → addProof). Previously stash recovery ran unlocked and could overlap with concurrent sweep iterations or other wallet mutations, risking duplicate proof writes. The PAID handler was already locked; the catch-handler ISSUED path also runs inside the existing lock scope. - Added sweepInFlight boolean guard: setInterval callbacks skip if the previous iteration is still running. Prevents overlapping sweep passes that could race on the same quotes or stash records. - Startup recovery (resumePendingReceives) now uses source-aware memos with PendingMintState.description for both minted and ISSUED cases. 'Recovered lightning receive' → 'Lightning receive' or 'LNURL withdraw: My Service'. ISSUED path similarly updated.
Added startupRecoveryComplete ref that is set to true (in finally) after startupRecoveryRef.current() finishes. The background invoice sweep checks this flag at the top of each iteration and returns early if startup recovery hasn't completed yet. This prevents the sweep's ISSUED stash-recovery path from overlapping with startup recovery's recoverProofStashes() call. Both paths dedupe against a point-in-time secret snapshot before writing proofs — if they run concurrently, both can decide a proof is 'missing' and write it twice, distorting local proof/balance state. The existing in-flight guard (sweepInFlight) prevents sweep-vs-sweep overlap. This new guard prevents sweep-vs-startup overlap. Together they ensure recoverProofStashes() is never called concurrently from the sweep.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
invoice,quoteId, andexpiresAt. Activity list shows "awaiting payment" badge with Show QR action for active invoices, "expired" badge with delete action for expired ones. Completed deposits are immutable (no QR, no delete).Changes
cashu-wallet-protocol.tsinvoice,quoteId,expiresAttoTransactionDatause-wallet.tsupdateTransaction,deleteTransaction,reconcilePendingInvoiceson startup; updatedrefreshTransactionsandaddTransactionfor new fieldsreceive-dialog.tsxdeposit-dialog.tsxtransaction-list-card.tsxtransaction-history.tsxApp.tsxInvoiceQrDialog, wiredupdateTransaction/deleteTransaction/handleShowInvoiceQr/handleDeleteTransaction, scanner integration from receive dialogBehavior matrix