Add the grain/grain.js (#1696)
This commit adds a `grain/grain.js` module, which contains a type and logic for representing Grain balances with 18 digits of precision. We use the native BigInt type (and add the necessary babel plugin to support it). Unfortunately, Flow does not yet support BigInts (see [facebook/flow#6639]). To hack around this, we lie to Flow, claiming that BigInts are numbers, and we expect/suppress the flow errors whenever we actually instantiate one. For example: ```js // $ExpectFlowError const myBigInt = 5n; ``` We can use the BigInt operators like `+`, `-`, `>` without flow errors, since these actually exist on numbers too. However, flow will fail to detect improper combinations of regular numbers and BigInts: ```js // $ExpectFlowError const x = 5n; const y = x + 5; // Uncaught TypeError: Cannot mix BigInt and other types ``` Since any improper mixing will result in a runtime error, these issues will be easy to detect via unit tests. In addition to adding the basic Grain type, I exported a `format` function which will display Grain balances in a human readable way. It supports arbitrary decimal precision, groups large amounts with comma separators, handles negative numbers, and adds a suffix string. The format method is thoroughly documented and tested. Thanks to @Beanow for valuable feedback on its implementation. Test plan: See included unit tests. `yarn test` passes. [facebook/flow#6639]: https://github.com/facebook/flow/issues/6639
This commit is contained in:
parent
7483e28024
commit
2397afc3d2
|
@ -17,6 +17,9 @@ const presets = [
|
||||||
"@babel/preset-flow",
|
"@babel/preset-flow",
|
||||||
];
|
];
|
||||||
|
|
||||||
const plugins = ["@babel/plugin-proposal-class-properties"];
|
const plugins = [
|
||||||
|
"@babel/plugin-proposal-class-properties",
|
||||||
|
"@babel/plugin-syntax-bigint",
|
||||||
|
];
|
||||||
|
|
||||||
module.exports = {presets, plugins};
|
module.exports = {presets, plugins};
|
||||||
|
|
|
@ -45,6 +45,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.8.7",
|
"@babel/core": "^7.8.7",
|
||||||
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
||||||
|
"@babel/plugin-syntax-bigint": "^7.8.3",
|
||||||
"@babel/preset-env": "^7.8.7",
|
"@babel/preset-env": "^7.8.7",
|
||||||
"@babel/preset-flow": "^7.8.3",
|
"@babel/preset-flow": "^7.8.3",
|
||||||
"@babel/preset-react": "^7.8.3",
|
"@babel/preset-react": "^7.8.3",
|
||||||
|
|
|
@ -0,0 +1,110 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
/* global BigInt */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This module contains the types for tracking Grain, which is the native
|
||||||
|
* project-specific, cred-linked token created in SourceCred instances. In
|
||||||
|
* practice, projects can call these tokens anything they want, but we will
|
||||||
|
* refer to the tokens as "Grain" throughout the codebase. The conserved
|
||||||
|
* properties of all Grains are that they are minted/distributed based on cred
|
||||||
|
* scores, and that they can be used to Boost contributions in a cred graph.
|
||||||
|
*
|
||||||
|
* Grain is represented by [BigInt]s so that we can avoid precision issues.
|
||||||
|
* Following the convention for ERC20 tokens, we will track and format Grain
|
||||||
|
* with 18 decimals of precision.
|
||||||
|
*
|
||||||
|
* Unfortunately Flow does not support BigInts yet. For now, we will hack
|
||||||
|
* around this by lying to Flow and claiming that the BigInts are actually
|
||||||
|
* numbers. Whenever we actually construct a BigInt, we will
|
||||||
|
* suppress the flow error at that declaration. Mixing BigInts with other types
|
||||||
|
* (e.g. 5n + 3) produces a runtime error, so even though Flow will not save
|
||||||
|
* us from these, they will be easy to detect. See [facebook/flow#6639][flow]
|
||||||
|
*
|
||||||
|
* [BigInt]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt
|
||||||
|
* [flow]: https://github.com/facebook/flow/issues/6639
|
||||||
|
*/
|
||||||
|
export type Grain = number;
|
||||||
|
|
||||||
|
// $ExpectFlowError
|
||||||
|
export const zero = 0n;
|
||||||
|
|
||||||
|
// How many digits of precision there are in "one" grain
|
||||||
|
export const decimalPrecision = 18;
|
||||||
|
|
||||||
|
// One "full" grain
|
||||||
|
// $ExpectFlowError
|
||||||
|
export const one = 10n ** BigInt(decimalPrecision);
|
||||||
|
|
||||||
|
export const DEFAULT_SUFFIX = "g";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a grain balance as a human-readable number, dividing the
|
||||||
|
* raw grain balance by `one`.
|
||||||
|
*
|
||||||
|
* The client controls how many digits of precision are shown; by default, we
|
||||||
|
* display zero digits. Grain balances will have commas added as
|
||||||
|
* thousands-separators if the balance is greater than 1000g.
|
||||||
|
*
|
||||||
|
* The client also specifies a suffix; by default, we use 'g' for grain.
|
||||||
|
*
|
||||||
|
* Here are some examples of its behavior, pretending that we use 2 decimals
|
||||||
|
* of precision for readability:
|
||||||
|
*
|
||||||
|
* format(133700042n) === "1,337,000g"
|
||||||
|
* format(133700042n, 2) === "1,337,000.42g"
|
||||||
|
* format(133700042n, 2, "seeds") === "1,337,000.42seeds"
|
||||||
|
* format(133700042n, 2, "") === "1,337,000.42"
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export function format(
|
||||||
|
grain: Grain,
|
||||||
|
decimals: number = 0,
|
||||||
|
suffix: string = DEFAULT_SUFFIX
|
||||||
|
): string {
|
||||||
|
if (
|
||||||
|
!Number.isInteger(decimals) ||
|
||||||
|
decimals < 0 ||
|
||||||
|
decimals > decimalPrecision
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`decimals must be integer in range [0..${decimalPrecision}]`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isNegative = grain < 0;
|
||||||
|
let digits = [...grain.toString()];
|
||||||
|
if (isNegative) {
|
||||||
|
// Remove the negative sign for consistency, we'll prepend it back at the end
|
||||||
|
digits = digits.slice(1, digits.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the number is less than one, we need to pad it with zeros at the front
|
||||||
|
if (digits.length < decimalPrecision + 1) {
|
||||||
|
digits = [
|
||||||
|
...new Array(decimalPrecision + 1 - digits.length).fill("0"),
|
||||||
|
...digits,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// If we have more than 1000 grain, then we will insert commas for
|
||||||
|
// readability
|
||||||
|
const integerDigits = digits.length - decimalPrecision;
|
||||||
|
const numCommasToInsert = Math.floor(integerDigits / 3);
|
||||||
|
for (let i = 0; i < numCommasToInsert; i++) {
|
||||||
|
// Count digits backwards from the last integer.
|
||||||
|
// Since we are moving from high index to low, we don't need to adjust for
|
||||||
|
// the fact that we're mutating the length of the array as we go... if you
|
||||||
|
// are concerned, rest assured that this logic is tested :)
|
||||||
|
digits.splice(integerDigits - i * 3 - 3, 0, ",");
|
||||||
|
}
|
||||||
|
if (decimals > 0) {
|
||||||
|
// Insert a decimal point at the right spot
|
||||||
|
digits.splice(digits.length - decimalPrecision, 0, ".");
|
||||||
|
}
|
||||||
|
// Slice away all the unwanted precision
|
||||||
|
digits = digits.slice(0, digits.length - decimalPrecision + decimals);
|
||||||
|
if (isNegative) {
|
||||||
|
// re-insert the negative sign, if appropriate
|
||||||
|
digits.splice(0, 0, "-");
|
||||||
|
}
|
||||||
|
return digits.join("") + suffix;
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import {format, one, decimalPrecision, zero} from "./grain";
|
||||||
|
|
||||||
|
describe("src/grain/grain", () => {
|
||||||
|
describe("format", () => {
|
||||||
|
// $ExpectFlowError
|
||||||
|
const pointOne = one / 10n;
|
||||||
|
// $ExpectFlowError
|
||||||
|
const onePointFive = pointOne * 15n;
|
||||||
|
// $ExpectFlowError
|
||||||
|
const almostOne = one - 1n;
|
||||||
|
// $ExpectFlowError
|
||||||
|
const fortyTwo = one * 42n;
|
||||||
|
// $ExpectFlowError
|
||||||
|
const negative = -1n;
|
||||||
|
// $ExpectFlowError
|
||||||
|
const leet = one * 1337n;
|
||||||
|
// $ExpectFlowError
|
||||||
|
const leetAndSpecial = leet * 1000n + fortyTwo + fortyTwo / 100n;
|
||||||
|
|
||||||
|
it("correctly rounds to smallest integer when decimals==0", () => {
|
||||||
|
expect(format(zero)).toEqual("0g");
|
||||||
|
expect(format(pointOne)).toEqual("0g");
|
||||||
|
expect(format(almostOne)).toEqual("0g");
|
||||||
|
expect(format(one)).toEqual("1g");
|
||||||
|
expect(format(onePointFive)).toEqual("1g");
|
||||||
|
expect(format(fortyTwo)).toEqual("42g");
|
||||||
|
});
|
||||||
|
it("correctly adds comma formatting for large numbers", () => {
|
||||||
|
expect(format(leet)).toEqual("1,337g");
|
||||||
|
expect(format(leet, 1)).toEqual("1,337.0g");
|
||||||
|
expect(format(leet + pointOne)).toEqual("1,337g");
|
||||||
|
expect(format(leet + pointOne, 1)).toEqual("1,337.1g");
|
||||||
|
expect(format(leetAndSpecial, 0)).toEqual("1,337,042g");
|
||||||
|
expect(format(leetAndSpecial, 2)).toEqual("1,337,042.42g");
|
||||||
|
});
|
||||||
|
it("correctly handles negative numbers", () => {
|
||||||
|
expect(format(negative * pointOne)).toEqual("-0g");
|
||||||
|
expect(format(negative * onePointFive)).toEqual("-1g");
|
||||||
|
expect(format(negative * fortyTwo)).toEqual("-42g");
|
||||||
|
expect(format(negative * onePointFive, 1)).toEqual("-1.5g");
|
||||||
|
expect(format(negative * onePointFive, 1)).toEqual("-1.5g");
|
||||||
|
expect(format(negative * leetAndSpecial, 0)).toEqual("-1,337,042g");
|
||||||
|
expect(format(negative * leetAndSpecial, 2)).toEqual("-1,337,042.42g");
|
||||||
|
});
|
||||||
|
it("handles full precision", () => {
|
||||||
|
expect(format(zero, decimalPrecision)).toEqual("0.000000000000000000g");
|
||||||
|
expect(format(one, decimalPrecision)).toEqual("1.000000000000000000g");
|
||||||
|
expect(format(pointOne, decimalPrecision)).toEqual(
|
||||||
|
"0.100000000000000000g"
|
||||||
|
);
|
||||||
|
// $ExpectFlowError
|
||||||
|
expect(format(-12345n, decimalPrecision)).toEqual(
|
||||||
|
"-0.000000000000012345g"
|
||||||
|
);
|
||||||
|
expect(format(leetAndSpecial, decimalPrecision)).toEqual(
|
||||||
|
"1,337,042.420000000000000000g"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("supports alternative suffixes", () => {
|
||||||
|
expect(format(onePointFive, 0, "SEEDS")).toEqual("1SEEDS");
|
||||||
|
expect(format(fortyTwo, 0, "SEEDS")).toEqual("42SEEDS");
|
||||||
|
expect(format(negative * onePointFive, 1, "SEEDS")).toEqual("-1.5SEEDS");
|
||||||
|
expect(format(negative * leetAndSpecial, 0, "SEEDS")).toEqual(
|
||||||
|
"-1,337,042SEEDS"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("throws an error if decimals is not an integer in range [0..decimalPrecision]", () => {
|
||||||
|
const badValues = [
|
||||||
|
-1,
|
||||||
|
-0.5,
|
||||||
|
0.33,
|
||||||
|
decimalPrecision + 1,
|
||||||
|
Infinity,
|
||||||
|
-Infinity,
|
||||||
|
NaN,
|
||||||
|
];
|
||||||
|
for (const bad of badValues) {
|
||||||
|
expect(() => format(one, bad)).toThrowError("must be integer in range");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -348,7 +348,7 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/helper-plugin-utils" "^7.8.0"
|
"@babel/helper-plugin-utils" "^7.8.0"
|
||||||
|
|
||||||
"@babel/plugin-syntax-bigint@^7.0.0":
|
"@babel/plugin-syntax-bigint@^7.0.0", "@babel/plugin-syntax-bigint@^7.8.3":
|
||||||
version "7.8.3"
|
version "7.8.3"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea"
|
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea"
|
||||||
integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==
|
integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==
|
||||||
|
|
Loading…
Reference in New Issue