taler-typescript-core

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

commit d7f0876e4c484537b22f4528a1ac66b71fb12735
parent 69c04d0885dd73cbfd8eaba10f7c23b2cf79354a
Author: Florian Dold <florian@dold.me>
Date:   Mon, 18 Aug 2025 16:15:28 +0200

wallet-core: apply per-exchange restriction in balance reporting properly, test

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-wallet-core/src/balance.ts | 44++++++++++++++++++++++++++++++++++++++------
Mpackages/taler-wallet-core/src/coinSelection.ts | 39++++++++++++++++-----------------------
3 files changed, 146 insertions(+), 43 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 @@ -19,10 +19,13 @@ */ import { AmountString, - Duration, j2s, + Logger, PaymentInsufficientBalanceDetails, + PreparePayResultType, + succeedOrThrow, TalerErrorCode, + TalerMerchantInstanceHttpClient, TalerWireGatewayAuth, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; @@ -39,15 +42,23 @@ import { setupDb, } from "../harness/harness.js"; +const logger = new Logger("test-wallet-insufficient-balance.ts"); + export async function runWalletInsufficientBalanceTest(t: GlobalTestState) { // Set up test environment const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")); - let { bankClient, bank, exchange, merchant, walletClient } = - await createSimpleTestkudosEnvironmentV3(t, coinConfig, { - skipWireFeeCreation: true, - }); + let { + bankClient, + bank, + exchange, + merchant, + walletClient, + merchantAdminAccessToken, + } = await createSimpleTestkudosEnvironmentV3(t, coinConfig, { + skipWireFeeCreation: true, + }); const dbTwo = await setupDb(t, { nameSuffix: "two", @@ -116,11 +127,12 @@ export async function runWalletInsufficientBalanceTest(t: GlobalTestState) { await wres.withdrawalFinishedCond; { - const exc = await t.assertThrowsTalerErrorAsyncLegacy( - walletClient.call(WalletApiOperation.CheckDeposit, { - amount: "TESTKUDOS:5" as AmountString, - depositPaytoUri: "payto://x-taler-bank/localhost/foobar", - }), + const exc = await t.assertThrowsTalerErrorAsync( + async () => + await walletClient.call(WalletApiOperation.CheckDeposit, { + amount: "TESTKUDOS:5" as AmountString, + depositPaytoUri: "payto://x-taler-bank/localhost/foobar", + }), ); t.assertDeepEqual( @@ -154,10 +166,11 @@ export async function runWalletInsufficientBalanceTest(t: GlobalTestState) { }); await wres2.withdrawalFinishedCond; - const exc = await t.assertThrowsTalerErrorAsyncLegacy( - walletClient.call(WalletApiOperation.CheckPeerPushDebit, { - amount: "TESTKUDOS:20" as AmountString, - }), + const exc = await t.assertThrowsTalerErrorAsync( + async () => + await walletClient.call(WalletApiOperation.CheckPeerPushDebit, { + amount: "TESTKUDOS:20" as AmountString, + }), ); const insufficientBalanceDetails: PaymentInsufficientBalanceDetails = @@ -183,6 +196,71 @@ export async function runWalletInsufficientBalanceTest(t: GlobalTestState) { "TESTKUDOS:9.47", ); } + + // Now test for insufficient balance details with the merchant. + // The merchant only accepts one of the exchanges. + { + const merchantClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + + const orderResp = succeedOrThrow( + await merchantClient.createOrder(merchantAdminAccessToken, { + order: { + amount: "TESTKUDOS:12", + summary: "Test", + }, + }), + ); + + let orderStatus = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + orderResp.order_id, + ), + ); + + t.assertTrue(orderStatus.order_status === "unpaid"); + + // Make wallet pay for the order + + const res = await walletClient.call(WalletApiOperation.PreparePayForUri, { + talerPayUri: orderStatus.taler_pay_uri, + }); + + t.assertDeepEqual(res.status, PreparePayResultType.InsufficientBalance); + + const insufficientBalanceDetails: PaymentInsufficientBalanceDetails = + res.balanceDetails; + + const perMyExchange = + insufficientBalanceDetails.perExchange[exchange.baseUrl]; + + t.assertTrue(!!perMyExchange); + + console.log(j2s(res)); + + t.assertAmountEquals( + insufficientBalanceDetails.balanceAvailable, + "TESTKUDOS:14.57", + ); + + t.assertAmountEquals( + insufficientBalanceDetails.balanceReceiverAcceptable, + "TESTKUDOS:9.72", + ); + + t.assertAmountEquals( + insufficientBalanceDetails.perExchange[exchange.baseUrl].balanceAvailable, + "TESTKUDOS:9.72", + ); + + t.assertAmountEquals( + insufficientBalanceDetails.perExchange[exchangeTwo.baseUrl] + .balanceAvailable, + "TESTKUDOS:4.85", + ); + } } runWalletInsufficientBalanceTest.suites = ["wallet"]; diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -84,6 +84,7 @@ import { WithdrawalRecordType, } from "./db.js"; import { + checkExchangeInScopeTx, getExchangeScopeInfo, getExchangeWireDetailsInTx, } from "./exchanges.js"; @@ -668,9 +669,24 @@ export async function getBalances( export interface PaymentRestrictionsForBalance { currency: string; + minAge: number; - restrictExchanges: ExchangeRestrictionSpec | undefined; + + /** + * Restriction by the *sender* on the exchange. + */ + restrictSenderScope: ScopeInfo | undefined; + + /** + * Restrictions of the *receiver* on the exchanges. + */ + restrictReceiverExchanges: ExchangeRestrictionSpec | undefined; + + /** + * Restriction of the receiver on the supported wire methods. + */ restrictWireMethods: string[] | undefined; + depositPaytoUri: string | undefined; } @@ -738,6 +754,8 @@ export async function getPaymentBalanceDetails( "refreshGroups", "exchanges", "exchangeDetails", + "globalCurrencyAuditors", + "globalCurrencyExchanges", "denominations", ], }, @@ -755,6 +773,8 @@ export async function getPaymentBalanceDetailsInTx( "refreshGroups", "exchanges", "exchangeDetails", + "globalCurrencyAuditors", + "globalCurrencyExchanges", "denominations", ] >, @@ -789,6 +809,17 @@ export async function getPaymentBalanceDetailsInTx( continue; } + // Skip exchanges if excluded by the receiver. + if (req.restrictSenderScope && + !(await checkExchangeInScopeTx( + tx, + ca.exchangeBaseUrl, + req.restrictSenderScope, + )) + ) { + continue; + } + const wireDetails = await getExchangeWireDetailsInTx( tx, ca.exchangeBaseUrl, @@ -827,16 +858,16 @@ export async function getPaymentBalanceDetailsInTx( let merchantExchangeAcceptable = false; - if (!req.restrictExchanges) { + if (!req.restrictReceiverExchanges) { merchantExchangeAcceptable = true; } else { - for (const ex of req.restrictExchanges.exchanges) { + for (const ex of req.restrictReceiverExchanges.exchanges) { if (ex.exchangeBaseUrl === ca.exchangeBaseUrl) { merchantExchangeAcceptable = true; break; } } - for (const acceptedAuditor of req.restrictExchanges.auditors) { + for (const acceptedAuditor of req.restrictReceiverExchanges.auditors) { for (const exchangeAuditor of wireDetails.auditors) { if (acceptedAuditor.auditorBaseUrl === exchangeAuditor.auditor_url) { merchantExchangeAcceptable = true; @@ -893,7 +924,7 @@ export async function getPaymentBalanceDetailsInTx( } const balRefresh = computeRefreshGroupAvailableAmountForExchanges( r, - req.restrictExchanges?.exchanges, + req.restrictReceiverExchanges?.exchanges, ); d.balanceAvailable = Amounts.add(d.balanceAvailable, balRefresh).amount; }); @@ -932,7 +963,8 @@ export async function getBalanceDetail( return await getPaymentBalanceDetails(wex, { currency: req.currency, - restrictExchanges: { + restrictSenderScope: undefined, + restrictReceiverExchanges: { auditors: [], exchanges, }, diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -306,7 +306,7 @@ export async function selectPayCoinsInTx( logger.trace(`selecting coins for ${j2s(req)}`); } - if (!! wex.ws.devExperimentState.merchantDepositInsufficient) { + if (!!wex.ws.devExperimentState.merchantDepositInsufficient) { return { type: "failure", insufficientBalanceDetails: await reportInsufficientBalanceDetails( @@ -570,14 +570,18 @@ export async function reportInsufficientBalanceDetails( "exchangeDetails", "refreshGroups", "denominations", + "globalCurrencyAuditors", + "globalCurrencyExchanges", ] >, req: ReportInsufficientBalanceRequest, ): Promise<PaymentInsufficientBalanceDetails> { + const currency = Amounts.currencyOf(req.instructedAmount); const details = await getPaymentBalanceDetailsInTx(wex, tx, { - restrictExchanges: req.restrictExchanges, + restrictSenderScope: undefined, + restrictReceiverExchanges: req.restrictExchanges, restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined, - currency: Amounts.currencyOf(req.instructedAmount), + currency, minAge: req.requiredMinimumAge ?? 0, depositPaytoUri: req.depositPaytoUri, }); @@ -603,15 +607,12 @@ export async function reportInsufficientBalanceDetails( continue; } const exchDet = await getPaymentBalanceDetailsInTx(wex, tx, { - restrictExchanges: { - exchanges: [ - { - exchangeBaseUrl: exch.baseUrl, - exchangePub: exch.detailsPointer?.masterPublicKey, - }, - ], - auditors: [], + restrictSenderScope: { + type: ScopeType.Exchange, + currency, + url: exch.baseUrl, }, + restrictReceiverExchanges: req.restrictExchanges, restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined, currency: Amounts.currencyOf(req.instructedAmount), minAge: req.requiredMinimumAge ?? 0, @@ -635,7 +636,7 @@ export async function reportInsufficientBalanceDetails( exchDet.maxMerchantEffectiveDepositAmount, ), missingGlobalFees, - causeHint: (!! wex.ws.devExperimentState.merchantDepositInsufficient) + causeHint: !!wex.ws.devExperimentState.merchantDepositInsufficient ? InsufficientBalanceHint.MerchantDepositInsufficient : getHint(req, exchDet), }; @@ -644,7 +645,7 @@ export async function reportInsufficientBalanceDetails( return { amountRequested: Amounts.stringify(req.instructedAmount), wireMethod: req.wireMethod, - causeHint: (!! wex.ws.devExperimentState.merchantDepositInsufficient) + causeHint: !!wex.ws.devExperimentState.merchantDepositInsufficient ? InsufficientBalanceHint.MerchantDepositInsufficient : getHint(req, details), balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable), @@ -1017,11 +1018,7 @@ async function selectPayCandidates( } const isInScope = req.restrictScope - ? await checkExchangeInScopeTx( - tx, - exchange.baseUrl, - req.restrictScope, - ) + ? await checkExchangeInScopeTx(tx, exchange.baseUrl, req.restrictScope) : true; if (!isInScope) { @@ -1611,11 +1608,7 @@ export async function getMaxPeerPushDebitAmount( continue; } const isInScope = req.restrictScope - ? await checkExchangeInScopeTx( - tx, - exch.baseUrl, - req.restrictScope, - ) + ? await checkExchangeInScopeTx(tx, exch.baseUrl, req.restrictScope) : true; if (!isInScope) { continue;