From 6fa4283ed65cd3943beb4486aee8e876732b73c5 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:55:12 +0200 Subject: [PATCH] fix: fence carve operations before mutation --- src/lnurlcash/ops.carve.cases.ts | 122 +++++++++++++++++++++++++++++++ src/lnurlcash/ops/carve.ts | 79 ++++++++------------ 2 files changed, 154 insertions(+), 47 deletions(-) create mode 100644 src/lnurlcash/ops.carve.cases.ts diff --git a/src/lnurlcash/ops.carve.cases.ts b/src/lnurlcash/ops.carve.cases.ts new file mode 100644 index 0000000..bef3e74 --- /dev/null +++ b/src/lnurlcash/ops.carve.cases.ts @@ -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) + }) +}) diff --git a/src/lnurlcash/ops/carve.ts b/src/lnurlcash/ops/carve.ts index fb4429d..780cd30 100644 --- a/src/lnurlcash/ops/carve.ts +++ b/src/lnurlcash/ops/carve.ts @@ -11,11 +11,11 @@ import { serverOf, settleNote, splitNote, - withNewK1 + withNewK1, } from 'lnurlcash-kit' -import type {LnurlcashOptions} from 'lnurlcash-kit' 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 // `consumed` - the mint call already burned every consumed input @@ -49,13 +49,13 @@ export type CarveResult = { export const ensureExactAmount = async ( bearers: Bearer[], amountMsat: number, - options: LnurlcashOptions = {} + options: FundOperationOptions = {}, ): Promise => { if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') } 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 // covered (an exact single-note match short-circuits - no mutation at @@ -68,19 +68,17 @@ export const ensureExactAmount = async ( let pick: Bearer[] | null = null for (const group of byServer.values()) { 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) if (!candidate) continue if (!pick || better(candidate, pick, amountMsat)) pick = candidate } if (!pick) { - throw new Error( - 'No mint holds enough verified, unspent balance to cover that amount.' - ) + throw new Error('No mint holds enough verified, unspent balance to cover that amount.') } const base = pick[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) { // already exact - hand over the note itself, untouched @@ -90,9 +88,9 @@ export const ensureExactAmount = async ( callback: base.callback, amount: base.amount, 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 // (true value + fresh secret; a failed settle leaves an unverified // note a refresh can repair, not a lost secret) + assertFundOwner(options) const merged = await mergeAmbiguitySafe(base, k1s, total, options) const unverified: NewBearer = { url: withNewK1(base.url, merged.k1, total, merged.signature), callback: base.callback, amount: total, verified: false, - mintPubkey: base.mintPubkey + mintPubkey: base.mintPubkey, } // a merge whose answer was lost leaves the service in an unknown // state from here - settling fires another mutation (the rotate @@ -115,27 +114,16 @@ export const ensureExactAmount = async ( // refresh repair. if (merged.rescued) return {note: unverified, consumed: pick} try { - const settled = await settleNote( - base.url, - merged.k1, - total, - merged.signature, - options - ) + const settled = await settleNote(base.url, merged.k1, total, merged.signature, options) return { note: { - url: withNewK1( - base.url, - settled.k1, - settled.amountMsat, - settled.signature - ), + url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature), callback: settled.callback, amount: settled.amountMsat, verified: true, - mintPubkey: base.mintPubkey + mintPubkey: base.mintPubkey, }, - consumed: pick + consumed: pick, } } catch { 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 // at it, whose own ambiguous failure would strand the rescued secret) let rescued = false + assertFundOwner(options) try { const parts = await splitNote(base.callback, k1s, amountMsat, options) partK1 = parts.k1 @@ -178,16 +167,16 @@ export const ensureExactAmount = async ( callback: base.callback, amount: amountMsat, verified: false, - mintPubkey: base.mintPubkey + mintPubkey: base.mintPubkey, }, { url: withNewK1(base.url, err.newSecrets[1], total - amountMsat), callback: base.callback, amount: total - amountMsat, verified: false, - mintPubkey: base.mintPubkey - } - ] + mintPubkey: base.mintPubkey, + }, + ], ) } // 'gone': the burn landed - the carried secrets are the only money @@ -200,7 +189,7 @@ export const ensureExactAmount = async ( callback: base.callback, amount: amountMsat, verified: partVerified, - mintPubkey: base.mintPubkey + mintPubkey: base.mintPubkey, } // settleNote: the change may be worth less than total - amount if this // mint charges split fees (LUD-25 deducts them from change, never the @@ -211,7 +200,7 @@ export const ensureExactAmount = async ( callback: base.callback, amount: total - amountMsat, verified: false, - mintPubkey: base.mintPubkey + mintPubkey: base.mintPubkey, } if (!rescued) { try { @@ -220,22 +209,18 @@ export const ensureExactAmount = async ( changeK1, total - amountMsat, changeSignature, - options + options, ) change = { - url: withNewK1( - base.url, - settled.k1, - settled.amountMsat, - settled.signature - ), + url: withNewK1(base.url, settled.k1, settled.amountMsat, settled.signature), callback: settled.callback, amount: settled.amountMsat, verified: true, - mintPubkey: base.mintPubkey + mintPubkey: base.mintPubkey, } - } catch { + } catch (error) { // settle is best-effort - the unverified change above is still tracked + if (!(error instanceof Error)) throw error } } return {note, change, consumed: pick} @@ -270,7 +255,7 @@ const mergeAmbiguitySafe = async ( base: Bearer, k1s: string[], total: number, - options: LnurlcashOptions + options: FundOperationOptions, ): Promise<{k1: string; signature?: string; rescued: boolean}> => { try { const merged = await mergeNotes(base.callback, k1s, options) @@ -288,9 +273,9 @@ const mergeAmbiguitySafe = async ( callback: base.callback, amount: total, verified: false, - mintPubkey: base.mintPubkey - } - ] + mintPubkey: base.mintPubkey, + }, + ], ) } // 'gone': the burn landed - the carried secret is the only money left