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
19 changes: 18 additions & 1 deletion src/clients/hypedexer/rest/analytics/analytics-indexer.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,24 @@ export class HypeDexerAnalyticsIndexerClient extends HypeDexerBaseClient {
}

/**
* Daily HyperEVM priority-fees burn chart.
* Daily HyperCore **order priority** burn chart — not HyperEVM, and not the
* whole of priority fees.
*
* Hyperliquid runs two priority mechanisms, both on HyperCore and both
* burning HYPE. This endpoint counts only the first:
* - order priority (write): up to 8 bps of notional, charged from
* undelegated staking balance on filled notional (IOC) or resting
* notional (ALO), deducted whether or not the order fills;
* - gossip priority (read): two Dutch auctions on a three-minute cycle for
* faster market-data reads, charged from spot balance, each auction
* resetting at 10x its last winning bid with a 0.1 HYPE floor.
* Served separately by `/hip3/priority-fees/gossip/*`.
*
* HyperEVM priority fees are a third, unrelated stream and are not here
* either. Anything consuming this as "priority fees" is understating them.
*
* @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/priority-fees
*
* Server caps the window around ~42 days regardless of the query.
* Returns `{ data: [{ date, fills, fillsWithFee, totalGas, uniqueUsers }] }`
* where `totalGas` is the daily HYPE amount burned.
Expand Down
133 changes: 133 additions & 0 deletions src/services/revenue/revenue.daily.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Turning Hypurrscan's cumulative fee counter into daily revenue.
*
* Kept apart from RevenueService so it stays a pure function of its input: no
* singleton, no Redis, no upstream clients, and therefore directly testable.
*/
import { FeeData } from '../../types/fees.types';

export const MICRO_USD_DIVISOR = 1_000_000;
export const SPOT_DEPLOYER_MULTIPLIER = 2;

export const SECONDS_PER_DAY = 86_400;

/**
* Widest gap between two cumulative fee points we will interpolate a UTC
* midnight across.
*
* The upstream series ticks every 24 h (median 24.00 h, p95 24.17 h) but has
* gone dark for as long as sixteen days. Across such a gap the counter still
* advanced, so the fees are real and only their day-by-day shape is unknown.
* Spreading them linearly keeps the lifetime total exact and draws a flat
* plateau that reads as the outage it was; the alternatives both lie, either
* dumping sixteen days of fees onto one record-breaking day or claiming the
* protocol earned nothing. Past a month the flat-rate assumption stops meaning
* anything, so that is where we stop and leave the days out.
*/
export const MAX_INTERPOLATION_GAP_SECONDS = 30 * 24 * 3600;

/** Daily perp/spot buckets plus the newest UTC day they actually cover. */
export interface PerpSpotSeries {
daily: Map<string, { perp: number; spot: number }>;
coverageThrough: string | null;
}

/** Format a Date as `YYYY-MM-DD` in UTC. */
export function utcDateKey(d: Date): string {
return d.toISOString().slice(0, 10);
}

/** Convert a unix seconds timestamp to its UTC date key. */
export function secondsToDateKey(seconds: number): string {
return utcDateKey(new Date(seconds * 1000));
}

