Repository navigation
Description
Problem
transferBetweenMints (src/lnurlcash/ops/transfer.ts) runs: carve at source → melt → observe target settlement (a poll of up to 2 minutes). The carve's changeset (consumed inputs marked spent + fresh note/change) is only committed by the caller (useMoveFundsPage.ts) after the whole flow resolves.
If the wait never resolves into a commit — the user closes the dialog, the tab crashes, the app is backgrounded and killed, or the promise is abandoned while polling — the carve outputs exist only in the pending promise's memory:
- the input note is burned at the source mint but still shows spendable locally → every later operation on it fails with "Invalid or already spent k1."
- the split/merge outputs are stranded: the mint holds notes keyed by secrets the wallet never persisted (only their hashes), so they are unspendable by anyone.
Observed in the wild: a transfer whose target mint's Lightning node was down hung in the claim poll; a retry re-carved the same input, and the mint's refusal desynced the wallet. (The retry-refusal half of that incident is fixed in #5 — this issue is the abort-before-commit half.)
The same exposure exists in any flow with a wait between mutation and commit — minting claims (claimMintedNote), NWC settleAndClaim is safe (it commits before recording settlement), pay flows bear checking.
Why it's hard
The protocol engine (src/lnurlcash/ops/) is deliberately pure: it returns changesets and never touches storage. Committing mid-flow breaks that contract — but the current design makes fund safety depend on the UI awaiting a multi-minute promise, which is not a thing a UI can guarantee.
Possible directions
- Commit the carve before the wait. The engine gains an
onCarve/checkpoint callback (or the flow is split intoprepareTransfer→awaitSettlement), so consumed inputs are marked spent and fresh outputs tracked before the long observation starts. The post-wait phase then only adds the claimed target note. - Resumable transfers. Persist pending transfers (invoice, claim material, carve result) durably, so a restart resumes or reconciles them — generalizes (1) and also covers crash-during-poll for minted receives (
PreparedMint.noteSecretis memory-only too, documented trade-off). - Refresh-driven reconcile. A startup/unlock pass that re-reads every bearer's server state and marks locally-live-but-server-burned notes spent — repairs the desync symptom but not the stranded outputs; complement, not fix.
(1) is the smallest change that removes the fund-loss window; (2) is the complete one.
Context
- Incident discussion and retry-refusal fix: PR feat: support LUD-25 named mint outputs (lnurl-mint v0.4.0) #5
- Engine purity contract:
src/lnurlcash/AGENTS.md - Related invariant: "An ambiguous mutation NEVER loses fresh secrets" — currently only holds while the flow promise is alive.
Activity
tompro commentedon Aug 31, 2026
Fixed in #8: carves now commit through an
onCarvecheckpoint the moment the mutation lands server-side, before any settlement wait — an abort mid-wait can no longer strand the outputs or desync the burned inputs. The ticket's direction 2 (resumable transfers) and the NWCpay_invoiceearly-commit threading remain as possible follow-ups.Merge pull request #8 from tompro/fix/carve-checkpoint