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>
This commit is contained in:
openhands 2026-02-22 11:10:50 +00:00
parent 4206f3bc63
commit 3fceb4145a
5 changed files with 179 additions and 113 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; // ethReserve, minted, burned, tax
const RING_BUFFER_SEGMENTS = 4; // ethReserve, minted, burned, holderCount
export const stackMeta = onchainTable('stackMeta', t => ({
id: t.text().primaryKey(),
@ -188,21 +188,6 @@ export const stats = onchainTable('stats', t => ({
floorDistanceBps: t.integer(),
}));
// ETH reserve history - tracks ethBalance over time for 7d growth calculation
export const ethReserveHistory = onchainTable('ethReserveHistory', t => ({
id: t.text().primaryKey(), // block_logIndex format
timestamp: t.bigint().notNull(),
ethBalance: t.bigint().notNull(),
}));
// Fee history - tracks fees earned over time for 7d totals
export const feeHistory = onchainTable('feeHistory', t => ({
id: t.text().primaryKey(), // block_logIndex format
timestamp: t.bigint().notNull(),
ethFees: t.bigint().notNull(),
krkFees: t.bigint().notNull(),
}));
// Individual staking positions
export const positions = onchainTable(
'positions',

View file

@ -6,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; // ethReserve, minted, burned, tax
export const RING_BUFFER_SEGMENTS = 4; // ethReserve, minted, burned, holderCount
export const MINIMUM_BLOCKS_FOR_RINGBUFFER = 100;
// Get deploy block from environment (set by bootstrap)
@ -34,46 +34,52 @@ function computeMetrics(ringBuffer: bigint[], pointer: number) {
let mintedWeek = 0n;
let burnedDay = 0n;
let burnedWeek = 0n;
let taxDay = 0n;
let taxWeek = 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
// Slot 0: ETH reserve snapshots per hour (latest value, not cumulative)
let ethReserveLatest = 0n;
let ethReserve24hAgo = 0n;
let ethReserve7dAgo = 0n;
// Slot 3: holderCount snapshots per hour
let holderCountLatest = 0n;
let holderCount24hAgo = 0n;
let holderCount7dAgo = 0n;
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 ethReserve = ringBuffer[baseIndex + 0];
const minted = ringBuffer[baseIndex + 1];
const burned = ringBuffer[baseIndex + 2];
const tax = ringBuffer[baseIndex + 3];
const holderCount = 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
// Track holder count at key points
if (i === 0 && holderCount > 0n) holderCountLatest = holderCount;
if (i === 23 && holderCount > 0n) holderCount24hAgo = holderCount;
if (holderCount > 0n) holderCount7dAgo = holderCount; // Last non-zero = oldest
if (i < 24) {
mintedDay += minted;
burnedDay += burned;
taxDay += tax;
}
mintedWeek += minted;
burnedWeek += burned;
taxWeek += tax;
}
return {
ethReserveLatest,
ethReserve24hAgo,
ethReserve7dAgo,
holderCountLatest,
holderCount24hAgo,
holderCount7dAgo,
mintedDay,
mintedWeek,
burnedDay,
burnedWeek,
taxDay,
taxWeek,
};
}
@ -95,12 +101,10 @@ 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);
return {
mintProjection,
burnProjection,
taxProjection,
};
}
@ -211,6 +215,11 @@ export async function updateHourlyData(context: StatsContext, timestamp: bigint)
let pointer = statsData.ringBufferPointer ?? 0;
const lastUpdate = statsData.lastHourlyUpdateTimestamp ?? 0n;
// Snapshot current holderCount into ring buffer slot 3
const currentHolderCount = BigInt(statsData.holderCount ?? 0);
const base = pointer * RING_BUFFER_SEGMENTS;
ringBuffer[base + 3] = currentHolderCount;
if (lastUpdate === 0n) {
await context.db.update(stats, { id: STATS_ID }).set({
lastHourlyUpdateTimestamp: currentHour,
@ -225,11 +234,11 @@ export async function updateHourlyData(context: StatsContext, timestamp: bigint)
for (let h = 0; h < hoursElapsed; h++) {
pointer = (pointer + 1) % HOURS_IN_RING_BUFFER;
const base = pointer * RING_BUFFER_SEGMENTS;
ringBuffer[base + 0] = 0n;
ringBuffer[base + 1] = 0n;
ringBuffer[base + 2] = 0n;
ringBuffer[base + 3] = 0n;
const newBase = pointer * RING_BUFFER_SEGMENTS;
ringBuffer[newBase + 0] = 0n;
ringBuffer[newBase + 1] = 0n;
ringBuffer[newBase + 2] = 0n;
ringBuffer[newBase + 3] = currentHolderCount; // Carry forward current holderCount
}
const metrics = computeMetrics(ringBuffer, pointer);
@ -242,15 +251,12 @@ export async function updateHourlyData(context: StatsContext, timestamp: bigint)
mintedLastWeek: metrics.mintedWeek,
burnedLastDay: metrics.burnedDay,
burnedLastWeek: metrics.burnedWeek,
taxPaidLastDay: metrics.taxDay,
taxPaidLastWeek: metrics.taxWeek,
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,
});
} else {
const metrics = computeMetrics(ringBuffer, pointer);
@ -262,15 +268,12 @@ export async function updateHourlyData(context: StatsContext, timestamp: bigint)
mintedLastWeek: metrics.mintedWeek,
burnedLastDay: metrics.burnedDay,
burnedLastWeek: metrics.burnedWeek,
taxPaidLastDay: metrics.taxDay,
taxPaidLastWeek: metrics.taxWeek,
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,
});
}
}

