Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
49 changes: 49 additions & 0 deletions src/routes/priorityFees/priorityFees.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
101 changes: 101 additions & 0 deletions src/services/priorityFees/priorityFees.series.ts
Original file line number Diff line number Diff line change
@@ -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<PriorityFeesWindow, number> = {
'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);
}
Loading
Loading