+
@@ -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
diff --git a/services/ponder/ponder.schema.ts b/services/ponder/ponder.schema.ts
index b143a20..1418fb3 100644
--- a/services/ponder/ponder.schema.ts
+++ b/services/ponder/ponder.schema.ts
@@ -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),
diff --git a/services/ponder/src/helpers/logger.ts b/services/ponder/src/helpers/logger.ts
new file mode 100644
index 0000000..4da5ab5
--- /dev/null
+++ b/services/ponder/src/helpers/logger.ts
@@ -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;
+}
diff --git a/services/ponder/src/helpers/stats.ts b/services/ponder/src/helpers/stats.ts
index fc93130..ec026af 100644
--- a/services/ponder/src/helpers/stats.ts
+++ b/services/ponder/src/helpers/stats.ts
@@ -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
>) {
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;
}
diff --git a/services/ponder/src/kraiken.ts b/services/ponder/src/kraiken.ts
index d78c59b..fcfd8c8 100644
--- a/services/ponder/src/kraiken.ts
+++ b/services/ponder/src/kraiken.ts
@@ -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,
});
diff --git a/services/ponder/src/lm.ts b/services/ponder/src/lm.ts
index d7e2f9a..f2b91f3 100644
--- a/services/ponder/src/lm.ts
+++ b/services/ponder/src/lm.ts
@@ -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);
}
diff --git a/tests/e2e/06-dashboard-pages.spec.ts b/tests/e2e/06-dashboard-pages.spec.ts
new file mode 100644
index 0000000..7b5a607
--- /dev/null
+++ b/tests/e2e/06-dashboard-pages.spec.ts
@@ -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();
+ }
+ });
+ });
+});
diff --git a/tests/e2e/usertest/setup-chain-state.ts b/tests/e2e/usertest/setup-chain-state.ts
index 53054ad..aaf145f 100644
--- a/tests/e2e/usertest/setup-chain-state.ts
+++ b/tests/e2e/usertest/setup-chain-state.ts
@@ -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)...');
diff --git a/tests/e2e/usertest/test-landing-variants.spec.ts b/tests/e2e/usertest/test-landing-variants.spec.ts
index 6913c81..5a01e65 100644
--- a/tests/e2e/usertest/test-landing-variants.spec.ts
+++ b/tests/e2e/usertest/test-landing-variants.spec.ts
@@ -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',
diff --git a/web-app/src/composables/usePositionDashboard.ts b/web-app/src/composables/usePositionDashboard.ts
new file mode 100644
index 0000000..e26751e
--- /dev/null
+++ b/web-app/src/composables/usePositionDashboard.ts
@@ -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) {
+ const position = ref(null);
+ const stats = ref(null);
+ const allActivePositions = ref([]);
+ const loading = ref(false);
+ const error = ref(null);
+ let pollTimer: ReturnType | 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,
+ };
+}
diff --git a/web-app/src/composables/useWalletDashboard.ts b/web-app/src/composables/useWalletDashboard.ts
new file mode 100644
index 0000000..209dd12
--- /dev/null
+++ b/web-app/src/composables/useWalletDashboard.ts
@@ -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) {
+ const holderBalance = ref('0');
+ const stats = ref(null);
+ const positions = ref([]);
+ const loading = ref(false);
+ const error = ref(null);
+ let pollTimer: ReturnType | 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,
+ };
+}
diff --git a/web-app/src/router/index.ts b/web-app/src/router/index.ts
index fca916d..6e25e4f 100644
--- a/web-app/src/router/index.ts
+++ b/web-app/src/router/index.ts
@@ -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'),
+ },
],
});
diff --git a/web-app/src/views/PositionView.vue b/web-app/src/views/PositionView.vue
new file mode 100644
index 0000000..5528f1f
--- /dev/null
+++ b/web-app/src/views/PositionView.vue
@@ -0,0 +1,474 @@
+
+
+
+
+
⚠️ {{ error }}
+
+
+
+
+
+
+
+
+
Deposited
+
{{ fmtKrk(depositKrk) }}
+
KRK
+
+
+
Current Value
+
{{ fmtKrk(currentValueKrk) }}
+
KRK
+
+
+
Tax Paid
+
{{ fmtKrk(taxPaidKrk) }}
+
KRK
+
+
+
Net Return
+
{{ netReturnKrk >= 0 ? '+' : '' }}{{ fmtKrk(netReturnKrk) }}
+
KRK
+
+
+
+
+
+
+
+
Position Details
+
+
+ Tax Rate
+ {{ taxRatePercent.toFixed(2) }}% (index {{ position.taxRateIndex }})
+
+
+ Created
+ {{ createdFormatted }}
+
+
+ Time Held
+ {{ timeHeld }}
+
+
+ Last Tax Payment
+ {{ lastTaxFormatted }}
+
+
+ Times Snatched
+ {{ position.snatched }}
+
+
+
+ Closed At
+ {{ closedAtFormatted }}
+
+
+ Payout
+ {{ fmtKrk(payoutKrk) }} KRK
+
+
+
+
+
+
+
+
Metrics
+
+
+ Daily Tax Cost
+ {{ fmtKrk(dailyTaxCost) }} KRK/day
+
+
+ Share of Pool
+ {{ sharePercent.toFixed(4) }}%
+
+
+ Days to Breakeven
+ {{ breakevenDays }}
+
+
+
+
+
+
Snatch Risk
+
{{ snatchRisk.level }}
+
{{ snatchRisk.count }} position{{ snatchRisk.count !== 1 ? 's' : '' }} at lower tax rates
+
+
+
+
+
+
+
Final Summary
+
+
+ Deposited
+ {{ fmtKrk(depositKrk) }} KRK
+
+
+ Payout
+ {{ fmtKrk(payoutKrk) }} KRK
+
+
+ Tax Paid
+ {{ fmtKrk(taxPaidKrk) }} KRK
+
+
+ Net P&L
+ {{ netPnlKrk >= 0 ? '+' : '' }}{{ fmtKrk(netPnlKrk) }} KRK
+
+
+ Duration
+ {{ timeHeld }}
+
+
+
+
+
+
Position not found.
+
+
+
+
+
+
diff --git a/web-app/src/views/WalletView.vue b/web-app/src/views/WalletView.vue
new file mode 100644
index 0000000..4f038b3
--- /dev/null
+++ b/web-app/src/views/WalletView.vue
@@ -0,0 +1,492 @@
+
+
+
+
+
+
+
+
+
Loading wallet data…
+
+
⚠️ {{ error }}
+
+
+
+
+
Your KRK Balance
+
{{ formatKrk(balanceKrk) }}
+
KRK
+
+
+
ETH Backing
+
{{ formatEth(ethBacking) }}
+
ETH
+
+
+
Floor Value
+
{{ formatEth(floorValue) }}
+
ETH
+
+
+
+
+
+
Protocol Health
+
+
+ ETH Reserve
+ {{ formatEth(ethReserveNum) }} ETH
+
+
·
+
+ Reserve Growth
+
+ {{ ethGrowthBps > 0 ? '+' : '' }}{{ (ethGrowthBps / 100).toFixed(2) }}%
+
+
+
·
+
+ Rebalances / 7d
+ {{ stats.recentersLastWeek }}
+
+
·
+
+ Holders
+ {{ stats.holderCount.toLocaleString() }}
+
+
·
+
+ Floor Price
+ {{ formatEth(floorPriceEth) }} ETH
+
+
+
+
+
+
+
+ Staking Positions
+ {{ positions.length }}
+
+
+
No staking positions found for this address.
+
+
+
+
+ Active
+
+ #{{ pos.id }}
+
+ Deposit
+ {{ formatKrk(tokenAmount(pos.kraikenDeposit)) }} KRK
+
+
+ Tax Rate
+ {{ (Number(pos.taxRate) * 100).toFixed(2) }}%
+
+
+ Tax Paid
+ {{ formatKrk(tokenAmount(pos.taxPaid)) }} KRK
+
+
+ Active
+
+ →
+
+
+
+
+
+ Closed
+
+ #{{ pos.id }}
+
+ Deposit
+ {{ formatKrk(tokenAmount(pos.kraikenDeposit)) }} KRK
+
+
+ Payout
+ {{ pos.payout ? formatKrk(tokenAmount(pos.payout)) + ' KRK' : 'N/A' }}
+
+
+ Closed
+
+ →
+
+
+
+
+
+
+
+
+
+