diff --git a/src/app.ts b/src/app.ts index 151b002..f27d0dd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -29,6 +29,7 @@ import userAuthRoutes from './routes/auth/user.auth.routes'; import marketSpotRoutes from './routes/spot/marketSpot.routes'; import marketPerpRoutes from './routes/perp/marketPerp.routes'; import revenueRoutes from './routes/revenue/revenue.routes'; +import priorityFeesRoutes from './routes/priorityFees/priorityFees.routes'; import globalSpotStatsRoutes from './routes/spot/spotStats.routes'; import stablecoinsRoutes from './routes/spot/stablecoins.routes'; import globalPerpStatsRoutes from './routes/perp/perpStats.routes'; @@ -136,6 +137,7 @@ app.use('/market/auction', auctionRoutes); app.use('/market/vaults', vaultsRoutes); app.use('/market/fees', feesRoutes); app.use('/market/revenue', revenueRoutes); +app.use('/market/priority-fees', priorityFeesRoutes); app.use('/wallet', walletRoutes); app.use('/project', projectRoutes); app.use('/project/csv', projectCsvRoutes); diff --git a/src/routes/priorityFees/priorityFees.routes.ts b/src/routes/priorityFees/priorityFees.routes.ts new file mode 100644 index 0000000..12ea19a --- /dev/null +++ b/src/routes/priorityFees/priorityFees.routes.ts @@ -0,0 +1,49 @@ +import { Router } from 'express'; +import { marketRateLimiter } from '../../middleware/apiRateLimiter'; +import { PriorityFeesService } from '../../services/priorityFees/priorityFees.service'; +import { PriorityFeesError, PriorityFeesWindow } from '../../types/priorityFees.types'; +import { logDeduplicator } from '../../utils/logDeduplicator'; + +const router = Router(); +const priorityFeesService = PriorityFeesService.getInstance(); + +const VALID_WINDOWS: PriorityFeesWindow[] = ['24h', '7d']; + +router.get('/series', marketRateLimiter, async (req, res) => { + const raw = (req.query.window as string | undefined) ?? '24h'; + const window = VALID_WINDOWS.includes(raw as PriorityFeesWindow) + ? (raw as PriorityFeesWindow) + : null; + + if (!window) { + return res.status(400).json({ + success: false, + error: { + message: `Invalid window. Must be one of: ${VALID_WINDOWS.join(', ')}`, + code: 'INVALID_WINDOW', + }, + }); + } + + try { + const series = await priorityFeesService.getSeries(window); + return res.json({ success: true, data: series }); + } catch (error: unknown) { + logDeduplicator.error('Error fetching priority fees series:', { + error: error instanceof Error ? error.message : String(error), + }); + + if (error instanceof PriorityFeesError) { + return res.status(error.statusCode).json({ + success: false, + error: { message: error.message, code: error.code }, + }); + } + return res.status(502).json({ + success: false, + error: { message: 'Upstream error', code: 'PRIORITY_FEES_SERIES_ERROR' }, + }); + } +}); + +export default router; diff --git a/src/services/priorityFees/priorityFees.series.ts b/src/services/priorityFees/priorityFees.series.ts new file mode 100644 index 0000000..223423e --- /dev/null +++ b/src/services/priorityFees/priorityFees.series.ts @@ -0,0 +1,101 @@ +import { PriorityFeesBucket, PriorityFeesWindow } from '../../types/priorityFees.types'; + +/** Widest lookback `/analytics/priority-fees/stats` accepts. */ +export const UPSTREAM_MAX_WINDOW_HOURS = 168; + +const HOUR_SECONDS = 3_600; + +/** Bucket width per window, in seconds. */ +export const BUCKET_SECONDS: Record = { + '24h': HOUR_SECONDS, + '7d': 6 * HOUR_SECONDS, +}; + +/** + * A single `/analytics/priority-fees/stats?hours=h` answer, normalized. + * + * The upstream reports cumulatively: every rollup starts `hours` ago and ends + * now, so a wider window always contains a narrower one. + */ +export interface CumulativeRollup { + hours: number; + startMs: number; + endMs: number; + gas: number; + fills: number; +} + +/** + * Lookback values to ask the upstream for, so that differencing consecutive + * answers yields one bucket each. + * + * The ladder is capped at 168 h because that is the widest rollup the upstream + * serves. A longer history would have to come from + * `/analytics/priority-fees/chart/daily`, which stopped advancing on + * 2026-07-11. + */ +export function bucketLadder(window: PriorityFeesWindow): number[] { + const step = BUCKET_SECONDS[window] / HOUR_SECONDS; + const span = window === '24h' ? 24 : UPSTREAM_MAX_WINDOW_HOURS; + const hours: number[] = []; + for (let h = step; h <= span; h += step) hours.push(h); + return hours; +} + +/** + * Turn cumulative rollups into disjoint buckets by differencing neighbours. + * + * The narrowest rollup is a bucket on its own; every wider one contributes the + * slice it adds over its predecessor. Buckets carry the span they actually + * cover rather than the nominal width, so a rollup the upstream failed to + * answer for widens its successor instead of silently shifting every bucket + * that follows onto the wrong hour. + * + * Returned oldest first, which is the order a chart reads. + */ +export function computeBuckets(rollups: CumulativeRollup[]): PriorityFeesBucket[] { + const usable = rollups + .filter( + (r) => + Number.isFinite(r.hours) && + r.hours > 0 && + Number.isFinite(r.startMs) && + Number.isFinite(r.endMs) && + r.startMs < r.endMs && + Number.isFinite(r.gas) && + Number.isFinite(r.fills) + ) + .sort((a, b) => a.hours - b.hours); + + const buckets: PriorityFeesBucket[] = []; + let previous: CumulativeRollup | null = null; + + for (const rollup of usable) { + if (previous === null) { + buckets.push({ + start: rollup.startMs, + end: rollup.endMs, + gas: Math.max(0, rollup.gas), + fills: Math.max(0, rollup.fills), + }); + previous = rollup; + continue; + } + + // Same lookback twice, or a wider window that somehow starts later: neither + // describes a slice, so there is nothing to add. + if (rollup.hours === previous.hours || rollup.startMs >= previous.startMs) continue; + + // Consecutive calls end a few seconds apart, so a near-empty slice can come + // out slightly negative. That is drift, not a refund. + buckets.push({ + start: rollup.startMs, + end: previous.startMs, + gas: Math.max(0, rollup.gas - previous.gas), + fills: Math.max(0, rollup.fills - previous.fills), + }); + previous = rollup; + } + + return buckets.sort((a, b) => a.start - b.start); +} diff --git a/src/services/priorityFees/priorityFees.service.ts b/src/services/priorityFees/priorityFees.service.ts new file mode 100644 index 0000000..eb99bb9 --- /dev/null +++ b/src/services/priorityFees/priorityFees.service.ts @@ -0,0 +1,234 @@ +import { + PriorityFeesSeries, + PriorityFeesTotals, + PriorityFeesWindow, +} from '../../types/priorityFees.types'; +import { redisService } from '../../core/redis.service'; +import { logDeduplicator } from '../../utils/logDeduplicator'; +import { HypeDexerAnalyticsIndexerClient } from '../../clients/hypedexer/rest/analytics/analytics-indexer.client'; +import { + BUCKET_SECONDS, + CumulativeRollup, + UPSTREAM_MAX_WINDOW_HOURS, + bucketLadder, + computeBuckets, +} from './priorityFees.series'; + +const SERIES_CACHE_PREFIX = 'priority-fees:series:'; +const SERIES_CACHE_TTL_SECONDS: Record = { + '24h': 5 * 60, + '7d': 15 * 60, +}; + +const PERP_MARKETS_CACHE_KEY = 'perp:markets'; + +/** + * Rollups fired at once. The upstream analytics lane allows 100 requests a + * minute; a window costs at most 29 of them and only on a cache miss, so the + * cap here is about not opening thirty sockets in one tick rather than about + * the quota. + */ +const FANOUT_CONCURRENCY = 6; + +const WINDOW_HOURS: Record = { + '24h': 24, + '7d': UPSTREAM_MAX_WINDOW_HOURS, +}; + +interface PerpMarketLite { + name: string; + price: number; +} + +/** `/analytics/priority-fees/stats` — only the fields we read. */ +interface PriorityStatsPayload { + total_priority_gas?: number; + total_fills_with_priority?: number; + avg_priority_gas?: number; + min_priority_gas?: number; + max_priority_gas?: number; + unique_users?: number; + time_range?: { start?: string; end?: string }; +} + +/** `/analytics/fills/stats` — the denominators. */ +interface FillsStatsPayload { + total_fills?: number; + unique_users?: number; +} + +function finite(value: unknown): number | null { + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +/** Upstream stamps are ISO-8601 and already UTC, with or without the suffix. */ +function parseUpstreamMs(iso: string | undefined): number | null { + if (typeof iso !== 'string' || iso === '') return null; + const ms = Date.parse(iso.endsWith('Z') ? iso : `${iso}Z`); + return Number.isFinite(ms) ? ms : null; +} + +/** Run `task` over `items`, at most `limit` in flight, preserving order. */ +async function mapWithConcurrency( + items: T[], + limit: number, + task: (item: T) => Promise +): Promise[]> { + const results: PromiseSettledResult[] = new Array(items.length); + let cursor = 0; + + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const index = cursor++; + if (index >= items.length) return; + results[index] = await Promise.allSettled([task(items[index])]).then((r) => r[0]); + } + }); + + await Promise.all(workers); + return results; +} + +/** + * Hourly priority-fee burn, rebuilt from cumulative rollups. + * + * The upstream serves a pre-aggregated daily chart, but it stopped advancing on + * 2026-07-11 and answers 200 with a frozen payload, so it cannot be the source + * for a live view. Its `stats` rollup is computed on the fly and stays current; + * asking it for every lookback from one hour to the window edge and differencing + * neighbouring answers reconstructs the series it no longer publishes. + */ +export class PriorityFeesService { + private static instance: PriorityFeesService; + + private analyticsClient = HypeDexerAnalyticsIndexerClient.getInstance(); + + private constructor() {} + + public static getInstance(): PriorityFeesService { + if (!PriorityFeesService.instance) { + PriorityFeesService.instance = new PriorityFeesService(); + } + return PriorityFeesService.instance; + } + + public async getSeries(window: PriorityFeesWindow): Promise { + const cacheKey = `${SERIES_CACHE_PREFIX}${window}`; + const cached = await redisService.get(cacheKey); + if (cached) return JSON.parse(cached) as PriorityFeesSeries; + + const series = await this.buildSeries(window); + await redisService.set(cacheKey, JSON.stringify(series), SERIES_CACHE_TTL_SECONDS[window]); + return series; + } + + private async buildSeries(window: PriorityFeesWindow): Promise { + const ladder = bucketLadder(window); + + const [rollupResults, fillsStats, hypeUsd] = await Promise.all([ + mapWithConcurrency(ladder, FANOUT_CONCURRENCY, (hours) => this.fetchRollup(hours)), + this.fetchFillsStats(WINDOW_HOURS[window]), + this.readHypeUsd(), + ]); + + const rollups: CumulativeRollup[] = []; + let missingBuckets = 0; + for (const result of rollupResults) { + if (result.status === 'fulfilled' && result.value !== null) rollups.push(result.value); + else missingBuckets++; + } + + if (rollups.length === 0) { + throw new Error('No priority-fee rollup could be fetched'); + } + + if (missingBuckets > 0) { + logDeduplicator.warn('PriorityFeesService: some rollups failed', { window, missingBuckets }); + } + + // The widest rollup covers the whole window, so it is the only answer that + // can carry the aggregates differencing destroys. + const widest = rollups.reduce((a, b) => (b.hours > a.hours ? b : a)); + + return { + window, + bucketSeconds: BUCKET_SECONDS[window], + buckets: computeBuckets(rollups), + totals: this.buildTotals(widest, fillsStats), + meta: { + hypeUsd, + generatedAt: Date.now(), + maxWindowHours: UPSTREAM_MAX_WINDOW_HOURS, + missingBuckets, + }, + }; + } + + private buildTotals( + widest: CumulativeRollup & { raw?: PriorityStatsPayload }, + fillsStats: FillsStatsPayload | null + ): PriorityFeesTotals { + const raw = widest.raw ?? {}; + return { + gas: widest.gas, + fills: widest.fills, + uniqueUsers: finite(raw.unique_users) ?? 0, + avgGas: finite(raw.avg_priority_gas) ?? 0, + minGas: finite(raw.min_priority_gas) ?? 0, + maxGas: finite(raw.max_priority_gas) ?? 0, + allFills: fillsStats ? finite(fillsStats.total_fills) : null, + allUsers: fillsStats ? finite(fillsStats.unique_users) : null, + }; + } + + private async fetchRollup( + hours: number + ): Promise<(CumulativeRollup & { raw: PriorityStatsPayload }) | null> { + const payload = (await this.analyticsClient.getPriorityFeesStats({ + hours, + })) as PriorityStatsPayload | null; + if (!payload || typeof payload !== 'object') return null; + + const gas = finite(payload.total_priority_gas); + const fills = finite(payload.total_fills_with_priority); + const startMs = parseUpstreamMs(payload.time_range?.start); + const endMs = parseUpstreamMs(payload.time_range?.end); + if (gas === null || fills === null || startMs === null || endMs === null) return null; + + return { hours, startMs, endMs, gas, fills, raw: payload }; + } + + /** + * Venue-wide fills over the same window, so the client can say what share of + * activity pays for priority instead of just how much priority costs. + */ + private async fetchFillsStats(hours: number): Promise { + try { + const payload = (await this.analyticsClient.getFillsStats({ hours })) as FillsStatsPayload; + return payload && typeof payload === 'object' ? payload : null; + } catch (error) { + logDeduplicator.warn('PriorityFeesService: fills stats fetch failed', { + hours, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + private async readHypeUsd(): Promise { + try { + const raw = await redisService.get(PERP_MARKETS_CACHE_KEY); + if (!raw) return null; + const markets = JSON.parse(raw) as PerpMarketLite[]; + const hype = markets.find((m) => m?.name === 'HYPE'); + const px = hype ? Number(hype.price) : NaN; + return Number.isFinite(px) && px > 0 ? px : null; + } catch (error) { + logDeduplicator.warn('PriorityFeesService: failed to read HYPE price', { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } +} diff --git a/src/types/priorityFees.types.ts b/src/types/priorityFees.types.ts new file mode 100644 index 0000000..3fd8356 --- /dev/null +++ b/src/types/priorityFees.types.ts @@ -0,0 +1,81 @@ +/** + * Priority-fee series types — an hourly view of the HyperCore **order priority** + * burn, reconstructed from cumulative rollups. + * + * Hyperliquid runs two priority mechanisms, both on HyperCore and both burning + * HYPE: + * - order priority (write): up to 8 bps of notional, charged from undelegated + * staking balance on filled notional (IOC) or resting notional (ALO), and + * deducted whether or not the order fills. That is what this series counts. + * - gossip priority (read): two Dutch auctions on a three-minute cycle selling + * faster market-data reads, charged from spot balance, each auction + * resetting at 10x its last winning bid with a 0.1 HYPE floor. Served by + * `/hip3/priority-fees/gossip/*`, whose feed has been frozen since + * 2026-07-11, so it is absent here. + * + * HyperEVM priority fees are a third, unrelated stream and are not counted + * either. + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/priority-fees + */ + +export type PriorityFeesWindow = '24h' | '7d'; + +export interface PriorityFeesBucket { + /** Bucket start, unix milliseconds (UTC). */ + start: number; + /** Bucket end, unix milliseconds (UTC). */ + end: number; + /** HYPE burned inside the bucket. */ + gas: number; + /** Fills that paid priority inside the bucket. */ + fills: number; +} + +/** + * Window-wide aggregates. + * + * These come from a single rollup rather than from summing buckets: `avgGas`, + * `minGas`, `maxGas` and `uniqueUsers` are not additive, so differencing two + * cumulative windows cannot produce them. + */ +export interface PriorityFeesTotals { + gas: number; + fills: number; + uniqueUsers: number; + avgGas: number; + minGas: number; + maxGas: number; + /** Every fill on the venue in the same window, priority-paying or not. */ + allFills: number | null; + /** Every trader on the venue in the same window. */ + allUsers: number | null; +} + +export interface PriorityFeesSeries { + window: PriorityFeesWindow; + /** Nominal width of one bucket, in seconds. */ + bucketSeconds: number; + /** Chronological, oldest first. */ + buckets: PriorityFeesBucket[]; + totals: PriorityFeesTotals; + meta: { + hypeUsd: number | null; + generatedAt: number; + /** Widest lookback the upstream rollup accepts, in hours. */ + maxWindowHours: number; + /** Buckets the upstream failed to answer for and that were left out. */ + missingBuckets: number; + }; +} + +export class PriorityFeesError extends Error { + constructor( + message: string, + public statusCode: number = 500, + public code: string = 'PRIORITY_FEES_ERROR' + ) { + super(message); + this.name = 'PriorityFeesError'; + } +} diff --git a/tests/integration/routes/priorityFees.routes.test.ts b/tests/integration/routes/priorityFees.routes.test.ts new file mode 100644 index 0000000..842debf --- /dev/null +++ b/tests/integration/routes/priorityFees.routes.test.ts @@ -0,0 +1,153 @@ +/** + * GET /market/priority-fees/series — window validation and the fan-out that + * rebuilds a series out of cumulative rollups. The upstream client is mocked so + * the suite never reaches HypeDexer. + */ +import type { NextFunction, Request, Response } from 'express'; +import express from 'express'; +import request from 'supertest'; + +jest.mock('../../../src/middleware/apiRateLimiter', () => ({ + marketRateLimiter: (_req: Request, _res: Response, next: NextFunction) => { + next(); + }, + passthroughRateLimiter: (_req: Request, _res: Response, next: NextFunction) => { + next(); + }, +})); + +const redisStore = new Map(); +jest.mock('../../../src/core/redis.service', () => ({ + redisService: { + get: jest.fn(async (key: string) => redisStore.get(key) ?? null), + set: jest.fn(async (key: string, value: string) => { + redisStore.set(key, value); + }), + getClient: jest.fn(), + }, +})); + +const NOW = Date.UTC(2026, 6, 27, 16, 0, 0); +const HOUR_MS = 3_600_000; + +/** Cumulative gas is 10 HYPE an hour, so every differenced bucket must be 10. */ +const getPriorityFeesStats = jest.fn(async ({ hours }: { hours: number }) => ({ + total_priority_gas: hours * 10, + total_fills_with_priority: hours * 100, + avg_priority_gas: 0.1, + min_priority_gas: 0.000001, + max_priority_gas: 7.5, + unique_users: 225, + time_range: { + start: new Date(NOW - hours * HOUR_MS).toISOString(), + end: new Date(NOW).toISOString(), + }, +})); + +const getFillsStats = jest.fn(async () => ({ total_fills: 9_000_000, unique_users: 65_000 })); + +jest.mock('../../../src/clients/hypedexer/rest/analytics/analytics-indexer.client', () => ({ + HypeDexerAnalyticsIndexerClient: { + getInstance: () => ({ getPriorityFeesStats, getFillsStats }), + }, +})); + +import priorityFeesRoutes from '../../../src/routes/priorityFees/priorityFees.routes'; + +function buildApp() { + const app = express(); + app.use('/market/priority-fees', priorityFeesRoutes); + return app; +} + +describe('GET /market/priority-fees/series', () => { + beforeEach(() => { + redisStore.clear(); + getPriorityFeesStats.mockClear(); + getFillsStats.mockClear(); + }); + + it('rejects a window the upstream cannot serve', async () => { + const res = await request(buildApp()).get('/market/priority-fees/series?window=30d'); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('INVALID_WINDOW'); + }); + + it('defaults to the last day', async () => { + const res = await request(buildApp()).get('/market/priority-fees/series'); + + expect(res.status).toBe(200); + expect(res.body.data.window).toBe('24h'); + }); + + it('returns one hourly bucket per hour of the day', async () => { + const res = await request(buildApp()).get('/market/priority-fees/series?window=24h'); + + expect(res.status).toBe(200); + const { buckets, bucketSeconds, meta } = res.body.data; + expect(bucketSeconds).toBe(3600); + expect(buckets).toHaveLength(24); + expect(meta.missingBuckets).toBe(0); + buckets.forEach((b: { gas: number; fills: number }) => { + expect(b.gas).toBeCloseTo(10, 8); + expect(b.fills).toBe(100); + }); + }); + + it('carries the aggregates differencing cannot produce, plus the venue denominators', async () => { + const res = await request(buildApp()).get('/market/priority-fees/series?window=24h'); + + const { totals } = res.body.data; + // Straight from the widest rollup, never summed across buckets. + expect(totals.uniqueUsers).toBe(225); + expect(totals.maxGas).toBe(7.5); + expect(totals.gas).toBeCloseTo(240, 8); + expect(totals.allFills).toBe(9_000_000); + expect(totals.allUsers).toBe(65_000); + }); + + it('steps the week in six-hour buckets and stops at the upstream ceiling', async () => { + const res = await request(buildApp()).get('/market/priority-fees/series?window=7d'); + + const { buckets, bucketSeconds, meta } = res.body.data; + expect(bucketSeconds).toBe(21_600); + expect(buckets).toHaveLength(28); + expect(meta.maxWindowHours).toBe(168); + const requested = getPriorityFeesStats.mock.calls.map((c) => c[0].hours); + expect(Math.max(...requested)).toBe(168); + }); + + it('serves the second request from cache instead of fanning out again', async () => { + const app = buildApp(); + await request(app).get('/market/priority-fees/series?window=24h'); + const afterFirst = getPriorityFeesStats.mock.calls.length; + + await request(app).get('/market/priority-fees/series?window=24h'); + expect(getPriorityFeesStats.mock.calls.length).toBe(afterFirst); + }); + + it('still answers when some rollups fail, and says how many were lost', async () => { + getPriorityFeesStats.mockImplementationOnce(async () => { + throw new Error('upstream 502'); + }); + + const res = await request(buildApp()).get('/market/priority-fees/series?window=24h'); + + expect(res.status).toBe(200); + expect(res.body.data.meta.missingBuckets).toBe(1); + expect(res.body.data.buckets.length).toBe(23); + }); + + it('fails with an upstream error when nothing can be fetched', async () => { + getPriorityFeesStats.mockImplementation(async () => { + throw new Error('upstream down'); + }); + + const res = await request(buildApp()).get('/market/priority-fees/series?window=24h'); + + expect(res.status).toBe(502); + expect(res.body.error.code).toBe('PRIORITY_FEES_SERIES_ERROR'); + }); +}); diff --git a/tests/unit/services/priorityFees.series.test.ts b/tests/unit/services/priorityFees.series.test.ts new file mode 100644 index 0000000..f944d77 --- /dev/null +++ b/tests/unit/services/priorityFees.series.test.ts @@ -0,0 +1,124 @@ +import { + BUCKET_SECONDS, + CumulativeRollup, + UPSTREAM_MAX_WINDOW_HOURS, + bucketLadder, + computeBuckets, +} from '../../../src/services/priorityFees/priorityFees.series'; + +const HOUR_MS = 3_600_000; +const NOW = Date.UTC(2026, 6, 27, 16, 0, 0); + +/** + * Build the rollup the upstream would answer for `hours`, given a cumulative + * total. Every rollup ends now and starts `hours` earlier, which is the whole + * property the differencing relies on. + */ +function rollup(hours: number, gas: number, fills: number): CumulativeRollup { + return { hours, startMs: NOW - hours * HOUR_MS, endMs: NOW, gas, fills }; +} + +describe('bucketLadder', () => { + it('asks for every hour of the last day', () => { + const ladder = bucketLadder('24h'); + expect(ladder).toHaveLength(24); + expect(ladder[0]).toBe(1); + expect(ladder[ladder.length - 1]).toBe(24); + }); + + it('steps the week in six-hour strides and stops at the upstream ceiling', () => { + const ladder = bucketLadder('7d'); + expect(ladder[0]).toBe(6); + expect(ladder[ladder.length - 1]).toBe(UPSTREAM_MAX_WINDOW_HOURS); + expect(ladder).toHaveLength(28); + expect(Math.max(...ladder)).toBeLessThanOrEqual(UPSTREAM_MAX_WINDOW_HOURS); + }); + + it('covers the window exactly, with no overlap between strides', () => { + for (const window of ['24h', '7d'] as const) { + const ladder = bucketLadder(window); + const stride = BUCKET_SECONDS[window] / 3600; + ladder.forEach((hours, i) => expect(hours).toBe(stride * (i + 1))); + } + }); +}); + +describe('computeBuckets', () => { + it('differences neighbouring rollups into one bucket each', () => { + // 10 HYPE in the last hour, 25 cumulative over two, 40 over three. + const buckets = computeBuckets([rollup(1, 10, 100), rollup(2, 25, 260), rollup(3, 40, 400)]); + + expect(buckets).toHaveLength(3); + expect(buckets.map((b) => b.gas)).toEqual([15, 15, 10]); + expect(buckets.map((b) => b.fills)).toEqual([140, 160, 100]); + }); + + it('returns buckets oldest first', () => { + const buckets = computeBuckets([rollup(3, 40, 400), rollup(1, 10, 100), rollup(2, 25, 260)]); + const starts = buckets.map((b) => b.start); + expect(starts).toEqual([...starts].sort((a, b) => a - b)); + }); + + it('spans each bucket over the slice it really covers', () => { + const buckets = computeBuckets([rollup(1, 10, 100), rollup(2, 25, 260)]); + const newest = buckets[buckets.length - 1]; + expect(newest.end - newest.start).toBe(HOUR_MS); + expect(newest.end).toBe(NOW); + }); + + it('widens the next bucket when a rollup is missing rather than shifting the rest', () => { + // The 2 h call failed, so the 3 h answer covers two hours on its own. + const buckets = computeBuckets([rollup(1, 10, 100), rollup(3, 40, 400)]); + + expect(buckets).toHaveLength(2); + const older = buckets[0]; + expect(older.end - older.start).toBe(2 * HOUR_MS); + expect(older.gas).toBe(30); + // The hour that did answer keeps its own boundaries. + expect(buckets[1].end - buckets[1].start).toBe(HOUR_MS); + }); + + it('reads a near-empty slice as zero, not as a refund', () => { + // Consecutive calls end seconds apart, so a quiet hour can difference negative. + const buckets = computeBuckets([rollup(1, 10.0001, 100), rollup(2, 10.0, 99)]); + const oldest = buckets[0]; + expect(oldest.gas).toBe(0); + expect(oldest.fills).toBe(0); + }); + + it('keeps the total across buckets equal to the widest rollup', () => { + const rollups = [rollup(1, 10, 100), rollup(2, 25, 260), rollup(3, 40, 400), rollup(4, 44, 430)]; + const buckets = computeBuckets(rollups); + + const summed = buckets.reduce((acc, b) => acc + b.gas, 0); + expect(summed).toBeCloseTo(44, 8); + expect(buckets.reduce((acc, b) => acc + b.fills, 0)).toBe(430); + }); + + it('ignores a duplicated lookback instead of emitting an empty bucket', () => { + const buckets = computeBuckets([rollup(1, 10, 100), rollup(1, 10, 100), rollup(2, 25, 260)]); + expect(buckets).toHaveLength(2); + }); + + it('drops malformed rollups', () => { + const buckets = computeBuckets([ + rollup(1, 10, 100), + { hours: 2, startMs: NaN, endMs: NOW, gas: 25, fills: 260 }, + { hours: 3, startMs: NOW - 3 * HOUR_MS, endMs: NOW, gas: Number.NaN, fills: 400 }, + rollup(4, 44, 430), + ]); + + expect(buckets).toHaveLength(2); + expect(buckets.reduce((acc, b) => acc + b.gas, 0)).toBe(44); + }); + + it('returns nothing when no rollup is usable', () => { + expect(computeBuckets([])).toEqual([]); + expect(computeBuckets([{ hours: 0, startMs: NOW, endMs: NOW, gas: 1, fills: 1 }])).toEqual([]); + }); + + it('never emits a bucket that ends before it starts', () => { + const buckets = computeBuckets([rollup(1, 10, 100), rollup(2, 25, 260), rollup(3, 40, 400)]); + buckets.forEach((b) => expect(b.end).toBeGreaterThan(b.start)); + }); +});