fix: fence carve operations before mutation

This commit is contained in:
2026-08-22 16:55:12 +02:00
parent 22420b9112
commit 6fa4283ed6
2 changed files with 154 additions and 47 deletions
+122
View File
@@ -0,0 +1,122 @@
import {describe, expect, it} from 'vitest'
import {fetchNoteInfo, noteK1} from 'lnurlcash-kit'
import type {Bearer} from './types'
import {UncertainOutcomeError, ensureExactAmount} from './ops'
import {requiredValue} from './test-utils'
import {makeBearer, mint, noteUrl, secret} from './ops.testHarness'
describe('ensureExactAmount', () => {
it('returns an already-exact note untouched, burning nothing', async () => {
const instance = await mint()
const k1 = secret('01')
const bearer = await makeBearer(instance, k1, 21_000)
const result = await ensureExactAmount([bearer], 21_000)
expect(noteK1(result.note.url)).toBe(k1)
expect(result.consumed).toEqual([])
expect(result.change).toBeUndefined()
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('split path: carves an exact note off a larger one, with change', async () => {
const instance = await mint()
const k1 = secret('02')
const bearer = await makeBearer(instance, k1, 21_000)
const result = await ensureExactAmount([bearer], 5_000)
expect(result.note.amount).toBe(5_000)
expect(result.note.verified).toBe(true)
expect(result.change?.amount).toBe(16_000)
expect(result.consumed.map((entry) => entry.id)).toEqual([bearer.id])
expect(instance.state.noteState(k1)).toBe('burned')
const partK1 = requiredValue(noteK1(result.note.url))
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000)
})
it('merge path: combines notes summing exactly to the target', async () => {
const instance = await mint()
const first = await makeBearer(instance, secret('03'), 3_000)
const second = await makeBearer(instance, secret('04'), 4_000)
const result = await ensureExactAmount([first, second], 7_000)
expect(result.note.amount).toBe(7_000)
expect(result.change).toBeUndefined()
expect(result.consumed).toHaveLength(2)
expect(instance.state.noteState(requiredValue(noteK1(first.url)))).toBe('burned')
expect(instance.state.noteState(requiredValue(noteK1(second.url)))).toBe('burned')
const mergedK1 = requiredValue(noteK1(result.note.url))
expect((await fetchNoteInfo(noteUrl(instance, mergedK1))).maxWithdrawable).toBe(7_000)
})
it('merge+split path: splits the target off several notes in one request', async () => {
const instance = await mint()
const first = await makeBearer(instance, secret('05'), 3_000)
const second = await makeBearer(instance, secret('06'), 4_000)
const result = await ensureExactAmount([first, second], 5_000)
expect(result.note.amount).toBe(5_000)
expect(result.change?.amount).toBe(2_000)
expect(result.consumed).toHaveLength(2)
const partK1 = requiredValue(noteK1(result.note.url))
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(2_000)
})
it('excludes spent and unverified notes from selection', async () => {
const instance = await mint()
const spentBearer = await makeBearer(instance, secret('07'), 50_000)
const unverified: Bearer = {
...(await makeBearer(instance, secret('08'), 50_000)),
callback: '',
}
await expect(
ensureExactAmount([{...spentBearer, spent: true}, unverified], 5_000),
).rejects.toThrow(/enough/)
})
it('refuses an amount no mint can cover', async () => {
const instance = await mint()
const bearer = await makeBearer(instance, secret('09'), 5_000)
await expect(ensureExactAmount([bearer], 50_000)).rejects.toThrow(/enough/)
})
it("rescues the fresh secrets when a split's answer is lost (probe: gone)", async () => {
const instance = await mint({dropAfterMutation: true})
const k1 = secret('10')
const bearer = await makeBearer(instance, k1, 21_000)
const result = await ensureExactAmount([bearer], 5_000)
const partK1 = requiredValue(noteK1(result.note.url))
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect(partK1).not.toBe(k1)
expect(instance.state.noteState(k1)).toBe('burned')
expect((await fetchNoteInfo(noteUrl(instance, partK1))).maxWithdrawable).toBe(5_000)
expect((await fetchNoteInfo(noteUrl(instance, changeK1))).maxWithdrawable).toBe(16_000)
})
it('surfaces the possible outputs when neither mutation nor probe can be confirmed', async () => {
const instance = await mint({dropAfterMutation: true})
const k1 = secret('11')
const bearer = await makeBearer(instance, k1, 21_000)
const probeKillingFetch: typeof fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (url.includes('/w/cb')) return fetch(input, init)
return Promise.reject(new Error('probe unreachable'))
}
const failure = await ensureExactAmount([bearer], 5_000, {
fetch: probeKillingFetch,
}).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(UncertainOutcomeError)
if (!(failure instanceof UncertainOutcomeError)) throw failure
expect(failure.possibleOutputs).toHaveLength(2)
const first = requiredValue(failure.possibleOutputs[0])
const second = requiredValue(failure.possibleOutputs[1])
expect(first.amount).toBe(5_000)
expect(second.amount).toBe(16_000)
expect(
(await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(first.url))))).maxWithdrawable,
).toBe(5_000)
expect(
(await fetchNoteInfo(noteUrl(instance, requiredValue(noteK1(second.url))))).maxWithdrawable,
).toBe(16_000)
})
})
+32 -47
View File
@@ -11,11 +11,11 @@ import {
serverOf, serverOf,
settleNote, settleNote,
splitNote, splitNote,
withNewK1 withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit'
import type {LnurlcashOptions} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types' import type {Bearer, NewBearer} from '../types'
import {UncertainOutcomeError} from './shared' import type {FundOperationOptions} from './shared'
import {assertFundOwner, UncertainOutcomeError} from './shared'
// the changeset stores apply after a mutation: `note`/`change` BEFORE // the changeset stores apply after a mutation: `note`/`change` BEFORE
// `consumed` - the mint call already burned every consumed input // `consumed` - the mint call already burned every consumed input
@@ -49,13 +49,13 @@ export type CarveResult = {
export const ensureExactAmount = async ( export const ensureExactAmount = async (
bearers: Bearer[], bearers: Bearer[],
amountMsat: number, amountMsat: number,
options: LnurlcashOptions = {} options: FundOperationOptions = {},
): Promise<CarveResult> => { ): Promise<CarveResult> => {
if (!Number.isInteger(amountMsat) || amountMsat <= 0) { if (!Number.isInteger(amountMsat) || amountMsat <= 0) {
throw new Error('Amount must be a positive whole number of msat.') throw new Error('Amount must be a positive whole number of msat.')
} }
const eligible = bearers.filter( const eligible = bearers.filter(
b => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url) (b) => !b.spent && b.callback !== '' && !b.deviceId && noteK1(b.url),
) )
// per-server greedy pick: smallest notes first until the target is // per-server greedy pick: smallest notes first until the target is
// covered (an exact single-note match short-circuits - no mutation at // covered (an exact single-note match short-circuits - no mutation at
@@ -68,19 +68,17 @@ export const ensureExactAmount = async (
let pick: Bearer[] | null = null let pick: Bearer[] | null = null
for (const group of byServer.values()) { for (const group of byServer.values()) {
const sorted = [...group].sort((a, b) => a.amount - b.amount) const sorted = [...group].sort((a, b) => a.amount - b.amount)
const exact = sorted.find(b => b.amount === amountMsat) const exact = sorted.find((b) => b.amount === amountMsat)
const candidate = exact ? [exact] : accumulate(sorted, amountMsat) const candidate = exact ? [exact] : accumulate(sorted, amountMsat)
if (!candidate) continue if (!candidate) continue
if (!pick || better(candidate, pick, amountMsat)) pick = candidate if (!pick || better(candidate, pick, amountMsat)) pick = candidate
} }
if (!pick) { if (!pick) {
throw new Error( throw new Error('No mint holds enough verified, unspent balance to cover that amount.')
'No mint holds enough verified, unspent balance to cover that amount.'
)
} }
const base = pick[0] const base = pick[0]
const total = pick.reduce((sum, b) => sum + b.amount, 0) const total = pick.reduce((sum, b) => sum + b.amount, 0)
const k1s = pick.map(b => requireNoteK1(b.url)) const k1s = pick.map((b) => requireNoteK1(b.url))
if (pick.length === 1 && total === amountMsat) { if (pick.length === 1 && total === amountMsat) {
// already exact - hand over the note itself, untouched // already exact - hand over the note itself, untouched
@@ -90,9 +88,9 @@ export const ensureExactAmount = async (
callback: base.callback, callback: base.callback,
amount: base.amount, amount: base.amount,
verified: base.verified, verified: base.verified,
mintPubkey: base.mintPubkey mintPubkey: base.mintPubkey,
}, },
consumed: [] consumed: [],
} }
} }
@@ -100,13 +98,14 @@ export const ensureExactAmount = async (
// merge path: many notes, exact sum - merge into one, then settle it // merge path: many notes, exact sum - merge into one, then settle it
// (true value + fresh secret; a failed settle leaves an unverified // (true value + fresh secret; a failed settle leaves an unverified
// note a refresh can repair, not a lost secret) // note a refresh can repair, not a lost secret)
assertFundOwner(options)
const merged = await mergeAmbiguitySafe(base, k1s, total, options) const merged = await mergeAmbiguitySafe(base, k1s, total, options)
const unverified: NewBearer = { const unverified: NewBearer = {
url: withNewK1(base.url, merged.k1, total, merged.signature), url: withNewK1(base.url, merged.k1, total, merged.signature),
callback: base.callback, callback: base.callback,
amount: total, amount: total,
verified: false, verified: false,
mintPubkey: base.mintPubkey mintPubkey: base.mintPubkey,
} }
// a merge whose answer was lost leaves the service in an unknown // a merge whose answer was lost leaves the service in an unknown
// state from here - settling fires another mutation (the rotate // state from here - settling fires another mutation (the rotate
@@ -115,27 +114,16 @@ export const ensureExactAmount = async (
// refresh repair. // refresh repair.
if (merged.rescued) return {note: unverified, consumed: pick} if (merged.rescued) return {note: unverified, consumed: pick}
try { try {
const settled = await settleNote( const settled = await settleNote(base.url, merged.k1, total, merged.signature, options)
base.url,
merged.k1,
total,
merged.signature,
options
)
return { return {
note: { note: {
url: withNewK1( url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
base.url,
settled.k1,
settled.amountMsat,
settled.signature
),
callback: settled.callback, callback: settled.callback,
amount: settled.amountMsat, amount: settled.amountMsat,
verified: true, verified: true,
mintPubkey: base.mintPubkey mintPubkey: base.mintPubkey,
}, },
consumed: pick consumed: pick,
} }
} catch { } catch {
return {note: unverified, consumed: pick} return {note: unverified, consumed: pick}
@@ -154,6 +142,7 @@ export const ensureExactAmount = async (
// state, so the change is NOT settled (that would fire another mutation // state, so the change is NOT settled (that would fire another mutation
// at it, whose own ambiguous failure would strand the rescued secret) // at it, whose own ambiguous failure would strand the rescued secret)
let rescued = false let rescued = false
assertFundOwner(options)
try { try {
const parts = await splitNote(base.callback, k1s, amountMsat, options) const parts = await splitNote(base.callback, k1s, amountMsat, options)
partK1 = parts.k1 partK1 = parts.k1
@@ -178,16 +167,16 @@ export const ensureExactAmount = async (
callback: base.callback, callback: base.callback,
amount: amountMsat, amount: amountMsat,
verified: false, verified: false,
mintPubkey: base.mintPubkey mintPubkey: base.mintPubkey,
}, },
{ {
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat), url: withNewK1(base.url, err.newSecrets[1], total - amountMsat),
callback: base.callback, callback: base.callback,
amount: total - amountMsat, amount: total - amountMsat,
verified: false, verified: false,
mintPubkey: base.mintPubkey mintPubkey: base.mintPubkey,
} },
] ],
) )
} }
// 'gone': the burn landed - the carried secrets are the only money // 'gone': the burn landed - the carried secrets are the only money
@@ -200,7 +189,7 @@ export const ensureExactAmount = async (
callback: base.callback, callback: base.callback,
amount: amountMsat, amount: amountMsat,
verified: partVerified, verified: partVerified,
mintPubkey: base.mintPubkey mintPubkey: base.mintPubkey,
} }
// settleNote: the change may be worth less than total - amount if this // settleNote: the change may be worth less than total - amount if this
// mint charges split fees (LUD-25 deducts them from change, never the // mint charges split fees (LUD-25 deducts them from change, never the
@@ -211,7 +200,7 @@ export const ensureExactAmount = async (
callback: base.callback, callback: base.callback,
amount: total - amountMsat, amount: total - amountMsat,
verified: false, verified: false,
mintPubkey: base.mintPubkey mintPubkey: base.mintPubkey,
} }
if (!rescued) { if (!rescued) {
try { try {
@@ -220,22 +209,18 @@ export const ensureExactAmount = async (
changeK1, changeK1,
total - amountMsat, total - amountMsat,
changeSignature, changeSignature,
options options,
) )
change = { change = {
url: withNewK1( url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature),
base.url,
settled.k1,
settled.amountMsat,
settled.signature
),
callback: settled.callback, callback: settled.callback,
amount: settled.amountMsat, amount: settled.amountMsat,
verified: true, verified: true,
mintPubkey: base.mintPubkey mintPubkey: base.mintPubkey,
} }
} catch { } catch (error) {
// settle is best-effort - the unverified change above is still tracked // settle is best-effort - the unverified change above is still tracked
if (!(error instanceof Error)) throw error
} }
} }
return {note, change, consumed: pick} return {note, change, consumed: pick}
@@ -270,7 +255,7 @@ const mergeAmbiguitySafe = async (
base: Bearer, base: Bearer,
k1s: string[], k1s: string[],
total: number, total: number,
options: LnurlcashOptions options: FundOperationOptions,
): Promise<{k1: string; signature?: string; rescued: boolean}> => { ): Promise<{k1: string; signature?: string; rescued: boolean}> => {
try { try {
const merged = await mergeNotes(base.callback, k1s, options) const merged = await mergeNotes(base.callback, k1s, options)
@@ -288,9 +273,9 @@ const mergeAmbiguitySafe = async (
callback: base.callback, callback: base.callback,
amount: total, amount: total,
verified: false, verified: false,
mintPubkey: base.mintPubkey mintPubkey: base.mintPubkey,
} },
] ],
) )
} }
// 'gone': the burn landed - the carried secret is the only money left // 'gone': the burn landed - the carried secret is the only money left