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:
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({