diff --git a/.changeset/action-tracker-implementation.md b/.changeset/action-tracker-implementation.md
new file mode 100644
index 00000000000..f1ef3bfe451
--- /dev/null
+++ b/.changeset/action-tracker-implementation.md
@@ -0,0 +1,5 @@
+---
+'@hyperlane-xyz/rebalancer': minor
+---
+
+Implemented ActionTracker for inflight-message-aware rebalancing. The ActionTracker tracked three entity types: user warp transfers (Transfer), rebalance intents (RebalanceIntent), and rebalance actions (RebalanceAction). It provided startup recovery by querying the Explorer for inflight messages, periodic sync operations to check message delivery status on-chain, and a complete API for creating and managing rebalance intents and actions. The implementation included a generic store interface (IStore) with an InMemoryStore implementation, comprehensive unit tests, and integration with ExplorerClient for querying inflight messages.
diff --git a/.changeset/collateral-deficit-strategy.md b/.changeset/collateral-deficit-strategy.md
new file mode 100644
index 00000000000..33d0f8bb6d7
--- /dev/null
+++ b/.changeset/collateral-deficit-strategy.md
@@ -0,0 +1,5 @@
+---
+'@hyperlane-xyz/rebalancer': minor
+---
+
+Added CollateralDeficitStrategy for just-in-time rebalancing. This strategy detected collateral deficits (negative effective balances from pending user transfers) and proposed fast rebalances using configured bridges. Modified reserveCollateral() to allow negative values for deficit detection.
diff --git a/.changeset/composite-strategy.md b/.changeset/composite-strategy.md
new file mode 100644
index 00000000000..55c0fbb909b
--- /dev/null
+++ b/.changeset/composite-strategy.md
@@ -0,0 +1,5 @@
+---
+'@hyperlane-xyz/rebalancer': minor
+---
+
+Added CompositeStrategy for chaining multiple rebalancing strategies. Routes from earlier strategies were passed as pending rebalances to later strategies for coordination. Strategy config used array format - single strategy is an array with 1 element. Also unified schema types by making bridgeLockTime optional and added name property to IStrategy interface for better logging.
diff --git a/.changeset/inflight-aware-base-strategy.md b/.changeset/inflight-aware-base-strategy.md
new file mode 100644
index 00000000000..7fc8b79f959
--- /dev/null
+++ b/.changeset/inflight-aware-base-strategy.md
@@ -0,0 +1,5 @@
+---
+'@hyperlane-xyz/rebalancer': minor
+---
+
+BaseStrategy is extended with inflight-aware rebalancing capabilities and bridge configuration support. RebalancingRoute extended with optional bridge field for bridge selection. Added three protected methods: reserveCollateral() to prevent draining collateral needed for incoming transfers, simulatePendingRebalances() for optional balance simulation, and filterRebalances() to filter routes based on actual balance sufficiency. The getRebalancingRoutes() method updated to accept optional inflightContext and integrate the new methods. getCategorizedBalances() signature updated to accept optional pendingRebalances parameter. BaseStrategy, WeightedStrategy, and MinAmountStrategy constructors extended with optional bridges parameter (ChainMap
) to store configured bridge addresses per chain.
diff --git a/.changeset/inflight-aware-rebalancing-major.md b/.changeset/inflight-aware-rebalancing-major.md
new file mode 100644
index 00000000000..b56b341a80d
--- /dev/null
+++ b/.changeset/inflight-aware-rebalancing-major.md
@@ -0,0 +1,25 @@
+---
+'@hyperlane-xyz/rebalancer': major
+---
+
+Inflight-aware rebalancing system with ActionTracker, new strategies, and type safety improvements.
+
+Breaking changes:
+- IRebalancer.rebalance() returned RebalanceExecutionResult[] instead of void
+- IStrategy.getRebalancingRoutes() accepted optional inflightContext parameter
+- IStrategy required a name property
+- RebalancingRoute renamed to StrategyRoute with required bridge field
+- MonitorEvent included confirmedBlockTags for confirmed block queries
+
+New features:
+- ActionTracker for tracking pending transfers and rebalance actions with Explorer integration
+- CollateralDeficitStrategy for just-in-time rebalancing based on pending inbound transfers
+- CompositeStrategy for chaining multiple strategies with coordination
+- BaseStrategy inflight-aware methods: reserveCollateral(), getAvailableBalance()
+- Query balances at confirmed blocks to sync with Explorer indexing
+- Strategy config supports array format for composing multiple strategies (backwards compatible)
+
+Bug fixes:
+- Record failure metrics when rebalance results contain failures
+- Treat missing Dispatch event as rebalance failure
+- Fix CompositeStrategy oscillation by separating proposed from pending rebalances
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 02ca810c6aa..d64357cab71 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -135,6 +135,9 @@ catalogs:
'@types/sinon-chai':
specifier: ^3.2.12
version: 3.2.12
+ '@types/uuid':
+ specifier: ^10.0.0
+ version: 10.0.0
'@types/ws':
specifier: ^8.5.5
version: 8.18.1
@@ -255,6 +258,9 @@ catalogs:
typescript-eslint:
specifier: ^8.37.0
version: 8.47.0
+ uuid:
+ specifier: ^10.0.0
+ version: 10.0.0
viem:
specifier: ^2.21.45
version: 2.39.3
@@ -1960,6 +1966,9 @@ importers:
prom-client:
specifier: 'catalog:'
version: 14.2.0
+ uuid:
+ specifier: 'catalog:'
+ version: 10.0.0
yaml:
specifier: 'catalog:'
version: 2.4.5
@@ -1991,6 +2000,9 @@ importers:
'@types/sinon':
specifier: 'catalog:'
version: 17.0.4
+ '@types/uuid':
+ specifier: 'catalog:'
+ version: 10.0.0
'@vercel/ncc':
specifier: 'catalog:'
version: 0.38.4
@@ -8525,6 +8537,9 @@ packages:
'@types/unzipper@0.10.11':
resolution: {integrity: sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==}
+ '@types/uuid@10.0.0':
+ resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
+
'@types/uuid@8.3.4':
resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==}
@@ -26336,6 +26351,8 @@ snapshots:
dependencies:
'@types/node': 24.10.9
+ '@types/uuid@10.0.0': {}
+
'@types/uuid@8.3.4': {}
'@types/uuid@9.0.8': {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 35ca47b9530..803b7624d58 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -33,6 +33,7 @@ catalog:
borsh: ^0.7.0
dotenv: ^10.0.0
asn1.js: ^5.4.1
+ uuid: ^10.0.0
# Starknet
starknet: ^7.4.0
@@ -130,6 +131,7 @@ catalog:
'@types/lodash-es': ^4.17.12
'@types/yargs': ^17.0.24
'@types/ws': ^8.5.5
+ '@types/uuid': ^10.0.0
# Hardhat
hardhat: ^2.22.2
diff --git a/typescript/cli/src/context/strategies/chain/chainResolver.ts b/typescript/cli/src/context/strategies/chain/chainResolver.ts
index a6d9ec28d43..0969fac6df4 100644
--- a/typescript/cli/src/context/strategies/chain/chainResolver.ts
+++ b/typescript/cli/src/context/strategies/chain/chainResolver.ts
@@ -1,4 +1,7 @@
-import { RebalancerConfig } from '@hyperlane-xyz/rebalancer';
+import {
+ RebalancerConfig,
+ getStrategyChainNames,
+} from '@hyperlane-xyz/rebalancer';
import {
type ChainName,
type DeployedCoreAddresses,
@@ -138,9 +141,9 @@ async function resolveWarpRebalancerChains(
// Load rebalancer config to get the configured chains
const rebalancerConfig = RebalancerConfig.load(argv.config);
- // Extract chain names from the rebalancer config's strategy.chains
+ // Extract chain names from all strategies in the rebalancer config
// This ensures we only create signers for chains we can actually rebalance
- const chains = Object.keys(rebalancerConfig.strategyConfig.chains);
+ const chains = getStrategyChainNames(rebalancerConfig.strategyConfig);
assert(chains.length !== 0, 'No chains configured in rebalancer config');
diff --git a/typescript/cli/src/tests/ethereum/warp/warp-rebalancer.e2e-test.ts b/typescript/cli/src/tests/ethereum/warp/warp-rebalancer.e2e-test.ts
index 1cd2b0cd6ed..1b2c14bb6be 100644
--- a/typescript/cli/src/tests/ethereum/warp/warp-rebalancer.e2e-test.ts
+++ b/typescript/cli/src/tests/ethereum/warp/warp-rebalancer.e2e-test.ts
@@ -11,7 +11,6 @@ import {
MockValueTransferBridge__factory,
} from '@hyperlane-xyz/core';
import {
- type RebalancerConfigFileInput,
RebalancerMinAmountType,
RebalancerStrategyOptions,
} from '@hyperlane-xyz/rebalancer';
@@ -38,7 +37,6 @@ import {
createSnapshot,
deployToken,
getTokenAddressFromWarpConfig,
- hyperlaneRelayer,
restoreSnapshot,
} from '../commands/helpers.js';
import {
@@ -73,8 +71,6 @@ describe('hyperlane warp rebalancer e2e tests', async function () {
// For these tests we mostly care about the first run
const CHECK_FREQUENCY = 60000;
- const DEFAULT_METRICS_SERVER = 'http://localhost:9090/metrics';
-
let tokenSymbol: string;
let warpRouteId: string;
let snapshots: { rpcUrl: string; snapshotId: string }[] = [];
@@ -286,39 +282,6 @@ describe('hyperlane warp rebalancer e2e tests', async function () {
);
}
- /**
- * Creates a mock GraphQL server for Explorer API
- * @param responseData The data to return in the GraphQL response
- * @returns Promise with server instance and URL
- */
- async function createMockExplorerServer(responseData: any): Promise<{
- server: any;
- url: string;
- close: () => Promise;
- }> {
- const http = await import('http');
- const server = http.createServer((req, res) => {
- if (req.method === 'POST') {
- res.writeHead(200, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify(responseData));
- } else {
- res.statusCode = 404;
- res.end();
- }
- });
-
- await new Promise((resolve) => server.listen(0, resolve));
- const address: any = server.address();
- const url = `http://127.0.0.1:${address.port}`;
-
- return {
- server,
- url,
- close: () =>
- new Promise((resolve) => server.close(() => resolve())),
- };
- }
-
async function startRebalancerAndExpectLog(
log: string | string[],
options: {
@@ -443,117 +406,6 @@ describe('hyperlane warp rebalancer e2e tests', async function () {
});
}
- // TODO: add when we resolve issues with the inflight guard
- // it('should successfully start the rebalancer', async () => {
- // await startRebalancerAndExpectLog('Rebalancer started successfully 🚀');
- // });
-
- // it('should skip when inflight detected by explorer', async () => {
- // // Create mock server that returns inflight messages
- // const mockServer = await createMockExplorerServer({
- // data: { message_view: [{ msg_id: '1' }] },
- // });
-
- // try {
- // // Ensure there is a potential route by creating an imbalance
- // const config: RebalancerConfigFileInput = {
- // warpRouteId,
- // strategy: {
- // rebalanceStrategy: RebalancerStrategyOptions.Weighted,
- // chains: {
- // [CHAIN_NAME_2]: {
- // weighted: { weight: '25', tolerance: '0' },
- // bridge: ethers.constants.AddressZero,
- // bridgeLockTime: 1,
- // },
- // [CHAIN_NAME_3]: {
- // weighted: { weight: '75', tolerance: '0' },
- // bridge: ethers.constants.AddressZero,
- // bridgeLockTime: 1,
- // },
- // },
- // },
- // };
-
- // writeYamlOrJson(REBALANCER_CONFIG_PATH, config);
-
- // await startRebalancerAndExpectLog(
- // 'Inflight rebalance detected via Explorer; skipping this cycle',
- // { explorerUrl: mockServer.url, checkFrequency: 2000 },
- // );
- // } finally {
- // await mockServer.close();
- // }
- // });
-
- it('should proceed when no inflight detected by explorer', async () => {
- // Create mock server that returns no inflight messages
- const mockServer = await createMockExplorerServer({
- data: { message_view: [] },
- });
-
- try {
- // Deploy and allow a bridge so the route can succeed
- const chain3Provider = new ethers.providers.JsonRpcProvider(
- chain3Metadata.rpcUrls[0].http,
- );
- const chain3Signer = new Wallet(ANVIL_KEY, chain3Provider);
- const chain3CollateralContract = HypERC20Collateral__factory.connect(
- getTokenAddressFromWarpConfig(warpCoreConfig, CHAIN_NAME_3),
- chain3Signer,
- );
-
- const bridgeContract = await new MockValueTransferBridge__factory(
- chain3Signer,
- ).deploy(chain3CollateralContract.address);
-
- await chain3CollateralContract.addBridge(
- chain2Metadata.domainId,
- bridgeContract.address,
- );
-
- // Configure imbalance and set bridge on origin chain
- const config: RebalancerConfigFileInput = {
- warpRouteId,
- strategy: {
- rebalanceStrategy: RebalancerStrategyOptions.Weighted,
- chains: {
- [CHAIN_NAME_2]: {
- weighted: { weight: '75', tolerance: '0' },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
- [CHAIN_NAME_3]: {
- weighted: { weight: '25', tolerance: '0' },
- bridge: bridgeContract.address,
- bridgeLockTime: 1,
- },
- },
- },
- };
-
- writeYamlOrJson(REBALANCER_CONFIG_PATH, config);
-
- await startRebalancerAndExpectLog(
- [
- 'Found rebalancing routes',
- 'Preparing all rebalance transactions.',
- 'Preparing transaction for route',
- 'Sending valid transactions.',
- 'Sending transaction for route',
- 'Transaction confirmed for route.',
- '✅ Rebalance successful',
- ],
- {
- explorerUrl: mockServer.url,
- timeout: 30000,
- },
- );
- } finally {
- await mockServer.close();
- }
- });
-
it('should throw when strategy config file does not exist', async () => {
rmSync(REBALANCER_CONFIG_PATH);
@@ -591,7 +443,7 @@ describe('hyperlane warp rebalancer e2e tests', async function () {
});
await startRebalancerAndExpectLog(
- `Error: Validation error: All chains must use the same minAmount type. at "strategy.chains"`,
+ `Error: Validation error: All chains must use the same minAmount type. at "strategy[0].chains"`,
);
});
@@ -689,9 +541,7 @@ describe('hyperlane warp rebalancer e2e tests', async function () {
});
it('should log that no routes are to be executed', async () => {
- await startRebalancerAndExpectLog(
- `No routes to execute. Assuming rebalance is complete. Resetting semaphore timer.`,
- );
+ await startRebalancerAndExpectLog(`No rebalancing needed`);
});
it('should not rebalance if mode is monitorOnly', async () => {
@@ -1012,56 +862,6 @@ describe('hyperlane warp rebalancer e2e tests', async function () {
);
});
- it('should successfully send rebalance transaction', async () => {
- // Assign rebalancer role
- const chain3Provider = new ethers.providers.JsonRpcProvider(
- chain3Metadata.rpcUrls[0].http,
- );
- const chain3Signer = new Wallet(ANVIL_KEY, chain3Provider);
- const chain3CollateralContract = HypERC20Collateral__factory.connect(
- getTokenAddressFromWarpConfig(warpCoreConfig, CHAIN_NAME_3),
- chain3Signer,
- );
-
- // Deploy the bridge
- const bridgeContract = await new MockValueTransferBridge__factory(
- chain3Signer,
- ).deploy(tokenChain3.address);
-
- // Allow bridge
- await chain3CollateralContract.addBridge(
- chain2Metadata.domainId,
- bridgeContract.address,
- );
-
- writeYamlOrJson(REBALANCER_CONFIG_PATH, {
- warpRouteId,
- strategy: {
- rebalanceStrategy: RebalancerStrategyOptions.Weighted,
- chains: {
- [CHAIN_NAME_2]: {
- weighted: {
- weight: '75',
- tolerance: '0',
- },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
- [CHAIN_NAME_3]: {
- weighted: {
- weight: '25',
- tolerance: '0',
- },
- bridge: bridgeContract.address,
- bridgeLockTime: 1,
- },
- },
- },
- });
-
- await startRebalancerAndExpectLog('✅ Rebalance successful');
- });
-
it('should skip rebalance if amount is below minimum threshold', async () => {
// Assign rebalancer role
const chain3Provider = new ethers.providers.JsonRpcProvider(
@@ -1111,7 +911,7 @@ describe('hyperlane warp rebalancer e2e tests', async function () {
});
await startRebalancerAndExpectLog(
- 'Route skipped due to minimum threshold amount not met.',
+ 'Dropping route below bridgeMinAcceptedAmount',
);
});
@@ -1269,71 +1069,7 @@ describe('hyperlane warp rebalancer e2e tests', async function () {
}
// Running the rebalancer again should not trigger any rebalance given that it is already balanced.
- await startRebalancerAndExpectLog(
- `No routes to execute. Assuming rebalance is complete. Resetting semaphore timer.`,
- );
- });
-
- it('should throw when the semaphore timer has not expired', async () => {
- const originContractAddress = getTokenAddressFromWarpConfig(
- warpCoreConfig,
- CHAIN_NAME_3,
- );
- const destDomain = chain2Metadata.domainId;
- const originRpc = chain3Metadata.rpcUrls[0].http;
-
- const originProvider = new ethers.providers.JsonRpcProvider(originRpc);
- const originSigner = new Wallet(ANVIL_KEY, originProvider);
- const originContract = HypERC20Collateral__factory.connect(
- originContractAddress,
- originSigner,
- );
-
- // --- Deploy the bridge ---
-
- const bridgeContract = await new MockValueTransferBridge__factory(
- originSigner,
- ).deploy(tokenChain3.address);
-
- // --- Allow bridge ---
-
- await originContract.addBridge(destDomain, bridgeContract.address);
-
- // --- Configure rebalancer ---
-
- writeYamlOrJson(REBALANCER_CONFIG_PATH, {
- warpRouteId,
- strategy: {
- rebalanceStrategy: RebalancerStrategyOptions.Weighted,
- chains: {
- [CHAIN_NAME_2]: {
- weighted: {
- weight: '75',
- tolerance: '0',
- },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 100,
- },
- [CHAIN_NAME_3]: {
- weighted: {
- weight: '25',
- tolerance: '0',
- },
- bridge: bridgeContract.address,
- bridgeLockTime: 100,
- },
- },
- },
- });
-
- // --- Start rebalancer ---
-
- await startRebalancerAndExpectLog(
- `Still in waiting period. Skipping rebalance.`,
- {
- checkFrequency: 2000,
- },
- );
+ await startRebalancerAndExpectLog(`No rebalancing needed`);
});
it('should successfully log metrics tracking', async () => {
@@ -1369,23 +1105,6 @@ describe('hyperlane warp rebalancer e2e tests', async function () {
});
});
- it('should not find any metrics server when metrics are not enabled', async () => {
- const rebalancer = startRebalancer({ withMetrics: false });
-
- // TODO: find a deterministic approach to this, as it may fail due to resource restrictions
- // Give the server some time to start, but we don't need to wait long as we expect it to fail
- await sleep(1000);
-
- // Check that metrics endpoint is not responding
- await expect(fetch(DEFAULT_METRICS_SERVER)).to.be.rejected;
-
- try {
- await rebalancer.kill('SIGINT');
- } catch {
- // Process may have already exited, which is fine
- }
- });
-
it('should start the metrics server and expose prometheus metrics', async () => {
const testPort = '9092';
process.env.PROMETHEUS_PORT = testPort;
@@ -1438,152 +1157,6 @@ describe('hyperlane warp rebalancer e2e tests', async function () {
}
});
- it('should use another warp route as bridge', async () => {
- // --- Deploy the other warp route ---
-
- const otherWarpRouteId = createWarpRouteConfigId(
- tokenSymbol,
- [CHAIN_NAME_2, CHAIN_NAME_3].sort().join('-'),
- );
- const otherWarpDeployConfigPath = `${REGISTRY_PATH}/deployments/warp_routes/${otherWarpRouteId}-deploy.yaml`;
- const otherWarpCoreConfigPath = `${REGISTRY_PATH}/deployments/warp_routes/${otherWarpRouteId}-config.yaml`;
-
- const otherWarpRouteDeployConfig: WarpRouteDeployConfig = {
- [CHAIN_NAME_2]: {
- type: TokenType.collateral,
- token: tokenChain2.address,
- mailbox: chain2Addresses.mailbox,
- owner: ANVIL_DEPLOYER_ADDRESS,
- },
- [CHAIN_NAME_3]: {
- type: TokenType.collateral,
- token: tokenChain3.address,
- mailbox: chain3Addresses.mailbox,
- owner: ANVIL_DEPLOYER_ADDRESS,
- },
- };
- writeYamlOrJson(otherWarpDeployConfigPath, otherWarpRouteDeployConfig);
- await hyperlaneWarpDeploy(otherWarpDeployConfigPath, otherWarpRouteId);
-
- const otherWarpCoreConfig: WarpCoreConfig = readYamlOrJson(
- otherWarpCoreConfigPath,
- );
-
- const chain2BridgeAddress = getTokenAddressFromWarpConfig(
- otherWarpCoreConfig,
- CHAIN_NAME_2,
- );
- const chain3BridgeAddress = getTokenAddressFromWarpConfig(
- otherWarpCoreConfig,
- CHAIN_NAME_3,
- );
-
- const chain2Signer = new Wallet(
- ANVIL_KEY,
- new ethers.providers.JsonRpcProvider(chain2Metadata.rpcUrls[0].http),
- );
-
- const chain3Signer = new Wallet(
- ANVIL_KEY,
- new ethers.providers.JsonRpcProvider(chain3Metadata.rpcUrls[0].http),
- );
-
- const chain2Contract = HypERC20Collateral__factory.connect(
- getTokenAddressFromWarpConfig(warpCoreConfig, CHAIN_NAME_2),
- chain2Signer,
- );
-
- const chain3Contract = HypERC20Collateral__factory.connect(
- getTokenAddressFromWarpConfig(warpCoreConfig, CHAIN_NAME_3),
- chain3Signer,
- );
-
- // --- Allow bridge ---
-
- await chain2Contract.addBridge(
- chain3Metadata.domainId,
- chain2BridgeAddress,
- );
-
- await chain3Contract.addBridge(
- chain2Metadata.domainId,
- chain3BridgeAddress,
- );
-
- // --- Fund warp route bridge collaterals ---
- await (
- await tokenChain2
- .connect(chain2Signer)
- .transfer(chain2BridgeAddress, toWei(10))
- ).wait();
-
- await (
- await tokenChain3
- .connect(chain3Signer)
- .transfer(chain3BridgeAddress, toWei(10))
- ).wait();
-
- writeYamlOrJson(REBALANCER_CONFIG_PATH, {
- warpRouteId,
- strategy: {
- rebalanceStrategy: RebalancerStrategyOptions.Weighted,
- chains: {
- [CHAIN_NAME_2]: {
- weighted: {
- weight: '25',
- tolerance: '0',
- },
- bridge: chain2BridgeAddress,
- bridgeLockTime: 60,
- bridgeIsWarp: true,
- },
- [CHAIN_NAME_3]: {
- weighted: {
- weight: '75',
- tolerance: '0',
- },
- bridge: chain3BridgeAddress,
- bridgeLockTime: 60,
- bridgeIsWarp: true,
- },
- },
- },
- });
-
- // --- Start relayer ---
- const relayer = hyperlaneRelayer(
- [CHAIN_NAME_2, CHAIN_NAME_3],
- otherWarpCoreConfigPath,
- );
-
- await sleep(2000);
-
- // --- Start rebalancer ---
- try {
- await startRebalancerAndExpectLog(
- [
- 'Rebalancer started successfully 🚀',
- 'Found rebalancing routes',
- 'Preparing all rebalance transactions.',
- 'Preparing transaction for route',
- 'Estimating gas for all prepared transactions.',
- 'Sending valid transactions.',
- 'Sending transaction for route',
- 'Transaction confirmed for route.',
- '✅ Rebalance successful',
- 'No routes to execute',
- ],
- { timeout: 30000, checkFrequency: 1000 },
- );
- } finally {
- try {
- await relayer.kill('SIGINT');
- } catch {
- // Process may have already exited, which is fine
- }
- }
- });
-
describe('manual rebalance', () => {
it('should successfully rebalance tokens between chains using a mock bridge', async () => {
const wccTokens = warpCoreConfig.tokens;
@@ -1755,172 +1328,7 @@ describe('hyperlane warp rebalancer e2e tests', async function () {
}
// Running the rebalancer again should not trigger any rebalance given that it is already balanced.
- await startRebalancerAndExpectLog(
- `No routes to execute. Assuming rebalance is complete. Resetting semaphore timer.`,
- );
- });
-
- it('should use another warp route as bridge', async () => {
- // --- Deploy the other warp route ---
-
- const otherWarpRouteId = createWarpRouteConfigId(
- tokenSymbol,
- [CHAIN_NAME_2, CHAIN_NAME_3].sort().join('-'),
- );
- const otherWarpDeployConfigPath = `${REGISTRY_PATH}/deployments/warp_routes/${otherWarpRouteId}-deploy.yaml`;
- const otherWarpCoreConfigPath = `${REGISTRY_PATH}/deployments/warp_routes/${otherWarpRouteId}-config.yaml`;
-
- const otherWarpRouteDeployConfig: WarpRouteDeployConfig = {
- [CHAIN_NAME_2]: {
- type: TokenType.collateral,
- token: tokenChain2.address,
- mailbox: chain2Addresses.mailbox,
- owner: ANVIL_DEPLOYER_ADDRESS,
- },
- [CHAIN_NAME_3]: {
- type: TokenType.collateral,
- token: tokenChain3.address,
- mailbox: chain3Addresses.mailbox,
- owner: ANVIL_DEPLOYER_ADDRESS,
- },
- };
- writeYamlOrJson(otherWarpDeployConfigPath, otherWarpRouteDeployConfig);
- await hyperlaneWarpDeploy(otherWarpDeployConfigPath, otherWarpRouteId);
-
- const otherWarpCoreConfig: WarpCoreConfig = readYamlOrJson(
- otherWarpCoreConfigPath,
- );
-
- const chain2BridgeAddress = getTokenAddressFromWarpConfig(
- otherWarpCoreConfig,
- CHAIN_NAME_2,
- );
- const chain3BridgeAddress = getTokenAddressFromWarpConfig(
- otherWarpCoreConfig,
- CHAIN_NAME_3,
- );
-
- const chain2Signer = new Wallet(
- ANVIL_KEY,
- new ethers.providers.JsonRpcProvider(chain2Metadata.rpcUrls[0].http),
- );
-
- const chain3Signer = new Wallet(
- ANVIL_KEY,
- new ethers.providers.JsonRpcProvider(chain3Metadata.rpcUrls[0].http),
- );
-
- const chain2Contract = HypERC20Collateral__factory.connect(
- getTokenAddressFromWarpConfig(warpCoreConfig, CHAIN_NAME_2),
- chain2Signer,
- );
-
- const chain3Contract = HypERC20Collateral__factory.connect(
- getTokenAddressFromWarpConfig(warpCoreConfig, CHAIN_NAME_3),
- chain3Signer,
- );
-
- // --- Allow bridge ---
-
- await chain2Contract.addBridge(
- chain3Metadata.domainId,
- chain2BridgeAddress,
- );
-
- await chain3Contract.addBridge(
- chain2Metadata.domainId,
- chain3BridgeAddress,
- );
-
- // --- Fund warp route bridge collaterals ---
- await (
- await tokenChain2
- .connect(chain2Signer)
- .transfer(chain2BridgeAddress, toWei(10))
- ).wait();
-
- await (
- await tokenChain3
- .connect(chain3Signer)
- .transfer(chain3BridgeAddress, toWei(10))
- ).wait();
-
- writeYamlOrJson(REBALANCER_CONFIG_PATH, {
- warpRouteId,
- strategy: {
- rebalanceStrategy: RebalancerStrategyOptions.Weighted,
- chains: {
- [CHAIN_NAME_2]: {
- weighted: {
- weight: '25',
- tolerance: '0',
- },
- bridge: chain2BridgeAddress,
- bridgeLockTime: 60,
- bridgeIsWarp: true,
- },
- [CHAIN_NAME_3]: {
- weighted: {
- weight: '75',
- tolerance: '0',
- },
- bridge: chain3BridgeAddress,
- bridgeLockTime: 60,
- bridgeIsWarp: true,
- },
- },
- },
- });
-
- // --- Start relayer ---
- const relayer = hyperlaneRelayer(
- [CHAIN_NAME_2, CHAIN_NAME_3],
- otherWarpCoreConfigPath,
- );
-
- await sleep(2000);
-
- // --- Start rebalancer ---
- try {
- await startRebalancerAndExpectLog(
- ['Calculating rebalancing routes', 'Found rebalancing routes'],
- {
- monitorOnly: true,
- },
- );
-
- const manualRebalanceAmount = '5';
-
- await startRebalancerAndExpectLog(
- [
- `Manual rebalance strategy selected. Origin: ${CHAIN_NAME_2}, Destination: ${CHAIN_NAME_3}, Amount: ${manualRebalanceAmount}`,
- 'Rebalance initiated',
- 'Preparing all rebalance transactions.',
- `✅ Manual rebalance from ${CHAIN_NAME_2} to ${CHAIN_NAME_3} for amount ${manualRebalanceAmount} submitted successfully.`,
- ],
- {
- timeout: 30000,
- manual: true,
- origin: CHAIN_NAME_2,
- destination: CHAIN_NAME_3,
- amount: manualRebalanceAmount,
- },
- );
-
- await startRebalancerAndExpectLog(
- ['Calculating rebalancing routes', 'Found rebalancing routes'],
- {
- timeout: 90000,
- monitorOnly: true,
- },
- );
- } finally {
- try {
- await relayer.kill('SIGINT');
- } catch {
- // Process may have already exited, which is fine
- }
- }
+ await startRebalancerAndExpectLog(`No rebalancing needed`);
});
});
});
diff --git a/typescript/infra/config/environments/mainnet3/rebalancer/USDCSTAGE/eclipsemainnet-config.yaml b/typescript/infra/config/environments/mainnet3/rebalancer/USDCSTAGE/eclipsemainnet-config.yaml
index edbe55c5c4f..c903f2e8f5b 100644
--- a/typescript/infra/config/environments/mainnet3/rebalancer/USDCSTAGE/eclipsemainnet-config.yaml
+++ b/typescript/infra/config/environments/mainnet3/rebalancer/USDCSTAGE/eclipsemainnet-config.yaml
@@ -1,27 +1,40 @@
warpRouteId: USDCSTAGE/eclipsemainnet
strategy:
- rebalanceStrategy: minAmount
- chains:
- base:
- minAmount:
- min: 0.000002
- target: 0.000002
- type: 'absolute'
- bridgeLockTime: 1800 # 30 mins in seconds
- bridge: '0x5C4aFb7e23B1Dc1B409dc1702f89C64527b25975'
+ # First: CollateralDeficitStrategy - uses FAST bridges for reactive rebalancing
+ # Note: arbitrum uses standard bridge (0x33e94B6D...) as it's the only one allowed for all destinations
+ - rebalanceStrategy: collateralDeficit
+ chains:
+ base:
+ buffer: 0
+ bridge: '0x584244d02b0fBf9054A5D5C9e9cE9A2E8adA0e28'
+ ethereum:
+ buffer: 0
+ bridge: '0xEE4a09db2C25592C04b8b342CB89f9a7f5E20BD2'
+ arbitrum:
+ buffer: 0
+ bridge: '0xb0B8D4C6EF212D76d5079df5Ff7A0888A27e9b32'
- ethereum:
- minAmount:
- min: 0.000002
- target: 0.000002
- type: 'absolute'
- bridgeLockTime: 1800 # 30 mins in seconds
- bridge: '0xedCBAa585FD0F80f20073F9958246476466205b8'
-
- arbitrum:
- minAmount:
- min: 0.000002
- target: 0.000002
- type: 'absolute'
- bridgeLockTime: 1800 # 30 mins in seconds
- bridge: '0x8a82186EA618b91D13A2041fb7aC31Bf01C02aD2'
+ # Second: MinAmountStrategy - uses STANDARD bridges for baseline floors
+ - rebalanceStrategy: minAmount
+ chains:
+ base:
+ minAmount:
+ min: 0.000001
+ target: 0.000001
+ type: 'absolute'
+ bridgeLockTime: 1800
+ bridge: '0x33e94B6D2ae697c16a750dB7c3d9443622C4405a'
+ ethereum:
+ minAmount:
+ min: 0.000001
+ target: 0.000001
+ type: 'absolute'
+ bridgeLockTime: 1800
+ bridge: '0x8c8D831E1e879604b4B304a2c951B8AEe3aB3a23'
+ arbitrum:
+ minAmount:
+ min: 0.000001
+ target: 0.000001
+ type: 'absolute'
+ bridgeLockTime: 1800
+ bridge: '0x4c19c653a8419A475d9B6735511cB81C15b8d9b2'
diff --git a/typescript/infra/src/rebalancer/helm.ts b/typescript/infra/src/rebalancer/helm.ts
index e1a7f233506..f4a2059fa9d 100644
--- a/typescript/infra/src/rebalancer/helm.ts
+++ b/typescript/infra/src/rebalancer/helm.ts
@@ -6,9 +6,10 @@ import { fromZodError } from 'zod-validation-error';
import {
type RebalancerConfigFileInput,
RebalancerConfigSchema,
+ getStrategyChainNames,
} from '@hyperlane-xyz/rebalancer';
import { DEFAULT_GITHUB_REGISTRY } from '@hyperlane-xyz/registry';
-import { isObjEmpty, rootLogger } from '@hyperlane-xyz/utils';
+import { rootLogger } from '@hyperlane-xyz/utils';
import { readYaml } from '@hyperlane-xyz/utils/fs';
import { DockerImageRepos, mainnetDockerTags } from '../../config/docker.js';
@@ -64,8 +65,8 @@ export class RebalancerHelmManager extends HelmManager {
throw new Error(fromZodError(validationResult.error).message);
}
- const { chains } = validationResult.data.strategy;
- if (isObjEmpty(chains)) {
+ const chainNames = getStrategyChainNames(validationResult.data.strategy);
+ if (chainNames.length === 0) {
throw new Error('No chains configured');
}
diff --git a/typescript/rebalancer/README.md b/typescript/rebalancer/README.md
index 9b188a6556b..da1dd9e88c8 100644
--- a/typescript/rebalancer/README.md
+++ b/typescript/rebalancer/README.md
@@ -9,11 +9,13 @@ The rebalancer monitors collateral balances across warp route deployments and au
## Features
- **Multi-Protocol Support**: Protocol-agnostic design supporting EVM chains (Cosmos, Sealevel, etc. when movable collateral contracts available)
-- **Flexible Strategies**: Weighted and minimum-amount rebalancing strategies
-- **Safety Features**: Inflight guards, semaphores, and comprehensive validation
+- **Flexible Strategies**: Weighted, minimum-amount, and collateral-deficit rebalancing strategies
+- **Composite Strategies**: Combine multiple strategies with different bridges for layered rebalancing
+- **Inflight Tracking**: Monitors pending rebalances to prevent duplicate transfers
+- **Safety Features**: Inflight message tracking and comprehensive validation
- **Observability**: Built-in Prometheus metrics and structured logging
- **Dual Mode**: Manual CLI execution or continuous daemon service
-- **Bridge Support**: Integration with Portal, Hyperlane, and other bridge providers
+- **Bridge Support**: Integration with Hyperlane warp routes and external bridges
## Installation
@@ -90,7 +92,9 @@ await service.executeManual({
## Configuration
-The rebalancer uses a YAML configuration file. See example:
+The rebalancer uses a YAML configuration file with a `warpRouteId` and `strategy` field.
+
+### Basic Example (Single Strategy)
```yaml
warpRouteId: ETH/ethereum-arbitrum-optimism
@@ -99,13 +103,129 @@ strategy:
chains:
ethereum:
weight: 60
- bridge: hyperlane
+ tolerance: 5
+ bridge: '0x1234...abcd'
arbitrum:
weight: 20
- bridge: hyperlane
- optimism:
- weight: 20
- bridge: portal
+ tolerance: 5
+ bridge: '0x5678...efgh'
+```
+
+### Composite Strategy (v1.0.0+)
+
+The `strategy` field accepts an array of strategies for composite rebalancing. Strategies are evaluated in order - the first strategy that produces routes is used.
+
+```yaml
+warpRouteId: USDC/base-ethereum-arbitrum
+strategy:
+ # First: CollateralDeficitStrategy with fast bridges for reactive rebalancing
+ - rebalanceStrategy: collateralDeficit
+ chains:
+ base:
+ buffer: 0
+ bridge: '0x584244d02b0fBf9054A5D5C9e9cE9A2E8adA0e28'
+ ethereum:
+ buffer: 0
+ bridge: '0xEE4a09db2C25592C04b8b342CB89f9a7f5E20BD2'
+
+ # Second: MinAmountStrategy with standard bridges for baseline floors
+ - rebalanceStrategy: minAmount
+ chains:
+ base:
+ minAmount:
+ min: 0.1
+ target: 0.11
+ type: 'absolute'
+ bridgeLockTime: 1800
+ bridge: '0x33e94B6D2ae697c16a750dB7c3d9443622C4405a'
+ ethereum:
+ minAmount:
+ min: 0.1
+ target: 0.11
+ type: 'absolute'
+ bridgeLockTime: 1800
+ bridge: '0x8c8D831E1e879604b4B304a2c951B8AEe3aB3a23'
+```
+
+> **Note**: When using `collateralDeficit` in a composite strategy, it must be the first strategy in the array.
+
+### Strategy Types
+
+| Strategy | Use Case | Chain Config Fields |
+| ------------------- | ------------------------------------------------------ | -------------------------------- |
+| `weighted` | Maintain percentage distribution across chains | `weight`, `tolerance` |
+| `minAmount` | Trigger rebalance when balance falls below floor | `minAmount: {min, target, type}` |
+| `collateralDeficit` | React to bridged supply gaps (synthetic vs collateral) | `buffer` |
+
+#### Weighted Strategy
+
+Maintains target weight percentages across chains. Rebalances when deviation exceeds tolerance.
+
+```yaml
+strategy:
+ rebalanceStrategy: weighted
+ chains:
+ ethereum:
+ weight: 60 # Target 60% of total supply
+ tolerance: 5 # Rebalance if >5% deviation
+ bridge: '0x...'
+```
+
+#### MinAmount Strategy
+
+Triggers rebalance when a chain's balance falls below the minimum threshold.
+
+```yaml
+strategy:
+ rebalanceStrategy: minAmount
+ chains:
+ ethereum:
+ minAmount:
+ min: 100 # Trigger rebalance below this
+ target: 110 # Rebalance up to this amount
+ type: 'absolute' # or 'relative' (percentage of total)
+ bridge: '0x...'
+```
+
+#### CollateralDeficit Strategy
+
+Monitors bridged (synthetic) supply vs collateral and rebalances to cover deficits.
+
+```yaml
+strategy:
+ rebalanceStrategy: collateralDeficit
+ chains:
+ ethereum:
+ buffer: 1000 # Extra collateral buffer above deficit
+ bridge: '0x...'
+```
+
+### Chain Config Reference
+
+| Field | Type | Required | Description |
+| ------------------------- | ---------------- | -------- | -------------------------------------------------------------- |
+| `bridge` | `0x...` address | Yes | Bridge contract address for this chain |
+| `bridgeLockTime` | number (seconds) | No | Expected bridge transfer duration (used for inflight tracking) |
+| `bridgeMinAcceptedAmount` | number | No | Skip routes with amounts below this threshold |
+| `override` | object | No | Per-destination bridge overrides (see below) |
+
+#### Per-Destination Overrides
+
+Use `override` to specify different bridge configs for specific destination chains:
+
+```yaml
+strategy:
+ rebalanceStrategy: minAmount
+ chains:
+ ethereum:
+ minAmount: { min: 100, target: 110, type: 'absolute' }
+ bridge: '0xDefaultBridge...'
+ override:
+ arbitrum:
+ bridge: '0xFastArbitrumBridge...'
+ bridgeLockTime: 600
+ optimism:
+ bridge: '0xOptimismBridge...'
```
## Architecture
@@ -117,12 +237,12 @@ typescript/rebalancer/
├── src/
│ ├── core/
│ │ ├── RebalancerService.ts # Main orchestrator
-│ │ ├── Rebalancer.ts # Core rebalancing logic
-│ │ ├── WithInflightGuard.ts # Concurrency protection
-│ │ └── WithSemaphore.ts # Semaphore wrapper
+│ │ └── Rebalancer.ts # Core rebalancing logic
│ ├── strategy/
-│ │ ├── WeightedStrategy.ts # Weighted distribution
-│ │ └── MinAmountStrategy.ts # Minimum threshold strategy
+│ │ ├── WeightedStrategy.ts # Weighted distribution
+│ │ ├── MinAmountStrategy.ts # Minimum threshold strategy
+│ │ ├── CollateralDeficitStrategy.ts # Bridged supply deficit strategy
+│ │ └── CompositeStrategy.ts # Combines multiple strategies
│ ├── monitor/
│ │ └── Monitor.ts # Balance monitoring
│ ├── metrics/
diff --git a/typescript/rebalancer/package.json b/typescript/rebalancer/package.json
index c44a205a12b..acce221162b 100644
--- a/typescript/rebalancer/package.json
+++ b/typescript/rebalancer/package.json
@@ -40,6 +40,7 @@
"pino": "catalog:",
"pino-pretty": "catalog:",
"prom-client": "catalog:",
+ "uuid": "catalog:",
"yaml": "catalog:",
"zod": "catalog:",
"zod-validation-error": "catalog:"
@@ -52,6 +53,7 @@
"@types/mocha": "catalog:",
"@types/node": "catalog:",
"@types/sinon": "catalog:",
+ "@types/uuid": "catalog:",
"@vercel/ncc": "catalog:",
"chai": "catalog:",
"chai-as-promised": "catalog:",
diff --git a/typescript/rebalancer/src/config/RebalancerConfig.test.ts b/typescript/rebalancer/src/config/RebalancerConfig.test.ts
index c9cdbab05b4..5336f766960 100644
--- a/typescript/rebalancer/src/config/RebalancerConfig.test.ts
+++ b/typescript/rebalancer/src/config/RebalancerConfig.test.ts
@@ -3,6 +3,7 @@ import { ethers } from 'ethers';
import { rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
+import type { z } from 'zod';
import { writeYamlOrJson } from '@hyperlane-xyz/utils/fs';
@@ -11,37 +12,49 @@ import {
type RebalancerConfigFileInput,
RebalancerMinAmountType,
RebalancerStrategyOptions,
+ type StrategyConfig,
+ getAllBridges,
} from './types.js';
const TEST_CONFIG_PATH = join(tmpdir(), 'rebalancer-config-test.yaml');
+// Helper to get strategy as array (for test type safety)
+// Schema accepts both single object and array, but tests use array format
+function getStrategyArray(
+ data: RebalancerConfigFileInput,
+): z.input[] {
+ return Array.isArray(data.strategy) ? data.strategy : [data.strategy];
+}
+
describe('RebalancerConfig', () => {
let data: RebalancerConfigFileInput;
beforeEach(() => {
data = {
warpRouteId: 'warpRouteId',
- strategy: {
- rebalanceStrategy: RebalancerStrategyOptions.Weighted,
- chains: {
- chain1: {
- weighted: {
- weight: 100,
- tolerance: 0,
+ strategy: [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
+ chains: {
+ chain1: {
+ weighted: {
+ weight: 100,
+ tolerance: 0,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
- chain2: {
- weighted: {
- weight: 100,
- tolerance: 0,
+ chain2: {
+ weighted: {
+ weight: 100,
+ tolerance: 0,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
},
},
- },
+ ],
};
writeYamlOrJson(TEST_CONFIG_PATH, data);
@@ -62,32 +75,34 @@ describe('RebalancerConfig', () => {
it('should load config from file', () => {
expect(RebalancerConfig.load(TEST_CONFIG_PATH)).to.deep.equal({
warpRouteId: 'warpRouteId',
- strategyConfig: {
- rebalanceStrategy: RebalancerStrategyOptions.Weighted,
- chains: {
- chain1: {
- weighted: {
- weight: 100n,
- tolerance: 0n,
+ strategyConfig: [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
+ chains: {
+ chain1: {
+ weighted: {
+ weight: 100n,
+ tolerance: 0n,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1_000,
},
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1_000,
- },
- chain2: {
- weighted: {
- weight: 100n,
- tolerance: 0n,
+ chain2: {
+ weighted: {
+ weight: 100n,
+ tolerance: 0n,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1_000,
},
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1_000,
},
},
- },
+ ],
});
});
it('should throw if chains are not configured', () => {
- data.strategy.chains = {};
+ getStrategyArray(data)[0].chains = {};
writeYamlOrJson(TEST_CONFIG_PATH, data);
@@ -110,37 +125,39 @@ describe('RebalancerConfig', () => {
it('should load relative params without modifications', () => {
data = {
warpRouteId: 'warpRouteId',
- strategy: {
- rebalanceStrategy: RebalancerStrategyOptions.MinAmount,
- chains: {
- chain1: {
- minAmount: {
- min: '0.2',
- target: 0.3,
- type: RebalancerMinAmountType.Relative,
+ strategy: [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.MinAmount,
+ chains: {
+ chain1: {
+ minAmount: {
+ min: '0.2',
+ target: 0.3,
+ type: RebalancerMinAmountType.Relative,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
- chain2: {
- minAmount: {
- min: '0.2',
- target: 0.3,
- type: RebalancerMinAmountType.Relative,
+ chain2: {
+ minAmount: {
+ min: '0.2',
+ target: 0.3,
+ type: RebalancerMinAmountType.Relative,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
},
},
- },
+ ],
};
writeYamlOrJson(TEST_CONFIG_PATH, data);
expect(
- RebalancerConfig.load(TEST_CONFIG_PATH).strategyConfig.chains.chain1,
+ RebalancerConfig.load(TEST_CONFIG_PATH).strategyConfig[0].chains.chain1,
).to.deep.equal({
- ...data.strategy.chains.chain1,
+ ...getStrategyArray(data)[0].chains.chain1,
bridgeLockTime: 1_000,
});
});
@@ -148,37 +165,39 @@ describe('RebalancerConfig', () => {
it('should load absolute params without modifications', () => {
data = {
warpRouteId: 'warpRouteId',
- strategy: {
- rebalanceStrategy: RebalancerStrategyOptions.MinAmount,
- chains: {
- chain1: {
- minAmount: {
- min: '100000',
- target: 140000,
- type: RebalancerMinAmountType.Absolute,
+ strategy: [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.MinAmount,
+ chains: {
+ chain1: {
+ minAmount: {
+ min: '100000',
+ target: 140000,
+ type: RebalancerMinAmountType.Absolute,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
- chain2: {
- minAmount: {
- min: '100000',
- target: 140000,
- type: RebalancerMinAmountType.Absolute,
+ chain2: {
+ minAmount: {
+ min: '100000',
+ target: 140000,
+ type: RebalancerMinAmountType.Absolute,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
},
},
- },
+ ],
};
writeYamlOrJson(TEST_CONFIG_PATH, data);
expect(
- RebalancerConfig.load(TEST_CONFIG_PATH).strategyConfig.chains.chain1,
+ RebalancerConfig.load(TEST_CONFIG_PATH).strategyConfig[0].chains.chain1,
).to.deep.equal({
- ...data.strategy.chains.chain1,
+ ...getStrategyArray(data)[0].chains.chain1,
bridgeLockTime: 1_000,
});
});
@@ -187,51 +206,54 @@ describe('RebalancerConfig', () => {
it('should parse a config with overrides', () => {
data = {
warpRouteId: 'warpRouteId',
- strategy: {
- rebalanceStrategy: RebalancerStrategyOptions.MinAmount,
- chains: {
- chain1: {
- minAmount: {
- min: 1000,
- target: 1100,
- type: RebalancerMinAmountType.Absolute,
- },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- override: {
- chain2: {
- bridge: '0x1234567890123456789012345678901234567890',
+ strategy: [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.MinAmount,
+ chains: {
+ chain1: {
+ minAmount: {
+ min: 1000,
+ target: 1100,
+ type: RebalancerMinAmountType.Absolute,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ override: {
+ chain2: {
+ bridge: '0x1234567890123456789012345678901234567890',
+ },
},
},
- },
- chain2: {
- minAmount: {
- min: 2000,
- target: 2200,
- type: RebalancerMinAmountType.Absolute,
+ chain2: {
+ minAmount: {
+ min: 2000,
+ target: 2200,
+ type: RebalancerMinAmountType.Absolute,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
- chain3: {
- minAmount: {
- min: 3000,
- target: 3300,
- type: RebalancerMinAmountType.Absolute,
+ chain3: {
+ minAmount: {
+ min: 3000,
+ target: 3300,
+ type: RebalancerMinAmountType.Absolute,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
},
},
- },
+ ],
};
writeYamlOrJson(TEST_CONFIG_PATH, data);
const config = RebalancerConfig.load(TEST_CONFIG_PATH);
- expect(config.strategyConfig.chains.chain1).to.have.property('override');
+ const chainConfig = config.strategyConfig[0].chains.chain1;
+ expect(chainConfig).to.have.property('override');
- const override = config.strategyConfig.chains.chain1.override;
+ const override = chainConfig.override;
expect(override).to.not.be.undefined;
expect(override).to.have.property('chain2');
@@ -245,37 +267,39 @@ describe('RebalancerConfig', () => {
it('should throw when an override references a non-existent chain', () => {
data = {
warpRouteId: 'warpRouteId',
- strategy: {
- rebalanceStrategy: RebalancerStrategyOptions.MinAmount,
- chains: {
- chain1: {
- minAmount: {
- min: 1000,
- target: 1100,
- type: RebalancerMinAmountType.Absolute,
- },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- override: {
- chain2: {
- bridge: '0x1234567890123456789012345678901234567890',
+ strategy: [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.MinAmount,
+ chains: {
+ chain1: {
+ minAmount: {
+ min: 1000,
+ target: 1100,
+ type: RebalancerMinAmountType.Absolute,
},
- chain3: {
- bridgeMinAcceptedAmount: 1000,
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ override: {
+ chain2: {
+ bridge: '0x1234567890123456789012345678901234567890',
+ },
+ chain3: {
+ bridgeMinAcceptedAmount: 1000,
+ },
},
},
- },
- chain2: {
- minAmount: {
- min: 2000,
- target: 2200,
- type: RebalancerMinAmountType.Absolute,
+ chain2: {
+ minAmount: {
+ min: 2000,
+ target: 2200,
+ type: RebalancerMinAmountType.Absolute,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
},
},
- },
+ ],
};
writeYamlOrJson(TEST_CONFIG_PATH, data);
@@ -288,34 +312,36 @@ describe('RebalancerConfig', () => {
it('should throw when an override references itself', () => {
data = {
warpRouteId: 'warpRouteId',
- strategy: {
- rebalanceStrategy: RebalancerStrategyOptions.MinAmount,
- chains: {
- chain1: {
- minAmount: {
- min: 1000,
- target: 1100,
- type: RebalancerMinAmountType.Absolute,
- },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- override: {
- chain1: {
- bridgeMinAcceptedAmount: 1000,
+ strategy: [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.MinAmount,
+ chains: {
+ chain1: {
+ minAmount: {
+ min: 1000,
+ target: 1100,
+ type: RebalancerMinAmountType.Absolute,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ override: {
+ chain1: {
+ bridgeMinAcceptedAmount: 1000,
+ },
},
},
- },
- chain2: {
- minAmount: {
- min: 2000,
- target: 2200,
- type: RebalancerMinAmountType.Absolute,
+ chain2: {
+ minAmount: {
+ min: 2000,
+ target: 2200,
+ type: RebalancerMinAmountType.Absolute,
+ },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
},
},
- },
+ ],
};
writeYamlOrJson(TEST_CONFIG_PATH, data);
@@ -326,7 +352,7 @@ describe('RebalancerConfig', () => {
});
it('should allow multiple chain overrides', () => {
- data.strategy.chains.chain1 = {
+ getStrategyArray(data)[0].chains.chain1 = {
bridge: ethers.constants.AddressZero,
bridgeMinAcceptedAmount: 3000,
bridgeLockTime: 1,
@@ -344,7 +370,7 @@ describe('RebalancerConfig', () => {
},
};
- data.strategy.chains.chain2 = {
+ getStrategyArray(data)[0].chains.chain2 = {
bridge: ethers.constants.AddressZero,
bridgeMinAcceptedAmount: 5000,
bridgeLockTime: 1,
@@ -354,7 +380,7 @@ describe('RebalancerConfig', () => {
},
};
- data.strategy.chains.chain3 = {
+ getStrategyArray(data)[0].chains.chain3 = {
bridge: ethers.constants.AddressZero,
bridgeMinAcceptedAmount: 6000,
bridgeLockTime: 1,
@@ -367,8 +393,8 @@ describe('RebalancerConfig', () => {
writeYamlOrJson(TEST_CONFIG_PATH, data);
const config = RebalancerConfig.load(TEST_CONFIG_PATH);
-
- const chain1Overrides = config.strategyConfig.chains.chain1.override;
+ const chainConfig = config.strategyConfig[0].chains.chain1;
+ const chain1Overrides = chainConfig.override;
expect(chain1Overrides).to.not.be.undefined;
expect(chain1Overrides).to.have.property('chain2');
expect(chain1Overrides).to.have.property('chain3');
@@ -384,4 +410,274 @@ describe('RebalancerConfig', () => {
);
});
});
+
+ describe('composite strategy validation', () => {
+ it('should throw if CollateralDeficitStrategy is not first in composite', () => {
+ data = {
+ warpRouteId: 'warpRouteId',
+ strategy: [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
+ chains: {
+ chain1: {
+ weighted: { weight: 100, tolerance: 0 },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ chain2: {
+ weighted: { weight: 100, tolerance: 0 },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ },
+ },
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.CollateralDeficit,
+ chains: {
+ chain1: {
+ buffer: 1000,
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ chain2: {
+ buffer: 1000,
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ },
+ },
+ ],
+ };
+
+ writeYamlOrJson(TEST_CONFIG_PATH, data);
+
+ expect(() => RebalancerConfig.load(TEST_CONFIG_PATH)).to.throw(
+ 'CollateralDeficitStrategy must be first when used in composite strategy',
+ );
+ });
+
+ it('should allow CollateralDeficitStrategy first in composite', () => {
+ data = {
+ warpRouteId: 'warpRouteId',
+ strategy: [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.CollateralDeficit,
+ chains: {
+ chain1: {
+ buffer: 1000,
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ chain2: {
+ buffer: 1000,
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ },
+ },
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
+ chains: {
+ chain1: {
+ weighted: { weight: 100, tolerance: 0 },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ chain2: {
+ weighted: { weight: 100, tolerance: 0 },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ },
+ },
+ ],
+ };
+
+ writeYamlOrJson(TEST_CONFIG_PATH, data);
+
+ expect(() => RebalancerConfig.load(TEST_CONFIG_PATH)).to.not.throw();
+ });
+ });
+});
+
+describe('getAllBridges', () => {
+ const BRIDGE_A = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
+ const BRIDGE_B = '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
+ const BRIDGE_C = '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC';
+
+ it('should return empty array for empty strategies', () => {
+ const result = getAllBridges([]);
+ expect(result).to.deep.equal([]);
+ });
+
+ it('should return bridge from single strategy', () => {
+ const strategies: StrategyConfig[] = [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
+ chains: {
+ chain1: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: BRIDGE_A,
+ bridgeLockTime: 1000,
+ },
+ chain2: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: BRIDGE_A,
+ bridgeLockTime: 1000,
+ },
+ },
+ },
+ ];
+
+ const result = getAllBridges(strategies);
+ expect(result).to.deep.equal([BRIDGE_A]);
+ });
+
+ it('should return all bridges from multiple strategies', () => {
+ const strategies: StrategyConfig[] = [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.CollateralDeficit,
+ chains: {
+ chain1: {
+ buffer: 1000,
+ bridge: BRIDGE_A,
+ bridgeLockTime: 1000,
+ },
+ chain2: {
+ buffer: 1000,
+ bridge: BRIDGE_A,
+ bridgeLockTime: 1000,
+ },
+ },
+ },
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
+ chains: {
+ chain1: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: BRIDGE_B,
+ bridgeLockTime: 1000,
+ },
+ chain2: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: BRIDGE_B,
+ bridgeLockTime: 1000,
+ },
+ },
+ },
+ ];
+
+ const result = getAllBridges(strategies);
+ expect(result).to.have.members([BRIDGE_A, BRIDGE_B]);
+ expect(result).to.have.lengthOf(2);
+ });
+
+ it('should include bridges from per-destination overrides', () => {
+ const strategies: StrategyConfig[] = [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
+ chains: {
+ chain1: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: BRIDGE_A,
+ bridgeLockTime: 1000,
+ override: {
+ chain2: {
+ bridge: BRIDGE_B,
+ },
+ chain3: {
+ bridge: BRIDGE_C,
+ },
+ },
+ },
+ chain2: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: BRIDGE_A,
+ bridgeLockTime: 1000,
+ },
+ chain3: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: BRIDGE_A,
+ bridgeLockTime: 1000,
+ },
+ },
+ },
+ ];
+
+ const result = getAllBridges(strategies);
+ expect(result).to.have.members([BRIDGE_A, BRIDGE_B, BRIDGE_C]);
+ expect(result).to.have.lengthOf(3);
+ });
+
+ it('should deduplicate bridges across strategies and overrides', () => {
+ const strategies: StrategyConfig[] = [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.CollateralDeficit,
+ chains: {
+ chain1: {
+ buffer: 1000,
+ bridge: BRIDGE_A,
+ bridgeLockTime: 1000,
+ },
+ chain2: {
+ buffer: 1000,
+ bridge: BRIDGE_B,
+ bridgeLockTime: 1000,
+ },
+ },
+ },
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
+ chains: {
+ chain1: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: BRIDGE_A, // Same as first strategy
+ bridgeLockTime: 1000,
+ override: {
+ chain2: {
+ bridge: BRIDGE_B, // Same as chain2 default
+ },
+ },
+ },
+ chain2: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: BRIDGE_B,
+ bridgeLockTime: 1000,
+ },
+ },
+ },
+ ];
+
+ const result = getAllBridges(strategies);
+ expect(result).to.have.members([BRIDGE_A, BRIDGE_B]);
+ expect(result).to.have.lengthOf(2);
+ });
+
+ it('should handle overrides without bridge property', () => {
+ const strategies: StrategyConfig[] = [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
+ chains: {
+ chain1: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: BRIDGE_A,
+ bridgeLockTime: 1000,
+ override: {
+ chain2: {
+ bridgeMinAcceptedAmount: 5000, // Override without bridge
+ },
+ },
+ },
+ chain2: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: BRIDGE_A,
+ bridgeLockTime: 1000,
+ },
+ },
+ },
+ ];
+
+ const result = getAllBridges(strategies);
+ expect(result).to.deep.equal([BRIDGE_A]);
+ });
});
diff --git a/typescript/rebalancer/src/config/RebalancerConfig.ts b/typescript/rebalancer/src/config/RebalancerConfig.ts
index fb3c12aa01d..149f873f346 100644
--- a/typescript/rebalancer/src/config/RebalancerConfig.ts
+++ b/typescript/rebalancer/src/config/RebalancerConfig.ts
@@ -1,18 +1,18 @@
import { fromZodError } from 'zod-validation-error';
-import { isObjEmpty } from '@hyperlane-xyz/utils';
import { readYamlOrJson } from '@hyperlane-xyz/utils/fs';
import {
type RebalancerConfigFileInput,
RebalancerConfigSchema,
type StrategyConfig,
+ getStrategyChainNames,
} from './types.js';
export class RebalancerConfig {
constructor(
public readonly warpRouteId: string,
- public readonly strategyConfig: StrategyConfig,
+ public readonly strategyConfig: StrategyConfig[],
) {}
/**
@@ -30,7 +30,9 @@ export class RebalancerConfig {
const { warpRouteId, strategy } = validationResult.data;
- if (isObjEmpty(strategy.chains)) {
+ // Check that at least one chain is configured across all strategies
+ const chainNames = getStrategyChainNames(strategy);
+ if (chainNames.length === 0) {
throw new Error('No chains configured');
}
diff --git a/typescript/rebalancer/src/config/types.ts b/typescript/rebalancer/src/config/types.ts
index 6666b9275c0..3297df2cf7a 100644
--- a/typescript/rebalancer/src/config/types.ts
+++ b/typescript/rebalancer/src/config/types.ts
@@ -3,6 +3,7 @@ import { z } from 'zod';
export enum RebalancerStrategyOptions {
Weighted = 'weighted',
MinAmount = 'minAmount',
+ CollateralDeficit = 'collateralDeficit',
}
// Weighted strategy config schema
@@ -36,11 +37,8 @@ const RebalancerBridgeConfigSchema = z.object({
.number()
.positive()
.transform((val) => val * 1_000)
- .describe('Expected time in seconds for bridge to process a transfer'),
- bridgeIsWarp: z
- .boolean()
.optional()
- .describe('True if the bridge is another Warp Route'),
+ .describe('Expected time in seconds for bridge to process a transfer'),
});
export const RebalancerBaseChainConfigSchema =
@@ -59,6 +57,11 @@ const MinAmountChainConfigSchema = RebalancerBaseChainConfigSchema.extend({
minAmount: RebalancerMinAmountConfigSchema,
});
+const CollateralDeficitChainConfigSchema =
+ RebalancerBaseChainConfigSchema.extend({
+ buffer: z.string().or(z.number()),
+ });
+
const WeightedStrategySchema = z.object({
rebalanceStrategy: z.literal(RebalancerStrategyOptions.Weighted),
chains: z.record(z.string(), WeightedChainConfigSchema),
@@ -69,75 +72,122 @@ const MinAmountStrategySchema = z.object({
chains: z.record(z.string(), MinAmountChainConfigSchema),
});
+const CollateralDeficitStrategySchema = z.object({
+ rebalanceStrategy: z.literal(RebalancerStrategyOptions.CollateralDeficit),
+ chains: z.record(z.string(), CollateralDeficitChainConfigSchema),
+});
+
export type WeightedStrategy = z.infer;
export type MinAmountStrategy = z.infer;
+export type CollateralDeficitStrategy = z.infer<
+ typeof CollateralDeficitStrategySchema
+>;
export type WeightedStrategyConfig = WeightedStrategy['chains'];
export type MinAmountStrategyConfig = MinAmountStrategy['chains'];
+export type CollateralDeficitStrategyConfig =
+ CollateralDeficitStrategy['chains'];
export const StrategyConfigSchema = z.discriminatedUnion('rebalanceStrategy', [
WeightedStrategySchema,
MinAmountStrategySchema,
+ CollateralDeficitStrategySchema,
]);
+// Accept either a single strategy (backwards compatible) or an array of strategies
+// Normalizes to array internally so the rest of the code doesn't need to change
+export const RebalancerStrategySchema = z
+ .union([
+ StrategyConfigSchema, // Old format: single object
+ z.array(StrategyConfigSchema).min(1), // New format: array
+ ])
+ .transform((val) => (Array.isArray(val) ? val : [val]));
+
export const RebalancerConfigSchema = z
.object({
warpRouteId: z.string(),
- strategy: StrategyConfigSchema,
+ strategy: RebalancerStrategySchema,
})
.superRefine((config, ctx) => {
- const chainNames = new Set(Object.keys(config.strategy.chains));
- // Check each chain's overrides
- for (const [chainName, chainConfig] of Object.entries(
- config.strategy.chains,
- )) {
- if (chainConfig.override) {
- for (const overrideChainName of Object.keys(chainConfig.override)) {
- // Each override key must reference a valid chain
- if (!chainNames.has(overrideChainName)) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: `Chain '${chainName}' has an override for '${overrideChainName}', but '${overrideChainName}' is not defined in the config`,
- path: [
- 'strategy',
- 'chains',
- chainName,
- 'override',
- overrideChainName,
- ],
- });
- }
+ // CollateralDeficitStrategy must be first in composite if it is used
+ if (config.strategy.length > 1) {
+ const hasCollateralDeficit = config.strategy.some(
+ (s) =>
+ s.rebalanceStrategy === RebalancerStrategyOptions.CollateralDeficit,
+ );
+ const collateralDeficitFirst =
+ config.strategy[0].rebalanceStrategy ===
+ RebalancerStrategyOptions.CollateralDeficit;
- // Override shouldn't be self-referencing
- if (chainName === overrideChainName) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: `Chain '${chainName}' has an override for '${chainName}', but '${chainName}' is self-referencing`,
- path: [
- 'strategy',
- 'chains',
- chainName,
- 'override',
- overrideChainName,
- ],
- });
- }
- }
+ if (hasCollateralDeficit && !collateralDeficitFirst) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ 'CollateralDeficitStrategy must be first when used in composite strategy',
+ path: ['strategy'],
+ });
}
}
- if (
- config.strategy.rebalanceStrategy === RebalancerStrategyOptions.MinAmount
+ // Validate each strategy in the array
+ for (
+ let strategyIndex = 0;
+ strategyIndex < config.strategy.length;
+ strategyIndex++
) {
- const minAmountChainsTypes = Object.values(config.strategy.chains).map(
- (c) => c.minAmount.type,
- );
- if (new Set(minAmountChainsTypes).size > 1) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: `All chains must use the same minAmount type.`,
- path: ['strategy', 'chains'],
- });
+ const strategy = config.strategy[strategyIndex];
+ const chainNames = new Set(Object.keys(strategy.chains));
+
+ // Check each chain's overrides
+ for (const [chainName, chainConfig] of Object.entries(strategy.chains)) {
+ if ('override' in chainConfig && chainConfig.override) {
+ for (const overrideChainName of Object.keys(chainConfig.override)) {
+ // Each override key must reference a valid chain
+ if (!chainNames.has(overrideChainName)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Chain '${chainName}' has an override for '${overrideChainName}', but '${overrideChainName}' is not defined in the config`,
+ path: [
+ 'strategy',
+ strategyIndex,
+ 'chains',
+ chainName,
+ 'override',
+ overrideChainName,
+ ],
+ });
+ }
+
+ // Override shouldn't be self-referencing
+ if (chainName === overrideChainName) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Chain '${chainName}' has an override for '${chainName}', but '${chainName}' is self-referencing`,
+ path: [
+ 'strategy',
+ strategyIndex,
+ 'chains',
+ chainName,
+ 'override',
+ overrideChainName,
+ ],
+ });
+ }
+ }
+ }
+ }
+
+ if (strategy.rebalanceStrategy === RebalancerStrategyOptions.MinAmount) {
+ const minAmountChainsTypes = Object.values(strategy.chains).map(
+ (c) => c.minAmount.type,
+ );
+ if (new Set(minAmountChainsTypes).size > 1) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `All chains must use the same minAmount type.`,
+ path: ['strategy', strategyIndex, 'chains'],
+ });
+ }
}
}
});
@@ -149,8 +199,65 @@ export type RebalancerWeightedChainConfig = z.infer<
export type RebalancerMinAmountChainConfig = z.infer<
typeof RebalancerMinAmountConfigSchema
>;
+export type CollateralDeficitChainConfig = z.infer<
+ typeof CollateralDeficitChainConfigSchema
+>;
export type StrategyConfig = z.infer;
export type RebalancerConfig = z.infer;
export type RebalancerConfigFileInput = z.input;
+
+/**
+ * Get all unique chain names from strategy config array.
+ */
+export function getStrategyChainNames(strategies: StrategyConfig[]): string[] {
+ const chainSet = new Set();
+ for (const strategy of strategies) {
+ Object.keys(strategy.chains).forEach((chain) => chainSet.add(chain));
+ }
+ return Array.from(chainSet);
+}
+
+/**
+ * Get chain config from the first strategy that has it.
+ * Returns undefined if no strategy has the chain.
+ */
+export function getStrategyChainConfig(
+ strategies: StrategyConfig[],
+ chainName: string,
+): StrategyConfig['chains'][string] | undefined {
+ for (const strategy of strategies) {
+ if (chainName in strategy.chains) {
+ return strategy.chains[chainName];
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Get all unique bridge addresses from all strategies and their overrides.
+ * This is used by ActionTracker to detect inflight rebalances across all configured bridges.
+ */
+export function getAllBridges(strategies: StrategyConfig[]): string[] {
+ const bridges = new Set();
+
+ for (const strategy of strategies) {
+ for (const chainConfig of Object.values(strategy.chains)) {
+ if (chainConfig.bridge) {
+ bridges.add(chainConfig.bridge);
+ }
+
+ if (chainConfig.override) {
+ for (const overrideConfig of Object.values(chainConfig.override)) {
+ const override = overrideConfig as { bridge?: string };
+ if (override.bridge) {
+ bridges.add(override.bridge);
+ }
+ }
+ }
+ }
+ }
+
+ return Array.from(bridges);
+}
diff --git a/typescript/rebalancer/src/core/Rebalancer.test.ts b/typescript/rebalancer/src/core/Rebalancer.test.ts
new file mode 100644
index 00000000000..bc4af2bf3e6
--- /dev/null
+++ b/typescript/rebalancer/src/core/Rebalancer.test.ts
@@ -0,0 +1,632 @@
+import chai, { expect } from 'chai';
+import chaiAsPromised from 'chai-as-promised';
+import { ethers } from 'ethers';
+import { pino } from 'pino';
+import Sinon from 'sinon';
+
+import { HyperlaneCore } from '@hyperlane-xyz/sdk';
+
+import {
+ buildTestRebalanceRoute,
+ createRebalancerTestContext,
+} from '../test/helpers.js';
+
+import { Rebalancer } from './Rebalancer.js';
+
+chai.use(chaiAsPromised);
+
+const testLogger = pino({ level: 'silent' });
+
+describe('Rebalancer', () => {
+ let sandbox: Sinon.SinonSandbox;
+
+ beforeEach(() => {
+ sandbox = Sinon.createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ describe('rebalance()', () => {
+ it('should return empty array for empty routes', async () => {
+ const ctx = createRebalancerTestContext();
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const results = await rebalancer.rebalance([]);
+
+ expect(results).to.deep.equal([]);
+ });
+
+ it('should return success result for single valid route', async () => {
+ const ctx = createRebalancerTestContext();
+
+ sandbox.stub(HyperlaneCore, 'getDispatchedMessages').returns([
+ {
+ id: '0x1111111111111111111111111111111111111111111111111111111111111111',
+ } as any,
+ ]);
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ });
+
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].success).to.be.true;
+ expect(results[0].route).to.deep.equal(route);
+ });
+
+ it('should return failure results for routes that fail preparation', async () => {
+ const ctx = createRebalancerTestContext(['ethereum', 'arbitrum'], {
+ ethereum: { isRebalancer: false },
+ });
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute();
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].success).to.be.false;
+ expect(results[0].route).to.deep.equal(route);
+ });
+
+ it('should handle mixed success and failure results', async () => {
+ const ctx = createRebalancerTestContext(
+ ['ethereum', 'arbitrum', 'optimism'],
+ {
+ ethereum: { isRebalancer: true },
+ optimism: { isRebalancer: false },
+ },
+ );
+
+ sandbox.stub(HyperlaneCore, 'getDispatchedMessages').returns([
+ {
+ id: '0xMessageId111111111111111111111111111111111111111111111111111111',
+ } as any,
+ ]);
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ {
+ ...ctx.chainMetadata,
+ optimism: {
+ ...ctx.chainMetadata.ethereum,
+ name: 'optimism',
+ domainId: 10,
+ } as any,
+ },
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const routes = [
+ buildTestRebalanceRoute({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ }),
+ buildTestRebalanceRoute({
+ origin: 'optimism',
+ destination: 'arbitrum',
+ }),
+ ];
+
+ const results = await rebalancer.rebalance(routes);
+
+ expect(results).to.have.lengthOf(2);
+ const successResults = results.filter((r) => r.success);
+ const failureResults = results.filter((r) => !r.success);
+ expect(successResults).to.have.lengthOf(1);
+ expect(failureResults).to.have.lengthOf(1);
+ });
+ });
+
+ describe('validateRoute()', () => {
+ it('should fail when origin token not found', async () => {
+ const ctx = createRebalancerTestContext(['arbitrum']);
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ });
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].success).to.be.false;
+ expect(results[0].error).to.include('null');
+ });
+
+ it('should fail when destination token not found', async () => {
+ const ctx = createRebalancerTestContext(['ethereum']);
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ });
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].success).to.be.false;
+ });
+
+ it('should fail when signer is not a rebalancer', async () => {
+ const ctx = createRebalancerTestContext(['ethereum', 'arbitrum'], {
+ ethereum: { isRebalancer: false },
+ });
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute();
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].success).to.be.false;
+ });
+
+ it('should fail when destination is not in allowed list', async () => {
+ const ctx = createRebalancerTestContext(['ethereum', 'arbitrum'], {
+ ethereum: {
+ allowedDestination: '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF',
+ },
+ });
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute();
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].success).to.be.false;
+ });
+
+ it('should fail when bridge is not allowed', async () => {
+ const ctx = createRebalancerTestContext(['ethereum', 'arbitrum'], {
+ ethereum: { isBridgeAllowed: false },
+ });
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute();
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].success).to.be.false;
+ });
+ });
+
+ describe('prepareTransactions()', () => {
+ it('should create failure result when quote fetching throws', async () => {
+ const ctx = createRebalancerTestContext(['ethereum', 'arbitrum'], {
+ ethereum: { throwOnQuotes: new Error('Quote fetch failed') },
+ });
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute();
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].success).to.be.false;
+ });
+
+ it('should create failure result when tx population throws', async () => {
+ const ctx = createRebalancerTestContext(['ethereum', 'arbitrum'], {
+ ethereum: { throwOnPopulate: new Error('Populate failed') },
+ });
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute();
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].success).to.be.false;
+ });
+ });
+
+ describe('executeTransactions()', () => {
+ it('should create failure result when gas estimation fails', async () => {
+ const ctx = createRebalancerTestContext(['ethereum', 'arbitrum']);
+ ctx.multiProvider.estimateGas = Sinon.stub().rejects(
+ new Error('Gas estimation failed'),
+ );
+
+ sandbox.stub(HyperlaneCore, 'getDispatchedMessages').returns([
+ {
+ id: '0xMessageId111111111111111111111111111111111111111111111111111111',
+ } as any,
+ ]);
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute();
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].success).to.be.false;
+ expect(results[0].error).to.include('Gas estimation failed');
+ });
+
+ it('should continue with other routes when one fails gas estimation', async () => {
+ const ctx = createRebalancerTestContext([
+ 'ethereum',
+ 'arbitrum',
+ 'optimism',
+ ]);
+
+ let callCount = 0;
+ ctx.multiProvider.estimateGas = Sinon.stub().callsFake(() => {
+ callCount++;
+ if (callCount === 1) {
+ return Promise.reject(new Error('Gas estimation failed'));
+ }
+ return Promise.resolve(ethers.BigNumber.from(100000));
+ });
+
+ sandbox.stub(HyperlaneCore, 'getDispatchedMessages').returns([
+ {
+ id: '0xMessageId111111111111111111111111111111111111111111111111111111',
+ } as any,
+ ]);
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ {
+ ...ctx.chainMetadata,
+ optimism: {
+ ...ctx.chainMetadata.ethereum,
+ name: 'optimism',
+ domainId: 10,
+ } as any,
+ },
+ { ...ctx.tokensByChainName, optimism: ctx.tokensByChainName.ethereum },
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const routes = [
+ buildTestRebalanceRoute({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ }),
+ buildTestRebalanceRoute({
+ origin: 'optimism',
+ destination: 'arbitrum',
+ }),
+ ];
+
+ const results = await rebalancer.rebalance(routes);
+
+ expect(results).to.have.lengthOf(2);
+ const failures = results.filter((r) => !r.success);
+ const successes = results.filter((r) => r.success);
+ expect(failures).to.have.lengthOf(1);
+ expect(successes).to.have.lengthOf(1);
+ });
+
+ it('should group transactions by origin chain', async () => {
+ const ctx = createRebalancerTestContext([
+ 'ethereum',
+ 'arbitrum',
+ 'optimism',
+ ]);
+
+ let sendCallCount = 0;
+ (ctx.multiProvider.sendTransaction as Sinon.SinonStub).callsFake(() => {
+ sendCallCount++;
+ return Promise.resolve({
+ transactionHash: `0x${sendCallCount.toString().padStart(64, '0')}`,
+ blockNumber: 100,
+ status: 1,
+ });
+ });
+
+ sandbox.stub(HyperlaneCore, 'getDispatchedMessages').returns([
+ {
+ id: '0x1111111111111111111111111111111111111111111111111111111111111111',
+ } as any,
+ ]);
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const routes = [
+ buildTestRebalanceRoute({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ }),
+ buildTestRebalanceRoute({
+ origin: 'ethereum',
+ destination: 'optimism',
+ }),
+ buildTestRebalanceRoute({
+ origin: 'optimism',
+ destination: 'arbitrum',
+ }),
+ ];
+
+ await rebalancer.rebalance(routes);
+
+ expect(sendCallCount).to.equal(3);
+ });
+ });
+
+ describe('sendTransactionsForChain()', () => {
+ it('should return error result when send fails', async () => {
+ const ctx = createRebalancerTestContext(['ethereum', 'arbitrum']);
+ ctx.multiProvider.sendTransaction = Sinon.stub().rejects(
+ new Error('Send failed'),
+ );
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute();
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].success).to.be.false;
+ expect(results[0].error).to.include('Send failed');
+ });
+
+ it('should continue sending remaining transactions after one fails', async () => {
+ const ctx = createRebalancerTestContext(['ethereum', 'arbitrum']);
+
+ let callCount = 0;
+ ctx.multiProvider.sendTransaction = Sinon.stub().callsFake(() => {
+ callCount++;
+ if (callCount === 1) {
+ return Promise.reject(new Error('First send failed'));
+ }
+ return Promise.resolve({
+ transactionHash:
+ '0xTxHash2222222222222222222222222222222222222222222222222222222222',
+ blockNumber: 100,
+ status: 1,
+ });
+ });
+
+ sandbox.stub(HyperlaneCore, 'getDispatchedMessages').returns([
+ {
+ id: '0xMessageId111111111111111111111111111111111111111111111111111111',
+ } as any,
+ ]);
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const routes = [
+ buildTestRebalanceRoute({
+ amount: ethers.utils.parseEther('100').toBigInt(),
+ }),
+ buildTestRebalanceRoute({
+ amount: ethers.utils.parseEther('200').toBigInt(),
+ }),
+ ];
+
+ const results = await rebalancer.rebalance(routes);
+
+ expect(results).to.have.lengthOf(2);
+ expect(results.filter((r) => !r.success)).to.have.lengthOf(1);
+ expect(results.filter((r) => r.success)).to.have.lengthOf(1);
+ });
+
+ it('should send transactions sequentially within same origin chain', async () => {
+ const ctx = createRebalancerTestContext([
+ 'ethereum',
+ 'arbitrum',
+ 'optimism',
+ ]);
+
+ const callOrder: string[] = [];
+ ctx.multiProvider.sendTransaction = Sinon.stub().callsFake(
+ async (chain: string) => {
+ callOrder.push(chain);
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ return {
+ transactionHash: `0x${callOrder.length.toString().padStart(64, '0')}`,
+ blockNumber: 100,
+ status: 1,
+ };
+ },
+ );
+
+ sandbox.stub(HyperlaneCore, 'getDispatchedMessages').returns([
+ {
+ id: '0x1111111111111111111111111111111111111111111111111111111111111111',
+ } as any,
+ ]);
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const routes = [
+ buildTestRebalanceRoute({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: ethers.utils.parseEther('100').toBigInt(),
+ }),
+ buildTestRebalanceRoute({
+ origin: 'ethereum',
+ destination: 'optimism',
+ amount: ethers.utils.parseEther('200').toBigInt(),
+ }),
+ ];
+
+ await rebalancer.rebalance(routes);
+
+ expect(callOrder).to.deep.equal(['ethereum', 'ethereum']);
+ });
+ });
+
+ describe('result building', () => {
+ it('should include messageId when dispatch message found', async () => {
+ const ctx = createRebalancerTestContext(['ethereum', 'arbitrum']);
+
+ const expectedMessageId =
+ '0xMessageId111111111111111111111111111111111111111111111111111111';
+ sandbox
+ .stub(HyperlaneCore, 'getDispatchedMessages')
+ .returns([{ id: expectedMessageId } as any]);
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute();
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].success).to.be.true;
+ expect(results[0].messageId).to.equal(expectedMessageId);
+ });
+
+ it('should return success: false when no Dispatch event found', async () => {
+ const ctx = createRebalancerTestContext(['ethereum', 'arbitrum']);
+
+ sandbox.stub(HyperlaneCore, 'getDispatchedMessages').returns([]);
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute();
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].success).to.be.false;
+ expect(results[0].error).to.include('no Dispatch event found');
+ expect(results[0].messageId).to.be.undefined;
+ });
+
+ it('should include txHash in result', async () => {
+ const ctx = createRebalancerTestContext(['ethereum', 'arbitrum']);
+
+ const expectedTxHash =
+ '0x1111111111111111111111111111111111111111111111111111111111111111';
+ sandbox.stub(HyperlaneCore, 'getDispatchedMessages').returns([
+ {
+ id: '0x2222222222222222222222222222222222222222222222222222222222222222',
+ } as any,
+ ]);
+
+ const rebalancer = new Rebalancer(
+ ctx.warpCore,
+ ctx.chainMetadata,
+ ctx.tokensByChainName,
+ ctx.multiProvider as any,
+ testLogger,
+ );
+
+ const route = buildTestRebalanceRoute();
+ const results = await rebalancer.rebalance([route]);
+
+ expect(results).to.have.lengthOf(1);
+ expect(results[0].txHash).to.equal(expectedTxHash);
+ });
+ });
+});
diff --git a/typescript/rebalancer/src/core/Rebalancer.ts b/typescript/rebalancer/src/core/Rebalancer.ts
index ab5b4922641..e982f09371d 100644
--- a/typescript/rebalancer/src/core/Rebalancer.ts
+++ b/typescript/rebalancer/src/core/Rebalancer.ts
@@ -1,37 +1,31 @@
-import { type PopulatedTransaction } from 'ethers';
+import { type PopulatedTransaction, type providers } from 'ethers';
import { type Logger } from 'pino';
import {
type ChainMap,
type ChainMetadata,
+ type ChainName,
+ type EthJsonRpcBlockParameterTag,
EvmMovableCollateralAdapter,
+ HyperlaneCore,
type InterchainGasQuote,
type MultiProvider,
type Token,
type WarpCore,
} from '@hyperlane-xyz/sdk';
-import {
- eqAddress,
- isNullish,
- mapAllSettled,
- toWei,
-} from '@hyperlane-xyz/utils';
+import { eqAddress, isNullish, mapAllSettled } from '@hyperlane-xyz/utils';
import type {
IRebalancer,
PreparedTransaction,
+ RebalanceExecutionResult,
+ RebalanceRoute,
} from '../interfaces/IRebalancer.js';
-import type { RebalancingRoute } from '../interfaces/IStrategy.js';
import { type Metrics } from '../metrics/Metrics.js';
-import {
- type BridgeConfigWithOverride,
- getBridgeConfig,
-} from '../utils/index.js';
export class Rebalancer implements IRebalancer {
private readonly logger: Logger;
constructor(
- private readonly bridges: ChainMap,
private readonly warpCore: WarpCore,
private readonly chainMetadata: ChainMap,
private readonly tokensByChainName: ChainMap,
@@ -42,65 +36,58 @@ export class Rebalancer implements IRebalancer {
this.logger = logger.child({ class: Rebalancer.name });
}
- async rebalance(routes: RebalancingRoute[]): Promise {
+ async rebalance(
+ routes: RebalanceRoute[],
+ ): Promise {
if (routes.length === 0) {
this.logger.info('No routes to execute, exiting');
- return;
+ return [];
}
this.logger.info({ numberOfRoutes: routes.length }, 'Rebalance initiated');
- const { preparedTransactions, preparationFailures } =
+ const { preparedTransactions, preparationFailureResults } =
await this.prepareTransactions(routes);
- let gasEstimationFailures = 0;
- let transactionFailures = 0;
- let successfulTransactions: PreparedTransaction[] = [];
+ let executionResults: RebalanceExecutionResult[] = [];
if (preparedTransactions.length > 0) {
- const filteredTransactions =
- this.filterTransactions(preparedTransactions);
- if (filteredTransactions.length > 0) {
- ({
- gasEstimationFailures,
- transactionFailures,
- successfulTransactions,
- } = await this.executeTransactions(filteredTransactions));
+ executionResults = await this.executeTransactions(preparedTransactions);
+ }
+
+ // Combine preparation failures with execution results
+ const allResults = [...preparationFailureResults, ...executionResults];
+
+ // Record metrics for successful transactions
+ const successfulResults = allResults.filter((r) => r.success);
+ if (this.metrics && successfulResults.length > 0) {
+ for (const result of successfulResults) {
+ const token = this.tokensByChainName[result.route.origin];
+ if (token) {
+ this.metrics.recordRebalanceAmount(
+ result.route,
+ token.amount(result.route.amount),
+ );
+ }
}
}
- if (
- preparationFailures > 0 ||
- gasEstimationFailures > 0 ||
- transactionFailures > 0
- ) {
+ const failures = allResults.filter((r) => !r.success);
+ if (failures.length > 0) {
this.logger.error(
- {
- preparationFailures,
- gasEstimationFailures,
- transactionFailures,
- },
- 'A rebalance stage failed.',
+ { failureCount: failures.length, totalRoutes: routes.length },
+ 'Some rebalance operations failed.',
);
- throw new Error('❌ Some rebalance transaction failed');
- }
-
- if (this.metrics && successfulTransactions.length > 0) {
- for (const transaction of successfulTransactions) {
- this.metrics.recordRebalanceAmount(
- transaction.route,
- transaction.originTokenAmount,
- );
- }
+ } else {
+ this.logger.info('✅ Rebalance successful');
}
- this.logger.info('✅ Rebalance successful');
- return;
+ return allResults;
}
- private async prepareTransactions(routes: RebalancingRoute[]): Promise<{
+ private async prepareTransactions(routes: RebalanceRoute[]): Promise<{
preparedTransactions: PreparedTransaction[];
- preparationFailures: number;
+ preparationFailureResults: RebalanceExecutionResult[];
}> {
this.logger.info(
{ numRoutes: routes.length },
@@ -116,15 +103,32 @@ export class Rebalancer implements IRebalancer {
const preparedTransactions = Array.from(fulfilled.values()).filter(
(tx): tx is PreparedTransaction => !isNullish(tx),
);
- // Count rejections + null results as failures
- const preparationFailures =
- rejected.size + (fulfilled.size - preparedTransactions.length);
- return { preparedTransactions, preparationFailures };
+ // Create failure results for tracking
+ const preparationFailureResults: RebalanceExecutionResult[] = [];
+ for (const [i, error] of rejected) {
+ preparationFailureResults.push({
+ route: routes[i],
+ success: false,
+ error: String(error),
+ });
+ }
+ // Also track null results (validation failures)
+ Array.from(fulfilled.entries()).forEach(([i, tx]) => {
+ if (isNullish(tx)) {
+ preparationFailureResults.push({
+ route: routes[i],
+ success: false,
+ error: 'Preparation returned null',
+ });
+ }
+ });
+
+ return { preparedTransactions, preparationFailureResults };
}
private async prepareTransaction(
- route: RebalancingRoute,
+ route: RebalanceRoute,
): Promise {
const { origin, destination, amount } = route;
@@ -153,12 +157,8 @@ export class Rebalancer implements IRebalancer {
const originHypAdapter = originToken.getHypAdapter(
this.warpCore.multiProvider,
) as EvmMovableCollateralAdapter;
- const { bridge, bridgeIsWarp } = getBridgeConfig(
- this.bridges,
- origin,
- destination,
- this.logger,
- );
+
+ const { bridge } = route;
// 2. Get quotes
let quotes: InterchainGasQuote[];
@@ -168,7 +168,6 @@ export class Rebalancer implements IRebalancer {
destinationChainMeta.domainId,
destinationToken.addressOrDenom,
amount,
- bridgeIsWarp,
);
} catch (error) {
this.logger.error(
@@ -210,7 +209,7 @@ export class Rebalancer implements IRebalancer {
return { populatedTx, route, originTokenAmount };
}
- private async validateRoute(route: RebalancingRoute): Promise {
+ private async validateRoute(route: RebalanceRoute): Promise {
const { origin, destination, amount } = route;
const originToken = this.tokensByChainName[origin];
const destinationToken = this.tokensByChainName[destination];
@@ -296,12 +295,8 @@ export class Rebalancer implements IRebalancer {
return false;
}
- const { bridge } = getBridgeConfig(
- this.bridges,
- origin,
- destination,
- this.logger,
- );
+ const { bridge } = route;
+
if (
!(await originHypAdapter.isBridgeAllowed(
destinationDomain.domainId,
@@ -327,149 +322,244 @@ export class Rebalancer implements IRebalancer {
private async executeTransactions(
transactions: PreparedTransaction[],
- ): Promise<{
- gasEstimationFailures: number;
- transactionFailures: number;
- successfulTransactions: PreparedTransaction[];
- }> {
+ ): Promise {
this.logger.info(
{ numTransactions: transactions.length },
'Estimating gas for all prepared transactions.',
);
- // 1. Estimate gas
- const { fulfilled, rejected } = await mapAllSettled(
- transactions,
- async (transaction) => {
+ const results: RebalanceExecutionResult[] = [];
+
+ // 1. Estimate gas for rebalance transactions
+ const gasEstimateResults = await Promise.allSettled(
+ transactions.map(async (transaction) => {
await this.multiProvider.estimateGas(
transaction.route.origin,
transaction.populatedTx,
);
return transaction;
- },
- (_, i) => i,
+ }),
);
- // 2. Filter out failed transactions and log errors
- const validTransactions = Array.from(fulfilled.values());
- const gasEstimationFailures = rejected.size;
- for (const [i, error] of rejected) {
- const failedTransaction = transactions[i];
- this.logger.error(
- {
- origin: failedTransaction.route.origin,
- destination: failedTransaction.route.destination,
- amount:
- failedTransaction.originTokenAmount.getDecimalFormattedAmount(),
- tokenName: failedTransaction.originTokenAmount.token.name,
- error,
- },
- 'Gas estimation failed for route.',
- );
- }
+ // 2. Filter out failed transactions and track failures
+ const validTransactions: PreparedTransaction[] = [];
+ gasEstimateResults.forEach((result, i) => {
+ if (result.status === 'fulfilled') {
+ validTransactions.push(result.value);
+ } else {
+ const failedTransaction = transactions[i];
+ this.logger.error(
+ {
+ origin: failedTransaction.route.origin,
+ destination: failedTransaction.route.destination,
+ amount:
+ failedTransaction.originTokenAmount.getDecimalFormattedAmount(),
+ tokenName: failedTransaction.originTokenAmount.token.name,
+ error: result.reason,
+ },
+ 'Gas estimation failed for route.',
+ );
+ results.push({
+ route: failedTransaction.route,
+ success: false,
+ error: `Gas estimation failed: ${String(result.reason)}`,
+ });
+ }
+ });
if (validTransactions.length === 0) {
this.logger.info('No transactions to execute after gas estimation.');
- return {
- gasEstimationFailures,
- transactionFailures: 0,
- successfulTransactions: [],
- };
+ return results;
}
- // 2. Send transactions
+ // 3. Group transactions by origin chain
+ const txsByOrigin = new Map();
+ for (const tx of validTransactions) {
+ const origin = tx.route.origin;
+ if (!txsByOrigin.has(origin)) {
+ txsByOrigin.set(origin, []);
+ }
+ txsByOrigin.get(origin)!.push(tx);
+ }
+
+ // 4. Send transactions - parallel across chains, sequential within each chain
this.logger.info(
- { numTransactions: validTransactions.length },
- 'Sending valid transactions.',
+ {
+ numChains: txsByOrigin.size,
+ numTransactions: validTransactions.length,
+ },
+ 'Sending transactions (parallel across chains, sequential within chain).',
+ );
+
+ const chainSendResults = await Promise.allSettled(
+ Array.from(txsByOrigin.entries()).map(([origin, txs]) =>
+ this.sendTransactionsForChain(origin, txs),
+ ),
);
- let transactionFailures = 0;
- const successfulTransactions: PreparedTransaction[] = [];
- for (const transaction of validTransactions) {
+
+ // 5. Collect successful sends and record send failures
+ const successfulSends: Array<{
+ transaction: PreparedTransaction;
+ receipt: providers.TransactionReceipt;
+ }> = [];
+
+ chainSendResults.forEach((chainResult) => {
+ if (chainResult.status === 'fulfilled') {
+ for (const txResult of chainResult.value) {
+ if ('receipt' in txResult) {
+ successfulSends.push(txResult);
+ } else {
+ results.push({
+ route: txResult.transaction.route,
+ success: false,
+ error: `Transaction send failed: ${txResult.error}`,
+ });
+ this.metrics?.recordActionAttempt(
+ txResult.transaction.route,
+ false,
+ );
+ }
+ }
+ } else {
+ // This shouldn't happen since sendTransactionsForChain catches errors internally,
+ // but handle it just in case
+ this.logger.error(
+ { error: chainResult.reason },
+ 'Unexpected error during chain transaction sending.',
+ );
+ }
+ });
+
+ // 6. Build results from confirmed receipts
+ for (const { transaction, receipt } of successfulSends) {
+ const result = this.buildResult(transaction, receipt);
+ results.push(result);
+ this.metrics?.recordActionAttempt(result.route, result.success);
+ }
+
+ return results;
+ }
+
+ // === Parallel Transaction Sending Methods ===
+
+ /**
+ * Send all transactions for a single origin chain sequentially.
+ * Sequential sending is required to avoid nonce contention when using the same signing key.
+ */
+ private async sendTransactionsForChain(
+ origin: ChainName,
+ transactions: PreparedTransaction[],
+ ): Promise<
+ Array<
+ | {
+ transaction: PreparedTransaction;
+ receipt: providers.TransactionReceipt;
+ }
+ | { transaction: PreparedTransaction; error: string }
+ >
+ > {
+ const results: Array<
+ | {
+ transaction: PreparedTransaction;
+ receipt: providers.TransactionReceipt;
+ }
+ | { transaction: PreparedTransaction; error: string }
+ > = [];
+
+ // Send sequentially to avoid nonce contention
+ for (const transaction of transactions) {
try {
- const { origin, destination } = transaction.route;
const decimalFormattedAmount =
transaction.originTokenAmount.getDecimalFormattedAmount();
const tokenName = transaction.originTokenAmount.token.name;
+
+ const reorgPeriod = this.getReorgPeriod(origin);
+
this.logger.info(
{
origin,
- destination,
+ destination: transaction.route.destination,
amount: decimalFormattedAmount,
tokenName,
+ reorgPeriod,
},
- 'Sending transaction for route.',
+ 'Sending rebalance transaction and waiting for reorgPeriod confirmations.',
);
+
const receipt = await this.multiProvider.sendTransaction(
origin,
transaction.populatedTx,
+ {
+ waitConfirmations: reorgPeriod as
+ | number
+ | EthJsonRpcBlockParameterTag,
+ },
);
+
this.logger.info(
{
origin,
- destination,
+ destination: transaction.route.destination,
amount: decimalFormattedAmount,
tokenName,
txHash: receipt.transactionHash,
},
- 'Transaction confirmed for route.',
+ 'Rebalance transaction confirmed at reorgPeriod depth.',
);
- successfulTransactions.push(transaction);
+
+ results.push({ transaction, receipt });
} catch (error) {
- transactionFailures++;
this.logger.error(
{
- origin: transaction.route.origin,
+ origin,
destination: transaction.route.destination,
amount: transaction.originTokenAmount.getDecimalFormattedAmount(),
tokenName: transaction.originTokenAmount.token.name,
error,
},
- 'Transaction failed for route.',
+ 'Transaction send failed for route.',
);
+ results.push({ transaction, error: String(error) });
}
}
- return {
- gasEstimationFailures,
- transactionFailures,
- successfulTransactions,
- };
+ return results;
}
- private filterTransactions(
- transactions: PreparedTransaction[],
- ): PreparedTransaction[] {
- const filteredTransactions: PreparedTransaction[] = [];
- for (const transaction of transactions) {
- const { origin, destination, amount } = transaction.route;
- const originToken = this.tokensByChainName[origin];
- const decimalFormattedAmount =
- transaction.originTokenAmount.getDecimalFormattedAmount();
-
- // minimum amount check
- const { bridgeMinAcceptedAmount } = getBridgeConfig(
- this.bridges,
- origin,
- destination,
- this.logger,
- );
- const minAccepted = BigInt(
- toWei(bridgeMinAcceptedAmount, originToken.decimals),
+ /**
+ * Build the execution result from a confirmed transaction receipt.
+ * Receipt is already confirmed at reorgPeriod depth from sendTransaction.
+ */
+ private buildResult(
+ transaction: PreparedTransaction,
+ receipt: providers.TransactionReceipt,
+ ): RebalanceExecutionResult {
+ const { origin, destination } = transaction.route;
+ const dispatchedMessages = HyperlaneCore.getDispatchedMessages(receipt);
+
+ if (dispatchedMessages.length === 0) {
+ this.logger.error(
+ { origin, destination, txHash: receipt.transactionHash },
+ 'No Dispatch event found in confirmed rebalance receipt',
);
- if (minAccepted > amount) {
- this.logger.info(
- {
- origin,
- destination,
- amount: decimalFormattedAmount,
- tokenName: originToken.name,
- },
- 'Route skipped due to minimum threshold amount not met.',
- );
- continue;
- }
- filteredTransactions.push(transaction);
+ return {
+ route: transaction.route,
+ success: false,
+ error: `Transaction confirmed but no Dispatch event found`,
+ txHash: receipt.transactionHash,
+ };
}
- return filteredTransactions;
+
+ return {
+ route: transaction.route,
+ success: true,
+ messageId: dispatchedMessages[0].id,
+ txHash: receipt.transactionHash,
+ };
+ }
+
+ private getReorgPeriod(chainName: string): number | string {
+ const metadata = this.multiProvider.getChainMetadata(chainName);
+ return metadata.blocks?.reorgPeriod ?? 32;
}
}
diff --git a/typescript/rebalancer/src/core/RebalancerService.test.ts b/typescript/rebalancer/src/core/RebalancerService.test.ts
new file mode 100644
index 00000000000..bc50a52bcf9
--- /dev/null
+++ b/typescript/rebalancer/src/core/RebalancerService.test.ts
@@ -0,0 +1,1144 @@
+import chai, { expect } from 'chai';
+import chaiAsPromised from 'chai-as-promised';
+import { pino } from 'pino';
+import Sinon from 'sinon';
+
+import type { MultiProvider, Token, WarpCore } from '@hyperlane-xyz/sdk';
+
+import type { RebalancerConfig } from '../config/RebalancerConfig.js';
+import { RebalancerStrategyOptions } from '../config/types.js';
+import { RebalancerContextFactory } from '../factories/RebalancerContextFactory.js';
+import { MonitorEventType } from '../interfaces/IMonitor.js';
+import type { IRebalancer } from '../interfaces/IRebalancer.js';
+import type { IStrategy } from '../interfaces/IStrategy.js';
+import { Metrics } from '../metrics/Metrics.js';
+import { Monitor } from '../monitor/Monitor.js';
+import { TEST_ADDRESSES, getTestAddress } from '../test/helpers.js';
+import type { IActionTracker } from '../tracking/index.js';
+import { InflightContextAdapter } from '../tracking/index.js';
+
+import {
+ RebalancerService,
+ type RebalancerServiceConfig,
+} from './RebalancerService.js';
+
+chai.use(chaiAsPromised);
+
+const testLogger = pino({ level: 'silent' });
+
+function createMockRebalancerConfig(): RebalancerConfig {
+ return {
+ warpRouteId: 'TEST/route',
+ strategyConfig: [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
+ chains: {
+ ethereum: {
+ bridge: TEST_ADDRESSES.bridge,
+ bridgeMinAcceptedAmount: 0,
+ weighted: { weight: 50n, tolerance: 10n },
+ },
+ arbitrum: {
+ bridge: TEST_ADDRESSES.bridge,
+ bridgeMinAcceptedAmount: 0,
+ weighted: { weight: 50n, tolerance: 10n },
+ },
+ },
+ },
+ ],
+ } as RebalancerConfig;
+}
+
+function createMockMultiProvider(): MultiProvider {
+ return {
+ getDomainId: Sinon.stub().callsFake((chain: string) => {
+ const domains: Record = { ethereum: 1, arbitrum: 42161 };
+ return domains[chain] ?? 0;
+ }),
+ getSigner: Sinon.stub().returns({
+ getAddress: Sinon.stub().resolves(TEST_ADDRESSES.signer),
+ }),
+ metadata: {
+ ethereum: { domainId: 1 },
+ arbitrum: { domainId: 42161 },
+ },
+ } as unknown as MultiProvider;
+}
+
+function createMockToken(chainName: string): Token {
+ return {
+ chainName,
+ name: `${chainName}Token`,
+ decimals: 18,
+ addressOrDenom: getTestAddress(chainName),
+ standard: 'EvmHypCollateral',
+ isCollateralized: () => true,
+ } as unknown as Token;
+}
+
+function createMockWarpCore(): WarpCore {
+ return {
+ tokens: [createMockToken('ethereum'), createMockToken('arbitrum')],
+ multiProvider: createMockMultiProvider(),
+ } as unknown as WarpCore;
+}
+
+function createMockRebalancer(): IRebalancer & { rebalance: Sinon.SinonStub } {
+ return {
+ rebalance: Sinon.stub().resolves([]),
+ };
+}
+
+function createMockStrategy(): IStrategy & {
+ getRebalancingRoutes: Sinon.SinonStub;
+} {
+ return {
+ name: 'mock-strategy',
+ getRebalancingRoutes: Sinon.stub().returns([]),
+ };
+}
+
+function createMockActionTracker(): IActionTracker {
+ return {
+ initialize: Sinon.stub().resolves(),
+ createRebalanceIntent: Sinon.stub().callsFake(async () => ({
+ id: `intent-${Date.now()}`,
+ status: 'not_started',
+ })),
+ createRebalanceAction: Sinon.stub().resolves(),
+ completeRebalanceAction: Sinon.stub().resolves(),
+ failRebalanceAction: Sinon.stub().resolves(),
+ completeRebalanceIntent: Sinon.stub().resolves(),
+ cancelRebalanceIntent: Sinon.stub().resolves(),
+ failRebalanceIntent: Sinon.stub().resolves(),
+ syncTransfers: Sinon.stub().resolves(),
+ syncRebalanceIntents: Sinon.stub().resolves(),
+ syncRebalanceActions: Sinon.stub().resolves(),
+ logStoreContents: Sinon.stub().resolves(),
+ getInProgressTransfers: Sinon.stub().resolves([]),
+ getActiveRebalanceIntents: Sinon.stub().resolves([]),
+ getTransfersByDestination: Sinon.stub().resolves([]),
+ getRebalanceIntentsByDestination: Sinon.stub().resolves([]),
+ };
+}
+
+function createMockInflightContextAdapter(): InflightContextAdapter & {
+ getInflightContext: Sinon.SinonStub;
+} {
+ return {
+ getInflightContext: Sinon.stub().resolves({
+ pendingRebalances: [],
+ pendingTransfers: [],
+ }),
+ } as unknown as InflightContextAdapter & {
+ getInflightContext: Sinon.SinonStub;
+ };
+}
+
+function createMockContextFactory(
+ overrides: {
+ warpCore?: WarpCore;
+ rebalancer?: IRebalancer;
+ strategy?: IStrategy;
+ actionTracker?: IActionTracker;
+ inflightAdapter?: InflightContextAdapter;
+ monitor?: Monitor;
+ metrics?: Metrics;
+ } = {},
+): RebalancerContextFactory {
+ const warpCore = overrides.warpCore ?? createMockWarpCore();
+ const rebalancer = overrides.rebalancer ?? createMockRebalancer();
+ const strategy = overrides.strategy ?? createMockStrategy();
+ const actionTracker = overrides.actionTracker ?? createMockActionTracker();
+ const inflightAdapter =
+ overrides.inflightAdapter ?? createMockInflightContextAdapter();
+ const monitor =
+ overrides.monitor ??
+ ({
+ on: Sinon.stub().returnsThis(),
+ start: Sinon.stub().resolves(),
+ stop: Sinon.stub().resolves(),
+ } as unknown as Monitor);
+
+ return {
+ getWarpCore: () => warpCore,
+ getTokenForChain: (chain: string) =>
+ warpCore.tokens.find((t) => t.chainName === chain),
+ createRebalancer: () => rebalancer,
+ createStrategy: async () => strategy,
+ createMonitor: () => monitor,
+ createMetrics: async () => overrides.metrics ?? ({} as Metrics),
+ createActionTracker: async () => ({
+ tracker: actionTracker,
+ adapter: inflightAdapter,
+ }),
+ } as unknown as RebalancerContextFactory;
+}
+
+interface DaemonTestSetup {
+ actionTracker: IActionTracker;
+ rebalancer: IRebalancer & { rebalance: Sinon.SinonStub };
+ strategy: IStrategy & { getRebalancingRoutes: Sinon.SinonStub };
+ triggerCycle: () => Promise;
+}
+
+async function setupDaemonTest(
+ sandbox: Sinon.SinonSandbox,
+ options: {
+ intentIds?: string[];
+ rebalanceResults: Array<{
+ route: {
+ origin: string;
+ destination: string;
+ amount: bigint;
+ intentId: string;
+ bridge: string;
+ };
+ success: boolean;
+ messageId?: string;
+ txHash?: string;
+ error?: string;
+ }>;
+ strategyRoutes: Array<{
+ origin: string;
+ destination: string;
+ amount: bigint;
+ bridge: string;
+ }>;
+ },
+): Promise {
+ const actionTracker = createMockActionTracker();
+ let intentIndex = 0;
+ (actionTracker.createRebalanceIntent as Sinon.SinonStub).callsFake(
+ async () => ({
+ id: options.intentIds?.[intentIndex] ?? `intent-${intentIndex + 1}`,
+ status: 'not_started' as const,
+ ...(intentIndex++, {}),
+ }),
+ );
+
+ const rebalancer = createMockRebalancer();
+ rebalancer.rebalance.resolves(options.rebalanceResults);
+
+ const strategy = createMockStrategy();
+ strategy.getRebalancingRoutes.returns(options.strategyRoutes);
+
+ const inflightAdapter = createMockInflightContextAdapter();
+
+ let tokenInfoHandler: ((event: any) => Promise) | undefined;
+ const monitor = {
+ on: Sinon.stub().callsFake((event: string, handler: any) => {
+ if (event === MonitorEventType.TokenInfo) {
+ tokenInfoHandler = handler;
+ }
+ return monitor;
+ }),
+ start: Sinon.stub().resolves(),
+ stop: Sinon.stub().resolves(),
+ } as unknown as Monitor;
+
+ const contextFactory = createMockContextFactory({
+ rebalancer,
+ strategy,
+ actionTracker,
+ inflightAdapter,
+ monitor,
+ });
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ { mode: 'daemon', checkFrequency: 60000, logger: testLogger },
+ );
+
+ await service.start();
+
+ return {
+ actionTracker,
+ rebalancer,
+ strategy,
+ triggerCycle: async () => {
+ expect(tokenInfoHandler).to.not.be.undefined;
+ await tokenInfoHandler!({
+ tokensInfo: [
+ { token: createMockToken('ethereum'), bridgedSupply: 5000n },
+ { token: createMockToken('arbitrum'), bridgedSupply: 5000n },
+ ],
+ });
+ },
+ };
+}
+
+describe('RebalancerService', () => {
+ let sandbox: Sinon.SinonSandbox;
+
+ beforeEach(() => {
+ sandbox = Sinon.createSandbox();
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ describe('executeManual()', () => {
+ it('should execute manual rebalance successfully', async () => {
+ const rebalancer = createMockRebalancer();
+ rebalancer.rebalance.resolves([
+ {
+ route: { origin: 'ethereum', destination: 'arbitrum', amount: 1000n },
+ success: true,
+ messageId:
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
+ txHash:
+ '0x2222222222222222222222222222222222222222222222222222222222222222',
+ },
+ ]);
+
+ const contextFactory = createMockContextFactory({ rebalancer });
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const config: RebalancerServiceConfig = {
+ mode: 'manual',
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await service.executeManual({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: '100',
+ });
+
+ expect(rebalancer.rebalance.calledOnce).to.be.true;
+ const calledRoutes = rebalancer.rebalance.firstCall.args[0];
+ expect(calledRoutes).to.have.lengthOf(1);
+ expect(calledRoutes[0].origin).to.equal('ethereum');
+ expect(calledRoutes[0].destination).to.equal('arbitrum');
+ });
+
+ it('should throw when origin token not found', async () => {
+ const warpCore = {
+ tokens: [createMockToken('arbitrum')],
+ multiProvider: createMockMultiProvider(),
+ } as unknown as WarpCore;
+
+ const contextFactory = createMockContextFactory({ warpCore });
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const config: RebalancerServiceConfig = {
+ mode: 'manual',
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await expect(
+ service.executeManual({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: '100',
+ }),
+ ).to.be.rejectedWith('Origin token not found');
+ });
+
+ it('should throw when amount is invalid', async () => {
+ const contextFactory = createMockContextFactory();
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const config: RebalancerServiceConfig = {
+ mode: 'manual',
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await expect(
+ service.executeManual({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 'invalid',
+ }),
+ ).to.be.rejectedWith('Amount must be a valid number');
+ });
+
+ it('should throw when amount is zero or negative', async () => {
+ const contextFactory = createMockContextFactory();
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const config: RebalancerServiceConfig = {
+ mode: 'manual',
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await expect(
+ service.executeManual({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: '0',
+ }),
+ ).to.be.rejectedWith('Amount must be greater than 0');
+
+ await expect(
+ service.executeManual({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: '-100',
+ }),
+ ).to.be.rejectedWith('Amount must be greater than 0');
+ });
+
+ it('should throw when origin chain has no bridge configured', async () => {
+ const contextFactory = createMockContextFactory();
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const configWithoutBridge: RebalancerConfig = {
+ warpRouteId: 'TEST/route',
+ strategyConfig: [
+ {
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
+ chains: {
+ arbitrum: {
+ bridge: TEST_ADDRESSES.bridge,
+ bridgeMinAcceptedAmount: 0,
+ weighted: { weight: 100n, tolerance: 10n },
+ },
+ },
+ },
+ ],
+ } as RebalancerConfig;
+
+ const config: RebalancerServiceConfig = {
+ mode: 'manual',
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ configWithoutBridge,
+ config,
+ );
+
+ await expect(
+ service.executeManual({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: '100',
+ }),
+ ).to.be.rejectedWith('No bridge configured for origin chain ethereum');
+ });
+
+ it('should throw when in monitorOnly mode', async () => {
+ const contextFactory = createMockContextFactory();
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const config: RebalancerServiceConfig = {
+ mode: 'manual',
+ monitorOnly: true,
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await expect(
+ service.executeManual({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: '100',
+ }),
+ ).to.be.rejectedWith('MonitorOnly mode cannot execute manual rebalances');
+ });
+
+ it('should propagate errors from rebalancer', async () => {
+ const rebalancer = createMockRebalancer();
+ rebalancer.rebalance.rejects(new Error('Rebalance failed'));
+
+ const contextFactory = createMockContextFactory({ rebalancer });
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const config: RebalancerServiceConfig = {
+ mode: 'manual',
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await expect(
+ service.executeManual({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: '100',
+ }),
+ ).to.be.rejectedWith('Rebalance failed');
+ });
+ });
+
+ describe('start()', () => {
+ it('should throw when not in daemon mode', async () => {
+ const config: RebalancerServiceConfig = {
+ mode: 'manual',
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await expect(service.start()).to.be.rejectedWith(
+ 'start() can only be called in daemon mode',
+ );
+ });
+
+ it('should start monitor in daemon mode', async () => {
+ const monitor = {
+ on: Sinon.stub().returnsThis(),
+ start: Sinon.stub().resolves(),
+ stop: Sinon.stub().resolves(),
+ } as unknown as Monitor;
+
+ const contextFactory = createMockContextFactory({ monitor });
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const config: RebalancerServiceConfig = {
+ mode: 'daemon',
+ checkFrequency: 60000,
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await service.start();
+
+ expect((monitor.on as Sinon.SinonStub).called).to.be.true;
+ expect((monitor.start as Sinon.SinonStub).calledOnce).to.be.true;
+ });
+ });
+
+ describe('stop()', () => {
+ it('should stop monitor', async () => {
+ const monitor = {
+ on: Sinon.stub().returnsThis(),
+ start: Sinon.stub().resolves(),
+ stop: Sinon.stub().resolves(),
+ } as unknown as Monitor;
+
+ const contextFactory = createMockContextFactory({ monitor });
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const config: RebalancerServiceConfig = {
+ mode: 'daemon',
+ checkFrequency: 60000,
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await service.start();
+ await service.stop();
+
+ expect((monitor.stop as Sinon.SinonStub).calledOnce).to.be.true;
+ });
+ });
+
+ describe('daemon mode metrics', () => {
+ it('should record failure metric when rebalance has failed results', async () => {
+ const rebalancer = createMockRebalancer();
+ rebalancer.rebalance.resolves([
+ {
+ route: {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ intentId: 'intent-1',
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ success: false,
+ error: 'Gas estimation failed',
+ },
+ ]);
+
+ const strategy = createMockStrategy();
+ strategy.getRebalancingRoutes.returns([
+ {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ ]);
+
+ const actionTracker = createMockActionTracker();
+ const inflightAdapter = createMockInflightContextAdapter();
+
+ const recordRebalancerSuccess = Sinon.stub();
+ const recordRebalancerFailure = Sinon.stub();
+ const metrics = {
+ recordRebalancerSuccess,
+ recordRebalancerFailure,
+ recordIntentCreated: Sinon.stub(),
+ processToken: Sinon.stub().resolves(),
+ } as unknown as Metrics;
+
+ let tokenInfoHandler: ((event: any) => Promise) | undefined;
+ const monitor = {
+ on: Sinon.stub().callsFake((event: string, handler: any) => {
+ if (event === MonitorEventType.TokenInfo) {
+ tokenInfoHandler = handler;
+ }
+ return monitor;
+ }),
+ start: Sinon.stub().resolves(),
+ stop: Sinon.stub().resolves(),
+ } as unknown as Monitor;
+
+ const contextFactory = createMockContextFactory({
+ rebalancer,
+ strategy,
+ actionTracker,
+ inflightAdapter,
+ monitor,
+ metrics,
+ });
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const config: RebalancerServiceConfig = {
+ mode: 'daemon',
+ checkFrequency: 60000,
+ withMetrics: true,
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await service.start();
+
+ expect(tokenInfoHandler).to.not.be.undefined;
+ await tokenInfoHandler!({
+ tokensInfo: [
+ { token: createMockToken('ethereum'), bridgedSupply: 5000n },
+ { token: createMockToken('arbitrum'), bridgedSupply: 5000n },
+ ],
+ });
+
+ expect(recordRebalancerFailure.calledOnce).to.be.true;
+ expect(recordRebalancerSuccess.called).to.be.false;
+ });
+
+ it('should record success metric when all rebalance results succeed', async () => {
+ const rebalancer = createMockRebalancer();
+ rebalancer.rebalance.resolves([
+ {
+ route: {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ intentId: 'intent-1',
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ success: true,
+ messageId:
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
+ txHash:
+ '0x2222222222222222222222222222222222222222222222222222222222222222',
+ },
+ ]);
+
+ const strategy = createMockStrategy();
+ strategy.getRebalancingRoutes.returns([
+ {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ ]);
+
+ const actionTracker = createMockActionTracker();
+ const inflightAdapter = createMockInflightContextAdapter();
+
+ const recordRebalancerSuccess = Sinon.stub();
+ const recordRebalancerFailure = Sinon.stub();
+ const metrics = {
+ recordRebalancerSuccess,
+ recordRebalancerFailure,
+ recordIntentCreated: Sinon.stub(),
+ processToken: Sinon.stub().resolves(),
+ } as unknown as Metrics;
+
+ let tokenInfoHandler: ((event: any) => Promise) | undefined;
+ const monitor = {
+ on: Sinon.stub().callsFake((event: string, handler: any) => {
+ if (event === MonitorEventType.TokenInfo) {
+ tokenInfoHandler = handler;
+ }
+ return monitor;
+ }),
+ start: Sinon.stub().resolves(),
+ stop: Sinon.stub().resolves(),
+ } as unknown as Monitor;
+
+ const contextFactory = createMockContextFactory({
+ rebalancer,
+ strategy,
+ actionTracker,
+ inflightAdapter,
+ monitor,
+ metrics,
+ });
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const config: RebalancerServiceConfig = {
+ mode: 'daemon',
+ checkFrequency: 60000,
+ withMetrics: true,
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await service.start();
+
+ expect(tokenInfoHandler).to.not.be.undefined;
+ await tokenInfoHandler!({
+ tokensInfo: [
+ { token: createMockToken('ethereum'), bridgedSupply: 5000n },
+ { token: createMockToken('arbitrum'), bridgedSupply: 5000n },
+ ],
+ });
+
+ expect(recordRebalancerSuccess.calledOnce).to.be.true;
+ expect(recordRebalancerFailure.called).to.be.false;
+ });
+
+ it('should record failure metric when rebalance has mixed results', async () => {
+ const rebalancer = createMockRebalancer();
+ rebalancer.rebalance.resolves([
+ {
+ route: {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ intentId: 'intent-1',
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ success: true,
+ messageId:
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
+ txHash:
+ '0x2222222222222222222222222222222222222222222222222222222222222222',
+ },
+ {
+ route: {
+ origin: 'arbitrum',
+ destination: 'ethereum',
+ amount: 500n,
+ intentId: 'intent-2',
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ success: false,
+ error: 'Insufficient balance',
+ },
+ ]);
+
+ const strategy = createMockStrategy();
+ strategy.getRebalancingRoutes.returns([
+ {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ {
+ origin: 'arbitrum',
+ destination: 'ethereum',
+ amount: 500n,
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ ]);
+
+ const actionTracker = createMockActionTracker();
+ const inflightAdapter = createMockInflightContextAdapter();
+
+ const recordRebalancerSuccess = Sinon.stub();
+ const recordRebalancerFailure = Sinon.stub();
+ const metrics = {
+ recordRebalancerSuccess,
+ recordRebalancerFailure,
+ recordIntentCreated: Sinon.stub(),
+ processToken: Sinon.stub().resolves(),
+ } as unknown as Metrics;
+
+ let tokenInfoHandler: ((event: any) => Promise) | undefined;
+ const monitor = {
+ on: Sinon.stub().callsFake((event: string, handler: any) => {
+ if (event === MonitorEventType.TokenInfo) {
+ tokenInfoHandler = handler;
+ }
+ return monitor;
+ }),
+ start: Sinon.stub().resolves(),
+ stop: Sinon.stub().resolves(),
+ } as unknown as Monitor;
+
+ const contextFactory = createMockContextFactory({
+ rebalancer,
+ strategy,
+ actionTracker,
+ inflightAdapter,
+ monitor,
+ metrics,
+ });
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const config: RebalancerServiceConfig = {
+ mode: 'daemon',
+ checkFrequency: 60000,
+ withMetrics: true,
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await service.start();
+
+ expect(tokenInfoHandler).to.not.be.undefined;
+ await tokenInfoHandler!({
+ tokensInfo: [
+ { token: createMockToken('ethereum'), bridgedSupply: 5000n },
+ { token: createMockToken('arbitrum'), bridgedSupply: 5000n },
+ ],
+ });
+
+ expect(recordRebalancerFailure.calledOnce).to.be.true;
+ expect(recordRebalancerSuccess.called).to.be.false;
+ });
+ });
+
+ describe('daemon mode intent tracking', () => {
+ it('should call failRebalanceIntent with correct intentId when route fails', async () => {
+ const { actionTracker, triggerCycle } = await setupDaemonTest(sandbox, {
+ intentIds: ['intent-123'],
+ rebalanceResults: [
+ {
+ route: {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ intentId: 'intent-123',
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ success: false,
+ error: 'Gas estimation failed',
+ },
+ ],
+ strategyRoutes: [
+ {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ ],
+ });
+
+ await triggerCycle();
+
+ expect((actionTracker.failRebalanceIntent as Sinon.SinonStub).calledOnce)
+ .to.be.true;
+ expect(
+ (actionTracker.failRebalanceIntent as Sinon.SinonStub).calledWith(
+ 'intent-123',
+ ),
+ ).to.be.true;
+ });
+
+ it('should call createRebalanceAction with correct intentId when route succeeds', async () => {
+ const { actionTracker, triggerCycle } = await setupDaemonTest(sandbox, {
+ intentIds: ['intent-456'],
+ rebalanceResults: [
+ {
+ route: {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ intentId: 'intent-456',
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ success: true,
+ messageId:
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
+ txHash:
+ '0x2222222222222222222222222222222222222222222222222222222222222222',
+ },
+ ],
+ strategyRoutes: [
+ {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ ],
+ });
+
+ await triggerCycle();
+
+ expect(
+ (actionTracker.createRebalanceAction as Sinon.SinonStub).calledOnce,
+ ).to.be.true;
+ const callArgs = (actionTracker.createRebalanceAction as Sinon.SinonStub)
+ .firstCall.args[0];
+ expect(callArgs.intentId).to.equal('intent-456');
+ });
+
+ it('should handle mixed success/failure results with correct intent mapping', async () => {
+ const { actionTracker, triggerCycle } = await setupDaemonTest(sandbox, {
+ intentIds: ['intent-1', 'intent-2'],
+ rebalanceResults: [
+ {
+ route: {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ intentId: 'intent-1',
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ success: true,
+ messageId:
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
+ txHash:
+ '0x2222222222222222222222222222222222222222222222222222222222222222',
+ },
+ {
+ route: {
+ origin: 'arbitrum',
+ destination: 'ethereum',
+ amount: 500n,
+ intentId: 'intent-2',
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ success: false,
+ error: 'Insufficient funds',
+ },
+ ],
+ strategyRoutes: [
+ {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ {
+ origin: 'arbitrum',
+ destination: 'ethereum',
+ amount: 500n,
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ ],
+ });
+
+ await triggerCycle();
+
+ // Verify createRebalanceAction called for intent-1 (success)
+ expect(
+ (actionTracker.createRebalanceAction as Sinon.SinonStub).calledOnce,
+ ).to.be.true;
+ expect(
+ (actionTracker.createRebalanceAction as Sinon.SinonStub).firstCall
+ .args[0].intentId,
+ ).to.equal('intent-1');
+
+ // Verify failRebalanceIntent called for intent-2 (failure)
+ expect((actionTracker.failRebalanceIntent as Sinon.SinonStub).calledOnce)
+ .to.be.true;
+ expect(
+ (actionTracker.failRebalanceIntent as Sinon.SinonStub).calledWith(
+ 'intent-2',
+ ),
+ ).to.be.true;
+ });
+
+ it('should assign intentId from createRebalanceIntent to route before calling rebalancer', async () => {
+ const { rebalancer, triggerCycle } = await setupDaemonTest(sandbox, {
+ intentIds: ['generated-intent-id'],
+ rebalanceResults: [
+ {
+ route: {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ intentId: 'generated-intent-id',
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ success: true,
+ messageId:
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
+ },
+ ],
+ strategyRoutes: [
+ {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ bridge: TEST_ADDRESSES.bridge,
+ },
+ ],
+ });
+
+ await triggerCycle();
+
+ // Verify rebalancer.rebalance was called with routes that have intentId
+ expect(rebalancer.rebalance.calledOnce).to.be.true;
+ const routesPassedToRebalancer = rebalancer.rebalance.firstCall.args[0];
+ expect(routesPassedToRebalancer).to.have.lengthOf(1);
+ expect(routesPassedToRebalancer[0].intentId).to.equal(
+ 'generated-intent-id',
+ );
+ });
+ });
+
+ describe('initialization', () => {
+ it('should initialize only once', async () => {
+ const contextFactory = createMockContextFactory();
+ const createStub = sandbox
+ .stub(RebalancerContextFactory, 'create')
+ .resolves(contextFactory);
+
+ const config: RebalancerServiceConfig = {
+ mode: 'manual',
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await service.executeManual({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: '100',
+ });
+
+ await service.executeManual({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: '200',
+ });
+
+ expect(createStub.calledOnce).to.be.true;
+ });
+
+ it('should create metrics when withMetrics is enabled', async () => {
+ const metrics = {} as Metrics;
+ const contextFactory = createMockContextFactory({ metrics });
+ const createMetricsSpy = Sinon.spy(contextFactory, 'createMetrics');
+
+ sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory);
+
+ const config: RebalancerServiceConfig = {
+ mode: 'manual',
+ withMetrics: true,
+ coingeckoApiKey: 'test-key',
+ logger: testLogger,
+ };
+
+ const service = new RebalancerService(
+ createMockMultiProvider(),
+ undefined,
+ {} as any,
+ createMockRebalancerConfig(),
+ config,
+ );
+
+ await service.executeManual({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: '100',
+ });
+
+ expect(createMetricsSpy.calledOnce).to.be.true;
+ expect(createMetricsSpy.firstCall.args[0]).to.equal('test-key');
+ });
+ });
+});
diff --git a/typescript/rebalancer/src/core/RebalancerService.ts b/typescript/rebalancer/src/core/RebalancerService.ts
index 9fec2874bfc..3c42d53fdc5 100644
--- a/typescript/rebalancer/src/core/RebalancerService.ts
+++ b/typescript/rebalancer/src/core/RebalancerService.ts
@@ -1,3 +1,4 @@
+import { randomUUID } from 'crypto';
import { Logger } from 'pino';
import { IRegistry } from '@hyperlane-xyz/registry';
@@ -9,17 +10,34 @@ import {
import { assert, toWei } from '@hyperlane-xyz/utils';
import { RebalancerConfig } from '../config/RebalancerConfig.js';
+import {
+ getStrategyChainConfig,
+ getStrategyChainNames,
+} from '../config/types.js';
import { RebalancerContextFactory } from '../factories/RebalancerContextFactory.js';
import {
+ type ConfirmedBlockTags,
MonitorEvent,
MonitorEventType,
MonitorPollingError,
MonitorStartError,
} from '../interfaces/IMonitor.js';
-import type { IRebalancer } from '../interfaces/IRebalancer.js';
-import type { IStrategy } from '../interfaces/IStrategy.js';
+import type {
+ IRebalancer,
+ RebalanceExecutionResult,
+ RebalanceRoute,
+} from '../interfaces/IRebalancer.js';
+import type {
+ IStrategy,
+ InflightContext,
+ StrategyRoute,
+} from '../interfaces/IStrategy.js';
import { Metrics } from '../metrics/Metrics.js';
import { Monitor } from '../monitor/Monitor.js';
+import {
+ type IActionTracker,
+ InflightContextAdapter,
+} from '../tracking/index.js';
import { getRawBalances } from '../utils/balanceUtils.js';
export interface RebalancerServiceConfig {
@@ -101,6 +119,8 @@ export class RebalancerService {
private rebalancer?: IRebalancer;
private metrics?: Metrics;
private mode: 'manual' | 'daemon';
+ private actionTracker?: IActionTracker;
+ private inflightContextAdapter?: InflightContextAdapter;
constructor(
private readonly multiProvider: MultiProvider,
@@ -159,7 +179,24 @@ export class RebalancerService {
);
}
- this.logger.info('✅ RebalancerService initialized successfully');
+ // Create ActionTracker for tracking inflight actions
+ const { tracker, adapter } =
+ await this.contextFactory.createActionTracker();
+ this.actionTracker = tracker;
+ this.inflightContextAdapter = adapter;
+ await this.actionTracker.initialize();
+ this.logger.info('ActionTracker initialized');
+
+ this.logger.info(
+ {
+ warpRouteId: this.rebalancerConfig.warpRouteId,
+ strategyTypes: this.rebalancerConfig.strategyConfig.map(
+ (s) => s.rebalanceStrategy,
+ ),
+ chains: getStrategyChainNames(this.rebalancerConfig.strategyConfig),
+ },
+ 'RebalancerService initialized',
+ );
}
/**
@@ -195,14 +232,28 @@ export class RebalancerService {
assert(!isNaN(amountNum), 'Amount must be a valid number');
assert(amountNum > 0, 'Amount must be greater than 0');
+ const originConfig = getStrategyChainConfig(
+ this.rebalancerConfig.strategyConfig,
+ origin,
+ );
+ assert(
+ originConfig?.bridge,
+ `No bridge configured for origin chain ${origin}`,
+ );
+
+ // Use destination-specific bridge override if configured, otherwise use default
+ const bridge =
+ originConfig.override?.[destination]?.bridge ?? originConfig.bridge;
+
try {
- await this.rebalancer.rebalance([
- {
- origin,
- destination,
- amount: BigInt(toWei(amount, originToken.decimals)),
- },
- ]);
+ const route: RebalanceRoute = {
+ intentId: randomUUID(),
+ origin,
+ destination,
+ amount: BigInt(toWei(amount, originToken.decimals)),
+ bridge,
+ };
+ await this.rebalancer.rebalance([route]);
this.logger.info(
`✅ Manual rebalance from ${origin} to ${destination} for amount ${amount} submitted successfully.`,
);
@@ -274,10 +325,9 @@ export class RebalancerService {
process.exit(0);
}
- /**
- * Event handler for token info updates from monitor
- */
private async onTokenInfo(event: MonitorEvent): Promise {
+ this.logger.info('Polling cycle started');
+
if (this.metrics) {
await Promise.all(
event.tokensInfo.map((tokenInfo) =>
@@ -286,24 +336,196 @@ export class RebalancerService {
);
}
+ await this.syncActionTracker(event.confirmedBlockTags);
+
const rawBalances = getRawBalances(
- Object.keys(this.rebalancerConfig.strategyConfig.chains),
+ getStrategyChainNames(this.rebalancerConfig.strategyConfig),
event,
this.logger,
);
- const rebalancingRoutes = this.strategy!.getRebalancingRoutes(rawBalances);
+ this.logger.info(
+ {
+ balances: Object.entries(rawBalances).map(([chain, balance]) => ({
+ chain,
+ balance: balance.toString(),
+ })),
+ },
+ 'Router balances',
+ );
+
+ // Get inflight context for strategy decision-making
+ const inflightContext = await this.getInflightContext();
- this.rebalancer
- ?.rebalance(rebalancingRoutes)
- .then(() => {
+ const strategyRoutes = this.strategy!.getRebalancingRoutes(
+ rawBalances,
+ inflightContext,
+ );
+
+ if (strategyRoutes.length > 0) {
+ this.logger.info(
+ {
+ routes: strategyRoutes.map((r) => ({
+ from: r.origin,
+ to: r.destination,
+ amount: r.amount.toString(),
+ })),
+ },
+ 'Routes proposed',
+ );
+ if (this.rebalancer) {
+ await this.executeWithTracking(strategyRoutes);
+ }
+ } else {
+ this.logger.info('No rebalancing needed');
+ }
+
+ this.logger.info('Polling cycle completed');
+ }
+
+ private async syncActionTracker(
+ confirmedBlockTags?: ConfirmedBlockTags,
+ ): Promise {
+ if (!this.actionTracker) return;
+
+ try {
+ await Promise.all([
+ this.actionTracker.syncTransfers(confirmedBlockTags),
+ this.actionTracker.syncRebalanceIntents(),
+ this.actionTracker.syncRebalanceActions(confirmedBlockTags),
+ ]);
+
+ await this.actionTracker.logStoreContents();
+ } catch (error) {
+ this.logger.warn(
+ { error },
+ 'ActionTracker sync failed, using stale data',
+ );
+ }
+ }
+
+ /**
+ * Get inflight context for strategy decision-making
+ */
+ private async getInflightContext(): Promise {
+ if (!this.inflightContextAdapter) {
+ return { pendingRebalances: [], pendingTransfers: [] };
+ }
+
+ return this.inflightContextAdapter.getInflightContext();
+ }
+
+ /**
+ * Execute rebalancing with intent tracking.
+ * Creates intents and assigns IDs to routes before execution, then processes results by ID.
+ */
+ private async executeWithTracking(
+ strategyRoutes: StrategyRoute[],
+ ): Promise {
+ if (!this.rebalancer || !this.actionTracker) {
+ this.logger.warn('Rebalancer or ActionTracker not available, skipping');
+ return;
+ }
+
+ // 1. Convert strategy routes to rebalance routes with IDs and create intents
+ // The route ID is used as the intent ID for direct matching
+ const rebalanceRoutes: RebalanceRoute[] = [];
+ const intentIds: string[] = [];
+
+ for (const route of strategyRoutes) {
+ const intent = await this.actionTracker.createRebalanceIntent({
+ origin: this.multiProvider.getDomainId(route.origin),
+ destination: this.multiProvider.getDomainId(route.destination),
+ amount: route.amount,
+ bridge: route.bridge,
+ });
+ intentIds.push(intent.id);
+ rebalanceRoutes.push({
+ ...route,
+ intentId: intent.id,
+ });
+ }
+
+ this.logger.debug(
+ { intentCount: rebalanceRoutes.length },
+ 'Created rebalance intents',
+ );
+
+ // 2. Execute rebalance with routes that have IDs
+ let results: RebalanceExecutionResult[];
+ try {
+ results = await this.rebalancer.rebalance(rebalanceRoutes);
+ const failedResults = results.filter((r) => !r.success);
+ if (failedResults.length > 0) {
+ this.metrics?.recordRebalancerFailure();
+ this.logger.warn(
+ { failureCount: failedResults.length, total: results.length },
+ 'Rebalancer cycle completed with failures',
+ );
+ } else {
this.metrics?.recordRebalancerSuccess();
this.logger.info('Rebalancer completed a cycle successfully');
- })
- .catch((error: any) => {
- this.metrics?.recordRebalancerFailure();
- this.logger.error({ error }, 'Error while rebalancing');
- });
+ }
+ } catch (error: any) {
+ this.metrics?.recordRebalancerFailure();
+ this.logger.error({ error }, 'Error while rebalancing');
+
+ // Mark all intents as failed
+ await Promise.all(
+ intentIds.map((id) => this.actionTracker!.failRebalanceIntent(id)),
+ );
+ return;
+ }
+
+ // 3. Process results - results have IDs that match intents directly
+ await this.processExecutionResults(results);
+ }
+
+ /**
+ * Process execution results and update tracking state.
+ * Results are matched to intents by the route ID (which equals the intent ID).
+ */
+ private async processExecutionResults(
+ results: RebalanceExecutionResult[],
+ ): Promise {
+ for (const result of results) {
+ const intentId = result.route.intentId;
+
+ if (result.success && result.messageId) {
+ await this.actionTracker!.createRebalanceAction({
+ intentId,
+ origin: this.multiProvider.getDomainId(result.route.origin),
+ destination: this.multiProvider.getDomainId(result.route.destination),
+ amount: result.route.amount,
+ messageId: result.messageId,
+ txHash: result.txHash,
+ });
+
+ this.logger.info(
+ {
+ intentId,
+ messageId: result.messageId,
+ txHash: result.txHash,
+ origin: result.route.origin,
+ destination: result.route.destination,
+ },
+ 'Rebalance action created successfully',
+ );
+ } else {
+ await this.actionTracker!.failRebalanceIntent(intentId);
+
+ this.logger.warn(
+ {
+ intentId,
+ success: result.success,
+ error: result.error,
+ origin: result.route.origin,
+ destination: result.route.destination,
+ },
+ 'Rebalance intent marked as failed',
+ );
+ }
+ }
}
/**
diff --git a/typescript/rebalancer/src/core/WithInflightGuard.test.ts b/typescript/rebalancer/src/core/WithInflightGuard.test.ts
deleted file mode 100644
index 6df72f30f40..00000000000
--- a/typescript/rebalancer/src/core/WithInflightGuard.test.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import chai, { expect } from 'chai';
-import chaiAsPromised from 'chai-as-promised';
-import { ethers } from 'ethers';
-import { pino } from 'pino';
-import Sinon from 'sinon';
-
-import { chainMetadata } from '@hyperlane-xyz/registry';
-import { ChainMetadataManager } from '@hyperlane-xyz/sdk';
-
-import { type RebalancingRoute } from '../interfaces/IStrategy.js';
-import { MockRebalancer, buildTestConfig } from '../test/helpers.js';
-import { ExplorerClient } from '../utils/ExplorerClient.js';
-
-import { WithInflightGuard } from './WithInflightGuard.js';
-
-chai.use(chaiAsPromised);
-
-const testLogger = pino({ level: 'silent' });
-
-describe('WithInflightGuard', () => {
- it('forwards empty routes without calling Explorer', async () => {
- const config = buildTestConfig();
-
- const rebalancer = new MockRebalancer();
- const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
-
- const explorer = new ExplorerClient('http://localhost');
- const explorerSpy = Sinon.stub(explorer, 'hasUndeliveredRebalance');
-
- const guard = new WithInflightGuard(
- config,
- rebalancer,
- explorer,
- ethers.Wallet.createRandom().address,
- new ChainMetadataManager(chainMetadata as any),
- testLogger,
- );
-
- await guard.rebalance([]);
-
- expect(explorerSpy.called).to.be.false;
- expect(rebalanceSpy.calledOnce).to.be.true;
- expect(rebalanceSpy.calledWith([])).to.be.true;
- });
-
- it('calls underlying rebalancer when no inflight is detected', async () => {
- const config = buildTestConfig({}, ['ethereum', 'arbitrum']);
- const routes: RebalancingRoute[] = [{ origin: 'ethereum' } as any];
-
- const rebalancer = new MockRebalancer();
- const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
-
- const explorer = new ExplorerClient('http://localhost');
- const explorerSpy = Sinon.stub(
- explorer,
- 'hasUndeliveredRebalance',
- ).resolves(false);
-
- const guard = new WithInflightGuard(
- config,
- rebalancer,
- explorer,
- ethers.Wallet.createRandom().address,
- new ChainMetadataManager(chainMetadata as any),
- testLogger,
- );
-
- await guard.rebalance(routes);
-
- expect(explorerSpy.calledOnce).to.be.true;
- expect(rebalanceSpy.calledOnce).to.be.true;
- expect(rebalanceSpy.calledWith(routes)).to.be.true;
- });
-
- it('skips rebalancing when inflight is detected', async () => {
- const config = buildTestConfig({}, ['ethereum', 'arbitrum']);
- const routes: RebalancingRoute[] = [{ origin: 'ethereum' } as any];
-
- const rebalancer = new MockRebalancer();
- const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
-
- const explorer = new ExplorerClient('http://localhost');
- const explorerSpy = Sinon.stub(
- explorer,
- 'hasUndeliveredRebalance',
- ).resolves(true);
-
- const guard = new WithInflightGuard(
- config,
- rebalancer,
- explorer,
- ethers.Wallet.createRandom().address,
- new ChainMetadataManager(chainMetadata as any),
- testLogger,
- );
-
- await guard.rebalance(routes);
-
- expect(explorerSpy.calledOnce).to.be.true;
- expect(rebalanceSpy.called).to.be.false;
- });
-
- it('propagates explorer query error', async () => {
- const config = buildTestConfig({}, ['ethereum', 'arbitrum']);
- const routes: RebalancingRoute[] = [{ origin: 'ethereum' } as any];
-
- const rebalancer = new MockRebalancer();
- const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
-
- const explorer = new ExplorerClient('http://localhost');
- const explorerSpy = Sinon.stub(explorer, 'hasUndeliveredRebalance').rejects(
- new Error('Explorer HTTP 405'),
- );
-
- const guard = new WithInflightGuard(
- config,
- rebalancer,
- explorer,
- ethers.Wallet.createRandom().address,
- new ChainMetadataManager(chainMetadata as any),
- testLogger,
- );
-
- await expect(guard.rebalance(routes)).to.be.rejectedWith(
- 'Explorer HTTP 405',
- );
-
- expect(explorerSpy.calledOnce).to.be.true;
- expect(rebalanceSpy.called).to.be.false;
- });
-});
diff --git a/typescript/rebalancer/src/core/WithInflightGuard.ts b/typescript/rebalancer/src/core/WithInflightGuard.ts
deleted file mode 100644
index dd8a08620c8..00000000000
--- a/typescript/rebalancer/src/core/WithInflightGuard.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import type { Logger } from 'pino';
-
-import { type ChainMetadataManager } from '@hyperlane-xyz/sdk';
-
-import { type RebalancerConfig } from '../config/RebalancerConfig.js';
-import type { IRebalancer } from '../interfaces/IRebalancer.js';
-import type { RebalancingRoute } from '../interfaces/IStrategy.js';
-import { type ExplorerClient } from '../utils/ExplorerClient.js';
-
-/**
- * Prevents rebalancing if there are inflight rebalances for the warp route.
- */
-export class WithInflightGuard implements IRebalancer {
- private readonly logger: Logger;
-
- constructor(
- private readonly config: RebalancerConfig,
- private readonly rebalancer: IRebalancer,
- private readonly explorer: ExplorerClient,
- private readonly txSender: string,
- private readonly chainManager: ChainMetadataManager,
- logger: Logger,
- ) {
- this.logger = logger.child({ class: WithInflightGuard.name });
- }
-
- async rebalance(routes: RebalancingRoute[]): Promise {
- // Always enforce the inflight guard
- if (routes.length === 0) {
- return this.rebalancer.rebalance(routes);
- }
-
- const chains = Object.keys(this.config.strategyConfig.chains);
- const bridges = chains.map(
- (chain) => this.config.strategyConfig.chains[chain].bridge,
- );
- const domains = chains.map((chain) => this.chainManager.getDomainId(chain));
-
- let hasInflightRebalances = false;
- try {
- hasInflightRebalances = await this.explorer.hasUndeliveredRebalance(
- {
- bridges,
- domains: Array.from(new Set(domains)),
- txSender: this.txSender,
- limit: 5,
- },
- this.logger,
- );
- } catch (e: any) {
- this.logger.error(
- { status: e.status, body: e.body },
- 'Explorer inflight query failed',
- );
- throw e;
- }
-
- if (hasInflightRebalances) {
- this.logger.info(
- 'Inflight rebalance detected via Explorer; skipping this cycle',
- );
- return;
- }
-
- return this.rebalancer.rebalance(routes);
- }
-}
diff --git a/typescript/rebalancer/src/core/WithSemaphore.test.ts b/typescript/rebalancer/src/core/WithSemaphore.test.ts
deleted file mode 100644
index 9c702e39b02..00000000000
--- a/typescript/rebalancer/src/core/WithSemaphore.test.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import chai, { expect } from 'chai';
-import chaiAsPromised from 'chai-as-promised';
-import { pino } from 'pino';
-import Sinon from 'sinon';
-
-import { RebalancerStrategyOptions } from '../config/types.js';
-import { type RebalancingRoute } from '../interfaces/IStrategy.js';
-import { MockRebalancer, buildTestConfig } from '../test/helpers.js';
-
-import { WithSemaphore } from './WithSemaphore.js';
-
-chai.use(chaiAsPromised);
-
-const testLogger = pino({ level: 'silent' });
-
-describe('WithSemaphore', () => {
- it('should call the underlying rebalancer', async () => {
- const config = buildTestConfig();
-
- const routes = [
- {
- origin: 'chain1',
- } as any as RebalancingRoute,
- ];
-
- const rebalancer = new MockRebalancer();
- const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
- const withSemaphore = new WithSemaphore(config, rebalancer, testLogger);
- await withSemaphore.rebalance(routes);
-
- expect(rebalanceSpy.calledOnce).to.be.true;
- expect(rebalanceSpy.calledWith(routes)).to.be.true;
- });
-
- it('should return early if there are no routes', async () => {
- const config = buildTestConfig();
-
- const rebalancer = new MockRebalancer();
- const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
- const withSemaphore = new WithSemaphore(config, rebalancer, testLogger);
- await withSemaphore.rebalance([]);
-
- expect(rebalanceSpy.calledOnce).to.be.false;
- });
-
- it('should return early if rebalance occurs before waitUntil is reached', async () => {
- const config = buildTestConfig();
-
- const routes = [
- {
- origin: 'chain1',
- } as any as RebalancingRoute,
- ];
-
- const rebalancer = new MockRebalancer();
- const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
- const withSemaphore = new WithSemaphore(config, rebalancer, testLogger);
- await withSemaphore.rebalance(routes);
-
- expect(rebalanceSpy.calledOnce).to.be.true;
- expect(rebalanceSpy.calledWith(routes)).to.be.true;
-
- rebalanceSpy.resetHistory();
- await withSemaphore.rebalance(routes);
-
- expect(rebalanceSpy.calledOnce).to.be.false;
- });
-
- it('should throw if a chain is missing', async () => {
- const config = buildTestConfig({
- strategyConfig: {
- rebalanceStrategy: RebalancerStrategyOptions.Weighted,
- chains: {},
- },
- });
-
- const routes = [
- {
- origin: 'chain1',
- } as any as RebalancingRoute,
- ];
-
- const rebalancer = new MockRebalancer();
- const withSemaphore = new WithSemaphore(config, rebalancer, testLogger);
-
- await expect(withSemaphore.rebalance(routes)).to.be.rejectedWith(
- `Chain ${routes[0].origin} not found in config`,
- );
- });
-
- it('should not execute if another rebalance is currently executing', async () => {
- const config = buildTestConfig();
-
- const routes = [
- {
- origin: 'chain1',
- } as any as RebalancingRoute,
- ];
-
- const rebalancer = new MockRebalancer();
- const rebalanceSpy = Sinon.spy(rebalancer, 'rebalance');
- const withSemaphore = new WithSemaphore(config, rebalancer, testLogger);
-
- const rebalancePromise1 = withSemaphore.rebalance(routes);
- const rebalancePromise2 = withSemaphore.rebalance(routes);
- await rebalancePromise1;
- await rebalancePromise2;
-
- expect(rebalanceSpy.calledOnce).to.be.true;
- });
-});
diff --git a/typescript/rebalancer/src/core/WithSemaphore.ts b/typescript/rebalancer/src/core/WithSemaphore.ts
deleted file mode 100644
index 0cd1ceed54b..00000000000
--- a/typescript/rebalancer/src/core/WithSemaphore.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import type { Logger } from 'pino';
-
-import { type RebalancerConfig } from '../config/RebalancerConfig.js';
-import type { IRebalancer } from '../interfaces/IRebalancer.js';
-import type { RebalancingRoute } from '../interfaces/IStrategy.js';
-
-/**
- * Prevents frequent rebalancing operations while bridges complete.
- */
-export class WithSemaphore implements IRebalancer {
- // Timestamp until which rebalancing should be blocked
- private waitUntil: number = 0;
- // Lock to prevent concurrent rebalance execution
- private executing: boolean = false;
- private readonly logger: Logger;
-
- constructor(
- private readonly config: RebalancerConfig,
- private readonly rebalancer: IRebalancer,
- logger: Logger,
- ) {
- this.logger = logger.child({ class: WithSemaphore.name });
- }
-
- /**
- * Rebalance with timing control
- * @param routes - Routes to process
- */
- async rebalance(routes: RebalancingRoute[]): Promise {
- if (this.executing) {
- this.logger.info('Currently executing rebalance. Skipping.');
-
- return;
- }
-
- // No routes mean the system is balanced so we reset the timer to allow new rebalancing
- if (!routes.length) {
- this.logger.info(
- 'No routes to execute. Assuming rebalance is complete. Resetting semaphore timer.',
- );
-
- this.waitUntil = 0;
- return;
- }
-
- // Skip if still in waiting period
- if (Date.now() < this.waitUntil) {
- this.logger.info('Still in waiting period. Skipping rebalance.');
-
- return;
- }
-
- // The wait period will be determined by the bridge with the highest wait tolerance
- const highestTolerance = this.getHighestLockTime(routes);
-
- try {
- // Execute rebalance
- this.executing = true;
- await this.rebalancer.rebalance(routes);
- } finally {
- this.executing = false;
- }
-
- // Set new waiting period
- this.waitUntil = Date.now() + highestTolerance;
-
- this.logger.info(
- {
- highestTolerance,
- waitUntil: this.waitUntil,
- },
- 'Rebalance semaphore locked',
- );
- }
-
- private getHighestLockTime(routes: RebalancingRoute[]) {
- return routes.reduce((highest, route) => {
- const origin = this.config.strategyConfig.chains[route.origin];
-
- if (!origin) {
- this.logger.error({ route }, 'Chain not found in config. Skipping.');
- throw new Error(`Chain ${route.origin} not found in config`);
- }
-
- const bridgeLockTime = origin.bridgeLockTime;
- const overrideLockTime =
- origin.override?.[route.destination]?.bridgeLockTime ?? 0;
-
- return Math.max(highest, bridgeLockTime, overrideLockTime);
- }, 0);
- }
-}
diff --git a/typescript/rebalancer/src/factories/RebalancerContextFactory.ts b/typescript/rebalancer/src/factories/RebalancerContextFactory.ts
index df958187a1f..ae5ab53205b 100644
--- a/typescript/rebalancer/src/factories/RebalancerContextFactory.ts
+++ b/typescript/rebalancer/src/factories/RebalancerContextFactory.ts
@@ -3,6 +3,7 @@ import { type Logger } from 'pino';
import { IRegistry } from '@hyperlane-xyz/registry';
import {
type ChainMap,
+ HyperlaneCore,
MultiProtocolProvider,
MultiProvider,
type Token,
@@ -11,16 +12,33 @@ import {
import { objMap } from '@hyperlane-xyz/utils';
import { type RebalancerConfig } from '../config/RebalancerConfig.js';
+import { getAllBridges, getStrategyChainNames } from '../config/types.js';
import { Rebalancer } from '../core/Rebalancer.js';
-import { WithSemaphore } from '../core/WithSemaphore.js';
import type { IRebalancer } from '../interfaces/IRebalancer.js';
import type { IStrategy } from '../interfaces/IStrategy.js';
import { Metrics } from '../metrics/Metrics.js';
import { PriceGetter } from '../metrics/PriceGetter.js';
import { Monitor } from '../monitor/Monitor.js';
import { StrategyFactory } from '../strategy/StrategyFactory.js';
+import {
+ ActionTracker,
+ type ActionTrackerConfig,
+ type IActionTracker,
+ InMemoryStore,
+ InflightContextAdapter,
+ type RebalanceAction,
+ type RebalanceActionStatus,
+ type RebalanceIntent,
+ type RebalanceIntentStatus,
+ type Transfer,
+ type TransferStatus,
+} from '../tracking/index.js';
+import { ExplorerClient } from '../utils/ExplorerClient.js';
import { isCollateralizedTokenEligibleForRebalancing } from '../utils/index.js';
+const DEFAULT_EXPLORER_URL =
+ process.env.EXPLORER_API_URL || 'https://explorer4.hasura.app/v1/graphql';
+
export class RebalancerContextFactory {
/**
* @param config - The rebalancer config
@@ -141,10 +159,14 @@ export class RebalancerContextFactory {
}
public async createStrategy(metrics?: Metrics): Promise {
+ const strategyTypes = this.config.strategyConfig.map(
+ (s) => s.rebalanceStrategy,
+ );
this.logger.debug(
{
warpRouteId: this.config.warpRouteId,
- strategyType: this.config.strategyConfig.rebalanceStrategy,
+ strategyTypes,
+ strategyCount: this.config.strategyConfig.length,
},
'Creating Strategy',
);
@@ -162,13 +184,8 @@ export class RebalancerContextFactory {
{ warpRouteId: this.config.warpRouteId },
'Creating Rebalancer',
);
+
const rebalancer = new Rebalancer(
- objMap(this.config.strategyConfig.chains, (_, v) => ({
- bridge: v.bridge,
- bridgeMinAcceptedAmount: v.bridgeMinAcceptedAmount ?? 0,
- bridgeIsWarp: v.bridgeIsWarp ?? false,
- override: v.override,
- })),
this.warpCore,
this.multiProvider.metadata,
this.tokensByChainName,
@@ -177,20 +194,104 @@ export class RebalancerContextFactory {
metrics,
);
- // Wrap with semaphore for concurrency control
- const withSemaphore = new WithSemaphore(
- this.config,
- rebalancer,
+ return rebalancer;
+ }
+
+ /**
+ * Creates an ActionTracker for tracking inflight rebalance actions and user transfers.
+ * Returns both the tracker and adapter for use by RebalancerService.
+ *
+ * @param explorerUrl - Optional explorer URL (defaults to production Hyperlane Explorer)
+ */
+ public async createActionTracker(
+ explorerUrl: string = DEFAULT_EXPLORER_URL,
+ ): Promise<{
+ tracker: IActionTracker;
+ adapter: InflightContextAdapter;
+ }> {
+ this.logger.debug(
+ { warpRouteId: this.config.warpRouteId },
+ 'Creating ActionTracker',
+ );
+
+ // 1. Create in-memory stores
+ const transferStore = new InMemoryStore();
+ const intentStore = new InMemoryStore<
+ RebalanceIntent,
+ RebalanceIntentStatus
+ >();
+ const actionStore = new InMemoryStore<
+ RebalanceAction,
+ RebalanceActionStatus
+ >();
+
+ // 2. Create ExplorerClient
+ const explorerClient = new ExplorerClient(explorerUrl);
+
+ // 3. Get HyperlaneCore from registry
+ const addresses = await this.registry.getAddresses();
+ const hyperlaneCore = HyperlaneCore.fromAddressesMap(
+ addresses,
+ this.multiProvider,
+ );
+
+ // 4. Get rebalancer address from signer
+ // Use the first chain in the strategy to get the signer address
+ const chainNames = getStrategyChainNames(this.config.strategyConfig);
+ if (chainNames.length === 0) {
+ throw new Error('No chains configured in strategy');
+ }
+ const signer = this.multiProvider.getSigner(chainNames[0]);
+ const rebalancerAddress = await signer.getAddress();
+
+ const bridges = getAllBridges(this.config.strategyConfig);
+
+ // Build router→domain mapping (source of truth for routers and domains)
+ const routersByDomain: Record = {};
+ for (const token of this.warpCore.tokens) {
+ const domain = this.multiProvider.getDomainId(token.chainName);
+ routersByDomain[domain] = token.addressOrDenom;
+ }
+
+ const trackerConfig: ActionTrackerConfig = {
+ routersByDomain,
+ bridges,
+ rebalancerAddress,
+ };
+
+ // 6. Create ActionTracker
+ const tracker = new ActionTracker(
+ transferStore,
+ intentStore,
+ actionStore,
+ explorerClient,
+ hyperlaneCore,
+ trackerConfig,
this.logger,
);
- return withSemaphore;
+ // 7. Create InflightContextAdapter
+ const adapter = new InflightContextAdapter(tracker, this.multiProvider);
+
+ this.logger.debug(
+ {
+ warpRouteId: this.config.warpRouteId,
+ routerCount: Object.keys(routersByDomain).length,
+ bridgeCount: bridges.length,
+ domainCount: Object.keys(routersByDomain).length,
+ },
+ 'ActionTracker created successfully',
+ );
+
+ return { tracker, adapter };
}
private async getInitialTotalCollateral(): Promise {
let initialTotalCollateral = 0n;
- const chainNames = new Set(Object.keys(this.config.strategyConfig.chains));
+ const chainNames = new Set(
+ getStrategyChainNames(this.config.strategyConfig),
+ );
await Promise.all(
this.warpCore.tokens.map(async (token) => {
diff --git a/typescript/rebalancer/src/index.ts b/typescript/rebalancer/src/index.ts
index 1895c1ec997..ba0af6af488 100644
--- a/typescript/rebalancer/src/index.ts
+++ b/typescript/rebalancer/src/index.ts
@@ -16,17 +16,18 @@ export type {
// Core rebalancing logic
export { Rebalancer } from './core/Rebalancer.js';
-export { WithInflightGuard } from './core/WithInflightGuard.js';
-export { WithSemaphore } from './core/WithSemaphore.js';
// Configuration
export { RebalancerConfig } from './config/RebalancerConfig.js';
export {
+ getStrategyChainConfig,
+ getStrategyChainNames,
RebalancerBaseChainConfigSchema,
RebalancerConfigSchema,
RebalancerMinAmountConfigSchema,
RebalancerMinAmountType,
RebalancerStrategyOptions,
+ RebalancerStrategySchema,
RebalancerWeightedChainConfigSchema,
StrategyConfigSchema,
} from './config/types.js';
@@ -42,6 +43,7 @@ export type {
// Strategy
export { BaseStrategy } from './strategy/BaseStrategy.js';
+export { CompositeStrategy } from './strategy/CompositeStrategy.js';
export { WeightedStrategy } from './strategy/WeightedStrategy.js';
export { MinAmountStrategy } from './strategy/MinAmountStrategy.js';
export { StrategyFactory } from './strategy/StrategyFactory.js';
@@ -57,13 +59,20 @@ export { PriceGetter } from './metrics/PriceGetter.js';
export type {
IRebalancer,
PreparedTransaction,
+ RebalanceRoute,
+ RebalanceExecutionResult,
} from './interfaces/IRebalancer.js';
export type {
IStrategy,
- RebalancingRoute,
+ StrategyRoute,
RawBalances,
+ InflightContext,
} from './interfaces/IStrategy.js';
-export type { IMonitor } from './interfaces/IMonitor.js';
+export type {
+ ConfirmedBlockTag,
+ ConfirmedBlockTags,
+ IMonitor,
+} from './interfaces/IMonitor.js';
export {
MonitorEventType,
MonitorEvent,
@@ -82,5 +91,8 @@ export { getRawBalances } from './utils/balanceUtils.js';
export { isCollateralizedTokenEligibleForRebalancing } from './utils/tokenUtils.js';
export { ExplorerClient } from './utils/ExplorerClient.js';
+// Tracking
+export { InflightContextAdapter } from './tracking/InflightContextAdapter.js';
+
// Factory
export { RebalancerContextFactory } from './factories/RebalancerContextFactory.js';
diff --git a/typescript/rebalancer/src/interfaces/IMonitor.ts b/typescript/rebalancer/src/interfaces/IMonitor.ts
index a9e0f29329a..1551c232bc3 100644
--- a/typescript/rebalancer/src/interfaces/IMonitor.ts
+++ b/typescript/rebalancer/src/interfaces/IMonitor.ts
@@ -1,4 +1,8 @@
-import { type Token } from '@hyperlane-xyz/sdk';
+import {
+ type ChainMap,
+ EthJsonRpcBlockParameterTag,
+ type Token,
+} from '@hyperlane-xyz/sdk';
import { WrappedError } from '../utils/errors.js';
@@ -16,17 +20,19 @@ export enum MonitorEventType {
Start = 'Start',
}
-/**
- * Represents an event emitted by the monitor containing bridgedSupply and token information.
- */
+export type ConfirmedBlockTag =
+ | number
+ | EthJsonRpcBlockParameterTag
+ | undefined;
+
+export type ConfirmedBlockTags = ChainMap;
+
export type MonitorEvent = {
- /**
- * Collection of objects containing the information retrieved by the Monitor.
- */
tokensInfo: {
token: Token;
bridgedSupply?: bigint;
}[];
+ confirmedBlockTags: ConfirmedBlockTags;
};
/**
@@ -35,10 +41,11 @@ export type MonitorEvent = {
export interface IMonitor {
/**
* Allows subscribers to listen to hyperlane's tokens info.
+ * Handler can be async - Monitor will await it before starting next cycle.
*/
on(
eventName: MonitorEventType.TokenInfo,
- fn: (event: MonitorEvent) => void,
+ fn: (event: MonitorEvent) => void | Promise,
): this;
/**
diff --git a/typescript/rebalancer/src/interfaces/IRebalancer.ts b/typescript/rebalancer/src/interfaces/IRebalancer.ts
index b5c8f16c299..e7a692b7f7a 100644
--- a/typescript/rebalancer/src/interfaces/IRebalancer.ts
+++ b/typescript/rebalancer/src/interfaces/IRebalancer.ts
@@ -3,21 +3,39 @@ import {
type TokenAmount,
} from '@hyperlane-xyz/sdk';
-import { type RebalancingRoute } from './IStrategy.js';
+import type { StrategyRoute } from './IStrategy.js';
+
+/**
+ * RebalanceRoute extends StrategyRoute with a required intentId for tracking.
+ * The intentId is assigned by RebalancerService before execution and links
+ * to the corresponding RebalanceIntent in the tracking system.
+ */
+export type RebalanceRoute = StrategyRoute & {
+ /** Links to the RebalanceIntent that this route fulfills */
+ intentId: string;
+};
export type PreparedTransaction = {
populatedTx: Awaited<
ReturnType
>;
- route: RebalancingRoute;
+ route: RebalanceRoute;
originTokenAmount: TokenAmount;
};
export type RebalanceMetrics = {
- route: RebalancingRoute;
+ route: RebalanceRoute;
originTokenAmount: TokenAmount;
};
+export type RebalanceExecutionResult = {
+ route: RebalanceRoute;
+ success: boolean;
+ messageId?: string;
+ txHash?: string;
+ error?: string;
+};
+
export interface IRebalancer {
- rebalance(routes: RebalancingRoute[]): Promise;
+ rebalance(routes: RebalanceRoute[]): Promise;
}
diff --git a/typescript/rebalancer/src/interfaces/IStrategy.ts b/typescript/rebalancer/src/interfaces/IStrategy.ts
index 002eb2c96ec..4b2cd10f6cd 100644
--- a/typescript/rebalancer/src/interfaces/IStrategy.ts
+++ b/typescript/rebalancer/src/interfaces/IStrategy.ts
@@ -1,13 +1,34 @@
import { type ChainMap, type ChainName } from '@hyperlane-xyz/sdk';
+import type { Address } from '@hyperlane-xyz/utils';
export type RawBalances = ChainMap;
-export type RebalancingRoute = {
+export interface Route {
origin: ChainName;
destination: ChainName;
amount: bigint;
+}
+
+export interface StrategyRoute extends Route {
+ bridge: Address;
+}
+
+export type InflightContext = {
+ /**
+ * In-flight rebalances from ActionTracker.
+ * Uses Route[] because recovered intents (from Explorer startup recovery)
+ * don't have bridge information. Some routes may have bridge at runtime.
+ */
+ pendingRebalances: Route[];
+ pendingTransfers: Route[];
+ /** Routes from earlier strategies - always have bridge */
+ proposedRebalances?: StrategyRoute[];
};
export interface IStrategy {
- getRebalancingRoutes(rawBalances: RawBalances): RebalancingRoute[];
+ readonly name: string;
+ getRebalancingRoutes(
+ rawBalances: RawBalances,
+ inflightContext?: InflightContext,
+ ): StrategyRoute[];
}
diff --git a/typescript/rebalancer/src/metrics/Metrics.ts b/typescript/rebalancer/src/metrics/Metrics.ts
index e6aa50c4afc..f481b6c4d70 100644
--- a/typescript/rebalancer/src/metrics/Metrics.ts
+++ b/typescript/rebalancer/src/metrics/Metrics.ts
@@ -22,13 +22,15 @@ import { ProtocolType, tryFn } from '@hyperlane-xyz/utils';
import { type IMetrics } from '../interfaces/IMetrics.js';
import { type MonitorEvent } from '../interfaces/IMonitor.js';
-import { type RebalancingRoute } from '../interfaces/IStrategy.js';
+import { type StrategyRoute } from '../interfaces/IStrategy.js';
import { type PriceGetter } from './PriceGetter.js';
import {
metricsRegister,
+ rebalancerActionsCreatedTotal,
rebalancerExecutionAmount,
rebalancerExecutionTotal,
+ rebalancerIntentsCreatedTotal,
rebalancerPollingErrorsTotal,
updateManagedLockboxBalanceMetrics,
updateNativeWalletBalanceMetrics,
@@ -64,10 +66,7 @@ export class Metrics implements IMetrics {
.inc();
}
- recordRebalanceAmount(
- route: RebalancingRoute,
- originTokenAmount: TokenAmount,
- ) {
+ recordRebalanceAmount(route: StrategyRoute, originTokenAmount: TokenAmount) {
rebalancerExecutionAmount
.labels({
warp_route_id: this.warpRouteId,
@@ -90,6 +89,28 @@ export class Metrics implements IMetrics {
.inc();
}
+ recordIntentCreated(route: StrategyRoute, strategy: string) {
+ rebalancerIntentsCreatedTotal
+ .labels({
+ warp_route_id: this.warpRouteId,
+ strategy,
+ origin: route.origin,
+ destination: route.destination,
+ })
+ .inc();
+ }
+
+ recordActionAttempt(route: StrategyRoute, succeeded: boolean) {
+ rebalancerActionsCreatedTotal
+ .labels({
+ warp_route_id: this.warpRouteId,
+ origin: route.origin,
+ destination: route.destination,
+ succeeded: String(succeeded),
+ })
+ .inc();
+ }
+
async processToken({
token,
bridgedSupply,
diff --git a/typescript/rebalancer/src/metrics/scripts/metrics.ts b/typescript/rebalancer/src/metrics/scripts/metrics.ts
index 1a1bc817da7..c2e657d0c4b 100644
--- a/typescript/rebalancer/src/metrics/scripts/metrics.ts
+++ b/typescript/rebalancer/src/metrics/scripts/metrics.ts
@@ -42,6 +42,20 @@ export const rebalancerPollingErrorsTotal = new Counter({
labelNames: ['warp_route_id'],
});
+export const rebalancerIntentsCreatedTotal = new Counter({
+ name: 'hyperlane_rebalancer_intents_created_total',
+ help: 'Total number of rebalancing intents (routes) created.',
+ registers: [metricsRegister],
+ labelNames: ['warp_route_id', 'strategy', 'origin', 'destination'],
+});
+
+export const rebalancerActionsCreatedTotal = new Counter({
+ name: 'hyperlane_rebalancer_actions_created_total',
+ help: 'Total number of rebalancing actions (transactions) attempted.',
+ registers: [metricsRegister],
+ labelNames: ['warp_route_id', 'origin', 'destination', 'succeeded'],
+});
+
/**
* Updates token balance metrics for a warp route token.
*/
diff --git a/typescript/rebalancer/src/monitor/Monitor.ts b/typescript/rebalancer/src/monitor/Monitor.ts
index 1f2ec8f3e8f..4c01692c77a 100644
--- a/typescript/rebalancer/src/monitor/Monitor.ts
+++ b/typescript/rebalancer/src/monitor/Monitor.ts
@@ -1,10 +1,15 @@
-import EventEmitter from 'events';
import { type Logger } from 'pino';
-import type { Token, WarpCore } from '@hyperlane-xyz/sdk';
+import {
+ EthJsonRpcBlockParameterTag,
+ type Token,
+ type WarpCore,
+} from '@hyperlane-xyz/sdk';
import { sleep } from '@hyperlane-xyz/utils';
import {
+ type ConfirmedBlockTag,
+ type ConfirmedBlockTags,
type IMonitor,
type MonitorEvent,
MonitorEventType,
@@ -14,9 +19,12 @@ import {
/**
* Simple monitor implementation that polls warp route collateral balances and emits them as MonitorEvent.
+ * Awaits the TokenInfo handler before starting the next cycle to prevent race conditions.
*/
export class Monitor implements IMonitor {
- private readonly emitter = new EventEmitter();
+ private tokenInfoHandler?: (event: MonitorEvent) => void | Promise;
+ private errorHandler?: (event: Error) => void;
+ private startHandler?: () => void;
private isMonitorRunning = false;
private resolveStop: (() => void) | null = null;
private stopPromise: Promise | null = null;
@@ -30,25 +38,69 @@ export class Monitor implements IMonitor {
private readonly logger: Logger,
) {}
+ private async getConfirmedBlockTag(
+ chainName: string,
+ ): Promise {
+ try {
+ const metadata = this.warpCore.multiProvider.getChainMetadata(chainName);
+ const reorgPeriod = metadata.blocks?.reorgPeriod ?? 32;
+
+ if (typeof reorgPeriod === 'string') {
+ return reorgPeriod as EthJsonRpcBlockParameterTag;
+ }
+
+ const provider =
+ this.warpCore.multiProvider.getEthersV5Provider(chainName);
+ const latestBlock = await provider.getBlockNumber();
+ return Math.max(0, latestBlock - reorgPeriod);
+ } catch (error) {
+ this.logger.warn(
+ { chain: chainName, error: (error as Error).message },
+ 'Failed to get confirmed block, using latest',
+ );
+ return undefined;
+ }
+ }
+
+ private async computeConfirmedBlockTags(): Promise {
+ const blockTags: ConfirmedBlockTags = {};
+ const chains = new Set(this.warpCore.tokens.map((t) => t.chainName));
+
+ for (const chain of chains) {
+ blockTags[chain] = await this.getConfirmedBlockTag(chain);
+ }
+
+ return blockTags;
+ }
+
// overloads from IMonitor
on(
eventName: MonitorEventType.TokenInfo,
- fn: (event: MonitorEvent) => void,
+ fn: (event: MonitorEvent) => void | Promise,
): this;
on(eventName: MonitorEventType.Error, fn: (event: Error) => void): this;
on(eventName: MonitorEventType.Start, fn: () => void): this;
- on(eventName: string, fn: (...args: any[]) => void): this {
- this.emitter.on(eventName, fn);
+ on(eventName: string, fn: (...args: any[]) => void | Promise): this {
+ switch (eventName) {
+ case MonitorEventType.TokenInfo:
+ this.tokenInfoHandler = fn as (
+ event: MonitorEvent,
+ ) => void | Promise;
+ break;
+ case MonitorEventType.Error:
+ this.errorHandler = fn as (event: Error) => void;
+ break;
+ case MonitorEventType.Start:
+ this.startHandler = fn as () => void;
+ break;
+ }
return this;
}
async start() {
if (this.isMonitorRunning) {
// Cannot start the same monitor multiple times
- this.emitter.emit(
- MonitorEventType.Error,
- new MonitorStartError('Monitor already running'),
- );
+ this.errorHandler?.(new MonitorStartError('Monitor already running'));
return;
}
@@ -58,13 +110,19 @@ export class Monitor implements IMonitor {
{ checkFrequency: this.checkFrequency },
'Monitor started',
);
- this.emitter.emit(MonitorEventType.Start);
+ this.startHandler?.();
while (this.isMonitorRunning) {
+ const cycleStart = Date.now();
+
try {
this.logger.debug('Polling cycle started');
+
+ const confirmedBlockTags = await this.computeConfirmedBlockTags();
+
const event: MonitorEvent = {
tokensInfo: [],
+ confirmedBlockTags,
};
for (const token of this.warpCore.tokens) {
@@ -76,7 +134,11 @@ export class Monitor implements IMonitor {
},
'Checking token',
);
- const bridgedSupply = await this.getTokenBridgedSupply(token);
+ const blockTag = confirmedBlockTags[token.chainName];
+ const bridgedSupply = await this.getTokenBridgedSupply(
+ token,
+ blockTag,
+ );
event.tokensInfo.push({
token,
@@ -84,12 +146,12 @@ export class Monitor implements IMonitor {
});
}
- // Emit the event warp routes info
- this.emitter.emit(MonitorEventType.TokenInfo, event);
+ if (this.tokenInfoHandler) {
+ await this.tokenInfoHandler(event);
+ }
this.logger.debug('Polling cycle completed');
} catch (error) {
- this.emitter.emit(
- MonitorEventType.Error,
+ this.errorHandler?.(
new MonitorPollingError(
`Error during monitor execution cycle: ${(error as Error).message}`,
error as Error,
@@ -97,12 +159,16 @@ export class Monitor implements IMonitor {
);
}
- // Wait for the specified check frequency before the next iteration
- await sleep(this.checkFrequency);
+ // Smart sleep: only wait for remaining time after cycle completes
+ const elapsed = Date.now() - cycleStart;
+ const remaining = this.checkFrequency - elapsed;
+ if (remaining > 0) {
+ await sleep(remaining);
+ }
+ // If elapsed >= checkFrequency, start next cycle immediately
}
} catch (error) {
- this.emitter.emit(
- MonitorEventType.Error,
+ this.errorHandler?.(
new MonitorStartError(
`Error starting monitor: ${(error as Error).message}`,
error as Error,
@@ -111,7 +177,9 @@ export class Monitor implements IMonitor {
}
// After the loop has been gracefully terminated, we can clean up.
- this.emitter.removeAllListeners();
+ this.tokenInfoHandler = undefined;
+ this.errorHandler = undefined;
+ this.startHandler = undefined;
this.logger.info('Monitor stopped');
// If stop() was called, resolve the promise to signal that we're done.
@@ -124,6 +192,7 @@ export class Monitor implements IMonitor {
private async getTokenBridgedSupply(
token: Token,
+ blockTag?: ConfirmedBlockTag,
): Promise {
if (!token.isHypToken()) {
this.logger.warn(
@@ -138,7 +207,25 @@ export class Monitor implements IMonitor {
}
const adapter = token.getHypAdapter(this.warpCore.multiProvider);
- const bridgedSupply = await adapter.getBridgedSupply();
+ let bridgedSupply: bigint | undefined;
+
+ try {
+ bridgedSupply = await adapter.getBridgedSupply({ blockTag });
+ this.logger.debug(
+ { chain: token.chainName, blockTag },
+ 'Queried confirmed balance',
+ );
+ } catch (error) {
+ this.logger.warn(
+ {
+ chain: token.chainName,
+ blockTag,
+ error: (error as Error).message,
+ },
+ 'Historical block query failed, falling back to latest',
+ );
+ bridgedSupply = await adapter.getBridgedSupply();
+ }
if (bridgedSupply === undefined) {
this.logger.warn(
diff --git a/typescript/rebalancer/src/strategy/BaseStrategy.ts b/typescript/rebalancer/src/strategy/BaseStrategy.ts
index 5a10752f6e2..c9e62c80bf8 100644
--- a/typescript/rebalancer/src/strategy/BaseStrategy.ts
+++ b/typescript/rebalancer/src/strategy/BaseStrategy.ts
@@ -1,13 +1,21 @@
import { type Logger } from 'pino';
-import type { ChainName } from '@hyperlane-xyz/sdk';
+import type { ChainMap, ChainName, Token } from '@hyperlane-xyz/sdk';
+import { toWei } from '@hyperlane-xyz/utils';
import type {
IStrategy,
+ InflightContext,
RawBalances,
- RebalancingRoute,
+ Route,
+ StrategyRoute,
} from '../interfaces/IStrategy.js';
import { type Metrics } from '../metrics/Metrics.js';
+import {
+ type BridgeConfig,
+ type BridgeConfigWithOverride,
+ getBridgeConfig,
+} from '../utils/bridgeUtils.js';
export type Delta = { chain: ChainName; amount: bigint };
@@ -15,41 +23,81 @@ export type Delta = { chain: ChainName; amount: bigint };
* Base abstract class for rebalancing strategies
*/
export abstract class BaseStrategy implements IStrategy {
+ abstract readonly name: string;
protected readonly chains: ChainName[];
protected readonly metrics?: Metrics;
protected readonly logger: Logger;
+ protected readonly bridgeConfigs: ChainMap;
+ protected readonly tokensByChainName?: ChainMap;
- constructor(chains: ChainName[], logger: Logger, metrics?: Metrics) {
+ constructor(
+ chains: ChainName[],
+ logger: Logger,
+ bridgeConfigs: ChainMap,
+ metrics?: Metrics,
+ tokensByChainName?: ChainMap,
+ ) {
// Rebalancing makes sense only with more than one chain.
if (chains.length < 2) {
throw new Error('At least two chains must be configured');
}
this.chains = chains;
this.logger = logger;
+ this.bridgeConfigs = bridgeConfigs;
this.metrics = metrics;
+ this.tokensByChainName = tokensByChainName;
+ }
+
+ protected getBridgeConfigForRoute(
+ origin: ChainName,
+ destination: ChainName,
+ ): BridgeConfig {
+ return getBridgeConfig(this.bridgeConfigs, origin, destination);
}
/**
* Main method to get rebalancing routes
*/
- getRebalancingRoutes(rawBalances: RawBalances): RebalancingRoute[] {
- this.logger.info(
- {
- context: this.constructor.name,
- rawBalances,
- },
- 'Input rawBalances',
- );
+ getRebalancingRoutes(
+ rawBalances: RawBalances,
+ inflightContext?: InflightContext,
+ ): StrategyRoute[] {
+ const pendingRebalances = inflightContext?.pendingRebalances ?? [];
+ const pendingTransfers = inflightContext?.pendingTransfers ?? [];
+ const proposedRebalances = inflightContext?.proposedRebalances ?? [];
+
this.logger.info(
{
- context: this.constructor.name,
+ strategy: this.name,
+ balances: Object.entries(rawBalances).map(([c, b]) => ({
+ chain: c,
+ balance: b.toString(),
+ })),
+ pendingRebalances: pendingRebalances.length,
+ pendingTransfers: pendingTransfers.length,
+ proposedRebalances: proposedRebalances.length,
},
- 'Calculating rebalancing routes',
+ 'Strategy evaluating',
);
this.validateRawBalances(rawBalances);
+ // Store original balances for filtering step
+ const actualBalances = rawBalances;
+
+ // Step 1: Reserve collateral for pending user transfers
+ // This prevents draining collateral needed for incoming user transfers
+ const effectiveBalances = this.reserveCollateral(
+ rawBalances,
+ pendingTransfers,
+ );
+
// Get balances categorized by surplus and deficit
- const { surpluses, deficits } = this.getCategorizedBalances(rawBalances);
+ // Pass pending and proposed rebalances so strategy can account for them
+ const { surpluses, deficits } = this.getCategorizedBalances(
+ effectiveBalances,
+ pendingRebalances,
+ proposedRebalances,
+ );
this.logger.debug(
{
@@ -126,7 +174,7 @@ export abstract class BaseStrategy implements IStrategy {
surpluses.sort((a, b) => (a.amount > b.amount ? -1 : 1));
deficits.sort((a, b) => (a.amount > b.amount ? -1 : 1));
- const routes: RebalancingRoute[] = [];
+ const routes: StrategyRoute[] = [];
// Transfer from surplus to deficit until all deficits are balanced.
while (deficits.length > 0 && surpluses.length > 0) {
@@ -137,24 +185,34 @@ export abstract class BaseStrategy implements IStrategy {
const transferAmount =
surplus.amount > deficit.amount ? deficit.amount : surplus.amount;
- // Creates the balancing route
- routes.push({
- origin: surplus.chain,
- destination: deficit.chain,
- amount: transferAmount,
- });
+ // Skip zero-amount routes (can occur after scaling when surpluses < deficits)
+ if (transferAmount > 0n) {
+ // Get bridge config for this route (with destination-specific overrides)
+ const bridgeConfig = this.getBridgeConfigForRoute(
+ surplus.chain,
+ deficit.chain,
+ );
+
+ // Creates the balancing route
+ routes.push({
+ origin: surplus.chain,
+ destination: deficit.chain,
+ amount: transferAmount,
+ bridge: bridgeConfig.bridge,
+ });
+ }
// Decreases the amounts for the following iterations
deficit.amount -= transferAmount;
surplus.amount -= transferAmount;
- // Removes the deficit if it is fully balanced
- if (!deficit.amount) {
+ // Removes the deficit if it is fully balanced (including scaled-to-zero)
+ if (deficit.amount <= 0n) {
deficits.shift();
}
// Removes the surplus if it has been drained
- if (!surplus.amount) {
+ if (surplus.amount <= 0n) {
surpluses.shift();
}
}
@@ -173,14 +231,40 @@ export abstract class BaseStrategy implements IStrategy {
},
'Found rebalancing routes',
);
- return routes;
+
+ const filteredRoutes = this.filterRoutes(routes, actualBalances);
+
+ this.logger.debug(
+ {
+ context: this.constructor.name,
+ filteredRoutesCount: filteredRoutes.length,
+ droppedCount: routes.length - filteredRoutes.length,
+ },
+ 'Filtered rebalancing routes',
+ );
+
+ // Record metrics for each intent that passed filtering
+ for (const route of filteredRoutes) {
+ this.metrics?.recordIntentCreated(route, this.name);
+ }
+
+ return filteredRoutes;
}
/**
* Abstract method to get balances categorized by surplus and deficit
* Each specific strategy should implement its own logic
+ *
+ * @param balances - Effective balances (after collateral reservation)
+ * @param pendingRebalances - In-flight rebalances (origin tx confirmed, balance already deducted)
+ * @param proposedRebalances - Routes from earlier strategies in same cycle (not yet executed)
+ * @returns Categorized surpluses and deficits as Delta arrays
*/
- protected abstract getCategorizedBalances(rawBalances: RawBalances): {
+ protected abstract getCategorizedBalances(
+ balances: RawBalances,
+ pendingRebalances?: Route[],
+ proposedRebalances?: StrategyRoute[],
+ ): {
surpluses: Delta[];
deficits: Delta[];
};
@@ -207,4 +291,210 @@ export abstract class BaseStrategy implements IStrategy {
}
}
}
+
+ /**
+ * Reserve collateral for pending user transfers.
+ * Subtracts pending transfer amounts from destination balances.
+ * This ensures we don't drain collateral needed for incoming transfers.
+ *
+ * @param rawBalances - Current on-chain balances
+ * @param pendingTransfers - Transfers that will need collateral on destination
+ * @returns Balances with reserved amounts subtracted
+ */
+ protected reserveCollateral(
+ rawBalances: RawBalances,
+ pendingTransfers: Route[],
+ ): RawBalances {
+ if (pendingTransfers.length === 0) {
+ return rawBalances;
+ }
+
+ const reserved = { ...rawBalances };
+
+ for (const transfer of pendingTransfers) {
+ const destBalance = reserved[transfer.destination] ?? 0n;
+ // Reserve the transfer amount from destination
+ // Allow negative values to indicate collateral deficits
+ reserved[transfer.destination] = destBalance - transfer.amount;
+
+ this.logger.debug(
+ {
+ context: this.constructor.name,
+ destination: transfer.destination,
+ amount: transfer.amount.toString(),
+ newBalance: reserved[transfer.destination].toString(),
+ },
+ 'Reserved collateral for pending transfer',
+ );
+ }
+
+ this.logger.info(
+ {
+ reservations: pendingTransfers.map((t) => ({
+ destination: t.destination,
+ amount: t.amount.toString(),
+ })),
+ },
+ 'Collateral reserved for pending transfers',
+ );
+
+ return reserved;
+ }
+
+ /**
+ * Simulate pending rebalances by adding to destination balances.
+ *
+ * Only adds to destination - does NOT subtract from origin because:
+ * - pendingRebalances only contains in_progress intents (origin tx confirmed)
+ * - Origin balance is already deducted on-chain
+ *
+ * @param rawBalances - Current balances (may already have collateral reserved)
+ * @param pendingRebalances - In-flight rebalance operations (in_progress only)
+ * @returns Simulated future balances after rebalances complete
+ */
+ protected simulatePendingRebalances(
+ rawBalances: RawBalances,
+ pendingRebalances: Route[],
+ ): RawBalances {
+ if (pendingRebalances.length === 0) {
+ return rawBalances;
+ }
+
+ const simulated = { ...rawBalances };
+
+ for (const rebalance of pendingRebalances) {
+ // Only add to destination - origin is already deducted on-chain
+ // (pendingRebalances only contains in_progress intents with confirmed origin tx)
+ simulated[rebalance.destination] =
+ (simulated[rebalance.destination] ?? 0n) + rebalance.amount;
+
+ this.logger.debug(
+ {
+ context: this.constructor.name,
+ destination: rebalance.destination,
+ amount: rebalance.amount.toString(),
+ },
+ 'Simulated pending rebalance (destination increase)',
+ );
+ }
+
+ this.logger.info(
+ {
+ simulations: pendingRebalances.map((r) => ({
+ from: r.origin,
+ to: r.destination,
+ amount: r.amount.toString(),
+ })),
+ },
+ 'Simulated pending rebalances',
+ );
+
+ return simulated;
+ }
+
+ /**
+ * Simulate proposed rebalances by subtracting from origin AND adding to destination.
+ *
+ * Unlike pendingRebalances, proposedRebalances are routes from earlier strategies
+ * in the same cycle that haven't been executed yet. Therefore:
+ * - Origin balance has NOT been deducted on-chain
+ * - We must simulate both sides to maintain accurate total balance
+ *
+ * @param rawBalances - Current balances (may already have pending rebalances simulated)
+ * @param proposedRebalances - Routes from earlier strategies (not yet executed)
+ * @returns Simulated balances after proposed rebalances complete
+ */
+ protected simulateProposedRebalances(
+ rawBalances: RawBalances,
+ proposedRebalances: Route[],
+ ): RawBalances {
+ if (proposedRebalances.length === 0) {
+ return rawBalances;
+ }
+
+ const simulated = { ...rawBalances };
+
+ for (const rebalance of proposedRebalances) {
+ // Subtract from origin (not yet deducted on-chain)
+ simulated[rebalance.origin] =
+ (simulated[rebalance.origin] ?? 0n) - rebalance.amount;
+
+ // Add to destination
+ simulated[rebalance.destination] =
+ (simulated[rebalance.destination] ?? 0n) + rebalance.amount;
+
+ this.logger.debug(
+ {
+ context: this.constructor.name,
+ origin: rebalance.origin,
+ destination: rebalance.destination,
+ amount: rebalance.amount.toString(),
+ },
+ 'Simulated proposed rebalance (origin decrease, destination increase)',
+ );
+ }
+
+ this.logger.info(
+ {
+ simulations: proposedRebalances.map((r) => ({
+ from: r.origin,
+ to: r.destination,
+ amount: r.amount.toString(),
+ })),
+ },
+ 'Simulated proposed rebalances',
+ );
+
+ return simulated;
+ }
+
+ protected filterRoutes(
+ routes: StrategyRoute[],
+ actualBalances: RawBalances,
+ ): StrategyRoute[] {
+ return routes.filter((route) => {
+ const balance = actualBalances[route.origin] ?? 0n;
+ if (balance < route.amount) {
+ this.logger.warn(
+ {
+ context: this.constructor.name,
+ origin: route.origin,
+ destination: route.destination,
+ required: route.amount.toString(),
+ available: balance.toString(),
+ },
+ 'Dropping route due to insufficient balance',
+ );
+ return false;
+ }
+
+ if (this.tokensByChainName) {
+ const token = this.tokensByChainName[route.origin];
+ if (token) {
+ const bridgeConfig = this.getBridgeConfigForRoute(
+ route.origin,
+ route.destination,
+ );
+ const minAmount = BigInt(
+ toWei(bridgeConfig.bridgeMinAcceptedAmount, token.decimals),
+ );
+ if (route.amount < minAmount) {
+ this.logger.info(
+ {
+ context: this.constructor.name,
+ origin: route.origin,
+ destination: route.destination,
+ amount: route.amount.toString(),
+ minAmount: minAmount.toString(),
+ },
+ 'Dropping route below bridgeMinAcceptedAmount',
+ );
+ return false;
+ }
+ }
+ }
+
+ return true;
+ });
+ }
}
diff --git a/typescript/rebalancer/src/strategy/CollateralDeficitStrategy.test.ts b/typescript/rebalancer/src/strategy/CollateralDeficitStrategy.test.ts
new file mode 100644
index 00000000000..884a63f4e8e
--- /dev/null
+++ b/typescript/rebalancer/src/strategy/CollateralDeficitStrategy.test.ts
@@ -0,0 +1,551 @@
+import { expect } from 'chai';
+import { pino } from 'pino';
+
+import {
+ type ChainMap,
+ type ChainName,
+ Token,
+ TokenStandard,
+} from '@hyperlane-xyz/sdk';
+import type { Address } from '@hyperlane-xyz/utils';
+
+import type {
+ RawBalances,
+ Route,
+ StrategyRoute,
+} from '../interfaces/IStrategy.js';
+import { extractBridgeConfigs } from '../test/helpers.js';
+
+import { CollateralDeficitStrategy } from './CollateralDeficitStrategy.js';
+
+const testLogger = pino({ level: 'silent' });
+
+const BRIDGE1 = '0x1234567890123456789012345678901234567890' as Address;
+const BRIDGE2 = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address;
+const OTHER_BRIDGE = '0x9876543210987654321098765432109876543210' as Address;
+
+describe('CollateralDeficitStrategy', () => {
+ let chain1: ChainName;
+ let chain2: ChainName;
+ let chain3: ChainName;
+ const tokensByChainName: ChainMap = {};
+ const tokenArgs = {
+ name: 'USDC',
+ decimals: 6, // USDC has 6 decimals
+ symbol: 'USDC',
+ standard: TokenStandard.ERC20,
+ addressOrDenom: '',
+ };
+
+ beforeEach(() => {
+ chain1 = 'chain1';
+ chain2 = 'chain2';
+ chain3 = 'chain3';
+ tokensByChainName[chain1] = new Token({ ...tokenArgs, chainName: chain1 });
+ tokensByChainName[chain2] = new Token({ ...tokenArgs, chainName: chain2 });
+ tokensByChainName[chain3] = new Token({ ...tokenArgs, chainName: chain3 });
+ });
+
+ describe('constructor', () => {
+ it('should throw an error when less than two chains are configured', () => {
+ expect(
+ () =>
+ new CollateralDeficitStrategy(
+ {
+ [chain1]: {
+ bridge: BRIDGE1,
+ buffer: '1000',
+ },
+ },
+ tokensByChainName,
+ testLogger,
+ {},
+ ),
+ ).to.throw('At least two chains must be configured');
+ });
+
+ it('should create a strategy with valid config', () => {
+ const strategy = new CollateralDeficitStrategy(
+ {
+ [chain1]: {
+ bridge: BRIDGE1,
+ buffer: '1000',
+ },
+ [chain2]: {
+ bridge: BRIDGE2,
+ buffer: '500',
+ },
+ },
+ tokensByChainName,
+ testLogger,
+ {},
+ );
+ expect(strategy).to.be.instanceOf(CollateralDeficitStrategy);
+ });
+ });
+
+ describe('getCategorizedBalances', () => {
+ it('should detect deficit when balance is negative and add buffer', () => {
+ const strategy = new CollateralDeficitStrategy(
+ {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ },
+ tokensByChainName,
+ testLogger,
+ {},
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: -5_000_000n, // -5 USDC (6 decimals)
+ [chain2]: 10_000_000n, // 10 USDC
+ };
+
+ const result = strategy['getCategorizedBalances'](rawBalances);
+
+ // chain1: deficit = |-5 USDC| + 1000 USDC = 1005 USDC = 1005000000 (wei)
+ expect(result.deficits).to.have.lengthOf(1);
+ expect(result.deficits[0].chain).to.equal(chain1);
+ expect(result.deficits[0].amount).to.equal(1_005_000_000n);
+
+ // chain2: surplus = 10 USDC
+ expect(result.surpluses).to.have.lengthOf(1);
+ expect(result.surpluses[0].chain).to.equal(chain2);
+ expect(result.surpluses[0].amount).to.equal(10_000_000n);
+ });
+
+ it('should treat zero balance as neither surplus nor deficit', () => {
+ const strategy = new CollateralDeficitStrategy(
+ {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ },
+ tokensByChainName,
+ testLogger,
+ {},
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: 0n,
+ [chain2]: 10_000_000n,
+ };
+
+ const result = strategy['getCategorizedBalances'](rawBalances);
+
+ expect(result.deficits).to.have.lengthOf(0);
+ expect(result.surpluses).to.have.lengthOf(1);
+ expect(result.surpluses[0].chain).to.equal(chain2);
+ });
+
+ it('should treat positive balance as surplus', () => {
+ const strategy = new CollateralDeficitStrategy(
+ {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ },
+ tokensByChainName,
+ testLogger,
+ {},
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: 5_000_000n, // 5 USDC
+ [chain2]: 10_000_000n, // 10 USDC
+ };
+
+ const result = strategy['getCategorizedBalances'](rawBalances);
+
+ expect(result.deficits).to.have.lengthOf(0);
+ expect(result.surpluses).to.have.lengthOf(2);
+ });
+
+ it('should filter pending rebalances by configured bridges and simulate', () => {
+ const config = {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+
+ const strategy = new CollateralDeficitStrategy(
+ config,
+ tokensByChainName,
+ testLogger,
+ bridgeConfigs,
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: -10_000_000n, // -10 USDC before simulation
+ [chain2]: 20_000_000n, // 20 USDC
+ };
+
+ const pendingRebalances: StrategyRoute[] = [
+ {
+ origin: chain2,
+ destination: chain1,
+ amount: 5_000_000n, // 5 USDC pending to chain1
+ bridge: BRIDGE2, // Matches chain2's bridge for chain2->chain1 route
+ },
+ ];
+
+ const result = strategy['getCategorizedBalances'](
+ rawBalances,
+ pendingRebalances,
+ );
+
+ // After simulation: chain1 = -10 + 5 = -5 USDC (destination increase)
+ // Deficit = |-5| + 1000 = 1005 USDC
+ expect(result.deficits).to.have.lengthOf(1);
+ expect(result.deficits[0].chain).to.equal(chain1);
+ expect(result.deficits[0].amount).to.equal(1_005_000_000n);
+
+ // chain2 after simulation: 20 USDC (no change - origin already deducted on-chain)
+ // Simulation only adds to destination, doesn't subtract from origin
+ expect(result.surpluses).to.have.lengthOf(1);
+ expect(result.surpluses[0].chain).to.equal(chain2);
+ expect(result.surpluses[0].amount).to.equal(20_000_000n);
+ });
+
+ it('should filter out pending rebalances with different bridge', () => {
+ const config = {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+
+ const strategy = new CollateralDeficitStrategy(
+ config,
+ tokensByChainName,
+ testLogger,
+ bridgeConfigs,
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: -10_000_000n,
+ [chain2]: 20_000_000n,
+ };
+
+ const pendingRebalances: StrategyRoute[] = [
+ {
+ origin: chain2,
+ destination: chain1,
+ amount: 5_000_000n,
+ bridge: OTHER_BRIDGE, // Does NOT match chain2's configured bridge for chain2->chain1
+ },
+ ];
+
+ const result = strategy['getCategorizedBalances'](
+ rawBalances,
+ pendingRebalances,
+ );
+
+ // Pending rebalance should be filtered out, so no simulation
+ // Deficit = |-10| + 1000 = 1010 USDC
+ expect(result.deficits).to.have.lengthOf(1);
+ expect(result.deficits[0].amount).to.equal(1_010_000_000n);
+
+ // chain2: no subtraction, stays at 20 USDC
+ expect(result.surpluses[0].amount).to.equal(20_000_000n);
+ });
+
+ it('should handle pending rebalance that fully covers deficit', () => {
+ const config = {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+
+ const strategy = new CollateralDeficitStrategy(
+ config,
+ tokensByChainName,
+ testLogger,
+ bridgeConfigs,
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: -5_000_000n, // -5 USDC
+ [chain2]: 20_000_000n,
+ };
+
+ const pendingRebalances: StrategyRoute[] = [
+ {
+ origin: chain2,
+ destination: chain1,
+ amount: 10_000_000n, // 10 USDC pending - more than enough
+ bridge: BRIDGE2, // Matches chain2's configured bridge for chain2->chain1
+ },
+ ];
+
+ const result = strategy['getCategorizedBalances'](
+ rawBalances,
+ pendingRebalances,
+ );
+
+ // After simulation: chain1 = -5 + 10 = 5 USDC (positive, no deficit)
+ expect(result.deficits).to.have.lengthOf(0);
+ expect(result.surpluses).to.have.lengthOf(2); // Both chains have surplus
+ });
+
+ it('should handle multiple chains with mixed balances', () => {
+ const strategy = new CollateralDeficitStrategy(
+ {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ [chain3]: { bridge: BRIDGE1, buffer: '2000' },
+ },
+ tokensByChainName,
+ testLogger,
+ {},
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: -5_000_000n, // -5 USDC -> deficit
+ [chain2]: 10_000_000n, // 10 USDC -> surplus
+ [chain3]: -3_000_000n, // -3 USDC -> deficit
+ };
+
+ const result = strategy['getCategorizedBalances'](rawBalances);
+
+ expect(result.deficits).to.have.lengthOf(2);
+ expect(result.surpluses).to.have.lengthOf(1);
+
+ // chain1: deficit = 5 + 1000 = 1005 USDC
+ const chain1Deficit = result.deficits.find((d) => d.chain === chain1);
+ expect(chain1Deficit?.amount).to.equal(1_005_000_000n);
+
+ // chain3: deficit = 3 + 2000 = 2003 USDC
+ const chain3Deficit = result.deficits.find((d) => d.chain === chain3);
+ expect(chain3Deficit?.amount).to.equal(2_003_000_000n);
+ });
+
+ it('should handle empty pending rebalances array', () => {
+ const strategy = new CollateralDeficitStrategy(
+ {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ },
+ tokensByChainName,
+ testLogger,
+ {},
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: -5_000_000n,
+ [chain2]: 10_000_000n,
+ };
+
+ const result = strategy['getCategorizedBalances'](rawBalances, []);
+
+ // No simulation should occur
+ expect(result.deficits).to.have.lengthOf(1);
+ expect(result.deficits[0].amount).to.equal(1_005_000_000n);
+ });
+ });
+
+ describe('getRebalancingRoutes', () => {
+ it('should set bridge field on output routes', () => {
+ const config = {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+
+ const strategy = new CollateralDeficitStrategy(
+ config,
+ tokensByChainName,
+ testLogger,
+ bridgeConfigs,
+ );
+
+ // Start with positive balances
+ const rawBalances: RawBalances = {
+ [chain1]: 2_000_000n, // 2 USDC
+ [chain2]: 20_000_000n, // 20 USDC
+ };
+
+ // Pending transfer will drain chain1 to create deficit
+ const inflightContext = {
+ pendingTransfers: [
+ {
+ origin: chain2,
+ destination: chain1,
+ amount: 7_000_000n, // 7 USDC pending to chain1
+ },
+ ],
+ pendingRebalances: [] as StrategyRoute[],
+ };
+
+ // After reserveCollateral: chain1 = 2 - 7 = -5 USDC (deficit)
+ const routes = strategy.getRebalancingRoutes(
+ rawBalances,
+ inflightContext,
+ );
+
+ expect(routes).to.have.lengthOf(1);
+ expect(routes[0].origin).to.equal(chain2);
+ expect(routes[0].destination).to.equal(chain1);
+ expect(routes[0].bridge).to.equal(BRIDGE2); // Uses chain2's (origin) bridge
+ });
+
+ it('should generate routes from surplus to deficit chains', () => {
+ const config = {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ [chain3]: { bridge: BRIDGE1, buffer: '100' },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+
+ const strategy = new CollateralDeficitStrategy(
+ config,
+ tokensByChainName,
+ testLogger,
+ bridgeConfigs,
+ );
+
+ // Start with positive balances
+ const rawBalances: RawBalances = {
+ [chain1]: 5_000_000n, // 5 USDC
+ [chain2]: 20_000_000n, // 20 USDC
+ [chain3]: 5_000_000n, // 5 USDC
+ };
+
+ // Pending transfer will create deficit on chain1
+ const inflightContext = {
+ pendingTransfers: [
+ {
+ origin: chain2,
+ destination: chain1,
+ amount: 15_000_000n, // 15 USDC pending to chain1
+ },
+ ],
+ pendingRebalances: [] as StrategyRoute[],
+ };
+
+ // After reserveCollateral: chain1 = 5 - 15 = -10 USDC (deficit)
+ const routes = strategy.getRebalancingRoutes(
+ rawBalances,
+ inflightContext,
+ );
+
+ // Should have route(s) from surplus chains (chain2, chain3) to deficit chain (chain1)
+ expect(routes.length).to.be.greaterThan(0);
+ routes.forEach((route) => {
+ expect([chain2, chain3]).to.include(route.origin);
+ expect(route.destination).to.equal(chain1);
+ });
+ });
+ });
+
+ describe('filterByConfiguredBridges', () => {
+ it('should filter rebalances by configured bridge for route', () => {
+ const config = {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+
+ const strategy = new CollateralDeficitStrategy(
+ config,
+ tokensByChainName,
+ testLogger,
+ bridgeConfigs,
+ );
+
+ const pendingRebalances: Array = [
+ {
+ origin: chain2,
+ destination: chain1,
+ amount: 5_000_000n,
+ bridge: BRIDGE2,
+ },
+ {
+ origin: chain1,
+ destination: chain2,
+ amount: 3_000_000n,
+ bridge: OTHER_BRIDGE,
+ },
+ {
+ origin: chain2,
+ destination: chain1,
+ amount: 2_000_000n,
+ },
+ ];
+
+ const filtered = strategy['filterByConfiguredBridges'](pendingRebalances);
+
+ expect(filtered).to.have.lengthOf(2);
+ expect((filtered[0] as StrategyRoute).bridge).to.equal(BRIDGE2);
+ expect((filtered[1] as StrategyRoute).bridge).to.be.undefined;
+ });
+
+ it('should include rebalance when bridge matches configured bridge for the route', () => {
+ const config = {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+
+ const strategy = new CollateralDeficitStrategy(
+ config,
+ tokensByChainName,
+ testLogger,
+ bridgeConfigs,
+ );
+
+ const pendingRebalances: Array = [
+ {
+ origin: chain2,
+ destination: chain1,
+ amount: 5_000_000n,
+ bridge: BRIDGE2,
+ },
+ ];
+
+ const filtered = strategy['filterByConfiguredBridges'](pendingRebalances);
+ expect(filtered).to.have.lengthOf(1);
+ expect((filtered[0] as StrategyRoute).bridge).to.equal(BRIDGE2);
+ });
+
+ it('should exclude rebalance when bridge does not match configured bridge for the route', () => {
+ const config = {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+
+ const strategy = new CollateralDeficitStrategy(
+ config,
+ tokensByChainName,
+ testLogger,
+ bridgeConfigs,
+ );
+
+ // Route from chain2 → chain1 with a different bridge
+ const pendingRebalances: StrategyRoute[] = [
+ {
+ origin: chain2,
+ destination: chain1,
+ amount: 5_000_000n,
+ bridge: BRIDGE1, // Does NOT match configured bridge for chain2->chain1 (should be BRIDGE2)
+ },
+ ];
+
+ const filtered = strategy['filterByConfiguredBridges'](pendingRebalances);
+ expect(filtered).to.have.lengthOf(0);
+ });
+
+ it('should return empty array for undefined pending rebalances', () => {
+ const strategy = new CollateralDeficitStrategy(
+ {
+ [chain1]: { bridge: BRIDGE1, buffer: '1000' },
+ [chain2]: { bridge: BRIDGE2, buffer: '500' },
+ },
+ tokensByChainName,
+ testLogger,
+ {},
+ );
+
+ const filtered = strategy['filterByConfiguredBridges'](undefined);
+ expect(filtered).to.have.lengthOf(0);
+ });
+ });
+});
diff --git a/typescript/rebalancer/src/strategy/CollateralDeficitStrategy.ts b/typescript/rebalancer/src/strategy/CollateralDeficitStrategy.ts
new file mode 100644
index 00000000000..7349128289d
--- /dev/null
+++ b/typescript/rebalancer/src/strategy/CollateralDeficitStrategy.ts
@@ -0,0 +1,390 @@
+import { Logger } from 'pino';
+
+import { type ChainMap, type ChainName, type Token } from '@hyperlane-xyz/sdk';
+import { toWei } from '@hyperlane-xyz/utils';
+
+import {
+ type CollateralDeficitStrategyConfig,
+ RebalancerStrategyOptions,
+} from '../config/types.js';
+import type {
+ InflightContext,
+ RawBalances,
+ Route,
+ StrategyRoute,
+} from '../interfaces/IStrategy.js';
+import { Metrics } from '../metrics/Metrics.js';
+import type { BridgeConfigWithOverride } from '../utils/bridgeUtils.js';
+
+import { BaseStrategy, type Delta } from './BaseStrategy.js';
+
+/**
+ * Strategy that detects collateral deficits (negative effective balances)
+ * and proposes JIT rebalances using fast bridges.
+ *
+ * Logic:
+ * 1. Filter pendingRebalances to only those using this strategy's configured bridges
+ * 2. Simulate filtered pending rebalances to get projected balances
+ * 3. Negative simulated balance = deficit (magnitude + buffer)
+ * 4. Positive simulated balance = potential surplus
+ */
+export class CollateralDeficitStrategy extends BaseStrategy {
+ readonly name = RebalancerStrategyOptions.CollateralDeficit;
+ private readonly config: CollateralDeficitStrategyConfig;
+ protected readonly logger: Logger;
+
+ constructor(
+ config: CollateralDeficitStrategyConfig,
+ tokensByChainName: ChainMap,
+ logger: Logger,
+ bridgeConfigs: ChainMap,
+ metrics?: Metrics,
+ ) {
+ const chains = Object.keys(config);
+ const log = logger.child({ class: CollateralDeficitStrategy.name });
+ super(chains, log, bridgeConfigs, metrics, tokensByChainName);
+ this.logger = log;
+ this.config = config;
+ this.logger.info('CollateralDeficitStrategy created');
+ }
+
+ /**
+ * Categorizes balances into surpluses and deficits.
+ *
+ * 1. Filter pendingRebalances/proposedRebalances by configured bridges
+ * 2. Simulate those rebalances to get projected balances
+ * 3. Negative balance = deficit (magnitude + buffer)
+ * 4. Positive balance = potential surplus
+ */
+ protected getCategorizedBalances(
+ rawBalances: RawBalances,
+ pendingRebalances?: Route[],
+ proposedRebalances?: StrategyRoute[],
+ ): {
+ surpluses: Delta[];
+ deficits: Delta[];
+ } {
+ // Filter pending rebalances to only those using this strategy's bridges
+ const filteredPending = this.filterByConfiguredBridges(pendingRebalances);
+ const filteredProposed = this.filterByConfiguredBridges(proposedRebalances);
+
+ this.logger.debug(
+ {
+ context: this.constructor.name,
+ totalPending: pendingRebalances?.length ?? 0,
+ filteredPending: filteredPending.length,
+ totalProposed: proposedRebalances?.length ?? 0,
+ filteredProposed: filteredProposed.length,
+ },
+ 'Filtered rebalances by configured bridges',
+ );
+
+ // Step 1: Simulate pending rebalances (in-flight, origin already deducted on-chain)
+ let simulatedBalances = this.simulatePendingRebalances(
+ rawBalances,
+ filteredPending,
+ );
+
+ // Step 2: Simulate proposed rebalances (from earlier strategies, not yet executed)
+ simulatedBalances = this.simulateProposedRebalances(
+ simulatedBalances,
+ filteredProposed,
+ );
+
+ const surpluses: Delta[] = [];
+ const deficits: Delta[] = [];
+
+ for (const chain of this.chains) {
+ const balance = simulatedBalances[chain];
+ const token = this.getTokenByChainName(chain);
+ const bufferWei = BigInt(
+ toWei(this.config[chain].buffer, token.decimals),
+ );
+
+ if (balance < 0n) {
+ // Negative balance indicates deficit
+ const deficitAmount = -balance + bufferWei;
+ deficits.push({ chain, amount: deficitAmount });
+
+ this.logger.debug(
+ {
+ context: this.constructor.name,
+ chain,
+ simulatedBalance: balance.toString(),
+ buffer: bufferWei.toString(),
+ deficitAmount: deficitAmount.toString(),
+ },
+ 'Detected collateral deficit',
+ );
+ } else if (balance > 0n) {
+ // Positive balance is potential surplus
+ surpluses.push({ chain, amount: balance });
+ }
+ }
+
+ this.logger.info(
+ {
+ surpluses: surpluses.map((s) => ({
+ chain: s.chain,
+ amount: s.amount.toString(),
+ })),
+ deficits: deficits.map((d) => ({
+ chain: d.chain,
+ amount: d.amount.toString(),
+ })),
+ },
+ 'Balance categorization',
+ );
+
+ return { surpluses, deficits };
+ }
+
+ /**
+ * Override to prefer transfer origins when selecting surplus chains.
+ *
+ * When a user transfer creates a deficit, the origin chain of that transfer
+ * is the natural source of funds (user deposited there). This prevents
+ * unnecessarily draining the largest balance (typically ethereum at 70%).
+ */
+ override getRebalancingRoutes(
+ rawBalances: RawBalances,
+ inflightContext?: InflightContext,
+ ): StrategyRoute[] {
+ const pendingRebalances = inflightContext?.pendingRebalances ?? [];
+ const pendingTransfers = inflightContext?.pendingTransfers ?? [];
+
+ this.logger.info(
+ {
+ strategy: this.name,
+ balances: Object.entries(rawBalances).map(([c, b]) => ({
+ chain: c,
+ balance: b.toString(),
+ })),
+ pendingRebalances: pendingRebalances.length,
+ pendingTransfers: pendingTransfers.length,
+ },
+ 'Strategy evaluating',
+ );
+ this.validateRawBalances(rawBalances);
+
+ const actualBalances = rawBalances;
+
+ // Step 1: Reserve collateral for pending user transfers
+ const effectiveBalances = this.reserveCollateral(
+ rawBalances,
+ pendingTransfers,
+ );
+
+ // Step 2: Get categorized balances
+ const { surpluses, deficits } = this.getCategorizedBalances(
+ effectiveBalances,
+ pendingRebalances,
+ );
+
+ this.logger.debug(
+ { context: this.constructor.name, surpluses },
+ 'Surpluses calculated',
+ );
+ this.logger.debug(
+ { context: this.constructor.name, deficits },
+ 'Deficits calculated',
+ );
+
+ const totalSurplus = surpluses.reduce((sum, s) => sum + s.amount, 0n);
+ const totalDeficit = deficits.reduce((sum, d) => sum + d.amount, 0n);
+
+ this.logger.debug(
+ { context: this.constructor.name, totalSurplus: totalSurplus.toString() },
+ 'Total surplus calculated',
+ );
+ this.logger.debug(
+ { context: this.constructor.name, totalDeficit: totalDeficit.toString() },
+ 'Total deficit calculated',
+ );
+
+ // Scale deficits if needed
+ if (totalSurplus < totalDeficit) {
+ this.logger.warn(
+ {
+ context: this.constructor.name,
+ totalSurplus: totalSurplus.toString(),
+ totalDeficit: totalDeficit.toString(),
+ },
+ 'Deficits are greater than surpluses. Scaling deficits',
+ );
+ this.metrics?.recordRebalancerFailure();
+ for (const deficit of deficits) {
+ deficit.amount = (deficit.amount * totalSurplus) / totalDeficit;
+ }
+ this.logger.debug(
+ { context: this.constructor.name, deficits },
+ 'Scaled deficits',
+ );
+ }
+
+ // Build transfer origin map for deficit chains
+ const deficitChains = new Set(deficits.map((d) => d.chain));
+ const transferOriginMap = this.buildTransferOriginMap(
+ pendingTransfers,
+ deficitChains,
+ );
+
+ // Sort surpluses with transfer origin preference (KEY CHANGE from base class)
+ this.sortSurplusesWithOriginPreference(surpluses, transferOriginMap);
+
+ // Sort deficits by amount (largest first)
+ deficits.sort((a, b) => (a.amount > b.amount ? -1 : 1));
+
+ const routes: StrategyRoute[] = [];
+
+ // Match surpluses to deficits
+ while (deficits.length > 0 && surpluses.length > 0) {
+ const surplus = surpluses[0];
+ const deficit = deficits[0];
+ const transferAmount =
+ surplus.amount > deficit.amount ? deficit.amount : surplus.amount;
+
+ if (transferAmount > 0n) {
+ const bridgeConfig = this.getBridgeConfigForRoute(
+ surplus.chain,
+ deficit.chain,
+ );
+ routes.push({
+ origin: surplus.chain,
+ destination: deficit.chain,
+ amount: transferAmount,
+ bridge: bridgeConfig.bridge,
+ });
+ }
+
+ deficit.amount -= transferAmount;
+ surplus.amount -= transferAmount;
+
+ if (deficit.amount <= 0n) deficits.shift();
+ if (surplus.amount <= 0n) surpluses.shift();
+ }
+
+ this.logger.debug(
+ { context: this.constructor.name, routes },
+ 'Generated routes',
+ );
+ this.logger.info(
+ { context: this.constructor.name, numberOfRoutes: routes.length },
+ 'Found rebalancing routes',
+ );
+
+ const filteredRoutes = this.filterRoutes(routes, actualBalances);
+
+ this.logger.debug(
+ {
+ context: this.constructor.name,
+ filteredRoutesCount: filteredRoutes.length,
+ droppedCount: routes.length - filteredRoutes.length,
+ },
+ 'Filtered rebalancing routes',
+ );
+
+ return filteredRoutes;
+ }
+
+ /**
+ * Filter pending rebalances to only those using this strategy's configured bridges.
+ * A rebalance matches if:
+ * - Its bridge matches the configured bridge (with overrides) for the route, OR
+ * - It has no bridge (recovered from Explorer, can't verify - include to be safe)
+ */
+ private filterByConfiguredBridges(pendingRebalances?: Route[]): Route[] {
+ if (!pendingRebalances || pendingRebalances.length === 0) {
+ return [];
+ }
+
+ return pendingRebalances.filter((rebalance) => {
+ if (!('bridge' in rebalance) || !rebalance.bridge) {
+ this.logger.debug(
+ { origin: rebalance.origin, destination: rebalance.destination },
+ 'Including pending rebalance without bridge (recovered intent)',
+ );
+ return true;
+ }
+ const bridgeConfig = this.getBridgeConfigForRoute(
+ rebalance.origin,
+ rebalance.destination,
+ );
+ return bridgeConfig?.bridge === rebalance.bridge;
+ });
+ }
+
+ /**
+ * Build a map from deficit chains to their transfer origin chains.
+ * This identifies which surplus chains are "natural" sources for each deficit.
+ */
+ private buildTransferOriginMap(
+ pendingTransfers: Route[],
+ deficitChains: Set,
+ ): Map> {
+ const originMap = new Map>();
+
+ for (const transfer of pendingTransfers) {
+ // Only track transfers TO deficit chains
+ if (deficitChains.has(transfer.destination)) {
+ if (!originMap.has(transfer.destination)) {
+ originMap.set(transfer.destination, new Set());
+ }
+ originMap.get(transfer.destination)!.add(transfer.origin);
+ }
+ }
+
+ return originMap;
+ }
+
+ /**
+ * Sort surpluses to prefer transfer origins over largest balances.
+ *
+ * Sorting priority:
+ * 1. Chains that are origins of transfers TO any deficit chain (preferred)
+ * 2. By amount descending (tiebreaker)
+ */
+ private sortSurplusesWithOriginPreference(
+ surpluses: Delta[],
+ transferOriginMap: Map>,
+ ): void {
+ // Collect all origin chains across all deficits
+ const allOriginChains = new Set();
+ for (const origins of transferOriginMap.values()) {
+ for (const origin of origins) {
+ allOriginChains.add(origin);
+ }
+ }
+
+ surpluses.sort((a, b) => {
+ const aIsOrigin = allOriginChains.has(a.chain);
+ const bIsOrigin = allOriginChains.has(b.chain);
+
+ // Prefer transfer origins
+ if (aIsOrigin && !bIsOrigin) return -1;
+ if (!aIsOrigin && bIsOrigin) return 1;
+
+ // Tiebreaker: larger amount first
+ return a.amount > b.amount ? -1 : 1;
+ });
+
+ if (allOriginChains.size > 0) {
+ this.logger.debug(
+ {
+ context: this.constructor.name,
+ preferredOrigins: Array.from(allOriginChains),
+ sortedSurpluses: surpluses.map((s) => s.chain),
+ },
+ 'Sorted surpluses with transfer origin preference',
+ );
+ }
+ }
+
+ protected getTokenByChainName(chainName: string): Token {
+ const token = this.tokensByChainName![chainName];
+ if (token === undefined) {
+ throw new Error(`Token not found for chain ${chainName}`);
+ }
+ return token;
+ }
+}
diff --git a/typescript/rebalancer/src/strategy/CompositeStrategy.test.ts b/typescript/rebalancer/src/strategy/CompositeStrategy.test.ts
new file mode 100644
index 00000000000..49ae8fa04a8
--- /dev/null
+++ b/typescript/rebalancer/src/strategy/CompositeStrategy.test.ts
@@ -0,0 +1,405 @@
+import { expect } from 'chai';
+import { pino } from 'pino';
+
+import type { ChainName } from '@hyperlane-xyz/sdk';
+
+import type {
+ IStrategy,
+ InflightContext,
+ RawBalances,
+ Route,
+ StrategyRoute,
+} from '../interfaces/IStrategy.js';
+
+import { CompositeStrategy } from './CompositeStrategy.js';
+
+const testLogger = pino({ level: 'silent' });
+const TEST_BRIDGE = '0x1234567890123456789012345678901234567890';
+
+class MockStrategy implements IStrategy {
+ readonly name = 'mock';
+ public lastInflightContext?: InflightContext;
+
+ constructor(private readonly routesToReturn: StrategyRoute[]) {}
+
+ getRebalancingRoutes(
+ _rawBalances: RawBalances,
+ inflightContext?: InflightContext,
+ ): StrategyRoute[] {
+ this.lastInflightContext = inflightContext;
+ return this.routesToReturn;
+ }
+}
+
+describe('CompositeStrategy', () => {
+ let chain1: ChainName;
+ let chain2: ChainName;
+ let chain3: ChainName;
+
+ beforeEach(() => {
+ chain1 = 'chain1';
+ chain2 = 'chain2';
+ chain3 = 'chain3';
+ });
+
+ describe('constructor', () => {
+ it('should throw an error when less than 2 strategies are provided', () => {
+ const mockStrategy = new MockStrategy([]);
+
+ expect(() => new CompositeStrategy([mockStrategy], testLogger)).to.throw(
+ 'CompositeStrategy requires at least 2 sub-strategies',
+ );
+ });
+
+ it('should throw an error when no strategies are provided', () => {
+ expect(() => new CompositeStrategy([], testLogger)).to.throw(
+ 'CompositeStrategy requires at least 2 sub-strategies',
+ );
+ });
+
+ it('should create a strategy with 2+ sub-strategies', () => {
+ const strategy1 = new MockStrategy([]);
+ const strategy2 = new MockStrategy([]);
+
+ const composite = new CompositeStrategy(
+ [strategy1, strategy2],
+ testLogger,
+ );
+ expect(composite).to.be.instanceOf(CompositeStrategy);
+ });
+ });
+
+ describe('getRebalancingRoutes', () => {
+ it('should concatenate routes from all sub-strategies', () => {
+ const route1: StrategyRoute = {
+ origin: chain1,
+ destination: chain2,
+ amount: 1000n,
+ bridge: TEST_BRIDGE,
+ };
+ const route2: StrategyRoute = {
+ origin: chain2,
+ destination: chain3,
+ amount: 2000n,
+ bridge: TEST_BRIDGE,
+ };
+
+ const strategy1 = new MockStrategy([route1]);
+ const strategy2 = new MockStrategy([route2]);
+
+ const composite = new CompositeStrategy(
+ [strategy1, strategy2],
+ testLogger,
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: 5000n,
+ [chain2]: 10000n,
+ [chain3]: 3000n,
+ };
+
+ const routes = composite.getRebalancingRoutes(rawBalances);
+
+ expect(routes).to.have.lengthOf(2);
+ expect(routes[0]).to.deep.equal(route1);
+ expect(routes[1]).to.deep.equal(route2);
+ });
+
+ it('should pass routes from earlier strategies as proposedRebalances to later strategies', () => {
+ const route1: StrategyRoute = {
+ origin: chain1,
+ destination: chain2,
+ amount: 1000n,
+ bridge: TEST_BRIDGE,
+ };
+
+ const strategy1 = new MockStrategy([route1]);
+ const strategy2 = new MockStrategy([]);
+
+ const composite = new CompositeStrategy(
+ [strategy1, strategy2],
+ testLogger,
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: 5000n,
+ [chain2]: 10000n,
+ };
+
+ composite.getRebalancingRoutes(rawBalances);
+
+ // Strategy 1 should receive empty proposedRebalances (none from earlier strategies)
+ expect(strategy1.lastInflightContext?.proposedRebalances).to.deep.equal(
+ [],
+ );
+
+ // Strategy 2 should receive route1 as proposedRebalances (from earlier strategy)
+ expect(
+ strategy2.lastInflightContext?.proposedRebalances,
+ ).to.have.lengthOf(1);
+ expect(
+ strategy2.lastInflightContext?.proposedRebalances?.[0],
+ ).to.deep.equal(route1);
+ });
+
+ it('should accumulate routes across multiple strategies as proposedRebalances', () => {
+ const route1: StrategyRoute = {
+ origin: chain1,
+ destination: chain2,
+ amount: 1000n,
+ bridge: TEST_BRIDGE,
+ };
+ const route2: StrategyRoute = {
+ origin: chain2,
+ destination: chain3,
+ amount: 2000n,
+ bridge: TEST_BRIDGE,
+ };
+ const route3: StrategyRoute = {
+ origin: chain3,
+ destination: chain1,
+ amount: 3000n,
+ bridge: TEST_BRIDGE,
+ };
+
+ const strategy1 = new MockStrategy([route1]);
+ const strategy2 = new MockStrategy([route2]);
+ const strategy3 = new MockStrategy([route3]);
+
+ const composite = new CompositeStrategy(
+ [strategy1, strategy2, strategy3],
+ testLogger,
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: 5000n,
+ [chain2]: 10000n,
+ [chain3]: 3000n,
+ };
+
+ composite.getRebalancingRoutes(rawBalances);
+
+ // Strategy 1: empty proposedRebalances (no earlier strategies)
+ expect(strategy1.lastInflightContext?.proposedRebalances).to.deep.equal(
+ [],
+ );
+
+ // Strategy 2: receives route1 as proposedRebalances
+ expect(
+ strategy2.lastInflightContext?.proposedRebalances,
+ ).to.have.lengthOf(1);
+
+ // Strategy 3: receives route1 + route2 as proposedRebalances
+ expect(
+ strategy3.lastInflightContext?.proposedRebalances,
+ ).to.have.lengthOf(2);
+ expect(
+ strategy3.lastInflightContext?.proposedRebalances?.[0],
+ ).to.deep.equal(route1);
+ expect(
+ strategy3.lastInflightContext?.proposedRebalances?.[1],
+ ).to.deep.equal(route2);
+ });
+
+ it('should preserve original pendingRebalances and use proposedRebalances for new routes', () => {
+ const originalPendingRebalance: StrategyRoute = {
+ origin: chain3,
+ destination: chain1,
+ amount: 500n,
+ bridge: TEST_BRIDGE,
+ };
+
+ const route1: StrategyRoute = {
+ origin: chain1,
+ destination: chain2,
+ amount: 1000n,
+ bridge: TEST_BRIDGE,
+ };
+
+ const strategy1 = new MockStrategy([route1]);
+ const strategy2 = new MockStrategy([]);
+
+ const composite = new CompositeStrategy(
+ [strategy1, strategy2],
+ testLogger,
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: 5000n,
+ [chain2]: 10000n,
+ [chain3]: 3000n,
+ };
+
+ const inflightContext: InflightContext = {
+ pendingTransfers: [],
+ pendingRebalances: [originalPendingRebalance],
+ };
+
+ composite.getRebalancingRoutes(rawBalances, inflightContext);
+
+ // Both strategies should receive the SAME original pendingRebalances (inflight intents)
+ expect(strategy1.lastInflightContext?.pendingRebalances).to.have.lengthOf(
+ 1,
+ );
+ expect(strategy1.lastInflightContext?.pendingRebalances[0]).to.deep.equal(
+ originalPendingRebalance,
+ );
+ expect(strategy2.lastInflightContext?.pendingRebalances).to.have.lengthOf(
+ 1,
+ );
+ expect(strategy2.lastInflightContext?.pendingRebalances[0]).to.deep.equal(
+ originalPendingRebalance,
+ );
+
+ // Strategy 1: empty proposedRebalances (no earlier strategies)
+ expect(strategy1.lastInflightContext?.proposedRebalances).to.deep.equal(
+ [],
+ );
+
+ // Strategy 2: receives route1 as proposedRebalances (from earlier strategy)
+ expect(
+ strategy2.lastInflightContext?.proposedRebalances,
+ ).to.have.lengthOf(1);
+ expect(
+ strategy2.lastInflightContext?.proposedRebalances?.[0],
+ ).to.deep.equal(route1);
+ });
+
+ it('should preserve pendingTransfers for all strategies', () => {
+ const pendingTransfer: Route = {
+ origin: chain1,
+ destination: chain2,
+ amount: 500n,
+ };
+
+ const strategy1 = new MockStrategy([]);
+ const strategy2 = new MockStrategy([]);
+
+ const composite = new CompositeStrategy(
+ [strategy1, strategy2],
+ testLogger,
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: 5000n,
+ [chain2]: 10000n,
+ };
+
+ const inflightContext: InflightContext = {
+ pendingTransfers: [pendingTransfer],
+ pendingRebalances: [],
+ };
+
+ composite.getRebalancingRoutes(rawBalances, inflightContext);
+
+ // Both strategies should receive the same pendingTransfers
+ expect(strategy1.lastInflightContext?.pendingTransfers).to.deep.equal([
+ pendingTransfer,
+ ]);
+ expect(strategy2.lastInflightContext?.pendingTransfers).to.deep.equal([
+ pendingTransfer,
+ ]);
+ });
+
+ it('should maintain route order (first strategy routes come first)', () => {
+ const route1a: StrategyRoute = {
+ origin: chain1,
+ destination: chain2,
+ amount: 1000n,
+ bridge: TEST_BRIDGE,
+ };
+ const route1b: StrategyRoute = {
+ origin: chain1,
+ destination: chain3,
+ amount: 1500n,
+ bridge: TEST_BRIDGE,
+ };
+ const route2a: StrategyRoute = {
+ origin: chain2,
+ destination: chain3,
+ amount: 2000n,
+ bridge: TEST_BRIDGE,
+ };
+
+ const strategy1 = new MockStrategy([route1a, route1b]);
+ const strategy2 = new MockStrategy([route2a]);
+
+ const composite = new CompositeStrategy(
+ [strategy1, strategy2],
+ testLogger,
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: 5000n,
+ [chain2]: 10000n,
+ [chain3]: 3000n,
+ };
+
+ const routes = composite.getRebalancingRoutes(rawBalances);
+
+ expect(routes).to.have.lengthOf(3);
+ expect(routes[0]).to.deep.equal(route1a);
+ expect(routes[1]).to.deep.equal(route1b);
+ expect(routes[2]).to.deep.equal(route2a);
+ });
+
+ it('should handle strategies that return no routes', () => {
+ const route2: StrategyRoute = {
+ origin: chain2,
+ destination: chain3,
+ amount: 2000n,
+ bridge: TEST_BRIDGE,
+ };
+
+ const strategy1 = new MockStrategy([]);
+ const strategy2 = new MockStrategy([route2]);
+ const strategy3 = new MockStrategy([]);
+
+ const composite = new CompositeStrategy(
+ [strategy1, strategy2, strategy3],
+ testLogger,
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: 5000n,
+ [chain2]: 10000n,
+ [chain3]: 3000n,
+ };
+
+ const routes = composite.getRebalancingRoutes(rawBalances);
+
+ expect(routes).to.have.lengthOf(1);
+ expect(routes[0]).to.deep.equal(route2);
+ });
+
+ it('should handle undefined inflightContext', () => {
+ const route1: StrategyRoute = {
+ origin: chain1,
+ destination: chain2,
+ amount: 1000n,
+ bridge: TEST_BRIDGE,
+ };
+
+ const strategy1 = new MockStrategy([route1]);
+ const strategy2 = new MockStrategy([]);
+
+ const composite = new CompositeStrategy(
+ [strategy1, strategy2],
+ testLogger,
+ );
+
+ const rawBalances: RawBalances = {
+ [chain1]: 5000n,
+ [chain2]: 10000n,
+ };
+
+ const routes = composite.getRebalancingRoutes(rawBalances, undefined);
+
+ expect(routes).to.have.lengthOf(1);
+ expect(strategy1.lastInflightContext?.pendingTransfers).to.deep.equal([]);
+ expect(strategy1.lastInflightContext?.pendingRebalances).to.deep.equal(
+ [],
+ );
+ });
+ });
+});
diff --git a/typescript/rebalancer/src/strategy/CompositeStrategy.ts b/typescript/rebalancer/src/strategy/CompositeStrategy.ts
new file mode 100644
index 00000000000..aa43f26fdee
--- /dev/null
+++ b/typescript/rebalancer/src/strategy/CompositeStrategy.ts
@@ -0,0 +1,102 @@
+import { Logger } from 'pino';
+
+import type {
+ IStrategy,
+ InflightContext,
+ RawBalances,
+ StrategyRoute,
+} from '../interfaces/IStrategy.js';
+
+/**
+ * Composite strategy that runs multiple sub-strategies sequentially.
+ *
+ * Key behavior: Routes from earlier strategies are passed as proposedRebalances
+ * to later strategies, allowing coordination between strategies.
+ *
+ * Requires at least 2 sub-strategies.
+ */
+export class CompositeStrategy implements IStrategy {
+ readonly name = 'composite';
+ protected readonly logger: Logger;
+
+ constructor(
+ private readonly strategies: IStrategy[],
+ logger: Logger,
+ ) {
+ if (strategies.length < 2) {
+ throw new Error('CompositeStrategy requires at least 2 sub-strategies');
+ }
+ this.logger = logger.child({ class: CompositeStrategy.name });
+ this.logger.info(
+ {
+ strategyCount: strategies.length,
+ strategies: strategies.map((s) => s.name),
+ },
+ 'CompositeStrategy created',
+ );
+ }
+
+ getRebalancingRoutes(
+ rawBalances: RawBalances,
+ inflightContext?: InflightContext,
+ ): StrategyRoute[] {
+ const allRoutes: StrategyRoute[] = [];
+
+ // Track routes from earlier strategies in this cycle as proposedRebalances
+ // These are NOT yet executed, so strategies need to simulate both origin and destination
+ let accumulatedProposedRebalances: StrategyRoute[] = [];
+
+ for (let i = 0; i < this.strategies.length; i++) {
+ const strategy = this.strategies[i];
+
+ // Build context with:
+ // - pendingRebalances: actual in-flight intents (origin tx confirmed, passed through from caller)
+ // - proposedRebalances: routes from earlier strategies in THIS cycle (not yet executed)
+ const contextForStrategy: InflightContext = {
+ pendingTransfers: inflightContext?.pendingTransfers ?? [],
+ pendingRebalances: inflightContext?.pendingRebalances ?? [],
+ proposedRebalances: accumulatedProposedRebalances,
+ };
+
+ this.logger.debug(
+ {
+ strategyIndex: i,
+ strategyName: strategy.name,
+ pendingRebalancesCount: contextForStrategy.pendingRebalances.length,
+ proposedRebalancesCount: accumulatedProposedRebalances.length,
+ },
+ 'Running sub-strategy',
+ );
+
+ const routes = strategy.getRebalancingRoutes(
+ rawBalances,
+ contextForStrategy,
+ );
+
+ this.logger.debug(
+ {
+ strategyIndex: i,
+ strategyName: strategy.name,
+ routeCount: routes.length,
+ },
+ 'Sub-strategy returned routes',
+ );
+
+ // Add routes to proposedRebalances for next strategy
+ accumulatedProposedRebalances = [
+ ...accumulatedProposedRebalances,
+ ...routes,
+ ];
+
+ // Add to final result
+ allRoutes.push(...routes);
+ }
+
+ this.logger.info(
+ { totalRoutes: allRoutes.length },
+ 'CompositeStrategy merged routes from all sub-strategies',
+ );
+
+ return allRoutes;
+ }
+}
diff --git a/typescript/rebalancer/src/strategy/MinAmountStrategy.test.ts b/typescript/rebalancer/src/strategy/MinAmountStrategy.test.ts
index 87ec8f35262..dfc7566b12d 100644
--- a/typescript/rebalancer/src/strategy/MinAmountStrategy.test.ts
+++ b/typescript/rebalancer/src/strategy/MinAmountStrategy.test.ts
@@ -11,6 +11,7 @@ import {
import { RebalancerMinAmountType } from '../config/types.js';
import type { RawBalances } from '../interfaces/IStrategy.js';
+import { extractBridgeConfigs } from '../test/helpers.js';
import { MinAmountStrategy } from './MinAmountStrategy.js';
@@ -58,6 +59,7 @@ describe('MinAmountStrategy', () => {
tokensByChainName,
totalCollateral,
testLogger,
+ {},
),
).to.throw('At least two chains must be configured');
});
@@ -87,6 +89,7 @@ describe('MinAmountStrategy', () => {
tokensByChainName,
totalCollateral,
testLogger,
+ {},
);
});
@@ -115,6 +118,7 @@ describe('MinAmountStrategy', () => {
tokensByChainName,
totalCollateral,
testLogger,
+ {},
);
});
@@ -145,6 +149,7 @@ describe('MinAmountStrategy', () => {
tokensByChainName,
totalCollateral,
testLogger,
+ {},
),
).to.throw('Minimum amount (-10) cannot be negative for chain chain2');
});
@@ -176,6 +181,7 @@ describe('MinAmountStrategy', () => {
tokensByChainName,
totalCollateral,
testLogger,
+ {},
),
).to.throw(
'Target (80) must be greater than or equal to min (100) for chain chain1',
@@ -209,6 +215,7 @@ describe('MinAmountStrategy', () => {
tokensByChainName,
totalCollateral,
testLogger,
+ {},
),
).to.throw(
'Target (0.4) must be greater than or equal to min (0.5) for chain chain1',
@@ -241,6 +248,7 @@ describe('MinAmountStrategy', () => {
tokensByChainName,
totalCollateral,
testLogger,
+ {},
).getRebalancingRoutes({
[chain1]: 100n,
[chain2]: 200n,
@@ -275,6 +283,7 @@ describe('MinAmountStrategy', () => {
tokensByChainName,
totalCollateral,
testLogger,
+ {},
).getRebalancingRoutes({
[chain1]: 100n,
[chain3]: 300n,
@@ -308,6 +317,7 @@ describe('MinAmountStrategy', () => {
tokensByChainName,
totalCollateral,
testLogger,
+ {},
).getRebalancingRoutes({
[chain1]: 100n,
[chain2]: -2n,
@@ -342,6 +352,7 @@ describe('MinAmountStrategy', () => {
tokensByChainName,
totalCollateral,
testLogger,
+ {},
);
const rawBalances: RawBalances = {
@@ -355,30 +366,33 @@ describe('MinAmountStrategy', () => {
});
it('should return a single route when a chain is below minimum amount', () => {
- const strategy = new MinAmountStrategy(
- {
- [chain1]: {
- minAmount: {
- min: '100',
- target: '120',
- type: RebalancerMinAmountType.Absolute,
- },
- bridge: AddressZero,
- bridgeLockTime: 1,
+ const config = {
+ [chain1]: {
+ minAmount: {
+ min: '100',
+ target: '120',
+ type: RebalancerMinAmountType.Absolute,
},
- [chain2]: {
- minAmount: {
- min: '100',
- target: '120',
- type: RebalancerMinAmountType.Absolute,
- },
- bridge: AddressZero,
- bridgeLockTime: 1,
+ bridge: AddressZero,
+ bridgeLockTime: 1,
+ },
+ [chain2]: {
+ minAmount: {
+ min: '100',
+ target: '120',
+ type: RebalancerMinAmountType.Absolute,
},
+ bridge: AddressZero,
+ bridgeLockTime: 1,
},
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+ const strategy = new MinAmountStrategy(
+ config,
tokensByChainName,
totalCollateral,
testLogger,
+ bridgeConfigs,
);
const rawBalances = {
@@ -393,44 +407,48 @@ describe('MinAmountStrategy', () => {
origin: chain2,
destination: chain1,
amount: BigInt(70e18),
+ bridge: AddressZero,
},
]);
});
it('should return multiple routes for multiple chains below minimum amount', () => {
- const strategy = new MinAmountStrategy(
- {
- [chain1]: {
- minAmount: {
- min: '80',
- target: '100',
- type: RebalancerMinAmountType.Absolute,
- },
- bridge: AddressZero,
- bridgeLockTime: 1,
+ const config = {
+ [chain1]: {
+ minAmount: {
+ min: '80',
+ target: '100',
+ type: RebalancerMinAmountType.Absolute,
},
- [chain2]: {
- minAmount: {
- min: '80',
- target: '100',
- type: RebalancerMinAmountType.Absolute,
- },
- bridge: AddressZero,
- bridgeLockTime: 1,
+ bridge: AddressZero,
+ bridgeLockTime: 1,
+ },
+ [chain2]: {
+ minAmount: {
+ min: '80',
+ target: '100',
+ type: RebalancerMinAmountType.Absolute,
},
- [chain3]: {
- minAmount: {
- min: '80',
- target: '100',
- type: RebalancerMinAmountType.Absolute,
- },
- bridge: AddressZero,
- bridgeLockTime: 1,
+ bridge: AddressZero,
+ bridgeLockTime: 1,
+ },
+ [chain3]: {
+ minAmount: {
+ min: '80',
+ target: '100',
+ type: RebalancerMinAmountType.Absolute,
},
+ bridge: AddressZero,
+ bridgeLockTime: 1,
},
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+ const strategy = new MinAmountStrategy(
+ config,
tokensByChainName,
totalCollateral,
testLogger,
+ bridgeConfigs,
);
const rawBalances = {
@@ -446,11 +464,13 @@ describe('MinAmountStrategy', () => {
origin: chain3,
destination: chain1,
amount: BigInt(50e18),
+ bridge: AddressZero,
},
{
origin: chain3,
destination: chain2,
amount: BigInt(25e18),
+ bridge: AddressZero,
},
]);
});
@@ -482,6 +502,7 @@ describe('MinAmountStrategy', () => {
tokensByChainName,
totalCollateral,
testLogger,
+ {},
),
).to.throw(
`Consider reducing the targets as the sum (340) is greater than sum of collaterals (300)`,
@@ -489,39 +510,42 @@ describe('MinAmountStrategy', () => {
});
it('should handle case where there is not enough surplus to meet all minimum requirements by scaling down deficits', () => {
- const strategy = new MinAmountStrategy(
- {
- [chain1]: {
- minAmount: {
- min: '100',
- target: '100',
- type: RebalancerMinAmountType.Absolute,
- },
- bridge: AddressZero,
- bridgeLockTime: 1,
+ const config = {
+ [chain1]: {
+ minAmount: {
+ min: '100',
+ target: '100',
+ type: RebalancerMinAmountType.Absolute,
},
- [chain2]: {
- minAmount: {
- min: '100',
- target: '100',
- type: RebalancerMinAmountType.Absolute,
- },
- bridge: AddressZero,
- bridgeLockTime: 1,
+ bridge: AddressZero,
+ bridgeLockTime: 1,
+ },
+ [chain2]: {
+ minAmount: {
+ min: '100',
+ target: '100',
+ type: RebalancerMinAmountType.Absolute,
},
- [chain3]: {
- minAmount: {
- min: '100',
- target: '100',
- type: RebalancerMinAmountType.Absolute,
- },
- bridge: AddressZero,
- bridgeLockTime: 1,
+ bridge: AddressZero,
+ bridgeLockTime: 1,
+ },
+ [chain3]: {
+ minAmount: {
+ min: '100',
+ target: '100',
+ type: RebalancerMinAmountType.Absolute,
},
+ bridge: AddressZero,
+ bridgeLockTime: 1,
},
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+ const strategy = new MinAmountStrategy(
+ config,
tokensByChainName,
totalCollateral,
testLogger,
+ bridgeConfigs,
);
const rawBalances = {
@@ -542,6 +566,78 @@ describe('MinAmountStrategy', () => {
expect(routes[1].amount).to.equal(BigInt(25e18));
});
+ it('should not produce zero-amount routes when scaling causes amounts to round to zero', () => {
+ // This test ensures that when deficit scaling produces zero amounts (due to integer division),
+ // these zero-amount routes are NOT included in the output.
+ // Bug scenario: totalSurplus=1, totalDeficit=3 -> each deficit of 1 scales to (1*1)/3 = 0
+ const config = {
+ [chain1]: {
+ minAmount: {
+ min: '90',
+ target: '100',
+ type: RebalancerMinAmountType.Absolute,
+ },
+ bridge: AddressZero,
+ bridgeLockTime: 1,
+ },
+ [chain2]: {
+ minAmount: {
+ min: '90',
+ target: '100',
+ type: RebalancerMinAmountType.Absolute,
+ },
+ bridge: AddressZero,
+ bridgeLockTime: 1,
+ },
+ [chain3]: {
+ minAmount: {
+ min: '90',
+ target: '100',
+ type: RebalancerMinAmountType.Absolute,
+ },
+ bridge: AddressZero,
+ bridgeLockTime: 1,
+ },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+ const strategy = new MinAmountStrategy(
+ config,
+ tokensByChainName,
+ totalCollateral,
+ testLogger,
+ bridgeConfigs,
+ );
+
+ // chain1 and chain2 are at 1 token each (far below min 90)
+ // chain3 has 91 tokens (surplus of 1 above min)
+ // Deficits: chain1 needs 99 (100-1), chain2 needs 99 (100-1), total = 198
+ // Surplus: chain3 has 1 (91-90)
+ // Scaling: each deficit of 99 becomes (99 * 1) / 198 = 0 (integer division!)
+ const rawBalances = {
+ [chain1]: BigInt(1e18),
+ [chain2]: BigInt(1e18),
+ [chain3]: BigInt(91e18),
+ };
+
+ const routes = strategy.getRebalancingRoutes(rawBalances);
+
+ // All routes should have non-zero amounts
+ // (Chai's greaterThan doesn't support BigInt, so use direct comparison)
+ for (const route of routes) {
+ expect(
+ route.amount > 0n,
+ `Route amount should be > 0, got ${route.amount}`,
+ ).to.be.true;
+ }
+
+ // The single token of surplus may produce one route (or none if both scale to 0)
+ // Either way, no zero-amount routes should exist
+ expect(
+ routes.every((r) => r.amount > 0n),
+ 'All routes must have non-zero amounts',
+ ).to.be.true;
+ });
+
it('should have no surplus or deficit when all at min', () => {
const strategy = new MinAmountStrategy(
{
@@ -567,6 +663,7 @@ describe('MinAmountStrategy', () => {
tokensByChainName,
totalCollateral,
testLogger,
+ {},
);
const rawBalances = {
@@ -580,30 +677,33 @@ describe('MinAmountStrategy', () => {
});
it('should consider the target amount with relative configuration', () => {
- const strategy = new MinAmountStrategy(
- {
- [chain1]: {
- minAmount: {
- min: 0.25,
- target: 0.3,
- type: RebalancerMinAmountType.Relative,
- },
- bridge: AddressZero,
- bridgeLockTime: 1,
+ const config = {
+ [chain1]: {
+ minAmount: {
+ min: 0.25,
+ target: 0.3,
+ type: RebalancerMinAmountType.Relative,
},
- [chain2]: {
- minAmount: {
- min: 0.25,
- target: 0.3,
- type: RebalancerMinAmountType.Relative,
- },
- bridge: AddressZero,
- bridgeLockTime: 1,
+ bridge: AddressZero,
+ bridgeLockTime: 1,
+ },
+ [chain2]: {
+ minAmount: {
+ min: 0.25,
+ target: 0.3,
+ type: RebalancerMinAmountType.Relative,
},
+ bridge: AddressZero,
+ bridgeLockTime: 1,
},
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+ const strategy = new MinAmountStrategy(
+ config,
tokensByChainName,
totalCollateral,
testLogger,
+ bridgeConfigs,
);
const rawBalances: RawBalances = {
@@ -618,6 +718,7 @@ describe('MinAmountStrategy', () => {
origin: chain2,
destination: chain1,
amount: 100n,
+ bridge: AddressZero,
},
]);
});
diff --git a/typescript/rebalancer/src/strategy/MinAmountStrategy.ts b/typescript/rebalancer/src/strategy/MinAmountStrategy.ts
index 677feeb48ba..1005f4303aa 100644
--- a/typescript/rebalancer/src/strategy/MinAmountStrategy.ts
+++ b/typescript/rebalancer/src/strategy/MinAmountStrategy.ts
@@ -7,9 +7,15 @@ import { fromWei, toWei } from '@hyperlane-xyz/utils';
import {
type MinAmountStrategyConfig,
RebalancerMinAmountType,
+ RebalancerStrategyOptions,
} from '../config/types.js';
-import type { RawBalances } from '../interfaces/IStrategy.js';
+import type {
+ RawBalances,
+ Route,
+ StrategyRoute,
+} from '../interfaces/IStrategy.js';
import { type Metrics } from '../metrics/Metrics.js';
+import type { BridgeConfigWithOverride } from '../utils/bridgeUtils.js';
import { BaseStrategy, type Delta } from './BaseStrategy.js';
@@ -18,19 +24,21 @@ import { BaseStrategy, type Delta } from './BaseStrategy.js';
* It ensures each chain has at least the specified minimum amount
*/
export class MinAmountStrategy extends BaseStrategy {
+ readonly name = RebalancerStrategyOptions.MinAmount;
private readonly config: MinAmountStrategyConfig = {};
protected readonly logger: Logger;
constructor(
config: MinAmountStrategyConfig,
- private readonly tokensByChainName: ChainMap,
+ tokensByChainName: ChainMap,
initialTotalCollateral: bigint,
logger: Logger,
+ bridgeConfigs: ChainMap,
metrics?: Metrics,
) {
const chains = Object.keys(config);
const log = logger.child({ class: MinAmountStrategy.name });
- super(chains, log, metrics);
+ super(chains, log, bridgeConfigs, metrics, tokensByChainName);
this.logger = log;
const minAmountType = config[chains[0]].minAmount.type;
@@ -67,37 +75,60 @@ export class MinAmountStrategy extends BaseStrategy {
* Gets balances categorized by surplus and deficit based on minimum amounts and targets
* - For absolute values: Uses exact token amounts
* - For relative values: Uses percentages of total balance across all chains
+ *
+ * Simulates both types of rebalances before calculating surpluses/deficits:
+ * - pendingRebalances: in-flight intents (origin tx confirmed, add to destination only)
+ * - proposedRebalances: routes from earlier strategies (subtract from origin AND add to destination)
+ *
+ * This prevents over-rebalancing when multiple strategies run in sequence.
*/
- protected getCategorizedBalances(rawBalances: RawBalances): {
+ protected getCategorizedBalances(
+ rawBalances: RawBalances,
+ pendingRebalances?: Route[],
+ proposedRebalances?: StrategyRoute[],
+ ): {
surpluses: Delta[];
deficits: Delta[];
} {
+ // Step 1: Simulate pending rebalances (in-flight, origin already deducted on-chain)
+ let simulatedBalances = this.simulatePendingRebalances(
+ rawBalances,
+ pendingRebalances ?? [],
+ );
+
+ // Step 2: Simulate proposed rebalances (from earlier strategies, not yet executed)
+ simulatedBalances = this.simulateProposedRebalances(
+ simulatedBalances,
+ proposedRebalances ?? [],
+ );
const totalCollateral = this.chains.reduce(
- (sum, chain) => sum + rawBalances[chain],
+ (sum, chain) => sum + simulatedBalances[chain],
0n,
);
return this.chains.reduce(
(acc, chain) => {
- const config = this.config[chain];
- const balance = rawBalances[chain];
+ const chainConfig = this.config[chain];
+ const balance = simulatedBalances[chain];
let minAmount: bigint;
let targetAmount: bigint;
- if (config.minAmount.type === RebalancerMinAmountType.Absolute) {
+ if (chainConfig.minAmount.type === RebalancerMinAmountType.Absolute) {
const token = this.getTokenByChainName(chain);
- minAmount = BigInt(toWei(config.minAmount.min, token.decimals));
- targetAmount = BigInt(toWei(config.minAmount.target, token.decimals));
+ minAmount = BigInt(toWei(chainConfig.minAmount.min, token.decimals));
+ targetAmount = BigInt(
+ toWei(chainConfig.minAmount.target, token.decimals),
+ );
} else {
minAmount = BigInt(
BigNumber(totalCollateral.toString())
- .times(config.minAmount.min)
+ .times(chainConfig.minAmount.min)
.toFixed(0, BigNumber.ROUND_FLOOR),
);
targetAmount = BigInt(
BigNumber(totalCollateral.toString())
- .times(config.minAmount.target)
+ .times(chainConfig.minAmount.target)
.toFixed(0, BigNumber.ROUND_FLOOR),
);
}
@@ -123,7 +154,7 @@ export class MinAmountStrategy extends BaseStrategy {
}
protected getTokenByChainName(chainName: string): Token {
- const token = this.tokensByChainName[chainName];
+ const token = this.tokensByChainName![chainName];
if (token === undefined) {
throw new Error(`Token not found for chain ${chainName}`);
diff --git a/typescript/rebalancer/src/strategy/StrategyFactory.test.ts b/typescript/rebalancer/src/strategy/StrategyFactory.test.ts
index 6aa8aeb8333..e8195301330 100644
--- a/typescript/rebalancer/src/strategy/StrategyFactory.test.ts
+++ b/typescript/rebalancer/src/strategy/StrategyFactory.test.ts
@@ -61,7 +61,7 @@ describe('StrategyFactory', () => {
};
const strategy = StrategyFactory.createStrategy(
- strategyConfig,
+ [strategyConfig],
tokensByChainName,
totalCollateral,
testLogger,
@@ -97,7 +97,7 @@ describe('StrategyFactory', () => {
};
const strategy = StrategyFactory.createStrategy(
- strategyConfig,
+ [strategyConfig],
tokensByChainName,
totalCollateral,
testLogger,
diff --git a/typescript/rebalancer/src/strategy/StrategyFactory.ts b/typescript/rebalancer/src/strategy/StrategyFactory.ts
index 3022644a0f4..76e251f34fa 100644
--- a/typescript/rebalancer/src/strategy/StrategyFactory.ts
+++ b/typescript/rebalancer/src/strategy/StrategyFactory.ts
@@ -8,40 +8,123 @@ import {
} from '../config/types.js';
import { type IStrategy } from '../interfaces/IStrategy.js';
import { type Metrics } from '../metrics/Metrics.js';
+import type { BridgeConfigWithOverride } from '../utils/bridgeUtils.js';
+import { CollateralDeficitStrategy } from './CollateralDeficitStrategy.js';
+import { CompositeStrategy } from './CompositeStrategy.js';
import { MinAmountStrategy } from './MinAmountStrategy.js';
import { WeightedStrategy } from './WeightedStrategy.js';
export class StrategyFactory {
/**
- * @param strategyConfig A discriminated union of strategy-specific configurations.
- * @param tokensByChainName - A map of chain->token to ease the lookup of token by chain
- * @param initialTotalCollateral - The initial total collateral of the rebalancer
- * @param logger - The logger to use for the strategy
- * @param metrics - The metrics to use for the strategy
+ * Creates a strategy from an array of strategy configs.
+ * - Single strategy (array with 1 element): Creates that strategy directly
+ * - Multiple strategies (array with 2+ elements): Creates CompositeStrategy
+ *
+ * @param strategyConfigs Array of strategy configurations (always array format)
+ * @param tokensByChainName A map of chain->token to ease the lookup of token by chain
+ * @param initialTotalCollateral The initial total collateral of the rebalancer
+ * @param logger The logger to use for the strategy
+ * @param metrics The metrics to use for the strategy
* @returns A concrete strategy implementation
*/
static createStrategy(
+ strategyConfigs: StrategyConfig[],
+ tokensByChainName: ChainMap,
+ initialTotalCollateral: bigint,
+ logger: Logger,
+ metrics?: Metrics,
+ ): IStrategy {
+ if (strategyConfigs.length === 0) {
+ throw new Error('At least one strategy must be configured');
+ }
+
+ // Single strategy - create directly without CompositeStrategy wrapper
+ if (strategyConfigs.length === 1) {
+ return this.createSingleStrategy(
+ strategyConfigs[0],
+ tokensByChainName,
+ initialTotalCollateral,
+ logger,
+ metrics,
+ );
+ }
+
+ // Multiple strategies - create CompositeStrategy
+ const subStrategies = strategyConfigs.map((config) =>
+ this.createSingleStrategy(
+ config,
+ tokensByChainName,
+ initialTotalCollateral,
+ logger,
+ metrics,
+ ),
+ );
+ return new CompositeStrategy(subStrategies, logger);
+ }
+
+ /**
+ * Create a single strategy from config.
+ */
+ private static createSingleStrategy(
strategyConfig: StrategyConfig,
tokensByChainName: ChainMap,
initialTotalCollateral: bigint,
logger: Logger,
metrics?: Metrics,
): IStrategy {
+ const bridgeConfigs = this.extractBridgeConfigs(strategyConfig);
+
switch (strategyConfig.rebalanceStrategy) {
- case RebalancerStrategyOptions.Weighted:
- return new WeightedStrategy(strategyConfig.chains, logger, metrics);
- case RebalancerStrategyOptions.MinAmount:
+ case RebalancerStrategyOptions.Weighted: {
+ return new WeightedStrategy(
+ strategyConfig.chains,
+ logger,
+ bridgeConfigs,
+ metrics,
+ tokensByChainName,
+ );
+ }
+ case RebalancerStrategyOptions.MinAmount: {
return new MinAmountStrategy(
strategyConfig.chains,
tokensByChainName,
initialTotalCollateral,
logger,
+ bridgeConfigs,
+ metrics,
+ );
+ }
+ case RebalancerStrategyOptions.CollateralDeficit: {
+ return new CollateralDeficitStrategy(
+ strategyConfig.chains,
+ tokensByChainName,
+ logger,
+ bridgeConfigs,
metrics,
);
+ }
default: {
throw new Error('Unsupported strategy type');
}
}
}
+
+ private static extractBridgeConfigs(
+ strategyConfig: StrategyConfig,
+ ): ChainMap {
+ const bridgeConfigs: ChainMap = {};
+
+ for (const [chain, config] of Object.entries(strategyConfig.chains)) {
+ bridgeConfigs[chain] = {
+ bridge: config.bridge,
+ bridgeMinAcceptedAmount: config.bridgeMinAcceptedAmount ?? 0,
+ override: config.override as ChainMap<
+ Partial<{ bridge: string; bridgeMinAcceptedAmount: string | number }>
+ >,
+ };
+ }
+
+ return bridgeConfigs;
+ }
}
diff --git a/typescript/rebalancer/src/strategy/WeightedStrategy.test.ts b/typescript/rebalancer/src/strategy/WeightedStrategy.test.ts
index e1086f39e63..39425c6b1cb 100644
--- a/typescript/rebalancer/src/strategy/WeightedStrategy.test.ts
+++ b/typescript/rebalancer/src/strategy/WeightedStrategy.test.ts
@@ -2,9 +2,10 @@ import { expect } from 'chai';
import { ethers } from 'ethers';
import { pino } from 'pino';
-import type { ChainName } from '@hyperlane-xyz/sdk';
+import type { ChainMap, ChainName, Token } from '@hyperlane-xyz/sdk';
import type { RawBalances } from '../interfaces/IStrategy.js';
+import { extractBridgeConfigs } from '../test/helpers.js';
import { WeightedStrategy } from './WeightedStrategy.js';
@@ -34,6 +35,7 @@ describe('WeightedStrategy', () => {
},
},
testLogger,
+ {},
),
).to.throw('At least two chains must be configured');
});
@@ -55,6 +57,7 @@ describe('WeightedStrategy', () => {
},
},
testLogger,
+ {},
),
).to.throw('Weight (-1) must not be negative for chain2');
});
@@ -76,6 +79,7 @@ describe('WeightedStrategy', () => {
},
},
testLogger,
+ {},
),
).to.throw('The total weight for all chains must be greater than 0');
});
@@ -97,6 +101,7 @@ describe('WeightedStrategy', () => {
},
},
testLogger,
+ {},
),
).to.throw('Tolerance (-1) must be between 0 and 100 for chain2');
@@ -116,6 +121,7 @@ describe('WeightedStrategy', () => {
},
},
testLogger,
+ {},
),
).to.throw('Tolerance (101) must be between 0 and 100 for chain2');
});
@@ -138,6 +144,7 @@ describe('WeightedStrategy', () => {
},
},
testLogger,
+ {},
).getRebalancingRoutes({
[chain1]: ethers.utils.parseEther('100').toBigInt(),
[chain2]: ethers.utils.parseEther('200').toBigInt(),
@@ -162,6 +169,7 @@ describe('WeightedStrategy', () => {
},
},
testLogger,
+ {},
).getRebalancingRoutes({
[chain1]: ethers.utils.parseEther('100').toBigInt(),
[chain3]: ethers.utils.parseEther('300').toBigInt(),
@@ -185,6 +193,7 @@ describe('WeightedStrategy', () => {
},
},
testLogger,
+ {},
).getRebalancingRoutes({
[chain1]: ethers.utils.parseEther('100').toBigInt(),
[chain2]: ethers.utils.parseEther('-200').toBigInt(),
@@ -207,6 +216,7 @@ describe('WeightedStrategy', () => {
},
},
testLogger,
+ {},
);
const rawBalances = {
@@ -220,21 +230,20 @@ describe('WeightedStrategy', () => {
});
it('should return a single route when a chain is unbalanced', () => {
- const strategy = new WeightedStrategy(
- {
- [chain1]: {
- weighted: { weight: 100n, tolerance: 0n },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
- [chain2]: {
- weighted: { weight: 100n, tolerance: 0n },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
+ const config = {
+ [chain1]: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- testLogger,
- );
+ [chain2]: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+ const strategy = new WeightedStrategy(config, testLogger, bridgeConfigs);
const rawBalances = {
[chain1]: ethers.utils.parseEther('100').toBigInt(),
@@ -248,6 +257,7 @@ describe('WeightedStrategy', () => {
origin: chain2,
destination: chain1,
amount: ethers.utils.parseEther('50').toBigInt(),
+ bridge: ethers.constants.AddressZero,
},
]);
});
@@ -267,6 +277,7 @@ describe('WeightedStrategy', () => {
},
},
testLogger,
+ {},
);
const rawBalances = {
@@ -280,26 +291,25 @@ describe('WeightedStrategy', () => {
});
it('should return a single route when two chains are unbalanced and can be solved with a single transfer', () => {
- const strategy = new WeightedStrategy(
- {
- [chain1]: {
- weighted: { weight: 100n, tolerance: 0n },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
- [chain2]: {
- weighted: { weight: 100n, tolerance: 0n },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
- [chain3]: {
- weighted: { weight: 100n, tolerance: 0n },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
+ const config = {
+ [chain1]: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- testLogger,
- );
+ [chain2]: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ [chain3]: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+ const strategy = new WeightedStrategy(config, testLogger, bridgeConfigs);
const rawBalances = {
[chain1]: ethers.utils.parseEther('100').toBigInt(),
@@ -314,30 +324,30 @@ describe('WeightedStrategy', () => {
origin: chain3,
destination: chain1,
amount: ethers.utils.parseEther('100').toBigInt(),
+ bridge: ethers.constants.AddressZero,
},
]);
});
it('should return two routes when two chains are unbalanced and cannot be solved with a single transfer', () => {
- const strategy = new WeightedStrategy(
- {
- [chain1]: {
- weighted: { weight: 100n, tolerance: 0n },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
- [chain2]: {
- weighted: { weight: 100n, tolerance: 0n },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
- [chain3]: {
- weighted: { weight: 100n, tolerance: 0n },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
+ const config = {
+ [chain1]: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- testLogger,
- );
+ [chain2]: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ [chain3]: {
+ weighted: { weight: 100n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+ const strategy = new WeightedStrategy(config, testLogger, bridgeConfigs);
const rawBalances = {
[chain1]: ethers.utils.parseEther('100').toBigInt(),
@@ -352,36 +362,37 @@ describe('WeightedStrategy', () => {
origin: chain3,
destination: chain1,
amount: 133333333333333333333n,
+ bridge: ethers.constants.AddressZero,
},
{
origin: chain3,
destination: chain2,
amount: 133333333333333333333n,
+ bridge: ethers.constants.AddressZero,
},
]);
});
it('should return routes to balance different weighted chains', () => {
- const strategy = new WeightedStrategy(
- {
- [chain1]: {
- weighted: { weight: 50n, tolerance: 0n },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
- [chain2]: {
- weighted: { weight: 25n, tolerance: 0n },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
- [chain3]: {
- weighted: { weight: 25n, tolerance: 0n },
- bridge: ethers.constants.AddressZero,
- bridgeLockTime: 1,
- },
+ const config = {
+ [chain1]: {
+ weighted: { weight: 50n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
},
- testLogger,
- );
+ [chain2]: {
+ weighted: { weight: 25n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ [chain3]: {
+ weighted: { weight: 25n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+ const strategy = new WeightedStrategy(config, testLogger, bridgeConfigs);
const rawBalances = {
[chain1]: ethers.utils.parseEther('100').toBigInt(),
@@ -396,13 +407,117 @@ describe('WeightedStrategy', () => {
origin: chain2,
destination: chain1,
amount: ethers.utils.parseEther('25').toBigInt(),
+ bridge: ethers.constants.AddressZero,
},
{
origin: chain3,
destination: chain1,
amount: ethers.utils.parseEther('25').toBigInt(),
+ bridge: ethers.constants.AddressZero,
},
]);
});
});
+
+ describe('bridgeMinAcceptedAmount filtering', () => {
+ function createMockToken(chainName: string, decimals = 18): Token {
+ return {
+ chainName,
+ decimals,
+ addressOrDenom: ethers.constants.AddressZero,
+ } as unknown as Token;
+ }
+
+ it('should filter out routes below bridgeMinAcceptedAmount', () => {
+ const chain1 = 'chain1';
+ const chain2 = 'chain2';
+
+ const tokensByChainName: ChainMap = {
+ [chain1]: createMockToken(chain1),
+ [chain2]: createMockToken(chain2),
+ };
+
+ const config = {
+ [chain1]: {
+ weighted: { weight: 50n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ bridgeMinAcceptedAmount: '100', // 100 tokens minimum
+ },
+ [chain2]: {
+ weighted: { weight: 50n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ bridgeMinAcceptedAmount: '100',
+ },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+ const strategy = new WeightedStrategy(
+ config,
+ testLogger,
+ bridgeConfigs,
+ undefined,
+ tokensByChainName,
+ );
+
+ // chain1 has 150, chain2 has 50 (total 200, each should have 100)
+ // Would generate route: chain1 -> chain2, amount = 50
+ // But 50 < bridgeMinAcceptedAmount (100), so route should be filtered
+ const rawBalances: RawBalances = {
+ [chain1]: ethers.utils.parseEther('150').toBigInt(),
+ [chain2]: ethers.utils.parseEther('50').toBigInt(),
+ };
+
+ const routes = strategy.getRebalancingRoutes(rawBalances);
+
+ expect(routes).to.have.lengthOf(0);
+ });
+
+ it('should keep routes at or above bridgeMinAcceptedAmount', () => {
+ const chain1 = 'chain1';
+ const chain2 = 'chain2';
+
+ const tokensByChainName: ChainMap = {
+ [chain1]: createMockToken(chain1),
+ [chain2]: createMockToken(chain2),
+ };
+
+ const config = {
+ [chain1]: {
+ weighted: { weight: 50n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ bridgeMinAcceptedAmount: '50', // 50 tokens minimum
+ },
+ [chain2]: {
+ weighted: { weight: 50n, tolerance: 0n },
+ bridge: ethers.constants.AddressZero,
+ bridgeLockTime: 1,
+ bridgeMinAcceptedAmount: '50',
+ },
+ };
+ const bridgeConfigs = extractBridgeConfigs(config);
+ const strategy = new WeightedStrategy(
+ config,
+ testLogger,
+ bridgeConfigs,
+ undefined,
+ tokensByChainName,
+ );
+
+ // chain1 has 200, chain2 has 100 (total 300, each should have 150)
+ // Route: chain1 -> chain2, amount = 50 (equals minAcceptedAmount)
+ const rawBalances: RawBalances = {
+ [chain1]: ethers.utils.parseEther('200').toBigInt(),
+ [chain2]: ethers.utils.parseEther('100').toBigInt(),
+ };
+
+ const routes = strategy.getRebalancingRoutes(rawBalances);
+
+ expect(routes).to.have.lengthOf(1);
+ expect(routes[0].amount).to.equal(
+ ethers.utils.parseEther('50').toBigInt(),
+ );
+ });
+ });
});
diff --git a/typescript/rebalancer/src/strategy/WeightedStrategy.ts b/typescript/rebalancer/src/strategy/WeightedStrategy.ts
index bb9407c1728..5912846774f 100644
--- a/typescript/rebalancer/src/strategy/WeightedStrategy.ts
+++ b/typescript/rebalancer/src/strategy/WeightedStrategy.ts
@@ -1,8 +1,18 @@
import { type Logger } from 'pino';
-import type { WeightedStrategyConfig } from '../config/types.js';
-import type { RawBalances } from '../interfaces/IStrategy.js';
+import type { ChainMap, Token } from '@hyperlane-xyz/sdk';
+
+import {
+ RebalancerStrategyOptions,
+ type WeightedStrategyConfig,
+} from '../config/types.js';
+import type {
+ RawBalances,
+ Route,
+ StrategyRoute,
+} from '../interfaces/IStrategy.js';
import { type Metrics } from '../metrics/Metrics.js';
+import type { BridgeConfigWithOverride } from '../utils/bridgeUtils.js';
import { BaseStrategy, type Delta } from './BaseStrategy.js';
@@ -11,6 +21,7 @@ import { BaseStrategy, type Delta } from './BaseStrategy.js';
* It distributes funds across chains based on their weights
*/
export class WeightedStrategy extends BaseStrategy {
+ readonly name = RebalancerStrategyOptions.Weighted;
private readonly config: WeightedStrategyConfig;
private readonly totalWeight: bigint;
protected readonly logger: Logger;
@@ -18,11 +29,13 @@ export class WeightedStrategy extends BaseStrategy {
constructor(
config: WeightedStrategyConfig,
logger: Logger,
+ bridgeConfigs: ChainMap,
metrics?: Metrics,
+ tokensByChainName?: ChainMap,
) {
const chains = Object.keys(config);
const log = logger.child({ class: WeightedStrategy.name });
- super(chains, log, metrics);
+ super(chains, log, bridgeConfigs, metrics, tokensByChainName);
this.logger = log;
let totalWeight = 0n;
@@ -54,14 +67,35 @@ export class WeightedStrategy extends BaseStrategy {
/**
* Gets balances categorized by surplus and deficit based on weights
+ *
+ * Simulates both types of rebalances before calculating surpluses/deficits:
+ * - pendingRebalances: in-flight intents (origin tx confirmed, add to destination only)
+ * - proposedRebalances: routes from earlier strategies (subtract from origin AND add to destination)
+ *
+ * This prevents over-rebalancing when multiple strategies run in sequence.
*/
- protected getCategorizedBalances(rawBalances: RawBalances): {
+ protected getCategorizedBalances(
+ rawBalances: RawBalances,
+ pendingRebalances?: Route[],
+ proposedRebalances?: StrategyRoute[],
+ ): {
surpluses: Delta[];
deficits: Delta[];
} {
- // Get the total balance from all chains
+ // Step 1: Simulate pending rebalances (in-flight, origin already deducted on-chain)
+ let simulatedBalances = this.simulatePendingRebalances(
+ rawBalances,
+ pendingRebalances ?? [],
+ );
+
+ // Step 2: Simulate proposed rebalances (from earlier strategies, not yet executed)
+ simulatedBalances = this.simulateProposedRebalances(
+ simulatedBalances,
+ proposedRebalances ?? [],
+ );
+ // Get the total balance from all chains (using simulated balances)
const total = this.chains.reduce(
- (sum, chain) => sum + rawBalances[chain],
+ (sum, chain) => sum + simulatedBalances[chain],
0n,
);
@@ -70,7 +104,7 @@ export class WeightedStrategy extends BaseStrategy {
const { weight, tolerance } = this.config[chain].weighted;
const target = (total * weight) / this.totalWeight;
const toleranceAmount = (target * tolerance) / 100n;
- const balance = rawBalances[chain];
+ const balance = simulatedBalances[chain];
// Apply the tolerance to deficits to prevent small imbalances
if (balance < target - toleranceAmount) {
diff --git a/typescript/rebalancer/src/strategy/index.ts b/typescript/rebalancer/src/strategy/index.ts
index f49d1d6a7a8..e78042db967 100644
--- a/typescript/rebalancer/src/strategy/index.ts
+++ b/typescript/rebalancer/src/strategy/index.ts
@@ -1,4 +1,6 @@
export { BaseStrategy } from './BaseStrategy.js';
+export { CollateralDeficitStrategy } from './CollateralDeficitStrategy.js';
+export { CompositeStrategy } from './CompositeStrategy.js';
export { MinAmountStrategy } from './MinAmountStrategy.js';
export { StrategyFactory } from './StrategyFactory.js';
export { WeightedStrategy } from './WeightedStrategy.js';
diff --git a/typescript/rebalancer/src/test/helpers.ts b/typescript/rebalancer/src/test/helpers.ts
index 2c6470a9e26..c86f5a0b29a 100644
--- a/typescript/rebalancer/src/test/helpers.ts
+++ b/typescript/rebalancer/src/test/helpers.ts
@@ -1,16 +1,394 @@
-import { ethers } from 'ethers';
+import { type PopulatedTransaction, ethers, type providers } from 'ethers';
+import Sinon from 'sinon';
+
+import {
+ type ChainMap,
+ type ChainMetadata,
+ type ChainName,
+ EvmMovableCollateralAdapter,
+ type InterchainGasQuote,
+ type MultiProvider,
+ type Token,
+ type TokenAmount,
+ type WarpCore,
+} from '@hyperlane-xyz/sdk';
import type { RebalancerConfig } from '../config/RebalancerConfig.js';
import { RebalancerStrategyOptions } from '../config/types.js';
-import type { IRebalancer } from '../interfaces/IRebalancer.js';
-import type { RebalancingRoute } from '../interfaces/IStrategy.js';
+import type {
+ IRebalancer,
+ PreparedTransaction,
+ RebalanceExecutionResult,
+ RebalanceRoute,
+} from '../interfaces/IRebalancer.js';
+import type { StrategyRoute } from '../interfaces/IStrategy.js';
+import type { BridgeConfigWithOverride } from '../utils/index.js';
+
+// === Mock Classes ===
export class MockRebalancer implements IRebalancer {
- rebalance(_routes: RebalancingRoute[]): Promise {
- return Promise.resolve();
+ rebalance(_routes: RebalanceRoute[]): Promise {
+ return Promise.resolve([]);
}
}
+// === Test Data Builders ===
+
+export function buildTestRoute(
+ overrides: Partial = {},
+): StrategyRoute {
+ return {
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: ethers.utils.parseEther('100').toBigInt(),
+ bridge: TEST_ADDRESSES.bridge,
+ ...overrides,
+ };
+}
+
+export function buildTestRebalanceRoute(
+ overrides: Partial = {},
+): RebalanceRoute {
+ return {
+ intentId: overrides.intentId ?? `test-route-${Date.now()}`,
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: ethers.utils.parseEther('100').toBigInt(),
+ bridge: TEST_ADDRESSES.bridge,
+ ...overrides,
+ };
+}
+
+export function buildTestResult(
+ overrides: Partial = {},
+): RebalanceExecutionResult {
+ const route = overrides.route ?? buildTestRebalanceRoute();
+ return {
+ route,
+ success: true,
+ messageId:
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
+ txHash:
+ '0x2222222222222222222222222222222222222222222222222222222222222222',
+ ...overrides,
+ };
+}
+
+export function buildTestPreparedTransaction(
+ overrides: Partial = {},
+): PreparedTransaction {
+ const route = overrides.route ?? buildTestRebalanceRoute();
+ return {
+ populatedTx: {
+ to: TEST_ADDRESSES.token,
+ data: '0x',
+ value: ethers.BigNumber.from(0),
+ } as PopulatedTransaction,
+ route,
+ originTokenAmount: createMockTokenAmount(route.amount),
+ ...overrides,
+ };
+}
+
+// === Mock Factories ===
+
+export function createMockTokenAmount(amount: bigint): TokenAmount {
+ return {
+ amount,
+ token: {
+ name: 'TestToken',
+ symbol: 'TEST',
+ decimals: 18,
+ addressOrDenom: TEST_ADDRESSES.token,
+ },
+ getDecimalFormattedAmount: () => ethers.utils.formatEther(amount),
+ } as unknown as TokenAmount;
+}
+
+export interface MockAdapterConfig {
+ isRebalancer?: boolean;
+ allowedDestination?: string;
+ isBridgeAllowed?: boolean;
+ quotes?: InterchainGasQuote[];
+ populatedTx?: PopulatedTransaction;
+ throwOnQuotes?: Error;
+ throwOnPopulate?: Error;
+}
+
+export function createMockAdapter(config: MockAdapterConfig = {}) {
+ const {
+ isRebalancer = true,
+ allowedDestination = TEST_ADDRESSES.arbitrum,
+ isBridgeAllowed = true,
+ quotes = [{ igpQuote: { amount: BigInt(1000000) } }],
+ populatedTx = {
+ to: TEST_ADDRESSES.token,
+ data: '0x',
+ value: ethers.BigNumber.from(0),
+ },
+ throwOnQuotes,
+ throwOnPopulate,
+ } = config;
+
+ const adapter = {
+ isRebalancer: Sinon.stub().resolves(isRebalancer),
+ getAllowedDestination: Sinon.stub().resolves(allowedDestination),
+ isBridgeAllowed: Sinon.stub().resolves(isBridgeAllowed),
+ getRebalanceQuotes: throwOnQuotes
+ ? Sinon.stub().rejects(throwOnQuotes)
+ : Sinon.stub().resolves(quotes),
+ populateRebalanceTx: throwOnPopulate
+ ? Sinon.stub().rejects(throwOnPopulate)
+ : Sinon.stub().resolves(populatedTx),
+ };
+
+ Object.setPrototypeOf(adapter, EvmMovableCollateralAdapter.prototype);
+ return adapter;
+}
+
+export interface MockTokenConfig {
+ name?: string;
+ decimals?: number;
+ addressOrDenom?: string;
+ adapter?: ReturnType;
+}
+
+export function createMockToken(config: MockTokenConfig = {}) {
+ const {
+ name = 'TestToken',
+ decimals = 18,
+ addressOrDenom = TEST_ADDRESSES.token,
+ adapter = createMockAdapter(),
+ } = config;
+
+ const token = {
+ name,
+ decimals,
+ addressOrDenom,
+ amount: (amt: bigint) => createMockTokenAmount(amt),
+ getHypAdapter: Sinon.stub().returns(adapter),
+ };
+
+ return { token, adapter };
+}
+
+export interface MockMultiProviderConfig {
+ chainMetadata?: ChainMap>;
+ signerAddress?: string;
+ sendTransactionReceipt?: providers.TransactionReceipt;
+ throwOnSendTransaction?: Error;
+ throwOnEstimateGas?: Error;
+ providerWaitForTransaction?: providers.TransactionReceipt;
+ providerGetBlock?: providers.Block | null;
+ providerGetTransactionReceipt?: providers.TransactionReceipt | null;
+}
+
+export function createMockMultiProvider(config: MockMultiProviderConfig = {}) {
+ const {
+ chainMetadata = {},
+ signerAddress = TEST_ADDRESSES.signer,
+ sendTransactionReceipt = {
+ transactionHash:
+ '0x1111111111111111111111111111111111111111111111111111111111111111',
+ blockNumber: 100,
+ status: 1,
+ } as providers.TransactionReceipt,
+ throwOnSendTransaction,
+ throwOnEstimateGas,
+ providerWaitForTransaction = sendTransactionReceipt,
+ providerGetBlock = { number: 150 } as providers.Block,
+ providerGetTransactionReceipt = sendTransactionReceipt,
+ } = config;
+
+ const mockProvider = {
+ waitForTransaction: Sinon.stub().resolves(providerWaitForTransaction),
+ getBlock: Sinon.stub().resolves(providerGetBlock),
+ getTransactionReceipt: Sinon.stub().resolves(providerGetTransactionReceipt),
+ };
+
+ const mockSigner = {
+ getAddress: Sinon.stub().resolves(signerAddress),
+ sendTransaction: throwOnSendTransaction
+ ? Sinon.stub().rejects(throwOnSendTransaction)
+ : Sinon.stub().resolves({
+ hash: sendTransactionReceipt.transactionHash,
+ wait: Sinon.stub().resolves(sendTransactionReceipt),
+ }),
+ };
+
+ const defaultChainMetadata: ChainMap> = {
+ ethereum: { domainId: 1, blocks: { confirmations: 32, reorgPeriod: 32 } },
+ arbitrum: { domainId: 42161, blocks: { confirmations: 0, reorgPeriod: 0 } },
+ };
+
+ const mergedMetadata = { ...defaultChainMetadata, ...chainMetadata };
+
+ return {
+ getChainMetadata: Sinon.stub().callsFake(
+ (chain: ChainName) => mergedMetadata[chain] ?? {},
+ ),
+ getProvider: Sinon.stub().returns(mockProvider),
+ getSigner: Sinon.stub().returns(mockSigner),
+ estimateGas: throwOnEstimateGas
+ ? Sinon.stub().rejects(throwOnEstimateGas)
+ : Sinon.stub().resolves(ethers.BigNumber.from(100000)),
+ sendTransaction: throwOnSendTransaction
+ ? Sinon.stub().rejects(throwOnSendTransaction)
+ : Sinon.stub().resolves(sendTransactionReceipt),
+ getDomainId: Sinon.stub().callsFake(
+ (chain: ChainName) => mergedMetadata[chain]?.domainId ?? 0,
+ ),
+ _mockProvider: mockProvider,
+ _mockSigner: mockSigner,
+ } as unknown as MultiProvider & {
+ _mockProvider: typeof mockProvider;
+ _mockSigner: typeof mockSigner;
+ };
+}
+
+export function createMockWarpCore(multiProvider: MultiProvider) {
+ return {
+ multiProvider,
+ } as unknown as WarpCore;
+}
+
+// Valid EVM test addresses (40 hex chars after 0x)
+export const TEST_ADDRESSES: Record = {
+ ethereum: '0x1111111111111111111111111111111111111111',
+ arbitrum: '0x2222222222222222222222222222222222222222',
+ optimism: '0x3333333333333333333333333333333333333333',
+ polygon: '0x4444444444444444444444444444444444444444',
+ bridge: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
+ signer: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+ token: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
+};
+
+export function getTestAddress(key: string): string {
+ return TEST_ADDRESSES[key] ?? `0x${key.padStart(40, '0').slice(-40)}`;
+}
+
+export function buildTestBridges(
+ chains: ChainName[] = ['ethereum', 'arbitrum'],
+): ChainMap {
+ return chains.reduce((acc, chain) => {
+ acc[chain] = {
+ bridge: TEST_ADDRESSES.bridge,
+ bridgeMinAcceptedAmount: 0,
+ };
+ return acc;
+ }, {} as ChainMap);
+}
+
+/**
+ * Convert a chain config map (with bridge addresses) to a BridgeConfigWithOverride map.
+ * Useful for tests that define bridge addresses in the strategy config.
+ */
+export function extractBridgeConfigs(
+ chainConfig: Record<
+ string,
+ { bridge: string; bridgeMinAcceptedAmount?: number | string }
+ >,
+): ChainMap {
+ return Object.entries(chainConfig).reduce((acc, [chain, config]) => {
+ acc[chain] = {
+ bridge: config.bridge,
+ bridgeMinAcceptedAmount: config.bridgeMinAcceptedAmount ?? 0,
+ };
+ return acc;
+ }, {} as ChainMap);
+}
+
+export function buildTestChainMetadata(
+ chains: ChainName[] = ['ethereum', 'arbitrum'],
+): ChainMap {
+ const domainIds: Record = {
+ ethereum: 1,
+ arbitrum: 42161,
+ optimism: 10,
+ polygon: 137,
+ };
+
+ return chains.reduce((acc, chain) => {
+ acc[chain] = {
+ name: chain,
+ chainId: domainIds[chain] ?? 1,
+ domainId: domainIds[chain] ?? 1,
+ protocol: 'ethereum' as any,
+ rpcUrls: [{ http: 'http://localhost:8545' }],
+ blocks: { reorgPeriod: chain === 'polygon' ? 'finalized' : 32 },
+ } as ChainMetadata;
+ return acc;
+ }, {} as ChainMap);
+}
+
+export interface RebalancerTestContext {
+ multiProvider: ReturnType;
+ warpCore: WarpCore;
+ bridges: ChainMap;
+ chainMetadata: ChainMap;
+ tokensByChainName: ChainMap;
+ adapters: ChainMap>;
+}
+
+export function createRebalancerTestContext(
+ chains: ChainName[] = ['ethereum', 'arbitrum'],
+ adapterConfigs: ChainMap = {},
+): RebalancerTestContext {
+ const multiProvider = createMockMultiProvider();
+ const warpCore = createMockWarpCore(
+ multiProvider as unknown as MultiProvider,
+ );
+ const bridges = buildTestBridges(chains);
+ const chainMetadata = buildTestChainMetadata(chains);
+
+ const adapters: ChainMap> = {};
+ const tokensByChainName: ChainMap = {};
+
+ for (const chain of chains) {
+ const adapterConfig = adapterConfigs[chain] ?? {};
+ const tokenAddress = getTestAddress(chain);
+ const { token, adapter } = createMockToken({
+ name: `${chain}Token`,
+ addressOrDenom: tokenAddress,
+ adapter: createMockAdapter(adapterConfig),
+ });
+ adapters[chain] = adapter;
+ tokensByChainName[chain] = token as unknown as Token;
+ }
+
+ for (const originChain of chains) {
+ const adapterConfig = adapterConfigs[originChain] ?? {};
+ if (adapterConfig.allowedDestination === undefined) {
+ const destAddressMap: Record = {};
+ for (const destChain of chains) {
+ if (originChain !== destChain) {
+ destAddressMap[chainMetadata[destChain].domainId] =
+ getTestAddress(destChain);
+ }
+ }
+ adapters[originChain].getAllowedDestination.callsFake(
+ (domainId: number) => {
+ return Promise.resolve(
+ destAddressMap[domainId] ??
+ '0x0000000000000000000000000000000000000000',
+ );
+ },
+ );
+ }
+ }
+
+ return {
+ multiProvider,
+ warpCore,
+ bridges,
+ chainMetadata,
+ tokensByChainName,
+ adapters,
+ };
+}
+
+// === Config Builders ===
+
export function buildTestConfig(
overrides: Partial = {},
chains: string[] = ['chain1'],
@@ -30,16 +408,42 @@ export function buildTestConfig(
{} as Record,
);
+ // Build the default strategy config
+ const defaultStrategyConfig = {
+ rebalanceStrategy: RebalancerStrategyOptions.Weighted,
+ chains: baseChains,
+ };
+
+ // If overrides has strategyConfig as an array, use it directly
+ // Otherwise, wrap single strategy in an array
+ let strategyConfig;
+ if (overrides.strategyConfig) {
+ if (Array.isArray(overrides.strategyConfig)) {
+ strategyConfig = overrides.strategyConfig;
+ } else {
+ // Single strategy override - use it directly wrapped in array
+ // If chains is explicitly provided, use it (don't merge with baseChains)
+ const singleConfig = overrides.strategyConfig as any;
+ strategyConfig = [
+ {
+ ...singleConfig,
+ chains:
+ singleConfig.chains !== undefined
+ ? singleConfig.chains
+ : baseChains,
+ },
+ ];
+ }
+ } else {
+ strategyConfig = [defaultStrategyConfig];
+ }
+
+ // Destructure to exclude strategyConfig from overrides spread
+ const { strategyConfig: _, ...restOverrides } = overrides;
+
return {
warpRouteId: 'test-route',
- strategyConfig: {
- rebalanceStrategy: RebalancerStrategyOptions.Weighted,
- chains: {
- ...baseChains,
- ...(overrides.strategyConfig?.chains ?? {}),
- },
- ...overrides.strategyConfig,
- },
- ...overrides,
+ ...restOverrides,
+ strategyConfig,
} as any as RebalancerConfig;
}
diff --git a/typescript/rebalancer/src/tracking/ActionTracker.test.ts b/typescript/rebalancer/src/tracking/ActionTracker.test.ts
new file mode 100644
index 00000000000..5de4939faea
--- /dev/null
+++ b/typescript/rebalancer/src/tracking/ActionTracker.test.ts
@@ -0,0 +1,783 @@
+import chai, { expect } from 'chai';
+import chaiAsPromised from 'chai-as-promised';
+import { pino } from 'pino';
+import Sinon from 'sinon';
+
+import { EthJsonRpcBlockParameterTag } from '@hyperlane-xyz/sdk';
+
+import type { ConfirmedBlockTags } from '../interfaces/IMonitor.js';
+import type { ExplorerMessage } from '../utils/ExplorerClient.js';
+
+import { ActionTracker, type ActionTrackerConfig } from './ActionTracker.js';
+import { InMemoryStore } from './store/InMemoryStore.js';
+import type { RebalanceAction, RebalanceIntent, Transfer } from './types.js';
+
+chai.use(chaiAsPromised);
+
+const testLogger = pino({ level: 'silent' });
+
+describe('ActionTracker', () => {
+ let transferStore: InMemoryStore;
+ let rebalanceIntentStore: InMemoryStore<
+ RebalanceIntent,
+ 'not_started' | 'in_progress' | 'complete' | 'cancelled'
+ >;
+ let rebalanceActionStore: InMemoryStore<
+ RebalanceAction,
+ 'in_progress' | 'complete' | 'failed'
+ >;
+ let explorerClient: any;
+ let core: any;
+ let config: ActionTrackerConfig;
+ let tracker: ActionTracker;
+ let mailboxStub: any;
+
+ beforeEach(() => {
+ transferStore = new InMemoryStore();
+ rebalanceIntentStore = new InMemoryStore();
+ rebalanceActionStore = new InMemoryStore();
+
+ // Create stub for ExplorerClient methods with default return values
+ const explorerGetInflightUserTransfers = Sinon.stub().resolves([]);
+ const explorerGetInflightRebalanceActions = Sinon.stub().resolves([]);
+
+ explorerClient = {
+ getInflightUserTransfers: explorerGetInflightUserTransfers,
+ getInflightRebalanceActions: explorerGetInflightRebalanceActions,
+ } as any;
+
+ // Create stub for mailbox
+ mailboxStub = {
+ delivered: Sinon.stub().resolves(false),
+ };
+
+ // Create stub for HyperlaneCore
+ const coreGetContracts = Sinon.stub().returns({ mailbox: mailboxStub });
+ const multiProviderGetChainName = Sinon.stub().callsFake(
+ (domain: number) => `chain${domain}`,
+ );
+
+ core = {
+ getContracts: coreGetContracts,
+ multiProvider: {
+ getChainName: multiProviderGetChainName,
+ },
+ } as any;
+
+ config = {
+ routersByDomain: {
+ 1: '0xrouter1',
+ 2: '0xrouter2',
+ 3: '0xrouter3',
+ },
+ bridges: ['0xbridge1', '0xbridge2'],
+ rebalancerAddress: '0xrebalancer',
+ };
+
+ tracker = new ActionTracker(
+ transferStore,
+ rebalanceIntentStore,
+ rebalanceActionStore,
+ explorerClient as any,
+ core as any,
+ config,
+ testLogger,
+ );
+ });
+
+ describe('initialize', () => {
+ it('should query for inflight rebalance messages and create synthetic entities', async () => {
+ const inflightMessages: ExplorerMessage[] = [
+ {
+ msg_id: '0xmsg1',
+ origin_domain_id: 1,
+ destination_domain_id: 2,
+ sender: '0xrouter1',
+ recipient: '0xrouter2',
+ origin_tx_hash: '0xtx1',
+ origin_tx_sender: '0xrebalancer',
+ origin_tx_recipient: '0xrouter1',
+ is_delivered: false,
+ message_body:
+ '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
+ },
+ ];
+
+ explorerClient.getInflightRebalanceActions.resolves(inflightMessages);
+ explorerClient.getInflightUserTransfers.resolves([]);
+
+ // Ensure mailbox returns false so action stays in_progress
+ mailboxStub.delivered.resolves(false);
+
+ await tracker.initialize();
+
+ // Verify ExplorerClient was called twice:
+ // 1. During startup recovery in initialize()
+ // 2. During syncRebalanceActions() called from initialize()
+ expect(explorerClient.getInflightRebalanceActions.callCount).to.equal(2);
+
+ // Verify synthetic intent and action were created
+ const intents = await rebalanceIntentStore.getAll();
+ expect(intents).to.have.lengthOf(1);
+ expect(intents[0].status).to.equal('in_progress');
+ expect(intents[0].amount).to.equal(100n);
+
+ const actions = await rebalanceActionStore.getAll();
+ expect(actions).to.have.lengthOf(1);
+ expect(actions[0].id).to.equal('0xmsg1');
+ expect(actions[0].status).to.equal('in_progress');
+ expect(actions[0].messageId).to.equal('0xmsg1');
+ });
+
+ it('should skip creating action if it already exists', async () => {
+ const inflightMessages: ExplorerMessage[] = [
+ {
+ msg_id: '0xmsg1',
+ origin_domain_id: 1,
+ destination_domain_id: 2,
+ sender: '0xrouter1',
+ recipient: '0xrouter2',
+ origin_tx_hash: '0xtx1',
+ origin_tx_sender: '0xrebalancer',
+ origin_tx_recipient: '0xrouter1',
+ is_delivered: false,
+ message_body:
+ '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
+ },
+ ];
+
+ // Pre-create action
+ await rebalanceActionStore.save({
+ id: '0xmsg1',
+ status: 'in_progress',
+ intentId: 'existing-intent',
+ messageId: '0xmsg1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+
+ explorerClient.getInflightRebalanceActions.resolves(inflightMessages);
+ explorerClient.getInflightUserTransfers.resolves([]);
+
+ await tracker.initialize();
+
+ // Verify no additional action was created
+ const actions = await rebalanceActionStore.getAll();
+ expect(actions).to.have.lengthOf(1);
+
+ // Verify no intent was created either
+ const intents = await rebalanceIntentStore.getAll();
+ expect(intents).to.have.lengthOf(0);
+ });
+ });
+
+ describe('syncTransfers', () => {
+ it('should create new transfers from Explorer messages', async () => {
+ const inflightMessages: ExplorerMessage[] = [
+ {
+ msg_id: '0xmsg1',
+ origin_domain_id: 1,
+ destination_domain_id: 2,
+ sender: '0xuser1',
+ recipient: '0xuser2',
+ origin_tx_hash: '0xtx1',
+ origin_tx_sender: '0xuser1',
+ origin_tx_recipient: '0xrouter1',
+ is_delivered: false,
+ message_body:
+ '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
+ },
+ ];
+
+ explorerClient.getInflightUserTransfers.resolves(inflightMessages);
+
+ await tracker.syncTransfers();
+
+ const transfers = await transferStore.getAll();
+ expect(transfers).to.have.lengthOf(1);
+ expect(transfers[0].id).to.equal('0xmsg1');
+ expect(transfers[0].status).to.equal('in_progress');
+ expect(transfers[0].sender).to.equal('0xuser1');
+ expect(transfers[0].amount).to.equal(100n);
+ });
+
+ it('should not duplicate transfers that already exist', async () => {
+ // Pre-create transfer
+ await transferStore.save({
+ id: '0xmsg1',
+ status: 'in_progress',
+ messageId: '0xmsg1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xuser1',
+ recipient: '0xuser2',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+
+ const inflightMessages: ExplorerMessage[] = [
+ {
+ msg_id: '0xmsg1',
+ origin_domain_id: 1,
+ destination_domain_id: 2,
+ sender: '0xuser1',
+ recipient: '0xuser2',
+ origin_tx_hash: '0xtx1',
+ origin_tx_sender: '0xuser1',
+ origin_tx_recipient: '0xrouter1',
+ is_delivered: false,
+ message_body:
+ '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064',
+ },
+ ];
+
+ explorerClient.getInflightUserTransfers.resolves(inflightMessages);
+
+ await tracker.syncTransfers();
+
+ const transfers = await transferStore.getAll();
+ expect(transfers).to.have.lengthOf(1);
+ });
+
+ it('should mark transfers as complete when delivered', async () => {
+ // Pre-create transfer
+ await transferStore.save({
+ id: '0xmsg1',
+ status: 'in_progress',
+ messageId: '0xmsg1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xuser1',
+ recipient: '0xuser2',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+
+ explorerClient.getInflightUserTransfers.resolves([]);
+ mailboxStub.delivered.resolves(true);
+
+ await tracker.syncTransfers();
+
+ const transfer = await transferStore.get('0xmsg1');
+ expect(transfer?.status).to.equal('complete');
+ });
+ });
+
+ describe('syncRebalanceIntents', () => {
+ it('should mark intents as complete when fully fulfilled', async () => {
+ const intent: RebalanceIntent = {
+ id: 'intent-1',
+ status: 'in_progress',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ fulfilledAmount: 100n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await rebalanceIntentStore.save(intent);
+
+ await tracker.syncRebalanceIntents();
+
+ const updated = await rebalanceIntentStore.get('intent-1');
+ expect(updated?.status).to.equal('complete');
+ });
+
+ it('should not mark intents as complete if not fully fulfilled', async () => {
+ const intent: RebalanceIntent = {
+ id: 'intent-1',
+ status: 'in_progress',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ fulfilledAmount: 50n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await rebalanceIntentStore.save(intent);
+
+ await tracker.syncRebalanceIntents();
+
+ const updated = await rebalanceIntentStore.get('intent-1');
+ expect(updated?.status).to.equal('in_progress');
+ });
+ });
+
+ describe('syncRebalanceActions', () => {
+ it('should mark actions as complete when delivered and update parent intent', async () => {
+ const intent: RebalanceIntent = {
+ id: 'intent-1',
+ status: 'in_progress',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ fulfilledAmount: 0n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ const action: RebalanceAction = {
+ id: 'action-1',
+ status: 'in_progress',
+ intentId: 'intent-1',
+ messageId: '0xmsg1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await rebalanceIntentStore.save(intent);
+ await rebalanceActionStore.save(action);
+
+ mailboxStub.delivered.resolves(true);
+
+ await tracker.syncRebalanceActions();
+
+ // Action should be complete
+ const updatedAction = await rebalanceActionStore.get('action-1');
+ expect(updatedAction?.status).to.equal('complete');
+
+ // Intent should be updated and complete
+ const updatedIntent = await rebalanceIntentStore.get('intent-1');
+ expect(updatedIntent?.fulfilledAmount).to.equal(100n);
+ expect(updatedIntent?.status).to.equal('complete');
+ });
+
+ it('should not mark actions as complete if not delivered', async () => {
+ const action: RebalanceAction = {
+ id: 'action-1',
+ status: 'in_progress',
+ intentId: 'intent-1',
+ messageId: '0xmsg1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await rebalanceActionStore.save(action);
+
+ mailboxStub.delivered.resolves(false);
+
+ await tracker.syncRebalanceActions();
+
+ const updatedAction = await rebalanceActionStore.get('action-1');
+ expect(updatedAction?.status).to.equal('in_progress');
+ });
+ });
+
+ describe('getInProgressTransfers', () => {
+ it('should return only in_progress transfers', async () => {
+ await transferStore.save({
+ id: 'transfer-1',
+ status: 'in_progress',
+ messageId: '0xmsg1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xsender1',
+ recipient: '0xrecipient1',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+
+ await transferStore.save({
+ id: 'transfer-2',
+ status: 'complete',
+ messageId: '0xmsg2',
+ origin: 2,
+ destination: 3,
+ amount: 200n,
+ sender: '0xsender2',
+ recipient: '0xrecipient2',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+
+ const result = await tracker.getInProgressTransfers();
+ expect(result).to.have.lengthOf(1);
+ expect(result[0].id).to.equal('transfer-1');
+ });
+ });
+
+ describe('getActiveRebalanceIntents', () => {
+ it('should return only in_progress intents (origin tx confirmed)', async () => {
+ await rebalanceIntentStore.save({
+ id: 'intent-1',
+ status: 'not_started',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ fulfilledAmount: 0n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+
+ await rebalanceIntentStore.save({
+ id: 'intent-2',
+ status: 'in_progress',
+ origin: 2,
+ destination: 3,
+ amount: 200n,
+ fulfilledAmount: 50n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+
+ await rebalanceIntentStore.save({
+ id: 'intent-3',
+ status: 'complete',
+ origin: 3,
+ destination: 1,
+ amount: 300n,
+ fulfilledAmount: 300n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+
+ // Only in_progress intents are returned - their origin tx is confirmed
+ // so simulation only needs to add to destination (origin already deducted on-chain)
+ const result = await tracker.getActiveRebalanceIntents();
+ expect(result).to.have.lengthOf(1);
+ expect(result[0].id).to.equal('intent-2');
+ });
+ });
+
+ describe('createRebalanceIntent', () => {
+ it('should create a new intent with status not_started', async () => {
+ const result = await tracker.createRebalanceIntent({
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ priority: 1,
+ strategyType: 'MinAmountStrategy',
+ });
+
+ expect(result.status).to.equal('not_started');
+ expect(result.origin).to.equal(1);
+ expect(result.destination).to.equal(2);
+ expect(result.amount).to.equal(100n);
+ expect(result.fulfilledAmount).to.equal(0n);
+ expect(result.priority).to.equal(1);
+ expect(result.strategyType).to.equal('MinAmountStrategy');
+
+ const stored = await rebalanceIntentStore.get(result.id);
+ expect(stored).to.deep.equal(result);
+ });
+ });
+
+ describe('createRebalanceAction', () => {
+ it('should create action and transition intent from not_started to in_progress', async () => {
+ const intent: RebalanceIntent = {
+ id: 'intent-1',
+ status: 'not_started',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ fulfilledAmount: 0n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await rebalanceIntentStore.save(intent);
+
+ const result = await tracker.createRebalanceAction({
+ intentId: 'intent-1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ messageId: '0xmsg1',
+ txHash: '0xtx1',
+ });
+
+ expect(result.status).to.equal('in_progress');
+ expect(result.intentId).to.equal('intent-1');
+ expect(result.messageId).to.equal('0xmsg1');
+
+ const updatedIntent = await rebalanceIntentStore.get('intent-1');
+ expect(updatedIntent?.status).to.equal('in_progress');
+ });
+
+ it('should not transition intent if already in_progress', async () => {
+ const intent: RebalanceIntent = {
+ id: 'intent-1',
+ status: 'in_progress',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ fulfilledAmount: 50n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await rebalanceIntentStore.save(intent);
+
+ await tracker.createRebalanceAction({
+ intentId: 'intent-1',
+ origin: 1,
+ destination: 2,
+ amount: 50n,
+ messageId: '0xmsg2',
+ txHash: '0xtx2',
+ });
+
+ const updatedIntent = await rebalanceIntentStore.get('intent-1');
+ expect(updatedIntent?.status).to.equal('in_progress');
+ expect(updatedIntent?.fulfilledAmount).to.equal(50n); // Should not change
+ });
+ });
+
+ describe('completeRebalanceAction', () => {
+ it('should mark action as complete and update parent intent fulfilledAmount', async () => {
+ const intent: RebalanceIntent = {
+ id: 'intent-1',
+ status: 'in_progress',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ fulfilledAmount: 0n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ const action: RebalanceAction = {
+ id: 'action-1',
+ status: 'in_progress',
+ intentId: 'intent-1',
+ messageId: '0xmsg1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await rebalanceIntentStore.save(intent);
+ await rebalanceActionStore.save(action);
+
+ await tracker.completeRebalanceAction('action-1');
+
+ const updatedAction = await rebalanceActionStore.get('action-1');
+ expect(updatedAction?.status).to.equal('complete');
+
+ const updatedIntent = await rebalanceIntentStore.get('intent-1');
+ expect(updatedIntent?.fulfilledAmount).to.equal(100n);
+ expect(updatedIntent?.status).to.equal('complete');
+ });
+
+ it('should throw error when action not found', async () => {
+ await expect(
+ tracker.completeRebalanceAction('non-existent'),
+ ).to.be.rejectedWith('RebalanceAction non-existent not found');
+ });
+ });
+
+ describe('cancelRebalanceIntent', () => {
+ it('should mark intent as cancelled', async () => {
+ const intent: RebalanceIntent = {
+ id: 'intent-1',
+ status: 'not_started',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ fulfilledAmount: 0n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await rebalanceIntentStore.save(intent);
+
+ await tracker.cancelRebalanceIntent('intent-1');
+
+ const updated = await rebalanceIntentStore.get('intent-1');
+ expect(updated?.status).to.equal('cancelled');
+ });
+ });
+
+ describe('failRebalanceAction', () => {
+ it('should mark action as failed', async () => {
+ const action: RebalanceAction = {
+ id: 'action-1',
+ status: 'in_progress',
+ intentId: 'intent-1',
+ messageId: '0xmsg1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await rebalanceActionStore.save(action);
+
+ await tracker.failRebalanceAction('action-1');
+
+ const updated = await rebalanceActionStore.get('action-1');
+ expect(updated?.status).to.equal('failed');
+ });
+ });
+
+ describe('Explorer query parameters', () => {
+ it('should pass routersByDomain to getInflightRebalanceActions for warp route filtering', async () => {
+ explorerClient.getInflightRebalanceActions.resolves([]);
+ explorerClient.getInflightUserTransfers.resolves([]);
+
+ await tracker.initialize();
+
+ const call = explorerClient.getInflightRebalanceActions.firstCall;
+ expect(call).to.not.be.null;
+
+ const params = call.args[0];
+ expect(params.routersByDomain).to.deep.equal(config.routersByDomain);
+ expect(params.bridges).to.deep.equal(config.bridges);
+ expect(params.rebalancerAddress).to.equal(config.rebalancerAddress);
+ });
+
+ it('should pass routersByDomain to getInflightUserTransfers for warp route filtering', async () => {
+ explorerClient.getInflightRebalanceActions.resolves([]);
+ explorerClient.getInflightUserTransfers.resolves([]);
+
+ await tracker.initialize();
+
+ const call = explorerClient.getInflightUserTransfers.firstCall;
+ expect(call).to.not.be.null;
+
+ const params = call.args[0];
+ expect(params.routersByDomain).to.deep.equal(config.routersByDomain);
+ expect(params.excludeTxSender).to.equal(config.rebalancerAddress);
+ });
+ });
+
+ describe('confirmedBlockTags synchronization', () => {
+ it('should use provided blockTag in syncTransfers delivery check', async () => {
+ await transferStore.save({
+ id: '0xmsg1',
+ status: 'in_progress',
+ messageId: '0xmsg1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xuser1',
+ recipient: '0xuser2',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+
+ explorerClient.getInflightUserTransfers.resolves([]);
+ mailboxStub.delivered.resolves(true);
+
+ const confirmedBlockTags = { chain2: 12345 };
+ await tracker.syncTransfers(confirmedBlockTags);
+
+ expect(mailboxStub.delivered.calledOnce).to.be.true;
+ const call = mailboxStub.delivered.firstCall;
+ expect(call.args[0]).to.equal('0xmsg1');
+ expect(call.args[1]).to.deep.equal({ blockTag: 12345 });
+
+ const transfer = await transferStore.get('0xmsg1');
+ expect(transfer?.status).to.equal('complete');
+ });
+
+ it('should use provided blockTag in syncRebalanceActions delivery check', async () => {
+ const intent: RebalanceIntent = {
+ id: 'intent-1',
+ status: 'in_progress',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ fulfilledAmount: 0n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ const action: RebalanceAction = {
+ id: 'action-1',
+ status: 'in_progress',
+ intentId: 'intent-1',
+ messageId: '0xmsg1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await rebalanceIntentStore.save(intent);
+ await rebalanceActionStore.save(action);
+
+ explorerClient.getInflightRebalanceActions.resolves([]);
+ mailboxStub.delivered.resolves(true);
+
+ const confirmedBlockTags = { chain2: 99999 };
+ await tracker.syncRebalanceActions(confirmedBlockTags);
+
+ expect(mailboxStub.delivered.calledOnce).to.be.true;
+ const call = mailboxStub.delivered.firstCall;
+ expect(call.args[0]).to.equal('0xmsg1');
+ expect(call.args[1]).to.deep.equal({ blockTag: 99999 });
+
+ const updatedAction = await rebalanceActionStore.get('action-1');
+ expect(updatedAction?.status).to.equal('complete');
+ });
+
+ it('should handle string blockTags (like "safe" or "finalized")', async () => {
+ await transferStore.save({
+ id: '0xmsg1',
+ status: 'in_progress',
+ messageId: '0xmsg1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xuser1',
+ recipient: '0xuser2',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+
+ explorerClient.getInflightUserTransfers.resolves([]);
+ mailboxStub.delivered.resolves(false);
+
+ const confirmedBlockTags: ConfirmedBlockTags = {
+ chain2: EthJsonRpcBlockParameterTag.Finalized,
+ };
+ await tracker.syncTransfers(confirmedBlockTags);
+
+ expect(mailboxStub.delivered.calledOnce).to.be.true;
+ const call = mailboxStub.delivered.firstCall;
+ expect(call.args[1]).to.deep.equal({ blockTag: 'finalized' });
+ });
+
+ it('should handle undefined blockTag for chain not in confirmedBlockTags', async () => {
+ await transferStore.save({
+ id: '0xmsg1',
+ status: 'in_progress',
+ messageId: '0xmsg1',
+ origin: 1,
+ destination: 3,
+ amount: 100n,
+ sender: '0xuser1',
+ recipient: '0xuser2',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ });
+
+ explorerClient.getInflightUserTransfers.resolves([]);
+ mailboxStub.delivered.resolves(false);
+
+ const confirmedBlockTags = { chain2: 12345 };
+ await tracker.syncTransfers(confirmedBlockTags);
+
+ expect(mailboxStub.delivered.calledOnce).to.be.true;
+ });
+ });
+});
diff --git a/typescript/rebalancer/src/tracking/ActionTracker.ts b/typescript/rebalancer/src/tracking/ActionTracker.ts
new file mode 100644
index 00000000000..d73738f6670
--- /dev/null
+++ b/typescript/rebalancer/src/tracking/ActionTracker.ts
@@ -0,0 +1,647 @@
+import type { Logger } from 'pino';
+import { v4 as uuidv4 } from 'uuid';
+
+import type { HyperlaneCore } from '@hyperlane-xyz/sdk';
+import type { Address, Domain } from '@hyperlane-xyz/utils';
+import { parseWarpRouteMessage } from '@hyperlane-xyz/utils';
+
+import type {
+ ConfirmedBlockTag,
+ ConfirmedBlockTags,
+} from '../interfaces/IMonitor.js';
+import type {
+ ExplorerClient,
+ ExplorerMessage,
+} from '../utils/ExplorerClient.js';
+
+import type {
+ CreateRebalanceActionParams,
+ CreateRebalanceIntentParams,
+ IActionTracker,
+} from './IActionTracker.js';
+import type {
+ IRebalanceActionStore,
+ IRebalanceIntentStore,
+ ITransferStore,
+ RebalanceAction,
+ RebalanceIntent,
+ Transfer,
+} from './types.js';
+
+export interface ActionTrackerConfig {
+ routersByDomain: Record; // Domain ID → router address (source of truth for routers and domains)
+ bridges: Address[]; // Bridge contract addresses for rebalance action queries
+ rebalancerAddress: Address;
+}
+
+/**
+ * ActionTracker implementation managing the lifecycle of tracked entities.
+ */
+export class ActionTracker implements IActionTracker {
+ constructor(
+ private readonly transferStore: ITransferStore,
+ private readonly rebalanceIntentStore: IRebalanceIntentStore,
+ private readonly rebalanceActionStore: IRebalanceActionStore,
+ private readonly explorerClient: ExplorerClient,
+ private readonly core: HyperlaneCore,
+ private readonly config: ActionTrackerConfig,
+ private readonly logger: Logger,
+ ) {}
+
+ // === Lifecycle ===
+
+ async initialize(): Promise {
+ this.logger.info('ActionTracker initializing');
+
+ // Log config for debugging
+ this.logger.debug(
+ {
+ routersByDomain: this.config.routersByDomain,
+ bridges: this.config.bridges,
+ rebalancerAddress: this.config.rebalancerAddress,
+ },
+ 'ActionTracker config',
+ );
+
+ // 1. Startup recovery: query Explorer for inflight rebalance messages
+ const inflightMessages =
+ await this.explorerClient.getInflightRebalanceActions(
+ {
+ bridges: this.config.bridges,
+ routersByDomain: this.config.routersByDomain,
+ rebalancerAddress: this.config.rebalancerAddress,
+ },
+ this.logger,
+ );
+
+ this.logger.info(
+ { count: inflightMessages.length },
+ 'Found inflight rebalance messages during startup',
+ );
+
+ // 2. For each message, create synthetic intent + action
+ for (const msg of inflightMessages) {
+ await this.recoverAction(msg);
+ }
+
+ // 3. Sync all stores
+ await this.syncTransfers();
+ await this.syncRebalanceIntents();
+ await this.syncRebalanceActions();
+
+ // Log store contents for debugging
+ await this.logStoreContents();
+
+ this.logger.info('ActionTracker initialized');
+ }
+
+ // === Sync Operations ===
+
+ async syncTransfers(confirmedBlockTags?: ConfirmedBlockTags): Promise {
+ this.logger.debug('Syncing transfers');
+
+ const inflightMessages = await this.explorerClient.getInflightUserTransfers(
+ {
+ routersByDomain: this.config.routersByDomain,
+ excludeTxSender: this.config.rebalancerAddress,
+ },
+ this.logger,
+ );
+
+ this.logger.debug(
+ { count: inflightMessages.length },
+ 'Received inflight user transfers from Explorer',
+ );
+
+ let newTransfers = 0;
+ let completedTransfers = 0;
+
+ for (const msg of inflightMessages) {
+ const transfer = await this.transferStore.get(msg.msg_id);
+
+ if (!transfer) {
+ this.logger.debug(
+ {
+ msgId: msg.msg_id,
+ origin: msg.origin_domain_id,
+ destination: msg.destination_domain_id,
+ sender: msg.sender,
+ recipient: msg.recipient,
+ messageBodyLength: msg.message_body?.length,
+ messageBodyPreview: msg.message_body?.substring(0, 66),
+ },
+ 'Processing new transfer message',
+ );
+
+ try {
+ const { amount } = parseWarpRouteMessage(msg.message_body);
+ const newTransfer: Transfer = {
+ id: msg.msg_id,
+ status: 'in_progress',
+ messageId: msg.msg_id,
+ origin: msg.origin_domain_id,
+ destination: msg.destination_domain_id,
+ sender: msg.sender,
+ recipient: msg.recipient,
+ amount,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+ await this.transferStore.save(newTransfer);
+ newTransfers++;
+ this.logger.debug(
+ { id: newTransfer.id, amount: amount.toString() },
+ 'Created new transfer',
+ );
+ } catch (error) {
+ this.logger.warn(
+ {
+ msgId: msg.msg_id,
+ messageBody: msg.message_body,
+ messageBodyLength: msg.message_body?.length,
+ origin: msg.origin_domain_id,
+ destination: msg.destination_domain_id,
+ error: error instanceof Error ? error.message : String(error),
+ },
+ 'Failed to parse message body, skipping transfer',
+ );
+ }
+ }
+ }
+
+ const existingTransfers = await this.getInProgressTransfers();
+ for (const transfer of existingTransfers) {
+ const chainName = this.core.multiProvider.getChainName(
+ transfer.destination,
+ );
+ const blockTag = confirmedBlockTags?.[chainName];
+
+ const delivered = await this.isMessageDelivered(
+ transfer.messageId,
+ transfer.destination,
+ blockTag,
+ );
+
+ if (delivered) {
+ await this.transferStore.update(transfer.id, { status: 'complete' });
+ completedTransfers++;
+ this.logger.debug({ id: transfer.id }, 'Transfer completed');
+ }
+ }
+
+ const inProgressCount = (await this.getInProgressTransfers()).length;
+ this.logger.info(
+ {
+ newTransfers,
+ completed: completedTransfers,
+ inProgress: inProgressCount,
+ },
+ 'Transfers synced',
+ );
+ }
+
+ async syncRebalanceIntents(): Promise {
+ this.logger.debug('Syncing rebalance intents');
+
+ // Check in_progress intents for completion
+ const inProgressIntents =
+ await this.rebalanceIntentStore.getByStatus('in_progress');
+ for (const intent of inProgressIntents) {
+ if (intent.fulfilledAmount >= intent.amount) {
+ await this.rebalanceIntentStore.update(intent.id, {
+ status: 'complete',
+ });
+ this.logger.debug({ id: intent.id }, 'RebalanceIntent completed');
+ }
+ }
+
+ this.logger.debug('Rebalance intents synced');
+ }
+
+ async syncRebalanceActions(
+ confirmedBlockTags?: ConfirmedBlockTags,
+ ): Promise {
+ this.logger.debug('Syncing rebalance actions');
+
+ let discoveredActions = 0;
+ let completedActions = 0;
+
+ const inflightMessages =
+ await this.explorerClient.getInflightRebalanceActions(
+ {
+ bridges: this.config.bridges,
+ routersByDomain: this.config.routersByDomain,
+ rebalancerAddress: this.config.rebalancerAddress,
+ },
+ this.logger,
+ );
+
+ this.logger.debug(
+ { count: inflightMessages.length },
+ 'Found inflight rebalance actions from Explorer',
+ );
+
+ const allActions = await this.rebalanceActionStore.getAll();
+
+ for (const msg of inflightMessages) {
+ const existingAction = allActions.find((a) => a.messageId === msg.msg_id);
+
+ if (!existingAction) {
+ this.logger.info(
+ {
+ msgId: msg.msg_id,
+ origin: msg.origin_domain_id,
+ destination: msg.destination_domain_id,
+ },
+ 'Discovered new rebalance action, recovering...',
+ );
+ await this.recoverAction(msg);
+ discoveredActions++;
+ }
+ }
+
+ const inProgressActions =
+ await this.rebalanceActionStore.getByStatus('in_progress');
+ for (const action of inProgressActions) {
+ const chainName = this.core.multiProvider.getChainName(
+ action.destination,
+ );
+ const blockTag = confirmedBlockTags?.[chainName];
+
+ const delivered = await this.isMessageDelivered(
+ action.messageId,
+ action.destination,
+ blockTag,
+ );
+
+ if (delivered) {
+ await this.completeRebalanceAction(action.id);
+ completedActions++;
+ this.logger.debug({ id: action.id }, 'RebalanceAction completed');
+ }
+ }
+
+ const inProgressCount = (
+ await this.rebalanceActionStore.getByStatus('in_progress')
+ ).length;
+ this.logger.info(
+ {
+ discovered: discoveredActions,
+ completed: completedActions,
+ inProgress: inProgressCount,
+ },
+ 'Actions synced',
+ );
+ }
+
+ // === Transfer Queries ===
+
+ async getInProgressTransfers(): Promise {
+ return this.transferStore.getByStatus('in_progress');
+ }
+
+ async getTransfersByDestination(destination: Domain): Promise {
+ return this.transferStore.getByDestination(destination);
+ }
+
+ // === RebalanceIntent Queries ===
+
+ async getActiveRebalanceIntents(): Promise {
+ // Only return in_progress intents - their origin tx is confirmed
+ // so simulation only needs to add to destination (origin already deducted on-chain)
+ return this.rebalanceIntentStore.getByStatus('in_progress');
+ }
+
+ async getRebalanceIntentsByDestination(
+ destination: Domain,
+ ): Promise {
+ return this.rebalanceIntentStore.getByDestination(destination);
+ }
+
+ // === RebalanceIntent Management ===
+
+ async createRebalanceIntent(
+ params: CreateRebalanceIntentParams,
+ ): Promise {
+ const intent: RebalanceIntent = {
+ id: uuidv4(),
+ status: 'not_started',
+ origin: params.origin,
+ destination: params.destination,
+ amount: params.amount,
+ fulfilledAmount: 0n,
+ bridge: params.bridge,
+ priority: params.priority,
+ strategyType: params.strategyType,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await this.rebalanceIntentStore.save(intent);
+ this.logger.debug(
+ { id: intent.id, origin: intent.origin, destination: intent.destination },
+ 'Created RebalanceIntent',
+ );
+
+ return intent;
+ }
+
+ async completeRebalanceIntent(id: string): Promise {
+ await this.rebalanceIntentStore.update(id, { status: 'complete' });
+ this.logger.info({ id }, 'Intent completed');
+ }
+
+ async cancelRebalanceIntent(id: string): Promise {
+ await this.rebalanceIntentStore.update(id, { status: 'cancelled' });
+ this.logger.debug({ id }, 'Cancelled RebalanceIntent');
+ }
+
+ async failRebalanceIntent(id: string): Promise {
+ await this.rebalanceIntentStore.update(id, { status: 'failed' });
+ this.logger.info({ id }, 'Intent failed');
+ }
+
+ // === RebalanceAction Management ===
+
+ async createRebalanceAction(
+ params: CreateRebalanceActionParams,
+ ): Promise {
+ const action: RebalanceAction = {
+ id: uuidv4(),
+ status: 'in_progress',
+ intentId: params.intentId,
+ messageId: params.messageId,
+ txHash: params.txHash,
+ origin: params.origin,
+ destination: params.destination,
+ amount: params.amount,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await this.rebalanceActionStore.save(action);
+
+ // Transition parent intent from not_started to in_progress
+ const intent = await this.rebalanceIntentStore.get(params.intentId);
+ if (intent && intent.status === 'not_started') {
+ await this.rebalanceIntentStore.update(intent.id, {
+ status: 'in_progress',
+ });
+ this.logger.debug(
+ { intentId: intent.id },
+ 'Transitioned RebalanceIntent to in_progress',
+ );
+ }
+
+ this.logger.debug(
+ { id: action.id, intentId: action.intentId },
+ 'Created RebalanceAction',
+ );
+
+ return action;
+ }
+
+ async completeRebalanceAction(id: string): Promise {
+ const action = await this.rebalanceActionStore.get(id);
+ if (!action) {
+ throw new Error(`RebalanceAction ${id} not found`);
+ }
+
+ await this.rebalanceActionStore.update(id, { status: 'complete' });
+
+ // Update parent intent's fulfilledAmount
+ const intent = await this.rebalanceIntentStore.get(action.intentId);
+ if (intent) {
+ const newFulfilledAmount = intent.fulfilledAmount + action.amount;
+ const updates: Partial = {
+ fulfilledAmount: newFulfilledAmount,
+ };
+
+ // Check if intent is now complete
+ if (newFulfilledAmount >= intent.amount) {
+ updates.status = 'complete';
+ this.logger.debug(
+ { intentId: intent.id },
+ 'RebalanceIntent fully fulfilled',
+ );
+ }
+
+ await this.rebalanceIntentStore.update(intent.id, updates);
+ }
+
+ this.logger.info({ id, intentId: action.intentId }, 'Action completed');
+ }
+
+ async failRebalanceAction(id: string): Promise {
+ await this.rebalanceActionStore.update(id, { status: 'failed' });
+ this.logger.info({ id }, 'Action failed');
+ }
+
+ // === Debug Helpers ===
+
+ /**
+ * Log the contents of all stores.
+ * Logs each item separately for full visibility (avoids [Object] truncation).
+ */
+ async logStoreContents(): Promise {
+ const transfers = await this.transferStore.getAll();
+ const intents = await this.rebalanceIntentStore.getAll();
+ const actions = await this.rebalanceActionStore.getAll();
+
+ const activeIntents = intents.filter((i) =>
+ ['not_started', 'in_progress'].includes(i.status),
+ );
+ const inProgressTransfers = transfers.filter(
+ (t) => t.status === 'in_progress',
+ );
+ const inProgressActions = actions.filter((a) => a.status === 'in_progress');
+
+ // Log summary
+ this.logger.info(
+ {
+ transfers: inProgressTransfers.length,
+ intents: activeIntents.length,
+ actions: inProgressActions.length,
+ },
+ 'Store summary',
+ );
+
+ // Log each transfer separately
+ for (const t of inProgressTransfers) {
+ this.logger.info(
+ {
+ type: 'transfer',
+ origin: t.origin,
+ destination: t.destination,
+ amount: t.amount.toString(),
+ messageId: t.messageId,
+ },
+ 'In-progress transfer',
+ );
+ }
+
+ // Log each intent separately
+ for (const i of activeIntents) {
+ this.logger.info(
+ {
+ type: 'intent',
+ id: i.id,
+ origin: i.origin,
+ destination: i.destination,
+ amount: i.amount.toString(),
+ status: i.status,
+ bridge: i.bridge,
+ },
+ 'Active intent',
+ );
+ }
+
+ // Log each action separately
+ for (const a of inProgressActions) {
+ this.logger.info(
+ {
+ type: 'action',
+ id: a.id,
+ origin: a.origin,
+ destination: a.destination,
+ amount: a.amount.toString(),
+ messageId: a.messageId,
+ intentId: a.intentId,
+ },
+ 'In-progress action',
+ );
+ }
+ }
+
+ // === Private Helpers ===
+
+ private async getConfirmedBlockTag(
+ chainName: string,
+ ): Promise {
+ try {
+ const metadata = this.core.multiProvider.getChainMetadata(chainName);
+ const reorgPeriod = metadata.blocks?.reorgPeriod ?? 32;
+
+ if (typeof reorgPeriod === 'string') {
+ return reorgPeriod as ConfirmedBlockTag;
+ }
+
+ const provider = this.core.multiProvider.getProvider(chainName);
+ const latestBlock = await provider.getBlockNumber();
+ return Math.max(0, latestBlock - reorgPeriod);
+ } catch (error) {
+ this.logger.warn(
+ { chain: chainName, error: (error as Error).message },
+ 'Failed to get confirmed block, using latest',
+ );
+ return undefined;
+ }
+ }
+
+ private async isMessageDelivered(
+ messageId: string,
+ destination: Domain,
+ providedBlockTag?: ConfirmedBlockTag,
+ ): Promise {
+ try {
+ const chainName = this.core.multiProvider.getChainName(destination);
+ const mailbox = this.core.getContracts(chainName).mailbox;
+
+ const blockTag =
+ providedBlockTag ?? (await this.getConfirmedBlockTag(chainName));
+ const delivered = await mailbox.delivered(messageId, { blockTag });
+
+ this.logger.debug(
+ { messageId, destination: chainName, blockTag, delivered },
+ 'Checked message delivery at confirmed block',
+ );
+
+ return delivered;
+ } catch (error) {
+ this.logger.warn(
+ { messageId, destination, error },
+ 'Failed to check message delivery status',
+ );
+ return false;
+ }
+ }
+
+ private async recoverAction(msg: ExplorerMessage): Promise {
+ // Check if action already exists
+ const existing = await this.rebalanceActionStore.get(msg.msg_id);
+ if (existing) {
+ this.logger.debug({ id: msg.msg_id }, 'Action already exists, skipping');
+ return;
+ }
+
+ this.logger.debug(
+ {
+ msgId: msg.msg_id,
+ origin: msg.origin_domain_id,
+ destination: msg.destination_domain_id,
+ sender: msg.sender,
+ recipient: msg.recipient,
+ txHash: msg.origin_tx_hash,
+ messageBodyLength: msg.message_body?.length,
+ messageBodyPreview: msg.message_body?.substring(0, 66),
+ },
+ 'Recovering rebalance action',
+ );
+
+ try {
+ // Create synthetic intent
+ const { amount } = parseWarpRouteMessage(msg.message_body);
+ const intent: RebalanceIntent = {
+ id: uuidv4(),
+ status: 'in_progress',
+ origin: msg.origin_domain_id,
+ destination: msg.destination_domain_id,
+ amount,
+ fulfilledAmount: 0n,
+ priority: undefined,
+ strategyType: undefined,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await this.rebalanceIntentStore.save(intent);
+ this.logger.debug(
+ { id: intent.id, amount: amount.toString() },
+ 'Created synthetic RebalanceIntent',
+ );
+
+ // Create action
+ const action: RebalanceAction = {
+ id: msg.msg_id,
+ status: 'in_progress',
+ intentId: intent.id,
+ messageId: msg.msg_id,
+ txHash: msg.origin_tx_hash,
+ origin: msg.origin_domain_id,
+ destination: msg.destination_domain_id,
+ amount,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await this.rebalanceActionStore.save(action);
+ this.logger.debug(
+ { id: action.id, intentId: action.intentId, amount: amount.toString() },
+ 'Recovered RebalanceAction',
+ );
+ } catch (error) {
+ this.logger.warn(
+ {
+ msgId: msg.msg_id,
+ messageBody: msg.message_body,
+ messageBodyLength: msg.message_body?.length,
+ origin: msg.origin_domain_id,
+ destination: msg.destination_domain_id,
+ txHash: msg.origin_tx_hash,
+ error: error instanceof Error ? error.message : String(error),
+ },
+ 'Failed to parse message body during recovery, skipping action',
+ );
+ }
+ }
+}
diff --git a/typescript/rebalancer/src/tracking/IActionTracker.ts b/typescript/rebalancer/src/tracking/IActionTracker.ts
new file mode 100644
index 00000000000..6128b1283d5
--- /dev/null
+++ b/typescript/rebalancer/src/tracking/IActionTracker.ts
@@ -0,0 +1,140 @@
+import type { Address, Domain } from '@hyperlane-xyz/utils';
+
+import type { ConfirmedBlockTags } from '../interfaces/IMonitor.js';
+
+import type { RebalanceAction, RebalanceIntent, Transfer } from './types.js';
+
+export interface CreateRebalanceIntentParams {
+ origin: Domain;
+ destination: Domain;
+ amount: bigint;
+ bridge?: Address;
+ priority?: number;
+ strategyType?: string;
+}
+
+export interface CreateRebalanceActionParams {
+ intentId: string;
+ origin: Domain;
+ destination: Domain;
+ amount: bigint;
+ messageId: string;
+ txHash?: string;
+}
+
+/**
+ * ActionTracker manages the lifecycle of tracked entities:
+ * - Transfers: Inflight user warp transfers
+ * - RebalanceIntents: Intents to move collateral
+ * - RebalanceActions: On-chain actions to fulfill intents
+ */
+export interface IActionTracker {
+ // === Lifecycle ===
+
+ /**
+ * Initialize the tracker by loading state from Explorer and on-chain data.
+ * Called once on startup.
+ */
+ initialize(): Promise;
+
+ // === Sync Operations ===
+
+ /**
+ * Sync inflight user transfers from Explorer and verify delivery status.
+ * @param confirmedBlockTags Optional block tags from Monitor for consistent state queries
+ */
+ syncTransfers(confirmedBlockTags?: ConfirmedBlockTags): Promise;
+
+ /**
+ * Sync rebalance intents by checking fulfillment status.
+ */
+ syncRebalanceIntents(): Promise;
+
+ /**
+ * Sync rebalance actions by verifying on-chain message delivery.
+ * @param confirmedBlockTags Optional block tags from Monitor for consistent state queries
+ */
+ syncRebalanceActions(confirmedBlockTags?: ConfirmedBlockTags): Promise;
+
+ // === Transfer Queries ===
+
+ /**
+ * Get all transfers currently in progress.
+ */
+ getInProgressTransfers(): Promise;
+
+ /**
+ * Get all transfers destined for a specific domain.
+ */
+ getTransfersByDestination(destination: Domain): Promise;
+
+ // === RebalanceIntent Queries ===
+
+ /**
+ * Get all active rebalance intents (not_started + in_progress).
+ */
+ getActiveRebalanceIntents(): Promise;
+
+ /**
+ * Get all rebalance intents destined for a specific domain.
+ */
+ getRebalanceIntentsByDestination(
+ destination: Domain,
+ ): Promise;
+
+ // === RebalanceIntent Management ===
+
+ /**
+ * Create a new rebalance intent.
+ * Initial status: 'not_started'
+ */
+ createRebalanceIntent(
+ params: CreateRebalanceIntentParams,
+ ): Promise;
+
+ /**
+ * Mark a rebalance intent as complete.
+ */
+ completeRebalanceIntent(id: string): Promise;
+
+ /**
+ * Cancel a rebalance intent.
+ * Used for deliberate stops (e.g., stale fulfillment).
+ */
+ cancelRebalanceIntent(id: string): Promise;
+
+ /**
+ * Mark a rebalance intent as failed.
+ * Used when tx execution was attempted but failed.
+ */
+ failRebalanceIntent(id: string): Promise;
+
+ // === RebalanceAction Management ===
+
+ /**
+ * Create a new rebalance action.
+ * Initial status: 'in_progress'
+ * Also transitions parent intent from 'not_started' to 'in_progress'.
+ */
+ createRebalanceAction(
+ params: CreateRebalanceActionParams,
+ ): Promise;
+
+ /**
+ * Mark a rebalance action as complete.
+ * Updates parent intent's fulfilledAmount.
+ */
+ completeRebalanceAction(id: string): Promise;
+
+ /**
+ * Mark a rebalance action as failed.
+ */
+ failRebalanceAction(id: string): Promise;
+
+ // === Debug ===
+
+ /**
+ * Log the contents of all stores for debugging purposes.
+ */
+ logStoreContents(): Promise;
+}
diff --git a/typescript/rebalancer/src/tracking/InflightContextAdapter.test.ts b/typescript/rebalancer/src/tracking/InflightContextAdapter.test.ts
new file mode 100644
index 00000000000..829b87a2879
--- /dev/null
+++ b/typescript/rebalancer/src/tracking/InflightContextAdapter.test.ts
@@ -0,0 +1,203 @@
+import { expect } from 'chai';
+import Sinon from 'sinon';
+
+import type { MultiProvider } from '@hyperlane-xyz/sdk';
+
+import type { IActionTracker } from './IActionTracker.js';
+import { InflightContextAdapter } from './InflightContextAdapter.js';
+import type { RebalanceIntent, Transfer } from './types.js';
+
+describe('InflightContextAdapter', () => {
+ let actionTracker: Sinon.SinonStubbedInstance;
+ let multiProvider: Sinon.SinonStubbedInstance;
+ let adapter: InflightContextAdapter;
+
+ beforeEach(() => {
+ actionTracker = {
+ getActiveRebalanceIntents: Sinon.stub(),
+ getInProgressTransfers: Sinon.stub(),
+ } as any;
+
+ multiProvider = {
+ getChainName: Sinon.stub(),
+ } as any;
+
+ adapter = new InflightContextAdapter(
+ actionTracker as any,
+ multiProvider as any,
+ );
+ });
+
+ afterEach(() => {
+ Sinon.restore();
+ });
+
+ describe('getInflightContext', () => {
+ it('should return both pendingRebalances and pendingTransfers', async () => {
+ const mockIntents: RebalanceIntent[] = [
+ {
+ id: 'intent1',
+ origin: 1,
+ destination: 2,
+ amount: 1000n,
+ fulfilledAmount: 0n,
+ status: 'not_started',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ ];
+
+ const mockTransfers: Transfer[] = [
+ {
+ id: 'transfer1',
+ origin: 1,
+ destination: 2,
+ amount: 500n,
+ messageId: '0x123',
+ sender: '0xabc' as any,
+ recipient: '0xdef' as any,
+ status: 'in_progress',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ ];
+
+ actionTracker.getActiveRebalanceIntents.resolves(mockIntents);
+ actionTracker.getInProgressTransfers.resolves(mockTransfers);
+ multiProvider.getChainName.withArgs(1).returns('ethereum');
+ multiProvider.getChainName.withArgs(2).returns('arbitrum');
+
+ const result = await adapter.getInflightContext();
+
+ expect(result.pendingRebalances).to.have.lengthOf(1);
+ expect(result.pendingRebalances[0]).to.deep.equal({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 1000n,
+ bridge: undefined,
+ });
+
+ expect(result.pendingTransfers).to.have.lengthOf(1);
+ expect(result.pendingTransfers[0]).to.deep.equal({
+ origin: 'ethereum',
+ destination: 'arbitrum',
+ amount: 500n,
+ });
+ });
+
+ it('should handle empty arrays', async () => {
+ actionTracker.getActiveRebalanceIntents.resolves([]);
+ actionTracker.getInProgressTransfers.resolves([]);
+
+ const result = await adapter.getInflightContext();
+
+ expect(result.pendingRebalances).to.be.an('array').that.is.empty;
+ expect(result.pendingTransfers).to.be.an('array').that.is.empty;
+ });
+
+ it('should correctly convert domain IDs to chain names', async () => {
+ const mockIntents: RebalanceIntent[] = [
+ {
+ id: 'intent1',
+ origin: 137,
+ destination: 10,
+ amount: 2000n,
+ fulfilledAmount: 0n,
+ status: 'not_started',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ ];
+
+ const mockTransfers: Transfer[] = [
+ {
+ id: 'transfer1',
+ origin: 137,
+ destination: 10,
+ amount: 300n,
+ messageId: '0x456',
+ sender: '0x111' as any,
+ recipient: '0x222' as any,
+ status: 'in_progress',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ ];
+
+ actionTracker.getActiveRebalanceIntents.resolves(mockIntents);
+ actionTracker.getInProgressTransfers.resolves(mockTransfers);
+ multiProvider.getChainName.withArgs(137).returns('polygon');
+ multiProvider.getChainName.withArgs(10).returns('optimism');
+
+ const result = await adapter.getInflightContext();
+
+ expect(result.pendingRebalances[0].origin).to.equal('polygon');
+ expect(result.pendingRebalances[0].destination).to.equal('optimism');
+ expect(result.pendingTransfers[0].origin).to.equal('polygon');
+ expect(result.pendingTransfers[0].destination).to.equal('optimism');
+ });
+
+ it('should handle multiple intents and transfers', async () => {
+ const mockIntents: RebalanceIntent[] = [
+ {
+ id: 'intent1',
+ origin: 1,
+ destination: 2,
+ amount: 1000n,
+ fulfilledAmount: 0n,
+ status: 'not_started',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ {
+ id: 'intent2',
+ origin: 2,
+ destination: 3,
+ amount: 1500n,
+ fulfilledAmount: 0n,
+ status: 'in_progress',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ ];
+
+ const mockTransfers: Transfer[] = [
+ {
+ id: 'transfer1',
+ origin: 1,
+ destination: 2,
+ amount: 500n,
+ messageId: '0x123',
+ sender: '0xabc' as any,
+ recipient: '0xdef' as any,
+ status: 'in_progress',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ {
+ id: 'transfer2',
+ origin: 3,
+ destination: 1,
+ amount: 750n,
+ messageId: '0x789',
+ sender: '0x333' as any,
+ recipient: '0x444' as any,
+ status: 'in_progress',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ ];
+
+ actionTracker.getActiveRebalanceIntents.resolves(mockIntents);
+ actionTracker.getInProgressTransfers.resolves(mockTransfers);
+ multiProvider.getChainName.withArgs(1).returns('ethereum');
+ multiProvider.getChainName.withArgs(2).returns('arbitrum');
+ multiProvider.getChainName.withArgs(3).returns('optimism');
+
+ const result = await adapter.getInflightContext();
+
+ expect(result.pendingRebalances).to.have.lengthOf(2);
+ expect(result.pendingTransfers).to.have.lengthOf(2);
+ });
+ });
+});
diff --git a/typescript/rebalancer/src/tracking/InflightContextAdapter.ts b/typescript/rebalancer/src/tracking/InflightContextAdapter.ts
new file mode 100644
index 00000000000..fac6be9228c
--- /dev/null
+++ b/typescript/rebalancer/src/tracking/InflightContextAdapter.ts
@@ -0,0 +1,42 @@
+import type { MultiProvider } from '@hyperlane-xyz/sdk';
+
+import type { InflightContext } from '../interfaces/IStrategy.js';
+
+import type { IActionTracker } from './IActionTracker.js';
+
+/**
+ * Adapter that converts ActionTracker data to strategy-consumable InflightContext.
+ * Handles conversion from Domain IDs (used by ActionTracker) to ChainNames (used by Strategy).
+ */
+export class InflightContextAdapter {
+ constructor(
+ private readonly actionTracker: IActionTracker,
+ private readonly multiProvider: MultiProvider,
+ ) {}
+
+ /**
+ * Get inflight context for strategy decision-making.
+ * Includes active rebalance intents and in-progress user transfers.
+ */
+ async getInflightContext(): Promise {
+ const intents = await this.actionTracker.getActiveRebalanceIntents();
+ const transfers = await this.actionTracker.getInProgressTransfers();
+
+ const pendingRebalances = intents.map((intent) => ({
+ origin: this.multiProvider.getChainName(intent.origin),
+ destination: this.multiProvider.getChainName(intent.destination),
+ // TODO: Review once inventory rebalancing is implemented and we expect
+ // partially fulfilled intents. May need to use (amount - fulfilledAmount).
+ amount: intent.amount,
+ bridge: intent.bridge,
+ }));
+
+ const pendingTransfers = transfers.map((transfer) => ({
+ origin: this.multiProvider.getChainName(transfer.origin),
+ destination: this.multiProvider.getChainName(transfer.destination),
+ amount: transfer.amount,
+ }));
+
+ return { pendingRebalances, pendingTransfers };
+ }
+}
diff --git a/typescript/rebalancer/src/tracking/index.ts b/typescript/rebalancer/src/tracking/index.ts
new file mode 100644
index 00000000000..46a295c168e
--- /dev/null
+++ b/typescript/rebalancer/src/tracking/index.ts
@@ -0,0 +1,36 @@
+// Export all store components
+export type { IStore } from './store/index.js';
+export { InMemoryStore } from './store/index.js';
+
+// Export types
+export type {
+ // Base interfaces
+ Identifiable,
+ CrossChainAction,
+ Timestamped,
+ TrackedActionBase,
+ // Status types
+ TransferStatus,
+ RebalanceIntentStatus,
+ RebalanceActionStatus,
+ // Entity types
+ Transfer,
+ RebalanceIntent,
+ RebalanceAction,
+ // Store type aliases
+ ITransferStore,
+ IRebalanceIntentStore,
+ IRebalanceActionStore,
+} from './types.js';
+
+// Export ActionTracker components
+export { ActionTracker, type ActionTrackerConfig } from './ActionTracker.js';
+
+export type {
+ IActionTracker,
+ CreateRebalanceIntentParams,
+ CreateRebalanceActionParams,
+} from './IActionTracker.js';
+
+// Export InflightContextAdapter
+export { InflightContextAdapter } from './InflightContextAdapter.js';
diff --git a/typescript/rebalancer/src/tracking/store/IStore.ts b/typescript/rebalancer/src/tracking/store/IStore.ts
new file mode 100644
index 00000000000..38795e2142a
--- /dev/null
+++ b/typescript/rebalancer/src/tracking/store/IStore.ts
@@ -0,0 +1,48 @@
+import type { Domain } from '@hyperlane-xyz/utils';
+
+import type { TrackedActionBase } from '../types.js';
+
+/**
+ * Generic store interface for tracking entities.
+ * Provides CRUD operations and query methods for any tracked entity type.
+ *
+ * @template T - The entity type extending TrackedActionBase
+ * @template Status - The status enum type for this entity
+ */
+export interface IStore {
+ /**
+ * Save a new entity or update an existing one.
+ */
+ save(entity: T): Promise;
+
+ /**
+ * Retrieve an entity by ID.
+ */
+ get(id: string): Promise;
+
+ /**
+ * Retrieve all entities.
+ */
+ getAll(): Promise;
+
+ /**
+ * Update an entity with partial data.
+ * Automatically updates the `updatedAt` timestamp.
+ */
+ update(id: string, updates: Partial): Promise;
+
+ /**
+ * Delete an entity by ID.
+ */
+ delete(id: string): Promise;
+
+ /**
+ * Query entities by status.
+ */
+ getByStatus(status: Status): Promise;
+
+ /**
+ * Query entities by destination domain.
+ */
+ getByDestination(destination: Domain): Promise;
+}
diff --git a/typescript/rebalancer/src/tracking/store/InMemoryStore.test.ts b/typescript/rebalancer/src/tracking/store/InMemoryStore.test.ts
new file mode 100644
index 00000000000..85f635a0309
--- /dev/null
+++ b/typescript/rebalancer/src/tracking/store/InMemoryStore.test.ts
@@ -0,0 +1,338 @@
+import chai, { expect } from 'chai';
+import chaiAsPromised from 'chai-as-promised';
+
+import type { Transfer } from '../types.js';
+
+import { InMemoryStore } from './InMemoryStore.js';
+
+chai.use(chaiAsPromised);
+
+describe('InMemoryStore', () => {
+ let store: InMemoryStore;
+
+ beforeEach(() => {
+ store = new InMemoryStore();
+ });
+
+ describe('save', () => {
+ it('should save a new entity', async () => {
+ const transfer: Transfer = {
+ id: 'transfer-1',
+ status: 'in_progress',
+ messageId: 'msg-1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xsender',
+ recipient: '0xrecipient',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await store.save(transfer);
+
+ const retrieved = await store.get('transfer-1');
+ expect(retrieved).to.deep.equal(transfer);
+ });
+
+ it('should overwrite an existing entity with the same id', async () => {
+ const transfer1: Transfer = {
+ id: 'transfer-1',
+ status: 'in_progress',
+ messageId: 'msg-1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xsender',
+ recipient: '0xrecipient',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ const transfer2: Transfer = {
+ ...transfer1,
+ status: 'complete',
+ updatedAt: Date.now() + 1000,
+ };
+
+ await store.save(transfer1);
+ await store.save(transfer2);
+
+ const retrieved = await store.get('transfer-1');
+ expect(retrieved?.status).to.equal('complete');
+ });
+ });
+
+ describe('get', () => {
+ it('should return undefined for non-existent entity', async () => {
+ const result = await store.get('non-existent');
+ expect(result).to.be.undefined;
+ });
+
+ it('should retrieve an existing entity', async () => {
+ const transfer: Transfer = {
+ id: 'transfer-1',
+ status: 'in_progress',
+ messageId: 'msg-1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xsender',
+ recipient: '0xrecipient',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await store.save(transfer);
+ const retrieved = await store.get('transfer-1');
+
+ expect(retrieved).to.deep.equal(transfer);
+ });
+ });
+
+ describe('getAll', () => {
+ it('should return empty array when no entities exist', async () => {
+ const result = await store.getAll();
+ expect(result).to.be.an('array').that.is.empty;
+ });
+
+ it('should return all entities', async () => {
+ const transfer1: Transfer = {
+ id: 'transfer-1',
+ status: 'in_progress',
+ messageId: 'msg-1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xsender1',
+ recipient: '0xrecipient1',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ const transfer2: Transfer = {
+ id: 'transfer-2',
+ status: 'complete',
+ messageId: 'msg-2',
+ origin: 2,
+ destination: 3,
+ amount: 200n,
+ sender: '0xsender2',
+ recipient: '0xrecipient2',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await store.save(transfer1);
+ await store.save(transfer2);
+
+ const result = await store.getAll();
+ expect(result).to.have.lengthOf(2);
+ expect(result).to.deep.include(transfer1);
+ expect(result).to.deep.include(transfer2);
+ });
+ });
+
+ describe('update', () => {
+ it('should throw error when updating non-existent entity', async () => {
+ await expect(
+ store.update('non-existent', { status: 'complete' }),
+ ).to.be.rejectedWith('Entity non-existent not found');
+ });
+
+ it('should update existing entity', async () => {
+ const transfer: Transfer = {
+ id: 'transfer-1',
+ status: 'in_progress',
+ messageId: 'msg-1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xsender',
+ recipient: '0xrecipient',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await store.save(transfer);
+ await store.update('transfer-1', { status: 'complete' });
+
+ const updated = await store.get('transfer-1');
+ expect(updated?.status).to.equal('complete');
+ expect(updated?.updatedAt).to.be.at.least(transfer.updatedAt);
+ });
+
+ it('should preserve non-updated fields', async () => {
+ const transfer: Transfer = {
+ id: 'transfer-1',
+ status: 'in_progress',
+ messageId: 'msg-1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xsender',
+ recipient: '0xrecipient',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await store.save(transfer);
+ await store.update('transfer-1', { status: 'complete' });
+
+ const updated = await store.get('transfer-1');
+ expect(updated?.messageId).to.equal('msg-1');
+ expect(updated?.origin).to.equal(1);
+ expect(updated?.destination).to.equal(2);
+ expect(updated?.amount).to.equal(100n);
+ });
+ });
+
+ describe('delete', () => {
+ it('should delete existing entity', async () => {
+ const transfer: Transfer = {
+ id: 'transfer-1',
+ status: 'in_progress',
+ messageId: 'msg-1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xsender',
+ recipient: '0xrecipient',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await store.save(transfer);
+ await store.delete('transfer-1');
+
+ const retrieved = await store.get('transfer-1');
+ expect(retrieved).to.be.undefined;
+ });
+
+ it('should not throw when deleting non-existent entity', async () => {
+ await expect(store.delete('non-existent')).to.not.be.rejected;
+ });
+ });
+
+ describe('getByStatus', () => {
+ it('should return empty array when no entities match status', async () => {
+ const result = await store.getByStatus('complete');
+ expect(result).to.be.an('array').that.is.empty;
+ });
+
+ it('should return only entities with matching status', async () => {
+ const transfer1: Transfer = {
+ id: 'transfer-1',
+ status: 'in_progress',
+ messageId: 'msg-1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xsender1',
+ recipient: '0xrecipient1',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ const transfer2: Transfer = {
+ id: 'transfer-2',
+ status: 'complete',
+ messageId: 'msg-2',
+ origin: 2,
+ destination: 3,
+ amount: 200n,
+ sender: '0xsender2',
+ recipient: '0xrecipient2',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ const transfer3: Transfer = {
+ id: 'transfer-3',
+ status: 'in_progress',
+ messageId: 'msg-3',
+ origin: 3,
+ destination: 1,
+ amount: 300n,
+ sender: '0xsender3',
+ recipient: '0xrecipient3',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await store.save(transfer1);
+ await store.save(transfer2);
+ await store.save(transfer3);
+
+ const inProgress = await store.getByStatus('in_progress');
+ expect(inProgress).to.have.lengthOf(2);
+ expect(inProgress).to.deep.include(transfer1);
+ expect(inProgress).to.deep.include(transfer3);
+
+ const complete = await store.getByStatus('complete');
+ expect(complete).to.have.lengthOf(1);
+ expect(complete).to.deep.include(transfer2);
+ });
+ });
+
+ describe('getByDestination', () => {
+ it('should return empty array when no entities match destination', async () => {
+ const result = await store.getByDestination(999);
+ expect(result).to.be.an('array').that.is.empty;
+ });
+
+ it('should return only entities with matching destination', async () => {
+ const transfer1: Transfer = {
+ id: 'transfer-1',
+ status: 'in_progress',
+ messageId: 'msg-1',
+ origin: 1,
+ destination: 2,
+ amount: 100n,
+ sender: '0xsender1',
+ recipient: '0xrecipient1',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ const transfer2: Transfer = {
+ id: 'transfer-2',
+ status: 'complete',
+ messageId: 'msg-2',
+ origin: 2,
+ destination: 3,
+ amount: 200n,
+ sender: '0xsender2',
+ recipient: '0xrecipient2',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ const transfer3: Transfer = {
+ id: 'transfer-3',
+ status: 'in_progress',
+ messageId: 'msg-3',
+ origin: 3,
+ destination: 2,
+ amount: 300n,
+ sender: '0xsender3',
+ recipient: '0xrecipient3',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+
+ await store.save(transfer1);
+ await store.save(transfer2);
+ await store.save(transfer3);
+
+ const toDomain2 = await store.getByDestination(2);
+ expect(toDomain2).to.have.lengthOf(2);
+ expect(toDomain2).to.deep.include(transfer1);
+ expect(toDomain2).to.deep.include(transfer3);
+
+ const toDomain3 = await store.getByDestination(3);
+ expect(toDomain3).to.have.lengthOf(1);
+ expect(toDomain3).to.deep.include(transfer2);
+ });
+ });
+});
diff --git a/typescript/rebalancer/src/tracking/store/InMemoryStore.ts b/typescript/rebalancer/src/tracking/store/InMemoryStore.ts
new file mode 100644
index 00000000000..181cf49fd90
--- /dev/null
+++ b/typescript/rebalancer/src/tracking/store/InMemoryStore.ts
@@ -0,0 +1,58 @@
+import type { Domain } from '@hyperlane-xyz/utils';
+
+import type { TrackedActionBase } from '../types.js';
+
+import type { IStore } from './IStore.js';
+
+/**
+ * In-memory implementation of the IStore interface.
+ * Uses a Map for fast lookups and keeps all data in memory.
+ *
+ * @template T - The entity type extending TrackedActionBase
+ * @template Status - The status enum type for this entity
+ */
+export class InMemoryStore
+ implements IStore
+{
+ protected data: Map = new Map();
+
+ async save(entity: T): Promise {
+ this.data.set(entity.id, entity);
+ }
+
+ async get(id: string): Promise {
+ return this.data.get(id);
+ }
+
+ async getAll(): Promise {
+ return Array.from(this.data.values());
+ }
+
+ async update(id: string, updates: Partial): Promise {
+ const existing = this.data.get(id);
+ if (!existing) {
+ throw new Error(`Entity ${id} not found`);
+ }
+ this.data.set(id, {
+ ...existing,
+ ...updates,
+ updatedAt: Date.now(),
+ } as T);
+ }
+
+ async delete(id: string): Promise {
+ this.data.delete(id);
+ }
+
+ async getByStatus(status: Status): Promise {
+ return Array.from(this.data.values()).filter(
+ (entity) => entity.status === status,
+ );
+ }
+
+ async getByDestination(destination: Domain): Promise {
+ return Array.from(this.data.values()).filter(
+ (entity) => entity.destination === destination,
+ );
+ }
+}
diff --git a/typescript/rebalancer/src/tracking/store/index.ts b/typescript/rebalancer/src/tracking/store/index.ts
new file mode 100644
index 00000000000..af3b8b270a0
--- /dev/null
+++ b/typescript/rebalancer/src/tracking/store/index.ts
@@ -0,0 +1,2 @@
+export type { IStore } from './IStore.js';
+export { InMemoryStore } from './InMemoryStore.js';
diff --git a/typescript/rebalancer/src/tracking/types.ts b/typescript/rebalancer/src/tracking/types.ts
new file mode 100644
index 00000000000..d2c3180e813
--- /dev/null
+++ b/typescript/rebalancer/src/tracking/types.ts
@@ -0,0 +1,74 @@
+import type { Address, Domain } from '@hyperlane-xyz/utils';
+
+import type { IStore } from './store/IStore.js';
+
+// === Base Interfaces ===
+
+export interface Identifiable {
+ id: string;
+}
+
+export interface CrossChainAction {
+ origin: Domain;
+ destination: Domain;
+ amount: bigint;
+}
+
+export interface Timestamped {
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface TrackedActionBase
+ extends Identifiable,
+ CrossChainAction,
+ Timestamped {
+ status: string;
+}
+
+// === Status Types ===
+
+export type TransferStatus = 'in_progress' | 'complete';
+export type RebalanceIntentStatus =
+ | 'not_started'
+ | 'in_progress'
+ | 'complete'
+ | 'cancelled'
+ | 'failed';
+export type RebalanceActionStatus = 'in_progress' | 'complete' | 'failed';
+
+// === Entity Types ===
+
+export interface Transfer extends TrackedActionBase {
+ status: TransferStatus;
+ messageId: string;
+ sender: Address;
+ recipient: Address;
+}
+
+export interface RebalanceIntent extends TrackedActionBase {
+ status: RebalanceIntentStatus;
+ fulfilledAmount: bigint;
+ bridge?: Address; // Optional - bridge contract used (missing for recovered intents)
+ priority?: number; // Optional - missing for recovered intents
+ strategyType?: string; // Optional - missing for recovered intents
+}
+
+export interface RebalanceAction extends TrackedActionBase {
+ status: RebalanceActionStatus;
+ intentId: string; // Links to parent RebalanceIntent
+ messageId: string; // Hyperlane message ID
+ txHash?: string; // Origin transaction hash
+}
+
+// === Type Aliases for Stores ===
+
+export type ITransferStore = IStore;
+export type IRebalanceIntentStore = IStore<
+ RebalanceIntent,
+ RebalanceIntentStatus
+>;
+export type IRebalanceActionStore = IStore<
+ RebalanceAction,
+ RebalanceActionStatus
+>;
diff --git a/typescript/rebalancer/src/utils/ExplorerClient.ts b/typescript/rebalancer/src/utils/ExplorerClient.ts
index bcf0fbf9854..4c457e77518 100644
--- a/typescript/rebalancer/src/utils/ExplorerClient.ts
+++ b/typescript/rebalancer/src/utils/ExplorerClient.ts
@@ -2,11 +2,37 @@ import type { Logger } from 'pino';
export type InflightRebalanceQueryParams = {
bridges: string[];
- domains: number[];
+ routersByDomain: Record; // Domain ID → router address (derive routers and domains from this)
txSender: string;
limit?: number;
};
+export type UserTransferQueryParams = {
+ routersByDomain: Record; // Domain ID → router address (derive routers and domains from this)
+ excludeTxSender: string; // Rebalancer address to exclude
+ limit?: number;
+};
+
+export type RebalanceActionQueryParams = {
+ bridges: string[]; // Bridge contract addresses
+ routersByDomain: Record; // Domain ID → router address (derive routers and domains from this)
+ rebalancerAddress: string; // Only include rebalancer's txs
+ limit?: number;
+};
+
+export type ExplorerMessage = {
+ msg_id: string;
+ origin_domain_id: number;
+ destination_domain_id: number;
+ sender: string;
+ recipient: string;
+ origin_tx_hash: string;
+ origin_tx_sender: string;
+ origin_tx_recipient: string;
+ is_delivered: boolean;
+ message_body: string;
+};
+
export class ExplorerClient {
constructor(private readonly baseUrl: string) {}
@@ -14,15 +40,43 @@ export class ExplorerClient {
return addr.replace(/^0x/i, '\\x').toLowerCase();
}
+ /**
+ * Normalize all hex fields in Explorer response from PostgreSQL bytea format (\\x) to standard hex (0x)
+ */
+ private normalizeExplorerMessage(msg: any): ExplorerMessage {
+ const normalizeHex = (hex: string): string => {
+ if (!hex) return hex;
+ return hex.startsWith('\\x') ? '0x' + hex.slice(2) : hex;
+ };
+
+ return {
+ msg_id: normalizeHex(msg.msg_id),
+ origin_domain_id: msg.origin_domain_id,
+ destination_domain_id: msg.destination_domain_id,
+ sender: normalizeHex(msg.sender),
+ recipient: normalizeHex(msg.recipient),
+ origin_tx_hash: normalizeHex(msg.origin_tx_hash),
+ origin_tx_sender: normalizeHex(msg.origin_tx_sender),
+ origin_tx_recipient: normalizeHex(msg.origin_tx_recipient),
+ is_delivered: msg.is_delivered,
+ message_body: normalizeHex(msg.message_body),
+ };
+ }
+
async hasUndeliveredRebalance(
params: InflightRebalanceQueryParams,
logger: Logger,
): Promise {
- const { bridges, domains, txSender, limit = 5 } = params;
+ const { bridges, routersByDomain, txSender, limit = 5 } = params;
+
+ // Derive routers and domains from routersByDomain
+ const routers = Object.values(routersByDomain);
+ const domains = Object.keys(routersByDomain).map(Number);
const variables = {
senders: bridges.map((a) => this.toBytea(a)),
recipients: bridges.map((a) => this.toBytea(a)),
+ originTxRecipients: routers.map((a) => this.toBytea(a)),
originDomains: domains,
destDomains: domains,
txSenders: [this.toBytea(txSender)],
@@ -35,6 +89,7 @@ export class ExplorerClient {
query InflightRebalancesForRoute(
$senders: [bytea!],
$recipients: [bytea!],
+ $originTxRecipients: [bytea!],
$originDomains: [Int!],
$destDomains: [Int!],
$txSenders: [bytea!],
@@ -46,6 +101,7 @@ export class ExplorerClient {
{ is_delivered: { _eq: false } },
{ sender: { _in: $senders } },
{ recipient: { _in: $recipients } },
+ { origin_tx_recipient: { _in: $originTxRecipients } },
{ origin_domain_id: { _in: $originDomains } },
{ destination_domain_id: { _in: $destDomains } },
{ origin_tx_sender: { _in: $txSenders } }
@@ -61,7 +117,9 @@ export class ExplorerClient {
recipient
origin_tx_hash
origin_tx_sender
+ origin_tx_recipient
is_delivered
+ message_body
}
}`;
@@ -94,6 +152,211 @@ export class ExplorerClient {
logger.debug({ rows }, 'Explorer query rows');
- return rows.length > 0;
+ // Post-query validation: verify each message's origin domain matches the expected router
+ const validatedRows = rows.filter((msg: any) => {
+ const expectedRouter = routersByDomain[msg.origin_domain_id];
+ if (!expectedRouter) return false;
+ const normalizedMsgRouter = msg.origin_tx_recipient?.startsWith('\\x')
+ ? '0x' + msg.origin_tx_recipient.slice(2)
+ : msg.origin_tx_recipient;
+ return (
+ normalizedMsgRouter?.toLowerCase() === expectedRouter.toLowerCase()
+ );
+ });
+
+ logger.debug(
+ { totalRows: rows.length, validatedRows: validatedRows.length },
+ 'Post-query validation results',
+ );
+
+ return validatedRows.length > 0;
+ }
+
+ /**
+ * Query inflight user transfers from the Explorer.
+ * Returns transfers where sender/recipient are routers, excluding rebalancer's own transactions.
+ */
+ async getInflightUserTransfers(
+ params: UserTransferQueryParams,
+ logger: Logger,
+ ): Promise {
+ const { routersByDomain, excludeTxSender, limit = 100 } = params;
+
+ // Derive routers and domains from routersByDomain
+ const routers = Object.values(routersByDomain);
+ const domains = Object.keys(routersByDomain).map(Number);
+
+ const variables = {
+ senders: routers.map((a) => this.toBytea(a)),
+ recipients: routers.map((a) => this.toBytea(a)),
+ originDomains: domains,
+ destDomains: domains,
+ excludeTxSender: this.toBytea(excludeTxSender),
+ limit,
+ };
+
+ logger.debug({ variables }, 'Explorer getInflightUserTransfers query');
+
+ const query = `
+ query InflightUserTransfers(
+ $senders: [bytea!],
+ $recipients: [bytea!],
+ $originDomains: [Int!],
+ $destDomains: [Int!],
+ $excludeTxSender: bytea!,
+ $limit: Int = 100
+ ) {
+ message_view(
+ where: {
+ _and: [
+ { is_delivered: { _eq: false } },
+ { sender: { _in: $senders } },
+ { recipient: { _in: $recipients } },
+ { origin_domain_id: { _in: $originDomains } },
+ { destination_domain_id: { _in: $destDomains } },
+ { origin_tx_sender: { _neq: $excludeTxSender } }
+ ]
+ }
+ order_by: { origin_tx_id: desc }
+ limit: $limit
+ ) {
+ msg_id
+ origin_domain_id
+ destination_domain_id
+ sender
+ recipient
+ origin_tx_hash
+ origin_tx_sender
+ origin_tx_recipient
+ is_delivered
+ message_body
+ }
+ }`;
+
+ const res = await fetch(this.baseUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }),
+ });
+
+ logger.debug(
+ { status: res.status },
+ 'Explorer getInflightUserTransfers response',
+ );
+
+ if (!res.ok) {
+ let errorDetails: string;
+ try {
+ const errorJson = await res.json();
+ errorDetails = JSON.stringify(errorJson);
+ } catch (_e) {
+ try {
+ errorDetails = await res.text();
+ } catch (_textError) {
+ errorDetails = 'Unable to read response body';
+ }
+ }
+ throw new Error(`Explorer query failed: ${res.status} ${errorDetails}`);
+ }
+
+ const json = await res.json();
+ const messages = json?.data?.message_view ?? [];
+ return messages.map((msg: any) => this.normalizeExplorerMessage(msg));
+ }
+
+ /**
+ * Query inflight rebalance actions from the Explorer.
+ * Returns messages where sender/recipient are bridges, tx sender is the rebalancer,
+ * and origin_tx_recipient is one of this warp route's routers.
+ */
+ async getInflightRebalanceActions(
+ params: RebalanceActionQueryParams,
+ logger: Logger,
+ ): Promise {
+ const { bridges, routersByDomain, rebalancerAddress, limit = 100 } = params;
+
+ // Derive routers and domains from routersByDomain
+ const routers = Object.values(routersByDomain);
+ const domains = Object.keys(routersByDomain).map(Number);
+
+ const variables = {
+ senders: bridges.map((a) => this.toBytea(a)),
+ recipients: bridges.map((a) => this.toBytea(a)),
+ originTxRecipients: routers.map((a) => this.toBytea(a)),
+ originDomains: domains,
+ destDomains: domains,
+ txSender: this.toBytea(rebalancerAddress),
+ limit,
+ };
+
+ logger.debug({ variables }, 'Explorer getInflightRebalanceActions query');
+
+ const query = `
+ query InflightRebalanceActions(
+ $senders: [bytea!],
+ $recipients: [bytea!],
+ $originTxRecipients: [bytea!],
+ $originDomains: [Int!],
+ $destDomains: [Int!],
+ $txSender: bytea!,
+ $limit: Int = 100
+ ) {
+ message_view(
+ where: {
+ _and: [
+ { is_delivered: { _eq: false } },
+ { sender: { _in: $senders } },
+ { recipient: { _in: $recipients } },
+ { origin_tx_recipient: { _in: $originTxRecipients } },
+ { origin_domain_id: { _in: $originDomains } },
+ { destination_domain_id: { _in: $destDomains } },
+ { origin_tx_sender: { _eq: $txSender } }
+ ]
+ }
+ order_by: { origin_tx_id: desc }
+ limit: $limit
+ ) {
+ msg_id
+ origin_domain_id
+ destination_domain_id
+ sender
+ recipient
+ origin_tx_hash
+ origin_tx_sender
+ origin_tx_recipient
+ is_delivered
+ message_body
+ }
+ }`;
+
+ const res = await fetch(this.baseUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }),
+ });
+
+ logger.debug(
+ { status: res.status },
+ 'Explorer getInflightRebalanceActions response',
+ );
+
+ if (!res.ok) {
+ let errorDetails: string;
+ try {
+ const errorJson = await res.json();
+ errorDetails = JSON.stringify(errorJson);
+ } catch (_e) {
+ try {
+ errorDetails = await res.text();
+ } catch (_textError) {
+ errorDetails = 'Unable to read response body';
+ }
+ }
+ throw new Error(`Explorer query failed: ${res.status} ${errorDetails}`);
+ }
+
+ const json = await res.json();
+ const messages = json?.data?.message_view ?? [];
+ return messages.map((msg: any) => this.normalizeExplorerMessage(msg));
}
}
diff --git a/typescript/rebalancer/src/utils/balanceUtils.test.ts b/typescript/rebalancer/src/utils/balanceUtils.test.ts
index a6ebf0d3e35..78b4fbd37cf 100644
--- a/typescript/rebalancer/src/utils/balanceUtils.test.ts
+++ b/typescript/rebalancer/src/utils/balanceUtils.test.ts
@@ -35,6 +35,7 @@ describe('getRawBalances', () => {
bridgedSupply: tokenBridgedSupply,
},
],
+ confirmedBlockTags: { mainnet: 1000 },
};
});
diff --git a/typescript/rebalancer/src/utils/balanceUtils.ts b/typescript/rebalancer/src/utils/balanceUtils.ts
index 93e5c98099c..18b9b878e52 100644
--- a/typescript/rebalancer/src/utils/balanceUtils.ts
+++ b/typescript/rebalancer/src/utils/balanceUtils.ts
@@ -26,7 +26,7 @@ export function getRawBalances(
// Ignore tokens that are not in the provided chains list
if (!chainSet.has(token.chainName)) {
- logger.info(
+ logger.debug(
{
context: getRawBalances.name,
chain: token.chainName,
@@ -40,7 +40,7 @@ export function getRawBalances(
// Ignore tokens that are not collateralized or are otherwise ineligible
if (!isCollateralizedTokenEligibleForRebalancing(token)) {
- logger.info(
+ logger.debug(
{
context: getRawBalances.name,
chain: token.chainName,
diff --git a/typescript/rebalancer/src/utils/bridgeUtils.test.ts b/typescript/rebalancer/src/utils/bridgeUtils.test.ts
index 9bc830e1d53..3afcfc39450 100644
--- a/typescript/rebalancer/src/utils/bridgeUtils.test.ts
+++ b/typescript/rebalancer/src/utils/bridgeUtils.test.ts
@@ -1,5 +1,4 @@
import { expect } from 'chai';
-import { pino } from 'pino';
import { type ChainMap } from '@hyperlane-xyz/sdk';
@@ -8,29 +7,24 @@ import {
getBridgeConfig,
} from './bridgeUtils.js';
-const testLogger = pino({ level: 'silent' });
-
describe('bridgeConfig', () => {
it('should return the base bridge config when no overrides exist', () => {
const bridges: ChainMap = {
chain1: {
bridge: '0x1234567890123456789012345678901234567890',
bridgeMinAcceptedAmount: 1000,
- bridgeIsWarp: true,
},
chain2: {
bridge: '0x0987654321098765432109876543210987654321',
bridgeMinAcceptedAmount: 2000,
- bridgeIsWarp: true,
},
};
- const result = getBridgeConfig(bridges, 'chain1', 'chain2', testLogger);
+ const result = getBridgeConfig(bridges, 'chain1', 'chain2');
expect(result).to.deep.equal({
bridge: '0x1234567890123456789012345678901234567890',
bridgeMinAcceptedAmount: 1000,
- bridgeIsWarp: true,
});
});
@@ -39,7 +33,6 @@ describe('bridgeConfig', () => {
chain1: {
bridge: '0x1234567890123456789012345678901234567890',
bridgeMinAcceptedAmount: 1000,
- bridgeIsWarp: true,
override: {
chain2: {
bridgeMinAcceptedAmount: 5000,
@@ -49,16 +42,14 @@ describe('bridgeConfig', () => {
chain2: {
bridge: '0x0987654321098765432109876543210987654321',
bridgeMinAcceptedAmount: 2000,
- bridgeIsWarp: true,
},
};
- const result = getBridgeConfig(bridges, 'chain1', 'chain2', testLogger);
+ const result = getBridgeConfig(bridges, 'chain1', 'chain2');
expect(result).to.deep.equal({
bridge: '0x1234567890123456789012345678901234567890',
bridgeMinAcceptedAmount: 5000,
- bridgeIsWarp: true,
});
});
@@ -67,7 +58,6 @@ describe('bridgeConfig', () => {
chain1: {
bridge: '0x1234567890123456789012345678901234567890',
bridgeMinAcceptedAmount: 1000,
- bridgeIsWarp: true,
override: {
chain2: {
bridge: '0xABCDEF0123456789ABCDEF0123456789ABCDEF01',
@@ -77,16 +67,14 @@ describe('bridgeConfig', () => {
chain2: {
bridge: '0x0987654321098765432109876543210987654321',
bridgeMinAcceptedAmount: 2000,
- bridgeIsWarp: true,
},
};
- const result = getBridgeConfig(bridges, 'chain1', 'chain2', testLogger);
+ const result = getBridgeConfig(bridges, 'chain1', 'chain2');
expect(result).to.deep.equal({
bridge: '0xABCDEF0123456789ABCDEF0123456789ABCDEF01',
bridgeMinAcceptedAmount: 1000,
- bridgeIsWarp: true,
});
});
});
diff --git a/typescript/rebalancer/src/utils/bridgeUtils.ts b/typescript/rebalancer/src/utils/bridgeUtils.ts
index 7be5d8067ee..9a6d8bdcec4 100644
--- a/typescript/rebalancer/src/utils/bridgeUtils.ts
+++ b/typescript/rebalancer/src/utils/bridgeUtils.ts
@@ -1,5 +1,3 @@
-import { type Logger } from 'pino';
-
import type { ChainMap, ChainName } from '@hyperlane-xyz/sdk';
export type BridgeConfigWithOverride = BridgeConfig & {
@@ -9,7 +7,6 @@ export type BridgeConfigWithOverride = BridgeConfig & {
export type BridgeConfig = {
bridge: string;
bridgeMinAcceptedAmount: string | number;
- bridgeIsWarp: boolean;
};
/**
@@ -23,15 +20,8 @@ export function getBridgeConfig(
bridges: ChainMap,
fromChain: ChainName,
toChain: ChainName,
- logger: Logger,
): BridgeConfig {
const fromConfig = bridges[fromChain];
-
- if (!fromConfig) {
- logger.error({ fromChain }, 'Bridge config not found');
- throw new Error(`Bridge config not found for chain ${fromChain}`);
- }
-
const routeSpecificOverrides = fromConfig.override?.[toChain];
// Create a new object with the properties from bridgeConfig, excluding the overrides property
diff --git a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts
index 28de939a825..88db1505cc4 100644
--- a/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts
+++ b/typescript/sdk/src/token/adapters/EvmTokenAdapter.ts
@@ -8,7 +8,6 @@ import {
ERC20,
ERC20__factory,
ERC4626__factory,
- GasRouter__factory,
HypERC20,
HypERC20Collateral,
HypERC20Collateral__factory,
@@ -548,22 +547,7 @@ export class EvmMovableCollateralAdapter
domain: Domain,
recipient: Address,
amount: Numberish,
- isWarp: boolean,
): Promise {
- // TODO: In the future, all bridges should get quotes from the quoteTransferRemote function.
- // Given that currently warp routes used as bridges do not, quotes need to be obtained differently.
- // This can probably be removed in the future.
- if (isWarp) {
- const gasRouter = GasRouter__factory.connect(bridge, this.getProvider());
- const gasPayment = await gasRouter.quoteGasPayment(domain);
-
- return [
- {
- igpQuote: { amount: BigInt(gasPayment.toString()) },
- },
- ];
- }
-
const bridgeContract = ITokenBridge__factory.connect(
bridge,
this.getProvider(),
diff --git a/typescript/sdk/src/token/adapters/ITokenAdapter.ts b/typescript/sdk/src/token/adapters/ITokenAdapter.ts
index 99c7d60035d..b0e0412aaa4 100644
--- a/typescript/sdk/src/token/adapters/ITokenAdapter.ts
+++ b/typescript/sdk/src/token/adapters/ITokenAdapter.ts
@@ -73,7 +73,6 @@ export interface IMovableCollateralRouterAdapter extends ITokenAdapter {
domain: Domain,
recipient: Address,
amount: Numberish,
- isWarp: boolean,
): Promise;
getWrappedTokenAddress(): Promise;