diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index c19b7653..00000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"5394fd24-2c0e-4283-be73-5c2f3db6eb97","pid":28223,"acquiredAt":1783688629894} \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index b925adae..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "mcp__openchainbench__list_benchmarks" - ] - } -} diff --git a/.gitignore b/.gitignore index c9d710d4..9aabd4cf 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,7 @@ scripts/hf_publisher/.venv/ scripts/hf_space/.venv/ **/__pycache__/ *.pyc + +# Claude harness local state (per-session lock + per-user perms) +.claude/settings.local.json +.claude/*.lock diff --git a/benchmarks/aggregator-head-lag.yml b/benchmarks/aggregator-head-lag.yml index d9fade57..713e1c2b 100644 --- a/benchmarks/aggregator-head-lag.yml +++ b/benchmarks/aggregator-head-lag.yml @@ -170,7 +170,7 @@ providers: # 30D view lost its first ~8 days to a fake wall-to-wall gap. # Modern data at 15s cadence carries ~60 samples per 15 min so # the guard still fires the moment a WS goes silent. - series: avg_over_time(head_lag_seconds{aggregator="mobula"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="mobula"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="mobula"}[15m]) > 5) + series: avg_over_time(head_lag_seconds{aggregator="mobula"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="mobula"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="mobula"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{}[15m])) > 5 and on(aggregator, region) avg_over_time(ws_connected{aggregator="mobula", chain=""}[15m]) > 0.8) # Total gauge-value changes on this aggregator across chains and # regions in the last 15min. Non-zero = fresh events arrived; 0 = # every subscription has been silent for 15+ minutes (the gauge is @@ -180,13 +180,13 @@ providers: regions: - region: us-east p50: quantile_over_time(0.50, head_lag_seconds{aggregator="mobula", region="us-east"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="mobula", region="us-east"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="mobula", region="us-east"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="mobula", region="us-east"}[15m]) > 5) + series: avg_over_time(head_lag_seconds{aggregator="mobula", region="us-east"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="mobula", region="us-east"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="mobula", region="us-east"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="us-east"}[15m])) > 5 and on(aggregator, region) avg_over_time(ws_connected{aggregator="mobula", region="us-east", chain=""}[15m]) > 0.8) - region: eu-west p50: quantile_over_time(0.50, head_lag_seconds{aggregator="mobula", region="eu-west"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="mobula", region="eu-west"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="mobula", region="eu-west"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="mobula", region="eu-west"}[15m]) > 5) + series: avg_over_time(head_lag_seconds{aggregator="mobula", region="eu-west"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="mobula", region="eu-west"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="mobula", region="eu-west"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="eu-west"}[15m])) > 5 and on(aggregator, region) avg_over_time(ws_connected{aggregator="mobula", region="eu-west", chain=""}[15m]) > 0.8) - region: ap-southeast p50: quantile_over_time(0.50, head_lag_seconds{aggregator="mobula", region="sgp"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="mobula", region="sgp"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="mobula", region="sgp"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="mobula", region="sgp"}[15m]) > 5) + series: avg_over_time(head_lag_seconds{aggregator="mobula", region="sgp"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="mobula", region="sgp"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="mobula", region="sgp"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="sgp"}[15m])) > 5 and on(aggregator, region) avg_over_time(ws_connected{aggregator="mobula", region="sgp", chain=""}[15m]) > 0.8) - slug: codex name: Codex @@ -199,18 +199,18 @@ providers: mean: avg_over_time(head_lag_seconds{aggregator="codex"}[24h]) * 1000 success: clamp_max(count_over_time(head_lag_seconds{aggregator="codex"}[24h]) / 5760, 1) sample_size: sum(count_over_time(head_lag_seconds{aggregator="codex"}[24h])) - series: avg_over_time(head_lag_seconds{aggregator="codex"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex"}[15m]) > 5) + series: avg_over_time(head_lag_seconds{aggregator="codex"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{}[15m])) > 5 and on(aggregator, region) avg_over_time(ws_connected{aggregator="codex", chain=""}[15m]) > 0.8) live_activity: sum(changes(head_lag_seconds{aggregator="codex"}[15m])) regions: - region: us-east p50: quantile_over_time(0.50, head_lag_seconds{aggregator="codex", region="us-east"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="codex", region="us-east"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex", region="us-east"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex", region="us-east"}[15m]) > 5) + series: avg_over_time(head_lag_seconds{aggregator="codex", region="us-east"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex", region="us-east"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex", region="us-east"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="us-east"}[15m])) > 5 and on(aggregator, region) avg_over_time(ws_connected{aggregator="codex", region="us-east", chain=""}[15m]) > 0.8) - region: eu-west p50: quantile_over_time(0.50, head_lag_seconds{aggregator="codex", region="eu-west"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="codex", region="eu-west"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex", region="eu-west"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex", region="eu-west"}[15m]) > 5) + series: avg_over_time(head_lag_seconds{aggregator="codex", region="eu-west"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex", region="eu-west"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex", region="eu-west"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="eu-west"}[15m])) > 5 and on(aggregator, region) avg_over_time(ws_connected{aggregator="codex", region="eu-west", chain=""}[15m]) > 0.8) - region: ap-southeast p50: quantile_over_time(0.50, head_lag_seconds{aggregator="codex", region="sgp"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="codex", region="sgp"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex", region="sgp"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex", region="sgp"}[15m]) > 5) + series: avg_over_time(head_lag_seconds{aggregator="codex", region="sgp"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="codex", region="sgp"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="codex", region="sgp"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="sgp"}[15m])) > 5 and on(aggregator, region) avg_over_time(ws_connected{aggregator="codex", region="sgp", chain=""}[15m]) > 0.8) - slug: geckoterminal name: GeckoTerminal @@ -223,16 +223,16 @@ providers: mean: avg_over_time(head_lag_seconds{aggregator="geckoterminal"}[24h]) * 1000 success: clamp_max(count_over_time(head_lag_seconds{aggregator="geckoterminal"}[24h]) / 5760, 1) sample_size: sum(count_over_time(head_lag_seconds{aggregator="geckoterminal"}[24h])) - series: avg_over_time(head_lag_seconds{aggregator="geckoterminal"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="geckoterminal"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="geckoterminal"}[15m]) > 5) + series: avg_over_time(head_lag_seconds{aggregator="geckoterminal"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="geckoterminal"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="geckoterminal"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{}[15m])) > 5) live_activity: sum(changes(head_lag_seconds{aggregator="geckoterminal"}[15m])) regions: - region: us-east p50: quantile_over_time(0.50, head_lag_seconds{aggregator="geckoterminal", region="us-east"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="us-east"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="geckoterminal", region="us-east"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="geckoterminal", region="us-east"}[15m]) > 5) + series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="us-east"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="geckoterminal", region="us-east"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="geckoterminal", region="us-east"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="us-east"}[15m])) > 5) - region: eu-west p50: quantile_over_time(0.50, head_lag_seconds{aggregator="geckoterminal", region="eu-west"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="eu-west"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="geckoterminal", region="eu-west"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="geckoterminal", region="eu-west"}[15m]) > 5) + series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="eu-west"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="geckoterminal", region="eu-west"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="geckoterminal", region="eu-west"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="eu-west"}[15m])) > 5) - region: ap-southeast p50: quantile_over_time(0.50, head_lag_seconds{aggregator="geckoterminal", region="sgp"}[24h]) * 1000 - series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="sgp"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="geckoterminal", region="sgp"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="geckoterminal", region="sgp"}[15m]) > 5) + series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="sgp"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="geckoterminal", region="sgp"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="geckoterminal", region="sgp"}[15m]) > 5 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="sgp"}[15m])) > 5) diff --git a/benchmarks/bridge-quote-latency.yml b/benchmarks/bridge-quote-latency.yml index 0e98a1d9..2f494ca3 100644 --- a/benchmarks/bridge-quote-latency.yml +++ b/benchmarks/bridge-quote-latency.yml @@ -54,7 +54,7 @@ methodology: - "Notional sizes: $5, $50, $300 per quote." - "Cadence: full sweep (4 routes × 3 amounts × N bridges) every 5 minutes for 24 hours." - "Region: eu-west. Single point of measurement; multi-region requires running additional monitor instances." - - "Histogram buckets: 50, 100, 200, 500, 1000, 2000, 5000, 10000 ms." + - "Histogram buckets: 10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000 ms. Sub-50ms buckets added 2026-07-13 so solver-cached fast paths (Near Intents on HyperCore in particular) surface their true p50 instead of being floored at the interpolation of the (0, 50] bucket." - "Failures (quote_failed, execution_failed, unsupported route) excluded from latency aggregates and counted toward success rate." # Per-corridor leader rewrite deferred: this YAML does not yet declare @@ -89,7 +89,7 @@ faq: - q: "Are intent and relay bridges always faster than aggregators?" a: "On a single-route query, usually. Intent layers like Relay quote against a pool of pre-positioned solvers, so the API is a thin price-discovery call. Aggregators like LI.FI run a route-search graph over N underlying bridges per request, which adds 100 to 400 ms of irreducible work. On multi-hop or rare-corridor queries the aggregator advantage in coverage matters more than the latency gap, and the comparison flips." - q: "How does OpenChainBench measure bridge quote latency?" - a: "The harness issues identical quote requests against every bridge for the same route and notional, every five minutes, from a single eu-west origin. Each request times wall-clock duration from send to last byte received, then publishes a Prometheus histogram (50, 100, 200, 500, 1000, 2000, 5000, 10000 ms buckets). p50, p90 and p99 are derived via `histogram_quantile` over a rolling 24-hour window. Errored quotes (quote_failed, unsupported route, timeout) are excluded from the latency aggregate and counted toward success rate so a fast-but-broken bridge cannot game the headline." + a: "The harness issues identical quote requests against every bridge for the same route and notional, every five minutes, from a single eu-west origin. Each request times wall-clock duration from send to last byte received, then publishes a Prometheus histogram (10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000 ms buckets). p50, p90 and p99 are derived via `histogram_quantile` over a rolling 24-hour window. Errored quotes (quote_failed, unsupported route, timeout) are excluded from the latency aggregate and counted toward success rate so a fast-but-broken bridge cannot game the headline." - q: "Does quote latency affect the price the user gets?" a: "Indirectly, yes. A slower quote API leaves a larger window between the moment the price is quoted and the moment the user signs. On volatile corridors the underlying spot can drift in that window, which surfaces as a quoted-vs-realized gap. Intent layers compensate by re-quoting at signing time; aggregators usually pass through a slippage parameter. The fee benchmark on this site (`/benchmarks/bridge-fee`) measures the realized cost; this page measures the latency half of the same flow." diff --git a/benchmarks/solana-tx-landing-latency.yml b/benchmarks/solana-tx-landing-latency.yml new file mode 100644 index 00000000..b09c765e --- /dev/null +++ b/benchmarks/solana-tx-landing-latency.yml @@ -0,0 +1,221 @@ +# OpenChainBench. Bench № 027 + +slug: solana-tx-landing-latency +number: "027" +title: Fastest Solana RPC for tx landing, live slot delta benchmark +seo_title: "Fastest Solana RPC 2026: slot delta" +seo_description: "Fastest Solana RPC for tx landing live: Helius, Jito, Astralane, Nozomi p50 slot delta ranked." +subtitle: How fast each landing service gets a signed mainnet tx confirmed. Slot delta = number of Solana slots between submit and confirmed (1 slot is roughly 400 ms). Active probing every hour from us-east. +category: Trading +status: live +metric: p50 slot delta to confirmed (7-day window) +unit: slots +higher_is_better: false + +disclaimer: | + Six caveats. (1) us-east only, sgp / eu-west arrive in V2. (2) One pre-registered tip per service. (3) Synthetic payload (1-lamport + memo); real swaps may land differently. (4) Helius / Astralane / Nozomi fan out to Jito internally; Jito control probe runs each cycle. (5) Confirmation = `confirmed`. (6) Slot delta is canonical; ms is derived (≈ slot_delta × 400 ms + RTT). Pair with /benchmarks/solana-tx-landing. + +seo_intro: | + This benchmark answers the only question that matters to a + Solana trader picking a landing service. how many slots does + your signed mainnet transaction take to reach the confirmed + state on chain. Every hour from a us-east probe, the harness + submits an identical signed tx through each of 5 services in + parallel, captures the submit slot before send and the land + slot from the signatureSubscribe WebSocket notification at + commitment=confirmed, and increments per-service Prometheus + histograms. Headline numbers shown are p50 and p99 slot delta + over a rolling 7-day window. Wall-clock milliseconds are + published alongside for intuition (one Solana slot is ~400 ms, + so a p50 of 1 slot is ~400 ms wall-clock plus submission RTT) + but slot delta is the canonical, sponsor-proof on-chain + measurement. + Why slot delta is the right metric. Solana confirmation is a + slot-level event. when a slot reaches supermajority vote, every + transaction in it becomes confirmed simultaneously. Wall-clock + ms conflates HTTP submission time, our RPC's polling lag, and + network RTT to the public WebSocket - all of which are + measurement artifacts unrelated to the landing service's actual + routing quality. Slot delta is what the chain itself records. + Coverage. 5 services probed in V0-Lean. Jito Block Engine (the + control / baseline because Helius, Astralane, Nozomi all + internally route some flow through it). Helius Sender in + `swqos_only=true` mode (isolates the Helius own-path from the + Jito leg). Astralane Iris (tip-refund mechanism). Nozomi by + Temporal Labs (premium tier, hard 1M lamport tip floor). + 0slot.trade (premium tier). NextBlock, bloXroute and + SolanaVibeStation arrive in the next tier (V1) once the first + sponsors land. Companion bench. /benchmarks/solana-tx-landing + measures market share via on-chain tip-wallet attribution - + who carries the flow today, regardless of speed. + +abstract: | + We probe 5 Solana transaction landing services from a single + Railway us-east region, once per hour, by submitting an + identical signed mainnet transaction to each. The payload is + the minimal valid Solana tx, compute-budget instructions + (50k CU limit, 50k micro-lamport/CU price), a 1-lamport + self-transfer, the per-service tip transfer to the service's + documented tip wallet, and an OCB-prefixed memo for forensic + traceability. All five services are submitted in parallel + goroutines within a single cycle so they sample the same chain + congestion window. The headline measurement is slot delta, + land_slot minus submit_slot, captured from the + signatureSubscribe WebSocket notification's context.slot field + at commitment=confirmed. Wall-clock ms is reported alongside + but is a derived approximation, slot_delta × ~400 ms plus + submission RTT and goroutine startup variance. A 60 s no- + confirmation deadline classifies the probe as + dropped{reason=timeout}; structured RPC errors classify as + invalid; transport failures as network_error; HTTP 419 / 429 / + "rate limit" errors classify as rate_limited (a separate label + so quota issues don't bias the bench against the throttled + service). Cost. ~$159/mo at SOL=$86, 86 % of which goes to the + four ≥1M-lamport-floor services (Nozomi, 0slot, bloXroute, + NextBlock, only two of these in V0-Lean). Sponsor SOL credits + covering a service's own probes are explicitly allowed per the + sponsor-proof framework. Limitations. (a) Single us-east + region, sgp / eu-west arrive in V2 once sponsors fund + geographic-edge story. (b) 1-hour cadence, 168 probes per + service per 7-day window, enough for stable p50 / p99 over the + publication window, not enough for intra-hour resolution + (V0.5 / V1 upgrade if needed). (c) Fan-out, Helius probed in + `swqos_only` mode only in V0-Lean to keep wire shape simple; + dual-mode arrives in v1.0.1 methodology PR. + +methodology: + - "Source endpoints (us-east Railway, base64 JSON-RPC sendTransaction unless noted). Jito `ny.mainnet.block-engine.jito.wtf/api/v1/transactions`. Helius Sender `ewr-sender.helius-rpc.com/fast?swqos_only=true` (skipPreflight + maxRetries=0). Nozomi `http://edge.nozomi.temporal.xyz/api/sendBatch?c=` (binary `[u16_BE_len][tx_bytes]`, HTTP per Temporal Labs). Astralane `ny.gateway.astralane.io/iris?api-key=` (3-elem params, mevProtect). 0slot `ny.0slot.trade?api-key=`." + - "Probe payload. 5 instructions in this exact order: SetComputeUnitLimit(50,000) + SetComputeUnitPrice(50,000 micro-lamports/CU) + SystemProgram.Transfer(payer→payer, 1 lamport) + SystemProgram.Transfer(payer→service tip wallet, floor lamports) + Memo(`ocb---`). cycle_id is an 8-byte random hex shared across the five parallel probes of one cycle, so the on-chain memos correlate." + - "Tip floors (pre-registered, methodology PR + 14-day window to change). Jito 10,000 lamports. Helius Sender 10,000. Astralane 500,000 net of refunds. Nozomi 1,000,000. 0slot 1,000,000." + - "Submission flow. One getLatestBlockhash(processed) shared across all five probes. One getSlot(processed) as submit_slot. For each service we subscribe to the signature via signatureSubscribe on the public WS BEFORE submission (otherwise a fast confirm could fire before we listen). Probes then fire in parallel goroutines, sign, POST. We block on the signatureNotification at commitment=confirmed; context.slot is land_slot; slot_delta = land_slot - submit_slot." + - "Why slot delta is canonical. Solana confirmation is slot-level. when a slot reaches supermajority, every tx in it becomes confirmed simultaneously. The WS pushes notifications for all subscribed sigs in that slot at the same instant. So sub-400 ms wallclock diffs between services in the same slot are artifacts (goroutine startup, RTT), not routing quality. slot_delta is what the chain records, what to cite in audits." + - "Wall-clock ms is a derived approximation. ms ≈ slot_delta × ~400 ms + HTTP submission RTT + variance. We publish it for intuition because traders think in seconds, not slots, but it should not be the sole metric in a sponsor pitch or audit. If a service argues 'your ms numbers are biased by your RTT', the answer is the slot delta column, which is RTT-independent." + - "Drop classification. timeout = no confirmation within 60 s. invalid = RPC error, on-chain Err, or BlockhashNotFound. network_error = transport-level (timeout, DNS, EOF, connection refused). rate_limited = HTTP 419 / 429 / 'rate limit' / 'too many requests'. landing_rate is published as success / (success + timeout), rate_limited and network_error are excluded so quota / transport issues don't bias the bench against a throttled service." + - "Jito control probe. Helius (default), Astralane, Nozomi route a portion of flow through Jito internally, conflating own-path vs Jito-caught-it. Jito is in the V0-Lean set so its control fires in the same cycle. Same slot_delta as Jito = no measurable own-path value. Suspect ahead by 1+ slot = real routing advantage." + - "Reproducibility. The full harness source is at github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing. Anyone with a funded Solana keypair (~1 SOL) can clone, set SOLANA_PROBE_KEYPAIR_BASE58, run the binary, and reproduce these metrics. The bench does not rely on any private or internal service for measurement, the only RPC dependency is the public `api.mainnet-beta.solana.com` HTTP + WebSocket endpoints." + - "Methodology v1.3 pre-registered at github.com/ChainBench/OpenChainBench/blob/main/docs/methodology/solana-tx-landing-active.md. Any change (tip floor, probe payload, cadence, region, metric definitions) ships as a public PR with a 14-day comment window. Major version bumps run a 30-day shadow period publishing old and new metrics in parallel." + +findings: + - "{{best_name}} leads the V0-Lean probe set at p50 = {{best_p50}} slot delta over the rolling 7-day window. Lower = fewer Solana slots between submission and confirmation. The gap between fastest and slowest is the operational signal, every service claims '99 %+ landing rate' in marketing copy, but the chain doesn't lie about which slot included your tx. A 1-slot difference is ~400 ms, enough for a MEV bot to front-run a competitor." + - "{{name:jito}} is the baseline / control. Helius (default mode), Astralane, and Nozomi all internally fan out to Jito, so the Jito p50 is the floor any premium service must beat. Same slot_delta as Jito on a given cycle = the service is essentially using Jito as its inclusion path. {{name:jito}} sits at p50 = {{p50:jito}} slot delta." + - "{{name:helius-sender}} in `swqos_only` mode isolates Helius's own routing path from the Jito leg. p50 = {{p50:helius-sender}} slot delta. A v1.0.1 methodology update will publish Helius default mode (with Jito fan-out) side-by-side for direct comparison." + - "{{name:nozomi}} premium pricing (1M lamport hard floor, ~10 × Jito's competitive level) only makes economic sense if the slot_delta advantage is meaningful. p50 = {{p50:nozomi}} slot delta. The gap vs Jito quantifies whether the tip premium buys real slot priority." + - "{{worst_name}} trails at p50 = {{worst_p50}} slot delta. The worst slot delta in the V0-Lean set is not necessarily a bad service, it may be a service whose strength is in dimensions this bench doesn't measure (anti-MEV protection, durable nonce, fee-refund mechanics). Latency is one variable, not the whole product." + +faq: + - q: "Why is slot delta the headline metric instead of wall-clock latency?" + a: "Solana confirmation is a slot-level event. when a slot reaches supermajority vote (~2/3 of stake), every transaction in that slot becomes confirmed simultaneously. The WebSocket pushes notifications for all subscribed signatures in that slot at the same instant. So if 3 services delivered txs that all landed in the same slot, our wallclock measurement records the same time for all 3, the only differentiation is whether the next service's tx landed in slot N or N+1. slot_delta captures that directly. Wall-clock ms is derived (slot_delta × ~400 ms + RTT + variance) and conflates routing quality with measurement artifacts like HTTP submission speed and our public RPC's network latency. We publish wall-clock ms because traders think in seconds, but slot_delta is what you should cite in an audit or methodology dispute. It's RTT-independent and reads directly from the chain." + - q: "What does '1 slot' actually mean in time?" + a: "Solana slots are ~400 ms in practice (~625 ms target with leader skips and forks averaging it down). A p50 slot_delta of 1 means your tx typically lands in the slot immediately following your submission, ~400 ms after sendTransaction return. p50 of 2 means typically one slot later, ~800 ms. The gap between p50 = 1 and p50 = 2 is the operational signal, a service that consistently lands 1 slot earlier than its competitors is ~400 ms ahead, which is the difference between catching an arbitrage and missing it." + - q: "Why an active bench when /benchmarks/solana-tx-landing already exists?" + a: "/benchmarks/solana-tx-landing is observational, it watches the chain and counts who carries the flow. It cannot answer 'how fast does my tx land if I send it now', because it doesn't send anything. This bench (active probing) answers that, at the cost of running 24 / 7 with real SOL ($159 / month at the V0-Lean cadence). The two benches answer different product questions. Read both." + - q: "Why only 5 services, not the 8 you measure observationally?" + a: "NextBlock, bloXroute Trader, and SolanaVibeStation all require paid plans or sales-call onboarding before they issue an API key. We're shipping V0-Lean today with the 5 services that have a clear self-serve or contact-based path. The other 3 will be added as the bench scales. The observational bench at /benchmarks/solana-tx-landing already covers all 8 because it doesn't need API keys." + - q: "Why us-east only?" + a: "V0-Lean. us-east is the de-facto Solana baseline (Jito, NextBlock, bloXroute, Helius all anchor their best-connected POPs there) and is where most Solana bots deploy by default. Adding eu-west and sgp triples the bench cost and answers a different question ('does the ranking change by geography?'), which is a planned V2 scope expansion." + - q: "What's the probe payload?" + a: "Five instructions in this exact order, locked by methodology §3. (1) SetComputeUnitLimit(50,000). (2) SetComputeUnitPrice(50,000 micro-lamports/CU), together a 2,500-lamport priority fee. (3) SystemProgram.Transfer of 1 lamport from the prober keypair to itself, the minimal valid state-touching tx. (4) SystemProgram.Transfer to the service's documented tip wallet at the pre-registered floor. (5) Memo program write with the cycle ID, service name, and probe mode. Total weight: ~600 bytes, well under the 1,232-byte tx limit." + - q: "How is fan-out handled?" + a: "Helius (default mode), Astralane, and Nozomi route a portion of flow through Jito internally. The Jito control probe, Jito is part of the V0-Lean probe set, fires in the same cycle as the suspect services with the same blockhash and a comparable tip. The slot_delta column tells you immediately whether a suspect service is adding value beyond a Jito wrapper. Same slot_delta as Jito = same inclusion slot = Jito caught it. Suspect ahead by 1+ slot = real own-path routing. Helius is additionally probed in `?swqos_only=true` mode to fully isolate its own routing path." + - q: "Can a service detect and prioritise our probes?" + a: "Yes, in principle. The memo prefix `ocb-` is deterministic and the keypair is constant per region. Anti-fingerprinting (memo randomisation, sub-account rotation, tip jitter within the floor band) ships in v1.0.2 methodology PR. We disclose this risk openly; the trade-off is that announcing the bench publicly to providers gives them a chance to fix real performance issues before we publish, which is a good outcome. We do NOT accept private deals to alter the probe surface for any specific service." + - q: "Why is sample_size on the dashboard ~168 per service?" + a: "V0-Lean cadence = 1 probe per service per hour from 1 region. 168 = 24 hours × 7 days. The 7-day publication window is the trade-off between statistical resolution (sample size grows with window) and freshness (shorter window reflects current chain conditions). At ~168 samples per cell, p50 is stable to within ±5 % and p99 to within ±15 %. Lower confidence intervals are unlocked at V0.5 cadence (1 / 10 min, ~$760 / mo) and above." + - q: "How is the confirmation observed?" + a: "Via `signatureSubscribe` on the public mainnet WebSocket (`wss://api.mainnet-beta.solana.com`). The subscription is registered BEFORE submission so a fast-confirming tx cannot complete before we are listening (otherwise we'd miss the notification and timeout spuriously). The RPC pushes the notification at the instant the commitment level is reached, so observation resolution is RTT-bounded (~30-50 ms us-east → mainnet-beta) and slot_delta is read directly from the notification's context.slot field. HTTP polling at 200 ms is an automatic fallback if the WebSocket fails to connect for a given cycle." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing + +prometheus: + window: 7d + expected_freshness_seconds: 7200 + +# Real metrics emitted by the active prober in solana-tx-landing harness: +# solana_landing_probe_success_total{service, mode, region} counter +# solana_landing_probe_dropped_total{service, mode, region, reason} counter +# solana_landing_probe_latency_ms{service, mode, region} gauge (set every cycle) +# solana_landing_probe_latency_slots{service, mode, region} gauge (set every cycle) +# solana_landing_probe_latency_slots_histogram{service, mode, region} histogram (debug) +# solana_landing_probe_latency_ms_histogram{service, mode, region} histogram (debug) +# solana_landing_probe_keypair_balance_sol{region} gauge +# solana_landing_probe_cycle_total{region} counter +# solana_landing_probe_enabled{region} gauge +# +# Headline metric (canonical) = slot_p50 / slot_p99 read from the gauge. +# Wall-clock ms is published alongside via the standard p50/p90/p99 fields +# for reader intuition but is derived (slot_delta × ~400 ms + RTT + variance). +# Mode label is `swqos_only` for helius-sender, `default` for the rest. +# +# Why quantile_over_time(gauge) instead of histogram_quantile(histogram)? +# At V0-Lean cadence (1 probe / hour) we have ~168 samples per cell over 7d. +# Histogram buckets {100, 250, 500, 1000, 2000, 5000, 10000, 30000, 60000} ms +# have ~3 buckets in the 1-5s zone where probes actually land, so +# histogram_quantile collapses to bucket midpoints (1500, 3500 ms) and the +# series looks flat. quantile_over_time on the gauge takes the real sample +# at the 50th percentile, which is the accurate published number. + +providers: + - slug: jito + name: Jito + tag: Baseline + control probe; atomic bundles + tip auction since 2022 + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed mainnet probes submitted to Jito's `ny.mainnet.block-engine.jito.wtf` from us-east." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="jito",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="jito",region="us-east"} + + - slug: helius-sender + name: Helius + tag: Isolated Helius own-path (no Jito fan-out); anycast + 7 POPs + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted to Helius Sender in `swqos_only=true` mode from us-east, isolating its own-path." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="helius-sender",mode="swqos_only",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"} + + - slug: astralane + name: Astralane + tag: Tip-refund mechanism, sendBundle / sendIdeal modes, FRA + NY POPs + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted with a 500k-lamport net tip to Astralane Iris's NY gateway from us-east." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="astralane",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="astralane",region="us-east"} + + - slug: nozomi + name: Nozomi + tag: Temporal Labs, direct-to-leader, premium 1M-lamport hard floor + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted with a 1M-lamport tip to Nozomi's `edge.nozomi.temporal.xyz` from us-east." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="nozomi",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="nozomi",region="us-east"} + + - slug: mobula + name: Mobula + tag: Multi-RPC fan-out aggregator (relays via Jito / Nozomi / zeroslot) + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly probes submitted via Mobula's `api.mobula.io/api/2/swap/send` multi-RPC fan-out from us-east, using a Jito tip wallet." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="mobula",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="mobula",region="us-east"} diff --git a/benchmarks/token-deployment-cost.yml b/benchmarks/token-deployment-cost.yml index 35c5cc40..8d970745 100644 --- a/benchmarks/token-deployment-cost.yml +++ b/benchmarks/token-deployment-cost.yml @@ -8,7 +8,7 @@ seo_description: "Live USD cost to create a fungible token on 7 non-EVM L1 chain subtitle: "Live USD cost to bring a fungible token into existence on each chain, using that chain's canonical method. 7 non-EVM L1 chains, 5 minute refresh, no transactions broadcast." seo_intro: | - This page answers a question every memecoin founder, RWA issuer and non-EVM L1 marketing team asks. How much does it actually cost in dollars to create a token on each non-EVM chain right now. We track 7 non-EVM L1 chains in parallel and refresh the number every 5 minutes. Solana uses the SPL mint plus the Associated Token Account plus the Metaplex metadata account so the comparison is fair against ERC20 which embeds name and symbol natively. Sui and Aptos use Move publish gas budgets. Cardano reads coins_per_utxo_size from koios and applies the Conway minUTxO formula. Stellar uses the canonical issuer plus distribution plus trustline two account flow. Cosmos TokenFactory chains (Osmosis, Injective) read denom_creation_fee from the LCD. Every number is reproducible from source. No transactions are broadcast. EVM chains are excluded from this leaderboard for now while we finalise the canonical OpenZeppelin v5 ERC20 artifact bytecode used in eth_estimateGas. + This page answers a question every memecoin founder, RWA issuer and non-EVM L1 marketing team asks. How much does it actually cost in dollars to create a token on each non-EVM chain right now. We track 7 non-EVM L1 chains in parallel and refresh the number every 5 minutes. Solana uses the SPL mint plus the Associated Token Account plus the Metaplex metadata account so the comparison is fair against ERC20 which embeds name and symbol natively. Sui and Aptos use Move publish gas budgets. Cardano reads coins_per_utxo_size from koios and applies the Conway minUTxO formula. Stellar uses the canonical issuer plus distribution plus trustline two account flow at protocol-minimum reserves. Cosmos TokenFactory chains (Osmosis, Injective) read denom_creation_fee from the LCD. Every number is reproducible from source. No transactions are broadcast. EVM chains are excluded from this leaderboard for now while we finalise the canonical OpenZeppelin v5 ERC20 artifact bytecode used in eth_estimateGas. faq: - q: "What does this benchmark measure?" @@ -58,7 +58,7 @@ methodology: - "Aptos. /v1/estimate_gas_price.gas_estimate octas per unit times 150000 gas units for a canonical FA standard publish. Converted to USD via APT price." - "Cosmos TokenFactory (Osmosis, Injective). LCD //tokenfactory/v1beta1/params.denom_creation_fee. Empty array (Osmosis) means gas only, rendered as less than 0.001 dollar. Injective charges a flat 0.1 INJ." - "Cardano. Koios /epoch_params.coins_per_utxo_size. Apply Conway minUTxO formula = (160 byte overhead + 70 byte single asset bundle) × coins_per_utxo_size + 180000 lovelace mint tx fee. Converted to USD via ADA price." - - "Stellar. Horizon /ledgers.base_reserve_in_stroops and base_fee_in_stroops on the latest ledger. Total cost = 3 base reserves (issuer account + distribution account + trustline on distribution) + 2 base fees (create_account + change_trust). Converted to USD via XLM price." + - "Stellar. Horizon /ledgers.base_reserve_in_stroops and base_fee_in_stroops on the latest ledger. Total cost = 5 base reserves (the protocol minimum of 2 per new account for issuer and distribution, plus 1 for the trustline) + 2 base fees (create_account + change_trust). Reserves are locked capital, refundable on account closure, counted here because bringing the asset into existence requires them upfront. Converted to USD via XLM price." - "USD prices. api.mobula.io/api/1/market/multi-data?symbols=SOL,SUI,APT,OSMO,INJ,ADA,XLM polled every 5 minutes." - "Failures. Any upstream error increments token_deployment_samples_total{chain,status=error} and leaves the previous gauge value in place." - "EVM chains excluded. Ethereum, BNB Chain, Avalanche, Polygon, Arbitrum, Optimism, Base, Blast, Mantle, opBNB, Celo, Scroll and Linea were previously exposed but relied on a placeholder init bytecode that undercounted real ERC20 deploy cost by 30 to 500x. Removed until the canonical OZ v5 artifact is finalised." diff --git a/benchmarks/token-quote-coverage.yml b/benchmarks/token-quote-coverage.yml index d766c819..435b9d8d 100644 --- a/benchmarks/token-quote-coverage.yml +++ b/benchmarks/token-quote-coverage.yml @@ -19,10 +19,14 @@ per_chain_explainer: - slug: bnb h2: "Best DEX aggregator for BNB Chain new token coverage" body: | - {{best_name:chain:bnb}} currently leads on BNB Chain at {{best_p50:chain:bnb}} coverage (24h) across Four.meme. BNB is the hardest environment in this benchmark for non-native aggregators: Four.meme graduates to PancakeSwap V2, and routing engines that do not maintain a live BNB Chain pool-creation index miss newly-minted pairs entirely. Providers that derive their BNB token list from a shared coingecko-style database rather than on-chain event tracking typically lag by several hours. The probe fires a 1 USDC (via USDC.e bridged) to token quote; BNB-native aggregators (OKX DEX, KyberSwap) have structural coverage advantages over Solana-first providers here. + {{best_name:chain:bnb}} currently leads on BNB Chain at {{best_p50:chain:bnb}} coverage (24h) across Four.meme. BNB is the hardest environment in this benchmark for non-native aggregators: Four.meme graduates to PancakeSwap V2, and routing engines that do not maintain a live BNB Chain pool-creation index miss newly-minted pairs entirely. Providers that derive their BNB token list from a shared coingecko-style database rather than on-chain event tracking typically lag by several hours. The probe fires a 1 USDC (via USDC.e bridged) to token quote; KyberSwap has structural coverage advantages over Solana-first providers here due to its live BNB pool-creation event index. + - slug: robinhood + h2: "DEX aggregator coverage on Robinhood Chain" + body: | + Robinhood Chain (chainId 4663) is Robinhood's EVM-compatible Layer 2, launched in 2025. Tokens on Robinhood launch and graduate via the Pons launchpad to Uniswap V3-based pools, with USDG as the primary stablecoin. The bench probes all EVM aggregators (KyberSwap, Mobula) against Pons-graduated tokens. As of launch, no major third-party aggregator has integrated Robinhood chain routing, so coverage is expected to be near 0% across all providers — this gap is the data point. The bench will capture the first aggregator to integrate Robinhood and reflect coverage improvements in real time. category: Aggregators -status: draft +status: live metric: Quote coverage unit: pct higher_is_better: true @@ -45,7 +49,6 @@ seo_intro: | a live Base pool graph. Four.meme graduates need PancakeSwap v2 event indexing. An aggregator that dominates one launchpad can have gaps on another depending on how it discovers new pools. - Current leader overall is {{best_name}} at {{best_p50}}. abstract: | We benchmark DEX aggregators on their ability to quote tokens that @@ -65,15 +68,15 @@ abstract: | structural (it does not index Meteora DBC pool types) or temporary (it lags new Raydium pairs by a few minutes). The chain dimension narrows to a single chain for providers that are chain-specific - (Jupiter is Solana-only; 1inch and KyberSwap are EVM-only on the + (Jupiter is Solana-only; KyberSwap and Odos are EVM-only on the launchpads they cover). methodology: - - "Token discovery: Dexscreener /token-boosts/latest/v1 returns recently-active tokens sorted by on-chain boost activity. Up to 20 tokens per 30-minute batch are selected from each supported chain (Solana, Base, BNB). A follow-up batch call to /latest/dex/tokens/{addresses} resolves the originating dexId (pool factory), which maps to a venue label: pumpfun/moonshot/meteora-dlmm→Solana venues, virtual-fun/clanker/wow→Base venues, any BNB pair→four-meme." + - "Token discovery: three sources run in parallel each hour. (1) Dexscreener /token-boosts/latest/v1 covers Solana (pump-fun, moonshot, meteora-dbc) and Robinhood chain (pons). (2) Virtuals Protocol /api/virtuals?filters[status]=SENTIENT covers Base graduated agents (virtuals venue). (3) GeckoTerminal /api/v2/networks/bsc/new_pools covers BSC (four-meme). All token addresses are enriched via a Dexscreener /latest/dex/tokens batch call to resolve the originating dexId and map to venue labels." - "Quote probe: 1 USDC → token, fired in parallel to all in-scope providers with a 10s timeout. Input token is always USDC (native USDC on Solana and Base, bridged USDC.e on BNB). A probe is a coverage hit when the HTTP response is 2xx and the parsed outAmount field is a non-zero positive integer or decimal. All other outcomes (4xx, 5xx, no-route JSON, empty outAmount, timeout) record as misses." - - "Aggregator endpoints: Mobula /api/1/trade/swap/quote (Bearer auth, multi-chain), Jupiter quote-api.jup.ag/v6/quote?strictMode=false (Solana), KyberSwap aggregator-api.kyberswap.com/{chain}/api/v1/routes (EVM), OKX DEX /api/v5/dex/aggregator/quote (multi-chain), Odos /sor/quote/v2 (EVM). Each adapter normalises chain identifiers, input/output decimals, and error response shapes before recording outcome." + - "Aggregator endpoints: Mobula /api/2/swap/quoting (Bearer auth, EVM), Jupiter lite-api.jup.ag/swap/v1/quote (Solana), KyberSwap aggregator-api.kyberswap.com/{chain}/api/v1/routes (EVM), ParaSwap apiv5.paraswap.io/prices (EVM). Each adapter normalises chain identifiers, input/output decimals, and error response shapes before recording outcome." - "Jupiter strictMode=false: queried without the curated-token filter so Jupiter will attempt routing for any token with a discoverable on-chain pool, matching how most wallets call the API. strictMode=true would trivially exclude new tokens and defeat the purpose of the benchmark." - - "Chain-scope enforcement: Jupiter probes Solana-only; KyberSwap and Odos probe EVM chains only (Base, BNB). OKX DEX and Mobula probe all three chains. Providers are never penalised for chains they do not support: Solana cells for KyberSwap and Odos are null, not zero, and are excluded from cross-chain aggregates." + - "Chain-scope enforcement: Jupiter probes Solana-only. KyberSwap, Mobula, and ParaSwap probe EVM chains. KyberSwap and Mobula also cover Robinhood chain; ParaSwap covers Base and BNB only. Robinhood chain is currently at 0% coverage across all providers — an accurate reflection that no major aggregator has integrated Robinhood yet. Providers are never penalised for chains they structurally do not support: Solana cells for EVM-only providers are null, not zero." - "Coverage metric: Prometheus counters token_quote_coverage_success_total and token_quote_coverage_attempts_total carry labels {provider, venue, chain}. The bench queries compute increase(success[24h]) / increase(attempts[24h]) * 100 live at read time. No pre-aggregation; the ratio reflects exactly the tokens probed in the rolling window." - "Probe cadence and deduplication: each batch cycles through the current Dexscreener token-boosts feed. Tokens are not deduplicated across batches; the same token may appear in multiple 30-minute windows if it remains boosted. The score therefore reflects freshness of the aggregator's current routing graph, not historical coverage depth." - "Single origin: all probes originate from the eu-west OCB VPS. Regional latency is not a factor in this bench (the outcome is binary hit/miss, not latency). No region dimension in v1." @@ -100,7 +103,7 @@ faq: - q: "Why is Jupiter queried with strictMode=false?" a: "Jupiter's strictMode=true restricts quotes to verified tokens on its curated list, which defeats the purpose of a coverage bench targeting newly-launched tokens. strictMode=false instructs Jupiter to attempt routing for any token with an existing pool, matching how wallets typically call the API when displaying all tradeable tokens." - q: "What happens when a provider is chain-specific?" - a: "Jupiter is Solana-only and appears with null values on Base and BNB launchpad rows. 1inch and KyberSwap are EVM-only and appear as null on Solana launchpad rows. OKX DEX and Mobula cover multiple chains and are probed on all launchpads they support. Null-chain cells are excluded from cross-launchpad aggregates so chain-specific providers are not penalized." + a: "Jupiter is Solana-only and appears with null values on Base and BNB launchpad rows. KyberSwap, Odos, and Mobula are EVM-only and appear as null on Solana launchpad rows. Null-chain cells are excluded from cross-launchpad aggregates so chain-specific providers are not penalized." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/token-quote-coverage @@ -115,15 +118,17 @@ dimensions: - { value: pump-fun, label: Pump.fun } - { value: virtuals, label: Virtuals } - { value: four-meme, label: Four.meme } + - { value: pons, label: Pons } - { value: wow, label: WOW.XYZ } - { value: moonshot, label: Moonshot } - { value: meteora-dbc, label: Meteora DBC } - { value: clanker, label: Clanker } chain: - - { value: all, label: All chains } - - { value: solana, label: Solana } - - { value: base, label: Base } - - { value: bnb, label: BNB } + - { value: all, label: All chains } + - { value: solana, label: Solana } + - { value: base, label: Base } + - { value: bnb, label: BNB } + - { value: robinhood, label: Robinhood } ledger_columns: - { label: "Coverage rate", slot: p50, unit: pct } @@ -138,7 +143,7 @@ rank_matrix_query: > providers: - slug: mobula name: Mobula - tag: EVM only, Base + BNB, API key required + tag: EVM only, Base + BNB + Robinhood, API key required formula: "Rolling 24h ratio of token_quote_coverage_success_total to token_quote_coverage_attempts_total for provider=mobula, across all launchpads and chains Mobula supports." queries: p50: sum(increase(token_quote_coverage_success_total{provider="mobula"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="mobula"}[24h])) * 100 @@ -164,7 +169,7 @@ providers: - slug: kyberswap name: KyberSwap - tag: EVM multi-chain, per-chain aggregator engine + tag: EVM multi-chain, Base + BNB + Robinhood formula: "Rolling 24h ratio for provider=kyberswap. EVM launchpads only (Virtuals, WOW, Four.meme, Clanker). Solana cells null." queries: p50: sum(increase(token_quote_coverage_success_total{provider="kyberswap"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="kyberswap"}[24h])) * 100 @@ -175,28 +180,17 @@ providers: sample_size: sum(increase(token_quote_coverage_attempts_total{provider="kyberswap"}[24h])) series: sum(rate(token_quote_coverage_success_total{provider="kyberswap"}[1h])) / sum(rate(token_quote_coverage_attempts_total{provider="kyberswap"}[1h])) * 100 - - slug: okx-dex - name: OKX DEX - tag: Multi-chain, Solana + EVM - formula: "Rolling 24h ratio for provider=okx-dex. Covers Solana and EVM chains." - queries: - p50: sum(increase(token_quote_coverage_success_total{provider="okx-dex"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="okx-dex"}[24h])) * 100 - p90: sum(increase(token_quote_coverage_attempts_total{provider="okx-dex"}[24h])) - p99: sum(increase(token_quote_coverage_success_total{provider="okx-dex"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="okx-dex"}[24h])) * 100 - mean: sum(increase(token_quote_coverage_success_total{provider="okx-dex"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="okx-dex"}[24h])) * 100 - success: clamp_max(sum(increase(token_quote_coverage_success_total{provider="okx-dex"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="okx-dex"}[24h])), 1) - sample_size: sum(increase(token_quote_coverage_attempts_total{provider="okx-dex"}[24h])) - series: sum(rate(token_quote_coverage_success_total{provider="okx-dex"}[1h])) / sum(rate(token_quote_coverage_attempts_total{provider="okx-dex"}[1h])) * 100 - - - slug: odos - name: Odos - tag: EVM multi-chain, Base + BNB + Ethereum - formula: "Rolling 24h ratio for provider=odos. EVM launchpads only. Solana cells null." + - slug: paraswap + name: ParaSwap + tag: EVM multi-chain, Base + BNB + formula: "Rolling 24h ratio for provider=paraswap. EVM launchpads only (Base, BNB). Solana and Robinhood cells null." queries: - p50: sum(increase(token_quote_coverage_success_total{provider="odos"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="odos"}[24h])) * 100 - p90: sum(increase(token_quote_coverage_attempts_total{provider="odos"}[24h])) - p99: sum(increase(token_quote_coverage_success_total{provider="odos"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="odos"}[24h])) * 100 - mean: sum(increase(token_quote_coverage_success_total{provider="odos"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="odos"}[24h])) * 100 - success: clamp_max(sum(increase(token_quote_coverage_success_total{provider="odos"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="odos"}[24h])), 1) - sample_size: sum(increase(token_quote_coverage_attempts_total{provider="odos"}[24h])) - series: sum(rate(token_quote_coverage_success_total{provider="odos"}[1h])) / sum(rate(token_quote_coverage_attempts_total{provider="odos"}[1h])) * 100 + p50: sum(increase(token_quote_coverage_success_total{provider="paraswap"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="paraswap"}[24h])) * 100 + p90: sum(increase(token_quote_coverage_attempts_total{provider="paraswap"}[24h])) + p99: sum(increase(token_quote_coverage_success_total{provider="paraswap"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="paraswap"}[24h])) * 100 + mean: sum(increase(token_quote_coverage_success_total{provider="paraswap"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="paraswap"}[24h])) * 100 + success: clamp_max(sum(increase(token_quote_coverage_success_total{provider="paraswap"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="paraswap"}[24h])), 1) + sample_size: sum(increase(token_quote_coverage_attempts_total{provider="paraswap"}[24h])) + series: sum(rate(token_quote_coverage_success_total{provider="paraswap"}[1h])) / sum(rate(token_quote_coverage_attempts_total{provider="paraswap"}[1h])) * 100 + + diff --git a/harnesses/bridge-monitor/.gitignore b/harnesses/bridge-monitor/.gitignore index 091b86f3..ad59826f 100644 --- a/harnesses/bridge-monitor/.gitignore +++ b/harnesses/bridge-monitor/.gitignore @@ -1,5 +1,7 @@ .env bin/ +spend-state.json +spend-state.json.tmp *.log prometheus_data/ grafana_data/ diff --git a/harnesses/bridge-monitor/cmd/monitor/across_bridge.go b/harnesses/bridge-monitor/cmd/monitor/across_bridge.go index fa43f673..d7f6cb9b 100644 --- a/harnesses/bridge-monitor/cmd/monitor/across_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/across_bridge.go @@ -188,7 +188,7 @@ func (a *AcrossBridge) TestRoute(route TestRoute, amount, amountUsd float64, raw // Latency is only meaningful for quotes that returned a usable route // (same rule as the other bridges). Fast 4xx rejections must not enter // the histogram or they skew the leaderboard. - bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) // fees.total.amountUsd is Across's own USD valuation of input value minus diff --git a/harnesses/bridge-monitor/cmd/monitor/balance.go b/harnesses/bridge-monitor/cmd/monitor/balance.go index 570a38fa..bb9f5e1d 100644 --- a/harnesses/bridge-monitor/cmd/monitor/balance.go +++ b/harnesses/bridge-monitor/cmd/monitor/balance.go @@ -9,6 +9,9 @@ import ( "os" "strings" "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" ) // BalanceChecker fetches wallet balances from Mobula API @@ -17,6 +20,18 @@ type BalanceChecker struct { apiKey string evmAddress string solanaAddress string + + // Fallback on-chain reader. When the Mobula portfolio API fails we read the + // triangle tokens directly from RPC instead of silently returning zeros, + // which used to make every tier look like "insufficient funds" during an + // API outage. Nil in quote-only mode (no TxExecutor). + onchain *TxExecutor +} + +// SetOnchainFallback wires the RPC-based balance readers. Called after the +// TxExecutor exists because the BalanceChecker is constructed first in main. +func (bc *BalanceChecker) SetOnchainFallback(tx *TxExecutor) { + bc.onchain = tx } // MobulaWalletResponse represents the Mobula wallet API response @@ -53,6 +68,17 @@ func NewBalanceChecker(apiKey, evmAddress, solanaAddress string) *BalanceChecker // GetAllBalances fetches balances for all configured wallets // Returns map[chain][token] = balance_usd func (bc *BalanceChecker) GetAllBalances() (map[string]map[string]float64, error) { + balances, _, err := bc.GetAllBalancesDetailed() + return balances, err +} + +// GetAllBalancesDetailed fetches balances for all configured wallets. +// Primary source is the Mobula portfolio API; on per-chain failure it falls +// back to direct RPC reads of the triangle tokens. The degraded flag is true +// when at least one chain could not be read by EITHER path: callers must treat +// degraded balances as unreadable (never "empty wallet") and must not trigger +// automatic rebalancing from them. +func (bc *BalanceChecker) GetAllBalancesDetailed() (map[string]map[string]float64, bool, error) { result := make(map[string]map[string]float64) // Initialize chains @@ -60,11 +86,17 @@ func (bc *BalanceChecker) GetAllBalances() (map[string]map[string]float64, error result["Base"] = make(map[string]float64) result["Arbitrum"] = make(map[string]float64) + degraded := false + // Fetch Solana balances if bc.solanaAddress != "" { solBalances, err := bc.fetchWalletBalance(bc.solanaAddress) if err != nil { log.Printf("⚠️ Failed to fetch Solana balances: %v", err) + if !bc.fillSolanaFromChain(result) { + log.Printf("⚠️ On-chain Solana fallback also failed: balances degraded") + degraded = true + } } else { indexAssets(result, solBalances, "Solana") } @@ -76,6 +108,10 @@ func (bc *BalanceChecker) GetAllBalances() (map[string]map[string]float64, error baseBalances, err := bc.fetchWalletBalanceByChain(bc.evmAddress, "base") if err != nil { log.Printf("⚠️ Failed to fetch Base balances: %v", err) + if !bc.fillEVMFromChain(result, "Base") { + log.Printf("⚠️ On-chain Base fallback also failed: balances degraded") + degraded = true + } } else { indexAssets(result, baseBalances, "Base") } @@ -84,12 +120,118 @@ func (bc *BalanceChecker) GetAllBalances() (map[string]map[string]float64, error arbBalances, err := bc.fetchWalletBalanceByChain(bc.evmAddress, "arbitrum") if err != nil { log.Printf("⚠️ Failed to fetch Arbitrum balances: %v", err) + if !bc.fillEVMFromChain(result, "Arbitrum") { + log.Printf("⚠️ On-chain Arbitrum fallback also failed: balances degraded") + degraded = true + } } else { indexAssets(result, arbBalances, "Arbitrum") } } - return result, nil + if degraded { + bridgeBalanceReadDegraded.Set(1) + } else { + bridgeBalanceReadDegraded.Set(0) + } + + return result, degraded, nil +} + +// Token addresses read by the on-chain fallback. Stablecoins are valued at $1 +// parity (good enough to gate executions); SOL and ETH go through the price +// cache with a static fallback because during a Mobula outage the pricer may +// be down too, and a stale gas estimate beats a zero. +const ( + solanaUSDCMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + baseUSDCAddr = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + arbUSDTAddr = "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9" + arbUSDCAddr = "0xaf88d065e77c8cc2239327c5edb3a432268e5831" +) + +// fillSolanaFromChain reads Solana USDC + native SOL via RPC. Returns true only +// when the triangle token (USDC) was read successfully: SOL alone is not enough +// to run the cycle simulation, so a USDC read failure keeps the chain degraded. +func (bc *BalanceChecker) fillSolanaFromChain(result map[string]map[string]float64) bool { + if bc.onchain == nil || bc.onchain.solanaClient == nil { + return false + } + owner, err := solana.PublicKeyFromBase58(bc.solanaAddress) + if err != nil { + return false + } + + mint := solana.MustPublicKeyFromBase58(solanaUSDCMint) + raw, err := bc.onchain.solanaSPLBalanceOf(owner, mint) + if err != nil { + return false + } + usd := rawToFloat(raw, 6) + result["Solana"]["USDC"] = usd + result["Solana"][strings.ToLower(solanaUSDCMint)] = usd + + if lamports, err := bc.onchain.solanaNativeBalance(owner); err == nil { + result["Solana"]["SOL"] = rawToFloat(lamports, 9) * TokenPriceUSD("SOL", 150) + } + log.Printf("✅ Solana balances recovered via on-chain fallback (USDC $%.2f)", usd) + return true +} + +// fillEVMFromChain reads the chain's triangle stablecoin + native ETH via RPC. +// Same success rule as Solana: the triangle token read must succeed. +func (bc *BalanceChecker) fillEVMFromChain(result map[string]map[string]float64, chain string) bool { + if bc.onchain == nil { + return false + } + owner := common.HexToAddress(bc.evmAddress) + + type tokenRead struct { + addr string + symbols []string + } + var reads []tokenRead + switch chain { + case "Base": + reads = []tokenRead{{baseUSDCAddr, []string{"USDC"}}} + case "Arbitrum": + // Both USDT0 and USDT symbols: cycle_sim reads "USDT0" (Mobula naming) + // while route helpers fall back to "USDT". + reads = []tokenRead{ + {arbUSDTAddr, []string{"USDT0", "USDT"}}, + {arbUSDCAddr, []string{"USDC"}}, + } + default: + return false + } + + ok := false + for i, r := range reads { + raw, err := bc.onchain.erc20BalanceOf(chain, common.HexToAddress(r.addr), owner) + if err != nil { + // Only the first entry is the triangle token; secondary reads are + // best-effort for the stranded-fund gauges. + if i == 0 { + return false + } + continue + } + usd := rawToFloat(raw, 6) + for _, sym := range r.symbols { + result[chain][sym] = usd + } + result[chain][strings.ToLower(r.addr)] = usd + if i == 0 { + ok = true + } + } + + if wei, err := bc.onchain.evmNativeBalance(chain, owner); err == nil { + result[chain]["ETH"] = rawToFloat(wei, 18) * TokenPriceUSD("ETH", 3000) + } + if ok { + log.Printf("✅ %s balances recovered via on-chain fallback", chain) + } + return ok } // indexAssets stores balances in the result map, keyed by BOTH symbol AND contract address (lowercase). @@ -124,7 +266,7 @@ func indexAssets(result map[string]map[string]float64, resp *MobulaWalletRespons // fetchWalletBalance fetches balance for a single wallet (all chains) func (bc *BalanceChecker) fetchWalletBalance(address string) (*MobulaWalletResponse, error) { - url := fmt.Sprintf("https://api.mobula.io/api/1/wallet/portfolio?wallet=%s", address) + url := fmt.Sprintf("https://demo-api.mobula.io/api/1/wallet/portfolio?wallet=%s", address) req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -156,7 +298,7 @@ func (bc *BalanceChecker) fetchWalletBalance(address string) (*MobulaWalletRespo // fetchWalletBalanceByChain fetches balance for a specific chain func (bc *BalanceChecker) fetchWalletBalanceByChain(address, chain string) (*MobulaWalletResponse, error) { - url := fmt.Sprintf("https://api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", address, chain) + url := fmt.Sprintf("https://demo-api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", address, chain) req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -268,12 +410,18 @@ func (bc *BalanceChecker) GetTotalBalanceUSD() (float64, error) { return total, nil } -// SimulateBalances returns fake balances for dry-run mode -func SimulateBalances() map[string]map[string]float64 { +// SimulateBalances returns fake balances for dry-run mode. Outside dry-run it +// always returns nil: simulated balances feeding a broadcast-capable process +// could green-light real transfers based on made-up numbers. +func SimulateBalances(mode ExecutionMode) map[string]map[string]float64 { // Check if we should simulate if os.Getenv("SIMULATE_BALANCES") != "true" { return nil } + if mode != ModeDryRun { + log.Printf("⚠️ SIMULATE_BALANCES=true ignored: EXECUTION_MODE is %q, simulated balances are only honored in dry-run", mode) + return nil + } log.Println("🧪 Using simulated balances (SIMULATE_BALANCES=true)") return map[string]map[string]float64{ diff --git a/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go b/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go index 2c15fd65..031eb0c3 100644 --- a/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go @@ -121,7 +121,7 @@ func (d *DebridgeBridge) TestRoute(route TestRoute, amount, amountUsd float64, r // (the published methodology measures exactly that). Fast failures, e.g. // Cloudflare 403s answered in 30ms, must not enter the histogram: they // made deBridge look 15x faster the moment its API started rejecting us. - bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) // Debridge returns input (with opEx ajusté) and output diff --git a/harnesses/bridge-monitor/cmd/monitor/executor.go b/harnesses/bridge-monitor/cmd/monitor/executor.go index 94ef212b..7428375f 100644 --- a/harnesses/bridge-monitor/cmd/monitor/executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/executor.go @@ -1,6 +1,7 @@ package main import ( + "crypto/ecdsa" "encoding/json" "fmt" "log" @@ -18,16 +19,17 @@ const ( ModeProduction ExecutionMode = "production" // Full execution loop ) -// ExecutionConfig holds the execution loop configuration +// ExecutionConfig holds the execution loop configuration. +// Daily spend tracking lives in the Executor's SpendTracker (UTC-date keyed, +// persisted to disk), not here: a plain struct field never reset and was +// zeroed by every restart. type ExecutionConfig struct { - Mode ExecutionMode - Freq5USD time.Duration // How often to run $5 tests - Freq50USD time.Duration // How often to run $50 tests - Freq300USD time.Duration // How often to run $300 tests - EnableDebridge bool // Whether to execute Debridge (expensive) - MaxDailySpendUSD float64 // Safety cap on daily spending - DailySpentUSD float64 // Track daily spending - LastResetDay int // Day of month for daily reset + Mode ExecutionMode + Freq5USD time.Duration // How often to run $5 tests + Freq50USD time.Duration // How often to run $50 tests + Freq300USD time.Duration // How often to run $300 tests + EnableDebridge bool // Whether to execute Debridge (expensive) + MaxDailySpendUSD float64 // Safety cap on daily spending } // ExecutionResult holds the result of an execution test @@ -68,6 +70,36 @@ type Executor struct { debridge *DebridgeBridge region string slack *SlackNotifier + spend *SpendTracker +} + +// DailySpent returns today's consumed budget via the mutex-guarded tracker. +// All cap checks must go through here, never through a raw field, so the +// reaper goroutine and the scheduler loop cannot race on the counter. +func (e *Executor) DailySpent() float64 { + return e.spend.Spent() +} + +// AddDailySpend books usd against today's budget (UTC-date keyed, persisted). +func (e *Executor) AddDailySpend(usd float64) { + e.spend.Add(usd) +} + +// accountSpend books a broadcast's cost against the daily cap. A successful +// fill books the realized fee. Any broadcast that produced a TxHash but did +// not confirm as a success books a conservative flat estimate, because the +// deposit or approval TX most likely burned gas even without a fill. Results +// that never broadcast cost nothing. +func (e *Executor) accountSpend(result *ExecutionResult) { + if result == nil || result.DryRun { + return + } + switch { + case result.Success: + e.AddDailySpend(result.ActualFeeUSD) + case result.TxHash != "": + e.AddDailySpend(failedTxFeeEstimateUSD()) + } } // NewExecutor creates a new executor @@ -92,6 +124,7 @@ func NewExecutor( debridge: debridge, region: region, slack: slack, + spend: NewSpendTracker(spendStatePath(), time.Now), } // Initialize TxExecutor if we have private keys @@ -112,6 +145,11 @@ func NewExecutor( } else { e.txExecutor = txExec log.Println("✅ TxExecutor initialized") + // Give the balance checker an RPC fallback so a Mobula portfolio + // API outage no longer reads as an empty wallet. + if balanceCheck != nil { + balanceCheck.SetOnchainFallback(txExec) + } } } @@ -128,12 +166,21 @@ func (e *Executor) RunDryRun(route TestRoute, amountUSD float64) *ExecutionResul log.Printf("🧪 [DRY-RUN] Testing %s with $%.0f", route.Name, amountUSD) - // Step 1: Check balances + // Step 1: Check balances. Keyless dry-run has no balance checker, so fall + // back to the SIMULATE_BALANCES snapshot instead of crashing. log.Printf(" 📊 Checking balances...") - balances, err := e.balanceCheck.GetAllBalances() - if err != nil { - log.Printf(" ❌ Balance check failed: %v", err) - result.Error = err + var balances map[string]map[string]float64 + if e.balanceCheck != nil { + var err error + balances, err = e.balanceCheck.GetAllBalances() + if err != nil { + log.Printf(" ❌ Balance check failed: %v", err) + result.Error = err + return result + } + } else if balances = SimulateBalances(e.config.Mode); balances == nil { + log.Printf(" ❌ No balance checker and SIMULATE_BALANCES not set") + result.Error = fmt.Errorf("no balance source in dry-run") return result } @@ -198,8 +245,8 @@ func (e *Executor) RunReal(route TestRoute, amountUSD float64) []*ExecutionResul } // Check daily spending limit - if e.config.DailySpentUSD >= e.config.MaxDailySpendUSD { - msg := fmt.Sprintf("Daily spending limit reached ($%.2f / $%.2f)", e.config.DailySpentUSD, e.config.MaxDailySpendUSD) + if spent := e.DailySpent(); spent >= e.config.MaxDailySpendUSD { + msg := fmt.Sprintf("Daily spending limit reached ($%.2f / $%.2f)", spent, e.config.MaxDailySpendUSD) log.Printf("⚠️ %s", msg) if e.slack != nil { _ = e.slack.NotifyScheduledSkip(route.Name, route.FromChain, route.FromToken, amountUSD, msg) @@ -261,10 +308,9 @@ func (e *Executor) RunReal(route TestRoute, amountUSD float64) []*ExecutionResul } } - // Update daily spending - if result.Success { - e.config.DailySpentUSD += result.ActualFeeUSD - } + // Update daily spending (realized fee on success, flat gas + // estimate on a broadcast that never confirmed). + e.accountSpend(result) } // Wait between bridges to avoid rate limiting @@ -282,8 +328,8 @@ func (e *Executor) RunBridgeOnRoute(bridge string, route TestRoute, amountUSD fl if e.txExecutor == nil || !e.txExecutor.CanExecute() { return nil } - if e.config.DailySpentUSD >= e.config.MaxDailySpendUSD { - log.Printf("⚠️ Daily spending limit reached ($%.2f / $%.2f)", e.config.DailySpentUSD, e.config.MaxDailySpendUSD) + if spent := e.DailySpent(); spent >= e.config.MaxDailySpendUSD { + log.Printf("⚠️ Daily spending limit reached ($%.2f / $%.2f)", spent, e.config.MaxDailySpendUSD) return nil } @@ -306,9 +352,7 @@ func (e *Executor) RunBridgeOnRoute(bridge string, route TestRoute, amountUSD fl } } - if result.Success { - e.config.DailySpentUSD += result.ActualFeeUSD - } + e.accountSpend(result) return result } @@ -356,13 +400,17 @@ func (e *Executor) executeOnBridge(bridge string, route TestRoute, amount, amoun result.ToToken = route.ToToken result.AmountUSD = amountUSD + // Keep the TxHash even when the attempt errored out: callers use it to + // tell a pre-broadcast failure (safe to retry) from a broadcast whose + // final status is unknown (terminal, funds may still be in flight) and + // to account the gas a failed TX still burned. + result.TxHash = txHash + if err != nil { log.Printf(" ❌ Execution failed: %v", err) result.Error = err return result } - - result.TxHash = txHash // If the sub-function flagged a refund/revert, keep Success=false so Slack and // Prometheus correctly classify it (Reverted takes precedence over Success). result.Success = !result.Reverted @@ -427,12 +475,18 @@ func (e *Executor) executeMobula(route TestRoute, amount float64, quoteStart tim log.Printf(" [mobula] Getting quote: %s → %s, amount: %.4f, sender: %s", route.FromChain, route.ToChain, amount, senderAddress[:8]+"...") - // Get quote with TX - quote, _, err := e.mobula.GetQuote( + // Two-step quote: for EVM origins the first response's deposit is a + // placeholder; only the signed re-quote's deposit is executable. See + // mobula_bridge.go GetSignedQuote for the flow. + var evmKey *ecdsa.PrivateKey + if e.txExecutor != nil { + evmKey = e.txExecutor.EVMPrivateKey() + } + quote, _, err := e.mobula.GetSignedQuote( route.FromChainAPI, route.FromToken, route.ToChainAPI, route.ToToken, senderAddress, receiverAddress, - amount, + amount, evmKey, ) if err != nil { log.Printf(" [mobula] ❌ Quote error: %v", err) @@ -852,6 +906,12 @@ func (e *Executor) recordExecutionMetrics(result *ExecutionResult) { result.Route.ToToken, amountStr, e.region, + // chain dimension label, same convention as every quote path + // (mobula_bridge.go etc). This 8th label was missed when the + // metrics gained the chain dimension, and because execution was + // paused in prod the mismatch only surfaced at the first real + // single-test (panic: inconsistent label cardinality). + result.Route.ToChain, } // Record latencies @@ -1004,8 +1064,8 @@ func (e *Executor) testBridgeDryRun(bridge string, route TestRoute, amountUSD fl log.Printf(" 💰 Estimated cost: $%.4f", expectedCost) // Update daily spending tracker (even in dry-run for estimation) - e.config.DailySpentUSD += expectedCost - log.Printf(" 📈 Daily spend estimate: $%.2f / $%.2f max", e.config.DailySpentUSD, e.config.MaxDailySpendUSD) + e.AddDailySpend(expectedCost) + log.Printf(" 📈 Daily spend estimate: $%.2f / $%.2f max", e.DailySpent(), e.config.MaxDailySpendUSD) } // ValidateSetup checks that everything is configured correctly diff --git a/harnesses/bridge-monitor/cmd/monitor/gas_topup.go b/harnesses/bridge-monitor/cmd/monitor/gas_topup.go new file mode 100644 index 00000000..5e244ff6 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/gas_topup.go @@ -0,0 +1,212 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + "time" +) + +// Gas auto-top-up: an execution slot can fail for the dumbest possible reason, +// no native gas to sign the deposit TX. Pre-flight already fetches balances, +// so we check SOL / Base ETH / Arb ETH against a USD floor and, when allowed, +// swap wallet USDC into native gas on that same chain via a LI.FI same-chain +// swap (their /v1/quote accepts fromChain == toChain and returns broadcastable +// calldata, same shape as a bridge quote). +// +// SAFETY GATE: GAS_TOPUP_ENABLED defaults to false. The plumbing below reuses +// the proven LiFi execution path, but the same-chain variant has not been +// exercised with real funds from this harness yet. Until someone runs one +// supervised top-up per chain and flips the env var, low gas only alerts. + +// LiFi native-token markers. +const ( + lifiEVMNative = "0x0000000000000000000000000000000000000000" + lifiSolanaNative = "11111111111111111111111111111111" +) + +type gasChain struct { + Chain string + NativeSym string + USDCSource string +} + +var gasChains = []gasChain{ + {Chain: "Solana", NativeSym: "SOL", USDCSource: solanaUSDCMint}, + {Chain: "Base", NativeSym: "ETH", USDCSource: baseUSDCAddr}, + {Chain: "Arbitrum", NativeSym: "ETH", USDCSource: arbUSDCAddr}, +} + +type GasTopper struct { + executor *Executor + slack *SlackNotifier + + enabled bool + minUSD float64 + topupUSD float64 + + // Per-chain USD swapped today; resets on UTC day change. Caps a runaway + // price-feed glitch to one top-up per chain per day. + spentToday map[string]float64 + day int +} + +func NewGasTopper(executor *Executor, slack *SlackNotifier) *GasTopper { + if executor == nil { + return nil + } + return &GasTopper{ + executor: executor, + slack: slack, + enabled: strings.EqualFold(strings.TrimSpace(os.Getenv("GAS_TOPUP_ENABLED")), "true"), + minUSD: parseFloat(os.Getenv("GAS_MIN_USD"), 15), + topupUSD: parseFloat(os.Getenv("GAS_TOPUP_USD"), 25), + spentToday: make(map[string]float64), + day: time.Now().UTC().YearDay(), + } +} + +// CheckAndTopUp inspects native gas on each chain and tops up where needed. +// Called from tier pre-flight with balances the caller already validated as +// non-degraded: a phantom zero here would otherwise trigger a pointless swap. +func (g *GasTopper) CheckAndTopUp(balances map[string]map[string]float64) { + if g == nil { + return + } + + if today := time.Now().UTC().YearDay(); today != g.day { + g.day = today + g.spentToday = make(map[string]float64) + } + + for _, gc := range gasChains { + gasUSD := balances[gc.Chain][gc.NativeSym] + if gasUSD >= g.minUSD { + continue + } + log.Printf("⛽ Low gas on %s: $%.2f %s (floor $%.2f)", gc.Chain, gasUSD, gc.NativeSym, g.minUSD) + + if !g.enabled { + bridgeGasTopup.WithLabelValues(gc.Chain, "gated").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "gated", fmt.Sprintf( + "Native gas is $%.2f, below the $%.2f floor, but GAS_TOPUP_ENABLED is off. Top up manually or enable after a supervised test.", + gasUSD, g.minUSD)) + continue + } + if g.executor.txExecutor == nil || !g.executor.txExecutor.CanExecute() { + // Dry-run or missing keys: detection is still useful in logs, but + // there is nothing safe to broadcast. + continue + } + if g.spentToday[gc.Chain] >= g.topupUSD { + bridgeGasTopup.WithLabelValues(gc.Chain, "capped").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "capped", fmt.Sprintf( + "Native gas is $%.2f but today's top-up budget ($%.2f) is already spent.", gasUSD, g.topupUSD)) + continue + } + // Same daily-cap pre-check the executor applies before any broadcast: + // a top-up is still spend and must not blow past MaxDailySpendUSD. + if spent := g.executor.DailySpent(); spent >= g.executor.config.MaxDailySpendUSD { + bridgeGasTopup.WithLabelValues(gc.Chain, "capped").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "capped", fmt.Sprintf( + "Native gas is $%.2f but the daily spend limit is reached ($%.2f / $%.2f). No top-up attempted.", + gasUSD, spent, g.executor.config.MaxDailySpendUSD)) + continue + } + + amount := g.topupUSD - g.spentToday[gc.Chain] + if amount > g.topupUSD { + amount = g.topupUSD + } + g.topUpChain(gc, amount, gasUSD) + } +} + +// topUpChain swaps `amountUSD` of USDC into native gas on one chain. +func (g *GasTopper) topUpChain(gc gasChain, amountUSD, gasUSD float64) { + bridgeGasTopup.WithLabelValues(gc.Chain, "attempted").Inc() + + toToken := lifiEVMNative + sender := g.executor.walletManager.EVMAddress + if gc.Chain == "Solana" { + toToken = lifiSolanaNative + sender = g.executor.walletManager.SolanaAddress + } + + route := TestRoute{ + Name: fmt.Sprintf("GAS_TOPUP_%s", strings.ToUpper(gc.Chain)), + FromChain: gc.Chain, + FromChainAPI: chainAPIFor(gc.Chain), + FromToken: gc.USDCSource, + ToChain: gc.Chain, + ToChainAPI: chainAPIFor(gc.Chain), + ToToken: toToken, + IsSolanaSrc: gc.Chain == "Solana", + } + rawUnits := toRawUnits(amountUSD) + + quote, _, err := g.executor.lifi.GetQuote(route, rawUnits, sender, sender) + if err != nil { + bridgeGasTopup.WithLabelValues(gc.Chain, "failed").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "failed", fmt.Sprintf( + "LI.FI same-chain swap quote failed (gas $%.2f, wanted $%.2f USDC to %s): %v", gasUSD, amountUSD, gc.NativeSym, err)) + return + } + + // EVM swaps of ERC-20 input need the router approved first, same as bridges. + if quote.Estimate.ApprovalAddress != "" && gc.Chain != "Solana" { + approvalHash, err := g.executor.txExecutor.ApproveERC20(gc.Chain, quote.Action.FromToken.Address, quote.Estimate.ApprovalAddress, quote.Action.FromAmount) + if err != nil { + bridgeGasTopup.WithLabelValues(gc.Chain, "failed").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "failed", fmt.Sprintf("USDC approval failed: %v", err)) + return + } + time.Sleep(5 * time.Second) + if ok, err := g.executor.txExecutor.CheckEVMTxStatus(gc.Chain, approvalHash); err != nil || !ok { + bridgeGasTopup.WithLabelValues(gc.Chain, "failed").Inc() + // The approval TX was broadcast and likely paid gas even though + // it never confirmed: book the conservative estimate. + g.executor.AddDailySpend(failedTxFeeEstimateUSD()) + _ = g.slack.NotifyGasTopUp(gc.Chain, "failed", "USDC approval not confirmed") + return + } + } + + var txHash string + if gc.Chain == "Solana" { + txHash, err = g.executor.txExecutor.ExecuteSolanaTransaction(quote.TransactionRequest.Data) + } else { + txHash, err = g.executor.txExecutor.ExecuteEVMTransaction(gc.Chain, quote.TransactionRequest.To, quote.TransactionRequest.Data, quote.TransactionRequest.Value) + } + if err != nil { + bridgeGasTopup.WithLabelValues(gc.Chain, "failed").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "failed", fmt.Sprintf("Broadcast failed: %v", err)) + return + } + + // Same-chain swaps settle in one TX: a confirmed receipt is a fill, no + // bridge status polling needed. Solana broadcast acceptance is our signal. + if gc.Chain != "Solana" { + time.Sleep(8 * time.Second) + if ok, err := g.executor.txExecutor.CheckEVMTxStatus(gc.Chain, txHash); err != nil || !ok { + bridgeGasTopup.WithLabelValues(gc.Chain, "failed").Inc() + // Broadcast happened: even a reverted or unconfirmed swap TX + // bled gas, so it counts toward the daily cap. + g.executor.AddDailySpend(failedTxFeeEstimateUSD()) + _ = g.slack.NotifyGasTopUp(gc.Chain, "failed", fmt.Sprintf("Swap TX not confirmed or reverted: %s", txHash)) + return + } + } + + g.spentToday[gc.Chain] += amountUSD + fee := 0.0 + for _, f := range quote.Estimate.FeeCosts { + fee += parseFloatOrZero(f.AmountUSD) + } + g.executor.AddDailySpend(fee) + bridgeGasTopup.WithLabelValues(gc.Chain, "succeeded").Inc() + _ = g.slack.NotifyGasTopUp(gc.Chain, "succeeded", fmt.Sprintf( + "Swapped $%.2f USDC to %s (was $%.2f, fee $%.4f, tx %s). Daily budget used: $%.2f / $%.2f.", + amountUSD, gc.NativeSym, gasUSD, fee, txHash, g.spentToday[gc.Chain], g.topupUSD)) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go b/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go index 82c928e1..ba7b9a58 100644 --- a/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go @@ -151,7 +151,7 @@ func (l *LiFiBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawUn // (the published methodology measures exactly that). Fast failures, e.g. // Cloudflare 403s answered in 30ms, must not enter the histogram: they // made deBridge look 15x faster the moment its API started rejecting us. - bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) inUsd, _ := strconv.ParseFloat(quote.Estimate.FromAmountUSD, 64) diff --git a/harnesses/bridge-monitor/cmd/monitor/main.go b/harnesses/bridge-monitor/cmd/monitor/main.go index 5f867994..a8fe5f4e 100644 --- a/harnesses/bridge-monitor/cmd/monitor/main.go +++ b/harnesses/bridge-monitor/cmd/monitor/main.go @@ -31,6 +31,13 @@ func main() { // Log configuration config.LogConfig() + // SIMULATE_BALANCES is a dry-run testing aid only. In any mode that can + // broadcast, phantom balances could green-light real transfers, so the + // flag is ignored everywhere outside dry-run (see SimulateBalances). + if config.SimulateBalances && config.ExecutionMode != string(ModeDryRun) { + log.Printf("⚠️ SIMULATE_BALANCES=true is set but EXECUTION_MODE is %q: simulated balances are IGNORED outside dry-run", config.ExecutionMode) + } + // Initialize bridges var mobulaBridge *MobulaBridge if config.MobulaAPIKey != "" { @@ -139,6 +146,9 @@ func main() { } } + // Pre-seed the self-healing series so alerting rules match from startup. + initSelfHealingMetrics() + // Start Prometheus metrics endpoint. Railway / most PaaS inject $PORT // and route external traffic to whatever value they chose. If we bind // to the wrong port the edge proxy returns 502 with x-railway-fallback. @@ -244,6 +254,7 @@ func main() { log.Println("⚠️ This will execute REAL transactions!") bridges := []string{"mobula", "relay", "lifi"} + execMu.Lock() for _, bridge := range bridges { log.Printf("\n━━━ Bridge: %s ━━━", bridge) for _, route := range triangleRoutes { @@ -252,6 +263,7 @@ func main() { time.Sleep(2 * time.Second) } } + execMu.Unlock() log.Println("\n✅ Single-test complete! Exiting.") return @@ -336,6 +348,20 @@ func main() { log.Println("🚀 Production mode: fixed-time scheduler started") } + // Auto-rebalancer shares the executor's quote and broadcast plumbing. Inert + // unless the executor can actually broadcast (production mode, keys present), + // so it costs nothing in dry-run or while paused. + var rebalancer *Rebalancer + var gasTopper *GasTopper + if executor != nil { + rebalancer = NewRebalancer(executor, slackNotifier) + gasTopper = NewGasTopper(executor, slackNotifier) + } + + // Stuck-fund reaper: gauges in every mode (alerting must survive a pause), + // corrective transfers only in production and unpaused. + StartReaper(balanceChecker, rebalancer, slackNotifier, config.ExecutionMode, paused) + // Track last meme execution day to run weekly lastMemeDay := -1 @@ -345,65 +371,145 @@ func main() { case <-getSchedulerChan(scheduler, "$5"): // $5 execution loop - daily at 10:00 UTC if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, GetTriangleRoutes(), 5.0, "daily") + runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, GetTriangleRoutes(), 5.0, "daily") // Meme routes use independent capital (TRUMP) — always attempt, // the per-route RunReal check catches insufficient TRUMP. now := time.Now().UTC() if now.Weekday() == time.Monday && now.YearDay() != lastMemeDay { log.Println("💸 Running $5 meme execution tests (weekly)...") + execMu.Lock() for _, route := range GetMemeRoutes() { executor.RunReal(route, 5.0) } + execMu.Unlock() lastMemeDay = now.YearDay() } } case <-getSchedulerChan(scheduler, "$50"): if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, GetTriangleRoutes(), 50.0, "Mon+Thu") + runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, GetTriangleRoutes(), 50.0, "Mon+Thu") } case <-getSchedulerChan(scheduler, "$300"): if executor != nil && config.ExecutionMode == "production" { - runTierIfViable(executor, balanceChecker, slackNotifier, GetTriangleRoutes(), 300.0, "Mon weekly") + runTierIfViable(executor, balanceChecker, slackNotifier, rebalancer, gasTopper, GetTriangleRoutes(), 300.0, "Mon weekly") } } } } +// downgradeLadder returns the tier amounts to try, largest first, starting at +// the scheduled tier. Partial data beats none: if $300 cannot run we still +// want the $50 or $5 datapoint from the same slot. +func downgradeLadder(tier float64) []float64 { + all := []float64{300, 50, 5} + var out []float64 + for _, t := range all { + if t <= tier { + out = append(out, t) + } + } + if len(out) == 0 { + out = []float64{tier} + } + return out +} + // runTierIfViable pre-flights the full R1→R2→R3 cycle at the given tier. If the -// simulation says the cycle cannot complete, emit ONE Slack "couldn't run" message -// and skip — next scheduler tick will retry. Returns true if the tier actually ran. +// simulation says the cycle cannot complete, it first lets the auto-rebalancer +// try to unblock the tier, then walks the downgrade ladder to a smaller amount. +// Only if nothing on the ladder is viable does it emit ONE Slack "couldn't run" +// message and skip, so the next scheduler tick retries. Returns true if any +// amount actually ran. func runTierIfViable(executor *Executor, bc *BalanceChecker, slack *SlackNotifier, - routes []TestRoute, tier float64, tierLabel string, + rebalancer *Rebalancer, gasTopper *GasTopper, routes []TestRoute, tier float64, tierLabel string, ) bool { if bc == nil { log.Printf("⚠️ No balance checker — skipping tier $%.0f pre-flight", tier) return false } - balances, err := bc.GetAllBalances() - if err != nil { - log.Printf("⚠️ Pre-flight balance fetch failed for $%.0f tier: %v", tier, err) - if slack != nil { - _ = slack.NotifyTierSkipped(tier, tierLabel, fmt.Sprintf("Balance API error: %v", err)) + // Single-flight: this slot owns the wallets end to end (gas top-up, + // corrective rebalances, triangle runs). The reaper TryLocks and skips + // its corrective action while this is held. + execMu.Lock() + defer execMu.Unlock() + + ladder := downgradeLadder(tier) + blockedReason := "" + + // ONE corrective-transfer budget for the whole scheduler slot, shared by + // every ladder rung. Without sharing, each rung consumed its own attempt + // counter and a single slot could broadcast up to six transfers. + budget := newSlotBudget() + + for i, amount := range ladder { + balances, degraded, err := bc.GetAllBalancesDetailed() + if err != nil || degraded { + // Unreadable is not the same as empty: refuse to act (and above all + // refuse to rebalance) on balances we cannot trust. + log.Printf("⚠️ Pre-flight balances unreadable for $%.0f tier (degraded=%v err=%v)", tier, degraded, err) + if slack != nil { + _ = slack.NotifyTierSkipped(tier, tierLabel, + fmt.Sprintf("Balances unreadable (degraded=%v, err=%v). Refusing to run or rebalance blind.", degraded, err)) + } + return false } - return false - } - sim := SimulateTriangleCycle(balances, tier) - if !sim.Viable { - log.Printf("⏭️ Tier $%.0f skipped: %s", tier, sim.Reason) - if slack != nil { - _ = slack.NotifyTierSkipped(tier, tierLabel, sim.Reason) + // Gas check once per slot: the same balances snapshot already carries + // SOL / ETH, and an execution without gas fails later anyway. + if i == 0 { + gasTopper.CheckAndTopUp(balances) } - return false + + sim := SimulateTriangleCycle(balances, amount) + if !sim.Viable && rebalancer != nil { + sim, _ = rebalancer.TryUnblockTier(sim, balances, tierLabel, budget) + } + if !sim.Viable { + if blockedReason == "" { + blockedReason = sim.Reason + } + log.Printf("⏭️ Tier $%.0f not viable at $%.0f: %s", tier, amount, sim.Reason) + continue + } + + if i > 0 { + log.Printf("⬇️ Tier $%.0f downgraded to $%.0f: %s", tier, amount, blockedReason) + bridgeTierDowngraded.WithLabelValues(tierAmountLabel(tier), tierAmountLabel(amount)).Set(1) + if slack != nil { + _ = slack.NotifyTierDowngrade(tier, amount, blockedReason) + } + } else { + // The scheduled tier runs at full size again: clear its downgrade + // gauges so the alert stops firing. + for _, smaller := range ladder[1:] { + bridgeTierDowngraded.WithLabelValues(tierAmountLabel(tier), tierAmountLabel(smaller)).Set(0) + } + } + + runTriangle(executor, routes, amount) + return true + } + + log.Printf("⏭️ Tier $%.0f skipped entirely: %s", tier, blockedReason) + if slack != nil { + _ = slack.NotifyTierSkipped(tier, tierLabel, blockedReason) } + return false +} + +func tierAmountLabel(tier float64) string { + return strconv.FormatFloat(tier, 'f', 0, 64) +} - // Sequential per-bridge orchestration: each bridge runs a full R1→R2→R3 triangle - // before the next bridge starts. Halves the peak capital need per leg, gives a - // clean round-trip cost per provider, and unlocks larger tiers with less capital. +// runTriangle runs the sequential per-bridge orchestration: each bridge runs a +// full R1→R2→R3 triangle before the next bridge starts. Halves the peak capital +// need per leg, gives a clean round-trip cost per provider, and unlocks larger +// tiers with less capital. +func runTriangle(executor *Executor, routes []TestRoute, tier float64) { log.Printf("💸 Running $%.0f triangle (sequential per-bridge)...", tier) bridges := []string{"mobula", "relay", "lifi"} for _, bridge := range bridges { @@ -423,7 +529,6 @@ func runTierIfViable(executor *Executor, bc *BalanceChecker, slack *SlackNotifie } } } - return true } // getSchedulerChan returns the appropriate scheduler channel or nil diff --git a/harnesses/bridge-monitor/cmd/monitor/metrics.go b/harnesses/bridge-monitor/cmd/monitor/metrics.go index 0ac368ae..5f2fa0f3 100644 --- a/harnesses/bridge-monitor/cmd/monitor/metrics.go +++ b/harnesses/bridge-monitor/cmd/monitor/metrics.go @@ -6,11 +6,17 @@ import ( ) var ( - // Quote latency (time to get quote response) + // Quote latency (time to get bridge quote in ms). Sub-50ms buckets + // (10, 25) added 2026-07-13 because Near Intents on solver-cached + // corridors (HyperCore in particular) returns in <50ms via the 1Click + // coordinator's cached-price fast path, and the previous [50, ...] + // scheme lumped every one of those observations into the (0, 50] + // bucket. histogram_quantile then linearly interpolated to ~25ms + // regardless of the true value, hiding the real bimodality. bridgeQuoteLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "bridge_quote_latency_ms", Help: "Latency to get bridge quote in milliseconds", - Buckets: []float64{50, 100, 200, 500, 1000, 2000, 5000, 10000}, + Buckets: []float64{10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000}, }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) // Execution latency (broadcast to funds received) @@ -128,4 +134,63 @@ var ( Name: "bridge_consecutive_failures", Help: "Number of consecutive execution failures for a bridge (resets on success)", }, []string{"bridge", "region"}) + + // 1 when at least one chain's balances could not be read by either the + // Mobula API or the on-chain fallback. Lets alerting distinguish "wallet is + // empty" from "we cannot see the wallet". + bridgeBalanceReadDegraded = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "bridge_balance_read_degraded", + Help: "1 if wallet balances are currently unreadable via both API and RPC, 0 otherwise", + }) + + // Auto-rebalance attempts by outcome: attempted, succeeded, failed, + // capped, in_flight (broadcast whose bridge status never resolved). + bridgeRebalanceAttempts = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "bridge_rebalance_attempts_total", + Help: "Total automatic rebalance attempts by outcome (attempted, succeeded, failed, capped, in_flight)", + }, []string{"outcome"}) + + // Set to 1 when a scheduled tier was downgraded to a smaller amount because + // the requested tier was not viable even after rebalancing. Reset to 0 when + // the original tier runs at full size again. + bridgeTierDowngraded = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_tier_downgraded", + Help: "1 when the last run of tier `from` was downgraded to tier `to`, 0 otherwise", + }, []string{"from", "to"}) + + // Hours funds have been sitting off their home triangle leg. 0 when the + // wallet only holds expected inventory. Exported continuously (also while + // paused) so Prometheus alerting can fire without any execution enabled. + bridgeStrandedHours = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_stranded_hours", + Help: "Hours a balance has been stranded off its home triangle leg (0 when home)", + }, []string{"chain", "token"}) + + // Gas top-up attempts per chain by outcome: attempted, succeeded, failed, + // capped, gated (needed but GAS_TOPUP_ENABLED is off). + bridgeGasTopup = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "bridge_gas_topup_total", + Help: "Total gas top-up attempts by chain and outcome", + }, []string{"chain", "outcome"}) ) + +// initSelfHealingMetrics pre-seeds the label combinations the alerting rules +// query, so the series exist on /metrics from process start instead of only +// after the first event. +func initSelfHealingMetrics() { + for _, outcome := range []string{"attempted", "succeeded", "failed", "capped", "in_flight"} { + bridgeRebalanceAttempts.WithLabelValues(outcome).Add(0) + } + bridgeTierDowngraded.WithLabelValues("300", "50").Set(0) + bridgeTierDowngraded.WithLabelValues("300", "5").Set(0) + bridgeTierDowngraded.WithLabelValues("50", "5").Set(0) + bridgeStrandedHours.WithLabelValues("Solana", "USDC").Set(0) + bridgeStrandedHours.WithLabelValues("Base", "USDC").Set(0) + bridgeStrandedHours.WithLabelValues("Arbitrum", "USDT0").Set(0) + for _, chain := range []string{"Solana", "Base", "Arbitrum"} { + for _, outcome := range []string{"attempted", "succeeded", "failed", "capped", "gated"} { + bridgeGasTopup.WithLabelValues(chain, outcome).Add(0) + } + } + bridgeBalanceReadDegraded.Set(0) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go index 73e2a41d..706c294a 100644 --- a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go @@ -1,6 +1,8 @@ package main import ( + "crypto/ecdsa" + "encoding/hex" "encoding/json" "fmt" "io" @@ -8,6 +10,10 @@ import ( "net/http" "strconv" "time" + + ethmath "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" ) type MobulaBridge struct { @@ -17,12 +23,22 @@ type MobulaBridge struct { type MobulaQuoteResponse struct { Data struct { + // Two-step flow fields: EVM origins return SignatureRequired=true + // and require a re-quote with the EIP-712 signature over TypedData + // before the deposit becomes executable (unsigned deposit stays + // unregistered server-side and the solver never picks it up). + IntentId string `json:"intentId"` + Deadline int64 `json:"deadline"` + SignatureRequired bool `json:"signatureRequired"` + TypedData json.RawMessage `json:"typedData"` + EstimatedAmountOut string `json:"estimatedAmountOut"` EstimatedAmountOutUsd string `json:"estimatedAmountOutUsd"` EstimatedTimeMs int64 `json:"estimatedTimeMs"` MaxTradeUsd int64 `json:"maxTradeUsd"` Fees struct { BridgeFeeBps int `json:"bridgeFeeBps"` + BridgeFeeUsd string `json:"bridgeFeeUsd"` GasFeeUsd string `json:"gasFeeUsd"` TotalFeeUsd string `json:"totalFeeUsd"` } `json:"fees"` @@ -87,11 +103,120 @@ func (m *MobulaBridge) APIKey() string { return m.apiKey } +// mobulaTypedData mirrors the typedData block of the quote response for +// EIP-712 signing. +type mobulaTypedData struct { + Domain struct { + Name string `json:"name"` + Version string `json:"version"` + ChainId int64 `json:"chainId"` + } `json:"domain"` + Types apitypes.Types `json:"types"` + PrimaryType string `json:"primaryType"` + Message apitypes.TypedDataMessage `json:"message"` +} + +// GetQuote fetches an unsigned quote. For EVM origins the returned deposit is +// a placeholder that the solver will not fill: use GetSignedQuote for real +// executions. func (m *MobulaBridge) GetQuote(originChain, originToken, destChain, destToken, senderAddress, walletAddress string, amount float64) (*MobulaQuoteResponse, time.Duration, error) { + return m.quote(originChain, originToken, destChain, destToken, senderAddress, walletAddress, amount, "") +} + +// GetSignedQuote runs the full two-step flow: unsigned quote → EIP-712 sign +// the returned typedData → re-quote echoing signature/intentId/deadline/ +// minAmountOut. Only the second response's deposit is executable. Solana +// origins skip the second call. evmKey==nil falls back to the unsigned quote +// (used by the read-only quote loop where no key is configured). +func (m *MobulaBridge) GetSignedQuote(originChain, originToken, destChain, destToken, senderAddress, walletAddress string, amount float64, evmKey *ecdsa.PrivateKey) (*MobulaQuoteResponse, time.Duration, error) { + quote, latency, err := m.GetQuote(originChain, originToken, destChain, destToken, senderAddress, walletAddress, amount) + if err != nil { + return nil, latency, err + } + if !quote.Data.SignatureRequired { + return quote, latency, nil + } + if evmKey == nil { + log.Printf(" [mobula] ⚠️ origin %s requires an EIP-712 signature but no EVM key is configured — returning unsigned quote (NOT executable)", originChain) + return quote, latency, nil + } + if len(quote.Data.TypedData) == 0 || quote.Data.IntentId == "" || quote.Data.Deadline == 0 { + return nil, latency, fmt.Errorf("signatureRequired but quote is missing typedData/intentId/deadline") + } + signature, minAmountOut, err := signBridgeIntent(quote.Data.TypedData, evmKey) + if err != nil { + return nil, latency, fmt.Errorf("failed to sign bridge intent: %w", err) + } + if minAmountOut == "" { + return nil, latency, fmt.Errorf("typedData message has no minAmountOut") + } + log.Printf(" [mobula] 🔏 Signed intent %s (deadline %d), fetching signed quote...", quote.Data.IntentId, quote.Data.Deadline) + extra := fmt.Sprintf("&signature=%s&intentId=%s&deadline=%d&minAmountOut=%s", signature, quote.Data.IntentId, quote.Data.Deadline, minAmountOut) + signed, signedLatency, err := m.quote(originChain, originToken, destChain, destToken, senderAddress, walletAddress, amount, extra) + if err != nil { + return nil, latency + signedLatency, fmt.Errorf("signed quote failed: %w", err) + } + return signed, latency + signedLatency, nil +} + +// signBridgeIntent hashes the EIP-712 typedData v4 and signs with evmKey. +// Returns the 0x-prefixed 65-byte signature and the minAmountOut extracted +// from the message (echoed back to the confirm endpoint). +func signBridgeIntent(typedDataJSON json.RawMessage, key *ecdsa.PrivateKey) (string, string, error) { + var raw mobulaTypedData + if err := json.Unmarshal(typedDataJSON, &raw); err != nil { + return "", "", fmt.Errorf("parse typedData: %w", err) + } + // Mobula's typedData omits the EIP712Domain type definition (only + // BridgeIntent is listed under types). go-ethereum requires it to hash + // the domain, so we synthesize one from the domain fields we actually + // received (name/version/chainId). + if _, ok := raw.Types["EIP712Domain"]; !ok { + raw.Types["EIP712Domain"] = []apitypes.Type{ + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + } + } + td := apitypes.TypedData{ + Types: raw.Types, + PrimaryType: raw.PrimaryType, + Domain: apitypes.TypedDataDomain{ + Name: raw.Domain.Name, + Version: raw.Domain.Version, + ChainId: ethmath.NewHexOrDecimal256(raw.Domain.ChainId), + }, + Message: raw.Message, + } + minAmountOut := "" + if v, ok := raw.Message["minAmountOut"]; ok { + minAmountOut = fmt.Sprintf("%v", v) + } + domainSep, err := td.HashStruct("EIP712Domain", td.Domain.Map()) + if err != nil { + return "", "", fmt.Errorf("hash domain: %w", err) + } + msgHash, err := td.HashStruct(td.PrimaryType, td.Message) + if err != nil { + return "", "", fmt.Errorf("hash message: %w", err) + } + digest := crypto.Keccak256([]byte("\x19\x01"), domainSep, msgHash) + sig, err := crypto.Sign(digest, key) + if err != nil { + return "", "", fmt.Errorf("sign digest: %w", err) + } + // canonical v is 27 or 28 (crypto.Sign returns 0/1) + if sig[64] < 27 { + sig[64] += 27 + } + return "0x" + hex.EncodeToString(sig), minAmountOut, nil +} + +func (m *MobulaBridge) quote(originChain, originToken, destChain, destToken, senderAddress, walletAddress string, amount float64, extraParams string) (*MobulaQuoteResponse, time.Duration, error) { start := time.Now() url := fmt.Sprintf( - "https://api.mobula.io/api/2/bridge/quote?originChainId=%s&originToken=%s&destinationChainId=%s&destinationToken=%s&amount=%s&walletAddress=%s&apiKey=%s", + "https://demo-api.mobula.io/api/2/bridge/quote?originChainId=%s&originToken=%s&destinationChainId=%s&destinationToken=%s&amount=%s&walletAddress=%s&apiKey=%s", originChain, originToken, destChain, destToken, strconv.FormatFloat(amount, 'f', -1, 64), walletAddress, m.apiKey, @@ -99,6 +224,7 @@ func (m *MobulaBridge) GetQuote(originChain, originToken, destChain, destToken, if senderAddress != "" { url += "&senderAddress=" + senderAddress } + url += extraParams req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -135,7 +261,7 @@ func (m *MobulaBridge) GetQuote(originChain, originToken, destChain, destToken, } func (m *MobulaBridge) GetStatus(txHash string) (*MobulaStatusResponse, error) { - url := fmt.Sprintf("https://api.mobula.io/api/2/bridge/status/%s", txHash) + url := fmt.Sprintf("https://demo-api.mobula.io/api/2/bridge/status/%s", txHash) req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -164,7 +290,7 @@ func (m *MobulaBridge) GetStatus(txHash string) (*MobulaStatusResponse, error) { } func (m *MobulaBridge) VerifyRoutes() (*MobulaRoutesResponse, error) { - url := "https://api.mobula.io/api/2/bridge/routes" + url := "https://demo-api.mobula.io/api/2/bridge/routes" req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -363,7 +489,7 @@ func (m *MobulaBridge) TestRoute(route TestRoute, amount, amountUsd float64, reg } // Success-only: see debridge_bridge.go, failures must not enter the histogram. - bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) diff --git a/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go b/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go index 645f2f0c..2958a8e6 100644 --- a/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go @@ -219,7 +219,7 @@ func (n *NearIntentsBridge) TestRoute(route TestRoute, amount, amountUsd float64 // Latency is only meaningful for quotes that returned a usable response // (same rule as the other bridges). Fast 4xx rejections must not enter // the histogram or they skew the leader board. - bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) inUsd, _ := strconv.ParseFloat(quote.Quote.AmountInUsd, 64) diff --git a/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go b/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go index ac450ef1..c6aac96d 100644 --- a/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go +++ b/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" ) @@ -49,6 +50,27 @@ func (tx *TxExecutor) evmClientFor(chain string) interface{ CallContract(context return nil } +// evmNativeBalance returns the native (ETH) balance of owner in wei. Needed by +// the balance fallback path: the Mobula portfolio API is the primary source, but +// when it is down we still need gas balances to gate executions safely. +func (tx *TxExecutor) evmNativeBalance(chain string, owner common.Address) (*big.Int, error) { + var client *ethclient.Client + switch strings.ToLower(chain) { + case "base": + client = tx.baseClient + case "arbitrum": + client = tx.arbitrumClient + default: + return nil, fmt.Errorf("unknown EVM chain: %s", chain) + } + if client == nil { + return nil, fmt.Errorf("no RPC client for %s", chain) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return client.BalanceAt(ctx, owner, nil) +} + // solanaSPLBalanceOf returns the SPL token balance for `owner` and `mint`. If // the associated token account does not yet exist (never received this token), // returns 0 — that's a valid pre-execution state. diff --git a/harnesses/bridge-monitor/cmd/monitor/pricer.go b/harnesses/bridge-monitor/cmd/monitor/pricer.go index 024aac08..4a677987 100644 --- a/harnesses/bridge-monitor/cmd/monitor/pricer.go +++ b/harnesses/bridge-monitor/cmd/monitor/pricer.go @@ -52,7 +52,7 @@ func TokenPriceUSD(symbol string, fallback float64) float64 { func fetchPriceUSD(symbol string) float64 { apiKey := os.Getenv("MOBULA_API_KEY") - url := fmt.Sprintf("https://api.mobula.io/api/1/market/data?symbol=%s", symbol) + url := fmt.Sprintf("https://demo-api.mobula.io/api/1/market/data?symbol=%s", symbol) req, err := http.NewRequest("GET", url, nil) if err != nil { return 0 diff --git a/harnesses/bridge-monitor/cmd/monitor/reaper.go b/harnesses/bridge-monitor/cmd/monitor/reaper.go new file mode 100644 index 00000000..a4387451 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/reaper.go @@ -0,0 +1,273 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + "time" +) + +// Stuck-fund reaper: the triangle expects inventory on exactly three legs +// (Sol USDC, Base USDC, Arb USDT) plus native gas. Refunds and partial fills +// sometimes leave capital elsewhere (typically USDC on Arbitrum after an R3 +// refund), where no scheduled route will ever pick it up again. The reaper +// tracks how long such balances sit stranded, exports that as a gauge for +// alerting, and, when execution is enabled, sends ONE corrective transfer per +// hourly tick back to the neediest home leg. + +// Ignore balances below this: bridge dust and rounding leftovers are not worth +// a corrective transfer's fees. +const strandedDustUSD = 1.0 + +// Reaper transfers share the rebalancer's spirit of "never move more than a +// tier plus buffer": one capped hop per tick instead of one big blind sweep. +const reaperMaxTransferUSD = 300 * rebalanceBufferFactor + +// Tokens that are SUPPOSED to sit on each chain. Everything else above the +// dust floor counts as stranded. TRUMP and BRETT are meme-route inventory, +// native SOL and ETH are gas. +var homeTokens = map[string]map[string]bool{ + "Solana": {"USDC": true, "SOL": true, "TRUMP": true}, + "Base": {"USDC": true, "ETH": true, "BRETT": true}, + "Arbitrum": {"USDT0": true, "USDT": true, "ETH": true}, +} + +// Stranded tokens we know how to route home. Anything else is alert-only: +// building a bridge TX for an unknown asset from inside a repair loop is how +// funds get burned, so we only ever move assets we have addresses for. +var movableStranded = map[string]map[string]string{ + "Solana": {"USDT": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"}, + "Base": {"USDT": "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2"}, + "Arbitrum": {"USDC": arbUSDCAddr}, +} + +type strandKey struct { + Chain string + Token string +} + +// StrandedTracker remembers when each off-home balance was first seen so the +// stranded duration survives across ticks (but not restarts: a restart resets +// the clock, which only delays the reaper, never makes it over-eager). +type StrandedTracker struct { + firstSeen map[strandKey]time.Time +} + +func NewStrandedTracker() *StrandedTracker { + return &StrandedTracker{firstSeen: make(map[strandKey]time.Time)} +} + +// Update computes stranded hours for every off-home balance in the snapshot +// and returns them, including explicit zeros for keys that just came home so +// the exported gauge resets. Pure given (balances, now), unit-tested. +func (t *StrandedTracker) Update(balances map[string]map[string]float64, now time.Time) map[strandKey]float64 { + hours := make(map[strandKey]float64) + current := make(map[strandKey]bool) + + for chain, tokens := range balances { + home := homeTokens[chain] + for token, usd := range tokens { + // Contract-address keys duplicate the symbol entries. + if strings.HasPrefix(token, "0x") || len(token) > 12 { + continue + } + if usd <= strandedDustUSD || (home != nil && home[token]) { + continue + } + k := strandKey{chain, token} + current[k] = true + first, seen := t.firstSeen[k] + if !seen { + first = now + t.firstSeen[k] = first + } + hours[k] = now.Sub(first).Hours() + } + } + + // Keys that just came home report an explicit 0 (so the exported gauge + // resets) and lose their firstSeen entry (so a later re-strand restarts + // the clock instead of inheriting the old one). + for k := range t.firstSeen { + if !current[k] { + hours[k] = 0 + delete(t.firstSeen, k) + } + } + return hours +} + +// StartReaper launches the hourly stuck-fund loop. The gauge side runs in +// every mode, including while BENCHMARK_PAUSED, because alerting must keep +// working during a pause. The corrective transfer only fires in production +// mode, unpaused, with broadcast-capable keys. +func StartReaper(bc *BalanceChecker, rebalancer *Rebalancer, slack *SlackNotifier, mode string, paused bool) { + // Balance source: real checker in normal operation, the SIMULATE_BALANCES + // snapshot in keyless dry-run so the gauge path stays testable locally. + // SimulateBalances itself refuses to return anything outside dry-run, so + // a production process with keys can never act on made-up numbers. + execMode := ExecutionMode(mode) + var fetch func() (map[string]map[string]float64, bool, error) + switch { + case bc != nil: + fetch = bc.GetAllBalancesDetailed + case SimulateBalances(execMode) != nil: + fetch = func() (map[string]map[string]float64, bool, error) { + return SimulateBalances(execMode), false, nil + } + default: + log.Println("🧹 Reaper disabled: no balance checker") + return + } + + strandedHours := parseFloat(os.Getenv("REAPER_STRANDED_HOURS"), 6) + tracker := NewStrandedTracker() + + canAct := mode == "production" && !paused && rebalancer.canAct() + log.Printf("🧹 Stuck-fund reaper started (threshold %.0fh, acting=%v)", strandedHours, canAct) + + go func() { + // First evaluation immediately so gauges exist right after boot, then hourly. + reaperTick(fetch, rebalancer, slack, tracker, strandedHours, canAct) + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for range ticker.C { + reaperTick(fetch, rebalancer, slack, tracker, strandedHours, canAct) + } + }() +} + +func reaperTick(fetch func() (map[string]map[string]float64, bool, error), rebalancer *Rebalancer, slack *SlackNotifier, + tracker *StrandedTracker, thresholdHours float64, canAct bool, +) { + balances, degraded, err := fetch() + if err != nil || degraded { + // Without trustworthy balances we cannot tell stranded from home, so + // neither the gauge nor a transfer would mean anything this tick. + log.Printf("🧹 Reaper tick skipped: balances unreadable (degraded=%v err=%v)", degraded, err) + return + } + + hours := tracker.Update(balances, time.Now().UTC()) + + // Home legs always report 0 so alert expressions have a baseline series. + for _, leg := range triangleLegs { + sym := leg.Token + if leg.Chain == "Arbitrum" { + sym = "USDT0" + } + bridgeStrandedHours.WithLabelValues(leg.Chain, sym).Set(0) + } + for k, h := range hours { + bridgeStrandedHours.WithLabelValues(k.Chain, k.Token).Set(h) + } + + // Pick the single worst offender above threshold; one transfer per tick + // keeps the blast radius of a bad tick to one capped TX. + var worst strandKey + worstHours := 0.0 + for k, h := range hours { + if h >= thresholdHours && h > worstHours { + worst, worstHours = k, h + } + } + if worstHours == 0 { + log.Printf("🧹 Reaper tick: no funds stranded beyond %.0fh", thresholdHours) + return + } + + amount := balances[worst.Chain][worst.Token] + log.Printf("🧹 Stranded funds: $%.2f %s on %s for %.1fh (threshold %.0fh)", + amount, worst.Token, worst.Chain, worstHours, thresholdHours) + + if !canAct { + // Read-only mode: the gauge above is the alert path, a human handles it. + return + } + + srcAddr, known := movableStranded[worst.Chain][worst.Token] + if !known { + _ = slack.NotifyReaper(fmt.Sprintf( + "$%.2f of %s stranded on %s for %.1fh, but I have no safe route for that asset. Manual rebalance needed (cmd/rebalance).", + amount, worst.Token, worst.Chain, worstHours)) + return + } + + if spent := rebalancer.executor.DailySpent(); spent >= rebalancer.executor.config.MaxDailySpendUSD { + _ = slack.NotifyReaper(fmt.Sprintf( + "$%.2f of %s stranded on %s for %.1fh, but daily spend limit is reached ($%.2f / $%.2f). Will retry next tick.", + amount, worst.Token, worst.Chain, worstHours, + spent, rebalancer.executor.config.MaxDailySpendUSD)) + return + } + + // Single-flight: never broadcast while a scheduler slot (triangle run, + // rebalance, gas top-up) holds the execution lock. Skip instead of + // queueing: waiting here could fire a stale corrective transfer right + // after the slot already rebalanced the same leg. + if !execMu.TryLock() { + log.Printf("🧹 Reaper: execution lock busy (another actor is broadcasting), skipping corrective transfer this tick") + return + } + defer execMu.Unlock() + + dest := neediestHomeLeg(balances) + if amount > reaperMaxTransferUSD { + amount = reaperMaxTransferUSD + } + + route := TestRoute{ + Name: fmt.Sprintf("REAPER_%s_%s", strings.ToUpper(worst.Chain), strings.ToUpper(dest.Chain)), + FromChain: worst.Chain, + FromChainAPI: chainAPIFor(worst.Chain), + FromToken: srcAddr, + ToChain: dest.Chain, + ToChainAPI: dest.ChainAPI, + ToToken: dest.TokenAddr, + IsSolanaSrc: worst.Chain == "Solana", + } + + _ = slack.NotifyReaper(fmt.Sprintf( + "Moving $%.2f of stranded %s from %s (stuck %.1fh) home to %s %s via cheapest bridge.", + amount, worst.Token, worst.Chain, worstHours, dest.Chain, dest.Token)) + + // ExecuteCheapest books the spend (realized fee on success, flat estimate + // for a broadcast that never confirmed), so no accounting here. + result := rebalancer.ExecuteCheapest(route, amount) + switch classifyCorrectiveResult(result) { + case outcomeSuccess: + _ = slack.NotifyReaper(fmt.Sprintf( + "Corrective transfer succeeded via %s (fee $%.4f, tx %s).", result.Bridge, result.ActualFeeUSD, result.TxHash)) + case outcomeInFlight: + _ = slack.NotifyReaper(fmt.Sprintf( + "Corrective transfer broadcast (tx %s) but its bridge status did not resolve. Funds may still be in flight; standing down, next tick re-evaluates fresh balances.", + result.TxHash)) + default: + detail := "no bridge produced a usable quote" + if result != nil && result.Error != nil { + detail = result.Error.Error() + } + _ = slack.NotifyReaper(fmt.Sprintf("Corrective transfer FAILED before broadcast: %s. Will retry next tick.", detail)) + } +} + +// neediestHomeLeg returns the triangle leg with the lowest balance: stranded +// funds should land where they unblock the next scheduled cycle soonest. +func neediestHomeLeg(balances map[string]map[string]float64) legSpec { + best := triangleLegs[0] + bestBal := legBalanceUSD(balances, best) + for _, leg := range triangleLegs[1:] { + if b := legBalanceUSD(balances, leg); b < bestBal { + best, bestBal = leg, b + } + } + return best +} + +func chainAPIFor(chain string) string { + if leg, ok := findLeg(chain); ok { + return leg.ChainAPI + } + return "" +} diff --git a/harnesses/bridge-monitor/cmd/monitor/rebalancer.go b/harnesses/bridge-monitor/cmd/monitor/rebalancer.go new file mode 100644 index 00000000..9ead71dc --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/rebalancer.go @@ -0,0 +1,401 @@ +package main + +import ( + "fmt" + "log" + "strings" + "time" +) + +// Auto-rebalancer: when the pre-flight cycle simulation says a tier cannot run +// because one leg is short, move the shortfall from the leg holding excess +// inventory instead of waiting for a human. Reuses the executor's quote and +// broadcast plumbing so a rebalance transfer is measured and notified exactly +// like a benchmark execution. cmd/rebalance stays as the manual fallback. + +const ( + // Two corrective transfers per SCHEDULER SLOT, shared across every rung + // of the downgrade ladder. One transfer plus one retry covers transient + // pre-broadcast failures without letting a broken bridge drain the daily + // budget. Before this was per rung, a single $300 slot could broadcast + // up to six transfers back to back. + maxCorrectiveTransfersPerSlot = 2 + + // Move 10 percent more than the computed shortfall so bridge fees and + // slippage on the transfer itself do not leave the leg short again. + rebalanceBufferFactor = 1.10 +) + +// slotBudget caps corrective transfers for one scheduler slot. A single +// instance is created per slot and threaded through every ladder rung. +type slotBudget struct { + remaining int + // inFlight marks that a corrective transfer broadcast a TX whose bridge + // status never resolved. The funds are most likely still moving (legit + // fills can take 2-5 minutes, longer than our status polling), so any + // rung that needs the same destination leg must stand down for the rest + // of the slot instead of double-sending. + inFlight bool + inFlightChain string +} + +func newSlotBudget() *slotBudget { + return &slotBudget{remaining: maxCorrectiveTransfersPerSlot} +} + +// blockedByInFlight reports whether a rung needing refillChain must stand +// down because an earlier transfer to that leg is still unresolved. +func (b *slotBudget) blockedByInFlight(refillChain string) bool { + return b != nil && b.inFlight && strings.EqualFold(b.inFlightChain, refillChain) +} + +// correctiveOutcome classifies a corrective transfer attempt for retry logic. +type correctiveOutcome int + +const ( + // outcomePreBroadcast: nothing left the wallet (quote failed, approval + // failed to broadcast, insufficient funds). Safe to consume another + // attempt from the slot budget. + outcomePreBroadcast correctiveOutcome = iota + // outcomeInFlight: a TX was broadcast but the attempt did not confirm as + // a success. The deposit may well have confirmed with the bridge fill + // still pending, so the funds must be assumed to be in flight. TERMINAL + // for the slot: retrying here is exactly how a double-send happens. + outcomeInFlight + // outcomeSuccess: the transfer confirmed end to end. + outcomeSuccess +) + +// classifyCorrectiveResult decides whether a corrective transfer attempt may +// be retried. Anything that produced a TxHash moved, or may have moved, real +// funds and is terminal. Only failures that provably never broadcast are +// safe to retry. +func classifyCorrectiveResult(result *ExecutionResult) correctiveOutcome { + if result == nil { + return outcomePreBroadcast + } + if result.Success { + return outcomeSuccess + } + if result.TxHash != "" { + return outcomeInFlight + } + return outcomePreBroadcast +} + +// legSpec describes one home leg of the USDC triangle. +type legSpec struct { + Chain string + ChainAPI string + Token string + TokenAddr string +} + +// The triangle's expected inventory distribution. Order matches R1, R2, R3. +var triangleLegs = []legSpec{ + {Chain: "Solana", ChainAPI: "solana:solana", Token: "USDC", TokenAddr: solanaUSDCMint}, + {Chain: "Base", ChainAPI: "evm:8453", Token: "USDC", TokenAddr: baseUSDCAddr}, + {Chain: "Arbitrum", ChainAPI: "evm:42161", Token: "USDT", TokenAddr: arbUSDTAddr}, +} + +func findLeg(chain string) (legSpec, bool) { + for _, leg := range triangleLegs { + if strings.EqualFold(leg.Chain, chain) { + return leg, true + } + } + return legSpec{}, false +} + +// legBalanceUSD looks up a leg's balance by contract address first (robust +// against symbol drift like USDT vs USDT0 in Mobula's DB), then by symbol. +func legBalanceUSD(balances map[string]map[string]float64, leg legSpec) float64 { + chain := balances[leg.Chain] + if chain == nil { + return 0 + } + if v, ok := chain[strings.ToLower(leg.TokenAddr)]; ok { + return v + } + if v, ok := chain[leg.Token]; ok { + return v + } + if leg.Token == "USDT" { + if v, ok := chain["USDT0"]; ok { + return v + } + } + return 0 +} + +// BuildRefillRoute turns a failed CycleSimulation into a corrective TestRoute. +// The source leg is the triangle leg holding the largest surplus above its own +// 1x tier requirement: taking from anywhere else would just move the blockage. +// The amount is the shortfall plus the fee buffer, never more, so a rebalance +// cannot silently drain a healthy leg. Pure function, unit-tested. +func BuildRefillRoute(sim CycleSimulation, balances map[string]map[string]float64) (TestRoute, float64, error) { + if sim.Viable { + return TestRoute{}, 0, fmt.Errorf("cycle already viable, nothing to rebalance") + } + if sim.RefillUSD <= 0 { + return TestRoute{}, 0, fmt.Errorf("simulation has no refill amount") + } + dest, ok := findLeg(sim.RefillChain) + if !ok { + return TestRoute{}, 0, fmt.Errorf("unknown refill chain %q", sim.RefillChain) + } + + var source legSpec + bestSurplus := 0.0 + for _, leg := range triangleLegs { + if leg.Chain == dest.Chain { + continue + } + surplus := legBalanceUSD(balances, leg) - sim.Tier + if surplus > bestSurplus { + source = leg + bestSurplus = surplus + } + } + if bestSurplus <= 0 { + return TestRoute{}, 0, fmt.Errorf("no leg holds excess inventory above its own $%.0f tier need", sim.Tier) + } + if bestSurplus < sim.RefillUSD { + return TestRoute{}, 0, fmt.Errorf("no leg holds enough excess: need $%.2f, best surplus is $%.2f on %s", + sim.RefillUSD, bestSurplus, source.Chain) + } + + amount := sim.RefillUSD * rebalanceBufferFactor + if amount > bestSurplus { + amount = bestSurplus + } + + route := TestRoute{ + Name: fmt.Sprintf("REBALANCE_%s_%s", strings.ToUpper(source.Chain), strings.ToUpper(dest.Chain)), + FromChain: source.Chain, + FromChainAPI: source.ChainAPI, + FromToken: source.TokenAddr, + ToChain: dest.Chain, + ToChainAPI: dest.ChainAPI, + ToToken: dest.TokenAddr, + IsSolanaSrc: source.Chain == "Solana", + } + return route, amount, nil +} + +// Rebalancer executes corrective transfers via the cheapest live bridge quote. +type Rebalancer struct { + executor *Executor + slack *SlackNotifier +} + +func NewRebalancer(executor *Executor, slack *SlackNotifier) *Rebalancer { + if executor == nil { + return nil + } + return &Rebalancer{executor: executor, slack: slack} +} + +// canAct verifies keys exist and we are in a mode allowed to broadcast. Keeps +// the rebalancer inert in dry-run and while the benchmark is paused. +func (r *Rebalancer) canAct() bool { + return r != nil && r.executor != nil && r.executor.txExecutor != nil && r.executor.txExecutor.CanExecute() +} + +// TryUnblockTier attempts corrective transfers to make the given failed +// simulation viable, consuming from the slot-wide budget shared across all +// ladder rungs. Returns the latest simulation and whether the tier can now +// run. Callers must only pass non-degraded balances: rebalancing on +// unreadable balances could move real funds based on phantom zeros. +func (r *Rebalancer) TryUnblockTier(sim CycleSimulation, balances map[string]map[string]float64, tierLabel string, budget *slotBudget) (CycleSimulation, bool) { + if !r.canAct() { + return sim, false + } + if budget == nil { + budget = newSlotBudget() + } + + for { + if budget.blockedByInFlight(sim.RefillChain) { + log.Printf("🔧 Tier $%.0f needs %s but a corrective transfer to that leg is already in flight, standing down for this slot", + sim.Tier, sim.RefillChain) + return sim, false + } + if budget.remaining <= 0 { + bridgeRebalanceAttempts.WithLabelValues("capped").Inc() + _ = r.slack.NotifyRebalance("capped", fmt.Sprintf( + "Tier $%.0f (%s) still blocked (%s) but this slot's corrective transfer budget (%d) is spent. Standing down until next slot.", + sim.Tier, tierLabel, sim.Reason, maxCorrectiveTransfersPerSlot)) + return sim, false + } + if spent := r.executor.DailySpent(); spent >= r.executor.config.MaxDailySpendUSD { + bridgeRebalanceAttempts.WithLabelValues("capped").Inc() + _ = r.slack.NotifyRebalance("capped", fmt.Sprintf( + "Tier $%.0f (%s) blocked (%s) but daily spend limit is reached ($%.2f / $%.2f). No rebalance attempted.", + sim.Tier, tierLabel, sim.Reason, spent, r.executor.config.MaxDailySpendUSD)) + return sim, false + } + + route, amountUSD, err := BuildRefillRoute(sim, balances) + if err != nil { + bridgeRebalanceAttempts.WithLabelValues("failed").Inc() + log.Printf("🔧 Rebalance not possible for tier $%.0f: %v", sim.Tier, err) + _ = r.slack.NotifyRebalance("failed", fmt.Sprintf( + "Tier $%.0f (%s) blocked (%s) and no corrective route could be built: %v", + sim.Tier, tierLabel, sim.Reason, err)) + return sim, false + } + + budget.remaining-- + attempt := maxCorrectiveTransfersPerSlot - budget.remaining + bridgeRebalanceAttempts.WithLabelValues("attempted").Inc() + log.Printf("🔧 Rebalance attempt %d/%d (slot budget): $%.2f %s -> %s (unblocks tier $%.0f)", + attempt, maxCorrectiveTransfersPerSlot, amountUSD, route.FromChain, route.ToChain, sim.Tier) + _ = r.slack.NotifyRebalance("attempted", fmt.Sprintf( + "Tier $%.0f (%s) blocked: %s\nMoving $%.2f from %s %s to %s %s (slot attempt %d/%d).", + sim.Tier, tierLabel, sim.Reason, amountUSD, route.FromChain, sourceSymbol(route), route.ToChain, sim.RefillToken, + attempt, maxCorrectiveTransfersPerSlot)) + + result := r.ExecuteCheapest(route, amountUSD) + switch classifyCorrectiveResult(result) { + case outcomeInFlight: + // The deposit TX exists on-chain but the bridge status never + // resolved. Funds are most likely still moving toward the short + // leg: any further transfer to that leg this slot would be a + // double send. Terminal for the whole scheduler slot. + budget.inFlight = true + budget.inFlightChain = sim.RefillChain + bridgeRebalanceAttempts.WithLabelValues("in_flight").Inc() + _ = r.slack.NotifyRebalance("in_flight", fmt.Sprintf( + "Corrective transfer for tier $%.0f broadcast (tx %s) but its bridge status did not resolve. Funds are likely still in flight: standing down until next slot to avoid a double send.", + sim.Tier, result.TxHash)) + return sim, false + + case outcomePreBroadcast: + bridgeRebalanceAttempts.WithLabelValues("failed").Inc() + detail := "no bridge produced a usable quote" + if result != nil && result.Error != nil { + detail = result.Error.Error() + } + _ = r.slack.NotifyRebalance("failed", fmt.Sprintf( + "Rebalance transfer for tier $%.0f failed before broadcast: %s", sim.Tier, detail)) + // Nothing left the wallet, so another attempt is safe if the + // slot budget still allows one. + continue + + case outcomeSuccess: + bridgeRebalanceAttempts.WithLabelValues("succeeded").Inc() + _ = r.slack.NotifyRebalance("succeeded", fmt.Sprintf( + "Moved $%.2f from %s to %s via %s (fee $%.4f, tx %s). Re-checking tier $%.0f viability.", + amountUSD, route.FromChain, route.ToChain, result.Bridge, result.ActualFeeUSD, result.TxHash, sim.Tier)) + } + + // Give the destination credit a moment to be indexed by the portfolio + // API before re-simulating, otherwise we would re-read the old state. + time.Sleep(15 * time.Second) + + fresh, degraded, err := r.executor.balanceCheck.GetAllBalancesDetailed() + if err != nil || degraded { + log.Printf("🔧 Post-rebalance balances unreadable (degraded=%v err=%v), stopping", degraded, err) + return sim, false + } + balances = fresh + sim = SimulateTriangleCycle(balances, sim.Tier) + if sim.Viable { + return sim, true + } + } +} + +// ExecuteCheapest quotes all three executing bridges and broadcasts through +// the cheapest one that returned a live quote. Shared by the tier rebalancer +// and the stuck-fund reaper so both follow the same cost discipline. +func (r *Rebalancer) ExecuteCheapest(route TestRoute, amountUSD float64) *ExecutionResult { + bridge, fee, err := r.quoteCheapest(route, amountUSD) + if err != nil { + log.Printf("🔧 Cheapest-quote selection failed for %s: %v", route.Name, err) + return nil + } + log.Printf("🔧 Cheapest bridge for %s: %s (quoted fee $%.4f)", route.Name, bridge, fee) + + rawUnits := toRawUnits(amountUSD) + result := r.executor.executeOnBridge(bridge, route, amountUSD, amountUSD, rawUnits) + // Book the cost here so both callers (tier rebalancer and reaper) share + // the same accounting, including the flat estimate for broadcasts that + // never confirmed. + r.executor.accountSpend(result) + return result +} + +// quoteCheapest fetches quotes from Mobula, Relay and LI.FI and returns the +// bridge with the lowest total quoted fee among those that answered. +func (r *Rebalancer) quoteCheapest(route TestRoute, amountUSD float64) (string, float64, error) { + e := r.executor + sender := e.walletManager.EVMAddress + receiver := e.walletManager.EVMAddress + if route.FromChain == "Solana" { + sender = e.walletManager.SolanaAddress + } + if route.ToChain == "Solana" { + receiver = e.walletManager.SolanaAddress + } + rawUnits := toRawUnits(amountUSD) + + bestBridge := "" + bestFee := 0.0 + + consider := func(bridge string, fee float64, err error) { + if err != nil { + log.Printf("🔧 [%s] rebalance quote failed: %v", bridge, err) + return + } + if bestBridge == "" || fee < bestFee { + bestBridge, bestFee = bridge, fee + } + } + + if e.mobula != nil { + quote, _, err := e.mobula.GetQuote(route.FromChainAPI, route.FromToken, route.ToChainAPI, route.ToToken, sender, receiver, amountUSD) + if err != nil { + consider("mobula", 0, err) + } else { + consider("mobula", parseFloatOrZero(quote.Data.Fees.TotalFeeUsd)+parseFloatOrZero(quote.Data.Fees.GasFeeUsd), nil) + } + } + + if quote, _, err := e.relay.GetQuote(route, rawUnits, sender, receiver); err != nil { + consider("relay", 0, err) + } else { + consider("relay", parseFloatOrZero(quote.Fees.RelayerService.AmountUsd)+parseFloatOrZero(quote.Fees.RelayerGas.AmountUsd), nil) + } + + if quote, _, err := e.lifi.GetQuote(route, rawUnits, sender, receiver); err != nil { + consider("lifi", 0, err) + } else { + fee := 0.0 + for _, f := range quote.Estimate.FeeCosts { + fee += parseFloatOrZero(f.AmountUSD) + } + consider("lifi", fee, nil) + } + + if bestBridge == "" { + return "", 0, fmt.Errorf("all bridges failed to quote %s", route.Name) + } + return bestBridge, bestFee, nil +} + +func parseFloatOrZero(s string) float64 { + return parseFloat(s, 0) +} + +// sourceSymbol maps the route's source token address back to a human symbol +// for Slack messages. +func sourceSymbol(route TestRoute) string { + for _, leg := range triangleLegs { + if strings.EqualFold(leg.TokenAddr, route.FromToken) { + return leg.Token + } + } + return route.FromToken +} diff --git a/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go b/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go index 708aa052..7350d132 100644 --- a/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go +++ b/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go @@ -188,7 +188,7 @@ func (r *RelayBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawU // (the published methodology measures exactly that). Fast failures, e.g. // Cloudflare 403s answered in 30ms, must not enter the histogram: they // made deBridge look 15x faster the moment its API started rejecting us. - bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Nanoseconds()) / 1e6) bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) inUsd, _ := strconv.ParseFloat(quote.Details.CurrencyIn.AmountUsd, 64) diff --git a/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go b/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go new file mode 100644 index 00000000..7eb6d013 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/selfheal_test.go @@ -0,0 +1,269 @@ +package main + +import ( + "fmt" + "math" + "testing" + "time" +) + +func balancesSnapshot(sol, base, arb float64) map[string]map[string]float64 { + return map[string]map[string]float64{ + "Solana": {"USDC": sol, "SOL": 30}, + "Base": {"USDC": base, "ETH": 40}, + "Arbitrum": {"USDT0": arb, "ETH": 25}, + } +} + +func TestBuildRefillRouteSourceSelection(t *testing.T) { + // Solana leg is short for a $50 tier; Base holds the biggest surplus so it + // must be picked as the source, not Arbitrum. + balances := balancesSnapshot(10, 400, 120) + sim := SimulateTriangleCycle(balances, 50) + if sim.Viable { + t.Fatal("expected $50 cycle to be blocked on Solana") + } + if sim.RefillChain != "Solana" { + t.Fatalf("expected refill chain Solana, got %s", sim.RefillChain) + } + + route, amount, err := BuildRefillRoute(sim, balances) + if err != nil { + t.Fatalf("BuildRefillRoute failed: %v", err) + } + if route.FromChain != "Base" { + t.Errorf("expected source Base (largest surplus), got %s", route.FromChain) + } + if route.ToChain != "Solana" { + t.Errorf("expected destination Solana, got %s", route.ToChain) + } + if route.FromToken != baseUSDCAddr { + t.Errorf("expected Base USDC source token, got %s", route.FromToken) + } + if route.ToToken != solanaUSDCMint { + t.Errorf("expected Solana USDC destination token, got %s", route.ToToken) + } + if !route.IsSolanaSrc == (route.FromChain == "Solana") { + t.Error("IsSolanaSrc inconsistent with FromChain") + } + + // Shortfall is 50 - 10 = 40, buffered by 10 percent. + want := sim.RefillUSD * rebalanceBufferFactor + if math.Abs(amount-want) > 0.01 { + t.Errorf("expected amount %.2f (need + 10%% buffer), got %.2f", want, amount) + } + if amount > 400-50 { + t.Errorf("amount %.2f exceeds Base surplus", amount) + } +} + +func TestBuildRefillRouteCapsAtSurplus(t *testing.T) { + // Surplus barely covers the shortfall: the buffered amount must be clamped + // to the surplus instead of overdrawing the source leg. + balances := balancesSnapshot(0, 55.5, 5) + sim := SimulateTriangleCycle(balances, 5) + if sim.Viable { + t.Fatal("expected $5 cycle to be blocked on Solana") + } + route, amount, err := BuildRefillRoute(sim, balances) + if err != nil { + t.Fatalf("BuildRefillRoute failed: %v", err) + } + if route.FromChain != "Base" { + t.Fatalf("expected source Base, got %s", route.FromChain) + } + surplus := 55.5 - 5 + if amount > surplus+0.001 { + t.Errorf("amount %.2f exceeds surplus %.2f", amount, surplus) + } +} + +func TestBuildRefillRouteNoExcess(t *testing.T) { + // Every leg is broke: refusing is the only safe answer. + balances := balancesSnapshot(1, 2, 3) + sim := SimulateTriangleCycle(balances, 50) + if _, _, err := BuildRefillRoute(sim, balances); err == nil { + t.Fatal("expected error when no leg holds excess inventory") + } +} + +func TestBuildRefillRouteViableRejected(t *testing.T) { + balances := balancesSnapshot(500, 500, 500) + sim := SimulateTriangleCycle(balances, 50) + if !sim.Viable { + t.Fatal("expected viable cycle") + } + if _, _, err := BuildRefillRoute(sim, balances); err == nil { + t.Fatal("expected error for viable simulation") + } +} + +func TestDowngradeLadder(t *testing.T) { + cases := []struct { + tier float64 + want []float64 + }{ + {300, []float64{300, 50, 5}}, + {50, []float64{50, 5}}, + {5, []float64{5}}, + } + for _, c := range cases { + got := downgradeLadder(c.tier) + if len(got) != len(c.want) { + t.Fatalf("tier %.0f: got %v want %v", c.tier, got, c.want) + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("tier %.0f: got %v want %v", c.tier, got, c.want) + break + } + } + } +} + +func TestClassifyCorrectiveResult(t *testing.T) { + if got := classifyCorrectiveResult(nil); got != outcomePreBroadcast { + t.Errorf("nil result: got %v, want pre-broadcast", got) + } + if got := classifyCorrectiveResult(&ExecutionResult{Success: true, TxHash: "0xabc"}); got != outcomeSuccess { + t.Errorf("confirmed fill: got %v, want success", got) + } + // A broadcast whose status never resolved is TERMINAL: the deposit may + // have confirmed with the fill still pending, so a retry double-sends. + if got := classifyCorrectiveResult(&ExecutionResult{Success: false, TxHash: "0xabc", Error: fmt.Errorf("status poll failed: timeout")}); got != outcomeInFlight { + t.Errorf("status timeout with TxHash: got %v, want in-flight", got) + } + // A revert or refund also carries a hash and stays terminal for the slot. + if got := classifyCorrectiveResult(&ExecutionResult{Reverted: true, TxHash: "0xabc"}); got != outcomeInFlight { + t.Errorf("reverted with TxHash: got %v, want in-flight", got) + } + // Pre-broadcast failures (quote failed, approval failed to broadcast, + // insufficient funds) have no hash and may consume another attempt. + if got := classifyCorrectiveResult(&ExecutionResult{Error: fmt.Errorf("quote failed")}); got != outcomePreBroadcast { + t.Errorf("quote failure: got %v, want pre-broadcast", got) + } +} + +func TestSlotBudgetSharedAcrossRungs(t *testing.T) { + if maxCorrectiveTransfersPerSlot != 2 { + t.Fatalf("slot budget must be 2 corrective transfers, got %d", maxCorrectiveTransfersPerSlot) + } + b := newSlotBudget() + if b.remaining != 2 { + t.Fatalf("fresh budget: got %d remaining, want 2", b.remaining) + } + + // Rung 1 ($300) consumes one attempt, rung 2 ($50) consumes the second: + // the SAME budget instance is threaded through the ladder, so rung 3 + // ($5) has nothing left. Per-rung counters allowed up to 6 transfers. + b.remaining-- + b.remaining-- + if b.remaining > 0 { + t.Fatalf("after two attempts across rungs the slot budget must be exhausted, got %d", b.remaining) + } +} + +func TestSlotBudgetInFlightBlocksSameLeg(t *testing.T) { + b := newSlotBudget() + if b.blockedByInFlight("Solana") { + t.Fatal("fresh budget must not block any leg") + } + + // A transfer to Solana broadcast but never resolved: every later rung + // needing Solana stands down, regardless of remaining budget. + b.inFlight = true + b.inFlightChain = "Solana" + if !b.blockedByInFlight("Solana") { + t.Error("rung needing the in-flight leg must stand down") + } + if !b.blockedByInFlight("solana") { + t.Error("leg matching must be case-insensitive") + } + if b.blockedByInFlight("Base") { + t.Error("a rung needing a different leg is not blocked by the in-flight transfer") + } + + var nilBudget *slotBudget + if nilBudget.blockedByInFlight("Solana") { + t.Error("nil budget must not block") + } +} + +func TestSimulateBalancesOnlyInDryRun(t *testing.T) { + t.Setenv("SIMULATE_BALANCES", "true") + if SimulateBalances(ModeDryRun) == nil { + t.Fatal("dry-run with SIMULATE_BALANCES=true must return the snapshot") + } + if SimulateBalances(ModeProduction) != nil { + t.Fatal("production mode must ignore SIMULATE_BALANCES") + } + if SimulateBalances(ModeSingleTest) != nil { + t.Fatal("single-test mode must ignore SIMULATE_BALANCES") + } + + t.Setenv("SIMULATE_BALANCES", "false") + if SimulateBalances(ModeDryRun) != nil { + t.Fatal("no snapshot when SIMULATE_BALANCES is off") + } +} + +func TestStrandedHoursComputation(t *testing.T) { + tracker := NewStrandedTracker() + t0 := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC) + + // USDC on Arbitrum is off-home (triangle expects USDT there). + balances := balancesSnapshot(100, 100, 100) + balances["Arbitrum"]["USDC"] = 42 + + hours := tracker.Update(balances, t0) + k := strandKey{"Arbitrum", "USDC"} + if hours[k] != 0 { + t.Errorf("first sighting should report 0 hours, got %.2f", hours[k]) + } + // Home legs never appear as stranded. + if _, ok := hours[strandKey{"Solana", "USDC"}]; ok { + t.Error("home leg Solana/USDC reported as stranded") + } + if _, ok := hours[strandKey{"Arbitrum", "USDT0"}]; ok { + t.Error("home leg Arbitrum/USDT0 reported as stranded") + } + + // 7 hours later the same balance is still there: above the 6h default. + hours = tracker.Update(balances, t0.Add(7*time.Hour)) + if math.Abs(hours[k]-7) > 0.001 { + t.Errorf("expected 7 stranded hours, got %.2f", hours[k]) + } + + // Funds moved home: the key must report an explicit 0 so the gauge resets, + // and the clock must restart if it strands again later. + delete(balances["Arbitrum"], "USDC") + hours = tracker.Update(balances, t0.Add(8*time.Hour)) + if hours[k] != 0 { + t.Errorf("expected explicit 0 after funds came home, got %.2f", hours[k]) + } + + balances["Arbitrum"]["USDC"] = 42 + hours = tracker.Update(balances, t0.Add(20*time.Hour)) + if hours[k] != 0 { + t.Errorf("re-stranding must restart the clock at 0, got %.2f", hours[k]) + } +} + +func TestStrandedIgnoresDustAndAddressKeys(t *testing.T) { + tracker := NewStrandedTracker() + now := time.Now().UTC() + + balances := balancesSnapshot(100, 100, 100) + balances["Arbitrum"]["USDC"] = 0.5 + balances["Arbitrum"][arbUSDCAddr] = 5000 + + hours := tracker.Update(balances, now) + if _, ok := hours[strandKey{"Arbitrum", "USDC"}]; ok { + t.Error("dust below $1 must not count as stranded") + } + for k := range hours { + if len(k.Token) > 12 { + t.Errorf("contract-address key leaked into stranded set: %v", k) + } + } +} diff --git a/harnesses/bridge-monitor/cmd/monitor/slack.go b/harnesses/bridge-monitor/cmd/monitor/slack.go index 7b9422f1..579f514b 100644 --- a/harnesses/bridge-monitor/cmd/monitor/slack.go +++ b/harnesses/bridge-monitor/cmd/monitor/slack.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" ) @@ -175,7 +176,7 @@ func (s *SlackNotifier) getBalanceSummary() string { // fetchBalances calls Mobula API to get wallet balances func (s *SlackNotifier) fetchBalances(wallet, blockchain string) string { - url := fmt.Sprintf("https://api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", wallet, blockchain) + url := fmt.Sprintf("https://demo-api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", wallet, blockchain) req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -278,6 +279,67 @@ Waiting for next scheduled slot. Top up the blocked leg to unblock. return s.send(message) } +// NotifyRebalance reports every auto-rebalance event (attempted, succeeded, +// failed, capped) so the owner can audit what the self-healing loop moved. +func (s *SlackNotifier) NotifyRebalance(outcome, detail string) error { + if s == nil { + return nil + } + emoji := map[string]string{ + "attempted": "🔧", + "succeeded": "✅", + "failed": "❌", + "capped": "🛑", + }[outcome] + if emoji == "" { + emoji = "🔧" + } + message := fmt.Sprintf(`%s *Auto-Rebalance %s* + +%s`, emoji, strings.ToUpper(outcome), detail) + return s.send(message) +} + +// NotifyTierDowngrade reports that a scheduled tier ran at a smaller amount +// because the requested size was not viable. Partial data beats none, but the +// owner should know the wallet needs attention. +func (s *SlackNotifier) NotifyTierDowngrade(from, to float64, reason string) error { + if s == nil { + return nil + } + message := fmt.Sprintf(`⬇️ *Tier DOWNGRADED: $%.0f to $%.0f* + +*Reason $%.0f was blocked:* %s + +Running the smaller tier so the benchmark keeps producing data.`, + from, to, from, reason) + return s.send(message) +} + +// NotifyReaper reports stuck-fund reaper actions (corrective transfer of funds +// stranded off their home triangle leg). +func (s *SlackNotifier) NotifyReaper(detail string) error { + if s == nil { + return nil + } + message := fmt.Sprintf(`🧹 *Stuck-Fund Reaper* + +%s`, detail) + return s.send(message) +} + +// NotifyGasTopUp reports gas top-up events (low native gas detected, swap +// attempted, succeeded, failed, capped or gated). +func (s *SlackNotifier) NotifyGasTopUp(chain, outcome, detail string) error { + if s == nil { + return nil + } + message := fmt.Sprintf(`⛽ *Gas Top-Up %s on %s* + +%s`, strings.ToUpper(outcome), chain, detail) + return s.send(message) +} + // NotifyStartup sends a startup notification func (s *SlackNotifier) NotifyStartup(mode string) error { if s == nil { diff --git a/harnesses/bridge-monitor/cmd/monitor/spend_tracker.go b/harnesses/bridge-monitor/cmd/monitor/spend_tracker.go new file mode 100644 index 00000000..93865dd9 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/spend_tracker.go @@ -0,0 +1,129 @@ +package main + +import ( + "encoding/json" + "log" + "os" + "strings" + "sync" + "time" +) + +// execMu is the package-level single-flight lock for every code path that can +// broadcast a transaction: scheduled triangle runs, the auto-rebalancer, the +// stuck-fund reaper and gas top-ups. All of these actors sign with the same +// two wallets, so two concurrent broadcasts race on PendingNonceAt and can +// reuse a nonce. Scheduler slots take the lock for the whole slot; the reaper +// only TryLocks and skips its corrective action when the lock is busy, it +// never queues behind a running slot. +var execMu sync.Mutex + +// SpendTracker accounts spending against the daily cap. The counter is keyed +// by UTC date so the budget resets exactly at midnight UTC, and the +// (date, spent) pair is persisted to disk so a process restart within the +// same UTC day keeps the consumed budget instead of minting a fresh one +// (a crash-looping process used to reset the counter on every start). +type SpendTracker struct { + mu sync.Mutex + path string + now func() time.Time + date string + spent float64 +} + +type spendState struct { + Date string `json:"date"` + SpentUSD float64 `json:"spent_usd"` +} + +// NewSpendTracker loads persisted state from path. A nil clock uses time.Now; +// tests inject a fake clock to drive the date rollover. State recorded on a +// different UTC date is discarded on load. +func NewSpendTracker(path string, now func() time.Time) *SpendTracker { + if now == nil { + now = time.Now + } + t := &SpendTracker{path: path, now: now} + t.date = t.utcDate() + if data, err := os.ReadFile(path); err == nil { + var s spendState + if json.Unmarshal(data, &s) == nil && s.Date == t.date && s.SpentUSD > 0 { + t.spent = s.SpentUSD + log.Printf("💾 Loaded daily spend state: $%.2f already spent on %s (%s)", t.spent, t.date, path) + } + } + return t +} + +func (t *SpendTracker) utcDate() string { + return t.now().UTC().Format("2006-01-02") +} + +// rolloverLocked resets the counter when the UTC date has changed since the +// last access. Callers must hold t.mu. +func (t *SpendTracker) rolloverLocked() { + if d := t.utcDate(); d != t.date { + log.Printf("💾 Daily spend reset: %s -> %s (was $%.2f)", t.date, d, t.spent) + t.date = d + t.spent = 0 + t.saveLocked() + } +} + +// Add books usd against today's budget and persists the new total. +func (t *SpendTracker) Add(usd float64) { + if usd <= 0 { + return + } + t.mu.Lock() + defer t.mu.Unlock() + t.rolloverLocked() + t.spent += usd + t.saveLocked() +} + +// Spent returns today's consumed budget, applying the UTC rollover first. +func (t *SpendTracker) Spent() float64 { + t.mu.Lock() + defer t.mu.Unlock() + t.rolloverLocked() + return t.spent +} + +// saveLocked persists atomically (write temp, rename). Callers hold t.mu. +func (t *SpendTracker) saveLocked() { + data, err := json.Marshal(spendState{Date: t.date, SpentUSD: t.spent}) + if err != nil { + return + } + tmp := t.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + log.Printf("⚠️ Failed to persist spend state to %s: %v", tmp, err) + return + } + if err := os.Rename(tmp, t.path); err != nil { + log.Printf("⚠️ Failed to persist spend state to %s: %v", t.path, err) + } +} + +// spendStatePath resolves where the (date, spent) pair lives. Defaults to a +// path relative to the working directory (NOT /tmp, which some distros wipe +// on a timer) so restarts on the same host see the same file. +func spendStatePath() string { + if p := strings.TrimSpace(os.Getenv("SPEND_STATE_PATH")); p != "" { + return p + } + // Dry-run gets its own default file so simulated spend never eats the + // production budget when both run on the same host (review pass 2). + if strings.EqualFold(strings.TrimSpace(os.Getenv("EXECUTION_MODE")), "production") { + return "./spend-state.json" + } + return "./spend-state.dryrun.json" +} + +// failedTxFeeEstimateUSD is booked against the daily cap for any broadcast +// that produced a TxHash but never confirmed as a success: the deposit or +// approval TX most likely paid gas even though the bridge never filled. +func failedTxFeeEstimateUSD() float64 { + return parseFloat(os.Getenv("FAILED_TX_FEE_ESTIMATE_USD"), 0.50) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/spend_tracker_test.go b/harnesses/bridge-monitor/cmd/monitor/spend_tracker_test.go new file mode 100644 index 00000000..f7637d8e --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/spend_tracker_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "math" + "os" + "path/filepath" + "testing" + "time" +) + +func TestSpendTrackerUTCRollover(t *testing.T) { + path := filepath.Join(t.TempDir(), "spend-state.json") + now := time.Date(2026, 7, 12, 23, 0, 0, 0, time.UTC) + clock := func() time.Time { return now } + + tr := NewSpendTracker(path, clock) + tr.Add(3.5) + if got := tr.Spent(); math.Abs(got-3.5) > 1e-9 { + t.Fatalf("expected 3.5 spent, got %v", got) + } + + // A restart within the same UTC day must keep the consumed budget: a + // crash-loop must not mint a fresh budget. + tr2 := NewSpendTracker(path, clock) + if got := tr2.Spent(); math.Abs(got-3.5) > 1e-9 { + t.Fatalf("restart lost same-day spend: got %v, want 3.5", got) + } + + // Crossing midnight UTC resets the counter without a restart. + now = time.Date(2026, 7, 13, 1, 0, 0, 0, time.UTC) + if got := tr2.Spent(); got != 0 { + t.Fatalf("expected 0 after UTC date rollover, got %v", got) + } + tr2.Add(1.25) + + // A restart on the new day loads the new day's spend. + tr3 := NewSpendTracker(path, clock) + if got := tr3.Spent(); math.Abs(got-1.25) > 1e-9 { + t.Fatalf("restart on new day: got %v, want 1.25", got) + } +} + +func TestSpendTrackerDiscardsStaleState(t *testing.T) { + path := filepath.Join(t.TempDir(), "spend-state.json") + if err := os.WriteFile(path, []byte(`{"date":"2026-07-11","spent_usd":9.99}`), 0o644); err != nil { + t.Fatal(err) + } + clock := func() time.Time { return time.Date(2026, 7, 12, 8, 0, 0, 0, time.UTC) } + tr := NewSpendTracker(path, clock) + if got := tr.Spent(); got != 0 { + t.Fatalf("yesterday's spend must not carry over, got %v", got) + } +} + +func TestSpendTrackerIgnoresCorruptState(t *testing.T) { + path := filepath.Join(t.TempDir(), "spend-state.json") + if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + tr := NewSpendTracker(path, nil) + if got := tr.Spent(); got != 0 { + t.Fatalf("corrupt state must read as 0, got %v", got) + } + tr.Add(2) + if got := tr.Spent(); math.Abs(got-2) > 1e-9 { + t.Fatalf("expected 2 after Add, got %v", got) + } +} + +func TestFailedTxFeeEstimateDefault(t *testing.T) { + t.Setenv("FAILED_TX_FEE_ESTIMATE_USD", "") + if got := failedTxFeeEstimateUSD(); math.Abs(got-0.50) > 1e-9 { + t.Fatalf("default estimate must be $0.50, got %v", got) + } + t.Setenv("FAILED_TX_FEE_ESTIMATE_USD", "1.25") + if got := failedTxFeeEstimateUSD(); math.Abs(got-1.25) > 1e-9 { + t.Fatalf("env override not honored, got %v", got) + } +} diff --git a/harnesses/bridge-monitor/cmd/monitor/tx_executor.go b/harnesses/bridge-monitor/cmd/monitor/tx_executor.go index 0e51c1bf..a9d08380 100644 --- a/harnesses/bridge-monitor/cmd/monitor/tx_executor.go +++ b/harnesses/bridge-monitor/cmd/monitor/tx_executor.go @@ -120,6 +120,13 @@ func (tx *TxExecutor) CanExecute() bool { return tx.solanaPrivateKey != nil && tx.evmPrivateKey != nil && !tx.dryRun } +// EVMPrivateKey exposes the loaded EVM key so the Mobula client can EIP-712 +// sign the bridge intent on the confirm step. Returns nil in quote-only mode +// (no key configured), which GetSignedQuote handles by degrading to unsigned. +func (tx *TxExecutor) EVMPrivateKey() *ecdsa.PrivateKey { + return tx.evmPrivateKey +} + // ExecuteSolanaTransaction signs and broadcasts a Solana transaction func (tx *TxExecutor) ExecuteSolanaTransaction(serializedTxBase64 string) (string, error) { if tx.dryRun { @@ -158,9 +165,14 @@ func (tx *TxExecutor) ExecuteSolanaTransaction(serializedTxBase64 string) (strin return "", fmt.Errorf("failed to sign tx: %w", err) } - // Send transaction + // Send transaction. Same send-ambiguity rule as the EVM path: a send + // error can follow node acceptance, so surface the signature when the + // client returns one and let callers treat it as in-flight. sig, err := tx.solanaClient.SendTransaction(ctx, transaction) if err != nil { + if sig != (solana.Signature{}) { + return sig.String(), fmt.Errorf("failed to send tx (may be accepted, sig %s): %w", sig.String(), err) + } return "", fmt.Errorf("failed to send tx: %w", err) } @@ -235,9 +247,14 @@ func (tx *TxExecutor) ExecuteSolanaFromInstructions(instructions []RelaySolanaIn return "", fmt.Errorf("failed to sign tx: %w", err) } - // Send transaction + // Send transaction. Same send-ambiguity rule as the EVM path: a send + // error can follow node acceptance, so surface the signature when the + // client returns one and let callers treat it as in-flight. sig, err := tx.solanaClient.SendTransaction(ctx, transaction) if err != nil { + if sig != (solana.Signature{}) { + return sig.String(), fmt.Errorf("failed to send tx (may be accepted, sig %s): %w", sig.String(), err) + } return "", fmt.Errorf("failed to send tx: %w", err) } @@ -314,13 +331,16 @@ func (tx *TxExecutor) ExecuteEVMTransaction(chain string, to string, data string return "", fmt.Errorf("failed to sign tx: %w", err) } - // Send transaction + // Send transaction. On error the node may STILL have accepted the tx + // (RPC timeout after acceptance): return the locally computed hash so + // callers classify this as in-flight, never as retry-safe. Discarding + // it caused the residual double-send hole found in review pass 2. + txHash := signedTx.Hash().Hex() err = client.SendTransaction(ctx, signedTx) if err != nil { - return "", fmt.Errorf("failed to send tx: %w", err) + return txHash, fmt.Errorf("failed to send tx (may be accepted, hash %s): %w", txHash, err) } - txHash := signedTx.Hash().Hex() log.Printf("📤 %s TX sent: %s", chain, txHash) return txHash, nil } @@ -407,7 +427,7 @@ func (tx *TxExecutor) PollMobulaStatus(txHash string, timeout time.Duration) (*B } func (tx *TxExecutor) getMobulaStatus(txHash string) (*BridgeStatus, error) { - url := fmt.Sprintf("https://api.mobula.io/api/2/bridge/status/%s", txHash) + url := fmt.Sprintf("https://demo-api.mobula.io/api/2/bridge/status/%s", txHash) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err diff --git a/harnesses/evm-block-builders/Dockerfile b/harnesses/evm-block-builders/Dockerfile new file mode 100644 index 00000000..7a6bc559 --- /dev/null +++ b/harnesses/evm-block-builders/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/evm-block-builders ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/evm-block-builders /app/evm-block-builders + +EXPOSE 2112 + +CMD ["/app/evm-block-builders"] diff --git a/harnesses/evm-block-builders/cmd/script/arb_feed.go b/harnesses/evm-block-builders/cmd/script/arb_feed.go new file mode 100644 index 00000000..48784d96 --- /dev/null +++ b/harnesses/evm-block-builders/cmd/script/arb_feed.go @@ -0,0 +1,285 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +// Arbitrum sequencer feed soft-confirmation lag. +// +// The sequencer feed (wss://arb1.arbitrum.io/feed) streams every L2 +// message the sequencer accepts, seconds before the block is queryable +// on a public RPC. That gap IS the soft-confirmation advantage: trading +// systems reading the feed act on state that RPC users haven't seen +// yet. We hold the feed open, timestamp each message's arrival keyed by +// sequence number, poll the public RPC head, and emit the feed→RPC +// visibility lag as a histogram. +// +// Feed sequenceNumber → L2 block number is a FIXED offset (genesis +// alignment; 22207817 at inception) but we derive it at runtime instead +// of hardcoding. Per calibration poll we sample cand = rpcHead - +// latestFeedSeq. While the feed is live, cand is trueOffset minus the +// 0-1 blocks in flight between feed and RPC (lag ~0.1s vs 0.25s block +// time), so trueOffset is the LARGEST frequently-seen candidate. A +// plain max is NOT safe: on connect the feed replays a short backlog, +// latestSeq briefly trails the chain, and cand overshoots (observed +// +3 → every lag inflated by ~750ms). We therefore ignore the first +// polls after the feed goes live and pick the highest candidate with +// meaningful support. The derived value is logged and exported +// (ebb_arb_derived_offset) so drift would be visible. + +const ( + arbDialTimeout = 15 * time.Second + arbReadDeadline = 60 * time.Second + arbWatchdog = 30 * time.Second + arbMinBackoff = 2 * time.Second + arbMaxBackoff = 60 * time.Second + arbCalibSamples = 60 // ~18s of head polls before freezing the offset + arbCalibWarmup = 10 // polls to discard while the feed drains its connect backlog + arbMaxLagSeconds = 30 // sanity bound: drop reconnect-gap outliers + arrivalsMax = 4000 +) + +type arbFeedState struct { + mu sync.Mutex + arrivals map[int64]time.Time // seq → first feed arrival + order []int64 // insertion order, for bounded pruning + latestSeq int64 +} + +func newArbFeedState() *arbFeedState { + return &arbFeedState{arrivals: make(map[int64]time.Time, arrivalsMax)} +} + +func (s *arbFeedState) record(seq int64, at time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + if _, dup := s.arrivals[seq]; dup { + return // only first arrival counts + } + s.arrivals[seq] = at + s.order = append(s.order, seq) + if seq > s.latestSeq { + s.latestSeq = seq + } + for len(s.order) > arrivalsMax { + delete(s.arrivals, s.order[0]) + s.order = s.order[1:] + } +} + +func (s *arbFeedState) lookup(seq int64) (time.Time, bool) { + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.arrivals[seq] + return t, ok +} + +func (s *arbFeedState) latest() int64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.latestSeq +} + +// arbFeedMsg is the minimal shape of a broadcast frame: +// +// {"version":1,"messages":[{"sequenceNumber":296300000,"message":{...}}]} +type arbFeedMsg struct { + Messages []struct { + SequenceNumber int64 `json:"sequenceNumber"` + } `json:"messages"` +} + +// runArbFeed holds the sequencer feed WS open (reconnect loop with +// backoff + silence watchdog, same pattern as l2-block-time). +func runArbFeed(ctx context.Context, state *arbFeedState) { + url := arbFeedURL() + fmt.Printf("[arb-feed] connecting %s\n", url) + backoff := arbMinBackoff + for ctx.Err() == nil { + connStart := time.Now() + err := readArbFeed(ctx, url, state) + if time.Since(connStart) > 5*time.Minute { + backoff = arbMinBackoff // stable session: don't carry stale backoff + } + arbFeedHealth.Set(0) + streamReconnects.WithLabelValues("arb-feed").Inc() + if err != nil { + fmt.Printf("[arb-feed] error: %v (reconnecting in %v)\n", err, backoff) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < arbMaxBackoff { + backoff *= 2 + if backoff > arbMaxBackoff { + backoff = arbMaxBackoff + } + } + } +} + +func readArbFeed(ctx context.Context, url string, state *arbFeedState) error { + dialer := *websocket.DefaultDialer + dialer.HandshakeTimeout = arbDialTimeout + hdr := http.Header{"User-Agent": []string{harnessUserAgent}} + conn, _, err := dialer.Dial(url, hdr) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close() + arbFeedHealth.Set(1) + fmt.Println("[arb-feed] connected") + + // Silence watchdog: Arbitrum produces ~4 blocks/s, so 30s without a + // frame is unambiguously a wedged subscription. Atomic keeps the + // watchdog lock-free (same pattern as l2-block-time's head watchdog). + done := make(chan struct{}) + defer close(done) + var lastMsgUnixNano atomic.Int64 + lastMsgUnixNano.Store(time.Now().UnixNano()) + go func() { + t := time.NewTicker(10 * time.Second) + defer t.Stop() + for { + select { + case <-done: + return + case <-ctx.Done(): + _ = conn.Close() + return + case now := <-t.C: + silent := now.Sub(time.Unix(0, lastMsgUnixNano.Load())) + if silent > arbWatchdog { + fmt.Printf("[arb-feed] watchdog: silent %s, forcing reconnect\n", silent.Round(time.Second)) + _ = conn.Close() + return + } + } + } + }() + + for { + _ = conn.SetReadDeadline(time.Now().Add(arbReadDeadline)) + _, raw, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read: %w", err) + } + now := time.Now() + lastMsgUnixNano.Store(now.UnixNano()) + + var msg arbFeedMsg + if err := json.Unmarshal(raw, &msg); err != nil { + continue // keepalive / non-broadcast frame + } + for _, m := range msg.Messages { + if m.SequenceNumber > 0 { + state.record(m.SequenceNumber, now) + arbFeedMessages.Inc() + } + } + } +} + +// runArbHeadPoller polls the Arbitrum RPC head, derives the +// seq→blockNumber offset during calibration, then emits the feed→RPC +// soft-confirmation lag for every head advance. +func runArbHeadPoller(ctx context.Context, state *arbFeedState) { + url := arbRPCURL() + fmt.Printf("[arb-rpc] head poll: %s every %s\n", url, l2HeadPollInterval) + + var ( + offset int64 + warmup int + candidates = map[int64]int{} + calibCount int + calibrated bool + prevHead int64 + ) + t := time.NewTicker(l2HeadPollInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + + head, err := blockNumber(url) + if err != nil { + arbRPCErrors.Inc() + continue + } + pollAt := time.Now() + latestSeq := state.latest() + if latestSeq == 0 { + continue // feed not live yet + } + + if !calibrated { + // Discard the first polls after the feed goes live: the connect + // backlog replay makes latestSeq briefly trail the chain, which + // would overshoot the offset (and inflate every lag sample). + if warmup < arbCalibWarmup { + warmup++ + continue + } + candidates[head-latestSeq]++ + calibCount++ + if calibCount >= arbCalibSamples { + // Highest candidate with real support (>= 20% of samples). + // Live-feed candidates cluster on trueOffset and + // trueOffset-1 (0-1 blocks in flight); transient overshoots + // have negligible counts and are rejected here. + minSupport := calibCount / 5 + for cand, n := range candidates { + if n >= minSupport && cand > offset { + offset = cand + } + } + if offset <= 0 { + // No candidate reached support (feed stalled mid-window, + // so cand drifted and the counts spread out). Locking + // offset=0 would silence the lag histogram forever; + // discard the window and re-calibrate instead. + fmt.Printf("[arb-rpc] calibration failed (no candidate with >=%d support over %d samples), retrying\n", minSupport, calibCount) + candidates = map[int64]int{} + calibCount = 0 + warmup = 0 + continue + } + calibrated = true + prevHead = head + arbDerivedOffset.Set(float64(offset)) + fmt.Printf("[arb-rpc] calibrated seq→block offset=%d over %d samples (expected ≈22207817)\n", offset, calibCount) + } + continue + } + + if head <= prevHead { + continue + } + for b := prevHead + 1; b <= head; b++ { + arrival, ok := state.lookup(b - offset) + if !ok { + continue + } + lagMs := float64(pollAt.Sub(arrival).Milliseconds()) + if lagMs >= 0 && lagMs < arbMaxLagSeconds*1000 { + arbSoftConfLag.Observe(lagMs) + arbLagSamples.Inc() + fmt.Printf("[arb] block=%d softconf lag=%.0fms\n", b, lagMs) + } + } + prevHead = head + } +} diff --git a/harnesses/evm-block-builders/cmd/script/base_flashblocks.go b/harnesses/evm-block-builders/cmd/script/base_flashblocks.go new file mode 100644 index 00000000..c4b37f74 --- /dev/null +++ b/harnesses/evm-block-builders/cmd/script/base_flashblocks.go @@ -0,0 +1,261 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/andybalholm/brotli" + "github.com/gorilla/websocket" +) + +// Base flashblocks (preconfirmation) stream. +// +// wss://mainnet.flashblocks.base.org/ws pushes one frame per flashblock +// (~every 200-250ms; ~10 flashblocks per 2s Base block). Frames are +// binary, brotli-compressed JSON of the shape: +// +// {"payload_id":"0x..","index":3,"base":{...},"diff":{...}, +// "metadata":{"block_number":31234567,"receipts":{...},...}} +// +// Two measurements: +// 1. inter-frame cadence (ebb_base_flashblock_interval_milliseconds): +// is the advertised 200ms preconfirmation rhythm actually held? +// 2. soft-confirmation lag (ebb_base_softconf_lag_milliseconds): time +// from the FIRST flashblock of block N arriving on the stream to +// block N being visible on the public RPC head. This is the head +// start a flashblock consumer gets over an RPC poller. +// +// If a frame fails brotli+JSON parsing we still record cadence (the +// frame arrived) and count a parse failure; cadence-only degradation is +// acceptable and disclosed. + +const ( + baseDialTimeout = 15 * time.Second + baseReadDeadline = 60 * time.Second + baseWatchdog = 20 * time.Second + baseMinBackoff = 2 * time.Second + baseMaxBackoff = 60 * time.Second + baseMaxLagMs = 30 * 1000 + // A reconnect gap must not pollute the cadence histogram. + baseMaxIntervalMs = 10 * 1000 + baseArrivalsMax = 200 +) + +type baseFeedState struct { + mu sync.Mutex + arrivals map[int64]time.Time // block number → first flashblock arrival + order []int64 +} + +func newBaseFeedState() *baseFeedState { + return &baseFeedState{arrivals: make(map[int64]time.Time, baseArrivalsMax)} +} + +func (s *baseFeedState) recordFirst(block int64, at time.Time) bool { + s.mu.Lock() + defer s.mu.Unlock() + if _, dup := s.arrivals[block]; dup { + return false + } + s.arrivals[block] = at + s.order = append(s.order, block) + for len(s.order) > baseArrivalsMax { + delete(s.arrivals, s.order[0]) + s.order = s.order[1:] + } + return true +} + +func (s *baseFeedState) lookup(block int64) (time.Time, bool) { + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.arrivals[block] + return t, ok +} + +type flashblockFrame struct { + Index *int64 `json:"index"` + Metadata struct { + BlockNumber int64 `json:"block_number"` + } `json:"metadata"` +} + +// runBaseFlashblocks holds the flashblocks WS open with reconnect +// backoff + silence watchdog and feeds cadence + arrival state. +func runBaseFlashblocks(ctx context.Context, state *baseFeedState) { + url := baseFlashblocksURL() + fmt.Printf("[base-fb] connecting %s\n", url) + backoff := baseMinBackoff + for ctx.Err() == nil { + connStart := time.Now() + err := readBaseFlashblocks(ctx, url, state) + if time.Since(connStart) > 5*time.Minute { + backoff = baseMinBackoff // stable session: don't carry stale backoff + } + baseFeedHealth.Set(0) + streamReconnects.WithLabelValues("base-flashblocks").Inc() + if err != nil { + fmt.Printf("[base-fb] error: %v (reconnecting in %v)\n", err, backoff) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < baseMaxBackoff { + backoff *= 2 + if backoff > baseMaxBackoff { + backoff = baseMaxBackoff + } + } + } +} + +func readBaseFlashblocks(ctx context.Context, url string, state *baseFeedState) error { + dialer := *websocket.DefaultDialer + dialer.HandshakeTimeout = baseDialTimeout + hdr := http.Header{"User-Agent": []string{harnessUserAgent}} + conn, _, err := dialer.Dial(url, hdr) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close() + baseFeedHealth.Set(1) + fmt.Println("[base-fb] connected") + + done := make(chan struct{}) + defer close(done) + var lastFrameUnixNano atomic.Int64 + lastFrameUnixNano.Store(time.Now().UnixNano()) + go func() { + t := time.NewTicker(5 * time.Second) + defer t.Stop() + for { + select { + case <-done: + return + case <-ctx.Done(): + _ = conn.Close() + return + case now := <-t.C: + silent := now.Sub(time.Unix(0, lastFrameUnixNano.Load())) + if silent > baseWatchdog { + fmt.Printf("[base-fb] watchdog: silent %s, forcing reconnect\n", silent.Round(time.Second)) + _ = conn.Close() + return + } + } + } + }() + + var lastFrame time.Time + logEvery := 0 + for { + _ = conn.SetReadDeadline(time.Now().Add(baseReadDeadline)) + _, raw, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read: %w", err) + } + now := time.Now() + lastFrameUnixNano.Store(now.UnixNano()) + + // Cadence: frame arrival rhythm, parse success or not. + if !lastFrame.IsZero() { + ivMs := float64(now.Sub(lastFrame).Milliseconds()) + if ivMs >= 0 && ivMs < baseMaxIntervalMs { + baseFlashblockInterval.Observe(ivMs) + } + } + lastFrame = now + baseFrames.Inc() + + frame, err := decodeFlashblockFrame(raw) + if err != nil { + baseParseFailures.Inc() + continue + } + if frame.Metadata.BlockNumber > 0 { + if state.recordFirst(frame.Metadata.BlockNumber, now) { + logEvery++ + if logEvery%10 == 1 { // ~1 log line per 20s, not per 200ms frame + idx := int64(-1) + if frame.Index != nil { + idx = *frame.Index + } + fmt.Printf("[base-fb] first flashblock block=%d index=%d\n", frame.Metadata.BlockNumber, idx) + } + } + } + } +} + +// decodeFlashblockFrame handles both brotli-compressed and plain-JSON +// frames (the stream compressed at inception; tolerate both so a +// server-side change doesn't blind the bench). +func decodeFlashblockFrame(raw []byte) (*flashblockFrame, error) { + var frame flashblockFrame + if len(raw) > 0 && (raw[0] == '{' || raw[0] == '[') { + if err := json.Unmarshal(raw, &frame); err == nil { + return &frame, nil + } + } + dec, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(raw)), 16<<20)) + if err != nil { + return nil, fmt.Errorf("brotli: %w", err) + } + if err := json.Unmarshal(dec, &frame); err != nil { + return nil, fmt.Errorf("json: %w", err) + } + return &frame, nil +} + +// runBaseHeadPoller polls the Base RPC head and emits the +// flashblock→canonical-RPC soft-confirmation lag. +func runBaseHeadPoller(ctx context.Context, state *baseFeedState) { + url := baseRPCURL() + fmt.Printf("[base-rpc] head poll: %s every %s\n", url, l2HeadPollInterval) + + var prevHead int64 + t := time.NewTicker(l2HeadPollInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + + head, err := blockNumber(url) + if err != nil { + baseRPCErrors.Inc() + continue + } + pollAt := time.Now() + if prevHead == 0 { + prevHead = head + continue + } + for b := prevHead + 1; b <= head; b++ { + arrival, ok := state.lookup(b) + if !ok { + continue + } + lagMs := float64(pollAt.Sub(arrival).Milliseconds()) + if lagMs >= 0 && lagMs < baseMaxLagMs { + baseSoftConfLag.Observe(lagMs) + baseLagSamples.Inc() + fmt.Printf("[base] block=%d softconf lag=%.0fms\n", b, lagMs) + } + } + if head > prevHead { + prevHead = head + } + } +} diff --git a/harnesses/evm-block-builders/cmd/script/builders.go b/harnesses/evm-block-builders/cmd/script/builders.go new file mode 100644 index 00000000..0156ee66 --- /dev/null +++ b/harnesses/evm-block-builders/cmd/script/builders.go @@ -0,0 +1,192 @@ +package main + +import ( + "context" + "encoding/hex" + "fmt" + "strings" + "time" +) + +// Ethereum builder attribution via block extraData. +// +// Same philosophy as bench 016 (solana-tx-landing): a curated table of +// known-entity tags, plus explicit logging of every string we could NOT +// attribute so the table grows from operator logs instead of silently +// under-counting. Builders self-label their blocks in extraData +// ("Titan (titanbuilder.xyz)", "beaverbuild.org", ...); vanilla +// (locally-built) blocks carry the execution client's default tag +// (geth/reth/besu/nethermind version strings) or are empty. +// +// extraData is self-reported: a builder could change or strip its tag +// at any time. The methodology in the spec discloses this; the relay +// bidtrace cross-check (relays.go) provides an independent signal. + +// builderRule maps a lowercase substring of the decoded extraData to a +// canonical builder slug. Order matters: first match wins, so put the +// most specific substrings first. +type builderRule struct { + substr string + slug string +} + +// builderTable is the curated attribution table. Slugs are the Prom +// label values the spec YAML queries. Verified against live mainnet +// blocks at bench inception (50-block sample: Titan 19, Quasar 16, +// Eureka 6, BuilderNet 2, beaverbuild 1, Builder+ 1, vanilla 1). +var builderTable = []builderRule{ + // Specific multi-word tags first. + {"bob-the-builder", "bobthebuilder"}, + {"bobthebuilder", "bobthebuilder"}, + {"builder0x69", "builder0x69"}, + {"penguinbuild", "penguinbuild"}, + {"beaverbuild", "beaverbuild"}, + {"buildernet", "buildernet"}, + {"builder+", "btcs"}, // BTCS "Builder+" + {"btcs", "btcs"}, + {"titan", "titan"}, + {"quasar", "quasar"}, + {"eureka", "eureka"}, + {"rsync", "rsync"}, + // Flashbots historically tags "Illuminate Dmocratize Dstribute". + {"flashbots", "flashbots"}, + {"illuminate", "flashbots"}, + {"dmocratize", "flashbots"}, + {"bloxroute", "bloxroute"}, + {"gambit", "gambit"}, + {"antbuilder", "antbuilder"}, + {"manifold", "manifold"}, + {"blockbeelder", "blockbeelder"}, + {"jetbldr", "jetbuilder"}, + {"payload", "payload"}, + {"boba-builder", "boba"}, +} + +// vanillaTags identify execution-client default extraData: the proposer +// built the block locally instead of outsourcing to a builder. These +// count as "vanilla", NOT as unattributed - a locally-built block is a +// positive decentralization signal, not a table gap. +var vanillaTags = []string{ + "geth", "go1.", "nethermind", "besu", "reth", "erigon", "linux", + "darwin", "windows", "ubuntu", +} + +// slugOther collects blocks whose extraData carries a tag we don't +// recognize. Every occurrence is logged with the raw string so the +// table can be grown (016 pattern). +const ( + slugOther = "other" + slugVanilla = "vanilla" +) + +// decodeExtraData turns the hex extraData field into a printable string +// for substring matching. Non-printable bytes (client tags like geth +// pack RLP fragments around the ASCII) are dropped. +func decodeExtraData(hexStr string) string { + raw, err := hex.DecodeString(strings.TrimPrefix(hexStr, "0x")) + if err != nil { + return "" + } + var b strings.Builder + for _, c := range raw { + if c >= 0x20 && c <= 0x7e { + b.WriteByte(c) + } + } + return b.String() +} + +// attributeBuilder maps decoded extraData to a builder slug. +// Returns (slug, attributed). attributed=false means the tag was +// non-empty and unrecognized (goes to "other" + unattributed counter). +func attributeBuilder(decoded string) (string, bool) { + lower := strings.ToLower(decoded) + if strings.TrimSpace(lower) == "" { + return slugVanilla, true + } + for _, r := range builderTable { + if strings.Contains(lower, r.substr) { + return r.slug, true + } + } + for _, t := range vanillaTags { + if strings.Contains(lower, t) { + return slugVanilla, true + } + } + return slugOther, false +} + +type ethBlock struct { + Number string `json:"number"` + ExtraData string `json:"extraData"` +} + +// runBuilderPoll polls the ETH head every slot (12s), attributes each +// new block's extraData and backfills small gaps by number so the +// market-share counters see every block, not just the ones that happen +// to be head at poll time. +func runBuilderPoll(ctx context.Context) { + url := ethRPCURL() + fmt.Printf("[eth] builder attribution poll: %s every %s\n", url, ethPollInterval) + + var lastSeen int64 + t := time.NewTicker(ethPollInterval) + defer t.Stop() + for { + var head ethBlock + err := jsonRPCCall(url, "eth_getBlockByNumber", []any{"latest", false}, &head) + if err != nil { + ethPollHealth.Set(0) + ethPollErrors.Inc() + fmt.Printf("[eth] poll error: %v\n", err) + } else { + ethPollHealth.Set(1) + headNum := parseHexInt64(head.Number) + if headNum > lastSeen { + // Backfill missed blocks (cap 10: a longer gap means we were + // down; counting a burst of stale blocks then would skew the + // 5m rate series without changing 24h share meaningfully). + start := headNum + if lastSeen > 0 { + start = lastSeen + 1 + if headNum-lastSeen > 10 { + start = headNum - 10 + 1 + } + } + for n := start; n < headNum; n++ { + var blk ethBlock + if err := jsonRPCCall(url, "eth_getBlockByNumber", + []any{fmt.Sprintf("0x%x", n), false}, &blk); err != nil { + fmt.Printf("[eth] backfill %d error: %v\n", n, err) + continue + } + recordBlock(n, blk.ExtraData) + } + recordBlock(headNum, head.ExtraData) + lastSeen = headNum + ethLastBlock.Set(float64(headNum)) + } + } + + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +func recordBlock(num int64, extraHex string) { + decoded := decodeExtraData(extraHex) + slug, attributed := attributeBuilder(decoded) + blocksTotal.WithLabelValues(slug).Inc() + if !attributed { + unattributedTotal.Inc() + // The whole point of the log line: grow the table from operator + // logs, exactly like 016 logs unattributable tip patterns. + fmt.Printf("[eth] UNATTRIBUTED block=%d extraData=%q raw=%s\n", num, decoded, extraHex) + } else { + fmt.Printf("[eth] block=%d builder=%s extraData=%q\n", num, slug, decoded) + } +} diff --git a/harnesses/evm-block-builders/cmd/script/config.go b/harnesses/evm-block-builders/cmd/script/config.go new file mode 100644 index 00000000..d5e08c65 --- /dev/null +++ b/harnesses/evm-block-builders/cmd/script/config.go @@ -0,0 +1,153 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +// Endpoints. All public, keyless, verified live at bench inception +// (2026-07-14). Every URL is env-overridable without a rebuild so a +// flaky public endpoint can be swapped from the deploy env. +// +// publicnode 403s default Go/Python User-Agents (bench 067 lesson), so +// EVERY outbound HTTP request and WS dial in this harness sends +// harnessUserAgent - see newRequest and the WS dial headers in +// arb_feed.go / base_flashblocks.go. + +const ( + defaultEthRPC = "https://ethereum-rpc.publicnode.com" + defaultArbRPC = "https://arbitrum-one-rpc.publicnode.com" + defaultBaseRPC = "https://base-rpc.publicnode.com" + defaultArbFeedURL = "wss://arb1.arbitrum.io/feed" + defaultBaseFlashblocks = "wss://mainnet.flashblocks.base.org/ws" + + // Browser-prefixed but honest: identifies the harness while passing + // UA-based bot filters (publicnode 403s bare Go-http-client UAs). + harnessUserAgent = "Mozilla/5.0 (compatible; OpenChainBench-harness/1.0; +https://openchainbench.com)" + + // ETH mainnet slot time. One poll per slot samples ~every block; the + // gap-backfill in builders.go covers the occasional missed one. + ethPollInterval = 12 * time.Second + + // Relay bidtrace APIs are rate-limited and slow-moving (1 payload per + // 12s slot at most); 5 min polls with limit=50 never miss a slot. + relayPollInterval = 5 * time.Minute + + // L2 head pollers. 300ms against publicnode is comfortably inside the + // public rate limits (verified live) and fine-grained enough to + // resolve a ~100ms soft-conf lag. + l2HeadPollInterval = 300 * time.Millisecond +) + +func envDefault(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +func ethRPCURL() string { return envDefault("ETH_RPC_URL", defaultEthRPC) } +func arbRPCURL() string { return envDefault("ARB_RPC_URL", defaultArbRPC) } +func baseRPCURL() string { return envDefault("BASE_RPC_URL", defaultBaseRPC) } +func arbFeedURL() string { + return envDefault("ARB_FEED_URL", defaultArbFeedURL) +} +func baseFlashblocksURL() string { + return envDefault("BASE_FLASHBLOCKS_URL", defaultBaseFlashblocks) +} + +// listenAddr defaults to :2112 - OCB convention, scraped by the shared +// Prometheus. We deliberately ignore $PORT so a platform-injected public +// port doesn't move the listener away from the address Prom expects. +// METRICS_ADDR exists for local dev where another harness holds 2112. +func listenAddr() string { return envDefault("METRICS_ADDR", ":2112") } + +var httpClient = &http.Client{Timeout: 15 * time.Second} + +// newRequest builds an *http.Request with the harness User-Agent set. +func newRequest(method, url string, body []byte) (*http.Request, error) { + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } + req, err := http.NewRequest(method, url, rdr) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", harnessUserAgent) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + return req, nil +} + +// jsonRPCCall does a single JSON-RPC 2.0 request and unmarshals the +// `result` field into out. +func jsonRPCCall(url, method string, params []any, out any) error { + payload, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": method, "params": params, + }) + if err != nil { + return err + } + req, err := newRequest(http.MethodPost, url, payload) + if err != nil { + return err + } + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) + return fmt.Errorf("%s: HTTP %d: %s", method, resp.StatusCode, string(body)) + } + var envl struct { + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&envl); err != nil { + return fmt.Errorf("%s: decode: %w", method, err) + } + if envl.Error != nil { + return fmt.Errorf("%s: rpc error %d: %s", method, envl.Error.Code, envl.Error.Message) + } + if envl.Result == nil { + return fmt.Errorf("%s: empty result", method) + } + return json.Unmarshal(envl.Result, out) +} + +// blockNumber fetches the current head number via eth_blockNumber. +func blockNumber(url string) (int64, error) { + var hexNum string + if err := jsonRPCCall(url, "eth_blockNumber", []any{}, &hexNum); err != nil { + return 0, err + } + n := parseHexInt64(hexNum) + if n == 0 { + return 0, fmt.Errorf("eth_blockNumber: unparseable %q", hexNum) + } + return n, nil +} + +func parseHexInt64(s string) int64 { + s = strings.TrimPrefix(s, "0x") + if s == "" { + return 0 + } + n, _ := strconv.ParseInt(s, 16, 64) + return n +} diff --git a/harnesses/evm-block-builders/cmd/script/loghub.go b/harnesses/evm-block-builders/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/evm-block-builders/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/evm-block-builders/cmd/script/main.go b/harnesses/evm-block-builders/cmd/script/main.go new file mode 100644 index 00000000..244d38ff --- /dev/null +++ b/harnesses/evm-block-builders/cmd/script/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" +) + +// Bench 085 "evm-block-builders": who builds Ethereum, plus L2 +// soft-confirmation lag. +// +// Four independent measurement loops share one process: +// 1. builders.go - ETH head poll, extraData → builder attribution +// 2. relays.go - MEV-Boost relay bidtrace pollers (cross-check) +// 3. arb_feed.go - Arbitrum sequencer feed → RPC visibility lag +// 4. base_flashblocks.go - Base flashblock cadence + → RPC lag +// +// Metrics server listens on :2112 - OCB convention; $PORT is ignored +// so Prometheus can scrape the expected address. + +func main() { + installLogCapture() // capture stdout into /logs ring buffer + fmt.Println("=== EVM Block Builders Harness (bench 085) ===") + fmt.Println("OpenChainBench: builder market share via extraData + L2 soft-confirmation lag.") + fmt.Println() + fmt.Printf("ETH RPC: %s (poll %s)\n", ethRPCURL(), ethPollInterval) + fmt.Printf("Relays: %d bidtrace APIs (poll %s)\n", len(relays()), relayPollInterval) + fmt.Printf("Arb feed: %s\n", arbFeedURL()) + fmt.Printf("Arb RPC: %s (poll %s)\n", arbRPCURL(), l2HeadPollInterval) + fmt.Printf("Base flashblocks: %s\n", baseFlashblocksURL()) + fmt.Printf("Base RPC: %s (poll %s)\n", baseRPCURL(), l2HeadPollInterval) + fmt.Println() + fmt.Println("Metrics server: :2112/metrics") + fmt.Println() + + go func() { + if err := StartMetricsServer(listenAddr()); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + // Give the loghub forwarding goroutine a beat to flush the + // fatal line to the real stdout before we exit. + time.Sleep(200 * time.Millisecond) + os.Exit(1) + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Initialise health gauges to 0; each loop flips its own to 1. + ethPollHealth.Set(0) + arbFeedHealth.Set(0) + baseFeedHealth.Set(0) + for _, r := range relays() { + relayHealth.WithLabelValues(r.Slug).Set(0) + } + + go runBuilderPoll(ctx) + runRelayPolls(ctx) + + arbState := newArbFeedState() + go runArbFeed(ctx, arbState) + go runArbHeadPoller(ctx, arbState) + + baseState := newBaseFeedState() + go runBaseFlashblocks(ctx, baseState) + go runBaseHeadPoller(ctx, baseState) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + s := <-sig + fmt.Printf("\n[shutdown] received %v\n", s) + cancel() +} diff --git a/harnesses/evm-block-builders/cmd/script/metrics.go b/harnesses/evm-block-builders/cmd/script/metrics.go new file mode 100644 index 00000000..93cfb14e --- /dev/null +++ b/harnesses/evm-block-builders/cmd/script/metrics.go @@ -0,0 +1,223 @@ +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 bench 085 (evm-block-builders). Prefix `ebb_`. +// The spec YAML (benchmarks/evm-block-builders.yml) queries these +// directly via PromQL. +var ( + /* ---- Ethereum builder attribution (headline) ---- */ + + // Per-builder block counter, attributed via extraData substring + // table. Includes the synthetic slugs "vanilla" (client-default + // extraData → locally built) and "other" (unrecognized tag). + blocksTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "ebb_blocks_total", + Help: "Ethereum mainnet blocks attributed to each builder via the block's extraData self-label. builder=vanilla means client-default extraData (locally built); builder=other means an unrecognized tag (also logged raw for table growth).", + }, + []string{"builder"}, + ) + + // Blocks whose extraData carried an unrecognized tag. The raw string + // is logged so the attribution table can grow (016 pattern). + unattributedTotal = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "ebb_unattributed_total", + Help: "Blocks whose extraData tag was non-empty but not in the curated builder table. Every occurrence logs the raw extraData for later table growth.", + }, + ) + + ethPollHealth = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "ebb_eth_poll_health", + Help: "1 when the Ethereum head poll is succeeding, 0 after the last poll errored.", + }, + ) + + ethPollErrors = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "ebb_eth_poll_errors_total", + Help: "Ethereum RPC poll errors since process start.", + }, + ) + + ethLastBlock = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "ebb_eth_last_block", + Help: "Most recent Ethereum block number attributed by the harness. Freshness probe target.", + }, + ) + + /* ---- Relay bidtrace cross-check ---- */ + + relayPayloadsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "ebb_relay_payloads_total", + Help: "Delivered payloads (proposer_payload_delivered bidtraces) counted per MEV-Boost relay, deduped per relay via a slot high-water-mark. The same slot on multiple relays is normal (multi-homed bids), so this measures relay share, not a partition of blocks.", + }, + []string{"relay"}, + ) + + relayPollErrors = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "ebb_relay_poll_errors_total", + Help: "Bidtrace poll errors per relay since process start.", + }, + []string{"relay"}, + ) + + relayHealth = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "ebb_relay_health", + Help: "1 when the last bidtrace poll for this relay succeeded, 0 otherwise.", + }, + []string{"relay"}, + ) + + relayLastSlot = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "ebb_relay_last_slot", + Help: "Highest slot seen in bidtraces per relay.", + }, + []string{"relay"}, + ) + + /* ---- Arbitrum sequencer feed soft-confirmation lag ---- */ + + arbSoftConfLag = promauto.NewHistogram( + prometheus.HistogramOpts{ + Name: "ebb_arb_softconf_lag_milliseconds", + Help: "Lag from a block's sequencer-feed arrival to the same block being visible on the public Arbitrum RPC head. Quantized by the ~300ms RPC poll interval.", + Buckets: []float64{ + 25, 50, 75, 100, 150, 200, 300, 400, 500, 750, + 1000, 1500, 2000, 3000, 5000, 10000, + }, + }, + ) + + arbLagSamples = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "ebb_arb_lag_samples_total", + Help: "Arbitrum soft-confirmation lag samples recorded.", + }, + ) + + arbFeedHealth = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "ebb_arb_feed_health", + Help: "1 while the Arbitrum sequencer feed WebSocket is connected, 0 otherwise.", + }, + ) + + arbFeedMessages = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "ebb_arb_feed_messages_total", + Help: "Sequencer feed messages received.", + }, + ) + + arbDerivedOffset = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "ebb_arb_derived_offset", + Help: "Runtime-derived sequenceNumber→blockNumber offset (expected ≈22207817). Drift signals a feed format change.", + }, + ) + + arbRPCErrors = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "ebb_arb_rpc_errors_total", + Help: "Arbitrum RPC head-poll errors since process start.", + }, + ) + + /* ---- Base flashblocks ---- */ + + baseFlashblockInterval = promauto.NewHistogram( + prometheus.HistogramOpts{ + Name: "ebb_base_flashblock_interval_milliseconds", + Help: "Wall-clock interval between consecutive flashblock frames on the Base flashblocks WebSocket (advertised rhythm: ~200ms).", + Buckets: []float64{ + 50, 100, 150, 200, 250, 300, 400, 500, 750, + 1000, 2000, 5000, + }, + }, + ) + + baseSoftConfLag = promauto.NewHistogram( + prometheus.HistogramOpts{ + Name: "ebb_base_softconf_lag_milliseconds", + Help: "Lag from the first flashblock of a Base block arriving on the stream to that block being visible on the public Base RPC head. Quantized by the ~300ms RPC poll interval.", + Buckets: []float64{ + 100, 250, 500, 750, 1000, 1250, 1500, 1750, 2000, + 2500, 3000, 5000, 10000, + }, + }, + ) + + baseLagSamples = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "ebb_base_lag_samples_total", + Help: "Base soft-confirmation lag samples recorded.", + }, + ) + + baseFeedHealth = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "ebb_base_feed_health", + Help: "1 while the Base flashblocks WebSocket is connected, 0 otherwise.", + }, + ) + + baseFrames = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "ebb_base_frames_total", + Help: "Flashblock frames received.", + }, + ) + + baseParseFailures = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "ebb_base_parse_failures_total", + Help: "Flashblock frames that failed brotli+JSON decoding (cadence still recorded).", + }, + ) + + baseRPCErrors = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "ebb_base_rpc_errors_total", + Help: "Base RPC head-poll errors since process start.", + }, + ) + + /* ---- Shared ---- */ + + streamReconnects = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "ebb_stream_reconnects_total", + Help: "WebSocket reconnects per stream (arb-feed, base-flashblocks) since process start.", + }, + []string{"stream"}, + ) +) + +// StartMetricsServer binds /metrics + /health + /logs on addr. Blocking +// call, run in its own goroutine. +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + 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("evm-block-builders harness · OpenChainBench")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/evm-block-builders/cmd/script/relays.go b/harnesses/evm-block-builders/cmd/script/relays.go new file mode 100644 index 00000000..91c7f922 --- /dev/null +++ b/harnesses/evm-block-builders/cmd/script/relays.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "time" +) + +// MEV-Boost relay bidtrace pollers. +// +// Every major relay exposes the standard Relay Data API keyless: +// GET /relay/v1/data/bidtraces/proposer_payload_delivered?limit=N +// One entry per delivered payload (= one relayed block). We poll each +// relay every 5 min and count NEW slots per relay via a high-water-mark +// so restarts / overlapping windows never double-count within a relay. +// +// The same slot appearing on multiple relays is NORMAL (builders +// multi-home their bids; the winning payload is reported by every relay +// that carried it), so relay counters are a market-share signal for +// relays, not a partition of blocks. This is the independent +// cross-check on the extraData attribution: relay-side builder pubkeys +// don't depend on the builder's self-chosen extraData tag. + +type relayEndpoint struct { + Slug string + Base string +} + +// relays verified answering keyless at bench inception (2026-07-14). +func relays() []relayEndpoint { + return []relayEndpoint{ + {"flashbots", "https://boost-relay.flashbots.net"}, + {"ultrasound", "https://relay.ultrasound.money"}, + {"agnostic", "https://agnostic-relay.net"}, + {"bloxroute-max-profit", "https://bloxroute.max-profit.blxrbdn.com"}, + {"bloxroute-regulated", "https://bloxroute.regulated.blxrbdn.com"}, + {"titan", "https://titanrelay.xyz"}, + {"aestus", "https://aestus.live"}, + } +} + +type bidTrace struct { + Slot string `json:"slot"` +} + +// runRelayPolls launches one poller goroutine per relay. +func runRelayPolls(ctx context.Context) { + for _, r := range relays() { + r := r + go pollRelay(ctx, r) + } +} + +func pollRelay(ctx context.Context, r relayEndpoint) { + url := r.Base + "/relay/v1/data/bidtraces/proposer_payload_delivered?limit=50" + fmt.Printf("[relay:%s] polling %s every %s\n", r.Slug, url, relayPollInterval) + + var maxSeenSlot int64 + primed := false // first successful fetch seeds the high-water-mark only + t := time.NewTicker(relayPollInterval) + defer t.Stop() + for { + slots, err := fetchDeliveredSlots(url) + if err != nil { + relayPollErrors.WithLabelValues(r.Slug).Inc() + relayHealth.WithLabelValues(r.Slug).Set(0) + fmt.Printf("[relay:%s] poll error: %v\n", r.Slug, err) + } else { + relayHealth.WithLabelValues(r.Slug).Set(1) + fresh := 0 + for _, s := range slots { + if s > maxSeenSlot { + fresh++ + } + } + if len(slots) > 0 { + sort.Slice(slots, func(i, j int) bool { return slots[i] > slots[j] }) + if slots[0] > maxSeenSlot { + maxSeenSlot = slots[0] + } + } + if !primed { + // First fetch after (re)start returns up to 50 historical + // payloads that were already counted before the restart. + // Seed the high-water-mark from them WITHOUT incrementing: + // Prom increase() tolerates counter resets, but re-counting + // old slots would inflate the series after every restart. + primed = true + fmt.Printf("[relay:%s] primed high-water-mark maxSlot=%d (skipped %d historical)\n", r.Slug, maxSeenSlot, fresh) + } else { + relayPayloadsTotal.WithLabelValues(r.Slug).Add(float64(fresh)) + fmt.Printf("[relay:%s] delivered=%d fresh=%d maxSlot=%d\n", r.Slug, len(slots), fresh, maxSeenSlot) + } + relayLastSlot.WithLabelValues(r.Slug).Set(float64(maxSeenSlot)) + } + + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +// fetchDeliveredSlots GETs the bidtraces endpoint and returns the slot +// numbers, deduped within the response (defensive: the same slot can +// appear twice on a relay after a reorged proposal). +func fetchDeliveredSlots(url string) ([]int64, error) { + req, err := newRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + var traces []bidTrace + if err := json.NewDecoder(resp.Body).Decode(&traces); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + seen := make(map[int64]struct{}, len(traces)) + out := make([]int64, 0, len(traces)) + for _, tr := range traces { + n, err := strconv.ParseInt(tr.Slot, 10, 64) + if err != nil || n == 0 { + continue + } + if _, dup := seen[n]; dup { + continue + } + seen[n] = struct{}{} + out = append(out, n) + } + return out, nil +} diff --git a/harnesses/evm-block-builders/go.mod b/harnesses/evm-block-builders/go.mod new file mode 100644 index 00000000..6b4d23fc --- /dev/null +++ b/harnesses/evm-block-builders/go.mod @@ -0,0 +1,22 @@ +module evm-block-builders + +go 1.24.0 + +require ( + github.com/andybalholm/brotli v1.1.1 + github.com/gorilla/websocket v1.5.3 + github.com/prometheus/client_golang v1.23.2 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/evm-block-builders/go.sum b/harnesses/evm-block-builders/go.sum new file mode 100644 index 00000000..91fbc2f0 --- /dev/null +++ b/harnesses/evm-block-builders/go.sum @@ -0,0 +1,52 @@ +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/nft-metadata-coverage/cmd/script/monitor.go b/harnesses/nft-metadata-coverage/cmd/script/monitor.go index 26f14653..5fd4d0da 100644 --- a/harnesses/nft-metadata-coverage/cmd/script/monitor.go +++ b/harnesses/nft-metadata-coverage/cmd/script/monitor.go @@ -25,7 +25,7 @@ func truncate(s string, n int) string { // providerOrder is the column order in the condensed line, log table, and // any debug dump. -var providerOrder = []string{"moralis", "alchemy", "opensea", "rarible"} +var providerOrder = []string{"moralis", "alchemy", "opensea"} // fieldLetter maps each field to a single-letter code (NIDFE) used in the // condensed per-collection log line. @@ -228,10 +228,11 @@ func runCheckAllProviders(cfg *Config) { results["opensea"] = osRes openSeaWaitTurn() // accounts for the second internal call's spacing - // Rarible is serial @ 1 req/sec. - raribleWaitTurn() - raRes := checkRarible(coll, cfg.RaribleAPIKey, cfg.MonitorRegion) - results["rarible"] = raRes + // Rarible delisted 2026-07-13: the free tier caps at 100 requests + // per MONTH (one 50-collection cycle burns half of it), below any + // measurable cadence. The probe code stays in rarible.go for a + // sponsored or paid key revival; it was never a spec provider, so + // nothing user-facing changes. for _, p := range providerOrder { applyResult(results[p], cfg.MonitorRegion) diff --git a/harnesses/nft-metadata-coverage/cmd/script/opensea.go b/harnesses/nft-metadata-coverage/cmd/script/opensea.go index 944ae839..0869c7d7 100644 --- a/harnesses/nft-metadata-coverage/cmd/script/opensea.go +++ b/harnesses/nft-metadata-coverage/cmd/script/opensea.go @@ -43,7 +43,12 @@ var openSeaClient = &http.Client{Timeout: 10 * time.Second} func resolveOpenSeaSlug(contract, apiKey, region string) (string, error) { url := fmt.Sprintf("https://api.opensea.io/api/v2/chain/ethereum/contract/%s", contract) req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("x-api-key", apiKey) + // OpenSea v2 serves these endpoints keyless (verified 2026-07-13), + // but an EMPTY x-api-key header gets a 401. Only set it when a key + // is actually configured. + if apiKey != "" { + req.Header.Set("x-api-key", apiKey) + } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", userAgent) @@ -73,12 +78,9 @@ func resolveOpenSeaSlug(contract, apiKey, region string) (string, error) { func checkOpenSea(coll NFTCollection, apiKey, region string) NFTResult { res := newResult("opensea", coll.Name) - if apiKey == "" { - res.Error = "missing_api_key" - res.ErrorType = "config" - recordError("opensea", region, "config") - return res - } + // Keyless mode is supported: OpenSea v2 collections/stats/contract + // endpoints answer without a key (verified 2026-07-13, incl. burst). + // A configured key only raises the rate-limit headroom. if coll.OpenSeaSlug == "" { res.Error = "missing_slug" res.ErrorType = "slug_resolve_error" @@ -91,7 +93,9 @@ func checkOpenSea(coll NFTCollection, apiKey, region string) NFTResult { // Call 1: collection metadata url1 := fmt.Sprintf("https://api.opensea.io/api/v2/collections/%s", coll.OpenSeaSlug) req1, _ := http.NewRequest("GET", url1, nil) - req1.Header.Set("x-api-key", apiKey) + if apiKey != "" { + req1.Header.Set("x-api-key", apiKey) + } req1.Header.Set("Accept", "application/json") req1.Header.Set("User-Agent", userAgent) @@ -130,7 +134,9 @@ func checkOpenSea(coll NFTCollection, apiKey, region string) NFTResult { // Call 2: stats (floor price). Non-fatal if it fails — we still score 4/5. url2 := fmt.Sprintf("https://api.opensea.io/api/v2/collections/%s/stats", coll.OpenSeaSlug) req2, _ := http.NewRequest("GET", url2, nil) - req2.Header.Set("x-api-key", apiKey) + if apiKey != "" { + req2.Header.Set("x-api-key", apiKey) + } req2.Header.Set("Accept", "application/json") req2.Header.Set("User-Agent", userAgent) diff --git a/harnesses/oracle-deviation/README.md b/harnesses/oracle-deviation/README.md index 1d41b04e..107e80fe 100644 --- a/harnesses/oracle-deviation/README.md +++ b/harnesses/oracle-deviation/README.md @@ -1,7 +1,11 @@ -# oracle-deviation — OpenChainBench № 025 +# oracle-deviation — OpenChainBench № 025 + № 082 Compares four price oracles in real time across ten USD pairs and -publishes the per-pair deviation as Prometheus gauges. +publishes the per-pair deviation as Prometheus gauges (bench № 025). +The same process also tracks oracle freshness (bench № 082): +staleness in seconds derived from each oracle's own last-update +timestamp, for Chainlink on three chains, Pyth via Hermes and +RedStone via its public per-symbol API. ## Sources @@ -12,6 +16,14 @@ publishes the per-pair deviation as Prometheus gauges. | Binance | REST `/api/v3/ticker/price` | none | 30 s | | Coinbase | REST `/products/

/ticker` | none | 30 s | +Freshness-only sources (№ 082, never enter the № 025 deviation matrix): + +| Source | Transport | Auth | Cadence | +| ------------------- | ------------------------------------------------ | ---- | ------- | +| Chainlink Arbitrum | `eth_call` on AggregatorV3 (ETH/USD) | none | 30 s | +| Chainlink Base | `eth_call` on AggregatorV3 (ETH/USD) | none | 30 s | +| RedStone | REST `/prices?symbol=&provider=redstone` | none | 30 s | + ## Pairs BTC, ETH, SOL, BNB, XRP, ADA, DOGE, AVAX, LINK, MATIC — all quoted @@ -48,6 +60,25 @@ ocb_oracle_scrape_errors_total{source, pair} counter ocb_oracle_last_round_age_seconds{source="chainlink", pair} gauge ``` +Bench № 082 (freshness) families. New label set (`oracle`, `pair`, +`chain`) instead of adding a `chain` label to the № 025 families, +which would have split their series and changed the live bench's +numbers: + +``` +ocb_oracle_staleness_seconds{oracle, pair, chain} gauge (s) +ocb_oracle_update_events_total{oracle, pair, chain} counter +ocb_oracle_stale_but_moved{oracle, pair, chain} gauge (0/1) +ocb_oracle_freshness_scrape_errors_total{oracle, pair, chain} counter +``` + +`chain` is where the freshness timestamp lives: `ethereum` / +`arbitrum` / `base` for Chainlink deployments, `hermes` for Pyth +(publish_time), `gateway` for RedStone (signed data-package +timestamp). `stale_but_moved` is 1 when staleness > 300 s AND the +Binance/Coinbase reference moved > 0.5 % since the update was +observed (thresholds documented in `freshness.go`). + The headline ranking signal for the OCB leaderboard is `ocb_oracle_max_deviation_pct` — a per-pair quantile over time (`quantile_over_time(0.95, ocb_oracle_max_deviation_pct[24h])`) @@ -76,3 +107,7 @@ curl -s :2112/metrics | grep ocb_oracle_max_deviation_pct | ------------------------ | ------------------------------------------- | | `ORACLE_RPC_PRIMARY` | `https://ethereum-rpc.publicnode.com` | | `ORACLE_RPC_FALLBACK` | `https://eth.llamarpc.com` | +| `ORACLE_RPC_ARBITRUM` | `https://arbitrum-one-rpc.publicnode.com` | +| `ORACLE_RPC_BASE` | `https://base-rpc.publicnode.com` | +| `ORACLE_REDSTONE_URL` | `https://api.redstone.finance/prices` | +| `ORACLE_METRICS_ADDR` | `:2112` (local-run override only) | diff --git a/harnesses/oracle-deviation/cmd/script/chainlink.go b/harnesses/oracle-deviation/cmd/script/chainlink.go index fdd292de..f8648564 100644 --- a/harnesses/oracle-deviation/cmd/script/chainlink.go +++ b/harnesses/oracle-deviation/cmd/script/chainlink.go @@ -66,9 +66,11 @@ type rpcResp struct { type chainlinkPoller struct { httpClient *http.Client - // rotated[i] is the index of the next RPC to try; on consecutive - // errors the poller flips to the fallback. Cheap atomic instead - // of a mutex since the field is only read/written by this poller. + // rpcs is the ordered endpoint list for the chain this poller + // reads from. rpcIdx rotates through it on transient errors. + // Cheap atomic instead of a mutex since the field is only + // read/written by this poller. + rpcs []string rpcIdx atomic.Int32 // decimals cache (feed → decimals). Populated lazily on first // successful read. @@ -80,9 +82,10 @@ type chainlinkPoller struct { failureCount map[string]int } -func newChainlinkPoller() *chainlinkPoller { +func newChainlinkPoller(rpcs []string) *chainlinkPoller { return &chainlinkPoller{ httpClient: &http.Client{Timeout: httpTimeout}, + rpcs: rpcs, decimalsCache: make(map[string]int), unsupported: make(map[string]bool), failureCount: make(map[string]int), @@ -90,10 +93,11 @@ func newChainlinkPoller() *chainlinkPoller { } func (c *chainlinkPoller) rpcURL() string { - if c.rpcIdx.Load()%2 == 0 { - return rpcEndpoint() + idx := int(c.rpcIdx.Load()) % len(c.rpcs) + if idx < 0 { + idx = -idx } - return rpcEndpointFallback() + return c.rpcs[idx] } // runChainlink loops every pair every pollInterval. The 10 pairs are @@ -101,7 +105,7 @@ func (c *chainlinkPoller) rpcURL() string { // (one batched request would be ideal — public RPCs often refuse // JSON-RPC batches, so we stick to one call per pair). func runChainlink(ctx context.Context, specs []PairSpec) { - c := newChainlinkPoller() + c := newChainlinkPoller([]string{rpcEndpoint(), rpcEndpointFallback()}) t := time.NewTicker(pollInterval) defer t.Stop() @@ -118,8 +122,12 @@ func runChainlink(ctx context.Context, specs []PairSpec) { } if c.unsupported[s.ChainlinkFeed] { // Mark error every cycle so the deviation calc skips - // stale values, but skip the network round-trip. + // stale values, but skip the network round-trip. Also + // signal the freshness error counter (mirrors + // runChainlinkChains) so a dead feed's climbing + // staleness gauge is distinguishable from a quiet feed. oracleScrapeErrors.WithLabelValues(string(SourceChainlink), string(s.Pair)).Inc() + freshnessScrapeErrors.WithLabelValues(string(SourceChainlink), string(s.Pair), ChainEthereum).Inc() continue } pollCtx, cancel := context.WithTimeout(ctx, httpTimeout*2) @@ -127,6 +135,7 @@ func runChainlink(ctx context.Context, specs []PairSpec) { cancel() if err != nil { oracleScrapeErrors.WithLabelValues(string(SourceChainlink), string(s.Pair)).Inc() + freshnessScrapeErrors.WithLabelValues(string(SourceChainlink), string(s.Pair), ChainEthereum).Inc() c.failureCount[s.ChainlinkFeed]++ // Distinguish reverts (permanent — bad address / // non-AggregatorV3 contract) from transient HTTP @@ -150,6 +159,10 @@ func runChainlink(ctx context.Context, specs []PairSpec) { // values (which would tag Chainlink's heartbeat lag as // "deviation" — that artifact is exactly what Fix C kills). recordPriceAt(SourceChainlink, s.Pair, price, time.Unix(updatedAt, 0)) + // Feed the № 082 freshness tracker with the same on-chain + // updatedAt (chain=ethereum). Freshness for other chains + // comes from runChainlinkChains. + recordFreshness(string(SourceChainlink), s.Pair, ChainEthereum, time.Unix(updatedAt, 0)) ageS := time.Since(time.Unix(updatedAt, 0)).Seconds() if ageS < 0 { ageS = 0 @@ -169,6 +182,62 @@ func runChainlink(ctx context.Context, specs []PairSpec) { } } +// runChainlinkChains polls the non-Ethereum Chainlink deployments +// (bench № 082, freshness only). Same read path as the mainnet +// poller, one dedicated chainlinkPoller per chain so the RPC rotation +// and decimals cache stay chain-local. Successes feed ONLY the +// freshness tracker: these prices never enter the 025 deviation store +// (see ChainFeed doc in config.go). Errors go to the freshness error +// counter, never to ocb_oracle_scrape_errors_total. +func runChainlinkChains(ctx context.Context, feeds []ChainFeed) { + pollers := make(map[string]*chainlinkPoller) + for _, f := range feeds { + if _, ok := pollers[f.Chain]; !ok { + pollers[f.Chain] = newChainlinkPoller(f.RPCs) + } + } + t := time.NewTicker(pollInterval) + defer t.Stop() + + tick := func() { + for _, f := range feeds { + c := pollers[f.Chain] + if c.unsupported[f.Feed] { + freshnessScrapeErrors.WithLabelValues(string(SourceChainlink), string(f.Pair), f.Chain).Inc() + continue + } + pollCtx, cancel := context.WithTimeout(ctx, httpTimeout*2) + price, updatedAt, err := c.read(pollCtx, f.Feed) + cancel() + if err != nil || price <= 0 { + freshnessScrapeErrors.WithLabelValues(string(SourceChainlink), string(f.Pair), f.Chain).Inc() + c.failureCount[f.Feed]++ + if isRevert(err) && c.failureCount[f.Feed] >= 3 { + c.unsupported[f.Feed] = true + fmt.Printf("[chainlink-%s/%s] feed %s marked unsupported after 3 reverts\n", f.Chain, f.Pair, f.Feed) + } + if err != nil && !isRevert(err) { + c.rpcIdx.Add(1) + } + fmt.Printf("[chainlink-%s/%s] err: %v (price=%v)\n", f.Chain, f.Pair, err, price) + continue + } + c.failureCount[f.Feed] = 0 + recordFreshness(string(SourceChainlink), f.Pair, f.Chain, time.Unix(updatedAt, 0)) + } + } + + tick() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + tick() + } + } +} + // read returns (price USD, updatedAt unix seconds, err). func (c *chainlinkPoller) read(ctx context.Context, feed string) (float64, int64, error) { // Fetch decimals first (cached). diff --git a/harnesses/oracle-deviation/cmd/script/config.go b/harnesses/oracle-deviation/cmd/script/config.go index db0f23b6..628e5b6a 100644 --- a/harnesses/oracle-deviation/cmd/script/config.go +++ b/harnesses/oracle-deviation/cmd/script/config.go @@ -83,6 +83,71 @@ func pairs() []PairSpec { } } +// ChainFeed wires one Chainlink AggregatorV3 deployment on a non- +// Ethereum chain to the freshness tracker (bench № 082). These feeds +// are freshness-only: they never enter the 025 deviation store (the +// price store is keyed by (pair, source), so an Arbitrum ETH/USD +// sample would silently overwrite the Ethereum one and change 025's +// deviation numbers). +type ChainFeed struct { + Pair Pair + Chain string // freshness `chain` label: arbitrum | base + Feed string // AggregatorV3 contract on that chain + RPCs []string +} + +// extraChainlinkFeeds lists the non-Ethereum Chainlink deployments the +// freshness bench tracks. Ethereum mainnet feeds are covered by the +// existing 025 poller (which now also feeds recordFreshness). +func extraChainlinkFeeds() []ChainFeed { + return []ChainFeed{ + { + Pair: "ETH/USD", + Chain: ChainArbitrum, + Feed: "0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612", + RPCs: []string{ + envDefault("ORACLE_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com"), + "https://arb1.arbitrum.io/rpc", + }, + }, + { + Pair: "ETH/USD", + Chain: ChainBase, + Feed: "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70", + RPCs: []string{ + envDefault("ORACLE_RPC_BASE", "https://base-rpc.publicnode.com"), + "https://mainnet.base.org", + }, + }, + } +} + +// RedstoneFeed maps a canonical OCB pair to RedStone's symbol on the +// public per-symbol price API. +type RedstoneFeed struct { + Pair Pair + Symbol string +} + +// redstoneFeeds is the freshness cohort for RedStone. ETH + BTC only +// in v1: the per-symbol endpoint costs one request per symbol per +// tick, and the bench's headline is ETH/USD. +func redstoneFeeds() []RedstoneFeed { + return []RedstoneFeed{ + {Pair: "ETH/USD", Symbol: "ETH"}, + {Pair: "BTC/USD", Symbol: "BTC"}, + } +} + +// redstoneBaseURL is RedStone's public per-symbol price API. The +// data-packages gateway payload for the whole redstone-primary-prod +// service is ~1.7 MB per request; the per-symbol endpoint returns a +// single small object with the signed package's value + timestamp, +// so we poll per symbol instead. +func redstoneBaseURL() string { + return envDefault("ORACLE_REDSTONE_URL", "https://api.redstone.finance/prices") +} + // rpcEndpoint is the Ethereum mainnet public RPC used by the // Chainlink poller to call latestRoundData() on the AggregatorV3 // contracts. Two free public endpoints are kept; the second is a diff --git a/harnesses/oracle-deviation/cmd/script/freshness.go b/harnesses/oracle-deviation/cmd/script/freshness.go new file mode 100644 index 00000000..1713f026 --- /dev/null +++ b/harnesses/oracle-deviation/cmd/script/freshness.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "math" + "sync" + "time" +) + +// Freshness tracking for OCB bench № 082 (oracle-freshness). +// +// Every freshness-capable poller calls recordFreshness with the +// source-declared update timestamp: +// +// - Chainlink: latestRoundData().updatedAt, per chain (ethereum, +// arbitrum, base). Push oracle: staleness in the multiple hundreds +// of seconds is EXPECTED in calm markets (deviation trigger + +// heartbeat mechanics), not a failure. +// - Pyth: Hermes publish_time. Pull oracle: Hermes IS the price +// source integrators pull from, so publish_time is the honest +// freshness measure (typically 1-2s). +// - RedStone: the signed data-package timestamp from the public +// per-symbol API (typically 15-25s; packages are produced on a +// ~10s cadence). +// +// Because "old" is not the same as "wrong" for a push oracle, the +// bench pairs raw staleness with a second signal: stale_but_moved, +// which only fires when the feed is old AND the market has left it +// behind. Thresholds: +// +// staleThresholdSeconds (300s): half of Chainlink's typical 1h +// heartbeat divided by 6 is arbitrary; 300s was picked because +// every observed healthy update gap on the measured feeds +// (deviation-triggered) lands under it EXCEPT genuinely quiet +// periods, and because 5 minutes of price lag is where perp/ +// lending liquidation math starts to hurt. +// staleMoveThresholdPct (0.5%): matches the widest deviation +// trigger configured on the measured Chainlink feeds. If the CEX +// print moved more than the feed's own trigger and the feed still +// hasn't updated, the feed is late by its own standard. +const ( + staleThresholdSeconds = 300.0 + staleMoveThresholdPct = 0.5 + // freshnessTickInterval refreshes the staleness gauges between + // polls so the gauge grows monotonically instead of stair-stepping + // on the 30s poll cadence. + freshnessTickInterval = 5 * time.Second +) + +// chain label values. For Chainlink the label is the chain the +// aggregator contract lives on. Pyth and RedStone are not read from a +// chain at all (pull-model oracles); their freshness source is named +// instead. +const ( + ChainEthereum = "ethereum" + ChainArbitrum = "arbitrum" + ChainBase = "base" + ChainHermes = "hermes" // Pyth Hermes publish_time + ChainGateway = "gateway" // RedStone public data gateway +) + +type freshKey struct { + oracle string + pair Pair + chain string +} + +type freshState struct { + // lastUpdate is the source-declared timestamp of the feed's most + // recent update (NOT our fetch time). + lastUpdate time.Time + // cexAtUpdate is the CEX reference price snapshotted when we first + // observed this update. 0 when no fresh CEX sample was available. + // Cold-start caveat: for a feed whose current round predates the + // harness boot, the snapshot is taken at boot, not at the round's + // true landing time; it converges on the first real update event. + cexAtUpdate float64 +} + +var ( + freshMu sync.Mutex + fresh = make(map[freshKey]freshState) +) + +// recordFreshness ingests one observation of a feed's own update +// timestamp. Increments the update-events counter when the timestamp +// moved forward vs the previous observation, snapshots the CEX +// reference at that moment, then republishes the gauges. +func recordFreshness(oracle string, pair Pair, chain string, sourceTS time.Time) { + if sourceTS.Unix() <= 0 { + // A zero/absent timestamp would read as ~56 years of staleness. + freshnessScrapeErrors.WithLabelValues(oracle, string(pair), chain).Inc() + return + } + key := freshKey{oracle: oracle, pair: pair, chain: chain} + freshMu.Lock() + st, seen := fresh[key] + if !seen || sourceTS.After(st.lastUpdate) { + if seen { + oracleUpdateEvents.WithLabelValues(oracle, string(pair), chain).Inc() + } + ref, _ := cexRefPrice(pair) + st = freshState{lastUpdate: sourceTS, cexAtUpdate: ref} + fresh[key] = st + } + freshMu.Unlock() + publishFreshness(key, st) +} + +// publishFreshness sets the staleness + stale_but_moved gauges for one +// (oracle, pair, chain) from its stored state. +func publishFreshness(key freshKey, st freshState) { + stale := time.Since(st.lastUpdate).Seconds() + if stale < 0 { + // Source clock marginally ahead of ours (Hermes publish_time + // can lead by sub-second). Clamp instead of publishing a + // negative age. + stale = 0 + } + oracleStalenessSeconds.WithLabelValues(key.oracle, string(key.pair), key.chain).Set(stale) + + moved := 0.0 + if stale > staleThresholdSeconds && st.cexAtUpdate > 0 { + if cur, ok := cexRefPrice(key.pair); ok { + movePct := math.Abs(cur-st.cexAtUpdate) / st.cexAtUpdate * 100 + if movePct > staleMoveThresholdPct { + moved = 1 + } + } + } + oracleStaleButMoved.WithLabelValues(key.oracle, string(key.pair), key.chain).Set(moved) +} + +// cexRefPrice returns the freshest CEX print for a pair from the +// existing 025 price store: Binance first, Coinbase as fallback, both +// subject to the same 2x-poll-interval staleness guard the deviation +// calc uses. ok=false when neither has a fresh sample. +func cexRefPrice(pair Pair) (float64, bool) { + storeMu.RLock() + defer storeMu.RUnlock() + srcMap := store[pair] + if srcMap == nil { + return 0, false + } + for _, src := range []Source{SourceBinance, SourceCoinbase} { + if p, ok := srcMap[src]; ok && time.Since(p.TS) <= 2*pollInterval { + return p.Value, true + } + } + return 0, false +} + +// runFreshnessUpdater republishes every tracked freshness gauge on a +// short cadence so staleness keeps climbing between the 30s polls (a +// Prometheus scrape landing mid-window sees the true age, not the age +// as of the last poll). +func runFreshnessUpdater(ctx context.Context) { + t := time.NewTicker(freshnessTickInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + freshMu.Lock() + snapshot := make(map[freshKey]freshState, len(fresh)) + for k, v := range fresh { + snapshot[k] = v + } + freshMu.Unlock() + for k, v := range snapshot { + publishFreshness(k, v) + } + } + } +} diff --git a/harnesses/oracle-deviation/cmd/script/main.go b/harnesses/oracle-deviation/cmd/script/main.go index c7b96cec..f3e9869c 100644 --- a/harnesses/oracle-deviation/cmd/script/main.go +++ b/harnesses/oracle-deviation/cmd/script/main.go @@ -320,6 +320,7 @@ func main() { installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== Oracle Deviation Harness ===") fmt.Println("OpenChainBench № 025 — 4 oracles × 10 pairs, max deviation gauge.") + fmt.Println("OpenChainBench № 082 — oracle freshness (chainlink eth/arb/base, pyth hermes, redstone gateway).") fmt.Println() specs := pairs() @@ -331,12 +332,16 @@ func main() { fmt.Println() fmt.Printf("RPC primary: %s\n", rpcEndpoint()) fmt.Printf("RPC fallback: %s\n", rpcEndpointFallback()) + // :2112 is the OCB convention (see top-of-file comment). The env + // override exists for LOCAL runs only, e.g. when another process + // already holds 2112 on a dev machine. Never set it in production. + metricsAddr := envDefault("ORACLE_METRICS_ADDR", ":2112") fmt.Printf("Poll cadence: %s per source per pair\n", pollInterval) - fmt.Println("Metrics server: :2112/metrics") + fmt.Printf("Metrics server: %s/metrics\n", metricsAddr) fmt.Println() go func() { - if err := StartMetricsServer(":2112"); err != nil { + if err := StartMetricsServer(metricsAddr); err != nil { fmt.Printf("[fatal] metrics server: %v\n", err) os.Exit(1) } @@ -351,6 +356,12 @@ func main() { go runCoinbase(ctx, specs) go runLatencyUpdater(ctx) + // № 082 freshness-only pollers. Kept out of the deviation store by + // construction (see ChainFeed / runRedstone docs). + go runChainlinkChains(ctx, extraChainlinkFeeds()) + go runRedstone(ctx, redstoneFeeds()) + go runFreshnessUpdater(ctx) + sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) s := <-sig diff --git a/harnesses/oracle-deviation/cmd/script/metrics.go b/harnesses/oracle-deviation/cmd/script/metrics.go index 9b47ca1b..b56f4ff3 100644 --- a/harnesses/oracle-deviation/cmd/script/metrics.go +++ b/harnesses/oracle-deviation/cmd/script/metrics.go @@ -106,6 +106,59 @@ var ( }, []string{"source", "pair"}, ) + + // --- Bench № 082 (oracle-freshness) metric families ------------- + // + // Deliberately NEW families instead of adding a `chain` label to + // the existing ones: bench 025's PromQL surface selects on + // {source, pair} and aggregates with count by (source); injecting + // a chain label there would split its series and silently change + // its numbers. Freshness gets its own label set: + // + // oracle: chainlink | pyth | redstone + // pair: canonical OCB pair (ETH/USD, BTC/USD, ...) + // chain: where the freshness timestamp lives. For Chainlink + // this is the chain the aggregator contract is deployed + // on (ethereum | arbitrum | base). Pyth is pull-based so + // its authoritative timestamp is the Hermes publish_time + // (chain="hermes"); RedStone's is the signed data-package + // timestamp from its public gateway (chain="gateway"). + + oracleStalenessSeconds = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "ocb_oracle_staleness_seconds", + Help: "Seconds between now and the oracle's own last-update timestamp (Chainlink latestRoundData.updatedAt per chain, Pyth Hermes publish_time, RedStone data-package timestamp). Refreshed every 5s between polls so staleness grows monotonically until the next observed update.", + }, + []string{"oracle", "pair", "chain"}, + ) + + oracleUpdateEvents = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "ocb_oracle_update_events_total", + Help: "Count of observed oracle updates: increments each time the source-declared update timestamp moves forward. Bounded below by reality and above by the 30s poll cadence (two updates inside one poll window count once).", + }, + []string{"oracle", "pair", "chain"}, + ) + + oracleStaleButMoved = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "ocb_oracle_stale_but_moved", + Help: "1 when the feed is older than 300s AND the CEX reference price (Binance, Coinbase fallback) has moved more than 0.5% since the update was observed, else 0. Staleness alone is normal for deviation-triggered feeds; stale AND moved is the dangerous state.", + }, + []string{"oracle", "pair", "chain"}, + ) + + // Separate error counter for the freshness pollers. NOT merged into + // ocb_oracle_scrape_errors_total because bench 025's success queries + // sum that counter across all sources per pair; redstone / L2 + // chainlink failures must not depress 025's success rate. + freshnessScrapeErrors = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "ocb_oracle_freshness_scrape_errors_total", + Help: "Failed freshness scrape attempts per (oracle, pair, chain). Kept separate from ocb_oracle_scrape_errors_total so bench 025's error-rate queries are unaffected by freshness-only sources.", + }, + []string{"oracle", "pair", "chain"}, + ) ) // StartMetricsServer binds /metrics + /health on addr. Blocking call — diff --git a/harnesses/oracle-deviation/cmd/script/pyth.go b/harnesses/oracle-deviation/cmd/script/pyth.go index ac4fd163..20e27206 100644 --- a/harnesses/oracle-deviation/cmd/script/pyth.go +++ b/harnesses/oracle-deviation/cmd/script/pyth.go @@ -56,6 +56,7 @@ func runPyth(ctx context.Context, specs []PairSpec) { if err != nil { for _, s := range specs { oracleScrapeErrors.WithLabelValues(string(SourcePyth), string(s.Pair)).Inc() + freshnessScrapeErrors.WithLabelValues(string(SourcePyth), string(s.Pair), ChainHermes).Inc() } fmt.Printf("[pyth] http err: %v\n", err) return @@ -65,6 +66,7 @@ func runPyth(ctx context.Context, specs []PairSpec) { if resp.StatusCode != 200 { for _, s := range specs { oracleScrapeErrors.WithLabelValues(string(SourcePyth), string(s.Pair)).Inc() + freshnessScrapeErrors.WithLabelValues(string(SourcePyth), string(s.Pair), ChainHermes).Inc() } fmt.Printf("[pyth] http %d: %s\n", resp.StatusCode, string(body)) return @@ -73,6 +75,7 @@ func runPyth(ctx context.Context, specs []PairSpec) { if err := json.Unmarshal(body, &entries); err != nil { for _, s := range specs { oracleScrapeErrors.WithLabelValues(string(SourcePyth), string(s.Pair)).Inc() + freshnessScrapeErrors.WithLabelValues(string(SourcePyth), string(s.Pair), ChainHermes).Inc() } fmt.Printf("[pyth] decode err: %v\n", err) return @@ -86,9 +89,24 @@ func runPyth(ctx context.Context, specs []PairSpec) { price, err := pythToFloat(e.Price.Price, e.Price.Expo) if err != nil { oracleScrapeErrors.WithLabelValues(string(SourcePyth), string(pair)).Inc() + freshnessScrapeErrors.WithLabelValues(string(SourcePyth), string(pair), ChainHermes).Inc() continue } - recordPrice(SourcePyth, pair, price) + // Bug fix: Hermes attaches publish_time to every price and + // we used to discard it (recordPrice stamped fetch time as + // SourceTS). Pyth is a pull oracle, so publish_time IS the + // authoritative freshness of the price integrators pull. + // Use it as SourceTS (typically 1-2s behind fetch, well + // inside the 10s alignment tolerance, so the 025 aligned + // deviation is unaffected in practice) and feed the № 082 + // freshness tracker from it. + if e.Price.PublishTime > 0 { + srcTS := time.Unix(e.Price.PublishTime, 0) + recordPriceAt(SourcePyth, pair, price, srcTS) + recordFreshness(string(SourcePyth), pair, ChainHermes, srcTS) + } else { + recordPrice(SourcePyth, pair, price) + } got[pair] = true } // Mark missing pairs as errored so the deviation map doesn't @@ -96,6 +114,7 @@ func runPyth(ctx context.Context, specs []PairSpec) { for _, s := range specs { if !got[s.Pair] { oracleScrapeErrors.WithLabelValues(string(SourcePyth), string(s.Pair)).Inc() + freshnessScrapeErrors.WithLabelValues(string(SourcePyth), string(s.Pair), ChainHermes).Inc() } } } diff --git a/harnesses/oracle-deviation/cmd/script/redstone.go b/harnesses/oracle-deviation/cmd/script/redstone.go new file mode 100644 index 00000000..a91930fb --- /dev/null +++ b/harnesses/oracle-deviation/cmd/script/redstone.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// RedStone freshness poller (bench № 082 only). RedStone is a +// modular oracle: signed data packages are produced off-chain on a +// ~10s cadence and delivered on-chain either by relayers (push +// model) or attached to transactions by the integrator (pull model, +// "core"). The freshest public view of the primary production +// service is the keyless per-symbol price API, which returns the +// latest signed package's value and timestamp. We deliberately avoid +// the data-packages gateway's full-service payload (~1.7 MB per +// request for redstone-primary-prod) and poll one small per-symbol +// object per pair instead. +// +// RedStone samples feed ONLY the freshness metrics. They never enter +// the 025 deviation store: bench 025 is a fixed 4-source matrix +// (chainlink/pyth/binance/coinbase) and its queries count sources per +// pair, so adding a 5th source would silently change its numbers. + +type redstonePrice struct { + Symbol string `json:"symbol"` + Value float64 `json:"value"` + Timestamp int64 `json:"timestamp"` // unix milliseconds +} + +func runRedstone(ctx context.Context, feeds []RedstoneFeed) { + client := &http.Client{Timeout: httpTimeout} + t := time.NewTicker(pollInterval) + defer t.Stop() + + tick := func() { + for _, f := range feeds { + pollCtx, cancel := context.WithTimeout(ctx, httpTimeout) + ts, err := fetchRedstone(pollCtx, client, f.Symbol) + cancel() + if err != nil { + freshnessScrapeErrors.WithLabelValues("redstone", string(f.Pair), ChainGateway).Inc() + fmt.Printf("[redstone/%s] err: %v\n", f.Pair, err) + continue + } + recordFreshness("redstone", f.Pair, ChainGateway, ts) + } + } + + tick() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + tick() + } + } +} + +// fetchRedstone returns the timestamp of the latest signed data +// package for one symbol. GET /prices?symbol=&provider=redstone&limit=1 +// answers with a one-element array; timestamp is unix ms. +func fetchRedstone(ctx context.Context, client *http.Client, symbol string) (time.Time, error) { + u := redstoneBaseURL() + "?symbol=" + url.QueryEscape(symbol) + "&provider=redstone&limit=1" + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) + if err != nil { + return time.Time{}, err + } + req.Header.Set("User-Agent", "openchainbench-oracle-freshness/1.0") + resp, err := client.Do(req) + if err != nil { + return time.Time{}, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return time.Time{}, fmt.Errorf("http %d: %s", resp.StatusCode, truncate(string(body), 200)) + } + var arr []redstonePrice + if err := json.Unmarshal(body, &arr); err != nil { + return time.Time{}, fmt.Errorf("decode: %w", err) + } + if len(arr) == 0 { + return time.Time{}, fmt.Errorf("empty result for %s", symbol) + } + p := arr[0] + if p.Value <= 0 { + return time.Time{}, fmt.Errorf("non-positive value %v for %s", p.Value, symbol) + } + if p.Timestamp <= 0 { + return time.Time{}, fmt.Errorf("missing timestamp for %s", symbol) + } + return time.UnixMilli(p.Timestamp), nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/harnesses/robinhood-subsidy/Dockerfile b/harnesses/robinhood-subsidy/Dockerfile new file mode 100644 index 00000000..63108cfc --- /dev/null +++ b/harnesses/robinhood-subsidy/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/robinhood-subsidy/cmd/script/loghub.go b/harnesses/robinhood-subsidy/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/robinhood-subsidy/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/robinhood-subsidy/cmd/script/main.go b/harnesses/robinhood-subsidy/cmd/script/main.go new file mode 100644 index 00000000..6169fbdb --- /dev/null +++ b/harnesses/robinhood-subsidy/cmd/script/main.go @@ -0,0 +1,315 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Robinhood Chain gas subsidy tracker. +// +// Robinhood Chain launched July 1, 2026 with a 90-day promotion where +// users pay $0 in gas because Robinhood covers the sequencer cost. +// The window closes September 29, 2026 (public wording: "cover gas +// fees for eligible users for the first 90 days"; math from mainnet +// launch confirms end-of-Q3). +// +// The bench publishes two things live: +// +// 1. Countdown: days remaining until the subsidy ends, and the +// fraction of the 90-day window already elapsed. +// 2. Estimated chain-side gas cost that Robinhood is paying to the +// sequencer since launch. Deliberately labeled "chain-side" and +// not "user fees": growthepie's fees_paid_by_users is ~$0 +// during the subsidy (because users literally pay $0), so a +// number in the $millions would contradict them if mislabeled. +// This bench measures what Robinhood, not the user, is paying. +// +// Data leg: Blockscout's /api/v2/stats/charts/transactions (daily +// tx count from launch, keyless) + /api/v2/stats (today's gas_used, +// current gas_prices tier, ETH price). The historical gas-used chart +// doesn't exist on this Blockscout instance (verified 400 on +// /api/v2/stats/charts/gas-used), so per-day gas is estimated from +// daily tx count multiplied by the current per-tx gas draw +// (gas_used_today / transactions_today), a stable ratio on an Orbit +// with a fixed-cost transaction mix. + +const ( + launchDate = "2026-07-01T00:00:00Z" + subsidyEndDate = "2026-09-29T23:59:59Z" + + pollInterval = 1 * time.Hour + httpTimeout = 20 * time.Second + + blockscoutBase = "https://robinhoodchain.blockscout.com" + robinhoodRPC = "https://rpc.mainnet.chain.robinhood.com" +) + +var ( + daysRemaining = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_days_remaining", + Help: "Days until the 90-day gas subsidy window ends on 2026-09-29 (negative once expired).", + }) + + windowFractionElapsed = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_window_fraction_elapsed", + Help: "Share of the 90-day subsidy window elapsed (0 to 1).", + }) + + chainSideCostTotalUSD = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_chainside_cost_total_usd", + Help: "Estimated cumulative USD chain-side gas cost since launch, i.e. what Robinhood is paying the sequencer while users pay $0. NOT growthepie's fees_paid_by_users (~$0 during the subsidy by design).", + }) + + chainSideCostTodayUSD = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_chainside_cost_today_usd", + Help: "Today's estimated chain-side gas cost (gas_used_today x average base fee x ETH price).", + }) + + projectedTotalUSD = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_projected_total_usd", + Help: "Linear projection: cumulative_cost x (90 / days_elapsed). Where chain-side gas cost lands if today's daily rate holds through Sep 29.", + }) + + dailyTxCountGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_daily_tx_count", + Help: "Daily transaction count on Robinhood Chain since launch (Blockscout charts/transactions).", + }, []string{"date"}) + + baseFeeGweiGauge = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_base_fee_gwei", + Help: "Current base fee on Robinhood Chain in gwei (eth_gasPrice / 1e9).", + }) + + ethPriceGauge = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "robinhood_subsidy_eth_price_usd", + Help: "ETH USD spot as reported by the chain's Blockscout instance.", + }) + + sourceCall = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "robinhood_subsidy_source_call_total", + Help: "Fetch outcomes per source.", + }, []string{"source", "result"}) +) + +func envDefault(k, def string) string { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + return v + } + return def +} + +func main() { + installLogCapture() + fmt.Println("=== Robinhood Chain Gas Subsidy Tracker ===") + fmt.Printf("Launch: %s | Subsidy ends: %s\n", launchDate, subsidyEndDate) + + go func() { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + if err := http.ListenAndServe(envDefault("LISTEN_ADDR", ":2112"), mux); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + } + }() + + client := &http.Client{Timeout: httpTimeout} + tick := func() { + now := time.Now().UTC() + launch, _ := time.Parse(time.RFC3339, launchDate) + end, _ := time.Parse(time.RFC3339, subsidyEndDate) + + daysLeft := end.Sub(now).Hours() / 24 + daysRemaining.Set(daysLeft) + totalWindow := end.Sub(launch).Hours() / 24 + elapsed := now.Sub(launch).Hours() / 24 + frac := math.Max(0, math.Min(1, elapsed/totalWindow)) + windowFractionElapsed.Set(frac) + + baseFeeGwei, ok := fetchBaseFeeGwei(client) + if !ok { + // Orbit floor observed since launch: ~0.05 gwei. Safe fallback + // so a transient RPC blip doesn't null the burn number. + baseFeeGwei = 0.05 + } + baseFeeGweiGauge.Set(baseFeeGwei) + + stats, ok := fetchBlockscoutStats(client) + if !ok || stats.CoinPrice <= 0 { + return + } + ethPriceGauge.Set(stats.CoinPrice) + + daily := fetchTransactionsChart(client) + if daily == nil { + return + } + + // Per-tx gas draw derived from today's Blockscout stats. The + // mix of tokenized-stock swaps + native transfers is stable + // on this chain, so gas_used_today / transactions_today is a + // clean proxy for historical days. + perTxGas := 0.0 + if stats.TxsToday > 0 { + perTxGas = float64(stats.GasUsedToday) / float64(stats.TxsToday) + } + if perTxGas <= 0 { + perTxGas = 150_000 // conservative fallback if stats missed + } + + // Cumulative cost: sum(daily_tx × per_tx_gas × base_fee × ETH). + total := 0.0 + for _, d := range daily { + gasUnits := float64(d.value) * perTxGas + eth := gasUnits * baseFeeGwei / 1e9 + total += eth * stats.CoinPrice + dailyTxCountGauge.WithLabelValues(d.date).Set(float64(d.value)) + } + chainSideCostTotalUSD.Set(total) + + todayCost := float64(stats.GasUsedToday) * baseFeeGwei / 1e9 * stats.CoinPrice + chainSideCostTodayUSD.Set(todayCost) + + if elapsed > 0.5 { + projectedTotalUSD.Set(total * (totalWindow / elapsed)) + } + + fmt.Printf("[tick] days_left=%.1f cost_total=$%.0f today=$%.0f eth=$%.2f base_fee=%.4fgwei per_tx_gas=%.0f\n", + daysLeft, total, todayCost, stats.CoinPrice, baseFeeGwei, perTxGas) + } + + tick() + t := time.NewTicker(pollInterval) + defer t.Stop() + for range t.C { + tick() + } +} + +// fetchBaseFeeGwei calls eth_gasPrice on the Robinhood RPC. +func fetchBaseFeeGwei(client *http.Client) (float64, bool) { + body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"eth_gasPrice","params":[]}`) + req, _ := http.NewRequest("POST", robinhoodRPC, body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + resp, err := client.Do(req) + if err != nil { + sourceCall.WithLabelValues("rpc", "network").Inc() + return 0, false + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if resp.StatusCode != 200 { + sourceCall.WithLabelValues("rpc", fmt.Sprintf("http_%d", resp.StatusCode)).Inc() + return 0, false + } + var env struct { + Result string `json:"result"` + } + if err := json.Unmarshal(raw, &env); err != nil || env.Result == "" { + sourceCall.WithLabelValues("rpc", "parse").Inc() + return 0, false + } + n, err := strconv.ParseUint(strings.TrimPrefix(env.Result, "0x"), 16, 64) + if err != nil { + sourceCall.WithLabelValues("rpc", "decode").Inc() + return 0, false + } + sourceCall.WithLabelValues("rpc", "ok").Inc() + return float64(n) / 1e9, true +} + +type stats struct { + CoinPrice float64 + GasUsedToday uint64 + TxsToday uint64 +} + +func fetchBlockscoutStats(client *http.Client) (stats, bool) { + raw, ok := getKeyless(client, blockscoutBase+"/api/v2/stats", "blockscout_stats") + if !ok { + return stats{}, false + } + var env struct { + GasUsedToday string `json:"gas_used_today"` + CoinPrice string `json:"coin_price"` + TransactionsToday string `json:"transactions_today"` + } + if err := json.Unmarshal(raw, &env); err != nil { + sourceCall.WithLabelValues("blockscout_stats", "parse").Inc() + return stats{}, false + } + price, _ := strconv.ParseFloat(env.CoinPrice, 64) + gas, _ := strconv.ParseUint(env.GasUsedToday, 10, 64) + txs, _ := strconv.ParseUint(env.TransactionsToday, 10, 64) + return stats{CoinPrice: price, GasUsedToday: gas, TxsToday: txs}, true +} + +type dailyPoint struct { + date string + value uint64 +} + +// fetchTransactionsChart is Blockscout's chart of daily tx count. +// Verified 2026-07-14 shape: {"chart_data":[{"date":"YYYY-MM-DD","transactions_count":N},...]} +func fetchTransactionsChart(client *http.Client) []dailyPoint { + raw, ok := getKeyless(client, blockscoutBase+"/api/v2/stats/charts/transactions", "blockscout_chart") + if !ok { + return nil + } + var env struct { + ChartData []struct { + Date string `json:"date"` + Count uint64 `json:"transactions_count"` + } `json:"chart_data"` + } + if err := json.Unmarshal(raw, &env); err != nil || len(env.ChartData) == 0 { + sourceCall.WithLabelValues("blockscout_chart", "parse").Inc() + return nil + } + out := make([]dailyPoint, 0, len(env.ChartData)) + for _, p := range env.ChartData { + out = append(out, dailyPoint{date: p.Date, value: p.Count}) + } + return out +} + +func getKeyless(client *http.Client, url, tag string) ([]byte, bool) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, false + } + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + sourceCall.WithLabelValues(tag, "network").Inc() + return nil, false + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<22)) + if err != nil { + sourceCall.WithLabelValues(tag, "read").Inc() + return nil, false + } + if resp.StatusCode != 200 { + sourceCall.WithLabelValues(tag, fmt.Sprintf("http_%d", resp.StatusCode)).Inc() + return nil, false + } + sourceCall.WithLabelValues(tag, "ok").Inc() + return raw, true +} diff --git a/harnesses/robinhood-subsidy/go.mod b/harnesses/robinhood-subsidy/go.mod new file mode 100644 index 00000000..0fe5c37c --- /dev/null +++ b/harnesses/robinhood-subsidy/go.mod @@ -0,0 +1,18 @@ +module robinhood-subsidy + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/robinhood-subsidy/go.sum b/harnesses/robinhood-subsidy/go.sum new file mode 100644 index 00000000..d6b8ca98 --- /dev/null +++ b/harnesses/robinhood-subsidy/go.sum @@ -0,0 +1,46 @@ +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/token-deployment-cost/cmd/script/cosmos_cardano_stellar.go b/harnesses/token-deployment-cost/cmd/script/cosmos_cardano_stellar.go index 1aa7c5f6..061ae2fa 100644 --- a/harnesses/token-deployment-cost/cmd/script/cosmos_cardano_stellar.go +++ b/harnesses/token-deployment-cost/cmd/script/cosmos_cardano_stellar.go @@ -175,7 +175,18 @@ type koiosEpochParams []struct { CoinsPerUtxoSize json.Number `json:"coins_per_utxo_size"` } +// Cached for an hour: epoch params move once per 5-day epoch and the +// shared koios public tier (5k/day per IP) is also consumed by the +// transaction-fee harness on this host. +var cardanoCostCache struct { + sample Sample + fetchedAt time.Time +} + func (s *cardanoSampler) Sample(ch ChainConfig) (Sample, error) { + if time.Since(cardanoCostCache.fetchedAt) < time.Hour && cardanoCostCache.sample.CostNative > 0 { + return cardanoCostCache.sample, nil + } resp, err := s.http.Get(ch.RPCURL + "/epoch_params") if err != nil { return Sample{}, err @@ -200,13 +211,16 @@ func (s *cardanoSampler) Sample(ch ChainConfig) (Sample, error) { } utxoSize := float64(cardanoEntryOverheadBytes + cardanoBundleBytes) lovelace := coins*utxoSize + cardanoMintTxFeeLovelace - return Sample{CostNative: lovelace, NativeUnit: "lovelace", GasUnits: math.NaN()}, nil + out := Sample{CostNative: lovelace, NativeUnit: "lovelace", GasUnits: math.NaN()} + cardanoCostCache.sample = out + cardanoCostCache.fetchedAt = time.Now() + return out, nil } // ----- Stellar ---------------------------------------------------------- -// Stellar custom asset cost is 3 base reserves locked (issuer account + -// distribution account + trustline on distribution) + 2 tx fees +// Stellar custom asset cost is 5 base reserves locked (2 per new +// account for issuer + distribution, 1 for the trustline) + 2 tx fees // (create_account + change_trust). base_reserve_in_stroops and // base_fee_in_stroops are in /ledgers — we use the latest ledger. // Reusing the issuer as the distribution account is an anti-pattern @@ -240,8 +254,11 @@ func (s *stellarSampler) Sample(ch ChainConfig) (Sample, error) { return Sample{}, fmt.Errorf("empty ledgers response") } rec := l.Embedded.Records[0] - // 3 × base_reserve (issuer + distribution + trustline) + 2 × base_fee - // (create_account + change_trust). - stroops := 3*rec.BaseReserve + 2*rec.BaseFee + // 5 × base_reserve + 2 × base_fee (create_account + change_trust). + // Protocol minimums: every new account locks 2 base reserves and + // each trustline adds 1, so issuer (2) + distribution (2) + + // trustline on distribution (1) = 5. The previous formula counted + // 3 and understated the locked capital by a full XLM. + stroops := 5*rec.BaseReserve + 2*rec.BaseFee return Sample{CostNative: float64(stroops), NativeUnit: "stroop", GasUnits: math.NaN()}, nil } diff --git a/harnesses/token-quote-coverage/.env.example b/harnesses/token-quote-coverage/.env.example new file mode 100644 index 00000000..21e98e19 --- /dev/null +++ b/harnesses/token-quote-coverage/.env.example @@ -0,0 +1,3 @@ +MONITOR_REGION=eu-west +MOBULA_API_KEY=your_key +LOGS_TOKEN=optional_for_logs_endpoint diff --git a/harnesses/token-quote-coverage/Dockerfile b/harnesses/token-quote-coverage/Dockerfile new file mode 100644 index 00000000..979ad597 --- /dev/null +++ b/harnesses/token-quote-coverage/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd ./cmd +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/monitor ./cmd/monitor + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates && update-ca-certificates +WORKDIR /app +COPY --from=build /out/monitor /app/monitor +EXPOSE 2112 +ENTRYPOINT ["/app/monitor"] diff --git a/harnesses/token-quote-coverage/README.md b/harnesses/token-quote-coverage/README.md new file mode 100644 index 00000000..840ae544 --- /dev/null +++ b/harnesses/token-quote-coverage/README.md @@ -0,0 +1,66 @@ +# Harness · token-quote-coverage + +> Go harness for bench № 102. Measures the share of newly-launched tokens each DEX aggregator can return a valid swap quote for, sampled from Dexscreener token-boosts across Solana, Base and BNB. + +**Bench**: [№ 102 · Token quote coverage](../../benchmarks/token-quote-coverage.yml) + +**Providers**: Mobula · Jupiter (Solana) · KyberSwap (EVM) · 1inch (EVM) · OKX DEX (multi-chain) · Odos (EVM) + +## How it works + +Every 60 minutes: + +1. Fetch up to 30 recently-active tokens from Dexscreener `/token-boosts/latest/v1`. +2. Batch-resolve originating DEX via `/latest/dex/tokens/{addresses}` to assign a `venue` label (pump-fun, virtuals, four-meme, etc.). +3. Fire a `1 USDC → token` quote at every provider that supports the token's chain, with a 10 s timeout per probe. +4. Record hit (`outAmount > 0`) or miss into `token_quote_coverage_success_total` / `token_quote_coverage_attempts_total` counters with `{provider, venue, chain}` labels. +5. Expose counters at `:2112/metrics` for Prometheus scrape. + +The bench YAML computes `increase(success[24h]) / increase(attempts[24h]) * 100` live at read time. + +## Metrics produced + +| Metric | Labels | Description | +| --- | --- | --- | +| `token_quote_coverage_success_total` | `provider, venue, chain` | Quote probes that returned outAmount > 0 | +| `token_quote_coverage_attempts_total` | `provider, venue, chain` | All quote probe attempts | + +## Run locally + +```bash +cp .env.example .env +# Fill in MOBULA_API_KEY and ONEINCH_API_KEY at minimum +go run ./cmd/monitor/ +``` + +Metrics will be at `http://localhost:2112/metrics`. No transactions, no wallets needed. + +## Environment variables + +| Var | Required | Notes | +| --- | --- | --- | +| `MOBULA_API_KEY` | yes | `Authorization: ` header | +| `ONEINCH_API_KEY` | yes | `Authorization: Bearer ` header | +| `OKX_DEX_API_KEY` | no | Optional; public rate limits apply without it | +| `MONITOR_REGION` | no | Metric label, default `eu-west` | +| `LOGS_TOKEN` | no | Enables `/logs?tail=N` debug endpoint | + +## Docker + +```bash +docker build -t token-quote-coverage . +docker run --rm --env-file .env -p 2112:2112 token-quote-coverage +``` + +## Provider chain scope + +| Provider | Solana | Base | BNB | +| --- | --- | --- | --- | +| Mobula | yes | yes | yes | +| Jupiter | yes | no | no | +| KyberSwap | no | yes | yes | +| 1inch | no | yes | yes | +| OKX DEX | yes | yes | yes | +| Odos | no | yes | yes | + +Providers are never probed on unsupported chains; those cells show `null` on the bench leaderboard, not zero. diff --git a/harnesses/token-quote-coverage/cmd/monitor/config.go b/harnesses/token-quote-coverage/cmd/monitor/config.go new file mode 100644 index 00000000..b66ba57a --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/config.go @@ -0,0 +1,58 @@ +package main + +import ( + "bufio" + "errors" + "os" + "strings" +) + +// Config holds runtime configuration for the token-quote-coverage harness. +type Config struct { + MonitorRegion string + LogsToken string + MobulaAPIKey string +} + +func loadEnv() (*Config, error) { + loadDotEnv() + + c := &Config{ + MonitorRegion: strings.TrimSpace(os.Getenv("MONITOR_REGION")), + LogsToken: strings.TrimSpace(os.Getenv("LOGS_TOKEN")), + MobulaAPIKey: strings.TrimSpace(os.Getenv("MOBULA_API_KEY")), + } + if c.MonitorRegion == "" { + c.MonitorRegion = "eu-west" + } + if c.MobulaAPIKey == "" { + return nil, errors.New("MOBULA_API_KEY is required") + } + return c, nil +} + +// loadDotEnv loads a .env file from cwd if present (local dev convenience). +func loadDotEnv() { + f, err := os.Open(".env") + if err != nil { + return + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.Index(line, "=") + if idx < 0 { + continue + } + k := strings.TrimSpace(line[:idx]) + v := strings.TrimSpace(line[idx+1:]) + v = strings.Trim(v, `"'`) + if os.Getenv(k) == "" { + os.Setenv(k, v) + } + } +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/dexscreener.go b/harnesses/token-quote-coverage/cmd/monitor/dexscreener.go new file mode 100644 index 00000000..4e6258da --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/dexscreener.go @@ -0,0 +1,154 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +var dexClient = &http.Client{Timeout: 15 * time.Second} + +// boostEntry is one item from /token-boosts/latest/v1 or a discovery source. +// Venue is pre-assigned when the source already knows the launchpad (e.g. Virtuals API). +// An empty Venue is resolved via Dexscreener pair enrichment. +type boostEntry struct { + ChainId string `json:"chainId"` + TokenAddress string `json:"tokenAddress"` + Venue string // optional: pre-assigned venue slug +} + +// FetchBoostedTokens calls the Dexscreener token-boosts endpoint and returns +// up to 30 tokens filtered to chains we support (solana, base, bsc). +func FetchBoostedTokens(ctx context.Context) ([]boostEntry, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.dexscreener.com/token-boosts/latest/v1", nil) + if err != nil { + return nil, err + } + resp, err := dexClient.Do(req) + if err != nil { + return nil, fmt.Errorf("dexscreener boosts: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("dexscreener boosts: status=%d body=%s", resp.StatusCode, snippet(body)) + } + var entries []boostEntry + if err := json.Unmarshal(body, &entries); err != nil { + return nil, fmt.Errorf("dexscreener boosts parse: %w", err) + } + + supported := map[string]bool{"solana": true, "base": true, "bsc": true, "robinhood": true} + var out []boostEntry + seen := map[string]bool{} + for _, e := range entries { + if !supported[e.ChainId] { + continue + } + key := e.ChainId + ":" + strings.ToLower(e.TokenAddress) + if seen[key] { + continue + } + seen[key] = true + out = append(out, e) + if len(out) >= 30 { + break + } + } + return out, nil +} + +// pairResp is the minimal shape of /latest/dex/tokens/{addresses}. +type pairResp struct { + Pairs []struct { + ChainId string `json:"chainId"` + DexId string `json:"dexId"` + BaseToken struct { + Address string `json:"address"` + } `json:"baseToken"` + QuoteToken struct { + Address string `json:"address"` + } `json:"quoteToken"` + } `json:"pairs"` +} + +const dexscreenerBatchSize = 30 + +// EnrichWithVenue resolves pair data for all entries in batches of 30 +// (Dexscreener API limit) and returns a Token slice with Venue populated. +// Tokens with no pair data are skipped. +func EnrichWithVenue(ctx context.Context, entries []boostEntry) ([]Token, error) { + if len(entries) == 0 { + return nil, nil + } + + type key struct{ chain, addr string } + dexIdOf := map[key]string{} + + for i := 0; i < len(entries); i += dexscreenerBatchSize { + end := i + dexscreenerBatchSize + if end > len(entries) { + end = len(entries) + } + batch := entries[i:end] + + addrs := make([]string, len(batch)) + for j, e := range batch { + addrs[j] = e.TokenAddress + } + url := "https://api.dexscreener.com/latest/dex/tokens/" + strings.Join(addrs, ",") + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + resp, err := dexClient.Do(req) + if err != nil { + return nil, fmt.Errorf("dexscreener tokens: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != 200 { + fmt.Printf("[dexscreener] batch %d-%d status=%d, skipping batch\n", i, end, resp.StatusCode) + continue + } + + var pr pairResp + if err := json.Unmarshal(body, &pr); err != nil { + fmt.Printf("[dexscreener] batch %d-%d parse error: %v\n", i, end, err) + continue + } + + for _, pair := range pr.Pairs { + k := key{pair.ChainId, strings.ToLower(pair.BaseToken.Address)} + if _, exists := dexIdOf[k]; !exists { + dexIdOf[k] = pair.DexId + } + } + } + + var tokens []Token + for _, e := range entries { + if e.Venue != "" { + // Venue pre-assigned by discovery source; skip Dexscreener lookup. + tokens = append(tokens, Token{Address: e.TokenAddress, Chain: e.ChainId, Venue: e.Venue}) + continue + } + k := key{e.ChainId, strings.ToLower(e.TokenAddress)} + dexId, ok := dexIdOf[k] + if !ok { + continue + } + tokens = append(tokens, Token{ + Address: e.TokenAddress, + Chain: e.ChainId, + Venue: venueFromPair(e.ChainId, dexId), + }) + } + return tokens, nil +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/gecko.go b/harnesses/token-quote-coverage/cmd/monitor/gecko.go new file mode 100644 index 00000000..5c8b7546 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/gecko.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// FetchNewBSCTokens returns base tokens from recently created pools on BSC +// via GeckoTerminal. Used as fallback since Four.meme API is not public. +func FetchNewBSCTokens(ctx context.Context) ([]boostEntry, error) { + req, err := http.NewRequestWithContext(ctx, "GET", + "https://api.geckoterminal.com/api/v2/networks/bsc/new_pools?page=1", + nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + resp, err := dexClient.Do(req) + if err != nil { + return nil, fmt.Errorf("geckoterminal bsc: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("geckoterminal bsc: status=%d", resp.StatusCode) + } + + var result struct { + Data []struct { + Relationships struct { + BaseToken struct { + Data struct { + ID string `json:"id"` // "bsc_0x..." + } `json:"data"` + } `json:"base_token"` + } `json:"relationships"` + } `json:"data"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("geckoterminal bsc parse: %w", err) + } + + var out []boostEntry + seen := map[string]bool{} + for _, pool := range result.Data { + id := pool.Relationships.BaseToken.Data.ID + if !strings.HasPrefix(id, "bsc_") { + continue + } + addr := id[4:] + k := strings.ToLower(addr) + if seen[k] { + continue + } + seen[k] = true + out = append(out, boostEntry{ChainId: "bsc", TokenAddress: addr}) + if len(out) >= 20 { + break + } + } + return out, nil +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/helpers.go b/harnesses/token-quote-coverage/cmd/monitor/helpers.go new file mode 100644 index 00000000..f8ea0365 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/helpers.go @@ -0,0 +1,38 @@ +package main + +import ( + "errors" + "net" + "strings" +) + +// classifyNetErr buckets transport errors into a small fixed set for logging. +func classifyNetErr(err error) string { + if err == nil { + return "none" + } + s := strings.ToLower(err.Error()) + switch { + case strings.Contains(s, "context deadline exceeded") || strings.Contains(s, "timeout"): + return "timeout" + case strings.Contains(s, "eof") || strings.Contains(s, "reset"): + return "conn_drop" + case strings.Contains(s, "no such host") || strings.Contains(s, "lookup"): + return "dns" + } + var ne net.Error + if errors.As(err, &ne) { + if ne.Timeout() { + return "timeout" + } + } + return "network" +} + +// snippet returns at most 200 bytes of a response body for logging. +func snippet(b []byte) string { + if len(b) > 200 { + return string(b[:200]) + } + return string(b) +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/http_client.go b/harnesses/token-quote-coverage/cmd/monitor/http_client.go new file mode 100644 index 00000000..3a0963c5 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/http_client.go @@ -0,0 +1,28 @@ +package main + +import ( + "net" + "net/http" + "time" +) + +// newWarmHTTPClient returns an http.Client wired for connection reuse. +// Per-provider timeout is enforced via context, not the client Timeout field, +// so the client can be shared across multiple concurrent requests. +func newWarmHTTPClient() *http.Client { + return &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 10, + MaxIdleConnsPerHost: 3, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ForceAttemptHTTP2: true, + }, + } +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/jupiter.go b/harnesses/token-quote-coverage/cmd/monitor/jupiter.go new file mode 100644 index 00000000..0f0197e2 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/jupiter.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" +) + +const ( + jupiterUSDCMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" +) + +// JupiterProvider hits lite-api.jup.ag/swap/v1/quote for Solana tokens. +// quote-api.jup.ag is deprecated (NXDOMAIN). lite-api is the current public endpoint. +type JupiterProvider struct { + client *http.Client +} + +func NewJupiterProvider() *JupiterProvider { + return &JupiterProvider{client: newWarmHTTPClient()} +} + +func (p *JupiterProvider) Slug() string { return "jupiter" } + +func (p *JupiterProvider) SupportsChain(chain string) bool { + return chain == "solana" +} + +func (p *JupiterProvider) Quote(ctx context.Context, token Token) (ok bool) { + q := url.Values{} + q.Set("inputMint", jupiterUSDCMint) + q.Set("outputMint", token.Address) + q.Set("amount", "1000000") + q.Set("slippageBps", "5000") + endpoint := "https://lite-api.jup.ag/swap/v1/quote?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + resp, err := p.client.Do(req) + if err != nil { + fmt.Printf("[jupiter] %s/%s net error: %s\n", token.Chain, token.Address, classifyNetErr(err)) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + fmt.Printf("[jupiter] %s/%s status=%d body=%s\n", token.Chain, token.Address, resp.StatusCode, snippet(body)) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + var r struct { + OutAmount string `json:"outAmount"` + } + if err := json.Unmarshal(body, &r); err != nil { + fmt.Printf("[jupiter] %s/%s parse error: %v\n", token.Chain, token.Address, err) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + n, _ := strconv.ParseInt(r.OutAmount, 10, 64) + ok = n > 0 + RecordProbe(p.Slug(), token.Venue, token.Chain, ok) + return ok +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/kyberswap.go b/harnesses/token-quote-coverage/cmd/monitor/kyberswap.go new file mode 100644 index 00000000..fdae51b9 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/kyberswap.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const ( + kyberUSDCBase = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + kyberUSDCBSC = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" + kyberUSDGRobinhood = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168" +) + +// KyberSwapProvider hits aggregator-api.kyberswap.com for Base and BSC. +type KyberSwapProvider struct { + client *http.Client +} + +func NewKyberSwapProvider() *KyberSwapProvider { + return &KyberSwapProvider{client: newWarmHTTPClient()} +} + +func (p *KyberSwapProvider) Slug() string { return "kyberswap" } + +func (p *KyberSwapProvider) SupportsChain(chain string) bool { + return chain == "base" || chain == "bsc" || chain == "robinhood" +} + +func (p *KyberSwapProvider) Quote(ctx context.Context, token Token) (ok bool) { + var slug, usdcAddr string + switch token.Chain { + case "base": + slug = "base" + usdcAddr = kyberUSDCBase + case "bsc": + slug = "bsc" + usdcAddr = kyberUSDCBSC + case "robinhood": + slug = "robinhood" + usdcAddr = kyberUSDGRobinhood + default: + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + q := url.Values{} + q.Set("tokenIn", usdcAddr) + q.Set("tokenOut", token.Address) + q.Set("amountIn", "1000000") + q.Set("saveGas", "false") + endpoint := fmt.Sprintf("https://aggregator-api.kyberswap.com/%s/api/v1/routes?%s", slug, q.Encode()) + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + req.Header.Set("x-client-id", "openchainbench") + + resp, err := p.client.Do(req) + if err != nil { + fmt.Printf("[kyberswap] %s/%s net error: %s\n", token.Chain, token.Address, classifyNetErr(err)) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + fmt.Printf("[kyberswap] %s/%s status=%d body=%s\n", token.Chain, token.Address, resp.StatusCode, snippet(body)) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + var r struct { + Data struct { + RouteSummary struct { + AmountOut string `json:"amountOut"` + } `json:"routeSummary"` + } `json:"data"` + } + if err := json.Unmarshal(body, &r); err != nil { + fmt.Printf("[kyberswap] %s/%s parse error: %v\n", token.Chain, token.Address, err) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + ok = r.Data.RouteSummary.AmountOut != "" && r.Data.RouteSummary.AmountOut != "0" + RecordProbe(p.Slug(), token.Venue, token.Chain, ok) + return ok +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/log_buffer.go b/harnesses/token-quote-coverage/cmd/monitor/log_buffer.go new file mode 100644 index 00000000..f4a66bb8 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/log_buffer.go @@ -0,0 +1,113 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// logBuffer keeps the last N log lines in memory for debug fetching via /logs. +// Captures BOTH log.* and fmt.Print* output (stdout is dup'd via a pipe). +// +// Implementation: fixed-size circular buffer. `head` points at the next slot +// to write; once `filled` is true every overwrite is O(1) instead of the O(n) +// slice-shift the prior version used. +type logBuffer struct { + mu sync.Mutex + lines []string + max int + head int // next write index + filled bool // wrapped at least once +} + +const logBufferMax = 5000 + +var globalLogBuffer = &logBuffer{lines: make([]string, logBufferMax), max: logBufferMax} + +func (b *logBuffer) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + b.lines[b.head] = entry + b.head++ + if b.head >= b.max { + b.head = 0 + b.filled = true + } + b.mu.Unlock() +} + +func (b *logBuffer) Snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + size := b.head + if b.filled { + size = b.max + } + if tail <= 0 || tail >= size { + tail = size + } + out := make([]string, 0, tail) + // Walk back `tail` slots from head-1 (modular). + for i := 0; i < tail; i++ { + idx := (b.head - tail + i + b.max) % b.max + out = append(out, b.lines[idx]) + } + return out +} + +// installLogCapture replaces os.Stdout with the write-end of a pipe, then +// spawns a goroutine that fan-outs every line to the real stdout AND the +// in-memory ring buffer. Catches fmt.Println/Printf as well as log.Printf. +// Call exactly once, very early in main(). +func installLogCapture() { + originalStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[log_buffer] failed to create pipe: %v (logs endpoint will be empty)\n", err) + return + } + os.Stdout = 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) + globalLogBuffer.push(line) + } + _, _ = io.Copy(originalStdout, r) + }() +} + +// setupLogsEndpoint exposes GET /logs?tail=N (default 500, max logBufferMax). +// Fail-secure: when LOGS_TOKEN env var is not set, returns 404. +func setupLogsEndpoint(mux *http.ServeMux) { + expectedToken := os.Getenv("LOGS_TOKEN") + mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { + if expectedToken == "" { + http.NotFound(w, r) + return + } + if r.Header.Get("X-Logs-Token") != expectedToken { + 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 globalLogBuffer.Snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/main.go b/harnesses/token-quote-coverage/cmd/monitor/main.go new file mode 100644 index 00000000..b513fdf2 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "sync" + "syscall" +) + +func main() { + installLogCapture() + fmt.Println("=== Token Quote Coverage Monitor ===") + fmt.Println("Probes swap providers hourly against recently-boosted tokens from Dexscreener.") + fmt.Println("Emits token_quote_coverage_success_total and token_quote_coverage_attempts_total.") + fmt.Println() + + cfg, err := loadEnv() + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Region: %s\n", cfg.MonitorRegion) + fmt.Printf("Mobula key set: %v\n", cfg.MobulaAPIKey != "") + fmt.Println("Metrics on :2112/metrics, logs on :2112/logs (LOGS_TOKEN gated)") + fmt.Println() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + var wg sync.WaitGroup + stopChan := make(chan struct{}) + + wg.Add(1) + go func() { + defer wg.Done() + fmt.Println("Starting Prometheus metrics server on :2112") + if err := StartMetricsServer(":2112"); err != nil { + fmt.Printf("Metrics server error: %v\n", err) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + runScheduler(cfg, stopChan) + }() + + <-sigChan + fmt.Println("\nShutting down...") + close(stopChan) + wg.Wait() + fmt.Println("Stopped") +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/metrics.go b/harnesses/token-quote-coverage/cmd/monitor/metrics.go new file mode 100644 index 00000000..af183f0f --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/metrics.go @@ -0,0 +1,53 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + coverageSuccess *prometheus.CounterVec + coverageAttempts *prometheus.CounterVec +) + +func init() { + coverageSuccess = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "token_quote_coverage_success_total", + Help: "Number of successful quote probes (outAmount > 0) per provider/venue/chain", + }, + []string{"provider", "venue", "chain"}, + ) + prometheus.MustRegister(coverageSuccess) + + coverageAttempts = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "token_quote_coverage_attempts_total", + Help: "Total quote probe attempts per provider/venue/chain", + }, + []string{"provider", "venue", "chain"}, + ) + prometheus.MustRegister(coverageAttempts) +} + +// RecordProbe records one probe attempt and optionally a success. +func RecordProbe(provider, venue, chain string, ok bool) { + coverageAttempts.WithLabelValues(provider, venue, chain).Inc() + if ok { + coverageSuccess.WithLabelValues(provider, venue, chain).Inc() + } +} + +// StartMetricsServer exposes /metrics, /logs, /healthz on :2112. +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + setupLogsEndpoint(mux) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/mobula.go b/harnesses/token-quote-coverage/cmd/monitor/mobula.go new file mode 100644 index 00000000..6158d915 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/mobula.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const ( + mobulaUSDCBase = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + mobulaUSDCBSC = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" + mobulaUSDGRobinhood = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168" + // dummy wallet required by the quoting endpoint (no funds needed) + mobulaWallet = "0x0000000000000000000000000000000000000001" +) + +// MobulaProvider hits api.mobula.io/api/2/swap/quoting for EVM chains (Base + BNB). +// Solana is excluded: /api/2/swap/quoting does not support Solana. +type MobulaProvider struct { + apiKey string + client *http.Client +} + +func NewMobulaProvider(apiKey string) *MobulaProvider { + return &MobulaProvider{apiKey: apiKey, client: newWarmHTTPClient()} +} + +func (p *MobulaProvider) Slug() string { return "mobula" } + +func (p *MobulaProvider) SupportsChain(chain string) bool { + switch chain { + case "base", "bsc", "robinhood": + return true + } + return false +} + +func (p *MobulaProvider) Quote(ctx context.Context, token Token) (ok bool) { + var chainId, tokenIn string + switch token.Chain { + case "base": + chainId, tokenIn = "8453", mobulaUSDCBase + case "bsc": + chainId, tokenIn = "56", mobulaUSDCBSC + case "robinhood": + chainId, tokenIn = "4663", mobulaUSDGRobinhood + default: + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + q := url.Values{} + q.Set("tokenIn", tokenIn) + q.Set("tokenOut", token.Address) + q.Set("amount", "1") + q.Set("chainId", chainId) + q.Set("walletAddress", mobulaWallet) + endpoint := "https://api.mobula.io/api/2/swap/quoting?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + req.Header.Set("Authorization", p.apiKey) + + resp, err := p.client.Do(req) + if err != nil { + fmt.Printf("[mobula] %s/%s net error: %s\n", token.Chain, token.Address, classifyNetErr(err)) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + fmt.Printf("[mobula] %s/%s status=%d body=%s\n", token.Chain, token.Address, resp.StatusCode, snippet(body)) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + var r struct { + Data struct { + AmountOutTokens string `json:"amountOutTokens"` + } `json:"data"` + } + if err := json.Unmarshal(body, &r); err != nil { + fmt.Printf("[mobula] %s/%s parse error: %v\n", token.Chain, token.Address, err) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + ok = r.Data.AmountOutTokens != "" && r.Data.AmountOutTokens != "0" + RecordProbe(p.Slug(), token.Venue, token.Chain, ok) + return ok +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/odos.go b/harnesses/token-quote-coverage/cmd/monitor/odos.go new file mode 100644 index 00000000..8dae9538 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/odos.go @@ -0,0 +1,113 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const ( + odosUSDCBase = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + odosUSDCBSC = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" + odosUSDGRobinhood = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168" +) + +// OdosProvider hits api.odos.xyz/sor/quote/v2 for Base and BSC. +// sem limits to 2 concurrent requests to avoid 429 throttling. +type OdosProvider struct { + client *http.Client + sem chan struct{} +} + +func NewOdosProvider() *OdosProvider { + return &OdosProvider{client: newWarmHTTPClient(), sem: make(chan struct{}, 2)} +} + +func (p *OdosProvider) Slug() string { return "odos" } + +func (p *OdosProvider) SupportsChain(chain string) bool { + return chain == "base" || chain == "bsc" || chain == "robinhood" +} + +func (p *OdosProvider) Quote(ctx context.Context, token Token) (ok bool) { + select { + case p.sem <- struct{}{}: + defer func() { <-p.sem }() + case <-ctx.Done(): + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + var chainId int + var usdcAddr string + switch token.Chain { + case "base": + chainId = 8453 + usdcAddr = odosUSDCBase + case "bsc": + chainId = 56 + usdcAddr = odosUSDCBSC + case "robinhood": + chainId = 4663 + usdcAddr = odosUSDGRobinhood + default: + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + payload := map[string]any{ + "chainId": chainId, + "inputTokens": []map[string]any{ + {"tokenAddress": usdcAddr, "amount": "1000000"}, + }, + "outputTokens": []map[string]any{ + {"tokenAddress": token.Address, "proportion": 1}, + }, + "slippageLimitPercent": 5, + "userAddr": "0x0000000000000000000000000000000000000001", + } + bb, _ := json.Marshal(payload) + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.odos.xyz/sor/quote/v2", bytes.NewBuffer(bb)) + if err != nil { + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + fmt.Printf("[odos] %s/%s net error: %s\n", token.Chain, token.Address, classifyNetErr(err)) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == 429 { + fmt.Printf("[odos] %s/%s throttled\n", token.Chain, token.Address) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + if resp.StatusCode != 200 { + fmt.Printf("[odos] %s/%s status=%d body=%s\n", token.Chain, token.Address, resp.StatusCode, snippet(body)) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + var r struct { + OutAmounts []string `json:"outAmounts"` + } + if err := json.Unmarshal(body, &r); err != nil { + fmt.Printf("[odos] %s/%s parse error: %v\n", token.Chain, token.Address, err) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + ok = len(r.OutAmounts) > 0 && r.OutAmounts[0] != "" && r.OutAmounts[0] != "0" + RecordProbe(p.Slug(), token.Venue, token.Chain, ok) + return ok +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/paraswap.go b/harnesses/token-quote-coverage/cmd/monitor/paraswap.go new file mode 100644 index 00000000..94e31ca7 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/paraswap.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const ( + paraswapUSDCBase = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + paraswapUSDCBSC = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" +) + +type ParaSwapProvider struct { + client *http.Client +} + +func NewParaSwapProvider() *ParaSwapProvider { + return &ParaSwapProvider{client: newWarmHTTPClient()} +} + +func (p *ParaSwapProvider) Slug() string { return "paraswap" } + +func (p *ParaSwapProvider) SupportsChain(chain string) bool { + return chain == "base" || chain == "bsc" +} + +func (p *ParaSwapProvider) Quote(ctx context.Context, token Token) (ok bool) { + var network, usdcAddr string + switch token.Chain { + case "base": + network = "8453" + usdcAddr = paraswapUSDCBase + case "bsc": + network = "56" + usdcAddr = paraswapUSDCBSC + default: + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + q := url.Values{} + q.Set("srcToken", usdcAddr) + q.Set("destToken", token.Address) + q.Set("amount", "1000000") + q.Set("network", network) + q.Set("srcDecimals", "6") + q.Set("destDecimals", "18") + q.Set("side", "SELL") + endpoint := "https://apiv5.paraswap.io/prices?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + resp, err := p.client.Do(req) + if err != nil { + fmt.Printf("[paraswap] %s/%s net error: %s\n", token.Chain, token.Address, classifyNetErr(err)) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + fmt.Printf("[paraswap] %s/%s status=%d body=%s\n", token.Chain, token.Address, resp.StatusCode, snippet(body)) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + var r struct { + PriceRoute struct { + DestAmount string `json:"destAmount"` + } `json:"priceRoute"` + } + if err := json.Unmarshal(body, &r); err != nil { + fmt.Printf("[paraswap] %s/%s parse error: %v\n", token.Chain, token.Address, err) + RecordProbe(p.Slug(), token.Venue, token.Chain, false) + return false + } + + ok = r.PriceRoute.DestAmount != "" && r.PriceRoute.DestAmount != "0" + RecordProbe(p.Slug(), token.Venue, token.Chain, ok) + return ok +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/provider.go b/harnesses/token-quote-coverage/cmd/monitor/provider.go new file mode 100644 index 00000000..0613bd96 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/provider.go @@ -0,0 +1,52 @@ +package main + +import "context" + +// Token is one recently-active token fetched from Dexscreener. +type Token struct { + Address string + Chain string // "solana", "base", "bsc", "robinhood" + Venue string // "pump-fun", "virtuals", "four-meme", "pons", etc. +} + +// Provider is the chain-aware quote coverage adapter contract. +// Quote returns true only when outAmount > 0 was parsed from the response. +// The adapter is responsible for recording the attempt counter before returning. +type Provider interface { + Slug() string + SupportsChain(chain string) bool + Quote(ctx context.Context, token Token) (ok bool) +} + +// venueFromPair maps a Dexscreener (chainId, dexId) to a canonical venue slug. +func venueFromPair(chainId, dexId string) string { + switch chainId { + case "solana": + switch dexId { + case "pump-fun", "pumpfun": + return "pump-fun" + case "moonshot": + return "moonshot" + case "meteora", "meteora-dlmm": + return "meteora-dbc" + default: + return "pump-fun" + } + case "base": + switch dexId { + case "virtual-fun", "virtualprotocol", "virtuals": + return "virtuals" + case "wow", "wow-xyz": + return "wow" + case "clanker": + return "clanker" + default: + return "virtuals" + } + case "bsc": + return "four-meme" + case "robinhood": + return "pons" + } + return "all" +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/scheduler.go b/harnesses/token-quote-coverage/cmd/monitor/scheduler.go new file mode 100644 index 00000000..011a02a0 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/scheduler.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "fmt" + "sync" + "time" +) + +const ( + tickInterval = 60 * time.Minute + probeTimeout = 10 * time.Second + fetchTimeout = 30 * time.Second +) + +// buildProviders returns all configured providers. +func buildProviders(cfg *Config) []Provider { + providers := []Provider{ + NewJupiterProvider(), + NewMobulaProvider(cfg.MobulaAPIKey), + NewKyberSwapProvider(), + NewParaSwapProvider(), + } + return providers +} + +// runScheduler fires immediately then every 60 minutes. +func runScheduler(cfg *Config, stopChan <-chan struct{}) { + providers := buildProviders(cfg) + fmt.Printf("[SCHED] starting with %d providers, region=%s\n", len(providers), cfg.MonitorRegion) + for _, p := range providers { + fmt.Printf("[SCHED] - %s\n", p.Slug()) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tick := time.NewTicker(tickInterval) + defer tick.Stop() + + doTick(ctx, providers) + + for { + select { + case <-stopChan: + return + case <-tick.C: + doTick(ctx, providers) + } + } +} + +// doTick fetches tokens from all discovery sources in parallel, merges, and probes. +func doTick(ctx context.Context, providers []Provider) { + fetchCtx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + // Fetch from all discovery sources concurrently: Dexscreener boosts (Solana + + // Robinhood), Virtuals Protocol API (Base), GeckoTerminal new pools (BSC). + type result struct { + name string + entries []boostEntry + err error + } + ch := make(chan result, 3) + go func() { + e, err := FetchBoostedTokens(fetchCtx) + ch <- result{"dexscreener-boosts", e, err} + }() + go func() { + e, err := FetchVirtualsTokens(fetchCtx) + ch <- result{"virtuals", e, err} + }() + go func() { + e, err := FetchNewBSCTokens(fetchCtx) + ch <- result{"geckoterminal-bsc", e, err} + }() + + seen := map[string]bool{} + var entries []boostEntry + for i := 0; i < 3; i++ { + r := <-ch + if r.err != nil { + fmt.Printf("[SCHED] %s error: %v\n", r.name, r.err) + continue + } + for _, e := range r.entries { + key := e.ChainId + ":" + e.TokenAddress + if !seen[key] { + seen[key] = true + entries = append(entries, e) + } + } + fmt.Printf("[SCHED] %s: %d tokens\n", r.name, len(r.entries)) + } + + if len(entries) == 0 { + fmt.Println("[SCHED] warning: all discovery sources returned 0 tokens") + return + } + fmt.Printf("[SCHED] %d unique tokens across all chains, enriching with venue data\n", len(entries)) + + tokens, err := EnrichWithVenue(fetchCtx, entries) + if err != nil { + fmt.Printf("[SCHED] EnrichWithVenue error: %v\n", err) + return + } + if len(tokens) == 0 { + fmt.Println("[SCHED] warning: no tokens survived venue enrichment") + return + } + fmt.Printf("[SCHED] probing %d tokens across %d providers\n", len(tokens), len(providers)) + + var wg sync.WaitGroup + for _, tok := range tokens { + for _, p := range providers { + if !p.SupportsChain(tok.Chain) { + continue + } + wg.Add(1) + go func(p Provider, tok Token) { + defer wg.Done() + probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + ok := p.Quote(probeCtx, tok) + status := "miss" + if ok { + status = "HIT" + } + fmt.Printf("[probe] %s %s/%s venue=%s -> %s\n", + p.Slug(), tok.Chain, tok.Address[:min(8, len(tok.Address))], tok.Venue, status) + }(p, tok) + } + } + wg.Wait() + fmt.Println("[SCHED] tick complete") +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/harnesses/token-quote-coverage/cmd/monitor/virtuals.go b/harnesses/token-quote-coverage/cmd/monitor/virtuals.go new file mode 100644 index 00000000..2d354800 --- /dev/null +++ b/harnesses/token-quote-coverage/cmd/monitor/virtuals.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// FetchVirtualsTokens returns recently graduated Virtuals Protocol agents on Base. +// SENTIENT status = agents that have a live DEX pool (real tokenAddress set). +// Sorted by lpCreatedAt desc so freshest graduates come first. +func FetchVirtualsTokens(ctx context.Context) ([]boostEntry, error) { + req, err := http.NewRequestWithContext(ctx, "GET", + "https://api.virtuals.io/api/virtuals?filters[status]=SENTIENT&sort[0]=lpCreatedAt:desc&pagination[pageSize]=20", + nil) + if err != nil { + return nil, err + } + + resp, err := dexClient.Do(req) + if err != nil { + return nil, fmt.Errorf("virtuals: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("virtuals: status=%d body=%s", resp.StatusCode, snippet(body)) + } + + // The API returns either {"data": [items]} or {"data": {"data": [items]}}. + // Handle both shapes. + var envelope struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("virtuals parse: %w", err) + } + + type item struct { + TokenAddress string `json:"tokenAddress"` + } + + var items []item + if err := json.Unmarshal(envelope.Data, &items); err != nil { + // try nested {"data": [...]} + var nested struct { + Data []item `json:"data"` + } + if err2 := json.Unmarshal(envelope.Data, &nested); err2 != nil { + return nil, fmt.Errorf("virtuals parse nested: %w", err2) + } + items = nested.Data + } + + var out []boostEntry + seen := map[string]bool{} + for _, it := range items { + if it.TokenAddress == "" { + continue + } + k := strings.ToLower(it.TokenAddress) + if seen[k] { + continue + } + seen[k] = true + out = append(out, boostEntry{ChainId: "base", TokenAddress: it.TokenAddress, Venue: "virtuals"}) + } + return out, nil +} diff --git a/harnesses/token-quote-coverage/go.mod b/harnesses/token-quote-coverage/go.mod new file mode 100644 index 00000000..07fd2ae4 --- /dev/null +++ b/harnesses/token-quote-coverage/go.mod @@ -0,0 +1,15 @@ +module token-quote-coverage + +go 1.22 + +require github.com/prometheus/client_golang v1.19.1 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + golang.org/x/sys v0.17.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect +) diff --git a/harnesses/token-quote-coverage/go.sum b/harnesses/token-quote-coverage/go.sum new file mode 100644 index 00000000..11fc1a0b --- /dev/null +++ b/harnesses/token-quote-coverage/go.sum @@ -0,0 +1,20 @@ +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.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= diff --git a/harnesses/transaction-fee/cmd/script/cardano.go b/harnesses/transaction-fee/cmd/script/cardano.go index 4cf92d28..0c7bbec6 100644 --- a/harnesses/transaction-fee/cmd/script/cardano.go +++ b/harnesses/transaction-fee/cmd/script/cardano.go @@ -51,8 +51,25 @@ type koiosEpochParams struct { MinFeeB float64 `json:"min_fee_b"` } +// Koios epoch params change once per 5-day epoch, but this fetcher runs +// on the 30s harness cycle. Caching for an hour keeps the total koios +// load around 24 calls/day (both OCB harnesses combined stay far under +// the 5,000/day public tier that we exhausted before this cache). +var cardanoParamCache struct { + minFeeA, minFeeB float64 + fetchedAt time.Time +} + func (f *cardanoFetcher) Sample(ch ChainConfig) ([]FeeSample, error) { + if time.Since(cardanoParamCache.fetchedAt) < time.Hour && cardanoParamCache.minFeeA > 0 { + feeLovelace := cardanoParamCache.minFeeB + (cardanoTypicalTxBytes * cardanoParamCache.minFeeA) + return []FeeSample{{Chain: ch.Slug, Tier: "single", NativeFee: feeLovelace}}, nil + } minFeeA, minFeeB, err := f.fetchProtocolParams(ch.RPCURL) + if err == nil { + cardanoParamCache.minFeeA, cardanoParamCache.minFeeB = minFeeA, minFeeB + cardanoParamCache.fetchedAt = time.Now() + } if err != nil { // Fall back to known-good constants rather than emit nothing. fmt.Printf("[cardano] koios fetch failed, using fallback constants: %v\n", err) diff --git a/public/logos/beaverbuild.jpg b/public/logos/beaverbuild.jpg new file mode 100644 index 00000000..2f6794a6 Binary files /dev/null and b/public/logos/beaverbuild.jpg differ diff --git a/public/logos/bobthebuilder.jpg b/public/logos/bobthebuilder.jpg new file mode 100644 index 00000000..b47283ba Binary files /dev/null and b/public/logos/bobthebuilder.jpg differ diff --git a/public/logos/btcs.png b/public/logos/btcs.png new file mode 100644 index 00000000..c947ae54 Binary files /dev/null and b/public/logos/btcs.png differ diff --git a/public/logos/buildernet.png b/public/logos/buildernet.png new file mode 100644 index 00000000..4ca16514 Binary files /dev/null and b/public/logos/buildernet.png differ diff --git a/public/logos/eureka.png b/public/logos/eureka.png new file mode 100644 index 00000000..833b3f5e Binary files /dev/null and b/public/logos/eureka.png differ diff --git a/public/logos/leorpc.png b/public/logos/leorpc.png new file mode 100644 index 00000000..27485d76 Binary files /dev/null and b/public/logos/leorpc.png differ diff --git a/public/logos/markets-mobile.svg b/public/logos/markets-mobile.svg new file mode 100644 index 00000000..05a28b14 --- /dev/null +++ b/public/logos/markets-mobile.svg @@ -0,0 +1 @@ + diff --git a/public/logos/quasar.png b/public/logos/quasar.png new file mode 100644 index 00000000..364ef935 Binary files /dev/null and b/public/logos/quasar.png differ diff --git a/public/logos/redstone.png b/public/logos/redstone.png new file mode 100644 index 00000000..a333e41c Binary files /dev/null and b/public/logos/redstone.png differ diff --git a/public/logos/slash.png b/public/logos/slash.png new file mode 100644 index 00000000..d002b793 Binary files /dev/null and b/public/logos/slash.png differ diff --git a/public/logos/titan.png b/public/logos/titan.png new file mode 100644 index 00000000..cbbbcd1e Binary files /dev/null and b/public/logos/titan.png differ diff --git a/public/logos/topdog.jpg b/public/logos/topdog.jpg new file mode 100644 index 00000000..ea41a198 Binary files /dev/null and b/public/logos/topdog.jpg differ diff --git a/public/logos/vanilla.svg b/public/logos/vanilla.svg new file mode 100644 index 00000000..91efc9cd --- /dev/null +++ b/public/logos/vanilla.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/app/api/mcp/[transport]/route.ts b/src/app/api/mcp/[transport]/route.ts index e9461cb6..448f96fb 100644 --- a/src/app/api/mcp/[transport]/route.ts +++ b/src/app/api/mcp/[transport]/route.ts @@ -62,8 +62,11 @@ const QUERY_PROM_ALLOWED_METRIC_PREFIXES = [ "pm_", // EVM swap quote latency bench (#033) "evm_swap_quote_", - // Perp fees + buyback + oracle + validator yield + // Perp fees + funding + venue KPIs + execution scanner + buyback + oracle + validator yield "perp_fees_", + "perp_funding_", + "perp_venue_", + "perp_execution_", "ocb_buyback_", "ocb_oracle_", "ocb_validator_", @@ -347,7 +350,8 @@ const mcpHandler = createMcpHandler( " l1_finality_*, l2_block_time_*", " metadata_coverage_*, metadata_api_latency_*, network_coverage_*,", " networks_supported, wallet_labels_*", - " perp_fees_*, ocb_buyback_*, ocb_oracle_*, ocb_validator_*, ocb_chain_*", + " perp_fees_*, perp_funding_*, perp_venue_*, perp_execution_*,", + " ocb_buyback_*, ocb_oracle_*, ocb_validator_*, ocb_chain_*", " gas_error_*, gas_predicted_*, gas_realized_*, gas_oracle_*", " peg_* (stablecoin peg, both variants)", " solana_landing_* (TX landing observational + active)", diff --git a/src/components/back-link.tsx b/src/components/back-link.tsx deleted file mode 100644 index 92a7b63a..00000000 --- a/src/components/back-link.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; - -/** "Back to

