harb/landing/src/components/LiveStats.vue
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

523 lines
17 KiB
Vue

<template>
<div v-if="!error && stats" class="live-stats">
<div class="stats-grid" :class="{ 'has-floor': showFloorPrice }">
<div class="stat-item">
<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>
<div class="stat-value">{{ ethPerToken }}</div>
</div>
<div v-if="showFloorPrice" class="stat-item">
<div class="stat-label">Floor Price</div>
<div class="stat-value">{{ floorPriceAmount }}</div>
<div v-if="floorDistanceText" class="floor-distance">{{ floorDistanceText }}</div>
</div>
<div class="stat-item">
<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>
<div class="stat-value">{{ rebalanceCount }}</div>
<div class="growth-badge growth-flat">{{ lastRebalance }}</div>
</div>
</div>
</div>
<div v-else-if="!error && !stats" class="live-stats">
<div class="stats-grid">
<div class="stat-item skeleton" v-for="i in 6" :key="i">
<div class="stat-label skeleton-text"></div>
<div class="stat-value skeleton-text"></div>
</div>
</div>
</div>
</template>
<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;
mintedLastWeek: string;
burnedLastWeek: string;
netSupplyChangeWeek: string;
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);
const error = ref(false);
let refreshInterval: number | null = null;
// Helper: safely convert a Wei BigInt string to ETH float
function weiToEth(wei: string | null | undefined): number {
if (!wei || wei === '0') return 0;
try {
return Number(BigInt(wei)) / 1e18;
} catch {
return 0;
}
}
/**
* 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);
return `${eth.toFixed(2)} ETH`;
});
// Growth indicator: null = hide (< 7 days history or missing)
const growthIndicator = computed((): string | null => {
if (!stats.value || stats.value.ethReserveGrowthBps == null) return null;
const bps = Number(stats.value.ethReserveGrowthBps);
const pct = Math.abs(bps / 100);
if (bps > 10) return `${pct.toFixed(1)}%`;
if (bps < -10) return `${pct.toFixed(1)}%`;
return `~ ${pct.toFixed(1)}%`;
});
const growthClass = computed(() => {
if (!stats.value || stats.value.ethReserveGrowthBps == null) return '';
const bps = Number(stats.value.ethReserveGrowthBps);
if (bps > 10) return 'growth-up';
if (bps < -10) return 'growth-down';
return 'growth-flat';
});
// ETH backing per token
const ethPerToken = computed(() => {
if (!stats.value) return '—';
const reserve = weiToEth(stats.value.lastEthReserve);
const supply = Number(stats.value.kraikenTotalSupply) / 1e18;
if (supply === 0) return '—';
const ratio = reserve / supply;
if (ratio >= 0.01) return `${ratio.toFixed(4)} ETH`;
if (ratio >= 0.0001) return `${ratio.toFixed(6)} ETH`;
return `${ratio.toExponential(2)} ETH`;
});
// Net supply change indicator (7d)
const netSupplyIndicator = computed((): string | null => {
if (!stats.value) return null;
const minted = Number(BigInt(stats.value.mintedLastWeek || '0'));
const burned = Number(BigInt(stats.value.burnedLastWeek || '0'));
const supply = Number(BigInt(stats.value.kraikenTotalSupply || '1'));
if (supply === 0) return null;
const netPct = ((minted - burned) / supply) * 100;
if (Math.abs(netPct) < 0.01) return '~ flat';
return netPct > 0 ? `${netPct.toFixed(1)}%` : `${Math.abs(netPct).toFixed(1)}%`;
});
const netSupplyClass = computed(() => {
if (!stats.value) return '';
const minted = Number(BigInt(stats.value.mintedLastWeek || '0'));
const burned = Number(BigInt(stats.value.burnedLastWeek || '0'));
if (minted > burned * 1.01) return 'growth-up';
if (burned > minted * 1.01) return 'growth-down';
return 'growth-flat';
});
// Rebalance count (weekly)
const rebalanceCount = computed(() => {
if (!stats.value) return '0';
return `${stats.value.recentersLastWeek} this week`;
});
const holders = computed(() => {
if (!stats.value) return '0 holders';
return `${stats.value.holderCount} holders`;
});
const lastRebalance = computed(() => {
if (!stats.value) return 'Never';
const now = Math.floor(Date.now() / 1000);
const diff = now - stats.value.lastRecenterTimestamp;
if (diff < 60) return 'Just now';
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`;
return `${Math.floor(diff / 86400)} days ago`;
});
const isRecentRebalance = computed(() => {
if (!stats.value) return false;
const now = Math.floor(Date.now() / 1000);
return (now - stats.value.lastRecenterTimestamp) < 3600;
});
const totalSupply = computed(() => {
if (!stats.value) return '0K KRK';
const supply = Number(stats.value.kraikenTotalSupply) / 1e18;
if (supply >= 1000000) {
return `${(supply / 1000000).toFixed(1)}M KRK`;
}
return `${(supply / 1000).toFixed(1)}K KRK`;
});
// Floor price: only show when data is available
const showFloorPrice = computed(() => {
return !!(stats.value?.floorPriceWei && stats.value.floorPriceWei !== '0');
});
const floorPriceAmount = computed(() => {
if (!showFloorPrice.value || !stats.value?.floorPriceWei) return null;
const eth = weiToEth(stats.value.floorPriceWei);
return `${eth.toFixed(4)} ETH`;
});
const floorDistanceText = computed((): string | null => {
if (!stats.value || stats.value.floorDistanceBps == null) return null;
const distPct = Number(stats.value.floorDistanceBps) / 100;
const aboveBelow = distPct >= 0 ? 'above' : 'below';
return `(${Math.abs(distPct).toFixed(0)}% ${aboveBelow})`;
});
async function fetchStats() {
try {
const endpoint = `${window.location.origin}/api/graphql`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `
query ProtocolStats {
statss(where: { id: "0x01" }) {
items {
kraikenTotalSupply
holderCount
lastRecenterTimestamp
recentersLastWeek
lastEthReserve
mintedLastWeek
burnedLastWeek
netSupplyChangeWeek
ethReserveGrowthBps
feesEarned7dEth
floorPriceWei
floorDistanceBps
currentPriceWei
ringBuffer
ringBufferPointer
}
}
}
`,
}),
});
if (!response.ok) {
throw new Error('Failed to fetch stats');
}
const data = await response.json();
if (data.data?.statss?.items?.[0]) {
const s = data.data.statss.items[0];
// If ETH reserve is 0 from Ponder (EthScarcity/EthAbundance events never emitted),
// read WETH balance of the Uniswap V3 pool directly via RPC
if (s.lastEthReserve === '0' || !s.lastEthReserve) {
try {
const rpc = `${window.location.origin}/api/rpc`;
const deployResp = await fetch(`${window.location.origin}/app/deployments-local.json`);
if (deployResp.ok) {
const deployments = await deployResp.json();
const krkAddr = deployments.contracts?.Kraiken;
if (krkAddr) {
const wethAddr = '0x4200000000000000000000000000000000000006';
const factoryAddr = '0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24';
const fee = 10000; // 1% fee tier
// Step 1: factory.getPool(weth, kraiken, fee) → pool address
// selector: 0x1698ee82
const wethPad = wethAddr.slice(2).padStart(64, '0');
const krkPad = krkAddr.slice(2).padStart(64, '0');
const feePad = fee.toString(16).padStart(64, '0');
const poolRes = await fetch(rpc, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_call',
params: [{ to: factoryAddr, data: '0x1698ee82' + wethPad + krkPad + feePad }, 'latest'] }) });
const poolJson = await poolRes.json();
const poolAddr = '0x' + (poolJson.result || '').slice(26);
if (poolAddr.length === 42 && poolAddr !== '0x' + '0'.repeat(40)) {
// Step 2: weth.balanceOf(pool) → ETH reserve in pool
const poolPad = poolAddr.slice(2).padStart(64, '0');
const balRes = await fetch(rpc, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'eth_call',
params: [{ to: wethAddr, data: '0x70a08231' + poolPad }, 'latest'] }) });
const balJson = await balRes.json();
const wethBal = BigInt(balJson.result || '0x0');
if (wethBal > 0n) {
s.lastEthReserve = wethBal.toString();
}
}
}
}
} catch { /* ignore RPC fallback errors */ }
}
stats.value = s;
error.value = false;
} else {
throw new Error('No stats data');
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to fetch protocol stats:', err);
error.value = true;
}
}
onMounted(() => {
fetchStats();
refreshInterval = window.setInterval(fetchStats, 30000); // Refresh every 30 seconds
});
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
<style scoped lang="sass">
.live-stats
width: 100%
padding: 40px 0
margin-top: 60px
.stats-grid
display: grid
grid-template-columns: 1fr
gap: 24px
max-width: 840px
margin: 0 auto
padding: 0 32px
&.has-floor
max-width: 1020px
@media (min-width: 640px)
grid-template-columns: repeat(2, 1fr)
@media (min-width: 992px)
grid-template-columns: repeat(3, 1fr)
gap: 32px
&.has-floor
grid-template-columns: repeat(3, 1fr)
.stat-item
display: flex
flex-direction: column
align-items: center
gap: 6px
padding: 20px
background: rgba(255, 255, 255, 0.03)
border: 1px solid rgba(255, 255, 255, 0.08)
border-radius: 12px
transition: all 0.3s ease
&:hover
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)
text-transform: uppercase
letter-spacing: 0.5px
font-weight: 500
.stat-value
font-size: 20px
color: #F0F0F0
font-weight: 600
font-family: 'orbitron', sans-serif
@media (min-width: 992px)
font-size: 24px
.growth-badge
font-size: 11px
font-weight: 600
letter-spacing: 0.5px
&.growth-up
color: #4ade80
&.growth-down
color: #f87171
&.growth-flat
color: rgba(240, 240, 240, 0.45)
.floor-distance
font-size: 11px
color: rgba(240, 240, 240, 0.5)
letter-spacing: 0.3px
.pulse
animation: pulse-glow 2s ease-in-out infinite
@keyframes pulse-glow
0%, 100%
background: rgba(117, 80, 174, 0.1)
border-color: rgba(117, 80, 174, 0.3)
50%
background: rgba(117, 80, 174, 0.2)
border-color: rgba(117, 80, 174, 0.5)
.skeleton
pointer-events: none
.skeleton-text
background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.1) 50%, rgba(255, 255, 255, 0.05) 75%)
background-size: 200% 100%
animation: shimmer 1.5s infinite
border-radius: 4px
&.stat-label
height: 12px
width: 80px
&.stat-value
height: 24px
width: 100px
@keyframes shimmer
0%
background-position: 200% 0
100%
background-position: -200% 0
</style>