commit a41d1c3114515b50c7a41ab18c8c70b24666b551
parent 6de60b6e7b4504db4941ef8ca7d6985d06997eaa
Author: Florian Dold <dold@taler.net>
Date: Thu, 6 Aug 2026 16:19:01 +0200
wallet-cli: accept ^N for the N-th most recent transaction
Diffstat:
3 files changed, 348 insertions(+), 96 deletions(-)
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
@@ -63,6 +63,7 @@ import {
Continuation,
ContinuationKind,
} from "./continuation.js";
+import { formatTxRef, parseTxRef, TX_REF_SYNTAX } from "./txref.js";
import {
CliUsageError,
formatTxState,
@@ -708,6 +709,40 @@ walletCli
);
});
+/**
+ * Resolve what the user passed in place of a transaction identifier.
+ *
+ * A "^N" reference is looked up in the transaction list, which the
+ * identifier itself cannot express: it carries no ordering.
+ */
+async function resolveTxRef(
+ ctx: WalletContext,
+ arg: string,
+): Promise<TransactionIdStr> {
+ const ref = parseTxRef(arg);
+ if (ref.kind === "id") {
+ return ref.transactionId;
+ }
+ // A negative limit returns the newest transactions first, so only the
+ // ones up to the requested position have to be looked at.
+ const res = await ctx.client.call(WalletApiOperation.GetTransactionsV2, {
+ limit: -ref.n,
+ });
+ const tx = res.transactions[ref.n - 1];
+ if (tx == null) {
+ throw new CliUsageError(
+ `no transaction ${formatTxRef(ref)}`,
+ res.transactions.length === 1
+ ? "the wallet has 1 transaction"
+ : `the wallet has ${res.transactions.length} transactions`,
+ );
+ }
+ // On stderr, so that it does not end up in the JSON that some of
+ // these commands write to stdout.
+ console.error(`${formatTxRef(ref)} -> ${tx.transactionId}`);
+ return tx.transactionId;
+}
+
const TX_STATE_FILTERS = [
"final",
"nonfinal",
@@ -768,20 +803,20 @@ transactionsCli
help: "Permanently delete a transaction from the transaction list.",
})
.requiredArgument("transactionId", clk.STRING, {
- help: "Identifier of the transaction to delete",
+ metavar: "TRANSACTION_ID",
+ help: `Transaction to delete. ${TX_REF_SYNTAX}`,
})
.action(async (args) => {
- await withWallet(
- args,
- {
- lazyTaskLoop: true,
- },
- async (wallet) => {
+ await runCliAction(() =>
+ withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const transactionId = await resolveTxRef(
+ wallet,
+ args.deleteTransaction.transactionId,
+ );
await wallet.client.call(WalletApiOperation.DeleteTransaction, {
- transactionId: args.deleteTransaction
- .transactionId as TransactionIdStr,
+ transactionId,
});
- },
+ }),
);
});
@@ -790,20 +825,20 @@ transactionsCli
help: "Suspend a transaction.",
})
.requiredArgument("transactionId", clk.STRING, {
- help: "Identifier of the transaction to suspend.",
+ metavar: "TRANSACTION_ID",
+ help: `Transaction to suspend. ${TX_REF_SYNTAX}`,
})
.action(async (args) => {
- await withWallet(
- args,
- {
- lazyTaskLoop: true,
- },
- async (wallet) => {
+ await runCliAction(() =>
+ withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const transactionId = await resolveTxRef(
+ wallet,
+ args.suspendTransaction.transactionId,
+ );
await wallet.client.call(WalletApiOperation.SuspendTransaction, {
- transactionId: args.suspendTransaction
- .transactionId as TransactionIdStr,
+ transactionId,
});
- },
+ }),
);
});
@@ -812,19 +847,20 @@ transactionsCli
help: "Fail a transaction (when it can't be aborted).",
})
.requiredArgument("transactionId", clk.STRING, {
- help: "Identifier of the transaction to fail.",
+ metavar: "TRANSACTION_ID",
+ help: `Transaction to fail. ${TX_REF_SYNTAX}`,
})
.action(async (args) => {
- await withWallet(
- args,
- {
- lazyTaskLoop: true,
- },
- async (wallet) => {
+ await runCliAction(() =>
+ withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const transactionId = await resolveTxRef(
+ wallet,
+ args.fail.transactionId,
+ );
await wallet.client.call(WalletApiOperation.FailTransaction, {
- transactionId: args.fail.transactionId as TransactionIdStr,
+ transactionId,
});
- },
+ }),
);
});
@@ -833,14 +869,21 @@ transactionsCli
help: "Resume a transaction.",
})
.requiredArgument("transactionId", clk.STRING, {
- help: "Identifier of the transaction to suspend.",
+ metavar: "TRANSACTION_ID",
+ help: `Transaction to resume. ${TX_REF_SYNTAX}`,
})
.action(async (args) => {
- await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
- await wallet.client.call(WalletApiOperation.ResumeTransaction, {
- transactionId: args.resumeTransaction.transactionId as TransactionIdStr,
- });
- });
+ await runCliAction(() =>
+ withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const transactionId = await resolveTxRef(
+ wallet,
+ args.resumeTransaction.transactionId,
+ );
+ await wallet.client.call(WalletApiOperation.ResumeTransaction, {
+ transactionId,
+ });
+ }),
+ );
});
transactionsCli
@@ -848,20 +891,27 @@ transactionsCli
help: "Look up a single transaction based on the transaction identifier.",
})
.requiredArgument("transactionId", clk.STRING, {
- help: "Identifier of the transaction to delete",
+ metavar: "TRANSACTION_ID",
+ help: `Transaction to look up. ${TX_REF_SYNTAX}`,
})
.flag("includeContractTerms", ["--include-contract-terms"])
.action(async (args) => {
- await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
- const tx = await wallet.client.call(
- WalletApiOperation.GetTransactionById,
- {
- transactionId: args.lookup.transactionId,
- includeContractTerms: args.lookup.includeContractTerms ?? false,
- },
- );
- console.log(j2s(tx));
- });
+ await runCliAction(() =>
+ withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const transactionId = await resolveTxRef(
+ wallet,
+ args.lookup.transactionId,
+ );
+ const tx = await wallet.client.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId,
+ includeContractTerms: args.lookup.includeContractTerms ?? false,
+ },
+ );
+ console.log(j2s(tx));
+ }),
+ );
});
transactionsCli
@@ -869,14 +919,21 @@ transactionsCli
help: "Abort a transaction.",
})
.requiredArgument("transactionId", clk.STRING, {
- help: "Identifier of the transaction to delete",
+ metavar: "TRANSACTION_ID",
+ help: `Transaction to abort. ${TX_REF_SYNTAX}`,
})
.action(async (args) => {
- await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
- await wallet.client.call(WalletApiOperation.AbortTransaction, {
- transactionId: args.abortTransaction.transactionId as TransactionIdStr,
- });
- });
+ await runCliAction(() =>
+ withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const transactionId = await resolveTxRef(
+ wallet,
+ args.abortTransaction.transactionId,
+ );
+ await wallet.client.call(WalletApiOperation.AbortTransaction, {
+ transactionId,
+ });
+ }),
+ );
});
walletCli
@@ -897,13 +954,22 @@ transactionsCli
.subcommand("retryTransaction", "retry", {
help: "Retry a transaction.",
})
- .requiredArgument("transactionId", clk.STRING)
+ .requiredArgument("transactionId", clk.STRING, {
+ metavar: "TRANSACTION_ID",
+ help: `Transaction to retry. ${TX_REF_SYNTAX}`,
+ })
.action(async (args) => {
- await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
- await wallet.client.call(WalletApiOperation.RetryTransaction, {
- transactionId: args.retryTransaction.transactionId as TransactionIdStr,
- });
- });
+ await runCliAction(() =>
+ withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const transactionId = await resolveTxRef(
+ wallet,
+ args.retryTransaction.transactionId,
+ );
+ await wallet.client.call(WalletApiOperation.RetryTransaction, {
+ transactionId,
+ });
+ }),
+ );
});
/**
@@ -1101,8 +1167,8 @@ async function runWaitTx(
"expected 'unsuccessful', 'error', 'both' or 'none'",
);
}
- const transactionId = a.transactionId as TransactionIdStr;
return await withWallet(cliArgs, { lazyTaskLoop: false }, async (ctx) => {
+ const transactionId = await resolveTxRef(ctx, a.transactionId);
let cancelFollow: (() => void) | undefined;
if (a.follow) {
cancelFollow = ctx.addNotificationListener((n) => {
@@ -1575,7 +1641,6 @@ async function runContinueTx(
cliArgs: WalletCliArgsType,
a: ContinueTxArgs,
): Promise<number> {
- const transactionId = a.transactionId as TransactionIdStr;
const opts: ContinuationOptions = {
yes: a.yes,
acceptTos: a.acceptTos,
@@ -1585,6 +1650,7 @@ async function runContinueTx(
restrictAge: a.restrictAge,
};
return await withWallet(cliArgs, { lazyTaskLoop: false }, async (ctx) => {
+ const transactionId = await resolveTxRef(ctx, a.transactionId);
const tx = await ctx.client.call(WalletApiOperation.GetTransactionById, {
transactionId,
});
@@ -1649,7 +1715,7 @@ function addContinueTxCommand(parent: any, argKey: string, name: string): void {
.subcommand(argKey, name, { help: continueTxHelp })
.requiredArgument("transactionId", clk.STRING, {
metavar: "TRANSACTION_ID",
- help: "Identifier of the transaction to continue.",
+ help: `Transaction to continue. ${TX_REF_SYNTAX}`,
})
.flag("yes", ["-y", "--yes"], {
help: "Confirm without asking.",
@@ -1702,7 +1768,7 @@ function addWaitTxCommand(parent: any, argKey: string, name: string): void {
.subcommand(argKey, name, { help: waitTxHelp })
.requiredArgument("transactionId", clk.STRING, {
metavar: "TRANSACTION_ID",
- help: "Identifier of the transaction to wait for.",
+ help: `Transaction to wait for. ${TX_REF_SYNTAX}`,
})
.maybeOption("state", ["-s", "--state"], clk.STRING, {
help: `State(s) to wait for (default: final). ${TX_STATE_SPEC_SYNTAX}`,
@@ -2594,33 +2660,50 @@ peerCli
peerCli
.subcommand("confirmIncomingPayPull", "confirm-pull-debit")
- .requiredArgument("transactionId", clk.STRING)
+ .requiredArgument("transactionId", clk.STRING, {
+ metavar: "TRANSACTION_ID",
+ help: `Transaction to confirm. ${TX_REF_SYNTAX}`,
+ })
.action(async (args) => {
- await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
- const resp = await wallet.client.call(
- WalletApiOperation.ConfirmPeerPullDebit,
- {
- transactionId: args.confirmIncomingPayPull
- .transactionId as TransactionIdStr,
- },
- );
- console.log(JSON.stringify(resp, undefined, 2));
- });
+ await runCliAction(() =>
+ withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const transactionId = await resolveTxRef(
+ wallet,
+ args.confirmIncomingPayPull.transactionId,
+ );
+ const resp = await wallet.client.call(
+ WalletApiOperation.ConfirmPeerPullDebit,
+ {
+ transactionId,
+ },
+ );
+ console.log(JSON.stringify(resp, undefined, 2));
+ }),
+ );
});
peerCli
.subcommand("confirmIncomingPayPush", "confirm-push-credit")
- .requiredArgument("transactionId", clk.STRING)
+ .requiredArgument("transactionId", clk.STRING, {
+ metavar: "TRANSACTION_ID",
+ help: `Transaction to confirm. ${TX_REF_SYNTAX}`,
+ })
.action(async (args) => {
- await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
- const resp = await wallet.client.call(
- WalletApiOperation.ConfirmPeerPushCredit,
- {
- transactionId: args.confirmIncomingPayPush.transactionId,
- },
- );
- console.log(JSON.stringify(resp, undefined, 2));
- });
+ await runCliAction(() =>
+ withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const transactionId = await resolveTxRef(
+ wallet,
+ args.confirmIncomingPayPush.transactionId,
+ );
+ const resp = await wallet.client.call(
+ WalletApiOperation.ConfirmPeerPushCredit,
+ {
+ transactionId,
+ },
+ );
+ console.log(JSON.stringify(resp, undefined, 2));
+ }),
+ );
});
peerCli
@@ -3190,28 +3273,46 @@ advancedCli
.subcommand("queryRefund", "query-refund", {
help: "Query refunds for a payment transaction.",
})
- .requiredArgument("transactionId", clk.STRING)
+ .requiredArgument("transactionId", clk.STRING, {
+ metavar: "TRANSACTION_ID",
+ help: `Payment transaction to query. ${TX_REF_SYNTAX}`,
+ })
.action(async (args) => {
- await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
- await wallet.client.call(WalletApiOperation.StartRefundQuery, {
- transactionId: args.queryRefund.transactionId as TransactionIdStr,
- });
- });
+ await runCliAction(() =>
+ withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const transactionId = await resolveTxRef(
+ wallet,
+ args.queryRefund.transactionId,
+ );
+ await wallet.client.call(WalletApiOperation.StartRefundQuery, {
+ transactionId,
+ });
+ }),
+ );
});
advancedCli
.subcommand("payConfirm", "pay-confirm", {
help: "Confirm payment proposed by a merchant.",
})
- .requiredArgument("transactionId", clk.STRING)
+ .requiredArgument("transactionId", clk.STRING, {
+ metavar: "TRANSACTION_ID",
+ help: `Payment transaction to confirm. ${TX_REF_SYNTAX}`,
+ })
.maybeOption("sessionIdOverride", ["--session-id"], clk.STRING)
.action(async (args) => {
- await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
- await wallet.client.call(WalletApiOperation.ConfirmPay, {
- transactionId: args.payConfirm.transactionId as TransactionIdStr,
- sessionId: args.payConfirm.sessionIdOverride,
- });
- });
+ await runCliAction(() =>
+ withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const transactionId = await resolveTxRef(
+ wallet,
+ args.payConfirm.transactionId,
+ );
+ await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ transactionId,
+ sessionId: args.payConfirm.sessionIdOverride,
+ });
+ }),
+ );
});
advancedCli
diff --git a/packages/taler-wallet-cli/src/txref.test.ts b/packages/taler-wallet-cli/src/txref.test.ts
@@ -0,0 +1,71 @@
+/*
+ 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";
+import { test } from "node:test";
+import { formatTxRef, parseTxRef } from "./txref.js";
+import { CliUsageError } from "./waitspec.js";
+
+test("a bare '^' is the most recent transaction", (t) => {
+ assert.deepStrictEqual(parseTxRef("^"), { kind: "recent", n: 1 });
+ assert.deepStrictEqual(parseTxRef(" ^ "), { kind: "recent", n: 1 });
+});
+
+test("'^N' counts back from the most recent", (t) => {
+ assert.deepStrictEqual(parseTxRef("^1"), { kind: "recent", n: 1 });
+ assert.deepStrictEqual(parseTxRef("^2"), { kind: "recent", n: 2 });
+ assert.deepStrictEqual(parseTxRef("^12"), { kind: "recent", n: 12 });
+});
+
+test("anything else is taken as an identifier", (t) => {
+ const id = "txn:withdrawal:TP7YXWYFZYK2J4QVE9C6S0W1GABT0H9Q776EQM433YBG";
+ assert.deepStrictEqual(parseTxRef(id), {
+ kind: "id",
+ transactionId: id,
+ });
+ // Not validated here: wallet-core gives the authoritative error.
+ assert.deepStrictEqual(parseTxRef("bogus"), {
+ kind: "id",
+ transactionId: "bogus",
+ });
+});
+
+test("'^0' is rejected, since counting starts at one", (t) => {
+ assert.throws(() => parseTxRef("^0"), CliUsageError);
+});
+
+test("a reference that is not a positive whole number is rejected", (t) => {
+ for (const bad of ["^-1", "^1.5", "^x", "^1x", "^ 1", "^+1"]) {
+ assert.throws(
+ () => parseTxRef(bad),
+ CliUsageError,
+ `expected ${bad} to throw`,
+ );
+ }
+});
+
+test("an empty reference is rejected", (t) => {
+ assert.throws(() => parseTxRef(""), CliUsageError);
+ assert.throws(() => parseTxRef(" "), CliUsageError);
+});
+
+test("references render the way they were written", (t) => {
+ assert.strictEqual(formatTxRef(parseTxRef("^")), "^1");
+ assert.strictEqual(formatTxRef(parseTxRef("^3")), "^3");
+ assert.strictEqual(
+ formatTxRef(parseTxRef("txn:refresh:ABC")),
+ "txn:refresh:ABC",
+ );
+});
diff --git a/packages/taler-wallet-cli/src/txref.ts b/packages/taler-wallet-cli/src/txref.ts
@@ -0,0 +1,80 @@
+/*
+ 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/>
+ */
+
+/**
+ * @file
+ * Parsing of the command line syntax for referring to a transaction.
+ *
+ * Transaction identifiers are long and carry no ordering, so the commands
+ * that take one also accept "^N" for the N-th most recent transaction.
+ * Real identifiers start with "txn:", so the "^" cannot be mistaken for one.
+ */
+
+/**
+ * Imports.
+ */
+import { TransactionIdStr } from "@gnu-taler/taler-util";
+import { CliUsageError } from "./waitspec.js";
+
+/**
+ * Syntax of a transaction reference, as shown in the command line help.
+ */
+export const TX_REF_SYNTAX =
+ "Either a transaction identifier, or '^N' for the N-th most recent" +
+ " transaction ('^' means '^1').";
+
+export type TxRef =
+ | { kind: "id"; transactionId: TransactionIdStr }
+ | { kind: "recent"; n: number };
+
+/**
+ * Parse what the user gave us in place of a transaction identifier.
+ */
+export function parseTxRef(arg: string): TxRef {
+ const s = arg.trim();
+ if (s === "") {
+ throw new CliUsageError("empty transaction reference");
+ }
+ if (!s.startsWith("^")) {
+ return { kind: "id", transactionId: s as TransactionIdStr };
+ }
+ const rest = s.substring(1);
+ if (rest === "") {
+ return { kind: "recent", n: 1 };
+ }
+ // Deliberately strict: parseInt would accept "^1x" and "^1.5".
+ if (!/^[0-9]+$/.test(rest)) {
+ throw new CliUsageError(
+ `invalid transaction reference '${arg}'`,
+ "expected '^' or '^N', with N a positive whole number",
+ );
+ }
+ const n = Number.parseInt(rest, 10);
+ if (n < 1) {
+ throw new CliUsageError(
+ `invalid transaction reference '${arg}'`,
+ "the most recent transaction is '^1'; there is no '^0'",
+ );
+ }
+ return { kind: "recent", n };
+}
+
+/**
+ * Render a reference the way it is written on the command line.
+ */
+export function formatTxRef(ref: TxRef): string {
+ return ref.kind === "id" ? ref.transactionId : `^${ref.n}`;
+}