From d9297f3dfddd5c7b072b46dee984251e3202ad75 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 19 Nov 2019 16:16:12 +0100 Subject: work on CLI --- package.json | 2 +- src/dbTypes.ts | 32 +++ src/headless/clk.ts | 546 +++++++++++++++++++++++++++++++++++++++ src/headless/taler-wallet-cli.ts | 430 +++++++++++++++++------------- src/wallet.ts | 118 +++++---- src/walletTypes.ts | 27 ++ tsconfig.json | 1 + yarn.lock | 8 +- 8 files changed, 928 insertions(+), 236 deletions(-) create mode 100644 src/headless/clk.ts diff --git a/package.json b/package.json index 94a70a020..8e7a5b355 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "through2": "3.0.1", "tslint": "^5.19.0", "typedoc": "^0.15.0", - "typescript": "^3.6.2", + "typescript": "^3.7.2", "uglify-js": "^3.0.27", "vinyl": "^2.2.0", "vinyl-fs": "^3.0.3", diff --git a/src/dbTypes.ts b/src/dbTypes.ts index ef79ae193..28893b8eb 100644 --- a/src/dbTypes.ts +++ b/src/dbTypes.ts @@ -896,6 +896,31 @@ export interface CoinsReturnRecord { wire: any; } + +export interface WithdrawalRecord { + /** + * Reserve that we're withdrawing from. + */ + reservePub: string; + + /** + * When was the withdrawal operation started started? + * Timestamp in milliseconds. + */ + startTimestamp: number; + + /** + * When was the withdrawal operation completed? + */ + finishTimestamp?: number; + + /** + * Amount that is being withdrawn with this operation. + * This does not include fees. + */ + withdrawalAmount: string; +} + /* tslint:disable:completed-docs */ /** @@ -1056,6 +1081,12 @@ export namespace Stores { } } + class WithdrawalsStore extends Store { + constructor() { + super("withdrawals", { keyPath: "id", autoIncrement: true }) + } + } + export const coins = new CoinsStore(); export const coinsReturns = new Store("coinsReturns", { keyPath: "contractTermsHash", @@ -1077,6 +1108,7 @@ export namespace Stores { export const purchases = new PurchasesStore(); export const tips = new TipsStore(); export const senderWires = new SenderWiresStore(); + export const withdrawals = new WithdrawalsStore(); } /* tslint:enable:completed-docs */ diff --git a/src/headless/clk.ts b/src/headless/clk.ts new file mode 100644 index 000000000..642a1bef3 --- /dev/null +++ b/src/headless/clk.ts @@ -0,0 +1,546 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + 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 process = require("process"); +import path = require("path"); +import readline = require("readline"); +import { symlinkSync } from "fs"; + +class Converter {} + +export let INT = new Converter(); +export let STRING: Converter = new Converter(); + +export interface OptionArgs { + help?: string; + default?: T; +} + +export interface ArgumentArgs { + metavar?: string; + help?: string; + default?: T; +} + +export interface SubcommandArgs { + help?: string; +} + +export interface FlagArgs { + help?: string; +} + +export interface ProgramArgs { + help?: string; +} + +interface ArgumentDef { + name: string; + conv: Converter; + args: ArgumentArgs; +} + +interface SubcommandDef { + commandGroup: CommandGroup; + name: string; + args: SubcommandArgs; +} + +type ActionFn = (x: TG) => void; + +type SubRecord = { + [Y in S]: { [X in N]: V }; +}; + +interface OptionDef { + name: string; + flagspec: string[]; + /** + * Converter, only present for options, not for flags. + */ + conv?: Converter; + args: OptionArgs; + isFlag: boolean; + required: boolean; +} + +function splitOpt(opt: string): { key: string; value?: string } { + const idx = opt.indexOf("="); + if (idx == -1) { + return { key: opt }; + } + return { key: opt.substring(0, idx), value: opt.substring(idx + 1) }; +} + +function formatListing(key: string, value?: string): string { + let res = " " + key; + if (!value) { + return res; + } + if (res.length >= 25) { + return res + "\n" + " " + value; + } else { + return res.padEnd(24) + " " + value; + } +} + +export class CommandGroup { + private shortOptions: { [name: string]: OptionDef } = {}; + private longOptions: { [name: string]: OptionDef } = {}; + private subcommandMap: { [name: string]: SubcommandDef } = {}; + private subcommands: SubcommandDef[] = []; + private options: OptionDef[] = []; + private arguments: ArgumentDef[] = []; + + private myAction?: ActionFn; + + constructor( + private argKey: string, + private name: string | null, + private scArgs: SubcommandArgs, + ) {} + + action(f: ActionFn) { + if (this.myAction) { + throw Error("only one action supported per command"); + } + this.myAction = f; + } + + requiredOption( + name: N, + flagspec: string[], + conv: Converter, + args: OptionArgs = {}, + ): CommandGroup> { + const def: OptionDef = { + args: args, + conv: conv, + flagspec: flagspec, + isFlag: false, + required: true, + name: name as string, + }; + this.options.push(def); + for (let flag of flagspec) { + if (flag.startsWith("--")) { + const flagname = flag.substring(2); + this.longOptions[flagname] = def; + } else if (flag.startsWith("-")) { + const flagname = flag.substring(1); + this.shortOptions[flagname] = def; + } else { + throw Error("option must start with '-' or '--'"); + } + } + return this as any; + } + + maybeOption( + name: N, + flagspec: string[], + conv: Converter, + args: OptionArgs = {}, + ): CommandGroup> { + const def: OptionDef = { + args: args, + conv: conv, + flagspec: flagspec, + isFlag: false, + required: false, + name: name as string, + }; + this.options.push(def); + for (let flag of flagspec) { + if (flag.startsWith("--")) { + const flagname = flag.substring(2); + this.longOptions[flagname] = def; + } else if (flag.startsWith("-")) { + const flagname = flag.substring(1); + this.shortOptions[flagname] = def; + } else { + throw Error("option must start with '-' or '--'"); + } + } + return this as any; + } + + argument( + name: N, + conv: Converter, + args: ArgumentArgs = {}, + ): CommandGroup> { + const argDef: ArgumentDef = { + args: args, + conv: conv, + name: name as string, + }; + this.arguments.push(argDef); + return this as any; + } + + flag( + name: N, + flagspec: string[], + args: OptionArgs = {}, + ): CommandGroup> { + const def: OptionDef = { + args: args, + flagspec: flagspec, + isFlag: true, + required: false, + name: name as string, + }; + this.options.push(def); + for (let flag of flagspec) { + if (flag.startsWith("--")) { + const flagname = flag.substring(2); + this.longOptions[flagname] = def; + } else if (flag.startsWith("-")) { + const flagname = flag.substring(1); + this.shortOptions[flagname] = def; + } else { + throw Error("option must start with '-' or '--'"); + } + } + return this as any; + } + + subcommand( + argKey: GN, + name: string, + args: SubcommandArgs = {}, + ): CommandGroup { + const cg = new CommandGroup(argKey as string, name, args); + const def: SubcommandDef = { + commandGroup: cg, + name: name as string, + args: args, + }; + cg.flag("help", ["-h", "--help"], { + help: "Show this message and exit.", + }); + this.subcommandMap[name as string] = def; + this.subcommands.push(def); + this.subcommands = this.subcommands.sort((x1, x2) => { + const a = x1.name; + const b = x2.name; + if (a === b) { + return 0; + } else if (a < b) { + return -1; + } else { + return 1; + } + }); + return cg as any; + } + + printHelp(progName: string, parents: CommandGroup[]) { + const chain: CommandGroup[] = Array.prototype.concat(parents, [ + this, + ]); + let usageSpec = ""; + for (let p of parents) { + usageSpec += (p.name ?? progName) + " "; + if (p.arguments.length >= 1) { + usageSpec += " "; + } + } + usageSpec += (this.name ?? progName) + " "; + if (this.subcommands.length != 0) { + usageSpec += "COMMAND "; + } + for (let a of this.arguments) { + const argName = a.args.metavar ?? a.name; + usageSpec += `<${argName}> `; + } + usageSpec = usageSpec.trimRight(); + console.log(`Usage: ${usageSpec}`); + if (this.scArgs.help) { + console.log(); + console.log(this.scArgs.help); + } + if (this.options.length != 0) { + console.log(); + console.log("Options:"); + for (let opt of this.options) { + let optSpec = opt.flagspec.join(", "); + if (!opt.isFlag) { + optSpec = optSpec + "=VALUE"; + } + console.log(formatListing(optSpec, opt.args.help)); + } + } + + if (this.subcommands.length != 0) { + console.log(); + console.log("Commands:"); + for (let subcmd of this.subcommands) { + console.log(formatListing(subcmd.name, subcmd.args.help)); + } + } + } + + /** + * Run the (sub-)command with the given command line parameters. + */ + run( + progname: string, + parents: CommandGroup[], + unparsedArgs: string[], + parsedArgs: any, + ) { + let posArgIndex = 0; + let argsTerminated = false; + let i; + let foundSubcommand: CommandGroup | undefined = undefined; + const myArgs: any = (parsedArgs[this.argKey] = {}); + const foundOptions: { [name: string]: boolean } = {}; + for (i = 0; i < unparsedArgs.length; i++) { + const argVal = unparsedArgs[i]; + if (argsTerminated == false) { + if (argVal === "--") { + argsTerminated = true; + continue; + } + if (argVal.startsWith("--")) { + const opt = argVal.substring(2); + const r = splitOpt(opt); + const d = this.longOptions[r.key]; + if (!d) { + const n = this.name ?? progname; + console.error(`error: unknown option '--${r.key}' for ${n}`); + process.exit(-1); + throw Error("not reached"); + } + if (d.isFlag) { + if (r.value !== undefined) { + console.error(`error: flag '--${r.key}' does not take a value`); + process.exit(-1); + throw Error("not reached"); + } + myArgs[d.name] = true; + } else { + if (r.value === undefined) { + if (i === unparsedArgs.length - 1) { + console.error(`error: option '--${r.key}' needs an argument`); + process.exit(-1); + throw Error("not reached"); + } + myArgs[d.name] = unparsedArgs[i+1]; + i++; + } else { + myArgs[d.name] = r.value; + } + foundOptions[d.name] = true; + } + continue; + } + if (argVal.startsWith("-") && argVal != "-") { + const optShort = argVal.substring(1); + for (let si = 0; si < optShort.length; si++) { + const chr = optShort[si]; + const opt = this.shortOptions[chr]; + if (!opt) { + console.error(`error: option '-${chr}' not known`); + process.exit(-1); + } + if (opt.isFlag) { + myArgs[opt.name] = true; + } else { + if (si == optShort.length - 1) { + if (i === unparsedArgs.length - 1) { + console.error(`error: option '-${chr}' needs an argument`); + process.exit(-1); + throw Error("not reached"); + } else { + myArgs[opt.name] = unparsedArgs[i + 1]; + i++; + } + } else { + myArgs[opt.name] = optShort.substring(si + 1); + } + foundOptions[opt.name] = true; + break; + } + } + continue; + } + } + if (this.subcommands.length != 0) { + const subcmd = this.subcommandMap[argVal]; + if (!subcmd) { + console.error(`error: unknown command '${argVal}'`); + process.exit(-1); + throw Error("not reached"); + } + foundSubcommand = subcmd.commandGroup; + break; + } else { + const d = this.arguments[posArgIndex]; + if (!d) { + const n = this.name ?? progname; + console.error(`error: too many arguments for ${n}`); + process.exit(-1); + throw Error("not reached"); + } + posArgIndex++; + } + } + + for (let option of this.options) { + if (option.isFlag == false && option.required == true) { + if (!foundOptions[option.name]) { + if (option.args.default !== undefined) { + parsedArgs[this.argKey] = option.args.default; + } else { + const name = option.flagspec.join(",") + console.error(`error: missing option '${name}'`); + process.exit(-1); + throw Error("not reached"); + } + } + } + } + + if (parsedArgs[this.argKey].help) { + this.printHelp(progname, parents); + process.exit(-1); + throw Error("not reached"); + } + + if (foundSubcommand) { + foundSubcommand.run( + progname, + Array.prototype.concat(parents, [this]), + unparsedArgs.slice(i + 1), + parsedArgs, + ); + } + + if (this.myAction) { + this.myAction(parsedArgs); + } else { + this.printHelp(progname, parents); + process.exit(-1); + throw Error("not reached"); + } + } +} + +export class Program { + private mainCommand: CommandGroup; + + constructor(argKey: string, args: ProgramArgs = {}) { + this.mainCommand = new CommandGroup(argKey, null, { + help: args.help, + }); + this.mainCommand.flag("help", ["-h", "--help"], { + help: "Show this message and exit.", + }); + } + + run() { + const args = process.argv; + if (args.length < 2) { + console.error( + "Error while parsing command line arguments: not enough arguments", + ); + process.exit(-1); + } + const progname = path.basename(args[1]); + const rest = args.slice(2); + + this.mainCommand.run(progname, [], rest, {}); + } + + subcommand( + argKey: GN, + name: string, + args: SubcommandArgs = {}, + ): CommandGroup { + const cmd = this.mainCommand.subcommand(argKey, name as string, args); + return cmd as any; + } + + requiredOption( + name: N, + flagspec: string[], + conv: Converter, + args: OptionArgs = {}, + ): Program> { + this.mainCommand.requiredOption(name, flagspec, conv, args); + return this as any; + } + + maybeOption( + name: N, + flagspec: string[], + conv: Converter, + args: OptionArgs = {}, + ): Program> { + this.mainCommand.maybeOption(name, flagspec, conv, args); + return this as any; + } + + /** + * Add a flag (option without value) to the program. + */ + flag( + name: N, + flagspec: string[], + args: OptionArgs = {}, + ): Program> { + this.mainCommand.flag(name, flagspec, args); + return this as any; + } + + /** + * Add a positional argument to the program. + */ + argument( + name: N, + conv: Converter, + args: ArgumentArgs = {}, + ): Program> { + this.mainCommand.argument(name, conv, args); + return this as any; + } +} + +export function program( + argKey: PN, + args: ProgramArgs = {}, +): Program { + return new Program(argKey as string, args); +} + +export function prompt(question: string): Promise { + const stdinReadline = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve, reject) => { + stdinReadline.question(question, res => { + resolve(res); + stdinReadline.close(); + }); + }); +} diff --git a/src/headless/taler-wallet-cli.ts b/src/headless/taler-wallet-cli.ts index 8c31e67d8..41f68319a 100644 --- a/src/headless/taler-wallet-cli.ts +++ b/src/headless/taler-wallet-cli.ts @@ -14,32 +14,68 @@ TALER; see the file COPYING. If not, see */ -import commander = require("commander"); import os = require("os"); import { getDefaultNodeWallet, withdrawTestBalance } from "./helpers"; import { MerchantBackendConnection } from "./merchant"; import { runIntegrationTest } from "./integrationtest"; import { Wallet } from "../wallet"; -import querystring = require("querystring"); import qrcodeGenerator = require("qrcode-generator"); -import readline = require("readline"); - -const program = new commander.Command(); -program.version("0.0.1").option("--verbose", "enable verbose output", false); +import * as clk from "./clk"; const walletDbPath = os.homedir + "/" + ".talerwalletdb.json"; -function prompt(question: string): Promise { - const stdinReadline = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve, reject) => { - stdinReadline.question(question, res => { - resolve(res); - stdinReadline.close(); - }); - }); +async function doPay( + wallet: Wallet, + payUrl: string, + options: { alwaysYes: boolean } = { alwaysYes: true }, +) { + const result = await wallet.preparePay(payUrl); + if (result.status === "error") { + console.error("Could not pay:", result.error); + process.exit(1); + return; + } + if (result.status === "insufficient-balance") { + console.log("contract", result.contractTerms!); + console.error("insufficient balance"); + process.exit(1); + return; + } + if (result.status === "paid") { + console.log("already paid!"); + process.exit(0); + return; + } + if (result.status === "payment-possible") { + console.log("paying ..."); + } else { + throw Error("not reached"); + } + console.log("contract", result.contractTerms!); + 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) { + const payRes = await wallet.confirmPay(result.proposalId!, undefined); + console.log("paid!"); + } else { + console.log("not paying"); + } } function applyVerbose(verbose: boolean) { @@ -49,31 +85,57 @@ function applyVerbose(verbose: boolean) { } } -program - .command("test-withdraw") - .option( - "-e, --exchange ", - "exchange base URL", - "https://exchange.test.taler.net/", - ) - .option("-a, --amount ", "amount to withdraw", "TESTKUDOS:10") - .option("-b, --bank ", "bank base URL", "https://bank.test.taler.net/") - .description("withdraw test currency from the test bank") - .action(async cmdObj => { - applyVerbose(program.verbose); - console.log("test-withdraw command called"); +const walletCli = clk + .program("wallet", { + help: "Command line interface for the GNU Taler wallet.", + }) + .maybeOption("inhibit", ["--inhibit"], clk.STRING, { + help: + "Inhibit running certain operations, useful for debugging and testing.", + }) + .flag("verbose", ["-V", "--verbose"], { + help: "Enable verbose output.", + }); + +walletCli + .subcommand("testPayCmd", "test-pay", { help: "create contract and pay" }) + .requiredOption("amount", ["-a", "--amount"], clk.STRING) + .requiredOption("summary", ["-s", "--summary"], clk.STRING, { + default: "Test Payment", + }) + .action(async args => { + const cmdArgs = args.testPayCmd; + console.log("creating order"); + const merchantBackend = new MerchantBackendConnection( + "https://backend.test.taler.net/", + "sandbox", + ); + const orderResp = await merchantBackend.createOrder( + cmdArgs.amount, + cmdArgs.summary, + "", + ); + console.log("created new order with order ID", orderResp.orderId); + const checkPayResp = await merchantBackend.checkPayment(orderResp.orderId); + const talerPayUri = checkPayResp.taler_pay_uri; + if (!talerPayUri) { + console.error("fatal: no taler pay URI received from backend"); + process.exit(1); + return; + } + console.log("taler pay URI:", talerPayUri); + const wallet = await getDefaultNodeWallet({ persistentStoragePath: walletDbPath, }); - await withdrawTestBalance(wallet, cmdObj.amount, cmdObj.bank, cmdObj.exchange); - process.exit(0); + + await doPay(wallet, talerPayUri, { alwaysYes: true }); }); -program - .command("balance") - .description("show wallet balance") - .action(async () => { - applyVerbose(program.verbose); +walletCli + .subcommand("", "balance", { help: "Show wallet balance." }) + .action(async args => { + applyVerbose(args.wallet.verbose); console.log("balance command called"); const wallet = await getDefaultNodeWallet({ persistentStoragePath: walletDbPath, @@ -84,12 +146,14 @@ program process.exit(0); }); - -program - .command("history") - .description("show wallet history") - .action(async () => { - applyVerbose(program.verbose); +walletCli + .subcommand("", "history", { help: "Show wallet event history." }) + .requiredOption("from", ["--from"], clk.STRING) + .requiredOption("to", ["--to"], clk.STRING) + .requiredOption("limit", ["--limit"], clk.STRING) + .requiredOption("contEvt", ["--continue-with"], clk.STRING) + .action(async args => { + applyVerbose(args.wallet.verbose); console.log("history command called"); const wallet = await getDefaultNodeWallet({ persistentStoragePath: walletDbPath, @@ -100,26 +164,45 @@ program process.exit(0); }); +walletCli + .subcommand("", "pending", { help: "Show pending operations." }) + .action(async args => { + applyVerbose(args.wallet.verbose); + console.log("history command called"); + const wallet = await getDefaultNodeWallet({ + persistentStoragePath: walletDbPath, + }); + console.log("got wallet"); + const pending = await wallet.getPendingOperations(); + console.log(JSON.stringify(pending, undefined, 2)); + process.exit(0); + }); + async function asyncSleep(milliSeconds: number): Promise { return new Promise((resolve, reject) => { setTimeout(() => resolve(), milliSeconds); }); } -program - .command("test-merchant-qrcode") - .option("-a, --amount ", "amount to spend", "TESTKUDOS:1") - .option("-s, --summary ", "contract summary", "Test Payment") - .action(async cmdObj => { - applyVerbose(program.verbose); +walletCli + .subcommand("testMerchantQrcodeCmd", "test-merchant-qrcode") + .requiredOption("amount", ["-a", "--amount"], clk.STRING, { + default: "TESTKUDOS:1", + }) + .requiredOption("summary", ["-s", "--summary"], clk.STRING, { + default: "Test Payment", + }) + .action(async args => { + const cmdArgs = args.testMerchantQrcodeCmd; + applyVerbose(args.wallet.verbose); console.log("creating order"); const merchantBackend = new MerchantBackendConnection( "https://backend.test.taler.net/", "sandbox", ); const orderResp = await merchantBackend.createOrder( - cmdObj.amount, - cmdObj.summary, + cmdArgs.amount, + cmdArgs.summary, "", ); console.log("created new order with order ID", orderResp.orderId); @@ -148,10 +231,59 @@ program } }); -program - .command("withdraw-uri ") - .action(async (withdrawUrl, cmdObj) => { - applyVerbose(program.verbose); +walletCli + .subcommand("integrationtestCmd", "integrationtest", { + help: "Run integration test with bank, exchange and merchant.", + }) + .requiredOption("exchange", ["-e", "--exchange"], clk.STRING, { + default: "https://exchange.test.taler.net/", + }) + .requiredOption("merchant", ["-m", "--merchant"], clk.STRING, { + default: "https://backend.test.taler.net/", + }) + .requiredOption("merchantApiKey", ["-k", "--merchant-api-key"], clk.STRING, { + default: "sandbox", + }) + .requiredOption("bank", ["-b", "--bank"], clk.STRING, { + default: "https://bank.test.taler.net/", + }) + .requiredOption("withdrawAmount", ["-b", "--bank"], clk.STRING, { + default: "TESTKUDOS:10", + }) + .requiredOption("spendAmount", ["-s", "--spend-amount"], clk.STRING, { + default: "TESTKUDOS:4", + }) + .action(async args => { + applyVerbose(args.wallet.verbose); + let cmdObj = args.integrationtestCmd; + + try { + await runIntegrationTest({ + amountToSpend: cmdObj.spendAmount, + amountToWithdraw: cmdObj.withdrawAmount, + bankBaseUrl: cmdObj.bank, + exchangeBaseUrl: cmdObj.exchange, + merchantApiKey: cmdObj.merchantApiKey, + merchantBaseUrl: cmdObj.merchant, + }).catch(err => { + console.error("Failed with exception:"); + console.error(err); + }); + + process.exit(0); + } catch (e) { + console.error(e); + process.exit(1); + } + }); + +walletCli + .subcommand("withdrawUriCmd", "withdraw-uri") + .argument("withdrawUri", clk.STRING) + .action(async args => { + applyVerbose(args.wallet.verbose); + const cmdArgs = args.withdrawUriCmd; + const withdrawUrl = cmdArgs.withdrawUri; console.log("withdrawing", withdrawUrl); const wallet = await getDefaultNodeWallet({ persistentStoragePath: walletDbPath, @@ -168,10 +300,7 @@ program return; } - const { - reservePub, - confirmTransferUrl, - } = await wallet.acceptWithdrawal( + const { reservePub, confirmTransferUrl } = await wallet.acceptWithdrawal( withdrawUrl, selectedExchange, ); @@ -187,10 +316,12 @@ program wallet.stop(); }); -program - .command("tip-uri ") - .action(async (tipUri, cmdObj) => { - applyVerbose(program.verbose); +walletCli + .subcommand("tipUriCmd", "tip-uri") + .argument("uri", clk.STRING) + .action(async args => { + applyVerbose(args.wallet.verbose); + const tipUri = args.tipUriCmd.uri; console.log("getting tip", tipUri); const wallet = await getDefaultNodeWallet({ persistentStoragePath: walletDbPath, @@ -201,12 +332,12 @@ program wallet.stop(); }); - - - program - .command("refund-uri ") - .action(async (refundUri, cmdObj) => { - applyVerbose(program.verbose); +walletCli + .subcommand("refundUriCmd", "refund-uri") + .argument("uri", clk.STRING) + .action(async args => { + applyVerbose(args.wallet.verbose); + const refundUri = args.refundUriCmd.uri; console.log("getting refund", refundUri); const wallet = await getDefaultNodeWallet({ persistentStoragePath: walletDbPath, @@ -215,131 +346,58 @@ program wallet.stop(); }); -program - .command("pay-uri { - applyVerbose(program.verbose); +const exchangesCli = walletCli + .subcommand("exchangesCmd", "exchanges", { + help: "Manage exchanges." + }); + +exchangesCli.subcommand("exchangesListCmd", "list", { + help: "List known exchanges." +}); + +exchangesCli.subcommand("exchangesListCmd", "update"); + +walletCli + .subcommand("payUriCmd", "pay-uri") + .argument("url", clk.STRING) + .flag("autoYes", ["-y", "--yes"]) + .action(async args => { + applyVerbose(args.wallet.verbose); + const payUrl = args.payUriCmd.url; console.log("paying for", payUrl); const wallet = await getDefaultNodeWallet({ persistentStoragePath: walletDbPath, }); - const result = await wallet.preparePay(payUrl); - if (result.status === "error") { - console.error("Could not pay:", result.error); - process.exit(1); - return; - } - if (result.status === "insufficient-balance") { - console.log("contract", result.contractTerms!); - console.error("insufficient balance"); - process.exit(1); - return; - } - if (result.status === "paid") { - console.log("already paid!"); - process.exit(0); - return; - } - if (result.status === "payment-possible") { - console.log("paying ..."); - } else { - throw Error("not reached"); - } - console.log("contract", result.contractTerms!); - let pay; - if (cmdObj.yes) { - pay = true; - } else { - while (true) { - const yesNoResp = (await 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) { - const payRes = await wallet.confirmPay(result.proposalId!, undefined); - console.log("paid!"); - } else { - console.log("not paying"); - } + await doPay(wallet, payUrl, { alwaysYes: args.payUriCmd.autoYes }); wallet.stop(); }); -program - .command("integrationtest") - .option( - "-e, --exchange ", - "exchange base URL", - "https://exchange.test.taler.net/", - ) - .option( - "-m, --merchant ", - "merchant base URL", - "https://backend.test.taler.net/", - ) - .option( - "-k, --merchant-api-key ", - "merchant API key", - "sandbox", - ) - .option( - "-b, --bank ", - "bank base URL", - "https://bank.test.taler.net/", - ) - .option( - "-w, --withdraw-amount ", - "amount to withdraw", - "TESTKUDOS:10", - ) - .option("-s, --spend-amount ", "amount to spend", "TESTKUDOS:4") - .description("Run integration test with bank, exchange and merchant.") - .action(async cmdObj => { - applyVerbose(program.verbose); - - try { - await runIntegrationTest({ - amountToSpend: cmdObj.spendAmount, - amountToWithdraw: cmdObj.withdrawAmount, - bankBaseUrl: cmdObj.bank, - exchangeBaseUrl: cmdObj.exchange, - merchantApiKey: cmdObj.merchantApiKey, - merchantBaseUrl: cmdObj.merchant, - }).catch(err => { - console.error("Failed with exception:"); - console.error(err); - }); - - process.exit(0); - } catch (e) { - console.error(e); - process.exit(1); - } - - }); - -// error on unknown commands -program.on("command:*", function() { - console.error( - "Invalid command: %s\nSee --help for a list of available commands.", - program.args.join(" "), - ); - process.exit(1); +const testCli = walletCli.subcommand("testingArgs", "testing", { + help: "Subcommands for testing GNU Taler deployments." }); -program.parse(process.argv); +testCli + .subcommand("withdrawArgs", "withdraw", { + help: "Withdraw from a test bank (must support test registrations).", + }) + .requiredOption("exchange", ["-e", "--exchange"], clk.STRING, { + default: "https://exchange.test.taler.net/", + help: "Exchange base URL.", + }) + .requiredOption("bank", ["-b", "--bank"], clk.STRING, { + default: "https://bank.test.taler.net/", + help: "Bank base URL", + }) + .action(async args => { + applyVerbose(args.wallet.verbose); + console.log("balance command called"); + const wallet = await getDefaultNodeWallet({ + persistentStoragePath: walletDbPath, + }); + console.log("got wallet"); + const balance = await wallet.getBalances(); + console.log(JSON.stringify(balance, undefined, 2)); + }); -if (process.argv.length <= 2) { - console.error("Error: No command given."); - program.help(); -} +walletCli.run(); diff --git a/src/wallet.ts b/src/wallet.ts index bbeaca601..f5219c459 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -62,6 +62,7 @@ import { Stores, TipRecord, WireFee, + WithdrawalRecord, } from "./dbTypes"; import { Auditor, @@ -106,6 +107,9 @@ import { WithdrawDetails, AcceptWithdrawalResponse, PurchaseDetails, + PendingOperationInfo, + PendingOperationsResponse, + HistoryQuery, } from "./walletTypes"; import { openPromise } from "./promiseUtils"; import { @@ -1159,6 +1163,9 @@ export class Wallet { return sp; } + /** + * Send reserve details + */ private async sendReserveInfoToBank(reservePub: string) { const reserve = await this.q().get( Stores.reserves, @@ -1576,54 +1583,58 @@ export class Wallet { console.log(`withdrawing ${denomsForWithdraw.length} coins`); - const ps = denomsForWithdraw.map(async denom => { - function mutateReserve(r: ReserveRecord): ReserveRecord { - const currentAmount = r.current_amount; - if (!currentAmount) { - throw Error("can't withdraw when amount is unknown"); - } - r.precoin_amount = Amounts.add( - r.precoin_amount, - denom.value, - denom.feeWithdraw, - ).amount; - const result = Amounts.sub( - currentAmount, - denom.value, - denom.feeWithdraw, - ); - if (result.saturated) { - console.error("can't create precoin, saturated"); - throw AbortTransaction; - } - r.current_amount = result.amount; + const stampMsNow = Math.floor(new Date().getTime()); - // Reserve is depleted if the amount left is too small to withdraw - if (Amounts.cmp(r.current_amount, smallestAmount) < 0) { - r.timestamp_depleted = new Date().getTime(); - } + const withdrawalRecord: WithdrawalRecord = { + reservePub: reserve.reserve_pub, + withdrawalAmount: Amounts.toString(withdrawAmount), + startTimestamp: stampMsNow, + } - return r; - } + const preCoinRecords: PreCoinRecord[] = await Promise.all(denomsForWithdraw.map(async denom => { + return await this.cryptoApi.createPreCoin(denom, reserve); + })); - const preCoin = await this.cryptoApi.createPreCoin(denom, reserve); + const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => x.value)).amount + const totalCoinWithdrawFee = Amounts.sum(denomsForWithdraw.map(x => x.feeWithdraw)).amount + const totalWithdrawAmount = Amounts.add(totalCoinValue, totalCoinWithdrawFee).amount - // This will fail and throw an exception if the remaining amount in the - // reserve is too low to create a pre-coin. - try { - await this.q() - .put(Stores.precoins, preCoin) - .mutate(Stores.reserves, reserve.reserve_pub, mutateReserve) - .finish(); - console.log("created precoin", preCoin.coinPub); - } catch (e) { - console.log("can't create pre-coin:", e.name, e.message); - return; + function mutateReserve(r: ReserveRecord): ReserveRecord { + const currentAmount = r.current_amount; + if (!currentAmount) { + throw Error("can't withdraw when amount is unknown"); + } + r.precoin_amount = Amounts.add(r.precoin_amount, totalWithdrawAmount).amount; + const result = Amounts.sub(currentAmount, totalWithdrawAmount); + if (result.saturated) { + console.error("can't create precoins, saturated"); + throw AbortTransaction; + } + r.current_amount = result.amount; + + // Reserve is depleted if the amount left is too small to withdraw + if (Amounts.cmp(r.current_amount, smallestAmount) < 0) { + r.timestamp_depleted = new Date().getTime(); } - await this.processPreCoin(preCoin.coinPub); - }); - await Promise.all(ps); + return r; + } + + // This will fail and throw an exception if the remaining amount in the + // reserve is too low to create a pre-coin. + try { + await this.q() + .putAll(Stores.precoins, preCoinRecords) + .put(Stores.withdrawals, withdrawalRecord) + .mutate(Stores.reserves, reserve.reserve_pub, mutateReserve) + .finish(); + } catch (e) { + return; + } + + for (let x of preCoinRecords) { + await this.processPreCoin(x.coinPub); + } } /** @@ -2701,7 +2712,7 @@ export class Wallet { /** * Retrive the full event history for this wallet. */ - async getHistory(): Promise<{ history: HistoryRecord[] }> { + async getHistory(historyQuery?: HistoryQuery): Promise<{ history: HistoryRecord[] }> { const history: HistoryRecord[] = []; // FIXME: do pagination instead of generating the full history @@ -2720,7 +2731,18 @@ export class Wallet { merchantName: p.contractTerms.merchant.name, }, timestamp: p.timestamp, - type: "offer-contract", + type: "claim-order", + }); + } + + const withdrawals = await this.q().iter(Stores.withdrawals).toArray() + for (const w of withdrawals) { + history.push({ + detail: { + withdrawalAmount: w.withdrawalAmount, + }, + timestamp: w.startTimestamp, + type: "withdraw", }); } @@ -2772,7 +2794,7 @@ export class Wallet { history.push({ detail: { exchangeBaseUrl: r.exchange_base_url, - requestedAmount: r.requested_amount, + requestedAmount: Amounts.toString(r.requested_amount), reservePub: r.reserve_pub, }, timestamp: r.created, @@ -2812,6 +2834,12 @@ export class Wallet { return { history }; } + async getPendingOperations(): Promise { + return { + pendingOperations: [] + }; + } + async getDenoms(exchangeUrl: string): Promise { const denoms = await this.q() .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchangeUrl) diff --git a/src/walletTypes.ts b/src/walletTypes.ts index fddf05680..e632cd38b 100644 --- a/src/walletTypes.ts +++ b/src/walletTypes.ts @@ -515,3 +515,30 @@ export interface WalletDiagnostics { firefoxIdbProblem: boolean; dbOutdated: boolean; } + +export interface PendingWithdrawOperation { + type: "withdraw" +} + +export interface PendingRefreshOperation { + type: "refresh" +} + +export interface PendingPayOperation { + type: "pay" +} + +export type PendingOperationInfo = PendingWithdrawOperation + +export interface PendingOperationsResponse { + pendingOperations: PendingOperationInfo[]; +} + +export interface HistoryQuery { + /** + * Verbosity of history events. + * Level 0: Only withdraw, pay, tip and refund events. + * Level 1: All events. + */ + level: number; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 820dd560a..e190e14b7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -40,6 +40,7 @@ "src/db.ts", "src/dbTypes.ts", "src/headless/bank.ts", + "src/headless/clk.ts", "src/headless/helpers.ts", "src/headless/integrationtest.ts", "src/headless/merchant.ts", diff --git a/yarn.lock b/yarn.lock index 31f7d3ef0..2e7ec95c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6746,10 +6746,10 @@ typescript@3.5.x: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.5.3.tgz#c830f657f93f1ea846819e929092f5fe5983e977" integrity sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g== -typescript@^3.6.2: - version "3.6.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.6.2.tgz#105b0f1934119dde543ac8eb71af3a91009efe54" - integrity sha512-lmQ4L+J6mnu3xweP8+rOrUwzmN+MRAj7TgtJtDaXE5PMyX2kCrklhg3rvOsOIfNeAWMQWO2F1GPc1kMD2vLAfw== +typescript@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.2.tgz#27e489b95fa5909445e9fef5ee48d81697ad18fb" + integrity sha512-ml7V7JfiN2Xwvcer+XAf2csGO1bPBdRbFCkYBczNZggrBZ9c7G3riSUeJmqEU5uOtXNPMhE3n+R4FA/3YOAWOQ== uglify-js@^3.0.27, uglify-js@^3.1.4: version "3.6.0" -- cgit v1.2.3