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
+11
View File
@@ -6,6 +6,7 @@ import {fetchInvoiceVerification, fetchNoteInfo, withNewK1} from 'lnurlcash-kit'
import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit'
import {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit'
import type {NewBearer} from '../types'
import type {CarveResult} from './carve'
// a mutation's answer was lost AND the probe could not tell whether it
// landed - the possible outputs the fresh secrets would control, for the
@@ -31,6 +32,16 @@ export class PollAbortedError extends Error {
export type FundOperationOptions = LnurlcashOptions & {
readonly assertOwner?: () => void
// called by a carve the moment its mutation has LANDED server-side (the
// inputs are burned, the outputs are known), before the flow moves on to
// anything slow or uncertain - the caller's one chance to durably commit
// the changeset so an abort during a long settlement wait can never
// strand the outputs or leave burned inputs looking spendable. If it
// rejects, the flow stops BEFORE anything further is spent and the
// rejection propagates: the carve state is then landed-but-maybe-
// uncommitted, which the caller must surface loudly. Never called for a
// carve that mutated nothing.
readonly onCarve?: (carve: CarveResult) => void | Promise<void>
}
export const assertFundOwner = (options: FundOperationOptions): void => {