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

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -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),

View 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;
}

View file

@ -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;
}

View file

@ -1,4 +1,5 @@
import { ponder } from 'ponder:registry';
import { getLogger } from './helpers/logger';
import { stats, holders, STATS_ID } from 'ponder:schema';
import {
ensureStatsExists,
@ -44,9 +45,7 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => {
// 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;
}
@ -105,7 +102,7 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => {
// 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;

View file

@ -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);
}

View 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();
}
});
});
});

View file

@ -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)...');

View file

@ -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',

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>