Replace UBI with ETH reserve in ring buffer, fix Dockerfile HEALTHCHECK, enhance LiveStats (#154)

This commit is contained in:
johba 2026-02-19 14:47:15 +01:00
parent 31063379a8
commit 76b2635e63
16 changed files with 2028 additions and 89 deletions

View file

@ -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),

View 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;
}

View file

@ -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;
}

View file

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

View file

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