" link used as the lead-in row on every detail - * page. The pattern (ArrowLeft + small muted label) was repeated - * verbatim across six route files; consolidating here means the - * visual treatment of "back nav" lives in one place. */ -interface BackLinkProps { - href: string; - label: string; - className?: string; -} - -const BASE_CLASS = - "inline-flex items-center gap-1.5 text-sm text-ink-muted hover:text-ink"; - -export function BackLink({ href, label, className }: BackLinkProps) { - const cls = className ? `${BASE_CLASS} ${className}` : BASE_CLASS; - return ( - - - {label} - - ); -} diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index 5f3a373b..c4d876e6 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -770,6 +770,7 @@ export function BenchmarkBody({ ? chartRegion : undefined } + chain={effectiveChain ?? undefined} excluded={excluded} onToggleExclude={toggleExclude} onResetExcluded={resetExcluded} diff --git a/src/components/hl-frontend-grid.tsx b/src/components/hl-frontend-grid.tsx deleted file mode 100644 index 9a88d287..00000000 --- a/src/components/hl-frontend-grid.tsx +++ /dev/null @@ -1,134 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import type { - HlHistoryFrontendCompact, - HlHistorySummary, -} from "@/lib/hl-builder-stats"; -import { HlFrontendCard } from "@/components/hl-frontend-card"; - -/** - * Responsive grid of `HlFrontendCard`, one per frontend in the compact - * history blob. Client component so the sort selector is interactive - * without a network round-trip. - * - * Sort modes: - * - `fees` → last non-null fees value descending (default: matches - * the leaderboard's implicit ordering). - * - `peak` → all-time max of the fees array, descending. - * - `age` → first-active timestamp ascending (oldest first). - * - `volume`→ last non-null volume value descending. - */ - -type SortBy = "fees" | "volume" | "peak" | "age"; - -export function HlFrontendGrid({ - history, - sortBy: initialSortBy = "fees", -}: { - history: HlHistorySummary; - sortBy?: SortBy; -}) { - const [sortBy, setSortBy] = useState(initialSortBy); - - const sorted = useMemo( - () => sortFrontends(history.frontends, sortBy), - [history.frontends, sortBy], - ); - - return ( -
-
-

