harb/services/ponder/tests/stats.test.ts

784 lines
31 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
makeEmptyRingBuffer,
parseRingBuffer,
serializeRingBuffer,
checkBlockHistorySufficient,
updateHourlyData,
ensureStatsExists,
recordEthReserveSnapshot,
updateEthReserve,
markPositionsUpdated,
refreshOutstandingStake,
refreshMinStake,
RING_BUFFER_SEGMENTS,
MINIMUM_BLOCKS_FOR_RINGBUFFER,
type StatsContext,
} from '../src/helpers/stats.js';
// Constants duplicated from the mock so tests don't re-import ponder:schema
const HOURS = 168;
const SECS_PER_HOUR = 3600;
// ---------------------------------------------------------------------------
// Helper factories
// ---------------------------------------------------------------------------
interface MockStatsRow {
ringBuffer: string[];
ringBufferPointer: number;
lastHourlyUpdateTimestamp: bigint;
holderCount: number;
minStake?: bigint;
stakeTotalSupply?: bigint;
outstandingStake?: bigint;
kraikenTotalSupply?: bigint;
}
function emptyStatsRow(overrides: Partial<MockStatsRow> = {}): MockStatsRow {
return {
ringBuffer: makeEmptyRingBuffer().map(String),
ringBufferPointer: 0,
lastHourlyUpdateTimestamp: 0n,
holderCount: 0,
minStake: 0n,
stakeTotalSupply: 0n,
outstandingStake: 0n,
kraikenTotalSupply: 0n,
...overrides,
};
}
/** Build a ring buffer string[] with a known value at a specific slot. */
function ringBufferWith(
pointer: number,
slotOffset: number,
value: bigint,
hoursBack = 0,
): string[] {
const buf = makeEmptyRingBuffer();
const idx = ((pointer - hoursBack + HOURS) % HOURS) * RING_BUFFER_SEGMENTS + slotOffset;
buf[idx] = value;
return buf.map(String);
}
function createSetMock() {
return vi.fn().mockResolvedValue(undefined);
}
function createMockContext(statsRow: MockStatsRow | null = null): StatsContext {
const setFn = createSetMock();
const ctx = {
db: {
find: vi.fn().mockResolvedValue(statsRow),
update: vi.fn().mockReturnValue({ set: setFn }),
insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }),
},
client: {
readContract: vi.fn().mockResolvedValue(0n),
},
contracts: {
Kraiken: { abi: [], address: '0x0000000000000000000000000000000000000001' as `0x${string}` },
Stake: { abi: [], address: '0x0000000000000000000000000000000000000002' as `0x${string}` },
},
network: {
contracts: {
Kraiken: { abi: [], address: '0x0000000000000000000000000000000000000001' },
},
},
logger: {
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
},
};
// Cast: vitest uses esbuild (no type-check), so the slim mock satisfies the runtime contract.
return ctx as unknown as StatsContext;
}
// ---------------------------------------------------------------------------
// makeEmptyRingBuffer
// ---------------------------------------------------------------------------
describe('makeEmptyRingBuffer', () => {
it('returns an array of the correct length', () => {
const buf = makeEmptyRingBuffer();
expect(buf).toHaveLength(HOURS * RING_BUFFER_SEGMENTS);
});
it('fills every element with 0n', () => {
const buf = makeEmptyRingBuffer();
expect(buf.every(v => v === 0n)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// parseRingBuffer
// ---------------------------------------------------------------------------
describe('parseRingBuffer', () => {
it('converts a string array to bigint array', () => {
const raw = ['1', '2', '3'];
expect(parseRingBuffer(raw)).toEqual([1n, 2n, 3n]);
});
it('returns empty ring buffer for null input', () => {
expect(parseRingBuffer(null)).toHaveLength(HOURS * RING_BUFFER_SEGMENTS);
expect(parseRingBuffer(null).every(v => v === 0n)).toBe(true);
});
it('returns empty ring buffer for undefined input', () => {
expect(parseRingBuffer(undefined)).toHaveLength(HOURS * RING_BUFFER_SEGMENTS);
});
it('returns empty ring buffer for empty array input', () => {
expect(parseRingBuffer([])).toHaveLength(HOURS * RING_BUFFER_SEGMENTS);
});
});
// ---------------------------------------------------------------------------
// serializeRingBuffer
// ---------------------------------------------------------------------------
describe('serializeRingBuffer', () => {
it('converts bigint array to string array', () => {
expect(serializeRingBuffer([0n, 1n, 1000n])).toEqual(['0', '1', '1000']);
});
it('round-trips through parseRingBuffer', () => {
const original = makeEmptyRingBuffer();
original[0] = 42n;
original[RING_BUFFER_SEGMENTS] = 99n;
const serialized = serializeRingBuffer(original);
expect(parseRingBuffer(serialized)).toEqual(original);
});
});
// ---------------------------------------------------------------------------
// checkBlockHistorySufficient
// ---------------------------------------------------------------------------
describe('checkBlockHistorySufficient', () => {
it('returns false when context has no network contracts', () => {
const ctx = { network: {} } as unknown as StatsContext;
const event = { block: { number: 200n } } as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
expect(checkBlockHistorySufficient(ctx, event)).toBe(false);
});
it('returns false when context is missing network entirely', () => {
const ctx = {} as unknown as StatsContext;
const event = { block: { number: 200n } } as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
expect(checkBlockHistorySufficient(ctx, event)).toBe(false);
});
it('returns false when not enough blocks have passed', () => {
const ctx = createMockContext();
const event = {
block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER - 1) },
} as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
expect(checkBlockHistorySufficient(ctx, event)).toBe(false);
});
it('uses console fallback when context has no logger', () => {
// Context with network.contracts.Kraiken but no logger — exercises logger.ts fallback branch
const ctx = {
network: { contracts: { Kraiken: {} } },
} as unknown as StatsContext;
const event = {
block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER - 1) },
} as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
// Should not throw; console.warn is used as fallback
expect(checkBlockHistorySufficient(ctx, event)).toBe(false);
});
it('returns true when sufficient blocks have passed', () => {
const ctx = createMockContext();
const event = {
block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER + 1) },
} as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
expect(checkBlockHistorySufficient(ctx, event)).toBe(true);
});
it('returns true at exact threshold', () => {
const ctx = createMockContext();
const event = {
block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER) },
} as unknown as Parameters<typeof checkBlockHistorySufficient>[1];
expect(checkBlockHistorySufficient(ctx, event)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// updateHourlyData
// ---------------------------------------------------------------------------
describe('updateHourlyData', () => {
it('returns early when no stats row exists', async () => {
const ctx = createMockContext(null);
await updateHourlyData(ctx, BigInt(SECS_PER_HOUR * 10));
expect((ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } }).db.update).not.toHaveBeenCalled();
});
it('initialises lastHourlyUpdateTimestamp when it is 0n', async () => {
const row = emptyStatsRow({ lastHourlyUpdateTimestamp: 0n });
const ctx = createMockContext(row);
const timestamp = BigInt(SECS_PER_HOUR * 5);
await updateHourlyData(ctx, timestamp);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
// Should store the current hour
expect(setArg.lastHourlyUpdateTimestamp).toBe((timestamp / BigInt(SECS_PER_HOUR)) * BigInt(SECS_PER_HOUR));
});
it('advances the pointer when a new hour has started', async () => {
const baseTimestamp = BigInt(SECS_PER_HOUR * 100);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: 0,
});
const ctx = createMockContext(row);
// Move 3 hours forward
const newTimestamp = baseTimestamp + BigInt(SECS_PER_HOUR * 3 + 60);
await updateHourlyData(ctx, newTimestamp);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.ringBufferPointer).toBe(3);
});
it('clamps hours elapsed to HOURS_IN_RING_BUFFER to prevent full-buffer clear loops', async () => {
const baseTimestamp = BigInt(SECS_PER_HOUR * 100);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: 0,
});
const ctx = createMockContext(row);
// Jump 500 hours — exceeds ring buffer
const newTimestamp = baseTimestamp + BigInt(SECS_PER_HOUR * 500);
await updateHourlyData(ctx, newTimestamp);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
// pointer should wrap around within HOURS range
expect(setArg.ringBufferPointer).toBeGreaterThanOrEqual(0);
expect(setArg.ringBufferPointer).toBeLessThan(HOURS);
});
it('computes metrics when hour has advanced (mintedDay, burnedDay, etc.)', async () => {
const pointer = 5;
// Slot 1 (minted) at index i=0 (current hour) through i=23 — 10n in each of last 48 hours
const buf = makeEmptyRingBuffer();
for (let h = 0; h < 48; h++) {
const idx = ((pointer - h + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[idx + 1] = 10n; // minted
buf[idx + 2] = 5n; // burned
}
const baseTimestamp = BigInt(SECS_PER_HOUR * 200);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2));
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
// After advancing 2 hours (new pointer=7), slots 6+7 are cleared (were already 0).
// Filled slots: i=2..49 from pointer=7 (48 slots × 10n minted / 5n burned).
// mintedLastDay: i<24 → i=2..23 = 22 slots × 10n = 220n
// mintedLastWeek: 48 × 10n = 480n
// mintNextHourProjected = mintedWeek / 168n = 480n / 168n = 2n (BigInt truncation)
// burnNextHourProjected = burnedWeek / 168n = 240n / 168n = 1n
expect(setArg.mintedLastWeek).toBe(480n);
expect(setArg.mintedLastDay).toBe(220n);
expect(setArg.burnedLastWeek).toBe(240n);
expect(setArg.burnedLastDay).toBe(110n);
expect(setArg.mintNextHourProjected).toBe(2n);
expect(setArg.burnNextHourProjected).toBe(1n);
expect(setArg.netSupplyChangeDay).toBe(110n);
expect(setArg.netSupplyChangeWeek).toBe(240n);
});
it('carries forward holderCount into new ring buffer slots', async () => {
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: BigInt(SECS_PER_HOUR * 10),
ringBufferPointer: 0,
holderCount: 42,
});
const ctx = createMockContext(row);
await updateHourlyData(ctx, BigInt(SECS_PER_HOUR * 12));
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArgs = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
const updatedBuf = parseRingBuffer(setArgs.ringBuffer as string[]);
// pointer advanced to 2; slot 3 of new pointer should be 42n
const newPointer = 2;
expect(updatedBuf[newPointer * RING_BUFFER_SEGMENTS + 3]).toBe(42n);
});
it('computes projections when same hour (no advancement)', async () => {
const pointer = 3;
const baseTimestamp = BigInt(SECS_PER_HOUR * 100);
// Add some minted data in the current and previous slot
const buf = makeEmptyRingBuffer();
buf[pointer * RING_BUFFER_SEGMENTS + 1] = 100n; // current hour minted
const prevPointer = ((pointer - 1 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[prevPointer + 1] = 80n; // previous hour minted
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
// Same hour but 30 minutes in
const sameHourTimestamp = baseTimestamp + BigInt(SECS_PER_HOUR / 2);
await updateHourlyData(ctx, sameHourTimestamp);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
// elapsed = 1800s (30 min); current minted = 100n, prev minted = 80n
// projectedTotal = (100n * 3600n) / 1800n = 200n
// medium = (80n + 200n) / 2n = 140n → mintProjection = 140n
// burn: current=0n, prev=0n → medium=0n → fallback: mintedWeek/7 = 0n
expect(setArg.mintNextHourProjected).toBe(140n);
expect(setArg.burnNextHourProjected).toBe(0n);
});
it('handles zero elapsedSeconds in projection (exact hour boundary)', async () => {
const pointer = 0;
const exactHour = BigInt(SECS_PER_HOUR * 50);
const buf = makeEmptyRingBuffer();
buf[pointer * RING_BUFFER_SEGMENTS + 1] = 50n; // some minted
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: exactHour,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
// Pass exactly the same timestamp (elapsedSeconds = 0)
await updateHourlyData(ctx, exactHour);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
});
it('computes ETH reserve change metrics when reserves are populated', async () => {
const pointer = 25;
const buf = makeEmptyRingBuffer();
// Set current hour ETH reserve (i=0)
buf[pointer * RING_BUFFER_SEGMENTS + 0] = 2000n;
// Set 24h ago ETH reserve (i=23)
const idx24h = ((pointer - 23 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[idx24h + 0] = 1000n;
const baseTimestamp = BigInt(SECS_PER_HOUR * 200);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2));
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.ethReserveLastDay).toBeGreaterThanOrEqual(0n);
});
});
// ---------------------------------------------------------------------------
// recordEthReserveSnapshot
// ---------------------------------------------------------------------------
describe('recordEthReserveSnapshot', () => {
it('returns early when no stats row exists', async () => {
const ctx = createMockContext(null);
await recordEthReserveSnapshot(ctx, 500n);
expect((ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } }).db.update).not.toHaveBeenCalled();
});
it('writes the ETH balance into slot 0 of the current pointer', async () => {
const pointer = 2;
const row = emptyStatsRow({ ringBufferPointer: pointer });
const ctx = createMockContext(row);
const ethBalance = 12345678n;
await recordEthReserveSnapshot(ctx, ethBalance);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
const updatedBuf = parseRingBuffer(setArg.ringBuffer as string[]);
expect(updatedBuf[pointer * RING_BUFFER_SEGMENTS + 0]).toBe(ethBalance);
});
});
// ---------------------------------------------------------------------------
// updateEthReserve
// ---------------------------------------------------------------------------
describe('updateEthReserve', () => {
it('skips update when weth balance is 0', async () => {
const ctx = createMockContext(emptyStatsRow());
// readContract returns 0n by default
await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`);
expect((ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } }).db.update).not.toHaveBeenCalled();
});
it('updates lastEthReserve when weth balance is non-zero', async () => {
const ctx = createMockContext(emptyStatsRow());
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(9999n);
await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.lastEthReserve).toBe(9999n);
});
it('logs warning and returns when readContract throws', async () => {
const ctx = createMockContext(emptyStatsRow());
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockRejectedValue(new Error('rpc error'));
await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// ensureStatsExists
// ---------------------------------------------------------------------------
describe('ensureStatsExists', () => {
it('returns existing stats without re-creating when row exists', async () => {
const row = emptyStatsRow();
const ctx = createMockContext(row);
const result = await ensureStatsExists(ctx, 1000n);
const dbMock = ctx as unknown as { db: { insert: ReturnType<typeof vi.fn> } };
expect(dbMock.db.insert).not.toHaveBeenCalled();
expect(result).toBe(row);
});
it('creates a new stats row when none exists', async () => {
// First call returns null, second call (post-insert) returns the row
const row = emptyStatsRow();
const dbFindMock = vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(row);
const ctx = createMockContext(null);
(ctx as unknown as { db: { find: ReturnType<typeof vi.fn> } }).db.find = dbFindMock;
const result = await ensureStatsExists(ctx, 1000n);
const dbMock = ctx as unknown as { db: { insert: ReturnType<typeof vi.fn> } };
expect(dbMock.db.insert).toHaveBeenCalled();
expect(result).toBe(row);
});
it('uses fallback values when readContract throws during creation', async () => {
const row = emptyStatsRow();
const dbFindMock = vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(row);
const ctx = createMockContext(null);
(ctx as unknown as { db: { find: ReturnType<typeof vi.fn> } }).db.find = dbFindMock;
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockRejectedValue(new Error('rpc error'));
const result = await ensureStatsExists(ctx, 1000n);
expect(result).toBe(row);
// insert should still have been called with fallback 0n values
const dbMock = ctx as unknown as { db: { insert: ReturnType<typeof vi.fn> } };
expect(dbMock.db.insert).toHaveBeenCalled();
});
it('works without timestamp argument', async () => {
const row = emptyStatsRow();
const ctx = createMockContext(row);
const result = await ensureStatsExists(ctx);
expect(result).toBe(row);
});
});
// ---------------------------------------------------------------------------
// markPositionsUpdated
// ---------------------------------------------------------------------------
describe('markPositionsUpdated', () => {
it('updates positionsUpdatedAt in the stats row', async () => {
const row = emptyStatsRow();
const ctx = createMockContext(row);
const ts = 7777n;
await markPositionsUpdated(ctx, ts);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.positionsUpdatedAt).toBe(ts);
});
});
// ---------------------------------------------------------------------------
// getStakeTotalSupply
// ---------------------------------------------------------------------------
describe('getStakeTotalSupply', () => {
beforeEach(() => {
vi.resetModules();
});
it('reads totalSupply from contract when cache is cold', async () => {
// Fresh import to reset module-level cache
const { getStakeTotalSupply: freshGet } = await import('../src/helpers/stats.js');
const row = emptyStatsRow({ stakeTotalSupply: 0n });
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(555n);
const result = await freshGet(ctx);
expect(result).toBe(555n);
});
it('returns cached value without calling readContract on second call', async () => {
// Fresh import to get a clean module with null cache
const { getStakeTotalSupply: freshGet } = await import('../src/helpers/stats.js');
const row = emptyStatsRow({ stakeTotalSupply: 0n });
const readContractMock = vi.fn().mockResolvedValue(777n);
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= readContractMock;
// First call — populates cache via readContract
const first = await freshGet(ctx);
expect(first).toBe(777n);
// Second call — should return cached value, readContract not called again
const second = await freshGet(ctx);
expect(second).toBe(777n);
expect(readContractMock).toHaveBeenCalledTimes(1);
});
});
// ---------------------------------------------------------------------------
// refreshOutstandingStake
// ---------------------------------------------------------------------------
describe('refreshOutstandingStake', () => {
it('reads outstandingStake from contract and persists it', async () => {
const row = emptyStatsRow();
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(1234n);
await refreshOutstandingStake(ctx);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.outstandingStake).toBe(1234n);
});
});
// ---------------------------------------------------------------------------
// refreshMinStake
// ---------------------------------------------------------------------------
describe('refreshMinStake', () => {
it('skips update when minStake is unchanged', async () => {
const row = emptyStatsRow({ minStake: 500n });
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(500n);
await refreshMinStake(ctx, row as unknown as Awaited<ReturnType<typeof ensureStatsExists>>);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).not.toHaveBeenCalled();
});
it('updates minStake when the on-chain value has changed', async () => {
const row = emptyStatsRow({ minStake: 100n });
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(200n);
await refreshMinStake(ctx, row as unknown as Awaited<ReturnType<typeof ensureStatsExists>>);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.minStake).toBe(200n);
});
it('fetches stats when no statsData arg is provided and row exists', async () => {
const row = emptyStatsRow({ minStake: 0n });
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(300n);
await refreshMinStake(ctx);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
});
it('creates stats row when find returns null', async () => {
const row = emptyStatsRow({ minStake: 0n });
const dbFindMock = vi.fn()
.mockResolvedValueOnce(null) // first find (refresh check)
.mockResolvedValueOnce(null) // ensureStatsExists find
.mockResolvedValueOnce(row); // ensureStatsExists post-insert
const ctx = createMockContext(null);
(ctx as unknown as { db: { find: ReturnType<typeof vi.fn> } }).db.find = dbFindMock;
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(300n);
await refreshMinStake(ctx);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
});
it('logs warning and returns when readContract throws', async () => {
const row = emptyStatsRow({ minStake: 100n });
const ctx = createMockContext(row);
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockRejectedValue(new Error('rpc error'));
await refreshMinStake(ctx, row as unknown as Awaited<ReturnType<typeof ensureStatsExists>>);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).not.toHaveBeenCalled();
});
it('uses ringBufferWith helper to build non-trivial buffers', () => {
const pointer = 10;
const buf = parseRingBuffer(ringBufferWith(pointer, 1, 999n));
expect(buf[pointer * RING_BUFFER_SEGMENTS + 1]).toBe(999n);
});
});
// ---------------------------------------------------------------------------
// RING_BUFFER_SEGMENTS and MINIMUM_BLOCKS_FOR_RINGBUFFER exports
// ---------------------------------------------------------------------------
describe('constants', () => {
it('RING_BUFFER_SEGMENTS is 4', () => {
expect(RING_BUFFER_SEGMENTS).toBe(4);
});
it('MINIMUM_BLOCKS_FOR_RINGBUFFER is 100', () => {
expect(MINIMUM_BLOCKS_FOR_RINGBUFFER).toBe(100);
});
});
// ---------------------------------------------------------------------------
// computeMetrics coverage: holderCount and ethReserve at 24h/7d boundaries
// ---------------------------------------------------------------------------
describe('computeMetrics boundary coverage (via updateHourlyData)', () => {
it('captures ethReserve7dAgo from the oldest non-zero slot', async () => {
const pointer = 2;
const buf = makeEmptyRingBuffer();
// Fill a slot > 24 hours back with a non-zero ETH reserve
const oldSlot = ((pointer - 50 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[oldSlot + 0] = 500n;
// Current hour also has a value
buf[pointer * RING_BUFFER_SEGMENTS + 0] = 1000n;
const baseTimestamp = BigInt(SECS_PER_HOUR * 200);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2));
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.ethReserveLastWeek).toBeGreaterThanOrEqual(0n);
});
it('records holderCount at 24h and 7d ago markers', async () => {
const pointer = 30;
const buf = makeEmptyRingBuffer();
// Set holderCount at current pointer (i=0)
buf[pointer * RING_BUFFER_SEGMENTS + 3] = 100n;
// Set holderCount at 24h ago (i=23)
const idx24h = ((pointer - 23 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[idx24h + 3] = 80n;
// Set holderCount far back (i=150, older than 7d)
const idxOld = ((pointer - 150 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[idxOld + 3] = 50n;
const baseTimestamp = BigInt(SECS_PER_HOUR * 500);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2));
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
});
it('handles projection fallback when weekly totals are also zero', async () => {
// All zeros in ring buffer — medium will be 0, fallback is weekly/7
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: BigInt(SECS_PER_HOUR * 100),
ringBufferPointer: 0,
});
const ctx = createMockContext(row);
const sameHourTimestamp = BigInt(SECS_PER_HOUR * 100) + BigInt(SECS_PER_HOUR / 4);
await updateHourlyData(ctx, sameHourTimestamp);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
// Projection should be 0 (weekly/7 = 0/7 = 0)
expect(setArg.mintNextHourProjected).toBe(0n);
expect(setArg.burnNextHourProjected).toBe(0n);
});
it('computes netSupplyChange from minted minus burned', async () => {
const pointer = 1;
const buf = makeEmptyRingBuffer();
for (let h = 0; h < 24; h++) {
const idx = ((pointer - h + HOURS) % HOURS) * RING_BUFFER_SEGMENTS;
buf[idx + 1] = 20n; // minted
buf[idx + 2] = 8n; // burned
}
const baseTimestamp = BigInt(SECS_PER_HOUR * 300);
const row = emptyStatsRow({
lastHourlyUpdateTimestamp: baseTimestamp,
ringBufferPointer: pointer,
ringBuffer: buf.map(String),
});
const ctx = createMockContext(row);
await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR));
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.netSupplyChangeDay).toBeGreaterThan(0n);
expect(setArg.netSupplyChangeWeek).toBeGreaterThan(0n);
});
});