aboutsummaryrefslogtreecommitdiff
path: root/lib/wallet
diff options
context:
space:
mode:
Diffstat (limited to 'lib/wallet')
-rw-r--r--lib/wallet/checkable.ts262
-rw-r--r--lib/wallet/chromeBadge.ts227
-rw-r--r--lib/wallet/cryptoApi.ts204
-rw-r--r--lib/wallet/cryptoLib.ts227
-rw-r--r--lib/wallet/cryptoWorker.ts61
-rw-r--r--lib/wallet/db.ts119
-rw-r--r--lib/wallet/emscriptif.ts1033
-rw-r--r--lib/wallet/helpers.ts67
-rw-r--r--lib/wallet/http.ts84
-rw-r--r--lib/wallet/query.ts415
-rw-r--r--lib/wallet/renderHtml.ts49
-rw-r--r--lib/wallet/types.ts396
-rw-r--r--lib/wallet/wallet.ts1156
-rw-r--r--lib/wallet/wxApi.ts41
-rw-r--r--lib/wallet/wxMessaging.ts392
15 files changed, 4733 insertions, 0 deletions
diff --git a/lib/wallet/checkable.ts b/lib/wallet/checkable.ts
new file mode 100644
index 000000000..9fd816578
--- /dev/null
+++ b/lib/wallet/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/lib/wallet/chromeBadge.ts b/lib/wallet/chromeBadge.ts
new file mode 100644
index 000000000..df12fba83
--- /dev/null
+++ b/lib/wallet/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/lib/wallet/cryptoApi.ts b/lib/wallet/cryptoApi.ts
new file mode 100644
index 000000000..db29592fc
--- /dev/null
+++ b/lib/wallet/cryptoApi.ts
@@ -0,0 +1,204 @@
+/*
+ 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} from "./types";
+import {Reserve} from "./types";
+import {Denomination} from "./types";
+import {Offer} from "./wallet";
+import {CoinWithDenom} from "./wallet";
+import {PayCoinInfo} from "./types";
+
+interface RegistryEntry {
+ resolve: any;
+ reject: any;
+ workerIndex: number;
+}
+
+interface WorkerState {
+ /**
+ * The actual worker thread.
+ */
+ w: Worker;
+ /**
+ * Are we currently running a task on this worker?
+ */
+ busy: boolean;
+}
+
+interface WorkItem {
+ operation: string;
+ args: any[];
+ resolve: any;
+ reject: any;
+}
+
+
+/**
+ * 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 rpcRegistry: {[n: number]: RegistryEntry} = {};
+ private workers: WorkerState[];
+ private workQueues: WorkItem[][];
+ /**
+ * Number of busy workers.
+ */
+ private numBusy: number = 0;
+ /**
+ * Number if pending work items.
+ */
+ private numWaiting: number = 0;
+
+
+ constructor() {
+ let handler = (msg: MessageEvent) => {
+ let id = msg.data.id;
+ if (typeof id !== "number") {
+ console.error("rpc id must be number");
+ return;
+ }
+ if (!this.rpcRegistry[id]) {
+ console.error(`RPC with id ${id} has no registry entry`);
+ return;
+ }
+ let {resolve, workerIndex} = this.rpcRegistry[id];
+ delete this.rpcRegistry[id];
+ let ws = this.workers[workerIndex];
+ if (!ws.busy) {
+ throw Error("assertion failed");
+ }
+ ws.busy = false;
+ this.numBusy--;
+ resolve(msg.data.result);
+
+ // 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()!;
+ let msg: any = {
+ operation: work.operation,
+ args: work.args,
+ id: this.registerRpcId(work.resolve, work.reject, workerIndex),
+ };
+ ws.w.postMessage(msg);
+ ws.busy = true;
+ this.numBusy++;
+ return;
+ }
+ }
+ };
+
+ this.workers = new Array<WorkerState>((navigator as any)["hardwareConcurrency"] || 2);
+
+ for (let i = 0; i < this.workers.length; i++) {
+ let w = new Worker("/lib/wallet/cryptoWorker.js");
+ w.onmessage = handler;
+ this.workers[i] = {
+ w,
+ busy: false,
+ };
+ }
+ this.workQueues = [];
+ for (let i = 0; i < NUM_PRIO; i++) {
+ this.workQueues.push([]);
+ }
+ }
+
+
+ private registerRpcId(resolve: any, reject: any,
+ workerIndex: number): number {
+ let id = this.nextRpcId++;
+ this.rpcRegistry[id] = {resolve, reject, workerIndex};
+ return id;
+ }
+
+
+ private doRpc<T>(operation: string, priority: number,
+ ...args: any[]): Promise<T> {
+ if (this.numBusy == this.workers.length) {
+ let q = this.workQueues[priority];
+ if (!q) {
+ throw Error("assertion failed");
+ }
+ return new Promise<T>((resolve, reject) => {
+ this.workQueues[priority].push({operation, args, resolve, reject});
+ });
+ }
+
+ for (let i = 0; i < this.workers.length; i++) {
+ let ws = this.workers[i];
+ if (ws.busy) {
+ continue;
+ }
+
+ ws.busy = true;
+ this.numBusy++;
+
+ return new Promise<T>((resolve, reject) => {
+ let msg: any = {
+ operation, args,
+ id: this.registerRpcId(resolve, reject, i),
+ };
+ ws.w.postMessage(msg);
+ });
+ }
+
+ throw Error("assertion failed");
+ }
+
+
+ createPreCoin(denom: Denomination, reserve: Reserve): Promise<PreCoin> {
+ return this.doRpc("createPreCoin", 1, denom, reserve);
+ }
+
+ hashString(str: string): Promise<string> {
+ return this.doRpc("hashString", 1, str);
+ }
+
+ hashRsaPub(rsaPub: string): Promise<string> {
+ return this.doRpc("hashRsaPub", 2, rsaPub);
+ }
+
+ 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);
+ }
+}
diff --git a/lib/wallet/cryptoLib.ts b/lib/wallet/cryptoLib.ts
new file mode 100644
index 000000000..9a77b3d74
--- /dev/null
+++ b/lib/wallet/cryptoLib.ts
@@ -0,0 +1,227 @@
+/*
+ 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, Reserve, PayCoinInfo} from "./types";
+import create = chrome.alarms.create;
+import {Offer} from "./wallet";
+import {CoinWithDenom} from "./wallet";
+import {CoinPaySig} from "./types";
+import {Denomination} from "./types";
+import {Amount} 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: Reserve): 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.ByteArray = native.rsaBlind(pubHash,
+ blindingFactor,
+ denomPub);
+
+ 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 hashString(str: string): string {
+ const b = native.ByteArray.fromString(str);
+ return b.hash().toCrock();
+ }
+
+
+ export function hashRsaPub(rsaPub: string): string {
+ return native.RsaPublicKey.fromCrock(rsaPub)
+ .encode()
+ .hash()
+ .toCrock();
+ }
+
+
+ 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();
+
+ 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;
+ }
+}
diff --git a/lib/wallet/cryptoWorker.ts b/lib/wallet/cryptoWorker.ts
new file mode 100644
index 000000000..4483c64e6
--- /dev/null
+++ b/lib/wallet/cryptoWorker.ts
@@ -0,0 +1,61 @@
+/*
+ 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("../emscripten/libwrapper.js",
+ "../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,
+ });
+
+// 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});
+ let modName = System.normalizeSync("../emscripten/emsc");
+ console.log("registering", modName);
+ System.set(modName, mod);
+}
+
+System.import("./cryptoLib")
+ .then((m) => {
+ m.main(self);
+ })
+ .catch((e) => {
+ console.log("crypto worker failed");
+ console.error(e.stack);
+ }); \ No newline at end of file
diff --git a/lib/wallet/db.ts b/lib/wallet/db.ts
new file mode 100644
index 000000000..5104f28fb
--- /dev/null
+++ b/lib/wallet/db.ts
@@ -0,0 +1,119 @@
+/*
+ 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 Dictionary = _.Dictionary;
+
+/**
+ * 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 = 7;
+
+/**
+ * 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
+ const exchanges = db.createObjectStore("exchanges",
+ {keyPath: "baseUrl"});
+ exchanges.createIndex("pubKey", "masterPublicKey");
+ db.createObjectStore("reserves", {keyPath: "reserve_pub"});
+ const coins = db.createObjectStore("coins", {keyPath: "coinPub"});
+ coins.createIndex("exchangeBaseUrl", "exchangeBaseUrl");
+ const transactions = db.createObjectStore("transactions",
+ {keyPath: "contractHash"});
+ transactions.createIndex("repurchase",
+ [
+ "contract.merchant_pub",
+ "contract.repurchase_correlation_id"
+ ]);
+
+ db.createObjectStore("precoins",
+ {keyPath: "coinPub", autoIncrement: true});
+ const history = db.createObjectStore("history",
+ {
+ keyPath: "id",
+ autoIncrement: true
+ });
+ history.createIndex("timestamp", "timestamp");
+ break;
+ default:
+ if (e.oldVersion != DB_VERSION) {
+ window.alert("Incompatible wallet dababase version, please reset" +
+ " db.");
+ chrome.browserAction.setBadgeText({text: "R!"});
+ 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 Dictionary<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 Dictionary<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);
+} \ No newline at end of file
diff --git a/lib/wallet/emscriptif.ts b/lib/wallet/emscriptif.ts
new file mode 100644
index 000000000..5879300e7
--- /dev/null
+++ b/lib/wallet/emscriptif.ts
@@ -0,0 +1,1033 @@
+/*
+ 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 * as EmscWrapper from "../emscripten/emsc";
+
+/**
+ * High-level interface to emscripten-compiled modules used
+ * by the wallet.
+ * @module EmscriptIf
+ * @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;
+
+let Module = EmscWrapper.Module;
+
+let getEmsc: EmscWrapper.EmscFunGen = (...args: any[]) => Module.cwrap.apply(
+ null,
+ args);
+
+var 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']),
+};
+
+var emscAlloc = {
+ get_amount: getEmsc('TALER_WRALL_get_amount',
+ 'number',
+ ['number', 'number', 'number', 'string']),
+ eddsa_key_create: getEmsc('GNUNET_CRYPTO_eddsa_key_create',
+ 'number', []),
+ eddsa_public_key_from_private: getEmsc(
+ 'TALER_WRALL_eddsa_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']),
+ 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']),
+ malloc: (size: number) => Module._malloc(size),
+};
+
+
+export enum SignaturePurpose {
+ RESERVE_WITHDRAW = 1200,
+ WALLET_COIN_DEPOSIT = 1201,
+ MASTER_DENOMINATION_KEY_VALIDITY = 1025,
+}
+
+enum RandomQuality {
+ WEAK = 0,
+ STRONG = 1,
+ NONCE = 2
+}
+
+
+abstract class ArenaObject {
+ protected _nativePtr: number | undefined = undefined;
+ arena: Arena;
+
+ abstract destroy(): void;
+
+ 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;
+ }
+
+ getNative(): number {
+ // 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;
+ }
+
+ free() {
+ if (this.nativePtr) {
+ emsc.free(this.nativePtr);
+ this._nativePtr = undefined;
+ }
+ }
+
+ alloc(size: number) {
+ if (this._nativePtr !== undefined) {
+ throw Error("Double allocation");
+ }
+ this.nativePtr = emscAlloc.malloc(size);
+ }
+
+ setNative(n: number) {
+ if (n === undefined) {
+ throw Error("Native pointer must be a number or null");
+ }
+ this._nativePtr = n;
+ }
+
+ set nativePtr(v: number) {
+ this.setNative(v);
+ }
+
+ get nativePtr() {
+ return this.getNative();
+ }
+}
+
+
+interface Arena {
+ put(obj: ArenaObject): void;
+ destroy(): void;
+}
+
+
+class DefaultArena 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 = []
+ }
+}
+
+
+function mySetTimeout(ms: number, fn: () => void) {
+ // We need to use different timeouts, depending on whether
+ // we run in node or a web extension
+ if ("function" === typeof setTimeout) {
+ setTimeout(fn, ms);
+ } else {
+ chrome.extension.getBackgroundPage().setTimeout(fn, ms);
+ }
+}
+
+
+/**
+ * Arena that destroys all its objects once control has returned to the message
+ * loop and a small interval has passed.
+ */
+class SyncArena extends DefaultArena {
+ private isScheduled: boolean;
+
+ constructor() {
+ super();
+ }
+
+ pub(obj: ArenaObject) {
+ super.put(obj);
+ if (!this.isScheduled) {
+ this.schedule();
+ }
+ this.heap.push(obj);
+ }
+
+ destroy() {
+ super.destroy();
+ }
+
+ private schedule() {
+ this.isScheduled = true;
+ mySetTimeout(50, () => {
+ this.isScheduled = false;
+ this.destroy();
+ });
+ }
+}
+
+let arenaStack: Arena[] = [];
+arenaStack.push(new SyncArena());
+
+
+export class Amount extends ArenaObject {
+ 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, "");
+ }
+ }
+
+ destroy() {
+ super.free();
+ }
+
+
+ static getZero(currency: string, a?: Arena): Amount {
+ let am = new Amount(undefined, a);
+ let r = emsc.amount_get_zero(currency, am.getNative());
+ 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 countBytes(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.
+ * Should contain only data, not pointers.
+ */
+abstract class PackedArenaObject extends ArenaObject {
+ 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.fromString(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());
+ }
+ }
+
+ destroy() {
+ emsc.free(this.nativePtr);
+ this.nativePtr = 0;
+ }
+
+ 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.getNative() + 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 DefaultArena();
+ 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);
+
+
+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);
+
+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.setNative(decodeFn(buf.getNative(),
+ 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 fromString(s: string, a?: Arena): ByteArray {
+ // UTF-8 bytes, including 0-terminator
+ let terminatedByteLength = countBytes(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 = countBytes(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]
+ ];
+ }
+}
+
+
+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.getNative(), 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);
+ }
+
+}
+
+
+export class UInt64 extends PackedArenaObject {
+ static fromNumber(n: number): UInt64 {
+ let x = new UInt64();
+ x.alloc();
+ set64(x.getNative(), n);
+ return x;
+ }
+
+ size() {
+ return 8;
+ }
+}
+
+
+// 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.getNative(), ptr);
+ let res = new ByteArray(len, undefined, arena);
+ res.setNative(Module.getValue(ptr, '*'));
+ emsc.free(ptr);
+ return res;
+ }
+
+ return encode;
+}
+
+
+export class RsaPublicKey extends ArenaObject 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 ArenaObject implements Encodeable {
+ static fromCrock: (s: string, a?: Arena) => RsaSignature;
+
+ encode: (arena?: Arena) => ByteArray;
+
+ destroy() {
+ emsc.rsa_signature_free(this.getNative());
+ this.setNative(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 {
+ let ptr = emscAlloc.malloc(PTR_SIZE);
+ let s = emscAlloc.rsa_blind(hashCode.nativePtr,
+ blindingKey.nativePtr,
+ pkey.nativePtr,
+ ptr);
+ return new ByteArray(s, Module.getValue(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);
+ if (r === GNUNET_OK) {
+ return true;
+ }
+ return false;
+}
+
+
+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;
+}
diff --git a/lib/wallet/helpers.ts b/lib/wallet/helpers.ts
new file mode 100644
index 000000000..5d231fe64
--- /dev/null
+++ b/lib/wallet/helpers.ts
@@ -0,0 +1,67 @@
+/*
+ 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
+ */
+
+import {AmountJson} from "./types";
+
+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 = 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]
+ }
+}
diff --git a/lib/wallet/http.ts b/lib/wallet/http.ts
new file mode 100644
index 000000000..8f82ceaff
--- /dev/null
+++ b/lib/wallet/http.ts
@@ -0,0 +1,84 @@
+/*
+ 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 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/lib/wallet/query.ts b/lib/wallet/query.ts
new file mode 100644
index 000000000..c7420a3f7
--- /dev/null
+++ b/lib/wallet/query.ts
@@ -0,0 +1,415 @@
+/*
+ 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 function Query(db: IDBDatabase) {
+ return new QueryRoot(db);
+}
+
+/**
+ * Stream that can be filtered, reduced or joined
+ * with indices.
+ */
+export interface QueryStream<T> {
+ indexJoin<S>(storeName: string,
+ indexName: string,
+ keyFn: (obj: any) => any): QueryStream<[T,S]>;
+ filter(f: (x: any) => boolean): QueryStream<T>;
+ reduce<S>(f: (v: T, acc: S) => S, start?: S): Promise<S>;
+ flatMap(f: (x: T) => T[]): QueryStream<T>;
+}
+
+
+/**
+ * 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> {
+ abstract subscribe(f: (isDone: boolean,
+ value: any,
+ tx: IDBTransaction) => void): void;
+
+ root: QueryRoot;
+
+ constructor(root: QueryRoot) {
+ this.root = root;
+ }
+
+ flatMap(f: (x: T) => T[]): QueryStream<T> {
+ return new QueryStreamFlatMap(this, f);
+ }
+
+ indexJoin<S>(storeName: string,
+ indexName: string,
+ key: any): QueryStream<[T,S]> {
+ this.root.addStoreAccess(storeName, false);
+ return new QueryStreamIndexJoin(this, storeName, indexName, key);
+ }
+
+ filter(f: (x: any) => boolean): QueryStream<T> {
+ return new QueryStreamFilter(this, f);
+ }
+
+ 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> extends QueryStreamBase<T> {
+ s: QueryStreamBase<T>;
+ flatMapFn: (v: T) => T[];
+
+ constructor(s: QueryStreamBase<T>, flatMapFn: (v: T) => T[]) {
+ 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 QueryStreamIndexJoin<T,S> extends QueryStreamBase<[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, [value, 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);
+ }
+}
+
+
+class QueryRoot {
+ 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;
+
+ constructor(db: IDBDatabase) {
+ this.db = db;
+ }
+
+ iter<T>(storeName: string,
+ {only = <string|undefined>undefined, indexName = <string|undefined>undefined} = {}): QueryStream<T> {
+ this.stores.add(storeName);
+ return new IterQueryStream(this, storeName, {only, indexName});
+ }
+
+ /**
+ * Put an object into the given object store.
+ * Overrides if an existing object with the same key exists
+ * in the store.
+ */
+ put(storeName: string, val: any): QueryRoot {
+ let doPut = (tx: IDBTransaction) => {
+ tx.objectStore(storeName).put(val);
+ };
+ this.addWork(doPut, storeName, 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(storeName: string, iterable: any[]): QueryRoot {
+ const doPutAll = (tx: IDBTransaction) => {
+ for (const obj of iterable) {
+ tx.objectStore(storeName).put(obj);
+ }
+ };
+ this.addWork(doPutAll, storeName, 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(storeName: string, val: any): QueryRoot {
+ const doAdd = (tx: IDBTransaction) => {
+ tx.objectStore(storeName).add(val);
+ };
+ this.addWork(doAdd, storeName, true);
+ return this;
+ }
+
+ /**
+ * Get one object from a store by its key.
+ */
+ get(storeName: any, key: any): Promise<any> {
+ if (key === void 0) {
+ throw Error("key must not be undefined");
+ }
+
+ const {resolve, promise} = openPromise();
+
+ const doGet = (tx: IDBTransaction) => {
+ const req = tx.objectStore(storeName).get(key);
+ req.onsuccess = () => {
+ resolve(req.result);
+ };
+ };
+
+ this.addWork(doGet, storeName, false);
+ return Promise.resolve()
+ .then(() => this.finish())
+ .then(() => promise);
+ }
+
+ /**
+ * Get one object from a store by its key.
+ */
+ getIndexed(storeName: string, indexName: string, key: any): Promise<any> {
+ if (key === void 0) {
+ throw Error("key must not be undefined");
+ }
+
+ const {resolve, promise} = openPromise();
+
+ const doGetIndexed = (tx: IDBTransaction) => {
+ const req = tx.objectStore(storeName).index(indexName).get(key);
+ req.onsuccess = () => {
+ resolve(req.result);
+ };
+ };
+
+ this.addWork(doGetIndexed, storeName, false);
+ return Promise.resolve()
+ .then(() => this.finish())
+ .then(() => promise);
+ }
+
+ /**
+ * 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();
+ };
+ 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.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;
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/wallet/renderHtml.ts b/lib/wallet/renderHtml.ts
new file mode 100644
index 000000000..6d9823d71
--- /dev/null
+++ b/lib/wallet/renderHtml.ts
@@ -0,0 +1,49 @@
+/*
+ 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): any {
+ let merchantName = m("strong", contract.merchant.name);
+ let amount = m("strong", prettyAmount(contract.amount));
+
+ return m("div", {}, [
+ m("p",
+ i18n.parts`${merchantName}
+ wants to enter a contract over ${amount}
+ with you.`),
+ m("p",
+ i18n`You are about to purchase:`),
+ m('ul',
+
+ contract.products.map(
+ (p: any) => m("li",
+ `${p.description}: ${prettyAmount(p.price)}`)))
+ ]);
+} \ No newline at end of file
diff --git a/lib/wallet/types.ts b/lib/wallet/types.ts
new file mode 100644
index 000000000..e8b7a1e39
--- /dev/null
+++ b/lib/wallet/types.ts
@@ -0,0 +1,396 @@
+/*
+ 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;
+}
+
+
+@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 Reserve {
+ exchange_base_url: string
+ reserve_priv: string;
+ reserve_pub: string;
+}
+
+
+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;
+}
+
+
+@Checkable.Class
+export class ExchangeHandle {
+ @Checkable.String
+ master_pub: string;
+
+ @Checkable.String
+ url: string;
+
+ static checked: (obj: any) => ExchangeHandle;
+}
+
+
+@Checkable.Class
+export class Contract {
+ @Checkable.String
+ H_wire: string;
+
+ @Checkable.Value(AmountJson)
+ amount: AmountJson;
+
+ @Checkable.List(Checkable.AnyObject)
+ auditors: any[];
+
+ @Checkable.String
+ expiry: 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;
+
+ @Checkable.Optional(Checkable.String)
+ receiver: 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;
+ }
+
+ 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, b: AmountJson): Result {
+ if (a.currency !== b.currency) {
+ throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`);
+ }
+ let currency = a.currency;
+ let value = a.value;
+ let fraction = a.fraction;
+ 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/lib/wallet/wallet.ts b/lib/wallet/wallet.ts
new file mode 100644
index 000000000..45d083570
--- /dev/null
+++ b/lib/wallet/wallet.ts
@@ -0,0 +1,1156 @@
+/*
+ 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,
+ CreateReserveResponse,
+ IExchangeInfo,
+ Denomination,
+ Notifier,
+ WireInfo
+} from "./types";
+import {HttpResponse, RequestException} from "./http";
+import {Query} from "./query";
+import {Checkable} from "./checkable";
+import {canonicalizeBaseUrl} from "./helpers";
+import {ReserveCreationInfo, Amounts} from "./types";
+import {PreCoin} from "./types";
+import {Reserve} from "./types";
+import {CryptoApi} from "./cryptoApi";
+import {Coin} from "./types";
+import {PayCoinInfo} from "./types";
+import {CheckRepurchaseResult} from "./types";
+import {Contract} from "./types";
+import {ExchangeHandle} from "./types";
+
+"use strict";
+
+export interface CoinWithDenom {
+ coin: Coin;
+ denom: Denomination;
+}
+
+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,
+ /**
+ * Amount we've already withdrawn from the reserve.
+ */
+ withdrawn_amount: AmountJson;
+ confirmed: boolean,
+}
+
+
+@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;
+
+ static checked: (obj: any) => Offer;
+}
+
+export interface HistoryRecord {
+ type: string;
+ timestamp: number;
+ subjectId?: string;
+ detail: any;
+ level: HistoryLevel;
+}
+
+
+interface ExchangeCoins {
+ [exchangeUrl: string]: CoinWithDenom[];
+}
+
+
+interface Transaction {
+ contractHash: string;
+ contract: Contract;
+ payReq: any;
+ merchantSig: string;
+}
+
+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;
+}
+
+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 + "}";
+}
+
+
+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]));
+}
+
+
+function flatMap<T, U>(xs: T[], f: (x: T) => U[]): U[] {
+ return xs.reduce((acc: U[], next: T) => [...f(next), ...acc], []);
+}
+
+
+function getTalerStampSec(stamp: string): number|null {
+ const m = stamp.match(/\/?Date\(([0-9]*)\)\/?/);
+ if (!m) {
+ return null;
+ }
+ return parseInt(m[1]);
+}
+
+
+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);
+ // Withdraw if still possible to withdraw within a minute
+ if (stamp_withdraw_sec + 60 > now_sec) {
+ return true;
+ }
+ return false;
+}
+
+
+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>;
+}
+
+
+function copy(o: any) {
+ return JSON.parse(JSON.stringify(o));
+}
+
+/**
+ * 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 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();
+
+ 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();
+ }
+ }
+
+ updateExchanges(): void {
+ console.log("updating exchanges");
+
+ Query(this.db)
+ .iter("exchanges")
+ .reduce((exchange: IExchangeInfo) => {
+ this.updateExchangeFromUrl(exchange.baseUrl)
+ .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");
+
+ Query(this.db)
+ .iter("reserves")
+ .reduce((reserve: any) => {
+ console.log("resuming reserve", reserve.reserve_pub);
+ this.processReserve(reserve);
+ });
+
+ Query(this.db)
+ .iter("precoins")
+ .reduce((preCoin: any) => {
+ console.log("resuming precoin");
+ this.processPreCoin(preCoin);
+ });
+ }
+
+
+ /**
+ * 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: any, url: string) {
+ let exchange: IExchangeInfo = mc[0];
+ console.log("got coin for exchange", url);
+ let coin: Coin = mc[1];
+ 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 [
+ Query(this.db)
+ .iter("exchanges", {indexName: "pubKey", only: info.master_pub})
+ .indexJoin("coins", "exchangeBaseUrl", (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.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: any = {};
+ payReq["amount"] = offer.contract.amount;
+ payReq["coins"] = payCoinInfo.map((x) => x.sig);
+ payReq["H_contract"] = offer.H_contract;
+ payReq["max_fee"] = offer.contract.max_fee;
+ payReq["merchant_sig"] = offer.merchant_sig;
+ payReq["exchange"] = URI(chosenExchange).href();
+ payReq["refund_deadline"] = offer.contract.refund_deadline;
+ payReq["timestamp"] = offer.contract.timestamp;
+ payReq["transaction_id"] = offer.contract.transaction_id;
+ let t: Transaction = {
+ contractHash: offer.H_contract,
+ contract: offer.contract,
+ payReq: payReq,
+ merchantSig: offer.merchant_sig,
+ };
+
+ let historyEntry = {
+ 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
+ }
+ };
+
+ await Query(this.db)
+ .put("transactions", t)
+ .put("history", historyEntry)
+ .putAll("coins", payCoinInfo.map((pci) => pci.updatedCoin))
+ .finish();
+
+ this.notifier.notify();
+ }
+
+
+ async putHistory(historyEntry: HistoryRecord): Promise<void> {
+ await Query(this.db).put("history", historyEntry).finish();
+ this.notifier.notify();
+ }
+
+
+ /**
+ * 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 Query(this.db)
+ .get("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
+ Query(this.db)
+ .get("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 Query(this.db)
+ .get("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 = {
+ type: "depleted-reserve",
+ subjectId: `reserve-progress-${reserveRecord.reserve_pub}`,
+ timestamp: (new Date).getTime(),
+ detail: {
+ reservePub: reserveRecord.reserve_pub,
+ requestedAmount: reserveRecord.requested_amount,
+ currentAmount: reserveRecord.current_amount,
+ }
+ };
+ await Query(this.db).put("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> {
+ try {
+ const coin = await this.withdrawExecute(preCoin);
+ this.storeCoin(coin);
+ } 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,
+ withdrawn_amount: Amounts.getZero(req.amount.currency)
+ };
+
+ const historyEntry = {
+ type: "create-reserve",
+ timestamp: now,
+ subjectId: `reserve-progress-${reserveRecord.reserve_pub}`,
+ detail: {
+ requestedAmount: req.amount,
+ reservePub: reserveRecord.reserve_pub,
+ }
+ };
+
+ await Query(this.db)
+ .put("reserves", reserveRecord)
+ .put("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 = await Query(this.db)
+ .get("reserves", req.reservePub);
+ const historyEntry = {
+ type: "confirm-reserve",
+ timestamp: now,
+ subjectId: `reserve-progress-${reserve.reserve_pub}`,
+ detail: {
+ reservePub: req.reservePub,
+ requestedAmount: reserve.requested_amount,
+ }
+ };
+ if (!reserve) {
+ console.error("Unable to confirm reserve, not found in DB");
+ return;
+ }
+ reserve.confirmed = true;
+ await Query(this.db)
+ .put("reserves", reserve)
+ .put("history", historyEntry)
+ .finish();
+
+ this.processReserve(reserve);
+ }
+
+
+ private async withdrawExecute(pc: PreCoin): Promise<Coin> {
+ let reserve = await Query(this.db)
+ .get("reserves", pc.reservePub);
+
+ 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,
+ };
+ return coin;
+ }
+
+ async storeCoin(coin: Coin): Promise<void> {
+ console.log("storing coin", new Date());
+
+ let historyEntry: HistoryRecord = {
+ type: "withdraw",
+ timestamp: (new Date).getTime(),
+ level: HistoryLevel.Expert,
+ detail: {
+ coinPub: coin.coinPub,
+ }
+ };
+ await Query(this.db)
+ .delete("precoins", coin.coinPub)
+ .add("coins", coin)
+ .add("history", historyEntry)
+ .finish();
+ this.notifier.notify();
+ }
+
+
+ /**
+ * Withdraw one coin of the given denomination from the given reserve.
+ */
+ private async withdraw(denom: Denomination, reserve: Reserve): Promise<void> {
+ console.log("creating pre coin at", new Date());
+ let preCoin = await this.cryptoApi
+ .createPreCoin(denom, reserve);
+ await Query(this.db)
+ .put("precoins", preCoin)
+ .finish();
+ await this.processPreCoin(preCoin);
+ }
+
+
+ /**
+ * Withdraw coins from a reserve until it is empty.
+ */
+ private async depleteReserve(reserve: any,
+ exchange: IExchangeInfo): Promise<number> {
+ let denomsAvailable: Denomination[] = copy(exchange.active_denoms);
+ let denomsForWithdraw = getWithdrawDenomList(reserve.current_amount,
+ denomsAvailable);
+
+ let ps = denomsForWithdraw.map((denom) => this.withdraw(denom, reserve));
+ 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<Reserve> {
+ let reserve = await Query(this.db)
+ .get("reserves", reservePub);
+ 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 Query(this.db)
+ .put("reserves", reserve)
+ .finish();
+ 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 Query(this.db)
+ .iter("coins",
+ {indexName: "exchangeBaseUrl", only: 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 = Query(this.db);
+ suspendedCoins.map((c) => {
+ console.log("suspending coin", c);
+ c.suspended = true;
+ q.put("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 Query(this.db).get("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 Query(this.db)
+ .put("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<any> {
+ function collectBalances(c: Coin, byCurrency: any) {
+ if (c.suspended) {
+ return byCurrency;
+ }
+ let acc: AmountJson = byCurrency[c.currentAmount.currency];
+ if (!acc) {
+ acc = Amounts.getZero(c.currentAmount.currency);
+ }
+ byCurrency[c.currentAmount.currency] = Amounts.add(c.currentAmount,
+ acc).amount;
+ return byCurrency;
+ }
+
+ let byCurrency = await Query(this.db)
+ .iter("coins")
+ .reduce(collectBalances, {});
+
+ return {balances: byCurrency};
+ }
+
+
+ /**
+ * 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
+ Query(this.db)
+ .iter("history", {indexName: "timestamp"})
+ .reduce(collect, []);
+
+ return {history};
+ }
+
+ async hashContract(contract: any): 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 = await Query(this.db)
+ .getIndexed("transactions",
+ "repurchase",
+ [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};
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/wallet/wxApi.ts b/lib/wallet/wxApi.ts
new file mode 100644
index 000000000..84235c6a9
--- /dev/null
+++ b/lib/wallet/wxApi.ts
@@ -0,0 +1,41 @@
+/*
+ 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} from "./types";
+import {ReserveCreationInfo} 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);
+ });
+ });
+}
diff --git a/lib/wallet/wxMessaging.ts b/lib/wallet/wxMessaging.ts
new file mode 100644
index 000000000..5c97248c4
--- /dev/null
+++ b/lib/wallet/wxMessaging.ts
@@ -0,0 +1,392 @@
+/*
+ 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);
+ },
+ ["ping"]: 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);
+ },
+ ["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);
+ },
+ ["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();
+ },
+ ["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();
+ },
+ };
+}
+
+
+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() {
+ console.log("notifying all ports");
+ 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: "lib/vendor/URI.js"});
+ chrome.tabs.executeScript(tab.id, {file: "lib/taler-wallet-lib.js"});
+ chrome.tabs.executeScript(tab.id, {file: "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);
+ });
+} \ No newline at end of file