commit 3dd4af714a98bfe871813f082c465056f778ff71
parent 92cfa4e7a07b97456d24ac1884d6354c0221e559
Author: Florian Dold <dold@taler.net>
Date: Thu, 6 Aug 2026 15:40:37 +0200
wallet-cli: add continue-tx to do what a transaction is waiting for
Transactions could be created and waited for, but a blocked one was a dead
end unless the original URI was handled again. The state it stopped in is
mapped to the step that unblocks it, performed here when wallet-core can do
it and reported with exit code 6 when it has to happen elsewhere.
Diffstat:
3 files changed, 1206 insertions(+), 76 deletions(-)
diff --git a/packages/taler-wallet-cli/src/continuation.test.ts b/packages/taler-wallet-cli/src/continuation.test.ts
@@ -0,0 +1,343 @@
+/*
+ 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 {
+ ExchangeTosStatus,
+ Transaction,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ WithdrawalType,
+} from "@gnu-taler/taler-util";
+import assert from "node:assert";
+import { test } from "node:test";
+import { computeContinuation, ContinuationKind } from "./continuation.js";
+
+/**
+ * Build a transaction with just the fields the mapping looks at.
+ *
+ * Called from inside the tests rather than at module level: the enums
+ * come from the package barrel, which is an import cycle, so reading
+ * them while this module is being loaded can throw.
+ */
+function makeTx(
+ type: TransactionType,
+ txState: TransactionState,
+ extra: Record<string, unknown> = {},
+): Transaction {
+ return {
+ transactionId: "txn:test:1",
+ type,
+ timestamp: { t_s: 1 },
+ scopes: [],
+ txState,
+ stId: 0,
+ txActions: [],
+ amountRaw: "KUDOS:1",
+ amountEffective: "KUDOS:1",
+ ...extra,
+ } as unknown as Transaction;
+}
+
+test("final states need nothing", (t) => {
+ for (const major of [
+ TransactionMajorState.Done,
+ TransactionMajorState.Failed,
+ TransactionMajorState.Aborted,
+ TransactionMajorState.Expired,
+ ]) {
+ const cont = computeContinuation(
+ makeTx(TransactionType.Payment, { major }),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.Nothing);
+ assert.strictEqual(cont.actionable, false);
+ assert.strictEqual(cont.automatic, false);
+ }
+});
+
+test("a working transaction needs nothing, whatever its minor state", (t) => {
+ const cont = computeContinuation(
+ makeTx(TransactionType.Withdrawal, {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ working: true,
+ }),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.Nothing);
+ assert.strictEqual(cont.actionable, false);
+});
+
+test("suspended transactions can be resumed", (t) => {
+ for (const major of [
+ TransactionMajorState.Suspended,
+ TransactionMajorState.SuspendedFinalizing,
+ TransactionMajorState.SuspendedAborting,
+ ]) {
+ const cont = computeContinuation(
+ makeTx(TransactionType.Deposit, { major }),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.Resume);
+ assert.strictEqual(cont.automatic, true);
+ }
+});
+
+test("a dialog state maps to the confirmation of its type", (t) => {
+ const dialog: TransactionState = {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ const cases: [TransactionType, ContinuationKind][] = [
+ [TransactionType.Payment, ContinuationKind.ConfirmPayment],
+ [TransactionType.Withdrawal, ContinuationKind.ConfirmWithdrawal],
+ [TransactionType.PeerPushCredit, ContinuationKind.ConfirmPeerPushCredit],
+ [TransactionType.PeerPullDebit, ContinuationKind.ConfirmPeerPullDebit],
+ ];
+ for (const [type, kind] of cases) {
+ const cont = computeContinuation(makeTx(type, dialog));
+ assert.strictEqual(cont.kind, kind);
+ assert.strictEqual(cont.actionable, true);
+ assert.strictEqual(cont.automatic, true);
+ }
+});
+
+test("a withdrawal in a dialog state has no exchange yet", (t) => {
+ const cont = computeContinuation(
+ makeTx(TransactionType.Withdrawal, {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ }),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.ConfirmWithdrawal);
+ assert.strictEqual(cont.tosExchangeBaseUrl, undefined);
+ assert.strictEqual(cont.requiresTos, undefined);
+});
+
+test("proposed terms of service gate a confirmation", (t) => {
+ const tx = makeTx(
+ TransactionType.PeerPushCredit,
+ {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ },
+ { exchangeBaseUrl: "https://exchange.example.com/" },
+ );
+ const proposed = computeContinuation(tx, {
+ tosStatus: ExchangeTosStatus.Proposed,
+ });
+ assert.strictEqual(
+ proposed.tosExchangeBaseUrl,
+ "https://exchange.example.com/",
+ );
+ assert.strictEqual(proposed.requiresTos, true);
+
+ const accepted = computeContinuation(tx, {
+ tosStatus: ExchangeTosStatus.Accepted,
+ });
+ assert.strictEqual(accepted.requiresTos, undefined);
+
+ const unknown = computeContinuation(tx);
+ assert.strictEqual(unknown.requiresTos, undefined);
+});
+
+test("kyc states report where to go", (t) => {
+ for (const minor of [
+ TransactionMinorState.KycRequired,
+ TransactionMinorState.BalanceKycRequired,
+ ]) {
+ const cont = computeContinuation(
+ makeTx(
+ TransactionType.Withdrawal,
+ { major: TransactionMajorState.Pending, minor },
+ {
+ kycUrl: "https://exchange.example.com/kyc-spa/ABC",
+ kycAccessToken: "ABC",
+ },
+ ),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.CompleteKyc);
+ assert.strictEqual(cont.actionable, true);
+ assert.strictEqual(cont.automatic, false);
+ assert.strictEqual(
+ cont.details?.kycUrl,
+ "https://exchange.example.com/kyc-spa/ABC",
+ );
+ }
+});
+
+test("a kyc state without a url still reports what is needed", (t) => {
+ const cont = computeContinuation(
+ makeTx(TransactionType.Withdrawal, {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ }),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.CompleteKyc);
+ assert.strictEqual(cont.details?.kycUrl, null);
+});
+
+test("kyc-auth reports the account to transfer from", (t) => {
+ const cont = computeContinuation(
+ makeTx(
+ TransactionType.Deposit,
+ {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycAuthRequired,
+ },
+ {
+ kycAuthTransferInfo: {
+ debitPaytoUri: "payto://iban/DE123",
+ accountPub: "PUBKEY",
+ amount: "KUDOS:0.01",
+ transferOptions: [],
+ },
+ },
+ ),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.KycAuthTransfer);
+ assert.strictEqual(cont.automatic, false);
+ assert.strictEqual(cont.details?.debitPaytoUri, "payto://iban/DE123");
+ assert.strictEqual(cont.details?.accountPub, "PUBKEY");
+});
+
+test("a bank-integrated withdrawal reports the confirmation url", (t) => {
+ const cont = computeContinuation(
+ makeTx(
+ TransactionType.Withdrawal,
+ {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ },
+ {
+ withdrawalDetails: {
+ type: WithdrawalType.TalerBankIntegrationApi,
+ confirmed: false,
+ bankConfirmationUrl: "https://bank.example.com/confirm/1",
+ reservePub: "RESERVE",
+ reserveIsReady: false,
+ },
+ },
+ ),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.ConfirmWithBank);
+ assert.strictEqual(cont.actionable, true);
+ assert.strictEqual(cont.automatic, false);
+ assert.strictEqual(
+ cont.details?.bankConfirmationUrl,
+ "https://bank.example.com/confirm/1",
+ );
+});
+
+test("a manual withdrawal waiting for funds reports the accounts", (t) => {
+ const cont = computeContinuation(
+ makeTx(
+ TransactionType.Withdrawal,
+ {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ExchangeWaitReserve,
+ },
+ {
+ withdrawalDetails: {
+ type: WithdrawalType.ManualTransfer,
+ exchangePaytoUris: ["payto://iban/DE999"],
+ reservePub: "RESERVE",
+ reserveIsReady: false,
+ },
+ },
+ ),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.MakeWireTransfer);
+ assert.deepStrictEqual(cont.details?.exchangeCreditAccounts, [
+ "payto://iban/DE999",
+ ]);
+});
+
+test("a manual withdrawal whose reserve is funded waits for the exchange", (t) => {
+ const cont = computeContinuation(
+ makeTx(
+ TransactionType.Withdrawal,
+ {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ExchangeWaitReserve,
+ },
+ {
+ withdrawalDetails: {
+ type: WithdrawalType.ManualTransfer,
+ exchangePaytoUris: ["payto://iban/DE999"],
+ reservePub: "RESERVE",
+ reserveIsReady: true,
+ },
+ },
+ ),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.WaitCounterparty);
+ assert.strictEqual(cont.actionable, false);
+});
+
+test("peer transactions in 'ready' want their uri shared", (t) => {
+ for (const type of [
+ TransactionType.PeerPullCredit,
+ TransactionType.PeerPushDebit,
+ ]) {
+ const cont = computeContinuation(
+ makeTx(
+ type,
+ {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
+ },
+ { talerUri: "taler://pay-push/example" },
+ ),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.ShareInvoice);
+ assert.strictEqual(cont.actionable, true);
+ assert.strictEqual(cont.automatic, false);
+ assert.strictEqual(cont.details?.talerUri, "taler://pay-push/example");
+ }
+});
+
+test("'ready' does not mean the same thing for a withdrawal", (t) => {
+ const cont = computeContinuation(
+ makeTx(TransactionType.Withdrawal, {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
+ }),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.WaitCounterparty);
+ assert.strictEqual(cont.actionable, false);
+});
+
+test("an unrecognized idle state is nobody's turn in particular", (t) => {
+ const cont = computeContinuation(
+ makeTx(TransactionType.Refresh, {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Refresh,
+ }),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.WaitCounterparty);
+ assert.strictEqual(cont.actionable, false);
+ assert.strictEqual(cont.automatic, false);
+});
+
+test("a dialog state of a type with no confirmation is not actionable", (t) => {
+ const cont = computeContinuation(
+ makeTx(TransactionType.Refund, {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ }),
+ );
+ assert.strictEqual(cont.kind, ContinuationKind.WaitCounterparty);
+ assert.strictEqual(cont.automatic, false);
+});
diff --git a/packages/taler-wallet-cli/src/continuation.ts b/packages/taler-wallet-cli/src/continuation.ts
@@ -0,0 +1,375 @@
+/*
+ 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
+ * What a transaction needs in order to make progress, used by the
+ * "continue-tx" command.
+ *
+ * The wallet API does not say this anywhere: txActions only lists the
+ * lifecycle transitions (abort, suspend, ...) and never the step that
+ * actually unblocks a transaction. So the mapping is spelled out here,
+ * keyed on the transaction type as well as the state, because a minor
+ * state does not mean the same thing for every type.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ ExchangeTosStatus,
+ isFinalTransactionState,
+ Transaction,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ WithdrawalType,
+} from "@gnu-taler/taler-util";
+
+export enum ContinuationKind {
+ /**
+ * Nothing to do: the transaction is final, or the wallet is
+ * working on it.
+ */
+ Nothing = "nothing",
+ Resume = "resume",
+ ConfirmPayment = "confirm-payment",
+ ConfirmWithdrawal = "confirm-withdrawal",
+ ConfirmPeerPushCredit = "confirm-peer-push-credit",
+ ConfirmPeerPullDebit = "confirm-peer-pull-debit",
+ CompleteKyc = "complete-kyc",
+ KycAuthTransfer = "kyc-auth-transfer",
+ ConfirmWithBank = "confirm-with-bank",
+ MakeWireTransfer = "make-wire-transfer",
+ ShareInvoice = "share-invoice",
+ /**
+ * The transaction is idle, but it is somebody else's turn.
+ */
+ WaitCounterparty = "wait-counterparty",
+}
+
+export interface Continuation {
+ kind: ContinuationKind;
+
+ /**
+ * Is somebody expected to do something? False when the transaction
+ * is final, when the wallet is working on it, and when we are waiting
+ * for a counterparty.
+ */
+ actionable: boolean;
+
+ /**
+ * Can this be done here, with a wallet API call? False for the steps
+ * that happen elsewhere, such as a KYC form or a bank's 2nd factor.
+ */
+ automatic: boolean;
+
+ /**
+ * One-line description, shown to the user.
+ */
+ summary: string;
+
+ /**
+ * Exchange whose terms of service gate this continuation, if it is
+ * known already. It is not known for a withdrawal that is still
+ * waiting for the user to pick an exchange.
+ */
+ tosExchangeBaseUrl?: string;
+
+ /**
+ * Terms of service still need to be accepted first. Only set when
+ * the caller passed the status of the exchange.
+ */
+ requiresTos?: boolean;
+
+ /**
+ * Whatever the user needs in order to act, such as a KYC URL or the
+ * account to transfer to. Reported as-is in the JSON output.
+ */
+ details?: Record<string, unknown>;
+}
+
+export interface ContinuationContext {
+ /**
+ * Terms of service status of the transaction's exchange, if the
+ * caller has looked it up.
+ */
+ tosStatus?: ExchangeTosStatus;
+}
+
+function nothing(summary: string): Continuation {
+ return {
+ kind: ContinuationKind.Nothing,
+ actionable: false,
+ automatic: false,
+ summary,
+ };
+}
+
+function waitCounterparty(summary: string): Continuation {
+ return {
+ kind: ContinuationKind.WaitCounterparty,
+ actionable: false,
+ automatic: false,
+ summary,
+ };
+}
+
+/**
+ * A step that only the user can take, elsewhere.
+ */
+function manual(
+ kind: ContinuationKind,
+ summary: string,
+ details?: Record<string, unknown>,
+): Continuation {
+ return { kind, actionable: true, automatic: false, summary, details };
+}
+
+/**
+ * A step that the CLI can take by calling wallet-core.
+ */
+function automatic(
+ kind: ContinuationKind,
+ summary: string,
+ tosExchangeBaseUrl?: string,
+): Continuation {
+ return {
+ kind,
+ actionable: true,
+ automatic: true,
+ summary,
+ tosExchangeBaseUrl,
+ };
+}
+
+/**
+ * The exchange whose terms of service gate a confirmation, as far as
+ * the transaction itself reveals it.
+ */
+function tosExchangeOf(tx: Transaction): string | undefined {
+ switch (tx.type) {
+ case TransactionType.Withdrawal:
+ // Undefined while the user has not chosen an exchange yet, which
+ // is exactly the state that needs confirming.
+ return tx.exchangeBaseUrl;
+ case TransactionType.PeerPushCredit:
+ case TransactionType.PeerPullCredit:
+ return tx.exchangeBaseUrl;
+ default:
+ return undefined;
+ }
+}
+
+/**
+ * The confirmation that a transaction in a dialog state is waiting for.
+ */
+function dialogContinuation(tx: Transaction): Continuation {
+ switch (tx.type) {
+ case TransactionType.Payment:
+ return automatic(
+ ContinuationKind.ConfirmPayment,
+ "the merchant's offer is waiting to be accepted",
+ );
+ case TransactionType.Withdrawal:
+ return automatic(
+ ContinuationKind.ConfirmWithdrawal,
+ "the withdrawal is waiting for an exchange and an amount",
+ tosExchangeOf(tx),
+ );
+ case TransactionType.PeerPushCredit:
+ return automatic(
+ ContinuationKind.ConfirmPeerPushCredit,
+ "an incoming peer payment is waiting to be accepted",
+ tosExchangeOf(tx),
+ );
+ case TransactionType.PeerPullDebit:
+ return automatic(
+ ContinuationKind.ConfirmPeerPullDebit,
+ "an invoice is waiting to be paid",
+ );
+ default:
+ return waitCounterparty(
+ `transaction type '${tx.type}' has no known confirmation step`,
+ );
+ }
+}
+
+/**
+ * What the user has to do somewhere else, based on the minor state.
+ */
+function manualContinuation(tx: Transaction): Continuation | undefined {
+ switch (tx.txState.minor) {
+ case TransactionMinorState.KycRequired:
+ case TransactionMinorState.BalanceKycRequired:
+ return manual(
+ ContinuationKind.CompleteKyc,
+ tx.kycUrl != null
+ ? `a KYC check is required: ${tx.kycUrl}`
+ : "a KYC check is required, but the exchange gave no address for it",
+ {
+ kycUrl: tx.kycUrl ?? null,
+ kycAccessToken: tx.kycAccessToken ?? null,
+ kycPaytoHash: tx.kycPaytoHash ?? null,
+ },
+ );
+ case TransactionMinorState.KycAuthRequired: {
+ const info = tx.kycAuthTransferInfo;
+ return manual(
+ ContinuationKind.KycAuthTransfer,
+ info != null
+ ? `transfer ${info.amount} from ${info.debitPaytoUri} to prove that the account is yours`
+ : "a transfer is required to prove account ownership, but the details are missing",
+ {
+ debitPaytoUri: info?.debitPaytoUri ?? null,
+ accountPub: info?.accountPub ?? null,
+ amount: info?.amount ?? null,
+ transferOptions: info?.transferOptions ?? null,
+ },
+ );
+ }
+ case TransactionMinorState.BankConfirmTransfer: {
+ const url = bankConfirmationUrlOf(tx);
+ return manual(
+ ContinuationKind.ConfirmWithBank,
+ url != null
+ ? `confirm the withdrawal with your bank: ${url}`
+ : "confirm the withdrawal with your bank",
+ { bankConfirmationUrl: url ?? null },
+ );
+ }
+ case TransactionMinorState.Ready:
+ // "Ready" is a working state for a withdrawal; only the two peer
+ // transactions that hand out a URI are waiting for the user here.
+ if (
+ tx.type === TransactionType.PeerPullCredit ||
+ tx.type === TransactionType.PeerPushDebit
+ ) {
+ return manual(
+ ContinuationKind.ShareInvoice,
+ tx.talerUri != null
+ ? `pass this URI to the other party: ${tx.talerUri}`
+ : "the other party has to be given the transaction's URI",
+ { talerUri: tx.talerUri ?? null },
+ );
+ }
+ return undefined;
+ case TransactionMinorState.ExchangeWaitReserve: {
+ const accounts = manualTransferAccountsOf(tx);
+ if (accounts == null) {
+ return undefined;
+ }
+ return manual(
+ ContinuationKind.MakeWireTransfer,
+ "transfer the funds to the exchange to complete the withdrawal",
+ { exchangeCreditAccounts: accounts },
+ );
+ }
+ default:
+ return undefined;
+ }
+}
+
+function bankConfirmationUrlOf(tx: Transaction): string | undefined {
+ if (
+ tx.type !== TransactionType.Withdrawal &&
+ tx.type !== TransactionType.InternalWithdrawal
+ ) {
+ return undefined;
+ }
+ const details = tx.withdrawalDetails;
+ if (details.type !== WithdrawalType.TalerBankIntegrationApi) {
+ return undefined;
+ }
+ return details.bankConfirmationUrl;
+}
+
+/**
+ * Accounts to wire funds to, for a manual withdrawal that the exchange
+ * has not seen the money for yet.
+ */
+function manualTransferAccountsOf(tx: Transaction): unknown[] | undefined {
+ if (
+ tx.type !== TransactionType.Withdrawal &&
+ tx.type !== TransactionType.InternalWithdrawal
+ ) {
+ return undefined;
+ }
+ const details = tx.withdrawalDetails;
+ if (details.type !== WithdrawalType.ManualTransfer) {
+ return undefined;
+ }
+ if (details.reserveIsReady) {
+ return undefined;
+ }
+ return details.exchangeCreditAccountDetails ?? details.exchangePaytoUris;
+}
+
+function isSuspended(major: TransactionMajorState): boolean {
+ return (
+ major === TransactionMajorState.Suspended ||
+ major === TransactionMajorState.SuspendedFinalizing ||
+ major === TransactionMajorState.SuspendedAborting
+ );
+}
+
+/**
+ * Work out what a transaction is waiting for.
+ *
+ * Pure, so that the table above can be tested without a wallet.
+ */
+export function computeContinuation(
+ tx: Transaction,
+ ctx: ContinuationContext = {},
+): Continuation {
+ const { major } = tx.txState;
+
+ if (isFinalTransactionState(tx.txState)) {
+ return nothing(`the transaction is already ${major}`);
+ }
+
+ // The wallet's own signal that it has this covered. Checked before
+ // everything else, so that the rest of the table only ever describes
+ // states in which the wallet has stopped by itself.
+ if (tx.txState.working) {
+ return nothing("the wallet is working on the transaction");
+ }
+
+ if (isSuspended(major)) {
+ return automatic(
+ ContinuationKind.Resume,
+ "the transaction is suspended and can be resumed",
+ );
+ }
+
+ let cont: Continuation;
+ if (major === TransactionMajorState.Dialog) {
+ cont = dialogContinuation(tx);
+ } else {
+ cont =
+ manualContinuation(tx) ??
+ waitCounterparty("the transaction is waiting for somebody else");
+ }
+
+ if (
+ cont.automatic &&
+ cont.tosExchangeBaseUrl != null &&
+ ctx.tosStatus === ExchangeTosStatus.Proposed
+ ) {
+ cont.requiresTos = true;
+ }
+ return cont;
+}
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
@@ -59,6 +59,11 @@ import {
} from "@gnu-taler/taler-util";
import { clk } from "@gnu-taler/taler-util/clk";
import {
+ computeContinuation,
+ Continuation,
+ ContinuationKind,
+} from "./continuation.js";
+import {
CliUsageError,
formatTxState,
formatTxStateSpec,
@@ -167,10 +172,11 @@ interface PayOptions {
}
async function doHandlePayTransaction(
- wallet: WalletCoreApiClient,
+ ctx: WalletContext,
transactionId: TransactionIdStr,
options: PayOptions,
): Promise<void> {
+ const wallet = ctx.client;
await wallet.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId,
txState: "nonpending",
@@ -220,48 +226,15 @@ async function doHandlePayTransaction(
return;
}
if (paySt.txState.major === TransactionMajorState.Dialog) {
- const choices = await wallet.call(WalletApiOperation.GetChoicesForPayment, {
- transactionId,
+ // Same step as "continue-tx" on this transaction, so that the two
+ // entry points cannot drift apart.
+ const res = await performConfirmPayment(ctx, transactionId, {
+ yes: options.alwaysYes ?? false,
+ acceptTos: false,
+ choiceIndex: options.choiceIndex,
});
- let choiceIndex: number | undefined;
- if (options.choiceIndex != null) {
- choiceIndex = options.choiceIndex;
- } else if (choices.choices.length > 1) {
- if (options.nonInteractive) {
- console.log(`choices:`);
- console.log(`Please choose an option with --choice-index.`);
- processExit(EXIT_INPUT_REQUIRED);
- } else {
- console.log(`${j2s(choices)}`);
- choiceIndex = await askChoice(choices.choices.length);
- }
- } else {
- choiceIndex = 0;
- console.log("contract:", choices.contractTerms);
- }
- const myChoice = choices.choices[choiceIndex];
- if (myChoice.status !== ChoiceSelectionDetailType.PaymentPossible) {
- console.log("insufficient balance for choice");
- processExit(1);
- }
- let doPay: boolean;
- if (options.alwaysYes) {
- doPay = true;
- } else if (options.nonInteractive) {
- console.log(`Please confirm payment by passing '--yes' to handle-uri`);
- processExit(EXIT_INPUT_REQUIRED);
- } else {
- doPay = await askYesNo();
- }
-
- if (doPay) {
- await wallet.call(WalletApiOperation.ConfirmPay, {
- transactionId,
- choiceIndex: choiceIndex,
- useDonau: true,
- });
- } else {
- console.log("not paying");
+ if (res.exitCode !== 0) {
+ processExit(res.exitCode);
}
if (options.noWait) {
return;
@@ -279,25 +252,25 @@ async function doHandlePayTransaction(
}
async function doPayTemplate(
- wallet: WalletCoreApiClient,
+ ctx: WalletContext,
payUrl: string,
options: PayOptions = {},
): Promise<void> {
- const r = await wallet.call(WalletApiOperation.PreparePayForTemplateV2, {
+ const r = await ctx.client.call(WalletApiOperation.PreparePayForTemplateV2, {
talerPayTemplateUri: payUrl,
});
- await doHandlePayTransaction(wallet, r.transactionId, options);
+ await doHandlePayTransaction(ctx, r.transactionId, options);
}
async function doPay(
- wallet: WalletCoreApiClient,
+ ctx: WalletContext,
payUrl: string,
options: PayOptions = {},
): Promise<void> {
- const r = await wallet.call(WalletApiOperation.PreparePayForUriV2, {
+ const r = await ctx.client.call(WalletApiOperation.PreparePayForUriV2, {
talerPayUri: payUrl,
});
- await doHandlePayTransaction(wallet, r.transactionId, options);
+ await doHandlePayTransaction(ctx, r.transactionId, options);
}
let globalNonInteractive = false;
@@ -363,14 +336,13 @@ async function askChoice(n: number): Promise<number> {
}
}
-async function askYesNo(): Promise<boolean> {
+async function askYesNo(question: string = "Pay?"): Promise<boolean> {
while (true) {
- const yesNoResp = (await promptOrFail("Pay? [Y/n]")).toLowerCase();
+ const yesNoResp = (await promptOrFail(`${question} [Y/n]`)).toLowerCase();
if (yesNoResp === "" || yesNoResp === "y" || yesNoResp === "yes") {
return true;
} else if (yesNoResp === "n" || yesNoResp === "no") {
return false;
- break;
} else {
console.log("please answer y/n");
}
@@ -1285,6 +1257,440 @@ async function waitForCreatedTx(
return unsuccessful ? EXIT_TX_UNSUCCESSFUL : 0;
}
+/**
+ * Answers that a continuation may need, supplied on the command line
+ * so that it also works without a terminal.
+ */
+interface ContinuationOptions {
+ yes: boolean;
+ acceptTos: boolean;
+ choiceIndex?: number;
+ exchange?: string;
+ amount?: string;
+ restrictAge?: number;
+}
+
+interface PerformResult {
+ performed: boolean;
+ exitCode: number;
+}
+
+const NOT_PERFORMED: PerformResult = { performed: false, exitCode: 0 };
+const PERFORMED: PerformResult = { performed: true, exitCode: 0 };
+const NEEDS_INPUT: PerformResult = {
+ performed: false,
+ exitCode: EXIT_INPUT_REQUIRED,
+};
+
+type Confirmation = "yes" | "no" | "input-required";
+
+/**
+ * Ask for a go-ahead, saying which flag would have given it when
+ * there is nobody to ask.
+ */
+async function confirmContinuation(
+ question: string,
+ opts: ContinuationOptions,
+): Promise<Confirmation> {
+ if (opts.yes) {
+ return "yes";
+ }
+ if (isNonInteractive()) {
+ console.error(`error: confirmation required: ${question}`);
+ console.error(" pass --yes to confirm without being asked");
+ return "input-required";
+ }
+ return (await askYesNo(question)) ? "yes" : "no";
+}
+
+/**
+ * Accept the exchange's terms of service, if they are still pending.
+ *
+ * Returns false if the user did not accept them, in which case the
+ * continuation cannot go ahead.
+ */
+async function settleTos(
+ ctx: WalletContext,
+ exchangeBaseUrl: string | undefined,
+ opts: ContinuationOptions,
+): Promise<boolean> {
+ if (exchangeBaseUrl == null) {
+ return true;
+ }
+ const ok = await cliHandleTos(ctx, exchangeBaseUrl, {
+ autoAccept: opts.acceptTos,
+ });
+ if (!ok) {
+ console.error(
+ `error: the terms of service of ${exchangeBaseUrl} have to be accepted first`,
+ );
+ }
+ return ok;
+}
+
+/**
+ * Pick a contract choice and pay, asking whoever is there to ask.
+ */
+async function performConfirmPayment(
+ ctx: WalletContext,
+ transactionId: TransactionIdStr,
+ opts: ContinuationOptions,
+): Promise<PerformResult> {
+ const choices = await ctx.client.call(
+ WalletApiOperation.GetChoicesForPayment,
+ { transactionId },
+ );
+ let choiceIndex: number;
+ if (opts.choiceIndex != null) {
+ choiceIndex = opts.choiceIndex;
+ } else if (choices.choices.length > 1) {
+ if (isNonInteractive()) {
+ console.error(
+ `error: the offer has ${choices.choices.length} choices; pass --choice-index`,
+ );
+ console.error(j2s(choices));
+ return { performed: false, exitCode: EXIT_INPUT_REQUIRED };
+ }
+ console.log(`${j2s(choices)}`);
+ choiceIndex = await askChoice(choices.choices.length);
+ } else {
+ choiceIndex = 0;
+ console.log("contract:", choices.contractTerms);
+ }
+ const myChoice = choices.choices[choiceIndex];
+ if (myChoice == null) {
+ throw new CliUsageError(
+ `no choice with index ${choiceIndex}`,
+ `the offer has ${choices.choices.length} choice(s)`,
+ );
+ }
+ if (myChoice.status !== ChoiceSelectionDetailType.PaymentPossible) {
+ console.error("error: insufficient balance for the selected choice");
+ return { performed: false, exitCode: EXIT_API_ERROR };
+ }
+ switch (await confirmContinuation("Pay?", opts)) {
+ case "input-required":
+ return NEEDS_INPUT;
+ case "no":
+ console.log("not paying");
+ return NOT_PERFORMED;
+ }
+ await ctx.client.call(WalletApiOperation.ConfirmPay, {
+ transactionId,
+ choiceIndex,
+ useDonau: true,
+ });
+ return PERFORMED;
+}
+
+/**
+ * Supply the exchange and amount that a withdrawal is waiting for.
+ */
+async function performConfirmWithdrawal(
+ ctx: WalletContext,
+ transactionId: TransactionIdStr,
+ opts: ContinuationOptions,
+): Promise<PerformResult> {
+ let exchangeBaseUrl = opts.exchange;
+ if (exchangeBaseUrl == null) {
+ if (isNonInteractive()) {
+ console.error(
+ "error: the withdrawal has no exchange yet; pass --exchange",
+ );
+ return { performed: false, exitCode: EXIT_INPUT_REQUIRED };
+ }
+ const known = await ctx.client.call(WalletApiOperation.ListExchanges, {});
+ for (const exch of known.exchanges) {
+ console.log(` ${exch.exchangeBaseUrl} (${exch.currency ?? "?"})`);
+ }
+ exchangeBaseUrl = (await promptOrFail("Exchange base URL: ")).trim();
+ }
+ if (exchangeBaseUrl === "") {
+ console.error("error: no exchange given");
+ return { performed: false, exitCode: EXIT_INPUT_REQUIRED };
+ }
+ if (!(await settleTos(ctx, exchangeBaseUrl, opts))) {
+ return NOT_PERFORMED;
+ }
+ switch (
+ await confirmContinuation(`Withdraw from ${exchangeBaseUrl}?`, opts)
+ ) {
+ case "input-required":
+ return NEEDS_INPUT;
+ case "no":
+ console.log("not withdrawing");
+ return NOT_PERFORMED;
+ }
+ let amount: AmountString | undefined;
+ if (opts.amount != null) {
+ // clk.AMOUNT does not validate, so a typo would only surface as a
+ // wallet-core error about a malformed request.
+ try {
+ amount = Amounts.stringify(Amounts.parseOrThrow(opts.amount));
+ } catch {
+ throw new CliUsageError(`invalid amount '${opts.amount}'`);
+ }
+ }
+ await ctx.client.call(WalletApiOperation.ConfirmWithdrawal, {
+ transactionId,
+ exchangeBaseUrl,
+ amount,
+ restrictAge: opts.restrictAge,
+ });
+ return PERFORMED;
+}
+
+/**
+ * Do whatever the transaction is waiting for, if it is something that
+ * wallet-core can be asked to do.
+ */
+async function performContinuation(
+ ctx: WalletContext,
+ tx: Transaction,
+ cont: Continuation,
+ opts: ContinuationOptions,
+): Promise<PerformResult> {
+ const transactionId = tx.transactionId;
+ switch (cont.kind) {
+ case ContinuationKind.Resume:
+ await ctx.client.call(WalletApiOperation.ResumeTransaction, {
+ transactionId,
+ });
+ return PERFORMED;
+ case ContinuationKind.ConfirmPayment:
+ return await performConfirmPayment(ctx, transactionId, opts);
+ case ContinuationKind.ConfirmWithdrawal:
+ return await performConfirmWithdrawal(ctx, transactionId, opts);
+ case ContinuationKind.ConfirmPeerPushCredit: {
+ if (!(await settleTos(ctx, cont.tosExchangeBaseUrl, opts))) {
+ return NOT_PERFORMED;
+ }
+ switch (
+ await confirmContinuation(
+ `Accept payment of ${tx.amountEffective}?`,
+ opts,
+ )
+ ) {
+ case "input-required":
+ return NEEDS_INPUT;
+ case "no":
+ console.log("not accepting");
+ return NOT_PERFORMED;
+ }
+ await ctx.client.call(WalletApiOperation.ConfirmPeerPushCredit, {
+ transactionId,
+ });
+ return PERFORMED;
+ }
+ case ContinuationKind.ConfirmPeerPullDebit: {
+ switch (
+ await confirmContinuation(`Pay invoice of ${tx.amountEffective}?`, opts)
+ ) {
+ case "input-required":
+ return NEEDS_INPUT;
+ case "no":
+ console.log("not paying");
+ return NOT_PERFORMED;
+ }
+ await ctx.client.call(WalletApiOperation.ConfirmPeerPullDebit, {
+ transactionId,
+ });
+ return PERFORMED;
+ }
+ default:
+ // Everything else is either nothing to do or somebody else's job.
+ return NOT_PERFORMED;
+ }
+}
+
+/**
+ * Look up the terms of service status, so that the continuation can say
+ * whether they are in the way.
+ */
+async function lookupTosStatus(
+ ctx: WalletContext,
+ exchangeBaseUrl: string,
+): Promise<ExchangeTosStatus | undefined> {
+ try {
+ const exch = await ctx.client.call(
+ WalletApiOperation.GetExchangeEntryByUrl,
+ { exchangeBaseUrl },
+ );
+ return exch.tosStatus;
+ } catch (e) {
+ // Only used to describe the continuation, so a missing entry is
+ // not worth failing the command over.
+ logger.warn(`could not look up exchange ${exchangeBaseUrl}: ${e}`);
+ return undefined;
+ }
+}
+
+/**
+ * Work out what a transaction needs, consulting the exchange when the
+ * answer depends on its terms of service.
+ */
+async function continuationForTx(
+ ctx: WalletContext,
+ tx: Transaction,
+): Promise<Continuation> {
+ const first = computeContinuation(tx);
+ if (!first.automatic || first.tosExchangeBaseUrl == null) {
+ return first;
+ }
+ const tosStatus = await lookupTosStatus(ctx, first.tosExchangeBaseUrl);
+ return computeContinuation(tx, { tosStatus });
+}
+
+interface ContinueTxArgs extends CreateWaitArgs {
+ transactionId: string;
+ yes: boolean;
+ acceptTos: boolean;
+ dryRun: boolean;
+ choiceIndex?: number;
+ exchange?: string;
+ amount?: string;
+ restrictAge?: number;
+ json: boolean;
+ quiet: boolean;
+}
+
+function reportContinuation(cont: Continuation, tx: Transaction): void {
+ console.log(`transaction is in state ${formatTxState(tx.txState)}`);
+ console.log(cont.summary);
+ if (cont.requiresTos) {
+ console.log(
+ `the terms of service of ${cont.tosExchangeBaseUrl} have to be accepted first`,
+ );
+ }
+ for (const [key, value] of Object.entries(cont.details ?? {})) {
+ if (value != null) {
+ console.log(
+ ` ${key}: ${typeof value === "string" ? value : j2s(value)}`,
+ );
+ }
+ }
+}
+
+async function runContinueTx(
+ cliArgs: WalletCliArgsType,
+ a: ContinueTxArgs,
+): Promise<number> {
+ const transactionId = a.transactionId as TransactionIdStr;
+ const opts: ContinuationOptions = {
+ yes: a.yes,
+ acceptTos: a.acceptTos,
+ choiceIndex: a.choiceIndex,
+ exchange: a.exchange,
+ amount: a.amount,
+ restrictAge: a.restrictAge,
+ };
+ return await withWallet(cliArgs, { lazyTaskLoop: false }, async (ctx) => {
+ const tx = await ctx.client.call(WalletApiOperation.GetTransactionById, {
+ transactionId,
+ });
+ const cont = await continuationForTx(ctx, tx);
+
+ let result: PerformResult = NOT_PERFORMED;
+ if (cont.actionable && cont.automatic && !a.dryRun) {
+ result = await performContinuation(ctx, tx, cont, opts);
+ }
+
+ if (a.json) {
+ console.log(
+ JSON.stringify(
+ {
+ transactionId,
+ continuation: cont.kind,
+ actionable: cont.actionable,
+ automatic: cont.automatic,
+ performed: result.performed,
+ summary: cont.summary,
+ requiresTos: cont.requiresTos ?? false,
+ tosExchangeBaseUrl: cont.tosExchangeBaseUrl ?? null,
+ details: cont.details ?? null,
+ transaction: txDetailsForJson(tx),
+ },
+ undefined,
+ 2,
+ ),
+ );
+ } else if (!a.quiet) {
+ if (a.dryRun || !cont.automatic || !cont.actionable) {
+ reportContinuation(cont, tx);
+ } else if (result.performed) {
+ console.log("transaction continued");
+ }
+ }
+
+ if (result.exitCode !== 0) {
+ return result.exitCode;
+ }
+ // Nothing the wallet can do about it, so say so with the exit code
+ // instead of pretending that the transaction was continued.
+ if (cont.actionable && !cont.automatic) {
+ return EXIT_INPUT_REQUIRED;
+ }
+ if (!result.performed) {
+ return 0;
+ }
+ return await waitForCreatedTx(ctx, transactionId, a);
+ });
+}
+
+const continueTxHelp = [
+ "Do whatever a transaction is waiting for.",
+ "Confirms offers, picks up suspended transactions and reports the steps",
+ "that have to happen elsewhere (exit code 6), such as a KYC check or a",
+ "bank's confirmation.",
+].join(" ");
+
+function addContinueTxCommand(parent: any, argKey: string, name: string): void {
+ parent
+ .subcommand(argKey, name, { help: continueTxHelp })
+ .requiredArgument("transactionId", clk.STRING, {
+ metavar: "TRANSACTION_ID",
+ help: "Identifier of the transaction to continue.",
+ })
+ .flag("yes", ["-y", "--yes"], {
+ help: "Confirm without asking.",
+ })
+ .flag("acceptTos", ["--accept-tos"], {
+ help: "Accept the exchange's terms of service without asking.",
+ })
+ .flag("dryRun", ["-n", "--dry-run"], {
+ help: "Only report what the transaction is waiting for.",
+ })
+ .maybeOption("choiceIndex", ["--choice-index"], clk.INT, {
+ help: "Choice to accept, for an offer that has several.",
+ })
+ .maybeOption("exchange", ["--exchange"], clk.STRING, {
+ help: "Exchange to withdraw from, for a withdrawal that has none yet.",
+ })
+ .maybeOption("amount", ["--amount"], clk.AMOUNT, {
+ help: "Amount to withdraw, for a withdrawal with an editable amount.",
+ })
+ .maybeOption("restrictAge", ["--restrict-age"], clk.INT, {
+ help: "Age restriction to withdraw with.",
+ })
+ .flag("wait", ["--wait"], {
+ help: "Wait for the transaction to be final afterwards.",
+ })
+ .maybeOption("timeout", ["-t", "--timeout"], clk.STRING, {
+ help: "Give up waiting after this duration (e.g. '30s', '5m').",
+ })
+ .flag("json", ["--json"], {
+ help: "Print the outcome as JSON on stdout.",
+ })
+ .flag("quiet", ["-q", "--quiet"], {
+ help: "Don't print anything, just set the exit code.",
+ })
+ .action(async (args: any) => {
+ await runCliAction(() =>
+ runContinueTx(args, args[argKey] as ContinueTxArgs),
+ );
+ });
+}
+
const waitTxHelp = [
"Block until a transaction is in one of the given states.",
"Meant for scripts: the state that ended the wait determines the exit code",
@@ -1335,6 +1741,9 @@ addWaitTxCommand(walletCli, "waitTx", "wait-tx");
// transaction subcommands. The argument key must differ.
addWaitTxCommand(transactionsCli, "transactionsWait", "wait");
+addContinueTxCommand(walletCli, "continueTx", "continue-tx");
+addContinueTxCommand(transactionsCli, "transactionsContinue", "continue");
+
walletCli
.subcommand("finishPendingOpt", "run-until-done", {
help: "Run until no more work is left.",
@@ -1476,6 +1885,7 @@ withdrawCli
async function cliHandleTos(
wallet: WalletContext,
exchangeBaseUrl: string,
+ opts: { autoAccept?: boolean } = {},
): Promise<boolean> {
while (1) {
const exch = await wallet.client.call(
@@ -1488,6 +1898,12 @@ async function cliHandleTos(
// ToS already accepted (or not applicable) — nothing to prompt for.
return true;
}
+ if (opts.autoAccept) {
+ await wallet.client.call(WalletApiOperation.SetExchangeTosAccepted, {
+ exchangeBaseUrl,
+ });
+ return true;
+ }
if (exch.tosStatus === ExchangeTosStatus.Proposed) {
const res = await promptOrFail(
`Accept terms of service of exchange ${exchangeBaseUrl}? [y/N/info]: `,
@@ -1550,20 +1966,18 @@ async function cliPeerPushCredit(
const res = await promptOrFail(
`Accept payment of ${prepRes.amountEffective}? [y/N/info/delete]: `,
);
- let done = false;
switch (res.toLowerCase()) {
case "y": {
- const tosOk = await cliHandleTos(wallet, prepRes.exchangeBaseUrl);
- if (!tosOk) {
- console.log(
- "ToS needs to be accepted before payment can be accepted",
- );
- done = true;
- break;
- }
- await wallet.client.call(WalletApiOperation.ConfirmPeerPushCredit, {
- transactionId: prepRes.transactionId,
+ // Same step as "continue-tx" on this transaction, so that the
+ // two entry points cannot drift apart.
+ const cont = await continuationForTx(wallet, txDet);
+ const perf = await performContinuation(wallet, txDet, cont, {
+ yes: true,
+ acceptTos: false,
});
+ if (!perf.performed) {
+ return;
+ }
console.log(
"peer-push-credit confirmed, waiting for transaction to be final...",
);
@@ -1571,20 +1985,21 @@ async function cliPeerPushCredit(
WalletApiOperation.TestingWaitTransactionState,
{
transactionId: prepRes.transactionId,
- txState: [
- { major: TransactionMajorState.Done },
- { major: TransactionMajorState.Failed, minor: "*" },
- { major: TransactionMajorState.Aborted, minor: "*" },
- ],
+ txState: "final",
},
);
- done = true;
- break;
+ return;
}
case "":
case "n": {
- done = true;
- break;
+ return;
+ }
+ case "delete": {
+ await wallet.client.call(WalletApiOperation.DeleteTransaction, {
+ transactionId: prepRes.transactionId,
+ });
+ console.log("transaction deleted");
+ return;
}
case "info": {
console.log(`${j2s(txDet)}`);
@@ -1595,9 +2010,6 @@ async function cliPeerPushCredit(
break;
}
}
- if (done) {
- break;
- }
}
}
}
@@ -1635,7 +2047,7 @@ walletCli
}
switch (parsedTalerUri.type) {
case TalerUriAction.PayTemplate:
- await doPayTemplate(wallet.client, uri, {
+ await doPayTemplate(wallet, uri, {
alwaysYes: args.handleUri.autoYes,
choiceIndex: args.handleUri.choiceIndex,
nonInteractive: args.handleUri.nonInteractive || isNonInteractive(),
@@ -1643,7 +2055,7 @@ walletCli
});
break;
case TalerUriAction.Pay:
- await doPay(wallet.client, uri, {
+ await doPay(wallet, uri, {
alwaysYes: args.handleUri.autoYes,
choiceIndex: args.handleUri.choiceIndex,
nonInteractive: args.handleUri.nonInteractive || isNonInteractive(),