import { type PopulatedTransaction, ethers, type providers } from 'ethers'; import Sinon from 'sinon'; import { type ChainMap, type ChainMetadata, type ChainName, EvmMovableCollateralAdapter, type IToken, type InterchainGasQuote, type MultiProvider, type Token, TokenAmount, type WarpCore, } from '@hyperlane-xyz/sdk'; import type { RebalancerConfig } from '../config/RebalancerConfig.js'; import { RebalancerStrategyOptions } from '../config/types.js'; import type { ExecutionResult, IRebalancer, MovableCollateralExecutionResult, PreparedTransaction, RebalancerType, } from '../interfaces/IRebalancer.js'; import type { MovableCollateralRoute, StrategyRoute, } from '../interfaces/IStrategy.js'; import type { BridgeConfigWithOverride } from '../utils/bridgeUtils.js'; // === Mock Classes === export class MockRebalancer implements IRebalancer { readonly rebalancerType: RebalancerType = 'movableCollateral'; rebalance(_routes: MovableCollateralRoute[]): Promise { return Promise.resolve([]); } } // === Test Data Builders === export function buildTestRoute( overrides: Partial = {}, executionType: 'movableCollateral' | 'inventory' = 'movableCollateral', ): StrategyRoute { if (executionType === 'inventory') { return { origin: 'ethereum', destination: 'arbitrum', amount: ethers.utils.parseEther('100').toBigInt(), executionType: 'inventory', externalBridge: 'lifi', ...overrides, } as StrategyRoute; } return { origin: 'ethereum', destination: 'arbitrum', amount: ethers.utils.parseEther('100').toBigInt(), executionType: 'movableCollateral', bridge: TEST_ADDRESSES.bridge, ...overrides, } as StrategyRoute; } export function buildTestMovableCollateralRoute( overrides: Partial = {}, ): MovableCollateralRoute { return { origin: 'ethereum', destination: 'arbitrum', amount: ethers.utils.parseEther('100').toBigInt(), executionType: 'movableCollateral', bridge: TEST_ADDRESSES.bridge, ...overrides, }; } export function buildTestResult( overrides: Partial = {}, ): MovableCollateralExecutionResult { const route = overrides.route ?? buildTestMovableCollateralRoute(); return { route, success: true, messageId: '0x1111111111111111111111111111111111111111111111111111111111111111', txHash: '0x2222222222222222222222222222222222222222222222222222222222222222', ...overrides, }; } export function buildTestPreparedTransaction( overrides: Partial = {}, ): PreparedTransaction { const route = overrides.route ?? ({ ...buildTestMovableCollateralRoute(), intentId: 'test-intent', } as MovableCollateralRoute & { intentId: string }); 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 { const token = { name: 'TestToken', symbol: 'TEST', decimals: 18, addressOrDenom: TEST_ADDRESSES.token, } as IToken; return new TokenAmount(amount, token); } 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; scale?: Token['scale']; adapter?: ReturnType; } export function createMockToken(config: MockTokenConfig = {}) { const { name = 'TestToken', decimals = 18, addressOrDenom = TEST_ADDRESSES.token, scale, adapter = createMockAdapter(), } = config; const token = { name, decimals, addressOrDenom, scale, 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, estimateBlockTime: 1 }, }, }; 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] = { executionType: 'movableCollateral', 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] = { executionType: 'movableCollateral', 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'], ): RebalancerConfig { const baseChains = chains.reduce( (acc, chain) => { (acc as any)[chain] = { bridgeLockTime: 60 * 1000, bridge: ethers.constants.AddressZero, weighted: { weight: BigInt(1), tolerance: BigInt(0), }, }; return acc; }, {} 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', ...restOverrides, strategyConfig, } as any as RebalancerConfig; }