From ad3365354bf51d605be02a3aaaa78bb74bb53a40 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:17:34 +0200 Subject: [PATCH 1/2] OKX's DEX feed is open after all, on a channel their docs do not mention (#2791) Their documented trades channel, wss://wsdex.okx.com/ws/v6/dex, answers "Please log in" to every subscribe including the docs' own verbatim example, and their fee table says why: Free reads "WebSocket not supported", cheapest WS-capable plan $99/month. Their web frontend holds a different socket that needs no credential. Two things hid it, both of which fail silently: - the channel is dex-market-trade-history, not any `trades` variant. Twenty other names were rejected before this one; - the params go in an extraParams JSON STRING. With flat keys the gateway still answers {"event":"subscribe"} and then pushes nothing, which is indistinguishable from a dead channel. An ack proves nothing here, only frames do. Base and Solana only, and both exclusions are structural. BNB publishes head_lag_seconds as receiveTime minus the provider's own timestamp, and every OKX timestamp is a whole second (1,800 sampled across three chains, all ts%1000 == 0), so measuring it there would publish our arithmetic as OKX's latency. Robinhood Chain is not in OKX's supported set. OKX cannot scope a subscription to a pool: six ways of passing the pool address are accepted and silently ignored, and the paid channel is token-scoped too, so this is the shape of the product rather than a level of access. It does not change what is scored. Only emissions matched to the reference by transaction hash are, and the reference is the bench pool, so the scored set is identical to the other four providers'. Verified before shipping rather than assumed: over 20 minutes on the Base pool, 213 of 213 pool swaps appeared in OKX's stream, p50 636 ms behind our node, OKX never first. The one cost is bookkeeping. An off-pool emission never matches and would expire as a miss, which would read as a 96%-miss provider and drown the signal that counter exists for: whether the reference itself is healthy. tokenScopedAggregators exempts it, and a test keeps the exemption to providers that genuinely cannot scope to a pool. The feed is undocumented frontend infrastructure with no stability guarantee. That is the bet geckoterminal_monitor.go already makes here, and the failure mode is the same: the flow watchdog purges the gauge and the column reads "Feed down" rather than a wrong number. Tests pin the socket and channel constants so a silent move fails loudly. okx.svg replaces the opaque JPEG, which drew a white box around the mark on dark backgrounds. Co-authored-by: Claude Opus 5 (1M context) --- benchmarks/aggregator-head-lag.yml | 46 +- .../cmd/script/head_lag_monitor.go | 5 + .../cmd/script/okx_head_lag_monitor.go | 403 ++++++++++++++++++ .../cmd/script/okx_head_lag_monitor_test.go | 159 +++++++ .../cmd/script/pending_match.go | 24 +- public/logos/okx.svg | 15 + src/data/provider-registry.ts | 3 +- src/lib/logo-manifest.ts | 2 +- 8 files changed, 649 insertions(+), 8 deletions(-) create mode 100644 harnesses/aggregator-head-lag/cmd/script/okx_head_lag_monitor.go create mode 100644 harnesses/aggregator-head-lag/cmd/script/okx_head_lag_monitor_test.go create mode 100644 public/logos/okx.svg diff --git a/benchmarks/aggregator-head-lag.yml b/benchmarks/aggregator-head-lag.yml index 6f64eac7e..d3cf72bf1 100644 --- a/benchmarks/aggregator-head-lag.yml +++ b/benchmarks/aggregator-head-lag.yml @@ -2,16 +2,16 @@ slug: aggregator-head-lag number: "001" -title: Fastest crypto price API, live head lag across Mobula, Codex, GeckoTerminal, Serialized +title: Fastest crypto price API, live head lag across 5 data providers seo_title: "Fastest crypto price API 2026: live head lag by chain" -seo_description: "{{best_name}} leads cross-chain head lag at {{best_p50}} (p50, 24h); per-chain leaders differ. Mobula, Codex, GeckoTerminal, Serialized measured live." -subtitle: Wall-clock head lag in seconds from on-chain swap event to API emission, measured live for Mobula, Codex, GeckoTerminal and Serialized on Base, BNB Chain, Solana and Robinhood Chain. +seo_description: "{{best_name}} leads cross-chain head lag at {{best_p50}} (p50, 24h); per-chain leaders differ. Mobula, Codex, GeckoTerminal, Serialized, OKX measured live." +subtitle: Wall-clock head lag in seconds from on-chain swap event to API emission, measured live for Mobula, Codex, GeckoTerminal, Serialized and OKX on Base, BNB Chain, Solana and Robinhood Chain. OKX is measured on Base and Solana, the two chains where the headline does not read its timestamps. per_chain_explainer: - slug: base h2: "Lowest Base price feed head lag" body: | - {{best_name:chain:base}} currently leads Base head lag at {{best_p50:chain:base}} (p50, 24h) across the 4 measured providers. Base targets a 2 s sequencer cadence on the OP Stack, so the chain itself sets a roughly 2 s floor below which no provider can publish: any latency you see beyond that floor is pipeline work (indexer, fan-out, edge). Measured by holding the sequencer's flashblock preconfirmation stream and timestamping each provider's emission of the same swap from us-east, eu-west and Singapore, so the zero point is the moment the trade becomes public rather than the hour its block will carry. + {{best_name:chain:base}} currently leads Base head lag at {{best_p50:chain:base}} (p50, 24h) across the 5 measured providers. Base targets a 2 s sequencer cadence on the OP Stack, so the chain itself sets a roughly 2 s floor below which no provider can publish: any latency you see beyond that floor is pipeline work (indexer, fan-out, edge). Measured by holding the sequencer's flashblock preconfirmation stream and timestamping each provider's emission of the same swap from us-east, eu-west and Singapore, so the zero point is the moment the trade becomes public rather than the hour its block will carry. - slug: bnb h2: "Lowest BNB Chain price feed head lag" body: | @@ -63,7 +63,7 @@ abstract: | same event. methodology: - - "Aggregators measured: Mobula, Codex, GeckoTerminal, Serialized." + - "Aggregators measured: Mobula, Codex, GeckoTerminal, Serialized on every chain; OKX on Base and Solana only. OKX's DEX feed cannot be scoped to a pool, so it is subscribed per token and matched to the bench pool by transaction hash; its off-pool trades are discarded rather than scored. It is absent from BNB because that chain's headline reads the provider's own timestamp and OKX sends whole seconds, and absent from Robinhood Chain because OKX does not index it." - "Chains: Base, BNB Chain, Solana, Robinhood Chain." - "Reference on BNB Chain and Robinhood Chain: archive nodes per chain, validated against block hashes. These carry no preconfirmation layer a provider consumes, and their chain-supplied timestamps sit within roughly 60 ms of the moment a trade is observable, so they keep the on-chain timestamp as the zero point." - "Solana (since 2026-09-16): the headline is the lag behind the first feed to report the trade, our own node subscription included in the race. Solana has no on-chain timestamp with sub-second precision, so the timestamps providers send compare conventions: Mobula's `date` (its ingestion time) read as a constant 0.10 s, Serialized's `at` (blockTime, whole seconds) as 0.75 s, while against a common clock the two feeds are 10 to 30 ms apart." @@ -297,3 +297,39 @@ providers: p50: avg by (aggregator) (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 and on(chain, region) sum by (chain, region) (changes(head_lag_seconds{region="sgp"}[15m])) > 5) + + # Base and Solana only, and both reasons are structural rather than + # provisional. BNB publishes `head_lag_seconds` as receiveTime minus the + # provider's own timestamp, and every OKX timestamp is a whole second + # (1,800 sampled across three chains, all ts%1000 == 0), so measuring it + # there would publish our own arithmetic as OKX's latency. Robinhood Chain + # is not in OKX's supported set at all. + # + # OKX also cannot scope a subscription to a pool, so it pushes every pool on + # a token and most of what it sends is discarded. That does not change what + # is scored: only emissions matched to the reference by transaction hash are, + # and the reference is the bench pool. Measured on the Base pool before + # shipping: 213 of 213 pool swaps present in OKX's stream. + - slug: okx + name: OKX + tag: WebSocket trades stream + formula: "Median seconds between an on-chain swap on the bench pools and the same trade arriving on OKX's DEX WebSocket trades stream (subscribed per token, matched to the pool by transaction hash), sampled every 15s over 24h. Base and Solana only." + queries: + p50: avg by (aggregator) (quantile_over_time(0.50, head_lag_seconds{aggregator="okx"}[24h])) * 1000 + p90: avg by (aggregator) (quantile_over_time(0.90, head_lag_seconds{aggregator="okx"}[24h])) * 1000 + p99: avg by (aggregator) (quantile_over_time(0.99, head_lag_seconds{aggregator="okx"}[24h])) * 1000 + mean: avg by (aggregator) (avg_over_time(head_lag_seconds{aggregator="okx"}[24h])) * 1000 + success: clamp_max(avg by (aggregator) (count_over_time(head_lag_seconds{aggregator="okx"}[24h]) / 2880), 1) + sample_size: sum(count_over_time(head_lag_seconds{aggregator="okx"}[24h])) + series: avg_over_time(head_lag_seconds{aggregator="okx"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="okx"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="okx"}[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="okx", chain=""}[15m]) > 0.8) + live_activity: sum(changes(head_lag_seconds{aggregator="okx"}[15m])) + regions: + - region: us-east + p50: avg by (aggregator) (quantile_over_time(0.50, head_lag_seconds{aggregator="okx", region="us-east"}[24h])) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="okx", region="us-east"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="okx", region="us-east"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="okx", 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="okx", region="us-east", chain=""}[15m]) > 0.8) + - region: eu-west + p50: avg by (aggregator) (quantile_over_time(0.50, head_lag_seconds{aggregator="okx", region="eu-west"}[24h])) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="okx", region="eu-west"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="okx", region="eu-west"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="okx", 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="okx", region="eu-west", chain=""}[15m]) > 0.8) + - region: ap-southeast + p50: avg by (aggregator) (quantile_over_time(0.50, head_lag_seconds{aggregator="okx", region="sgp"}[24h])) * 1000 + series: avg_over_time(head_lag_seconds{aggregator="okx", region="sgp"}[1h]) * 1000 unless (changes(head_lag_seconds{aggregator="okx", region="sgp"}[15m]) == 0 and count_over_time(head_lag_seconds{aggregator="okx", 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="okx", region="sgp", chain=""}[15m]) > 0.8) diff --git a/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go b/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go index 563cb4ee3..e27b63caa 100644 --- a/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go +++ b/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go @@ -826,6 +826,11 @@ func runHeadLagMonitor(config *Config, stopChan <-chan struct{}) { wg.Add(1) go runSerializedHeadLagMonitor(config, stopChan, &wg) + // Start OKX monitor. Base and Solana only, and token-scoped upstream: + // see the header of okx_head_lag_monitor.go for both reasons. + wg.Add(1) + go runOKXHeadLagMonitor(config, stopChan, &wg) + // Wait for all to finish wg.Wait() fmt.Println("[HEAD-LAG] All monitors stopped") diff --git a/harnesses/aggregator-head-lag/cmd/script/okx_head_lag_monitor.go b/harnesses/aggregator-head-lag/cmd/script/okx_head_lag_monitor.go new file mode 100644 index 000000000..8b4b44ecc --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/okx_head_lag_monitor.go @@ -0,0 +1,403 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// ============================================================================ +// OKX DEX head lag monitor +// ============================================================================ +// +// OKX's Market API documents a `trades` channel on wss://wsdex.okx.com/ws/v6/dex +// that answers "Please log in" to every subscribe, and their fee table is +// explicit about why: the Free plan reads "WebSocket not supported", with the +// cheapest WS-capable plan at $99/month. +// +// The channel below is a different surface: the one their own web frontend +// holds, open with no credential. Two things made it hard to find and are worth +// writing down, because both produce silent failures: +// +// - the channel is `dex-market-trade-history`, not any `trades` variant; +// - the params go inside an `extraParams` JSON STRING. With flat +// chainIndex/tokenContractAddress keys the server still answers +// {"event":"subscribe"} and then pushes nothing at all. An ack proves +// nothing here; only frames do. +// +// Undocumented frontend infrastructure, so it carries no stability guarantee. +// That is the same bet geckoterminal_monitor.go already makes in this harness +// (its pool ids are commented "extracted via reverse engineering"), and the +// failure mode is the same: the feed goes quiet, the watchdog purges the gauge, +// and the column reads "Feed down" rather than a wrong number. +// +// --------------------------------------------------------------------------- +// Why only Base and Solana +// --------------------------------------------------------------------------- +// +// BNB is deliberately absent. The chain publishes `head_lag_seconds` as +// receiveTime minus the provider's OWN timestamp (it is in neither +// referenceChains nor raceChains), and every OKX timestamp is a whole second: +// 1,800 sampled across three chains, all ts%1000 == 0. Putting a +// second-quantised clock in the same column as the others' millisecond clocks +// would publish our arithmetic as OKX's latency. Base reads the reference +// clock and Solana the race, neither of which touches the provider timestamp, +// so both are safe. BNB can follow if it ever gains a reference. +// +// Robinhood Chain is absent because OKX does not support it at all. +// +// --------------------------------------------------------------------------- +// Why the subscription is per token while every other provider is per pool +// --------------------------------------------------------------------------- +// +// OKX cannot scope a subscription to a pool. Six ways of passing the pool +// address (poolAddress, poolId, pairAddress, dexContractAddress, with and +// without the token, plus a dexName filter) are all accepted and silently +// ignored, and the paid v6 channel is token-scoped too, so this is the shape of +// the product rather than a level of access. +// +// That does NOT mean OKX is measured on a different trade stream. Only +// emissions that match the reference by transaction hash are ever scored, and +// the reference is the bench pool, so the scored set is identical to the other +// four providers'. Measured over 20 minutes on the Base pool: 213 of 213 pool +// swaps appeared in OKX's stream, p50 636 ms behind our node, OKX never first. +// The off-pool trades are discarded, not scored. +// +// Their one cost is bookkeeping: an off-pool emission never matches, so it +// would expire as a miss. tokenScopedAggregators in pending_match.go exempts +// this provider from that counter, because an unmatched emission here means +// "a trade from another pool", not "a failure". +const ( + okxWSURL = "wss://wsdexpri.okx.com:443/ws/v5/ipublic" + okxChannel = "dex-market-trade-history" + + // The gateway closes a socket that has been idle for 30 s. + okxPingPeriod = 20 * time.Second + + // A bench pool silent this long means the subscription is dead even + // while pongs keep the socket alive. Same policy as the codex and + // serialized paths. Generous because it is measured per chain and the + // Base pool runs ~830 swaps an hour, the Solana one far more. + okxFlowTimeout = 10 * time.Minute + + okxReadTimeout = 90 * time.Second +) + +// okxChains are the chains OKX is measured on, with the token side to +// subscribe. The token is the non-stable leg of each bench pool, because +// OKX indexes trades by token and the stable leg would pull in the entire +// chain's stablecoin flow. +var okxChains = []struct { + ChainName string // metrics label, must match headLagPools + OKXChain string // OKX's own chain index: 8453 Base, 501 Solana + Token string +}{ + {ChainName: "base", OKXChain: "8453", Token: "0x4200000000000000000000000000000000000006"}, + {ChainName: "solana", OKXChain: "501", Token: "So11111111111111111111111111111111111111112"}, +} + +type okxTrade struct { + ChainID string `json:"chainId"` + TxHash string `json:"txHash"` + Timestamp string `json:"timestamp"` + DexName string `json:"dexName"` +} + +type okxFrame struct { + Event string `json:"event"` + Msg string `json:"msg"` + Code string `json:"code"` + Arg struct { + Channel string `json:"channel"` + ExtraParams string `json:"extraParams"` + } `json:"arg"` + Data []okxTrade `json:"data"` +} + +// okxExtraParams is the arg shape the gateway actually honours. Flat keys are +// accepted and ignored, which is why this is a nested JSON string. +func okxExtraParams(chainID, token string) string { + b, err := json.Marshal(map[string]string{ + "chainId": chainID, + "tokenContractAddress": token, + }) + if err != nil { + // Both inputs are constants from okxChains; a failure here is a + // programming error, not a runtime condition. + return "" + } + return string(b) +} + +// okxChainName maps OKX's chain index back to our metrics label, so a frame +// that arrives on the wrong subscription is never scored on the wrong chain. +func okxChainName(okxChain string) (string, bool) { + for _, c := range okxChains { + if c.OKXChain == okxChain { + return c.ChainName, true + } + } + return "", false +} + +// runOKXHeadLagMonitor holds one socket per chain, because the gateway keys +// subscriptions by channel name alone: it acks a second token on the same +// connection and then ignores it, so sharing a socket would silently measure +// only the first chain. +func runOKXHeadLagMonitor(config *Config, stopChan <-chan struct{}, wg *sync.WaitGroup) { + defer wg.Done() + fmt.Printf("[HEAD-LAG][OKX] Starting WebSocket monitors for %d chains...\n", len(okxChains)) + + var inner sync.WaitGroup + for _, c := range okxChains { + inner.Add(1) + go func(chainName, okxChain, token string) { + defer inner.Done() + okxRunChain(config, chainName, okxChain, token, stopChan) + }(c.ChainName, c.OKXChain, c.Token) + } + inner.Wait() + fmt.Println("[HEAD-LAG][OKX] All chain monitors stopped") +} + +// okxRunChain redials with backoff forever. The connected gauge is cleared on +// every exit so the page cannot show a live reading for a dead socket. +func okxRunChain(config *Config, chainName, okxChain, token string, stopChan <-chan struct{}) { + const baseDelay = 5 * time.Second + const maxDelay = 60 * time.Second + delay := baseDelay + attempt := 0 + + for { + select { + case <-stopChan: + return + default: + } + attempt++ + err := okxConnectAndStream(config, chainName, okxChain, token, stopChan) + RecordWSConnected("okx", config.MonitorRegion, false) + + select { + case <-stopChan: + return + default: + } + + if err != nil { + RecordWSReconnect("okx", config.MonitorRegion) + RecordHeadLagError("okx", chainName, "disconnect", config.MonitorRegion) + log.Printf("[HEAD-LAG][OKX][%s] attempt #%d ended: %v, reconnect in %v", + chainName, attempt, err, delay) + } else { + log.Printf("[HEAD-LAG][OKX][%s] stream closed cleanly, reconnect in %v", chainName, delay) + } + + select { + case <-stopChan: + return + case <-time.After(delay): + } + + // Exponential backoff, capped. A successful stream resets it below. + delay *= 2 + if delay > maxDelay { + delay = maxDelay + } + } +} + +func okxConnectAndStream(config *Config, chainName, okxChain, token string, stopChan <-chan struct{}) error { + dialer := websocket.Dialer{HandshakeTimeout: 15 * time.Second} + conn, _, err := dialer.Dial(okxWSURL, nil) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close() + + // One writer only. Both the ping goroutine and the subscribe below go + // through this, because gorilla panics on concurrent writes. + var writeMu sync.Mutex + send := func(v any) error { + writeMu.Lock() + defer writeMu.Unlock() + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + return conn.WriteJSON(v) + } + sendRaw := func(s string) error { + writeMu.Lock() + defer writeMu.Unlock() + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + return conn.WriteMessage(websocket.TextMessage, []byte(s)) + } + + if err := send(map[string]any{ + "op": "subscribe", + "args": []map[string]string{{ + "channel": okxChannel, + "extraParams": okxExtraParams(okxChain, token), + }}, + }); err != nil { + return fmt.Errorf("subscribe %s: %w", chainName, err) + } + + done := make(chan struct{}) + defer close(done) + + // Keepalive. OKX's own client sends the bare string "ping" and the + // gateway answers "pong"; a JSON frame is not accepted here. + go func() { + t := time.NewTicker(okxPingPeriod) + defer t.Stop() + for { + select { + case <-done: + return + case <-stopChan: + return + case <-t.C: + if err := sendRaw("ping"); err != nil { + return + } + } + } + }() + + // Flow watchdog. An ack with no frames is the documented failure mode of + // a wrong arg shape, and a feed that goes quiet later looks identical, so + // both are treated the same: purge the gauge and force a redial rather + // than let the page hold a frozen value. + var lastMu sync.Mutex + lastTrade := time.Now() + go func() { + t := time.NewTicker(time.Minute) + defer t.Stop() + for { + select { + case <-done: + return + case <-stopChan: + return + case <-t.C: + lastMu.Lock() + idle := time.Since(lastTrade) + lastMu.Unlock() + if idle > okxFlowTimeout { + log.Printf("[HEAD-LAG][OKX][%s] no trade in %v, forcing redial", chainName, idle.Round(time.Second)) + RecordHeadLagError("okx", chainName, "flow_stall", config.MonitorRegion) + DeleteHeadLagSeries("okx", chainName, config.MonitorRegion) + _ = conn.Close() + return + } + } + } + }() + + // Not marked connected until a frame actually arrives: the ack is not + // evidence this subscription is alive. + framesSeen := false + + for { + select { + case <-stopChan: + return nil + default: + } + + _ = conn.SetReadDeadline(time.Now().Add(okxReadTimeout)) + _, raw, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read: %w", err) + } + receiveTime := time.Now() + + // "pong" is a bare string, not JSON. + if trimmed := strings.TrimSpace(string(raw)); trimmed == "pong" || trimmed == "ping" { + continue + } + + var f okxFrame + if err := json.Unmarshal(raw, &f); err != nil { + continue + } + if f.Event == "error" { + return fmt.Errorf("gateway error %s: %s", f.Code, f.Msg) + } + if f.Event == "subscribe" { + log.Printf("[HEAD-LAG][OKX][%s] subscribe acked, waiting for frames", chainName) + continue + } + if len(f.Data) == 0 { + continue + } + + if !framesSeen { + framesSeen = true + RecordWSConnected("okx", config.MonitorRegion, true) + fmt.Printf("[HEAD-LAG][OKX][%s] pushing trades\n", chainName) + } + lastMu.Lock() + lastTrade = receiveTime + lastMu.Unlock() + + for _, t := range f.Data { + okxHandleTrade(config, chainName, t, receiveTime) + } + } +} + +// okxHandleTrade scores one pushed trade. +// +// providerLag is computed and passed for completeness, but neither chain here +// publishes it: Base reads the reference clock and Solana the race. That is +// deliberate, see the note on BNB at the top of this file. OKX's timestamps are +// whole seconds, so this figure must never become a headline. +func okxHandleTrade(config *Config, subChain string, t okxTrade, receiveTime time.Time) { + hash := strings.TrimSpace(t.TxHash) + if hash == "" { + return + } + + // A frame carrying another chain's index means the gateway misrouted it. + // Score it on the chain it claims, never on the subscription's. + chainName := subChain + if t.ChainID != "" { + mapped, ok := okxChainName(t.ChainID) + if !ok { + return + } + chainName = mapped + } + + providerLag := 0.0 + if ms := parseOKXMillis(t.Timestamp); ms > 0 { + providerLag = receiveTime.Sub(time.UnixMilli(ms)).Seconds() + } + + emitHeadLag("okx", chainName, config.MonitorRegion, hash, receiveTime, 0, providerLag) +} + +// parseOKXMillis reads the unix-millis timestamp OKX sends as a string. +// Returns 0 when it is absent or unparseable, which the caller treats as +// "no provider timestamp" rather than as epoch. +func parseOKXMillis(s string) int64 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + var ms int64 + if _, err := fmt.Sscanf(s, "%d", &ms); err != nil { + return 0 + } + // Guard against a seconds-precision value arriving where millis are + // expected: anything below this is not a plausible millisecond epoch. + if ms < 1_000_000_000_000 { + return 0 + } + return ms +} diff --git a/harnesses/aggregator-head-lag/cmd/script/okx_head_lag_monitor_test.go b/harnesses/aggregator-head-lag/cmd/script/okx_head_lag_monitor_test.go new file mode 100644 index 000000000..47d5019f9 --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/okx_head_lag_monitor_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +// The arg shape is the whole trick. With flat chainIndex/tokenContractAddress +// keys the gateway answers {"event":"subscribe"} and then pushes nothing for as +// long as you care to wait, which is indistinguishable from a dead channel and +// is exactly how this feed stayed hidden. The params have to be a JSON STRING +// under extraParams, so this pins the encoding rather than the behaviour. +func TestOKXExtraParamsIsANestedJSONString(t *testing.T) { + got := okxExtraParams("8453", "0x4200000000000000000000000000000000000006") + + // It must be a string that itself parses as an object, not an object. + var inner map[string]string + if err := json.Unmarshal([]byte(got), &inner); err != nil { + t.Fatalf("extraParams must be parseable JSON, got %q: %v", got, err) + } + if inner["chainId"] != "8453" { + t.Errorf("chainId: got %q want 8453", inner["chainId"]) + } + if inner["tokenContractAddress"] != "0x4200000000000000000000000000000000000006" { + t.Errorf("token: got %q", inner["tokenContractAddress"]) + } + + // And it must travel as a string once the frame is marshalled, because an + // object here is the silent-no-data failure. + frame, err := json.Marshal(map[string]any{ + "op": "subscribe", + "args": []map[string]string{{ + "channel": okxChannel, + "extraParams": got, + }}, + }) + if err != nil { + t.Fatalf("marshal frame: %v", err) + } + s := string(frame) + if !strings.Contains(s, `"extraParams":"{`) { + t.Errorf("extraParams must serialise as a quoted string, frame was %s", s) + } + if strings.Contains(s, `"extraParams":{`) { + t.Errorf("extraParams serialised as an object, which acks and never pushes: %s", s) + } +} + +// Every chain the monitor subscribes must be one the bench actually publishes, +// and must be measured by an instrument that does not use OKX's own timestamp. +// OKX sends whole seconds, so a chain outside referenceChains and raceChains +// would put a second-quantised clock in a column of millisecond ones. +func TestOKXChainsAvoidTheProviderTimestamp(t *testing.T) { + for _, c := range okxChains { + if !referenceChains[c.ChainName] && !raceChains[c.ChainName] { + t.Errorf("chain %q publishes receiveTime minus the provider's own timestamp, "+ + "and OKX's timestamps are whole seconds; it must not be measured here", + c.ChainName) + } + found := false + for _, p := range headLagPools { + if p.ChainName == c.ChainName { + found = true + break + } + } + if !found { + t.Errorf("chain %q is not a bench pool, so no reference exists to match against", c.ChainName) + } + } +} + +// BNB is a bench pool and OKX supports the chain, so nothing stops someone +// adding it except the reason above. This fails if that happens without BNB +// first gaining a reference or race instrument. +func TestOKXDoesNotCoverBNB(t *testing.T) { + for _, c := range okxChains { + if c.ChainName == "bnb" { + t.Fatal("bnb publishes the provider-timestamp figure; adding OKX there " + + "publishes its one-second quantisation as latency. Give bnb a " + + "reference clock first, then revisit.") + } + } +} + +// A misrouted frame must never be scored on the subscription's chain. +func TestOKXChainNameMapsOKXIndexes(t *testing.T) { + if got, ok := okxChainName("8453"); !ok || got != "base" { + t.Errorf("8453: got %q %v want base true", got, ok) + } + if got, ok := okxChainName("501"); !ok || got != "solana" { + t.Errorf("501: got %q %v want solana true", got, ok) + } + // 56 is BNB at OKX. We do not measure it, so a frame claiming it must be + // dropped rather than mapped onto whichever chain we were subscribed to. + if _, ok := okxChainName("56"); ok { + t.Error("56 (bnb) must not resolve while OKX is not measured there") + } + if _, ok := okxChainName(""); ok { + t.Error("empty chain index must not resolve") + } +} + +// The timestamp is a string of unix millis. A seconds-precision value, an empty +// field or junk must read as "no timestamp" rather than as 1970, which would +// publish a 56-year lag. +func TestParseOKXMillisRejectsNonMillis(t *testing.T) { + cases := []struct { + in string + want int64 + }{ + {"1790963535000", 1790963535000}, + {" 1790963535000 ", 1790963535000}, + {"1790963535", 0}, // seconds, not millis + {"", 0}, + {"null", 0}, + {"abc", 0}, + {"0", 0}, + } + for _, c := range cases { + if got := parseOKXMillis(c.in); got != c.want { + t.Errorf("parseOKXMillis(%q) = %d, want %d", c.in, got, c.want) + } + } +} + +// The feed is undocumented frontend infrastructure. If OKX ever moves it, the +// monitor must fail loudly rather than quietly measure something else, so the +// constants are pinned here: a silent edit to either is what a reader of this +// test is being warned about. +func TestOKXEndpointConstantsArePinned(t *testing.T) { + if okxWSURL != "wss://wsdexpri.okx.com:443/ws/v5/ipublic" { + t.Errorf("socket changed to %q. The documented wsdex.okx.com/ws/v6/dex "+ + "requires a paid plan (their fee table: Free = WebSocket not supported), "+ + "so verify the new one is open before shipping.", okxWSURL) + } + if okxChannel != "dex-market-trade-history" { + t.Errorf("channel changed to %q. Note that every `trades`-style name is "+ + "rejected on this path, and the paid v6 channel named `trades` is "+ + "login-gated.", okxChannel) + } +} + +// Exempting a provider from the miss counter is a real loosening, so it must +// stay narrow: the exemption exists only because the subscription cannot be +// scoped to a pool. +func TestOnlyTokenScopedProvidersSkipTheMissCounter(t *testing.T) { + if !tokenScopedAggregators["okx"] { + t.Error("okx is token-scoped upstream and must be exempt, or its miss " + + "counter reads 96% and hides whether the reference is healthy") + } + for _, a := range []string{"mobula", "codex", "geckoterminal", "serialized", "reference"} { + if tokenScopedAggregators[a] { + t.Errorf("%q subscribes per pool, so an unmatched emission really is a "+ + "miss and must still be counted", a) + } + } +} diff --git a/harnesses/aggregator-head-lag/cmd/script/pending_match.go b/harnesses/aggregator-head-lag/cmd/script/pending_match.go index 90194396c..b720e9e7d 100644 --- a/harnesses/aggregator-head-lag/cmd/script/pending_match.go +++ b/harnesses/aggregator-head-lag/cmd/script/pending_match.go @@ -61,6 +61,21 @@ var pending = &pendingQueue{} // before every provider (head_lag_ref_seconds positive for all of them). var referenceChains = map[string]bool{"base": true} +// tokenScopedAggregators cannot scope their subscription to a pool, so they +// push every pool on a token and most of what they send is off-bench. +// +// This changes nothing about what is scored: only emissions matched to the +// reference by transaction hash are, and the reference is the bench pool, so +// the scored set is the same for every provider. It changes only the miss +// counter, which for these providers would otherwise be dominated by trades +// that were never supposed to match. +// +// OKX is here because six ways of passing a pool address to its channel are +// accepted and silently ignored, and its paid channel is token-scoped too. +// Measured on the Base pool over 20 minutes: 213 of 213 pool swaps present, +// so the coverage this relies on is not assumed. +var tokenScopedAggregators = map[string]bool{"okx": true} + // emitHeadLag is the single entry point for a provider emission. // // Chains outside referenceChains keep publishing the provider-timestamp @@ -116,7 +131,14 @@ func runPendingResolver(stopChan <-chan struct{}) { continue } if now.Sub(e.receiveTime) > pendingDeadline { - RecordHeadLagRefMiss(e.aggregator, e.chain, e.region) + // A token-scoped provider sends us every pool on the token, + // so an emission the reference never saw is a trade from + // another pool, not a failure. Counting it would read as a + // 96%-miss provider and would drown the signal this counter + // exists for: whether the reference itself is healthy. + if !tokenScopedAggregators[e.aggregator] { + RecordHeadLagRefMiss(e.aggregator, e.chain, e.region) + } continue } keep = append(keep, e) diff --git a/public/logos/okx.svg b/public/logos/okx.svg new file mode 100644 index 000000000..61b3e4196 --- /dev/null +++ b/public/logos/okx.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index 13abef00d..66db4539a 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -2010,8 +2010,9 @@ export const PROVIDER_REGISTRY: Record = { okx: { url: "https://www.okx.com", description: - "OKX is a major centralized exchange whose USDT-margined perpetual swaps settle funding on variable periods (typically 8 hours), with rates capped per instrument.", + "OKX is a major centralized exchange whose USDT-margined perpetual swaps settle funding on variable periods (typically 8 hours), with rates capped per instrument. Its OnchainOS Market API also indexes DEX trades across 27 chains, which is the surface measured for head lag.", twitter: "@okx", + docs: "https://web3.okx.com/onchainos/dev-docs/market/market-api-introduction", }, paradex: { url: "https://www.paradex.trade", diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 01c4b808b..6954c1c6d 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -522,7 +522,7 @@ const RAW: Record = { // ─── Perp funding venues (bench № 036) ─── bybit: "/logos/bybit.jpg", - okx: "/logos/okx.jpg", + okx: "/logos/okx.svg", paradex: "/logos/paradex.jpg", aster: "/logos/aster.svg", From 353ca10b48b18164d0a4077c3fdf4f2d4829eeed Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:18:14 +0200 Subject: [PATCH 2/2] the BNB column was measuring BNB's block time, not the providers (#2792) head_lag_seconds on bnb published receiveTime minus the provider's OWN timestamp, because bnb was in neither referenceChains nor raceChains. The spec justified that by claiming BNB's chain-supplied timestamps sat "within roughly 60 ms of the moment a trade is observable". The gap between that series and the reference-matched one IS that quantity, and it is 569 ms. So the dominant term in the published column was block time, which every provider shares, and the column barely separated them. Measured over 24 h, 8,604 samples per provider, eu-west p50: published against the reference mobula 0.621 s 0.052 s serialized 0.657 s 0.102 s codex 0.676 s 0.215 s geckoterminal 33.881 s 31.196 s Within 9% of each other before, spread over a factor of four after, and the ranking is unchanged. This removes a common offset rather than reshuffling anyone. p90 moves the same way (1.0-1.2 s to 0.12-0.33 s). The gate in pending_match.go is that the reference must arrive before every provider. Our node is beaten 6.6% of the time by mobula on bnb, 4.7% by serialized, 3.7% by codex. Base, already on the reference and shipping, sits at 4.2% / 3.1% / 0%. Same order of magnitude, so this clears the bar the project already applies to itself. The reference subscription this reads already existed and already populated head_lag_ref_seconds; nothing new is collected. Only which series the headline reads changes. Published numbers on the bnb column will drop by roughly 5-10x the day this deploys. That is a methodology correction, not providers getting faster, and the spec now says so where it used to carry the 60 ms claim. TestReferenceChainsScope asserted bnb must NOT be here. Updated deliberately rather than deleted, with the reason, and a new test pins why bnb belongs now and why robinhood and solana still do not. Co-authored-by: Claude Opus 5 (1M context) --- benchmarks/aggregator-head-lag.yml | 4 +- .../cmd/script/base_flashblock_ref_test.go | 9 ++- .../cmd/script/pending_match.go | 21 +++++- .../cmd/script/reference_chains_test.go | 67 +++++++++++++++++++ 4 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 harnesses/aggregator-head-lag/cmd/script/reference_chains_test.go diff --git a/benchmarks/aggregator-head-lag.yml b/benchmarks/aggregator-head-lag.yml index d3cf72bf1..2a454764b 100644 --- a/benchmarks/aggregator-head-lag.yml +++ b/benchmarks/aggregator-head-lag.yml @@ -65,7 +65,7 @@ abstract: | methodology: - "Aggregators measured: Mobula, Codex, GeckoTerminal, Serialized on every chain; OKX on Base and Solana only. OKX's DEX feed cannot be scoped to a pool, so it is subscribed per token and matched to the bench pool by transaction hash; its off-pool trades are discarded rather than scored. It is absent from BNB because that chain's headline reads the provider's own timestamp and OKX sends whole seconds, and absent from Robinhood Chain because OKX does not index it." - "Chains: Base, BNB Chain, Solana, Robinhood Chain." - - "Reference on BNB Chain and Robinhood Chain: archive nodes per chain, validated against block hashes. These carry no preconfirmation layer a provider consumes, and their chain-supplied timestamps sit within roughly 60 ms of the moment a trade is observable, so they keep the on-chain timestamp as the zero point." + - "Reference on BNB Chain: a node subscription we hold ourselves, every emission matched to it by transaction hash, the ruler Base uses. Until 2026-10-03 the column used each provider's own timestamp, assuming BNB's chain-supplied timestamps sat within roughly 60 ms of a trade becoming observable. Measured, that gap is 569 ms, so the old column tracked block time more than any pipeline: the four feeds sat within 9% of each other, against the common clock they spread over a factor of four." - "Solana (since 2026-09-16): the headline is the lag behind the first feed to report the trade, our own node subscription included in the race. Solana has no on-chain timestamp with sub-second precision, so the timestamps providers send compare conventions: Mobula's `date` (its ingestion time) read as a constant 0.10 s, Serialized's `at` (blockTime, whole seconds) as 0.75 s, while against a common clock the two feeds are 10 to 30 ms apart." - "Solana zero point: no RPC WebSocket we hold, public or keyed (mainnet-beta, Helius, Alchemy), precedes the providers' geyser feeds, so an absolute zero point is not available and the first observation is the ruler. Our node stays in the race: it validates the hash on chain and bounds the field whenever a feed is slower than a plain RPC subscription." - "Race: every feed's arrival for a transaction is compared to the earliest feed's (ties within 5 ms count as first for each feed involved; trades only one feed reported are not scored). Published on every chain as `head_lag_first_share_pct`, the First to report view: the share of the last 24 h of trades a feed reported before every other feed." @@ -132,7 +132,7 @@ faq: - q: "Why does the Solana leader show a lag near zero?" a: "Because on Solana the ruler is the first feed to report the trade, not the chain. Solana's blockTime has one-second resolution and nothing on chain marks the millisecond a trade became observable, so measuring each provider against the timestamp it sends compared clock conventions: one provider's field was its own ingestion time (a constant 0.10 s), another's the blockTime (0.75 s on average). Against a common clock the two are 10 to 30 ms apart. The First to report view answers the question directly: the share of trades each feed delivered first. Our own node runs in the race and only wins on a small share of trades, which tells you these push feeds beat a plain RPC subscription; an absolute number needs a geyser-grade reference, which no RPC WebSocket we tested provides." - q: "How is head lag measured on OpenChainBench?" - a: "On Base the harness holds the sequencer's flashblock preconfirmation stream in every region and timestamps each transaction the first time it appears there; on BNB Chain, Solana and Robinhood Chain it holds canonical-tip archive nodes and uses the on-chain timestamp. Every swap is timestamped with millisecond precision. The harness then watches each provider's feed for the same event and computes the delta. p50, p90 and p99 are aggregated over 24 hours via Prometheus `quantile_over_time`. Errors and dropouts are tracked separately as a presence ratio so a fast feed that disappears does not silently rank above a slower feed that stays up." + a: "On Base the harness holds the sequencer's flashblock preconfirmation stream in every region and timestamps each transaction the first time it appears there; on BNB Chain it holds a node subscription and matches every provider emission to it by transaction hash; on Solana and Robinhood Chain it uses canonical-tip archive nodes. Every swap is timestamped with millisecond precision. The harness then watches each provider's feed for the same event and computes the delta. p50, p90 and p99 are aggregated over 24 hours via Prometheus `quantile_over_time`. Errors and dropouts are tracked separately as a presence ratio so a fast feed that disappears does not silently rank above a slower feed that stays up." - q: "Which data provider should I pick for a real-time trading interface?" a: "Read both the p50 and the p99 columns. p50 tells the typical experience, p99 tells the worst minute a user will see when a chain spikes or a region's path degrades. A 500 ms p50 with a 30 s p99 means most of the time the UI feels snappy and once per minute it stutters. The leaderboard splits both, plus the success rate column flags providers whose feed dropped during the measurement window." diff --git a/harnesses/aggregator-head-lag/cmd/script/base_flashblock_ref_test.go b/harnesses/aggregator-head-lag/cmd/script/base_flashblock_ref_test.go index 035361172..3a02bc71a 100644 --- a/harnesses/aggregator-head-lag/cmd/script/base_flashblock_ref_test.go +++ b/harnesses/aggregator-head-lag/cmd/script/base_flashblock_ref_test.go @@ -38,13 +38,18 @@ func TestTxHashFromRaw(t *testing.T) { // Only the chains in referenceChains publish a reference-based headline; // the rest must keep the provider's on-chain timestamp untouched. +// +// BNB moved into the set on 2026-10-03 after its reference was measured +// against the gate in pending_match.go. Robinhood and Solana stay out for +// different reasons: Robinhood has no measurement yet, and Solana has no RPC +// WebSocket that precedes the providers' geyser feeds, so it races instead. func TestReferenceChainsScope(t *testing.T) { - for _, chain := range []string{"bnb", "robinhood", "solana"} { + for _, chain := range []string{"robinhood", "solana"} { if referenceChains[chain] { t.Errorf("%s must not be re-based on the reference", chain) } } - for _, chain := range []string{"base"} { + for _, chain := range []string{"base", "bnb"} { if !referenceChains[chain] { t.Errorf("%s must be re-based on the reference", chain) } diff --git a/harnesses/aggregator-head-lag/cmd/script/pending_match.go b/harnesses/aggregator-head-lag/cmd/script/pending_match.go index b720e9e7d..cf367444c 100644 --- a/harnesses/aggregator-head-lag/cmd/script/pending_match.go +++ b/harnesses/aggregator-head-lag/cmd/script/pending_match.go @@ -59,7 +59,26 @@ var pending = &pendingQueue{} // lag behind the first observation of the trade rather than behind our // node. Add a chain here only once its reference is measured to arrive // before every provider (head_lag_ref_seconds positive for all of them). -var referenceChains = map[string]bool{"base": true} +// +// BNB joined 2026-10-03, against that gate and because the column it +// replaces was mostly measuring the chain. On the provider-timestamp +// figure the four providers sat within 9 % of each other (eu-west p50: +// mobula 0.621 s, serialized 0.657 s, codex 0.676 s) because the dominant +// term was BNB's block time, which every provider shares. Against the +// reference they spread over a factor of four (0.052 / 0.102 / 0.215), +// and the ranking is unchanged, so this removes a common offset rather +// than reshuffling anyone. +// +// The gate itself: our node is beaten to a trade 6.6 % of the time by +// mobula on BNB, 4.7 % by serialized, 3.7 % by codex. Base, already here +// and shipping, sits at 4.2 % / 3.1 % / 0 %. Same order, measured over +// 8,604 samples per provider. +// +// It also retires a published claim that did not survive measurement. +// The spec said BNB's chain-supplied timestamps sat "within roughly 60 ms +// of the moment a trade is observable". The gap between the two series is +// exactly that quantity, and it is 569 ms. +var referenceChains = map[string]bool{"base": true, "bnb": true} // tokenScopedAggregators cannot scope their subscription to a pool, so they // push every pool on a token and most of what they send is off-bench. diff --git a/harnesses/aggregator-head-lag/cmd/script/reference_chains_test.go b/harnesses/aggregator-head-lag/cmd/script/reference_chains_test.go new file mode 100644 index 000000000..23f5141a5 --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/reference_chains_test.go @@ -0,0 +1,67 @@ +package main + +import "testing" + +// A chain in referenceChains publishes head_lag_seconds as the lag behind our +// own clock; a chain outside it publishes the lag behind the provider's own +// timestamp. Those are different instruments, so no chain may be in both this +// map and raceChains, and every chain in either must be a bench pool. +func TestChainInstrumentsDoNotOverlap(t *testing.T) { + for chain := range referenceChains { + if raceChains[chain] { + t.Errorf("%q is in both referenceChains and raceChains; the headline would "+ + "depend on which code path wrote last", chain) + } + } + for _, m := range []map[string]bool{referenceChains, raceChains} { + for chain := range m { + found := false + for _, p := range headLagPools { + if p.ChainName == chain { + found = true + break + } + } + if !found { + t.Errorf("%q has no bench pool, so there is nothing to reference against", chain) + } + } + } +} + +// BNB moved onto the reference clock on 2026-10-03. Reverting it would restore +// a column whose dominant term is BNB's block time rather than any provider's +// pipeline: on the old instrument the four feeds sat within 9% of each other +// (eu-west p50 0.621 / 0.657 / 0.676 s), against the reference they spread over +// a factor of four (0.052 / 0.102 / 0.215 s), ranking unchanged. +// +// It also retired a published claim: the spec said BNB's chain-supplied +// timestamps sat "within roughly 60 ms of the moment a trade is observable". +// The gap between the two series is that quantity, and it measured 569 ms. +func TestBNBUsesTheReferenceClock(t *testing.T) { + if !referenceChains["bnb"] { + t.Fatal("bnb left referenceChains. That republishes the provider's own timestamp " + + "as the zero point, which measures BNB's block time more than any provider, " + + "and it re-blocks OKX on that chain because OKX sends whole-second timestamps.") + } +} + +// Base stays on the reference clock: its zero point is the flashblock +// preconfirmation stream, which precedes the sealed block by ~1.6 s. +func TestBaseStaysOnTheReferenceClock(t *testing.T) { + if !referenceChains["base"] { + t.Fatal("base must read the flashblock reference, not the provider timestamp") + } +} + +// Solana is deliberately NOT a referenceChain: no RPC WebSocket we can hold +// precedes the providers' geyser feeds, so it races instead. +func TestSolanaRacesRatherThanReferences(t *testing.T) { + if referenceChains["solana"] { + t.Error("solana has no reference that beats the providers' geyser feeds; " + + "it belongs in raceChains") + } + if !raceChains["solana"] { + t.Error("solana must stay in raceChains") + } +}