package cache import ( "context" "fmt" "math/big" "sync" "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/fraktal/mev-beta/bindings/uniswap" "github.com/fraktal/mev-beta/internal/logger" ) // ReserveData holds cached reserve information for a pool type ReserveData struct { Reserve0 *big.Int Reserve1 *big.Int Liquidity *big.Int // For UniswapV3 SqrtPriceX96 *big.Int // For UniswapV3 Tick int // For UniswapV3 LastUpdated time.Time IsV3 bool } // ReserveCache provides cached access to pool reserves with TTL type ReserveCache struct { client *ethclient.Client logger *logger.Logger cache map[common.Address]*ReserveData cacheMutex sync.RWMutex ttl time.Duration cleanupStop chan struct{} // Metrics hits uint64 misses uint64 } // NewReserveCache creates a new reserve cache with the specified TTL func NewReserveCache(client *ethclient.Client, logger *logger.Logger, ttl time.Duration) *ReserveCache { rc := &ReserveCache{ client: client, logger: logger, cache: make(map[common.Address]*ReserveData), ttl: ttl, cleanupStop: make(chan struct{}), hits: 0, misses: 0, } // Start background cleanup goroutine go rc.cleanupExpiredEntries() return rc } // Get retrieves cached reserve data for a pool, or nil if not cached/expired func (rc *ReserveCache) Get(poolAddress common.Address) *ReserveData { rc.cacheMutex.RLock() defer rc.cacheMutex.RUnlock() data, exists := rc.cache[poolAddress] if !exists { rc.misses++ return nil } // Check if expired if time.Since(data.LastUpdated) > rc.ttl { rc.misses++ return nil } rc.hits++ return data } // GetOrFetch retrieves reserve data from cache, or fetches from RPC if not cached func (rc *ReserveCache) GetOrFetch(ctx context.Context, poolAddress common.Address, isV3 bool) (*ReserveData, error) { // Try cache first if cached := rc.Get(poolAddress); cached != nil { return cached, nil } // Cache miss - fetch from RPC var data *ReserveData var err error if isV3 { data, err = rc.fetchV3Reserves(ctx, poolAddress) } else { data, err = rc.fetchV2Reserves(ctx, poolAddress) } if err != nil { return nil, fmt.Errorf("failed to fetch reserves for %s: %w", poolAddress.Hex(), err) } // Cache the result rc.Set(poolAddress, data) return data, nil } // fetchV2Reserves queries UniswapV2 pool reserves via RPC func (rc *ReserveCache) fetchV2Reserves(ctx context.Context, poolAddress common.Address) (*ReserveData, error) { // Create contract binding pairContract, err := uniswap.NewIUniswapV2Pair(poolAddress, rc.client) if err != nil { return nil, fmt.Errorf("failed to bind V2 pair contract: %w", err) } // Call getReserves() reserves, err := pairContract.GetReserves(&bind.CallOpts{Context: ctx}) if err != nil { return nil, fmt.Errorf("getReserves() call failed: %w", err) } data := &ReserveData{ Reserve0: reserves.Reserve0, // Already *big.Int from contract binding Reserve1: reserves.Reserve1, // Already *big.Int from contract binding LastUpdated: time.Now(), IsV3: false, } return data, nil } // fetchV3Reserves queries UniswapV3 pool state via RPC func (rc *ReserveCache) fetchV3Reserves(ctx context.Context, poolAddress common.Address) (*ReserveData, error) { // For UniswapV3, we need to query slot0() and liquidity() // This requires the IUniswapV3Pool binding // Check if we have a V3 pool binding available // For now, return an error indicating V3 needs implementation // TODO: Implement V3 reserve calculation from slot0() and liquidity() return nil, fmt.Errorf("V3 reserve fetching not yet implemented - needs IUniswapV3Pool binding") } // Set stores reserve data in the cache func (rc *ReserveCache) Set(poolAddress common.Address, data *ReserveData) { rc.cacheMutex.Lock() defer rc.cacheMutex.Unlock() data.LastUpdated = time.Now() rc.cache[poolAddress] = data } // Invalidate removes a pool's cached data (for event-driven invalidation) func (rc *ReserveCache) Invalidate(poolAddress common.Address) { rc.cacheMutex.Lock() defer rc.cacheMutex.Unlock() delete(rc.cache, poolAddress) rc.logger.Debug(fmt.Sprintf("Invalidated cache for pool %s", poolAddress.Hex())) } // InvalidateMultiple removes multiple pools' cached data at once func (rc *ReserveCache) InvalidateMultiple(poolAddresses []common.Address) { rc.cacheMutex.Lock() defer rc.cacheMutex.Unlock() for _, addr := range poolAddresses { delete(rc.cache, addr) } rc.logger.Debug(fmt.Sprintf("Invalidated cache for %d pools", len(poolAddresses))) } // Clear removes all cached data func (rc *ReserveCache) Clear() { rc.cacheMutex.Lock() defer rc.cacheMutex.Unlock() rc.cache = make(map[common.Address]*ReserveData) rc.logger.Info("Cleared reserve cache") } // GetMetrics returns cache performance metrics func (rc *ReserveCache) GetMetrics() (hits, misses uint64, hitRate float64, size int) { rc.cacheMutex.RLock() defer rc.cacheMutex.RUnlock() total := rc.hits + rc.misses if total > 0 { hitRate = float64(rc.hits) / float64(total) * 100.0 } return rc.hits, rc.misses, hitRate, len(rc.cache) } // cleanupExpiredEntries runs in background to remove expired cache entries func (rc *ReserveCache) cleanupExpiredEntries() { ticker := time.NewTicker(rc.ttl / 2) // Cleanup at half the TTL interval defer ticker.Stop() for { select { case <-ticker.C: rc.performCleanup() case <-rc.cleanupStop: return } } } // performCleanup removes expired entries from cache func (rc *ReserveCache) performCleanup() { rc.cacheMutex.Lock() defer rc.cacheMutex.Unlock() now := time.Now() expiredCount := 0 for addr, data := range rc.cache { if now.Sub(data.LastUpdated) > rc.ttl { delete(rc.cache, addr) expiredCount++ } } if expiredCount > 0 { rc.logger.Debug(fmt.Sprintf("Cleaned up %d expired cache entries", expiredCount)) } } // Stop halts the background cleanup goroutine func (rc *ReserveCache) Stop() { close(rc.cleanupStop) } // CalculateV3ReservesFromState calculates effective reserves for V3 pool from liquidity and price // This is a helper function for when we have liquidity and sqrtPriceX96 but need reserve values func CalculateV3ReservesFromState(liquidity, sqrtPriceX96 *big.Int) (*big.Int, *big.Int) { // For UniswapV3, reserves are not stored directly but can be approximated from: // reserve0 = liquidity / sqrt(price) // reserve1 = liquidity * sqrt(price) // Convert sqrtPriceX96 to sqrtPrice (divide by 2^96) q96 := new(big.Float).SetInt(new(big.Int).Exp(big.NewInt(2), big.NewInt(96), nil)) sqrtPriceFloat := new(big.Float).SetInt(sqrtPriceX96) sqrtPrice := new(big.Float).Quo(sqrtPriceFloat, q96) liquidityFloat := new(big.Float).SetInt(liquidity) // Calculate reserve0 = liquidity / sqrtPrice reserve0Float := new(big.Float).Quo(liquidityFloat, sqrtPrice) // Calculate reserve1 = liquidity * sqrtPrice reserve1Float := new(big.Float).Mul(liquidityFloat, sqrtPrice) // Convert back to big.Int reserve0 := new(big.Int) reserve1 := new(big.Int) reserve0Float.Int(reserve0) reserve1Float.Int(reserve1) return reserve0, reserve1 }