import { ponder } from 'ponder:registry'; import { getLogger } from './helpers/logger'; import { recenters, stats, STATS_ID, HOURS_IN_RING_BUFFER } from 'ponder:schema'; import { ensureStatsExists, recordEthReserveSnapshot, parseRingBuffer, RING_BUFFER_SEGMENTS } from './helpers/stats'; /** * Fee tracking approach: * * Option 1 (not implemented): Index Uniswap V3 Pool Collect events * - Pros: Accurate fee data directly from the pool * - Cons: Requires adding pool contract to ponder.config.ts, forcing a full resync * * Option 2 (not implemented): Derive from ETH balance changes * - Pros: No config changes needed * - Cons: Less accurate, hard to isolate fees from other balance changes * * The feesEarned7dEth and feesEarned7dKrk fields default to 0n until implemented. */ /** * Calculate price in wei per KRK token from a Uniswap V3 tick * For WETH/KRK pool where WETH is token0: * - price = amount1/amount0 = 1.0001^tick * - This gives KRK per WETH * - We want wei per KRK, so we invert and scale */ function priceFromTick(tick: number): bigint { // Calculate 1.0001^tick using floating point const price = Math.pow(1.0001, tick); // Price is KRK/WETH, we want WEI per KRK // Since both tokens have 18 decimals, we need to invert // priceWei = (10^18) / price const priceWei = 10 ** 18 / price; return BigInt(Math.floor(priceWei)); } /** * Calculate basis points difference between two values * bps = (new - old) / old * 10000 */ function calculateBps(newValue: bigint, oldValue: bigint): number { if (oldValue === 0n) return 0; const diff = newValue - oldValue; const bps = (Number(diff) * 10000) / Number(oldValue); return Math.floor(bps); } /** * Handle LiquidityManager Recentered events * Creates a new recenter record and updates stats. * NOTE: Recenter day/week counts are simple incrementing counters. * For accurate rolling windows, the API layer can query the recenters table directly. */ ponder.on('LiquidityManager:Recentered', async ({ event, context }) => { await ensureStatsExists(context, event.block.timestamp); const { currentTick, isUp } = event.args; const recenterId = `${event.block.number}_${event.log.logIndex}`; // Insert recenter record (ethBalance populated below after read) await context.db.insert(recenters).values({ id: recenterId, timestamp: event.block.timestamp, currentTick: Number(currentTick), isUp, ethBalance: null, outstandingSupply: null, vwapTick: null, }); // Update stats — increment counters (simple approach; API can do accurate rolling queries) const statsData = await context.db.find(stats, { id: STATS_ID }); if (!statsData) return; await context.db.update(stats, { id: STATS_ID }).set({ lastRecenterTimestamp: event.block.timestamp, lastRecenterTick: Number(currentTick), recentersLastDay: (statsData.recentersLastDay ?? 0) + 1, recentersLastWeek: (statsData.recentersLastWeek ?? 0) + 1, }); }); /** * Handle LiquidityManager EthScarcity events * Updates the most recent recenter record with ETH reserve and VWAP data * FIXED: Search for matching recenter by block + tick instead of assuming logIndex - 1 */ ponder.on('LiquidityManager:EthScarcity', async ({ event, context }) => { const { currentTick, ethBalance, outstandingSupply, vwapTick } = event.args; // Strategy: Try logIndex-1 first (common case), then search by block+tick (fallback) // This handles both the common case efficiently and edge cases correctly let recenterId = `${event.block.number}_${event.log.logIndex - 1}`; let recenter = await context.db.find(recenters, { id: recenterId }); // If logIndex-1 didn't work, search for matching recenter in same block by tick if (!recenter) { getLogger(context).warn(`EthScarcity: logIndex-1 failed for block ${event.block.number}. Searching by tick ${currentTick}...`); // Fallback: scan recent recenters from this block with matching tick // Build candidate IDs to check (scan backwards from current logIndex) for (let offset = 2; offset <= 10 && offset <= event.log.logIndex; offset++) { const candidateId = `${event.block.number}_${event.log.logIndex - offset}`; const candidate = await context.db.find(recenters, { id: candidateId }); if (candidate && candidate.currentTick === Number(currentTick)) { recenter = candidate; recenterId = candidateId; getLogger(context).info(`EthScarcity: Found matching recenter at offset -${offset}`); break; } } } if (recenter) { await context.db.update(recenters, { id: recenterId }).set({ ethBalance, outstandingSupply, vwapTick: Number(vwapTick), }); } else { getLogger(context).error( `EthScarcity: No matching Recentered event found for block ${event.block.number}, tick ${currentTick}, logIndex ${event.log.logIndex}` ); } // Update stats with reserve data, floor price, and 7d growth await updateReserveStats(context, event, ethBalance, currentTick, vwapTick); }); /** * Handle LiquidityManager EthAbundance events * Updates the most recent recenter record with ETH reserve and VWAP data * FIXED: Search for matching recenter by block + tick instead of assuming logIndex - 1 */ ponder.on('LiquidityManager:EthAbundance', async ({ event, context }) => { const { currentTick, ethBalance, outstandingSupply, vwapTick } = event.args; // Strategy: Try logIndex-1 first (common case), then search by block+tick (fallback) // This handles both the common case efficiently and edge cases correctly let recenterId = `${event.block.number}_${event.log.logIndex - 1}`; let recenter = await context.db.find(recenters, { id: recenterId }); // If logIndex-1 didn't work, search for matching recenter in same block by tick if (!recenter) { getLogger(context).warn(`EthAbundance: logIndex-1 failed for block ${event.block.number}. Searching by tick ${currentTick}...`); // Fallback: scan recent recenters from this block with matching tick // Build candidate IDs to check (scan backwards from current logIndex) for (let offset = 2; offset <= 10 && offset <= event.log.logIndex; offset++) { const candidateId = `${event.block.number}_${event.log.logIndex - offset}`; const candidate = await context.db.find(recenters, { id: candidateId }); if (candidate && candidate.currentTick === Number(currentTick)) { recenter = candidate; recenterId = candidateId; getLogger(context).info(`EthAbundance: Found matching recenter at offset -${offset}`); break; } } } if (recenter) { await context.db.update(recenters, { id: recenterId }).set({ ethBalance, outstandingSupply, vwapTick: Number(vwapTick), }); } else { getLogger(context).error( `EthAbundance: No matching Recentered event found for block ${event.block.number}, tick ${currentTick}, logIndex ${event.log.logIndex}` ); } // Update stats with reserve data, floor price, and 7d growth await updateReserveStats(context, event, ethBalance, currentTick, vwapTick); }); /** * Shared logic for EthScarcity and EthAbundance handlers: * Records ETH reserve in ring buffer, calculates 7d growth from ring buffer, floor price, and updates stats. */ async function updateReserveStats( // eslint-disable-next-line @typescript-eslint/no-explicit-any context: { db: any; log: any }, event: { block: { number: bigint; timestamp: bigint }; log: { logIndex: number } }, ethBalance: bigint, currentTick: number | bigint, vwapTick: number | bigint ) { // Record ETH reserve in ring buffer for hourly time-series await recordEthReserveSnapshot(context, event.block.timestamp, ethBalance); // Compute 7d growth from ring buffer (slot 0 = ethReserve snapshots) const statsData = await context.db.find(stats, { id: STATS_ID }); let ethReserve7dAgo: bigint | null = null; let ethReserveGrowthBps: number | null = null; if (statsData) { const ringBuffer = parseRingBuffer(statsData.ringBuffer as string[]); const pointer = statsData.ringBufferPointer ?? 0; // Walk backwards through ring buffer to find oldest non-zero ETH reserve for (let i = HOURS_IN_RING_BUFFER - 1; i >= 0; i--) { const baseIndex = ((pointer - i + HOURS_IN_RING_BUFFER) % HOURS_IN_RING_BUFFER) * RING_BUFFER_SEGMENTS; const reserve = ringBuffer[baseIndex + 0]; if (reserve > 0n) { ethReserve7dAgo = reserve; break; } } if (ethReserve7dAgo && ethReserve7dAgo > 0n) { ethReserveGrowthBps = calculateBps(ethBalance, ethReserve7dAgo); } } // Calculate floor price (from vwapTick) and current price (from currentTick) const floorTick = Number(vwapTick); const floorPriceWei = priceFromTick(floorTick); const currentPriceWei = priceFromTick(Number(currentTick)); // Calculate distance from floor in basis points const floorDistanceBps = calculateBps(currentPriceWei, floorPriceWei); // Update stats with all metrics await context.db.update(stats, { id: STATS_ID }).set({ lastEthReserve: ethBalance, lastVwapTick: Number(vwapTick), ethReserve7dAgo, ethReserveGrowthBps, floorTick, floorPriceWei, currentPriceWei, floorDistanceBps, }); }