commit 3529b94fe7c59ac357b8b8e053b3b0c70be42d15
parent 9c0fc4565460544a4f377d62bc5e18182e3ed7ca
Author: Florian Dold <florian@dold.me>
Date: Mon, 14 Jul 2025 22:34:54 +0200
harness: add separate test to reproduce exchange crash
Diffstat:
3 files changed, 228 insertions(+), 26 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-deposit-too-large.ts b/packages/taler-harness/src/integrationtests/test-deposit-too-large.ts
@@ -0,0 +1,166 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AmountString,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import {
+ CryptoDispatcher,
+ SynchronousCryptoWorkerFactoryPlain,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ checkReserve,
+ CoinInfo,
+ depositCoinBatch,
+ downloadExchangeInfo,
+ findDenomOrThrow,
+ topupReserveWithBank,
+ withdrawCoin,
+} from "@gnu-taler/taler-wallet-core/dbless";
+import { CoinConfig } from "../harness/denomStructures.js";
+import { createSimpleTestkudosEnvironmentV2 } from "../harness/environments.js";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+];
+
+/**
+ * Test deposit with a large number of coins.
+ *
+ * In particular, this checks that the wallet properly
+ * splits deposits into batches with <=64 coins per batch.
+ *
+ * Since we use an artifically large number of coins, this
+ * test is a bit slower than other tests.
+ */
+export async function runDepositTooLargeTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(t);
+
+ const http = harnessHttpLib;
+ const cryptiDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptiDisp.cryptoApi;
+
+ const merchantPair = await cryptoApi.createEddsaKeypair({});
+ const merchantPub = merchantPair.pub;
+ const merchantPriv = merchantPair.priv;
+
+ try {
+ // Withdraw digital cash into the wallet.
+
+ const exchangeInfo = await downloadExchangeInfo(exchange.baseUrl, http);
+
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+
+ let reserveUrl = new URL(
+ `reserves/${reserveKeyPair.pub}`,
+ exchange.baseUrl,
+ );
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+ const longpollReq = http.fetch(reserveUrl.href, {
+ method: "GET",
+ });
+
+ await topupReserveWithBank({
+ amount: "TESTKUDOS:20" as AmountString,
+ http,
+ reservePub: reserveKeyPair.pub,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeInfo,
+ });
+
+ console.log("waiting for longpoll request");
+ const resp = await longpollReq;
+ console.log(`got response, status ${resp.status}`);
+
+ console.log(exchangeInfo);
+
+ await checkReserve(http, exchange.baseUrl, reserveKeyPair.pub);
+
+ const d1 = findDenomOrThrow(exchangeInfo, "TESTKUDOS:0.1" as AmountString);
+
+ const coins: CoinInfo[] = [];
+ const amounts: AmountString[] = [];
+
+ for (let i = 0; i < 100; i++) {
+ const coin = await withdrawCoin({
+ http,
+ cryptoApi,
+ reserveKeyPair: {
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ },
+ denom: d1,
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+ coins.push(coin);
+ amounts.push("TESTKUDOS:0.1");
+ }
+
+ const wireSalt = encodeCrock(getRandomBytes(16));
+ const contractTermsHash = encodeCrock(getRandomBytes(64));
+
+ await depositCoinBatch({
+ contractTermsHash,
+ merchantPriv,
+ wireSalt,
+ amounts,
+ coins,
+ cryptoApi,
+ exchangeBaseUrl: exchange.baseUrl,
+ http,
+ });
+ } catch (e) {
+ if (e instanceof TalerError) {
+ console.log(e);
+ console.log(j2s(e.errorDetail));
+ } else {
+ console.log(e);
+ }
+ }
+
+ {
+ // Try downloading exchange info again to make
+ // sure that exchange is still running and didn't crash!
+ const exchangeInfo = await downloadExchangeInfo(exchange.baseUrl, http);
+ }
+}
+
+runDepositTooLargeTest.suites = ["wallet", "slow"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -48,6 +48,7 @@ import { runDenomUnofferedTest } from "./test-denom-unoffered.js";
import { runDepositFaultTest } from "./test-deposit-fault.js";
import { runDepositLargeTest } from "./test-deposit-large.js";
import { runDepositMergeTest } from "./test-deposit-merge.js";
+import { runDepositTooLargeTest } from "./test-deposit-too-large.js";
import { runDepositTest } from "./test-deposit.js";
import { runExchangeDepositTest } from "./test-exchange-deposit.js";
import { runExchangeKycAuthTest } from "./test-exchange-kyc-auth.js";
@@ -361,6 +362,7 @@ const allTests: TestMainFunction[] = [
runWalletExchangeMigrationTest,
runKycFormCompressionTest,
runDepositLargeTest,
+ runDepositTooLargeTest,
];
export interface TestRunSpec {
diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts
@@ -31,6 +31,7 @@ import {
AmountJson,
AmountString,
Amounts,
+ BatchDepositRequestCoin,
DenominationPubKey,
EddsaPrivateKeyString,
EddsaPublicKeyString,
@@ -215,7 +216,32 @@ export async function depositCoin(args: {
// 16 bytes, crockford encoded
wireSalt?: string;
}): Promise<void> {
- const { coin, http, cryptoApi } = args;
+ return await depositCoinBatch({
+ coins: [args.coin],
+ amounts: [args.amount],
+ cryptoApi: args.cryptoApi,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ http: args.http,
+ merchantPriv: args.merchantPriv,
+ depositPayto: args.depositPayto,
+ contractTermsHash: args.contractTermsHash,
+ wireSalt: args.wireSalt,
+ });
+}
+
+export async function depositCoinBatch(args: {
+ http: HttpRequestLibrary;
+ cryptoApi: TalerCryptoInterface;
+ exchangeBaseUrl: string;
+ coins: CoinInfo[];
+ amounts: AmountString[];
+ depositPayto?: string;
+ merchantPriv: string;
+ contractTermsHash?: string;
+ // 16 bytes, crockford encoded
+ wireSalt?: string;
+}): Promise<void> {
+ const { coins, amounts, http, cryptoApi } = args;
const depositPayto =
args.depositPayto ?? "payto://x-taler-bank/localhost/foo?receiver-name=foo";
const wireSalt = args.wireSalt ?? encodeCrock(getRandomBytes(16));
@@ -238,35 +264,43 @@ export async function depositCoin(args: {
merchantPriv = res.priv;
merchantPub = res.pub;
}
- const dp = await cryptoApi.signDepositPermission({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- contractTermsHash,
- denomKeyType: coin.denomPub.cipher,
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- exchangeBaseUrl: args.exchangeBaseUrl,
- feeDeposit: Amounts.parseOrThrow(coin.feeDeposit),
- merchantPub,
- spendAmount: Amounts.parseOrThrow(args.amount),
- timestamp: depositTimestamp,
- refundDeadline: refundDeadline,
- wireInfoHash: hashWire(depositPayto, wireSalt),
- });
+ if (coins.length != amounts.length) {
+ throw Error(`coins and amounts must match`);
+ }
+
+ const depositCoins: BatchDepositRequestCoin[] = [];
+ for (let i = 0; i < coins.length; i++) {
+ const coin = coins[i];
+ const amount = amounts[i];
+ const dp = await cryptoApi.signDepositPermission({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contractTermsHash,
+ denomKeyType: coin.denomPub.cipher,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ feeDeposit: Amounts.parseOrThrow(coin.feeDeposit),
+ merchantPub,
+ spendAmount: Amounts.parseOrThrow(amount),
+ timestamp: depositTimestamp,
+ refundDeadline: refundDeadline,
+ wireInfoHash: hashWire(depositPayto, wireSalt),
+ });
+ depositCoins.push({
+ contribution: Amounts.stringify(dp.contribution),
+ coin_pub: dp.coin_pub,
+ coin_sig: dp.coin_sig,
+ denom_pub_hash: dp.h_denom,
+ ub_sig: dp.ub_sig,
+ });
+ }
const merchantContractSigResp = await cryptoApi.signContractTermsHash({
contractTermsHash,
merchantPriv: args.merchantPriv,
});
const requestBody: ExchangeBatchDepositRequest = {
- coins: [
- {
- contribution: Amounts.stringify(dp.contribution),
- coin_pub: dp.coin_pub,
- coin_sig: dp.coin_sig,
- denom_pub_hash: dp.h_denom,
- ub_sig: dp.ub_sig,
- },
- ],
+ coins: depositCoins,
merchant_sig: merchantContractSigResp.sig,
merchant_payto_uri: depositPayto,
wire_salt: wireSalt,
@@ -276,7 +310,7 @@ export async function depositCoin(args: {
refund_deadline: refundDeadline,
merchant_pub: merchantPub,
};
- const url = new URL(`batch-deposit`, dp.exchange_url);
+ const url = new URL(`batch-deposit`, args.exchangeBaseUrl);
const httpResp = await http.fetch(url.href, {
method: "POST",
body: requestBody,