taler-typescript-core

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

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:
Mpackages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-exchange.ts | 24++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-wallet.ts | 287++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/balance.test.ts | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/balance.ts | 40+++++++++++++++++++++++++++-------------
Mpackages/taler-wallet-core/src/coinSelection.test.ts | 153++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/coinSelection.ts | 596++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
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 {