feat/ponder-lm-indexing (#142)

This commit is contained in:
johba 2026-02-18 00:19:05 +01:00
parent de3c8eef94
commit 31063379a8
107 changed files with 12517 additions and 367 deletions

View file

@ -0,0 +1,255 @@
<template>
<div class="protocol-stats-card">
<FCard>
<template v-if="!initialized">
<div class="stats-loading">
<FLoader></FLoader>
<p>Loading protocol statistics...</p>
</div>
</template>
<template v-else-if="statsError">
<div class="stats-error">
<p> Protocol statistics unavailable</p>
<p class="error-detail">{{ statsError }}</p>
</div>
</template>
<template v-else>
<div class="stats-header">
<h3 class="stats-title">
Protocol Activity (last 24h)
<IconInfo size="20px">
<template #text>
Real-time protocol health metrics from the Ponder indexer. Shows supply growth, tax collection, rebalance activity, and
reserve status.
</template>
</IconInfo>
</h3>
</div>
<div class="stats-grid">
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">Supply Growth:</span>
<span class="stat-value" :class="{ positive: supplyGrowthPercent > 0, negative: supplyGrowthPercent < 0 }">
{{ formatPercent(supplyGrowthPercent) }}
</span>
</div>
<span class="stat-separator">·</span>
<div class="stat-item">
<span class="stat-label">Tax Collected:</span>
<span class="stat-value">{{ formatToken(taxPaidLastDay) }} KRK</span>
</div>
<span class="stat-separator">·</span>
<div class="stat-item">
<span class="stat-label">Net Expansion:</span>
<span class="stat-value" :class="{ positive: netExpansionRate > 0, negative: netExpansionRate < 0 }">
{{ formatPercent(netExpansionRate) }}
</span>
</div>
</div>
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">ETH Reserve:</span>
<span class="stat-value">{{ formatToken(ethReserve) }} ETH</span>
</div>
<span class="stat-separator">·</span>
<div class="stat-item">
<span class="stat-label">Holders:</span>
<span class="stat-value">{{ holderCount.toLocaleString() }}</span>
</div>
</div>
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">Last Rebalance:</span>
<span class="stat-value">{{ lastRebalanceText }}</span>
</div>
<span class="stat-separator">·</span>
<div class="stat-item">
<span class="stat-label">Rebalances Today:</span>
<span class="stat-value">{{ recentersLastDay }}</span>
</div>
</div>
<div class="stats-row indicative-rate">
<div class="stat-item">
<span class="stat-label">Indicative Annual Rate:</span>
<span class="stat-value rate-value">~{{ formatPercent(annualizedRate) }}</span>
<span class="stat-disclaimer">(based on 7d average not a promise)</span>
</div>
</div>
</div>
</template>
</FCard>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import FCard from '@/components/fcomponents/FCard.vue';
import FLoader from '@/components/fcomponents/FLoader.vue';
import IconInfo from '@/components/icons/IconInfo.vue';
import { useProtocolStats } from '@/composables/useProtocolStats';
import { useWallet } from '@/composables/useWallet';
import { DEFAULT_CHAIN_ID } from '@/config';
const wallet = useWallet();
const initialChainId = wallet.account.chainId ?? DEFAULT_CHAIN_ID;
const {
initialized,
statsError,
supplyGrowthPercent,
netExpansionRate,
ethReserve,
taxPaidLastDay,
holderCount,
recentersLastDay,
lastRecenterTimestamp,
annualizedRate,
} = useProtocolStats(initialChainId);
function formatToken(value: number, decimals: number = 2): string {
if (!Number.isFinite(value)) {
return '0';
}
return value.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
});
}
function formatPercent(value: number): string {
if (!Number.isFinite(value)) {
return '0%';
}
const sign = value > 0 ? '+' : '';
return `${sign}${value.toFixed(2)}%`;
}
const lastRebalanceText = computed(() => {
if (!lastRecenterTimestamp.value) {
return 'N/A';
}
const now = Date.now();
const timestampMs = lastRecenterTimestamp.value * 1000;
const diffMs = now - timestampMs;
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) {
return `${diffDays}d ago`;
} else if (diffHours > 0) {
return `${diffHours}h ago`;
} else if (diffMinutes > 0) {
return `${diffMinutes}m ago`;
} else {
return 'Just now';
}
});
</script>
<style lang="sass" scoped>
.protocol-stats-card
margin-bottom: 24px
.f-card
background: linear-gradient(135deg, rgba(117, 80, 174, 0.08) 0%, rgba(117, 80, 174, 0.03) 100%)
border: 1px solid rgba(117, 80, 174, 0.2)
.stats-loading,
.stats-error
display: flex
flex-direction: column
align-items: center
justify-content: center
padding: 24px
gap: 12px
text-align: center
p
margin: 0
color: #9A9898
.error-detail
font-size: 13px
color: #666
.stats-header
margin-bottom: 16px
.stats-title
display: flex
align-items: center
gap: 8px
margin: 0
font-size: 20px
font-weight: 600
color: #FFFFFF
.stats-grid
display: flex
flex-direction: column
gap: 12px
.stats-row
display: flex
flex-wrap: wrap
align-items: center
gap: 8px
font-size: 15px
&.indicative-rate
margin-top: 8px
padding-top: 12px
border-top: 1px solid rgba(117, 80, 174, 0.2)
.stat-item
display: flex
align-items: center
gap: 6px
flex-wrap: wrap
.stat-label
color: #9A9898
font-weight: 500
.stat-value
color: #FFFFFF
font-weight: 600
&.positive
color: #4ADE80
&.negative
color: #F87171
&.rate-value
color: #7550AE
font-size: 16px
.stat-separator
color: #666
font-weight: 300
.stat-disclaimer
color: #9A9898
font-size: 13px
font-style: italic
font-weight: 400
@media (max-width: 768px)
.stats-row
flex-direction: column
align-items: flex-start
gap: 8px
.stat-separator
display: none
</style>

