From 66106077baece98f2177dd5dc8cb566d427d36fc Mon Sep 17 00:00:00 2001 From: johba Date: Thu, 19 Feb 2026 16:22:23 +0100 Subject: [PATCH] feat: wallet P&L with average cost basis tracking (#156) (#158) --- services/ponder/ponder.schema.ts | 11 ++- services/ponder/src/kraiken.ts | 22 ++++++ web-app/src/composables/useWalletDashboard.ts | 35 +++++++++ web-app/src/views/WalletView.vue | 74 ++++++++++++++++++- 4 files changed, 139 insertions(+), 3 deletions(-) diff --git a/services/ponder/ponder.schema.ts b/services/ponder/ponder.schema.ts index 1418fb3..eeaedf5 100644 --- a/services/ponder/ponder.schema.ts +++ b/services/ponder/ponder.schema.ts @@ -259,12 +259,21 @@ export const recenters = onchainTable('recenters', t => ({ vwapTick: t.integer(), // nullable })); -// Holders - track Kraiken token holders +// Holders - track Kraiken token holders with cost basis for P&L export const holders = onchainTable( 'holders', t => ({ address: t.hex().primaryKey(), balance: t.bigint().notNull(), + // Cost basis tracking (updated on swaps only, not wallet-to-wallet transfers) + totalEthSpent: t + .bigint() + .notNull() + .$default(() => 0n), // cumulative ETH spent buying KRK + totalTokensAcquired: t + .bigint() + .notNull() + .$default(() => 0n), // cumulative KRK received from buys }), table => ({ addressIdx: index().on(table.address), diff --git a/services/ponder/src/kraiken.ts b/services/ponder/src/kraiken.ts index fcfd8c8..8158e22 100644 --- a/services/ponder/src/kraiken.ts +++ b/services/ponder/src/kraiken.ts @@ -14,6 +14,10 @@ import { validateContractVersion } from './helpers/version'; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; +// Pool address for detecting swaps (buys/sells) +// Computed deterministically from Uniswap V3 factory + WETH + Kraiken + 1% fee +const POOL_ADDRESS = (process.env.POOL_ADDRESS || '0x1f69cbfc7d3529a4fb4eadf18ec5644b2603b5ab').toLowerCase() as `0x${string}`; + // Track if version has been validated let versionValidated = false; @@ -77,9 +81,25 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => { const oldBalance = toHolder?.balance ?? 0n; const newBalance = oldBalance + value; + // Detect buy: tokens coming FROM the pool = user bought KRK + const isBuy = from.toLowerCase() === POOL_ADDRESS; + let ethSpentDelta = 0n; + + if (isBuy && value > 0n) { + // Approximate ETH cost using current price from stats + const currentPrice = statsData.currentPriceWei ?? 0n; + if (currentPrice > 0n) { + ethSpentDelta = (value * currentPrice) / 10n ** 18n; + } + } + if (toHolder) { await context.db.update(holders, { address: to }).set({ balance: newBalance, + ...(isBuy && { + totalEthSpent: (toHolder.totalEthSpent ?? 0n) + ethSpentDelta, + totalTokensAcquired: (toHolder.totalTokensAcquired ?? 0n) + value, + }), }); // If this was a new holder (balance was 0), increment count if (oldBalance === 0n) { @@ -90,6 +110,8 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => { await context.db.insert(holders).values({ address: to, balance: newBalance, + totalEthSpent: ethSpentDelta, + totalTokensAcquired: isBuy ? value : 0n, }); holderCountDelta += 1; } diff --git a/web-app/src/composables/useWalletDashboard.ts b/web-app/src/composables/useWalletDashboard.ts index 209dd12..d48a941 100644 --- a/web-app/src/composables/useWalletDashboard.ts +++ b/web-app/src/composables/useWalletDashboard.ts @@ -48,6 +48,8 @@ function formatTokenAmount(rawWei: string, decimals = 18): number { export function useWalletDashboard(address: Ref) { const holderBalance = ref('0'); + const holderTotalEthSpent = ref('0'); + const holderTotalTokensAcquired = ref('0'); const stats = ref(null); const positions = ref([]); const loading = ref(false); @@ -78,6 +80,8 @@ export function useWalletDashboard(address: Ref) { holders(address: "${addr}") { address balance + totalEthSpent + totalTokensAcquired } statss(where: { id: "0x01" }) { items { @@ -122,6 +126,8 @@ export function useWalletDashboard(address: Ref) { const holder = res.data?.data?.holders; holderBalance.value = holder?.balance ?? '0'; + holderTotalEthSpent.value = holder?.totalEthSpent ?? '0'; + holderTotalTokensAcquired.value = holder?.totalTokensAcquired ?? '0'; const statsItems = res.data?.data?.statss?.items; stats.value = Array.isArray(statsItems) && statsItems.length > 0 ? (statsItems[0] as WalletStats) : null; @@ -158,6 +164,31 @@ export function useWalletDashboard(address: Ref) { return balance * floorPriceEth; }); + // Cost basis & P&L + const avgCostBasis = computed(() => { + const spent = formatTokenAmount(holderTotalEthSpent.value); + const acquired = formatTokenAmount(holderTotalTokensAcquired.value); + if (acquired === 0) return 0; + return spent / acquired; + }); + + const currentPriceEth = computed(() => { + if (!stats.value?.currentPriceWei) return 0; + return formatTokenAmount(stats.value.currentPriceWei); + }); + + const unrealizedPnlEth = computed(() => { + const basis = avgCostBasis.value; + if (basis === 0) return 0; + return (currentPriceEth.value - basis) * balanceKrk.value; + }); + + const unrealizedPnlPercent = computed(() => { + const basis = avgCostBasis.value; + if (basis === 0) return 0; + return (currentPriceEth.value / basis - 1) * 100; + }); + const activePositions = computed(() => positions.value.filter(p => p.status === 'Active')); const closedPositions = computed(() => positions.value.filter(p => p.status === 'Closed')); @@ -180,6 +211,10 @@ export function useWalletDashboard(address: Ref) { balanceKrk, ethBacking, floorValue, + avgCostBasis, + currentPriceEth, + unrealizedPnlEth, + unrealizedPnlPercent, stats, positions, activePositions, diff --git a/web-app/src/views/WalletView.vue b/web-app/src/views/WalletView.vue index 4f038b3..7f4b5d4 100644 --- a/web-app/src/views/WalletView.vue +++ b/web-app/src/views/WalletView.vue @@ -24,6 +24,16 @@
⚠️ {{ error }}
+ +
+
Unrealized P&L
+
{{ unrealizedPnlEth >= 0 ? '+' : '' }}{{ formatEth(unrealizedPnlEth) }} ETH
+
{{ unrealizedPnlPercent >= 0 ? '+' : '' }}{{ unrealizedPnlPercent.toFixed(1) }}%
+
+ Avg cost: {{ formatEth(avgCostBasis) }} ETH/KRK · Current: {{ formatEth(currentPriceEth) }} ETH/KRK +
+
+
@@ -152,8 +162,21 @@ 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 { + loading, + error, + balanceKrk, + ethBacking, + floorValue, + avgCostBasis, + currentPriceEth, + unrealizedPnlEth, + unrealizedPnlPercent, + stats, + positions, + activePositions, + closedPositions, +} = useWalletDashboard(addressParam); const copied = ref(false); @@ -489,4 +512,51 @@ const ethGrowthBps = computed(() => Number(stats.value?.ethReserveGrowthBps ?? 0 background: rgba(255,255,255,0.08) color: #9A9898 border: 1px solid rgba(255,255,255,0.15) + +.pnl-card + background: rgba(255,255,255,0.06) + border: 1px solid rgba(255,255,255,0.15) + border-radius: 16px + padding: 1.5rem + margin-bottom: 1.5rem + text-align: center + + &.positive + border-color: rgba(16, 185, 129, 0.4) + background: rgba(16, 185, 129, 0.08) + + &.negative + border-color: rgba(239, 68, 68, 0.4) + background: rgba(239, 68, 68, 0.08) + + &__label + font-size: 0.85rem + color: #9A9898 + margin-bottom: 0.5rem + + &__value + font-size: 1.8rem + font-weight: 700 + + .positive & + color: #10B981 + + .negative & + color: #EF4444 + + &__percent + font-size: 1.2rem + font-weight: 600 + margin-top: 0.25rem + + .positive & + color: #10B981 + + .negative & + color: #EF4444 + + &__detail + font-size: 0.75rem + color: #9A9898 + margin-top: 0.75rem