/* This file is part of GNU Taler (C) 2019 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 */ /** * Imports. */ import { AbsoluteTime, addPaytoQueryParams, AgeRestriction, AmountString, codecForList, codecForString, CoreApiResponse, Duration, encodeCrock, getErrorDetailFromException, getRandomBytes, j2s, Logger, NotificationType, parsePaytoUri, parseTalerUri, PreparePayResultType, sampleWalletCoreTransactions, setDangerousTimetravel, setGlobalLogLevelFromString, summarizeTalerErrorDetail, TalerUriAction, TransactionIdStr, WalletNotification, } from "@gnu-taler/taler-util"; import { clk } from "@gnu-taler/taler-util/clk"; import { getenv, pathHomedir, processExit, readFile, readlinePrompt, setUnhandledRejectionHandler, } from "@gnu-taler/taler-util/compat"; import { createPlatformHttpLib } from "@gnu-taler/taler-util/http"; import { JsonMessage, runRpcServer } from "@gnu-taler/taler-util/twrpc"; import { AccessStats, createNativeWalletHost2, nativeCrypto, Wallet, WalletApiOperation, WalletCoreApiClient, } from "@gnu-taler/taler-wallet-core"; import { createRemoteWallet, getClientFromRemoteWallet, makeNotificationWaiter, } from "@gnu-taler/taler-wallet-core/remote"; import * as fs from "node:fs"; // This module also serves as the entry point for the crypto // thread worker, and thus must expose these two handlers. export { handleWorkerError, handleWorkerMessage, } from "@gnu-taler/taler-wallet-core"; const logger = new Logger("taler-wallet-cli.ts"); let observabilityEventFile: string | undefined = undefined; const EXIT_EXCEPTION = 4; const EXIT_API_ERROR = 5; setUnhandledRejectionHandler((error: any) => { logger.error("unhandledRejection", error.message); logger.error("stack", error.stack); processExit(1); }); const defaultWalletDbPath = pathHomedir() + "/" + ".talerwalletdb.sqlite3"; const defaultWalletCoreSocket = pathHomedir() + "/" + ".wallet-core.sock"; function assertUnreachable(x: never): never { throw new Error("Didn't expect to get here"); } async function doPay( wallet: WalletCoreApiClient, payUrl: string, options: { alwaysYes: boolean } = { alwaysYes: true }, ): Promise { const result = await wallet.call(WalletApiOperation.PreparePayForUri, { talerPayUri: payUrl, }); if (result.status === PreparePayResultType.InsufficientBalance) { console.log("contract", result.contractTerms); console.error("insufficient balance"); processExit(1); return; } if (result.status === PreparePayResultType.AlreadyConfirmed) { if (result.paid) { console.log("already paid!"); } else { console.log("payment already in progress"); } processExit(0); return; } if (result.status === "payment-possible") { console.log("paying ..."); } else { throw Error("not reached"); } console.log("contract", result.contractTerms); console.log("raw amount:", result.amountRaw); console.log("effective amount:", result.amountEffective); let pay; if (options.alwaysYes) { pay = true; } else { while (true) { const yesNoResp = (await clk.prompt("Pay? [Y/n]")).toLowerCase(); if (yesNoResp === "" || yesNoResp === "y" || yesNoResp === "yes") { pay = true; break; } else if (yesNoResp === "n" || yesNoResp === "no") { pay = false; break; } else { console.log("please answer y/n"); } } } if (pay) { await wallet.call(WalletApiOperation.ConfirmPay, { proposalId: result.proposalId, }); } else { console.log("not paying"); } } function applyVerbose(verbose: boolean): void { // TODO } declare const __VERSION__: string; declare const __GIT_HASH__: string; function printVersion(): void { console.log(`${__VERSION__} ${__GIT_HASH__}`); processExit(0); } export const walletCli = clk .program("wallet", { help: "Command line interface for the GNU Taler wallet.", }) .maybeOption("walletDbFile", ["--wallet-db"], clk.STRING, { help: "Location of the wallet database file", }) .maybeOption("walletConnection", ["--wallet-connection"], clk.STRING, { help: "Connect to an RPC wallet", }) .maybeOption("timetravel", ["--timetravel"], clk.INT, { help: "modify system time by given offset in microseconds", onPresentHandler: (x) => { // Convert microseconds to milliseconds and do timetravel logger.info(`timetravelling ${x} microseconds`); setDangerousTimetravel(x / 1000); }, }) .maybeOption("cryptoWorker", ["--crypto-worker"], clk.STRING, { help: "Override crypto worker implementation type.", }) .maybeOption("log", ["-L", "--log"], clk.STRING, { help: "configure log level (NONE, ..., TRACE)", onPresentHandler: (x) => { setGlobalLogLevelFromString(x); }, }) .maybeOption("inhibit", ["--inhibit"], clk.STRING, { help: "Inhibit running certain operations, useful for debugging and testing.", }) .flag("noThrottle", ["--no-throttle"], { help: "Don't do any request throttling.", }) .flag("noHttp", ["--no-http"], { help: "Allow unsafe http connections.", }) .flag("version", ["-v", "--version"], { onPresentHandler: printVersion, }) .flag("verbose", ["-V", "--verbose"], { help: "Enable verbose output.", }) .flag("skipDefaults", ["--skip-defaults"], { help: "Skip configuring default exchanges.", }); type WalletCliArgsType = clk.GetArgType; function checkEnvFlag(name: string): boolean { const val = getenv(name); if (val == "1") { return true; } return false; } export interface WalletContext { /** * High-level client for making API requests to wallet-core. */ client: WalletCoreApiClient; /** * Low-level interface for making API requests to wallet-core. */ makeCoreApiRequest( operation: string, payload: unknown, ): Promise; /** * Return a promise that resolves after the wallet has emitted a notification * that meets the criteria of the "cond" predicate. */ waitForNotificationCond( cond: (n: WalletNotification) => T | false | undefined, ): Promise; } interface CreateWalletResult { wallet: Wallet; getStats: () => AccessStats; } async function createLocalWallet( walletCliArgs: WalletCliArgsType, notificationHandler?: (n: WalletNotification) => void, noInit?: boolean, ): Promise { const dbPath = walletCliArgs.wallet.walletDbFile ?? defaultWalletDbPath; const myHttpLib = createPlatformHttpLib({ enableThrottling: walletCliArgs.wallet.noThrottle ? false : true, requireTls: walletCliArgs.wallet.noHttp, }); const wh = await createNativeWalletHost2({ persistentStoragePath: dbPath !== ":memory:" ? dbPath : undefined, httpLib: myHttpLib, notifyHandler: (n) => { logger.info(`wallet notification: ${j2s(n)}`); if (notificationHandler) { notificationHandler(n); } }, cryptoWorkerType: walletCliArgs.wallet.cryptoWorker as any, }); applyVerbose(walletCliArgs.wallet.verbose); const res = { wallet: wh.wallet, getStats: wh.getDbStats }; if (noInit) { return res; } try { await wh.wallet.handleCoreApiRequest("initWallet", "native-init", { config: { features: {}, testing: { devModeActive: checkEnvFlag("TALER_WALLET_DEV_MODE"), denomselAllowLate: checkEnvFlag( "TALER_WALLET_DEBUG_DENOMSEL_ALLOW_LATE", ), emitObservabilityEvents: observabilityEventFile != null, skipDefaults: walletCliArgs.wallet.skipDefaults, }, }, }); return res; } catch (e) { const ed = getErrorDetailFromException(e); console.error("Operation failed: " + summarizeTalerErrorDetail(ed)); console.error("Error details:", JSON.stringify(ed, undefined, 2)); processExit(1); } } function writeObservabilityLog(notif: WalletNotification): void { if (observabilityEventFile) { switch (notif.type) { case NotificationType.RequestObservabilityEvent: case NotificationType.TaskObservabilityEvent: fs.appendFileSync(observabilityEventFile, JSON.stringify(notif) + "\n"); break; } } } async function withWallet( walletCliArgs: WalletCliArgsType, f: (ctx: WalletContext) => Promise, ): Promise { const waiter = makeNotificationWaiter(); const onNotif = (notif: WalletNotification) => { waiter.notify(notif); writeObservabilityLog(notif); }; if (walletCliArgs.wallet.walletConnection) { logger.info("creating remote wallet"); const w = await createRemoteWallet({ name: "wallet", notificationHandler: onNotif, socketFilename: walletCliArgs.wallet.walletConnection, }); const ctx: WalletContext = { makeCoreApiRequest(operation, payload) { return w.makeCoreApiRequest(operation, payload); }, client: getClientFromRemoteWallet(w), waitForNotificationCond: waiter.waitForNotificationCond, }; const res = await f(ctx); w.close(); return res; } else { const wh = await createLocalWallet(walletCliArgs, onNotif); const ctx: WalletContext = { client: wh.wallet.client, waitForNotificationCond: waiter.waitForNotificationCond, makeCoreApiRequest(operation, payload) { return wh.wallet.handleCoreApiRequest(operation, "my-req", payload); }, }; const result = await f(ctx); await wh.wallet.client.call(WalletApiOperation.Shutdown, {}); if (process.env.TALER_WALLET_DBSTATS) { console.log("database stats:"); console.log(j2s(wh.getStats())); } return result; } } walletCli .subcommand("balance", "balance", { help: "Show wallet balance." }) .flag("json", ["--json"], { help: "Show raw JSON.", }) .action(async (args) => { await withWallet(args, async (wallet) => { const balance = await wallet.client.call( WalletApiOperation.GetBalances, {}, ); console.log(JSON.stringify(balance, undefined, 2)); }); }); walletCli .subcommand("api", "api", { help: "Call the wallet-core API directly." }) .requiredArgument("operation", clk.STRING) .requiredArgument("request", clk.STRING) .flag("expectSuccess", ["--expect-success"], { help: "Exit with non-zero status code when request fails instead of returning error JSON.", }) .action(async (args) => { await withWallet(args, async (wallet) => { let requestJson; logger.info(`handling 'api' request (${args.api.operation})`); const jsonContent = args.api.request.startsWith("@") ? readFile(args.api.request.substring(1)) : args.api.request; try { requestJson = JSON.parse(jsonContent); } catch (e) { console.error("Invalid JSON"); processExit(1); } try { const resp = await wallet.makeCoreApiRequest( args.api.operation, requestJson, ); console.log(JSON.stringify(resp, undefined, 2)); if (resp.type === "error") { if (args.api.expectSuccess) { processExit(EXIT_API_ERROR); } } } catch (e) { logger.error(`Got exception while handling API request ${e}`); processExit(EXIT_EXCEPTION); } }); logger.info("finished handling API request"); }); const transactionsCli = walletCli .subcommand("transactions", "transactions", { help: "Manage transactions." }) .maybeOption("currency", ["--currency"], clk.STRING, { help: "Filter by currency.", }) .maybeOption("search", ["--search"], clk.STRING, { help: "Filter by search string", }) .flag("includeRefreshes", ["--include-refreshes"]); // Default action transactionsCli.action(async (args) => { await withWallet(args, async (wallet) => { const pending = await wallet.client.call( WalletApiOperation.GetTransactions, { currency: args.transactions.currency, search: args.transactions.search, includeRefreshes: args.transactions.includeRefreshes, sort: "stable-ascending", }, ); console.log(JSON.stringify(pending, undefined, 2)); }); }); transactionsCli .subcommand("deleteTransaction", "delete", { help: "Permanently delete a transaction from the transaction list.", }) .requiredArgument("transactionId", clk.STRING, { help: "Identifier of the transaction to delete", }) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.DeleteTransaction, { transactionId: args.deleteTransaction.transactionId as TransactionIdStr, }); }); }); transactionsCli .subcommand("suspendTransaction", "suspend", { help: "Suspend a transaction.", }) .requiredArgument("transactionId", clk.STRING, { help: "Identifier of the transaction to suspend.", }) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.SuspendTransaction, { transactionId: args.suspendTransaction .transactionId as TransactionIdStr, }); }); }); transactionsCli .subcommand("fail", "fail", { help: "Fail a transaction (when it can't be aborted).", }) .requiredArgument("transactionId", clk.STRING, { help: "Identifier of the transaction to fail.", }) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.FailTransaction, { transactionId: args.fail.transactionId as TransactionIdStr, }); }); }); transactionsCli .subcommand("resumeTransaction", "resume", { help: "Resume a transaction.", }) .requiredArgument("transactionId", clk.STRING, { help: "Identifier of the transaction to suspend.", }) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.ResumeTransaction, { transactionId: args.resumeTransaction.transactionId as TransactionIdStr, }); }); }); transactionsCli .subcommand("lookup", "lookup", { help: "Look up a single transaction based on the transaction identifier.", }) .requiredArgument("transactionId", clk.STRING, { help: "Identifier of the transaction to delete", }) .action(async (args) => { await withWallet(args, async (wallet) => { const tx = await wallet.client.call( WalletApiOperation.GetTransactionById, { transactionId: args.lookup.transactionId, }, ); console.log(j2s(tx)); }); }); transactionsCli .subcommand("abortTransaction", "abort", { help: "Abort a transaction.", }) .requiredArgument("transactionId", clk.STRING, { help: "Identifier of the transaction to delete", }) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.AbortTransaction, { transactionId: args.abortTransaction.transactionId as TransactionIdStr, }); }); }); walletCli .subcommand("version", "version", { help: "Show version details.", }) .action(async (args) => { await withWallet(args, async (wallet) => { const versionInfo = await wallet.client.call( WalletApiOperation.GetVersion, {}, ); console.log(j2s(versionInfo)); }); }); transactionsCli .subcommand("retryTransaction", "retry", { help: "Retry a transaction.", }) .requiredArgument("transactionId", clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.RetryTransaction, { transactionId: args.retryTransaction.transactionId as TransactionIdStr, }); }); }); walletCli .subcommand("finishPendingOpt", "run-until-done", { help: "Run until no more work is left.", }) .action(async (args) => { await withWallet(args, async (ctx) => { await ctx.client.call(WalletApiOperation.TestingWaitTasksDone, {}); }); }); const withdrawCli = walletCli.subcommand("withdraw", "withdraw", { help: "Withdraw with a taler://withdraw/ URI", }); withdrawCli .subcommand("withdrawCheckUri", "check-uri") .requiredArgument("uri", clk.STRING) .maybeOption("restrictAge", ["--restrict-age"], clk.INT) .action(async (args) => { const uri = args.withdrawCheckUri.uri; const restrictAge = args.withdrawCheckUri.restrictAge; console.log(`age restriction requested (${restrictAge})`); await withWallet(args, async (wallet) => { const withdrawInfo = await wallet.client.call( WalletApiOperation.GetWithdrawalDetailsForUri, { talerWithdrawUri: uri, restrictAge, }, ); console.log("withdrawInfo", withdrawInfo); }); }); withdrawCli .subcommand("withdrawCheckAmount", "check-amount") .requiredArgument("exchange", clk.STRING) .requiredArgument("amount", clk.AMOUNT) .maybeOption("restrictAge", ["--restrict-age"], clk.INT) .action(async (args) => { const restrictAge = args.withdrawCheckAmount.restrictAge; console.log(`age restriction requested (${restrictAge})`); await withWallet(args, async (wallet) => { const withdrawInfo = await wallet.client.call( WalletApiOperation.GetWithdrawalDetailsForAmount, { amount: args.withdrawCheckAmount.amount, exchangeBaseUrl: args.withdrawCheckAmount.exchange, restrictAge, }, ); console.log("withdrawInfo", withdrawInfo); }); }); withdrawCli .subcommand("withdrawAcceptUri", "accept-uri") .requiredArgument("uri", clk.STRING) .requiredOption("exchange", ["--exchange"], clk.STRING) .maybeOption("restrictAge", ["--restrict-age"], clk.INT) .action(async (args) => { const uri = args.withdrawAcceptUri.uri; const restrictAge = args.withdrawAcceptUri.restrictAge; console.log(`age restriction requested (${restrictAge})`); await withWallet(args, async (wallet) => { const res = await wallet.client.call( WalletApiOperation.AcceptBankIntegratedWithdrawal, { exchangeBaseUrl: args.withdrawAcceptUri.exchange, talerWithdrawUri: uri, restrictAge, }, ); console.log(j2s(res)); }); }); walletCli .subcommand("handleUri", "handle-uri", { help: "Handle a taler:// URI.", }) .maybeArgument("uri", clk.STRING) .maybeOption("withdrawalExchange", ["--withdrawal-exchange"], clk.STRING, { help: "Exchange to use for withdrawal operations.", }) .maybeOption("restrictAge", ["--restrict-age"], clk.INT) .flag("autoYes", ["-y", "--yes"]) .action(async (args) => { await withWallet(args, async (wallet) => { let uri; if (args.handleUri.uri) { uri = args.handleUri.uri; } else { uri = await readlinePrompt("Taler URI: "); } const parsedTalerUri = parseTalerUri(uri); if (!parsedTalerUri) { throw Error("invalid taler URI"); } switch (parsedTalerUri.type) { case TalerUriAction.Pay: await doPay(wallet.client, uri, { alwaysYes: args.handleUri.autoYes, }); break; case TalerUriAction.Refund: await wallet.client.call(WalletApiOperation.StartRefundQueryForUri, { talerRefundUri: uri, }); break; case TalerUriAction.Withdraw: { const withdrawInfo = await wallet.client.call( WalletApiOperation.GetWithdrawalDetailsForUri, { talerWithdrawUri: uri, restrictAge: args.handleUri.restrictAge, }, ); console.log("withdrawInfo", withdrawInfo); const selectedExchange = args.handleUri.withdrawalExchange ?? withdrawInfo.defaultExchangeBaseUrl; if (!selectedExchange) { console.error( "no exchange specified for withdrawal (and no exchange suggested by the bank)", ); processExit(1); return; } const res = await wallet.client.call( WalletApiOperation.AcceptBankIntegratedWithdrawal, { exchangeBaseUrl: selectedExchange, talerWithdrawUri: uri, }, ); console.log("accept withdrawal response", res); break; } case TalerUriAction.DevExperiment: { await wallet.client.call(WalletApiOperation.ApplyDevExperiment, { devExperimentUri: uri, }); break; } default: console.log(`URI type (${parsedTalerUri.type}) not handled`); break; } return; }); }); withdrawCli .subcommand("withdrawManually", "manual", { help: "Withdraw manually from an exchange.", }) .requiredOption("exchange", ["--exchange"], clk.STRING, { help: "Base URL of the exchange.", }) .requiredOption("amount", ["--amount"], clk.AMOUNT, { help: "Amount to withdraw", }) .maybeOption("forcedReservePriv", ["--forced-reserve-priv"], clk.STRING, {}) .maybeOption("restrictAge", ["--restrict-age"], clk.INT) .action(async (args) => { await withWallet(args, async (wallet) => { const exchangeBaseUrl = args.withdrawManually.exchange; const amount = args.withdrawManually.amount; const d = await wallet.client.call( WalletApiOperation.GetWithdrawalDetailsForAmount, { amount: args.withdrawManually.amount, exchangeBaseUrl: exchangeBaseUrl, }, ); const acct = d.withdrawalAccountsList[0]; if (!acct) { console.log("exchange has no accounts"); return; } const resp = await wallet.client.call( WalletApiOperation.AcceptManualWithdrawal, { amount, exchangeBaseUrl, restrictAge: parseInt(String(args.withdrawManually.restrictAge), 10), forceReservePriv: args.withdrawManually.forcedReservePriv, }, ); const reservePub = resp.reservePub; const completePaytoUri = addPaytoQueryParams(acct.paytoUri, { amount: args.withdrawManually.amount, message: `Taler top-up ${reservePub}`, }); console.log("Created reserve", reservePub); console.log("Payto URI", completePaytoUri); }); }); const exchangesCli = walletCli.subcommand("exchangesCmd", "exchanges", { help: "Manage exchanges.", }); exchangesCli .subcommand("exchangesListCmd", "list", { help: "List known exchanges.", }) .action(async (args) => { console.log("Listing exchanges ..."); await withWallet(args, async (wallet) => { const exchanges = await wallet.client.call( WalletApiOperation.ListExchanges, {}, ); console.log(JSON.stringify(exchanges, undefined, 2)); }); }); exchangesCli .subcommand("exchangesUpdateCmd", "update", { help: "Update or add an exchange by base URL.", }) .requiredArgument("url", clk.STRING, { help: "Base URL of the exchange.", }) .flag("force", ["-f", "--force"]) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.UpdateExchangeEntry, { exchangeBaseUrl: args.exchangesUpdateCmd.url, force: args.exchangesUpdateCmd.force, }); }); }); exchangesCli .subcommand("exchangesShowCmd", "show", { help: "Show exchange details", }) .requiredArgument("url", clk.STRING, { help: "Base URL of the exchange.", }) .action(async (args) => { await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.GetExchangeDetailedInfo, { exchangeBaseUrl: args.exchangesShowCmd.url, }, ); console.log(JSON.stringify(resp, undefined, 2)); }); }); exchangesCli .subcommand("exchangesAddCmd", "add", { help: "Add an exchange by base URL.", }) .requiredArgument("url", clk.STRING, { help: "Base URL of the exchange.", }) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.AddExchange, { exchangeBaseUrl: args.exchangesAddCmd.url, }); }); }); exchangesCli .subcommand("exchangesAddCmd", "delete", { help: "Delete an exchange by base URL.", }) .requiredArgument("url", clk.STRING, { help: "Base URL of the exchange.", }) .flag("purge", ["--purge"]) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.DeleteExchange, { exchangeBaseUrl: args.exchangesAddCmd.url, purge: args.exchangesAddCmd.purge, }); }); }); exchangesCli .subcommand("exchangesAcceptTosCmd", "accept-tos", { help: "Accept terms of service.", }) .requiredArgument("url", clk.STRING, { help: "Base URL of the exchange.", }) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.SetExchangeTosAccepted, { exchangeBaseUrl: args.exchangesAcceptTosCmd.url, }); }); }); exchangesCli .subcommand("exchangesTosCmd", "tos", { help: "Show/request terms of service.", }) .requiredArgument("url", clk.STRING, { help: "Base URL of the exchange.", }) .maybeOption("contentTypes", ["--content-type"], clk.STRING) .action(async (args) => { let acceptedFormat: string[] | undefined = undefined; if (args.exchangesTosCmd.contentTypes) { const split = args.exchangesTosCmd.contentTypes .split(",") .map((x) => x.trim()); acceptedFormat = split; } await withWallet(args, async (wallet) => { const tosResult = await wallet.client.call( WalletApiOperation.GetExchangeTos, { exchangeBaseUrl: args.exchangesTosCmd.url, acceptedFormat, }, ); console.log(JSON.stringify(tosResult, undefined, 2)); }); }); const backupCli = walletCli.subcommand("backupArgs", "backup", { help: "Subcommands for backups", }); backupCli.subcommand("exportDb", "export-db").action(async (args) => { await withWallet(args, async (wallet) => { const backup = await wallet.client.call(WalletApiOperation.ExportDb, {}); console.log(JSON.stringify(backup, undefined, 2)); }); }); backupCli.subcommand("storeBackup", "store").action(async (args) => { await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.CreateStoredBackup, {}, ); console.log(JSON.stringify(resp, undefined, 2)); }); }); backupCli.subcommand("storeBackup", "list-stored").action(async (args) => { await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.ListStoredBackups, {}, ); console.log(JSON.stringify(resp, undefined, 2)); }); }); backupCli .subcommand("storeBackup", "delete-stored") .requiredArgument("name", clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.DeleteStoredBackup, { name: args.storeBackup.name, }, ); console.log(JSON.stringify(resp, undefined, 2)); }); }); backupCli .subcommand("recoverBackup", "recover-stored") .requiredArgument("name", clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.RecoverStoredBackup, { name: args.recoverBackup.name, }, ); console.log(JSON.stringify(resp, undefined, 2)); }); }); backupCli.subcommand("importDb", "import-db").action(async (args) => { await withWallet(args, async (wallet) => { const dumpRaw = await read(process.stdin); const dump = JSON.parse(dumpRaw); await wallet.client.call(WalletApiOperation.ImportDb, { dump, }); }); }); const depositCli = walletCli.subcommand("depositArgs", "deposit", { help: "Subcommands for depositing money to payto:// accounts", }); depositCli .subcommand("createDepositArgs", "create") .requiredArgument("amount", clk.AMOUNT) .requiredArgument("targetPayto", clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.CreateDepositGroup, { amount: args.createDepositArgs.amount, depositPaytoUri: args.createDepositArgs.targetPayto, }, ); console.log(`Created deposit ${resp.depositGroupId}`); }); }); const peerCli = walletCli.subcommand("peerArgs", "p2p", { help: "Subcommands for peer-to-peer payments.", }); peerCli .subcommand("checkPayPush", "check-push-debit", { help: "Check fees for starting a peer-push debit transaction.", }) .requiredArgument("amount", clk.AMOUNT, { help: "Amount to pay", }) .action(async (args) => { await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.CheckPeerPushDebit, { amount: args.checkPayPush.amount, }, ); console.log(JSON.stringify(resp, undefined, 2)); }); }); peerCli .subcommand("checkPayPull", "check-pull-credit", { help: "Check fees for a starting peer-pull credit transaction.", }) .requiredArgument("amount", clk.AMOUNT, { help: "Amount to request", }) .action(async (args) => { await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.CheckPeerPullCredit, { amount: args.checkPayPull.amount, }, ); console.log(JSON.stringify(resp, undefined, 2)); }); }); peerCli .subcommand("prepareIncomingPayPull", "prepare-pull-debit") .requiredArgument("talerUri", clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.PreparePeerPullDebit, { talerUri: args.prepareIncomingPayPull.talerUri, }, ); console.log(JSON.stringify(resp, undefined, 2)); }); }); peerCli .subcommand("confirmIncomingPayPull", "confirm-pull-debit") .requiredArgument("transactionId", clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.ConfirmPeerPullDebit, { transactionId: args.confirmIncomingPayPull .transactionId as TransactionIdStr, }, ); console.log(JSON.stringify(resp, undefined, 2)); }); }); peerCli .subcommand("confirmIncomingPayPush", "confirm-push-credit") .requiredArgument("transactionId", clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.ConfirmPeerPushCredit, { transactionId: args.confirmIncomingPayPush.transactionId, }, ); console.log(JSON.stringify(resp, undefined, 2)); }); }); peerCli .subcommand("initiatePayPull", "initiate-pull-credit", { help: "Initiate a peer-pull payment.", }) .requiredArgument("amount", clk.AMOUNT, { help: "Amount to request", }) .maybeOption("summary", ["--summary"], clk.STRING, { help: "Summary to use in the contract terms.", }) .maybeOption("purseExpiration", ["--purse-expiration"], clk.STRING) .maybeOption("exchangeBaseUrl", ["--exchange"], clk.STRING) .action(async (args) => { let purseExpiration: AbsoluteTime; if (args.initiatePayPull.purseExpiration) { purseExpiration = AbsoluteTime.addDuration( AbsoluteTime.now(), Duration.fromPrettyString(args.initiatePayPull.purseExpiration), ); } else { purseExpiration = AbsoluteTime.addDuration( AbsoluteTime.now(), Duration.fromSpec({ hours: 1 }), ); } await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.InitiatePeerPullCredit, { exchangeBaseUrl: args.initiatePayPull.exchangeBaseUrl, partialContractTerms: { amount: args.initiatePayPull.amount, summary: args.initiatePayPull.summary ?? "Invoice", purse_expiration: AbsoluteTime.toProtocolTimestamp(purseExpiration), }, }, ); console.log(JSON.stringify(resp, undefined, 2)); }); }); peerCli .subcommand("preparePushCredit", "prepare-push-credit") .requiredArgument("talerUri", clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.PreparePeerPushCredit, { talerUri: args.preparePushCredit.talerUri, }, ); console.log(JSON.stringify(resp, undefined, 2)); }); }); peerCli .subcommand("payPush", "initiate-push-debit", { help: "Initiate a peer-push payment.", }) .requiredArgument("amount", clk.AMOUNT, { help: "Amount to pay", }) .maybeOption("summary", ["--summary"], clk.STRING, { help: "Summary to use in the contract terms.", }) .maybeOption("purseExpiration", ["--purse-expiration"], clk.STRING) .action(async (args) => { let purseExpiration: AbsoluteTime; if (args.payPush.purseExpiration) { purseExpiration = AbsoluteTime.addDuration( AbsoluteTime.now(), Duration.fromPrettyString(args.payPush.purseExpiration), ); } else { purseExpiration = AbsoluteTime.addDuration( AbsoluteTime.now(), Duration.fromSpec({ hours: 1 }), ); } await withWallet(args, async (wallet) => { const resp = await wallet.client.call( WalletApiOperation.InitiatePeerPushDebit, { partialContractTerms: { amount: args.payPush.amount, summary: args.payPush.summary ?? "Payment", purse_expiration: AbsoluteTime.toProtocolTimestamp(purseExpiration), }, }, ); console.log(JSON.stringify(resp, undefined, 2)); }); }); const advancedCli = walletCli.subcommand("advancedArgs", "advanced", { help: "Subcommands for advanced operations (only use if you know what you're doing!).", }); advancedCli .subcommand("genReserve", "gen-reserve", { help: "Generate a reserve key pair (not stored in the DB).", }) .action(async (args) => { const pair = await nativeCrypto.createEddsaKeypair({}); console.log( j2s({ reservePub: pair.pub, reservePriv: pair.priv, }), ); }); advancedCli .subcommand("tasks", "tasks", { help: "Show active wallet-core tasks.", }) .action(async (args) => { await withWallet(args, async (wallet) => { const tasks = await wallet.client.call( WalletApiOperation.GetActiveTasks, {}, ); console.log(j2s(tasks)); }); }); advancedCli .subcommand("sampleTransactions", "sample-transactions", { help: "Print sample wallet-core transactions", }) .action(async (args) => { console.log(JSON.stringify(sampleWalletCoreTransactions, undefined, 2)); }); advancedCli .subcommand("serve", "serve", { help: "Serve the wallet API via a unix domain socket.", }) .requiredOption("unixPath", ["--unix-path"], clk.STRING, { default: defaultWalletCoreSocket, }) .flag("noInit", ["--no-init"], { help: "Do not initialize the wallet. The client must send the initWallet message.", }) .action(async (args) => { const socketPath = args.serve.unixPath; logger.info(`serving at ${socketPath}`); let cleanupCalled = false; const cleanupSocket = (signal: string, code: number) => { if (cleanupCalled) { return; } cleanupCalled = true; try { logger.info("cleaning up socket"); fs.unlinkSync(socketPath); } catch (e) { logger.warn(`unable to clean up socket: ${e}`); } process.exit(128 + code); }; process.on("SIGTERM", cleanupSocket); process.on("SIGINT", cleanupSocket); const onNotif = (notif: WalletNotification) => { writeObservabilityLog(notif); }; const wh = await createLocalWallet(args, onNotif, args.serve.noInit); const w = wh.wallet; let nextClientId = 1; const notifyHandlers = new Map void>(); w.addNotificationListener((n) => { notifyHandlers.forEach((v, k) => { v(n); }); }); await runRpcServer({ socketFilename: args.serve.unixPath, onConnect(client) { logger.info("connected"); const clientId = nextClientId++; notifyHandlers.set(clientId, (n: WalletNotification) => { client.sendResponse({ type: "notification", payload: n as unknown as JsonMessage, }); }); return { onDisconnect() { notifyHandlers.delete(clientId); logger.info("disconnected"); }, onMessage(msg) { logger.info(`message: ${j2s(msg)}`); const op = (msg as any).operation; const id = (msg as any).id; const payload = (msg as any).args; w.handleCoreApiRequest(op, id, payload) .then((resp) => { logger.info("sending response"); client.sendResponse(resp as unknown as JsonMessage); }) .catch((e) => { logger.error(`unexpected error: ${e}`); }); }, }; }, }); }); advancedCli .subcommand("init", "init", { help: "Initialize the wallet (with DB) and exit.", }) .action(async (args) => { await withWallet(args, async () => {}); }); advancedCli .subcommand("runPendingOpt", "run-pending", { help: "Run pending operations.", }) .action(async (args) => { logger.error( "Subcommand run-pending not supported anymore. Please use run-until-done or the client/server wallet.", ); }); advancedCli .subcommand("pending", "pending", { help: "Show pending operations." }) .action(async (args) => { await withWallet(args, async (wallet) => { const pending = await wallet.client.call( WalletApiOperation.GetPendingOperations, {}, ); console.log(JSON.stringify(pending, undefined, 2)); }); }); advancedCli .subcommand("benchInternal", "bench-internal", { help: "Run the 'bench-internal' benchmark", }) .action(async (args) => { const myHttpLib = createPlatformHttpLib(); const res = await createNativeWalletHost2({ // No persistent DB storage. persistentStoragePath: undefined, httpLib: myHttpLib, }); const wallet = res.wallet; await wallet.client.call(WalletApiOperation.InitWallet, {}); await wallet.client.call(WalletApiOperation.RunIntegrationTest, { amountToSpend: "TESTKUDOS:1" as AmountString, amountToWithdraw: "TESTKUDOS:3" as AmountString, corebankApiBaseUrl: "http://localhost:8082/taler-bank-access/", exchangeBaseUrl: "http://localhost:8081/", merchantBaseUrl: "http://localhost:8083/", }); await wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {}); await wallet.client.call(WalletApiOperation.Shutdown, {}); }); advancedCli .subcommand("genSegwit", "gen-segwit") .requiredArgument("paytoUri", clk.STRING) .requiredArgument("reservePub", clk.STRING) .action(async (args) => { const p = parsePaytoUri(args.genSegwit.paytoUri); console.log(p); }); const currenciesCli = walletCli.subcommand("currencies", "currencies", { help: "Manage currencies.", }); currenciesCli .subcommand("listGlobalAuditors", "list-global-auditors", { help: "List global-currency auditors.", }) .action(async (args) => { await withWallet(args, async (wallet) => { const currencies = await wallet.client.call( WalletApiOperation.ListGlobalCurrencyAuditors, {}, ); console.log(JSON.stringify(currencies, undefined, 2)); }); }); currenciesCli .subcommand("listGlobalExchanges", "list-global-exchanges", { help: "List global-currency exchanges.", }) .action(async (args) => { await withWallet(args, async (wallet) => { const currencies = await wallet.client.call( WalletApiOperation.ListGlobalCurrencyExchanges, {}, ); console.log(JSON.stringify(currencies, undefined, 2)); }); }); currenciesCli .subcommand("addGlobalExchange", "add-global-exchange", { help: "Add a global-currency exchange.", }) .requiredOption("currency", ["--currency"], clk.STRING) .requiredOption("exchangeBaseUrl", ["--url"], clk.STRING) .requiredOption("exchangePub", ["--pub"], clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { const currencies = await wallet.client.call( WalletApiOperation.AddGlobalCurrencyExchange, { currency: args.addGlobalExchange.currency, exchangeBaseUrl: args.addGlobalExchange.exchangeBaseUrl, exchangeMasterPub: args.addGlobalExchange.exchangePub, }, ); console.log(JSON.stringify(currencies, undefined, 2)); }); }); currenciesCli .subcommand("removeGlobalExchange", "remove-global-exchange", { help: "Remove a global-currency exchange.", }) .requiredOption("currency", ["--currency"], clk.STRING) .requiredOption("exchangeBaseUrl", ["--url"], clk.STRING) .requiredOption("exchangePub", ["--pub"], clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { const currencies = await wallet.client.call( WalletApiOperation.RemoveGlobalCurrencyExchange, { currency: args.removeGlobalExchange.currency, exchangeBaseUrl: args.removeGlobalExchange.exchangeBaseUrl, exchangeMasterPub: args.removeGlobalExchange.exchangePub, }, ); console.log(JSON.stringify(currencies, undefined, 2)); }); }); currenciesCli .subcommand("addGlobalAuditor", "add-global-auditor", { help: "Add a global-currency auditor.", }) .requiredOption("currency", ["--currency"], clk.STRING) .requiredOption("auditorBaseUrl", ["--url"], clk.STRING) .requiredOption("auditorPub", ["--pub"], clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { const currencies = await wallet.client.call( WalletApiOperation.AddGlobalCurrencyAuditor, { currency: args.addGlobalAuditor.currency, auditorBaseUrl: args.addGlobalAuditor.auditorBaseUrl, auditorPub: args.addGlobalAuditor.auditorPub, }, ); console.log(JSON.stringify(currencies, undefined, 2)); }); }); currenciesCli .subcommand("removeGlobalAuditor", "remove-global-auditor", { help: "Remove a global-currency auditor.", }) .requiredOption("currency", ["--currency"], clk.STRING) .requiredOption("auditorBaseUrl", ["--url"], clk.STRING) .requiredOption("auditorPub", ["--pub"], clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { const currencies = await wallet.client.call( WalletApiOperation.RemoveGlobalCurrencyAuditor, { currency: args.removeGlobalAuditor.currency, auditorBaseUrl: args.removeGlobalAuditor.auditorBaseUrl, auditorPub: args.removeGlobalAuditor.auditorPub, }, ); console.log(JSON.stringify(currencies, undefined, 2)); }); }); advancedCli .subcommand("clearDatabase", "clear-database", { help: "Clear the database, irrevocable deleting all data in the wallet.", }) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.ClearDb, {}); }); }); advancedCli .subcommand("recycle", "recycle", { help: "Export, clear and re-import the database via the backup mechanism.", }) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.Recycle, {}); }); }); advancedCli .subcommand("payPrepare", "pay-prepare", { help: "Claim an order but don't pay yet.", }) .requiredArgument("url", clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { const res = await wallet.client.call( WalletApiOperation.PreparePayForUri, { talerPayUri: args.payPrepare.url, }, ); switch (res.status) { case PreparePayResultType.InsufficientBalance: console.log("insufficient balance"); break; case PreparePayResultType.AlreadyConfirmed: if (res.paid) { console.log("already paid!"); } else { console.log("payment in progress"); } break; case PreparePayResultType.PaymentPossible: console.log("payment possible"); break; default: assertUnreachable(res); } }); }); advancedCli .subcommand("queryRefund", "query-refund", { help: "Query refunds for a payment transaction.", }) .requiredArgument("transactionId", clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.StartRefundQuery, { transactionId: args.queryRefund.transactionId as TransactionIdStr, }); }); }); advancedCli .subcommand("payConfirm", "pay-confirm", { help: "Confirm payment proposed by a merchant.", }) .requiredArgument("proposalId", clk.STRING) .maybeOption("sessionIdOverride", ["--session-id"], clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.ConfirmPay, { proposalId: args.payConfirm.proposalId, sessionId: args.payConfirm.sessionIdOverride, }); }); }); advancedCli .subcommand("refresh", "force-refresh", { help: "Force a refresh on a coin.", }) .requiredArgument("coinPub", clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.ForceRefresh, { refreshCoinSpecs: [ { coinPub: args.refresh.coinPub, }, ], }); }); }); advancedCli .subcommand("dumpCoins", "dump-coins", { help: "Dump coins in an easy-to-process format.", }) .action(async (args) => { await withWallet(args, async (wallet) => { const coinDump = await wallet.client.call( WalletApiOperation.DumpCoins, {}, ); console.log(JSON.stringify(coinDump, undefined, 2)); }); }); const coinPubListCodec = codecForList(codecForString()); advancedCli .subcommand("suspendCoins", "suspend-coins", { help: "Mark a coin as suspended, will not be used for payments.", }) .requiredArgument("coinPubSpec", clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { let coinPubList: string[]; try { coinPubList = coinPubListCodec.decode( JSON.parse(args.suspendCoins.coinPubSpec), ); } catch (e: any) { console.log("could not parse coin list:", e.message); processExit(1); } for (const c of coinPubList) { await wallet.client.call(WalletApiOperation.SetCoinSuspended, { coinPub: c, suspended: true, }); } }); }); advancedCli .subcommand("unsuspendCoins", "unsuspend-coins", { help: "Mark a coin as suspended, will not be used for payments.", }) .requiredArgument("coinPubSpec", clk.STRING) .action(async (args) => { await withWallet(args, async (wallet) => { let coinPubList: string[]; try { coinPubList = coinPubListCodec.decode( JSON.parse(args.unsuspendCoins.coinPubSpec), ); } catch (e: any) { console.log("could not parse coin list:", e.message); processExit(1); } for (const c of coinPubList) { await wallet.client.call(WalletApiOperation.SetCoinSuspended, { coinPub: c, suspended: false, }); } }); }); advancedCli .subcommand("coins", "list-coins", { help: "List coins.", }) .action(async (args) => { await withWallet(args, async (wallet) => { const coins = await wallet.client.call(WalletApiOperation.DumpCoins, {}); for (const coin of coins.coins) { console.log(`coin ${coin.coin_pub}`); console.log(` exchange ${coin.exchange_base_url}`); console.log(` denomPubHash ${coin.denom_pub_hash}`); console.log(` status ${coin.coin_status}`); } }); }); const testCli = walletCli.subcommand("testingArgs", "testing", { help: "Subcommands for testing.", }); testCli .subcommand("withdrawTestkudos", "withdraw-testkudos") .action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.WithdrawTestkudos, {}); }); }); testCli.subcommand("withdrawKudos", "withdraw-kudos").action(async (args) => { await withWallet(args, async (wallet) => { await wallet.client.call(WalletApiOperation.WithdrawTestBalance, { amount: "KUDOS:50" as AmountString, corebankApiBaseUrl: "https://bank.demo.taler.net/", exchangeBaseUrl: "https://exchange.demo.taler.net/", }); }); }); class PerfTimer { tStarted: bigint | undefined; tSum = BigInt(0); tSumSq = BigInt(0); start() { this.tStarted = process.hrtime.bigint(); } stop() { const now = process.hrtime.bigint(); const s = this.tStarted; if (s == null) { throw Error(); } this.tSum = this.tSum + (now - s); this.tSumSq = this.tSumSq + (now - s) * (now - s); } mean(nRuns: number): number { return Number(this.tSum / BigInt(nRuns)); } stdev(nRuns: number) { const m = this.tSum / BigInt(nRuns); const x = this.tSumSq / BigInt(nRuns) - m * m; return Math.floor(Math.sqrt(Number(x))); } } testCli .subcommand("benchmarkAgeRestrictions", "benchmark-age-restrictions") .requiredOption("reps", ["--reps"], clk.INT, { default: 100, help: "repetitions (default: 100)", }) .action(async (args) => { const numReps = args.benchmarkAgeRestrictions.reps ?? 100; let tCommit = new PerfTimer(); let tAttest = new PerfTimer(); let tVerify = new PerfTimer(); let tDerive = new PerfTimer(); let tCompare = new PerfTimer(); console.log("starting benchmark"); for (let i = 0; i < numReps; i++) { console.log(`doing iteration ${i}`); tCommit.start(); const commitProof = await AgeRestriction.restrictionCommit( 0b1000001010101010101001, 21, ); tCommit.stop(); tAttest.start(); const attest = AgeRestriction.commitmentAttest(commitProof, 18); tAttest.stop(); tVerify.start(); const attestRes = AgeRestriction.commitmentVerify( commitProof.commitment, encodeCrock(attest), 18, ); tVerify.stop(); if (!attestRes) { throw Error(); } const salt = getRandomBytes(32); tDerive.start(); const deriv = await AgeRestriction.commitmentDerive(commitProof, salt); tDerive.stop(); tCompare.start(); const res2 = await AgeRestriction.commitCompare( deriv.commitment, commitProof.commitment, salt, ); tCompare.stop(); if (!res2) { throw Error(); } } console.log( `edx25519-commit (ns): ${tCommit.mean(numReps)} (stdev ${tCommit.stdev( numReps, )})`, ); console.log( `edx25519-attest (ns): ${tAttest.mean(numReps)} (stdev ${tAttest.stdev( numReps, )})`, ); console.log( `edx25519-verify (ns): ${tVerify.mean(numReps)} (stdev ${tVerify.stdev( numReps, )})`, ); console.log( `edx25519-derive (ns): ${tDerive.mean(numReps)} (stdev ${tDerive.stdev( numReps, )})`, ); console.log( `edx25519-compare (ns): ${tCompare.mean(numReps)} (stdev ${tCompare.stdev( numReps, )})`, ); }); testCli.subcommand("logtest", "logtest").action(async (args) => { logger.trace("This is a trace message."); logger.info("This is an info message."); logger.warn("This is an warning message."); logger.error("This is an error message."); }); async function read(stream: NodeJS.ReadStream) { const chunks = []; for await (const chunk of stream) chunks.push(chunk); return Buffer.concat(chunks).toString("utf8"); } export function main() { const maybeFilename = getenv("TALER_WALLET_DEBUG_OBSERVE"); if (!!maybeFilename) { observabilityEventFile = maybeFilename; } walletCli.run(); }