Fix proof selection algo performance

This commit is contained in:
minibits-cash
2026-09-28 21:31:53 +02:00
parent 641e904be6
commit 4bb408c4a9
2 changed files with 110 additions and 34 deletions
+93
View File
@@ -0,0 +1,93 @@
/**
* Exact-match proof selection (`CashuUtils.findExactMatch` / `getProofsToSend`).
*
* Regression coverage for a transfer that stalled ~50s on the JS thread and got
* the app killed by the iOS watchdog for memory: the former backtracker searched
* exhaustively when no exact match existed. Pins that the greedy replacement finds
* an exact match whenever one exists, and stays fast when none does.
*
* @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'
let secretSeq = 0
const mkProof = (amount: number): Proof =>
({id: '00aaaaaaaaaaaaaa', amount, secret: `secret-${secretSeq++}`, C: 'C', unit: 'sat'} as unknown as Proof)
const sum = (ps: Proof[]) => ps.reduce((s, p) => s + p.amount, 0)
// Seeded so a failure is reproducible.
let seed = 42
const rnd = () => (seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31
/** A wallet as mints issue it: each receipt split into powers of two, small change optionally thinned out. */
const mkWallet = (receipts: number, maxAmount: number, thinBelow = 0): Proof[] => {
const proofs: Proof[] = []
for (let r = 0; r < receipts; r++) {
const amount = 1 + Math.floor(rnd() * maxAmount)
for (let b = 0; b < 21; b++) {
const denom = 1 << b
if (amount & denom && !(denom < thinBelow && rnd() < 0.85)) proofs.push(mkProof(denom))
}
}
return proofs
}
/** Ground truth: can any subset sum exactly to target? */
const reachable = (proofs: Proof[], target: number) => {
const dp = new Uint8Array(target + 1)
dp[0] = 1
for (const p of proofs) for (let s = target; s >= p.amount; s--) if (dp[s - p.amount]) dp[s] = 1
return dp[target] === 1
}
describe('findExactMatch', () => {
test.each([
['plenty of change', 60, 20000, 0],
['sparse wallet', 5, 100000, 0],
['little small change', 40, 20000, 64],
])('finds an exact match whenever one exists — %s', (_, receipts, maxAmount, thinBelow) => {
for (let i = 0; i < 100; i++) {
const wallet = mkWallet(receipts, maxAmount, thinBelow)
// Half the targets are a real subset sum, half arbitrary.
const target = rnd() < 0.5
? sum(wallet.filter(() => rnd() < 0.3)) || 1
: 1 + Math.floor(rnd() * sum(wallet))
const match = CashuUtils.findExactMatch(target, wallet)
expect(match !== null).toBe(reachable(wallet, target))
if (match) expect(sum(match)).toBe(target)
}
})
test('does not reorder the caller\'s array', () => {
const wallet = [1, 8, 2, 4].map(mkProof)
const before = wallet.map(p => p.secret)
CashuUtils.findExactMatch(5, wallet)
expect(wallet.map(p => p.secret)).toEqual(before)
})
test('returns fast on a large wallet with no exact match', () => {
// 1000 proofs, no denomination below 2: an odd target is unreachable. The
// former backtracker took ~60s and ~700MB on this in desktop Node.
const wallet = Array.from({length: 1000}, (_, i) => mkProof(2 ** (1 + (i % 16))))
const start = Date.now()
expect(CashuUtils.findExactMatch(187415, wallet)).toBeNull()
const selected = CashuUtils.getProofsToSend(187415, wallet)
expect(Date.now() - start).toBeLessThan(200)
expect(sum(selected)).toBeGreaterThanOrEqual(187415)
})
})
+17 -34
View File
@@ -167,44 +167,27 @@ const getMintsFromToken = function (token: Token): string[] {
}
/*
* Largest-first greedy: take every proof that still fits. Exact for Cashu's
* power-of-two amounts — each amount divides every larger one, so if any subset
* sums to the target, this finds one. (It is also the first path the former
* backtracker explored; the rest of that search could never succeed and, on a
* large wallet with no exact match, ran for tens of seconds and exhausted memory.)
* With non-power-of-two amounts it may miss a match and return null, which only
* costs a swap via the findMinExcess fallback.
*/
const findExactMatch = function (requestedAmount: number, proofs: Proof[]): Proof[] | null {
const result: Proof[] = [];
const memo = new Set<string>(); // A set to store visited states
const MAX_DEPTH = 1000; // Set a reasonable recursion depth limit
const result: Proof[] = []
let remaining = requestedAmount
function backtrack(start: number, remaining: number, depth: number): boolean {
if (depth > MAX_DEPTH) {
log.error('[findExactMatch] Hit max algo depth')
return false; // Stop recursion if the depth limit is reached
}
if (remaining === 0) {
return true;
}
if (memo.has(`${start}-${remaining}`)) { // Check if we've already visited this state
log.trace('[findExactMatch] Same state cycle detected')
return false;
}
memo.add(`${start}-${remaining}`); // Mark the state as visited
for (let i = start; i < proofs.length; i++) {
if (proofs[i].amount > remaining) continue;
result.push(proofs[i]);
if (backtrack(i + 1, remaining - proofs[i].amount, depth + 1)) {
return true;
}
result.pop();
}
return false;
for (const proof of [...proofs].sort((a, b) => b.amount - a.amount)) {
if (remaining === 0) break
if (proof.amount > remaining) continue
result.push(proof)
remaining -= proof.amount
}
proofs.sort((a, b) => b.amount - a.amount);
if (backtrack(0, requestedAmount, 0)) {
return result;
}
return null;
return remaining === 0 ? result : null
}
const findMinExcess = function (requestedAmount: number, proofs: Proof[], preference: 'SMALL' | 'BIG' = 'SMALL'): Proof[] {