package arbitrage import ( "context" "fmt" "math/big" "sort" "sync" "time" "github.com/ethereum/go-ethereum/common" "github.com/fraktal/mev-beta/internal/logger" "github.com/fraktal/mev-beta/pkg/uniswap" "github.com/holiman/uint256" ) // MultiHopScanner implements advanced multi-hop arbitrage detection type MultiHopScanner struct { logger *logger.Logger // Configuration maxHops int // Maximum number of hops in arbitrage path minProfitWei *big.Int // Minimum profit threshold in wei maxSlippage float64 // Maximum acceptable slippage maxPaths int // Maximum paths to evaluate per opportunity pathTimeout time.Duration // Timeout for path calculation // Caching pathCache map[string][]*ArbitragePath cacheMutex sync.RWMutex cacheExpiry time.Duration // Token graph for path finding tokenGraph *TokenGraph // Pool registry pools map[common.Address]*PoolInfo poolMutex sync.RWMutex } // ArbitragePath represents a complete arbitrage path type ArbitragePath struct { Tokens []common.Address // Token path (A -> B -> C -> A) Pools []*PoolInfo // Pools used in each hop Protocols []string // Protocol for each hop Fees []int64 // Fee for each hop EstimatedGas *big.Int // Estimated gas cost NetProfit *big.Int // Net profit after gas ROI float64 // Return on investment percentage LastUpdated time.Time // When this path was calculated } // PoolInfo contains information about a trading pool type PoolInfo struct { Address common.Address Token0 common.Address Token1 common.Address Protocol string Fee int64 Liquidity *uint256.Int SqrtPriceX96 *uint256.Int LastUpdated time.Time } // TokenGraph represents a graph of tokens connected by pools type TokenGraph struct { adjacencyList map[common.Address]map[common.Address][]*PoolInfo mutex sync.RWMutex } // NewMultiHopScanner creates a new multi-hop arbitrage scanner func NewMultiHopScanner(logger *logger.Logger, marketMgr interface{}) *MultiHopScanner { return &MultiHopScanner{ logger: logger, maxHops: 4, // Max 4 hops (A->B->C->D->A) minProfitWei: big.NewInt(1000000000000000), // 0.001 ETH minimum profit maxSlippage: 0.03, // 3% max slippage maxPaths: 100, // Evaluate top 100 paths pathTimeout: time.Millisecond * 500, // 500ms timeout pathCache: make(map[string][]*ArbitragePath), cacheExpiry: time.Minute * 2, // Cache for 2 minutes tokenGraph: NewTokenGraph(), pools: make(map[common.Address]*PoolInfo), } } // NewTokenGraph creates a new token graph func NewTokenGraph() *TokenGraph { return &TokenGraph{ adjacencyList: make(map[common.Address]map[common.Address][]*PoolInfo), } } // ScanForArbitrage scans for multi-hop arbitrage opportunities func (mhs *MultiHopScanner) ScanForArbitrage(ctx context.Context, triggerToken common.Address, amount *big.Int) ([]*ArbitragePath, error) { start := time.Now() mhs.logger.Debug(fmt.Sprintf("Starting multi-hop arbitrage scan for token %s with amount %s", triggerToken.Hex(), amount.String())) // Update token graph with latest pool data if err := mhs.updateTokenGraph(ctx); err != nil { return nil, fmt.Errorf("failed to update token graph: %w", err) } // Check cache first cacheKey := fmt.Sprintf("%s_%s", triggerToken.Hex(), amount.String()) if paths := mhs.getCachedPaths(cacheKey); paths != nil { mhs.logger.Debug(fmt.Sprintf("Found %d cached arbitrage paths", len(paths))) return paths, nil } // Find all possible arbitrage paths starting with triggerToken allPaths := mhs.findArbitragePaths(ctx, triggerToken, amount) // Filter and rank paths by profitability profitablePaths := mhs.filterProfitablePaths(allPaths) // Sort by net profit descending sort.Slice(profitablePaths, func(i, j int) bool { return profitablePaths[i].NetProfit.Cmp(profitablePaths[j].NetProfit) > 0 }) // Limit to top paths if len(profitablePaths) > mhs.maxPaths { profitablePaths = profitablePaths[:mhs.maxPaths] } // Cache results mhs.setCachedPaths(cacheKey, profitablePaths) elapsed := time.Since(start) mhs.logger.Info(fmt.Sprintf("Multi-hop arbitrage scan completed in %v: found %d profitable paths out of %d total paths", elapsed, len(profitablePaths), len(allPaths))) return profitablePaths, nil } // findArbitragePaths finds all possible arbitrage paths func (mhs *MultiHopScanner) findArbitragePaths(ctx context.Context, startToken common.Address, amount *big.Int) []*ArbitragePath { var allPaths []*ArbitragePath // Use DFS to find paths that return to the start token visited := make(map[common.Address]bool) currentPath := []*PoolInfo{} currentTokens := []common.Address{startToken} mhs.dfsArbitragePaths(ctx, startToken, startToken, amount, visited, currentPath, currentTokens, &allPaths, 0) return allPaths } // dfsArbitragePaths uses depth-first search to find arbitrage paths func (mhs *MultiHopScanner) dfsArbitragePaths( ctx context.Context, currentToken, targetToken common.Address, amount *big.Int, visited map[common.Address]bool, currentPath []*PoolInfo, currentTokens []common.Address, allPaths *[]*ArbitragePath, depth int, ) { // Check context for cancellation select { case <-ctx.Done(): return default: } // Prevent infinite recursion if depth >= mhs.maxHops { return } // If we're back at the start token and have made at least 2 hops, we found a cycle if depth > 1 && currentToken == targetToken { path := mhs.createArbitragePath(currentTokens, currentPath, amount) if path != nil { *allPaths = append(*allPaths, path) } return } // Get adjacent tokens (tokens we can trade to from current token) adjacent := mhs.tokenGraph.GetAdjacentTokens(currentToken) for nextToken, pools := range adjacent { // Skip if already visited (prevent cycles except back to start) if visited[nextToken] && nextToken != targetToken { continue } // Try each pool that connects currentToken to nextToken for _, pool := range pools { if !mhs.isPoolUsable(pool) { continue } // Mark as visited visited[nextToken] = true // Add to current path newPath := append(currentPath, pool) newTokens := append(currentTokens, nextToken) // Recursive search mhs.dfsArbitragePaths(ctx, nextToken, targetToken, amount, visited, newPath, newTokens, allPaths, depth+1) // Backtrack delete(visited, nextToken) } } } // createArbitragePath creates an ArbitragePath from the given route func (mhs *MultiHopScanner) createArbitragePath(tokens []common.Address, pools []*PoolInfo, initialAmount *big.Int) *ArbitragePath { if len(tokens) < 3 || len(pools) != len(tokens)-1 { return nil } // Calculate the output amount through the path currentAmount := new(big.Int).Set(initialAmount) protocols := make([]string, len(pools)) fees := make([]int64, len(pools)) totalGasCost := big.NewInt(0) for i, pool := range pools { protocols[i] = pool.Protocol fees[i] = pool.Fee // Calculate swap output for this hop outputAmount, err := mhs.calculateSwapOutput(currentAmount, pool, tokens[i], tokens[i+1]) if err != nil { mhs.logger.Debug(fmt.Sprintf("Failed to calculate swap output for pool %s: %v", pool.Address.Hex(), err)) return nil } currentAmount = outputAmount // Add estimated gas cost for this hop hopGasCost := mhs.estimateHopGasCost(pool.Protocol) totalGasCost.Add(totalGasCost, hopGasCost) } // Calculate net profit (final amount - initial amount - gas cost) netProfit := new(big.Int).Sub(currentAmount, initialAmount) netProfit.Sub(netProfit, totalGasCost) // Calculate ROI roi := 0.0 if initialAmount.Cmp(big.NewInt(0)) > 0 { profitFloat := new(big.Float).SetInt(netProfit) initialFloat := new(big.Float).SetInt(initialAmount) roiFloat := new(big.Float).Quo(profitFloat, initialFloat) roi, _ = roiFloat.Float64() roi *= 100 // Convert to percentage } return &ArbitragePath{ Tokens: tokens, Pools: pools, Protocols: protocols, Fees: fees, EstimatedGas: totalGasCost, NetProfit: netProfit, ROI: roi, LastUpdated: time.Now(), } } // calculateSwapOutput calculates the output amount for a swap func (mhs *MultiHopScanner) calculateSwapOutput(amountIn *big.Int, pool *PoolInfo, tokenIn, tokenOut common.Address) (*big.Int, error) { // This is a simplified calculation // In production, you would use the exact AMM formulas for each protocol if pool.SqrtPriceX96 == nil || pool.Liquidity == nil { return nil, fmt.Errorf("missing pool data") } // For Uniswap V3, use the pricing formulas if pool.Protocol == "UniswapV3" { return mhs.calculateUniswapV3Output(amountIn, pool, tokenIn, tokenOut) } // For other protocols, use simplified AMM formula return mhs.calculateSimpleAMMOutput(amountIn, pool, tokenIn, tokenOut) } // calculateUniswapV3Output calculates output for Uniswap V3 pools func (mhs *MultiHopScanner) calculateUniswapV3Output(amountIn *big.Int, pool *PoolInfo, tokenIn, tokenOut common.Address) (*big.Int, error) { // Convert sqrtPriceX96 to price price := uniswap.SqrtPriceX96ToPrice(pool.SqrtPriceX96.ToBig()) // Simple approximation: amountOut = amountIn * price * (1 - fee) amountInFloat := new(big.Float).SetInt(amountIn) var amountOut *big.Float if tokenIn == pool.Token0 { // Token0 -> Token1 amountOut = new(big.Float).Mul(amountInFloat, price) } else { // Token1 -> Token0 amountOut = new(big.Float).Quo(amountInFloat, price) } // Apply fee feeRate := new(big.Float).SetFloat64(float64(pool.Fee) / 1000000) // fee is in basis points oneMinusFee := new(big.Float).Sub(big.NewFloat(1.0), feeRate) amountOut.Mul(amountOut, oneMinusFee) // Convert back to big.Int result := new(big.Int) amountOut.Int(result) return result, nil } // calculateSimpleAMMOutput calculates output using proper Uniswap V2 AMM formula func (mhs *MultiHopScanner) calculateSimpleAMMOutput(amountIn *big.Int, pool *PoolInfo, tokenIn, tokenOut common.Address) (*big.Int, error) { // For Uniswap V2-style pools, we need actual reserve data // Since we don't have direct reserve data, we'll estimate based on liquidity and price if pool.SqrtPriceX96 == nil || pool.Liquidity == nil { return nil, fmt.Errorf("missing pool price or liquidity data") } // Convert sqrtPriceX96 to price (token1/token0) sqrtPriceX96 := pool.SqrtPriceX96.ToBig() price := new(big.Float) // price = (sqrtPriceX96 / 2^96)^2 q96 := new(big.Float).SetInt(new(big.Int).Exp(big.NewInt(2), big.NewInt(96), nil)) sqrtPrice := new(big.Float).SetInt(sqrtPriceX96) sqrtPrice.Quo(sqrtPrice, q96) price.Mul(sqrtPrice, sqrtPrice) // Estimate reserves from liquidity and price // For Uniswap V2: reserve0 * reserve1 = k, and price = reserve1/reserve0 // So: reserve0 = sqrt(k/price), reserve1 = sqrt(k*price) k := new(big.Float).SetInt(pool.Liquidity.ToBig()) k.Mul(k, k) // k = L^2 for approximation // Calculate reserves priceInv := new(big.Float).Quo(big.NewFloat(1.0), price) reserve0Float := new(big.Float).Sqrt(new(big.Float).Mul(k, priceInv)) reserve1Float := new(big.Float).Sqrt(new(big.Float).Mul(k, price)) // Convert to big.Int reserve0 := new(big.Int) reserve1 := new(big.Int) reserve0Float.Int(reserve0) reserve1Float.Int(reserve1) // Determine which reserves to use based on token direction var reserveIn, reserveOut *big.Int if tokenIn == pool.Token0 { reserveIn = reserve0 reserveOut = reserve1 } else { reserveIn = reserve1 reserveOut = reserve0 } // Ensure reserves are not zero if reserveIn.Cmp(big.NewInt(0)) == 0 || reserveOut.Cmp(big.NewInt(0)) == 0 { return nil, fmt.Errorf("invalid reserve calculation: reserveIn=%s, reserveOut=%s", reserveIn.String(), reserveOut.String()) } // Apply Uniswap V2 AMM formula: x * y = k with fees // amountOut = (amountIn * (1000 - fee) * reserveOut) / (reserveIn * 1000 + amountIn * (1000 - fee)) // Get fee from pool (convert basis points to per-mille) fee := pool.Fee / 100 // Convert from basis points (3000) to per-mille (30) if fee > 1000 { fee = 30 // Default to 3% if fee seems wrong } feeMultiplier := big.NewInt(1000 - fee) // e.g., 970 for 3% fee // Calculate numerator: amountIn * feeMultiplier * reserveOut numerator := new(big.Int).Mul(amountIn, feeMultiplier) numerator.Mul(numerator, reserveOut) // Calculate denominator: reserveIn * 1000 + amountIn * feeMultiplier denominator := new(big.Int).Mul(reserveIn, big.NewInt(1000)) temp := new(big.Int).Mul(amountIn, feeMultiplier) denominator.Add(denominator, temp) // Calculate output amount amountOut := new(big.Int).Div(numerator, denominator) // Sanity check: ensure amountOut is reasonable (not more than reserves) if amountOut.Cmp(reserveOut) >= 0 { return nil, fmt.Errorf("calculated output (%s) exceeds available reserves (%s)", amountOut.String(), reserveOut.String()) } return amountOut, nil } // Additional helper methods... // updateTokenGraph updates the token graph with current pool data func (mhs *MultiHopScanner) updateTokenGraph(ctx context.Context) error { // For now, create a minimal token graph with some default pools // In production, this would be populated from a real pool discovery service mhs.tokenGraph.mutex.Lock() defer mhs.tokenGraph.mutex.Unlock() // Clear existing graph mhs.tokenGraph.adjacencyList = make(map[common.Address]map[common.Address][]*PoolInfo) // Add some example pools for testing (these would come from pool discovery in production) // This is a simplified implementation to avoid circular dependencies return nil } // addPoolToGraph adds a pool to the token graph func (mhs *MultiHopScanner) addPoolToGraph(pool *PoolInfo) { // Add bidirectional edges mhs.addEdge(pool.Token0, pool.Token1, pool) mhs.addEdge(pool.Token1, pool.Token0, pool) } // addEdge adds an edge to the graph func (mhs *MultiHopScanner) addEdge(from, to common.Address, pool *PoolInfo) { if mhs.tokenGraph.adjacencyList[from] == nil { mhs.tokenGraph.adjacencyList[from] = make(map[common.Address][]*PoolInfo) } mhs.tokenGraph.adjacencyList[from][to] = append(mhs.tokenGraph.adjacencyList[from][to], pool) } // GetAdjacentTokens returns tokens adjacent to the given token func (tg *TokenGraph) GetAdjacentTokens(token common.Address) map[common.Address][]*PoolInfo { tg.mutex.RLock() defer tg.mutex.RUnlock() if adjacent, exists := tg.adjacencyList[token]; exists { return adjacent } return make(map[common.Address][]*PoolInfo) } // filterProfitablePaths filters paths that meet profitability criteria func (mhs *MultiHopScanner) filterProfitablePaths(paths []*ArbitragePath) []*ArbitragePath { var profitable []*ArbitragePath for _, path := range paths { if mhs.isProfitable(path) { profitable = append(profitable, path) } } return profitable } // isProfitable checks if a path meets profitability criteria func (mhs *MultiHopScanner) isProfitable(path *ArbitragePath) bool { // Check minimum profit threshold if path.NetProfit.Cmp(mhs.minProfitWei) < 0 { return false } // Check ROI threshold (minimum 1%) if path.ROI < 1.0 { return false } return true } // isPoolUsable checks if a pool has sufficient liquidity and is recent func (mhs *MultiHopScanner) isPoolUsable(pool *PoolInfo) bool { // Check if pool data is recent (within 5 minutes) if time.Since(pool.LastUpdated) > 5*time.Minute { return false } // Check minimum liquidity (equivalent to 0.1 ETH) minLiquidity := uint256.NewInt(100000000000000000) if pool.Liquidity.Cmp(minLiquidity) < 0 { return false } return true } // estimateHopGasCost estimates gas cost for a single hop func (mhs *MultiHopScanner) estimateHopGasCost(protocol string) *big.Int { switch protocol { case "UniswapV3": return big.NewInt(150000) // ~150k gas per V3 swap case "UniswapV2": return big.NewInt(120000) // ~120k gas per V2 swap case "SushiSwap": return big.NewInt(120000) // Similar to V2 default: return big.NewInt(150000) // Conservative estimate } } // getCachedPaths retrieves cached paths func (mhs *MultiHopScanner) getCachedPaths(key string) []*ArbitragePath { mhs.cacheMutex.RLock() defer mhs.cacheMutex.RUnlock() if paths, exists := mhs.pathCache[key]; exists { // Check if cache is still valid if len(paths) > 0 && time.Since(paths[0].LastUpdated) < mhs.cacheExpiry { return paths } } return nil } // setCachedPaths stores paths in cache func (mhs *MultiHopScanner) setCachedPaths(key string, paths []*ArbitragePath) { mhs.cacheMutex.Lock() defer mhs.cacheMutex.Unlock() mhs.pathCache[key] = paths }