218 lines
7.5 KiB
TypeScript
218 lines
7.5 KiB
TypeScript
import { ponder } from 'ponder:registry';
|
|
import { getLogger } from './helpers/logger';
|
|
import { stats, holders, transactions, STATS_ID } from 'ponder:schema';
|
|
import {
|
|
ensureStatsExists,
|
|
parseRingBuffer,
|
|
serializeRingBuffer,
|
|
updateHourlyData,
|
|
checkBlockHistorySufficient,
|
|
RING_BUFFER_SEGMENTS,
|
|
refreshMinStake,
|
|
updateEthReserve,
|
|
} from './helpers/stats';
|
|
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;
|
|
|
|
ponder.on('Kraiken:Transfer', async ({ event, context }) => {
|
|
// Validate version once at first event
|
|
if (!versionValidated) {
|
|
await validateContractVersion(context);
|
|
versionValidated = true;
|
|
}
|
|
|
|
const { from, to, value } = event.args;
|
|
|
|
await ensureStatsExists(context, event.block.timestamp);
|
|
|
|
// Track holder balances for ALL transfers (not just mint/burn)
|
|
const statsData = await context.db.find(stats, { id: STATS_ID });
|
|
if (!statsData) return;
|
|
|
|
let holderCountDelta = 0;
|
|
|
|
// 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) {
|
|
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;
|
|
}
|
|
|
|
const newBalance = fromHolder.balance - value;
|
|
|
|
// CRITICAL FIX: Prevent negative balances
|
|
if (newBalance < 0n) {
|
|
getLogger(context).error(`Transfer would create negative balance for ${from}: ${fromHolder.balance} - ${value} = ${newBalance}`);
|
|
return;
|
|
}
|
|
|
|
if (newBalance === 0n) {
|
|
// Holder balance went to zero - remove from holder count
|
|
await context.db.update(holders, { address: from }).set({
|
|
balance: 0n,
|
|
});
|
|
holderCountDelta -= 1;
|
|
} else {
|
|
await context.db.update(holders, { address: from }).set({
|
|
balance: newBalance,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update 'to' holder (if not burn)
|
|
if (to !== ZERO_ADDRESS) {
|
|
const toHolder = await context.db.find(holders, { address: to });
|
|
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) {
|
|
holderCountDelta += 1;
|
|
}
|
|
} else {
|
|
// New holder
|
|
await context.db.insert(holders).values({
|
|
address: to,
|
|
balance: newBalance,
|
|
totalEthSpent: ethSpentDelta,
|
|
totalTokensAcquired: isBuy ? value : 0n,
|
|
});
|
|
holderCountDelta += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Record buy/sell transactions
|
|
if (!isSelfTransfer && from !== ZERO_ADDRESS && to !== ZERO_ADDRESS) {
|
|
const isBuy = from.toLowerCase() === POOL_ADDRESS;
|
|
const isSell = to.toLowerCase() === POOL_ADDRESS;
|
|
|
|
if (isBuy || isSell) {
|
|
const currentPrice = statsData.currentPriceWei ?? 0n;
|
|
const ethEstimate = currentPrice > 0n ? (value * currentPrice) / 10n ** 18n : 0n;
|
|
const txId = `${event.transaction.hash}-${event.log.logIndex}`;
|
|
|
|
await context.db.insert(transactions).values({
|
|
id: txId,
|
|
holder: isBuy ? to : from,
|
|
type: isBuy ? 'buy' : 'sell',
|
|
tokenAmount: value,
|
|
ethAmount: ethEstimate,
|
|
timestamp: event.block.timestamp,
|
|
blockNumber: Number(event.block.number),
|
|
txHash: event.transaction.hash,
|
|
});
|
|
|
|
// Update ETH reserve from pool WETH balance — buys/sells shift pool ETH
|
|
await updateEthReserve(context, POOL_ADDRESS);
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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,
|
|
});
|
|
}
|
|
|
|
// Check if we have sufficient block history for reliable ringbuffer operations
|
|
if (!checkBlockHistorySufficient(context, event)) {
|
|
// Insufficient history - skip ringbuffer updates but continue with basic stats
|
|
if (from === ZERO_ADDRESS) {
|
|
await context.db.update(stats, { id: STATS_ID }).set({
|
|
kraikenTotalSupply: statsData.kraikenTotalSupply + value,
|
|
totalMinted: statsData.totalMinted + value,
|
|
});
|
|
} else if (to === ZERO_ADDRESS) {
|
|
await context.db.update(stats, { id: STATS_ID }).set({
|
|
kraikenTotalSupply: statsData.kraikenTotalSupply - value,
|
|
totalBurned: statsData.totalBurned + value,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
await updateHourlyData(context, event.block.timestamp);
|
|
|
|
const ringBuffer = parseRingBuffer(statsData.ringBuffer as string[]);
|
|
const pointer = statsData.ringBufferPointer ?? 0;
|
|
const baseIndex = pointer * RING_BUFFER_SEGMENTS;
|
|
|
|
if (from === ZERO_ADDRESS) {
|
|
ringBuffer[baseIndex + 1] = ringBuffer[baseIndex + 1] + value;
|
|
|
|
await context.db.update(stats, { id: STATS_ID }).set({
|
|
ringBuffer: serializeRingBuffer(ringBuffer),
|
|
kraikenTotalSupply: statsData.kraikenTotalSupply + value,
|
|
totalMinted: statsData.totalMinted + value,
|
|
});
|
|
} else if (to === ZERO_ADDRESS) {
|
|
ringBuffer[baseIndex + 2] = ringBuffer[baseIndex + 2] + value;
|
|
|
|
await context.db.update(stats, { id: STATS_ID }).set({
|
|
ringBuffer: serializeRingBuffer(ringBuffer),
|
|
kraikenTotalSupply: statsData.kraikenTotalSupply - value,
|
|
totalBurned: statsData.totalBurned + value,
|
|
});
|
|
}
|
|
|
|
await updateHourlyData(context, event.block.timestamp);
|
|
});
|
|
|
|
ponder.on('StatsBlock:block', async ({ event, context }) => {
|
|
const statsData = await ensureStatsExists(context, event.block.timestamp);
|
|
|
|
await refreshMinStake(context, statsData);
|
|
|
|
// Only update hourly data if we have sufficient block history
|
|
if (checkBlockHistorySufficient(context, event)) {
|
|
await updateHourlyData(context, event.block.timestamp);
|
|
}
|
|
});
|