Fixes for amount conversion & BigNumber lib de-duping (#2913)

### Description

- Bring BigNumber de-duping improvements from @ottbunn into v3
- Fixes for amount conversion
- Create unit tests for some amount fns
- Run utils unit tests in CI

### Related issues

https://github.com/hyperlane-xyz/issues/issues/690
https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/2751
https://github.com/hyperlane-xyz/hyperlane-monorepo/pull/2828

### Backward compatibility

No, minor breaking changes to signatures of amount util fns

### Testing

Unit tests
3.1.4
J M Rossy 12 months ago committed by GitHub
parent 66d064a324
commit bd70a599e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 14
      typescript/infra/src/config/gas-oracle.ts
  2. 3
      typescript/utils/.mocharc.json
  3. 1
      typescript/utils/index.ts
  4. 6
      typescript/utils/package.json
  5. 54
      typescript/utils/src/amount.test.ts
  6. 146
      typescript/utils/src/amount.ts
  7. 133
      typescript/utils/src/big-numbers.test.ts
  8. 76
      typescript/utils/src/big-numbers.ts
  9. 7
      typescript/utils/src/multisig.ts
  10. 1
      yarn.lock

@ -1,7 +1,7 @@
import { BigNumber, ethers } from 'ethers';
import { ChainMap, ChainName } from '@hyperlane-xyz/sdk';
import { convertDecimalsEthersBigNumber } from '@hyperlane-xyz/utils';
import { convertDecimals } from '@hyperlane-xyz/utils';
import { mustGetChainNativeTokenDecimals } from '../utils/utils';
@ -77,15 +77,17 @@ export function getTokenExchangeRateFromValues(
localValue: BigNumber,
remote: ChainName,
remoteValue: BigNumber,
) {
): BigNumber {
// This does not yet account for decimals!
const exchangeRate = remoteValue
.mul(TOKEN_EXCHANGE_RATE_MULTIPLIER)
.div(localValue);
return convertDecimalsEthersBigNumber(
mustGetChainNativeTokenDecimals(remote),
mustGetChainNativeTokenDecimals(local),
exchangeRate,
return BigNumber.from(
convertDecimals(
mustGetChainNativeTokenDecimals(remote),
mustGetChainNativeTokenDecimals(local),
exchangeRate.toString(),
),
);
}

@ -0,0 +1,3 @@
{
"require": ["ts-node/register"]
}

@ -39,7 +39,6 @@ export {
} from './src/addresses';
export {
convertDecimals,
convertDecimalsEthersBigNumber,
eqAmountApproximate,
fromWei,
fromWeiRounded,

@ -6,7 +6,8 @@
"@cosmjs/encoding": "^0.31.3",
"@solana/web3.js": "^1.78.0",
"bignumber.js": "^9.1.1",
"ethers": "^5.7.2"
"ethers": "^5.7.2",
"mocha": "^10.2.0"
},
"devDependencies": {
"chai": "^4.3.0",
@ -27,7 +28,8 @@
"build": "tsc",
"clean": "rm -rf ./dist",
"check": "tsc --noEmit",
"prettier": "prettier --write ./src"
"prettier": "prettier --write ./src",
"test": "mocha --config .mocharc.json './src/**/*.test.ts'"
},
"sideEffects": false,
"types": "dist/index.d.ts",

@ -0,0 +1,54 @@
import { expect } from 'chai';
import { eqAmountApproximate, fromWei, fromWeiRounded, toWei } from './amount';
describe('fromWei', () => {
it('parses and converts correctly', () => {
expect(fromWei(1, 0)).to.equal('1');
expect(fromWei('1000000', 6)).to.equal('1');
expect(fromWei('1000000000000000000')).to.equal('1');
expect(fromWei('1000000000000000000.1234')).to.equal('1');
});
});
describe('fromWeiRounded', () => {
it('parses and converts correctly', () => {
expect(fromWeiRounded(1, 0)).to.equal('1.0000');
expect(fromWeiRounded('1000000', 6)).to.equal('1.0000');
expect(fromWeiRounded('1000000000000000000')).to.equal('1.0000');
expect(fromWeiRounded('1000000000000000000.1234')).to.equal('1.0000');
});
it('rounds correctly', () => {
expect(fromWeiRounded(1234567890, 6, 2)).to.equal('1234.56');
expect(fromWeiRounded('1234567890', 6, 4)).to.equal('1234.5678');
expect(fromWeiRounded('10000000000000000000')).to.equal('10.0000');
expect(fromWeiRounded('10000000000000000000', 18, 0)).to.equal('10');
});
it('can drop decimals for large numbers', () => {
expect(fromWeiRounded('10001000000000000000000')).to.equal('10001.00');
expect(fromWeiRounded('10001000000000000000', 15, 4)).to.equal(
'10001.0000',
);
});
});
describe('toWei', () => {
it('parses and converts correctly', () => {
expect(toWei(1, 0)).to.equal('1');
expect(toWei('1', 6)).to.equal('1000000');
expect(toWei('123.456')).to.equal('123456000000000000000');
expect(toWei('1.00000000000000000001')).to.equal('1000000000000000000');
expect(toWei('1.00000000000000000001', 6)).to.equal('1000000');
});
});
describe('eqAmountApproximate', () => {
it('compares correctly', () => {
expect(eqAmountApproximate(1, 1.001, 0.001)).to.be.true;
expect(eqAmountApproximate(9, 9.001, 0.01)).to.be.true;
expect(eqAmountApproximate('9876543210', '9876543210', '1')).to.be.true;
expect(eqAmountApproximate('9876543210', '9876543212', '1')).to.be.false;
});
});

@ -1,77 +1,84 @@
import { formatUnits, parseUnits } from '@ethersproject/units';
import BigNumber from 'bignumber.js';
import { ethers } from 'ethers';
const DEFAULT_MIN_ROUNDED_VALUE = 0.00001;
const DEFAULT_DISPLAY_DECIMALS = 4;
const DEFAULT_TOKEN_DECIMALS = 18;
type NumberT = BigNumber.Value;
/**
* Convert the given Wei value to Ether value
* @param value The value to convert.
* @returns Converted value in string type.
*/
export function fromWei(
value: NumberT | null | undefined,
value: BigNumber.Value | null | undefined,
decimals = DEFAULT_TOKEN_DECIMALS,
): number {
if (!value) return 0;
const valueString = value.toString().trim();
const flooredValue = new BigNumber(valueString).toFixed(
0,
BigNumber.ROUND_FLOOR,
);
return parseFloat(formatUnits(flooredValue, decimals));
): string {
if (!value) return (0).toString();
const valueString = value.toString(10).trim();
const flooredValue = BigNumber(valueString).toFixed(0, BigNumber.ROUND_FLOOR);
return parseFloat(formatUnits(flooredValue, decimals)).toString();
}
// Similar to fromWei above but rounds to set number of decimals
// with a minimum floor, configured per token
/**
* Convert the given Wei value to Ether value,
* round to set number of decimals with a minimum floor, configured per token
* @param value The value to convert.
* @param decimals
* @returns Converted value in string type.
*/
export function fromWeiRounded(
value: NumberT | null | undefined,
value: BigNumber.Value | null | undefined,
decimals = DEFAULT_TOKEN_DECIMALS,
roundDownIfSmall = true,
displayDecimals?: number,
): string {
if (!value) return '0';
const flooredValue = new BigNumber(value).toFixed(0, BigNumber.ROUND_FLOOR);
const amount = new BigNumber(formatUnits(flooredValue, decimals));
const flooredValue = BigNumber(value).toFixed(0, BigNumber.ROUND_FLOOR);
const amount = BigNumber(formatUnits(flooredValue, decimals));
if (amount.isZero()) return '0';
// If amount is less than min value
if (amount.lt(DEFAULT_MIN_ROUNDED_VALUE)) {
if (roundDownIfSmall) return '0';
return amount.toString(10);
}
const displayDecimals = amount.gte(10000) ? 2 : DEFAULT_DISPLAY_DECIMALS;
return amount.toFixed(displayDecimals).toString();
displayDecimals ??= amount.gte(10000) ? 2 : DEFAULT_DISPLAY_DECIMALS;
return amount.toFixed(displayDecimals, BigNumber.ROUND_FLOOR);
}
/**
* Convert the given value to Wei value
* @param value The value to convert.
* @returns Converted value in string type.
*/
export function toWei(
value: NumberT | null | undefined,
value: BigNumber.Value | null | undefined,
decimals = DEFAULT_TOKEN_DECIMALS,
): BigNumber {
if (!value) return new BigNumber(0);
): string {
if (!value) return BigNumber(0).toString();
// First convert to a BigNumber, and then call `toString` with the
// explicit radix 10 such that the result is formatted as a base-10 string
// and not in scientific notation.
const valueBN = new BigNumber(value);
const valueBN = BigNumber(value);
const valueString = valueBN.toString(10).trim();
const components = valueString.split('.');
if (components.length === 1) {
return new BigNumber(parseUnits(valueString, decimals).toString());
return parseUnits(valueString, decimals).toString();
} else if (components.length === 2) {
const trimmedFraction = components[1].substring(0, decimals);
return new BigNumber(
parseUnits(`${components[0]}.${trimmedFraction}`, decimals).toString(),
);
return parseUnits(
`${components[0]}.${trimmedFraction}`,
decimals,
).toString();
} else {
throw new Error(`Cannot convert ${valueString} to wei`);
}
}
/**
* Try to parse the given value into BigNumber.js BigNumber
* @param value The value to parse.
* @returns Parsed value in BigNumber.js BigNumber type.
*/
export function tryParseAmount(
value: NumberT | null | undefined,
value: BigNumber.Value | null | undefined,
): BigNumber | null {
try {
if (!value) return null;
const parsed = new BigNumber(value);
const parsed = BigNumber(value);
if (!parsed || parsed.isNaN() || !parsed.isFinite()) return null;
else return parsed;
} catch (error) {
@ -79,15 +86,20 @@ export function tryParseAmount(
}
}
// Checks if an amount is equal of nearly equal to balance within a small margin of error
// Necessary because amounts in the UI are often rounded
/**
* Checks if an amount is equal of nearly equal to balance within a small margin of error
* Necessary because amounts in the UI are often rounded
* @param amount1 The amount to compare.
* @param amount2 The amount to compare.
* @returns true/false.
*/
export function eqAmountApproximate(
amountInWei1: BigNumber,
amountInWei2: NumberT,
) {
const minValueWei = toWei(DEFAULT_MIN_ROUNDED_VALUE);
// Is difference btwn amount and balance less than min amount shown for token
return amountInWei1.minus(amountInWei2).abs().lt(minValueWei);
amount1: BigNumber.Value,
amount2: BigNumber.Value,
maxDifference: BigNumber.Value,
): boolean {
// Is difference btwn amounts less than maxDifference
return BigNumber(amount1).minus(amount2).abs().lte(maxDifference);
}
/**
@ -96,50 +108,26 @@ export function eqAmountApproximate(
* @param fromDecimals The number of decimals `value` has.
* @param toDecimals The number of decimals to convert `value` to.
* @param value The value to convert.
* @returns `value` represented with `toDecimals` decimals.
* @returns `value` represented with `toDecimals` decimals in string type.
*/
export function convertDecimals(
fromDecimals: number,
toDecimals: number,
value: NumberT,
) {
const amount = new BigNumber(value);
value: BigNumber.Value,
): string {
const amount = BigNumber(value);
if (fromDecimals === toDecimals) return amount;
if (fromDecimals === toDecimals) return amount.toString(10);
else if (fromDecimals > toDecimals) {
const difference = fromDecimals - toDecimals;
return amount
.div(new BigNumber(10).pow(difference))
.integerValue(BigNumber.ROUND_FLOOR);
}
// fromDecimals < toDecimals
else {
const difference = toDecimals - fromDecimals;
return amount.times(new BigNumber(10).pow(difference));
}
}
/**
* Converts a value with `fromDecimals` decimals to a value with `toDecimals` decimals.
* Incurs a loss of precision when `fromDecimals` > `toDecimals`.
* @param fromDecimals The number of decimals `value` has.
* @param toDecimals The number of decimals to convert `value` to.
* @param value The value to convert.
* @returns `value` represented with `toDecimals` decimals.
*/
export function convertDecimalsEthersBigNumber(
fromDecimals: number,
toDecimals: number,
value: ethers.BigNumber,
) {
if (fromDecimals === toDecimals) return value;
else if (fromDecimals > toDecimals) {
const difference = fromDecimals - toDecimals;
return value.div(ethers.BigNumber.from('10').pow(difference));
.div(BigNumber(10).pow(difference))
.integerValue(BigNumber.ROUND_FLOOR)
.toString(10);
}
// fromDecimals < toDecimals
else {
const difference = toDecimals - fromDecimals;
return value.mul(ethers.BigNumber.from('10').pow(difference));
return amount.times(BigNumber(10).pow(difference)).toString(10);
}
}

@ -1,49 +1,118 @@
import BigNumber from 'bignumber.js';
import { expect } from 'chai';
import { BigNumber, FixedNumber } from 'ethers';
import { FixedNumber } from 'ethers';
import { bigToFixed, fixedToBig, mulBigAndFixed } from './big-numbers';
import {
BigNumberMax,
BigNumberMin,
bigToFixed,
fixedToBig,
isBigNumberish,
isZeroish,
mulBigAndFixed,
} from './big-numbers';
describe('utils', () => {
describe('bigToFixed', () => {
it('converts a BigNumber to a FixedNumber', () => {
const big = BigNumber.from('1234');
const fixed = bigToFixed(big);
describe('isBigNumberish', () => {
const testCases = [
{ expect: false, context: 'invalid number', case: 'invalidNumber' },
{ expect: false, context: 'NaN', case: NaN },
{ expect: false, context: 'undefined', case: undefined },
{ expect: false, context: 'null', case: null },
{ expect: true, context: 'decimal', case: 123.123 },
{ expect: true, context: 'integer', case: 300_000 },
{ expect: true, context: 'hex 0', case: 0x00 },
{ expect: true, context: 'hex 0', case: 0x000 },
{
expect: true,
context: 'address 0',
case: 0x0000000000000000000000000000000000000000,
},
];
testCases.forEach((tc) => {
it(`returns ${tc.expect} for ${tc.case}`, () => {
expect(isBigNumberish(tc.case!)).to.equal(tc.expect);
});
});
});
expect(fixed.toUnsafeFloat()).to.equal(1234);
describe('isZeroish', () => {
const testCases = [
{ expect: false, context: 'invalid number', case: 'invalidNumber' },
{ expect: false, context: 'NaN', case: NaN },
{ expect: false, context: 'undefined', case: undefined },
{ expect: false, context: 'null', case: null },
{ expect: false, context: 'non 0 decimal', case: 123.123 },
{ expect: false, context: 'non 0 integer', case: 123 },
{ expect: true, context: 'hex 0', case: 0x00 },
{ expect: true, context: 'hex 0', case: 0x000 },
{
expect: true,
context: 'address 0',
case: 0x0000000000000000000000000000000000000000,
},
];
testCases.forEach((tc) => {
it(`returns ${tc.expect} for ${tc.case}`, () => {
expect(isZeroish(tc.case!)).to.equal(tc.expect);
});
});
});
describe('fixedToBig', () => {
it('converts a FixedNumber to a floored BigNumber', () => {
const fixed = FixedNumber.from('12.34');
const big = fixedToBig(fixed);
describe('bigToFixed', () => {
it('converts a BigNumber to a FixedNumber', () => {
const big = BigNumber('7.5e-10');
const fixed = bigToFixed(big);
expect(big.toNumber()).to.equal(12);
});
expect(fixed.toUnsafeFloat()).to.equal(7.5e-10);
});
});
it('converts a FixedNumber to a ceilinged BigNumber', () => {
const fixed = FixedNumber.from('12.34');
const big = fixedToBig(fixed, true);
describe('fixedToBig', () => {
it('converts a FixedNumber to a floored BigNumber', () => {
const fixed = FixedNumber.from('12.34');
const big = fixedToBig(fixed);
expect(big.toNumber()).to.equal(13);
});
expect(big.toNumber()).to.equal(12);
});
describe('mulBigAndFixed', () => {
it('gets the floored product of a BigNumber and FixedNumber', () => {
const big = BigNumber.from('1000');
const fixed = FixedNumber.from('1.2345');
const product = mulBigAndFixed(big, fixed);
it('converts a FixedNumber to a ceilinged BigNumber', () => {
const fixed = FixedNumber.from('12.34');
const big = fixedToBig(fixed, true);
expect(product.toNumber()).to.equal(1234);
});
expect(big.toNumber()).to.equal(13);
});
});
it('gets the ceilinged product of a BigNumber and FixedNumber', () => {
const big = BigNumber.from('1000');
const fixed = FixedNumber.from('1.2345');
const product = mulBigAndFixed(big, fixed, true);
describe('mulBigAndFixed', () => {
it('gets the floored product of a BigNumber and FixedNumber', () => {
const big = BigNumber('1000');
const fixed = FixedNumber.from('1.2345');
const product = mulBigAndFixed(big, fixed);
expect(product.toNumber()).to.equal(1235);
});
expect(product).to.equal((1234).toString());
});
it('gets the ceilinged product of a BigNumber and FixedNumber', () => {
const big = BigNumber('1000');
const fixed = FixedNumber.from('1.2345');
const product = mulBigAndFixed(big, fixed, true);
expect(product).to.equal((1235).toString());
});
});
describe('BigNumberMin', () => {
it('gets the min between the two BigNumber', () => {
const big = BigNumber('1000');
const bigger = BigNumber('10000');
expect(BigNumberMin(big, bigger)).to.equal(big.toString());
});
});
describe('BigNumberMax', () => {
it('gets the max between the two BigNumber', () => {
const big = BigNumber('1000');
const bigger = BigNumber('10000');
expect(BigNumberMax(big, bigger)).to.equal(bigger.toString());
});
});

@ -1,26 +1,33 @@
import { BigNumber, BigNumberish, FixedNumber, constants } from 'ethers';
import BigNumber from 'bignumber.js';
import { FixedNumber } from 'ethers';
import { isNullish } from './typeof';
// Use toString(10) on bignumber.js to prevent ethers.js bigNumber error
// when parsing exponential string over e21
export function isBigNumberish(value: any): value is BigNumberish {
/**
* Check if a value is bigNumberish (e.g. valid numbers, bigNumber).
* @param value The value to check.
* @returns true/false.
*/
export function isBigNumberish(
value: BigNumber.Value | undefined | null,
): boolean {
try {
if (isNullish(value)) return false;
return BigNumber.from(value)._isBigNumber;
const val = BigNumber(value!);
return !val.isNaN() && val.isFinite() && BigNumber.isBigNumber(val);
} catch (error) {
return false;
}
}
// If a value (e.g. hex string or number) is zeroish (0, 0x0, 0x00, etc.)
export function isZeroish(value: BigNumberish) {
/**
* Check if a value (e.g. hex string or number) is zeroish (0, 0x0, 0x00, etc.).
* @param value The value to check.
* @returns true/false.
*/
export function isZeroish(value: BigNumber.Value): boolean {
try {
if (
!value ||
value === constants.HashZero ||
value === constants.AddressZero
)
return true;
return BigNumber.from(value).isZero();
return BigNumber(value).isZero();
} catch (error) {
return false;
}
@ -31,8 +38,8 @@ export function isZeroish(value: BigNumberish) {
* @param big The BigNumber to convert.
* @returns A FixedNumber representation of a BigNumber.
*/
export function bigToFixed(big: BigNumber): FixedNumber {
return FixedNumber.from(big.toString());
export function bigToFixed(big: BigNumber.Value): FixedNumber {
return FixedNumber.from(big.toString(10));
}
/**
@ -43,7 +50,7 @@ export function bigToFixed(big: BigNumber): FixedNumber {
*/
export function fixedToBig(fixed: FixedNumber, ceil = false): BigNumber {
const fixedAsInteger = ceil ? fixed.ceiling() : fixed.floor();
return BigNumber.from(fixedAsInteger.toFormat('fixed256x0').toString());
return BigNumber(fixedAsInteger.toFormat('fixed256x0').toString());
}
/**
@ -51,21 +58,40 @@ export function fixedToBig(fixed: FixedNumber, ceil = false): BigNumber {
* @param big The BigNumber to multiply.
* @param fixed The FixedNumber to multiply.
* @param ceil If true, the ceiling of the product is used. Otherwise, the floor is used.
* @returns The BigNumber product.
* @returns The BigNumber product in string type.
*/
export function mulBigAndFixed(
big: BigNumber,
big: BigNumber.Value,
fixed: FixedNumber,
ceil = false,
): BigNumber {
): string {
// Converts big to a FixedNumber, multiplies it by fixed, and converts the product back
// to a BigNumber.
return fixedToBig(fixed.mulUnsafe(bigToFixed(big)), ceil);
return fixedToBig(fixed.mulUnsafe(bigToFixed(big)), ceil).toString(10);
}
export function BigNumberMin(bn1: BigNumber, bn2: BigNumber) {
return bn1.gte(bn2) ? bn2 : bn1;
/**
* Return the smaller in the given two BigNumbers.
* @param bn1 The BigNumber to compare.
* @param bn2 The BigNumber to compare.
* @returns The smaller BigNumber in string type.
*/
export function BigNumberMin(
bn1: BigNumber.Value,
bn2: BigNumber.Value,
): string {
return BigNumber(bn1).gte(bn2) ? bn2.toString(10) : bn1.toString(10);
}
export function BigNumberMax(bn1: BigNumber, bn2: BigNumber) {
return bn1.lte(bn2) ? bn2 : bn1;
/**
* Return the bigger in the given two BigNumbers.
* @param bn1 The BigNumber to compare.
* @param bn2 The BigNumber to compare.
* @returns The bigger BigNumber in string type.
*/
export function BigNumberMax(
bn1: BigNumber.Value,
bn2: BigNumber.Value,
): string {
return BigNumber(bn1).lte(bn2) ? bn2.toString(10) : bn1.toString(10);
}

@ -1,4 +1,5 @@
import { BigNumber, utils } from 'ethers';
import BigNumber from 'bignumber.js';
import { utils } from 'ethers';
import { addressToBytes32 } from './addresses';
import { ParsedLegacyMultisigIsmMetadata } from './types';
@ -18,7 +19,7 @@ export const parseLegacyMultisigIsmMetadata = (
const checkpointRoot = utils.hexlify(
buf.slice(MERKLE_ROOT_OFFSET, MERKLE_INDEX_OFFSET),
);
const checkpointIndex = BigNumber.from(
const checkpointIndex = BigNumber(
utils.hexlify(buf.slice(MERKLE_INDEX_OFFSET, ORIGIN_MAILBOX_OFFSET)),
).toNumber();
const originMailbox = utils.hexlify(
@ -30,7 +31,7 @@ export const parseLegacyMultisigIsmMetadata = (
);
};
const proof = parseBytesArray(MERKLE_PROOF_OFFSET, 32, 32);
const threshold = BigNumber.from(
const threshold = BigNumber(
utils.hexlify(buf.slice(THRESHOLD_OFFSET, SIGNATURES_OFFSET)),
).toNumber();
const signatures = parseBytesArray(

@ -4182,6 +4182,7 @@ __metadata:
bignumber.js: "npm:^9.1.1"
chai: "npm:^4.3.0"
ethers: "npm:^5.7.2"
mocha: "npm:^10.2.0"
prettier: "npm:^2.8.8"
typescript: "npm:5.1.6"
languageName: unknown

Loading…
Cancel
Save