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
+18 -5
View File
@@ -1,6 +1,11 @@
// ensureExactAmount: carve an exact amount out of the held notes, merging
// and/or splitting as needed, into a single fresh note worth exactly the
// target - the operation every send and every melt starts from.
//
// A carve whose mutation landed reports itself through options.onCarve
// BEFORE returning, so a caller about to do something slow (a settlement
// wait) can durably commit first - a flow aborted mid-wait must never
// leave burned inputs looking spendable or strand the fresh outputs.
import {
AmbiguousMutationError,
@@ -81,6 +86,14 @@ export const ensureExactAmount = async (
const total = pick.reduce((sum, b) => sum + b.amount, 0)
const k1s = pick.map((b) => requireNoteK1(b.url))
// a carve that burned inputs reports itself the moment its outcome is
// known, before the caller's flow moves on to anything slow or
// uncertain - see FundOperationOptions.onCarve
const checkpoint = async (result: CarveResult): Promise<CarveResult> => {
await options.onCarve?.(result)
return result
}
if (pick.length === 1 && total === amountMsat) {
// already exact - hand over the note itself, untouched
return {
@@ -113,10 +126,10 @@ export const ensureExactAmount = async (
// inside settleNote) at it, whose own ambiguous failure would strand
// the rescued secret. Don't compound: return unverified and let a
// refresh repair.
if (merged.rescued) return {note: unverified, consumed: pick}
if (merged.rescued) return checkpoint({note: unverified, consumed: pick})
try {
const settled = await settleNote(base.url, merged.k1, total, merged.signature, options)
return {
return checkpoint({
note: {
url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
callback: settled.callback,
@@ -125,9 +138,9 @@ export const ensureExactAmount = async (
mintPubkey: base.mintPubkey,
},
consumed: pick,
}
})
} catch {
return {note: unverified, consumed: pick}
return checkpoint({note: unverified, consumed: pick})
}
}
@@ -264,7 +277,7 @@ export const ensureExactAmount = async (
if (!(error instanceof Error)) throw error
}
}
return {note, change, consumed: pick}
return checkpoint({note, change, consumed: pick})
}
// smallest-first accumulation until the target is covered; null when the
+27 -9
View File
@@ -41,6 +41,13 @@ export type PayResult = {
invoice: string
amountMsat: number
verifyUrl: string | null
// on failed-funds-returned, when the classification rotate succeeded:
// the returned funds re-secured at a fresh secret. Kept OUT of `carve`
// (which always describes the carve as committed by the onCarve
// checkpoint) so an early-committed carve and a post-wait rotation can
// both be persisted - the carved note the rotate burned must be marked
// spent, this note added
rotatedNote?: NewBearer
// a fresh secret rescued from an ambiguous rotate during outcome
// classification - the caller must track it unverified; if the rotate
// landed, this is the only copy of the (returned) funds
@@ -56,6 +63,10 @@ export type PayOptions = {
// kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions
assertOwner?: () => void
// carve checkpoint - committed before the melt and the settlement wait,
// so an abort mid-wait never strands the carve (see
// FundOperationOptions.onCarve)
onCarve?: FundOperationOptions['onCarve']
}
// A melt's resolved promise only means the payment is in flight; the
@@ -71,9 +82,13 @@ export type PayOptions = {
export const payWithBearers = async (
bearers: Bearer[],
input: string,
{amountMsat, poll = {}, kit = {}, assertOwner}: PayOptions = {},
{amountMsat, poll = {}, kit = {}, assertOwner, onCarve}: PayOptions = {},
): Promise<PayResult> => {
const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit
const options: FundOperationOptions = {
...kit,
...(assertOwner ? {assertOwner} : {}),
...(onCarve ? {onCarve} : {}),
}
let invoice: string
let amount: number
const trimmed = input.trim()
@@ -150,15 +165,18 @@ export const payWithBearers = async (
const rotated = await rotateNote(carve.note.callback, k1, options)
// the rotate succeeded, so the mint restored the note - and k1 had
// been on the wire since the melt, so the rotation doubles as the
// required re-securing of the returned funds
// required re-securing of the returned funds. Reported separately
// from `carve` (see PayResult.rotatedNote): the carve may already be
// committed, and its note is what this rotate burned
return {
outcome: 'failed-funds-returned',
carve: {
...carve,
note: {
...carve.note,
url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature),
},
carve,
rotatedNote: {
url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature),
callback: carve.note.callback,
amount,
verified: true,
...(carve.note.mintPubkey ? {mintPubkey: carve.note.mintPubkey} : {}),
},
invoice,
amountMsat: amount,
+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 => {
+28 -8
View File
@@ -108,6 +108,13 @@ export type TransferResult = {
targetServer: string
// the fresh target note, on 'settled'
mintedAtTarget?: ClaimedNote
// on failed-funds-returned, when the source-probe rotate succeeded: the
// returned funds re-secured at a fresh secret. Kept OUT of `carve`
// (which always describes the carve as committed by the onCarve
// checkpoint) so an early-committed carve and a post-wait rotation can
// both be persisted - the carved note the rotate burned must be marked
// spent, this note added
rotatedNote?: NewBearer
// present whenever the claim could still complete later
claimMaterial?: TransferClaimMaterial
// a fresh secret rescued from an ambiguous rotate while classifying the
@@ -121,6 +128,10 @@ export type TransferOptions = {
// kit transport overrides (fetch injection, timeouts)
kit?: LnurlcashOptions
assertOwner?: () => void
// carve checkpoint - committed before the melt and the target
// settlement wait, so an abort mid-wait never strands the carve (see
// FundOperationOptions.onCarve)
onCarve?: FundOperationOptions['onCarve']
}
// the claim retry material for a named target: the note at the wallet's
@@ -140,9 +151,13 @@ export const transferBetweenMints = async (
bearers: Bearer[],
amountMsat: number,
targetMint: string,
{poll = {}, kit = {}, assertOwner}: TransferOptions = {},
{poll = {}, kit = {}, assertOwner, onCarve}: TransferOptions = {},
): Promise<TransferResult> => {
const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit
const options: FundOperationOptions = {
...kit,
...(assertOwner ? {assertOwner} : {}),
...(onCarve ? {onCarve} : {}),
}
if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.')
}
@@ -331,15 +346,20 @@ export const transferBetweenMints = async (
// as the way back to the money if the invoice settles later
try {
const rotated = await rotateNote(carve.note.callback, k1, options)
// the rotate succeeded: the melt provably never landed, and the
// funds are back - re-secured, since the melt attempt put k1 on the
// wire. Reported separately from `carve` (see rotatedNote): the
// carve may already be committed, and its note is what this rotate
// burned
return {
...base,
outcome: 'failed-funds-returned',
carve: {
...carve,
note: {
...carve.note,
url: withNewK1(carve.note.url, rotated.k1, carve.note.amount, rotated.signature),
},
rotatedNote: {
url: withNewK1(carve.note.url, rotated.k1, carve.note.amount, rotated.signature),
callback: carve.note.callback,
amount: carve.note.amount,
verified: true,
...(carve.note.mintPubkey ? {mintPubkey: carve.note.mintPubkey} : {}),
},
}
} catch (err) {