aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/background/background.html13
-rw-r--r--src/background/background.ts42
-rw-r--r--src/checkable.ts262
-rw-r--r--src/chromeBadge.ts227
-rw-r--r--src/components.ts44
-rw-r--r--src/content_scripts/notify.js325
-rw-r--r--src/content_scripts/notify.ts370
-rw-r--r--src/cryptoApi-test.ts79
-rw-r--r--src/cryptoApi.ts256
-rw-r--r--src/cryptoLib.ts345
-rw-r--r--src/cryptoWorker.ts64
-rw-r--r--src/db.ts117
-rw-r--r--src/emscripten/taler-emscripten-lib.d.ts56
-rw-r--r--src/emscriptif-test.ts21
-rw-r--r--src/emscriptif.ts1244
-rw-r--r--src/helpers.ts140
-rw-r--r--src/http.ts97
-rw-r--r--src/i18n.ts205
-rw-r--r--src/i18n/de.po130
-rw-r--r--src/i18n/en-US.po100
-rw-r--r--src/i18n/fr.po96
-rw-r--r--src/i18n/it.po96
-rw-r--r--src/i18n/poheader26
-rw-r--r--src/i18n/taler-wallet-webex.pot96
-rw-r--r--src/module-trampoline.js73
-rw-r--r--src/pages/confirm-contract.html75
-rw-r--r--src/pages/confirm-contract.tsx231
-rw-r--r--src/pages/confirm-create-reserve.html93
-rw-r--r--src/pages/confirm-create-reserve.tsx397
-rw-r--r--src/pages/debug.html13
-rw-r--r--src/pages/help/empty-wallet.html30
-rw-r--r--src/pages/show-db.html15
-rw-r--r--src/pages/show-db.ts57
-rw-r--r--src/pages/tree.html36
-rw-r--r--src/pages/tree.tsx400
-rw-r--r--src/popup/popup.css84
-rw-r--r--src/popup/popup.html26
-rw-r--r--src/popup/popup.tsx508
-rw-r--r--src/query.ts612
-rw-r--r--src/renderHtml.tsx63
-rw-r--r--src/style/lang.css11
-rw-r--r--src/style/wallet.css139
l---------src/taler-wallet-lib.ts1
-rw-r--r--src/types-test.ts38
-rw-r--r--src/types.ts554
-rw-r--r--src/wallet.ts1657
-rw-r--r--src/wxApi.ts75
-rw-r--r--src/wxMessaging.ts439
48 files changed, 10078 insertions, 0 deletions
diff --git a/src/background/background.html b/src/background/background.html
new file mode 100644
index 000000000..621e0fbb9
--- /dev/null
+++ b/src/background/background.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <script src="../vendor/URI.js"></script>
+ <script src="../vendor/system-csp-production.src.js"></script>
+ <script src="background.js"></script>
+ <meta charset="UTF-8">
+ <title>(wallet bg page)</title>
+</head>
+<body>
+ <img id="taler-logo" src="/img/icon.png">
+</body>
+</html>
diff --git a/src/background/background.ts b/src/background/background.ts
new file mode 100644
index 000000000..57335e023
--- /dev/null
+++ b/src/background/background.ts
@@ -0,0 +1,42 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+/**
+ * Entry point for the background page.
+ *
+ * @author Florian Dold
+ */
+
+"use strict";
+
+window.addEventListener("load", () => {
+
+ // TypeScript does not allow ".js" extensions in the
+ // module name, so SystemJS must add it.
+ System.config({
+ defaultJSExtensions: true,
+ });
+
+ System.import("../wxMessaging")
+ .then((wxMessaging: any) => {
+ // Export as global for debugger
+ (window as any).wxMessaging = wxMessaging;
+ wxMessaging.wxMain();
+ }).catch((e: Error) => {
+ console.log("wallet failed");
+ console.error(e.stack);
+ });
+});
diff --git a/src/checkable.ts b/src/checkable.ts
new file mode 100644
index 000000000..89d0c7150
--- /dev/null
+++ b/src/checkable.ts
@@ -0,0 +1,262 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+
+"use strict";
+
+/**
+ * Decorators for type-checking JSON into
+ * an object.
+ * @module Checkable
+ * @author Florian Dold
+ */
+
+export namespace Checkable {
+
+ type Path = (number | string)[];
+
+ interface SchemaErrorConstructor {
+ new (err: string): SchemaError;
+ }
+
+ interface SchemaError {
+ name: string;
+ message: string;
+ }
+
+ interface Prop {
+ propertyKey: any;
+ checker: any;
+ type: any;
+ elementChecker?: any;
+ elementProp?: any;
+ }
+
+ export let SchemaError = (function SchemaError(message: string) {
+ this.name = 'SchemaError';
+ this.message = message;
+ this.stack = (<any>new Error()).stack;
+ }) as any as SchemaErrorConstructor;
+
+
+ SchemaError.prototype = new Error;
+
+ let chkSym = Symbol("checkable");
+
+
+ function checkNumber(target: any, prop: Prop, path: Path): any {
+ if ((typeof target) !== "number") {
+ throw new SchemaError(`expected number for ${path}`);
+ }
+ return target;
+ }
+
+
+ function checkString(target: any, prop: Prop, path: Path): any {
+ if (typeof target !== "string") {
+ throw new SchemaError(`expected string for ${path}, got ${typeof target} instead`);
+ }
+ return target;
+ }
+
+
+ function checkAnyObject(target: any, prop: Prop, path: Path): any {
+ if (typeof target !== "object") {
+ throw new SchemaError(`expected (any) object for ${path}, got ${typeof target} instead`);
+ }
+ return target;
+ }
+
+
+ function checkAny(target: any, prop: Prop, path: Path): any {
+ return target;
+ }
+
+
+ function checkList(target: any, prop: Prop, path: Path): any {
+ if (!Array.isArray(target)) {
+ throw new SchemaError(`array expected for ${path}, got ${typeof target} instead`);
+ }
+ for (let i = 0; i < target.length; i++) {
+ let v = target[i];
+ prop.elementChecker(v, prop.elementProp, path.concat([i]));
+ }
+ return target;
+ }
+
+
+ function checkOptional(target: any, prop: Prop, path: Path): any {
+ console.assert(prop.propertyKey);
+ prop.elementChecker(target,
+ prop.elementProp,
+ path.concat([prop.propertyKey]));
+ return target;
+ }
+
+
+ function checkValue(target: any, prop: Prop, path: Path): any {
+ let type = prop.type;
+ if (!type) {
+ throw Error(`assertion failed (prop is ${JSON.stringify(prop)})`);
+ }
+ let v = target;
+ if (!v || typeof v !== "object") {
+ throw new SchemaError(
+ `expected object for ${path.join(".")}, got ${typeof v} instead`);
+ }
+ let props = type.prototype[chkSym].props;
+ let remainingPropNames = new Set(Object.getOwnPropertyNames(v));
+ let obj = new type();
+ for (let prop of props) {
+ if (!remainingPropNames.has(prop.propertyKey)) {
+ if (prop.optional) {
+ continue;
+ }
+ throw new SchemaError("Property missing: " + prop.propertyKey);
+ }
+ if (!remainingPropNames.delete(prop.propertyKey)) {
+ throw new SchemaError("assertion failed");
+ }
+ let propVal = v[prop.propertyKey];
+ obj[prop.propertyKey] = prop.checker(propVal,
+ prop,
+ path.concat([prop.propertyKey]));
+ }
+
+ if (remainingPropNames.size != 0) {
+ throw new SchemaError("superfluous properties " + JSON.stringify(Array.from(
+ remainingPropNames.values())));
+ }
+ return obj;
+ }
+
+
+ export function Class(target: any) {
+ target.checked = (v: any) => {
+ return checkValue(v, {
+ propertyKey: "(root)",
+ type: target,
+ checker: checkValue
+ }, ["(root)"]);
+ };
+ return target;
+ }
+
+
+ export function Value(type: any) {
+ if (!type) {
+ throw Error("Type does not exist yet (wrong order of definitions?)");
+ }
+ function deco(target: Object, propertyKey: string | symbol): void {
+ let chk = mkChk(target);
+ chk.props.push({
+ propertyKey: propertyKey,
+ checker: checkValue,
+ type: type
+ });
+ }
+
+ return deco;
+ }
+
+
+ export function List(type: any) {
+ let stub = {};
+ type(stub, "(list-element)");
+ let elementProp = mkChk(stub).props[0];
+ let elementChecker = elementProp.checker;
+ if (!elementChecker) {
+ throw Error("assertion failed");
+ }
+ function deco(target: Object, propertyKey: string | symbol): void {
+ let chk = mkChk(target);
+ chk.props.push({
+ elementChecker,
+ elementProp,
+ propertyKey: propertyKey,
+ checker: checkList,
+ });
+ }
+
+ return deco;
+ }
+
+
+ export function Optional(type: any) {
+ let stub = {};
+ type(stub, "(optional-element)");
+ let elementProp = mkChk(stub).props[0];
+ let elementChecker = elementProp.checker;
+ if (!elementChecker) {
+ throw Error("assertion failed");
+ }
+ function deco(target: Object, propertyKey: string | symbol): void {
+ let chk = mkChk(target);
+ chk.props.push({
+ elementChecker,
+ elementProp,
+ propertyKey: propertyKey,
+ checker: checkOptional,
+ optional: true,
+ });
+ }
+
+ return deco;
+ }
+
+
+ export function Number(target: Object, propertyKey: string | symbol): void {
+ let chk = mkChk(target);
+ chk.props.push({ propertyKey: propertyKey, checker: checkNumber });
+ }
+
+
+ export function AnyObject(target: Object,
+ propertyKey: string | symbol): void {
+ let chk = mkChk(target);
+ chk.props.push({
+ propertyKey: propertyKey,
+ checker: checkAnyObject
+ });
+ }
+
+
+ export function Any(target: Object,
+ propertyKey: string | symbol): void {
+ let chk = mkChk(target);
+ chk.props.push({
+ propertyKey: propertyKey,
+ checker: checkAny,
+ optional: true
+ });
+ }
+
+
+ export function String(target: Object, propertyKey: string | symbol): void {
+ let chk = mkChk(target);
+ chk.props.push({ propertyKey: propertyKey, checker: checkString });
+ }
+
+
+ function mkChk(target: any) {
+ let chk = target[chkSym];
+ if (!chk) {
+ chk = { props: [] };
+ target[chkSym] = chk;
+ }
+ return chk;
+ }
+} \ No newline at end of file
diff --git a/src/chromeBadge.ts b/src/chromeBadge.ts
new file mode 100644
index 000000000..df12fba83
--- /dev/null
+++ b/src/chromeBadge.ts
@@ -0,0 +1,227 @@
+/*
+ This file is part of TALER
+ (C) 2016 INRIA
+
+ 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
+ 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/>
+ */
+
+import {
+ Badge
+} from "./wallet";
+
+
+/**
+ * Polyfill for requestAnimationFrame, which
+ * doesn't work from a background page.
+ */
+function rAF(cb: (ts: number) => void) {
+ window.setTimeout(() => {
+ cb(performance.now());
+ }, 100 /* 100 ms delay between frames */);
+}
+
+
+export class ChromeBadge implements Badge {
+ canvas: HTMLCanvasElement;
+ ctx: CanvasRenderingContext2D;
+ /**
+ * True if animation running. The animation
+ * might still be running even if we're not busy anymore,
+ * just to transition to the "normal" state in a animated way.
+ */
+ animationRunning: boolean = false;
+
+ /**
+ * Is the wallet still busy? Note that we do not stop the
+ * animation immediately when the wallet goes idle, but
+ * instead slowly close the gap.
+ */
+ isBusy: boolean = false;
+
+ /**
+ * Current rotation angle, ranges from 0 to rotationAngleMax.
+ */
+ rotationAngle: number = 0;
+
+ /**
+ * While animating, how wide is the current gap in the circle?
+ * Ranges from 0 to openMax.
+ */
+ gapWidth: number = 0;
+
+ /**
+ * Maximum value for our rotationAngle, corresponds to 2 Pi.
+ */
+ static rotationAngleMax = 1000;
+
+ /**
+ * How fast do we rotate? Given in rotation angle (relative to rotationAngleMax) per millisecond.
+ */
+ static rotationSpeed = 0.5;
+
+ /**
+ * How fast to we open? Given in rotation angle (relative to rotationAngleMax) per millisecond.
+ */
+ static openSpeed = 0.15;
+
+ /**
+ * How fast to we close? Given as a multiplication factor per frame update.
+ */
+ static closeSpeed = 0.7;
+
+ /**
+ * How far do we open? Given relative to rotationAngleMax.
+ */
+ static openMax = 100;
+
+ constructor(window?: Window) {
+ // Allow injecting another window for testing
+ let bg = window || chrome.extension.getBackgroundPage();
+ if (!bg) {
+ throw Error("no window available");
+ }
+ this.canvas = bg.document.createElement("canvas");
+ // Note: changing the width here means changing the font
+ // size in draw() as well!
+ this.canvas.width = 32;
+ this.canvas.height = 32;
+ this.ctx = this.canvas.getContext("2d")!;
+ this.draw();
+ }
+
+ /**
+ * Draw the badge based on the current state.
+ */
+ private draw() {
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+
+ this.ctx.translate(this.canvas.width / 2, this.canvas.height / 2);
+
+ this.ctx.beginPath();
+ this.ctx.arc(0, 0, this.canvas.width / 2 - 2, 0, 2 * Math.PI);
+ this.ctx.fillStyle = "white";
+ this.ctx.fill();
+
+ // move into the center, off by 2 for aligning the "T" with the bottom
+ // of the circle.
+ this.ctx.translate(0, 2);
+
+ // pick sans-serif font; note: 14px is based on the 32px width above!
+ this.ctx.font = "bold 24px sans-serif";
+ // draw the "T" perfectly centered (x and y) to the current position
+ this.ctx.textAlign = "center";
+ this.ctx.textBaseline = "middle";
+ this.ctx.fillStyle = "black";
+ this.ctx.fillText("T", 0, 0);
+ // now move really into the center
+ this.ctx.translate(0, -2);
+ // start drawing the (possibly open) circle
+ this.ctx.beginPath();
+ this.ctx.lineWidth = 2.5;
+ if (this.animationRunning) {
+ /* Draw circle around the "T" with an opening of this.gapWidth */
+ this.ctx.arc(0, 0,
+ this.canvas.width / 2 - 2, /* radius */
+ this.rotationAngle / ChromeBadge.rotationAngleMax * Math.PI * 2,
+ ((this.rotationAngle + ChromeBadge.rotationAngleMax - this.gapWidth) / ChromeBadge.rotationAngleMax) * Math.PI * 2,
+ false);
+ }
+ else {
+ /* Draw full circle */
+ this.ctx.arc(0, 0,
+ this.canvas.width / 2 - 2, /* radius */
+ 0,
+ Math.PI * 2,
+ false);
+ }
+ this.ctx.stroke();
+ // go back to the origin
+ this.ctx.translate(-this.canvas.width / 2, -this.canvas.height / 2);
+
+ // Allow running outside the extension for testing
+ if (window["chrome"] && window.chrome["browserAction"]) {
+ let imageData = this.ctx.getImageData(0,
+ 0,
+ this.canvas.width,
+ this.canvas.height);
+ chrome.browserAction.setIcon({imageData});
+ }
+ }
+
+ private animate() {
+ if (this.animationRunning) {
+ return;
+ }
+ this.animationRunning = true;
+ let start: number|undefined = undefined;
+ let step = (timestamp: number) => {
+ if (!this.animationRunning) {
+ return;
+ }
+ if (!start) {
+ start = timestamp;
+ }
+ let delta = (timestamp - start);
+ if (!this.isBusy && 0 == this.gapWidth) {
+ // stop if we're close enough to origin
+ this.rotationAngle = 0;
+ } else {
+ this.rotationAngle = (this.rotationAngle + (timestamp - start) * ChromeBadge.rotationSpeed) % ChromeBadge.rotationAngleMax;
+ }
+ if (this.isBusy) {
+ if (this.gapWidth < ChromeBadge.openMax) {
+ this.gapWidth += ChromeBadge.openSpeed * (timestamp - start);
+ }
+ if (this.gapWidth > ChromeBadge.openMax) {
+ this.gapWidth = ChromeBadge.openMax;
+ }
+ }
+ else {
+ if (this.gapWidth > 0) {
+ this.gapWidth--;
+ this.gapWidth *= ChromeBadge.closeSpeed;
+ }
+ }
+
+
+ if (this.isBusy || this.gapWidth > 0) {
+ start = timestamp;
+ rAF(step);
+ } else {
+ this.animationRunning = false;
+ }
+ this.draw();
+ };
+ rAF(step);
+ }
+
+ setText(s: string) {
+ chrome.browserAction.setBadgeText({text: s});
+ }
+
+ setColor(c: string) {
+ chrome.browserAction.setBadgeBackgroundColor({color: c});
+ }
+
+ startBusy() {
+ if (this.isBusy) {
+ return;
+ }
+ this.isBusy = true;
+ this.animate();
+ }
+
+ stopBusy() {
+ this.isBusy = false;
+ }
+}
diff --git a/src/components.ts b/src/components.ts
new file mode 100644
index 000000000..066e6d07f
--- /dev/null
+++ b/src/components.ts
@@ -0,0 +1,44 @@
+/*
+ This file is part of TALER
+ (C) 2016 Inria
+
+ 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
+ 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/>
+ */
+
+
+/**
+ * General helper components
+ *
+ * @author Florian Dold
+ */
+
+export interface StateHolder<T> {
+ (): T;
+ (newState: T): void;
+}
+
+/**
+ * Component that doesn't hold its state in one object,
+ * but has multiple state holders.
+ */
+export abstract class ImplicitStateComponent<PropType> extends React.Component<PropType, any> {
+ makeState<StateType>(initial: StateType): StateHolder<StateType> {
+ let state: StateType = initial;
+ return (s?: StateType): StateType => {
+ if (s !== undefined) {
+ state = s;
+ this.setState({} as any);
+ }
+ return state;
+ };
+ }
+}
diff --git a/src/content_scripts/notify.js b/src/content_scripts/notify.js
new file mode 100644
index 000000000..74447386b
--- /dev/null
+++ b/src/content_scripts/notify.js
@@ -0,0 +1,325 @@
+/*
+ This file is part of TALER
+ (C) 2015 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+/**
+ * Script that is injected into (all!) pages to allow them
+ * to interact with the GNU Taler wallet via DOM Events.
+ *
+ * @author Florian Dold
+ */
+"use strict";
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator.throw(value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments)).next());
+ });
+};
+// Make sure we don't pollute the namespace too much.
+var TalerNotify;
+(function (TalerNotify) {
+ const PROTOCOL_VERSION = 1;
+ let logVerbose = false;
+ try {
+ logVerbose = !!localStorage.getItem("taler-log-verbose");
+ }
+ catch (e) {
+ }
+ if (!taler) {
+ console.error("Taler wallet lib not included, HTTP 402 payments not" +
+ " supported");
+ }
+ function subst(url, H_contract) {
+ url = url.replace("${H_contract}", H_contract);
+ url = url.replace("${$}", "$");
+ return url;
+ }
+ const handlers = [];
+ function hashContract(contract) {
+ let walletHashContractMsg = {
+ type: "hash-contract",
+ detail: { contract }
+ };
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(walletHashContractMsg, (resp) => {
+ if (!resp.hash) {
+ console.log("error", resp);
+ reject(Error("hashing failed"));
+ }
+ resolve(resp.hash);
+ });
+ });
+ }
+ function checkRepurchase(contract) {
+ const walletMsg = {
+ type: "check-repurchase",
+ detail: {
+ contract: contract
+ },
+ };
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ resolve(resp);
+ });
+ });
+ }
+ function putHistory(historyEntry) {
+ const walletMsg = {
+ type: "put-history-entry",
+ detail: {
+ historyEntry,
+ },
+ };
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ resolve();
+ });
+ });
+ }
+ function saveOffer(offer) {
+ const walletMsg = {
+ type: "save-offer",
+ detail: {
+ offer: {
+ contract: offer.contract,
+ merchant_sig: offer.merchant_sig,
+ H_contract: offer.H_contract,
+ offer_time: new Date().getTime() / 1000
+ },
+ },
+ };
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ resolve(resp);
+ });
+ });
+ }
+ function init() {
+ chrome.runtime.sendMessage({ type: "get-tab-cookie" }, (resp) => {
+ if (chrome.runtime.lastError) {
+ logVerbose && console.log("extension not yet ready");
+ window.setTimeout(init, 200);
+ return;
+ }
+ registerHandlers();
+ // Hack to know when the extension is unloaded
+ let port = chrome.runtime.connect();
+ port.onDisconnect.addListener(() => {
+ logVerbose && console.log("chrome runtime disconnected, removing handlers");
+ for (let handler of handlers) {
+ document.removeEventListener(handler.type, handler.listener);
+ }
+ });
+ if (resp && resp.type === "fetch") {
+ logVerbose && console.log("it's fetch");
+ taler.internalOfferContractFrom(resp.contractUrl);
+ document.documentElement.style.visibility = "hidden";
+ }
+ else if (resp && resp.type === "execute") {
+ logVerbose && console.log("it's execute");
+ document.documentElement.style.visibility = "hidden";
+ taler.internalExecutePayment(resp.contractHash, resp.payUrl, resp.offerUrl);
+ }
+ });
+ }
+ logVerbose && console.log("loading Taler content script");
+ init();
+ function registerHandlers() {
+ /**
+ * Add a handler for a DOM event, which automatically
+ * handles adding sequence numbers to responses.
+ */
+ function addHandler(type, handler) {
+ let handlerWrap = (e) => {
+ if (e.type != type) {
+ throw Error(`invariant violated`);
+ }
+ let callId = undefined;
+ if (e.detail && e.detail.callId != undefined) {
+ callId = e.detail.callId;
+ }
+ let responder = (msg) => {
+ let fullMsg = Object.assign({}, msg, { callId });
+ let opts = { detail: fullMsg };
+ if ("function" == typeof cloneInto) {
+ opts = cloneInto(opts, document.defaultView);
+ }
+ let evt = new CustomEvent(type + "-result", opts);
+ document.dispatchEvent(evt);
+ };
+ handler(e.detail, responder);
+ };
+ document.addEventListener(type, handlerWrap);
+ handlers.push({ type, listener: handlerWrap });
+ }
+ addHandler("taler-query-id", (msg, sendResponse) => {
+ // FIXME: maybe include this info in taoer-probe?
+ sendResponse({ id: chrome.runtime.id });
+ });
+ addHandler("taler-probe", (msg, sendResponse) => {
+ sendResponse();
+ });
+ addHandler("taler-create-reserve", (msg) => {
+ let params = {
+ amount: JSON.stringify(msg.amount),
+ callback_url: URI(msg.callback_url)
+ .absoluteTo(document.location.href),
+ bank_url: document.location.href,
+ wt_types: JSON.stringify(msg.wt_types),
+ };
+ let uri = URI(chrome.extension.getURL("/src/pages/confirm-create-reserve.html"));
+ let redirectUrl = uri.query(params).href();
+ window.location.href = redirectUrl;
+ });
+ addHandler("taler-confirm-reserve", (msg, sendResponse) => {
+ let walletMsg = {
+ type: "confirm-reserve",
+ detail: {
+ reservePub: msg.reserve_pub
+ }
+ };
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ sendResponse();
+ });
+ });
+ addHandler("taler-confirm-contract", (msg) => __awaiter(this, void 0, void 0, function* () {
+ if (!msg.contract_wrapper) {
+ console.error("contract wrapper missing");
+ return;
+ }
+ const offer = msg.contract_wrapper;
+ if (!offer.contract) {
+ console.error("contract field missing");
+ return;
+ }
+ if (!offer.H_contract) {
+ console.error("H_contract field missing");
+ return;
+ }
+ let walletHashContractMsg = {
+ type: "hash-contract",
+ detail: { contract: offer.contract }
+ };
+ let contractHash = yield hashContract(offer.contract);
+ if (contractHash != offer.H_contract) {
+ console.error("merchant-supplied contract hash is wrong");
+ return;
+ }
+ let resp = yield checkRepurchase(offer.contract);
+ if (resp.error) {
+ console.error("wallet backend error", resp);
+ return;
+ }
+ if (resp.isRepurchase) {
+ logVerbose && console.log("doing repurchase");
+ console.assert(resp.existingFulfillmentUrl);
+ console.assert(resp.existingContractHash);
+ window.location.href = subst(resp.existingFulfillmentUrl, resp.existingContractHash);
+ }
+ else {
+ let merchantName = "(unknown)";
+ try {
+ merchantName = offer.contract.merchant.name;
+ }
+ catch (e) {
+ }
+ let historyEntry = {
+ timestamp: (new Date).getTime(),
+ subjectId: `contract-${contractHash}`,
+ type: "offer-contract",
+ detail: {
+ contractHash,
+ merchantName,
+ }
+ };
+ yield putHistory(historyEntry);
+ let offerId = yield saveOffer(offer);
+ const uri = URI(chrome.extension.getURL("/src/pages/confirm-contract.html"));
+ const params = {
+ offerId: offerId.toString(),
+ };
+ const target = uri.query(params).href();
+ if (msg.replace_navigation === true) {
+ document.location.replace(target);
+ }
+ else {
+ document.location.href = target;
+ }
+ }
+ }));
+ addHandler("taler-payment-failed", (msg, sendResponse) => {
+ const walletMsg = {
+ type: "payment-failed",
+ detail: {
+ contractHash: msg.H_contract
+ },
+ };
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ sendResponse();
+ });
+ });
+ addHandler("taler-payment-succeeded", (msg, sendResponse) => {
+ if (!msg.H_contract) {
+ console.error("H_contract missing in taler-payment-succeeded");
+ return;
+ }
+ logVerbose && console.log("got taler-payment-succeeded");
+ const walletMsg = {
+ type: "payment-succeeded",
+ detail: {
+ contractHash: msg.H_contract,
+ },
+ };
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ sendResponse();
+ });
+ });
+ addHandler("taler-get-payment", (msg, sendResponse) => {
+ const walletMsg = {
+ type: "execute-payment",
+ detail: {
+ H_contract: msg.H_contract,
+ },
+ };
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ if (resp.rateLimitExceeded) {
+ console.error("rate limit exceeded, check for redirect loops");
+ }
+ if (!resp.success) {
+ if (msg.offering_url) {
+ window.location.href = msg.offering_url;
+ }
+ else {
+ console.error("execute-payment failed", resp);
+ }
+ return;
+ }
+ let contract = resp.contract;
+ if (!contract) {
+ throw Error("contract missing");
+ }
+ // We have the details for then payment, the merchant page
+ // is responsible to give it to the merchant.
+ sendResponse({
+ H_contract: msg.H_contract,
+ contract: resp.contract,
+ payment: resp.payReq,
+ });
+ });
+ });
+ }
+})(TalerNotify || (TalerNotify = {}));
+//# sourceMappingURL=notify.js.map \ No newline at end of file
diff --git a/src/content_scripts/notify.ts b/src/content_scripts/notify.ts
new file mode 100644
index 000000000..6fb4eae47
--- /dev/null
+++ b/src/content_scripts/notify.ts
@@ -0,0 +1,370 @@
+/*
+ This file is part of TALER
+ (C) 2015 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+
+/**
+ * Script that is injected into (all!) pages to allow them
+ * to interact with the GNU Taler wallet via DOM Events.
+ *
+ * @author Florian Dold
+ */
+
+
+"use strict";
+
+declare var cloneInto: any;
+
+// Make sure we don't pollute the namespace too much.
+namespace TalerNotify {
+ const PROTOCOL_VERSION = 1;
+
+ let logVerbose: boolean = false;
+ try {
+ logVerbose = !!localStorage.getItem("taler-log-verbose");
+ } catch (e) {
+ // can't read from local storage
+ }
+
+ if (!taler) {
+ console.error("Taler wallet lib not included, HTTP 402 payments not" +
+ " supported");
+ }
+
+ function subst(url: string, H_contract: string) {
+ url = url.replace("${H_contract}", H_contract);
+ url = url.replace("${$}", "$");
+ return url;
+ }
+
+ interface Handler {
+ type: string;
+ listener: (e: CustomEvent) => void|Promise<void>;
+ }
+ const handlers: Handler[] = [];
+
+ function hashContract(contract: string): Promise<string> {
+ let walletHashContractMsg = {
+ type: "hash-contract",
+ detail: {contract}
+ };
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(walletHashContractMsg, (resp: any) => {
+ if (!resp.hash) {
+ console.log("error", resp);
+ reject(Error("hashing failed"));
+ }
+ resolve(resp.hash);
+ });
+ });
+ }
+
+ function checkRepurchase(contract: string): Promise<any> {
+ const walletMsg = {
+ type: "check-repurchase",
+ detail: {
+ contract: contract
+ },
+ };
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(walletMsg, (resp: any) => {
+ resolve(resp);
+ });
+ });
+ }
+
+ function putHistory(historyEntry: any): Promise<void> {
+ const walletMsg = {
+ type: "put-history-entry",
+ detail: {
+ historyEntry,
+ },
+ };
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(walletMsg, (resp: any) => {
+ resolve();
+ });
+ });
+ }
+
+ function saveOffer(offer: any): Promise<number> {
+ const walletMsg = {
+ type: "save-offer",
+ detail: {
+ offer: {
+ contract: offer.contract,
+ merchant_sig: offer.merchant_sig,
+ H_contract: offer.H_contract,
+ offer_time: new Date().getTime() / 1000
+ },
+ },
+ };
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(walletMsg, (resp: any) => {
+ resolve(resp);
+ });
+ });
+ }
+
+ function init() {
+ chrome.runtime.sendMessage({type: "get-tab-cookie"}, (resp) => {
+ if (chrome.runtime.lastError) {
+ logVerbose && console.log("extension not yet ready");
+ window.setTimeout(init, 200);
+ return;
+ }
+ registerHandlers();
+ // Hack to know when the extension is unloaded
+ let port = chrome.runtime.connect();
+
+ port.onDisconnect.addListener(() => {
+ logVerbose && console.log("chrome runtime disconnected, removing handlers");
+ for (let handler of handlers) {
+ document.removeEventListener(handler.type, handler.listener);
+ }
+ });
+
+ if (resp && resp.type === "fetch") {
+ logVerbose && console.log("it's fetch");
+ taler.internalOfferContractFrom(resp.contractUrl);
+ document.documentElement.style.visibility = "hidden";
+
+ } else if (resp && resp.type === "execute") {
+ logVerbose && console.log("it's execute");
+ document.documentElement.style.visibility = "hidden";
+ taler.internalExecutePayment(resp.contractHash,
+ resp.payUrl,
+ resp.offerUrl);
+ }
+ });
+ }
+
+ logVerbose && console.log("loading Taler content script");
+ init();
+
+ interface HandlerFn {
+ (detail: any, sendResponse: (msg: any) => void): void;
+ }
+
+ function registerHandlers() {
+ /**
+ * Add a handler for a DOM event, which automatically
+ * handles adding sequence numbers to responses.
+ */
+ function addHandler(type: string, handler: HandlerFn) {
+ let handlerWrap = (e: CustomEvent) => {
+ if (e.type != type) {
+ throw Error(`invariant violated`);
+ }
+ let callId: number|undefined = undefined;
+ if (e.detail && e.detail.callId != undefined) {
+ callId = e.detail.callId;
+ }
+ let responder = (msg?: any) => {
+ let fullMsg = Object.assign({}, msg, {callId});
+ let opts = { detail: fullMsg };
+ if ("function" == typeof cloneInto) {
+ opts = cloneInto(opts, document.defaultView);
+ }
+ let evt = new CustomEvent(type + "-result", opts);
+ document.dispatchEvent(evt);
+ };
+ handler(e.detail, responder);
+ };
+ document.addEventListener(type, handlerWrap);
+ handlers.push({type, listener: handlerWrap});
+ }
+
+
+ addHandler("taler-query-id", (msg: any, sendResponse: any) => {
+ // FIXME: maybe include this info in taoer-probe?
+ sendResponse({id: chrome.runtime.id})
+ });
+
+ addHandler("taler-probe", (msg: any, sendResponse: any) => {
+ sendResponse();
+ });
+
+ addHandler("taler-create-reserve", (msg: any) => {
+ let params = {
+ amount: JSON.stringify(msg.amount),
+ callback_url: URI(msg.callback_url)
+ .absoluteTo(document.location.href),
+ bank_url: document.location.href,
+ wt_types: JSON.stringify(msg.wt_types),
+ };
+ let uri = URI(chrome.extension.getURL("/src/pages/confirm-create-reserve.html"));
+ let redirectUrl = uri.query(params).href();
+ window.location.href = redirectUrl;
+ });
+
+ addHandler("taler-confirm-reserve", (msg: any, sendResponse: any) => {
+ let walletMsg = {
+ type: "confirm-reserve",
+ detail: {
+ reservePub: msg.reserve_pub
+ }
+ };
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ sendResponse();
+ });
+ });
+
+
+ addHandler("taler-confirm-contract", async(msg: any) => {
+ if (!msg.contract_wrapper) {
+ console.error("contract wrapper missing");
+ return;
+ }
+
+ const offer = msg.contract_wrapper;
+
+ if (!offer.contract) {
+ console.error("contract field missing");
+ return;
+ }
+
+ if (!offer.H_contract) {
+ console.error("H_contract field missing");
+ return;
+ }
+
+ let walletHashContractMsg = {
+ type: "hash-contract",
+ detail: {contract: offer.contract}
+ };
+
+ let contractHash = await hashContract(offer.contract);
+
+ if (contractHash != offer.H_contract) {
+ console.error("merchant-supplied contract hash is wrong");
+ return;
+ }
+
+ let resp = await checkRepurchase(offer.contract);
+
+ if (resp.error) {
+ console.error("wallet backend error", resp);
+ return;
+ }
+
+ if (resp.isRepurchase) {
+ logVerbose && console.log("doing repurchase");
+ console.assert(resp.existingFulfillmentUrl);
+ console.assert(resp.existingContractHash);
+ window.location.href = subst(resp.existingFulfillmentUrl,
+ resp.existingContractHash);
+
+ } else {
+
+ let merchantName = "(unknown)";
+ try {
+ merchantName = offer.contract.merchant.name;
+ } catch (e) {
+ // bad contract / name not included
+ }
+
+ let historyEntry = {
+ timestamp: (new Date).getTime(),
+ subjectId: `contract-${contractHash}`,
+ type: "offer-contract",
+ detail: {
+ contractHash,
+ merchantName,
+ }
+ };
+ await putHistory(historyEntry);
+ let offerId = await saveOffer(offer);
+
+ const uri = URI(chrome.extension.getURL(
+ "/src/pages/confirm-contract.html"));
+ const params = {
+ offerId: offerId.toString(),
+ };
+ const target = uri.query(params).href();
+ if (msg.replace_navigation === true) {
+ document.location.replace(target);
+ } else {
+ document.location.href = target;
+ }
+ }
+ });
+
+ addHandler("taler-payment-failed", (msg: any, sendResponse: any) => {
+ const walletMsg = {
+ type: "payment-failed",
+ detail: {
+ contractHash: msg.H_contract
+ },
+ };
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ sendResponse();
+ })
+ });
+
+ addHandler("taler-payment-succeeded", (msg: any, sendResponse: any) => {
+ if (!msg.H_contract) {
+ console.error("H_contract missing in taler-payment-succeeded");
+ return;
+ }
+ logVerbose && console.log("got taler-payment-succeeded");
+ const walletMsg = {
+ type: "payment-succeeded",
+ detail: {
+ contractHash: msg.H_contract,
+ },
+ };
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ sendResponse();
+ })
+ });
+
+ addHandler("taler-get-payment", (msg: any, sendResponse: any) => {
+ const walletMsg = {
+ type: "execute-payment",
+ detail: {
+ H_contract: msg.H_contract,
+ },
+ };
+
+ chrome.runtime.sendMessage(walletMsg, (resp) => {
+ if (resp.rateLimitExceeded) {
+ console.error("rate limit exceeded, check for redirect loops");
+ }
+
+ if (!resp.success) {
+ if (msg.offering_url) {
+ window.location.href = msg.offering_url;
+ } else {
+ console.error("execute-payment failed", resp);
+ }
+ return;
+ }
+ let contract = resp.contract;
+ if (!contract) {
+ throw Error("contract missing");
+ }
+
+ // We have the details for then payment, the merchant page
+ // is responsible to give it to the merchant.
+ sendResponse({
+ H_contract: msg.H_contract,
+ contract: resp.contract,
+ payment: resp.payReq,
+ });
+ });
+ });
+ }
+}
diff --git a/src/cryptoApi-test.ts b/src/cryptoApi-test.ts
new file mode 100644
index 000000000..d764ea5dd
--- /dev/null
+++ b/src/cryptoApi-test.ts
@@ -0,0 +1,79 @@
+import {CryptoApi} from "./cryptoApi";
+import {ReserveRecord, Denomination} from "src/types";
+import {test, TestLib} from "testlib/talertest";
+
+let masterPub1: string = "CQQZ9DY3MZ1ARMN5K1VKDETS04Y2QCKMMCFHZSWJWWVN82BTTH00";
+
+let denomValid1: Denomination = {
+ "master_sig": "CJFJCQ48Q45PSGJ5KY94N6M2TPARESM2E15BSPBD95YVVPEARAEQ6V6G4Z2XBMS0QM0F3Y9EYVP276FCS90EQ1578ZC8JHFBZ3NGP3G",
+ "stamp_start": "/Date(1473148381)/",
+ "stamp_expire_withdraw": "/Date(2482300381)/",
+ "stamp_expire_deposit": "/Date(1851580381)/",
+ "denom_pub": "51R7ARKCD5HJTTV5F4G0M818E9SP280A40G2GVH04CR30GHS84R3JHHP6GSM2D9Q6514CGT568R32C9J6CWM4DSH64TM4DSM851K0CA48CVKAC1P6H144C2160T46DHK8CVM4HJ274S38C1M6S338D9N6GWM8DT684T3JCT36S13EC9G88R3EGHQ8S0KJGSQ60SKGD216N33AGJ2651K2E9S60TMCD1N75244HHQ6X33EDJ570R3GGJ2651MACA38D130DA560VK4HHJ68WK2CA26GW3ECSH6D13EC9S88VK2GT66WVK8D9G750K0D9R8RRK4DHQ71332GHK8D23GE26710M2H9K6WVK8HJ38MVKEGA66N23AC9H88VKACT58MV3CCSJ6H1K4DT38GRK0C9M8N33CE1R60V4AHA38H1KECSH6S33JH9N8GRKGH1K68S36GH354520818CMG26C1H60R30C935452081918G2J2G0",
+ "stamp_expire_legal": "/Date(1567756381)/",
+ "value": {
+ "currency": "PUDOS",
+ "value": 0,
+ "fraction": 100000
+ },
+ "fee_withdraw": {
+ "currency": "PUDOS",
+ "value": 0,
+ "fraction": 10000
+ },
+ "fee_deposit": {
+ "currency": "PUDOS",
+ "value": 0,
+ "fraction": 10000
+ },
+ "fee_refresh": {
+ "currency": "PUDOS",
+ "value": 0,
+ "fraction": 10000
+ },
+ "fee_refund": {
+ "currency": "PUDOS",
+ "value": 0,
+ "fraction": 10000
+ }
+};
+
+let denomInvalid1 = JSON.parse(JSON.stringify(denomValid1));
+denomInvalid1.value.value += 1;
+
+test("string hashing", async (t: TestLib) => {
+ let crypto = new CryptoApi();
+ let s = await crypto.hashString("hello taler");
+ let sh = "8RDMADB3YNF3QZBS3V467YZVJAMC2QAQX0TZGVZ6Q5PFRRAJFT70HHN0QF661QR9QWKYMMC7YEMPD679D2RADXCYK8Y669A2A5MKQFR";
+ t.assert(s == sh);
+ t.pass();
+});
+
+test("precoin creation", async (t: TestLib) => {
+ let crypto = new CryptoApi();
+ let {priv, pub} = await crypto.createEddsaKeypair();
+ let r: ReserveRecord = {
+ reserve_pub: pub,
+ reserve_priv: priv,
+ exchange_base_url: "https://example.com/exchange",
+ created: 0,
+ requested_amount: {currency: "PUDOS", value: 0, fraction: 0},
+ precoin_amount: {currency: "PUDOS", value: 0, fraction: 0},
+ current_amount: null,
+ confirmed: false,
+ last_query: null,
+ };
+
+ let precoin = await crypto.createPreCoin(denomValid1, r);
+ t.pass();
+});
+
+test("denom validation", async (t: TestLib) => {
+ let crypto = new CryptoApi();
+ let v: boolean;
+ v = await crypto.isValidDenom(denomValid1, masterPub1);
+ t.assert(v);
+ v = await crypto.isValidDenom(denomInvalid1, masterPub1);
+ t.assert(!v);
+ t.pass();
+});
diff --git a/src/cryptoApi.ts b/src/cryptoApi.ts
new file mode 100644
index 000000000..41f6c9593
--- /dev/null
+++ b/src/cryptoApi.ts
@@ -0,0 +1,256 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+
+/**
+ * API to access the Taler crypto worker thread.
+ * @author Florian Dold
+ */
+
+
+import {PreCoin, Coin, ReserveRecord, AmountJson} from "./types";
+import {Denomination} from "./types";
+import {Offer} from "./wallet";
+import {CoinWithDenom} from "./wallet";
+import {PayCoinInfo} from "./types";
+import {RefreshSession} from "./types";
+
+
+interface WorkerState {
+ /**
+ * The actual worker thread.
+ */
+ w: Worker|null;
+
+ /**
+ * Work we're currently executing or null if not busy.
+ */
+ currentWorkItem: WorkItem|null;
+
+ /**
+ * Timer to terminate the worker if it's not busy enough.
+ */
+ terminationTimerHandle: number|null;
+}
+
+interface WorkItem {
+ operation: string;
+ args: any[];
+ resolve: any;
+ reject: any;
+
+ /**
+ * Serial id to identify a matching response.
+ */
+ rpcId: number;
+}
+
+
+/**
+ * Number of different priorities. Each priority p
+ * must be 0 <= p < NUM_PRIO.
+ */
+const NUM_PRIO = 5;
+
+export class CryptoApi {
+ private nextRpcId: number = 1;
+ private workers: WorkerState[];
+ private workQueues: WorkItem[][];
+ /**
+ * Number of busy workers.
+ */
+ private numBusy: number = 0;
+
+ /**
+ * Start a worker (if not started) and set as busy.
+ */
+ wake<T>(ws: WorkerState, work: WorkItem): void {
+ if (ws.currentWorkItem != null) {
+ throw Error("assertion failed");
+ }
+ ws.currentWorkItem = work;
+ this.numBusy++;
+ if (!ws.w) {
+ let w = new Worker("/src/cryptoWorker.js");
+ w.onmessage = (m: MessageEvent) => this.handleWorkerMessage(ws, m);
+ w.onerror = (e: ErrorEvent) => this.handleWorkerError(ws, e);
+ ws.w = w;
+ }
+
+ let msg: any = {
+ operation: work.operation, args: work.args,
+ id: work.rpcId
+ };
+ this.resetWorkerTimeout(ws);
+ ws.w!.postMessage(msg);
+ }
+
+ resetWorkerTimeout(ws: WorkerState) {
+ if (ws.terminationTimerHandle != null) {
+ clearTimeout(ws.terminationTimerHandle);
+ }
+ let destroy = () => {
+ // terminate worker if it's idle
+ if (ws.w && ws.currentWorkItem == null) {
+ ws.w!.terminate();
+ ws.w = null;
+ }
+ };
+ ws.terminationTimerHandle = setTimeout(destroy, 20 * 1000);
+ }
+
+ handleWorkerError(ws: WorkerState, e: ErrorEvent) {
+ if (ws.currentWorkItem) {
+ console.error(`error in worker during ${ws.currentWorkItem!.operation}`,
+ e);
+ } else {
+ console.error("error in worker", e);
+ }
+ console.error(e.message);
+ try {
+ ws.w!.terminate();
+ ws.w = null;
+ } catch (e) {
+ console.error(e);
+ }
+ if (ws.currentWorkItem != null) {
+ ws.currentWorkItem.reject(e);
+ ws.currentWorkItem = null;
+ this.numBusy--;
+ }
+ this.findWork(ws);
+ }
+
+ findWork(ws: WorkerState) {
+ // try to find more work for this worker
+ for (let i = 0; i < NUM_PRIO; i++) {
+ let q = this.workQueues[NUM_PRIO - i - 1];
+ if (q.length != 0) {
+ let work: WorkItem = q.shift()!;
+ this.wake(ws, work);
+ return;
+ }
+ }
+ }
+
+ handleWorkerMessage(ws: WorkerState, msg: MessageEvent) {
+ let id = msg.data.id;
+ if (typeof id !== "number") {
+ console.error("rpc id must be number");
+ return;
+ }
+ let currentWorkItem = ws.currentWorkItem;
+ ws.currentWorkItem = null;
+ this.numBusy--;
+ this.findWork(ws);
+ if (!currentWorkItem) {
+ console.error("unsolicited response from worker");
+ return;
+ }
+ if (id != currentWorkItem.rpcId) {
+ console.error(`RPC with id ${id} has no registry entry`);
+ return;
+ }
+ currentWorkItem.resolve(msg.data.result);
+ }
+
+ constructor() {
+ this.workers = new Array<WorkerState>((navigator as any)["hardwareConcurrency"] || 2);
+
+ for (let i = 0; i < this.workers.length; i++) {
+ this.workers[i] = {
+ w: null,
+ terminationTimerHandle: null,
+ currentWorkItem: null,
+ };
+ }
+ this.workQueues = [];
+ for (let i = 0; i < NUM_PRIO; i++) {
+ this.workQueues.push([]);
+ }
+ }
+
+ private doRpc<T>(operation: string, priority: number,
+ ...args: any[]): Promise<T> {
+
+ return new Promise((resolve, reject) => {
+ let rpcId = this.nextRpcId++;
+ let workItem: WorkItem = {operation, args, resolve, reject, rpcId};
+
+ if (this.numBusy == this.workers.length) {
+ let q = this.workQueues[priority];
+ if (!q) {
+ throw Error("assertion failed");
+ }
+ this.workQueues[priority].push(workItem);
+ return;
+ }
+
+ for (let i = 0; i < this.workers.length; i++) {
+ let ws = this.workers[i];
+ if (ws.currentWorkItem != null) {
+ continue;
+ }
+
+ this.wake<T>(ws, workItem);
+ return;
+ }
+
+ throw Error("assertion failed");
+ });
+ }
+
+
+ createPreCoin(denom: Denomination, reserve: ReserveRecord): Promise<PreCoin> {
+ return this.doRpc("createPreCoin", 1, denom, reserve);
+ }
+
+ hashString(str: string): Promise<string> {
+ return this.doRpc("hashString", 1, str);
+ }
+
+ isValidDenom(denom: Denomination,
+ masterPub: string): Promise<boolean> {
+ return this.doRpc("isValidDenom", 2, denom, masterPub);
+ }
+
+ signDeposit(offer: Offer,
+ cds: CoinWithDenom[]): Promise<PayCoinInfo> {
+ return this.doRpc("signDeposit", 3, offer, cds);
+ }
+
+ createEddsaKeypair(): Promise<{priv: string, pub: string}> {
+ return this.doRpc("createEddsaKeypair", 1);
+ }
+
+ rsaUnblind(sig: string, bk: string, pk: string): Promise<string> {
+ return this.doRpc("rsaUnblind", 4, sig, bk, pk);
+ }
+
+ createRefreshSession(exchangeBaseUrl: string,
+ kappa: number,
+ meltCoin: Coin,
+ newCoinDenoms: Denomination[],
+ meltFee: AmountJson): Promise<RefreshSession> {
+ return this.doRpc("createRefreshSession",
+ 4,
+ exchangeBaseUrl,
+ kappa,
+ meltCoin,
+ newCoinDenoms,
+ meltFee);
+ }
+}
diff --git a/src/cryptoLib.ts b/src/cryptoLib.ts
new file mode 100644
index 000000000..1db686756
--- /dev/null
+++ b/src/cryptoLib.ts
@@ -0,0 +1,345 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+/**
+ * Web worker for crypto operations.
+ * @author Florian Dold
+ */
+
+"use strict";
+
+import * as native from "./emscriptif";
+import {
+ PreCoin, PayCoinInfo, AmountJson,
+ RefreshSession, RefreshPreCoin, ReserveRecord
+} from "./types";
+import create = chrome.alarms.create;
+import {Offer} from "./wallet";
+import {CoinWithDenom} from "./wallet";
+import {CoinPaySig, Coin} from "./types";
+import {Denomination, Amounts} from "./types";
+import {Amount} from "./emscriptif";
+import {HashContext} from "./emscriptif";
+import {RefreshMeltCoinAffirmationPS} from "./emscriptif";
+import {EddsaPublicKey} from "./emscriptif";
+import {HashCode} from "./emscriptif";
+
+
+export function main(worker: Worker) {
+ worker.onmessage = (msg: MessageEvent) => {
+ if (!Array.isArray(msg.data.args)) {
+ console.error("args must be array");
+ return;
+ }
+ if (typeof msg.data.id != "number") {
+ console.error("RPC id must be number");
+ }
+ if (typeof msg.data.operation != "string") {
+ console.error("RPC operation must be string");
+ }
+ let f = (RpcFunctions as any)[msg.data.operation];
+ if (!f) {
+ console.error(`unknown operation: '${msg.data.operation}'`);
+ return;
+ }
+ let res = f(...msg.data.args);
+ worker.postMessage({result: res, id: msg.data.id});
+ }
+}
+
+
+namespace RpcFunctions {
+
+ /**
+ * Create a pre-coin of the given denomination to be withdrawn from then given
+ * reserve.
+ */
+ export function createPreCoin(denom: Denomination,
+ reserve: ReserveRecord): PreCoin {
+ let reservePriv = new native.EddsaPrivateKey();
+ reservePriv.loadCrock(reserve.reserve_priv);
+ let reservePub = new native.EddsaPublicKey();
+ reservePub.loadCrock(reserve.reserve_pub);
+ let denomPub = native.RsaPublicKey.fromCrock(denom.denom_pub);
+ let coinPriv = native.EddsaPrivateKey.create();
+ let coinPub = coinPriv.getPublicKey();
+ let blindingFactor = native.RsaBlindingKeySecret.create();
+ let pubHash: native.HashCode = coinPub.hash();
+ let ev = native.rsaBlind(pubHash,
+ blindingFactor,
+ denomPub);
+
+ if (!ev) {
+ throw Error("couldn't blind (malicious exchange key?)");
+ }
+
+ if (!denom.fee_withdraw) {
+ throw Error("Field fee_withdraw missing");
+ }
+
+ let amountWithFee = new native.Amount(denom.value);
+ amountWithFee.add(new native.Amount(denom.fee_withdraw));
+ let withdrawFee = new native.Amount(denom.fee_withdraw);
+
+ // Signature
+ let withdrawRequest = new native.WithdrawRequestPS({
+ reserve_pub: reservePub,
+ amount_with_fee: amountWithFee.toNbo(),
+ withdraw_fee: withdrawFee.toNbo(),
+ h_denomination_pub: denomPub.encode().hash(),
+ h_coin_envelope: ev.hash()
+ });
+
+ var sig = native.eddsaSign(withdrawRequest.toPurpose(), reservePriv);
+
+ let preCoin: PreCoin = {
+ reservePub: reservePub.toCrock(),
+ blindingKey: blindingFactor.toCrock(),
+ coinPub: coinPub.toCrock(),
+ coinPriv: coinPriv.toCrock(),
+ denomPub: denomPub.encode().toCrock(),
+ exchangeBaseUrl: reserve.exchange_base_url,
+ withdrawSig: sig.toCrock(),
+ coinEv: ev.toCrock(),
+ coinValue: denom.value
+ };
+ return preCoin;
+ }
+
+
+ export function isValidDenom(denom: Denomination,
+ masterPub: string): boolean {
+ let p = new native.DenominationKeyValidityPS({
+ master: native.EddsaPublicKey.fromCrock(masterPub),
+ denom_hash: native.RsaPublicKey.fromCrock(denom.denom_pub)
+ .encode()
+ .hash(),
+ expire_legal: native.AbsoluteTimeNbo.fromTalerString(denom.stamp_expire_legal),
+ expire_spend: native.AbsoluteTimeNbo.fromTalerString(denom.stamp_expire_deposit),
+ expire_withdraw: native.AbsoluteTimeNbo.fromTalerString(denom.stamp_expire_withdraw),
+ start: native.AbsoluteTimeNbo.fromTalerString(denom.stamp_start),
+ value: (new native.Amount(denom.value)).toNbo(),
+ fee_deposit: (new native.Amount(denom.fee_deposit)).toNbo(),
+ fee_refresh: (new native.Amount(denom.fee_refresh)).toNbo(),
+ fee_withdraw: (new native.Amount(denom.fee_withdraw)).toNbo(),
+ fee_refund: (new native.Amount(denom.fee_refund)).toNbo(),
+ });
+
+ let nativeSig = new native.EddsaSignature();
+ nativeSig.loadCrock(denom.master_sig);
+
+ let nativePub = native.EddsaPublicKey.fromCrock(masterPub);
+
+ return native.eddsaVerify(native.SignaturePurpose.MASTER_DENOMINATION_KEY_VALIDITY,
+ p.toPurpose(),
+ nativeSig,
+ nativePub);
+
+ }
+
+
+ export function createEddsaKeypair(): {priv: string, pub: string} {
+ const priv = native.EddsaPrivateKey.create();
+ const pub = priv.getPublicKey();
+ return {priv: priv.toCrock(), pub: pub.toCrock()};
+ }
+
+
+ export function rsaUnblind(sig: string, bk: string, pk: string): string {
+ let denomSig = native.rsaUnblind(native.RsaSignature.fromCrock(sig),
+ native.RsaBlindingKeySecret.fromCrock(bk),
+ native.RsaPublicKey.fromCrock(pk));
+ return denomSig.encode().toCrock()
+ }
+
+
+ /**
+ * Generate updated coins (to store in the database)
+ * and deposit permissions for each given coin.
+ */
+ export function signDeposit(offer: Offer,
+ cds: CoinWithDenom[]): PayCoinInfo {
+ let ret: PayCoinInfo = [];
+ let amountSpent = native.Amount.getZero(cds[0].coin.currentAmount.currency);
+ let amountRemaining = new native.Amount(offer.contract.amount);
+ for (let cd of cds) {
+ let coinSpend: Amount;
+
+ if (amountRemaining.value == 0 && amountRemaining.fraction == 0) {
+ break;
+ }
+
+ if (amountRemaining.cmp(new native.Amount(cd.coin.currentAmount)) < 0) {
+ coinSpend = new native.Amount(amountRemaining.toJson());
+ } else {
+ coinSpend = new native.Amount(cd.coin.currentAmount);
+ }
+
+ amountSpent.add(coinSpend);
+ amountRemaining.sub(coinSpend);
+
+ let newAmount = new native.Amount(cd.coin.currentAmount);
+ newAmount.sub(coinSpend);
+ cd.coin.currentAmount = newAmount.toJson();
+ cd.coin.dirty = true;
+ cd.coin.transactionPending = true;
+
+ let d = new native.DepositRequestPS({
+ h_contract: native.HashCode.fromCrock(offer.H_contract),
+ h_wire: native.HashCode.fromCrock(offer.contract.H_wire),
+ amount_with_fee: coinSpend.toNbo(),
+ coin_pub: native.EddsaPublicKey.fromCrock(cd.coin.coinPub),
+ deposit_fee: new native.Amount(cd.denom.fee_deposit).toNbo(),
+ merchant: native.EddsaPublicKey.fromCrock(offer.contract.merchant_pub),
+ refund_deadline: native.AbsoluteTimeNbo.fromTalerString(offer.contract.refund_deadline),
+ timestamp: native.AbsoluteTimeNbo.fromTalerString(offer.contract.timestamp),
+ transaction_id: native.UInt64.fromNumber(offer.contract.transaction_id),
+ });
+
+ let coinSig = native.eddsaSign(d.toPurpose(),
+ native.EddsaPrivateKey.fromCrock(cd.coin.coinPriv))
+ .toCrock();
+
+ let s: CoinPaySig = {
+ coin_sig: coinSig,
+ coin_pub: cd.coin.coinPub,
+ ub_sig: cd.coin.denomSig,
+ denom_pub: cd.coin.denomPub,
+ f: coinSpend.toJson(),
+ };
+ ret.push({sig: s, updatedCoin: cd.coin});
+ }
+ return ret;
+ }
+
+
+ export function createRefreshSession(exchangeBaseUrl: string,
+ kappa: number,
+ meltCoin: Coin,
+ newCoinDenoms: Denomination[],
+ meltFee: AmountJson): RefreshSession {
+
+ let valueWithFee = Amounts.getZero(newCoinDenoms[0].value.currency);
+
+ for (let ncd of newCoinDenoms) {
+ valueWithFee = Amounts.add(valueWithFee,
+ ncd.value,
+ ncd.fee_withdraw).amount;
+ }
+
+ // melt fee
+ valueWithFee = Amounts.add(valueWithFee, meltFee).amount;
+
+ let sessionHc = new HashContext();
+
+ let transferPubs: string[] = [];
+ let transferPrivs: string[] = [];
+
+ let preCoinsForGammas: RefreshPreCoin[][] = [];
+
+ for (let i = 0; i < kappa; i++) {
+ let t = native.EcdhePrivateKey.create();
+ let pub = t.getPublicKey();
+ sessionHc.read(pub);
+ transferPrivs.push(t.toCrock());
+ transferPubs.push(pub.toCrock());
+ }
+
+ for (let i = 0; i < newCoinDenoms.length; i++) {
+ let r = native.RsaPublicKey.fromCrock(newCoinDenoms[i].denom_pub);
+ sessionHc.read(r.encode());
+ }
+
+ sessionHc.read(native.EddsaPublicKey.fromCrock(meltCoin.coinPub));
+ sessionHc.read((new native.Amount(valueWithFee)).toNbo());
+
+ for (let i = 0; i < kappa; i++) {
+ let preCoins: RefreshPreCoin[] = [];
+ for (let j = 0; j < newCoinDenoms.length; j++) {
+
+ let transferPriv = native.EcdhePrivateKey.fromCrock(transferPrivs[i]);
+ let oldCoinPub = native.EddsaPublicKey.fromCrock(meltCoin.coinPub);
+ let transferSecret = native.ecdhEddsa(transferPriv, oldCoinPub);
+
+ let fresh = native.setupFreshCoin(transferSecret, j);
+
+ let coinPriv = fresh.priv;
+ let coinPub = coinPriv.getPublicKey();
+ let blindingFactor = fresh.blindingKey;
+ let pubHash: native.HashCode = coinPub.hash();
+ let denomPub = native.RsaPublicKey.fromCrock(newCoinDenoms[j].denom_pub);
+ let ev = native.rsaBlind(pubHash,
+ blindingFactor,
+ denomPub);
+ if (!ev) {
+ throw Error("couldn't blind (malicious exchange key?)");
+ }
+ let preCoin: RefreshPreCoin = {
+ blindingKey: blindingFactor.toCrock(),
+ coinEv: ev.toCrock(),
+ publicKey: coinPub.toCrock(),
+ privateKey: coinPriv.toCrock(),
+ };
+ preCoins.push(preCoin);
+ sessionHc.read(ev);
+ }
+ preCoinsForGammas.push(preCoins);
+ }
+
+ let sessionHash = new HashCode();
+ sessionHash.alloc();
+ sessionHc.finish(sessionHash);
+
+ let confirmData = new RefreshMeltCoinAffirmationPS({
+ coin_pub: EddsaPublicKey.fromCrock(meltCoin.coinPub),
+ amount_with_fee: (new Amount(valueWithFee)).toNbo(),
+ session_hash: sessionHash,
+ melt_fee: (new Amount(meltFee)).toNbo()
+ });
+
+
+ let confirmSig: string = native.eddsaSign(confirmData.toPurpose(),
+ native.EddsaPrivateKey.fromCrock(
+ meltCoin.coinPriv)).toCrock();
+
+ let valueOutput = Amounts.getZero(newCoinDenoms[0].value.currency);
+ for (let denom of newCoinDenoms) {
+ valueOutput = Amounts.add(valueOutput, denom.value).amount;
+ }
+
+ let refreshSession: RefreshSession = {
+ meltCoinPub: meltCoin.coinPub,
+ newDenoms: newCoinDenoms.map((d) => d.denom_pub),
+ confirmSig,
+ valueWithFee,
+ transferPubs,
+ preCoinsForGammas,
+ hash: sessionHash.toCrock(),
+ norevealIndex: undefined,
+ exchangeBaseUrl,
+ transferPrivs,
+ finished: false,
+ valueOutput,
+ };
+
+ return refreshSession;
+ }
+
+ export function hashString(str: string): string {
+ const b = native.ByteArray.fromStringWithNull(str);
+ return b.hash().toCrock();
+ }
+}
diff --git a/src/cryptoWorker.ts b/src/cryptoWorker.ts
new file mode 100644
index 000000000..661b6e174
--- /dev/null
+++ b/src/cryptoWorker.ts
@@ -0,0 +1,64 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+/**
+ * Web worker for crypto operations.
+ * @author Florian Dold
+ */
+
+"use strict";
+
+
+importScripts("/src/emscripten/taler-emscripten-lib.js",
+ "/src/vendor/system-csp-production.src.js");
+
+
+// TypeScript does not allow ".js" extensions in the
+// module name, so SystemJS must add it.
+System.config({
+ defaultJSExtensions: true,
+ map: {
+ "src": "/src",
+ },
+});
+
+// We expect that in the manifest, the emscripten js is loaded
+// becore the background page.
+// Currently it is not possible to use SystemJS to load the emscripten js.
+declare var Module: any;
+if ("object" !== typeof Module) {
+ throw Error("emscripten not loaded, no 'Module' defined");
+}
+
+
+// Manually register the emscripten js as a SystemJS, so that
+// we can use it from TypeScript by importing it.
+
+{
+ let mod = System.newModule({Module: Module, default: Module});
+ let modName = System.normalizeSync("/src/emscripten/taler-emscripten-lib");
+ console.log("registering", modName);
+ System.set(modName, mod);
+}
+
+System.import("/src/cryptoLib")
+ .then((m: any) => {
+ m.main(self);
+ })
+ .catch((e: Error) => {
+ console.log("crypto worker failed");
+ console.error(e.stack);
+ });
diff --git a/src/db.ts b/src/db.ts
new file mode 100644
index 000000000..9cffc164c
--- /dev/null
+++ b/src/db.ts
@@ -0,0 +1,117 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+"use strict";
+import {IExchangeInfo} from "./types";
+
+/**
+ * Declarations and helpers for
+ * things that are stored in the wallet's
+ * database.
+ * @module Db
+ * @author Florian Dold
+ */
+
+const DB_NAME = "taler";
+const DB_VERSION = 11;
+
+import {Stores} from "./wallet";
+import {Store, Index} from "./query";
+
+
+
+
+
+/**
+ * Return a promise that resolves
+ * to the taler wallet db.
+ */
+export function openTalerDb(): Promise<IDBDatabase> {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
+ req.onerror = (e) => {
+ reject(e);
+ };
+ req.onsuccess = (e) => {
+ resolve(req.result);
+ };
+ req.onupgradeneeded = (e) => {
+ const db = req.result;
+ console.log("DB: upgrade needed: oldVersion = " + e.oldVersion);
+ switch (e.oldVersion) {
+ case 0: // DB does not exist yet
+
+ for (let n in Stores) {
+ if ((Stores as any)[n] instanceof Store) {
+ let si: Store<any> = (Stores as any)[n];
+ const s = db.createObjectStore(si.name, si.storeParams);
+ for (let indexName in (si as any)) {
+ if ((si as any)[indexName] instanceof Index) {
+ let ii: Index<any,any> = (si as any)[indexName];
+ s.createIndex(ii.indexName, ii.keyPath);
+ }
+ }
+ }
+ }
+ break;
+ default:
+ if (e.oldVersion != DB_VERSION) {
+ window.alert("Incompatible wallet dababase version, please reset" +
+ " db.");
+ chrome.browserAction.setBadgeText({text: "err"});
+ chrome.browserAction.setBadgeBackgroundColor({color: "#F00"});
+ throw Error("incompatible DB");
+ }
+ break;
+ }
+ };
+ });
+}
+
+
+export function exportDb(db: IDBDatabase): Promise<any> {
+ let dump = {
+ name: db.name,
+ version: db.version,
+ stores: {} as {[s: string]: any},
+ };
+
+ return new Promise((resolve, reject) => {
+
+ let tx = db.transaction(Array.from(db.objectStoreNames));
+ tx.addEventListener("complete", () => {
+ resolve(dump);
+ });
+ for (let i = 0; i < db.objectStoreNames.length; i++) {
+ let name = db.objectStoreNames[i];
+ let storeDump = {} as {[s: string]: any};
+ dump.stores[name] = storeDump;
+ let store = tx.objectStore(name)
+ .openCursor()
+ .addEventListener("success", (e: Event) => {
+ let cursor = (e.target as any).result;
+ if (cursor) {
+ storeDump[cursor.key] = cursor.value;
+ cursor.continue();
+ }
+ });
+ }
+ });
+}
+
+export function deleteDb() {
+ indexedDB.deleteDatabase(DB_NAME);
+}
diff --git a/src/emscripten/taler-emscripten-lib.d.ts b/src/emscripten/taler-emscripten-lib.d.ts
new file mode 100644
index 000000000..97821d9ef
--- /dev/null
+++ b/src/emscripten/taler-emscripten-lib.d.ts
@@ -0,0 +1,56 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+export interface EmscFunGen {
+ (name: string,
+ ret: string,
+ args: string[]): ((...x: (number|string)[]) => any);
+ (name: string,
+ ret: "number",
+ args: string[]): ((...x: (number|string)[]) => number);
+ (name: string,
+ ret: "void",
+ args: string[]): ((...x: (number|string)[]) => void);
+ (name: string,
+ ret: "string",
+ args: string[]): ((...x: (number|string)[]) => string);
+}
+
+
+export declare namespace Module {
+ var cwrap: EmscFunGen;
+
+ function ccall(name: string, ret:"number"|"string", argTypes: any[], args: any[]): any
+
+ function stringToUTF8(s: string, addr: number, maxLength: number): void
+
+ function _free(ptr: number): void;
+
+ function _malloc(n: number): number;
+
+ function Pointer_stringify(p: number, len?: number): string;
+
+ function getValue(ptr: number, type: string, noSafe?: boolean): number;
+
+ function setValue(ptr: number, value: number, type: string,
+ noSafe?: boolean): void;
+
+ function writeStringToMemory(s: string,
+ buffer: number,
+ dontAddNull?: boolean): void;
+}
+
+export default Module;
diff --git a/src/emscriptif-test.ts b/src/emscriptif-test.ts
new file mode 100644
index 000000000..ddafa32bc
--- /dev/null
+++ b/src/emscriptif-test.ts
@@ -0,0 +1,21 @@
+import {test, TestLib} from "testlib/talertest";
+import * as native from "./emscriptif";
+
+test("string hashing", (t: TestLib) => {
+ let x = native.ByteArray.fromStringWithNull("hello taler");
+ let h = "8RDMADB3YNF3QZBS3V467YZVJAMC2QAQX0TZGVZ6Q5PFRRAJFT70HHN0QF661QR9QWKYMMC7YEMPD679D2RADXCYK8Y669A2A5MKQFR"
+ let hc = x.hash().toCrock();
+ console.log(`# hc ${hc}`);
+ t.assert(h === hc, "must equal");
+ t.pass();
+});
+
+test("signing", (t: TestLib) => {
+ let x = native.ByteArray.fromStringWithNull("hello taler");
+ let priv = native.EddsaPrivateKey.create();
+ let pub = priv.getPublicKey();
+ let purpose = new native.EccSignaturePurpose(native.SignaturePurpose.TEST, x);
+ let sig = native.eddsaSign(purpose, priv);
+ t.assert(native.eddsaVerify(native.SignaturePurpose.TEST, purpose, sig, pub));
+ t.pass();
+});
diff --git a/src/emscriptif.ts b/src/emscriptif.ts
new file mode 100644
index 000000000..afb689b4d
--- /dev/null
+++ b/src/emscriptif.ts
@@ -0,0 +1,1244 @@
+/*
+ This file is part of TALER
+ (C) 2015 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+import {AmountJson} from "./types";
+import Module, {EmscFunGen} from "src/emscripten/taler-emscripten-lib";
+
+/**
+ * High-level interface to emscripten-compiled modules used
+ * by the wallet.
+ *
+ * @author Florian Dold
+ */
+
+"use strict";
+
+// Size of a native pointer.
+const PTR_SIZE = 4;
+
+const GNUNET_OK = 1;
+const GNUNET_YES = 1;
+const GNUNET_NO = 0;
+const GNUNET_SYSERR = -1;
+
+
+const getEmsc: EmscFunGen = (name: string, ret: any, argTypes: any[]) => {
+ return (...args: any[]) => {
+ return Module.ccall(name, ret, argTypes, args);
+ }
+};
+
+
+/**
+ * Wrapped emscripten functions that do not allocate any memory.
+ */
+const emsc = {
+ free: (ptr: number) => Module._free(ptr),
+ get_value: getEmsc("TALER_WR_get_value",
+ "number",
+ ["number"]),
+ get_fraction: getEmsc("TALER_WR_get_fraction",
+ "number",
+ ["number"]),
+ get_currency: getEmsc("TALER_WR_get_currency",
+ "string",
+ ["number"]),
+ amount_add: getEmsc("TALER_amount_add",
+ "number",
+ ["number", "number", "number"]),
+ amount_subtract: getEmsc("TALER_amount_subtract",
+ "number",
+ ["number", "number", "number"]),
+ amount_normalize: getEmsc("TALER_amount_normalize",
+ "void",
+ ["number"]),
+ amount_get_zero: getEmsc("TALER_amount_get_zero",
+ "number",
+ ["string", "number"]),
+ amount_cmp: getEmsc("TALER_amount_cmp",
+ "number",
+ ["number", "number"]),
+ amount_hton: getEmsc("TALER_amount_hton",
+ "void",
+ ["number", "number"]),
+ amount_ntoh: getEmsc("TALER_amount_ntoh",
+ "void",
+ ["number", "number"]),
+ hash: getEmsc("GNUNET_CRYPTO_hash",
+ "void",
+ ["number", "number", "number"]),
+ memmove: getEmsc("memmove",
+ "number",
+ ["number", "number", "number"]),
+ rsa_public_key_free: getEmsc("GNUNET_CRYPTO_rsa_public_key_free",
+ "void",
+ ["number"]),
+ rsa_signature_free: getEmsc("GNUNET_CRYPTO_rsa_signature_free",
+ "void",
+ ["number"]),
+ string_to_data: getEmsc("GNUNET_STRINGS_string_to_data",
+ "number",
+ ["number", "number", "number", "number"]),
+ eddsa_sign: getEmsc("GNUNET_CRYPTO_eddsa_sign",
+ "number",
+ ["number", "number", "number"]),
+ eddsa_verify: getEmsc("GNUNET_CRYPTO_eddsa_verify",
+ "number",
+ ["number", "number", "number", "number"]),
+ hash_create_random: getEmsc("GNUNET_CRYPTO_hash_create_random",
+ "void",
+ ["number", "number"]),
+ rsa_blinding_key_destroy: getEmsc("GNUNET_CRYPTO_rsa_blinding_key_free",
+ "void",
+ ["number"]),
+ random_block: getEmsc("GNUNET_CRYPTO_random_block",
+ "void",
+ ["number", "number", "number"]),
+ hash_context_abort: getEmsc("GNUNET_CRYPTO_hash_context_abort",
+ "void",
+ ["number"]),
+ hash_context_read: getEmsc("GNUNET_CRYPTO_hash_context_read",
+ "void",
+ ["number", "number", "number"]),
+ hash_context_finish: getEmsc("GNUNET_CRYPTO_hash_context_finish",
+ "void",
+ ["number", "number"]),
+ ecdh_eddsa: getEmsc("GNUNET_CRYPTO_ecdh_eddsa",
+ "number",
+ ["number", "number", "number"]),
+
+ setup_fresh_coin: getEmsc(
+ "TALER_setup_fresh_coin",
+ "void",
+ ["number", "number", "number"]),
+};
+
+const emscAlloc = {
+ get_amount: getEmsc("TALER_WRALL_get_amount",
+ "number",
+ ["number", "number", "number", "string"]),
+ eddsa_key_create: getEmsc("GNUNET_CRYPTO_eddsa_key_create",
+ "number", []),
+ ecdsa_key_create: getEmsc("GNUNET_CRYPTO_ecdsa_key_create",
+ "number", []),
+ ecdhe_key_create: getEmsc("GNUNET_CRYPTO_ecdhe_key_create",
+ "number", []),
+ eddsa_public_key_from_private: getEmsc(
+ "TALER_WRALL_eddsa_public_key_from_private",
+ "number",
+ ["number"]),
+ ecdsa_public_key_from_private: getEmsc(
+ "TALER_WRALL_ecdsa_public_key_from_private",
+ "number",
+ ["number"]),
+ ecdhe_public_key_from_private: getEmsc(
+ "TALER_WRALL_ecdhe_public_key_from_private",
+ "number",
+ ["number"]),
+ data_to_string_alloc: getEmsc("GNUNET_STRINGS_data_to_string_alloc",
+ "number",
+ ["number", "number"]),
+ purpose_create: getEmsc("TALER_WRALL_purpose_create",
+ "number",
+ ["number", "number", "number"]),
+ rsa_blind: getEmsc("GNUNET_CRYPTO_rsa_blind",
+ "number",
+ ["number", "number", "number", "number", "number"]),
+ rsa_blinding_key_create: getEmsc("GNUNET_CRYPTO_rsa_blinding_key_create",
+ "number",
+ ["number"]),
+ rsa_blinding_key_encode: getEmsc("GNUNET_CRYPTO_rsa_blinding_key_encode",
+ "number",
+ ["number", "number"]),
+ rsa_signature_encode: getEmsc("GNUNET_CRYPTO_rsa_signature_encode",
+ "number",
+ ["number", "number"]),
+ rsa_blinding_key_decode: getEmsc("GNUNET_CRYPTO_rsa_blinding_key_decode",
+ "number",
+ ["number", "number"]),
+ rsa_public_key_decode: getEmsc("GNUNET_CRYPTO_rsa_public_key_decode",
+ "number",
+ ["number", "number"]),
+ rsa_signature_decode: getEmsc("GNUNET_CRYPTO_rsa_signature_decode",
+ "number",
+ ["number", "number"]),
+ rsa_public_key_encode: getEmsc("GNUNET_CRYPTO_rsa_public_key_encode",
+ "number",
+ ["number", "number"]),
+ rsa_unblind: getEmsc("GNUNET_CRYPTO_rsa_unblind",
+ "number",
+ ["number", "number", "number"]),
+ hash_context_start: getEmsc("GNUNET_CRYPTO_hash_context_start",
+ "number",
+ []),
+ malloc: (size: number) => Module._malloc(size),
+};
+
+
+export enum SignaturePurpose {
+ RESERVE_WITHDRAW = 1200,
+ WALLET_COIN_DEPOSIT = 1201,
+ MASTER_DENOMINATION_KEY_VALIDITY = 1025,
+ WALLET_COIN_MELT = 1202,
+ TEST = 4242,
+}
+
+export enum RandomQuality {
+ WEAK = 0,
+ STRONG = 1,
+ NONCE = 2
+}
+
+interface ArenaObject {
+ destroy(): void;
+}
+
+
+export class HashContext implements ArenaObject {
+ private hashContextPtr: number | undefined;
+
+ constructor() {
+ this.hashContextPtr = emscAlloc.hash_context_start();
+ }
+
+ read(obj: PackedArenaObject): void {
+ if (!this.hashContextPtr) {
+ throw Error("assertion failed");
+ }
+ emsc.hash_context_read(this.hashContextPtr, obj.nativePtr, obj.size());
+ }
+
+ finish(h: HashCode) {
+ if (!this.hashContextPtr) {
+ throw Error("assertion failed");
+ }
+ h.alloc();
+ emsc.hash_context_finish(this.hashContextPtr, h.nativePtr);
+ }
+
+ destroy(): void {
+ if (this.hashContextPtr) {
+ emsc.hash_context_abort(this.hashContextPtr);
+ }
+ this.hashContextPtr = undefined;
+ }
+}
+
+
+abstract class MallocArenaObject implements ArenaObject {
+ protected _nativePtr: number | undefined = undefined;
+
+ /**
+ * Is this a weak reference to the underlying memory?
+ */
+ isWeak = false;
+ arena: Arena;
+
+ destroy(): void {
+ if (this._nativePtr && !this.isWeak) {
+ emsc.free(this.nativePtr);
+ this._nativePtr = undefined;
+ }
+ }
+
+ constructor(arena?: Arena) {
+ if (!arena) {
+ if (arenaStack.length == 0) {
+ throw Error("No arena available")
+ }
+ arena = arenaStack[arenaStack.length - 1];
+ }
+ arena.put(this);
+ this.arena = arena;
+ }
+
+ alloc(size: number) {
+ if (this._nativePtr !== undefined) {
+ throw Error("Double allocation");
+ }
+ this.nativePtr = emscAlloc.malloc(size);
+ }
+
+ set nativePtr(v: number) {
+ if (v === undefined) {
+ throw Error("Native pointer must be a number or null");
+ }
+ this._nativePtr = v;
+ }
+
+ get nativePtr() {
+ // We want to allow latent allocation
+ // of native wrappers, but we never want to
+ // pass 'undefined' to emscripten.
+ if (this._nativePtr === undefined) {
+ throw Error("Native pointer not initialized");
+ }
+ return this._nativePtr;
+ }
+}
+
+
+interface Arena {
+ put(obj: ArenaObject): void;
+ destroy(): void;
+}
+
+
+/**
+ * Arena that must be manually destroyed.
+ */
+class SimpleArena implements Arena {
+ heap: Array<ArenaObject>;
+
+ constructor() {
+ this.heap = [];
+ }
+
+ put(obj: ArenaObject) {
+ this.heap.push(obj);
+ }
+
+ destroy() {
+ for (let obj of this.heap) {
+ obj.destroy();
+ }
+ this.heap = []
+ }
+}
+
+
+/**
+ * Arena that destroys all its objects once control has returned to the message
+ * loop.
+ */
+class SyncArena extends SimpleArena {
+ private isScheduled: boolean;
+
+ constructor() {
+ super();
+ }
+
+ pub(obj: MallocArenaObject) {
+ super.put(obj);
+ if (!this.isScheduled) {
+ this.schedule();
+ }
+ this.heap.push(obj);
+ }
+
+ private schedule() {
+ this.isScheduled = true;
+ Promise.resolve().then(() => {
+ this.isScheduled = false;
+ this.destroy();
+ });
+ }
+}
+
+let arenaStack: Arena[] = [];
+arenaStack.push(new SyncArena());
+
+
+export class Amount extends MallocArenaObject {
+ constructor(args?: AmountJson, arena?: Arena) {
+ super(arena);
+ if (args) {
+ this.nativePtr = emscAlloc.get_amount(args.value,
+ 0,
+ args.fraction,
+ args.currency);
+ } else {
+ this.nativePtr = emscAlloc.get_amount(0, 0, 0, "");
+ }
+ }
+
+ static getZero(currency: string, a?: Arena): Amount {
+ let am = new Amount(undefined, a);
+ let r = emsc.amount_get_zero(currency, am.nativePtr);
+ if (r != GNUNET_OK) {
+ throw Error("invalid currency");
+ }
+ return am;
+ }
+
+
+ toNbo(a?: Arena): AmountNbo {
+ let x = new AmountNbo(a);
+ x.alloc();
+ emsc.amount_hton(x.nativePtr, this.nativePtr);
+ return x;
+ }
+
+ fromNbo(nbo: AmountNbo): void {
+ emsc.amount_ntoh(this.nativePtr, nbo.nativePtr);
+ }
+
+ get value() {
+ return emsc.get_value(this.nativePtr);
+ }
+
+ get fraction() {
+ return emsc.get_fraction(this.nativePtr);
+ }
+
+ get currency(): String {
+ return emsc.get_currency(this.nativePtr);
+ }
+
+ toJson(): AmountJson {
+ return {
+ value: emsc.get_value(this.nativePtr),
+ fraction: emsc.get_fraction(this.nativePtr),
+ currency: emsc.get_currency(this.nativePtr)
+ };
+ }
+
+ /**
+ * Add an amount to this amount.
+ */
+ add(a: Amount) {
+ let res = emsc.amount_add(this.nativePtr, a.nativePtr, this.nativePtr);
+ if (res < 1) {
+ // Overflow
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Perform saturating subtraction on amounts.
+ */
+ sub(a: Amount) {
+ // this = this - a
+ let res = emsc.amount_subtract(this.nativePtr, this.nativePtr, a.nativePtr);
+ if (res == 0) {
+ // Underflow
+ return false;
+ }
+ if (res > 0) {
+ return true;
+ }
+ throw Error("Incompatible currencies");
+ }
+
+ cmp(a: Amount) {
+ // If we don't check this, the c code aborts.
+ if (this.currency !== a.currency) {
+ throw Error(`incomparable currencies (${this.currency} and ${a.currency})`);
+ }
+ return emsc.amount_cmp(this.nativePtr, a.nativePtr);
+ }
+
+ normalize() {
+ emsc.amount_normalize(this.nativePtr);
+ }
+}
+
+
+/**
+ * Count the UTF-8 characters in a JavaScript string.
+ */
+function countUtf8Bytes(str: string): number {
+ var s = str.length;
+ // JavaScript strings are UTF-16 arrays
+ for (let i = str.length - 1; i >= 0; i--) {
+ var code = str.charCodeAt(i);
+ if (code > 0x7f && code <= 0x7ff) {
+ // We need an extra byte in utf-8 here
+ s++;
+ } else if (code > 0x7ff && code <= 0xffff) {
+ // We need two extra bytes in utf-8 here
+ s += 2;
+ }
+ // Skip over the other surrogate
+ if (code >= 0xDC00 && code <= 0xDFFF) {
+ i--;
+ }
+ }
+ return s;
+}
+
+
+/**
+ * Managed reference to a contiguous block of memory in the Emscripten heap.
+ * Can be converted from / to a serialized representation.
+ * Should contain only data, not pointers.
+ */
+abstract class PackedArenaObject extends MallocArenaObject {
+ abstract size(): number;
+
+ constructor(a?: Arena) {
+ super(a);
+ }
+
+ randomize(qual: RandomQuality = RandomQuality.STRONG): void {
+ emsc.random_block(qual, this.nativePtr, this.size());
+ }
+
+ toCrock(): string {
+ var d = emscAlloc.data_to_string_alloc(this.nativePtr, this.size());
+ var s = Module.Pointer_stringify(d);
+ emsc.free(d);
+ return s;
+ }
+
+ toJson(): any {
+ // Per default, the json encoding of
+ // packed arena objects is just the crockford encoding.
+ // Subclasses typically want to override this.
+ return this.toCrock();
+ }
+
+ loadCrock(s: string) {
+ this.alloc();
+ // We need to get the javascript string
+ // to the emscripten heap first.
+ let buf = ByteArray.fromStringWithNull(s);
+ let res = emsc.string_to_data(buf.nativePtr,
+ s.length,
+ this.nativePtr,
+ this.size());
+ buf.destroy();
+ if (res < 1) {
+ throw {error: "wrong encoding"};
+ }
+ }
+
+ alloc() {
+ // FIXME: should the client be allowed to call alloc multiple times?
+ if (!this._nativePtr) {
+ this.nativePtr = emscAlloc.malloc(this.size());
+ }
+ }
+
+ hash(): HashCode {
+ var x = new HashCode();
+ x.alloc();
+ emsc.hash(this.nativePtr, this.size(), x.nativePtr);
+ return x;
+ }
+
+ hexdump() {
+ let bytes: string[] = [];
+ for (let i = 0; i < this.size(); i++) {
+ let b = Module.getValue(this.nativePtr + i, "i8");
+ b = (b + 256) % 256;
+ bytes.push("0".concat(b.toString(16)).slice(-2));
+ }
+ let lines: string[] = [];
+ for (let i = 0; i < bytes.length; i += 8) {
+ lines.push(bytes.slice(i, i + 8).join(","));
+ }
+ return lines.join("\n");
+ }
+}
+
+
+export class AmountNbo extends PackedArenaObject {
+ size() {
+ return 24;
+ }
+
+ toJson(): any {
+ let a = new SimpleArena();
+ let am = new Amount(undefined, a);
+ am.fromNbo(this);
+ let json = am.toJson();
+ a.destroy();
+ return json;
+ }
+}
+
+
+export class EddsaPrivateKey extends PackedArenaObject {
+ static create(a?: Arena): EddsaPrivateKey {
+ let obj = new EddsaPrivateKey(a);
+ obj.nativePtr = emscAlloc.eddsa_key_create();
+ return obj;
+ }
+
+ size() {
+ return 32;
+ }
+
+ getPublicKey(a?: Arena): EddsaPublicKey {
+ let obj = new EddsaPublicKey(a);
+ obj.nativePtr = emscAlloc.eddsa_public_key_from_private(this.nativePtr);
+ return obj;
+ }
+
+ static fromCrock: (s: string) => EddsaPrivateKey;
+}
+mixinStatic(EddsaPrivateKey, fromCrock);
+
+
+export class EcdsaPrivateKey extends PackedArenaObject {
+ static create(a?: Arena): EcdsaPrivateKey {
+ let obj = new EcdsaPrivateKey(a);
+ obj.nativePtr = emscAlloc.ecdsa_key_create();
+ return obj;
+ }
+
+ size() {
+ return 32;
+ }
+
+ getPublicKey(a?: Arena): EcdsaPublicKey {
+ let obj = new EcdsaPublicKey(a);
+ obj.nativePtr = emscAlloc.ecdsa_public_key_from_private(this.nativePtr);
+ return obj;
+ }
+
+ static fromCrock: (s: string) => EcdsaPrivateKey;
+}
+mixinStatic(EcdsaPrivateKey, fromCrock);
+
+
+export class EcdhePrivateKey extends PackedArenaObject {
+ static create(a?: Arena): EcdhePrivateKey {
+ let obj = new EcdhePrivateKey(a);
+ obj.nativePtr = emscAlloc.ecdhe_key_create();
+ return obj;
+ }
+
+ size() {
+ return 32;
+ }
+
+ getPublicKey(a?: Arena): EcdhePublicKey {
+ let obj = new EcdhePublicKey(a);
+ obj.nativePtr = emscAlloc.ecdhe_public_key_from_private(this.nativePtr);
+ return obj;
+ }
+
+ static fromCrock: (s: string) => EcdhePrivateKey;
+}
+mixinStatic(EcdhePrivateKey, fromCrock);
+
+
+function fromCrock(s: string) {
+ let x = new this();
+ x.alloc();
+ x.loadCrock(s);
+ return x;
+}
+
+
+function mixin(obj: any, method: any, name?: string) {
+ if (!name) {
+ name = method.name;
+ }
+ if (!name) {
+ throw Error("Mixin needs a name.");
+ }
+ obj.prototype[method.name] = method;
+}
+
+
+function mixinStatic(obj: any, method: any, name?: string) {
+ if (!name) {
+ name = method.name;
+ }
+ if (!name) {
+ throw Error("Mixin needs a name.");
+ }
+ obj[method.name] = method;
+}
+
+
+export class EddsaPublicKey extends PackedArenaObject {
+ size() {
+ return 32;
+ }
+
+ static fromCrock: (s: string) => EddsaPublicKey;
+}
+mixinStatic(EddsaPublicKey, fromCrock);
+
+export class EcdsaPublicKey extends PackedArenaObject {
+ size() {
+ return 32;
+ }
+
+ static fromCrock: (s: string) => EcdsaPublicKey;
+}
+mixinStatic(EcdsaPublicKey, fromCrock);
+
+
+export class EcdhePublicKey extends PackedArenaObject {
+ size() {
+ return 32;
+ }
+
+ static fromCrock: (s: string) => EcdhePublicKey;
+}
+mixinStatic(EcdhePublicKey, fromCrock);
+
+
+function makeFromCrock(decodeFn: (p: number, s: number) => number) {
+ function fromCrock(s: string, a?: Arena) {
+ let obj = new this(a);
+ let buf = ByteArray.fromCrock(s);
+ obj.nativePtr = decodeFn(buf.nativePtr, buf.size());
+ buf.destroy();
+ return obj;
+ }
+
+ return fromCrock;
+}
+
+function makeToCrock(encodeFn: (po: number,
+ ps: number) => number): () => string {
+ function toCrock() {
+ let ptr = emscAlloc.malloc(PTR_SIZE);
+ let size = emscAlloc.rsa_blinding_key_encode(this.nativePtr, ptr);
+ let res = new ByteArray(size, Module.getValue(ptr, '*'));
+ let s = res.toCrock();
+ emsc.free(ptr);
+ res.destroy();
+ return s;
+ }
+
+ return toCrock;
+}
+
+export class RsaBlindingKeySecret extends PackedArenaObject {
+ size() {
+ return 32;
+ }
+
+ /**
+ * Create a random blinding key secret.
+ */
+ static create(a?: Arena): RsaBlindingKeySecret {
+ let o = new RsaBlindingKeySecret(a);
+ o.alloc();
+ o.randomize();
+ return o;
+ }
+
+ static fromCrock: (s: string) => RsaBlindingKeySecret;
+}
+mixinStatic(RsaBlindingKeySecret, fromCrock);
+
+
+export class HashCode extends PackedArenaObject {
+ size() {
+ return 64;
+ }
+
+ static fromCrock: (s: string) => HashCode;
+
+ random(qual: RandomQuality = RandomQuality.STRONG) {
+ this.alloc();
+ emsc.hash_create_random(qual, this.nativePtr);
+ }
+}
+mixinStatic(HashCode, fromCrock);
+
+
+export class ByteArray extends PackedArenaObject {
+ private allocatedSize: number;
+
+ size() {
+ return this.allocatedSize;
+ }
+
+ constructor(desiredSize: number, init?: number, a?: Arena) {
+ super(a);
+ if (init === undefined) {
+ this.nativePtr = emscAlloc.malloc(desiredSize);
+ } else {
+ this.nativePtr = init;
+ }
+ this.allocatedSize = desiredSize;
+ }
+
+ static fromStringWithoutNull(s: string, a?: Arena): ByteArray {
+ // UTF-8 bytes, including 0-terminator
+ let terminatedByteLength = countUtf8Bytes(s) + 1;
+ let hstr = emscAlloc.malloc(terminatedByteLength);
+ Module.stringToUTF8(s, hstr, terminatedByteLength);
+ return new ByteArray(terminatedByteLength - 1, hstr, a);
+ }
+
+ static fromStringWithNull(s: string, a?: Arena): ByteArray {
+ // UTF-8 bytes, including 0-terminator
+ let terminatedByteLength = countUtf8Bytes(s) + 1;
+ let hstr = emscAlloc.malloc(terminatedByteLength);
+ Module.stringToUTF8(s, hstr, terminatedByteLength);
+ return new ByteArray(terminatedByteLength, hstr, a);
+ }
+
+ static fromCrock(s: string, a?: Arena): ByteArray {
+ let byteLength = countUtf8Bytes(s);
+ let hstr = emscAlloc.malloc(byteLength + 1);
+ Module.stringToUTF8(s, hstr, byteLength + 1);
+ let decodedLen = Math.floor((byteLength * 5) / 8);
+ let ba = new ByteArray(decodedLen, undefined, a);
+ let res = emsc.string_to_data(hstr, byteLength, ba.nativePtr, decodedLen);
+ emsc.free(hstr);
+ if (res != GNUNET_OK) {
+ throw Error("decoding failed");
+ }
+ return ba;
+ }
+}
+
+
+export class EccSignaturePurpose extends PackedArenaObject {
+ size() {
+ return this.payloadSize + 8;
+ }
+
+ payloadSize: number;
+
+ constructor(purpose: SignaturePurpose,
+ payload: PackedArenaObject,
+ a?: Arena) {
+ super(a);
+ this.nativePtr = emscAlloc.purpose_create(purpose,
+ payload.nativePtr,
+ payload.size());
+ this.payloadSize = payload.size();
+ }
+}
+
+
+abstract class SignatureStruct {
+ abstract fieldTypes(): Array<any>;
+
+ abstract purpose(): SignaturePurpose;
+
+ private members: any = {};
+
+ constructor(x: { [name: string]: any }) {
+ for (let k in x) {
+ this.set(k, x[k]);
+ }
+ }
+
+ toPurpose(a?: Arena): EccSignaturePurpose {
+ let totalSize = 0;
+ for (let f of this.fieldTypes()) {
+ let name = f[0];
+ let member = this.members[name];
+ if (!member) {
+ throw Error(`Member ${name} not set`);
+ }
+ totalSize += member.size();
+ }
+
+ let buf = emscAlloc.malloc(totalSize);
+ let ptr = buf;
+ for (let f of this.fieldTypes()) {
+ let name = f[0];
+ let member = this.members[name];
+ let size = member.size();
+ emsc.memmove(ptr, member.nativePtr, size);
+ ptr += size;
+ }
+ let ba = new ByteArray(totalSize, buf, a);
+ return new EccSignaturePurpose(this.purpose(), ba);
+ }
+
+
+ toJson() {
+ let res: any = {};
+ for (let f of this.fieldTypes()) {
+ let name = f[0];
+ let member = this.members[name];
+ if (!member) {
+ throw Error(`Member ${name} not set`);
+ }
+ res[name] = member.toJson();
+ }
+ res["purpose"] = this.purpose();
+ return res;
+ }
+
+ protected set(name: string, value: PackedArenaObject) {
+ let typemap: any = {};
+ for (let f of this.fieldTypes()) {
+ typemap[f[0]] = f[1];
+ }
+ if (!(name in typemap)) {
+ throw Error(`Key ${name} not found`);
+ }
+ if (!(value instanceof typemap[name])) {
+ throw Error("Wrong type for ${name}");
+ }
+ this.members[name] = value;
+ }
+}
+
+
+// It's redundant, but more type safe.
+export interface WithdrawRequestPS_Args {
+ reserve_pub: EddsaPublicKey;
+ amount_with_fee: AmountNbo;
+ withdraw_fee: AmountNbo;
+ h_denomination_pub: HashCode;
+ h_coin_envelope: HashCode;
+}
+
+
+export class WithdrawRequestPS extends SignatureStruct {
+ constructor(w: WithdrawRequestPS_Args) {
+ super(w);
+ }
+
+ purpose() {
+ return SignaturePurpose.RESERVE_WITHDRAW;
+ }
+
+ fieldTypes() {
+ return [
+ ["reserve_pub", EddsaPublicKey],
+ ["amount_with_fee", AmountNbo],
+ ["withdraw_fee", AmountNbo],
+ ["h_denomination_pub", HashCode],
+ ["h_coin_envelope", HashCode]
+ ];
+ }
+}
+
+
+interface RefreshMeltCoinAffirmationPS_Args {
+ session_hash: HashCode;
+ amount_with_fee: AmountNbo;
+ melt_fee: AmountNbo;
+ coin_pub: EddsaPublicKey;
+}
+
+export class RefreshMeltCoinAffirmationPS extends SignatureStruct {
+
+ constructor(w: RefreshMeltCoinAffirmationPS_Args) {
+ super(w);
+ }
+
+ purpose() {
+ return SignaturePurpose.WALLET_COIN_MELT;
+ }
+
+ fieldTypes() {
+ return [
+ ["session_hash", HashCode],
+ ["amount_with_fee", AmountNbo],
+ ["melt_fee", AmountNbo],
+ ["coin_pub", EddsaPublicKey]
+ ];
+ }
+}
+
+
+export class AbsoluteTimeNbo extends PackedArenaObject {
+ static fromTalerString(s: string): AbsoluteTimeNbo {
+ let x = new AbsoluteTimeNbo();
+ x.alloc();
+ let r = /Date\(([0-9]+)\)/;
+ let m = r.exec(s);
+ if (!m || m.length != 2) {
+ throw Error();
+ }
+ let n = parseInt(m[1]) * 1000000;
+ // XXX: This only works up to 54 bit numbers.
+ set64(x.nativePtr, n);
+ return x;
+ }
+
+ size() {
+ return 8;
+ }
+}
+
+
+// XXX: This only works up to 54 bit numbers.
+function set64(p: number, n: number) {
+ for (let i = 0; i < 8; ++i) {
+ Module.setValue(p + (7 - i), n & 0xFF, "i8");
+ n = Math.floor(n / 256);
+ }
+}
+
+// XXX: This only works up to 54 bit numbers.
+function set32(p: number, n: number) {
+ for (let i = 0; i < 4; ++i) {
+ Module.setValue(p + (3 - i), n & 0xFF, "i8");
+ n = Math.floor(n / 256);
+ }
+}
+
+
+export class UInt64 extends PackedArenaObject {
+ static fromNumber(n: number): UInt64 {
+ let x = new UInt64();
+ x.alloc();
+ set64(x.nativePtr, n);
+ return x;
+ }
+
+ size() {
+ return 8;
+ }
+}
+
+
+export class UInt32 extends PackedArenaObject {
+ static fromNumber(n: number): UInt64 {
+ let x = new UInt32();
+ x.alloc();
+ set32(x.nativePtr, n);
+ return x;
+ }
+
+ size() {
+ return 4;
+ }
+}
+
+
+// It's redundant, but more type safe.
+export interface DepositRequestPS_Args {
+ h_contract: HashCode;
+ h_wire: HashCode;
+ timestamp: AbsoluteTimeNbo;
+ refund_deadline: AbsoluteTimeNbo;
+ transaction_id: UInt64;
+ amount_with_fee: AmountNbo;
+ deposit_fee: AmountNbo;
+ merchant: EddsaPublicKey;
+ coin_pub: EddsaPublicKey;
+}
+
+
+export class DepositRequestPS extends SignatureStruct {
+ constructor(w: DepositRequestPS_Args) {
+ super(w);
+ }
+
+ purpose() {
+ return SignaturePurpose.WALLET_COIN_DEPOSIT;
+ }
+
+ fieldTypes() {
+ return [
+ ["h_contract", HashCode],
+ ["h_wire", HashCode],
+ ["timestamp", AbsoluteTimeNbo],
+ ["refund_deadline", AbsoluteTimeNbo],
+ ["transaction_id", UInt64],
+ ["amount_with_fee", AmountNbo],
+ ["deposit_fee", AmountNbo],
+ ["merchant", EddsaPublicKey],
+ ["coin_pub", EddsaPublicKey],
+ ];
+ }
+}
+
+export interface DenominationKeyValidityPS_args {
+ master: EddsaPublicKey;
+ start: AbsoluteTimeNbo;
+ expire_withdraw: AbsoluteTimeNbo;
+ expire_spend: AbsoluteTimeNbo;
+ expire_legal: AbsoluteTimeNbo;
+ value: AmountNbo;
+ fee_withdraw: AmountNbo;
+ fee_deposit: AmountNbo;
+ fee_refresh: AmountNbo;
+ fee_refund: AmountNbo;
+ denom_hash: HashCode;
+}
+
+export class DenominationKeyValidityPS extends SignatureStruct {
+ constructor(w: DenominationKeyValidityPS_args) {
+ super(w);
+ }
+
+ purpose() {
+ return SignaturePurpose.MASTER_DENOMINATION_KEY_VALIDITY;
+ }
+
+ fieldTypes() {
+ return [
+ ["master", EddsaPublicKey],
+ ["start", AbsoluteTimeNbo],
+ ["expire_withdraw", AbsoluteTimeNbo],
+ ["expire_spend", AbsoluteTimeNbo],
+ ["expire_legal", AbsoluteTimeNbo],
+ ["value", AmountNbo],
+ ["fee_withdraw", AmountNbo],
+ ["fee_deposit", AmountNbo],
+ ["fee_refresh", AmountNbo],
+ ["fee_refund", AmountNbo],
+ ["denom_hash", HashCode]
+ ];
+ }
+}
+
+
+interface Encodeable {
+ encode(arena?: Arena): ByteArray;
+}
+
+function makeEncode(encodeFn: any) {
+ function encode(arena?: Arena) {
+ let ptr = emscAlloc.malloc(PTR_SIZE);
+ let len = encodeFn(this.nativePtr, ptr);
+ let res = new ByteArray(len, undefined, arena);
+ res.nativePtr = Module.getValue(ptr, '*');
+ emsc.free(ptr);
+ return res;
+ }
+
+ return encode;
+}
+
+
+export class RsaPublicKey extends MallocArenaObject implements Encodeable {
+ static fromCrock: (s: string, a?: Arena) => RsaPublicKey;
+
+ toCrock() {
+ return this.encode().toCrock();
+ }
+
+ destroy() {
+ emsc.rsa_public_key_free(this.nativePtr);
+ this.nativePtr = 0;
+ }
+
+ encode: (arena?: Arena) => ByteArray;
+}
+mixinStatic(RsaPublicKey, makeFromCrock(emscAlloc.rsa_public_key_decode));
+mixin(RsaPublicKey, makeEncode(emscAlloc.rsa_public_key_encode));
+
+
+export class EddsaSignature extends PackedArenaObject {
+ size() {
+ return 64;
+ }
+}
+
+
+export class RsaSignature extends MallocArenaObject implements Encodeable {
+ static fromCrock: (s: string, a?: Arena) => RsaSignature;
+
+ encode: (arena?: Arena) => ByteArray;
+
+ destroy() {
+ emsc.rsa_signature_free(this.nativePtr);
+ this.nativePtr = 0;
+ }
+}
+mixinStatic(RsaSignature, makeFromCrock(emscAlloc.rsa_signature_decode));
+mixin(RsaSignature, makeEncode(emscAlloc.rsa_signature_encode));
+
+
+export function rsaBlind(hashCode: HashCode,
+ blindingKey: RsaBlindingKeySecret,
+ pkey: RsaPublicKey,
+ arena?: Arena): ByteArray|null {
+ let buf_ptr_out = emscAlloc.malloc(PTR_SIZE);
+ let buf_size_out = emscAlloc.malloc(PTR_SIZE);
+ let res = emscAlloc.rsa_blind(hashCode.nativePtr,
+ blindingKey.nativePtr,
+ pkey.nativePtr,
+ buf_ptr_out,
+ buf_size_out);
+ let buf_ptr = Module.getValue(buf_ptr_out, '*');
+ let buf_size = Module.getValue(buf_size_out, '*');
+ emsc.free(buf_ptr_out);
+ emsc.free(buf_size_out);
+ if (res != GNUNET_OK) {
+ // malicious key
+ return null;
+ }
+ return new ByteArray(buf_size, buf_ptr, arena);
+}
+
+
+export function eddsaSign(purpose: EccSignaturePurpose,
+ priv: EddsaPrivateKey,
+ a?: Arena): EddsaSignature {
+ let sig = new EddsaSignature(a);
+ sig.alloc();
+ let res = emsc.eddsa_sign(priv.nativePtr, purpose.nativePtr, sig.nativePtr);
+ if (res < 1) {
+ throw Error("EdDSA signing failed");
+ }
+ return sig;
+}
+
+
+export function eddsaVerify(purposeNum: number,
+ verify: EccSignaturePurpose,
+ sig: EddsaSignature,
+ pub: EddsaPublicKey,
+ a?: Arena): boolean {
+ let r = emsc.eddsa_verify(purposeNum,
+ verify.nativePtr,
+ sig.nativePtr,
+ pub.nativePtr);
+ return r === GNUNET_OK;
+}
+
+
+export function rsaUnblind(sig: RsaSignature,
+ bk: RsaBlindingKeySecret,
+ pk: RsaPublicKey,
+ a?: Arena): RsaSignature {
+ let x = new RsaSignature(a);
+ x.nativePtr = emscAlloc.rsa_unblind(sig.nativePtr,
+ bk.nativePtr,
+ pk.nativePtr);
+ return x;
+}
+
+
+type TransferSecretP = HashCode;
+
+
+export interface FreshCoin {
+ priv: EddsaPrivateKey;
+ blindingKey: RsaBlindingKeySecret;
+}
+
+export function ecdhEddsa(priv: EcdhePrivateKey,
+ pub: EddsaPublicKey): HashCode {
+ let h = new HashCode();
+ h.alloc();
+ let res = emsc.ecdh_eddsa(priv.nativePtr, pub.nativePtr, h.nativePtr);
+ if (res != GNUNET_OK) {
+ throw Error("ecdh_eddsa failed");
+ }
+ return h;
+}
+
+export function setupFreshCoin(secretSeed: TransferSecretP,
+ coinIndex: number): FreshCoin {
+ let priv = new EddsaPrivateKey();
+ priv.isWeak = true;
+ let blindingKey = new RsaBlindingKeySecret();
+ blindingKey.isWeak = true;
+ let buf = new ByteArray(priv.size() + blindingKey.size());
+
+ emsc.setup_fresh_coin(secretSeed.nativePtr, coinIndex, buf.nativePtr);
+
+ priv.nativePtr = buf.nativePtr;
+ blindingKey.nativePtr = buf.nativePtr + priv.size();
+
+ return {priv, blindingKey};
+}
diff --git a/src/helpers.ts b/src/helpers.ts
new file mode 100644
index 000000000..26cd350ee
--- /dev/null
+++ b/src/helpers.ts
@@ -0,0 +1,140 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+/**
+ * Smaller helper functions that do not depend
+ * on the emscripten machinery.
+ *
+ * @author Florian Dold
+ */
+
+/// <reference path="../decl/urijs/URIjs.d.ts" />
+
+import {AmountJson} from "./types";
+import URI = uri.URI;
+
+export function substituteFulfillmentUrl(url: string, vars: any) {
+ url = url.replace("${H_contract}", vars.H_contract);
+ url = url.replace("${$}", "$");
+ return url;
+}
+
+
+export function amountToPretty(amount: AmountJson): string {
+ let x = amount.value + amount.fraction / 1e6;
+ return `${x} ${amount.currency}`;
+}
+
+
+/**
+ * Canonicalize a base url, typically for the exchange.
+ *
+ * See http://api.taler.net/wallet.html#general
+ */
+export function canonicalizeBaseUrl(url: string) {
+ let x: URI = new URI(url);
+ if (!x.protocol()) {
+ x.protocol("https");
+ }
+ x.path(x.path() + "/").normalizePath();
+ x.fragment();
+ x.query();
+ return x.href()
+}
+
+
+export function parsePrettyAmount(pretty: string): AmountJson|undefined {
+ const res = /([0-9]+)(.[0-9]+)?\s*(\w+)/.exec(pretty);
+ if (!res) {
+ return undefined;
+ }
+ return {
+ value: parseInt(res[1], 10),
+ fraction: res[2] ? (parseFloat(`0.${res[2]}`) * 1e-6) : 0,
+ currency: res[3]
+ }
+}
+
+
+
+/**
+ * Convert object to JSON with canonical ordering of keys
+ * and whitespace omitted.
+ */
+export function canonicalJson(obj: any): string {
+ // Check for cycles, etc.
+ JSON.stringify(obj);
+ if (typeof obj == "string" || typeof obj == "number" || obj === null) {
+ return JSON.stringify(obj)
+ }
+ if (Array.isArray(obj)) {
+ let objs: string[] = obj.map((e) => canonicalJson(e));
+ return `[${objs.join(',')}]`;
+ }
+ let keys: string[] = [];
+ for (let key in obj) {
+ keys.push(key);
+ }
+ keys.sort();
+ let s = "{";
+ for (let i = 0; i < keys.length; i++) {
+ let key = keys[i];
+ s += JSON.stringify(key) + ":" + canonicalJson(obj[key]);
+ if (i != keys.length - 1) {
+ s += ",";
+ }
+ }
+ return s + "}";
+}
+
+
+export function deepEquals(x: any, y: any): boolean {
+ if (x === y) {
+ return true;
+ }
+
+ if (Array.isArray(x) && x.length !== y.length) {
+ return false;
+ }
+
+ var p = Object.keys(x);
+ return Object.keys(y).every((i) => p.indexOf(i) !== -1) &&
+ p.every((i) => deepEquals(x[i], y[i]));
+}
+
+
+export function flatMap<T, U>(xs: T[], f: (x: T) => U[]): U[] {
+ return xs.reduce((acc: U[], next: T) => [...f(next), ...acc], []);
+}
+
+
+export function getTalerStampSec(stamp: string): number | null {
+ const m = stamp.match(/\/?Date\(([0-9]*)\)\/?/);
+ if (!m) {
+ return null;
+ }
+ return parseInt(m[1]);
+}
+
+
+export function getTalerStampDate(stamp: string): Date | null {
+ let sec = getTalerStampSec(stamp);
+ if (sec == null) {
+ return null;
+ }
+ return new Date(sec * 1000);
+}
+
diff --git a/src/http.ts b/src/http.ts
new file mode 100644
index 000000000..1d22c4eb2
--- /dev/null
+++ b/src/http.ts
@@ -0,0 +1,97 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+/**
+ * Helpers for doing XMLHttpRequest-s that are based on ES6 promises.
+ * @module Http
+ * @author Florian Dold
+ */
+
+"use strict";
+
+
+export interface HttpResponse {
+ status: number;
+ responseText: string;
+}
+
+
+export interface HttpRequestLibrary {
+ req(method: string,
+ url: string | uri.URI,
+ options?: any): Promise<HttpResponse>;
+
+ get(url: string | uri.URI): Promise<HttpResponse>;
+
+ postJson(url: string | uri.URI, body: any): Promise<HttpResponse>;
+
+ postForm(url: string | uri.URI, form: any): Promise<HttpResponse>;
+}
+
+
+export class BrowserHttpLib {
+ req(method: string,
+ url: string|uri.URI,
+ options?: any): Promise<HttpResponse> {
+ let urlString: string;
+ if (url instanceof URI) {
+ urlString = url.href();
+ } else if (typeof url === "string") {
+ urlString = url;
+ }
+
+ return new Promise((resolve, reject) => {
+ let myRequest = new XMLHttpRequest();
+ myRequest.open(method, urlString);
+ if (options && options.req) {
+ myRequest.send(options.req);
+ } else {
+ myRequest.send();
+ }
+ myRequest.addEventListener("readystatechange", (e) => {
+ if (myRequest.readyState == XMLHttpRequest.DONE) {
+ let resp = {
+ status: myRequest.status,
+ responseText: myRequest.responseText
+ };
+ resolve(resp);
+ }
+ });
+ });
+ }
+
+
+ get(url: string|uri.URI) {
+ return this.req("get", url);
+ }
+
+
+ postJson(url: string|uri.URI, body: any) {
+ return this.req("post", url, {req: JSON.stringify(body)});
+ }
+
+
+ postForm(url: string|uri.URI, form: any) {
+ return this.req("post", url, {req: form});
+ }
+}
+
+
+export class RequestException {
+ constructor(detail: any) {
+
+ }
+}
diff --git a/src/i18n.ts b/src/i18n.ts
new file mode 100644
index 000000000..c91b385a7
--- /dev/null
+++ b/src/i18n.ts
@@ -0,0 +1,205 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+"use strict";
+
+document.addEventListener(
+ "DOMContentLoaded",
+ function () {
+ try {
+ document.body.lang = chrome.i18n.getUILanguage();
+ } catch (e) {
+ // chrome.* not available?
+ }
+ });
+
+declare var i18n: any;
+
+/**
+ * Information about the last two i18n results, used by plural()
+ * 2-element array, each element contains { stringFound: boolean, pluralValue: number }
+ */
+var i18nResult = <any>[];
+
+const JedModule: any = (window as any)["Jed"];
+var jed: any;
+
+
+class PluralNumber {
+ n: number;
+
+ constructor(n: number) {
+ this.n = n;
+ }
+
+ valueOf () {
+ return this.n;
+ }
+
+ toString () {
+ return this.n.toString();
+ }
+}
+
+
+/**
+ * Initialize Jed
+ */
+function init () {
+ if ("object" === typeof jed) {
+ return;
+ }
+ if ("function" !== typeof JedModule) {
+ return;
+ }
+ if (!(i18n.lang in i18n.strings)) {
+ i18n.lang = "en-US";
+ return;
+ }
+ jed = new JedModule(i18n.strings[i18n.lang]);
+}
+
+
+/**
+ * Convert template strings to a msgid
+ */
+function toI18nString(strings: string[]) {
+ let str = "";
+ for (let i = 0; i < strings.length; i++) {
+ str += strings[i];
+ if (i < strings.length - 1) {
+ str += "%"+ (i+1) +"$s";
+ }
+ }
+ return str;
+}
+
+
+/**
+ * Use the first number in values to determine plural form
+ */
+function getPluralValue (values: any) {
+ let n = null;
+ for (let i = 0; i < values.length; i++) {
+ if ("number" === typeof values[i] || values[i] instanceof PluralNumber) {
+ if (null === n || values[i] instanceof PluralNumber) {
+ n = values[i].valueOf();
+ }
+ }
+ }
+ return (null === n) ? 1 : n;
+}
+
+
+/**
+ * Store information about the result of the last to i18n() or i18n.parts()
+ *
+ * @param i18nString the string template as found in i18n.strings
+ * @param pluralValue value returned by getPluralValue()
+ */
+function setI18nResult (i18nString: string, pluralValue: number) {
+ i18nResult[1] = i18nResult[0];
+ i18nResult[0] = {
+ stringFound: i18nString in i18n.strings[i18n.lang].locale_data[i18n.lang],
+ pluralValue: pluralValue
+ };
+}
+
+
+/**
+ * Internationalize a string template with arbitrary serialized values.
+ */
+var i18n = <any>function i18n(strings: string[], ...values: any[]) {
+ init();
+ //console.log('i18n:', strings, values);
+ if ("object" !== typeof jed) {
+ // Fallback implementation in case i18n lib is not there
+ return String.raw(strings as any, ...values);
+ }
+
+ let str = toI18nString (strings);
+ let n = getPluralValue (values);
+ let tr = jed.translate(str).ifPlural(n, str).fetch(...values);
+
+ setI18nResult (str, n);
+ return tr;
+};
+
+try {
+ i18n.lang = chrome.i18n.getUILanguage();
+} catch (e) {
+ console.warn("i18n default language not available");
+}
+i18n.strings = {};
+
+
+/**
+ * Interpolate i18nized values with arbitrary objects.
+ * @return Array of strings/objects.
+ */
+i18n.parts = function(strings: string[], ...values: any[]) {
+ init();
+ if ("object" !== typeof jed) {
+ // Fallback implementation in case i18n lib is not there
+ let parts: string[] = [];
+
+ for (let i = 0; i < strings.length; i++) {
+ parts.push(strings[i]);
+ if (i < values.length) {
+ parts.push(values[i]);
+ }
+ }
+ return parts;
+ }
+
+ let str = toI18nString (strings);
+ let n = getPluralValue (values);
+ let tr = jed.ngettext(str, str, n).split(/%(\d+)\$s/);
+ let parts: string[] = [];
+ for (let i = 0; i < tr.length; i++) {
+ if (0 == i % 2) {
+ parts.push(tr[i]);
+ } else {
+ parts.push(values[parseInt(tr[i]) - 1]);
+ }
+ }
+
+ setI18nResult (str, n);
+ return parts;
+};
+
+
+/**
+ * Pluralize based on first numeric parameter in the template.
+ * @todo The plural argument is used for extraction by pogen.js
+ */
+i18n.plural = function (singular: any, plural: any) {
+ if (i18nResult[1].stringFound) { // string found in translation file?
+ // 'singular' has the correctly translated & pluralized text
+ return singular;
+ } else {
+ // return appropriate form based on value found in 'singular'
+ return (1 == i18nResult[1].pluralValue) ? singular : plural;
+ }
+};
+
+
+/**
+ * Return a number that is used to determine the plural form for a template.
+ */
+i18n.number = function (n : number) {
+ return new PluralNumber (n);
+};
diff --git a/src/i18n/de.po b/src/i18n/de.po
new file mode 100644
index 000000000..0a5c1397f
--- /dev/null
+++ b/src/i18n/de.po
@@ -0,0 +1,130 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# 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
+# 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/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2016-11-09 22:39+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: lib/wallet/renderHtml.tsx:37
+#, c-format
+msgid ""
+"%1$s\n"
+" wants to enter a contract over %2$s\n"
+" with you."
+msgstr ""
+"%1$s\n"
+" möchte einen Vertrag über %2$s\n"
+" mit Ihnen abschließen."
+
+#: lib/wallet/renderHtml.tsx:42
+#, c-format
+msgid "You are about to purchase:"
+msgstr "Sie sind dabei, Folgendes zu kaufen:"
+
+#: pages/confirm-contract.tsx:141
+#, c-format
+msgid "You have insufficient funds of the requested currency in your wallet."
+msgstr ""
+
+#: pages/confirm-create-reserve.tsx:275
+#, c-format
+msgid "Error: URL is empty"
+msgstr ""
+
+#: pages/confirm-create-reserve.tsx:282
+#, c-format
+msgid "Error: URL may not be relative"
+msgstr ""
+
+#: popup/popup.tsx:270
+#, c-format
+msgid "Error: could not retrieve balance information."
+msgstr ""
+
+#: popup/popup.tsx:303
+#, fuzzy, c-format
+msgid "Bank requested reserve (%1$s) for %2$s."
+msgstr "Bank bestätig anlegen der Reserve (%1$s) bei %2$s"
+
+#: popup/popup.tsx:314
+#, c-format
+msgid "Started to withdraw %1$s from %2$s (%3$s)."
+msgstr ""
+
+#: popup/popup.tsx:324
+#, c-format
+msgid "Merchant %1$s offered contract %2$s."
+msgstr ""
+
+#: popup/popup.tsx:333
+#, fuzzy, c-format
+msgid "Withdrew %1$s from %2$s (%3$s)."
+msgstr "Reserve (%1$s) mit %2$s bei %3$s erzeugt"
+
+#: popup/popup.tsx:343
+#, c-format
+msgid "Paid %1$s to merchant %2$s. (%3$s)"
+msgstr ""
+
+#: popup/popup.tsx:381
+#, c-format
+msgid "Error: could not retrieve event history"
+msgstr ""
+
+#: popup/popup.tsx:415
+#, c-format
+msgid "Your wallet has no events recorded."
+msgstr "Ihre Geldbörse verzeichnet keine Vorkommnisse."
+
+#~ msgid "Confirm Payment"
+#~ msgstr "Bezahlung bestätigen"
+
+#~ msgid "Balance"
+#~ msgstr "Saldo"
+
+#~ msgid "History"
+#~ msgstr "Verlauf"
+
+#~ msgid "Debug"
+#~ msgstr "Debug"
+
+#, fuzzy
+#~ msgid "You have no balance to show. Need some %1$s getting started?"
+#~ msgstr "Sie haben kein Digitalgeld. Wollen Sie %1$s? abheben?"
+
+#~ msgid "Withdraw at %1$s"
+#~ msgstr "Abheben bei %1$s"
+
+#~ msgid "Wallet depleted reserve (%1$s) at %2$s"
+#~ msgstr "Geldbörse hat die Reserve (%1$s) erschöpft"
+
+#~ msgid "Please enter a URL"
+#~ msgstr "Bitte eine URL eingeben"
+
+#~ msgid "The URL you've entered is not valid (must be absolute)"
+#~ msgstr "Die eingegebene URL ist nicht gültig (muss absolut sein)"
+
+#~ msgid "The bank wants to create a reserve over %1$s."
+#~ msgstr "Die Bank möchte eine Reserve über %1$s anlegen."
diff --git a/src/i18n/en-US.po b/src/i18n/en-US.po
new file mode 100644
index 000000000..befaf6974
--- /dev/null
+++ b/src/i18n/en-US.po
@@ -0,0 +1,100 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# 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
+# 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/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2016-11-09 22:39+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: lib/wallet/renderHtml.tsx:37
+#, c-format
+msgid ""
+"%1$s\n"
+" wants to enter a contract over %2$s\n"
+" with you."
+msgstr ""
+
+#: lib/wallet/renderHtml.tsx:42
+#, c-format
+msgid "You are about to purchase:"
+msgstr ""
+
+#: pages/confirm-contract.tsx:141
+#, c-format
+msgid "You have insufficient funds of the requested currency in your wallet."
+msgstr ""
+
+#: pages/confirm-create-reserve.tsx:275
+#, c-format
+msgid "Error: URL is empty"
+msgstr ""
+
+#: pages/confirm-create-reserve.tsx:282
+#, c-format
+msgid "Error: URL may not be relative"
+msgstr ""
+
+#: popup/popup.tsx:270
+#, c-format
+msgid "Error: could not retrieve balance information."
+msgstr ""
+
+#: popup/popup.tsx:303
+#, c-format
+msgid "Bank requested reserve (%1$s) for %2$s."
+msgstr ""
+
+#: popup/popup.tsx:314
+#, c-format
+msgid "Started to withdraw %1$s from %2$s (%3$s)."
+msgstr ""
+
+#: popup/popup.tsx:324
+#, c-format
+msgid "Merchant %1$s offered contract %2$s."
+msgstr ""
+
+#: popup/popup.tsx:333
+#, c-format
+msgid "Withdrew %1$s from %2$s (%3$s)."
+msgstr ""
+
+#: popup/popup.tsx:343
+#, c-format
+msgid "Paid %1$s to merchant %2$s. (%3$s)"
+msgstr ""
+
+#: popup/popup.tsx:381
+#, c-format
+msgid "Error: could not retrieve event history"
+msgstr ""
+
+#: popup/popup.tsx:415
+#, c-format
+msgid "Your wallet has no events recorded."
+msgstr ""
+
+#, fuzzy
+#~ msgid "DEBUG: Your balance on %1$s is %2$s KUDO. Get more at %3$s"
+#~ msgstr "DEBUG: Your balance is %2$s KUDO on %1$s. Get more at %3$s"
diff --git a/src/i18n/fr.po b/src/i18n/fr.po
new file mode 100644
index 000000000..7a8422002
--- /dev/null
+++ b/src/i18n/fr.po
@@ -0,0 +1,96 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# 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
+# 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/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2016-11-09 22:39+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: lib/wallet/renderHtml.tsx:37
+#, c-format
+msgid ""
+"%1$s\n"
+" wants to enter a contract over %2$s\n"
+" with you."
+msgstr ""
+
+#: lib/wallet/renderHtml.tsx:42
+#, c-format
+msgid "You are about to purchase:"
+msgstr ""
+
+#: pages/confirm-contract.tsx:141
+#, c-format
+msgid "You have insufficient funds of the requested currency in your wallet."
+msgstr ""
+
+#: pages/confirm-create-reserve.tsx:275
+#, c-format
+msgid "Error: URL is empty"
+msgstr ""
+
+#: pages/confirm-create-reserve.tsx:282
+#, c-format
+msgid "Error: URL may not be relative"
+msgstr ""
+
+#: popup/popup.tsx:270
+#, c-format
+msgid "Error: could not retrieve balance information."
+msgstr ""
+
+#: popup/popup.tsx:303
+#, c-format
+msgid "Bank requested reserve (%1$s) for %2$s."
+msgstr ""
+
+#: popup/popup.tsx:314
+#, c-format
+msgid "Started to withdraw %1$s from %2$s (%3$s)."
+msgstr ""
+
+#: popup/popup.tsx:324
+#, c-format
+msgid "Merchant %1$s offered contract %2$s."
+msgstr ""
+
+#: popup/popup.tsx:333
+#, c-format
+msgid "Withdrew %1$s from %2$s (%3$s)."
+msgstr ""
+
+#: popup/popup.tsx:343
+#, c-format
+msgid "Paid %1$s to merchant %2$s. (%3$s)"
+msgstr ""
+
+#: popup/popup.tsx:381
+#, c-format
+msgid "Error: could not retrieve event history"
+msgstr ""
+
+#: popup/popup.tsx:415
+#, c-format
+msgid "Your wallet has no events recorded."
+msgstr ""
diff --git a/src/i18n/it.po b/src/i18n/it.po
new file mode 100644
index 000000000..7a8422002
--- /dev/null
+++ b/src/i18n/it.po
@@ -0,0 +1,96 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# 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
+# 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/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2016-11-09 22:39+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: lib/wallet/renderHtml.tsx:37
+#, c-format
+msgid ""
+"%1$s\n"
+" wants to enter a contract over %2$s\n"
+" with you."
+msgstr ""
+
+#: lib/wallet/renderHtml.tsx:42
+#, c-format
+msgid "You are about to purchase:"
+msgstr ""
+
+#: pages/confirm-contract.tsx:141
+#, c-format
+msgid "You have insufficient funds of the requested currency in your wallet."
+msgstr ""
+
+#: pages/confirm-create-reserve.tsx:275
+#, c-format
+msgid "Error: URL is empty"
+msgstr ""
+
+#: pages/confirm-create-reserve.tsx:282
+#, c-format
+msgid "Error: URL may not be relative"
+msgstr ""
+
+#: popup/popup.tsx:270
+#, c-format
+msgid "Error: could not retrieve balance information."
+msgstr ""
+
+#: popup/popup.tsx:303
+#, c-format
+msgid "Bank requested reserve (%1$s) for %2$s."
+msgstr ""
+
+#: popup/popup.tsx:314
+#, c-format
+msgid "Started to withdraw %1$s from %2$s (%3$s)."
+msgstr ""
+
+#: popup/popup.tsx:324
+#, c-format
+msgid "Merchant %1$s offered contract %2$s."
+msgstr ""
+
+#: popup/popup.tsx:333
+#, c-format
+msgid "Withdrew %1$s from %2$s (%3$s)."
+msgstr ""
+
+#: popup/popup.tsx:343
+#, c-format
+msgid "Paid %1$s to merchant %2$s. (%3$s)"
+msgstr ""
+
+#: popup/popup.tsx:381
+#, c-format
+msgid "Error: could not retrieve event history"
+msgstr ""
+
+#: popup/popup.tsx:415
+#, c-format
+msgid "Your wallet has no events recorded."
+msgstr ""
diff --git a/src/i18n/poheader b/src/i18n/poheader
new file mode 100644
index 000000000..3ec704932
--- /dev/null
+++ b/src/i18n/poheader
@@ -0,0 +1,26 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# 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
+# 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/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
diff --git a/src/i18n/taler-wallet-webex.pot b/src/i18n/taler-wallet-webex.pot
new file mode 100644
index 000000000..7a8422002
--- /dev/null
+++ b/src/i18n/taler-wallet-webex.pot
@@ -0,0 +1,96 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# 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
+# 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/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2016-11-09 22:39+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: lib/wallet/renderHtml.tsx:37
+#, c-format
+msgid ""
+"%1$s\n"
+" wants to enter a contract over %2$s\n"
+" with you."
+msgstr ""
+
+#: lib/wallet/renderHtml.tsx:42
+#, c-format
+msgid "You are about to purchase:"
+msgstr ""
+
+#: pages/confirm-contract.tsx:141
+#, c-format
+msgid "You have insufficient funds of the requested currency in your wallet."
+msgstr ""
+
+#: pages/confirm-create-reserve.tsx:275
+#, c-format
+msgid "Error: URL is empty"
+msgstr ""
+
+#: pages/confirm-create-reserve.tsx:282
+#, c-format
+msgid "Error: URL may not be relative"
+msgstr ""
+
+#: popup/popup.tsx:270
+#, c-format
+msgid "Error: could not retrieve balance information."
+msgstr ""
+
+#: popup/popup.tsx:303
+#, c-format
+msgid "Bank requested reserve (%1$s) for %2$s."
+msgstr ""
+
+#: popup/popup.tsx:314
+#, c-format
+msgid "Started to withdraw %1$s from %2$s (%3$s)."
+msgstr ""
+
+#: popup/popup.tsx:324
+#, c-format
+msgid "Merchant %1$s offered contract %2$s."
+msgstr ""
+
+#: popup/popup.tsx:333
+#, c-format
+msgid "Withdrew %1$s from %2$s (%3$s)."
+msgstr ""
+
+#: popup/popup.tsx:343
+#, c-format
+msgid "Paid %1$s to merchant %2$s. (%3$s)"
+msgstr ""
+
+#: popup/popup.tsx:381
+#, c-format
+msgid "Error: could not retrieve event history"
+msgstr ""
+
+#: popup/popup.tsx:415
+#, c-format
+msgid "Your wallet has no events recorded."
+msgstr ""
diff --git a/src/module-trampoline.js b/src/module-trampoline.js
new file mode 100644
index 000000000..20ed91638
--- /dev/null
+++ b/src/module-trampoline.js
@@ -0,0 +1,73 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+
+/**
+ * Boilerplate to initialize the module system and call main()
+ *
+ * @author Florian Dold
+ */
+
+"use strict";
+
+if (typeof System === "undefined") {
+ throw Error("system loader not present (must be included before the" +
+ " trampoline");
+}
+
+System.config({
+ defaultJSExtensions: true,
+ map: {
+ src: "/src/",
+ },
+});
+
+let me = window.location.protocol
+ + "//" + window.location.host
+ + window.location.pathname.replace(/[.]html$/, ".js");
+
+let domLoaded = false;
+
+document.addEventListener("DOMContentLoaded", function(event) {
+ domLoaded = true;
+});
+
+function execMain(m) {
+ if (m.main) {
+ console.log("executing module main");
+ let res = m.main();
+ } else {
+ console.warn("module does not export a main() function");
+ }
+}
+
+console.log("loading", me);
+
+System.import(me)
+ .then((m) => {
+ console.log("module imported", me);
+ if (domLoaded) {
+ execMain(m);
+ return;
+ }
+ document.addEventListener("DOMContentLoaded", function(event) {
+ execMain(m);
+ });
+ })
+ .catch((e) => {
+ console.log("trampoline failed");
+ console.error(e.stack);
+ });
diff --git a/src/pages/confirm-contract.html b/src/pages/confirm-contract.html
new file mode 100644
index 000000000..54a4d618d
--- /dev/null
+++ b/src/pages/confirm-contract.html
@@ -0,0 +1,75 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <title>Taler Wallet: Confirm Reserve Creation</title>
+
+ <link rel="stylesheet" type="text/css" href="/src/style/lang.css">
+ <link rel="stylesheet" type="text/css" href="/src/style/wallet.css">
+
+ <link rel="icon" href="/img/icon.png">
+
+ <script src="/src/vendor/URI.js"></script>
+ <script src="/src/vendor/react.js"></script>
+ <script src="/src/vendor/react-dom.js"></script>
+ <script src="/src/vendor/system-csp-production.src.js"></script>
+ <!-- <script src="/src/vendor/jed.js"></script> -->
+ <script src="/src/i18n.js"></script>
+ <script src="/src/i18n/strings.js"></script>
+ <script src="/src/module-trampoline.js"></script>
+
+ <style>
+ button.accept {
+ background-color: #5757D2;
+ border: 1px solid black;
+ border-radius: 5px;
+ margin: 1em 0;
+ padding: 0.5em;
+ font-weight: bold;
+ color: white;
+ }
+ button.linky {
+ background:none!important;
+ border:none;
+ padding:0!important;
+
+ font-family:arial,sans-serif;
+ color:#069;
+ text-decoration:underline;
+ cursor:pointer;
+ }
+
+ input.url {
+ width: 25em;
+ }
+
+
+ button.accept:disabled {
+ background-color: #dedbe8;
+ border: 1px solid white;
+ border-radius: 5px;
+ margin: 1em 0;
+ padding: 0.5em;
+ font-weight: bold;
+ color: #2C2C2C;
+ }
+
+ .errorbox {
+ border: 1px solid;
+ display: inline-block;
+ margin: 1em;
+ padding: 1em;
+ font-weight: bold;
+ background: #FF8A8A;
+ }
+ </style>
+</head>
+
+<body>
+ <section id="main">
+ <h1>GNU Taler Wallet</h1>
+ <article id="contract" class="fade"></article>
+ </section>
+</body>
+
+</html>
diff --git a/src/pages/confirm-contract.tsx b/src/pages/confirm-contract.tsx
new file mode 100644
index 000000000..7bae691b1
--- /dev/null
+++ b/src/pages/confirm-contract.tsx
@@ -0,0 +1,231 @@
+/*
+ This file is part of TALER
+ (C) 2015 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+/**
+ * Page shown to the user to confirm entering
+ * a contract.
+ *
+ * @author Florian Dold
+ */
+
+"use strict";
+
+import {substituteFulfillmentUrl} from "src/helpers";
+import {Contract, AmountJson, IExchangeInfo} from "src/types";
+import {Offer} from "src/wallet";
+import {renderContract, prettyAmount} from "src/renderHtml";
+import {getExchanges} from "src/wxApi";
+
+
+interface DetailState {
+ collapsed: boolean;
+ exchanges: null|IExchangeInfo[];
+}
+
+interface DetailProps {
+ contract: Contract
+ collapsed: boolean
+}
+
+
+class Details extends React.Component<DetailProps, DetailState> {
+ constructor(props: DetailProps) {
+ super(props);
+ console.log("new Details component created");
+ this.state = {
+ collapsed: props.collapsed,
+ exchanges: null
+ };
+
+ console.log("initial state:", this.state);
+
+ this.update();
+ }
+
+ async update() {
+ let exchanges = await getExchanges();
+ this.setState({exchanges} as any);
+ }
+
+ render() {
+ if (this.state.collapsed) {
+ return (
+ <div>
+ <button className="linky"
+ onClick={() => { this.setState({collapsed: false} as any)}}>
+ show more details
+ </button>
+ </div>
+ );
+ } else {
+ return (
+ <div>
+ <button className="linky"
+ onClick={() => this.setState({collapsed: true} as any)}>
+ show less details
+ </button>
+ <div>
+ Accepted exchanges:
+ <ul>
+ {this.props.contract.exchanges.map(
+ e => <li>{`${e.url}: ${e.master_pub}`}</li>)}
+ </ul>
+ Exchanges in the wallet:
+ <ul>
+ {(this.state.exchanges || []).map(
+ (e: IExchangeInfo) =>
+ <li>{`${e.baseUrl}: ${e.masterPublicKey}`}</li>)}
+ </ul>
+ </div>
+ </div>);
+ }
+ }
+}
+
+interface ContractPromptProps {
+ offerId: number;
+}
+
+interface ContractPromptState {
+ offer: any;
+ error: string|null;
+ payDisabled: boolean;
+}
+
+class ContractPrompt extends React.Component<ContractPromptProps, ContractPromptState> {
+ constructor() {
+ super();
+ this.state = {
+ offer: undefined,
+ error: null,
+ payDisabled: true,
+ }
+ }
+
+ componentWillMount() {
+ this.update();
+ }
+
+ componentWillUnmount() {
+ // FIXME: abort running ops
+ }
+
+ async update() {
+ let offer = await this.getOffer();
+ this.setState({offer} as any);
+ this.checkPayment();
+ }
+
+ getOffer(): Promise<Offer> {
+ return new Promise((resolve, reject) => {
+ let msg = {
+ type: 'get-offer',
+ detail: {
+ offerId: this.props.offerId
+ }
+ };
+ chrome.runtime.sendMessage(msg, (resp) => {
+ resolve(resp);
+ });
+ })
+ }
+
+ checkPayment() {
+ let msg = {
+ type: 'check-pay',
+ detail: {
+ offer: this.state.offer
+ }
+ };
+ chrome.runtime.sendMessage(msg, (resp) => {
+ if (resp.error) {
+ console.log("check-pay error", JSON.stringify(resp));
+ switch (resp.error) {
+ case "coins-insufficient":
+ this.state.error = i18n`You have insufficient funds of the requested currency in your wallet.`;
+ break;
+ default:
+ this.state.error = `Error: ${resp.error}`;
+ break;
+ }
+ this.state.payDisabled = true;
+ } else {
+ this.state.payDisabled = false;
+ this.state.error = null;
+ }
+ this.setState({} as any);
+ window.setTimeout(() => this.checkPayment(), 500);
+ });
+ }
+
+ doPayment() {
+ let d = {offer: this.state.offer};
+ chrome.runtime.sendMessage({type: 'confirm-pay', detail: d}, (resp) => {
+ if (resp.error) {
+ console.log("confirm-pay error", JSON.stringify(resp));
+ switch (resp.error) {
+ case "coins-insufficient":
+ this.state.error = "You do not have enough coins of the" +
+ " requested currency.";
+ break;
+ default:
+ this.state.error = `Error: ${resp.error}`;
+ break;
+ }
+ this.setState({} as any);
+ return;
+ }
+ let c = d.offer.contract;
+ console.log("contract", c);
+ document.location.href = substituteFulfillmentUrl(c.fulfillment_url,
+ this.state.offer);
+ });
+ }
+
+
+ render() {
+ if (!this.state.offer) {
+ return <span>...</span>;
+ }
+ let c = this.state.offer.contract;
+ return (
+ <div>
+ <div>
+ {renderContract(c)}
+ </div>
+ <button onClick={() => this.doPayment()}
+ disabled={this.state.payDisabled}
+ className="accept">
+ Confirm payment
+ </button>
+ <div>
+ {(this.state.error ? <p className="errorbox">{this.state.error}</p> : <p />)}
+ </div>
+ <Details contract={c} collapsed={!this.state.error}/>
+ </div>
+ );
+ }
+}
+
+
+export function main() {
+ let url = URI(document.location.href);
+ let query: any = URI.parseQuery(url.query());
+ let offerId = JSON.parse(query.offerId);
+
+ ReactDOM.render(<ContractPrompt offerId={offerId}/>, document.getElementById(
+ "contract")!);
+}
diff --git a/src/pages/confirm-create-reserve.html b/src/pages/confirm-create-reserve.html
new file mode 100644
index 000000000..c67c7e960
--- /dev/null
+++ b/src/pages/confirm-create-reserve.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <title>Taler Wallet: Select Taler Provider</title>
+
+ <link rel="icon" href="/img/icon.png">
+
+ <script src="/src/vendor/URI.js"></script>
+ <script src="/src/vendor/react.js"></script>
+ <script src="/src/vendor/react-dom.js"></script>
+
+ <!-- i18n -->
+ <script src="/src/vendor/jed.js"></script>
+ <script src="/src/i18n.js"></script>
+ <script src="/src/i18n/strings.js"></script>
+
+ <!-- module loading -->
+ <script src="/src/vendor/system-csp-production.src.js"></script>
+ <script src="/src/module-trampoline.js"></script>
+
+
+ <style>
+ #main {
+ border: solid 1px black;
+ border-radius: 10px;
+ margin: auto;
+ max-width: 50%;
+ padding: 2em;
+ }
+
+ button.accept {
+ background-color: #5757D2;
+ border: 1px solid black;
+ border-radius: 5px;
+ margin: 1em 0;
+ padding: 0.5em;
+ font-weight: bold;
+ color: white;
+ }
+ button.linky {
+ background:none!important;
+ border:none;
+ padding:0!important;
+
+ font-family:arial,sans-serif;
+ color:#069;
+ text-decoration:underline;
+ cursor:pointer;
+ }
+
+
+ button.accept:disabled {
+ background-color: #dedbe8;
+ border: 1px solid white;
+ border-radius: 5px;
+ margin: 1em 0;
+ padding: 0.5em;
+ font-weight: bold;
+ color: #2C2C2C;
+ }
+
+ input.url {
+ width: 25em;
+ }
+
+ table {
+ border-collapse: collapse;
+ }
+
+ td {
+ border-left: 1px solid black;
+ border-right: 1px solid black;
+ text-align: center;
+ padding: 0.3em;
+ }
+
+ span.spacer {
+ padding-left: 0.5em;
+ padding-right: 0.5em;
+ }
+
+ </style>
+</head>
+
+<body>
+ <section id="main">
+ <h1>GNU Taler Wallet</h1>
+ <div class="fade" id="exchange-selection"></div>
+ </section>
+</body>
+
+</html>
diff --git a/src/pages/confirm-create-reserve.tsx b/src/pages/confirm-create-reserve.tsx
new file mode 100644
index 000000000..372f11a4b
--- /dev/null
+++ b/src/pages/confirm-create-reserve.tsx
@@ -0,0 +1,397 @@
+/*
+ This file is part of TALER
+ (C) 2015-2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+
+/**
+ * Page shown to the user to confirm creation
+ * of a reserve, usually requested by the bank.
+ *
+ * @author Florian Dold
+ */
+
+import {amountToPretty, canonicalizeBaseUrl} from "src/helpers";
+import {
+ AmountJson, CreateReserveResponse,
+ ReserveCreationInfo, Amounts,
+ Denomination,
+} from "src/types";
+import {getReserveCreationInfo} from "src/wxApi";
+import {ImplicitStateComponent, StateHolder} from "src/components";
+
+"use strict";
+
+
+function delay<T>(delayMs: number, value: T): Promise<T> {
+ return new Promise<T>((resolve, reject) => {
+ setTimeout(() => resolve(value), delayMs);
+ });
+}
+
+class EventTrigger {
+ triggerResolve: any;
+ triggerPromise: Promise<boolean>;
+
+ constructor() {
+ this.reset();
+ }
+
+ private reset() {
+ this.triggerPromise = new Promise<boolean>((resolve, reject) => {
+ this.triggerResolve = resolve;
+ });
+ }
+
+ trigger() {
+ this.triggerResolve(false);
+ this.reset();
+ }
+
+ async wait(delayMs: number): Promise<boolean> {
+ return await Promise.race([this.triggerPromise, delay(delayMs, true)]);
+ }
+}
+
+
+function renderReserveCreationDetails(rci: ReserveCreationInfo|null) {
+ if (!rci) {
+ return <p>
+ Details will be displayed when a valid exchange provider URL is entered.</p>
+ }
+
+ let denoms = rci.selectedDenoms;
+
+ let countByPub: {[s: string]: number} = {};
+ let uniq: Denomination[] = [];
+
+ denoms.forEach((x: Denomination) => {
+ let c = countByPub[x.denom_pub] || 0;
+ if (c == 0) {
+ uniq.push(x);
+ }
+ c += 1;
+ countByPub[x.denom_pub] = c;
+ });
+
+ function row(denom: Denomination) {
+ return (
+ <tr>
+ <td>{countByPub[denom.denom_pub] + "x"}</td>
+ <td>{amountToPretty(denom.value)}</td>
+ <td>{amountToPretty(denom.fee_withdraw)}</td>
+ <td>{amountToPretty(denom.fee_refresh)}</td>
+ <td>{amountToPretty(denom.fee_deposit)}</td>
+ </tr>
+ );
+ }
+
+ let withdrawFeeStr = amountToPretty(rci.withdrawFee);
+ let overheadStr = amountToPretty(rci.overhead);
+
+ return (
+ <div>
+ <p>{`Withdrawal fees: ${withdrawFeeStr}`}</p>
+ <p>{`Rounding loss: ${overheadStr}`}</p>
+ <table>
+ <thead>
+ <th># Coins</th>
+ <th>Value</th>
+ <th>Withdraw Fee</th>
+ <th>Refresh Fee</th>
+ <th>Deposit fee</th>
+ </thead>
+ <tbody>
+ {uniq.map(row)}
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+
+function getSuggestedExchange(currency: string): Promise<string> {
+ // TODO: make this request go to the wallet backend
+ // Right now, this is a stub.
+ const defaultExchange: {[s: string]: string} = {
+ "KUDOS": "https://exchange.demo.taler.net",
+ "PUDOS": "https://exchange.test.taler.net",
+ };
+
+ let exchange = defaultExchange[currency];
+
+ if (!exchange) {
+ exchange = ""
+ }
+
+ return Promise.resolve(exchange);
+}
+
+
+function WithdrawFee(props: {reserveCreationInfo: ReserveCreationInfo|null}): JSX.Element {
+ if (props.reserveCreationInfo) {
+ let {overhead, withdrawFee} = props.reserveCreationInfo;
+ let totalCost = Amounts.add(overhead, withdrawFee).amount;
+ return <p>Withdraw fees: {amountToPretty(totalCost)}</p>;
+ }
+ return <p />;
+}
+
+
+interface ExchangeSelectionProps {
+ suggestedExchangeUrl: string;
+ amount: AmountJson;
+ callback_url: string;
+ wt_types: string[];
+}
+
+
+class ExchangeSelection extends ImplicitStateComponent<ExchangeSelectionProps> {
+ statusString: StateHolder<string|null> = this.makeState(null);
+ reserveCreationInfo: StateHolder<ReserveCreationInfo|null> = this.makeState(
+ null);
+ url: StateHolder<string|null> = this.makeState(null);
+ detailCollapsed: StateHolder<boolean> = this.makeState(true);
+
+ updateEvent = new EventTrigger();
+
+ constructor(props: ExchangeSelectionProps) {
+ super(props);
+ this.onUrlChanged(props.suggestedExchangeUrl || null);
+ }
+
+
+ renderAdvanced(): JSX.Element {
+ if (this.detailCollapsed() && this.url() !== null && !this.statusString()) {
+ return (
+ <button className="linky"
+ onClick={() => this.detailCollapsed(false)}>
+ view fee structure / select different exchange provider
+ </button>
+ );
+ }
+ return (
+ <div>
+ <h2>Provider Selection</h2>
+ <label>URL: </label>
+ <input className="url" type="text" spellCheck={false}
+ value={this.url()!}
+ key="exchange-url-input"
+ onInput={(e) => this.onUrlChanged((e.target as HTMLInputElement).value)}/>
+ <br />
+ {this.renderStatus()}
+ <h2>Detailed Fee Structure</h2>
+ {renderReserveCreationDetails(this.reserveCreationInfo())}
+ </div>)
+ }
+
+ renderFee() {
+ if (!this.reserveCreationInfo()) {
+ return "??";
+ }
+ let rci = this.reserveCreationInfo()!;
+ let totalCost = Amounts.add(rci.overhead, rci.withdrawFee).amount;
+ return `${amountToPretty(totalCost)}`;
+ }
+
+ renderFeeStatus() {
+ if (this.reserveCreationInfo()) {
+ return (
+ <p>
+ The exchange provider will charge
+ {" "}
+ {this.renderFee()}
+ {" "}
+ in fees.
+ </p>
+ );
+ }
+ if (this.url() && !this.statusString()) {
+ let shortName = URI(this.url()!).host();
+ return <p>
+ Waiting for a response from
+ {" "}
+ <em>{shortName}</em>
+ </p>;
+ }
+ if (this.statusString()) {
+ return (
+ <p>
+ <strong style={{color: "red"}}>A problem occured, see below.</strong>
+ </p>
+ );
+ }
+ return (
+ <p>
+ Information about fees will be available when an exchange provider is selected.
+ </p>
+ );
+ }
+
+ render(): JSX.Element {
+ return (
+ <div>
+ <p>
+ {"You are about to withdraw "}
+ <strong>{amountToPretty(this.props.amount)}</strong>
+ {" from your bank account into your wallet."}
+ </p>
+ {this.renderFeeStatus()}
+ <button className="accept"
+ disabled={this.reserveCreationInfo() == null}
+ onClick={() => this.confirmReserve()}>
+ Accept fees and withdraw
+ </button>
+ <br/>
+ {this.renderAdvanced()}
+ </div>
+ );
+ }
+
+
+ confirmReserve() {
+ this.confirmReserveImpl(this.reserveCreationInfo()!,
+ this.url()!,
+ this.props.amount,
+ this.props.callback_url);
+ }
+
+ /**
+ * Do an update of the reserve creation info, without any debouncing.
+ */
+ async forceReserveUpdate() {
+ this.reserveCreationInfo(null);
+ if (!this.url()) {
+ this.statusString(i18n`Error: URL is empty`);
+ return;
+ }
+
+ this.statusString(null);
+ let parsedUrl = URI(this.url()!);
+ if (parsedUrl.is("relative")) {
+ this.statusString(i18n`Error: URL may not be relative`);
+ return;
+ }
+
+ try {
+ let r = await getReserveCreationInfo(this.url()!,
+ this.props.amount);
+ console.log("get exchange info resolved");
+ this.reserveCreationInfo(r);
+ console.dir(r);
+ } catch (e) {
+ console.log("get exchange info rejected");
+ if (e.hasOwnProperty("httpStatus")) {
+ this.statusString(`Error: request failed with status ${e.httpStatus}`);
+ } else if (e.hasOwnProperty("errorResponse")) {
+ let resp = e.errorResponse;
+ this.statusString(`Error: ${resp.error} (${resp.hint})`);
+ }
+ }
+ }
+
+ reset() {
+ this.statusString(null);
+ this.reserveCreationInfo(null);
+ }
+
+ confirmReserveImpl(rci: ReserveCreationInfo,
+ exchange: string,
+ amount: AmountJson,
+ callback_url: string) {
+ const d = {exchange, amount};
+ const cb = (rawResp: any) => {
+ if (!rawResp) {
+ throw Error("empty response");
+ }
+ // FIXME: filter out types that bank/exchange don't have in common
+ let wire_details = rci.wireInfo;
+ if (!rawResp.error) {
+ const resp = CreateReserveResponse.checked(rawResp);
+ let q: {[name: string]: string|number} = {
+ wire_details: JSON.stringify(wire_details),
+ exchange: resp.exchange,
+ reserve_pub: resp.reservePub,
+ amount_value: amount.value,
+ amount_fraction: amount.fraction,
+ amount_currency: amount.currency,
+ };
+ let url = URI(callback_url).addQuery(q);
+ if (!url.is("absolute")) {
+ throw Error("callback url is not absolute");
+ }
+ console.log("going to", url.href());
+ document.location.href = url.href();
+ } else {
+ this.reset();
+ this.statusString(
+ `Oops, something went wrong.` +
+ `The wallet responded with error status (${rawResp.error}).`);
+ }
+ };
+ chrome.runtime.sendMessage({type: 'create-reserve', detail: d}, cb);
+ }
+
+ async onUrlChanged(url: string|null) {
+ this.reset();
+ this.url(url);
+ if (url == undefined) {
+ return;
+ }
+ this.updateEvent.trigger();
+ let waited = await this.updateEvent.wait(200);
+ if (waited) {
+ // Run the actual update if nobody else preempted us.
+ this.forceReserveUpdate();
+ this.forceUpdate();
+ }
+ }
+
+ renderStatus(): any {
+ if (this.statusString()) {
+ return <p><strong style={{color: "red"}}>{this.statusString()}</strong></p>;
+ } else if (!this.reserveCreationInfo()) {
+ return <p>Checking URL, please wait ...</p>;
+ }
+ return "";
+ }
+}
+
+export async function main() {
+ const url = URI(document.location.href);
+ const query: any = URI.parseQuery(url.query());
+ const amount = AmountJson.checked(JSON.parse(query.amount));
+ const callback_url = query.callback_url;
+ const bank_url = query.bank_url;
+ const wt_types = JSON.parse(query.wt_types);
+
+ try {
+ const suggestedExchangeUrl = await getSuggestedExchange(amount.currency);
+ let args = {
+ wt_types,
+ suggestedExchangeUrl,
+ callback_url,
+ amount
+ };
+
+ ReactDOM.render(<ExchangeSelection {...args} />, document.getElementById(
+ "exchange-selection")!);
+
+ } catch (e) {
+ // TODO: provide more context information, maybe factor it out into a
+ // TODO:generic error reporting function or component.
+ document.body.innerText = `Fatal error: "${e.message}".`;
+ console.error(`got error "${e.message}"`, e);
+ }
+}
diff --git a/src/pages/debug.html b/src/pages/debug.html
new file mode 100644
index 000000000..b8ddc7ccb
--- /dev/null
+++ b/src/pages/debug.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html>
+ <head>
+ <title>Taler Wallet Debugging</title>
+ <link rel="icon" href="../img/icon.png">
+ </head>
+ <body>
+ <h1>Debug Pages</h1>
+ <a href="show-db.html">Show DB</a> <br>
+ <a href="/src/popup/balance-overview.html">Show balance</a>
+
+ </body>
+</html>
diff --git a/src/pages/help/empty-wallet.html b/src/pages/help/empty-wallet.html
new file mode 100644
index 000000000..dd29d9689
--- /dev/null
+++ b/src/pages/help/empty-wallet.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>GNU Taler Help - Empty Wallet</title>
+ <link rel="icon" href="/img/icon.png">
+ <meta name="description" content="">
+ <link rel="stylesheet" type="text/css" href="/src/style/wallet.css">
+ </head>
+ <body>
+ <div class="container" id="main">
+ <div class="row">
+ <div class="col-lg-12">
+ <h2 lang="en">Your wallet is empty!</h2>
+ <p lang="en">You have succeeded with installing the Taler wallet. However, before
+ you can buy articles using the Taler wallet, you must withdraw electronic coins.
+ This is typically done by visiting your bank's online banking Web site. There,
+ you instruct your bank to transfer the funds to a Taler exchange operator. In
+ return, your wallet will be allowed to withdraw electronic coins.</p>
+ <p lang="en">At this stage, we are not aware of any regular exchange operators issuing
+ coins in well-known currencies. However, to see how Taler would work, you
+ can visit our "fake" bank at
+ <a href="https://bank.demo.taler.net/">bank.demo.taler.net</a> to
+ withdraw coins in the "KUDOS" currency that we created just for
+ demonstrating the system.</p>
+ </div>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/src/pages/show-db.html b/src/pages/show-db.html
new file mode 100644
index 000000000..af8ca6eb1
--- /dev/null
+++ b/src/pages/show-db.html
@@ -0,0 +1,15 @@
+
+<!doctype html>
+
+<html>
+ <head>
+ <title>Taler Wallet: Reserve Created</title>
+ <link rel="stylesheet" type="text/css" href="../style/wallet.css">
+ <link rel="icon" href="/img/icon.png">
+ <script src="show-db.js"></script>
+ </head>
+ <body>
+ <h1>DB Dump</h1>
+ <pre id="dump"></pre>
+ </body>
+</html>
diff --git a/src/pages/show-db.ts b/src/pages/show-db.ts
new file mode 100644
index 000000000..71e74388b
--- /dev/null
+++ b/src/pages/show-db.ts
@@ -0,0 +1,57 @@
+/*
+ This file is part of TALER
+ (C) 2015 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+
+/**
+ * Wallet database dump for debugging.
+ *
+ * @author Florian Dold
+ */
+
+function replacer(match: string, pIndent: string, pKey: string, pVal: string,
+ pEnd: string) {
+ var key = '<span class=json-key>';
+ var val = '<span class=json-value>';
+ var str = '<span class=json-string>';
+ var r = pIndent || '';
+ if (pKey) {
+ r = r + key + pKey.replace(/[": ]/g, '') + '</span>: ';
+ }
+ if (pVal) {
+ r = r + (pVal[0] == '"' ? str : val) + pVal + '</span>';
+ }
+ return r + (pEnd || '');
+}
+
+
+function prettyPrint(obj: any) {
+ var jsonLine = /^( *)("[\w]+": )?("[^"]*"|[\w.+-]*)?([,[{])?$/mg;
+ return JSON.stringify(obj, null as any, 3)
+ .replace(/&/g, '&amp;').replace(/\\"/g, '&quot;')
+ .replace(/</g, '&lt;').replace(/>/g, '&gt;')
+ .replace(jsonLine, replacer);
+}
+
+
+document.addEventListener("DOMContentLoaded", () => {
+ chrome.runtime.sendMessage({type: 'dump-db'}, (resp) => {
+ const el = document.getElementById('dump');
+ if (!el) {
+ throw Error();
+ }
+ el.innerHTML = prettyPrint(resp);
+ });
+});
diff --git a/src/pages/tree.html b/src/pages/tree.html
new file mode 100644
index 000000000..306044159
--- /dev/null
+++ b/src/pages/tree.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <title>Taler Wallet: Tree View</title>
+
+ <link rel="stylesheet" type="text/css" href="../style/lang.css">
+ <link rel="stylesheet" type="text/css" href="../style/wallet.css">
+
+ <link rel="icon" href="/img/icon.png">
+
+ <script src="/src/vendor/URI.js"></script>
+ <script src="/src/vendor/react.js"></script>
+ <script src="/src/vendor/react-dom.js"></script>
+
+ <!-- i18n -->
+ <script src="/src/vendor/jed.js"></script>
+ <script src="/src/i18n.js"></script>
+ <script src="/src/i18n/strings.js"></script>
+
+ <script src="/src/vendor/system-csp-production.src.js"></script>
+ <script src="/src/module-trampoline.js"></script>
+
+ <style>
+ .tree-item {
+ margin: 2em;
+ border-radius: 5px;
+ border: 1px solid gray;
+ padding: 1em;
+ }
+ </style>
+
+ <body>
+ <div id="container"></div>
+ </body>
+</html>
diff --git a/src/pages/tree.tsx b/src/pages/tree.tsx
new file mode 100644
index 000000000..e368ffe9b
--- /dev/null
+++ b/src/pages/tree.tsx
@@ -0,0 +1,400 @@
+/*
+ This file is part of TALER
+ (C) 2016 Inria
+
+ 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
+ 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/>
+ */
+
+/**
+ * Show contents of the wallet as a tree.
+ *
+ * @author Florian Dold
+ */
+
+
+import { IExchangeInfo } from "src/types";
+import { ReserveRecord, Coin, PreCoin, Denomination } from "src/types";
+import { ImplicitStateComponent, StateHolder } from "src/components";
+import {
+ getReserves, getExchanges, getCoins, getPreCoins,
+ refresh
+} from "src/wxApi";
+import { prettyAmount, abbrev } from "src/renderHtml";
+import { getTalerStampDate } from "src/helpers";
+
+interface ReserveViewProps {
+ reserve: ReserveRecord;
+}
+
+class ReserveView extends React.Component<ReserveViewProps, void> {
+ render(): JSX.Element {
+ let r: ReserveRecord = this.props.reserve;
+ return (
+ <div className="tree-item">
+ <ul>
+ <li>Key: {r.reserve_pub}</li>
+ <li>Created: {(new Date(r.created * 1000).toString())}</li>
+ <li>Current: {r.current_amount ? prettyAmount(r.current_amount!) : "null"}</li>
+ <li>Requested: {prettyAmount(r.requested_amount)}</li>
+ <li>Confirmed: {r.confirmed}</li>
+ </ul>
+ </div>
+ );
+ }
+}
+
+interface ReserveListProps {
+ exchangeBaseUrl: string;
+}
+
+interface ToggleProps {
+ expanded: StateHolder<boolean>;
+}
+
+class Toggle extends ImplicitStateComponent<ToggleProps> {
+ renderButton() {
+ let show = () => {
+ this.props.expanded(true);
+ this.setState({});
+ };
+ let hide = () => {
+ this.props.expanded(false);
+ this.setState({});
+ };
+ if (this.props.expanded()) {
+ return <button onClick={hide}>hide</button>;
+ }
+ return <button onClick={show}>show</button>;
+
+ }
+ render() {
+ return (
+ <div style={{display: "inline"}}>
+ {this.renderButton()}
+ {this.props.expanded() ? this.props.children : []}
+ </div>);
+ }
+}
+
+
+interface CoinViewProps {
+ coin: Coin;
+}
+
+interface RefreshDialogProps {
+ coin: Coin;
+}
+
+class RefreshDialog extends ImplicitStateComponent<RefreshDialogProps> {
+ refreshRequested = this.makeState<boolean>(false);
+ render(): JSX.Element {
+ if (!this.refreshRequested()) {
+ return (
+ <div style={{display: "inline"}}>
+ <button onClick={() => this.refreshRequested(true)}>refresh</button>
+ </div>
+ );
+ }
+ return (
+ <div>
+ Refresh amount: <input type="text" size={10} />
+ <button onClick={() => refresh(this.props.coin.coinPub)}>ok</button>
+ <button onClick={() => this.refreshRequested(false)}>cancel</button>
+ </div>
+ );
+ }
+}
+
+class CoinView extends React.Component<CoinViewProps, void> {
+ render() {
+ let c = this.props.coin;
+ return (
+ <div className="tree-item">
+ <ul>
+ <li>Key: {c.coinPub}</li>
+ <li>Current amount: {prettyAmount(c.currentAmount)}</li>
+ <li>Denomination: {abbrev(c.denomPub, 20)}</li>
+ <li>Suspended: {(c.suspended || false).toString()}</li>
+ <li><RefreshDialog coin={c} /></li>
+ </ul>
+ </div>
+ );
+ }
+}
+
+
+
+interface PreCoinViewProps {
+ precoin: PreCoin;
+}
+
+class PreCoinView extends React.Component<PreCoinViewProps, void> {
+ render() {
+ let c = this.props.precoin;
+ return (
+ <div className="tree-item">
+ <ul>
+ <li>Key: {c.coinPub}</li>
+ </ul>
+ </div>
+ );
+ }
+}
+
+interface CoinListProps {
+ exchangeBaseUrl: string;
+}
+
+class CoinList extends ImplicitStateComponent<CoinListProps> {
+ coins = this.makeState<Coin[] | null>(null);
+ expanded = this.makeState<boolean>(false);
+
+ constructor(props: CoinListProps) {
+ super(props);
+ this.update(props);
+ }
+
+ async update(props: CoinListProps) {
+ let coins = await getCoins(props.exchangeBaseUrl);
+ this.coins(coins);
+ }
+
+ componentWillReceiveProps(newProps: CoinListProps) {
+ this.update(newProps);
+ }
+
+ render(): JSX.Element {
+ if (!this.coins()) {
+ return <div>...</div>;
+ }
+ return (
+ <div className="tree-item">
+ Coins ({this.coins() !.length.toString()})
+ {" "}
+ <Toggle expanded={this.expanded}>
+ {this.coins() !.map((c) => <CoinView coin={c} />)}
+ </Toggle>
+ </div>
+ );
+ }
+}
+
+
+interface PreCoinListProps {
+ exchangeBaseUrl: string;
+}
+
+class PreCoinList extends ImplicitStateComponent<PreCoinListProps> {
+ precoins = this.makeState<PreCoin[] | null>(null);
+ expanded = this.makeState<boolean>(false);
+
+ constructor(props: PreCoinListProps) {
+ super(props);
+ this.update();
+ }
+
+ async update() {
+ let precoins = await getPreCoins(this.props.exchangeBaseUrl);
+ this.precoins(precoins);
+ }
+
+ render(): JSX.Element {
+ if (!this.precoins()) {
+ return <div>...</div>;
+ }
+ return (
+ <div className="tree-item">
+ Pre-Coins ({this.precoins() !.length.toString()})
+ {" "}
+ <Toggle expanded={this.expanded}>
+ {this.precoins() !.map((c) => <PreCoinView precoin={c} />)}
+ </Toggle>
+ </div>
+ );
+ }
+}
+
+interface DenominationListProps {
+ exchange: IExchangeInfo;
+}
+
+interface ExpanderTextProps {
+ text: string;
+}
+
+class ExpanderText extends ImplicitStateComponent<ExpanderTextProps> {
+ expanded = this.makeState<boolean>(false);
+ textArea: any = undefined;
+
+ componentDidUpdate() {
+ if (this.expanded() && this.textArea) {
+ this.textArea.focus();
+ this.textArea.scrollTop = 0;
+ }
+ }
+
+ render(): JSX.Element {
+ if (!this.expanded()) {
+ return (
+ <span onClick={() => { this.expanded(true); }}>
+ {(this.props.text.length <= 10)
+ ? this.props.text
+ : (
+ <span>
+ {this.props.text.substring(0,10)}
+ <span style={{textDecoration: "underline"}}>...</span>
+ </span>
+ )
+ }
+ </span>
+ );
+ }
+ return (
+ <textarea
+ readOnly
+ style={{display: "block"}}
+ onBlur={() => this.expanded(false)}
+ ref={(e) => this.textArea = e}>
+ {this.props.text}
+ </textarea>
+ );
+ }
+}
+
+class DenominationList extends ImplicitStateComponent<DenominationListProps> {
+ expanded = this.makeState<boolean>(false);
+
+ renderDenom(d: Denomination) {
+ return (
+ <div className="tree-item">
+ <ul>
+ <li>Value: {prettyAmount(d.value)}</li>
+ <li>Withdraw fee: {prettyAmount(d.fee_withdraw)}</li>
+ <li>Refresh fee: {prettyAmount(d.fee_refresh)}</li>
+ <li>Deposit fee: {prettyAmount(d.fee_deposit)}</li>
+ <li>Refund fee: {prettyAmount(d.fee_refund)}</li>
+ <li>Start: {getTalerStampDate(d.stamp_start)!.toString()}</li>
+ <li>Withdraw expiration: {getTalerStampDate(d.stamp_expire_withdraw)!.toString()}</li>
+ <li>Legal expiration: {getTalerStampDate(d.stamp_expire_legal)!.toString()}</li>
+ <li>Deposit expiration: {getTalerStampDate(d.stamp_expire_deposit)!.toString()}</li>
+ <li>Denom pub: <ExpanderText text={d.denom_pub} /></li>
+ </ul>
+ </div>
+ );
+ }
+
+ render(): JSX.Element {
+ return (
+ <div className="tree-item">
+ Denominations ({this.props.exchange.active_denoms.length.toString()})
+ {" "}
+ <Toggle expanded={this.expanded}>
+ {this.props.exchange.active_denoms.map((d) => this.renderDenom(d))}
+ </Toggle>
+ </div>
+ );
+ }
+}
+
+class ReserveList extends ImplicitStateComponent<ReserveListProps> {
+ reserves = this.makeState<ReserveRecord[] | null>(null);
+ expanded = this.makeState<boolean>(false);
+
+ constructor(props: ReserveListProps) {
+ super(props);
+ this.update();
+ }
+
+ async update() {
+ let reserves = await getReserves(this.props.exchangeBaseUrl);
+ this.reserves(reserves);
+ }
+
+ render(): JSX.Element {
+ if (!this.reserves()) {
+ return <div>...</div>;
+ }
+ return (
+ <div className="tree-item">
+ Reserves ({this.reserves() !.length.toString()})
+ {" "}
+ <Toggle expanded={this.expanded}>
+ {this.reserves() !.map((r) => <ReserveView reserve={r} />)}
+ </Toggle>
+ </div>
+ );
+ }
+}
+
+interface ExchangeProps {
+ exchange: IExchangeInfo;
+}
+
+class ExchangeView extends React.Component<ExchangeProps, void> {
+ render(): JSX.Element {
+ let e = this.props.exchange;
+ return (
+ <div className="tree-item">
+ <ul>
+ <li>Exchange Base Url: {this.props.exchange.baseUrl}</li>
+ <li>Master public key: <ExpanderText text={this.props.exchange.masterPublicKey} /></li>
+ </ul>
+ <DenominationList exchange={e} />
+ <ReserveList exchangeBaseUrl={this.props.exchange.baseUrl} />
+ <CoinList exchangeBaseUrl={this.props.exchange.baseUrl} />
+ <PreCoinList exchangeBaseUrl={this.props.exchange.baseUrl} />
+ </div>
+ );
+ }
+}
+
+interface ExchangesListState {
+ exchanges?: IExchangeInfo[];
+}
+
+class ExchangesList extends React.Component<any, ExchangesListState> {
+ constructor() {
+ super();
+ let port = chrome.runtime.connect();
+ port.onMessage.addListener((msg: any) => {
+ if (msg.notify) {
+ console.log("got notified");
+ this.update();
+ }
+ });
+ this.update();
+ this.state = {} as any;
+ }
+
+ async update() {
+ let exchanges = await getExchanges();
+ console.log("exchanges: ", exchanges);
+ this.setState({ exchanges });
+ }
+
+ render(): JSX.Element {
+ let exchanges = this.state.exchanges;
+ if (!exchanges) {
+ return <span>...</span>;
+ }
+ return (
+ <div className="tree-item">
+ Exchanges ({exchanges.length.toString()}):
+ {exchanges.map(e => <ExchangeView exchange={e} />)}
+ </div>
+ );
+ }
+}
+
+export function main() {
+ ReactDOM.render(<ExchangesList />, document.getElementById("container")!);
+}
diff --git a/src/popup/popup.css b/src/popup/popup.css
new file mode 100644
index 000000000..675412c11
--- /dev/null
+++ b/src/popup/popup.css
@@ -0,0 +1,84 @@
+
+/**
+ * @author Gabor X. Toth
+ * @author Marcello Stanisci
+ * @author Florian Dold
+ */
+
+body {
+ min-height: 20em;
+ width: 30em;
+ margin: 0;
+ padding: 0;
+ max-height: 800px;
+ overflow: hidden;
+}
+
+.nav {
+ background-color: #ddd;
+ padding: 0.5em 0;
+}
+
+.nav a {
+ color: black;
+ padding: 0.5em;
+ text-decoration: none;
+}
+
+.nav a.active {
+ background-color: white;
+ font-weight: bold;
+}
+
+
+.container {
+ overflow-y: scroll;
+ max-height: 400px;
+}
+
+.abbrev {
+ text-decoration-style: dotted;
+}
+
+#content {
+ padding: 1em;
+}
+
+
+#wallet-table .amount {
+ text-align: right;
+}
+
+.hidden {
+ display: none;
+}
+
+#transactions-table th,
+#transactions-table td {
+ padding: 0.2em 0.5em;
+}
+
+#reserve-create table {
+ width: 100%;
+}
+
+#reserve-create table td.label {
+ width: 5em;
+}
+
+#reserve-create table .input input[type="text"] {
+ width: 100%;
+}
+
+.historyItem {
+ border: 1px solid black;
+ border-radius: 10px;
+ padding-left: 0.5em;
+ margin: 0.5em;
+}
+
+.historyDate {
+ font-size: 90%;
+ margin: 0.3em;
+ color: slategray;
+}
diff --git a/src/popup/popup.html b/src/popup/popup.html
new file mode 100644
index 000000000..30b11aaea
--- /dev/null
+++ b/src/popup/popup.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="utf-8">
+
+ <link rel="stylesheet" type="text/css" href="../style/lang.css">
+ <link rel="stylesheet" type="text/css" href="popup.css">
+
+ <script src="/src/vendor/react.js"></script>
+ <script src="/src/vendor/react-dom.js"></script>
+ <script src="/src/vendor/URI.js"></script>
+
+ <script src="/src/vendor/jed.js"></script>
+ <script src="/src/i18n.js"></script>
+ <script src="/src/i18n/strings.js"></script>
+
+ <script src="/src/vendor/system-csp-production.src.js"></script>
+ <script src="/src/module-trampoline.js"></script>
+</head>
+
+<body>
+<div id="content" style="margin:0;padding:0"></div>
+</body>
+
+</html>
diff --git a/src/popup/popup.tsx b/src/popup/popup.tsx
new file mode 100644
index 000000000..c59ee3ea8
--- /dev/null
+++ b/src/popup/popup.tsx
@@ -0,0 +1,508 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+
+/**
+ * Popup shown to the user when they click
+ * the Taler browser action button.
+ *
+ * @author Florian Dold
+ */
+
+
+"use strict";
+
+import {substituteFulfillmentUrl} from "src/helpers";
+import BrowserClickedEvent = chrome.browserAction.BrowserClickedEvent;
+import {HistoryRecord, HistoryLevel} from "src/wallet";
+import {
+ AmountJson, WalletBalance, Amounts,
+ WalletBalanceEntry
+} from "src/types";
+import {abbrev, prettyAmount} from "src/renderHtml";
+
+declare var i18n: any;
+
+function onUpdateNotification(f: () => void): () => void {
+ let port = chrome.runtime.connect({name: "notifications"});
+ let listener = (msg: any, port: any) => {
+ f();
+ };
+ port.onMessage.addListener(listener);
+ return () => {
+ port.onMessage.removeListener(listener);
+ }
+}
+
+
+class Router extends React.Component<any,any> {
+ static setRoute(s: string): void {
+ window.location.hash = s;
+ }
+
+ static getRoute(): string {
+ // Omit the '#' at the beginning
+ return window.location.hash.substring(1);
+ }
+
+ static onRoute(f: any): () => void {
+ Router.routeHandlers.push(f);
+ return () => {
+ let i = Router.routeHandlers.indexOf(f);
+ this.routeHandlers = this.routeHandlers.splice(i, 1);
+ }
+ }
+
+ static routeHandlers: any[] = [];
+
+ componentWillMount() {
+ console.log("router mounted");
+ window.onhashchange = () => {
+ this.setState({});
+ for (let f of Router.routeHandlers) {
+ f();
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ console.log("router unmounted");
+ }
+
+
+ render(): JSX.Element {
+ let route = window.location.hash.substring(1);
+ console.log("rendering route", route);
+ let defaultChild: React.ReactChild|null = null;
+ let foundChild: React.ReactChild|null = null;
+ React.Children.forEach(this.props.children, (child) => {
+ let childProps: any = (child as any).props;
+ if (!childProps) {
+ return;
+ }
+ if (childProps["default"]) {
+ defaultChild = child;
+ }
+ if (childProps["route"] == route) {
+ foundChild = child;
+ }
+ })
+ let child: React.ReactChild | null = foundChild || defaultChild;
+ if (!child) {
+ throw Error("unknown route");
+ }
+ Router.setRoute((child as any).props["route"]);
+ return <div>{child}</div>;
+ }
+}
+
+export function main() {
+ console.log("popup main");
+
+ let el = (
+ <div>
+ <WalletNavBar />
+ <div style={{margin: "1em"}}>
+ <Router>
+ <WalletBalanceView route="/balance" default/>
+ <WalletHistory route="/history"/>
+ <WalletDebug route="/debug"/>
+ </Router>
+ </div>
+ </div>
+ );
+
+ ReactDOM.render(el, document.getElementById("content")!);
+}
+
+interface TabProps {
+ target: string;
+ children?: React.ReactNode;
+}
+
+function Tab(props: TabProps) {
+ let cssClass = "";
+ if (props.target == Router.getRoute()) {
+ cssClass = "active";
+ }
+ let onClick = (e: React.MouseEvent) => {
+ Router.setRoute(props.target);
+ e.preventDefault();
+ };
+ return (
+ <a onClick={onClick} href={props.target} className={cssClass}>
+ {props.children}
+ </a>
+ );
+}
+
+
+class WalletNavBar extends React.Component<any,any> {
+ cancelSubscription: any;
+
+ componentWillMount() {
+ this.cancelSubscription = Router.onRoute(() => {
+ this.setState({});
+ });
+ }
+
+ componentWillUnmount() {
+ if (this.cancelSubscription) {
+ this.cancelSubscription();
+ }
+ }
+
+ render() {
+ console.log("rendering nav bar");
+ return (
+ <div className="nav" id="header">
+ <Tab target="/balance">
+ Balance
+ </Tab>
+ <Tab target="/history">
+ History
+ </Tab>
+ <Tab target="/debug">
+ Debug
+ </Tab>
+ </div>);
+ }
+}
+
+
+function ExtensionLink(props: any) {
+ let onClick = (e: React.MouseEvent) => {
+ chrome.tabs.create({
+ "url": chrome.extension.getURL(props.target)
+ });
+ e.preventDefault();
+ };
+ return (
+ <a onClick={onClick} href={props.target}>
+ {props.children}
+ </a>)
+}
+
+class WalletBalanceView extends React.Component<any, any> {
+ balance: WalletBalance;
+ gotError = false;
+ canceler: (() => void) | undefined = undefined;
+ unmount = false;
+
+ componentWillMount() {
+ this.canceler = onUpdateNotification(() => this.updateBalance());
+ this.updateBalance();
+ }
+
+ componentWillUnmount() {
+ console.log("component WalletBalanceView will unmount");
+ if (this.canceler) {
+ this.canceler();
+ }
+ this.unmount = true;
+ }
+
+ updateBalance() {
+ chrome.runtime.sendMessage({type: "balances"}, (resp) => {
+ if (this.unmount) {
+ return;
+ }
+ if (resp.error) {
+ this.gotError = true;
+ console.error("could not retrieve balances", resp);
+ this.setState({});
+ return;
+ }
+ this.gotError = false;
+ console.log("got wallet", resp);
+ this.balance = resp;
+ this.setState({});
+ });
+ }
+
+ renderEmpty(): JSX.Element {
+ let helpLink = (
+ <ExtensionLink target="pages/help/empty-wallet.html">
+ help
+ </ExtensionLink>
+ );
+ return <div>You have no balance to show. Need some
+ {" "}{helpLink}{" "}
+ getting started?</div>;
+ }
+
+ formatPending(entry: WalletBalanceEntry): JSX.Element {
+ let incoming: JSX.Element | undefined;
+ let payment: JSX.Element | undefined;
+
+ console.log("available: ", entry.pendingIncoming ? prettyAmount(entry.available) : null);
+ console.log("incoming: ", entry.pendingIncoming ? prettyAmount(entry.pendingIncoming) : null);
+
+ if (Amounts.isNonZero(entry.pendingIncoming)) {
+ incoming = (
+ <span>
+ <span style={{color: "darkgreen"}}>
+ {"+"}
+ {prettyAmount(entry.pendingIncoming)}
+ </span>
+ {" "}
+ incoming
+ </span>);
+ }
+
+ if (Amounts.isNonZero(entry.pendingPayment)) {
+ payment = (
+ <span>
+ <span style={{color: "darkblue"}}>
+ {prettyAmount(entry.pendingPayment)}
+ </span>
+ {" "}
+ being spent
+ </span>);
+ }
+
+ let l = [incoming, payment].filter((x) => x !== undefined);
+ if (l.length == 0) {
+ return <span />;
+ }
+
+ if (l.length == 1) {
+ return <span>({l})</span>
+ }
+ return <span>({l[0]}, {l[1]})</span>;
+
+ }
+
+ render(): JSX.Element {
+ let wallet = this.balance;
+ if (this.gotError) {
+ return i18n`Error: could not retrieve balance information.`;
+ }
+ if (!wallet) {
+ return <span></span>;
+ }
+ console.log(wallet);
+ let listing = Object.keys(wallet).map((key) => {
+ let entry: WalletBalanceEntry = wallet[key];
+ return (
+ <p>
+ {prettyAmount(entry.available)}
+ {" "}
+ {this.formatPending(entry)}
+ </p>
+ );
+ });
+ if (listing.length > 0) {
+ return <div>{listing}</div>;
+ }
+
+ return this.renderEmpty();
+ }
+}
+
+
+function formatHistoryItem(historyItem: HistoryRecord) {
+ const d = historyItem.detail;
+ const t = historyItem.timestamp;
+ console.log("hist item", historyItem);
+ switch (historyItem.type) {
+ case "create-reserve":
+ return (
+ <p>
+ {i18n.parts`Bank requested reserve (${abbrev(d.reservePub)}) for ${prettyAmount(
+ d.requestedAmount)}.`}
+ </p>
+ );
+ case "confirm-reserve": {
+ // FIXME: eventually remove compat fix
+ let exchange = d.exchangeBaseUrl ? URI(d.exchangeBaseUrl).host() : "??";
+ let amount = prettyAmount(d.requestedAmount);
+ let pub = abbrev(d.reservePub);
+ return (
+ <p>
+ {i18n.parts`Started to withdraw ${amount} from ${exchange} (${pub}).`}
+ </p>
+ );
+ }
+ case "offer-contract": {
+ let link = chrome.extension.getURL("view-contract.html");
+ let linkElem = <a href={link}>{abbrev(d.contractHash)}</a>;
+ let merchantElem = <em>{abbrev(d.merchantName, 15)}</em>;
+ return (
+ <p>
+ {i18n.parts`Merchant ${merchantElem} offered contract ${linkElem}.`}
+ </p>
+ );
+ }
+ case "depleted-reserve": {
+ let exchange = d.exchangeBaseUrl ? URI(d.exchangeBaseUrl).host() : "??";
+ let amount = prettyAmount(d.requestedAmount);
+ let pub = abbrev(d.reservePub);
+ return (<p>
+ {i18n.parts`Withdrew ${amount} from ${exchange} (${pub}).`}
+ </p>);
+ }
+ case "pay": {
+ let url = substituteFulfillmentUrl(d.fulfillmentUrl,
+ {H_contract: d.contractHash});
+ let merchantElem = <em>{abbrev(d.merchantName, 15)}</em>;
+ let fulfillmentLinkElem = <a href={url} onClick={openTab(url)}>view product</a>;
+ return (
+ <p>
+ {i18n.parts`Paid ${prettyAmount(d.amount)} to merchant ${merchantElem}. (${fulfillmentLinkElem})`}
+ </p>);
+ }
+ default:
+ return (<p>i18n`Unknown event (${historyItem.type})`</p>);
+ }
+}
+
+
+class WalletHistory extends React.Component<any, any> {
+ myHistory: any[];
+ gotError = false;
+ unmounted = false;
+
+ componentWillMount() {
+ this.update();
+ onUpdateNotification(() => this.update());
+ }
+
+ componentWillUnmount() {
+ console.log("history component unmounted");
+ this.unmounted = true;
+ }
+
+ update() {
+ chrome.runtime.sendMessage({type: "get-history"}, (resp) => {
+ if (this.unmounted) {
+ return;
+ }
+ console.log("got history response");
+ if (resp.error) {
+ this.gotError = true;
+ console.error("could not retrieve history", resp);
+ this.setState({});
+ return;
+ }
+ this.gotError = false;
+ console.log("got history", resp.history);
+ this.myHistory = resp.history;
+ this.setState({});
+ });
+ }
+
+ render(): JSX.Element {
+ console.log("rendering history");
+ let history: HistoryRecord[] = this.myHistory;
+ if (this.gotError) {
+ return i18n`Error: could not retrieve event history`;
+ }
+
+ if (!history) {
+ // We're not ready yet
+ return <span />;
+ }
+
+ let subjectMemo: {[s: string]: boolean} = {};
+ let listing: any[] = [];
+ for (let record of history.reverse()) {
+ if (record.subjectId && subjectMemo[record.subjectId]) {
+ continue;
+ }
+ if (record.level != undefined && record.level < HistoryLevel.User) {
+ continue;
+ }
+ subjectMemo[record.subjectId as string] = true;
+
+ let item = (
+ <div className="historyItem">
+ <div className="historyDate">
+ {(new Date(record.timestamp)).toString()}
+ </div>
+ {formatHistoryItem(record)}
+ </div>
+ );
+
+ listing.push(item);
+ }
+
+ if (listing.length > 0) {
+ return <div className="container">{listing}</div>;
+ }
+ return <p>{i18n`Your wallet has no events recorded.`}</p>
+ }
+
+}
+
+
+function reload() {
+ try {
+ chrome.runtime.reload();
+ window.close();
+ } catch (e) {
+ // Functionality missing in firefox, ignore!
+ }
+}
+
+function confirmReset() {
+ if (confirm("Do you want to IRREVOCABLY DESTROY everything inside your" +
+ " wallet and LOSE ALL YOUR COINS?")) {
+ chrome.runtime.sendMessage({type: "reset"});
+ window.close();
+ }
+}
+
+
+function WalletDebug(props: any) {
+ return (<div>
+ <p>Debug tools:</p>
+ <button onClick={openExtensionPage("popup/popup.html")}>
+ wallet tab
+ </button>
+ <button onClick={openExtensionPage("pages/show-db.html")}>
+ show db
+ </button>
+ <button onClick={openExtensionPage("pages/tree.html")}>
+ show tree
+ </button>
+ <br />
+ <button onClick={confirmReset}>
+ reset
+ </button>
+ <button onClick={reload}>
+ reload chrome extension
+ </button>
+ </div>);
+}
+
+
+function openExtensionPage(page: string) {
+ return function() {
+ chrome.tabs.create({
+ "url": chrome.extension.getURL(page)
+ });
+ }
+}
+
+
+function openTab(page: string) {
+ return function() {
+ chrome.tabs.create({
+ "url": page
+ });
+ }
+}
diff --git a/src/query.ts b/src/query.ts
new file mode 100644
index 000000000..08e270ea6
--- /dev/null
+++ b/src/query.ts
@@ -0,0 +1,612 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+
+/**
+ * Database query abstractions.
+ * @module Query
+ * @author Florian Dold
+ */
+
+"use strict";
+
+
+export interface JoinResult<L,R> {
+ left: L;
+ right: R;
+}
+
+
+export class Store<T> {
+ name: string;
+ validator?: (v: T) => T;
+ storeParams: IDBObjectStoreParameters;
+
+ constructor(name: string, storeParams: IDBObjectStoreParameters,
+ validator?: (v: T) => T) {
+ this.name = name;
+ this.validator = validator;
+ this.storeParams = storeParams;
+ }
+}
+
+export class Index<S extends IDBValidKey,T> {
+ indexName: string;
+ storeName: string;
+ keyPath: string | string[];
+
+ constructor(s: Store<T>, indexName: string, keyPath: string | string[]) {
+ this.storeName = s.name;
+ this.indexName = indexName;
+ this.keyPath = keyPath;
+ }
+}
+
+/**
+ * Stream that can be filtered, reduced or joined
+ * with indices.
+ */
+export interface QueryStream<T> {
+ indexJoin<S,I extends IDBValidKey>(index: Index<I,S>,
+ keyFn: (obj: T) => I): QueryStream<JoinResult<T, S>>;
+ keyJoin<S,I extends IDBValidKey>(store: Store<S>,
+ keyFn: (obj: T) => I): QueryStream<JoinResult<T,S>>;
+ filter(f: (T: any) => boolean): QueryStream<T>;
+ reduce<S>(f: (v: T, acc: S) => S, start?: S): Promise<S>;
+ map<S>(f: (x:T) => S): QueryStream<S>;
+ flatMap<S>(f: (x: T) => S[]): QueryStream<S>;
+ toArray(): Promise<T[]>;
+
+ then(onfulfill: any, onreject: any): any;
+}
+
+export let AbortTransaction = Symbol("abort_transaction");
+
+/**
+ * Get an unresolved promise together with its extracted resolve / reject
+ * function.
+ */
+function openPromise<T>() {
+ let resolve: ((value?: T | PromiseLike<T>) => void) | null = null;
+ let reject: ((reason?: any) => void) | null = null;
+ const promise = new Promise<T>((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ if (!(resolve && reject)) {
+ // Never happens, unless JS implementation is broken
+ throw Error();
+ }
+ return {resolve, reject, promise};
+}
+
+
+abstract class QueryStreamBase<T> implements QueryStream<T>, PromiseLike<void> {
+ abstract subscribe(f: (isDone: boolean,
+ value: any,
+ tx: IDBTransaction) => void): void;
+
+ root: QueryRoot;
+
+ constructor(root: QueryRoot) {
+ this.root = root;
+ }
+
+ then<R>(onfulfilled: (value: void) => R | PromiseLike<R>, onrejected: (reason: any) => R | PromiseLike<R>): PromiseLike<R> {
+ return this.root.then(onfulfilled, onrejected);
+ }
+
+ flatMap<S>(f: (x: T) => S[]): QueryStream<S> {
+ return new QueryStreamFlatMap<T,S>(this, f);
+ }
+
+ map<S>(f: (x: T) => S): QueryStream<S> {
+ return new QueryStreamMap(this, f);
+ }
+
+ indexJoin<S,I extends IDBValidKey>(index: Index<I,S>,
+ keyFn: (obj: T) => I): QueryStream<JoinResult<T, S>> {
+ this.root.addStoreAccess(index.storeName, false);
+ return new QueryStreamIndexJoin(this, index.storeName, index.indexName, keyFn);
+ }
+
+ keyJoin<S, I extends IDBValidKey>(store: Store<S>,
+ keyFn: (obj: T) => I): QueryStream<JoinResult<T, S>> {
+ this.root.addStoreAccess(store.name, false);
+ return new QueryStreamKeyJoin(this, store.name, keyFn);
+ }
+
+ filter(f: (x: any) => boolean): QueryStream<T> {
+ return new QueryStreamFilter(this, f);
+ }
+
+ toArray(): Promise<T[]> {
+ let {resolve, promise} = openPromise();
+ let values: T[] = [];
+
+ this.subscribe((isDone, value) => {
+ if (isDone) {
+ resolve(values);
+ return;
+ }
+ values.push(value);
+ });
+
+ return Promise.resolve()
+ .then(() => this.root.finish())
+ .then(() => promise);
+ }
+
+ reduce<A>(f: (x: any, acc?: A) => A, init?: A): Promise<any> {
+ let {resolve, promise} = openPromise();
+ let acc = init;
+
+ this.subscribe((isDone, value) => {
+ if (isDone) {
+ resolve(acc);
+ return;
+ }
+ acc = f(value, acc);
+ });
+
+ return Promise.resolve()
+ .then(() => this.root.finish())
+ .then(() => promise);
+ }
+}
+
+type FilterFn = (e: any) => boolean;
+type SubscribeFn = (done: boolean, value: any, tx: IDBTransaction) => void;
+
+interface FlatMapFn<T> {
+ (v: T): T[];
+}
+
+class QueryStreamFilter<T> extends QueryStreamBase<T> {
+ s: QueryStreamBase<T>;
+ filterFn: FilterFn;
+
+ constructor(s: QueryStreamBase<T>, filterFn: FilterFn) {
+ super(s.root);
+ this.s = s;
+ this.filterFn = filterFn;
+ }
+
+ subscribe(f: SubscribeFn) {
+ this.s.subscribe((isDone, value, tx) => {
+ if (isDone) {
+ f(true, undefined, tx);
+ return;
+ }
+ if (this.filterFn(value)) {
+ f(false, value, tx);
+ }
+ });
+ }
+}
+
+
+class QueryStreamFlatMap<T,S> extends QueryStreamBase<S> {
+ s: QueryStreamBase<T>;
+ flatMapFn: (v: T) => S[];
+
+ constructor(s: QueryStreamBase<T>, flatMapFn: (v: T) => S[]) {
+ super(s.root);
+ this.s = s;
+ this.flatMapFn = flatMapFn;
+ }
+
+ subscribe(f: SubscribeFn) {
+ this.s.subscribe((isDone, value, tx) => {
+ if (isDone) {
+ f(true, undefined, tx);
+ return;
+ }
+ let values = this.flatMapFn(value);
+ for (let v in values) {
+ f(false, value, tx)
+ }
+ });
+ }
+}
+
+
+class QueryStreamMap<S,T> extends QueryStreamBase<T> {
+ s: QueryStreamBase<S>;
+ mapFn: (v: S) => T;
+
+ constructor(s: QueryStreamBase<S>, mapFn: (v: S) => T) {
+ super(s.root);
+ this.s = s;
+ this.mapFn = mapFn;
+ }
+
+ subscribe(f: SubscribeFn) {
+ this.s.subscribe((isDone, value, tx) => {
+ if (isDone) {
+ f(true, undefined, tx);
+ return;
+ }
+ let mappedValue = this.mapFn(value);
+ f(false, mappedValue, tx);
+ });
+ }
+}
+
+
+class QueryStreamIndexJoin<T, S> extends QueryStreamBase<JoinResult<T, S>> {
+ s: QueryStreamBase<T>;
+ storeName: string;
+ key: any;
+ indexName: string;
+
+ constructor(s: QueryStreamBase<T>, storeName: string, indexName: string,
+ key: any) {
+ super(s.root);
+ this.s = s;
+ this.storeName = storeName;
+ this.key = key;
+ this.indexName = indexName;
+ }
+
+ subscribe(f: SubscribeFn) {
+ this.s.subscribe((isDone, value, tx) => {
+ if (isDone) {
+ f(true, undefined, tx);
+ return;
+ }
+ console.log("joining on", this.key(value));
+ let s = tx.objectStore(this.storeName).index(this.indexName);
+ let req = s.openCursor(IDBKeyRange.only(this.key(value)));
+ req.onsuccess = () => {
+ let cursor = req.result;
+ if (cursor) {
+ f(false, {left: value, right: cursor.value}, tx);
+ cursor.continue();
+ } else {
+ f(true, undefined, tx);
+ }
+ }
+ });
+ }
+}
+
+
+class QueryStreamKeyJoin<T, S> extends QueryStreamBase<JoinResult<T, S>> {
+ s: QueryStreamBase<T>;
+ storeName: string;
+ key: any;
+
+ constructor(s: QueryStreamBase<T>, storeName: string,
+ key: any) {
+ super(s.root);
+ this.s = s;
+ this.storeName = storeName;
+ this.key = key;
+ }
+
+ subscribe(f: SubscribeFn) {
+ this.s.subscribe((isDone, value, tx) => {
+ if (isDone) {
+ f(true, undefined, tx);
+ return;
+ }
+ console.log("joining on", this.key(value));
+ let s = tx.objectStore(this.storeName);
+ let req = s.openCursor(IDBKeyRange.only(this.key(value)));
+ req.onsuccess = () => {
+ let cursor = req.result;
+ if (cursor) {
+ f(false, {left:value, right: cursor.value}, tx);
+ cursor.continue();
+ } else {
+ f(true, undefined, tx);
+ }
+ }
+ });
+ }
+}
+
+
+class IterQueryStream<T> extends QueryStreamBase<T> {
+ private storeName: string;
+ private options: any;
+ private subscribers: SubscribeFn[];
+
+ constructor(qr: QueryRoot, storeName: string, options: any) {
+ super(qr);
+ this.options = options;
+ this.storeName = storeName;
+ this.subscribers = [];
+
+ let doIt = (tx: IDBTransaction) => {
+ const {indexName = void 0, only = void 0} = this.options;
+ let s: any;
+ if (indexName !== void 0) {
+ s = tx.objectStore(this.storeName)
+ .index(this.options.indexName);
+ } else {
+ s = tx.objectStore(this.storeName);
+ }
+ let kr: IDBKeyRange | undefined = undefined;
+ if (only !== undefined) {
+ kr = IDBKeyRange.only(this.options.only);
+ }
+ let req = s.openCursor(kr);
+ req.onsuccess = () => {
+ let cursor: IDBCursorWithValue = req.result;
+ if (cursor) {
+ for (let f of this.subscribers) {
+ f(false, cursor.value, tx);
+ }
+ cursor.continue();
+ } else {
+ for (let f of this.subscribers) {
+ f(true, undefined, tx);
+ }
+ }
+ }
+ };
+
+ this.root.addWork(doIt);
+ }
+
+ subscribe(f: SubscribeFn) {
+ this.subscribers.push(f);
+ }
+}
+
+
+export class QueryRoot implements PromiseLike<void> {
+ private work: ((t: IDBTransaction) => void)[] = [];
+ private db: IDBDatabase;
+ private stores = new Set();
+ private kickoffPromise: Promise<void>;
+
+ /**
+ * Some operations is a write operation,
+ * and we need to do a "readwrite" transaction/
+ */
+ private hasWrite: boolean;
+
+ private finishScheduled: boolean;
+
+ constructor(db: IDBDatabase) {
+ this.db = db;
+ }
+
+ then<R>(onfulfilled: (value: void) => R | PromiseLike<R>, onrejected: (reason: any) => R | PromiseLike<R>): PromiseLike<R> {
+ return this.finish().then(onfulfilled, onrejected);
+ }
+
+ iter<T>(store: Store<T>): QueryStream<T> {
+ this.stores.add(store.name);
+ this.scheduleFinish();
+ return new IterQueryStream(this, store.name, {});
+ }
+
+ iterIndex<S extends IDBValidKey,T>(index: Index<S,T>,
+ only?: S): QueryStream<T> {
+ this.stores.add(index.storeName);
+ this.scheduleFinish();
+ return new IterQueryStream(this, index.storeName, {
+ only,
+ indexName: index.indexName
+ });
+ }
+
+ /**
+ * Put an object into the given object store.
+ * Overrides if an existing object with the same key exists
+ * in the store.
+ */
+ put<T>(store: Store<T>, val: T): QueryRoot {
+ let doPut = (tx: IDBTransaction) => {
+ tx.objectStore(store.name).put(val);
+ };
+ this.scheduleFinish();
+ this.addWork(doPut, store.name, true);
+ return this;
+ }
+
+
+ putWithResult<T>(store: Store<T>, val: T): Promise<IDBValidKey> {
+ const {resolve, promise} = openPromise();
+ let doPutWithResult = (tx: IDBTransaction) => {
+ let req = tx.objectStore(store.name).put(val);
+ req.onsuccess = () => {
+ resolve(req.result);
+ }
+ this.scheduleFinish();
+ };
+ this.addWork(doPutWithResult, store.name, true);
+ return Promise.resolve()
+ .then(() => this.finish())
+ .then(() => promise);
+ }
+
+
+ mutate<T>(store: Store<T>, key: any, f: (v: T) => T): QueryRoot {
+ let doPut = (tx: IDBTransaction) => {
+ let reqGet = tx.objectStore(store.name).get(key);
+ reqGet.onsuccess = () => {
+ let r = reqGet.result;
+ let m: T;
+ try {
+ m = f(r);
+ } catch (e) {
+ if (e == AbortTransaction) {
+ tx.abort();
+ return;
+ }
+ throw e;
+ }
+
+ tx.objectStore(store.name).put(m);
+ }
+ };
+ this.scheduleFinish();
+ this.addWork(doPut, store.name, true);
+ return this;
+ }
+
+
+ /**
+ * Add all object from an iterable to the given object store.
+ * Fails if the object's key is already present
+ * in the object store.
+ */
+ putAll<T>(store: Store<T>, iterable: T[]): QueryRoot {
+ const doPutAll = (tx: IDBTransaction) => {
+ for (let obj of iterable) {
+ tx.objectStore(store.name).put(obj);
+ }
+ };
+ this.scheduleFinish();
+ this.addWork(doPutAll, store.name, true);
+ return this;
+ }
+
+ /**
+ * Add an object to the given object store.
+ * Fails if the object's key is already present
+ * in the object store.
+ */
+ add<T>(store: Store<T>, val: T): QueryRoot {
+ const doAdd = (tx: IDBTransaction) => {
+ tx.objectStore(store.name).add(val);
+ };
+ this.scheduleFinish();
+ this.addWork(doAdd, store.name, true);
+ return this;
+ }
+
+ /**
+ * Get one object from a store by its key.
+ */
+ get<T>(store: Store<T>, key: any): Promise<T|undefined> {
+ if (key === void 0) {
+ throw Error("key must not be undefined");
+ }
+
+ const {resolve, promise} = openPromise();
+
+ const doGet = (tx: IDBTransaction) => {
+ const req = tx.objectStore(store.name).get(key);
+ req.onsuccess = () => {
+ resolve(req.result);
+ };
+ };
+
+ this.addWork(doGet, store.name, false);
+ return Promise.resolve()
+ .then(() => this.finish())
+ .then(() => promise);
+ }
+
+ /**
+ * Get one object from a store by its key.
+ */
+ getIndexed<I extends IDBValidKey,T>(index: Index<I,T>,
+ key: I): Promise<T|undefined> {
+ if (key === void 0) {
+ throw Error("key must not be undefined");
+ }
+
+ const {resolve, promise} = openPromise();
+
+ const doGetIndexed = (tx: IDBTransaction) => {
+ const req = tx.objectStore(index.storeName)
+ .index(index.indexName)
+ .get(key);
+ req.onsuccess = () => {
+ resolve(req.result);
+ };
+ };
+
+ this.addWork(doGetIndexed, index.storeName, false);
+ return Promise.resolve()
+ .then(() => this.finish())
+ .then(() => promise);
+ }
+
+ private scheduleFinish() {
+ if (!this.finishScheduled) {
+ Promise.resolve().then(() => this.finish());
+ this.finishScheduled = true;
+ }
+ }
+
+ /**
+ * Finish the query, and start the query in the first place if necessary.
+ */
+ finish(): Promise<void> {
+ if (this.kickoffPromise) {
+ return this.kickoffPromise;
+ }
+ this.kickoffPromise = new Promise<void>((resolve, reject) => {
+ if (this.work.length == 0) {
+ resolve();
+ return;
+ }
+ const mode = this.hasWrite ? "readwrite" : "readonly";
+ const tx = this.db.transaction(Array.from(this.stores), mode);
+ tx.oncomplete = () => {
+ resolve();
+ };
+ tx.onabort = () => {
+ reject(Error("transaction aborted"));
+ };
+ for (let w of this.work) {
+ w(tx);
+ }
+ });
+ return this.kickoffPromise;
+ }
+
+ /**
+ * Delete an object by from the given object store.
+ */
+ delete(storeName: string, key: any): QueryRoot {
+ const doDelete = (tx: IDBTransaction) => {
+ tx.objectStore(storeName).delete(key);
+ };
+ this.scheduleFinish();
+ this.addWork(doDelete, storeName, true);
+ return this;
+ }
+
+ /**
+ * Low-level function to add a task to the internal work queue.
+ */
+ addWork(workFn: (t: IDBTransaction) => void,
+ storeName?: string,
+ isWrite?: boolean) {
+ this.work.push(workFn);
+ if (storeName) {
+ this.addStoreAccess(storeName, isWrite);
+ }
+ }
+
+ addStoreAccess(storeName: string, isWrite?: boolean) {
+ if (storeName) {
+ this.stores.add(storeName);
+ }
+ if (isWrite) {
+ this.hasWrite = true;
+ }
+ }
+}
diff --git a/src/renderHtml.tsx b/src/renderHtml.tsx
new file mode 100644
index 000000000..940d5c425
--- /dev/null
+++ b/src/renderHtml.tsx
@@ -0,0 +1,63 @@
+/*
+ This file is part of TALER
+ (C) 2016 INRIA
+
+ 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
+ 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/>
+ */
+
+/**
+ * Helpers functions to render Taler-related data structures to HTML.
+ *
+ * @author Florian Dold
+ */
+
+
+import {AmountJson, Contract} from "./types";
+
+export function prettyAmount(amount: AmountJson) {
+ let v = amount.value + amount.fraction / 1e6;
+ return `${v.toFixed(2)} ${amount.currency}`;
+}
+
+export function renderContract(contract: Contract): JSX.Element {
+ let merchantName = <strong>{contract.merchant.name}</strong>;
+ let amount = <strong>{prettyAmount(contract.amount)}</strong>;
+
+ return (
+ <div>
+ <p>
+ The merchant {merchantName}
+ wants to enter a contract over {amount}{" "}
+ with you.
+ </p>
+ <p>{i18n`You are about to purchase:`}</p>
+ <ul>
+ {contract.products.map(
+ (p: any, i: number) => (<li key={i}>{`${p.description}: ${prettyAmount(p.price)}`}</li>))
+ }
+ </ul>
+ </div>
+ );
+}
+
+
+export function abbrev(s: string, n: number = 5) {
+ let sAbbrev = s;
+ if (s.length > n) {
+ sAbbrev = s.slice(0, n) + "..";
+ }
+ return (
+ <span className="abbrev" title={s}>
+ {sAbbrev}
+ </span>
+ );
+}
diff --git a/src/style/lang.css b/src/style/lang.css
new file mode 100644
index 000000000..1cf073527
--- /dev/null
+++ b/src/style/lang.css
@@ -0,0 +1,11 @@
+body [lang] {
+ visibility: hidden;
+}
+
+body:lang(en) :lang(en),
+body:lang(de) :lang(de),
+body:lang(fr) :lang(fr),
+body:lang(it) :lang(it),
+body:lang(es) :lang(es) {
+ visibility: visible;
+}
diff --git a/src/style/wallet.css b/src/style/wallet.css
new file mode 100644
index 000000000..e5a8f91b3
--- /dev/null
+++ b/src/style/wallet.css
@@ -0,0 +1,139 @@
+#main {
+ border: solid 1px black;
+ border-radius: 10px;
+ margin: auto;
+ max-width: 50%;
+ padding: 2em;
+}
+
+header {
+ width: 100%;
+ height: 100px;
+ margin: 0;
+ padding: 0;
+ border-bottom: 1px solid black;
+}
+
+header h1 {
+ font-size: 200%;
+ margin: 0;
+ padding: 0 0 0 120px;
+ position: relative;
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+header #logo {
+ float: left;
+ width: 100px;
+ height: 100px;
+ padding: 0;
+ margin: 0;
+ text-align: center;
+ border-right: 1px solid black;
+ background-image: url(/img/logo.png);
+ background-size: 100px;
+}
+
+aside {
+ width: 100px;
+ float: left;
+}
+
+section#main {
+ margin: auto;
+ padding: 20px;
+ border-left: 1px solid black;
+ height: 100%;
+ max-width: 50%;
+}
+
+section#main h1:first-child {
+ margin-top: 0;
+}
+
+h1 {
+ font-size: 160%;
+}
+
+h2 {
+ font-size: 140%;
+}
+
+h3 {
+ font-size: 120%;
+}
+
+h4, h5, h6 {
+ font-size: 100%;
+}
+
+.form-row {
+ padding-top: 5px;
+ padding-bottom: 5px;
+}
+
+label {
+ padding-right: 1em;
+}
+
+label::after {
+ content: ":";
+}
+
+input.url {
+ width: 25em;
+}
+
+.formish {
+}
+
+.json-key {
+ color: brown;
+}
+.json-value {
+ color: navy;
+ }
+.json-string {
+ color: olive;
+}
+
+button {
+ font-size: 120%;
+ padding: 0.5em;
+}
+
+button.confirm-pay {
+ float: right;
+}
+
+/* We use fading to hide slower DOM updates */
+.fade {
+ -webkit-animation: fade 0.7s;
+ animation: fade 0.7s;
+ opacity: 1;
+}
+
+@-webkit-keyframes fade {
+ from {opacity: 0}
+ to {opacity: 1}
+}
+@keyframes fade {
+ from {opacity: 0}
+ to {opacity: 1}
+ }
+
+button.linky {
+ background:none!important;
+ border:none;
+ padding:0!important;
+
+ font-family:arial,sans-serif;
+ color:#069;
+ text-decoration:underline;
+ cursor:pointer;
+}
+
+table, th, td {
+ border: 1px solid black;
+}
diff --git a/src/taler-wallet-lib.ts b/src/taler-wallet-lib.ts
new file mode 120000
index 000000000..20e599359
--- /dev/null
+++ b/src/taler-wallet-lib.ts
@@ -0,0 +1 @@
+../web-common/taler-wallet-lib.ts \ No newline at end of file
diff --git a/src/types-test.ts b/src/types-test.ts
new file mode 100644
index 000000000..3ebb1a5db
--- /dev/null
+++ b/src/types-test.ts
@@ -0,0 +1,38 @@
+import {test, TestLib} from "testlib/talertest";
+import {Amounts} from "./types";
+import * as types from "./types";
+
+let amt = (value: number, fraction: number, currency: string): types.AmountJson => ({value, fraction, currency});
+
+test("amount addition (simple)", (t: TestLib) => {
+ let a1 = amt(1,0,"EUR");
+ let a2 = amt(1,0,"EUR");
+ let a3 = amt(2,0,"EUR");
+ t.assert(0 == types.Amounts.cmp(Amounts.add(a1, a2).amount, a3));
+ t.pass();
+});
+
+test("amount addition (saturation)", (t: TestLib) => {
+ let a1 = amt(1,0,"EUR");
+ let res = Amounts.add(Amounts.getMaxAmount("EUR"), a1);
+ t.assert(res.saturated);
+ t.pass();
+});
+
+test("amount subtraction (simple)", (t: TestLib) => {
+ let a1 = amt(2,5,"EUR");
+ let a2 = amt(1,0,"EUR");
+ let a3 = amt(1,5,"EUR");
+ t.assert(0 == types.Amounts.cmp(Amounts.sub(a1, a2).amount, a3));
+ t.pass();
+});
+
+test("amount subtraction (saturation)", (t: TestLib) => {
+ let a1 = amt(0,0,"EUR");
+ let a2 = amt(1,0,"EUR");
+ let res = Amounts.sub(a1, a2);
+ t.assert(res.saturated);
+ res = Amounts.sub(a1, a1);
+ t.assert(!res.saturated);
+ t.pass();
+});
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 000000000..39d374069
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,554 @@
+/*
+ This file is part of TALER
+ (C) 2015 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+/**
+ * Common types that are used by Taler.
+ *
+ * Note most types are defined in wallet.ts, types that
+ * are defined in types.ts are intended to be used by components
+ * that do not depend on the whole wallet implementation (which depends on
+ * emscripten).
+ *
+ * @author Florian Dold
+ */
+
+import { Checkable } from "./checkable";
+
+@Checkable.Class
+export class AmountJson {
+ @Checkable.Number
+ value: number;
+
+ @Checkable.Number
+ fraction: number;
+
+ @Checkable.String
+ currency: string;
+
+ static checked: (obj: any) => AmountJson;
+}
+
+
+export interface SignedAmountJson {
+ amount: AmountJson;
+ isNegative: boolean;
+}
+
+
+export interface ReserveRecord {
+ reserve_pub: string;
+ reserve_priv: string,
+ exchange_base_url: string,
+ created: number,
+ last_query: number | null,
+ /**
+ * Current amount left in the reserve
+ */
+ current_amount: AmountJson | null,
+ /**
+ * Amount requested when the reserve was created.
+ * When a reserve is re-used (rare!) the current_amount can
+ * be higher than the requested_amount
+ */
+ requested_amount: AmountJson,
+
+
+ /**
+ * What's the current amount that sits
+ * in precoins?
+ */
+ precoin_amount: AmountJson;
+
+
+ confirmed: boolean,
+}
+
+
+@Checkable.Class
+export class CreateReserveResponse {
+ /**
+ * Exchange URL where the bank should create the reserve.
+ * The URL is canonicalized in the response.
+ */
+ @Checkable.String
+ exchange: string;
+
+ @Checkable.String
+ reservePub: string;
+
+ static checked: (obj: any) => CreateReserveResponse;
+}
+
+
+@Checkable.Class
+export class Denomination {
+ @Checkable.Value(AmountJson)
+ value: AmountJson;
+
+ @Checkable.String
+ denom_pub: string;
+
+ @Checkable.Value(AmountJson)
+ fee_withdraw: AmountJson;
+
+ @Checkable.Value(AmountJson)
+ fee_deposit: AmountJson;
+
+ @Checkable.Value(AmountJson)
+ fee_refresh: AmountJson;
+
+ @Checkable.Value(AmountJson)
+ fee_refund: AmountJson;
+
+ @Checkable.String
+ stamp_start: string;
+
+ @Checkable.String
+ stamp_expire_withdraw: string;
+
+ @Checkable.String
+ stamp_expire_legal: string;
+
+ @Checkable.String
+ stamp_expire_deposit: string;
+
+ @Checkable.String
+ master_sig: string;
+
+ static checked: (obj: any) => Denomination;
+}
+
+
+export interface IExchangeInfo {
+ baseUrl: string;
+ masterPublicKey: string;
+
+ /**
+ * All denominations we ever received from the exchange.
+ * Expired denominations may be garbage collected.
+ */
+ all_denoms: Denomination[];
+
+ /**
+ * Denominations we received with the last update.
+ * Subset of "denoms".
+ */
+ active_denoms: Denomination[];
+
+ /**
+ * Timestamp for last update.
+ */
+ last_update_time: number;
+}
+
+export interface WireInfo {
+ [type: string]: any;
+}
+
+export interface ReserveCreationInfo {
+ exchangeInfo: IExchangeInfo;
+ wireInfo: WireInfo;
+ selectedDenoms: Denomination[];
+ withdrawFee: AmountJson;
+ overhead: AmountJson;
+}
+
+
+/**
+ * A coin that isn't yet signed by an exchange.
+ */
+export interface PreCoin {
+ coinPub: string;
+ coinPriv: string;
+ reservePub: string;
+ denomPub: string;
+ blindingKey: string;
+ withdrawSig: string;
+ coinEv: string;
+ exchangeBaseUrl: string;
+ coinValue: AmountJson;
+}
+
+export interface RefreshPreCoin {
+ publicKey: string;
+ privateKey: string;
+ coinEv: string;
+ blindingKey: string
+}
+
+
+/**
+ * Ongoing refresh
+ */
+export interface RefreshSession {
+ /**
+ * Public key that's being melted in this session.
+ */
+ meltCoinPub: string;
+
+ /**
+ * How much of the coin's value is melted away
+ * with this refresh session?
+ */
+ valueWithFee: AmountJson
+
+ /**
+ * Sum of the value of denominations we want
+ * to withdraw in this session, without fees.
+ */
+ valueOutput: AmountJson;
+
+ /**
+ * Signature to confirm the melting.
+ */
+ confirmSig: string;
+
+ /**
+ * Denominations of the newly requested coins
+ */
+ newDenoms: string[];
+
+
+ preCoinsForGammas: RefreshPreCoin[][];
+
+
+ /**
+ * The transfer keys, kappa of them.
+ */
+ transferPubs: string[];
+
+ transferPrivs: string[];
+
+ /**
+ * The no-reveal-index after we've done the melting.
+ */
+ norevealIndex?: number;
+
+ /**
+ * Hash of the session.
+ */
+ hash: string;
+
+ exchangeBaseUrl: string;
+
+ finished: boolean;
+}
+
+
+export interface CoinPaySig {
+ coin_sig: string;
+ coin_pub: string;
+ ub_sig: string;
+ denom_pub: string;
+ f: AmountJson;
+}
+
+/**
+ * Coin as stored in the "coins" data store
+ * of the wallet database.
+ */
+export interface Coin {
+ /**
+ * Public key of the coin.
+ */
+ coinPub: string;
+
+ /**
+ * Private key to authorize operations on the coin.
+ */
+ coinPriv: string;
+
+ /**
+ * Key used by the exchange used to sign the coin.
+ */
+ denomPub: string;
+
+ /**
+ * Unblinded signature by the exchange.
+ */
+ denomSig: string;
+
+ /**
+ * Amount that's left on the coin.
+ */
+ currentAmount: AmountJson;
+
+ /**
+ * Base URL that identifies the exchange from which we got the
+ * coin.
+ */
+ exchangeBaseUrl: string;
+
+ /**
+ * We have withdrawn the coin, but it's not accepted by the exchange anymore.
+ * We have to tell an auditor and wait for compensation or for the exchange
+ * to fix it.
+ */
+ suspended?: boolean;
+
+ /**
+ * Was the coin revealed in a transaction?
+ */
+ dirty: boolean;
+
+ /**
+ * Is the coin currently involved in a transaction?
+ *
+ * This delays refreshing until the transaction is finished or
+ * aborted.
+ */
+ transactionPending: boolean;
+}
+
+
+@Checkable.Class
+export class ExchangeHandle {
+ @Checkable.String
+ master_pub: string;
+
+ @Checkable.String
+ url: string;
+
+ static checked: (obj: any) => ExchangeHandle;
+}
+
+export interface WalletBalance {
+ [currency: string]: WalletBalanceEntry;
+}
+
+export interface WalletBalanceEntry {
+ available: AmountJson;
+ pendingIncoming: AmountJson;
+ pendingPayment: AmountJson;
+}
+
+
+interface Merchant {
+ /**
+ * label for a location with the business address of the merchant
+ */
+ address: string;
+
+ /**
+ * the merchant's legal name of business
+ */
+ name: string;
+
+ /**
+ * label for a location that denotes the jurisdiction for disputes.
+ * Some of the typical fields for a location (such as a street address) may be absent.
+ */
+ jurisdiction: string;
+
+ /**
+ * Instance of the merchant, in case one merchant
+ * represents multiple receivers.
+ */
+ instance?: string;
+}
+
+@Checkable.Class
+export class Contract {
+ @Checkable.String
+ H_wire: string;
+
+ @Checkable.String
+ summary: string;
+
+ @Checkable.Value(AmountJson)
+ amount: AmountJson;
+
+ @Checkable.List(Checkable.AnyObject)
+ auditors: any[];
+
+ /**
+ * DEPRECATED alias for pay_deadline.
+ */
+ @Checkable.Optional(Checkable.String)
+ expiry: string;
+
+ @Checkable.Optional(Checkable.String)
+ pay_deadline: string;
+
+ @Checkable.Any
+ locations: any;
+
+ @Checkable.Value(AmountJson)
+ max_fee: AmountJson;
+
+ @Checkable.Any
+ merchant: any;
+
+ @Checkable.String
+ merchant_pub: string;
+
+ @Checkable.List(Checkable.Value(ExchangeHandle))
+ exchanges: ExchangeHandle[];
+
+ @Checkable.List(Checkable.AnyObject)
+ products: any[];
+
+ @Checkable.String
+ refund_deadline: string;
+
+ @Checkable.String
+ timestamp: string;
+
+ @Checkable.Number
+ transaction_id: number;
+
+ @Checkable.String
+ fulfillment_url: string;
+
+ @Checkable.Optional(Checkable.String)
+ repurchase_correlation_id: string;
+
+ /**
+ * DEPRECATED alias for instance
+ */
+ @Checkable.Optional(Checkable.String)
+ receiver: string;
+
+ @Checkable.Optional(Checkable.String)
+ instance: string;
+
+ static checked: (obj: any) => Contract;
+}
+
+
+export type PayCoinInfo = Array<{ updatedCoin: Coin, sig: CoinPaySig }>;
+
+
+export namespace Amounts {
+ export interface Result {
+ amount: AmountJson;
+ // Was there an over-/underflow?
+ saturated: boolean;
+ }
+
+ export function getMaxAmount(currency: string): AmountJson {
+ return {
+ currency,
+ value: Number.MAX_SAFE_INTEGER,
+ fraction: 2 ** 32,
+ }
+ }
+
+ export function getZero(currency: string): AmountJson {
+ return {
+ currency,
+ value: 0,
+ fraction: 0,
+ }
+ }
+
+ export function add(first: AmountJson, ...rest: AmountJson[]): Result {
+ let currency = first.currency;
+ let value = first.value + Math.floor(first.fraction / 1e6);
+ if (value > Number.MAX_SAFE_INTEGER) {
+ return { amount: getMaxAmount(currency), saturated: true };
+ }
+ let fraction = first.fraction % 1e6;
+ for (let x of rest) {
+ if (x.currency !== currency) {
+ throw Error(`Mismatched currency: ${x.currency} and ${currency}`);
+ }
+
+ value = value + x.value + Math.floor((fraction + x.fraction) / 1e6);
+ fraction = (fraction + x.fraction) % 1e6;
+ if (value > Number.MAX_SAFE_INTEGER) {
+ return { amount: getMaxAmount(currency), saturated: true };
+ }
+ }
+ return { amount: { currency, value, fraction }, saturated: false };
+ }
+
+
+ export function sub(a: AmountJson, ...rest: AmountJson[]): Result {
+ let currency = a.currency;
+ let value = a.value;
+ let fraction = a.fraction;
+
+ for (let b of rest) {
+ if (b.currency !== currency) {
+ throw Error(`Mismatched currency: ${b.currency} and ${currency}`);
+ }
+ if (fraction < b.fraction) {
+ if (value < 1) {
+ return { amount: { currency, value: 0, fraction: 0 }, saturated: true };
+ }
+ value--;
+ fraction += 1e6;
+ }
+ console.assert(fraction >= b.fraction);
+ fraction -= b.fraction;
+ if (value < b.value) {
+ return { amount: { currency, value: 0, fraction: 0 }, saturated: true };
+ }
+ value -= b.value;
+ }
+
+ return { amount: { currency, value, fraction }, saturated: false };
+ }
+
+ export function cmp(a: AmountJson, b: AmountJson): number {
+ if (a.currency !== b.currency) {
+ throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`);
+ }
+ let av = a.value + Math.floor(a.fraction / 1e6);
+ let af = a.fraction % 1e6;
+ let bv = b.value + Math.floor(b.fraction / 1e6);
+ let bf = b.fraction % 1e6;
+ switch (true) {
+ case av < bv:
+ return -1;
+ case av > bv:
+ return 1;
+ case af < bf:
+ return -1;
+ case af > bf:
+ return 1;
+ case af == bf:
+ return 0;
+ default:
+ throw Error("assertion failed");
+ }
+ }
+
+ export function copy(a: AmountJson): AmountJson {
+ return {
+ value: a.value,
+ fraction: a.fraction,
+ currency: a.currency,
+ }
+ }
+
+ export function isNonZero(a: AmountJson) {
+ return a.value > 0 || a.fraction > 0;
+ }
+}
+
+
+export interface CheckRepurchaseResult {
+ isRepurchase: boolean;
+ existingContractHash?: string;
+ existingFulfillmentUrl?: string;
+}
+
+
+export interface Notifier {
+ notify(): void;
+}
diff --git a/src/wallet.ts b/src/wallet.ts
new file mode 100644
index 000000000..9fb6e5a27
--- /dev/null
+++ b/src/wallet.ts
@@ -0,0 +1,1657 @@
+/*
+ This file is part of TALER
+ (C) 2015 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+/**
+ * High-level wallet operations that should be indepentent from the underlying
+ * browser extension interface.
+ * @module Wallet
+ * @author Florian Dold
+ */
+
+import {
+ AmountJson,
+ Amounts,
+ CheckRepurchaseResult,
+ Coin,
+ CoinPaySig,
+ Contract,
+ CreateReserveResponse,
+ Denomination,
+ ExchangeHandle,
+ IExchangeInfo,
+ Notifier,
+ PayCoinInfo,
+ PreCoin,
+ RefreshSession,
+ ReserveCreationInfo,
+ ReserveRecord,
+ WalletBalance,
+ WalletBalanceEntry,
+ WireInfo,
+} from "./types";
+import {
+ HttpRequestLibrary,
+ HttpResponse,
+ RequestException,
+} from "./http";
+import {
+ AbortTransaction,
+ Index,
+ JoinResult,
+ QueryRoot,
+ Store,
+} from "./query";
+import {Checkable} from "./checkable";
+import {
+ amountToPretty,
+ canonicalizeBaseUrl,
+ canonicalJson,
+ deepEquals,
+ flatMap,
+ getTalerStampSec,
+} from "./helpers";
+import {CryptoApi} from "./cryptoApi";
+
+"use strict";
+
+export interface CoinWithDenom {
+ coin: Coin;
+ denom: Denomination;
+}
+
+
+@Checkable.Class
+export class KeysJson {
+ @Checkable.List(Checkable.Value(Denomination))
+ denoms: Denomination[];
+
+ @Checkable.String
+ master_public_key: string;
+
+ @Checkable.Any
+ auditors: any[];
+
+ @Checkable.String
+ list_issue_date: string;
+
+ @Checkable.Any
+ signkeys: any;
+
+ @Checkable.String
+ eddsa_pub: string;
+
+ @Checkable.String
+ eddsa_sig: string;
+
+ static checked: (obj: any) => KeysJson;
+}
+
+
+@Checkable.Class
+export class CreateReserveRequest {
+ /**
+ * The initial amount for the reserve.
+ */
+ @Checkable.Value(AmountJson)
+ amount: AmountJson;
+
+ /**
+ * Exchange URL where the bank should create the reserve.
+ */
+ @Checkable.String
+ exchange: string;
+
+ static checked: (obj: any) => CreateReserveRequest;
+}
+
+
+@Checkable.Class
+export class ConfirmReserveRequest {
+ /**
+ * Public key of then reserve that should be marked
+ * as confirmed.
+ */
+ @Checkable.String
+ reservePub: string;
+
+ static checked: (obj: any) => ConfirmReserveRequest;
+}
+
+
+@Checkable.Class
+export class Offer {
+ @Checkable.Value(Contract)
+ contract: Contract;
+
+ @Checkable.String
+ merchant_sig: string;
+
+ @Checkable.String
+ H_contract: string;
+
+ @Checkable.Number
+ offer_time: number;
+
+ /**
+ * Serial ID when the offer is stored in the wallet DB.
+ */
+ @Checkable.Optional(Checkable.Number)
+ id?: number;
+
+ static checked: (obj: any) => Offer;
+}
+
+export interface HistoryRecord {
+ type: string;
+ timestamp: number;
+ subjectId?: string;
+ detail: any;
+ level: HistoryLevel;
+}
+
+
+interface ExchangeCoins {
+ [exchangeUrl: string]: CoinWithDenom[];
+}
+
+interface PayReq {
+ amount: AmountJson;
+ coins: CoinPaySig[];
+ H_contract: string;
+ max_fee: AmountJson;
+ merchant_sig: string;
+ exchange: string;
+ refund_deadline: string;
+ timestamp: string;
+ transaction_id: number;
+ pay_deadline: string;
+ /**
+ * Merchant instance identifier that should receive the
+ * payment, if applicable.
+ */
+ instance?: string;
+}
+
+interface Transaction {
+ contractHash: string;
+ contract: Contract;
+ payReq: PayReq;
+ merchantSig: string;
+
+ /**
+ * The transaction isn't active anymore, it's either successfully paid
+ * or refunded/aborted.
+ */
+ finished: boolean;
+}
+
+export enum HistoryLevel {
+ Trace = 1,
+ Developer = 2,
+ Expert = 3,
+ User = 4,
+}
+
+
+export interface Badge {
+ setText(s: string): void;
+ setColor(c: string): void;
+ startBusy(): void;
+ stopBusy(): void;
+}
+
+
+function setTimeout(f: any, t: number) {
+ return chrome.extension.getBackgroundPage().setTimeout(f, t);
+}
+
+
+function isWithdrawableDenom(d: Denomination) {
+ const now_sec = (new Date).getTime() / 1000;
+ const stamp_withdraw_sec = getTalerStampSec(d.stamp_expire_withdraw);
+ const stamp_start_sec = getTalerStampSec(d.stamp_start);
+ // Withdraw if still possible to withdraw within a minute
+ if ((stamp_withdraw_sec + 60 > now_sec) && (now_sec >= stamp_start_sec)) {
+ return true;
+ }
+ return false;
+}
+
+
+/**
+ * Result of updating exisiting information
+ * about an exchange with a new '/keys' response.
+ */
+interface KeyUpdateInfo {
+ updatedExchangeInfo: IExchangeInfo;
+ addedDenominations: Denomination[];
+ removedDenominations: Denomination[];
+}
+
+
+/**
+ * Get a list of denominations (with repetitions possible)
+ * whose total value is as close as possible to the available
+ * amount, but never larger.
+ */
+function getWithdrawDenomList(amountAvailable: AmountJson,
+ denoms: Denomination[]): Denomination[] {
+ let remaining = Amounts.copy(amountAvailable);
+ const ds: Denomination[] = [];
+
+ denoms = denoms.filter(isWithdrawableDenom);
+ denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
+
+ // This is an arbitrary number of coins
+ // we can withdraw in one go. It's not clear if this limit
+ // is useful ...
+ for (let i = 0; i < 1000; i++) {
+ let found = false;
+ for (let d of denoms) {
+ let cost = Amounts.add(d.value, d.fee_withdraw).amount;
+ if (Amounts.cmp(remaining, cost) < 0) {
+ continue;
+ }
+ found = true;
+ remaining = Amounts.sub(remaining, cost).amount;
+ ds.push(d);
+ break;
+ }
+ if (!found) {
+ break;
+ }
+ }
+ return ds;
+}
+
+
+export namespace Stores {
+ class ExchangeStore extends Store<IExchangeInfo> {
+ constructor() {
+ super("exchanges", {keyPath: "baseUrl"});
+ }
+
+ pubKeyIndex = new Index<string,IExchangeInfo>(this, "pubKey", "masterPublicKey");
+ }
+
+ class CoinsStore extends Store<Coin> {
+ constructor() {
+ super("coins", {keyPath: "coinPub"});
+ }
+
+ exchangeBaseUrlIndex = new Index<string,Coin>(this, "exchangeBaseUrl", "exchangeBaseUrl");
+ }
+
+ class HistoryStore extends Store<HistoryRecord> {
+ constructor() {
+ super("history", {
+ keyPath: "id",
+ autoIncrement: true
+ });
+ }
+
+ timestampIndex = new Index<number,HistoryRecord>(this, "timestamp", "timestamp");
+ }
+
+ class OffersStore extends Store<Offer> {
+ constructor() {
+ super("offers", {
+ keyPath: "id",
+ autoIncrement: true
+ });
+ }
+ }
+
+ class TransactionsStore extends Store<Transaction> {
+ constructor() {
+ super("transactions", {keyPath: "contractHash"});
+ }
+
+ repurchaseIndex = new Index<[string,string],Transaction>(this, "repurchase", [
+ "contract.merchant_pub",
+ "contract.repurchase_correlation_id"
+ ]);
+ }
+
+ export let exchanges: ExchangeStore = new ExchangeStore();
+ export let transactions: TransactionsStore = new TransactionsStore();
+ export let reserves: Store<ReserveRecord> = new Store<ReserveRecord>("reserves", {keyPath: "reserve_pub"});
+ export let coins: CoinsStore = new CoinsStore();
+ export let refresh: Store<RefreshSession> = new Store<RefreshSession>("refresh", {keyPath: "meltCoinPub"});
+ export let history: HistoryStore = new HistoryStore();
+ export let offers: OffersStore = new OffersStore();
+ export let precoins: Store<PreCoin> = new Store<PreCoin>("precoins", {keyPath: "coinPub"});
+}
+
+
+export class Wallet {
+ private db: IDBDatabase;
+ private http: HttpRequestLibrary;
+ private badge: Badge;
+ private notifier: Notifier;
+ public cryptoApi: CryptoApi;
+
+ /**
+ * Set of identifiers for running operations.
+ */
+ private runningOperations: Set<string> = new Set();
+
+ q(): QueryRoot {
+ return new QueryRoot(this.db);
+ }
+
+ constructor(db: IDBDatabase,
+ http: HttpRequestLibrary,
+ badge: Badge,
+ notifier: Notifier) {
+ this.db = db;
+ this.http = http;
+ this.badge = badge;
+ this.notifier = notifier;
+ this.cryptoApi = new CryptoApi();
+
+ this.resumePendingFromDb();
+ }
+
+
+ private startOperation(operationId: string) {
+ this.runningOperations.add(operationId);
+ this.badge.startBusy();
+ }
+
+ private stopOperation(operationId: string) {
+ this.runningOperations.delete(operationId);
+ if (this.runningOperations.size == 0) {
+ this.badge.stopBusy();
+ }
+ }
+
+ async updateExchanges(): Promise<void> {
+ console.log("updating exchanges");
+
+ let exchangesUrls = await this.q()
+ .iter(Stores.exchanges)
+ .map((e) => e.baseUrl)
+ .toArray();
+
+ for (let url of exchangesUrls) {
+ this.updateExchangeFromUrl(url)
+ .catch((e) => {
+ console.error("updating exchange failed", e);
+ });
+ }
+ }
+
+ /**
+ * Resume various pending operations that are pending
+ * by looking at the database.
+ */
+ private resumePendingFromDb(): void {
+ console.log("resuming pending operations from db");
+
+ this.q()
+ .iter(Stores.reserves)
+ .reduce((reserve) => {
+ console.log("resuming reserve", reserve.reserve_pub);
+ this.processReserve(reserve);
+ });
+
+ this.q()
+ .iter(Stores.precoins)
+ .reduce((preCoin) => {
+ console.log("resuming precoin");
+ this.processPreCoin(preCoin);
+ });
+
+ this.q()
+ .iter(Stores.refresh)
+ .reduce((r: RefreshSession) => {
+ this.continueRefreshSession(r);
+ });
+
+ // FIXME: optimize via index
+ this.q()
+ .iter(Stores.coins)
+ .reduce((c: Coin) => {
+ if (c.dirty && !c.transactionPending) {
+ this.refresh(c.coinPub);
+ }
+ });
+ }
+
+
+ /**
+ * Get exchanges and associated coins that are still spendable,
+ * but only if the sum the coins' remaining value exceeds the payment amount.
+ */
+ private async getPossibleExchangeCoins(paymentAmount: AmountJson,
+ depositFeeLimit: AmountJson,
+ allowedExchanges: ExchangeHandle[]): Promise<ExchangeCoins> {
+ // Mapping from exchange base URL to list of coins together with their
+ // denomination
+ let m: ExchangeCoins = {};
+
+ let x: number;
+
+ function storeExchangeCoin(mc: JoinResult<IExchangeInfo, Coin>,
+ url: string) {
+ let exchange: IExchangeInfo = mc.left;
+ console.log("got coin for exchange", url);
+ let coin: Coin = mc.right;
+ if (coin.suspended) {
+ console.log("skipping suspended coin",
+ coin.denomPub,
+ "from exchange",
+ exchange.baseUrl);
+ return;
+ }
+ let denom = exchange.active_denoms.find((e) => e.denom_pub === coin.denomPub);
+ if (!denom) {
+ console.warn("denom not found (database inconsistent)");
+ return;
+ }
+ if (denom.value.currency !== paymentAmount.currency) {
+ console.warn("same pubkey for different currencies");
+ return;
+ }
+ let cd = {coin, denom};
+ let x = m[url];
+ if (!x) {
+ m[url] = [cd];
+ } else {
+ x.push(cd);
+ }
+ }
+
+ // Make sure that we don't look up coins
+ // for the same URL twice ...
+ let handledExchanges = new Set();
+
+ let ps = flatMap(allowedExchanges, (info: ExchangeHandle) => {
+ if (handledExchanges.has(info.url)) {
+ return [];
+ }
+ handledExchanges.add(info.url);
+ console.log("Checking for merchant's exchange", JSON.stringify(info));
+ return [
+ this.q()
+ .iterIndex(Stores.exchanges.pubKeyIndex, info.master_pub)
+ .indexJoin(Stores.coins.exchangeBaseUrlIndex,
+ (exchange) => exchange.baseUrl)
+ .reduce((x) => storeExchangeCoin(x, info.url))
+ ];
+ });
+
+ await Promise.all(ps);
+
+ let ret: ExchangeCoins = {};
+
+ if (Object.keys(m).length == 0) {
+ console.log("not suitable exchanges found");
+ }
+
+ console.log("exchange coins:");
+ console.dir(m);
+
+ // We try to find the first exchange where we have
+ // enough coins to cover the paymentAmount with fees
+ // under depositFeeLimit
+
+ nextExchange:
+ for (let key in m) {
+ let coins = m[key];
+ // Sort by ascending deposit fee
+ coins.sort((o1, o2) => Amounts.cmp(o1.denom.fee_deposit,
+ o2.denom.fee_deposit));
+ let maxFee = Amounts.copy(depositFeeLimit);
+ let minAmount = Amounts.copy(paymentAmount);
+ let accFee = Amounts.copy(coins[0].denom.fee_deposit);
+ let accAmount = Amounts.getZero(coins[0].coin.currentAmount.currency);
+ let usableCoins: CoinWithDenom[] = [];
+ nextCoin:
+ for (let i = 0; i < coins.length; i++) {
+ let coinAmount = Amounts.copy(coins[i].coin.currentAmount);
+ let coinFee = coins[i].denom.fee_deposit;
+ if (Amounts.cmp(coinAmount, coinFee) <= 0) {
+ continue nextCoin;
+ }
+ accFee = Amounts.add(accFee, coinFee).amount;
+ accAmount = Amounts.add(accAmount, coinAmount).amount;
+ if (Amounts.cmp(accFee, maxFee) >= 0) {
+ // FIXME: if the fees are too high, we have
+ // to cover them ourselves ....
+ console.log("too much fees");
+ continue nextExchange;
+ }
+ usableCoins.push(coins[i]);
+ if (Amounts.cmp(accAmount, minAmount) >= 0) {
+ ret[key] = usableCoins;
+ continue nextExchange;
+ }
+ }
+ }
+ return ret;
+ }
+
+
+ /**
+ * Record all information that is necessary to
+ * pay for a contract in the wallet's database.
+ */
+ private async recordConfirmPay(offer: Offer,
+ payCoinInfo: PayCoinInfo,
+ chosenExchange: string): Promise<void> {
+ let payReq: PayReq = {
+ amount: offer.contract.amount,
+ coins: payCoinInfo.map((x) => x.sig),
+ H_contract: offer.H_contract,
+ max_fee: offer.contract.max_fee,
+ merchant_sig: offer.merchant_sig,
+ exchange: URI(chosenExchange).href(),
+ refund_deadline: offer.contract.refund_deadline,
+ pay_deadline: offer.contract.pay_deadline,
+ timestamp: offer.contract.timestamp,
+ transaction_id: offer.contract.transaction_id,
+ instance: offer.contract.merchant.instance
+ };
+ let t: Transaction = {
+ contractHash: offer.H_contract,
+ contract: offer.contract,
+ payReq: payReq,
+ merchantSig: offer.merchant_sig,
+ finished: false,
+ };
+
+ let historyEntry: HistoryRecord = {
+ type: "pay",
+ timestamp: (new Date).getTime(),
+ subjectId: `contract-${offer.H_contract}`,
+ detail: {
+ merchantName: offer.contract.merchant.name,
+ amount: offer.contract.amount,
+ contractHash: offer.H_contract,
+ fulfillmentUrl: offer.contract.fulfillment_url,
+ },
+ level: HistoryLevel.User
+ };
+
+ await this.q()
+ .put(Stores.transactions, t)
+ .put(Stores.history, historyEntry)
+ .putAll(Stores.coins, payCoinInfo.map((pci) => pci.updatedCoin))
+ .finish();
+
+ this.notifier.notify();
+ }
+
+
+ async putHistory(historyEntry: HistoryRecord): Promise<void> {
+ await this.q().put(Stores.history, historyEntry).finish();
+ this.notifier.notify();
+ }
+
+
+ async saveOffer(offer: Offer): Promise<number> {
+ console.log(`saving offer in wallet.ts`);
+ let id = await this.q().putWithResult(Stores.offers, offer);
+ this.notifier.notify();
+ console.log(`saved offer with id ${id}`);
+ if (typeof id !== "number") {
+ throw Error("db schema wrong");
+ }
+ return id;
+ }
+
+
+ /**
+ * Add a contract to the wallet and sign coins,
+ * but do not send them yet.
+ */
+ async confirmPay(offer: Offer): Promise<any> {
+ console.log("executing confirmPay");
+
+ let transaction = await this.q().get(Stores.transactions, offer.H_contract);
+
+ if (transaction) {
+ // Already payed ...
+ return {};
+ }
+
+ let mcs = await this.getPossibleExchangeCoins(offer.contract.amount,
+ offer.contract.max_fee,
+ offer.contract.exchanges);
+
+ if (Object.keys(mcs).length == 0) {
+ console.log("not confirming payment, insufficient coins");
+ return {
+ error: "coins-insufficient",
+ };
+ }
+ let exchangeUrl = Object.keys(mcs)[0];
+
+ let ds = await this.cryptoApi.signDeposit(offer, mcs[exchangeUrl]);
+ await this.recordConfirmPay(offer,
+ ds,
+ exchangeUrl);
+ return {};
+ }
+
+
+ /**
+ * Add a contract to the wallet and sign coins,
+ * but do not send them yet.
+ */
+ async checkPay(offer: Offer): Promise<any> {
+ // First check if we already payed for it.
+ let transaction = await this.q().get(Stores.transactions, offer.H_contract);
+ if (transaction) {
+ return {isPayed: true};
+ }
+
+ // If not already payed, check if we could pay for it.
+ let mcs = await this.getPossibleExchangeCoins(offer.contract.amount,
+ offer.contract.max_fee,
+ offer.contract.exchanges);
+
+ if (Object.keys(mcs).length == 0) {
+ console.log("not confirming payment, insufficient coins");
+ return {
+ error: "coins-insufficient",
+ };
+ }
+ return {isPayed: false};
+ }
+
+
+ /**
+ * Retrieve all necessary information for looking up the contract
+ * with the given hash.
+ */
+ async executePayment(H_contract: string): Promise<any> {
+ let t = await this.q().get<Transaction>(Stores.transactions, H_contract);
+ if (!t) {
+ return {
+ success: false,
+ contractFound: false,
+ }
+ }
+ let resp = {
+ success: true,
+ payReq: t.payReq,
+ contract: t.contract,
+ };
+ return resp;
+ }
+
+
+ /**
+ * First fetch information requred to withdraw from the reserve,
+ * then deplete the reserve, withdrawing coins until it is empty.
+ */
+ private async processReserve(reserveRecord: ReserveRecord,
+ retryDelayMs: number = 250): Promise<void> {
+ const opId = "reserve-" + reserveRecord.reserve_pub;
+ this.startOperation(opId);
+
+ try {
+ let exchange = await this.updateExchangeFromUrl(reserveRecord.exchange_base_url);
+ let reserve = await this.updateReserve(reserveRecord.reserve_pub,
+ exchange);
+ let n = await this.depleteReserve(reserve, exchange);
+
+ if (n != 0) {
+ let depleted: HistoryRecord = {
+ type: "depleted-reserve",
+ subjectId: `reserve-progress-${reserveRecord.reserve_pub}`,
+ timestamp: (new Date).getTime(),
+ detail: {
+ exchangeBaseUrl: reserveRecord.exchange_base_url,
+ reservePub: reserveRecord.reserve_pub,
+ requestedAmount: reserveRecord.requested_amount,
+ currentAmount: reserveRecord.current_amount,
+ },
+ level: HistoryLevel.User
+ };
+ await this.q().put(Stores.history, depleted).finish();
+ }
+ } catch (e) {
+ // random, exponential backoff truncated at 3 minutes
+ let nextDelay = Math.min(2 * retryDelayMs + retryDelayMs * Math.random(),
+ 3000 * 60);
+ console.warn(`Failed to deplete reserve, trying again in ${retryDelayMs} ms`);
+ setTimeout(() => this.processReserve(reserveRecord, nextDelay),
+ retryDelayMs);
+ } finally {
+ this.stopOperation(opId);
+ }
+ }
+
+
+ private async processPreCoin(preCoin: PreCoin,
+ retryDelayMs = 100): Promise<void> {
+
+ let exchange = await this.q().get(Stores.exchanges,
+ preCoin.exchangeBaseUrl);
+ if (!exchange) {
+ console.error("db inconsistend: exchange for precoin not found");
+ return;
+ }
+ let denom = exchange.all_denoms.find((d) => d.denom_pub == preCoin.denomPub);
+ if (!denom) {
+ console.error("db inconsistent: denom for precoin not found");
+ return;
+ }
+
+ try {
+ const coin = await this.withdrawExecute(preCoin);
+
+ const mutateReserve = (r: ReserveRecord) => {
+
+ console.log(`before committing coin: current ${amountToPretty(r.current_amount!)}, precoin: ${amountToPretty(
+ r.precoin_amount)})}`);
+
+ let x = Amounts.sub(r.precoin_amount,
+ preCoin.coinValue,
+ denom!.fee_withdraw);
+ if (x.saturated) {
+ console.error("database inconsistent");
+ throw AbortTransaction;
+ }
+ r.precoin_amount = x.amount;
+ return r;
+ };
+
+ let historyEntry: HistoryRecord = {
+ type: "withdraw",
+ timestamp: (new Date).getTime(),
+ level: HistoryLevel.Expert,
+ detail: {
+ coinPub: coin.coinPub,
+ }
+ };
+
+ await this.q()
+ .mutate(Stores.reserves, preCoin.reservePub, mutateReserve)
+ .delete("precoins", coin.coinPub)
+ .add(Stores.coins, coin)
+ .add(Stores.history, historyEntry)
+ .finish();
+
+ this.notifier.notify();
+ } catch (e) {
+ console.error("Failed to withdraw coin from precoin, retrying in",
+ retryDelayMs,
+ "ms", e);
+ // exponential backoff truncated at one minute
+ let nextRetryDelayMs = Math.min(retryDelayMs * 2, 1000 * 60);
+ setTimeout(() => this.processPreCoin(preCoin, nextRetryDelayMs),
+ retryDelayMs);
+ }
+ }
+
+
+ /**
+ * Create a reserve, but do not flag it as confirmed yet.
+ */
+ async createReserve(req: CreateReserveRequest): Promise<CreateReserveResponse> {
+ let keypair = await this.cryptoApi.createEddsaKeypair();
+ const now = (new Date).getTime();
+ const canonExchange = canonicalizeBaseUrl(req.exchange);
+
+ const reserveRecord: ReserveRecord = {
+ reserve_pub: keypair.pub,
+ reserve_priv: keypair.priv,
+ exchange_base_url: canonExchange,
+ created: now,
+ last_query: null,
+ current_amount: null,
+ requested_amount: req.amount,
+ confirmed: false,
+ precoin_amount: Amounts.getZero(req.amount.currency),
+ };
+
+ const historyEntry = {
+ type: "create-reserve",
+ level: HistoryLevel.Expert,
+ timestamp: now,
+ subjectId: `reserve-progress-${reserveRecord.reserve_pub}`,
+ detail: {
+ requestedAmount: req.amount,
+ reservePub: reserveRecord.reserve_pub,
+ }
+ };
+
+ await this.q()
+ .put(Stores.reserves, reserveRecord)
+ .put(Stores.history, historyEntry)
+ .finish();
+
+ let r: CreateReserveResponse = {
+ exchange: canonExchange,
+ reservePub: keypair.pub,
+ };
+ return r;
+ }
+
+
+ /**
+ * Mark an existing reserve as confirmed. The wallet will start trying
+ * to withdraw from that reserve. This may not immediately succeed,
+ * since the exchange might not know about the reserve yet, even though the
+ * bank confirmed its creation.
+ *
+ * A confirmed reserve should be shown to the user in the UI, while
+ * an unconfirmed reserve should be hidden.
+ */
+ async confirmReserve(req: ConfirmReserveRequest): Promise<void> {
+ const now = (new Date).getTime();
+ let reserve: ReserveRecord|undefined = await (
+ this.q().get<ReserveRecord>(Stores.reserves,
+ req.reservePub));
+ if (!reserve) {
+ console.error("Unable to confirm reserve, not found in DB");
+ return;
+ }
+ console.log("reserve confirmed");
+ const historyEntry: HistoryRecord = {
+ type: "confirm-reserve",
+ timestamp: now,
+ subjectId: `reserve-progress-${reserve.reserve_pub}`,
+ detail: {
+ exchangeBaseUrl: reserve.exchange_base_url,
+ reservePub: req.reservePub,
+ requestedAmount: reserve.requested_amount,
+ },
+ level: HistoryLevel.User,
+ };
+ reserve.confirmed = true;
+ await this.q()
+ .put(Stores.reserves, reserve)
+ .put(Stores.history, historyEntry)
+ .finish();
+ this.notifier.notify();
+
+ this.processReserve(reserve);
+ }
+
+
+ private async withdrawExecute(pc: PreCoin): Promise<Coin> {
+ let reserve = await this.q().get<ReserveRecord>(Stores.reserves,
+ pc.reservePub);
+
+ if (!reserve) {
+ throw Error("db inconsistent");
+ }
+
+ let wd: any = {};
+ wd.denom_pub = pc.denomPub;
+ wd.reserve_pub = pc.reservePub;
+ wd.reserve_sig = pc.withdrawSig;
+ wd.coin_ev = pc.coinEv;
+ let reqUrl = URI("reserve/withdraw").absoluteTo(reserve.exchange_base_url);
+ let resp = await this.http.postJson(reqUrl, wd);
+
+
+ if (resp.status != 200) {
+ throw new RequestException({
+ hint: "Withdrawal failed",
+ status: resp.status
+ });
+ }
+ let r = JSON.parse(resp.responseText);
+ let denomSig = await this.cryptoApi.rsaUnblind(r.ev_sig,
+ pc.blindingKey,
+ pc.denomPub);
+ let coin: Coin = {
+ coinPub: pc.coinPub,
+ coinPriv: pc.coinPriv,
+ denomPub: pc.denomPub,
+ denomSig: denomSig,
+ currentAmount: pc.coinValue,
+ exchangeBaseUrl: pc.exchangeBaseUrl,
+ dirty: false,
+ transactionPending: false,
+ };
+ return coin;
+ }
+
+
+ /**
+ * Withdraw coins from a reserve until it is empty.
+ */
+ private async depleteReserve(reserve: ReserveRecord,
+ exchange: IExchangeInfo): Promise<number> {
+ if (!reserve.current_amount) {
+ throw Error("can't withdraw when amount is unknown");
+ }
+ let denomsAvailable: Denomination[] = Array.from(exchange.active_denoms);
+ let denomsForWithdraw = getWithdrawDenomList(reserve.current_amount!,
+ denomsAvailable);
+
+ let ps = denomsForWithdraw.map(async(denom) => {
+ function mutateReserve(r: ReserveRecord): ReserveRecord {
+ let currentAmount = r.current_amount;
+ if (!currentAmount) {
+ throw Error("can't withdraw when amount is unknown");
+ }
+ r.precoin_amount = Amounts.add(r.precoin_amount,
+ denom.value,
+ denom.fee_withdraw).amount;
+ let result = Amounts.sub(currentAmount,
+ denom.value,
+ denom.fee_withdraw);
+ if (result.saturated) {
+ console.error("can't create precoin, saturated");
+ throw AbortTransaction;
+ }
+ r.current_amount = result.amount;
+
+ console.log(`after creating precoin: current ${amountToPretty(r.current_amount)}, precoin: ${amountToPretty(
+ r.precoin_amount)})}`);
+
+ return r;
+ }
+
+ let preCoin = await this.cryptoApi
+ .createPreCoin(denom, reserve);
+ await this.q()
+ .put(Stores.precoins, preCoin)
+ .mutate(Stores.reserves, reserve.reserve_pub, mutateReserve);
+ await this.processPreCoin(preCoin);
+ });
+
+ await Promise.all(ps);
+ return ps.length;
+ }
+
+
+ /**
+ * Update the information about a reserve that is stored in the wallet
+ * by quering the reserve's exchange.
+ */
+ private async updateReserve(reservePub: string,
+ exchange: IExchangeInfo): Promise<ReserveRecord> {
+ let reserve = await this.q()
+ .get<ReserveRecord>(Stores.reserves, reservePub);
+ if (!reserve) {
+ throw Error("reserve not in db");
+ }
+ let reqUrl = URI("reserve/status").absoluteTo(exchange.baseUrl);
+ reqUrl.query({'reserve_pub': reservePub});
+ let resp = await this.http.get(reqUrl);
+ if (resp.status != 200) {
+ throw Error();
+ }
+ let reserveInfo = JSON.parse(resp.responseText);
+ if (!reserveInfo) {
+ throw Error();
+ }
+ let oldAmount = reserve.current_amount;
+ let newAmount = reserveInfo.balance;
+ reserve.current_amount = reserveInfo.balance;
+ let historyEntry = {
+ type: "reserve-update",
+ timestamp: (new Date).getTime(),
+ subjectId: `reserve-progress-${reserve.reserve_pub}`,
+ detail: {
+ reservePub,
+ requestedAmount: reserve.requested_amount,
+ oldAmount,
+ newAmount
+ }
+ };
+ await this.q()
+ .put(Stores.reserves, reserve)
+ .finish();
+ this.notifier.notify();
+ return reserve;
+ }
+
+
+ /**
+ * Get the wire information for the exchange with the given base URL.
+ */
+ async getWireInfo(exchangeBaseUrl: string): Promise<WireInfo> {
+ exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
+ let reqUrl = URI("wire").absoluteTo(exchangeBaseUrl);
+ let resp = await this.http.get(reqUrl);
+
+ if (resp.status != 200) {
+ throw Error("/wire request failed");
+ }
+
+ let wiJson = JSON.parse(resp.responseText);
+ if (!wiJson) {
+ throw Error("/wire response malformed")
+ }
+ return wiJson;
+ }
+
+ async getReserveCreationInfo(baseUrl: string,
+ amount: AmountJson): Promise<ReserveCreationInfo> {
+ let exchangeInfo = await this.updateExchangeFromUrl(baseUrl);
+
+ let selectedDenoms = getWithdrawDenomList(amount,
+ exchangeInfo.active_denoms);
+ let acc = Amounts.getZero(amount.currency);
+ for (let d of selectedDenoms) {
+ acc = Amounts.add(acc, d.fee_withdraw).amount;
+ }
+ let actualCoinCost = selectedDenoms
+ .map((d: Denomination) => Amounts.add(d.value,
+ d.fee_withdraw).amount)
+ .reduce((a, b) => Amounts.add(a, b).amount);
+
+ let wireInfo = await this.getWireInfo(baseUrl);
+
+ let ret: ReserveCreationInfo = {
+ exchangeInfo,
+ selectedDenoms,
+ wireInfo,
+ withdrawFee: acc,
+ overhead: Amounts.sub(amount, actualCoinCost).amount,
+ };
+ return ret;
+ }
+
+
+ /**
+ * Update or add exchange DB entry by fetching the /keys information.
+ * Optionally link the reserve entry to the new or existing
+ * exchange entry in then DB.
+ */
+ async updateExchangeFromUrl(baseUrl: string): Promise<IExchangeInfo> {
+ baseUrl = canonicalizeBaseUrl(baseUrl);
+ let reqUrl = URI("keys").absoluteTo(baseUrl);
+ let resp = await this.http.get(reqUrl);
+ if (resp.status != 200) {
+ throw Error("/keys request failed");
+ }
+ let exchangeKeysJson = KeysJson.checked(JSON.parse(resp.responseText));
+ return this.updateExchangeFromJson(baseUrl, exchangeKeysJson);
+ }
+
+ private async suspendCoins(exchangeInfo: IExchangeInfo): Promise<void> {
+ let suspendedCoins = await (
+ this.q()
+ .iterIndex(Stores.coins.exchangeBaseUrlIndex, exchangeInfo.baseUrl)
+ .reduce((coin: Coin, suspendedCoins: Coin[]) => {
+ if (!exchangeInfo.active_denoms.find((c) => c.denom_pub == coin.denomPub)) {
+ return Array.prototype.concat(suspendedCoins, [coin]);
+ }
+ return Array.prototype.concat(suspendedCoins);
+ }, []));
+
+ let q = this.q();
+ suspendedCoins.map((c) => {
+ console.log("suspending coin", c);
+ c.suspended = true;
+ q.put(Stores.coins, c);
+ });
+ await q.finish();
+ }
+
+
+ private async updateExchangeFromJson(baseUrl: string,
+ exchangeKeysJson: KeysJson): Promise<IExchangeInfo> {
+ const updateTimeSec = getTalerStampSec(exchangeKeysJson.list_issue_date);
+ if (updateTimeSec === null) {
+ throw Error("invalid update time");
+ }
+
+ let r = await this.q().get<IExchangeInfo>(Stores.exchanges, baseUrl);
+
+ let exchangeInfo: IExchangeInfo;
+
+ if (!r) {
+ exchangeInfo = {
+ baseUrl,
+ all_denoms: [],
+ active_denoms: [],
+ last_update_time: updateTimeSec,
+ masterPublicKey: exchangeKeysJson.master_public_key,
+ };
+ console.log("making fresh exchange");
+ } else {
+ if (updateTimeSec < r.last_update_time) {
+ console.log("outdated /keys, not updating");
+ return r
+ }
+ exchangeInfo = r;
+ console.log("updating old exchange");
+ }
+
+ let updatedExchangeInfo = await this.updateExchangeInfo(exchangeInfo,
+ exchangeKeysJson);
+ await this.suspendCoins(updatedExchangeInfo);
+
+ await this.q()
+ .put(Stores.exchanges, updatedExchangeInfo)
+ .finish();
+
+ return updatedExchangeInfo;
+ }
+
+
+ private async updateExchangeInfo(exchangeInfo: IExchangeInfo,
+ newKeys: KeysJson): Promise<IExchangeInfo> {
+ if (exchangeInfo.masterPublicKey != newKeys.master_public_key) {
+ throw Error("public keys do not match");
+ }
+
+ exchangeInfo.active_denoms = [];
+
+ let denomsToCheck = newKeys.denoms.filter((newDenom) => {
+ // did we find the new denom in the list of all (old) denoms?
+ let found = false;
+ for (let oldDenom of exchangeInfo.all_denoms) {
+ if (oldDenom.denom_pub === newDenom.denom_pub) {
+ let a: any = Object.assign({}, oldDenom);
+ let b: any = Object.assign({}, newDenom);
+ // pub hash is only there for convenience in the wallet
+ delete a["pub_hash"];
+ delete b["pub_hash"];
+ if (!deepEquals(a, b)) {
+ console.error("denomination parameters were modified, old/new:");
+ console.dir(a);
+ console.dir(b);
+ // FIXME: report to auditors
+ }
+ found = true;
+ break;
+ }
+ }
+
+ if (found) {
+ exchangeInfo.active_denoms.push(newDenom);
+ // No need to check signatures
+ return false;
+ }
+ return true;
+ });
+
+ let ps = denomsToCheck.map(async(denom) => {
+ let valid = await this.cryptoApi
+ .isValidDenom(denom,
+ exchangeInfo.masterPublicKey);
+ if (!valid) {
+ console.error("invalid denomination",
+ denom,
+ "with key",
+ exchangeInfo.masterPublicKey);
+ // FIXME: report to auditors
+ }
+ exchangeInfo.active_denoms.push(denom);
+ exchangeInfo.all_denoms.push(denom);
+ });
+
+ await Promise.all(ps);
+
+ return exchangeInfo;
+ }
+
+
+ /**
+ * Retrieve a mapping from currency name to the amount
+ * that is currenctly available for spending in the wallet.
+ */
+ async getBalances(): Promise<WalletBalance> {
+ function ensureEntry(balance: WalletBalance, currency: string) {
+ let entry: WalletBalanceEntry|undefined = balance[currency];
+ let z = Amounts.getZero(currency);
+ if (!entry) {
+ balance[currency] = entry = {
+ available: z,
+ pendingIncoming: z,
+ pendingPayment: z,
+ };
+ }
+ return entry;
+ }
+
+ function collectBalances(c: Coin, balance: WalletBalance) {
+ if (c.suspended) {
+ return balance;
+ }
+ let currency = c.currentAmount.currency;
+ let entry = ensureEntry(balance, currency);
+ entry.available = Amounts.add(entry.available, c.currentAmount).amount;
+ return balance;
+ }
+
+ function collectPendingWithdraw(r: ReserveRecord, balance: WalletBalance) {
+ if (!r.confirmed) {
+ return balance;
+ }
+ let entry = ensureEntry(balance, r.requested_amount.currency);
+ let amount = r.current_amount;
+ if (!amount) {
+ amount = r.requested_amount;
+ }
+ amount = Amounts.add(amount, r.precoin_amount).amount;
+ if (Amounts.cmp(smallestWithdraw[r.exchange_base_url], amount) < 0) {
+ entry.pendingIncoming = Amounts.add(entry.pendingIncoming,
+ amount).amount;
+ }
+ return balance;
+ }
+
+ function collectPendingRefresh(r: RefreshSession, balance: WalletBalance) {
+ if (!r.finished) {
+ return balance;
+ }
+ let entry = ensureEntry(balance, r.valueWithFee.currency);
+ entry.pendingIncoming = Amounts.add(entry.pendingIncoming,
+ r.valueOutput).amount;
+
+ return balance;
+ }
+
+ function collectPayments(t: Transaction, balance: WalletBalance) {
+ if (t.finished) {
+ return balance;
+ }
+ let entry = ensureEntry(balance, t.contract.amount.currency);
+ entry.pendingPayment = Amounts.add(entry.pendingPayment,
+ t.contract.amount).amount;
+
+ return balance;
+ }
+
+ function collectSmallestWithdraw(e: IExchangeInfo, sw: any) {
+ let min: AmountJson|undefined;
+ for (let d of e.active_denoms) {
+ let v = Amounts.add(d.value, d.fee_withdraw).amount;
+ if (!min) {
+ min = v;
+ continue;
+ }
+ if (Amounts.cmp(v, min) < 0) {
+ min = v;
+ }
+ }
+ sw[e.baseUrl] = min;
+ return sw;
+ }
+
+ let balance = {};
+ // Mapping from exchange pub to smallest
+ // possible amount we can withdraw
+ let smallestWithdraw: {[baseUrl: string]: AmountJson} = {};
+
+ smallestWithdraw = await (this.q()
+ .iter(Stores.exchanges)
+ .reduce(collectSmallestWithdraw, {}));
+
+ console.log("smallest withdraws", smallestWithdraw);
+
+ let tx = this.q();
+ tx.iter(Stores.coins)
+ .reduce(collectBalances, balance);
+ tx.iter(Stores.refresh)
+ .reduce(collectPendingRefresh, balance);
+ tx.iter(Stores.reserves)
+ .reduce(collectPendingWithdraw, balance);
+ tx.iter(Stores.transactions)
+ .reduce(collectPayments, balance);
+ await tx.finish();
+ return balance;
+
+ }
+
+
+ async createRefreshSession(oldCoinPub: string): Promise<RefreshSession|undefined> {
+ let coin = await this.q().get<Coin>(Stores.coins, oldCoinPub);
+
+ if (!coin) {
+ throw Error("coin not found");
+ }
+
+ let exchange = await this.updateExchangeFromUrl(coin.exchangeBaseUrl);
+
+ if (!exchange) {
+ throw Error("db inconsistent");
+ }
+
+ let oldDenom = exchange.all_denoms.find((d) => d.denom_pub == coin!.denomPub);
+
+ if (!oldDenom) {
+ throw Error("db inconsistent");
+ }
+
+ let availableDenoms: Denomination[] = exchange.active_denoms;
+
+ let availableAmount = Amounts.sub(coin.currentAmount,
+ oldDenom.fee_refresh).amount;
+
+ let newCoinDenoms = getWithdrawDenomList(availableAmount,
+ availableDenoms);
+
+ console.log("refreshing into", newCoinDenoms);
+
+ if (newCoinDenoms.length == 0) {
+ console.log("not refreshing, value too small");
+ return undefined;
+ }
+
+
+ let refreshSession: RefreshSession = await (
+ this.cryptoApi.createRefreshSession(exchange.baseUrl,
+ 3,
+ coin,
+ newCoinDenoms,
+ oldDenom.fee_refresh));
+
+ function mutateCoin(c: Coin): Coin {
+ let r = Amounts.sub(c.currentAmount,
+ refreshSession.valueWithFee);
+ if (r.saturated) {
+ // Something else must have written the coin value
+ throw AbortTransaction;
+ }
+ c.currentAmount = r.amount;
+ return c;
+ }
+
+ await this.q()
+ .put(Stores.refresh, refreshSession)
+ .mutate(Stores.coins, coin.coinPub, mutateCoin)
+ .finish();
+
+ return refreshSession;
+ }
+
+
+ async refresh(oldCoinPub: string): Promise<void> {
+ let refreshSession: RefreshSession|undefined;
+ let oldSession = await this.q().get(Stores.refresh, oldCoinPub);
+ if (oldSession) {
+ refreshSession = oldSession;
+ } else {
+ refreshSession = await this.createRefreshSession(oldCoinPub);
+ }
+ if (!refreshSession) {
+ // refreshing not necessary
+ return;
+ }
+ this.continueRefreshSession(refreshSession);
+ }
+
+ async continueRefreshSession(refreshSession: RefreshSession) {
+ if (refreshSession.finished) {
+ return;
+ }
+ if (typeof refreshSession.norevealIndex !== "number") {
+ let coinPub = refreshSession.meltCoinPub;
+ await this.refreshMelt(refreshSession);
+ let r = await this.q().get<RefreshSession>(Stores.refresh, coinPub);
+ if (!r) {
+ throw Error("refresh session does not exist anymore");
+ }
+ refreshSession = r;
+ }
+
+ await this.refreshReveal(refreshSession);
+ }
+
+
+ async refreshMelt(refreshSession: RefreshSession): Promise<void> {
+ if (refreshSession.norevealIndex != undefined) {
+ console.error("won't melt again");
+ return;
+ }
+
+ let coin = await this.q().get<Coin>(Stores.coins,
+ refreshSession.meltCoinPub);
+ if (!coin) {
+ console.error("can't melt coin, it does not exist");
+ return;
+ }
+
+ let reqUrl = URI("refresh/melt").absoluteTo(refreshSession.exchangeBaseUrl);
+ let meltCoin = {
+ coin_pub: coin.coinPub,
+ denom_pub: coin.denomPub,
+ denom_sig: coin.denomSig,
+ confirm_sig: refreshSession.confirmSig,
+ value_with_fee: refreshSession.valueWithFee,
+ };
+ let coinEvs = refreshSession.preCoinsForGammas.map((x) => x.map((y) => y.coinEv));
+ let req = {
+ "new_denoms": refreshSession.newDenoms,
+ "melt_coin": meltCoin,
+ "transfer_pubs": refreshSession.transferPubs,
+ "coin_evs": coinEvs,
+ };
+ console.log("melt request:", req);
+ let resp = await this.http.postJson(reqUrl, req);
+
+ console.log("melt request:", req);
+ console.log("melt response:", resp.responseText);
+
+ if (resp.status != 200) {
+ console.error(resp.responseText);
+ throw Error("refresh failed");
+ }
+
+ let respJson = JSON.parse(resp.responseText);
+
+ if (!respJson) {
+ throw Error("exchange responded with garbage");
+ }
+
+ let norevealIndex = respJson.noreveal_index;
+
+ if (typeof norevealIndex != "number") {
+ throw Error("invalid response");
+ }
+
+ refreshSession.norevealIndex = norevealIndex;
+
+ await this.q().put(Stores.refresh, refreshSession).finish();
+ }
+
+
+ async refreshReveal(refreshSession: RefreshSession): Promise<void> {
+ let norevealIndex = refreshSession.norevealIndex;
+ if (norevealIndex == undefined) {
+ throw Error("can't reveal without melting first");
+ }
+ let privs = Array.from(refreshSession.transferPrivs);
+ privs.splice(norevealIndex, 1);
+
+ let req = {
+ "session_hash": refreshSession.hash,
+ "transfer_privs": privs,
+ };
+
+ let reqUrl = URI("refresh/reveal")
+ .absoluteTo(refreshSession.exchangeBaseUrl);
+ console.log("reveal request:", req);
+ let resp = await this.http.postJson(reqUrl, req);
+
+ console.log("session:", refreshSession);
+ console.log("reveal response:", resp);
+
+ if (resp.status != 200) {
+ console.log("error: /refresh/reveal returned status " + resp.status);
+ return;
+ }
+
+ let respJson = JSON.parse(resp.responseText);
+
+ if (!respJson.ev_sigs || !Array.isArray(respJson.ev_sigs)) {
+ console.log("/refresh/reveal did not contain ev_sigs");
+ }
+
+ let exchange = await this.q().get<IExchangeInfo>(Stores.exchanges,
+ refreshSession.exchangeBaseUrl);
+ if (!exchange) {
+ console.error(`exchange ${refreshSession.exchangeBaseUrl} not found`);
+ return;
+ }
+
+ let coins: Coin[] = [];
+
+ for (let i = 0; i < respJson.ev_sigs.length; i++) {
+ let denom = exchange.all_denoms.find((d) => d.denom_pub == refreshSession.newDenoms[i]);
+ if (!denom) {
+ console.error("denom not found");
+ continue;
+ }
+ let pc = refreshSession.preCoinsForGammas[refreshSession.norevealIndex!][i];
+ let denomSig = await this.cryptoApi.rsaUnblind(respJson.ev_sigs[i].ev_sig,
+ pc.blindingKey,
+ denom.denom_pub);
+ let coin: Coin = {
+ coinPub: pc.publicKey,
+ coinPriv: pc.privateKey,
+ denomPub: denom.denom_pub,
+ denomSig: denomSig,
+ currentAmount: denom.value,
+ exchangeBaseUrl: refreshSession.exchangeBaseUrl,
+ dirty: false,
+ transactionPending: false,
+ };
+
+ coins.push(coin);
+ }
+
+ refreshSession.finished = true;
+
+ await this.q()
+ .putAll(Stores.coins, coins)
+ .put(Stores.refresh, refreshSession)
+ .finish();
+ }
+
+
+ /**
+ * Retrive the full event history for this wallet.
+ */
+ async getHistory(): Promise<any> {
+ function collect(x: any, acc: any) {
+ acc.push(x);
+ return acc;
+ }
+
+ let history = await (
+ this.q()
+ .iterIndex(Stores.history.timestampIndex)
+ .reduce(collect, []));
+
+ return {history};
+ }
+
+
+ async getOffer(offerId: number): Promise<any> {
+ let offer = await this.q() .get(Stores.offers, offerId);
+ return offer;
+ }
+
+ async getExchanges(): Promise<IExchangeInfo[]> {
+ return this.q()
+ .iter<IExchangeInfo>(Stores.exchanges)
+ .flatMap((e) => [e])
+ .toArray();
+ }
+
+ async getReserves(exchangeBaseUrl: string): Promise<ReserveRecord[]> {
+ return this.q()
+ .iter<ReserveRecord>(Stores.reserves)
+ .filter((r: ReserveRecord) => r.exchange_base_url === exchangeBaseUrl)
+ .toArray();
+ }
+
+ async getCoins(exchangeBaseUrl: string): Promise<Coin[]> {
+ return this.q()
+ .iter<Coin>(Stores.coins)
+ .filter((c: Coin) => c.exchangeBaseUrl === exchangeBaseUrl)
+ .toArray();
+ }
+
+ async getPreCoins(exchangeBaseUrl: string): Promise<PreCoin[]> {
+ return this.q()
+ .iter<PreCoin>(Stores.precoins)
+ .filter((c: PreCoin) => c.exchangeBaseUrl === exchangeBaseUrl)
+ .toArray();
+ }
+
+ async hashContract(contract: Contract): Promise<string> {
+ return this.cryptoApi.hashString(canonicalJson(contract));
+ }
+
+ /**
+ * Check if there's an equivalent contract we've already purchased.
+ */
+ async checkRepurchase(contract: Contract): Promise<CheckRepurchaseResult> {
+ if (!contract.repurchase_correlation_id) {
+ console.log("no repurchase: no correlation id");
+ return {isRepurchase: false};
+ }
+ let result: Transaction|undefined = await (
+ this.q()
+ .getIndexed(Stores.transactions.repurchaseIndex,
+ [
+ contract.merchant_pub,
+ contract.repurchase_correlation_id
+ ]));
+
+ if (result) {
+ console.assert(result.contract.repurchase_correlation_id == contract.repurchase_correlation_id);
+ return {
+ isRepurchase: true,
+ existingContractHash: result.contractHash,
+ existingFulfillmentUrl: result.contract.fulfillment_url,
+ };
+ } else {
+ return {isRepurchase: false};
+ }
+ }
+
+
+ async paymentSucceeded(contractHash: string): Promise<any> {
+ const doPaymentSucceeded = async() => {
+ let t = await this.q().get<Transaction>(Stores.transactions,
+ contractHash);
+ if (!t) {
+ console.error("contract not found");
+ return;
+ }
+ t.finished = true;
+ let modifiedCoins: Coin[] = [];
+ for (let pc of t.payReq.coins) {
+ let c = await this.q().get<Coin>(Stores.coins, pc.coin_pub);
+ if (!c) {
+ console.error("coin not found");
+ return;
+ }
+ c.transactionPending = false;
+ modifiedCoins.push(c);
+ }
+
+ await this.q()
+ .putAll(Stores.coins, modifiedCoins)
+ .put(Stores.transactions, t)
+ .finish();
+ for (let c of t.payReq.coins) {
+ this.refresh(c.coin_pub);
+ }
+ };
+ doPaymentSucceeded();
+ return;
+ }
+}
diff --git a/src/wxApi.ts b/src/wxApi.ts
new file mode 100644
index 000000000..a85b56c28
--- /dev/null
+++ b/src/wxApi.ts
@@ -0,0 +1,75 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+import {
+ AmountJson,
+ Coin,
+ PreCoin,
+ ReserveCreationInfo,
+ IExchangeInfo,
+ ReserveRecord
+} from "./types";
+
+/**
+ * Interface to the wallet through WebExtension messaging.
+ * @author Florian Dold
+ */
+
+
+export function getReserveCreationInfo(baseUrl: string,
+ amount: AmountJson): Promise<ReserveCreationInfo> {
+ let m = { type: "reserve-creation-info", detail: { baseUrl, amount } };
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(m, (resp) => {
+ if (resp.error) {
+ console.error("error response", resp);
+ let e = Error("call to reserve-creation-info failed");
+ (e as any).errorResponse = resp;
+ reject(e);
+ return;
+ }
+ resolve(resp);
+ });
+ });
+}
+
+export async function callBackend(type: string, detail?: any): Promise<any> {
+ return new Promise<IExchangeInfo[]>((resolve, reject) => {
+ chrome.runtime.sendMessage({ type, detail }, (resp) => {
+ resolve(resp);
+ });
+ });
+}
+
+export async function getExchanges(): Promise<IExchangeInfo[]> {
+ return await callBackend("get-exchanges");
+}
+
+export async function getReserves(exchangeBaseUrl: string): Promise<ReserveRecord[]> {
+ return await callBackend("get-reserves", { exchangeBaseUrl });
+}
+
+export async function getCoins(exchangeBaseUrl: string): Promise<Coin[]> {
+ return await callBackend("get-coins", { exchangeBaseUrl });
+}
+
+export async function getPreCoins(exchangeBaseUrl: string): Promise<PreCoin[]> {
+ return await callBackend("get-precoins", { exchangeBaseUrl });
+}
+
+export async function refresh(coinPub: string): Promise<void> {
+ return await callBackend("refresh-coin", { coinPub });
+}
diff --git a/src/wxMessaging.ts b/src/wxMessaging.ts
new file mode 100644
index 000000000..990f1488b
--- /dev/null
+++ b/src/wxMessaging.ts
@@ -0,0 +1,439 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ 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
+ 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/>
+ */
+
+
+import {
+ Wallet,
+ Offer,
+ Badge,
+ ConfirmReserveRequest,
+ CreateReserveRequest
+} from "./wallet";
+import { deleteDb, exportDb, openTalerDb } from "./db";
+import { BrowserHttpLib } from "./http";
+import { Checkable } from "./checkable";
+import { AmountJson } from "./types";
+import Port = chrome.runtime.Port;
+import { Notifier } from "./types";
+import { Contract } from "./types";
+import MessageSender = chrome.runtime.MessageSender;
+import { ChromeBadge } from "./chromeBadge";
+
+"use strict";
+
+/**
+ * Messaging for the WebExtensions wallet. Should contain
+ * parts that are specific for WebExtensions, but as little business
+ * logic as possible.
+ *
+ * @author Florian Dold
+ */
+
+
+type Handler = (detail: any, sender: MessageSender) => Promise<any>;
+
+function makeHandlers(db: IDBDatabase,
+ wallet: Wallet): { [msg: string]: Handler } {
+ return {
+ ["balances"]: function (detail, sender) {
+ return wallet.getBalances();
+ },
+ ["dump-db"]: function (detail, sender) {
+ return exportDb(db);
+ },
+ ["get-tab-cookie"]: function (detail, sender) {
+ if (!sender || !sender.tab || !sender.tab.id) {
+ return Promise.resolve();
+ }
+ let id: number = sender.tab.id;
+ let info: any = <any>paymentRequestCookies[id];
+ delete paymentRequestCookies[id];
+ return Promise.resolve(info);
+ },
+ ["ping"]: function (detail, sender) {
+ return Promise.resolve();
+ },
+ ["reset"]: function (detail, sender) {
+ if (db) {
+ let tx = db.transaction(Array.from(db.objectStoreNames), 'readwrite');
+ for (let i = 0; i < db.objectStoreNames.length; i++) {
+ tx.objectStore(db.objectStoreNames[i]).clear();
+ }
+ }
+ deleteDb();
+
+ chrome.browserAction.setBadgeText({ text: "" });
+ console.log("reset done");
+ // Response is synchronous
+ return Promise.resolve({});
+ },
+ ["create-reserve"]: function (detail, sender) {
+ const d = {
+ exchange: detail.exchange,
+ amount: detail.amount,
+ };
+ const req = CreateReserveRequest.checked(d);
+ return wallet.createReserve(req);
+ },
+ ["confirm-reserve"]: function (detail, sender) {
+ // TODO: make it a checkable
+ const d = {
+ reservePub: detail.reservePub
+ };
+ const req = ConfirmReserveRequest.checked(d);
+ return wallet.confirmReserve(req);
+ },
+ ["confirm-pay"]: function (detail, sender) {
+ let offer: Offer;
+ try {
+ offer = Offer.checked(detail.offer);
+ } catch (e) {
+ if (e instanceof Checkable.SchemaError) {
+ console.error("schema error:", e.message);
+ return Promise.resolve({
+ error: "invalid contract",
+ hint: e.message,
+ detail: detail
+ });
+ } else {
+ throw e;
+ }
+ }
+
+ return wallet.confirmPay(offer);
+ },
+ ["check-pay"]: function (detail, sender) {
+ let offer: Offer;
+ try {
+ offer = Offer.checked(detail.offer);
+ } catch (e) {
+ if (e instanceof Checkable.SchemaError) {
+ console.error("schema error:", e.message);
+ return Promise.resolve({
+ error: "invalid contract",
+ hint: e.message,
+ detail: detail
+ });
+ } else {
+ throw e;
+ }
+ }
+ return wallet.checkPay(offer);
+ },
+ ["execute-payment"]: function (detail: any, sender: MessageSender) {
+ if (sender.tab && sender.tab.id) {
+ rateLimitCache[sender.tab.id]++;
+ if (rateLimitCache[sender.tab.id] > 10) {
+ console.warn("rate limit for execute payment exceeded");
+ let msg = {
+ error: "rate limit exceeded for execute-payment",
+ rateLimitExceeded: true,
+ hint: "Check for redirect loops",
+ };
+ return Promise.resolve(msg);
+ }
+ }
+ return wallet.executePayment(detail.H_contract);
+ },
+ ["exchange-info"]: function (detail) {
+ if (!detail.baseUrl) {
+ return Promise.resolve({ error: "bad url" });
+ }
+ return wallet.updateExchangeFromUrl(detail.baseUrl);
+ },
+ ["hash-contract"]: function (detail) {
+ if (!detail.contract) {
+ return Promise.resolve({ error: "contract missing" });
+ }
+ return wallet.hashContract(detail.contract).then((hash) => {
+ return { hash };
+ });
+ },
+ ["put-history-entry"]: function (detail: any) {
+ if (!detail.historyEntry) {
+ return Promise.resolve({ error: "historyEntry missing" });
+ }
+ return wallet.putHistory(detail.historyEntry);
+ },
+ ["save-offer"]: function (detail: any) {
+ let offer = detail.offer;
+ if (!offer) {
+ return Promise.resolve({ error: "offer missing" });
+ }
+ console.log("handling safe-offer");
+ return wallet.saveOffer(offer);
+ },
+ ["reserve-creation-info"]: function (detail, sender) {
+ if (!detail.baseUrl || typeof detail.baseUrl !== "string") {
+ return Promise.resolve({ error: "bad url" });
+ }
+ let amount = AmountJson.checked(detail.amount);
+ return wallet.getReserveCreationInfo(detail.baseUrl, amount);
+ },
+ ["check-repurchase"]: function (detail, sender) {
+ let contract = Contract.checked(detail.contract);
+ return wallet.checkRepurchase(contract);
+ },
+ ["get-history"]: function (detail, sender) {
+ // TODO: limit history length
+ return wallet.getHistory();
+ },
+ ["get-offer"]: function (detail, sender) {
+ return wallet.getOffer(detail.offerId);
+ },
+ ["get-exchanges"]: function (detail, sender) {
+ return wallet.getExchanges();
+ },
+ ["get-reserves"]: function (detail, sender) {
+ if (typeof detail.exchangeBaseUrl !== "string") {
+ return Promise.reject(Error("exchangeBaseUrl missing"));
+ }
+ return wallet.getReserves(detail.exchangeBaseUrl);
+ },
+ ["get-coins"]: function (detail, sender) {
+ if (typeof detail.exchangeBaseUrl !== "string") {
+ return Promise.reject(Error("exchangBaseUrl missing"));
+ }
+ return wallet.getCoins(detail.exchangeBaseUrl);
+ },
+ ["get-precoins"]: function (detail, sender) {
+ if (typeof detail.exchangeBaseUrl !== "string") {
+ return Promise.reject(Error("exchangBaseUrl missing"));
+ }
+ return wallet.getPreCoins(detail.exchangeBaseUrl);
+ },
+ ["refresh-coin"]: function (detail, sender) {
+ if (typeof detail.coinPub !== "string") {
+ return Promise.reject(Error("coinPub missing"));
+ }
+ return wallet.refresh(detail.coinPub);
+ },
+ ["payment-failed"]: function (detail, sender) {
+ // For now we just update exchanges (maybe the exchange did something
+ // wrong and the keys were messed up).
+ // FIXME: in the future we should look at what actually went wrong.
+ console.error("payment reported as failed");
+ wallet.updateExchanges();
+ return Promise.resolve();
+ },
+ ["payment-succeeded"]: function (detail, sender) {
+ let contractHash = detail.contractHash;
+ if (!contractHash) {
+ return Promise.reject(Error("contractHash missing"));
+ }
+ return wallet.paymentSucceeded(contractHash);
+ },
+ };
+}
+
+
+function dispatch(handlers: any, req: any, sender: any, sendResponse: any) {
+ if (req.type in handlers) {
+ Promise
+ .resolve()
+ .then(() => {
+ const p = handlers[req.type](req.detail, sender);
+
+ return p.then((r: any) => {
+ try {
+ sendResponse(r);
+ } catch (e) {
+ // might fail if tab disconnected
+ }
+ })
+ })
+ .catch((e) => {
+ console.log(`exception during wallet handler for '${req.type}'`);
+ console.log("request", req);
+ console.error(e);
+ try {
+ sendResponse({
+ error: "exception",
+ hint: e.message,
+ stack: e.stack.toString()
+ });
+
+ } catch (e) {
+ // might fail if tab disconnected
+ }
+ });
+ // The sendResponse call is async
+ return true;
+ } else {
+ console.error(`Request type ${JSON.stringify(req)} unknown, req ${req.type}`);
+ try {
+ sendResponse({ error: "request unknown" });
+ } catch (e) {
+ // might fail if tab disconnected
+ }
+
+ // The sendResponse call is sync
+ return false;
+ }
+}
+
+class ChromeNotifier implements Notifier {
+ ports: Port[] = [];
+
+ constructor() {
+ chrome.runtime.onConnect.addListener((port) => {
+ console.log("got connect!");
+ this.ports.push(port);
+ port.onDisconnect.addListener(() => {
+ let i = this.ports.indexOf(port);
+ if (i >= 0) {
+ this.ports.splice(i, 1);
+ } else {
+ console.error("port already removed");
+ }
+ });
+ });
+ }
+
+ notify() {
+ for (let p of this.ports) {
+ p.postMessage({ notify: true });
+ }
+ }
+}
+
+
+/**
+ * Mapping from tab ID to payment information (if any).
+ */
+let paymentRequestCookies: { [n: number]: any } = {};
+
+function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[],
+ url: string, tabId: number): any {
+ const headers: { [s: string]: string } = {};
+ for (let kv of headerList) {
+ if (kv.value) {
+ headers[kv.name.toLowerCase()] = kv.value;
+ }
+ }
+
+ const contractUrl = headers["x-taler-contract-url"];
+ if (contractUrl !== undefined) {
+ paymentRequestCookies[tabId] = { type: "fetch", contractUrl };
+ return;
+ }
+
+ const contractHash = headers["x-taler-contract-hash"];
+
+ if (contractHash !== undefined) {
+ const payUrl = headers["x-taler-pay-url"];
+ if (payUrl === undefined) {
+ console.log("malformed 402, X-Taler-Pay-Url missing");
+ return;
+ }
+
+ // Offer URL is optional
+ const offerUrl = headers["x-taler-offer-url"];
+ paymentRequestCookies[tabId] = {
+ type: "execute",
+ offerUrl,
+ payUrl,
+ contractHash
+ };
+ return;
+ }
+
+ // looks like it's not a taler request, it might be
+ // for a different payment system (or the shop is buggy)
+ console.log("ignoring non-taler 402 response");
+}
+
+// Useful for debugging ...
+export let wallet: Wallet | undefined = undefined;
+export let badge: ChromeBadge | undefined = undefined;
+
+// Rate limit cache for executePayment operations, to break redirect loops
+let rateLimitCache: { [n: number]: number } = {};
+
+function clearRateLimitCache() {
+ rateLimitCache = {};
+}
+
+export function wxMain() {
+ chrome.browserAction.setBadgeText({ text: "" });
+ badge = new ChromeBadge();
+
+ chrome.tabs.query({}, function (tabs) {
+ for (let tab of tabs) {
+ if (!tab.url || !tab.id) {
+ return;
+ }
+ let uri = URI(tab.url);
+ if (uri.protocol() == "http" || uri.protocol() == "https") {
+ console.log("injecting into existing tab", tab.id);
+ chrome.tabs.executeScript(tab.id, { file: "/src/vendor/URI.js" });
+ chrome.tabs.executeScript(tab.id, { file: "/src/taler-wallet-lib.js" });
+ chrome.tabs.executeScript(tab.id, { file: "/src/content_scripts/notify.js" });
+ }
+ }
+ });
+
+ chrome.extension.getBackgroundPage().setInterval(clearRateLimitCache, 5000);
+
+ Promise.resolve()
+ .then(() => {
+ return openTalerDb();
+ })
+ .catch((e) => {
+ console.error("could not open database");
+ console.error(e);
+ })
+ .then((db: IDBDatabase) => {
+ let http = new BrowserHttpLib();
+ let notifier = new ChromeNotifier();
+ console.log("setting wallet");
+ wallet = new Wallet(db, http, badge!, notifier);
+
+ // Handlers for messages coming directly from the content
+ // script on the page
+ let handlers = makeHandlers(db, wallet!);
+ chrome.runtime.onMessage.addListener((req, sender, sendResponse) => {
+ try {
+ return dispatch(handlers, req, sender, sendResponse)
+ } catch (e) {
+ console.log(`exception during wallet handler (dispatch)`);
+ console.log("request", req);
+ console.error(e);
+ sendResponse({
+ error: "exception",
+ hint: e.message,
+ stack: e.stack.toString()
+ });
+ return false;
+ }
+ });
+
+ // Handlers for catching HTTP requests
+ chrome.webRequest.onHeadersReceived.addListener((details) => {
+ if (details.statusCode != 402) {
+ return;
+ }
+ console.log(`got 402 from ${details.url}`);
+ return handleHttpPayment(details.responseHeaders || [],
+ details.url,
+ details.tabId);
+ }, { urls: ["<all_urls>"] }, ["responseHeaders", "blocking"]);
+ })
+ .catch((e) => {
+ console.error("could not initialize wallet messaging");
+ console.error(e);
+ });
+}