import { AddressZero } from '@ethersproject/constants'; import { expect } from 'chai'; import { pino } from 'pino'; import { type ChainMap, type ChainName, Token, TokenStandard, } from '@hyperlane-xyz/sdk'; import { RebalancerMinAmountType } from '../config/types.js'; import { type MonitorEvent } from '../interfaces/IMonitor.js'; import type { RawBalances } from '../interfaces/IStrategy.js'; import { extractBridgeConfigs } from '../test/helpers.js'; import { getRawBalances } from '../utils/balanceUtils.js'; import { MinAmountStrategy } from './MinAmountStrategy.js'; const testLogger = pino({ level: 'silent' }); describe('MinAmountStrategy', () => { let chain1: ChainName; let chain2: ChainName; let chain3: ChainName; const tokensByChainName: ChainMap = {}; const tokenArgs = { name: 'token', decimals: 18, symbol: 'TOKEN', standard: TokenStandard.ERC20, addressOrDenom: '', }; const totalCollateral = BigInt(300e18); beforeEach(() => { chain1 = 'chain1'; chain2 = 'chain2'; chain3 = 'chain3'; tokensByChainName[chain1] = new Token({ ...tokenArgs, chainName: chain1 }); tokensByChainName[chain2] = new Token({ ...tokenArgs, chainName: chain2 }); tokensByChainName[chain3] = new Token({ ...tokenArgs, chainName: chain3 }); }); describe('constructor', () => { it('should throw an error when less than two chains are configured', () => { expect( () => new MinAmountStrategy( { [chain1]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }, tokensByChainName, totalCollateral, testLogger, {}, ), ).to.throw('At least two chains must be configured'); }); it('should create a strategy with minAmount and target using absolute values', () => { new MinAmountStrategy( { [chain1]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }, tokensByChainName, totalCollateral, testLogger, {}, ); }); it('should create a strategy with minAmount and target using relative values', () => { new MinAmountStrategy( { [chain1]: { minAmount: { min: 0.3, target: 0.4, type: RebalancerMinAmountType.Relative, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: 0.4, target: 0.5, type: RebalancerMinAmountType.Relative, }, bridge: AddressZero, bridgeLockTime: 1, }, }, tokensByChainName, totalCollateral, testLogger, {}, ); }); it('should throw an error when minAmount is negative', () => { expect( () => new MinAmountStrategy( { [chain1]: { minAmount: { min: 100, target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '-10', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }, tokensByChainName, totalCollateral, testLogger, {}, ), ).to.throw('Minimum amount (-10) cannot be negative for chain chain2'); }); it('should throw an error when target is less than min', () => { expect( () => new MinAmountStrategy( { [chain1]: { minAmount: { min: '100', target: '80', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }, tokensByChainName, totalCollateral, testLogger, {}, ), ).to.throw( 'Target (80) must be greater than or equal to min (100) for chain chain1', ); }); it('should throw an error when relative target is less than relative min', () => { expect( () => new MinAmountStrategy( { [chain1]: { minAmount: { min: 0.5, target: 0.4, type: RebalancerMinAmountType.Relative, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: 0.3, target: 0.5, type: RebalancerMinAmountType.Relative, }, bridge: AddressZero, bridgeLockTime: 1, }, }, tokensByChainName, totalCollateral, testLogger, {}, ), ).to.throw( 'Target (0.4) must be greater than or equal to min (0.5) for chain chain1', ); }); it('should throw an error when raw balances chains length does not match configured chains length', () => { expect(() => new MinAmountStrategy( { [chain1]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }, tokensByChainName, totalCollateral, testLogger, {}, ).getRebalancingRoutes({ [chain1]: 100n, [chain2]: 200n, [chain3]: 300n, }), ).to.throw('Config chains do not match raw balances chains length'); }); it('should throw an error when a raw balance is missing', () => { expect(() => new MinAmountStrategy( { [chain1]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }, tokensByChainName, totalCollateral, testLogger, {}, ).getRebalancingRoutes({ [chain1]: 100n, [chain3]: 300n, } as RawBalances), ).to.throw('Raw balance for chain chain2 not found'); }); it('should throw an error when a raw balance is negative', () => { expect(() => new MinAmountStrategy( { [chain1]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }, tokensByChainName, totalCollateral, testLogger, {}, ).getRebalancingRoutes({ [chain1]: 100n, [chain2]: -2n, }), ).to.throw('Raw balance for chain chain2 is negative'); }); }); describe('getRebalancingRoutes', () => { it('should return an empty array when all chains have at least the minimum amount', () => { const strategy = new MinAmountStrategy( { [chain1]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }, tokensByChainName, totalCollateral, testLogger, {}, ); const rawBalances: RawBalances = { [chain1]: BigInt(120e18), [chain2]: BigInt(120e18), }; const routes = strategy.getRebalancingRoutes(rawBalances); expect(routes).to.be.empty; }); it('should return a single route when a chain is below minimum amount', () => { const config = { [chain1]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }; const bridgeConfigs = extractBridgeConfigs(config); const strategy = new MinAmountStrategy( config, tokensByChainName, totalCollateral, testLogger, bridgeConfigs, ); const rawBalances = { [chain1]: BigInt(50e18), [chain2]: BigInt(200e18), }; const routes = strategy.getRebalancingRoutes(rawBalances); expect(routes).to.deep.equal([ { origin: chain2, destination: chain1, amount: BigInt(70e18), bridge: AddressZero, executionType: 'movableCollateral', }, ]); }); it('should normalize absolute thresholds for mixed-decimal routes', () => { const mixedTokensByChainName: ChainMap = { [chain1]: new Token({ ...tokenArgs, chainName: chain1, decimals: 18, scale: { numerator: 1, denominator: 1_000_000_000_000 }, }), [chain2]: new Token({ ...tokenArgs, chainName: chain2, decimals: 6, }), }; const config = { [chain1]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }; const strategy = new MinAmountStrategy( config, mixedTokensByChainName, 250_000_000n, testLogger, extractBridgeConfigs(config), ); const routes = strategy.getRebalancingRoutes({ [chain1]: 50_000_000n, [chain2]: 200_000_000n, }); expect(routes).to.deep.equal([ { origin: chain2, destination: chain1, amount: 70_000_000n, bridge: AddressZero, executionType: 'movableCollateral', }, ]); }); it('normalizes mixed-decimal local balances before routing', () => { const mixedTokensByChainName: ChainMap = { [chain1]: new Token({ ...tokenArgs, chainName: chain1, standard: TokenStandard.EvmHypCollateral, decimals: 18, scale: { numerator: 1, denominator: 1_000_000_000_000 }, }), [chain2]: new Token({ ...tokenArgs, chainName: chain2, standard: TokenStandard.EvmHypCollateral, decimals: 6, }), }; const config = { [chain1]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }; const strategy = new MinAmountStrategy( config, mixedTokensByChainName, 250_000_000n, testLogger, extractBridgeConfigs(config), ); const event: MonitorEvent = { tokensInfo: [ { token: mixedTokensByChainName[chain1], bridgedSupply: 50_000_000_000_000_000_000n, }, { token: mixedTokensByChainName[chain2], bridgedSupply: 200_000_000n, }, ], confirmedBlockTags: { [chain1]: 1, [chain2]: 1, }, }; const rawBalances = getRawBalances([chain1, chain2], event, testLogger); expect(rawBalances).to.deep.equal({ [chain1]: 50_000_000n, [chain2]: 200_000_000n, }); const routes = strategy.getRebalancingRoutes(rawBalances); expect(routes).to.deep.equal([ { origin: chain2, destination: chain1, amount: 70_000_000n, bridge: AddressZero, executionType: 'movableCollateral', }, ]); }); it('should return multiple routes for multiple chains below minimum amount', () => { const config = { [chain1]: { minAmount: { min: '80', target: '100', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '80', target: '100', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain3]: { minAmount: { min: '80', target: '100', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }; const bridgeConfigs = extractBridgeConfigs(config); const strategy = new MinAmountStrategy( config, tokensByChainName, totalCollateral, testLogger, bridgeConfigs, ); const rawBalances = { [chain1]: BigInt(50e18), [chain2]: BigInt(75e18), [chain3]: BigInt(300e18), }; const routes = strategy.getRebalancingRoutes(rawBalances); expect(routes).to.deep.equal([ { origin: chain3, destination: chain1, amount: BigInt(50e18), bridge: AddressZero, executionType: 'movableCollateral', }, { origin: chain3, destination: chain2, amount: BigInt(25e18), bridge: AddressZero, executionType: 'movableCollateral', }, ]); }); it('should throw an error when the sum of `target` is greater than the sum of collaterals', () => { expect( () => new MinAmountStrategy( { [chain1]: { minAmount: { min: '100', target: '220', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }, tokensByChainName, totalCollateral, testLogger, {}, ), ).to.throw( `Consider reducing the targets as the sum (340) is greater than sum of collaterals (300)`, ); }); it('should keep minAmount validation errors human-readable for mixed-decimal routes', () => { const mixedTokensByChainName: ChainMap = { [chain1]: new Token({ ...tokenArgs, chainName: chain1, decimals: 18, scale: { numerator: 1, denominator: 1_000_000_000_000 }, }), [chain2]: new Token({ ...tokenArgs, chainName: chain2, decimals: 6, }), }; expect( () => new MinAmountStrategy( { [chain1]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '120', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }, mixedTokensByChainName, 230_000_000n, testLogger, {}, ), ).to.throw( `Consider reducing the targets as the sum (240) is greater than sum of collaterals (230)`, ); }); it('should keep fractional minAmount validation errors human-readable across heterogeneous decimals', () => { const mixedTokensByChainName: ChainMap = { [chain1]: new Token({ ...tokenArgs, chainName: chain1, decimals: 6, }), [chain2]: new Token({ ...tokenArgs, chainName: chain2, decimals: 18, scale: { numerator: 1, denominator: 1_000_000_000_000 }, }), }; expect( () => new MinAmountStrategy( { [chain1]: { minAmount: { min: '100', target: '120.25', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '120.25', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }, mixedTokensByChainName, 230_500_000n, testLogger, {}, ), ).to.throw( `Consider reducing the targets as the sum (240.5) is greater than sum of collaterals (230.5)`, ); }); it('should handle case where there is not enough surplus to meet all minimum requirements by scaling down deficits', () => { const config = { [chain1]: { minAmount: { min: '100', target: '100', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '100', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain3]: { minAmount: { min: '100', target: '100', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }; const bridgeConfigs = extractBridgeConfigs(config); const strategy = new MinAmountStrategy( config, tokensByChainName, totalCollateral, testLogger, bridgeConfigs, ); const rawBalances = { [chain1]: BigInt(50e18), [chain2]: BigInt(50e18), [chain3]: BigInt(150e18), // Only 50n of surplus, not enough to bring both chains up to minimum }; const routes = strategy.getRebalancingRoutes(rawBalances); // It scales down the deficits to prevent sending all surplus to a single chain expect(routes.length).to.equal(2); expect(routes[0].origin).to.equal(chain3); expect(routes[0].destination).to.equal(chain1); expect(routes[0].amount).to.equal(BigInt(25e18)); expect(routes[1].origin).to.equal(chain3); expect(routes[1].destination).to.equal(chain2); expect(routes[1].amount).to.equal(BigInt(25e18)); }); it('should not produce zero-amount routes when scaling causes amounts to round to zero', () => { // This test ensures that when deficit scaling produces zero amounts (due to integer division), // these zero-amount routes are NOT included in the output. // Bug scenario: totalSurplus=1, totalDeficit=3 -> each deficit of 1 scales to (1*1)/3 = 0 const config = { [chain1]: { minAmount: { min: '90', target: '100', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '90', target: '100', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain3]: { minAmount: { min: '90', target: '100', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }; const bridgeConfigs = extractBridgeConfigs(config); const strategy = new MinAmountStrategy( config, tokensByChainName, totalCollateral, testLogger, bridgeConfigs, ); // chain1 and chain2 are at 1 token each (far below min 90) // chain3 has 91 tokens (surplus of 1 above min) // Deficits: chain1 needs 99 (100-1), chain2 needs 99 (100-1), total = 198 // Surplus: chain3 has 1 (91-90) // Scaling: each deficit of 99 becomes (99 * 1) / 198 = 0 (integer division!) const rawBalances = { [chain1]: BigInt(1e18), [chain2]: BigInt(1e18), [chain3]: BigInt(91e18), }; const routes = strategy.getRebalancingRoutes(rawBalances); // All routes should have non-zero amounts // (Chai's greaterThan doesn't support BigInt, so use direct comparison) for (const route of routes) { expect( route.amount > 0n, `Route amount should be > 0, got ${route.amount}`, ).to.be.true; } // The single token of surplus may produce one route (or none if both scale to 0) // Either way, no zero-amount routes should exist expect( routes.every((r) => r.amount > 0n), 'All routes must have non-zero amounts', ).to.be.true; }); it('should have no surplus or deficit when all at min', () => { const strategy = new MinAmountStrategy( { [chain1]: { minAmount: { min: '100', target: '100', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: '100', target: '100', type: RebalancerMinAmountType.Absolute, }, bridge: AddressZero, bridgeLockTime: 1, }, }, tokensByChainName, totalCollateral, testLogger, {}, ); const rawBalances = { [chain1]: BigInt(100e18), [chain2]: BigInt(100e18), }; const routes = strategy.getRebalancingRoutes(rawBalances); expect(routes).to.be.empty; }); it('should consider the target amount with relative configuration', () => { const config = { [chain1]: { minAmount: { min: 0.25, target: 0.3, type: RebalancerMinAmountType.Relative, }, bridge: AddressZero, bridgeLockTime: 1, }, [chain2]: { minAmount: { min: 0.25, target: 0.3, type: RebalancerMinAmountType.Relative, }, bridge: AddressZero, bridgeLockTime: 1, }, }; const bridgeConfigs = extractBridgeConfigs(config); const strategy = new MinAmountStrategy( config, tokensByChainName, totalCollateral, testLogger, bridgeConfigs, ); const rawBalances: RawBalances = { [chain1]: 200n, [chain2]: 800n, }; const routes = strategy.getRebalancingRoutes(rawBalances); expect(routes).to.deep.equal([ { origin: chain2, destination: chain1, amount: 100n, bridge: AddressZero, executionType: 'movableCollateral', }, ]); }); }); });