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