diff --git a/__tests__/feeReserveSelection.test.ts b/__tests__/feeReserveSelection.test.ts new file mode 100644 index 00000000..c645ed3c --- /dev/null +++ b/__tests__/feeReserveSelection.test.ts @@ -0,0 +1,199 @@ +/** + * Fixed-point proof selection for fee-bearing operations + * (`CashuUtils.selectProofsToSendWithFeeReserve`). + * + * Regression coverage for the melt/swap underfunding bug: a mint charges a + * per-proof input fee that grows with the NUMBER of proofs spent. Selecting + * proofs for a larger, fee-inclusive target can pull in more proofs, raising + * the fee again — so a SINGLE fee recompute can leave the inputs short and the + * mint rejects with "not enough inputs provided for melt" (melt) / + * "Not enough funds available for swap" (send). + * + * Both `TransferOperationApi.prepare` (melt) and `SendOperationApi.prepare` + * (swap) route their input selection through this helper, so these tests pin + * the shared invariant that previously broke in production. + * + * @jest-environment node + */ + +jest.mock('../src/services/logService', () => ({ + log: { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), + }, +})) + +jest.mock('../src/services/nostrService', () => ({ + NostrClient: {getFirstTagValue: jest.fn()}, +})) + +import {CashuUtils} from '../src/services/cashu/cashuUtils' +import {Proof} from '../src/models/Proof' + +// ── Test fixtures ──────────────────────────────────────────────────────────── + +let secretSeq = 0 +const mkProof = (amount: number): Proof => + ({ + id: '00aaaaaaaaaaaaaa', + amount, + secret: `secret-${secretSeq++}`, + C: 'C', + unit: 'sat', + } as unknown as Proof) + +/** `count` proofs each worth `denom` sats. */ +const mkProofs = (denom: number, count: number): Proof[] => + Array.from({length: count}, () => mkProof(denom)) + +/** + * Mint fee as a function of proof COUNT, mirroring NUT-02 + * `fee = ceil(count * fee_ppk / 1000)`. + */ +const feeForPpk = (ppk: number) => (proofs: Proof[]): number => + Math.ceil((proofs.length * ppk) / 1000) + +/** + * The OLD (buggy) logic: select for the target, compute the fee once, re-select + * for target+fee, and stop — never re-checking the fee of the re-selected set. + * Used to prove the scenario genuinely underfunds before the fix. + */ +const naiveSelect = ( + targetAmount: number, + proofs: Proof[], + getFees: (p: Proof[]) => number, +): {proofsToSend: Proof[]; feeReserve: number} => { + let selected = CashuUtils.getProofsToSend(targetAmount, proofs) + const feeReserve = getFees(selected) + if (feeReserve > 0) { + selected = CashuUtils.getProofsToSend(targetAmount + feeReserve, proofs) + } + return {proofsToSend: selected, feeReserve} +} + +const sum = (proofs: Proof[]) => CashuUtils.getProofsAmount(proofs) + +beforeEach(() => { + secretSeq = 0 +}) + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('selectProofsToSendWithFeeReserve', () => { + test('no fee → returns the first selection unchanged', () => { + const proofs = mkProofs(1, 130) + const {proofsToSend, feeReserve} = CashuUtils.selectProofsToSendWithFeeReserve( + 100, + proofs, + () => 0, + ) + expect(feeReserve).toBe(0) + expect(sum(proofsToSend)).toBe(100) + }) + + test('converges so the selected set covers its OWN input fee', () => { + // 1-sat proofs ⇒ getProofsToSend(N) selects exactly N proofs. With + // fee = ceil(count / 50) the fee climbs as more proofs are pulled in, + // requiring more than one recompute (100 → 102 → 103). + const proofs = mkProofs(1, 130) + const getFees = (p: Proof[]) => Math.ceil(p.length / 50) + + const {proofsToSend, feeReserve} = CashuUtils.selectProofsToSendWithFeeReserve( + 100, + proofs, + getFees, + ) + + // The core invariant the mint enforces. + expect(sum(proofsToSend)).toBeGreaterThanOrEqual(100 + getFees(proofsToSend)) + // Reported reserve matches the true fee of the returned set. + expect(feeReserve).toBe(getFees(proofsToSend)) + // Specifically: 103 proofs, fee 3 (100 + 3). + expect(proofsToSend.length).toBe(103) + expect(feeReserve).toBe(3) + }) + + test('the old single-recompute logic underfunded the same scenario', () => { + // Demonstrates the bug the fix addresses: the naive path stops one proof + // short of covering the input fee of the set it actually selected. + const proofs = mkProofs(1, 130) + const getFees = (p: Proof[]) => Math.ceil(p.length / 50) + + const naive = naiveSelect(100, proofs, getFees) + // Naive selected 102 proofs, whose true fee is 3 → needs 103 but provides + // only 102. This is exactly the "provided X, needed X+1" mint rejection. + expect(naive.proofsToSend.length).toBe(102) + expect(sum(naive.proofsToSend)).toBeLessThan(100 + getFees(naive.proofsToSend)) + + // The fixed helper does NOT underfund. + const fixed = CashuUtils.selectProofsToSendWithFeeReserve(100, proofs, getFees) + expect(sum(fixed.proofsToSend)).toBeGreaterThanOrEqual(100 + getFees(fixed.proofsToSend)) + }) + + test('reproduces the "provided 103, needed 104" off-by-one at fee_ppk=1000', () => { + // fee_ppk = 1000 ⇒ fee = proof count. A first selection of 3 one-sat proofs + // for amount=100 would never reach 100, so use a realistic denomination mix + // and assert the converged invariant directly (selection internals aside). + const proofs = [ + ...mkProofs(64, 1), + ...mkProofs(32, 1), + ...mkProofs(16, 1), + ...mkProofs(8, 1), + ...mkProofs(4, 1), + ...mkProofs(2, 2), + ...mkProofs(1, 6), + ] + const getFees = feeForPpk(1000) + + const {proofsToSend, feeReserve} = CashuUtils.selectProofsToSendWithFeeReserve( + 100, + proofs, + getFees, + ) + + expect(sum(proofsToSend)).toBeGreaterThanOrEqual(100 + feeReserve) + expect(feeReserve).toBe(getFees(proofsToSend)) + }) + + test('keeps the first selection when its overshoot already covers the fee', () => { + // Big denominations: selecting for 100 overshoots to 128, which already + // covers a tiny fee, so no extra proofs are pulled in. + const proofs = [...mkProofs(128, 1), ...mkProofs(64, 1), ...mkProofs(1, 10)] + const getFees = feeForPpk(1000) // fee = count (here at most a few) + + const {proofsToSend, feeReserve} = CashuUtils.selectProofsToSendWithFeeReserve( + 100, + proofs, + getFees, + ) + expect(sum(proofsToSend)).toBeGreaterThanOrEqual(100 + feeReserve) + }) + + test('throws VALIDATION_ERROR when funds cannot cover amount + converged fee', () => { + // Exactly 100 sats available but a non-zero fee is required on top. + const proofs = mkProofs(1, 100) + const getFees = (p: Proof[]) => Math.ceil(p.length / 50) // ≥1 once near 100 + + expect(() => + CashuUtils.selectProofsToSendWithFeeReserve(100, proofs, getFees, { + caller: 'unit-test', + }), + ).toThrow(/not enough funds/i) + }) + + test('respects the iteration guard instead of looping forever', () => { + // A pathological fee that always demands one more than is available would + // loop indefinitely without the guard; here it must terminate by throwing. + const proofs = mkProofs(1, 200) + const everGrowingFee = (p: Proof[]) => p.length // fee == count, never catches up + + expect(() => + CashuUtils.selectProofsToSendWithFeeReserve(100, proofs, everGrowingFee, { + maxIterations: 8, + }), + ).toThrow(/not enough funds/i) + }) +}) diff --git a/src/services/cashu/cashuUtils.ts b/src/services/cashu/cashuUtils.ts index 4b423c0e..be343a70 100644 --- a/src/services/cashu/cashuUtils.ts +++ b/src/services/cashu/cashuUtils.ts @@ -251,6 +251,70 @@ const getProofsToSend = function (requestedAmount: number, proofs: Proof[]): Pro return findMinExcess(requestedAmount, proofs) } +/** + * Select proofs that cover `targetAmount` PLUS the mint's per-proof input fee on + * the selected proofs themselves. + * + * A mint charges an input fee that grows with the NUMBER of proofs spent + * (NUT-02: `fee = ceil(Σ input_fee_ppk / 1000)`). Selecting proofs for a larger, + * fee-inclusive target can pull in additional proofs, which raises the fee, + * which raises the required amount again. A single fee recompute is therefore + * not enough — the second selection's true fee can exceed the budgeted reserve, + * leaving the inputs short. The mint then rejects with "not enough inputs + * provided for melt" (melt) or cashu-ts throws "Not enough funds available for + * swap" (send, called with `includeFees:false`). + * + * This iterates to a fixed point so the returned set always satisfies: + * + * sum(proofsToSend) >= targetAmount + getFeesForProofs(proofsToSend) + * + * @param targetAmount Amount that must remain AFTER the input fee (e.g. send + * amount, or melt `amount + lightning fee_reserve`). + * @param proofs Spendable proofs to select from. + * @param getFeesForProofs Mint fee for a given proof set (wraps + * `cashuWallet.getFeesForProofs`). + * @param options.maxIterations Convergence guard (default 32). + * @throws VALIDATION_ERROR if available proofs cannot cover the converged total. + */ +const selectProofsToSendWithFeeReserve = function ( + targetAmount: number, + proofs: Proof[], + getFeesForProofs: (selected: Proof[]) => number, + options?: {maxIterations?: number; caller?: string}, +): {proofsToSend: Proof[]; feeReserve: number} { + const maxIterations = options?.maxIterations ?? 32 + const caller = options?.caller ?? 'selectProofsToSendWithFeeReserve' + const totalAvailable = getProofsAmount(proofs) + + let proofsToSend = getProofsToSend(targetAmount, proofs) + let feeReserve = getFeesForProofs(proofsToSend) + let amountWithFees = targetAmount + feeReserve + + let guard = 0 + while (getProofsAmount(proofsToSend) < amountWithFees && guard++ < maxIterations) { + if (totalAvailable < amountWithFees) { + throw new AppError( + Err.VALIDATION_ERROR, + 'There is not enough funds to send this amount.', + {totalAvailable, amountWithFees, caller}, + ) + } + proofsToSend = getProofsToSend(amountWithFees, proofs) + feeReserve = getFeesForProofs(proofsToSend) + amountWithFees = targetAmount + feeReserve + } + + if (getProofsAmount(proofsToSend) < amountWithFees) { + throw new AppError( + Err.VALIDATION_ERROR, + 'There is not enough funds to send this amount.', + {totalAvailable, amountWithFees, caller}, + ) + } + + return {proofsToSend, feeReserve} +} + /** * removes a set of tokens from another set of tokens, and returns the remaining. @@ -671,6 +735,7 @@ export const CashuUtils = { findExactMatch, findMinExcess, getProofsToSend, + selectProofsToSendWithFeeReserve, exportProofs, getProofsSubset, verifyProofsDleqOrThrow, diff --git a/src/services/wallet/operations/sendOperationApi.ts b/src/services/wallet/operations/sendOperationApi.ts index 311dd29d..64961018 100644 --- a/src/services/wallet/operations/sendOperationApi.ts +++ b/src/services/wallet/operations/sendOperationApi.ts @@ -213,22 +213,30 @@ async function prepare(input: PrepareSendInput): Promise { const exactMatch = candidatesAmount === amount if (isP2PK || !exactMatch) { - // Swap needed + // Swap needed. Select proofs covering the send amount + the mint's + // per-proof input fee on the selected proofs. The helper iterates to + // a fixed point so the locked set always covers its own swap fee — + // without it, the fee computed on the first selection can be too low + // for the (larger) re-selected set, and cashu-ts (called with + // includeFees:false) then rejects with + // "Not enough funds available for swap". const walletInstance = await walletStore.getWallet(mintUrl, unit, {withSeed: true}) as CashuWallet - swapFeeReserve = walletInstance.getFeesForProofs(candidates).toNumber() - const amountWithFees = amount + swapFeeReserve - - if (totalAmountFromMint < amountWithFees) { + try { + ;({proofsToSend: candidates, feeReserve: swapFeeReserve} = + CashuUtils.selectProofsToSendWithFeeReserve( + amount, + proofsFromMint, + selected => walletInstance.getFeesForProofs(selected).toNumber(), + {caller: 'SendOperationApi.prepare'}, + )) + } catch (e: any) { throw new ValidationError('There is not enough funds to send this amount.', { totalAmountFromMint, - amountWithFees, transactionId: transaction.id, caller: 'SendOperationApi.prepare', + message: e.message, }) } - if (swapFeeReserve > 0) { - candidates = CashuUtils.getProofsToSend(amountWithFees, proofsFromMint) - } path = 'online-swap' proofsToLock = candidates } else { diff --git a/src/services/wallet/operations/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts index 2309d690..068968d3 100644 --- a/src/services/wallet/operations/transferOperationApi.ts +++ b/src/services/wallet/operations/transferOperationApi.ts @@ -207,29 +207,34 @@ async function prepare(input: PrepareTransferInput): Promise walletInstance.getFeesForProofs(selected).toNumber(), + {caller: 'TransferOperationApi.prepare'}, + )) + } catch (e: any) { throw new ValidationError('There is not enough funds to send this amount.', { totalAmountFromMint, - amountWithFees, transactionId, caller: 'TransferOperationApi.prepare', + message: e.message, }) } - if (meltFeeReserve > 0) { - proofsToMeltFrom = CashuUtils.getProofsToSend(amountWithFees, proofsFromMint) - proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom) - } + let amountWithFees = amount + lightningFeeReserve + meltFeeReserve + let proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom) // ── Preemptive swap path ──────────────────────────────────────────── // Inputs that overshoot needed amount by >20% get swapped for tighter @@ -238,7 +243,7 @@ async function prepare(input: PrepareTransferInput): Promise amountWithFees * 1.2) { + if (proofsToMeltFromAmount > amountWithFees * 1.1) { log.info( '[TransferOperationApi.prepare] proofsToMeltFromAmount overshoots amountWithFees by >20%, running preemptive swap', {proofsToMeltFromAmount, amountWithFees},