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:
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;