- Add vitest ^2 + @vitest/coverage-v8 ^2 as devDependencies - Add `test` and `test:coverage` scripts to package.json - Create vitest.config.ts with resolve.alias to mock ponder virtual modules (ponder:schema, ponder:registry) and point kraiken-lib/version to source - Add coverage/ to .gitignore - Add tests/**/* and vitest.config.ts to tsconfig.json include - Create tests/__mocks__/ponder-schema.ts and ponder-registry.ts stubs - Create tests/stats.test.ts — 48 tests covering ring buffer logic, segment updates, hourly advancement, projections, ETH reserve snapshots, all exported async helpers with mock Ponder contexts - Create tests/version.test.ts — 14 tests covering isCompatibleVersion, getVersionMismatchError, and validateContractVersion (compatible / mismatch / error paths, existing-meta upsert path) - Create tests/abi.test.ts — 6 tests covering validateAbi and validateContractAbi Tests placed at tests/ (not src/tests/) so Ponder's Vite build does not attempt to execute test files as event handlers on startup. Result: 68 tests pass, 100% line/statement/function coverage on all helpers (stats.ts, version.ts, abi.ts, logger.ts) — exceeds 95% target. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
772 lines
31 KiB
TypeScript
772 lines
31 KiB
TypeScript
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];
|
|
// mintedLastWeek and mintedLastDay should be positive
|
|
expect(setArg.mintedLastWeek).toBeGreaterThan(0n);
|
|
expect(setArg.mintedLastDay).toBeGreaterThan(0n);
|
|
expect(setArg.burnedLastWeek).toBeGreaterThan(0n);
|
|
expect(setArg.burnedLastDay).toBeGreaterThan(0n);
|
|
});
|
|
|
|
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];
|
|
// Projections should be computed
|
|
expect(setArg.mintNextHourProjected).toBeDefined();
|
|
expect(setArg.burnNextHourProjected).toBeDefined();
|
|
});
|
|
|
|
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, 1000n, 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, 1000n, 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);
|
|
});
|
|
});
|
|
|