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

@ -5,6 +5,9 @@
<div class="stat-label">ETH Reserve</div>
<div class="stat-value">{{ ethReserveAmount }}</div>
<div v-if="growthIndicator !== null" class="growth-badge" :class="growthClass">{{ growthIndicator }}</div>
<svg v-if="ethReserveSpark.length > 1" class="sparkline" viewBox="0 0 80 24" preserveAspectRatio="none">
<polyline :points="toSvgPoints(ethReserveSpark)" fill="none" stroke="rgba(96,165,250,0.5)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
</svg>
</div>
<div class="stat-item">
<div class="stat-label">ETH / Token</div>
@ -19,10 +22,17 @@
<div class="stat-label">Supply (7d)</div>
<div class="stat-value">{{ totalSupply }}</div>
<div v-if="netSupplyIndicator !== null" class="growth-badge" :class="netSupplyClass">{{ netSupplyIndicator }}</div>
<svg v-if="supplySpark.length > 1" class="sparkline" viewBox="0 0 80 24" preserveAspectRatio="none">
<polyline :points="toSvgPoints(supplySpark)" fill="none" stroke="rgba(74,222,128,0.5)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
</svg>
</div>
<div class="stat-item">
<div class="stat-label">Holders</div>
<div class="stat-value">{{ holders }}</div>
<div v-if="holderGrowthIndicator !== null" class="growth-badge" :class="holderGrowthClass">{{ holderGrowthIndicator }}</div>
<svg v-if="holdersSpark.length > 1" class="sparkline" viewBox="0 0 80 24" preserveAspectRatio="none">
<polyline :points="toSvgPoints(holdersSpark)" fill="none" stroke="rgba(251,191,36,0.5)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
</svg>
</div>
<div class="stat-item" :class="{ 'pulse': isRecentRebalance }">
<div class="stat-label">Rebalances</div>
@ -44,22 +54,25 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
const RING_SEGMENTS = 4; // ethReserve, minted, burned, holderCount
const RING_HOURS = 168; // 7 days * 24 hours
interface Stats {
kraikenTotalSupply: string;
holderCount: number;
lastRecenterTimestamp: number;
recentersLastWeek: number;
lastEthReserve: string;
taxPaidLastWeek: string;
mintedLastWeek: string;
burnedLastWeek: string;
netSupplyChangeWeek: string;
// New fields (batch1) all nullable until indexer has sufficient history
ethReserveGrowthBps: number | null;
feesEarned7dEth: string | null;
floorPriceWei: string | null;
floorDistanceBps: number | null;
currentPriceWei: string | null;
ringBuffer: string[] | null;
ringBufferPointer: number | null;
}
const stats = ref<Stats | null>(null);
@ -76,6 +89,101 @@ function weiToEth(wei: string | null | undefined): number {
}
}
/**
* Extract a time-ordered series from the ring buffer for a given slot offset.
* Skips leading zeros (pre-launch padding).
*/
function extractSeries(ringBuffer: string[], pointer: number, slotOffset: number): number[] {
const raw: number[] = [];
for (let i = 0; i < RING_HOURS; i++) {
// Walk from oldest to newest
const idx = ((pointer + 1 + i) % RING_HOURS) * RING_SEGMENTS + slotOffset;
raw.push(Number(ringBuffer[idx] || '0'));
}
// Skip leading zeros
const firstNonZero = raw.findIndex(v => v > 0);
return firstNonZero === -1 ? [] : raw.slice(firstNonZero);
}
/**
* Build cumulative net supply series from minted (slot 1) and burned (slot 2).
*/
function extractSupplySeries(ringBuffer: string[], pointer: number): number[] {
const minted: number[] = [];
const burned: number[] = [];
for (let i = 0; i < RING_HOURS; i++) {
const idx = ((pointer + 1 + i) % RING_HOURS) * RING_SEGMENTS;
minted.push(Number(ringBuffer[idx + 1] || '0'));
burned.push(Number(ringBuffer[idx + 2] || '0'));
}
// Build cumulative net supply change
const cumulative: number[] = [];
let sum = 0;
let hasData = false;
for (let i = 0; i < RING_HOURS; i++) {
const net = minted[i] - burned[i];
if (net !== 0) hasData = true;
sum += net;
if (hasData) cumulative.push(sum);
}
return cumulative;
}
/**
* Convert a number[] series to SVG polyline points string, scaled to 80x24.
*/
function toSvgPoints(series: number[]): string {
if (series.length < 2) return '';
const min = Math.min(...series);
const max = Math.max(...series);
const range = max - min || 1;
return series
.map((v, i) => {
const x = (i / (series.length - 1)) * 80;
const y = 24 - ((v - min) / range) * 22 - 1; // 1px padding top/bottom
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
}
// Sparkline series extracted from ring buffer
const ethReserveSpark = computed(() => {
if (!stats.value?.ringBuffer || stats.value.ringBufferPointer == null) return [];
return extractSeries(stats.value.ringBuffer, stats.value.ringBufferPointer, 0);
});
const supplySpark = computed(() => {
if (!stats.value?.ringBuffer || stats.value.ringBufferPointer == null) return [];
return extractSupplySeries(stats.value.ringBuffer, stats.value.ringBufferPointer);
});
const holdersSpark = computed(() => {
if (!stats.value?.ringBuffer || stats.value.ringBufferPointer == null) return [];
return extractSeries(stats.value.ringBuffer, stats.value.ringBufferPointer, 3);
});
// Holder growth indicator from ring buffer
const holderGrowthIndicator = computed((): string | null => {
const series = holdersSpark.value;
if (series.length < 2) return null;
const oldest = series[0];
const newest = series[series.length - 1];
if (oldest === 0) return null;
const pct = ((newest - oldest) / oldest) * 100;
if (Math.abs(pct) < 0.1) return '~ flat';
return pct > 0 ? `${pct.toFixed(1)}% this week` : `${Math.abs(pct).toFixed(1)}% this week`;
});
const holderGrowthClass = computed(() => {
const series = holdersSpark.value;
if (series.length < 2) return '';
const oldest = series[0];
const newest = series[series.length - 1];
if (newest > oldest) return 'growth-up';
if (newest < oldest) return 'growth-down';
return 'growth-flat';
});
const ethReserveAmount = computed(() => {
if (!stats.value) return '0.00 ETH';
const eth = weiToEth(stats.value.lastEthReserve);
@ -107,7 +215,6 @@ const ethPerToken = computed(() => {
const supply = Number(stats.value.kraikenTotalSupply) / 1e18;
if (supply === 0) return '—';
const ratio = reserve / supply;
// Format with appropriate precision
if (ratio >= 0.01) return `${ratio.toFixed(4)} ETH`;
if (ratio >= 0.0001) return `${ratio.toFixed(6)} ETH`;
return `${ratio.toExponential(2)} ETH`;
@ -207,7 +314,6 @@ async function fetchStats() {
lastRecenterTimestamp
recentersLastWeek
lastEthReserve
taxPaidLastWeek
mintedLastWeek
burnedLastWeek
netSupplyChangeWeek
@ -216,6 +322,8 @@ async function fetchStats() {
floorPriceWei
floorDistanceBps
currentPriceWei
ringBuffer
ringBufferPointer
}
}
}
@ -338,6 +446,12 @@ onUnmounted(() => {
background: rgba(255, 255, 255, 0.05)
border-color: rgba(255, 255, 255, 0.12)
.sparkline
width: 80px
height: 24px
margin-top: 4px
opacity: 0.8
.stat-label
font-size: 12px
color: rgba(240, 240, 240, 0.6)