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 { DEFAULT_INTENT_TTL_MS, RebalancerStrategyOptions, } from '../config/types.js'; import { RebalancerContextFactory } from '../factories/RebalancerContextFactory.js'; import type { ExternalBridgeRegistry } from '../interfaces/IExternalBridge.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/IActionTracker.js'; import { InflightContextAdapter } from '../tracking/InflightContextAdapter.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 }, }, }, }, ], intentTTL: DEFAULT_INTENT_TTL_MS, } 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 { rebalancerType: 'movableCollateral' as const, 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(), syncInventoryMovementActions: Sinon.stub().resolves({ completed: 0, failed: 0, }), logStoreContents: Sinon.stub().resolves(), getInProgressTransfers: Sinon.stub().resolves([]), getActiveRebalanceIntents: Sinon.stub().resolves([]), getTransfersByDestination: Sinon.stub().resolves([]), getRebalanceIntentsByDestination: Sinon.stub().resolves([]), getTransfer: Sinon.stub().resolves(undefined), getRebalanceIntent: Sinon.stub().resolves(undefined), getRebalanceAction: Sinon.stub().resolves(undefined), getInProgressActions: Sinon.stub().resolves([]), getPartiallyFulfilledInventoryIntents: Sinon.stub().resolves([]), getActionsByType: Sinon.stub().resolves([]), getActionsForIntent: Sinon.stub().resolves([]), getInflightInventoryMovements: Sinon.stub().resolves(0n), }; } 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: (_actionTracker: IActionTracker) => rebalancer, createRebalancers: async (_actionTracker: IActionTracker) => ({ rebalancers: [rebalancer], externalBridgeRegistry: {}, inventoryConfig: undefined, }), createStrategy: async () => strategy, createMonitor: () => monitor, createMetrics: async () => overrides.metrics ?? ({} as Metrics), createActionTracker: async () => ({ tracker: actionTracker, adapter: inflightAdapter, }), createOrchestrator: (options: { strategy: IStrategy; actionTracker: IActionTracker; inflightContextAdapter: InflightContextAdapter; rebalancers: IRebalancer[]; externalBridgeRegistry: Partial; metrics?: Metrics; }) => ({ executeCycle: Sinon.stub().callsFake(async (_event: any) => { // Simulate orchestrator behavior: call strategy, then rebalancer, then record metrics const strategyWithGetRoutes = options.strategy as any; const routes = strategyWithGetRoutes.getRebalancingRoutes?.() ?? []; if (routes.length > 0 && options.rebalancers[0]) { const results = await options.rebalancers[0].rebalance(routes); if (options.metrics && results) { results.forEach((result: any) => { if (result.success) { (options.metrics as any).recordRebalancerSuccess?.(); } else { (options.metrics as any).recordRebalancerFailure?.(); } }); } } return { success: true }; }), }), } 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: { rebalanceResults: Array<{ route: { origin: string; destination: string; amount: bigint; bridge: string; }; success: boolean; messageId?: string; txHash?: string; error?: string; }>; strategyRoutes: Array<{ origin: string; destination: string; amount: bigint; bridge: string; }>; }, ): Promise { const actionTracker = createMockActionTracker(); 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 normalize manual amount to canonical units when token has scale', async () => { const rebalancer = createMockRebalancer(); const warpCore = { tokens: [ { ...createMockToken('ethereum'), decimals: 18, scale: { numerator: 1n, denominator: 1_000_000_000_000n }, }, createMockToken('arbitrum'), ], multiProvider: createMockMultiProvider(), } as unknown as WarpCore; const contextFactory = createMockContextFactory({ rebalancer, warpCore }); sandbox.stub(RebalancerContextFactory, 'create').resolves(contextFactory); const service = new RebalancerService( createMockMultiProvider(), undefined, {} as any, createMockRebalancerConfig(), { mode: 'manual', logger: testLogger }, ); await service.executeManual({ origin: 'ethereum', destination: 'arbitrum', amount: '1', }); const calledRoutes = rebalancer.rebalance.firstCall.args[0]; expect(calledRoutes[0].amount).to.equal(1_000_000n); }); 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 }, }, }, }, ], intentTTL: DEFAULT_INTENT_TTL_MS, } 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.calledOnce).to.be.true; }); }); describe('daemon mode rebalancer calls', () => { it('should call rebalancer with routes from strategy', async () => { const { rebalancer, triggerCycle } = await setupDaemonTest(sandbox, { rebalanceResults: [ { route: { origin: 'ethereum', destination: 'arbitrum', amount: 1000n, bridge: TEST_ADDRESSES.bridge, }, success: true, messageId: '0x1111111111111111111111111111111111111111111111111111111111111111', }, ], strategyRoutes: [ { origin: 'ethereum', destination: 'arbitrum', amount: 1000n, bridge: TEST_ADDRESSES.bridge, }, ], }); await triggerCycle(); expect(rebalancer.rebalance.calledOnce).to.be.true; const routesPassedToRebalancer = rebalancer.rebalance.firstCall.args[0]; expect(routesPassedToRebalancer).to.have.lengthOf(1); expect(routesPassedToRebalancer[0].origin).to.equal('ethereum'); expect(routesPassedToRebalancer[0].destination).to.equal('arbitrum'); }); it('should call rebalancer with multiple routes', async () => { const { rebalancer, triggerCycle } = await setupDaemonTest(sandbox, { rebalanceResults: [ { route: { origin: 'ethereum', destination: 'arbitrum', amount: 1000n, bridge: TEST_ADDRESSES.bridge, }, success: true, }, { route: { origin: 'arbitrum', destination: 'ethereum', amount: 500n, bridge: TEST_ADDRESSES.bridge, }, success: true, }, ], strategyRoutes: [ { origin: 'ethereum', destination: 'arbitrum', amount: 1000n, bridge: TEST_ADDRESSES.bridge, }, { origin: 'arbitrum', destination: 'ethereum', amount: 500n, bridge: TEST_ADDRESSES.bridge, }, ], }); await triggerCycle(); expect(rebalancer.rebalance.calledOnce).to.be.true; const routesPassedToRebalancer = rebalancer.rebalance.firstCall.args[0]; expect(routesPassedToRebalancer).to.have.lengthOf(2); }); it('should not call rebalancer when no routes proposed', async () => { const { rebalancer, triggerCycle } = await setupDaemonTest(sandbox, { rebalanceResults: [], strategyRoutes: [], }); await triggerCycle(); expect(rebalancer.rebalance.called).to.be.false; }); }); 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'); }); }); });