Skip to content

Transfers aborted mid-claim strand the carve outputs (funds desync) #6

Closed

Description

@tompro

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

  1. Commit the carve before the wait. The engine gains an onCarve/checkpoint callback (or the flow is split into prepareTransfer → 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.
  2. 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.noteSecret is memory-only too, documented trade-off).
  3. 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

Activity

  1. tompro commented on Aug 31, 2026

    @tompro
    OwnerAuthor

    Fixed in #8: carves now commit through an onCarve checkpoint 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 NWC pay_invoice early-commit threading remain as possible follow-ups.

  2. added a commit that references this issue on Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions