parent
76b2635e63
commit
66106077ba
4 changed files with 139 additions and 3 deletions
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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&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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue