@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
526 lines • 21.1 kB
JavaScript
import { assert, expect } from 'chai';
import { randomAddress } from '../test/testUtils.js';
import { TokenType } from '../token/config.js';
import { alignLocalAmountToMessage, localAmountFromMessage, messageAmountFromLocal, minLocalAmountForMessage, normalizeScale, scalesEqual, verifyScale, } from './decimals.js';
describe(normalizeScale.name, () => {
it('should normalize undefined to DEFAULT_SCALE', () => {
const result = normalizeScale(undefined);
expect(result).to.deep.equal({ numerator: 1n, denominator: 1n });
});
it('should normalize a plain number to {BigInt(n), 1n}', () => {
const result = normalizeScale(1000);
expect(result).to.deep.equal({ numerator: 1000n, denominator: 1n });
});
it('should normalize {number, number} to {bigint, bigint}', () => {
const result = normalizeScale({ numerator: 5, denominator: 3 });
expect(result).to.deep.equal({ numerator: 5n, denominator: 3n });
});
it('should pass through {bigint, bigint} unchanged', () => {
const result = normalizeScale({ numerator: 10n ** 18n, denominator: 1n });
expect(result).to.deep.equal({ numerator: 10n ** 18n, denominator: 1n });
});
});
describe(scalesEqual.name, () => {
it('should treat undefined as equal to {1n, 1n}', () => {
expect(scalesEqual(undefined, { numerator: 1n, denominator: 1n })).to.be
.true;
expect(scalesEqual(undefined, undefined)).to.be.true;
});
it('should accept plain number scales (backwards compat)', () => {
expect(scalesEqual(1000, 1000)).to.be.true;
expect(scalesEqual(1000, 2000)).to.be.false;
expect(scalesEqual(1, undefined)).to.be.true;
});
it('should accept {number, number} scales (backwards compat)', () => {
expect(scalesEqual({ numerator: 1000, denominator: 1 }, 1000)).to.be.true;
expect(scalesEqual({ numerator: 1, denominator: 2 }, { numerator: 2, denominator: 4 })).to.be.true;
});
it('should compare mixed scale types correctly', () => {
// number vs bigint object
expect(scalesEqual(1000, { numerator: 1000n, denominator: 1n })).to.be.true;
// number object vs bigint object
expect(scalesEqual({ numerator: 1000, denominator: 1 }, { numerator: 1000n, denominator: 1n })).to.be.true;
});
it('should compare bigint scales', () => {
expect(scalesEqual({ numerator: 1000000000000n, denominator: 1n }, { numerator: 1000000000000n, denominator: 1n })).to.be.true;
expect(scalesEqual({ numerator: 1n, denominator: 1n }, { numerator: 2n, denominator: 1n })).to.be.false;
});
it('should compare fractional scales via cross-multiplication', () => {
expect(scalesEqual({ numerator: 1n, denominator: 2n }, { numerator: 2n, denominator: 4n })).to.be.true;
expect(scalesEqual({ numerator: 1n, denominator: 3n }, { numerator: 1n, denominator: 2n })).to.be.false;
});
it('should handle large values without precision loss', () => {
// 10^18 exceeds Number.MAX_SAFE_INTEGER
const large = { numerator: 10n ** 18n, denominator: 1n };
const slightlyDifferent = { numerator: 10n ** 18n + 1n, denominator: 1n };
expect(scalesEqual(large, large)).to.be.true;
expect(scalesEqual(large, slightlyDifferent)).to.be.false;
});
});
describe('scale conversion helpers', () => {
it('converts local amount to message amount using floor rounding', () => {
expect(messageAmountFromLocal(5n, { numerator: 1n, denominator: 3n })).to.equal(1n);
expect(messageAmountFromLocal(2n, { numerator: 3n, denominator: 2n })).to.equal(3n);
});
it('converts message amount to local amount using inbound floor rounding', () => {
expect(localAmountFromMessage(7n, { numerator: 3n, denominator: 2n })).to.equal(4n);
expect(localAmountFromMessage(1n, { numerator: 1n, denominator: 3n })).to.equal(3n);
});
it('computes the minimum local amount needed to reach a message amount', () => {
expect(minLocalAmountForMessage(7n, { numerator: 3n, denominator: 2n })).to.equal(5n);
expect(minLocalAmountForMessage(2n, { numerator: 1n, denominator: 3n })).to.equal(6n);
});
it('rejects negative message amounts for ceil local conversion', () => {
expect(() => minLocalAmountForMessage(-1n, { numerator: 1n, denominator: 3n })).to.throw('Message amount must be non-negative');
});
it('aligns local amounts to exact message progress without leaking local dust', () => {
expect(alignLocalAmountToMessage(5n, { numerator: 1n, denominator: 3n })).to.deep.equal({
localAmount: 3n,
messageAmount: 1n,
});
expect(alignLocalAmountToMessage(5n, { numerator: 3n, denominator: 2n })).to.deep.equal({
localAmount: 5n,
messageAmount: 7n,
});
expect(alignLocalAmountToMessage(999999999999n, {
numerator: 1n,
denominator: 1000000000000n,
})).to.deep.equal({
localAmount: 0n,
messageAmount: 0n,
});
});
it('rejects negative local amounts for alignment', () => {
expect(() => alignLocalAmountToMessage(-1n, { numerator: 1n, denominator: 3n })).to.throw('Local amount must be non-negative');
});
it('returns identity for undefined scale', () => {
expect(messageAmountFromLocal(42n, undefined)).to.equal(42n);
expect(localAmountFromMessage(42n, undefined)).to.equal(42n);
});
it('handles zero amounts', () => {
expect(messageAmountFromLocal(0n, { numerator: 1n, denominator: 3n })).to.equal(0n);
expect(localAmountFromMessage(0n, { numerator: 3n, denominator: 2n })).to.equal(0n);
});
it('round-trips exact-divisible amounts', () => {
const scale = { numerator: 3n, denominator: 2n };
const local = 6n;
const message = messageAmountFromLocal(local, scale);
expect(localAmountFromMessage(message, scale)).to.equal(local);
});
});
describe(verifyScale.name, () => {
const TOKEN_NAME = 'TOKEN';
const ETH_DECIMALS = 18;
const USDC_DECIMALS = 6;
it('should return true when all decimals are uniform', () => {
const configMap = new Map([
[
'chain1',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: ETH_DECIMALS },
],
[
'chain2',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: ETH_DECIMALS },
],
]);
expect(verifyScale(configMap)).to.be.true;
});
it('should return true when all decimals are uniform and scale is not provided', () => {
const configMap = new Map([
[
'chain1',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: USDC_DECIMALS },
],
[
'chain2',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: USDC_DECIMALS },
],
]);
expect(verifyScale(configMap)).to.be.true;
});
it('should return false when decimals are uniform but scales are mismatched', () => {
const configMap = new Map([
[
'chain1',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: ETH_DECIMALS,
scale: 1000,
},
],
[
'chain2',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: ETH_DECIMALS },
],
]);
expect(verifyScale(configMap)).to.be.false;
});
it('should return true with plain number scale (backwards compat)', () => {
const configMap = new Map([
[
'chain1',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: ETH_DECIMALS },
],
[
'chain2',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: USDC_DECIMALS,
scale: 1_000_000_000_000,
},
],
]);
expect(verifyScale(configMap)).to.be.true;
});
it('should return true with bigint scale', () => {
const configMap = new Map([
[
'chain1',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: ETH_DECIMALS },
],
[
'chain2',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: USDC_DECIMALS,
scale: { numerator: 1000000000000n, denominator: 1n },
},
],
]);
expect(verifyScale(configMap)).to.be.true;
});
it('should return false when decimals are non-uniform and an incorrect scale is provided', () => {
const configMap = new Map([
[
'chain1',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: ETH_DECIMALS },
],
[
'chain2',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: USDC_DECIMALS,
scale: 100,
},
],
]);
expect(verifyScale(configMap)).to.be.false;
});
it('should return false when decimals are non-uniform and scale is missing', () => {
const configMap = new Map([
[
'chain1',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: ETH_DECIMALS },
],
[
'chain2',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: USDC_DECIMALS },
],
]);
expect(verifyScale(configMap)).to.be.false;
});
it('should throw an error if decimals are not defined for a token config', () => {
const configMap = new Map([
[
'chain1',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: ETH_DECIMALS },
],
['chain2', { name: TOKEN_NAME, symbol: TOKEN_NAME }],
]);
assert.throws(() => verifyScale(configMap), 'Decimals must be defined for token config on chain chain2');
});
it('should handle WarpRouteDeployConfigMailboxRequired input type', () => {
const config = {
chain1: {
type: TokenType.collateral,
token: randomAddress(),
owner: randomAddress(),
decimals: ETH_DECIMALS,
mailbox: randomAddress(),
},
chain2: {
type: TokenType.collateral,
token: randomAddress(),
owner: randomAddress(),
decimals: USDC_DECIMALS,
scale: 1_000_000_000_000,
mailbox: randomAddress(),
},
};
expect(verifyScale(config)).to.be.true;
});
it('should handle WarpRouteDeployConfigMailboxRequired with uniform decimals', () => {
const config = {
chain1: {
type: TokenType.collateral,
token: randomAddress(),
owner: randomAddress(),
decimals: ETH_DECIMALS,
mailbox: randomAddress(),
},
chain2: {
type: TokenType.collateral,
token: randomAddress(),
owner: randomAddress(),
decimals: ETH_DECIMALS,
mailbox: randomAddress(),
},
};
expect(verifyScale(config)).to.be.true;
});
it('should return false for WarpRouteDeployConfigMailboxRequired with incorrect scale', () => {
const config = {
chain1: {
type: TokenType.collateral,
token: randomAddress(),
owner: randomAddress(),
decimals: ETH_DECIMALS,
mailbox: randomAddress(),
},
chain2: {
type: TokenType.collateral,
token: randomAddress(),
owner: randomAddress(),
decimals: USDC_DECIMALS,
scale: 1000,
mailbox: randomAddress(),
},
};
expect(verifyScale(config)).to.be.false;
});
it('should throw an error for WarpRouteDeployConfigMailboxRequired with missing decimals', () => {
const config = {
chain1: {
type: TokenType.collateral,
token: randomAddress(),
owner: randomAddress(),
decimals: ETH_DECIMALS,
mailbox: randomAddress(),
},
chain2: {
type: TokenType.collateral,
token: randomAddress(),
owner: randomAddress(),
decimals: undefined,
mailbox: randomAddress(),
},
};
assert.throws(() => verifyScale(config), 'Decimals must be defined for token config on chain chain2');
});
// --- Scale-down (BSC-style) tests ---
// Convention: max-decimal chain carries {numerator:1, denominator:N}, others carry no scale.
// This keeps message encoding at the lower decimal precision.
it('should return true with scale-down on the high-decimal chain', () => {
// BSC (18 dec) scales down by 1/1e12, ETH (6 dec) carries no scale.
// Effective message amount: BSC = 1/1e12 * 10^18 = 10^6, ETH = 1 * 10^6 = 10^6 ✓
const configMap = new Map([
[
'bsc',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: ETH_DECIMALS,
scale: { numerator: 1, denominator: 1_000_000_000_000 },
},
],
[
'eth',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: USDC_DECIMALS },
],
]);
expect(verifyScale(configMap)).to.be.true;
});
it('should return false when scale-down ratio is incorrect', () => {
// BSC uses wrong denominator (100 instead of 1e12)
const configMap = new Map([
[
'bsc',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: ETH_DECIMALS,
scale: { numerator: 1, denominator: 100 },
},
],
[
'eth',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: USDC_DECIMALS },
],
]);
expect(verifyScale(configMap)).to.be.false;
});
it('should return true with mixed scale-up and scale-down reaching the same effective amount', () => {
// chain1 (18 dec) scales down by 1/1e6, chain2 (6 dec) scales up by 1e6.
// Both reach 10^12 effective message encoding.
const configMap = new Map([
[
'chain1',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: ETH_DECIMALS,
scale: { numerator: 1, denominator: 1_000_000 },
},
],
[
'chain2',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: USDC_DECIMALS,
scale: 1_000_000,
},
],
]);
expect(verifyScale(configMap)).to.be.true;
});
it('should return true with scale-down across three chains', () => {
// BSC (18), ETH (6), and a third 6-decimal chain — all consistent with BSC scale-down
const configMap = new Map([
[
'bsc',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: ETH_DECIMALS,
scale: { numerator: 1, denominator: 1_000_000_000_000 },
},
],
[
'eth',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: USDC_DECIMALS },
],
[
'arbitrum',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: USDC_DECIMALS },
],
]);
expect(verifyScale(configMap)).to.be.true;
});
it('should return true with scale-down using WarpRouteDeployConfigMailboxRequired', () => {
// BSC (18dec) scales down by 1/1e12 to match ETH (6dec) — via deploy config input type
const config = {
bsc: {
type: TokenType.collateral,
token: randomAddress(),
owner: randomAddress(),
decimals: ETH_DECIMALS,
scale: { numerator: 1, denominator: 1_000_000_000_000 },
mailbox: randomAddress(),
},
eth: {
type: TokenType.collateral,
token: randomAddress(),
owner: randomAddress(),
decimals: USDC_DECIMALS,
mailbox: randomAddress(),
},
};
expect(verifyScale(config)).to.be.true;
});
it('should return true when two chains both use scale-down to the same effective amount', () => {
// chain1 (18dec) with 1/1e12 → effective 10^6
// chain2 (12dec) with 1/1e6 → effective 10^6
const configMap = new Map([
[
'chain1',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: ETH_DECIMALS,
scale: { numerator: 1, denominator: 1_000_000_000_000 },
},
],
[
'chain2',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: 12,
scale: { numerator: 1, denominator: 1_000_000 },
},
],
]);
expect(verifyScale(configMap)).to.be.true;
});
it('should return true for non-reduced equivalent fractions (uniform decimals)', () => {
// Both 18dec: one with 2/2e12 (non-reduced), one with 1/1e12 (reduced) — equivalent
// Cross-multiply check: 2 * 1e12 === 1 * 2e12 → 2e12 === 2e12 ✓
const configMap = new Map([
[
'chain1',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: ETH_DECIMALS,
scale: { numerator: 2, denominator: 2_000_000_000_000 },
},
],
[
'chain2',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: ETH_DECIMALS,
scale: { numerator: 1, denominator: 1_000_000_000_000 },
},
],
]);
expect(verifyScale(configMap)).to.be.true;
});
it('should return true for a single-chain config', () => {
// Single chain — no pairs to compare, trivially consistent
const configMap = new Map([
[
'chain1',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: ETH_DECIMALS },
],
]);
expect(verifyScale(configMap)).to.be.true;
});
it('should return false when three chains have the third inconsistent', () => {
// chain1 (18dec, no scale), chain2 (6dec, scale 1e12 ✓), chain3 (6dec, scale 100 ✗)
const configMap = new Map([
[
'chain1',
{ name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: ETH_DECIMALS },
],
[
'chain2',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: USDC_DECIMALS,
scale: 1_000_000_000_000,
},
],
[
'chain3',
{
name: TOKEN_NAME,
symbol: TOKEN_NAME,
decimals: USDC_DECIMALS,
scale: 100,
},
],
]);
expect(verifyScale(configMap)).to.be.false;
});
it('should return true for an empty config', () => {
const configMap = new Map();
expect(verifyScale(configMap)).to.be.true;
});
it('should treat decimals: 0 as defined (not falsy)', () => {
// Exercises the nullish check fix (config.decimals != null).
// decimals: 0 is falsy but defined — should not throw.
const configMap = new Map([
['chain1', { name: TOKEN_NAME, symbol: TOKEN_NAME, decimals: 0 }],
]);
expect(verifyScale(configMap)).to.be.true;
});
});
//# sourceMappingURL=decimals.test.js.map