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
+41
View File
@@ -2,6 +2,7 @@ import {describe, expect, it} from 'vitest'
import {fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import type {Bearer} from './types'
import type {CarveResult} from './ops'
import {UncertainOutcomeError, ensureExactAmount} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret} from './ops.testHarness'
@@ -119,4 +120,44 @@ describe('ensureExactAmount', () => {
(await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(second.url))))).maxWithdrawable,
).toBe(16_000)
})
it('reports a mutation-bearing carve through onCarve before returning', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('12'), 21_000)
const seen: CarveResult[] = []
const result = await ensureExactAmount([bearer], 5_000, {
onCarve: (carve) => {
seen.push(carve)
},
})
// the hook saw exactly the returned result, once
expect(seen).toHaveLength(1)
expect(seen[0]).toBe(result)
expect(result.consumed).toHaveLength(1)
})
it('never fires onCarve for an already-exact note - nothing was burned', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('13'), 21_000)
let called = false
const result = await ensureExactAmount([bearer], 21_000, {
onCarve: () => {
called = true
},
})
expect(called).toBe(false)
expect(result.consumed).toEqual([])
})
it('propagates an onCarve failure - the carve landed, the caller must surface it', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('14'), 21_000)
await expect(
ensureExactAmount([bearer], 5_000, {
onCarve: () => {
throw new Error('commit failed')
},
}),
).rejects.toThrow(/commit failed/)
})
})