├── .eslintignore
├── .eslintrc.js
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── example
├── index.html
├── index.tsx
├── package.json
├── tsconfig.json
└── yarn.lock
├── package.json
├── src
├── TokenAmount.test.ts
├── TokenAmount.ts
├── characters.ts
├── convert.test.ts
├── convert.ts
├── format.test.ts
├── format.ts
├── index.ts
├── math.test.ts
├── math.ts
└── types.ts
├── tsconfig.json
└── yarn.lock
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | example
3 | dist
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | jest: true,
4 | },
5 | extends: [
6 | 'eslint:recommended',
7 | 'plugin:@typescript-eslint/recommended',
8 | 'plugin:@typescript-eslint/recommended-requiring-type-checking',
9 | 'prettier/@typescript-eslint',
10 | 'plugin:prettier/recommended',
11 | ],
12 | plugins: ['@typescript-eslint', 'prettier'],
13 | parserOptions: {
14 | project: './tsconfig.json',
15 | },
16 | }
17 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | on: push
2 | name: CI
3 | jobs:
4 | CI:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@v1
8 | - name: Install node
9 | uses: actions/setup-node@v1
10 | with:
11 | node-version: 12
12 | - name: npm install
13 | run: npm install
14 | - name: npm build
15 | run: npm run build
16 | - name: npm test
17 | run: npm run test
18 | env:
19 | CI: true
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | .cache/
4 | yarn-error.log
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true
4 | }
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Aragon Association
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 💸 TokenAmount
2 |
3 | [
](https://www.npmjs.com/package/token-amount) [
](https://bundlephobia.com/result?p=token-amount)
4 |
5 | A transportable object for token amounts with formatting.
6 |
7 | ## Usage
8 |
9 | Add it to your project:
10 |
11 | ```console
12 | yarn add token-amount
13 | ```
14 |
15 | Use it:
16 |
17 | ```js
18 | import TokenAmount from 'token-amount'
19 |
20 | const amount = new TokenAmount('9388295879707883945', 18, { symbol: 'ANT' })
21 |
22 | console.log(amount.format()) // '9.39 ANT'
23 | ```
24 |
25 | ## API
26 |
27 | ### Constructor
28 |
29 | #### new TokenAmount(value, decimals, { symbol })
30 |
31 | Instantiates a new `TokenAmount` with the given `value` and `decimals`. The options are optional, and can take a `symbol` (e.g. `"ANT"`).
32 |
33 | ##### Parameters
34 |
35 | - `value`: the amount value, as a `BigInt`, `String`, `Number` or `BigInt`-like (e.g. BN.js).
36 | - `decimals`: the amount of decimals, as a `BigInt`, `String`, `Number` or `BigInt`-like (e.g. BN.js).
37 | - `symbol`: the token symbol, as a `String`.
38 |
39 | ### Formatting
40 |
41 | #### TokenAmount#format(options)
42 |
43 | Formats the token amount.
44 |
45 | ##### Parameters
46 |
47 | - `options.symbol`: the token symbol, as a `String`. Overrides the value set in the constructor.
48 | - `options.commify`: whether the formatted amount should include comma separators
49 | - `options.digits`: the number of digits to display. Defaults to `2`.
50 | - `options.displaySign`: whether the sign (`-` or `+`) should be displayed for the amount.
51 |
52 | #### TokenAmount#toString(options)
53 |
54 | Alias to TokenAmount#format().
55 |
56 | #### TokenAmount.format(amount, decimals, options)
57 |
58 | Static equivalent of `TokenAmount#format()`, with the `TokenAmount` instance passed as a first parameter.
59 |
60 | ### Converting
61 |
62 | #### TokenAmount#convert(rate, targetDecimals, options)
63 |
64 | Converts from a rate, returning a new `TokenAmount` instance with the desired decimals and set options. The conversion rate is expressed as the amount of the output token obtained per unit of the input token. An example would be:
65 |
66 | - Input token: ANT
67 | - Output token: ETH
68 | - Amount of ANT: 10
69 | - Conversion rate: 0.5 ETH per ANT. (1 ANT = 0.5 ETH)
70 | - Converted Amount = 10 \* 0.50 = 5 ETH.
71 |
72 | ##### Parameters
73 |
74 | - `rate`: the rate to convert from, as a `String` or a `Number`.
75 | - `targetDecimals`: the target amount of decimals for the output, as a `BigInt`, `String`, `Number` or `BigInt`-like (e.g. BN.js).
76 | - `options.symbol`: the token symbol, as a `String`.
77 |
78 | #### TokenAmount.convert(amount, rate, targetDecimals, options)
79 |
80 | Static equivalent of `TokenAmount#convert()`, with the `TokenAmount` instance passed as a first parameter.
81 |
82 | ### Transporting
83 |
84 | #### TokenAmount#export()
85 |
86 | Exports the object into a string that can get stored or transported.
87 |
88 | #### TokenAmount.import()
89 |
90 | Instantiates a new instance by importing a string generated by `TokenAmount#export()`.
91 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/example/index.tsx:
--------------------------------------------------------------------------------
1 | import BN from 'bn.js'
2 | import React from 'react'
3 | import ReactDOM from 'react-dom'
4 | import TokenAmount from 'token-amount'
5 |
6 | function App() {
7 | const value = new BN('938829587970788394500000')
8 | const formattedAmount = new TokenAmount(value, 18).format()
9 |
10 | return (
11 |
21 | {formattedAmount}
22 |
23 | )
24 | }
25 |
26 | ReactDOM.render(, document.getElementById('root'))
27 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "start": "parcel index.html",
4 | "build": "parcel build index.html"
5 | },
6 | "dependencies": {
7 | "bn.js": "^5.1.3",
8 | "react": "^16.13.1",
9 | "react-dom": "^16.13.1",
10 | "token-amount": "link:.."
11 | },
12 | "devDependencies": {
13 | "@types/bn.js": "^4.11.6",
14 | "@types/react": "^16.9.49",
15 | "@types/react-dom": "^16.9.8",
16 | "parcel": "^1.12.3",
17 | "typescript": "^3.4.5"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "target": "es2015",
5 | "module": "commonjs",
6 | "jsx": "react",
7 | "moduleResolution": "node",
8 | "noImplicitAny": true,
9 | "noUnusedLocals": false,
10 | "noUnusedParameters": false,
11 | "removeComments": true,
12 | "strictNullChecks": true,
13 | "preserveConstEnums": true,
14 | "sourceMap": true,
15 | "lib": ["es2015", "es2016", "dom"],
16 | "types": ["node"],
17 | "esModuleInterop": true,
18 | "skipLibCheck": true,
19 | "forceConsistentCasingInFileNames": true
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "token-amount",
3 | "version": "0.3.0",
4 | "author": "Aragon Association ",
5 | "license": "MIT",
6 | "main": "dist/index.js",
7 | "module": "dist/token-amount.esm.js",
8 | "sideEffects": false,
9 | "typings": "dist/index.d.ts",
10 | "files": [
11 | "dist"
12 | ],
13 | "engines": {
14 | "node": ">=10"
15 | },
16 | "browserslist": [
17 | "> 2%",
18 | "not dead",
19 | "not ie > 0"
20 | ],
21 | "scripts": {
22 | "start": "tsdx watch --transpileOnly",
23 | "build": "tsdx build",
24 | "test": "tsdx test",
25 | "lint": "tsdx lint src --max-warnings 0 && tsc",
26 | "prepare": "tsdx build",
27 | "prepublishOnly": "git push && git push --tags",
28 | "size": "size-limit",
29 | "analyze": "size-limit --why"
30 | },
31 | "husky": {
32 | "hooks": {
33 | "pre-commit": "yarn lint"
34 | }
35 | },
36 | "size-limit": [
37 | {
38 | "path": "dist/token-amount.cjs.production.min.js",
39 | "limit": "10 KB"
40 | },
41 | {
42 | "path": "dist/token-amount.esm.js",
43 | "limit": "10 KB"
44 | }
45 | ],
46 | "dependencies": {
47 | "jsbi": "^3.1.4"
48 | },
49 | "devDependencies": {
50 | "@size-limit/preset-small-lib": "^4.6.0",
51 | "@typescript-eslint/eslint-plugin": "^4.3.0",
52 | "@typescript-eslint/parser": "^4.3.0",
53 | "eslint-plugin-prettier": "^3.1.4",
54 | "husky": "^4.3.0",
55 | "prettier": "^2.1.2",
56 | "size-limit": "^4.6.0",
57 | "tsdx": "^0.14.0",
58 | "tslib": "^2.0.1",
59 | "typescript": "^4.0.3"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/TokenAmount.test.ts:
--------------------------------------------------------------------------------
1 | /* global BigInt */
2 | import TokenAmount from './TokenAmount'
3 |
4 | describe('formatTokenAmount()', () => {
5 | test('should instanciate correctly', () => {
6 | const amount = new TokenAmount(BigInt('9388295879707883945'), 18, {
7 | symbol: 'ANT',
8 | })
9 | expect(amount.value).toEqual('9388295879707883945')
10 | expect(amount.decimals).toEqual(18)
11 | expect(amount.symbol).toEqual('ANT')
12 | })
13 | test('should convert correctly', () => {
14 | const oneEth = BigInt('1000000000000000000')
15 | const amount1 = new TokenAmount(oneEth, 18, {
16 | symbol: 'ANT',
17 | })
18 |
19 | // Convert a token amount with a number rate
20 | expect(amount1.convert(0.5, 4).value.toString()).toEqual('5000')
21 |
22 | const amount2 = new TokenAmount(oneEth, 18)
23 |
24 | expect(amount2.convert('400', '2').value.toString()).toEqual('40000')
25 |
26 | const amount3 = new TokenAmount('1', 0)
27 |
28 | expect(amount3.convert('0.5', 2).value.toString()).toEqual('50')
29 |
30 | const amount4 = new TokenAmount(oneEth, 18, { symbol: 'ANT' })
31 |
32 | // 1 ANT to ANJ price on 04/09/20 (DD/MM/YY)
33 | expect(
34 | amount4.convert('70.63271216490210467', 18).value.toString()
35 | ).toEqual('70632712164902104670')
36 |
37 | const amount5 = new TokenAmount('2000000000000000000', 18, {
38 | symbol: 'ANT',
39 | })
40 |
41 | expect(
42 | amount5.convert('70.63271216490210467', 18).value.toString()
43 | ).toEqual('141265424329804209340')
44 |
45 | // 1 ETH -> USDC (18 decimals to 6 decimals conversion)
46 | const amount6 = new TokenAmount(oneEth, 18, { symbol: 'ANT' })
47 |
48 | expect(amount6.convert('384.049', 6).value.toString()).toEqual('384049000')
49 |
50 | const amount7 = new TokenAmount(oneEth, 18, { symbol: 'ANT' })
51 |
52 | expect(amount7.convert('102', 1).value.toString()).toEqual('1020')
53 |
54 | // Tiny rate (ANT to WBTC)
55 | // 0.00537237 WBTC per 1 ANT
56 | // Also 18 decimals -> 8 decimals
57 | const amount8 = new TokenAmount(oneEth, 18, {
58 | symbol: 'ANT',
59 | })
60 |
61 | expect(amount8.convert('0.00053727', 8).value.toString()).toEqual('53727')
62 |
63 | // WBTC to ANT (reverse case)
64 | const amount9 = new TokenAmount('100000000', 8, {
65 | symbol: 'WBTC',
66 | })
67 |
68 | expect(amount9.convert('1823.17', 18).value.toString()).toEqual(
69 | '1823170000000000000000'
70 | )
71 | })
72 | test('should format correctly', () => {
73 | const amount = new TokenAmount(BigInt('9388295879707883945'), 18)
74 | expect(amount.format()).toEqual('9.39')
75 | expect(amount.toString()).toEqual('9.39')
76 | expect(amount.format({ digits: 1 })).toEqual('9.4')
77 | expect(amount.format({ digits: 0 })).toEqual('9')
78 | })
79 | test('should export correctly', () => {
80 | const amount1 = new TokenAmount(BigInt('9388295879707883945'), 18)
81 | expect(amount1.export()).toEqual(
82 | JSON.stringify({ d: 18, v: '9388295879707883945' })
83 | )
84 | const amount2 = new TokenAmount(BigInt('9388295879707883945'), 18, {
85 | symbol: 'ANT',
86 | })
87 | expect(amount2.export()).toEqual(
88 | JSON.stringify({ d: 18, v: '9388295879707883945', s: 'ANT' })
89 | )
90 | })
91 | test('should import correctly', () => {
92 | const exported = new TokenAmount(BigInt('9388295879707883945'), 18, {
93 | symbol: 'ANT',
94 | }).export()
95 | const amount = TokenAmount.import(exported)
96 |
97 | expect(amount.value).toEqual('9388295879707883945')
98 | expect(amount.decimals).toEqual(18)
99 | expect(amount.symbol).toEqual('ANT')
100 | })
101 | })
102 |
--------------------------------------------------------------------------------
/src/TokenAmount.ts:
--------------------------------------------------------------------------------
1 | import JSBI from 'jsbi'
2 | import { convertAmount } from './convert'
3 | import { formatTokenAmount } from './format'
4 | import { BigIntish, ExportData, Options, Rate } from './types'
5 |
6 | export default class TokenAmount {
7 | #decimals: JSBI
8 | #value: JSBI
9 | #symbol: string
10 |
11 | constructor(value: BigIntish, decimals: BigIntish, { symbol = '' } = {}) {
12 | this.#decimals = JSBI.BigInt(String(decimals))
13 | this.#value = JSBI.BigInt(String(value))
14 | this.#symbol = symbol
15 | }
16 |
17 | get decimals(): number {
18 | return JSBI.toNumber(this.#decimals)
19 | }
20 |
21 | get symbol(): string {
22 | return this.#symbol
23 | }
24 |
25 | get value(): string {
26 | return this.#value.toString()
27 | }
28 |
29 | export(): string {
30 | const { decimals, symbol, value } = this
31 | const data: ExportData = { d: decimals, v: value }
32 | if (symbol) {
33 | data.s = symbol
34 | }
35 | return JSON.stringify(data)
36 | }
37 |
38 | static import(json: string): TokenAmount {
39 | let data: ExportData
40 |
41 | try {
42 | data = JSON.parse(json) as ExportData
43 | } catch (err) {
44 | throw new Error('TokenAmount.import(): couldn’t parse data.')
45 | }
46 | if (!data.d || !data.v) {
47 | throw new Error('TokenAmount.import(): invalid data format provided.')
48 | }
49 | return new TokenAmount(data.v, data.d, { symbol: data.s })
50 | }
51 |
52 | static convert(
53 | amount: { value: BigIntish; decimals: BigIntish },
54 | convertRate: Rate,
55 | targetDecimals: BigIntish,
56 | options?: Options
57 | ): TokenAmount {
58 | const convertedAmount = convertAmount(
59 | amount.value,
60 | amount.decimals,
61 | convertRate,
62 | targetDecimals
63 | )
64 | return new TokenAmount(convertedAmount, targetDecimals, options)
65 | }
66 |
67 | convert(
68 | rate: Rate,
69 | targetDecimals: BigIntish,
70 | options?: Options
71 | ): TokenAmount {
72 | return TokenAmount.convert(this, rate, targetDecimals, options)
73 | }
74 |
75 | static format = formatTokenAmount
76 |
77 | format(options?: Options): string {
78 | return formatTokenAmount(this.#value, this.#decimals, {
79 | symbol: this.#symbol,
80 | ...options,
81 | })
82 | }
83 |
84 | toString(options?: Options): string {
85 | return this.format(options)
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/characters.ts:
--------------------------------------------------------------------------------
1 | export const NO_BREAK_SPACE = '\u00a0'
2 |
--------------------------------------------------------------------------------
/src/convert.test.ts:
--------------------------------------------------------------------------------
1 | import { convertAmount } from './convert'
2 |
3 | const ONE_ETH = 1000000000000000000n
4 |
5 | describe('convertAmount tests', () => {
6 | test('Converts amounts correctly', () => {
7 | expect(convertAmount(1, 1, 1, 1)).toEqual('1')
8 | expect(convertAmount(ONE_ETH, 18, 0.5, 4)).toEqual('5000')
9 | expect(convertAmount(1, 0, 0.5, 2)).toEqual('50')
10 | expect(convertAmount(1, 0, 0.25, 2)).toEqual('25')
11 | expect(convertAmount(1, 0, 0.125, 3)).toEqual('125')
12 | expect(convertAmount(100, 0, 50, 0)).toEqual('5000')
13 | expect(convertAmount(ONE_ETH, 18, 400, 2)).toEqual('40000')
14 | })
15 |
16 | test('Converts amounts correctly with rates being strings', () => {
17 | expect(convertAmount(1, 1, '1', 1)).toEqual('1')
18 | expect(convertAmount(ONE_ETH, 18, '0.5', 4)).toEqual('5000')
19 | expect(convertAmount(1, 0, '0.5', 2)).toEqual('50')
20 | expect(convertAmount(1, 0, '0.25', 2)).toEqual('25')
21 | expect(convertAmount(1, 0, '0.125', 3)).toEqual('125')
22 | expect(convertAmount(100, 0, '50', 0)).toEqual('5000')
23 | expect(convertAmount(ONE_ETH, 18, '400', 2)).toEqual('40000')
24 | expect(convertAmount(4000n * ONE_ETH, 18, '400', 2)).toEqual('160000000')
25 | })
26 |
27 | test('Rounds properly during the conversion', () => {
28 | expect(convertAmount(ONE_ETH, 18, '23998327.34987439', 2)).toEqual(
29 | '2399832735'
30 | )
31 | expect(convertAmount(1, 2, '23998327.34987439', 9)).toEqual(
32 | '239983273498744'
33 | )
34 | expect(convertAmount(ONE_ETH, 18, '23998327.74987439', 0)).toEqual(
35 | '23998328'
36 | )
37 | })
38 |
39 | test('Handles conversion rates higher than the amount', () => {
40 | expect(convertAmount(10, 0, '23998327.76987439', 0)).toEqual('239983278')
41 | })
42 | })
43 |
--------------------------------------------------------------------------------
/src/convert.ts:
--------------------------------------------------------------------------------
1 | import JSBI from 'jsbi'
2 | import { divideRoundBigInt } from './math'
3 | import { BigIntish } from './types'
4 |
5 | // Cache 10 since we are using it a lot.
6 | const _10 = JSBI.BigInt(10)
7 |
8 | /**
9 | * Converts an amount. The conversion rate is expressed as the amount of the output token
10 | * obtained per unit of the input token.
11 | *
12 | * e.g:
13 | * Input token: ANT
14 | * Output token: ETH
15 | * Amount of ANT: 10
16 | * Conversion rate: 0.5 ETH per ANT. (1 ANT = 0.5 ETH)
17 | * Converted Amount = 10 * 0.50 = 5 ETH.
18 | *
19 | * @param {BigInt|string|number} amount Amount of the input token to convert.
20 | * @param {BigInt|string|number} decimals Decimals of the input token to convert.
21 | * @param {string|number} convertRate Rate of conversion between the input and output token.
22 | * @param {BigInt|string|number} targetDecimals Decimals for the output amount.
23 | * @returns {string}
24 | */
25 | export function convertAmount(
26 | amount: BigIntish,
27 | decimals: BigIntish,
28 | convertRate: string | number,
29 | targetDecimals: BigIntish
30 | ): string {
31 | const parsedAmount = JSBI.BigInt(String(amount))
32 | const parsedDecimals = JSBI.BigInt(String(decimals))
33 | const parsedTargetDecimals = JSBI.BigInt(String(targetDecimals))
34 |
35 | const [rateWhole = '', rateDec = ''] = String(convertRate).split('.')
36 |
37 | // Remove any trailing zeros from the decimal part
38 | const parsedRateDec = rateDec.replace(/0*$/, '')
39 |
40 | // Construct the final rate, and remove any leading zeros
41 | const rate = JSBI.BigInt(`${rateWhole}${parsedRateDec}`.replace(/^0*/, ''))
42 |
43 | const ratePrecision = JSBI.BigInt(parsedRateDec.length)
44 | const scaledRate = JSBI.multiply(
45 | rate,
46 | JSBI.exponentiate(_10, parsedTargetDecimals)
47 | )
48 |
49 | const convertedAmount = divideRoundBigInt(
50 | divideRoundBigInt(
51 | JSBI.multiply(parsedAmount, scaledRate),
52 | JSBI.exponentiate(_10, ratePrecision)
53 | ),
54 | JSBI.exponentiate(_10, parsedDecimals)
55 | )
56 |
57 | return String(convertedAmount)
58 | }
59 |
--------------------------------------------------------------------------------
/src/format.test.ts:
--------------------------------------------------------------------------------
1 | /* global BigInt */
2 | import { NO_BREAK_SPACE } from './characters'
3 | import { formatNumber, formatTokenAmount } from './format'
4 |
5 | describe('formatNumber()', () => {
6 | test('should add commas', () => {
7 | expect(formatNumber(1)).toEqual('1')
8 | expect(formatNumber(12)).toEqual('12')
9 | expect(formatNumber(123)).toEqual('123')
10 | expect(formatNumber(1234)).toEqual('1,234')
11 | expect(formatNumber(12345)).toEqual('12,345')
12 | expect(formatNumber(123456)).toEqual('123,456')
13 | expect(formatNumber(1234567)).toEqual('1,234,567')
14 | })
15 |
16 | test('should work with decimals', () => {
17 | expect(formatNumber(1.3)).toEqual('1.3')
18 | expect(formatNumber(12.12)).toEqual('12.12')
19 | expect(formatNumber(123.123)).toEqual('123.123')
20 | expect(formatNumber(1234.1234)).toEqual('1,234.1234')
21 | expect(formatNumber(12345.12345)).toEqual('12,345.12345')
22 | expect(formatNumber(123456.123456)).toEqual('123,456.123456')
23 | expect(formatNumber(1234567.1234567)).toEqual('1,234,567.1234567')
24 | })
25 |
26 | test('should work with strings', () => {
27 | expect(formatNumber('123456123456')).toEqual('123,456,123,456')
28 | expect(formatNumber('1234567.1234567')).toEqual('1,234,567.1234567')
29 | })
30 |
31 | test('should work with BigInt', () => {
32 | expect(formatNumber(BigInt('123456123456239873298739287'))).toEqual(
33 | '123,456,123,456,239,873,298,739,287'
34 | )
35 | })
36 | })
37 |
38 | describe('formatTokenAmount()', () => {
39 | test('should handle native numbers', () => {
40 | expect(formatTokenAmount(9381295879707883945, 18, { digits: 1 })).toEqual(
41 | '9.4'
42 | )
43 | })
44 |
45 | test('should format numbers properly', () => {
46 | expect(
47 | formatTokenAmount('32989381295879707883945', 18, { digits: 4 })
48 | ).toEqual('32,989.3813')
49 | })
50 |
51 | test('should handle string numbers', () => {
52 | expect(
53 | formatTokenAmount('2839381295879707883945', 18, { digits: 18 })
54 | ).toEqual('2,839.381295879707883945')
55 | })
56 |
57 | test('should handle BigInt-like numbers', () => {
58 | expect(
59 | formatTokenAmount(BigInt('2839381295879707883945'), 18, { digits: 8 })
60 | ).toEqual('2,839.38129588')
61 | })
62 |
63 | test('should handle large numbers without decimals', () => {
64 | expect(
65 | formatTokenAmount(BigInt('2839000000000000000000'), 18, { digits: 8 })
66 | ).toEqual('2,839')
67 | })
68 |
69 | test('should handle really big numbers', () => {
70 | expect(
71 | formatTokenAmount(
72 | BigInt('9873298739827329832792839381295879707883945'),
73 | 18,
74 | { digits: 8 }
75 | )
76 | ).toEqual('9,873,298,739,827,329,832,792,839.38129588')
77 | })
78 |
79 | test('should round properly', () => {
80 | expect(
81 | formatTokenAmount(BigInt('4442839381295879707883948'), 18, { digits: 17 })
82 | ).toEqual('4,442,839.38129587970788395')
83 | })
84 |
85 | test('should add symbol', () => {
86 | expect(
87 | formatTokenAmount(BigInt('4442839381295879707883948'), 18, {
88 | digits: 17,
89 | symbol: 'ANT',
90 | })
91 | ).toEqual(`4,442,839.38129587970788395${NO_BREAK_SPACE}ANT`)
92 | })
93 |
94 | test('should remove trailing zeros', () => {
95 | expect(
96 | formatTokenAmount(BigInt('283938129587970000000'), 18, { digits: 18 })
97 | ).toEqual(`283.93812958797`)
98 | })
99 |
100 | test('should truncate decimals after the last significant digit', () => {
101 | expect(
102 | formatTokenAmount(BigInt('2839000000010000000000'), 18, { digits: 8 })
103 | ).toEqual('2,839.00000001')
104 | expect(
105 | formatTokenAmount(BigInt('2839000000010000000000'), 18, { digits: 9 })
106 | ).toEqual('2,839.00000001')
107 | expect(
108 | formatTokenAmount(BigInt('2839000000010000000000'), 18, { digits: 7 })
109 | ).toEqual('2,839')
110 | })
111 |
112 | test('should handle non-18 decimal units', () => {
113 | expect(
114 | formatTokenAmount(BigInt('2839000000010000000000'), 10, { digits: 8 })
115 | ).toEqual('283,900,000,001')
116 | expect(formatTokenAmount(BigInt('283900010000'), 6, { digits: 3 })).toEqual(
117 | '283,900.01'
118 | )
119 | expect(
120 | formatTokenAmount(BigInt('28390000000100000000000000'), 24, { digits: 7 })
121 | ).toEqual('28.39')
122 | })
123 |
124 | test('should handle decimals units smaller than digits', () => {
125 | expect(formatTokenAmount(BigInt('283999'), 4, { digits: 8 })).toEqual(
126 | '28.3999'
127 | )
128 | })
129 |
130 | test('should handle decimals units greater than digits', () => {
131 | expect(
132 | formatTokenAmount(BigInt('2839000000010000000009'), 6, { digits: 10 })
133 | ).toEqual('2,839,000,000,010,000.000009')
134 | })
135 |
136 | test('should handle zero decimals units', () => {
137 | expect(formatTokenAmount(BigInt('2839'), 0)).toEqual('2,839')
138 | })
139 |
140 | test('should display the sign', () => {
141 | expect(
142 | formatTokenAmount(BigInt('4442839381295879707883948'), 18, {
143 | digits: 17,
144 | displaySign: true,
145 | })
146 | ).toEqual(`+4,442,839.38129587970788395`)
147 |
148 | expect(
149 | formatTokenAmount(BigInt('-4442839381295879707883948'), 18, {
150 | digits: 17,
151 | displaySign: true,
152 | })
153 | ).toEqual(`-4,442,839.38129587970788395`)
154 |
155 | expect(formatTokenAmount(0, 18, { digits: 17, displaySign: true })).toEqual(
156 | '+0'
157 | )
158 | // Todo: support negative 0s
159 | expect(
160 | formatTokenAmount(-0, 18, { digits: 17, displaySign: true })
161 | ).toEqual('+0')
162 | })
163 |
164 | test('should throw when a negative number is used for decimals', () => {
165 | expect(() => {
166 | formatTokenAmount(BigInt('2839000000010000000000'), -1)
167 | }).toThrow()
168 | })
169 |
170 | test('should throw when a negative number is being used for digits', () => {
171 | expect(() => {
172 | formatTokenAmount(BigInt('2839000000010000000000'), 18, { digits: -2 })
173 | }).toThrow()
174 | })
175 |
176 | test('should not show commas if the commify option is not being passed in', () => {
177 | expect(
178 | formatTokenAmount(BigInt('1000000000000000000000'), 18, {
179 | commify: false,
180 | })
181 | ).toEqual('1000')
182 | expect(
183 | formatTokenAmount(BigInt('98765000000000000000000'), 18, {
184 | commify: false,
185 | })
186 | ).toEqual('98765')
187 | expect(
188 | formatTokenAmount(BigInt('501924000000000000000000'), 18, {
189 | commify: false,
190 | })
191 | ).toEqual('501924')
192 | })
193 | })
194 |
--------------------------------------------------------------------------------
/src/format.ts:
--------------------------------------------------------------------------------
1 | import JSBI from 'jsbi'
2 | import { NO_BREAK_SPACE } from './characters'
3 | import { divideRoundBigInt } from './math'
4 | import { BigIntish, Options } from './types'
5 |
6 | /**
7 | * Formats a number for display purposes.
8 | *
9 | * This function is not using Intl.NumberFormat() to be compatible with big
10 | * integers expressed as string, or BigInt-like objects.
11 | *
12 | * @param {BigInt|string|number} number Number to convert
13 | * @returns {string}
14 | */
15 | export function formatNumber(number: BigIntish): string {
16 | const numAsString = String(number)
17 | const [integer, decimals] = numAsString.split('.')
18 |
19 | return [...integer].reverse().reduce(
20 | (result, digit, index) => {
21 | return `${digit}${index > 0 && index % 3 === 0 ? ',' : ''}${result}`
22 | },
23 | decimals ? `.${decimals}` : ''
24 | )
25 | }
26 |
27 | /**
28 | * Formats a token amount for display purposes.
29 | *
30 | * @param {BigInt|string|number} amount Number to round
31 | * @param {BigInt|string|number} decimals Decimal placement for amount
32 | * @param {BigInt|string|number} digits Rounds the number to a given decimal place
33 | * @param {boolean} options.commify Decides if the formatted amount should include commas
34 | * @param {boolean} options.displaySign Decides if the sign should be displayed
35 | * @param {string} options.symbol Symbol for the token amount
36 | * @returns {string}
37 | */
38 | export function formatTokenAmount(
39 | amount: BigIntish,
40 | decimals: BigIntish,
41 | options: Options = {}
42 | ): string {
43 | const {
44 | commify = true,
45 | digits = 2,
46 | symbol = '',
47 | displaySign = false,
48 | } = options
49 |
50 | let parsedAmount = JSBI.BigInt(String(amount))
51 | const parsedDecimals = JSBI.BigInt(String(decimals))
52 | let parsedDigits = JSBI.BigInt(String(digits))
53 |
54 | const _0 = JSBI.BigInt(0)
55 | const _10 = JSBI.BigInt(10)
56 |
57 | if (JSBI.lessThan(parsedDecimals, _0)) {
58 | throw new Error('formatTokenAmount(): decimals cannot be negative')
59 | }
60 |
61 | if (JSBI.lessThan(parsedDigits, _0)) {
62 | throw new Error('formatTokenAmount(): digits cannot be negative')
63 | }
64 |
65 | if (JSBI.lessThan(parsedDecimals, parsedDigits)) {
66 | parsedDigits = parsedDecimals
67 | }
68 |
69 | const negative = JSBI.lessThan(parsedAmount, _0)
70 |
71 | if (negative) {
72 | parsedAmount = JSBI.unaryMinus(parsedAmount)
73 | }
74 |
75 | const amountConverted = JSBI.equal(parsedDecimals, _0)
76 | ? parsedAmount
77 | : JSBI.BigInt(
78 | divideRoundBigInt(
79 | parsedAmount,
80 | JSBI.exponentiate(_10, JSBI.subtract(parsedDecimals, parsedDigits))
81 | )
82 | )
83 |
84 | const leftPart = JSBI.divide(
85 | amountConverted,
86 | JSBI.exponentiate(_10, parsedDigits)
87 | )
88 | const processedLeftPart = commify ? formatNumber(leftPart) : leftPart
89 |
90 | const rightPart = String(
91 | JSBI.remainder(amountConverted, JSBI.exponentiate(_10, parsedDigits))
92 | )
93 | .padStart(Number(parsedDigits), '0')
94 | .replace(/0+$/, '')
95 |
96 | return [
97 | displaySign ? (negative ? '-' : '+') : '',
98 | processedLeftPart,
99 | rightPart ? `.${rightPart}` : '',
100 | symbol ? `${NO_BREAK_SPACE}${symbol}` : '',
101 | ].join('')
102 | }
103 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import TokenAmount from './TokenAmount'
2 |
3 | export default TokenAmount
4 |
--------------------------------------------------------------------------------
/src/math.test.ts:
--------------------------------------------------------------------------------
1 | /* global BigInt */
2 |
3 | import { divideRoundBigInt } from './math'
4 |
5 | describe('divideRoundBigInt()', () => {
6 | test('should round the result', () => {
7 | expect(divideRoundBigInt(BigInt('479238'), BigInt('10'))).toEqual('47924')
8 | expect(divideRoundBigInt(BigInt('479238'), BigInt('5'))).toEqual('95848')
9 | })
10 | })
11 |
--------------------------------------------------------------------------------
/src/math.ts:
--------------------------------------------------------------------------------
1 | import JSBI from 'jsbi'
2 | import { BigIntish } from './types'
3 |
4 | /**
5 | * Divide and round two big integers.
6 | *
7 | * @param {BigInt|string|number} dividend Integer to be divided + rounded
8 | * @param {BigInt|string|number} divisor Divisor
9 | * @returns {string}
10 | */
11 | export function divideRoundBigInt(
12 | dividend: BigIntish,
13 | divisor: BigIntish
14 | ): string {
15 | const parsedDividend = JSBI.BigInt(String(dividend))
16 | const parsedDivisor = JSBI.BigInt(String(divisor))
17 |
18 | return JSBI.divide(
19 | JSBI.add(parsedDividend, JSBI.divide(parsedDivisor, JSBI.BigInt(2))),
20 | parsedDivisor
21 | ).toString()
22 | }
23 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export type BigIntish = bigint | { toString: () => string } | string | number
2 |
3 | export type Rate = string | number
4 |
5 | export type Options = {
6 | commify?: boolean
7 | digits?: BigIntish
8 | symbol?: string
9 | displaySign?: boolean
10 | }
11 |
12 | export type ExportData = {
13 | v: string
14 | d: number
15 | s?: string
16 | }
17 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs
3 | "include": ["src"],
4 | "compilerOptions": {
5 | "module": "esnext",
6 | "lib": ["dom", "esnext"],
7 | "importHelpers": true,
8 | "target": "ES2020",
9 | // output .d.ts declaration files for consumers
10 | "declaration": true,
11 | // output .js.map sourcemap files for consumers
12 | "sourceMap": true,
13 | // match output dir to input dir. e.g. dist/index instead of dist/src/index
14 | "rootDir": "./src",
15 | // stricter type-checking for stronger correctness. Recommended by TS
16 | "strict": true,
17 | // linter checks for common issues
18 | "noImplicitReturns": true,
19 | "noFallthroughCasesInSwitch": true,
20 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | // use Node's module resolution algorithm, instead of the legacy TS one
24 | "moduleResolution": "node",
25 | // interop between ESM and CJS modules. Recommended by TS
26 | "esModuleInterop": true,
27 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS
28 | "skipLibCheck": true,
29 | // error out if import and file system have a casing mismatch. Recommended by TS
30 | "forceConsistentCasingInFileNames": true,
31 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc`
32 | "noEmit": true
33 | }
34 | }
35 |
--------------------------------------------------------------------------------