taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-wallet-cli/src/index.ts | 293+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Apackages/taler-wallet-cli/src/txref.test.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-cli/src/txref.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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}`; +}