taler-typescript-core

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

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:
Apackages/taler-wallet-cli/src/continuation.test.ts | 343+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-cli/src/continuation.ts | 375+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-cli/src/index.ts | 564++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
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(),