From e1369ff7e8fc02116b9c4261036f0e42e3423cf4 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 2 Dec 2019 00:42:40 +0100 Subject: the giant refactoring: split wallet into multiple parts --- src/amounts.ts | 340 ---- src/android/index.ts | 6 +- src/checkable.ts | 417 ---- src/crypto/cryptoApi.ts | 12 +- src/crypto/cryptoImplementation.ts | 25 +- src/db.ts | 10 +- src/dbTypes.ts | 116 +- src/headless/bank.ts | 11 +- src/headless/helpers.ts | 10 +- src/headless/merchant.ts | 13 +- src/headless/taler-wallet-cli.ts | 7 +- src/helpers-test.ts | 38 - src/helpers.ts | 203 -- src/http.ts | 109 -- src/libtoolVersion-test.ts | 30 - src/libtoolVersion.ts | 86 - src/logging.ts | 25 - src/promiseUtils.ts | 39 - src/query.ts | 446 ----- src/talerTypes.ts | 6 +- src/taleruri-test.ts | 206 -- src/taleruri.ts | 218 --- src/timer.ts | 145 -- src/types-test.ts | 78 +- src/util/amounts.ts | 340 ++++ src/util/assertUnreachable.ts | 19 + src/util/asyncMemo.ts | 52 + src/util/checkable.ts | 417 ++++ src/util/helpers-test.ts | 38 + src/util/helpers.ts | 204 ++ src/util/http.ts | 109 ++ src/util/libtoolVersion-test.ts | 30 + src/util/libtoolVersion.ts | 86 + src/util/logging.ts | 25 + src/util/payto-test.ts | 31 + src/util/payto.ts | 54 + src/util/promiseUtils.ts | 39 + src/util/query.ts | 446 +++++ src/util/taleruri-test.ts | 230 +++ src/util/taleruri.ts | 200 ++ src/util/timer.ts | 145 ++ src/util/wire.ts | 53 + src/wallet-impl/balance.ts | 144 ++ src/wallet-impl/exchanges.ts | 401 ++++ src/wallet-impl/history.ts | 172 ++ src/wallet-impl/pay.ts | 822 ++++++++ src/wallet-impl/payback.ts | 88 + src/wallet-impl/pending.ts | 208 ++ src/wallet-impl/refresh.ts | 416 ++++ src/wallet-impl/refund.ts | 245 +++ src/wallet-impl/reserves.ts | 567 ++++++ src/wallet-impl/return.ts | 274 +++ src/wallet-impl/state.ts | 32 + src/wallet-impl/tip.ts | 246 +++ src/wallet-impl/withdraw.ts | 577 ++++++ src/wallet-test.ts | 44 +- src/wallet.ts | 3815 ++---------------------------------- src/walletTypes.ts | 36 +- src/webex/messages.ts | 14 +- src/webex/notify.ts | 28 +- src/webex/pages/add-auditor.tsx | 27 +- src/webex/pages/pay.tsx | 13 +- src/webex/pages/popup.tsx | 20 +- src/webex/pages/refund.tsx | 6 +- src/webex/pages/return-coins.tsx | 6 +- src/webex/pages/tip.tsx | 10 +- src/webex/pages/withdraw.tsx | 6 +- src/webex/renderHtml.tsx | 4 +- src/webex/wxApi.ts | 25 +- src/webex/wxBackend.ts | 60 +- src/wire.ts | 53 - 71 files changed, 7099 insertions(+), 6374 deletions(-) delete mode 100644 src/amounts.ts delete mode 100644 src/checkable.ts delete mode 100644 src/helpers-test.ts delete mode 100644 src/helpers.ts delete mode 100644 src/http.ts delete mode 100644 src/libtoolVersion-test.ts delete mode 100644 src/libtoolVersion.ts delete mode 100644 src/logging.ts delete mode 100644 src/promiseUtils.ts delete mode 100644 src/query.ts delete mode 100644 src/taleruri-test.ts delete mode 100644 src/taleruri.ts delete mode 100644 src/timer.ts create mode 100644 src/util/amounts.ts create mode 100644 src/util/assertUnreachable.ts create mode 100644 src/util/asyncMemo.ts create mode 100644 src/util/checkable.ts create mode 100644 src/util/helpers-test.ts create mode 100644 src/util/helpers.ts create mode 100644 src/util/http.ts create mode 100644 src/util/libtoolVersion-test.ts create mode 100644 src/util/libtoolVersion.ts create mode 100644 src/util/logging.ts create mode 100644 src/util/payto-test.ts create mode 100644 src/util/payto.ts create mode 100644 src/util/promiseUtils.ts create mode 100644 src/util/query.ts create mode 100644 src/util/taleruri-test.ts create mode 100644 src/util/taleruri.ts create mode 100644 src/util/timer.ts create mode 100644 src/util/wire.ts create mode 100644 src/wallet-impl/balance.ts create mode 100644 src/wallet-impl/exchanges.ts create mode 100644 src/wallet-impl/history.ts create mode 100644 src/wallet-impl/pay.ts create mode 100644 src/wallet-impl/payback.ts create mode 100644 src/wallet-impl/pending.ts create mode 100644 src/wallet-impl/refresh.ts create mode 100644 src/wallet-impl/refund.ts create mode 100644 src/wallet-impl/reserves.ts create mode 100644 src/wallet-impl/return.ts create mode 100644 src/wallet-impl/state.ts create mode 100644 src/wallet-impl/tip.ts create mode 100644 src/wallet-impl/withdraw.ts delete mode 100644 src/wire.ts (limited to 'src') diff --git a/src/amounts.ts b/src/amounts.ts deleted file mode 100644 index b90d54a31..000000000 --- a/src/amounts.ts +++ /dev/null @@ -1,340 +0,0 @@ -/* - This file is part of TALER - (C) 2018 GNUnet e.V. and 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 - */ - - -/** - * Types and helper functions for dealing with Taler amounts. - */ - - -/** - * Imports. - */ -import { Checkable } from "./checkable"; - - -/** - * Number of fractional units that one value unit represents. - */ -export const fractionalBase = 1e8; - -/** - * How many digits behind the comma are required to represent the - * fractional value in human readable decimal format? Must match - * lg(fractionalBase) - */ -export const fractionalLength = 8; - -/** - * Maximum allowed value field of an amount. - */ -export const maxAmountValue = 2 ** 52; - - -/** - * Non-negative financial amount. Fractional values are expressed as multiples - * of 1e-8. - */ -@Checkable.Class() -export class AmountJson { - /** - * Value, must be an integer. - */ - @Checkable.Number() - readonly value: number; - - /** - * Fraction, must be an integer. Represent 1/1e8 of a unit. - */ - @Checkable.Number() - readonly fraction: number; - - /** - * Currency of the amount. - */ - @Checkable.String() - readonly currency: string; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => AmountJson; -} - - -/** - * Result of a possibly overflowing operation. - */ -export interface Result { - /** - * Resulting, possibly saturated amount. - */ - amount: AmountJson; - /** - * Was there an over-/underflow? - */ - saturated: boolean; -} - - -/** - * Get an amount that represents zero units of a currency. - */ -export function getZero(currency: string): AmountJson { - return { - currency, - fraction: 0, - value: 0, - }; -} - - -export function sum(amounts: AmountJson[]) { - if (amounts.length <= 0) { - throw Error("can't sum zero amounts"); - } - return add(amounts[0], ...amounts.slice(1)); -} - - -/** - * Add two amounts. Return the result and whether - * the addition overflowed. The overflow is always handled - * by saturating and never by wrapping. - * - * Throws when currencies don't match. - */ -export function add(first: AmountJson, ...rest: AmountJson[]): Result { - const currency = first.currency; - let value = first.value + Math.floor(first.fraction / fractionalBase); - if (value > maxAmountValue) { - return { - amount: { currency, value: maxAmountValue, fraction: fractionalBase - 1 }, - saturated: true - }; - } - let fraction = first.fraction % fractionalBase; - for (const x of rest) { - if (x.currency !== currency) { - throw Error(`Mismatched currency: ${x.currency} and ${currency}`); - } - - value = value + x.value + Math.floor((fraction + x.fraction) / fractionalBase); - fraction = Math.floor((fraction + x.fraction) % fractionalBase); - if (value > maxAmountValue) { - return { - amount: { currency, value: maxAmountValue, fraction: fractionalBase - 1 }, - saturated: true - }; - } - } - return { amount: { currency, value, fraction }, saturated: false }; -} - - -/** - * Subtract two amounts. Return the result and whether - * the subtraction overflowed. The overflow is always handled - * by saturating and never by wrapping. - * - * Throws when currencies don't match. - */ -export function sub(a: AmountJson, ...rest: AmountJson[]): Result { - const currency = a.currency; - let value = a.value; - let fraction = a.fraction; - - for (const 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 += fractionalBase; - } - 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 }; -} - - -/** - * Compare two amounts. Returns 0 when equal, -1 when a < b - * and +1 when a > b. Throws when currencies don't match. - */ -export function cmp(a: AmountJson, b: AmountJson): number { - if (a.currency !== b.currency) { - throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`); - } - const av = a.value + Math.floor(a.fraction / fractionalBase); - const af = a.fraction % fractionalBase; - const bv = b.value + Math.floor(b.fraction / fractionalBase); - const bf = b.fraction % fractionalBase; - 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"); - } -} - - -/** - * Create a copy of an amount. - */ -export function copy(a: AmountJson): AmountJson { - return { - currency: a.currency, - fraction: a.fraction, - value: a.value, - }; -} - - -/** - * Divide an amount. Throws on division by zero. - */ -export function divide(a: AmountJson, n: number): AmountJson { - if (n === 0) { - throw Error(`Division by 0`); - } - if (n === 1) { - return {value: a.value, fraction: a.fraction, currency: a.currency}; - } - const r = a.value % n; - return { - currency: a.currency, - fraction: Math.floor(((r * fractionalBase) + a.fraction) / n), - value: Math.floor(a.value / n), - }; -} - - -/** - * Check if an amount is non-zero. - */ -export function isNonZero(a: AmountJson): boolean { - return a.value > 0 || a.fraction > 0; -} - - -/** - * Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct. - */ -export function parse(s: string): AmountJson|undefined { - const res = s.match(/^([a-zA-Z0-9_*-]+):([0-9]+)([.][0-9]+)?$/); - if (!res) { - return undefined; - } - const tail = res[3] || ".0"; - if (tail.length > fractionalLength + 1) { - return undefined; - } - let value = Number.parseInt(res[2]); - if (value > maxAmountValue) { - return undefined; - } - return { - currency: res[1], - fraction: Math.round(fractionalBase * Number.parseFloat(tail)), - value, - }; -} - - -/** - * Parse amount in standard string form (like 'EUR:20.5'), - * throw if the input is not a valid amount. - */ -export function parseOrThrow(s: string): AmountJson { - const res = parse(s); - if (!res) { - throw Error(`Can't parse amount: "${s}"`); - } - return res; -} - - -/** - * Convert a float to a Taler amount. - * Loss of precision possible. - */ -export function fromFloat(floatVal: number, currency: string) { - return { - currency, - fraction: Math.floor((floatVal - Math.floor(floatVal)) * fractionalBase), - value: Math.floor(floatVal), - }; -} - - -/** - * Convert to standard human-readable string representation that's - * also used in JSON formats. - */ -export function toString(a: AmountJson): string { - const av = a.value + Math.floor(a.fraction / fractionalBase); - const af = a.fraction % fractionalBase; - let s = av.toString() - - if (af) { - s = s + "."; - let n = af; - for (let i = 0; i < fractionalLength; i++) { - if (!n) { - break; - } - s = s + Math.floor(n / fractionalBase * 10).toString(); - n = (n * 10) % fractionalBase; - } - } - - return `${a.currency}:${s}`; -} - - -/** - * Check if the argument is a valid amount in string form. - */ -export function check(a: any): boolean { - if (typeof a !== "string") { - return false; - } - try { - const parsedAmount = parse(a); - return !!parsedAmount; - } catch { - return false; - } -} diff --git a/src/android/index.ts b/src/android/index.ts index ab0d3f7b5..6a29f7946 100644 --- a/src/android/index.ts +++ b/src/android/index.ts @@ -24,10 +24,10 @@ import { DefaultNodeWalletArgs, NodeHttpLib, } from "../headless/helpers"; -import { openPromise, OpenedPromise } from "../promiseUtils"; +import { openPromise, OpenedPromise } from "../util/promiseUtils"; import fs = require("fs"); import axios from "axios"; -import { HttpRequestLibrary, HttpResponse } from "../http"; +import { HttpRequestLibrary, HttpResponse } from "../util/http"; import querystring = require("querystring"); // @ts-ignore: special built-in module @@ -66,7 +66,7 @@ export class AndroidHttpLib implements HttpRequestLibrary { } } - postJson(url: string, body: any): Promise { + postJson(url: string, body: any): Promise { if (this.useNfcTunnel) { const myId = this.requestId++; const p = openPromise(); diff --git a/src/checkable.ts b/src/checkable.ts deleted file mode 100644 index 3c9fe5bc1..000000000 --- a/src/checkable.ts +++ /dev/null @@ -1,417 +0,0 @@ -/* - 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 - */ - - -/** - * Decorators for validating JSON objects and converting them to a typed - * object. - * - * The decorators are put onto classes, and the validation is done - * via a static method that is filled in by the annotation. - * - * Example: - * ``` - * @Checkable.Class - * class Person { - * @Checkable.String - * name: string; - * @Checkable.Number - * age: number; - * - * // Method will be implemented automatically - * static checked(obj: any): Person; - * } - * ``` - */ -export namespace Checkable { - - type Path = Array; - - interface SchemaErrorConstructor { - new (err: string): SchemaError; - } - - interface SchemaError { - name: string; - message: string; - } - - interface Prop { - propertyKey: any; - checker: any; - type?: any; - typeThunk?: () => any; - elementChecker?: any; - elementProp?: any; - keyProp?: any; - stringChecker?: (s: string) => boolean; - valueProp?: any; - optional?: boolean; - } - - interface CheckableInfo { - extraAllowed: boolean; - props: Prop[]; - } - - // tslint:disable-next-line:no-shadowed-variable - export const SchemaError = (function SchemaError(this: any, message: string) { - const that: any = this as any; - that.name = "SchemaError"; - that.message = message; - that.stack = (new Error() as any).stack; - }) as any as SchemaErrorConstructor; - - - SchemaError.prototype = new Error(); - - /** - * Classes that are checkable are annotated with this - * checkable info symbol, which contains the information necessary - * to check if they're valid. - */ - const checkableInfoSym = Symbol("checkableInfo"); - - /** - * Get the current property list for a checkable type. - */ - function getCheckableInfo(target: any): CheckableInfo { - let chk = target[checkableInfoSym] as CheckableInfo|undefined; - if (!chk) { - chk = { props: [], extraAllowed: false }; - target[checkableInfoSym] = chk; - } - return chk; - } - - - 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`); - } - if (prop.stringChecker && !prop.stringChecker(target)) { - throw new SchemaError(`string property ${path} malformed`); - } - return target; - } - - function checkBoolean(target: any, prop: Prop, path: Path): any { - if (typeof target !== "boolean") { - throw new SchemaError(`expected boolean 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++) { - const v = target[i]; - prop.elementChecker(v, prop.elementProp, path.concat([i])); - } - return target; - } - - function checkMap(target: any, prop: Prop, path: Path): any { - if (typeof target !== "object") { - throw new SchemaError(`expected object for ${path}, got ${typeof target} instead`); - } - for (const key in target) { - prop.keyProp.checker(key, prop.keyProp, path.concat([key])); - const value = target[key]; - prop.valueProp.checker(value, prop.valueProp, path.concat([key])); - } - 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; - if (prop.type) { - type = prop.type; - } else if (prop.typeThunk) { - type = prop.typeThunk(); - if (!type) { - throw Error(`assertion failed: typeThunk returned null (prop is ${JSON.stringify(prop)})`); - } - } else { - throw Error(`assertion failed: type/typeThunk missing (prop is ${JSON.stringify(prop)})`); - } - const typeName = type.name || "??"; - const v = target; - if (!v || typeof v !== "object") { - throw new SchemaError( - `expected object for ${path.join(".")}, got ${typeof v} instead`); - } - const chk = type.prototype[checkableInfoSym]; - const props = chk.props; - const remainingPropNames = new Set(Object.getOwnPropertyNames(v)); - const obj = new type(); - for (const innerProp of props) { - if (!remainingPropNames.has(innerProp.propertyKey)) { - if (innerProp.optional) { - continue; - } - throw new SchemaError(`Property '${innerProp.propertyKey}' missing on '${path}' of '${typeName}'`); - } - if (!remainingPropNames.delete(innerProp.propertyKey)) { - throw new SchemaError("assertion failed"); - } - const propVal = v[innerProp.propertyKey]; - obj[innerProp.propertyKey] = innerProp.checker(propVal, - innerProp, - path.concat([innerProp.propertyKey])); - } - - if (!chk.extraAllowed && remainingPropNames.size !== 0) { - const err = `superfluous properties ${JSON.stringify(Array.from(remainingPropNames.values()))} of ${typeName}`; - throw new SchemaError(err); - } - return obj; - } - - - /** - * Class with checkable annotations on fields. - * This annotation adds the implementation of the `checked` - * static method. - */ - export function Class(opts: {extra?: boolean, validate?: boolean} = {}) { - return (target: any) => { - const chk = getCheckableInfo(target.prototype); - chk.extraAllowed = !!opts.extra; - target.checked = (v: any) => { - const cv = checkValue(v, { - checker: checkValue, - propertyKey: "(root)", - type: target, - }, ["(root)"]); - if (opts.validate) { - if (typeof target.validate !== "function") { - throw Error("invalid Checkable annotion: validate method required"); - } - // May throw exception - target.validate(cv); - } - return cv; - }; - return target; - }; - } - - - /** - * Target property must be a Checkable object of the given type. - */ - export function Value(typeThunk: () => any) { - function deco(target: object, propertyKey: string | symbol): void { - const chk = getCheckableInfo(target); - chk.props.push({ - checker: checkValue, - propertyKey, - typeThunk, - }); - } - - return deco; - } - - - /** - * List of values that match the given annotation. For example, `@Checkable.List(Checkable.String)` is - * an annotation for a list of strings. - */ - export function List(type: any) { - const stub = {}; - type(stub, "(list-element)"); - const elementProp = getCheckableInfo(stub).props[0]; - const elementChecker = elementProp.checker; - if (!elementChecker) { - throw Error("assertion failed"); - } - function deco(target: object, propertyKey: string | symbol): void { - const chk = getCheckableInfo(target); - chk.props.push({ - checker: checkList, - elementChecker, - elementProp, - propertyKey, - }); - } - - return deco; - } - - - /** - * Map from the key type to value type. Takes two annotations, - * one for the key type and one for the value type. - */ - export function Map(keyType: any, valueType: any) { - const keyStub = {}; - keyType(keyStub, "(map-key)"); - const keyProp = getCheckableInfo(keyStub).props[0]; - if (!keyProp) { - throw Error("assertion failed"); - } - const valueStub = {}; - valueType(valueStub, "(map-value)"); - const valueProp = getCheckableInfo(valueStub).props[0]; - if (!valueProp) { - throw Error("assertion failed"); - } - function deco(target: object, propertyKey: string | symbol): void { - const chk = getCheckableInfo(target); - chk.props.push({ - checker: checkMap, - keyProp, - propertyKey, - valueProp, - }); - } - - return deco; - } - - - /** - * Makes another annotation optional, for example `@Checkable.Optional(Checkable.Number)`. - */ - export function Optional(type: (target: object, propertyKey: string | symbol) => void | any) { - const stub = {}; - type(stub, "(optional-element)"); - const elementProp = getCheckableInfo(stub).props[0]; - const elementChecker = elementProp.checker; - if (!elementChecker) { - throw Error("assertion failed"); - } - function deco(target: object, propertyKey: string | symbol): void { - const chk = getCheckableInfo(target); - chk.props.push({ - checker: checkOptional, - elementChecker, - elementProp, - optional: true, - propertyKey, - }); - } - - return deco; - } - - - /** - * Target property must be a number. - */ - export function Number(): (target: object, propertyKey: string | symbol) => void { - const deco = (target: object, propertyKey: string | symbol) => { - const chk = getCheckableInfo(target); - chk.props.push({checker: checkNumber, propertyKey}); - }; - return deco; - } - - - /** - * Target property must be an arbitary object. - */ - export function AnyObject(): (target: object, propertyKey: string | symbol) => void { - const deco = (target: object, propertyKey: string | symbol) => { - const chk = getCheckableInfo(target); - chk.props.push({ - checker: checkAnyObject, - propertyKey, - }); - }; - return deco; - } - - - /** - * Target property can be anything. - * - * Not useful by itself, but in combination with higher-order annotations - * such as List or Map. - */ - export function Any(): (target: object, propertyKey: string | symbol) => void { - const deco = (target: object, propertyKey: string | symbol) => { - const chk = getCheckableInfo(target); - chk.props.push({ - checker: checkAny, - optional: true, - propertyKey, - }); - }; - return deco; - } - - - /** - * Target property must be a string. - */ - export function String( - stringChecker?: (s: string) => boolean): (target: object, propertyKey: string | symbol, - ) => void { - const deco = (target: object, propertyKey: string | symbol) => { - const chk = getCheckableInfo(target); - chk.props.push({ checker: checkString, propertyKey, stringChecker }); - }; - return deco; - } - - /** - * Target property must be a boolean value. - */ - export function Boolean(): (target: object, propertyKey: string | symbol) => void { - const deco = (target: object, propertyKey: string | symbol) => { - const chk = getCheckableInfo(target); - chk.props.push({ checker: checkBoolean, propertyKey }); - }; - return deco; - } -} diff --git a/src/crypto/cryptoApi.ts b/src/crypto/cryptoApi.ts index b5eae9beb..5ef787711 100644 --- a/src/crypto/cryptoApi.ts +++ b/src/crypto/cryptoApi.ts @@ -22,12 +22,11 @@ /** * Imports. */ -import { AmountJson } from "../amounts"; +import { AmountJson } from "../util/amounts"; import { CoinRecord, DenominationRecord, - PlanchetRecord, RefreshSessionRecord, ReserveRecord, TipPlanchet, @@ -38,9 +37,9 @@ import { CryptoWorker } from "./cryptoWorker"; import { ContractTerms, PaybackRequest } from "../talerTypes"; -import { BenchmarkResult, CoinWithDenom, PayCoinInfo, PlanchetCreationResult } from "../walletTypes"; +import { BenchmarkResult, CoinWithDenom, PayCoinInfo, PlanchetCreationResult, PlanchetCreationRequest } from "../walletTypes"; -import * as timer from "../timer"; +import * as timer from "../util/timer"; /** * State of a crypto worker. @@ -336,10 +335,9 @@ export class CryptoApi { } createPlanchet( - denom: DenominationRecord, - reserve: ReserveRecord, + req: PlanchetCreationRequest ): Promise { - return this.doRpc("createPlanchet", 1, denom, reserve); + return this.doRpc("createPlanchet", 1, req); } createTipPlanchet(denom: DenominationRecord): Promise { diff --git a/src/crypto/cryptoImplementation.ts b/src/crypto/cryptoImplementation.ts index 7cddf9031..faebbaa4a 100644 --- a/src/crypto/cryptoImplementation.ts +++ b/src/crypto/cryptoImplementation.ts @@ -42,11 +42,12 @@ import { PayCoinInfo, Timestamp, PlanchetCreationResult, + PlanchetCreationRequest, } from "../walletTypes"; -import { canonicalJson, getTalerStampSec } from "../helpers"; -import { AmountJson } from "../amounts"; -import * as Amounts from "../amounts"; -import * as timer from "../timer"; +import { canonicalJson, getTalerStampSec } from "../util/helpers"; +import { AmountJson } from "../util/amounts"; +import * as Amounts from "../util/amounts"; +import * as timer from "../util/timer"; import { getRandomBytes, encodeCrock, @@ -155,24 +156,23 @@ export class CryptoImplementation { * reserve. */ createPlanchet( - denom: DenominationRecord, - reserve: ReserveRecord, + req: PlanchetCreationRequest, ): PlanchetCreationResult { - const reservePub = decodeCrock(reserve.reservePub); - const reservePriv = decodeCrock(reserve.reservePriv); - const denomPub = decodeCrock(denom.denomPub); + const reservePub = decodeCrock(req.reservePub); + const reservePriv = decodeCrock(req.reservePriv); + const denomPub = decodeCrock(req.denomPub); const coinKeyPair = createEddsaKeyPair(); const blindingFactor = createBlindingKeySecret(); const coinPubHash = hash(coinKeyPair.eddsaPub); const ev = rsaBlind(coinPubHash, blindingFactor, denomPub); - const amountWithFee = Amounts.add(denom.value, denom.feeWithdraw).amount; + const amountWithFee = Amounts.add(req.value, req.feeWithdraw).amount; const denomPubHash = hash(denomPub); const evHash = hash(ev); const withdrawRequest = buildSigPS(SignaturePurpose.RESERVE_WITHDRAW) .put(reservePub) .put(amountToBuffer(amountWithFee)) - .put(amountToBuffer(denom.feeWithdraw)) + .put(amountToBuffer(req.feeWithdraw)) .put(denomPubHash) .put(evHash) .build(); @@ -184,10 +184,9 @@ export class CryptoImplementation { coinEv: encodeCrock(ev), coinPriv: encodeCrock(coinKeyPair.eddsaPriv), coinPub: encodeCrock(coinKeyPair.eddsaPub), - coinValue: denom.value, + coinValue: req.value, denomPub: encodeCrock(denomPub), denomPubHash: encodeCrock(denomPubHash), - exchangeBaseUrl: reserve.exchangeBaseUrl, reservePub: encodeCrock(reservePub), withdrawSig: encodeCrock(sig), }; diff --git a/src/db.ts b/src/db.ts index e317b0aaf..ddf3771b7 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,5 +1,5 @@ import { Stores, WALLET_DB_VERSION } from "./dbTypes"; -import { Store, Index } from "./query"; +import { Store, Index } from "./util/query"; const DB_NAME = "taler"; @@ -21,9 +21,7 @@ export function openTalerDb( req.onsuccess = e => { req.result.onversionchange = (evt: IDBVersionChangeEvent) => { console.log( - `handling live db version change from ${evt.oldVersion} to ${ - evt.newVersion - }`, + `handling live db version change from ${evt.oldVersion} to ${evt.newVersion}`, ); req.result.close(); onVersionChange(); @@ -33,9 +31,7 @@ export function openTalerDb( req.onupgradeneeded = e => { const db = req.result; console.log( - `DB: upgrade needed: oldVersion=${e.oldVersion}, newVersion=${ - e.newVersion - }`, + `DB: upgrade needed: oldVersion=${e.oldVersion}, newVersion=${e.newVersion}`, ); switch (e.oldVersion) { case 0: // DB does not exist yet diff --git a/src/dbTypes.ts b/src/dbTypes.ts index 8dba28edb..731f0358b 100644 --- a/src/dbTypes.ts +++ b/src/dbTypes.ts @@ -23,8 +23,8 @@ /** * Imports. */ -import { AmountJson } from "./amounts"; -import { Checkable } from "./checkable"; +import { AmountJson } from "./util/amounts"; +import { Checkable } from "./util/checkable"; import { Auditor, CoinPaySig, @@ -35,7 +35,7 @@ import { TipResponse, } from "./talerTypes"; -import { Index, Store } from "./query"; +import { Index, Store } from "./util/query"; import { Timestamp, OperationError } from "./walletTypes"; /** @@ -444,30 +444,22 @@ export interface ExchangeRecord { * A coin that isn't yet signed by an exchange. */ export interface PlanchetRecord { - withdrawSessionId: string; - /** - * Index of the coin in the withdrawal session. - */ - coinIndex: number; - /** * Public key of the coin. */ coinPub: string; coinPriv: string; + /** + * Public key of the reserve, this might be a reserve not + * known to the wallet if the planchet is from a tip. + */ reservePub: string; denomPubHash: string; denomPub: string; blindingKey: string; withdrawSig: string; coinEv: string; - exchangeBaseUrl: string; coinValue: AmountJson; - /** - * Set to true if this pre-coin came from a tip. - * Until the tip is marked as "accepted", the resulting - * coin will not be used for payments. - */ isFromTip: boolean; } @@ -511,6 +503,12 @@ export enum CoinStatus { Dormant = "dormant", } +export enum CoinSource { + Withdraw = "withdraw", + Refresh = "refresh", + Tip = "tip", +} + /** * CoinRecord as stored in the "coins" data store * of the wallet database. @@ -690,11 +688,9 @@ export interface TipRecord { exchangeUrl: string; /** - * Domain of the merchant, necessary to uniquely identify the tip since - * merchants can freely choose the ID and a malicious merchant might cause a - * collision. + * Base URL of the merchant that is giving us the tip. */ - merchantDomain: string; + merchantBaseUrl: string; /** * Planchets, the members included in TipPlanchetDetail will be sent to the @@ -702,13 +698,6 @@ export interface TipRecord { */ planchets?: TipPlanchet[]; - /** - * Coin public keys from the planchets. - * This field is redundant and used for indexing the record via - * a multi-entry index to look up tip records by coin public key. - */ - coinPubs: string[]; - /** * Response if the merchant responded, * undefined otherwise. @@ -716,18 +705,21 @@ export interface TipRecord { response?: TipResponse[]; /** - * Identifier for the tip, chosen by the merchant. + * Tip ID chosen by the wallet. */ tipId: string; + /** + * The merchant's identifier for this tip. + */ + merchantTipId: string; + /** * URL to go to once the tip has been accepted. */ nextUrl?: string; timestamp: Timestamp; - - pickupUrl: string; } /** @@ -983,13 +975,24 @@ export interface CoinsReturnRecord { wire: any; } +export interface WithdrawalSourceTip { + type: "tip"; + tipId: string; +} + +export interface WithdrawalSourceReserve { + type: "reserve"; + reservePub: string; +} + +export type WithdrawalSource = WithdrawalSourceTip | WithdrawalSourceReserve + export interface WithdrawalSessionRecord { withdrawSessionId: string; - /** - * Reserve that we're withdrawing from. - */ - reservePub: string; + source: WithdrawalSource; + + exchangeBaseUrl: string; /** * When was the withdrawal operation started started? @@ -1010,15 +1013,12 @@ export interface WithdrawalSessionRecord { denoms: string[]; + planchets: (undefined | PlanchetRecord)[]; + /** * Coins in this session that are withdrawn are set to true. */ withdrawn: boolean[]; - - /** - * Coins in this session already have a planchet are set to true. - */ - planchetCreated: boolean[]; } export interface BankWithdrawUriRecord { @@ -1071,11 +1071,7 @@ export namespace Stores { constructor() { super("proposals", { keyPath: "proposalId" }); } - urlIndex = new Index( - this, - "urlIndex", - "url", - ); + urlIndex = new Index(this, "urlIndex", "url"); } class PurchasesStore extends Store { @@ -1140,16 +1136,8 @@ export namespace Stores { class TipsStore extends Store { constructor() { - super("tips", { - keyPath: (["tipId", "merchantDomain"] as any) as IDBKeyPath, - }); + super("tips", { keyPath: "tipId" }); } - coinPubIndex = new Index( - this, - "coinPubIndex", - "coinPubs", - { multiEntry: true }, - ); } class SenderWiresStore extends Store { @@ -1162,11 +1150,6 @@ export namespace Stores { constructor() { super("withdrawals", { keyPath: "withdrawSessionId" }); } - byReservePub = new Index( - this, - "withdrawalsReservePubIndex", - "reservePub", - ); } class BankWithdrawUrisStore extends Store { @@ -1175,24 +1158,6 @@ export namespace Stores { } } - class PlanchetsStore extends Store { - constructor() { - super("planchets", { - keyPath: "coinPub", - }); - } - byReservePub = new Index( - this, - "planchetsReservePubIndex", - "reservePub", - ); - byWithdrawalWithIdx = new Index( - this, - "planchetsByWithdrawalWithIdxIndex", - ["withdrawSessionId", "coinIndex"], - ); - } - export const coins = new CoinsStore(); export const coinsReturns = new Store("coinsReturns", { keyPath: "contractTermsHash", @@ -1201,7 +1166,6 @@ export namespace Stores { export const currencies = new CurrenciesStore(); export const denominations = new DenominationsStore(); export const exchanges = new ExchangesStore(); - export const planchets = new PlanchetsStore(); export const proposals = new ProposalsStore(); export const refresh = new Store("refresh", { keyPath: "refreshSessionId", diff --git a/src/headless/bank.ts b/src/headless/bank.ts index 36f61a71a..99d7e050b 100644 --- a/src/headless/bank.ts +++ b/src/headless/bank.ts @@ -25,7 +25,6 @@ */ import Axios from "axios"; import querystring = require("querystring"); -import URI = require("urijs"); export interface BankUser { username: string; @@ -50,9 +49,7 @@ export class Bank { amount, }; - const reqUrl = new URI("api/withdraw-headless-uri") - .absoluteTo(this.bankBaseUrl) - .href(); + const reqUrl = new URL("api/withdraw-headless-uri", this.bankBaseUrl).href; const resp = await Axios({ method: "post", @@ -82,9 +79,7 @@ export class Bank { reservePub: string, exchangePaytoUri: string, ) { - const reqUrl = new URI("api/withdraw-headless") - .absoluteTo(this.bankBaseUrl) - .href(); + const reqUrl = new URL("api/withdraw-headless", this.bankBaseUrl).href; const body = { auth: { type: "basic" }, @@ -111,7 +106,7 @@ export class Bank { } async registerRandomUser(): Promise { - const reqUrl = new URI("api/register").absoluteTo(this.bankBaseUrl).href(); + const reqUrl = new URL("api/register", this.bankBaseUrl).href; const randId = makeId(8); const bankUser: BankUser = { username: `testuser-${randId}`, diff --git a/src/headless/helpers.ts b/src/headless/helpers.ts index 9faf24daf..e5338369e 100644 --- a/src/headless/helpers.ts +++ b/src/headless/helpers.ts @@ -28,13 +28,13 @@ import { SynchronousCryptoWorkerFactory } from "../crypto/synchronousWorker"; import { openTalerDb } from "../db"; import Axios from "axios"; import querystring = require("querystring"); -import { HttpRequestLibrary } from "../http"; -import * as amounts from "../amounts"; +import { HttpRequestLibrary } from "../util/http"; +import * as amounts from "../util/amounts"; import { Bank } from "./bank"; import fs = require("fs"); import { NodeCryptoWorkerFactory } from "../crypto/nodeProcessWorker"; -import { Logger } from "../logging"; +import { Logger } from "../util/logging"; const logger = new Logger("helpers.ts"); @@ -51,7 +51,7 @@ class ConsoleBadge implements Badge { } export class NodeHttpLib implements HttpRequestLibrary { - async get(url: string): Promise { + async get(url: string): Promise { try { const resp = await Axios({ method: "get", @@ -70,7 +70,7 @@ export class NodeHttpLib implements HttpRequestLibrary { async postJson( url: string, body: any, - ): Promise { + ): Promise { try { const resp = await Axios({ method: "post", diff --git a/src/headless/merchant.ts b/src/headless/merchant.ts index 423e3d09e..1b9630732 100644 --- a/src/headless/merchant.ts +++ b/src/headless/merchant.ts @@ -24,7 +24,6 @@ */ import axios from "axios"; import { CheckPaymentResponse } from "../talerTypes"; -import URI = require("urijs"); /** * Connection to the *internal* merchant backend. @@ -35,7 +34,7 @@ export class MerchantBackendConnection { reason: string, refundAmount: string, ): Promise { - const reqUrl = new URI("refund").absoluteTo(this.merchantBaseUrl).href(); + const reqUrl = new URL("refund", this.merchantBaseUrl); const refundReq = { order_id: orderId, reason, @@ -43,7 +42,7 @@ export class MerchantBackendConnection { }; const resp = await axios({ method: "post", - url: reqUrl, + url: reqUrl.href, data: refundReq, responseType: "json", headers: { @@ -64,7 +63,7 @@ export class MerchantBackendConnection { constructor(public merchantBaseUrl: string, public apiKey: string) {} async authorizeTip(amount: string, justification: string) { - const reqUrl = new URI("tip-authorize").absoluteTo(this.merchantBaseUrl).href(); + const reqUrl = new URL("tip-authorize", this.merchantBaseUrl).href; const tipReq = { amount, justification, @@ -90,7 +89,7 @@ export class MerchantBackendConnection { summary: string, fulfillmentUrl: string, ): Promise<{ orderId: string }> { - const reqUrl = new URI("order").absoluteTo(this.merchantBaseUrl).href(); + const reqUrl = new URL("order", this.merchantBaseUrl).href; const orderReq = { order: { amount, @@ -118,9 +117,7 @@ export class MerchantBackendConnection { } async checkPayment(orderId: string): Promise { - const reqUrl = new URI("check-payment") - .absoluteTo(this.merchantBaseUrl) - .href(); + const reqUrl = new URL("check-payment", this.merchantBaseUrl).href; const resp = await axios({ method: "get", url: reqUrl, diff --git a/src/headless/taler-wallet-cli.ts b/src/headless/taler-wallet-cli.ts index cb2ff055c..9598b9d98 100644 --- a/src/headless/taler-wallet-cli.ts +++ b/src/headless/taler-wallet-cli.ts @@ -23,8 +23,8 @@ import { Wallet, OperationFailedAndReportedError } from "../wallet"; import qrcodeGenerator = require("qrcode-generator"); import * as clk from "./clk"; import { BridgeIDBFactory, MemoryBackend } from "idb-bridge"; -import { Logger } from "../logging"; -import * as Amounts from "../amounts"; +import { Logger } from "../util/logging"; +import * as Amounts from "../util/amounts"; import { decodeCrock } from "../crypto/talerCrypto"; import { Bank } from "./bank"; @@ -93,7 +93,6 @@ async function doPay( function applyVerbose(verbose: boolean) { if (verbose) { console.log("enabled verbose logging"); - Wallet.enableTracing = true; BridgeIDBFactory.enableTracing = true; } } @@ -217,7 +216,7 @@ walletCli } else if (uri.startsWith("taler://tip/")) { const res = await wallet.getTipStatus(uri); console.log("tip status", res); - await wallet.acceptTip(uri); + await wallet.acceptTip(res.tipId); } else if (uri.startsWith("taler://refund/")) { await wallet.applyRefund(uri); } else if (uri.startsWith("taler://withdraw/")) { diff --git a/src/helpers-test.ts b/src/helpers-test.ts deleted file mode 100644 index 74817120a..000000000 --- a/src/helpers-test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - This file is part of TALER - (C) 2017 Inria and 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 - */ - - -import test from "ava"; -import * as helpers from "./helpers"; - - -test("URL canonicalization", (t) => { - // converts to relative, adds https - t.is( - "https://alice.example.com/exchange/", - helpers.canonicalizeBaseUrl("alice.example.com/exchange")); - - // keeps http, adds trailing slash - t.is( - "http://alice.example.com/exchange/", - helpers.canonicalizeBaseUrl("http://alice.example.com/exchange")); - - // keeps http, adds trailing slash - t.is( - "http://alice.example.com/exchange/", - helpers.canonicalizeBaseUrl("http://alice.example.com/exchange#foobar")); - t.pass(); -}); diff --git a/src/helpers.ts b/src/helpers.ts deleted file mode 100644 index 1983cee9b..000000000 --- a/src/helpers.ts +++ /dev/null @@ -1,203 +0,0 @@ -/* - 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 - */ - -/** - * Small helper functions that don't fit anywhere else. - */ - -/** - * Imports. - */ -import { AmountJson } from "./amounts"; -import * as Amounts from "./amounts"; - -import URI = require("urijs"); -import { Timestamp } from "./walletTypes"; - -/** - * Show an amount in a form suitable for the user. - * FIXME: In the future, this should consider currency-specific - * settings such as significant digits or currency symbols. - */ -export function amountToPretty(amount: AmountJson): string { - const x = amount.value + amount.fraction / Amounts.fractionalBase; - 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) { - if (!url.startsWith("http") && !url.startsWith("https")) { - url = "https://" + url; - } - const x = new URI(url); - x.path(x.path() + "/").normalizePath(); - x.fragment(""); - x.query(); - return x.href(); -} - - -/** - * 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)) { - const objs: string[] = obj.map((e) => canonicalJson(e)); - return `[${objs.join(",")}]`; - } - const keys: string[] = []; - for (const key in obj) { - keys.push(key); - } - keys.sort(); - let s = "{"; - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - s += JSON.stringify(key) + ":" + canonicalJson(obj[key]); - if (i !== keys.length - 1) { - s += ","; - } - } - return s + "}"; -} - - -/** - * Check for deep equality of two objects. - * Only arrays, objects and primitives are supported. - */ -export function deepEquals(x: any, y: any): boolean { - if (x === y) { - return true; - } - - if (Array.isArray(x) && x.length !== y.length) { - return false; - } - - const p = Object.keys(x); - return Object.keys(y).every((i) => p.indexOf(i) !== -1) && - p.every((i) => deepEquals(x[i], y[i])); -} - - -/** - * Map from a collection to a list or results and then - * concatenate the results. - */ -export function flatMap(xs: T[], f: (x: T) => U[]): U[] { - return xs.reduce((acc: U[], next: T) => [...f(next), ...acc], []); -} - - -/** - * Extract a numeric timstamp (in seconds) from the Taler date format - * ("/Date([n])/"). Returns null if input is not in the right format. - */ -export function getTalerStampSec(stamp: string): number | null { - const m = stamp.match(/\/?Date\(([0-9]*)\)\/?/); - if (!m || !m[1]) { - return null; - } - return parseInt(m[1], 10); -} - -/** - * Extract a timestamp from a Taler timestamp string. - */ -export function extractTalerStamp(stamp: string): Timestamp | undefined { - const m = stamp.match(/\/?Date\(([0-9]*)\)\/?/); - if (!m || !m[1]) { - return undefined; - } - return { - t_ms: parseInt(m[1], 10) * 1000, - }; -} - -/** - * Extract a timestamp from a Taler timestamp string. - */ -export function extractTalerStampOrThrow(stamp: string): Timestamp { - const r = extractTalerStamp(stamp); - if (!r) { - throw Error("invalid time stamp"); - } - return r; -} - -/** - * Check if a timestamp is in the right format. - */ -export function timestampCheck(stamp: string): boolean { - return getTalerStampSec(stamp) !== null; -} - - -/** - * Get a JavaScript Date object from a Taler date string. - * Returns null if input is not in the right format. - */ -export function getTalerStampDate(stamp: string): Date | null { - const sec = getTalerStampSec(stamp); - if (sec == null) { - return null; - } - return new Date(sec * 1000); -} - -/** - * Compute the hash function of a JSON object. - */ -export function hash(val: any): number { - const str = canonicalJson(val); - // https://github.com/darkskyapp/string-hash - let h = 5381; - let i = str.length; - while (i) { - h = (h * 33) ^ str.charCodeAt(--i); - } - - /* JavaScript does bitwise operations (like XOR, above) on 32-bit signed - * integers. Since we want the results to be always positive, convert the - * signed int to an unsigned by doing an unsigned bitshift. */ - return h >>> 0; -} - - -/** - * Lexically compare two strings. - */ -export function strcmp(s1: string, s2: string): number { - if (s1 < s2) { - return -1; - } - if (s1 > s2) { - return 1; - } - return 0; -} diff --git a/src/http.ts b/src/http.ts deleted file mode 100644 index a2bfab279..000000000 --- a/src/http.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - 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 - */ - -/** - * Helpers for doing XMLHttpRequest-s that are based on ES6 promises. - * Allows for easy mocking for test cases. - */ - -/** - * An HTTP response that is returned by all request methods of this library. - */ -export interface HttpResponse { - status: number; - responseJson: object & any; -} - -/** - * The request library is bundled into an interface to make mocking easy. - */ -export interface HttpRequestLibrary { - get(url: string): Promise; - - postJson(url: string, body: any): Promise; -} - -/** - * An implementation of the [[HttpRequestLibrary]] using the - * browser's XMLHttpRequest. - */ -export class BrowserHttpLib implements HttpRequestLibrary { - private req( - method: string, - url: string, - options?: any, - ): Promise { - return new Promise((resolve, reject) => { - const myRequest = new XMLHttpRequest(); - myRequest.open(method, url); - if (options && options.req) { - myRequest.send(options.req); - } else { - myRequest.send(); - } - - myRequest.onerror = e => { - console.error("http request error"); - reject(Error("could not make XMLHttpRequest")); - }; - - myRequest.addEventListener("readystatechange", e => { - if (myRequest.readyState === XMLHttpRequest.DONE) { - if (myRequest.status === 0) { - reject(Error("HTTP Request failed (status code 0, maybe URI scheme is wrong?)")) - return; - } - if (myRequest.status != 200) { - reject( - Error( - `HTTP Response with unexpected status code ${myRequest.status}: ${myRequest.statusText}`, - ), - ); - return; - } - let responseJson; - try { - responseJson = JSON.parse(myRequest.responseText); - } catch (e) { - reject(Error("Invalid JSON from HTTP response")); - return; - } - if (responseJson === null || typeof responseJson !== "object") { - reject(Error("Invalid JSON from HTTP response")); - return; - } - const resp = { - responseJson: responseJson, - status: myRequest.status, - }; - resolve(resp); - } - }); - }); - } - - get(url: string) { - return this.req("get", url); - } - - postJson(url: string, body: any) { - return this.req("post", url, { req: JSON.stringify(body) }); - } - - postForm(url: string, form: any) { - return this.req("post", url, { req: form }); - } -} diff --git a/src/libtoolVersion-test.ts b/src/libtoolVersion-test.ts deleted file mode 100644 index 0a610e455..000000000 --- a/src/libtoolVersion-test.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - This file is part of TALER - (C) 2017 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 - */ - -import * as LibtoolVersion from "./libtoolVersion"; - -import test from "ava"; - -test("version comparison", (t) => { - t.deepEqual(LibtoolVersion.compare("0:0:0", "0:0:0"), {compatible: true, currentCmp: 0}); - t.deepEqual(LibtoolVersion.compare("0:0:0", ""), undefined); - t.deepEqual(LibtoolVersion.compare("foo", "0:0:0"), undefined); - t.deepEqual(LibtoolVersion.compare("0:0:0", "1:0:1"), {compatible: true, currentCmp: -1}); - t.deepEqual(LibtoolVersion.compare("0:0:0", "1:5:1"), {compatible: true, currentCmp: -1}); - t.deepEqual(LibtoolVersion.compare("0:0:0", "1:5:0"), {compatible: false, currentCmp: -1}); - t.deepEqual(LibtoolVersion.compare("1:0:0", "0:5:0"), {compatible: false, currentCmp: 1}); - t.deepEqual(LibtoolVersion.compare("1:0:1", "1:5:1"), {compatible: true, currentCmp: 0}); -}); diff --git a/src/libtoolVersion.ts b/src/libtoolVersion.ts deleted file mode 100644 index cc2435b94..000000000 --- a/src/libtoolVersion.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - This file is part of TALER - (C) 2017 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 - */ - -/** - * Semantic versioning, but libtool-style. - * See https://www.gnu.org/software/libtool/manual/html_node/Libtool-versioning.html - */ - - -/** - * Result of comparing two libtool versions. - */ -export interface VersionMatchResult { - /** - * Is the first version compatible with the second? - */ - compatible: boolean; - /** - * Is the first version older (-1), newser (+1) or - * identical (0)? - */ - currentCmp: number; -} - -interface Version { - current: number; - revision: number; - age: number; -} - -/** - * Compare two libtool-style version strings. - */ -export function compare(me: string, other: string): VersionMatchResult|undefined { - const meVer = parseVersion(me); - const otherVer = parseVersion(other); - - if (!(meVer && otherVer)) { - return undefined; - } - - const compatible = (meVer.current - meVer.age <= otherVer.current && - meVer.current >= (otherVer.current - otherVer.age)); - - const currentCmp = Math.sign(meVer.current - otherVer.current); - - return {compatible, currentCmp}; -} - - -function parseVersion(v: string): Version|undefined { - const [currentStr, revisionStr, ageStr, ...rest] = v.split(":"); - if (rest.length !== 0) { - return undefined; - } - const current = Number.parseInt(currentStr); - const revision = Number.parseInt(revisionStr); - const age = Number.parseInt(ageStr); - - if (Number.isNaN(current)) { - return undefined; - } - - if (Number.isNaN(revision)) { - return undefined; - } - - if (Number.isNaN(age)) { - return undefined; - } - - return {current, revision, age}; -} diff --git a/src/logging.ts b/src/logging.ts deleted file mode 100644 index a21943e6e..000000000 --- a/src/logging.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - This file is part of TALER - (C) 2019 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 - */ - -export class Logger { - constructor(private tag: string) {} - info(message: string, ...args: any[]) { - console.log(`${new Date().toISOString()} ${this.tag} INFO ` + message, ...args); - } - trace(message: string, ...args: any[]) { - console.log(`${new Date().toISOString()} ${this.tag} TRACE ` + message, ...args) - } -} \ No newline at end of file diff --git a/src/promiseUtils.ts b/src/promiseUtils.ts deleted file mode 100644 index eb649471b..000000000 --- a/src/promiseUtils.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 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 - */ - - export interface OpenedPromise { - promise: Promise; - resolve: (val: T) => void; - reject: (err: any) => void; - } - -/** - * Get an unresolved promise together with its extracted resolve / reject - * function. - */ -export function openPromise(): OpenedPromise { - let resolve: ((x?: any) => void) | null = null; - let reject: ((reason?: any) => void) | null = null; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - if (!(resolve && reject)) { - // Never happens, unless JS implementation is broken - throw Error(); - } - return { resolve, reject, promise }; -} \ No newline at end of file diff --git a/src/query.ts b/src/query.ts deleted file mode 100644 index 5726bcaa6..000000000 --- a/src/query.ts +++ /dev/null @@ -1,446 +0,0 @@ -/* - 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 - */ - -/** - * Database query abstractions. - * @module Query - * @author Florian Dold - */ - -/** - * Imports. - */ -import { openPromise } from "./promiseUtils"; - - -/** - * Result of an inner join. - */ -export interface JoinResult { - left: L; - right: R; -} - -/** - * Result of a left outer join. - */ -export interface JoinLeftResult { - left: L; - right?: R; -} - -/** - * Definition of an object store. - */ -export class Store { - constructor( - public name: string, - public storeParams?: IDBObjectStoreParameters, - public validator?: (v: T) => T, - ) {} -} - -/** - * Options for an index. - */ -export interface IndexOptions { - /** - * If true and the path resolves to an array, create an index entry for - * each member of the array (instead of one index entry containing the full array). - * - * Defaults to false. - */ - multiEntry?: boolean; -} - -function requestToPromise(req: IDBRequest): Promise { - const stack = Error("Failed request was started here.") - return new Promise((resolve, reject) => { - req.onsuccess = () => { - resolve(req.result); - }; - req.onerror = () => { - console.log("error in DB request", req.error); - reject(req.error); - console.log("Request failed:", stack); - }; - }); -} - -function transactionToPromise(tx: IDBTransaction): Promise { - const stack = Error("Failed transaction was started here."); - return new Promise((resolve, reject) => { - tx.onabort = () => { - reject(TransactionAbort); - }; - tx.oncomplete = () => { - resolve(); - }; - tx.onerror = () => { - console.error("Transaction failed:", stack); - reject(tx.error); - }; - }); -} - -export async function oneShotGet( - db: IDBDatabase, - store: Store, - key: any, -): Promise { - const tx = db.transaction([store.name], "readonly"); - const req = tx.objectStore(store.name).get(key); - const v = await requestToPromise(req) - await transactionToPromise(tx); - return v; -} - -export async function oneShotGetIndexed( - db: IDBDatabase, - index: Index, - key: any, -): Promise { - const tx = db.transaction([index.storeName], "readonly"); - const req = tx - .objectStore(index.storeName) - .index(index.indexName) - .get(key); - const v = await requestToPromise(req); - await transactionToPromise(tx); - return v; -} - -export async function oneShotPut( - db: IDBDatabase, - store: Store, - value: T, - key?: any, -): Promise { - const tx = db.transaction([store.name], "readwrite"); - const req = tx.objectStore(store.name).put(value, key); - const v = await requestToPromise(req); - await transactionToPromise(tx); - return v; -} - -function applyMutation( - req: IDBRequest, - f: (x: T) => T | undefined, -): Promise { - return new Promise((resolve, reject) => { - req.onsuccess = () => { - const cursor = req.result; - if (cursor) { - const val = cursor.value; - const modVal = f(val); - if (modVal !== undefined && modVal !== null) { - const req2: IDBRequest = cursor.update(modVal); - req2.onerror = () => { - reject(req2.error); - }; - req2.onsuccess = () => { - cursor.continue(); - }; - } else { - cursor.continue(); - } - } else { - resolve(); - } - }; - req.onerror = () => { - reject(req.error); - }; - }); -} - -export async function oneShotMutate( - db: IDBDatabase, - store: Store, - key: any, - f: (x: T) => T | undefined, -): Promise { - const tx = db.transaction([store.name], "readwrite"); - const req = tx.objectStore(store.name).openCursor(key); - await applyMutation(req, f); - await transactionToPromise(tx); -} - -type CursorResult = CursorEmptyResult | CursorValueResult; - -interface CursorEmptyResult { - hasValue: false; -} - -interface CursorValueResult { - hasValue: true; - value: T; -} - -class ResultStream { - private currentPromise: Promise; - private gotCursorEnd: boolean = false; - private awaitingResult: boolean = false; - - constructor(private req: IDBRequest) { - this.awaitingResult = true; - let p = openPromise(); - this.currentPromise = p.promise; - req.onsuccess = () => { - if (!this.awaitingResult) { - throw Error("BUG: invariant violated"); - } - const cursor = req.result; - if (cursor) { - this.awaitingResult = false; - p.resolve(); - p = openPromise(); - this.currentPromise = p.promise; - } else { - this.gotCursorEnd = true; - p.resolve(); - } - }; - req.onerror = () => { - p.reject(req.error); - }; - } - - async toArray(): Promise { - const arr: T[] = []; - while (true) { - const x = await this.next(); - if (x.hasValue) { - arr.push(x.value); - } else { - break; - } - } - return arr; - } - - async map(f: (x: T) => R): Promise { - const arr: R[] = []; - while (true) { - const x = await this.next(); - if (x.hasValue) { - arr.push(f(x.value)); - } else { - break; - } - } - return arr; - } - - async forEach(f: (x: T) => void): Promise { - while (true) { - const x = await this.next(); - if (x.hasValue) { - f(x.value); - } else { - break; - } - } - } - - async filter(f: (x: T) => boolean): Promise { - const arr: T[] = []; - while (true) { - const x = await this.next(); - if (x.hasValue) { - if (f(x.value)) { - arr.push(x.value); - } - } else { - break; - } - } - return arr; - } - - async next(): Promise> { - if (this.gotCursorEnd) { - return { hasValue: false }; - } - if (!this.awaitingResult) { - const cursor = this.req.result; - if (!cursor) { - throw Error("assertion failed"); - } - this.awaitingResult = true; - cursor.continue(); - } - await this.currentPromise; - if (this.gotCursorEnd) { - return { hasValue: false }; - } - const cursor = this.req.result; - if (!cursor) { - throw Error("assertion failed"); - } - return { hasValue: true, value: cursor.value }; - } -} - -export function oneShotIter( - db: IDBDatabase, - store: Store, -): ResultStream { - const tx = db.transaction([store.name], "readonly"); - const req = tx.objectStore(store.name).openCursor(); - return new ResultStream(req); -} - -export function oneShotIterIndex( - db: IDBDatabase, - index: Index, - query?: any, -): ResultStream { - const tx = db.transaction([index.storeName], "readonly"); - const req = tx - .objectStore(index.storeName) - .index(index.indexName) - .openCursor(query); - return new ResultStream(req); -} - -class TransactionHandle { - constructor(private tx: IDBTransaction) {} - - put(store: Store, value: T, key?: any): Promise { - const req = this.tx.objectStore(store.name).put(value, key); - return requestToPromise(req); - } - - add(store: Store, value: T, key?: any): Promise { - const req = this.tx.objectStore(store.name).add(value, key); - return requestToPromise(req); - } - - get(store: Store, key: any): Promise { - const req = this.tx.objectStore(store.name).get(key); - return requestToPromise(req); - } - - iter(store: Store, key?: any): ResultStream { - const req = this.tx.objectStore(store.name).openCursor(key); - return new ResultStream(req); - } - - delete(store: Store, key: any): Promise { - const req = this.tx.objectStore(store.name).delete(key); - return requestToPromise(req); - } - - mutate(store: Store, key: any, f: (x: T) => T | undefined) { - const req = this.tx.objectStore(store.name).openCursor(key); - return applyMutation(req, f); - } -} - -export function runWithWriteTransaction( - db: IDBDatabase, - stores: Store[], - f: (t: TransactionHandle) => Promise, -): Promise { - const stack = Error("Failed transaction was started here."); - return new Promise((resolve, reject) => { - const storeName = stores.map(x => x.name); - const tx = db.transaction(storeName, "readwrite"); - let funResult: any = undefined; - let gotFunResult: boolean = false; - tx.oncomplete = () => { - // This is a fatal error: The transaction completed *before* - // the transaction function returned. Likely, the transaction - // function waited on a promise that is *not* resolved in the - // microtask queue, thus triggering the auto-commit behavior. - // Unfortunately, the auto-commit behavior of IDB can't be switched - // of. There are some proposals to add this functionality in the future. - if (!gotFunResult) { - const msg = - "BUG: transaction closed before transaction function returned"; - console.error(msg); - reject(Error(msg)); - } - resolve(funResult); - }; - tx.onerror = () => { - console.error("error in transaction"); - }; - tx.onabort = () => { - if (tx.error) { - console.error("Transaction aborted with error:", tx.error); - } else { - console.log("Trasaction aborted (no error)"); - } - reject(TransactionAbort); - }; - const th = new TransactionHandle(tx); - const resP = f(th); - resP.then(result => { - gotFunResult = true; - funResult = result; - }).catch((e) => { - if (e == TransactionAbort) { - console.info("aborting transaction"); - } else { - tx.abort(); - console.error("Transaction failed:", e); - console.error(stack); - } - }); - }); -} - -/** - * Definition of an index. - */ -export class Index { - /** - * Name of the store that this index is associated with. - */ - storeName: string; - - /** - * Options to use for the index. - */ - options: IndexOptions; - - constructor( - s: Store, - public indexName: string, - public keyPath: string | string[], - options?: IndexOptions, - ) { - const defaultOptions = { - multiEntry: false, - }; - this.options = { ...defaultOptions, ...(options || {}) }; - this.storeName = s.name; - } - - /** - * We want to have the key type parameter in use somewhere, - * because otherwise the compiler complains. In iterIndex the - * key type is pretty useful. - */ - protected _dummyKey: S | undefined; -} - -/** - * Exception that should be thrown by client code to abort a transaction. - */ -export const TransactionAbort = Symbol("transaction_abort"); diff --git a/src/talerTypes.ts b/src/talerTypes.ts index 1e658d5be..a65813678 100644 --- a/src/talerTypes.ts +++ b/src/talerTypes.ts @@ -26,11 +26,11 @@ /** * Imports. */ -import { Checkable } from "./checkable"; +import { Checkable } from "./util/checkable"; -import * as Amounts from "./amounts"; +import * as Amounts from "./util/amounts"; -import { timestampCheck } from "./helpers"; +import { timestampCheck } from "./util/helpers"; /** * Denomination as found in the /keys response from the exchange. diff --git a/src/taleruri-test.ts b/src/taleruri-test.ts deleted file mode 100644 index 360f565f7..000000000 --- a/src/taleruri-test.ts +++ /dev/null @@ -1,206 +0,0 @@ -/* - This file is part of TALER - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -import test from "ava"; -import { parsePayUri, parseWithdrawUri, parseRefundUri, parseTipUri } from "./taleruri"; - -test("taler pay url parsing: http(s)", (t) => { - const url1 = "https://example.com/bar?spam=eggs"; - const r1 = parsePayUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.downloadUrl, url1); - t.is(r1.sessionId, undefined); - const url2 = "http://example.com/bar?spam=eggs"; - const r2 = parsePayUri(url2); - if (!r2) { - t.fail(); - return; - } -}); - - -test("taler pay url parsing: wrong scheme", (t) => { - const url1 = "talerfoo://"; - const r1 = parsePayUri(url1); - t.is(r1, undefined); - - const url2 = "taler://refund/a/b/c/d/e/f"; - const r2 = parsePayUri(url1); - t.is(r2, undefined); -}); - - -test("taler pay url parsing: defaults", (t) => { - const url1 = "taler://pay/example.com/-/-/myorder"; - const r1 = parsePayUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.downloadUrl, "https://example.com/public/proposal?order_id=myorder"); - t.is(r1.sessionId, undefined); - - const url2 = "taler://pay/example.com/-/-/myorder/mysession"; - const r2 = parsePayUri(url2); - if (!r2) { - t.fail(); - return; - } - t.is(r2.downloadUrl, "https://example.com/public/proposal?order_id=myorder"); - t.is(r2.sessionId, "mysession"); -}); - - -test("taler pay url parsing: trailing parts", (t) => { - const url1 = "taler://pay/example.com/-/-/myorder/mysession/spam/eggs"; - const r1 = parsePayUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.downloadUrl, "https://example.com/public/proposal?order_id=myorder"); - t.is(r1.sessionId, "mysession"); -}); - - -test("taler pay url parsing: instance", (t) => { - const url1 = "taler://pay/example.com/-/myinst/myorder"; - const r1 = parsePayUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.downloadUrl, "https://example.com/public/instances/myinst/proposal?order_id=myorder"); -}); - - -test("taler pay url parsing: path prefix and instance", (t) => { - const url1 = "taler://pay/example.com/mypfx/myinst/myorder"; - const r1 = parsePayUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.downloadUrl, "https://example.com/mypfx/instances/myinst/proposal?order_id=myorder"); -}); - -test("taler pay url parsing: complex path prefix", (t) => { - const url1 = "taler://pay/example.com/mypfx%2Fpublic/-/myorder"; - const r1 = parsePayUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.downloadUrl, "https://example.com/mypfx/public/proposal?order_id=myorder"); -}); - -test("taler pay url parsing: complex path prefix and instance", (t) => { - const url1 = "taler://pay/example.com/mypfx%2Fpublic/foo/myorder"; - const r1 = parsePayUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.downloadUrl, "https://example.com/mypfx/public/instances/foo/proposal?order_id=myorder"); -}); - -test("taler pay url parsing: non-https #1", (t) => { - const url1 = "taler://pay/example.com/-/-/myorder?insecure=1"; - const r1 = parsePayUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.downloadUrl, "http://example.com/public/proposal?order_id=myorder"); -}); - -test("taler pay url parsing: non-https #2", (t) => { - const url1 = "taler://pay/example.com/-/-/myorder?insecure=2"; - const r1 = parsePayUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.downloadUrl, "https://example.com/public/proposal?order_id=myorder"); -}); - - -test("taler withdraw uri parsing", (t) => { - const url1 = "taler://withdraw/bank.example.com/-/12345"; - const r1 = parseWithdrawUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.statusUrl, "https://bank.example.com/api/withdraw-operation/12345"); -}); - - -test("taler refund uri parsing", (t) => { - const url1 = "taler://refund/merchant.example.com/-/-/1234"; - const r1 = parseRefundUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.refundUrl, "https://merchant.example.com/public/refund?order_id=1234"); -}); - - -test("taler refund uri parsing with instance", (t) => { - const url1 = "taler://refund/merchant.example.com/-/myinst/1234"; - const r1 = parseRefundUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.refundUrl, "https://merchant.example.com/public/instances/myinst/refund?order_id=1234"); -}); - -test("taler tip pickup uri", (t) => { - const url1 = "taler://tip/merchant.example.com/-/-/tipid"; - const r1 = parseTipUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.tipPickupUrl, "https://merchant.example.com/public/tip-pickup?tip_id=tipid"); -}); - - -test("taler tip pickup uri with instance", (t) => { - const url1 = "taler://tip/merchant.example.com/-/tipm/tipid"; - const r1 = parseTipUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.tipPickupUrl, "https://merchant.example.com/public/instances/tipm/tip-pickup?tip_id=tipid"); -}); - - -test("taler tip pickup uri with instance and prefix", (t) => { - const url1 = "taler://tip/merchant.example.com/my%2fpfx/tipm/tipid"; - const r1 = parseTipUri(url1); - if (!r1) { - t.fail(); - return; - } - t.is(r1.tipPickupUrl, "https://merchant.example.com/my/pfx/instances/tipm/tip-pickup?tip_id=tipid"); -}); diff --git a/src/taleruri.ts b/src/taleruri.ts deleted file mode 100644 index c810def29..000000000 --- a/src/taleruri.ts +++ /dev/null @@ -1,218 +0,0 @@ -/* - This file is part of TALER - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -import URI = require("urijs"); -import { string } from "prop-types"; - -export interface PayUriResult { - downloadUrl: string; - sessionId?: string; -} - -export interface WithdrawUriResult { - statusUrl: string; -} - -export interface RefundUriResult { - refundUrl: string; -} - -export interface TipUriResult { - tipPickupUrl: string; - tipId: string; - merchantInstance: string; - merchantOrigin: string; -} - -export function parseWithdrawUri(s: string): WithdrawUriResult | undefined { - const parsedUri = new URI(s); - if (parsedUri.scheme() !== "taler") { - return undefined; - } - if (parsedUri.authority() != "withdraw") { - return undefined; - } - - let [host, path, withdrawId] = parsedUri.segmentCoded(); - - if (path === "-") { - path = "/api/withdraw-operation"; - } - - return { - statusUrl: new URI({ protocol: "https", hostname: host, path: path }) - .segmentCoded(withdrawId) - .href(), - }; -} - -export function parsePayUri(s: string): PayUriResult | undefined { - const parsedUri = new URI(s); - const query: any = parsedUri.query(true); - if (parsedUri.scheme() === "http" || parsedUri.scheme() === "https") { - return { - downloadUrl: s, - sessionId: undefined, - }; - } - if (parsedUri.scheme() != "taler") { - return undefined; - } - if (parsedUri.authority() != "pay") { - return undefined; - } - - let [ - _, - host, - maybePath, - maybeInstance, - orderId, - maybeSessionid, - ] = parsedUri.path().split("/"); - - if (!host) { - return undefined; - } - - if (!maybePath) { - return undefined; - } - - if (!orderId) { - return undefined; - } - - if (maybePath === "-") { - maybePath = "public/"; - } else { - maybePath = decodeURIComponent(maybePath) + "/"; - } - let maybeInstancePath = ""; - if (maybeInstance !== "-") { - maybeInstancePath = `instances/${maybeInstance}/`; - } - - let protocol = "https"; - if (query["insecure"] === "1") { - protocol = "http"; - } - - const downloadUrl = new URI( - protocol + "://" + host + "/" + decodeURIComponent(maybePath) + maybeInstancePath + "proposal", - ) - .addQuery({ order_id: orderId }) - .href(); - - return { - downloadUrl, - sessionId: maybeSessionid, - }; -} - -export function parseTipUri(s: string): TipUriResult | undefined { - const parsedUri = new URI(s); - if (parsedUri.scheme() != "taler") { - return undefined; - } - if (parsedUri.authority() != "tip") { - return undefined; - } - - let [_, host, maybePath, maybeInstance, tipId] = parsedUri.path().split("/"); - - if (!host) { - return undefined; - } - - if (!maybePath) { - return undefined; - } - - if (!tipId) { - return undefined; - } - - if (maybePath === "-") { - maybePath = "public/"; - } else { - maybePath = decodeURIComponent(maybePath) + "/"; - } - let maybeInstancePath = ""; - if (maybeInstance !== "-") { - maybeInstancePath = `instances/${maybeInstance}/`; - } - - const tipPickupUrl = new URI( - "https://" + host + "/" + maybePath + maybeInstancePath + "tip-pickup", - ).addQuery({ tip_id: tipId }).href(); - - return { - tipPickupUrl, - tipId: tipId, - merchantInstance: maybeInstance, - merchantOrigin: new URI(tipPickupUrl).origin(), - }; -} - -export function parseRefundUri(s: string): RefundUriResult | undefined { - const parsedUri = new URI(s); - if (parsedUri.scheme() != "taler") { - return undefined; - } - if (parsedUri.authority() != "refund") { - return undefined; - } - - let [ - _, - host, - maybePath, - maybeInstance, - orderId, - ] = parsedUri.path().split("/"); - - if (!host) { - return undefined; - } - - if (!maybePath) { - return undefined; - } - - if (!orderId) { - return undefined; - } - - if (maybePath === "-") { - maybePath = "public/"; - } else { - maybePath = decodeURIComponent(maybePath) + "/"; - } - let maybeInstancePath = ""; - if (maybeInstance !== "-") { - maybeInstancePath = `instances/${maybeInstance}/`; - } - - const refundUrl = new URI( - "https://" + host + "/" + maybePath + maybeInstancePath + "refund", - ) - .addQuery({ order_id: orderId }) - .href(); - return { - refundUrl, - }; -} diff --git a/src/timer.ts b/src/timer.ts deleted file mode 100644 index d3bb5d485..000000000 --- a/src/timer.ts +++ /dev/null @@ -1,145 +0,0 @@ -/* - This file is part of TALER - (C) 2017 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 - */ - -/** - * Cross-platform timers. - * - * NodeJS and the browser use slightly different timer API, - * this abstracts over these differences. - */ - -/** - * Cancelable timer. - */ -export interface TimerHandle { - clear(): void; -} - -class IntervalHandle { - constructor(public h: any) { - } - - clear() { - clearInterval(this.h); - } -} - -class TimeoutHandle { - constructor(public h: any) { - } - - clear() { - clearTimeout(this.h); - } -} - -/** - * Get a performance counter in milliseconds. - */ -export const performanceNow: () => number = (() => { - if (typeof process !== "undefined" && process.hrtime) { - return () => { - const t = process.hrtime(); - return t[0] * 1e9 + t[1]; - }; - } else if (typeof "performance" !== "undefined") { - return () => performance.now(); - } else { - return () => 0; - } -})(); - -/** - * Call a function every time the delay given in milliseconds passes. - */ -export function every(delayMs: number, callback: () => void): TimerHandle { - return new IntervalHandle(setInterval(callback, delayMs)); -} - -/** - * Call a function after the delay given in milliseconds passes. - */ -export function after(delayMs: number, callback: () => void): TimerHandle { - return new TimeoutHandle(setTimeout(callback, delayMs)); -} - - -const nullTimerHandle = { - clear() { - // do nothing - return; - }, -}; - -/** - * Group of timers that can be destroyed at once. - */ -export class TimerGroup { - private stopped: boolean = false; - - private timerMap: { [index: number]: TimerHandle } = {}; - - private idGen = 1; - - stopCurrentAndFutureTimers() { - this.stopped = true; - for (const x in this.timerMap) { - if (!this.timerMap.hasOwnProperty(x)) { - continue; - } - this.timerMap[x].clear(); - delete this.timerMap[x]; - } - } - - after(delayMs: number, callback: () => void): TimerHandle { - if (this.stopped) { - console.warn("dropping timer since timer group is stopped"); - return nullTimerHandle; - } - const h = after(delayMs, callback); - const myId = this.idGen++; - this.timerMap[myId] = h; - - const tm = this.timerMap; - - return { - clear() { - h.clear(); - delete tm[myId]; - }, - }; - } - - every(delayMs: number, callback: () => void): TimerHandle { - if (this.stopped) { - console.warn("dropping timer since timer group is stopped"); - return nullTimerHandle; - } - const h = every(delayMs, callback); - const myId = this.idGen++; - this.timerMap[myId] = h; - - const tm = this.timerMap; - - return { - clear() { - h.clear(); - delete tm[myId]; - }, - }; - } -} diff --git a/src/types-test.ts b/src/types-test.ts index 56a826441..38cb9260a 100644 --- a/src/types-test.ts +++ b/src/types-test.ts @@ -15,12 +15,16 @@ */ import test from "ava"; -import * as Amounts from "./amounts"; +import * as Amounts from "./util/amounts"; import { ContractTerms } from "./talerTypes"; -const amt = (value: number, fraction: number, currency: string): Amounts.AmountJson => ({value, fraction, currency}); +const amt = ( + value: number, + fraction: number, + currency: string, +): Amounts.AmountJson => ({ value, fraction, currency }); -test("amount addition (simple)", (t) => { +test("amount addition (simple)", t => { const a1 = amt(1, 0, "EUR"); const a2 = amt(1, 0, "EUR"); const a3 = amt(2, 0, "EUR"); @@ -28,14 +32,14 @@ test("amount addition (simple)", (t) => { t.pass(); }); -test("amount addition (saturation)", (t) => { +test("amount addition (saturation)", t => { const a1 = amt(1, 0, "EUR"); const res = Amounts.add(amt(Amounts.maxAmountValue, 0, "EUR"), a1); t.true(res.saturated); t.pass(); }); -test("amount subtraction (simple)", (t) => { +test("amount subtraction (simple)", t => { const a1 = amt(2, 5, "EUR"); const a2 = amt(1, 0, "EUR"); const a3 = amt(1, 5, "EUR"); @@ -43,7 +47,7 @@ test("amount subtraction (simple)", (t) => { t.pass(); }); -test("amount subtraction (saturation)", (t) => { +test("amount subtraction (saturation)", t => { const a1 = amt(0, 0, "EUR"); const a2 = amt(1, 0, "EUR"); let res = Amounts.sub(a1, a2); @@ -53,8 +57,7 @@ test("amount subtraction (saturation)", (t) => { t.pass(); }); - -test("amount comparison", (t) => { +test("amount comparison", t => { t.is(Amounts.cmp(amt(1, 0, "EUR"), amt(1, 0, "EUR")), 0); t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 0, "EUR")), 1); t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 2, "EUR")), -1); @@ -65,18 +68,36 @@ test("amount comparison", (t) => { t.pass(); }); - -test("amount parsing", (t) => { - t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0"), - amt(0, 0, "TESTKUDOS")), 0); - t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:10"), - amt(10, 0, "TESTKUDOS")), 0); - t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0.1"), - amt(0, 10000000, "TESTKUDOS")), 0); - t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0.00000001"), - amt(0, 1, "TESTKUDOS")), 0); - t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:4503599627370496.99999999"), - amt(4503599627370496, 99999999, "TESTKUDOS")), 0); +test("amount parsing", t => { + t.is( + Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0"), amt(0, 0, "TESTKUDOS")), + 0, + ); + t.is( + Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:10"), amt(10, 0, "TESTKUDOS")), + 0, + ); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:0.1"), + amt(0, 10000000, "TESTKUDOS"), + ), + 0, + ); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:0.00000001"), + amt(0, 1, "TESTKUDOS"), + ), + 0, + ); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:4503599627370496.99999999"), + amt(4503599627370496, 99999999, "TESTKUDOS"), + ), + 0, + ); t.throws(() => Amounts.parseOrThrow("foo:")); t.throws(() => Amounts.parseOrThrow("1.0")); t.throws(() => Amounts.parseOrThrow("42")); @@ -85,14 +106,18 @@ test("amount parsing", (t) => { t.throws(() => Amounts.parseOrThrow("EUR:.42")); t.throws(() => Amounts.parseOrThrow("EUR:42.")); t.throws(() => Amounts.parseOrThrow("TESTKUDOS:4503599627370497.99999999")); - t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0.99999999"), - amt(0, 99999999, "TESTKUDOS")), 0); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:0.99999999"), + amt(0, 99999999, "TESTKUDOS"), + ), + 0, + ); t.throws(() => Amounts.parseOrThrow("TESTKUDOS:0.999999991")); t.pass(); }); - -test("amount stringification", (t) => { +test("amount stringification", t => { t.is(Amounts.toString(amt(0, 0, "TESTKUDOS")), "TESTKUDOS:0"); t.is(Amounts.toString(amt(4, 94000000, "TESTKUDOS")), "TESTKUDOS:4.94"); t.is(Amounts.toString(amt(0, 10000000, "TESTKUDOS")), "TESTKUDOS:0.1"); @@ -103,13 +128,12 @@ test("amount stringification", (t) => { t.pass(); }); - -test("contract terms validation", (t) => { +test("contract terms validation", t => { const c = { H_wire: "123", amount: "EUR:1.5", auditors: [], - exchanges: [{master_pub: "foo", url: "foo"}], + exchanges: [{ master_pub: "foo", url: "foo" }], fulfillment_url: "foo", max_fee: "EUR:1.5", merchant_pub: "12345", diff --git a/src/util/amounts.ts b/src/util/amounts.ts new file mode 100644 index 000000000..b90d54a31 --- /dev/null +++ b/src/util/amounts.ts @@ -0,0 +1,340 @@ +/* + This file is part of TALER + (C) 2018 GNUnet e.V. and 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 + */ + + +/** + * Types and helper functions for dealing with Taler amounts. + */ + + +/** + * Imports. + */ +import { Checkable } from "./checkable"; + + +/** + * Number of fractional units that one value unit represents. + */ +export const fractionalBase = 1e8; + +/** + * How many digits behind the comma are required to represent the + * fractional value in human readable decimal format? Must match + * lg(fractionalBase) + */ +export const fractionalLength = 8; + +/** + * Maximum allowed value field of an amount. + */ +export const maxAmountValue = 2 ** 52; + + +/** + * Non-negative financial amount. Fractional values are expressed as multiples + * of 1e-8. + */ +@Checkable.Class() +export class AmountJson { + /** + * Value, must be an integer. + */ + @Checkable.Number() + readonly value: number; + + /** + * Fraction, must be an integer. Represent 1/1e8 of a unit. + */ + @Checkable.Number() + readonly fraction: number; + + /** + * Currency of the amount. + */ + @Checkable.String() + readonly currency: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => AmountJson; +} + + +/** + * Result of a possibly overflowing operation. + */ +export interface Result { + /** + * Resulting, possibly saturated amount. + */ + amount: AmountJson; + /** + * Was there an over-/underflow? + */ + saturated: boolean; +} + + +/** + * Get an amount that represents zero units of a currency. + */ +export function getZero(currency: string): AmountJson { + return { + currency, + fraction: 0, + value: 0, + }; +} + + +export function sum(amounts: AmountJson[]) { + if (amounts.length <= 0) { + throw Error("can't sum zero amounts"); + } + return add(amounts[0], ...amounts.slice(1)); +} + + +/** + * Add two amounts. Return the result and whether + * the addition overflowed. The overflow is always handled + * by saturating and never by wrapping. + * + * Throws when currencies don't match. + */ +export function add(first: AmountJson, ...rest: AmountJson[]): Result { + const currency = first.currency; + let value = first.value + Math.floor(first.fraction / fractionalBase); + if (value > maxAmountValue) { + return { + amount: { currency, value: maxAmountValue, fraction: fractionalBase - 1 }, + saturated: true + }; + } + let fraction = first.fraction % fractionalBase; + for (const x of rest) { + if (x.currency !== currency) { + throw Error(`Mismatched currency: ${x.currency} and ${currency}`); + } + + value = value + x.value + Math.floor((fraction + x.fraction) / fractionalBase); + fraction = Math.floor((fraction + x.fraction) % fractionalBase); + if (value > maxAmountValue) { + return { + amount: { currency, value: maxAmountValue, fraction: fractionalBase - 1 }, + saturated: true + }; + } + } + return { amount: { currency, value, fraction }, saturated: false }; +} + + +/** + * Subtract two amounts. Return the result and whether + * the subtraction overflowed. The overflow is always handled + * by saturating and never by wrapping. + * + * Throws when currencies don't match. + */ +export function sub(a: AmountJson, ...rest: AmountJson[]): Result { + const currency = a.currency; + let value = a.value; + let fraction = a.fraction; + + for (const 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 += fractionalBase; + } + 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 }; +} + + +/** + * Compare two amounts. Returns 0 when equal, -1 when a < b + * and +1 when a > b. Throws when currencies don't match. + */ +export function cmp(a: AmountJson, b: AmountJson): number { + if (a.currency !== b.currency) { + throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`); + } + const av = a.value + Math.floor(a.fraction / fractionalBase); + const af = a.fraction % fractionalBase; + const bv = b.value + Math.floor(b.fraction / fractionalBase); + const bf = b.fraction % fractionalBase; + 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"); + } +} + + +/** + * Create a copy of an amount. + */ +export function copy(a: AmountJson): AmountJson { + return { + currency: a.currency, + fraction: a.fraction, + value: a.value, + }; +} + + +/** + * Divide an amount. Throws on division by zero. + */ +export function divide(a: AmountJson, n: number): AmountJson { + if (n === 0) { + throw Error(`Division by 0`); + } + if (n === 1) { + return {value: a.value, fraction: a.fraction, currency: a.currency}; + } + const r = a.value % n; + return { + currency: a.currency, + fraction: Math.floor(((r * fractionalBase) + a.fraction) / n), + value: Math.floor(a.value / n), + }; +} + + +/** + * Check if an amount is non-zero. + */ +export function isNonZero(a: AmountJson): boolean { + return a.value > 0 || a.fraction > 0; +} + + +/** + * Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct. + */ +export function parse(s: string): AmountJson|undefined { + const res = s.match(/^([a-zA-Z0-9_*-]+):([0-9]+)([.][0-9]+)?$/); + if (!res) { + return undefined; + } + const tail = res[3] || ".0"; + if (tail.length > fractionalLength + 1) { + return undefined; + } + let value = Number.parseInt(res[2]); + if (value > maxAmountValue) { + return undefined; + } + return { + currency: res[1], + fraction: Math.round(fractionalBase * Number.parseFloat(tail)), + value, + }; +} + + +/** + * Parse amount in standard string form (like 'EUR:20.5'), + * throw if the input is not a valid amount. + */ +export function parseOrThrow(s: string): AmountJson { + const res = parse(s); + if (!res) { + throw Error(`Can't parse amount: "${s}"`); + } + return res; +} + + +/** + * Convert a float to a Taler amount. + * Loss of precision possible. + */ +export function fromFloat(floatVal: number, currency: string) { + return { + currency, + fraction: Math.floor((floatVal - Math.floor(floatVal)) * fractionalBase), + value: Math.floor(floatVal), + }; +} + + +/** + * Convert to standard human-readable string representation that's + * also used in JSON formats. + */ +export function toString(a: AmountJson): string { + const av = a.value + Math.floor(a.fraction / fractionalBase); + const af = a.fraction % fractionalBase; + let s = av.toString() + + if (af) { + s = s + "."; + let n = af; + for (let i = 0; i < fractionalLength; i++) { + if (!n) { + break; + } + s = s + Math.floor(n / fractionalBase * 10).toString(); + n = (n * 10) % fractionalBase; + } + } + + return `${a.currency}:${s}`; +} + + +/** + * Check if the argument is a valid amount in string form. + */ +export function check(a: any): boolean { + if (typeof a !== "string") { + return false; + } + try { + const parsedAmount = parse(a); + return !!parsedAmount; + } catch { + return false; + } +} diff --git a/src/util/assertUnreachable.ts b/src/util/assertUnreachable.ts new file mode 100644 index 000000000..90f2476b4 --- /dev/null +++ b/src/util/assertUnreachable.ts @@ -0,0 +1,19 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +export function assertUnreachable(x: never): never { + throw new Error("Didn't expect to get here"); +} \ No newline at end of file diff --git a/src/util/asyncMemo.ts b/src/util/asyncMemo.ts new file mode 100644 index 000000000..8b7b1c9bb --- /dev/null +++ b/src/util/asyncMemo.ts @@ -0,0 +1,52 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +export interface MemoEntry { + p: Promise; + t: number; + n: number; +} + +export class AsyncOpMemo { + n = 0; + memo: { [k: string]: MemoEntry } = {}; + put(key: string, p: Promise): Promise { + const n = this.n++; + this.memo[key] = { + p, + n, + t: new Date().getTime(), + }; + p.finally(() => { + const r = this.memo[key]; + if (r && r.n === n) { + delete this.memo[key]; + } + }); + return p; + } + find(key: string): Promise | undefined { + const res = this.memo[key]; + const tNow = new Date().getTime(); + if (res && res.t < tNow - 10 * 1000) { + delete this.memo[key]; + return; + } else if (res) { + return res.p; + } + return; + } +} \ No newline at end of file diff --git a/src/util/checkable.ts b/src/util/checkable.ts new file mode 100644 index 000000000..3c9fe5bc1 --- /dev/null +++ b/src/util/checkable.ts @@ -0,0 +1,417 @@ +/* + 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 + */ + + +/** + * Decorators for validating JSON objects and converting them to a typed + * object. + * + * The decorators are put onto classes, and the validation is done + * via a static method that is filled in by the annotation. + * + * Example: + * ``` + * @Checkable.Class + * class Person { + * @Checkable.String + * name: string; + * @Checkable.Number + * age: number; + * + * // Method will be implemented automatically + * static checked(obj: any): Person; + * } + * ``` + */ +export namespace Checkable { + + type Path = Array; + + interface SchemaErrorConstructor { + new (err: string): SchemaError; + } + + interface SchemaError { + name: string; + message: string; + } + + interface Prop { + propertyKey: any; + checker: any; + type?: any; + typeThunk?: () => any; + elementChecker?: any; + elementProp?: any; + keyProp?: any; + stringChecker?: (s: string) => boolean; + valueProp?: any; + optional?: boolean; + } + + interface CheckableInfo { + extraAllowed: boolean; + props: Prop[]; + } + + // tslint:disable-next-line:no-shadowed-variable + export const SchemaError = (function SchemaError(this: any, message: string) { + const that: any = this as any; + that.name = "SchemaError"; + that.message = message; + that.stack = (new Error() as any).stack; + }) as any as SchemaErrorConstructor; + + + SchemaError.prototype = new Error(); + + /** + * Classes that are checkable are annotated with this + * checkable info symbol, which contains the information necessary + * to check if they're valid. + */ + const checkableInfoSym = Symbol("checkableInfo"); + + /** + * Get the current property list for a checkable type. + */ + function getCheckableInfo(target: any): CheckableInfo { + let chk = target[checkableInfoSym] as CheckableInfo|undefined; + if (!chk) { + chk = { props: [], extraAllowed: false }; + target[checkableInfoSym] = chk; + } + return chk; + } + + + 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`); + } + if (prop.stringChecker && !prop.stringChecker(target)) { + throw new SchemaError(`string property ${path} malformed`); + } + return target; + } + + function checkBoolean(target: any, prop: Prop, path: Path): any { + if (typeof target !== "boolean") { + throw new SchemaError(`expected boolean 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++) { + const v = target[i]; + prop.elementChecker(v, prop.elementProp, path.concat([i])); + } + return target; + } + + function checkMap(target: any, prop: Prop, path: Path): any { + if (typeof target !== "object") { + throw new SchemaError(`expected object for ${path}, got ${typeof target} instead`); + } + for (const key in target) { + prop.keyProp.checker(key, prop.keyProp, path.concat([key])); + const value = target[key]; + prop.valueProp.checker(value, prop.valueProp, path.concat([key])); + } + 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; + if (prop.type) { + type = prop.type; + } else if (prop.typeThunk) { + type = prop.typeThunk(); + if (!type) { + throw Error(`assertion failed: typeThunk returned null (prop is ${JSON.stringify(prop)})`); + } + } else { + throw Error(`assertion failed: type/typeThunk missing (prop is ${JSON.stringify(prop)})`); + } + const typeName = type.name || "??"; + const v = target; + if (!v || typeof v !== "object") { + throw new SchemaError( + `expected object for ${path.join(".")}, got ${typeof v} instead`); + } + const chk = type.prototype[checkableInfoSym]; + const props = chk.props; + const remainingPropNames = new Set(Object.getOwnPropertyNames(v)); + const obj = new type(); + for (const innerProp of props) { + if (!remainingPropNames.has(innerProp.propertyKey)) { + if (innerProp.optional) { + continue; + } + throw new SchemaError(`Property '${innerProp.propertyKey}' missing on '${path}' of '${typeName}'`); + } + if (!remainingPropNames.delete(innerProp.propertyKey)) { + throw new SchemaError("assertion failed"); + } + const propVal = v[innerProp.propertyKey]; + obj[innerProp.propertyKey] = innerProp.checker(propVal, + innerProp, + path.concat([innerProp.propertyKey])); + } + + if (!chk.extraAllowed && remainingPropNames.size !== 0) { + const err = `superfluous properties ${JSON.stringify(Array.from(remainingPropNames.values()))} of ${typeName}`; + throw new SchemaError(err); + } + return obj; + } + + + /** + * Class with checkable annotations on fields. + * This annotation adds the implementation of the `checked` + * static method. + */ + export function Class(opts: {extra?: boolean, validate?: boolean} = {}) { + return (target: any) => { + const chk = getCheckableInfo(target.prototype); + chk.extraAllowed = !!opts.extra; + target.checked = (v: any) => { + const cv = checkValue(v, { + checker: checkValue, + propertyKey: "(root)", + type: target, + }, ["(root)"]); + if (opts.validate) { + if (typeof target.validate !== "function") { + throw Error("invalid Checkable annotion: validate method required"); + } + // May throw exception + target.validate(cv); + } + return cv; + }; + return target; + }; + } + + + /** + * Target property must be a Checkable object of the given type. + */ + export function Value(typeThunk: () => any) { + function deco(target: object, propertyKey: string | symbol): void { + const chk = getCheckableInfo(target); + chk.props.push({ + checker: checkValue, + propertyKey, + typeThunk, + }); + } + + return deco; + } + + + /** + * List of values that match the given annotation. For example, `@Checkable.List(Checkable.String)` is + * an annotation for a list of strings. + */ + export function List(type: any) { + const stub = {}; + type(stub, "(list-element)"); + const elementProp = getCheckableInfo(stub).props[0]; + const elementChecker = elementProp.checker; + if (!elementChecker) { + throw Error("assertion failed"); + } + function deco(target: object, propertyKey: string | symbol): void { + const chk = getCheckableInfo(target); + chk.props.push({ + checker: checkList, + elementChecker, + elementProp, + propertyKey, + }); + } + + return deco; + } + + + /** + * Map from the key type to value type. Takes two annotations, + * one for the key type and one for the value type. + */ + export function Map(keyType: any, valueType: any) { + const keyStub = {}; + keyType(keyStub, "(map-key)"); + const keyProp = getCheckableInfo(keyStub).props[0]; + if (!keyProp) { + throw Error("assertion failed"); + } + const valueStub = {}; + valueType(valueStub, "(map-value)"); + const valueProp = getCheckableInfo(valueStub).props[0]; + if (!valueProp) { + throw Error("assertion failed"); + } + function deco(target: object, propertyKey: string | symbol): void { + const chk = getCheckableInfo(target); + chk.props.push({ + checker: checkMap, + keyProp, + propertyKey, + valueProp, + }); + } + + return deco; + } + + + /** + * Makes another annotation optional, for example `@Checkable.Optional(Checkable.Number)`. + */ + export function Optional(type: (target: object, propertyKey: string | symbol) => void | any) { + const stub = {}; + type(stub, "(optional-element)"); + const elementProp = getCheckableInfo(stub).props[0]; + const elementChecker = elementProp.checker; + if (!elementChecker) { + throw Error("assertion failed"); + } + function deco(target: object, propertyKey: string | symbol): void { + const chk = getCheckableInfo(target); + chk.props.push({ + checker: checkOptional, + elementChecker, + elementProp, + optional: true, + propertyKey, + }); + } + + return deco; + } + + + /** + * Target property must be a number. + */ + export function Number(): (target: object, propertyKey: string | symbol) => void { + const deco = (target: object, propertyKey: string | symbol) => { + const chk = getCheckableInfo(target); + chk.props.push({checker: checkNumber, propertyKey}); + }; + return deco; + } + + + /** + * Target property must be an arbitary object. + */ + export function AnyObject(): (target: object, propertyKey: string | symbol) => void { + const deco = (target: object, propertyKey: string | symbol) => { + const chk = getCheckableInfo(target); + chk.props.push({ + checker: checkAnyObject, + propertyKey, + }); + }; + return deco; + } + + + /** + * Target property can be anything. + * + * Not useful by itself, but in combination with higher-order annotations + * such as List or Map. + */ + export function Any(): (target: object, propertyKey: string | symbol) => void { + const deco = (target: object, propertyKey: string | symbol) => { + const chk = getCheckableInfo(target); + chk.props.push({ + checker: checkAny, + optional: true, + propertyKey, + }); + }; + return deco; + } + + + /** + * Target property must be a string. + */ + export function String( + stringChecker?: (s: string) => boolean): (target: object, propertyKey: string | symbol, + ) => void { + const deco = (target: object, propertyKey: string | symbol) => { + const chk = getCheckableInfo(target); + chk.props.push({ checker: checkString, propertyKey, stringChecker }); + }; + return deco; + } + + /** + * Target property must be a boolean value. + */ + export function Boolean(): (target: object, propertyKey: string | symbol) => void { + const deco = (target: object, propertyKey: string | symbol) => { + const chk = getCheckableInfo(target); + chk.props.push({ checker: checkBoolean, propertyKey }); + }; + return deco; + } +} diff --git a/src/util/helpers-test.ts b/src/util/helpers-test.ts new file mode 100644 index 000000000..74817120a --- /dev/null +++ b/src/util/helpers-test.ts @@ -0,0 +1,38 @@ +/* + This file is part of TALER + (C) 2017 Inria and 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 + */ + + +import test from "ava"; +import * as helpers from "./helpers"; + + +test("URL canonicalization", (t) => { + // converts to relative, adds https + t.is( + "https://alice.example.com/exchange/", + helpers.canonicalizeBaseUrl("alice.example.com/exchange")); + + // keeps http, adds trailing slash + t.is( + "http://alice.example.com/exchange/", + helpers.canonicalizeBaseUrl("http://alice.example.com/exchange")); + + // keeps http, adds trailing slash + t.is( + "http://alice.example.com/exchange/", + helpers.canonicalizeBaseUrl("http://alice.example.com/exchange#foobar")); + t.pass(); +}); diff --git a/src/util/helpers.ts b/src/util/helpers.ts new file mode 100644 index 000000000..eb8a1c7b2 --- /dev/null +++ b/src/util/helpers.ts @@ -0,0 +1,204 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see + */ + +/** + * Small helper functions that don't fit anywhere else. + */ + +/** + * Imports. + */ +import { AmountJson } from "./amounts"; +import * as Amounts from "./amounts"; + +import { Timestamp } from "../walletTypes"; + +/** + * Show an amount in a form suitable for the user. + * FIXME: In the future, this should consider currency-specific + * settings such as significant digits or currency symbols. + */ +export function amountToPretty(amount: AmountJson): string { + const x = amount.value + amount.fraction / Amounts.fractionalBase; + 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) { + if (!url.startsWith("http") && !url.startsWith("https")) { + url = "https://" + url; + } + const x = new URL(url); + if (!x.pathname.endsWith("/")) { + x.pathname = x.pathname + "/"; + } + x.search = ""; + x.hash = ""; + return x.href; +} + + +/** + * 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)) { + const objs: string[] = obj.map((e) => canonicalJson(e)); + return `[${objs.join(",")}]`; + } + const keys: string[] = []; + for (const key in obj) { + keys.push(key); + } + keys.sort(); + let s = "{"; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + s += JSON.stringify(key) + ":" + canonicalJson(obj[key]); + if (i !== keys.length - 1) { + s += ","; + } + } + return s + "}"; +} + + +/** + * Check for deep equality of two objects. + * Only arrays, objects and primitives are supported. + */ +export function deepEquals(x: any, y: any): boolean { + if (x === y) { + return true; + } + + if (Array.isArray(x) && x.length !== y.length) { + return false; + } + + const p = Object.keys(x); + return Object.keys(y).every((i) => p.indexOf(i) !== -1) && + p.every((i) => deepEquals(x[i], y[i])); +} + + +/** + * Map from a collection to a list or results and then + * concatenate the results. + */ +export function flatMap(xs: T[], f: (x: T) => U[]): U[] { + return xs.reduce((acc: U[], next: T) => [...f(next), ...acc], []); +} + + +/** + * Extract a numeric timstamp (in seconds) from the Taler date format + * ("/Date([n])/"). Returns null if input is not in the right format. + */ +export function getTalerStampSec(stamp: string): number | null { + const m = stamp.match(/\/?Date\(([0-9]*)\)\/?/); + if (!m || !m[1]) { + return null; + } + return parseInt(m[1], 10); +} + +/** + * Extract a timestamp from a Taler timestamp string. + */ +export function extractTalerStamp(stamp: string): Timestamp | undefined { + const m = stamp.match(/\/?Date\(([0-9]*)\)\/?/); + if (!m || !m[1]) { + return undefined; + } + return { + t_ms: parseInt(m[1], 10) * 1000, + }; +} + +/** + * Extract a timestamp from a Taler timestamp string. + */ +export function extractTalerStampOrThrow(stamp: string): Timestamp { + const r = extractTalerStamp(stamp); + if (!r) { + throw Error("invalid time stamp"); + } + return r; +} + +/** + * Check if a timestamp is in the right format. + */ +export function timestampCheck(stamp: string): boolean { + return getTalerStampSec(stamp) !== null; +} + + +/** + * Get a JavaScript Date object from a Taler date string. + * Returns null if input is not in the right format. + */ +export function getTalerStampDate(stamp: string): Date | null { + const sec = getTalerStampSec(stamp); + if (sec == null) { + return null; + } + return new Date(sec * 1000); +} + +/** + * Compute the hash function of a JSON object. + */ +export function hash(val: any): number { + const str = canonicalJson(val); + // https://github.com/darkskyapp/string-hash + let h = 5381; + let i = str.length; + while (i) { + h = (h * 33) ^ str.charCodeAt(--i); + } + + /* JavaScript does bitwise operations (like XOR, above) on 32-bit signed + * integers. Since we want the results to be always positive, convert the + * signed int to an unsigned by doing an unsigned bitshift. */ + return h >>> 0; +} + + +/** + * Lexically compare two strings. + */ +export function strcmp(s1: string, s2: string): number { + if (s1 < s2) { + return -1; + } + if (s1 > s2) { + return 1; + } + return 0; +} diff --git a/src/util/http.ts b/src/util/http.ts new file mode 100644 index 000000000..a2bfab279 --- /dev/null +++ b/src/util/http.ts @@ -0,0 +1,109 @@ +/* + 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 + */ + +/** + * Helpers for doing XMLHttpRequest-s that are based on ES6 promises. + * Allows for easy mocking for test cases. + */ + +/** + * An HTTP response that is returned by all request methods of this library. + */ +export interface HttpResponse { + status: number; + responseJson: object & any; +} + +/** + * The request library is bundled into an interface to make mocking easy. + */ +export interface HttpRequestLibrary { + get(url: string): Promise; + + postJson(url: string, body: any): Promise; +} + +/** + * An implementation of the [[HttpRequestLibrary]] using the + * browser's XMLHttpRequest. + */ +export class BrowserHttpLib implements HttpRequestLibrary { + private req( + method: string, + url: string, + options?: any, + ): Promise { + return new Promise((resolve, reject) => { + const myRequest = new XMLHttpRequest(); + myRequest.open(method, url); + if (options && options.req) { + myRequest.send(options.req); + } else { + myRequest.send(); + } + + myRequest.onerror = e => { + console.error("http request error"); + reject(Error("could not make XMLHttpRequest")); + }; + + myRequest.addEventListener("readystatechange", e => { + if (myRequest.readyState === XMLHttpRequest.DONE) { + if (myRequest.status === 0) { + reject(Error("HTTP Request failed (status code 0, maybe URI scheme is wrong?)")) + return; + } + if (myRequest.status != 200) { + reject( + Error( + `HTTP Response with unexpected status code ${myRequest.status}: ${myRequest.statusText}`, + ), + ); + return; + } + let responseJson; + try { + responseJson = JSON.parse(myRequest.responseText); + } catch (e) { + reject(Error("Invalid JSON from HTTP response")); + return; + } + if (responseJson === null || typeof responseJson !== "object") { + reject(Error("Invalid JSON from HTTP response")); + return; + } + const resp = { + responseJson: responseJson, + status: myRequest.status, + }; + resolve(resp); + } + }); + }); + } + + get(url: string) { + return this.req("get", url); + } + + postJson(url: string, body: any) { + return this.req("post", url, { req: JSON.stringify(body) }); + } + + postForm(url: string, form: any) { + return this.req("post", url, { req: form }); + } +} diff --git a/src/util/libtoolVersion-test.ts b/src/util/libtoolVersion-test.ts new file mode 100644 index 000000000..0a610e455 --- /dev/null +++ b/src/util/libtoolVersion-test.ts @@ -0,0 +1,30 @@ +/* + This file is part of TALER + (C) 2017 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 + */ + +import * as LibtoolVersion from "./libtoolVersion"; + +import test from "ava"; + +test("version comparison", (t) => { + t.deepEqual(LibtoolVersion.compare("0:0:0", "0:0:0"), {compatible: true, currentCmp: 0}); + t.deepEqual(LibtoolVersion.compare("0:0:0", ""), undefined); + t.deepEqual(LibtoolVersion.compare("foo", "0:0:0"), undefined); + t.deepEqual(LibtoolVersion.compare("0:0:0", "1:0:1"), {compatible: true, currentCmp: -1}); + t.deepEqual(LibtoolVersion.compare("0:0:0", "1:5:1"), {compatible: true, currentCmp: -1}); + t.deepEqual(LibtoolVersion.compare("0:0:0", "1:5:0"), {compatible: false, currentCmp: -1}); + t.deepEqual(LibtoolVersion.compare("1:0:0", "0:5:0"), {compatible: false, currentCmp: 1}); + t.deepEqual(LibtoolVersion.compare("1:0:1", "1:5:1"), {compatible: true, currentCmp: 0}); +}); diff --git a/src/util/libtoolVersion.ts b/src/util/libtoolVersion.ts new file mode 100644 index 000000000..cc2435b94 --- /dev/null +++ b/src/util/libtoolVersion.ts @@ -0,0 +1,86 @@ +/* + This file is part of TALER + (C) 2017 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 + */ + +/** + * Semantic versioning, but libtool-style. + * See https://www.gnu.org/software/libtool/manual/html_node/Libtool-versioning.html + */ + + +/** + * Result of comparing two libtool versions. + */ +export interface VersionMatchResult { + /** + * Is the first version compatible with the second? + */ + compatible: boolean; + /** + * Is the first version older (-1), newser (+1) or + * identical (0)? + */ + currentCmp: number; +} + +interface Version { + current: number; + revision: number; + age: number; +} + +/** + * Compare two libtool-style version strings. + */ +export function compare(me: string, other: string): VersionMatchResult|undefined { + const meVer = parseVersion(me); + const otherVer = parseVersion(other); + + if (!(meVer && otherVer)) { + return undefined; + } + + const compatible = (meVer.current - meVer.age <= otherVer.current && + meVer.current >= (otherVer.current - otherVer.age)); + + const currentCmp = Math.sign(meVer.current - otherVer.current); + + return {compatible, currentCmp}; +} + + +function parseVersion(v: string): Version|undefined { + const [currentStr, revisionStr, ageStr, ...rest] = v.split(":"); + if (rest.length !== 0) { + return undefined; + } + const current = Number.parseInt(currentStr); + const revision = Number.parseInt(revisionStr); + const age = Number.parseInt(ageStr); + + if (Number.isNaN(current)) { + return undefined; + } + + if (Number.isNaN(revision)) { + return undefined; + } + + if (Number.isNaN(age)) { + return undefined; + } + + return {current, revision, age}; +} diff --git a/src/util/logging.ts b/src/util/logging.ts new file mode 100644 index 000000000..309d1593b --- /dev/null +++ b/src/util/logging.ts @@ -0,0 +1,25 @@ +/* + This file is part of TALER + (C) 2019 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 + */ + +export class Logger { + constructor(private tag: string) {} + info(message: string, ...args: any[]) { + console.log(`${new Date().toISOString()} ${this.tag} INFO ` + message, ...args); + } + trace(message: any, ...args: any[]) { + console.log(`${new Date().toISOString()} ${this.tag} TRACE ` + message, ...args) + } +} \ No newline at end of file diff --git a/src/util/payto-test.ts b/src/util/payto-test.ts new file mode 100644 index 000000000..82daff164 --- /dev/null +++ b/src/util/payto-test.ts @@ -0,0 +1,31 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import test from "ava"; + +import { parsePaytoUri } from "./payto"; + +test("basic payto parsing", (t) => { + const r1 = parsePaytoUri("https://example.com/"); + t.is(r1, undefined); + + const r2 = parsePaytoUri("payto:blabla"); + t.is(r2, undefined); + + const r3 = parsePaytoUri("payto://x-taler-bank/123"); + t.is(r3?.targetType, "x-taler-bank"); + t.is(r3?.targetPath, "123"); +}); \ No newline at end of file diff --git a/src/util/payto.ts b/src/util/payto.ts new file mode 100644 index 000000000..0926fdeed --- /dev/null +++ b/src/util/payto.ts @@ -0,0 +1,54 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +interface PaytoUri { + targetType: string; + targetPath: string; + params: { [name: string]: string }; +} + + +export function parsePaytoUri(s: string): PaytoUri | undefined { + const pfx = "payto://" + if (!s.startsWith(pfx)) { + return undefined; + } + + const [acct, search] = s.slice(pfx.length).split("?"); + + const firstSlashPos = acct.indexOf("/"); + + if (firstSlashPos === -1) { + return undefined; + } + + const targetType = acct.slice(0, firstSlashPos); + const targetPath = acct.slice(firstSlashPos + 1); + + const params: { [k: string]: string } = {}; + + const searchParams = new URLSearchParams(search || ""); + + searchParams.forEach((v, k) => { + params[v] = k; + }); + + return { + targetPath, + targetType, + params, + } +} \ No newline at end of file diff --git a/src/util/promiseUtils.ts b/src/util/promiseUtils.ts new file mode 100644 index 000000000..eb649471b --- /dev/null +++ b/src/util/promiseUtils.ts @@ -0,0 +1,39 @@ +/* + This file is part of GNU Taler + (C) 2019 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 + */ + + export interface OpenedPromise { + promise: Promise; + resolve: (val: T) => void; + reject: (err: any) => void; + } + +/** + * Get an unresolved promise together with its extracted resolve / reject + * function. + */ +export function openPromise(): OpenedPromise { + let resolve: ((x?: any) => void) | null = null; + let reject: ((reason?: any) => void) | null = null; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + if (!(resolve && reject)) { + // Never happens, unless JS implementation is broken + throw Error(); + } + return { resolve, reject, promise }; +} \ No newline at end of file diff --git a/src/util/query.ts b/src/util/query.ts new file mode 100644 index 000000000..5726bcaa6 --- /dev/null +++ b/src/util/query.ts @@ -0,0 +1,446 @@ +/* + 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 + */ + +/** + * Database query abstractions. + * @module Query + * @author Florian Dold + */ + +/** + * Imports. + */ +import { openPromise } from "./promiseUtils"; + + +/** + * Result of an inner join. + */ +export interface JoinResult { + left: L; + right: R; +} + +/** + * Result of a left outer join. + */ +export interface JoinLeftResult { + left: L; + right?: R; +} + +/** + * Definition of an object store. + */ +export class Store { + constructor( + public name: string, + public storeParams?: IDBObjectStoreParameters, + public validator?: (v: T) => T, + ) {} +} + +/** + * Options for an index. + */ +export interface IndexOptions { + /** + * If true and the path resolves to an array, create an index entry for + * each member of the array (instead of one index entry containing the full array). + * + * Defaults to false. + */ + multiEntry?: boolean; +} + +function requestToPromise(req: IDBRequest): Promise { + const stack = Error("Failed request was started here.") + return new Promise((resolve, reject) => { + req.onsuccess = () => { + resolve(req.result); + }; + req.onerror = () => { + console.log("error in DB request", req.error); + reject(req.error); + console.log("Request failed:", stack); + }; + }); +} + +function transactionToPromise(tx: IDBTransaction): Promise { + const stack = Error("Failed transaction was started here."); + return new Promise((resolve, reject) => { + tx.onabort = () => { + reject(TransactionAbort); + }; + tx.oncomplete = () => { + resolve(); + }; + tx.onerror = () => { + console.error("Transaction failed:", stack); + reject(tx.error); + }; + }); +} + +export async function oneShotGet( + db: IDBDatabase, + store: Store, + key: any, +): Promise { + const tx = db.transaction([store.name], "readonly"); + const req = tx.objectStore(store.name).get(key); + const v = await requestToPromise(req) + await transactionToPromise(tx); + return v; +} + +export async function oneShotGetIndexed( + db: IDBDatabase, + index: Index, + key: any, +): Promise { + const tx = db.transaction([index.storeName], "readonly"); + const req = tx + .objectStore(index.storeName) + .index(index.indexName) + .get(key); + const v = await requestToPromise(req); + await transactionToPromise(tx); + return v; +} + +export async function oneShotPut( + db: IDBDatabase, + store: Store, + value: T, + key?: any, +): Promise { + const tx = db.transaction([store.name], "readwrite"); + const req = tx.objectStore(store.name).put(value, key); + const v = await requestToPromise(req); + await transactionToPromise(tx); + return v; +} + +function applyMutation( + req: IDBRequest, + f: (x: T) => T | undefined, +): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => { + const cursor = req.result; + if (cursor) { + const val = cursor.value; + const modVal = f(val); + if (modVal !== undefined && modVal !== null) { + const req2: IDBRequest = cursor.update(modVal); + req2.onerror = () => { + reject(req2.error); + }; + req2.onsuccess = () => { + cursor.continue(); + }; + } else { + cursor.continue(); + } + } else { + resolve(); + } + }; + req.onerror = () => { + reject(req.error); + }; + }); +} + +export async function oneShotMutate( + db: IDBDatabase, + store: Store, + key: any, + f: (x: T) => T | undefined, +): Promise { + const tx = db.transaction([store.name], "readwrite"); + const req = tx.objectStore(store.name).openCursor(key); + await applyMutation(req, f); + await transactionToPromise(tx); +} + +type CursorResult = CursorEmptyResult | CursorValueResult; + +interface CursorEmptyResult { + hasValue: false; +} + +interface CursorValueResult { + hasValue: true; + value: T; +} + +class ResultStream { + private currentPromise: Promise; + private gotCursorEnd: boolean = false; + private awaitingResult: boolean = false; + + constructor(private req: IDBRequest) { + this.awaitingResult = true; + let p = openPromise(); + this.currentPromise = p.promise; + req.onsuccess = () => { + if (!this.awaitingResult) { + throw Error("BUG: invariant violated"); + } + const cursor = req.result; + if (cursor) { + this.awaitingResult = false; + p.resolve(); + p = openPromise(); + this.currentPromise = p.promise; + } else { + this.gotCursorEnd = true; + p.resolve(); + } + }; + req.onerror = () => { + p.reject(req.error); + }; + } + + async toArray(): Promise { + const arr: T[] = []; + while (true) { + const x = await this.next(); + if (x.hasValue) { + arr.push(x.value); + } else { + break; + } + } + return arr; + } + + async map(f: (x: T) => R): Promise { + const arr: R[] = []; + while (true) { + const x = await this.next(); + if (x.hasValue) { + arr.push(f(x.value)); + } else { + break; + } + } + return arr; + } + + async forEach(f: (x: T) => void): Promise { + while (true) { + const x = await this.next(); + if (x.hasValue) { + f(x.value); + } else { + break; + } + } + } + + async filter(f: (x: T) => boolean): Promise { + const arr: T[] = []; + while (true) { + const x = await this.next(); + if (x.hasValue) { + if (f(x.value)) { + arr.push(x.value); + } + } else { + break; + } + } + return arr; + } + + async next(): Promise> { + if (this.gotCursorEnd) { + return { hasValue: false }; + } + if (!this.awaitingResult) { + const cursor = this.req.result; + if (!cursor) { + throw Error("assertion failed"); + } + this.awaitingResult = true; + cursor.continue(); + } + await this.currentPromise; + if (this.gotCursorEnd) { + return { hasValue: false }; + } + const cursor = this.req.result; + if (!cursor) { + throw Error("assertion failed"); + } + return { hasValue: true, value: cursor.value }; + } +} + +export function oneShotIter( + db: IDBDatabase, + store: Store, +): ResultStream { + const tx = db.transaction([store.name], "readonly"); + const req = tx.objectStore(store.name).openCursor(); + return new ResultStream(req); +} + +export function oneShotIterIndex( + db: IDBDatabase, + index: Index, + query?: any, +): ResultStream { + const tx = db.transaction([index.storeName], "readonly"); + const req = tx + .objectStore(index.storeName) + .index(index.indexName) + .openCursor(query); + return new ResultStream(req); +} + +class TransactionHandle { + constructor(private tx: IDBTransaction) {} + + put(store: Store, value: T, key?: any): Promise { + const req = this.tx.objectStore(store.name).put(value, key); + return requestToPromise(req); + } + + add(store: Store, value: T, key?: any): Promise { + const req = this.tx.objectStore(store.name).add(value, key); + return requestToPromise(req); + } + + get(store: Store, key: any): Promise { + const req = this.tx.objectStore(store.name).get(key); + return requestToPromise(req); + } + + iter(store: Store, key?: any): ResultStream { + const req = this.tx.objectStore(store.name).openCursor(key); + return new ResultStream(req); + } + + delete(store: Store, key: any): Promise { + const req = this.tx.objectStore(store.name).delete(key); + return requestToPromise(req); + } + + mutate(store: Store, key: any, f: (x: T) => T | undefined) { + const req = this.tx.objectStore(store.name).openCursor(key); + return applyMutation(req, f); + } +} + +export function runWithWriteTransaction( + db: IDBDatabase, + stores: Store[], + f: (t: TransactionHandle) => Promise, +): Promise { + const stack = Error("Failed transaction was started here."); + return new Promise((resolve, reject) => { + const storeName = stores.map(x => x.name); + const tx = db.transaction(storeName, "readwrite"); + let funResult: any = undefined; + let gotFunResult: boolean = false; + tx.oncomplete = () => { + // This is a fatal error: The transaction completed *before* + // the transaction function returned. Likely, the transaction + // function waited on a promise that is *not* resolved in the + // microtask queue, thus triggering the auto-commit behavior. + // Unfortunately, the auto-commit behavior of IDB can't be switched + // of. There are some proposals to add this functionality in the future. + if (!gotFunResult) { + const msg = + "BUG: transaction closed before transaction function returned"; + console.error(msg); + reject(Error(msg)); + } + resolve(funResult); + }; + tx.onerror = () => { + console.error("error in transaction"); + }; + tx.onabort = () => { + if (tx.error) { + console.error("Transaction aborted with error:", tx.error); + } else { + console.log("Trasaction aborted (no error)"); + } + reject(TransactionAbort); + }; + const th = new TransactionHandle(tx); + const resP = f(th); + resP.then(result => { + gotFunResult = true; + funResult = result; + }).catch((e) => { + if (e == TransactionAbort) { + console.info("aborting transaction"); + } else { + tx.abort(); + console.error("Transaction failed:", e); + console.error(stack); + } + }); + }); +} + +/** + * Definition of an index. + */ +export class Index { + /** + * Name of the store that this index is associated with. + */ + storeName: string; + + /** + * Options to use for the index. + */ + options: IndexOptions; + + constructor( + s: Store, + public indexName: string, + public keyPath: string | string[], + options?: IndexOptions, + ) { + const defaultOptions = { + multiEntry: false, + }; + this.options = { ...defaultOptions, ...(options || {}) }; + this.storeName = s.name; + } + + /** + * We want to have the key type parameter in use somewhere, + * because otherwise the compiler complains. In iterIndex the + * key type is pretty useful. + */ + protected _dummyKey: S | undefined; +} + +/** + * Exception that should be thrown by client code to abort a transaction. + */ +export const TransactionAbort = Symbol("transaction_abort"); diff --git a/src/util/taleruri-test.ts b/src/util/taleruri-test.ts new file mode 100644 index 000000000..02eecf209 --- /dev/null +++ b/src/util/taleruri-test.ts @@ -0,0 +1,230 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import test from "ava"; +import { + parsePayUri, + parseWithdrawUri, + parseRefundUri, + parseTipUri, +} from "./taleruri"; + +test("taler pay url parsing: http(s)", t => { + const url1 = "https://example.com/bar?spam=eggs"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.downloadUrl, url1); + t.is(r1.sessionId, undefined); + const url2 = "http://example.com/bar?spam=eggs"; + const r2 = parsePayUri(url2); + if (!r2) { + t.fail(); + return; + } +}); + +test("taler pay url parsing: wrong scheme", t => { + const url1 = "talerfoo://"; + const r1 = parsePayUri(url1); + t.is(r1, undefined); + + const url2 = "taler://refund/a/b/c/d/e/f"; + const r2 = parsePayUri(url1); + t.is(r2, undefined); +}); + +test("taler pay url parsing: defaults", t => { + const url1 = "taler://pay/example.com/-/-/myorder"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.downloadUrl, "https://example.com/public/proposal?order_id=myorder"); + t.is(r1.sessionId, undefined); + + const url2 = "taler://pay/example.com/-/-/myorder/mysession"; + const r2 = parsePayUri(url2); + if (!r2) { + t.fail(); + return; + } + t.is(r2.downloadUrl, "https://example.com/public/proposal?order_id=myorder"); + t.is(r2.sessionId, "mysession"); +}); + +test("taler pay url parsing: trailing parts", t => { + const url1 = "taler://pay/example.com/-/-/myorder/mysession/spam/eggs"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.downloadUrl, "https://example.com/public/proposal?order_id=myorder"); + t.is(r1.sessionId, "mysession"); +}); + +test("taler pay url parsing: instance", t => { + const url1 = "taler://pay/example.com/-/myinst/myorder"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.downloadUrl, + "https://example.com/public/instances/myinst/proposal?order_id=myorder", + ); +}); + +test("taler pay url parsing: path prefix and instance", t => { + const url1 = "taler://pay/example.com/mypfx/myinst/myorder"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.downloadUrl, + "https://example.com/mypfx/instances/myinst/proposal?order_id=myorder", + ); +}); + +test("taler pay url parsing: complex path prefix", t => { + const url1 = "taler://pay/example.com/mypfx%2Fpublic/-/myorder"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.downloadUrl, + "https://example.com/mypfx/public/proposal?order_id=myorder", + ); +}); + +test("taler pay url parsing: complex path prefix and instance", t => { + const url1 = "taler://pay/example.com/mypfx%2Fpublic/foo/myorder"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.downloadUrl, + "https://example.com/mypfx/public/instances/foo/proposal?order_id=myorder", + ); +}); + +test("taler pay url parsing: non-https #1", t => { + const url1 = "taler://pay/example.com/-/-/myorder?insecure=1"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.downloadUrl, "http://example.com/public/proposal?order_id=myorder"); +}); + +test("taler pay url parsing: non-https #2", t => { + const url1 = "taler://pay/example.com/-/-/myorder?insecure=2"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.downloadUrl, "https://example.com/public/proposal?order_id=myorder"); +}); + +test("taler withdraw uri parsing", t => { + const url1 = "taler://withdraw/bank.example.com/-/12345"; + const r1 = parseWithdrawUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.statusUrl, "https://bank.example.com/api/withdraw-operation/12345"); +}); + +test("taler refund uri parsing", t => { + const url1 = "taler://refund/merchant.example.com/-/-/1234"; + const r1 = parseRefundUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.refundUrl, + "https://merchant.example.com/public/refund?order_id=1234", + ); +}); + +test("taler refund uri parsing with instance", t => { + const url1 = "taler://refund/merchant.example.com/-/myinst/1234"; + const r1 = parseRefundUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.refundUrl, + "https://merchant.example.com/public/instances/myinst/refund?order_id=1234", + ); +}); + +test("taler tip pickup uri", t => { + const url1 = "taler://tip/merchant.example.com/-/-/tipid"; + const r1 = parseTipUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.merchantBaseUrl, + "https://merchant.example.com/public/tip-pickup?tip_id=tipid", + ); +}); + +test("taler tip pickup uri with instance", t => { + const url1 = "taler://tip/merchant.example.com/-/tipm/tipid"; + const r1 = parseTipUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.merchantBaseUrl, + "https://merchant.example.com/public/instances/tipm/", + ); + t.is(r1.merchantTipId, "tipid"); +}); + +test("taler tip pickup uri with instance and prefix", t => { + const url1 = "taler://tip/merchant.example.com/my%2fpfx/tipm/tipid"; + const r1 = parseTipUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.merchantBaseUrl, + "https://merchant.example.com/my/pfx/instances/tipm/", + ); + t.is(r1.merchantTipId, "tipid"); +}); diff --git a/src/util/taleruri.ts b/src/util/taleruri.ts new file mode 100644 index 000000000..aa6705c07 --- /dev/null +++ b/src/util/taleruri.ts @@ -0,0 +1,200 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +export interface PayUriResult { + downloadUrl: string; + sessionId?: string; +} + +export interface WithdrawUriResult { + statusUrl: string; +} + +export interface RefundUriResult { + refundUrl: string; +} + +export interface TipUriResult { + merchantTipId: string; + merchantOrigin: string; + merchantBaseUrl: string; +} + +export function parseWithdrawUri(s: string): WithdrawUriResult | undefined { + const pfx = "taler://withdraw/"; + if (!s.startsWith(pfx)) { + return undefined; + } + + const rest = s.substring(pfx.length); + + let [host, path, withdrawId] = rest.split("/"); + + if (path === "-") { + path = "api/withdraw-operation"; + } + + return { + statusUrl: `https://${host}/${path}/${withdrawId}`, + }; +} + +export function parsePayUri(s: string): PayUriResult | undefined { + if (s.startsWith("https://") || s.startsWith("http://")) { + return { + downloadUrl: s, + sessionId: undefined, + }; + } + const pfx = "taler://pay/"; + if (!s.startsWith(pfx)) { + return undefined; + } + + const [path, search] = s.slice(pfx.length).split("?"); + + let [host, maybePath, maybeInstance, orderId, maybeSessionid] = path.split( + "/", + ); + + if (!host) { + return undefined; + } + + if (!maybePath) { + return undefined; + } + + if (!orderId) { + return undefined; + } + + if (maybePath === "-") { + maybePath = "public/"; + } else { + maybePath = decodeURIComponent(maybePath) + "/"; + } + let maybeInstancePath = ""; + if (maybeInstance !== "-") { + maybeInstancePath = `instances/${maybeInstance}/`; + } + + let protocol = "https"; + const searchParams = new URLSearchParams(search); + if (searchParams.get("insecure") === "1") { + protocol = "http"; + } + + const downloadUrl = + `${protocol}://${host}/` + + decodeURIComponent(maybePath) + + maybeInstancePath + + `proposal?order_id=${orderId}`; + + return { + downloadUrl, + sessionId: maybeSessionid, + }; +} + +export function parseTipUri(s: string): TipUriResult | undefined { + const pfx = "taler://tip/"; + if (!s.startsWith(pfx)) { + return undefined; + } + + const path = s.slice(pfx.length); + + let [host, maybePath, maybeInstance, tipId] = path.split("/"); + + if (!host) { + return undefined; + } + + if (!maybePath) { + return undefined; + } + + if (!tipId) { + return undefined; + } + + if (maybePath === "-") { + maybePath = "public/"; + } else { + maybePath = decodeURIComponent(maybePath) + "/"; + } + let maybeInstancePath = ""; + if (maybeInstance !== "-") { + maybeInstancePath = `instances/${maybeInstance}/`; + } + + const merchantBaseUrl = `https://${host}/${maybePath}${maybeInstancePath}`; + + return { + merchantTipId: tipId, + merchantOrigin: new URL(merchantBaseUrl).origin, + merchantBaseUrl, + }; +} + +export function parseRefundUri(s: string): RefundUriResult | undefined { + const pfx = "taler://refund/"; + + if (!s.startsWith(pfx)) { + return undefined; + } + + const path = s.slice(pfx.length); + + let [host, maybePath, maybeInstance, orderId] = path.split("/"); + + if (!host) { + return undefined; + } + + if (!maybePath) { + return undefined; + } + + if (!orderId) { + return undefined; + } + + if (maybePath === "-") { + maybePath = "public/"; + } else { + maybePath = decodeURIComponent(maybePath) + "/"; + } + let maybeInstancePath = ""; + if (maybeInstance !== "-") { + maybeInstancePath = `instances/${maybeInstance}/`; + } + + const refundUrl = + "https://" + + host + + "/" + + maybePath + + maybeInstancePath + + "refund" + + "?order_id=" + + orderId; + + return { + refundUrl, + }; +} diff --git a/src/util/timer.ts b/src/util/timer.ts new file mode 100644 index 000000000..d3bb5d485 --- /dev/null +++ b/src/util/timer.ts @@ -0,0 +1,145 @@ +/* + This file is part of TALER + (C) 2017 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 + */ + +/** + * Cross-platform timers. + * + * NodeJS and the browser use slightly different timer API, + * this abstracts over these differences. + */ + +/** + * Cancelable timer. + */ +export interface TimerHandle { + clear(): void; +} + +class IntervalHandle { + constructor(public h: any) { + } + + clear() { + clearInterval(this.h); + } +} + +class TimeoutHandle { + constructor(public h: any) { + } + + clear() { + clearTimeout(this.h); + } +} + +/** + * Get a performance counter in milliseconds. + */ +export const performanceNow: () => number = (() => { + if (typeof process !== "undefined" && process.hrtime) { + return () => { + const t = process.hrtime(); + return t[0] * 1e9 + t[1]; + }; + } else if (typeof "performance" !== "undefined") { + return () => performance.now(); + } else { + return () => 0; + } +})(); + +/** + * Call a function every time the delay given in milliseconds passes. + */ +export function every(delayMs: number, callback: () => void): TimerHandle { + return new IntervalHandle(setInterval(callback, delayMs)); +} + +/** + * Call a function after the delay given in milliseconds passes. + */ +export function after(delayMs: number, callback: () => void): TimerHandle { + return new TimeoutHandle(setTimeout(callback, delayMs)); +} + + +const nullTimerHandle = { + clear() { + // do nothing + return; + }, +}; + +/** + * Group of timers that can be destroyed at once. + */ +export class TimerGroup { + private stopped: boolean = false; + + private timerMap: { [index: number]: TimerHandle } = {}; + + private idGen = 1; + + stopCurrentAndFutureTimers() { + this.stopped = true; + for (const x in this.timerMap) { + if (!this.timerMap.hasOwnProperty(x)) { + continue; + } + this.timerMap[x].clear(); + delete this.timerMap[x]; + } + } + + after(delayMs: number, callback: () => void): TimerHandle { + if (this.stopped) { + console.warn("dropping timer since timer group is stopped"); + return nullTimerHandle; + } + const h = after(delayMs, callback); + const myId = this.idGen++; + this.timerMap[myId] = h; + + const tm = this.timerMap; + + return { + clear() { + h.clear(); + delete tm[myId]; + }, + }; + } + + every(delayMs: number, callback: () => void): TimerHandle { + if (this.stopped) { + console.warn("dropping timer since timer group is stopped"); + return nullTimerHandle; + } + const h = every(delayMs, callback); + const myId = this.idGen++; + this.timerMap[myId] = h; + + const tm = this.timerMap; + + return { + clear() { + h.clear(); + delete tm[myId]; + }, + }; + } +} diff --git a/src/util/wire.ts b/src/util/wire.ts new file mode 100644 index 000000000..63b73d864 --- /dev/null +++ b/src/util/wire.ts @@ -0,0 +1,53 @@ +/* + This file is part of TALER + (C) 2017 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 + */ + + +/** + * Display and manipulate wire information. + * + * Right now, all types are hard-coded. In the future, there might be plugins / configurable + * methods or support for the "payto://" URI scheme. + */ + +/** + * Imports. + */ +import * as i18n from "../i18n"; + +/** + * Short summary of the wire information. + * + * Might abbreviate and return the same summary for different + * wire details. + */ +export function summarizeWire(w: any): string { + if (!w.type) { + return i18n.str`Invalid Wire`; + } + switch (w.type.toLowerCase()) { + case "test": + if (!w.account_number && w.account_number !== 0) { + return i18n.str`Invalid Test Wire Detail`; + } + if (!w.bank_uri) { + return i18n.str`Invalid Test Wire Detail`; + } + return i18n.str`Test Wire Acct #${w.account_number} on ${w.bank_uri}`; + default: + return i18n.str`Unknown Wire Detail`; + } +} + diff --git a/src/wallet-impl/balance.ts b/src/wallet-impl/balance.ts new file mode 100644 index 000000000..1d8e077af --- /dev/null +++ b/src/wallet-impl/balance.ts @@ -0,0 +1,144 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** + * Imports. + */ +import { + HistoryQuery, + HistoryEvent, + WalletBalance, + WalletBalanceEntry, +} from "../walletTypes"; +import { oneShotIter, runWithWriteTransaction } from "../util/query"; +import { InternalWalletState } from "./state"; +import { Stores, TipRecord, CoinStatus } from "../dbTypes"; +import * as Amounts from "../util/amounts"; +import { AmountJson } from "../util/amounts"; +import { Logger } from "../util/logging"; + +const logger = new Logger("withdraw.ts"); + +/** + * Get detailed balance information, sliced by exchange and by currency. + */ +export async function getBalances( + ws: InternalWalletState, +): Promise { + /** + * Add amount to a balance field, both for + * the slicing by exchange and currency. + */ + function addTo( + balance: WalletBalance, + field: keyof WalletBalanceEntry, + amount: AmountJson, + exchange: string, + ): void { + const z = Amounts.getZero(amount.currency); + const balanceIdentity = { + available: z, + paybackAmount: z, + pendingIncoming: z, + pendingPayment: z, + pendingIncomingDirty: z, + pendingIncomingRefresh: z, + pendingIncomingWithdraw: z, + }; + let entryCurr = balance.byCurrency[amount.currency]; + if (!entryCurr) { + balance.byCurrency[amount.currency] = entryCurr = { + ...balanceIdentity, + }; + } + let entryEx = balance.byExchange[exchange]; + if (!entryEx) { + balance.byExchange[exchange] = entryEx = { ...balanceIdentity }; + } + entryCurr[field] = Amounts.add(entryCurr[field], amount).amount; + entryEx[field] = Amounts.add(entryEx[field], amount).amount; + } + + const balanceStore = { + byCurrency: {}, + byExchange: {}, + }; + + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.refresh, Stores.reserves, Stores.purchases], + async tx => { + await tx.iter(Stores.coins).forEach(c => { + if (c.suspended) { + return; + } + if (c.status === CoinStatus.Fresh) { + addTo(balanceStore, "available", c.currentAmount, c.exchangeBaseUrl); + } + if (c.status === CoinStatus.Dirty) { + addTo( + balanceStore, + "pendingIncoming", + c.currentAmount, + c.exchangeBaseUrl, + ); + addTo( + balanceStore, + "pendingIncomingDirty", + c.currentAmount, + c.exchangeBaseUrl, + ); + } + }); + await tx.iter(Stores.refresh).forEach(r => { + // Don't count finished refreshes, since the refresh already resulted + // in coins being added to the wallet. + if (r.finished) { + return; + } + addTo( + balanceStore, + "pendingIncoming", + r.valueOutput, + r.exchangeBaseUrl, + ); + addTo( + balanceStore, + "pendingIncomingRefresh", + r.valueOutput, + r.exchangeBaseUrl, + ); + }); + + await tx.iter(Stores.purchases).forEach(t => { + if (t.finished) { + return; + } + for (const c of t.payReq.coins) { + addTo( + balanceStore, + "pendingPayment", + Amounts.parseOrThrow(c.contribution), + c.exchange_url, + ); + } + }); + }, + ); + + logger.trace("computed balances:", balanceStore); + return balanceStore; +} diff --git a/src/wallet-impl/exchanges.ts b/src/wallet-impl/exchanges.ts new file mode 100644 index 000000000..b3677c6c6 --- /dev/null +++ b/src/wallet-impl/exchanges.ts @@ -0,0 +1,401 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { InternalWalletState } from "./state"; +import { + WALLET_CACHE_BREAKER_CLIENT_VERSION, + OperationFailedAndReportedError, +} from "../wallet"; +import { KeysJson, Denomination, ExchangeWireJson } from "../talerTypes"; +import { getTimestampNow, OperationError } from "../walletTypes"; +import { + ExchangeRecord, + ExchangeUpdateStatus, + Stores, + DenominationRecord, + DenominationStatus, + WireFee, +} from "../dbTypes"; +import { + canonicalizeBaseUrl, + extractTalerStamp, + extractTalerStampOrThrow, +} from "../util/helpers"; +import { + oneShotGet, + oneShotPut, + runWithWriteTransaction, + oneShotMutate, +} from "../util/query"; +import * as Amounts from "../util/amounts"; +import { parsePaytoUri } from "../util/payto"; + +async function denominationRecordFromKeys( + ws: InternalWalletState, + exchangeBaseUrl: string, + denomIn: Denomination, +): Promise { + const denomPubHash = await ws.cryptoApi.hashDenomPub(denomIn.denom_pub); + const d: DenominationRecord = { + denomPub: denomIn.denom_pub, + denomPubHash, + exchangeBaseUrl, + feeDeposit: Amounts.parseOrThrow(denomIn.fee_deposit), + feeRefresh: Amounts.parseOrThrow(denomIn.fee_refresh), + feeRefund: Amounts.parseOrThrow(denomIn.fee_refund), + feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw), + isOffered: true, + masterSig: denomIn.master_sig, + stampExpireDeposit: extractTalerStampOrThrow(denomIn.stamp_expire_deposit), + stampExpireLegal: extractTalerStampOrThrow(denomIn.stamp_expire_legal), + stampExpireWithdraw: extractTalerStampOrThrow( + denomIn.stamp_expire_withdraw, + ), + stampStart: extractTalerStampOrThrow(denomIn.stamp_start), + status: DenominationStatus.Unverified, + value: Amounts.parseOrThrow(denomIn.value), + }; + return d; +} + +async function setExchangeError( + ws: InternalWalletState, + baseUrl: string, + err: OperationError, +): Promise { + const mut = (exchange: ExchangeRecord) => { + exchange.lastError = err; + return exchange; + }; + await oneShotMutate(ws.db, Stores.exchanges, baseUrl, mut); +} + +/** + * Fetch the exchange's /keys and update our database accordingly. + * + * Exceptions thrown in this method must be caught and reported + * in the pending operations. + */ +async function updateExchangeWithKeys( + ws: InternalWalletState, + baseUrl: string, +): Promise { + const existingExchangeRecord = await oneShotGet( + ws.db, + Stores.exchanges, + baseUrl, + ); + + if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FETCH_KEYS) { + return; + } + const keysUrl = new URL("keys", baseUrl); + keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); + + let keysResp; + try { + keysResp = await ws.http.get(keysUrl.href); + } catch (e) { + const m = `Fetching keys failed: ${e.message}`; + await setExchangeError(ws, baseUrl, { + type: "network", + details: { + requestUrl: e.config?.url, + }, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + let exchangeKeysJson: KeysJson; + try { + exchangeKeysJson = KeysJson.checked(keysResp.responseJson); + } catch (e) { + const m = `Parsing /keys response failed: ${e.message}`; + await setExchangeError(ws, baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + + const lastUpdateTimestamp = extractTalerStamp( + exchangeKeysJson.list_issue_date, + ); + if (!lastUpdateTimestamp) { + const m = `Parsing /keys response failed: invalid list_issue_date.`; + await setExchangeError(ws, baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + + if (exchangeKeysJson.denoms.length === 0) { + const m = "exchange doesn't offer any denominations"; + await setExchangeError(ws, baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + + const protocolVersion = exchangeKeysJson.version; + if (!protocolVersion) { + const m = "outdate exchange, no version in /keys response"; + await setExchangeError(ws, baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + + const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) + .currency; + + const newDenominations = await Promise.all( + exchangeKeysJson.denoms.map(d => + denominationRecordFromKeys(ws, baseUrl, d), + ), + ); + + await runWithWriteTransaction( + ws.db, + [Stores.exchanges, Stores.denominations], + async tx => { + const r = await tx.get(Stores.exchanges, baseUrl); + if (!r) { + console.warn(`exchange ${baseUrl} no longer present`); + return; + } + if (r.details) { + // FIXME: We need to do some consistency checks! + } + r.details = { + auditors: exchangeKeysJson.auditors, + currency: currency, + lastUpdateTime: lastUpdateTimestamp, + masterPublicKey: exchangeKeysJson.master_public_key, + protocolVersion: protocolVersion, + }; + r.updateStatus = ExchangeUpdateStatus.FETCH_WIRE; + r.lastError = undefined; + await tx.put(Stores.exchanges, r); + + for (const newDenom of newDenominations) { + const oldDenom = await tx.get(Stores.denominations, [ + baseUrl, + newDenom.denomPub, + ]); + if (oldDenom) { + // FIXME: Do consistency check + } else { + await tx.put(Stores.denominations, newDenom); + } + } + }, + ); +} + +/** + * Fetch wire information for an exchange and store it in the database. + * + * @param exchangeBaseUrl Exchange base URL, assumed to be already normalized. + */ +async function updateExchangeWithWireInfo( + ws: InternalWalletState, + exchangeBaseUrl: string, +) { + const exchange = await oneShotGet(ws.db, Stores.exchanges, exchangeBaseUrl); + if (!exchange) { + return; + } + if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) { + return; + } + const reqUrl = new URL("wire", exchangeBaseUrl); + reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION) + + const resp = await ws.http.get(reqUrl.href); + + const wiJson = resp.responseJson; + if (!wiJson) { + throw Error("/wire response malformed"); + } + const wireInfo = ExchangeWireJson.checked(wiJson); + const feesForType: { [wireMethod: string]: WireFee[] } = {}; + for (const wireMethod of Object.keys(wireInfo.fees)) { + const feeList: WireFee[] = []; + for (const x of wireInfo.fees[wireMethod]) { + const startStamp = extractTalerStamp(x.start_date); + if (!startStamp) { + throw Error("wrong date format"); + } + const endStamp = extractTalerStamp(x.end_date); + if (!endStamp) { + throw Error("wrong date format"); + } + feeList.push({ + closingFee: Amounts.parseOrThrow(x.closing_fee), + endStamp, + sig: x.sig, + startStamp, + wireFee: Amounts.parseOrThrow(x.wire_fee), + }); + } + feesForType[wireMethod] = feeList; + } + + await runWithWriteTransaction(ws.db, [Stores.exchanges], async tx => { + const r = await tx.get(Stores.exchanges, exchangeBaseUrl); + if (!r) { + return; + } + if (r.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) { + return; + } + r.wireInfo = { + accounts: wireInfo.accounts, + feesForType: feesForType, + }; + r.updateStatus = ExchangeUpdateStatus.FINISHED; + r.lastError = undefined; + await tx.put(Stores.exchanges, r); + }); +} + +/** + * Update or add exchange DB entry by fetching the /keys and /wire information. + * Optionally link the reserve entry to the new or existing + * exchange entry in then DB. + */ +export async function updateExchangeFromUrl( + ws: InternalWalletState, + baseUrl: string, + force: boolean = false, +): Promise { + const now = getTimestampNow(); + baseUrl = canonicalizeBaseUrl(baseUrl); + + const r = await oneShotGet(ws.db, Stores.exchanges, baseUrl); + if (!r) { + const newExchangeRecord: ExchangeRecord = { + baseUrl: baseUrl, + details: undefined, + wireInfo: undefined, + updateStatus: ExchangeUpdateStatus.FETCH_KEYS, + updateStarted: now, + updateReason: "initial", + timestampAdded: getTimestampNow(), + }; + await oneShotPut(ws.db, Stores.exchanges, newExchangeRecord); + } else { + await runWithWriteTransaction(ws.db, [Stores.exchanges], async t => { + const rec = await t.get(Stores.exchanges, baseUrl); + if (!rec) { + return; + } + if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && !force) { + return; + } + if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && force) { + rec.updateReason = "forced"; + } + rec.updateStarted = now; + rec.updateStatus = ExchangeUpdateStatus.FETCH_KEYS; + rec.lastError = undefined; + t.put(Stores.exchanges, rec); + }); + } + + await updateExchangeWithKeys(ws, baseUrl); + await updateExchangeWithWireInfo(ws, baseUrl); + + const updatedExchange = await oneShotGet(ws.db, Stores.exchanges, baseUrl); + + if (!updatedExchange) { + // This should practically never happen + throw Error("exchange not found"); + } + return updatedExchange; +} + +/** + * Check if and how an exchange is trusted and/or audited. + */ +export async function getExchangeTrust( + ws: InternalWalletState, + exchangeInfo: ExchangeRecord, +): Promise<{ isTrusted: boolean; isAudited: boolean }> { + let isTrusted = false; + let isAudited = false; + const exchangeDetails = exchangeInfo.details; + if (!exchangeDetails) { + throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); + } + const currencyRecord = await oneShotGet( + ws.db, + Stores.currencies, + exchangeDetails.currency, + ); + if (currencyRecord) { + for (const trustedExchange of currencyRecord.exchanges) { + if (trustedExchange.exchangePub === exchangeDetails.masterPublicKey) { + isTrusted = true; + break; + } + } + for (const trustedAuditor of currencyRecord.auditors) { + for (const exchangeAuditor of exchangeDetails.auditors) { + if (trustedAuditor.auditorPub === exchangeAuditor.auditor_pub) { + isAudited = true; + break; + } + } + } + } + return { isTrusted, isAudited }; +} + +export async function getExchangePaytoUri( + ws: InternalWalletState, + exchangeBaseUrl: string, + supportedTargetTypes: string[], +): Promise { + // We do the update here, since the exchange might not even exist + // yet in our database. + const exchangeRecord = await updateExchangeFromUrl(ws, exchangeBaseUrl); + if (!exchangeRecord) { + throw Error(`Exchange '${exchangeBaseUrl}' not found.`); + } + const exchangeWireInfo = exchangeRecord.wireInfo; + if (!exchangeWireInfo) { + throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`); + } + for (let account of exchangeWireInfo.accounts) { + const res = parsePaytoUri(account.url); + if (!res) { + continue; + } + if (supportedTargetTypes.includes(res.targetType)) { + return account.url; + } + } + throw Error("no matching exchange account found"); +} diff --git a/src/wallet-impl/history.ts b/src/wallet-impl/history.ts new file mode 100644 index 000000000..976dab885 --- /dev/null +++ b/src/wallet-impl/history.ts @@ -0,0 +1,172 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + + /** + * Imports. + */ +import { HistoryQuery, HistoryEvent } from "../walletTypes"; +import { oneShotIter } from "../util/query"; +import { InternalWalletState } from "./state"; +import { Stores, TipRecord } from "../dbTypes"; +import * as Amounts from "../util/amounts"; +import { AmountJson } from "../util/amounts"; + +/** + * Retrive the full event history for this wallet. + */ +export async function getHistory( + ws: InternalWalletState, + historyQuery?: HistoryQuery, +): Promise<{ history: HistoryEvent[] }> { + const history: HistoryEvent[] = []; + + // FIXME: do pagination instead of generating the full history + + // We uniquely identify history rows via their timestamp. + // This works as timestamps are guaranteed to be monotonically + // increasing even + + const proposals = await oneShotIter(ws.db, Stores.proposals).toArray(); + for (const p of proposals) { + history.push({ + detail: { + contractTermsHash: p.contractTermsHash, + merchantName: p.contractTerms.merchant.name, + }, + timestamp: p.timestamp, + type: "claim-order", + explicit: false, + }); + } + + const withdrawals = await oneShotIter( + ws.db, + Stores.withdrawalSession, + ).toArray(); + for (const w of withdrawals) { + history.push({ + detail: { + withdrawalAmount: w.withdrawalAmount, + }, + timestamp: w.startTimestamp, + type: "withdraw", + explicit: false, + }); + } + + const purchases = await oneShotIter(ws.db, Stores.purchases).toArray(); + for (const p of purchases) { + history.push({ + detail: { + amount: p.contractTerms.amount, + contractTermsHash: p.contractTermsHash, + fulfillmentUrl: p.contractTerms.fulfillment_url, + merchantName: p.contractTerms.merchant.name, + }, + timestamp: p.timestamp, + type: "pay", + explicit: false, + }); + if (p.timestamp_refund) { + const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount); + const amountsPending = Object.keys(p.refundsPending).map(x => + Amounts.parseOrThrow(p.refundsPending[x].refund_amount), + ); + const amountsDone = Object.keys(p.refundsDone).map(x => + Amounts.parseOrThrow(p.refundsDone[x].refund_amount), + ); + const amounts: AmountJson[] = amountsPending.concat(amountsDone); + const amount = Amounts.add( + Amounts.getZero(contractAmount.currency), + ...amounts, + ).amount; + + history.push({ + detail: { + contractTermsHash: p.contractTermsHash, + fulfillmentUrl: p.contractTerms.fulfillment_url, + merchantName: p.contractTerms.merchant.name, + refundAmount: amount, + }, + timestamp: p.timestamp_refund, + type: "refund", + explicit: false, + }); + } + } + + const reserves = await oneShotIter(ws.db, Stores.reserves).toArray(); + + for (const r of reserves) { + const reserveType = r.bankWithdrawStatusUrl ? "taler-bank" : "manual"; + history.push({ + detail: { + exchangeBaseUrl: r.exchangeBaseUrl, + requestedAmount: Amounts.toString(r.initiallyRequestedAmount), + reservePub: r.reservePub, + reserveType, + bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, + }, + timestamp: r.created, + type: "reserve-created", + explicit: false, + }); + if (r.timestampConfirmed) { + history.push({ + detail: { + exchangeBaseUrl: r.exchangeBaseUrl, + requestedAmount: Amounts.toString(r.initiallyRequestedAmount), + reservePub: r.reservePub, + reserveType, + bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, + }, + timestamp: r.created, + type: "reserve-confirmed", + explicit: false, + }); + } + } + + const tips: TipRecord[] = await oneShotIter(ws.db, Stores.tips).toArray(); + for (const tip of tips) { + history.push({ + detail: { + accepted: tip.accepted, + amount: tip.amount, + merchantBaseUrl: tip.merchantBaseUrl, + tipId: tip.merchantTipId, + }, + timestamp: tip.timestamp, + explicit: false, + type: "tip", + }); + } + + await oneShotIter(ws.db, Stores.exchanges).forEach(exchange => { + history.push({ + type: "exchange-added", + explicit: false, + timestamp: exchange.timestampAdded, + detail: { + exchangeBaseUrl: exchange.baseUrl, + }, + }); + }); + + history.sort((h1, h2) => Math.sign(h1.timestamp.t_ms - h2.timestamp.t_ms)); + + return { history }; +} diff --git a/src/wallet-impl/pay.ts b/src/wallet-impl/pay.ts new file mode 100644 index 000000000..d4d2b3cd4 --- /dev/null +++ b/src/wallet-impl/pay.ts @@ -0,0 +1,822 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { AmountJson } from "../util/amounts"; +import { + Auditor, + ExchangeHandle, + MerchantRefundResponse, + PayReq, + Proposal, + ContractTerms, +} from "../talerTypes"; +import { + Timestamp, + CoinSelectionResult, + CoinWithDenom, + PayCoinInfo, + getTimestampNow, + PreparePayResult, + ConfirmPayResult, +} from "../walletTypes"; +import { + oneShotIter, + oneShotIterIndex, + oneShotGet, + runWithWriteTransaction, + oneShotPut, + oneShotGetIndexed, +} from "../util/query"; +import { + Stores, + CoinStatus, + DenominationRecord, + ProposalRecord, + PurchaseRecord, + CoinRecord, + ProposalStatus, +} from "../dbTypes"; +import * as Amounts from "../util/amounts"; +import { + amountToPretty, + strcmp, + extractTalerStamp, + canonicalJson, +} from "../util/helpers"; +import { Logger } from "../util/logging"; +import { InternalWalletState } from "./state"; +import { parsePayUri } from "../util/taleruri"; +import { getTotalRefreshCost, refresh } from "./refresh"; +import { acceptRefundResponse } from "./refund"; +import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; + +export interface SpeculativePayData { + payCoinInfo: PayCoinInfo; + exchangeUrl: string; + orderDownloadId: string; + proposal: ProposalRecord; +} + +interface CoinsForPaymentArgs { + allowedAuditors: Auditor[]; + allowedExchanges: ExchangeHandle[]; + depositFeeLimit: AmountJson; + paymentAmount: AmountJson; + wireFeeAmortization: number; + wireFeeLimit: AmountJson; + wireFeeTime: Timestamp; + wireMethod: string; +} + +interface SelectPayCoinsResult { + cds: CoinWithDenom[]; + totalFees: AmountJson; +} + +const logger = new Logger("pay.ts"); + +/** + * Select coins for a payment under the merchant's constraints. + * + * @param denoms all available denoms, used to compute refresh fees + */ +export function selectPayCoins( + denoms: DenominationRecord[], + cds: CoinWithDenom[], + paymentAmount: AmountJson, + depositFeeLimit: AmountJson, +): SelectPayCoinsResult | undefined { + if (cds.length === 0) { + return undefined; + } + // Sort by ascending deposit fee and denomPub if deposit fee is the same + // (to guarantee deterministic results) + cds.sort( + (o1, o2) => + Amounts.cmp(o1.denom.feeDeposit, o2.denom.feeDeposit) || + strcmp(o1.denom.denomPub, o2.denom.denomPub), + ); + const currency = cds[0].denom.value.currency; + const cdsResult: CoinWithDenom[] = []; + let accDepositFee: AmountJson = Amounts.getZero(currency); + let accAmount: AmountJson = Amounts.getZero(currency); + for (const { coin, denom } of cds) { + if (coin.suspended) { + continue; + } + if (coin.status !== CoinStatus.Fresh) { + continue; + } + if (Amounts.cmp(denom.feeDeposit, coin.currentAmount) >= 0) { + continue; + } + cdsResult.push({ coin, denom }); + accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount; + let leftAmount = Amounts.sub( + coin.currentAmount, + Amounts.sub(paymentAmount, accAmount).amount, + ).amount; + accAmount = Amounts.add(coin.currentAmount, accAmount).amount; + const coversAmount = Amounts.cmp(accAmount, paymentAmount) >= 0; + const coversAmountWithFee = + Amounts.cmp( + accAmount, + Amounts.add(paymentAmount, denom.feeDeposit).amount, + ) >= 0; + const isBelowFee = Amounts.cmp(accDepositFee, depositFeeLimit) <= 0; + + logger.trace("candidate coin selection", { + coversAmount, + isBelowFee, + accDepositFee, + accAmount, + paymentAmount, + }); + + if ((coversAmount && isBelowFee) || coversAmountWithFee) { + const depositFeeToCover = Amounts.sub(accDepositFee, depositFeeLimit) + .amount; + leftAmount = Amounts.sub(leftAmount, depositFeeToCover).amount; + logger.trace("deposit fee to cover", amountToPretty(depositFeeToCover)); + let totalFees: AmountJson = Amounts.getZero(currency); + if (coversAmountWithFee && !isBelowFee) { + // these are the fees the customer has to pay + // because the merchant doesn't cover them + totalFees = Amounts.sub(depositFeeLimit, accDepositFee).amount; + } + totalFees = Amounts.add( + totalFees, + getTotalRefreshCost(denoms, denom, leftAmount), + ).amount; + return { cds: cdsResult, totalFees }; + } + } + return undefined; +} + +/** + * Get exchanges and associated coins that are still spendable, but only + * if the sum the coins' remaining value covers the payment amount and fees. + */ +async function getCoinsForPayment( + ws: InternalWalletState, + args: CoinsForPaymentArgs, +): Promise { + const { + allowedAuditors, + allowedExchanges, + depositFeeLimit, + paymentAmount, + wireFeeAmortization, + wireFeeLimit, + wireFeeTime, + wireMethod, + } = args; + + let remainingAmount = paymentAmount; + + const exchanges = await oneShotIter(ws.db, Stores.exchanges).toArray(); + + for (const exchange of exchanges) { + let isOkay: boolean = false; + const exchangeDetails = exchange.details; + if (!exchangeDetails) { + continue; + } + const exchangeFees = exchange.wireInfo; + if (!exchangeFees) { + continue; + } + + // is the exchange explicitly allowed? + for (const allowedExchange of allowedExchanges) { + if (allowedExchange.master_pub === exchangeDetails.masterPublicKey) { + isOkay = true; + break; + } + } + + // is the exchange allowed because of one of its auditors? + if (!isOkay) { + for (const allowedAuditor of allowedAuditors) { + for (const auditor of exchangeDetails.auditors) { + if (auditor.auditor_pub === allowedAuditor.auditor_pub) { + isOkay = true; + break; + } + } + if (isOkay) { + break; + } + } + } + + if (!isOkay) { + continue; + } + + const coins = await oneShotIterIndex( + ws.db, + Stores.coins.exchangeBaseUrlIndex, + exchange.baseUrl, + ).toArray(); + + const denoms = await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + exchange.baseUrl, + ).toArray(); + + if (!coins || coins.length === 0) { + continue; + } + + // Denomination of the first coin, we assume that all other + // coins have the same currency + const firstDenom = await oneShotGet(ws.db, Stores.denominations, [ + exchange.baseUrl, + coins[0].denomPub, + ]); + if (!firstDenom) { + throw Error("db inconsistent"); + } + const currency = firstDenom.value.currency; + const cds: CoinWithDenom[] = []; + for (const coin of coins) { + const denom = await oneShotGet(ws.db, Stores.denominations, [ + exchange.baseUrl, + coin.denomPub, + ]); + if (!denom) { + throw Error("db inconsistent"); + } + if (denom.value.currency !== currency) { + console.warn( + `same pubkey for different currencies at exchange ${exchange.baseUrl}`, + ); + continue; + } + if (coin.suspended) { + continue; + } + if (coin.status !== CoinStatus.Fresh) { + continue; + } + cds.push({ coin, denom }); + } + + let totalFees = Amounts.getZero(currency); + let wireFee: AmountJson | undefined; + for (const fee of exchangeFees.feesForType[wireMethod] || []) { + if (fee.startStamp <= wireFeeTime && fee.endStamp >= wireFeeTime) { + wireFee = fee.wireFee; + break; + } + } + + if (wireFee) { + const amortizedWireFee = Amounts.divide(wireFee, wireFeeAmortization); + if (Amounts.cmp(wireFeeLimit, amortizedWireFee) < 0) { + totalFees = Amounts.add(amortizedWireFee, totalFees).amount; + remainingAmount = Amounts.add(amortizedWireFee, remainingAmount).amount; + } + } + + const res = selectPayCoins(denoms, cds, remainingAmount, depositFeeLimit); + + if (res) { + totalFees = Amounts.add(totalFees, res.totalFees).amount; + return { + cds: res.cds, + exchangeUrl: exchange.baseUrl, + totalAmount: remainingAmount, + totalFees, + }; + } + } + return undefined; +} + +/** + * Record all information that is necessary to + * pay for a proposal in the wallet's database. + */ +async function recordConfirmPay( + ws: InternalWalletState, + proposal: ProposalRecord, + payCoinInfo: PayCoinInfo, + chosenExchange: string, +): Promise { + const payReq: PayReq = { + coins: payCoinInfo.sigs, + merchant_pub: proposal.contractTerms.merchant_pub, + mode: "pay", + order_id: proposal.contractTerms.order_id, + }; + const t: PurchaseRecord = { + abortDone: false, + abortRequested: false, + contractTerms: proposal.contractTerms, + contractTermsHash: proposal.contractTermsHash, + finished: false, + lastSessionId: undefined, + merchantSig: proposal.merchantSig, + payReq, + refundsDone: {}, + refundsPending: {}, + timestamp: getTimestampNow(), + timestamp_refund: undefined, + }; + + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.purchases], + async tx => { + await tx.put(Stores.purchases, t); + for (let c of payCoinInfo.updatedCoins) { + await tx.put(Stores.coins, c); + } + }, + ); + + ws.badge.showNotification(); + ws.notifier.notify(); + return t; +} + +function getNextUrl(contractTerms: ContractTerms): string { + const fu = new URL(contractTerms.fulfillment_url) + fu.searchParams.set("order_id", contractTerms.order_id); + return fu.href; +} + +export async function abortFailedPayment( + ws: InternalWalletState, + contractTermsHash: string, +): Promise { + const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash); + if (!purchase) { + throw Error("Purchase not found, unable to abort with refund"); + } + if (purchase.finished) { + throw Error("Purchase already finished, not aborting"); + } + if (purchase.abortDone) { + console.warn("abort requested on already aborted purchase"); + return; + } + + purchase.abortRequested = true; + + // From now on, we can't retry payment anymore, + // so mark this in the DB in case the /pay abort + // does not complete on the first try. + await oneShotPut(ws.db, Stores.purchases, purchase); + + let resp; + + const abortReq = { ...purchase.payReq, mode: "abort-refund" }; + + const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href; + + try { + resp = await ws.http.postJson(payUrl, abortReq); + } catch (e) { + // Gives the user the option to retry / abort and refresh + console.log("aborting payment failed", e); + throw e; + } + + const refundResponse = MerchantRefundResponse.checked(resp.responseJson); + await acceptRefundResponse(ws, refundResponse); + + await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { + const p = await tx.get(Stores.purchases, purchase.contractTermsHash); + if (!p) { + return; + } + p.abortDone = true; + await tx.put(Stores.purchases, p); + }); +} + +/** + * Download a proposal and store it in the database. + * Returns an id for it to retrieve it later. + * + * @param sessionId Current session ID, if the proposal is being + * downloaded in the context of a session ID. + */ +async function downloadProposal( + ws: InternalWalletState, + url: string, + sessionId?: string, +): Promise { + const oldProposal = await oneShotGetIndexed( + ws.db, + Stores.proposals.urlIndex, + url, + ); + if (oldProposal) { + return oldProposal.proposalId; + } + + const { priv, pub } = await ws.cryptoApi.createEddsaKeypair(); + const parsed_url = new URL(url); + parsed_url.searchParams.set("nonce", pub); + const urlWithNonce = parsed_url.href; + console.log("downloading contract from '" + urlWithNonce + "'"); + let resp; + try { + resp = await ws.http.get(urlWithNonce); + } catch (e) { + console.log("contract download failed", e); + throw e; + } + + const proposal = Proposal.checked(resp.responseJson); + + const contractTermsHash = await ws.cryptoApi.hashString( + canonicalJson(proposal.contract_terms), + ); + + const proposalId = encodeCrock(getRandomBytes(32)); + + const proposalRecord: ProposalRecord = { + contractTerms: proposal.contract_terms, + contractTermsHash, + merchantSig: proposal.sig, + noncePriv: priv, + timestamp: getTimestampNow(), + url, + downloadSessionId: sessionId, + proposalId: proposalId, + proposalStatus: ProposalStatus.PROPOSED, + }; + await oneShotPut(ws.db, Stores.proposals, proposalRecord); + ws.notifier.notify(); + + return proposalId; +} + +async function submitPay( + ws: InternalWalletState, + contractTermsHash: string, + sessionId: string | undefined, +): Promise { + const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash); + if (!purchase) { + throw Error("Purchase not found: " + contractTermsHash); + } + if (purchase.abortRequested) { + throw Error("not submitting payment for aborted purchase"); + } + let resp; + const payReq = { ...purchase.payReq, session_id: sessionId }; + + const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href; + + try { + resp = await ws.http.postJson(payUrl, payReq); + } catch (e) { + // Gives the user the option to retry / abort and refresh + console.log("payment failed", e); + throw e; + } + const merchantResp = resp.responseJson; + console.log("got success from pay URL"); + + const merchantPub = purchase.contractTerms.merchant_pub; + const valid: boolean = await ws.cryptoApi.isValidPaymentSignature( + merchantResp.sig, + contractTermsHash, + merchantPub, + ); + if (!valid) { + console.error("merchant payment signature invalid"); + // FIXME: properly display error + throw Error("merchant payment signature invalid"); + } + purchase.finished = true; + const modifiedCoins: CoinRecord[] = []; + for (const pc of purchase.payReq.coins) { + const c = await oneShotGet(ws.db, Stores.coins, pc.coin_pub); + if (!c) { + console.error("coin not found"); + throw Error("coin used in payment not found"); + } + c.status = CoinStatus.Dirty; + modifiedCoins.push(c); + } + + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.purchases], + async tx => { + for (let c of modifiedCoins) { + tx.put(Stores.coins, c); + } + tx.put(Stores.purchases, purchase); + }, + ); + + for (const c of purchase.payReq.coins) { + refresh(ws, c.coin_pub); + } + + const nextUrl = getNextUrl(purchase.contractTerms); + ws.cachedNextUrl[purchase.contractTerms.fulfillment_url] = { + nextUrl, + lastSessionId: sessionId, + }; + + return { nextUrl }; +} + +/** + * Check if a payment for the given taler://pay/ URI is possible. + * + * If the payment is possible, the signature are already generated but not + * yet send to the merchant. + */ +export async function preparePay( + ws: InternalWalletState, + talerPayUri: string, +): Promise { + const uriResult = parsePayUri(talerPayUri); + + if (!uriResult) { + return { + status: "error", + error: "URI not supported", + }; + } + + let proposalId: string; + try { + proposalId = await downloadProposal( + ws, + uriResult.downloadUrl, + uriResult.sessionId, + ); + } catch (e) { + return { + status: "error", + error: e.toString(), + }; + } + const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId); + if (!proposal) { + throw Error(`could not get proposal ${proposalId}`); + } + + console.log("proposal", proposal); + + const differentPurchase = await oneShotGetIndexed( + ws.db, + Stores.purchases.fulfillmentUrlIndex, + proposal.contractTerms.fulfillment_url, + ); + + let fulfillmentUrl = proposal.contractTerms.fulfillment_url; + let doublePurchaseDetection = false; + if (fulfillmentUrl.startsWith("http")) { + doublePurchaseDetection = true; + } + + if (differentPurchase && doublePurchaseDetection) { + // We do this check to prevent merchant B to find out if we bought a + // digital product with merchant A by abusing the existing payment + // redirect feature. + if ( + differentPurchase.contractTerms.merchant_pub != + proposal.contractTerms.merchant_pub + ) { + console.warn( + "merchant with different public key offered contract with same fulfillment URL as an existing purchase", + ); + } else { + if (uriResult.sessionId) { + await submitPay( + ws, + differentPurchase.contractTermsHash, + uriResult.sessionId, + ); + } + return { + status: "paid", + contractTerms: differentPurchase.contractTerms, + nextUrl: getNextUrl(differentPurchase.contractTerms), + }; + } + } + + // First check if we already payed for it. + const purchase = await oneShotGet( + ws.db, + Stores.purchases, + proposal.contractTermsHash, + ); + + if (!purchase) { + const paymentAmount = Amounts.parseOrThrow(proposal.contractTerms.amount); + let wireFeeLimit; + if (proposal.contractTerms.max_wire_fee) { + wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee); + } else { + wireFeeLimit = Amounts.getZero(paymentAmount.currency); + } + // If not already payed, check if we could pay for it. + const res = await getCoinsForPayment(ws, { + allowedAuditors: proposal.contractTerms.auditors, + allowedExchanges: proposal.contractTerms.exchanges, + depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee), + paymentAmount, + wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, + wireFeeLimit, + // FIXME: parse this properly + wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { + t_ms: 0, + }, + wireMethod: proposal.contractTerms.wire_method, + }); + + if (!res) { + console.log("not confirming payment, insufficient coins"); + return { + status: "insufficient-balance", + contractTerms: proposal.contractTerms, + proposalId: proposal.proposalId, + }; + } + + // Only create speculative signature if we don't already have one for this proposal + if ( + !ws.speculativePayData || + (ws.speculativePayData && + ws.speculativePayData.orderDownloadId !== proposalId) + ) { + const { exchangeUrl, cds, totalAmount } = res; + const payCoinInfo = await ws.cryptoApi.signDeposit( + proposal.contractTerms, + cds, + totalAmount, + ); + ws.speculativePayData = { + exchangeUrl, + payCoinInfo, + proposal, + orderDownloadId: proposalId, + }; + logger.trace("created speculative pay data for payment"); + } + + return { + status: "payment-possible", + contractTerms: proposal.contractTerms, + proposalId: proposal.proposalId, + totalFees: res.totalFees, + }; + } + + if (uriResult.sessionId) { + await submitPay(ws, purchase.contractTermsHash, uriResult.sessionId); + } + + return { + status: "paid", + contractTerms: proposal.contractTerms, + nextUrl: getNextUrl(purchase.contractTerms), + }; +} + +/** + * Get the speculative pay data, but only if coins have not changed in between. + */ +async function getSpeculativePayData( + ws: InternalWalletState, + proposalId: string, +): Promise { + const sp = ws.speculativePayData; + if (!sp) { + return; + } + if (sp.orderDownloadId !== proposalId) { + return; + } + const coinKeys = sp.payCoinInfo.updatedCoins.map(x => x.coinPub); + const coins: CoinRecord[] = []; + for (let coinKey of coinKeys) { + const cc = await oneShotGet(ws.db, Stores.coins, coinKey); + if (cc) { + coins.push(cc); + } + } + for (let i = 0; i < coins.length; i++) { + const specCoin = sp.payCoinInfo.originalCoins[i]; + const currentCoin = coins[i]; + + // Coin does not exist anymore! + if (!currentCoin) { + return; + } + if (Amounts.cmp(specCoin.currentAmount, currentCoin.currentAmount) !== 0) { + return; + } + } + return sp; +} + +/** + * Add a contract to the wallet and sign coins, and send them. + */ +export async function confirmPay( + ws: InternalWalletState, + proposalId: string, + sessionIdOverride: string | undefined, +): Promise { + logger.trace( + `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, + ); + const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId); + + if (!proposal) { + throw Error(`proposal with id ${proposalId} not found`); + } + + const sessionId = sessionIdOverride || proposal.downloadSessionId; + + let purchase = await oneShotGet( + ws.db, + Stores.purchases, + proposal.contractTermsHash, + ); + + if (purchase) { + return submitPay(ws, purchase.contractTermsHash, sessionId); + } + + const contractAmount = Amounts.parseOrThrow(proposal.contractTerms.amount); + + let wireFeeLimit; + if (!proposal.contractTerms.max_wire_fee) { + wireFeeLimit = Amounts.getZero(contractAmount.currency); + } else { + wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee); + } + + const res = await getCoinsForPayment(ws, { + allowedAuditors: proposal.contractTerms.auditors, + allowedExchanges: proposal.contractTerms.exchanges, + depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee), + paymentAmount: Amounts.parseOrThrow(proposal.contractTerms.amount), + wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, + wireFeeLimit, + // FIXME: parse this properly + wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { + t_ms: 0, + }, + wireMethod: proposal.contractTerms.wire_method, + }); + + logger.trace("coin selection result", res); + + if (!res) { + // Should not happen, since checkPay should be called first + console.log("not confirming payment, insufficient coins"); + throw Error("insufficient balance"); + } + + const sd = await getSpeculativePayData(ws, proposalId); + if (!sd) { + const { exchangeUrl, cds, totalAmount } = res; + const payCoinInfo = await ws.cryptoApi.signDeposit( + proposal.contractTerms, + cds, + totalAmount, + ); + purchase = await recordConfirmPay(ws, proposal, payCoinInfo, exchangeUrl); + } else { + purchase = await recordConfirmPay( + ws, + sd.proposal, + sd.payCoinInfo, + sd.exchangeUrl, + ); + } + + return submitPay(ws, purchase.contractTermsHash, sessionId); +} diff --git a/src/wallet-impl/payback.ts b/src/wallet-impl/payback.ts new file mode 100644 index 000000000..5bf5ff06e --- /dev/null +++ b/src/wallet-impl/payback.ts @@ -0,0 +1,88 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** + * Imports. + */ +import { + oneShotIter, + runWithWriteTransaction, + oneShotGet, + oneShotPut, +} from "../util/query"; +import { InternalWalletState } from "./state"; +import { Stores, TipRecord, CoinStatus } from "../dbTypes"; + +import { Logger } from "../util/logging"; +import { PaybackConfirmation } from "../talerTypes"; +import { updateExchangeFromUrl } from "./exchanges"; + +const logger = new Logger("payback.ts"); + +export async function payback( + ws: InternalWalletState, + coinPub: string, +): Promise { + let coin = await oneShotGet(ws.db, Stores.coins, coinPub); + if (!coin) { + throw Error(`Coin ${coinPub} not found, can't request payback`); + } + const reservePub = coin.reservePub; + if (!reservePub) { + throw Error(`Can't request payback for a refreshed coin`); + } + const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); + if (!reserve) { + throw Error(`Reserve of coin ${coinPub} not found`); + } + switch (coin.status) { + case CoinStatus.Dormant: + throw Error(`Can't do payback for coin ${coinPub} since it's dormant`); + } + coin.status = CoinStatus.Dormant; + // Even if we didn't get the payback yet, we suspend withdrawal, since + // technically we might update reserve status before we get the response + // from the reserve for the payback request. + reserve.hasPayback = true; + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.reserves], + async tx => { + await tx.put(Stores.coins, coin!!); + await tx.put(Stores.reserves, reserve); + }, + ); + ws.notifier.notify(); + + const paybackRequest = await ws.cryptoApi.createPaybackRequest(coin); + const reqUrl = new URL("payback", coin.exchangeBaseUrl); + const resp = await ws.http.postJson(reqUrl.href, paybackRequest); + if (resp.status !== 200) { + throw Error(); + } + const paybackConfirmation = PaybackConfirmation.checked(resp.responseJson); + if (paybackConfirmation.reserve_pub !== coin.reservePub) { + throw Error(`Coin's reserve doesn't match reserve on payback`); + } + coin = await oneShotGet(ws.db, Stores.coins, coinPub); + if (!coin) { + throw Error(`Coin ${coinPub} not found, can't confirm payback`); + } + coin.status = CoinStatus.Dormant; + await oneShotPut(ws.db, Stores.coins, coin); + ws.notifier.notify(); + await updateExchangeFromUrl(ws, coin.exchangeBaseUrl, true); +} diff --git a/src/wallet-impl/pending.ts b/src/wallet-impl/pending.ts new file mode 100644 index 000000000..a66571a34 --- /dev/null +++ b/src/wallet-impl/pending.ts @@ -0,0 +1,208 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + + /** + * Imports. + */ +import { PendingOperationInfo, PendingOperationsResponse } from "../walletTypes"; +import { oneShotIter } from "../util/query"; +import { InternalWalletState } from "./state"; +import { Stores, ExchangeUpdateStatus, ReserveRecordStatus, CoinStatus, ProposalStatus } from "../dbTypes"; + +export async function getPendingOperations( + ws: InternalWalletState, +): Promise { + const pendingOperations: PendingOperationInfo[] = []; + const exchanges = await oneShotIter(ws.db, Stores.exchanges).toArray(); + for (let e of exchanges) { + switch (e.updateStatus) { + case ExchangeUpdateStatus.FINISHED: + if (e.lastError) { + pendingOperations.push({ + type: "bug", + message: + "Exchange record is in FINISHED state but has lastError set", + details: { + exchangeBaseUrl: e.baseUrl, + }, + }); + } + if (!e.details) { + pendingOperations.push({ + type: "bug", + message: + "Exchange record does not have details, but no update in progress.", + details: { + exchangeBaseUrl: e.baseUrl, + }, + }); + } + if (!e.wireInfo) { + pendingOperations.push({ + type: "bug", + message: + "Exchange record does not have wire info, but no update in progress.", + details: { + exchangeBaseUrl: e.baseUrl, + }, + }); + } + break; + case ExchangeUpdateStatus.FETCH_KEYS: + pendingOperations.push({ + type: "exchange-update", + stage: "fetch-keys", + exchangeBaseUrl: e.baseUrl, + lastError: e.lastError, + reason: e.updateReason || "unknown", + }); + break; + case ExchangeUpdateStatus.FETCH_WIRE: + pendingOperations.push({ + type: "exchange-update", + stage: "fetch-wire", + exchangeBaseUrl: e.baseUrl, + lastError: e.lastError, + reason: e.updateReason || "unknown", + }); + break; + default: + pendingOperations.push({ + type: "bug", + message: "Unknown exchangeUpdateStatus", + details: { + exchangeBaseUrl: e.baseUrl, + exchangeUpdateStatus: e.updateStatus, + }, + }); + break; + } + } + await oneShotIter(ws.db, Stores.reserves).forEach(reserve => { + const reserveType = reserve.bankWithdrawStatusUrl + ? "taler-bank" + : "manual"; + switch (reserve.reserveStatus) { + case ReserveRecordStatus.DORMANT: + // nothing to report as pending + break; + case ReserveRecordStatus.WITHDRAWING: + case ReserveRecordStatus.UNCONFIRMED: + case ReserveRecordStatus.QUERYING_STATUS: + case ReserveRecordStatus.REGISTERING_BANK: + pendingOperations.push({ + type: "reserve", + stage: reserve.reserveStatus, + timestampCreated: reserve.created, + reserveType, + reservePub: reserve.reservePub, + }); + break; + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + pendingOperations.push({ + type: "reserve", + stage: reserve.reserveStatus, + timestampCreated: reserve.created, + reserveType, + reservePub: reserve.reservePub, + bankWithdrawConfirmUrl: reserve.bankWithdrawConfirmUrl, + }); + break; + default: + pendingOperations.push({ + type: "bug", + message: "Unknown reserve record status", + details: { + reservePub: reserve.reservePub, + reserveStatus: reserve.reserveStatus, + }, + }); + break; + } + }); + + await oneShotIter(ws.db, Stores.refresh).forEach(r => { + if (r.finished) { + return; + } + let refreshStatus: string; + if (r.norevealIndex === undefined) { + refreshStatus = "melt"; + } else { + refreshStatus = "reveal"; + } + + pendingOperations.push({ + type: "refresh", + oldCoinPub: r.meltCoinPub, + refreshStatus, + refreshOutputSize: r.newDenoms.length, + refreshSessionId: r.refreshSessionId, + }); + }); + + await oneShotIter(ws.db, Stores.coins).forEach(coin => { + if (coin.status == CoinStatus.Dirty) { + pendingOperations.push({ + type: "dirty-coin", + coinPub: coin.coinPub, + }); + } + }); + + await oneShotIter(ws.db, Stores.withdrawalSession).forEach(ws => { + const numCoinsWithdrawn = ws.withdrawn.reduce( + (a, x) => a + (x ? 1 : 0), + 0, + ); + const numCoinsTotal = ws.withdrawn.length; + if (numCoinsWithdrawn < numCoinsTotal) { + pendingOperations.push({ + type: "withdraw", + numCoinsTotal, + numCoinsWithdrawn, + source: ws.source, + withdrawSessionId: ws.withdrawSessionId, + }); + } + }); + + await oneShotIter(ws.db, Stores.proposals).forEach(proposal => { + if (proposal.proposalStatus == ProposalStatus.PROPOSED) { + pendingOperations.push({ + type: "proposal", + merchantBaseUrl: proposal.contractTerms.merchant_base_url, + proposalId: proposal.proposalId, + proposalTimestamp: proposal.timestamp, + }); + } + }); + + await oneShotIter(ws.db, Stores.tips).forEach(tip => { + if (tip.accepted && !tip.pickedUp) { + pendingOperations.push({ + type: "tip", + merchantBaseUrl: tip.merchantBaseUrl, + tipId: tip.tipId, + merchantTipId: tip.merchantTipId, + }); + } + }); + + return { + pendingOperations, + }; +} diff --git a/src/wallet-impl/refresh.ts b/src/wallet-impl/refresh.ts new file mode 100644 index 000000000..7e7270ed3 --- /dev/null +++ b/src/wallet-impl/refresh.ts @@ -0,0 +1,416 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { AmountJson } from "../util/amounts"; +import * as Amounts from "../util/amounts"; +import { + DenominationRecord, + Stores, + CoinStatus, + RefreshPlanchetRecord, + CoinRecord, + RefreshSessionRecord, +} from "../dbTypes"; +import { amountToPretty } from "../util/helpers"; +import { + oneShotGet, + oneShotMutate, + runWithWriteTransaction, + TransactionAbort, + oneShotIterIndex, +} from "../util/query"; +import { InternalWalletState } from "./state"; +import { Logger } from "../util/logging"; +import { getWithdrawDenomList } from "./withdraw"; +import { updateExchangeFromUrl } from "./exchanges"; + +const logger = new Logger("refresh.ts"); + +/** + * Get the amount that we lose when refreshing a coin of the given denomination + * with a certain amount left. + * + * If the amount left is zero, then the refresh cost + * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of + * the right denominations), then the cost is the full amount left. + * + * Considers refresh fees, withdrawal fees after refresh and amounts too small + * to refresh. + */ +export function getTotalRefreshCost( + denoms: DenominationRecord[], + refreshedDenom: DenominationRecord, + amountLeft: AmountJson, +): AmountJson { + const withdrawAmount = Amounts.sub(amountLeft, refreshedDenom.feeRefresh) + .amount; + const withdrawDenoms = getWithdrawDenomList(withdrawAmount, denoms); + const resultingAmount = Amounts.add( + Amounts.getZero(withdrawAmount.currency), + ...withdrawDenoms.map(d => d.value), + ).amount; + const totalCost = Amounts.sub(amountLeft, resultingAmount).amount; + logger.trace( + "total refresh cost for", + amountToPretty(amountLeft), + "is", + amountToPretty(totalCost), + ); + return totalCost; +} + +async function refreshMelt( + ws: InternalWalletState, + refreshSessionId: string, +): Promise { + const refreshSession = await oneShotGet( + ws.db, + Stores.refresh, + refreshSessionId, + ); + if (!refreshSession) { + return; + } + if (refreshSession.norevealIndex !== undefined) { + return; + } + + const coin = await oneShotGet( + ws.db, + Stores.coins, + refreshSession.meltCoinPub, + ); + + if (!coin) { + console.error("can't melt coin, it does not exist"); + return; + } + + const reqUrl = new URL("refresh/melt", refreshSession.exchangeBaseUrl); + const meltReq = { + coin_pub: coin.coinPub, + confirm_sig: refreshSession.confirmSig, + denom_pub_hash: coin.denomPubHash, + denom_sig: coin.denomSig, + rc: refreshSession.hash, + value_with_fee: refreshSession.valueWithFee, + }; + logger.trace("melt request:", meltReq); + const resp = await ws.http.postJson(reqUrl.href, meltReq); + + logger.trace("melt response:", resp.responseJson); + + if (resp.status !== 200) { + console.error(resp.responseJson); + throw Error("refresh failed"); + } + + const respJson = resp.responseJson; + + const norevealIndex = respJson.noreveal_index; + + if (typeof norevealIndex !== "number") { + throw Error("invalid response"); + } + + refreshSession.norevealIndex = norevealIndex; + + await oneShotMutate(ws.db, Stores.refresh, refreshSessionId, rs => { + if (rs.norevealIndex !== undefined) { + return; + } + if (rs.finished) { + return; + } + rs.norevealIndex = norevealIndex; + return rs; + }); + + ws.notifier.notify(); +} + +async function refreshReveal( + ws: InternalWalletState, + refreshSessionId: string, +): Promise { + const refreshSession = await oneShotGet( + ws.db, + Stores.refresh, + refreshSessionId, + ); + if (!refreshSession) { + return; + } + const norevealIndex = refreshSession.norevealIndex; + if (norevealIndex === undefined) { + throw Error("can't reveal without melting first"); + } + const privs = Array.from(refreshSession.transferPrivs); + privs.splice(norevealIndex, 1); + + const planchets = refreshSession.planchetsForGammas[norevealIndex]; + if (!planchets) { + throw Error("refresh index error"); + } + + const meltCoinRecord = await oneShotGet( + ws.db, + Stores.coins, + refreshSession.meltCoinPub, + ); + if (!meltCoinRecord) { + throw Error("inconsistent database"); + } + + const evs = planchets.map((x: RefreshPlanchetRecord) => x.coinEv); + + const linkSigs: string[] = []; + for (let i = 0; i < refreshSession.newDenoms.length; i++) { + const linkSig = await ws.cryptoApi.signCoinLink( + meltCoinRecord.coinPriv, + refreshSession.newDenomHashes[i], + refreshSession.meltCoinPub, + refreshSession.transferPubs[norevealIndex], + planchets[i].coinEv, + ); + linkSigs.push(linkSig); + } + + const req = { + coin_evs: evs, + new_denoms_h: refreshSession.newDenomHashes, + rc: refreshSession.hash, + transfer_privs: privs, + transfer_pub: refreshSession.transferPubs[norevealIndex], + link_sigs: linkSigs, + }; + + const reqUrl = new URL("refresh/reveal", refreshSession.exchangeBaseUrl); + logger.trace("reveal request:", req); + + let resp; + try { + resp = await ws.http.postJson(reqUrl.href, req); + } catch (e) { + console.error("got error during /refresh/reveal request"); + console.error(e); + return; + } + + logger.trace("session:", refreshSession); + logger.trace("reveal response:", resp); + + if (resp.status !== 200) { + console.error("error: /refresh/reveal returned status " + resp.status); + return; + } + + const respJson = resp.responseJson; + + if (!respJson.ev_sigs || !Array.isArray(respJson.ev_sigs)) { + console.error("/refresh/reveal did not contain ev_sigs"); + return; + } + + const exchange = oneShotGet( + ws.db, + Stores.exchanges, + refreshSession.exchangeBaseUrl, + ); + if (!exchange) { + console.error(`exchange ${refreshSession.exchangeBaseUrl} not found`); + return; + } + + const coins: CoinRecord[] = []; + + for (let i = 0; i < respJson.ev_sigs.length; i++) { + const denom = await oneShotGet(ws.db, Stores.denominations, [ + refreshSession.exchangeBaseUrl, + refreshSession.newDenoms[i], + ]); + if (!denom) { + console.error("denom not found"); + continue; + } + const pc = + refreshSession.planchetsForGammas[refreshSession.norevealIndex!][i]; + const denomSig = await ws.cryptoApi.rsaUnblind( + respJson.ev_sigs[i].ev_sig, + pc.blindingKey, + denom.denomPub, + ); + const coin: CoinRecord = { + blindingKey: pc.blindingKey, + coinPriv: pc.privateKey, + coinPub: pc.publicKey, + currentAmount: denom.value, + denomPub: denom.denomPub, + denomPubHash: denom.denomPubHash, + denomSig, + exchangeBaseUrl: refreshSession.exchangeBaseUrl, + reservePub: undefined, + status: CoinStatus.Fresh, + coinIndex: -1, + withdrawSessionId: "", + }; + + coins.push(coin); + } + + refreshSession.finished = true; + + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.refresh], + async tx => { + const rs = await tx.get(Stores.refresh, refreshSessionId); + if (!rs) { + return; + } + if (rs.finished) { + return; + } + for (let coin of coins) { + await tx.put(Stores.coins, coin); + } + await tx.put(Stores.refresh, refreshSession); + }, + ); + ws.notifier.notify(); +} + +export async function processRefreshSession( + ws: InternalWalletState, + refreshSessionId: string, +) { + const refreshSession = await oneShotGet( + ws.db, + Stores.refresh, + refreshSessionId, + ); + if (!refreshSession) { + return; + } + if (refreshSession.finished) { + return; + } + if (typeof refreshSession.norevealIndex !== "number") { + await refreshMelt(ws, refreshSession.refreshSessionId); + } + await refreshReveal(ws, refreshSession.refreshSessionId); + logger.trace("refresh finished"); +} + +export async function refresh( + ws: InternalWalletState, + oldCoinPub: string, + force: boolean = false, +): Promise { + const coin = await oneShotGet(ws.db, Stores.coins, oldCoinPub); + if (!coin) { + console.warn("can't refresh, coin not in database"); + return; + } + switch (coin.status) { + case CoinStatus.Dirty: + break; + case CoinStatus.Dormant: + return; + case CoinStatus.Fresh: + if (!force) { + return; + } + break; + } + + const exchange = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl); + if (!exchange) { + throw Error("db inconsistent: exchange of coin not found"); + } + + const oldDenom = await oneShotGet(ws.db, Stores.denominations, [ + exchange.baseUrl, + coin.denomPub, + ]); + + if (!oldDenom) { + throw Error("db inconsistent: denomination for coin not found"); + } + + const availableDenoms: DenominationRecord[] = await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + exchange.baseUrl, + ).toArray(); + + const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh) + .amount; + + const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms); + + if (newCoinDenoms.length === 0) { + logger.trace( + `not refreshing, available amount ${amountToPretty( + availableAmount, + )} too small`, + ); + await oneShotMutate(ws.db, Stores.coins, oldCoinPub, x => { + if (x.status != coin.status) { + // Concurrent modification? + return; + } + x.status = CoinStatus.Dormant; + return x; + }); + ws.notifier.notify(); + return; + } + + const refreshSession: RefreshSessionRecord = await ws.cryptoApi.createRefreshSession( + exchange.baseUrl, + 3, + coin, + newCoinDenoms, + oldDenom.feeRefresh, + ); + + function mutateCoin(c: CoinRecord): CoinRecord { + const r = Amounts.sub(c.currentAmount, refreshSession.valueWithFee); + if (r.saturated) { + // Something else must have written the coin value + throw TransactionAbort; + } + c.currentAmount = r.amount; + c.status = CoinStatus.Dormant; + return c; + } + + // Store refresh session and subtract refreshed amount from + // coin in the same transaction. + await runWithWriteTransaction( + ws.db, + [Stores.refresh, Stores.coins], + async tx => { + await tx.put(Stores.refresh, refreshSession); + await tx.mutate(Stores.coins, coin.coinPub, mutateCoin); + }, + ); + logger.info(`created refresh session ${refreshSession.refreshSessionId}`); + ws.notifier.notify(); + + await processRefreshSession(ws, refreshSession.refreshSessionId); +} diff --git a/src/wallet-impl/refund.ts b/src/wallet-impl/refund.ts new file mode 100644 index 000000000..2a9dea149 --- /dev/null +++ b/src/wallet-impl/refund.ts @@ -0,0 +1,245 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { + MerchantRefundResponse, + RefundRequest, + MerchantRefundPermission, +} from "../talerTypes"; +import { PurchaseRecord, Stores, CoinRecord, CoinStatus } from "../dbTypes"; +import { getTimestampNow } from "../walletTypes"; +import { + oneShotMutate, + oneShotGet, + runWithWriteTransaction, + oneShotIterIndex, +} from "../util/query"; +import { InternalWalletState } from "./state"; +import { parseRefundUri } from "../util/taleruri"; +import { Logger } from "../util/logging"; +import { AmountJson } from "../util/amounts"; +import * as Amounts from "../util/amounts"; +import { getTotalRefreshCost, refresh } from "./refresh"; + +const logger = new Logger("refund.ts"); + +export async function getFullRefundFees( + ws: InternalWalletState, + refundPermissions: MerchantRefundPermission[], +): Promise { + if (refundPermissions.length === 0) { + throw Error("no refunds given"); + } + const coin0 = await oneShotGet( + ws.db, + Stores.coins, + refundPermissions[0].coin_pub, + ); + if (!coin0) { + throw Error("coin not found"); + } + let feeAcc = Amounts.getZero( + Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency, + ); + + const denoms = await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + coin0.exchangeBaseUrl, + ).toArray(); + + for (const rp of refundPermissions) { + const coin = await oneShotGet(ws.db, Stores.coins, rp.coin_pub); + if (!coin) { + throw Error("coin not found"); + } + const denom = await oneShotGet(ws.db, Stores.denominations, [ + coin0.exchangeBaseUrl, + coin.denomPub, + ]); + if (!denom) { + throw Error(`denom not found (${coin.denomPub})`); + } + // FIXME: this assumes that the refund already happened. + // When it hasn't, the refresh cost is inaccurate. To fix this, + // we need introduce a flag to tell if a coin was refunded or + // refreshed normally (and what about incremental refunds?) + const refundAmount = Amounts.parseOrThrow(rp.refund_amount); + const refundFee = Amounts.parseOrThrow(rp.refund_fee); + const refreshCost = getTotalRefreshCost( + denoms, + denom, + Amounts.sub(refundAmount, refundFee).amount, + ); + feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount; + } + return feeAcc; +} + +async function submitRefunds( + ws: InternalWalletState, + contractTermsHash: string, +): Promise { + const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash); + if (!purchase) { + console.error( + "not submitting refunds, contract terms not found:", + contractTermsHash, + ); + return; + } + const pendingKeys = Object.keys(purchase.refundsPending); + if (pendingKeys.length === 0) { + return; + } + for (const pk of pendingKeys) { + const perm = purchase.refundsPending[pk]; + const req: RefundRequest = { + coin_pub: perm.coin_pub, + h_contract_terms: purchase.contractTermsHash, + merchant_pub: purchase.contractTerms.merchant_pub, + merchant_sig: perm.merchant_sig, + refund_amount: perm.refund_amount, + refund_fee: perm.refund_fee, + rtransaction_id: perm.rtransaction_id, + }; + console.log("sending refund permission", perm); + // FIXME: not correct once we support multiple exchanges per payment + const exchangeUrl = purchase.payReq.coins[0].exchange_url; + const reqUrl = new URL("refund", exchangeUrl); + const resp = await ws.http.postJson(reqUrl.href, req); + if (resp.status !== 200) { + console.error("refund failed", resp); + continue; + } + + // Transactionally mark successful refunds as done + const transformPurchase = ( + t: PurchaseRecord | undefined, + ): PurchaseRecord | undefined => { + if (!t) { + console.warn("purchase not found, not updating refund"); + return; + } + if (t.refundsPending[pk]) { + t.refundsDone[pk] = t.refundsPending[pk]; + delete t.refundsPending[pk]; + } + return t; + }; + const transformCoin = ( + c: CoinRecord | undefined, + ): CoinRecord | undefined => { + if (!c) { + console.warn("coin not found, can't apply refund"); + return; + } + const refundAmount = Amounts.parseOrThrow(perm.refund_amount); + const refundFee = Amounts.parseOrThrow(perm.refund_fee); + c.status = CoinStatus.Dirty; + c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; + c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; + + return c; + }; + + await runWithWriteTransaction( + ws.db, + [Stores.purchases, Stores.coins], + async tx => { + await tx.mutate(Stores.purchases, contractTermsHash, transformPurchase); + await tx.mutate(Stores.coins, perm.coin_pub, transformCoin); + }, + ); + refresh(ws, perm.coin_pub); + } + + ws.badge.showNotification(); + ws.notifier.notify(); +} + +export async function acceptRefundResponse( + ws: InternalWalletState, + refundResponse: MerchantRefundResponse, +): Promise { + const refundPermissions = refundResponse.refund_permissions; + + if (!refundPermissions.length) { + console.warn("got empty refund list"); + throw Error("empty refund"); + } + + /** + * Add refund to purchase if not already added. + */ + function f(t: PurchaseRecord | undefined): PurchaseRecord | undefined { + if (!t) { + console.error("purchase not found, not adding refunds"); + return; + } + + t.timestamp_refund = getTimestampNow(); + + for (const perm of refundPermissions) { + if ( + !t.refundsPending[perm.merchant_sig] && + !t.refundsDone[perm.merchant_sig] + ) { + t.refundsPending[perm.merchant_sig] = perm; + } + } + return t; + } + + const hc = refundResponse.h_contract_terms; + + // Add the refund permissions to the purchase within a DB transaction + await oneShotMutate(ws.db, Stores.purchases, hc, f); + ws.notifier.notify(); + + await submitRefunds(ws, hc); + + return hc; +} + +/** + * Accept a refund, return the contract hash for the contract + * that was involved in the refund. + */ +export async function applyRefund( + ws: InternalWalletState, + talerRefundUri: string, +): Promise { + const parseResult = parseRefundUri(talerRefundUri); + + if (!parseResult) { + throw Error("invalid refund URI"); + } + + const refundUrl = parseResult.refundUrl; + + logger.trace("processing refund"); + let resp; + try { + resp = await ws.http.get(refundUrl); + } catch (e) { + console.error("error downloading refund permission", e); + throw e; + } + + const refundResponse = MerchantRefundResponse.checked(resp.responseJson); + return acceptRefundResponse(ws, refundResponse); +} diff --git a/src/wallet-impl/reserves.ts b/src/wallet-impl/reserves.ts new file mode 100644 index 000000000..265eddce4 --- /dev/null +++ b/src/wallet-impl/reserves.ts @@ -0,0 +1,567 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { + CreateReserveRequest, + CreateReserveResponse, + getTimestampNow, + ConfirmReserveRequest, + OperationError, +} from "../walletTypes"; +import { canonicalizeBaseUrl } from "../util/helpers"; +import { InternalWalletState } from "./state"; +import { + ReserveRecordStatus, + ReserveRecord, + CurrencyRecord, + Stores, + WithdrawalSessionRecord, +} from "../dbTypes"; +import { + oneShotMutate, + oneShotPut, + oneShotGet, + runWithWriteTransaction, + TransactionAbort, +} from "../util/query"; +import { Logger } from "../util/logging"; +import * as Amounts from "../util/amounts"; +import { updateExchangeFromUrl, getExchangeTrust } from "./exchanges"; +import { WithdrawOperationStatusResponse, ReserveStatus } from "../talerTypes"; +import { assertUnreachable } from "../util/assertUnreachable"; +import { OperationFailedAndReportedError } from "../wallet"; +import { encodeCrock } from "../crypto/talerCrypto"; +import { randomBytes } from "../crypto/primitives/nacl-fast"; +import { + getVerifiedWithdrawDenomList, + processWithdrawSession, +} from "./withdraw"; + +const logger = new Logger("reserves.ts"); + +/** + * Create a reserve, but do not flag it as confirmed yet. + * + * Adds the corresponding exchange as a trusted exchange if it is neither + * audited nor trusted already. + */ +export async function createReserve( + ws: InternalWalletState, + req: CreateReserveRequest, +): Promise { + const keypair = await ws.cryptoApi.createEddsaKeypair(); + const now = getTimestampNow(); + const canonExchange = canonicalizeBaseUrl(req.exchange); + + let reserveStatus; + if (req.bankWithdrawStatusUrl) { + reserveStatus = ReserveRecordStatus.REGISTERING_BANK; + } else { + reserveStatus = ReserveRecordStatus.UNCONFIRMED; + } + + const currency = req.amount.currency; + + const reserveRecord: ReserveRecord = { + created: now, + withdrawAllocatedAmount: Amounts.getZero(currency), + withdrawCompletedAmount: Amounts.getZero(currency), + withdrawRemainingAmount: Amounts.getZero(currency), + exchangeBaseUrl: canonExchange, + hasPayback: false, + initiallyRequestedAmount: req.amount, + reservePriv: keypair.priv, + reservePub: keypair.pub, + senderWire: req.senderWire, + timestampConfirmed: undefined, + timestampReserveInfoPosted: undefined, + bankWithdrawStatusUrl: req.bankWithdrawStatusUrl, + exchangeWire: req.exchangeWire, + reserveStatus, + lastStatusQuery: undefined, + }; + + const senderWire = req.senderWire; + if (senderWire) { + const rec = { + paytoUri: senderWire, + }; + await oneShotPut(ws.db, Stores.senderWires, rec); + } + + const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange); + const exchangeDetails = exchangeInfo.details; + if (!exchangeDetails) { + throw Error("exchange not updated"); + } + const { isAudited, isTrusted } = await getExchangeTrust(ws, exchangeInfo); + let currencyRecord = await oneShotGet( + ws.db, + Stores.currencies, + exchangeDetails.currency, + ); + if (!currencyRecord) { + currencyRecord = { + auditors: [], + exchanges: [], + fractionalDigits: 2, + name: exchangeDetails.currency, + }; + } + + if (!isAudited && !isTrusted) { + currencyRecord.exchanges.push({ + baseUrl: req.exchange, + exchangePub: exchangeDetails.masterPublicKey, + }); + } + + const cr: CurrencyRecord = currencyRecord; + + const resp = await runWithWriteTransaction( + ws.db, + [Stores.currencies, Stores.reserves, Stores.bankWithdrawUris], + async tx => { + // Check if we have already created a reserve for that bankWithdrawStatusUrl + if (reserveRecord.bankWithdrawStatusUrl) { + const bwi = await tx.get( + Stores.bankWithdrawUris, + reserveRecord.bankWithdrawStatusUrl, + ); + if (bwi) { + const otherReserve = await tx.get(Stores.reserves, bwi.reservePub); + if (otherReserve) { + logger.trace( + "returning existing reserve for bankWithdrawStatusUri", + ); + return { + exchange: otherReserve.exchangeBaseUrl, + reservePub: otherReserve.reservePub, + }; + } + } + await tx.put(Stores.bankWithdrawUris, { + reservePub: reserveRecord.reservePub, + talerWithdrawUri: reserveRecord.bankWithdrawStatusUrl, + }); + } + await tx.put(Stores.currencies, cr); + await tx.put(Stores.reserves, reserveRecord); + const r: CreateReserveResponse = { + exchange: canonExchange, + reservePub: keypair.pub, + }; + return r; + }, + ); + + // Asynchronously process the reserve, but return + // to the caller already. + processReserve(ws, resp.reservePub).catch(e => { + console.error("Processing reserve failed:", e); + }); + + return resp; +} + +/** + * First fetch information requred to withdraw from the reserve, + * then deplete the reserve, withdrawing coins until it is empty. + * + * The returned promise resolves once the reserve is set to the + * state DORMANT. + */ +export async function processReserve( + ws: InternalWalletState, + reservePub: string, +): Promise { + const p = ws.memoProcessReserve.find(reservePub); + if (p) { + return p; + } else { + return ws.memoProcessReserve.put( + reservePub, + processReserveImpl(ws, reservePub), + ); + } +} + +async function registerReserveWithBank( + ws: InternalWalletState, + reservePub: string, +): Promise { + let reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); + switch (reserve?.reserveStatus) { + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + case ReserveRecordStatus.REGISTERING_BANK: + break; + default: + return; + } + const bankStatusUrl = reserve.bankWithdrawStatusUrl; + if (!bankStatusUrl) { + return; + } + console.log("making selection"); + if (reserve.timestampReserveInfoPosted) { + throw Error("bank claims that reserve info selection is not done"); + } + const bankResp = await ws.http.postJson(bankStatusUrl, { + reserve_pub: reservePub, + selected_exchange: reserve.exchangeWire, + }); + console.log("got response", bankResp); + await oneShotMutate(ws.db, Stores.reserves, reservePub, r => { + switch (r.reserveStatus) { + case ReserveRecordStatus.REGISTERING_BANK: + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + break; + default: + return; + } + r.timestampReserveInfoPosted = getTimestampNow(); + r.reserveStatus = ReserveRecordStatus.WAIT_CONFIRM_BANK; + return r; + }); + return processReserveBankStatus(ws, reservePub); +} + +export async function processReserveBankStatus( + ws: InternalWalletState, + reservePub: string, +): Promise { + let reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); + switch (reserve?.reserveStatus) { + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + case ReserveRecordStatus.REGISTERING_BANK: + break; + default: + return; + } + const bankStatusUrl = reserve.bankWithdrawStatusUrl; + if (!bankStatusUrl) { + return; + } + + let status: WithdrawOperationStatusResponse; + try { + const statusResp = await ws.http.get(bankStatusUrl); + status = WithdrawOperationStatusResponse.checked(statusResp.responseJson); + } catch (e) { + throw e; + } + + if (status.selection_done) { + if (reserve.reserveStatus === ReserveRecordStatus.REGISTERING_BANK) { + await registerReserveWithBank(ws, reservePub); + return await processReserveBankStatus(ws, reservePub); + } + } else { + await registerReserveWithBank(ws, reservePub); + return await processReserveBankStatus(ws, reservePub); + } + + if (status.transfer_done) { + await oneShotMutate(ws.db, Stores.reserves, reservePub, r => { + switch (r.reserveStatus) { + case ReserveRecordStatus.REGISTERING_BANK: + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + break; + default: + return; + } + const now = getTimestampNow(); + r.timestampConfirmed = now; + r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; + return r; + }); + await processReserveImpl(ws, reservePub); + } else { + await oneShotMutate(ws.db, Stores.reserves, reservePub, r => { + switch (r.reserveStatus) { + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + break; + default: + return; + } + r.bankWithdrawConfirmUrl = status.confirm_transfer_url; + return r; + }); + } +} + +async function setReserveError( + ws: InternalWalletState, + reservePub: string, + err: OperationError, +): Promise { + const mut = (reserve: ReserveRecord) => { + reserve.lastError = err; + return reserve; + }; + await oneShotMutate(ws.db, Stores.reserves, reservePub, mut); +} + +/** + * Update the information about a reserve that is stored in the wallet + * by quering the reserve's exchange. + */ +async function updateReserve( + ws: InternalWalletState, + reservePub: string, +): Promise { + const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); + if (!reserve) { + throw Error("reserve not in db"); + } + + if (reserve.timestampConfirmed === undefined) { + throw Error("reserve not confirmed yet"); + } + + if (reserve.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) { + return; + } + + const reqUrl = new URL("reserve/status", reserve.exchangeBaseUrl); + reqUrl.searchParams.set("reserve_pub", reservePub); + let resp; + try { + resp = await ws.http.get(reqUrl.href); + } catch (e) { + if (e.response?.status === 404) { + return; + } else { + const m = e.message; + setReserveError(ws, reservePub, { + type: "network", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + } + const reserveInfo = ReserveStatus.checked(resp.responseJson); + const balance = Amounts.parseOrThrow(reserveInfo.balance); + await oneShotMutate(ws.db, Stores.reserves, reserve.reservePub, r => { + if (r.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) { + return; + } + + // FIXME: check / compare history! + if (!r.lastStatusQuery) { + // FIXME: check if this matches initial expectations + r.withdrawRemainingAmount = balance; + } else { + const expectedBalance = Amounts.sub( + r.withdrawAllocatedAmount, + r.withdrawCompletedAmount, + ); + const cmp = Amounts.cmp(balance, expectedBalance.amount); + if (cmp == 0) { + // Nothing changed. + return; + } + if (cmp > 0) { + const extra = Amounts.sub(balance, expectedBalance.amount).amount; + r.withdrawRemainingAmount = Amounts.add( + r.withdrawRemainingAmount, + extra, + ).amount; + } else { + // We're missing some money. + } + } + r.lastStatusQuery = getTimestampNow(); + r.reserveStatus = ReserveRecordStatus.WITHDRAWING; + return r; + }); + ws.notifier.notify(); +} + +async function processReserveImpl( + ws: InternalWalletState, + reservePub: string, +): Promise { + const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); + if (!reserve) { + console.log("not processing reserve: reserve does not exist"); + return; + } + logger.trace( + `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`, + ); + switch (reserve.reserveStatus) { + case ReserveRecordStatus.UNCONFIRMED: + // nothing to do + break; + case ReserveRecordStatus.REGISTERING_BANK: + await processReserveBankStatus(ws, reservePub); + return processReserveImpl(ws, reservePub); + case ReserveRecordStatus.QUERYING_STATUS: + await updateReserve(ws, reservePub); + return processReserveImpl(ws, reservePub); + case ReserveRecordStatus.WITHDRAWING: + await depleteReserve(ws, reservePub); + break; + case ReserveRecordStatus.DORMANT: + // nothing to do + break; + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + await processReserveBankStatus(ws, reservePub); + break; + default: + console.warn("unknown reserve record status:", reserve.reserveStatus); + assertUnreachable(reserve.reserveStatus); + break; + } +} + +export async function confirmReserve( + ws: InternalWalletState, + req: ConfirmReserveRequest, +): Promise { + const now = getTimestampNow(); + await oneShotMutate(ws.db, Stores.reserves, req.reservePub, reserve => { + if (reserve.reserveStatus !== ReserveRecordStatus.UNCONFIRMED) { + return; + } + reserve.timestampConfirmed = now; + reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; + return reserve; + }); + + ws.notifier.notify(); + + processReserve(ws, req.reservePub).catch(e => { + console.log("processing reserve failed:", e); + }); +} + +/** + * Withdraw coins from a reserve until it is empty. + * + * When finished, marks the reserve as depleted by setting + * the depleted timestamp. + */ +async function depleteReserve( + ws: InternalWalletState, + reservePub: string, +): Promise { + const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); + if (!reserve) { + return; + } + if (reserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { + return; + } + logger.trace(`depleting reserve ${reservePub}`); + + const withdrawAmount = reserve.withdrawRemainingAmount; + + logger.trace(`getting denom list`); + + const denomsForWithdraw = await getVerifiedWithdrawDenomList( + ws, + reserve.exchangeBaseUrl, + withdrawAmount, + ); + logger.trace(`got denom list`); + if (denomsForWithdraw.length === 0) { + const m = `Unable to withdraw from reserve, no denominations are available to withdraw.`; + await setReserveError(ws, reserve.reservePub, { + type: "internal", + message: m, + details: {}, + }); + console.log(m); + throw new OperationFailedAndReportedError(m); + } + + logger.trace("selected denominations"); + + const withdrawalSessionId = encodeCrock(randomBytes(32)); + + const withdrawalRecord: WithdrawalSessionRecord = { + withdrawSessionId: withdrawalSessionId, + exchangeBaseUrl: reserve.exchangeBaseUrl, + source: { + type: "reserve", + reservePub: reserve.reservePub, + }, + withdrawalAmount: Amounts.toString(withdrawAmount), + startTimestamp: getTimestampNow(), + denoms: denomsForWithdraw.map(x => x.denomPub), + withdrawn: denomsForWithdraw.map(x => false), + planchets: denomsForWithdraw.map(x => undefined), + }; + + const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => x.value)) + .amount; + const totalCoinWithdrawFee = Amounts.sum( + denomsForWithdraw.map(x => x.feeWithdraw), + ).amount; + const totalWithdrawAmount = Amounts.add(totalCoinValue, totalCoinWithdrawFee) + .amount; + + function mutateReserve(r: ReserveRecord): ReserveRecord { + const remaining = Amounts.sub( + r.withdrawRemainingAmount, + totalWithdrawAmount, + ); + if (remaining.saturated) { + console.error("can't create planchets, saturated"); + throw TransactionAbort; + } + const allocated = Amounts.add( + r.withdrawAllocatedAmount, + totalWithdrawAmount, + ); + if (allocated.saturated) { + console.error("can't create planchets, saturated"); + throw TransactionAbort; + } + r.withdrawRemainingAmount = remaining.amount; + r.withdrawAllocatedAmount = allocated.amount; + r.reserveStatus = ReserveRecordStatus.DORMANT; + + return r; + } + + const success = await runWithWriteTransaction( + ws.db, + [Stores.withdrawalSession, Stores.reserves], + async tx => { + const myReserve = await tx.get(Stores.reserves, reservePub); + if (!myReserve) { + return false; + } + if (myReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { + return false; + } + await tx.mutate(Stores.reserves, reserve.reservePub, mutateReserve); + await tx.put(Stores.withdrawalSession, withdrawalRecord); + return true; + }, + ); + + if (success) { + console.log("processing new withdraw session"); + await processWithdrawSession(ws, withdrawalSessionId); + } else { + console.trace("withdraw session already existed"); + } +} diff --git a/src/wallet-impl/return.ts b/src/wallet-impl/return.ts new file mode 100644 index 000000000..9cf12052d --- /dev/null +++ b/src/wallet-impl/return.ts @@ -0,0 +1,274 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** + * Imports. + */ +import { + HistoryQuery, + HistoryEvent, + WalletBalance, + WalletBalanceEntry, + ReturnCoinsRequest, + CoinWithDenom, +} from "../walletTypes"; +import { oneShotIter, runWithWriteTransaction, oneShotGet, oneShotIterIndex, oneShotPut } from "../util/query"; +import { InternalWalletState } from "./state"; +import { Stores, TipRecord, CoinStatus, CoinsReturnRecord, CoinRecord } from "../dbTypes"; +import * as Amounts from "../util/amounts"; +import { AmountJson } from "../util/amounts"; +import { Logger } from "../util/logging"; +import { canonicalJson } from "../util/helpers"; +import { ContractTerms } from "../talerTypes"; +import { selectPayCoins } from "./pay"; + +const logger = new Logger("return.ts"); + +async function getCoinsForReturn( + ws: InternalWalletState, + exchangeBaseUrl: string, + amount: AmountJson, +): Promise { + const exchange = await oneShotGet( + ws.db, + Stores.exchanges, + exchangeBaseUrl, + ); + if (!exchange) { + throw Error(`Exchange ${exchangeBaseUrl} not known to the wallet`); + } + + const coins: CoinRecord[] = await oneShotIterIndex( + ws.db, + Stores.coins.exchangeBaseUrlIndex, + exchange.baseUrl, + ).toArray(); + + if (!coins || !coins.length) { + return []; + } + + const denoms = await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + exchange.baseUrl, + ).toArray(); + + // Denomination of the first coin, we assume that all other + // coins have the same currency + const firstDenom = await oneShotGet(ws.db, Stores.denominations, [ + exchange.baseUrl, + coins[0].denomPub, + ]); + if (!firstDenom) { + throw Error("db inconsistent"); + } + const currency = firstDenom.value.currency; + + const cds: CoinWithDenom[] = []; + for (const coin of coins) { + const denom = await oneShotGet(ws.db, Stores.denominations, [ + exchange.baseUrl, + coin.denomPub, + ]); + if (!denom) { + throw Error("db inconsistent"); + } + if (denom.value.currency !== currency) { + console.warn( + `same pubkey for different currencies at exchange ${exchange.baseUrl}`, + ); + continue; + } + if (coin.suspended) { + continue; + } + if (coin.status !== CoinStatus.Fresh) { + continue; + } + cds.push({ coin, denom }); + } + + const res = selectPayCoins(denoms, cds, amount, amount); + if (res) { + return res.cds; + } + return undefined; +} + + +/** + * Trigger paying coins back into the user's account. + */ +export async function returnCoins( + ws: InternalWalletState, + req: ReturnCoinsRequest, +): Promise { + logger.trace("got returnCoins request", req); + const wireType = (req.senderWire as any).type; + logger.trace("wireType", wireType); + if (!wireType || typeof wireType !== "string") { + console.error(`wire type must be a non-empty string, not ${wireType}`); + return; + } + const stampSecNow = Math.floor(new Date().getTime() / 1000); + const exchange = await oneShotGet(ws.db, Stores.exchanges, req.exchange); + if (!exchange) { + console.error(`Exchange ${req.exchange} not known to the wallet`); + return; + } + const exchangeDetails = exchange.details; + if (!exchangeDetails) { + throw Error("exchange information needs to be updated first."); + } + logger.trace("selecting coins for return:", req); + const cds = await getCoinsForReturn(ws, req.exchange, req.amount); + logger.trace(cds); + + if (!cds) { + throw Error("coin return impossible, can't select coins"); + } + + const { priv, pub } = await ws.cryptoApi.createEddsaKeypair(); + + const wireHash = await ws.cryptoApi.hashString( + canonicalJson(req.senderWire), + ); + + const contractTerms: ContractTerms = { + H_wire: wireHash, + amount: Amounts.toString(req.amount), + auditors: [], + exchanges: [ + { master_pub: exchangeDetails.masterPublicKey, url: exchange.baseUrl }, + ], + extra: {}, + fulfillment_url: "", + locations: [], + max_fee: Amounts.toString(req.amount), + merchant: {}, + merchant_pub: pub, + order_id: "none", + pay_deadline: `/Date(${stampSecNow + 30 * 5})/`, + wire_transfer_deadline: `/Date(${stampSecNow + 60 * 5})/`, + merchant_base_url: "taler://return-to-account", + products: [], + refund_deadline: `/Date(${stampSecNow + 60 * 5})/`, + timestamp: `/Date(${stampSecNow})/`, + wire_method: wireType, + }; + + const contractTermsHash = await ws.cryptoApi.hashString( + canonicalJson(contractTerms), + ); + + const payCoinInfo = await ws.cryptoApi.signDeposit( + contractTerms, + cds, + Amounts.parseOrThrow(contractTerms.amount), + ); + + logger.trace("pci", payCoinInfo); + + const coins = payCoinInfo.sigs.map(s => ({ coinPaySig: s })); + + const coinsReturnRecord: CoinsReturnRecord = { + coins, + contractTerms, + contractTermsHash, + exchange: exchange.baseUrl, + merchantPriv: priv, + wire: req.senderWire, + }; + + await runWithWriteTransaction( + ws.db, + [Stores.coinsReturns, Stores.coins], + async tx => { + await tx.put(Stores.coinsReturns, coinsReturnRecord); + for (let c of payCoinInfo.updatedCoins) { + await tx.put(Stores.coins, c); + } + }, + ); + ws.badge.showNotification(); + ws.notifier.notify(); + + depositReturnedCoins(ws, coinsReturnRecord); +} + +async function depositReturnedCoins( + ws: InternalWalletState, + coinsReturnRecord: CoinsReturnRecord, +): Promise { + for (const c of coinsReturnRecord.coins) { + if (c.depositedSig) { + continue; + } + const req = { + H_wire: coinsReturnRecord.contractTerms.H_wire, + coin_pub: c.coinPaySig.coin_pub, + coin_sig: c.coinPaySig.coin_sig, + contribution: c.coinPaySig.contribution, + denom_pub: c.coinPaySig.denom_pub, + h_contract_terms: coinsReturnRecord.contractTermsHash, + merchant_pub: coinsReturnRecord.contractTerms.merchant_pub, + pay_deadline: coinsReturnRecord.contractTerms.pay_deadline, + refund_deadline: coinsReturnRecord.contractTerms.refund_deadline, + timestamp: coinsReturnRecord.contractTerms.timestamp, + ub_sig: c.coinPaySig.ub_sig, + wire: coinsReturnRecord.wire, + wire_transfer_deadline: coinsReturnRecord.contractTerms.pay_deadline, + }; + logger.trace("req", req); + const reqUrl = new URL("deposit", coinsReturnRecord.exchange); + const resp = await ws.http.postJson(reqUrl.href, req); + if (resp.status !== 200) { + console.error("deposit failed due to status code", resp); + continue; + } + const respJson = resp.responseJson; + if (respJson.status !== "DEPOSIT_OK") { + console.error("deposit failed", resp); + continue; + } + + if (!respJson.sig) { + console.error("invalid 'sig' field", resp); + continue; + } + + // FIXME: verify signature + + // For every successful deposit, we replace the old record with an updated one + const currentCrr = await oneShotGet( + ws.db, + Stores.coinsReturns, + coinsReturnRecord.contractTermsHash, + ); + if (!currentCrr) { + console.error("database inconsistent"); + continue; + } + for (const nc of currentCrr.coins) { + if (nc.coinPaySig.coin_pub === c.coinPaySig.coin_pub) { + nc.depositedSig = respJson.sig; + } + } + await oneShotPut(ws.db, Stores.coinsReturns, currentCrr); + ws.notifier.notify(); + } +} diff --git a/src/wallet-impl/state.ts b/src/wallet-impl/state.ts new file mode 100644 index 000000000..3d6bb8bdf --- /dev/null +++ b/src/wallet-impl/state.ts @@ -0,0 +1,32 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { HttpRequestLibrary } from "../util/http"; +import { Badge, Notifier, NextUrlResult } from "../walletTypes"; +import { SpeculativePayData } from "./pay"; +import { CryptoApi } from "../crypto/cryptoApi"; +import { AsyncOpMemo } from "../util/asyncMemo"; + +export interface InternalWalletState { + db: IDBDatabase; + http: HttpRequestLibrary; + badge: Badge; + notifier: Notifier; + cryptoApi: CryptoApi; + speculativePayData: SpeculativePayData | undefined; + cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult }; + memoProcessReserve: AsyncOpMemo; +} \ No newline at end of file diff --git a/src/wallet-impl/tip.ts b/src/wallet-impl/tip.ts new file mode 100644 index 000000000..b102d026f --- /dev/null +++ b/src/wallet-impl/tip.ts @@ -0,0 +1,246 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + + +import { oneShotGet, oneShotPut, oneShotMutate, runWithWriteTransaction } from "../util/query"; +import { InternalWalletState } from "./state"; +import { parseTipUri } from "../util/taleruri"; +import { TipStatus, getTimestampNow } from "../walletTypes"; +import { TipPickupGetResponse, TipPlanchetDetail, TipResponse } from "../talerTypes"; +import * as Amounts from "../util/amounts"; +import { Stores, PlanchetRecord, WithdrawalSessionRecord } from "../dbTypes"; +import { getWithdrawDetailsForAmount, getVerifiedWithdrawDenomList, processWithdrawSession } from "./withdraw"; +import { getTalerStampSec } from "../util/helpers"; +import { updateExchangeFromUrl } from "./exchanges"; +import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto"; + + +export async function getTipStatus( + ws: InternalWalletState, + talerTipUri: string): Promise { + const res = parseTipUri(talerTipUri); + if (!res) { + throw Error("invalid taler://tip URI"); + } + + const tipStatusUrl = new URL("tip-pickup", res.merchantBaseUrl); + tipStatusUrl.searchParams.set("tip_id", res.merchantTipId); + console.log("checking tip status from", tipStatusUrl.href); + const merchantResp = await ws.http.get(tipStatusUrl.href); + console.log("resp:", merchantResp.responseJson); + const tipPickupStatus = TipPickupGetResponse.checked( + merchantResp.responseJson, + ); + + console.log("status", tipPickupStatus); + + let amount = Amounts.parseOrThrow(tipPickupStatus.amount); + + let tipRecord = await oneShotGet(ws.db, Stores.tips, [ + res.merchantTipId, + res.merchantOrigin, + ]); + + if (!tipRecord) { + const withdrawDetails = await getWithdrawDetailsForAmount( + ws, + tipPickupStatus.exchange_url, + amount, + ); + + const tipId = encodeCrock(getRandomBytes(32)); + + tipRecord = { + tipId, + accepted: false, + amount, + deadline: getTalerStampSec(tipPickupStatus.stamp_expire)!, + exchangeUrl: tipPickupStatus.exchange_url, + merchantBaseUrl: res.merchantBaseUrl, + nextUrl: undefined, + pickedUp: false, + planchets: undefined, + response: undefined, + timestamp: getTimestampNow(), + merchantTipId: res.merchantTipId, + totalFees: Amounts.add( + withdrawDetails.overhead, + withdrawDetails.withdrawFee, + ).amount, + }; + await oneShotPut(ws.db, Stores.tips, tipRecord); + } + + const tipStatus: TipStatus = { + accepted: !!tipRecord && tipRecord.accepted, + amount: Amounts.parseOrThrow(tipPickupStatus.amount), + amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left), + exchangeUrl: tipPickupStatus.exchange_url, + nextUrl: tipPickupStatus.extra.next_url, + merchantOrigin: res.merchantOrigin, + merchantTipId: res.merchantTipId, + expirationTimestamp: getTalerStampSec(tipPickupStatus.stamp_expire)!, + timestamp: getTalerStampSec(tipPickupStatus.stamp_created)!, + totalFees: tipRecord.totalFees, + tipId: tipRecord.tipId, + }; + + return tipStatus; +} + +export async function processTip( + ws: InternalWalletState, + tipId: string, +) { + let tipRecord = await oneShotGet(ws.db, Stores.tips, tipId); + if (!tipRecord) { + return; + } + + if (tipRecord.pickedUp) { + console.log("tip already picked up"); + return; + } + + if (!tipRecord.planchets) { + await updateExchangeFromUrl(ws, tipRecord.exchangeUrl); + const denomsForWithdraw = await getVerifiedWithdrawDenomList( + ws, + tipRecord.exchangeUrl, + tipRecord.amount, + ); + + const planchets = await Promise.all( + denomsForWithdraw.map(d => ws.cryptoApi.createTipPlanchet(d)), + ); + + await oneShotMutate(ws.db, Stores.tips, tipId, r => { + if (!r.planchets) { + r.planchets = planchets; + } + return r; + }); + } + + tipRecord = await oneShotGet(ws.db, Stores.tips, tipId); + if (!tipRecord) { + throw Error("tip not in database"); + } + + if (!tipRecord.planchets) { + throw Error("invariant violated"); + } + + console.log("got planchets for tip!"); + + // Planchets in the form that the merchant expects + const planchetsDetail: TipPlanchetDetail[] = tipRecord.planchets.map(p => ({ + coin_ev: p.coinEv, + denom_pub_hash: p.denomPubHash, + })); + + let merchantResp; + + const tipStatusUrl = new URL("tip-pickup", tipRecord.merchantBaseUrl); + + try { + const req = { planchets: planchetsDetail, tip_id: tipRecord.merchantTipId }; + merchantResp = await ws.http.postJson(tipStatusUrl.href, req); + console.log("got merchant resp:", merchantResp); + } catch (e) { + console.log("tipping failed", e); + throw e; + } + + const response = TipResponse.checked(merchantResp.responseJson); + + if (response.reserve_sigs.length !== tipRecord.planchets.length) { + throw Error("number of tip responses does not match requested planchets"); + } + + const planchets: PlanchetRecord[] = []; + + for (let i = 0; i < tipRecord.planchets.length; i++) { + const tipPlanchet = tipRecord.planchets[i]; + const planchet: PlanchetRecord = { + blindingKey: tipPlanchet.blindingKey, + coinEv: tipPlanchet.coinEv, + coinPriv: tipPlanchet.coinPriv, + coinPub: tipPlanchet.coinPub, + coinValue: tipPlanchet.coinValue, + denomPub: tipPlanchet.denomPub, + denomPubHash: tipPlanchet.denomPubHash, + reservePub: response.reserve_pub, + withdrawSig: response.reserve_sigs[i].reserve_sig, + isFromTip: true, + }; + planchets.push(planchet); + } + + const withdrawalSessionId = encodeCrock(getRandomBytes(32)); + + const withdrawalSession: WithdrawalSessionRecord = { + denoms: planchets.map((x) => x.denomPub), + exchangeBaseUrl: tipRecord.exchangeUrl, + planchets: planchets, + source: { + type: "tip", + tipId: tipRecord.tipId, + }, + startTimestamp: getTimestampNow(), + withdrawSessionId: withdrawalSessionId, + withdrawalAmount: Amounts.toString(tipRecord.amount), + withdrawn: planchets.map((x) => false), + }; + + + await runWithWriteTransaction(ws.db, [Stores.tips, Stores.withdrawalSession], async (tx) => { + const tr = await tx.get(Stores.tips, tipId); + if (!tr) { + return; + } + if (tr.pickedUp) { + return; + } + tr.pickedUp = true; + + await tx.put(Stores.tips, tr); + await tx.put(Stores.withdrawalSession, withdrawalSession); + }); + + await processWithdrawSession(ws, withdrawalSessionId); + + ws.notifier.notify(); + ws.badge.showNotification(); + return; +} + +export async function acceptTip( + ws: InternalWalletState, + tipId: string, +): Promise { + const tipRecord = await oneShotGet(ws.db, Stores.tips, tipId); + if (!tipRecord) { + console.log("tip not found"); + return; + } + + tipRecord.accepted = true; + await oneShotPut(ws.db, Stores.tips, tipRecord); + + await processTip(ws, tipId); + return; +} diff --git a/src/wallet-impl/withdraw.ts b/src/wallet-impl/withdraw.ts new file mode 100644 index 000000000..4e2d80556 --- /dev/null +++ b/src/wallet-impl/withdraw.ts @@ -0,0 +1,577 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { AmountJson } from "../util/amounts"; +import { + DenominationRecord, + Stores, + DenominationStatus, + CoinStatus, + CoinRecord, + PlanchetRecord, +} from "../dbTypes"; +import * as Amounts from "../util/amounts"; +import { + getTimestampNow, + AcceptWithdrawalResponse, + DownloadedWithdrawInfo, + ReserveCreationInfo, + WithdrawDetails, +} from "../walletTypes"; +import { WithdrawOperationStatusResponse } from "../talerTypes"; +import { InternalWalletState } from "./state"; +import { parseWithdrawUri } from "../util/taleruri"; +import { Logger } from "../util/logging"; +import { + oneShotGet, + oneShotPut, + oneShotIterIndex, + oneShotGetIndexed, + runWithWriteTransaction, +} from "../util/query"; +import { + updateExchangeFromUrl, + getExchangePaytoUri, + getExchangeTrust, +} from "./exchanges"; +import { createReserve, processReserveBankStatus } from "./reserves"; +import { WALLET_PROTOCOL_VERSION } from "../wallet"; + +import * as LibtoolVersion from "../util/libtoolVersion"; + +const logger = new Logger("withdraw.ts"); + +function isWithdrawableDenom(d: DenominationRecord) { + const now = getTimestampNow(); + const started = now.t_ms >= d.stampStart.t_ms; + const stillOkay = d.stampExpireWithdraw.t_ms + 60 * 1000 > now.t_ms; + return started && stillOkay; +} + +/** + * Get a list of denominations (with repetitions possible) + * whose total value is as close as possible to the available + * amount, but never larger. + */ +export function getWithdrawDenomList( + amountAvailable: AmountJson, + denoms: DenominationRecord[], +): DenominationRecord[] { + let remaining = Amounts.copy(amountAvailable); + const ds: DenominationRecord[] = []; + + 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 (const d of denoms) { + const cost = Amounts.add(d.value, d.feeWithdraw).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; +} + +/** + * Get information about a withdrawal from + * a taler://withdraw URI. + */ +export async function getWithdrawalInfo( + ws: InternalWalletState, + talerWithdrawUri: string, +): Promise { + const uriResult = parseWithdrawUri(talerWithdrawUri); + if (!uriResult) { + throw Error("can't parse URL"); + } + const resp = await ws.http.get(uriResult.statusUrl); + console.log("resp:", resp.responseJson); + const status = WithdrawOperationStatusResponse.checked(resp.responseJson); + return { + amount: Amounts.parseOrThrow(status.amount), + confirmTransferUrl: status.confirm_transfer_url, + extractedStatusUrl: uriResult.statusUrl, + selectionDone: status.selection_done, + senderWire: status.sender_wire, + suggestedExchange: status.suggested_exchange, + transferDone: status.transfer_done, + wireTypes: status.wire_types, + }; +} + +export async function acceptWithdrawal( + ws: InternalWalletState, + talerWithdrawUri: string, + selectedExchange: string, +): Promise { + const withdrawInfo = await getWithdrawalInfo(ws, talerWithdrawUri); + const exchangeWire = await getExchangePaytoUri( + ws, + selectedExchange, + withdrawInfo.wireTypes, + ); + const reserve = await createReserve(ws, { + amount: withdrawInfo.amount, + bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl, + exchange: selectedExchange, + senderWire: withdrawInfo.senderWire, + exchangeWire: exchangeWire, + }); + // We do this here, as the reserve should be registered before we return, + // so that we can redirect the user to the bank's status page. + await processReserveBankStatus(ws, reserve.reservePub); + console.log("acceptWithdrawal: returning"); + return { + reservePub: reserve.reservePub, + confirmTransferUrl: withdrawInfo.confirmTransferUrl, + }; +} + +async function getPossibleDenoms( + ws: InternalWalletState, + exchangeBaseUrl: string, +): Promise { + return await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + exchangeBaseUrl, + ).filter(d => { + return ( + d.status === DenominationStatus.Unverified || + d.status === DenominationStatus.VerifiedGood + ); + }); +} + +/** + * Given a planchet, withdraw a coin from the exchange. + */ +async function processPlanchet( + ws: InternalWalletState, + withdrawalSessionId: string, + coinIdx: number, +): Promise { + const withdrawalSession = await oneShotGet( + ws.db, + Stores.withdrawalSession, + withdrawalSessionId, + ); + if (!withdrawalSession) { + return; + } + if (withdrawalSession.withdrawn[coinIdx]) { + return; + } + if (withdrawalSession.source.type === "reserve") { + + } + const planchet = withdrawalSession.planchets[coinIdx]; + if (!planchet) { + console.log("processPlanchet: planchet not found"); + return; + } + const exchange = await oneShotGet( + ws.db, + Stores.exchanges, + withdrawalSession.exchangeBaseUrl, + ); + if (!exchange) { + console.error("db inconsistent: exchange for planchet not found"); + return; + } + + const denom = await oneShotGet(ws.db, Stores.denominations, [ + withdrawalSession.exchangeBaseUrl, + planchet.denomPub, + ]); + + if (!denom) { + console.error("db inconsistent: denom for planchet not found"); + return; + } + + const wd: any = {}; + wd.denom_pub_hash = planchet.denomPubHash; + wd.reserve_pub = planchet.reservePub; + wd.reserve_sig = planchet.withdrawSig; + wd.coin_ev = planchet.coinEv; + const reqUrl = new URL("reserve/withdraw", exchange.baseUrl).href; + const resp = await ws.http.postJson(reqUrl, wd); + + const r = resp.responseJson; + + const denomSig = await ws.cryptoApi.rsaUnblind( + r.ev_sig, + planchet.blindingKey, + planchet.denomPub, + ); + + const coin: CoinRecord = { + blindingKey: planchet.blindingKey, + coinPriv: planchet.coinPriv, + coinPub: planchet.coinPub, + currentAmount: planchet.coinValue, + denomPub: planchet.denomPub, + denomPubHash: planchet.denomPubHash, + denomSig, + exchangeBaseUrl: withdrawalSession.exchangeBaseUrl, + reservePub: planchet.reservePub, + status: CoinStatus.Fresh, + coinIndex: coinIdx, + withdrawSessionId: withdrawalSessionId, + }; + + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.withdrawalSession, Stores.reserves], + async tx => { + const ws = await tx.get( + Stores.withdrawalSession, + withdrawalSessionId, + ); + if (!ws) { + return; + } + if (ws.withdrawn[coinIdx]) { + // Already withdrawn + return; + } + ws.withdrawn[coinIdx] = true; + await tx.put(Stores.withdrawalSession, ws); + if (!planchet.isFromTip) { + const r = await tx.get(Stores.reserves, planchet.reservePub); + if (r) { + r.withdrawCompletedAmount = Amounts.add( + r.withdrawCompletedAmount, + Amounts.add(denom.value, denom.feeWithdraw).amount, + ).amount; + await tx.put(Stores.reserves, r); + } + } + await tx.add(Stores.coins, coin); + }, + ); + ws.notifier.notify(); + logger.trace(`withdraw of one coin ${coin.coinPub} finished`); +} + +/** + * Get a list of denominations to withdraw from the given exchange for the + * given amount, making sure that all denominations' signatures are verified. + * + * Writes to the DB in order to record the result from verifying + * denominations. + */ +export async function getVerifiedWithdrawDenomList( + ws: InternalWalletState, + exchangeBaseUrl: string, + amount: AmountJson, +): Promise { + const exchange = await oneShotGet(ws.db, Stores.exchanges, exchangeBaseUrl); + if (!exchange) { + console.log("exchange not found"); + throw Error(`exchange ${exchangeBaseUrl} not found`); + } + const exchangeDetails = exchange.details; + if (!exchangeDetails) { + console.log("exchange details not available"); + throw Error(`exchange ${exchangeBaseUrl} details not available`); + } + + console.log("getting possible denoms"); + + const possibleDenoms = await getPossibleDenoms(ws, exchange.baseUrl); + + console.log("got possible denoms"); + + let allValid = false; + + let selectedDenoms: DenominationRecord[]; + + do { + allValid = true; + const nextPossibleDenoms = []; + selectedDenoms = getWithdrawDenomList(amount, possibleDenoms); + console.log("got withdraw denom list"); + for (const denom of selectedDenoms || []) { + if (denom.status === DenominationStatus.Unverified) { + console.log( + "checking validity", + denom, + exchangeDetails.masterPublicKey, + ); + const valid = await ws.cryptoApi.isValidDenom( + denom, + exchangeDetails.masterPublicKey, + ); + console.log("done checking validity"); + if (!valid) { + denom.status = DenominationStatus.VerifiedBad; + allValid = false; + } else { + denom.status = DenominationStatus.VerifiedGood; + nextPossibleDenoms.push(denom); + } + await oneShotPut(ws.db, Stores.denominations, denom); + } else { + nextPossibleDenoms.push(denom); + } + } + } while (selectedDenoms.length > 0 && !allValid); + + console.log("returning denoms"); + + return selectedDenoms; +} + +async function processWithdrawCoin( + ws: InternalWalletState, + withdrawalSessionId: string, + coinIndex: number, +) { + logger.info("starting withdraw for coin"); + const withdrawalSession = await oneShotGet( + ws.db, + Stores.withdrawalSession, + withdrawalSessionId, + ); + if (!withdrawalSession) { + console.log("ws doesn't exist"); + return; + } + + const coin = await oneShotGetIndexed( + ws.db, + Stores.coins.byWithdrawalWithIdx, + [withdrawalSessionId, coinIndex], + ); + + if (coin) { + console.log("coin already exists"); + return; + } + + if (withdrawalSession.planchets[coinIndex]) { + return processPlanchet(ws, withdrawalSessionId, coinIndex); + } else { + const src = withdrawalSession.source; + if (src.type !== "reserve") { + throw Error("invalid state"); + } + const reserve = await oneShotGet(ws.db, Stores.reserves, src.reservePub) + if (!reserve) { + return; + } + const denom = await oneShotGet(ws.db, Stores.denominations, [ + withdrawalSession.exchangeBaseUrl, + withdrawalSession.denoms[coinIndex], + ]); + if (!denom) { + return; + } + const r = await ws.cryptoApi.createPlanchet({ + denomPub: denom.denomPub, + feeWithdraw: denom.feeWithdraw, + reservePriv: reserve.reservePriv, + reservePub: reserve.reservePub, + value: denom.value, + }); + const newPlanchet: PlanchetRecord = { + blindingKey: r.blindingKey, + coinEv: r.coinEv, + coinPriv: r.coinPriv, + coinPub: r.coinPub, + coinValue: r.coinValue, + denomPub: r.denomPub, + denomPubHash: r.denomPubHash, + isFromTip: false, + reservePub: r.reservePub, + withdrawSig: r.withdrawSig, + }; + await runWithWriteTransaction( + ws.db, + [Stores.withdrawalSession], + async tx => { + const myWs = await tx.get( + Stores.withdrawalSession, + withdrawalSessionId, + ); + if (!myWs) { + return; + } + if (myWs.planchets[coinIndex]) { + return; + } + myWs.planchets[coinIndex] = newPlanchet; + await tx.put(Stores.withdrawalSession, myWs); + }, + ); + await processPlanchet(ws, withdrawalSessionId, coinIndex); + } +} + +export async function processWithdrawSession( + ws: InternalWalletState, + withdrawalSessionId: string, +): Promise { + logger.trace("processing withdraw session", withdrawalSessionId); + const withdrawalSession = await oneShotGet( + ws.db, + Stores.withdrawalSession, + withdrawalSessionId, + ); + if (!withdrawalSession) { + logger.trace("withdraw session doesn't exist"); + return; + } + + const ps = withdrawalSession.denoms.map((d, i) => + processWithdrawCoin(ws, withdrawalSessionId, i), + ); + await Promise.all(ps); + ws.badge.showNotification(); + return; +} + +export async function getWithdrawDetailsForAmount( + ws: InternalWalletState, + baseUrl: string, + amount: AmountJson, +): Promise { + const exchangeInfo = await updateExchangeFromUrl(ws, baseUrl); + const exchangeDetails = exchangeInfo.details; + if (!exchangeDetails) { + throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); + } + const exchangeWireInfo = exchangeInfo.wireInfo; + if (!exchangeWireInfo) { + throw Error(`exchange ${exchangeInfo.baseUrl} wire details not available`); + } + + const selectedDenoms = await getVerifiedWithdrawDenomList( + ws, + baseUrl, + amount, + ); + let acc = Amounts.getZero(amount.currency); + for (const d of selectedDenoms) { + acc = Amounts.add(acc, d.feeWithdraw).amount; + } + const actualCoinCost = selectedDenoms + .map((d: DenominationRecord) => Amounts.add(d.value, d.feeWithdraw).amount) + .reduce((a, b) => Amounts.add(a, b).amount); + + const exchangeWireAccounts: string[] = []; + for (let account of exchangeWireInfo.accounts) { + exchangeWireAccounts.push(account.url); + } + + const { isTrusted, isAudited } = await getExchangeTrust(ws, exchangeInfo); + + let earliestDepositExpiration = selectedDenoms[0].stampExpireDeposit; + for (let i = 1; i < selectedDenoms.length; i++) { + const expireDeposit = selectedDenoms[i].stampExpireDeposit; + if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) { + earliestDepositExpiration = expireDeposit; + } + } + + const possibleDenoms = await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + baseUrl, + ).filter(d => d.isOffered); + + const trustedAuditorPubs = []; + const currencyRecord = await oneShotGet( + ws.db, + Stores.currencies, + amount.currency, + ); + if (currencyRecord) { + trustedAuditorPubs.push(...currencyRecord.auditors.map(a => a.auditorPub)); + } + + let versionMatch; + if (exchangeDetails.protocolVersion) { + versionMatch = LibtoolVersion.compare( + WALLET_PROTOCOL_VERSION, + exchangeDetails.protocolVersion, + ); + + if ( + versionMatch && + !versionMatch.compatible && + versionMatch.currentCmp === -1 + ) { + console.warn( + `wallet version ${WALLET_PROTOCOL_VERSION} might be outdated ` + + `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`, + ); + } + } + + const ret: ReserveCreationInfo = { + earliestDepositExpiration, + exchangeInfo, + exchangeWireAccounts, + exchangeVersion: exchangeDetails.protocolVersion || "unknown", + isAudited, + isTrusted, + numOfferedDenoms: possibleDenoms.length, + overhead: Amounts.sub(amount, actualCoinCost).amount, + selectedDenoms, + trustedAuditorPubs, + versionMatch, + walletVersion: WALLET_PROTOCOL_VERSION, + wireFees: exchangeWireInfo, + withdrawFee: acc, + }; + return ret; +} + +export async function getWithdrawDetailsForUri( + ws: InternalWalletState, + talerWithdrawUri: string, + maybeSelectedExchange?: string, +): Promise { + const info = await getWithdrawalInfo(ws, talerWithdrawUri); + let rci: ReserveCreationInfo | undefined = undefined; + if (maybeSelectedExchange) { + rci = await getWithdrawDetailsForAmount( + ws, + maybeSelectedExchange, + info.amount, + ); + } + return { + withdrawInfo: info, + reserveCreationInfo: rci, + }; +} diff --git a/src/wallet-test.ts b/src/wallet-test.ts index fef11ae5d..cc8532f07 100644 --- a/src/wallet-test.ts +++ b/src/wallet-test.ts @@ -14,7 +14,6 @@ TALER; see the file COPYING. If not, see */ - import test from "ava"; import * as dbTypes from "./dbTypes"; @@ -22,9 +21,9 @@ import * as types from "./walletTypes"; import * as wallet from "./wallet"; -import { AmountJson} from "./amounts"; -import * as Amounts from "./amounts"; - +import { AmountJson } from "./util/amounts"; +import * as Amounts from "./util/amounts"; +import { selectPayCoins } from "./wallet-impl/pay"; function a(x: string): AmountJson { const amt = Amounts.parse(x); @@ -34,8 +33,11 @@ function a(x: string): AmountJson { return amt; } - -function fakeCwd(current: string, value: string, feeDeposit: string): types.CoinWithDenom { +function fakeCwd( + current: string, + value: string, + feeDeposit: string, +): types.CoinWithDenom { return { coin: { blindingKey: "(mock)", @@ -71,14 +73,13 @@ function fakeCwd(current: string, value: string, feeDeposit: string): types.Coin }; } - -test("coin selection 1", (t) => { +test("coin selection 1", t => { const cds: types.CoinWithDenom[] = [ fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.1"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"), ]; - const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.1")); + const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.1")); if (!res) { t.fail(); return; @@ -87,15 +88,14 @@ test("coin selection 1", (t) => { t.pass(); }); - -test("coin selection 2", (t) => { +test("coin selection 2", t => { const cds: types.CoinWithDenom[] = [ fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"), // Merchant covers the fee, this one shouldn't be used fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"), ]; - const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5")); + const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5")); if (!res) { t.fail(); return; @@ -104,15 +104,14 @@ test("coin selection 2", (t) => { t.pass(); }); - -test("coin selection 3", (t) => { +test("coin selection 3", t => { const cds: types.CoinWithDenom[] = [ fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), // this coin should be selected instead of previous one with fee fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"), ]; - const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5")); + const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5")); if (!res) { t.fail(); return; @@ -121,14 +120,13 @@ test("coin selection 3", (t) => { t.pass(); }); - -test("coin selection 4", (t) => { +test("coin selection 4", t => { const cds: types.CoinWithDenom[] = [ fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), ]; - const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2")); + const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2")); if (!res) { t.fail(); return; @@ -137,25 +135,23 @@ test("coin selection 4", (t) => { t.pass(); }); - -test("coin selection 5", (t) => { +test("coin selection 5", t => { const cds: types.CoinWithDenom[] = [ fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), ]; - const res = wallet.selectPayCoins([], cds, a("EUR:4.0"), a("EUR:0.2")); + const res = selectPayCoins([], cds, a("EUR:4.0"), a("EUR:0.2")); t.true(!res); t.pass(); }); - -test("coin selection 6", (t) => { +test("coin selection 6", t => { const cds: types.CoinWithDenom[] = [ fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), ]; - const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2")); + const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2")); t.true(!res); t.pass(); }); diff --git a/src/wallet.ts b/src/wallet.ts index 8fe8d367d..91f6c0cca 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -1,17 +1,17 @@ /* - This file is part of TALER + This file is part of GNU Taler (C) 2015-2019 GNUnet e.V. - TALER is free software; you can redistribute it and/or modify it under the + GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - TALER is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with - TALER; see the file COPYING. If not, see + GNU Taler; see the file COPYING. If not, see */ /** @@ -23,93 +23,59 @@ * Imports. */ import { CryptoApi, CryptoWorkerFactory } from "./crypto/cryptoApi"; +import { HttpRequestLibrary } from "./util/http"; import { - amountToPretty, - canonicalJson, - canonicalizeBaseUrl, - getTalerStampSec, - strcmp, - extractTalerStamp, - extractTalerStampOrThrow, -} from "./helpers"; -import { HttpRequestLibrary } from "./http"; -import * as LibtoolVersion from "./libtoolVersion"; -import { - TransactionAbort, oneShotPut, oneShotGet, runWithWriteTransaction, oneShotIter, oneShotIterIndex, - oneShotGetIndexed, - oneShotMutate, -} from "./query"; +} from "./util/query"; + +import { AmountJson } from "./util/amounts"; +import * as Amounts from "./util/amounts"; -import { AmountJson } from "./amounts"; -import * as Amounts from "./amounts"; +import { + acceptWithdrawal, + getWithdrawalInfo, + getWithdrawDetailsForUri, + getWithdrawDetailsForAmount, +} from "./wallet-impl/withdraw"; -import URI = require("urijs"); +import { + abortFailedPayment, + preparePay, + confirmPay, + SpeculativePayData, +} from "./wallet-impl/pay"; import { CoinRecord, CoinStatus, - CoinsReturnRecord, CurrencyRecord, DenominationRecord, - DenominationStatus, ExchangeRecord, PlanchetRecord, ProposalRecord, PurchaseRecord, - RefreshPlanchetRecord, - RefreshSessionRecord, ReserveRecord, Stores, - TipRecord, - WireFee, - WithdrawalSessionRecord, - ExchangeUpdateStatus, ReserveRecordStatus, - ProposalStatus, } from "./dbTypes"; -import { - Auditor, - ContractTerms, - Denomination, - ExchangeHandle, - ExchangeWireJson, - KeysJson, - MerchantRefundPermission, - MerchantRefundResponse, - PayReq, - PaybackConfirmation, - Proposal, - RefundRequest, - ReserveStatus, - TipPlanchetDetail, - TipResponse, - WithdrawOperationStatusResponse, - TipPickupGetResponse, -} from "./talerTypes"; +import { MerchantRefundPermission } from "./talerTypes"; import { Badge, BenchmarkResult, - CoinSelectionResult, - CoinWithDenom, ConfirmPayResult, ConfirmReserveRequest, CreateReserveRequest, CreateReserveResponse, HistoryEvent, - NextUrlResult, Notifier, - PayCoinInfo, - ReserveCreationInfo, ReturnCoinsRequest, SenderWireInfos, TipStatus, WalletBalance, - WalletBalanceEntry, PreparePayResult, DownloadedWithdrawInfo, WithdrawDetails, @@ -118,26 +84,32 @@ import { PendingOperationInfo, PendingOperationsResponse, HistoryQuery, - getTimestampNow, - OperationError, - Timestamp, } from "./walletTypes"; -import { - parsePayUri, - parseWithdrawUri, - parseTipUri, - parseRefundUri, -} from "./taleruri"; -import { Logger } from "./logging"; -import { randomBytes } from "./crypto/primitives/nacl-fast"; -import { encodeCrock, getRandomBytes } from "./crypto/talerCrypto"; +import { Logger } from "./util/logging"; -interface SpeculativePayData { - payCoinInfo: PayCoinInfo; - exchangeUrl: string; - orderDownloadId: string; - proposal: ProposalRecord; -} +import { assertUnreachable } from "./util/assertUnreachable"; + +import { applyRefund, getFullRefundFees } from "./wallet-impl/refund"; + +import { + updateExchangeFromUrl, + getExchangeTrust, + getExchangePaytoUri, +} from "./wallet-impl/exchanges"; +import { processReserve } from "./wallet-impl/reserves"; + +import { AsyncOpMemo } from "./util/asyncMemo"; + +import { InternalWalletState } from "./wallet-impl/state"; +import { createReserve, confirmReserve } from "./wallet-impl/reserves"; +import { processRefreshSession, refresh } from "./wallet-impl/refresh"; +import { processWithdrawSession } from "./wallet-impl/withdraw"; +import { getHistory } from "./wallet-impl/history"; +import { getPendingOperations } from "./wallet-impl/pending"; +import { getBalances } from "./wallet-impl/balance"; +import { acceptTip, getTipStatus } from "./wallet-impl/tip"; +import { returnCoins } from "./wallet-impl/return"; +import { payback } from "./wallet-impl/payback"; /** * Wallet protocol version spoken with the exchange @@ -147,7 +119,7 @@ interface SpeculativePayData { */ export const WALLET_PROTOCOL_VERSION = "3:0:0"; -const WALLET_CACHE_BREAKER_CLIENT_VERSION = "2"; +export const WALLET_CACHE_BREAKER_CLIENT_VERSION = "2"; const builtinCurrencies: CurrencyRecord[] = [ { @@ -164,186 +136,6 @@ const builtinCurrencies: CurrencyRecord[] = [ }, ]; -function isWithdrawableDenom(d: DenominationRecord) { - const now = getTimestampNow(); - const started = now.t_ms >= d.stampStart.t_ms; - const stillOkay = d.stampExpireWithdraw.t_ms + 60 * 1000 > now.t_ms; - return started && stillOkay; -} - -interface SelectPayCoinsResult { - cds: CoinWithDenom[]; - totalFees: AmountJson; -} - -function assertUnreachable(x: never): never { - throw new Error("Didn't expect to get here"); -} - -/** - * Get the amount that we lose when refreshing a coin of the given denomination - * with a certain amount left. - * - * If the amount left is zero, then the refresh cost - * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of - * the right denominations), then the cost is the full amount left. - * - * Considers refresh fees, withdrawal fees after refresh and amounts too small - * to refresh. - */ -export function getTotalRefreshCost( - denoms: DenominationRecord[], - refreshedDenom: DenominationRecord, - amountLeft: AmountJson, -): AmountJson { - const withdrawAmount = Amounts.sub(amountLeft, refreshedDenom.feeRefresh) - .amount; - const withdrawDenoms = getWithdrawDenomList(withdrawAmount, denoms); - const resultingAmount = Amounts.add( - Amounts.getZero(withdrawAmount.currency), - ...withdrawDenoms.map(d => d.value), - ).amount; - const totalCost = Amounts.sub(amountLeft, resultingAmount).amount; - Wallet.enableTracing && - console.log( - "total refresh cost for", - amountToPretty(amountLeft), - "is", - amountToPretty(totalCost), - ); - return totalCost; -} - -/** - * Select coins for a payment under the merchant's constraints. - * - * @param denoms all available denoms, used to compute refresh fees - */ -export function selectPayCoins( - denoms: DenominationRecord[], - cds: CoinWithDenom[], - paymentAmount: AmountJson, - depositFeeLimit: AmountJson, -): SelectPayCoinsResult | undefined { - if (cds.length === 0) { - return undefined; - } - // Sort by ascending deposit fee and denomPub if deposit fee is the same - // (to guarantee deterministic results) - cds.sort( - (o1, o2) => - Amounts.cmp(o1.denom.feeDeposit, o2.denom.feeDeposit) || - strcmp(o1.denom.denomPub, o2.denom.denomPub), - ); - const currency = cds[0].denom.value.currency; - const cdsResult: CoinWithDenom[] = []; - let accDepositFee: AmountJson = Amounts.getZero(currency); - let accAmount: AmountJson = Amounts.getZero(currency); - for (const { coin, denom } of cds) { - if (coin.suspended) { - continue; - } - if (coin.status !== CoinStatus.Fresh) { - continue; - } - if (Amounts.cmp(denom.feeDeposit, coin.currentAmount) >= 0) { - continue; - } - cdsResult.push({ coin, denom }); - accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount; - let leftAmount = Amounts.sub( - coin.currentAmount, - Amounts.sub(paymentAmount, accAmount).amount, - ).amount; - accAmount = Amounts.add(coin.currentAmount, accAmount).amount; - const coversAmount = Amounts.cmp(accAmount, paymentAmount) >= 0; - const coversAmountWithFee = - Amounts.cmp( - accAmount, - Amounts.add(paymentAmount, denom.feeDeposit).amount, - ) >= 0; - const isBelowFee = Amounts.cmp(accDepositFee, depositFeeLimit) <= 0; - - Wallet.enableTracing && - console.log("candidate coin selection", { - coversAmount, - isBelowFee, - accDepositFee, - accAmount, - paymentAmount, - }); - - if ((coversAmount && isBelowFee) || coversAmountWithFee) { - const depositFeeToCover = Amounts.sub(accDepositFee, depositFeeLimit) - .amount; - leftAmount = Amounts.sub(leftAmount, depositFeeToCover).amount; - Wallet.enableTracing && - console.log("deposit fee to cover", amountToPretty(depositFeeToCover)); - - let totalFees: AmountJson = Amounts.getZero(currency); - if (coversAmountWithFee && !isBelowFee) { - // these are the fees the customer has to pay - // because the merchant doesn't cover them - totalFees = Amounts.sub(depositFeeLimit, accDepositFee).amount; - } - totalFees = Amounts.add( - totalFees, - getTotalRefreshCost(denoms, denom, leftAmount), - ).amount; - return { cds: cdsResult, totalFees }; - } - } - return undefined; -} - -/** - * 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: DenominationRecord[], -): DenominationRecord[] { - let remaining = Amounts.copy(amountAvailable); - const ds: DenominationRecord[] = []; - - 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 (const d of denoms) { - const cost = Amounts.add(d.value, d.feeWithdraw).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; -} - -interface CoinsForPaymentArgs { - allowedAuditors: Auditor[]; - allowedExchanges: ExchangeHandle[]; - depositFeeLimit: AmountJson; - paymentAmount: AmountJson; - wireFeeAmortization: number; - wireFeeLimit: AmountJson; - wireFeeTime: Timestamp; - wireMethod: string; -} - /** * This error is thrown when an */ @@ -358,60 +150,27 @@ export class OperationFailedAndReportedError extends Error { const logger = new Logger("wallet.ts"); -interface MemoEntry { - p: Promise; - t: number; - n: number; -} - -class AsyncOpMemo { - n = 0; - memo: { [k: string]: MemoEntry } = {}; - put(key: string, p: Promise): Promise { - const n = this.n++; - this.memo[key] = { - p, - n, - t: new Date().getTime(), - }; - p.finally(() => { - const r = this.memo[key]; - if (r && r.n === n) { - delete this.memo[key]; - } - }); - return p; - } - find(key: string): Promise | undefined { - const res = this.memo[key]; - const tNow = new Date().getTime(); - if (res && res.t < tNow - 10 * 1000) { - delete this.memo[key]; - return; - } else if (res) { - return res.p; - } - return; - } -} - /** * The platform-independent wallet implementation. */ export class Wallet { - /** - * IndexedDB database used by the wallet. - */ - db: IDBDatabase; - static enableTracing = false; - private http: HttpRequestLibrary; - private badge: Badge; - private notifier: Notifier; - private cryptoApi: CryptoApi; - private speculativePayData: SpeculativePayData | undefined; - private cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {}; + private ws: InternalWalletState; + + get db(): IDBDatabase { + return this.ws.db; + } + + private get badge(): Badge { + return this.ws.badge; + } + + private get cryptoApi(): CryptoApi { + return this.ws.cryptoApi; + } - private memoProcessReserve = new AsyncOpMemo(); + private get notifier(): Notifier { + return this.ws.notifier; + } constructor( db: IDBDatabase, @@ -420,11 +179,25 @@ export class Wallet { notifier: Notifier, cryptoWorkerFactory: CryptoWorkerFactory, ) { - this.db = db; - this.http = http; - this.badge = badge; - this.notifier = notifier; - this.cryptoApi = new CryptoApi(cryptoWorkerFactory); + this.ws = { + badge, + cachedNextUrl: {}, + cryptoApi: new CryptoApi(cryptoWorkerFactory), + db, + http, + notifier, + speculativePayData: undefined, + memoProcessReserve: new AsyncOpMemo(), + }; + } + + getExchangePaytoUri(exchangeBaseUrl: string, supportedTargetTypes: string[]) { + return getExchangePaytoUri(this.ws, exchangeBaseUrl, supportedTargetTypes); + } + + + getWithdrawDetailsForAmount(baseUrl: any, amount: AmountJson): any { + return getWithdrawDetailsForAmount(this.ws, baseUrl, amount); } /** @@ -443,7 +216,7 @@ export class Wallet { await this.updateExchangeFromUrl(pending.exchangeBaseUrl); break; case "planchet": - await this.processPlanchet(pending.coinPub); + // Nothing to do, since the withdraw session will process the planchet break; case "refresh": await this.processRefreshSession(pending.refreshSessionId); @@ -535,272 +308,6 @@ export class Wallet { ); } - private async getCoinsForReturn( - exchangeBaseUrl: string, - amount: AmountJson, - ): Promise { - const exchange = await oneShotGet( - this.db, - Stores.exchanges, - exchangeBaseUrl, - ); - if (!exchange) { - throw Error(`Exchange ${exchangeBaseUrl} not known to the wallet`); - } - - const coins: CoinRecord[] = await oneShotIterIndex( - this.db, - Stores.coins.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - if (!coins || !coins.length) { - return []; - } - - const denoms = await oneShotIterIndex( - this.db, - Stores.denominations.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - // Denomination of the first coin, we assume that all other - // coins have the same currency - const firstDenom = await oneShotGet(this.db, Stores.denominations, [ - exchange.baseUrl, - coins[0].denomPub, - ]); - if (!firstDenom) { - throw Error("db inconsistent"); - } - const currency = firstDenom.value.currency; - - const cds: CoinWithDenom[] = []; - for (const coin of coins) { - const denom = await oneShotGet(this.db, Stores.denominations, [ - exchange.baseUrl, - coin.denomPub, - ]); - if (!denom) { - throw Error("db inconsistent"); - } - if (denom.value.currency !== currency) { - console.warn( - `same pubkey for different currencies at exchange ${exchange.baseUrl}`, - ); - continue; - } - if (coin.suspended) { - continue; - } - if (coin.status !== CoinStatus.Fresh) { - continue; - } - cds.push({ coin, denom }); - } - - const res = selectPayCoins(denoms, cds, amount, amount); - if (res) { - return res.cds; - } - return undefined; - } - - /** - * Get exchanges and associated coins that are still spendable, but only - * if the sum the coins' remaining value covers the payment amount and fees. - */ - private async getCoinsForPayment( - args: CoinsForPaymentArgs, - ): Promise { - const { - allowedAuditors, - allowedExchanges, - depositFeeLimit, - paymentAmount, - wireFeeAmortization, - wireFeeLimit, - wireFeeTime, - wireMethod, - } = args; - - let remainingAmount = paymentAmount; - - const exchanges = await oneShotIter(this.db, Stores.exchanges).toArray(); - - for (const exchange of exchanges) { - let isOkay: boolean = false; - const exchangeDetails = exchange.details; - if (!exchangeDetails) { - continue; - } - const exchangeFees = exchange.wireInfo; - if (!exchangeFees) { - continue; - } - - // is the exchange explicitly allowed? - for (const allowedExchange of allowedExchanges) { - if (allowedExchange.master_pub === exchangeDetails.masterPublicKey) { - isOkay = true; - break; - } - } - - // is the exchange allowed because of one of its auditors? - if (!isOkay) { - for (const allowedAuditor of allowedAuditors) { - for (const auditor of exchangeDetails.auditors) { - if (auditor.auditor_pub === allowedAuditor.auditor_pub) { - isOkay = true; - break; - } - } - if (isOkay) { - break; - } - } - } - - if (!isOkay) { - continue; - } - - const coins = await oneShotIterIndex( - this.db, - Stores.coins.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - const denoms = await oneShotIterIndex( - this.db, - Stores.denominations.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - if (!coins || coins.length === 0) { - continue; - } - - // Denomination of the first coin, we assume that all other - // coins have the same currency - const firstDenom = await oneShotGet(this.db, Stores.denominations, [ - exchange.baseUrl, - coins[0].denomPub, - ]); - if (!firstDenom) { - throw Error("db inconsistent"); - } - const currency = firstDenom.value.currency; - const cds: CoinWithDenom[] = []; - for (const coin of coins) { - const denom = await oneShotGet(this.db, Stores.denominations, [ - exchange.baseUrl, - coin.denomPub, - ]); - if (!denom) { - throw Error("db inconsistent"); - } - if (denom.value.currency !== currency) { - console.warn( - `same pubkey for different currencies at exchange ${exchange.baseUrl}`, - ); - continue; - } - if (coin.suspended) { - continue; - } - if (coin.status !== CoinStatus.Fresh) { - continue; - } - cds.push({ coin, denom }); - } - - let totalFees = Amounts.getZero(currency); - let wireFee: AmountJson | undefined; - for (const fee of exchangeFees.feesForType[wireMethod] || []) { - if (fee.startStamp <= wireFeeTime && fee.endStamp >= wireFeeTime) { - wireFee = fee.wireFee; - break; - } - } - - if (wireFee) { - const amortizedWireFee = Amounts.divide(wireFee, wireFeeAmortization); - if (Amounts.cmp(wireFeeLimit, amortizedWireFee) < 0) { - totalFees = Amounts.add(amortizedWireFee, totalFees).amount; - remainingAmount = Amounts.add(amortizedWireFee, remainingAmount) - .amount; - } - } - - const res = selectPayCoins(denoms, cds, remainingAmount, depositFeeLimit); - - if (res) { - totalFees = Amounts.add(totalFees, res.totalFees).amount; - return { - cds: res.cds, - exchangeUrl: exchange.baseUrl, - totalAmount: remainingAmount, - totalFees, - }; - } - } - return undefined; - } - - /** - * Record all information that is necessary to - * pay for a proposal in the wallet's database. - */ - private async recordConfirmPay( - proposal: ProposalRecord, - payCoinInfo: PayCoinInfo, - chosenExchange: string, - ): Promise { - const payReq: PayReq = { - coins: payCoinInfo.sigs, - merchant_pub: proposal.contractTerms.merchant_pub, - mode: "pay", - order_id: proposal.contractTerms.order_id, - }; - const t: PurchaseRecord = { - abortDone: false, - abortRequested: false, - contractTerms: proposal.contractTerms, - contractTermsHash: proposal.contractTermsHash, - finished: false, - lastSessionId: undefined, - merchantSig: proposal.merchantSig, - payReq, - refundsDone: {}, - refundsPending: {}, - timestamp: getTimestampNow(), - timestamp_refund: undefined, - }; - - await runWithWriteTransaction( - this.db, - [Stores.coins, Stores.purchases], - async tx => { - await tx.put(Stores.purchases, t); - for (let c of payCoinInfo.updatedCoins) { - await tx.put(Stores.coins, c); - } - }, - ); - - this.badge.showNotification(); - this.notifier.notify(); - return t; - } - - getNextUrl(contractTerms: ContractTerms): string { - const fu = new URI(contractTerms.fulfillment_url); - fu.addSearch("order_id", contractTerms.order_id); - return fu.href(); - } - /** * Check if a payment for the given taler://pay/ URI is possible. * @@ -808,305 +315,7 @@ export class Wallet { * yet send to the merchant. */ async preparePay(talerPayUri: string): Promise { - const uriResult = parsePayUri(talerPayUri); - - if (!uriResult) { - return { - status: "error", - error: "URI not supported", - }; - } - - let proposalId: string; - try { - proposalId = await this.downloadProposal( - uriResult.downloadUrl, - uriResult.sessionId, - ); - } catch (e) { - return { - status: "error", - error: e.toString(), - }; - } - const proposal = await this.getProposal(proposalId); - if (!proposal) { - throw Error(`could not get proposal ${proposalId}`); - } - - console.log("proposal", proposal); - - const differentPurchase = await oneShotGetIndexed( - this.db, - Stores.purchases.fulfillmentUrlIndex, - proposal.contractTerms.fulfillment_url, - ); - - let fulfillmentUrl = proposal.contractTerms.fulfillment_url; - let doublePurchaseDetection = false; - if (fulfillmentUrl.startsWith("http")) { - doublePurchaseDetection = true; - } - - if (differentPurchase && doublePurchaseDetection) { - // We do this check to prevent merchant B to find out if we bought a - // digital product with merchant A by abusing the existing payment - // redirect feature. - if ( - differentPurchase.contractTerms.merchant_pub != - proposal.contractTerms.merchant_pub - ) { - console.warn( - "merchant with different public key offered contract with same fulfillment URL as an existing purchase", - ); - } else { - if (uriResult.sessionId) { - await this.submitPay( - differentPurchase.contractTermsHash, - uriResult.sessionId, - ); - } - return { - status: "paid", - contractTerms: differentPurchase.contractTerms, - nextUrl: this.getNextUrl(differentPurchase.contractTerms), - }; - } - } - - // First check if we already payed for it. - const purchase = await oneShotGet( - this.db, - Stores.purchases, - proposal.contractTermsHash, - ); - - if (!purchase) { - const paymentAmount = Amounts.parseOrThrow(proposal.contractTerms.amount); - let wireFeeLimit; - if (proposal.contractTerms.max_wire_fee) { - wireFeeLimit = Amounts.parseOrThrow( - proposal.contractTerms.max_wire_fee, - ); - } else { - wireFeeLimit = Amounts.getZero(paymentAmount.currency); - } - // If not already payed, check if we could pay for it. - const res = await this.getCoinsForPayment({ - allowedAuditors: proposal.contractTerms.auditors, - allowedExchanges: proposal.contractTerms.exchanges, - depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee), - paymentAmount, - wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, - wireFeeLimit, - // FIXME: parse this properly - wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { - t_ms: 0, - }, - wireMethod: proposal.contractTerms.wire_method, - }); - - if (!res) { - console.log("not confirming payment, insufficient coins"); - return { - status: "insufficient-balance", - contractTerms: proposal.contractTerms, - proposalId: proposal.proposalId, - }; - } - - // Only create speculative signature if we don't already have one for this proposal - if ( - !this.speculativePayData || - (this.speculativePayData && - this.speculativePayData.orderDownloadId !== proposalId) - ) { - const { exchangeUrl, cds, totalAmount } = res; - const payCoinInfo = await this.cryptoApi.signDeposit( - proposal.contractTerms, - cds, - totalAmount, - ); - this.speculativePayData = { - exchangeUrl, - payCoinInfo, - proposal, - orderDownloadId: proposalId, - }; - Wallet.enableTracing && - console.log("created speculative pay data for payment"); - } - - return { - status: "payment-possible", - contractTerms: proposal.contractTerms, - proposalId: proposal.proposalId, - totalFees: res.totalFees, - }; - } - - if (uriResult.sessionId) { - await this.submitPay(purchase.contractTermsHash, uriResult.sessionId); - } - - return { - status: "paid", - contractTerms: proposal.contractTerms, - nextUrl: this.getNextUrl(purchase.contractTerms), - }; - } - - /** - * Download a proposal and store it in the database. - * Returns an id for it to retrieve it later. - * - * @param sessionId Current session ID, if the proposal is being - * downloaded in the context of a session ID. - */ - async downloadProposal(url: string, sessionId?: string): Promise { - const oldProposal = await oneShotGetIndexed( - this.db, - Stores.proposals.urlIndex, - url, - ); - if (oldProposal) { - return oldProposal.proposalId; - } - - const { priv, pub } = await this.cryptoApi.createEddsaKeypair(); - const parsed_url = new URI(url); - const urlWithNonce = parsed_url.setQuery({ nonce: pub }).href(); - console.log("downloading contract from '" + urlWithNonce + "'"); - let resp; - try { - resp = await this.http.get(urlWithNonce); - } catch (e) { - console.log("contract download failed", e); - throw e; - } - - const proposal = Proposal.checked(resp.responseJson); - - const contractTermsHash = await this.hashContract(proposal.contract_terms); - - const proposalId = encodeCrock(getRandomBytes(32)); - - const proposalRecord: ProposalRecord = { - contractTerms: proposal.contract_terms, - contractTermsHash, - merchantSig: proposal.sig, - noncePriv: priv, - timestamp: getTimestampNow(), - url, - downloadSessionId: sessionId, - proposalId: proposalId, - proposalStatus: ProposalStatus.PROPOSED, - }; - await oneShotPut(this.db, Stores.proposals, proposalRecord); - this.notifier.notify(); - - return proposalId; - } - - async refundFailedPay(proposalId: number) { - console.log(`refunding failed payment with proposal id ${proposalId}`); - const proposal = await oneShotGet(this.db, Stores.proposals, proposalId); - if (!proposal) { - throw Error(`proposal with id ${proposalId} not found`); - } - - const purchase = await oneShotGet( - this.db, - Stores.purchases, - proposal.contractTermsHash, - ); - - if (!purchase) { - throw Error("purchase not found for proposal"); - } - - if (purchase.finished) { - throw Error("can't auto-refund finished purchase"); - } - } - - async submitPay( - contractTermsHash: string, - sessionId: string | undefined, - ): Promise { - const purchase = await oneShotGet( - this.db, - Stores.purchases, - contractTermsHash, - ); - if (!purchase) { - throw Error("Purchase not found: " + contractTermsHash); - } - if (purchase.abortRequested) { - throw Error("not submitting payment for aborted purchase"); - } - let resp; - const payReq = { ...purchase.payReq, session_id: sessionId }; - - const payUrl = new URI("pay") - .absoluteTo(purchase.contractTerms.merchant_base_url) - .href(); - - try { - resp = await this.http.postJson(payUrl, payReq); - } catch (e) { - // Gives the user the option to retry / abort and refresh - console.log("payment failed", e); - throw e; - } - const merchantResp = resp.responseJson; - console.log("got success from pay URL"); - - const merchantPub = purchase.contractTerms.merchant_pub; - const valid: boolean = await this.cryptoApi.isValidPaymentSignature( - merchantResp.sig, - contractTermsHash, - merchantPub, - ); - if (!valid) { - console.error("merchant payment signature invalid"); - // FIXME: properly display error - throw Error("merchant payment signature invalid"); - } - purchase.finished = true; - const modifiedCoins: CoinRecord[] = []; - for (const pc of purchase.payReq.coins) { - const c = await oneShotGet(this.db, Stores.coins, pc.coin_pub); - if (!c) { - console.error("coin not found"); - throw Error("coin used in payment not found"); - } - c.status = CoinStatus.Dirty; - modifiedCoins.push(c); - } - - await runWithWriteTransaction( - this.db, - [Stores.coins, Stores.purchases], - async tx => { - for (let c of modifiedCoins) { - tx.put(Stores.coins, c); - } - tx.put(Stores.purchases, purchase); - }, - ); - - for (const c of purchase.payReq.coins) { - this.refresh(c.coin_pub); - } - - const nextUrl = this.getNextUrl(purchase.contractTerms); - this.cachedNextUrl[purchase.contractTerms.fulfillment_url] = { - nextUrl, - lastSessionId: sessionId, - }; - - return { nextUrl }; + return preparePay(this.ws, talerPayUri); } /** @@ -1138,218 +347,8 @@ export class Wallet { proposalId: string, sessionIdOverride: string | undefined, ): Promise { - Wallet.enableTracing && - console.log( - `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, - ); - const proposal = await oneShotGet(this.db, Stores.proposals, proposalId); - - if (!proposal) { - throw Error(`proposal with id ${proposalId} not found`); - } - - const sessionId = sessionIdOverride || proposal.downloadSessionId; - - let purchase = await oneShotGet( - this.db, - Stores.purchases, - proposal.contractTermsHash, - ); - - if (purchase) { - return this.submitPay(purchase.contractTermsHash, sessionId); - } - - const contractAmount = Amounts.parseOrThrow(proposal.contractTerms.amount); - - let wireFeeLimit; - if (!proposal.contractTerms.max_wire_fee) { - wireFeeLimit = Amounts.getZero(contractAmount.currency); - } else { - wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee); - } - - const res = await this.getCoinsForPayment({ - allowedAuditors: proposal.contractTerms.auditors, - allowedExchanges: proposal.contractTerms.exchanges, - depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee), - paymentAmount: Amounts.parseOrThrow(proposal.contractTerms.amount), - wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, - wireFeeLimit, - // FIXME: parse this properly - wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { - t_ms: 0, - }, - wireMethod: proposal.contractTerms.wire_method, - }); - - Wallet.enableTracing && console.log("coin selection result", res); - - if (!res) { - // Should not happen, since checkPay should be called first - console.log("not confirming payment, insufficient coins"); - throw Error("insufficient balance"); - } - - const sd = await this.getSpeculativePayData(proposalId); - if (!sd) { - const { exchangeUrl, cds, totalAmount } = res; - const payCoinInfo = await this.cryptoApi.signDeposit( - proposal.contractTerms, - cds, - totalAmount, - ); - purchase = await this.recordConfirmPay( - proposal, - payCoinInfo, - exchangeUrl, - ); - } else { - purchase = await this.recordConfirmPay( - sd.proposal, - sd.payCoinInfo, - sd.exchangeUrl, - ); - } - - return this.submitPay(purchase.contractTermsHash, sessionId); - } - - /** - * Get the speculative pay data, but only if coins have not changed in between. - */ - async getSpeculativePayData( - proposalId: string, - ): Promise { - const sp = this.speculativePayData; - if (!sp) { - return; - } - if (sp.orderDownloadId !== proposalId) { - return; - } - const coinKeys = sp.payCoinInfo.updatedCoins.map(x => x.coinPub); - const coins: CoinRecord[] = []; - for (let coinKey of coinKeys) { - const cc = await oneShotGet(this.db, Stores.coins, coinKey); - if (cc) { - coins.push(cc); - } - } - for (let i = 0; i < coins.length; i++) { - const specCoin = sp.payCoinInfo.originalCoins[i]; - const currentCoin = coins[i]; - - // Coin does not exist anymore! - if (!currentCoin) { - return; - } - if ( - Amounts.cmp(specCoin.currentAmount, currentCoin.currentAmount) !== 0 - ) { - return; - } - } - return sp; - } - - private async processReserveBankStatus(reservePub: string): Promise { - let reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - switch (reserve?.reserveStatus) { - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - case ReserveRecordStatus.REGISTERING_BANK: - break; - default: - return; - } - const bankStatusUrl = reserve.bankWithdrawStatusUrl; - if (!bankStatusUrl) { - return; - } - - let status: WithdrawOperationStatusResponse; - try { - const statusResp = await this.http.get(bankStatusUrl); - status = WithdrawOperationStatusResponse.checked(statusResp.responseJson); - } catch (e) { - throw e; - } - - if (status.selection_done) { - if (reserve.reserveStatus === ReserveRecordStatus.REGISTERING_BANK) { - await this.registerReserveWithBank(reservePub); - return await this.processReserveBankStatus(reservePub); - } - } else { - await this.registerReserveWithBank(reservePub); - return await this.processReserveBankStatus(reservePub); - } - - if (status.transfer_done) { - await oneShotMutate(this.db, Stores.reserves, reservePub, r => { - switch (r.reserveStatus) { - case ReserveRecordStatus.REGISTERING_BANK: - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - break; - default: - return; - } - const now = getTimestampNow(); - r.timestampConfirmed = now; - r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; - return r; - }); - await this.processReserveImpl(reservePub); - } else { - await oneShotMutate(this.db, Stores.reserves, reservePub, r => { - switch (r.reserveStatus) { - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - break; - default: - return; - } - r.bankWithdrawConfirmUrl = status.confirm_transfer_url; - return r; - }); - } - } - - async registerReserveWithBank(reservePub: string) { - let reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - switch (reserve?.reserveStatus) { - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - case ReserveRecordStatus.REGISTERING_BANK: - break; - default: - return; - } - const bankStatusUrl = reserve.bankWithdrawStatusUrl; - if (!bankStatusUrl) { - return; - } - console.log("making selection"); - if (reserve.timestampReserveInfoPosted) { - throw Error("bank claims that reserve info selection is not done"); - } - const bankResp = await this.http.postJson(bankStatusUrl, { - reserve_pub: reservePub, - selected_exchange: reserve.exchangeWire, - }); - console.log("got response", bankResp); - await oneShotMutate(this.db, Stores.reserves, reservePub, r => { - switch (r.reserveStatus) { - case ReserveRecordStatus.REGISTERING_BANK: - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - break; - default: - return; - } - r.timestampReserveInfoPosted = getTimestampNow(); - r.reserveStatus = ReserveRecordStatus.WAIT_CONFIRM_BANK; - return r; - }); - return this.processReserveBankStatus(reservePub); - } + return confirmPay(this.ws, proposalId, sessionIdOverride); + } /** * First fetch information requred to withdraw from the reserve, @@ -1359,149 +358,7 @@ export class Wallet { * state DORMANT. */ async processReserve(reservePub: string): Promise { - const p = this.memoProcessReserve.find(reservePub); - if (p) { - return p; - } else { - return this.memoProcessReserve.put( - reservePub, - this.processReserveImpl(reservePub), - ); - } - } - - private async processReserveImpl(reservePub: string): Promise { - const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - if (!reserve) { - console.log("not processing reserve: reserve does not exist"); - return; - } - logger.trace( - `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`, - ); - switch (reserve.reserveStatus) { - case ReserveRecordStatus.UNCONFIRMED: - // nothing to do - break; - case ReserveRecordStatus.REGISTERING_BANK: - await this.processReserveBankStatus(reservePub); - return this.processReserveImpl(reservePub); - case ReserveRecordStatus.QUERYING_STATUS: - await this.updateReserve(reservePub); - return this.processReserveImpl(reservePub); - case ReserveRecordStatus.WITHDRAWING: - await this.depleteReserve(reservePub); - break; - case ReserveRecordStatus.DORMANT: - // nothing to do - break; - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - await this.processReserveBankStatus(reservePub); - break; - default: - console.warn("unknown reserve record status:", reserve.reserveStatus); - assertUnreachable(reserve.reserveStatus); - break; - } - } - - /** - * Given a planchet, withdraw a coin from the exchange. - */ - private async processPlanchet(coinPub: string): Promise { - logger.trace("process planchet", coinPub); - const planchet = await oneShotGet(this.db, Stores.planchets, coinPub); - if (!planchet) { - console.log("processPlanchet: planchet not found"); - return; - } - const exchange = await oneShotGet( - this.db, - Stores.exchanges, - planchet.exchangeBaseUrl, - ); - if (!exchange) { - console.error("db inconsistent: exchange for planchet not found"); - return; - } - - const denom = await oneShotGet(this.db, Stores.denominations, [ - planchet.exchangeBaseUrl, - planchet.denomPub, - ]); - - if (!denom) { - console.error("db inconsistent: denom for planchet not found"); - return; - } - - const wd: any = {}; - wd.denom_pub_hash = planchet.denomPubHash; - wd.reserve_pub = planchet.reservePub; - wd.reserve_sig = planchet.withdrawSig; - wd.coin_ev = planchet.coinEv; - const reqUrl = new URI("reserve/withdraw").absoluteTo(exchange.baseUrl); - const resp = await this.http.postJson(reqUrl.href(), wd); - - const r = resp.responseJson; - - const denomSig = await this.cryptoApi.rsaUnblind( - r.ev_sig, - planchet.blindingKey, - planchet.denomPub, - ); - - const coin: CoinRecord = { - blindingKey: planchet.blindingKey, - coinPriv: planchet.coinPriv, - coinPub: planchet.coinPub, - currentAmount: planchet.coinValue, - denomPub: planchet.denomPub, - denomPubHash: planchet.denomPubHash, - denomSig, - exchangeBaseUrl: planchet.exchangeBaseUrl, - reservePub: planchet.reservePub, - status: CoinStatus.Fresh, - coinIndex: planchet.coinIndex, - withdrawSessionId: planchet.withdrawSessionId, - }; - - await runWithWriteTransaction( - this.db, - [Stores.planchets, Stores.coins, Stores.withdrawalSession, Stores.reserves], - async tx => { - const currentPc = await tx.get(Stores.planchets, coin.coinPub); - if (!currentPc) { - return; - } - const ws = await tx.get( - Stores.withdrawalSession, - planchet.withdrawSessionId, - ); - if (!ws) { - return; - } - if (ws.withdrawn[planchet.coinIndex]) { - // Already withdrawn - return; - } - ws.withdrawn[planchet.coinIndex] = true; - await tx.put(Stores.withdrawalSession, ws); - const r = await tx.get(Stores.reserves, planchet.reservePub); - if (!r) { - return; - } - r.withdrawCompletedAmount = Amounts.add( - r.withdrawCompletedAmount, - Amounts.add(denom.value, denom.feeWithdraw).amount, - ).amount; - tx.put(Stores.reserves, r); - await tx.delete(Stores.planchets, coin.coinPub); - await tx.add(Stores.coins, coin); - }, - ); - this.notifier.notify(); - logger.trace(`withdraw of one coin ${coin.coinPub} finished`); + return processReserve(this.ws, reservePub); } /** @@ -1513,119 +370,7 @@ export class Wallet { async createReserve( req: CreateReserveRequest, ): Promise { - const keypair = await this.cryptoApi.createEddsaKeypair(); - const now = getTimestampNow(); - const canonExchange = canonicalizeBaseUrl(req.exchange); - - let reserveStatus; - if (req.bankWithdrawStatusUrl) { - reserveStatus = ReserveRecordStatus.REGISTERING_BANK; - } else { - reserveStatus = ReserveRecordStatus.UNCONFIRMED; - } - - const currency = req.amount.currency; - - const reserveRecord: ReserveRecord = { - created: now, - withdrawAllocatedAmount: Amounts.getZero(currency), - withdrawCompletedAmount: Amounts.getZero(currency), - withdrawRemainingAmount: Amounts.getZero(currency), - exchangeBaseUrl: canonExchange, - hasPayback: false, - initiallyRequestedAmount: req.amount, - reservePriv: keypair.priv, - reservePub: keypair.pub, - senderWire: req.senderWire, - timestampConfirmed: undefined, - timestampReserveInfoPosted: undefined, - bankWithdrawStatusUrl: req.bankWithdrawStatusUrl, - exchangeWire: req.exchangeWire, - reserveStatus, - lastStatusQuery: undefined, - }; - - const senderWire = req.senderWire; - if (senderWire) { - const rec = { - paytoUri: senderWire, - }; - await oneShotPut(this.db, Stores.senderWires, rec); - } - - const exchangeInfo = await this.updateExchangeFromUrl(req.exchange); - const exchangeDetails = exchangeInfo.details; - if (!exchangeDetails) { - throw Error("exchange not updated"); - } - const { isAudited, isTrusted } = await this.getExchangeTrust(exchangeInfo); - let currencyRecord = await oneShotGet( - this.db, - Stores.currencies, - exchangeDetails.currency, - ); - if (!currencyRecord) { - currencyRecord = { - auditors: [], - exchanges: [], - fractionalDigits: 2, - name: exchangeDetails.currency, - }; - } - - if (!isAudited && !isTrusted) { - currencyRecord.exchanges.push({ - baseUrl: req.exchange, - exchangePub: exchangeDetails.masterPublicKey, - }); - } - - const cr: CurrencyRecord = currencyRecord; - - const resp = await runWithWriteTransaction( - this.db, - [Stores.currencies, Stores.reserves, Stores.bankWithdrawUris], - async tx => { - // Check if we have already created a reserve for that bankWithdrawStatusUrl - if (reserveRecord.bankWithdrawStatusUrl) { - const bwi = await tx.get( - Stores.bankWithdrawUris, - reserveRecord.bankWithdrawStatusUrl, - ); - if (bwi) { - const otherReserve = await tx.get(Stores.reserves, bwi.reservePub); - if (otherReserve) { - logger.trace( - "returning existing reserve for bankWithdrawStatusUri", - ); - return { - exchange: otherReserve.exchangeBaseUrl, - reservePub: otherReserve.reservePub, - }; - } - } - await tx.put(Stores.bankWithdrawUris, { - reservePub: reserveRecord.reservePub, - talerWithdrawUri: reserveRecord.bankWithdrawStatusUrl, - }); - } - await tx.put(Stores.currencies, cr); - await tx.put(Stores.reserves, reserveRecord); - const r: CreateReserveResponse = { - exchange: canonExchange, - reservePub: keypair.pub, - }; - return r; - }, - ); - - // Asynchronously process the reserve, but return - // to the caller already. - this.processReserve(resp.reservePub).catch(e => { - console.error("Processing reserve failed:", e); - }); - - return resp; + return createReserve(this.ws, req); } /** @@ -1638,1342 +383,60 @@ export class Wallet { * an unconfirmed reserve should be hidden. */ async confirmReserve(req: ConfirmReserveRequest): Promise { - const now = getTimestampNow(); - await oneShotMutate(this.db, Stores.reserves, req.reservePub, reserve => { - if (reserve.reserveStatus !== ReserveRecordStatus.UNCONFIRMED) { - return; - } - reserve.timestampConfirmed = now; - reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; - return reserve; - }); - - this.notifier.notify(); + return confirmReserve(this.ws, req); + } - this.processReserve(req.reservePub).catch(e => { - console.log("processing reserve failed:", e); - }); + private async processWithdrawSession( + withdrawalSessionId: string, + ): Promise { + return processWithdrawSession(this.ws, withdrawalSessionId); } /** - * Withdraw coins from a reserve until it is empty. - * - * When finished, marks the reserve as depleted by setting - * the depleted timestamp. + * Check if and how an exchange is trusted and/or audited. */ - private async depleteReserve(reservePub: string): Promise { - const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - if (!reserve) { - return; - } - if (reserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { - return; - } - logger.trace(`depleting reserve ${reservePub}`); - - const withdrawAmount = reserve.withdrawRemainingAmount; - - logger.trace(`getting denom list`); - - const denomsForWithdraw = await this.getVerifiedWithdrawDenomList( - reserve.exchangeBaseUrl, - withdrawAmount, - ); - logger.trace(`got denom list`); - if (denomsForWithdraw.length === 0) { - const m = `Unable to withdraw from reserve, no denominations are available to withdraw.`; - await this.setReserveError(reserve.reservePub, { - type: "internal", - message: m, - details: {}, - }); - console.log(m); - throw new OperationFailedAndReportedError(m); - } - - logger.trace("selected denominations"); - - const withdrawalSessionId = encodeCrock(randomBytes(32)); - - const withdrawalRecord: WithdrawalSessionRecord = { - withdrawSessionId: withdrawalSessionId, - reservePub: reserve.reservePub, - withdrawalAmount: Amounts.toString(withdrawAmount), - startTimestamp: getTimestampNow(), - denoms: denomsForWithdraw.map(x => x.denomPub), - withdrawn: denomsForWithdraw.map(x => false), - planchetCreated: denomsForWithdraw.map(x => false), - }; - - const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => x.value)) - .amount; - const totalCoinWithdrawFee = Amounts.sum( - denomsForWithdraw.map(x => x.feeWithdraw), - ).amount; - const totalWithdrawAmount = Amounts.add( - totalCoinValue, - totalCoinWithdrawFee, - ).amount; - - function mutateReserve(r: ReserveRecord): ReserveRecord { - const remaining = Amounts.sub( - r.withdrawRemainingAmount, - totalWithdrawAmount, - ); - if (remaining.saturated) { - console.error("can't create planchets, saturated"); - throw TransactionAbort; - } - const allocated = Amounts.add( - r.withdrawAllocatedAmount, - totalWithdrawAmount, - ); - if (allocated.saturated) { - console.error("can't create planchets, saturated"); - throw TransactionAbort; - } - r.withdrawRemainingAmount = remaining.amount; - r.withdrawAllocatedAmount = allocated.amount; - r.reserveStatus = ReserveRecordStatus.DORMANT; - - return r; - } - - const success = await runWithWriteTransaction( - this.db, - [Stores.planchets, Stores.withdrawalSession, Stores.reserves], - async tx => { - const myReserve = await tx.get(Stores.reserves, reservePub); - if (!myReserve) { - return false; - } - if (myReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { - return false; - } - await tx.mutate(Stores.reserves, reserve.reservePub, mutateReserve); - await tx.put(Stores.withdrawalSession, withdrawalRecord); - return true; - }, - ); - - if (success) { - console.log("processing new withdraw session"); - await this.processWithdrawSession(withdrawalSessionId); - } else { - console.trace("withdraw session already existed"); - } - } - - private async processWithdrawSession(withdrawalSessionId: string): Promise { - logger.trace("processing withdraw session", withdrawalSessionId); - const ws = await oneShotGet( - this.db, - Stores.withdrawalSession, - withdrawalSessionId, - ); - if (!ws) { - logger.trace("withdraw session doesn't exist"); - return; - } - - const ps = ws.denoms.map((d, i) => - this.processWithdrawCoin(withdrawalSessionId, i), - ); - await Promise.all(ps); - this.badge.showNotification(); - return; + async getExchangeTrust( + exchangeInfo: ExchangeRecord, + ): Promise<{ isTrusted: boolean; isAudited: boolean }> { + return getExchangeTrust(this.ws, exchangeInfo); } - private async processWithdrawCoin( - withdrawalSessionId: string, - coinIndex: number, - ) { - logger.info("starting withdraw for coin"); - const ws = await oneShotGet( - this.db, - Stores.withdrawalSession, - withdrawalSessionId, - ); - if (!ws) { - console.log("ws doesn't exist"); - return; - } - - const coin = await oneShotGetIndexed( - this.db, - Stores.coins.byWithdrawalWithIdx, - [withdrawalSessionId, coinIndex], - ); - - if (coin) { - console.log("coin already exists"); - return; - } - - const pc = await oneShotGetIndexed( - this.db, - Stores.planchets.byWithdrawalWithIdx, - [withdrawalSessionId, coinIndex], + async getWithdrawDetailsForUri( + talerWithdrawUri: string, + maybeSelectedExchange?: string, + ): Promise { + return getWithdrawDetailsForUri( + this.ws, + talerWithdrawUri, + maybeSelectedExchange, ); - - if (pc) { - return this.processPlanchet(pc.coinPub); - } else { - const reserve = await oneShotGet(this.db, Stores.reserves, ws.reservePub); - if (!reserve) { - return; - } - const denom = await oneShotGet(this.db, Stores.denominations, [ - reserve.exchangeBaseUrl, - ws.denoms[coinIndex], - ]); - if (!denom) { - return; - } - const r = await this.cryptoApi.createPlanchet(denom, reserve); - const newPlanchet: PlanchetRecord = { - blindingKey: r.blindingKey, - coinEv: r.coinEv, - coinIndex, - coinPriv: r.coinPriv, - coinPub: r.coinPub, - coinValue: r.coinValue, - denomPub: r.denomPub, - denomPubHash: r.denomPubHash, - exchangeBaseUrl: r.exchangeBaseUrl, - isFromTip: false, - reservePub: r.reservePub, - withdrawSessionId: withdrawalSessionId, - withdrawSig: r.withdrawSig, - }; - await runWithWriteTransaction(this.db, [Stores.planchets, Stores.withdrawalSession], async (tx) => { - const myWs = await tx.get(Stores.withdrawalSession, withdrawalSessionId); - if (!myWs) { - return; - } - if (myWs.planchetCreated[coinIndex]) { - return; - } - await tx.put(Stores.planchets, newPlanchet); - }); - await this.processPlanchet(newPlanchet.coinPub); - } } /** - * Update the information about a reserve that is stored in the wallet - * by quering the reserve's exchange. + * Update or add exchange DB entry by fetching the /keys and /wire information. + * Optionally link the reserve entry to the new or existing + * exchange entry in then DB. */ - private async updateReserve(reservePub: string): Promise { - const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - if (!reserve) { - throw Error("reserve not in db"); - } - - if (reserve.timestampConfirmed === undefined) { - throw Error("reserve not confirmed yet"); - } - - if (reserve.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) { - return; - } - - const reqUrl = new URI("reserve/status").absoluteTo( - reserve.exchangeBaseUrl, - ); - reqUrl.query({ reserve_pub: reservePub }); - let resp; - try { - resp = await this.http.get(reqUrl.href()); - } catch (e) { - if (e.response?.status === 404) { - return; - } else { - const m = e.message; - this.setReserveError(reservePub, { - type: "network", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - } - const reserveInfo = ReserveStatus.checked(resp.responseJson); - const balance = Amounts.parseOrThrow(reserveInfo.balance); - await oneShotMutate(this.db, Stores.reserves, reserve.reservePub, r => { - if (r.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) { - return; - } - - // FIXME: check / compare history! - if (!r.lastStatusQuery) { - // FIXME: check if this matches initial expectations - r.withdrawRemainingAmount = balance; - } else { - const expectedBalance = Amounts.sub( - r.withdrawAllocatedAmount, - r.withdrawCompletedAmount, - ); - const cmp = Amounts.cmp(balance, expectedBalance.amount); - if (cmp == 0) { - // Nothing changed. - return; - } - if (cmp > 0) { - const extra = Amounts.sub(balance, expectedBalance.amount).amount; - r.withdrawRemainingAmount = Amounts.add( - r.withdrawRemainingAmount, - extra, - ).amount; - } else { - // We're missing some money. - } - } - r.lastStatusQuery = getTimestampNow(); - r.reserveStatus = ReserveRecordStatus.WITHDRAWING; - return r; - }); - this.notifier.notify(); - } - - async getPossibleDenoms( - exchangeBaseUrl: string, - ): Promise { - return await oneShotIterIndex( - this.db, - Stores.denominations.exchangeBaseUrlIndex, - exchangeBaseUrl, - ).filter(d => { - return ( - d.status === DenominationStatus.Unverified || - d.status === DenominationStatus.VerifiedGood - ); - }); + async updateExchangeFromUrl( + baseUrl: string, + force: boolean = false, + ): Promise { + return updateExchangeFromUrl(this.ws, baseUrl, force); } /** - * Compute the smallest withdrawable amount possible, based on verified denominations. - * - * Writes to the DB in order to record the result from verifying - * denominations. + * Get detailed balance information, sliced by exchange and by currency. */ - async getVerifiedSmallestWithdrawAmount( - exchangeBaseUrl: string, - ): Promise { - const exchange = await oneShotGet( - this.db, - Stores.exchanges, - exchangeBaseUrl, - ); - if (!exchange) { - throw Error(`exchange ${exchangeBaseUrl} not found`); - } - const exchangeDetails = exchange.details; - if (!exchangeDetails) { - throw Error(`exchange ${exchangeBaseUrl} details not available`); - } - - const possibleDenoms = await this.getPossibleDenoms(exchange.baseUrl); - - possibleDenoms.sort((d1, d2) => { - const a1 = Amounts.add(d1.feeWithdraw, d1.value).amount; - const a2 = Amounts.add(d2.feeWithdraw, d2.value).amount; - return Amounts.cmp(a1, a2); - }); + async getBalances(): Promise { + return getBalances(this.ws); + } - for (const denom of possibleDenoms) { - if (denom.status === DenominationStatus.VerifiedGood) { - return Amounts.add(denom.feeWithdraw, denom.value).amount; - } - const valid = await this.cryptoApi.isValidDenom( - denom, - exchangeDetails.masterPublicKey, - ); - if (!valid) { - denom.status = DenominationStatus.VerifiedBad; - } else { - denom.status = DenominationStatus.VerifiedGood; - } - await oneShotPut(this.db, Stores.denominations, denom); - if (valid) { - return Amounts.add(denom.feeWithdraw, denom.value).amount; - } - } - return Amounts.getZero(exchangeDetails.currency); + async refresh(oldCoinPub: string, force: boolean = false): Promise { + return refresh(this.ws, oldCoinPub, force); } - /** - * Get a list of denominations to withdraw from the given exchange for the - * given amount, making sure that all denominations' signatures are verified. - * - * Writes to the DB in order to record the result from verifying - * denominations. - */ - async getVerifiedWithdrawDenomList( - exchangeBaseUrl: string, - amount: AmountJson, - ): Promise { - const exchange = await oneShotGet( - this.db, - Stores.exchanges, - exchangeBaseUrl, - ); - if (!exchange) { - console.log("exchange not found"); - throw Error(`exchange ${exchangeBaseUrl} not found`); - } - const exchangeDetails = exchange.details; - if (!exchangeDetails) { - console.log("exchange details not available"); - throw Error(`exchange ${exchangeBaseUrl} details not available`); - } - - console.log("getting possible denoms"); - - const possibleDenoms = await this.getPossibleDenoms(exchange.baseUrl); - - console.log("got possible denoms"); - - let allValid = false; - - let selectedDenoms: DenominationRecord[]; - - do { - allValid = true; - const nextPossibleDenoms = []; - selectedDenoms = getWithdrawDenomList(amount, possibleDenoms); - console.log("got withdraw denom list"); - for (const denom of selectedDenoms || []) { - if (denom.status === DenominationStatus.Unverified) { - console.log("checking validity", denom, exchangeDetails.masterPublicKey); - const valid = await this.cryptoApi.isValidDenom( - denom, - exchangeDetails.masterPublicKey, - ); - console.log("done checking validity"); - if (!valid) { - denom.status = DenominationStatus.VerifiedBad; - allValid = false; - } else { - denom.status = DenominationStatus.VerifiedGood; - nextPossibleDenoms.push(denom); - } - await oneShotPut(this.db, Stores.denominations, denom); - } else { - nextPossibleDenoms.push(denom); - } - } - } while (selectedDenoms.length > 0 && !allValid); - - console.log("returning denoms"); - - return selectedDenoms; - } - - /** - * Check if and how an exchange is trusted and/or audited. - */ - async getExchangeTrust( - exchangeInfo: ExchangeRecord, - ): Promise<{ isTrusted: boolean; isAudited: boolean }> { - let isTrusted = false; - let isAudited = false; - const exchangeDetails = exchangeInfo.details; - if (!exchangeDetails) { - throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); - } - const currencyRecord = await oneShotGet( - this.db, - Stores.currencies, - exchangeDetails.currency, - ); - if (currencyRecord) { - for (const trustedExchange of currencyRecord.exchanges) { - if (trustedExchange.exchangePub === exchangeDetails.masterPublicKey) { - isTrusted = true; - break; - } - } - for (const trustedAuditor of currencyRecord.auditors) { - for (const exchangeAuditor of exchangeDetails.auditors) { - if (trustedAuditor.auditorPub === exchangeAuditor.auditor_pub) { - isAudited = true; - break; - } - } - } - } - return { isTrusted, isAudited }; - } - - async getWithdrawDetailsForUri( - talerWithdrawUri: string, - maybeSelectedExchange?: string, - ): Promise { - const info = await this.getWithdrawalInfo(talerWithdrawUri); - let rci: ReserveCreationInfo | undefined = undefined; - if (maybeSelectedExchange) { - rci = await this.getWithdrawDetailsForAmount( - maybeSelectedExchange, - info.amount, - ); - } - return { - withdrawInfo: info, - reserveCreationInfo: rci, - }; - } - - async getWithdrawDetailsForAmount( - baseUrl: string, - amount: AmountJson, - ): Promise { - const exchangeInfo = await this.updateExchangeFromUrl(baseUrl); - const exchangeDetails = exchangeInfo.details; - if (!exchangeDetails) { - throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); - } - const exchangeWireInfo = exchangeInfo.wireInfo; - if (!exchangeWireInfo) { - throw Error( - `exchange ${exchangeInfo.baseUrl} wire details not available`, - ); - } - - const selectedDenoms = await this.getVerifiedWithdrawDenomList( - baseUrl, - amount, - ); - let acc = Amounts.getZero(amount.currency); - for (const d of selectedDenoms) { - acc = Amounts.add(acc, d.feeWithdraw).amount; - } - const actualCoinCost = selectedDenoms - .map( - (d: DenominationRecord) => Amounts.add(d.value, d.feeWithdraw).amount, - ) - .reduce((a, b) => Amounts.add(a, b).amount); - - const exchangeWireAccounts: string[] = []; - for (let account of exchangeWireInfo.accounts) { - exchangeWireAccounts.push(account.url); - } - - const { isTrusted, isAudited } = await this.getExchangeTrust(exchangeInfo); - - let earliestDepositExpiration = selectedDenoms[0].stampExpireDeposit; - for (let i = 1; i < selectedDenoms.length; i++) { - const expireDeposit = selectedDenoms[i].stampExpireDeposit; - if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) { - earliestDepositExpiration = expireDeposit; - } - } - - const possibleDenoms = await oneShotIterIndex( - this.db, - Stores.denominations.exchangeBaseUrlIndex, - baseUrl, - ).filter(d => d.isOffered); - - const trustedAuditorPubs = []; - const currencyRecord = await oneShotGet( - this.db, - Stores.currencies, - amount.currency, - ); - if (currencyRecord) { - trustedAuditorPubs.push( - ...currencyRecord.auditors.map(a => a.auditorPub), - ); - } - - let versionMatch; - if (exchangeDetails.protocolVersion) { - versionMatch = LibtoolVersion.compare( - WALLET_PROTOCOL_VERSION, - exchangeDetails.protocolVersion, - ); - - if ( - versionMatch && - !versionMatch.compatible && - versionMatch.currentCmp === -1 - ) { - console.warn( - `wallet version ${WALLET_PROTOCOL_VERSION} might be outdated ` + - `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`, - ); - if (isFirefox()) { - console.log("skipping update check on Firefox"); - } else { - chrome.runtime.requestUpdateCheck((status, details) => { - console.log("update check status:", status); - }); - } - } - } - - const ret: ReserveCreationInfo = { - earliestDepositExpiration, - exchangeInfo, - exchangeWireAccounts, - exchangeVersion: exchangeDetails.protocolVersion || "unknown", - isAudited, - isTrusted, - numOfferedDenoms: possibleDenoms.length, - overhead: Amounts.sub(amount, actualCoinCost).amount, - selectedDenoms, - trustedAuditorPubs, - versionMatch, - walletVersion: WALLET_PROTOCOL_VERSION, - wireFees: exchangeWireInfo, - withdrawFee: acc, - }; - return ret; - } - - async getExchangePaytoUri( - exchangeBaseUrl: string, - supportedTargetTypes: string[], - ): Promise { - // We do the update here, since the exchange might not even exist - // yet in our database. - const exchangeRecord = await this.updateExchangeFromUrl(exchangeBaseUrl); - if (!exchangeRecord) { - throw Error(`Exchange '${exchangeBaseUrl}' not found.`); - } - const exchangeWireInfo = exchangeRecord.wireInfo; - if (!exchangeWireInfo) { - throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`); - } - for (let account of exchangeWireInfo.accounts) { - const paytoUri = new URI(account.url); - if (supportedTargetTypes.includes(paytoUri.authority())) { - return account.url; - } - } - throw Error("no matching exchange account found"); - } - - /** - * Update or add exchange DB entry by fetching the /keys and /wire information. - * Optionally link the reserve entry to the new or existing - * exchange entry in then DB. - */ - async updateExchangeFromUrl( - baseUrl: string, - force: boolean = false, - ): Promise { - const now = getTimestampNow(); - baseUrl = canonicalizeBaseUrl(baseUrl); - - const r = await oneShotGet(this.db, Stores.exchanges, baseUrl); - if (!r) { - const newExchangeRecord: ExchangeRecord = { - baseUrl: baseUrl, - details: undefined, - wireInfo: undefined, - updateStatus: ExchangeUpdateStatus.FETCH_KEYS, - updateStarted: now, - updateReason: "initial", - timestampAdded: getTimestampNow(), - }; - await oneShotPut(this.db, Stores.exchanges, newExchangeRecord); - } else { - await runWithWriteTransaction(this.db, [Stores.exchanges], async t => { - const rec = await t.get(Stores.exchanges, baseUrl); - if (!rec) { - return; - } - if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && !force) { - return; - } - if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && force) { - rec.updateReason = "forced"; - } - rec.updateStarted = now; - rec.updateStatus = ExchangeUpdateStatus.FETCH_KEYS; - rec.lastError = undefined; - t.put(Stores.exchanges, rec); - }); - } - - await this.updateExchangeWithKeys(baseUrl); - await this.updateExchangeWithWireInfo(baseUrl); - - const updatedExchange = await oneShotGet( - this.db, - Stores.exchanges, - baseUrl, - ); - - if (!updatedExchange) { - // This should practically never happen - throw Error("exchange not found"); - } - return updatedExchange; - } - - private async setExchangeError( - baseUrl: string, - err: OperationError, - ): Promise { - const mut = (exchange: ExchangeRecord) => { - exchange.lastError = err; - return exchange; - }; - await oneShotMutate(this.db, Stores.exchanges, baseUrl, mut); - } - - private async setReserveError( - reservePub: string, - err: OperationError, - ): Promise { - const mut = (reserve: ReserveRecord) => { - reserve.lastError = err; - return reserve; - }; - await oneShotMutate(this.db, Stores.reserves, reservePub, mut); - } - - /** - * Fetch the exchange's /keys and update our database accordingly. - * - * Exceptions thrown in this method must be caught and reported - * in the pending operations. - */ - private async updateExchangeWithKeys(baseUrl: string): Promise { - const existingExchangeRecord = await oneShotGet( - this.db, - Stores.exchanges, - baseUrl, - ); - - if ( - existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FETCH_KEYS - ) { - return; - } - const keysUrl = new URI("keys") - .absoluteTo(baseUrl) - .addQuery("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - let keysResp; - try { - keysResp = await this.http.get(keysUrl.href()); - } catch (e) { - const m = `Fetching keys failed: ${e.message}`; - await this.setExchangeError(baseUrl, { - type: "network", - details: { - requestUrl: e.config?.url, - }, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - let exchangeKeysJson: KeysJson; - try { - exchangeKeysJson = KeysJson.checked(keysResp.responseJson); - } catch (e) { - const m = `Parsing /keys response failed: ${e.message}`; - await this.setExchangeError(baseUrl, { - type: "protocol-violation", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - - const lastUpdateTimestamp = extractTalerStamp( - exchangeKeysJson.list_issue_date, - ); - if (!lastUpdateTimestamp) { - const m = `Parsing /keys response failed: invalid list_issue_date.`; - await this.setExchangeError(baseUrl, { - type: "protocol-violation", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - - if (exchangeKeysJson.denoms.length === 0) { - const m = "exchange doesn't offer any denominations"; - await this.setExchangeError(baseUrl, { - type: "protocol-violation", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - - const protocolVersion = exchangeKeysJson.version; - if (!protocolVersion) { - const m = "outdate exchange, no version in /keys response"; - await this.setExchangeError(baseUrl, { - type: "protocol-violation", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - - const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) - .currency; - - const newDenominations = await Promise.all( - exchangeKeysJson.denoms.map(d => - this.denominationRecordFromKeys(baseUrl, d), - ), - ); - - await runWithWriteTransaction( - this.db, - [Stores.exchanges, Stores.denominations], - async tx => { - const r = await tx.get(Stores.exchanges, baseUrl); - if (!r) { - console.warn(`exchange ${baseUrl} no longer present`); - return; - } - if (r.details) { - // FIXME: We need to do some consistency checks! - } - r.details = { - auditors: exchangeKeysJson.auditors, - currency: currency, - lastUpdateTime: lastUpdateTimestamp, - masterPublicKey: exchangeKeysJson.master_public_key, - protocolVersion: protocolVersion, - }; - r.updateStatus = ExchangeUpdateStatus.FETCH_WIRE; - r.lastError = undefined; - await tx.put(Stores.exchanges, r); - - for (const newDenom of newDenominations) { - const oldDenom = await tx.get(Stores.denominations, [ - baseUrl, - newDenom.denomPub, - ]); - if (oldDenom) { - // FIXME: Do consistency check - } else { - await tx.put(Stores.denominations, newDenom); - } - } - }, - ); - } - - /** - * Fetch wire information for an exchange and store it in the database. - * - * @param exchangeBaseUrl Exchange base URL, assumed to be already normalized. - */ - private async updateExchangeWithWireInfo(exchangeBaseUrl: string) { - const exchange = await this.findExchange(exchangeBaseUrl); - if (!exchange) { - return; - } - if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) { - return; - } - const reqUrl = new URI("wire") - .absoluteTo(exchangeBaseUrl) - .addQuery("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - const resp = await this.http.get(reqUrl.href()); - - const wiJson = resp.responseJson; - if (!wiJson) { - throw Error("/wire response malformed"); - } - const wireInfo = ExchangeWireJson.checked(wiJson); - const feesForType: { [wireMethod: string]: WireFee[] } = {}; - for (const wireMethod of Object.keys(wireInfo.fees)) { - const feeList: WireFee[] = []; - for (const x of wireInfo.fees[wireMethod]) { - const startStamp = extractTalerStamp(x.start_date); - if (!startStamp) { - throw Error("wrong date format"); - } - const endStamp = extractTalerStamp(x.end_date); - if (!endStamp) { - throw Error("wrong date format"); - } - feeList.push({ - closingFee: Amounts.parseOrThrow(x.closing_fee), - endStamp, - sig: x.sig, - startStamp, - wireFee: Amounts.parseOrThrow(x.wire_fee), - }); - } - feesForType[wireMethod] = feeList; - } - - await runWithWriteTransaction(this.db, [Stores.exchanges], async tx => { - const r = await tx.get(Stores.exchanges, exchangeBaseUrl); - if (!r) { - return; - } - if (r.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) { - return; - } - r.wireInfo = { - accounts: wireInfo.accounts, - feesForType: feesForType, - }; - r.updateStatus = ExchangeUpdateStatus.FINISHED; - r.lastError = undefined; - await tx.put(Stores.exchanges, r); - }); - } - - /** - * Get detailed balance information, sliced by exchange and by currency. - */ - async getBalances(): Promise { - /** - * Add amount to a balance field, both for - * the slicing by exchange and currency. - */ - function addTo( - balance: WalletBalance, - field: keyof WalletBalanceEntry, - amount: AmountJson, - exchange: string, - ): void { - const z = Amounts.getZero(amount.currency); - const balanceIdentity = { - available: z, - paybackAmount: z, - pendingIncoming: z, - pendingPayment: z, - pendingIncomingDirty: z, - pendingIncomingRefresh: z, - pendingIncomingWithdraw: z, - }; - let entryCurr = balance.byCurrency[amount.currency]; - if (!entryCurr) { - balance.byCurrency[amount.currency] = entryCurr = { - ...balanceIdentity, - }; - } - let entryEx = balance.byExchange[exchange]; - if (!entryEx) { - balance.byExchange[exchange] = entryEx = { ...balanceIdentity }; - } - entryCurr[field] = Amounts.add(entryCurr[field], amount).amount; - entryEx[field] = Amounts.add(entryEx[field], amount).amount; - } - - const balanceStore = { - byCurrency: {}, - byExchange: {}, - }; - - await runWithWriteTransaction( - this.db, - [Stores.coins, Stores.refresh, Stores.reserves, Stores.purchases], - async tx => { - await tx.iter(Stores.coins).forEach(c => { - if (c.suspended) { - return; - } - if (c.status === CoinStatus.Fresh) { - addTo( - balanceStore, - "available", - c.currentAmount, - c.exchangeBaseUrl, - ); - } - if (c.status === CoinStatus.Dirty) { - addTo( - balanceStore, - "pendingIncoming", - c.currentAmount, - c.exchangeBaseUrl, - ); - addTo( - balanceStore, - "pendingIncomingDirty", - c.currentAmount, - c.exchangeBaseUrl, - ); - } - }); - await tx.iter(Stores.refresh).forEach(r => { - // Don't count finished refreshes, since the refresh already resulted - // in coins being added to the wallet. - if (r.finished) { - return; - } - addTo( - balanceStore, - "pendingIncoming", - r.valueOutput, - r.exchangeBaseUrl, - ); - addTo( - balanceStore, - "pendingIncomingRefresh", - r.valueOutput, - r.exchangeBaseUrl, - ); - }); - - await tx.iter(Stores.purchases).forEach(t => { - if (t.finished) { - return; - } - for (const c of t.payReq.coins) { - addTo( - balanceStore, - "pendingPayment", - Amounts.parseOrThrow(c.contribution), - c.exchange_url, - ); - } - }); - }, - ); - - Wallet.enableTracing && console.log("computed balances:", balanceStore); - return balanceStore; - } - - async refresh(oldCoinPub: string, force: boolean = false): Promise { - const coin = await oneShotGet(this.db, Stores.coins, oldCoinPub); - if (!coin) { - console.warn("can't refresh, coin not in database"); - return; - } - switch (coin.status) { - case CoinStatus.Dirty: - break; - case CoinStatus.Dormant: - return; - case CoinStatus.Fresh: - if (!force) { - return; - } - break; - } - - const exchange = await this.updateExchangeFromUrl(coin.exchangeBaseUrl); - if (!exchange) { - throw Error("db inconsistent: exchange of coin not found"); - } - - const oldDenom = await oneShotGet(this.db, Stores.denominations, [ - exchange.baseUrl, - coin.denomPub, - ]); - - if (!oldDenom) { - throw Error("db inconsistent: denomination for coin not found"); - } - - const availableDenoms: DenominationRecord[] = await oneShotIterIndex( - this.db, - Stores.denominations.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh) - .amount; - - const newCoinDenoms = getWithdrawDenomList( - availableAmount, - availableDenoms, - ); - - if (newCoinDenoms.length === 0) { - logger.trace( - `not refreshing, available amount ${amountToPretty( - availableAmount, - )} too small`, - ); - await oneShotMutate(this.db, Stores.coins, oldCoinPub, x => { - if (x.status != coin.status) { - // Concurrent modification? - return; - } - x.status = CoinStatus.Dormant; - return x; - }); - this.notifier.notify(); - return; - } - - const refreshSession: RefreshSessionRecord = await this.cryptoApi.createRefreshSession( - exchange.baseUrl, - 3, - coin, - newCoinDenoms, - oldDenom.feeRefresh, - ); - - function mutateCoin(c: CoinRecord): CoinRecord { - const r = Amounts.sub(c.currentAmount, refreshSession.valueWithFee); - if (r.saturated) { - // Something else must have written the coin value - throw TransactionAbort; - } - c.currentAmount = r.amount; - c.status = CoinStatus.Dormant; - return c; - } - - // Store refresh session and subtract refreshed amount from - // coin in the same transaction. - await runWithWriteTransaction( - this.db, - [Stores.refresh, Stores.coins], - async tx => { - await tx.put(Stores.refresh, refreshSession); - await tx.mutate(Stores.coins, coin.coinPub, mutateCoin); - }, - ); - logger.info(`created refresh session ${refreshSession.refreshSessionId}`); - this.notifier.notify(); - - await this.processRefreshSession(refreshSession.refreshSessionId); - } - - async processRefreshSession(refreshSessionId: string) { - const refreshSession = await oneShotGet( - this.db, - Stores.refresh, - refreshSessionId, - ); - if (!refreshSession) { - return; - } - if (refreshSession.finished) { - return; - } - if (typeof refreshSession.norevealIndex !== "number") { - await this.refreshMelt(refreshSession.refreshSessionId); - } - await this.refreshReveal(refreshSession.refreshSessionId); - logger.trace("refresh finished"); - } - - async refreshMelt(refreshSessionId: string): Promise { - const refreshSession = await oneShotGet( - this.db, - Stores.refresh, - refreshSessionId, - ); - if (!refreshSession) { - return; - } - if (refreshSession.norevealIndex !== undefined) { - return; - } - - const coin = await oneShotGet( - this.db, - Stores.coins, - refreshSession.meltCoinPub, - ); - - if (!coin) { - console.error("can't melt coin, it does not exist"); - return; - } - - const reqUrl = new URI("refresh/melt").absoluteTo( - refreshSession.exchangeBaseUrl, - ); - const meltReq = { - coin_pub: coin.coinPub, - confirm_sig: refreshSession.confirmSig, - denom_pub_hash: coin.denomPubHash, - denom_sig: coin.denomSig, - rc: refreshSession.hash, - value_with_fee: refreshSession.valueWithFee, - }; - Wallet.enableTracing && console.log("melt request:", meltReq); - const resp = await this.http.postJson(reqUrl.href(), meltReq); - - Wallet.enableTracing && console.log("melt response:", resp.responseJson); - - if (resp.status !== 200) { - console.error(resp.responseJson); - throw Error("refresh failed"); - } - - const respJson = resp.responseJson; - - const norevealIndex = respJson.noreveal_index; - - if (typeof norevealIndex !== "number") { - throw Error("invalid response"); - } - - refreshSession.norevealIndex = norevealIndex; - - await oneShotMutate(this.db, Stores.refresh, refreshSessionId, rs => { - if (rs.norevealIndex !== undefined) { - return; - } - if (rs.finished) { - return; - } - rs.norevealIndex = norevealIndex; - return rs; - }); - - this.notifier.notify(); - } - - private async refreshReveal(refreshSessionId: string): Promise { - const refreshSession = await oneShotGet( - this.db, - Stores.refresh, - refreshSessionId, - ); - if (!refreshSession) { - return; - } - const norevealIndex = refreshSession.norevealIndex; - if (norevealIndex === undefined) { - throw Error("can't reveal without melting first"); - } - const privs = Array.from(refreshSession.transferPrivs); - privs.splice(norevealIndex, 1); - - const planchets = refreshSession.planchetsForGammas[norevealIndex]; - if (!planchets) { - throw Error("refresh index error"); - } - - const meltCoinRecord = await oneShotGet( - this.db, - Stores.coins, - refreshSession.meltCoinPub, - ); - if (!meltCoinRecord) { - throw Error("inconsistent database"); - } - - const evs = planchets.map((x: RefreshPlanchetRecord) => x.coinEv); - - const linkSigs: string[] = []; - for (let i = 0; i < refreshSession.newDenoms.length; i++) { - const linkSig = await this.cryptoApi.signCoinLink( - meltCoinRecord.coinPriv, - refreshSession.newDenomHashes[i], - refreshSession.meltCoinPub, - refreshSession.transferPubs[norevealIndex], - planchets[i].coinEv, - ); - linkSigs.push(linkSig); - } - - const req = { - coin_evs: evs, - new_denoms_h: refreshSession.newDenomHashes, - rc: refreshSession.hash, - transfer_privs: privs, - transfer_pub: refreshSession.transferPubs[norevealIndex], - link_sigs: linkSigs, - }; - - const reqUrl = new URI("refresh/reveal").absoluteTo( - refreshSession.exchangeBaseUrl, - ); - Wallet.enableTracing && console.log("reveal request:", req); - - let resp; - try { - resp = await this.http.postJson(reqUrl.href(), req); - } catch (e) { - console.error("got error during /refresh/reveal request"); - console.error(e); - return; - } - - Wallet.enableTracing && console.log("session:", refreshSession); - Wallet.enableTracing && console.log("reveal response:", resp); - - if (resp.status !== 200) { - console.error("error: /refresh/reveal returned status " + resp.status); - return; - } - - const respJson = resp.responseJson; - - if (!respJson.ev_sigs || !Array.isArray(respJson.ev_sigs)) { - console.error("/refresh/reveal did not contain ev_sigs"); - return; - } - - const exchange = await this.findExchange(refreshSession.exchangeBaseUrl); - if (!exchange) { - console.error(`exchange ${refreshSession.exchangeBaseUrl} not found`); - return; - } - - const coins: CoinRecord[] = []; - - for (let i = 0; i < respJson.ev_sigs.length; i++) { - const denom = await oneShotGet(this.db, Stores.denominations, [ - refreshSession.exchangeBaseUrl, - refreshSession.newDenoms[i], - ]); - if (!denom) { - console.error("denom not found"); - continue; - } - const pc = - refreshSession.planchetsForGammas[refreshSession.norevealIndex!][i]; - const denomSig = await this.cryptoApi.rsaUnblind( - respJson.ev_sigs[i].ev_sig, - pc.blindingKey, - denom.denomPub, - ); - const coin: CoinRecord = { - blindingKey: pc.blindingKey, - coinPriv: pc.privateKey, - coinPub: pc.publicKey, - currentAmount: denom.value, - denomPub: denom.denomPub, - denomPubHash: denom.denomPubHash, - denomSig, - exchangeBaseUrl: refreshSession.exchangeBaseUrl, - reservePub: undefined, - status: CoinStatus.Fresh, - coinIndex: -1, - withdrawSessionId: "", - }; - - coins.push(coin); - } - - refreshSession.finished = true; - - await runWithWriteTransaction( - this.db, - [Stores.coins, Stores.refresh], - async tx => { - const rs = await tx.get(Stores.refresh, refreshSessionId); - if (!rs) { - return; - } - if (rs.finished) { - return; - } - for (let coin of coins) { - await tx.put(Stores.coins, coin); - } - await tx.put(Stores.refresh, refreshSession); - }, - ); - this.notifier.notify(); + async processRefreshSession(refreshSessionId: string) { + return processRefreshSession(this.ws, refreshSessionId); } async findExchange( @@ -2988,324 +451,11 @@ export class Wallet { async getHistory( historyQuery?: HistoryQuery, ): Promise<{ history: HistoryEvent[] }> { - const history: HistoryEvent[] = []; - - // FIXME: do pagination instead of generating the full history - - // We uniquely identify history rows via their timestamp. - // This works as timestamps are guaranteed to be monotonically - // increasing even - - const proposals = await oneShotIter(this.db, Stores.proposals).toArray(); - for (const p of proposals) { - history.push({ - detail: { - contractTermsHash: p.contractTermsHash, - merchantName: p.contractTerms.merchant.name, - }, - timestamp: p.timestamp, - type: "claim-order", - explicit: false, - }); - } - - const withdrawals = await oneShotIter( - this.db, - Stores.withdrawalSession, - ).toArray(); - for (const w of withdrawals) { - history.push({ - detail: { - withdrawalAmount: w.withdrawalAmount, - }, - timestamp: w.startTimestamp, - type: "withdraw", - explicit: false, - }); - } - - const purchases = await oneShotIter(this.db, Stores.purchases).toArray(); - for (const p of purchases) { - history.push({ - detail: { - amount: p.contractTerms.amount, - contractTermsHash: p.contractTermsHash, - fulfillmentUrl: p.contractTerms.fulfillment_url, - merchantName: p.contractTerms.merchant.name, - }, - timestamp: p.timestamp, - type: "pay", - explicit: false, - }); - if (p.timestamp_refund) { - const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount); - const amountsPending = Object.keys(p.refundsPending).map(x => - Amounts.parseOrThrow(p.refundsPending[x].refund_amount), - ); - const amountsDone = Object.keys(p.refundsDone).map(x => - Amounts.parseOrThrow(p.refundsDone[x].refund_amount), - ); - const amounts: AmountJson[] = amountsPending.concat(amountsDone); - const amount = Amounts.add( - Amounts.getZero(contractAmount.currency), - ...amounts, - ).amount; - - history.push({ - detail: { - contractTermsHash: p.contractTermsHash, - fulfillmentUrl: p.contractTerms.fulfillment_url, - merchantName: p.contractTerms.merchant.name, - refundAmount: amount, - }, - timestamp: p.timestamp_refund, - type: "refund", - explicit: false, - }); - } - } - - const reserves = await oneShotIter(this.db, Stores.reserves).toArray(); - - for (const r of reserves) { - const reserveType = r.bankWithdrawStatusUrl ? "taler-bank" : "manual"; - history.push({ - detail: { - exchangeBaseUrl: r.exchangeBaseUrl, - requestedAmount: Amounts.toString(r.initiallyRequestedAmount), - reservePub: r.reservePub, - reserveType, - bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, - }, - timestamp: r.created, - type: "reserve-created", - explicit: false, - }); - if (r.timestampConfirmed) { - history.push({ - detail: { - exchangeBaseUrl: r.exchangeBaseUrl, - requestedAmount: Amounts.toString(r.initiallyRequestedAmount), - reservePub: r.reservePub, - reserveType, - bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, - }, - timestamp: r.created, - type: "reserve-confirmed", - explicit: false, - }); - } - } - - const tips: TipRecord[] = await oneShotIter(this.db, Stores.tips).toArray(); - for (const tip of tips) { - history.push({ - detail: { - accepted: tip.accepted, - amount: tip.amount, - merchantDomain: tip.merchantDomain, - tipId: tip.tipId, - }, - timestamp: tip.timestamp, - explicit: false, - type: "tip", - }); - } - - await oneShotIter(this.db, Stores.exchanges).forEach(exchange => { - history.push({ - type: "exchange-added", - explicit: false, - timestamp: exchange.timestampAdded, - detail: { - exchangeBaseUrl: exchange.baseUrl, - }, - }); - }); - - history.sort((h1, h2) => Math.sign(h1.timestamp.t_ms - h2.timestamp.t_ms)); - - return { history }; + return getHistory(this.ws, historyQuery); } async getPendingOperations(): Promise { - const pendingOperations: PendingOperationInfo[] = []; - const exchanges = await this.getExchanges(); - for (let e of exchanges) { - switch (e.updateStatus) { - case ExchangeUpdateStatus.FINISHED: - if (e.lastError) { - pendingOperations.push({ - type: "bug", - message: - "Exchange record is in FINISHED state but has lastError set", - details: { - exchangeBaseUrl: e.baseUrl, - }, - }); - } - if (!e.details) { - pendingOperations.push({ - type: "bug", - message: - "Exchange record does not have details, but no update in progress.", - details: { - exchangeBaseUrl: e.baseUrl, - }, - }); - } - if (!e.wireInfo) { - pendingOperations.push({ - type: "bug", - message: - "Exchange record does not have wire info, but no update in progress.", - details: { - exchangeBaseUrl: e.baseUrl, - }, - }); - } - break; - case ExchangeUpdateStatus.FETCH_KEYS: - pendingOperations.push({ - type: "exchange-update", - stage: "fetch-keys", - exchangeBaseUrl: e.baseUrl, - lastError: e.lastError, - reason: e.updateReason || "unknown", - }); - break; - case ExchangeUpdateStatus.FETCH_WIRE: - pendingOperations.push({ - type: "exchange-update", - stage: "fetch-wire", - exchangeBaseUrl: e.baseUrl, - lastError: e.lastError, - reason: e.updateReason || "unknown", - }); - break; - default: - pendingOperations.push({ - type: "bug", - message: "Unknown exchangeUpdateStatus", - details: { - exchangeBaseUrl: e.baseUrl, - exchangeUpdateStatus: e.updateStatus, - }, - }); - break; - } - } - await oneShotIter(this.db, Stores.reserves).forEach(reserve => { - const reserveType = reserve.bankWithdrawStatusUrl - ? "taler-bank" - : "manual"; - switch (reserve.reserveStatus) { - case ReserveRecordStatus.DORMANT: - // nothing to report as pending - break; - case ReserveRecordStatus.WITHDRAWING: - case ReserveRecordStatus.UNCONFIRMED: - case ReserveRecordStatus.QUERYING_STATUS: - case ReserveRecordStatus.REGISTERING_BANK: - pendingOperations.push({ - type: "reserve", - stage: reserve.reserveStatus, - timestampCreated: reserve.created, - reserveType, - reservePub: reserve.reservePub, - }); - break; - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - pendingOperations.push({ - type: "reserve", - stage: reserve.reserveStatus, - timestampCreated: reserve.created, - reserveType, - reservePub: reserve.reservePub, - bankWithdrawConfirmUrl: reserve.bankWithdrawConfirmUrl, - }); - break; - default: - pendingOperations.push({ - type: "bug", - message: "Unknown reserve record status", - details: { - reservePub: reserve.reservePub, - reserveStatus: reserve.reserveStatus, - }, - }); - break; - } - }); - - await oneShotIter(this.db, Stores.refresh).forEach(r => { - if (r.finished) { - return; - } - let refreshStatus: string; - if (r.norevealIndex === undefined) { - refreshStatus = "melt"; - } else { - refreshStatus = "reveal"; - } - - pendingOperations.push({ - type: "refresh", - oldCoinPub: r.meltCoinPub, - refreshStatus, - refreshOutputSize: r.newDenoms.length, - refreshSessionId: r.refreshSessionId, - }); - }); - - await oneShotIter(this.db, Stores.planchets).forEach(pc => { - pendingOperations.push({ - type: "planchet", - coinPub: pc.coinPub, - reservePub: pc.reservePub, - }); - }); - - await oneShotIter(this.db, Stores.coins).forEach(coin => { - if (coin.status == CoinStatus.Dirty) { - pendingOperations.push({ - type: "dirty-coin", - coinPub: coin.coinPub, - }); - } - }); - - await oneShotIter(this.db, Stores.withdrawalSession).forEach(ws => { - const numCoinsWithdrawn = ws.withdrawn.reduce( - (a, x) => a + (x ? 1 : 0), - 0, - ); - const numCoinsTotal = ws.withdrawn.length; - if (numCoinsWithdrawn < numCoinsTotal) { - pendingOperations.push({ - type: "withdraw", - numCoinsTotal, - numCoinsWithdrawn, - reservePub: ws.reservePub, - withdrawSessionId: ws.withdrawSessionId, - }); - } - }); - - await oneShotIter(this.db, Stores.proposals).forEach(proposal => { - if (proposal.proposalStatus == ProposalStatus.PROPOSED) { - pendingOperations.push({ - type: "proposal", - merchantBaseUrl: proposal.contractTerms.merchant_base_url, - proposalId: proposal.proposalId, - proposalTimestamp: proposal.timestamp, - }); - } - }); - - return { - pendingOperations, - }; + return getPendingOperations(this.ws); } async getDenoms(exchangeUrl: string): Promise { @@ -3331,7 +481,7 @@ export class Wallet { } async updateCurrency(currencyRecord: CurrencyRecord): Promise { - Wallet.enableTracing && console.log("updating currency to", currencyRecord); + logger.trace("updating currency to", currencyRecord); await oneShotPut(this.db, Stores.currencies, currencyRecord); this.notifier.notify(); } @@ -3352,107 +502,8 @@ export class Wallet { return await oneShotIter(this.db, Stores.coins).toArray(); } - async getPlanchets(exchangeBaseUrl: string): Promise { - return await oneShotIter(this.db, Stores.planchets).filter( - c => c.exchangeBaseUrl === exchangeBaseUrl, - ); - } - - private async hashContract(contract: ContractTerms): Promise { - return this.cryptoApi.hashString(canonicalJson(contract)); - } - async payback(coinPub: string): Promise { - let coin = await oneShotGet(this.db, Stores.coins, coinPub); - if (!coin) { - throw Error(`Coin ${coinPub} not found, can't request payback`); - } - const reservePub = coin.reservePub; - if (!reservePub) { - throw Error(`Can't request payback for a refreshed coin`); - } - const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - if (!reserve) { - throw Error(`Reserve of coin ${coinPub} not found`); - } - switch (coin.status) { - case CoinStatus.Dormant: - throw Error(`Can't do payback for coin ${coinPub} since it's dormant`); - } - coin.status = CoinStatus.Dormant; - // Even if we didn't get the payback yet, we suspend withdrawal, since - // technically we might update reserve status before we get the response - // from the reserve for the payback request. - reserve.hasPayback = true; - await runWithWriteTransaction( - this.db, - [Stores.coins, Stores.reserves], - async tx => { - await tx.put(Stores.coins, coin!!); - await tx.put(Stores.reserves, reserve); - }, - ); - this.notifier.notify(); - - const paybackRequest = await this.cryptoApi.createPaybackRequest(coin); - const reqUrl = new URI("payback").absoluteTo(coin.exchangeBaseUrl); - const resp = await this.http.postJson(reqUrl.href(), paybackRequest); - if (resp.status !== 200) { - throw Error(); - } - const paybackConfirmation = PaybackConfirmation.checked(resp.responseJson); - if (paybackConfirmation.reserve_pub !== coin.reservePub) { - throw Error(`Coin's reserve doesn't match reserve on payback`); - } - coin = await oneShotGet(this.db, Stores.coins, coinPub); - if (!coin) { - throw Error(`Coin ${coinPub} not found, can't confirm payback`); - } - coin.status = CoinStatus.Dormant; - await oneShotPut(this.db, Stores.coins, coin); - this.notifier.notify(); - await this.updateReserve(reservePub!); - } - - private async denominationRecordFromKeys( - exchangeBaseUrl: string, - denomIn: Denomination, - ): Promise { - const denomPubHash = await this.cryptoApi.hashDenomPub(denomIn.denom_pub); - const d: DenominationRecord = { - denomPub: denomIn.denom_pub, - denomPubHash, - exchangeBaseUrl, - feeDeposit: Amounts.parseOrThrow(denomIn.fee_deposit), - feeRefresh: Amounts.parseOrThrow(denomIn.fee_refresh), - feeRefund: Amounts.parseOrThrow(denomIn.fee_refund), - feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw), - isOffered: true, - masterSig: denomIn.master_sig, - stampExpireDeposit: extractTalerStampOrThrow( - denomIn.stamp_expire_deposit, - ), - stampExpireLegal: extractTalerStampOrThrow(denomIn.stamp_expire_legal), - stampExpireWithdraw: extractTalerStampOrThrow( - denomIn.stamp_expire_withdraw, - ), - stampStart: extractTalerStampOrThrow(denomIn.stamp_start), - status: DenominationStatus.Unverified, - value: Amounts.parseOrThrow(denomIn.value), - }; - return d; - } - - async withdrawPaybackReserve(reservePub: string): Promise { - const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - if (!reserve) { - throw Error(`Reserve ${reservePub} does not exist`); - } - reserve.hasPayback = false; - await oneShotPut(this.db, Stores.reserves, reserve); - this.depleteReserve(reserve.reservePub).catch(e => { - console.error("Error depleting reserve after payback", e); - }); + return payback(this.ws, coinPub); } async getPaybackReserves(): Promise { @@ -3481,7 +532,6 @@ export class Wallet { Object.keys(wi.feesForType).map(k => s.add(k)); }); - Wallet.enableTracing && console.log(m); const exchangeWireTypes: { [url: string]: string[] } = {}; Object.keys(m).map(e => { exchangeWireTypes[e] = Array.from(m[e]); @@ -3504,202 +554,7 @@ export class Wallet { * Trigger paying coins back into the user's account. */ async returnCoins(req: ReturnCoinsRequest): Promise { - Wallet.enableTracing && console.log("got returnCoins request", req); - const wireType = (req.senderWire as any).type; - Wallet.enableTracing && console.log("wireType", wireType); - if (!wireType || typeof wireType !== "string") { - console.error(`wire type must be a non-empty string, not ${wireType}`); - return; - } - const stampSecNow = Math.floor(new Date().getTime() / 1000); - const exchange = await this.findExchange(req.exchange); - if (!exchange) { - console.error(`Exchange ${req.exchange} not known to the wallet`); - return; - } - const exchangeDetails = exchange.details; - if (!exchangeDetails) { - throw Error("exchange information needs to be updated first."); - } - Wallet.enableTracing && console.log("selecting coins for return:", req); - const cds = await this.getCoinsForReturn(req.exchange, req.amount); - Wallet.enableTracing && console.log(cds); - - if (!cds) { - throw Error("coin return impossible, can't select coins"); - } - - const { priv, pub } = await this.cryptoApi.createEddsaKeypair(); - - const wireHash = await this.cryptoApi.hashString( - canonicalJson(req.senderWire), - ); - - const contractTerms: ContractTerms = { - H_wire: wireHash, - amount: Amounts.toString(req.amount), - auditors: [], - exchanges: [ - { master_pub: exchangeDetails.masterPublicKey, url: exchange.baseUrl }, - ], - extra: {}, - fulfillment_url: "", - locations: [], - max_fee: Amounts.toString(req.amount), - merchant: {}, - merchant_pub: pub, - order_id: "none", - pay_deadline: `/Date(${stampSecNow + 30 * 5})/`, - wire_transfer_deadline: `/Date(${stampSecNow + 60 * 5})/`, - merchant_base_url: "taler://return-to-account", - products: [], - refund_deadline: `/Date(${stampSecNow + 60 * 5})/`, - timestamp: `/Date(${stampSecNow})/`, - wire_method: wireType, - }; - - const contractTermsHash = await this.cryptoApi.hashString( - canonicalJson(contractTerms), - ); - - const payCoinInfo = await this.cryptoApi.signDeposit( - contractTerms, - cds, - Amounts.parseOrThrow(contractTerms.amount), - ); - - Wallet.enableTracing && console.log("pci", payCoinInfo); - - const coins = payCoinInfo.sigs.map(s => ({ coinPaySig: s })); - - const coinsReturnRecord: CoinsReturnRecord = { - coins, - contractTerms, - contractTermsHash, - exchange: exchange.baseUrl, - merchantPriv: priv, - wire: req.senderWire, - }; - - await runWithWriteTransaction( - this.db, - [Stores.coinsReturns, Stores.coins], - async tx => { - await tx.put(Stores.coinsReturns, coinsReturnRecord); - for (let c of payCoinInfo.updatedCoins) { - await tx.put(Stores.coins, c); - } - }, - ); - this.badge.showNotification(); - this.notifier.notify(); - - this.depositReturnedCoins(coinsReturnRecord); - } - - async depositReturnedCoins( - coinsReturnRecord: CoinsReturnRecord, - ): Promise { - for (const c of coinsReturnRecord.coins) { - if (c.depositedSig) { - continue; - } - const req = { - H_wire: coinsReturnRecord.contractTerms.H_wire, - coin_pub: c.coinPaySig.coin_pub, - coin_sig: c.coinPaySig.coin_sig, - contribution: c.coinPaySig.contribution, - denom_pub: c.coinPaySig.denom_pub, - h_contract_terms: coinsReturnRecord.contractTermsHash, - merchant_pub: coinsReturnRecord.contractTerms.merchant_pub, - pay_deadline: coinsReturnRecord.contractTerms.pay_deadline, - refund_deadline: coinsReturnRecord.contractTerms.refund_deadline, - timestamp: coinsReturnRecord.contractTerms.timestamp, - ub_sig: c.coinPaySig.ub_sig, - wire: coinsReturnRecord.wire, - wire_transfer_deadline: coinsReturnRecord.contractTerms.pay_deadline, - }; - Wallet.enableTracing && console.log("req", req); - const reqUrl = new URI("deposit").absoluteTo(coinsReturnRecord.exchange); - const resp = await this.http.postJson(reqUrl.href(), req); - if (resp.status !== 200) { - console.error("deposit failed due to status code", resp); - continue; - } - const respJson = resp.responseJson; - if (respJson.status !== "DEPOSIT_OK") { - console.error("deposit failed", resp); - continue; - } - - if (!respJson.sig) { - console.error("invalid 'sig' field", resp); - continue; - } - - // FIXME: verify signature - - // For every successful deposit, we replace the old record with an updated one - const currentCrr = await oneShotGet( - this.db, - Stores.coinsReturns, - coinsReturnRecord.contractTermsHash, - ); - if (!currentCrr) { - console.error("database inconsistent"); - continue; - } - for (const nc of currentCrr.coins) { - if (nc.coinPaySig.coin_pub === c.coinPaySig.coin_pub) { - nc.depositedSig = respJson.sig; - } - } - await oneShotPut(this.db, Stores.coinsReturns, currentCrr); - this.notifier.notify(); - } - } - - private async acceptRefundResponse( - refundResponse: MerchantRefundResponse, - ): Promise { - const refundPermissions = refundResponse.refund_permissions; - - if (!refundPermissions.length) { - console.warn("got empty refund list"); - throw Error("empty refund"); - } - - /** - * Add refund to purchase if not already added. - */ - function f(t: PurchaseRecord | undefined): PurchaseRecord | undefined { - if (!t) { - console.error("purchase not found, not adding refunds"); - return; - } - - t.timestamp_refund = getTimestampNow(); - - for (const perm of refundPermissions) { - if ( - !t.refundsPending[perm.merchant_sig] && - !t.refundsDone[perm.merchant_sig] - ) { - t.refundsPending[perm.merchant_sig] = perm; - } - } - return t; - } - - const hc = refundResponse.h_contract_terms; - - // Add the refund permissions to the purchase within a DB transaction - await oneShotMutate(this.db, Stores.purchases, hc, f); - this.notifier.notify(); - - await this.submitRefunds(hc); - - return hc; + return returnCoins(this.ws, req); } /** @@ -3707,112 +562,7 @@ export class Wallet { * that was involved in the refund. */ async applyRefund(talerRefundUri: string): Promise { - const parseResult = parseRefundUri(talerRefundUri); - - if (!parseResult) { - throw Error("invalid refund URI"); - } - - const refundUrl = parseResult.refundUrl; - - Wallet.enableTracing && console.log("processing refund"); - let resp; - try { - resp = await this.http.get(refundUrl); - } catch (e) { - console.error("error downloading refund permission", e); - throw e; - } - - const refundResponse = MerchantRefundResponse.checked(resp.responseJson); - return this.acceptRefundResponse(refundResponse); - } - - private async submitRefunds(contractTermsHash: string): Promise { - const purchase = await oneShotGet( - this.db, - Stores.purchases, - contractTermsHash, - ); - if (!purchase) { - console.error( - "not submitting refunds, contract terms not found:", - contractTermsHash, - ); - return; - } - const pendingKeys = Object.keys(purchase.refundsPending); - if (pendingKeys.length === 0) { - return; - } - for (const pk of pendingKeys) { - const perm = purchase.refundsPending[pk]; - const req: RefundRequest = { - coin_pub: perm.coin_pub, - h_contract_terms: purchase.contractTermsHash, - merchant_pub: purchase.contractTerms.merchant_pub, - merchant_sig: perm.merchant_sig, - refund_amount: perm.refund_amount, - refund_fee: perm.refund_fee, - rtransaction_id: perm.rtransaction_id, - }; - console.log("sending refund permission", perm); - // FIXME: not correct once we support multiple exchanges per payment - const exchangeUrl = purchase.payReq.coins[0].exchange_url; - const reqUrl = new URI("refund").absoluteTo(exchangeUrl); - const resp = await this.http.postJson(reqUrl.href(), req); - if (resp.status !== 200) { - console.error("refund failed", resp); - continue; - } - - // Transactionally mark successful refunds as done - const transformPurchase = ( - t: PurchaseRecord | undefined, - ): PurchaseRecord | undefined => { - if (!t) { - console.warn("purchase not found, not updating refund"); - return; - } - if (t.refundsPending[pk]) { - t.refundsDone[pk] = t.refundsPending[pk]; - delete t.refundsPending[pk]; - } - return t; - }; - const transformCoin = ( - c: CoinRecord | undefined, - ): CoinRecord | undefined => { - if (!c) { - console.warn("coin not found, can't apply refund"); - return; - } - const refundAmount = Amounts.parseOrThrow(perm.refund_amount); - const refundFee = Amounts.parseOrThrow(perm.refund_fee); - c.status = CoinStatus.Dirty; - c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; - c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; - - return c; - }; - - await runWithWriteTransaction( - this.db, - [Stores.purchases, Stores.coins], - async tx => { - await tx.mutate( - Stores.purchases, - contractTermsHash, - transformPurchase, - ); - await tx.mutate(Stores.coins, perm.coin_pub, transformCoin); - }, - ); - this.refresh(perm.coin_pub); - } - - this.badge.showNotification(); - this.notifier.notify(); + return applyRefund(this.ws, talerRefundUri); } async getPurchase( @@ -3824,277 +574,19 @@ export class Wallet { async getFullRefundFees( refundPermissions: MerchantRefundPermission[], ): Promise { - if (refundPermissions.length === 0) { - throw Error("no refunds given"); - } - const coin0 = await oneShotGet( - this.db, - Stores.coins, - refundPermissions[0].coin_pub, - ); - if (!coin0) { - throw Error("coin not found"); - } - let feeAcc = Amounts.getZero( - Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency, - ); - - const denoms = await oneShotIterIndex( - this.db, - Stores.denominations.exchangeBaseUrlIndex, - coin0.exchangeBaseUrl, - ).toArray(); - - for (const rp of refundPermissions) { - const coin = await oneShotGet(this.db, Stores.coins, rp.coin_pub); - if (!coin) { - throw Error("coin not found"); - } - const denom = await oneShotGet(this.db, Stores.denominations, [ - coin0.exchangeBaseUrl, - coin.denomPub, - ]); - if (!denom) { - throw Error(`denom not found (${coin.denomPub})`); - } - // FIXME: this assumes that the refund already happened. - // When it hasn't, the refresh cost is inaccurate. To fix this, - // we need introduce a flag to tell if a coin was refunded or - // refreshed normally (and what about incremental refunds?) - const refundAmount = Amounts.parseOrThrow(rp.refund_amount); - const refundFee = Amounts.parseOrThrow(rp.refund_fee); - const refreshCost = getTotalRefreshCost( - denoms, - denom, - Amounts.sub(refundAmount, refundFee).amount, - ); - feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount; - } - return feeAcc; + return getFullRefundFees(this.ws, refundPermissions); } async acceptTip(talerTipUri: string): Promise { - const { tipId, merchantOrigin } = await this.getTipStatus(talerTipUri); - let tipRecord = await oneShotGet(this.db, Stores.tips, [ - tipId, - merchantOrigin, - ]); - if (!tipRecord) { - throw Error("tip not in database"); - } - - tipRecord.accepted = true; - await oneShotPut(this.db, Stores.tips, tipRecord); - - if (tipRecord.pickedUp) { - console.log("tip already picked up"); - return; - } - await this.updateExchangeFromUrl(tipRecord.exchangeUrl); - const denomsForWithdraw = await this.getVerifiedWithdrawDenomList( - tipRecord.exchangeUrl, - tipRecord.amount, - ); - - if (!tipRecord.planchets) { - const planchets = await Promise.all( - denomsForWithdraw.map(d => this.cryptoApi.createTipPlanchet(d)), - ); - const coinPubs: string[] = planchets.map(x => x.coinPub); - - await oneShotMutate(this.db, Stores.tips, [tipId, merchantOrigin], r => { - if (!r.planchets) { - r.planchets = planchets; - r.coinPubs = coinPubs; - } - return r; - }); - - this.notifier.notify(); - } - - tipRecord = await oneShotGet(this.db, Stores.tips, [tipId, merchantOrigin]); - if (!tipRecord) { - throw Error("tip not in database"); - } - - if (!tipRecord.planchets) { - throw Error("invariant violated"); - } - - console.log("got planchets for tip!"); - - // Planchets in the form that the merchant expects - const planchetsDetail: TipPlanchetDetail[] = tipRecord.planchets.map(p => ({ - coin_ev: p.coinEv, - denom_pub_hash: p.denomPubHash, - })); - - let merchantResp; - - try { - const req = { planchets: planchetsDetail, tip_id: tipId }; - merchantResp = await this.http.postJson(tipRecord.pickupUrl, req); - console.log("got merchant resp:", merchantResp); - } catch (e) { - console.log("tipping failed", e); - throw e; - } - - const response = TipResponse.checked(merchantResp.responseJson); - - if (response.reserve_sigs.length !== tipRecord.planchets.length) { - throw Error("number of tip responses does not match requested planchets"); - } - - for (let i = 0; i < tipRecord.planchets.length; i++) { - const tipPlanchet = tipRecord.planchets[i]; - const planchet: PlanchetRecord = { - blindingKey: tipPlanchet.blindingKey, - coinEv: tipPlanchet.coinEv, - coinPriv: tipPlanchet.coinPriv, - coinPub: tipPlanchet.coinPub, - coinValue: tipPlanchet.coinValue, - denomPub: tipPlanchet.denomPub, - denomPubHash: tipPlanchet.denomPubHash, - exchangeBaseUrl: tipRecord.exchangeUrl, - isFromTip: true, - reservePub: response.reserve_pub, - withdrawSig: response.reserve_sigs[i].reserve_sig, - coinIndex: -1, - withdrawSessionId: "", - }; - await oneShotPut(this.db, Stores.planchets, planchet); - await this.processPlanchet(planchet.coinPub); - } - - tipRecord.pickedUp = true; - - await oneShotPut(this.db, Stores.tips, tipRecord); - - this.notifier.notify(); - this.badge.showNotification(); - return; + return acceptTip(this.ws, talerTipUri); } async getTipStatus(talerTipUri: string): Promise { - const res = parseTipUri(talerTipUri); - if (!res) { - throw Error("invalid taler://tip URI"); - } - - const tipStatusUrl = new URI(res.tipPickupUrl).href(); - console.log("checking tip status from", tipStatusUrl); - const merchantResp = await this.http.get(tipStatusUrl); - console.log("resp:", merchantResp.responseJson); - const tipPickupStatus = TipPickupGetResponse.checked( - merchantResp.responseJson, - ); - - console.log("status", tipPickupStatus); - - let amount = Amounts.parseOrThrow(tipPickupStatus.amount); - - let tipRecord = await oneShotGet(this.db, Stores.tips, [ - res.tipId, - res.merchantOrigin, - ]); - - if (!tipRecord) { - const withdrawDetails = await this.getWithdrawDetailsForAmount( - tipPickupStatus.exchange_url, - amount, - ); - - tipRecord = { - accepted: false, - amount, - coinPubs: [], - deadline: getTalerStampSec(tipPickupStatus.stamp_expire)!, - exchangeUrl: tipPickupStatus.exchange_url, - merchantDomain: res.merchantOrigin, - nextUrl: undefined, - pickedUp: false, - planchets: undefined, - response: undefined, - timestamp: getTimestampNow(), - tipId: res.tipId, - pickupUrl: res.tipPickupUrl, - totalFees: Amounts.add( - withdrawDetails.overhead, - withdrawDetails.withdrawFee, - ).amount, - }; - await oneShotPut(this.db, Stores.tips, tipRecord); - } - - const tipStatus: TipStatus = { - accepted: !!tipRecord && tipRecord.accepted, - amount: Amounts.parseOrThrow(tipPickupStatus.amount), - amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left), - exchangeUrl: tipPickupStatus.exchange_url, - nextUrl: tipPickupStatus.extra.next_url, - merchantOrigin: res.merchantOrigin, - tipId: res.tipId, - expirationTimestamp: getTalerStampSec(tipPickupStatus.stamp_expire)!, - timestamp: getTalerStampSec(tipPickupStatus.stamp_created)!, - totalFees: tipRecord.totalFees, - }; - - return tipStatus; + return getTipStatus(this.ws, talerTipUri); } async abortFailedPayment(contractTermsHash: string): Promise { - const purchase = await oneShotGet( - this.db, - Stores.purchases, - contractTermsHash, - ); - if (!purchase) { - throw Error("Purchase not found, unable to abort with refund"); - } - if (purchase.finished) { - throw Error("Purchase already finished, not aborting"); - } - if (purchase.abortDone) { - console.warn("abort requested on already aborted purchase"); - return; - } - - purchase.abortRequested = true; - - // From now on, we can't retry payment anymore, - // so mark this in the DB in case the /pay abort - // does not complete on the first try. - await oneShotPut(this.db, Stores.purchases, purchase); - - let resp; - - const abortReq = { ...purchase.payReq, mode: "abort-refund" }; - - const payUrl = new URI("pay") - .absoluteTo(purchase.contractTerms.merchant_base_url) - .href(); - - try { - resp = await this.http.postJson(payUrl, abortReq); - } catch (e) { - // Gives the user the option to retry / abort and refresh - console.log("aborting payment failed", e); - throw e; - } - - const refundResponse = MerchantRefundResponse.checked(resp.responseJson); - await this.acceptRefundResponse(refundResponse); - - await runWithWriteTransaction(this.db, [Stores.purchases], async tx => { - const p = await tx.get(Stores.purchases, purchase.contractTermsHash); - if (!p) { - return; - } - p.abortDone = true; - await tx.put(Stores.purchases, p); - }); + return abortFailedPayment(this.ws, contractTermsHash); } public async handleNotifyReserve() { @@ -4102,7 +594,7 @@ export class Wallet { for (const r of reserves) { if (r.reserveStatus === ReserveRecordStatus.WAIT_CONFIRM_BANK) { try { - this.processReserveBankStatus(r.reservePub); + this.processReserve(r.reservePub); } catch (e) { console.error(e); } @@ -4128,49 +620,14 @@ export class Wallet { async getWithdrawalInfo( talerWithdrawUri: string, ): Promise { - const uriResult = parseWithdrawUri(talerWithdrawUri); - if (!uriResult) { - throw Error("can't parse URL"); - } - const resp = await this.http.get(uriResult.statusUrl); - console.log("resp:", resp.responseJson); - const status = WithdrawOperationStatusResponse.checked(resp.responseJson); - return { - amount: Amounts.parseOrThrow(status.amount), - confirmTransferUrl: status.confirm_transfer_url, - extractedStatusUrl: uriResult.statusUrl, - selectionDone: status.selection_done, - senderWire: status.sender_wire, - suggestedExchange: status.suggested_exchange, - transferDone: status.transfer_done, - wireTypes: status.wire_types, - }; + return getWithdrawalInfo(this.ws, talerWithdrawUri); } async acceptWithdrawal( talerWithdrawUri: string, selectedExchange: string, ): Promise { - const withdrawInfo = await this.getWithdrawalInfo(talerWithdrawUri); - const exchangeWire = await this.getExchangePaytoUri( - selectedExchange, - withdrawInfo.wireTypes, - ); - const reserve = await this.createReserve({ - amount: withdrawInfo.amount, - bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl, - exchange: selectedExchange, - senderWire: withdrawInfo.senderWire, - exchangeWire: exchangeWire, - }); - // We do this here, as the reserve should be registered before we return, - // so that we can redirect the user to the bank's status page. - await this.processReserveBankStatus(reserve.reservePub); - console.log("acceptWithdrawal: returning"); - return { - reservePub: reserve.reservePub, - confirmTransferUrl: withdrawInfo.confirmTransferUrl, - }; + return acceptWithdrawal(this.ws, talerWithdrawUri, selectedExchange); } async getPurchaseDetails(hc: string): Promise { diff --git a/src/walletTypes.ts b/src/walletTypes.ts index 45560694e..5736282ec 100644 --- a/src/walletTypes.ts +++ b/src/walletTypes.ts @@ -25,16 +25,17 @@ /** * Imports. */ -import { Checkable } from "./checkable"; -import * as LibtoolVersion from "./libtoolVersion"; +import { Checkable } from "./util/checkable"; +import * as LibtoolVersion from "./util/libtoolVersion"; -import { AmountJson } from "./amounts"; +import { AmountJson } from "./util/amounts"; import { CoinRecord, DenominationRecord, ExchangeRecord, ExchangeWireInfo, + WithdrawalSource, } from "./dbTypes"; import { CoinPaySig, ContractTerms, PayReq } from "./talerTypes"; @@ -413,6 +414,7 @@ export interface TipStatus { nextUrl: string; exchangeUrl: string; tipId: string; + merchantTipId: string; merchantOrigin: string; expirationTimestamp: number; timestamp: number; @@ -523,7 +525,7 @@ export interface WalletDiagnostics { export interface PendingWithdrawOperation { type: "withdraw"; - reservePub: string; + source: WithdrawalSource, withdrawSessionId: string; numCoinsWithdrawn: number; numCoinsTotal: number; @@ -576,13 +578,6 @@ export interface PendingRefreshOperation { refreshOutputSize: number; } -export interface PendingPlanchetOperation { - type: "planchet"; - coinPub: string; - reservePub: string; - lastError?: OperationError; -} - export interface PendingDirtyCoinOperation { type: "dirty-coin"; coinPub: string; @@ -595,14 +590,21 @@ export interface PendingProposalOperation { proposalId: string; } +export interface PendingTipOperation { + type: "tip"; + tipId: string; + merchantBaseUrl: string; + merchantTipId: string; +} + export type PendingOperationInfo = | PendingWithdrawOperation | PendingReserveOperation | PendingBugOperation - | PendingPlanchetOperation | PendingDirtyCoinOperation | PendingExchangeUpdateOperation | PendingRefreshOperation + | PendingTipOperation | PendingProposalOperation; export interface PendingOperationsResponse { @@ -642,7 +644,6 @@ export function getTimestampNow(): Timestamp { }; } - export interface PlanchetCreationResult { coinPub: string; coinPriv: string; @@ -652,6 +653,13 @@ export interface PlanchetCreationResult { blindingKey: string; withdrawSig: string; coinEv: string; - exchangeBaseUrl: string; coinValue: AmountJson; +} + +export interface PlanchetCreationRequest { + value: AmountJson; + feeWithdraw: AmountJson; + denomPub: string; + reservePub: string; + reservePriv: string; } \ No newline at end of file diff --git a/src/webex/messages.ts b/src/webex/messages.ts index e321e5ac1..cf409b44e 100644 --- a/src/webex/messages.ts +++ b/src/webex/messages.ts @@ -21,7 +21,7 @@ // Messages are already documented in wxApi. /* tslint:disable:completed-docs */ -import { AmountJson } from "../amounts"; +import { AmountJson } from "../util/amounts"; import * as dbTypes from "../dbTypes"; import * as talerTypes from "../talerTypes"; import * as walletTypes from "../walletTypes"; @@ -113,10 +113,6 @@ export interface MessageMap { request: { reservePub: string }; response: dbTypes.ReserveRecord[]; }; - "get-planchets": { - request: { exchangeBaseUrl: string }; - response: dbTypes.PlanchetRecord[]; - }; "get-denoms": { request: { exchangeBaseUrl: string }; response: dbTypes.DenominationRecord[]; @@ -153,14 +149,6 @@ export interface MessageMap { request: {}; response: void; }; - "download-proposal": { - request: { url: string }; - response: number; - }; - "submit-pay": { - request: { contractTermsHash: string; sessionId: string | undefined }; - response: walletTypes.ConfirmPayResult; - }; "accept-refund": { request: { refundUrl: string }; response: string; diff --git a/src/webex/notify.ts b/src/webex/notify.ts index 4e53c3e1f..61a96cb1b 100644 --- a/src/webex/notify.ts +++ b/src/webex/notify.ts @@ -24,8 +24,6 @@ /** * Imports. */ -import URI = require("urijs"); - import wxApi = require("./wxApi"); declare var cloneInto: any; @@ -180,25 +178,19 @@ function registerHandlers() { }); addHandler("taler-create-reserve", (msg: any) => { - const params = { - amount: JSON.stringify(msg.amount), - bank_url: document.location.href, - callback_url: new URI(msg.callback_url) .absoluteTo(document.location.href), - suggested_exchange_url: msg.suggested_exchange_url, - wt_types: JSON.stringify(msg.wt_types), - }; - const uri = new URI(chrome.extension.getURL("/src/webex/pages/confirm-create-reserve.html")); - const redirectUrl = uri.query(params).href(); - window.location.href = redirectUrl; + const uri = new URL(chrome.extension.getURL("/src/webex/pages/confirm-create-reserve.html")); + uri.searchParams.set("amount", JSON.stringify(msg.amount)); + uri.searchParams.set("bank_url", document.location.href); + uri.searchParams.set("callback_url", new URL(msg.callback_url, document.location.href).href); + uri.searchParams.set("suggested_exchange_url", msg.suggested_exchange_url); + uri.searchParams.set("wt_types", JSON.stringify(msg.wt_types)); + window.location.href = uri.href; }); addHandler("taler-add-auditor", (msg: any) => { - const params = { - req: JSON.stringify(msg), - }; - const uri = new URI(chrome.extension.getURL("/src/webex/pages/add-auditor.html")); - const redirectUrl = uri.query(params).href(); - window.location.href = redirectUrl; + const uri = new URL(chrome.extension.getURL("/src/webex/pages/add-auditor.html")); + uri.searchParams.set("req", JSON.stringify(msg)) + window.location.href = uri.href; }); addHandler("taler-confirm-reserve", async (msg: any, sendResponse: any) => { diff --git a/src/webex/pages/add-auditor.tsx b/src/webex/pages/add-auditor.tsx index 7e3e06322..766db9c5d 100644 --- a/src/webex/pages/add-auditor.tsx +++ b/src/webex/pages/add-auditor.tsx @@ -23,7 +23,6 @@ import { CurrencyRecord } from "../../dbTypes"; import { getCurrencies, updateCurrency } from "../wxApi"; import React, { useState } from "react"; -import URI = require("urijs"); import { registerMountPage } from "../renderHtml"; interface ConfirmAuditorProps { @@ -118,14 +117,24 @@ function ConfirmAuditor(props: ConfirmAuditorProps) { registerMountPage(() => { - const walletPageUrl = new URI(document.location.href); - const query: any = JSON.parse( - (URI.parseQuery(walletPageUrl.query()) as any).req, - ); - const url = query.url; - const currency: string = query.currency; - const auditorPub: string = query.auditorPub; - const expirationStamp = Number.parseInt(query.expirationStamp); + const walletPageUrl = new URL(document.location.href); + const url = walletPageUrl.searchParams.get("url"); + if (!url) { + throw Error("missign parameter (url)"); + } + const currency = walletPageUrl.searchParams.get("currency"); + if (!currency) { + throw Error("missing parameter (currency)"); + } + const auditorPub = walletPageUrl.searchParams.get("auditorPub"); + if (!auditorPub) { + throw Error("missing parameter (auditorPub)"); + } + const auditorStampStr = walletPageUrl.searchParams.get("expirationStamp"); + if (!auditorStampStr) { + throw Error("missing parameter (auditorStampStr)"); + } + const expirationStamp = Number.parseInt(auditorStampStr); const args = { url, currency, auditorPub, expirationStamp }; return ; }); diff --git a/src/webex/pages/pay.tsx b/src/webex/pages/pay.tsx index 7f2a174b7..cff2f9461 100644 --- a/src/webex/pages/pay.tsx +++ b/src/webex/pages/pay.tsx @@ -30,9 +30,8 @@ import { renderAmount, ProgressButton, registerMountPage } from "../renderHtml"; import * as wxApi from "../wxApi"; import React, { useState, useEffect } from "react"; -import URI = require("urijs"); -import * as Amounts from "../../amounts"; +import * as Amounts from "../../util/amounts"; function TalerPayDialog({ talerPayUri }: { talerPayUri: string }) { const [payStatus, setPayStatus] = useState(); @@ -164,10 +163,10 @@ function TalerPayDialog({ talerPayUri }: { talerPayUri: string }) { } registerMountPage(() => { - const url = new URI(document.location.href); - const query: any = URI.parseQuery(url.query()); - - let talerPayUri = query.talerPayUri; - + const url = new URL(document.location.href); + const talerPayUri = url.searchParams.get("talerPayUri"); + if (!talerPayUri) { + throw Error("invalid parameter"); + } return ; }); diff --git a/src/webex/pages/popup.tsx b/src/webex/pages/popup.tsx index 78b7374b3..27d5dddba 100644 --- a/src/webex/pages/popup.tsx +++ b/src/webex/pages/popup.tsx @@ -26,8 +26,8 @@ */ import * as i18n from "../../i18n"; -import { AmountJson } from "../../amounts"; -import * as Amounts from "../../amounts"; +import { AmountJson } from "../../util/amounts"; +import * as Amounts from "../../util/amounts"; import { HistoryEvent, @@ -44,9 +44,6 @@ import { import * as wxApi from "../wxApi"; import * as React from "react"; -import * as ReactDOM from "react-dom"; - -import URI = require("urijs"); function onUpdateNotification(f: () => void): () => void { const port = chrome.runtime.connect({ name: "notifications" }); @@ -339,7 +336,7 @@ function formatHistoryItem(historyItem: HistoryEvent) { ); case "confirm-reserve": { - const exchange = new URI(d.exchangeBaseUrl).host(); + const exchange = new URL(d.exchangeBaseUrl).host; const pub = abbrev(d.reservePub); return ( @@ -359,7 +356,7 @@ function formatHistoryItem(historyItem: HistoryEvent) { } case "depleted-reserve": { const exchange = d.exchangeBaseUrl - ? new URI(d.exchangeBaseUrl).host() + ? new URL(d.exchangeBaseUrl).host : "??"; const amount = renderAmount(d.requestedAmount); const pub = abbrev(d.reservePub); @@ -396,11 +393,10 @@ function formatHistoryItem(historyItem: HistoryEvent) { ); } case "tip": { - const tipPageUrl = new URI( - chrome.extension.getURL("/src/webex/pages/tip.html"), - ); - const params = { tip_id: d.tipId, merchant_domain: d.merchantDomain }; - const url = tipPageUrl.query(params).href(); + const tipPageUrl = new URL(chrome.extension.getURL("/src/webex/pages/tip.html")); + tipPageUrl.searchParams.set("tip_id", d.tipId); + tipPageUrl.searchParams.set("merchant_domain", d.merchantDomain); + const url = tipPageUrl.href; const tipLink = {i18n.str`tip`}; // i18n: Tip return ( diff --git a/src/webex/pages/refund.tsx b/src/webex/pages/refund.tsx index 79cadcdc9..5196c9ea6 100644 --- a/src/webex/pages/refund.tsx +++ b/src/webex/pages/refund.tsx @@ -22,7 +22,6 @@ import React, { useEffect, useState } from "react"; import ReactDOM from "react-dom"; -import URI = require("urijs"); import * as wxApi from "../wxApi"; import { PurchaseDetails } from "../../walletTypes"; @@ -76,8 +75,7 @@ function RefundStatusView(props: { talerRefundUri: string }) { } async function main() { - const url = new URI(document.location.href); - const query: any = URI.parseQuery(url.query()); + const url = new URL(document.location.href); const container = document.getElementById("container"); if (!container) { @@ -85,7 +83,7 @@ async function main() { return; } - const talerRefundUri = query.talerRefundUri; + const talerRefundUri = url.searchParams.get("talerRefundUri"); if (!talerRefundUri) { console.error("taler refund URI requred"); return; diff --git a/src/webex/pages/return-coins.tsx b/src/webex/pages/return-coins.tsx index b5d53c31e..be65b4121 100644 --- a/src/webex/pages/return-coins.tsx +++ b/src/webex/pages/return-coins.tsx @@ -25,8 +25,8 @@ * Imports. */ -import { AmountJson } from "../../amounts"; -import * as Amounts from "../../amounts"; +import { AmountJson } from "../../util/amounts"; +import * as Amounts from "../../util/amounts"; import { SenderWireInfos, @@ -35,7 +35,7 @@ import { import * as i18n from "../../i18n"; -import * as wire from "../../wire"; +import * as wire from "../../util/wire"; import { getBalance, diff --git a/src/webex/pages/tip.tsx b/src/webex/pages/tip.tsx index 148b8203c..ac904cf0d 100644 --- a/src/webex/pages/tip.tsx +++ b/src/webex/pages/tip.tsx @@ -23,7 +23,6 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; -import URI = require("urijs"); import * as i18n from "../../i18n"; @@ -31,7 +30,7 @@ import { acceptTip, getReserveCreationInfo, getTipStatus } from "../wxApi"; import { WithdrawDetailView, renderAmount, ProgressButton } from "../renderHtml"; -import * as Amounts from "../../amounts"; +import * as Amounts from "../../util/amounts"; import { useState, useEffect } from "react"; import { TipStatus } from "../../walletTypes"; @@ -68,7 +67,7 @@ function TipDisplay(props: { talerTipUri: string }) { const accept = async () => { setLoading(true); - await acceptTip(props.talerTipUri); + await acceptTip(tipStatus.tipId); setFinished(true); }; @@ -101,9 +100,8 @@ function TipDisplay(props: { talerTipUri: string }) { async function main() { try { - const url = new URI(document.location.href); - const query: any = URI.parseQuery(url.query()); - const talerTipUri = query.talerTipUri; + const url = new URL(document.location.href); + const talerTipUri = url.searchParams.get("talerTipUri"); if (typeof talerTipUri !== "string") { throw Error("talerTipUri must be a string"); } diff --git a/src/webex/pages/withdraw.tsx b/src/webex/pages/withdraw.tsx index 39b27f2d8..6b7152dc2 100644 --- a/src/webex/pages/withdraw.tsx +++ b/src/webex/pages/withdraw.tsx @@ -32,7 +32,6 @@ import { WithdrawDetailView, renderAmount } from "../renderHtml"; import React, { useState, useEffect } from "react"; import * as ReactDOM from "react-dom"; -import URI = require("urijs"); import { getWithdrawDetails, acceptWithdrawal } from "../wxApi"; function NewExchangeSelection(props: { talerWithdrawUri: string }) { @@ -199,9 +198,8 @@ function NewExchangeSelection(props: { talerWithdrawUri: string }) { async function main() { try { - const url = new URI(document.location.href); - const query: any = URI.parseQuery(url.query()); - let talerWithdrawUri = query.talerWithdrawUri; + const url = new URL(document.location.href); + const talerWithdrawUri = url.searchParams.get("talerWithdrawUri"); if (!talerWithdrawUri) { throw Error("withdraw URI required"); } diff --git a/src/webex/renderHtml.tsx b/src/webex/renderHtml.tsx index 42bcdbabc..945719b65 100644 --- a/src/webex/renderHtml.tsx +++ b/src/webex/renderHtml.tsx @@ -23,8 +23,8 @@ /** * Imports. */ -import { AmountJson } from "../amounts"; -import * as Amounts from "../amounts"; +import { AmountJson } from "../util/amounts"; +import * as Amounts from "../util/amounts"; import { DenominationRecord } from "../dbTypes"; import { ReserveCreationInfo } from "../walletTypes"; import * as moment from "moment"; diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts index a8b35ed34..ea26cd2eb 100644 --- a/src/webex/wxApi.ts +++ b/src/webex/wxApi.ts @@ -22,7 +22,7 @@ /** * Imports. */ -import { AmountJson } from "../amounts"; +import { AmountJson } from "../util/amounts"; import { CoinRecord, CurrencyRecord, @@ -173,14 +173,6 @@ export function getCoins(exchangeBaseUrl: string): Promise { } -/** - * Get all planchets withdrawn from the given exchange. - */ -export function getPlanchets(exchangeBaseUrl: string): Promise { - return callBackend("get-planchets", { exchangeBaseUrl }); -} - - /** * Get all denoms offered by the given exchange. */ @@ -211,13 +203,6 @@ export function confirmPay(proposalId: string, sessionId: string | undefined): P return callBackend("confirm-pay", { proposalId, sessionId }); } -/** - * Replay paying for a purchase. - */ -export function submitPay(contractTermsHash: string, sessionId: string | undefined): Promise { - return callBackend("submit-pay", { contractTermsHash, sessionId }); -} - /** * Mark a reserve as confirmed. @@ -302,14 +287,6 @@ export function clearNotification(): Promise { return callBackend("clear-notification", { }); } - -/** - * Download a contract. - */ -export function downloadProposal(url: string): Promise { - return callBackend("download-proposal", { url }); -} - /** * Download a refund and accept it. */ diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts index 78c86a976..2d7f963e9 100644 --- a/src/webex/wxBackend.ts +++ b/src/webex/wxBackend.ts @@ -23,8 +23,8 @@ /** * Imports. */ -import { BrowserHttpLib } from "../http"; -import { AmountJson } from "../amounts"; +import { BrowserHttpLib } from "../util/http"; +import { AmountJson } from "../util/amounts"; import { ConfirmReserveRequest, CreateReserveRequest, @@ -39,11 +39,10 @@ import { openTalerDb, exportDb, importDb, deleteDb } from "../db"; import { ChromeBadge } from "./chromeBadge"; import { MessageType } from "./messages"; import * as wxApi from "./wxApi"; -import URI = require("urijs"); import Port = chrome.runtime.Port; import MessageSender = chrome.runtime.MessageSender; import { BrowserCryptoWorkerFactory } from "../crypto/cryptoApi"; -import { OpenedPromise, openPromise } from "../promiseUtils"; +import { OpenedPromise, openPromise } from "../util/promiseUtils"; const NeedsWallet = Symbol("NeedsWallet"); @@ -122,15 +121,6 @@ async function handleMessage( } return needsWallet().confirmPay(detail.proposalId, detail.sessionId); } - case "submit-pay": { - if (typeof detail.contractTermsHash !== "string") { - throw Error("contractTermsHash must be a string"); - } - return needsWallet().submitPay( - detail.contractTermsHash, - detail.sessionId, - ); - } case "exchange-info": { if (!detail.baseUrl) { return Promise.resolve({ error: "bad url" }); @@ -170,7 +160,7 @@ async function handleMessage( if (typeof detail.reservePub !== "string") { return Promise.reject(Error("reservePub missing")); } - return needsWallet().withdrawPaybackReserve(detail.reservePub); + throw Error("not implemented"); } case "get-coins": { if (typeof detail.exchangeBaseUrl !== "string") { @@ -178,12 +168,6 @@ async function handleMessage( } return needsWallet().getCoinsForExchange(detail.exchangeBaseUrl); } - case "get-planchets": { - if (typeof detail.exchangeBaseUrl !== "string") { - return Promise.reject(Error("exchangBaseUrl missing")); - } - return needsWallet().getPlanchets(detail.exchangeBaseUrl); - } case "get-denoms": { if (typeof detail.exchangeBaseUrl !== "string") { return Promise.reject(Error("exchangBaseUrl missing")); @@ -244,9 +228,6 @@ async function handleMessage( case "clear-notification": { return needsWallet().clearNotification(); } - case "download-proposal": { - return needsWallet().downloadProposal(detail.url); - } case "abort-failed-payment": { if (!detail.contractTermsHash) { throw Error("contracTermsHash not given"); @@ -404,18 +385,19 @@ function makeSyncWalletRedirect( oldUrl: string, params?: { [name: string]: string | undefined }, ): object { - const innerUrl = new URI(chrome.extension.getURL("/src/webex/pages/" + url)); + const innerUrl = new URL(chrome.extension.getURL("/src/webex/pages/" + url)); if (params) { for (const key in params) { - if (params[key]) { - innerUrl.addSearch(key, params[key]); + const p = params[key]; + if (p) { + innerUrl.searchParams.set(key, p); } } } - const outerUrl = new URI( + const outerUrl = new URL( chrome.extension.getURL("/src/webex/pages/redirect.html"), ); - outerUrl.addSearch("url", innerUrl); + outerUrl.searchParams.set("url", innerUrl.href); if (isFirefox()) { // Some platforms don't support the sync redirect (yet), so fall back to // async redirect after a timeout. @@ -423,12 +405,12 @@ function makeSyncWalletRedirect( await waitMs(150); const tab = await getTab(tabId); if (tab.url === oldUrl) { - chrome.tabs.update(tabId, { url: outerUrl.href() }); + chrome.tabs.update(tabId, { url: outerUrl.href }); } }; doit(); } - return { redirectUrl: outerUrl.href() }; + return { redirectUrl: outerUrl.href }; } /** @@ -549,29 +531,29 @@ export async function wxMain() { if (!tab.url || !tab.id) { continue; } - const uri = new URI(tab.url); - if (uri.protocol() !== "http" && uri.protocol() !== "https") { + const uri = new URL(tab.url); + if (uri.protocol !== "http:" && uri.protocol !== "https:") { continue; } console.log( "injecting into existing tab", tab.id, "with url", - uri.href(), + uri.href, "protocol", - uri.protocol(), + uri.protocol, ); injectScript( tab.id, { file: "/dist/contentScript-bundle.js", runAt: "document_start" }, - uri.href(), + uri.href, ); const code = ` if (("taler" in window) || document.documentElement.getAttribute("data-taler-nojs")) { document.dispatchEvent(new Event("taler-probe-result")); } `; - injectScript(tab.id, { code, runAt: "document_start" }, uri.href()); + injectScript(tab.id, { code, runAt: "document_start" }, uri.href); } }); @@ -603,8 +585,8 @@ export async function wxMain() { if (!tab.url || !tab.id) { return; } - const uri = new URI(tab.url); - if (!(uri.protocol() === "http" || uri.protocol() === "https")) { + const uri = new URL(tab.url); + if (!(uri.protocol === "http:" || uri.protocol === "https:")) { return; } const code = ` @@ -612,7 +594,7 @@ export async function wxMain() { document.dispatchEvent(new Event("taler-probe-result")); } `; - injectScript(tab.id!, { code, runAt: "document_start" }, uri.href()); + injectScript(tab.id!, { code, runAt: "document_start" }, uri.href); }); }; diff --git a/src/wire.ts b/src/wire.ts deleted file mode 100644 index c06a30bbd..000000000 --- a/src/wire.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - This file is part of TALER - (C) 2017 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 - */ - - -/** - * Display and manipulate wire information. - * - * Right now, all types are hard-coded. In the future, there might be plugins / configurable - * methods or support for the "payto://" URI scheme. - */ - -/** - * Imports. - */ -import * as i18n from "./i18n"; - -/** - * Short summary of the wire information. - * - * Might abbreviate and return the same summary for different - * wire details. - */ -export function summarizeWire(w: any): string { - if (!w.type) { - return i18n.str`Invalid Wire`; - } - switch (w.type.toLowerCase()) { - case "test": - if (!w.account_number && w.account_number !== 0) { - return i18n.str`Invalid Test Wire Detail`; - } - if (!w.bank_uri) { - return i18n.str`Invalid Test Wire Detail`; - } - return i18n.str`Test Wire Acct #${w.account_number} on ${w.bank_uri}`; - default: - return i18n.str`Unknown Wire Detail`; - } -} - -- cgit v1.2.3