From a40894e93568c9cbc3f523d3a2bb28d3f1d9a5ab Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 12:05:18 -0300 Subject: [PATCH 01/18] fix: WIP weighted targets --- .../cli/src/rebalancer/strategy/Strategy.ts | 93 ++++++++++--------- 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index 1ad60a1dbb7..e8a386e471e 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -10,7 +10,14 @@ export class Strategy implements IStrategy { /** * @param tolerance Value used to prevent rebalancing amounts that are already close to the target */ - constructor(private readonly tolerance: bigint = 0n) {} + constructor( + private readonly tolerance: bigint = 0n, + private readonly weights: Record, + ) { + if (Object.values(weights).reduce((acc, next) => acc + next, 0n) !== 100n) { + throw new Error('Weights must add up to 100'); + } + } /** * Get the optimized routes that will rebalance all chains to the same balance @@ -19,64 +26,60 @@ export class Strategy implements IStrategy { const entries = Object.entries(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); - // Skip rebalancing when the average balance is very small - if (target < this.tolerance) { - return []; - } + // How much each chain should have according to the weights + const targets = Object.entries(this.weights).reduce( + (targets, [chain, weight]) => { + targets[chain] = (total * weight) / 100n; + return targets; + }, + {} as Record, + ); - const surpluss: { chain: ChainName; amount: bigint }[] = []; - const deficits: { chain: ChainName; amount: bigint }[] = []; + // Group balances by balances with surplus or deficit + const { surpluss, deficits } = entries.reduce( + (acc, [chain, balance]) => { + const target = targets[chain]; - // 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 - } - } + if (balance < target) { + 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 { chain: ChainName; amount: bigint }[], + deficits: [] as { chain: ChainName; amount: bigint }[], + }, + ); const routes: RebalancingRoute[] = []; - // Keep iterating until all routes have been found while (surpluss.length > 0 && 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, - }); + const transferAmount = + surplus.amount < deficit.amount ? surplus.amount : deficit.amount; - deficits.shift(); - surplus.amount -= deficit.amount; - } else if (surplus.amount < deficit.amount) { - routes.push({ - fromChain, - toChain, - amount: surplus.amount, - }); + routes.push({ + fromChain: surplus.chain, + toChain: deficit.chain, + amount: transferAmount, + }); - surpluss.shift(); - deficit.amount -= surplus.amount; - } else { - routes.push({ - fromChain, - toChain, - amount: surplus.amount, - }); + deficit.amount -= transferAmount; + surplus.amount -= transferAmount; + if (!deficit.amount) { deficits.shift(); + } + + if (!surplus.amount) { surpluss.shift(); } } From 3a3930143806217433a69b4e74d883a6b1b8964c Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 13:03:40 -0300 Subject: [PATCH 02/18] fix: Weighted targets --- .../cli/src/rebalancer/strategy/Strategy.ts | 76 +++++++++++++++---- 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index e8a386e471e..435f3fcaf5e 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -6,15 +6,46 @@ import { RebalancingRoute, } from '../interfaces/IStrategy.js'; +/** + * Per chain configuration for the strategy + */ +type Config = 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 + */ +type Delta = { chain: ChainName; amount: bigint }; + export class Strategy implements IStrategy { - /** - * @param tolerance Value used to prevent rebalancing amounts that are already close to the target - */ - constructor( - private readonly tolerance: bigint = 0n, - private readonly weights: Record, - ) { - if (Object.values(weights).reduce((acc, next) => acc + next, 0n) !== 100n) { + constructor(private readonly config: Config) { + let totalWeight = 0n; + + for (const [, { weight, tolerance }] of Object.entries(config)) { + if (weight > 100n || weight < 0n) { + throw new Error('Weight must be between 0 and 100'); + } + + if (tolerance > 100n || tolerance < 0n) { + throw new Error('Tolerance must be between 0 and 100'); + } + + totalWeight += weight; + } + + if (totalWeight !== 100n) { throw new Error('Weights must add up to 100'); } } @@ -28,8 +59,8 @@ export class Strategy implements IStrategy { const total = entries.reduce((sum, [, balance]) => sum + balance, 0n); // How much each chain should have according to the weights - const targets = Object.entries(this.weights).reduce( - (targets, [chain, weight]) => { + const targets = Object.entries(this.config).reduce( + (targets, [chain, { weight }]) => { targets[chain] = (total * weight) / 100n; return targets; }, @@ -40,8 +71,11 @@ export class Strategy implements IStrategy { const { surpluss, deficits } = entries.reduce( (acc, [chain, balance]) => { const target = targets[chain]; + const tolerance = this.config[chain].tolerance; + const toleranceAmount = (target * tolerance) / 100n; - if (balance < 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 }); @@ -52,33 +86,45 @@ export class Strategy implements IStrategy { return acc; }, { - surpluss: [] as { chain: ChainName; amount: bigint }[], - deficits: [] as { chain: ChainName; amount: bigint }[], + 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[] = []; - 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]; + // Transfers the whole surplus or just the amount to balance the deficit const transferAmount = - surplus.amount < deficit.amount ? surplus.amount : deficit.amount; + surplus.amount > deficit.amount ? deficit.amount : surplus.amount; + // Creates the balancing route routes.push({ fromChain: surplus.chain, toChain: deficit.chain, amount: transferAmount, }); + // 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(); } From faaf63f73cf90d2f8676c3a77c61e30f7fcf11d2 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 13:07:15 -0300 Subject: [PATCH 03/18] fix: Validate input --- typescript/cli/src/rebalancer/strategy/Strategy.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index 435f3fcaf5e..947530488ec 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -55,6 +55,17 @@ export class Strategy implements IStrategy { */ getRebalancingRoutes(rawBalances: RawBalances): RebalancingRoute[] { const entries = Object.entries(rawBalances); + + for (const [chain, balance] of entries) { + if (!this.config[chain]) { + throw new Error(`Chain ${chain} not found in configuration`); + } + + if (balance < 0n) { + throw new Error(`Balance ${balance} is negative`); + } + } + // Get the total balance from all chains const total = entries.reduce((sum, [, balance]) => sum + balance, 0n); From 5fa7f4104d9e89af6fc4f540d3d26bb7c2e649e6 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 13:34:28 -0300 Subject: [PATCH 04/18] fix: Validate amount of chains --- typescript/cli/src/rebalancer/strategy/Strategy.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index 947530488ec..72ffd5aa853 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -31,9 +31,17 @@ type Delta = { chain: ChainName; amount: bigint }; export class Strategy implements IStrategy { constructor(private readonly config: Config) { + const chains = Object.keys(config); + + if (chains.length < 2) { + throw new Error('At least two chains must be configured'); + } + let totalWeight = 0n; - for (const [, { weight, tolerance }] of Object.entries(config)) { + for (const chain of chains) { + const { weight, tolerance } = config[chain]; + if (weight > 100n || weight < 0n) { throw new Error('Weight must be between 0 and 100'); } From 86975b2eb5a793e832b250f4d3f4faf91f6e16f1 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 13:41:50 -0300 Subject: [PATCH 05/18] fix: Validate raw balances --- .../cli/src/rebalancer/strategy/Strategy.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index 72ffd5aa853..846de4c9092 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -64,15 +64,7 @@ export class Strategy implements IStrategy { getRebalancingRoutes(rawBalances: RawBalances): RebalancingRoute[] { const entries = Object.entries(rawBalances); - for (const [chain, balance] of entries) { - if (!this.config[chain]) { - throw new Error(`Chain ${chain} not found in configuration`); - } - - if (balance < 0n) { - throw new Error(`Balance ${balance} is negative`); - } - } + this.validateRawBalances(rawBalances); // Get the total balance from all chains const total = entries.reduce((sum, [, balance]) => sum + balance, 0n); @@ -151,4 +143,25 @@ export class Strategy implements IStrategy { return routes; } + + private validateRawBalances(rawBalances: RawBalances): void { + const configChains = Object.keys(this.config); + const rawBalancesChains = Object.keys(rawBalances); + + if (configChains.length !== rawBalancesChains.length) { + throw new Error('Config chains do not match raw balances chains length'); + } + + for (const chain of configChains) { + 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`); + } + } + } } From 52c2101e75e8007b018b5d5b8e77400ab8b6c3da Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 13:43:34 -0300 Subject: [PATCH 06/18] fix: Reorder --- typescript/cli/src/rebalancer/strategy/Strategy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index 846de4c9092..770942af99d 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -62,10 +62,10 @@ export class Strategy implements IStrategy { * Get the optimized routes that will rebalance all chains to the same balance */ getRebalancingRoutes(rawBalances: RawBalances): RebalancingRoute[] { - const entries = Object.entries(rawBalances); - this.validateRawBalances(rawBalances); + const entries = Object.entries(rawBalances); + // Get the total balance from all chains const total = entries.reduce((sum, [, balance]) => sum + balance, 0n); From 181761886f67a236fc16ef209f062631decab62c Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 13:46:28 -0300 Subject: [PATCH 07/18] fix: Validate config on a separate function --- .../cli/src/rebalancer/strategy/Strategy.ts | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index 770942af99d..101f259cd60 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -31,31 +31,7 @@ type Delta = { chain: ChainName; amount: bigint }; export class Strategy implements IStrategy { constructor(private readonly config: Config) { - const chains = Object.keys(config); - - 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 > 100n || weight < 0n) { - throw new Error('Weight must be between 0 and 100'); - } - - if (tolerance > 100n || tolerance < 0n) { - throw new Error('Tolerance must be between 0 and 100'); - } - - totalWeight += weight; - } - - if (totalWeight !== 100n) { - throw new Error('Weights must add up to 100'); - } + this.validateConfig(config); } /** @@ -144,6 +120,34 @@ export class Strategy implements IStrategy { return routes; } + private validateConfig(config: Config): void { + const chains = Object.keys(config); + + 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 > 100n || weight < 0n) { + throw new Error('Weight must be between 0 and 100'); + } + + if (tolerance > 100n || tolerance < 0n) { + throw new Error('Tolerance must be between 0 and 100'); + } + + totalWeight += weight; + } + + if (totalWeight !== 100n) { + throw new Error('Weights must add up to 100'); + } + } + private validateRawBalances(rawBalances: RawBalances): void { const configChains = Object.keys(this.config); const rawBalancesChains = Object.keys(rawBalances); From 14461f18f45693ce24ddf6be5dab576900748366 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 14:46:55 -0300 Subject: [PATCH 08/18] fix: Create types file --- .../cli/src/rebalancer/strategy/types.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 typescript/cli/src/rebalancer/strategy/types.ts diff --git a/typescript/cli/src/rebalancer/strategy/types.ts b/typescript/cli/src/rebalancer/strategy/types.ts new file mode 100644 index 00000000000..1ba8ac16e4f --- /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 Config = 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 }; From 0f02e7b551f67339fd45e4119a0a4a020f9689f4 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 14:48:08 -0300 Subject: [PATCH 09/18] fix: Refactor --- .../cli/src/rebalancer/strategy/Strategy.ts | 73 ++++++------------- 1 file changed, 23 insertions(+), 50 deletions(-) diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index 101f259cd60..c5c6b1aa7c4 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -6,60 +6,35 @@ import { RebalancingRoute, } from '../interfaces/IStrategy.js'; -/** - * Per chain configuration for the strategy - */ -type Config = 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 - */ -type Delta = { chain: ChainName; amount: bigint }; +import { Config, Delta } from './types.js'; export class Strategy implements IStrategy { + private readonly chains: ChainName[]; + constructor(private readonly config: Config) { - this.validateConfig(config); + this.chains = Object.keys(this.config); + this.validateConfig(); } /** - * 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[] { this.validateRawBalances(rawBalances); - const entries = Object.entries(rawBalances); - // Get the total balance from all chains - const total = entries.reduce((sum, [, balance]) => sum + balance, 0n); - - // How much each chain should have according to the weights - const targets = Object.entries(this.config).reduce( - (targets, [chain, { weight }]) => { - targets[chain] = (total * weight) / 100n; - return targets; - }, - {} as Record, + const total = this.chains.reduce( + (sum, chain) => sum + rawBalances[chain], + 0n, ); // Group balances by balances with surplus or deficit - const { surpluss, deficits } = entries.reduce( - (acc, [chain, balance]) => { - const target = targets[chain]; - const tolerance = this.config[chain].tolerance; + const { surpluss, deficits } = this.chains.reduce( + (acc, chain) => { + const { weight, tolerance } = this.config[chain]; + const target = (total * weight) / 100n; const toleranceAmount = (target * tolerance) / 100n; + const balance = rawBalances[chain]; // Apply the tolerance to deficits to prevent small imbalances if (balance < target - toleranceAmount) { @@ -120,23 +95,22 @@ export class Strategy implements IStrategy { return routes; } - private validateConfig(config: Config): void { - const chains = Object.keys(config); - - if (chains.length < 2) { + private validateConfig(): void { + // Rebalancing makes sense only with more than one chain. + if (this.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]; + for (const chain of this.chains) { + const { weight, tolerance } = this.config[chain]; - if (weight > 100n || weight < 0n) { + if (weight < 0n || weight > 100n) { throw new Error('Weight must be between 0 and 100'); } - if (tolerance > 100n || tolerance < 0n) { + if (tolerance < 0n || tolerance > 100n) { throw new Error('Tolerance must be between 0 and 100'); } @@ -149,14 +123,13 @@ export class Strategy implements IStrategy { } private validateRawBalances(rawBalances: RawBalances): void { - const configChains = Object.keys(this.config); const rawBalancesChains = Object.keys(rawBalances); - if (configChains.length !== rawBalancesChains.length) { + if (this.chains.length !== rawBalancesChains.length) { throw new Error('Config chains do not match raw balances chains length'); } - for (const chain of configChains) { + for (const chain of this.chains) { const balance: bigint | undefined = rawBalances[chain]; if (balance === undefined) { From b3c494d69926c696cfefa328232e1c5a9023940d Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 15:09:47 -0300 Subject: [PATCH 10/18] fix: Tests --- .../src/rebalancer/strategy/Strategy.test.ts | 46 ++++++-------- .../cli/src/rebalancer/strategy/Strategy.ts | 63 +++++++++---------- .../cli/src/rebalancer/strategy/types.ts | 2 +- 3 files changed, 51 insertions(+), 60 deletions(-) diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.test.ts b/typescript/cli/src/rebalancer/strategy/Strategy.test.ts index 6ac85c7287b..813b5117ad5 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.test.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.test.ts @@ -25,7 +25,11 @@ describe('Strategy', () => { [chain3]: ethers.utils.parseEther('300').toBigInt(), }; - strategy = new Strategy(); + strategy = new Strategy({ + [chain1]: { weight: 100n, tolerance: 0n }, + [chain2]: { weight: 100n, tolerance: 0n }, + [chain3]: { weight: 100n, tolerance: 0n }, + }); }); describe('when balances for chain1, chain2, and chain3 are 100, 200, and 300 respectively', () => { @@ -91,16 +95,20 @@ describe('Strategy', () => { }); }); - describe('when tolerance is 10 ether', () => { + describe('when tolerance for each chain is 10%', () => { beforeEach(() => { - strategy = new Strategy(ethers.utils.parseEther('10').toBigInt()); + strategy = new Strategy({ + [chain1]: { weight: 100n, tolerance: 10n }, + [chain2]: { weight: 100n, tolerance: 10n }, + [chain3]: { weight: 100n, tolerance: 10n }, + }); }); - describe('when balances for chain1, chain2, and chain3 are 80, 90, and 100 respectively', () => { + describe('when balances for chain1, chain2, and chain3 are 90, 95, and 100 respectively', () => { beforeEach(() => { balances = { - [chain1]: ethers.utils.parseEther('80').toBigInt(), - [chain2]: ethers.utils.parseEther('90').toBigInt(), + [chain1]: ethers.utils.parseEther('90').toBigInt(), + [chain2]: ethers.utils.parseEther('95').toBigInt(), [chain3]: ethers.utils.parseEther('100').toBigInt(), }; }); @@ -112,41 +120,25 @@ describe('Strategy', () => { }); }); - describe('when balances for chain1, chain2, and chain3 are 70, 90, and 110 respectively', () => { + describe('when balances for chain1, chain2, and chain3 are 80, 90, and 100 respectively', () => { beforeEach(() => { balances = { - [chain1]: ethers.utils.parseEther('70').toBigInt(), + [chain1]: ethers.utils.parseEther('80').toBigInt(), [chain2]: ethers.utils.parseEther('90').toBigInt(), - [chain3]: ethers.utils.parseEther('110').toBigInt(), + [chain3]: ethers.utils.parseEther('100').toBigInt(), }; }); - it('should return one route for 20 from chain3 to chain1', () => { + it('should return one route for 10 from chain3 to chain1', () => { const routes = strategy.getRebalancingRoutes(balances); expect(routes).to.have.lengthOf(1); expect(routes[0]).to.deep.equal({ fromChain: 'chain3', toChain: 'chain1', - amount: ethers.utils.parseEther('20').toBigInt(), + amount: ethers.utils.parseEther('10').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 no routes', () => { - const routes = strategy.getRebalancingRoutes(balances); - - expect(routes).to.be.empty; - }); - }); }); }); diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index c5c6b1aa7c4..9efb89a9662 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -6,14 +6,40 @@ import { RebalancingRoute, } from '../interfaces/IStrategy.js'; -import { Config, Delta } from './types.js'; +import { Delta, StrategyConfig } from './types.js'; export class Strategy implements IStrategy { private readonly chains: ChainName[]; + private readonly config: StrategyConfig; + private readonly totalWeight: bigint; - constructor(private readonly config: Config) { - this.chains = Object.keys(this.config); - this.validateConfig(); + 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; } /** @@ -32,7 +58,7 @@ export class Strategy implements IStrategy { const { surpluss, deficits } = this.chains.reduce( (acc, chain) => { const { weight, tolerance } = this.config[chain]; - const target = (total * weight) / 100n; + const target = (total * weight) / this.totalWeight; const toleranceAmount = (target * tolerance) / 100n; const balance = rawBalances[chain]; @@ -95,33 +121,6 @@ export class Strategy implements IStrategy { return routes; } - private validateConfig(): void { - // Rebalancing makes sense only with more than one chain. - if (this.chains.length < 2) { - throw new Error('At least two chains must be configured'); - } - - let totalWeight = 0n; - - for (const chain of this.chains) { - const { weight, tolerance } = this.config[chain]; - - if (weight < 0n || weight > 100n) { - throw new Error('Weight must be between 0 and 100'); - } - - if (tolerance < 0n || tolerance > 100n) { - throw new Error('Tolerance must be between 0 and 100'); - } - - totalWeight += weight; - } - - if (totalWeight !== 100n) { - throw new Error('Weights must add up to 100'); - } - } - private validateRawBalances(rawBalances: RawBalances): void { const rawBalancesChains = Object.keys(rawBalances); diff --git a/typescript/cli/src/rebalancer/strategy/types.ts b/typescript/cli/src/rebalancer/strategy/types.ts index 1ba8ac16e4f..9e081fef584 100644 --- a/typescript/cli/src/rebalancer/strategy/types.ts +++ b/typescript/cli/src/rebalancer/strategy/types.ts @@ -3,7 +3,7 @@ import { ChainName } from '@hyperlane-xyz/sdk'; /** * Per chain configuration for the strategy */ -export type Config = Record< +export type StrategyConfig = Record< ChainName, { /** From 8b4de31846c88447afcd018a05cbdb78e0e9135f Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 15:11:31 -0300 Subject: [PATCH 11/18] fix: Add to index --- typescript/cli/src/rebalancer/index.ts | 1 + 1 file changed, 1 insertion(+) 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'; From ba64cff4d08d21c62594b5a163fe7c2842ddc8d1 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 15:29:26 -0300 Subject: [PATCH 12/18] fix: Reset e2e script --- typescript/cli/scripts/run-e2e-test.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 From e0bdd74fc9853773ca8210505fcb8fc9bb54d5d7 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 15:51:40 -0300 Subject: [PATCH 13/18] fix: Fix e2e --- typescript/cli/src/commands/warp.ts | 26 +++++++--- typescript/cli/src/tests/commands/helpers.ts | 2 + typescript/cli/src/tests/commands/warp.ts | 10 +--- .../tests/warp/warp-rebalancer.e2e-test.ts | 50 ++++++++++++++++--- 4 files changed, 64 insertions(+), 24 deletions(-) diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts index 2a4ac77a117..27dbeb73dde 100644 --- a/typescript/cli/src/commands/warp.ts +++ b/typescript/cli/src/commands/warp.ts @@ -29,6 +29,7 @@ import { IStrategy, Monitor, Strategy, + StrategyConfig, } from '../rebalancer/index.js'; import { sendTestTransfer } from '../send/transfer.js'; import { runSingleChainSelectionStep } from '../utils/chains.js'; @@ -411,7 +412,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,19 +428,18 @@ 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'); @@ -450,8 +450,18 @@ export const rebalancer: CommandModuleWithContext<{ checkFrequency, ); + // Load the strategy config from disk + const strategyConfig = readYamlOrJson(strategyConfigFile); + + // Convert tolerance and weight from strings to BigInt. + // This is necessary because bigints are not serializable so they would have been stored as strings + Object.values(strategyConfig).forEach((chainConfig) => { + chainConfig.tolerance = BigInt(chainConfig.tolerance); + chainConfig.weight = BigInt(chainConfig.weight); + }); + // Instantiates the strategy that will get rebalancing routes based on monitor results - const strategy: IStrategy = new Strategy(BigInt(strategyTolerance)); + const strategy: IStrategy = new Strategy(strategyConfig); // Instantiates the executor that will process rebalancing routes const executor: IExecutor = new Executor(); 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..5d2ca4b81cc 100644 --- a/typescript/cli/src/tests/warp/warp-rebalancer.e2e-test.ts +++ b/typescript/cli/src/tests/warp/warp-rebalancer.e2e-test.ts @@ -1,4 +1,5 @@ import { Wallet } from 'ethers'; +import { rmSync } from 'fs'; import { ProcessPromise } from 'zx'; import { $ } from 'zx'; @@ -21,6 +22,7 @@ import { CHAIN_NAME_4, CORE_CONFIG_PATH, DEFAULT_E2E_TEST_TIMEOUT, + REBALANCER_STRATEGY_CONFIG_PATH, createSnapshot, deployOrUseExistingCore, deployToken, @@ -104,6 +106,11 @@ describe('hyperlane warp rebalancer e2e tests', async function () { }); beforeEach(async () => { + writeYamlOrJson(REBALANCER_STRATEGY_CONFIG_PATH, { + [CHAIN_NAME_2]: { weight: '100', tolerance: '0' }, + [CHAIN_NAME_3]: { weight: '100', tolerance: '0' }, + }); + process = undefined; const chain2Metadata: ChainMetadata = readYamlOrJson(CHAIN_2_METADATA_PATH); @@ -131,6 +138,8 @@ describe('hyperlane warp rebalancer e2e tests', async function () { }); afterEach(async () => { + rmSync(REBALANCER_STRATEGY_CONFIG_PATH); + if (process) { await process.kill(); } @@ -143,7 +152,11 @@ describe('hyperlane warp rebalancer e2e tests', async function () { }); it('should successfuly start the rebalancer', async () => { - process = hyperlaneWarpRebalancer(warpRouteId, CHECK_FREQUENCY); + process = hyperlaneWarpRebalancer( + warpRouteId, + CHECK_FREQUENCY, + REBALANCER_STRATEGY_CONFIG_PATH, + ); for await (const chunk of process.stdout) { if (chunk.includes('Rebalancer started successfully 🚀')) { @@ -154,7 +167,11 @@ describe('hyperlane warp rebalancer e2e tests', async function () { describe('with no balance on collateral contracts', () => { it('should report an empty array of routes being executed', async () => { - process = hyperlaneWarpRebalancer(warpRouteId, CHECK_FREQUENCY); + process = hyperlaneWarpRebalancer( + warpRouteId, + CHECK_FREQUENCY, + REBALANCER_STRATEGY_CONFIG_PATH, + ); for await (const chunk of process.stdout) { if (chunk.includes('Executing rebalancing routes: []')) { @@ -192,7 +209,11 @@ describe('hyperlane warp rebalancer e2e tests', async function () { }); it('should report an empty array of routes being executed', async () => { - process = hyperlaneWarpRebalancer(warpRouteId, CHECK_FREQUENCY); + process = hyperlaneWarpRebalancer( + warpRouteId, + CHECK_FREQUENCY, + REBALANCER_STRATEGY_CONFIG_PATH, + ); for await (const chunk of process.stdout) { if (chunk.includes('Executing rebalancing routes: []')) { @@ -230,7 +251,11 @@ describe('hyperlane warp rebalancer e2e tests', async function () { }); it('should report an array of routes being executed', async () => { - process = hyperlaneWarpRebalancer(warpRouteId, CHECK_FREQUENCY); + process = hyperlaneWarpRebalancer( + warpRouteId, + CHECK_FREQUENCY, + REBALANCER_STRATEGY_CONFIG_PATH, + ); for await (const chunk of process.stdout) { if ( @@ -249,11 +274,20 @@ describe('hyperlane warp rebalancer e2e tests', async function () { } }); - 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)), + describe('with chain tolerances set to 50%', () => { + beforeEach(async () => { + writeYamlOrJson(REBALANCER_STRATEGY_CONFIG_PATH, { + [CHAIN_NAME_2]: { weight: '100', tolerance: '50' }, + [CHAIN_NAME_3]: { weight: '100', tolerance: '50' }, }); + }); + + it('should report an empty array of routes being executed', async () => { + process = hyperlaneWarpRebalancer( + warpRouteId, + CHECK_FREQUENCY, + REBALANCER_STRATEGY_CONFIG_PATH, + ); for await (const chunk of process.stdout) { if (chunk.includes('Executing rebalancing routes: []')) { From 9665c7955329f401b95377f5235ddbe093879eb1 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 16:21:23 -0300 Subject: [PATCH 14/18] fix: Fix unit tests --- .../src/rebalancer/strategy/Strategy.test.ts | 271 ++++++++++++------ 1 file changed, 182 insertions(+), 89 deletions(-) diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.test.ts b/typescript/cli/src/rebalancer/strategy/Strategy.test.ts index 813b5117ad5..2c0271606cd 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.test.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.test.ts @@ -10,135 +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(), - }; + 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'); + }); - strategy = new Strategy({ - [chain1]: { weight: 100n, tolerance: 0n }, - [chain2]: { weight: 100n, tolerance: 0n }, - [chain3]: { weight: 100n, tolerance: 0n }, + 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'); + }); + + 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, 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('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 a single route for 100 from chain3 to chain1', () => { - 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(1); - expect(routes[0]).to.deep.equal({ - fromChain: 'chain3', - toChain: 'chain1', - amount: ethers.utils.parseEther('100').toBigInt(), - }); + 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 300 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('300').toBigInt(), }; - }); - it('should return two routes for 66 from chain3 to chain1 and 66 from chain3 to chain2', () => { - const routes = strategy.getRebalancingRoutes(balances); + const routes = strategy.getRebalancingRoutes(rawBalances); - 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 - }); + expect(routes).to.be.empty; }); - }); - describe('when balances for chain1, chain2, and chain3 are 100, 100, and 100 respectively', () => { - beforeEach(() => { - balances = { + 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('100').toBigInt(), - [chain3]: 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(), + }, + ]); }); - it('should return no routes', () => { - const routes = strategy.getRebalancingRoutes(balances); + 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 }, + }); + + const rawBalances = { + [chain1]: ethers.utils.parseEther('100').toBigInt(), + [chain2]: ethers.utils.parseEther('101').toBigInt(), + }; + + const routes = strategy.getRebalancingRoutes(rawBalances); expect(routes).to.be.empty; }); - }); - describe('when tolerance for each chain is 10%', () => { - beforeEach(() => { - strategy = new Strategy({ - [chain1]: { weight: 100n, tolerance: 10n }, - [chain2]: { weight: 100n, tolerance: 10n }, - [chain3]: { weight: 100n, tolerance: 10n }, + 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 }, }); + + 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.deep.equal([ + { + fromChain: chain3, + toChain: chain1, + amount: ethers.utils.parseEther('100').toBigInt(), + }, + ]); }); - describe('when balances for chain1, chain2, and chain3 are 90, 95, and 100 respectively', () => { - beforeEach(() => { - balances = { - [chain1]: ethers.utils.parseEther('90').toBigInt(), - [chain2]: ethers.utils.parseEther('95').toBigInt(), - [chain3]: ethers.utils.parseEther('100').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(), + }; - expect(routes).to.be.empty; - }); + const routes = strategy.getRebalancingRoutes(rawBalances); + + expect(routes).to.deep.equal([ + { + fromChain: chain3, + toChain: chain1, + amount: 133333333333333333333n, + }, + { + fromChain: chain3, + toChain: chain2, + amount: 133333333333333333333n, + }, + ]); }); - 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 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 }, }); - it('should return one route for 10 from chain3 to chain1', () => { - const routes = strategy.getRebalancingRoutes(balances); + const rawBalances = { + [chain1]: ethers.utils.parseEther('100').toBigInt(), + [chain2]: ethers.utils.parseEther('100').toBigInt(), + [chain3]: ethers.utils.parseEther('100').toBigInt(), + }; - expect(routes).to.have.lengthOf(1); - expect(routes[0]).to.deep.equal({ - fromChain: 'chain3', - toChain: 'chain1', - amount: ethers.utils.parseEther('10').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(), + }, + ]); }); }); }); From 2a125522c6b484907cb3f3f5dfaba10bf46fec44 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:16:09 -0300 Subject: [PATCH 15/18] fix: Update e2es --- typescript/cli/src/commands/warp.ts | 66 ++--- .../tests/warp/warp-rebalancer.e2e-test.ts | 235 +++++++++--------- 2 files changed, 145 insertions(+), 156 deletions(-) diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts index 27dbeb73dde..97762d0a8bd 100644 --- a/typescript/cli/src/commands/warp.ts +++ b/typescript/cli/src/commands/warp.ts @@ -20,7 +20,7 @@ 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 { log, logBlue, logCommandHeader, logGreen, logRed } from '../logger.js'; import { runWarpRouteRead } from '../read/warp.js'; import { Executor, @@ -441,47 +441,47 @@ export const rebalancer: CommandModuleWithContext<{ checkFrequency, 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, + ); - // Load the strategy config from disk - const strategyConfig = readYamlOrJson(strategyConfigFile); + const strategyConfig = readYamlOrJson(strategyConfigFile); - // Convert tolerance and weight from strings to BigInt. - // This is necessary because bigints are not serializable so they would have been stored as strings - Object.values(strategyConfig).forEach((chainConfig) => { - chainConfig.tolerance = BigInt(chainConfig.tolerance); - chainConfig.weight = BigInt(chainConfig.weight); - }); + Object.values(strategyConfig).forEach((chainConfig) => { + chainConfig.tolerance = BigInt(chainConfig.tolerance); + chainConfig.weight = BigInt(chainConfig.weight); + }); - // Instantiates the strategy that will get rebalancing routes based on monitor results - const strategy: IStrategy = new Strategy(strategyConfig); + // Instantiates the strategy that will get rebalancing routes based on monitor results + const strategy: IStrategy = new Strategy(strategyConfig); - // 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) { + logRed((e as Error).message); + process.exit(1); + } }, }; 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 5d2ca4b81cc..3c67f92d16a 100644 --- a/typescript/cli/src/tests/warp/warp-rebalancer.e2e-test.ts +++ b/typescript/cli/src/tests/warp/warp-rebalancer.e2e-test.ts @@ -1,3 +1,4 @@ +import { expect } from 'chai'; import { Wallet } from 'ethers'; import { rmSync } from 'fs'; import { ProcessPromise } from 'zx'; @@ -48,11 +49,14 @@ describe('hyperlane warp rebalancer e2e tests', async function () { let snapshots: { rpcUrl: string; snapshotId: string }[] = []; + let logEmitted: boolean; + 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), @@ -60,14 +64,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, @@ -102,10 +108,33 @@ 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 () => { + logEmitted = false; + writeYamlOrJson(REBALANCER_STRATEGY_CONFIG_PATH, { [CHAIN_NAME_2]: { weight: '100', tolerance: '0' }, [CHAIN_NAME_3]: { weight: '100', tolerance: '0' }, @@ -138,7 +167,11 @@ describe('hyperlane warp rebalancer e2e tests', async function () { }); afterEach(async () => { - rmSync(REBALANCER_STRATEGY_CONFIG_PATH); + try { + rmSync(REBALANCER_STRATEGY_CONFIG_PATH); + } catch (e) { + // Ignore + } if (process) { await process.kill(); @@ -149,8 +182,22 @@ describe('hyperlane warp rebalancer e2e tests', async function () { restoreSnapshot(rpcUrl, snapshotId), ), ); + + expect(logEmitted).to.equal( + true, + 'Test finished before the log was emitted', + ); }); + async function waitForLog(process: ProcessPromise, log: string) { + for await (const chunk of process.stdout) { + if (chunk.includes(log)) { + logEmitted = true; + break; + } + } + } + it('should successfuly start the rebalancer', async () => { process = hyperlaneWarpRebalancer( warpRouteId, @@ -158,143 +205,85 @@ describe('hyperlane warp rebalancer e2e tests', async function () { REBALANCER_STRATEGY_CONFIG_PATH, ); - for await (const chunk of process.stdout) { - if (chunk.includes('Rebalancer started successfully 🚀')) { - break; - } - } + await waitForLog(process, 'Rebalancer started successfully 🚀'); }); - describe('with no balance on collateral contracts', () => { - it('should report an empty array of routes being executed', async () => { - process = hyperlaneWarpRebalancer( - warpRouteId, - CHECK_FREQUENCY, - REBALANCER_STRATEGY_CONFIG_PATH, - ); - - for await (const chunk of process.stdout) { - if (chunk.includes('Executing rebalancing routes: []')) { - break; - } - } - }); - }); + it('should throw when strategy config file does not exist', async () => { + rmSync(REBALANCER_STRATEGY_CONFIG_PATH); - describe('with the same balance on all collateral contracts', () => { - beforeEach(async () => { - const ogVerbose = $.verbose; - $.verbose = false; + process = hyperlaneWarpRebalancer( + warpRouteId, + CHECK_FREQUENCY, + 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 waitForLog( + process, + `File doesn't existavdasdvasdv 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, - REBALANCER_STRATEGY_CONFIG_PATH, - ); - - for await (const chunk of process.stdout) { - if (chunk.includes('Executing rebalancing routes: []')) { - break; - } - } + process = hyperlaneWarpRebalancer( + warpRouteId, + CHECK_FREQUENCY, + REBALANCER_STRATEGY_CONFIG_PATH, + ); + + await waitForLog(process, `Cannot convert weight to a BigInt`); + + await process.kill(); + + writeYamlOrJson(REBALANCER_STRATEGY_CONFIG_PATH, { + [CHAIN_NAME_2]: { weight: '100', tolerance: '0' }, + [CHAIN_NAME_3]: { weight: '100', tolerance: 'tolerance' }, }); + + process = hyperlaneWarpRebalancer( + warpRouteId, + CHECK_FREQUENCY, + REBALANCER_STRATEGY_CONFIG_PATH, + ); + + await waitForLog(process, `Cannot convert tolerance to a BigInt`); }); - describe('with different balances on collateral contracts', () => { - beforeEach(async () => { - const ogVerbose = $.verbose; - $.verbose = false; + it('should log that no routes are to be executed', async () => { + process = hyperlaneWarpRebalancer( + warpRouteId, + CHECK_FREQUENCY, + REBALANCER_STRATEGY_CONFIG_PATH, + ); - 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), - ), - ), - ]); + await waitForLog(process, `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, - REBALANCER_STRATEGY_CONFIG_PATH, - ); - - for await (const chunk of process.stdout) { - if ( - chunk.includes( - `Executing rebalancing routes: [ + process = hyperlaneWarpRebalancer( + warpRouteId, + CHECK_FREQUENCY, + REBALANCER_STRATEGY_CONFIG_PATH, + ); + + await waitForLog( + process, + `Executing rebalancing routes: [ { fromChain: 'anvil3', toChain: 'anvil2', - amount: 10000000000000000000n + amount: 50000000000000000000n } ]`, - ) - ) { - break; - } - } - }); - - describe('with chain tolerances set to 50%', () => { - beforeEach(async () => { - writeYamlOrJson(REBALANCER_STRATEGY_CONFIG_PATH, { - [CHAIN_NAME_2]: { weight: '100', tolerance: '50' }, - [CHAIN_NAME_3]: { weight: '100', tolerance: '50' }, - }); - }); - - it('should report an empty array of routes being executed', async () => { - process = hyperlaneWarpRebalancer( - warpRouteId, - CHECK_FREQUENCY, - REBALANCER_STRATEGY_CONFIG_PATH, - ); - - for await (const chunk of process.stdout) { - if (chunk.includes('Executing rebalancing routes: []')) { - break; - } - } - }); - }); + ); }); }); From 78d48adf0850d9b9793ebd83b3c76adfd7f210ae Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Wed, 23 Apr 2025 10:21:45 -0300 Subject: [PATCH 16/18] fix: Use zod to validate config --- typescript/cli/src/commands/warp.ts | 13 ++----- .../cli/src/rebalancer/strategy/Strategy.ts | 35 ++++++++++++++++++- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts index 97762d0a8bd..569c4943a44 100644 --- a/typescript/cli/src/commands/warp.ts +++ b/typescript/cli/src/commands/warp.ts @@ -29,7 +29,6 @@ import { IStrategy, Monitor, Strategy, - StrategyConfig, } from '../rebalancer/index.js'; import { sendTestTransfer } from '../send/transfer.js'; import { runSingleChainSelectionStep } from '../utils/chains.js'; @@ -449,15 +448,8 @@ export const rebalancer: CommandModuleWithContext<{ checkFrequency, ); - const strategyConfig = readYamlOrJson(strategyConfigFile); - - Object.values(strategyConfig).forEach((chainConfig) => { - chainConfig.tolerance = BigInt(chainConfig.tolerance); - chainConfig.weight = BigInt(chainConfig.weight); - }); - // Instantiates the strategy that will get rebalancing routes based on monitor results - const strategy: IStrategy = new Strategy(strategyConfig); + const strategy: IStrategy = Strategy.fromConfigFile(strategyConfigFile); // Instantiates the executor that will process rebalancing routes const executor: IExecutor = new Executor(); @@ -480,7 +472,8 @@ export const rebalancer: CommandModuleWithContext<{ logGreen('Rebalancer started successfully 🚀'); } catch (e) { logRed((e as Error).message); - process.exit(1); + throw e; + // process.exit(1); } }, }; diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index 9efb89a9662..795e0095567 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -1,5 +1,9 @@ +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, @@ -13,6 +17,35 @@ export class Strategy implements IStrategy { private readonly config: StrategyConfig; private readonly totalWeight: bigint; + /** + * Create a new Strategy from a config file found at the given path. + */ + 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); @@ -43,7 +76,7 @@ export class Strategy implements IStrategy { } /** - * Get the optimized routes to rebalance the defined chains + * Get the optimized routes to rebalance the defined chains. */ getRebalancingRoutes(rawBalances: RawBalances): RebalancingRoute[] { this.validateRawBalances(rawBalances); From c35930c39508d1dffb86445a8768ce34a1279140 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Wed, 23 Apr 2025 11:50:27 -0300 Subject: [PATCH 17/18] fix: exit with 1 --- typescript/cli/src/commands/warp.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts index 569c4943a44..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, logRed } from '../logger.js'; +import { + errorRed, + log, + logBlue, + logCommandHeader, + logGreen, +} from '../logger.js'; import { runWarpRouteRead } from '../read/warp.js'; import { Executor, @@ -471,9 +477,8 @@ export const rebalancer: CommandModuleWithContext<{ logGreen('Rebalancer started successfully 🚀'); } catch (e) { - logRed((e as Error).message); - throw e; - // process.exit(1); + errorRed('Rebalancer could not be started:', (e as Error).message); + process.exit(1); } }, }; From 41e68454c9de62a71f3de1c57d15ef779fe6b3f0 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Wed, 23 Apr 2025 11:50:48 -0300 Subject: [PATCH 18/18] fix: Create startRebalancerAndExpectLog func --- .../tests/warp/warp-rebalancer.e2e-test.ts | 110 +++++------------- 1 file changed, 31 insertions(+), 79 deletions(-) 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 3c67f92d16a..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,7 +1,5 @@ -import { expect } from 'chai'; import { Wallet } from 'ethers'; import { rmSync } from 'fs'; -import { ProcessPromise } from 'zx'; import { $ } from 'zx'; import { createWarpRouteConfigId } from '@hyperlane-xyz/registry'; @@ -44,13 +42,8 @@ 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 }[] = []; - let logEmitted: boolean; - before(async () => { const ogVerbose = $.verbose; $.verbose = false; @@ -133,15 +126,11 @@ describe('hyperlane warp rebalancer e2e tests', async function () { }); beforeEach(async () => { - logEmitted = false; - writeYamlOrJson(REBALANCER_STRATEGY_CONFIG_PATH, { [CHAIN_NAME_2]: { weight: '100', tolerance: '0' }, [CHAIN_NAME_3]: { weight: '100', tolerance: '0' }, }); - process = undefined; - const chain2Metadata: ChainMetadata = readYamlOrJson(CHAIN_2_METADATA_PATH); const chain3Metadata: ChainMetadata = readYamlOrJson(CHAIN_3_METADATA_PATH); const chain4Metadata: ChainMetadata = readYamlOrJson(CHAIN_4_METADATA_PATH); @@ -173,94 +162,66 @@ describe('hyperlane warp rebalancer e2e tests', async function () { // Ignore } - if (process) { - await process.kill(); - } - await Promise.all( snapshots.map(({ rpcUrl, snapshotId }) => restoreSnapshot(rpcUrl, snapshotId), ), ); - - expect(logEmitted).to.equal( - true, - 'Test finished before the log was emitted', - ); }); - async function waitForLog(process: ProcessPromise, log: string) { - for await (const chunk of process.stdout) { - if (chunk.includes(log)) { - logEmitted = true; - break; - } - } - } - - it('should successfuly start the rebalancer', async () => { - process = hyperlaneWarpRebalancer( + async function startRebalancerAndExpectLog(log: string): Promise { + const process = hyperlaneWarpRebalancer( warpRouteId, CHECK_FREQUENCY, REBALANCER_STRATEGY_CONFIG_PATH, ); - await waitForLog(process, 'Rebalancer started successfully 🚀'); + 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(log)) { + resolve(); + await process.kill(); + break; + } + } + }); + } + + it('should successfuly start the rebalancer', async () => { + await startRebalancerAndExpectLog('Rebalancer started successfully 🚀'); }); it('should throw when strategy config file does not exist', async () => { rmSync(REBALANCER_STRATEGY_CONFIG_PATH); - process = hyperlaneWarpRebalancer( - warpRouteId, - CHECK_FREQUENCY, - REBALANCER_STRATEGY_CONFIG_PATH, - ); - - await waitForLog( - process, - `File doesn't existavdasdvasdv at ${REBALANCER_STRATEGY_CONFIG_PATH}`, + await startRebalancerAndExpectLog( + `File doesn't exist at ${REBALANCER_STRATEGY_CONFIG_PATH}`, ); }); 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' }, + [CHAIN_NAME_2]: { weight: 'weight', tolerance: 0 }, + [CHAIN_NAME_3]: { weight: 100, tolerance: 0 }, }); - process = hyperlaneWarpRebalancer( - warpRouteId, - CHECK_FREQUENCY, - REBALANCER_STRATEGY_CONFIG_PATH, - ); - - await waitForLog(process, `Cannot convert weight to a BigInt`); - - await process.kill(); + await startRebalancerAndExpectLog(`Cannot convert weight to a BigInt`); writeYamlOrJson(REBALANCER_STRATEGY_CONFIG_PATH, { - [CHAIN_NAME_2]: { weight: '100', tolerance: '0' }, - [CHAIN_NAME_3]: { weight: '100', tolerance: 'tolerance' }, + [CHAIN_NAME_2]: { weight: 100, tolerance: 0 }, + [CHAIN_NAME_3]: { weight: 100, tolerance: 'tolerance' }, }); - process = hyperlaneWarpRebalancer( - warpRouteId, - CHECK_FREQUENCY, - REBALANCER_STRATEGY_CONFIG_PATH, - ); - - await waitForLog(process, `Cannot convert tolerance to a BigInt`); + await startRebalancerAndExpectLog(`Cannot convert tolerance to a BigInt`); }); it('should log that no routes are to be executed', async () => { - process = hyperlaneWarpRebalancer( - warpRouteId, - CHECK_FREQUENCY, - REBALANCER_STRATEGY_CONFIG_PATH, - ); - - await waitForLog(process, `Executing rebalancing routes: []`); + await startRebalancerAndExpectLog(`Executing rebalancing routes: []`); }); it('should log that a single route is to be executed', async () => { @@ -269,21 +230,12 @@ describe('hyperlane warp rebalancer e2e tests', async function () { [CHAIN_NAME_3]: { weight: '25', tolerance: '0' }, }); - process = hyperlaneWarpRebalancer( - warpRouteId, - CHECK_FREQUENCY, - REBALANCER_STRATEGY_CONFIG_PATH, - ); - - await waitForLog( - process, - `Executing rebalancing routes: [ + await startRebalancerAndExpectLog(`Executing rebalancing routes: [ { fromChain: 'anvil3', toChain: 'anvil2', amount: 50000000000000000000n } -]`, - ); +]`); }); });