diff --git a/pkg/arbitrage/simple_detector.go b/pkg/arbitrage/simple_detector.go index 1f6edb3..0dbb195 100644 --- a/pkg/arbitrage/simple_detector.go +++ b/pkg/arbitrage/simple_detector.go @@ -7,6 +7,7 @@ import ( "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" @@ -19,12 +20,13 @@ import ( 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 + 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 @@ -60,18 +62,18 @@ type Opportunity struct { // 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 + 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 + 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 } } @@ -86,17 +88,24 @@ func NewSimpleDetector(poolCache cache.PoolCache, logger observability.Logger, c } return &SimpleDetector{ - poolCache: poolCache, - logger: logger, - minProfitBPS: big.NewInt(cfg.MinProfitBPS), - maxGasCostWei: big.NewInt(cfg.MaxGasCostWei), - slippageBPS: big.NewInt(cfg.SlippageBPS), - minLiquidityUSD: big.NewInt(cfg.MinLiquidityUSD), + 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) { @@ -223,6 +232,8 @@ func (d *SimpleDetector) calculateOpportunity( 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) @@ -260,16 +271,90 @@ func (d *SimpleDetector) calculateOpportunity( OutputAmount: outputAmount, ProfitAmount: profitAmount, ProfitBPS: profitBPS, - GasCostWei: big.NewInt(1e15), // Placeholder: 0.001 ETH gas estimate + GasCostWei: gasEstimate, } } -// calculateSwapOutput calculates the output amount for a swap using constant product formula -// This is a simplified version for MVP - production would use protocol-specific math +// 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 @@ -306,15 +391,97 @@ func (d *SimpleDetector) calculateSwapOutput( 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 MVP, we use 1% of the pool's reserve as a simple heuristic +// For V2: uses 1% of pool reserves +// For V3: uses fixed amount based on liquidity func (d *SimpleDetector) estimateOptimalInputAmount(pool *types.PoolInfo) *big.Int { - // Use 1% of the smaller reserve as input amount + // 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(1e18) // Default to 1 token (18 decimals) + return big.NewInt(1e17) // Default to 0.1 token (18 decimals) } smallerReserve := reserve0 @@ -331,6 +498,12 @@ func (d *SimpleDetector) estimateOptimalInputAmount(pool *types.PoolInfo) *big.I inputAmount = minAmount } + // Cap at 1 ETH equivalent for safety + maxAmount := big.NewInt(1e18) + if inputAmount.Cmp(maxAmount) > 0 { + inputAmount = maxAmount + } + return inputAmount } @@ -339,19 +512,45 @@ func (d *SimpleDetector) filterProfitable(opportunities []*Opportunity) []*Oppor var profitable []*Opportunity for _, opp := range opportunities { - // Check if profit meets minimum threshold + if opp.ProfitAmount == nil || opp.ProfitAmount.Sign() <= 0 { + continue + } + + // Check if profit meets minimum threshold (percentage) if opp.ProfitBPS.Cmp(d.minProfitBPS) < 0 { continue } - // Check if gas cost is acceptable - if opp.GasCostWei.Cmp(d.maxGasCostWei) > 0 { + // Convert profit to wei for gas comparison + profitWei := d.profitToWei(opp) + if profitWei == nil { continue } - // Check if profit exceeds gas cost - // TODO: Need to convert gas cost to token terms for proper comparison - // For now, just check profit is positive (already done in calculateOpportunity) + // 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) } @@ -359,6 +558,98 @@ func (d *SimpleDetector) filterProfitable(opportunities []*Opportunity) []*Oppor 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()