View file

@ -8,6 +8,20 @@
</template>
<template v-else>
<div class="unaudited-notice" role="alert">
This protocol is unaudited. Use at your own risk.
</div>
<div class="uniswap-link-banner">
<span>Need $KRK?</span>
<a
:href="`https://app.uniswap.org/swap?outputCurrency=${chainData?.harb}&chain=base`"
target="_blank"
rel="noopener noreferrer"
class="uniswap-link"
>
Get $KRK on Uniswap
</a>
</div>
<form class="stake-form" @submit.prevent="handleSubmit" :aria-describedby="formStatusId" novalidate>
<div class="form-group">
<label :id="sliderLabelId" :for="sliderId" class="subheader2">Token Amount</label>
@ -71,14 +85,18 @@
<div class="row row-2">
<div class="form-field tax-field">
<div class="field-label">
<label :for="taxSelectId">Tax</label>
<label :for="taxSelectId">Position Cost (Tax Rate)</label>
<IconInfo size="20px">
<template #text>
The yearly tax you have to pay to keep your slots open. The tax is paid when unstaking or manually in the dashboard.
If someone pays a higher tax they can buy you out.
If someone pays a higher tax they can buy you out.<br /><br />
<strong>Trade-off:</strong> Higher tax = your position is harder to snatch, but reduces your returns. Lower tax = higher returns, but anyone willing to pay more tax can take your position.
</template>
</IconInfo>
</div>
<div class="tax-helper-text">
💡 Higher tax = harder to snatch, but reduces your returns
</div>
<div class="tax-select-wrapper">
<select :id="taxSelectId" class="tax-select" v-model.number="taxRateIndex" :aria-describedby="taxHelpId">
<option v-for="option in taxOptions" :key="option.index" :value="option.index">
@ -137,6 +155,45 @@
{{ actionState.label }}
</FButton>
</form>
<div class="contract-addresses">
<div class="contract-addresses__title">Contract Addresses (Sepolia Testnet)</div>
<div class="contract-addresses__list">
<div class="contract-address">
<span class="contract-address__label">KRK Token:</span>
<a
href="https://sepolia.basescan.org/address/0xff196f1e3a895404d073b8611252cf97388773a7"
target="_blank"
rel="noopener noreferrer"
class="contract-address__link"
>
0xff196f1e3a895404d073b8611252cf97388773a7
</a>
</div>
<div class="contract-address">
<span class="contract-address__label">Stake Contract:</span>
<a
href="https://sepolia.basescan.org/address/0xc36e784e1dff616bdae4eac7b310f0934faf04a4"
target="_blank"
rel="noopener noreferrer"
class="contract-address__link"
>
0xc36e784e1dff616bdae4eac7b310f0934faf04a4
</a>
</div>
<div class="contract-address">
<span class="contract-address__label">LM Contract:</span>
<a
href="https://sepolia.basescan.org/address/0x33d10f2449ffede92b43d4fba562f132ba6a766a"
target="_blank"
rel="noopener noreferrer"
class="contract-address__link"
>
0x33d10f2449ffede92b43d4fba562f132ba6a766a
</a>
</div>
</div>
</div>
</template>
</div>
</div>
@ -158,7 +215,7 @@ import { assetsToShares } from '@/contracts/stake';
import { useWallet } from '@/composables/useWallet';
import { ref, onMounted, watch, computed, watchEffect, getCurrentInstance } from 'vue';
import { useStatCollection, loadStats } from '@/composables/useStatCollection';
import { DEFAULT_CHAIN_ID } from '@/config';
import { DEFAULT_CHAIN_ID, getChain } from '@/config';
const demo = sessionStorage.getItem('demo') === 'true';
@ -172,6 +229,7 @@ const initialChainId = wallet.account.chainId ?? DEFAULT_CHAIN_ID;
const statCollection = useStatCollection(initialChainId);
const { activePositions: _activePositions } = usePositions(initialChainId);
const currentChainId = computed(() => wallet.account.chainId ?? DEFAULT_CHAIN_ID);
const chainData = computed(() => getChain(currentChainId.value));
const instance = getCurrentInstance();
const uid = instance?.uid ?? Math.floor(Math.random() * 10000);
@ -500,6 +558,7 @@ async function handleSubmit() {
}
onMounted(async () => {
await wallet.loadBalance();
stake.stakingAmountNumber = minStakeAmount.value;
});
</script>
@ -511,6 +570,38 @@ onMounted(async () => {
flex-direction: column
gap: 24px
.unaudited-notice
padding: 12px 16px
background-color: rgba(255, 200, 0, 0.1)
border-left: 3px solid #ffc800
border-radius: 4px
font-size: 14px
color: #ffc800
text-align: center
.uniswap-link-banner
padding: 14px 18px
background: linear-gradient(90deg, rgba(117, 80, 174, 0.15) 0%, rgba(117, 80, 174, 0.05) 100%)
border-radius: 8px
display: flex
align-items: center
justify-content: center
gap: 12px
font-size: 15px
span
color: #9A9898
.uniswap-link
color: #7550AE
text-decoration: none
font-weight: 600
transition: color 0.2s ease
&:hover
color: #9370DB
text-decoration: underline
.stake-form
position: relative
display: flex
@ -619,6 +710,14 @@ onMounted(async () => {
gap: 8px
font-weight: 600
.tax-helper-text
padding: 8px 12px
background-color: rgba(117, 80, 174, 0.15)
border-radius: 6px
font-size: 13px
color: #9A9898
margin-bottom: 4px
.tax-select-wrapper
position: relative
display: flex
@ -669,4 +768,39 @@ onMounted(async () => {
flex-direction: column
>*
flex: 1 1 auto
.contract-addresses
padding: 16px
background-color: rgba(45, 45, 45, 0.3)
border-radius: 8px
font-size: 14px
&__title
font-weight: 600
margin-bottom: 12px
color: #FFFFFF
&__list
display: flex
flex-direction: column
gap: 8px
.contract-address
display: flex
flex-direction: column
gap: 4px
&__label
color: #9A9898
font-size: 12px
&__link
color: #7550AE
text-decoration: none
word-break: break-all
font-family: monospace
font-size: 13px
&:hover
text-decoration: underline
</style>

View file

@ -20,6 +20,14 @@
<FTag v-if="tag">{{ tag }}</FTag>
</div>
</div>
<div class="pnl-metrics">
<div class="pnl-line1" :class="{ 'pnl-positive': netReturn > 0, 'pnl-negative': netReturn < 0 }">
Gross: {{ formatPercent(grossReturn) }} · Tax: {{ formatPercent(-taxCostPercent) }} · Net: {{ formatPercent(netReturn) }}
</div>
<div class="pnl-line2">
Held {{ timeHeldFormatted }} · Snatched {{ props.position.snatched }} time{{ props.position.snatched === 1 ? '' : 's' }}
</div>
</div>
<!-- <div class="collapse-amount">
<span class="number-small">{{ compactNumber(props.amount) }}</span>
<span class="caption"> $KRK</span>
@ -121,6 +129,69 @@ const tag = computed(() => {
const total = computed(() => props.amount + profit.value! + -taxPaidGes.value!);
// P&L calculations (FIXED: Use BigInt math to preserve precision)
const grossReturn = computed(() => {
try {
const currentSupply = BigInt(statCollection.kraikenTotalSupply);
const initSupply = BigInt(props.position.totalSupplyInit);
if (initSupply === 0n) return 0;
// Calculate percentage change using BigInt to avoid precision loss
// Formula: ((current - init) / init) * 100
// To maintain precision, multiply by 10000 first, then divide by 100 for display
const diff = currentSupply - initSupply;
const percentBigInt = (diff * 10000n) / initSupply;
return Number(percentBigInt) / 100;
} catch (error) {
void error; // suppress lint
return 0;
}
});
const taxCostPercent = computed(() => {
try {
const taxPaid = BigInt(props.position.taxPaid);
const deposit = BigInt(props.position.harbDeposit);
if (deposit === 0n) return 0;
// Calculate percentage using BigInt precision
const percentBigInt = (taxPaid * 10000n) / deposit;
return Number(percentBigInt) / 100;
} catch (error) {
void error; // suppress lint
return 0;
}
});
const netReturn = computed(() => {
return grossReturn.value - taxCostPercent.value;
});
const timeHeldFormatted = computed(() => {
if (!props.position.creationTime) return '0d 0h';
// Handle both Date objects and bigint/number timestamps
let creationTimestamp: number;
if (props.position.creationTime instanceof Date) {
creationTimestamp = Math.floor(props.position.creationTime.getTime() / 1000);
} else {
creationTimestamp = Number(props.position.creationTime);
}
const nowTimestamp = Math.floor(Date.now() / 1000);
const secondsHeld = nowTimestamp - creationTimestamp;
const days = Math.floor(secondsHeld / 86400);
const hours = Math.floor((secondsHeld % 86400) / 3600);
return `${days}d ${hours}h`;
});
function formatPercent(value: number): string {
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(1)}%`;
}
const filteredTaxRates = computed(() => {
const currentIndex = props.position.taxRateIndex ?? -1;
return adjustTaxRate.taxRates.filter(option => option.index > currentIndex);
@ -206,6 +277,21 @@ onMounted(() => {
.tags-list
margin-right: 32px
text-align: right
.pnl-metrics
display: flex
flex-direction: column
gap: 4px
padding-top: 8px
font-size: 13px
.pnl-line1
font-weight: 500
&.pnl-positive
color: #4ade80
&.pnl-negative
color: #f87171
.pnl-line2
color: #9A9898
font-size: 12px
.collapsableDiv
.collapsed-body
display: flex