- {history.frontends.length} frontends · 12-month rolling 30d fees -

-
- - Fees now - - - Volume now - - - All-time peak - - - Oldest first - -
-
- -
- {sorted.map((f, i) => ( - - ))} -
-
- ); -} - -function SortButton({ - value, - current, - onSelect, - children, -}: { - value: SortBy; - current: SortBy; - onSelect: (v: SortBy) => void; - children: React.ReactNode; -}) { - const active = value === current; - return ( - - ); -} - -function lastNonNull(arr: (number | null)[]): number { - for (let i = arr.length - 1; i >= 0; i--) { - const v = arr[i]; - if (v !== null && Number.isFinite(v)) return v; - } - return 0; -} - -function peakOf(arr: (number | null)[]): number { - let m = 0; - for (const v of arr) { - if (v !== null && v > m) m = v; - } - return m; -} - -function sortFrontends( - frontends: HlHistoryFrontendCompact[], - by: SortBy, -): HlHistoryFrontendCompact[] { - const copy = [...frontends]; - if (by === "fees") { - copy.sort((a, b) => lastNonNull(b.fees) - lastNonNull(a.fees)); - } else if (by === "volume") { - copy.sort((a, b) => lastNonNull(b.volume) - lastNonNull(a.volume)); - } else if (by === "peak") { - copy.sort((a, b) => peakOf(b.fees) - peakOf(a.fees)); - } else if (by === "age") { - copy.sort((a, b) => a.firstIdx - b.firstIdx); - } - return copy; -} diff --git a/src/components/hl-history-chart.tsx b/src/components/hl-history-chart.tsx deleted file mode 100644 index 5186c465..00000000 --- a/src/components/hl-history-chart.tsx +++ /dev/null @@ -1,598 +0,0 @@ -"use client"; - -import { useMemo, useRef, useState } from "react"; -import type { - HlHistoryFrontendCompact, - HlHistorySummary, -} from "@/lib/hl-builder-stats"; - -/** - * 12-month evolution chart for every active HL frontend (~98). Two - * toggleable metrics (fees / volume, both 30d rolling). Top-N frontends - * are drawn in the OCB palette; the remaining "long tail" renders in a - * desaturated grey overlay so the eye still gets the shape of the - * cohort's overall scale without the legend blowing up. - * - * The input blob is the compact shape written by the worker: - * - shared time axis `t0 + step*i` - * - per-frontend `firstIdx` drops leading nulls - * - values are pre-rounded to integer USD - * - * Design goals: - * - Stays a single SVG. No recharts / D3. 98 × 365 int points renders - * comfortably; grey tail lines share a single `` styling. - * - Gaps: `v === null` points break the line rather than dropping to - * zero. Matches the harness' "no sample this UTC day" semantic and - * keeps early-history cohorts (post-launch) from starting from an - * artificial floor. - * - Colours: 10-slot OCB palette, cycled if the top set grows past - * 10. Hovered / pinned line lifts to full opacity; the rest dim. - * - Crosshair tooltip lists top-N + hovered tail entry so the reader - * never chases a grey line without a label. - */ - -const COLORS = [ - "#9d65ff", // violet - "#ff8a3d", // orange - "#22c55e", // emerald - "#38bdf8", // sky - "#f43f5e", // rose - "#eab308", // amber - "#14b8a6", // teal - "#a855f7", // fuchsia - "#f97316", // deep orange - "#0ea5e9", // blue - "#84cc16", // lime - "#ec4899", // pink - "#06b6d4", // cyan - "#f59e0b", // dark amber - "#10b981", // green - "#8b5cf6", // purple - "#ef4444", // red - "#3b82f6", // indigo - "#d946ef", // magenta - "#65a30d", // olive -]; - -/** How many frontends get a colour + legend entry. The rest are drawn - * as a desaturated grey overlay so the chart shows the full cohort's - * scale without the legend collapsing under 98 chips. */ -const TOP_COLORED = 20; - -type Metric = "fees" | "volume"; - -export function HlHistoryChart({ - history, - focusSlugs, -}: { - history: HlHistorySummary; - /** When provided, only these slugs render (all in colour, no grey - * long-tail overlay, no top/tail split). Powers the per-frontend - * detail page at `/hyperliquid/[slug]`. */ - focusSlugs?: string[]; -}) { - const [metric, setMetric] = useState("fees"); - const [pinnedSlug, setPinnedSlug] = useState(null); - - const focusSet = useMemo( - () => (focusSlugs && focusSlugs.length > 0 ? new Set(focusSlugs) : null), - [focusSlugs], - ); - - const activeFrontends = useMemo( - () => - focusSet - ? history.frontends.filter((f) => focusSet.has(f.slug)) - : history.frontends, - [history.frontends, focusSet], - ); - if (activeFrontends.length === 0) { - return ( -

- No history samples yet — the backfill is still populating. -

- ); - } - - // Focus mode: every requested slug gets a colour; skip the grey tail. - const topFrontends = focusSet - ? activeFrontends - : activeFrontends.slice(0, TOP_COLORED); - const tailFrontends = focusSet ? [] : activeFrontends.slice(TOP_COLORED); - - return ( -
-
-
-

