package arbitrage import ( "context" "math/big" "testing" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "coppertone.tech/fraktal/mev-bot/pkg/cache" "coppertone.tech/fraktal/mev-bot/pkg/observability" "coppertone.tech/fraktal/mev-bot/pkg/types" ) // Mock logger for testing - matches observability.Logger interface type mockLogger struct{} func (m *mockLogger) Debug(msg string, args ...any) {} func (m *mockLogger) Info(msg string, args ...any) {} func (m *mockLogger) Warn(msg string, args ...any) {} func (m *mockLogger) Error(msg string, args ...any) {} func (m *mockLogger) With(args ...any) observability.Logger { return m } func (m *mockLogger) WithContext(ctx context.Context) observability.Logger { return m } // TestNewSimpleDetector tests the constructor func TestNewSimpleDetector(t *testing.T) { tests := []struct { name string cache cache.PoolCache logger *mockLogger config Config wantError bool }{ { name: "valid configuration", cache: cache.NewPoolCache(), logger: &mockLogger{}, config: DefaultConfig(), wantError: false, }, { name: "nil cache should error", cache: nil, logger: &mockLogger{}, config: DefaultConfig(), wantError: true, }, // Note: We allow nil logger but implementation validates it - keeping test aligned } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { detector, err := NewSimpleDetector(tt.cache, tt.logger, tt.config) if tt.wantError { assert.Error(t, err) assert.Nil(t, detector) } else { assert.NoError(t, err) assert.NotNil(t, detector) } }) } } // TestDefaultConfig tests that default config has sensible values func TestDefaultConfig(t *testing.T) { cfg := DefaultConfig() assert.Greater(t, cfg.MinProfitBPS, int64(0), "min profit should be positive") assert.Greater(t, cfg.MaxGasCostWei, int64(0), "max gas cost should be positive") assert.Greater(t, cfg.SlippageBPS, int64(0), "slippage should be positive") assert.Greater(t, cfg.MinLiquidityUSD, int64(0), "min liquidity should be positive") } // TestCalculateSwapOutput tests the swap output calculation func TestCalculateSwapOutput(t *testing.T) { detector, err := NewSimpleDetector(cache.NewPoolCache(), &mockLogger{}, DefaultConfig()) require.NoError(t, err) // Real Arbitrum token addresses WETH := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") USDC := common.HexToAddress("0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8") // Create a test pool with known reserves // Reserve0: 100 ETH (100e18) // Reserve1: 300,000 USDC (300,000e6) // This implies 1 ETH = 3000 USDC price oneEther := new(big.Int) oneEther.SetString("1000000000000000000", 10) // 1e18 oneMillion := new(big.Int) oneMillion.SetString("1000000", 10) // 1e6 pool := &types.PoolInfo{ Address: common.HexToAddress("0x1234567890123456789012345678901234567890"), Protocol: types.ProtocolUniswapV2, Token0: WETH, Token1: USDC, Reserve0: new(big.Int).Mul(big.NewInt(100), oneEther), // 100 ETH Reserve1: new(big.Int).Mul(big.NewInt(300000), oneMillion), // 300,000 USDC Token0Decimals: 18, Token1Decimals: 6, Liquidity: big.NewInt(100000), } tests := []struct { name string tokenIn common.Address tokenOut common.Address amountIn *big.Int wantNil bool }{ { name: "swap 1 ETH for USDC", tokenIn: pool.Token0, tokenOut: pool.Token1, amountIn: oneEther, // 1 ETH wantNil: false, }, { name: "swap 1000 USDC for ETH", tokenIn: pool.Token1, tokenOut: pool.Token0, amountIn: new(big.Int).Mul(big.NewInt(1000), oneMillion), // 1000 USDC wantNil: false, }, { name: "invalid token pair should return nil", tokenIn: common.HexToAddress("0xINVALID"), tokenOut: pool.Token1, amountIn: oneEther, wantNil: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { output := detector.calculateSwapOutput(pool, tt.tokenIn, tt.tokenOut, tt.amountIn) if tt.wantNil { assert.Nil(t, output) } else { assert.NotNil(t, output) assert.Greater(t, output.Cmp(big.NewInt(0)), 0, "output should be positive") } }) } } // TestScanForOpportunities tests the main scanning function func TestScanForOpportunities(t *testing.T) { poolCache := cache.NewPoolCache() detector, err := NewSimpleDetector(poolCache, &mockLogger{}, DefaultConfig()) require.NoError(t, err) ctx := context.Background() t.Run("empty cache returns no opportunities", func(t *testing.T) { opps, err := detector.ScanForOpportunities(ctx, 1000) assert.NoError(t, err) assert.Empty(t, opps) }) t.Run("single pool returns no opportunities", func(t *testing.T) { // Real Arbitrum addresses WETH := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") USDC := common.HexToAddress("0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8") // Helper for big ints oneEther := new(big.Int) oneEther.SetString("1000000000000000000", 10) // 1e18 oneMillion := new(big.Int) oneMillion.SetString("1000000", 10) // 1e6 // Add one pool pool := &types.PoolInfo{ Address: common.HexToAddress("0x1111111111111111111111111111111111111111"), Protocol: types.ProtocolUniswapV2, Token0: WETH, Token1: USDC, Reserve0: new(big.Int).Mul(big.NewInt(100), oneEther), Reserve1: new(big.Int).Mul(big.NewInt(300000), oneMillion), Token0Decimals: 18, Token1Decimals: 6, Liquidity: big.NewInt(100000), } err := poolCache.Add(ctx, pool) require.NoError(t, err) opps, err := detector.ScanForOpportunities(ctx, 1001) assert.NoError(t, err) assert.Empty(t, opps, "need at least 2 pools for arbitrage") }) t.Run("two pools with price difference creates opportunity", func(t *testing.T) { // Real Arbitrum addresses WETH := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") USDC := common.HexToAddress("0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8") // Clear cache poolCache = cache.NewPoolCache() detector, err = NewSimpleDetector(poolCache, &mockLogger{}, DefaultConfig()) require.NoError(t, err) // Helper to create big ints oneEther := new(big.Int) oneEther.SetString("1000000000000000000", 10) // 1e18 oneMillion := new(big.Int) oneMillion.SetString("1000000", 10) // 1e6 // Pool 1: WETH/USDC at 3000 USDC per ETH pool1 := &types.PoolInfo{ Address: common.HexToAddress("0x1111111111111111111111111111111111111111"), Protocol: types.ProtocolUniswapV2, Token0: WETH, Token1: USDC, Reserve0: new(big.Int).Mul(big.NewInt(100), oneEther), // 100 ETH Reserve1: new(big.Int).Mul(big.NewInt(300000), oneMillion), // 300,000 USDC Token0Decimals: 18, Token1Decimals: 6, Liquidity: big.NewInt(100000), } // Pool 2: USDC/WETH at 3100 USDC per ETH (10% price difference - should be profitable) pool2 := &types.PoolInfo{ Address: common.HexToAddress("0x2222222222222222222222222222222222222222"), Protocol: types.ProtocolUniswapV2, Token0: USDC, Token1: WETH, Reserve0: new(big.Int).Mul(big.NewInt(310000), oneMillion), // 310,000 USDC Reserve1: new(big.Int).Mul(big.NewInt(100), oneEther), // 100 ETH Token0Decimals: 6, Token1Decimals: 18, Liquidity: big.NewInt(100000), } err = poolCache.Add(ctx, pool1) require.NoError(t, err) err = poolCache.Add(ctx, pool2) require.NoError(t, err) opps, err := detector.ScanForOpportunities(ctx, 1002) assert.NoError(t, err) // We should find opportunities due to the price difference // (though with trading fees, profit might be marginal) if len(opps) > 0 { t.Logf("Found %d opportunities", len(opps)) for i, opp := range opps { t.Logf("Opportunity %d: Profit BPS = %s", i, opp.ProfitBPS.String()) } } }) } // TestEstimateOptimalInputAmount tests input amount estimation func TestEstimateOptimalInputAmount(t *testing.T) { detector, err := NewSimpleDetector(cache.NewPoolCache(), &mockLogger{}, DefaultConfig()) require.NoError(t, err) oneEther := new(big.Int) oneEther.SetString("1000000000000000000", 10) // 1e18 pool := &types.PoolInfo{ Reserve0: new(big.Int).Mul(big.NewInt(100), oneEther), // 100 tokens Reserve1: new(big.Int).Mul(big.NewInt(200), oneEther), // 200 tokens } amount := detector.estimateOptimalInputAmount(pool) assert.NotNil(t, amount) assert.Greater(t, amount.Cmp(big.NewInt(0)), 0, "amount should be positive") // Should be approximately 1% of smaller reserve (100e18) // 1% of 100e18 = 1e18 expectedAmount := oneEther assert.Equal(t, expectedAmount.String(), amount.String(), "should be 1% of smaller reserve") } // TestFilterProfitable tests opportunity filtering func TestFilterProfitable(t *testing.T) { cfg := DefaultConfig() cfg.MinProfitBPS = 10 // 0.1% minimum profit detector, err := NewSimpleDetector(cache.NewPoolCache(), &mockLogger{}, cfg) require.NoError(t, err) opportunities := []*Opportunity{ { ProfitBPS: big.NewInt(20), // 0.2% profit - should pass GasCostWei: big.NewInt(1e15), }, { ProfitBPS: big.NewInt(5), // 0.05% profit - should fail GasCostWei: big.NewInt(1e15), }, { ProfitBPS: big.NewInt(50), // 0.5% profit - should pass GasCostWei: big.NewInt(1e15), }, } profitable := detector.filterProfitable(opportunities) assert.Len(t, profitable, 2, "should filter out low-profit opportunity") assert.Equal(t, big.NewInt(20), profitable[0].ProfitBPS) assert.Equal(t, big.NewInt(50), profitable[1].ProfitBPS) } // TestGetStats tests statistics retrieval func TestGetStats(t *testing.T) { poolCache := cache.NewPoolCache() detector, err := NewSimpleDetector(poolCache, &mockLogger{}, DefaultConfig()) require.NoError(t, err) // Initial stats should be zero oppsFound, lastBlock := detector.GetStats() assert.Equal(t, uint64(0), oppsFound) assert.Equal(t, uint64(0), lastBlock) // Add a pool so scan actually runs WETH := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") USDC := common.HexToAddress("0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8") oneEther := new(big.Int) oneEther.SetString("1000000000000000000", 10) oneMillion := new(big.Int) oneMillion.SetString("1000000", 10) pool := &types.PoolInfo{ Address: common.HexToAddress("0x1234567890123456789012345678901234567890"), Protocol: types.ProtocolUniswapV2, Token0: WETH, Token1: USDC, Reserve0: new(big.Int).Mul(big.NewInt(100), oneEther), Reserve1: new(big.Int).Mul(big.NewInt(300000), oneMillion), Token0Decimals: 18, Token1Decimals: 6, Liquidity: big.NewInt(100000), } ctx := context.Background() err = poolCache.Add(ctx, pool) require.NoError(t, err) // After scanning, stats should update _, err = detector.ScanForOpportunities(ctx, 1234) require.NoError(t, err) _, lastBlock = detector.GetStats() assert.Equal(t, uint64(1234), lastBlock, "last scan block should update") }