From 4bb408c4a9098f42eb655ebd5e99570302d93a4f Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Mon, 28 Sep 2026 21:31:53 +0200 Subject: [PATCH] Fix proof selection algo performance --- __tests__/exactMatchSelection.test.ts | 93 +++++++++++++++++++++++++++ src/services/cashu/cashuUtils.ts | 51 +++++---------- 2 files changed, 110 insertions(+), 34 deletions(-) create mode 100644 __tests__/exactMatchSelection.test.ts diff --git a/__tests__/exactMatchSelection.test.ts b/__tests__/exactMatchSelection.test.ts new file mode 100644 index 00000000..9b84669e --- /dev/null +++ b/__tests__/exactMatchSelection.test.ts @@ -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) + }) +}) diff --git a/src/services/cashu/cashuUtils.ts b/src/services/cashu/cashuUtils.ts index c87d8cb4..d3c6d227 100644 --- a/src/services/cashu/cashuUtils.ts +++ b/src/services/cashu/cashuUtils.ts @@ -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(); // 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[] {