commit 87250c4f4ec55a639cb8058c4f249f5d14ac83ec
parent 44890ef04a86e5eae08b6f30dcedbb7371e14e29
Author: Florian Dold <dold@taler.net>
Date: Thu, 10 Sep 2026 01:31:56 +0200
taler-harness: cover coin recovery against a running exchange
Exercise recovery after another wallet refreshes shared coins,
including recursive melts, residual balances, and both refresh protocol
versions. Include RSA and CS denominations, age restrictions, an original
CS blinding seed, unfinished reveals, and CLI progress output.
Diffstat:
2 files changed, 308 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-coin-recovery.ts b/packages/taler-harness/src/integrationtests/test-wallet-coin-recovery.ts
@@ -0,0 +1,298 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 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 {
+ AgeRestriction,
+ Amounts,
+ CoinStatus,
+ CoinRecoveryProgressNotification,
+ NotificationType,
+ DenomKeyType,
+ encodeCrock,
+ getRandomBytes,
+ succeedOrThrow,
+ TalerExchangeHttpClient,
+} from "@gnu-taler/taler-util";
+import {
+ WalletApiOperation,
+ nativeCrypto,
+ makeIdbRunner,
+ makeSqliteRunner,
+} from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ withdrawViaBankV4,
+} from "../harness/environments.js";
+import { GlobalTestState, runCommand } from "../harness/harness.js";
+
+function coinConfig(cs: boolean): CoinConfig[] {
+ return [1, 2, 6].map((value) => ({
+ cipher: cs ? "CS" : "RSA",
+ rsaKeySize: 1024,
+ name: `coin-${value}`,
+ value: `TESTKUDOS:${value}`,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ }));
+}
+
+async function recoverAcrossWallets(t: GlobalTestState, cs: boolean) {
+ const {
+ walletClient: recovering,
+ bank,
+ exchange,
+ } = await createSimpleTestkudosEnvironmentV3(
+ t,
+ coinConfig(cs),
+ cs ? { ageMaskSpec: "8:10:12:14:16:18" } : {},
+ );
+ const withdrawal = await withdrawViaBankV4(t, {
+ walletClient: recovering,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:6",
+ restrictAge: cs ? 18 : undefined,
+ });
+ await withdrawal.withdrawalFinishedCond;
+ const backup = await recovering.call(WalletApiOperation.ExportDb, {});
+ const { walletClient: spending, walletService } =
+ await createWalletDaemonWithClient(t, { name: "spending" });
+ await spending.call(WalletApiOperation.ImportDb, { dump: backup });
+ const root = (await spending.call(WalletApiOperation.DumpCoins, {})).coins[0];
+ t.assertDeepEqual(root.denomValue, "TESTKUDOS:6");
+ // Two generations, each leaving a residual on its input coin.
+ await spending.call(WalletApiOperation.ForceRefresh, {
+ refreshCoinSpecs: [{ coinPub: root.coinPub, amount: "TESTKUDOS:5" }],
+ });
+ await spending.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+ const child = (
+ await spending.call(WalletApiOperation.DumpCoins, {})
+ ).coins.find(
+ (c) => c.coinStatus === CoinStatus.Fresh && c.denomValue === "TESTKUDOS:2",
+ );
+ t.assertTrue(!!child);
+ await spending.call(WalletApiOperation.ForceRefresh, {
+ refreshCoinSpecs: [{ coinPub: child.coinPub, amount: "TESTKUDOS:1" }],
+ });
+ await spending.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+ await walletService.stop();
+ const notifications: CoinRecoveryProgressNotification[] = [];
+ const remove = recovering.addNotificationListener((n) => {
+ if (n.type === NotificationType.CoinRecoveryProgress) notifications.push(n);
+ });
+ const finished = recovering.waitForNotificationCond(
+ (n) =>
+ n.type === NotificationType.CoinRecoveryProgress &&
+ n.progressToken === "recovery-test" &&
+ n.phase === "complete",
+ );
+ const recovered = await recovering.call(
+ WalletApiOperation.TestingRecoverCoins,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ progressToken: "recovery-test",
+ },
+ );
+ await finished;
+ remove();
+ t.assertDeepEqual(recovered.issues, []);
+ t.assertTrue(recovered.complete);
+ t.assertAmountEquals(recovered.recoveredAmount, "TESTKUDOS:6");
+ t.assertTrue(recovered.numRecovered >= 4);
+ t.assertDeepEqual(notifications[0].phase, "starting");
+ t.assertDeepEqual(notifications.at(-1)?.phase, "complete");
+ t.assertTrue(notifications.every((n) => n.progressToken === "recovery-test"));
+ const checked = await recovering.call(WalletApiOperation.TestingCheckCoins, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+ t.assertDeepEqual(checked.issues, []);
+ t.assertAmountEquals(checked.actualMaterialBalance!, "TESTKUDOS:6");
+ const fresh = (
+ await recovering.call(WalletApiOperation.DumpCoins, {})
+ ).coins.filter((c) => c.coinStatus === CoinStatus.Fresh);
+ if (cs) t.assertTrue(fresh.every((c) => !!c.ageCommitmentProof));
+ const again = await recovering.call(WalletApiOperation.TestingRecoverCoins, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+ t.assertTrue(again.complete);
+ t.assertAmountEquals(again.recoveredAmount, "TESTKUDOS:0");
+ const cliResult = await runCommand(t, "recovery-cli", "taler-wallet-cli", [
+ "--wallet-connection",
+ `${t.testDir}/wallet.sock`,
+ "advanced",
+ "recover-coins",
+ "--exchange",
+ exchange.baseUrl,
+ cs ? "--progress" : "-P",
+ ]);
+ const parsed = JSON.parse(cliResult);
+ t.assertTrue(parsed.complete);
+ t.assertAmountEquals(parsed.recoveredAmount, "TESTKUDOS:0");
+}
+
+export async function runWalletCoinRecoveryTest(t: GlobalTestState) {
+ await recoverAcrossWallets(t, false);
+}
+runWalletCoinRecoveryTest.suites = ["wallet"];
+
+export async function runWalletCoinRecoveryCsAgeTest(t: GlobalTestState) {
+ await recoverAcrossWallets(t, true);
+}
+runWalletCoinRecoveryCsAgeTest.suites = ["wallet"];
+
+/** Replay v27 with an independently chosen CS seed, including an unfinished reveal. */
+async function recoverV27(t: GlobalTestState, cs: boolean) {
+ const { walletClient, bank, exchange } =
+ await createSimpleTestkudosEnvironmentV3(
+ t,
+ coinConfig(cs),
+ cs ? { ageMaskSpec: "8:10:12:14:16:18" } : {},
+ );
+ const w = await withdrawViaBankV4(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:6",
+ restrictAge: cs ? 18 : undefined,
+ });
+ await w.withdrawalFinishedCond;
+ const dump = await walletClient.call(WalletApiOperation.ExportDb, {});
+ // Read the same backend-neutral records for either wallet database format.
+ const db = await (dump.databases ? makeIdbRunner() : makeSqliteRunner());
+ let snapshot;
+ try {
+ await db.importDatabase(dump, async () => {});
+ snapshot = await db.runReadWriteTx(async (tx) => ({
+ coins: await tx.listAllCoins(),
+ denoms: await tx.listAllDenominations(),
+ }));
+ } finally {
+ await db.close();
+ }
+ const old = snapshot.coins[0];
+ const oldDenom = snapshot.denoms.find(
+ (d) => d.denomPubHash === old.denomPubHash,
+ )!;
+ const denoms = ["2", "1", "2"].map(
+ (v) =>
+ snapshot.denoms.find(
+ (d) =>
+ d.value === `TESTKUDOS:${v}` &&
+ d.stampStart <= Date.now() * 1000 &&
+ d.stampExpireWithdraw > Date.now() * 1000,
+ )!,
+ );
+ const client = new TalerExchangeHttpClient(exchange.baseUrl);
+ const seed = encodeCrock(getRandomBytes(64));
+ const blindingSeed = cs ? encodeCrock(getRandomBytes(32)) : undefined;
+ const values = cs
+ ? succeedOrThrow(
+ await client.postBlindingPrepare({
+ body: {
+ cipher: DenomKeyType.ClauseSchnorr,
+ operation: "melt",
+ seed: blindingSeed!,
+ nks: denoms.map((d, coin_offset) => ({
+ coin_offset,
+ denom_pub_hash: d.denomPubHash,
+ })),
+ },
+ }),
+ ).r_pubs.map((p) => ({
+ cipher: DenomKeyType.ClauseSchnorr as const,
+ r_pub_0: p[0],
+ r_pub_1: p[1],
+ }))
+ : undefined;
+ const session = await nativeCrypto.deriveRefreshSessionV2({
+ kappa: 3,
+ meltCoinPub: old.coinPub,
+ meltCoinPriv: old.coinPriv,
+ meltCoinDenomPubHash: old.denomPubHash,
+ meltCoinMaxAge: old.maxAge,
+ meltCoinAgeCommitmentProof: old.ageCommitmentProof,
+ feeRefresh: Amounts.parseOrThrow(oldDenom.fees.feeRefresh),
+ sessionPublicSeed: seed,
+ blindingSeed,
+ exchangeWithdrawValues: values,
+ newCoinDenoms: denoms.map((d) => ({
+ denomPub: d.denomPub,
+ denomPubHash: d.denomPubHash,
+ value: d.value,
+ feeWithdraw: d.fees.feeWithdraw,
+ count: 1,
+ })),
+ });
+ const melt = succeedOrThrow(
+ await client.postMelt({
+ body: {
+ old_coin_pub: old.coinPub,
+ old_denom_pub_h: old.denomPubHash,
+ old_denom_sig: old.denomSig,
+ old_age_commitment_h: old.ageCommitmentProof
+ ? AgeRestriction.hashCommitment(old.ageCommitmentProof.commitment)
+ : undefined,
+ refresh_seed: seed,
+ blinding_seed: blindingSeed,
+ confirm_sig: session.confirmSig,
+ coin_evs: session.planchets.map((p) => p.map((c) => c.coinEv)),
+ denoms_h: denoms.map((d) => d.denomPubHash),
+ value_with_fee: Amounts.stringify(session.meltValueWithFee),
+ },
+ }),
+ );
+ if (!cs)
+ succeedOrThrow(
+ await client.postRevealMelt({
+ body: {
+ rc: session.hash,
+ signatures: session.signatures.filter(
+ (_, i) => i !== melt.noreveal_index,
+ ),
+ },
+ }),
+ );
+ const recovered = await walletClient.call(
+ WalletApiOperation.TestingRecoverCoins,
+ { exchangeBaseUrl: exchange.baseUrl },
+ );
+ t.assertDeepEqual(recovered.issues, []);
+ t.assertTrue(recovered.complete);
+ t.assertAmountEquals(recovered.recoveredAmount, "TESTKUDOS:6");
+ const checked = await walletClient.call(
+ WalletApiOperation.TestingCheckCoins,
+ { exchangeBaseUrl: exchange.baseUrl },
+ );
+ t.assertDeepEqual(checked.issues, []);
+ t.assertAmountEquals(checked.actualMaterialBalance!, "TESTKUDOS:6");
+}
+
+export async function runWalletCoinRecoveryV27Test(t: GlobalTestState) {
+ await recoverV27(t, false);
+}
+runWalletCoinRecoveryV27Test.suites = ["wallet"];
+
+export async function runWalletCoinRecoveryV27CsAgeTest(t: GlobalTestState) {
+ await recoverV27(t, true);
+}
+runWalletCoinRecoveryV27CsAgeTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -226,6 +226,12 @@ import { runWalletNetworkAvailabilityTest } from "./test-wallet-network-availabi
import { runWalletNotificationsTest } from "./test-wallet-notifications.js";
import { runWalletRefreshErrorsTest } from "./test-wallet-refresh-errors.js";
import { runWalletRefreshRedenominateTest } from "./test-wallet-refresh-redenominate.js";
+import {
+ runWalletCoinRecoveryTest,
+ runWalletCoinRecoveryCsAgeTest,
+ runWalletCoinRecoveryV27Test,
+ runWalletCoinRecoveryV27CsAgeTest,
+} from "./test-wallet-coin-recovery.js";
import { runWalletRefreshTest } from "./test-wallet-refresh.js";
import { runWalletTokensDiscountTest } from "./test-wallet-tokens-discount.js";
import { runWalletTokensTest } from "./test-wallet-tokens.js";
@@ -407,6 +413,10 @@ const allTests: TestMainFunction[] = [
runWalletDd48Test,
runCurrencyScopeTest,
runWalletRefreshTest,
+ runWalletCoinRecoveryTest,
+ runWalletCoinRecoveryCsAgeTest,
+ runWalletCoinRecoveryV27Test,
+ runWalletCoinRecoveryV27CsAgeTest,
runOtpTest,
runExchangeManagementTest,
runWalletBadNetworkTest,