feat(funds): recover staged mutations

This commit is contained in:
2026-09-05 10:22:51 +02:00
parent a30c4bb2cc
commit 3876791a0b
22 changed files with 1838 additions and 969 deletions
+19 -25
View File
@@ -1,17 +1,10 @@
import {describe, expect, it} from 'vitest'
import {noteK1} from 'lnurlcash-kit'
import type {CarveResult} from './ops'
import {ensureExactAmount, receiveBearer} from './ops'
import {claimFromPreimage, prepareMint} from './ops/mint'
import {requiredValue} from './test-utils'
import {
makeBearer,
mint,
noteUrl,
retryingCbFetch,
secret,
settleLastInvoice,
} from './ops.testHarness'
import {makeBearer, mint, noteUrl, retryingCbFetch, secret} from './ops.testHarness'
// The redeem callback is a GET, and HTTP stacks retry GETs - so a mint
// can execute the first attempt of a mutation and refuse the byte-identical
@@ -24,7 +17,10 @@ describe('mutation retry refusals', () => {
const instance = await mint()
const k1 = secret('60')
const bearer = await makeBearer(instance, k1, 50_000)
const result = await ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()})
const result = await ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(),
onCarve: () => undefined,
})
// the split landed on the first (unseen) attempt: the input is burned
// and both outputs are live under the secrets the wallet carried
expect(instance.state.noteState(k1)).toBe('burned')
@@ -41,7 +37,10 @@ describe('mutation retry refusals', () => {
const instance = await mint()
const a = await makeBearer(instance, secret('61'), 20_000)
const b = await makeBearer(instance, secret('62'), 5_000)
const result = await ensureExactAmount([a, b], 25_000, {fetch: retryingCbFetch()})
const result = await ensureExactAmount([a, b], 25_000, {
fetch: retryingCbFetch(),
onCarve: () => undefined,
})
expect(result.consumed).toHaveLength(2)
expect(instance.state.noteState(secret('61'))).toBe('burned')
expect(instance.state.noteState(secret('62'))).toBe('burned')
@@ -50,19 +49,6 @@ describe('mutation retry refusals', () => {
expect(instance.state.noteState(mergedK1)).toBe('outstanding')
})
it('adopts the fresh secret when the claim rotate answer is a retry refusal', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
const preimage = await settleLastInvoice(instance)
const claimed = await claimFromPreimage(prepared, preimage, {fetch: retryingCbFetch()})
expect(claimed.rotated).toBe(true)
expect(claimed.note.verified).toBe(true)
expect(instance.state.noteState(preimage)).toBe('burned')
const k1 = requiredValue(noteK1(claimed.note.url))
expect(k1).not.toBe(preimage)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => {
const instance = await mint()
const senderK1 = secret('63')
@@ -83,8 +69,16 @@ describe('mutation retry refusals', () => {
const k1 = secret('64')
const bearer = await makeBearer(instance, k1, 50_000)
instance.state.settleMelt(k1) // burned elsewhere, before we ever asked
const checkpoints: CarveResult[] = []
await expect(
ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}),
ensureExactAmount([bearer], 25_000, {
fetch: retryingCbFetch(),
onCarve: (carve) => {
checkpoints.push(carve)
},
}),
).rejects.toThrow(/spent/i)
expect(checkpoints).toHaveLength(1)
expect(checkpoints[0]?.consumed).toEqual([])
})
})