diff --git a/.babelrc.js b/.babelrc.js index 266bf4b..ba73b91 100644 --- a/.babelrc.js +++ b/.babelrc.js @@ -17,6 +17,9 @@ const presets = [ "@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}; diff --git a/package.json b/package.json index 9ee0ffd..5ce4dbb 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "devDependencies": { "@babel/core": "^7.8.7", "@babel/plugin-proposal-class-properties": "^7.8.3", + "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/preset-env": "^7.8.7", "@babel/preset-flow": "^7.8.3", "@babel/preset-react": "^7.8.3", diff --git a/src/grain/grain.js b/src/grain/grain.js new file mode 100644 index 0000000..7fc34b4 --- /dev/null +++ b/src/grain/grain.js @@ -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; +} diff --git a/src/grain/grain.test.js b/src/grain/grain.test.js new file mode 100644 index 0000000..2cd837e --- /dev/null +++ b/src/grain/grain.test.js @@ -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"); + } + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index b2e58dc..f1b8c77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -348,7 +348,7 @@ dependencies: "@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" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==