View file

@ -1,10 +1,7 @@
import { ponder } from 'ponder:registry';
import { getLogger } from './helpers/logger';
import { recenters, stats, STATS_ID, ethReserveHistory } from 'ponder:schema';
import { ensureStatsExists, recordEthReserveSnapshot } from './helpers/stats';
import { gte, asc } from 'drizzle-orm';
const SECONDS_IN_7_DAYS = 7n * 24n * 60n * 60n;
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:
@ -17,12 +14,6 @@ const SECONDS_IN_7_DAYS = 7n * 24n * 60n * 60n;
* - Pros: No config changes needed
* - Cons: Less accurate, hard to isolate fees from other balance changes
*
* Current: Fee tracking infrastructure (feeHistory table, stats fields) is in place
* but not populated. To implement:
* 1. Add UniswapV3Pool contract to ponder.config.ts with Collect event
* 2. Handle Collect events to populate feeHistory table
* 3. Calculate 7-day rolling totals from feeHistory
*
* The feesEarned7dEth and feesEarned7dKrk fields default to 0n until implemented.
*/
@ -134,14 +125,6 @@ ponder.on('LiquidityManager:EthScarcity', async ({ event, context }) => {
);
}
// Record ETH reserve to history for 7d growth tracking
const historyId = `${event.block.number}_${event.log.logIndex}`;
await context.db.insert(ethReserveHistory).values({
id: historyId,
timestamp: event.block.timestamp,
ethBalance,
});
// Update stats with reserve data, floor price, and 7d growth
await updateReserveStats(context, event, ethBalance, currentTick, vwapTick);
});
@ -195,7 +178,7 @@ ponder.on('LiquidityManager:EthAbundance', async ({ event, context }) => {
/**
* Shared logic for EthScarcity and EthAbundance handlers:
* Records ETH reserve history, calculates 7d growth, floor price, and updates stats.
* 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
@ -205,29 +188,31 @@ async function updateReserveStats(
currentTick: number | bigint,
vwapTick: number | bigint
) {
// Record ETH reserve to history for 7d growth tracking
const historyId = `${event.block.number}_${event.log.logIndex}`;
await context.db.insert(ethReserveHistory).values({
id: historyId,
timestamp: event.block.timestamp,
ethBalance,
});
// Look back 7 days for growth calculation using raw Drizzle query
const sevenDaysAgo = event.block.timestamp - SECONDS_IN_7_DAYS;
const oldReserves = await context.db.sql
.select()
.from(ethReserveHistory)
.where(gte(ethReserveHistory.timestamp, sevenDaysAgo))
.orderBy(asc(ethReserveHistory.timestamp))
.limit(1);
// 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 (oldReserves.length > 0 && oldReserves[0]) {
ethReserve7dAgo = oldReserves[0].ethBalance;
ethReserveGrowthBps = calculateBps(ethBalance, ethReserve7dAgo);
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)
@ -249,7 +234,4 @@ async function updateReserveStats(
currentPriceWei,
floorDistanceBps,
});
// Record ETH reserve in ring buffer for hourly time-series
await recordEthReserveSnapshot(context, event.block.timestamp, ethBalance);
}

View file

@ -4,12 +4,9 @@ import {
ensureStatsExists,
getStakeTotalSupply,
markPositionsUpdated,
parseRingBuffer,
refreshOutstandingStake,
serializeRingBuffer,
updateHourlyData,
checkBlockHistorySufficient,
RING_BUFFER_SEGMENTS,
} from './helpers/stats';
import type { StatsContext } from './helpers/stats';
@ -154,31 +151,16 @@ ponder.on('Stake:PositionTaxPaid', async ({ event, context }) => {
lastTaxTime: event.block.timestamp,
});
// Only update ringbuffer if we have sufficient block history
// Update totalTaxPaid counter (no longer ring-buffered)
const statsData = await context.db.find(stats, { id: STATS_ID });
if (statsData) {
await context.db.update(stats, { id: STATS_ID }).set({
totalTaxPaid: statsData.totalTaxPaid + event.args.taxPaid,
});
}
if (checkBlockHistorySufficient(context, event)) {
const statsData = await context.db.find(stats, { id: STATS_ID });
if (statsData) {
const ringBuffer = parseRingBuffer(statsData.ringBuffer as string[]);
const pointer = statsData.ringBufferPointer ?? 0;
const baseIndex = pointer * RING_BUFFER_SEGMENTS;
ringBuffer[baseIndex + 3] = ringBuffer[baseIndex + 3] + event.args.taxPaid;
await context.db.update(stats, { id: STATS_ID }).set({
ringBuffer: serializeRingBuffer(ringBuffer),
totalTaxPaid: statsData.totalTaxPaid + event.args.taxPaid,
});
}
await updateHourlyData(context, event.block.timestamp);
} else {
// Insufficient history - update only totalTaxPaid without ringbuffer
const statsData = await context.db.find(stats, { id: STATS_ID });
if (statsData) {
await context.db.update(stats, { id: STATS_ID }).set({
totalTaxPaid: statsData.totalTaxPaid + event.args.taxPaid,
});
}
}
await refreshOutstandingStake(context);