Fix fee estimation

This commit is contained in:
minibits-cash
2026-06-27 23:55:40 +02:00
parent 74c1b77765
commit 81699251aa
4 changed files with 301 additions and 24 deletions
+199
View File
@@ -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)
})
})
+65
View File
@@ -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,
@@ -213,22 +213,30 @@ async function prepare(input: PrepareSendInput): Promise<PreparedSendData> {
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 {
@@ -207,29 +207,34 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
const proofsFromMint = proofsStore.getByMint(mintUrl, {state: 'UNSPENT', unit})
const totalAmountFromMint = CashuUtils.getProofsAmount(proofsFromMint)
let proofsToMeltFrom = CashuUtils.getProofsToSend(
const walletInstance = (await walletStore.getWallet(mintUrl, unit, {withSeed: true})) as CashuWallet
// Select proofs covering amount + lightning fee_reserve + the mint's
// per-proof input fee on the selected proofs. The helper iterates to a fixed
// point so the inputs always cover their own input fee — without it, the fee
// computed on the first selection can be too low for the (larger) re-selected
// set and the mint rejects with "not enough inputs provided for melt".
let proofsToMeltFrom: Proof[]
let meltFeeReserve: number
try {
;({proofsToSend: proofsToMeltFrom, feeReserve: meltFeeReserve} =
CashuUtils.selectProofsToSendWithFeeReserve(
amount + lightningFeeReserve,
proofsFromMint,
)
let proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom)
const walletInstance = (await walletStore.getWallet(mintUrl, unit, {withSeed: true})) as CashuWallet
let meltFeeReserve = walletInstance.getFeesForProofs(proofsToMeltFrom).toNumber()
const amountWithFees = amount + lightningFeeReserve + meltFeeReserve
if (totalAmountFromMint < amountWithFees) {
selected => 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<PreparedTransferDat
let path: TransferPath = 'direct-melt'
let preemptiveSwapFeePaid = 0
if (proofsToMeltFromAmount > amountWithFees * 1.2) {
if (proofsToMeltFromAmount > amountWithFees * 1.1) {
log.info(
'[TransferOperationApi.prepare] proofsToMeltFromAmount overshoots amountWithFees by >20%, running preemptive swap',
{proofsToMeltFromAmount, amountWithFees},