/**
* Compute daily perp & spot from the cumulative `/fees` series.
*
* Hypurrscan publishes one cumulative point per ~day, near 23:50 UTC. Diffing
* consecutive points measures the interval between those two timestamps, not a
* calendar day, so any point that lands early turns the day it is filed under
* into a partial one. That is invisible in the middle of the series but fatal
* at its tail: a snapshot whose newest point is intraday emits a day worth an
* hour of fees, which reads as a collapse rather than as missing data.
*
* So we interpolate the counters onto UTC midnight and diff midnight to
* midnight. Every emitted day is then a true UTC day, the running day is never
* emitted (its closing boundary is in the future), an outage is spread over the
* days it spans instead of landing on one, and boundaries inside a gap wider
* than MAX_INTERPOLATION_GAP_SECONDS are skipped rather than guessed.
* `coverageThrough` is the newest day that survived, which the caller uses to
* stop the breakdown where its dominant source stops.
*
* spot is multiplied by SPOT_DEPLOYER_MULTIPLIER to reflect gross-user fees
* (Hypurrscan stores the protocol share only — the deployer takes the other
* half on HIP-1 spot pairs).
*/
export function computePerpSpotDaily(fees: FeeData[]): PerpSpotSeries {
const points = fees
.filter(
(p) =>
Number.isFinite(p.time) &&
Number.isFinite(p.total_fees) &&
Number.isFinite(p.total_spot_fees)
)
.sort((a, b) => a.time - b.time);

const daily = new Map<string, { perp: number; spot: number }>();
if (points.length < 2) return { daily, coverageThrough: null };

const first = points[0].time;
const last = points[points.length - 1].time;
const firstBoundary = Math.ceil(first / SECONDS_PER_DAY) * SECONDS_PER_DAY;
const lastBoundary = Math.floor(last / SECONDS_PER_DAY) * SECONDS_PER_DAY;
if (lastBoundary <= firstBoundary) return { daily, coverageThrough: null };

// Read the counters at every UTC midnight the series brackets. `null` marks a
// boundary sitting inside a gap too wide to interpolate.
const atBoundary = new Map<number, { total: number; spot: number } | null>();
let i = 1;
for (let t = firstBoundary; t <= lastBoundary; t += SECONDS_PER_DAY) {
while (i < points.length && points[i].time < t) i++;
const prev = points[i - 1];
const next = points[i];
const span = next ? next.time - prev.time : Infinity;
if (!next || span <= 0 || span > MAX_INTERPOLATION_GAP_SECONDS) {
atBoundary.set(t, null);
continue;
}
const fraction = (t - prev.time) / span;
atBoundary.set(t, {
total: prev.total_fees + (next.total_fees - prev.total_fees) * fraction,
spot: prev.total_spot_fees + (next.total_spot_fees - prev.total_spot_fees) * fraction,
});
}

let coverageThrough: string | null = null;
for (let t = firstBoundary; t < lastBoundary; t += SECONDS_PER_DAY) {
const open = atBoundary.get(t);
const close = atBoundary.get(t + SECONDS_PER_DAY);
if (!open || !close) continue;

const totalDelta = (close.total - open.total) / MICRO_USD_DIVISOR;
const spotProtocolDelta = (close.spot - open.spot) / MICRO_USD_DIVISOR;
const date = secondsToDateKey(t);

daily.set(date, {
perp: Math.max(0, totalDelta - spotProtocolDelta),
spot: Math.max(0, spotProtocolDelta * SPOT_DEPLOYER_MULTIPLIER),
});
coverageThrough = date;
}

return { daily, coverageThrough };
}

/** Newest key holding a strictly positive value, or null if there is none. */
export function lastPopulatedDate(daily: Map<string, number>): string | null {
let latest: string | null = null;
for (const [date, value] of daily) {
if (value > 0 && (latest === null || date > latest)) latest = date;
}
return latest;
}
104 changes: 37 additions & 67 deletions src/services/revenue/revenue.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
RevenueSourceStatus,
RevenueWindow,
} from '../../types/revenue.types';
import { FeeData } from '../../types/fees.types';
import { AuctionInfo } from '../../types/auction.types';
import { redisService } from '../../core/redis.service';
import { logDeduplicator } from '../../utils/logDeduplicator';
Expand All @@ -16,9 +15,14 @@ import { HypurrscanClient } from '../../clients/hypurrscan/auction.client';
import { HypeDexerHip3Client } from '../../clients/hypedexer/rest/hip3/hip3.client';
import { HypeDexerHip4Client } from '../../clients/hypedexer/rest/hip4/hip4.client';
import { HypeDexerAnalyticsIndexerClient } from '../../clients/hypedexer/rest/analytics/analytics-indexer.client';
import {
SECONDS_PER_DAY,
SPOT_DEPLOYER_MULTIPLIER,
computePerpSpotDaily,
lastPopulatedDate,
utcDateKey,
} from './revenue.daily';

const MICRO_USD_DIVISOR = 1_000_000;
const SPOT_DEPLOYER_MULTIPLIER = 2;

const HIP3_CACHE_KEY = 'revenue:hip3:auctions';
const HIP3_CACHE_TTL_SECONDS = 30 * 60;
Expand Down Expand Up @@ -52,16 +56,6 @@ const WINDOW_DAYS: Record<Exclude<RevenueWindow, 'all'>, number> = {
'1y': 365,
};

/** Format a Date as `YYYY-MM-DD` in UTC. */
function utcDateKey(d: Date): string {
return d.toISOString().slice(0, 10);
}

/** Convert a unix seconds timestamp to its UTC date key. */
function secondsToDateKey(seconds: number): string {
return utcDateKey(new Date(seconds * 1000));
}

