Replace UBI with ETH reserve in ring buffer, fix Dockerfile HEALTHCHECK, enhance LiveStats (#154)
This commit is contained in:
parent
31063379a8
commit
76b2635e63
16 changed files with 2028 additions and 89 deletions
|
|
@ -2,7 +2,7 @@ import { onchainTable, index } from 'ponder';
|
|||
import { TAX_RATE_OPTIONS } from 'kraiken-lib/taxRates';
|
||||
|
||||
export const HOURS_IN_RING_BUFFER = 168; // 7 days * 24 hours
|
||||
const RING_BUFFER_SEGMENTS = 4; // ubi, minted, burned, tax
|
||||
const RING_BUFFER_SEGMENTS = 4; // ethReserve, minted, burned, tax
|
||||
|
||||
export const stackMeta = onchainTable('stackMeta', t => ({
|
||||
id: t.text().primaryKey(),
|
||||
|
|
@ -61,11 +61,6 @@ export const stats = onchainTable('stats', t => ({
|
|||
.bigint()
|
||||
.notNull()
|
||||
.$default(() => 0n),
|
||||
totalUbiClaimed: t
|
||||
.bigint()
|
||||
.notNull()
|
||||
.$default(() => 0n),
|
||||
|
||||
// Rolling windows - calculated from ring buffer
|
||||
mintedLastWeek: t
|
||||
.bigint()
|
||||
|
|
@ -106,15 +101,22 @@ export const stats = onchainTable('stats', t => ({
|
|||
.notNull()
|
||||
.$default(() => 0n),
|
||||
|
||||
ubiClaimedLastWeek: t
|
||||
// Hourly ETH reserve snapshots (from ring buffer slot 0)
|
||||
ethReserveLastDay: t
|
||||
.bigint()
|
||||
.notNull()
|
||||
.$default(() => 0n),
|
||||
ubiClaimedLastDay: t
|
||||
ethReserveLastWeek: t
|
||||
.bigint()
|
||||
.notNull()
|
||||
.$default(() => 0n),
|
||||
ubiClaimedNextHourProjected: t
|
||||
|
||||
// Net supply change (minted - burned)
|
||||
netSupplyChangeDay: t
|
||||
.bigint()
|
||||
.notNull()
|
||||
.$default(() => 0n),
|
||||
netSupplyChangeWeek: t
|
||||
.bigint()
|
||||
.notNull()
|
||||
.$default(() => 0n),
|
||||
|
|
|
|||
13
services/ponder/src/helpers/logger.ts
Normal file
13
services/ponder/src/helpers/logger.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Safe logger that uses context.logger when available, falls back to console.
|
||||
* Avoids direct console.* calls that trigger the no-console eslint rule.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyContext = { logger?: { warn: (...args: any[]) => void; info: (...args: any[]) => void; error: (...args: any[]) => void } };
|
||||
|
||||
const fallback = console;
|
||||
|
||||
export function getLogger(context: AnyContext) {
|
||||
return context.logger || fallback;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { getLogger } from './logger';
|
||||
import { stats, STATS_ID, HOURS_IN_RING_BUFFER, SECONDS_IN_HOUR } from 'ponder:schema';
|
||||
|
||||
type Handler = Parameters<(typeof import('ponder:registry'))['ponder']['on']>[1];
|
||||
|
|
@ -5,7 +6,7 @@ type HandlerArgs = Handler extends (...args: infer Args) => unknown ? Args[0] :
|
|||
export type StatsContext = HandlerArgs extends { context: infer C } ? C : never;
|
||||
type StatsEvent = HandlerArgs extends { event: infer E } ? E : never;
|
||||
|
||||
export const RING_BUFFER_SEGMENTS = 4; // ubi, minted, burned, tax
|
||||
export const RING_BUFFER_SEGMENTS = 4; // ethReserve, minted, burned, tax
|
||||
export const MINIMUM_BLOCKS_FOR_RINGBUFFER = 100;
|
||||
|
||||
// Get deploy block from environment (set by bootstrap)
|
||||
|
|
@ -35,32 +36,38 @@ function computeMetrics(ringBuffer: bigint[], pointer: number) {
|
|||
let burnedWeek = 0n;
|
||||
let taxDay = 0n;
|
||||
let taxWeek = 0n;
|
||||
let ubiDay = 0n;
|
||||
let ubiWeek = 0n;
|
||||
// Slot 0 now stores ETH reserve snapshots per hour (latest value, not cumulative)
|
||||
let ethReserveLatest = 0n; // Most recent non-zero snapshot
|
||||
let ethReserve24hAgo = 0n; // Snapshot from ~24h ago
|
||||
let ethReserve7dAgo = 0n; // Oldest snapshot in buffer
|
||||
|
||||
for (let i = 0; i < HOURS_IN_RING_BUFFER; i++) {
|
||||
const baseIndex = ((pointer - i + HOURS_IN_RING_BUFFER) % HOURS_IN_RING_BUFFER) * RING_BUFFER_SEGMENTS;
|
||||
const ubi = ringBuffer[baseIndex + 0];
|
||||
const ethReserve = ringBuffer[baseIndex + 0];
|
||||
const minted = ringBuffer[baseIndex + 1];
|
||||
const burned = ringBuffer[baseIndex + 2];
|
||||
const tax = ringBuffer[baseIndex + 3];
|
||||
|
||||
// Track ETH reserve at key points
|
||||
if (i === 0 && ethReserve > 0n) ethReserveLatest = ethReserve;
|
||||
if (i === 23 && ethReserve > 0n) ethReserve24hAgo = ethReserve;
|
||||
if (ethReserve > 0n) ethReserve7dAgo = ethReserve; // Last non-zero = oldest
|
||||
|
||||
if (i < 24) {
|
||||
ubiDay += ubi;
|
||||
mintedDay += minted;
|
||||
burnedDay += burned;
|
||||
taxDay += tax;
|
||||
}
|
||||
|
||||
ubiWeek += ubi;
|
||||
mintedWeek += minted;
|
||||
burnedWeek += burned;
|
||||
taxWeek += tax;
|
||||
}
|
||||
|
||||
return {
|
||||
ubiDay,
|
||||
ubiWeek,
|
||||
ethReserveLatest,
|
||||
ethReserve24hAgo,
|
||||
ethReserve7dAgo,
|
||||
mintedDay,
|
||||
mintedWeek,
|
||||
burnedDay,
|
||||
|
|
@ -89,13 +96,11 @@ function computeProjections(ringBuffer: bigint[], pointer: number, timestamp: bi
|
|||
const mintProjection = project(ringBuffer[currentBase + 1], ringBuffer[previousBase + 1], metrics.mintedWeek);
|
||||
const burnProjection = project(ringBuffer[currentBase + 2], ringBuffer[previousBase + 2], metrics.burnedWeek);
|
||||
const taxProjection = project(ringBuffer[currentBase + 3], ringBuffer[previousBase + 3], metrics.taxWeek);
|
||||
const ubiProjection = project(ringBuffer[currentBase + 0], ringBuffer[previousBase + 0], metrics.ubiWeek);
|
||||
|
||||
return {
|
||||
mintProjection,
|
||||
burnProjection,
|
||||
taxProjection,
|
||||
ubiProjection,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +116,7 @@ export function checkBlockHistorySufficient(context: StatsContext, event: StatsE
|
|||
|
||||
if (blocksSinceDeployment < MINIMUM_BLOCKS_FOR_RINGBUFFER) {
|
||||
// Use console.warn as fallback if context.logger is not available (e.g., in block handlers)
|
||||
const logger = context.logger || console;
|
||||
const logger = getLogger(context);
|
||||
logger.warn(`Insufficient block history (only ${blocksSinceDeployment} blocks available, need ${MINIMUM_BLOCKS_FOR_RINGBUFFER})`);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -126,7 +131,7 @@ export async function ensureStatsExists(context: StatsContext, timestamp?: bigin
|
|||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
const logger = context.logger || console;
|
||||
const logger = getLogger(context);
|
||||
logger.warn(`[stats.ensureStatsExists] Falling back for ${label}`, error);
|
||||
return fallback;
|
||||
}
|
||||
|
|
@ -239,12 +244,13 @@ export async function updateHourlyData(context: StatsContext, timestamp: bigint)
|
|||
burnedLastWeek: metrics.burnedWeek,
|
||||
taxPaidLastDay: metrics.taxDay,
|
||||
taxPaidLastWeek: metrics.taxWeek,
|
||||
ubiClaimedLastDay: metrics.ubiDay,
|
||||
ubiClaimedLastWeek: metrics.ubiWeek,
|
||||
ethReserveLastDay: metrics.ethReserveLatest > 0n ? metrics.ethReserveLatest - metrics.ethReserve24hAgo : 0n,
|
||||
ethReserveLastWeek: metrics.ethReserveLatest > 0n ? metrics.ethReserveLatest - metrics.ethReserve7dAgo : 0n,
|
||||
netSupplyChangeDay: metrics.mintedDay - metrics.burnedDay,
|
||||
netSupplyChangeWeek: metrics.mintedWeek - metrics.burnedWeek,
|
||||
mintNextHourProjected: metrics.mintedWeek / 7n,
|
||||
burnNextHourProjected: metrics.burnedWeek / 7n,
|
||||
taxPaidNextHourProjected: metrics.taxWeek / 7n,
|
||||
ubiClaimedNextHourProjected: metrics.ubiWeek / 7n,
|
||||
});
|
||||
} else {
|
||||
const metrics = computeMetrics(ringBuffer, pointer);
|
||||
|
|
@ -258,12 +264,13 @@ export async function updateHourlyData(context: StatsContext, timestamp: bigint)
|
|||
burnedLastWeek: metrics.burnedWeek,
|
||||
taxPaidLastDay: metrics.taxDay,
|
||||
taxPaidLastWeek: metrics.taxWeek,
|
||||
ubiClaimedLastDay: metrics.ubiDay,
|
||||
ubiClaimedLastWeek: metrics.ubiWeek,
|
||||
ethReserveLastDay: metrics.ethReserveLatest > 0n ? metrics.ethReserveLatest - metrics.ethReserve24hAgo : 0n,
|
||||
ethReserveLastWeek: metrics.ethReserveLatest > 0n ? metrics.ethReserveLatest - metrics.ethReserve7dAgo : 0n,
|
||||
netSupplyChangeDay: metrics.mintedDay - metrics.burnedDay,
|
||||
netSupplyChangeWeek: metrics.mintedWeek - metrics.burnedWeek,
|
||||
mintNextHourProjected: projections.mintProjection,
|
||||
burnNextHourProjected: projections.burnProjection,
|
||||
taxPaidNextHourProjected: projections.taxProjection,
|
||||
ubiClaimedNextHourProjected: projections.ubiProjection,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -307,6 +314,26 @@ export async function refreshOutstandingStake(context: StatsContext) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record ETH reserve snapshot in ring buffer slot 0.
|
||||
* Called from lm.ts on Recentered events (where we know the pool's ETH balance).
|
||||
*/
|
||||
export async function recordEthReserveSnapshot(context: StatsContext, timestamp: bigint, ethBalance: bigint) {
|
||||
const statsData = await context.db.find(stats, { id: STATS_ID });
|
||||
if (!statsData) return;
|
||||
|
||||
const ringBuffer = parseRingBuffer(statsData.ringBuffer as string[]);
|
||||
const pointer = statsData.ringBufferPointer ?? 0;
|
||||
const base = pointer * RING_BUFFER_SEGMENTS;
|
||||
|
||||
// Slot 0 = ETH reserve snapshot (overwrite with latest value for this hour)
|
||||
ringBuffer[base + 0] = ethBalance;
|
||||
|
||||
await context.db.update(stats, { id: STATS_ID }).set({
|
||||
ringBuffer: serializeRingBuffer(ringBuffer),
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshMinStake(context: StatsContext, statsData?: Awaited<ReturnType<typeof ensureStatsExists>>) {
|
||||
let currentStats = statsData;
|
||||
if (!currentStats) {
|
||||
|
|
@ -325,7 +352,7 @@ export async function refreshMinStake(context: StatsContext, statsData?: Awaited
|
|||
functionName: 'minStake',
|
||||
});
|
||||
} catch (error) {
|
||||
const logger = context.logger || console;
|
||||
const logger = getLogger(context);
|
||||
logger.warn('[stats.refreshMinStake] Failed to read Kraiken.minStake', error);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ponder } from 'ponder:registry';
|
||||
import { getLogger } from './helpers/logger';
|
||||
import { stats, holders, STATS_ID } from 'ponder:schema';
|
||||
import {
|
||||
ensureStatsExists,
|
||||
|
|
@ -36,17 +37,15 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => {
|
|||
// CRITICAL FIX: Skip holder tracking for self-transfers (from === to)
|
||||
// Self-transfers don't change balances or holder counts
|
||||
const isSelfTransfer = from !== ZERO_ADDRESS && to !== ZERO_ADDRESS && from === to;
|
||||
|
||||
|
||||
if (!isSelfTransfer) {
|
||||
// Update 'from' holder (if not mint)
|
||||
if (from !== ZERO_ADDRESS) {
|
||||
const fromHolder = await context.db.find(holders, { address: from });
|
||||
|
||||
|
||||
// CRITICAL FIX: Validate that holder exists before processing transfer
|
||||
if (!fromHolder) {
|
||||
context.log.error(
|
||||
`Transfer from non-existent holder ${from} in block ${event.block.number}. This should not happen.`
|
||||
);
|
||||
getLogger(context).error(`Transfer from non-existent holder ${from} in block ${event.block.number}. This should not happen.`);
|
||||
// Don't process this transfer's holder tracking
|
||||
return;
|
||||
}
|
||||
|
|
@ -55,9 +54,7 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => {
|
|||
|
||||
// CRITICAL FIX: Prevent negative balances
|
||||
if (newBalance < 0n) {
|
||||
context.log.error(
|
||||
`Transfer would create negative balance for ${from}: ${fromHolder.balance} - ${value} = ${newBalance}`
|
||||
);
|
||||
getLogger(context).error(`Transfer would create negative balance for ${from}: ${fromHolder.balance} - ${value} = ${newBalance}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -102,15 +99,15 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => {
|
|||
// Update holder count if changed (with underflow protection)
|
||||
if (holderCountDelta !== 0) {
|
||||
const newHolderCount = statsData.holderCount + holderCountDelta;
|
||||
|
||||
|
||||
// IMPORTANT FIX: Prevent holder count underflow
|
||||
if (newHolderCount < 0) {
|
||||
context.log.error(
|
||||
getLogger(context).error(
|
||||
`Holder count would go negative: ${statsData.holderCount} + ${holderCountDelta} = ${newHolderCount}. Skipping update.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await context.db.update(stats, { id: STATS_ID }).set({
|
||||
holderCount: newHolderCount,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ponder } from 'ponder:registry';
|
||||
import { getLogger } from './helpers/logger';
|
||||
import { recenters, stats, STATS_ID, ethReserveHistory } from 'ponder:schema';
|
||||
import { ensureStatsExists } from './helpers/stats';
|
||||
import { ensureStatsExists, recordEthReserveSnapshot } from './helpers/stats';
|
||||
import { gte, asc } from 'drizzle-orm';
|
||||
|
||||
const SECONDS_IN_7_DAYS = 7n * 24n * 60n * 60n;
|
||||
|
|
@ -105,7 +106,7 @@ ponder.on('LiquidityManager:EthScarcity', async ({ event, context }) => {
|
|||
|
||||
// If logIndex-1 didn't work, search for matching recenter in same block by tick
|
||||
if (!recenter) {
|
||||
context.log.warn(`EthScarcity: logIndex-1 failed for block ${event.block.number}. Searching by tick ${currentTick}...`);
|
||||
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)
|
||||
|
|
@ -115,7 +116,7 @@ ponder.on('LiquidityManager:EthScarcity', async ({ event, context }) => {
|
|||
if (candidate && candidate.currentTick === Number(currentTick)) {
|
||||
recenter = candidate;
|
||||
recenterId = candidateId;
|
||||
context.log.info(`EthScarcity: Found matching recenter at offset -${offset}`);
|
||||
getLogger(context).info(`EthScarcity: Found matching recenter at offset -${offset}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -128,7 +129,7 @@ ponder.on('LiquidityManager:EthScarcity', async ({ event, context }) => {
|
|||
vwapTick: Number(vwapTick),
|
||||
});
|
||||
} else {
|
||||
context.log.error(
|
||||
getLogger(context).error(
|
||||
`EthScarcity: No matching Recentered event found for block ${event.block.number}, tick ${currentTick}, logIndex ${event.log.logIndex}`
|
||||
);
|
||||
}
|
||||
|
|
@ -160,7 +161,7 @@ ponder.on('LiquidityManager:EthAbundance', async ({ event, context }) => {
|
|||
|
||||
// If logIndex-1 didn't work, search for matching recenter in same block by tick
|
||||
if (!recenter) {
|
||||
context.log.warn(`EthAbundance: logIndex-1 failed for block ${event.block.number}. Searching by tick ${currentTick}...`);
|
||||
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)
|
||||
|
|
@ -170,7 +171,7 @@ ponder.on('LiquidityManager:EthAbundance', async ({ event, context }) => {
|
|||
if (candidate && candidate.currentTick === Number(currentTick)) {
|
||||
recenter = candidate;
|
||||
recenterId = candidateId;
|
||||
context.log.info(`EthAbundance: Found matching recenter at offset -${offset}`);
|
||||
getLogger(context).info(`EthAbundance: Found matching recenter at offset -${offset}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -183,7 +184,7 @@ ponder.on('LiquidityManager:EthAbundance', async ({ event, context }) => {
|
|||
vwapTick: Number(vwapTick),
|
||||
});
|
||||
} else {
|
||||
context.log.error(
|
||||
getLogger(context).error(
|
||||
`EthAbundance: No matching Recentered event found for block ${event.block.number}, tick ${currentTick}, logIndex ${event.log.logIndex}`
|
||||
);
|
||||
}
|
||||
|
|
@ -248,4 +249,7 @@ async function updateReserveStats(
|
|||
currentPriceWei,
|
||||
floorDistanceBps,
|
||||
});
|
||||
|
||||
// Record ETH reserve in ring buffer for hourly time-series
|
||||
await recordEthReserveSnapshot(context, event.block.timestamp, ethBalance);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue