diff options
Diffstat (limited to 'packages/taler-wallet-core/src/util')
44 files changed, 4342 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/util/RequestThrottler.d.ts.map b/packages/taler-wallet-core/src/util/RequestThrottler.d.ts.map new file mode 100644 index 000000000..3a2fa1081 --- /dev/null +++ b/packages/taler-wallet-core/src/util/RequestThrottler.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"RequestThrottler.d.ts","sourceRoot":"","sources":["RequestThrottler.ts"],"names":[],"mappings":"AAiGA;;;;GAIG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,aAAa,CAAyC;IAE9D;;;;OAIG;IACH,OAAO,CAAC,QAAQ;IAShB;;;;OAIG;IACH,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;CAI3C"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/RequestThrottler.ts b/packages/taler-wallet-core/src/util/RequestThrottler.ts new file mode 100644 index 000000000..6f51a72bc --- /dev/null +++ b/packages/taler-wallet-core/src/util/RequestThrottler.ts @@ -0,0 +1,129 @@ +/* + 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. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Implementation of token bucket throttling. + */ + +/** + * Imports. + */ +import { getTimestampNow, timestampDifference } from "../util/time"; +import { URL } from "./url"; + +/** + * Maximum request per second, per origin. + */ +const MAX_PER_SECOND = 50; + +/** + * Maximum request per minute, per origin. + */ +const MAX_PER_MINUTE = 100; + +/** + * Maximum request per hour, per origin. + */ +const MAX_PER_HOUR = 1000; + +/** + * Throttling state for one origin. + */ +class OriginState { + private tokensSecond: number = MAX_PER_SECOND; + private tokensMinute: number = MAX_PER_MINUTE; + private tokensHour: number = MAX_PER_HOUR; + private lastUpdate = getTimestampNow(); + + private refill(): void { + const now = getTimestampNow(); + const d = timestampDifference(now, this.lastUpdate); + if (d.d_ms === "forever") { + throw Error("assertion failed"); + } + const d_s = d.d_ms / 1000; + this.tokensSecond = Math.min( + MAX_PER_SECOND, + this.tokensSecond + d_s / 1000, + ); + this.tokensMinute = Math.min( + MAX_PER_MINUTE, + this.tokensMinute + (d_s / 1000) * 60, + ); + this.tokensHour = Math.min( + MAX_PER_HOUR, + this.tokensHour + (d_s / 1000) * 60 * 60, + ); + this.lastUpdate = now; + } + + /** + * Return true if the request for this origin should be throttled. + * Otherwise, take a token out of the respective buckets. + */ + applyThrottle(): boolean { + this.refill(); + if (this.tokensSecond < 1) { + console.log("request throttled (per second limit exceeded)"); + return true; + } + if (this.tokensMinute < 1) { + console.log("request throttled (per minute limit exceeded)"); + return true; + } + if (this.tokensHour < 1) { + console.log("request throttled (per hour limit exceeded)"); + return true; + } + this.tokensSecond--; + this.tokensMinute--; + this.tokensHour--; + return false; + } +} + +/** + * Request throttler, used as a "last layer of defense" when some + * other part of the re-try logic is broken and we're sending too + * many requests to the same exchange/bank/merchant. + */ +export class RequestThrottler { + private perOriginInfo: { [origin: string]: OriginState } = {}; + + /** + * Get the throttling state for an origin, or + * initialize if no state is associated with the + * origin yet. + */ + private getState(origin: string): OriginState { + const s = this.perOriginInfo[origin]; + if (s) { + return s; + } + const ns = (this.perOriginInfo[origin] = new OriginState()); + return ns; + } + + /** + * Apply throttling to a request. + * + * @returns whether the request should be throttled. + */ + applyThrottle(requestUrl: string): boolean { + const origin = new URL(requestUrl).origin; + return this.getState(origin).applyThrottle(); + } +} diff --git a/packages/taler-wallet-core/src/util/amounts-test.ts b/packages/taler-wallet-core/src/util/amounts-test.ts new file mode 100644 index 000000000..afd8caa51 --- /dev/null +++ b/packages/taler-wallet-core/src/util/amounts-test.ts @@ -0,0 +1,140 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import test from "ava"; + +import { Amounts, AmountJson } from "../util/amounts"; + +const jAmt = ( + value: number, + fraction: number, + currency: string, +): AmountJson => ({ value, fraction, currency }); + +const sAmt = (s: string): AmountJson => Amounts.parseOrThrow(s); + +test("amount addition (simple)", (t) => { + const a1 = jAmt(1, 0, "EUR"); + const a2 = jAmt(1, 0, "EUR"); + const a3 = jAmt(2, 0, "EUR"); + t.true(0 === Amounts.cmp(Amounts.add(a1, a2).amount, a3)); + t.pass(); +}); + +test("amount addition (saturation)", (t) => { + const a1 = jAmt(1, 0, "EUR"); + const res = Amounts.add(jAmt(Amounts.maxAmountValue, 0, "EUR"), a1); + t.true(res.saturated); + t.pass(); +}); + +test("amount subtraction (simple)", (t) => { + const a1 = jAmt(2, 5, "EUR"); + const a2 = jAmt(1, 0, "EUR"); + const a3 = jAmt(1, 5, "EUR"); + t.true(0 === Amounts.cmp(Amounts.sub(a1, a2).amount, a3)); + t.pass(); +}); + +test("amount subtraction (saturation)", (t) => { + const a1 = jAmt(0, 0, "EUR"); + const a2 = jAmt(1, 0, "EUR"); + let res = Amounts.sub(a1, a2); + t.true(res.saturated); + res = Amounts.sub(a1, a1); + t.true(!res.saturated); + t.pass(); +}); + +test("amount comparison", (t) => { + t.is(Amounts.cmp(jAmt(1, 0, "EUR"), jAmt(1, 0, "EUR")), 0); + t.is(Amounts.cmp(jAmt(1, 1, "EUR"), jAmt(1, 0, "EUR")), 1); + t.is(Amounts.cmp(jAmt(1, 1, "EUR"), jAmt(1, 2, "EUR")), -1); + t.is(Amounts.cmp(jAmt(1, 0, "EUR"), jAmt(0, 0, "EUR")), 1); + t.is(Amounts.cmp(jAmt(0, 0, "EUR"), jAmt(1, 0, "EUR")), -1); + t.is(Amounts.cmp(jAmt(1, 0, "EUR"), jAmt(0, 100000000, "EUR")), 0); + t.throws(() => Amounts.cmp(jAmt(1, 0, "FOO"), jAmt(1, 0, "BAR"))); + t.pass(); +}); + +test("amount parsing", (t) => { + t.is( + Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0"), jAmt(0, 0, "TESTKUDOS")), + 0, + ); + t.is( + Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:10"), jAmt(10, 0, "TESTKUDOS")), + 0, + ); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:0.1"), + jAmt(0, 10000000, "TESTKUDOS"), + ), + 0, + ); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:0.00000001"), + jAmt(0, 1, "TESTKUDOS"), + ), + 0, + ); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:4503599627370496.99999999"), + jAmt(4503599627370496, 99999999, "TESTKUDOS"), + ), + 0, + ); + t.throws(() => Amounts.parseOrThrow("foo:")); + t.throws(() => Amounts.parseOrThrow("1.0")); + t.throws(() => Amounts.parseOrThrow("42")); + t.throws(() => Amounts.parseOrThrow(":1.0")); + t.throws(() => Amounts.parseOrThrow(":42")); + 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"), + jAmt(0, 99999999, "TESTKUDOS"), + ), + 0, + ); + t.throws(() => Amounts.parseOrThrow("TESTKUDOS:0.999999991")); + t.pass(); +}); + +test("amount stringification", (t) => { + t.is(Amounts.stringify(jAmt(0, 0, "TESTKUDOS")), "TESTKUDOS:0"); + t.is(Amounts.stringify(jAmt(4, 94000000, "TESTKUDOS")), "TESTKUDOS:4.94"); + t.is(Amounts.stringify(jAmt(0, 10000000, "TESTKUDOS")), "TESTKUDOS:0.1"); + t.is(Amounts.stringify(jAmt(0, 1, "TESTKUDOS")), "TESTKUDOS:0.00000001"); + t.is(Amounts.stringify(jAmt(5, 0, "TESTKUDOS")), "TESTKUDOS:5"); + // denormalized + t.is(Amounts.stringify(jAmt(1, 100000000, "TESTKUDOS")), "TESTKUDOS:2"); + t.pass(); +}); + +test("amount multiplication", (t) => { + t.is(Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 0).amount), "EUR:0"); + t.is(Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 1).amount), "EUR:1.11"); + t.is(Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 2).amount), "EUR:2.22"); + t.is(Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 3).amount), "EUR:3.33"); + t.is(Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 4).amount), "EUR:4.44"); + t.is(Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 5).amount), "EUR:5.55"); +}); diff --git a/packages/taler-wallet-core/src/util/amounts.d.ts.map b/packages/taler-wallet-core/src/util/amounts.d.ts.map new file mode 100644 index 000000000..c70d06fb7 --- /dev/null +++ b/packages/taler-wallet-core/src/util/amounts.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"amounts.d.ts","sourceRoot":"","sources":["amounts.ts"],"names":[],"mappings":"AAgBA;;GAEG;AAEH;;GAEG;AACH,OAAO,EAIL,KAAK,EACN,MAAM,SAAS,CAAC;AAEjB;;GAEG;AACH,eAAO,MAAM,cAAc,YAAM,CAAC;AAElC;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAElC;;GAEG;AACH,eAAO,MAAM,cAAc,QAAU,CAAC;AAEtC;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB;;OAEG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAEvB;;OAEG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAE1B;;OAEG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,eAAO,MAAM,kBAAkB,QAAO,KAAK,CAAC,UAAU,CAK9B,CAAC;AAEzB;;GAEG;AACH,MAAM,WAAW,MAAM;IACrB;;OAEG;IACH,MAAM,EAAE,UAAU,CAAC;IACnB;;OAEG;IACH,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;GAEG;AACH,wBAAgB,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,CAMpD;AAED,wBAAgB,GAAG,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,MAAM,CAKjD;AAED;;;;;;GAMG;AACH,wBAAgB,GAAG,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,IAAI,EAAE,UAAU,EAAE,GAAG,MAAM,CA8BpE;AAED;;;;;;GAMG;AACH,wBAAgB,GAAG,CAAC,CAAC,EAAE,UAAU,EAAE,GAAG,IAAI,EAAE,UAAU,EAAE,GAAG,MAAM,CAyBhE;AAED;;;GAGG;AACH,wBAAgB,GAAG,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,UAAU,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAsB5D;AAED;;GAEG;AACH,wBAAgB,IAAI,CAAC,CAAC,EAAE,UAAU,GAAG,UAAU,CAM9C;AAED;;GAEG;AACH,wBAAgB,MAAM,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,MAAM,GAAG,UAAU,CAa3D;AAED;;GAEG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,UAAU,GAAG,OAAO,CAEhD;AAED,wBAAgB,MAAM,CAAC,CAAC,EAAE,UAAU,GAAG,OAAO,CAE7C;AAED;;GAEG;AACH,wBAAgB,KAAK,CAAC,CAAC,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAkBvD;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,GAAG,UAAU,CAMlD;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU,CAMxE;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,UAAU,GAAG,MAAM,CAkB/C;AAED;;GAEG;AACH,iBAAS,KAAK,CAAC,CAAC,EAAE,GAAG,GAAG,OAAO,CAU9B;AAED,iBAAS,IAAI,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CA8B9C;AAGD,eAAO,MAAM,OAAO;;;;;;;;;;;;;;;;CAgBnB,CAAC"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/amounts.ts b/packages/taler-wallet-core/src/util/amounts.ts new file mode 100644 index 000000000..00f4b17d7 --- /dev/null +++ b/packages/taler-wallet-core/src/util/amounts.ts @@ -0,0 +1,384 @@ +/* + This file is part of GNU Taler + (C) 2019 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Types and helper functions for dealing with Taler amounts. + */ + +/** + * Imports. + */ +import { + makeCodecForObject, + codecForString, + codecForNumber, + Codec, +} from "./codec"; + +/** + * 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. + */ +export interface AmountJson { + /** + * Value, must be an integer. + */ + readonly value: number; + + /** + * Fraction, must be an integer. Represent 1/1e8 of a unit. + */ + readonly fraction: number; + + /** + * Currency of the amount. + */ + readonly currency: string; +} + +export const codecForAmountJson = (): Codec<AmountJson> => + makeCodecForObject<AmountJson>() + .property("currency", codecForString) + .property("value", codecForNumber) + .property("fraction", codecForNumber) + .build("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[]): Result { + 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): -1 | 0 | 1 { + 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; +} + +export function isZero(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; + } + const 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): AmountJson { + 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 stringify(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. + */ +function check(a: any): boolean { + if (typeof a !== "string") { + return false; + } + try { + const parsedAmount = parse(a); + return !!parsedAmount; + } catch { + return false; + } +} + +function mult(a: AmountJson, n: number): Result { + if (!Number.isInteger(n)) { + throw Error("amount can only be multipied by an integer"); + } + if (n < 0) { + throw Error("amount can only be multiplied by a positive integer"); + } + if (n == 0) { + return { amount: getZero(a.currency), saturated: false }; + } + let x = a; + let acc = getZero(a.currency); + while (n > 1) { + if (n % 2 == 0) { + n = n / 2; + } else { + n = (n - 1) / 2; + const r2 = add(acc, x); + if (r2.saturated) { + return r2; + } + acc = r2.amount; + } + const r2 = add(x, x); + if (r2.saturated) { + return r2; + } + x = r2.amount; + } + return add(acc, x); +} + +// Export all amount-related functions here for better IDE experience. +export const Amounts = { + stringify: stringify, + parse: parse, + parseOrThrow: parseOrThrow, + cmp: cmp, + add: add, + sum: sum, + sub: sub, + mult: mult, + check: check, + getZero: getZero, + isZero: isZero, + maxAmountValue: maxAmountValue, + fromFloat: fromFloat, + copy: copy, + fractionalBase: fractionalBase, +}; diff --git a/packages/taler-wallet-core/src/util/assertUnreachable.d.ts.map b/packages/taler-wallet-core/src/util/assertUnreachable.d.ts.map new file mode 100644 index 000000000..64a1ed8e8 --- /dev/null +++ b/packages/taler-wallet-core/src/util/assertUnreachable.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"assertUnreachable.d.ts","sourceRoot":"","sources":["assertUnreachable.ts"],"names":[],"mappings":"AAgBA,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,KAAK,GAAG,KAAK,CAEjD"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/assertUnreachable.ts b/packages/taler-wallet-core/src/util/assertUnreachable.ts new file mode 100644 index 000000000..ffdf88f04 --- /dev/null +++ b/packages/taler-wallet-core/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 <http://www.gnu.org/licenses/> + */ + +export function assertUnreachable(x: never): never { + throw new Error("Didn't expect to get here"); +} diff --git a/packages/taler-wallet-core/src/util/asyncMemo.d.ts.map b/packages/taler-wallet-core/src/util/asyncMemo.d.ts.map new file mode 100644 index 000000000..0b764b61b --- /dev/null +++ b/packages/taler-wallet-core/src/util/asyncMemo.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"asyncMemo.d.ts","sourceRoot":"","sources":["asyncMemo.ts"],"names":[],"mappings":"AAsBA,qBAAa,cAAc,CAAC,CAAC;IAC3B,OAAO,CAAC,CAAC,CAAK;IACd,OAAO,CAAC,OAAO,CAAqC;IAEpD,OAAO,CAAC,OAAO;IAOf,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAiBnD,KAAK,IAAI,IAAI;CAGd;AAED,qBAAa,iBAAiB,CAAC,CAAC;IAC9B,OAAO,CAAC,CAAC,CAAK;IACd,OAAO,CAAC,SAAS,CAA2B;IAE5C,OAAO,CAAC,OAAO;IAMf,IAAI,CAAC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAkBtC,KAAK,IAAI,IAAI;CAGd"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/asyncMemo.ts b/packages/taler-wallet-core/src/util/asyncMemo.ts new file mode 100644 index 000000000..6e88081b6 --- /dev/null +++ b/packages/taler-wallet-core/src/util/asyncMemo.ts @@ -0,0 +1,87 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +interface MemoEntry<T> { + p: Promise<T>; + t: number; + n: number; +} + +export class AsyncOpMemoMap<T> { + private n = 0; + private memoMap: { [k: string]: MemoEntry<T> } = {}; + + private cleanUp(key: string, n: number): void { + const r = this.memoMap[key]; + if (r && r.n === n) { + delete this.memoMap[key]; + } + } + + memo(key: string, pg: () => Promise<T>): Promise<T> { + const res = this.memoMap[key]; + if (res) { + return res.p; + } + const n = this.n++; + // Wrap the operation in case it immediately throws + const p = Promise.resolve().then(() => pg()); + this.memoMap[key] = { + p, + n, + t: new Date().getTime(), + }; + return p.finally(() => { + this.cleanUp(key, n); + }); + } + clear(): void { + this.memoMap = {}; + } +} + +export class AsyncOpMemoSingle<T> { + private n = 0; + private memoEntry: MemoEntry<T> | undefined; + + private cleanUp(n: number): void { + if (this.memoEntry && this.memoEntry.n === n) { + this.memoEntry = undefined; + } + } + + memo(pg: () => Promise<T>): Promise<T> { + const res = this.memoEntry; + if (res) { + return res.p; + } + const n = this.n++; + // Wrap the operation in case it immediately throws + const p = Promise.resolve().then(() => pg()); + p.finally(() => { + this.cleanUp(n); + }); + this.memoEntry = { + p, + n, + t: new Date().getTime(), + }; + return p; + } + clear(): void { + this.memoEntry = undefined; + } +} diff --git a/packages/taler-wallet-core/src/util/codec-test.ts b/packages/taler-wallet-core/src/util/codec-test.ts new file mode 100644 index 000000000..b429c318c --- /dev/null +++ b/packages/taler-wallet-core/src/util/codec-test.ts @@ -0,0 +1,78 @@ +/* + This file is part of GNU Taler + (C) 2018-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 <http://www.gnu.org/licenses/> + */ + +/** + * Type-safe codecs for converting from/to JSON. + */ + +import test from "ava"; +import { + Codec, + makeCodecForObject, + makeCodecForConstString, + codecForString, + makeCodecForUnion, +} from "./codec"; + +interface MyObj { + foo: string; +} + +interface AltOne { + type: "one"; + foo: string; +} + +interface AltTwo { + type: "two"; + bar: string; +} + +type MyUnion = AltOne | AltTwo; + +test("basic codec", (t) => { + const myObjCodec = makeCodecForObject<MyObj>() + .property("foo", codecForString) + .build("MyObj"); + const res = myObjCodec.decode({ foo: "hello" }); + t.assert(res.foo === "hello"); + + t.throws(() => { + myObjCodec.decode({ foo: 123 }); + }); +}); + +test("union", (t) => { + const altOneCodec: Codec<AltOne> = makeCodecForObject<AltOne>() + .property("type", makeCodecForConstString("one")) + .property("foo", codecForString) + .build("AltOne"); + const altTwoCodec: Codec<AltTwo> = makeCodecForObject<AltTwo>() + .property("type", makeCodecForConstString("two")) + .property("bar", codecForString) + .build("AltTwo"); + const myUnionCodec: Codec<MyUnion> = makeCodecForUnion<MyUnion>() + .discriminateOn("type") + .alternative("one", altOneCodec) + .alternative("two", altTwoCodec) + .build<MyUnion>("MyUnion"); + + const res = myUnionCodec.decode({ type: "one", foo: "bla" }); + t.is(res.type, "one"); + if (res.type == "one") { + t.is(res.foo, "bla"); + } +}); diff --git a/packages/taler-wallet-core/src/util/codec.d.ts.map b/packages/taler-wallet-core/src/util/codec.d.ts.map new file mode 100644 index 000000000..4304f5cef --- /dev/null +++ b/packages/taler-wallet-core/src/util/codec.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"codec.d.ts","sourceRoot":"","sources":["codec.ts"],"names":[],"mappings":"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"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/codec.ts b/packages/taler-wallet-core/src/util/codec.ts new file mode 100644 index 000000000..2ce3c2cba --- /dev/null +++ b/packages/taler-wallet-core/src/util/codec.ts @@ -0,0 +1,406 @@ +/* + This file is part of GNU Taler + (C) 2018-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 <http://www.gnu.org/licenses/> + */ + +/** + * Type-safe codecs for converting from/to JSON. + */ + +/* eslint-disable @typescript-eslint/ban-types */ + +/** + * Error thrown when decoding fails. + */ +export class DecodingError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, DecodingError.prototype); + this.name = "DecodingError"; + } +} + +/** + * Context information to show nicer error messages when decoding fails. + */ +export interface Context { + readonly path?: string[]; +} + +export function renderContext(c?: Context): string { + const p = c?.path; + if (p) { + return p.join("."); + } else { + return "(unknown)"; + } +} + +function joinContext(c: Context | undefined, part: string): Context { + const path = c?.path ?? []; + return { + path: path.concat([part]), + }; +} + +/** + * A codec converts untyped JSON to a typed object. + */ +export interface Codec<V> { + /** + * Decode untyped JSON to an object of type [[V]]. + */ + readonly decode: (x: any, c?: Context) => V; +} + +type SingletonRecord<K extends keyof any, V> = { [Y in K]: V }; + +interface Prop { + name: string; + codec: Codec<any>; +} + +interface Alternative { + tagValue: any; + codec: Codec<any>; +} + +class ObjectCodecBuilder<OutputType, PartialOutputType> { + private propList: Prop[] = []; + + /** + * Define a property for the object. + */ + property<K extends keyof OutputType & string, V extends OutputType[K]>( + x: K, + codec: Codec<V>, + ): ObjectCodecBuilder<OutputType, PartialOutputType & SingletonRecord<K, V>> { + if (!codec) { + throw Error("inner codec must be defined"); + } + this.propList.push({ name: x, codec: codec }); + return this as any; + } + + /** + * Return the built codec. + * + * @param objectDisplayName name of the object that this codec operates on, + * used in error messages. + */ + build(objectDisplayName: string): Codec<PartialOutputType> { + const propList = this.propList; + return { + decode(x: any, c?: Context): PartialOutputType { + if (!c) { + c = { + path: [`(${objectDisplayName})`], + }; + } + if (typeof x !== "object") { + throw new DecodingError( + `expected object for ${objectDisplayName} at ${renderContext( + c, + )} but got ${typeof x}`, + ); + } + const obj: any = {}; + for (const prop of propList) { + const propRawVal = x[prop.name]; + const propVal = prop.codec.decode( + propRawVal, + joinContext(c, prop.name), + ); + obj[prop.name] = propVal; + } + return obj as PartialOutputType; + }, + }; + } +} + +class UnionCodecBuilder< + TargetType, + TagPropertyLabel extends keyof TargetType, + CommonBaseType, + PartialTargetType +> { + private alternatives = new Map<any, Alternative>(); + + constructor( + private discriminator: TagPropertyLabel, + private baseCodec?: Codec<CommonBaseType>, + ) {} + + /** + * Define a property for the object. + */ + alternative<V>( + tagValue: TargetType[TagPropertyLabel], + codec: Codec<V>, + ): UnionCodecBuilder< + TargetType, + TagPropertyLabel, + CommonBaseType, + PartialTargetType | V + > { + if (!codec) { + throw Error("inner codec must be defined"); + } + this.alternatives.set(tagValue, { codec, tagValue }); + return this as any; + } + + /** + * Return the built codec. + * + * @param objectDisplayName name of the object that this codec operates on, + * used in error messages. + */ + build<R extends PartialTargetType & CommonBaseType = never>( + objectDisplayName: string, + ): Codec<R> { + const alternatives = this.alternatives; + const discriminator = this.discriminator; + const baseCodec = this.baseCodec; + return { + decode(x: any, c?: Context): R { + if (!c) { + c = { + path: [`(${objectDisplayName})`], + }; + } + const d = x[discriminator]; + if (d === undefined) { + throw new DecodingError( + `expected tag for ${objectDisplayName} at ${renderContext( + c, + )}.${discriminator}`, + ); + } + const alt = alternatives.get(d); + if (!alt) { + throw new DecodingError( + `unknown tag for ${objectDisplayName} ${d} at ${renderContext( + c, + )}.${discriminator}`, + ); + } + const altDecoded = alt.codec.decode(x); + if (baseCodec) { + const baseDecoded = baseCodec.decode(x, c); + return { ...baseDecoded, ...altDecoded }; + } else { + return altDecoded; + } + }, + }; + } +} + +export class UnionCodecPreBuilder<T> { + discriminateOn<D extends keyof T, B = {}>( + discriminator: D, + baseCodec?: Codec<B>, + ): UnionCodecBuilder<T, D, B, never> { + return new UnionCodecBuilder<T, D, B, never>(discriminator, baseCodec); + } +} + +/** + * Return a builder for a codec that decodes an object with properties. + */ +export function makeCodecForObject<T>(): ObjectCodecBuilder<T, {}> { + return new ObjectCodecBuilder<T, {}>(); +} + +export function makeCodecForUnion<T>(): UnionCodecPreBuilder<T> { + return new UnionCodecPreBuilder<T>(); +} + +/** + * Return a codec for a mapping from a string to values described by the inner codec. + */ +export function makeCodecForMap<T>( + innerCodec: Codec<T>, +): Codec<{ [x: string]: T }> { + if (!innerCodec) { + throw Error("inner codec must be defined"); + } + return { + decode(x: any, c?: Context): { [x: string]: T } { + const map: { [x: string]: T } = {}; + if (typeof x !== "object") { + throw new DecodingError(`expected object at ${renderContext(c)}`); + } + for (const i in x) { + map[i] = innerCodec.decode(x[i], joinContext(c, `[${i}]`)); + } + return map; + }, + }; +} + +/** + * Return a codec for a list, containing values described by the inner codec. + */ +export function makeCodecForList<T>(innerCodec: Codec<T>): Codec<T[]> { + if (!innerCodec) { + throw Error("inner codec must be defined"); + } + return { + decode(x: any, c?: Context): T[] { + const arr: T[] = []; + if (!Array.isArray(x)) { + throw new DecodingError(`expected array at ${renderContext(c)}`); + } + for (const i in x) { + arr.push(innerCodec.decode(x[i], joinContext(c, `[${i}]`))); + } + return arr; + }, + }; +} + +/** + * Return a codec for a value that must be a number. + */ +export const codecForNumber: Codec<number> = { + decode(x: any, c?: Context): number { + if (typeof x === "number") { + return x; + } + throw new DecodingError( + `expected number at ${renderContext(c)} but got ${typeof x}`, + ); + }, +}; + +/** + * Return a codec for a value that must be a number. + */ +export const codecForBoolean: Codec<boolean> = { + decode(x: any, c?: Context): boolean { + if (typeof x === "boolean") { + return x; + } + throw new DecodingError( + `expected boolean at ${renderContext(c)} but got ${typeof x}`, + ); + }, +}; + +/** + * Return a codec for a value that must be a string. + */ +export const codecForString: Codec<string> = { + decode(x: any, c?: Context): string { + if (typeof x === "string") { + return x; + } + throw new DecodingError( + `expected string at ${renderContext(c)} but got ${typeof x}`, + ); + }, +}; + +/** + * Codec that allows any value. + */ +export const codecForAny: Codec<any> = { + decode(x: any, c?: Context): any { + return x; + }, +}; + +/** + * Return a codec for a value that must be a string. + */ +export function makeCodecForConstString<V extends string>(s: V): Codec<V> { + return { + decode(x: any, c?: Context): V { + if (x === s) { + return x; + } + throw new DecodingError( + `expected string constant "${s}" at ${renderContext( + c, + )} but got ${typeof x}`, + ); + }, + }; +} + +/** + * Return a codec for a boolean true constant. + */ +export function makeCodecForConstTrue(): Codec<true> { + return { + decode(x: any, c?: Context): true { + if (x === true) { + return x; + } + throw new DecodingError( + `expected boolean true at ${renderContext(c)} but got ${typeof x}`, + ); + }, + }; +} + +/** + * Return a codec for a boolean true constant. + */ +export function makeCodecForConstFalse(): Codec<false> { + return { + decode(x: any, c?: Context): false { + if (x === false) { + return x; + } + throw new DecodingError( + `expected boolean false at ${renderContext(c)} but got ${typeof x}`, + ); + }, + }; +} + +/** + * Return a codec for a value that must be a constant number. + */ +export function makeCodecForConstNumber<V extends number>(n: V): Codec<V> { + return { + decode(x: any, c?: Context): V { + if (x === n) { + return x; + } + throw new DecodingError( + `expected number constant "${n}" at ${renderContext( + c, + )} but got ${typeof x}`, + ); + }, + }; +} + +export function makeCodecOptional<V>( + innerCodec: Codec<V>, +): Codec<V | undefined> { + return { + decode(x: any, c?: Context): V | undefined { + if (x === undefined || x === null) { + return undefined; + } + return innerCodec.decode(x, c); + }, + }; +} diff --git a/packages/taler-wallet-core/src/util/helpers-test.ts b/packages/taler-wallet-core/src/util/helpers-test.ts new file mode 100644 index 000000000..dbecf14b8 --- /dev/null +++ b/packages/taler-wallet-core/src/util/helpers-test.ts @@ -0,0 +1,46 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +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"), + ); + + // Remove search component + t.is( + "http://alice.example.com/exchange/", + helpers.canonicalizeBaseUrl("http://alice.example.com/exchange?foo=bar"), + ); + + t.pass(); +}); diff --git a/packages/taler-wallet-core/src/util/helpers.d.ts.map b/packages/taler-wallet-core/src/util/helpers.d.ts.map new file mode 100644 index 000000000..789c5c81c --- /dev/null +++ b/packages/taler-wallet-core/src/util/helpers.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["helpers.ts"],"names":[],"mappings":"AAgBA;;GAEG;AAEH;;GAEG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAIvC;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAGzD;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAWvD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAwB9C;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,GAAG,OAAO,CAclD;AAED,wBAAgB,QAAQ,CAAC,CAAC,EAAE,GAAG,GAAG,GAAG,CAGpC;AAED;;;GAGG;AACH,wBAAgB,OAAO,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,EAAE,CAE5D;AAED;;GAEG;AACH,wBAAgB,IAAI,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAarC;AAED;;GAEG;AACH,wBAAgB,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAQrD"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/helpers.ts b/packages/taler-wallet-core/src/util/helpers.ts new file mode 100644 index 000000000..ae4b0359e --- /dev/null +++ b/packages/taler-wallet-core/src/util/helpers.ts @@ -0,0 +1,148 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Small helper functions that don't fit anywhere else. + */ + +/** + * Imports. + */ +import { AmountJson } from "./amounts"; +import * as Amounts from "./amounts"; +import { URL } from "./url"; + +/** + * 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): 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])) + ); +} + +export function deepCopy(x: any): any { + // FIXME: this has many issues ... + return JSON.parse(JSON.stringify(x)); +} + +/** + * Map from a collection to a list or results and then + * concatenate the results. + */ +export function flatMap<T, U>(xs: T[], f: (x: T) => U[]): U[] { + return xs.reduce((acc: U[], next: T) => [...f(next), ...acc], []); +} + +/** + * 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/packages/taler-wallet-core/src/util/http.d.ts.map b/packages/taler-wallet-core/src/util/http.d.ts.map new file mode 100644 index 000000000..edbe41970 --- /dev/null +++ b/packages/taler-wallet-core/src/util/http.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["http.ts"],"names":[],"mappings":"AAgBA;;;GAGG;AAEH;;GAEG;AACH,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAOhC;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,CAAC;IACrB,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;CACzB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,EAAE;QAAE,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACtC;AAED,oBAAY,kBAAkB;IAC5B,EAAE,MAAM;IACR,IAAI,MAAM;CACX;AAED;;GAEG;AACH,qBAAa,OAAO;IAClB,OAAO,CAAC,SAAS,CAA6B;IAE9C,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAQhC,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;CASvC;AAED;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IAElE;;OAEG;IACH,QAAQ,CACN,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,GAAG,EACT,GAAG,CAAC,EAAE,kBAAkB,GACvB,OAAO,CAAC,YAAY,CAAC,CAAC;CAC1B;AAED,aAAK,kBAAkB,GAAG;IACxB,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC;AAEZ,aAAK,eAAe,CAAC,CAAC,IAClB;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,QAAQ,EAAE,CAAC,CAAA;CAAE,GAC/B;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,kBAAkB,EAAE,kBAAkB,CAAA;CAAE,CAAC;AAE9D,wBAAsB,kCAAkC,CAAC,CAAC,EACxD,YAAY,EAAE,YAAY,EAC1B,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,GACd,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,CAuC7B;AAED,wBAAgB,2BAA2B,CACzC,YAAY,EAAE,YAAY,EAC1B,kBAAkB,EAAE,kBAAkB,GACrC,KAAK,CAYP;AAED,wBAAsB,8BAA8B,CAAC,CAAC,EACpD,YAAY,EAAE,YAAY,EAC1B,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,GACd,OAAO,CAAC,CAAC,CAAC,CAMZ;AAGD,wBAAsB,kCAAkC,CAAC,CAAC,EACxD,YAAY,EAAE,YAAY,GACzB,OAAO,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC,CAyBlC;AAED,wBAAsB,2BAA2B,CAC/C,YAAY,EAAE,YAAY,GACzB,OAAO,CAAC,IAAI,CAAC,CAiBf;AAED,wBAAsB,8BAA8B,CAAC,CAAC,EACpD,YAAY,EAAE,YAAY,GACzB,OAAO,CAAC,MAAM,CAAC,CAMjB"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/http.ts b/packages/taler-wallet-core/src/util/http.ts new file mode 100644 index 000000000..ad9f0293c --- /dev/null +++ b/packages/taler-wallet-core/src/util/http.ts @@ -0,0 +1,237 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Helpers for doing XMLHttpRequest-s that are based on ES6 promises. + * Allows for easy mocking for test cases. + */ + +/** + * Imports + */ +import { Codec } from "./codec"; +import { OperationFailedError, makeErrorDetails } from "../operations/errors"; +import { TalerErrorCode } from "../TalerErrorCode"; +import { Logger } from "./logging"; + +const logger = new Logger("http.ts"); + +/** + * An HTTP response that is returned by all request methods of this library. + */ +export interface HttpResponse { + requestUrl: string; + status: number; + headers: Headers; + json(): Promise<any>; + text(): Promise<string>; +} + +export interface HttpRequestOptions { + headers?: { [name: string]: string }; +} + +export enum HttpResponseStatus { + Ok = 200, + Gone = 210, +} + +/** + * Headers, roughly modeled after the fetch API's headers object. + */ +export class Headers { + private headerMap = new Map<string, string>(); + + get(name: string): string | null { + const r = this.headerMap.get(name.toLowerCase()); + if (r) { + return r; + } + return null; + } + + set(name: string, value: string): void { + const normalizedName = name.toLowerCase(); + const existing = this.headerMap.get(normalizedName); + if (existing !== undefined) { + this.headerMap.set(normalizedName, existing + "," + value); + } else { + this.headerMap.set(normalizedName, value); + } + } +} + +/** + * Interface for the HTTP request library used by the wallet. + * + * The request library is bundled into an interface to make mocking and + * request tunneling easy. + */ +export interface HttpRequestLibrary { + /** + * Make an HTTP GET request. + */ + get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse>; + + /** + * Make an HTTP POST request with a JSON body. + */ + postJson( + url: string, + body: any, + opt?: HttpRequestOptions, + ): Promise<HttpResponse>; +} + +type TalerErrorResponse = { + code: number; +} & unknown; + +type ResponseOrError<T> = + | { isError: false; response: T } + | { isError: true; talerErrorResponse: TalerErrorResponse }; + +export async function readSuccessResponseJsonOrErrorCode<T>( + httpResponse: HttpResponse, + codec: Codec<T>, +): Promise<ResponseOrError<T>> { + if (!(httpResponse.status >= 200 && httpResponse.status < 300)) { + const errJson = await httpResponse.json(); + const talerErrorCode = errJson.code; + if (typeof talerErrorCode !== "number") { + throw new OperationFailedError( + makeErrorDetails( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + "Error response did not contain error code", + { + requestUrl: httpResponse.requestUrl, + }, + ), + ); + } + return { + isError: true, + talerErrorResponse: errJson, + }; + } + const respJson = await httpResponse.json(); + let parsedResponse: T; + try { + parsedResponse = codec.decode(respJson); + } catch (e) { + throw OperationFailedError.fromCode( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + "Response invalid", + { + requestUrl: httpResponse.requestUrl, + httpStatusCode: httpResponse.status, + validationError: e.toString(), + }, + ); + } + return { + isError: false, + response: parsedResponse, + }; +} + +export function throwUnexpectedRequestError( + httpResponse: HttpResponse, + talerErrorResponse: TalerErrorResponse, +): never { + throw new OperationFailedError( + makeErrorDetails( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + "Unexpected error code in response", + { + requestUrl: httpResponse.requestUrl, + httpStatusCode: httpResponse.status, + errorResponse: talerErrorResponse, + }, + ), + ); +} + +export async function readSuccessResponseJsonOrThrow<T>( + httpResponse: HttpResponse, + codec: Codec<T>, +): Promise<T> { + const r = await readSuccessResponseJsonOrErrorCode(httpResponse, codec); + if (!r.isError) { + return r.response; + } + throwUnexpectedRequestError(httpResponse, r.talerErrorResponse); +} + +export async function readSuccessResponseTextOrErrorCode<T>( + httpResponse: HttpResponse, +): Promise<ResponseOrError<string>> { + if (!(httpResponse.status >= 200 && httpResponse.status < 300)) { + const errJson = await httpResponse.json(); + const talerErrorCode = errJson.code; + if (typeof talerErrorCode !== "number") { + throw new OperationFailedError( + makeErrorDetails( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + "Error response did not contain error code", + { + requestUrl: httpResponse.requestUrl, + }, + ), + ); + } + return { + isError: true, + talerErrorResponse: errJson, + }; + } + const respJson = await httpResponse.text(); + return { + isError: false, + response: respJson, + }; +} + +export async function checkSuccessResponseOrThrow( + httpResponse: HttpResponse, +): Promise<void> { + if (!(httpResponse.status >= 200 && httpResponse.status < 300)) { + const errJson = await httpResponse.json(); + const talerErrorCode = errJson.code; + if (typeof talerErrorCode !== "number") { + throw new OperationFailedError( + makeErrorDetails( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + "Error response did not contain error code", + { + requestUrl: httpResponse.requestUrl, + }, + ), + ); + } + throwUnexpectedRequestError(httpResponse, errJson); + } +} + +export async function readSuccessResponseTextOrThrow<T>( + httpResponse: HttpResponse, +): Promise<string> { + const r = await readSuccessResponseTextOrErrorCode(httpResponse); + if (!r.isError) { + return r.response; + } + throwUnexpectedRequestError(httpResponse, r.talerErrorResponse); +} diff --git a/packages/taler-wallet-core/src/util/libtoolVersion-test.ts b/packages/taler-wallet-core/src/util/libtoolVersion-test.ts new file mode 100644 index 000000000..e58e94759 --- /dev/null +++ b/packages/taler-wallet-core/src/util/libtoolVersion-test.ts @@ -0,0 +1,48 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +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/packages/taler-wallet-core/src/util/libtoolVersion.d.ts.map b/packages/taler-wallet-core/src/util/libtoolVersion.d.ts.map new file mode 100644 index 000000000..d0e111aa1 --- /dev/null +++ b/packages/taler-wallet-core/src/util/libtoolVersion.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"libtoolVersion.d.ts","sourceRoot":"","sources":["libtoolVersion.ts"],"names":[],"mappings":"AAgBA;;;GAGG;AAEH;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC;;OAEG;IACH,UAAU,EAAE,OAAO,CAAC;IACpB;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAQD;;GAEG;AACH,wBAAgB,OAAO,CACrB,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,MAAM,GACZ,kBAAkB,GAAG,SAAS,CAehC"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/libtoolVersion.ts b/packages/taler-wallet-core/src/util/libtoolVersion.ts new file mode 100644 index 000000000..5e9d0b74e --- /dev/null +++ b/packages/taler-wallet-core/src/util/libtoolVersion.ts @@ -0,0 +1,88 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +/** + * 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/packages/taler-wallet-core/src/util/logging.d.ts.map b/packages/taler-wallet-core/src/util/logging.d.ts.map new file mode 100644 index 000000000..3e289d866 --- /dev/null +++ b/packages/taler-wallet-core/src/util/logging.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"logging.d.ts","sourceRoot":"","sources":["logging.ts"],"names":[],"mappings":"AAuCA;;;GAGG;AACH,qBAAa,MAAM;IACL,OAAO,CAAC,GAAG;gBAAH,GAAG,EAAE,MAAM;IAE/B,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI;IAW3C,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI;IAW3C,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI;IAW5C,KAAK,CAAC,OAAO,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI;CAU1C"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/logging.ts b/packages/taler-wallet-core/src/util/logging.ts new file mode 100644 index 000000000..e4f3be2ff --- /dev/null +++ b/packages/taler-wallet-core/src/util/logging.ts @@ -0,0 +1,89 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +/** + * Check if we are running under nodejs. + */ + +const isNode = + typeof process !== "undefined" && process.release.name === "node"; + +function writeNodeLog( + message: string, + tag: string, + level: string, + args: any[], +): void { + process.stderr.write(`${new Date().toISOString()} ${tag} ${level} `); + process.stderr.write(message); + if (args.length != 0) { + process.stderr.write(" "); + process.stderr.write(JSON.stringify(args, undefined, 2)); + } + process.stderr.write("\n"); +} + +/** + * Logger that writes to stderr when running under node, + * and uses the corresponding console.* method to log in the browser. + */ +export class Logger { + constructor(private tag: string) {} + + info(message: string, ...args: any[]): void { + if (isNode) { + writeNodeLog(message, this.tag, "INFO", args); + } else { + console.info( + `${new Date().toISOString()} ${this.tag} INFO ` + message, + ...args, + ); + } + } + + warn(message: string, ...args: any[]): void { + if (isNode) { + writeNodeLog(message, this.tag, "WARN", args); + } else { + console.warn( + `${new Date().toISOString()} ${this.tag} INFO ` + message, + ...args, + ); + } + } + + error(message: string, ...args: any[]): void { + if (isNode) { + writeNodeLog(message, this.tag, "ERROR", args); + } else { + console.info( + `${new Date().toISOString()} ${this.tag} ERROR ` + message, + ...args, + ); + } + } + + trace(message: any, ...args: any[]): void { + if (isNode) { + writeNodeLog(message, this.tag, "TRACE", args); + } else { + console.info( + `${new Date().toISOString()} ${this.tag} TRACE ` + message, + ...args, + ); + } + } +} diff --git a/packages/taler-wallet-core/src/util/payto-test.ts b/packages/taler-wallet-core/src/util/payto-test.ts new file mode 100644 index 000000000..01280b650 --- /dev/null +++ b/packages/taler-wallet-core/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 <http://www.gnu.org/licenses/> + */ + +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"); +}); diff --git a/packages/taler-wallet-core/src/util/payto.d.ts.map b/packages/taler-wallet-core/src/util/payto.d.ts.map new file mode 100644 index 000000000..a23c5f5d4 --- /dev/null +++ b/packages/taler-wallet-core/src/util/payto.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"payto.d.ts","sourceRoot":"","sources":["payto.ts"],"names":[],"mappings":"AAkBA,UAAU,QAAQ;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE;QAAE,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACpC;AAID;;GAEG;AACH,wBAAgB,mBAAmB,CACjC,CAAC,EAAE,MAAM,EACT,MAAM,EAAE;IAAE,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,GACjC,MAAM,CAOR;AAED,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CA6B7D"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/payto.ts b/packages/taler-wallet-core/src/util/payto.ts new file mode 100644 index 000000000..a1c47eb2f --- /dev/null +++ b/packages/taler-wallet-core/src/util/payto.ts @@ -0,0 +1,71 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +import { URLSearchParams } from "./url"; + +interface PaytoUri { + targetType: string; + targetPath: string; + params: { [name: string]: string }; +} + +const paytoPfx = "payto://"; + +/** + * Add query parameters to a payto URI + */ +export function addPaytoQueryParams( + s: string, + params: { [name: string]: string }, +): string { + const [acct, search] = s.slice(paytoPfx.length).split("?"); + const searchParams = new URLSearchParams(search || ""); + for (const k of Object.keys(params)) { + searchParams.set(k, params[k]); + } + return paytoPfx + acct + "?" + searchParams.toString(); +} + +export function parsePaytoUri(s: string): PaytoUri | undefined { + if (!s.startsWith(paytoPfx)) { + return undefined; + } + + const [acct, search] = s.slice(paytoPfx.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, + }; +} diff --git a/packages/taler-wallet-core/src/util/promiseUtils.d.ts.map b/packages/taler-wallet-core/src/util/promiseUtils.d.ts.map new file mode 100644 index 000000000..1ca9a4c99 --- /dev/null +++ b/packages/taler-wallet-core/src/util/promiseUtils.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"promiseUtils.d.ts","sourceRoot":"","sources":["promiseUtils.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,aAAa,CAAC,CAAC;IAC9B,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACpB,OAAO,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,IAAI,CAAC;IAC1B,MAAM,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAC;CAC5B;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,CAAC,KAAK,aAAa,CAAC,CAAC,CAAC,CAYjD;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,YAAY,CAAgB;IACpC,OAAO,CAAC,mBAAmB,CAAsB;;IAOjD,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAIrB,OAAO,IAAI,IAAI;CAMhB"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/promiseUtils.ts b/packages/taler-wallet-core/src/util/promiseUtils.ts new file mode 100644 index 000000000..d409686d9 --- /dev/null +++ b/packages/taler-wallet-core/src/util/promiseUtils.ts @@ -0,0 +1,60 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +export interface OpenedPromise<T> { + promise: Promise<T>; + resolve: (val: T) => void; + reject: (err: any) => void; +} + +/** + * Get an unresolved promise together with its extracted resolve / reject + * function. + */ +export function openPromise<T>(): OpenedPromise<T> { + let resolve: ((x?: any) => void) | null = null; + let reject: ((reason?: any) => void) | null = null; + const promise = new Promise<T>((res, rej) => { + resolve = res; + reject = rej; + }); + if (!(resolve && reject)) { + // Never happens, unless JS implementation is broken + throw Error(); + } + return { resolve, reject, promise }; +} + +export class AsyncCondition { + private _waitPromise: Promise<void>; + private _resolveWaitPromise: (val: void) => void; + constructor() { + const op = openPromise<void>(); + this._waitPromise = op.promise; + this._resolveWaitPromise = op.resolve; + } + + wait(): Promise<void> { + return this._waitPromise; + } + + trigger(): void { + this._resolveWaitPromise(); + const op = openPromise<void>(); + this._waitPromise = op.promise; + this._resolveWaitPromise = op.resolve; + } +} diff --git a/packages/taler-wallet-core/src/util/query.d.ts.map b/packages/taler-wallet-core/src/util/query.d.ts.map new file mode 100644 index 000000000..4b3fc92ea --- /dev/null +++ b/packages/taler-wallet-core/src/util/query.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["query.ts"],"names":[],"mappings":"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"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/util/query.ts new file mode 100644 index 000000000..53359752e --- /dev/null +++ b/packages/taler-wallet-core/src/util/query.ts @@ -0,0 +1,576 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Database query abstractions. + * @module Query + * @author Florian Dold + */ + +/** + * Imports. + */ +import { openPromise } from "./promiseUtils"; +import type { idbtypes } from "idb-bridge"; + +/** + * Exception that should be thrown by client code to abort a transaction. + */ +export const TransactionAbort = Symbol("transaction_abort"); + +/** + * Definition of an object store. + */ +export class Store<T> { + constructor( + public name: string, + public storeParams?: idbtypes.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: idbtypes.IDBRequest): Promise<any> { + 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: idbtypes.IDBTransaction): Promise<void> { + 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); + }; + }); +} + +function applyMutation<T>( + req: idbtypes.IDBRequest, + f: (x: T) => T | undefined, +): Promise<void> { + 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: idbtypes.IDBRequest = cursor.update(modVal); + req2.onerror = () => { + reject(req2.error); + }; + req2.onsuccess = () => { + cursor.continue(); + }; + } else { + cursor.continue(); + } + } else { + resolve(); + } + }; + req.onerror = () => { + reject(req.error); + }; + }); +} + +type CursorResult<T> = CursorEmptyResult<T> | CursorValueResult<T>; + +interface CursorEmptyResult<T> { + hasValue: false; +} + +interface CursorValueResult<T> { + hasValue: true; + value: T; +} + +class ResultStream<T> { + private currentPromise: Promise<void>; + private gotCursorEnd = false; + private awaitingResult = false; + + constructor(private req: idbtypes.IDBRequest) { + this.awaitingResult = true; + let p = openPromise<void>(); + 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<void>(); + this.currentPromise = p.promise; + } else { + this.gotCursorEnd = true; + p.resolve(); + } + }; + req.onerror = () => { + p.reject(req.error); + }; + } + + async toArray(): Promise<T[]> { + const arr: T[] = []; + while (true) { + const x = await this.next(); + if (x.hasValue) { + arr.push(x.value); + } else { + break; + } + } + return arr; + } + + async map<R>(f: (x: T) => R): Promise<R[]> { + const arr: R[] = []; + while (true) { + const x = await this.next(); + if (x.hasValue) { + arr.push(f(x.value)); + } else { + break; + } + } + return arr; + } + + async forEachAsync(f: (x: T) => Promise<void>): Promise<void> { + while (true) { + const x = await this.next(); + if (x.hasValue) { + await f(x.value); + } else { + break; + } + } + } + + async forEach(f: (x: T) => void): Promise<void> { + while (true) { + const x = await this.next(); + if (x.hasValue) { + f(x.value); + } else { + break; + } + } + } + + async filter(f: (x: T) => boolean): Promise<T[]> { + 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<CursorResult<T>> { + if (this.gotCursorEnd) { + return { hasValue: false }; + } + if (!this.awaitingResult) { + const cursor: idbtypes.IDBCursor | undefined = 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 class TransactionHandle { + constructor(private tx: idbtypes.IDBTransaction) {} + + put<T>(store: Store<T>, value: T, key?: any): Promise<any> { + const req = this.tx.objectStore(store.name).put(value, key); + return requestToPromise(req); + } + + add<T>(store: Store<T>, value: T, key?: any): Promise<any> { + const req = this.tx.objectStore(store.name).add(value, key); + return requestToPromise(req); + } + + get<T>(store: Store<T>, key: any): Promise<T | undefined> { + const req = this.tx.objectStore(store.name).get(key); + return requestToPromise(req); + } + + getIndexed<S extends idbtypes.IDBValidKey, T>( + index: Index<S, T>, + key: any, + ): Promise<T | undefined> { + const req = this.tx + .objectStore(index.storeName) + .index(index.indexName) + .get(key); + return requestToPromise(req); + } + + iter<T>(store: Store<T>, key?: any): ResultStream<T> { + const req = this.tx.objectStore(store.name).openCursor(key); + return new ResultStream<T>(req); + } + + iterIndexed<S extends idbtypes.IDBValidKey, T>( + index: Index<S, T>, + key?: any, + ): ResultStream<T> { + const req = this.tx + .objectStore(index.storeName) + .index(index.indexName) + .openCursor(key); + return new ResultStream<T>(req); + } + + delete<T>(store: Store<T>, key: any): Promise<void> { + const req = this.tx.objectStore(store.name).delete(key); + return requestToPromise(req); + } + + mutate<T>( + store: Store<T>, + key: any, + f: (x: T) => T | undefined, + ): Promise<void> { + const req = this.tx.objectStore(store.name).openCursor(key); + return applyMutation(req, f); + } +} + +function runWithTransaction<T>( + db: idbtypes.IDBDatabase, + stores: Store<any>[], + f: (t: TransactionHandle) => Promise<T>, + mode: "readonly" | "readwrite", +): Promise<T> { + 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, mode); + let funResult: any = undefined; + let gotFunResult = 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"); + console.error(stack); + }; + 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 = Promise.resolve().then(() => f(th)); + resP + .then((result) => { + gotFunResult = true; + funResult = result; + }) + .catch((e) => { + if (e == TransactionAbort) { + console.info("aborting transaction"); + } else { + console.error("Transaction failed:", e); + console.error(stack); + tx.abort(); + } + }) + .catch((e) => { + console.error("fatal: aborting transaction failed", e); + }); + }); +} + +/** + * Definition of an index. + */ +export class Index<S extends idbtypes.IDBValidKey, T> { + /** + * Name of the store that this index is associated with. + */ + storeName: string; + + /** + * Options to use for the index. + */ + options: IndexOptions; + + constructor( + s: Store<T>, + 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; +} + +/** + * Return a promise that resolves + * to the taler wallet db. + */ +export function openDatabase( + idbFactory: idbtypes.IDBFactory, + databaseName: string, + databaseVersion: number, + onVersionChange: () => void, + onUpgradeNeeded: ( + db: idbtypes.IDBDatabase, + oldVersion: number, + newVersion: number, + ) => void, +): Promise<idbtypes.IDBDatabase> { + return new Promise<idbtypes.IDBDatabase>((resolve, reject) => { + const req = idbFactory.open(databaseName, databaseVersion); + req.onerror = (e) => { + console.log("taler database error", e); + reject(new Error("database error")); + }; + req.onsuccess = (e) => { + req.result.onversionchange = (evt: idbtypes.IDBVersionChangeEvent) => { + console.log( + `handling live db version change from ${evt.oldVersion} to ${evt.newVersion}`, + ); + req.result.close(); + onVersionChange(); + }; + resolve(req.result); + }; + req.onupgradeneeded = (e) => { + const db = req.result; + const newVersion = e.newVersion; + if (!newVersion) { + throw Error("upgrade needed, but new version unknown"); + } + onUpgradeNeeded(db, e.oldVersion, newVersion); + }; + }); +} + +export class Database { + constructor(private db: idbtypes.IDBDatabase) {} + + static deleteDatabase(idbFactory: idbtypes.IDBFactory, dbName: string): void { + idbFactory.deleteDatabase(dbName); + } + + async exportDatabase(): Promise<any> { + const db = this.db; + const dump = { + name: db.name, + stores: {} as { [s: string]: any }, + version: db.version, + }; + + return new Promise((resolve, reject) => { + const tx = db.transaction(Array.from(db.objectStoreNames)); + tx.addEventListener("complete", () => { + resolve(dump); + }); + // tslint:disable-next-line:prefer-for-of + for (let i = 0; i < db.objectStoreNames.length; i++) { + const name = db.objectStoreNames[i]; + const storeDump = {} as { [s: string]: any }; + dump.stores[name] = storeDump; + tx.objectStore(name) + .openCursor() + .addEventListener("success", (e: idbtypes.Event) => { + const cursor = (e.target as any).result; + if (cursor) { + storeDump[cursor.key] = cursor.value; + cursor.continue(); + } + }); + } + }); + } + + importDatabase(dump: any): Promise<void> { + const db = this.db; + console.log("importing db", dump); + return new Promise<void>((resolve, reject) => { + const tx = db.transaction(Array.from(db.objectStoreNames), "readwrite"); + if (dump.stores) { + for (const storeName in dump.stores) { + const objects = []; + const dumpStore = dump.stores[storeName]; + for (const key in dumpStore) { + objects.push(dumpStore[key]); + } + console.log(`importing ${objects.length} records into ${storeName}`); + const store = tx.objectStore(storeName); + for (const obj of objects) { + store.put(obj); + } + } + } + tx.addEventListener("complete", () => { + resolve(); + }); + }); + } + + async get<T>(store: Store<T>, key: any): Promise<T | undefined> { + const tx = this.db.transaction([store.name], "readonly"); + const req = tx.objectStore(store.name).get(key); + const v = await requestToPromise(req); + await transactionToPromise(tx); + return v; + } + + async getIndexed<S extends idbtypes.IDBValidKey, T>( + index: Index<S, T>, + key: any, + ): Promise<T | undefined> { + const tx = this.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; + } + + async put<T>(store: Store<T>, value: T, key?: any): Promise<any> { + const tx = this.db.transaction([store.name], "readwrite"); + const req = tx.objectStore(store.name).put(value, key); + const v = await requestToPromise(req); + await transactionToPromise(tx); + return v; + } + + async mutate<T>( + store: Store<T>, + key: any, + f: (x: T) => T | undefined, + ): Promise<void> { + const tx = this.db.transaction([store.name], "readwrite"); + const req = tx.objectStore(store.name).openCursor(key); + await applyMutation(req, f); + await transactionToPromise(tx); + } + + iter<T>(store: Store<T>): ResultStream<T> { + const tx = this.db.transaction([store.name], "readonly"); + const req = tx.objectStore(store.name).openCursor(); + return new ResultStream<T>(req); + } + + iterIndex<S extends idbtypes.IDBValidKey, T>( + index: Index<S, T>, + query?: any, + ): ResultStream<T> { + const tx = this.db.transaction([index.storeName], "readonly"); + const req = tx + .objectStore(index.storeName) + .index(index.indexName) + .openCursor(query); + return new ResultStream<T>(req); + } + + async runWithReadTransaction<T>( + stores: Store<any>[], + f: (t: TransactionHandle) => Promise<T>, + ): Promise<T> { + return runWithTransaction<T>(this.db, stores, f, "readonly"); + } + + async runWithWriteTransaction<T>( + stores: Store<any>[], + f: (t: TransactionHandle) => Promise<T>, + ): Promise<T> { + return runWithTransaction<T>(this.db, stores, f, "readwrite"); + } +} diff --git a/packages/taler-wallet-core/src/util/reserveHistoryUtil-test.ts b/packages/taler-wallet-core/src/util/reserveHistoryUtil-test.ts new file mode 100644 index 000000000..79022de77 --- /dev/null +++ b/packages/taler-wallet-core/src/util/reserveHistoryUtil-test.ts @@ -0,0 +1,285 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import test from "ava"; +import { + reconcileReserveHistory, + summarizeReserveHistory, +} from "./reserveHistoryUtil"; +import { + WalletReserveHistoryItem, + WalletReserveHistoryItemType, +} from "../types/dbTypes"; +import { + ReserveTransaction, + ReserveTransactionType, +} from "../types/ReserveTransaction"; +import { Amounts } from "./amounts"; + +test("basics", (t) => { + const r = reconcileReserveHistory([], []); + t.deepEqual(r.updatedLocalHistory, []); +}); + +test("unmatched credit", (t) => { + const localHistory: WalletReserveHistoryItem[] = []; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 1); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:100"); + t.deepEqual(Amounts.stringify(s.awaitedReserveAmount), "TESTKUDOS:0"); + t.deepEqual(Amounts.stringify(s.unclaimedReserveAmount), "TESTKUDOS:100"); +}); + +test("unmatched credit #2", (t) => { + const localHistory: WalletReserveHistoryItem[] = []; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:50", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC02", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:150"); + t.deepEqual(Amounts.stringify(s.awaitedReserveAmount), "TESTKUDOS:0"); + t.deepEqual(Amounts.stringify(s.unclaimedReserveAmount), "TESTKUDOS:150"); +}); + +test("matched credit", (t) => { + const localHistory: WalletReserveHistoryItem[] = [ + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:100"), + matchedExchangeTransaction: { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + }, + ]; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:50", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC02", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:150"); + t.deepEqual(Amounts.stringify(s.awaitedReserveAmount), "TESTKUDOS:0"); + t.deepEqual(Amounts.stringify(s.unclaimedReserveAmount), "TESTKUDOS:150"); +}); + +test("fulfilling credit", (t) => { + const localHistory: WalletReserveHistoryItem[] = [ + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:100"), + }, + ]; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:50", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC02", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:150"); +}); + +test("unfulfilled credit", (t) => { + const localHistory: WalletReserveHistoryItem[] = [ + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:100"), + }, + ]; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:50", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC02", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:150"); +}); + +test("awaited credit", (t) => { + const localHistory: WalletReserveHistoryItem[] = [ + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:50"), + }, + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:100"), + }, + ]; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:100"); + t.deepEqual(Amounts.stringify(s.awaitedReserveAmount), "TESTKUDOS:50"); + t.deepEqual(Amounts.stringify(s.unclaimedReserveAmount), "TESTKUDOS:100"); +}); + +test("withdrawal new match", (t) => { + const localHistory: WalletReserveHistoryItem[] = [ + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:100"), + matchedExchangeTransaction: { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + }, + { + type: WalletReserveHistoryItemType.Withdraw, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:5"), + }, + ]; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + { + type: ReserveTransactionType.Withdraw, + amount: "TESTKUDOS:5", + h_coin_envelope: "foobar", + h_denom_pub: "foobar", + reserve_sig: "foobar", + withdraw_fee: "TESTKUDOS:0.1", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:95"); + t.deepEqual(Amounts.stringify(s.awaitedReserveAmount), "TESTKUDOS:0"); + t.deepEqual(Amounts.stringify(s.unclaimedReserveAmount), "TESTKUDOS:95"); +}); + +test("claimed but now arrived", (t) => { + const localHistory: WalletReserveHistoryItem[] = [ + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:100"), + matchedExchangeTransaction: { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + }, + { + type: WalletReserveHistoryItemType.Withdraw, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:5"), + }, + ]; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:100"); + t.deepEqual(Amounts.stringify(s.awaitedReserveAmount), "TESTKUDOS:0"); + t.deepEqual(Amounts.stringify(s.unclaimedReserveAmount), "TESTKUDOS:95"); +}); diff --git a/packages/taler-wallet-core/src/util/reserveHistoryUtil.d.ts.map b/packages/taler-wallet-core/src/util/reserveHistoryUtil.d.ts.map new file mode 100644 index 000000000..aec8f0715 --- /dev/null +++ b/packages/taler-wallet-core/src/util/reserveHistoryUtil.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"reserveHistoryUtil.d.ts","sourceRoot":"","sources":["reserveHistoryUtil.ts"],"names":[],"mappings":"AAgBA;;GAEG;AACH,OAAO,EACL,wBAAwB,EAEzB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,kBAAkB,EAEnB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,OAAO,MAAM,iBAAiB,CAAC;AAK3C;;;;GAIG;AAEH,MAAM,WAAW,2BAA2B;IAC1C;;OAEG;IACH,mBAAmB,EAAE,wBAAwB,EAAE,CAAC;IAEhD;;;OAGG;IACH,aAAa,EAAE,wBAAwB,EAAE,CAAC;IAE1C;;;OAGG;IACH,eAAe,EAAE,wBAAwB,EAAE,CAAC;CAC7C;AAED;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,sBAAsB,EAAE,OAAO,CAAC,UAAU,CAAC;IAE3C;;OAEG;IACH,sBAAsB,EAAE,OAAO,CAAC,UAAU,CAAC;IAE3C;;OAEG;IACH,oBAAoB,EAAE,OAAO,CAAC,UAAU,CAAC;IAEzC;;;OAGG;IACH,eAAe,EAAE,OAAO,CAAC,UAAU,CAAC;CACrC;AA6BD;;GAEG;AACH,wBAAgB,yBAAyB,CACvC,EAAE,EAAE,wBAAwB,EAC5B,EAAE,EAAE,kBAAkB,GACrB,OAAO,CAwBT;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CACrC,YAAY,EAAE,wBAAwB,EAAE,EACxC,QAAQ,EAAE,MAAM,GACf,qBAAqB,CAmFvB;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,YAAY,EAAE,wBAAwB,EAAE,EACxC,aAAa,EAAE,kBAAkB,EAAE,GAClC,2BAA2B,CAqH7B"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/reserveHistoryUtil.ts b/packages/taler-wallet-core/src/util/reserveHistoryUtil.ts new file mode 100644 index 000000000..855b71a3d --- /dev/null +++ b/packages/taler-wallet-core/src/util/reserveHistoryUtil.ts @@ -0,0 +1,360 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { + WalletReserveHistoryItem, + WalletReserveHistoryItemType, +} from "../types/dbTypes"; +import { + ReserveTransaction, + ReserveTransactionType, +} from "../types/ReserveTransaction"; +import * as Amounts from "../util/amounts"; +import { timestampCmp } from "./time"; +import { deepCopy } from "./helpers"; +import { AmountJson } from "../util/amounts"; + +/** + * Helpers for dealing with reserve histories. + * + * @author Florian Dold <dold@taler.net> + */ + +export interface ReserveReconciliationResult { + /** + * The wallet's local history reconciled with the exchange's reserve history. + */ + updatedLocalHistory: WalletReserveHistoryItem[]; + + /** + * History items that were newly created, subset of the + * updatedLocalHistory items. + */ + newAddedItems: WalletReserveHistoryItem[]; + + /** + * History items that were newly matched, subset of the + * updatedLocalHistory items. + */ + newMatchedItems: WalletReserveHistoryItem[]; +} + +/** + * Various totals computed from the wallet's view + * on the reserve history. + */ +export interface ReserveHistorySummary { + /** + * Balance computed by the wallet, should match the balance + * computed by the reserve. + */ + computedReserveBalance: Amounts.AmountJson; + + /** + * Reserve balance that is still available for withdrawal. + */ + unclaimedReserveAmount: Amounts.AmountJson; + + /** + * Amount that we're still expecting to come into the reserve. + */ + awaitedReserveAmount: Amounts.AmountJson; + + /** + * Amount withdrawn from the reserve so far. Only counts + * finished withdrawals, not withdrawals in progress. + */ + withdrawnAmount: Amounts.AmountJson; +} + +/** + * Check if two reserve history items (exchange's version) match. + */ +function isRemoteHistoryMatch( + t1: ReserveTransaction, + t2: ReserveTransaction, +): boolean { + switch (t1.type) { + case ReserveTransactionType.Closing: { + return t1.type === t2.type && t1.wtid == t2.wtid; + } + case ReserveTransactionType.Credit: { + return t1.type === t2.type && t1.wire_reference === t2.wire_reference; + } + case ReserveTransactionType.Recoup: { + return ( + t1.type === t2.type && + t1.coin_pub === t2.coin_pub && + timestampCmp(t1.timestamp, t2.timestamp) === 0 + ); + } + case ReserveTransactionType.Withdraw: { + return t1.type === t2.type && t1.h_coin_envelope === t2.h_coin_envelope; + } + } +} + +/** + * Check a local reserve history item and a remote history item are a match. + */ +export function isLocalRemoteHistoryMatch( + t1: WalletReserveHistoryItem, + t2: ReserveTransaction, +): boolean { + switch (t1.type) { + case WalletReserveHistoryItemType.Credit: { + return ( + t2.type === ReserveTransactionType.Credit && + !!t1.expectedAmount && + Amounts.cmp(t1.expectedAmount, Amounts.parseOrThrow(t2.amount)) === 0 + ); + } + case WalletReserveHistoryItemType.Withdraw: + return ( + t2.type === ReserveTransactionType.Withdraw && + !!t1.expectedAmount && + Amounts.cmp(t1.expectedAmount, Amounts.parseOrThrow(t2.amount)) === 0 + ); + case WalletReserveHistoryItemType.Recoup: { + return ( + t2.type === ReserveTransactionType.Recoup && + !!t1.expectedAmount && + Amounts.cmp(t1.expectedAmount, Amounts.parseOrThrow(t2.amount)) === 0 + ); + } + } + return false; +} + +/** + * Compute totals for the wallet's view of the reserve history. + */ +export function summarizeReserveHistory( + localHistory: WalletReserveHistoryItem[], + currency: string, +): ReserveHistorySummary { + const posAmounts: AmountJson[] = []; + const negAmounts: AmountJson[] = []; + const expectedPosAmounts: AmountJson[] = []; + const expectedNegAmounts: AmountJson[] = []; + const withdrawnAmounts: AmountJson[] = []; + + for (const item of localHistory) { + switch (item.type) { + case WalletReserveHistoryItemType.Credit: + if (item.matchedExchangeTransaction) { + posAmounts.push( + Amounts.parseOrThrow(item.matchedExchangeTransaction.amount), + ); + } else if (item.expectedAmount) { + expectedPosAmounts.push(item.expectedAmount); + } + break; + case WalletReserveHistoryItemType.Recoup: + if (item.matchedExchangeTransaction) { + if (item.matchedExchangeTransaction) { + posAmounts.push( + Amounts.parseOrThrow(item.matchedExchangeTransaction.amount), + ); + } else if (item.expectedAmount) { + expectedPosAmounts.push(item.expectedAmount); + } else { + throw Error("invariant failed"); + } + } + break; + case WalletReserveHistoryItemType.Closing: + if (item.matchedExchangeTransaction) { + negAmounts.push( + Amounts.parseOrThrow(item.matchedExchangeTransaction.amount), + ); + } else { + throw Error("invariant failed"); + } + break; + case WalletReserveHistoryItemType.Withdraw: + if (item.matchedExchangeTransaction) { + negAmounts.push( + Amounts.parseOrThrow(item.matchedExchangeTransaction.amount), + ); + withdrawnAmounts.push( + Amounts.parseOrThrow(item.matchedExchangeTransaction.amount), + ); + } else if (item.expectedAmount) { + expectedNegAmounts.push(item.expectedAmount); + } else { + throw Error("invariant failed"); + } + break; + } + } + + const z = Amounts.getZero(currency); + + const computedBalance = Amounts.sub( + Amounts.add(z, ...posAmounts).amount, + ...negAmounts, + ).amount; + + const unclaimedReserveAmount = Amounts.sub( + Amounts.add(z, ...posAmounts).amount, + ...negAmounts, + ...expectedNegAmounts, + ).amount; + + const awaitedReserveAmount = Amounts.sub( + Amounts.add(z, ...expectedPosAmounts).amount, + ...expectedNegAmounts, + ).amount; + + const withdrawnAmount = Amounts.add(z, ...withdrawnAmounts).amount; + + return { + computedReserveBalance: computedBalance, + unclaimedReserveAmount: unclaimedReserveAmount, + awaitedReserveAmount: awaitedReserveAmount, + withdrawnAmount, + }; +} + +/** + * Reconcile the wallet's local model of the reserve history + * with the reserve history of the exchange. + */ +export function reconcileReserveHistory( + localHistory: WalletReserveHistoryItem[], + remoteHistory: ReserveTransaction[], +): ReserveReconciliationResult { + const updatedLocalHistory: WalletReserveHistoryItem[] = deepCopy( + localHistory, + ); + const newMatchedItems: WalletReserveHistoryItem[] = []; + const newAddedItems: WalletReserveHistoryItem[] = []; + + const remoteMatched = remoteHistory.map(() => false); + const localMatched = localHistory.map(() => false); + + // Take care of deposits + + // First, see which pairs are already a definite match. + for (let remoteIndex = 0; remoteIndex < remoteHistory.length; remoteIndex++) { + const rhi = remoteHistory[remoteIndex]; + for (let localIndex = 0; localIndex < localHistory.length; localIndex++) { + if (localMatched[localIndex]) { + continue; + } + const lhi = localHistory[localIndex]; + if (!lhi.matchedExchangeTransaction) { + continue; + } + if (isRemoteHistoryMatch(rhi, lhi.matchedExchangeTransaction)) { + localMatched[localIndex] = true; + remoteMatched[remoteIndex] = true; + break; + } + } + } + + // Check that all previously matched items are still matched + for (let localIndex = 0; localIndex < localHistory.length; localIndex++) { + if (localMatched[localIndex]) { + continue; + } + const lhi = localHistory[localIndex]; + if (lhi.matchedExchangeTransaction) { + // Don't use for further matching + localMatched[localIndex] = true; + // FIXME: emit some error here! + throw Error("previously matched reserve history item now unmatched"); + } + } + + // Next, find out if there are any exact new matches between local and remote + // history items + for (let localIndex = 0; localIndex < localHistory.length; localIndex++) { + if (localMatched[localIndex]) { + continue; + } + const lhi = localHistory[localIndex]; + for ( + let remoteIndex = 0; + remoteIndex < remoteHistory.length; + remoteIndex++ + ) { + const rhi = remoteHistory[remoteIndex]; + if (remoteMatched[remoteIndex]) { + continue; + } + if (isLocalRemoteHistoryMatch(lhi, rhi)) { + localMatched[localIndex] = true; + remoteMatched[remoteIndex] = true; + updatedLocalHistory[localIndex].matchedExchangeTransaction = rhi as any; + newMatchedItems.push(lhi); + break; + } + } + } + + // Finally we add new history items + for (let remoteIndex = 0; remoteIndex < remoteHistory.length; remoteIndex++) { + if (remoteMatched[remoteIndex]) { + continue; + } + const rhi = remoteHistory[remoteIndex]; + let newItem: WalletReserveHistoryItem; + switch (rhi.type) { + case ReserveTransactionType.Closing: { + newItem = { + type: WalletReserveHistoryItemType.Closing, + matchedExchangeTransaction: rhi, + }; + break; + } + case ReserveTransactionType.Credit: { + newItem = { + type: WalletReserveHistoryItemType.Credit, + matchedExchangeTransaction: rhi, + }; + break; + } + case ReserveTransactionType.Recoup: { + newItem = { + type: WalletReserveHistoryItemType.Recoup, + matchedExchangeTransaction: rhi, + }; + break; + } + case ReserveTransactionType.Withdraw: { + newItem = { + type: WalletReserveHistoryItemType.Withdraw, + matchedExchangeTransaction: rhi, + }; + break; + } + } + updatedLocalHistory.push(newItem); + newAddedItems.push(newItem); + } + + return { + updatedLocalHistory, + newAddedItems, + newMatchedItems, + }; +} diff --git a/packages/taler-wallet-core/src/util/talerconfig.ts b/packages/taler-wallet-core/src/util/talerconfig.ts new file mode 100644 index 000000000..ec08c352f --- /dev/null +++ b/packages/taler-wallet-core/src/util/talerconfig.ts @@ -0,0 +1,120 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Utilities to handle Taler-style configuration files. + * + * @author Florian Dold <dold@taler.net> + */ + +/** + * Imports + */ +import { AmountJson } from "./amounts"; +import * as Amounts from "./amounts"; + +export class ConfigError extends Error { + constructor(message: string) { + super(); + Object.setPrototypeOf(this, ConfigError.prototype); + this.name = "ConfigError"; + this.message = message; + } +} + +type OptionMap = { [optionName: string]: string }; +type SectionMap = { [sectionName: string]: OptionMap }; + +export class ConfigValue<T> { + constructor( + private sectionName: string, + private optionName: string, + private val: string | undefined, + private converter: (x: string) => T, + ) {} + + required(): T { + if (!this.val) { + throw new ConfigError( + `required option [${this.sectionName}]/${this.optionName} not found`, + ); + } + return this.converter(this.val); + } +} + +export class Configuration { + private sectionMap: SectionMap = {}; + + loadFromString(s: string): void { + const reComment = /^\s*#.*$/; + const reSection = /^\s*\[\s*([^\]]*)\s*\]\s*$/; + const reParam = /^\s*([^=]+?)\s*=\s*(.*?)\s*$/; + const reEmptyLine = /^\s*$/; + + let currentSection: string | undefined = undefined; + + const lines = s.split("\n"); + for (const line of lines) { + console.log("parsing line", JSON.stringify(line)); + if (reEmptyLine.test(line)) { + continue; + } + if (reComment.test(line)) { + continue; + } + const secMatch = line.match(reSection); + if (secMatch) { + currentSection = secMatch[1]; + console.log("setting section to", currentSection); + continue; + } + if (currentSection === undefined) { + throw Error("invalid configuration, expected section header"); + } + const paramMatch = line.match(reParam); + if (paramMatch) { + const optName = paramMatch[1]; + let val = paramMatch[2]; + if (val.startsWith('"') && val.endsWith('"')) { + val = val.slice(1, val.length - 1); + } + const sec = this.sectionMap[currentSection] ?? {}; + this.sectionMap[currentSection] = Object.assign(sec, { + [optName]: val, + }); + continue; + } + throw Error( + "invalid configuration, expected section header or option assignment", + ); + } + + console.log("parsed config", JSON.stringify(this.sectionMap, undefined, 2)); + } + + getString(section: string, option: string): ConfigValue<string> { + const val = (this.sectionMap[section] ?? {})[option]; + return new ConfigValue(section, option, val, (x) => x); + } + + getAmount(section: string, option: string): ConfigValue<AmountJson> { + const val = (this.sectionMap[section] ?? {})[option]; + return new ConfigValue(section, option, val, (x) => + Amounts.parseOrThrow(x), + ); + } +} diff --git a/packages/taler-wallet-core/src/util/taleruri-test.ts b/packages/taler-wallet-core/src/util/taleruri-test.ts new file mode 100644 index 000000000..b6c326119 --- /dev/null +++ b/packages/taler-wallet-core/src/util/taleruri-test.ts @@ -0,0 +1,184 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +import test from "ava"; +import { + parsePayUri, + parseWithdrawUri, + parseRefundUri, + parseTipUri, +} from "./taleruri"; + +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(url2); + 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.merchantBaseUrl, "https://example.com/"); + t.is(r1.sessionId, ""); + + const url2 = "taler://pay/example.com/myorder/mysession"; + const r2 = parsePayUri(url2); + if (!r2) { + t.fail(); + return; + } + t.is(r2.merchantBaseUrl, "https://example.com/"); + t.is(r2.sessionId, "mysession"); +}); + +test("taler pay url parsing: instance", (t) => { + const url1 = "taler://pay/example.com/instances/myinst/myorder/"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.merchantBaseUrl, "https://example.com/instances/myinst/"); + t.is(r1.orderId, "myorder"); +}); + +test("taler pay url parsing (claim token)", (t) => { + const url1 = "taler://pay/example.com/instances/myinst/myorder/?c=ASDF"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.merchantBaseUrl, "https://example.com/instances/myinst/"); + t.is(r1.orderId, "myorder"); + t.is(r1.claimToken, "ASDF"); +}); + +test("taler refund uri parsing: non-https #1", (t) => { + const url1 = "taler+http://refund/example.com/myorder"; + const r1 = parseRefundUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.merchantBaseUrl, "http://example.com/"); + t.is(r1.orderId, "myorder"); +}); + +test("taler pay uri parsing: non-https", (t) => { + const url1 = "taler+http://pay/example.com/myorder/"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.merchantBaseUrl, "http://example.com/"); + t.is(r1.orderId, "myorder"); +}); + +test("taler pay uri parsing: missing session component", (t) => { + const url1 = "taler+http://pay/example.com/myorder"; + const r1 = parsePayUri(url1); + if (r1) { + t.fail(); + return; + } + t.pass(); +}); + +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.withdrawalOperationId, "12345"); + t.is(r1.bankIntegrationApiBaseUrl, "https://bank.example.com/"); +}); + +test("taler withdraw uri parsing (http)", (t) => { + const url1 = "taler+http://withdraw/bank.example.com/12345"; + const r1 = parseWithdrawUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.withdrawalOperationId, "12345"); + t.is(r1.bankIntegrationApiBaseUrl, "http://bank.example.com/"); +}); + +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.merchantBaseUrl, "https://merchant.example.com/"); + t.is(r1.orderId, "1234"); +}); + +test("taler refund uri parsing with instance", (t) => { + const url1 = "taler://refund/merchant.example.com/instances/myinst/1234"; + const r1 = parseRefundUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.orderId, "1234"); + t.is(r1.merchantBaseUrl, "https://merchant.example.com/instances/myinst/"); +}); + +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/"); +}); + +test("taler tip pickup uri with instance", (t) => { + const url1 = "taler://tip/merchant.example.com/instances/tipm/tipid"; + const r1 = parseTipUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.merchantBaseUrl, "https://merchant.example.com/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/pfx/tipm/tipid"; + const r1 = parseTipUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.merchantBaseUrl, "https://merchant.example.com/my/pfx/tipm/"); + t.is(r1.merchantTipId, "tipid"); +}); diff --git a/packages/taler-wallet-core/src/util/taleruri.d.ts.map b/packages/taler-wallet-core/src/util/taleruri.d.ts.map new file mode 100644 index 000000000..36c16c889 --- /dev/null +++ b/packages/taler-wallet-core/src/util/taleruri.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"taleruri.d.ts","sourceRoot":"","sources":["taleruri.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,YAAY;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,yBAAyB,EAAE,MAAM,CAAC;IAClC,qBAAqB,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,eAAe;IAC9B,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,iBAAiB,GAAG,SAAS,CAoBzE;AAED,0BAAkB,YAAY;IAC5B,QAAQ,cAAc;IACtB,aAAa,mBAAmB;IAChC,QAAQ,cAAc;IACtB,WAAW,iBAAiB;IAC5B,kBAAkB,yBAAyB;IAC3C,OAAO,YAAY;CACpB;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,YAAY,CA2BxD;AA0BD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAyB/D;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAoB/D;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAoBrE"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/taleruri.ts b/packages/taler-wallet-core/src/util/taleruri.ts new file mode 100644 index 000000000..43a869afe --- /dev/null +++ b/packages/taler-wallet-core/src/util/taleruri.ts @@ -0,0 +1,215 @@ +/* + This file is part of GNU Taler + (C) 2019-2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { URLSearchParams } from "./url"; + +export interface PayUriResult { + merchantBaseUrl: string; + orderId: string; + sessionId: string; + claimToken: string | undefined; +} + +export interface WithdrawUriResult { + bankIntegrationApiBaseUrl: string; + withdrawalOperationId: string; +} + +export interface RefundUriResult { + merchantBaseUrl: string; + orderId: string; +} + +export interface TipUriResult { + merchantTipId: string; + merchantBaseUrl: string; +} + +/** + * Parse a taler[+http]://withdraw URI. + * Return undefined if not passed a valid URI. + */ +export function parseWithdrawUri(s: string): WithdrawUriResult | undefined { + const pi = parseProtoInfo(s, "withdraw"); + if (!pi) { + return undefined; + } + const parts = pi.rest.split("/"); + + if (parts.length < 2) { + return undefined; + } + + const host = parts[0].toLowerCase(); + const pathSegments = parts.slice(1, parts.length - 1); + const withdrawId = parts[parts.length - 1]; + const p = [host, ...pathSegments].join("/"); + + return { + bankIntegrationApiBaseUrl: `${pi.innerProto}://${p}/`, + withdrawalOperationId: withdrawId, + }; +} + +export const enum TalerUriType { + TalerPay = "taler-pay", + TalerWithdraw = "taler-withdraw", + TalerTip = "taler-tip", + TalerRefund = "taler-refund", + TalerNotifyReserve = "taler-notify-reserve", + Unknown = "unknown", +} + +/** + * Classify a taler:// URI. + */ +export function classifyTalerUri(s: string): TalerUriType { + const sl = s.toLowerCase(); + if (sl.startsWith("taler://pay/")) { + return TalerUriType.TalerPay; + } + if (sl.startsWith("taler+http://pay/")) { + return TalerUriType.TalerPay; + } + if (sl.startsWith("taler://tip/")) { + return TalerUriType.TalerTip; + } + if (sl.startsWith("taler+http://tip/")) { + return TalerUriType.TalerTip; + } + if (sl.startsWith("taler://refund/")) { + return TalerUriType.TalerRefund; + } + if (sl.startsWith("taler+http://refund/")) { + return TalerUriType.TalerRefund; + } + if (sl.startsWith("taler://withdraw/")) { + return TalerUriType.TalerWithdraw; + } + if (sl.startsWith("taler://notify-reserve/")) { + return TalerUriType.TalerNotifyReserve; + } + return TalerUriType.Unknown; +} + +interface TalerUriProtoInfo { + innerProto: "http" | "https"; + rest: string; +} + +function parseProtoInfo( + s: string, + action: string, +): TalerUriProtoInfo | undefined { + const pfxPlain = `taler://${action}/`; + const pfxHttp = `taler+http://${action}/`; + if (s.toLowerCase().startsWith(pfxPlain)) { + return { + innerProto: "https", + rest: s.substring(pfxPlain.length), + }; + } else if (s.toLowerCase().startsWith(pfxHttp)) { + return { + innerProto: "http", + rest: s.substring(pfxHttp.length), + }; + } else { + return undefined; + } +} + +/** + * Parse a taler[+http]://pay URI. + * Return undefined if not passed a valid URI. + */ +export function parsePayUri(s: string): PayUriResult | undefined { + const pi = parseProtoInfo(s, "pay"); + if (!pi) { + return undefined; + } + const c = pi?.rest.split("?"); + const q = new URLSearchParams(c[1] ?? ""); + const claimToken = q.get("c") ?? undefined; + const parts = c[0].split("/"); + if (parts.length < 3) { + return undefined; + } + const host = parts[0].toLowerCase(); + const sessionId = parts[parts.length - 1]; + const orderId = parts[parts.length - 2]; + const pathSegments = parts.slice(1, parts.length - 2); + const p = [host, ...pathSegments].join("/"); + const merchantBaseUrl = `${pi.innerProto}://${p}/`; + + return { + merchantBaseUrl, + orderId, + sessionId: sessionId, + claimToken, + }; +} + +/** + * Parse a taler[+http]://tip URI. + * Return undefined if not passed a valid URI. + */ +export function parseTipUri(s: string): TipUriResult | undefined { + const pi = parseProtoInfo(s, "tip"); + if (!pi) { + return undefined; + } + const c = pi?.rest.split("?"); + const parts = c[0].split("/"); + if (parts.length < 2) { + return undefined; + } + const host = parts[0].toLowerCase(); + const tipId = parts[parts.length - 1]; + const pathSegments = parts.slice(1, parts.length - 1); + const p = [host, ...pathSegments].join("/"); + const merchantBaseUrl = `${pi.innerProto}://${p}/`; + + return { + merchantBaseUrl, + merchantTipId: tipId, + }; +} + +/** + * Parse a taler[+http]://refund URI. + * Return undefined if not passed a valid URI. + */ +export function parseRefundUri(s: string): RefundUriResult | undefined { + const pi = parseProtoInfo(s, "refund"); + if (!pi) { + return undefined; + } + const c = pi?.rest.split("?"); + const parts = c[0].split("/"); + if (parts.length < 2) { + return undefined; + } + const host = parts[0].toLowerCase(); + const orderId = parts[parts.length - 1]; + const pathSegments = parts.slice(1, parts.length - 1); + const p = [host, ...pathSegments].join("/"); + const merchantBaseUrl = `${pi.innerProto}://${p}/`; + + return { + merchantBaseUrl, + orderId, + }; +} diff --git a/packages/taler-wallet-core/src/util/testvectors.ts b/packages/taler-wallet-core/src/util/testvectors.ts new file mode 100644 index 000000000..57ac6e992 --- /dev/null +++ b/packages/taler-wallet-core/src/util/testvectors.ts @@ -0,0 +1,36 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports + */ +import { + setupRefreshPlanchet, + encodeCrock, + getRandomBytes, +} from "../crypto/talerCrypto"; + +export function printTestVectors() { + const secretSeed = getRandomBytes(64); + const coinIndex = Math.ceil(Math.random() * 100); + const p = setupRefreshPlanchet(secretSeed, coinIndex); + console.log("setupRefreshPlanchet"); + console.log(` (in) secret seed: ${encodeCrock(secretSeed)}`); + console.log(` (in) coin index: ${coinIndex}`); + console.log(` (out) blinding secret: ${encodeCrock(p.bks)}`); + console.log(` (out) coin priv: ${encodeCrock(p.coinPriv)}`); + console.log(` (out) coin pub: ${encodeCrock(p.coinPub)}`); +} diff --git a/packages/taler-wallet-core/src/util/time.d.ts.map b/packages/taler-wallet-core/src/util/time.d.ts.map new file mode 100644 index 000000000..c38a23356 --- /dev/null +++ b/packages/taler-wallet-core/src/util/time.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"time.d.ts","sourceRoot":"","sources":["time.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAA0B,MAAM,SAAS,CAAC;AAkBxD;;GAEG;AAEH,qBAAa,SAAS;IACpB;;OAEG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC;AAED,MAAM,WAAW,QAAQ;IACvB;;OAEG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;CACnC;AAID,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAEvD;AAED,wBAAgB,eAAe,IAAI,SAAS,CAI3C;AAED,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,SAAS,EACnB,GAAG,YAAoB,GACtB,QAAQ,CAWV;AAED,wBAAgB,YAAY,CAAC,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE,SAAS,GAAG,SAAS,CAQpE;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,EAAE,EAAE,SAAS,GAAG,SAAS,CAOlE;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,QAAQ,GAAG,QAAQ,CAQhE;AAED,wBAAgB,YAAY,CAAC,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE,SAAS,GAAG,MAAM,CAiBjE;AAED,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,SAAS,EAAE,CAAC,EAAE,QAAQ,GAAG,SAAS,CAK1E;AAED,wBAAgB,0BAA0B,CACxC,EAAE,EAAE,SAAS,EACb,CAAC,EAAE,QAAQ,GACV,SAAS,CAQX;AAED,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,SAAS,GAAG,MAAM,CAKvD;AAED,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE,SAAS,GAAG,QAAQ,CAQ1E;AAED,wBAAgB,kBAAkB,CAChC,CAAC,EAAE,SAAS,EACZ,KAAK,EAAE,SAAS,EAChB,GAAG,EAAE,SAAS,GACb,OAAO,CAQT;AAED,eAAO,MAAM,iBAAiB,EAAE,KAAK,CAAC,SAAS,CAc9C,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,KAAK,CAAC,QAAQ,CAc5C,CAAC"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/time.ts b/packages/taler-wallet-core/src/util/time.ts new file mode 100644 index 000000000..5c2f49d12 --- /dev/null +++ b/packages/taler-wallet-core/src/util/time.ts @@ -0,0 +1,198 @@ +import { Codec, renderContext, Context } from "./codec"; + +/* + This file is part of GNU Taler + (C) 2017-2019 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Helpers for relative and absolute time. + */ + +export class Timestamp { + /** + * Timestamp in milliseconds. + */ + readonly t_ms: number | "never"; +} + +export interface Duration { + /** + * Duration in milliseconds. + */ + readonly d_ms: number | "forever"; +} + +let timeshift = 0; + +export function setDangerousTimetravel(dt: number): void { + timeshift = dt; +} + +export function getTimestampNow(): Timestamp { + return { + t_ms: new Date().getTime() + timeshift, + }; +} + +export function getDurationRemaining( + deadline: Timestamp, + now = getTimestampNow(), +): Duration { + if (deadline.t_ms === "never") { + return { d_ms: "forever" }; + } + if (now.t_ms === "never") { + throw Error("invalid argument for 'now'"); + } + if (deadline.t_ms < now.t_ms) { + return { d_ms: 0 }; + } + return { d_ms: deadline.t_ms - now.t_ms }; +} + +export function timestampMin(t1: Timestamp, t2: Timestamp): Timestamp { + if (t1.t_ms === "never") { + return { t_ms: t2.t_ms }; + } + if (t2.t_ms === "never") { + return { t_ms: t2.t_ms }; + } + return { t_ms: Math.min(t1.t_ms, t2.t_ms) }; +} + +/** + * Truncate a timestamp so that that it represents a multiple + * of seconds. The timestamp is always rounded down. + */ +export function timestampTruncateToSecond(t1: Timestamp): Timestamp { + if (t1.t_ms === "never") { + return { t_ms: "never" }; + } + return { + t_ms: Math.floor(t1.t_ms / 1000) * 1000, + }; +} + +export function durationMin(d1: Duration, d2: Duration): Duration { + if (d1.d_ms === "forever") { + return { d_ms: d2.d_ms }; + } + if (d2.d_ms === "forever") { + return { d_ms: d2.d_ms }; + } + return { d_ms: Math.min(d1.d_ms, d2.d_ms) }; +} + +export function timestampCmp(t1: Timestamp, t2: Timestamp): number { + if (t1.t_ms === "never") { + if (t2.t_ms === "never") { + return 0; + } + return 1; + } + if (t2.t_ms === "never") { + return -1; + } + if (t1.t_ms == t2.t_ms) { + return 0; + } + if (t1.t_ms > t2.t_ms) { + return 1; + } + return -1; +} + +export function timestampAddDuration(t1: Timestamp, d: Duration): Timestamp { + if (t1.t_ms === "never" || d.d_ms === "forever") { + return { t_ms: "never" }; + } + return { t_ms: t1.t_ms + d.d_ms }; +} + +export function timestampSubtractDuraction( + t1: Timestamp, + d: Duration, +): Timestamp { + if (t1.t_ms === "never") { + return { t_ms: "never" }; + } + if (d.d_ms === "forever") { + return { t_ms: 0 }; + } + return { t_ms: Math.max(0, t1.t_ms - d.d_ms) }; +} + +export function stringifyTimestamp(t: Timestamp): string { + if (t.t_ms === "never") { + return "never"; + } + return new Date(t.t_ms).toISOString(); +} + +export function timestampDifference(t1: Timestamp, t2: Timestamp): Duration { + if (t1.t_ms === "never") { + return { d_ms: "forever" }; + } + if (t2.t_ms === "never") { + return { d_ms: "forever" }; + } + return { d_ms: Math.abs(t1.t_ms - t2.t_ms) }; +} + +export function timestampIsBetween( + t: Timestamp, + start: Timestamp, + end: Timestamp, +): boolean { + if (timestampCmp(t, start) < 0) { + return false; + } + if (timestampCmp(t, end) > 0) { + return false; + } + return true; +} + +export const codecForTimestamp: Codec<Timestamp> = { + decode(x: any, c?: Context): Timestamp { + const t_ms = x.t_ms; + if (typeof t_ms === "string") { + if (t_ms === "never") { + return { t_ms: "never" }; + } + throw Error(`expected timestamp at ${renderContext(c)}`); + } + if (typeof t_ms === "number") { + return { t_ms }; + } + throw Error(`expected timestamp at ${renderContext(c)}`); + }, +}; + +export const codecForDuration: Codec<Duration> = { + decode(x: any, c?: Context): Duration { + const d_ms = x.d_ms; + if (typeof d_ms === "string") { + if (d_ms === "forever") { + return { d_ms: "forever" }; + } + throw Error(`expected duration at ${renderContext(c)}`); + } + if (typeof d_ms === "number") { + return { d_ms }; + } + throw Error(`expected duration at ${renderContext(c)}`); + }, +}; diff --git a/packages/taler-wallet-core/src/util/timer.d.ts.map b/packages/taler-wallet-core/src/util/timer.d.ts.map new file mode 100644 index 000000000..c2b5e536e --- /dev/null +++ b/packages/taler-wallet-core/src/util/timer.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"timer.d.ts","sourceRoot":"","sources":["timer.ts"],"names":[],"mappings":"AAgBA;;;;;GAKG;AAEH;;GAEG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAKlC;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,KAAK,IAAI,IAAI,CAAC;CACf;AAkBD;;GAEG;AACH,eAAO,MAAM,cAAc,EAAE,MAAM,MAa/B,CAAC;AAEL;;GAEG;AACH,wBAAgB,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,WAAW,CAExE;AAED;;GAEG;AACH,wBAAgB,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,WAAW,CAExE;AASD;;GAEG;AACH,qBAAa,UAAU;IACrB,OAAO,CAAC,OAAO,CAAS;IAExB,OAAO,CAAC,QAAQ,CAAwC;IAExD,OAAO,CAAC,KAAK,CAAK;IAElB,0BAA0B,IAAI,IAAI;IAWlC,YAAY,CAAC,OAAO,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAU9C,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,WAAW;IAmBzD,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,WAAW;CAkB1D"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/timer.ts b/packages/taler-wallet-core/src/util/timer.ts new file mode 100644 index 000000000..8eab1399c --- /dev/null +++ b/packages/taler-wallet-core/src/util/timer.ts @@ -0,0 +1,165 @@ +/* + This file is part of GNU Taler + (C) 2017-2019 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Cross-platform timers. + * + * NodeJS and the browser use slightly different timer API, + * this abstracts over these differences. + */ + +/** + * Imports. + */ +import { Duration } from "./time"; +import { Logger } from "./logging"; + +const logger = new Logger("timer.ts"); + +/** + * Cancelable timer. + */ +export interface TimerHandle { + clear(): void; +} + +class IntervalHandle { + constructor(public h: any) {} + + clear(): void { + clearInterval(this.h); + } +} + +class TimeoutHandle { + constructor(public h: any) {} + + clear(): void { + clearTimeout(this.h); + } +} + +/** + * Get a performance counter in milliseconds. + */ +export const performanceNow: () => number = (() => { + // @ts-ignore + if (typeof process !== "undefined" && process.hrtime) { + return () => { + const t = process.hrtime(); + return t[0] * 1e9 + t[1]; + }; + } + + // @ts-ignore + if (typeof performance !== "undefined") { + // @ts-ignore + return () => performance.now(); + } + + 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 = false; + + private timerMap: { [index: number]: TimerHandle } = {}; + + private idGen = 1; + + stopCurrentAndFutureTimers(): void { + this.stopped = true; + for (const x in this.timerMap) { + if (!this.timerMap.hasOwnProperty(x)) { + continue; + } + this.timerMap[x].clear(); + delete this.timerMap[x]; + } + } + + resolveAfter(delayMs: Duration): Promise<void> { + return new Promise<void>((resolve, reject) => { + if (delayMs.d_ms !== "forever") { + this.after(delayMs.d_ms, () => { + resolve(); + }); + } + }); + } + + after(delayMs: number, callback: () => void): TimerHandle { + if (this.stopped) { + logger.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) { + logger.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/packages/taler-wallet-core/src/util/url.d.ts.map b/packages/taler-wallet-core/src/util/url.d.ts.map new file mode 100644 index 000000000..f238a9b5a --- /dev/null +++ b/packages/taler-wallet-core/src/util/url.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"url.d.ts","sourceRoot":"","sources":["url.ts"],"names":[],"mappings":"AAgBA,UAAU,GAAG;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,IAAI,MAAM,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,YAAY,EAAE,eAAe,CAAC;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,IAAI,MAAM,CAAC;CAClB;AAED,UAAU,eAAe;IACvB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACjC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC/B,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IAC3B,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,IAAI,IAAI,IAAI,CAAC;IACb,QAAQ,IAAI,MAAM,CAAC;IACnB,OAAO,CACL,UAAU,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,KAAK,IAAI,EACzE,OAAO,CAAC,EAAE,GAAG,GACZ,IAAI,CAAC;CACT;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAI,IAAI,CAAC,EAAE,MAAM,EAAE,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,GAAG,eAAe,GAAG,eAAe,CAAC;CAC7F;AAED,MAAM,WAAW,OAAO;IACtB,KAAI,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,GAAG,GAAG,GAAG,CAAC;CAC5C;AAQD,eAAO,MAAM,GAAG,EAAE,OAAc,CAAC;AASjC,eAAO,MAAM,eAAe,EAAE,mBAAsC,CAAC"}
\ No newline at end of file diff --git a/packages/taler-wallet-core/src/util/url.ts b/packages/taler-wallet-core/src/util/url.ts new file mode 100644 index 000000000..b50b4b466 --- /dev/null +++ b/packages/taler-wallet-core/src/util/url.ts @@ -0,0 +1,74 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +interface URL { + hash: string; + host: string; + hostname: string; + href: string; + toString(): string; + readonly origin: string; + password: string; + pathname: string; + port: string; + protocol: string; + search: string; + readonly searchParams: URLSearchParams; + username: string; + toJSON(): string; +} + +interface URLSearchParams { + append(name: string, value: string): void; + delete(name: string): void; + get(name: string): string | null; + getAll(name: string): string[]; + has(name: string): boolean; + set(name: string, value: string): void; + sort(): void; + toString(): string; + forEach( + callbackfn: (value: string, key: string, parent: URLSearchParams) => void, + thisArg?: any, + ): void; +} + +export interface URLSearchParamsCtor { + new ( + init?: string[][] | Record<string, string> | string | URLSearchParams, + ): URLSearchParams; +} + +export interface URLCtor { + new (url: string, base?: string | URL): URL; +} + +// @ts-ignore +const _URL = globalThis.URL; +if (!_URL) { + throw Error("FATAL: URL not available"); +} + +export const URL: URLCtor = _URL; + +// @ts-ignore +const _URLSearchParams = globalThis.URLSearchParams; + +if (!_URLSearchParams) { + throw Error("FATAL: URLSearchParams not available"); +} + +export const URLSearchParams: URLSearchParamsCtor = _URLSearchParams; diff --git a/packages/taler-wallet-core/src/util/wire.ts b/packages/taler-wallet-core/src/util/wire.ts new file mode 100644 index 000000000..95e324f3c --- /dev/null +++ b/packages/taler-wallet-core/src/util/wire.ts @@ -0,0 +1,51 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +/** + * 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`; + } +} |