feat/ponder-lm-indexing (#142)
This commit is contained in:
parent
de3c8eef94
commit
31063379a8
107 changed files with 12517 additions and 367 deletions
255
web-app/src/components/ProtocolStatsCard.vue
Normal file
255
web-app/src/components/ProtocolStatsCard.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue