taler-typescript-core

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

commit ddf899cbfb7c1e48a652fd331b344a9d3ad2428a
parent d179e4c3c8f3a415c87048a3153ba90f3d5f0819
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 00:21:23 +0200

wallet-core: fix insufficient balance maximum

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts | 10++++++++++
Mpackages/taler-wallet-core/src/coinSelection.test.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/coinSelection.ts | 32+++++++++++++++++++++++++++-----
3 files changed, 82 insertions(+), 5 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts b/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts @@ -182,6 +182,16 @@ export async function runWalletInsufficientBalanceTest(t: GlobalTestState) { (x) => x.type === CoinSelectionFailureReasonType.WireFeeUnavailable, ), ); + t.assertAmountEquals( + insufficientBalanceDetails.exchanges[exchange.baseUrl] + .maximumPayableAmount, + "TESTKUDOS:0", + ); + t.assertTrue( + !insufficientBalanceDetails.reasons.some( + (x) => x.type === CoinSelectionFailureReasonType.FeesNotCovered, + ), + ); } t.logStep("start-p2p-push-test"); diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -37,6 +37,7 @@ import { findMatchingWire, getMaxDepositAmount, testing_classifyMaximumFailure, + testing_computeMaximumPayableAmount, testing_getBalanceAvailabilityReason, testing_getMaxDepositAmountForAvailableCoins, testing_getMaxPeerPushDebitAmountForAvailableCoins, @@ -145,6 +146,50 @@ test("fee diagnostics require sufficient eligible fee-free value", () => { ); }); +test("payment maximum stays zero when all exchanges are ineligible", () => { + const maximum = testing_computeMaximumPayableAmount({ + operation: "pay", + combinedBaseMaximum: Amounts.parseOrThrow("LOCAL:0"), + combinedFeeFreeMaximum: Amounts.parseOrThrow("LOCAL:0"), + largestExchangeMaximum: Amounts.parseOrThrow("LOCAL:0"), + allowance: Amounts.parseOrThrow("LOCAL:5"), + }); + assert.deepStrictEqual(maximum, Amounts.parseOrThrow("LOCAL:0")); +}); + +test("payment maximum is capped by the eligible balance", () => { + const maximum = testing_computeMaximumPayableAmount({ + operation: "pay", + combinedBaseMaximum: Amounts.parseOrThrow("LOCAL:3.8"), + combinedFeeFreeMaximum: Amounts.parseOrThrow("LOCAL:4"), + largestExchangeMaximum: Amounts.parseOrThrow("LOCAL:4"), + allowance: Amounts.parseOrThrow("LOCAL:5"), + }); + assert.deepStrictEqual(maximum, Amounts.parseOrThrow("LOCAL:4")); +}); + +test("payment maximum applies the fee allowance to eligible balance", () => { + const maximum = testing_computeMaximumPayableAmount({ + operation: "pay", + combinedBaseMaximum: Amounts.parseOrThrow("LOCAL:9.5"), + combinedFeeFreeMaximum: Amounts.parseOrThrow("LOCAL:10"), + largestExchangeMaximum: Amounts.parseOrThrow("LOCAL:10"), + allowance: Amounts.parseOrThrow("LOCAL:0.2"), + }); + assert.deepStrictEqual(maximum, Amounts.parseOrThrow("LOCAL:9.7")); +}); + +test("peer maximum remains limited to one exchange", () => { + const maximum = testing_computeMaximumPayableAmount({ + operation: "peer", + combinedBaseMaximum: Amounts.parseOrThrow("LOCAL:12"), + combinedFeeFreeMaximum: Amounts.parseOrThrow("LOCAL:12"), + largestExchangeMaximum: Amounts.parseOrThrow("LOCAL:7"), + allowance: Amounts.parseOrThrow("LOCAL:5"), + }); + assert.deepStrictEqual(maximum, Amounts.parseOrThrow("LOCAL:7")); +}); + test("p2p: should select the coin", (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 @@ -552,6 +552,27 @@ function addFeeAllowance( return Amounts.min(upperBound, Amounts.add(baseAmount, allowance).amount); } +interface MaximumPayableComputation { + operation: "pay" | "peer"; + combinedBaseMaximum: AmountJson; + combinedFeeFreeMaximum: AmountJson; + largestExchangeMaximum: AmountJson; + allowance: AmountJson; +} + +function computeMaximumPayableAmount( + args: MaximumPayableComputation, +): AmountJson { + if (args.operation === "peer") { + return args.largestExchangeMaximum; + } + return addFeeAllowance( + args.combinedBaseMaximum, + args.combinedFeeFreeMaximum, + args.allowance, + ); +} + function makeBalanceSnapshot( details: Pick<PaymentBalanceDetails, "balanceAvailable" | "balanceMaterial">, ): CoinSelectionBalanceSnapshot { @@ -618,6 +639,7 @@ export const testing_makeBalanceSnapshot = makeBalanceSnapshot; export const testing_getBalanceAvailabilityReason = getBalanceAvailabilityReason; export const testing_classifyMaximumFailure = classifyMaximumFailure; +export const testing_computeMaximumPayableAmount = computeMaximumPayableAmount; async function getSupersededCoinBalances( wex: WalletExecutionContext, @@ -992,13 +1014,13 @@ export async function reportInsufficientBalanceDetails( const allowance = req.depositFeeLimit ?? (req.feesCoveredByCounterparty ? req.instructedAmount : zero); - const combinedMaximum = addFeeAllowance( + const maximumPayable = computeMaximumPayableAmount({ + operation: req.operation, combinedBaseMaximum, - details.balanceAgeAcceptable, + combinedFeeFreeMaximum, + largestExchangeMaximum, allowance, - ); - const maximumPayable = - req.operation === "peer" ? largestExchangeMaximum : combinedMaximum; + }); const feeFreeMaximum = req.operation === "peer" ? largestFeeFreeMaximum : combinedFeeFreeMaximum; const maximumFailure = classifyMaximumFailure({