mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
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:
@@ -3,6 +3,7 @@ import {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
||||
import {hashK1, noteK1} from 'lnurlcash-kit'
|
||||
|
||||
import {transferBetweenMints} from './ops'
|
||||
import type {CarveResult} from './ops'
|
||||
import {requiredValue} from './test-utils'
|
||||
import {expectBurned, makeBearer, mint, secret, settleWhenRequested} from './ops.testHarness'
|
||||
|
||||
@@ -128,10 +129,15 @@ describe('transferBetweenMints', () => {
|
||||
expect(result.outcome).toBe('failed-funds-returned')
|
||||
expect(result.mintedAtTarget).toBeUndefined()
|
||||
expect(source.state.noteState(k1)).toBe('burned')
|
||||
const returnedK1 = requiredValue(noteK1(result.carve.note.url))
|
||||
// the returned funds come back re-secured at the source-probe rotate's
|
||||
// fresh secret - carried as rotatedNote, NOT folded into the carve
|
||||
// (which an onCarve checkpoint may already have committed)
|
||||
const rotatedNote = requiredValue(result.rotatedNote)
|
||||
expect(rotatedNote.verified).toBe(true)
|
||||
const returnedK1 = requiredValue(noteK1(rotatedNote.url))
|
||||
expect(returnedK1).not.toBe(k1)
|
||||
expect(source.state.noteState(returnedK1)).toBe('outstanding')
|
||||
expect(result.carve.note.amount).toBe(21_000)
|
||||
expect(rotatedNote.amount).toBe(21_000)
|
||||
})
|
||||
|
||||
it('moves value to a NAMED target: the note lands at the wallet secret, no rotate', async () => {
|
||||
@@ -212,4 +218,43 @@ describe('transferBetweenMints', () => {
|
||||
expect(newK1).not.toBe(preimage)
|
||||
expect(target.state.noteState(newK1)).toBe('outstanding')
|
||||
})
|
||||
|
||||
it('fires onCarve after the carve landed and before the melt starts', async () => {
|
||||
const source = await mint()
|
||||
const target = await mint({testHooks: true})
|
||||
const bearer = await makeBearer(source, secret('51'), 21_000)
|
||||
const seen: CarveResult[] = []
|
||||
const pending = transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
|
||||
poll: fastPoll,
|
||||
onCarve: (carve) => {
|
||||
seen.push(carve)
|
||||
// at hook time the melt must NOT have started: the carved note is
|
||||
// still outstanding (a melt would leave it pending)
|
||||
expect(source.state.noteState(requiredValue(noteK1(carve.note.url)))).toBe('outstanding')
|
||||
},
|
||||
})
|
||||
await settleWhenRequested(target)
|
||||
const result = await pending
|
||||
expect(result.outcome).toBe('settled')
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]?.consumed.map((entry) => entry.id)).toEqual([bearer.id])
|
||||
})
|
||||
|
||||
it('aborts before the melt when the carve commit hook fails', async () => {
|
||||
const source = await mint()
|
||||
const target = await mint({testHooks: true})
|
||||
const k1 = secret('52')
|
||||
const bearer = await makeBearer(source, k1, 21_000)
|
||||
await expect(
|
||||
transferBetweenMints([bearer], 10_000, `mint@127.0.0.1:${target.port}`, {
|
||||
onCarve: () => {
|
||||
throw new Error('commit failed')
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/commit failed/)
|
||||
// the split landed (the input is burned) but the melt never happened:
|
||||
// nothing sits pending at the source, nothing was ever paid
|
||||
expect(source.state.noteState(k1)).toBe('burned')
|
||||
expect([...source.state.notes.values()].every((note) => note.state !== 'pending')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user