fix(lnurlcash): commit carves before the settlement wait (#6)

A flow that carves and then waits minutes on a settlement poll used to
commit the carve only after the whole flow resolved - an abort mid-wait
left burned inputs spendable locally and stranded the fresh outputs.
ensureExactAmount now reports a mutation-bearing carve through the new
FundOperationOptions.onCarve checkpoint the moment its outcome is known;
payWithBearers and transferBetweenMints thread it through.

Because the carve may now be committed before the flows' classification
rotates run, those rotations no longer rewrite carve.note in the result -
returned funds come back as a separate verified rotatedNote, so the
committed carve and the re-secured note can both be persisted. NWC's
payChangeset adapts to the rotatedNote field; its own commit timing is
unchanged (follow-up).
This commit is contained in:
2026-08-31 14:48:03 +02:00
parent f3d5cc4b31
commit 587dd11ba8
8 changed files with 204 additions and 32 deletions
+7 -6
View File
@@ -23,18 +23,19 @@ import {errResult, okResult} from './protocol'
// maps a PayResult onto the store delta, mirroring PayInvoiceDialog's
// semantics: carve inputs lock spent, change is tracked, returned funds
// are tracked UNMARKED, rescued secrets are tracked unverified. One
// are tracked UNMARKED, rescued secrets are tracked unverified. The
// returned funds live in `rotatedNote` when the classification rotate
// re-secured them (burning the carved note in the process); one
// deliberate divergence: in the exact-match + funds-returned case the
// original bearer (old k1, burned server-side by the classification
// rotate) is left for the next refresh to reconcile, exactly as the UI
// leaves it - the money itself sits in the re-secured note, which IS
// tracked.
// original bearer (old k1, burned server-side by that rotate) is left for
// the next refresh to reconcile, exactly as the UI leaves it - the money
// itself sits in the re-secured note, which IS tracked.
export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => {
const add: NewBearer[] = []
const markSpent: string[] = result.carve.consumed.map((b) => b.id)
if (result.carve.change) add.push(result.carve.change)
if (result.outcome === 'failed-funds-returned') {
add.push(result.carve.note)
add.push(result.rotatedNote ?? result.carve.note)
} else {
// settled / still-pending / already-spent: the carved note is gone or
// locked. When it was one of the wallet's own bearers (an exact-match