Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions typescript/cli/src/commands/warp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChainName, bigint>);

// 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 🚀');
Expand Down
8 changes: 3 additions & 5 deletions typescript/cli/src/rebalancer/executor/Executor.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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<void> {
console.log('Executing rebalancing routes:', routes);
}
}
10 changes: 2 additions & 8 deletions typescript/cli/src/rebalancer/interfaces/IExecutor.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
processRebalancingRoutes(routes: RebalancingRoute[]): Promise<void>;
}
48 changes: 6 additions & 42 deletions typescript/cli/src/rebalancer/interfaces/IStrategy.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,13 @@
import { ChainName } from '@hyperlane-xyz/sdk';

import { MonitorEvent } from './IMonitor.js';
export type RawBalances = Record<ChainName, bigint>;

/**
* 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<void>;
getRebalancingRoutes(rawBalances: RawBalances): RebalancingRoute[];
}
93 changes: 93 additions & 0 deletions typescript/cli/src/rebalancer/strategy/Strategy.test.ts
Original file line number Diff line number Diff line change
@@ -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<ChainName, bigint>;

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);
});
});
});
91 changes: 67 additions & 24 deletions typescript/cli/src/rebalancer/strategy/Strategy.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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;
}
}
22 changes: 7 additions & 15 deletions typescript/cli/src/tests/warp/warp-rebalancer.e2e-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down