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:
Dandelion Mané 2020-03-14 12:31:02 -07:00 committed by GitHub
parent 7483e28024
commit 2397afc3d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 200 additions and 2 deletions

View File

@ -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};

View File

@ -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",

110
src/grain/grain.js Normal file
View File

@ -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;
}

84
src/grain/grain.test.js Normal file
View File

@ -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");
}
});
});
});

View File

@ -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==