/** Convert a unix milliseconds timestamp to its UTC date key. */
function msToDateKey(ms: number): string {
return utcDateKey(new Date(ms));
Expand Down Expand Up @@ -138,7 +132,8 @@ export class RevenueService {
throw new RevenueError('No fees historical data available', 503, 'REVENUE_NO_DATA');
}

const perpSpotDaily = this.computePerpSpotDaily(fees);
const { daily: perpSpotDaily, coverageThrough: perpSpotThrough } =
computePerpSpotDaily(fees);
const hip1Daily = this.bucketHip1ByDay(auctions);
const hip3Daily = this.bucketHip3ByDay(hip3Rows, hypeUsd);
const hip4Daily = this.bucketHip4ByDay(hip4Rows);
Expand All @@ -151,7 +146,12 @@ export class RevenueService {
...hip4Daily.keys(),
...priorityDaily.keys(),
]);
const sortedDates = Array.from(allDates).sort();
// Perp and spot are ~97% of the book. An auction or a HIP-4 bucket landing
// on a day perp/spot does not cover would render as a near-empty bar rather
// than as the absent day it is, so the breakdown ends where perp/spot ends.
const sortedDates = Array.from(allDates)
.filter((date) => perpSpotThrough === null || date <= perpSpotThrough)
.sort();

