harb/services/ponder/src/lm.ts
openhands 3fceb4145a feat: replace tax with holders in ring buffer, add sparkline charts (#170)
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>
2026-02-22 18:56:36 +00:00

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,
});
}