taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit e69d25477800eb11eabdd7acafdbe4ce188e9e97
parent b13a53c32576ebffdd9fa21496d2d175bdc3b1d9
Author: Florian Dold <dold@taler.net>
Date:   Mon, 10 Aug 2026 02:15:26 +0200

wallet-core: implement DD91 coin selection

Diffstat:
Mpackages/taler-wallet-core/src/coinSelection.test.ts | 309++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/coinSelection.ts | 484++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
2 files changed, 722 insertions(+), 71 deletions(-)

diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -115,9 +115,9 @@ test("p2p: should select 3 coins", (t) => { denomPubHash: "hash0", maxAge: 32, contributions: [ + Amounts.parseOrThrow("LOCAL:0.3"), Amounts.parseOrThrow("LOCAL:10"), Amounts.parseOrThrow("LOCAL:10"), - Amounts.parseOrThrow("LOCAL:0.3"), ], }, }); @@ -511,18 +511,321 @@ test("overpay when remaining < depositFee", (t) => { exchangeMasterPub: "123", denomPubHash: "hash0", maxAge: 32, - contributions: [Amounts.parseOrThrow("LOCAL:1.1")], + contributions: [Amounts.parseOrThrow("LOCAL:2")], + }, + "hash1;32;http://exchange.localhost/": { + exchangeBaseUrl: "http://exchange.localhost/", + exchangeMasterPub: "123", + denomPubHash: "hash1", + maxAge: 32, + contributions: [Amounts.parseOrThrow("LOCAL:0.1")], + }, + }); +}); + +test("DD91 removes redundant small coins after ascending selection", (t) => { + const tally = emptyTallyForPeerPayment({ + instructedAmount: Amounts.parseOrThrow("LOCAL:6"), + }); + const coins = testing_selectGreedy( + { wireFeesPerExchange: {} }, + createCandidates([ + { + amount: "LOCAL:8" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + { + amount: "LOCAL:1" as AmountString, + numAvailable: 4, + depositFee: "LOCAL:0" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + ]), + tally, + ); + + assert.deepStrictEqual(coins, { + "hash0;32;http://exchange.localhost/": { + exchangeBaseUrl: "http://exchange.localhost/", + exchangeMasterPub: "123", + denomPubHash: "hash0", + maxAge: 32, + contributions: [Amounts.parseOrThrow("LOCAL:6")], + }, + }); +}); + +test("DD91 pruning remains exact when selected values exceed the amount limit", (t) => { + const tally = emptyTallyForPeerPayment({ + instructedAmount: Amounts.parseOrThrow("LOCAL:4503599627370496"), + }); + const coins = testing_selectGreedy( + { wireFeesPerExchange: {} }, + createCandidates([ + { + amount: "LOCAL:4503599627370496" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + { + amount: "LOCAL:2251799813685248" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + ]), + tally, + ); + + assert.deepStrictEqual(Object.keys(coins ?? {}), [ + "hash0;32;http://exchange.localhost/", + ]); +}); + +test("DD91 prefers the denomination that expires first", (t) => { + const tally = emptyTallyForPeerPayment({ + instructedAmount: Amounts.parseOrThrow("LOCAL:1"), + }); + const candidates = createCandidates([ + { + amount: "LOCAL:1" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + { + amount: "LOCAL:1" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + ]); + candidates[0].stampExpireDeposit = AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ hours: 2 }), + ), + ); + candidates[1].stampExpireDeposit = inTheDistantFuture; + + const coins = testing_selectGreedy( + { wireFeesPerExchange: {} }, + candidates, + tally, + ); + + assert.deepStrictEqual(Object.keys(coins ?? {}), [ + "hash1;32;http://exchange.localhost/", + ]); +}); + +test("DD91 replaces small coins when that avoids customer fees", (t) => { + const tally = emptyTallyForPeerPayment({ + instructedAmount: Amounts.parseOrThrow("LOCAL:3.7"), + }); + tally.amountDepositFeeLimitRemaining = Amounts.parseOrThrow("LOCAL:0.1"); + const coins = testing_selectGreedy( + { wireFeesPerExchange: {} }, + createCandidates([ + { + amount: "LOCAL:4" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0.1" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + { + amount: "LOCAL:1" as AmountString, + numAvailable: 4, + depositFee: "LOCAL:0.1" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + ]), + tally, + ); + + assert.deepStrictEqual(coins, { + "hash0;32;http://exchange.localhost/": { + exchangeBaseUrl: "http://exchange.localhost/", + exchangeMasterPub: "123", + denomPubHash: "hash0", + maxAge: 32, + contributions: [Amounts.parseOrThrow("LOCAL:3.7")], }, + }); + assert.deepStrictEqual( + tally.customerDepositFees, + Amounts.parseOrThrow("LOCAL:0"), + ); + assert.deepStrictEqual( + tally.totalDepositFees, + Amounts.parseOrThrow("LOCAL:0.1"), + ); +}); + +test("DD91 prunes coins made redundant by lower fees", (t) => { + const tally = emptyTallyForPeerPayment({ + instructedAmount: Amounts.parseOrThrow("LOCAL:3.8"), + }); + tally.amountDepositFeeLimitRemaining = Amounts.parseOrThrow("LOCAL:0.1"); + const coins = testing_selectGreedy( + { wireFeesPerExchange: {} }, + createCandidates([ + { + amount: "LOCAL:4" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0.1" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + { + amount: "LOCAL:1" as AmountString, + numAvailable: 4, + depositFee: "LOCAL:0.1" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + { + amount: "LOCAL:0.1" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0.1" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + ]), + tally, + ); + + assert.deepStrictEqual(coins, { + "hash0;32;http://exchange.localhost/": { + exchangeBaseUrl: "http://exchange.localhost/", + exchangeMasterPub: "123", + denomPubHash: "hash0", + maxAge: 32, + contributions: [Amounts.parseOrThrow("LOCAL:3.8")], + }, + }); +}); + +test("DD91 keeps spending an overabundant small denomination", (t) => { + const tally = emptyTallyForPeerPayment({ + instructedAmount: Amounts.parseOrThrow("LOCAL:3.7"), + }); + tally.amountDepositFeeLimitRemaining = Amounts.parseOrThrow("LOCAL:0.1"); + const coins = testing_selectGreedy( + { wireFeesPerExchange: {} }, + createCandidates([ + { + amount: "LOCAL:4" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0.1" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + { + amount: "LOCAL:1" as AmountString, + numAvailable: 21, + depositFee: "LOCAL:0.1" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + ]), + tally, + ); + + assert.deepStrictEqual(coins, { "hash1;32;http://exchange.localhost/": { exchangeBaseUrl: "http://exchange.localhost/", exchangeMasterPub: "123", denomPubHash: "hash1", maxAge: 32, - contributions: [Amounts.parseOrThrow("LOCAL:1")], + contributions: [ + Amounts.parseOrThrow("LOCAL:1"), + Amounts.parseOrThrow("LOCAL:1"), + Amounts.parseOrThrow("LOCAL:1"), + Amounts.parseOrThrow("LOCAL:1"), + ], }, }); }); +test("DD91 still optimizes at exactly the 5*F_D balance threshold", (t) => { + const tally = emptyTallyForPeerPayment({ + instructedAmount: Amounts.parseOrThrow("LOCAL:3.7"), + }); + tally.amountDepositFeeLimitRemaining = Amounts.parseOrThrow("LOCAL:0.1"); + const coins = testing_selectGreedy( + { wireFeesPerExchange: {} }, + createCandidates([ + { + amount: "LOCAL:4" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0.1" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + { + amount: "LOCAL:1" as AmountString, + numAvailable: 20, + depositFee: "LOCAL:0.1" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + ]), + tally, + ); + + assert.deepStrictEqual(Object.keys(coins ?? {}), [ + "hash0;32;http://exchange.localhost/", + ]); + assert.deepStrictEqual( + tally.customerDepositFees, + Amounts.parseOrThrow("LOCAL:0"), + ); +}); + +test("DD91 selection is independent of candidate input order", (t) => { + const candidates = createCandidates([ + { + amount: "LOCAL:4" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0.1" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + { + amount: "LOCAL:1" as AmountString, + numAvailable: 4, + depositFee: "LOCAL:0.1" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + ]); + const select = (orderedCandidates: AvailableCoinsOfDenom[]) => { + const tally = emptyTallyForPeerPayment({ + instructedAmount: Amounts.parseOrThrow("LOCAL:3.7"), + }); + tally.amountDepositFeeLimitRemaining = Amounts.parseOrThrow("LOCAL:0.1"); + return testing_selectGreedy( + { wireFeesPerExchange: {} }, + orderedCandidates, + tally, + ); + }; + + assert.deepStrictEqual(select(candidates), select([...candidates].reverse())); +}); + test("prefer exact denom", (t) => { const instructedAmount = Amounts.parseOrThrow("LOCAL:2"); const tally = emptyTallyForPeerPayment({ diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -779,93 +779,441 @@ function selectGreedyDefault( candidateDenoms: AvailableCoinsOfDenom[], tally: CoinSelectionTally, ): SelResult | undefined { - const selectedDenom: SelResult = {}; + const baseTally = cloneTally(tally); const currency = Amounts.currencyOf(tally.amountPayRemaining); - // Optimization: If we have a coin that exactly fits, use it. - for (let i = 0; i < candidateDenoms.length; i++) { - const denom = candidateDenoms[i]; - const maxCost = Amounts.add( + const candidates = candidateDenoms + .filter((denom) => { + const meta = getCandidateSelectionMeta(denom); + return denom.numAvailable > 0 && meta.depositFee <= meta.value; + }) + .sort(compareCandidateDenoms); + const selection: MutableCoinSelection = { + coins: [], + selectedCountByDenom: new Map(), + totals: { + value: 0n, + depositFees: 0n, + wireFees: 0n, + exchangeCoinCount: new Map(), + }, + }; + + // DD91 pass 1: add the smallest, earliest-expiring coins until the + // selection covers the payment and the fees not covered by the merchant. + addCoins: for (const denom of candidates) { + for (let i = 0; i < denom.numAvailable; i++) { + addSelectedCoin(selection, denom, req, baseTally); + if (selectionIsSufficient(selection.totals, baseTally)) { + break addCoins; + } + } + } + if (!selectionIsSufficient(selection.totals, baseTally)) { + return undefined; + } + + pruneSelection(selection, req, baseTally); + + reduceSelectionFees(selection, candidates, req, baseTally); + // Lower fees can leave another coin redundant even though replacements + // preserve the total denomination value. + pruneSelection(selection, req, baseTally); + + const finalCoins = selection.coins + .filter((coin) => coin.selected) + .sort((a, b) => compareCandidateDenoms(a.denom, b.denom)); + checkLogicInvariant( + finalCoins.length > 0 || Amounts.isZero(tally.amountPayRemaining), + ); + + // Account for all fees before assigning the change to the smallest coin. + // The selection is irreducible after pass 2, so the excess is smaller than + // that coin's value. + for (const coin of finalCoins) { + tallyFees( + tally, + req.wireFeesPerExchange, + coin.denom.exchangeBaseUrl, + Amounts.parseOrThrow(coin.denom.feeDeposit), + ); + } + const excess = amountFromUnits( + currency, + selection.totals.value - amountToUnits(tally.amountPayRemaining), + ); + if (finalCoins.length > 0) { + checkLogicInvariant(Amounts.cmp(excess, finalCoins[0].denom.value) < 0); + } + const selectedDenom: SelResult = {}; + for (let i = 0; i < finalCoins.length; i++) { + const denom = finalCoins[i].denom; + const contribution = + i === 0 + ? Amounts.sub(denom.value, excess).amount + : Amounts.parseOrThrow(denom.value); + applyContributions(selectedDenom, [contribution], denom); + tally.amountPayRemaining = Amounts.sub( tally.amountPayRemaining, - req.wireFeesPerExchange[denom.exchangeBaseUrl] ?? - Amounts.zeroOfCurrency(currency), - denom.feeDeposit, + contribution, ).amount; - if (denom.numAvailable <= 0) { + } + checkLogicInvariant(Amounts.isZero(tally.amountPayRemaining)); + return selectedDenom; +} + +interface SelectedCandidateCoin { + denom: AvailableCoinsOfDenom; + selected: boolean; +} + +interface SelectionTotals { + value: bigint; + depositFees: bigint; + wireFees: bigint; + exchangeCoinCount: Map<string, number>; +} + +interface MutableCoinSelection { + coins: SelectedCandidateCoin[]; + selectedCountByDenom: Map<AvailableCoinsOfDenom, number>; + totals: SelectionTotals; +} + +interface CandidateSelectionMeta { + value: bigint; + depositFee: bigint; + expireDeposit: AbsoluteTime; +} + +const candidateSelectionMeta = new WeakMap< + AvailableCoinsOfDenom, + CandidateSelectionMeta +>(); + +function getCandidateSelectionMeta( + denom: AvailableCoinsOfDenom, +): CandidateSelectionMeta { + let meta = candidateSelectionMeta.get(denom); + if (!meta) { + meta = { + value: amountToUnits(denom.value), + depositFee: amountToUnits(denom.feeDeposit), + expireDeposit: AbsoluteTime.fromProtocolTimestamp( + denom.stampExpireDeposit, + ), + }; + candidateSelectionMeta.set(denom, meta); + } + return meta; +} + +function compareBigInt(left: bigint, right: bigint): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +/** + * DD91 pass 2: consider removals from largest to smallest. Walking the + * ascending selection backwards also removes later-expiring coins first + * within one denomination. + */ +function pruneSelection( + selection: MutableCoinSelection, + req: SelectGreedyRequest, + baseTally: CoinSelectionTally, +): void { + for (let i = selection.coins.length - 1; i >= 0; i--) { + const coin = selection.coins[i]; + if (!coin.selected) { continue; } - if (Amounts.cmp(denom.value, tally.amountPayRemaining) < 0) { - break; + removeSelectedCoin(selection, coin, req, baseTally); + if (!selectionIsSufficient(selection.totals, baseTally)) { + restoreSelectedCoin(selection, coin, req, baseTally); } - if (Amounts.cmp(denom.value, maxCost) > 0) { - continue; + } +} + +function compareCandidateDenoms( + left: AvailableCoinsOfDenom, + right: AvailableCoinsOfDenom, +): number { + const leftMeta = getCandidateSelectionMeta(left); + const rightMeta = getCandidateSelectionMeta(right); + return ( + compareBigInt(leftMeta.value, rightMeta.value) || + AbsoluteTime.cmp(leftMeta.expireDeposit, rightMeta.expireDeposit) || + compareBigInt(leftMeta.depositFee, rightMeta.depositFee) || + strcmp(left.exchangeBaseUrl, right.exchangeBaseUrl) || + strcmp(left.denomPubHash, right.denomPubHash) || + left.maxAge - right.maxAge + ); +} + +function selectionCustomerFees( + totals: SelectionTotals, + baseTally: CoinSelectionTally, +): bigint { + const fees = totals.depositFees + totals.wireFees; + const allowance = amountToUnits(baseTally.amountDepositFeeLimitRemaining); + return fees > allowance ? fees - allowance : 0n; +} + +function selectionIsSufficient( + totals: SelectionTotals, + baseTally: CoinSelectionTally, +): boolean { + const required = + amountToUnits(baseTally.amountPayRemaining) + + selectionCustomerFees(totals, baseTally); + return totals.value >= required; +} + +function addSelectionTotals( + totals: SelectionTotals, + denom: AvailableCoinsOfDenom, + req: SelectGreedyRequest, + baseTally: CoinSelectionTally, +): void { + const meta = getCandidateSelectionMeta(denom); + totals.value += meta.value; + totals.depositFees += meta.depositFee; + const oldCount = totals.exchangeCoinCount.get(denom.exchangeBaseUrl) ?? 0; + if ( + oldCount === 0 && + !baseTally.wireFeeCoveredForExchange.has(denom.exchangeBaseUrl) + ) { + const wireFee = req.wireFeesPerExchange[denom.exchangeBaseUrl]; + totals.wireFees += wireFee ? amountToUnits(wireFee) : 0n; + } + totals.exchangeCoinCount.set(denom.exchangeBaseUrl, oldCount + 1); +} + +function removeSelectionTotals( + totals: SelectionTotals, + denom: AvailableCoinsOfDenom, + req: SelectGreedyRequest, + baseTally: CoinSelectionTally, +): void { + const meta = getCandidateSelectionMeta(denom); + totals.value -= meta.value; + totals.depositFees -= meta.depositFee; + const oldCount = totals.exchangeCoinCount.get(denom.exchangeBaseUrl) ?? 0; + checkLogicInvariant(oldCount > 0); + if (oldCount === 1) { + totals.exchangeCoinCount.delete(denom.exchangeBaseUrl); + if (!baseTally.wireFeeCoveredForExchange.has(denom.exchangeBaseUrl)) { + const wireFee = req.wireFeesPerExchange[denom.exchangeBaseUrl]; + totals.wireFees -= wireFee ? amountToUnits(wireFee) : 0n; } - const testTally = cloneTally(tally); - tallyFees( - testTally, - req.wireFeesPerExchange, - denom.exchangeBaseUrl, - Amounts.parseOrThrow(denom.feeDeposit), - ); - if (Amounts.cmp(testTally.amountPayRemaining, denom.value) == 0) { - // This time, don't modify testTally but the real tally. - tallyFees( - tally, - req.wireFeesPerExchange, - denom.exchangeBaseUrl, - Amounts.parseOrThrow(denom.feeDeposit), - ); - applyContributions( - selectedDenom, - [Amounts.parseOrThrow(denom.value)], - denom, - ); - return selectedDenom; + } else { + totals.exchangeCoinCount.set(denom.exchangeBaseUrl, oldCount - 1); + } +} + +function addSelectedCoin( + selection: MutableCoinSelection, + denom: AvailableCoinsOfDenom, + req: SelectGreedyRequest, + baseTally: CoinSelectionTally, +): SelectedCandidateCoin { + const coin = { denom, selected: false }; + selection.coins.push(coin); + restoreSelectedCoin(selection, coin, req, baseTally); + return coin; +} + +function restoreSelectedCoin( + selection: MutableCoinSelection, + coin: SelectedCandidateCoin, + req: SelectGreedyRequest, + baseTally: CoinSelectionTally, +): void { + checkLogicInvariant(!coin.selected); + coin.selected = true; + selection.selectedCountByDenom.set( + coin.denom, + (selection.selectedCountByDenom.get(coin.denom) ?? 0) + 1, + ); + addSelectionTotals(selection.totals, coin.denom, req, baseTally); +} + +function removeSelectedCoin( + selection: MutableCoinSelection, + coin: SelectedCandidateCoin, + req: SelectGreedyRequest, + baseTally: CoinSelectionTally, +): void { + checkLogicInvariant(coin.selected); + coin.selected = false; + const oldCount = selection.selectedCountByDenom.get(coin.denom) ?? 0; + checkLogicInvariant(oldCount > 0); + if (oldCount === 1) { + selection.selectedCountByDenom.delete(coin.denom); + } else { + selection.selectedCountByDenom.set(coin.denom, oldCount - 1); + } + removeSelectionTotals(selection.totals, coin.denom, req, baseTally); +} + +function denominationFactor( + larger: AvailableCoinsOfDenom, + smaller: AvailableCoinsOfDenom, + exact: boolean, +): number | undefined { + const largerUnits = getCandidateSelectionMeta(larger).value; + const smallerUnits = getCandidateSelectionMeta(smaller).value; + if (smallerUnits === 0n) { + return undefined; + } + const quotient = largerUnits / smallerUnits; + const hasRemainder = largerUnits % smallerUnits !== 0n; + const factor = quotient + (hasRemainder ? 1n : 0n); + if ((exact && hasRemainder) || factor > BigInt(Number.MAX_SAFE_INTEGER)) { + return undefined; + } + return Number(factor); +} + +const amountFractionalBaseBigInt = 100_000_000n; + +function amountToUnits(amount: AmountJson | string): bigint { + const parsed = Amounts.parseOrThrow(amount); + return ( + BigInt(parsed.value) * amountFractionalBaseBigInt + BigInt(parsed.fraction) + ); +} + +function amountFromUnits(currency: string, units: bigint): AmountJson { + checkLogicInvariant(units >= 0n); + const value = Number(units / amountFractionalBaseBigInt); + checkLogicInvariant(Number.isSafeInteger(value)); + return { + currency, + value, + fraction: Number(units % amountFractionalBaseBigInt), + }; +} + +/** + * DD91 pass 3: replace exact groups of small coins with a larger coin when + * this lowers the customer's fees and the wallet does not have an excess of + * that small denomination. + */ +function reduceSelectionFees( + selection: MutableCoinSelection, + candidates: AvailableCoinsOfDenom[], + req: SelectGreedyRequest, + baseTally: CoinSelectionTally, +): void { + if (selectionCustomerFees(selection.totals, baseTally) === 0n) { + return; + } + + const groups: AvailableCoinsOfDenom[][] = []; + for (const candidate of candidates) { + const last = groups[groups.length - 1]; + if ( + !last || + getCandidateSelectionMeta(last[0]).value !== + getCandidateSelectionMeta(candidate).value + ) { + groups.push([candidate]); + } else { + last.push(candidate); } } - // Otherwise use smallest coins first. - for (let i = 0; i < candidateDenoms.length; i++) { - const denom = candidateDenoms[candidateDenoms.length - i - 1]; - const contributions: AmountJson[] = []; + const selectedCoinsByValue = new Map<bigint, SelectedCandidateCoin[]>(); + for (const coin of selection.coins) { + if (!coin.selected) { + continue; + } + const value = getCandidateSelectionMeta(coin.denom).value; + const coins = selectedCoinsByValue.get(value) ?? []; + coins.push(coin); + selectedCoinsByValue.set(value, coins); + } - // Don't use this coin if depositing it is more expensive than - // the amount it would give the merchant. - if (Amounts.cmp(denom.feeDeposit, denom.value) > 0) { + // An excess in any non-largest denomination means that reducing coin + // count would work against DD91's primary goal of draining small coins. + for (let i = 0; i + 1 < groups.length; i++) { + const factor = denominationFactor(groups[i + 1][0], groups[i][0], false); + if (factor === undefined || factor < 2) { continue; } + const numAvailable = groups[i].reduce( + (sum, candidate) => sum + candidate.numAvailable, + 0, + ); + if (numAvailable > 5 * factor) { + return; + } + } + for (let smallIndex = 0; smallIndex + 1 < groups.length; smallIndex++) { + const smallGroup = groups[smallIndex]; + const smallValue = getCandidateSelectionMeta(smallGroup[0]).value; + const selectedSmallCoins = selectedCoinsByValue.get(smallValue) ?? []; + selectedSmallCoins.sort((a, b) => compareCandidateDenoms(b.denom, a.denom)); for ( - let j = 0; - j < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining); - j++ + let largeIndex = smallIndex + 1; + largeIndex < groups.length; + largeIndex++ ) { - // Save the allowance *before* tallying. - const depositFeeAllowance = tally.amountDepositFeeLimitRemaining; - - tallyFees( - tally, - req.wireFeesPerExchange, - denom.exchangeBaseUrl, - Amounts.parseOrThrow(denom.feeDeposit), - ); - - const coinSpend = Amounts.max( - Amounts.min(tally.amountPayRemaining, denom.value), - // Underflow saturates to zero - Amounts.sub(denom.feeDeposit, depositFeeAllowance).amount, - ); - - tally.amountPayRemaining = Amounts.sub( - tally.amountPayRemaining, - coinSpend, - ).amount; + const largeGroup = groups[largeIndex]; + const factor = denominationFactor(largeGroup[0], smallGroup[0], true); + if (factor === undefined || factor < 2) { + continue; + } - contributions.push(coinSpend); + while (selectedSmallCoins.length >= factor) { + const smallCoins = selectedSmallCoins.slice(0, factor); + + let replacementMade = false; + for (const largeDenom of largeGroup) { + const numSelected = + selection.selectedCountByDenom.get(largeDenom) ?? 0; + if (numSelected >= largeDenom.numAvailable) { + continue; + } + const feesBefore = selectionCustomerFees(selection.totals, baseTally); + for (const coin of smallCoins) { + removeSelectedCoin(selection, coin, req, baseTally); + } + const replacement = addSelectedCoin( + selection, + largeDenom, + req, + baseTally, + ); + const feesAfter = selectionCustomerFees(selection.totals, baseTally); + if ( + feesAfter < feesBefore && + selectionIsSufficient(selection.totals, baseTally) + ) { + replacementMade = true; + selectedSmallCoins.splice(0, factor); + const largeValue = getCandidateSelectionMeta(largeDenom).value; + const selectedLargeCoins = + selectedCoinsByValue.get(largeValue) ?? []; + selectedLargeCoins.push(replacement); + selectedCoinsByValue.set(largeValue, selectedLargeCoins); + break; + } + removeSelectedCoin(selection, replacement, req, baseTally); + for (const coin of smallCoins) { + restoreSelectedCoin(selection, coin, req, baseTally); + } + } + if (!replacementMade) { + break; + } + if (selectionCustomerFees(selection.totals, baseTally) === 0n) { + return; + } + } } - - applyContributions(selectedDenom, contributions, denom); } - return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined; } function selectForced(