commit 85cbaaf254166b4da4ffa50e9e6c3411e6b13f5c
parent bb2b54eacb9f63d7b5f8613070e2a29fb82dccf3
Author: Florian Dold <dold@taler.net>
Date: Wed, 19 Aug 2026 11:37:56 +0200
wallet-core: improve insufficient balance diagnostics
Issue: https://bugs.taler.net/n/11618
Diffstat:
7 files changed, 1172 insertions(+), 97 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
@@ -21,6 +21,7 @@ import {
AmountString,
BasicAuth,
ChoiceSelectionDetailType,
+ CoinSelectionFailureReasonType,
j2s,
Logger,
PaymentInsufficientBalanceDetails,
@@ -154,6 +155,33 @@ export async function runWalletInsufficientBalanceTest(t: GlobalTestState) {
insufficientBalanceDetails.balanceExchangeDepositable,
"TESTKUDOS:0",
);
+ t.assertTrue(insufficientBalanceDetails.balance != null);
+ t.assertAmountEquals(
+ insufficientBalanceDetails.maximumPayableAmount,
+ "TESTKUDOS:0",
+ );
+ t.assertAmountEquals(
+ insufficientBalanceDetails.balance.material,
+ "TESTKUDOS:9.85",
+ );
+ t.assertAmountEquals(
+ insufficientBalanceDetails.balance.pendingRefresh,
+ "TESTKUDOS:0",
+ );
+ t.assertAmountEquals(
+ insufficientBalanceDetails.balance.available,
+ "TESTKUDOS:9.85",
+ );
+ t.assertTrue(
+ insufficientBalanceDetails.reasons.some(
+ (x) => x.type === CoinSelectionFailureReasonType.WireFeeUnavailable,
+ ),
+ );
+ t.assertTrue(
+ insufficientBalanceDetails.exchanges[exchange.baseUrl].reasons.some(
+ (x) => x.type === CoinSelectionFailureReasonType.WireFeeUnavailable,
+ ),
+ );
}
t.logStep("start-p2p-push-test");
@@ -198,6 +226,62 @@ export async function runWalletInsufficientBalanceTest(t: GlobalTestState) {
perMyExchange.maxEffectiveSpendAmount,
"TESTKUDOS:9.75",
);
+ t.assertTrue(insufficientBalanceDetails.balance != null);
+ t.assertAmountEquals(
+ insufficientBalanceDetails.maximumPayableAmount,
+ "TESTKUDOS:9.75",
+ );
+ t.assertTrue(
+ insufficientBalanceDetails.reasons.some(
+ (x) =>
+ x.type ===
+ CoinSelectionFailureReasonType.AvailableBalanceInsufficient,
+ ),
+ );
+ t.assertAmountEquals(
+ insufficientBalanceDetails.balance.material,
+ "TESTKUDOS:14.75",
+ );
+ t.assertAmountEquals(
+ insufficientBalanceDetails.balance.pendingRefresh,
+ "TESTKUDOS:0",
+ );
+
+ const fragmentationExc = await t.assertThrowsTalerErrorAsync(
+ async () =>
+ await walletClient.call(WalletApiOperation.CheckPeerPushDebit, {
+ amount: "TESTKUDOS:12" as AmountString,
+ }),
+ );
+ const fragmentationDetails = fragmentationExc.errorDetail
+ .insufficientBalanceDetails as PaymentInsufficientBalanceDetails;
+ t.assertTrue(fragmentationDetails.reasons != null);
+ t.assertTrue(
+ fragmentationDetails.reasons.some(
+ (x) => x.type === CoinSelectionFailureReasonType.BalanceFragmented,
+ ),
+ );
+ t.assertTrue(
+ !fragmentationDetails.reasons.some(
+ (x) => x.type === CoinSelectionFailureReasonType.FeesNotCovered,
+ ),
+ );
+
+ const feeExc = await t.assertThrowsTalerErrorAsync(
+ async () =>
+ await walletClient.call(WalletApiOperation.CheckPeerPushDebit, {
+ amount: "TESTKUDOS:9.8" as AmountString,
+ exchangeBaseUrl: exchange.baseUrl,
+ }),
+ );
+ const feeDetails = feeExc.errorDetail
+ .insufficientBalanceDetails as PaymentInsufficientBalanceDetails;
+ t.assertTrue(feeDetails.reasons != null);
+ t.assertTrue(
+ feeDetails.reasons.some(
+ (x) => x.type === CoinSelectionFailureReasonType.FeesNotCovered,
+ ),
+ );
}
// Now test for insufficient balance details with the merchant.
@@ -286,6 +370,22 @@ export async function runWalletInsufficientBalanceTest(t: GlobalTestState) {
.balanceAvailable,
"TESTKUDOS:4.90",
);
+ t.assertTrue(insufficientBalanceDetails.reasons != null);
+ t.assertTrue(
+ insufficientBalanceDetails.reasons.some(
+ (x) => x.type === CoinSelectionFailureReasonType.ReceiverNotAccepted,
+ ),
+ );
+ t.assertTrue(
+ !insufficientBalanceDetails.reasons.some(
+ (x) => x.type === CoinSelectionFailureReasonType.FeesNotCovered,
+ ),
+ );
+ t.assertTrue(
+ insufficientBalanceDetails.exchanges[exchangeTwo.baseUrl].reasons.some(
+ (x) => x.type === CoinSelectionFailureReasonType.ReceiverNotAccepted,
+ ),
+ );
}
}
diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts
@@ -929,6 +929,30 @@ export interface RegexAccountRestriction {
human_hint_i18n?: InternationalizedString;
}
+const codecForDenyAllAccountRestriction =
+ (): Codec<DenyAllAccountRestriction> =>
+ buildCodecForObject<DenyAllAccountRestriction>()
+ .property("type", codecForConstString("deny"))
+ .build("DenyAllAccountRestriction");
+
+const codecForRegexAccountRestriction = (): Codec<RegexAccountRestriction> =>
+ buildCodecForObject<RegexAccountRestriction>()
+ .property("type", codecForConstString("regex"))
+ .property("payto_regex", codecForString())
+ .property("human_hint", codecForString())
+ .property(
+ "human_hint_i18n",
+ codecOptional(codecForInternationalizedString()),
+ )
+ .build("RegexAccountRestriction");
+
+export const codecForAccountRestriction = (): Codec<AccountRestriction> =>
+ buildCodecForUnion<AccountRestriction>()
+ .discriminateOn("type")
+ .alternative("deny", codecForDenyAllAccountRestriction())
+ .alternative("regex", codecForRegexAccountRestriction())
+ .build("AccountRestriction");
+
export type CoinEnvelope = CoinEnvelopeRsa | CoinEnvelopeCs;
export interface CoinEnvelopeRsa {
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -914,9 +914,134 @@ export enum InsufficientBalanceHint {
}
/**
- * Detailed reason for why the wallet's balance is insufficient.
+ * Machine-readable reasons that prevented a requested coin selection.
+ *
+ * Unlike {@link InsufficientBalanceHint}, these values are exhaustive and can
+ * be reported together. Consumers must branch on the discriminator instead
+ * of assuming that the first entry is the only cause.
+ */
+export enum CoinSelectionFailureReasonType {
+ AvailableBalanceInsufficient = "available-balance-insufficient",
+ PendingRefresh = "pending-refresh",
+ MinimumAge = "minimum-age",
+ ScopeRestricted = "scope-restricted",
+ ReceiverNotAccepted = "receiver-not-accepted",
+ ReceiverExchangeMasterPubMismatch = "receiver-exchange-master-pub-mismatch",
+ WireMethodUnsupported = "wire-method-unsupported",
+ WireFeeUnavailable = "wire-fee-unavailable",
+ DepositAccountRestricted = "deposit-account-restricted",
+ ExchangeGlobalFeesUnavailable = "exchange-global-fees-unavailable",
+ FeesNotCovered = "fees-not-covered",
+ BalanceFragmented = "balance-fragmented",
+ SupersededExchangeMasterPub = "superseded-exchange-master-pub",
+ SelectionFailed = "selection-failed",
+}
+
+export type CoinSelectionFailureReason =
+ | {
+ type: CoinSelectionFailureReasonType.AvailableBalanceInsufficient;
+ amountAvailable: AmountString;
+ }
+ | {
+ type: CoinSelectionFailureReasonType.PendingRefresh;
+ amountPendingRefresh: AmountString;
+ }
+ | {
+ type: CoinSelectionFailureReasonType.MinimumAge;
+ requiredMinimumAge: number;
+ amountAgeAcceptable: AmountString;
+ }
+ | {
+ type: CoinSelectionFailureReasonType.ScopeRestricted;
+ scopeInfo: ScopeInfo;
+ }
+ | { type: CoinSelectionFailureReasonType.ReceiverNotAccepted }
+ | {
+ type: CoinSelectionFailureReasonType.ReceiverExchangeMasterPubMismatch;
+ walletMasterPub: string;
+ receiverMasterPubs: string[];
+ }
+ | {
+ type: CoinSelectionFailureReasonType.WireMethodUnsupported;
+ wireMethod: string;
+ }
+ | {
+ type: CoinSelectionFailureReasonType.WireFeeUnavailable;
+ wireMethod: string;
+ }
+ | {
+ type: CoinSelectionFailureReasonType.DepositAccountRestricted;
+ wireMethod: string;
+ accountRestrictions: Record<string, AccountRestriction[]>;
+ }
+ | { type: CoinSelectionFailureReasonType.ExchangeGlobalFeesUnavailable }
+ | {
+ type: CoinSelectionFailureReasonType.FeesNotCovered;
+ maximumPayableAmount: AmountString;
+ }
+ | {
+ type: CoinSelectionFailureReasonType.BalanceFragmented;
+ combinedMaximumPayableAmount: AmountString;
+ }
+ | {
+ type: CoinSelectionFailureReasonType.SupersededExchangeMasterPub;
+ amountAffected: AmountString;
+ }
+ | { type: CoinSelectionFailureReasonType.SelectionFailed };
+
+/**
+ * Balance amounts before age, receiver, wire and fee restrictions.
+ *
+ * The terminology follows balance.ts: pending withdrawals and peer credits
+ * are pending incoming balance and are not included here. Only effective
+ * outputs expected from unfinished refreshes bridge material to available.
*/
-export interface PaymentInsufficientBalanceDetails {
+export interface CoinSelectionBalanceSnapshot {
+ /** Balance that the wallet believes it can spend immediately. */
+ material: AmountString;
+
+ /** Expected effective output of unfinished refresh operations. */
+ pendingRefresh: AmountString;
+
+ /** Material balance plus pending refresh output. */
+ available: AmountString;
+}
+
+export interface CoinSelectionExchangeFailureDiagnostics {
+ /** Balance held at this exchange before payment restrictions. */
+ balance: CoinSelectionBalanceSnapshot;
+
+ /** Maximum contribution selectable from this exchange for this request. */
+ maximumPayableAmount: AmountString;
+
+ /** Exchange-local reasons, in deterministic evaluation order. */
+ reasons: CoinSelectionFailureReason[];
+}
+
+/** Structured explanation emitted by current wallet-core versions. */
+export interface PaymentInsufficientBalanceStructuredDetails {
+ /** Balance in the requested sender scope before payment restrictions. */
+ balance: CoinSelectionBalanceSnapshot;
+
+ /**
+ * Maximum contribution attainable under the failed request's actual
+ * restrictions and fee policy. For peer payments this is the maximum at
+ * one exchange, since peer payments cannot combine exchanges.
+ */
+ maximumPayableAmount: AmountString;
+
+ /** Operation-wide reasons, in deterministic evaluation order. */
+ reasons: CoinSelectionFailureReason[];
+
+ /** Detailed analysis for every same-currency exchange known to the wallet. */
+ exchanges: Record<string, CoinSelectionExchangeFailureDiagnostics>;
+}
+
+/**
+ * Request context and compatibility fields shared by old and current
+ * insufficient-balance responses.
+ */
+interface PaymentInsufficientBalanceCompatibilityDetails {
/**
* Amount requested by the merchant.
*/
@@ -934,80 +1059,186 @@ export interface PaymentInsufficientBalanceDetails {
* If this hint is not provided, the balance hints of
* the individual exchanges should be shown, as the overall
* reason might be a combination of the reasons for different exchanges.
+ *
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use reasons.
*/
causeHint?: InsufficientBalanceHint;
/**
* Balance of type "available" (see balance.ts for definition).
+ *
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use balance.available.
*/
balanceAvailable: AmountString;
/**
* Balance of type "material" (see balance.ts for definition).
+ *
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use balance.material.
*/
balanceMaterial: AmountString;
/**
* Balance of type "age-acceptable" (see balance.ts for definition).
+ *
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use reasons and exchanges.
*/
balanceAgeAcceptable: AmountString;
/**
* Balance of type "receiver-acceptable" (see balance.ts for definition).
*
- * @deprecated (2025-12-05) use balanceReceiver[...]Acceptable instead.
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2025-12-05) Use reasons and exchanges.
*/
balanceReceiverAcceptable: AmountString;
/**
* Balance of type "receiver-exchange-url-acceptable" (see balance.ts for definition).
+ *
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use reasons and exchanges.
*/
balanceReceiverExchangeUrlAcceptable: AmountString;
/**
* Balance of type "receiver-exchange-pub-acceptable" (see balance.ts for definition).
+ *
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use reasons and exchanges.
*/
balanceReceiverExchangePubAcceptable: AmountString;
/**
* Balance of type "receiver-auditor-url-acceptable" (see balance.ts for definition).
+ *
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use reasons and exchanges.
*/
balanceReceiverAuditorUrlAcceptable: AmountString;
/**
* Balance of type "merchant-depositable" (see balance.ts for definition).
+ *
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use maximumPayableAmount and reasons.
*/
balanceReceiverDepositable: AmountString;
+ /**
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use maximumPayableAmount and reasons.
+ */
balanceExchangeDepositable: AmountString;
/**
* Maximum effective amount that the wallet can spend,
* when all fees are paid by the wallet.
+ *
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use maximumPayableAmount.
*/
maxEffectiveSpendAmount: AmountString;
+ /**
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use exchanges.
+ */
perExchange: {
[url: string]: {
+ /**
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use exchanges[url].balance.available.
+ */
balanceAvailable: AmountString;
+
+ /**
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use exchanges[url].balance.material.
+ */
balanceMaterial: AmountString;
+
+ /**
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use exchanges[url].maximumPayableAmount and reasons.
+ */
balanceExchangeDepositable: AmountString;
+
+ /**
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use exchanges[url].reasons.
+ */
balanceAgeAcceptable: AmountString;
/**
- * @deprecated (2025-12-05) use balanceReceiver[...]Acceptable instead.
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2025-12-05) Use exchanges[url].reasons.
*/
balanceReceiverAcceptable: AmountString;
+ /**
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use exchanges[url].reasons.
+ */
balanceReceiverExchangeUrlAcceptable: AmountString;
+
+ /**
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use exchanges[url].reasons.
+ */
balanceReceiverExchangePubAcceptable: AmountString;
+
+ /**
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use exchanges[url].reasons.
+ */
balanceReceiverAuditorUrlAcceptable: AmountString;
+
+ /**
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use exchanges[url].maximumPayableAmount and reasons.
+ */
balanceReceiverDepositable: AmountString;
+
+ /**
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use exchanges[url].maximumPayableAmount.
+ */
maxEffectiveSpendAmount: AmountString;
/**
* The exchange master public key configured by the merchant
* backend differs from the one of the coins stored in the wallet.
+ *
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use the receiver-exchange-master-pub-mismatch reason.
*/
exchangeMasterPubMismatch: boolean;
@@ -1015,19 +1246,46 @@ export interface PaymentInsufficientBalanceDetails {
* Exchange doesn't have global fees configured for the relevant year,
* p2p payments aren't possible.
*
- * @deprecated (2025-02-18) use causeHint instead
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2025-02-18) Use the exchange-global-fees-unavailable reason.
*/
missingGlobalFees: boolean;
/**
* Hint that UIs should show to explain the insufficient
* balance.
+ *
+ * Compatibility-only and planned for removal after consumers migrate.
+ *
+ * @deprecated (2026-08-19) Use exchanges[url].reasons.
*/
causeHint?: InsufficientBalanceHint | undefined;
};
};
}
+interface PaymentInsufficientBalanceLegacyOnly {
+ balance?: undefined;
+ maximumPayableAmount?: undefined;
+ reasons?: undefined;
+ exchanges?: undefined;
+}
+
+/**
+ * Detailed reason for why the wallet's balance is insufficient.
+ *
+ * Current wallet-core versions emit all structured fields. The legacy-only
+ * alternative lets clients continue decoding responses from older cores
+ * without allowing partially populated structured diagnostics.
+ */
+export type PaymentInsufficientBalanceDetails =
+ PaymentInsufficientBalanceCompatibilityDetails &
+ (
+ | PaymentInsufficientBalanceStructuredDetails
+ | PaymentInsufficientBalanceLegacyOnly
+ );
+
export interface PaymentTokenAvailabilityDetails {
/**
* Number of tokens requested by the merchant.
@@ -1071,25 +1329,6 @@ export enum TokenAvailabilityHint {
MerchantUntrusted = "merchant-untrusted",
}
-export const codecForPayMerchantInsufficientBalanceDetails =
- (): Codec<PaymentInsufficientBalanceDetails> =>
- buildCodecForObject<PaymentInsufficientBalanceDetails>()
- .property("amountRequested", codecForAmountString())
- .property("wireMethod", codecOptional(codecForString()))
- .property("balanceAgeAcceptable", codecForAmountString())
- .property("balanceAvailable", codecForAmountString())
- .property("balanceMaterial", codecForAmountString())
- .property("balanceReceiverAcceptable", codecForAmountString())
- .property("balanceReceiverExchangeUrlAcceptable", codecForAmountString())
- .property("balanceReceiverExchangePubAcceptable", codecForAmountString())
- .property("balanceReceiverAuditorUrlAcceptable", codecForAmountString())
- .property("balanceReceiverDepositable", codecForAmountString())
- .property("balanceExchangeDepositable", codecForAmountString())
- .property("perExchange", codecForAny())
- .property("maxEffectiveSpendAmount", codecForAmountString())
- .deprecatedProperty("balanceReceiverAcceptable")
- .build("PayMerchantInsufficientBalanceDetails");
-
export interface PreparePayV2Result {
transactionId: TransactionIdStr;
}
diff --git a/packages/taler-wallet-core/src/balance.test.ts b/packages/taler-wallet-core/src/balance.test.ts
@@ -15,8 +15,11 @@
import assert from "node:assert";
import { test } from "node:test";
-import { ScopeType } from "@gnu-taler/taler-util";
-import { getBalancesInsideTransaction } from "./balance.js";
+import { Amounts, ScopeType } from "@gnu-taler/taler-util";
+import {
+ getBalancesInsideTransaction,
+ getPaymentBalanceDetailsInTx,
+} from "./balance.js";
import {
ExchangeEntryDbRecordStatus,
ExchangeEntryDbUpdateStatus,
@@ -151,3 +154,65 @@ test("haveProdBalance classifies demo, test, and production exchanges", async ()
);
}
});
+
+test("pending refresh balance respects the requested sender scope", async () => {
+ const tx = {
+ async getCoinAvailabilities() {
+ return [];
+ },
+ async getActiveRefreshGroups() {
+ return [
+ {
+ currency: "TESTKUDOS",
+ infoPerExchange: {
+ "https://exchange-b.example/": {
+ outputEffective: "TESTKUDOS:5",
+ },
+ },
+ },
+ ];
+ },
+ async checkExchangeInScope(baseUrl: string, scope: { url?: string }) {
+ return scope.url === baseUrl;
+ },
+ } as unknown as WalletDbTransaction;
+ const wex = { ws: { devExperimentState: {} } } as WalletExecutionContext;
+
+ const scoped = await getPaymentBalanceDetailsInTx(wex, tx, {
+ currency: "TESTKUDOS",
+ minAge: 0,
+ restrictSenderScope: {
+ type: ScopeType.Exchange,
+ currency: "TESTKUDOS",
+ url: "https://exchange-a.example/",
+ },
+ restrictReceiverExchanges: undefined,
+ restrictWireMethods: undefined,
+ depositPaytoUri: undefined,
+ });
+ assert.strictEqual(Amounts.stringify(scoped.balanceAvailable), "TESTKUDOS:0");
+ assert.strictEqual(Amounts.stringify(scoped.balanceMaterial), "TESTKUDOS:0");
+
+ const unscoped = await getPaymentBalanceDetailsInTx(wex, tx, {
+ currency: "TESTKUDOS",
+ minAge: 0,
+ restrictSenderScope: undefined,
+ restrictReceiverExchanges: undefined,
+ restrictWireMethods: undefined,
+ depositPaytoUri: undefined,
+ });
+ assert.strictEqual(
+ Amounts.stringify(unscoped.balanceAvailable),
+ "TESTKUDOS:5",
+ );
+ assert.strictEqual(
+ Amounts.stringify(unscoped.balanceMaterial),
+ "TESTKUDOS:0",
+ );
+ assert.strictEqual(
+ Amounts.stringify(
+ Amounts.sub(unscoped.balanceAvailable, unscoped.balanceMaterial).amount,
+ ),
+ "TESTKUDOS:5",
+ );
+});
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
@@ -61,7 +61,6 @@
*/
import {
- AllowedExchangeInfo,
AmountJson,
AmountLike,
Amounts,
@@ -78,7 +77,11 @@ import {
ScopeInfo,
ScopeType,
} from "@gnu-taler/taler-util";
-import { ExchangeRestrictionSpec, findMatchingWire } from "./coinSelection.js";
+import {
+ checkExchangeAccepted,
+ ExchangeRestrictionSpec,
+ findMatchingWire,
+} from "./coinSelection.js";
import {
DepositOperationStatus,
ExchangeEntryDbRecordStatus,
@@ -116,10 +119,11 @@ interface WalletBalance {
shoppingUrls: Set<string>;
}
-function computeRefreshGroupAvailableAmountForExchanges(
+async function computeRefreshGroupAvailableAmountForExchanges(
+ tx: WalletDbTransaction,
r: WalletRefreshGroup,
- restrictExchanges: AllowedExchangeInfo[] | undefined,
-): AmountJson {
+ restrictSenderScope: ScopeInfo | undefined,
+): Promise<AmountJson> {
// Don't count finished refreshes, since the refresh already resulted
// in coins being added to the wallet.
let available = Amounts.zeroOfCurrency(r.currency);
@@ -132,11 +136,12 @@ function computeRefreshGroupAvailableAmountForExchanges(
for (const exch of Object.keys(r.infoPerExchange)) {
const pe = r.infoPerExchange[exch];
if (
- restrictExchanges == null ||
- restrictExchanges.find((x) => x.exchangeBaseUrl === exch) != null
+ restrictSenderScope &&
+ !(await tx.checkExchangeInScope(exch, restrictSenderScope))
) {
- available = Amounts.add(available, pe.outputEffective).amount;
+ continue;
}
+ available = Amounts.add(available, pe.outputEffective).amount;
}
return available;
}
@@ -983,7 +988,7 @@ export async function getPaymentBalanceDetailsInTx(
} else {
for (const wm of req.restrictWireMethods) {
const wmf = findMatchingWire(wm, req.depositPaytoUri, wireDetails);
- if (wmf) {
+ if (wmf.status === "match") {
wireOkay = true;
break;
}
@@ -1032,8 +1037,10 @@ export async function getPaymentBalanceDetailsInTx(
}
}
- const merchantExchangeAcceptable =
- merchantExchangeUrlAcceptable || merchantExchangeAuditorAcceptable;
+ const merchantExchangeAcceptable = checkExchangeAccepted(
+ wireDetails,
+ req.restrictReceiverExchanges,
+ ).accepted;
const merchantExchangeDepositable = merchantExchangeAcceptable && wireOkay;
d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount;
@@ -1085,6 +1092,12 @@ export async function getPaymentBalanceDetailsInTx(
merchantExchangeAcceptable &&
merchantExchangeDepositable
) {
+ // Coin selection rejects denominations whose deposit fee exceeds their
+ // value. Keep the diagnostic maximum aligned with that rule while the
+ // broader balance tallies above continue to show that the coins exist.
+ if (Amounts.cmp(denom.feeDeposit, ca.value) > 0) {
+ continue;
+ }
d.maxMerchantEffectiveDepositAmount = Amounts.add(
d.maxMerchantEffectiveDepositAmount,
Amounts.mult(ca.value, ca.freshCoinCount).amount,
@@ -1102,9 +1115,10 @@ export async function getPaymentBalanceDetailsInTx(
if (r.currency != req.currency) {
continue;
}
- const balRefresh = computeRefreshGroupAvailableAmountForExchanges(
+ const balRefresh = await computeRefreshGroupAvailableAmountForExchanges(
+ tx,
r,
- req.restrictReceiverExchanges?.exchanges,
+ req.restrictSenderScope,
);
d.balanceAvailable = Amounts.add(d.balanceAvailable, balRefresh).amount;
}
diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts
@@ -19,6 +19,7 @@ import {
AccountRestriction,
AmountString,
Amounts,
+ CoinSelectionFailureReasonType,
DenomKeyType,
DenominationPubKey,
Duration,
@@ -31,10 +32,14 @@ import assert from "node:assert";
import {
AvailableCoinsOfDenom,
CoinSelectionTally,
+ checkExchangeAccepted,
emptyTallyForPeerPayment,
findMatchingWire,
+ testing_classifyMaximumFailure,
+ testing_getBalanceAvailabilityReason,
testing_getMaxDepositAmountForAvailableCoins,
testing_getMaxPeerPushDebitAmountForAvailableCoins,
+ testing_makeBalanceSnapshot,
testing_selectGreedy,
} from "./coinSelection.js";
@@ -49,6 +54,86 @@ const inThePast = AbsoluteTime.toProtocolTimestamp(
),
);
+test("balance snapshot distinguishes pending refresh from material balance", () => {
+ const details = {
+ balanceMaterial: Amounts.parseOrThrow("LOCAL:2"),
+ balanceAvailable: Amounts.parseOrThrow("LOCAL:12"),
+ };
+ assert.deepStrictEqual(testing_makeBalanceSnapshot(details), {
+ material: "LOCAL:2",
+ pendingRefresh: "LOCAL:10",
+ available: "LOCAL:12",
+ });
+ assert.deepStrictEqual(
+ testing_getBalanceAvailabilityReason(
+ details,
+ Amounts.parseOrThrow("LOCAL:10"),
+ ),
+ {
+ type: CoinSelectionFailureReasonType.PendingRefresh,
+ amountPendingRefresh: "LOCAL:10",
+ },
+ );
+});
+
+test("pending refresh stays informational when available is insufficient", () => {
+ const details = {
+ balanceMaterial: Amounts.parseOrThrow("LOCAL:0"),
+ balanceAvailable: Amounts.parseOrThrow("LOCAL:5"),
+ };
+ assert.deepStrictEqual(testing_makeBalanceSnapshot(details), {
+ material: "LOCAL:0",
+ pendingRefresh: "LOCAL:5",
+ available: "LOCAL:5",
+ });
+ assert.deepStrictEqual(
+ testing_getBalanceAvailabilityReason(
+ details,
+ Amounts.parseOrThrow("LOCAL:10"),
+ ),
+ {
+ type: CoinSelectionFailureReasonType.AvailableBalanceInsufficient,
+ amountAvailable: "LOCAL:5",
+ },
+ );
+});
+
+test("fragmentation takes precedence over global fee diagnostics", () => {
+ assert.deepStrictEqual(
+ testing_classifyMaximumFailure({
+ operation: "peer",
+ instructedAmount: Amounts.parseOrThrow("LOCAL:10"),
+ maximumPayable: Amounts.parseOrThrow("LOCAL:6"),
+ combinedMaximumPayable: Amounts.parseOrThrow("LOCAL:12"),
+ feeFreeMaximum: Amounts.parseOrThrow("LOCAL:11"),
+ }),
+ { balanceFragmented: true, feesNotCovered: false },
+ );
+});
+
+test("fee diagnostics require sufficient eligible fee-free value", () => {
+ assert.deepStrictEqual(
+ testing_classifyMaximumFailure({
+ operation: "pay",
+ instructedAmount: Amounts.parseOrThrow("LOCAL:10"),
+ maximumPayable: Amounts.parseOrThrow("LOCAL:9.8"),
+ combinedMaximumPayable: Amounts.parseOrThrow("LOCAL:9.8"),
+ feeFreeMaximum: Amounts.parseOrThrow("LOCAL:10.1"),
+ }),
+ { balanceFragmented: false, feesNotCovered: true },
+ );
+ assert.deepStrictEqual(
+ testing_classifyMaximumFailure({
+ operation: "pay",
+ instructedAmount: Amounts.parseOrThrow("LOCAL:10"),
+ maximumPayable: Amounts.parseOrThrow("LOCAL:4"),
+ combinedMaximumPayable: Amounts.parseOrThrow("LOCAL:4"),
+ feeFreeMaximum: Amounts.parseOrThrow("LOCAL:4.1"),
+ }),
+ { balanceFragmented: false, feesNotCovered: false },
+ );
+});
+
test("p2p: should select the coin", (t) => {
const instructedAmount = Amounts.parseOrThrow("LOCAL:2");
const tally = emptyTallyForPeerPayment({
@@ -993,8 +1078,7 @@ test("a wire account without restrictions matches", (t) => {
"payto://iban/CH62414246VCSW2LM4FG0",
wireInfoWithRestrictions([]),
);
- assert.ok(res);
- assert.strictEqual(res.ok, true);
+ assert.strictEqual(res.status, "match");
});
test("a rejected wire account reports the restrictions that rejected it", (t) => {
@@ -1004,15 +1088,74 @@ test("a rejected wire account reports the restrictions that rejected it", (t) =>
"payto://iban/CH62414246VCSW2LM4FG0",
wireInfoWithRestrictions(restrictions),
);
- assert.ok(res);
- assert.strictEqual(res.ok, false);
- if (res.ok === false) {
+ assert.strictEqual(res.status, "account-restricted");
+ if (res.status === "account-restricted") {
assert.deepStrictEqual(res.accountRestrictions, {
"payto://iban/DE76500202009817493529": restrictions,
});
}
});
+test("wire matching distinguishes an unsupported method", (t) => {
+ const res = findMatchingWire(
+ "x-taler-bank",
+ undefined,
+ wireInfoWithRestrictions([]),
+ );
+ assert.strictEqual(res.status, "wire-method-unsupported");
+});
+
+test("wire matching distinguishes a missing current fee", (t) => {
+ const wire = wireInfoWithRestrictions([]);
+ wire.wireInfo.feesForType = {};
+ const res = findMatchingWire(
+ "iban",
+ "payto://iban/CH62414246VCSW2LM4FG0",
+ wire,
+ );
+ assert.strictEqual(res.status, "wire-fee-unavailable");
+});
+
+test("wire matching accepts a later unrestricted account", (t) => {
+ const wire = wireInfoWithRestrictions([{ type: "deny" }]);
+ wire.wireInfo.accounts.push({
+ payto_uri: "payto://iban/DE75512108001245126199",
+ master_sig: "DUMMY",
+ credit_restrictions: [],
+ debit_restrictions: [],
+ });
+ const res = findMatchingWire(
+ "iban",
+ "payto://iban/CH62414246VCSW2LM4FG0",
+ wire,
+ );
+ assert.strictEqual(res.status, "match");
+});
+
+test("receiver acceptance identifies a master key mismatch at the same URL", () => {
+ const acceptance = checkExchangeAccepted(
+ {
+ exchangeBaseUrl: "https://exchange.example/",
+ masterPublicKey: "WALLET_MASTER_PUB",
+ auditors: [],
+ },
+ {
+ exchanges: [
+ {
+ exchangeBaseUrl: "https://exchange.example/",
+ exchangePub: "RECEIVER_MASTER_PUB",
+ },
+ ],
+ auditors: [],
+ },
+ );
+ assert.strictEqual(acceptance.accepted, false);
+ assert.strictEqual(acceptance.masterPubMismatch, true);
+ assert.deepStrictEqual(acceptance.receiverMasterPubs, [
+ "RECEIVER_MASTER_PUB",
+ ]);
+});
+
// The tests below pin the "legacy-2024" algorithm, which reproduces how the
// wallet selected coins in 2024. They deliberately mirror scenarios that the
// default algorithm now handles differently, so a change to either one shows
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts
@@ -36,6 +36,9 @@ import {
checkDbInvariant,
checkLogicInvariant,
CoinSelectionAlgorithm,
+ CoinSelectionBalanceSnapshot,
+ CoinSelectionFailureReason,
+ CoinSelectionFailureReasonType,
CoinStatus,
DenominationInfo,
Exchange,
@@ -50,6 +53,7 @@ import {
Logger,
PayCoinSelection,
PaymentInsufficientBalanceDetails,
+ PaymentInsufficientBalanceStructuredDetails,
Paytos,
Result,
ScopeInfo,
@@ -323,6 +327,9 @@ export async function selectPayCoinsInTx(
requiredMinimumAge: req.requiredMinimumAge,
wireMethod: req.restrictWireMethod,
depositPaytoUri: req.depositPaytoUri,
+ restrictScope: req.restrictScope,
+ depositFeeLimit: req.depositFeeLimit,
+ operation: "pay",
},
),
} satisfies SelectPayCoinsResult;
@@ -342,6 +349,9 @@ export async function selectPayCoinsInTx(
requiredMinimumAge: req.requiredMinimumAge,
wireMethod: req.restrictWireMethod,
depositPaytoUri: req.depositPaytoUri,
+ restrictScope: req.restrictScope,
+ depositFeeLimit: req.depositFeeLimit,
+ operation: "pay",
},
),
} satisfies SelectPayCoinsResult;
@@ -462,6 +472,11 @@ interface ReportInsufficientBalanceRequest {
restrictExchanges: ExchangeRestrictionSpec | undefined;
wireMethod: string | undefined;
depositPaytoUri: string | undefined;
+ restrictScope?: ScopeInfo;
+ exchangeBaseUrl?: string;
+ depositFeeLimit?: AmountJson;
+ feesCoveredByCounterparty?: boolean;
+ operation: "pay" | "peer";
}
function getHint(
@@ -487,7 +502,7 @@ function getHint(
return InsufficientBalanceHint.MerchantAcceptInsufficient;
} else if (
isMerchant &&
- Amounts.cmp(exchDet.balanceExchangeDepositable, req.instructedAmount) < 0
+ Amounts.cmp(exchDet.balanceReceiverDepositable, req.instructedAmount) < 0
) {
return InsufficientBalanceHint.MerchantDepositInsufficient;
} else if (
@@ -506,14 +521,154 @@ function getHint(
return InsufficientBalanceHint.WalletBalanceAvailableInsufficient;
}
+function appendReason(
+ reasons: CoinSelectionFailureReason[],
+ reason: CoinSelectionFailureReason,
+): void {
+ if (!reasons.some((x) => x.type === reason.type)) {
+ reasons.push(reason);
+ }
+}
+
+function addFeeAllowance(
+ baseAmount: AmountJson,
+ upperBound: AmountJson,
+ allowance: AmountJson,
+): AmountJson {
+ return Amounts.min(upperBound, Amounts.add(baseAmount, allowance).amount);
+}
+
+function makeBalanceSnapshot(
+ details: Pick<PaymentBalanceDetails, "balanceAvailable" | "balanceMaterial">,
+): CoinSelectionBalanceSnapshot {
+ checkLogicInvariant(
+ Amounts.cmp(details.balanceAvailable, details.balanceMaterial) >= 0,
+ "available balance must include the material balance",
+ );
+ return {
+ material: Amounts.stringify(details.balanceMaterial),
+ pendingRefresh: Amounts.stringify(
+ Amounts.sub(details.balanceAvailable, details.balanceMaterial).amount,
+ ),
+ available: Amounts.stringify(details.balanceAvailable),
+ };
+}
+
+function getBalanceAvailabilityReason(
+ details: Pick<PaymentBalanceDetails, "balanceAvailable" | "balanceMaterial">,
+ instructedAmount: AmountJson,
+): CoinSelectionFailureReason | undefined {
+ if (Amounts.cmp(details.balanceAvailable, instructedAmount) < 0) {
+ return {
+ type: CoinSelectionFailureReasonType.AvailableBalanceInsufficient,
+ amountAvailable: Amounts.stringify(details.balanceAvailable),
+ };
+ }
+ if (Amounts.cmp(details.balanceMaterial, instructedAmount) < 0) {
+ return {
+ type: CoinSelectionFailureReasonType.PendingRefresh,
+ amountPendingRefresh: Amounts.stringify(
+ Amounts.sub(details.balanceAvailable, details.balanceMaterial).amount,
+ ),
+ };
+ }
+ return undefined;
+}
+
+interface MaximumFailureClassification {
+ balanceFragmented: boolean;
+ feesNotCovered: boolean;
+}
+
+function classifyMaximumFailure(args: {
+ operation: "pay" | "peer";
+ instructedAmount: AmountJson;
+ maximumPayable: AmountJson;
+ combinedMaximumPayable: AmountJson;
+ feeFreeMaximum: AmountJson;
+}): MaximumFailureClassification {
+ const balanceFragmented =
+ args.operation === "peer" &&
+ Amounts.cmp(args.combinedMaximumPayable, args.instructedAmount) >= 0 &&
+ Amounts.cmp(args.maximumPayable, args.instructedAmount) < 0;
+ return {
+ balanceFragmented,
+ feesNotCovered:
+ !balanceFragmented &&
+ Amounts.cmp(args.maximumPayable, args.instructedAmount) < 0 &&
+ Amounts.cmp(args.feeFreeMaximum, args.instructedAmount) >= 0,
+ };
+}
+
+export const testing_makeBalanceSnapshot = makeBalanceSnapshot;
+export const testing_getBalanceAvailabilityReason =
+ getBalanceAvailabilityReason;
+export const testing_classifyMaximumFailure = classifyMaximumFailure;
+
+async function getSupersededCoinBalances(
+ wex: WalletExecutionContext,
+ tx: WalletDbTransaction,
+ currency: string,
+): Promise<Map<string, AmountJson>> {
+ const amounts = new Map<string, AmountJson>();
+ const exchangeMasterPubs = new Map<string, string | undefined>();
+ const availabilities = await tx.getCoinAvailabilities();
+ for (const availability of availabilities) {
+ if (
+ availability.currency !== currency ||
+ availability.freshCoinCount === 0
+ ) {
+ continue;
+ }
+ let exchangeMasterPub = exchangeMasterPubs.get(
+ availability.exchangeBaseUrl,
+ );
+ if (!exchangeMasterPubs.has(availability.exchangeBaseUrl)) {
+ exchangeMasterPub = (
+ await tx.getExchangeDetails(availability.exchangeBaseUrl)
+ )?.masterPublicKey;
+ exchangeMasterPubs.set(availability.exchangeBaseUrl, exchangeMasterPub);
+ }
+ const denom = await getDenomInfo(wex, tx, availability);
+ if (
+ !denom ||
+ !exchangeMasterPub ||
+ denom.exchangeMasterPub === exchangeMasterPub
+ ) {
+ continue;
+ }
+ const current =
+ amounts.get(availability.exchangeBaseUrl) ??
+ Amounts.zeroOfCurrency(currency);
+ amounts.set(
+ availability.exchangeBaseUrl,
+ Amounts.add(
+ current,
+ Amounts.mult(availability.value, availability.freshCoinCount).amount,
+ ).amount,
+ );
+ }
+ return amounts;
+}
+
export async function reportInsufficientBalanceDetails(
wex: WalletExecutionContext,
tx: WalletDbTransaction,
req: ReportInsufficientBalanceRequest,
): Promise<PaymentInsufficientBalanceDetails> {
const currency = Amounts.currencyOf(req.instructedAmount);
+ const zero = Amounts.zeroOfCurrency(currency);
+ const effectiveScope =
+ req.restrictScope ??
+ (req.exchangeBaseUrl
+ ? {
+ type: ScopeType.Exchange as const,
+ currency,
+ url: req.exchangeBaseUrl,
+ }
+ : undefined);
const details = await getPaymentBalanceDetailsInTx(wex, tx, {
- restrictSenderScope: undefined,
+ restrictSenderScope: effectiveScope,
restrictReceiverExchanges: req.restrictExchanges,
restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined,
currency,
@@ -521,7 +676,22 @@ export async function reportInsufficientBalanceDetails(
depositPaytoUri: req.depositPaytoUri,
});
const perExchange: PaymentInsufficientBalanceDetails["perExchange"] = {};
+ const diagnosticExchanges: PaymentInsufficientBalanceStructuredDetails["exchanges"] =
+ {};
const exchanges = await tx.getExchanges();
+ const supersededCoinBalances = await getSupersededCoinBalances(
+ wex,
+ tx,
+ currency,
+ );
+ let combinedBaseMaximum = zero;
+ let combinedExchangeMaximum = zero;
+ let largestExchangeMaximum = zero;
+ let combinedFeeFreeMaximum = zero;
+ let largestFeeFreeMaximum = zero;
+ let anySameCurrencyExchange = false;
+ let allUsablePeerExchangesMissingGlobalFees = req.operation === "peer";
+ let sawMasterPubMismatch = false;
for (const exch of exchanges) {
if (!exch.detailsPointer) {
@@ -541,6 +711,7 @@ export async function reportInsufficientBalanceDetails(
// Do not report anything for an exchange with a different currency.
continue;
}
+ anySameCurrencyExchange = true;
const exchDet = await getPaymentBalanceDetailsInTx(wex, tx, {
restrictSenderScope: {
type: ScopeType.Exchange,
@@ -554,6 +725,155 @@ export async function reportInsufficientBalanceDetails(
depositPaytoUri: req.depositPaytoUri,
});
+ const reasons: CoinSelectionFailureReason[] = [];
+ const inRequestedScope =
+ (!req.exchangeBaseUrl || req.exchangeBaseUrl === exch.baseUrl) &&
+ (!req.restrictScope ||
+ (await checkExchangeInScopeTx(tx, exch.baseUrl, req.restrictScope)));
+ if (!inRequestedScope && effectiveScope) {
+ appendReason(reasons, {
+ type: CoinSelectionFailureReasonType.ScopeRestricted,
+ scopeInfo: effectiveScope,
+ });
+ }
+
+ const supersededBalance = supersededCoinBalances.get(exch.baseUrl) ?? zero;
+ if (Amounts.isNonZero(supersededBalance)) {
+ appendReason(reasons, {
+ type: CoinSelectionFailureReasonType.SupersededExchangeMasterPub,
+ amountAffected: Amounts.stringify(supersededBalance),
+ });
+ }
+
+ if (
+ (req.requiredMinimumAge ?? 0) > 0 &&
+ Amounts.cmp(exchDet.balanceAgeAcceptable, exchDet.balanceMaterial) < 0
+ ) {
+ appendReason(reasons, {
+ type: CoinSelectionFailureReasonType.MinimumAge,
+ requiredMinimumAge: req.requiredMinimumAge ?? 0,
+ amountAgeAcceptable: Amounts.stringify(exchDet.balanceAgeAcceptable),
+ });
+ }
+
+ const acceptance = checkExchangeAccepted(exchWire, req.restrictExchanges);
+ if (!acceptance.accepted) {
+ if (acceptance.masterPubMismatch) {
+ if (Amounts.isNonZero(exchDet.balanceMaterial)) {
+ sawMasterPubMismatch = true;
+ }
+ appendReason(reasons, {
+ type: CoinSelectionFailureReasonType.ReceiverExchangeMasterPubMismatch,
+ walletMasterPub: exchWire.masterPublicKey,
+ receiverMasterPubs: acceptance.receiverMasterPubs,
+ });
+ } else {
+ appendReason(reasons, {
+ type: CoinSelectionFailureReasonType.ReceiverNotAccepted,
+ });
+ }
+ }
+
+ let wireMatch: MatchingWireResult | undefined;
+ if (req.wireMethod) {
+ wireMatch = findMatchingWire(
+ req.wireMethod,
+ req.depositPaytoUri,
+ exchWire,
+ );
+ switch (wireMatch.status) {
+ case "match":
+ break;
+ case "account-restricted":
+ appendReason(reasons, {
+ type: CoinSelectionFailureReasonType.DepositAccountRestricted,
+ wireMethod: req.wireMethod,
+ accountRestrictions: wireMatch.accountRestrictions,
+ });
+ break;
+ case "wire-method-unsupported":
+ appendReason(reasons, {
+ type: CoinSelectionFailureReasonType.WireMethodUnsupported,
+ wireMethod: req.wireMethod,
+ });
+ break;
+ case "wire-fee-unavailable":
+ appendReason(reasons, {
+ type: CoinSelectionFailureReasonType.WireFeeUnavailable,
+ wireMethod: req.wireMethod,
+ });
+ break;
+ default:
+ assertUnreachable(wireMatch);
+ }
+ }
+
+ if (req.operation === "peer" && missingGlobalFees) {
+ appendReason(reasons, {
+ type: CoinSelectionFailureReasonType.ExchangeGlobalFeesUnavailable,
+ });
+ } else if (req.operation === "peer" && inRequestedScope) {
+ allUsablePeerExchangesMissingGlobalFees = false;
+ }
+
+ let baseMaximum = exchDet.maxMerchantEffectiveDepositAmount;
+ if (wireMatch?.status === "match") {
+ baseMaximum =
+ Amounts.cmp(baseMaximum, wireMatch.wireFee) >= 0
+ ? Amounts.sub(baseMaximum, wireMatch.wireFee).amount
+ : zero;
+ }
+ const exchangeEligible =
+ inRequestedScope &&
+ acceptance.accepted &&
+ (!wireMatch || wireMatch.status === "match") &&
+ !(req.operation === "peer" && missingGlobalFees);
+ if (!exchangeEligible) {
+ baseMaximum = zero;
+ }
+
+ const feeFreeMaximum = exchangeEligible
+ ? exchDet.balanceAgeAcceptable
+ : zero;
+ combinedFeeFreeMaximum = Amounts.add(
+ combinedFeeFreeMaximum,
+ feeFreeMaximum,
+ ).amount;
+ largestFeeFreeMaximum = Amounts.max(largestFeeFreeMaximum, feeFreeMaximum);
+
+ const allowance =
+ req.depositFeeLimit ??
+ (req.feesCoveredByCounterparty ? req.instructedAmount : zero);
+ const maximumPayable = exchangeEligible
+ ? addFeeAllowance(baseMaximum, exchDet.balanceAgeAcceptable, allowance)
+ : zero;
+ combinedBaseMaximum = Amounts.add(combinedBaseMaximum, baseMaximum).amount;
+ combinedExchangeMaximum = Amounts.add(
+ combinedExchangeMaximum,
+ maximumPayable,
+ ).amount;
+ largestExchangeMaximum = Amounts.max(
+ largestExchangeMaximum,
+ maximumPayable,
+ );
+
+ if (
+ exchangeEligible &&
+ Amounts.cmp(exchDet.balanceAgeAcceptable, req.instructedAmount) >= 0 &&
+ Amounts.cmp(maximumPayable, req.instructedAmount) < 0
+ ) {
+ appendReason(reasons, {
+ type: CoinSelectionFailureReasonType.FeesNotCovered,
+ maximumPayableAmount: Amounts.stringify(maximumPayable),
+ });
+ }
+
+ diagnosticExchanges[exch.baseUrl] = {
+ balance: makeBalanceSnapshot(exchDet),
+ maximumPayableAmount: Amounts.stringify(maximumPayable),
+ reasons,
+ };
+
perExchange[exch.baseUrl] = {
balanceAvailable: Amounts.stringify(exchDet.balanceAvailable),
balanceMaterial: Amounts.stringify(exchDet.balanceMaterial),
@@ -584,23 +904,145 @@ export async function reportInsufficientBalanceDetails(
// public key it does not accept, i.e. the key the merchant configured
// differs from the one the coins were issued under. The reverse case
// is a URL the merchant does not list, not a key mismatch.
- exchangeMasterPubMismatch:
- Amounts.cmp(
- exchDet.balanceReceiverExchangeUrlAcceptable,
- exchDet.balanceReceiverExchangePubAcceptable,
- ) > 0,
+ exchangeMasterPubMismatch: acceptance.masterPubMismatch,
causeHint: !!wex.ws.devExperimentState.merchantDepositInsufficient
? InsufficientBalanceHint.MerchantDepositInsufficient
- : getHint(req, exchDet),
+ : req.operation === "peer" && missingGlobalFees
+ ? InsufficientBalanceHint.ExchangeMissingGlobalFees
+ : getHint(req, exchDet),
};
}
+ const globalReasons: CoinSelectionFailureReason[] = [];
+ const availabilityReason = getBalanceAvailabilityReason(
+ details,
+ req.instructedAmount,
+ );
+ if (availabilityReason) {
+ appendReason(globalReasons, availabilityReason);
+ }
+ if (
+ (req.requiredMinimumAge ?? 0) > 0 &&
+ Amounts.cmp(details.balanceMaterial, req.instructedAmount) >= 0 &&
+ Amounts.cmp(details.balanceAgeAcceptable, req.instructedAmount) < 0
+ ) {
+ appendReason(globalReasons, {
+ type: CoinSelectionFailureReasonType.MinimumAge,
+ requiredMinimumAge: req.requiredMinimumAge ?? 0,
+ amountAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable),
+ });
+ }
+ if (
+ req.restrictExchanges &&
+ Amounts.cmp(details.balanceAgeAcceptable, req.instructedAmount) >= 0 &&
+ Amounts.cmp(details.balanceReceiverAcceptable, req.instructedAmount) < 0
+ ) {
+ appendReason(globalReasons, {
+ type: CoinSelectionFailureReasonType.ReceiverNotAccepted,
+ });
+ }
+ if (effectiveScope && Amounts.isZero(details.balanceMaterial)) {
+ appendReason(globalReasons, {
+ type: CoinSelectionFailureReasonType.ScopeRestricted,
+ scopeInfo: effectiveScope,
+ });
+ }
+ if (
+ req.operation === "peer" &&
+ anySameCurrencyExchange &&
+ allUsablePeerExchangesMissingGlobalFees
+ ) {
+ appendReason(globalReasons, {
+ type: CoinSelectionFailureReasonType.ExchangeGlobalFeesUnavailable,
+ });
+ }
+
+ const allowance =
+ req.depositFeeLimit ??
+ (req.feesCoveredByCounterparty ? req.instructedAmount : zero);
+ const combinedMaximum = addFeeAllowance(
+ combinedBaseMaximum,
+ details.balanceAgeAcceptable,
+ allowance,
+ );
+ const maximumPayable =
+ req.operation === "peer" ? largestExchangeMaximum : combinedMaximum;
+ const feeFreeMaximum =
+ req.operation === "peer" ? largestFeeFreeMaximum : combinedFeeFreeMaximum;
+ const maximumFailure = classifyMaximumFailure({
+ operation: req.operation,
+ instructedAmount: req.instructedAmount,
+ maximumPayable,
+ combinedMaximumPayable: combinedExchangeMaximum,
+ feeFreeMaximum,
+ });
+ if (maximumFailure.balanceFragmented) {
+ appendReason(globalReasons, {
+ type: CoinSelectionFailureReasonType.BalanceFragmented,
+ combinedMaximumPayableAmount: Amounts.stringify(combinedExchangeMaximum),
+ });
+ }
+ if (maximumFailure.feesNotCovered) {
+ appendReason(globalReasons, {
+ type: CoinSelectionFailureReasonType.FeesNotCovered,
+ maximumPayableAmount: Amounts.stringify(maximumPayable),
+ });
+ }
+ for (const type of [
+ CoinSelectionFailureReasonType.ReceiverExchangeMasterPubMismatch,
+ CoinSelectionFailureReasonType.WireMethodUnsupported,
+ CoinSelectionFailureReasonType.WireFeeUnavailable,
+ CoinSelectionFailureReasonType.DepositAccountRestricted,
+ CoinSelectionFailureReasonType.ExchangeGlobalFeesUnavailable,
+ CoinSelectionFailureReasonType.SupersededExchangeMasterPub,
+ ]) {
+ const exchangeReason = Object.values(diagnosticExchanges)
+ .flatMap((x) =>
+ x.reasons.filter(
+ (reason) =>
+ Amounts.isNonZero(x.balance.material) ||
+ reason.type ===
+ CoinSelectionFailureReasonType.SupersededExchangeMasterPub,
+ ),
+ )
+ .find((x) => x.type === type);
+ if (exchangeReason) {
+ appendReason(globalReasons, exchangeReason);
+ }
+ }
+ if (globalReasons.length === 0) {
+ appendReason(globalReasons, {
+ type: CoinSelectionFailureReasonType.SelectionFailed,
+ });
+ }
+
+ const structuredDetails: PaymentInsufficientBalanceStructuredDetails = {
+ balance: makeBalanceSnapshot(details),
+ maximumPayableAmount: Amounts.stringify(maximumPayable),
+ reasons: globalReasons,
+ exchanges: diagnosticExchanges,
+ };
+
+ let causeHint = getHint(req, details);
+ if (
+ req.operation === "peer" &&
+ globalReasons.some(
+ (x) =>
+ x.type === CoinSelectionFailureReasonType.ExchangeGlobalFeesUnavailable,
+ )
+ ) {
+ causeHint = InsufficientBalanceHint.ExchangeMissingGlobalFees;
+ } else if (sawMasterPubMismatch) {
+ causeHint = InsufficientBalanceHint.MerchantAcceptInsufficient;
+ }
+
return {
amountRequested: Amounts.stringify(req.instructedAmount),
wireMethod: req.wireMethod,
causeHint: !!wex.ws.devExperimentState.merchantDepositInsufficient
? InsufficientBalanceHint.MerchantDepositInsufficient
- : getHint(req, details),
+ : causeHint,
+ ...structuredDetails,
balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable),
balanceAvailable: Amounts.stringify(details.balanceAvailable),
balanceMaterial: Amounts.stringify(details.balanceMaterial),
@@ -1286,15 +1728,32 @@ export type AvailableCoinsOfDenom = DenominationInfo & {
numAvailable: number;
};
+export type MatchingWireResult =
+ | { status: "match"; wireFee: AmountJson }
+ | {
+ status: "account-restricted";
+ accountRestrictions: Record<string, AccountRestriction[]>;
+ }
+ | { status: "wire-method-unsupported" }
+ | { status: "wire-fee-unavailable" };
+
export function findMatchingWire(
wireMethod: string,
depositPaytoUri: string | undefined,
exchangeWireDetails: { wireInfo: WireInfo },
-):
- | { ok: true; wireFee: AmountJson }
- | { ok: false; accountRestrictions: Record<string, AccountRestriction[]> }
- | undefined {
+): MatchingWireResult {
const accountRestrictions: Record<string, AccountRestriction[]> = {};
+ let supportsWireMethod = false;
+ const wireFeeStr = exchangeWireDetails.wireInfo.feesForType[wireMethod]?.find(
+ (x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ },
+ )?.wireFee;
+
for (const acc of exchangeWireDetails.wireInfo.accounts) {
const ppRes = Paytos.fromString(acc.payto_uri);
if (Result.isError(ppRes)) {
@@ -1305,18 +1764,9 @@ export function findMatchingWire(
if (pp.targetType !== wireMethod) {
continue;
}
- const wireFeeStr = exchangeWireDetails.wireInfo.feesForType[
- wireMethod
- ]?.find((x) => {
- return AbsoluteTime.isBetween(
- AbsoluteTime.now(),
- AbsoluteTime.fromProtocolTimestamp(x.startStamp),
- AbsoluteTime.fromProtocolTimestamp(x.endStamp),
- );
- })?.wireFee;
-
+ supportsWireMethod = true;
if (!wireFeeStr) {
- continue;
+ return { status: "wire-fee-unavailable" };
}
let debitAccountCheckOk = false;
@@ -1340,44 +1790,76 @@ export function findMatchingWire(
continue;
}
- return {
- ok: true,
- wireFee: Amounts.parseOrThrow(wireFeeStr),
- };
+ return { status: "match", wireFee: Amounts.parseOrThrow(wireFeeStr) };
}
if (Object.keys(accountRestrictions).length > 0) {
return {
- ok: false,
+ status: "account-restricted",
accountRestrictions,
};
- } else {
- return undefined;
}
+ return supportsWireMethod
+ ? { status: "wire-fee-unavailable" }
+ : { status: "wire-method-unsupported" };
}
-function checkExchangeAccepted(
- exchangeDetails: ExchangeDetails,
+export interface ExchangeAcceptanceResult {
+ accepted: boolean;
+ acceptedByExchangePub: boolean;
+ acceptedByAuditorPub: boolean;
+ exchangeUrlListed: boolean;
+ receiverMasterPubs: string[];
+ masterPubMismatch: boolean;
+}
+
+export function checkExchangeAccepted(
+ exchangeDetails: Pick<
+ ExchangeDetails,
+ "masterPublicKey" | "exchangeBaseUrl" | "auditors"
+ >,
exchangeRestrictions: ExchangeRestrictionSpec | undefined,
-): boolean {
+): ExchangeAcceptanceResult {
if (!exchangeRestrictions) {
- return true;
+ return {
+ accepted: true,
+ acceptedByExchangePub: true,
+ acceptedByAuditorPub: true,
+ exchangeUrlListed: true,
+ receiverMasterPubs: [exchangeDetails.masterPublicKey],
+ masterPubMismatch: false,
+ };
}
- let accepted = false;
+ let acceptedByExchangePub = false;
+ let acceptedByAuditorPub = false;
+ const receiverMasterPubs: string[] = [];
for (const allowedExchange of exchangeRestrictions.exchanges) {
+ if (allowedExchange.exchangeBaseUrl === exchangeDetails.exchangeBaseUrl) {
+ receiverMasterPubs.push(allowedExchange.exchangePub);
+ }
if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
- accepted = true;
+ acceptedByExchangePub = true;
break;
}
}
for (const allowedAuditor of exchangeRestrictions.auditors) {
for (const providedAuditor of exchangeDetails.auditors) {
if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) {
- accepted = true;
+ acceptedByAuditorPub = true;
break;
}
}
}
- return accepted;
+ const accepted = acceptedByExchangePub || acceptedByAuditorPub;
+ const exchangeUrlListed = receiverMasterPubs.length > 0;
+ return {
+ accepted,
+ acceptedByExchangePub,
+ acceptedByAuditorPub,
+ exchangeUrlListed,
+ receiverMasterPubs,
+ masterPubMismatch:
+ !acceptedByExchangePub && exchangeUrlListed && !acceptedByAuditorPub,
+ };
}
interface SelectPayCandidatesRequest {
@@ -1434,27 +1916,31 @@ async function selectPayCandidates(
req.depositPaytoUri,
exchangeDetails,
);
- if (!wireMatch) {
- if (logger.shouldLogTrace()) {
- logger.trace(
- `skipping ${exchange.baseUrl} due to missing wire info mismatch`,
- );
- }
- continue;
- }
- if (!wireMatch.ok) {
- depositRestrictions[exchange.baseUrl] = wireMatch.accountRestrictions;
- continue;
+ switch (wireMatch.status) {
+ case "match":
+ wfPerExchange[exchange.baseUrl] = wireMatch.wireFee;
+ break;
+ case "account-restricted":
+ depositRestrictions[exchange.baseUrl] = wireMatch.accountRestrictions;
+ continue;
+ case "wire-method-unsupported":
+ case "wire-fee-unavailable":
+ logger.shouldLogTrace() &&
+ logger.trace(
+ `skipping ${exchange.baseUrl} due to ${wireMatch.status}`,
+ );
+ continue;
+ default:
+ assertUnreachable(wireMatch);
}
- wfPerExchange[exchange.baseUrl] = wireMatch.wireFee;
}
// Exchange is trusted in the exchange list or auditor list
- let accepted = checkExchangeAccepted(
+ const acceptance = checkExchangeAccepted(
exchangeDetails,
req.restrictExchanges,
);
- if (!accepted) {
+ if (!acceptance.accepted) {
if (logger.shouldLogTrace()) {
logger.trace(`skipping ${exchange.baseUrl} due to unacceptability`);
}
@@ -1803,6 +2289,10 @@ export async function selectPeerCoinsInTx(
requiredMinimumAge: undefined,
wireMethod: undefined,
depositPaytoUri: undefined,
+ restrictScope: req.restrictScope,
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ feesCoveredByCounterparty: req.feesCoveredByCounterparty,
+ operation: "peer",
},
);
return {