Replace UBI with ETH reserve in ring buffer, fix Dockerfile HEALTHCHECK, enhance LiveStats (#154)

This commit is contained in:
johba 2026-02-19 14:47:15 +01:00
parent 31063379a8
commit 76b2635e63
16 changed files with 2028 additions and 89 deletions

View file

@ -0,0 +1,289 @@
import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue';
import axios from 'axios';
import { DEFAULT_CHAIN_ID } from '@/config';
import { resolveGraphqlEndpoint, formatGraphqlError } from '@/utils/graphqlRetry';
import logger from '@/utils/logger';
const GRAPHQL_TIMEOUT_MS = 15_000;
const POLL_INTERVAL_MS = 30_000;
export interface PositionRecord {
id: string;
owner: string;
share: number;
taxRate: number;
taxRateIndex: number;
kraikenDeposit: string;
stakeDeposit: string;
taxPaid: string;
snatched: number;
status: string;
creationTime: string;
lastTaxTime: string;
closedAt: string | null;
totalSupplyInit: string;
totalSupplyEnd: string | null;
payout: string | null;
}
export interface PositionStats {
stakeTotalSupply: string;
outstandingStake: string;
kraikenTotalSupply: string;
lastEthReserve: string;
}
export interface ActivePositionShort {
id: string;
taxRateIndex: number;
kraikenDeposit: string;
}
function formatTokenAmount(rawWei: string, decimals = 18): number {
try {
const big = BigInt(rawWei);
const divisor = 10 ** decimals;
return Number(big) / divisor;
} catch {
return 0;
}
}
function formatDate(ts: string | null): string {
if (!ts) return 'N/A';
try {
// ts may be seconds (unix) or ms
const num = Number(ts);
const ms = num > 1e12 ? num : num * 1000;
return new Date(ms).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return 'N/A';
}
}
function durationHuman(fromTs: string | null, toTs: string | null = null): string {
if (!fromTs) return 'N/A';
try {
const from = Number(fromTs) > 1e12 ? Number(fromTs) : Number(fromTs) * 1000;
const to = toTs ? (Number(toTs) > 1e12 ? Number(toTs) : Number(toTs) * 1000) : Date.now();
const diffMs = to - from;
if (diffMs < 0) return 'N/A';
const totalSec = Math.floor(diffMs / 1000);
const days = Math.floor(totalSec / 86400);
const hours = Math.floor((totalSec % 86400) / 3600);
const mins = Math.floor((totalSec % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (mins > 0 && days === 0) parts.push(`${mins}m`);
return parts.length ? parts.join(' ') : '<1m';
} catch {
return 'N/A';
}
}
export function usePositionDashboard(positionId: Ref<string>) {
const position = ref<PositionRecord | null>(null);
const stats = ref<PositionStats | null>(null);
const allActivePositions = ref<ActivePositionShort[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function fetchData() {
const id = positionId.value;
if (!id) return;
loading.value = true;
error.value = null;
let endpoint: string;
try {
endpoint = resolveGraphqlEndpoint(DEFAULT_CHAIN_ID);
} catch (err) {
error.value = err instanceof Error ? err.message : 'GraphQL endpoint not configured';
loading.value = false;
return;
}
try {
const res = await axios.post(
endpoint,
{
query: `query PositionDashboard {
positions(id: "${id}") {
id
owner
share
taxRate
taxRateIndex
kraikenDeposit
stakeDeposit
taxPaid
snatched
status
creationTime
lastTaxTime
closedAt
totalSupplyInit
totalSupplyEnd
payout
}
statss(where: { id: "0x01" }) {
items {
stakeTotalSupply
outstandingStake
kraikenTotalSupply
lastEthReserve
}
}
positionss(where: { status: "Active" }, limit: 1000) {
items {
id
taxRateIndex
kraikenDeposit
}
}
}`,
},
{ timeout: GRAPHQL_TIMEOUT_MS }
);
const gqlErrors = res.data?.errors;
if (Array.isArray(gqlErrors) && gqlErrors.length > 0) {
throw new Error(gqlErrors.map((e: { message?: string }) => e.message ?? 'GraphQL error').join(', '));
}
position.value = res.data?.data?.positions ?? null;
const statsItems = res.data?.data?.statss?.items;
stats.value = Array.isArray(statsItems) && statsItems.length > 0 ? (statsItems[0] as PositionStats) : null;
const activeItems = res.data?.data?.positionss?.items;
allActivePositions.value = Array.isArray(activeItems) ? (activeItems as ActivePositionShort[]) : [];
logger.info(`PositionDashboard loaded for #${id}`);
} catch (err) {
error.value = formatGraphqlError(err);
logger.info('PositionDashboard fetch error', err);
} finally {
loading.value = false;
}
}
// Derived values
const depositKrk = computed(() => formatTokenAmount(position.value?.kraikenDeposit ?? '0'));
const taxPaidKrk = computed(() => formatTokenAmount(position.value?.taxPaid ?? '0'));
const currentValueKrk = computed(() => {
if (!position.value || !stats.value) return 0;
const share = Number(position.value.share);
const outstanding = formatTokenAmount(stats.value.outstandingStake);
return share * outstanding;
});
const netReturnKrk = computed(() => {
return currentValueKrk.value - depositKrk.value - taxPaidKrk.value;
});
const taxRatePercent = computed(() => {
if (!position.value) return 0;
return Number(position.value.taxRate) * 100;
});
const dailyTaxCost = computed(() => {
if (!position.value) return 0;
return (depositKrk.value * Number(position.value.taxRate)) / 365;
});
const sharePercent = computed(() => {
if (!position.value) return 0;
return Number(position.value.share) * 100;
});
const createdFormatted = computed(() => formatDate(position.value?.creationTime ?? null));
const lastTaxFormatted = computed(() => formatDate(position.value?.lastTaxTime ?? null));
const closedAtFormatted = computed(() => formatDate(position.value?.closedAt ?? null));
const timeHeld = computed(() => {
if (!position.value) return 'N/A';
return durationHuman(position.value.creationTime, position.value.closedAt ?? null);
});
// Snatch risk: count active positions with lower taxRateIndex
const snatchRisk = computed(() => {
if (!position.value) return { count: 0, level: 'UNKNOWN', color: '#9A9898' };
const myIndex = Number(position.value.taxRateIndex);
const lower = allActivePositions.value.filter(p => Number(p.taxRateIndex) < myIndex).length;
const total = allActivePositions.value.length;
let level: string;
let color: string;
if (total === 0) {
level = 'LOW';
color = '#4ADE80';
} else {
const ratio = lower / total;
if (ratio < 0.33) {
level = 'LOW';
color = '#4ADE80';
} else if (ratio < 0.67) {
level = 'MEDIUM';
color = '#FACC15';
} else {
level = 'HIGH';
color = '#F87171';
}
}
return { count: lower, level, color };
});
const payoutKrk = computed(() => formatTokenAmount(position.value?.payout ?? '0'));
const netPnlKrk = computed(() => {
if (!position.value) return 0;
return payoutKrk.value - depositKrk.value - taxPaidKrk.value;
});
onMounted(async () => {
await fetchData();
pollTimer = setInterval(() => void fetchData(), POLL_INTERVAL_MS);
});
onUnmounted(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
return {
loading,
error,
position,
stats,
allActivePositions,
depositKrk,
taxPaidKrk,
currentValueKrk,
netReturnKrk,
taxRatePercent,
dailyTaxCost,
sharePercent,
createdFormatted,
lastTaxFormatted,
closedAtFormatted,
timeHeld,
snatchRisk,
payoutKrk,
netPnlKrk,
refresh: fetchData,
};
}

View file

@ -0,0 +1,189 @@
import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue';
import axios from 'axios';
import { DEFAULT_CHAIN_ID } from '@/config';
import { resolveGraphqlEndpoint, formatGraphqlError } from '@/utils/graphqlRetry';
import logger from '@/utils/logger';
const GRAPHQL_TIMEOUT_MS = 15_000;
const POLL_INTERVAL_MS = 30_000;
export interface WalletPosition {
id: string;
share: number;
taxRate: number;
taxRateIndex: number;
kraikenDeposit: string;
taxPaid: string;
snatched: number;
status: string;
creationTime: string;
lastTaxTime: string;
payout: string | null;
totalSupplyInit: string;
totalSupplyEnd: string | null;
}
export interface WalletStats {
kraikenTotalSupply: string;
lastEthReserve: string;
floorPriceWei: string;
currentPriceWei: string;
ethReserveGrowthBps: number;
recentersLastWeek: number;
mintedLastWeek: string;
burnedLastWeek: string;
netSupplyChangeWeek: string;
holderCount: number;
}
function formatTokenAmount(rawWei: string, decimals = 18): number {
try {
const big = BigInt(rawWei);
const divisor = 10 ** decimals;
return Number(big) / divisor;
} catch {
return 0;
}
}
export function useWalletDashboard(address: Ref<string>) {
const holderBalance = ref<string>('0');
const stats = ref<WalletStats | null>(null);
const positions = ref<WalletPosition[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function fetchData() {
const addr = address.value?.toLowerCase();
if (!addr) return;
loading.value = true;
error.value = null;
let endpoint: string;
try {
endpoint = resolveGraphqlEndpoint(DEFAULT_CHAIN_ID);
} catch (err) {
error.value = err instanceof Error ? err.message : 'GraphQL endpoint not configured';
loading.value = false;
return;
}
try {
const res = await axios.post(
endpoint,
{
query: `query WalletDashboard {
holders(address: "${addr}") {
address
balance
}
statss(where: { id: "0x01" }) {
items {
kraikenTotalSupply
lastEthReserve
floorPriceWei
currentPriceWei
ethReserveGrowthBps
recentersLastWeek
mintedLastWeek
burnedLastWeek
netSupplyChangeWeek
holderCount
}
}
positionss(where: { owner: "${addr}" }, limit: 1000) {
items {
id
share
taxRate
taxRateIndex
kraikenDeposit
taxPaid
snatched
status
creationTime
lastTaxTime
payout
totalSupplyInit
totalSupplyEnd
}
}
}`,
},
{ timeout: GRAPHQL_TIMEOUT_MS }
);
const gqlErrors = res.data?.errors;
if (Array.isArray(gqlErrors) && gqlErrors.length > 0) {
throw new Error(gqlErrors.map((e: { message?: string }) => e.message ?? 'GraphQL error').join(', '));
}
const holder = res.data?.data?.holders;
holderBalance.value = holder?.balance ?? '0';
const statsItems = res.data?.data?.statss?.items;
stats.value = Array.isArray(statsItems) && statsItems.length > 0 ? (statsItems[0] as WalletStats) : null;
const posItems = res.data?.data?.positionss?.items;
positions.value = Array.isArray(posItems) ? (posItems as WalletPosition[]) : [];
logger.info(`WalletDashboard loaded for ${addr}`);
} catch (err) {
error.value = formatGraphqlError(err);
logger.info('WalletDashboard fetch error', err);
} finally {
loading.value = false;
}
}
// Derived values
const balanceKrk = computed(() => formatTokenAmount(holderBalance.value));
const ethBacking = computed(() => {
if (!stats.value) return 0;
const balance = balanceKrk.value;
const reserve = formatTokenAmount(stats.value.lastEthReserve);
const totalSupply = formatTokenAmount(stats.value.kraikenTotalSupply);
if (totalSupply === 0) return 0;
return balance * (reserve / totalSupply);
});
const floorValue = computed(() => {
if (!stats.value) return 0;
const balance = balanceKrk.value;
// floorPriceWei is price per token in wei → convert to ETH
const floorPriceEth = formatTokenAmount(stats.value.floorPriceWei);
return balance * floorPriceEth;
});
const activePositions = computed(() => positions.value.filter(p => p.status === 'Active'));
const closedPositions = computed(() => positions.value.filter(p => p.status === 'Closed'));
onMounted(async () => {
await fetchData();
pollTimer = setInterval(() => void fetchData(), POLL_INTERVAL_MS);
});
onUnmounted(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
return {
loading,
error,
holderBalance,
balanceKrk,
ethBacking,
floorValue,
stats,
positions,
activePositions,
closedPositions,
refresh: fetchData,
};
}

View file

@ -37,6 +37,33 @@ const router = createRouter({
},
component: () => import('../views/GetKrkView.vue'),
},
{
path: '/cheats',
name: 'cheats',
meta: {
title: 'Cheat Console',
layout: 'NavbarLayout',
},
component: () => import('../views/CheatsView.vue'),
},
{
path: '/wallet/:address',
name: 'wallet',
meta: {
title: 'Wallet',
layout: 'NavbarLayout',
},
component: () => import('../views/WalletView.vue'),
},
{
path: '/position/:id',
name: 'position',
meta: {
title: 'Position',
layout: 'NavbarLayout',
},
component: () => import('../views/PositionView.vue'),
},
],
});

View file

@ -0,0 +1,474 @@
<template>
<div class="position-view">
<!-- Loading / Error -->
<div v-if="loading && !position" class="pos-loading">
<div class="spinner"></div>
<p>Loading position</p>
</div>
<div v-if="error" class="pos-error"> {{ error }}</div>
<template v-if="position">
<!-- Header -->
<div class="pos-header">
<div class="pos-header__left">
<div class="pos-header__id">Position #{{ position.id }}</div>
<span class="status-badge" :class="position.status === 'Active' ? 'status-badge--active' : 'status-badge--closed'">
{{ position.status }}
</span>
</div>
<div class="pos-header__owner">
<span class="pos-header__owner-label">Owner</span>
<RouterLink :to="{ name: 'wallet', params: { address: position.owner } }" class="pos-header__owner-addr">
{{ truncateAddr(position.owner) }}
</RouterLink>
</div>
</div>
<!-- Overview Cards -->
<div class="overview-cards">
<div class="ov-card">
<div class="ov-card__label">Deposited</div>
<div class="ov-card__value">{{ fmtKrk(depositKrk) }}</div>
<div class="ov-card__unit">KRK</div>
</div>
<div class="ov-card">
<div class="ov-card__label">Current Value</div>
<div class="ov-card__value">{{ fmtKrk(currentValueKrk) }}</div>
<div class="ov-card__unit">KRK</div>
</div>
<div class="ov-card">
<div class="ov-card__label">Tax Paid</div>
<div class="ov-card__value">{{ fmtKrk(taxPaidKrk) }}</div>
<div class="ov-card__unit">KRK</div>
</div>
<div class="ov-card" :class="netReturnKrk >= 0 ? 'ov-card--positive' : 'ov-card--negative'">
<div class="ov-card__label">Net Return</div>
<div class="ov-card__value">{{ netReturnKrk >= 0 ? '+' : '' }}{{ fmtKrk(netReturnKrk) }}</div>
<div class="ov-card__unit">KRK</div>
</div>
</div>
<!-- Two-column detail + metrics -->
<div class="detail-grid">
<!-- Position Details -->
<div class="detail-panel">
<h3 class="panel-title">Position Details</h3>
<div class="detail-rows">
<div class="detail-row">
<span class="detail-row__label">Tax Rate</span>
<span class="detail-row__value"
>{{ taxRatePercent.toFixed(2) }}% <span class="muted">(index {{ position.taxRateIndex }})</span></span
>
</div>
<div class="detail-row">
<span class="detail-row__label">Created</span>
<span class="detail-row__value">{{ createdFormatted }}</span>
</div>
<div class="detail-row">
<span class="detail-row__label">Time Held</span>
<span class="detail-row__value">{{ timeHeld }}</span>
</div>
<div class="detail-row">
<span class="detail-row__label">Last Tax Payment</span>
<span class="detail-row__value">{{ lastTaxFormatted }}</span>
</div>
<div class="detail-row">
<span class="detail-row__label">Times Snatched</span>
<span class="detail-row__value">{{ position.snatched }}</span>
</div>
<template v-if="position.status === 'Closed'">
<div class="detail-row">
<span class="detail-row__label">Closed At</span>
<span class="detail-row__value">{{ closedAtFormatted }}</span>
</div>
<div class="detail-row">
<span class="detail-row__label">Payout</span>
<span class="detail-row__value">{{ fmtKrk(payoutKrk) }} KRK</span>
</div>
</template>
</div>
</div>
<!-- Computed Metrics -->
<div class="detail-panel">
<h3 class="panel-title">Metrics</h3>
<div class="detail-rows">
<div class="detail-row">
<span class="detail-row__label">Daily Tax Cost</span>
<span class="detail-row__value">{{ fmtKrk(dailyTaxCost) }} KRK/day</span>
</div>
<div class="detail-row">
<span class="detail-row__label">Share of Pool</span>
<span class="detail-row__value">{{ sharePercent.toFixed(4) }}%</span>
</div>
<div class="detail-row">
<span class="detail-row__label">Days to Breakeven</span>
<span class="detail-row__value">{{ breakevenDays }}</span>
</div>
</div>
<!-- Snatch Risk -->
<div class="snatch-risk" :style="{ '--risk-color': snatchRisk.color }">
<div class="snatch-risk__title">Snatch Risk</div>
<div class="snatch-risk__badge">{{ snatchRisk.level }}</div>
<div class="snatch-risk__detail">{{ snatchRisk.count }} position{{ snatchRisk.count !== 1 ? 's' : '' }} at lower tax rates</div>
</div>
</div>
</div>
<!-- Closed Summary -->
<div v-if="position.status === 'Closed'" class="closed-summary">
<h3 class="panel-title">Final Summary</h3>
<div class="closed-grid">
<div class="closed-item">
<span class="closed-item__label">Deposited</span>
<span class="closed-item__value">{{ fmtKrk(depositKrk) }} KRK</span>
</div>
<div class="closed-item">
<span class="closed-item__label">Payout</span>
<span class="closed-item__value">{{ fmtKrk(payoutKrk) }} KRK</span>
</div>
<div class="closed-item">
<span class="closed-item__label">Tax Paid</span>
<span class="closed-item__value">{{ fmtKrk(taxPaidKrk) }} KRK</span>
</div>
<div class="closed-item" :class="netPnlKrk >= 0 ? 'positive' : 'negative'">
<span class="closed-item__label">Net P&amp;L</span>
<span class="closed-item__value">{{ netPnlKrk >= 0 ? '+' : '' }}{{ fmtKrk(netPnlKrk) }} KRK</span>
</div>
<div class="closed-item">
<span class="closed-item__label">Duration</span>
<span class="closed-item__value">{{ timeHeld }}</span>
</div>
</div>
</div>
</template>
<div v-else-if="!loading && !error" class="pos-not-found">Position not found.</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import { usePositionDashboard } from '@/composables/usePositionDashboard';
const route = useRoute();
const positionId = computed(() => String(route.params.id ?? ''));
const {
loading,
error,
position,
depositKrk,
taxPaidKrk,
currentValueKrk,
netReturnKrk,
taxRatePercent,
dailyTaxCost,
sharePercent,
createdFormatted,
lastTaxFormatted,
closedAtFormatted,
timeHeld,
snatchRisk,
payoutKrk,
netPnlKrk,
} = usePositionDashboard(positionId);
function fmtKrk(val: number): string {
if (!Number.isFinite(val)) return '0.00';
return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 });
}
function truncateAddr(addr: string): string {
if (!addr || addr.length < 10) return addr;
return `${addr.slice(0, 6)}${addr.slice(-4)}`;
}
// Days to breakeven: (currentValue - deposit) / dailyTaxCost
// Only meaningful if position is active and dailyTaxCost > 0
const breakevenDays = computed(() => {
if (!position.value || position.value.status !== 'Active') return 'N/A';
const dtc = dailyTaxCost.value;
if (dtc <= 0) return 'N/A';
// daily gain current_value growth but we don't have real-time growth rate here
// fallback: show just daily cost relative to net return
if (netReturnKrk.value >= 0) return 'Already profitable';
const daysLeft = Math.abs(netReturnKrk.value) / dtc;
return `~${Math.ceil(daysLeft)} days`;
});
</script>
<style lang="sass" scoped>
.position-view
display: flex
flex-direction: column
gap: 28px
padding: 16px
max-width: 1200px
margin: 0 auto
@media (min-width: 992px)
padding: 48px
// Loading/Error
.pos-loading
display: flex
align-items: center
gap: 12px
color: #9A9898
padding: 32px
justify-content: center
.pos-error
background: rgba(248, 113, 113, 0.1)
border: 1px solid rgba(248, 113, 113, 0.3)
border-radius: 12px
padding: 16px
color: #F87171
.pos-not-found
text-align: center
color: #9A9898
padding: 64px
.spinner
width: 20px
height: 20px
border: 2px solid rgba(117, 80, 174, 0.3)
border-top-color: #7550AE
border-radius: 50%
animation: spin 0.8s linear infinite
@keyframes spin
to
transform: rotate(360deg)
// Header
.pos-header
background: linear-gradient(135deg, rgba(117, 80, 174, 0.15) 0%, rgba(117, 80, 174, 0.05) 100%)
border: 1px solid rgba(117, 80, 174, 0.3)
border-radius: 20px
padding: 24px 32px
display: flex
flex-direction: column
gap: 16px
@media (min-width: 768px)
flex-direction: row
align-items: center
justify-content: space-between
&__left
display: flex
align-items: center
gap: 16px
&__id
font-family: "Audiowide", sans-serif
font-size: 28px
color: #ffffff
@media (min-width: 768px)
font-size: 36px
&__owner
display: flex
flex-direction: column
gap: 4px
&__owner-label
font-size: 11px
text-transform: uppercase
letter-spacing: 1px
color: #9A9898
&__owner-addr
font-family: monospace
color: #7550AE
text-decoration: none
font-size: 15px
&:hover
color: #9A70DE
text-decoration: underline
.status-badge
padding: 6px 14px
border-radius: 99px
font-size: 13px
font-weight: 700
text-transform: uppercase
letter-spacing: 0.5px
&--active
background: rgba(74, 222, 128, 0.15)
color: #4ADE80
border: 1px solid rgba(74, 222, 128, 0.3)
&--closed
background: rgba(255,255,255,0.08)
color: #9A9898
border: 1px solid rgba(255,255,255,0.15)
// Overview Cards
.overview-cards
display: flex
flex-direction: column
gap: 16px
@media (min-width: 768px)
flex-direction: row
.ov-card
flex: 1
background: #07111B
border: 1px solid rgba(117, 80, 174, 0.2)
border-radius: 16px
padding: 20px 24px
display: flex
flex-direction: column
gap: 4px
&--positive
border-color: rgba(74, 222, 128, 0.3)
.ov-card__value
color: #4ADE80
&--negative
border-color: rgba(248, 113, 113, 0.3)
.ov-card__value
color: #F87171
&__label
font-size: 11px
text-transform: uppercase
letter-spacing: 1px
color: #9A9898
&__value
font-family: "Audiowide", sans-serif
font-size: 24px
color: #ffffff
line-height: 1.2
&__unit
font-size: 12px
color: #7550AE
font-weight: 600
// Detail Grid
.detail-grid
display: flex
flex-direction: column
gap: 20px
@media (min-width: 768px)
flex-direction: row
.detail-panel
flex: 1
background: #07111B
border: 1px solid rgba(117, 80, 174, 0.15)
border-radius: 16px
padding: 24px
display: flex
flex-direction: column
gap: 16px
.panel-title
font-size: 16px
font-weight: 600
color: #ffffff
margin: 0
padding-bottom: 12px
border-bottom: 1px solid rgba(117, 80, 174, 0.2)
.detail-rows
display: flex
flex-direction: column
gap: 12px
.detail-row
display: flex
justify-content: space-between
align-items: center
gap: 8px
&__label
font-size: 13px
color: #9A9898
&__value
font-size: 13px
color: #ffffff
font-weight: 600
text-align: right
.muted
color: #9A9898
font-weight: 400
font-size: 12px
// Snatch Risk
.snatch-risk
margin-top: 8px
padding: 16px
border-radius: 12px
border: 1px solid var(--risk-color, #9A9898)
background: color-mix(in srgb, var(--risk-color, #9A9898) 10%, transparent)
display: flex
flex-direction: column
gap: 6px
&__title
font-size: 11px
text-transform: uppercase
letter-spacing: 1px
color: #9A9898
&__badge
font-size: 20px
font-weight: 700
color: var(--risk-color, #9A9898)
font-family: "Audiowide", sans-serif
&__detail
font-size: 13px
color: #9A9898
// Closed Summary
.closed-summary
background: #07111B
border: 1px solid rgba(255,255,255,0.1)
border-radius: 16px
padding: 24px
display: flex
flex-direction: column
gap: 16px
.closed-grid
display: flex
flex-wrap: wrap
gap: 24px
.closed-item
display: flex
flex-direction: column
gap: 4px
min-width: 120px
&.positive
.closed-item__value
color: #4ADE80
&.negative
.closed-item__value
color: #F87171
&__label
font-size: 11px
text-transform: uppercase
letter-spacing: 0.5px
color: #9A9898
&__value
font-family: "Audiowide", sans-serif
font-size: 18px
color: #ffffff
</style>

View file

@ -0,0 +1,492 @@
<template>
<div class="wallet-view">
<!-- Header -->
<div class="wallet-header">
<div class="wallet-header__address">
<span class="wallet-header__label">Wallet</span>
<div class="wallet-header__addr-row">
<span class="wallet-header__addr-full">{{ truncatedAddress }}</span>
<button class="copy-btn" :class="{ copied: copied }" @click="copyAddress" title="Copy address">
{{ copied ? '✓' : '⎘' }}
</button>
</div>
</div>
<div class="wallet-header__balance">
<span class="wallet-header__balance-num">{{ formatKrk(balanceKrk) }}</span>
<span class="wallet-header__balance-sym">KRK</span>
</div>
</div>
<!-- Loading / Error -->
<div v-if="loading && !balanceKrk" class="wallet-loading">
<div class="spinner"></div>
<p>Loading wallet data</p>
</div>
<div v-if="error" class="wallet-error"> {{ error }}</div>
<!-- Value Cards -->
<div class="value-cards">
<div class="value-card">
<div class="value-card__label">Your KRK Balance</div>
<div class="value-card__value">{{ formatKrk(balanceKrk) }}</div>
<div class="value-card__unit">KRK</div>
</div>
<div class="value-card">
<div class="value-card__label">ETH Backing</div>
<div class="value-card__value">{{ formatEth(ethBacking) }}</div>
<div class="value-card__unit">ETH</div>
</div>
<div class="value-card">
<div class="value-card__label">Floor Value</div>
<div class="value-card__value">{{ formatEth(floorValue) }}</div>
<div class="value-card__unit">ETH</div>
</div>
</div>
<!-- Protocol Health Strip -->
<div class="health-strip" v-if="stats">
<div class="health-strip__title">Protocol Health</div>
<div class="health-strip__items">
<div class="health-item">
<span class="health-item__label">ETH Reserve</span>
<span class="health-item__value">{{ formatEth(ethReserveNum) }} ETH</span>
</div>
<span class="health-sep">·</span>
<div class="health-item">
<span class="health-item__label">Reserve Growth</span>
<span class="health-item__value" :class="{ positive: ethGrowthBps > 0, negative: ethGrowthBps < 0 }">
{{ ethGrowthBps > 0 ? '+' : '' }}{{ (ethGrowthBps / 100).toFixed(2) }}%
</span>
</div>
<span class="health-sep">·</span>
<div class="health-item">
<span class="health-item__label">Rebalances / 7d</span>
<span class="health-item__value">{{ stats.recentersLastWeek }}</span>
</div>
<span class="health-sep">·</span>
<div class="health-item">
<span class="health-item__label">Holders</span>
<span class="health-item__value">{{ stats.holderCount.toLocaleString() }}</span>
</div>
<span class="health-sep">·</span>
<div class="health-item">
<span class="health-item__label">Floor Price</span>
<span class="health-item__value">{{ formatEth(floorPriceEth) }} ETH</span>
</div>
</div>
</div>
<!-- Staking Positions -->
<div class="positions-section">
<h3 class="positions-section__title">
Staking Positions
<span class="positions-section__count">{{ positions.length }}</span>
</h3>
<div v-if="positions.length === 0 && !loading" class="positions-empty">No staking positions found for this address.</div>
<div v-else class="positions-list">
<!-- Active positions -->
<template v-if="activePositions.length > 0">
<div class="positions-group-label">Active</div>
<RouterLink
v-for="pos in activePositions"
:key="pos.id"
:to="{ name: 'position', params: { id: pos.id } }"
class="position-row active"
>
<div class="position-row__id">#{{ pos.id }}</div>
<div class="position-row__deposit">
<span class="position-row__field-label">Deposit</span>
<span class="position-row__field-val">{{ formatKrk(tokenAmount(pos.kraikenDeposit)) }} KRK</span>
</div>
<div class="position-row__tax">
<span class="position-row__field-label">Tax Rate</span>
<span class="position-row__field-val">{{ (Number(pos.taxRate) * 100).toFixed(2) }}%</span>
</div>
<div class="position-row__paid">
<span class="position-row__field-label">Tax Paid</span>
<span class="position-row__field-val">{{ formatKrk(tokenAmount(pos.taxPaid)) }} KRK</span>
</div>
<div class="position-row__status">
<span class="status-badge status-badge--active">Active</span>
</div>
<div class="position-row__arrow"></div>
</RouterLink>
</template>
<!-- Closed positions -->
<template v-if="closedPositions.length > 0">
<div class="positions-group-label">Closed</div>
<RouterLink
v-for="pos in closedPositions"
:key="pos.id"
:to="{ name: 'position', params: { id: pos.id } }"
class="position-row closed"
>
<div class="position-row__id">#{{ pos.id }}</div>
<div class="position-row__deposit">
<span class="position-row__field-label">Deposit</span>
<span class="position-row__field-val">{{ formatKrk(tokenAmount(pos.kraikenDeposit)) }} KRK</span>
</div>
<div class="position-row__payout">
<span class="position-row__field-label">Payout</span>
<span class="position-row__field-val">{{ pos.payout ? formatKrk(tokenAmount(pos.payout)) + ' KRK' : 'N/A' }}</span>
</div>
<div class="position-row__status">
<span class="status-badge status-badge--closed">Closed</span>
</div>
<div class="position-row__arrow"></div>
</RouterLink>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import { useWalletDashboard } from '@/composables/useWalletDashboard';
const route = useRoute();
const addressParam = computed(() => String(route.params.address ?? ''));
const { loading, error, balanceKrk, ethBacking, floorValue, stats, positions, activePositions, closedPositions } =
useWalletDashboard(addressParam);
const copied = ref(false);
const truncatedAddress = computed(() => {
const a = addressParam.value;
if (!a || a.length < 10) return a;
return `${a.slice(0, 6)}${a.slice(-4)}`;
});
function copyAddress() {
navigator.clipboard.writeText(addressParam.value).then(() => {
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
});
}
function formatKrk(val: number): string {
if (!Number.isFinite(val)) return '0.00';
return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 });
}
function formatEth(val: number): string {
if (!Number.isFinite(val)) return '0.0000';
return val.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 });
}
function tokenAmount(rawWei: string, decimals = 18): number {
try {
const big = BigInt(rawWei ?? '0');
return Number(big) / 10 ** decimals;
} catch {
return 0;
}
}
const ethReserveNum = computed(() => tokenAmount(stats.value?.lastEthReserve ?? '0'));
const floorPriceEth = computed(() => tokenAmount(stats.value?.floorPriceWei ?? '0'));
const ethGrowthBps = computed(() => Number(stats.value?.ethReserveGrowthBps ?? 0));
</script>
<style lang="sass" scoped>
.wallet-view
display: flex
flex-direction: column
gap: 28px
padding: 16px
max-width: 1200px
margin: 0 auto
@media (min-width: 992px)
padding: 48px
// Header
.wallet-header
background: linear-gradient(135deg, rgba(117, 80, 174, 0.15) 0%, rgba(117, 80, 174, 0.05) 100%)
border: 1px solid rgba(117, 80, 174, 0.3)
border-radius: 20px
padding: 28px 32px
display: flex
flex-direction: column
gap: 16px
@media (min-width: 768px)
flex-direction: row
align-items: center
justify-content: space-between
&__label
font-size: 12px
text-transform: uppercase
letter-spacing: 1.5px
color: #7550AE
margin-bottom: 6px
display: block
&__addr-row
display: flex
align-items: center
gap: 10px
&__addr-full
font-size: 16px
color: #ffffff
font-family: monospace
word-break: break-all
&__balance
display: flex
align-items: baseline
gap: 8px
&__balance-num
font-family: "Audiowide", sans-serif
font-size: 36px
color: #7550AE
@media (min-width: 768px)
font-size: 44px
&__balance-sym
font-size: 18px
color: #9A9898
.copy-btn
background: rgba(117, 80, 174, 0.2)
border: 1px solid rgba(117, 80, 174, 0.4)
border-radius: 6px
color: #ffffff
cursor: pointer
font-size: 16px
padding: 4px 10px
transition: background 0.2s
&:hover
background: rgba(117, 80, 174, 0.4)
&.copied
color: #4ADE80
border-color: #4ADE80
// Loading/Error
.wallet-loading
display: flex
align-items: center
gap: 12px
color: #9A9898
padding: 16px
.wallet-error
background: rgba(248, 113, 113, 0.1)
border: 1px solid rgba(248, 113, 113, 0.3)
border-radius: 12px
padding: 16px
color: #F87171
.spinner
width: 20px
height: 20px
border: 2px solid rgba(117, 80, 174, 0.3)
border-top-color: #7550AE
border-radius: 50%
animation: spin 0.8s linear infinite
@keyframes spin
to
transform: rotate(360deg)
// Value Cards
.value-cards
display: flex
flex-direction: column
gap: 16px
@media (min-width: 768px)
flex-direction: row
.value-card
flex: 1
background: #07111B
border: 1px solid rgba(117, 80, 174, 0.2)
border-radius: 16px
padding: 24px
display: flex
flex-direction: column
gap: 6px
&__label
font-size: 12px
text-transform: uppercase
letter-spacing: 1px
color: #9A9898
&__value
font-family: "Audiowide", sans-serif
font-size: 28px
color: #ffffff
line-height: 1.2
&__unit
font-size: 13px
color: #7550AE
font-weight: 600
// Health Strip
.health-strip
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)
border-radius: 14px
padding: 16px 24px
display: flex
flex-direction: column
gap: 12px
&__title
font-size: 12px
text-transform: uppercase
letter-spacing: 1px
color: #7550AE
margin: 0
&__items
display: flex
flex-wrap: wrap
gap: 8px
align-items: center
.health-item
display: flex
gap: 6px
align-items: center
&__label
color: #9A9898
font-size: 13px
&__value
color: #ffffff
font-size: 13px
font-weight: 600
&.positive
color: #4ADE80
&.negative
color: #F87171
.health-sep
color: #444
padding: 0 2px
// Positions
.positions-section
display: flex
flex-direction: column
gap: 16px
&__title
display: flex
align-items: center
gap: 10px
font-size: 20px
color: #ffffff
margin: 0
&__count
background: rgba(117, 80, 174, 0.3)
color: #7550AE
border-radius: 99px
padding: 2px 10px
font-size: 14px
font-weight: 700
.positions-empty
color: #9A9898
padding: 24px
text-align: center
background: #07111B
border-radius: 12px
border: 1px solid rgba(255,255,255,0.07)
.positions-group-label
font-size: 11px
text-transform: uppercase
letter-spacing: 1.5px
color: #7550AE
padding: 4px 0
.positions-list
display: flex
flex-direction: column
gap: 8px
.position-row
display: flex
flex-wrap: wrap
align-items: center
gap: 16px
padding: 16px 20px
border-radius: 12px
border: 1px solid rgba(255,255,255,0.08)
text-decoration: none
transition: border-color 0.2s, background 0.2s
cursor: pointer
&.active
background: rgba(117, 80, 174, 0.06)
&:hover
border-color: rgba(117, 80, 174, 0.4)
background: rgba(117, 80, 174, 0.12)
&.closed
background: rgba(255,255,255,0.03)
&:hover
border-color: rgba(255,255,255,0.2)
background: rgba(255,255,255,0.05)
&__id
font-family: "Audiowide", sans-serif
color: #7550AE
font-size: 14px
min-width: 70px
&__deposit, &__tax, &__paid, &__payout
display: flex
flex-direction: column
gap: 2px
min-width: 100px
&__field-label
font-size: 11px
color: #9A9898
text-transform: uppercase
letter-spacing: 0.5px
&__field-val
font-size: 14px
color: #ffffff
font-weight: 600
&__status
margin-left: auto
&__arrow
color: #7550AE
font-size: 18px
.status-badge
padding: 4px 12px
border-radius: 99px
font-size: 12px
font-weight: 700
text-transform: uppercase
letter-spacing: 0.5px
&--active
background: rgba(74, 222, 128, 0.15)
color: #4ADE80
border: 1px solid rgba(74, 222, 128, 0.3)
&--closed
background: rgba(255,255,255,0.08)
color: #9A9898
border: 1px solid rgba(255,255,255,0.15)
</style>