feat: wallet P&L with average cost basis tracking (#156) (#158)

This commit is contained in:
johba 2026-02-19 16:22:23 +01:00
parent 76b2635e63
commit 66106077ba
4 changed files with 139 additions and 3 deletions

View file

@ -259,12 +259,21 @@ export const recenters = onchainTable('recenters', t => ({
vwapTick: t.integer(), // nullable vwapTick: t.integer(), // nullable
})); }));
// Holders - track Kraiken token holders // Holders - track Kraiken token holders with cost basis for P&L
export const holders = onchainTable( export const holders = onchainTable(
'holders', 'holders',
t => ({ t => ({
address: t.hex().primaryKey(), address: t.hex().primaryKey(),
balance: t.bigint().notNull(), 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 => ({ table => ({
addressIdx: index().on(table.address), addressIdx: index().on(table.address),

View file

@ -14,6 +14,10 @@ import { validateContractVersion } from './helpers/version';
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; 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 // Track if version has been validated
let versionValidated = false; let versionValidated = false;
@ -77,9 +81,25 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => {
const oldBalance = toHolder?.balance ?? 0n; const oldBalance = toHolder?.balance ?? 0n;
const newBalance = oldBalance + value; 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) { if (toHolder) {
await context.db.update(holders, { address: to }).set({ await context.db.update(holders, { address: to }).set({
balance: newBalance, 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 this was a new holder (balance was 0), increment count
if (oldBalance === 0n) { if (oldBalance === 0n) {
@ -90,6 +110,8 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => {
await context.db.insert(holders).values({ await context.db.insert(holders).values({
address: to, address: to,
balance: newBalance, balance: newBalance,
totalEthSpent: ethSpentDelta,
totalTokensAcquired: isBuy ? value : 0n,
}); });
holderCountDelta += 1; holderCountDelta += 1;
} }

View file

@ -48,6 +48,8 @@ function formatTokenAmount(rawWei: string, decimals = 18): number {
export function useWalletDashboard(address: Ref<string>) { export function useWalletDashboard(address: Ref<string>) {
const holderBalance = ref<string>('0'); const holderBalance = ref<string>('0');
const holderTotalEthSpent = ref<string>('0');
const holderTotalTokensAcquired = ref<string>('0');
const stats = ref<WalletStats | null>(null); const stats = ref<WalletStats | null>(null);
const positions = ref<WalletPosition[]>([]); const positions = ref<WalletPosition[]>([]);
const loading = ref(false); const loading = ref(false);
@ -78,6 +80,8 @@ export function useWalletDashboard(address: Ref<string>) {
holders(address: "${addr}") { holders(address: "${addr}") {
address address
balance balance
totalEthSpent
totalTokensAcquired
} }
statss(where: { id: "0x01" }) { statss(where: { id: "0x01" }) {
items { items {
@ -122,6 +126,8 @@ export function useWalletDashboard(address: Ref<string>) {
const holder = res.data?.data?.holders; const holder = res.data?.data?.holders;
holderBalance.value = holder?.balance ?? '0'; holderBalance.value = holder?.balance ?? '0';
holderTotalEthSpent.value = holder?.totalEthSpent ?? '0';
holderTotalTokensAcquired.value = holder?.totalTokensAcquired ?? '0';
const statsItems = res.data?.data?.statss?.items; const statsItems = res.data?.data?.statss?.items;
stats.value = Array.isArray(statsItems) && statsItems.length > 0 ? (statsItems[0] as WalletStats) : null; stats.value = Array.isArray(statsItems) && statsItems.length > 0 ? (statsItems[0] as WalletStats) : null;
@ -158,6 +164,31 @@ export function useWalletDashboard(address: Ref<string>) {
return balance * floorPriceEth; 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 activePositions = computed(() => positions.value.filter(p => p.status === 'Active'));
const closedPositions = computed(() => positions.value.filter(p => p.status === 'Closed')); const closedPositions = computed(() => positions.value.filter(p => p.status === 'Closed'));
@ -180,6 +211,10 @@ export function useWalletDashboard(address: Ref<string>) {
balanceKrk, balanceKrk,
ethBacking, ethBacking,
floorValue, floorValue,
avgCostBasis,
currentPriceEth,
unrealizedPnlEth,
unrealizedPnlPercent,
stats, stats,
positions, positions,
activePositions, activePositions,

View file

@ -24,6 +24,16 @@
</div> </div>
<div v-if="error" class="wallet-error"> {{ error }}</div> <div v-if="error" class="wallet-error"> {{ error }}</div>
<!-- P&L Card (hero) -->
<div class="pnl-card" v-if="avgCostBasis > 0" :class="{ positive: unrealizedPnlEth >= 0, negative: unrealizedPnlEth < 0 }">
<div class="pnl-card__label">Unrealized P&amp;L</div>
<div class="pnl-card__value">{{ unrealizedPnlEth >= 0 ? '+' : '' }}{{ formatEth(unrealizedPnlEth) }} ETH</div>
<div class="pnl-card__percent">{{ unrealizedPnlPercent >= 0 ? '+' : '' }}{{ unrealizedPnlPercent.toFixed(1) }}%</div>
<div class="pnl-card__detail">
Avg cost: {{ formatEth(avgCostBasis) }} ETH/KRK · Current: {{ formatEth(currentPriceEth) }} ETH/KRK
</div>
</div>
<!-- Value Cards --> <!-- Value Cards -->
<div class="value-cards"> <div class="value-cards">
<div class="value-card"> <div class="value-card">
@ -152,8 +162,21 @@ import { useWalletDashboard } from '@/composables/useWalletDashboard';
const route = useRoute(); const route = useRoute();
const addressParam = computed(() => String(route.params.address ?? '')); const addressParam = computed(() => String(route.params.address ?? ''));
const { loading, error, balanceKrk, ethBacking, floorValue, stats, positions, activePositions, closedPositions } = const {
useWalletDashboard(addressParam); loading,
error,
balanceKrk,
ethBacking,
floorValue,
avgCostBasis,
currentPriceEth,
unrealizedPnlEth,
unrealizedPnlPercent,
stats,
positions,
activePositions,
closedPositions,
} = useWalletDashboard(addressParam);
const copied = ref(false); const copied = ref(false);
@ -489,4 +512,51 @@ const ethGrowthBps = computed(() => Number(stats.value?.ethReserveGrowthBps ?? 0
background: rgba(255,255,255,0.08) background: rgba(255,255,255,0.08)
color: #9A9898 color: #9A9898
border: 1px solid rgba(255,255,255,0.15) 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
</style> </style>