mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
Fix proof selection algo performance
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user