From 9e473f81014e60d51184e8777c5d3501b08530ed Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Mon, 21 Apr 2025 10:28:06 -0300 Subject: [PATCH 1/6] feat: Simple strat + tests --- .../src/rebalancer/strategy/Strategy.test.ts | 93 +++++++++++++++++++ .../cli/src/rebalancer/strategy/Strategy.ts | 89 +++++++++++++----- 2 files changed, 157 insertions(+), 25 deletions(-) create mode 100644 typescript/cli/src/rebalancer/strategy/Strategy.test.ts diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.test.ts b/typescript/cli/src/rebalancer/strategy/Strategy.test.ts new file mode 100644 index 00000000000..713633110d0 --- /dev/null +++ b/typescript/cli/src/rebalancer/strategy/Strategy.test.ts @@ -0,0 +1,93 @@ +import { expect } from 'chai'; +import { ethers } from 'ethers'; + +import { ChainName } from '@hyperlane-xyz/sdk'; + +import { Route, Strategy } from './Strategy.js'; + +describe('Strategy', () => { + let chain1: ChainName; + 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(), + }; + }); + + it('should return a single route for 100 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('100').toBigInt(), + } as Route); + }); + }); + + 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(), + }; + }); + + it('should return two routes for 66 from chain3 to chain1 and 66 from chain3 to chain2', () => { + const routes = strategy.getRebalancingRoutes(balances); + + expect(routes).to.have.lengthOf(2); + expect(routes[0]).to.deep.equal({ + fromChain: 'chain3', + toChain: 'chain1', + amount: 66666666666666666666n, // 66 + } as Route); + expect(routes[1]).to.deep.equal({ + fromChain: 'chain3', + toChain: 'chain2', + amount: 66666666666666666666n, // 66 + } as Route); + }); + }); + + describe('when balances for chain1, chain2, and chain3 are 100, 100, and 100 respectively', () => { + beforeEach(() => { + balances = { + [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); + + expect(routes).to.have.lengthOf(0); + }); + }); +}); diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index 41bceece4c9..5b172ba283f 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -1,31 +1,70 @@ -import EventEmitter from 'events'; +import { ChainName } from '@hyperlane-xyz/sdk'; -import { MonitorEvent } from '../interfaces/IMonitor.js'; -import { IStrategy, StrategyEvent } from '../interfaces/IStrategy.js'; +export type Route = { + fromChain: ChainName; + toChain: ChainName; + amount: bigint; +}; -/** - * Simple strategy implementation that processes token balances accross chains and emits a StrategyEvent - * containing if and how rebalancing is has to be applied. - */ -export class Strategy implements IStrategy { - private readonly STRATEGY_EVENT = 'strategy'; - private readonly emitter = new EventEmitter(); +export class Strategy { + getRebalancingRoutes(balances: Record): Route[] { + const entries = Object.entries(balances); + const total = entries.reduce((sum, [, balance]) => sum + balance, 0n); + const target = total / BigInt(entries.length); - subscribe(fn: (event: StrategyEvent) => void): void { - this.emitter.on(this.STRATEGY_EVENT, fn); - } + const surpluss: { chain: ChainName; amount: bigint }[] = []; + const deficits: { chain: ChainName; amount: bigint }[] = []; + + for (const [chain, balance] of entries) { + if (balance < target) { + deficits.push({ chain, amount: target - balance }); + } else if (balance > target) { + surpluss.push({ chain, amount: balance - target }); + } else { + // Do nothing as the balance is already on target + } + } + + const routes: Route[] = []; + + 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, + }); + + deficits.shift(); + + surplus.amount -= deficit.amount; + } else if (surplus.amount < deficit.amount) { + routes.push({ + fromChain, + toChain, + amount: surplus.amount, + }); + + surpluss.shift(); + + deficit.amount -= surplus.amount; + } else { + routes.push({ + fromChain, + toChain, + amount: surplus.amount, + }); + + deficits.shift(); + surpluss.shift(); + } + } - async handleMonitorEvent(event: MonitorEvent): Promise { - // TODO: Implement actual strategy logic - // Current implementation is a placeholder used to test something in typescript/cli/src/tests/warp/warp-rebalancer.e2e-test.ts - const strategyEvent: StrategyEvent = { - route: event.balances.map((b) => ({ - origin: b.chain, - destination: b.chain, - token: b.token, - amount: b.value, - })), - }; - this.emitter.emit(this.STRATEGY_EVENT, strategyEvent); + return routes; } } From a97d496722822c6fdc45f29bbec6dae9ef2ef516 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Mon, 21 Apr 2025 10:42:11 -0300 Subject: [PATCH 2/6] feat: Add comments --- typescript/cli/src/rebalancer/strategy/Strategy.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index 5b172ba283f..7fad68ecd4c 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -7,14 +7,19 @@ export type Route = { }; export class Strategy { + /** + * Get the optimized routes that will rebalance all chains to the same balance + */ getRebalancingRoutes(balances: Record): Route[] { const entries = Object.entries(balances); + // 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 surpluss: { chain: ChainName; amount: bigint }[] = []; const deficits: { chain: ChainName; amount: bigint }[] = []; + // Group balances by balances with surplus or deficit for (const [chain, balance] of entries) { if (balance < target) { deficits.push({ chain, amount: target - balance }); @@ -27,6 +32,7 @@ export class Strategy { const routes: Route[] = []; + // Keep iterating until all routes have been found while (surpluss.length > 0 && deficits.length > 0) { const surplus = surpluss[0]; const deficit = deficits[0]; @@ -41,7 +47,6 @@ export class Strategy { }); deficits.shift(); - surplus.amount -= deficit.amount; } else if (surplus.amount < deficit.amount) { routes.push({ @@ -51,7 +56,6 @@ export class Strategy { }); surpluss.shift(); - deficit.amount -= surplus.amount; } else { routes.push({ From 29d725cc12c3210504276c72e00679098284e62d Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Mon, 21 Apr 2025 10:56:36 -0300 Subject: [PATCH 3/6] fix: Update executor --- typescript/cli/src/rebalancer/executor/Executor.ts | 8 +++----- typescript/cli/src/rebalancer/interfaces/IExecutor.ts | 10 ++-------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/typescript/cli/src/rebalancer/executor/Executor.ts b/typescript/cli/src/rebalancer/executor/Executor.ts index e221b7da177..2e9778f07fb 100644 --- a/typescript/cli/src/rebalancer/executor/Executor.ts +++ b/typescript/cli/src/rebalancer/executor/Executor.ts @@ -1,10 +1,8 @@ import { IExecutor } from '../interfaces/IExecutor.js'; -import { StrategyEvent } from '../interfaces/IStrategy.js'; +import { RebalancingRoute } from '../interfaces/IStrategy.js'; export class Executor implements IExecutor { - async handleStrategyEvent(_event: StrategyEvent): Promise { - // TODO: Replace with actual executor logic - // Current implementation is a placeholder used to test something in typescript/cli/src/tests/warp/warp-rebalancer.e2e-test.ts - console.log('Executing strategy event:', _event); + async processRebalancingRoutes(routes: RebalancingRoute[]): Promise { + console.log('Executing rebalancing routes:', routes); } } diff --git a/typescript/cli/src/rebalancer/interfaces/IExecutor.ts b/typescript/cli/src/rebalancer/interfaces/IExecutor.ts index 59c68339904..9da1b0fc0aa 100644 --- a/typescript/cli/src/rebalancer/interfaces/IExecutor.ts +++ b/typescript/cli/src/rebalancer/interfaces/IExecutor.ts @@ -1,11 +1,5 @@ -import { StrategyEvent } from './IStrategy.js'; +import { RebalancingRoute } from './IStrategy.js'; -/** - * Interface for the class that will execute rebalancing transactions on-chain. - */ export interface IExecutor { - /** - * Executes rebalancing based on the data provided by the strategy. - */ - handleStrategyEvent(event: StrategyEvent): Promise; + processRebalancingRoutes(routes: RebalancingRoute[]): Promise; } From 49e1c45e19bc2fac02e7a6cb0db46547ba603b76 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Mon, 21 Apr 2025 10:56:53 -0300 Subject: [PATCH 4/6] fix: Update strategy interface --- .../src/rebalancer/interfaces/IStrategy.ts | 48 +++---------------- .../src/rebalancer/strategy/Strategy.test.ts | 8 ++-- .../cli/src/rebalancer/strategy/Strategy.ts | 18 +++---- 3 files changed, 19 insertions(+), 55 deletions(-) diff --git a/typescript/cli/src/rebalancer/interfaces/IStrategy.ts b/typescript/cli/src/rebalancer/interfaces/IStrategy.ts index 7d3ae8c311e..7e8e611d858 100644 --- a/typescript/cli/src/rebalancer/interfaces/IStrategy.ts +++ b/typescript/cli/src/rebalancer/interfaces/IStrategy.ts @@ -1,49 +1,13 @@ import { ChainName } from '@hyperlane-xyz/sdk'; -import { MonitorEvent } from './IMonitor.js'; +export type RawBalances = Record; -/** - * Represents an event emitted by the strategy containing routing information - * for token rebalancing across different chains. - */ -export type StrategyEvent = { - /** - * Array of objects containing routing information for token transfers. - * It is an array given that rebalancing might require multiple asset movements. - */ - route: { - /** - * The source chain where tokens will be transferred from. - */ - origin: ChainName; - /** - * The target chain where tokens will be transferred to. - */ - destination: ChainName; - /** - * The address of the token to be transferred. - */ - token: string; - /** - * The amount of tokens to be transferred. - */ - amount: bigint; - }[]; +export type RebalancingRoute = { + fromChain: ChainName; + toChain: ChainName; + amount: bigint; }; -/** - * Interface for a strategy service that determines optimal token routing - * based on monitored balance information. - */ export interface IStrategy { - /** - * Allows subscribers to listen to rebalancing requirements whenever they are emitted. - */ - subscribe(fn: (event: StrategyEvent) => void): void; - - /** - * Processes balance information from the monitor and determines if rebalancing is needed. - * Should emit a StrategyEvent containing the rebalancing requirements. - */ - handleMonitorEvent(event: MonitorEvent): Promise; + getRebalancingRoutes(rawBalances: RawBalances): RebalancingRoute[]; } diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.test.ts b/typescript/cli/src/rebalancer/strategy/Strategy.test.ts index 713633110d0..c3fc8eb3cf7 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.test.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.test.ts @@ -3,7 +3,7 @@ import { ethers } from 'ethers'; import { ChainName } from '@hyperlane-xyz/sdk'; -import { Route, Strategy } from './Strategy.js'; +import { Strategy } from './Strategy.js'; describe('Strategy', () => { let chain1: ChainName; @@ -45,7 +45,7 @@ describe('Strategy', () => { fromChain: 'chain3', toChain: 'chain1', amount: ethers.utils.parseEther('100').toBigInt(), - } as Route); + }); }); }); @@ -66,12 +66,12 @@ describe('Strategy', () => { fromChain: 'chain3', toChain: 'chain1', amount: 66666666666666666666n, // 66 - } as Route); + }); expect(routes[1]).to.deep.equal({ fromChain: 'chain3', toChain: 'chain2', amount: 66666666666666666666n, // 66 - } as Route); + }); }); }); diff --git a/typescript/cli/src/rebalancer/strategy/Strategy.ts b/typescript/cli/src/rebalancer/strategy/Strategy.ts index 7fad68ecd4c..fa6303a55ae 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -1,17 +1,17 @@ import { ChainName } from '@hyperlane-xyz/sdk'; -export type Route = { - fromChain: ChainName; - toChain: ChainName; - amount: bigint; -}; +import { + IStrategy, + RawBalances, + RebalancingRoute, +} from '../interfaces/IStrategy.js'; -export class Strategy { +export class Strategy implements IStrategy { /** * Get the optimized routes that will rebalance all chains to the same balance */ - getRebalancingRoutes(balances: Record): Route[] { - const entries = Object.entries(balances); + getRebalancingRoutes(rawBalances: RawBalances): RebalancingRoute[] { + 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 @@ -30,7 +30,7 @@ export class Strategy { } } - const routes: Route[] = []; + const routes: RebalancingRoute[] = []; // Keep iterating until all routes have been found while (surpluss.length > 0 && deficits.length > 0) { From 960210c83d13b32b8425ba36324a64f36d9710b3 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Mon, 21 Apr 2025 10:57:27 -0300 Subject: [PATCH 5/6] fix: Update strategy and executor usage in command --- typescript/cli/src/commands/warp.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/typescript/cli/src/commands/warp.ts b/typescript/cli/src/commands/warp.ts index c14a3fc64e3..c2b15dc1545 100644 --- a/typescript/cli/src/commands/warp.ts +++ b/typescript/cli/src/commands/warp.ts @@ -437,20 +437,25 @@ export const rebalancer: CommandModuleWithContext<{ checkFrequency, ); - // Instantiates the strategy that will process monitor events and determine whether a rebalance is needed + // Instantiates the strategy that will get rebalancing routes based on monitor results const strategy: IStrategy = new Strategy(); - // Instantiates the executor that will process strategy results and execute the rebalance + // Instantiates the executor that will process rebalancing routes const executor: IExecutor = new Executor(); - // Subscribes the strategy to the monitor - monitor.subscribe((event) => strategy.handleMonitorEvent(event)); + // 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); - // Subscribes the executor to the strategy - strategy.subscribe((event) => executor.handleStrategyEvent(event)); + const rebalancingRoutes = strategy.getRebalancingRoutes(balances); + + executor.processRebalancingRoutes(rebalancingRoutes); + }); // Starts the monitor to begin polling balances. - // This will keep running until the process is terminated await monitor.start(); logGreen('Rebalancer started successfully 🚀'); From 5dbed774627e25f814f9d7066d7159b2cadf4ed6 Mon Sep 17 00:00:00 2001 From: Fernando Zavalia <24811313+fzavalia@users.noreply.github.com> Date: Mon, 21 Apr 2025 10:57:40 -0300 Subject: [PATCH 6/6] fix: Update e2e --- .../tests/warp/warp-rebalancer.e2e-test.ts | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 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 ae26adfea4f..10f673103e8 100644 --- a/typescript/cli/src/tests/warp/warp-rebalancer.e2e-test.ts +++ b/typescript/cli/src/tests/warp/warp-rebalancer.e2e-test.ts @@ -105,21 +105,13 @@ describe('hyperlane warp rebalancer e2e tests', async function () { // Verify that it logs an expected output for await (const chunk of process.stdout) { if ( - chunk.includes(`Executing strategy event: { - route: [ - { - origin: 'anvil2', - destination: 'anvil2', - token: '0x59b670e9fA9D0A427751Af201D676719a970857b', - amount: 49000000000000000000n - }, - { - origin: 'anvil3', - destination: 'anvil3', - token: '0x59b670e9fA9D0A427751Af201D676719a970857b', - amount: 51000000000000000000n - } - ]`) + chunk.includes(`Executing rebalancing routes: [ + { + fromChain: 'anvil3', + toChain: 'anvil2', + amount: 1000000000000000000n + } +]`) ) { process.kill(); break;