From cec663a87b68324df44f7b5965745e250341bb4b Mon Sep 17 00:00:00 2001 From: Yaugourt <180565593+Yaugourt@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:03:05 -0400 Subject: [PATCH] feat(priority-fees): rebuild the burn series the upstream stopped publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-aggregated daily priority-fee chart stopped advancing on 2026-07-11 and has answered 200 with a frozen payload every day since, so nothing that reads it can draw a current line. Its `stats` rollup is computed on the fly and is still accurate to the minute, but it only ever reports one cumulative window ending now. Asking that rollup for every lookback across the window and differencing neighbouring answers reconstructs the series: the narrowest window is a bucket on its own, and each wider one contributes the slice it adds. Buckets carry the span they really cover rather than the nominal width, because the upstream occasionally clamps a window short, and inheriting that as a wider bucket is honest where silently shifting every later bucket onto the wrong hour is not. Windows stop at 7d since 168 h is the widest lookback the rollup accepts. Aggregates that differencing destroys — unique payers, avg, min, max — come from the single widest rollup instead of from summing buckets, and venue-wide fill counts ride along so a client can say what share of activity pays for priority rather than only what priority costs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013zGKQKm432ZKEw1GjVWPZG --- src/app.ts | 2 + .../priorityFees/priorityFees.routes.ts | 49 ++++ .../priorityFees/priorityFees.series.ts | 101 ++++++++ .../priorityFees/priorityFees.service.ts | 234 ++++++++++++++++++ src/types/priorityFees.types.ts | 81 ++++++ .../routes/priorityFees.routes.test.ts | 153 ++++++++++++ .../unit/services/priorityFees.series.test.ts | 124 ++++++++++ 7 files changed, 744 insertions(+) create mode 100644 src/routes/priorityFees/priorityFees.routes.ts create mode 100644 src/services/priorityFees/priorityFees.series.ts create mode 100644 src/services/priorityFees/priorityFees.service.ts create mode 100644 src/types/priorityFees.types.ts create mode 100644 tests/integration/routes/priorityFees.routes.test.ts create mode 100644 tests/unit/services/priorityFees.series.test.ts 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)); + }); +});