mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Fix fee estimation
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user