package arbitrage import ( "context" "fmt" "math/big" "sync" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "coppertone.tech/fraktal/mev-bot/pkg/cache" "coppertone.tech/fraktal/mev-bot/pkg/observability" "coppertone.tech/fraktal/mev-bot/pkg/types" ) // SimpleDetector implements basic 2-hop arbitrage detection for MVP // It focuses on finding simple circular arbitrage opportunities: // Token A -> Token B -> Token A across two different pools type SimpleDetector struct { poolCache cache.PoolCache logger observability.Logger ethRPC *ethclient.Client // Configuration minProfitBPS *big.Int // Minimum profit in basis points (1 BPS = 0.01%) maxGasCostWei *big.Int // Maximum acceptable gas cost in wei slippageBPS *big.Int // Slippage tolerance in basis points minLiquidityUSD *big.Int // Minimum pool liquidity in USD // State mu sync.RWMutex opportunitiesFound uint64 lastScanBlock uint64 } // Opportunity represents a 2-hop arbitrage opportunity type Opportunity struct { // Path information InputToken common.Address BridgeToken common.Address OutputToken common.Address // Pool information FirstPool *types.PoolInfo SecondPool *types.PoolInfo // Trade parameters InputAmount *big.Int BridgeAmount *big.Int OutputAmount *big.Int ProfitAmount *big.Int // Profitability metrics ProfitBPS *big.Int // Profit in basis points GasCostWei *big.Int // Estimated gas cost // Metadata BlockNumber uint64 Timestamp int64 } // Config holds configuration for the simple detector type Config struct { MinProfitBPS int64 // Minimum profit in basis points (e.g., 10 = 0.1%) MaxGasCostWei int64 // Maximum acceptable gas cost in wei SlippageBPS int64 // Slippage tolerance in basis points (e.g., 50 = 0.5%) MinLiquidityUSD int64 // Minimum pool liquidity in USD } // DefaultConfig returns sensible defaults for Fast MVP func DefaultConfig() Config { return Config{ MinProfitBPS: 10, // 0.1% minimum profit MaxGasCostWei: 1e16, // 0.01 ETH max gas cost SlippageBPS: 50, // 0.5% slippage tolerance MinLiquidityUSD: 10000, // $10k minimum liquidity } } // NewSimpleDetector creates a new simple arbitrage detector func NewSimpleDetector(poolCache cache.PoolCache, logger observability.Logger, cfg Config) (*SimpleDetector, error) { if poolCache == nil { return nil, fmt.Errorf("pool cache cannot be nil") } if logger == nil { return nil, fmt.Errorf("logger cannot be nil") } return &SimpleDetector{ poolCache: poolCache, logger: logger, ethRPC: nil, minProfitBPS: big.NewInt(cfg.MinProfitBPS), maxGasCostWei: big.NewInt(cfg.MaxGasCostWei), slippageBPS: big.NewInt(cfg.SlippageBPS), minLiquidityUSD: big.NewInt(cfg.MinLiquidityUSD), opportunitiesFound: 0, lastScanBlock: 0, }, nil } // WithRPC attaches an ethclient for live gas price. func (d *SimpleDetector) WithRPC(client *ethclient.Client) *SimpleDetector { d.ethRPC = client return d } // ScanForOpportunities scans for arbitrage opportunities across all cached pools // This is the main entry point for the detection engine func (d *SimpleDetector) ScanForOpportunities(ctx context.Context, blockNumber uint64) ([]*Opportunity, error) { d.logger.Info("scanning for arbitrage opportunities", "block", blockNumber) // Get all pools from cache (use GetByLiquidity with minLiquidity=0 and high limit) pools, err := d.poolCache.GetByLiquidity(ctx, big.NewInt(0), 10000) if err != nil { return nil, fmt.Errorf("failed to get pools from cache: %w", err) } if len(pools) == 0 { d.logger.Warn("no pools in cache, skipping scan") return nil, nil } d.logger.Debug("scanning pools", "count", len(pools)) // For MVP, we'll focus on simple 2-hop cycles: // Find pairs of pools that share a common token (bridge token) // Then check if we can profit by trading through both pools var opportunities []*Opportunity var mu sync.Mutex var wg sync.WaitGroup // Use a simple concurrent scan approach // For each pool, check if it can form a 2-hop cycle with any other pool for i := 0; i < len(pools); i++ { wg.Add(1) go func(pool1Index int) { defer wg.Done() pool1 := pools[pool1Index] localOpps := d.findTwoHopCycles(ctx, pool1, pools) if len(localOpps) > 0 { mu.Lock() opportunities = append(opportunities, localOpps...) mu.Unlock() } }(i) } wg.Wait() // Filter opportunities by profitability profitableOpps := d.filterProfitable(opportunities) d.mu.Lock() d.opportunitiesFound += uint64(len(profitableOpps)) d.lastScanBlock = blockNumber d.mu.Unlock() d.logger.Info("scan complete", "totalPools", len(pools), "opportunities", len(profitableOpps), "block", blockNumber, ) return profitableOpps, nil } // findTwoHopCycles finds 2-hop arbitrage cycles starting from a given pool // A 2-hop cycle is: TokenA -> TokenB (via pool1) -> TokenA (via pool2) func (d *SimpleDetector) findTwoHopCycles(ctx context.Context, pool1 *types.PoolInfo, allPools []*types.PoolInfo) []*Opportunity { var opportunities []*Opportunity // Check both directions for pool1 // Direction 1: Token0 -> Token1 -> Token0 // Direction 2: Token1 -> Token0 -> Token1 // Direction 1: Swap Token0 for Token1 in pool1 bridgeToken := pool1.Token1 startToken := pool1.Token0 // Find pools that can swap bridgeToken back to startToken for _, pool2 := range allPools { if pool2.Address == pool1.Address { continue // Skip same pool } // Check if pool2 can convert bridgeToken -> startToken if (pool2.Token0 == bridgeToken && pool2.Token1 == startToken) || (pool2.Token1 == bridgeToken && pool2.Token0 == startToken) { // Found a potential cycle! // Now calculate if it's profitable opp := d.calculateOpportunity(ctx, pool1, pool2, startToken, bridgeToken) if opp != nil { opportunities = append(opportunities, opp) } } } // Direction 2: Swap Token1 for Token0 in pool1 bridgeToken = pool1.Token0 startToken = pool1.Token1 // Find pools that can swap bridgeToken back to startToken for _, pool2 := range allPools { if pool2.Address == pool1.Address { continue // Skip same pool } // Check if pool2 can convert bridgeToken -> startToken if (pool2.Token0 == bridgeToken && pool2.Token1 == startToken) || (pool2.Token1 == bridgeToken && pool2.Token0 == startToken) { // Found a potential cycle! opp := d.calculateOpportunity(ctx, pool1, pool2, startToken, bridgeToken) if opp != nil { opportunities = append(opportunities, opp) } } } return opportunities } // calculateOpportunity calculates the profitability of a 2-hop arbitrage // For MVP, we use a simple constant product formula (UniswapV2 style) func (d *SimpleDetector) calculateOpportunity( ctx context.Context, pool1, pool2 *types.PoolInfo, inputToken, bridgeToken common.Address, ) *Opportunity { // Refresh gas estimate per-path gasEstimate := d.estimateGasCost(ctx, pool1, pool2) // For MVP, use a fixed input amount based on pool liquidity // In production, we'd optimize the input amount for maximum profit inputAmount := d.estimateOptimalInputAmount(pool1) // Step 1: Calculate output from first swap (inputToken -> bridgeToken via pool1) bridgeAmount := d.calculateSwapOutput(pool1, inputToken, bridgeToken, inputAmount) if bridgeAmount == nil || bridgeAmount.Cmp(big.NewInt(0)) <= 0 { return nil } // Step 2: Calculate output from second swap (bridgeToken -> inputToken via pool2) outputAmount := d.calculateSwapOutput(pool2, bridgeToken, inputToken, bridgeAmount) if outputAmount == nil || outputAmount.Cmp(big.NewInt(0)) <= 0 { return nil } // Calculate profit (outputAmount - inputAmount) profitAmount := new(big.Int).Sub(outputAmount, inputAmount) if profitAmount.Cmp(big.NewInt(0)) <= 0 { return nil // No profit } // Calculate profit in basis points: (profit / input) * 10000 profitBPS := new(big.Int).Mul(profitAmount, big.NewInt(10000)) profitBPS.Div(profitBPS, inputAmount) return &Opportunity{ InputToken: inputToken, BridgeToken: bridgeToken, OutputToken: inputToken, // Circle back to input token FirstPool: pool1, SecondPool: pool2, InputAmount: inputAmount, BridgeAmount: bridgeAmount, OutputAmount: outputAmount, ProfitAmount: profitAmount, ProfitBPS: profitBPS, GasCostWei: gasEstimate, } } // estimateGasCost returns a gas estimate in wei. // Strategy: per-hop gas based on protocol + flashloan overhead. // Gas price from EIP-1559 tip + base if available, else SuggestGasPrice, else 5 gwei fallback. func (d *SimpleDetector) estimateGasCost(ctx context.Context, pools ...*types.PoolInfo) *big.Int { // If no RPC, fall back to heuristic gas if d.ethRPC == nil { return d.heuristicGasCost(ctx, pools...) } gasPrice := d.gasPrice(ctx) // We lack contract calldata; use EstimateGas on empty call is useless. // Therefore, keep heuristic but scaled by live gas price. // TODO: replace with real path-specific calldata once executor is wired. return new(big.Int).Mul(new(big.Int).SetInt64(d.heuristicGasUnits(pools...)), gasPrice) } // heuristicGasCost returns heuristic gas * live/fallback price. func (d *SimpleDetector) heuristicGasCost(ctx context.Context, pools ...*types.PoolInfo) *big.Int { gasPrice := d.gasPrice(ctx) return new(big.Int).Mul(new(big.Int).SetInt64(d.heuristicGasUnits(pools...)), gasPrice) } func (d *SimpleDetector) heuristicGasUnits(pools ...*types.PoolInfo) int64 { var totalGas int64 = 120000 // base flashloan + execution overhead for _, p := range pools { if p == nil { continue } switch p.Protocol { case types.ProtocolUniswapV2: totalGas += 110000 case types.ProtocolUniswapV3: totalGas += 150000 default: totalGas += 130000 // unknown AMM heuristic } } return totalGas } func (d *SimpleDetector) gasPrice(ctx context.Context) *big.Int { gasPrice := big.NewInt(5e9) // 5 gwei fallback if d.ethRPC != nil { if header, err := d.ethRPC.HeaderByNumber(ctx, nil); err == nil && header != nil && header.BaseFee != nil { if tip, err := d.ethRPC.SuggestGasTipCap(ctx); err == nil && tip != nil { gasPrice = new(big.Int).Add(header.BaseFee, tip) } } else if gp, err := d.ethRPC.SuggestGasPrice(ctx); err == nil && gp != nil { gasPrice = gp } } return gasPrice } // calculateSwapOutput calculates the output amount for a swap // Supports both V2 (constant product) and V3 (sqrtPriceX96) pools func (d *SimpleDetector) calculateSwapOutput( pool *types.PoolInfo, tokenIn, tokenOut common.Address, amountIn *big.Int, ) *big.Int { // Route to protocol-specific calculation switch pool.Protocol { case types.ProtocolUniswapV3: return d.calculateV3SwapOutput(pool, tokenIn, tokenOut, amountIn) default: return d.calculateV2SwapOutput(pool, tokenIn, tokenOut, amountIn) } } // calculateV2SwapOutput uses constant product formula for UniswapV2-style pools func (d *SimpleDetector) calculateV2SwapOutput( pool *types.PoolInfo, tokenIn, tokenOut common.Address, amountIn *big.Int, ) *big.Int { // Determine reserves based on token direction var reserveIn, reserveOut *big.Int if pool.Token0 == tokenIn && pool.Token1 == tokenOut { reserveIn = pool.Reserve0 reserveOut = pool.Reserve1 } else if pool.Token1 == tokenIn && pool.Token0 == tokenOut { reserveIn = pool.Reserve1 reserveOut = pool.Reserve0 } else { d.logger.Warn("token mismatch in pool", "pool", pool.Address.Hex()) return nil } // Check reserves are valid if reserveIn == nil || reserveOut == nil || reserveIn.Cmp(big.NewInt(0)) <= 0 || reserveOut.Cmp(big.NewInt(0)) <= 0 { d.logger.Warn("invalid reserves", "pool", pool.Address.Hex()) return nil } // Constant product formula: (amountIn * 997 * reserveOut) / (reserveIn * 1000 + amountIn * 997) // The 997/1000 factor accounts for the 0.3% UniswapV2 fee amountInWithFee := new(big.Int).Mul(amountIn, big.NewInt(997)) numerator := new(big.Int).Mul(amountInWithFee, reserveOut) denominator := new(big.Int).Mul(reserveIn, big.NewInt(1000)) denominator.Add(denominator, amountInWithFee) amountOut := new(big.Int).Div(numerator, denominator) return amountOut } // calculateV3SwapOutput calculates output using sqrtPriceX96 for UniswapV3 pools // Uses simplified spot price calculation (ignores tick crossing for MVP) func (d *SimpleDetector) calculateV3SwapOutput( pool *types.PoolInfo, tokenIn, tokenOut common.Address, amountIn *big.Int, ) *big.Int { if pool.SqrtPriceX96 == nil || pool.SqrtPriceX96.Cmp(big.NewInt(0)) == 0 { d.logger.Warn("invalid sqrtPriceX96", "pool", pool.Address.Hex()) return nil } if pool.Liquidity == nil || pool.Liquidity.Cmp(big.NewInt(0)) == 0 { d.logger.Warn("invalid liquidity", "pool", pool.Address.Hex()) return nil } // Determine swap direction zeroForOne := pool.Token0 == tokenIn && pool.Token1 == tokenOut oneForZero := pool.Token1 == tokenIn && pool.Token0 == tokenOut if !zeroForOne && !oneForZero { d.logger.Warn("token mismatch in V3 pool", "pool", pool.Address.Hex()) return nil } // Calculate fee multiplier (fee is in hundredths of a bip, e.g., 3000 = 0.3%) // feePct = fee / 1000000, so feeMultiplier = (1000000 - fee) / 1000000 fee := int64(pool.Fee) if fee == 0 { fee = 3000 // Default 0.3% } // Simplified V3 price calculation using sqrtPriceX96 // price = (sqrtPriceX96 / 2^96)^2 = sqrtPriceX96^2 / 2^192 // For token0 -> token1: amountOut = amountIn * price // For token1 -> token0: amountOut = amountIn / price sqrtPrice := pool.SqrtPriceX96 // Calculate price ratio: sqrtPrice^2 / 2^192 // To avoid overflow, we scale carefully // price = sqrtPrice * sqrtPrice / (2^96 * 2^96) q96 := new(big.Int).Lsh(big.NewInt(1), 96) // 2^96 if zeroForOne { // token0 -> token1: amountOut = amountIn * sqrtPrice^2 / 2^192 // Rearrange: amountOut = amountIn * sqrtPrice / 2^96 * sqrtPrice / 2^96 temp := new(big.Int).Mul(amountIn, sqrtPrice) temp.Div(temp, q96) temp.Mul(temp, sqrtPrice) temp.Div(temp, q96) // Apply fee temp.Mul(temp, big.NewInt(1000000-fee)) temp.Div(temp, big.NewInt(1000000)) return temp } else { // token1 -> token0: amountOut = amountIn * 2^192 / sqrtPrice^2 // Rearrange: amountOut = amountIn * 2^96 / sqrtPrice * 2^96 / sqrtPrice temp := new(big.Int).Mul(amountIn, q96) temp.Div(temp, sqrtPrice) temp.Mul(temp, q96) temp.Div(temp, sqrtPrice) // Apply fee temp.Mul(temp, big.NewInt(1000000-fee)) temp.Div(temp, big.NewInt(1000000)) return temp } } // estimateOptimalInputAmount estimates a reasonable input amount for testing // For V2: uses 1% of pool reserves // For V3: uses fixed amount based on liquidity func (d *SimpleDetector) estimateOptimalInputAmount(pool *types.PoolInfo) *big.Int { // For V3 pools, use a fixed reasonable amount since no reserves if pool.Protocol == types.ProtocolUniswapV3 { // Use 0.1 ETH equivalent as test amount for V3 return big.NewInt(1e17) // 0.1 tokens (18 decimals) } // For V2: Use 1% of the smaller reserve as input amount reserve0 := pool.Reserve0 reserve1 := pool.Reserve1 if reserve0 == nil || reserve1 == nil { return big.NewInt(1e17) // Default to 0.1 token (18 decimals) } smallerReserve := reserve0 if reserve1.Cmp(reserve0) < 0 { smallerReserve = reserve1 } // 1% of smaller reserve inputAmount := new(big.Int).Div(smallerReserve, big.NewInt(100)) // Ensure minimum of 0.01 tokens (for 18 decimal tokens) minAmount := big.NewInt(1e16) if inputAmount.Cmp(minAmount) < 0 { inputAmount = minAmount } // Cap at 1 ETH equivalent for safety maxAmount := big.NewInt(1e18) if inputAmount.Cmp(maxAmount) > 0 { inputAmount = maxAmount } return inputAmount } // filterProfitable filters opportunities to only include those meeting profitability criteria func (d *SimpleDetector) filterProfitable(opportunities []*Opportunity) []*Opportunity { var profitable []*Opportunity for _, opp := range opportunities { if opp.ProfitAmount == nil || opp.ProfitAmount.Sign() <= 0 { continue } // Check if profit meets minimum threshold (percentage) if opp.ProfitBPS.Cmp(d.minProfitBPS) < 0 { continue } // Convert profit to wei for gas comparison profitWei := d.profitToWei(opp) if profitWei == nil { continue } // Apply slippage haircut slippageLoss := new(big.Int).Mul(profitWei, d.slippageBPS) slippageLoss.Div(slippageLoss, big.NewInt(10000)) netProfit := new(big.Int).Sub(profitWei, slippageLoss) // Subtract estimated gas if opp.GasCostWei == nil { opp.GasCostWei = big.NewInt(0) } netProfit.Sub(netProfit, opp.GasCostWei) // Require net profit to exceed zero and gas allowance if netProfit.Cmp(big.NewInt(0)) <= 0 { continue } if netProfit.Cmp(d.maxGasCostWei) <= 0 { continue } // Cap gas cost if opp.GasCostWei.Cmp(d.maxGasCostWei) > 0 { continue } profitable = append(profitable, opp) } return profitable } // profitToWei attempts to express ProfitAmount in wei using pool pricing. // Strategy: if InputToken is WETH, return ProfitAmount. // Otherwise, if FirstPool involves WETH, derive price and convert. // Returns nil when price cannot be determined. func (d *SimpleDetector) profitToWei(opp *Opportunity) *big.Int { if opp == nil || opp.FirstPool == nil { return nil } weth := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") // Arbitrum WETH // If profit token is WETH already if opp.InputToken == weth { return new(big.Int).Set(opp.ProfitAmount) } // Try direct WETH pair on either pool if price := priceViaWETH(opp.InputToken, opp.FirstPool, opp.SecondPool, opp.ProfitAmount); price != nil { return price } // Fallback: derive token→WETH price via most liquid WETH pair in cache return d.priceFromCacheToWETH(opp.InputToken, opp.ProfitAmount, weth) } // priceViaWETH tries to convert amount using WETH legs present in the two pools. func priceViaWETH(token common.Address, p1, p2 *types.PoolInfo, amount *big.Int) *big.Int { pools := []*types.PoolInfo{p1, p2} for _, p := range pools { if p == nil { continue } weth := common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1") if (p.Token0 == token && p.Token1 == weth) || (p.Token1 == token && p.Token0 == weth) { r0 := types.ScaleToDecimals(p.Reserve0, p.Token0Decimals, 18) r1 := types.ScaleToDecimals(p.Reserve1, p.Token1Decimals, 18) if r0.Sign() == 0 || r1.Sign() == 0 { continue } var price *big.Int if p.Token0 == token { price = new(big.Int).Div(r1, r0) } else { price = new(big.Int).Div(r0, r1) } if price.Sign() == 0 { continue } return new(big.Int).Mul(amount, price) } } return nil } // priceFromCacheToWETH finds the most liquid WETH pair in cache for the token and prices amount to wei. func (d *SimpleDetector) priceFromCacheToWETH(token common.Address, amount *big.Int, weth common.Address) *big.Int { ctx := context.Background() // Fetch up to 100 pools ordered by liquidity pools, err := d.poolCache.GetByLiquidity(ctx, big.NewInt(0), 200) if err != nil || len(pools) == 0 { return nil } var best *types.PoolInfo for _, p := range pools { if (p.Token0 == token && p.Token1 == weth) || (p.Token1 == token && p.Token0 == weth) { best = p break // pools are liquidity-sorted, first match is most liquid } } if best == nil { return nil } r0 := types.ScaleToDecimals(best.Reserve0, best.Token0Decimals, 18) r1 := types.ScaleToDecimals(best.Reserve1, best.Token1Decimals, 18) if r0.Sign() == 0 || r1.Sign() == 0 { return nil } var price *big.Int if best.Token0 == token { price = new(big.Int).Div(r1, r0) } else { price = new(big.Int).Div(r0, r1) } if price.Sign() == 0 { return nil } return new(big.Int).Mul(amount, price) } // GetStats returns statistics about the detector's operation func (d *SimpleDetector) GetStats() (opportunitiesFound uint64, lastScanBlock uint64) { d.mu.RLock() defer d.mu.RUnlock() return d.opportunitiesFound, d.lastScanBlock }