Ring buffer slot 3 now stores holderCount snapshots instead of tax deltas. Tax tracking simplified to a totalTaxPaid counter on the stats record. Removed unbounded ethReserveHistory and feeHistory tables; 7d ETH reserve growth is now computed from the ring buffer. LiveStats renders inline SVG sparklines for ETH reserve, supply, and holders with holder growth %. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
237 lines
9.1 KiB
TypeScript
237 lines
9.1 KiB
TypeScript
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,
|
|
});
|
|
}
|