- Last 12 months · rolling 30d -

-

- Daily-stepped snapshot of {activeFrontends.length} HL frontends - {tailFrontends.length > 0 ? ( - <> - {" "} - — top {TOP_COLORED} highlighted, {tailFrontends.length} in the - grey long tail - - ) : null} -

-
-
- - log10 scale - -
- - -
-
-
- - - -
- {topFrontends.map((f, i) => { - const color = COLORS[i % COLORS.length]; - const pinned = pinnedSlug === f.slug; - return ( - - ); - })} -
-
- ); -} - -function ChartCanvas({ - history, - topFrontends, - tailFrontends, - metric, - pinnedSlug, -}: { - history: HlHistorySummary; - topFrontends: HlHistoryFrontendCompact[]; - tailFrontends: HlHistoryFrontendCompact[]; - metric: Metric; - pinnedSlug: string | null; -}) { - const W = 1100; - const H = 360; - const PAD_L = 68; - const PAD_R = 20; - const PAD_T = 20; - const PAD_B = 44; - const plotW = W - PAD_L - PAD_R; - const plotH = H - PAD_T - PAD_B; - - const stepMs = history.step * 1000; - const t0 = history.t0; - - const seriesOf = (f: HlHistoryFrontendCompact): (number | null)[] => - metric === "fees" ? f.fees : f.volume; - - const timestampAt = (f: HlHistoryFrontendCompact, i: number): number => - t0 + stepMs * (f.firstIdx + i); - - // Shared time axis: derive from the compact envelope. Longest series = - // t0 → t0 + step*(maxFirstIdx + maxLen - 1). Fall back to (t0, t0+step) - // so the SVG still lays out on an empty payload. - const tRange = useMemo(() => { - let tMin = Number.POSITIVE_INFINITY; - let tMax = Number.NEGATIVE_INFINITY; - const all = [...topFrontends, ...tailFrontends]; - for (const f of all) { - const s = seriesOf(f); - if (s.length === 0) continue; - const first = timestampAt(f, 0); - const last = timestampAt(f, s.length - 1); - if (first < tMin) tMin = first; - if (last > tMax) tMax = last; - } - if (!Number.isFinite(tMin) || !Number.isFinite(tMax) || tMin === tMax) { - return { tMin: t0, tMax: t0 + stepMs }; - } - return { tMin, tMax }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [topFrontends, tailFrontends, metric, t0, stepMs]); - - const yMax = useMemo(() => { - let m = 0; - const all = [...topFrontends, ...tailFrontends]; - for (const f of all) { - for (const v of seriesOf(f)) { - if (v !== null && v > m) m = v; - } - } - return niceLogMax(m); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [topFrontends, tailFrontends, metric]); - - // Log10 scale on the Y axis. We compress the value into log space via - // log10(v + 1) so v === 0 maps cleanly to 0 (no −∞) and the +1 offset - // is negligible once we hit even $10. Effective floor is 1 (log10(1+1) - // ≈ 0.30), which keeps sub-$1 noise off the axis. Long-tail frontends - // in the $100–$10k range now get vertical breathing room next to the - // $1M+ leaders instead of collapsing into the zero line. - const logMin = 0; // log10(0 + 1) = 0 - const logMax = Math.log10(yMax + 1); - const logDen = logMax - logMin || 1; - - const xFor = (t: number) => { - const span = tRange.tMax - tRange.tMin || 1; - return PAD_L + ((t - tRange.tMin) / span) * plotW; - }; - const yFor = (v: number) => { - const clamped = v > 0 ? v : 0; - const norm = (Math.log10(clamped + 1) - logMin) / logDen; - return PAD_T + plotH * (1 - norm); - }; - - // Multi-segment path: break the line whenever we hit a null so the - // chart shows gaps rather than a straight fall to zero + spike back. - const pathFor = (f: HlHistoryFrontendCompact): string => { - const s = seriesOf(f); - const parts: string[] = []; - let inSegment = false; - for (let i = 0; i < s.length; i++) { - const v = s[i]; - if (v === null) { - inSegment = false; - continue; - } - const cmd = inSegment ? "L" : "M"; - const t = timestampAt(f, i); - parts.push(`${cmd} ${xFor(t).toFixed(1)} ${yFor(v).toFixed(1)}`); - inSegment = true; - } - return parts.join(" "); - }; - - // Power-of-10 gridlines from $1 → yMax. Log axis needs decade ticks - // (not evenly spaced fractions) so the reader can eyeball orders of - // magnitude directly. - const yTicks = useMemo(() => buildLogTicks(yMax), [yMax]); - const monthTicks = useMemo( - () => buildMonthTicks(tRange.tMin, tRange.tMax), - [tRange.tMin, tRange.tMax], - ); - - const svgRef = useRef(null); - const [hoverT, setHoverT] = useState(null); - - const onMove: React.PointerEventHandler = (e) => { - const svg = svgRef.current; - if (!svg) return; - const rect = svg.getBoundingClientRect(); - const xRatio = (e.clientX - rect.left) / rect.width; - const px = xRatio * W; - if (px < PAD_L || px > W - PAD_R) { - setHoverT(null); - return; - } - const span = tRange.tMax - tRange.tMin; - const t = tRange.tMin + ((px - PAD_L) / plotW) * span; - setHoverT(t); - }; - - // Snap hover to nearest sample per frontend for the tooltip readout. - // Only the coloured top-N surface in the tooltip; a 98-line list would - // be unreadable. - const hoverRows = useMemo(() => { - if (hoverT === null) return null; - const rows: { slug: string; name: string; color: string; v: number | null }[] = []; - for (let i = 0; i < topFrontends.length; i++) { - const f = topFrontends[i]; - const s = seriesOf(f); - if (s.length === 0) { - rows.push({ slug: f.slug, name: f.name, color: COLORS[i % COLORS.length], v: null }); - continue; - } - let bestIdx = 0; - let bestDist = Math.abs(timestampAt(f, 0) - hoverT); - for (let j = 1; j < s.length; j++) { - const d = Math.abs(timestampAt(f, j) - hoverT); - if (d < bestDist) { - bestDist = d; - bestIdx = j; - } - } - rows.push({ - slug: f.slug, - name: f.name, - color: COLORS[i % COLORS.length], - v: s[bestIdx] ?? null, - }); - } - rows.sort((a, b) => (b.v ?? -1) - (a.v ?? -1)); - return rows; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hoverT, topFrontends, metric, t0, stepMs]); - - const hoverX = hoverT !== null ? xFor(hoverT) : null; - const hoverDate = hoverT !== null ? formatDate(hoverT) : null; - - return ( -
- setHoverT(null)} - > - {yTicks.map((v) => { - const y = yFor(v); - const isFloor = v <= 1; - return ( - - - - {fmtUSDShort(v)} - - - ); - })} - - {monthTicks.map((mt) => { - const x = xFor(mt.t); - if (x < PAD_L - 1 || x > W - PAD_R + 1) return null; - return ( - - - - {mt.label} - - - ); - })} - - {/* Long-tail grey overlay. Drawn first so the coloured top-N - paints above it. Kept as one class + one stroke so the DOM - stays cheap even with ~80 extra paths. */} - {tailFrontends.map((f) => ( - - ))} - - {topFrontends.map((f, i) => { - const color = COLORS[i % COLORS.length]; - const dimmed = pinnedSlug !== null && pinnedSlug !== f.slug; - return ( - - ); - })} - - {hoverX !== null && ( - - )} - - - {hoverRows && hoverDate && hoverX !== null && ( - - )} -
- ); -} - -function Tooltip({ - rows, - date, - xFrac, -}: { - rows: { slug: string; name: string; color: string; v: number | null }[]; - date: string; - xFrac: number; -}) { - const left = Math.max(4, Math.min(96, xFrac * 100)); - const flipX = left > 55; - return ( -
-

- {date} -

-
- {rows.slice(0, 10).map((r) => ( -
- - - {r.name} - - - {r.v === null ? "—" : fmtUSDShort(r.v)} - -
- ))} -
-
- ); -} - -/** Round up to the next decade for a log-scale ceiling ($10, $100, $1k, …). - * Guarantees the topmost gridline is a clean power of 10 so labels never - * read `$1.7M` or `$3.4M`. */ -function niceLogMax(v: number): number { - if (!Number.isFinite(v) || v <= 10) return 10; - return Math.pow(10, Math.ceil(Math.log10(v))); -} - -/** Decade gridlines from $1 up through niceLogMax. Small enough (≤ 8 - * entries for a $10M ceiling) that we don't need mid-decade ticks. */ -function buildLogTicks(max: number): number[] { - const topExp = Math.max(1, Math.ceil(Math.log10(Math.max(max, 10)))); - const out: number[] = [1]; - for (let e = 1; e <= topExp; e++) { - out.push(Math.pow(10, e)); - } - return out; -} - -function fmtUSDShort(v: number): string { - if (!Number.isFinite(v) || v === 0) return "$0"; - const abs = Math.abs(v); - if (abs >= 1_000_000_000) return `$${(v / 1_000_000_000).toFixed(1)}B`; - if (abs >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M`; - if (abs >= 1_000) return `$${(v / 1_000).toFixed(0)}K`; - return `$${v.toFixed(0)}`; -} - -function formatDate(t: number): string { - const d = new Date(t); - return d.toISOString().slice(0, 10); -} - -/** First-of-month labels between two epoch-ms bounds. Keeps the count - * bounded (~12 labels) so the axis never crowds. */ -function buildMonthTicks( - tMinMs: number, - tMaxMs: number, -): { t: number; label: string }[] { - const out: { t: number; label: string }[] = []; - const start = new Date(tMinMs); - const cursor = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1)); - const MONTH_NAMES = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ]; - while (cursor.getTime() <= tMaxMs) { - const t = cursor.getTime(); - if (t >= tMinMs) { - const label = - cursor.getUTCMonth() === 0 - ? `${MONTH_NAMES[0]} ${cursor.getUTCFullYear() % 100}` - : MONTH_NAMES[cursor.getUTCMonth()]; - out.push({ t, label }); - } - cursor.setUTCMonth(cursor.getUTCMonth() + 1); - } - return out; -} diff --git a/src/components/logo-card-link.tsx b/src/components/logo-card-link.tsx deleted file mode 100644 index d5b4f8c4..00000000 --- a/src/components/logo-card-link.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import Link from "next/link"; -import type { ReactNode } from "react"; - -/** - * Shared primitive for the "card-soft" link tiles that paint the hub - * pages (alternatives, compare, chains, answers top-results, etc.). - * - * The hub cards all rendered the same skeleton inline - logo on the left, - * title + subtitle in the middle, a chevron or pill on the right. Pulling - * the markup here keeps the hover state, padding ramp and shape contract - * (`card-soft rounded-xl ... hover:border-ink/40 transition-colors`) in - * one place so a future restyle doesn't require chasing 8 call sites. - * - * Three layouts: - * - `variant="default"` (row, p-4 sm:p-5): roomy hub tile. - * - `variant="compact"` (row, p-4): tighter hub-index shape used by - * /alternatives, /compare, /chains. - * - `variant="stack"` (column, p-4): the stat-card shape used by the - * "Top N" sections in /answers/[slug] and /alternatives/[slug]. Top - * row stays the same; the bottom row is whatever `children` provides - * (typically a metric value strip). - * - * `subtitle` is a passthrough ReactNode. Pass a plain string to get the - * default paragraph styling, or pass a fully-styled element (e.g. the - * `label-mono` caption that compare/chains used inline) to opt out. - */ -interface LogoCardLinkProps { - href: string; - title: ReactNode; - subtitle?: ReactNode; - /** Logo, chain icon, emoji, dual-logo stack - anything that paints on the left. */ - logo?: ReactNode; - /** Right-side affordance: chevron, pill, type badge, status dot. */ - rightSlot?: ReactNode; - /** Extra content rendered below the top row (only painted when variant="stack"). */ - children?: ReactNode; - className?: string; - variant?: "default" | "compact" | "stack"; -} - -export function LogoCardLink({ - href, - title, - subtitle, - logo, - rightSlot, - children, - className, - variant = "default", -}: LogoCardLinkProps) { - const padding = variant === "default" ? "p-4 sm:p-5" : "p-4"; - const layout = - variant === "stack" - ? "flex flex-col gap-2" - : "flex items-center gap-4"; - - const base = `card-soft rounded-xl ${padding} h-full transition-colors hover:border-ink/40 group ${layout}`; - const merged = className ? `${base} ${className}` : base; - - // String subtitles get the default paragraph styling so the common - // /alternatives case stays a one-liner. Non-string subtitles render - // as-is so /compare and /chains can keep their uppercase mono caption. - const renderSubtitle = () => { - if (subtitle == null) return null; - if (typeof subtitle === "string") { - return ( -

- {subtitle} -

- ); - } - return subtitle; - }; - - if (variant === "stack") { - return ( - -
- {logo &&
{logo}
} -
-

- {title} -

- {subtitle && ( -

- {subtitle} -

- )} -
- {rightSlot &&
{rightSlot}
} -
- {children} - - ); - } - - return ( - - {logo &&
{logo}
} -
-

- {title} -

- {renderSubtitle()} -
- {rightSlot &&
{rightSlot}
} - - ); -} diff --git a/src/components/provider-logo.tsx b/src/components/provider-logo.tsx index 8f52e362..4125ae79 100644 --- a/src/components/provider-logo.tsx +++ b/src/components/provider-logo.tsx @@ -80,6 +80,8 @@ export function ProviderLogo({ // hairline shadow so they pop on both light and dark page backgrounds. const NEEDS_LIGHT_CHIP = new Set([ "aptos", + "slash", + "markets-mobile", "aapl", "pltr", "leorpc", diff --git a/src/components/report-section-modal.tsx b/src/components/report-section-modal.tsx deleted file mode 100644 index 9901dc82..00000000 --- a/src/components/report-section-modal.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { CheckCircle2, Loader2, X } from "lucide-react"; - -type Status = "idle" | "submitting" | "ok" | "error"; - -type Props = { - slug: string; - onClose: () => void; -}; - -/** - * The heavy report-form modal. Code-split out of the trigger via - * `next/dynamic` so the form + icons + status state only land in the - * client bundle when the user actually clicks "Report". - */ -export default function ReportSectionModal({ slug, onClose }: Props) { - const [message, setMessage] = useState(""); - const [contact, setContact] = useState(""); - const [wantsContact, setWantsContact] = useState(false); - const [status, setStatus] = useState("idle"); - const [errorMsg, setErrorMsg] = useState(null); - - // Lock body scroll while the modal is mounted and close on Escape. - // Effect lives in the modal (not the trigger) because the modal only - // mounts when `open` is true, so we don't even need to gate on it. - useEffect(() => { - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKey); - return () => { - document.body.style.overflow = prev; - window.removeEventListener("keydown", onKey); - }; - }, [onClose]); - - async function submit(e: React.SyntheticEvent) { - e.preventDefault(); - if (status === "submitting") return; - setStatus("submitting"); - setErrorMsg(null); - try { - const url = typeof window !== "undefined" ? new URL(window.location.href) : null; - const chain = url?.searchParams.get("chain") ?? null; - const res = await fetch("/api/report", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - slug, - chain, - message, - contact: wantsContact && contact ? contact : null, - page: url?.toString() ?? "", - }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - setErrorMsg( - typeof data?.error === "string" - ? data.error - : "Something went wrong. Try again." - ); - setStatus("error"); - return; - } - setStatus("ok"); - } catch { - setErrorMsg("Network error. Try again."); - setStatus("error"); - } - } - - const tooShort = message.trim().length < 5; - - return ( -
-
e.stopPropagation()} - > -
- - Report a problem - - -
- - {status === "ok" ? ( -
- - - -

Thanks, report received.

-

- {wantsContact && contact - ? "A maintainer will take a look and reach out at the contact you left." - : "A maintainer will take a look. Have a great day."} -

- -
- ) : ( -
-

- Spotted a wrong number, a missing provider, an outage, or - anything off about this benchmark? Tell us, it goes straight - to a maintainer. -

-
- -