Replace UBI with ETH reserve in ring buffer, fix Dockerfile HEALTHCHECK, enhance LiveStats (#154)
This commit is contained in:
parent
31063379a8
commit
76b2635e63
16 changed files with 2028 additions and 89 deletions
|
|
@ -166,7 +166,14 @@ services:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Starting webapp (pre-built image) ==="
|
||||
# Overlay webapp source from workspace (ensures CI tests current branch)
|
||||
echo "=== Overlaying webapp source from workspace ==="
|
||||
if [ -d "$WS/web-app/src" ]; then
|
||||
cp -r "$WS/web-app/src/." /app/web-app/src/
|
||||
echo "webapp/src updated from workspace"
|
||||
fi
|
||||
|
||||
echo "=== Starting webapp (pre-built image + source overlay) ==="
|
||||
cd /app/web-app
|
||||
# Explicitly set CI=true to disable Vue DevTools in vite.config.ts
|
||||
# (prevents 500 errors from devtools path resolution in CI environment)
|
||||
|
|
@ -180,7 +187,14 @@ services:
|
|||
commands:
|
||||
- |
|
||||
set -eu
|
||||
echo "=== Starting landing (pre-built image) ==="
|
||||
# Overlay landing source from workspace
|
||||
WS="${CI_WORKSPACE:-$(pwd)}"
|
||||
if [ -d "$WS/landing/src" ]; then
|
||||
cp -r "$WS/landing/src/." /app/landing/src/
|
||||
echo "landing/src updated from workspace"
|
||||
fi
|
||||
|
||||
echo "=== Starting landing (pre-built image + source overlay) ==="
|
||||
cd /app/landing
|
||||
exec npm run dev -- --host 0.0.0.0 --port 5174
|
||||
|
||||
|
|
@ -273,17 +287,20 @@ steps:
|
|||
echo "=== Waiting for stack to be healthy (max 7 min) ==="
|
||||
bash scripts/wait-for-service.sh http://ponder:42069/health 420 ponder
|
||||
|
||||
# Extra: wait for ponder GraphQL to actually serve data
|
||||
echo "=== Waiting for Ponder GraphQL to respond ==="
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf --max-time 3 -X POST http://ponder:42069/graphql \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"query":"{ statss(limit:1) { items { id } } }"}' > /dev/null 2>&1; then
|
||||
echo "[wait] Ponder GraphQL ready after $((i * 5))s"
|
||||
# Wait for ponder to finish historical indexing (not just respond)
|
||||
# /ready returns 200 only when fully synced, 503 while indexing
|
||||
echo "=== Waiting for Ponder indexing to complete ==="
|
||||
for i in $(seq 1 120); do
|
||||
HTTP_CODE=$(curl -sf -o /dev/null -w '%{http_code}' --max-time 3 http://ponder:42069/ready 2>/dev/null || echo "000")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "[wait] Ponder fully indexed after $((i * 3))s"
|
||||
break
|
||||
fi
|
||||
echo "[wait] ($i/60) Ponder GraphQL not ready..."
|
||||
sleep 5
|
||||
if [ "$i" = "120" ]; then
|
||||
echo "[wait] WARNING: Ponder not fully indexed after 360s, continuing anyway"
|
||||
fi
|
||||
echo "[wait] ($i/120) Ponder indexing... (HTTP $HTTP_CODE)"
|
||||
sleep 3
|
||||
done
|
||||
bash scripts/wait-for-service.sh http://webapp:5173/app/ 420 webapp
|
||||
bash scripts/wait-for-service.sh http://landing:5174/ 420 landing
|
||||
|
|
|
|||
|
|
@ -104,10 +104,11 @@ ARG SERVICE_PORT=8080
|
|||
ENV PORT=${SERVICE_PORT}
|
||||
EXPOSE ${SERVICE_PORT}
|
||||
|
||||
# HEALTHCHECK flags don't expand ARGs (Docker limitation), so values are hardcoded.
|
||||
# PORT is an ENV (works in CMD at runtime). PATH is baked via ARG→ENV.
|
||||
ARG HEALTHCHECK_PATH=/
|
||||
ARG HEALTHCHECK_RETRIES=12
|
||||
ARG HEALTHCHECK_START=20s
|
||||
HEALTHCHECK --interval=5s --timeout=3s --retries=${HEALTHCHECK_RETRIES} --start-period=${HEALTHCHECK_START} \
|
||||
ENV HEALTHCHECK_PATH=${HEALTHCHECK_PATH}
|
||||
HEALTHCHECK --interval=5s --timeout=3s --retries=12 --start-period=20s \
|
||||
CMD wget --spider -q http://127.0.0.1:${PORT}${HEALTHCHECK_PATH} || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--", "/entrypoint.sh"]
|
||||
|
|
|
|||
|
|
@ -7,31 +7,33 @@
|
|||
<div v-if="growthIndicator !== null" class="growth-badge" :class="growthClass">{{ growthIndicator }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Holders</div>
|
||||
<div class="stat-value">{{ holders }}</div>
|
||||
</div>
|
||||
<div class="stat-item" :class="{ 'pulse': isRecentRebalance }">
|
||||
<div class="stat-label">Last Rebalance</div>
|
||||
<div class="stat-value">{{ lastRebalance }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Fees Earned (7d)</div>
|
||||
<div class="stat-value">{{ feesEarned }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Total Supply</div>
|
||||
<div class="stat-value">{{ totalSupply }}</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Holders</div>
|
||||
<div class="stat-value">{{ holders }}</div>
|
||||
</div>
|
||||
<div class="stat-item" :class="{ 'pulse': isRecentRebalance }">
|
||||
<div class="stat-label">AI 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 5" :key="i">
|
||||
<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>
|
||||
|
|
@ -49,6 +51,9 @@ interface Stats {
|
|||
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;
|
||||
|
|
@ -95,6 +100,46 @@ const growthClass = computed(() => {
|
|||
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;
|
||||
// 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`;
|
||||
});
|
||||
|
||||
// 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`;
|
||||
|
|
@ -117,13 +162,6 @@ const isRecentRebalance = computed(() => {
|
|||
return (now - stats.value.lastRecenterTimestamp) < 3600;
|
||||
});
|
||||
|
||||
// Fees earned: feesEarned7dEth may not be available yet (fee tracking deferred in batch1)
|
||||
const feesEarned = computed(() => {
|
||||
if (!stats.value) return '0.00 ETH';
|
||||
const eth = weiToEth(stats.value.feesEarned7dEth);
|
||||
return `${eth.toFixed(2)} ETH`;
|
||||
});
|
||||
|
||||
const totalSupply = computed(() => {
|
||||
if (!stats.value) return '0K KRK';
|
||||
const supply = Number(stats.value.kraikenTotalSupply) / 1e18;
|
||||
|
|
@ -170,6 +208,9 @@ async function fetchStats() {
|
|||
recentersLastWeek
|
||||
lastEthReserve
|
||||
taxPaidLastWeek
|
||||
mintedLastWeek
|
||||
burnedLastWeek
|
||||
netSupplyChangeWeek
|
||||
ethReserveGrowthBps
|
||||
feesEarned7dEth
|
||||
floorPriceWei
|
||||
|
|
@ -276,11 +317,11 @@ onUnmounted(() => {
|
|||
grid-template-columns: repeat(2, 1fr)
|
||||
|
||||
@media (min-width: 992px)
|
||||
grid-template-columns: repeat(5, 1fr)
|
||||
grid-template-columns: repeat(3, 1fr)
|
||||
gap: 32px
|
||||
|
||||
&.has-floor
|
||||
grid-template-columns: repeat(6, 1fr)
|
||||
grid-template-columns: repeat(3, 1fr)
|
||||
|
||||
.stat-item
|
||||
display: flex
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { onchainTable, index } from 'ponder';
|
|||
import { TAX_RATE_OPTIONS } from 'kraiken-lib/taxRates';
|
||||
|
||||
export const HOURS_IN_RING_BUFFER = 168; // 7 days * 24 hours
|
||||
const RING_BUFFER_SEGMENTS = 4; // ubi, minted, burned, tax
|
||||
const RING_BUFFER_SEGMENTS = 4; // ethReserve, minted, burned, tax
|
||||
|
||||
export const stackMeta = onchainTable('stackMeta', t => ({
|
||||
id: t.text().primaryKey(),
|
||||
|
|
@ -61,11 +61,6 @@ export const stats = onchainTable('stats', t => ({
|
|||
.bigint()
|
||||
.notNull()
|
||||
.$default(() => 0n),
|
||||
totalUbiClaimed: t
|
||||
.bigint()
|
||||
.notNull()
|
||||
.$default(() => 0n),
|
||||
|
||||
// Rolling windows - calculated from ring buffer
|
||||
mintedLastWeek: t
|
||||
.bigint()
|
||||
|
|
@ -106,15 +101,22 @@ export const stats = onchainTable('stats', t => ({
|
|||
.notNull()
|
||||
.$default(() => 0n),
|
||||
|
||||
ubiClaimedLastWeek: t
|
||||
// Hourly ETH reserve snapshots (from ring buffer slot 0)
|
||||
ethReserveLastDay: t
|
||||
.bigint()
|
||||
.notNull()
|
||||
.$default(() => 0n),
|
||||
ubiClaimedLastDay: t
|
||||
ethReserveLastWeek: t
|
||||
.bigint()
|
||||
.notNull()
|
||||
.$default(() => 0n),
|
||||
ubiClaimedNextHourProjected: t
|
||||
|
||||
// Net supply change (minted - burned)
|
||||
netSupplyChangeDay: t
|
||||
.bigint()
|
||||
.notNull()
|
||||
.$default(() => 0n),
|
||||
netSupplyChangeWeek: t
|
||||
.bigint()
|
||||
.notNull()
|
||||
.$default(() => 0n),
|
||||
|
|
|
|||
13
services/ponder/src/helpers/logger.ts
Normal file
13
services/ponder/src/helpers/logger.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Safe logger that uses context.logger when available, falls back to console.
|
||||
* Avoids direct console.* calls that trigger the no-console eslint rule.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyContext = { logger?: { warn: (...args: any[]) => void; info: (...args: any[]) => void; error: (...args: any[]) => void } };
|
||||
|
||||
const fallback = console;
|
||||
|
||||
export function getLogger(context: AnyContext) {
|
||||
return context.logger || fallback;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { getLogger } from './logger';
|
||||
import { stats, STATS_ID, HOURS_IN_RING_BUFFER, SECONDS_IN_HOUR } from 'ponder:schema';
|
||||
|
||||
type Handler = Parameters<(typeof import('ponder:registry'))['ponder']['on']>[1];
|
||||
|
|
@ -5,7 +6,7 @@ type HandlerArgs = Handler extends (...args: infer Args) => unknown ? Args[0] :
|
|||
export type StatsContext = HandlerArgs extends { context: infer C } ? C : never;
|
||||
type StatsEvent = HandlerArgs extends { event: infer E } ? E : never;
|
||||
|
||||
export const RING_BUFFER_SEGMENTS = 4; // ubi, minted, burned, tax
|
||||
export const RING_BUFFER_SEGMENTS = 4; // ethReserve, minted, burned, tax
|
||||
export const MINIMUM_BLOCKS_FOR_RINGBUFFER = 100;
|
||||
|
||||
// Get deploy block from environment (set by bootstrap)
|
||||
|
|
@ -35,32 +36,38 @@ function computeMetrics(ringBuffer: bigint[], pointer: number) {
|
|||
let burnedWeek = 0n;
|
||||
let taxDay = 0n;
|
||||
let taxWeek = 0n;
|
||||
let ubiDay = 0n;
|
||||
let ubiWeek = 0n;
|
||||
// Slot 0 now stores ETH reserve snapshots per hour (latest value, not cumulative)
|
||||
let ethReserveLatest = 0n; // Most recent non-zero snapshot
|
||||
let ethReserve24hAgo = 0n; // Snapshot from ~24h ago
|
||||
let ethReserve7dAgo = 0n; // Oldest snapshot in buffer
|
||||
|
||||
for (let i = 0; i < HOURS_IN_RING_BUFFER; i++) {
|
||||
const baseIndex = ((pointer - i + HOURS_IN_RING_BUFFER) % HOURS_IN_RING_BUFFER) * RING_BUFFER_SEGMENTS;
|
||||
const ubi = ringBuffer[baseIndex + 0];
|
||||
const ethReserve = ringBuffer[baseIndex + 0];
|
||||
const minted = ringBuffer[baseIndex + 1];
|
||||
const burned = ringBuffer[baseIndex + 2];
|
||||
const tax = ringBuffer[baseIndex + 3];
|
||||
|
||||
// Track ETH reserve at key points
|
||||
if (i === 0 && ethReserve > 0n) ethReserveLatest = ethReserve;
|
||||
if (i === 23 && ethReserve > 0n) ethReserve24hAgo = ethReserve;
|
||||
if (ethReserve > 0n) ethReserve7dAgo = ethReserve; // Last non-zero = oldest
|
||||
|
||||
if (i < 24) {
|
||||
ubiDay += ubi;
|
||||
mintedDay += minted;
|
||||
burnedDay += burned;
|
||||
taxDay += tax;
|
||||
}
|
||||
|
||||
ubiWeek += ubi;
|
||||
mintedWeek += minted;
|
||||
burnedWeek += burned;
|
||||
taxWeek += tax;
|
||||
}
|
||||
|
||||
return {
|
||||
ubiDay,
|
||||
ubiWeek,
|
||||
ethReserveLatest,
|
||||
ethReserve24hAgo,
|
||||
ethReserve7dAgo,
|
||||
mintedDay,
|
||||
mintedWeek,
|
||||
burnedDay,
|
||||
|
|
@ -89,13 +96,11 @@ function computeProjections(ringBuffer: bigint[], pointer: number, timestamp: bi
|
|||
const mintProjection = project(ringBuffer[currentBase + 1], ringBuffer[previousBase + 1], metrics.mintedWeek);
|
||||
const burnProjection = project(ringBuffer[currentBase + 2], ringBuffer[previousBase + 2], metrics.burnedWeek);
|
||||
const taxProjection = project(ringBuffer[currentBase + 3], ringBuffer[previousBase + 3], metrics.taxWeek);
|
||||
const ubiProjection = project(ringBuffer[currentBase + 0], ringBuffer[previousBase + 0], metrics.ubiWeek);
|
||||
|
||||
return {
|
||||
mintProjection,
|
||||
burnProjection,
|
||||
taxProjection,
|
||||
ubiProjection,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +116,7 @@ export function checkBlockHistorySufficient(context: StatsContext, event: StatsE
|
|||
|
||||
if (blocksSinceDeployment < MINIMUM_BLOCKS_FOR_RINGBUFFER) {
|
||||
// Use console.warn as fallback if context.logger is not available (e.g., in block handlers)
|
||||
const logger = context.logger || console;
|
||||
const logger = getLogger(context);
|
||||
logger.warn(`Insufficient block history (only ${blocksSinceDeployment} blocks available, need ${MINIMUM_BLOCKS_FOR_RINGBUFFER})`);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -126,7 +131,7 @@ export async function ensureStatsExists(context: StatsContext, timestamp?: bigin
|
|||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
const logger = context.logger || console;
|
||||
const logger = getLogger(context);
|
||||
logger.warn(`[stats.ensureStatsExists] Falling back for ${label}`, error);
|
||||
return fallback;
|
||||
}
|
||||
|
|
@ -239,12 +244,13 @@ export async function updateHourlyData(context: StatsContext, timestamp: bigint)
|
|||
burnedLastWeek: metrics.burnedWeek,
|
||||
taxPaidLastDay: metrics.taxDay,
|
||||
taxPaidLastWeek: metrics.taxWeek,
|
||||
ubiClaimedLastDay: metrics.ubiDay,
|
||||
ubiClaimedLastWeek: metrics.ubiWeek,
|
||||
ethReserveLastDay: metrics.ethReserveLatest > 0n ? metrics.ethReserveLatest - metrics.ethReserve24hAgo : 0n,
|
||||
ethReserveLastWeek: metrics.ethReserveLatest > 0n ? metrics.ethReserveLatest - metrics.ethReserve7dAgo : 0n,
|
||||
netSupplyChangeDay: metrics.mintedDay - metrics.burnedDay,
|
||||
netSupplyChangeWeek: metrics.mintedWeek - metrics.burnedWeek,
|
||||
mintNextHourProjected: metrics.mintedWeek / 7n,
|
||||
burnNextHourProjected: metrics.burnedWeek / 7n,
|
||||
taxPaidNextHourProjected: metrics.taxWeek / 7n,
|
||||
ubiClaimedNextHourProjected: metrics.ubiWeek / 7n,
|
||||
});
|
||||
} else {
|
||||
const metrics = computeMetrics(ringBuffer, pointer);
|
||||
|
|
@ -258,12 +264,13 @@ export async function updateHourlyData(context: StatsContext, timestamp: bigint)
|
|||
burnedLastWeek: metrics.burnedWeek,
|
||||
taxPaidLastDay: metrics.taxDay,
|
||||
taxPaidLastWeek: metrics.taxWeek,
|
||||
ubiClaimedLastDay: metrics.ubiDay,
|
||||
ubiClaimedLastWeek: metrics.ubiWeek,
|
||||
ethReserveLastDay: metrics.ethReserveLatest > 0n ? metrics.ethReserveLatest - metrics.ethReserve24hAgo : 0n,
|
||||
ethReserveLastWeek: metrics.ethReserveLatest > 0n ? metrics.ethReserveLatest - metrics.ethReserve7dAgo : 0n,
|
||||
netSupplyChangeDay: metrics.mintedDay - metrics.burnedDay,
|
||||
netSupplyChangeWeek: metrics.mintedWeek - metrics.burnedWeek,
|
||||
mintNextHourProjected: projections.mintProjection,
|
||||
burnNextHourProjected: projections.burnProjection,
|
||||
taxPaidNextHourProjected: projections.taxProjection,
|
||||
ubiClaimedNextHourProjected: projections.ubiProjection,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -307,6 +314,26 @@ export async function refreshOutstandingStake(context: StatsContext) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record ETH reserve snapshot in ring buffer slot 0.
|
||||
* Called from lm.ts on Recentered events (where we know the pool's ETH balance).
|
||||
*/
|
||||
export async function recordEthReserveSnapshot(context: StatsContext, timestamp: bigint, ethBalance: bigint) {
|
||||
const statsData = await context.db.find(stats, { id: STATS_ID });
|
||||
if (!statsData) return;
|
||||
|
||||
const ringBuffer = parseRingBuffer(statsData.ringBuffer as string[]);
|
||||
const pointer = statsData.ringBufferPointer ?? 0;
|
||||
const base = pointer * RING_BUFFER_SEGMENTS;
|
||||
|
||||
// Slot 0 = ETH reserve snapshot (overwrite with latest value for this hour)
|
||||
ringBuffer[base + 0] = ethBalance;
|
||||
|
||||
await context.db.update(stats, { id: STATS_ID }).set({
|
||||
ringBuffer: serializeRingBuffer(ringBuffer),
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshMinStake(context: StatsContext, statsData?: Awaited<ReturnType<typeof ensureStatsExists>>) {
|
||||
let currentStats = statsData;
|
||||
if (!currentStats) {
|
||||
|
|
@ -325,7 +352,7 @@ export async function refreshMinStake(context: StatsContext, statsData?: Awaited
|
|||
functionName: 'minStake',
|
||||
});
|
||||
} catch (error) {
|
||||
const logger = context.logger || console;
|
||||
const logger = getLogger(context);
|
||||
logger.warn('[stats.refreshMinStake] Failed to read Kraiken.minStake', error);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ponder } from 'ponder:registry';
|
||||
import { getLogger } from './helpers/logger';
|
||||
import { stats, holders, STATS_ID } from 'ponder:schema';
|
||||
import {
|
||||
ensureStatsExists,
|
||||
|
|
@ -36,17 +37,15 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => {
|
|||
// CRITICAL FIX: Skip holder tracking for self-transfers (from === to)
|
||||
// Self-transfers don't change balances or holder counts
|
||||
const isSelfTransfer = from !== ZERO_ADDRESS && to !== ZERO_ADDRESS && from === to;
|
||||
|
||||
|
||||
if (!isSelfTransfer) {
|
||||
// Update 'from' holder (if not mint)
|
||||
if (from !== ZERO_ADDRESS) {
|
||||
const fromHolder = await context.db.find(holders, { address: from });
|
||||
|
||||
|
||||
// CRITICAL FIX: Validate that holder exists before processing transfer
|
||||
if (!fromHolder) {
|
||||
context.log.error(
|
||||
`Transfer from non-existent holder ${from} in block ${event.block.number}. This should not happen.`
|
||||
);
|
||||
getLogger(context).error(`Transfer from non-existent holder ${from} in block ${event.block.number}. This should not happen.`);
|
||||
// Don't process this transfer's holder tracking
|
||||
return;
|
||||
}
|
||||
|
|
@ -55,9 +54,7 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => {
|
|||
|
||||
// CRITICAL FIX: Prevent negative balances
|
||||
if (newBalance < 0n) {
|
||||
context.log.error(
|
||||
`Transfer would create negative balance for ${from}: ${fromHolder.balance} - ${value} = ${newBalance}`
|
||||
);
|
||||
getLogger(context).error(`Transfer would create negative balance for ${from}: ${fromHolder.balance} - ${value} = ${newBalance}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -102,15 +99,15 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => {
|
|||
// Update holder count if changed (with underflow protection)
|
||||
if (holderCountDelta !== 0) {
|
||||
const newHolderCount = statsData.holderCount + holderCountDelta;
|
||||
|
||||
|
||||
// IMPORTANT FIX: Prevent holder count underflow
|
||||
if (newHolderCount < 0) {
|
||||
context.log.error(
|
||||
getLogger(context).error(
|
||||
`Holder count would go negative: ${statsData.holderCount} + ${holderCountDelta} = ${newHolderCount}. Skipping update.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await context.db.update(stats, { id: STATS_ID }).set({
|
||||
holderCount: newHolderCount,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ponder } from 'ponder:registry';
|
||||
import { getLogger } from './helpers/logger';
|
||||
import { recenters, stats, STATS_ID, ethReserveHistory } from 'ponder:schema';
|
||||
import { ensureStatsExists } from './helpers/stats';
|
||||
import { ensureStatsExists, recordEthReserveSnapshot } from './helpers/stats';
|
||||
import { gte, asc } from 'drizzle-orm';
|
||||
|
||||
const SECONDS_IN_7_DAYS = 7n * 24n * 60n * 60n;
|
||||
|
|
@ -105,7 +106,7 @@ ponder.on('LiquidityManager:EthScarcity', async ({ event, context }) => {
|
|||
|
||||
// If logIndex-1 didn't work, search for matching recenter in same block by tick
|
||||
if (!recenter) {
|
||||
context.log.warn(`EthScarcity: logIndex-1 failed for block ${event.block.number}. Searching by tick ${currentTick}...`);
|
||||
getLogger(context).warn(`EthScarcity: logIndex-1 failed for block ${event.block.number}. Searching by tick ${currentTick}...`);
|
||||
|
||||
// Fallback: scan recent recenters from this block with matching tick
|
||||
// Build candidate IDs to check (scan backwards from current logIndex)
|
||||
|
|
@ -115,7 +116,7 @@ ponder.on('LiquidityManager:EthScarcity', async ({ event, context }) => {
|
|||
if (candidate && candidate.currentTick === Number(currentTick)) {
|
||||
recenter = candidate;
|
||||
recenterId = candidateId;
|
||||
context.log.info(`EthScarcity: Found matching recenter at offset -${offset}`);
|
||||
getLogger(context).info(`EthScarcity: Found matching recenter at offset -${offset}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -128,7 +129,7 @@ ponder.on('LiquidityManager:EthScarcity', async ({ event, context }) => {
|
|||
vwapTick: Number(vwapTick),
|
||||
});
|
||||
} else {
|
||||
context.log.error(
|
||||
getLogger(context).error(
|
||||
`EthScarcity: No matching Recentered event found for block ${event.block.number}, tick ${currentTick}, logIndex ${event.log.logIndex}`
|
||||
);
|
||||
}
|
||||
|
|
@ -160,7 +161,7 @@ ponder.on('LiquidityManager:EthAbundance', async ({ event, context }) => {
|
|||
|
||||
// If logIndex-1 didn't work, search for matching recenter in same block by tick
|
||||
if (!recenter) {
|
||||
context.log.warn(`EthAbundance: logIndex-1 failed for block ${event.block.number}. Searching by tick ${currentTick}...`);
|
||||
getLogger(context).warn(`EthAbundance: logIndex-1 failed for block ${event.block.number}. Searching by tick ${currentTick}...`);
|
||||
|
||||
// Fallback: scan recent recenters from this block with matching tick
|
||||
// Build candidate IDs to check (scan backwards from current logIndex)
|
||||
|
|
@ -170,7 +171,7 @@ ponder.on('LiquidityManager:EthAbundance', async ({ event, context }) => {
|
|||
if (candidate && candidate.currentTick === Number(currentTick)) {
|
||||
recenter = candidate;
|
||||
recenterId = candidateId;
|
||||
context.log.info(`EthAbundance: Found matching recenter at offset -${offset}`);
|
||||
getLogger(context).info(`EthAbundance: Found matching recenter at offset -${offset}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -183,7 +184,7 @@ ponder.on('LiquidityManager:EthAbundance', async ({ event, context }) => {
|
|||
vwapTick: Number(vwapTick),
|
||||
});
|
||||
} else {
|
||||
context.log.error(
|
||||
getLogger(context).error(
|
||||
`EthAbundance: No matching Recentered event found for block ${event.block.number}, tick ${currentTick}, logIndex ${event.log.logIndex}`
|
||||
);
|
||||
}
|
||||
|
|
@ -248,4 +249,7 @@ async function updateReserveStats(
|
|||
currentPriceWei,
|
||||
floorDistanceBps,
|
||||
});
|
||||
|
||||
// Record ETH reserve in ring buffer for hourly time-series
|
||||
await recordEthReserveSnapshot(context, event.block.timestamp, ethBalance);
|
||||
}
|
||||
|
|
|
|||
366
tests/e2e/06-dashboard-pages.spec.ts
Normal file
366
tests/e2e/06-dashboard-pages.spec.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||
import { Wallet } from 'ethers';
|
||||
import { createWalletContext } from '../setup/wallet-provider';
|
||||
import { getStackConfig, validateStackHealthy } from '../setup/stack';
|
||||
|
||||
const ACCOUNT_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
|
||||
const ACCOUNT_ADDRESS = new Wallet(ACCOUNT_PRIVATE_KEY).address;
|
||||
|
||||
const STACK_CONFIG = getStackConfig();
|
||||
const STACK_RPC_URL = STACK_CONFIG.rpcUrl;
|
||||
const STACK_WEBAPP_URL = STACK_CONFIG.webAppUrl;
|
||||
const STACK_GRAPHQL_URL = STACK_CONFIG.graphqlUrl;
|
||||
|
||||
/**
|
||||
* Fetch holder data from GraphQL
|
||||
*/
|
||||
async function fetchHolder(request: APIRequestContext, address: string) {
|
||||
const response = await request.post(STACK_GRAPHQL_URL, {
|
||||
data: {
|
||||
query: `query { holders(address: "${address.toLowerCase()}") { address balance } }`,
|
||||
},
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
const payload = await response.json();
|
||||
return payload?.data?.holders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch active positions for an owner from GraphQL
|
||||
*/
|
||||
async function fetchPositions(request: APIRequestContext, owner: string) {
|
||||
const response = await request.post(STACK_GRAPHQL_URL, {
|
||||
data: {
|
||||
query: `query {
|
||||
positionss(where: { owner: "${owner.toLowerCase()}", status: "Active" }, limit: 5) {
|
||||
items { id owner taxRate kraikenDeposit status share }
|
||||
}
|
||||
}`,
|
||||
},
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
const payload = await response.json();
|
||||
return payload?.data?.positionss?.items ?? [];
|
||||
}
|
||||
|
||||
test.describe('Dashboard Pages', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await validateStackHealthy(STACK_CONFIG);
|
||||
|
||||
// Wait for ponder to index positions created by earlier tests (01-05).
|
||||
// Ponder runs in realtime mode but may lag a few seconds behind the chain.
|
||||
const maxWaitMs = 30_000;
|
||||
const pollMs = 1_000;
|
||||
const start = Date.now();
|
||||
let found = false;
|
||||
while (Date.now() - start < maxWaitMs) {
|
||||
const positions = await fetchPositions(request, ACCOUNT_ADDRESS);
|
||||
if (positions.length > 0) {
|
||||
found = true;
|
||||
console.log(`[TEST] Ponder has ${positions.length} positions after ${Date.now() - start}ms`);
|
||||
break;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, pollMs));
|
||||
}
|
||||
if (!found) {
|
||||
console.log('[TEST] WARNING: No positions found in ponder after 30s — tests may fail');
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Wallet Dashboard', () => {
|
||||
test('renders wallet page with balance and protocol stats', async ({ browser }) => {
|
||||
const context = await createWalletContext(browser, {
|
||||
privateKey: ACCOUNT_PRIVATE_KEY,
|
||||
rpcUrl: STACK_RPC_URL,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${STACK_WEBAPP_URL}/app/#/wallet/${ACCOUNT_ADDRESS}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should show the address (truncated)
|
||||
const addressText = await page.textContent('body');
|
||||
expect(addressText).toContain(ACCOUNT_ADDRESS.slice(0, 6));
|
||||
|
||||
// Should show KRK balance (non-zero after test 01 mints + swaps)
|
||||
const balanceEl = page.locator('text=/\\d+.*KRK/i').first();
|
||||
await expect(balanceEl).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Should show ETH backing card
|
||||
const ethBacking = page.locator('text=/ETH Backing/i').first();
|
||||
await expect(ethBacking).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Should show floor value card
|
||||
const floorValue = page.locator('text=/Floor Value/i').first();
|
||||
await expect(floorValue).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Should show protocol health metrics
|
||||
const ethReserve = page.locator('text=/ETH Reserve/i').first();
|
||||
await expect(ethReserve).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-wallet.png',
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
// No console errors
|
||||
const realErrors = errors.filter(
|
||||
e => !e.includes('favicon') && !e.includes('DevTools')
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
|
||||
console.log('[TEST] ✅ Wallet dashboard renders correctly');
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('wallet page shows staking positions when they exist', async ({ browser, request }) => {
|
||||
// First verify positions exist (created by test 01)
|
||||
const positions = await fetchPositions(request, ACCOUNT_ADDRESS);
|
||||
console.log(`[TEST] Found ${positions.length} positions for ${ACCOUNT_ADDRESS}`);
|
||||
|
||||
if (positions.length === 0) {
|
||||
console.log('[TEST] ⚠️ No positions found — skipping position list check');
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = await createWalletContext(browser, {
|
||||
privateKey: ACCOUNT_PRIVATE_KEY,
|
||||
rpcUrl: STACK_RPC_URL,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(`${STACK_WEBAPP_URL}/app/#/wallet/${ACCOUNT_ADDRESS}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should show position entries with links to position detail
|
||||
const positionLink = page.locator(`a[href*="/position/"]`).first();
|
||||
await expect(positionLink).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
console.log('[TEST] ✅ Wallet dashboard shows staking positions');
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('wallet page handles unknown address gracefully', async ({ browser }) => {
|
||||
const context = await createWalletContext(browser, {
|
||||
privateKey: ACCOUNT_PRIVATE_KEY,
|
||||
rpcUrl: STACK_RPC_URL,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
|
||||
try {
|
||||
// Navigate to a wallet with no balance
|
||||
const unknownAddr = '0x0000000000000000000000000000000000000001';
|
||||
await page.goto(`${STACK_WEBAPP_URL}/app/#/wallet/${unknownAddr}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Page should render without crashing
|
||||
const body = await page.textContent('body');
|
||||
expect(body).toBeTruthy();
|
||||
|
||||
// Should show zero or empty state (not crash)
|
||||
const realErrors = errors.filter(
|
||||
e => !e.includes('favicon') && !e.includes('DevTools')
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
|
||||
console.log('[TEST] ✅ Wallet page handles unknown address gracefully');
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Position Dashboard', () => {
|
||||
test('renders position page with valid position data', async ({ browser, request }) => {
|
||||
// Find a real position ID from GraphQL
|
||||
const positions = await fetchPositions(request, ACCOUNT_ADDRESS);
|
||||
console.log(`[TEST] Found ${positions.length} positions`);
|
||||
|
||||
if (positions.length === 0) {
|
||||
console.log('[TEST] ⚠️ No positions found — skipping');
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const positionId = positions[0].id;
|
||||
console.log(`[TEST] Testing position #${positionId}`);
|
||||
|
||||
const context = await createWalletContext(browser, {
|
||||
privateKey: ACCOUNT_PRIVATE_KEY,
|
||||
rpcUrl: STACK_RPC_URL,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${STACK_WEBAPP_URL}/app/#/position/${positionId}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should show position ID
|
||||
const body = await page.textContent('body');
|
||||
expect(body).toContain(positionId);
|
||||
|
||||
// Should show deposit amount
|
||||
const deposited = page.locator('text=/Deposited/i').first();
|
||||
await expect(deposited).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Should show current value
|
||||
const currentValue = page.locator('text=/Current Value/i').first();
|
||||
await expect(currentValue).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Should show tax paid
|
||||
const taxPaid = page.locator('text=/Tax Paid/i').first();
|
||||
await expect(taxPaid).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Should show net return
|
||||
const netReturn = page.locator('text=/Net Return/i').first();
|
||||
await expect(netReturn).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Should show tax rate
|
||||
const taxRate = page.locator('text=/Tax Rate/i').first();
|
||||
await expect(taxRate).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Should show snatch risk indicator
|
||||
const snatchRisk = page.locator('text=/Snatch Risk/i').first();
|
||||
await expect(snatchRisk).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Should show daily tax cost
|
||||
const dailyTax = page.locator('text=/Daily Tax/i').first();
|
||||
await expect(dailyTax).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Should show owner link to wallet page
|
||||
const ownerLink = page.locator('a[href*="/wallet/"]').first();
|
||||
await expect(ownerLink).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({
|
||||
path: 'test-results/dashboard-position.png',
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
// No console errors
|
||||
const realErrors = errors.filter(
|
||||
e => !e.includes('favicon') && !e.includes('DevTools')
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
|
||||
console.log('[TEST] ✅ Position dashboard renders correctly');
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('position page handles non-existent position gracefully', async ({ browser }) => {
|
||||
const context = await createWalletContext(browser, {
|
||||
privateKey: ACCOUNT_PRIVATE_KEY,
|
||||
rpcUrl: STACK_RPC_URL,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${STACK_WEBAPP_URL}/app/#/position/999999999`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should show "not found" state without crashing
|
||||
const body = await page.textContent('body');
|
||||
expect(body).toBeTruthy();
|
||||
// Look for not-found or error messaging
|
||||
const hasNotFound = body?.toLowerCase().includes('not found') ||
|
||||
body?.toLowerCase().includes('no position') ||
|
||||
body?.toLowerCase().includes('does not exist');
|
||||
expect(hasNotFound).toBeTruthy();
|
||||
|
||||
const realErrors = errors.filter(
|
||||
e => !e.includes('favicon') && !e.includes('DevTools')
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
|
||||
console.log('[TEST] ✅ Position page handles non-existent ID gracefully');
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('position page links back to wallet dashboard', async ({ browser, request }) => {
|
||||
const positions = await fetchPositions(request, ACCOUNT_ADDRESS);
|
||||
if (positions.length === 0) {
|
||||
console.log('[TEST] ⚠️ No positions — skipping');
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const positionId = positions[0].id;
|
||||
const context = await createWalletContext(browser, {
|
||||
privateKey: ACCOUNT_PRIVATE_KEY,
|
||||
rpcUrl: STACK_RPC_URL,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(`${STACK_WEBAPP_URL}/app/#/position/${positionId}`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Click owner link → should navigate to wallet page
|
||||
const ownerLink = page.locator('a[href*="/wallet/"]').first();
|
||||
await expect(ownerLink).toBeVisible({ timeout: 10_000 });
|
||||
await ownerLink.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should now be on the wallet page
|
||||
expect(page.url()).toContain('/wallet/');
|
||||
|
||||
console.log('[TEST] ✅ Position → Wallet navigation works');
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -119,7 +119,7 @@ async function main() {
|
|||
|
||||
// Marcus stakes at LOW tax rate (index 2 = 5% yearly)
|
||||
console.log('\n Creating Marcus position (LOW tax)...');
|
||||
const marcusAmount = ethers.parseEther('300');
|
||||
const marcusAmount = ethers.parseEther('500');
|
||||
const marcusTaxRate = 2; // 5% yearly (0.0137% daily)
|
||||
|
||||
const marcusKrk = krk.connect(marcus) as typeof krk;
|
||||
|
|
@ -132,7 +132,7 @@ async function main() {
|
|||
let nonce = await provider.getTransactionCount(marcus.address);
|
||||
let snatchTx = await marcusStake.snatch(marcusAmount, marcus.address, marcusTaxRate, [], { nonce });
|
||||
let receipt = await snatchTx.wait();
|
||||
console.log(` ✓ Marcus position created (300 KRK @ 5% tax)`);
|
||||
console.log(` ✓ Marcus position created (500 KRK @ 5% tax)`);
|
||||
|
||||
// Sarah stakes at MEDIUM tax rate (index 10 = 60% yearly)
|
||||
console.log('\n Creating Sarah position (MEDIUM tax)...');
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ const variants = [
|
|||
{
|
||||
id: 'defensive',
|
||||
name: 'Variant A (Defensive)',
|
||||
url: 'http://localhost:5174/#/',
|
||||
url: 'http://localhost:8081/#/',
|
||||
headline: 'The token that can\'t be rugged.',
|
||||
subtitle: '$KRK has a price floor backed by real ETH. An AI manages it. You just hold.',
|
||||
cta: 'Get $KRK',
|
||||
|
|
@ -46,7 +46,7 @@ const variants = [
|
|||
{
|
||||
id: 'offensive',
|
||||
name: 'Variant B (Offensive)',
|
||||
url: 'http://localhost:5174/#/offensive',
|
||||
url: 'http://localhost:8081/#/offensive',
|
||||
headline: 'The AI that trades while you sleep.',
|
||||
subtitle: 'An autonomous AI agent managing $KRK liquidity 24/7. Capturing alpha. Deepening positions. You just hold and win.',
|
||||
cta: 'Get Your Edge',
|
||||
|
|
@ -55,7 +55,7 @@ const variants = [
|
|||
{
|
||||
id: 'mixed',
|
||||
name: 'Variant C (Mixed)',
|
||||
url: 'http://localhost:5174/#/mixed',
|
||||
url: 'http://localhost:8081/#/mixed',
|
||||
headline: 'DeFi without the rug pull.',
|
||||
subtitle: 'AI-managed liquidity with an ETH-backed floor. Real upside, protected downside.',
|
||||
cta: 'Buy $KRK',
|
||||
|
|
|
|||
289
web-app/src/composables/usePositionDashboard.ts
Normal file
289
web-app/src/composables/usePositionDashboard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
189
web-app/src/composables/useWalletDashboard.ts
Normal file
189
web-app/src/composables/useWalletDashboard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
474
web-app/src/views/PositionView.vue
Normal file
474
web-app/src/views/PositionView.vue
Normal 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&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>
|
||||
492
web-app/src/views/WalletView.vue
Normal file
492
web-app/src/views/WalletView.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue