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 🚀'); 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; } 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 new file mode 100644 index 00000000000..c3fc8eb3cf7 --- /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 { 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(), + }); + }); + }); + + 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 + }); + expect(routes[1]).to.deep.equal({ + fromChain: 'chain3', + toChain: 'chain2', + amount: 66666666666666666666n, // 66 + }); + }); + }); + + 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..fa6303a55ae 100644 --- a/typescript/cli/src/rebalancer/strategy/Strategy.ts +++ b/typescript/cli/src/rebalancer/strategy/Strategy.ts @@ -1,31 +1,74 @@ -import EventEmitter from 'events'; +import { ChainName } from '@hyperlane-xyz/sdk'; -import { MonitorEvent } from '../interfaces/IMonitor.js'; -import { IStrategy, StrategyEvent } from '../interfaces/IStrategy.js'; +import { + IStrategy, + RawBalances, + RebalancingRoute, +} from '../interfaces/IStrategy.js'; -/** - * 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(); + /** + * Get the optimized routes that will rebalance all chains to the same balance + */ + 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 + const target = total / BigInt(entries.length); + const surpluss: { chain: ChainName; amount: bigint }[] = []; + const deficits: { chain: ChainName; amount: bigint }[] = []; - subscribe(fn: (event: StrategyEvent) => void): void { - this.emitter.on(this.STRATEGY_EVENT, fn); - } + // Group balances by balances with surplus or deficit + 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: 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, + }); + + 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; } } 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;