const days: RevenueDay[] = sortedDates.map((date) => {
const ps = perpSpotDaily.get(date) ?? { perp: 0, spot: 0 };
Expand All @@ -166,78 +166,39 @@ export class RevenueService {
const lifetime = this.computeLifetime(days);
const windowed = this.sliceByWindow(days, window);

// perp/spot come from diffing a cumulative series. If that series stops
// advancing, every day past its last point gets a real-looking perp=0,
// spot=0 from the `?? { perp: 0, spot: 0 }` fallback above. Detect that by
// comparing the last date perp/spot actually covers against the newest
// date in the breakdown (auctions/HIP-3/HIP-4 keep advancing), and report
// it as stale so the client can warn instead of trusting a zeroed total.
const perpSpotDates = Array.from(perpSpotDaily.keys()).sort();
const lastPerpSpotDate = perpSpotDates[perpSpotDates.length - 1] ?? null;
const latestDate = sortedDates[sortedDates.length - 1] ?? null;
const perpSpotStale =
lastPerpSpotDate !== null && latestDate !== null && lastPerpSpotDate < latestDate;
// A daily feed that stops advancing is indistinguishable from a quiet one
// once it is bucketed: both come out as zeros. The only tell is the newest
// day each source actually populates. The running UTC day never counts —
// no source can have closed it yet — so the bar is yesterday.
const expectedThrough = utcDateKey(new Date(Date.now() - SECONDS_PER_DAY * 1000));
const priorityThrough = lastPopulatedDate(priorityDaily);
const isBehind = (through: string | null): boolean =>
through === null || through < expectedThrough;

const coverage = { perpSpot: perpSpotThrough, priority: priorityThrough };

const meta: RevenueMeta = {
spotMultiplier: SPOT_DEPLOYER_MULTIPLIER,
hypeUsd,
lastUpdate: Date.now(),
coverage,
sourceStatus: {
perpSpot:
feesResult.status !== 'fulfilled' || fees.length === 0
? 'error'
: perpSpotStale
: isBehind(perpSpotThrough)
? 'stale'
: 'ok',
hip1: auctionsResult.status === 'fulfilled' ? 'ok' : 'error',
hip3: this.hip3Status(hip3Result, hypeUsd),
hip4: hip4Result.status === 'fulfilled' ? 'ok' : 'error',
priority: this.priorityStatus(priorityResult, hypeUsd),
priority: this.priorityStatus(priorityResult, hypeUsd, priorityThrough, expectedThrough),
},
};

return { window, days: windowed, lifetime, meta };
}

/**
* Compute daily perp & spot from the cumulative `/fees` series.
*
* Hypurrscan returns one cumulative point per ~day. We snap to UTC days by
* keeping the LAST point of each day (closest to end-of-day cumulative
* total), then diff consecutive days.
*
* spot is multiplied by SPOT_DEPLOYER_MULTIPLIER to reflect gross-user fees
* (Hypurrscan stores the protocol share only — the deployer takes the other
* half on HIP-1 spot pairs).
*/
private computePerpSpotDaily(fees: FeeData[]): Map<string, { perp: number; spot: number }> {
const lastPerDay = new Map<string, FeeData>();
for (const point of fees) {
const key = secondsToDateKey(point.time);
const prev = lastPerDay.get(key);
if (!prev || point.time > prev.time) {
lastPerDay.set(key, point);
}
}

const sortedDays = Array.from(lastPerDay.entries()).sort(([a], [b]) => a.localeCompare(b));
const out = new Map<string, { perp: number; spot: number }>();

for (let i = 1; i < sortedDays.length; i++) {
const [date, curr] = sortedDays[i];
const [, prev] = sortedDays[i - 1];

const totalDelta = (curr.total_fees - prev.total_fees) / MICRO_USD_DIVISOR;
const spotProtocolDelta = (curr.total_spot_fees - prev.total_spot_fees) / MICRO_USD_DIVISOR;
const perp = Math.max(0, totalDelta - spotProtocolDelta);
const spot = Math.max(0, spotProtocolDelta * SPOT_DEPLOYER_MULTIPLIER);

out.set(date, { perp, spot });
}

return out;
}

/**
* Bucket /pastAuctions by UTC day. `deployGas` is the USDC paid by the
* auction winner (stored negative — debit on the deployer side). We take
Expand Down Expand Up @@ -434,12 +395,21 @@ export class RevenueService {
return rows;
}

/**
* The upstream chart answers 200 with a frozen payload when its aggregation
* job dies, so settling successfully proves nothing — the priority line read
* `ok` through sixteen consecutive days of silent zeros. Judge it on the
* newest day it populates instead.
*/
private priorityStatus(
priorityResult: PromiseSettledResult<PriorityFeeRow[]>,
hypeUsd: number | null,
priorityThrough: string | null,
expectedThrough: string,
): RevenueSourceStatus {
if (priorityResult.status !== 'fulfilled') return 'error';
if (hypeUsd === null) return 'stale';
if (priorityThrough === null || priorityThrough < expectedThrough) return 'stale';
return 'ok';
}
}
28 changes: 25 additions & 3 deletions src/types/revenue.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,19 @@
* - hip1: Spot listing auction proceeds (USDC). Sum of |deployGas| from /pastAuctions.
* - hip3: Perp DEX auction proceeds (HYPE × USD). Closed auctions × HYPE price.
* - hip4: Prediction-market fees. 0 until mainnet launch.
* - priority: HyperEVM priority fees (HYPE × USD). 100% burned on-chain — counts as
* protocol-captured value (EIP-1559-style mechanism). HypeDexer caps history
* to ~42 days, older days fall back to 0.
* - priority: HyperCore ORDER priority fees (HYPE × USD) — not HyperEVM, and not all of
* priority. Up to 8 bps of notional charged from undelegated staking balance
* on filled notional (IOC) or resting notional (ALO), burned whether or not
* the order fills. 100% burned, so it counts as protocol-captured value.
* HypeDexer caps history to ~42 days, older days fall back to 0.
*
* NOT included: gossip priority, the second HyperCore burn — two Dutch
* auctions on a three-minute cycle selling faster market-data reads, charged
* from spot balance, each resetting at 10x its last winning bid with a
* 0.1 HYPE floor. It is served by /hip3/priority-fees/gossip/* whose feed has
* been frozen since 2026-07-11, so it cannot be added until that is fixed.
* Also not included: HyperEVM priority fees, a third and unrelated stream.
* @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/priority-fees
*/

export type RevenueWindow = '7d' | '30d' | '90d' | '1y' | 'all';
Expand Down Expand Up @@ -39,10 +49,22 @@ export interface RevenueLifetime {

export type RevenueSourceStatus = 'ok' | 'stale' | 'error' | 'not_yet_live';

/**
* Newest UTC day each series actually populates. A frozen upstream feed keeps
* answering 200 with a stale payload, which bucketing turns into zeros that are
* indistinguishable from a quiet day, so the client needs the real end of each
* series to stop drawing it there instead of down to the floor.
*/
export interface RevenueCoverage {
perpSpot: string | null;
priority: string | null;
}

export interface RevenueMeta {
spotMultiplier: number;
hypeUsd: number | null;
lastUpdate: number;
coverage: RevenueCoverage;
sourceStatus: {
perpSpot: RevenueSourceStatus;
hip1: RevenueSourceStatus;
Expand Down
Loading
Loading