From a9f1b0b160f2587150ae2206e57fcbd3883fd467 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:13:34 +0200 Subject: [PATCH] feat: add bench 117/118/119 - gains showcase trio (cost slope, mark deviation, protocol longevity) bench 117 perp-cost-slope: derives cost slope from existing perp-fees data, no new harness bench 118 perp-mark-price-lag: new Go harness sampling mark vs Binance ref every 60s for 6 venues x 3 assets bench 119 perp-protocol-longevity: embedded incident registry, 1705 days clean for gains vs 389 for gmx --- benchmarks/perp-cost-slope.yml | 262 ++++++++++++ benchmarks/perp-mark-price-lag.yml | 180 ++++++++ benchmarks/perp-protocol-longevity.yml | 174 ++++++++ harnesses/perp-mark-price-lag/Dockerfile | 15 + .../perp-mark-price-lag/cmd/script/config.go | 38 ++ .../cmd/script/fetchers.go | 404 ++++++++++++++++++ .../perp-mark-price-lag/cmd/script/main.go | 126 ++++++ .../perp-mark-price-lag/cmd/script/metrics.go | 124 ++++++ .../perp-mark-price-lag/cmd/script/types.go | 19 + harnesses/perp-mark-price-lag/go.mod | 5 + harnesses/perp-protocol-longevity/Dockerfile | 15 + .../cmd/script/main.go | 72 ++++ .../cmd/script/metrics.go | 84 ++++ .../cmd/script/registry.go | 108 +++++ harnesses/perp-protocol-longevity/go.mod | 5 + 15 files changed, 1631 insertions(+) create mode 100644 benchmarks/perp-cost-slope.yml create mode 100644 benchmarks/perp-mark-price-lag.yml create mode 100644 benchmarks/perp-protocol-longevity.yml create mode 100644 harnesses/perp-mark-price-lag/Dockerfile create mode 100644 harnesses/perp-mark-price-lag/cmd/script/config.go create mode 100644 harnesses/perp-mark-price-lag/cmd/script/fetchers.go create mode 100644 harnesses/perp-mark-price-lag/cmd/script/main.go create mode 100644 harnesses/perp-mark-price-lag/cmd/script/metrics.go create mode 100644 harnesses/perp-mark-price-lag/cmd/script/types.go create mode 100644 harnesses/perp-mark-price-lag/go.mod create mode 100644 harnesses/perp-protocol-longevity/Dockerfile create mode 100644 harnesses/perp-protocol-longevity/cmd/script/main.go create mode 100644 harnesses/perp-protocol-longevity/cmd/script/metrics.go create mode 100644 harnesses/perp-protocol-longevity/cmd/script/registry.go create mode 100644 harnesses/perp-protocol-longevity/go.mod diff --git a/benchmarks/perp-cost-slope.yml b/benchmarks/perp-cost-slope.yml new file mode 100644 index 00000000..e93bac99 --- /dev/null +++ b/benchmarks/perp-cost-slope.yml @@ -0,0 +1,262 @@ +# OpenChainBench. Bench 117 + +slug: perp-cost-slope +number: "117" +title: "Perp DEX cost invariance: all-in bps at $1M vs $1k, live slope ranked" +seo_title: "Perp DEX cost at $1M vs $1k 2026" +seo_description: "{{best_name}} leads perp cost invariance at {{best_p50}} cost slope (24h avg). Ratio of all-in bps at $1M vs $1k: oracle-priced venues score 1.00, orderbook venues above. Gains, GMX, Hyperliquid, dYdX ranked live." +subtitle: "Ratio of all-in opening cost at $1,000,000 vs $1,000 notional, per venue per asset. A venue with flat cost at every size scores 1.00. A deeper book scores closer to 1.00 than a thin one. Oracle-priced venues (no orderbook to walk) always score 1.00 by construction." +category: Trading +status: live +metric: "Cost slope ($1M / $1k)" +unit: count +higher_is_better: false + +seo_intro: | + Every perp DEX comparison article ranks venues by their published taker + fee rate. That number is size-independent by design: 4.5 bps on + Hyperliquid costs the same at $1,000 and $1,000,000 in taker fee alone. + What changes is the price impact from walking the orderbook. A $1M + market order eats deeper into the book than a $1k order, so the spread + component grows. Venues with no orderbook (oracle-priced: gains.trade, + GMX v2) are immune to this effect because there is no book to walk: the + protocol charges a flat percentage of notional regardless of size. On + those venues, a $1M trade costs exactly the same number of bps as a $1k + trade, giving a cost slope of 1.00. On an orderbook venue the slope + exceeds 1.00 by however much impact the extra notional adds. The wider + the gap above 1.00, the more the venue penalises large orders relative + to small ones. A venue whose visible book cannot absorb $1M at all shows + no slope because the $1M tier is skipped: that absence is a depth signal + stronger than any ratio. This benchmark derives its slope directly from + the existing perp-fees harness, which already measures all-in bps at + $1k, $10k, $100k and $1M per venue per asset every five minutes. + +abstract: | + The cost slope is the ratio of a venue's all-in opening cost at $1M + notional to its all-in cost at $1k notional. Both terms come from + perp_fees_all_in_bps_tier, the multi-tier companion series already + published by the perp-fees harness (bench 007). No new data collection + is required: the bench derives the ratio in PromQL at query time. A + ratio of 1.00 means the venue charges the same basis points per unit of + notional at $1M as at $1k, which is only possible for oracle-priced + venues where the fee is a flat percentage of position size with no + orderbook to walk. For orderbook venues the ratio rises above 1.00 + because deeper book levels carry progressively wider prices. The + companion fill-rate panel shows the fraction of measurement cycles in + which the $1M tier was successfully filled: venues that cannot reliably + absorb $1M show a fill rate below 1.00, which is a stronger signal than + any slope number. Data is refreshed every five minutes from the + perp-fees harness. The chain tabs scope the board to ETH, BTC or SOL + individually so the slope of a thin SOL book is not mixed with a deep + ETH book. + +methodology: + - "Cadence: derived in PromQL from perp_fees_all_in_bps_tier, refreshed every 5 minutes by the perp-fees harness (bench 007)." + - "Cost slope = avg_over_time(all_in_bps_tier{notional=1000000}[24h]) / avg_over_time(all_in_bps_tier{notional=1000}[24h]) per venue per asset. A ratio of 1.00 means flat cost across sizes." + - "Oracle-priced venues (gains.trade, GMX v2): the fee is a flat percentage of position size with no orderbook. The $1M all-in equals the $1k all-in in bps exactly, so slope = 1.00 always." + - "Orderbook venues (Hyperliquid, dYdX, Lighter, Paradex, Extended, Aster, edgeX): the $1M tier walks deeper into the book than the $1k tier, so the spread component grows and slope > 1.00." + - "Missing $1M tier: when the visible book cannot absorb $1M the harness skips the tier and increments perp_fees_tier_skipped_total rather than extrapolating. The slope PromQL returns no data for that venue, shown as N/A in the leaderboard." + - "Fill-rate panel: count_over_time(tier{notional=1000000}[24h]) / count_over_time(tier{notional=1000}[24h]) per venue per asset. 1.00 = tier always filled; 0.50 = tier filled half the time; 0.00 = tier never filled." + - "Chain tabs: the board is scoped per asset (ETH, BTC, SOL) because book depth differs significantly across assets and cross-asset averaging obscures the signal." + - "Failures and stale data: inherited from the perp-fees harness. A venue that errors keeps its last gauge and its health drops to 0." + +per_chain_explainer: + - slug: ETH + h2: "ETH perp cost slope: size impact on the deepest pair" + body: | + ETH-PERP is the deepest pair on every venue in the cohort. On orderbook venues the $1M + tier is usually filled because ETH books carry several million USD of visible depth on the + ask side. The slope is therefore computable for most venues on ETH, and the spread between + the oracle-priced 1.00 and the orderbook venues reveals the raw cost of depth-limited execution. + Gains and GMX always read 1.00 because their protocol charges a flat open fee regardless of + notional; there is no book to walk for ETH on those venues. + - slug: BTC + h2: "BTC perp cost slope: tightest books, flattest slopes" + body: | + BTC carries the highest open interest on most venues and typically has the tightest spread + at any given notional. Orderbook venues therefore show their flattest slopes on BTC: the + $1M impact is minimised by depth. Oracle-priced venues (gains, gmx) remain at exactly 1.00 + regardless. + - slug: SOL + h2: "SOL perp cost slope: where thin books expose the gap" + body: | + SOL books are thinner than ETH or BTC on most venues. The $1M tier is frequently skipped + on smaller venues (shown as N/A), and on the venues that do fill it the slope is noticeably + higher than on ETH. This is the asset where the flat-cost advantage of oracle-priced venues + is most visible: gains and gmx score 1.00 while orderbook venues may show slopes of 1.50 + or higher. + +findings: + - "{{best_name}} leads the cost-slope leaderboard at {{best_p50}} (24h average). A slope of 1.00 means the venue charges the same basis points per unit of notional at $1M as at $1k." + - "{{name:gains}} and {{name:gmx}} are the only venues in the cohort that structurally score 1.00: they use oracle pricing with no orderbook, so the fee is a flat percentage of position size regardless of trade size." + - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} cost slope (24h avg). The deepest onchain orderbook in the cohort keeps its impact low, but it still exceeds 1.00 at $1M notional on most assets." + - "Venues showing N/A in the slope column cannot reliably fill a $1M market order. The fill-rate panel shows what fraction of measurement cycles the $1M tier was successfully absorbed." + - "The cost slope is asset-specific: use the chain tabs to compare ETH (deepest books), BTC (tightest books) and SOL (thinnest books) separately." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/perp-fees + +prometheus: + window: 24h + expected_freshness_seconds: 1800 + freshness_metric: perp_fees_all_in_bps_tier + +faq: + - q: "What does cost slope of 1.00 mean?" + a: "A cost slope of 1.00 means the all-in basis points for a $1M trade are identical to a $1k trade on that venue. This is only possible for oracle-priced venues where the fee is a flat percentage of notional and there is no orderbook impact term." + - q: "Why are gains.trade and GMX always 1.00?" + a: "Both venues use oracle pricing with no orderbook. Their open fee (totalPositionSizeFeeP on Gains, positionFeeFactor on GMX) is a flat percentage of position size. Trading $1M costs the same bps as $1k by construction. This is the core design tradeoff: flat cost at any size, in exchange for using a price oracle rather than a live orderbook." + - q: "What does N/A in the slope column mean?" + a: "The venue's visible book could not absorb $1M of notional during the measurement period. The perp-fees harness skips a tier rather than extrapolate when the book depth is insufficient, so the $1M all-in gauge is absent. No slope can be computed. The fill-rate panel shows what fraction of cycles the $1M tier was successfully filled." + - q: "Is a 1.00 slope always better?" + a: "For a trader placing a $1M order: yes, a flat cost is better than a rising one. For a trader placing a $1k order: the slope is irrelevant because both the taker fee and the spread at small notional dominate regardless. The headline perp-fees bench (bench 007) covers the $1k case; this bench covers the size-scaling dimension." + - q: "Can an orderbook venue ever match 1.00?" + a: "In theory an infinitely deep book would have zero price impact at any size, giving a slope of exactly 1.00. In practice no onchain orderbook reaches that depth for sizes above $100k on SOL or above $1M on ETH or BTC. The fill rate panel shows how often a venue actually absorbs the full notional." + - q: "How does this differ from the perp-fees bench?" + a: "perp-fees (bench 007) ranks venues by absolute all-in cost at $1k notional. This bench ranks the same venues by how much their cost grows between $1k and $1M. A venue cheap at $1k can be expensive at $1M if its book is thin. The two benches together give a full picture: absolute cost at small size plus sensitivity to larger size." + +dimensions: + chain: + - { value: ETH, label: ETH } + - { value: BTC, label: BTC } + - { value: SOL, label: SOL } + +panel_main_label: "Cost slope ($1M / $1k)" +metric_panels: + - id: fill_rate_1m + label: "$1M fill rate" + metric: "avg(count_over_time(perp_fees_all_in_bps_tier{notional=\"1000000\"}[24h]) / count_over_time(perp_fees_all_in_bps_tier{notional=\"1000\"}[24h]))" + label_key: venue + unit: count + higher_is_better: true + description: "Fraction of 5-minute cycles in which the venue's visible book successfully absorbed $1M of buy notional for the selected asset. 1.00 = always filled; oracle-priced venues always fill. A low number means the book is too thin to reliably absorb large orders." + +providers: + - slug: gains + name: gains.trade + tag: "Synthetic perps on Base, oracle-priced, flat cost at any size" + formula: "all_in_bps_tier{notional=1000000}[24h] / all_in_bps_tier{notional=1000}[24h]. Oracle-priced (flat % of notional, no orderbook): ratio always 1.00 by construction." + queries: + p50: avg_over_time(perp_fees_all_in_bps_tier{venue="gains",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="gains",notional="1000"}[24h]) + p90: avg_over_time(perp_fees_all_in_bps_tier{venue="gains",notional="100000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="gains",notional="1000"}[24h]) + p99: avg_over_time(perp_fees_all_in_bps_tier{venue="gains",notional="10000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="gains",notional="1000"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps_tier{venue="gains",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="gains",notional="1000"}[24h]) + success: avg_over_time(perp_fees_health{venue="gains"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps_tier{venue="gains",notional="1000000"}[24h]) + series: perp_fees_all_in_bps_tier{venue="gains",notional="1000000"} + + - slug: gmx + name: GMX v2 + tag: "Synthetics on Arbitrum, oracle-priced, flat cost at any size" + formula: "all_in_bps_tier{notional=1000000}[24h] / all_in_bps_tier{notional=1000}[24h]. Oracle-priced (positionFeeFactor flat % of notional, no orderbook): ratio always 1.00." + queries: + p50: avg_over_time(perp_fees_all_in_bps_tier{venue="gmx",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="gmx",notional="1000"}[24h]) + p90: avg_over_time(perp_fees_all_in_bps_tier{venue="gmx",notional="100000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="gmx",notional="1000"}[24h]) + p99: avg_over_time(perp_fees_all_in_bps_tier{venue="gmx",notional="10000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="gmx",notional="1000"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps_tier{venue="gmx",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="gmx",notional="1000"}[24h]) + success: avg_over_time(perp_fees_health{venue="gmx"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps_tier{venue="gmx",notional="1000000"}[24h]) + series: perp_fees_all_in_bps_tier{venue="gmx",notional="1000000"} + + - slug: hyperliquid + name: Hyperliquid + tag: "HyperBFT L1 perp DEX, deepest onchain book" + formula: "all_in_bps_tier{notional=1000000}[24h] / all_in_bps_tier{notional=1000}[24h]. Orderbook: $1M tier walks deeper than $1k, raising the spread component above 1.00." + queries: + p50: avg_over_time(perp_fees_all_in_bps_tier{venue="hyperliquid",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="hyperliquid",notional="1000"}[24h]) + p90: avg_over_time(perp_fees_all_in_bps_tier{venue="hyperliquid",notional="100000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="hyperliquid",notional="1000"}[24h]) + p99: avg_over_time(perp_fees_all_in_bps_tier{venue="hyperliquid",notional="10000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="hyperliquid",notional="1000"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps_tier{venue="hyperliquid",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="hyperliquid",notional="1000"}[24h]) + success: avg_over_time(perp_fees_health{venue="hyperliquid"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps_tier{venue="hyperliquid",notional="1000000"}[24h]) + series: perp_fees_all_in_bps_tier{venue="hyperliquid",notional="1000000"} + + - slug: lighter + name: Lighter + tag: "zk-rollup, zero taker fee, onchain orderbook" + formula: "all_in_bps_tier{notional=1000000}[24h] / all_in_bps_tier{notional=1000}[24h]. Orderbook: $1M tier walks 100 levels of the Lighter zk-rollup book." + queries: + p50: avg_over_time(perp_fees_all_in_bps_tier{venue="lighter",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="lighter",notional="1000"}[24h]) + p90: avg_over_time(perp_fees_all_in_bps_tier{venue="lighter",notional="100000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="lighter",notional="1000"}[24h]) + p99: avg_over_time(perp_fees_all_in_bps_tier{venue="lighter",notional="10000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="lighter",notional="1000"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps_tier{venue="lighter",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="lighter",notional="1000"}[24h]) + success: avg_over_time(perp_fees_health{venue="lighter"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps_tier{venue="lighter",notional="1000000"}[24h]) + series: perp_fees_all_in_bps_tier{venue="lighter",notional="1000000"} + + - slug: dydx + name: dYdX v4 + tag: "Cosmos appchain orderbook" + formula: "all_in_bps_tier{notional=1000000}[24h] / all_in_bps_tier{notional=1000}[24h]. Orderbook: full dYdX v4 indexer book walked at each tier." + queries: + p50: avg_over_time(perp_fees_all_in_bps_tier{venue="dydx",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="dydx",notional="1000"}[24h]) + p90: avg_over_time(perp_fees_all_in_bps_tier{venue="dydx",notional="100000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="dydx",notional="1000"}[24h]) + p99: avg_over_time(perp_fees_all_in_bps_tier{venue="dydx",notional="10000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="dydx",notional="1000"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps_tier{venue="dydx",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="dydx",notional="1000"}[24h]) + success: avg_over_time(perp_fees_health{venue="dydx"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps_tier{venue="dydx",notional="1000000"}[24h]) + series: perp_fees_all_in_bps_tier{venue="dydx",notional="1000000"} + + - slug: paradex + name: Paradex + tag: "Starknet appchain perps, 100-level book" + formula: "all_in_bps_tier{notional=1000000}[24h] / all_in_bps_tier{notional=1000}[24h]. Orderbook: Starknet appchain, 100-level book depth caps visible depth at ~$1M on majors." + queries: + p50: avg_over_time(perp_fees_all_in_bps_tier{venue="paradex",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="paradex",notional="1000"}[24h]) + p90: avg_over_time(perp_fees_all_in_bps_tier{venue="paradex",notional="100000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="paradex",notional="1000"}[24h]) + p99: avg_over_time(perp_fees_all_in_bps_tier{venue="paradex",notional="10000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="paradex",notional="1000"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps_tier{venue="paradex",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="paradex",notional="1000"}[24h]) + success: avg_over_time(perp_fees_health{venue="paradex"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps_tier{venue="paradex",notional="1000000"}[24h]) + series: perp_fees_all_in_bps_tier{venue="paradex",notional="1000000"} + + - slug: extended + name: Extended + tag: "Starknet perps, full public book" + formula: "all_in_bps_tier{notional=1000000}[24h] / all_in_bps_tier{notional=1000}[24h]. Orderbook: full public Starknet book walked at each tier." + queries: + p50: avg_over_time(perp_fees_all_in_bps_tier{venue="extended",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="extended",notional="1000"}[24h]) + p90: avg_over_time(perp_fees_all_in_bps_tier{venue="extended",notional="100000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="extended",notional="1000"}[24h]) + p99: avg_over_time(perp_fees_all_in_bps_tier{venue="extended",notional="10000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="extended",notional="1000"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps_tier{venue="extended",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="extended",notional="1000"}[24h]) + success: avg_over_time(perp_fees_health{venue="extended"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps_tier{venue="extended",notional="1000000"}[24h]) + series: perp_fees_all_in_bps_tier{venue="extended",notional="1000000"} + + - slug: polymarket + name: Polymarket + tag: "Polygon perps, up to 500 book levels" + formula: "all_in_bps_tier{notional=1000000}[24h] / all_in_bps_tier{notional=1000}[24h]. Orderbook: Polygon CLOB, up to 500 book levels walked per tier." + queries: + p50: avg_over_time(perp_fees_all_in_bps_tier{venue="polymarket",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="polymarket",notional="1000"}[24h]) + p90: avg_over_time(perp_fees_all_in_bps_tier{venue="polymarket",notional="100000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="polymarket",notional="1000"}[24h]) + p99: avg_over_time(perp_fees_all_in_bps_tier{venue="polymarket",notional="10000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="polymarket",notional="1000"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps_tier{venue="polymarket",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="polymarket",notional="1000"}[24h]) + success: avg_over_time(perp_fees_health{venue="polymarket"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps_tier{venue="polymarket",notional="1000000"}[24h]) + series: perp_fees_all_in_bps_tier{venue="polymarket",notional="1000000"} + + - slug: aster + name: Aster + tag: "BNB Chain perp DEX, Binance-style REST book" + formula: "all_in_bps_tier{notional=1000000}[24h] / all_in_bps_tier{notional=1000}[24h]. Orderbook: BNB Chain CLOB, fapi depth endpoint walked per tier." + queries: + p50: avg_over_time(perp_fees_all_in_bps_tier{venue="aster",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="aster",notional="1000"}[24h]) + p90: avg_over_time(perp_fees_all_in_bps_tier{venue="aster",notional="100000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="aster",notional="1000"}[24h]) + p99: avg_over_time(perp_fees_all_in_bps_tier{venue="aster",notional="10000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="aster",notional="1000"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps_tier{venue="aster",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="aster",notional="1000"}[24h]) + success: avg_over_time(perp_fees_health{venue="aster"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps_tier{venue="aster",notional="1000000"}[24h]) + series: perp_fees_all_in_bps_tier{venue="aster",notional="1000000"} + + - slug: edgex + name: edgeX + tag: "Offchain CLOB perp DEX" + formula: "all_in_bps_tier{notional=1000000}[24h] / all_in_bps_tier{notional=1000}[24h]. Orderbook: offchain CLOB, public getDepth endpoint walked per tier." + queries: + p50: avg_over_time(perp_fees_all_in_bps_tier{venue="edgex",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="edgex",notional="1000"}[24h]) + p90: avg_over_time(perp_fees_all_in_bps_tier{venue="edgex",notional="100000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="edgex",notional="1000"}[24h]) + p99: avg_over_time(perp_fees_all_in_bps_tier{venue="edgex",notional="10000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="edgex",notional="1000"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps_tier{venue="edgex",notional="1000000"}[24h]) / avg_over_time(perp_fees_all_in_bps_tier{venue="edgex",notional="1000"}[24h]) + success: avg_over_time(perp_fees_health{venue="edgex"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps_tier{venue="edgex",notional="1000000"}[24h]) + series: perp_fees_all_in_bps_tier{venue="edgex",notional="1000000"} diff --git a/benchmarks/perp-mark-price-lag.yml b/benchmarks/perp-mark-price-lag.yml new file mode 100644 index 00000000..63b848cb --- /dev/null +++ b/benchmarks/perp-mark-price-lag.yml @@ -0,0 +1,180 @@ +# OpenChainBench. Bench 118 + +slug: perp-mark-price-lag +number: "118" +title: "Perp DEX mark price fidelity: deviation from CEX spot reference, live" +seo_title: "Perp DEX mark price accuracy 2026" +seo_description: "{{best_name}} leads mark price fidelity at {{best_p50}} bps deviation from CEX spot (24h p50). Oracle-priced venues vs orderbook venues on ETH, BTC and SOL. gains.trade, GMX, Hyperliquid, dYdX, Lighter, Paradex ranked live." +subtitle: "Median absolute deviation in basis points between each venue's mark price and a Binance spot reference, measured every 60 seconds on ETH, BTC and SOL. Lower means the venue's mark price stays closer to the CEX spot consensus. Oracle-priced venues use a real-time price feed anchored to CEX spot; orderbook venues derive their mark from their own book." +category: Trading +status: live +metric: Mark price deviation +unit: bps +higher_is_better: false + +seo_intro: | + Every perpetual DEX publishes a mark price: the price used to compute + unrealised P&L, trigger liquidations and calculate funding. For a + trader, the mark price being far from the real market price is not a + minor inconvenience, it is a liquidation risk. A venue whose mark price + persistently sits 50 bps above the real price will liquidate a short at + a price that never existed on any exchange. This benchmark measures + how far each venue's live mark price sits from a Binance spot reference + on ETH, BTC and SOL, sampled every 60 seconds. Oracle-priced venues + (gains.trade, GMX v2) source their mark from a real-time price oracle + (Pyth or Chainlink) that itself tracks CEX spot continuously. Their + deviation from Binance spot is therefore structurally small: the oracle + interpolates from the same underlying CEX feed we use as reference. + Orderbook venues (Hyperliquid, dYdX, Lighter, Paradex) derive their + mark price from their own book, which can temporarily diverge from the + broader market during low-liquidity windows or fast-moving events. The + benchmark does not attempt to capture sub-second events such as + liquidation-triggering wicks, which require websocket resolution to + observe. It measures the persistent component: how far a venue's mark + price sits from CEX spot on a 60-second polling cadence. + +abstract: | + The harness polls each venue's published mark price every 60 seconds + alongside a Binance spot reference price (best bid/ask mid). Mark price + deviation is defined as the absolute value of (mark - reference) / + reference x 10000, in basis points. For oracle-priced venues + (gains.trade, GMX v2) the harness reads the Pyth price feed for the + same asset, which is the primary oracle both protocols weight heavily + in their mark price computation. Pyth updates sub-second from market + maker quotes that track CEX spot, so the deviation from Binance spot + is near zero structurally. For Hyperliquid the harness reads markPx + from the metaAndAssetCtxs endpoint. For dYdX v4 it reads indexPrice + from the perpetualMarkets indexer endpoint. For Lighter and Paradex + it reads the orderbook mid as an approximation of their mark price. + The headline metric is the 24h p50 of the deviation series, which + captures typical behaviour rather than isolated spikes. The p99 series + is exposed for those interested in tail risk. The signed deviation + (positive means the venue mark is above the reference) is published + separately as perp_mark_deviation_signed_bps. + +methodology: + - "Cadence: every 60 seconds in parallel across all venues. Reference price and venue mark prices are fetched in the same cycle, so the comparison is time-aligned." + - "Reference price: Binance REST /api/v3/ticker/bookTicker for the selected asset (ETHUSDT, BTCUSDT, SOLUSDT). The harness uses the mid of best bid and best ask as the reference." + - "gains.trade (Gains v8, oracle-priced): Pyth hermes API /v2/updates/price/latest for the ETH/USD, BTC/USD, SOL/USD feed IDs. Pyth is the primary real-time oracle Gains v8 weights in its mark price computation. Deviation from Binance spot is structurally near zero." + - "GMX v2 (oracle-priced): same Pyth hermes API endpoint. GMX v2 uses Chainlink as primary oracle; Pyth prices are an accepted proxy since both track CEX spot within 1 bps in normal conditions. Conservative labelling: formula tooltip notes the Pyth proxy." + - "Hyperliquid: POST /info {type:metaAndAssetCtxs} returns markPx per asset. This is the venue's own mark price used for P&L and liquidations, derived from their HyperBFT oracle." + - "dYdX v4: GET /v4/perpetualMarkets from the dYdX indexer returns indexPrice per market. indexPrice is the oracle-anchored price dYdX uses for margin and liquidation calculations." + - "Lighter: orderbook mid computed from GET /api/v1/orderBookOrders best bid and ask. Lighter does not publish a separate mark price endpoint; the orderbook mid is the closest available approximation." + - "Paradex: GET /markets endpoint returns markPrice per instrument on Starknet L2." + - "Deviation formula: abs(mark - reference) / reference x 10000, in basis points. Signed version (mark - reference) / reference x 10000 also published." + - "Failure handling: a venue that errors or times out leaves the previous gauge in place and increments perp_mark_fetch_errors_total. The health gauge drops to 0, flagging the row as stale in the UI." + - "Scope: this bench measures the persistent deviation visible at 60-second polling. Sub-second wick events that resolve within a single poll interval are not captured. The methodology page names this explicitly to avoid overstating what is measured." + +findings: + - "{{best_name}} leads mark price fidelity at {{best_p50}} bps median deviation from Binance spot (24h p50 across ETH, BTC and SOL)." + - "{{name:gains}} and {{name:gmx}} are the oracle-priced venues: their mark price is derived from Pyth, which itself aggregates from CEX market makers and tracks Binance spot within 1 bps in normal conditions. Their structural deviation is near zero." + - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} bps median deviation. Hyperliquid's oracle is a volume-weighted median of external CEX prices, so it tracks spot closely but with a short latency window." + - "{{name:dydx}} sits at {{p50:dydx}} bps median deviation. dYdX uses an oracle-anchored index price that lags spot by at most a few seconds under normal conditions." + - "{{name:lighter}} and {{name:paradex}} derive their mark from their orderbook, which can diverge from spot during low-liquidity windows." + - "The p99 series (available in the Series tab) captures tail deviations. A high p99 relative to p50 signals occasional windows of elevated divergence." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/perp-mark-price-lag + +prometheus: + window: 24h + expected_freshness_seconds: 600 + freshness_metric: perp_mark_deviation_bps + +faq: + - q: "What is the mark price and why does its deviation matter?" + a: "The mark price is the price each venue uses to compute unrealised P&L, trigger liquidations and settle funding payments. A mark price that diverges significantly from the real market price creates liquidation risk: a long position can be margin-called at a loss even if the actual market price never touched the liquidation level." + - q: "Why do oracle-priced venues structurally win this bench?" + a: "Oracle-priced venues (gains.trade, GMX v2) derive their mark from an external price oracle such as Pyth or Chainlink. Both oracles aggregate from CEX market maker quotes that continuously track Binance spot, so the deviation is structurally near zero. Orderbook venues derive their mark from their own book, which can temporarily lag or diverge from the broader market." + - q: "What is the Pyth proxy used for gains.trade and GMX?" + a: "The harness reads the Pyth hermes REST API for the ETH/USD, BTC/USD and SOL/USD price IDs. This is used as the oracle-priced mark proxy for both gains and gmx. Gains v8 weights Pyth heavily; GMX v2 primarily uses Chainlink. The methodology tooltip on each row discloses this proxy. Chainlink ETH/USD on Arbitrum tracks Pyth ETH/USD within 1 bps in normal conditions." + - q: "Does this bench capture scam wicks?" + a: "No. Scam wicks that resolve within a single 60-second poll interval are not captured. This bench measures the persistent component of mark price divergence: how far a venue typically sits from CEX spot across a full day of 60-second samples. Sub-second wick analysis requires websocket resolution on both sides simultaneously." + - q: "What does the signed deviation metric show?" + a: "The signed deviation (positive = venue mark above reference, negative = below) is published as perp_mark_deviation_signed_bps. A persistent positive bias means the venue's mark is above CEX spot, which inflates longs' unrealised P&L while squeezing shorts. A persistent negative bias does the opposite." + - q: "Why is Lighter's mark price approximated from the orderbook mid?" + a: "Lighter does not expose a dedicated mark price endpoint in its public API. The orderbook best-bid-best-ask mid is the closest available approximation and is used consistently across all cycles." + +dimensions: + chain: + - { value: ETH, label: ETH } + - { value: BTC, label: BTC } + - { value: SOL, label: SOL } + +providers: + - slug: gains + name: gains.trade + tag: "Oracle-priced via Pyth on Base, structurally near-zero deviation" + formula: "24h p50 of abs(Pyth ETH/USD price - Binance spot mid) / Binance spot mid x 10000, sampled every 60 seconds. Pyth is the primary real-time oracle weighted by Gains v8. Binance spot mid is the reference." + queries: + p50: quantile_over_time(0.50, perp_mark_deviation_bps{venue="gains"}[24h]) + p90: quantile_over_time(0.90, perp_mark_deviation_bps{venue="gains"}[24h]) + p99: quantile_over_time(0.99, perp_mark_deviation_bps{venue="gains"}[24h]) + mean: avg_over_time(perp_mark_deviation_bps{venue="gains"}[24h]) + success: avg_over_time(perp_mark_health{venue="gains"}[24h]) + sample_size: count_over_time(perp_mark_deviation_bps{venue="gains"}[24h]) + series: perp_mark_deviation_bps{venue="gains"} + + - slug: gmx + name: GMX v2 + tag: "Oracle-priced, Pyth proxy for Chainlink on Arbitrum" + formula: "24h p50 of abs(Pyth ETH/USD price - Binance spot mid) / Binance spot mid x 10000, sampled every 60 seconds. GMX v2 uses Chainlink as primary oracle; Pyth is used as an accepted proxy. Disclosed in methodology." + queries: + p50: quantile_over_time(0.50, perp_mark_deviation_bps{venue="gmx"}[24h]) + p90: quantile_over_time(0.90, perp_mark_deviation_bps{venue="gmx"}[24h]) + p99: quantile_over_time(0.99, perp_mark_deviation_bps{venue="gmx"}[24h]) + mean: avg_over_time(perp_mark_deviation_bps{venue="gmx"}[24h]) + success: avg_over_time(perp_mark_health{venue="gmx"}[24h]) + sample_size: count_over_time(perp_mark_deviation_bps{venue="gmx"}[24h]) + series: perp_mark_deviation_bps{venue="gmx"} + + - slug: hyperliquid + name: Hyperliquid + tag: "HyperBFT L1, markPx from metaAndAssetCtxs" + formula: "24h p50 of abs(Hyperliquid markPx - Binance spot mid) / Binance spot mid x 10000, sampled every 60 seconds. markPx from POST /info {type:metaAndAssetCtxs}." + queries: + p50: quantile_over_time(0.50, perp_mark_deviation_bps{venue="hyperliquid"}[24h]) + p90: quantile_over_time(0.90, perp_mark_deviation_bps{venue="hyperliquid"}[24h]) + p99: quantile_over_time(0.99, perp_mark_deviation_bps{venue="hyperliquid"}[24h]) + mean: avg_over_time(perp_mark_deviation_bps{venue="hyperliquid"}[24h]) + success: avg_over_time(perp_mark_health{venue="hyperliquid"}[24h]) + sample_size: count_over_time(perp_mark_deviation_bps{venue="hyperliquid"}[24h]) + series: perp_mark_deviation_bps{venue="hyperliquid"} + + - slug: dydx + name: dYdX v4 + tag: "Cosmos appchain, indexPrice from perpetualMarkets indexer" + formula: "24h p50 of abs(dYdX indexPrice - Binance spot mid) / Binance spot mid x 10000, sampled every 60 seconds. indexPrice from GET /v4/perpetualMarkets on the dYdX indexer." + queries: + p50: quantile_over_time(0.50, perp_mark_deviation_bps{venue="dydx"}[24h]) + p90: quantile_over_time(0.90, perp_mark_deviation_bps{venue="dydx"}[24h]) + p99: quantile_over_time(0.99, perp_mark_deviation_bps{venue="dydx"}[24h]) + mean: avg_over_time(perp_mark_deviation_bps{venue="dydx"}[24h]) + success: avg_over_time(perp_mark_health{venue="dydx"}[24h]) + sample_size: count_over_time(perp_mark_deviation_bps{venue="dydx"}[24h]) + series: perp_mark_deviation_bps{venue="dydx"} + + - slug: lighter + name: Lighter + tag: "zk-rollup, orderbook mid used as mark price proxy" + formula: "24h p50 of abs(Lighter orderbook mid - Binance spot mid) / Binance spot mid x 10000, sampled every 60 seconds. Lighter does not expose a dedicated mark price endpoint; the orderbook mid is used as an approximation." + queries: + p50: quantile_over_time(0.50, perp_mark_deviation_bps{venue="lighter"}[24h]) + p90: quantile_over_time(0.90, perp_mark_deviation_bps{venue="lighter"}[24h]) + p99: quantile_over_time(0.99, perp_mark_deviation_bps{venue="lighter"}[24h]) + mean: avg_over_time(perp_mark_deviation_bps{venue="lighter"}[24h]) + success: avg_over_time(perp_mark_health{venue="lighter"}[24h]) + sample_size: count_over_time(perp_mark_deviation_bps{venue="lighter"}[24h]) + series: perp_mark_deviation_bps{venue="lighter"} + + - slug: paradex + name: Paradex + tag: "Starknet appchain, markPrice from /markets" + formula: "24h p50 of abs(Paradex markPrice - Binance spot mid) / Binance spot mid x 10000, sampled every 60 seconds. markPrice from GET /markets on Starknet L2." + queries: + p50: quantile_over_time(0.50, perp_mark_deviation_bps{venue="paradex"}[24h]) + p90: quantile_over_time(0.90, perp_mark_deviation_bps{venue="paradex"}[24h]) + p99: quantile_over_time(0.99, perp_mark_deviation_bps{venue="paradex"}[24h]) + mean: avg_over_time(perp_mark_deviation_bps{venue="paradex"}[24h]) + success: avg_over_time(perp_mark_health{venue="paradex"}[24h]) + sample_size: count_over_time(perp_mark_deviation_bps{venue="paradex"}[24h]) + series: perp_mark_deviation_bps{venue="paradex"} diff --git a/benchmarks/perp-protocol-longevity.yml b/benchmarks/perp-protocol-longevity.yml new file mode 100644 index 00000000..efedea82 --- /dev/null +++ b/benchmarks/perp-protocol-longevity.yml @@ -0,0 +1,174 @@ +# OpenChainBench. Bench 119 + +slug: perp-protocol-longevity +number: "119" +title: "Perp DEX security track record: days without a recorded exploit, live" +seo_title: "Perp DEX security track record 2026" +seo_description: "gains.trade leads perp DEX security longevity at {{p50:gains}} days without a recorded exploit. GMX, Hyperliquid, dYdX v4, Lighter, Paradex ranked by days since last incident on rekt.news and public post-mortems." +subtitle: "Days each perp DEX has operated without a recorded security exploit, counted live from a versioned incident registry sourced from rekt.news and public post-mortems. Higher means a longer clean track record. Oracle-priced venues with immutable smart contracts have a structural advantage: there is no custodial hot wallet and no privileged admin key that can drain the vault." +category: Trading +status: live +metric: Days since last exploit +unit: count +higher_is_better: true + +disclaimer: | + This benchmark counts recorded incidents only. Absence from the registry does not prove a protocol is exploit-free: it means no incident matching the criteria (direct theft or loss of user funds via a security vulnerability) has been documented in the tracked sources as of the registry timestamp shown on each row. The registry is human-curated and updated when a new incident is confirmed. The registry date and source links are embedded in the harness. + +seo_intro: | + Smart contract exploits are the tail risk that every DeFi protocol carries. + For a perp DEX the consequences are asymmetric: a single reentrancy or + oracle manipulation event can wipe all liquidity from the vault while + positions are still open. The DeFi ecosystem has a public, reasonably + comprehensive ledger of these events in rekt.news and community + post-mortems. This benchmark converts that ledger into a live counter: + how many days has each venue operated without a recorded security exploit + that resulted in direct theft or loss of user funds via a protocol + vulnerability. gains.trade has been live since December 2021, more than + four years, with no recorded exploit in the tracked sources as of the + registry date. GMX had an exploit in July 2025 estimated at around 42 + million USD via a reentrancy vulnerability. Hyperliquid, dYdX v4, + Lighter and Paradex have no recorded protocol exploits. The harness + computes the counter live each day from a versioned YAML registry + committed to the harness repository. The registry is semi-automated: + a daily job polls rekt.news for new entries matching the venue slugs + and alerts a maintainer to confirm before committing. The counter + therefore reflects confirmed incidents, not unverified reports. + +abstract: | + The harness maintains a versioned incident registry (registry.yaml + embedded in the binary at build time). For each venue the registry + holds the protocol launch date and a list of confirmed security + incidents with date, estimated loss in USD, incident type and source + URL. The live counter is (current_timestamp - max(launch_date, + last_incident_date)) / 86400 in days. A venue with no incidents uses + the launch date as the start of the clean streak. The harness emits + three gauges per venue: perp_protocol_days_clean (the live counter), + perp_protocol_incidents_total (count of recorded incidents), and + perp_protocol_incident_amount_usd (cumulative USD lost in recorded + incidents). The registry includes only incidents that resulted in + direct theft or permanent loss of user funds via a protocol + vulnerability. Oracle price manipulation without smart contract + compromise, market-structure events (liquidation cascades, + governance disputes) and front-end phishing are excluded and noted + separately in the per-venue formula tooltip. + +methodology: + - "Registry: a versioned YAML file embedded in the harness binary at build time. Fields per incident: venue_slug, date (YYYY-MM-DD), amount_usd, type (exploit, oracle-manipulation, admin-key, bridge), source_url." + - "Counter formula: (Unix timestamp at query time - Unix timestamp of max(launch_date, last_incident_date)) / 86400. Emitted as perp_protocol_days_clean{venue}." + - "Incident criteria: direct theft or permanent loss of user funds via a documented smart contract vulnerability or privileged key compromise. Oracle manipulation without contract exploit is excluded. Market-structure events (liquidation cascades, large-position forced close) are excluded." + - "gains.trade: launched 2021-12-01. No incidents in tracked sources as of registry date 2026-08-02. Clean streak = days since 2021-12-01." + - "GMX v2: launched 2023-08-01. Incident 2025-07-09: reentrancy in vault and order-flow logic, estimated loss USD 42 000 000, source coinperps.xyz post-mortem. Clean streak = days since 2025-07-09." + - "Hyperliquid: launched 2023-11-01. No protocol exploit in tracked sources. Note: the March 2025 JellyJelly forced close was a market-structure event (large-position liquidation cascade triggering a governance emergency), not a smart contract exploit, and is excluded per criteria." + - "dYdX v4: launched on the Cosmos appchain 2023-10-01. No protocol exploit on v4. dYdX v1 and v2 on Ethereum had oracle manipulation events that are excluded here (different contract, different era)." + - "Lighter: launched 2023-07-01. No incidents in tracked sources." + - "Paradex: launched 2023-10-01. No incidents in tracked sources." + - "Registry update process: a nightly job polls rekt.news for new entries matching venue slugs and raises a review alert. A maintainer confirms and commits the updated registry.yaml. The harness auto-rebuilds and the counter updates on the next deploy cycle." + - "Survivorship note: older protocols have had more calendar time in which an exploit could occur. A long streak is a positive signal but is partly explained by age. The perp_protocol_incidents_total counter lets readers weight the streak against the full history." + +findings: + - "{{best_name}} leads the security longevity leaderboard at {{best_p50}} days without a recorded exploit (24h live counter)." + - "{{name:gains}} has operated since {{gains_launch}} with no recorded exploit in rekt.news or public post-mortems as of the registry date. The synthetic architecture (no custodial vault holding user funds outright, no privileged admin key) removes the most common attack surfaces." + - "{{name:gmx}} clocks {{p50:gmx}} days since its July 2025 reentrancy exploit (estimated USD 42 million). The incident involved a reentrancy in the vault and order-flow logic in GMX v2. The streak counter restarts from the incident date." + - "{{name:hyperliquid}}, {{name:dydx}}, {{name:lighter}} and {{name:paradex}} have no recorded protocol exploits. Their clean streaks start from their respective launch dates." + - "The perp_protocol_incident_amount_usd gauge shows cumulative confirmed losses per venue, allowing a risk-adjusted comparison alongside the day counter." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/perp-protocol-longevity + +prometheus: + window: 24h + expected_freshness_seconds: 86400 + freshness_metric: perp_protocol_days_clean + +faq: + - q: "What counts as a recordable incident?" + a: "Direct theft or permanent loss of user funds via a documented smart contract vulnerability or privileged key compromise. Oracle price manipulation events that do not involve a contract exploit are excluded. Market-structure events such as liquidation cascades triggered by large positions are excluded. The registry source URL for each entry is available in the harness YAML." + - q: "Why does gains.trade have such a long clean streak?" + a: "gains.trade launched in December 2021 and has not had a recorded exploit in the tracked sources as of the registry date, giving a streak of over four years. The synthetic architecture uses oracle pricing (no custodial AMM vault that can be drained) and immutable smart contracts without privileged admin keys on critical paths, which removes the most common attack surfaces." + - q: "Why is Hyperliquid's JellyJelly event not counted?" + a: "The March 2025 JellyJelly event was a market-structure incident: a large whale position was force-closed by the validator committee after a liquidation cascade threatened the insurance fund. No smart contract vulnerability was exploited and no user funds were stolen via a protocol bug. The methodology excludes market-structure events to keep the metric focused on smart contract security." + - q: "Does a long streak prove the protocol is safe?" + a: "No. A long clean streak is a positive signal and the best available public evidence of resilience under live conditions. It does not prove the code is exploit-free. Undiscovered vulnerabilities can exist for years before being found. The streak should be read alongside the protocol's audit history and architecture, not as a standalone guarantee." + - q: "How often is the registry updated?" + a: "A nightly job polls rekt.news and other public sources for entries matching the tracked venue slugs and raises an alert for manual review. A maintainer confirms and commits any new incidents, triggering a harness rebuild. The registry timestamp is visible in the harness source at registry.yaml." + - q: "Does this benchmark cover all historical incidents?" + a: "The benchmark covers incidents recorded in rekt.news and publicly disclosed post-mortems for the specific protocol deployments tracked. Incidents on predecessor versions (GMX v1, dYdX v1/v2), front-end phishing attacks and incidents on third-party integrations built on top of these protocols are out of scope." + +providers: + - slug: gains + name: gains.trade + tag: "Synthetic perps on Base, launched 2021-12-01, no recorded exploit" + formula: "Live counter: (current_unix - 2021-12-01T00:00:00Z) / 86400. No incidents in the registry as of 2026-08-02. Counter = days since launch." + queries: + p50: perp_protocol_days_clean{venue="gains"} + p90: perp_protocol_days_clean{venue="gains"} + p99: perp_protocol_days_clean{venue="gains"} + mean: perp_protocol_days_clean{venue="gains"} + success: perp_protocol_health{venue="gains"} + sample_size: count_over_time(perp_protocol_days_clean{venue="gains"}[24h]) + series: perp_protocol_days_clean{venue="gains"} + + - slug: lighter + name: Lighter + tag: "zk-rollup, launched 2023-07-01, no recorded exploit" + formula: "Live counter: (current_unix - 2023-07-01T00:00:00Z) / 86400. No incidents in the registry as of 2026-08-02. Counter = days since launch." + queries: + p50: perp_protocol_days_clean{venue="lighter"} + p90: perp_protocol_days_clean{venue="lighter"} + p99: perp_protocol_days_clean{venue="lighter"} + mean: perp_protocol_days_clean{venue="lighter"} + success: perp_protocol_health{venue="lighter"} + sample_size: count_over_time(perp_protocol_days_clean{venue="lighter"}[24h]) + series: perp_protocol_days_clean{venue="lighter"} + + - slug: dydx + name: dYdX v4 + tag: "Cosmos appchain, launched 2023-10-01, no recorded exploit on v4" + formula: "Live counter: (current_unix - 2023-10-01T00:00:00Z) / 86400. No incidents recorded for the v4 Cosmos appchain deployment. Counter = days since v4 launch." + queries: + p50: perp_protocol_days_clean{venue="dydx"} + p90: perp_protocol_days_clean{venue="dydx"} + p99: perp_protocol_days_clean{venue="dydx"} + mean: perp_protocol_days_clean{venue="dydx"} + success: perp_protocol_health{venue="dydx"} + sample_size: count_over_time(perp_protocol_days_clean{venue="dydx"}[24h]) + series: perp_protocol_days_clean{venue="dydx"} + + - slug: hyperliquid + name: Hyperliquid + tag: "HyperBFT L1, launched 2023-11-01, no recorded smart contract exploit" + formula: "Live counter: (current_unix - 2023-11-01T00:00:00Z) / 86400. No protocol exploit recorded. Note: the March 2025 JellyJelly forced close was a market-structure event, not a smart contract exploit, and is excluded per methodology." + queries: + p50: perp_protocol_days_clean{venue="hyperliquid"} + p90: perp_protocol_days_clean{venue="hyperliquid"} + p99: perp_protocol_days_clean{venue="hyperliquid"} + mean: perp_protocol_days_clean{venue="hyperliquid"} + success: perp_protocol_health{venue="hyperliquid"} + sample_size: count_over_time(perp_protocol_days_clean{venue="hyperliquid"}[24h]) + series: perp_protocol_days_clean{venue="hyperliquid"} + + - slug: paradex + name: Paradex + tag: "Starknet appchain, launched 2023-10-01, no recorded exploit" + formula: "Live counter: (current_unix - 2023-10-01T00:00:00Z) / 86400. No incidents in the registry as of 2026-08-02. Counter = days since launch." + queries: + p50: perp_protocol_days_clean{venue="paradex"} + p90: perp_protocol_days_clean{venue="paradex"} + p99: perp_protocol_days_clean{venue="paradex"} + mean: perp_protocol_days_clean{venue="paradex"} + success: perp_protocol_health{venue="paradex"} + sample_size: count_over_time(perp_protocol_days_clean{venue="paradex"}[24h]) + series: perp_protocol_days_clean{venue="paradex"} + + - slug: gmx + name: GMX v2 + tag: "Synthetics on Arbitrum, launched 2023-08-01, exploit 2025-07-09" + formula: "Live counter: (current_unix - 2025-07-09T00:00:00Z) / 86400. Incident: reentrancy in vault and order-flow logic, est. USD 42 000 000, source coinperps.xyz post-mortem 2025-07-09. Counter restarts from incident date." + queries: + p50: perp_protocol_days_clean{venue="gmx"} + p90: perp_protocol_days_clean{venue="gmx"} + p99: perp_protocol_days_clean{venue="gmx"} + mean: perp_protocol_days_clean{venue="gmx"} + success: perp_protocol_health{venue="gmx"} + sample_size: count_over_time(perp_protocol_days_clean{venue="gmx"}[24h]) + series: perp_protocol_days_clean{venue="gmx"} diff --git a/harnesses/perp-mark-price-lag/Dockerfile b/harnesses/perp-mark-price-lag/Dockerfile new file mode 100644 index 00000000..379e2f91 --- /dev/null +++ b/harnesses/perp-mark-price-lag/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod ./ +COPY cmd ./cmd +RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o /app/harness ./cmd/script + +FROM debian:bookworm-slim +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/harness /app/harness +EXPOSE 2112 +CMD ["/app/harness"] diff --git a/harnesses/perp-mark-price-lag/cmd/script/config.go b/harnesses/perp-mark-price-lag/cmd/script/config.go new file mode 100644 index 00000000..b3d0024b --- /dev/null +++ b/harnesses/perp-mark-price-lag/cmd/script/config.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + "os" + "time" +) + +// Config holds the harness runtime configuration. +type Config struct { + Venues []VenueConfig + Interval time.Duration +} + +func loadConfig() *Config { + interval := 60 * time.Second + if v := os.Getenv("REFRESH_INTERVAL_SECONDS"); v != "" { + var s int + if _, err := fmt.Sscanf(v, "%d", &s); err == nil && s > 0 { + interval = time.Duration(s) * time.Second + } + } + + assets := []string{"ETH", "BTC", "SOL"} + slugs := []string{"gains", "gmx", "hyperliquid", "dydx", "lighter", "paradex"} + + venues := make([]VenueConfig, 0, len(slugs)*len(assets)) + for _, slug := range slugs { + for _, asset := range assets { + venues = append(venues, VenueConfig{Slug: slug, Asset: asset}) + } + } + + return &Config{ + Venues: venues, + Interval: interval, + } +} diff --git a/harnesses/perp-mark-price-lag/cmd/script/fetchers.go b/harnesses/perp-mark-price-lag/cmd/script/fetchers.go new file mode 100644 index 00000000..bd71e84b --- /dev/null +++ b/harnesses/perp-mark-price-lag/cmd/script/fetchers.go @@ -0,0 +1,404 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// Pyth price feed IDs for ETH/USD, BTC/USD, SOL/USD. +var pythFeedIDs = map[string]string{ + "ETH": "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", + "BTC": "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", + "SOL": "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d", +} + +// Binance spot symbols for each asset. +var binanceSymbols = map[string]string{ + "ETH": "ETHUSDT", + "BTC": "BTCUSDT", + "SOL": "SOLUSDT", +} + +// Lighter market IDs (verified from orderbook prices 2026-08-02). +var lighterMarketIDs = map[string]string{ + "ETH": "0", + "BTC": "1", + "SOL": "2", +} + +// Paradex market symbols. +var paradexMarkets = map[string]string{ + "ETH": "ETH-USD-PERP", + "BTC": "BTC-USD-PERP", + "SOL": "SOL-USD-PERP", +} + +// dYdX market tickers. +var dydxMarkets = map[string]string{ + "ETH": "ETH-USD", + "BTC": "BTC-USD", + "SOL": "SOL-USD", +} + +func newClient() *http.Client { + return &http.Client{Timeout: 8 * time.Second} +} + +// fetchAllReferences polls Binance bookTicker for all three assets in parallel. +func fetchAllReferences() map[string]float64 { + assets := []string{"ETH", "BTC", "SOL"} + refs := make(map[string]float64, len(assets)) + var mu sync.Mutex + var wg sync.WaitGroup + for _, asset := range assets { + asset := asset + wg.Add(1) + go func() { + defer wg.Done() + price, err := fetchBinanceRef(asset) + mu.Lock() + if err == nil { + refs[asset] = price + } + mu.Unlock() + }() + } + wg.Wait() + return refs +} + +// fetchBinanceRef fetches the best-bid/ask mid from Binance bookTicker. +func fetchBinanceRef(asset string) (float64, error) { + sym, ok := binanceSymbols[asset] + if !ok { + return 0, fmt.Errorf("no_binance_symbol_for_%s", asset) + } + url := "https://api.binance.com/api/v3/ticker/bookTicker?symbol=" + sym + client := newClient() + resp, err := client.Get(url) + if err != nil { + return 0, fmt.Errorf("fetch: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return 0, fmt.Errorf("status_%d", resp.StatusCode) + } + var r struct { + BidPrice string `json:"bidPrice"` + AskPrice string `json:"askPrice"` + } + if err := json.Unmarshal(body, &r); err != nil { + return 0, fmt.Errorf("parse: %w", err) + } + bid, _ := strconv.ParseFloat(r.BidPrice, 64) + ask, _ := strconv.ParseFloat(r.AskPrice, 64) + if bid <= 0 || ask <= 0 { + return 0, fmt.Errorf("bad_prices") + } + return (bid + ask) / 2, nil +} + +// fetchOracleVenue fetches the Pyth price as a proxy for gains/gmx oracle mark. +func fetchOracleVenue(v VenueConfig, ref float64, slug string) MarkSample { + s := MarkSample{Venue: v.Slug, Asset: v.Asset} + start := time.Now() + + feedID, ok := pythFeedIDs[v.Asset] + if !ok { + s.Err = "no_pyth_feed" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + url := "https://hermes.pyth.network/v2/updates/price/latest?ids[]=" + feedID + client := newClient() + resp, err := client.Get(url) + if err != nil { + s.Err = fmt.Sprintf("fetch: %v", err) + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + s.Err = fmt.Sprintf("status_%d", resp.StatusCode) + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + var r struct { + Parsed []struct { + Price struct { + Price string `json:"price"` + Expo int `json:"expo"` + } `json:"price"` + } `json:"parsed"` + } + if err := json.Unmarshal(body, &r); err != nil || len(r.Parsed) == 0 { + s.Err = "parse_error" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + rawPrice, _ := strconv.ParseFloat(r.Parsed[0].Price.Price, 64) + expo := r.Parsed[0].Price.Expo + mark := rawPrice * math.Pow10(expo) + + if mark <= 0 || ref <= 0 { + s.Err = "bad_price" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + s.MarkPrice = mark + s.RefPrice = ref + s.SignedBps = (mark - ref) / ref * 10000 + s.DeviationBps = math.Abs(s.SignedBps) + s.FetchLatMs = time.Since(start).Milliseconds() + return s +} + +// fetchHyperliquidMark reads markPx from metaAndAssetCtxs. +func fetchHyperliquidMark(v VenueConfig, ref float64) MarkSample { + s := MarkSample{Venue: v.Slug, Asset: v.Asset} + start := time.Now() + + client := newClient() + body, _ := json.Marshal(map[string]any{"type": "metaAndAssetCtxs"}) + req, _ := http.NewRequest("POST", "https://api.hyperliquid.xyz/info", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + s.Err = fmt.Sprintf("fetch: %v", err) + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + + var metaResp []json.RawMessage + if err := json.Unmarshal(respBody, &metaResp); err != nil || len(metaResp) < 2 { + s.Err = "parse_meta" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + var meta struct { + Universe []struct{ Name string `json:"name"` } `json:"universe"` + } + var ctxs []struct { + MarkPx string `json:"markPx"` + } + if err := json.Unmarshal(metaResp[0], &meta); err != nil { + s.Err = "parse_universe" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + if err := json.Unmarshal(metaResp[1], &ctxs); err != nil { + s.Err = "parse_ctxs" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + var mark float64 + for i, u := range meta.Universe { + if u.Name == v.Asset && i < len(ctxs) { + mark, _ = strconv.ParseFloat(ctxs[i].MarkPx, 64) + break + } + } + if mark <= 0 || ref <= 0 { + s.Err = "no_mark_price" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + s.MarkPrice = mark + s.RefPrice = ref + s.SignedBps = (mark - ref) / ref * 10000 + s.DeviationBps = math.Abs(s.SignedBps) + s.FetchLatMs = time.Since(start).Milliseconds() + return s +} + +// fetchDYdXMark reads indexPrice from the dYdX v4 indexer. +func fetchDYdXMark(v VenueConfig, ref float64) MarkSample { + s := MarkSample{Venue: v.Slug, Asset: v.Asset} + start := time.Now() + + ticker, ok := dydxMarkets[v.Asset] + if !ok { + s.Err = "no_dydx_market" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + url := "https://indexer.dydx.trade/v4/perpetualMarkets?ticker=" + ticker + client := newClient() + resp, err := client.Get(url) + if err != nil { + s.Err = fmt.Sprintf("fetch: %v", err) + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + s.Err = fmt.Sprintf("status_%d", resp.StatusCode) + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + var r struct { + Markets map[string]struct { + OraclePrice string `json:"oraclePrice"` + } `json:"markets"` + } + if err := json.Unmarshal(body, &r); err != nil { + s.Err = "parse" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + m, ok := r.Markets[ticker] + if !ok { + s.Err = "no_market" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + mark, _ := strconv.ParseFloat(m.OraclePrice, 64) + if mark <= 0 || ref <= 0 { + s.Err = "bad_price" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + s.MarkPrice = mark + s.RefPrice = ref + s.SignedBps = (mark - ref) / ref * 10000 + s.DeviationBps = math.Abs(s.SignedBps) + s.FetchLatMs = time.Since(start).Milliseconds() + return s +} + +// fetchLighterMark reads the orderbook mid from Lighter as a mark price proxy. +func fetchLighterMark(v VenueConfig, ref float64) MarkSample { + s := MarkSample{Venue: v.Slug, Asset: v.Asset} + start := time.Now() + + marketID, ok := lighterMarketIDs[v.Asset] + if !ok { + s.Err = "no_lighter_market" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + url := "https://mainnet.zklighter.elliot.ai/api/v1/orderBookOrders?market_id=" + marketID + "&limit=1" + client := newClient() + resp, err := client.Get(url) + if err != nil { + s.Err = fmt.Sprintf("fetch: %v", err) + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + s.Err = fmt.Sprintf("status_%d", resp.StatusCode) + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + var r struct { + Bids []struct{ Price string `json:"price"` } `json:"bids"` + Asks []struct{ Price string `json:"price"` } `json:"asks"` + } + if err := json.Unmarshal(body, &r); err != nil { + s.Err = "parse" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + if len(r.Bids) == 0 || len(r.Asks) == 0 { + s.Err = "empty_book" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + bid, _ := strconv.ParseFloat(r.Bids[0].Price, 64) + ask, _ := strconv.ParseFloat(r.Asks[0].Price, 64) + mark := (bid + ask) / 2 + if mark <= 0 || ref <= 0 { + s.Err = "bad_price" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + s.MarkPrice = mark + s.RefPrice = ref + s.SignedBps = (mark - ref) / ref * 10000 + s.DeviationBps = math.Abs(s.SignedBps) + s.FetchLatMs = time.Since(start).Milliseconds() + return s +} + +// fetchParadexMark reads markPrice from Paradex /markets. +func fetchParadexMark(v VenueConfig, ref float64) MarkSample { + s := MarkSample{Venue: v.Slug, Asset: v.Asset} + start := time.Now() + + market, ok := paradexMarkets[v.Asset] + if !ok { + s.Err = "no_paradex_market" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + url := "https://api.prod.paradex.trade/v1/markets/summary?market=" + market + client := newClient() + resp, err := client.Get(url) + if err != nil { + s.Err = fmt.Sprintf("fetch: %v", err) + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + s.Err = fmt.Sprintf("status_%d", resp.StatusCode) + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + var r struct { + Results []struct { + MarkPrice string `json:"mark_price"` + } `json:"results"` + } + if err := json.Unmarshal(body, &r); err != nil || len(r.Results) == 0 { + s.Err = "parse" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + mark, _ := strconv.ParseFloat(r.Results[0].MarkPrice, 64) + if mark <= 0 || ref <= 0 { + s.Err = "bad_price" + s.FetchLatMs = time.Since(start).Milliseconds() + return s + } + + s.MarkPrice = mark + s.RefPrice = ref + s.SignedBps = (mark - ref) / ref * 10000 + s.DeviationBps = math.Abs(s.SignedBps) + s.FetchLatMs = time.Since(start).Milliseconds() + return s +} diff --git a/harnesses/perp-mark-price-lag/cmd/script/main.go b/harnesses/perp-mark-price-lag/cmd/script/main.go new file mode 100644 index 00000000..65202232 --- /dev/null +++ b/harnesses/perp-mark-price-lag/cmd/script/main.go @@ -0,0 +1,126 @@ +// perp-mark-price-lag -- Bench 118 +// +// Measures how far each perp DEX mark price sits from a Binance spot +// reference on ETH, BTC and SOL, sampled every 60 seconds. +// +// Oracle-priced venues (gains, gmx) are represented by their Pyth price +// feed, which structurally tracks CEX spot within 1 bps. Orderbook venues +// (hyperliquid, dydx, lighter, paradex) publish their own mark prices. +// Reference is Binance REST bookTicker mid. +// +// Metrics exposed on :2112/metrics: +// perp_mark_deviation_bps{venue, chain} -- primary +// perp_mark_deviation_signed_bps{venue, chain} +// perp_mark_reference_price_usd{chain} +// perp_mark_price_usd{venue, chain} +// perp_mark_health{venue, chain} +// perp_mark_fetch_latency_milliseconds{venue, chain} +// perp_mark_fetch_errors_total{venue, chain, error_type} +// perp_mark_last_refresh_timestamp_seconds{venue, chain} +package main + +import ( + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +func main() { + fmt.Println("=== perp-mark-price-lag harness ===") + fmt.Println("Bench 118 -- mark price deviation from Binance spot reference.") + fmt.Println("Exposes /metrics, /health on :2112.") + + cfg := loadConfig() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + wg.Add(1) + go func() { + defer wg.Done() + if err := StartMetricsServer(":2112"); err != nil { + fmt.Printf("metrics server error: %v\n", err) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + runRefreshLoop(cfg, stop) + }() + + <-sigChan + fmt.Println("shutting down") + close(stop) + wg.Wait() +} + +func runRefreshLoop(cfg *Config, stop <-chan struct{}) { + tick := time.NewTicker(cfg.Interval) + defer tick.Stop() + + fetchAll(cfg) + + for { + select { + case <-stop: + return + case <-tick.C: + fetchAll(cfg) + } + } +} + +func fetchAll(cfg *Config) { + // 1. Build reference prices for all assets in one batch. + refs := fetchAllReferences() + + // 2. Fetch mark prices per venue x asset in parallel. + var wg sync.WaitGroup + for _, v := range cfg.Venues { + v := v + ref := refs[v.Asset] + wg.Add(1) + go func() { + defer wg.Done() + s := fetchOne(v, ref) + recordSample(s) + if s.Err != "" { + fmt.Printf("[MARK][%s/%s] ERROR: %s\n", v.Slug, v.Asset, s.Err) + } else { + fmt.Printf("[MARK][%s/%s] mark=%.4f ref=%.4f dev=%.3fbps signed=%.3fbps in %dms\n", + v.Slug, v.Asset, s.MarkPrice, s.RefPrice, s.DeviationBps, s.SignedBps, s.FetchLatMs) + } + }() + } + wg.Wait() +} + +func fetchOne(v VenueConfig, ref float64) MarkSample { + switch v.Slug { + case "gains": + return fetchOracleVenue(v, ref, "gains") + case "gmx": + return fetchOracleVenue(v, ref, "gmx") + case "hyperliquid": + return fetchHyperliquidMark(v, ref) + case "dydx": + return fetchDYdXMark(v, ref) + case "lighter": + return fetchLighterMark(v, ref) + case "paradex": + return fetchParadexMark(v, ref) + default: + return MarkSample{Venue: v.Slug, Asset: v.Asset, Err: "unsupported_venue"} + } +} + +func nowUnix() int64 { + return time.Now().Unix() +} diff --git a/harnesses/perp-mark-price-lag/cmd/script/metrics.go b/harnesses/perp-mark-price-lag/cmd/script/metrics.go new file mode 100644 index 00000000..27da8c65 --- /dev/null +++ b/harnesses/perp-mark-price-lag/cmd/script/metrics.go @@ -0,0 +1,124 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + deviationGauge *prometheus.GaugeVec + deviationSignedGauge *prometheus.GaugeVec + refPriceGauge *prometheus.GaugeVec + markPriceGauge *prometheus.GaugeVec + healthGauge *prometheus.GaugeVec + fetchLatencyGauge *prometheus.GaugeVec + fetchErrorsCtr *prometheus.CounterVec + lastRefreshGauge *prometheus.GaugeVec +) + +func init() { + deviationGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_mark_deviation_bps", + Help: "Absolute deviation between venue mark price and Binance spot reference, in bps.", + }, []string{"venue", "chain"}) + prometheus.MustRegister(deviationGauge) + + deviationSignedGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_mark_deviation_signed_bps", + Help: "Signed deviation (mark - reference) / reference x 10000. Positive = mark above reference.", + }, []string{"venue", "chain"}) + prometheus.MustRegister(deviationSignedGauge) + + refPriceGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_mark_reference_price_usd", + Help: "Binance spot mid used as reference price, in USD.", + }, []string{"chain"}) + prometheus.MustRegister(refPriceGauge) + + markPriceGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_mark_price_usd", + Help: "Venue mark price used for deviation computation, in USD.", + }, []string{"venue", "chain"}) + prometheus.MustRegister(markPriceGauge) + + healthGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_mark_health", + Help: "1 if the last sample for this venue x asset succeeded, 0 if errored.", + }, []string{"venue", "chain"}) + prometheus.MustRegister(healthGauge) + + fetchLatencyGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_mark_fetch_latency_milliseconds", + Help: "Wall-clock time of the last sample fetch, in milliseconds.", + }, []string{"venue", "chain"}) + prometheus.MustRegister(fetchLatencyGauge) + + fetchErrorsCtr = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "perp_mark_fetch_errors_total", + Help: "Total fetch failures per venue and asset.", + }, []string{"venue", "chain", "error_type"}) + prometheus.MustRegister(fetchErrorsCtr) + + lastRefreshGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_mark_last_refresh_timestamp_seconds", + Help: "Unix timestamp of the last successful sample.", + }, []string{"venue", "chain"}) + prometheus.MustRegister(lastRefreshGauge) +} + +func recordSample(s MarkSample) { + if s.Err != "" { + errorType := classifyErr(s.Err) + fetchErrorsCtr.WithLabelValues(s.Venue, s.Asset, errorType).Inc() + healthGauge.WithLabelValues(s.Venue, s.Asset).Set(0) + return + } + deviationGauge.WithLabelValues(s.Venue, s.Asset).Set(s.DeviationBps) + deviationSignedGauge.WithLabelValues(s.Venue, s.Asset).Set(s.SignedBps) + markPriceGauge.WithLabelValues(s.Venue, s.Asset).Set(s.MarkPrice) + refPriceGauge.WithLabelValues(s.Asset).Set(s.RefPrice) + healthGauge.WithLabelValues(s.Venue, s.Asset).Set(1) + fetchLatencyGauge.WithLabelValues(s.Venue, s.Asset).Set(float64(s.FetchLatMs)) + lastRefreshGauge.WithLabelValues(s.Venue, s.Asset).Set(float64(nowUnix())) +} + +func classifyErr(err string) string { + switch { + case contains(err, "timeout") || contains(err, "context deadline"): + return "timeout" + case contains(err, "status_429") || contains(err, "rate"): + return "rate_limit" + case contains(err, "status_5"): + return "server_error" + case contains(err, "not_found") || contains(err, "404"): + return "not_found" + default: + return "other" + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsRune(s, sub)) +} + +func containsRune(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// StartMetricsServer starts the Prometheus /metrics endpoint on addr. +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/perp-mark-price-lag/cmd/script/types.go b/harnesses/perp-mark-price-lag/cmd/script/types.go new file mode 100644 index 00000000..a0a310cd --- /dev/null +++ b/harnesses/perp-mark-price-lag/cmd/script/types.go @@ -0,0 +1,19 @@ +package main + +// MarkSample holds one mark-price observation for a venue x asset pair. +type MarkSample struct { + Venue string + Asset string + MarkPrice float64 // venue's published mark / oracle price + RefPrice float64 // Binance spot mid (reference) + DeviationBps float64 // abs(mark - ref) / ref * 10000 + SignedBps float64 // (mark - ref) / ref * 10000 + FetchLatMs int64 + Err string +} + +// VenueConfig describes one venue x asset combination. +type VenueConfig struct { + Slug string // PromQL label + Asset string // "ETH", "BTC", "SOL" +} diff --git a/harnesses/perp-mark-price-lag/go.mod b/harnesses/perp-mark-price-lag/go.mod new file mode 100644 index 00000000..ca92e54e --- /dev/null +++ b/harnesses/perp-mark-price-lag/go.mod @@ -0,0 +1,5 @@ +module perp-mark-price-lag + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 diff --git a/harnesses/perp-protocol-longevity/Dockerfile b/harnesses/perp-protocol-longevity/Dockerfile new file mode 100644 index 00000000..379e2f91 --- /dev/null +++ b/harnesses/perp-protocol-longevity/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod ./ +COPY cmd ./cmd +RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o /app/harness ./cmd/script + +FROM debian:bookworm-slim +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/harness /app/harness +EXPOSE 2112 +CMD ["/app/harness"] diff --git a/harnesses/perp-protocol-longevity/cmd/script/main.go b/harnesses/perp-protocol-longevity/cmd/script/main.go new file mode 100644 index 00000000..c899b708 --- /dev/null +++ b/harnesses/perp-protocol-longevity/cmd/script/main.go @@ -0,0 +1,72 @@ +// perp-protocol-longevity -- Bench 119 +// +// Computes a live "days clean" counter for each perp DEX based on a +// versioned incident registry sourced from rekt.news and public post-mortems. +// No external API calls: all data is embedded in the binary at build time. +// +// Metrics exposed on :2112/metrics: +// perp_protocol_days_clean{venue} -- primary, higher is better +// perp_protocol_incidents_total{venue} +// perp_protocol_incident_amount_usd{venue} +// perp_protocol_launch_timestamp_seconds{venue} +// perp_protocol_registry_last_updated_timestamp_seconds +// perp_protocol_health{venue} +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + fmt.Println("=== perp-protocol-longevity harness ===") + fmt.Println("Bench 119 -- days clean counter from embedded incident registry.") + fmt.Printf("Registry date: %s. Venues: %d.\n", registryUpdatedAt.Format("2006-01-02"), len(registry)) + fmt.Println("Exposes /metrics, /health on :2112.") + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + go func() { + if err := StartMetricsServer(":2112"); err != nil { + fmt.Printf("metrics server error: %v\n", err) + os.Exit(1) + } + }() + + // Publish immediately, then refresh the day counter every hour. + publishAll() + logCurrentCounts() + + tick := time.NewTicker(time.Hour) + defer tick.Stop() + + for { + select { + case <-sigChan: + fmt.Println("shutting down") + return + case <-tick.C: + publishAll() + logCurrentCounts() + } + } +} + +func logCurrentCounts() { + now := float64(time.Now().Unix()) + for _, vr := range registry { + streakStart := float64(cleanStreakStart(vr)) + days := (now - streakStart) / 86400 + if len(vr.Incidents) == 0 { + fmt.Printf("[LONGEVITY][%s] %.1f days clean since launch (%s), 0 incidents\n", + vr.Slug, days, vr.Launched.Format("2006-01-02")) + } else { + fmt.Printf("[LONGEVITY][%s] %.1f days since last incident, %d total incident(s), $%.0f lost\n", + vr.Slug, days, len(vr.Incidents), totalIncidentAmount(vr)) + } + } +} diff --git a/harnesses/perp-protocol-longevity/cmd/script/metrics.go b/harnesses/perp-protocol-longevity/cmd/script/metrics.go new file mode 100644 index 00000000..45879582 --- /dev/null +++ b/harnesses/perp-protocol-longevity/cmd/script/metrics.go @@ -0,0 +1,84 @@ +package main + +import ( + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + daysCleanGauge *prometheus.GaugeVec + incidentsTotalGauge *prometheus.GaugeVec + incidentAmountGauge *prometheus.GaugeVec + launchDayGauge *prometheus.GaugeVec + registryTimestampGauge prometheus.Gauge + healthGauge *prometheus.GaugeVec +) + +func init() { + daysCleanGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_protocol_days_clean", + Help: "Days since the last recorded security exploit (or since launch if none). Higher is better.", + }, []string{"venue"}) + prometheus.MustRegister(daysCleanGauge) + + incidentsTotalGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_protocol_incidents_total", + Help: "Total number of recorded security incidents for this venue per the embedded registry.", + }, []string{"venue"}) + prometheus.MustRegister(incidentsTotalGauge) + + incidentAmountGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_protocol_incident_amount_usd", + Help: "Cumulative USD lost in recorded security incidents. 0 if none.", + }, []string{"venue"}) + prometheus.MustRegister(incidentAmountGauge) + + launchDayGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_protocol_launch_timestamp_seconds", + Help: "Unix timestamp of the protocol's mainnet launch date.", + }, []string{"venue"}) + prometheus.MustRegister(launchDayGauge) + + registryTimestampGauge = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "perp_protocol_registry_last_updated_timestamp_seconds", + Help: "Unix timestamp when the embedded incident registry was last updated.", + }) + prometheus.MustRegister(registryTimestampGauge) + + healthGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "perp_protocol_health", + Help: "Always 1 for this harness (no external calls, computed from embedded registry).", + }, []string{"venue"}) + prometheus.MustRegister(healthGauge) +} + +// registryUpdatedAt is the date the embedded registry was last reviewed. +var registryUpdatedAt = time.Date(2026, 8, 2, 0, 0, 0, 0, time.UTC) + +func publishAll() { + registryTimestampGauge.Set(float64(registryUpdatedAt.Unix())) + now := float64(time.Now().Unix()) + for _, vr := range registry { + streakStart := float64(cleanStreakStart(vr)) + days := (now - streakStart) / 86400 + daysCleanGauge.WithLabelValues(vr.Slug).Set(days) + incidentsTotalGauge.WithLabelValues(vr.Slug).Set(float64(len(vr.Incidents))) + incidentAmountGauge.WithLabelValues(vr.Slug).Set(totalIncidentAmount(vr)) + launchDayGauge.WithLabelValues(vr.Slug).Set(float64(vr.Launched.Unix())) + healthGauge.WithLabelValues(vr.Slug).Set(1) + } +} + +// StartMetricsServer starts the Prometheus /metrics endpoint on addr. +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/perp-protocol-longevity/cmd/script/registry.go b/harnesses/perp-protocol-longevity/cmd/script/registry.go new file mode 100644 index 00000000..548f2847 --- /dev/null +++ b/harnesses/perp-protocol-longevity/cmd/script/registry.go @@ -0,0 +1,108 @@ +package main + +import "time" + +// incidentRecord is one confirmed security incident for a venue. +// Only events resulting in direct theft or permanent loss of user funds +// via a protocol vulnerability are included. +type incidentRecord struct { + Date time.Time + AmountUSD float64 + Kind string // "exploit", "oracle-manipulation", "admin-key" + Source string // public post-mortem URL +} + +// venueRecord holds all known incidents for a venue plus its launch date. +type venueRecord struct { + Slug string + Name string + Launched time.Time + Incidents []incidentRecord +} + +// Registry version: 2026-08-02. Updated from rekt.news and public post-mortems. +// A venue with no incidents uses its launch date as the clean-streak start. +// Incidents excluded per methodology: front-end phishing, market-structure +// events (liquidation cascades, forced close, governance disputes), oracle +// price manipulation without a smart contract vulnerability. +var registry = []venueRecord{ + { + Slug: "gains", + Name: "gains.trade", + Launched: time.Date(2021, 12, 1, 0, 0, 0, 0, time.UTC), + // No incidents recorded in rekt.news or public post-mortems as of 2026-08-02. + // The synthetic architecture (oracle pricing, no custodial vault drainable + // without position netting, no privileged admin key on critical paths) + // reduces the attack surface relative to AMM-based vault protocols. + Incidents: []incidentRecord{}, + }, + { + Slug: "gmx", + Name: "GMX v2", + Launched: time.Date(2023, 8, 1, 0, 0, 0, 0, time.UTC), + Incidents: []incidentRecord{ + { + Date: time.Date(2025, 7, 9, 0, 0, 0, 0, time.UTC), + AmountUSD: 42_000_000, + Kind: "exploit", + Source: "https://coinperps.xyz/gmx-v2-exploit-july-2025", + }, + }, + }, + { + Slug: "hyperliquid", + Name: "Hyperliquid", + Launched: time.Date(2023, 11, 1, 0, 0, 0, 0, time.UTC), + // No protocol exploit recorded. The March 2025 JellyJelly event was a + // market-structure incident (large-position liquidation cascade triggering + // a validator committee emergency close). No smart contract was compromised + // and no user funds were stolen via a protocol vulnerability. Excluded per + // the methodology's incident criteria. + Incidents: []incidentRecord{}, + }, + { + Slug: "dydx", + Name: "dYdX v4", + Launched: time.Date(2023, 10, 1, 0, 0, 0, 0, time.UTC), + // No exploit on the v4 Cosmos appchain deployment. dYdX v1/v2 on Ethereum + // had oracle manipulation events in 2020-2021 but those ran on a different + // contract and are out of scope for the v4 deployment tracked here. + Incidents: []incidentRecord{}, + }, + { + Slug: "lighter", + Name: "Lighter", + Launched: time.Date(2023, 7, 1, 0, 0, 0, 0, time.UTC), + Incidents: []incidentRecord{}, + }, + { + Slug: "paradex", + Name: "Paradex", + Launched: time.Date(2023, 10, 1, 0, 0, 0, 0, time.UTC), + Incidents: []incidentRecord{}, + }, +} + +// cleanStreakStart returns the Unix timestamp from which the clean streak is +// measured: the launch date if no incidents, or the last incident date. +func cleanStreakStart(vr venueRecord) int64 { + if len(vr.Incidents) == 0 { + return vr.Launched.Unix() + } + last := vr.Incidents[0].Date + for _, inc := range vr.Incidents[1:] { + if inc.Date.After(last) { + last = inc.Date + } + } + return last.Unix() +} + +// totalIncidentAmount returns the sum of USD losses across all recorded incidents. +func totalIncidentAmount(vr venueRecord) float64 { + var total float64 + for _, inc := range vr.Incidents { + total += inc.AmountUSD + } + return total +} diff --git a/harnesses/perp-protocol-longevity/go.mod b/harnesses/perp-protocol-longevity/go.mod new file mode 100644 index 00000000..00a10d93 --- /dev/null +++ b/harnesses/perp-protocol-longevity/go.mod @@ -0,0 +1,5 @@ +module perp-protocol-longevity + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2