use static helpers for amounts

This commit is contained in:
Florian Dold 2021-03-17 18:21:43 +01:00
parent 07cdfb2e4e
commit 51f2ad9b6d
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
6 changed files with 308 additions and 308 deletions

View File

@ -16,7 +16,7 @@
import test from "ava"; import test from "ava";
import { Amounts, AmountJson } from "./amounts.js"; import { Amounts, AmountJson, amountMaxValue } from "./amounts.js";
const jAmt = ( const jAmt = (
value: number, value: number,
@ -36,7 +36,7 @@ test("amount addition (simple)", (t) => {
test("amount addition (saturation)", (t) => { test("amount addition (saturation)", (t) => {
const a1 = jAmt(1, 0, "EUR"); const a1 = jAmt(1, 0, "EUR");
const res = Amounts.add(jAmt(Amounts.maxAmountValue, 0, "EUR"), a1); const res = Amounts.add(jAmt(amountMaxValue, 0, "EUR"), a1);
t.true(res.saturated); t.true(res.saturated);
t.pass(); t.pass();
}); });

View File

@ -32,19 +32,19 @@ import { AmountString } from "./talerTypes";
/** /**
* Number of fractional units that one value unit represents. * Number of fractional units that one value unit represents.
*/ */
export const fractionalBase = 1e8; export const amountFractionalBase = 1e8;
/** /**
* How many digits behind the comma are required to represent the * How many digits behind the comma are required to represent the
* fractional value in human readable decimal format? Must match * fractional value in human readable decimal format? Must match
* lg(fractionalBase) * lg(fractionalBase)
*/ */
export const fractionalLength = 8; export const amountFractionalLength = 8;
/** /**
* Maximum allowed value field of an amount. * Maximum allowed value field of an amount.
*/ */
export const maxAmountValue = 2 ** 52; export const amountMaxValue = 2 ** 52;
/** /**
* Non-negative financial amount. Fractional values are expressed as multiples * Non-negative financial amount. Fractional values are expressed as multiples
@ -91,80 +91,97 @@ export interface Result {
} }
/** /**
* Type for things that are treated like amounts.
*/
export type AmountLike = AmountString | AmountJson;
/**
* Helper class for dealing with amounts.
*/
export class Amounts {
private constructor() {
throw Error("not instantiable");
}
/**
* Get an amount that represents zero units of a currency. * Get an amount that represents zero units of a currency.
*/ */
export function getZero(currency: string): AmountJson { static getZero(currency: string): AmountJson {
return { return {
currency, currency,
fraction: 0, fraction: 0,
value: 0, value: 0,
}; };
} }
export type AmountLike = AmountString | AmountJson; static jsonifyAmount(amt: AmountLike): AmountJson {
export function jsonifyAmount(amt: AmountLike): AmountJson {
if (typeof amt === "string") { if (typeof amt === "string") {
return parseOrThrow(amt); return Amounts.parseOrThrow(amt);
} }
return amt; return amt;
} }
export function sum(amounts: AmountLike[]): Result { static sum(amounts: AmountLike[]): Result {
if (amounts.length <= 0) { if (amounts.length <= 0) {
throw Error("can't sum zero amounts"); throw Error("can't sum zero amounts");
} }
const jsonAmounts = amounts.map((x) => jsonifyAmount(x)); const jsonAmounts = amounts.map((x) => Amounts.jsonifyAmount(x));
return add(jsonAmounts[0], ...jsonAmounts.slice(1)); return Amounts.add(jsonAmounts[0], ...jsonAmounts.slice(1));
} }
/** /**
* Add two amounts. Return the result and whether * Add two amounts. Return the result and whether
* the addition overflowed. The overflow is always handled * the addition overflowed. The overflow is always handled
* by saturating and never by wrapping. * by saturating and never by wrapping.
* *
* Throws when currencies don't match. * Throws when currencies don't match.
*/ */
export function add(first: AmountJson, ...rest: AmountJson[]): Result { static add(first: AmountJson, ...rest: AmountJson[]): Result {
const currency = first.currency; const currency = first.currency;
let value = first.value + Math.floor(first.fraction / fractionalBase); let value = first.value + Math.floor(first.fraction / amountFractionalBase);
if (value > maxAmountValue) { if (value > amountMaxValue) {
return { return {
amount: { currency, value: maxAmountValue, fraction: fractionalBase - 1 }, amount: {
currency,
value: amountMaxValue,
fraction: amountFractionalBase - 1,
},
saturated: true, saturated: true,
}; };
} }
let fraction = first.fraction % fractionalBase; let fraction = first.fraction % amountFractionalBase;
for (const x of rest) { for (const x of rest) {
if (x.currency !== currency) { if (x.currency !== currency) {
throw Error(`Mismatched currency: ${x.currency} and ${currency}`); throw Error(`Mismatched currency: ${x.currency} and ${currency}`);
} }
value = value =
value + x.value + Math.floor((fraction + x.fraction) / fractionalBase); value +
fraction = Math.floor((fraction + x.fraction) % fractionalBase); x.value +
if (value > maxAmountValue) { Math.floor((fraction + x.fraction) / amountFractionalBase);
fraction = Math.floor((fraction + x.fraction) % amountFractionalBase);
if (value > amountMaxValue) {
return { return {
amount: { amount: {
currency, currency,
value: maxAmountValue, value: amountMaxValue,
fraction: fractionalBase - 1, fraction: amountFractionalBase - 1,
}, },
saturated: true, saturated: true,
}; };
} }
} }
return { amount: { currency, value, fraction }, saturated: false }; return { amount: { currency, value, fraction }, saturated: false };
} }
/** /**
* Subtract two amounts. Return the result and whether * Subtract two amounts. Return the result and whether
* the subtraction overflowed. The overflow is always handled * the subtraction overflowed. The overflow is always handled
* by saturating and never by wrapping. * by saturating and never by wrapping.
* *
* Throws when currencies don't match. * Throws when currencies don't match.
*/ */
export function sub(a: AmountJson, ...rest: AmountJson[]): Result { static sub(a: AmountJson, ...rest: AmountJson[]): Result {
const currency = a.currency; const currency = a.currency;
let value = a.value; let value = a.value;
let fraction = a.fraction; let fraction = a.fraction;
@ -175,10 +192,13 @@ export function sub(a: AmountJson, ...rest: AmountJson[]): Result {
} }
if (fraction < b.fraction) { if (fraction < b.fraction) {
if (value < 1) { if (value < 1) {
return { amount: { currency, value: 0, fraction: 0 }, saturated: true }; return {
amount: { currency, value: 0, fraction: 0 },
saturated: true,
};
} }
value--; value--;
fraction += fractionalBase; fraction += amountFractionalBase;
} }
console.assert(fraction >= b.fraction); console.assert(fraction >= b.fraction);
fraction -= b.fraction; fraction -= b.fraction;
@ -189,22 +209,22 @@ export function sub(a: AmountJson, ...rest: AmountJson[]): Result {
} }
return { amount: { currency, value, fraction }, saturated: false }; return { amount: { currency, value, fraction }, saturated: false };
} }
/** /**
* Compare two amounts. Returns 0 when equal, -1 when a < b * Compare two amounts. Returns 0 when equal, -1 when a < b
* and +1 when a > b. Throws when currencies don't match. * and +1 when a > b. Throws when currencies don't match.
*/ */
export function cmp(a: AmountLike, b: AmountLike): -1 | 0 | 1 { static cmp(a: AmountLike, b: AmountLike): -1 | 0 | 1 {
a = jsonifyAmount(a); a = Amounts.jsonifyAmount(a);
b = jsonifyAmount(b); b = Amounts.jsonifyAmount(b);
if (a.currency !== b.currency) { if (a.currency !== b.currency) {
throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`); throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`);
} }
const av = a.value + Math.floor(a.fraction / fractionalBase); const av = a.value + Math.floor(a.fraction / amountFractionalBase);
const af = a.fraction % fractionalBase; const af = a.fraction % amountFractionalBase;
const bv = b.value + Math.floor(b.fraction / fractionalBase); const bv = b.value + Math.floor(b.fraction / amountFractionalBase);
const bf = b.fraction % fractionalBase; const bf = b.fraction % amountFractionalBase;
switch (true) { switch (true) {
case av < bv: case av < bv:
return -1; return -1;
@ -219,23 +239,23 @@ export function cmp(a: AmountLike, b: AmountLike): -1 | 0 | 1 {
default: default:
throw Error("assertion failed"); throw Error("assertion failed");
} }
} }
/** /**
* Create a copy of an amount. * Create a copy of an amount.
*/ */
export function copy(a: AmountJson): AmountJson { static copy(a: AmountJson): AmountJson {
return { return {
currency: a.currency, currency: a.currency,
fraction: a.fraction, fraction: a.fraction,
value: a.value, value: a.value,
}; };
} }
/** /**
* Divide an amount. Throws on division by zero. * Divide an amount. Throws on division by zero.
*/ */
export function divide(a: AmountJson, n: number): AmountJson { static divide(a: AmountJson, n: number): AmountJson {
if (n === 0) { if (n === 0) {
throw Error(`Division by 0`); throw Error(`Division by 0`);
} }
@ -245,111 +265,91 @@ export function divide(a: AmountJson, n: number): AmountJson {
const r = a.value % n; const r = a.value % n;
return { return {
currency: a.currency, currency: a.currency,
fraction: Math.floor((r * fractionalBase + a.fraction) / n), fraction: Math.floor((r * amountFractionalBase + a.fraction) / n),
value: Math.floor(a.value / n), value: Math.floor(a.value / n),
}; };
} }
/** /**
* Check if an amount is non-zero. * Check if an amount is non-zero.
*/ */
export function isNonZero(a: AmountJson): boolean { static isNonZero(a: AmountJson): boolean {
return a.value > 0 || a.fraction > 0; return a.value > 0 || a.fraction > 0;
} }
export function isZero(a: AmountLike): boolean { static isZero(a: AmountLike): boolean {
a = jsonifyAmount(a); a = Amounts.jsonifyAmount(a);
return a.value === 0 && a.fraction === 0; return a.value === 0 && a.fraction === 0;
} }
/** /**
* Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct. * Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct.
*/ */
export function parse(s: string): AmountJson | undefined { static parse(s: string): AmountJson | undefined {
const res = s.match(/^([a-zA-Z0-9_*-]+):([0-9]+)([.][0-9]+)?$/); const res = s.match(/^([a-zA-Z0-9_*-]+):([0-9]+)([.][0-9]+)?$/);
if (!res) { if (!res) {
return undefined; return undefined;
} }
const tail = res[3] || ".0"; const tail = res[3] || ".0";
if (tail.length > fractionalLength + 1) { if (tail.length > amountFractionalLength + 1) {
return undefined; return undefined;
} }
const value = Number.parseInt(res[2]); const value = Number.parseInt(res[2]);
if (value > maxAmountValue) { if (value > amountMaxValue) {
return undefined; return undefined;
} }
return { return {
currency: res[1], currency: res[1],
fraction: Math.round(fractionalBase * Number.parseFloat(tail)), fraction: Math.round(amountFractionalBase * Number.parseFloat(tail)),
value, value,
}; };
} }
/** /**
* Parse amount in standard string form (like 'EUR:20.5'), * Parse amount in standard string form (like 'EUR:20.5'),
* throw if the input is not a valid amount. * throw if the input is not a valid amount.
*/ */
export function parseOrThrow(s: string): AmountJson { static parseOrThrow(s: string): AmountJson {
const res = parse(s); const res = Amounts.parse(s);
if (!res) { if (!res) {
throw Error(`Can't parse amount: "${s}"`); throw Error(`Can't parse amount: "${s}"`);
} }
return res; return res;
} }
/** /**
* Convert a float to a Taler amount. * Convert a float to a Taler amount.
* Loss of precision possible. * Loss of precision possible.
*/ */
export function fromFloat(floatVal: number, currency: string): AmountJson { static fromFloat(floatVal: number, currency: string): AmountJson {
return { return {
currency, currency,
fraction: Math.floor((floatVal - Math.floor(floatVal)) * fractionalBase), fraction: Math.floor(
(floatVal - Math.floor(floatVal)) * amountFractionalBase,
),
value: Math.floor(floatVal), value: Math.floor(floatVal),
}; };
}
/**
* Convert to standard human-readable string representation that's
* also used in JSON formats.
*/
export function stringify(a: AmountLike): string {
a = jsonifyAmount(a);
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; static min(a: AmountLike, b: AmountLike): AmountJson {
const cr = Amounts.cmp(a, b);
if (cr >= 0) {
return Amounts.jsonifyAmount(b);
} else {
return Amounts.jsonifyAmount(a);
} }
} }
return `${a.currency}:${s}`; static max(a: AmountLike, b: AmountLike): AmountJson {
} const cr = Amounts.cmp(a, b);
if (cr >= 0) {
/** return Amounts.jsonifyAmount(a);
* Check if the argument is a valid amount in string form. } else {
*/ return Amounts.jsonifyAmount(b);
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 { static mult(a: AmountJson, n: number): Result {
if (!Number.isInteger(n)) { if (!Number.isInteger(n)) {
throw Error("amount can only be multipied by an integer"); throw Error("amount can only be multipied by an integer");
} }
@ -357,67 +357,67 @@ function mult(a: AmountJson, n: number): Result {
throw Error("amount can only be multiplied by a positive integer"); throw Error("amount can only be multiplied by a positive integer");
} }
if (n == 0) { if (n == 0) {
return { amount: getZero(a.currency), saturated: false }; return { amount: Amounts.getZero(a.currency), saturated: false };
} }
let x = a; let x = a;
let acc = getZero(a.currency); let acc = Amounts.getZero(a.currency);
while (n > 1) { while (n > 1) {
if (n % 2 == 0) { if (n % 2 == 0) {
n = n / 2; n = n / 2;
} else { } else {
n = (n - 1) / 2; n = (n - 1) / 2;
const r2 = add(acc, x); const r2 = Amounts.add(acc, x);
if (r2.saturated) { if (r2.saturated) {
return r2; return r2;
} }
acc = r2.amount; acc = r2.amount;
} }
const r2 = add(x, x); const r2 = Amounts.add(x, x);
if (r2.saturated) { if (r2.saturated) {
return r2; return r2;
} }
x = r2.amount; x = r2.amount;
} }
return add(acc, x); return Amounts.add(acc, x);
} }
function max(a: AmountLike, b: AmountLike): AmountJson { /**
const cr = Amounts.cmp(a, b); * Check if the argument is a valid amount in string form.
if (cr >= 0) { */
return jsonifyAmount(a); static check(a: any): boolean {
} else { if (typeof a !== "string") {
return jsonifyAmount(b); return false;
}
try {
const parsedAmount = Amounts.parse(a);
return !!parsedAmount;
} catch {
return false;
}
}
/**
* Convert to standard human-readable string representation that's
* also used in JSON formats.
*/
static stringify(a: AmountLike): string {
a = Amounts.jsonifyAmount(a);
const av = a.value + Math.floor(a.fraction / amountFractionalBase);
const af = a.fraction % amountFractionalBase;
let s = av.toString();
if (af) {
s = s + ".";
let n = af;
for (let i = 0; i < amountFractionalLength; i++) {
if (!n) {
break;
}
s = s + Math.floor((n / amountFractionalBase) * 10).toString();
n = (n * 10) % amountFractionalBase;
}
}
return `${a.currency}:${s}`;
} }
} }
function min(a: AmountLike, b: AmountLike): AmountJson {
const cr = Amounts.cmp(a, b);
if (cr >= 0) {
return jsonifyAmount(b);
} else {
return jsonifyAmount(a);
}
}
// 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,
max: max,
min: min,
check: check,
getZero: getZero,
isZero: isZero,
maxAmountValue: maxAmountValue,
fromFloat: fromFloat,
copy: copy,
fractionalBase: fractionalBase,
divide: divide,
};

View File

@ -31,7 +31,7 @@ import { URL } from "./url";
* settings such as significant digits or currency symbols. * settings such as significant digits or currency symbols.
*/ */
export function amountToPretty(amount: AmountJson): string { export function amountToPretty(amount: AmountJson): string {
const x = amount.value + amount.fraction / Amounts.fractionalBase; const x = amount.value + amount.fraction / Amounts.amountFractionalBase;
return `${x} ${amount.currency}`; return `${x} ${amount.currency}`;
} }

View File

@ -187,7 +187,7 @@ export interface RecoupRequest {
/** /**
* Response that we get from the exchange for a payback request. * Response that we get from the exchange for a payback request.
*/ */
export class RecoupConfirmation { export interface RecoupConfirmation {
/** /**
* Public key of the reserve that will receive the payback. * Public key of the reserve that will receive the payback.
*/ */
@ -235,7 +235,7 @@ export interface CoinDepositPermission {
* Information about an exchange as stored inside a * Information about an exchange as stored inside a
* merchant's contract terms. * merchant's contract terms.
*/ */
export class ExchangeHandle { export interface ExchangeHandle {
/** /**
* Master public signing key of the exchange. * Master public signing key of the exchange.
*/ */
@ -247,7 +247,7 @@ export class ExchangeHandle {
url: string; url: string;
} }
export class AuditorHandle { export interface AuditorHandle {
/** /**
* Official name of the auditor. * Official name of the auditor.
*/ */
@ -349,7 +349,7 @@ export interface InternationalizedString {
/** /**
* Contract terms from a merchant. * Contract terms from a merchant.
*/ */
export class ContractTerms { export interface ContractTerms {
/** /**
* Hash of the merchant's wire details. * Hash of the merchant's wire details.
*/ */
@ -489,7 +489,7 @@ export class ContractTerms {
/** /**
* Refund permission in the format that the merchant gives it to us. * Refund permission in the format that the merchant gives it to us.
*/ */
export class MerchantAbortPayRefundDetails { export interface MerchantAbortPayRefundDetails {
/** /**
* Amount to be refunded. * Amount to be refunded.
*/ */
@ -540,7 +540,7 @@ export class MerchantAbortPayRefundDetails {
/** /**
* Response for a refund pickup or a /pay in abort mode. * Response for a refund pickup or a /pay in abort mode.
*/ */
export class MerchantRefundResponse { export interface MerchantRefundResponse {
/** /**
* Public key of the merchant * Public key of the merchant
*/ */
@ -592,7 +592,7 @@ export interface TipPickupRequest {
* Reserve signature, defined as separate class to facilitate * Reserve signature, defined as separate class to facilitate
* schema validation with "@Checkable". * schema validation with "@Checkable".
*/ */
export class BlindSigWrapper { export interface BlindSigWrapper {
/** /**
* Reserve signature. * Reserve signature.
*/ */
@ -603,7 +603,7 @@ export class BlindSigWrapper {
* Response of the merchant * Response of the merchant
* to the TipPickupRequest. * to the TipPickupRequest.
*/ */
export class TipResponse { export interface TipResponse {
/** /**
* The order of the signatures matches the planchets list. * The order of the signatures matches the planchets list.
*/ */
@ -705,12 +705,12 @@ export class WireFeesJson {
end_date: Timestamp; end_date: Timestamp;
} }
export class AccountInfo { export interface AccountInfo {
payto_uri: string; payto_uri: string;
master_sig: string; master_sig: string;
} }
export class ExchangeWireJson { export interface ExchangeWireJson {
accounts: AccountInfo[]; accounts: AccountInfo[];
fees: { [methodName: string]: WireFeesJson[] }; fees: { [methodName: string]: WireFeesJson[] };
} }

View File

@ -24,7 +24,7 @@
* Imports * Imports
*/ */
import { AmountJson } from "./amounts"; import { AmountJson } from "./amounts";
import * as Amounts from "./amounts"; import { Amounts } from "./amounts";
import fs from "fs"; import fs from "fs";
export class ConfigError extends Error { export class ConfigError extends Error {

View File

@ -21,7 +21,7 @@
/** /**
* Imports. * Imports.
*/ */
import { AmountJson, Amounts } from "@gnu-taler/taler-util"; import { amountFractionalBase, AmountJson, Amounts } from "@gnu-taler/taler-util";
import { URL } from "./url"; import { URL } from "./url";
/** /**
@ -30,7 +30,7 @@ import { URL } from "./url";
* settings such as significant digits or currency symbols. * settings such as significant digits or currency symbols.
*/ */
export function amountToPretty(amount: AmountJson): string { export function amountToPretty(amount: AmountJson): string {
const x = amount.value + amount.fraction / Amounts.fractionalBase; const x = amount.value + amount.fraction / amountFractionalBase;
return `${x} ${amount.currency}`; return `${x} ${amount.currency}`;
} }