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 { 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[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[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[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[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[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[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 } }).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 } }; expect(dbMock.db.update).toHaveBeenCalled(); const setArg = (dbMock.db.update as ReturnType).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 } }; const setArg = (dbMock.db.update as ReturnType).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 } }; const setArg = (dbMock.db.update as ReturnType).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 } }; const setArg = (dbMock.db.update as ReturnType).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 } }; const setArgs = (dbMock.db.update as ReturnType).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 } }; expect(dbMock.db.update).toHaveBeenCalled(); const setArg = (dbMock.db.update as ReturnType).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 } }; 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 } }; const setArg = (dbMock.db.update as ReturnType).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 } }).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 } }; const setArg = (dbMock.db.update as ReturnType).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 } }).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 } }).client.readContract = vi.fn().mockResolvedValue(9999n); await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`); const dbMock = ctx as unknown as { db: { update: ReturnType } }; expect(dbMock.db.update).toHaveBeenCalled(); const setArg = (dbMock.db.update as ReturnType).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 } }).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 } }; 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 } }; 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 } }).db.find = dbFindMock; const result = await ensureStatsExists(ctx, 1000n); const dbMock = ctx as unknown as { db: { insert: ReturnType } }; 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 } }).db.find = dbFindMock; (ctx as unknown as { client: { readContract: ReturnType } }).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 } }; 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 } }; expect(dbMock.db.update).toHaveBeenCalled(); const setArg = (dbMock.db.update as ReturnType).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 } }).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 } }).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 } }).client.readContract = vi.fn().mockResolvedValue(1234n); await refreshOutstandingStake(ctx); const dbMock = ctx as unknown as { db: { update: ReturnType } }; expect(dbMock.db.update).toHaveBeenCalled(); const setArg = (dbMock.db.update as ReturnType).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 } }).client.readContract = vi.fn().mockResolvedValue(500n); await refreshMinStake(ctx, row as unknown as Awaited>); const dbMock = ctx as unknown as { db: { update: ReturnType } }; 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 } }).client.readContract = vi.fn().mockResolvedValue(200n); await refreshMinStake(ctx, row as unknown as Awaited>); const dbMock = ctx as unknown as { db: { update: ReturnType } }; expect(dbMock.db.update).toHaveBeenCalled(); const setArg = (dbMock.db.update as ReturnType).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 } }).client.readContract = vi.fn().mockResolvedValue(300n); await refreshMinStake(ctx); const dbMock = ctx as unknown as { db: { update: ReturnType } }; 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 } }).db.find = dbFindMock; (ctx as unknown as { client: { readContract: ReturnType } }).client.readContract = vi.fn().mockResolvedValue(300n); await refreshMinStake(ctx); const dbMock = ctx as unknown as { db: { update: ReturnType } }; 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 } }).client.readContract = vi.fn().mockRejectedValue(new Error('rpc error')); await refreshMinStake(ctx, row as unknown as Awaited>); const dbMock = ctx as unknown as { db: { update: ReturnType } }; 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 } }; expect(dbMock.db.update).toHaveBeenCalled(); const setArg = (dbMock.db.update as ReturnType).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 } }; 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 } }; const setArg = (dbMock.db.update as ReturnType).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 } }; const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; expect(setArg.netSupplyChangeDay).toBeGreaterThan(0n); expect(setArg.netSupplyChangeWeek).toBeGreaterThan(0n); }); });