diff --git a/benchmarks/layerzero-message-latency.yml b/benchmarks/layerzero-message-latency.yml new file mode 100644 index 00000000..226ace7a --- /dev/null +++ b/benchmarks/layerzero-message-latency.yml @@ -0,0 +1,177 @@ +# OpenChainBench. Bench № 104 + +slug: layerzero-message-latency +number: "104" +title: Fastest LayerZero source chain by end-to-end message delivery +seo_title: "Fastest LayerZero source chain 2026" +seo_description: "{{best_name}} leads LayerZero end-to-end delivery at {{best_p50}} (source-tx to destination execution p50, 24h). Observed, not simulated." +subtitle: Wall-clock seconds from source-chain transaction to destination-chain execution for a LayerZero message, measured across LayerZero source chains via the public layerzero-scan feed. + +category: Bridges +status: draft +metric: LayerZero delivery +unit: ms +higher_is_better: false + +seo_intro: | + LayerZero is a cross-chain messaging network built on a + configurable Decentralized Verifier Network (DVN) stack. When a + transaction on a source chain emits a LayerZero message, the + application-selected DVNs each verify the source block, the + application-selected Executor then delivers the message on the + destination chain. This benchmark measures end-to-end message + delivery latency by source chain: wall-clock seconds from the + source-tx block timestamp to the destination-tx block timestamp for + every DELIVERED message returned by layerzero-scan. + Every value on this page is an observation of real user traffic + (Stargate, LayerZero-native applications, cross-chain token + bridges), never a canary transaction. Latency distribution reflects + the combined DVN verification time + Executor delivery time + + destination block inclusion. Sub-second BFT source chains (BNB, + Sui) minimize the observation floor; L1s with longer block times + (Ethereum ~12s) inherit that floor before DVN work even begins. + +abstract: | + For each LayerZero-supported source chain with non-trivial mainnet + traffic, we measure the wall-clock latency between a source-chain + transaction and destination-chain execution. Data source: the public + `https://scan.layerzero-api.com/v1/messages/latest` feed, polled + every 60 seconds with two pages of 150 messages each. Each + DELIVERED message's `destination.blockTimestamp - + source.blockTimestamp` is one observation of end-to-end latency, + recorded in a per-source-chain histogram. The p50/p90/p99 shown + here are computed via Prometheus `histogram_quantile` over the + last 24 hours. + +methodology: + - "Data source: `https://scan.layerzero-api.com/v1/messages/latest?limit=150`, the public LayerZero Scan API. Polled every 60 seconds, 2 pages per poll (cursor via `nextToken`)." + - "Latency definition: `destination.tx.blockTimestamp - source.tx.blockTimestamp` from a LayerZero message row with `status.name: DELIVERED`. Both timestamps are Unix seconds from the source and destination chain blocks respectively." + - "Filter: only `DELIVERED` messages contribute to latency. In-flight statuses (INFLIGHT, CONFIRMING, PAYLOAD_STORED, BLOCKED, FAILED) are counted separately in `lz_message_status_total` so a spike in CONFIRMING without matching DELIVERED surfaces a stuck-message incident." + - "Dedupe: last 10 000 guids kept in memory (rolling FIFO). LayerZero sustained rate is ~4 messages/second network-wide, so the cache holds ~40 minutes of history." + - "Bucketing: histogram with buckets 2, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 300, 600, 900, 1200, 1800 seconds (30 min ceiling)." + - "Guard: samples with delta `< 0` or `> 30 min` are dropped (clock skew, stuck messages re-executed later)." + - "No canary transactions: this bench observes organic LayerZero traffic (~250 messages/hour network-wide). No wallet is funded, no gas is paid." + - "Scope: LayerZero-supported source chains with mainnet traffic in the observation window. LayerZero exposes many exotic chains (orderly, flare, ape, robinhood) that show up as separate rows once volume is non-trivial; low-traffic sources will have wide error bars." + - "Fair-comparison caveat: LayerZero DVNs sign pre-finality by default, so end-to-end delivery excludes the source-chain finality wait that finality-aware networks (Chainlink CCIP) impose. Comparing raw numbers to CCIP on ETH-source lanes is not apples-to-apples; the meta-bench view (cross-chain-messaging-comparison) exposes a finality-normalized column." + +findings: + - "{{best_name}} currently leads LayerZero end-to-end delivery at {{best_p50}} (source-tx to destination execution p50, 24h) across measured source chains." + - "Sub-second BFT source chains lead: BNB Chain and Solana finalize source blocks fast enough that end-to-end delivery lands in single-digit seconds once DVN verification and Executor delivery complete." + - "Ethereum-source lanes inherit the ~12s block-time floor; delivery to the destination adds DVN verification round-trip (typically 20-60s) and destination block inclusion." + +faq: + - q: "What is LayerZero?" + a: "LayerZero is a cross-chain messaging network built on a configurable DVN (Decentralized Verifier Network) stack. Applications choose which DVNs verify their messages and which Executor delivers them, so the security model is application-defined rather than a single global consensus like Wormhole or CCIP." + - q: "What does this benchmark measure?" + a: "Wall-clock seconds from a source-chain transaction to destination-chain execution for LayerZero messages that reached DELIVERED status. It is a passive measurement of real user traffic , every observation is a LayerZero message a real user or protocol actually sent." + - q: "Why do some source chains have huge tails?" + a: "Two causes typically. First, some source chains have long block times (Ethereum ~12s), which sets a floor before DVN work even begins. Second, the DVN + Executor pair the application selects can be slow if it is a low-liquidity or under-resourced set , this is a choice the application makes, not a property of LayerZero itself." + - q: "How does the data get sourced?" + a: "The harness polls the public `https://scan.layerzero-api.com/v1/messages/latest` endpoint every 60 seconds and records `destination.blockTimestamp - source.blockTimestamp` for each new DELIVERED message. The endpoint is LayerZero's own scan API , no auth required. No canary transactions are sent, no user funds are used." + - q: "Which source chain is fastest for LayerZero today?" + a: "{{best_name}} currently leads at {{best_p50}} (p50 over the last 24 hours). The leaderboard re-sorts continuously against fresh Prometheus samples; the ranking on this page is the ranking right now." + - q: "Can I compare this to the Chainlink CCIP or Wormhole bench?" + a: "Only carefully. LayerZero DVNs sign pre-finality by default; CCIP waits for source-chain finality before its DON commits; Wormhole Guardians sign a VAA that a relayer then delivers separately. Each protocol measures a slightly different thing when you look at end-to-end wall-clock. The cross-chain messaging comparison meta-bench exposes a finality-normalized column so DVN+Executor work can be compared to CCIP's DON+execution work directly." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/layerzero-message-latency + +prometheus: + window: 24h + freshness_metric: lz_message_seen_total + +providers: + - slug: ethereum + name: Ethereum + tag: Ethereum → any chain, ~12s block time floor before DVN verification begins + formula: "50th percentile over 24h of LayerZero end-to-end delivery latency for messages sourced from Ethereum." + queries: + p50: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="ethereum"}[24h]))) + p90: histogram_quantile(0.90, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="ethereum"}[24h]))) + p99: histogram_quantile(0.99, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="ethereum"}[24h]))) + mean: sum(rate(lz_message_latency_milliseconds_sum{source_chain="ethereum"}[24h])) / sum(rate(lz_message_latency_milliseconds_count{source_chain="ethereum"}[24h])) + sample_size: sum(increase(lz_message_seen_total{source_chain="ethereum"}[24h])) + series: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="ethereum"}[1h]))) + + - slug: solana + name: Solana + tag: Solana → any chain, sub-second slot inclusion + DVN verification + formula: "50th percentile over 24h of LayerZero end-to-end delivery latency for messages sourced from Solana." + queries: + p50: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="solana"}[24h]))) + p90: histogram_quantile(0.90, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="solana"}[24h]))) + p99: histogram_quantile(0.99, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="solana"}[24h]))) + mean: sum(rate(lz_message_latency_milliseconds_sum{source_chain="solana"}[24h])) / sum(rate(lz_message_latency_milliseconds_count{source_chain="solana"}[24h])) + sample_size: sum(increase(lz_message_seen_total{source_chain="solana"}[24h])) + series: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="solana"}[1h]))) + + - slug: bnb + name: BNB Chain + tag: BNB Chain → any chain, ~3s block time + DVN verification + formula: "50th percentile over 24h of LayerZero end-to-end delivery latency for messages sourced from BNB Chain." + queries: + p50: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="bnb"}[24h]))) + p90: histogram_quantile(0.90, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="bnb"}[24h]))) + p99: histogram_quantile(0.99, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="bnb"}[24h]))) + mean: sum(rate(lz_message_latency_milliseconds_sum{source_chain="bnb"}[24h])) / sum(rate(lz_message_latency_milliseconds_count{source_chain="bnb"}[24h])) + sample_size: sum(increase(lz_message_seen_total{source_chain="bnb"}[24h])) + series: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="bnb"}[1h]))) + + - slug: arbitrum + name: Arbitrum + tag: Arbitrum One → any chain, sub-second L2 block + DVN verification + formula: "50th percentile over 24h of LayerZero end-to-end delivery latency for messages sourced from Arbitrum." + queries: + p50: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="arbitrum"}[24h]))) + p90: histogram_quantile(0.90, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="arbitrum"}[24h]))) + p99: histogram_quantile(0.99, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="arbitrum"}[24h]))) + mean: sum(rate(lz_message_latency_milliseconds_sum{source_chain="arbitrum"}[24h])) / sum(rate(lz_message_latency_milliseconds_count{source_chain="arbitrum"}[24h])) + sample_size: sum(increase(lz_message_seen_total{source_chain="arbitrum"}[24h])) + series: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="arbitrum"}[1h]))) + + - slug: base + name: Base + tag: Base → any chain, 2s block time + DVN verification + formula: "50th percentile over 24h of LayerZero end-to-end delivery latency for messages sourced from Base." + queries: + p50: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="base"}[24h]))) + p90: histogram_quantile(0.90, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="base"}[24h]))) + p99: histogram_quantile(0.99, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="base"}[24h]))) + mean: sum(rate(lz_message_latency_milliseconds_sum{source_chain="base"}[24h])) / sum(rate(lz_message_latency_milliseconds_count{source_chain="base"}[24h])) + sample_size: sum(increase(lz_message_seen_total{source_chain="base"}[24h])) + series: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="base"}[1h]))) + + - slug: optimism + name: Optimism + tag: Optimism → any chain, 2s block time + DVN verification + formula: "50th percentile over 24h of LayerZero end-to-end delivery latency for messages sourced from Optimism." + queries: + p50: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="optimism"}[24h]))) + p90: histogram_quantile(0.90, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="optimism"}[24h]))) + p99: histogram_quantile(0.99, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="optimism"}[24h]))) + mean: sum(rate(lz_message_latency_milliseconds_sum{source_chain="optimism"}[24h])) / sum(rate(lz_message_latency_milliseconds_count{source_chain="optimism"}[24h])) + sample_size: sum(increase(lz_message_seen_total{source_chain="optimism"}[24h])) + series: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="optimism"}[1h]))) + + - slug: polygon + name: Polygon + tag: Polygon PoS → any chain, Bor block time + DVN verification + formula: "50th percentile over 24h of LayerZero end-to-end delivery latency for messages sourced from Polygon." + queries: + p50: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="polygon"}[24h]))) + p90: histogram_quantile(0.90, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="polygon"}[24h]))) + p99: histogram_quantile(0.99, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="polygon"}[24h]))) + mean: sum(rate(lz_message_latency_milliseconds_sum{source_chain="polygon"}[24h])) / sum(rate(lz_message_latency_milliseconds_count{source_chain="polygon"}[24h])) + sample_size: sum(increase(lz_message_seen_total{source_chain="polygon"}[24h])) + series: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="polygon"}[1h]))) + + - slug: avalanche + name: Avalanche + tag: Avalanche C-Chain → any chain, Snowman++ sub-second finality + DVN verification + formula: "50th percentile over 24h of LayerZero end-to-end delivery latency for messages sourced from Avalanche." + queries: + p50: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="avalanche"}[24h]))) + p90: histogram_quantile(0.90, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="avalanche"}[24h]))) + p99: histogram_quantile(0.99, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="avalanche"}[24h]))) + mean: sum(rate(lz_message_latency_milliseconds_sum{source_chain="avalanche"}[24h])) / sum(rate(lz_message_latency_milliseconds_count{source_chain="avalanche"}[24h])) + sample_size: sum(increase(lz_message_seen_total{source_chain="avalanche"}[24h])) + series: histogram_quantile(0.50, sum by(le) (rate(lz_message_latency_milliseconds_bucket{source_chain="avalanche"}[1h]))) diff --git a/harnesses/layerzero-message-latency/Dockerfile b/harnesses/layerzero-message-latency/Dockerfile new file mode 100644 index 00000000..270b0b69 --- /dev/null +++ b/harnesses/layerzero-message-latency/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod ./ +RUN go mod download || true + +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/layerzero-message-latency/cmd/script/chains.go b/harnesses/layerzero-message-latency/cmd/script/chains.go new file mode 100644 index 00000000..2c888741 --- /dev/null +++ b/harnesses/layerzero-message-latency/cmd/script/chains.go @@ -0,0 +1,67 @@ +package main + +// lzChainSlug normalises LayerZero's `pathway.sender.chain` (kebab-cased +// human name: `ethereum`, `bsc`, `base`, `arbitrum`, `solana`, …) to +// the OCB canonical chain slug used across every other bench. +// +// LayerZero's naming is mostly already our canonical form (great luck); +// only a few divergences need explicit mapping: +// - `bsc` → `bnb` (we use the token symbol convention across the site) +// - LayerZero exposes a bunch of exotic chains (orderly, flare, ape, +// robinhood, hyperliquid) that map to our slugs where they exist. +// +// Unknown names fall through to a synthetic `chain-` slug in +// main.go so we never drop data silently. +var lzChainSlug = map[string]string{ + "ethereum": "ethereum", + "solana": "solana", + "bsc": "bnb", + "polygon": "polygon", + "avalanche": "avalanche", + "arbitrum": "arbitrum", + "optimism": "optimism", + "base": "base", + "linea": "linea", + "scroll": "scroll", + "blast": "blast", + "mantle": "mantle", + "celo": "celo", + "gnosis": "gnosis", + "fantom": "fantom", + "tron": "tron", + "metis": "metis", + "moonbeam": "moonbeam", + "kaia": "kaia", + "aurora": "aurora", + "cronos": "cronos", + "fraxtal": "fraxtal", + "zksync": "zksync", + "taiko": "taiko", + "soneium": "soneium", + "unichain": "unichain", + "world": "world-chain", + "berachain": "berachain", + "sonic": "sonic", + "monad": "monad", + "ink": "ink", + "opbnb": "opbnb", + "hyperliquid": "hyperliquid", + "robinhood": "robinhood", + "sui": "sui", + "aptos": "aptos", + "injective": "injective", + "sei": "sei", + // LayerZero-native / exotic chains — kept under their own slug so + // they show up as distinct rows rather than under `chain-` + // which reads worse. + "orderly": "chain-orderly", + "flare": "chain-flare", + "ape": "chain-ape", + "telos": "chain-telos", + "zircuit": "chain-zircuit", + "story": "chain-story", + "bob": "bob", + "kroma": "kroma", + "astar": "astar", + "apechain": "apechain", +} diff --git a/harnesses/layerzero-message-latency/cmd/script/main.go b/harnesses/layerzero-message-latency/cmd/script/main.go new file mode 100644 index 00000000..11a1c7ec --- /dev/null +++ b/harnesses/layerzero-message-latency/cmd/script/main.go @@ -0,0 +1,272 @@ +// layerzero-message-latency: passive LayerZero delivery-latency bench. +// +// Every 60s, polls two pages of https://scan.layerzero-api.com/v1/messages/latest +// for the most recent messages (max 150 per page). For each message the +// monitor has not seen before AND that has DELIVERED status (with both +// source and destination block timestamps present), computes +// `destination.blockTimestamp - source.blockTimestamp` and records it +// in a Prometheus histogram bucketed by source + destination chain. +// +// The harness never sends canary transactions. LayerZero handles enough +// production traffic (~250 msg/hour network-wide) that passive polling +// gives statistically robust p50/p90 per source chain within 24h. +// +// Design notes: +// - `?status=DELIVERED` querystring filter is broken/undocumented in +// the API — we filter client-side after unmarshaling. +// - Fresh INFLIGHT / CONFIRMING messages have empty source.tx blocks. +// They must be skipped without being added to the dedupe cache so +// the DELIVERED transition is observed on a later poll. +// - Timestamps are int64 Unix seconds (source-chain block times), not +// RFC3339 strings — no ms precision available, no indexer skew (a +// cleaner signal than Wormhole's `indexedAt`). +// - Two pages per poll: sustained 250 msg/hour ~= 4/min * 60s = 240 +// new messages per poll. One page (max 150) would miss ~90/min +// under peak load. Cursor is honored via ?nextToken=. +// - Dedupe on `guid` via 10k LRU. Sustained rate ~4 msg/s → cache +// holds ~40 min of history. +package main + +import ( + "container/list" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +const ( + lzMessagesBase = "https://scan.layerzero-api.com/v1/messages/latest" + lzPageLimit = 150 + lzPagesPerPoll = 2 + pollInterval = 60 * time.Second + requestTimeout = 15 * time.Second + dedupeCacheMaxLen = 10_000 + metricsListenAddr = ":2112" + // Guard against clock skew and stuck messages. LayerZero rarely + // exceeds 15 min end-to-end; anything beyond 30 min is almost + // certainly bad data. + maxLatencyMs = 1_800_000 // 30 min +) + +type lzTx struct { + BlockTimestamp int64 `json:"blockTimestamp"` +} + +type lzStage struct { + Tx lzTx `json:"tx"` +} + +type lzChainRef struct { + Chain string `json:"chain"` +} + +type lzPathway struct { + Sender lzChainRef `json:"sender"` + Receiver lzChainRef `json:"receiver"` +} + +type lzStatus struct { + Name string `json:"name"` +} + +type lzMessage struct { + GUID string `json:"guid"` + Status lzStatus `json:"status"` + Pathway lzPathway `json:"pathway"` + Source lzStage `json:"source"` + Destination lzStage `json:"destination"` +} + +type lzResponse struct { + Data []lzMessage `json:"data"` + NextToken string `json:"nextToken"` +} + +type lruSet struct { + max int + order *list.List + set map[string]*list.Element +} + +func newLRUSet(max int) *lruSet { + return &lruSet{max: max, order: list.New(), set: make(map[string]*list.Element, max)} +} + +func (l *lruSet) contains(k string) bool { _, ok := l.set[k]; return ok } + +func (l *lruSet) add(k string) { + if _, ok := l.set[k]; ok { + return + } + el := l.order.PushBack(k) + l.set[k] = el + for l.order.Len() > l.max { + front := l.order.Front() + if front == nil { + return + } + l.order.Remove(front) + delete(l.set, front.Value.(string)) + } +} + +func (l *lruSet) size() int { return l.order.Len() } + +func chainSlug(name string) string { + if s, ok := lzChainSlug[name]; ok { + return s + } + return "chain-" + strings.ToLower(name) +} + +// fetchPage retrieves one page. Returns messages + nextToken (empty if last). +func fetchPage(ctx context.Context, client *http.Client, nextToken string) ([]lzMessage, string, error) { + v := url.Values{} + v.Set("limit", fmt.Sprintf("%d", lzPageLimit)) + if nextToken != "" { + v.Set("nextToken", nextToken) + } + target := lzMessagesBase + "?" + v.Encode() + req, err := http.NewRequestWithContext(ctx, "GET", target, nil) + if err != nil { + return nil, "", fmt.Errorf("request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "OpenChainBench/layerzero-message-latency") + resp, err := client.Do(req) + if err != nil { + return nil, "", fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) + return nil, "", fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) + } + var parsed lzResponse + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, "", fmt.Errorf("decode: %w", err) + } + return parsed.Data, parsed.NextToken, nil +} + +func poll(ctx context.Context, client *http.Client, seen *lruSet) error { + nextToken := "" + totalRows := 0 + fresh := 0 + for page := 0; page < lzPagesPerPoll; page++ { + msgs, nt, err := fetchPage(ctx, client, nextToken) + if err != nil { + return err + } + totalRows += len(msgs) + for _, m := range msgs { + if m.GUID == "" { + continue + } + srcSlug := chainSlug(m.Pathway.Sender.Chain) + + // Record status once (first sighting). Do NOT dedupe on + // non-DELIVERED yet — we want to observe DELIVERED transition + // on later poll. + if !seen.contains(m.GUID) { + lzStatusTotal.WithLabelValues(srcSlug, m.Status.Name).Inc() + } + + if m.Status.Name != "DELIVERED" { + continue + } + if m.Source.Tx.BlockTimestamp == 0 || m.Destination.Tx.BlockTimestamp == 0 { + // Rare — DELIVERED without one of the tx blocks. Mark + // seen so we don't loop forever on a malformed row. + seen.add(m.GUID) + continue + } + if seen.contains(m.GUID) { + continue + } + + deltaSec := m.Destination.Tx.BlockTimestamp - m.Source.Tx.BlockTimestamp + deltaMs := float64(deltaSec) * 1000 + if deltaMs < 0 || deltaMs > maxLatencyMs { + seen.add(m.GUID) + continue + } + dstSlug := chainSlug(m.Pathway.Receiver.Chain) + lzLatencyMs.WithLabelValues(srcSlug, dstSlug).Observe(deltaMs) + lzSeenTotal.WithLabelValues(srcSlug, dstSlug).Inc() + seen.add(m.GUID) + fresh++ + } + if nt == "" { + break + } + nextToken = nt + } + lzDedupeCacheSize.Set(float64(seen.size())) + log.Printf("poll: %d rows, %d fresh, dedupe=%d", totalRows, fresh, seen.size()) + return nil +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + log.Println("layerzero-message-latency: starting") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client := &http.Client{Timeout: requestTimeout} + seen := newLRUSet(dedupeCacheMaxLen) + var mu sync.Mutex + + go func() { + http.Handle("/metrics", promhttp.Handler()) + http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte("ok")) + }) + log.Printf("metrics: listening on %s", metricsListenAddr) + if err := http.ListenAndServe(metricsListenAddr, nil); err != nil { + log.Fatalf("metrics server: %v", err) + } + }() + + mu.Lock() + if err := poll(ctx, client, seen); err != nil { + log.Printf("poll error: %v", err) + lzPollErrors.Inc() + } + mu.Unlock() + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + + for { + select { + case <-ticker.C: + mu.Lock() + if err := poll(ctx, client, seen); err != nil { + log.Printf("poll error: %v", err) + lzPollErrors.Inc() + } + mu.Unlock() + case <-sig: + log.Println("shutdown") + return + } + } +} diff --git a/harnesses/layerzero-message-latency/cmd/script/metrics.go b/harnesses/layerzero-message-latency/cmd/script/metrics.go new file mode 100644 index 00000000..734bfbf4 --- /dev/null +++ b/harnesses/layerzero-message-latency/cmd/script/metrics.go @@ -0,0 +1,69 @@ +package main + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +// lz_message_latency_milliseconds is a histogram of LayerZero end-to-end +// message delivery latency, bucketed by source + destination chain. +// Latency = `destination.tx.blockTimestamp - source.tx.blockTimestamp` +// from the layerzero-scan `/v1/messages/latest` payload. Timestamps are +// source-chain and destination-chain block times, so this measures +// wall-clock delivery time as experienced end-to-end (DVN verification +// + executor delivery), not just protocol signing latency. +// +// Emitted in MILLISECONDS to match the site's `unit: ms` display +// convention. Buckets extend to 30 min: LayerZero rarely runs longer +// than a few minutes end-to-end (fewer finality waits than CCIP), but +// the tail catches DVN misconfig or destination-chain congestion. +var ( + lzLatencyMs = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "lz_message_latency_milliseconds", + Help: "End-to-end delivery latency (ms) for a LayerZero message, from source-chain block to destination-chain block, labeled by source + destination chain.", + Buckets: []float64{2_000, 5_000, 10_000, 15_000, 20_000, 30_000, 45_000, 60_000, 90_000, 120_000, 180_000, 300_000, 600_000, 900_000, 1_200_000, 1_800_000}, + }, + []string{"source_chain", "dest_chain"}, + ) + + lzSeenTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "lz_message_seen_total", + Help: "Total unique DELIVERED LayerZero messages observed by this monitor, by source + destination chain.", + }, + []string{"source_chain", "dest_chain"}, + ) + + // Non-DELIVERED statuses observed once per guid: INFLIGHT, CONFIRMING, + // FAILED, PAYLOAD_STORED, BLOCKED, etc. Surface stuck-message spikes + // without polluting the latency histogram. + lzStatusTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "lz_message_status_total", + Help: "LayerZero messages seen per status (DELIVERED, INFLIGHT, CONFIRMING, FAILED, ...), by source chain.", + }, + []string{"source_chain", "status"}, + ) + + lzPollErrors = prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "lz_message_poll_errors_total", + Help: "Total failed pollings of the LayerZero-scan /v1/messages/latest feed.", + }, + ) + + lzDedupeCacheSize = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "lz_message_dedupe_cache_size", + Help: "Number of recent LayerZero guids the monitor keeps for deduplication.", + }, + ) +) + +func init() { + prometheus.MustRegister(lzLatencyMs) + prometheus.MustRegister(lzSeenTotal) + prometheus.MustRegister(lzStatusTotal) + prometheus.MustRegister(lzPollErrors) + prometheus.MustRegister(lzDedupeCacheSize) +} diff --git a/harnesses/layerzero-message-latency/go.mod b/harnesses/layerzero-message-latency/go.mod new file mode 100644 index 00000000..f3b51805 --- /dev/null +++ b/harnesses/layerzero-message-latency/go.mod @@ -0,0 +1,18 @@ +module layerzero-message-latency + +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.17.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.36.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/harnesses/layerzero-message-latency/go.sum b/harnesses/layerzero-message-latency/go.sum new file mode 100644 index 00000000..f415d46b --- /dev/null +++ b/harnesses/layerzero-message-latency/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.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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/src/components/chart-export-button.tsx b/src/components/chart-export-button.tsx index 47478bdc..5705f3c0 100644 --- a/src/components/chart-export-button.tsx +++ b/src/components/chart-export-button.tsx @@ -19,9 +19,11 @@ */ import { useCallback, useState } from "react"; -import { Copy, Download, Check, Loader2 } from "lucide-react"; +import { Copy, Download, Check, Loader2, FileText } from "lucide-react"; import { toBlob } from "html-to-image"; +type CsvLine = { slug: string; name: string; values: (number | null)[] }; + type Props = { /** Ref to the element to capture. Should include chart + watermark. */ targetRef: import("react").RefObject; @@ -29,6 +31,12 @@ type Props = { filename?: string; /** Optional class to tweak wrapper size. */ className?: string; + /** When provided, shows a CSV download button. */ + csvLines?: CsvLine[]; + /** Total hours the series spans (used to back-fill timestamps). */ + rangeHours?: number; + /** Expected number of points in each series. */ + rangePoints?: number; }; const PIXEL_RATIO = 2; // Retina-quality PNG for legible screenshots. @@ -37,10 +45,14 @@ export function ChartExportButton({ targetRef, filename = "openchainbench-chart", className = "", + csvLines, + rangeHours = 24, + rangePoints, }: Props) { const [state, setState] = useState< "idle" | "working" | "copied" | "downloaded" | "error" >("idle"); + const [csvState, setCsvState] = useState<"idle" | "done">("idle"); const capture = useCallback(async (): Promise => { const el = targetRef.current; @@ -121,6 +133,35 @@ export function ChartExportButton({ } }, [capture, filename]); + const onDownloadCsv = useCallback(() => { + if (!csvLines || csvLines.length === 0) return; + const now = Date.now(); + const rangeMs = rangeHours * 3_600_000; + const nPoints = rangePoints ?? csvLines[0]?.values.length ?? 72; + const stepMs = rangeMs / nPoints; + const header = ["timestamp", ...csvLines.map((l) => l.name)].join(","); + const rows = (csvLines[0]?.values ?? []).map((_, i) => { + const ts = new Date(now - rangeMs + (i + 1) * stepMs).toISOString(); + const vals = csvLines.map((l) => { + const v = l.values[i]; + return v == null ? "" : String(v); + }); + return [ts, ...vals].join(","); + }); + const blob = new Blob([[header, ...rows].join("\n")], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${filename}.csv`; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 4000); + setCsvState("done"); + setTimeout(() => setCsvState("idle"), 1600); + }, [csvLines, rangeHours, rangePoints, filename]); + return ( 0 ? "" : "rounded-r-md"}`} title="Download chart as PNG" aria-label="Download chart as PNG" > @@ -164,6 +205,24 @@ export function ChartExportButton({ )} + {csvLines && csvLines.length > 0 && ( + <> + + + + )} ); } diff --git a/src/components/time-series-chart/index.tsx b/src/components/time-series-chart/index.tsx index 0fb6f0ea..0a4d1781 100644 --- a/src/components/time-series-chart/index.tsx +++ b/src/components/time-series-chart/index.tsx @@ -451,6 +451,9 @@ export function TimeSeriesChart({ {zoom && (