diff --git a/benchmarks/hyperliquid-hip3-deployers.yml b/benchmarks/hyperliquid-hip3-deployers.yml index e8c8f2eb..57f3459b 100644 --- a/benchmarks/hyperliquid-hip3-deployers.yml +++ b/benchmarks/hyperliquid-hip3-deployers.yml @@ -54,7 +54,7 @@ abstract: | methodology: - "Data source. A local hl node operated on OCB infrastructure tails the Hyperliquid mainnet and writes every block of fills to node_fills_by_block hourly YYYYMMDD HH on local disk. Each line is one JSON block, the events array carries one entry per fill, and every fill on a HIP-3 market includes the dex namespaced coin (xyz:AAPL) plus the deployerFee in USDC." - - "Attribution. A fill belongs to a HIP-3 dex when its coin carries a namespace prefix (xyz:AAPL belongs to xyz). Namespaces are unique on chain by construction, so no hand curated registry is needed; a new deployer appears on the leaderboard with its first observed fill. Core Hyperliquid markets carry no namespace and no deployer fee, they are excluded." + - "Attribution. A fill belongs to a HIP-3 dex when its coin carries a namespace prefix (xyz:AAPL belongs to xyz). Namespaces are unique on chain by construction, so no hand curated registry is needed; the harness starts tracking a new deployer with its first observed fill, and the namespace is added to this page on the next spec review. Core Hyperliquid markets carry no namespace and no deployer fee, they are excluded." - "Cadence. The Go harness re reads the current and previous hourly files every 30 seconds, parses appended lines, and updates per dex hourly buckets keyed by the UTC hour floor of the fill timestamp. Prometheus scrapes the metrics every 30 seconds via a Caddy reverse proxy with basic auth. End to end staleness from fill landing on chain to bench page render is typically under one minute." - "Headline calculation. For each dex over the last 24 rolling hours we sum the deployerFee USD values of every fill on its namespaced markets. The 7 day and 30 day figures sum the same field over the last 168 and 720 hourly buckets, so the metric is consistent across the three ranges." - "Volume companion. hl_hip3_deployer_volume_usd_24h, _7d and _30d are computed the same way on the px times sz product of every fill. They feed the Volume column and tab." @@ -83,7 +83,7 @@ faq: - q: "Where does the data come from?" a: "A local hl node operated on OCB infrastructure tails the Hyperliquid mainnet. The node writes every block of fills to disk; a Go harness running on the same host reads these files continuously, attributes namespaced fills to their dex, and aggregates per hour. No third party API, no internal Mobula service." - q: "How are dexes identified?" - a: "By their on chain coin namespace. Every HIP-3 market trades under a prefix (xyz:AAPL, vntl:MAG7, km:US500) that is unique to its deployer. The set is discovered dynamically from the fill stream, so a brand new deployer appears on the leaderboard with its first fill. Namespaces not yet matched to a public brand are listed under the raw prefix." + a: "By their on chain coin namespace. Every HIP-3 market trades under a prefix (xyz:AAPL, vntl:MAG7, km:US500) that is unique to its deployer. The harness discovers the set dynamically from the fill stream and starts metering a brand new deployer with its first fill; the page lists each new namespace after a short spec review. Namespaces not yet matched to a public brand are listed under the raw prefix." - q: "Why is trade.xyz so far ahead?" a: "It operates the deepest tokenized equity and commodity catalog on Hyperliquid, more than 70 markets including the large cap US names, and captures the bulk of HIP-3 open interest. Deployer revenue is volume times fee policy, and xyz leads on both breadth and notional." - q: "What does the Effective fee bps column tell me?" diff --git a/harnesses/hyperliquid-frontends/README.md b/harnesses/hyperliquid-frontends/README.md index 11e994e7..3164e2bb 100644 --- a/harnesses/hyperliquid-frontends/README.md +++ b/harnesses/hyperliquid-frontends/README.md @@ -1,78 +1,31 @@ -# hyperliquid-frontends harness +# hyperliquid-frontends-local -OpenChainBench bench № 030 — quality benchmark of Hyperliquid frontends, ranked by **how much they extract from users**, not by raw volume share. +Local harness that reads the hl-node L1 output directly from +`/mnt/hyperliquid/data/node_fills_by_block/hourly/` instead of fetching the +public daily CSV bucket. Produces the same per-builder metrics as the +`hyperliquid-frontends` harness but at sub-minute freshness instead of 24-48h +lag. -## What it measures +Deployed on the OVH SGP server where the hl-node runs. Exposes Prometheus +metrics on `127.0.0.1:2113/metrics`, fronted by Caddy with basic auth on +`:8088` for OCB Prom scraping. -Three quality metrics per builder, refreshed hourly from the public daily fills dumps Hyperliquid publishes at `https://stats-data.hyperliquid.xyz/Mainnet/builder_fills/{address}/{YYYYMMDD}.csv.lz4`: +Metrics emitted (all labeled by `slug`): -| Metric | Definition | -|---|---| -| **Effective fee bps** | `sum(builder_fee) / sum(notional) × 10 000` — volume-weighted average over 24 h | -| **$ per user** | `sum(builder_fee) / count(distinct user)` — raw efficiency per active trader | -| **Fee discipline** | `stddev_over_time(effective_fee_bps[30d])` — computed in Prometheus, surfaces rotating-promo cycles | +- `hl_frontend_volume_usd_24h_v2` +- `hl_frontend_fees_usd_24h_v2` +- `hl_frontend_users_24h_v2` +- `hl_frontend_fills_total_24h_v2` +- `hl_frontend_effective_fee_bps_v2` +- `hl_frontend_local_last_tick_unix_v2` (heartbeat) -Headline ranking = lowest effective fee = most aligned with traders. - -Why this framing: the Hyperliquid frontend wars already have a dozen volume-share dashboards (ASXN HyperScreener, Coinmarketman HyperTracker, Flowscan, Allium, Hyperdash, several Dune boards). What nobody publishes cleanly is the user-cost side. This bench is the user-cost side. - -## Builder registry - -`builders.json` is a hand-curated `[{slug, name, address, valid_from, notes}]` array. Addresses are cross-referenced against: -- Flowscan `/builders` — live leaderboard -- Hyperliquid governance forum disclosures -- Each frontend's public announcement of their builder code - -**The committed file ships with placeholder `0x0000…` addresses.** They MUST be filled in before the harness can fetch anything useful. The harness will return `403` for every placeholder address (the Hyperliquid bucket returns 403 when the date file doesn't exist for that address). - -Workflow for adding / updating addresses: -1. Pull the latest Flowscan builders list, cross-check with ASXN's published mapping -2. Edit `builders.json`, bump `valid_from` to today -3. PR with a Flowscan screenshot in the description so the registry change is auditable -4. After merge, redeploy the harness on Railway - -## Prometheus metrics emitted +Run flags: ``` -hl_frontend_effective_fee_bps{builder} gauge -hl_frontend_fees_per_user_usd{builder} gauge -hl_frontend_volume_usd_24h{builder} gauge -hl_frontend_users_24h{builder} gauge -hl_frontend_fills_total{builder} gauge -hl_frontend_unattributed_share_pct gauge -hl_frontend_registry_age_seconds gauge -hl_frontend_csv_fetch_status_total{builder,code} counter +hl-frontends-local \ + -data /mnt/hyperliquid/data/node_fills_by_block/hourly \ + -builders builders.json \ + -addr 127.0.0.1:2113 \ + -window-hours 24 \ + -tick 30s ``` - -Bench page column mapping is documented in `benchmarks/hyperliquid-frontends.yml` — p50 / p90 / p99 are repurposed for effective fee / 30d stddev / $/user respectively since the unit is bps and there is no native percentile semantics. - -## Run locally - -```bash -cd harnesses/hyperliquid-frontends -go run ./cmd/script -curl http://localhost:2112/metrics | grep hl_frontend_ -``` - -## Deploy - -Standard OCB-miniapp shape — multi-stage Dockerfile, port 2112, scraped by the shared `openchainbench-monitoring` Prometheus via `hyperliquid-frontends.railway.internal:2112`. - -After deploy, add a scrape config entry: - -```yaml -- job_name: 'hyperliquid-frontends' - static_configs: - - targets: - - 'hyperliquid-frontends.railway.internal:2112' - labels: - benchmark: hyperliquid-frontends - metrics_path: /metrics -``` - -## Known limits - -- **Daily granularity** — the CSV bucket only rolls at UTC midnight. Intra-day movement is invisible. A v1.1 upgrade would consume the WebSocket `userFills` stream filtered on the `b` field for realtime. -- **Native HL UI excluded** — orders without a builder code are not attributable here. Covered by the separate `/benchmarks/aggregator-head-lag` bench. -- **Wash trading** — fills are taken at face value. A frontend running wash flow shows up exactly as the chain records. Mitigation = the unique-user column flags anomalous low-user / high-volume signatures. A sybil-cluster heuristic ships in v1.1. -- **Registry maintenance** — the builder address list is hand-curated. The `hl_frontend_unattributed_share_pct` metric makes the coverage gap visible; an alert fires when it crosses 2 %. diff --git a/harnesses/hyperliquid-frontends/builders.json b/harnesses/hyperliquid-frontends/builders.json index c6f8f0ec..66e66b84 100644 --- a/harnesses/hyperliquid-frontends/builders.json +++ b/harnesses/hyperliquid-frontends/builders.json @@ -46,13 +46,486 @@ "name": "Dexari", "address": "0x7975cafdff839ed5047244ed3a0dd82a89866081", "valid_from": "2026-05-31", - "notes": "Dexari HL frontend (previously called Dexterity in our internal docs — naming corrected after Flowscan cross-reference). Verified via CMM registry." + "notes": "Dexari HL frontend (previously called Dexterity in our internal docs, naming corrected after Flowscan cross-reference). Verified via CMM registry." }, { "slug": "okto", "name": "Okto", "address": "0x05984fd37db96dc2a11a09519a8def556e80590b", "valid_from": "2026-05-31", - "notes": "Okto HL integration (primary address). Documented secondary: 0x4fe1141b9066f3777f4bd4d4ac9d216173031dc1." + "notes": "Okto HL integration. Tracks both documented builder addresses (primary + secondary). Either may receive routed flow depending on Okto's deployment state.", + "addresses": [ + "0x05984fd37db96dc2a11a09519a8def556e80590b", + "0x4fe1141b9066f3777f4bd4d4ac9d216173031dc1" + ] + }, + { + "slug": "trust-wallet", + "name": "Trust Wallet", + "address": "0x5af1b5f44207784dcb850bbb4143c5dcd1885f71", + "valid_from": "2026-06-02", + "notes": "Trust Wallet HL perps integration. Source: DefiLlama dimension-adapters factory/hyperliquid.ts dexsProtocols." + }, + { + "slug": "sushi", + "name": "Sushi", + "address": "0x12ee177db3ceafedc639d023a29cc8588db3a4b9", + "valid_from": "2026-06-02", + "notes": "Sushi perps frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "dreamcash", + "name": "Dreamcash", + "address": "0x4950994884602d1b6c6d96e4fe30f58205c39395", + "valid_from": "2026-06-02", + "notes": "Dreamcash HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "based-app", + "name": "Based", + "address": "0x1924b8561eef20e70ede628a296175d358be80e5", + "valid_from": "2026-06-02", + "notes": "Based app HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "blink", + "name": "Blink", + "address": "0xc7bcb2eee9bbfbf875499960746bc52b2e1a75c6", + "valid_from": "2026-06-02", + "notes": "Blink perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "perpmate", + "name": "Perpmate", + "address": "0xe4fea748eca48f44b1e042775f0c2363be1a2d80", + "valid_from": "2026-06-02", + "notes": "Perpmate HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "arena", + "name": "Arena", + "address": "0x7056a6bc0a962b6ca37bc5da4c4c5127c81b7af3", + "valid_from": "2026-06-02", + "notes": "Arena perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "minaraai", + "name": "MinaraAI", + "address": "0x5a3bc60b0a99a7f4fbf0d15554fa5fe88e7628c2", + "valid_from": "2026-06-02", + "notes": "MinaraAI HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "apexliquid", + "name": "ApexLiquid", + "address": "0xe1f55f2f25884c2ddc86b6f7efa5f45b2ef04221", + "valid_from": "2026-06-02", + "notes": "ApexLiquid HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "coin98", + "name": "Coin98", + "address": "0x3342ee6851ef0ec3cf42658c2be3b28a905271aa", + "valid_from": "2026-06-02", + "notes": "Coin98 wallet HL perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "coinpilot", + "name": "CoinPilot", + "address": "0xe9935bb291ab3603b4d7862e6f19315f759aa3a4", + "valid_from": "2026-06-02", + "notes": "CoinPilot HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts. Note: same address listed for splashos-perps in DefiLlama config, likely a config bug there." + }, + { + "slug": "echosync", + "name": "Echosync", + "address": "0x831ad7eb3e600a3ab8df851ce27df8d8dd6b5d9c", + "valid_from": "2026-06-02", + "notes": "Echosync HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "fomo", + "name": "FOMO", + "address": "0xb838e4d1c8bcf71fa8e63299d5aa3258c83d6adb", + "valid_from": "2026-06-02", + "notes": "FOMO perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "gemwallet", + "name": "Gem Wallet", + "address": "0x0d9dab1a248f63b0a48965ba8435e4de7497a3dc", + "valid_from": "2026-06-02", + "notes": "Gem Wallet HL perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "gtr-trade", + "name": "GTR Trade", + "address": "0x5ef4deeb76f87d979d0ddc8c51f5b4f65d1c972a", + "valid_from": "2026-06-02", + "notes": "GTR Trade HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "hyprearn", + "name": "Hyprearn", + "address": "0x70cf605bb180daf00c3e2f1ca3df5bb602664452", + "valid_from": "2026-06-02", + "notes": "Hyprearn HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "legend-trade", + "name": "Legend Trade", + "address": "0x4e65de9ca0abe3d36f7e3d7a7ce9f0dbe406a412", + "valid_from": "2026-06-02", + "notes": "Legend Trade HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "katoshi", + "name": "Katoshi", + "address": "0x274e3cdb7bdc4805f41a07e3348243ba3e7e5b72", + "valid_from": "2026-06-02", + "notes": "Katoshi perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "metascalp", + "name": "Metascalp", + "address": "0xa9ab442f9dfe752dc74b666c41e7a0498baf8687", + "valid_from": "2026-06-02", + "notes": "Metascalp perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "moontrader", + "name": "Moontrader", + "address": "0x38b176c674cd9a3b97a59b0a7045ba26a13783cb", + "valid_from": "2026-06-02", + "notes": "Moontrader HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "onekey", + "name": "OneKey", + "address": "0x9b12e858da780a96876e3018780cf0d83359b0bb", + "valid_from": "2026-06-02", + "notes": "OneKey wallet HL perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "pear", + "name": "Pear", + "address": "0xa47d4d99191db54a4829cdf3de2417e527c3b042", + "valid_from": "2026-06-02", + "notes": "Pear interface. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "rabby", + "name": "Rabby", + "address": "0xad9be64fd7a35d99a138b87cb212baefbcdcf045", + "valid_from": "2026-06-02", + "notes": "Rabby wallet HL perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "ranger-finance", + "name": "Ranger Finance", + "address": "0xf5bc9107916b91a3ea5966cd2e51655d21b7eb02", + "valid_from": "2026-06-02", + "notes": "Ranger Finance perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "senpi", + "name": "Senpi", + "address": "0x1368f4311db5807f7c7924d736adaeb83e47bafe", + "valid_from": "2026-06-02", + "notes": "Senpi perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "superx", + "name": "SuperX", + "address": "0x4ecd58def11dc3cadf7deb09f27da69d5475acb3", + "valid_from": "2026-06-02", + "notes": "SuperX HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "supurr", + "name": "Supurr", + "address": "0x36be02a397e969e010ccbd7333f4169f66b8989f", + "valid_from": "2026-06-02", + "notes": "Supurr perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "unigox", + "name": "Unigox", + "address": "0xf8ead1ecc72dfbb87cdd7bf78450f7cf68d046a3", + "valid_from": "2026-06-02", + "notes": "Unigox perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "uxuy", + "name": "UXUY", + "address": "0x2e266a0f40e9f5bca48f5df1686aab10b1b68ec8", + "valid_from": "2026-06-02", + "notes": "UXUY HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "wunder", + "name": "Wunder", + "address": "0x75982eb8b734b24b653b39e308489a428041f162", + "valid_from": "2026-06-02", + "notes": "Wunder perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "grider", + "name": "Grider", + "address": "0x0176337c97bb884b8ac4be2276a5c779ab1156b9", + "valid_from": "2026-06-02", + "notes": "Grider perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "tradoor", + "name": "Tradoor", + "address": "0x92345453ce2000642d7d4ceeae4fccc6c2e41d23", + "valid_from": "2026-06-02", + "notes": "Tradoor perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "bullpenfi", + "name": "BullpenFi", + "address": "0x4c8731897503f86a2643959cbaa1e075e84babb7", + "valid_from": "2026-06-02", + "notes": "BullpenFi perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "dexly-trade", + "name": "Dexly Trade", + "address": "0x22047776933bc123d0602ed17aaf0d2f5647df0c", + "valid_from": "2026-06-02", + "notes": "Dexly Trade. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "hyperdash", + "name": "Hyperdash", + "address": "0xe966a12bf7b93838096e4519a684519ab22df618", + "valid_from": "2026-06-02", + "notes": "Hyperdash HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "infinex", + "name": "Infinex", + "address": "0xcf56dd84ed85eb4929e0a76a0f2f04049b4ffc1a", + "valid_from": "2026-06-02", + "notes": "Infinex perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "liminal", + "name": "Liminal", + "address": "0x7e1830b1796b01f2f6a7118d50d4d02491421f32", + "valid_from": "2026-06-02", + "notes": "Liminal perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "liquid-perps", + "name": "Liquid Perps", + "address": "0x6d4e7f472e6a491b98cbeed327417e310ae8ce48", + "valid_from": "2026-06-02", + "notes": "Liquid Perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "lit-trade", + "name": "Lit Trade", + "address": "0x24a747628494231347f4f6aead2ec14f50bcc8b7", + "valid_from": "2026-06-02", + "notes": "Lit Trade. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "lootbase", + "name": "Lootbase", + "address": "0x3e0ef9ad4096c30acefbf7a996f4c19edd071286", + "valid_from": "2026-06-02", + "notes": "Lootbase HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "mass-dot-money", + "name": "Mass.money", + "address": "0xf944069b489f1ebff4c3c6a6014d58cbef7c7009", + "valid_from": "2026-06-02", + "notes": "Mass.money perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "moonbot", + "name": "Moonbot", + "address": "0xb84c7fb41ee7d8781e2b0d59eed2accd2ae99533", + "valid_from": "2026-06-02", + "notes": "Moonbot. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "rainbow", + "name": "Rainbow", + "address": "0x60dc8e3dad2e4e0738e813b9cb09b9c00b5e0fc9", + "valid_from": "2026-06-02", + "notes": "Rainbow wallet HL perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "supercexy", + "name": "SuperCEXy", + "address": "0x0000000bfbf4c62c43c2e71ef0093f382bf7a7b4", + "valid_from": "2026-06-02", + "notes": "SuperCEXy. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "superstack", + "name": "Superstack", + "address": "0xcdb943570bcb48a6f1d3228d0175598fea19e87b", + "valid_from": "2026-06-02", + "notes": "Superstack. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "wallet-v", + "name": "Wallet V", + "address": "0x68c68ba58f50bdbe5c4a6faf0186b140eab2b764", + "valid_from": "2026-06-02", + "notes": "Wallet V. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "xtrade-protocol", + "name": "xTrade Protocol", + "address": "0xa58d3d31f09d75bd92ae2ef277e785b2ebb83b77", + "valid_from": "2026-06-02", + "notes": "xTrade Protocol perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "taco-trade", + "name": "Taco Trade", + "address": "0xf5b79dea3d8cf3efa95e8176ebd885634d869f51", + "valid_from": "2026-06-02", + "notes": "Taco Trade. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "silhouette", + "name": "Silhouette", + "address": "0x5d2c2bd98f10616771d7b5124ad2090ba72aa43c", + "valid_from": "2026-06-02", + "notes": "Silhouette (silhouette-naked). Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "tread-fi", + "name": "Tread.fi", + "address": "0x999a4b5f268a8fbf33736feff360d462ad248dbf", + "valid_from": "2026-06-02", + "notes": "Tread.fi HL frontend. Source: DefiLlama dimension-adapters dexs/treadfi-perps.ts." + }, + { + "slug": "flowbot", + "name": "FlowBot", + "address": "0xb5d19a1f92fcd5bfdd154d16793bb394f246cb36", + "valid_from": "2026-06-02", + "notes": "FlowBot HL frontend. Source: DefiLlama dimension-adapters dexs/flowbot-perps.ts." + }, + { + "slug": "nautilus-trader", + "name": "Nautilus Trader", + "address": "0x0c8d970c462726e014ad36f6c5a63e99db48a8e7", + "valid_from": "2026-06-02", + "notes": "Nautilus Trader (Nautech Systems). Identified via behavioral pattern (heavy MU 85%, 0 builder fee, low user count) cross-referenced with GitHub code search." + }, + { + "slug": "0x7cc0fd2b", + "name": "0x7cc0…e781", + "address": "0x7cc0fd2b76835ab96aa4a3501a9f65e75677e781", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$5703/24h fees, 2514 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x557edb25", + "name": "0x557e…3c81", + "address": "0x557edb253b1d7ed5f15b248a5a3fd919fa5d3c81", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$4157/24h fees, 2771 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x446fbc72", + "name": "0x446f…d6c6", + "address": "0x446fbc72549fdcf656a5165f97b354e5a0e7d6c6", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$1836/24h fees, 22 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xf85a6185", + "name": "0xf85a…5688", + "address": "0xf85a61857c0682b9b59d562310df106b4f785688", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$1805/24h fees, 24 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xb290f2f3", + "name": "0xb290…c34a", + "address": "0xb290f2f3fad4e540d0550985951cdad2711ac34a", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$1641/24h fees, 57 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xa1fcd6e2", + "name": "0xa1fc…bf4d", + "address": "0xa1fcd6e2356c445d38ec1e25eae634e1104abf4d", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$1181/24h fees, 28 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x53a19541", + "name": "0x53a1…fcd1", + "address": "0x53a1954188fc9bf2edb45b94450100507b92fcd1", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$1132/24h fees, 56 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xdbc27ea7", + "name": "0xdbc2…97a6", + "address": "0xdbc27ea7aa99274026404b2fa21114815d9997a6", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$938/24h fees, 123 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xea2c82b5", + "name": "0xea2c…75b3", + "address": "0xea2c82b5aba243ab631c0ce151763d5e38df75b3", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$880/24h fees, 121 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x49791d46", + "name": "0x4979…37b8", + "address": "0x49791d4667e310abe173bc4989aed4f0bed837b8", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$851/24h fees, 4 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x9f83fe01", + "name": "0x9f83…31d9", + "address": "0x9f83fe01f4a62d44e8ca471e2eeb42b5c05531d9", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$733/24h fees, 135 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x42f32260", + "name": "0x42f3…f992", + "address": "0x42f3226007290b02c5a0b15bccbb1ba6df04f992", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$690/24h fees, 99 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xc95d92dc", + "name": "0xc95d…1aca", + "address": "0xc95d92dc8ca672abcc8aaec49a94559bbf481aca", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$543/24h fees, 17 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xdf39721d", + "name": "0xdf39…4750", + "address": "0xdf39721d2c4fc0fedc92c68e3879ba594bb64750", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$525/24h fees, 84 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x40e9d9fe", + "name": "0x40e9…5260", + "address": "0x40e9d9feba3df27e1fb9a924264bf775230d5260", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$513/24h fees, 13 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." } -] +] \ No newline at end of file diff --git a/harnesses/hyperliquid-frontends/cmd/script/fetcher.go b/harnesses/hyperliquid-frontends/cmd/script/fetcher.go deleted file mode 100644 index f04cc952..00000000 --- a/harnesses/hyperliquid-frontends/cmd/script/fetcher.go +++ /dev/null @@ -1,225 +0,0 @@ -package main - -import ( - "context" - "encoding/csv" - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" - - "github.com/pierrec/lz4/v4" -) - -// processBuilder fetches today's + yesterday's Hyperliquid fills CSV -// for one builder address, aggregates the last complete UTC day, -// updates every Prom metric for that builder, AND upserts each fill -// into the SQLite state so the retention pass later in the cycle -// sees fresh data. Returns the day notional so the caller can -// compute the cross-builder volume share. -// -// The headline window is `yesterday's complete UTC day` (i.e. the -// CSV for date T-1). We switched from rolling-24h because the -// rolling cutoff was unfairly zeroing builders whose flow lands -// early in the UTC day — at 18:00 UTC the cutoff `now - 24h` is -// 18:00 yesterday, so a builder that traded between 00:00-17:00 -// yesterday would show 0. The UTC-day window gives every builder -// the same comparison surface at the cost of 0-24h of staleness, -// which is fine for a fee-quality bench (frontends don't change -// pricing minute-to-minute). Today's CSV is still fetched so the -// SQLite state captures the in-progress day for retention math. -func processBuilder(ctx context.Context, b Builder, state *State) float64 { - now := time.Now().UTC() - - // Hyperliquid publishes the per-day CSV with a variable delay — - // usually T-1 lands within hours of UTC midnight, but the bucket - // has gone 48 h+ without a fresh file (observed 2026-05-31). To - // avoid zeroing every builder on a publish lag, walk back up to - // 3 days and pick the most recent date that returned a non-empty - // CSV. State always upserts from whatever days we did fetch so - // retention math gets the freshest signal regardless. - dates := []time.Time{ - now.AddDate(0, 0, -1), - now.AddDate(0, 0, -2), - now.AddDate(0, 0, -3), - } - var headlineFills []fillRow - var headlineDateKey string - for _, d := range dates { - batch, code, err := fetchDay(ctx, b.Address, d) - hlCSVFetchStatus.WithLabelValues(b.Slug, code).Inc() - if err != nil { - fmt.Printf("[%s] %s: %s err=%v\n", b.Slug, d.Format("20060102"), code, err) - continue - } - // State always gets every fill — retention math wants the - // long history of unique users, not just the headline day. - for _, f := range batch { - if state != nil && f.User != "" { - if err := state.Upsert(b.Slug, f.User, f.Time.UnixMilli(), f.Px*f.Sz); err != nil { - fmt.Printf("[%s] state upsert error: %v\n", b.Slug, err) - } - } - } - // Pick the first day that has at least one fill — that's - // the freshest "complete UTC day" for this builder. - if headlineDateKey == "" && len(batch) > 0 { - headlineFills = batch - headlineDateKey = d.Format("20060102") - } - } - - var ( - notionalUSD float64 - builderFeeUSD float64 - fillCount int - users = make(map[string]struct{}) - ) - for _, f := range headlineFills { - notionalUSD += f.Px * f.Sz - builderFeeUSD += f.BuilderFee - fillCount++ - if f.User != "" { - users[f.User] = struct{}{} - } - } - - hlFillsTotal.WithLabelValues(b.Slug).Set(float64(fillCount)) - hlVolumeUSD24h.WithLabelValues(b.Slug).Set(notionalUSD) - hlUsers24h.WithLabelValues(b.Slug).Set(float64(len(users))) - - if notionalUSD > 0 { - hlEffectiveFeeBps.WithLabelValues(b.Slug).Set(builderFeeUSD / notionalUSD * 10_000) - } - if len(users) > 0 { - hlFeesPerUserUSD.WithLabelValues(b.Slug).Set(builderFeeUSD / float64(len(users))) - } - - fmt.Printf("[%s] day=%s fills=%d notional=$%.0f fees=$%.2f users=%d eff=%.2fbps\n", - b.Slug, headlineDateKey, fillCount, notionalUSD, builderFeeUSD, len(users), - safeRatio(builderFeeUSD, notionalUSD)*10_000) - return notionalUSD -} - -type fillRow struct { - Time time.Time - User string - Coin string - Side string - Px float64 - Sz float64 - BuilderFee float64 -} - -// fetchDay GETs the per-day CSV dump, LZ4-decompresses, parses into -// fillRow slices. Returns (rows, http_code_label, err). The -// http_code_label is the string we want to label the -// `hl_frontend_csv_fetch_status_total` counter with — "200" for OK, -// "403" for "no fills that day" (Hyperliquid bucket returns 403 when -// the date file doesn't exist), "5xx" for server errors, "error" for -// transport-level failures. -func fetchDay(ctx context.Context, address string, day time.Time) ([]fillRow, string, error) { - url := fmt.Sprintf( - "https://stats-data.hyperliquid.xyz/Mainnet/builder_fills/%s/%s.csv.lz4", - strings.ToLower(address), - day.Format("20060102"), - ) - reqCtx, cancel := context.WithTimeout(ctx, httpTimeout) - defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) - if err != nil { - return nil, "error", err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, "error", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if resp.StatusCode >= 500 { - return nil, "5xx", fmt.Errorf("http %d", resp.StatusCode) - } - return nil, fmt.Sprintf("%d", resp.StatusCode), nil - } - - dec := lz4.NewReader(resp.Body) - rd := csv.NewReader(dec) - rd.FieldsPerRecord = -1 - header, err := rd.Read() - if err != nil { - return nil, "200", fmt.Errorf("header: %w", err) - } - col := map[string]int{} - for i, h := range header { - col[strings.TrimSpace(h)] = i - } - required := []string{"time", "user", "px", "sz", "builder_fee"} - for _, r := range required { - if _, ok := col[r]; !ok { - return nil, "200", fmt.Errorf("missing column %q in header %v", r, header) - } - } - - var rows []fillRow - for { - rec, err := rd.Read() - if err == io.EOF { - break - } - if err != nil { - return rows, "200", fmt.Errorf("row: %w", err) - } - if len(rec) < len(header) { - continue - } - t, err := parseFillTime(rec[col["time"]]) - if err != nil { - continue - } - px, _ := strconv.ParseFloat(rec[col["px"]], 64) - sz, _ := strconv.ParseFloat(rec[col["sz"]], 64) - fee, _ := strconv.ParseFloat(rec[col["builder_fee"]], 64) - rows = append(rows, fillRow{ - Time: t, - User: strings.ToLower(rec[col["user"]]), - Coin: safeCol(rec, col, "coin"), - Side: safeCol(rec, col, "side"), - Px: px, - Sz: sz, - BuilderFee: fee, - }) - } - return rows, "200", nil -} - -func safeCol(rec []string, col map[string]int, k string) string { - i, ok := col[k] - if !ok || i >= len(rec) { - return "" - } - return rec[i] -} - -// parseFillTime accepts the two timestamp shapes Hyperliquid has -// shipped over the lifetime of the bucket: Unix-millis integers and -// RFC3339 strings. Both have been observed in the wild. -func parseFillTime(raw string) (time.Time, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return time.Time{}, fmt.Errorf("empty time") - } - if ms, err := strconv.ParseInt(raw, 10, 64); err == nil { - return time.UnixMilli(ms).UTC(), nil - } - return time.Parse(time.RFC3339Nano, raw) -} - -func safeRatio(a, b float64) float64 { - if b == 0 { - return 0 - } - return a / b -} diff --git a/harnesses/hyperliquid-frontends/cmd/script/fills.go b/harnesses/hyperliquid-frontends/cmd/script/fills.go new file mode 100644 index 00000000..6a4152f4 --- /dev/null +++ b/harnesses/hyperliquid-frontends/cmd/script/fills.go @@ -0,0 +1,424 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "math" + "os" + "strconv" + "strings" +) + +// HL node_fills_by_block hourly file schema (one JSON object per line): +// +// { +// "local_time": "2026-06-01T15:00:00.073Z", +// "block_time": "2026-06-01T14:59:59.839Z", +// "block_number": 1018934794, +// "events": [ +// [ "", { fill_object } ], +// ... +// ] +// } +// +// Fill object fields used: +// coin, px, sz, side, time (ms), builderFee, builder, crossed (bool) + +type blockBatch struct { + BlockNumber int64 `json:"block_number"` + Events [][]interface{} `json:"events"` +} + +type fillObj struct { + Coin string `json:"coin"` + Px string `json:"px"` + Sz string `json:"sz"` + Side string `json:"side"` + Time int64 `json:"time"` + Fee string `json:"fee"` + BuilderFee string `json:"builderFee,omitempty"` + Builder string `json:"builder,omitempty"` + DeployerFee string `json:"deployerFee,omitempty"` + Crossed bool `json:"crossed"` +} + +// consumeFile reads new bytes from a file (using stored byte cursor) and +// dispatches each builder-attributed fill to its bucket. +func (a *AggState) consumeFile(path string, warm bool) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return err + } + + a.mu.Lock() + cursor := a.fileCursors[path] + if warm { + cursor = 0 + } + a.mu.Unlock() + + if cursor >= info.Size() { + return nil + } + if _, err := f.Seek(cursor, 0); err != nil { + return err + } + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 1<<20), 16<<20) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var bb blockBatch + if err := json.Unmarshal(line, &bb); err != nil { + continue + } + for _, ev := range bb.Events { + if len(ev) < 2 { + continue + } + user, _ := ev[0].(string) + user = strings.ToLower(user) + raw, _ := json.Marshal(ev[1]) + var fo fillObj + if err := json.Unmarshal(raw, &fo); err != nil { + continue + } + px, _ := strconv.ParseFloat(fo.Px, 64) + sz, _ := strconv.ParseFloat(fo.Sz, 64) + if px <= 0 || sz <= 0 { + continue + } + + // HIP-3 path: a namespaced coin ("xyz:AAPL") attributes the + // fill to a deployer dex, independently of builder codes. + if i := strings.IndexByte(fo.Coin, ':'); i > 0 { + df, _ := strconv.ParseFloat(fo.DeployerFee, 64) + a.mu.Lock() + a.ingestHip3Locked(fo.Coin[:i], fo.Coin, user, fo.Time, px*sz, df) + a.mu.Unlock() + } + + builder := strings.ToLower(fo.Builder) + if builder == "" { + continue + } + slug, ok := a.byAddr[builder] + if !ok { + continue + } + bf, _ := strconv.ParseFloat(fo.BuilderFee, 64) + + a.mu.Lock() + var devBps float64 + var devValid bool + if last, ok := a.lastPxByCoin[fo.Coin]; ok && last > 0 { + devBps = math.Abs(px-last) / last * 10_000 + devValid = true + } + a.lastPxByCoin[fo.Coin] = px + notional := px * sz + a.fillsByBuilder[slug] = append(a.fillsByBuilder[slug], fill{ + tsMs: fo.Time, + user: user, + coin: fo.Coin, + notional: notional, + builderFee: bf, + crossed: fo.Crossed, + devBps: devBps, + devValid: devValid, + }) + + // Hourly bucket for 7d/30d rolling sums. UTC-hour floor of the + // fill timestamp keys the bucket. Same path serves warmup and + // live tail (warmup re-zeros file cursors but never reruns the + // same line twice within a process), so this is the canonical + // ingestion point. + tsHour := (fo.Time / 1000 / 3600) * 3600 + byHour, ok := a.hourlyByBuilder[slug] + if !ok { + byHour = make(map[int64]*hourlyBucket) + a.hourlyByBuilder[slug] = byHour + } + bk, ok := byHour[tsHour] + if !ok { + bk = &hourlyBucket{} + byHour[tsHour] = bk + } + bk.feesUSD += bf + bk.volumeUSD += notional + + // Per-UTC-day wallet set for the 7d/30d unique-user counts. + // Same ingestion point as the hourly buckets; the disk + // backfill seeds history through scanUsersFile instead. + if user != "" { + a.addDailyUserLocked(slug, fo.Time, user) + } + a.mu.Unlock() + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("scan: %w", err) + } + + a.mu.Lock() + a.fileCursors[path] = info.Size() + a.mu.Unlock() + return nil +} + +// ingestHip3Locked records one HIP-3 fill into the dex's hourly bucket, +// hourly wallet/market sets (24h unions) and daily wallet set (7d/30d). +// Caller must hold a.mu. +func (a *AggState) ingestHip3Locked(dex, coin, user string, tsMs int64, notional, deployerFee float64) { + tsHour := (tsMs / 1000 / 3600) * 3600 + hours, ok := a.hip3Hourly[dex] + if !ok { + hours = make(map[int64]*hip3Bucket) + a.hip3Hourly[dex] = hours + } + bk, ok := hours[tsHour] + if !ok { + bk = &hip3Bucket{} + hours[tsHour] = bk + } + bk.feesUSD += deployerFee + bk.volumeUSD += notional + bk.fills++ + + if user != "" { + hu, ok := a.hip3HourlyUsers[dex] + if !ok { + hu = make(map[int64]map[string]struct{}) + a.hip3HourlyUsers[dex] = hu + } + set, ok := hu[tsHour] + if !ok { + set = make(map[string]struct{}) + hu[tsHour] = set + } + set[user] = struct{}{} + + dayKey := (tsMs / 1000 / 86400) * 86400 + byDay, ok := a.hip3DailyUsers[dex] + if !ok { + byDay = make(map[int64]map[string]struct{}) + a.hip3DailyUsers[dex] = byDay + } + dset, ok := byDay[dayKey] + if !ok { + dset = make(map[string]struct{}) + byDay[dayKey] = dset + } + dset[user] = struct{}{} + } + + hm, ok := a.hip3HourlyMarkets[dex] + if !ok { + hm = make(map[int64]map[string]struct{}) + a.hip3HourlyMarkets[dex] = hm + } + mset, ok := hm[tsHour] + if !ok { + mset = make(map[string]struct{}) + hm[tsHour] = mset + } + mset[coin] = struct{}{} + + if tsMs > a.hip3LastFillMs[dex] { + a.hip3LastFillMs[dex] = tsMs + } +} + +// addDailyUserLocked records a wallet in its builder's UTC-day set. +// Caller must hold a.mu. +func (a *AggState) addDailyUserLocked(slug string, tsMs int64, user string) { + dayKey := (tsMs / 1000 / 86400) * 86400 + byDay, ok := a.dailyUsersByBuilder[slug] + if !ok { + byDay = make(map[int64]map[string]struct{}) + a.dailyUsersByBuilder[slug] = byDay + } + set, ok := byDay[dayKey] + if !ok { + set = make(map[string]struct{}) + byDay[dayKey] = set + } + set[user] = struct{}{} +} + +var ( + builderNeedle = []byte(`"builder":"`) + deployerNeedle = []byte(`"deployerFee":"`) +) + +// scanHistoryFile streams one hourly file extracting builder wallets and +// HIP-3 deployer aggregates into local accumulators, then merges under the +// lock once per file. Unlike consumeFile it stores no fills and moves no +// cursors, so it can sweep the whole retention horizon without touching +// the live-tail state. Lines with neither a builder attribution nor a +// deployer fee are skipped before JSON parsing. +// +// Wallet sets are idempotent and merged for every file. Additive HIP-3 +// hour sums are seeded ONLY for files strictly older than the warmup +// horizon (a.backfillCutoffSec), which consumeFile already owns. +func (a *AggState) scanHistoryFile(path string, hourTs int64) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + seedSums := hourTs < a.backfillCutoffSec + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 1<<20), 16<<20) + + builderDays := make(map[string]map[int64]map[string]struct{}) + hip3Days := make(map[string]map[int64]map[string]struct{}) + hip3Sums := make(map[string]map[int64]*hip3Bucket) + + for scanner.Scan() { + line := scanner.Bytes() + hasBuilder := bytes.Contains(line, builderNeedle) + hasDeployer := bytes.Contains(line, deployerNeedle) + if !hasBuilder && !hasDeployer { + continue + } + var bb blockBatch + if err := json.Unmarshal(line, &bb); err != nil { + continue + } + for _, ev := range bb.Events { + if len(ev) < 2 { + continue + } + user, _ := ev[0].(string) + user = strings.ToLower(user) + raw, _ := json.Marshal(ev[1]) + var fo fillObj + if err := json.Unmarshal(raw, &fo); err != nil { + continue + } + dayKey := (fo.Time / 1000 / 86400) * 86400 + + if i := strings.IndexByte(fo.Coin, ':'); i > 0 { + dex := fo.Coin[:i] + if user != "" { + byDay, ok := hip3Days[dex] + if !ok { + byDay = make(map[int64]map[string]struct{}) + hip3Days[dex] = byDay + } + set, ok := byDay[dayKey] + if !ok { + set = make(map[string]struct{}) + byDay[dayKey] = set + } + set[user] = struct{}{} + } + if seedSums { + px, _ := strconv.ParseFloat(fo.Px, 64) + sz, _ := strconv.ParseFloat(fo.Sz, 64) + if px > 0 && sz > 0 { + df, _ := strconv.ParseFloat(fo.DeployerFee, 64) + fillHour := (fo.Time / 1000 / 3600) * 3600 + hours, ok := hip3Sums[dex] + if !ok { + hours = make(map[int64]*hip3Bucket) + hip3Sums[dex] = hours + } + bk, ok := hours[fillHour] + if !ok { + bk = &hip3Bucket{} + hours[fillHour] = bk + } + bk.feesUSD += df + bk.volumeUSD += px * sz + bk.fills++ + } + } + } + + builder := strings.ToLower(fo.Builder) + if builder == "" || user == "" { + continue + } + // byAddr is write-once at construction, safe to read unlocked. + slug, ok := a.byAddr[builder] + if !ok { + continue + } + byDay, ok := builderDays[slug] + if !ok { + byDay = make(map[int64]map[string]struct{}) + builderDays[slug] = byDay + } + set, ok := byDay[dayKey] + if !ok { + set = make(map[string]struct{}) + byDay[dayKey] = set + } + set[user] = struct{}{} + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("scan: %w", err) + } + + a.mu.Lock() + for slug, byDay := range builderDays { + for day, set := range byDay { + for u := range set { + a.addDailyUserLocked(slug, day*1000, u) + } + } + } + for dex, byDay := range hip3Days { + target, ok := a.hip3DailyUsers[dex] + if !ok { + target = make(map[int64]map[string]struct{}) + a.hip3DailyUsers[dex] = target + } + for day, set := range byDay { + tset, ok := target[day] + if !ok { + tset = make(map[string]struct{}) + target[day] = tset + } + for u := range set { + tset[u] = struct{}{} + } + } + } + for dex, hours := range hip3Sums { + target, ok := a.hip3Hourly[dex] + if !ok { + target = make(map[int64]*hip3Bucket) + a.hip3Hourly[dex] = target + } + for h, bk := range hours { + tbk, ok := target[h] + if !ok { + tbk = &hip3Bucket{} + target[h] = tbk + } + tbk.feesUSD += bk.feesUSD + tbk.volumeUSD += bk.volumeUSD + tbk.fills += bk.fills + } + } + a.mu.Unlock() + return nil +} diff --git a/harnesses/hyperliquid-frontends/cmd/script/loghub.go b/harnesses/hyperliquid-frontends/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/hyperliquid-frontends/cmd/script/loghub.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a +// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token +// matching the LOGS_TOKEN env var. +// +// Keep in sync across miniapps (was previously the shared/loghub package; we +// inline because Railway's per-harness Docker build context can't reach a +// sibling shared module via go.mod replace). + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a +// pipe, then spawns a goroutine that fan-outs every line to the original +// stdout AND the in-memory ring buffer. Call exactly once, very early in +// main(). +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header +// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset. +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/hyperliquid-frontends/cmd/script/main.go b/harnesses/hyperliquid-frontends/cmd/script/main.go index 586ed70a..96c018d0 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/main.go +++ b/harnesses/hyperliquid-frontends/cmd/script/main.go @@ -1,146 +1,600 @@ package main -// Hyperliquid frontends quality benchmark. -// -// Pulls the per-builder, per-day fills CSV that the Hyperliquid team -// publishes at https://stats-data.hyperliquid.xyz/Mainnet/builder_fills/ -// then computes three quality metrics per builder: -// -// - effective_fee_bps = sum(builder_fee) / sum(notional) * 10000 -// - fees_per_user_usd = sum(builder_fee) / count(distinct user) -// - volume_usd_24h = sum(px * sz) -// -// 30-day fee discipline is NOT computed here — it falls out for free -// via `stddev_over_time(hl_frontend_effective_fee_bps[30d])` in -// Prometheus once the time-series accumulates. -// -// MVP shape — wire it up, point at the public bucket, expose Prom on -// :2112. Builder address registry lives at ../builders.json so a new -// builder is added without code change. - import ( "context" + "encoding/json" + "flag" "fmt" + "log" + "net/http" "os" "os/signal" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" "syscall" "time" -) -const ( - scrapeInterval = 3600 * time.Second // poll each builder hourly - httpTimeout = 30 * time.Second + "github.com/prometheus/client_golang/prometheus/promhttp" ) -func main() { - fmt.Println("=== Hyperliquid Frontends Quality Harness ===") - fmt.Println("OpenChainBench - builder-code effective fee + $/user + volume.") - fmt.Println() +// Local HL frontends bench harness (v2). +// +// Reads /mnt/hyperliquid/data/node_fills_by_block/hourly/YYYYMMDD/HH files +// produced by the hl-node running on the same host. Aggregates fills per +// known builder over a rolling 24h window and exposes Prometheus metrics on +// :2113/metrics (loopback). A Caddy reverse proxy fronts :8088 with basic +// auth for external scrape from the OCB Prom on Railway. - registry, err := loadRegistry("./builders.json") - if err != nil { - fmt.Printf("[fatal] load registry: %v\n", err) - os.Exit(1) +type Builder struct { + Slug string `json:"slug"` + Name string `json:"name"` + Address string `json:"address"` + Addresses []string `json:"addresses,omitempty"` + ValidFrom string `json:"valid_from"` + Notes string `json:"notes"` +} + +// allAddresses returns every builder address attributed to this entry, lowercased. +// Some frontends (Okto and friends) route through multiple builder addresses; +// the registry can list them under `addresses` while `address` stays as the +// primary for human readability. We always include `address` as the canonical +// entry and append any extras from `addresses` without duplicates. +func (b Builder) allAddresses() []string { + seen := make(map[string]struct{}, 1+len(b.Addresses)) + out := make([]string, 0, 1+len(b.Addresses)) + add := func(s string) { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return + } + if _, ok := seen[s]; ok { + return + } + seen[s] = struct{}{} + out = append(out, s) } - fmt.Printf("Registry: %d builders\n", len(registry)) - for _, b := range registry { - fmt.Printf(" · %-15s %s\n", b.Slug, b.Address) + add(b.Address) + for _, a := range b.Addresses { + add(a) } - fmt.Println() + return out +} + +func main() { + installLogCapture() // capture stdout into /logs ring buffer + var ( + dataDir = flag.String("data", "/mnt/hyperliquid/data/node_fills_by_block/hourly", "HL node_fills_by_block hourly root") + buildersF = flag.String("builders", "builders.json", "builders registry") + metricsAddr = flag.String("addr", "127.0.0.1:2113", "metrics listen addr") + windowH = flag.Int("window-hours", 24, "rolling window length in hours") + tickEvery = flag.Duration("tick", 30*time.Second, "aggregate refresh interval") + usersBackfD = flag.Int("users-backfill-days", 8, "days of fill history to scan once at startup to seed the 7d/30d unique-user sets") + ) + flag.Parse() - state, err := OpenState() + builders, err := loadBuilders(*buildersF) if err != nil { - fmt.Printf("[fatal] open state: %v\n", err) - os.Exit(1) + log.Fatalf("load builders: %v", err) } - defer state.Close() + log.Printf("loaded %d builders", len(builders)) - fmt.Println("Metrics server: :2112/metrics") - fmt.Println() + registerMetrics(builders) - // Hardcoded :2112 per the OCB Railway scrape convention. We - // deliberately ignore Railway's $PORT (would move the listener - // away from where the shared Prom expects it). METRICS_ADDR is - // a local-dev escape hatch only — never set in production. - addr := os.Getenv("METRICS_ADDR") - if addr == "" { - addr = ":2112" + state := newAggState(builders, time.Duration(*windowH)*time.Hour) + // Hour floor of the oldest file warmup will read. Backfill seeds + // additive HIP-3 sums only for strictly older files (see AggState). + state.backfillCutoffSec = time.Now().UTC(). + Add(-time.Duration(*windowH+2) * time.Hour). + Truncate(time.Hour).Unix() + + if err := state.warmup(*dataDir); err != nil { + log.Printf("warmup: %v (continuing — values will fill over time)", err) } + state.publish() + + go state.backfillUsers(*dataDir, *usersBackfD) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() go func() { - if err := StartMetricsServer(addr); err != nil { - fmt.Printf("[fatal] metrics server: %v\n", err) - os.Exit(1) + t := time.NewTicker(*tickEvery) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := state.tickRefresh(*dataDir); err != nil { + log.Printf("tickRefresh: %v", err) + } + state.publish() + } } }() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "ok") + }) + srv := &http.Server{Addr: *metricsAddr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} - // First cycle fires after a 5 s warmup so /metrics has data on the - // first scrape; subsequent cycles run on the configured interval. - warmup := time.NewTimer(5 * time.Second) - defer warmup.Stop() - ticker := time.NewTicker(scrapeInterval) - defer ticker.Stop() - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGTERM) - - for { - select { - case <-warmup.C: - runCycle(ctx, registry, state) - case <-ticker.C: - runCycle(ctx, registry, state) - case s := <-sig: - fmt.Printf("\n[shutdown] received %v\n", s) - cancel() - return + go func() { + log.Printf("metrics listening on %s", *metricsAddr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("http: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + log.Printf("shutting down") + shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutCancel() + _ = srv.Shutdown(shutCtx) +} + +func loadBuilders(p string) ([]Builder, error) { + b, err := os.ReadFile(p) + if err != nil { + return nil, err + } + var bs []Builder + if err := json.Unmarshal(b, &bs); err != nil { + return nil, err + } + for i := range bs { + bs[i].Address = strings.ToLower(bs[i].Address) + for j := range bs[i].Addresses { + bs[i].Addresses[j] = strings.ToLower(bs[i].Addresses[j]) } } + return bs, nil } -func runCycle(ctx context.Context, registry []Builder, state *State) { - cycleStart := time.Now() - // Per-builder pass: fetch CSVs, update gauges, upsert into state. - var totalNotional float64 - perBuilderNotional := make(map[string]float64, len(registry)) - for _, b := range registry { - select { - case <-ctx.Done(): - return - default: +type AggState struct { + mu sync.Mutex + builders []Builder + byAddr map[string]string + window time.Duration + fillsByBuilder map[string][]fill + lastPxByCoin map[string]float64 + fileCursors map[string]int64 + lastTopAssetsByBuilder map[string][]string + // Per-builder hourly aggregates kept for 30 days. Key: floor(ts to the + // hour, UTC) in unix seconds. Memory cost: 720 buckets * ~60 builders * + // 2 floats = ~350 KB total. Used to compute 7d / 30d rolling sums for + // fees and volume without keeping every fill in memory for 30 days. + hourlyByBuilder map[string]map[int64]*hourlyBucket + // Per-builder per-UTC-day unique wallet sets, for the 7d/30d user + // counts. Day key: floor(ts to the day, UTC) in unix seconds. + // Uniqueness can't be folded into hourlyBucket floats — it needs the + // full wallet set per window. Memory: top builders run ~3-4k wallets + // per day; across the cohort this stays well under 100 MB for 30 days. + dailyUsersByBuilder map[string]map[int64]map[string]struct{} + // Set once the one-shot disk backfill has scanned the retention + // horizon. Until then the 7d/30d user gauges are not published, so a + // restart never lowballs them off the 26h warmup window alone. + usersSeeded bool + + // HIP-3 deployer aggregates. A fill belongs to a HIP-3 dex when its + // coin is namespaced ("xyz:AAPL" → dex "xyz"); the deployerFee field + // carries the dex operator's cut in USDC. Volumes here are far larger + // than the builder-code stream (trade.xyz alone clears ~4M fills/24h), + // so nothing is kept per fill: hourly buckets for sums, hourly wallet + // and market sets for the exact 24h unions (pruned past 26h), per-day + // wallet sets for the 7d/30d user counts (seeded by the backfill). + hip3Hourly map[string]map[int64]*hip3Bucket + hip3HourlyUsers map[string]map[int64]map[string]struct{} + hip3HourlyMarkets map[string]map[int64]map[string]struct{} + hip3DailyUsers map[string]map[int64]map[string]struct{} + hip3LastFillMs map[string]int64 + // Hour floor (unix sec) of the oldest file the warmup/tail path owns. + // The backfill seeds additive hip3 sums ONLY for strictly older files, + // so overlapping hours are never double counted. Wallet sets are + // idempotent and merged from both paths. + backfillCutoffSec int64 +} + +type hip3Bucket struct { + feesUSD float64 + volumeUSD float64 + fills float64 +} + +type hourlyBucket struct { + feesUSD float64 + volumeUSD float64 +} + +type fill struct { + tsMs int64 + user string + coin string + notional float64 + builderFee float64 + crossed bool + devBps float64 + devValid bool +} + +func newAggState(builders []Builder, window time.Duration) *AggState { + byAddr := make(map[string]string, len(builders)) + for _, b := range builders { + for _, addr := range b.allAddresses() { + byAddr[addr] = b.Slug } - notional := processBuilder(ctx, b, state) - perBuilderNotional[b.Slug] = notional - totalNotional += notional } - // Volume share + retention pass once state is up to date. - for _, b := range registry { - if totalNotional > 0 { - hlVolumeSharePct.WithLabelValues(b.Slug).Set(perBuilderNotional[b.Slug] / totalNotional * 100) + return &AggState{ + builders: builders, + byAddr: byAddr, + window: window, + fillsByBuilder: make(map[string][]fill), + lastPxByCoin: make(map[string]float64), + fileCursors: make(map[string]int64), + lastTopAssetsByBuilder: make(map[string][]string), + hourlyByBuilder: make(map[string]map[int64]*hourlyBucket), + dailyUsersByBuilder: make(map[string]map[int64]map[string]struct{}), + hip3Hourly: make(map[string]map[int64]*hip3Bucket), + hip3HourlyUsers: make(map[string]map[int64]map[string]struct{}), + hip3HourlyMarkets: make(map[string]map[int64]map[string]struct{}), + hip3DailyUsers: make(map[string]map[int64]map[string]struct{}), + hip3LastFillMs: make(map[string]int64), + } +} + +func (a *AggState) publish() { + a.mu.Lock() + defer a.mu.Unlock() + + nowMs := time.Now().UnixMilli() + cutoff := nowMs - a.window.Milliseconds() + windowMin := a.window.Minutes() + + // Hourly bucket horizons (unix seconds, UTC floor) for 7d and 30d sums. + // We prune everything older than 30d on each publish — ~720 entries per + // builder, so the iteration cost is negligible. + nowSec := nowMs / 1000 + cutoff7d := nowSec - 7*24*3600 + cutoff30d := nowSec - 30*24*3600 + + for _, b := range a.builders { + fills := a.fillsByBuilder[b.Slug] + var pruned []fill + var volume, fees float64 + var devSum float64 + var devCount int + var maxTs int64 + var takerCount int + assetVol := make(map[string]float64) + users := make(map[string]struct{}) + + for _, f := range fills { + if f.tsMs < cutoff { + continue + } + pruned = append(pruned, f) + volume += f.notional + fees += f.builderFee + if f.user != "" { + users[f.user] = struct{}{} + } + if f.devValid { + devSum += f.devBps + devCount++ + } + if f.tsMs > maxTs { + maxTs = f.tsMs + } + if f.crossed { + takerCount++ + } + if f.coin != "" { + assetVol[f.coin] += f.notional + } + } + a.fillsByBuilder[b.Slug] = pruned + + hlVolumeUSD24h.WithLabelValues(b.Slug).Set(volume) + hlFeesUSD24h.WithLabelValues(b.Slug).Set(fees) + hlUsers24h.WithLabelValues(b.Slug).Set(float64(len(users))) + hlFillsTotal24h.WithLabelValues(b.Slug).Set(float64(len(pruned))) + + if volume > 0 { + hlEffectiveFeeBps.WithLabelValues(b.Slug).Set(fees / volume * 10_000) + } else { + hlEffectiveFeeBps.WithLabelValues(b.Slug).Set(0) + } + + if len(users) > 0 { + hlFeesPerUserUSD.WithLabelValues(b.Slug).Set(fees / float64(len(users))) + } else { + hlFeesPerUserUSD.WithLabelValues(b.Slug).Set(0) + } + + if maxTs > 0 { + ageSec := float64(nowMs-maxTs) / 1000.0 + hlLastFillAgeSeconds.WithLabelValues(b.Slug).Set(ageSec) + } else { + hlLastFillAgeSeconds.WithLabelValues(b.Slug).Set(a.window.Seconds()) + } + + if windowMin > 0 { + hlFillsPerMin.WithLabelValues(b.Slug).Set(float64(len(pruned)) / windowMin) + } + + if devCount > 0 { + hlPriceDeviationBps.WithLabelValues(b.Slug).Set(devSum / float64(devCount)) + } else { + hlPriceDeviationBps.WithLabelValues(b.Slug).Set(0) } - for _, days := range []int{7, 30} { - ret, cohort, err := state.Retention(ctx, b.Slug, days) - if err != nil { - fmt.Printf("[%s] retention(%dd) error: %v\n", b.Slug, days, err) + + if len(pruned) > 0 { + hlTakerPct.WithLabelValues(b.Slug).Set(float64(takerCount) / float64(len(pruned))) + } else { + hlTakerPct.WithLabelValues(b.Slug).Set(0) + } + + a.publishTopAssets(b.Slug, assetVol) + + // 7d / 30d rolling sums from hourly buckets. Prune anything older + // than 30d while we're iterating. + buckets := a.hourlyByBuilder[b.Slug] + var fees7d, fees30d, volume7d, volume30d float64 + for tsHour, bk := range buckets { + if tsHour < cutoff30d { + delete(buckets, tsHour) + continue + } + volume30d += bk.volumeUSD + fees30d += bk.feesUSD + if tsHour >= cutoff7d { + volume7d += bk.volumeUSD + fees7d += bk.feesUSD + } + } + hlVolumeUSD7d.WithLabelValues(b.Slug).Set(volume7d) + hlVolumeUSD30d.WithLabelValues(b.Slug).Set(volume30d) + hlFeesUSD7d.WithLabelValues(b.Slug).Set(fees7d) + hlFeesUSD30d.WithLabelValues(b.Slug).Set(fees30d) + + // 7d/30d unique users from the per-UTC-day wallet sets. Gated on + // the disk backfill so a fresh process never publishes counts + // built from the 26h warmup alone. Day sets older than 31 days + // are pruned in the same pass. + if a.usersSeeded { + cutoffDay7 := nowSec - 7*24*3600 + cutoffDayPrune := nowSec - 31*24*3600 + u7 := make(map[string]struct{}) + u30 := make(map[string]struct{}) + days := a.dailyUsersByBuilder[b.Slug] + for day, set := range days { + if day < cutoffDayPrune { + delete(days, day) + continue + } + for u := range set { + u30[u] = struct{}{} + if day >= cutoffDay7 { + u7[u] = struct{}{} + } + } + } + hlUsers7d.WithLabelValues(b.Slug).Set(float64(len(u7))) + hlUsers30d.WithLabelValues(b.Slug).Set(float64(len(u30))) + } + } + + a.publishHip3Locked(nowMs) + hlLastTickUnix.Set(float64(time.Now().Unix())) +} + +// publishHip3Locked folds the HIP-3 hourly buckets and wallet/market sets +// into the per-dex gauges. Caller must hold a.mu. +func (a *AggState) publishHip3Locked(nowMs int64) { + nowSec := nowMs / 1000 + cutoff24 := nowSec - 24*3600 + cutoff7d := nowSec - 7*24*3600 + cutoff30d := nowSec - 30*24*3600 + cutoffSets := nowSec - 26*3600 + + for dex, hours := range a.hip3Hourly { + var f24, v24, fl24, f7, v7, f30, v30 float64 + for h, bk := range hours { + if h < cutoff30d { + delete(hours, h) continue } - switch days { - case 7: - hlD7Retention.WithLabelValues(b.Slug).Set(ret) - hlD7CohortSize.WithLabelValues(b.Slug).Set(float64(cohort)) - case 30: - hlD30Retention.WithLabelValues(b.Slug).Set(ret) - hlD30CohortSize.WithLabelValues(b.Slug).Set(float64(cohort)) + f30 += bk.feesUSD + v30 += bk.volumeUSD + if h >= cutoff7d { + f7 += bk.feesUSD + v7 += bk.volumeUSD + } + if h >= cutoff24 { + f24 += bk.feesUSD + v24 += bk.volumeUSD + fl24 += bk.fills + } + } + + users24 := make(map[string]struct{}) + for h, set := range a.hip3HourlyUsers[dex] { + if h < cutoffSets { + delete(a.hip3HourlyUsers[dex], h) + continue + } + if h < cutoff24 { + continue + } + for u := range set { + users24[u] = struct{}{} + } + } + markets24 := make(map[string]struct{}) + for h, set := range a.hip3HourlyMarkets[dex] { + if h < cutoffSets { + delete(a.hip3HourlyMarkets[dex], h) + continue + } + if h < cutoff24 { + continue + } + for m := range set { + markets24[m] = struct{}{} + } + } + + hip3FeesUSD24h.WithLabelValues(dex).Set(f24) + hip3FeesUSD7d.WithLabelValues(dex).Set(f7) + hip3FeesUSD30d.WithLabelValues(dex).Set(f30) + hip3VolumeUSD24h.WithLabelValues(dex).Set(v24) + hip3VolumeUSD7d.WithLabelValues(dex).Set(v7) + hip3VolumeUSD30d.WithLabelValues(dex).Set(v30) + hip3Fills24h.WithLabelValues(dex).Set(fl24) + hip3Users24h.WithLabelValues(dex).Set(float64(len(users24))) + hip3Markets24h.WithLabelValues(dex).Set(float64(len(markets24))) + if v24 > 0 { + hip3EffectiveFeeBps.WithLabelValues(dex).Set(f24 / v24 * 10_000) + } else { + hip3EffectiveFeeBps.WithLabelValues(dex).Set(0) + } + if last := a.hip3LastFillMs[dex]; last > 0 { + hip3LastFillAgeSeconds.WithLabelValues(dex).Set(float64(nowMs-last) / 1000.0) + } + + if a.usersSeeded { + cutoffDay7 := nowSec - 7*24*3600 + cutoffDayPrune := nowSec - 31*24*3600 + u7 := make(map[string]struct{}) + u30 := make(map[string]struct{}) + for day, set := range a.hip3DailyUsers[dex] { + if day < cutoffDayPrune { + delete(a.hip3DailyUsers[dex], day) + continue + } + for u := range set { + u30[u] = struct{}{} + if day >= cutoffDay7 { + u7[u] = struct{}{} + } + } } + hip3Users7d.WithLabelValues(dex).Set(float64(len(u7))) + hip3Users30d.WithLabelValues(dex).Set(float64(len(u30))) + } + } +} + +// backfillUsers streams the node's hourly fill files across the retention +// horizon ONCE at startup and seeds the per-day wallet sets. Runs in a +// goroutine after warmup; the 24h metrics are live the whole time. The +// scan only JSON-parses lines that contain a builder attribution (~5% of +// fills), so a full 8-day horizon (~35 GB) takes single-digit minutes of +// sequential IO without starving the co-located node. +func (a *AggState) backfillUsers(root string, days int) { + start := time.Now() + now := time.Now().UTC() + type hourFile struct { + path string + hourTs int64 + } + var files []hourFile + for i := days * 24; i >= 0; i-- { + t := now.Add(-time.Duration(i) * time.Hour) + p := filepath.Join(root, t.Format("20060102"), fmt.Sprintf("%d", t.Hour())) + if _, err := os.Stat(p); err == nil { + files = append(files, hourFile{p, t.Truncate(time.Hour).Unix()}) + } + } + for _, f := range files { + if err := a.scanHistoryFile(f.path, f.hourTs); err != nil { + log.Printf("history backfill %s: %v", f.path, err) + } + } + a.mu.Lock() + a.usersSeeded = true + a.mu.Unlock() + log.Printf("users backfill done: %d hourly files in %s", len(files), time.Since(start)) +} + +func (a *AggState) publishTopAssets(slug string, assetVol map[string]float64) { + type kv struct { + asset string + vol float64 + } + pairs := make([]kv, 0, len(assetVol)) + for k, v := range assetVol { + if v > 0 { + pairs = append(pairs, kv{k, v}) + } + } + sort.Slice(pairs, func(i, j int) bool { return pairs[i].vol > pairs[j].vol }) + if len(pairs) > 3 { + pairs = pairs[:3] + } + + prev := a.lastTopAssetsByBuilder[slug] + newAssets := make(map[string]bool, len(pairs)) + for _, p := range pairs { + newAssets[p.asset] = true + } + for i, old := range prev { + if !newAssets[old] { + hlAssetVolumeTopUSD.DeleteLabelValues(slug, old, strconv.Itoa(i+1)) } } - // Prune anything past our 90-day horizon so the SQLite file stays - // bounded. 90d > our longest retention window (D30) with 2x safety - // margin for backfill / replay scenarios. - if n, err := state.PruneOlderThan(90); err == nil && n > 0 { - fmt.Printf("[state] pruned %d rows older than 90d\n", n) + + current := make([]string, len(pairs)) + for i, p := range pairs { + hlAssetVolumeTopUSD.WithLabelValues(slug, p.asset, strconv.Itoa(i+1)).Set(p.vol) + current[i] = p.asset + } + a.lastTopAssetsByBuilder[slug] = current +} + +func (a *AggState) listHourFiles(root string) ([]string, error) { + now := time.Now().UTC() + hours := int(a.window.Hours()) + 2 + var out []string + for i := hours; i >= 0; i-- { + t := now.Add(-time.Duration(i) * time.Hour) + p := filepath.Join(root, t.Format("20060102"), fmt.Sprintf("%d", t.Hour())) + if _, err := os.Stat(p); err == nil { + out = append(out, p) + } + } + return out, nil +} + +func (a *AggState) warmup(root string) error { + files, err := a.listHourFiles(root) + if err != nil { + return err + } + for _, f := range files { + if err := a.consumeFile(f, true); err != nil { + log.Printf("warmup %s: %v", f, err) + } + } + log.Printf("warmup done; rolling window pre-loaded across %d hourly files", len(files)) + return nil +} + +func (a *AggState) tickRefresh(root string) error { + files, err := a.listHourFiles(root) + if err != nil { + return err + } + for _, f := range files { + if err := a.consumeFile(f, false); err != nil { + log.Printf("consumeFile %s: %v", f, err) + } } - fmt.Printf("[cycle] done in %s\n", time.Since(cycleStart).Round(time.Millisecond)) + return nil } diff --git a/harnesses/hyperliquid-frontends/cmd/script/metrics.go b/harnesses/hyperliquid-frontends/cmd/script/metrics.go index 5d9e074a..da0c1fb6 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/metrics.go +++ b/harnesses/hyperliquid-frontends/cmd/script/metrics.go @@ -1,136 +1,282 @@ package main import ( - "net/http" - "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/prometheus/client_golang/prometheus/promhttp" ) -// Prom metrics for the Hyperliquid frontends quality bench. Names are -// prefixed `hl_frontend_` so the OCB MCP allowlist needs one entry -// (`hl_frontend_`) for every metric this harness emits. +// All v2 metrics use the `builder` label name to stay consistent with the v1 +// bucket-fetch harness query patterns. The v1 service emits the same metric +// names without the _v2 suffix; both coexist in Prom during A/B. var ( - hlEffectiveFeeBps = promauto.NewGaugeVec( + hlVolumeUSD24h = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_effective_fee_bps", - Help: "Volume-weighted effective builder fee in basis points over the last 24h: sum(builder_fee) / sum(notional) * 10000.", + Name: "hl_frontend_volume_usd_24h_v2", + Help: "Rolling 24h notional volume in USD per HL builder (local node source)", }, []string{"builder"}, ) - hlFeesPerUserUSD = promauto.NewGaugeVec( + hlFeesUSD24h = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_fees_per_user_usd", - Help: "USD fees captured per unique trader over the last 24h: sum(builder_fee_usd) / count(distinct user).", + Name: "hl_frontend_fees_usd_24h_v2", + Help: "Rolling 24h builder-fee revenue in USD per HL builder (local node source)", }, []string{"builder"}, ) - hlVolumeUSD24h = promauto.NewGaugeVec( + hlVolumeUSD7d = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_volume_usd_7d_v2", + Help: "Rolling 7d notional volume in USD per HL builder (hourly bucket sum)", + }, + []string{"builder"}, + ) + + hlVolumeUSD30d = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_volume_usd_24h", - Help: "Total notional USD volume routed via this builder in the last 24h. Secondary signal — not the headline.", + Name: "hl_frontend_volume_usd_30d_v2", + Help: "Rolling 30d notional volume in USD per HL builder (hourly bucket sum)", + }, + []string{"builder"}, + ) + + hlFeesUSD7d = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_fees_usd_7d_v2", + Help: "Rolling 7d builder-fee revenue in USD per HL builder (hourly bucket sum)", + }, + []string{"builder"}, + ) + + hlFeesUSD30d = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_fees_usd_30d_v2", + Help: "Rolling 30d builder-fee revenue in USD per HL builder (hourly bucket sum)", }, []string{"builder"}, ) hlUsers24h = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_users_24h", - Help: "Count of unique trader addresses attributed to this builder in the last 24h.", + Name: "hl_frontend_users_24h_v2", + Help: "Unique users that traded through this builder in the last 24h", }, []string{"builder"}, ) - hlFillsTotal = promauto.NewGaugeVec( + // 7d/30d unique users from per-UTC-day wallet sets. Deliberately NOT + // zero-initialised in registerMetrics: the series stays absent until + // the one-shot disk backfill seeds the day sets, so a fresh restart + // can't publish lowball counts that a dashboard would read as a user + // exodus. 30d is limited by node file retention (counts grow until + // 30 full days of fills exist on disk, same caveat as fees_30d). + hlUsers7d = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_fills_total", - Help: "Count of attributed fills observed in the last 24h. Drives the leaderboard sample_size column.", + Name: "hl_frontend_users_7d_v2", + Help: "Unique users that traded through this builder over the last 7 UTC days (union of daily wallet sets)", }, []string{"builder"}, ) - hlUnattributedShare = promauto.NewGauge( + hlUsers30d = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_unattributed_share_pct", - Help: "Share of Hyperliquid builder-fee volume from addresses NOT in our registry. >2% should trigger a registry-update review.", + Name: "hl_frontend_users_30d_v2", + Help: "Unique users that traded through this builder over the last 30 UTC days (union of daily wallet sets, bounded by node file retention)", }, + []string{"builder"}, ) - hlRegistryAge = promauto.NewGauge( + hlFillsTotal24h = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_registry_age_seconds", - Help: "Seconds since the builders.json file was last modified on disk. Surfaces stale-registry drift.", + Name: "hl_frontend_fills_total_24h_v2", + Help: "Count of fills attributed to this builder in the last 24h", }, + []string{"builder"}, ) - hlCSVFetchStatus = promauto.NewCounterVec( - prometheus.CounterOpts{ - Name: "hl_frontend_csv_fetch_status_total", - Help: "Outcome of each fetch of the per-day Hyperliquid fills CSV, by HTTP code (200, 403=no fills that day, 5xx, error).", + hlEffectiveFeeBps = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_effective_fee_bps_v2", + Help: "Builder fees / notional volume in bps over the last 24h (user-perspective cost)", }, - []string{"builder", "code"}, + []string{"builder"}, ) - // Cohort retention — the unique OCB edge. Of the users whose first - // observed fill for a builder lands in the 24h window {7,30} days - // ago, the fraction that traded again in the last 24h. Requires - // the SQLite state layer because no public source publishes it. - hlD7Retention = promauto.NewGaugeVec( + hlFeesPerUserUSD = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_d7_retention_pct", - Help: "D7 cohort retention in percent: of users who first traded via this builder 7 days ago, fraction that traded again in the last 24h.", + Name: "hl_frontend_fees_per_user_usd_v2", + Help: "Builder fees / unique users over the last 24h (per-user ARPU)", }, []string{"builder"}, ) - hlD30Retention = promauto.NewGaugeVec( + // BANGER №1: outage / freshness + hlLastFillAgeSeconds = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_d30_retention_pct", - Help: "D30 cohort retention in percent: of users who first traded via this builder 30 days ago, fraction that traded again in the last 24h.", + Name: "hl_frontend_last_fill_age_seconds_v2", + Help: "Seconds since this builder's most recent fill — outage detector", }, []string{"builder"}, ) - hlD7CohortSize = promauto.NewGaugeVec( + hlFillsPerMin = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_d7_cohort_size", - Help: "Number of users in the D7 cohort. Low cohort sizes (<50) make the retention percentage statistically noisy.", + Name: "hl_frontend_fills_per_min_v2", + Help: "Rate of fills per minute for this builder, averaged over the rolling 24h window", }, []string{"builder"}, ) - hlD30CohortSize = promauto.NewGaugeVec( + // BANGER №2: price deviation (slippage proxy) + hlPriceDeviationBps = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_d30_cohort_size", - Help: "Number of users in the D30 cohort.", + Name: "hl_frontend_price_deviation_bps_v2", + Help: "Mean |fill_px - last_trade_px_same_asset| in bps. Proxy for slippage / execution quality. Lower = better.", }, []string{"builder"}, ) - // Volume share computed across builders in the registry. Every - // other dashboard has this — we expose it as secondary so the - // page still surfaces the number readers expect to see. - hlVolumeSharePct = promauto.NewGaugeVec( + // BANGER №3: maker / taker split. crossed=true ⇒ taker; false ⇒ maker. + hlTakerPct = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_volume_share_pct", - Help: "Share in percent of the last 24h notional volume across builders in our registry. Coverage gap (= volume from unregistered addresses) surfaces in hl_frontend_unattributed_share_pct.", + Name: "hl_frontend_taker_pct_v2", + Help: "Share of this builder's fills (0..1) that were takers (crossed the spread)", }, []string{"builder"}, ) + + // BANGER №4: per-asset top-3 dominance. 8 builders × 3 ranks = 24 series. + hlAssetVolumeTopUSD = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_asset_volume_top_usd_v2", + Help: "USD volume in the rolling 24h for the top-N asset traded via this builder (rank=1 is #1)", + }, + []string{"builder", "asset", "rank"}, + ) + + hlLastTickUnix = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "hl_frontend_local_last_tick_unix_v2", + Help: "Unix timestamp of the last successful aggregate refresh (harness liveness)", + }, + ) + + // HIP-3 deployer metrics. The `dex` label is the coin namespace prefix + // ("xyz:AAPL" → "xyz"); the set is dynamic (no registry) because HIP-3 + // deployment is permissionless and cardinality is naturally low (one + // label value per staked deployer). Not zero-initialised: a dex series + // appears with its first observed fill. users_7d/30d share the + // usersSeeded gate with the builder metrics. + hip3FeesUSD24h = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_hip3_deployer_fees_usd_24h", + Help: "Rolling 24h deployer-fee revenue in USD per HIP-3 dex (local node source)", + }, + []string{"dex"}, + ) + hip3FeesUSD7d = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_hip3_deployer_fees_usd_7d", + Help: "Rolling 7d deployer-fee revenue in USD per HIP-3 dex (hourly bucket sum)", + }, + []string{"dex"}, + ) + hip3FeesUSD30d = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_hip3_deployer_fees_usd_30d", + Help: "Rolling 30d deployer-fee revenue in USD per HIP-3 dex (hourly bucket sum, bounded by node file retention)", + }, + []string{"dex"}, + ) + hip3VolumeUSD24h = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_hip3_deployer_volume_usd_24h", + Help: "Rolling 24h notional volume in USD per HIP-3 dex", + }, + []string{"dex"}, + ) + hip3VolumeUSD7d = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_hip3_deployer_volume_usd_7d", + Help: "Rolling 7d notional volume in USD per HIP-3 dex (hourly bucket sum)", + }, + []string{"dex"}, + ) + hip3VolumeUSD30d = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_hip3_deployer_volume_usd_30d", + Help: "Rolling 30d notional volume in USD per HIP-3 dex (hourly bucket sum, bounded by node file retention)", + }, + []string{"dex"}, + ) + hip3Users24h = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_hip3_deployer_users_24h", + Help: "Unique wallets that traded on this HIP-3 dex in the last 24h (hourly set union)", + }, + []string{"dex"}, + ) + hip3Users7d = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_hip3_deployer_users_7d", + Help: "Unique wallets that traded on this HIP-3 dex over the last 7 UTC days (union of daily wallet sets)", + }, + []string{"dex"}, + ) + hip3Users30d = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_hip3_deployer_users_30d", + Help: "Unique wallets that traded on this HIP-3 dex over the last 30 UTC days (union of daily wallet sets, bounded by node file retention)", + }, + []string{"dex"}, + ) + hip3Fills24h = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_hip3_deployer_fills_24h", + Help: "Count of fills on this HIP-3 dex in the last 24h", + }, + []string{"dex"}, + ) + hip3Markets24h = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_hip3_deployer_markets_24h", + Help: "Distinct markets traded on this HIP-3 dex in the last 24h", + }, + []string{"dex"}, + ) + hip3EffectiveFeeBps = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_hip3_deployer_effective_fee_bps", + Help: "Deployer fees / notional volume in bps over the last 24h (trader-perspective cost of the dex)", + }, + []string{"dex"}, + ) + hip3LastFillAgeSeconds = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_hip3_deployer_last_fill_age_seconds", + Help: "Seconds since this HIP-3 dex's most recent fill — outage detector", + }, + []string{"dex"}, + ) ) -func StartMetricsServer(addr string) error { - mux := http.NewServeMux() - mux.Handle("/metrics", promhttp.Handler()) - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("ok")) - }) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("hyperliquid-frontends harness · OpenChainBench")) - }) - return http.ListenAndServe(addr, mux) +func registerMetrics(builders []Builder) { + for _, b := range builders { + hlVolumeUSD24h.WithLabelValues(b.Slug).Set(0) + hlFeesUSD24h.WithLabelValues(b.Slug).Set(0) + hlVolumeUSD7d.WithLabelValues(b.Slug).Set(0) + hlVolumeUSD30d.WithLabelValues(b.Slug).Set(0) + hlFeesUSD7d.WithLabelValues(b.Slug).Set(0) + hlFeesUSD30d.WithLabelValues(b.Slug).Set(0) + hlUsers24h.WithLabelValues(b.Slug).Set(0) + hlFillsTotal24h.WithLabelValues(b.Slug).Set(0) + hlEffectiveFeeBps.WithLabelValues(b.Slug).Set(0) + hlFeesPerUserUSD.WithLabelValues(b.Slug).Set(0) + hlLastFillAgeSeconds.WithLabelValues(b.Slug).Set(0) + hlFillsPerMin.WithLabelValues(b.Slug).Set(0) + hlPriceDeviationBps.WithLabelValues(b.Slug).Set(0) + hlTakerPct.WithLabelValues(b.Slug).Set(0) + } } diff --git a/harnesses/hyperliquid-frontends/cmd/script/registry.go b/harnesses/hyperliquid-frontends/cmd/script/registry.go deleted file mode 100644 index af26d153..00000000 --- a/harnesses/hyperliquid-frontends/cmd/script/registry.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "strings" -) - -// Builder maps a curated frontend slug to its Hyperliquid builder-code -// EVM address. The address is what the on-chain `ApproveBuilderFee` -// action registers and what every order with that builder embedded -// surfaces in the per-day CSV dumps. -type Builder struct { - Slug string `json:"slug"` - Name string `json:"name"` - Address string `json:"address"` - ValidFrom string `json:"valid_from"` - Notes string `json:"notes,omitempty"` -} - -func loadRegistry(path string) ([]Builder, error) { - raw, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read %s: %w", path, err) - } - var entries []Builder - if err := json.Unmarshal(raw, &entries); err != nil { - return nil, fmt.Errorf("parse %s: %w", path, err) - } - for i := range entries { - // Lowercase addresses — the Hyperliquid stats bucket is case - // sensitive (paths only resolve when the address is lower). - entries[i].Address = strings.ToLower(entries[i].Address) - } - if len(entries) == 0 { - return nil, fmt.Errorf("empty registry: %s", path) - } - return entries, nil -} diff --git a/harnesses/hyperliquid-frontends/cmd/script/state.go b/harnesses/hyperliquid-frontends/cmd/script/state.go deleted file mode 100644 index c967dfa5..00000000 --- a/harnesses/hyperliquid-frontends/cmd/script/state.go +++ /dev/null @@ -1,135 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "fmt" - "os" - "path/filepath" - "time" - - _ "modernc.org/sqlite" -) - -// State layer = pure-Go SQLite under $STATE_DIR/state.db (defaults to -// ./state.db). One table records (builder, user) first-seen / last-seen -// timestamps so we can compute cohort retention without re-parsing the -// full CSV history every cycle. -// -// This is the differentiator vs every other Hyperliquid frontends -// dashboard — nobody publishes D7/D30 retention per builder cleanly -// because nobody bothers maintaining the state. Five lines of SQL -// plus a single 100 MB file on a Railway volume gets us a metric the -// rest of the field can't match. - -type State struct { - db *sql.DB -} - -func OpenState() (*State, error) { - dir := os.Getenv("STATE_DIR") - if dir == "" { - dir = "." - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, fmt.Errorf("mkdir state: %w", err) - } - path := filepath.Join(dir, "state.db") - db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") - if err != nil { - return nil, fmt.Errorf("open sqlite %s: %w", path, err) - } - if _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS user_activity ( - builder TEXT NOT NULL, - user TEXT NOT NULL, - first_seen_ms INTEGER NOT NULL, - last_seen_ms INTEGER NOT NULL, - fills INTEGER NOT NULL DEFAULT 0, - notional_usd REAL NOT NULL DEFAULT 0, - PRIMARY KEY (builder, user) - ); - CREATE INDEX IF NOT EXISTS idx_user_activity_first_seen - ON user_activity(builder, first_seen_ms); - CREATE INDEX IF NOT EXISTS idx_user_activity_last_seen - ON user_activity(builder, last_seen_ms); - `); err != nil { - return nil, fmt.Errorf("schema: %w", err) - } - fmt.Printf("[state] sqlite opened at %s\n", path) - return &State{db: db}, nil -} - -// Upsert one observation. Idempotent: re-running the same CSV is safe -// because first_seen is `MIN(existing, candidate)` and last_seen is -// `MAX(existing, candidate)`. Fills + notional aggregate via SUM but -// we de-dupe on the caller side per cycle (only NEW rows passed in). -func (s *State) Upsert(builder, user string, tsMs int64, notionalUSD float64) error { - _, err := s.db.Exec(` - INSERT INTO user_activity (builder, user, first_seen_ms, last_seen_ms, fills, notional_usd) - VALUES (?, ?, ?, ?, 1, ?) - ON CONFLICT(builder, user) DO UPDATE SET - first_seen_ms = MIN(first_seen_ms, excluded.first_seen_ms), - last_seen_ms = MAX(last_seen_ms, excluded.last_seen_ms), - fills = fills + 1, - notional_usd = notional_usd + excluded.notional_usd - `, builder, user, tsMs, tsMs, notionalUSD) - return err -} - -// Retention computes cohort retention for the given builder + window -// in days. Definition: of users whose first_seen_ms falls in the -// 24-hour window starting `days` ago, what fraction had a last_seen_ms -// within the last 24 hours. -// -// cohort = COUNT first_seen ∈ [now - days*24h, now - (days-1)*24h) -// active = COUNT first_seen ∈ that bucket AND last_seen ≥ (now - 24h) -// ratio = active / cohort (0 when cohort empty) -// -// D7 → days=7. D30 → days=30. -func (s *State) Retention(ctx context.Context, builder string, days int) (float64, int, error) { - now := time.Now().UTC().UnixMilli() - cohortStart := now - int64(days)*86_400_000 - cohortEnd := now - int64(days-1)*86_400_000 - activeAfter := now - 86_400_000 - - var cohort, returned int - // COALESCE because SUM(CASE) returns NULL on an empty result - // set (no users in the cohort window). database/sql can't scan - // NULL into a plain int, so we coerce to 0 in SQL. - err := s.db.QueryRowContext(ctx, ` - SELECT - COUNT(*) AS cohort, - COALESCE(SUM(CASE WHEN last_seen_ms >= ? THEN 1 ELSE 0 END), 0) AS returned - FROM user_activity - WHERE builder = ? - AND first_seen_ms >= ? - AND first_seen_ms < ? - `, activeAfter, builder, cohortStart, cohortEnd).Scan(&cohort, &returned) - if err != nil { - return 0, 0, err - } - if cohort == 0 { - return 0, 0, nil - } - return float64(returned) / float64(cohort) * 100, cohort, nil -} - -// PruneOlderThan removes activity rows older than the retention horizon -// so the SQLite file doesn't grow forever. Run once per cycle. -func (s *State) PruneOlderThan(days int) (int64, error) { - cutoff := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).UnixMilli() - res, err := s.db.Exec(`DELETE FROM user_activity WHERE last_seen_ms < ?`, cutoff) - if err != nil { - return 0, err - } - n, _ := res.RowsAffected() - return n, nil -} - -func (s *State) Close() error { - if s == nil || s.db == nil { - return nil - } - return s.db.Close() -} diff --git a/harnesses/hyperliquid-frontends/go.mod b/harnesses/hyperliquid-frontends/go.mod index 2fe449b4..a9a1aff2 100644 --- a/harnesses/hyperliquid-frontends/go.mod +++ b/harnesses/hyperliquid-frontends/go.mod @@ -1,9 +1,17 @@ module github.com/OpenChainBench/OpenChainBench/harnesses/hyperliquid-frontends -go 1.23 +go 1.22 + +require github.com/prometheus/client_golang v1.20.5 require ( - github.com/pierrec/lz4/v4 v4.1.21 - github.com/prometheus/client_golang v1.20.5 - modernc.org/sqlite v1.34.1 + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.17.9 // 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.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + golang.org/x/sys v0.22.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect ) diff --git a/harnesses/hyperliquid-frontends/go.sum b/harnesses/hyperliquid-frontends/go.sum index 55d3db80..d5318cf8 100644 --- a/harnesses/hyperliquid-frontends/go.sum +++ b/harnesses/hyperliquid-frontends/go.sum @@ -2,30 +2,14 @@ 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= -github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -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= @@ -34,40 +18,7 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= -golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= -golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= -modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= -modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= -modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= -modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= -modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= -modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= -modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= -modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= -modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= -modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= -modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= -modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=