commit 44890ef04a86e5eae08b6f30dcedbb7371e14e29
parent c727431c67ff83792f9147a425b3d84e1a2e6152
Author: Florian Dold <dold@taler.net>
Date: Thu, 10 Sep 2026 01:31:54 +0200
wallet-cli: add recover-coins with a refreshing progress display
Expose advanced recover-coins for one exchange, with --all-coins to
include spent roots and --progress or -P to display recovery progress.
Render a throttled single line on terminals and start/end messages when
redirected, keeping the JSON result on stdout.
Diffstat:
3 files changed, 257 insertions(+), 0 deletions(-)
diff --git a/packages/taler-wallet-cli/src/coin-recovery-progress.test.ts b/packages/taler-wallet-cli/src/coin-recovery-progress.test.ts
@@ -0,0 +1,90 @@
+/*
+ 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 assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ CoinRecoveryProgressNotification,
+ NotificationType,
+} from "@gnu-taler/taler-util";
+import { createCoinRecoveryProgress } from "./coin-recovery-progress.js";
+
+function progress(
+ overrides: Partial<CoinRecoveryProgressNotification> = {},
+): CoinRecoveryProgressNotification {
+ return {
+ type: NotificationType.CoinRecoveryProgress,
+ progressToken: "mine",
+ exchangeBaseUrl: "https://exchange.test/",
+ phase: "history",
+ numChecked: 1,
+ numDiscovered: 3,
+ numQueued: 2,
+ numRecovered: 1,
+ recoveredAmount: "TESTKUDOS:2",
+ numIssues: 0,
+ ...overrides,
+ };
+}
+
+test("redirected progress has plain start/end lines and ignores foreign tokens", () => {
+ const chunks: string[] = [];
+ const p = createCoinRecoveryProgress("mine", {
+ write: (s) => chunks.push(s),
+ });
+ p.onNotification(
+ progress({ progressToken: "someone-else", phase: "complete" }),
+ );
+ p.onNotification(progress());
+ assert.equal(chunks.length, 1);
+ p.onNotification(progress({ phase: "incomplete" }));
+ p.finish();
+ assert.equal(chunks.length, 2);
+ assert.match(chunks[1], /^incomplete: checked 1\/3/);
+ assert(!chunks.join("").includes("\x1b"));
+});
+
+test("TTY progress coalesces updates, truncates and stops on completion", async () => {
+ const chunks: string[] = [];
+ const p = createCoinRecoveryProgress("mine", {
+ isTTY: true,
+ columns: 30,
+ write: (s) => chunks.push(s),
+ });
+ for (let i = 0; i < 100; i++) p.onNotification(progress({ numChecked: i }));
+ assert.equal(chunks.length, 1);
+ await new Promise((resolve) => setTimeout(resolve, 120));
+ assert.equal(chunks.length, 2);
+ assert(chunks[1].startsWith("\r\x1b[2K"));
+ assert(chunks[1].slice(5).length < 30);
+ p.onNotification(progress({ phase: "cancelled" }));
+ const finalCount = chunks.length;
+ assert.equal(chunks.at(-1), "\n");
+ await new Promise((resolve) => setTimeout(resolve, 120));
+ p.onNotification(progress());
+ p.finish();
+ assert.equal(chunks.length, finalCount);
+});
+
+test("failure before the first notification still prints a terminal line", () => {
+ const chunks: string[] = [];
+ const p = createCoinRecoveryProgress("mine", {
+ write: (s) => chunks.push(s),
+ });
+ p.finish("failed");
+ assert.equal(chunks.length, 2);
+ assert.match(chunks[1], /^failed/);
+});
diff --git a/packages/taler-wallet-cli/src/coin-recovery-progress.ts b/packages/taler-wallet-cli/src/coin-recovery-progress.ts
@@ -0,0 +1,100 @@
+/*
+ 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 {
+ CoinRecoveryProgressNotification,
+ NotificationType,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+
+interface ProgressOutput {
+ isTTY?: boolean;
+ columns?: number;
+ write(text: string): unknown;
+}
+
+const terminalPhases = new Set([
+ "complete",
+ "incomplete",
+ "failed",
+ "cancelled",
+]);
+
+/** stderr only; remote notifications are broadcasts, so always filter the token. */
+export function createCoinRecoveryProgress(
+ progressToken: string,
+ output: ProgressOutput = process.stderr,
+) {
+ const started = Date.now();
+ let latest: CoinRecoveryProgressNotification | undefined;
+ let closed = false;
+ let fallbackPhase: string = "starting";
+ let wroteStart = false;
+ let wroteTerminal = false;
+ let lastLine = "";
+ const line = () =>
+ latest
+ ? `${latest.phase}: checked ${latest.numChecked}/${latest.numDiscovered}, queued ${latest.numQueued}, recovered ${latest.numRecovered} (${latest.recoveredAmount}), issues ${latest.numIssues}, ${Math.floor((Date.now() - started) / 1000)}s`
+ : `${fallbackPhase} coin recovery, ${Math.floor((Date.now() - started) / 1000)}s`;
+ const draw = () => {
+ if (closed) return;
+ const text = line();
+ if (output.isTTY) {
+ // Leave one column free to prevent an automatic line wrap.
+ const width = Math.max(0, (output.columns ?? 80) - 1);
+ const display = text.replace(/[\x00-\x1f\x7f-\x9f]/g, "").slice(0, width);
+ if (display !== lastLine) {
+ output.write(`\r\x1b[2K${display}`);
+ lastLine = display;
+ }
+ } else if (
+ !wroteStart ||
+ (terminalPhases.has(latest?.phase ?? fallbackPhase) && !wroteTerminal)
+ ) {
+ output.write(text + "\n");
+ wroteStart = true;
+ if (terminalPhases.has(latest?.phase ?? fallbackPhase))
+ wroteTerminal = true;
+ }
+ };
+ draw();
+ const timer = output.isTTY ? setInterval(draw, 100) : undefined;
+ const close = () => {
+ if (closed) return;
+ if (timer) clearInterval(timer);
+ draw();
+ if (output.isTTY) output.write("\n");
+ closed = true;
+ };
+ return {
+ onNotification(n: WalletNotification): void {
+ if (
+ closed ||
+ n.type !== NotificationType.CoinRecoveryProgress ||
+ n.progressToken !== progressToken
+ )
+ return;
+ latest = n;
+ if (terminalPhases.has(n.phase)) close();
+ else if (!output.isTTY) draw();
+ },
+ finish(phase: "complete" | "incomplete" | "failed" = "failed"): void {
+ fallbackPhase = phase;
+ if (!closed && latest) latest = { ...latest, phase };
+ close();
+ },
+ };
+}
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
@@ -89,6 +89,7 @@ import {
Continuation,
ContinuationKind,
} from "./continuation.js";
+import { createCoinRecoveryProgress } from "./coin-recovery-progress.js";
import { CLI_ENABLE_VAR, parseEnabledMarks } from "./marks.js";
import {
createWalletHttpLib,
@@ -3616,6 +3617,72 @@ advancedCli
});
advancedCli
+ .subcommand("recoverCoins", "recover-coins", {
+ help: "Recover coins through the exchange link protocol and print the result as JSON.",
+ })
+ .requiredOption("exchange", ["--exchange"], clk.STRING, {
+ help: "Base URL of the exchange whose coins should be recovered.",
+ })
+ .flag("allCoins", ["--all-coins"], {
+ help: "Start from every coin status instead of only fresh coins.",
+ })
+ .flag("progress", ["--progress", "-P"], {
+ help: "Show a refreshing progress line on stderr.",
+ })
+ .action(async (args) => {
+ await runCliAction(() =>
+ withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const progressToken = encodeCrock(getRandomBytes(32));
+ const progress = args.recoverCoins.progress
+ ? createCoinRecoveryProgress(progressToken)
+ : undefined;
+ const removeListener =
+ progress &&
+ wallet.addNotificationListener((n) => progress.onNotification(n));
+ const cancel = () => {
+ void wallet.client
+ .call(WalletApiOperation.CancelProgressToken, {
+ operation: WalletApiOperation.TestingRecoverCoins,
+ progressToken,
+ })
+ .catch(() => {});
+ };
+ process.on("SIGINT", cancel);
+ let finalPhase: "complete" | "incomplete" | "failed" = "failed";
+ try {
+ const result = await wallet.client.call(
+ WalletApiOperation.TestingRecoverCoins,
+ {
+ exchangeBaseUrl: args.recoverCoins.exchange,
+ onlyFresh: !args.recoverCoins.allCoins,
+ progressToken,
+ },
+ );
+ finalPhase = result.complete ? "complete" : "incomplete";
+ // Remote replies may arrive before their final notification.
+ progress?.onNotification({
+ type: NotificationType.CoinRecoveryProgress,
+ progressToken,
+ exchangeBaseUrl: result.exchangeBaseUrl,
+ phase: finalPhase,
+ numChecked: result.numChecked,
+ numDiscovered: result.numDiscovered,
+ numQueued: result.numQueued,
+ numRecovered: result.numRecovered,
+ recoveredAmount: result.recoveredAmount,
+ numIssues: result.issues.length,
+ });
+ console.log(j2s(result));
+ } finally {
+ process.off("SIGINT", cancel);
+ removeListener?.();
+ progress?.finish(finalPhase);
+ }
+ }),
+ );
+ });
+
+advancedCli
.subcommand("performanceStats", "performance-stats", {
help: "Print performance stats.",
})