package arbitrage import ( "context" "math/big" "testing" "time" "github.com/ethereum/go-ethereum/common" "github.com/holiman/uint256" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/fraktal/mev-beta/internal/logger" "github.com/fraktal/mev-beta/pkg/market" ) // MockMarketManager is a mock implementation of MarketManager for testing type MockMarketManager struct { mock.Mock } func (m *MockMarketManager) GetAllPools() []market.PoolData { args := m.Called() return args.Get(0).([]market.PoolData) } func (m *MockMarketManager) GetPool(ctx context.Context, poolAddress common.Address) (*market.PoolData, error) { args := m.Called(ctx, poolAddress) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*market.PoolData), args.Error(1) } func (m *MockMarketManager) GetPoolsByTokens(token0, token1 common.Address) []*market.PoolData { args := m.Called(token0, token1) return args.Get(0).([]*market.PoolData) } func (m *MockMarketManager) UpdatePool(poolAddress common.Address, liquidity *uint256.Int, sqrtPriceX96 *uint256.Int, tick int) { m.Called(poolAddress, liquidity, sqrtPriceX96, tick) } func (m *MockMarketManager) GetPoolsByTokensWithProtocol(token0, token1 common.Address, protocol string) []*market.PoolData { args := m.Called(token0, token1, protocol) return args.Get(0).([]*market.PoolData) } // TestNewMultiHopScanner tests the creation of a new MultiHopScanner func TestNewMultiHopScanner(t *testing.T) { log := logger.New("info", "text", "") marketMgr := &market.MarketManager{} scanner := NewMultiHopScanner(log, nil, marketMgr) assert.NotNil(t, scanner) assert.Equal(t, log, scanner.logger) // Note: marketMgr is not stored in the scanner struct // NOTE: These values have been optimized for aggressive opportunity detection: // - maxHops reduced from 4 to 3 for faster execution // - minProfitWei reduced to 0.00001 ETH for more opportunities // - maxSlippage increased to 5% for broader market coverage // - maxPaths increased to 200 for thorough opportunity search // - pathTimeout increased to 2s for complete analysis assert.Equal(t, 3, scanner.maxHops) assert.Equal(t, "10000000000000", scanner.minProfitWei.String()) assert.Equal(t, 0.05, scanner.maxSlippage) assert.Equal(t, 200, scanner.maxPaths) assert.Equal(t, time.Second*2, scanner.pathTimeout) assert.NotNil(t, scanner.pathCache) assert.NotNil(t, scanner.tokenGraph) assert.NotNil(t, scanner.pools) } // TestTokenGraph tests the TokenGraph functionality func TestTokenGraph(t *testing.T) { graph := NewTokenGraph() assert.NotNil(t, graph) assert.NotNil(t, graph.adjacencyList) // Test adding edges tokenA := common.HexToAddress("0xA") tokenB := common.HexToAddress("0xB") sqrtPriceX96, _ := uint256.FromDecimal("79228162514264337593543950336") pool := &PoolInfo{ Address: common.HexToAddress("0x1"), Token0: tokenA, Token1: tokenB, Protocol: "UniswapV3", Fee: 3000, Liquidity: uint256.NewInt(1000000), SqrtPriceX96: sqrtPriceX96, LastUpdated: time.Now(), } // Add pool to graph graph.mutex.Lock() graph.adjacencyList[tokenA] = make(map[common.Address][]*PoolInfo) graph.adjacencyList[tokenA][tokenB] = append(graph.adjacencyList[tokenA][tokenB], pool) graph.mutex.Unlock() // Test getting adjacent tokens adjacent := graph.GetAdjacentTokens(tokenA) assert.Len(t, adjacent, 1) assert.Contains(t, adjacent, tokenB) assert.Len(t, adjacent[tokenB], 1) assert.Equal(t, pool, adjacent[tokenB][0]) } // TestIsPoolUsable tests the isPoolUsable function func TestIsPoolUsable(t *testing.T) { log := logger.New("info", "text", "") marketMgr := &market.MarketManager{} scanner := NewMultiHopScanner(log, nil, marketMgr) // Test usable pool (recent and sufficient liquidity) now := time.Now() sqrtPriceX961, _ := uint256.FromDecimal("79228162514264337593543950336") usablePool := &PoolInfo{ Address: common.HexToAddress("0x1"), Token0: common.HexToAddress("0xA"), Token1: common.HexToAddress("0xB"), Protocol: "UniswapV3", Fee: 3000, Liquidity: uint256.NewInt(1000000000000000000), // 1 ETH worth of liquidity SqrtPriceX96: sqrtPriceX961, LastUpdated: now, } assert.True(t, scanner.isPoolUsable(usablePool)) // Test pool with insufficient liquidity sqrtPriceX962, _ := uint256.FromDecimal("79228162514264337593543950336") unusablePool1 := &PoolInfo{ Address: common.HexToAddress("0x2"), Token0: common.HexToAddress("0xA"), Token1: common.HexToAddress("0xB"), Protocol: "UniswapV3", Fee: 3000, Liquidity: uint256.NewInt(10000000000000000), // 0.01 ETH worth of liquidity (too little) SqrtPriceX96: sqrtPriceX962, LastUpdated: now, } assert.False(t, scanner.isPoolUsable(unusablePool1)) // Test stale pool sqrtPriceX963, _ := uint256.FromDecimal("79228162514264337593543950336") stalePool := &PoolInfo{ Address: common.HexToAddress("0x3"), Token0: common.HexToAddress("0xA"), Token1: common.HexToAddress("0xB"), Protocol: "UniswapV3", Fee: 3000, Liquidity: uint256.NewInt(1000000000000000000), SqrtPriceX96: sqrtPriceX963, LastUpdated: now.Add(-10 * time.Minute), // 10 minutes ago (stale) } assert.False(t, scanner.isPoolUsable(stalePool)) } // TestCalculateSimpleAMMOutput tests the calculateSimpleAMMOutput function func TestCalculateSimpleAMMOutput(t *testing.T) { log := logger.New("info", "text", "") marketMgr := &market.MarketManager{} scanner := NewMultiHopScanner(log, nil, marketMgr) // Create a pool with known values for testing tokenIn := common.HexToAddress("0xA") tokenOut := common.HexToAddress("0xB") // Create a pool with realistic values // SqrtPriceX96 = 79228162514264337593543950336 (represents 1.0 price) // Liquidity = 1000000000000000000 (1 ETH) sqrtPriceX965, _ := uint256.FromDecimal("79228162514264337593543950336") pool := &PoolInfo{ Address: common.HexToAddress("0x1"), Token0: tokenIn, Token1: tokenOut, Protocol: "UniswapV2", Fee: 3000, Liquidity: uint256.NewInt(1000000000000000000), SqrtPriceX96: sqrtPriceX965, LastUpdated: time.Now(), } // Test with 1 ETH input amountIn := big.NewInt(1000000000000000000) // 1 ETH output, err := scanner.calculateSimpleAMMOutput(amountIn, pool, tokenIn, tokenOut) // We should get a valid output assert.NoError(t, err) assert.NotNil(t, output) assert.True(t, output.Sign() > 0) // Test with missing data badPool := &PoolInfo{ Address: common.HexToAddress("0x2"), Token0: tokenIn, Token1: tokenOut, Protocol: "UniswapV2", Fee: 3000, Liquidity: nil, // Missing liquidity SqrtPriceX96: nil, // Missing sqrtPriceX96 LastUpdated: time.Now(), } output, err = scanner.calculateSimpleAMMOutput(amountIn, badPool, tokenIn, tokenOut) assert.Error(t, err) assert.Nil(t, output) } // TestCalculateUniswapV3Output tests the calculateUniswapV3Output function func TestCalculateUniswapV3Output(t *testing.T) { log := logger.New("info", "text", "") marketMgr := &market.MarketManager{} scanner := NewMultiHopScanner(log, nil, marketMgr) // Create a pool with known values for testing tokenIn := common.HexToAddress("0xA") tokenOut := common.HexToAddress("0xB") // Create a pool with realistic values sqrtPriceX96, _ := uint256.FromDecimal("79228162514264337593543950336") pool := &PoolInfo{ Address: common.HexToAddress("0x1"), Token0: tokenIn, Token1: tokenOut, Protocol: "UniswapV3", Fee: 3000, Liquidity: uint256.NewInt(1000000000000000000), SqrtPriceX96: sqrtPriceX96, LastUpdated: time.Now(), } // Test with 1 ETH input amountIn := big.NewInt(1000000000000000000) // 1 ETH output, err := scanner.calculateUniswapV3Output(amountIn, pool, tokenIn, tokenOut) // We should get a valid output assert.NoError(t, err) assert.NotNil(t, output) assert.True(t, output.Sign() > 0) } // TestEstimateHopGasCost tests the estimateHopGasCost function func TestEstimateHopGasCost(t *testing.T) { log := logger.New("info", "text", "") marketMgr := &market.MarketManager{} scanner := NewMultiHopScanner(log, nil, marketMgr) // NOTE: Gas estimates have been optimized for flash loan execution: // Flash loans are more efficient than capital-requiring swaps because: // - No capital lock-up required // - Lower slippage on large amounts // - More predictable execution // Therefore, gas costs are realistically lower than non-flash-loan swaps // Test UniswapV3 - optimized to 70k for flash loans gas := scanner.estimateHopGasCost("UniswapV3") assert.Equal(t, int64(70000), gas.Int64()) // Test UniswapV2 - optimized to 60k for flash loans gas = scanner.estimateHopGasCost("UniswapV2") assert.Equal(t, int64(60000), gas.Int64()) // Test SushiSwap - optimized to 60k for flash loans (similar to V2) gas = scanner.estimateHopGasCost("SushiSwap") assert.Equal(t, int64(60000), gas.Int64()) // Test default case - conservative estimate of 70k gas = scanner.estimateHopGasCost("UnknownProtocol") assert.Equal(t, int64(70000), gas.Int64()) } // TestIsProfitable tests the isProfitable function func TestIsProfitable(t *testing.T) { log := logger.New("info", "text", "") marketMgr := &market.MarketManager{} scanner := NewMultiHopScanner(log, nil, marketMgr) // Create a profitable path profitablePath := &ArbitragePath{ NetProfit: big.NewInt(2000000000000000000), // 2 ETH profit ROI: 5.0, // 5% ROI } assert.True(t, scanner.isProfitable(profitablePath)) // Create an unprofitable path (below minimum profit) unprofitablePath1 := &ArbitragePath{ NetProfit: big.NewInt(100000000000000000), // 0.1 ETH profit (below 0.001 ETH threshold) ROI: 0.5, // 0.5% ROI } assert.False(t, scanner.isProfitable(unprofitablePath1)) // Create a path with good profit but poor ROI unprofitablePath2 := &ArbitragePath{ NetProfit: big.NewInt(5000000000000000000), // 5 ETH profit ROI: 0.5, // 0.5% ROI (below 1% threshold) } assert.False(t, scanner.isProfitable(unprofitablePath2)) } // TestCreateArbitragePath tests the createArbitragePath function func TestCreateArbitragePath(t *testing.T) { log := logger.New("info", "text", "") marketMgr := &market.MarketManager{} scanner := NewMultiHopScanner(log, nil, marketMgr) // Test with invalid inputs tokens := []common.Address{ common.HexToAddress("0xA"), common.HexToAddress("0xB"), } sqrtPriceX966, _ := uint256.FromDecimal("79228162514264337593543950336") pools := []*PoolInfo{ { Address: common.HexToAddress("0x1"), Token0: common.HexToAddress("0xA"), Token1: common.HexToAddress("0xB"), Protocol: "UniswapV3", Fee: 3000, Liquidity: uint256.NewInt(1000000000000000000), SqrtPriceX96: sqrtPriceX966, LastUpdated: time.Now(), }, } initialAmount := big.NewInt(1000000000000000000) // 1 ETH // This should fail because we need at least 3 tokens for a valid arbitrage path (A->B->A) path := scanner.createArbitragePath(tokens, pools, initialAmount) assert.Nil(t, path) // Test with valid inputs (triangle: A->B->C->A) validTokens := []common.Address{ common.HexToAddress("0xA"), common.HexToAddress("0xB"), common.HexToAddress("0xC"), common.HexToAddress("0xA"), // Back to start } sqrtPriceX967, _ := uint256.FromDecimal("79228162514264337593543950336") sqrtPriceX968, _ := uint256.FromDecimal("79228162514264337593543950336") sqrtPriceX969, _ := uint256.FromDecimal("79228162514264337593543950336") validPools := []*PoolInfo{ { Address: common.HexToAddress("0x1"), Token0: common.HexToAddress("0xA"), Token1: common.HexToAddress("0xB"), Protocol: "UniswapV3", Fee: 3000, Liquidity: uint256.NewInt(1000000000000000000), SqrtPriceX96: sqrtPriceX967, LastUpdated: time.Now(), }, { Address: common.HexToAddress("0x2"), Token0: common.HexToAddress("0xB"), Token1: common.HexToAddress("0xC"), Protocol: "UniswapV3", Fee: 3000, Liquidity: uint256.NewInt(1000000000000000000), SqrtPriceX96: sqrtPriceX968, LastUpdated: time.Now(), }, { Address: common.HexToAddress("0x3"), Token0: common.HexToAddress("0xC"), Token1: common.HexToAddress("0xA"), Protocol: "UniswapV3", Fee: 3000, Liquidity: uint256.NewInt(1000000000000000000), SqrtPriceX96: sqrtPriceX969, LastUpdated: time.Now(), }, } path = scanner.createArbitragePath(validTokens, validPools, initialAmount) assert.NotNil(t, path) assert.Len(t, path.Tokens, 4) assert.Len(t, path.Pools, 3) assert.Len(t, path.Protocols, 3) assert.Len(t, path.Fees, 3) assert.NotNil(t, path.EstimatedGas) assert.NotNil(t, path.NetProfit) } // TestScanForArbitrage tests the main ScanForArbitrage function func TestScanForArbitrage(t *testing.T) { log := logger.New("info", "text", "") // Create a mock market manager mockMarketMgr := &MockMarketManager{} sqrtPriceX9610, _ := uint256.FromDecimal("79228162514264337593543950336") // Set up mock expectations mockMarketMgr.On("GetAllPools").Return([]market.PoolData{ { Address: common.HexToAddress("0x1"), Token0: common.HexToAddress("0xA"), Token1: common.HexToAddress("0xB"), Fee: 3000, Liquidity: uint256.NewInt(1000000000000000000), SqrtPriceX96: sqrtPriceX9610, LastUpdated: time.Now(), }, }) scanner := NewMultiHopScanner(log, nil, mockMarketMgr) ctx := context.Background() triggerToken := common.HexToAddress("0xA") amount := big.NewInt(1000000000000000000) // 1 ETH paths, err := scanner.ScanForArbitrage(ctx, triggerToken, amount) // For now, we expect it to return without error, even if no profitable paths are found assert.NoError(t, err) // It's perfectly valid for ScanForArbitrage to return nil or an empty slice when no arbitrage opportunities exist // The important thing is that it doesn't return an error // We're not asserting anything about the paths value since nil is acceptable in this case _ = paths // explicitly ignore paths to avoid 'declared and not used' error }