feat/ponder-lm-indexing (#142)

This commit is contained in:
johba 2026-02-18 00:19:05 +01:00
parent de3c8eef94
commit 31063379a8
107 changed files with 12517 additions and 367 deletions

View file

@ -21,8 +21,16 @@ type Query {
stackMetas(where: stackMetaFilter, orderBy: String, orderDirection: String, before: String, after: String, limit: Int): stackMetaPage!
stats(id: String!): stats
statss(where: statsFilter, orderBy: String, orderDirection: String, before: String, after: String, limit: Int): statsPage!
ethReserveHistory(id: String!): ethReserveHistory
ethReserveHistorys(where: ethReserveHistoryFilter, orderBy: String, orderDirection: String, before: String, after: String, limit: Int): ethReserveHistoryPage!
feeHistory(id: String!): feeHistory
feeHistorys(where: feeHistoryFilter, orderBy: String, orderDirection: String, before: String, after: String, limit: Int): feeHistoryPage!
positions(id: String!): positions
positionss(where: positionsFilter, orderBy: String, orderDirection: String, before: String, after: String, limit: Int): positionsPage!
recenters(id: String!): recenters
recenterss(where: recentersFilter, orderBy: String, orderDirection: String, before: String, after: String, limit: Int): recentersPage!
holders(address: String!): holders
holderss(where: holdersFilter, orderBy: String, orderDirection: String, before: String, after: String, limit: Int): holdersPage!
_meta: Meta
}
@ -115,6 +123,22 @@ type stats {
ringBufferPointer: Int!
lastHourlyUpdateTimestamp: BigInt!
ringBuffer: JSON!
holderCount: Int!
lastRecenterTimestamp: BigInt!
lastRecenterTick: Int!
recentersLastDay: Int!
recentersLastWeek: Int!
lastEthReserve: BigInt!
lastVwapTick: Int!
ethReserve7dAgo: BigInt
ethReserveGrowthBps: Int
feesEarned7dEth: BigInt!
feesEarned7dKrk: BigInt!
feesLastUpdated: BigInt
floorTick: Int
floorPriceWei: BigInt
currentPriceWei: BigInt
floorDistanceBps: Int
}
type statsPage {
@ -320,6 +344,229 @@ input statsFilter {
lastHourlyUpdateTimestamp_lt: BigInt
lastHourlyUpdateTimestamp_gte: BigInt
lastHourlyUpdateTimestamp_lte: BigInt
holderCount: Int
holderCount_not: Int
holderCount_in: [Int]
holderCount_not_in: [Int]
holderCount_gt: Int
holderCount_lt: Int
holderCount_gte: Int
holderCount_lte: Int
lastRecenterTimestamp: BigInt
lastRecenterTimestamp_not: BigInt
lastRecenterTimestamp_in: [BigInt]
lastRecenterTimestamp_not_in: [BigInt]
lastRecenterTimestamp_gt: BigInt
lastRecenterTimestamp_lt: BigInt
lastRecenterTimestamp_gte: BigInt
lastRecenterTimestamp_lte: BigInt
lastRecenterTick: Int
lastRecenterTick_not: Int
lastRecenterTick_in: [Int]
lastRecenterTick_not_in: [Int]
lastRecenterTick_gt: Int
lastRecenterTick_lt: Int
lastRecenterTick_gte: Int
lastRecenterTick_lte: Int
recentersLastDay: Int
recentersLastDay_not: Int
recentersLastDay_in: [Int]
recentersLastDay_not_in: [Int]
recentersLastDay_gt: Int
recentersLastDay_lt: Int
recentersLastDay_gte: Int
recentersLastDay_lte: Int
recentersLastWeek: Int
recentersLastWeek_not: Int
recentersLastWeek_in: [Int]
recentersLastWeek_not_in: [Int]
recentersLastWeek_gt: Int
recentersLastWeek_lt: Int
recentersLastWeek_gte: Int
recentersLastWeek_lte: Int
lastEthReserve: BigInt
lastEthReserve_not: BigInt
lastEthReserve_in: [BigInt]
lastEthReserve_not_in: [BigInt]
lastEthReserve_gt: BigInt
lastEthReserve_lt: BigInt
lastEthReserve_gte: BigInt
lastEthReserve_lte: BigInt
lastVwapTick: Int
lastVwapTick_not: Int
lastVwapTick_in: [Int]
lastVwapTick_not_in: [Int]
lastVwapTick_gt: Int
lastVwapTick_lt: Int
lastVwapTick_gte: Int
lastVwapTick_lte: Int
ethReserve7dAgo: BigInt
ethReserve7dAgo_not: BigInt
ethReserve7dAgo_in: [BigInt]
ethReserve7dAgo_not_in: [BigInt]
ethReserve7dAgo_gt: BigInt
ethReserve7dAgo_lt: BigInt
ethReserve7dAgo_gte: BigInt
ethReserve7dAgo_lte: BigInt
ethReserveGrowthBps: Int
ethReserveGrowthBps_not: Int
ethReserveGrowthBps_in: [Int]
ethReserveGrowthBps_not_in: [Int]
ethReserveGrowthBps_gt: Int
ethReserveGrowthBps_lt: Int
ethReserveGrowthBps_gte: Int
ethReserveGrowthBps_lte: Int
feesEarned7dEth: BigInt
feesEarned7dEth_not: BigInt
feesEarned7dEth_in: [BigInt]
feesEarned7dEth_not_in: [BigInt]
feesEarned7dEth_gt: BigInt
feesEarned7dEth_lt: BigInt
feesEarned7dEth_gte: BigInt
feesEarned7dEth_lte: BigInt
feesEarned7dKrk: BigInt
feesEarned7dKrk_not: BigInt
feesEarned7dKrk_in: [BigInt]
feesEarned7dKrk_not_in: [BigInt]
feesEarned7dKrk_gt: BigInt
feesEarned7dKrk_lt: BigInt
feesEarned7dKrk_gte: BigInt
feesEarned7dKrk_lte: BigInt
feesLastUpdated: BigInt
feesLastUpdated_not: BigInt
feesLastUpdated_in: [BigInt]
feesLastUpdated_not_in: [BigInt]
feesLastUpdated_gt: BigInt
feesLastUpdated_lt: BigInt
feesLastUpdated_gte: BigInt
feesLastUpdated_lte: BigInt
floorTick: Int
floorTick_not: Int
floorTick_in: [Int]
floorTick_not_in: [Int]
floorTick_gt: Int
floorTick_lt: Int
floorTick_gte: Int
floorTick_lte: Int
floorPriceWei: BigInt
floorPriceWei_not: BigInt
floorPriceWei_in: [BigInt]
floorPriceWei_not_in: [BigInt]
floorPriceWei_gt: BigInt
floorPriceWei_lt: BigInt
floorPriceWei_gte: BigInt
floorPriceWei_lte: BigInt
currentPriceWei: BigInt
currentPriceWei_not: BigInt
currentPriceWei_in: [BigInt]
currentPriceWei_not_in: [BigInt]
currentPriceWei_gt: BigInt
currentPriceWei_lt: BigInt
currentPriceWei_gte: BigInt
currentPriceWei_lte: BigInt
floorDistanceBps: Int
floorDistanceBps_not: Int
floorDistanceBps_in: [Int]
floorDistanceBps_not_in: [Int]
floorDistanceBps_gt: Int
floorDistanceBps_lt: Int
floorDistanceBps_gte: Int
floorDistanceBps_lte: Int
}
type ethReserveHistory {
id: String!
timestamp: BigInt!
ethBalance: BigInt!
}
type ethReserveHistoryPage {
items: [ethReserveHistory!]!
pageInfo: PageInfo!
totalCount: Int!
}
input ethReserveHistoryFilter {
AND: [ethReserveHistoryFilter]
OR: [ethReserveHistoryFilter]
id: String
id_not: String
id_in: [String]
id_not_in: [String]
id_contains: String
id_not_contains: String
id_starts_with: String
id_ends_with: String
id_not_starts_with: String
id_not_ends_with: String
timestamp: BigInt
timestamp_not: BigInt
timestamp_in: [BigInt]
timestamp_not_in: [BigInt]
timestamp_gt: BigInt
timestamp_lt: BigInt
timestamp_gte: BigInt
timestamp_lte: BigInt
ethBalance: BigInt
ethBalance_not: BigInt
ethBalance_in: [BigInt]
ethBalance_not_in: [BigInt]
ethBalance_gt: BigInt
ethBalance_lt: BigInt
ethBalance_gte: BigInt
ethBalance_lte: BigInt
}
type feeHistory {
id: String!
timestamp: BigInt!
ethFees: BigInt!
krkFees: BigInt!
}
type feeHistoryPage {
items: [feeHistory!]!
pageInfo: PageInfo!
totalCount: Int!
}
input feeHistoryFilter {
AND: [feeHistoryFilter]
OR: [feeHistoryFilter]
id: String
id_not: String
id_in: [String]
id_not_in: [String]
id_contains: String
id_not_contains: String
id_starts_with: String
id_ends_with: String
id_not_starts_with: String
id_not_ends_with: String
timestamp: BigInt
timestamp_not: BigInt
timestamp_in: [BigInt]
timestamp_not_in: [BigInt]
timestamp_gt: BigInt
timestamp_lt: BigInt
timestamp_gte: BigInt
timestamp_lte: BigInt
ethFees: BigInt
ethFees_not: BigInt
ethFees_in: [BigInt]
ethFees_not_in: [BigInt]
ethFees_gt: BigInt
ethFees_lt: BigInt
ethFees_gte: BigInt
ethFees_lte: BigInt
krkFees: BigInt
krkFees_not: BigInt
krkFees_in: [BigInt]
krkFees_not_in: [BigInt]
krkFees_gt: BigInt
krkFees_lt: BigInt
krkFees_gte: BigInt
krkFees_lte: BigInt
}
type positions {
@ -493,4 +740,113 @@ input positionsFilter {
payout_lt: BigInt
payout_gte: BigInt
payout_lte: BigInt
}
type recenters {
id: String!
timestamp: BigInt!
currentTick: Int!
isUp: Boolean!
ethBalance: BigInt
outstandingSupply: BigInt
vwapTick: Int
}
type recentersPage {
items: [recenters!]!
pageInfo: PageInfo!
totalCount: Int!
}
input recentersFilter {
AND: [recentersFilter]
OR: [recentersFilter]
id: String
id_not: String
id_in: [String]
id_not_in: [String]
id_contains: String
id_not_contains: String
id_starts_with: String
id_ends_with: String
id_not_starts_with: String
id_not_ends_with: String
timestamp: BigInt
timestamp_not: BigInt
timestamp_in: [BigInt]
timestamp_not_in: [BigInt]
timestamp_gt: BigInt
timestamp_lt: BigInt
timestamp_gte: BigInt
timestamp_lte: BigInt
currentTick: Int
currentTick_not: Int
currentTick_in: [Int]
currentTick_not_in: [Int]
currentTick_gt: Int
currentTick_lt: Int
currentTick_gte: Int
currentTick_lte: Int
isUp: Boolean
isUp_not: Boolean
isUp_in: [Boolean]
isUp_not_in: [Boolean]
ethBalance: BigInt
ethBalance_not: BigInt
ethBalance_in: [BigInt]
ethBalance_not_in: [BigInt]
ethBalance_gt: BigInt
ethBalance_lt: BigInt
ethBalance_gte: BigInt
ethBalance_lte: BigInt
outstandingSupply: BigInt
outstandingSupply_not: BigInt
outstandingSupply_in: [BigInt]
outstandingSupply_not_in: [BigInt]
outstandingSupply_gt: BigInt
outstandingSupply_lt: BigInt
outstandingSupply_gte: BigInt
outstandingSupply_lte: BigInt
vwapTick: Int
vwapTick_not: Int
vwapTick_in: [Int]
vwapTick_not_in: [Int]
vwapTick_gt: Int
vwapTick_lt: Int
vwapTick_gte: Int
vwapTick_lte: Int
}
type holders {
address: String!
balance: BigInt!
}
type holdersPage {
items: [holders!]!
pageInfo: PageInfo!
totalCount: Int!
}
input holdersFilter {
AND: [holdersFilter]
OR: [holdersFilter]
address: String
address_not: String
address_in: [String]
address_not_in: [String]
address_contains: String
address_not_contains: String
address_starts_with: String
address_ends_with: String
address_not_starts_with: String
address_not_ends_with: String
balance: BigInt
balance_not: BigInt
balance_in: [BigInt]
balance_not_in: [BigInt]
balance_gt: BigInt
balance_lt: BigInt
balance_gte: BigInt
balance_lte: BigInt
}

View file

@ -1,6 +1,6 @@
import { createConfig } from 'ponder';
import type { Abi } from 'viem';
import { KraikenAbi, StakeAbi } from 'kraiken-lib/abis';
import { KraikenAbi, StakeAbi, LiquidityManagerAbi } from 'kraiken-lib/abis';
// Network configurations keyed by canonical environment name
type NetworkConfig = {
@ -10,6 +10,7 @@ type NetworkConfig = {
contracts: {
kraiken: string;
stake: string;
liquidityManager: string;
startBlock: number;
};
};
@ -22,6 +23,7 @@ const networks: Record<string, NetworkConfig> = {
contracts: {
kraiken: process.env.KRAIKEN_ADDRESS || '0x56186c1E64cA8043dEF78d06AfF222212eA5df71',
stake: process.env.STAKE_ADDRESS || '0x056E4a859558A3975761ABd7385506BC4D8A8E60',
liquidityManager: process.env.LM_ADDRESS || '0x33d10f2449ffede92b43d4fba562f132ba6a766a',
startBlock: parseInt(process.env.START_BLOCK || '31425917'),
},
},
@ -31,6 +33,7 @@ const networks: Record<string, NetworkConfig> = {
contracts: {
kraiken: '0x22c264Ecf8D4E49D1E3CabD8DD39b7C4Ab51C1B8',
stake: '0xe28020BCdEeAf2779dd47c670A8eFC2973316EE2',
liquidityManager: process.env.LM_ADDRESS || '0x0000000000000000000000000000000000000000',
startBlock: 20940337,
},
},
@ -40,6 +43,7 @@ const networks: Record<string, NetworkConfig> = {
contracts: {
kraiken: '0x45caa5929f6ee038039984205bdecf968b954820',
stake: '0xed70707fab05d973ad41eae8d17e2bcd36192cfc',
liquidityManager: process.env.LM_ADDRESS || '0x0000000000000000000000000000000000000000',
startBlock: 26038614,
},
},
@ -85,6 +89,12 @@ export default createConfig({
address: selectedNetwork.contracts.stake as `0x${string}`,
startBlock: selectedNetwork.contracts.startBlock,
},
LiquidityManager: {
abi: LiquidityManagerAbi satisfies Abi,
chain: NETWORK,
address: selectedNetwork.contracts.liquidityManager as `0x${string}`,
startBlock: selectedNetwork.contracts.startBlock,
},
},
blocks: {
StatsBlock: {

View file

@ -133,6 +133,72 @@ export const stats = onchainTable('stats', t => ({
.$type<string[]>()
.notNull()
.$default(() => Array(HOURS_IN_RING_BUFFER * RING_BUFFER_SEGMENTS).fill('0')),
// LiquidityManager stats
holderCount: t
.integer()
.notNull()
.$default(() => 0),
lastRecenterTimestamp: t
.bigint()
.notNull()
.$default(() => 0n),
lastRecenterTick: t
.integer()
.notNull()
.$default(() => 0),
recentersLastDay: t
.integer()
.notNull()
.$default(() => 0),
recentersLastWeek: t
.integer()
.notNull()
.$default(() => 0),
lastEthReserve: t
.bigint()
.notNull()
.$default(() => 0n),
lastVwapTick: t
.integer()
.notNull()
.$default(() => 0),
// 7-day ETH reserve growth metrics
ethReserve7dAgo: t.bigint(),
ethReserveGrowthBps: t.integer(),
// 7-day trading fees earned
feesEarned7dEth: t
.bigint()
.notNull()
.$default(() => 0n),
feesEarned7dKrk: t
.bigint()
.notNull()
.$default(() => 0n),
feesLastUpdated: t.bigint(),
// Floor price metrics
floorTick: t.integer(),
floorPriceWei: t.bigint(),
currentPriceWei: t.bigint(),
floorDistanceBps: t.integer(),
}));
// ETH reserve history - tracks ethBalance over time for 7d growth calculation
export const ethReserveHistory = onchainTable('ethReserveHistory', t => ({
id: t.text().primaryKey(), // block_logIndex format
timestamp: t.bigint().notNull(),
ethBalance: t.bigint().notNull(),
}));
// Fee history - tracks fees earned over time for 7d totals
export const feeHistory = onchainTable('feeHistory', t => ({
id: t.text().primaryKey(), // block_logIndex format
timestamp: t.bigint().notNull(),
ethFees: t.bigint().notNull(),
krkFees: t.bigint().notNull(),
}));
// Individual staking positions
@ -180,6 +246,29 @@ export const positions = onchainTable(
// Maps index → decimal (e.g., TAX_RATES[0] = 0.01 for 1% yearly)
export const TAX_RATES = TAX_RATE_OPTIONS.map(opt => opt.decimal);
// Recenters - track LiquidityManager recenter events
export const recenters = onchainTable('recenters', t => ({
id: t.text().primaryKey(), // block_logIndex format
timestamp: t.bigint().notNull(),
currentTick: t.integer().notNull(),
isUp: t.boolean().notNull(),
ethBalance: t.bigint(), // nullable - only from Scarcity/Abundance events
outstandingSupply: t.bigint(), // nullable
vwapTick: t.integer(), // nullable
}));
// Holders - track Kraiken token holders
export const holders = onchainTable(
'holders',
t => ({
address: t.hex().primaryKey(),
balance: t.bigint().notNull(),
}),
table => ({
addressIdx: index().on(table.address),
})
);
// Helper constants
export const STATS_ID = '0x01';
export const SECONDS_IN_HOUR = 3600;

View file

@ -1,6 +1,7 @@
// Import all event handlers
import './kraiken';
import './stake';
import './lm';
// This file serves as the entry point for all indexing functions
// Ponder will automatically register all event handlers from imported files

View file

@ -1,5 +1,5 @@
import { ponder } from 'ponder:registry';
import { stats, STATS_ID } from 'ponder:schema';
import { stats, holders, STATS_ID } from 'ponder:schema';
import {
ensureStatsExists,
parseRingBuffer,
@ -27,34 +27,114 @@ ponder.on('Kraiken:Transfer', async ({ event, context }) => {
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) {
context.log.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) {
context.log.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;
if (toHolder) {
await context.db.update(holders, { address: to }).set({
balance: newBalance,
});
// 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,
});
holderCountDelta += 1;
}
}
}
// 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(
`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) {
const statsData = await context.db.find(stats, { id: STATS_ID });
if (statsData) {
await context.db.update(stats, { id: STATS_ID }).set({
kraikenTotalSupply: statsData.kraikenTotalSupply + value,
totalMinted: statsData.totalMinted + value,
});
}
await context.db.update(stats, { id: STATS_ID }).set({
kraikenTotalSupply: statsData.kraikenTotalSupply + value,
totalMinted: statsData.totalMinted + value,
});
} else if (to === ZERO_ADDRESS) {
const statsData = await context.db.find(stats, { id: STATS_ID });
if (statsData) {
await context.db.update(stats, { id: STATS_ID }).set({
kraikenTotalSupply: statsData.kraikenTotalSupply - value,
totalBurned: statsData.totalBurned + value,
});
}
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 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 baseIndex = pointer * RING_BUFFER_SEGMENTS;

251
services/ponder/src/lm.ts Normal file
View file

@ -0,0 +1,251 @@
import { ponder } from 'ponder:registry';
import { recenters, stats, STATS_ID, ethReserveHistory } from 'ponder:schema';
import { ensureStatsExists } from './helpers/stats';
import { gte, asc } from 'drizzle-orm';
const SECONDS_IN_7_DAYS = 7n * 24n * 60n * 60n;
/**
* Fee tracking approach:
*
* Option 1 (not implemented): Index Uniswap V3 Pool Collect events
* - Pros: Accurate fee data directly from the pool
* - Cons: Requires adding pool contract to ponder.config.ts, forcing a full resync
*
* Option 2 (not implemented): Derive from ETH balance changes
* - Pros: No config changes needed
* - Cons: Less accurate, hard to isolate fees from other balance changes
*
* Current: Fee tracking infrastructure (feeHistory table, stats fields) is in place
* but not populated. To implement:
* 1. Add UniswapV3Pool contract to ponder.config.ts with Collect event
* 2. Handle Collect events to populate feeHistory table
* 3. Calculate 7-day rolling totals from feeHistory
*
* The feesEarned7dEth and feesEarned7dKrk fields default to 0n until implemented.
*/
/**
* Calculate price in wei per KRK token from a Uniswap V3 tick
* For WETH/KRK pool where WETH is token0:
* - price = amount1/amount0 = 1.0001^tick
* - This gives KRK per WETH
* - We want wei per KRK, so we invert and scale
*/
function priceFromTick(tick: number): bigint {
// Calculate 1.0001^tick using floating point
const price = Math.pow(1.0001, tick);
// Price is KRK/WETH, we want WEI per KRK
// Since both tokens have 18 decimals, we need to invert
// priceWei = (10^18) / price
const priceWei = 10 ** 18 / price;
return BigInt(Math.floor(priceWei));
}
/**
* Calculate basis points difference between two values
* bps = (new - old) / old * 10000
*/
function calculateBps(newValue: bigint, oldValue: bigint): number {
if (oldValue === 0n) return 0;
const diff = newValue - oldValue;
const bps = (Number(diff) * 10000) / Number(oldValue);
return Math.floor(bps);
}
/**
* Handle LiquidityManager Recentered events
* Creates a new recenter record and updates stats.
* NOTE: Recenter day/week counts are simple incrementing counters.
* For accurate rolling windows, the API layer can query the recenters table directly.
*/
ponder.on('LiquidityManager:Recentered', async ({ event, context }) => {
await ensureStatsExists(context, event.block.timestamp);
const { currentTick, isUp } = event.args;
const recenterId = `${event.block.number}_${event.log.logIndex}`;
// Insert recenter record (ethBalance populated below after read)
await context.db.insert(recenters).values({
id: recenterId,
timestamp: event.block.timestamp,
currentTick: Number(currentTick),
isUp,
ethBalance: null,
outstandingSupply: null,
vwapTick: null,
});
// Update stats — increment counters (simple approach; API can do accurate rolling queries)
const statsData = await context.db.find(stats, { id: STATS_ID });
if (!statsData) return;
await context.db.update(stats, { id: STATS_ID }).set({
lastRecenterTimestamp: event.block.timestamp,
lastRecenterTick: Number(currentTick),
recentersLastDay: (statsData.recentersLastDay ?? 0) + 1,
recentersLastWeek: (statsData.recentersLastWeek ?? 0) + 1,
});
});
/**
* Handle LiquidityManager EthScarcity events
* Updates the most recent recenter record with ETH reserve and VWAP data
* FIXED: Search for matching recenter by block + tick instead of assuming logIndex - 1
*/
ponder.on('LiquidityManager:EthScarcity', async ({ event, context }) => {
const { currentTick, ethBalance, outstandingSupply, vwapTick } = event.args;
// Strategy: Try logIndex-1 first (common case), then search by block+tick (fallback)
// This handles both the common case efficiently and edge cases correctly
let recenterId = `${event.block.number}_${event.log.logIndex - 1}`;
let recenter = await context.db.find(recenters, { id: recenterId });
// 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}...`);
// Fallback: scan recent recenters from this block with matching tick
// Build candidate IDs to check (scan backwards from current logIndex)
for (let offset = 2; offset <= 10 && offset <= event.log.logIndex; offset++) {
const candidateId = `${event.block.number}_${event.log.logIndex - offset}`;
const candidate = await context.db.find(recenters, { id: candidateId });
if (candidate && candidate.currentTick === Number(currentTick)) {
recenter = candidate;
recenterId = candidateId;
context.log.info(`EthScarcity: Found matching recenter at offset -${offset}`);
break;
}
}
}
if (recenter) {
await context.db.update(recenters, { id: recenterId }).set({
ethBalance,
outstandingSupply,
vwapTick: Number(vwapTick),
});
} else {
context.log.error(
`EthScarcity: No matching Recentered event found for block ${event.block.number}, tick ${currentTick}, logIndex ${event.log.logIndex}`
);
}
// Record ETH reserve to history for 7d growth tracking
const historyId = `${event.block.number}_${event.log.logIndex}`;
await context.db.insert(ethReserveHistory).values({
id: historyId,
timestamp: event.block.timestamp,
ethBalance,
});
// Update stats with reserve data, floor price, and 7d growth
await updateReserveStats(context, event, ethBalance, currentTick, vwapTick);
});
/**
* Handle LiquidityManager EthAbundance events
* Updates the most recent recenter record with ETH reserve and VWAP data
* FIXED: Search for matching recenter by block + tick instead of assuming logIndex - 1
*/
ponder.on('LiquidityManager:EthAbundance', async ({ event, context }) => {
const { currentTick, ethBalance, outstandingSupply, vwapTick } = event.args;
// Strategy: Try logIndex-1 first (common case), then search by block+tick (fallback)
// This handles both the common case efficiently and edge cases correctly
let recenterId = `${event.block.number}_${event.log.logIndex - 1}`;
let recenter = await context.db.find(recenters, { id: recenterId });
// 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}...`);
// Fallback: scan recent recenters from this block with matching tick
// Build candidate IDs to check (scan backwards from current logIndex)
for (let offset = 2; offset <= 10 && offset <= event.log.logIndex; offset++) {
const candidateId = `${event.block.number}_${event.log.logIndex - offset}`;
const candidate = await context.db.find(recenters, { id: candidateId });
if (candidate && candidate.currentTick === Number(currentTick)) {
recenter = candidate;
recenterId = candidateId;
context.log.info(`EthAbundance: Found matching recenter at offset -${offset}`);
break;
}
}
}
if (recenter) {
await context.db.update(recenters, { id: recenterId }).set({
ethBalance,
outstandingSupply,
vwapTick: Number(vwapTick),
});
} else {
context.log.error(
`EthAbundance: No matching Recentered event found for block ${event.block.number}, tick ${currentTick}, logIndex ${event.log.logIndex}`
);
}
// Update stats with reserve data, floor price, and 7d growth
await updateReserveStats(context, event, ethBalance, currentTick, vwapTick);
});
/**
* Shared logic for EthScarcity and EthAbundance handlers:
* Records ETH reserve history, calculates 7d growth, floor price, and updates stats.
*/
async function updateReserveStats(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: { db: any; log: any },
event: { block: { number: bigint; timestamp: bigint }; log: { logIndex: number } },
ethBalance: bigint,
currentTick: number | bigint,
vwapTick: number | bigint
) {
// Record ETH reserve to history for 7d growth tracking
const historyId = `${event.block.number}_${event.log.logIndex}`;
await context.db.insert(ethReserveHistory).values({
id: historyId,
timestamp: event.block.timestamp,
ethBalance,
});
// Look back 7 days for growth calculation using raw Drizzle query
const sevenDaysAgo = event.block.timestamp - SECONDS_IN_7_DAYS;
const oldReserves = await context.db.sql
.select()
.from(ethReserveHistory)
.where(gte(ethReserveHistory.timestamp, sevenDaysAgo))
.orderBy(asc(ethReserveHistory.timestamp))
.limit(1);
let ethReserve7dAgo: bigint | null = null;
let ethReserveGrowthBps: number | null = null;
if (oldReserves.length > 0 && oldReserves[0]) {
ethReserve7dAgo = oldReserves[0].ethBalance;
ethReserveGrowthBps = calculateBps(ethBalance, ethReserve7dAgo);
}
// Calculate floor price (from vwapTick) and current price (from currentTick)
const floorTick = Number(vwapTick);
const floorPriceWei = priceFromTick(floorTick);
const currentPriceWei = priceFromTick(Number(currentTick));
// Calculate distance from floor in basis points
const floorDistanceBps = calculateBps(currentPriceWei, floorPriceWei);
// Update stats with all metrics
await context.db.update(stats, { id: STATS_ID }).set({
lastEthReserve: ethBalance,
lastVwapTick: Number(vwapTick),
ethReserve7dAgo,
ethReserveGrowthBps,
floorTick,
floorPriceWei,
currentPriceWei,
floorDistanceBps,
});
}