work on CLI

This commit is contained in:
Florian Dold 2019-11-19 16:16:12 +01:00
parent 87aa0f65c3
commit d9297f3dfd
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
8 changed files with 964 additions and 272 deletions

View File

@ -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",

View File

@ -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<WithdrawalRecord> {
constructor() {
super("withdrawals", { keyPath: "id", autoIncrement: true })
}
}
export const coins = new CoinsStore();
export const coinsReturns = new Store<CoinsReturnRecord>("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 */

546
src/headless/clk.ts Normal file
View File

@ -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 <http://www.gnu.org/licenses/>
*/
/**
* Imports.
*/
import process = require("process");
import path = require("path");
import readline = require("readline");
import { symlinkSync } from "fs";
class Converter<T> {}
export let INT = new Converter<number>();
export let STRING: Converter<string> = new Converter<string>();
export interface OptionArgs<T> {
help?: string;
default?: T;
}
export interface ArgumentArgs<T> {
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<any>;
args: ArgumentArgs<any>;
}
interface SubcommandDef {
commandGroup: CommandGroup<any, any>;
name: string;
args: SubcommandArgs;
}
type ActionFn<TG> = (x: TG) => void;
type SubRecord<S extends keyof any, N extends keyof any, V> = {
[Y in S]: { [X in N]: V };
};
interface OptionDef {
name: string;
flagspec: string[];
/**
* Converter, only present for options, not for flags.
*/
conv?: Converter<any>;
args: OptionArgs<any>;
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<GN extends keyof any, TG> {
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<TG>;
constructor(
private argKey: string,
private name: string | null,
private scArgs: SubcommandArgs,
) {}
action(f: ActionFn<TG>) {
if (this.myAction) {
throw Error("only one action supported per command");
}
this.myAction = f;
}
requiredOption<N extends keyof any, V>(
name: N,
flagspec: string[],
conv: Converter<V>,
args: OptionArgs<V> = {},
): CommandGroup<GN, TG & SubRecord<GN, N, V>> {
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<N extends keyof any, V>(
name: N,
flagspec: string[],
conv: Converter<V>,
args: OptionArgs<V> = {},
): CommandGroup<GN, TG & SubRecord<GN, N, V | undefined>> {
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<N extends keyof any, V>(
name: N,
conv: Converter<V>,
args: ArgumentArgs<V> = {},
): CommandGroup<GN, TG & SubRecord<GN, N, V>> {
const argDef: ArgumentDef = {
args: args,
conv: conv,
name: name as string,
};
this.arguments.push(argDef);
return this as any;
}
flag<N extends string, V>(
name: N,
flagspec: string[],
args: OptionArgs<V> = {},
): CommandGroup<GN, TG & SubRecord<GN, N, boolean>> {
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<GN extends keyof any>(
argKey: GN,
name: string,
args: SubcommandArgs = {},
): CommandGroup<GN, TG> {
const cg = new CommandGroup<GN, {}>(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<any, any>[]) {
const chain: CommandGroup<any, any>[] = Array.prototype.concat(parents, [
this,
]);
let usageSpec = "";
for (let p of parents) {
usageSpec += (p.name ?? progName) + " ";
if (p.arguments.length >= 1) {
usageSpec += "<ARGS...> ";
}
}
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<any, any>[],
unparsedArgs: string[],
parsedArgs: any,
) {
let posArgIndex = 0;
let argsTerminated = false;
let i;
let foundSubcommand: CommandGroup<any, any> | 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<PN extends keyof any, T> {
private mainCommand: CommandGroup<any, any>;
constructor(argKey: string, args: ProgramArgs = {}) {
this.mainCommand = new CommandGroup<any, any>(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<GN extends keyof any>(
argKey: GN,
name: string,
args: SubcommandArgs = {},
): CommandGroup<GN, T> {
const cmd = this.mainCommand.subcommand(argKey, name as string, args);
return cmd as any;
}
requiredOption<N extends keyof any, V>(
name: N,
flagspec: string[],
conv: Converter<V>,
args: OptionArgs<V> = {},
): Program<PN, T & SubRecord<PN, N, V>> {
this.mainCommand.requiredOption(name, flagspec, conv, args);
return this as any;
}
maybeOption<N extends keyof any, V>(
name: N,
flagspec: string[],
conv: Converter<V>,
args: OptionArgs<V> = {},
): Program<PN, T & SubRecord<PN, N, V | undefined>> {
this.mainCommand.maybeOption(name, flagspec, conv, args);
return this as any;
}
/**
* Add a flag (option without value) to the program.
*/
flag<N extends string>(
name: N,
flagspec: string[],
args: OptionArgs<boolean> = {},
): Program<N, T & SubRecord<PN, N, boolean>> {
this.mainCommand.flag(name, flagspec, args);
return this as any;
}
/**
* Add a positional argument to the program.
*/
argument<N extends keyof any, V>(
name: N,
conv: Converter<V>,
args: ArgumentArgs<V> = {},
): Program<N, T & SubRecord<PN, N, V>> {
this.mainCommand.argument(name, conv, args);
return this as any;
}
}
export function program<PN extends keyof any>(
argKey: PN,
args: ProgramArgs = {},
): Program<PN, {}> {
return new Program(argKey as string, args);
}
export function prompt(question: string): Promise<string> {
const stdinReadline = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise<string>((resolve, reject) => {
stdinReadline.question(question, res => {
resolve(res);
stdinReadline.close();
});
});
}

View File

@ -14,32 +14,68 @@
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
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<string> {
const stdinReadline = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise<string>((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-url>",
"exchange base URL",
"https://exchange.test.taler.net/",
)
.option("-a, --amount <withdraw-amt>", "amount to withdraw", "TESTKUDOS:10")
.option("-b, --bank <bank-url>", "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<void> {
return new Promise<void>((resolve, reject) => {
setTimeout(() => resolve(), milliSeconds);
});
}
program
.command("test-merchant-qrcode")
.option("-a, --amount <spend-amt>", "amount to spend", "TESTKUDOS:1")
.option("-s, --summary <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,164 +231,31 @@ program
}
});
program
.command("withdraw-uri <withdraw-uri>")
.action(async (withdrawUrl, cmdObj) => {
applyVerbose(program.verbose);
console.log("withdrawing", withdrawUrl);
const wallet = await getDefaultNodeWallet({
persistentStoragePath: walletDbPath,
});
const withdrawInfo = await wallet.getWithdrawalInfo(withdrawUrl);
console.log("withdraw info", withdrawInfo);
const selectedExchange = withdrawInfo.suggestedExchange;
if (!selectedExchange) {
console.error("no suggested exchange!");
process.exit(1);
return;
}
const {
reservePub,
confirmTransferUrl,
} = await wallet.acceptWithdrawal(
withdrawUrl,
selectedExchange,
);
if (confirmTransferUrl) {
console.log("please confirm the transfer at", confirmTransferUrl);
}
await wallet.processReserve(reservePub);
console.log("finished withdrawing");
wallet.stop();
});
program
.command("tip-uri <tip-uri>")
.action(async (tipUri, cmdObj) => {
applyVerbose(program.verbose);
console.log("getting tip", tipUri);
const wallet = await getDefaultNodeWallet({
persistentStoragePath: walletDbPath,
});
const res = await wallet.getTipStatus(tipUri);
console.log("tip status", res);
await wallet.acceptTip(tipUri);
wallet.stop();
});
program
.command("refund-uri <refund-uri>")
.action(async (refundUri, cmdObj) => {
applyVerbose(program.verbose);
console.log("getting refund", refundUri);
const wallet = await getDefaultNodeWallet({
persistentStoragePath: walletDbPath,
});
await wallet.applyRefund(refundUri);
wallet.stop();
});
program
.command("pay-uri <pay-uri")
.option("-y, --yes", "automatically answer yes to prompts")
.action(async (payUrl, cmdObj) => {
applyVerbose(program.verbose);
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");
}
wallet.stop();
});
program
.command("integrationtest")
.option(
"-e, --exchange <exchange-url>",
"exchange base URL",
"https://exchange.test.taler.net/",
)
.option(
"-m, --merchant <merchant-url>",
"merchant base URL",
"https://backend.test.taler.net/",
)
.option(
"-k, --merchant-api-key <merchant-api-key>",
"merchant API key",
"sandbox",
)
.option(
"-b, --bank <bank-url>",
"bank base URL",
"https://bank.test.taler.net/",
)
.option(
"-w, --withdraw-amount <withdraw-amt>",
"amount to withdraw",
"TESTKUDOS:10",
)
.option("-s, --spend-amount <spend-amt>", "amount to spend", "TESTKUDOS:4")
.description("Run integration test with bank, exchange and merchant.")
.action(async 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({
@ -325,21 +275,129 @@ program
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);
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,
});
const withdrawInfo = await wallet.getWithdrawalInfo(withdrawUrl);
console.log("withdraw info", withdrawInfo);
const selectedExchange = withdrawInfo.suggestedExchange;
if (!selectedExchange) {
console.error("no suggested exchange!");
process.exit(1);
return;
}
const { reservePub, confirmTransferUrl } = await wallet.acceptWithdrawal(
withdrawUrl,
selectedExchange,
);
if (confirmTransferUrl) {
console.log("please confirm the transfer at", confirmTransferUrl);
}
await wallet.processReserve(reservePub);
console.log("finished withdrawing");
wallet.stop();
});
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,
});
const res = await wallet.getTipStatus(tipUri);
console.log("tip status", res);
await wallet.acceptTip(tipUri);
wallet.stop();
});
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,
});
await wallet.applyRefund(refundUri);
wallet.stop();
});
const exchangesCli = walletCli
.subcommand("exchangesCmd", "exchanges", {
help: "Manage exchanges."
});
exchangesCli.subcommand("exchangesListCmd", "list", {
help: "List known exchanges."
});
program.parse(process.argv);
exchangesCli.subcommand("exchangesListCmd", "update");
if (process.argv.length <= 2) {
console.error("Error: No command given.");
program.help();
}
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,
});
await doPay(wallet, payUrl, { alwaysYes: args.payUriCmd.autoYes });
wallet.stop();
});
const testCli = walletCli.subcommand("testingArgs", "testing", {
help: "Subcommands for testing GNU Taler deployments."
});
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));
});
walletCli.run();

View File

@ -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<ReserveRecord>(
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 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
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();
}
const preCoin = await this.cryptoApi.createPreCoin(denom, reserve);
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()
.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;
}
await this.processPreCoin(preCoin.coinPub);
});
// 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;
}
await Promise.all(ps);
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<WithdrawalRecord>(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<PendingOperationsResponse> {
return {
pendingOperations: []
};
}
async getDenoms(exchangeUrl: string): Promise<DenominationRecord[]> {
const denoms = await this.q()
.iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchangeUrl)

View File

@ -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;
}

View File

@ -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",

View File

@ -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"