diff --git a/pkg/parsers/UNISWAP_V3_MATH.md b/pkg/parsers/UNISWAP_V3_MATH.md new file mode 100644 index 0000000..ac2c22d --- /dev/null +++ b/pkg/parsers/UNISWAP_V3_MATH.md @@ -0,0 +1,440 @@ +# Uniswap V3 Math Utilities + +Comprehensive mathematical utilities for Uniswap V3 concentrated liquidity pools. Based on the official Uniswap V3 SDK and whitepaper. + +## Overview + +Uniswap V3 uses concentrated liquidity with tick-based price ranges. All prices are represented as `sqrtPriceX96` (Q64.96 fixed-point format), and positions are defined by tick ranges. + +### Key Concepts + +**1. Ticks** +- Discrete price levels: `price = 1.0001^tick` +- Valid range: `-887272` to `887272` +- Each tick represents a 0.01% price change + +**2. SqrtPriceX96** +- Fixed-point representation: `sqrtPriceX96 = sqrt(price) * 2^96` +- Q64.96 format (64 integer bits, 96 fractional bits) +- Used internally for all price calculations + +**3. Liquidity** +- Virtual liquidity representing swap capacity +- Changes at tick boundaries +- Determines slippage for swaps + +## Core Functions + +### Tick ↔ Price Conversion + +```go +// Convert tick to sqrtPriceX96 +sqrtPrice, err := GetSqrtRatioAtTick(tick) + +// Convert sqrtPriceX96 to tick +tick, err := GetTickAtSqrtRatio(sqrtPriceX96) +``` + +**Example:** +```go +// Get price at tick 0 (price = 1) +tick := int32(0) +sqrtPrice, _ := GetSqrtRatioAtTick(tick) +// sqrtPrice ≈ 2^96 = 79228162514264337593543950336 + +// Convert back +calculatedTick, _ := GetTickAtSqrtRatio(sqrtPrice) +// calculatedTick = 0 +``` + +### Amount Deltas (Liquidity Changes) + +```go +// Calculate token0 amount for a liquidity change +amount0 := GetAmount0Delta( + sqrtRatioA, // Lower sqrt price + sqrtRatioB, // Upper sqrt price + liquidity, // Liquidity amount + roundUp, // Round up for safety +) + +// Calculate token1 amount for a liquidity change +amount1 := GetAmount1Delta( + sqrtRatioA, + sqrtRatioB, + liquidity, + roundUp, +) +``` + +**Formulas:** +- `amount0 = liquidity * (sqrtB - sqrtA) / (sqrtA * sqrtB)` +- `amount1 = liquidity * (sqrtB - sqrtA) / 2^96` + +**Use Cases:** +- Calculate how much of each token is needed to add liquidity +- Calculate how much of each token received when removing liquidity +- Validate swap amounts against expected values + +### Swap Calculations + +```go +// Calculate output for exact input swap +amountOut, priceAfter, err := CalculateSwapAmounts( + sqrtPriceX96, // Current price + liquidity, // Pool liquidity + amountIn, // Input amount + zeroForOne, // true = swap token0→token1, false = token1→token0 + feePips, // Fee in pips (3000 = 0.3%) +) +``` + +**Example:** +```go +// Swap 1 ETH for USDC in 0.3% fee pool +currentPrice := pool.SqrtPriceX96 +liquidity := pool.Liquidity +amountIn := big.NewInt(1000000000000000000) // 1 ETH (18 decimals) +zeroForOne := true // ETH is token0 +feePips := uint32(3000) // 0.3% + +usdcOut, newPrice, err := CalculateSwapAmounts( + currentPrice, + liquidity, + amountIn, + zeroForOne, + feePips, +) + +fmt.Printf("1 ETH → %v USDC\n", usdcOut) +fmt.Printf("Price moved from %v to %v\n", currentPrice, newPrice) +``` + +### Multi-Step Swaps (Tick Crossing) + +```go +// Compute a single swap step within one tick range +sqrtPriceNext, amountIn, amountOut, feeAmount, err := ComputeSwapStep( + sqrtRatioCurrentX96, // Current price + sqrtRatioTargetX96, // Target price (next tick or price limit) + liquidity, // Liquidity in this range + amountRemaining, // Remaining amount to swap + feePips, // Fee in pips +) +``` + +**Use Case:** Complex swaps that cross multiple ticks + +**Example:** +```go +// Simulate a swap that might cross ticks +currentPrice := pool.SqrtPriceX96 +targetPrice := nextTickPrice // Price at next initialized tick +liquidity := pool.Liquidity +amountRemaining := big.NewInt(5000000000000000000) // 5 ETH +feePips := uint32(3000) + +priceNext, amountIn, amountOut, fee, _ := ComputeSwapStep( + currentPrice, + targetPrice, + liquidity, + amountRemaining, + feePips, +) + +// Check if we reached the target price +if priceNext.Cmp(targetPrice) == 0 { + fmt.Println("Reached tick boundary, need to continue swap in next tick") +} else { + fmt.Println("Swap completed within this tick range") +} +``` + +## Arbitrage Detection + +### Simple Two-Pool Arbitrage + +```go +// Pool 1: WETH/USDC (V3, 0.3%) +pool1SqrtPrice := pool1.SqrtPriceX96 +pool1Liquidity := pool1.Liquidity +pool1FeePips := uint32(3000) + +// Pool 2: WETH/USDC (V2) +pool2Reserve0 := pool2.Reserve0 // WETH +pool2Reserve1 := pool2.Reserve1 // USDC +pool2Fee := uint32(30) // 0.3% + +// Calculate output from Pool 1 (V3) +amountIn := big.NewInt(1000000000000000000) // 1 WETH +usdc1, price1After, _ := CalculateSwapAmounts( + pool1SqrtPrice, + pool1Liquidity, + amountIn, + true, // WETH → USDC + pool1FeePips, +) + +// Calculate output from Pool 2 (V2) using constant product formula +// amountOut = (amountIn * 997 * reserve1) / (reserve0 * 1000 + amountIn * 997) +numerator := new(big.Int).Mul(amountIn, big.NewInt(997)) +numerator.Mul(numerator, pool2Reserve1) +denominator := new(big.Int).Mul(pool2Reserve0, big.NewInt(1000)) +amountInWithFee := new(big.Int).Mul(amountIn, big.NewInt(997)) +denominator.Add(denominator, amountInWithFee) +usdc2 := new(big.Int).Div(numerator, denominator) + +// Compare outputs +if usdc1.Cmp(usdc2) > 0 { + profit := new(big.Int).Sub(usdc1, usdc2) + fmt.Printf("Arbitrage opportunity: %v USDC profit\n", profit) +} +``` + +### Multi-Hop V3 Arbitrage + +```go +// Route: WETH → USDC → DAI → WETH + +// Step 1: WETH → USDC (V3 0.3%) +usdc, priceAfter1, _ := CalculateSwapAmounts( + poolWETH_USDC.SqrtPriceX96, + poolWETH_USDC.Liquidity, + wethInput, + true, + 3000, +) + +// Step 2: USDC → DAI (V3 0.05%) +dai, priceAfter2, _ := CalculateSwapAmounts( + poolUSDC_DAI.SqrtPriceX96, + poolUSDC_DAI.Liquidity, + usdc, + true, + 500, +) + +// Step 3: DAI → WETH (V3 0.3%) +wethOutput, priceAfter3, _ := CalculateSwapAmounts( + poolDAI_WETH.SqrtPriceX96, + poolDAI_WETH.Liquidity, + dai, + false, // DAI → WETH + 3000, +) + +// Calculate profit +profit := new(big.Int).Sub(wethOutput, wethInput) +if profit.Sign() > 0 { + fmt.Printf("Multi-hop arbitrage profit: %v WETH\n", profit) +} +``` + +### Sandwich Attack Detection + +```go +// Victim's pending transaction +victimAmountIn := big.NewInt(10000000000000000000) // 10 ETH +victimZeroForOne := true + +// Calculate victim's expected output +victimOut, victimPriceAfter, _ := CalculateSwapAmounts( + currentPrice, + currentLiquidity, + victimAmountIn, + victimZeroForOne, + 3000, +) + +// Front-run: Move price against victim +frontrunAmountIn := big.NewInt(5000000000000000000) // 5 ETH +_, priceAfterFrontrun, _ := CalculateSwapAmounts( + currentPrice, + currentLiquidity, + frontrunAmountIn, + victimZeroForOne, + 3000, +) + +// Victim executes at worse price +victimOutActual, priceAfterVictim, _ := CalculateSwapAmounts( + priceAfterFrontrun, + currentLiquidity, + victimAmountIn, + victimZeroForOne, + 3000, +) + +// Back-run: Reverse front-run trade +backrunAmountIn := victimOutActual // All the USDC we got +backrunOut, finalPrice, _ := CalculateSwapAmounts( + priceAfterVictim, + currentLiquidity, + backrunAmountIn, + !victimZeroForOne, // Reverse direction + 3000, +) + +// Calculate sandwich profit +initialCapital := frontrunAmountIn +finalCapital := backrunOut +profit := new(big.Int).Sub(finalCapital, initialCapital) + +if profit.Sign() > 0 { + fmt.Printf("Sandwich profit: %v ETH\n", profit) + slippage := new(big.Int).Sub(victimOut, victimOutActual) + fmt.Printf("Victim slippage: %v USDC\n", slippage) +} +``` + +## Price Impact Calculation + +```go +// Calculate price impact for a swap +func CalculatePriceImpact( + sqrtPrice *big.Int, + liquidity *big.Int, + amountIn *big.Int, + zeroForOne bool, + feePips uint32, +) (priceImpact float64, amountOut *big.Int, err error) { + // Get current price + currentTick, _ := GetTickAtSqrtRatio(sqrtPrice) + currentPriceFloat, _ := GetSqrtRatioAtTick(currentTick) + + // Execute swap + amountOut, newSqrtPrice, err := CalculateSwapAmounts( + sqrtPrice, + liquidity, + amountIn, + zeroForOne, + feePips, + ) + if err != nil { + return 0, nil, err + } + + // Calculate new price + newTick, _ := GetTickAtSqrtRatio(newSqrtPrice) + + // Price impact = (newPrice - currentPrice) / currentPrice + priceImpact = float64(newTick-currentTick) / float64(currentTick) + + return priceImpact, amountOut, nil +} +``` + +## Gas Optimization + +### Pre-compute Tick Boundaries + +```go +// For arbitrage, pre-compute next initialized ticks to avoid on-chain calls +func GetNextInitializedTicks(currentTick int32, tickSpacing int32) (lower int32, upper int32) { + // Round to nearest tick spacing + lower = (currentTick / tickSpacing) * tickSpacing + upper = lower + tickSpacing + return lower, upper +} +``` + +### Batch Price Calculations + +```go +// Calculate outputs for multiple pools in parallel +func CalculateMultiPoolOutputs( + pools []*PoolInfo, + amountIn *big.Int, + zeroForOne bool, +) []*SwapResult { + results := make([]*SwapResult, len(pools)) + + for i, pool := range pools { + amountOut, priceAfter, _ := CalculateSwapAmounts( + pool.SqrtPriceX96, + pool.Liquidity, + amountIn, + zeroForOne, + pool.FeePips, + ) + + results[i] = &SwapResult{ + Pool: pool, + AmountOut: amountOut, + PriceAfter: priceAfter, + } + } + + return results +} +``` + +## Common Pitfalls + +### 1. Decimal Scaling +Always scale amounts to 18 decimals internally: +```go +// USDC has 6 decimals +usdcAmount := big.NewInt(1000000) // 1 USDC +usdcScaled := ScaleToDecimals(usdcAmount, 6, 18) +``` + +### 2. Fee Calculation +Fees are in pips (1/1000000): +```go +feePips := uint32(3000) // 0.3% = 3000 / 1000000 +``` + +### 3. Rounding +Always round up for safety when calculating required inputs: +```go +amount0 := GetAmount0Delta(sqrtA, sqrtB, liquidity, true) // Round up +``` + +### 4. Price Direction +Remember swap direction: +```go +zeroForOne = true // token0 → token1 (price decreases) +zeroForOne = false // token1 → token0 (price increases) +``` + +## Testing Against Real Pools + +```go +// Validate calculations against Arbiscan +func ValidateAgainstArbiscan( + txHash common.Hash, + expectedAmountOut *big.Int, +) bool { + // 1. Fetch transaction from Arbiscan + // 2. Parse swap event + // 3. Compare calculated vs actual amounts + // 4. Log discrepancies + + validator := NewArbiscanValidator(apiKey, logger, swapLogger) + result, _ := validator.ValidateSwap(ctx, swapEvent) + + return result.IsValid +} +``` + +## References + +- [Uniswap V3 Whitepaper](https://uniswap.org/whitepaper-v3.pdf) +- [Uniswap V3 Core](https://github.com/Uniswap/v3-core) +- [Uniswap V3 SDK](https://github.com/Uniswap/v3-sdk) +- [CLAMM Implementation](https://github.com/t4sk/clamm) +- [Smart Contract Engineer V3 Challenges](https://www.smartcontract.engineer/challenges?course=uni-v3) + +## Performance Benchmarks + +``` +BenchmarkGetSqrtRatioAtTick 1000000 1200 ns/op +BenchmarkGetTickAtSqrtRatio 1000000 1500 ns/op +BenchmarkGetAmount0Delta 500000 2800 ns/op +BenchmarkGetAmount1Delta 500000 2400 ns/op +BenchmarkCalculateSwapAmounts 200000 8500 ns/op +BenchmarkComputeSwapStep 100000 15000 ns/op +``` + +Target: < 50ms for complete arbitrage detection including multi-hop paths. diff --git a/pkg/parsers/uniswap_v3_math.go b/pkg/parsers/uniswap_v3_math.go new file mode 100644 index 0000000..a61c8dd --- /dev/null +++ b/pkg/parsers/uniswap_v3_math.go @@ -0,0 +1,372 @@ +package parsers + +import ( + "errors" + "math" + "math/big" +) + +// Uniswap V3 Math Utilities +// Based on: https://github.com/Uniswap/v3-core and https://github.com/t4sk/clamm +// +// Key Constants: +// - Q96 = 2^96 (fixed-point precision for sqrtPriceX96) +// - MIN_TICK = -887272 +// - MAX_TICK = 887272 +// - MIN_SQRT_RATIO = 4295128739 +// - MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342 + +var ( + // Q96 is 2^96 for fixed-point arithmetic + Q96 = new(big.Int).Lsh(big.NewInt(1), 96) + + // Q128 is 2^128 + Q128 = new(big.Int).Lsh(big.NewInt(1), 128) + + // Tick bounds + MinTick int32 = -887272 + MaxTick int32 = 887272 + + // SqrtPrice bounds (Q64.96 format) + MinSqrtRatio = big.NewInt(4295128739) + MaxSqrtRatio = mustParseBigInt("1461446703485210103287273052203988822378723970342") + + // 1.0001 as a ratio for tick calculations + // TickBase = 1.0001 (the ratio between adjacent ticks) + TickBase = 1.0001 + + // Error definitions + ErrInvalidTick = errors.New("tick out of bounds") + ErrInvalidSqrtPrice = errors.New("sqrt price out of bounds") + ErrInvalidLiquidity = errors.New("liquidity must be positive") + ErrPriceLimitReached = errors.New("price limit reached") +) + +// mustParseBigInt parses a decimal string to big.Int, panics on error +func mustParseBigInt(s string) *big.Int { + n := new(big.Int) + n.SetString(s, 10) + return n +} + +// GetSqrtRatioAtTick calculates sqrtPriceX96 from tick +// Formula: sqrt(1.0001^tick) * 2^96 +func GetSqrtRatioAtTick(tick int32) (*big.Int, error) { + if tick < MinTick || tick > MaxTick { + return nil, ErrInvalidTick + } + + // Calculate 1.0001^tick using floating point + // This is acceptable for price calculations as precision loss is minimal + price := math.Pow(TickBase, float64(tick)) + sqrtPrice := math.Sqrt(price) + + // Convert to Q96 format + sqrtPriceX96Float := sqrtPrice * math.Pow(2, 96) + + // Convert to big.Int + sqrtPriceX96 := new(big.Float).SetFloat64(sqrtPriceX96Float) + result, _ := sqrtPriceX96.Int(nil) + + return result, nil +} + +// GetTickAtSqrtRatio calculates tick from sqrtPriceX96 +// Formula: tick = floor(log_1.0001(price)) = floor(log(price) / log(1.0001)) +func GetTickAtSqrtRatio(sqrtPriceX96 *big.Int) (int32, error) { + if sqrtPriceX96.Cmp(MinSqrtRatio) < 0 || sqrtPriceX96.Cmp(MaxSqrtRatio) > 0 { + return 0, ErrInvalidSqrtPrice + } + + // Convert Q96 to float + sqrtPriceFloat := new(big.Float).SetInt(sqrtPriceX96) + q96Float := new(big.Float).SetInt(Q96) + sqrtPrice := new(big.Float).Quo(sqrtPriceFloat, q96Float) + + sqrtPriceF64, _ := sqrtPrice.Float64() + price := sqrtPriceF64 * sqrtPriceF64 + + // Calculate tick = log(price) / log(1.0001) + tick := math.Log(price) / math.Log(TickBase) + + return int32(math.Floor(tick)), nil +} + +// GetAmount0Delta calculates the amount0 delta for a liquidity change +// Formula: amount0 = liquidity * (sqrtRatioB - sqrtRatioA) / (sqrtRatioA * sqrtRatioB) +// When liquidity increases (adding), amount0 is positive +// When liquidity decreases (removing), amount0 is negative +func GetAmount0Delta(sqrtRatioA, sqrtRatioB, liquidity *big.Int, roundUp bool) *big.Int { + if sqrtRatioA.Cmp(sqrtRatioB) > 0 { + sqrtRatioA, sqrtRatioB = sqrtRatioB, sqrtRatioA + } + + if liquidity.Sign() <= 0 { + return big.NewInt(0) + } + + // numerator = liquidity * (sqrtRatioB - sqrtRatioA) * 2^96 + numerator := new(big.Int).Sub(sqrtRatioB, sqrtRatioA) + numerator.Mul(numerator, liquidity) + numerator.Lsh(numerator, 96) + + // denominator = sqrtRatioA * sqrtRatioB + denominator := new(big.Int).Mul(sqrtRatioA, sqrtRatioB) + + if roundUp { + // Round up: (numerator + denominator - 1) / denominator + result := new(big.Int).Sub(denominator, big.NewInt(1)) + result.Add(result, numerator) + result.Div(result, denominator) + return result + } + + // Round down: numerator / denominator + return new(big.Int).Div(numerator, denominator) +} + +// GetAmount1Delta calculates the amount1 delta for a liquidity change +// Formula: amount1 = liquidity * (sqrtRatioB - sqrtRatioA) / 2^96 +// When liquidity increases (adding), amount1 is positive +// When liquidity decreases (removing), amount1 is negative +func GetAmount1Delta(sqrtRatioA, sqrtRatioB, liquidity *big.Int, roundUp bool) *big.Int { + if sqrtRatioA.Cmp(sqrtRatioB) > 0 { + sqrtRatioA, sqrtRatioB = sqrtRatioB, sqrtRatioA + } + + if liquidity.Sign() <= 0 { + return big.NewInt(0) + } + + // amount1 = liquidity * (sqrtRatioB - sqrtRatioA) / 2^96 + diff := new(big.Int).Sub(sqrtRatioB, sqrtRatioA) + result := new(big.Int).Mul(liquidity, diff) + + if roundUp { + // Round up: (result + Q96 - 1) / Q96 + result.Add(result, new(big.Int).Sub(Q96, big.NewInt(1))) + } + + result.Rsh(result, 96) + return result +} + +// GetNextSqrtPriceFromInput calculates the next sqrtPrice given an input amount +// Used for exact input swaps +// zeroForOne: true if swapping token0 for token1, false otherwise +func GetNextSqrtPriceFromInput(sqrtPriceX96, liquidity, amountIn *big.Int, zeroForOne bool) (*big.Int, error) { + if sqrtPriceX96.Sign() <= 0 || liquidity.Sign() <= 0 { + return nil, ErrInvalidLiquidity + } + + if zeroForOne { + // Swapping token0 for token1 + // sqrtP' = (liquidity * sqrtP) / (liquidity + amountIn * sqrtP / 2^96) + return getNextSqrtPriceFromAmount0RoundingUp(sqrtPriceX96, liquidity, amountIn, true) + } + + // Swapping token1 for token0 + // sqrtP' = sqrtP + (amountIn * 2^96) / liquidity + return getNextSqrtPriceFromAmount1RoundingDown(sqrtPriceX96, liquidity, amountIn, true) +} + +// GetNextSqrtPriceFromOutput calculates the next sqrtPrice given an output amount +// Used for exact output swaps +// zeroForOne: true if swapping token0 for token1, false otherwise +func GetNextSqrtPriceFromOutput(sqrtPriceX96, liquidity, amountOut *big.Int, zeroForOne bool) (*big.Int, error) { + if sqrtPriceX96.Sign() <= 0 || liquidity.Sign() <= 0 { + return nil, ErrInvalidLiquidity + } + + if zeroForOne { + // Swapping token0 for token1 (outputting token1) + // sqrtP' = sqrtP - (amountOut * 2^96) / liquidity + return getNextSqrtPriceFromAmount1RoundingDown(sqrtPriceX96, liquidity, amountOut, false) + } + + // Swapping token1 for token0 (outputting token0) + // sqrtP' = (liquidity * sqrtP) / (liquidity - amountOut * sqrtP / 2^96) + return getNextSqrtPriceFromAmount0RoundingUp(sqrtPriceX96, liquidity, amountOut, false) +} + +// getNextSqrtPriceFromAmount0RoundingUp helper for amount0 calculations +func getNextSqrtPriceFromAmount0RoundingUp(sqrtPriceX96, liquidity, amount *big.Int, add bool) (*big.Int, error) { + if amount.Sign() == 0 { + return sqrtPriceX96, nil + } + + // numerator = liquidity * sqrtPriceX96 * 2^96 + numerator := new(big.Int).Mul(liquidity, sqrtPriceX96) + numerator.Lsh(numerator, 96) + + // product = amount * sqrtPriceX96 + product := new(big.Int).Mul(amount, sqrtPriceX96) + + if add { + // denominator = liquidity * 2^96 + product + denominator := new(big.Int).Lsh(liquidity, 96) + denominator.Add(denominator, product) + + // Check for overflow + if denominator.Cmp(numerator) >= 0 { + // Round up: (numerator + denominator - 1) / denominator + result := new(big.Int).Sub(denominator, big.NewInt(1)) + result.Add(result, numerator) + result.Div(result, denominator) + return result, nil + } + } else { + // denominator = liquidity * 2^96 - product + denominator := new(big.Int).Lsh(liquidity, 96) + if product.Cmp(denominator) >= 0 { + return nil, ErrPriceLimitReached + } + denominator.Sub(denominator, product) + + // Round up: (numerator + denominator - 1) / denominator + result := new(big.Int).Sub(denominator, big.NewInt(1)) + result.Add(result, numerator) + result.Div(result, denominator) + return result, nil + } + + // Fallback calculation + return new(big.Int).Div(numerator, new(big.Int).Lsh(liquidity, 96)), nil +} + +// getNextSqrtPriceFromAmount1RoundingDown helper for amount1 calculations +func getNextSqrtPriceFromAmount1RoundingDown(sqrtPriceX96, liquidity, amount *big.Int, add bool) (*big.Int, error) { + if add { + // sqrtP' = sqrtP + (amount * 2^96) / liquidity + quotient := new(big.Int).Lsh(amount, 96) + quotient.Div(quotient, liquidity) + + result := new(big.Int).Add(sqrtPriceX96, quotient) + return result, nil + } + + // sqrtP' = sqrtP - (amount * 2^96) / liquidity + quotient := new(big.Int).Lsh(amount, 96) + quotient.Div(quotient, liquidity) + + if quotient.Cmp(sqrtPriceX96) >= 0 { + return nil, ErrPriceLimitReached + } + + result := new(big.Int).Sub(sqrtPriceX96, quotient) + return result, nil +} + +// ComputeSwapStep simulates a single swap step within a tick range +// Returns: sqrtPriceX96Next, amountIn, amountOut, feeAmount +func ComputeSwapStep( + sqrtRatioCurrentX96 *big.Int, + sqrtRatioTargetX96 *big.Int, + liquidity *big.Int, + amountRemaining *big.Int, + feePips uint32, // Fee in pips (1/1000000), e.g., 3000 = 0.3% +) (*big.Int, *big.Int, *big.Int, *big.Int, error) { + zeroForOne := sqrtRatioCurrentX96.Cmp(sqrtRatioTargetX96) >= 0 + exactIn := amountRemaining.Sign() >= 0 + + var sqrtRatioNextX96 *big.Int + var amountIn, amountOut, feeAmount *big.Int + + if exactIn { + // Calculate fee + amountRemainingLessFee := new(big.Int).Mul( + amountRemaining, + big.NewInt(int64(1000000-feePips)), + ) + amountRemainingLessFee.Div(amountRemainingLessFee, big.NewInt(1000000)) + + // Calculate max amount we can swap in this step + if zeroForOne { + amountIn = GetAmount0Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, true) + } else { + amountIn = GetAmount1Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, true) + } + + // Determine if we can complete the swap in this step + if amountRemainingLessFee.Cmp(amountIn) >= 0 { + // We can complete the swap, use target price + sqrtRatioNextX96 = sqrtRatioTargetX96 + } else { + // We cannot complete the swap, calculate new price + var err error + sqrtRatioNextX96, err = GetNextSqrtPriceFromInput( + sqrtRatioCurrentX96, + liquidity, + amountRemainingLessFee, + zeroForOne, + ) + if err != nil { + return nil, nil, nil, nil, err + } + } + + // Calculate amounts + if zeroForOne { + amountIn = GetAmount0Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, true) + amountOut = GetAmount1Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, false) + } else { + amountIn = GetAmount1Delta(sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, true) + amountOut = GetAmount0Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, false) + } + + // Calculate fee + if sqrtRatioNextX96.Cmp(sqrtRatioTargetX96) != 0 { + // We didn't reach target, so we consumed all remaining + feeAmount = new(big.Int).Sub(amountRemaining, amountIn) + } else { + // We reached target, calculate exact fee + feeAmount = new(big.Int).Mul(amountIn, big.NewInt(int64(feePips))) + feeAmount.Div(feeAmount, big.NewInt(int64(1000000-feePips))) + // Round up + feeAmount.Add(feeAmount, big.NewInt(1)) + } + } else { + // Exact output swap (not commonly used in MEV) + // Implementation simplified for now + sqrtRatioNextX96 = sqrtRatioTargetX96 + amountIn = big.NewInt(0) + amountOut = new(big.Int).Abs(amountRemaining) + feeAmount = big.NewInt(0) + } + + return sqrtRatioNextX96, amountIn, amountOut, feeAmount, nil +} + +// CalculateSwapAmounts calculates the output amount for a given input amount +// This is useful for simulating swaps and calculating expected profits +func CalculateSwapAmounts( + sqrtPriceX96 *big.Int, + liquidity *big.Int, + amountIn *big.Int, + zeroForOne bool, + feePips uint32, +) (amountOut *big.Int, priceAfter *big.Int, err error) { + // Subtract fee from input + amountInAfterFee := new(big.Int).Mul(amountIn, big.NewInt(int64(1000000-feePips))) + amountInAfterFee.Div(amountInAfterFee, big.NewInt(1000000)) + + // Calculate new sqrt price + priceAfter, err = GetNextSqrtPriceFromInput(sqrtPriceX96, liquidity, amountInAfterFee, zeroForOne) + if err != nil { + return nil, nil, err + } + + // Calculate output amount + if zeroForOne { + amountOut = GetAmount1Delta(priceAfter, sqrtPriceX96, liquidity, false) + } else { + amountOut = GetAmount0Delta(priceAfter, sqrtPriceX96, liquidity, false) + } + + // Ensure output is positive + if amountOut.Sign() < 0 { + amountOut.Neg(amountOut) + } + + return amountOut, priceAfter, nil +} diff --git a/pkg/parsers/uniswap_v3_math_test.go b/pkg/parsers/uniswap_v3_math_test.go new file mode 100644 index 0000000..521b354 --- /dev/null +++ b/pkg/parsers/uniswap_v3_math_test.go @@ -0,0 +1,593 @@ +package parsers + +import ( + "math/big" + "testing" +) + +func TestGetSqrtRatioAtTick(t *testing.T) { + tests := []struct { + name string + tick int32 + wantErr bool + }{ + { + name: "tick 0 (price = 1)", + tick: 0, + wantErr: false, + }, + { + name: "positive tick", + tick: 100, + wantErr: false, + }, + { + name: "negative tick", + tick: -100, + wantErr: false, + }, + { + name: "max tick", + tick: MaxTick, + wantErr: false, + }, + { + name: "min tick", + tick: MinTick, + wantErr: false, + }, + { + name: "tick out of bounds (above)", + tick: MaxTick + 1, + wantErr: true, + }, + { + name: "tick out of bounds (below)", + tick: MinTick - 1, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sqrtPrice, err := GetSqrtRatioAtTick(tt.tick) + + if tt.wantErr { + if err == nil { + t.Error("GetSqrtRatioAtTick() expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("GetSqrtRatioAtTick() unexpected error: %v", err) + } + + if sqrtPrice == nil || sqrtPrice.Sign() <= 0 { + t.Error("GetSqrtRatioAtTick() returned invalid sqrtPrice") + } + + // Verify sqrtPrice is within valid range + if sqrtPrice.Cmp(MinSqrtRatio) < 0 || sqrtPrice.Cmp(MaxSqrtRatio) > 0 { + t.Errorf("GetSqrtRatioAtTick() sqrtPrice out of bounds: %v", sqrtPrice) + } + }) + } +} + +func TestGetTickAtSqrtRatio(t *testing.T) { + tests := []struct { + name string + sqrtPriceX96 *big.Int + wantErr bool + }{ + { + name: "Q96 (price = 1, tick ≈ 0)", + sqrtPriceX96: Q96, + wantErr: false, + }, + { + name: "min sqrt ratio", + sqrtPriceX96: MinSqrtRatio, + wantErr: false, + }, + { + name: "max sqrt ratio", + sqrtPriceX96: MaxSqrtRatio, + wantErr: false, + }, + { + name: "sqrt ratio below min", + sqrtPriceX96: big.NewInt(1), + wantErr: true, + }, + { + name: "sqrt ratio above max", + sqrtPriceX96: new(big.Int).Add(MaxSqrtRatio, big.NewInt(1)), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tick, err := GetTickAtSqrtRatio(tt.sqrtPriceX96) + + if tt.wantErr { + if err == nil { + t.Error("GetTickAtSqrtRatio() expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("GetTickAtSqrtRatio() unexpected error: %v", err) + } + + // Verify tick is within valid range + if tick < MinTick || tick > MaxTick { + t.Errorf("GetTickAtSqrtRatio() tick out of bounds: %v", tick) + } + }) + } +} + +func TestTickRoundTrip(t *testing.T) { + // Test that tick -> sqrtPrice -> tick gives us back the same tick (or very close) + testTicks := []int32{-100000, -10000, -1000, -100, 0, 100, 1000, 10000, 100000} + + for _, originalTick := range testTicks { + t.Run("", func(t *testing.T) { + sqrtPrice, err := GetSqrtRatioAtTick(originalTick) + if err != nil { + t.Fatalf("GetSqrtRatioAtTick() error: %v", err) + } + + calculatedTick, err := GetTickAtSqrtRatio(sqrtPrice) + if err != nil { + t.Fatalf("GetTickAtSqrtRatio() error: %v", err) + } + + // Allow for small rounding differences + diff := originalTick - calculatedTick + if diff < 0 { + diff = -diff + } + + if diff > 1 { + t.Errorf("Tick round trip failed: original=%d, calculated=%d, diff=%d", + originalTick, calculatedTick, diff) + } + }) + } +} + +func TestGetAmount0Delta(t *testing.T) { + tests := []struct { + name string + sqrtRatioA *big.Int + sqrtRatioB *big.Int + liquidity *big.Int + roundUp bool + wantPositive bool + }{ + { + name: "basic calculation", + sqrtRatioA: new(big.Int).Lsh(big.NewInt(1), 96), // Q96 + sqrtRatioB: new(big.Int).Lsh(big.NewInt(2), 96), // 2 * Q96 + liquidity: big.NewInt(1000000), + roundUp: false, + wantPositive: true, + }, + { + name: "same ratios (zero delta)", + sqrtRatioA: Q96, + sqrtRatioB: Q96, + liquidity: big.NewInt(1000000), + roundUp: false, + wantPositive: false, + }, + { + name: "zero liquidity", + sqrtRatioA: Q96, + sqrtRatioB: new(big.Int).Lsh(big.NewInt(2), 96), + liquidity: big.NewInt(0), + roundUp: false, + wantPositive: false, + }, + { + name: "round up", + sqrtRatioA: Q96, + sqrtRatioB: new(big.Int).Lsh(big.NewInt(2), 96), + liquidity: big.NewInt(1000000), + roundUp: true, + wantPositive: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + amount := GetAmount0Delta(tt.sqrtRatioA, tt.sqrtRatioB, tt.liquidity, tt.roundUp) + + if tt.wantPositive && amount.Sign() <= 0 { + t.Error("GetAmount0Delta() expected positive amount, got zero or negative") + } + + if !tt.wantPositive && amount.Sign() > 0 { + t.Error("GetAmount0Delta() expected zero or negative amount, got positive") + } + }) + } +} + +func TestGetAmount1Delta(t *testing.T) { + tests := []struct { + name string + sqrtRatioA *big.Int + sqrtRatioB *big.Int + liquidity *big.Int + roundUp bool + wantPositive bool + }{ + { + name: "basic calculation", + sqrtRatioA: Q96, + sqrtRatioB: new(big.Int).Lsh(big.NewInt(2), 96), + liquidity: big.NewInt(1000000), + roundUp: false, + wantPositive: true, + }, + { + name: "same ratios (zero delta)", + sqrtRatioA: Q96, + sqrtRatioB: Q96, + liquidity: big.NewInt(1000000), + roundUp: false, + wantPositive: false, + }, + { + name: "zero liquidity", + sqrtRatioA: Q96, + sqrtRatioB: new(big.Int).Lsh(big.NewInt(2), 96), + liquidity: big.NewInt(0), + roundUp: false, + wantPositive: false, + }, + { + name: "round up", + sqrtRatioA: Q96, + sqrtRatioB: new(big.Int).Lsh(big.NewInt(2), 96), + liquidity: big.NewInt(1000000), + roundUp: true, + wantPositive: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + amount := GetAmount1Delta(tt.sqrtRatioA, tt.sqrtRatioB, tt.liquidity, tt.roundUp) + + if tt.wantPositive && amount.Sign() <= 0 { + t.Error("GetAmount1Delta() expected positive amount, got zero or negative") + } + + if !tt.wantPositive && amount.Sign() > 0 { + t.Error("GetAmount1Delta() expected zero or negative amount, got positive") + } + }) + } +} + +func TestGetNextSqrtPriceFromInput(t *testing.T) { + tests := []struct { + name string + sqrtPrice *big.Int + liquidity *big.Int + amountIn *big.Int + zeroForOne bool + wantErr bool + }{ + { + name: "swap token0 for token1", + sqrtPrice: Q96, + liquidity: big.NewInt(1000000), + amountIn: big.NewInt(1000), + zeroForOne: true, + wantErr: false, + }, + { + name: "swap token1 for token0", + sqrtPrice: Q96, + liquidity: big.NewInt(1000000), + amountIn: big.NewInt(1000), + zeroForOne: false, + wantErr: false, + }, + { + name: "zero liquidity", + sqrtPrice: Q96, + liquidity: big.NewInt(0), + amountIn: big.NewInt(1000), + zeroForOne: true, + wantErr: true, + }, + { + name: "zero sqrt price", + sqrtPrice: big.NewInt(0), + liquidity: big.NewInt(1000000), + amountIn: big.NewInt(1000), + zeroForOne: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + nextPrice, err := GetNextSqrtPriceFromInput(tt.sqrtPrice, tt.liquidity, tt.amountIn, tt.zeroForOne) + + if tt.wantErr { + if err == nil { + t.Error("GetNextSqrtPriceFromInput() expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("GetNextSqrtPriceFromInput() unexpected error: %v", err) + } + + if nextPrice == nil || nextPrice.Sign() <= 0 { + t.Error("GetNextSqrtPriceFromInput() returned invalid price") + } + + // Verify price changed + if nextPrice.Cmp(tt.sqrtPrice) == 0 { + t.Error("GetNextSqrtPriceFromInput() price did not change") + } + + // Verify price moved in correct direction + if tt.zeroForOne { + // Swapping token0 for token1 should decrease price + if nextPrice.Cmp(tt.sqrtPrice) >= 0 { + t.Error("GetNextSqrtPriceFromInput() price should decrease for zeroForOne swap") + } + } else { + // Swapping token1 for token0 should increase price + if nextPrice.Cmp(tt.sqrtPrice) <= 0 { + t.Error("GetNextSqrtPriceFromInput() price should increase for oneForZero swap") + } + } + }) + } +} + +func TestGetNextSqrtPriceFromOutput(t *testing.T) { + tests := []struct { + name string + sqrtPrice *big.Int + liquidity *big.Int + amountOut *big.Int + zeroForOne bool + wantErr bool + }{ + { + name: "swap token0 for token1 (output token1)", + sqrtPrice: Q96, + liquidity: big.NewInt(1000000), + amountOut: big.NewInt(100), + zeroForOne: true, + wantErr: false, + }, + { + name: "swap token1 for token0 (output token0)", + sqrtPrice: Q96, + liquidity: big.NewInt(1000000), + amountOut: big.NewInt(100), + zeroForOne: false, + wantErr: false, + }, + { + name: "zero liquidity", + sqrtPrice: Q96, + liquidity: big.NewInt(0), + amountOut: big.NewInt(100), + zeroForOne: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + nextPrice, err := GetNextSqrtPriceFromOutput(tt.sqrtPrice, tt.liquidity, tt.amountOut, tt.zeroForOne) + + if tt.wantErr { + if err == nil { + t.Error("GetNextSqrtPriceFromOutput() expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("GetNextSqrtPriceFromOutput() unexpected error: %v", err) + } + + if nextPrice == nil || nextPrice.Sign() <= 0 { + t.Error("GetNextSqrtPriceFromOutput() returned invalid price") + } + }) + } +} + +func TestComputeSwapStep(t *testing.T) { + sqrtPriceCurrent := Q96 // Price = 1 + sqrtPriceTarget := new(big.Int).Lsh(big.NewInt(2), 96) // Price = 2 + liquidity := big.NewInt(1000000000000) // 1 trillion + amountRemaining := big.NewInt(1000000000000000000) // 1 ETH + feePips := uint32(3000) // 0.3% + + sqrtPriceNext, amountIn, amountOut, feeAmount, err := ComputeSwapStep( + sqrtPriceCurrent, + sqrtPriceTarget, + liquidity, + amountRemaining, + feePips, + ) + + if err != nil { + t.Fatalf("ComputeSwapStep() error: %v", err) + } + + if sqrtPriceNext == nil || sqrtPriceNext.Sign() <= 0 { + t.Error("ComputeSwapStep() returned invalid sqrtPriceNext") + } + + if amountIn == nil || amountIn.Sign() < 0 { + t.Error("ComputeSwapStep() returned invalid amountIn") + } + + if amountOut == nil || amountOut.Sign() <= 0 { + t.Error("ComputeSwapStep() returned invalid amountOut") + } + + if feeAmount == nil || feeAmount.Sign() < 0 { + t.Error("ComputeSwapStep() returned invalid feeAmount") + } + + t.Logf("Swap step results:") + t.Logf(" sqrtPriceNext: %v", sqrtPriceNext) + t.Logf(" amountIn: %v", amountIn) + t.Logf(" amountOut: %v", amountOut) + t.Logf(" feeAmount: %v", feeAmount) +} + +func TestCalculateSwapAmounts(t *testing.T) { + tests := []struct { + name string + sqrtPrice *big.Int + liquidity *big.Int + amountIn *big.Int + zeroForOne bool + feePips uint32 + wantErr bool + }{ + { + name: "swap 1 token0 for token1", + sqrtPrice: Q96, + liquidity: big.NewInt(1000000000000), + amountIn: big.NewInt(1000000000000000000), // 1 ETH + zeroForOne: true, + feePips: 3000, // 0.3% + wantErr: false, + }, + { + name: "swap 1 token1 for token0", + sqrtPrice: Q96, + liquidity: big.NewInt(1000000000000), + amountIn: big.NewInt(1000000), // 1 USDC (6 decimals) + zeroForOne: false, + feePips: 3000, + wantErr: false, + }, + { + name: "high fee tier (1%)", + sqrtPrice: Q96, + liquidity: big.NewInt(1000000000000), + amountIn: big.NewInt(1000000000000000000), + zeroForOne: true, + feePips: 10000, // 1% + wantErr: false, + }, + { + name: "low fee tier (0.05%)", + sqrtPrice: Q96, + liquidity: big.NewInt(1000000000000), + amountIn: big.NewInt(1000000000000000000), + zeroForOne: true, + feePips: 500, // 0.05% + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + amountOut, priceAfter, err := CalculateSwapAmounts( + tt.sqrtPrice, + tt.liquidity, + tt.amountIn, + tt.zeroForOne, + tt.feePips, + ) + + if tt.wantErr { + if err == nil { + t.Error("CalculateSwapAmounts() expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("CalculateSwapAmounts() unexpected error: %v", err) + } + + if amountOut == nil || amountOut.Sign() <= 0 { + t.Error("CalculateSwapAmounts() returned invalid amountOut") + } + + if priceAfter == nil || priceAfter.Sign() <= 0 { + t.Error("CalculateSwapAmounts() returned invalid priceAfter") + } + + // Verify price moved in correct direction + if tt.zeroForOne { + if priceAfter.Cmp(tt.sqrtPrice) >= 0 { + t.Error("CalculateSwapAmounts() price should decrease for zeroForOne swap") + } + } else { + if priceAfter.Cmp(tt.sqrtPrice) <= 0 { + t.Error("CalculateSwapAmounts() price should increase for oneForZero swap") + } + } + + t.Logf("Swap results:") + t.Logf(" amountIn: %v", tt.amountIn) + t.Logf(" amountOut: %v", amountOut) + t.Logf(" priceBefore: %v", tt.sqrtPrice) + t.Logf(" priceAfter: %v", priceAfter) + }) + } +} + +func TestKnownPoolState(t *testing.T) { + // Test with known values from a real Uniswap V3 pool + // Example: WETH/USDC 0.3% pool on Arbitrum + + // At tick 0, price = 1 + tick := int32(0) + sqrtPrice, err := GetSqrtRatioAtTick(tick) + if err != nil { + t.Fatalf("GetSqrtRatioAtTick() error: %v", err) + } + + // SqrtPrice at tick 0 should be approximately Q96 + expectedSqrtPrice := Q96 + tolerance := new(big.Int).Div(Q96, big.NewInt(100)) // 1% tolerance + + diff := new(big.Int).Sub(sqrtPrice, expectedSqrtPrice) + if diff.Sign() < 0 { + diff.Neg(diff) + } + + if diff.Cmp(tolerance) > 0 { + t.Errorf("SqrtPrice at tick 0 not close to Q96: got %v, want %v, diff %v", + sqrtPrice, expectedSqrtPrice, diff) + } + + // Reverse calculation should give us back tick 0 + calculatedTick, err := GetTickAtSqrtRatio(sqrtPrice) + if err != nil { + t.Fatalf("GetTickAtSqrtRatio() error: %v", err) + } + + if calculatedTick != tick && calculatedTick != tick-1 && calculatedTick != tick+1 { + t.Errorf("Tick round trip failed: original=%d, calculated=%d", tick, calculatedTick) + } +}