diff --git a/typescript/cli/scripts/run-e2e-test.sh b/typescript/cli/scripts/run-e2e-test.sh index 7ae565c6257..d1315240908 100755 --- a/typescript/cli/scripts/run-e2e-test.sh +++ b/typescript/cli/scripts/run-e2e-test.sh @@ -3,7 +3,7 @@ function cleanup() { set +e pkill -f anvil - rm -rf ./test-configs/anvil/deployments + rm -rf ./tmp rm -f ./test-configs/anvil/chains/anvil2/addresses.yaml rm -f ./test-configs/anvil/chains/anvil3/addresses.yaml rm -f ./test-configs/anvil/chains/anvil4/addresses.yaml @@ -13,9 +13,9 @@ function cleanup() { cleanup echo "Starting anvil2, anvil3 and anvil4 chains for E2E tests" -anvil --chain-id 31338 -p 8555 --gas-price 1 > /dev/null & -anvil --chain-id 31347 -p 8600 --gas-price 1 > /dev/null & -anvil --chain-id 31348 -p 8601 --gas-price 1 > /dev/null & +anvil --chain-id 31338 -p 8555 --state /tmp/anvil2/state --gas-price 1 > /dev/null & +anvil --chain-id 31347 -p 8600 --state /tmp/anvil3/state --gas-price 1 > /dev/null & +anvil --chain-id 31348 -p 8601 --state /tmp/anvil4/state --gas-price 1 > /dev/null & echo "Running E2E tests" if [ -n "${CLI_E2E_TEST}" ]; then diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts index 2a4ac77a117..9fb56d658d4 100644 --- a/typescript/cli/src/commands/warp.ts +++ b/typescript/cli/src/commands/warp.ts @@ -20,7 +20,13 @@ import { } from '../context/types.js'; import { evaluateIfDryRunFailure } from '../deploy/dry-run.js'; import { runWarpRouteApply, runWarpRouteDeploy } from '../deploy/warp.js'; -import { log, logBlue, logCommandHeader, logGreen } from '../logger.js'; +import { + errorRed, + log, + logBlue, + logCommandHeader, + logGreen, +} from '../logger.js'; import { runWarpRouteRead } from '../read/warp.js'; import { Executor, @@ -411,7 +417,7 @@ export const check: CommandModuleWithContext<{ export const rebalancer: CommandModuleWithContext<{ warpRouteId: string; checkFrequency: number; - strategyTolerance: string; + strategyConfigFile: string; }> = { command: 'rebalancer', describe: 'Run a warp route collateral rebalancer', @@ -427,51 +433,53 @@ export const rebalancer: CommandModuleWithContext<{ demandOption: true, alias: 'v', }, - strategyTolerance: { + strategyConfigFile: { type: 'string', - description: - 'Tolerance threshold for imbalance detection (specified in token base units; e.g., 1000000 for 1 USDC, 1000000000000000000 for 1 ETH)', - demandOption: false, - default: '0', + description: 'The path to a strategy configuration file (.json or .yaml)', + demandOption: true, + alias: 's', }, }, handler: async ({ context, warpRouteId, checkFrequency, - strategyTolerance, + strategyConfigFile, }) => { - logCommandHeader('Hyperlane Warp Rebalancer'); - - // Instantiates the warp route monitor - const monitor: IMonitor = new Monitor( - context.registry, - warpRouteId, - checkFrequency, - ); + try { + // Instantiates the warp route monitor + const monitor: IMonitor = new Monitor( + context.registry, + warpRouteId, + checkFrequency, + ); - // Instantiates the strategy that will get rebalancing routes based on monitor results - const strategy: IStrategy = new Strategy(BigInt(strategyTolerance)); + // Instantiates the strategy that will get rebalancing routes based on monitor results + const strategy: IStrategy = Strategy.fromConfigFile(strategyConfigFile); - // Instantiates the executor that will process rebalancing routes - const executor: IExecutor = new Executor(); + // Instantiates the executor that will process rebalancing routes + const executor: IExecutor = new Executor(); - // Observe monitor events and process rebalancing routes - monitor.subscribe((event) => { - const balances = event.balances.reduce((acc, next) => { - acc[next.chain] = next.value; - return acc; - }, {} as Record); + // Observe monitor events and process rebalancing routes + monitor.subscribe((event) => { + const balances = event.balances.reduce((acc, next) => { + acc[next.chain] = next.value; + return acc; + }, {} as Record); - const rebalancingRoutes = strategy.getRebalancingRoutes(balances); + const rebalancingRoutes = strategy.getRebalancingRoutes(balances); - executor.processRebalancingRoutes(rebalancingRoutes); - }); + executor.processRebalancingRoutes(rebalancingRoutes); + }); - // Starts the monitor to begin polling balances. - await monitor.start(); + // Starts the monitor to begin polling balances. + await monitor.start(); - logGreen('Rebalancer started successfully 🚀'); + logGreen('Rebalancer started successfully 🚀'); + } catch (e) { + errorRed('Rebalancer could not be started:', (e as Error).message); + process.exit(1); + } }, }; diff --git a/typescript/cli/src/rebalancer/index.ts b/typescript/cli/src/rebalancer/index.ts index 41255ea1f31..e175ad1f792 100644 --- a/typescript/cli/src/rebalancer/index.ts +++ b/typescript/cli/src/rebalancer/index.ts @@ -1,5 +1,6 @@ export * from './executor/Executor.js'; export * from './strategy/Strategy.js'; +export * from './strategy/types.js'; export * from './monitor/Monitor.js'; export * from './interfaces/IExecutor.js'; export * from './interfaces/IMonitor.js'; diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.test.ts b/typescript/cli/src/rebalancer/strategy/Strategy.test.ts index 6ac85c7287b..2c0271606cd 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.test.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.test.ts @@ -10,143 +10,228 @@ describe('Strategy', () => { let chain2: ChainName; let chain3: ChainName; - let balances: Record; - - let strategy: Strategy; - beforeEach(() => { chain1 = 'chain1'; chain2 = 'chain2'; chain3 = 'chain3'; - - balances = { - [chain1]: ethers.utils.parseEther('100').toBigInt(), - [chain2]: ethers.utils.parseEther('200').toBigInt(), - [chain3]: ethers.utils.parseEther('300').toBigInt(), - }; - - strategy = new Strategy(); }); - describe('when balances for chain1, chain2, and chain3 are 100, 200, and 300 respectively', () => { - beforeEach(() => { - balances = { - [chain1]: ethers.utils.parseEther('100').toBigInt(), - [chain2]: ethers.utils.parseEther('200').toBigInt(), - [chain3]: ethers.utils.parseEther('300').toBigInt(), - }; + describe('constructor', () => { + it('should throw an error when less than two chains are configured', () => { + expect( + () => new Strategy({ [chain1]: { weight: 100n, tolerance: 0n } }), + ).to.throw('At least two chains must be configured'); }); - it('should return a single route for 100 from chain3 to chain1', () => { - const routes = strategy.getRebalancingRoutes(balances); + it('should throw an error when weight is less than or equal to 0', () => { + expect( + () => + new Strategy({ + [chain1]: { weight: 100n, tolerance: 0n }, + [chain2]: { weight: 0n, tolerance: 0n }, + }), + ).to.throw('Weight must be greater than 0'); + + expect( + () => + new Strategy({ + [chain1]: { weight: 100n, tolerance: 0n }, + [chain2]: { weight: -1n, tolerance: 0n }, + }), + ).to.throw('Weight must be greater than 0'); + }); - expect(routes).to.have.lengthOf(1); - expect(routes[0]).to.deep.equal({ - fromChain: 'chain3', - toChain: 'chain1', - amount: ethers.utils.parseEther('100').toBigInt(), - }); + it('should throw an error when tolerance is less than 0 or greater than 100', () => { + expect( + () => + new Strategy({ + [chain1]: { weight: 100n, tolerance: 0n }, + [chain2]: { weight: 100n, tolerance: -1n }, + }), + ).to.throw('Tolerance must be between 0 and 100'); + + expect( + () => + new Strategy({ + [chain1]: { weight: 100n, tolerance: 100n }, + [chain2]: { weight: 100n, tolerance: 101n }, + }), + ).to.throw('Tolerance must be between 0 and 100'); }); }); - describe('when balances for chain1, chain2, and chain3 are 100, 100, and 300 respectively', () => { - beforeEach(() => { - balances = { - [chain1]: ethers.utils.parseEther('100').toBigInt(), - [chain2]: ethers.utils.parseEther('100').toBigInt(), - [chain3]: ethers.utils.parseEther('300').toBigInt(), - }; + describe('getRebalancingRoutes', () => { + it('should throw an error when raw balances chains length does not match configured chains length', () => { + expect(() => + new Strategy({ + [chain1]: { weight: 100n, tolerance: 0n }, + [chain2]: { weight: 100n, tolerance: 0n }, + }).getRebalancingRoutes({ + [chain1]: ethers.utils.parseEther('100').toBigInt(), + [chain2]: ethers.utils.parseEther('200').toBigInt(), + [chain3]: ethers.utils.parseEther('300').toBigInt(), + }), + ).to.throw('Config chains do not match raw balances chains length'); }); - it('should return two routes for 66 from chain3 to chain1 and 66 from chain3 to chain2', () => { - const routes = strategy.getRebalancingRoutes(balances); + it('should throw an error when a raw balance is missing', () => { + expect(() => + new Strategy({ + [chain1]: { weight: 100n, tolerance: 0n }, + [chain2]: { weight: 100n, tolerance: 0n }, + }).getRebalancingRoutes({ + [chain1]: ethers.utils.parseEther('100').toBigInt(), + [chain3]: ethers.utils.parseEther('300').toBigInt(), + }), + ).to.throw('Raw balance for chain chain2 not found'); + }); - expect(routes).to.have.lengthOf(2); - expect(routes[0]).to.deep.equal({ - fromChain: 'chain3', - toChain: 'chain1', - amount: 66666666666666666666n, // 66 - }); - expect(routes[1]).to.deep.equal({ - fromChain: 'chain3', - toChain: 'chain2', - amount: 66666666666666666666n, // 66 - }); + it('should throw an error when a raw balance is negative', () => { + expect(() => + new Strategy({ + [chain1]: { weight: 100n, tolerance: 0n }, + [chain2]: { weight: 100n, tolerance: 0n }, + }).getRebalancingRoutes({ + [chain1]: ethers.utils.parseEther('100').toBigInt(), + [chain2]: ethers.utils.parseEther('-200').toBigInt(), + }), + ).to.throw('Raw balance for chain chain2 is negative'); }); - }); - describe('when balances for chain1, chain2, and chain3 are 100, 100, and 100 respectively', () => { - beforeEach(() => { - balances = { + it('should return an empty array when all chains are balanced', () => { + const strategy = new Strategy({ + [chain1]: { weight: 100n, tolerance: 0n }, + [chain2]: { weight: 100n, tolerance: 0n }, + }); + + const rawBalances = { [chain1]: ethers.utils.parseEther('100').toBigInt(), [chain2]: ethers.utils.parseEther('100').toBigInt(), - [chain3]: ethers.utils.parseEther('100').toBigInt(), }; - }); - it('should return no routes', () => { - const routes = strategy.getRebalancingRoutes(balances); + const routes = strategy.getRebalancingRoutes(rawBalances); expect(routes).to.be.empty; }); - }); - describe('when tolerance is 10 ether', () => { - beforeEach(() => { - strategy = new Strategy(ethers.utils.parseEther('10').toBigInt()); + it('should return a single route when a chain is unbalanced', () => { + const strategy = new Strategy({ + [chain1]: { weight: 100n, tolerance: 0n }, + [chain2]: { weight: 100n, tolerance: 0n }, + }); + + const rawBalances = { + [chain1]: ethers.utils.parseEther('100').toBigInt(), + [chain2]: ethers.utils.parseEther('200').toBigInt(), + }; + + const routes = strategy.getRebalancingRoutes(rawBalances); + + expect(routes).to.deep.equal([ + { + fromChain: chain2, + toChain: chain1, + amount: ethers.utils.parseEther('50').toBigInt(), + }, + ]); }); - describe('when balances for chain1, chain2, and chain3 are 80, 90, and 100 respectively', () => { - beforeEach(() => { - balances = { - [chain1]: ethers.utils.parseEther('80').toBigInt(), - [chain2]: ethers.utils.parseEther('90').toBigInt(), - [chain3]: ethers.utils.parseEther('100').toBigInt(), - }; + it('should return an empty array when a chain is unbalanced but has tolerance', () => { + const strategy = new Strategy({ + [chain1]: { weight: 100n, tolerance: 1n }, + [chain2]: { weight: 100n, tolerance: 1n }, }); - it('should return no routes', () => { - const routes = strategy.getRebalancingRoutes(balances); + const rawBalances = { + [chain1]: ethers.utils.parseEther('100').toBigInt(), + [chain2]: ethers.utils.parseEther('101').toBigInt(), + }; - expect(routes).to.be.empty; - }); + const routes = strategy.getRebalancingRoutes(rawBalances); + + expect(routes).to.be.empty; }); - describe('when balances for chain1, chain2, and chain3 are 70, 90, and 110 respectively', () => { - beforeEach(() => { - balances = { - [chain1]: ethers.utils.parseEther('70').toBigInt(), - [chain2]: ethers.utils.parseEther('90').toBigInt(), - [chain3]: ethers.utils.parseEther('110').toBigInt(), - }; + it('should return a single route when two chains are unbalanced and can be solved with a single transfer', () => { + const strategy = new Strategy({ + [chain1]: { weight: 100n, tolerance: 0n }, + [chain2]: { weight: 100n, tolerance: 0n }, + [chain3]: { weight: 100n, tolerance: 0n }, }); - it('should return one route for 20 from chain3 to chain1', () => { - const routes = strategy.getRebalancingRoutes(balances); + const rawBalances = { + [chain1]: ethers.utils.parseEther('100').toBigInt(), + [chain2]: ethers.utils.parseEther('200').toBigInt(), + [chain3]: ethers.utils.parseEther('300').toBigInt(), + }; + + const routes = strategy.getRebalancingRoutes(rawBalances); - expect(routes).to.have.lengthOf(1); - expect(routes[0]).to.deep.equal({ - fromChain: 'chain3', - toChain: 'chain1', - amount: ethers.utils.parseEther('20').toBigInt(), - }); - }); + expect(routes).to.deep.equal([ + { + fromChain: chain3, + toChain: chain1, + amount: ethers.utils.parseEther('100').toBigInt(), + }, + ]); }); - describe('when balances for chain1, chain2, and chain3 are lower than the tolerance', () => { - beforeEach(() => { - balances = { - [chain1]: ethers.utils.parseEther('9').toBigInt(), - [chain2]: ethers.utils.parseEther('9').toBigInt(), - [chain3]: ethers.utils.parseEther('9').toBigInt(), - }; + it('should return two routes when two chains are unbalanced and cannot be solved with a single transfer', () => { + const strategy = new Strategy({ + [chain1]: { weight: 100n, tolerance: 0n }, + [chain2]: { weight: 100n, tolerance: 0n }, + [chain3]: { weight: 100n, tolerance: 0n }, }); - it('should return no routes', () => { - const routes = strategy.getRebalancingRoutes(balances); + const rawBalances = { + [chain1]: ethers.utils.parseEther('100').toBigInt(), + [chain2]: ethers.utils.parseEther('100').toBigInt(), + [chain3]: ethers.utils.parseEther('500').toBigInt(), + }; + + const routes = strategy.getRebalancingRoutes(rawBalances); + + expect(routes).to.deep.equal([ + { + fromChain: chain3, + toChain: chain1, + amount: 133333333333333333333n, + }, + { + fromChain: chain3, + toChain: chain2, + amount: 133333333333333333333n, + }, + ]); + }); - expect(routes).to.be.empty; + it('should return a single route to balance different weighted chains', () => { + const strategy = new Strategy({ + [chain1]: { weight: 50n, tolerance: 0n }, + [chain2]: { weight: 25n, tolerance: 0n }, + [chain3]: { weight: 25n, tolerance: 0n }, }); + + const rawBalances = { + [chain1]: ethers.utils.parseEther('100').toBigInt(), + [chain2]: ethers.utils.parseEther('100').toBigInt(), + [chain3]: ethers.utils.parseEther('100').toBigInt(), + }; + + const routes = strategy.getRebalancingRoutes(rawBalances); + + expect(routes).to.deep.equal([ + { + fromChain: chain2, + toChain: chain1, + amount: ethers.utils.parseEther('25').toBigInt(), + }, + { + fromChain: chain3, + toChain: chain1, + amount: ethers.utils.parseEther('25').toBigInt(), + }, + ]); }); }); }); diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index 1ad60a1dbb7..795e0095567 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -1,86 +1,176 @@ +import { z } from 'zod'; +import { fromZodError } from 'zod-validation-error'; + import { ChainName } from '@hyperlane-xyz/sdk'; +import { readYamlOrJson } from '../../utils/files.js'; import { IStrategy, RawBalances, RebalancingRoute, } from '../interfaces/IStrategy.js'; +import { Delta, StrategyConfig } from './types.js'; + export class Strategy implements IStrategy { + private readonly chains: ChainName[]; + private readonly config: StrategyConfig; + private readonly totalWeight: bigint; + /** - * @param tolerance Value used to prevent rebalancing amounts that are already close to the target + * Create a new Strategy from a config file found at the given path. */ - constructor(private readonly tolerance: bigint = 0n) {} + static fromConfigFile(configPath: string): Strategy { + const ChainConfigSchema = z.object({ + weight: z + .string() + .or(z.number()) + .transform((val) => BigInt(val)), + tolerance: z + .string() + .or(z.number()) + .transform((val) => BigInt(val)), + }); + + const StrategyConfigSchema = z.record(z.string(), ChainConfigSchema); + + const config = readYamlOrJson(configPath); + + const validationResult = StrategyConfigSchema.safeParse(config); + + if (!validationResult.success) { + const validationError = fromZodError(validationResult.error); + throw new Error(validationError.message); + } + + return new Strategy(validationResult.data); + } + + constructor(config: StrategyConfig) { + const chains = Object.keys(config); + + // Rebalancing makes sense only with more than one chain. + if (chains.length < 2) { + throw new Error('At least two chains must be configured'); + } + + let totalWeight = 0n; + + for (const chain of chains) { + const { weight, tolerance } = config[chain]; + + if (weight <= 0n) { + throw new Error('Weight must be greater than 0'); + } + + if (tolerance < 0n || tolerance > 100n) { + throw new Error('Tolerance must be between 0 and 100'); + } + + totalWeight += weight; + } + + this.chains = chains; + this.config = config; + this.totalWeight = totalWeight; + } /** - * Get the optimized routes that will rebalance all chains to the same balance + * Get the optimized routes to rebalance the defined chains. */ getRebalancingRoutes(rawBalances: RawBalances): RebalancingRoute[] { - const entries = Object.entries(rawBalances); + this.validateRawBalances(rawBalances); + // Get the total balance from all chains - const total = entries.reduce((sum, [, balance]) => sum + balance, 0n); - // Get the average balance - const target = total / BigInt(entries.length); + const total = this.chains.reduce( + (sum, chain) => sum + rawBalances[chain], + 0n, + ); - // Skip rebalancing when the average balance is very small - if (target < this.tolerance) { - return []; - } + // Group balances by balances with surplus or deficit + const { surpluss, deficits } = this.chains.reduce( + (acc, chain) => { + const { weight, tolerance } = this.config[chain]; + const target = (total * weight) / this.totalWeight; + const toleranceAmount = (target * tolerance) / 100n; + const balance = rawBalances[chain]; - const surpluss: { chain: ChainName; amount: bigint }[] = []; - const deficits: { chain: ChainName; amount: bigint }[] = []; - - // Group balances by balances with surplus or deficit. - // The tolerance is used to consider "balanced" chains that are already close to the target - for (const [chain, balance] of entries) { - if (balance < target - this.tolerance) { - deficits.push({ chain, amount: target - balance }); - } else if (balance > target + this.tolerance) { - surpluss.push({ chain, amount: balance - target }); - } else { - // Do nothing as the balance is already on target - } - } + // Apply the tolerance to deficits to prevent small imbalances + if (balance < target - toleranceAmount) { + acc.deficits.push({ chain, amount: target - balance }); + } else if (balance > target) { + acc.surpluss.push({ chain, amount: balance - target }); + } else { + // Do nothing as the balance is already on target + } + + return acc; + }, + { + surpluss: [] as Delta[], + deficits: [] as Delta[], + }, + ); + + // Sort from largest to smallest amounts as to always transfer largest amounts + // first and decrease the amount of routes required + surpluss.sort((a, b) => (a.amount > b.amount ? -1 : 1)); + deficits.sort((a, b) => (a.amount > b.amount ? -1 : 1)); const routes: RebalancingRoute[] = []; - // Keep iterating until all routes have been found - while (surpluss.length > 0 && deficits.length > 0) { + // Transfer from surplus to deficit until all deficits are balanced. + // It is not possible in this implementation for surpluses to run out before deficits + while (deficits.length > 0) { const surplus = surpluss[0]; const deficit = deficits[0]; - const fromChain = surplus.chain; - const toChain = deficit.chain; - if (surplus.amount > deficit.amount) { - routes.push({ - fromChain, - toChain, - amount: deficit.amount, - }); + // Transfers the whole surplus or just the amount to balance the deficit + const transferAmount = + surplus.amount > deficit.amount ? deficit.amount : surplus.amount; - deficits.shift(); - surplus.amount -= deficit.amount; - } else if (surplus.amount < deficit.amount) { - routes.push({ - fromChain, - toChain, - amount: surplus.amount, - }); + // Creates the balancing route + routes.push({ + fromChain: surplus.chain, + toChain: deficit.chain, + amount: transferAmount, + }); - surpluss.shift(); - deficit.amount -= surplus.amount; - } else { - routes.push({ - fromChain, - toChain, - amount: surplus.amount, - }); + // Decreases the amounts for the following iterations + deficit.amount -= transferAmount; + surplus.amount -= transferAmount; + // Removes the deficit if it is fully balanced + if (!deficit.amount) { deficits.shift(); + } + + // Removes the surplus if it has been drained + if (!surplus.amount) { surpluss.shift(); } } return routes; } + + private validateRawBalances(rawBalances: RawBalances): void { + const rawBalancesChains = Object.keys(rawBalances); + + if (this.chains.length !== rawBalancesChains.length) { + throw new Error('Config chains do not match raw balances chains length'); + } + + for (const chain of this.chains) { + const balance: bigint | undefined = rawBalances[chain]; + + if (balance === undefined) { + throw new Error(`Raw balance for chain ${chain} not found`); + } + + if (balance < 0n) { + throw new Error(`Raw balance for chain ${chain} is negative`); + } + } + } } diff --git a/typescript/cli/src/rebalancer/strategy/types.ts b/typescript/cli/src/rebalancer/strategy/types.ts new file mode 100644 index 00000000000..9e081fef584 --- /dev/null +++ b/typescript/cli/src/rebalancer/strategy/types.ts @@ -0,0 +1,24 @@ +import { ChainName } from '@hyperlane-xyz/sdk'; + +/** + * Per chain configuration for the strategy + */ +export type StrategyConfig = Record< + ChainName, + { + /** + * How much in % of the total balance the chain should have + */ + weight: bigint; + /** + * How much in % of the target balance a deficitary chain can + * deviate before being considered unbalanced + */ + tolerance: bigint; + } +>; + +/** + * The amount of tokens that a chain deviates from the target balance + */ +export type Delta = { chain: ChainName; amount: bigint }; diff --git a/typescript/cli/src/tests/commands/helpers.ts b/typescript/cli/src/tests/commands/helpers.ts index c96551bf441..0b3245defca 100644 --- a/typescript/cli/src/tests/commands/helpers.ts +++ b/typescript/cli/src/tests/commands/helpers.ts @@ -60,6 +60,8 @@ export const WARP_CONFIG_PATH_2 = `${TEMP_PATH}/${CHAIN_NAME_2}/warp-route-deplo export const WARP_DEPLOY_OUTPUT_PATH = `${TEMP_PATH}/warp-route-deployment.yaml`; export const WARP_CORE_CONFIG_PATH_2 = `${REGISTRY_PATH}/deployments/warp_routes/ETH/anvil2-config.yaml`; +export const REBALANCER_STRATEGY_CONFIG_PATH = `${TEMP_PATH}/rebalancer-strategy.json`; + export function getCombinedWarpRoutePath( tokenSymbol: string, chains: string[], diff --git a/typescript/cli/src/tests/commands/warp.ts b/typescript/cli/src/tests/commands/warp.ts index ab9654a6882..718bef585c1 100644 --- a/typescript/cli/src/tests/commands/warp.ts +++ b/typescript/cli/src/tests/commands/warp.ts @@ -159,19 +159,13 @@ export function hyperlaneWarpSendRelay( export function hyperlaneWarpRebalancer( warpRouteId: string, checkFrequency: number, - options: { - strategyTolerance?: bigint; - } = {}, + strategyConfigFile: string, ): ProcessPromise { return $`yarn workspace @hyperlane-xyz/cli run hyperlane warp rebalancer \ --registry ${REGISTRY_PATH} \ --warpRouteId ${warpRouteId} \ --checkFrequency ${checkFrequency} \ - ${ - options.strategyTolerance - ? ['--strategyTolerance', options.strategyTolerance] - : '' - }`; + --strategyConfigFile ${strategyConfigFile}`; } /** diff --git a/typescript/cli/src/tests/warp/warp-rebalancer.e2e-test.ts b/typescript/cli/src/tests/warp/warp-rebalancer.e2e-test.ts index aef76109cd7..020575c97f7 100644 --- a/typescript/cli/src/tests/warp/warp-rebalancer.e2e-test.ts +++ b/typescript/cli/src/tests/warp/warp-rebalancer.e2e-test.ts @@ -1,5 +1,5 @@ import { Wallet } from 'ethers'; -import { ProcessPromise } from 'zx'; +import { rmSync } from 'fs'; import { $ } from 'zx'; import { createWarpRouteConfigId } from '@hyperlane-xyz/registry'; @@ -21,6 +21,7 @@ import { CHAIN_NAME_4, CORE_CONFIG_PATH, DEFAULT_E2E_TEST_TIMEOUT, + REBALANCER_STRATEGY_CONFIG_PATH, createSnapshot, deployOrUseExistingCore, deployToken, @@ -41,16 +42,14 @@ describe('hyperlane warp rebalancer e2e tests', async function () { let warpDeploymentPath: string; let tokenSymbol: string; let warpRouteId: string; - - let process: ProcessPromise | undefined; - let snapshots: { rpcUrl: string; snapshotId: string }[] = []; before(async () => { const ogVerbose = $.verbose; $.verbose = false; - // Deploy core contracts on all chains + console.log('Deploying core contracts on all chains...'); + const [chain2Addresses, chain3Addresses, chain4Addresses] = await Promise.all([ deployOrUseExistingCore(CHAIN_NAME_2, CORE_CONFIG_PATH, ANVIL_KEY), @@ -58,14 +57,16 @@ describe('hyperlane warp rebalancer e2e tests', async function () { deployOrUseExistingCore(CHAIN_NAME_4, CORE_CONFIG_PATH, ANVIL_KEY), ]); - // Deploy ERC20s + console.log('Deploying ERC20s...'); + const [tokenChain2, tokenChain3] = await Promise.all([ deployToken(ANVIL_KEY, CHAIN_NAME_2), deployToken(ANVIL_KEY, CHAIN_NAME_3), ]); tokenSymbol = await tokenChain2.symbol(); - // Deploy Warp Route + console.log('Deploying Warp Route...'); + warpDeploymentPath = getCombinedWarpRoutePath(tokenSymbol, [ CHAIN_NAME_2, CHAIN_NAME_3, @@ -100,11 +101,35 @@ describe('hyperlane warp rebalancer e2e tests', async function () { CHAIN_NAME_4, ]); + console.log('Bridging tokens...'); + + await Promise.all([ + hyperlaneWarpSendRelay( + CHAIN_NAME_2, + CHAIN_NAME_4, + warpDeploymentPath, + true, + toWei(100), + ), + sleep(1000).then(() => + hyperlaneWarpSendRelay( + CHAIN_NAME_3, + CHAIN_NAME_4, + warpDeploymentPath, + true, + toWei(100), + ), + ), + ]); + $.verbose = ogVerbose; }); beforeEach(async () => { - process = undefined; + writeYamlOrJson(REBALANCER_STRATEGY_CONFIG_PATH, { + [CHAIN_NAME_2]: { weight: '100', tolerance: '0' }, + [CHAIN_NAME_3]: { weight: '100', tolerance: '0' }, + }); const chain2Metadata: ChainMetadata = readYamlOrJson(CHAIN_2_METADATA_PATH); const chain3Metadata: ChainMetadata = readYamlOrJson(CHAIN_3_METADATA_PATH); @@ -131,8 +156,10 @@ describe('hyperlane warp rebalancer e2e tests', async function () { }); afterEach(async () => { - if (process) { - await process.kill(); + try { + rmSync(REBALANCER_STRATEGY_CONFIG_PATH); + } catch (e) { + // Ignore } await Promise.all( @@ -142,125 +169,73 @@ describe('hyperlane warp rebalancer e2e tests', async function () { ); }); - it('should successfuly start the rebalancer', async () => { - process = hyperlaneWarpRebalancer(warpRouteId, CHECK_FREQUENCY); - - for await (const chunk of process.stdout) { - if (chunk.includes('Rebalancer started successfully 🚀')) { - break; - } - } - }); + async function startRebalancerAndExpectLog(log: string): Promise { + const process = hyperlaneWarpRebalancer( + warpRouteId, + CHECK_FREQUENCY, + REBALANCER_STRATEGY_CONFIG_PATH, + ); - describe('with no balance on collateral contracts', () => { - it('should report an empty array of routes being executed', async () => { - process = hyperlaneWarpRebalancer(warpRouteId, CHECK_FREQUENCY); + return new Promise(async (resolve, reject) => { + process.catch((e) => { + // TODO: Do a pretty print of the error + reject(e.text()); + }); for await (const chunk of process.stdout) { - if (chunk.includes('Executing rebalancing routes: []')) { + if (chunk.includes(log)) { + resolve(); + await process.kill(); break; } } }); + } + + it('should successfuly start the rebalancer', async () => { + await startRebalancerAndExpectLog('Rebalancer started successfully 🚀'); }); - describe('with the same balance on all collateral contracts', () => { - beforeEach(async () => { - const ogVerbose = $.verbose; - $.verbose = false; + it('should throw when strategy config file does not exist', async () => { + rmSync(REBALANCER_STRATEGY_CONFIG_PATH); - await Promise.all([ - hyperlaneWarpSendRelay( - CHAIN_NAME_2, - CHAIN_NAME_4, - warpDeploymentPath, - true, - toWei(50), - ), - sleep(1000).then(() => - hyperlaneWarpSendRelay( - CHAIN_NAME_3, - CHAIN_NAME_4, - warpDeploymentPath, - true, - toWei(50), - ), - ), - ]); + await startRebalancerAndExpectLog( + `File doesn't exist at ${REBALANCER_STRATEGY_CONFIG_PATH}`, + ); + }); - $.verbose = ogVerbose; + it('should throw if a strategy bigint cannot be parsed', async () => { + writeYamlOrJson(REBALANCER_STRATEGY_CONFIG_PATH, { + [CHAIN_NAME_2]: { weight: 'weight', tolerance: 0 }, + [CHAIN_NAME_3]: { weight: 100, tolerance: 0 }, }); - it('should report an empty array of routes being executed', async () => { - process = hyperlaneWarpRebalancer(warpRouteId, CHECK_FREQUENCY); + await startRebalancerAndExpectLog(`Cannot convert weight to a BigInt`); - for await (const chunk of process.stdout) { - if (chunk.includes('Executing rebalancing routes: []')) { - break; - } - } + writeYamlOrJson(REBALANCER_STRATEGY_CONFIG_PATH, { + [CHAIN_NAME_2]: { weight: 100, tolerance: 0 }, + [CHAIN_NAME_3]: { weight: 100, tolerance: 'tolerance' }, }); - }); - describe('with different balances on collateral contracts', () => { - beforeEach(async () => { - const ogVerbose = $.verbose; - $.verbose = false; + await startRebalancerAndExpectLog(`Cannot convert tolerance to a BigInt`); + }); - await Promise.all([ - hyperlaneWarpSendRelay( - CHAIN_NAME_2, - CHAIN_NAME_4, - warpDeploymentPath, - true, - toWei(40), - ), - sleep(1000).then(() => - hyperlaneWarpSendRelay( - CHAIN_NAME_3, - CHAIN_NAME_4, - warpDeploymentPath, - true, - toWei(60), - ), - ), - ]); + it('should log that no routes are to be executed', async () => { + await startRebalancerAndExpectLog(`Executing rebalancing routes: []`); + }); - $.verbose = ogVerbose; + it('should log that a single route is to be executed', async () => { + writeYamlOrJson(REBALANCER_STRATEGY_CONFIG_PATH, { + [CHAIN_NAME_2]: { weight: '75', tolerance: '0' }, + [CHAIN_NAME_3]: { weight: '25', tolerance: '0' }, }); - it('should report an array of routes being executed', async () => { - process = hyperlaneWarpRebalancer(warpRouteId, CHECK_FREQUENCY); - - for await (const chunk of process.stdout) { - if ( - chunk.includes( - `Executing rebalancing routes: [ + await startRebalancerAndExpectLog(`Executing rebalancing routes: [ { fromChain: 'anvil3', toChain: 'anvil2', - amount: 10000000000000000000n + amount: 50000000000000000000n } -]`, - ) - ) { - break; - } - } - }); - - describe('with strategy tolerance of 10 ether', () => { - it('should report an empty array of routes being executed', async () => { - process = hyperlaneWarpRebalancer(warpRouteId, CHECK_FREQUENCY, { - strategyTolerance: BigInt(toWei(10)), - }); - - for await (const chunk of process.stdout) { - if (chunk.includes('Executing rebalancing routes: []')) { - break; - } - } - }); - }); +]`); }); });