taler-typescript-core

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

commit c08a5c8214e8f97a89d277bf11c6495a94190567
parent 8f10278107a4e314333b4dd87d2c50ec3a30015f
Author: Florian Dold <florian@dold.me>
Date:   Thu, 12 Mar 2026 19:00:12 +0100

wallet-core: improve coin selection

Diffstat:
Mpackages/taler-wallet-core/src/coinSelection.test.ts | 55+++++++++++++++++++++++++++++++++++++++++++++++++++----
Mpackages/taler-wallet-core/src/coinSelection.ts | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
2 files changed, 133 insertions(+), 31 deletions(-)

diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -150,7 +150,6 @@ test("pay: select one coin to pay with fee", (t) => { customerDepositFees: zero, customerWireFees: zero, wireFeeCoveredForExchange: new Set<string>(), - lastDepositFee: zero, totalDepositFees: zero, } satisfies CoinSelectionTally; const coins = testing_selectGreedy( @@ -184,7 +183,6 @@ test("pay: select one coin to pay with fee", (t) => { customerDepositFees: Amounts.parse("LOCAL:0.1"), customerWireFees: Amounts.parse("LOCAL:0.1"), wireFeeCoveredForExchange: new Set(["http://exchange.localhost/"]), - lastDepositFee: Amounts.parse("LOCAL:0.1"), totalDepositFees: Amounts.parse("LOCAL:0.1"), }); }); @@ -504,13 +502,62 @@ test("overpay when remaining < depositFee", (t) => { exchangeBaseUrl: "http://exchange.localhost/", denomPubHash: "hash0", maxAge: 32, - contributions: [Amounts.parseOrThrow("LOCAL:2")], + contributions: [Amounts.parseOrThrow("LOCAL:1.1")], + }, + "hash1;32;http://exchange.localhost/": { + exchangeBaseUrl: "http://exchange.localhost/", + denomPubHash: "hash1", + maxAge: 32, + contributions: [Amounts.parseOrThrow("LOCAL:1")], + }, + }); +}); + +test("prefer exact denom", (t) => { + const instructedAmount = Amounts.parseOrThrow("LOCAL:2"); + const tally = emptyTallyForPeerPayment({ + instructedAmount, + }); + tally.amountDepositFeeLimitRemaining = Amounts.parseOrThrow("LOCAL:0.5"); + + const coins = testing_selectGreedy( + { + wireFeesPerExchange: {}, }, + createCandidates([ + { + amount: "LOCAL:3" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0.2" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + { + amount: "LOCAL:2" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0.2" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + { + amount: "LOCAL:1" as AmountString, + numAvailable: 1, + depositFee: "LOCAL:0.2" as AmountString, + fromExchange: "http://exchange.localhost/", + fromMasterPub: "123", + }, + ]), + tally, + ); + + t.assert(coins != null); + + t.deepEqual(coins, { "hash1;32;http://exchange.localhost/": { exchangeBaseUrl: "http://exchange.localhost/", denomPubHash: "hash1", maxAge: 32, - contributions: [Amounts.parseOrThrow("LOCAL:0.1")], + contributions: [Amounts.parseOrThrow("LOCAL:2")], }, }); }); diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -103,8 +103,17 @@ export interface CoinSelectionTally { customerWireFees: AmountJson; wireFeeCoveredForExchange: Set<string>; +} - lastDepositFee: AmountJson; +function cloneTally(tally: CoinSelectionTally): CoinSelectionTally { + return { + amountDepositFeeLimitRemaining: tally.amountDepositFeeLimitRemaining, + amountPayRemaining: tally.amountPayRemaining, + customerDepositFees: tally.customerDepositFees, + customerWireFees: tally.customerWireFees, + totalDepositFees: tally.totalDepositFees, + wireFeeCoveredForExchange: new Set(tally.wireFeeCoveredForExchange), + }; } /** @@ -165,7 +174,6 @@ function tallyFees( tally.amountPayRemaining, dfRemaining, ).amount; - tally.lastDepositFee = feeDeposit; tally.totalDepositFees = Amounts.add( tally.totalDepositFees, feeDeposit, @@ -246,7 +254,6 @@ async function internalSelectPayCoins( customerWireFees: Amounts.zeroOfCurrency(currency), totalDepositFees: Amounts.zeroOfCurrency(currency), wireFeeCoveredForExchange: new Set(), - lastDepositFee: Amounts.zeroOfCurrency(currency), }; await maybeRepairCoinSelection( @@ -725,26 +732,92 @@ export interface SelectGreedyRequest { wireFeesPerExchange: Record<string, AmountJson>; } +function applyContributions( + selectedDenom: SelResult, + contributions: AmountJson[], + denom: AvailableCoinsOfDenom, +): void { + if (contributions.length) { + const avKey = makeAvailabilityKey( + denom.exchangeBaseUrl, + denom.denomPubHash, + denom.maxAge, + ); + let sd = selectedDenom[avKey]; + if (!sd) { + sd = { + contributions: [], + denomPubHash: denom.denomPubHash, + exchangeBaseUrl: denom.exchangeBaseUrl, + maxAge: denom.maxAge, + }; + } + sd.contributions.push(...contributions); + selectedDenom[avKey] = sd; + } +} + function selectGreedy( req: SelectGreedyRequest, candidateDenoms: AvailableCoinsOfDenom[], tally: CoinSelectionTally, ): SelResult | undefined { const selectedDenom: SelResult = {}; - for (const denom of candidateDenoms) { + 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( + tally.amountPayRemaining, + req.wireFeesPerExchange[denom.exchangeBaseUrl] ?? + Amounts.zeroOfCurrency(currency), + denom.feeDeposit, + ).amount; + if (Amounts.cmp(denom.value, tally.amountPayRemaining) < 0) { + break; + } + if (Amounts.cmp(denom.value, maxCost) > 0) { + continue; + } + 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; + } + } + + // Otherwise use smallest coins first. + for (let i = 0; i < candidateDenoms.length; i++) { + const denom = candidateDenoms[candidateDenoms.length - i - 1]; const contributions: AmountJson[] = []; // 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) { - tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit); continue; } for ( - let i = 0; - i < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining); - i++ + let j = 0; + j < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining); + j++ ) { // Save the allowance *before* tallying. const depositFeeAllowance = tally.amountDepositFeeLimitRemaining; @@ -770,24 +843,7 @@ function selectGreedy( contributions.push(coinSpend); } - if (contributions.length) { - const avKey = makeAvailabilityKey( - denom.exchangeBaseUrl, - denom.denomPubHash, - denom.maxAge, - ); - let sd = selectedDenom[avKey]; - if (!sd) { - sd = { - contributions: [], - denomPubHash: denom.denomPubHash, - exchangeBaseUrl: denom.exchangeBaseUrl, - maxAge: denom.maxAge, - }; - } - sd.contributions.push(...contributions); - selectedDenom[avKey] = sd; - } + applyContributions(selectedDenom, contributions, denom); } return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined; } @@ -1230,7 +1286,6 @@ export function emptyTallyForPeerPayment( return { amountPayRemaining: instructedAmount, customerDepositFees: zero, - lastDepositFee: zero, amountDepositFeeLimitRemaining: req.feesCoveredByCounterparty ? instructedAmount : zero,