diff --git a/.github/workflows/sync-dev-from-main.yml b/.github/workflows/sync-dev-from-main.yml new file mode 100644 index 00000000..afce609b --- /dev/null +++ b/.github/workflows/sync-dev-from-main.yml @@ -0,0 +1,109 @@ +name: Sync dev from main + +# Purpose +# ------- +# The workflow enforces the AGENTS.md rule "immediately fast-forward dev +# from main after a hotfix". Every hotfix that lands on main used to +# require a manual `git checkout dev && git merge --ff-only main && git +# push` step; that step was skipped often enough for dev to drift 100+ +# commits behind main, silently rotting the staging environment. +# +# On every push to main this job: +# 1. Tries a fast-forward of dev to main. If it succeeds, dev is now +# identical to main and the staging deploy fires as usual (its +# workflow triggers on push to dev). +# 2. If a fast-forward isn't possible (dev has commits main doesn't), +# the job attempts a real merge commit. Clean auto-merge = pushed +# straight to dev. +# 3. Only when git itself can't resolve the merge (real conflicts) do +# we fall back to opening a "sync/main-to-dev-" branch + a PR +# against dev. A human resolves those conflicts by merging the PR. +# +# The job is intentionally lenient: pushing to dev directly is normally +# forbidden (AGENTS.md), but this bot is the one exception because it +# only carries commits that main has already accepted. + +on: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: sync-dev-from-main + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout full history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: dev + + - name: Configure bot identity + run: | + git config user.name "ocb-sync-bot" + git config user.email "ocb-sync-bot@users.noreply.github.com" + + - name: Try fast-forward dev → main + id: ff + run: | + set -e + git fetch origin main:refs/remotes/origin/main + if git merge-base --is-ancestor origin/main HEAD; then + echo "already-in-sync=true" >> "$GITHUB_OUTPUT" + echo "dev already contains origin/main; nothing to do." + exit 0 + fi + if git merge --ff-only origin/main; then + git push origin dev + echo "ff-succeeded=true" >> "$GITHUB_OUTPUT" + echo "dev fast-forwarded to $(git rev-parse --short HEAD)" + exit 0 + fi + echo "ff-failed=true" >> "$GITHUB_OUTPUT" + + - name: Attempt clean merge commit + if: steps.ff.outputs.ff-failed == 'true' + id: merge + run: | + set -e + if git merge --no-edit --no-ff origin/main -m "sync: main → dev (auto-merge $(git rev-parse --short origin/main))"; then + git push origin dev + echo "merge-pushed=true" >> "$GITHUB_OUTPUT" + echo "dev auto-merged main at $(git rev-parse --short HEAD)" + exit 0 + fi + git merge --abort + echo "merge-failed=true" >> "$GITHUB_OUTPUT" + + - name: Open sync PR on conflict + if: steps.merge.outputs.merge-failed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + SHORT=$(git rev-parse --short origin/main) + BRANCH="sync/main-to-dev-$SHORT" + git checkout -B "$BRANCH" + # Force a merge commit that stops on conflicts. We DO NOT + # push conflict markers to dev; instead we push a branch that + # a human can pull, resolve locally, and merge into dev. + git reset --hard dev + git merge --no-edit --no-ff origin/main || true + git add -A + git commit -m "sync: main → dev (CONFLICTS — resolve before merge)" || true + git push -f origin "$BRANCH" + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base dev \ + --head "$BRANCH" \ + --title "sync: main → dev (auto, needs conflict resolution)" \ + --body "Automated by \`.github/workflows/sync-dev-from-main.yml\`. Fast-forward + auto-merge both failed; a human needs to resolve conflicts and merge this PR into \`dev\`. Base main SHA: $SHORT." \ + || echo "PR already exists for $BRANCH; leaving it alone." diff --git a/answers/which-prediction-market-data-api-is-the-freshest.yml b/answers/which-prediction-market-data-api-is-the-freshest.yml index 57af902b..b985e0d4 100644 --- a/answers/which-prediction-market-data-api-is-the-freshest.yml +++ b/answers/which-prediction-market-data-api-is-the-freshest.yml @@ -1,15 +1,15 @@ slug: which-prediction-market-data-api-is-the-freshest question: "Which prediction market data API publishes the freshest Polymarket data?" short_answer: | - {{best_name}} currently relays Polymarket trades the fastest at {{best_p50}} (p50, 24h) versus Polymarket's own CLOB WebSocket gateway, measured by cross correlating trade events on Polymarket, Mobula and Codex on the same rotating basket of top 20 markets by 24 hour volume. + {{best_name}} currently relays Polymarket trades the fastest at {{best_p50}} (p50, 24h) versus Polymarket's own CLOB WebSocket gateway, measured by cross correlating trade events on Polymarket and Codex on the same rotating basket of top 20 markets by 24 hour volume. benchmark: pm-data-freshness intro: | - Prediction markets produce the most time sensitive event stream in crypto. An election market settles in seconds, a sports book moves on every play, and the price tick a trading UI shows is only as fresh as the API behind it. Builders integrating Polymarket through a data provider rather than hitting the CLOB directly need to know how many milliseconds that provider adds between Polymarket publishing a trade and the provider relaying the same trade to its WebSocket subscribers. This page answers exactly that. The OpenChainBench pm-data-freshness harness holds three WebSocket subscribers in parallel: Polymarket's own CLOB gateway (the canonical T0), Mobula's PM WebSocket, and Codex GraphQL subscriptions, all subscribed to the same basket of top 20 Polymarket markets by 24 h volume, refreshed every 5 minutes. Each trade is matched across providers by a tuple of conditionId, price rounded to 3 decimals, trade size and a 5 second time bucket; the per provider lag versus Polymarket's gateway publish time is recorded as a Prometheus histogram and the leaderboard ranks by p50 in milliseconds, lower is fresher. + Prediction markets produce the most time sensitive event stream in crypto. An election market settles in seconds, a sports book moves on every play, and the price tick a trading UI shows is only as fresh as the API behind it. Builders integrating Polymarket through a data provider rather than hitting the CLOB directly need to know how many milliseconds that provider adds between Polymarket publishing a trade and the provider relaying the same trade to its WebSocket subscribers. This page answers exactly that. The OpenChainBench pm-data-freshness harness holds two WebSocket subscribers in parallel: Polymarket's own CLOB gateway (the canonical T0) and Codex GraphQL subscriptions, both subscribed to the same basket of top 20 Polymarket markets by 24 h volume, refreshed every 5 minutes. Each trade is matched across providers by a tuple of conditionId, price rounded to 3 decimals, trade size and a 5 second time bucket; the per provider lag versus Polymarket's gateway publish time is recorded as a Prometheus histogram and the leaderboard ranks by p50 in milliseconds, lower is fresher. methodology: | - Three WebSocket clients run in parallel inside the harness on a Railway europe west 4 instance. Polymarket's own gateway (`wss://ws-subscriptions-clob.polymarket.com/ws/market`, public, no auth, sub 50 ms publish latency from EU West) is the canonical T0 by construction because nothing downstream can be faster than the source. Mobula's PM WebSocket (`wss://pm-api-prod-eu.mobula.io`) requires an API key in the subscribe payload and a browser User Agent on the upgrade request (Cloudflare on the gateway silently filters the default Go HTTP UA). Codex GraphQL (`wss://graph.codex.io/graphql` with the `graphql-transport-ws` subprotocol) runs an `onPredictionTradesCreated` firehose subscription filtered client side to the Polymarket protocol marketIds in the active basket. The cross correlation key is `(conditionId, priceUSD times 1000 rounded, sizeUSD times 1 million rounded, floor(trade_time / 5s))`; the 5 second bucket absorbs minor clock skew between gateways without merging unrelated trades. Histogram buckets run 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000 ms. Providers that fail to relay a matched trade within 90 seconds are not counted toward p50, only toward their receive total, so a provider cannot look fresh on the leaderboard by silently dropping events; the success rate column surfaces that. + Two WebSocket clients run in parallel inside the harness on a Railway europe west 4 instance. Polymarket's own gateway (`wss://ws-subscriptions-clob.polymarket.com/ws/market`, public, no auth, sub 50 ms publish latency from EU West) is the canonical T0 by construction because nothing downstream can be faster than the source. Codex GraphQL (`wss://graph.codex.io/graphql` with the `graphql-transport-ws` subprotocol) runs an `onPredictionTradesCreated` firehose subscription filtered client side to the Polymarket protocol marketIds in the active basket. The cross correlation key is `(conditionId, priceUSD times 1000 rounded, sizeUSD times 1 million rounded, floor(trade_time / 5s))`; the 5 second bucket absorbs minor clock skew between gateways without merging unrelated trades. Histogram buckets run 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000 ms. Providers that fail to relay a matched trade within 90 seconds are not counted toward p50, only toward their receive total, so a provider cannot look fresh on the leaderboard by silently dropping events; the success rate column surfaces that. limitations: - "Polymarket's own gateway is the canonical T0 by construction. Its row on the leaderboard sits near zero because the harness compares everything else against it, not because Polymarket has solved network latency; the displayed sub millisecond value is a floor (vector(0.5)) so the row stays visible on the linear chart axis next to the multi second Codex value." @@ -20,11 +20,9 @@ limitations: faq: - q: "Which Polymarket data API has the lowest latency right now?" - a: "{{best_name}} currently leads at {{best_p50}} (p50 over the last 24 hours), measured as wall clock time from Polymarket's own CLOB WebSocket publish to provider relay on the same trade. The leaderboard re sorts every minute on fresh Prometheus samples, so the answer reflects the actual measured lag on the active market basket, not a marketing claim. Mobula's edge cached relay typically clocks tens of milliseconds; Codex's on chain Polygon indexer carries a structural floor near 2 seconds because it ingests block confirmations." + a: "{{best_name}} currently leads at {{best_p50}} (p50 over the last 24 hours), measured as wall clock time from Polymarket's own CLOB WebSocket publish to provider relay on the same trade. The leaderboard re sorts every minute on fresh Prometheus samples, so the answer reflects the actual measured lag on the active market basket, not a marketing claim. Codex's on chain Polygon indexer carries a structural floor near 2 seconds because it ingests block confirmations." - q: "What does freshness delta mean for a prediction market API?" a: "The bench connects to Polymarket's own CLOB WebSocket and to each provider's WebSocket simultaneously, subscribes to the same markets, and for every trade event records how many milliseconds the provider takes to relay the event after Polymarket itself publishes it. Lower is better. Polymarket's own gateway publish time is the canonical T0 because by construction nothing downstream can be faster than the source. The cross correlation key (conditionId, price, size, 5 s bucket) survives minor clock skew between gateways without merging unrelated trades." - - q: "Is Mobula's PM WebSocket faster than Codex?" - a: "Yes, by a structural margin, because the two providers do fundamentally different things under the hood. Mobula's PM WebSocket is an edge cached relay of Polymarket's own gateway, so its p50 delta is roughly the network round trip between the two gateways plus a few milliseconds of bookkeeping. Codex ingests the on chain Polygon confirmation of each trade, which adds Polygon block time (around 2 seconds) before any trade can be relayed. For a live UI, Mobula's path wins on freshness; for on chain reconciliation or settlement workflows, Codex's chain indexed path is what you actually want. The leaderboard surfaces both numbers honestly." - q: "Why not include Polymarket REST polling on this bench?" a: "Freshness is a WebSocket question. REST polling at 1 second cadence would have a floor freshness near 500 ms (poll interval divided by 2) plus round trip time, dominated by how often you poll. The Polymarket gateway WebSocket exists for exactly this reason, to avoid that floor. Adding REST as a row would make the leaderboard noisy without changing the conclusion: WebSocket beats polling by definition for real time data." - q: "Are these numbers comparable to Kalshi or Limitless?" @@ -40,5 +38,5 @@ related: - which-crypto-price-api-is-the-fastest seo_title: "Which prediction market data API is the freshest in 2026?" -seo_description: "{{best_name}} leads at {{best_p50}} (p50, 24h) versus Polymarket's CLOB gateway, with Mobula and Codex measured live by OpenChainBench on the top 20 Polymarket markets by volume." +seo_description: "{{best_name}} leads at {{best_p50}} (p50, 24h) versus Polymarket's CLOB gateway, with Codex measured live by OpenChainBench on the top 20 Polymarket markets by volume." status: live diff --git a/benchmarks/network-fees.yml b/benchmarks/network-fees.yml index 730f3900..cedc60ba 100644 --- a/benchmarks/network-fees.yml +++ b/benchmarks/network-fees.yml @@ -163,6 +163,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="ethereum",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="ethereum",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="ethereum"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="ethereum",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="ethereum",tier="std"} - slug: bnb @@ -176,6 +177,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="bnb",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="bnb",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="bnb"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="bnb",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="bnb",tier="std"} - slug: solana @@ -189,6 +191,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="solana",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="solana",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="solana"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="solana",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="solana",tier="std"} - slug: tron @@ -202,6 +205,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h]) success: avg_over_time(tx_fee_health{chain="tron"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="tron",tier="single"}[7d]) series: tx_fee_native_transfer_usd{chain="tron",tier="single"} - slug: cardano @@ -215,6 +219,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h]) success: avg_over_time(tx_fee_health{chain="cardano"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="cardano",tier="single"}[7d]) series: tx_fee_native_transfer_usd{chain="cardano",tier="single"} - slug: sui @@ -228,6 +233,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="sui"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="sui",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="sui",tier="std"} - slug: litecoin @@ -241,6 +247,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="litecoin",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="litecoin",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="litecoin"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="litecoin",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="litecoin",tier="std"} - slug: monero @@ -254,6 +261,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="monero",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="monero",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="monero"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="monero",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="monero",tier="std"} - slug: arbitrum @@ -267,6 +275,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="arbitrum",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="arbitrum",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="arbitrum"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="arbitrum",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="arbitrum",tier="std"} - slug: robinhood name: Robinhood Chain @@ -279,6 +288,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="robinhood",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="robinhood",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="robinhood"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="robinhood",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="robinhood",tier="std"} - slug: base name: Base @@ -291,6 +301,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="base",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="base",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="base"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="base",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="base",tier="std"} - slug: zksync name: zkSync Era @@ -303,6 +314,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="zksync",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="zksync",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="zksync"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="zksync",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="zksync",tier="std"} - slug: linea name: Linea @@ -315,6 +327,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="linea",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="linea",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="linea"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="linea",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="linea",tier="std"} - slug: mantle name: Mantle @@ -327,6 +340,7 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="mantle",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="mantle",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="mantle"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="mantle",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="mantle",tier="std"} - slug: taiko name: Taiko @@ -339,4 +353,5 @@ providers: p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="taiko",tier="fast"}[24h]) mean: avg_over_time(tx_fee_native_transfer_usd{chain="taiko",tier="std"}[24h]) success: avg_over_time(tx_fee_health{chain="taiko"}[24h]) + sample_size: count_over_time(tx_fee_native_transfer_usd{chain="taiko",tier="std"}[7d]) series: tx_fee_native_transfer_usd{chain="taiko",tier="std"} diff --git a/benchmarks/perp-execution-quality.yml b/benchmarks/perp-execution-quality.yml index 3809f50b..9f28c266 100644 --- a/benchmarks/perp-execution-quality.yml +++ b/benchmarks/perp-execution-quality.yml @@ -4,8 +4,8 @@ slug: perp-execution-quality number: "054" title: Best DEX for a $BOT market order, live slippage ranked seo_title: "Best DEX to trade $BOT, live orderbook slippage" -seo_description: "{{best_name}} leads $BOT slippage at {{best_p50}} for a $10,000 buy (24h avg). Lighter vs Hyperliquid HIP-3 (xyz:BOT), measured live from public orderbooks every 30 seconds." -subtitle: Effective slippage in basis points for a $10,000 market buy of $BOT perps, walked live on each DEX orderbook every 30 seconds. Lower means the venue fills the size closer to mid. Lighter native and Hyperliquid HIP-3 (xyz:BOT) are the two DEX venues currently listing $BOT. +seo_description: "{{best_name}} leads $BOT slippage at {{best_p50}} for a $5,000 buy (24h avg). Lighter vs Hyperliquid HIP-3 (xyz:BOT), measured live from public orderbooks every 30 seconds." +subtitle: Effective slippage in basis points for a $5,000 market buy of $BOT perps, walked live on each DEX orderbook every 30 seconds. Lower means the venue fills the size closer to mid. Lighter native and Hyperliquid HIP-3 (xyz:BOT) are the two DEX venues currently listing $BOT. category: Trading status: live metric: Effective slippage @@ -23,7 +23,7 @@ seo_intro: | of USD notionals (from $100 up to $500,000), and publishes the effective slippage in basis points as a live gauge per venue, per side and per size. The headline number is the 24 hour average at the - $10,000 buy bucket, which is the size a mid-sized $BOT trader is most + $5,000 buy bucket, chosen to match the reliable book depth of the thinner $BOT venue (Hyperliquid HIP-3 xyz:BOT fills $5k in 100% of ticks and $10k in only ~23%) likely to execute at market. CEX venues are not part of this bench; DefiLlama covers the CEX side of the audit. @@ -49,14 +49,14 @@ methodology: - "Slippage bps: (avg_execution_price - mid) / mid x 10000 on the buy side, mirrored on the sell side, sign-normalized so every published value represents cost to the trader." - "Buckets published: $100, $1,000, $5,000, $10,000, $25,000, $50,000, $100,000, $500,000. Both sides (buy, sell)." - "Depth fallback: if the visible book does not cover 99% of the requested size, the slippage series for that (venue, side, size) is dropped for that tick and `perp_execution_max_fillable_usd{side}` reports the ceiling instead." - - "Headline. avg_over_time of `perp_execution_slippage_bps{asset=\"BOT\", side=\"buy\", size_usd=\"10000\"}` over 24h, per venue. A one-print spike does not move the ranking." + - "Headline. avg_over_time of `perp_execution_slippage_bps{asset=\"BOT\", side=\"buy\", size_usd=\"5000\"}` over 24h, per venue. A one-print spike does not move the ranking." - "Failures. A venue that errors or times out drops its `perp_execution_health` to 0 and increments `perp_execution_fetch_errors_total{error_type}`; the leaderboard tags it as stale." - "CEX venues are intentionally out of scope. Traders comparing against a Binance or Bybit fill should read DefiLlama for the CEX-side audit; the DEX bench does not aggregate what it cannot orderbook-walk from a public endpoint." findings: - - "{{best_name}} currently leads at {{best_p50}} of slippage on a $10,000 $BOT buy (24 h average) between the {{count}} DEX venues listing $BOT." - - "{{name:lighter}} sits at {{p50:lighter}} on the same $10,000 bucket. Fully onchain orderbook on zk-rollup, zero taker fee, so the number is essentially the crossed half-spread plus depth impact." - - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} on the same $10,000 bucket. HIP-3 markets settle through the same HyperBFT orderbook engine as native Hyperliquid perps but liquidity is provided by the deployer-affiliated market maker, so depth can be thinner than on a native flagship pair." + - "{{best_name}} currently leads at {{best_p50}} of slippage on a $5,000 $BOT buy (24 h average) between the {{count}} DEX venues listing $BOT." + - "{{name:lighter}} sits at {{p50:lighter}} on the same $5,000 bucket. Fully onchain orderbook on zk-rollup, zero taker fee, so the number is essentially the crossed half-spread plus depth impact." + - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} on the same $5,000 bucket. HIP-3 markets settle through the same HyperBFT orderbook engine as native Hyperliquid perps but liquidity is provided by the deployer-affiliated market maker, so depth can be thinner than on a native flagship pair." - "Both venues publish additional size buckets ($100 up to $500,000, both sides). The full grid is exposed via the raw `perp_execution_slippage_bps` series with the `size_usd` and `side` labels." - "$BOT is not currently listed on any other decentralized perp venue with a public orderbook endpoint. New listings will show up here automatically once their `(asset, venue)` pair is added to the harness config." @@ -68,7 +68,7 @@ prometheus: faq: - q: "Which DEX has the best execution for a $BOT market order right now?" - a: "{{best_name}} currently leads at {{best_p50}} of slippage on a $10,000 buy (24 h average). The leaderboard re-sorts every 30 seconds against fresh Prometheus samples so the answer reflects the last day of live orderbook walks on Lighter and Hyperliquid HIP-3 (`xyz:BOT`), not a marketing snapshot." + a: "{{best_name}} currently leads at {{best_p50}} of slippage on a $5,000 buy (24 h average). The leaderboard re-sorts every 30 seconds against fresh Prometheus samples so the answer reflects the last day of live orderbook walks on Lighter and Hyperliquid HIP-3 (`xyz:BOT`), not a marketing snapshot." - q: "Why is $BOT only tracked on two venues?" a: "Because those are the only two decentralized perp venues currently listing $BOT with a public orderbook endpoint: Lighter (market_id 185) and Hyperliquid HIP-3 under the `xyz:BOT` coin symbol. Native Hyperliquid does not have $BOT listed, so `coin=\"BOT\"` on `/info` returns nothing; the HIP-3 permissionless listing is the only path for $BOT on Hyperliquid infrastructure today." - q: "What is HIP-3 and how is `xyz:BOT` different from a native Hyperliquid perp?" @@ -86,15 +86,15 @@ providers: - slug: lighter name: Lighter tag: zk-rollup perp DEX, fully onchain orderbook - formula: "Average over 24h of the effective slippage in bps for a $10,000 buy on Lighter's native $BOT orderbook, walked live every 30 seconds from `/api/v1/orderBookOrders?market_id=185`." + formula: "Average over 24h of the effective slippage in bps for a $5,000 buy on Lighter's native $BOT orderbook, walked live every 30 seconds from `/api/v1/orderBookOrders?market_id=185`." queries: - p50: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"}[24h]) - p90: quantile_over_time(0.90, perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"}[24h]) - p99: quantile_over_time(0.99, perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"}[24h]) - mean: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"}[24h]) + p50: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="5000"}[24h]) + p90: quantile_over_time(0.90, perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="5000"}[24h]) + p99: quantile_over_time(0.99, perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="5000"}[24h]) + mean: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="5000"}[24h]) success: avg_over_time(perp_execution_health{asset="BOT", venue="lighter"}[24h]) - sample_size: count_over_time(perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"}[24h]) - series: perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"} + sample_size: count_over_time(perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="5000"}[24h]) + series: perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="5000"} - slug: hyperliquid # name must stay the venue brand: provider profiles prefer bench spec @@ -102,12 +102,12 @@ providers: # /products/hyperliquid page title. The market detail lives in the tag. name: Hyperliquid tag: HIP-3 permissionless perp, xyz:BOT market - formula: "Average over 24h of the effective slippage in bps for a $10,000 buy on the Hyperliquid HIP-3 `xyz:BOT` orderbook, walked live every 30 seconds from `POST /info {type:l2Book, coin:\"xyz:BOT\"}`." + formula: "Average over 24h of the effective slippage in bps for a $5,000 buy on the Hyperliquid HIP-3 `xyz:BOT` orderbook, walked live every 30 seconds from `POST /info {type:l2Book, coin:\"xyz:BOT\"}`." queries: - p50: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) - p90: quantile_over_time(0.90, perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) - p99: quantile_over_time(0.99, perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) - mean: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) + p50: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="5000"}[24h]) + p90: quantile_over_time(0.90, perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="5000"}[24h]) + p99: quantile_over_time(0.99, perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="5000"}[24h]) + mean: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="5000"}[24h]) success: avg_over_time(perp_execution_health{asset="BOT", venue="hyperliquid"}[24h]) - sample_size: count_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) - series: perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"} + sample_size: count_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="5000"}[24h]) + series: perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="5000"} diff --git a/benchmarks/perp-fees.yml b/benchmarks/perp-fees.yml index db6aff79..951839ac 100644 --- a/benchmarks/perp-fees.yml +++ b/benchmarks/perp-fees.yml @@ -241,6 +241,32 @@ providers: sample_size: count_over_time(perp_fees_all_in_bps{venue="extended"}[24h]) series: perp_fees_all_in_bps{venue="extended"} + - slug: aster + name: Aster + tag: BSC perp DEX, 5 bps taker, Binance-style REST book + formula: "Average over 24h of (5 bps documented base taker fee plus half-spread and impact from the Aster fapi depth endpoint asks walked for $1000 of buy notional in the selected asset), in bps." + queries: + p50: avg_over_time(perp_fees_all_in_bps{venue="aster"}[24h]) + p90: quantile_over_time(0.90, perp_fees_all_in_bps{venue="aster"}[24h]) + p99: quantile_over_time(0.99, perp_fees_all_in_bps{venue="aster"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps{venue="aster"}[24h]) + success: avg_over_time(perp_fees_health{venue="aster"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps{venue="aster"}[24h]) + series: perp_fees_all_in_bps{venue="aster"} + + - slug: edgex + name: edgeX + tag: Offchain CLOB perp DEX, 3.8 bps taker + formula: "Average over 24h of (3.8 bps documented base taker fee plus half-spread and impact from the edgeX public getDepth endpoint asks walked for $1000 of buy notional in the selected asset), in bps." + queries: + p50: avg_over_time(perp_fees_all_in_bps{venue="edgex"}[24h]) + p90: quantile_over_time(0.90, perp_fees_all_in_bps{venue="edgex"}[24h]) + p99: quantile_over_time(0.99, perp_fees_all_in_bps{venue="edgex"}[24h]) + mean: avg_over_time(perp_fees_all_in_bps{venue="edgex"}[24h]) + success: avg_over_time(perp_fees_health{venue="edgex"}[24h]) + sample_size: count_over_time(perp_fees_all_in_bps{venue="edgex"}[24h]) + series: perp_fees_all_in_bps{venue="edgex"} + # Notional-tier companion panels. The panel metric leaves the chain label # unpinned and wraps the selector in avg(), so the ETH / BTC / SOL tab # filter is injected exactly like on the headline queries and the panels diff --git a/benchmarks/solana-rpc.yml b/benchmarks/solana-rpc.yml index bce3f755..72cf7d9a 100644 --- a/benchmarks/solana-rpc.yml +++ b/benchmarks/solana-rpc.yml @@ -75,7 +75,7 @@ dimensions: providers: - slug: solana - name: Solana Labs + name: Solana tag: Chain-official api.mainnet-beta.solana.com, 100 req per 10s documented formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `getSlot` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to api.mainnet-beta.solana.com." queries: diff --git a/benchmarks/token-deployment-cost.yml b/benchmarks/token-deployment-cost.yml index 38f3af8c..8d970745 100644 --- a/benchmarks/token-deployment-cost.yml +++ b/benchmarks/token-deployment-cost.yml @@ -95,6 +95,7 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="solana"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="solana"}[24h]) series: token_deployment_cost_usd{chain="solana"} + sample_size: count_over_time(token_deployment_cost_usd{chain="solana"}[7d]) - slug: sui name: Sui @@ -107,6 +108,7 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="sui"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="sui"}[24h]) series: token_deployment_cost_usd{chain="sui"} + sample_size: count_over_time(token_deployment_cost_usd{chain="sui"}[7d]) - slug: aptos name: Aptos @@ -119,6 +121,7 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="aptos"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="aptos"}[24h]) series: token_deployment_cost_usd{chain="aptos"} + sample_size: count_over_time(token_deployment_cost_usd{chain="aptos"}[7d]) - slug: osmosis name: Osmosis @@ -131,6 +134,7 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="osmosis"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="osmosis"}[24h]) series: token_deployment_cost_usd{chain="osmosis"} + sample_size: count_over_time(token_deployment_cost_usd{chain="osmosis"}[7d]) - slug: injective name: Injective @@ -143,6 +147,7 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="injective"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="injective"}[24h]) series: token_deployment_cost_usd{chain="injective"} + sample_size: count_over_time(token_deployment_cost_usd{chain="injective"}[7d]) - slug: cardano name: Cardano @@ -155,6 +160,7 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="cardano"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="cardano"}[24h]) series: token_deployment_cost_usd{chain="cardano"} + sample_size: count_over_time(token_deployment_cost_usd{chain="cardano"}[7d]) - slug: stellar name: Stellar @@ -167,3 +173,4 @@ providers: p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="stellar"}[24h]) mean: avg_over_time(token_deployment_cost_usd{chain="stellar"}[24h]) series: token_deployment_cost_usd{chain="stellar"} + sample_size: count_over_time(token_deployment_cost_usd{chain="stellar"}[7d]) diff --git a/benchmarks/tokenized-stock-peg.yml b/benchmarks/tokenized-stock-peg.yml index 9fa21efc..dfb89752 100644 --- a/benchmarks/tokenized-stock-peg.yml +++ b/benchmarks/tokenized-stock-peg.yml @@ -98,6 +98,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="nvda"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="regular"} - slug: aapl name: AAPL @@ -109,6 +110,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="aapl"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="regular"} - slug: googl name: GOOGL @@ -120,6 +122,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="googl"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="regular"} - slug: tsla name: TSLA @@ -131,6 +134,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="tsla"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="regular"} - slug: pltr name: PLTR @@ -142,6 +146,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="pltr"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="regular"} - slug: meta name: META @@ -153,6 +158,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="meta"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="regular"} - slug: amd name: AMD @@ -164,6 +170,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="amd"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="regular"} - slug: msft name: MSFT @@ -175,6 +182,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="msft"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="regular"} - slug: amzn name: AMZN @@ -186,6 +194,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="amzn"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="regular"} - slug: spy name: SPY @@ -197,6 +206,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="spy"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="regular"} - slug: mu name: MU @@ -208,4 +218,5 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="mu"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="regular"} diff --git a/benchmarks/tokenized-stock-weekend-drift.yml b/benchmarks/tokenized-stock-weekend-drift.yml index a5835a4b..95a4699d 100644 --- a/benchmarks/tokenized-stock-weekend-drift.yml +++ b/benchmarks/tokenized-stock-weekend-drift.yml @@ -46,7 +46,7 @@ methodology: findings: - "{{best_name}} held tightest last weekend at {{best_p50}} (max drift, bps) across {{count}} tokenized equities." - "{{name:tsla}} ({{p50:tsla}}) has the most active pool of the cohort, and its weekend drift is the closest read on what a 24/7 stock is worth on a Sunday when nobody has a reference to arb against." - - "The largest weekend drifts consistently come from the thin-pool symbols (MSFT, AMZN, MU). That is not a peg failure, it is the arithmetic of a 2 percent fee band on a pool depth in the low tens of thousands." + - "The largest weekend drifts come from the thin-pool symbols (MU, AMD, PLTR, MSFT, AMZN, NVDA) sitting on the 2 percent fee tier at pool depths in the low tens of thousands. That is not a peg failure, it is the arithmetic of a wide fee band with no arbitrageur reference during the 60 hour Nasdaq close." - "Every reading resets at Monday open. The page is the only place that captures the intra-weekend maximum before the reset happens." faq: @@ -57,7 +57,7 @@ faq: - q: "Is a high drift bad?" a: "It reads as inefficient markets on paper, but during the 60 hour Nasdaq close there is no fair value to converge to, and every drift closes at the next open. The number matters most for downstream products (lending, structured payoffs) that use the tokenized price as an oracle when the real market is closed." - q: "Which stocks drift the most on weekends?" - a: "The pattern is fee tier times pool depth. On the current cohort, MSFT and AMZN (2 percent fee tier, sub 50k depth) hit the widest weekend drifts, sometimes several hundred basis points. NVDA and TSLA (0.3 percent fee tier, several hundred thousand of depth) drift the least." + a: "The pattern is fee tier times pool depth. On the current cohort, MU, AMD, PLTR (2 percent fee tier, sub 50k depth) hit the widest weekend drifts, sometimes several hundred basis points. GOOGL, SPY and TSLA (0.3 percent fee tier, several hundred thousand of depth) drift the least." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/tokenized-stock-peg @@ -76,6 +76,7 @@ providers: p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[72h])[7d:6h]) mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[72h])[7d:6h]) success: avg_over_time(tsp_health{asset="nvda"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[7d]) series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="nvda", market_state="closed"}[72h]) - slug: aapl @@ -88,6 +89,7 @@ providers: p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[72h])[7d:6h]) mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[72h])[7d:6h]) success: avg_over_time(tsp_health{asset="aapl"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[7d]) series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="aapl", market_state="closed"}[72h]) - slug: googl @@ -100,6 +102,7 @@ providers: p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[72h])[7d:6h]) mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[72h])[7d:6h]) success: avg_over_time(tsp_health{asset="googl"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[7d]) series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="googl", market_state="closed"}[72h]) - slug: tsla @@ -112,6 +115,7 @@ providers: p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[72h])[7d:6h]) mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[72h])[7d:6h]) success: avg_over_time(tsp_health{asset="tsla"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[7d]) series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="tsla", market_state="closed"}[72h]) - slug: msft @@ -124,6 +128,7 @@ providers: p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[72h])[7d:6h]) mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[72h])[7d:6h]) success: avg_over_time(tsp_health{asset="msft"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[7d]) series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="msft", market_state="closed"}[72h]) - slug: amzn @@ -136,6 +141,7 @@ providers: p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[72h])[7d:6h]) mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[72h])[7d:6h]) success: avg_over_time(tsp_health{asset="amzn"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[7d]) series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amzn", market_state="closed"}[72h]) - slug: meta @@ -148,6 +154,7 @@ providers: p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[72h])[7d:6h]) mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[72h])[7d:6h]) success: avg_over_time(tsp_health{asset="meta"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[7d]) series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="meta", market_state="closed"}[72h]) - slug: amd @@ -160,6 +167,7 @@ providers: p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[72h])[7d:6h]) mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[72h])[7d:6h]) success: avg_over_time(tsp_health{asset="amd"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[7d]) series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="amd", market_state="closed"}[72h]) - slug: pltr @@ -172,6 +180,7 @@ providers: p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[72h])[7d:6h]) mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[72h])[7d:6h]) success: avg_over_time(tsp_health{asset="pltr"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[7d]) series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="pltr", market_state="closed"}[72h]) - slug: spy @@ -184,6 +193,7 @@ providers: p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[72h])[7d:6h]) mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[72h])[7d:6h]) success: avg_over_time(tsp_health{asset="spy"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[7d]) series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="spy", market_state="closed"}[72h]) - slug: mu @@ -196,4 +206,5 @@ providers: p99: quantile_over_time(0.99, max_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[72h])[7d:6h]) mean: avg_over_time(max_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[72h])[7d:6h]) success: avg_over_time(tsp_health{asset="mu"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[7d]) series: max_over_time(tsp_deviation_bps{issuer="robinhood", asset="mu", market_state="closed"}[72h]) diff --git a/benchmarks/usdy-nav-basis.yml b/benchmarks/usdy-nav-basis.yml index 9b756be2..581ceb1d 100644 --- a/benchmarks/usdy-nav-basis.yml +++ b/benchmarks/usdy-nav-basis.yml @@ -76,6 +76,7 @@ providers: p99: quantile_over_time(0.99, abs(usdy_basis_bps{venue="orca-solana"})[24h:]) mean: avg_over_time(abs(usdy_basis_bps{venue="orca-solana"})[24h:]) success: avg_over_time(usdy_health{venue="orca-solana"}[24h]) + sample_size: count_over_time(usdy_basis_bps{venue="orca-solana"}[7d]) series: usdy_basis_bps{venue="orca-solana"} - slug: pyth-market name: Pyth market composite @@ -87,4 +88,5 @@ providers: p99: quantile_over_time(0.99, abs(usdy_basis_bps{venue="pyth-market"})[24h:]) mean: avg_over_time(abs(usdy_basis_bps{venue="pyth-market"})[24h:]) success: avg_over_time(usdy_health{venue="pyth-market"}[24h]) + sample_size: count_over_time(usdy_basis_bps{venue="pyth-market"}[7d]) series: usdy_basis_bps{venue="pyth-market"} diff --git a/benchmarks/validator-yield.yml b/benchmarks/validator-yield.yml index cf714536..ba37a8e2 100644 --- a/benchmarks/validator-yield.yml +++ b/benchmarks/validator-yield.yml @@ -188,6 +188,58 @@ providers: sample_size: ocb_chain_total_validators{chain="ethereum"} series: ocb_chain_median_net_yield_bps{chain="ethereum"} + - slug: cardano + name: Cardano + tag: Top 50 pools by stake · Koios API, MEV n/a + formula: "Median net yield in bps across the top 50 Cardano pools by active stake, where net = ρ × (1 − τ) × (1 − margin) with ρ from Koios epoch_params (monetary_expansion) and τ (treasury_growth_rate). Simplified vs full a0/k/saturation curve." + queries: + p50: ocb_chain_median_net_yield_bps{chain="cardano"} + p90: quantile(0.90, ocb_validator_net_yield_bps{chain="cardano"}) + p99: quantile(0.99, ocb_validator_net_yield_bps{chain="cardano"}) + mean: avg(ocb_validator_net_yield_bps{chain="cardano"}) + success: clamp_max(avg_over_time(ocb_validator_uptime_pct{chain="cardano"}[24h]) / 100, 1) + sample_size: ocb_chain_total_validators{chain="cardano"} + series: ocb_chain_median_net_yield_bps{chain="cardano"} + + - slug: sui + name: Sui + tag: All active validators · suix_getValidatorsApy, no MEV + formula: "Median net APR in bps across all active Sui validators, from suix_getValidatorsApy on a public Sui RPC. apy is already net of commission per Sui docs." + queries: + p50: ocb_chain_median_net_yield_bps{chain="sui"} + p90: quantile(0.90, ocb_validator_net_yield_bps{chain="sui"}) + p99: quantile(0.99, ocb_validator_net_yield_bps{chain="sui"}) + mean: avg(ocb_validator_net_yield_bps{chain="sui"}) + success: clamp_max(avg_over_time(ocb_validator_uptime_pct{chain="sui"}[24h]) / 100, 1) + sample_size: ocb_chain_total_validators{chain="sui"} + series: ocb_chain_median_net_yield_bps{chain="sui"} + + - slug: cosmos-hub + name: Cosmos Hub + tag: Top 200 bonded validators · LCD REST, no MEV + formula: "Per-validator net = network_APR × (1 − commission_rate), where network_APR = inflation × (1 − community_tax) / bonded_ratio. Inflation and bonded_ratio from Cosmos LCD staking + mint modules; community_tax hardcoded to 2%." + queries: + p50: ocb_chain_median_net_yield_bps{chain="cosmos-hub"} + p90: quantile(0.90, ocb_validator_net_yield_bps{chain="cosmos-hub"}) + p99: quantile(0.99, ocb_validator_net_yield_bps{chain="cosmos-hub"}) + mean: avg(ocb_validator_net_yield_bps{chain="cosmos-hub"}) + success: clamp_max(avg_over_time(ocb_validator_uptime_pct{chain="cosmos-hub"}[24h]) / 100, 1) + sample_size: ocb_chain_total_validators{chain="cosmos-hub"} + series: ocb_chain_median_net_yield_bps{chain="cosmos-hub"} + + - slug: avalanche + name: Avalanche + tag: Top 100 P-Chain validators, no MEV + formula: "Per-validator net = base 8.5% APR × uptime × (1 − delegationFee/100), from platform.getCurrentValidators on Avalanche P-Chain. Base APR is the documented protocol constant since P-Chain does not publish realised per-validator APR." + queries: + p50: ocb_chain_median_net_yield_bps{chain="avalanche"} + p90: quantile(0.90, ocb_validator_net_yield_bps{chain="avalanche"}) + p99: quantile(0.99, ocb_validator_net_yield_bps{chain="avalanche"}) + mean: avg(ocb_validator_net_yield_bps{chain="avalanche"}) + success: clamp_max(avg_over_time(ocb_validator_uptime_pct{chain="avalanche"}[24h]) / 100, 1) + sample_size: ocb_chain_total_validators{chain="avalanche"} + series: ocb_chain_median_net_yield_bps{chain="avalanche"} + # Ethereum publishes ONE synthetic series (validator="beacon-network"), # the network-average consensus-layer nominal APR from the beacon spec # reward formula on the live total effective balance (ultrasound.money, diff --git a/benchmarks/ws-head-latency-solana.yml b/benchmarks/ws-head-latency-solana.yml index d2576b05..26ff4d07 100644 --- a/benchmarks/ws-head-latency-solana.yml +++ b/benchmarks/ws-head-latency-solana.yml @@ -112,7 +112,7 @@ providers: series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana", region="sgp"}[1h]))) - slug: solana - name: Solana Foundation + name: Solana tag: Keyless WS, wss://api.mainnet-beta.solana.com formula: "Median ms behind the earliest provider to push each Solana slotSubscribe notification, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. Lag is relative to the per-slot winner." queries: diff --git a/benchmarks/xstocks-peg.yml b/benchmarks/xstocks-peg.yml index 9f7d8d8e..7215b6f8 100644 --- a/benchmarks/xstocks-peg.yml +++ b/benchmarks/xstocks-peg.yml @@ -83,6 +83,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="tsla"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"} - slug: nvda name: NVDAx @@ -94,6 +95,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="nvda"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"} - slug: aapl name: AAPLx @@ -105,6 +107,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="aapl"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"} - slug: msft name: MSFTx @@ -116,6 +119,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="msft"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"} - slug: amzn name: AMZNx @@ -127,6 +131,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="amzn"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"} - slug: googl name: GOOGLx @@ -138,6 +143,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="googl"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"} - slug: meta name: METAx @@ -149,6 +155,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="meta"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"} - slug: hood name: HOODx @@ -160,6 +167,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="hood"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"} - slug: spy name: SPYx @@ -171,6 +179,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="spy"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"} - slug: qqq name: QQQx @@ -182,6 +191,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="qqq"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"} - slug: coin name: COINx @@ -193,6 +203,7 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="coin"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"} - slug: pltr name: PLTRx @@ -204,4 +215,5 @@ providers: p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h]) mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h]) success: avg_over_time(tsp_health{asset="pltr"}[24h]) + sample_size: count_over_time(tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[7d]) series: tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"} diff --git a/harnesses/bridge-monitor/README.md b/harnesses/bridge-monitor/README.md index f2c6d855..e9508e23 100644 --- a/harnesses/bridge-monitor/README.md +++ b/harnesses/bridge-monitor/README.md @@ -16,7 +16,7 @@ Two binaries live here: - **`cmd/monitor/`** (the Dockerfile default) is the continuous quote loop + execution scheduler. It scrapes every bridge for quotes, executes the paid leg on its schedule, and exposes every Prometheus metric the two benches above consume. - **`cmd/rebalance/`** is a one-shot utility that keeps the execution wallets topped up across chains so the paid leg never runs out of inventory mid-cycle. Build separately with `go build ./cmd/rebalance`. -The numbers on openchainbench.com come from an instance of `cmd/monitor/` running on Mobula's Railway infra. Anyone with the wallet capital documented below can clone this directory, fill `.env`, and reproduce them end-to-end. +The numbers on openchainbench.com come from an instance of `cmd/monitor/` running on the OpenChainBench VPS (`ocb-par-main`, docker-compose in `/opt/ocb/`, container `ocb-bridge-monitor`). Anyone with the wallet capital documented below can clone this directory, fill `.env`, and reproduce them end-to-end. ## How it works @@ -32,7 +32,7 @@ Plus daily P&L cron, wallet balance metrics, pre-flight tier simulation, and per This harness is a **data producer only**. it exposes `/metrics` on port `9090` (overridable via `METRICS_PORT`). The shared OpenChainBench Prometheus (see [`/infrastructure/prometheus`](../../infrastructure/prometheus)) scrapes that endpoint: ``` -bridge-monitor.railway.internal:9090 ──► prometheus.railway.internal ──► public site +ocb-bridge-monitor:2112 ──► ocb-prometheus (docker network `web`) ──► public site ``` ## Test routes @@ -117,9 +117,11 @@ EXECUTION_MODE=single-test TEST_AMOUNT_USD=0.50 go run ./cmd/monitor/ EXECUTION_MODE=production go run ./cmd/monitor/ ``` -## Run on Railway +## Run on the OCB VPS -This service is deployed from the OpenChainBench repo, root directory `harnesses/bridge-monitor/`. Set the env vars listed below; the shared Prometheus will pick it up via DNS automatically. +Deployed as the `bridge-monitor` service in `/opt/ocb/docker-compose.yml` on `ocb-par-main`. The image builds from this directory; env file lives at `/run/ocb/.env.bridge-monitor` (SOPS-managed), state persisted at `/data/state/bridge-monitor`. The compose-networked `ocb-prometheus` scrapes `:2112/metrics` automatically. + +Deploy via `rsync` + `docker compose up -d --build bridge-monitor` from a checkout on the VPS. ## Project layout diff --git a/harnesses/rpc-capabilities/cmd/script/config.go b/harnesses/rpc-capabilities/cmd/script/config.go index dc344dcd..da545732 100644 --- a/harnesses/rpc-capabilities/cmd/script/config.go +++ b/harnesses/rpc-capabilities/cmd/script/config.go @@ -238,7 +238,6 @@ func chains() []Chain { {Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_ETHEREUM_NODIES", "https://eth-pokt.nodies.app")}, {Slug: "lava", Name: "Lava Network", URL: envDefault("RPC_URL_ETHEREUM_LAVA", "https://eth1.lava.build")}, {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_ETHEREUM_BLASTAPI", "https://eth-mainnet.public.blastapi.io")}, - {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_ETHEREUM_THIRDWEB", "https://ethereum.rpc.thirdweb.com")}, {Slug: "gatewayfm", Name: "Gateway.fm", URL: envDefault("RPC_URL_ETHEREUM_GATEWAYFM", "https://rpc.eth.gateway.fm")}, {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_ETHEREUM_BLOXROUTE", "https://eth.rpc.blxrbdn.com")}, }, @@ -252,7 +251,6 @@ func chains() []Chain { {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_POLYGON_DRPC", "https://polygon.drpc.org")}, {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_POLYGON_TENDERLY", "https://gateway.tenderly.co/public/polygon")}, {Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_POLYGON_NODIES", "https://polygon-pokt.nodies.app")}, - {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_POLYGON_THIRDWEB", "https://polygon.rpc.thirdweb.com")}, }, }, // ─── Arbitrum One (8 providers) ───────────────────────────── @@ -268,7 +266,6 @@ func chains() []Chain { {Slug: "lava", Name: "Lava Network", URL: envDefault("RPC_URL_ARBITRUM_LAVA", "https://arb1.lava.build")}, {Slug: "arbitrum-official", Name: "Arbitrum Official", URL: envDefault("RPC_URL_ARBITRUM_OFFICIAL", "https://arb1.arbitrum.io/rpc")}, {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_ARBITRUM_BLASTAPI", "https://arbitrum-one.public.blastapi.io")}, - {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_ARBITRUM_THIRDWEB", "https://arbitrum.rpc.thirdweb.com")}, }, }, // ─── Optimism (6 providers) ───────────────────────────────── @@ -281,7 +278,6 @@ func chains() []Chain { {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_OPTIMISM_TENDERLY", "https://gateway.tenderly.co/public/optimism")}, {Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_OPTIMISM_NODIES", "https://op-pokt.nodies.app")}, {Slug: "optimism-official", Name: "Optimism Official", URL: envDefault("RPC_URL_OPTIMISM_OFFICIAL", "https://mainnet.optimism.io")}, - {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_OPTIMISM_THIRDWEB", "https://optimism.rpc.thirdweb.com")}, }, }, // ─── Base (6 providers) ───────────────────────────────────── @@ -295,7 +291,6 @@ func chains() []Chain { {Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_BASE_NODIES", "https://base-pokt.nodies.app")}, {Slug: "base-official", Name: "Base Official", URL: envDefault("RPC_URL_BASE_OFFICIAL", "https://mainnet.base.org")}, {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_BASE_BLASTAPI", "https://base-mainnet.public.blastapi.io")}, - {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_BASE_THIRDWEB", "https://base.rpc.thirdweb.com")}, {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_BASE_BLOXROUTE", "https://base.rpc.blxrbdn.com")}, }, }, @@ -309,7 +304,6 @@ func chains() []Chain { {Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_BNB_NODIES", "https://bsc-pokt.nodies.app")}, {Slug: "binance", Name: "Binance Official", URL: envDefault("RPC_URL_BNB_OFFICIAL", "https://bsc-dataseed1.binance.org")}, {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_BNB_BLASTAPI", "https://bsc-mainnet.public.blastapi.io")}, - {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_BNB_THIRDWEB", "https://bsc.rpc.thirdweb.com")}, {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_BNB_BLOXROUTE", "https://bsc.rpc.blxrbdn.com")}, }, }, @@ -324,7 +318,6 @@ func chains() []Chain { {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_AVALANCHE_DRPC", "https://avalanche.drpc.org")}, {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_AVALANCHE_TENDERLY", "https://gateway.tenderly.co/public/avalanche")}, {Slug: "avalanche-official", Name: "Avalanche Official", URL: envDefault("RPC_URL_AVALANCHE_OFFICIAL", "https://api.avax.network/ext/bc/C/rpc")}, - {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_AVALANCHE_THIRDWEB", "https://avalanche.rpc.thirdweb.com")}, }, }, // ─── Linea (4 providers) ──────────────────────────────────── @@ -335,7 +328,6 @@ func chains() []Chain { {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_LINEA_PUBLICNODE", "https://linea-rpc.publicnode.com")}, {Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_LINEA_DRPC", "https://linea.drpc.org")}, {Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_LINEA_TENDERLY", "https://gateway.tenderly.co/public/linea")}, - {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_LINEA_THIRDWEB", "https://linea.rpc.thirdweb.com")}, }, }, // ─── Scroll (4 providers) ─────────────────────────────────── diff --git a/harnesses/token-trade-coverage/.env.example b/harnesses/token-trade-coverage/.env.example new file mode 100644 index 00000000..a613cf9a --- /dev/null +++ b/harnesses/token-trade-coverage/.env.example @@ -0,0 +1,41 @@ +# token-trade-coverage harness. Fill in and rename to `.env`. + +# Provider keys (harness tolerates missing keys per-provider; missing +# key sets probe_ok=0 for that provider only, others keep going). +MOBULA_API_KEY= +BITQUERY_API_KEY= +CODEX_API_KEY= + +# ─── Cadence ────────────────────────────────────────────────────────── +# Base sweep cadence. 3600 = 1 hour. Every SWEEP_SEC the harness iterates +# every (provider, chain, token) tuple, respecting the per-provider +# EveryN sub-sampling below. +SWEEP_SEC=3600 + +# ─── Per-provider sub-sampling (respects free-tier quotas) ──────────── +# A provider runs only when `iteration % EveryN == 0`. Iteration is the +# 0-indexed sweep counter (boot = 0, first tick = 1, ...). +# +# Default free-tier budget math (at SWEEP_SEC=3600, 8 EVM+Solana tokens, +# 2 Stellar tokens Mobula-only): +# Mobula MOBULA_EVERY_N=1 → 240 calls / day = free (own API) +# Bitquery BITQUERY_EVERY_N=6 → 8 tokens × 4 sweeps/day × 30 = 960 +# calls / month at ~1 pt each = ~960 pts (Free plan cap 1000) +# Codex CODEX_EVERY_N=1 → 192 calls / day = fits Codex rate limits +# +# On a paid Bitquery plan (Developer $99/mo = 500k pts), set BITQUERY_EVERY_N=1. +MOBULA_EVERY_N=1 +BITQUERY_EVERY_N=6 +CODEX_EVERY_N=1 + +# ─── Query caps (prevent runaway pagination on heavy tokens) ────────── +# Mobula: cursor pages, 5000 rows each → MAX_PAGES=20 caps at 100k trades / token / sweep. +# Bitquery: single-shot query, no cursor → MAX_ROWS is the `limit` in the GraphQL. +# Codex: cursor pages, 200 rows each → MAX_PAGES=10 caps at 2000 events / token / sweep. +MOBULA_MAX_PAGES=20 +BITQUERY_MAX_ROWS=10000 +CODEX_MAX_PAGES=10 + +# ─── Runtime knobs ──────────────────────────────────────────────────── +METRICS_PORT=2112 +HTTP_TIMEOUT_SEC=30 diff --git a/harnesses/token-trade-coverage/.gitignore b/harnesses/token-trade-coverage/.gitignore new file mode 100644 index 00000000..a0113a2f --- /dev/null +++ b/harnesses/token-trade-coverage/.gitignore @@ -0,0 +1,2 @@ +.env +/scanner diff --git a/harnesses/token-trade-coverage/Dockerfile b/harnesses/token-trade-coverage/Dockerfile new file mode 100644 index 00000000..0b6e7282 --- /dev/null +++ b/harnesses/token-trade-coverage/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/scanner ./cmd/scanner + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates && update-ca-certificates +COPY --from=build /out/scanner /usr/local/bin/scanner +EXPOSE 2112 +ENTRYPOINT ["/usr/local/bin/scanner"] diff --git a/harnesses/token-trade-coverage/README.md b/harnesses/token-trade-coverage/README.md new file mode 100644 index 00000000..d32dc009 --- /dev/null +++ b/harnesses/token-trade-coverage/README.md @@ -0,0 +1,98 @@ +# Harness · token-trade-coverage + +> Source for bench № 090 · Most complete onchain trade data API. Measures, for each reference token per chain, how many trades each provider (Mobula, Bitquery, Codex, Moralis) returns in a fixed 60-minute window, then publishes the capture rate vs the union baseline. + +## What ships in this directory + +- `cmd/scanner/main.go` — measurement loop. Every `SWEEP_SEC` (default 1800 = 30 min) it iterates every (provider, chain, token) tuple, fetches trades in the same 60-minute window, computes the union baseline as `max(counts across providers)` and emits per-provider capture rate to Prometheus. +- `cmd/scanner/mobula.go`, `bitquery.go`, `codex.go`, `moralis.go` — one file per provider. Each exposes a single `fetchTrades(ctx, chain, tokenAddress, windowStart, windowEnd) (int, error)` function that returns the number of distinct trades. Not the full trade objects — we only need the count for capture-rate computation, so the harness never materializes hundreds of MB of trade JSON in memory. +- `cmd/scanner/config.go` — reference token list per chain, env parsing, provider capability matrix (which provider supports which chain). +- `cmd/scanner/metrics.go` — Prom metric definitions (`ocb_token_trade_capture_pct`, `ocb_token_trade_probe_ok`, `ocb_token_trade_query_latency_ms`, `ocb_token_trade_dex_count`). + +## Providers + +| Provider | Endpoint | Auth | Chains | +| --- | --- | --- | --- | +| Mobula | `GET /api/2/trades/filters` | `Authorization: ` | Solana, Ethereum, BSC, Base, Stellar | +| Bitquery | `POST /graphql` (streaming.bitquery.io) | `X-API-KEY` header | Solana, Ethereum, BSC, Base | +| Codex | `POST /graphql` (graph.codex.io) | `Authorization: ` | Solana, Ethereum, BSC, Base | +| Moralis | `GET /token/mainnet/{addr}/swaps` (Solana) or `GET /erc20/{addr}/swaps` (EVM) | `X-API-Key` | Solana, Ethereum, BSC, Base | + +Stellar is measured for Mobula only (the other three do not ship a public Stellar trades endpoint at time of writing). The bench renders Stellar coverage as a per-chain view where non-supporting providers are absent from the row, not counted as zero. + +## Reference tokens + +Chosen for meaningful trade activity in the measurement window so a coverage gap is visible above sampling noise. See `config.go` for the current list. Rotated periodically to avoid a single token going illiquid and dragging every provider's absolute count to zero. + +## Cadence + +`SWEEP_SEC=1800` (30 min). One full sweep does 4 providers × 5 chains × 2 tokens = up to 40 API calls (fewer when a provider does not support a chain). Each call is bounded by `HTTP_TIMEOUT_SEC=30`. A full sweep completes well inside 30 minutes so `SWEEP_SEC` cadence and `avg_over_time(...[24h])` on the spec queries stay honest. + +## Env vars + +Required: + +- `MOBULA_API_KEY` — Mobula API key. Contact mobula.io if you don't have one. +- `BITQUERY_API_KEY` — Bitquery streaming.bitquery.io key. +- `CODEX_API_KEY` — Codex (Defined) API key. NOTE: this bench does NOT use the cookie-based JWT flow from `aggregator-head-lag` because the query volume is high (batch historical, not live subscribe). Fresh dedicated key recommended. +- `MORALIS_API_KEY` — Moralis Web3 Data API key. + +Optional: + +- `SWEEP_SEC` (default 1800) +- `METRICS_PORT` (default 2112) +- `HTTP_TIMEOUT_SEC` (default 30) +- `LOG_LEVEL` (default info; set debug to log every provider call) + +## Metrics produced + +| Metric | Description | +| --- | --- | +| `ocb_token_trade_capture_pct{provider, chain, token}` | Capture rate percent (0-100). `provider_count / max_provider_count * 100`. Union baseline is per (chain, token) per cycle. | +| `ocb_token_trade_absolute_count{provider, chain, token}` | Raw trade count returned by that provider in the measurement window. | +| `ocb_token_trade_query_latency_ms{provider, chain, token}` | Wall-clock latency of the provider call, including pagination. | +| `ocb_token_trade_dex_count{provider, chain, token}` | Distinct DEX venues represented in the returned trade set. Companion metric — coverage breadth vs pure count. | +| `ocb_token_trade_probe_ok{provider, chain, token}` | 1 on successful fetch, 0 on error/timeout. Consumed by the spec's `success` query. | + +The spec at `benchmarks/token-trade-coverage.yml` aggregates these across (chain, token) into a per-provider p50 for the headline leaderboard. + +## Run locally + +```bash +cp .env.example .env +# Fill in the four API keys +go run ./cmd/scanner/ +``` + +`/metrics` at `http://localhost:2112/metrics`. + +## Run in the OCB VPS stack + +Add a service block in `/opt/ocb/docker-compose.yml`: + +```yaml + token-trade-coverage: + build: + context: /opt/ocb/harnesses/token-trade-coverage + dockerfile: Dockerfile + container_name: ocb-token-trade-coverage + restart: unless-stopped + env_file: /run/ocb/.env.token-trade-coverage + expose: ["2112"] + networks: [web] + mem_limit: 512m + cpus: "0.3" +``` + +Prometheus scrape config additions in `/opt/ocb/prometheus.yml`: + +```yaml + - job_name: token-trade-coverage + scrape_interval: 60s + static_configs: + - targets: ["token-trade-coverage:2112"] +``` + +## Reference implementation + +The initial TypeScript reference lives at https://github.com/Flotapponnier/token-trade-benchmark-. This harness is a Go port with Prometheus emission and OCB conventions. Any semantic drift between the two (which trades count, how the window is bounded) is corrected here first; the reference repo is a design document, not a source of truth. diff --git a/harnesses/token-trade-coverage/cmd/scanner/bitquery.go b/harnesses/token-trade-coverage/cmd/scanner/bitquery.go new file mode 100644 index 00000000..2d2005cf --- /dev/null +++ b/harnesses/token-trade-coverage/cmd/scanner/bitquery.go @@ -0,0 +1,191 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const bitqueryURL = "https://streaming.bitquery.io/graphql" + +// bitqueryEVMResp / bitquerySolanaResp keep the response tight: only +// what we need to count trades and derive DEX venues. +type bitqueryEVMResp struct { + Data struct { + EVM struct { + DEXTrades []struct { + Transaction struct { + Hash string `json:"Hash"` + } `json:"Transaction"` + Trade struct { + Dex struct { + ProtocolName string `json:"ProtocolName"` + } `json:"Dex"` + } `json:"Trade"` + } `json:"DEXTrades"` + } `json:"EVM"` + } `json:"data"` +} + +type bitquerySolanaResp struct { + Data struct { + Solana struct { + DEXTradeByTokens []struct { + Transaction struct { + Signature string `json:"Signature"` + } `json:"Transaction"` + Trade struct { + Dex struct { + ProtocolName string `json:"ProtocolName"` + } `json:"Dex"` + } `json:"Trade"` + } `json:"DEXTradeByTokens"` + } `json:"Solana"` + } `json:"data"` +} + +// fetchBitquery returns (trade count, distinct DEXs, error). Bitquery +// caps a single query at 10000 rows and has no cursor pagination on +// DEXTradeByTokens; on tokens whose true trade count exceeds 10k the +// bench under-reports Bitquery vs a paginated provider. The findings +// section of the spec calls this out honestly rather than pretending +// Bitquery is worse than it is. +func fetchBitquery( + ctx context.Context, + client *http.Client, + apiKey string, + tok Token, + windowStart, windowEnd int64, + maxRows int, +) (int, int, error) { + if apiKey == "" { + return 0, 0, fmt.Errorf("BITQUERY_API_KEY not set") + } + if maxRows <= 0 { + maxRows = 10000 + } + + sinceISO := time.Unix(windowStart/1000, 0).UTC().Format(time.RFC3339) + tillISO := time.Unix(windowEnd/1000, 0).UTC().Format(time.RFC3339) + + var query string + if tok.Chain == "solana" { + query = solanaTradesGQL(tok.Address, sinceISO, tillISO, maxRows) + } else { + network := bitqueryEVMNetwork(tok.Chain) + if network == "" { + return 0, 0, fmt.Errorf("bitquery: unsupported chain %s", tok.Chain) + } + query = evmTradesGQL(network, tok.Address, sinceISO, tillISO, maxRows) + } + + body, _ := json.Marshal(map[string]string{"query": query}) + req, err := http.NewRequestWithContext(ctx, "POST", bitqueryURL, bytes.NewReader(body)) + if err != nil { + return 0, 0, err + } + // Bitquery's new OAuth-style keys (ory_at_*) require Bearer auth on + // streaming.bitquery.io/graphql. The legacy X-API-KEY header on that + // endpoint returns HTTP 402 "No active billing period" even when the + // account has an active free plan — the migration was silent and the + // docs still show the old header on some pages. + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return 0, 0, err + } + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return 0, 0, err + } + if resp.StatusCode >= 300 { + return 0, 0, fmt.Errorf("bitquery http %d: %s", resp.StatusCode, truncate(string(respBody), 200)) + } + + dexSet := map[string]struct{}{} + hashSet := map[string]struct{}{} + + if tok.Chain == "solana" { + var r bitquerySolanaResp + if err := json.Unmarshal(respBody, &r); err != nil { + return 0, 0, fmt.Errorf("bitquery parse: %w", err) + } + for _, t := range r.Data.Solana.DEXTradeByTokens { + if _, seen := hashSet[t.Transaction.Signature]; seen { + continue + } + hashSet[t.Transaction.Signature] = struct{}{} + if t.Trade.Dex.ProtocolName != "" { + dexSet[t.Trade.Dex.ProtocolName] = struct{}{} + } + } + } else { + var r bitqueryEVMResp + if err := json.Unmarshal(respBody, &r); err != nil { + return 0, 0, fmt.Errorf("bitquery parse: %w", err) + } + for _, t := range r.Data.EVM.DEXTrades { + if _, seen := hashSet[t.Transaction.Hash]; seen { + continue + } + hashSet[t.Transaction.Hash] = struct{}{} + if t.Trade.Dex.ProtocolName != "" { + dexSet[t.Trade.Dex.ProtocolName] = struct{}{} + } + } + } + return len(hashSet), len(dexSet), nil +} + +func bitqueryEVMNetwork(chain string) string { + switch chain { + case "ethereum": + return "eth" + case "bsc": + return "bsc" + case "base": + return "base" + } + return "" +} + +func solanaTradesGQL(mintAddress, since, till string, maxRows int) string { + return fmt.Sprintf(`{ + Solana(dataset: realtime) { + DEXTradeByTokens( + limit: {count: %d} + where: { + Block: {Time: {since: "%s", till: "%s"}} + Trade: {Currency: {MintAddress: {is: "%s"}}} + } + ) { + Transaction { Signature } + Trade { Dex { ProtocolName } } + } + } +}`, maxRows, since, till, mintAddress) +} + +func evmTradesGQL(network, tokenAddress, since, till string, maxRows int) string { + return fmt.Sprintf(`{ + EVM(dataset: realtime, network: %s) { + DEXTrades( + limit: {count: %d} + where: { + Block: {Time: {since: "%s", till: "%s"}} + Trade: {Buy: {Currency: {SmartContract: {is: "%s"}}}} + } + ) { + Transaction { Hash } + Trade { Dex { ProtocolName } } + } + } +}`, network, maxRows, since, till, tokenAddress) +} diff --git a/harnesses/token-trade-coverage/cmd/scanner/codex.go b/harnesses/token-trade-coverage/cmd/scanner/codex.go new file mode 100644 index 00000000..9b19b224 --- /dev/null +++ b/harnesses/token-trade-coverage/cmd/scanner/codex.go @@ -0,0 +1,155 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const codexURL = "https://graph.codex.io/graphql" + +// codexResp: minimal envelope. Codex's `getTokenEvents` is cursor-paginated +// via the `cursor` string returned in each response. +// +// Fields kept minimal on purpose. `exchangeAddress` was dropped from +// the query because it's not a valid field on Codex's `Event` type +// (GraphQL 400: `Cannot query field exchangeAddress on type Event`). +// Codex's DEX identity lives one hop away on the `Pair.exchange` type, +// which would require joining `Pair` records per event and inflates +// the query volume for a companion metric that we already publish +// honestly (0) for a provider that doesn't expose it inline. +type codexResp struct { + Data struct { + GetTokenEvents struct { + Items []struct { + TransactionHash string `json:"transactionHash"` + EventDisplayType string `json:"eventDisplayType"` + } `json:"items"` + Cursor string `json:"cursor"` + } `json:"getTokenEvents"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +// codexNetworkID maps OCB chain slug to Codex's numeric networkId. +// Same table as in the reference TS impl. +func codexNetworkID(chain string) int { + switch chain { + case "solana": + return 1399811149 + case "ethereum": + return 1 + case "bsc": + return 56 + case "base": + return 8453 + } + return 0 +} + +// fetchCodex returns (trade count, distinct exchanges, error). Uses +// standard API-key auth via `Authorization` header — the cookie-based +// JWT flow that lives in aggregator-head-lag is not used here because +// query volume is batch-historical, not live-subscribe. +func fetchCodex( + ctx context.Context, + client *http.Client, + apiKey string, + tok Token, + windowStart, windowEnd int64, + maxPages int, +) (int, int, error) { + if apiKey == "" { + return 0, 0, fmt.Errorf("CODEX_API_KEY not set") + } + if maxPages <= 0 { + maxPages = 10 + } + networkID := codexNetworkID(tok.Chain) + if networkID == 0 { + return 0, 0, fmt.Errorf("codex: unsupported chain %s", tok.Chain) + } + + fromSec := windowStart / 1000 + toSec := windowEnd / 1000 + + var ( + total int + cursor string + exchSet = map[string]struct{}{} + hashSet = map[string]struct{}{} + ) + + for page := 0; page < maxPages; page++ { + var cursorClause string + if cursor != "" { + cursorClause = fmt.Sprintf(`cursor: %q`, cursor) + } + query := fmt.Sprintf(` +query GetEvents { + getTokenEvents( + query: { + address: "%s" + networkId: %d + timestamp: {from: %d, to: %d} + eventType: Swap + } + limit: 200 + %s + ) { + items { + transactionHash + eventDisplayType + } + cursor + } +}`, tok.Address, networkID, fromSec, toSec, cursorClause) + + body, _ := json.Marshal(map[string]string{"query": query}) + req, err := http.NewRequestWithContext(ctx, "POST", codexURL, bytes.NewReader(body)) + if err != nil { + return total, len(exchSet), err + } + req.Header.Set("Authorization", apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return total, len(exchSet), err + } + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return total, len(exchSet), err + } + if resp.StatusCode >= 300 { + return total, len(exchSet), fmt.Errorf("codex http %d: %s", resp.StatusCode, truncate(string(respBody), 200)) + } + var r codexResp + if err := json.Unmarshal(respBody, &r); err != nil { + return total, len(exchSet), fmt.Errorf("codex parse: %w", err) + } + if len(r.Errors) > 0 { + return total, len(exchSet), fmt.Errorf("codex graphql: %s", r.Errors[0].Message) + } + for _, it := range r.Data.GetTokenEvents.Items { + if _, seen := hashSet[it.TransactionHash]; seen { + continue + } + hashSet[it.TransactionHash] = struct{}{} + total++ + // Codex Event doesn't expose DEX inline; leave dexCount=0 + // rather than fabricating a synthetic venue. + } + if r.Data.GetTokenEvents.Cursor == "" || len(r.Data.GetTokenEvents.Items) == 0 { + break + } + cursor = r.Data.GetTokenEvents.Cursor + } + return total, len(exchSet), nil +} diff --git a/harnesses/token-trade-coverage/cmd/scanner/config.go b/harnesses/token-trade-coverage/cmd/scanner/config.go new file mode 100644 index 00000000..68717aca --- /dev/null +++ b/harnesses/token-trade-coverage/cmd/scanner/config.go @@ -0,0 +1,169 @@ +package main + +import ( + "os" + "strconv" + "time" +) + +// Token is a reference token watched for coverage measurement. +type Token struct { + Chain string // "solana", "ethereum", "bsc", "base", "stellar" + Address string // native address on that chain + Symbol string // short label for logs +} + +// ProviderCapability declares which chains a provider is measured on. +// A provider is EXCLUDED from a chain's row if the chain is not listed +// here; that keeps the leaderboard honest instead of counting a +// non-integration as a zero-score defeat. +type ProviderCapability map[string]bool + +// Config is populated once at startup and read-only afterwards. +type Config struct { + SweepSec int + MetricsPort string + HTTPTimeoutSec int + MobulaKey string + BitqueryKey string + CodexKey string + Tokens []Token + Capabilities map[string]ProviderCapability // provider -> {chain -> supported} + MeasurementWinMs int64 // rolling window per measurement + + // Per-provider sub-sampling (respects free-tier quotas). + // A provider only runs when `iteration % everyN == 0`. Default 1 + // means every sweep. Higher = sparser samples but preserves free- + // tier point budget on providers with tight limits (Bitquery Free + // gives 1000 points / month, so on the default 60-min cadence with + // EveryN=6 we spend ≤ 960 points / month across 8 supported tokens). + MobulaEveryN int + BitqueryEveryN int + CodexEveryN int + + // Page caps: bound the max pagination pages per provider per token + // per sweep. Prevents runaway cursor loops on a single very-heavy + // token from burning a whole sweep's quota. + MobulaMaxPages int + BitqueryMaxRows int // Bitquery has no cursor — this is the per-query row cap + CodexMaxPages int +} + +// LoadConfig reads env, defaults + hardcoded reference token list. +// Reference tokens are chosen for meaningful trade activity so a +// coverage gap becomes visible; rotate them here when a listed token +// goes illiquid. +func LoadConfig() *Config { + return &Config{ + // Base cadence: 60 min. Doubled from the initial 30-min value + // after quota math on Bitquery's 1000-pt free plan (see + // BitqueryEveryN below). 24 sweeps / day gives >= 12 samples per + // (chain, token) per day for providers that run every sweep — + // plenty for a stable p50 over 24h. + SweepSec: envInt("SWEEP_SEC", 3600), + MetricsPort: envStr("METRICS_PORT", "2112"), + HTTPTimeoutSec: envInt("HTTP_TIMEOUT_SEC", 30), + MeasurementWinMs: int64(60*60) * 1000, // 60 min rolling window + MobulaKey: os.Getenv("MOBULA_API_KEY"), + BitqueryKey: os.Getenv("BITQUERY_API_KEY"), + CodexKey: os.Getenv("CODEX_API_KEY"), + + // Mobula: unlimited (own infra). Run every sweep. + MobulaEveryN: envInt("MOBULA_EVERY_N", 1), + // Bitquery Free: 1000 pts / month. At ~1-2 pts per + // DEXTradeByTokens call and 8 supported tokens per sweep, + // running every 6th sweep = 4 sweeps / day × 8 tokens × 30 + // days = 960 calls / month, well inside the free budget. + // Bump to 1 if on the Developer plan ($99/mo, 500k pts). + BitqueryEveryN: envInt("BITQUERY_EVERY_N", 6), + // Codex Free: generous rate-based limits (no monthly point + // cap on the current tier). Run every sweep. + CodexEveryN: envInt("CODEX_EVERY_N", 1), + + // Page caps. A high-volume token that fires cursor-paginated + // requests indefinitely can drain a monthly budget in one + // sweep. These bound the worst case per token per sweep. + MobulaMaxPages: envInt("MOBULA_MAX_PAGES", 20), + BitqueryMaxRows: envInt("BITQUERY_MAX_ROWS", 10000), + CodexMaxPages: envInt("CODEX_MAX_PAGES", 10), + Tokens: []Token{ + // Reference tokens selected 2026-07-23 via a Mobula + // `/api/2/trades/filters` sweep over the last hour, picking + // mid-liquidity actives (not pure blue chips, so provider + // coverage differences show; not pump.fun day-olds, so the + // bench doesn't die if a specific token's activity dries up + // tomorrow). All 10 verified > 50 trades / 1 h at pick time. + // Rotate here when a listed token's volume drops off. + // + // Solana. BONK and WIF are both well past the pump.fun window + // and trade across every Solana DEX indexed by the three + // providers, which is what surfaces real coverage differences. + {Chain: "solana", Address: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", Symbol: "BONK"}, + {Chain: "solana", Address: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm", Symbol: "WIF"}, + // Ethereum. PEPE covers the top of the meme distribution; + // MOG sits mid-liquidity and often reveals per-DEX gaps that + // PEPE's wall-to-wall coverage masks. + {Chain: "ethereum", Address: "0x6982508145454Ce325dDbE47a25d4ec3d2311933", Symbol: "PEPE"}, + {Chain: "ethereum", Address: "0xaaeE1A9723aaDB7afA2810263653A34bA2C21C7a", Symbol: "MOG"}, + // BSC. CAKE is the anchor (own PancakeSwap listings); FLOKI + // trades across PancakeSwap + long-tail BEP-20 AMMs so the + // per-DEX split shows. + {Chain: "bsc", Address: "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82", Symbol: "CAKE"}, + {Chain: "bsc", Address: "0xfb5B838b6cfEEdC2873aB27866079AC55363D37E", Symbol: "FLOKI"}, + // Base. BRETT and DEGEN — top two active memes on Aerodrome + // + Uniswap v3, wide enough coverage on Base that they're + // stable picks across weeks. + {Chain: "base", Address: "0x532f27101965dd16442E59d40670FaF5eBB142E4", Symbol: "BRETT"}, + {Chain: "base", Address: "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed", Symbol: "DEGEN"}, + // Stellar. yXLM (wrapped XLM) and SHX (Stronghold) are both + // high-activity Stellar-native assets. Address format is + // Stellar's canonical `:` string — Mobula + // accepts that directly as `tokenAddress`. + {Chain: "stellar", Address: "yXLM:GARDNV3Q7YGT4AKSDF25LT32YSCCW4EV22Y2TV3I2PU2MMXJTEDL5T55", Symbol: "yXLM"}, + {Chain: "stellar", Address: "SHX:GDSTRSHXHGJ7ZIVRBXEYE5Q74XUVCUSEKEBR7UCHEUUEK72N7I7KJ6JH", Symbol: "SHX"}, + }, + Capabilities: map[string]ProviderCapability{ + "mobula": { + "solana": true, "ethereum": true, "bsc": true, "base": true, "stellar": true, + }, + "bitquery": { + "solana": true, "ethereum": true, "bsc": true, "base": true, + }, + "codex": { + "solana": true, "ethereum": true, "bsc": true, "base": true, + }, + }, + } +} + +func (c *Config) HTTPTimeout() time.Duration { + return time.Duration(c.HTTPTimeoutSec) * time.Second +} + +// Supports reports whether the (provider, chain) pair is measured. +func (c *Config) Supports(provider, chain string) bool { + caps, ok := c.Capabilities[provider] + if !ok { + return false + } + return caps[chain] +} + +func envInt(k string, def int) int { + v := os.Getenv(k) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} + +func envStr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} diff --git a/harnesses/token-trade-coverage/cmd/scanner/main.go b/harnesses/token-trade-coverage/cmd/scanner/main.go new file mode 100644 index 00000000..017d4560 --- /dev/null +++ b/harnesses/token-trade-coverage/cmd/scanner/main.go @@ -0,0 +1,185 @@ +// Materialize the token-trade-coverage bench (№ 090). +// +// Loop: every SWEEP_SEC, iterate every (provider, chain, token) tuple, +// fetch trades in the same rolling 60-minute window, compute the union +// baseline per (chain, token) as max(counts) across providers and +// publish capture rate + companion metrics to Prometheus. +// +// The design keeps memory bounded: providers return counts, not trade +// arrays, so a token with 50k trades in the window costs O(1) here. + +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var providers = []string{"mobula", "bitquery", "codex"} + +func main() { + cfg := LoadConfig() + log.Printf("[boot] sweep=%ds providers=%v tokens=%d", cfg.SweepSec, providers, len(cfg.Tokens)) + + // Expose /metrics before the first sweep so Prom starts scraping + // immediately; capture_pct just stays 0 until the loop populates it. + http.Handle("/metrics", promhttp.Handler()) + go func() { + addr := ":" + cfg.MetricsPort + log.Printf("[boot] metrics on %s/metrics", addr) + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatalf("http: %v", err) + } + }() + + client := &http.Client{Timeout: cfg.HTTPTimeout()} + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + ticker := time.NewTicker(time.Duration(cfg.SweepSec) * time.Second) + defer ticker.Stop() + + // First sweep runs immediately at boot so Prom sees data within + // SWEEP_SEC + measurement time rather than waiting a full cycle. + iteration := 0 + sweep(ctx, cfg, client, iteration) + for { + select { + case <-ctx.Done(): + log.Printf("[shutdown] signal received") + return + case <-ticker.C: + iteration++ + sweep(ctx, cfg, client, iteration) + } + } +} + +// providerEnabled reports whether the provider should run on this +// iteration given its sub-sampling cadence (see Config.*EveryN). +func providerEnabled(cfg *Config, provider string, iteration int) bool { + var everyN int + switch provider { + case "mobula": + everyN = cfg.MobulaEveryN + case "bitquery": + everyN = cfg.BitqueryEveryN + case "codex": + everyN = cfg.CodexEveryN + default: + return false + } + if everyN <= 1 { + return true + } + return iteration%everyN == 0 +} + +// sweep runs one full measurement cycle. For each token, we fan out +// across every supported provider in parallel, wait for all to finish +// (or fail), compute the union baseline as max(counts) and emit. +// +// `iteration` is the 0-indexed sweep counter — used for per-provider +// sub-sampling (see providerEnabled). Skipped providers keep their +// previous capture_pct value in Prom, which `avg_over_time([24h])` +// then averages across the sparse sample. +func sweep(ctx context.Context, cfg *Config, client *http.Client, iteration int) { + sweepStart := time.Now() + windowEnd := sweepStart.UnixMilli() + windowStart := windowEnd - cfg.MeasurementWinMs + + activeProviders := make([]string, 0, len(providers)) + for _, p := range providers { + if providerEnabled(cfg, p, iteration) { + activeProviders = append(activeProviders, p) + } + } + log.Printf("[sweep] iter=%d active=%v", iteration, activeProviders) + + for _, tok := range cfg.Tokens { + if tok.Address == "" { + // Placeholder row (Stellar addresses TBD). Skip silently + // so the container doesn't spam warnings until they land. + continue + } + results := make([]Result, 0, len(activeProviders)) + var mu sync.Mutex + var wg sync.WaitGroup + + for _, p := range activeProviders { + if !cfg.Supports(p, tok.Chain) { + continue + } + wg.Add(1) + go func(p string) { + defer wg.Done() + start := time.Now() + count, dex, err := fetchOne(ctx, client, cfg, p, tok, windowStart, windowEnd) + latMs := float64(time.Since(start).Milliseconds()) + r := Result{ + Provider: p, + Chain: tok.Chain, + Token: tok.Symbol, + Count: count, + DexCount: dex, + LatencyMs: latMs, + OK: err == nil, + } + if err != nil { + log.Printf("[%s][%s/%s] err: %v", p, tok.Chain, tok.Symbol, err) + } else { + log.Printf("[%s][%s/%s] count=%d dex=%d %.0fms", p, tok.Chain, tok.Symbol, count, dex, latMs) + } + mu.Lock() + results = append(results, r) + mu.Unlock() + }(p) + } + wg.Wait() + + // Union baseline: the largest count observed across providers + // that succeeded. If every provider failed the baseline is 0 + // and capture rate stays 0 across the board (spec `success` + // query then flags the (chain, token) as unresponsive). + unionMax := 0 + for _, r := range results { + if r.OK && r.Count > unionMax { + unionMax = r.Count + } + } + emitCycle(results, unionMax) + } + log.Printf("[sweep] done in %.1fs", time.Since(sweepStart).Seconds()) +} + +// fetchOne dispatches to the provider-specific fetcher, threading each +// provider's page/row cap so a runaway pagination loop can never drain +// a monthly quota in a single sweep. +func fetchOne( + ctx context.Context, + client *http.Client, + cfg *Config, + provider string, + tok Token, + windowStart, windowEnd int64, +) (int, int, error) { + switch provider { + case "mobula": + return fetchMobula(ctx, client, cfg.MobulaKey, tok, windowStart, windowEnd, cfg.MobulaMaxPages) + case "bitquery": + return fetchBitquery(ctx, client, cfg.BitqueryKey, tok, windowStart, windowEnd, cfg.BitqueryMaxRows) + case "codex": + return fetchCodex(ctx, client, cfg.CodexKey, tok, windowStart, windowEnd, cfg.CodexMaxPages) + } + return 0, 0, fmt.Errorf("unknown provider %s", provider) +} diff --git a/harnesses/token-trade-coverage/cmd/scanner/metrics.go b/harnesses/token-trade-coverage/cmd/scanner/metrics.go new file mode 100644 index 00000000..3481c072 --- /dev/null +++ b/harnesses/token-trade-coverage/cmd/scanner/metrics.go @@ -0,0 +1,85 @@ +package main + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// Metric surface. Labels are (provider, chain, token). Kept exactly +// three labels so PromQL aggregation stays simple and the spec's +// `series` query at the bench level can group by any one of them. +var ( + capturePct = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "ocb_token_trade_capture_pct", + Help: "Percent of the union-baseline trade count returned by this provider for (chain, token) in the last measurement window.", + }, []string{"provider", "chain", "token"}) + + absoluteCount = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "ocb_token_trade_absolute_count", + Help: "Raw trade count returned by this provider for (chain, token) in the last measurement window.", + }, []string{"provider", "chain", "token"}) + + queryLatency = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "ocb_token_trade_query_latency_ms", + Help: "Wall-clock latency of the provider's trade fetch call including pagination, in milliseconds.", + }, []string{"provider", "chain", "token"}) + + dexCount = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "ocb_token_trade_dex_count", + Help: "Distinct DEX venues represented in the trade set returned by this provider for (chain, token).", + }, []string{"provider", "chain", "token"}) + + probeOK = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "ocb_token_trade_probe_ok", + Help: "1 on successful fetch, 0 on error or timeout. Consumed by the bench spec's `success` query.", + }, []string{"provider", "chain", "token"}) + + // Cumulative API-call counter for quota observability. Counted at + // the fetchOne granularity — one increment per (provider, token) + // tuple in a sweep, regardless of how many paginated sub-requests + // fired underneath — because that's what maps 1:1 to the provider's + // monthly point/credit budget as billed. `increase(...[30d])` per + // provider gives the running monthly consumption. + apiCalls = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "ocb_token_trade_api_calls_total", + Help: "Total fetchOne invocations per provider since worker start. Query with increase()[30d] for monthly consumption vs free-tier budget.", + }, []string{"provider"}) +) + +// Result is the per-call outcome of one provider fetch. +type Result struct { + Provider string + Chain string + Token string + Count int + DexCount int + LatencyMs float64 + OK bool +} + +// emitCycle publishes one full cycle's worth of results. Called once +// per (chain, token) after every provider has been queried and the +// union baseline is known. +func emitCycle(results []Result, unionMax int) { + for _, r := range results { + lbl := prometheus.Labels{ + "provider": r.Provider, + "chain": r.Chain, + "token": r.Token, + } + absoluteCount.With(lbl).Set(float64(r.Count)) + queryLatency.With(lbl).Set(r.LatencyMs) + dexCount.With(lbl).Set(float64(r.DexCount)) + if r.OK { + probeOK.With(lbl).Set(1) + } else { + probeOK.With(lbl).Set(0) + } + if unionMax > 0 && r.OK { + capturePct.With(lbl).Set(float64(r.Count) / float64(unionMax) * 100) + } else { + capturePct.With(lbl).Set(0) + } + apiCalls.WithLabelValues(r.Provider).Inc() + } +} diff --git a/harnesses/token-trade-coverage/cmd/scanner/mobula.go b/harnesses/token-trade-coverage/cmd/scanner/mobula.go new file mode 100644 index 00000000..ff32cd68 --- /dev/null +++ b/harnesses/token-trade-coverage/cmd/scanner/mobula.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +const mobulaBase = "https://api.mobula.io/api/2/trades/filters" + +// mobulaResp is the minimal envelope we care about: we only need the +// count (via len(data)) and the DEX per row for coverage-breadth. Full +// trade objects are 30+ fields we intentionally ignore. +// +// Field names track Mobula's `/api/2/trades/filters` response as of +// 2026-07-23. `transactionHash` doubles as the dedup key across +// paginated pages. DEX identity comes from `platform.name` when the +// venue is a mapped protocol (Raydium, Uniswap v3, …); otherwise the +// pool address in `marketAddress` acts as a fallback venue proxy. +type mobulaTrade struct { + TransactionHash string `json:"transactionHash"` + Platform *struct { + Name string `json:"name"` + } `json:"platform"` + MarketAddress string `json:"marketAddress"` +} + +type mobulaResp struct { + Data []mobulaTrade `json:"data"` + Pagination struct { + HasMore bool `json:"hasMore"` + NextCursor string `json:"nextCursor"` + } `json:"pagination"` +} + +// fetchMobula returns (trade count, distinct DEXs, error). Paginates +// via cursor until either exhausted or the safety cap is hit. +func fetchMobula( + ctx context.Context, + client *http.Client, + apiKey string, + tok Token, + windowStart, windowEnd int64, + maxPages int, +) (int, int, error) { + if apiKey == "" { + return 0, 0, fmt.Errorf("MOBULA_API_KEY not set") + } + if maxPages <= 0 { + maxPages = 20 + } + var ( + total int + cursor string + dexSet = map[string]struct{}{} + hashSet = map[string]struct{}{} + ) + for page := 0; page < maxPages; page++ { + u, _ := url.Parse(mobulaBase) + q := u.Query() + q.Set("blockchain", mobulaChainName(tok.Chain)) + q.Set("tokenAddress", tok.Address) + q.Set("from", strconv.FormatInt(windowStart, 10)) + q.Set("to", strconv.FormatInt(windowEnd, 10)) + q.Set("limit", "5000") + q.Set("sortOrder", "asc") + if cursor != "" { + q.Set("cursor", cursor) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) + if err != nil { + return total, len(dexSet), err + } + req.Header.Set("Authorization", apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return total, len(dexSet), err + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return total, len(dexSet), err + } + if resp.StatusCode >= 300 { + return total, len(dexSet), fmt.Errorf("mobula http %d: %s", resp.StatusCode, truncate(string(body), 200)) + } + var r mobulaResp + if err := json.Unmarshal(body, &r); err != nil { + return total, len(dexSet), fmt.Errorf("mobula parse: %w", err) + } + for _, t := range r.Data { + // Dedupe by tx hash across pages. Providers occasionally + // return the same hash on consecutive pages when a cursor + // resets under the hood. + if _, seen := hashSet[t.TransactionHash]; seen { + continue + } + hashSet[t.TransactionHash] = struct{}{} + total++ + // DEX identity: prefer `platform.name` (mapped protocol), + // fall back to `marketAddress` (raw pool address) so a + // venue with no mapped platform still contributes one + // distinct venue to the coverage-breadth metric instead + // of being dropped silently. + if t.Platform != nil && t.Platform.Name != "" { + dexSet[t.Platform.Name] = struct{}{} + } else if t.MarketAddress != "" { + dexSet[t.MarketAddress] = struct{}{} + } + } + if !r.Pagination.HasMore || r.Pagination.NextCursor == "" || len(r.Data) == 0 { + break + } + cursor = r.Pagination.NextCursor + // Cursor pages back-to-back can trip provider rate limits; a + // short breather keeps the fetch inside its budget without + // starving the cadence. + time.Sleep(50 * time.Millisecond) + } + return total, len(dexSet), nil +} + +// mobulaChainName maps OCB canonical chain slug → Mobula chain param +// value. Kept centralised so the mapping is auditable in one place. +func mobulaChainName(chain string) string { + switch chain { + case "solana": + return "Solana" + case "ethereum": + return "Ethereum" + case "bsc": + return "BNB Smart Chain (BEP20)" + case "base": + return "Base" + case "stellar": + return "Stellar" + default: + return chain + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/harnesses/token-trade-coverage/go.mod b/harnesses/token-trade-coverage/go.mod new file mode 100644 index 00000000..000366ca --- /dev/null +++ b/harnesses/token-trade-coverage/go.mod @@ -0,0 +1,17 @@ +module github.com/ChainBench/OpenChainBench/harnesses/token-trade-coverage + +go 1.23 + +require github.com/prometheus/client_golang v1.20.5 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + golang.org/x/sys v0.28.0 // indirect + google.golang.org/protobuf v1.36.1 // indirect +) diff --git a/harnesses/token-trade-coverage/go.sum b/harnesses/token-trade-coverage/go.sum new file mode 100644 index 00000000..c231b510 --- /dev/null +++ b/harnesses/token-trade-coverage/go.sum @@ -0,0 +1,32 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/infrastructure/monitoring/prometheus/prometheus.yml b/infrastructure/monitoring/prometheus/prometheus.yml index 840e35af..213f5321 100644 --- a/infrastructure/monitoring/prometheus/prometheus.yml +++ b/infrastructure/monitoring/prometheus/prometheus.yml @@ -171,6 +171,23 @@ scrape_configs: benchmark: rpc-capabilities metrics_path: /metrics + # OpenChainBench bench №081 + siblings 087 (base) + 088 (solana) — + # WebSocket head-latency race across free keyless RPC providers. + # Same 3-region layout as rpc-capabilities. Each service pins its + # REGION env var; the harness bakes it as a ConstLabel on every + # ws_head_* metric so Prom fed the union of the 3 replicas gets + # {provider, chain, region} triples for the per-region spec tabs. + - job_name: 'ws-head-latency' + honor_labels: true + static_configs: + - targets: + - 'ws-head-latency-us.railway.internal:2112' # us-east + - 'ws-head-latency-eu.railway.internal:2112' # eu-west + - 'ws-head-latency-sgp.railway.internal:2112' # sgp + labels: + benchmark: ws-head-latency + metrics_path: /metrics + # OpenChainBench — keyed free-tier RPC latency (Alchemy, Infura, # Chainstack, Ankr, Helius). Companion to rpc-capabilities: same # metric names + tier="keyed" label, picked up by the shared diff --git a/src/app/api/series/[slug]/route.ts b/src/app/api/series/[slug]/route.ts index 47c52114..23428968 100644 --- a/src/app/api/series/[slug]/route.ts +++ b/src/app/api/series/[slug]/route.ts @@ -3,6 +3,7 @@ import { unstable_cache } from "next/cache"; import { getBenchmark } from "@/data/benchmarks"; import { filterSig, loadSpecsUncached, specToBenchmark } from "@/lib/materialize/load"; import { readMaterialized } from "@/lib/materialize/store"; +import { loadSnapshotFromBlob } from "@/lib/bench-blob"; import { buildProviderColors } from "@/lib/series-colors"; import { logoPath } from "@/lib/logo-manifest"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; @@ -32,7 +33,10 @@ const getSeriesMapCached = unstable_cache( venue: string | undefined, ): Promise | null> => { const sig = filterSig({ chain, region, kind, venue }); - const stored = await readMaterialized(slug, sig); + // Try CDN blob first (Phase 3), fall back to Redis via SRH. + const stored = + (await loadSnapshotFromBlob(slug, sig)) ?? + (await readMaterialized(slug, sig)); if (stored) { const fromBlob = range === "7d" diff --git a/src/app/benchmarks/[slug]/share-card/route.tsx b/src/app/benchmarks/[slug]/share-card/route.tsx index c750e0b1..12fd31a8 100644 --- a/src/app/benchmarks/[slug]/share-card/route.tsx +++ b/src/app/benchmarks/[slug]/share-card/route.tsx @@ -12,15 +12,10 @@ import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; import { SLUG_RE } from "@/lib/slug"; /** Best → worst, depending on whether higher numbers are better. Drops - * rows with missing `ms` or non-positive p50. Both zero and negative - * values must be filtered here: zeros blow up bar heights via - * divide-by-zero maxP50, and negatives (used as a "warming up" - * sentinel on funding benches via commit c03c599) produce a negative - * maxP50 and inverted ratios which makes Satori reject the JSX with - * an opaque "Spread syntax" error mid-stream. The `!== 0` rewrite - * was correct for the /products page (which surfaces the negative - * sentinel explicitly) but leaked into the share-card render path - * where negatives are just noise on the bar chart. */ + * rows with missing `ms` or non-positive p50, mirroring /api/stat. Those + * zero-value placeholders blow up the bar layout (NaN heights from a + * divide-by-zero maxP50, ratios > 1) which makes Satori reject the JSX + * with an opaque "Spread syntax" error mid-stream. */ function sortByP50(b: Benchmark): ProviderResult[] { return [...b.results] .filter( @@ -28,7 +23,7 @@ function sortByP50(b: Benchmark): ProviderResult[] { !!r && !!r.ms && Number.isFinite(r.ms.p50) && - r.ms.p50 > 0, + r.ms.p50 !== 0, ) .sort( b.higherIsBetter @@ -53,22 +48,6 @@ function compactProviderName(name: string): string { return map[name] ?? name; } -/** Scale the title font size when the bench title is long so it stops - * overflowing the layout area on the share-card. Long titles like - * "Fastest free public RPC for Ethereum, BNB, Polygon and 23 more - * chains (plus Solana and Polkadot)" (105 chars) at the default 44-56 - * px wrap to 4 lines and collide with the leaderboard rows or the - * time-series chart on Snapshot. This linear ramp keeps titles under - * 60 chars at the caller's base size and shrinks longer ones on a - * predictable slope so the layout stays inside 630 px. */ -function scaledTitleSize(base: number, title: string): number { - const len = title.length; - if (len <= 60) return base; - if (len <= 80) return Math.round(base * 0.78); - if (len <= 100) return Math.round(base * 0.62); - return Math.round(base * 0.5); -} - /** Direction-aware comparison label for the Compare card centre cell. * delta = b - a, where a is the leader (rank 1). * - Latency / time benches: a is faster, b is "slower by". @@ -161,17 +140,6 @@ const MIME: Record = { }; const _providerLogoCache = new Map(); - -// Satori (the JSX-to-SVG renderer next/og uses) only decodes PNG, JPEG -// and SVG. Feed it AVIF or WebP bytes and the internal decoder throws -// "TypeError: u2 is not iterable" mid-stream, which surfaces as a -// generic HTTP 500 on the share-card endpoint with no meaningful log. -// Skipping unsupported formats here falls back to the initials chip in -// CardProviderLogo, same as when the logo file is missing entirely. -// Root cause of the pre-existing 500 on rpc-capabilities (publicnode -// .avif, drpc / lava .webp), polkadot-rpc, ws-head-latency-*. -const SATORI_UNSUPPORTED_EXT = new Set([".avif", ".webp"]); - /** Returns a data URL for the registered logo file, or null if missing. * Result is cached per slug to avoid hitting the filesystem on every * render of the share-card. */ @@ -182,14 +150,9 @@ function getProviderLogoDataUrl(slug: string): string | null { _providerLogoCache.set(slug, null); return null; } - const ext = extname(rel).toLowerCase(); - if (SATORI_UNSUPPORTED_EXT.has(ext)) { - _providerLogoCache.set(slug, null); - return null; - } try { const buf = readFileSync(join(process.cwd(), "public", rel)); - const mime = MIME[ext] ?? "image/png"; + const mime = MIME[extname(rel).toLowerCase()] ?? "image/png"; const url = `data:${mime};base64,${buf.toString("base64")}`; _providerLogoCache.set(slug, url); return url; @@ -235,18 +198,6 @@ function CardProviderLogo({ ); } - // Resolve CSS `var(--color-*)` fallbacks from chipBackground / - // chipTextColor to the concrete palette hex. Satori evaluates - // `var(...)` to the CSS-wide `initial` keyword and rejects a - // `background: initial` declaration, taking the whole render down - // with a "Failed to parse declaration" error. Full-cohort case: - // evm-block-builders (Titan, BuilderNet, Quasar, Eureka, Builder+, - // Vanilla) — none have a registered logo or brand chip color so - // every row falls through here and the whole PNG fails to render. - const bgRaw = chipBackground(slug); - const fgRaw = chipTextColor(slug); - const bg = bgRaw.startsWith("var(") ? INK_SOFT : bgRaw; - const fg = fgRaw.startsWith("var(") ? PAPER : fgRaw; return (
{showCategory && {benchmark.category}} {chainLabel && {chainLabel}} - {filterPills.map((p) => ( - - {p.value} - - ))}
- {label} + CHAIN {children}
); @@ -488,7 +426,6 @@ function CardShell({ rightText, showCategory = true, chainLabel, - filterPills = [], }: { benchmark: Benchmark; accentColor?: string; @@ -496,7 +433,6 @@ function CardShell({ rightText?: string; showCategory?: boolean; chainLabel?: string | null; - filterPills?: { kind: string; value: string }[]; }) { return (
matchesChainSlug(c.value, chainParam)) ?? null; - - // Same treatment for region / kind / venue - modal chip pickers - // (share-section-modal.tsx) send these as `?region=`, `?kind=`, - // `?venue=`. Validated against the spec-declared dimensions so an - // attacker cannot inject arbitrary label values into the Prom query. - const findDim = (paramName: string, dims: { value: string; label: string }[]) => { - const raw = url.searchParams.get(paramName); - if (!raw || raw === "all") return null; - return dims.find((d) => d.value === raw) ?? null; - }; - const regionOption = findDim("region", aggregate.dimensions?.region ?? []); - const kindOption = findDim("kind", aggregate.dimensions?.kind ?? []); - const venueOption = findDim("venue", aggregate.dimensions?.venue ?? []); - - const loaderOpts: { - chain?: string; - region?: string; - kind?: string; - venue?: string; - } = {}; - if (chainOption) loaderOpts.chain = chainOption.value; - if (regionOption) loaderOpts.region = regionOption.value; - if (kindOption) loaderOpts.kind = kindOption.value; - if (venueOption) loaderOpts.venue = venueOption.value; - - const benchmark = - Object.keys(loaderOpts).length > 0 - ? (await getBenchmark(slug, loaderOpts)) ?? aggregate - : aggregate; - + const benchmark = chainOption + ? (await getBenchmark(slug, { chain: chainOption.value })) ?? aggregate + : aggregate; // No pill for `all` either - it's the unfiltered default view, calling // it out as a "chain" reads awkward. const chainLabel = chainOption?.label ?? null; - const filterPills: { kind: string; value: string }[] = []; - if (regionOption) filterPills.push({ kind: "REGION", value: regionOption.label }); - if (kindOption) filterPills.push({ kind: "KIND", value: kindOption.label }); - if (venueOption) filterPills.push({ kind: "VENUE", value: venueOption.label }); const rawTemplate = url.searchParams.get("template"); const template: "ranking" | "snapshot" | "headline" | "compare" | "leaderboard" = @@ -681,16 +585,16 @@ export async function GET( switch (template) { case "snapshot": - return renderSnapshot(filteredSafe, colors, chainLabel, filterPills); + return renderSnapshot(filteredSafe, colors, chainLabel); case "headline": - return renderHeadline(benchmark, colors, headlineProvider, chainLabel, filterPills); + return renderHeadline(benchmark, colors, headlineProvider, chainLabel); case "compare": - return renderCompare(benchmark, colors, compareA, compareB, chainLabel, filterPills); + return renderCompare(benchmark, colors, compareA, compareB, chainLabel); case "leaderboard": - return renderLeaderboard(benchmark, colors, chainLabel, filterPills); + return renderLeaderboard(benchmark, colors, chainLabel); case "ranking": default: - return renderRanking(benchmark, colors, chainLabel, filterPills); + return renderRanking(benchmark, colors, chainLabel); } } @@ -698,8 +602,7 @@ export async function GET( async function renderRanking( benchmark: Benchmark, colors: Map, - chainLabel?: string | null, - filterPills: { kind: string; value: string }[] = [] + chainLabel?: string | null ) { const sorted = sortByP50(benchmark); const maxP50 = Math.max(...sorted.map((r) => r.ms.p50)) || 1; @@ -707,7 +610,7 @@ async function renderRanking( // Scale type sizes down when the bench has many providers, otherwise // the long names (StellarExpert, WalletExplorer, …) collide. const dense = count >= 7; - const titleSize = scaledTitleSize(dense ? 44 : 56, benchmark.title); + const titleSize = dense ? 44 : 56; const valueSize = dense ? 22 : 26; const nameSize = dense ? 15 : 18; const captionSize = dense ? 11 : 12; @@ -719,7 +622,7 @@ async function renderRanking( return new ImageResponse( ( - +
{benchmark.title} @@ -748,7 +650,6 @@ async function renderRanking( color: INK_SOFT, lineHeight: 1.3, maxWidth: 980, - flexShrink: 0, }} > Product ranking by p50 · {benchmark.metric}. @@ -857,23 +758,29 @@ async function renderRanking( async function renderLeaderboard( benchmark: Benchmark, colors: Map, - chainLabel?: string | null, - filterPills: { kind: string; value: string }[] = [] + chainLabel?: string | null ) { - // Row height ~55px + gap 14 = ~70px per row. Content area is - // ~450px when title fits on 1 line and ~390px when it wraps to 2. - // Cap 6 = 420px which stays under both. - const sorted = sortByP50(benchmark).slice(0, 6); + const sorted = sortByP50(benchmark); const maxP50 = Math.max(...sorted.map((r) => r.ms.p50)) || 1; - const total = benchmark.results.length; - const subtitleLB = - total > sorted.length - ? `Top ${sorted.length} of ${total} · ranked by p50 · ${benchmark.metric}.` - : `Ranked by p50 · ${benchmark.metric}.`; + const subtitleLB = `Ranked by p50 · ${benchmark.metric}.`; + // Scale down type + spacing when the roster is dense OR the title is + // long, otherwise a 2-line 50pt title collides with the row list in + // the 630px canvas (weekend-drift 11 rows + long title case). + const count = sorted.length; + const titleLen = benchmark.title.length; + const dense = count >= 8 || titleLen > 55; + const veryDense = count >= 10 || titleLen > 70; + const titleSize = veryDense ? 32 : dense ? 40 : 50; + const rankSize = veryDense ? 18 : dense ? 20 : 24; + const nameSize = veryDense ? 18 : dense ? 20 : 24; + const valueSize = veryDense ? 22 : dense ? 24 : 28; + const barHeight = veryDense ? 6 : dense ? 7 : 8; + const rowGap = veryDense ? 8 : dense ? 10 : 14; + const logoSize = veryDense ? 22 : dense ? 24 : 28; return new ImageResponse( ( - +
{benchmark.title} @@ -901,7 +807,6 @@ async function renderLeaderboard( fontSize: 16, color: INK_SOFT, marginTop: 2, - flexShrink: 0, }} > {subtitleLB} @@ -913,8 +818,8 @@ async function renderLeaderboard( flexDirection: "column", flex: 1, justifyContent: "flex-start", - gap: 14, - marginTop: 18, + gap: rowGap, + marginTop: 14, }} > {sorted.map((r, i) => { @@ -923,15 +828,15 @@ async function renderLeaderboard( return (
@@ -942,7 +847,7 @@ async function renderLeaderboard( display: "flex", flexDirection: "column", flex: 1, - gap: 6, + gap: 4, }} >
{r.name} @@ -979,7 +884,7 @@ async function renderLeaderboard( > {fmtValue(r.ms.p50, benchmark.unit)} - + {unitSuffix(benchmark.unit, r.ms.p50).trim()} @@ -996,7 +901,7 @@ async function renderLeaderboard( style={{ display: "flex", width: "100%", - height: 8, + height: barHeight, background: `${color}22`, borderRadius: 4, }} @@ -1005,7 +910,7 @@ async function renderLeaderboard( style={{ display: "flex", width: `${widthPct}%`, - height: 8, + height: barHeight, background: color, borderRadius: 4, }} @@ -1027,17 +932,9 @@ async function renderLeaderboard( async function renderSnapshot( benchmark: Benchmark, colors: Map, - chainLabel?: string | null, - filterPills: { kind: string; value: string }[] = [] + chainLabel?: string | null ) { - // Cap dynamically by title length: long titles (>=90 chars, 2 lines) - // eat the vertical space that would otherwise fit the 2nd legend row, - // so shrink the cohort further to keep the legend from overlapping - // the footer. Values chosen from empirical layout tests on - // rpc-capabilities (106 chars, 13 providers) and oracle-deviation - // (60 chars, 10 providers). - const cap = benchmark.title.length >= 90 ? 6 : 8; - const sorted = sortByP50(benchmark).slice(0, cap); + const sorted = sortByP50(benchmark); const seriesList = sorted .map((r) => ({ slug: r.slug, @@ -1054,11 +951,7 @@ async function renderSnapshot( .filter((s) => s.values.length > 1); const chartW = 1086; - // 280 was too tall - the legend routinely wraps to 2 rows and - // overlaps the footer on wide-cohort benches (rpc-capabilities 13, - // ethereum-rpc 8, perp-fees 8). 220 leaves ~60px more for 2 legend - // rows to fit above the footer. - const chartH = 220; + const chartH = 280; const all = seriesList.flatMap((s) => s.values); const min = all.length ? Math.min(...all) : 0; const max = all.length ? Math.max(...all) : 1; @@ -1067,7 +960,7 @@ async function renderSnapshot( return new ImageResponse( ( - +
+ {/* Title + subtitle. Satori quirk: a bare `display: flex` + text div stays at one-line height even when the text + visually wraps, so the next sibling stacks on top of the + wrapped lines. Explicit `flexDirection: column` on each + text box forces satori to measure the wrapped content + height. maxWidth pins the wrap point below the container + width so long titles never touch the right edge. */}
{benchmark.title} @@ -1092,10 +993,11 @@ async function renderSnapshot(
{benchmark.subtitle} @@ -1231,8 +1133,7 @@ async function renderHeadline( benchmark: Benchmark, colors: Map, featured?: Benchmark["results"][number], - chainLabel?: string | null, - filterPills: { kind: string; value: string }[] = [] + chainLabel?: string | null ) { const sorted = sortByP50(benchmark); const winner = featured ?? sorted[0]; @@ -1244,7 +1145,7 @@ async function renderHeadline( return new ImageResponse( ( - +
, paneA?: Benchmark["results"][number], paneB?: Benchmark["results"][number], - chainLabel?: string | null, - filterPills: { kind: string; value: string }[] = [] + chainLabel?: string | null ) { const sorted = sortByP50(benchmark); const a = paneA ?? sorted[0]; @@ -1359,7 +1259,7 @@ async function renderCompare( paneB && paneB.slug !== a?.slug ? paneB : sorted.find((r) => r.slug !== a?.slug); - if (!a || !b) return renderHeadline(benchmark, colors, a, chainLabel, filterPills); + if (!a || !b) return renderHeadline(benchmark, colors, a); const aColor = colors.get(a.slug) ?? INK_SOFT; const bColor = colors.get(b.slug) ?? INK_SOFT; @@ -1372,7 +1272,7 @@ async function renderCompare( return new ImageResponse( ( - +
{benchmark.title} · top 2 diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 350d8e27..793c4fdc 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -183,16 +183,25 @@ export default async function ProviderPage({ // Vercel keeps serving the last good render instead of caching a page // full of "data warming up" for the next 5 minutes. // - // Only fire when at least one appearance has a non-zero p50: that - // rules out newly-added providers whose store snapshot lags the harness - // by one aggregate cycle (thirdweb ship, 2026-07-24: 4 fresh appearances - // all rank=0 while the CDN blob still served the pre-ship snapshot). + // Tightened gate: only fire when EVERY appearance claims availability + // "live", is not marked unresponsive, AND has a non-zero p50. This + // captures the true store-read-failure signature (data present but + // ranking silently failed) while excluding newly-added providers whose + // store snapshot lags the harness by one aggregate cycle (thirdweb + // ship 2026-07-24: 4 fresh appearances all rank=0 while the CDN blob + // still served the pre-ship snapshot with p50=0). + const allClaimLive = p.appearances.every( + (a) => + a.result.availability === "live" && + a.result.unresponsive !== true, + ); const anyMeasuredAppearance = p.appearances.some( (a) => (a.result.ms?.p50 ?? 0) > 0, ); if ( p.appearances.length >= 3 && p.appearances.every((a) => a.rank === 0) && + allClaimLive && anyMeasuredAppearance ) { throw new Error(`degraded store read for /products/${slug}: ${p.appearances.length} appearances, all unranked`); diff --git a/src/components/home-bench-table.tsx b/src/components/home-bench-table.tsx index bd475b12..d26258d7 100644 --- a/src/components/home-bench-table.tsx +++ b/src/components/home-bench-table.tsx @@ -3,6 +3,7 @@ import { ArrowUpRight } from "lucide-react"; import type { Benchmark } from "@/types/benchmark"; import { Hint } from "@/components/hint"; import { MiniChart } from "@/components/mini-chart"; +import { ProviderLogo } from "@/components/provider-logo"; import { CATEGORY_COLOR } from "@/lib/category-colors"; import { fieldValue, isInsufficient, leader } from "@/lib/citation"; import { fmtValue, unitSuffix } from "@/lib/format"; @@ -90,9 +91,12 @@ function BenchTitleCell({ b }: { b: Benchmark }) { {chips.map((c) => (
  • - - {c.name.charAt(0)} - + {/* Was: bare first-letter chip that collapsed + "Mobula/CoinGecko/CoinPaprika/Moralis" into "M C C M" + with no useful signal. ProviderLogo renders the real + brand mark (falls back to a multi-letter initials + chip when the logo is missing). */} +
  • ))} diff --git a/src/components/live/live-number.tsx b/src/components/live/live-number.tsx index c8db0d74..6508076c 100644 --- a/src/components/live/live-number.tsx +++ b/src/components/live/live-number.tsx @@ -28,20 +28,50 @@ export function LiveNumber({ value, format, monotonic = false, + maxValue, className, }: { value: number | undefined; format: (n: number | undefined) => string; monotonic?: boolean; + /** Defensive ceiling. A single garbage push from the upstream relay + * (observed: onchain vol24h briefly returning 8e79) would otherwise + * latch into `lastRef` and, under `monotonic`, stick as the + * displayed value forever because every subsequent healthy push is + * smaller and gets rejected as a "down tick". Reject any incoming + * value above the ceiling before it can pollute the snapshot pair. */ + maxValue?: number; className?: string; }) { - const [display, setDisplay] = useState(value); + // Never SEED the display with a value that already exceeds the + // ceiling. Otherwise the very first render (before any useEffect runs) + // would paint the garbage, then the monotonic guard would reject + // every healthy value that follows as a "down tick" and the garbage + // would stick until the tab was closed. + const seed = + value == null || (maxValue != null && value > maxValue) ? undefined : value; + const [display, setDisplay] = useState(seed); const lastRef = useRef<{ value: number; ts: number } | null>(null); const prevRef = useRef<{ value: number; ts: number } | null>(null); const rafRef = useRef(null); useEffect(() => { if (value == null || !Number.isFinite(value)) return; + if (maxValue != null && value > maxValue) { + // Flush a poisoned lastRef AND the displayed value so a healthy + // push can seed the ticker fresh on the next tick. Without + // resetting `display`, the monotonic guard below would keep + // rejecting the healthy value as smaller than the garbage still + // shown to the user. + if (lastRef.current && lastRef.current.value > maxValue) { + lastRef.current = null; + prevRef.current = null; + } + setDisplay((d) => + d != null && maxValue != null && d > maxValue ? undefined : d, + ); + return; + } const now = performance.now(); // Only advance the snapshot pair on DISTINCT values. Consecutive pushes // with the same value (relay 1 Hz tick × lighthouse 1-5 min poll) would @@ -53,7 +83,7 @@ export function LiveNumber({ if (lastRef.current.value === value) return; prevRef.current = lastRef.current; lastRef.current = { value, ts: now }; - }, [value]); + }, [value, maxValue]); useEffect(() => { let cancelled = false; diff --git a/src/components/live/ticker.tsx b/src/components/live/ticker.tsx index 1409ef69..118504e2 100644 --- a/src/components/live/ticker.tsx +++ b/src/components/live/ticker.tsx @@ -56,22 +56,30 @@ export const LiveTicker = memo(function LiveTicker({
    + {/* maxValue: defensive ceilings against upstream corruption. Real + world highs (crypto ATH): vol24h ~$500B, trades24h ~50M, + mcap ~$4T. Ceilings sit 20-100x above those to leave room for + plausible growth while catching the 10^20+ garbage tick that + latched the ticker at 8e79 on the home page on 2026-07-17. */}