Skip to content

fix: receive screen redesign + pending invoice tracking#39

Merged
LiranCohen merged 18 commits into
masterfrom
fix/receive-screen-and-pending-invoices
Apr 10, 2026
Merged

fix: receive screen redesign + pending invoice tracking#39
LiranCohen merged 18 commits into
masterfrom
fix/receive-screen-and-pending-invoices

Conversation

@LiranCohen

Copy link
Copy Markdown
Contributor

Summary

  • Receive screen camera placeholder: The Lightning tab now shows a camera/scanner viewfinder area where the QR code will appear. Tapping opens the scanner; once an invoice is generated the QR replaces it and the camera aspect disappears.
  • Pending invoice lifecycle: Lightning invoices (both receive and deposit) are immediately persisted as pending transactions with invoice, quoteId, and expiresAt. 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).
  • Startup reconciliation: On app load, pending invoices are checked against the mint — paid ones get tokens minted and marked completed, already-issued ones are marked completed, active ones remain for the user to re-share.

Changes

File What changed
cashu-wallet-protocol.ts Added invoice, quoteId, expiresAt to TransactionData
use-wallet.ts Added updateTransaction, deleteTransaction, reconcilePendingInvoices on startup; updated refreshTransactions and addTransaction for new fields
receive-dialog.tsx Camera placeholder in Lightning tab; creates pending tx on invoice creation; updates tx on payment/expiry
deposit-dialog.tsx Same pending tx pattern as receive
transaction-list-card.tsx Pending/expired invoice badges, Show QR and Delete actions with proper guards
transaction-history.tsx Consistent pending/expired badges
App.tsx InvoiceQrDialog, wired updateTransaction/deleteTransaction/handleShowInvoiceQr/handleDeleteTransaction, scanner integration from receive dialog

Behavior matrix

State Show QR Delete Amount color
Pending (active) Yes No Warning
Pending (expired) No Yes Muted
Completed No No Success

@LiranCohen LiranCohen force-pushed the fix/receive-screen-and-pending-invoices branch from d868eb7 to ea856a6 Compare April 9, 2026 23:15
- 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
@LiranCohen LiranCohen force-pushed the fix/receive-screen-and-pending-invoices branch from ea856a6 to a3f9876 Compare April 9, 2026 23:18
…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.
@LiranCohen LiranCohen merged commit 95acebd into master Apr 10, 2026
3 checks passed
@LiranCohen LiranCohen deleted the fix/receive-screen-and-pending-invoices branch April 10, 2026 04:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant