gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-wallet-core] branch master updated: towards integration tests wit


From: gnunet
Subject: [taler-wallet-core] branch master updated: towards integration tests with fault injection
Date: Wed, 05 Aug 2020 21:00:45 +0200

This is an automated email from the git hooks/post-receive script.

dold pushed a commit to branch master
in repository wallet-core.

The following commit(s) were added to refs/heads/master by this push:
     new 82a2437c towards integration tests with fault injection
82a2437c is described below

commit 82a2437c0967871d6b942105c98c3382978cad29
Author: Florian Dold <florian.dold@gmail.com>
AuthorDate: Thu Aug 6 00:30:36 2020 +0530

    towards integration tests with fault injection
---
 .gitignore                                         |   4 +-
 packages/taler-integrationtests/package.json       |  43 +
 .../taler-integrationtests/src/faultInjection.ts   | 222 +++++
 packages/taler-integrationtests/src/harness.ts     | 907 +++++++++++++++++++++
 packages/taler-integrationtests/src/helpers.ts     | 157 ++++
 .../taler-integrationtests/src/merchantApiTypes.ts | 217 +++++
 .../src/test-payment-fault.ts                      | 194 +++++
 .../taler-integrationtests/src/test-payment.ts     |  80 ++
 .../taler-integrationtests/src/test-withdrawal.ts  |  68 ++
 packages/taler-integrationtests/testrunner         |  63 ++
 packages/taler-integrationtests/tsconfig.json      |  32 +
 packages/taler-wallet-android/src/index.ts         |   1 +
 packages/taler-wallet-cli/bin/taler-wallet-cli     |   2 +-
 packages/taler-wallet-cli/src/index.ts             |  15 +-
 packages/taler-wallet-core/package.json            |  14 +-
 packages/taler-wallet-core/rollup.config.js        |   7 +-
 .../src/crypto/workers/cryptoApi.ts                |  12 +-
 .../src/crypto/workers/nodeThreadWorker.ts         |  19 +-
 .../taler-wallet-core/src/headless/NodeHttpLib.ts  |  10 +-
 packages/taler-wallet-core/src/index.ts            |   7 +
 .../taler-wallet-core/src/operations/exchanges.ts  |  10 +
 packages/taler-wallet-core/src/operations/pay.ts   |   2 +-
 .../taler-wallet-core/src/operations/withdraw.ts   |   2 +
 .../taler-wallet-core/src/types/walletTypes.ts     |  17 +
 packages/taler-wallet-core/src/util/http.ts        |   7 +
 .../taler-wallet-core/src/util/talerconfig-test.ts | 124 +++
 packages/taler-wallet-core/src/util/talerconfig.ts | 151 +++-
 packages/taler-wallet-core/src/util/timer.ts       |  36 +
 .../src/browserHttpLib.ts                          |   5 +-
 pnpm-lock.yaml                                     | 142 +++-
 30 files changed, 2536 insertions(+), 34 deletions(-)

diff --git a/.gitignore b/.gitignore
index 05780984..0dff3b85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,7 +7,8 @@ tsconfig.tsbuildinfo
 
 # GNU-style build system
 /configure
-build-system/config.mk
+/build-system/config.mk
+/Makefile
 
 # Editor files
 \#*\#
@@ -20,3 +21,4 @@ build-scripts/
 
 # Git worktree of pre-built wallet files
 prebuilt/
+
diff --git a/packages/taler-integrationtests/package.json 
b/packages/taler-integrationtests/package.json
new file mode 100644
index 00000000..71385237
--- /dev/null
+++ b/packages/taler-integrationtests/package.json
@@ -0,0 +1,43 @@
+{
+  "name": "taler-integrationtests",
+  "version": "0.0.1",
+  "description": "Integration tests and fault injection for GNU Taler 
components",
+  "main": "index.js",
+  "scripts": {
+    "compile": "tsc",
+    "test": "tsc && ava"
+  },
+  "author": "Florian Dold <dold@taler.net>",
+  "license": "AGPL-3.0-or-later",
+  "devDependencies": {
+    "@ava/typescript": "^1.1.1",
+    "ava": "^3.11.1",
+    "esm": "^3.2.25",
+    "source-map-support": "^0.5.19",
+    "ts-node": "^8.10.2"
+  },
+  "dependencies": {
+    "axios": "^0.19.2",
+    "taler-wallet-core": "workspace:*",
+    "tslib": "^2.0.0",
+    "typescript": "^3.9.7"
+  },
+  "ava": {
+    "require": [
+      "esm"
+    ],
+    "files": [
+      "src/**/test-*"
+    ],
+    "typescript": {
+      "extensions": [
+        "js",
+        "ts",
+        "tsx"
+      ],
+      "rewritePaths": {
+        "src/": "lib/"
+      }
+    }
+  }
+}
diff --git a/packages/taler-integrationtests/src/faultInjection.ts 
b/packages/taler-integrationtests/src/faultInjection.ts
new file mode 100644
index 00000000..a9c249fd
--- /dev/null
+++ b/packages/taler-integrationtests/src/faultInjection.ts
@@ -0,0 +1,222 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Fault injection proxy.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports
+ */
+import * as http from "http";
+import { URL } from "url";
+import {
+  GlobalTestState,
+  ExchangeService,
+  BankService,
+  ExchangeServiceInterface,
+} from "./harness";
+
+export interface FaultProxyConfig {
+  inboundPort: number;
+  targetPort: number;
+}
+
+/**
+ * Fault injection context.  Modified by fault injection functions.
+ */
+export interface FaultInjectionRequestContext {
+  requestUrl: string;
+  method: string;
+  requestHeaders: Record<string, string | string[] | undefined>;
+  requestBody?: Buffer;
+  dropRequest: boolean;
+}
+
+export interface FaultInjectionResponseContext {
+  request: FaultInjectionRequestContext;
+  statusCode: number;
+  responseHeaders: Record<string, string | string[] | undefined>;
+  responseBody: Buffer | undefined;
+  dropResponse: boolean;
+}
+
+export interface FaultSpec {
+  modifyRequest?: (ctx: FaultInjectionRequestContext) => void;
+  modifyResponse?: (ctx: FaultInjectionResponseContext) => void;
+}
+
+export class FaultProxy {
+  constructor(
+    private globalTestState: GlobalTestState,
+    private faultProxyConfig: FaultProxyConfig,
+  ) {}
+
+  private currentFaultSpecs: FaultSpec[] = [];
+
+  start() {
+    const server = http.createServer((req, res) => {
+      const requestChunks: Buffer[] = [];
+      const requestUrl = 
`http://locahost:${this.faultProxyConfig.inboundPort}${req.url}`;
+      console.log("request for", new URL(requestUrl));
+      req.on("data", (chunk) => {
+        requestChunks.push(chunk);
+      });
+      req.on("end", () => {
+        console.log("end of data");
+        let requestBuffer: Buffer | undefined;
+        if (requestChunks.length > 0) {
+          requestBuffer = Buffer.concat(requestChunks);
+        }
+        console.log("full request body", requestBuffer);
+
+        const faultReqContext: FaultInjectionRequestContext = {
+          dropRequest: false,
+          method: req.method!!,
+          requestHeaders: req.headers,
+          requestUrl,
+          requestBody: requestBuffer,
+        };
+
+        for (const faultSpec of this.currentFaultSpecs) {
+          if (faultSpec.modifyRequest) {
+            faultSpec.modifyRequest(faultReqContext);
+          }
+        }
+
+        if (faultReqContext.dropRequest) {
+          res.destroy();
+          return;
+        }
+
+        const faultedUrl = new URL(faultReqContext.requestUrl);
+
+        const proxyRequest = http.request({
+          method: faultReqContext.method,
+          host: "localhost",
+          port: this.faultProxyConfig.targetPort,
+          path: faultedUrl.pathname + faultedUrl.search,
+          headers: faultReqContext.requestHeaders,
+        });
+
+        console.log(
+          `proxying request to target path '${
+            faultedUrl.pathname + faultedUrl.search
+          }'`,
+        );
+
+        if (faultReqContext.requestBody) {
+          proxyRequest.write(faultReqContext.requestBody);
+        }
+        proxyRequest.end();
+        proxyRequest.on("response", (proxyResp) => {
+          console.log("gotten response from target", proxyResp.statusCode);
+          const respChunks: Buffer[] = [];
+          proxyResp.on("data", (proxyRespData) => {
+            respChunks.push(proxyRespData);
+          });
+          proxyResp.on("end", () => {
+            console.log("end of target response");
+            let responseBuffer: Buffer | undefined;
+            if (respChunks.length > 0) {
+              responseBuffer = Buffer.concat(respChunks);
+            }
+            const faultRespContext: FaultInjectionResponseContext = {
+              request: faultReqContext,
+              dropResponse: false,
+              responseBody: responseBuffer,
+              responseHeaders: proxyResp.headers,
+              statusCode: proxyResp.statusCode!!,
+            };
+            for (const faultSpec of this.currentFaultSpecs) {
+              const modResponse = faultSpec.modifyResponse;  
+              if (modResponse) {
+                modResponse(faultRespContext);
+              }
+            }
+            if (faultRespContext.dropResponse) {
+              req.destroy();
+              return;
+            }
+            if (faultRespContext.responseBody) {
+              // We must accomodate for potentially changed content length
+              faultRespContext.responseHeaders[
+                "content-length"
+              ] = `${faultRespContext.responseBody.byteLength}`;
+            }
+            console.log("writing response head");
+            res.writeHead(
+              faultRespContext.statusCode,
+              http.STATUS_CODES[faultRespContext.statusCode],
+              faultRespContext.responseHeaders,
+            );
+            if (faultRespContext.responseBody) {
+              res.write(faultRespContext.responseBody);
+            }
+            res.end();
+          });
+        });
+      });
+    });
+
+    server.listen(this.faultProxyConfig.inboundPort);
+    this.globalTestState.servers.push(server);
+  }
+
+  addFault(f: FaultSpec) {
+    this.currentFaultSpecs.push(f);
+  }
+
+  clearFault() {
+    this.currentFaultSpecs = [];
+  }
+}
+
+export class FaultInjectedExchangeService implements ExchangeServiceInterface {
+  baseUrl: string;
+  port: number;
+  faultProxy: FaultProxy;
+
+  get name(): string {
+    return this.innerExchange.name;
+  }
+
+  get masterPub(): string {
+    return this.innerExchange.masterPub;
+  }
+
+  private innerExchange: ExchangeService;
+
+  constructor(
+    t: GlobalTestState,
+    e: ExchangeService,
+    proxyInboundPort: number,
+  ) {
+    this.innerExchange = e;
+    this.faultProxy = new FaultProxy(t, {
+      inboundPort: proxyInboundPort,
+      targetPort: e.port,
+    });
+    this.faultProxy.start();
+
+    const exchangeUrl = new URL(e.baseUrl);
+    exchangeUrl.port = `${proxyInboundPort}`;
+    this.baseUrl = exchangeUrl.href;
+    this.port = proxyInboundPort;
+  }
+}
diff --git a/packages/taler-integrationtests/src/harness.ts 
b/packages/taler-integrationtests/src/harness.ts
new file mode 100644
index 00000000..14fa2071
--- /dev/null
+++ b/packages/taler-integrationtests/src/harness.ts
@@ -0,0 +1,907 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Test harness for various GNU Taler components.
+ * Also provides a fault-injection proxy.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports
+ */
+import * as util from "util";
+import * as fs from "fs";
+import * as path from "path";
+import * as os from "os";
+import * as http from "http";
+import { ChildProcess, spawn } from "child_process";
+import {
+  Configuration,
+  walletCoreApi,
+  codec,
+  AmountJson,
+  Amounts,
+} from "taler-wallet-core";
+import { URL } from "url";
+import axios from "axios";
+import { talerCrypto, time } from "taler-wallet-core";
+import { codecForMerchantOrderPrivateStatusResponse, 
codecForPostOrderResponse, PostOrderRequest, PostOrderResponse } from 
"./merchantApiTypes";
+
+const exec = util.promisify(require("child_process").exec);
+
+async function delay(ms: number): Promise<void> {
+  return new Promise((resolve, reject) => {
+    setTimeout(() => resolve(), ms);
+  });
+}
+
+interface WaitResult {
+  code: number | null;
+  signal: NodeJS.Signals | null;
+}
+
+/**
+ * Run a shell command, return stdout.
+ */
+export async function sh(command: string): Promise<string> {
+  console.log("runing command");
+  console.log(command);
+  return new Promise((resolve, reject) => {
+    const stdoutChunks: Buffer[] = [];
+    const proc = spawn(command, {
+      stdio: ["inherit", "pipe", "inherit"],
+      shell: true,
+    });
+    proc.stdout.on("data", (x) => {
+      console.log("child process got data chunk");
+      if (x instanceof Buffer) {
+        stdoutChunks.push(x);
+      } else {
+        throw Error("unexpected data chunk type");
+      }
+    });
+    proc.on("exit", (code) => {
+      console.log("child process exited");
+      if (code != 0) {
+        reject(Error(`Unexpected exit code ${code} for '${command}'`));
+        return;
+      }
+      const b = Buffer.concat(stdoutChunks).toString("utf-8");
+      resolve(b);
+    });
+    proc.on("error", () => {
+      reject(Error("Child process had error"));
+    });
+  });
+}
+
+export class ProcessWrapper {
+  private waitPromise: Promise<WaitResult>;
+  constructor(public proc: ChildProcess) {
+    this.waitPromise = new Promise((resolve, reject) => {
+      proc.on("exit", (code, signal) => {
+        resolve({ code, signal });
+      });
+      proc.on("error", (err) => {
+        reject(err);
+      });
+    });
+  }
+
+  wait(): Promise<WaitResult> {
+    return this.waitPromise;
+  }
+}
+
+export function makeTempDir(): Promise<string> {
+  return new Promise((resolve, reject) => {
+    fs.mkdtemp(
+      path.join(os.tmpdir(), "taler-integrationtest-"),
+      (err, directory) => {
+        if (err) {
+          reject(err);
+          return;
+        }
+        resolve(directory);
+        console.log(directory);
+      },
+    );
+  });
+}
+
+interface CoinConfig {
+  name: string;
+  value: string;
+  durationWithdraw: string;
+  durationSpend: string;
+  durationLegal: string;
+  feeWithdraw: string;
+  feeDeposit: string;
+  feeRefresh: string;
+  feeRefund: string;
+  rsaKeySize: number;
+}
+
+const coinCommon = {
+  durationLegal: "3 years",
+  durationSpend: "2 years",
+  durationWithdraw: "7 days",
+  rsaKeySize: 1024,
+};
+
+const coin_ct1 = (curr: string): CoinConfig => ({
+  ...coinCommon,
+  name: `${curr}_ct1`,
+  value: `${curr}:0.01`,
+  feeDeposit: `${curr}:0.00`,
+  feeRefresh: `${curr}:0.01`,
+  feeRefund: `${curr}:0.00`,
+  feeWithdraw: `${curr}:0.01`,
+});
+
+const coin_ct10 = (curr: string): CoinConfig => ({
+  ...coinCommon,
+  name: `${curr}_ct10`,
+  value: `${curr}:0.10`,
+  feeDeposit: `${curr}:0.01`,
+  feeRefresh: `${curr}:0.01`,
+  feeRefund: `${curr}:0.00`,
+  feeWithdraw: `${curr}:0.01`,
+});
+
+const coin_u1 = (curr: string): CoinConfig => ({
+  ...coinCommon,
+  name: `${curr}_u1`,
+  value: `${curr}:1`,
+  feeDeposit: `${curr}:0.02`,
+  feeRefresh: `${curr}:0.02`,
+  feeRefund: `${curr}:0.02`,
+  feeWithdraw: `${curr}:0.02`,
+});
+
+const coin_u2 = (curr: string): CoinConfig => ({
+  ...coinCommon,
+  name: `${curr}_u2`,
+  value: `${curr}:2`,
+  feeDeposit: `${curr}:0.02`,
+  feeRefresh: `${curr}:0.02`,
+  feeRefund: `${curr}:0.02`,
+  feeWithdraw: `${curr}:0.02`,
+});
+
+const coin_u4 = (curr: string): CoinConfig => ({
+  ...coinCommon,
+  name: `${curr}_u4`,
+  value: `${curr}:4`,
+  feeDeposit: `${curr}:0.02`,
+  feeRefresh: `${curr}:0.02`,
+  feeRefund: `${curr}:0.02`,
+  feeWithdraw: `${curr}:0.02`,
+});
+
+const coin_u8 = (curr: string): CoinConfig => ({
+  ...coinCommon,
+  name: `${curr}_u8`,
+  value: `${curr}:8`,
+  feeDeposit: `${curr}:0.16`,
+  feeRefresh: `${curr}:0.16`,
+  feeRefund: `${curr}:0.16`,
+  feeWithdraw: `${curr}:0.16`,
+});
+
+const coin_u10 = (curr: string): CoinConfig => ({
+  ...coinCommon,
+  name: `${curr}_u10`,
+  value: `${curr}:10`,
+  feeDeposit: `${curr}:0.2`,
+  feeRefresh: `${curr}:0.2`,
+  feeRefund: `${curr}:0.2`,
+  feeWithdraw: `${curr}:0.2`,
+});
+
+export class GlobalTestParams {
+  testDir: string;
+}
+
+export class GlobalTestState {
+  testDir: string;
+  procs: ProcessWrapper[];
+  servers: http.Server[];
+  constructor(params: GlobalTestParams) {
+    this.testDir = params.testDir;
+    this.procs = [];
+    this.servers = [];
+
+    process.on("SIGINT", () => this.shutdownSync());
+    process.on("SIGTERM", () => this.shutdownSync());
+    process.on("unhandledRejection", () => this.shutdownSync());
+    process.on("uncaughtException", () => this.shutdownSync());
+  }
+
+  assertTrue(b: boolean): asserts b {
+    if (!b) {
+      throw Error("test assertion failed");
+    }
+  }
+
+  assertAmountEquals(
+    amtExpected: string | AmountJson,
+    amtActual: string | AmountJson,
+  ): void {
+    let ja1: AmountJson;
+    let ja2: AmountJson;
+    if (typeof amtExpected === "string") {
+      ja1 = Amounts.parseOrThrow(amtExpected);
+    } else {
+      ja1 = amtExpected;
+    }
+    if (typeof amtActual === "string") {
+      ja2 = Amounts.parseOrThrow(amtActual);
+    } else {
+      ja2 = amtActual;
+    }
+
+    if (Amounts.cmp(ja1, ja2) != 0) {
+      throw Error(
+        `test assertion failed: expected ${Amounts.stringify(
+          ja1,
+        )} but got ${Amounts.stringify(ja2)}`,
+      );
+    }
+  }
+
+  private shutdownSync(): void {
+    for (const s of this.servers) {
+      s.close();
+      s.removeAllListeners();
+    }
+    for (const p of this.procs) {
+      if (p.proc.exitCode == null) {
+        p.proc.kill("SIGTERM");
+      } else {
+      }
+    }
+    console.log("*** test harness interrupted");
+    console.log("*** test state can be found under", this.testDir);
+    process.exit(1);
+  }
+
+  spawnService(command: string, logName: string): ProcessWrapper {
+    const proc = spawn(command, {
+      shell: true,
+      stdio: ["inherit", "pipe", "pipe"],
+    });
+    const stderrLogFileName = this.testDir + `/${logName}-stderr.log`;
+    const stderrLog = fs.createWriteStream(stderrLogFileName, {
+      flags: "a",
+    });
+    proc.stderr.pipe(stderrLog);
+    const stdoutLogFileName = this.testDir + `/${logName}-stdout.log`;
+    const stdoutLog = fs.createWriteStream(stdoutLogFileName, {
+      flags: "a",
+    });
+    proc.stdout.pipe(stdoutLog);
+    const procWrap = new ProcessWrapper(proc);
+    this.procs.push(procWrap);
+    return procWrap;
+  }
+
+  async terminate(): Promise<void> {
+    console.log("terminating");
+    for (const s of this.servers) {
+      s.close();
+      s.removeAllListeners();
+    }
+    for (const p of this.procs) {
+      if (p.proc.exitCode == null) {
+        console.log("killing process", p.proc.pid);
+        p.proc.kill("SIGTERM");
+        await p.wait();
+      }
+    }
+  }
+}
+
+export interface TalerConfigSection {
+  options: Record<string, string | undefined>;
+}
+
+export interface TalerConfig {
+  sections: Record<string, TalerConfigSection>;
+}
+
+export interface DbInfo {
+  connStr: string;
+  dbname: string;
+}
+
+export async function setupDb(gc: GlobalTestState): Promise<DbInfo> {
+  const dbname = "taler-integrationtest";
+  await exec(`dropdb "${dbname}" || true`);
+  await exec(`createdb "${dbname}"`);
+  return {
+    connStr: `postgres:///${dbname}`,
+    dbname,
+  };
+}
+
+export interface BankConfig {
+  currency: string;
+  httpPort: number;
+  database: string;
+  suggestedExchange: string | undefined;
+  suggestedExchangePayto: string | undefined;
+  allowRegistrations: boolean;
+}
+
+function setPaths(config: Configuration, home: string) {
+  config.setString("paths", "taler_home", home);
+  config.setString(
+    "paths",
+    "taler_data_home",
+    "$TALER_HOME/.local/share/taler/",
+  );
+  config.setString("paths", "taler_config_home", "$TALER_HOME/.config/taler/");
+  config.setString("paths", "taler_cache_home", "$TALER_HOME/.config/taler/");
+  config.setString(
+    "paths",
+    "taler_runtime_dir",
+    "${TMPDIR:-${TMP:-/tmp}}/taler-system-runtime/",
+  );
+}
+
+function setCoin(config: Configuration, c: CoinConfig) {
+  const s = `coin_${c.name}`;
+  config.setString(s, "value", c.value);
+  config.setString(s, "duration_withdraw", c.durationWithdraw);
+  config.setString(s, "duration_spend", c.durationSpend);
+  config.setString(s, "duration_legal", c.durationLegal);
+  config.setString(s, "fee_deposit", c.feeDeposit);
+  config.setString(s, "fee_withdraw", c.feeWithdraw);
+  config.setString(s, "fee_refresh", c.feeRefresh);
+  config.setString(s, "fee_refund", c.feeRefund);
+  config.setString(s, "rsa_keysize", `${c.rsaKeySize}`);
+}
+
+export class BankService {
+  proc: ProcessWrapper | undefined;
+  static async create(
+    gc: GlobalTestState,
+    bc: BankConfig,
+  ): Promise<BankService> {
+    const config = new Configuration();
+    setPaths(config, gc.testDir + "/talerhome");
+    config.setString("taler", "currency", bc.currency);
+    config.setString("bank", "database", bc.database);
+    config.setString("bank", "http_port", `${bc.httpPort}`);
+    config.setString("bank", "max_debt_bank", `${bc.currency}:999999`);
+    config.setString(
+      "bank",
+      "allow_registrations",
+      bc.allowRegistrations ? "yes" : "no",
+    );
+    if (bc.suggestedExchange) {
+      config.setString("bank", "suggested_exchange", bc.suggestedExchange);
+    }
+    if (bc.suggestedExchangePayto) {
+      config.setString(
+        "bank",
+        "suggested_exchange_payto",
+        bc.suggestedExchangePayto,
+      );
+    }
+    const cfgFilename = gc.testDir + "/bank.conf";
+    config.write(cfgFilename);
+    return new BankService(gc, bc, cfgFilename);
+  }
+
+  get port() {
+    return this.bankConfig.httpPort;
+  }
+
+  private constructor(
+    private globalTestState: GlobalTestState,
+    private bankConfig: BankConfig,
+    private configFile: string,
+  ) {}
+
+  async start(): Promise<void> {
+    this.proc = this.globalTestState.spawnService(
+      `taler-bank-manage -c "${this.configFile}" serve-http`,
+      "bank",
+    );
+  }
+
+  async pingUntilAvailable(): Promise<void> {
+    const url = `http://localhost:${this.bankConfig.httpPort}/config`;
+    while (true) {
+      try {
+        console.log("pinging bank");
+        const resp = await axios.get(url);
+        return;
+      } catch (e) {
+        console.log("bank not ready:", e.toString());
+        await delay(1000);
+      }
+    }
+  }
+
+  async createAccount(username: string, password: string): Promise<void> {
+    const url = 
`http://localhost:${this.bankConfig.httpPort}/testing/register`;
+    await axios.post(url, {
+      username,
+      password,
+    });
+  }
+
+  async createRandomBankUser(): Promise<BankUser> {
+    const bankUser: BankUser = {
+      username:
+        "user-" + talerCrypto.encodeCrock(talerCrypto.getRandomBytes(10)),
+      password: "pw-" + 
talerCrypto.encodeCrock(talerCrypto.getRandomBytes(10)),
+    };
+    await this.createAccount(bankUser.username, bankUser.password);
+    return bankUser;
+  }
+
+  async createWithdrawalOperation(
+    bankUser: BankUser,
+    amount: string,
+  ): Promise<WithdrawalOperationInfo> {
+    const url = 
`http://localhost:${this.bankConfig.httpPort}/accounts/${bankUser.username}/withdrawals`;
+    const resp = await axios.post(
+      url,
+      {
+        amount,
+      },
+      {
+        auth: bankUser,
+      },
+    );
+    return codecForWithdrawalOperationInfo().decode(resp.data);
+  }
+
+  async confirmWithdrawalOperation(
+    bankUser: BankUser,
+    wopi: WithdrawalOperationInfo,
+  ): Promise<void> {
+    const url = 
`http://localhost:${this.bankConfig.httpPort}/accounts/${bankUser.username}/withdrawals/${wopi.withdrawal_id}/confirm`;
+    await axios.post(
+      url,
+      {},
+      {
+        auth: bankUser,
+      },
+    );
+  }
+}
+
+export interface BankUser {
+  username: string;
+  password: string;
+}
+
+export interface WithdrawalOperationInfo {
+  withdrawal_id: string;
+  taler_withdraw_uri: string;
+}
+
+const codecForWithdrawalOperationInfo = (): codec.Codec<
+  WithdrawalOperationInfo
+> =>
+  codec
+    .makeCodecForObject<WithdrawalOperationInfo>()
+    .property("withdrawal_id", codec.codecForString)
+    .property("taler_withdraw_uri", codec.codecForString)
+    .build("WithdrawalOperationInfo");
+
+export interface ExchangeConfig {
+  name: string;
+  currency: string;
+  roundUnit?: string;
+  httpPort: number;
+  database: string;
+}
+
+export interface ExchangeServiceInterface {
+  readonly baseUrl: string;
+  readonly port: number;
+  readonly name: string;
+  readonly masterPub: string;
+}
+
+export class ExchangeService implements ExchangeServiceInterface {
+  static create(gc: GlobalTestState, e: ExchangeConfig) {
+    const config = new Configuration();
+    config.setString("taler", "currency", e.currency);
+    config.setString(
+      "taler",
+      "currency_round_unit",
+      e.roundUnit ?? `${e.currency}:0.01`,
+    );
+    setPaths(config, gc.testDir + "/talerhome");
+
+    config.setString(
+      "exchange",
+      "keydir",
+      "${TALER_DATA_HOME}/exchange/live-keys/",
+    );
+    config.setString(
+      "exchage",
+      "revocation_dir",
+      "${TALER_DATA_HOME}/exchange/revocations",
+    );
+    config.setString("exchange", "max_keys_caching", "forever");
+    config.setString("exchange", "db", "postgres");
+    config.setString(
+      "exchange",
+      "master_priv_file",
+      "${TALER_DATA_HOME}/exchange/offline-keys/master.priv",
+    );
+    config.setString("exchange", "serve", "tcp");
+    config.setString("exchange", "port", `${e.httpPort}`);
+    config.setString("exchange", "port", `${e.httpPort}`);
+    config.setString("exchange", "signkey_duration", "4 weeks");
+    config.setString("exchange", "legal_duraction", "2 years");
+    config.setString("exchange", "lookahead_sign", "32 weeks 1 day");
+    config.setString("exchange", "lookahead_provide", "4 weeks 1 day");
+
+    for (let i = 2020; i < 2029; i++) {
+      config.setString(
+        "fees-x-taler-bank",
+        `wire-fee-${i}`,
+        `${e.currency}:0.01`,
+      );
+      config.setString(
+        "fees-x-taler-bank",
+        `closing-fee-${i}`,
+        `${e.currency}:0.01`,
+      );
+    }
+
+    config.setString("exchangedb-postgres", "config", e.database);
+
+    setCoin(config, coin_ct1(e.currency));
+    setCoin(config, coin_ct10(e.currency));
+    setCoin(config, coin_u1(e.currency));
+    setCoin(config, coin_u2(e.currency));
+    setCoin(config, coin_u4(e.currency));
+    setCoin(config, coin_u8(e.currency));
+    setCoin(config, coin_u10(e.currency));
+
+    const exchangeMasterKey = talerCrypto.createEddsaKeyPair();
+
+    config.setString(
+      "exchange",
+      "master_public_key",
+      talerCrypto.encodeCrock(exchangeMasterKey.eddsaPub),
+    );
+
+    const masterPrivFile = config
+      .getPath("exchange", "master_priv_file")
+      .required();
+
+    fs.mkdirSync(path.dirname(masterPrivFile), { recursive: true });
+
+    fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv));
+
+    console.log("writing key to", masterPrivFile);
+    console.log("pub is", talerCrypto.encodeCrock(exchangeMasterKey.eddsaPub));
+    console.log(
+      "priv is",
+      talerCrypto.encodeCrock(exchangeMasterKey.eddsaPriv),
+    );
+
+    const cfgFilename = gc.testDir + `/exchange-${e.name}.conf`;
+    config.write(cfgFilename);
+    return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey);
+  }
+
+  get masterPub() {
+    return talerCrypto.encodeCrock(this.keyPair.eddsaPub);
+  }
+
+  get port() {
+    return this.exchangeConfig.httpPort;
+  }
+
+  async setupTestBankAccount(
+    bc: BankService,
+    localName: string,
+    accountName: string,
+    password: string,
+  ): Promise<void> {
+    await bc.createAccount(accountName, password);
+    const config = Configuration.load(this.configFilename);
+    config.setString(
+      `exchange-account-${localName}`,
+      "wire_response",
+      `\${TALER_DATA_HOME}/exchange/account-${localName}.json`,
+    );
+    config.setString(
+      `exchange-account-${localName}`,
+      "payto_uri",
+      `payto://x-taler-bank/localhost/${accountName}`,
+    );
+    config.setString(`exchange-account-${localName}`, "enable_credit", "yes");
+    config.setString(`exchange-account-${localName}`, "enable_debit", "yes");
+    config.setString(
+      `exchange-account-${localName}`,
+      "wire_gateway_url",
+      `http://localhost:${bc.port}/taler-wire-gateway/${accountName}/`,
+    );
+    config.setString(
+      `exchange-account-${localName}`,
+      "wire_gateway_auth_method",
+      "basic",
+    );
+    config.setString(`exchange-account-${localName}`, "username", accountName);
+    config.setString(`exchange-account-${localName}`, "password", password);
+    config.write(this.configFilename);
+  }
+
+  exchangeHttpProc: ProcessWrapper | undefined;
+  exchangeWirewatchProc: ProcessWrapper | undefined;
+
+  constructor(
+    private globalState: GlobalTestState,
+    private exchangeConfig: ExchangeConfig,
+    private configFilename: string,
+    private keyPair: talerCrypto.EddsaKeyPair,
+  ) {}
+
+  get name() {
+    return this.exchangeConfig.name;
+  }
+
+  get baseUrl() {
+    return `http://localhost:${this.exchangeConfig.httpPort}/`;
+  }
+
+  async start(): Promise<void> {
+    await exec(`taler-exchange-dbinit -c "${this.configFilename}"`);
+    await exec(`taler-exchange-wire -c "${this.configFilename}"`);
+    await exec(`taler-exchange-keyup -c "${this.configFilename}"`);
+
+    this.exchangeWirewatchProc = this.globalState.spawnService(
+      `taler-exchange-wirewatch -c "${this.configFilename}"`,
+      `exchange-wirewatch-${this.name}`,
+    );
+
+    this.exchangeHttpProc = this.globalState.spawnService(
+      `taler-exchange-httpd -c "${this.configFilename}"`,
+      `exchange-httpd-${this.name}`,
+    );
+  }
+
+  async pingUntilAvailable(): Promise<void> {
+    const url = `http://localhost:${this.exchangeConfig.httpPort}/keys`;
+    while (true) {
+      try {
+        console.log("pinging exchange");
+        const resp = await axios.get(url);
+        console.log(resp.data);
+        return;
+      } catch (e) {
+        console.log("exchange not ready:", e.toString());
+        await delay(1000);
+      }
+    }
+  }
+}
+
+export interface MerchantConfig {
+  name: string;
+  currency: string;
+  httpPort: number;
+  database: string;
+}
+
+export class MerchantService {
+  proc: ProcessWrapper | undefined;
+
+  constructor(
+    private globalState: GlobalTestState,
+    private merchantConfig: MerchantConfig,
+    private configFilename: string,
+  ) {}
+
+  async start(): Promise<void> {
+    await exec(`taler-merchant-dbinit -c "${this.configFilename}"`);
+
+    this.proc = this.globalState.spawnService(
+      `taler-merchant-httpd -c "${this.configFilename}"`,
+      `merchant-${this.merchantConfig.name}`,
+    );
+  }
+
+  static async create(
+    gc: GlobalTestState,
+    mc: MerchantConfig,
+  ): Promise<MerchantService> {
+    const config = new Configuration();
+    config.setString("taler", "currency", mc.currency);
+
+    config.setString("merchant", "serve", "tcp");
+    config.setString("merchant", "port", `${mc.httpPort}`);
+    config.setString("merchant", "db", "postgres");
+    config.setString("exchangedb-postgres", "config", mc.database);
+
+    const cfgFilename = gc.testDir + `/merchant-${mc.name}.conf`;
+    config.write(cfgFilename);
+
+    return new MerchantService(gc, mc, cfgFilename);
+  }
+
+  addExchange(e: ExchangeServiceInterface): void {
+    const config = Configuration.load(this.configFilename);
+    config.setString(
+      `merchant-exchange-${e.name}`,
+      "exchange_base_url",
+      e.baseUrl,
+    );
+    config.setString(
+      `merchant-exchange-${e.name}`,
+      "currency",
+      this.merchantConfig.currency,
+    );
+    config.setString(`merchant-exchange-${e.name}`, "master_key", e.masterPub);
+    config.write(this.configFilename);
+  }
+
+  async addInstance(instanceConfig: MerchantInstanceConfig): Promise<void> {
+    if (!this.proc) {
+      throw Error("merchant must be running to add instance");
+    }
+    console.log("adding instance");
+    const url = 
`http://localhost:${this.merchantConfig.httpPort}/private/instances`;
+    await axios.post(url, {
+      payto_uris: instanceConfig.paytoUris,
+      id: instanceConfig.id,
+      name: instanceConfig.name,
+      address: instanceConfig.address ?? {},
+      jurisdiction: instanceConfig.jurisdiction ?? {},
+      default_max_wire_fee:
+        instanceConfig.defaultMaxWireFee ??
+        `${this.merchantConfig.currency}:1.0`,
+      default_wire_fee_amortization:
+        instanceConfig.defaultWireFeeAmortization ?? 3,
+      default_max_deposit_fee:
+        instanceConfig.defaultMaxDepositFee ??
+        `${this.merchantConfig.currency}:1.0`,
+      default_wire_transfer_delay: instanceConfig.defaultWireTransferDelay ?? {
+        d_ms: "forever",
+      },
+      default_pay_delay: instanceConfig.defaultPayDelay ?? { d_ms: "forever" },
+    });
+  }
+
+  async queryPrivateOrderStatus(instanceName: string, orderId: string) {
+    let url;
+    if (instanceName === "default") {
+      url = 
`http://localhost:${this.merchantConfig.httpPort}/private/orders/${orderId}`
+    } else {
+      url = 
`http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/private/orders/${orderId}`;
+    }
+    const resp = await axios.get(url);
+    return codecForMerchantOrderPrivateStatusResponse().decode(resp.data);
+  }
+
+  async createOrder(
+    instanceName: string,
+    req: PostOrderRequest,
+  ): Promise<PostOrderResponse> {
+    let url;
+    if (instanceName === "default") {
+      url = `http://localhost:${this.merchantConfig.httpPort}/private/orders`;
+    } else {
+      url = 
`http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/private/orders`;
+    }
+    const resp = await axios.post(url, req);
+    return codecForPostOrderResponse().decode(resp.data);
+  }
+
+  async pingUntilAvailable(): Promise<void> {
+    const url = `http://localhost:${this.merchantConfig.httpPort}/config`;
+    while (true) {
+      try {
+        console.log("pinging merchant");
+        const resp = await axios.get(url);
+        console.log(resp.data);
+        return;
+      } catch (e) {
+        console.log("merchant not ready", e.toString());
+        await delay(1000);
+      }
+    }
+  }
+}
+
+export interface MerchantInstanceConfig {
+  id: string;
+  name: string;
+  paytoUris: string[];
+  address?: unknown;
+  jurisdiction?: unknown;
+  defaultMaxWireFee?: string;
+  defaultMaxDepositFee?: string;
+  defaultWireFeeAmortization?: number;
+  defaultWireTransferDelay?: time.Duration;
+  defaultPayDelay?: time.Duration;
+}
+
+export function runTest(testMain: (gc: GlobalTestState) => Promise<void>) {
+  const main = async () => {
+    const gc = new GlobalTestState({
+      testDir: await makeTempDir(),
+    });
+    try {
+      await testMain(gc);
+    } finally {
+      if (process.env["TALER_TEST_KEEP"] !== "1") {
+        await gc.terminate();
+        console.log("test logs and config can be found under", gc.testDir);
+      }
+    }
+  };
+
+  main().catch((e) => {
+    console.error("FATAL: test failed with exception");
+    if (e instanceof Error) {
+      console.error(e);
+    } else {
+      console.error(e);
+    }
+
+    if (process.env["TALER_TEST_KEEP"] !== "1") {
+      process.exit(1);
+    }
+  });
+}
+
+function shellWrap(s: string) {
+  return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'";
+}
+
+export class WalletCli {
+  constructor(private globalTestState: GlobalTestState) {}
+
+  async apiRequest(
+    request: string,
+    payload: Record<string, unknown>,
+  ): Promise<walletCoreApi.CoreApiResponse> {
+    const wdb = this.globalTestState.testDir + "/walletdb.json";
+    const resp = await sh(
+      `taler-wallet-cli --no-throttle --wallet-db '${wdb}' api '${request}' 
${shellWrap(
+        JSON.stringify(payload),
+      )}`,
+    );
+    console.log(resp);
+    return JSON.parse(resp) as walletCoreApi.CoreApiResponse;
+  }
+
+  async runUntilDone(): Promise<void> {
+    const wdb = this.globalTestState.testDir + "/walletdb.json";
+    await sh(`taler-wallet-cli --no-throttle --wallet-db ${wdb} 
run-until-done`);
+  }
+
+  async runPending(): Promise<void> {
+    const wdb = this.globalTestState.testDir + "/walletdb.json";
+    await sh(`taler-wallet-cli --no-throttle --wallet-db ${wdb} run-pending`);
+  }
+}
diff --git a/packages/taler-integrationtests/src/helpers.ts 
b/packages/taler-integrationtests/src/helpers.ts
new file mode 100644
index 00000000..01362370
--- /dev/null
+++ b/packages/taler-integrationtests/src/helpers.ts
@@ -0,0 +1,157 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Helpers to create typical test environments.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports
+ */
+import {
+  GlobalTestState,
+  DbInfo,
+  ExchangeService,
+  WalletCli,
+  MerchantService,
+  setupDb,
+  BankService,
+} from "./harness";
+import { AmountString } from "taler-wallet-core/lib/types/talerTypes";
+
+export interface SimpleTestEnvironment {
+  commonDb: DbInfo;
+  bank: BankService;
+  exchange: ExchangeService;
+  merchant: MerchantService;
+  wallet: WalletCli;
+}
+
+/**
+ * Run a test case with a simple TESTKUDOS Taler environment, consisting
+ * of one exchange, one bank and one merchant.
+ */
+export async function createSimpleTestkudosEnvironment(
+  t: GlobalTestState,
+): Promise<SimpleTestEnvironment> {
+  const db = await setupDb(t);
+
+  const bank = await BankService.create(t, {
+    allowRegistrations: true,
+    currency: "TESTKUDOS",
+    database: db.connStr,
+    httpPort: 8082,
+    suggestedExchange: "http://localhost:8081/";,
+    suggestedExchangePayto: "payto://x-taler-bank/MyExchange",
+  });
+
+  await bank.start();
+
+  await bank.pingUntilAvailable();
+
+  const exchange = ExchangeService.create(t, {
+    name: "testexchange-1",
+    currency: "TESTKUDOS",
+    httpPort: 8081,
+    database: db.connStr,
+  });
+
+  await exchange.setupTestBankAccount(bank, "1", "MyExchange", "x");
+
+  await exchange.start();
+  await exchange.pingUntilAvailable();
+
+  const merchant = await MerchantService.create(t, {
+    name: "testmerchant-1",
+    currency: "TESTKUDOS",
+    httpPort: 8083,
+    database: db.connStr,
+  });
+
+  merchant.addExchange(exchange);
+
+  await merchant.start();
+  await merchant.pingUntilAvailable();
+
+  await merchant.addInstance({
+    id: "minst1",
+    name: "minst1",
+    paytoUris: ["payto://x-taler-bank/minst1"],
+  });
+
+  await merchant.addInstance({
+    id: "default",
+    name: "Default Instance",
+    paytoUris: [`payto://x-taler-bank/merchant-default`],
+  });
+
+  console.log("setup done!");
+
+  const wallet = new WalletCli(t);
+
+  return {
+    commonDb: db,
+    exchange,
+    merchant,
+    wallet,
+    bank,
+  };
+}
+
+/**
+ * Withdraw balance.
+ */
+export async function withdrawViaBank(t: GlobalTestState, p: {
+  wallet: WalletCli;
+  bank: BankService;
+  exchange: ExchangeService;
+  amount: AmountString;
+}): Promise<void> {
+
+  const { wallet, bank, exchange, amount } = p;
+
+  const user = await bank.createRandomBankUser();
+  const wop = await bank.createWithdrawalOperation(user, amount);
+
+  // Hand it to the wallet
+
+  const r1 = await wallet.apiRequest("getWithdrawalDetailsForUri", {
+    talerWithdrawUri: wop.taler_withdraw_uri,
+  });
+  t.assertTrue(r1.type === "response");
+
+  await wallet.runPending();
+
+  // Confirm it
+
+  await bank.confirmWithdrawalOperation(user, wop);
+
+  // Withdraw
+
+  const r2 = await wallet.apiRequest("acceptBankIntegratedWithdrawal", {
+    exchangeBaseUrl: exchange.baseUrl,
+    talerWithdrawUri: wop.taler_withdraw_uri,
+  });
+  t.assertTrue(r2.type === "response");
+  await wallet.runUntilDone();
+
+  // Check balance
+
+  const balApiResp = await wallet.apiRequest("getBalances", {});
+  t.assertTrue(balApiResp.type === "response");
+}
diff --git a/packages/taler-integrationtests/src/merchantApiTypes.ts 
b/packages/taler-integrationtests/src/merchantApiTypes.ts
new file mode 100644
index 00000000..412b9bb8
--- /dev/null
+++ b/packages/taler-integrationtests/src/merchantApiTypes.ts
@@ -0,0 +1,217 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Test harness for various GNU Taler components.
+ * Also provides a fault-injection proxy.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports
+ */
+import {
+  codec,
+  talerTypes,
+  time,
+} from "taler-wallet-core";
+
+
+export interface PostOrderRequest {
+  // The order must at least contain the minimal
+  // order detail, but can override all
+  order: Partial<talerTypes.ContractTerms>;
+
+  // if set, the backend will then set the refund deadline to the current
+  // time plus the specified delay.
+  refund_delay?: time.Duration;
+
+  // specifies the payment target preferred by the client. Can be used
+  // to select among the various (active) wire methods supported by the 
instance.
+  payment_target?: string;
+
+  // FIXME: some fields are missing
+
+  // Should a token for claiming the order be generated?
+  // False can make sense if the ORDER_ID is sufficiently
+  // high entropy to prevent adversarial claims (like it is
+  // if the backend auto-generates one). Default is 'true'.
+  create_token?: boolean;
+}
+
+export type ClaimToken = string;
+
+export interface PostOrderResponse {
+  order_id: string;
+  token?: ClaimToken;
+}
+
+export const codecForPostOrderResponse = (): codec.Codec<PostOrderResponse> =>
+  codec
+    .makeCodecForObject<PostOrderResponse>()
+    .property("order_id", codec.codecForString)
+    .property("token", codec.makeCodecOptional(codec.codecForString))
+    .build("PostOrderResponse");
+
+export const codecForCheckPaymentPaidResponse = (): codec.Codec<
+  CheckPaymentPaidResponse
+> =>
+  codec
+    .makeCodecForObject<CheckPaymentPaidResponse>()
+    .property("order_status", codec.makeCodecForConstString("paid"))
+    .property("refunded", codec.codecForBoolean)
+    .property("wired", codec.codecForBoolean)
+    .property("deposit_total", codec.codecForString)
+    .property("exchange_ec", codec.codecForNumber)
+    .property("exchange_hc", codec.codecForNumber)
+    .property("refund_amount", codec.codecForString)
+    .property("contract_terms", talerTypes.codecForContractTerms())
+    // FIXME: specify
+    .property("wire_details", codec.codecForAny)
+    .property("wire_reports", codec.codecForAny)
+    .property("refund_details", codec.codecForAny)
+    .build("CheckPaymentPaidResponse");
+
+export const codecForCheckPaymentUnpaidResponse = (): codec.Codec<
+  CheckPaymentUnpaidResponse
+> =>
+  codec
+    .makeCodecForObject<CheckPaymentUnpaidResponse>()
+    .property("order_status", codec.makeCodecForConstString("unpaid"))
+    .property("taler_pay_uri", codec.codecForString)
+    .property(
+      "already_paid_order_id",
+      codec.makeCodecOptional(codec.codecForString),
+    )
+    .build("CheckPaymentPaidResponse");
+
+export const codecForMerchantOrderPrivateStatusResponse = (): codec.Codec<
+  MerchantOrderPrivateStatusResponse
+> =>
+  codec
+    .makeCodecForUnion<MerchantOrderPrivateStatusResponse>()
+    .discriminateOn("order_status")
+    .alternative("paid", codecForCheckPaymentPaidResponse())
+    .alternative("unpaid", codecForCheckPaymentUnpaidResponse())
+    .build("MerchantOrderPrivateStatusResponse");
+
+export type MerchantOrderPrivateStatusResponse =
+  | CheckPaymentPaidResponse
+  | CheckPaymentUnpaidResponse;
+
+export interface CheckPaymentPaidResponse {
+  // did the customer pay for this contract
+  order_status: "paid";
+
+  // Was the payment refunded (even partially)
+  refunded: boolean;
+
+  // Did the exchange wire us the funds
+  wired: boolean;
+
+  // Total amount the exchange deposited into our bank account
+  // for this contract, excluding fees.
+  deposit_total: talerTypes.AmountString;
+
+  // Numeric error code indicating errors the exchange
+  // encountered tracking the wire transfer for this purchase (before
+  // we even got to specific coin issues).
+  // 0 if there were no issues.
+  exchange_ec: number;
+
+  // HTTP status code returned by the exchange when we asked for
+  // information to track the wire transfer for this purchase.
+  // 0 if there were no issues.
+  exchange_hc: number;
+
+  // Total amount that was refunded, 0 if refunded is false.
+  refund_amount: talerTypes.AmountString;
+
+  // Contract terms
+  contract_terms: talerTypes.ContractTerms;
+
+  // Ihe wire transfer status from the exchange for this order if available, 
otherwise empty array
+  wire_details: TransactionWireTransfer[];
+
+  // Reports about trouble obtaining wire transfer details, empty array if no 
trouble were encountered.
+  wire_reports: TransactionWireReport[];
+
+  // The refund details for this order.  One entry per
+  // refunded coin; empty array if there are no refunds.
+  refund_details: RefundDetails[];
+}
+
+export interface CheckPaymentUnpaidResponse {
+  order_status: "unpaid";
+
+  // URI that the wallet must process to complete the payment.
+  taler_pay_uri: string;
+
+  // Alternative order ID which was paid for already in the same session.
+  // Only given if the same product was purchased before in the same session.
+  already_paid_order_id?: string;
+
+  // We do we NOT return the contract terms here because they may not
+  // exist in case the wallet did not yet claim them.
+}
+
+export interface RefundDetails {
+  // Reason given for the refund
+  reason: string;
+
+  // when was the refund approved
+  timestamp: time.Timestamp;
+
+  // Total amount that was refunded (minus a refund fee).
+  amount: talerTypes.AmountString;
+}
+
+export interface TransactionWireTransfer {
+  // Responsible exchange
+  exchange_url: string;
+
+  // 32-byte wire transfer identifier
+  wtid: string;
+
+  // execution time of the wire transfer
+  execution_time: time.Timestamp;
+
+  // Total amount that has been wire transfered
+  // to the merchant
+  amount: talerTypes.AmountString;
+
+  // Was this transfer confirmed by the merchant via the
+  // POST /transfers API, or is it merely claimed by the exchange?
+  confirmed: boolean;
+}
+
+export interface TransactionWireReport {
+  // Numerical error code
+  code: number;
+
+  // Human-readable error description
+  hint: string;
+
+  // Numerical error code from the exchange.
+  exchange_ec: number;
+
+  // HTTP status code received from the exchange.
+  exchange_hc: number;
+
+  // Public key of the coin for which we got the exchange error.
+  coin_pub: talerTypes.CoinPublicKeyString;
+}
diff --git a/packages/taler-integrationtests/src/test-payment-fault.ts 
b/packages/taler-integrationtests/src/test-payment-fault.ts
new file mode 100644
index 00000000..2e044888
--- /dev/null
+++ b/packages/taler-integrationtests/src/test-payment-fault.ts
@@ -0,0 +1,194 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Sample fault injection test.
+ */
+
+/**
+ * Imports.
+ */
+import {
+  runTest,
+  GlobalTestState,
+  MerchantService,
+  ExchangeService,
+  setupDb,
+  BankService,
+  WalletCli,
+} from "./harness";
+import { FaultInjectedExchangeService, FaultInjectionRequestContext, 
FaultInjectionResponseContext } from "./faultInjection";
+import { CoreApiResponse } from "taler-wallet-core/lib/walletCoreApiHandler";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+runTest(async (t: GlobalTestState) => {
+  // Set up test environment
+
+  const db = await setupDb(t);
+
+  const bank = await BankService.create(t, {
+    allowRegistrations: true,
+    currency: "TESTKUDOS",
+    database: db.connStr,
+    httpPort: 8082,
+    suggestedExchange: "http://localhost:8091/";,
+    suggestedExchangePayto: "payto://x-taler-bank/MyExchange",
+  });
+
+  await bank.start();
+
+  await bank.pingUntilAvailable();
+
+  const exchange = ExchangeService.create(t, {
+    name: "testexchange-1",
+    currency: "TESTKUDOS",
+    httpPort: 8081,
+    database: db.connStr,
+  });
+
+  await exchange.setupTestBankAccount(bank, "1", "MyExchange", "x");
+
+  await exchange.start();
+  await exchange.pingUntilAvailable();
+
+  const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091);
+
+  // Print all requests to the exchange
+  faultyExchange.faultProxy.addFault({
+    modifyRequest(ctx: FaultInjectionRequestContext) {
+      console.log("got request", ctx);
+    },
+    modifyResponse(ctx: FaultInjectionResponseContext) {
+      console.log("got response", ctx);
+    }
+  });
+
+  const merchant = await MerchantService.create(t, {
+    name: "testmerchant-1",
+    currency: "TESTKUDOS",
+    httpPort: 8083,
+    database: db.connStr,
+  });
+
+  merchant.addExchange(faultyExchange);
+
+  await merchant.start();
+  await merchant.pingUntilAvailable();
+
+  await merchant.addInstance({
+    id: "default",
+    name: "Default Instance",
+    paytoUris: [`payto://x-taler-bank/merchant-default`],
+  });
+
+  console.log("setup done!");
+
+  const wallet = new WalletCli(t);
+
+  // Create withdrawal operation
+
+  const user = await bank.createRandomBankUser();
+  const wop = await bank.createWithdrawalOperation(user, "TESTKUDOS:20");
+
+  // Hand it to the wallet
+
+  const r1 = await wallet.apiRequest("getWithdrawalDetailsForUri", {
+    talerWithdrawUri: wop.taler_withdraw_uri,
+  });
+  t.assertTrue(r1.type === "response");
+
+  await wallet.runPending();
+
+  // Confirm it
+
+  await bank.confirmWithdrawalOperation(user, wop);
+
+  // Withdraw
+
+  const r2 = await wallet.apiRequest("acceptBankIntegratedWithdrawal", {
+    exchangeBaseUrl: faultyExchange.baseUrl,
+    talerWithdrawUri: wop.taler_withdraw_uri,
+  });
+  t.assertTrue(r2.type === "response");
+  await wallet.runUntilDone();
+
+  // Check balance
+
+  const balApiResp = await wallet.apiRequest("getBalances", {});
+  t.assertTrue(balApiResp.type === "response");
+
+  // Set up order.
+
+  const orderResp = await merchant.createOrder("default", {
+    order: {
+      summary: "Buy me!",
+      amount: "TESTKUDOS:5",
+      fulfillment_url: "taler://fulfillment-success/thx",
+    },
+  });
+
+  let orderStatus = await merchant.queryPrivateOrderStatus(
+    "default",
+    orderResp.order_id,
+  );
+
+  t.assertTrue(orderStatus.order_status === "unpaid");
+
+  // Make wallet pay for the order
+
+  let apiResp: CoreApiResponse;
+
+  apiResp = await wallet.apiRequest("preparePay", {
+    talerPayUri: orderStatus.taler_pay_uri,
+  });
+  t.assertTrue(apiResp.type === "response");
+
+  const proposalId = (apiResp.result as any).proposalId;
+
+  await wallet.runPending();
+
+  // Drop 10 responses from the exchange.
+  let faultCount = 0;
+  faultyExchange.faultProxy.addFault({
+    modifyResponse(ctx: FaultInjectionResponseContext) {
+      if (faultCount < 10) {
+        faultCount++;
+        ctx.dropResponse = true;
+      }
+    }
+  });
+
+  // confirmPay won't work, as the exchange is unreachable
+
+  apiResp = await wallet.apiRequest("confirmPay", {
+    // FIXME: should be validated, don't cast!
+    proposalId: proposalId,
+  });
+  t.assertTrue(apiResp.type === "error");
+
+  await wallet.runUntilDone();
+
+  // Check if payment was successful.
+
+  orderStatus = await merchant.queryPrivateOrderStatus(
+    "default",
+    orderResp.order_id,
+  );
+
+  t.assertTrue(orderStatus.order_status === "paid");
+});
diff --git a/packages/taler-integrationtests/src/test-payment.ts 
b/packages/taler-integrationtests/src/test-payment.ts
new file mode 100644
index 00000000..fe44c183
--- /dev/null
+++ b/packages/taler-integrationtests/src/test-payment.ts
@@ -0,0 +1,80 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { runTest, GlobalTestState } from "./harness";
+import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+runTest(async (t: GlobalTestState) => {
+  // Set up test environment
+
+  const {
+    wallet,
+    bank,
+    exchange,
+    merchant,
+  } = await createSimpleTestkudosEnvironment(t);
+
+  // Withdraw digital cash into the wallet.
+
+  await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+
+  // Set up order.
+
+  const orderResp = await merchant.createOrder("default", {
+    order: {
+      summary: "Buy me!",
+      amount: "TESTKUDOS:5",
+      fulfillment_url: "taler://fulfillment-success/thx",
+    },
+  });
+
+  let orderStatus = await merchant.queryPrivateOrderStatus(
+    "default",
+    orderResp.order_id,
+  );
+
+  t.assertTrue(orderStatus.order_status === "unpaid")
+
+  // Make wallet pay for the order
+
+  const r1 = await wallet.apiRequest("preparePay", {
+    talerPayUri: orderStatus.taler_pay_uri,
+  });
+  t.assertTrue(r1.type === "response");
+
+  const r2 = await wallet.apiRequest("confirmPay", {
+    // FIXME: should be validated, don't cast!
+    proposalId: (r1.result as any).proposalId,
+  });
+  t.assertTrue(r2.type === "response");
+
+  // Check if payment was successful.
+
+  orderStatus = await merchant.queryPrivateOrderStatus(
+    "default",
+    orderResp.order_id,
+  );
+
+  t.assertTrue(orderStatus.order_status === "paid");
+
+  await t.terminate();
+});
diff --git a/packages/taler-integrationtests/src/test-withdrawal.ts 
b/packages/taler-integrationtests/src/test-withdrawal.ts
new file mode 100644
index 00000000..67720a8a
--- /dev/null
+++ b/packages/taler-integrationtests/src/test-withdrawal.ts
@@ -0,0 +1,68 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { runTest, GlobalTestState } from "./harness";
+import { createSimpleTestkudosEnvironment } from "./helpers";
+import { walletTypes } from "taler-wallet-core";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+runTest(async (t: GlobalTestState) => {
+  
+  // Set up test environment
+
+  const { wallet, bank, exchange } = await createSimpleTestkudosEnvironment(t);
+
+  // Create a withdrawal operation
+
+  const user = await bank.createRandomBankUser();
+  const wop = await bank.createWithdrawalOperation(user, "TESTKUDOS:10");
+
+  // Hand it to the wallet
+
+  const r1 = await wallet.apiRequest("getWithdrawalDetailsForUri", {
+    talerWithdrawUri: wop.taler_withdraw_uri,
+  });
+  t.assertTrue(r1.type === "response");
+
+  await wallet.runPending();
+
+  // Confirm it
+
+  await bank.confirmWithdrawalOperation(user, wop);
+
+  // Withdraw
+
+  const r2 = await wallet.apiRequest("acceptBankIntegratedWithdrawal", {
+    exchangeBaseUrl: exchange.baseUrl,
+    talerWithdrawUri: wop.taler_withdraw_uri,
+  });
+  t.assertTrue(r2.type === "response");
+  await wallet.runUntilDone();
+
+  // Check balance
+
+  const balApiResp = await wallet.apiRequest("getBalances", {});
+  t.assertTrue(balApiResp.type === "response");
+  const balResp = 
walletTypes.codecForBalancesResponse().decode(balApiResp.result);
+  t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available)
+
+  await t.terminate();
+});
diff --git a/packages/taler-integrationtests/testrunner 
b/packages/taler-integrationtests/testrunner
new file mode 100755
index 00000000..28262450
--- /dev/null
+++ b/packages/taler-integrationtests/testrunner
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+
+# Simple test runner for the wallet integration tests.
+#
+# Usage: $0 TESTGLOB
+#
+# The TESTGLOB can be used to select which test cases to execute
+
+set -eu
+
+if [ "$#" -ne 1 ]; then
+    echo "Usage: $0 TESTGLOB"
+    exit 1
+fi
+
+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
+
+cd $DIR
+
+./node_modules/.bin/tsc
+
+export ESM_OPTIONS='{"sourceMap": true}'
+
+shopt -s extglob
+
+num_exec=0
+num_fail=0
+num_succ=0
+
+# Glob tests
+for file in lib/$1?(.js); do
+  case "$file" in
+    *.js)
+      echo "executing test $file"
+      ret=0
+      node -r source-map-support/register -r esm $file || ret=$?
+      num_exec=$((num_exec+1))
+      case $ret in
+        0)
+          num_succ=$((num_succ+1))
+          ;;
+        *)
+          num_fail=$((num_fail+1))
+          ;;
+      esac
+      ;;
+    *)
+      continue
+    ;;
+  esac
+done
+
+echo "-----------------------------------"
+echo "Tests finished"
+echo "$num_succ/$num_exec tests succeeded"
+echo "-----------------------------------"
+
+if [[ $num_fail = 0 ]]; then
+  exit 0
+else
+  exit 1
+fi
+
diff --git a/packages/taler-integrationtests/tsconfig.json 
b/packages/taler-integrationtests/tsconfig.json
new file mode 100644
index 00000000..07e8ab0b
--- /dev/null
+++ b/packages/taler-integrationtests/tsconfig.json
@@ -0,0 +1,32 @@
+{
+  "compileOnSave": true,
+  "compilerOptions": {
+    "composite": true,
+    "declaration": true,
+    "declarationMap": false,
+    "target": "ES6",
+    "module": "ESNext",
+    "moduleResolution": "node",
+    "sourceMap": true,
+    "lib": ["es6"],
+    "types": ["node"],
+    "noImplicitReturns": true,
+    "noFallthroughCasesInSwitch": true,
+    "strict": true,
+    "strictPropertyInitialization": false,
+    "outDir": "lib",
+    "noImplicitAny": true,
+    "noImplicitThis": true,
+    "incremental": true,
+    "esModuleInterop": true,
+    "importHelpers": true,
+    "rootDir": "./src",
+    "typeRoots": ["./node_modules/@types"]
+  },
+  "references": [
+    {
+      "path": "../idb-bridge/"
+    }
+  ],
+  "include": ["src/**/*"]
+}
diff --git a/packages/taler-wallet-android/src/index.ts 
b/packages/taler-wallet-android/src/index.ts
index d0001e99..c949a477 100644
--- a/packages/taler-wallet-android/src/index.ts
+++ b/packages/taler-wallet-android/src/index.ts
@@ -113,6 +113,7 @@ export class AndroidHttpLib implements 
httpLib.HttpRequestLibrary {
         requestUrl: "",
         headers,
         status: msg.status,
+        requestMethod: "FIXME",
         json: async () => JSON.parse(msg.responseText),
         text: async () => msg.responseText,
       };
diff --git a/packages/taler-wallet-cli/bin/taler-wallet-cli 
b/packages/taler-wallet-cli/bin/taler-wallet-cli
index 87151402..756de202 100755
--- a/packages/taler-wallet-cli/bin/taler-wallet-cli
+++ b/packages/taler-wallet-cli/bin/taler-wallet-cli
@@ -4,4 +4,4 @@ try {
 } catch (e) {
   // Do nothing.
 }
-require('../dist/taler-wallet-cli.js')
+require('../dist/taler-wallet-cli.js').walletCli.run();
diff --git a/packages/taler-wallet-cli/src/index.ts 
b/packages/taler-wallet-cli/src/index.ts
index c8e517e5..ae5371ec 100644
--- a/packages/taler-wallet-cli/src/index.ts
+++ b/packages/taler-wallet-cli/src/index.ts
@@ -34,6 +34,12 @@ import {
   NodeHttpLib,
 } from "taler-wallet-core";
 import * as clk from "./clk";
+import { NodeThreadCryptoWorkerFactory } from 
"taler-wallet-core/lib/crypto/workers/nodeThreadWorker";
+import { CryptoApi } from "taler-wallet-core/lib/crypto/workers/cryptoApi";
+
+// This module also serves as the entry point for the crypto
+// thread worker, and thus must expose these two handlers.
+export { handleWorkerError, handleWorkerMessage  } from "taler-wallet-core";
 
 const logger = new Logger("taler-wallet-cli.ts");
 
@@ -109,7 +115,7 @@ function printVersion(): void {
   process.exit(0);
 }
 
-const walletCli = clk
+export const walletCli = clk
   .program("wallet", {
     help: "Command line interface for the GNU Taler wallet.",
   })
@@ -637,4 +643,9 @@ testCli.subcommand("vectors", "vectors").action(async 
(args) => {
   testvectors.printTestVectors();
 });
 
-walletCli.run();
+testCli.subcommand("cryptoworker", "cryptoworker").action(async (args) => {
+  const workerFactory = new NodeThreadCryptoWorkerFactory();
+  const cryptoApi = new CryptoApi(workerFactory);
+  const res = await cryptoApi.hashString("foo");
+  console.log(res);
+});
diff --git a/packages/taler-wallet-core/package.json 
b/packages/taler-wallet-core/package.json
index 20240bab..68bf45d0 100644
--- a/packages/taler-wallet-core/package.json
+++ b/packages/taler-wallet-core/package.json
@@ -34,25 +34,27 @@
     "@typescript-eslint/eslint-plugin": "^3.6.1",
     "@typescript-eslint/parser": "^3.6.1",
     "ava": "^3.10.1",
+    "dts-bundle-generator": "^5.3.0",
     "eslint": "^7.4.0",
     "eslint-config-airbnb-typescript": "^8.0.2",
     "eslint-plugin-import": "^2.22.0",
     "eslint-plugin-jsx-a11y": "^6.3.1",
     "eslint-plugin-react": "^7.20.3",
     "eslint-plugin-react-hooks": "^4.0.8",
+    "esm": "^3.2.25",
     "jed": "^1.1.1",
     "moment": "^2.27.0",
     "nyc": "^15.1.0",
     "po2json": "^0.4.5",
     "pogen": "workspace:*",
     "prettier": "^2.0.5",
+    "rimraf": "^3.0.2",
+    "rollup": "^2.23.0",
+    "rollup-plugin-sourcemaps": "^0.6.2",
     "source-map-resolve": "^0.6.0",
     "structured-clone": "^0.2.2",
     "typedoc": "^0.17.8",
-    "typescript": "^3.9.7",
-    "rollup": "^2.23.0",
-    "esm": "^3.2.25",
-    "rimraf": "^3.0.2"
+    "typescript": "^3.9.7"
   },
   "dependencies": {
     "@types/node": "^14.0.27",
@@ -63,7 +65,9 @@
     "tslib": "^2.0.0"
   },
   "ava": {
-    "require": ["esm"],
+    "require": [
+      "esm"
+    ],
     "files": [
       "src/**/*-test.*"
     ],
diff --git a/packages/taler-wallet-core/rollup.config.js 
b/packages/taler-wallet-core/rollup.config.js
index 2f0a86b2..bcc8e5b2 100644
--- a/packages/taler-wallet-core/rollup.config.js
+++ b/packages/taler-wallet-core/rollup.config.js
@@ -4,13 +4,14 @@ import nodeResolve from "@rollup/plugin-node-resolve";
 import json from "@rollup/plugin-json";
 import builtins from "builtin-modules";
 import pkg from "./package.json";
+import sourcemaps from 'rollup-plugin-sourcemaps';
 
 export default {
   input: "lib/index.js",
   output: {
     file: pkg.main,
     format: "cjs",
-    sourcemap: false,
+    sourcemap: true,
   },
   external: builtins,
   plugins: [
@@ -18,11 +19,13 @@ export default {
       preferBuiltins: true,
     }),
 
+    sourcemaps(),
+
     commonjs({
       include: [/node_modules/, /dist/],
       extensions: [".js"],
       ignoreGlobal: false,
-      sourceMap: false,
+      sourceMap: true,
     }),
 
     json(),
diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts 
b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts
index a272d572..20d13a3f 100644
--- a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts
+++ b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts
@@ -1,17 +1,17 @@
 /*
- This file is part of TALER
+ This file is part of GNU Taler
  (C) 2016 GNUnet e.V.
 
- TALER is free software; you can redistribute it and/or modify it under the
+ 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.
 
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ 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
- TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
+ GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
  */
 
 /**
@@ -46,6 +46,7 @@ import {
 
 import * as timer from "../../util/timer";
 import { Logger } from "../../util/logging";
+import { walletCoreApi } from "../..";
 
 const logger = new Logger("cryptoApi.ts");
 
@@ -182,7 +183,7 @@ export class CryptoApi {
     };
     this.resetWorkerTimeout(ws);
     work.startTime = timer.performanceNow();
-    setTimeout(() => worker.postMessage(msg), 0);
+    timer.after(0, () => worker.postMessage(msg));
   }
 
   resetWorkerTimeout(ws: WorkerState): void {
@@ -198,6 +199,7 @@ export class CryptoApi {
       }
     };
     ws.terminationTimerHandle = timer.after(15 * 1000, destroy);
+    //ws.terminationTimerHandle.unref();
   }
 
   handleWorkerError(ws: WorkerState, e: any): void {
diff --git a/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts 
b/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts
index 6c9dfc56..d4d85833 100644
--- a/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts
+++ b/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts
@@ -21,6 +21,9 @@ import { CryptoWorkerFactory } from "./cryptoApi";
 import { CryptoWorker } from "./cryptoWorker";
 import os from "os";
 import { CryptoImplementation } from "./cryptoImplementation";
+import { Logger } from "../../util/logging";
+
+const logger = new Logger("nodeThreadWorker.ts");
 
 const f = __filename;
 
@@ -37,16 +40,22 @@ const workerCode = `
   try {
     tw = require("${f}");
   } catch (e) {
-    console.log("could not load from ${f}");
+    console.warn("could not load from ${f}");
   }
   if (!tw) {
     try {
       tw = require("taler-wallet-android");
     } catch (e) {
-      console.log("could not load taler-wallet-android either");
+      console.warn("could not load taler-wallet-android either");
       throw e;
     }
   }
+  if (typeof tw.handleWorkerMessage !== "function") {
+    throw Error("module loaded for crypto worker lacks handleWorkerMessage");
+  }
+  if (typeof tw.handleWorkerError !== "function") {
+    throw Error("module loaded for crypto worker lacks handleWorkerError");
+  }
   parentPort.on("message", tw.handleWorkerMessage);
   parentPort.on("error", tw.handleWorkerError);
 `;
@@ -138,6 +147,9 @@ class NodeThreadCryptoWorker implements CryptoWorker {
   constructor() {
     // eslint-disable-next-line @typescript-eslint/no-var-requires
     const worker_threads = require("worker_threads");
+
+    logger.trace("starting node crypto worker");
+    
     this.nodeWorker = new worker_threads.Worker(workerCode, { eval: true });
     this.nodeWorker.on("error", (err: Error) => {
       console.error("error in node worker:", err);
@@ -145,6 +157,9 @@ class NodeThreadCryptoWorker implements CryptoWorker {
         this.onerror(err);
       }
     });
+    this.nodeWorker.on("exit", (err) => {
+      logger.trace(`worker exited with code ${err}`);
+    });
     this.nodeWorker.on("message", (v: any) => {
       if (this.onmessage) {
         this.onmessage(v);
diff --git a/packages/taler-wallet-core/src/headless/NodeHttpLib.ts 
b/packages/taler-wallet-core/src/headless/NodeHttpLib.ts
index d109c3b7..59730ab3 100644
--- a/packages/taler-wallet-core/src/headless/NodeHttpLib.ts
+++ b/packages/taler-wallet-core/src/headless/NodeHttpLib.ts
@@ -45,7 +45,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
   }
 
   private async req(
-    method: "post" | "get",
+    method: "POST" | "GET",
     url: string,
     body: any,
     opt?: HttpRequestOptions,
@@ -72,6 +72,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
           {
             httpStatusCode: resp.status,
             requestUrl: url,
+            requestMethod: method,
           },
         ),
       );
@@ -88,6 +89,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
             {
               httpStatusCode: resp.status,
               requestUrl: url,
+              requestMethod: method,
             },
           ),
         );
@@ -100,6 +102,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
             {
               httpStatusCode: resp.status,
               requestUrl: url,
+              requestMethod: method,
             },
           ),
         );
@@ -112,6 +115,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
     }
     return {
       requestUrl: url,
+      requestMethod: method,
       headers,
       status: resp.status,
       text: async () => resp.data,
@@ -120,7 +124,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
   }
 
   async get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
-    return this.req("get", url, undefined, opt);
+    return this.req("GET", url, undefined, opt);
   }
 
   async postJson(
@@ -128,6 +132,6 @@ export class NodeHttpLib implements HttpRequestLibrary {
     body: any,
     opt?: HttpRequestOptions,
   ): Promise<HttpResponse> {
-    return this.req("post", url, body, opt);
+    return this.req("POST", url, body, opt);
   }
 }
diff --git a/packages/taler-wallet-core/src/index.ts 
b/packages/taler-wallet-core/src/index.ts
index e70fc44f..5c4961bd 100644
--- a/packages/taler-wallet-core/src/index.ts
+++ b/packages/taler-wallet-core/src/index.ts
@@ -73,3 +73,10 @@ export * as i18n from "./i18n";
 export * as nodeThreadWorker from "./crypto/workers/nodeThreadWorker";
 
 export * as walletNotifications from "./types/notifications";
+
+export { Configuration } from "./util/talerconfig";
+
+export {
+  handleWorkerMessage,
+  handleWorkerError,
+} from "./crypto/workers/nodeThreadWorker";
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts 
b/packages/taler-wallet-core/src/operations/exchanges.ts
index ee49fddb..8967173c 100644
--- a/packages/taler-wallet-core/src/operations/exchanges.ts
+++ b/packages/taler-wallet-core/src/operations/exchanges.ts
@@ -112,6 +112,8 @@ async function updateExchangeWithKeys(
     return;
   }
 
+  logger.info("updating exchange /keys info");
+
   const keysUrl = new URL("keys", baseUrl);
   keysUrl.searchParams.set("cacheBreaker", 
WALLET_CACHE_BREAKER_CLIENT_VERSION);
 
@@ -121,6 +123,8 @@ async function updateExchangeWithKeys(
     codecForExchangeKeysJson(),
   );
 
+  logger.info("received /keys response");
+
   if (exchangeKeysJson.denoms.length === 0) {
     const opErr = makeErrorDetails(
       TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
@@ -152,12 +156,16 @@ async function updateExchangeWithKeys(
   const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value)
     .currency;
 
+  logger.trace("processing denominations");
+
   const newDenominations = await Promise.all(
     exchangeKeysJson.denoms.map((d) =>
       denominationRecordFromKeys(ws, baseUrl, d),
     ),
   );
 
+  logger.trace("done with processing denominations");
+
   const lastUpdateTimestamp = getTimestampNow();
 
   const recoupGroupId: string | undefined = undefined;
@@ -241,6 +249,8 @@ async function updateExchangeWithKeys(
       console.log("error while recouping coins:", e);
     });
   }
+
+  logger.trace("done updating exchange /keys");
 }
 
 async function updateExchangeFinalize(
diff --git a/packages/taler-wallet-core/src/operations/pay.ts 
b/packages/taler-wallet-core/src/operations/pay.ts
index f23e326f..0fa9e0a6 100644
--- a/packages/taler-wallet-core/src/operations/pay.ts
+++ b/packages/taler-wallet-core/src/operations/pay.ts
@@ -781,7 +781,7 @@ export async function submitPay(
   }
   const sessionId = purchase.lastSessionId;
 
-  console.log("paying with session ID", sessionId);
+  logger.trace("paying with session ID", sessionId);
 
   const payUrl = new URL(
     `orders/${purchase.contractData.orderId}/pay`,
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts 
b/packages/taler-wallet-core/src/operations/withdraw.ts
index 3b0aa009..9719772a 100644
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ b/packages/taler-wallet-core/src/operations/withdraw.ts
@@ -712,7 +712,9 @@ export async function getWithdrawalDetailsForUri(
   ws: InternalWalletState,
   talerWithdrawUri: string,
 ): Promise<WithdrawUriInfoResponse> {
+  logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
   const info = await getBankWithdrawalInfo(ws, talerWithdrawUri);
+  logger.trace(`got bank info`);
   if (info.suggestedExchange) {
     // FIXME: right now the exchange gets permanently added,
     // we might want to only temporarily add it.
diff --git a/packages/taler-wallet-core/src/types/walletTypes.ts 
b/packages/taler-wallet-core/src/types/walletTypes.ts
index 04f50f29..83275a0c 100644
--- a/packages/taler-wallet-core/src/types/walletTypes.ts
+++ b/packages/taler-wallet-core/src/types/walletTypes.ts
@@ -40,8 +40,11 @@ import {
   codecForString,
   makeCodecOptional,
   Codec,
+  makeCodecForList,
+  codecForBoolean,
 } from "../util/codec";
 import { AmountString } from "./talerTypes";
+import { codec } from "..";
 
 /**
  * Response for the create reserve request to the wallet.
@@ -164,6 +167,20 @@ export interface BalancesResponse {
   balances: Balance[];
 }
 
+export const codecForBalance = (): Codec<Balance> =>
+  makeCodecForObject<Balance>()
+    .property("available", codecForString)
+    .property("hasPendingTransactions", codecForBoolean)
+    .property("pendingIncoming", codecForString)
+    .property("pendingOutgoing", codecForString)
+    .property("requiresUserInput", codecForBoolean)
+    .build("Balance");
+
+export const codecForBalancesResponse = (): Codec<BalancesResponse> =>
+  makeCodecForObject<BalancesResponse>()
+    .property("balances", makeCodecForList(codecForBalance()))
+    .build("BalancesResponse");
+
 /**
  * For terseness.
  */
diff --git a/packages/taler-wallet-core/src/util/http.ts 
b/packages/taler-wallet-core/src/util/http.ts
index ad9f0293..72de2ed1 100644
--- a/packages/taler-wallet-core/src/util/http.ts
+++ b/packages/taler-wallet-core/src/util/http.ts
@@ -34,6 +34,7 @@ const logger = new Logger("http.ts");
  */
 export interface HttpResponse {
   requestUrl: string;
+  requestMethod: string;
   status: number;
   headers: Headers;
   json(): Promise<any>;
@@ -118,6 +119,8 @@ export async function readSuccessResponseJsonOrErrorCode<T>(
           "Error response did not contain error code",
           {
             requestUrl: httpResponse.requestUrl,
+            requestMethod: httpResponse.requestMethod,
+            httpStatusCode: httpResponse.status,
           },
         ),
       );
@@ -188,7 +191,9 @@ export async function readSuccessResponseTextOrErrorCode<T>(
           TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
           "Error response did not contain error code",
           {
+            httpStatusCode: httpResponse.status,
             requestUrl: httpResponse.requestUrl,
+            requestMethod: httpResponse.requestMethod,
           },
         ),
       );
@@ -217,7 +222,9 @@ export async function checkSuccessResponseOrThrow(
           TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
           "Error response did not contain error code",
           {
+            httpStatusCode: httpResponse.status,
             requestUrl: httpResponse.requestUrl,
+            requestMethod: httpResponse.requestMethod,
           },
         ),
       );
diff --git a/packages/taler-wallet-core/src/util/talerconfig-test.ts 
b/packages/taler-wallet-core/src/util/talerconfig-test.ts
new file mode 100644
index 00000000..71359fd3
--- /dev/null
+++ b/packages/taler-wallet-core/src/util/talerconfig-test.ts
@@ -0,0 +1,124 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports
+ */
+import test from "ava";
+import { pathsub, Configuration } from "./talerconfig";
+
+test("pathsub", (t) => {
+  t.assert("foo" === pathsub("foo", () => undefined));
+
+  t.assert("fo${bla}o" === pathsub("fo${bla}o", () => undefined));
+
+  const d: Record<string, string> = {
+    w: "world",
+    f: "foo",
+    "1foo": "x",
+    "foo_bar": "quux",
+  };
+
+  t.is(
+    pathsub("hello ${w}!", (v) => d[v]),
+    "hello world!",
+  );
+
+  t.is(
+    pathsub("hello ${w} ${w}!", (v) => d[v]),
+    "hello world world!",
+  );
+
+  t.is(
+    pathsub("hello ${x:-blabla}!", (v) => d[v]),
+    "hello blabla!",
+  );
+
+  // No braces
+  t.is(
+    pathsub("hello $w!", (v) => d[v]),
+    "hello world!",
+  );
+  t.is(
+    pathsub("hello $foo!", (v) => d[v]),
+    "hello $foo!",
+  );
+  t.is(
+    pathsub("hello $1foo!", (v) => d[v]),
+    "hello $1foo!",
+  );
+  t.is(
+    pathsub("hello $$ world!", (v) => d[v]),
+    "hello $$ world!",
+  );
+  t.is(
+    pathsub("hello $$ world!", (v) => d[v]),
+    "hello $$ world!",
+  );
+
+  t.is(
+    pathsub("hello $foo_bar!", (v) => d[v]),
+    "hello quux!",
+  );
+
+  // Recursive lookup in default
+  t.is(
+    pathsub("hello ${x:-${w}}!", (v) => d[v]),
+    "hello world!",
+  );
+
+  // No variables in variable name part
+  t.is(
+    pathsub("hello ${${w}:-x}!", (v) => d[v]),
+    "hello ${${w}:-x}!",
+  );
+
+  // Missing closing brace
+  t.is(
+    pathsub("hello ${w!", (v) => d[v]),
+    "hello ${w!",
+  );
+});
+
+test("path expansion", (t) => {
+  const config = new Configuration();
+  config.setString("paths", "taler_home", "foo/bar");
+  config.setString(
+    "paths",
+    "taler_data_home",
+    "$TALER_HOME/.local/share/taler/",
+  );
+  config.setString(
+    "exchange",
+    "master_priv_file",
+    "${TALER_DATA_HOME}/exchange/offline-keys/master.priv",
+  );
+  t.is(
+    config.getPath("exchange", "MaStER_priv_file").required(),
+    "foo/bar/.local/share/taler//exchange/offline-keys/master.priv",
+  );
+});
+
+test("recursive path resolution", (t) => {
+  console.log("recursive test");
+  const config = new Configuration();
+  config.setString("paths", "a", "x${b}");
+  config.setString("paths", "b", "y${a}");
+  config.setString("foo", "x", "z${a}");
+  t.throws(() => {
+    config.getPath("foo", "a").required();
+  });
+});
diff --git a/packages/taler-wallet-core/src/util/talerconfig.ts 
b/packages/taler-wallet-core/src/util/talerconfig.ts
index ec08c352..61bb6d20 100644
--- a/packages/taler-wallet-core/src/util/talerconfig.ts
+++ b/packages/taler-wallet-core/src/util/talerconfig.ts
@@ -25,6 +25,8 @@
  */
 import { AmountJson } from "./amounts";
 import * as Amounts from "./amounts";
+import fs from "fs";
+import { acceptExchangeTermsOfService } from "../operations/exchanges";
 
 export class ConfigError extends Error {
   constructor(message: string) {
@@ -56,6 +58,89 @@ export class ConfigValue<T> {
   }
 }
 
+/**
+ * Shell-style path substitution.
+ * 
+ * Supported patterns:
+ * "$x" (look up "x")
+ * "${x}" (look up "x")
+ * "${x:-y}" (look up "x", fall back to expanded y)
+ */
+export function pathsub(
+  x: string,
+  lookup: (s: string, depth: number) => string | undefined,
+  depth = 0,
+): string {
+  if (depth >= 10) {
+    throw Error("recursion in path substitution");
+  }
+  let s = x;
+  let l = 0;
+  while (l < s.length) {
+    if (s[l] === "$") {
+      if (s[l + 1] === "{") {
+        let depth = 1;
+        const start = l;
+        let p = start + 2;
+        let insideNamePart = true;
+        let hasDefault = false;
+        for (; p < s.length; p++) {
+          if (s[p] == "}") {
+            insideNamePart = false;
+            depth--;
+          } else if (s[p] === "$" && s[p + 1] === "{") {
+            insideNamePart = false;
+            depth++;
+          }
+          if (insideNamePart && s[p] === ":" && s[p + 1] === "-") {
+            hasDefault = true;
+          }
+          if (depth == 0) {
+            break;
+          }
+        }
+        if (depth == 0) {
+          const inner = s.slice(start + 2, p);
+          let varname: string;
+          let defaultValue: string | undefined;
+          if (hasDefault) {
+            [varname, defaultValue] = inner.split(":-", 2);
+          } else {
+            varname = inner;
+            defaultValue = undefined;
+          }
+
+          const r = lookup(inner, depth + 1);
+          if (r !== undefined) {
+            s = s.substr(0, start) + r + s.substr(p + 1);
+            l = start + r.length;
+            continue;
+          } else if (defaultValue !== undefined) {
+            const resolvedDefault = pathsub(defaultValue, lookup, depth + 1);
+            s = s.substr(0, start) + resolvedDefault + s.substr(p + 1);
+            l = start + resolvedDefault.length;
+            continue;
+          }
+        }
+        l = p;
+        continue;
+      } else {
+        const m = /^[a-zA-Z-_][a-zA-Z0-9-_]*/.exec(s.substring(l + 1));
+        if (m && m[0]) {
+          const r = lookup(m[0], depth + 1);
+          if (r !== undefined) {
+            s = s.substr(0, l) + r + s.substr(l + 1 + m[0].length);
+            l = l + r.length;
+            continue;
+          }
+        }
+      }
+    }
+    l++;
+  }
+  return s;
+}
+
 export class Configuration {
   private sectionMap: SectionMap = {};
 
@@ -69,7 +154,6 @@ export class Configuration {
 
     const lines = s.split("\n");
     for (const line of lines) {
-      console.log("parsing line", JSON.stringify(line));
       if (reEmptyLine.test(line)) {
         continue;
       }
@@ -79,15 +163,15 @@ export class Configuration {
       const secMatch = line.match(reSection);
       if (secMatch) {
         currentSection = secMatch[1];
-        console.log("setting section to", currentSection);
         continue;
       }
       if (currentSection === undefined) {
         throw Error("invalid configuration, expected section header");
       }
+      currentSection = currentSection.toUpperCase();
       const paramMatch = line.match(reParam);
       if (paramMatch) {
-        const optName = paramMatch[1];
+        const optName = paramMatch[1].toUpperCase();
         let val = paramMatch[2];
         if (val.startsWith('"') && val.endsWith('"')) {
           val = val.slice(1, val.length - 1);
@@ -102,13 +186,44 @@ export class Configuration {
         "invalid configuration, expected section header or option assignment",
       );
     }
+  }
 
-    console.log("parsed config", JSON.stringify(this.sectionMap, undefined, 
2));
+  setString(section: string, option: string, value: string): void {
+    const secNorm = section.toUpperCase();
+    const sec = this.sectionMap[secNorm] ?? (this.sectionMap[secNorm] = {});
+    sec[option.toUpperCase()] = value;
   }
 
   getString(section: string, option: string): ConfigValue<string> {
-    const val = (this.sectionMap[section] ?? {})[option];
-    return new ConfigValue(section, option, val, (x) => x);
+    const secNorm = section.toUpperCase();
+    const optNorm = option.toUpperCase();
+    const val = (this.sectionMap[section] ?? {})[optNorm];
+    return new ConfigValue(secNorm, optNorm, val, (x) => x);
+  }
+
+  getPath(section: string, option: string): ConfigValue<string> {
+    const secNorm = section.toUpperCase();
+    const optNorm = option.toUpperCase();
+    const val = (this.sectionMap[secNorm] ?? {})[optNorm];
+    return new ConfigValue(secNorm, optNorm, val, (x) =>
+      pathsub(x, (v, d) => this.lookupVariable(v, d + 1)),
+    );
+  }
+
+  lookupVariable(x: string, depth: number = 0): string | undefined {
+    console.log("looking up", x);
+    // We loop up options in PATHS in upper case, as option names
+    // are case insensitive
+    const val = (this.sectionMap["PATHS"] ?? {})[x.toUpperCase()];
+    if (val !== undefined) {
+      return pathsub(val, (v, d) => this.lookupVariable(v, d), depth);
+    }
+    // Environment variables can be case sensitive, respect that.
+    const envVal = process.env[x];
+    if (envVal !== undefined) {
+      return envVal;
+    }
+    return;
   }
 
   getAmount(section: string, option: string): ConfigValue<AmountJson> {
@@ -117,4 +232,28 @@ export class Configuration {
       Amounts.parseOrThrow(x),
     );
   }
+
+  static load(filename: string): Configuration {
+    const s = fs.readFileSync(filename, "utf-8");
+    const cfg = new Configuration();
+    cfg.loadFromString(s);
+    return cfg;
+  }
+
+  write(filename: string): void {
+    let s = "";
+    for (const sectionName of Object.keys(this.sectionMap)) {
+      s += `[${sectionName}]\n`;
+      for (const optionName of Object.keys(
+        this.sectionMap[sectionName] ?? {},
+      )) {
+        const val = this.sectionMap[sectionName][optionName];
+        if (val !== undefined) {
+          s += `${optionName} = ${val}\n`;
+        }
+      }
+      s += "\n";
+    }
+    fs.writeFileSync(filename, s);
+  }
 }
diff --git a/packages/taler-wallet-core/src/util/timer.ts 
b/packages/taler-wallet-core/src/util/timer.ts
index 8eab1399..d652fdcd 100644
--- a/packages/taler-wallet-core/src/util/timer.ts
+++ b/packages/taler-wallet-core/src/util/timer.ts
@@ -34,6 +34,12 @@ const logger = new Logger("timer.ts");
  */
 export interface TimerHandle {
   clear(): void;
+
+  /**
+   * Make sure the event loop exits when the timer is the
+   * only event left.  Has no effect in the browser.
+   */
+  unref(): void;
 }
 
 class IntervalHandle {
@@ -42,6 +48,16 @@ class IntervalHandle {
   clear(): void {
     clearInterval(this.h);
   }
+
+  /**
+   * Make sure the event loop exits when the timer is the
+   * only event left.  Has no effect in the browser.
+   */
+  unref(): void {
+    if (typeof this.h === "object") {
+      this.h.unref();
+    }
+  }
 }
 
 class TimeoutHandle {
@@ -50,6 +66,16 @@ class TimeoutHandle {
   clear(): void {
     clearTimeout(this.h);
   }
+
+  /**
+   * Make sure the event loop exits when the timer is the
+   * only event left.  Has no effect in the browser.
+   */
+  unref(): void {
+    if (typeof this.h === "object") {
+      this.h.unref();
+    }
+  }
 }
 
 /**
@@ -92,6 +118,10 @@ const nullTimerHandle = {
     // do nothing
     return;
   },
+  unref() {
+    // do nothing
+    return;
+  }
 };
 
 /**
@@ -141,6 +171,9 @@ export class TimerGroup {
         h.clear();
         delete tm[myId];
       },
+      unref() {
+        h.unref();
+      }
     };
   }
 
@@ -160,6 +193,9 @@ export class TimerGroup {
         h.clear();
         delete tm[myId];
       },
+      unref() {
+        h.unref();
+      }
     };
   }
 }
diff --git a/packages/taler-wallet-webextension/src/browserHttpLib.ts 
b/packages/taler-wallet-webextension/src/browserHttpLib.ts
index 2782e4a1..42c0c4f0 100644
--- a/packages/taler-wallet-webextension/src/browserHttpLib.ts
+++ b/packages/taler-wallet-webextension/src/browserHttpLib.ts
@@ -102,6 +102,7 @@ export class BrowserHttpLib implements 
httpLib.HttpRequestLibrary {
             requestUrl: url,
             status: myRequest.status,
             headers: headerMap,
+            requestMethod: method,
             json: makeJson,
             text: async () => myRequest.responseText,
           };
@@ -112,7 +113,7 @@ export class BrowserHttpLib implements 
httpLib.HttpRequestLibrary {
   }
 
   get(url: string, opt?: httpLib.HttpRequestOptions): 
Promise<httpLib.HttpResponse> {
-    return this.req("get", url, undefined, opt);
+    return this.req("GET", url, undefined, opt);
   }
 
   postJson(
@@ -120,7 +121,7 @@ export class BrowserHttpLib implements 
httpLib.HttpRequestLibrary {
     body: unknown,
     opt?: httpLib.HttpRequestOptions,
   ): Promise<httpLib.HttpResponse> {
-    return this.req("post", url, JSON.stringify(body), opt);
+    return this.req("POST", url, JSON.stringify(body), opt);
   }
 
   stop(): void {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 971d9d55..63b2ab80 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,6 +29,28 @@ importers:
     specifiers:
       '@types/node': ^11.12.0
       typescript: ^3.3.4000
+  packages/taler-integrationtests:
+    dependencies:
+      axios: 0.19.2
+      taler-wallet-core: 'link:../taler-wallet-core'
+      tslib: 2.0.0
+      typescript: 3.9.7
+    devDependencies:
+      '@ava/typescript': 1.1.1
+      ava: 3.11.1
+      esm: 3.2.25
+      source-map-support: 0.5.19
+      ts-node: 8.10.2_typescript@3.9.7
+    specifiers:
+      '@ava/typescript': ^1.1.1
+      ava: ^3.11.1
+      axios: ^0.19.2
+      esm: ^3.2.25
+      source-map-support: ^0.5.19
+      taler-wallet-core: 'workspace:*'
+      ts-node: ^8.10.2
+      tslib: ^2.0.0
+      typescript: ^3.9.7
   packages/taler-wallet-android:
     dependencies:
       taler-wallet-core: 'link:../taler-wallet-core'
@@ -100,6 +122,7 @@ importers:
       '@typescript-eslint/eslint-plugin': 
3.7.1_98f5354ad0bbc327ab4925c12674a6b1
       '@typescript-eslint/parser': 3.7.1_eslint@7.6.0+typescript@3.9.7
       ava: 3.11.0
+      dts-bundle-generator: 5.3.0
       eslint: 7.6.0
       eslint-config-airbnb-typescript: 8.0.2_de36c6f68d63a4142de06a31bab9d790
       eslint-plugin-import: 2.22.0_eslint@7.6.0
@@ -115,6 +138,7 @@ importers:
       prettier: 2.0.5
       rimraf: 3.0.2
       rollup: 2.23.0
+      rollup-plugin-sourcemaps: 0.6.2_1bb4f16ce5b550396581a296af208cfa
       source-map-resolve: 0.6.0
       structured-clone: 0.2.2
       typedoc: 0.17.8_typescript@3.9.7
@@ -127,6 +151,7 @@ importers:
       ava: ^3.10.1
       axios: ^0.19.2
       big-integer: ^1.6.48
+      dts-bundle-generator: ^5.3.0
       eslint: ^7.4.0
       eslint-config-airbnb-typescript: ^8.0.2
       eslint-plugin-import: ^2.22.0
@@ -143,6 +168,7 @@ importers:
       prettier: ^2.0.5
       rimraf: ^3.0.2
       rollup: ^2.23.0
+      rollup-plugin-sourcemaps: ^0.6.2
       source-map-resolve: ^0.6.0
       source-map-support: ^0.5.19
       structured-clone: ^0.2.2
@@ -861,6 +887,10 @@ packages:
     dev: true
     resolution:
       integrity: sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=
+  /arg/4.1.3:
+    dev: true
+    resolution:
+      integrity: 
sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
   /argparse/1.0.10:
     dependencies:
       sprintf-js: 1.0.3
@@ -1032,6 +1062,69 @@ packages:
     hasBin: true
     resolution:
       integrity: 
sha512-y5U8BGeSRjs/OypsC4CJxr+L1KtLKU5kUyHr5hcghXn7HNr2f4LE/4gvl0Q5lNkLX1obdRW1oODphNdU/glwmA==
+  /ava/3.11.1:
+    dependencies:
+      '@concordance/react': 2.0.0
+      acorn: 7.3.1
+      acorn-walk: 7.2.0
+      ansi-styles: 4.2.1
+      arrgv: 1.0.2
+      arrify: 2.0.1
+      callsites: 3.1.0
+      chalk: 4.1.0
+      chokidar: 3.4.1
+      chunkd: 2.0.1
+      ci-info: 2.0.0
+      ci-parallel-vars: 1.0.1
+      clean-yaml-object: 0.1.0
+      cli-cursor: 3.1.0
+      cli-truncate: 2.1.0
+      code-excerpt: 3.0.0
+      common-path-prefix: 3.0.0
+      concordance: 5.0.0
+      convert-source-map: 1.7.0
+      currently-unhandled: 0.4.1
+      debug: 4.1.1
+      del: 5.1.0
+      emittery: 0.7.1
+      equal-length: 1.0.1
+      figures: 3.2.0
+      globby: 11.0.1
+      ignore-by-default: 2.0.0
+      import-local: 3.0.2
+      indent-string: 4.0.0
+      is-error: 2.2.2
+      is-plain-object: 4.1.1
+      is-promise: 4.0.0
+      lodash: 4.17.19
+      matcher: 3.0.0
+      md5-hex: 3.0.1
+      mem: 6.1.0
+      ms: 2.1.2
+      ora: 4.0.5
+      p-map: 4.0.0
+      picomatch: 2.2.2
+      pkg-conf: 3.1.0
+      plur: 4.0.0
+      pretty-ms: 7.0.0
+      read-pkg: 5.2.0
+      resolve-cwd: 3.0.0
+      slash: 3.0.0
+      source-map-support: 0.5.19
+      stack-utils: 2.0.2
+      strip-ansi: 6.0.0
+      supertap: 1.0.0
+      temp-dir: 2.0.0
+      trim-off-newlines: 1.0.1
+      update-notifier: 4.1.0
+      write-file-atomic: 3.0.3
+      yargs: 15.4.1
+    dev: true
+    engines:
+      node: '>=10.18.0 <11 || >=12.14.0 <12.17.0 || >=12.17.0 <13 || >=14.0.0'
+    hasBin: true
+    resolution:
+      integrity: 
sha512-yGPD0msa5Qronw7GHDNlLaB7oU5zryYtXeuvny40YV6TMskSghqK7Ky3NisM/sr+aqI3DY7sfmORx8dIWQgMoQ==
   /axe-core/3.5.5:
     dev: true
     engines:
@@ -1541,6 +1634,12 @@ packages:
       node: '>=8'
     resolution:
       integrity: 
sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA==
+  /diff/4.0.2:
+    dev: true
+    engines:
+      node: '>=0.3.1'
+    resolution:
+      integrity: 
sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
   /dir-glob/3.0.1:
     dependencies:
       path-type: 4.0.0
@@ -1617,6 +1716,16 @@ packages:
       node: '>=8'
     resolution:
       integrity: 
sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==
+  /dts-bundle-generator/5.3.0:
+    dependencies:
+      typescript: 3.9.7
+      yargs: 15.4.1
+    dev: true
+    engines:
+      node: '>=12.0.0'
+    hasBin: true
+    resolution:
+      integrity: 
sha512-PevcqtUQDsVs1FoXNEEvBgXWP2pNXT/booL+ufNcKSynEP8l01ebI9MgamECljThi+MHyjxYEbwGx+95TvigMQ==
   /duplexer3/0.1.4:
     dev: true
     resolution:
@@ -3070,6 +3179,10 @@ packages:
       node: '>=8'
     resolution:
       integrity: 
sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
+  /make-error/1.3.6:
+    dev: true
+    resolution:
+      integrity: 
sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
   /map-age-cleaner/0.1.3:
     dependencies:
       p-defer: 1.0.0
@@ -3353,14 +3466,14 @@ packages:
     dev: true
     resolution:
       integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
-  /onetime/5.1.0:
+  /onetime/5.1.1:
     dependencies:
       mimic-fn: 2.1.0
     dev: true
     engines:
       node: '>=6'
     resolution:
-      integrity: 
sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==
+      integrity: 
sha512-ZpZpjcJeugQfWsfyQlshVoowIIQ1qBGSVll4rfDq6JJVO//fesjoX808hXWfBjY+ROZgpKDI5TRSRBSoJiZ8eg==
   /optionator/0.9.1:
     dependencies:
       deep-is: 0.1.3
@@ -3944,7 +4057,7 @@ packages:
       integrity: sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=
   /restore-cursor/3.1.0:
     dependencies:
-      onetime: 5.1.0
+      onetime: 5.1.1
       signal-exit: 3.0.3
     dev: true
     engines:
@@ -4449,6 +4562,22 @@ packages:
       node: '>=0.10.0'
     resolution:
       integrity: sha1-n5up2e+odkw4dpi8v+sshI8RrbM=
+  /ts-node/8.10.2_typescript@3.9.7:
+    dependencies:
+      arg: 4.1.3
+      diff: 4.0.2
+      make-error: 1.3.6
+      source-map-support: 0.5.19
+      typescript: 3.9.7
+      yn: 3.1.1
+    dev: true
+    engines:
+      node: '>=6.0.0'
+    hasBin: true
+    peerDependencies:
+      typescript: '>=2.7'
+    resolution:
+      integrity: 
sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA==
   /tsconfig-paths/3.9.0:
     dependencies:
       '@types/json5': 0.0.29
@@ -4539,7 +4668,6 @@ packages:
     resolution:
       integrity: 
sha512-/OyrHCJ8jtzu+QZ+771YaxQ9s4g5Z3XsQE3Ma7q+BL392xxBn4UMvvCdVnqKC2T/dz03/VXSLVKOP3lHmDdc/w==
   /typescript/3.9.7:
-    dev: true
     engines:
       node: '>=4.2.0'
     hasBin: true
@@ -4736,3 +4864,9 @@ packages:
       node: '>=8'
     resolution:
       integrity: 
sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
+  /yn/3.1.1:
+    dev: true
+    engines:
+      node: '>=6'
+    resolution:
+      integrity: 
sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]