diff --git a/.gitignore b/.gitignore index a00e396f..32b1bcfe 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,9 @@ docs/*-cost-model.md *.tsbuildinfo next-env.d.ts .claude/worktrees/ + +# compiled harness binaries (go build artifacts) +harnesses/*/script +harnesses/*/monitor +harnesses/*/cmd/script/script +harnesses/*/cmd/monitor/monitor 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 7f1dfef9..3a8494fa 100644 --- a/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go +++ b/harnesses/aggregator-head-lag/cmd/script/head_lag_monitor.go @@ -28,10 +28,16 @@ type HeadLagPool struct { // Pools to monitor - high activity pools for accurate lag measurement var headLagPools = []HeadLagPool{ { + // Switched 2026-05-29 to the Raydium SOL/USDC ($8.7M liq, $2.6M + // vol24h vs the previous Orca pool 7qbRF6Y… at $206K / $46K). + // This is the only Solana pool GMGN pushes per-pair events for, + // so all 4 providers measure the same reference pool → true + // apples-to-apples instead of GMGN-on-slot-heartbeat vs others- + // on-per-swap. Bonus: much higher event rate tightens stats. Name: "SOL/USDC Raydium", Blockchain: "solana", NetworkID: 1399811149, - Address: "7qbRF6YsyGuLUVs6Y1q64bdVrfe4ZcUUz1JRdoVNUJnm", + Address: "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2", ChainName: "solana", }, { @@ -42,10 +48,17 @@ var headLagPools = []HeadLagPool{ ChainName: "base", }, { - Name: "WBNB/BUSD PancakeSwap", + // Switched 2026-05-29 off the WBNB/BUSD pool ($104K vol24h — + // BUSD was deprecated by Binance in 2023 so swap events on it + // are too sparse for percentile stats: the bench was reporting + // zero BNB samples across all 3 regions). New pool is + // PancakeSwap V3 WBNB/USDT 0.01% with $17M liq + $100M vol24h — + // ~1000x event rate, the canonical BNB pair every aggregator + // indexes. + Name: "WBNB/USDT PancakeSwap V3", Blockchain: "evm:56", NetworkID: 56, - Address: "0x58f876857a02d6762e0101bb5c46a8c1ed44dc16", + Address: "0x172fcd41e0913e95784454622d1c3724f546f849", ChainName: "bnb", }, } diff --git a/harnesses/aggregator-head-lag/cmd/script/log_buffer.go b/harnesses/aggregator-head-lag/cmd/script/log_buffer.go deleted file mode 100644 index a0cce9a2..00000000 --- a/harnesses/aggregator-head-lag/cmd/script/log_buffer.go +++ /dev/null @@ -1,106 +0,0 @@ -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). -type logBuffer struct { - mu sync.Mutex - lines []string - max int -} - -const logBufferMax = 5000 - -var globalLogBuffer = &logBuffer{max: logBufferMax} - -func (b *logBuffer) 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 *logBuffer) 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 -} - -// 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. This 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 { - // Fallback: don't intercept. /logs will be empty but everything else still works. - 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) - // Allow long lines (default is 64KB, bump to 1MB for safety) - buf := make([]byte, 0, 1024*1024) - scanner.Buffer(buf, 1024*1024) - for scanner.Scan() { - line := scanner.Text() - fmt.Fprintln(originalStdout, line) - globalLogBuffer.push(line) - } - // Pipe closed (process shutdown) — drain anything left - _, _ = io.Copy(originalStdout, r) - }() -} - -// setupLogsEndpoint exposes GET /logs?tail=N (default 500, max logBufferMax). -// Fail-secure: when LOGS_TOKEN env var is not set, the endpoint returns 404 -// (refuses by default). When set, requires header `X-Logs-Token` to match. -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/aggregator-head-lag/cmd/script/loghub.go b/harnesses/aggregator-head-lag/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/aggregator-head-lag/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/aggregator-head-lag/cmd/script/metrics.go b/harnesses/aggregator-head-lag/cmd/script/metrics.go index 288e1b16..b31e2c04 100644 --- a/harnesses/aggregator-head-lag/cmd/script/metrics.go +++ b/harnesses/aggregator-head-lag/cmd/script/metrics.go @@ -411,11 +411,11 @@ func StartMetricsServer(addr string) error { // Prometheus metrics endpoint mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) // Admin cleanup endpoint setupCleanupEndpoint(mux) - // Debug: tail of in-memory log ring - setupLogsEndpoint(mux) + // Debug: tail of in-memory log ring (shared loghub package) return http.ListenAndServe(addr, mux) } diff --git a/harnesses/aggregator-head-lag/cmd/script/update_metrics_calls.sh b/harnesses/aggregator-head-lag/cmd/script/update_metrics_calls.sh new file mode 100755 index 00000000..bd84baa2 --- /dev/null +++ b/harnesses/aggregator-head-lag/cmd/script/update_metrics_calls.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Auto-update all RecordX calls to include region parameter + +FILES="head_lag_monitor.go codex_rest_monitor.go mobula_rest_monitor.go quote_api_monitor.go geckoterminal_monitor.go moralis_rest_monitor.go metadata_coverage_monitor.go" + +for file in $FILES; do + if [ -f "$file" ]; then + echo "Updating $file..." + # Use perl for multiline regex + perl -i -pe 's/RecordHeadLag\(([^)]+)\)/RecordHeadLag($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordRESTLatency\(([^)]+)\)/RecordRESTLatency($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordRESTError\(([^)]+)\)/RecordRESTError($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordQuoteAPILatency\(([^)]+)\)/RecordQuoteAPILatency($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordQuoteAPIError\(([^)]+)\)/RecordQuoteAPIError($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordHeadLagError\(([^)]+)\)/RecordHeadLagError($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordCodexBlockNumber\(([^)]+)\)/RecordCodexBlockNumber($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordMetadataCoverage\(([^)]+)\)/RecordMetadataCoverage($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordMetadataLatency\(([^)]+)\)/RecordMetadataLatency($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordPoolDiscoveryLatency\(([^)]+)\)/RecordPoolDiscoveryLatency($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordPoolDiscoveryError\(([^)]+)\)/RecordPoolDiscoveryError($1, config.MonitorRegion)/g' "$file" + fi +done + +echo "✓ All files updated" diff --git a/harnesses/bridge-monitor/Dockerfile b/harnesses/bridge-monitor/Dockerfile index d8836ea3..75a62cad 100644 --- a/harnesses/bridge-monitor/Dockerfile +++ b/harnesses/bridge-monitor/Dockerfile @@ -9,10 +9,7 @@ RUN go mod download # Copy source code COPY . . -# Build the monitor binary (continuous quote loop + execution scheduler -# that emits all the Prometheus metrics consumed by bridge-quote-latency -# and bridge-fee). The rebalance utility lives at ./cmd/rebalance and can -# be built separately with `go build ./cmd/rebalance`. +# Build the binary RUN CGO_ENABLED=0 GOOS=linux go build -o /bridge-monitor ./cmd/monitor # Final stage diff --git a/harnesses/buyback-audit/cmd/script/loghub.go b/harnesses/buyback-audit/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/buyback-audit/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/buyback-audit/cmd/script/main.go b/harnesses/buyback-audit/cmd/script/main.go index b894e702..101e02a2 100644 --- a/harnesses/buyback-audit/cmd/script/main.go +++ b/harnesses/buyback-audit/cmd/script/main.go @@ -17,6 +17,7 @@ import ( // listener away from the address Prometheus expects. func main() { + installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== Buyback Execution Audit Harness (OCB #018) ===") fmt.Println("Measures executed_USD / promised_USD on-chain per protocol over 7d & 30d.") fmt.Println() diff --git a/harnesses/buyback-audit/cmd/script/metrics.go b/harnesses/buyback-audit/cmd/script/metrics.go index bcacd8c5..83c5de59 100644 --- a/harnesses/buyback-audit/cmd/script/metrics.go +++ b/harnesses/buyback-audit/cmd/script/metrics.go @@ -70,6 +70,7 @@ var ( 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.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) diff --git a/harnesses/gas-estimation/cmd/script/loghub.go b/harnesses/gas-estimation/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/gas-estimation/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/gas-estimation/cmd/script/main.go b/harnesses/gas-estimation/cmd/script/main.go index e25cfc83..0a7b69b2 100644 --- a/harnesses/gas-estimation/cmd/script/main.go +++ b/harnesses/gas-estimation/cmd/script/main.go @@ -18,6 +18,7 @@ import ( // l2-block-time (see mobula-api commit 833026a719). func main() { + installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== Gas Estimation Accuracy Harness ===") fmt.Println("OpenChainBench - multi-chain gas oracle prediction error.") fmt.Println() diff --git a/harnesses/gas-estimation/cmd/script/metrics.go b/harnesses/gas-estimation/cmd/script/metrics.go index d933fa14..86eb774f 100644 --- a/harnesses/gas-estimation/cmd/script/metrics.go +++ b/harnesses/gas-estimation/cmd/script/metrics.go @@ -114,6 +114,7 @@ var ( 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")) }) diff --git a/harnesses/l1-finality/cmd/script/cardano.go b/harnesses/l1-finality/cmd/script/cardano.go index 297077f2..2a65a3e1 100644 --- a/harnesses/l1-finality/cmd/script/cardano.go +++ b/harnesses/l1-finality/cmd/script/cardano.go @@ -10,8 +10,11 @@ import ( "time" ) -// Cardano: Koios free public API. Probabilistic finality with strong -// settlement after ~50 blocks (~17 minutes at 20 s slot times). +// Cardano: Koios free public API. Probabilistic finality. We measure +// the gap at the practical-settlement convention used by major +// exchanges (Coinbase = 10 confs, Kraken = 15) — the depth defined in +// config.go (currently 15). Theoretical Ouroboros Praos full settlement +// is k = 2160 blocks (~12 h) but no production actor waits that long. // Koios: /tip is fast, /blocks?block_height=eq.X also fast. // /blocks with order+limit is broken under load. Use /tip + a specific diff --git a/harnesses/l1-finality/cmd/script/log_buffer.go b/harnesses/l1-finality/cmd/script/log_buffer.go deleted file mode 100644 index a650d3e5..00000000 --- a/harnesses/l1-finality/cmd/script/log_buffer.go +++ /dev/null @@ -1,87 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "io" - "net/http" - "os" - "strconv" - "sync" - "time" -) - -const logBufferMax = 5000 - -type logBuffer struct { - mu sync.Mutex - lines []string -} - -var globalLogBuffer = &logBuffer{} - -func (b *logBuffer) push(line string) { - entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line - b.mu.Lock() - if len(b.lines) >= logBufferMax { - b.lines = append(b.lines[1:], entry) - } else { - b.lines = append(b.lines, entry) - } - b.mu.Unlock() -} - -func (b *logBuffer) 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 -} - -func installLogCapture() { - original := os.Stdout - r, w, err := os.Pipe() - if err != nil { - fmt.Fprintf(original, "[log_buffer] pipe failed: %v\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(original, line) - globalLogBuffer.push(line) - } - _, _ = io.Copy(original, r) - }() -} - -func setupLogsEndpoint(mux *http.ServeMux) { - expectedToken := os.Getenv("LOGS_TOKEN") - mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { - if expectedToken != "" && 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/l1-finality/cmd/script/loghub.go b/harnesses/l1-finality/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/l1-finality/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/l1-finality/cmd/script/main.go b/harnesses/l1-finality/cmd/script/main.go index 09696a9d..45a97bba 100644 --- a/harnesses/l1-finality/cmd/script/main.go +++ b/harnesses/l1-finality/cmd/script/main.go @@ -10,7 +10,7 @@ import ( ) func main() { - installLogCapture() + installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== L1 Finality Lag Monitor ===") fmt.Println("Bench № 006 — measures wall-clock distance between latest and finalized blocks per chain.") fmt.Println() diff --git a/harnesses/l1-finality/cmd/script/metrics.go b/harnesses/l1-finality/cmd/script/metrics.go index 87d4c545..a00cfd35 100644 --- a/harnesses/l1-finality/cmd/script/metrics.go +++ b/harnesses/l1-finality/cmd/script/metrics.go @@ -162,7 +162,7 @@ func recordDebugSnapshot(s FinalitySample) { func StartMetricsServer(addr string) error { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) - setupLogsEndpoint(mux) + mux.Handle("/logs", logsHandler()) setupFinalityDebugEndpoint(mux) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) }) return http.ListenAndServe(addr, mux) diff --git a/harnesses/l1-finality/cmd/script/stellar_ws.go b/harnesses/l1-finality/cmd/script/stellar_ws.go index 25932cbf..a5dfe756 100644 --- a/harnesses/l1-finality/cmd/script/stellar_ws.go +++ b/harnesses/l1-finality/cmd/script/stellar_ws.go @@ -108,13 +108,24 @@ func runStellarSSE(cursor string, lastSeen *time.Time, lastSeq *int64) (string, // those polluted p50/p90 down to 0ms even though the // real SCP cadence is 5-7s. Compare event `closed_at` // against now: if it's older than ~6s the event is - // historical and we drop it. We also reset lastSeen so - // the next live event doesn't measure lag against a - // stale pre-disconnect baseline. + // historical and we drop it. + // + // We DO NOT reset lastSeen here. The previous version + // did `*lastSeen = time.Time{}`, which combined with the + // `!lastSeen.IsZero()` guard below caused the first + // live event after every reconnect to be silently + // skipped. Since Horizon force-closes every ~10s and the + // SCP cadence is ~5-7s, we'd typically see only 1-2 live + // events between reconnects — losing the first one left + // the gauge mostly empty (Prom dashboard showed Stellar + // as silent). With the reset removed, the first live + // event after each reconnect contributes a sample using + // the lastSeen baseline that survived from the previous + // connection cycle. The 30 s sanity bound below filters + // the natural reconnect outlier so it doesn't skew p50. closedAt, perr := time.Parse(time.RFC3339, ev.ClosedAt) isLive := perr == nil && time.Since(closedAt) < 6*time.Second if !isLive { - *lastSeen = time.Time{} *lastSeq = ev.Sequence continue } @@ -122,7 +133,11 @@ func runStellarSSE(cursor string, lastSeen *time.Time, lastSeq *int64) (string, now := time.Now() if !lastSeen.IsZero() && *lastSeq > 0 && ev.Sequence > *lastSeq { lagMs := float64(now.Sub(*lastSeen).Milliseconds()) - if lagMs >= 0 { + // Real SCP cadence is 5-7 s; anything > 30 s is a + // reconnect artifact (we crossed a 10 s SSE close + + // a few seconds of replay catch-up) and should not + // pollute the distribution. + if lagMs >= 0 && lagMs < 30_000 { wallClockLagGauge.WithLabelValues("stellar").Set(lagMs) wallClockLagSum.WithLabelValues("stellar").Observe(lagMs) wallClockSampleCtr.WithLabelValues("stellar").Inc() diff --git a/harnesses/l1-finality/cmd/script/ton_ws.go b/harnesses/l1-finality/cmd/script/ton_ws.go index 6a1727d4..8b0f75be 100644 --- a/harnesses/l1-finality/cmd/script/ton_ws.go +++ b/harnesses/l1-finality/cmd/script/ton_ws.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "os" "strings" "sync" "time" @@ -38,7 +39,11 @@ type tonSSEMessage struct { } // StartTONWallClock launches a persistent SSE subscriber for TON -// masterchain. Reconnects with exponential backoff on error. +// masterchain. Reconnects with exponential backoff on error. When the +// SSE stream is unavailable (tonapi gated it behind auth in 2026-06 and +// deprecated it in favor of webhooks), falls back to fast-polling the +// anonymous REST head endpoint so the wallclock metrics keep flowing at +// ~±300 ms precision instead of flatlining at health=0. func StartTONWallClock() { go func() { backoff := 2 * time.Second @@ -47,6 +52,10 @@ func StartTONWallClock() { if err != nil { fmt.Printf("[L1][ton] SSE error: %v (reconnecting in %v)\n", err, backoff) wallClockHealth.WithLabelValues("ton").Set(0) + // Run the REST fallback for a window, then retry SSE + // (the key may have been provisioned + service restarted, + // or tonapi may have restored the stream). + pollTONWallClock(5 * time.Minute) } time.Sleep(backoff) if backoff < 60*time.Second { @@ -56,6 +65,80 @@ func StartTONWallClock() { }() } +// pollTONWallClock approximates the SSE wall-clock measurement by +// polling masterchain-head every 300 ms (anonymous REST tier allows it; +// 429s back the cadence off). Emits the same metric family as the SSE +// path: first sight of seqno N finalizes N-1. +func pollTONWallClock(window time.Duration) { + fmt.Println("[L1][ton] falling back to REST fast-poll wallclock") + client := &http.Client{Timeout: 5 * time.Second} + st := &tonState{firstSeen: map[int64]time.Time{}} + interval := 300 * time.Millisecond + deadline := time.Now().Add(window) + healthy := false + for time.Now().Before(deadline) { + head, err := tonapiHead(client, tonAPIBase) + if err != nil { + if strings.Contains(err.Error(), "status_429") && interval < 2*time.Second { + interval *= 2 + fmt.Printf("[L1][ton] poll throttled, backing off to %v\n", interval) + } + time.Sleep(interval) + continue + } + if !healthy { + wallClockHealth.WithLabelValues("ton").Set(1) + healthy = true + } + // Cap at 2 s in poll mode: TON masterchain cadence is 0.4-0.7 s, + // so a multi-second "lag" here is poll aliasing (429 backoff + // stretching the cadence), not finality. Observed pre-cap: 10.6 s + // garbage samples polluting the 24h histogram. + st.observeSeqno(head.Seqno, 2000) + time.Sleep(interval) + } + wallClockHealth.WithLabelValues("ton").Set(0) +} + +// observeSeqno records the first-seen time of a masterchain seqno and +// emits the finality lag for the previous block, mirroring handleData. +// maxLagMs > 0 discards implausible lags (poll-mode aliasing guard); +// 0 means no cap (SSE events carry true arrival times). +func (st *tonState) observeSeqno(seqno int64, maxLagMs float64) { + if seqno <= 0 { + return + } + now := time.Now() + st.mu.Lock() + defer st.mu.Unlock() + if _, ok := st.firstSeen[seqno]; !ok { + st.firstSeen[seqno] = now + } + prev := seqno - 1 + if t, ok := st.firstSeen[prev]; ok && prev > st.lastSeen { + lagMs := float64(now.Sub(t).Milliseconds()) + if lagMs >= 0 && (maxLagMs == 0 || lagMs <= maxLagMs) { + wallClockLagGauge.WithLabelValues("ton").Set(lagMs) + wallClockLagSum.WithLabelValues("ton").Observe(lagMs) + wallClockSampleCtr.WithLabelValues("ton").Inc() + mode := "" + if maxLagMs > 0 { + mode = " (poll)" + } + fmt.Printf("[L1][ton] block=%d wall-clock-lag=%.0fms%s\n", prev, lagMs, mode) + } + st.lastSeen = prev + } + if seqno > 1000 { + cutoff := seqno - 1000 + for h := range st.firstSeen { + if h < cutoff { + delete(st.firstSeen, h) + } + } + } +} + func runTONSSE() error { req, err := http.NewRequest("GET", tonSSEURL, nil) if err != nil { @@ -63,6 +146,12 @@ func runTONSSE() error { } req.Header.Set("Accept", "text/event-stream") req.Header.Set("Cache-Control", "no-cache") + // tonapi.io gated the SSE stream behind auth (observed 2026-06-11: + // anonymous requests get 401; REST endpoints stay open). Reuse the + // same key the REST poller sends. + if k := os.Getenv("TON_API_KEY"); k != "" { + req.Header.Set("Authorization", "Bearer "+k) + } client := &http.Client{Timeout: 0} resp, err := client.Do(req) @@ -70,6 +159,9 @@ func runTONSSE() error { return fmt.Errorf("dial: %w", err) } defer resp.Body.Close() + if resp.StatusCode == 401 && os.Getenv("TON_API_KEY") == "" { + return fmt.Errorf("status_401 (tonapi SSE now requires a key: set TON_API_KEY on this service, free tier at tonconsole.com)") + } if resp.StatusCode != 200 { return fmt.Errorf("status_%d", resp.StatusCode) } @@ -113,37 +205,7 @@ func (st *tonState) handleData(payload string) { if msg.Workchain != -1 || msg.Seqno <= 0 { return } - - now := time.Now() - st.mu.Lock() - defer st.mu.Unlock() - - if _, ok := st.firstSeen[msg.Seqno]; !ok { - st.firstSeen[msg.Seqno] = now - } - - // Block N is finalized once N+1 is observed. So when we see seqno N - // for the first time, look up the previous seqno's first-seen time - // and emit the lag. - prev := msg.Seqno - 1 - if t, ok := st.firstSeen[prev]; ok && prev > st.lastSeen { - lagMs := float64(now.Sub(t).Milliseconds()) - if lagMs >= 0 { - wallClockLagGauge.WithLabelValues("ton").Set(lagMs) - wallClockLagSum.WithLabelValues("ton").Observe(lagMs) - wallClockSampleCtr.WithLabelValues("ton").Inc() - fmt.Printf("[L1][ton] block=%d wall-clock-lag=%.0fms\n", prev, lagMs) - } - st.lastSeen = prev - } - - // GC seen entries older than 1000 blocks. - if msg.Seqno > 1000 { - cutoff := msg.Seqno - 1000 - for h := range st.firstSeen { - if h < cutoff { - delete(st.firstSeen, h) - } - } - } + // Block N is finalized once N+1 is observed: shared emission logic + // with the REST fallback poller. + st.observeSeqno(msg.Seqno, 0) } diff --git a/harnesses/l2-block-time/cmd/script/loghub.go b/harnesses/l2-block-time/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/l2-block-time/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/l2-block-time/cmd/script/main.go b/harnesses/l2-block-time/cmd/script/main.go index 5b615bb3..3b10fbcf 100644 --- a/harnesses/l2-block-time/cmd/script/main.go +++ b/harnesses/l2-block-time/cmd/script/main.go @@ -8,6 +8,7 @@ import ( ) func main() { + installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== L2 Block Time Monitor ===") fmt.Println("Bench № 009 — measures live wall-clock interval between newHeads events on each L2 sequencer.") fmt.Println() diff --git a/harnesses/l2-block-time/cmd/script/metrics.go b/harnesses/l2-block-time/cmd/script/metrics.go index 400fa3da..31c1fe50 100644 --- a/harnesses/l2-block-time/cmd/script/metrics.go +++ b/harnesses/l2-block-time/cmd/script/metrics.go @@ -60,6 +60,7 @@ var ( 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")) }) diff --git a/harnesses/metadata-coverage/cmd/script/log_buffer.go b/harnesses/metadata-coverage/cmd/script/log_buffer.go deleted file mode 100644 index 0f7455e0..00000000 --- a/harnesses/metadata-coverage/cmd/script/log_buffer.go +++ /dev/null @@ -1,102 +0,0 @@ -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). -type logBuffer struct { - mu sync.Mutex - lines []string - max int -} - -const logBufferMax = 5000 - -var globalLogBuffer = &logBuffer{max: logBufferMax} - -func (b *logBuffer) 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 *logBuffer) 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 -} - -// 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. This 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 { - // Fallback: don't intercept. /logs will be empty but everything else still works. - 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) - // Allow long lines (default is 64KB, bump to 1MB for safety) - buf := make([]byte, 0, 1024*1024) - scanner.Buffer(buf, 1024*1024) - for scanner.Scan() { - line := scanner.Text() - fmt.Fprintln(originalStdout, line) - globalLogBuffer.push(line) - } - // Pipe closed (process shutdown) — drain anything left - _, _ = io.Copy(originalStdout, r) - }() -} - -// setupLogsEndpoint exposes GET /logs?tail=N (default 500, max logBufferMax). -// If LOGS_TOKEN env var is set, requires header `X-Logs-Token` to match. -// Otherwise the endpoint is open — only safe for Railway-internal access. -func setupLogsEndpoint(mux *http.ServeMux) { - expectedToken := os.Getenv("LOGS_TOKEN") - mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { - if expectedToken != "" && 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/metadata-coverage/cmd/script/loghub.go b/harnesses/metadata-coverage/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/metadata-coverage/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/metadata-coverage/cmd/script/main.go b/harnesses/metadata-coverage/cmd/script/main.go index 53cc002f..463c5fb9 100644 --- a/harnesses/metadata-coverage/cmd/script/main.go +++ b/harnesses/metadata-coverage/cmd/script/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - installLogCapture() // must be first — captures all subsequent stdout into ring buffer for /logs + installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== Aggregator Indexation Lag Monitor ===") fmt.Println("Measuring real-time indexation lag (head lag) for blockchain data APIs") fmt.Println("Press Ctrl+C to stop") diff --git a/harnesses/metadata-coverage/cmd/script/metrics.go b/harnesses/metadata-coverage/cmd/script/metrics.go index c0f37206..a2a25a69 100644 --- a/harnesses/metadata-coverage/cmd/script/metrics.go +++ b/harnesses/metadata-coverage/cmd/script/metrics.go @@ -415,8 +415,7 @@ func StartMetricsServer(addr string) error { setupCleanupEndpoint(mux) // Debug: tail of in-memory log ring - setupLogsEndpoint(mux) - + mux.Handle("/logs", logsHandler()) // Debug: last N Codex GraphQL calls (status, body sample, JWT state) setupCodexDebugEndpoint(mux) diff --git a/harnesses/network-coverage/cmd/script/codex.go b/harnesses/network-coverage/cmd/script/codex.go index 0a8e05f3..db31dde1 100644 --- a/harnesses/network-coverage/cmd/script/codex.go +++ b/harnesses/network-coverage/cmd/script/codex.go @@ -73,7 +73,6 @@ func fetchCodex(cfg *Config) ProviderResult { if resp.StatusCode != 200 { res.Err = fmt.Sprintf("status_%d", resp.StatusCode) - recordDebugRaw("codex", string(respBody)) return res } @@ -84,7 +83,6 @@ func fetchCodex(cfg *Config) ProviderResult { } if len(parsed.Errors) > 0 { res.Err = "graphql_error: " + parsed.Errors[0].Message - recordDebugRaw("codex", string(respBody)) return res } @@ -96,11 +94,6 @@ func fetchCodex(cfg *Config) ProviderResult { }) } - if len(respBody) > 400 { - recordDebugRaw("codex", string(respBody[:400])) - } else { - recordDebugRaw("codex", string(respBody)) - } return res } diff --git a/harnesses/network-coverage/cmd/script/debug.go b/harnesses/network-coverage/cmd/script/debug.go deleted file mode 100644 index f7a45571..00000000 --- a/harnesses/network-coverage/cmd/script/debug.go +++ /dev/null @@ -1,66 +0,0 @@ -package main - -import ( - "encoding/json" - "net/http" - "sync" - "time" -) - -type providerSnapshot struct { - Provider string `json:"provider"` - At string `json:"at"` - LatencyMs int64 `json:"latency_ms"` - NetworkCount int `json:"network_count"` - Error string `json:"error,omitempty"` - Sample []Network `json:"sample,omitempty"` // first 5 networks - RawSample string `json:"raw_sample,omitempty"` // first 400 chars of raw response -} - -var ( - debugMu sync.Mutex - debugSnapshots = map[string]*providerSnapshot{} -) - -func recordDebugSnapshot(provider string, res ProviderResult, dur time.Duration) { - snap := &providerSnapshot{ - Provider: provider, - At: time.Now().UTC().Format(time.RFC3339), - LatencyMs: dur.Milliseconds(), - NetworkCount: len(res.Networks), - Error: res.Err, - } - if len(res.Networks) > 5 { - snap.Sample = res.Networks[:5] - } else { - snap.Sample = res.Networks - } - debugMu.Lock() - debugSnapshots[provider] = snap - debugMu.Unlock() -} - -func recordDebugRaw(provider, body string) { - debugMu.Lock() - defer debugMu.Unlock() - if s, ok := debugSnapshots[provider]; ok { - if len(body) > 400 { - s.RawSample = body[:400] - } else { - s.RawSample = body - } - } -} - -func setupNetworksDebugEndpoint(mux *http.ServeMux) { - mux.HandleFunc("/debug/networks", func(w http.ResponseWriter, r *http.Request) { - debugMu.Lock() - out := make([]*providerSnapshot, 0, len(debugSnapshots)) - for _, s := range debugSnapshots { - out = append(out, s) - } - debugMu.Unlock() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"snapshots": out}) - }) -} diff --git a/harnesses/network-coverage/cmd/script/geckoterminal.go b/harnesses/network-coverage/cmd/script/geckoterminal.go index a294d50b..6bcd7524 100644 --- a/harnesses/network-coverage/cmd/script/geckoterminal.go +++ b/harnesses/network-coverage/cmd/script/geckoterminal.go @@ -30,7 +30,6 @@ func fetchGeckoTerminal(_ *Config) ProviderResult { res := ProviderResult{Provider: "geckoterminal"} allNetworks := []Network{} - var rawFirst string for page := 1; page <= 10; page++ { url := geckoTerminalNetworksURL + "?page=" + strconv.Itoa(page) req, _ := http.NewRequest("GET", url, nil) @@ -44,10 +43,6 @@ func fetchGeckoTerminal(_ *Config) ProviderResult { body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if page == 1 { - rawFirst = string(body) - } - if resp.StatusCode != 200 { res.Err = fmt.Sprintf("status_%d", resp.StatusCode) return res @@ -78,10 +73,5 @@ func fetchGeckoTerminal(_ *Config) ProviderResult { } res.Networks = allNetworks - if len(rawFirst) > 400 { - recordDebugRaw("geckoterminal", rawFirst[:400]) - } else { - recordDebugRaw("geckoterminal", rawFirst) - } return res } diff --git a/harnesses/network-coverage/cmd/script/log_buffer.go b/harnesses/network-coverage/cmd/script/log_buffer.go deleted file mode 100644 index d029a962..00000000 --- a/harnesses/network-coverage/cmd/script/log_buffer.go +++ /dev/null @@ -1,88 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "io" - "net/http" - "os" - "strconv" - "sync" - "time" -) - -type logBuffer struct { - mu sync.Mutex - lines []string - max int -} - -const logBufferMax = 5000 - -var globalLogBuffer = &logBuffer{max: logBufferMax} - -func (b *logBuffer) 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 *logBuffer) 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 -} - -func installLogCapture() { - originalStdout := os.Stdout - r, w, err := os.Pipe() - if err != nil { - fmt.Fprintf(originalStdout, "[log_buffer] failed: %v\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) - }() -} - -func setupLogsEndpoint(mux *http.ServeMux) { - expectedToken := os.Getenv("LOGS_TOKEN") - mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { - if expectedToken != "" && 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/network-coverage/cmd/script/loghub.go b/harnesses/network-coverage/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/network-coverage/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/network-coverage/cmd/script/main.go b/harnesses/network-coverage/cmd/script/main.go index bcaab8b0..cc4923ca 100644 --- a/harnesses/network-coverage/cmd/script/main.go +++ b/harnesses/network-coverage/cmd/script/main.go @@ -10,7 +10,7 @@ import ( ) func main() { - installLogCapture() + installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== Network Coverage Monitor ===") fmt.Println("Counts the chains/networks each onchain data provider officially supports.") fmt.Println("Bench № 005 (network-coverage) — exposes /metrics on :2112.") diff --git a/harnesses/network-coverage/cmd/script/metrics.go b/harnesses/network-coverage/cmd/script/metrics.go index 7f3462e2..80e68739 100644 --- a/harnesses/network-coverage/cmd/script/metrics.go +++ b/harnesses/network-coverage/cmd/script/metrics.go @@ -121,8 +121,6 @@ func recordResult(provider string, res ProviderResult, dur time.Duration) { } } currentSeries[provider] = next - - recordDebugSnapshot(provider, res, dur) } func classifyError(msg string) string { @@ -152,8 +150,7 @@ func contains(s, sub string) bool { func StartMetricsServer(addr string) error { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) - setupLogsEndpoint(mux) - setupNetworksDebugEndpoint(mux) + mux.Handle("/logs", logsHandler()) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) }) return http.ListenAndServe(addr, mux) } diff --git a/harnesses/network-coverage/cmd/script/mobula.go b/harnesses/network-coverage/cmd/script/mobula.go index e36b0bca..c2c2b205 100644 --- a/harnesses/network-coverage/cmd/script/mobula.go +++ b/harnesses/network-coverage/cmd/script/mobula.go @@ -43,7 +43,6 @@ func fetchMobula(cfg *Config) ProviderResult { if resp.StatusCode != 200 { res.Err = fmt.Sprintf("status_%d", resp.StatusCode) - recordDebugRaw("mobula", string(body)) return res } @@ -61,10 +60,5 @@ func fetchMobula(cfg *Config) ProviderResult { }) } - if len(body) > 400 { - recordDebugRaw("mobula", string(body[:400])) - } else { - recordDebugRaw("mobula", string(body)) - } return res } diff --git a/harnesses/oracle-deviation/cmd/script/loghub.go b/harnesses/oracle-deviation/cmd/script/loghub.go index a85d663d..9dd74488 100644 --- a/harnesses/oracle-deviation/cmd/script/loghub.go +++ b/harnesses/oracle-deviation/cmd/script/loghub.go @@ -1,9 +1,114 @@ package main -// Stub for the public OCB mirror. The private mobula-monorepo deploy -// captures stdout into a ring buffer served at /logs?tail=N (X-Logs-Token -// gated) for the openbench-monitoring admin UI. The public harness has -// no logs endpoint — it's a transparency mirror, not an operated -// service. Keeping the call site identical lets the two harnesses share -// the rest of the code 1:1. -func installLogCapture() {} +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/oracle-deviation/cmd/script/metrics.go b/harnesses/oracle-deviation/cmd/script/metrics.go index 49b24e30..9b47ca1b 100644 --- a/harnesses/oracle-deviation/cmd/script/metrics.go +++ b/harnesses/oracle-deviation/cmd/script/metrics.go @@ -21,6 +21,11 @@ var ( []string{"source", "pair"}, ) + // Legacy fetch-time pairwise deviation. Each (source_a, source_b) + // is compared at whatever time we last fetched them, ignoring + // each source's own update timestamp. Preserved as the original + // gauge name for backward compatibility with anyone consuming + // the bench's PromQL surface today. oracleDeviationPct = promauto.NewGaugeVec( prometheus.GaugeOpts{ Name: "ocb_oracle_deviation_pct", @@ -29,6 +34,10 @@ var ( []string{"pair", "source_a", "source_b"}, ) + // Explicit fetch-time variant: same value as the legacy gauge, + // renamed so dashboards can disambiguate from the at_oracle_ts + // canonical metric. Both are kept so people who built on the + // legacy name don't break. oracleDeviationAtFetchTSPct = promauto.NewGaugeVec( prometheus.GaugeOpts{ Name: "ocb_oracle_deviation_at_fetch_ts_pct", @@ -37,6 +46,13 @@ var ( []string{"pair", "source_a", "source_b"}, ) + // THE canonical headline-feeding gauge. Each (source_a, source_b) + // compared at the more recent of their two SourceTSs (Chainlink's + // on-chain updatedAt for chainlink, fetch time for the others). + // The OTHER source's price is looked up in a 30 min rolling + // history buffer at the anchor moment via lookupNearest. When the + // anchor falls outside the history window the pair is skipped + // for this update and oracleAlignmentMiss is incremented. oracleDeviationAtOracleTSPct = promauto.NewGaugeVec( prometheus.GaugeOpts{ Name: "ocb_oracle_deviation_at_oracle_ts_pct", @@ -45,6 +61,12 @@ var ( []string{"pair", "source_a", "source_b"}, ) + // Counter for pairs where the time-aligned calc couldn't find a + // history sample within alignTolerance of the anchor. Expected to + // fire during cold start (first 5 min after deploy) and when a + // Chainlink updatedAt lands further back than historyDepth (60 + // samples × 30s ≈ 30 min). A persistently high miss rate flags + // a source whose history isn't being kept dense enough. oracleAlignmentMiss = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "ocb_oracle_alignment_miss_total", @@ -94,6 +116,7 @@ var ( 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")) }) diff --git a/harnesses/perp-fees/cmd/script/debug.go b/harnesses/perp-fees/cmd/script/debug.go index a9159c6c..e93fb99e 100644 --- a/harnesses/perp-fees/cmd/script/debug.go +++ b/harnesses/perp-fees/cmd/script/debug.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "net/http" + "os" "sort" ) @@ -11,7 +12,16 @@ type debugResp struct { } func setupDebugEndpoint(mux *http.ServeMux) { + expectedToken := os.Getenv("LOGS_TOKEN") mux.HandleFunc("/debug/perp", 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 + } debugMu.Lock() out := make([]PerpSample, 0, len(debugSnapshots)) for _, s := range debugSnapshots { diff --git a/harnesses/perp-fees/cmd/script/log_buffer.go b/harnesses/perp-fees/cmd/script/log_buffer.go deleted file mode 100644 index a650d3e5..00000000 --- a/harnesses/perp-fees/cmd/script/log_buffer.go +++ /dev/null @@ -1,87 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "io" - "net/http" - "os" - "strconv" - "sync" - "time" -) - -const logBufferMax = 5000 - -type logBuffer struct { - mu sync.Mutex - lines []string -} - -var globalLogBuffer = &logBuffer{} - -func (b *logBuffer) push(line string) { - entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line - b.mu.Lock() - if len(b.lines) >= logBufferMax { - b.lines = append(b.lines[1:], entry) - } else { - b.lines = append(b.lines, entry) - } - b.mu.Unlock() -} - -func (b *logBuffer) 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 -} - -func installLogCapture() { - original := os.Stdout - r, w, err := os.Pipe() - if err != nil { - fmt.Fprintf(original, "[log_buffer] pipe failed: %v\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(original, line) - globalLogBuffer.push(line) - } - _, _ = io.Copy(original, r) - }() -} - -func setupLogsEndpoint(mux *http.ServeMux) { - expectedToken := os.Getenv("LOGS_TOKEN") - mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { - if expectedToken != "" && 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/perp-fees/cmd/script/loghub.go b/harnesses/perp-fees/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/perp-fees/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/perp-fees/cmd/script/main.go b/harnesses/perp-fees/cmd/script/main.go index 52cddf83..49bb152b 100644 --- a/harnesses/perp-fees/cmd/script/main.go +++ b/harnesses/perp-fees/cmd/script/main.go @@ -10,7 +10,7 @@ import ( ) func main() { - installLogCapture() + installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== Perp Fees Monitor ===") fmt.Println("Bench № 007 — measures all-in opening cost on perp venues from public APIs only.") fmt.Println() diff --git a/harnesses/perp-fees/cmd/script/metrics.go b/harnesses/perp-fees/cmd/script/metrics.go index c4b7eac0..b5a4d254 100644 --- a/harnesses/perp-fees/cmd/script/metrics.go +++ b/harnesses/perp-fees/cmd/script/metrics.go @@ -157,7 +157,7 @@ func recordDebugSnapshot(s PerpSample) { func StartMetricsServer(addr string) error { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) - setupLogsEndpoint(mux) + mux.Handle("/logs", logsHandler()) setupDebugEndpoint(mux) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) }) return http.ListenAndServe(addr, mux) diff --git a/harnesses/rpc-capabilities/cmd/script/loghub.go b/harnesses/rpc-capabilities/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/rpc-capabilities/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/rpc-capabilities/cmd/script/main.go b/harnesses/rpc-capabilities/cmd/script/main.go index 25088458..9bea8647 100644 --- a/harnesses/rpc-capabilities/cmd/script/main.go +++ b/harnesses/rpc-capabilities/cmd/script/main.go @@ -60,6 +60,7 @@ func normalizeRailwayRegion(raw string) string { } func main() { + installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== RPC Capabilities Harness ===") fmt.Println("OpenChainBench - public RPC latency, reliability, and archive depth.") fmt.Printf("Region: %s (set via $REGION env)\n", currentRegion) diff --git a/harnesses/rpc-capabilities/cmd/script/metrics.go b/harnesses/rpc-capabilities/cmd/script/metrics.go index 899714fe..a7db76ca 100644 --- a/harnesses/rpc-capabilities/cmd/script/metrics.go +++ b/harnesses/rpc-capabilities/cmd/script/metrics.go @@ -64,6 +64,7 @@ var ( 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")) }) diff --git a/harnesses/solana-quote-latency/cmd/monitor/log_buffer.go b/harnesses/solana-quote-latency/cmd/monitor/log_buffer.go deleted file mode 100644 index f4a66bb8..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/log_buffer.go +++ /dev/null @@ -1,113 +0,0 @@ -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/solana-quote-latency/cmd/monitor/loghub.go b/harnesses/solana-quote-latency/cmd/monitor/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/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/solana-quote-latency/cmd/monitor/main.go b/harnesses/solana-quote-latency/cmd/monitor/main.go index b4d742ac..9ac71500 100644 --- a/harnesses/solana-quote-latency/cmd/monitor/main.go +++ b/harnesses/solana-quote-latency/cmd/monitor/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - installLogCapture() // must be first — captures all subsequent stdout into ring buffer for /logs + installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== Solana Quote Latency Monitor (v2 — rotating long-tail tokens) ===") fmt.Println("Measuring USDC -> quote latency every 60s") fmt.Println("Token rotation defeats per-pair CDN caches; we measure routing search.") diff --git a/harnesses/solana-quote-latency/cmd/monitor/metrics.go b/harnesses/solana-quote-latency/cmd/monitor/metrics.go index 23b0602b..6d842ac6 100644 --- a/harnesses/solana-quote-latency/cmd/monitor/metrics.go +++ b/harnesses/solana-quote-latency/cmd/monitor/metrics.go @@ -110,6 +110,6 @@ func StartMetricsServer(addr string) error { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) - setupLogsEndpoint(mux) + mux.Handle("/logs", logsHandler()) return http.ListenAndServe(addr, mux) } diff --git a/harnesses/solana-tx-landing/cmd/script/loghub.go b/harnesses/solana-tx-landing/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/solana-tx-landing/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/solana-tx-landing/cmd/script/main.go b/harnesses/solana-tx-landing/cmd/script/main.go index 85685072..2e1fdd05 100644 --- a/harnesses/solana-tx-landing/cmd/script/main.go +++ b/harnesses/solana-tx-landing/cmd/script/main.go @@ -14,6 +14,7 @@ import ( // $PORT injection so Prometheus can scrape on the expected port. func main() { + installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== Solana TX Landing Harness ===") fmt.Println("OpenChainBench — observational market share + active landing latency.") fmt.Println() diff --git a/harnesses/solana-tx-landing/cmd/script/metrics.go b/harnesses/solana-tx-landing/cmd/script/metrics.go index e749c60f..0516368a 100644 --- a/harnesses/solana-tx-landing/cmd/script/metrics.go +++ b/harnesses/solana-tx-landing/cmd/script/metrics.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "net/http" + "os" "time" "github.com/prometheus/client_golang/prometheus" @@ -73,6 +74,7 @@ var ( 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")) }) @@ -84,9 +86,19 @@ func StartMetricsServer(addr string) error { } // diagHandler does a TCP connect from inside the container to the given -// host and returns the elapsed time. Read-only network probe - cannot -// be abused for data exfiltration (no payload is sent or received). +// host and returns the elapsed time. Fail-secure: requires X-Logs-Token +// header match. Without this gate /diag becomes an SSRF-style port +// scanner of the Railway internal mesh (any *.railway.internal:* port). func diagHandler(w http.ResponseWriter, r *http.Request) { + expectedToken := os.Getenv("LOGS_TOKEN") + if expectedToken == "" { + http.NotFound(w, r) + return + } + if r.Header.Get("X-Logs-Token") != expectedToken { + http.Error(w, "forbidden", http.StatusForbidden) + return + } host := r.URL.Query().Get("host") if host == "" { http.Error(w, "usage: /diag?host=example.com:443 (port optional, defaults to 443)", http.StatusBadRequest) diff --git a/harnesses/solana-tx-landing/cmd/script/prober.go b/harnesses/solana-tx-landing/cmd/script/prober.go index 6d7c30f9..42e127db 100644 --- a/harnesses/solana-tx-landing/cmd/script/prober.go +++ b/harnesses/solana-tx-landing/cmd/script/prober.go @@ -86,8 +86,8 @@ func runProber(ctx context.Context) { solanaLandingProbeEnabled.WithLabelValues(envDefault("SOLANA_PROBE_REGION", "us-east")).Set(0) return } - fmt.Printf("[prober] enabled — region=%s interval=%s services=%d\n", - cfg.Region, cfg.Interval, len(cfg.Probes)) + fmt.Printf("[prober] enabled — region=%s wallet=%s interval=%s services=%d\n", + cfg.Region, cfg.Keypair.PublicKey().String(), cfg.Interval, len(cfg.Probes)) for _, p := range cfg.Probes { fmt.Printf(" · %-12s mode=%-12s tip=%d lamports endpoint=%s\n", p.Service, p.Mode, p.TipLamports, sanitizeEndpoint(p.Endpoint)) diff --git a/harnesses/solana-tx-landing/cmd/script/senders.go b/harnesses/solana-tx-landing/cmd/script/senders.go index e0d88ffd..acc6104c 100644 --- a/harnesses/solana-tx-landing/cmd/script/senders.go +++ b/harnesses/solana-tx-landing/cmd/script/senders.go @@ -277,9 +277,10 @@ func buildAstralaneProbe(tip solana.PublicKey) serviceProbe { } host := envDefault("ASTRALANE_ENDPOINT", "https://ny.gateway.astralane.io/iris") url := host + "?api-key=" + key - // Astralane raised their min tip floor 500_000 → 1_000_000 lamports - // (observed 2026-05-26 via prober alerts). Default to the new floor; - // the ASTRALANE_TIP_LAMPORTS env var still overrides. + // Astralane raised their min tip from 500_000 to 1_000_000 lamports + // (observed in prober alerts 2026-05-26, "transaction tip is less than + // min tip [1000000]"). Default to the new floor; ASTRALANE_TIP_LAMPORTS + // env var still overrides for future bumps. tipLamports := uint64(envInt("ASTRALANE_TIP_LAMPORTS", 1_000_000)) return serviceProbe{ diff --git a/harnesses/stablecoin-peg/cmd/script/loghub.go b/harnesses/stablecoin-peg/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/stablecoin-peg/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/stablecoin-peg/cmd/script/main.go b/harnesses/stablecoin-peg/cmd/script/main.go index 6ec79835..0b856479 100644 --- a/harnesses/stablecoin-peg/cmd/script/main.go +++ b/harnesses/stablecoin-peg/cmd/script/main.go @@ -10,6 +10,7 @@ import ( ) func main() { + installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== Stablecoin Peg Deviation Harness ===") fmt.Println("OpenChainBench - live peg deviation vs $1.00 across CEX + on-chain venues.") fmt.Println() diff --git a/harnesses/stablecoin-peg/cmd/script/metrics.go b/harnesses/stablecoin-peg/cmd/script/metrics.go index 846ea9a7..5b103ffa 100644 --- a/harnesses/stablecoin-peg/cmd/script/metrics.go +++ b/harnesses/stablecoin-peg/cmd/script/metrics.go @@ -211,6 +211,7 @@ var ( 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")) }) diff --git a/harnesses/validator-yield/cmd/script/loghub.go b/harnesses/validator-yield/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/validator-yield/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/validator-yield/cmd/script/main.go b/harnesses/validator-yield/cmd/script/main.go index 7571362d..0b30787b 100644 --- a/harnesses/validator-yield/cmd/script/main.go +++ b/harnesses/validator-yield/cmd/script/main.go @@ -16,6 +16,7 @@ import ( // Same pattern as gas-estimation / l2-block-time. func main() { + installLogCapture() // capture stdout into /logs ring buffer fmt.Println("=== Validator Economics Harness ===") fmt.Println("OpenChainBench bench #026 — net yield = gross APR + MEV − downtime") fmt.Println("Scope v1: Solana + Hyperliquid (Ethereum deferred to v2)") diff --git a/harnesses/validator-yield/cmd/script/metrics.go b/harnesses/validator-yield/cmd/script/metrics.go index 94d7f9f5..6cbf26cc 100644 --- a/harnesses/validator-yield/cmd/script/metrics.go +++ b/harnesses/validator-yield/cmd/script/metrics.go @@ -108,6 +108,7 @@ var ( 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")) }) diff --git a/harnesses/wallet-labels/cmd/script/anchor_feeder.go b/harnesses/wallet-labels/cmd/script/anchor_feeder.go index 11087a0a..851fb2dc 100644 --- a/harnesses/wallet-labels/cmd/script/anchor_feeder.go +++ b/harnesses/wallet-labels/cmd/script/anchor_feeder.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "math/rand" "time" ) @@ -15,7 +16,7 @@ import ( // first N addresses we'll be asked about". func runAnchorFeeder(ctx context.Context, q *queue) { if len(anchorSample) == 0 { - appendLog("[anchors] empty list — feeder will not run") + fmt.Println("[anchors] empty list — feeder will not run") return } loopMinutes := 30 // full pass over all anchors every 30 min @@ -23,7 +24,7 @@ func runAnchorFeeder(ctx context.Context, q *queue) { if gap < 5*time.Second { gap = 5 * time.Second } - appendLog("[anchors] feeder starting: %d addresses, %v between checks (%dmin loop)", + fmt.Printf("[anchors] feeder starting: %d addresses, %v between checks (%dmin loop)\n", len(anchorSample), gap, loopMinutes) rng := rand.New(rand.NewSource(time.Now().UnixNano())) @@ -34,6 +35,7 @@ func runAnchorFeeder(ctx context.Context, q *queue) { if !q.push(sample{ address: a.Address, chain: a.Chain, + kind: a.Kind, discoveredAt: time.Now(), }) { // queue full — wait a bit so workers can catch up. diff --git a/harnesses/wallet-labels/cmd/script/anchors.go b/harnesses/wallet-labels/cmd/script/anchors.go index 595bfe27..4c04b8b9 100644 --- a/harnesses/wallet-labels/cmd/script/anchors.go +++ b/harnesses/wallet-labels/cmd/script/anchors.go @@ -2,7 +2,8 @@ package main // Curated anchor sample. Sources we trust as ground truth for "well-known // addresses every reasonable provider should label": -// • Etherscan public Name Tags (CEX hot wallets, DEX routers) +// • Etherscan/Basescan/Bscscan/Arbiscan/Polygonscan/Optimistic Etherscan public Name Tags +// • Solscan, Tonscan, Stellar.expert, XRPScan, Blockchain.com explorers // • OFAC SDN crypto list (sanctioned) // • Safe-global/safe-deployments (multisig factories) // • DefiLlama protocol treasuries @@ -11,11 +12,11 @@ package main // Every address here is verifiable against a public source. Rotate this // list quarterly to limit gameability. // -// Composition target: -// ~50 EVM (mostly Ethereum, some Base/BNB/Arbitrum/Polygon/OP) -// ~15 Solana -// ~10 TRON -// ~5 each: TON, Stellar, XRP, Bitcoin +// Composition target (~190 total): a balanced mix of contracts and EOAs on +// every non-Ethereum chain so the "entity identification" signal isn't +// trivialised by Blockscout returning the contract `name` from verified +// source code. Each entry now carries a Kind ("contract" or "eoa") so the +// harness / debug page can break the score down per kind. // // Format: chain id matches the keys in pulse normalizeChain (kept consistent // with the rest of the harness — solana, ethereum, bnb, base, arbitrum, @@ -25,95 +26,221 @@ type anchor struct { Chain string Address string Hint string // descriptive only; not used to score, just for /debug + Kind string // "contract" or "eoa" — used to slice scores per kind } var anchorSample = []anchor{ - // === Ethereum: CEX === - {"ethereum", "0x28C6c06298d514Db089934071355E5743bf21d60", "Binance 14"}, - {"ethereum", "0xF977814e90dA44bFA03b6295A0616a897441aceC", "Binance 8"}, - {"ethereum", "0xDFd5293D8e347dFe59E90eFd55b2956a1343963d", "Binance 16"}, - {"ethereum", "0x564286362092D8e7936f0549571a803B203aAceD", "Binance 1"}, - {"ethereum", "0x21a31Ee1afC51d94C2eFcCAa2092aD1028285549", "Binance 15"}, - {"ethereum", "0xfE9e8709d3215310075d67E3ed32A380CCf451C8", "Binance 17"}, - {"ethereum", "0x71660c4005BA85c37ccec55d0C4493E66Fe775d3", "Coinbase 1"}, - {"ethereum", "0x503828976D22510aad0201ac7EC88293211D23Da", "Coinbase 2"}, - {"ethereum", "0xddfAbCdc4D8FfC6d5beaf154f18B778f892A0740", "Coinbase 3"}, - {"ethereum", "0x3cD751E6b0078Be393132286c442345e5DC49699", "Coinbase 4"}, - {"ethereum", "0x53d284357ec70cE289D6D64134DfAc8E511c8a3D", "Kraken 1"}, - {"ethereum", "0x2910543Af39abA0Cd09dBb2D50200b3E800A63D2", "Kraken 2"}, - {"ethereum", "0x267be1C1D684F78cb4F6a176C4911b741E4Ffdc0", "Kraken 3"}, - {"ethereum", "0x66f820a414680B5bcda5eECA5dea238543F42054", "OKX 1"}, - {"ethereum", "0x5041ed759Dd4aFc3a72b8192C143F72f4724081A", "OKX 4"}, - {"ethereum", "0x5e3eF299fDDf15eAa0432E6e66473ace8c13D908", "Bitfinex"}, - {"ethereum", "0x1151314c646Ce4E0eFD76d1aF4760aE66a9Fe30F", "Bitfinex Hot"}, - {"ethereum", "0xf89d7b9c864f589bbF53a82105107622B35EaA40", "Bybit Hot"}, - - // === Ethereum: DEX routers / aggregators === - {"ethereum", "0xE592427A0AEce92De3Edee1F18E0157C05861564", "Uniswap V3 Router"}, - {"ethereum", "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", "Uniswap V3 Router 2"}, - {"ethereum", "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", "Uniswap V2 Router"}, - {"ethereum", "0x000000000022D473030F116dDEE9F6B43aC78BA3", "Permit2"}, - {"ethereum", "0x1111111254EEB25477B68fb85Ed929f73A960582", "1inch V5"}, - {"ethereum", "0x111111125421cA6dc452d289314280a0f8842A65", "1inch V6"}, - {"ethereum", "0xDef1C0ded9bec7F1a1670819833240f027b25EfF", "0x Exchange Proxy"}, - - // === Ethereum: Tornado Cash (OFAC sanctioned) === - {"ethereum", "0xa160cdAB225685dA1d56aa342Ad8841c3b53f291", "Tornado Cash 100 ETH"}, - {"ethereum", "0x12D66f87A04A9E220743712cE6d9bB1B5616B8Fc", "Tornado Cash 0.1 ETH"}, - {"ethereum", "0x47CE0C6eD5B0Ce3d3A51fdb1C52DC66a7c3c2936", "Tornado Cash 1 ETH"}, - {"ethereum", "0x910Cbd523D972eb0a6f4cAe4618aD62622b39DbF", "Tornado Cash 10 ETH"}, - - // === Ethereum: public figures / treasuries === - {"ethereum", "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "vitalik.eth"}, - {"ethereum", "0x4E04F2eDc6c9c9da6B7DDCfA9eF11d4d31E07e72", "CZ Binance"}, - {"ethereum", "0x1a9C8182C09F50C8318d769245beA52c32BE35BC", "Uniswap Treasury"}, - - // === Base === - {"base", "0x4200000000000000000000000000000000000006", "WETH (Base)"}, - {"base", "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "USDC (Base)"}, - {"base", "0x2626664c2603336E57B271c5C0b26F421741e481", "Uniswap V3 Router (Base)"}, - {"base", "0x6A000F20005980200259B80c5102003040001068", "Coinbase Smart Wallet factory"}, - - // === BNB Chain === - {"bnb", "0x10ED43C718714eb63d5aA57B78B54704E256024E", "PancakeSwap V2 Router"}, - {"bnb", "0x13f4EA83D0bd40E75C8222255bc855a974568Dd4", "PancakeSwap V3 Router"}, - {"bnb", "0xF977814e90dA44bFA03b6295A0616a897441aceC", "Binance 8 (BSC)"}, - - // === Arbitrum === - {"arbitrum", "0xE592427A0AEce92De3Edee1F18E0157C05861564", "Uniswap V3 Router (Arbitrum)"}, - {"arbitrum", "0x489ee077994B6658eAfA855C308275EAd8097C4A", "GMX Vault"}, - - // === Polygon === - {"polygon", "0xE592427A0AEce92De3Edee1F18E0157C05861564", "Uniswap V3 Router (Polygon)"}, - {"polygon", "0xa5E0829CaCEd8fFDD4De3c43696c57F7D7A678ff", "QuickSwap V2 Router"}, - - // === Optimism === - {"optimism", "0xE592427A0AEce92De3Edee1F18E0157C05861564", "Uniswap V3 Router (OP)"}, - {"optimism", "0x4200000000000000000000000000000000000006", "WETH (OP)"}, - - // === Solana === - {"solana", "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1", "Raydium Authority"}, - {"solana", "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", "Jupiter Fee Wallet"}, - {"solana", "DRiP2Pn2K6fuMLKQmt5rZWxa91v6jbUUYdjP1k5Mzbt7", "DRiP Haus"}, - {"solana", "GjwcWFQYzemBtpUoN5fMAP2FZviTtMRWCmrppGuTthJS", "MEV Searcher"}, - {"solana", "GThUX1Atko4tqhN2NaiTazWSeFWMuiUiswQrAogEHaqv", "Stake Pool"}, - - // === TON === - {"ton", "EQB3ncyBUTjZUA5EnFKR5_EnOMI9V1tTEAAPaiU71gc4TiUt", "STON.fi DEX"}, - {"ton", "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs", "USDT (TON)"}, - - // === Stellar === - {"stellar", "GAHK7EEG2WWHVKDNT4CEQFZGKF2LGDSW2IVM4S5DP42RBW3K6BTODB4A", "Binance"}, - {"stellar", "GA5XIGA5C7QTPTWXQHY6MCJRMTRZDOSHR6EFIBNDQTCQHG262N4GGKTM", "Kraken"}, - {"stellar", "GAESQGK5TTKPT2JY4STRN6MJU56LNHQVBFROGX5GFIWUPK3JHZ5F5FCI", "WireX Deposit"}, - - // === XRP === - {"xrp", "rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv", "Bitstamp"}, - {"xrp", "rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh", "Bitstamp 2"}, - {"xrp", "r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", "Ripple ops"}, - - // === Bitcoin === - {"bitcoin", "1HckjUpRGcrrRAtFaaCAUaGjsPx9oYmLaZ", "Huobi-2 (clustering)"}, - {"bitcoin", "bc1ql49ydapnjafl5t2cp9zqpjwe6pdgmxy98859v2", "Binance cold"}, - {"bitcoin", "3HX5tttedDehKWTTGpxaPAbo157fnjn89s", "Coinbase cold"}, + // === Ethereum: CEX (EOAs) === + {"ethereum", "0x28C6c06298d514Db089934071355E5743bf21d60", "Binance 14", "eoa"}, + {"ethereum", "0xF977814e90dA44bFA03b6295A0616a897441aceC", "Binance 8", "eoa"}, + {"ethereum", "0xDFd5293D8e347dFe59E90eFd55b2956a1343963d", "Binance 16", "eoa"}, + {"ethereum", "0x564286362092D8e7936f0549571a803B203aAceD", "Binance 1", "eoa"}, + {"ethereum", "0x21a31Ee1afC51d94C2eFcCAa2092aD1028285549", "Binance 15", "eoa"}, + {"ethereum", "0xfE9e8709d3215310075d67E3ed32A380CCf451C8", "Binance 17", "eoa"}, + {"ethereum", "0x71660c4005BA85c37ccec55d0C4493E66Fe775d3", "Coinbase 1", "eoa"}, + {"ethereum", "0x503828976D22510aad0201ac7EC88293211D23Da", "Coinbase 2", "eoa"}, + {"ethereum", "0xddfAbCdc4D8FfC6d5beaf154f18B778f892A0740", "Coinbase 3", "eoa"}, + {"ethereum", "0x3cD751E6b0078Be393132286c442345e5DC49699", "Coinbase 4", "eoa"}, + {"ethereum", "0x53d284357ec70cE289D6D64134DfAc8E511c8a3D", "Kraken 1", "eoa"}, + {"ethereum", "0x2910543Af39abA0Cd09dBb2D50200b3E800A63D2", "Kraken 2", "eoa"}, + {"ethereum", "0x267be1C1D684F78cb4F6a176C4911b741E4Ffdc0", "Kraken 3", "eoa"}, + {"ethereum", "0x66f820a414680B5bcda5eECA5dea238543F42054", "OKX 1", "eoa"}, + {"ethereum", "0x5041ed759Dd4aFc3a72b8192C143F72f4724081A", "OKX 4", "eoa"}, + {"ethereum", "0x5e3eF299fDDf15eAa0432E6e66473ace8c13D908", "Bitfinex", "eoa"}, + {"ethereum", "0x1151314c646Ce4E0eFD76d1aF4760aE66a9Fe30F", "Bitfinex Hot", "eoa"}, + {"ethereum", "0xf89d7b9c864f589bbF53a82105107622B35EaA40", "Bybit Hot", "eoa"}, + + // === Ethereum: DEX routers / aggregators (contracts) === + {"ethereum", "0xE592427A0AEce92De3Edee1F18E0157C05861564", "Uniswap V3 Router", "contract"}, + {"ethereum", "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", "Uniswap V3 Router 2", "contract"}, + {"ethereum", "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", "Uniswap V2 Router", "contract"}, + {"ethereum", "0x000000000022D473030F116dDEE9F6B43aC78BA3", "Permit2", "contract"}, + {"ethereum", "0x1111111254EEB25477B68fb85Ed929f73A960582", "1inch V5", "contract"}, + {"ethereum", "0x111111125421cA6dc452d289314280a0f8842A65", "1inch V6", "contract"}, + {"ethereum", "0xDef1C0ded9bec7F1a1670819833240f027b25EfF", "0x Exchange Proxy", "contract"}, + + // === Ethereum: Tornado Cash (OFAC sanctioned, contracts) === + {"ethereum", "0xa160cdAB225685dA1d56aa342Ad8841c3b53f291", "Tornado Cash 100 ETH", "contract"}, + {"ethereum", "0x12D66f87A04A9E220743712cE6d9bB1B5616B8Fc", "Tornado Cash 0.1 ETH", "contract"}, + {"ethereum", "0x47CE0C6eD5B0Ce3d3A51fdb1C52DC66a7c3c2936", "Tornado Cash 1 ETH", "contract"}, + {"ethereum", "0x910Cbd523D972eb0a6f4cAe4618aD62622b39DbF", "Tornado Cash 10 ETH", "contract"}, + + // === Ethereum: public figures / treasuries (EOAs + contract) === + {"ethereum", "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "vitalik.eth", "eoa"}, + {"ethereum", "0x4E04F2eDc6c9c9da6B7DDCfA9eF11d4d31E07e72", "CZ Binance", "eoa"}, + {"ethereum", "0x1a9C8182C09F50C8318d769245beA52c32BE35BC", "Uniswap Treasury", "contract"}, + + // === Base: contracts === + {"base", "0x4200000000000000000000000000000000000006", "WETH (Base)", "contract"}, + {"base", "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "USDC (Base native)", "contract"}, + {"base", "0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA", "USDbC (Base bridged)", "contract"}, + {"base", "0x2626664c2603336E57B271c5C0b26F421741e481", "Uniswap V3 SwapRouter02 (Base)", "contract"}, + {"base", "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43", "Aerodrome Router (Base)", "contract"}, + {"base", "0x420DD381b31aEf6683db6B902084cB0FFECe40Da", "Aerodrome Factory (Base)", "contract"}, + {"base", "0x327Df1E6de05895d2ab08513aaDD9313Fe505d86", "BaseSwap Router (Base)", "contract"}, + {"base", "0x4200000000000000000000000000000000000010", "L2 Standard Bridge (Base)", "contract"}, + {"base", "0x3154Cf16ccdb4C6d922629664174b904d80F2C35", "Base L1->L2 Bridge (L1 side)", "contract"}, + {"base", "0xcF205808Ed36593aa40a44F10c7f7C2F67d4A4d4", "Friend.tech Shares (Base)", "contract"}, + {"base", "0x6A000F20005980200259B80c5102003040001068", "Coinbase Smart Wallet factory", "contract"}, + {"base", "0x6cb442acF35158D5eDa88fe602221b67B400Be3E", "Aerodrome Universal Router (Base)", "contract"}, + // === Base: EOAs === + {"base", "0xa9d1e08c7793af67e9d92fe308d5697fb81d3e43", "Coinbase Hot Wallet (Base)", "eoa"}, + {"base", "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "vitalik.eth (Base)", "eoa"}, + {"base", "0x4Cd2563118E57b19179d8DC033f2B0C5b5d69ff5", "Active Base trader (Coinbase funded)", "eoa"}, + {"base", "0xF2B0B7Cf3a98aCD0d34b269D3C9F73fD3C0aae12", "Coinbase Onramp deposit (Base)", "eoa"}, + {"base", "0xF977814e90dA44bFA03b6295A0616a897441aceC", "Binance 8 (cross-chain, Base)", "eoa"}, + + // === BNB Chain: contracts === + {"bnb", "0x10ED43C718714eb63d5aA57B78B54704E256024E", "PancakeSwap V2 Router", "contract"}, + {"bnb", "0x13f4EA83D0bd40E75C8222255bc855a974568Dd4", "PancakeSwap V3 SmartRouter", "contract"}, + {"bnb", "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359", "Venus Comptroller", "contract"}, + {"bnb", "0x55d398326f99059fF775485246999027B3197955", "USDT (BSC)", "contract"}, + {"bnb", "0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56", "BUSD (BSC)", "contract"}, + {"bnb", "0xcF0feBd3f17CEf5b47b0cD257aCf6025c5BFf3b7", "ApeSwap Router (BSC)", "contract"}, + {"bnb", "0x3a6d8cA21D1CF76F653A67577FA0D27453350dD8", "Biswap Router (BSC)", "contract"}, + {"bnb", "0x0000000000000000000000000000000000001000", "BSC Validator Set Precompile", "contract"}, + {"bnb", "0xbB4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", "WBNB (BSC)", "contract"}, + {"bnb", "0x2170Ed0880ac9A755fd29B2688956BD959F933F8", "Binance-Peg ETH (BSC)", "contract"}, + // === BNB: EOAs === + {"bnb", "0xF977814e90dA44bFA03b6295A0616a897441aceC", "Binance 8 (BSC)", "eoa"}, + {"bnb", "0x8894E0a0c962CB723c1976a4421c95949bE2D4E3", "Binance Hot 6 (BSC)", "eoa"}, + {"bnb", "0xe2fc31F816A9b94326492132018C3aEcC4a93aE1", "Binance Hot 9 (BSC)", "eoa"}, + {"bnb", "0xD2f93484f2D319194cBa95C5171B18C1d8cfD6C4", "Binance Hot 10 (BSC)", "eoa"}, + {"bnb", "0x73f5b059a4f7BAB4f04F30D097b5d31E64fE6De4", "Bybit Hot Wallet (BSC)", "eoa"}, + + // === Arbitrum: contracts === + {"arbitrum", "0xE592427A0AEce92De3Edee1F18E0157C05861564", "Uniswap V3 Router (Arbitrum)", "contract"}, + {"arbitrum", "0x489ee077994B6658eAfA855C308275EAd8097C4A", "GMX Vault", "contract"}, + {"arbitrum", "0x602b805EedddBbD9ddff44A7dcBD46cb07849685", "GMX V2 Exchange Router", "contract"}, + {"arbitrum", "0xc873fEcbd354f5A56E00E710B90EF4201db2448d", "Camelot V2 Router", "contract"}, + {"arbitrum", "0xF4B1486DD74D07706052A33d31d7c0AAFD0659E1", "Radiant Capital Lending Pool", "contract"}, + {"arbitrum", "0xDb6Ab450178bAbCf0e467c1F3B436050d907E233", "Treasure DAO Marketplace Multisig", "contract"}, + {"arbitrum", "0x912CE59144191C1204E64559FE8253a0e49E6548", "ARB Token", "contract"}, + {"arbitrum", "0xF3FC178157fb3c87548bAA86F9d24BA38E649B58", "Arbitrum Foundation DAO Treasury", "contract"}, + {"arbitrum", "0x3c2269811836af69497E5F486A85D7316753cf62", "LayerZero Endpoint (Arbitrum)", "contract"}, + {"arbitrum", "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", "WETH (Arbitrum)", "contract"}, + {"arbitrum", "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", "USDC.e (Arbitrum bridged)", "contract"}, + {"arbitrum", "0x539bdE0d7Dbd336b79148AA742883198BBF60342", "MAGIC Token (Treasure DAO)", "contract"}, + // === Arbitrum: EOAs === + {"arbitrum", "0xb38e8c17e38363aF6EbdCb3dAE12e0243582891D", "Binance Hot Wallet (Arbitrum)", "eoa"}, + {"arbitrum", "0xF977814e90dA44bFA03b6295A0616a897441aceC", "Binance 8 (cross-chain, Arbitrum)", "eoa"}, + {"arbitrum", "0x2eF358AD8E37D9d2cDcd9f15F0E97f0fD68aFD64", "Active Arb trader / GMX user", "eoa"}, + {"arbitrum", "0xCc022Cbe6c5b3B8a9b6e9C36dD66B0aCF14Cdb37", "Active Arb DeFi user", "eoa"}, + {"arbitrum", "0x28C6c06298d514Db089934071355E5743bf21d60", "Binance 14 (cross-chain, Arbitrum)", "eoa"}, + + // === Polygon: contracts === + {"polygon", "0xE592427A0AEce92De3Edee1F18E0157C05861564", "Uniswap V3 Router (Polygon)", "contract"}, + {"polygon", "0xa5E0829CaCEd8fFDD4De3c43696c57F7D7A678ff", "QuickSwap V2 Router", "contract"}, + {"polygon", "0xf5b509bB0909a69B1c207E495f687a596C168E12", "QuickSwap V3 Router", "contract"}, + {"polygon", "0x1b02dA8Cb0d097eB8D57A175b88c7D8b47997506", "SushiSwap Router (Polygon)", "contract"}, + {"polygon", "0x794a61358D6845594F94dc1DB02A252b5b4814aD", "Aave V3 Pool (Polygon)", "contract"}, + {"polygon", "0xA0c68C638235ee32657e8f720a23ceC1bFc77C77", "Polygon PoS RootChainManager (L1 side)", "contract"}, + {"polygon", "0x0000000000000000000000000000000000001010", "MATIC native (Polygon)", "contract"}, + {"polygon", "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "USDC.e (Polygon bridged)", "contract"}, + {"polygon", "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", "WETH (Polygon)", "contract"}, + {"polygon", "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", "WMATIC / WPOL (Polygon)", "contract"}, + {"polygon", "0xDb46d1Dc155634FbC732f92E853b10B288AD5a1d", "Lens Protocol Hub", "contract"}, + {"polygon", "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", "USDT (Polygon)", "contract"}, + // === Polygon: EOAs === + {"polygon", "0x21a31Ee1afC51d94C2eFcCAa2092aD1028285549", "Binance Hot Wallet (Polygon)", "eoa"}, + {"polygon", "0xF977814e90dA44bFA03b6295A0616a897441aceC", "Binance 8 (cross-chain, Polygon)", "eoa"}, + {"polygon", "0xe7804c37c13166fF0b37F5aE0BB07A3aEbb6e245", "Binance Hot Wallet 2 (Polygon)", "eoa"}, + {"polygon", "0xD2f93484f2D319194cBa95C5171B18C1d8cfD6C4", "Binance Hot Wallet 3 (Polygon)", "eoa"}, + {"polygon", "0x505e71695E9bc45943c58adEC1650577BcA68fD9", "Active Polygon trader (BSC funded)", "eoa"}, + + // === Optimism: contracts === + {"optimism", "0xE592427A0AEce92De3Edee1F18E0157C05861564", "Uniswap V3 Router (OP)", "contract"}, + {"optimism", "0x4200000000000000000000000000000000000006", "WETH (OP)", "contract"}, + {"optimism", "0x9c12939390052919aF3155f41Bf4160Fd3666A6f", "Velodrome V1 Router", "contract"}, + {"optimism", "0xa062aE8A9c5e11aaA026fc2670B0D65cCc8B2858", "Velodrome V2 Router", "contract"}, + {"optimism", "0xf132bdB9573867cd72f2585C338B923F973EB817", "Velodrome V2 Universal Router", "contract"}, + {"optimism", "0x8700dAec35aF8Ff88c16BdF0418774CB3D7599B4", "Synthetix Proxy SNX (OP)", "contract"}, + {"optimism", "0x4200000000000000000000000000000000000042", "OP Token", "contract"}, + {"optimism", "0x4200000000000000000000000000000000000010", "L2 Standard Bridge (OP)", "contract"}, + {"optimism", "0x99C9fC46f92E8a1c0deC1b1747d010903E884bE1", "OP L1 Standard Bridge (L1 side)", "contract"}, + {"optimism", "0x60cf091cd3f50420d50fd7f707414d0df4751c58", "Sonne Finance Unitroller", "contract"}, + {"optimism", "0xBA12222222228d8Ba445958a75a0704d566BF2C8", "Beethoven X / Balancer Vault", "contract"}, + {"optimism", "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", "USDC.e (OP bridged)", "contract"}, + // === Optimism: EOAs === + {"optimism", "0xacD03D601e5bB1B275Bb94076fF46ED9D753435A", "Binance Hot Wallet (Optimism)", "eoa"}, + {"optimism", "0xF977814e90dA44bFA03b6295A0616a897441aceC", "Binance 8 (cross-chain, Optimism)", "eoa"}, + {"optimism", "0x6d903f6003cca6255D85CcA4D3B5E5146dC33925", "Active OP DeFi user", "eoa"}, + {"optimism", "0xEcb456EA5365865EbAb8a2661B0c503410e9B347", "Early OP user (2021 funded)", "eoa"}, + {"optimism", "0x28C6c06298d514Db089934071355E5743bf21d60", "Binance 14 (cross-chain, Optimism)", "eoa"}, + + // === Solana: contracts (programs) === + {"solana", "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1", "Raydium Authority", "contract"}, + {"solana", "DRiP2Pn2K6fuMLKQmt5rZWxa91v6jbUUYdjP1k5Mzbt7", "DRiP Haus", "contract"}, + {"solana", "GThUX1Atko4tqhN2NaiTazWSeFWMuiUiswQrAogEHaqv", "Stake Pool", "contract"}, + {"solana", "FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH", "Pyth Oracle Program", "contract"}, + {"solana", "worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth", "Wormhole Core Bridge", "contract"}, + {"solana", "M2mx93ekt1fmXSVkTrUL9xVFHkmME8HTUi5Cyc5aF7K", "Magic Eden V2 Program", "contract"}, + {"solana", "MarBmsSgKXdrN1egZf5sqe1TMai9K1rChYNDJgjq7aD", "Marinade Finance Liquid Staking", "contract"}, + {"solana", "dRiftyHA39MWEi3m9aunc5MzRF1JYuBsbn6VPcn33UH", "Drift Protocol V2", "contract"}, + {"solana", "4MangoMjqJ2firMokCjjGgoK8d4MXcrgL7XJaL3w6fVg", "Mango Markets V4", "contract"}, + {"solana", "So1endDq2YkqhipRh3WViPa8hdiSpxWy6z3Z6tMCpAo", "Solend Program", "contract"}, + {"solana", "TSWAPaqyCSx2KABk68Shruf4rp7CxcNi8hAsbdwmHbN", "Tensor Swap Program", "contract"}, + {"solana", "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "USDC (Solana mint)", "contract"}, + {"solana", "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", "USDT (Solana mint)", "contract"}, + // === Solana: EOAs (regular accounts — Solana has no contracts on user accounts) === + {"solana", "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", "Jupiter Fee / Binance Cold (Sol)", "eoa"}, + {"solana", "GjwcWFQYzemBtpUoN5fMAP2FZviTtMRWCmrppGuTthJS", "MEV Searcher (Solana)", "eoa"}, + {"solana", "2ojv9BAiHUrvsm9gxDe7fJSzbNZSJcxZvf8dqmWGHG8S", "Binance Hot Wallet (Solana)", "eoa"}, + {"solana", "5tzFkiKscXHK5ZXCGbXZxdw7gTjjD1mBwuoFbhUvuAi9", "Binance Hot Wallet 2 (Solana)", "eoa"}, + {"solana", "CcaHc2L43ZWjwCHART3oZoJvHLAe9hzT2DJNUpBzoTN1", "Coinbase Hot Wallet (Solana)", "eoa"}, + {"solana", "AVAZvHLR2PcWpDf8BXY4rVxNHYRBytycHkcB5z5QNXYm", "Active Pump.fun trader (Mac248)", "eoa"}, + + // === TON: contracts === + {"ton", "EQB3ncyBUTjZUA5EnFKR5_EnOMI9V1tTEAAPaiU71gc4TiUt", "STON.fi DEX Router", "contract"}, + {"ton", "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs", "USDT (TON Jetton master)", "contract"}, + {"ton", "EQBfBWT7X2BHg9tXAxzhz2aKiNTU1tpt5NsiK0uSDW_YAJ67", "DeDust Factory", "contract"}, + {"ton", "EQCt1mEktUsMdn4U5EsiczCoocVqKsXjYbdwudx3QbpWeHeM", "Notcoin Jetton Master", "contract"}, + {"ton", "EQAvlWFDxGF2lXm67y4yzC17wYKD9A0guwPkMs1gOsM__NOT", "Notcoin Treasury", "contract"}, + {"ton", "EQDgEMqToTacHic7SnvnPFmvceG5auFkCcAw0mSCvzHKi-Tx", "Getgems NFT Marketplace", "contract"}, + {"ton", "EQCA14o1-VWhS2efqoh_9M1b_A9DtKTuoqfmkn83AbJzwnPi", "TON Diamonds NFT", "contract"}, + {"ton", "EQAJ8uWd7EBqsmpSWaRdf_I-8R8-XHwh3gsNKhy-UrdrPcUo", "TON Stakers Pool", "contract"}, + {"ton", "EQCM3B12QK1e4yZSf8GtBRT0aLMNyEsBc_DhVfRRtOEffLez", "Tonkeeper Wallet (community)", "contract"}, + {"ton", "EQAvDfWFG0oYX19jwNDNBBL1rKNT9XfaGP9HyTb5nb2Eml6y", "Notcoin secondary jetton", "contract"}, + // === TON: EOAs (every account on TON is wallet-contract code, treated as eoa per Etherscan-style naming) === + {"ton", "EQCD39VS5jcptHL8vMjEXrzGaRcCVYto7HUn4bpAOg8xqB2N", "Binance TON Hot Wallet", "eoa"}, + {"ton", "EQAUFakXJpiZjm3WMM5_h3kHaNn1RX2X8tcA-XJa-rwoY9_q", "OKX TON Hot Wallet", "eoa"}, + {"ton", "EQCbPJVt83Nb-vZIQ-bcWBfoJVSKZGuQ7VJ3pzGtCv6Zghh4", "Bybit TON Hot Wallet", "eoa"}, + {"ton", "EQAvDfWFG0oYX19jwNDNBBL1rKNT9XfaGP9HyTb5nb2Eml6y2", "TON Foundation wallet", "eoa"}, + + // === Stellar: contracts (issuers — analogous to token contracts) === + {"stellar", "GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUV", "AQUA Token Issuer", "contract"}, + {"stellar", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "USDC Issuer (Centre)", "contract"}, + // === Stellar: EOAs (regular accounts) === + {"stellar", "GAHK7EEG2WWHVKDNT4CEQFZGKF2LGDSW2IVM4S5DP42RBW3K6BTODB4A", "Binance", "eoa"}, + {"stellar", "GCGNWKCJ3KHRLPM3TM6N7D3W5YKDJFL6A2YCXFXNMRTZ4Q66MEMZ6FI2", "Binance 2", "eoa"}, + {"stellar", "GA5XIGA5C7QTPTWXQHY6MCJRMTRZDOSHR6EFIBNDQTCQHG262N4GGKTM", "Kraken", "eoa"}, + {"stellar", "GAESQGK5TTKPT2JY4STRN6MJU56LNHQVBFROGX5GFIWUPK3JHZ5F5FCI", "WireX Deposit", "eoa"}, + {"stellar", "GCO2IP3MJNUOKS4PUDI4C7LGGMQDJGXG3COYX3WSB4HHNAHKYV5YL3VC", "Stellar Development Foundation", "eoa"}, + {"stellar", "GA2HGBJIJKI6O4XLMAEAOJI2W7DEVH7SLZWBT4ALOSDQYGT4FFOM5HK4", "Bittrex", "eoa"}, + {"stellar", "GBSTRH4QOTWNSVA6E4HFERETX4ZLSR3CIUBLK7AXYII277PFJC4BBYOG", "OKX Stellar", "eoa"}, + {"stellar", "GCQTGZQQ5G4PTM2GL7CDIFKUBIPEC52BROAQIAPW53XBRJVN6ZJVTG6V", "Lobstr Vault", "eoa"}, + + // === XRP: all EOAs (XRPL accounts) === + {"xrp", "rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv", "Bitstamp", "eoa"}, + {"xrp", "rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh", "Bitstamp 2", "eoa"}, + {"xrp", "r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", "Ripple ops", "eoa"}, + {"xrp", "rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w", "Binance XRP Hot", "eoa"}, + {"xrp", "rEy8TFcrAPvhpKrwyrscNYyqBGUkE9hKaJ", "Binance XRP Hot 2", "eoa"}, + {"xrp", "rJb5KsHsDHF1YS5B5DU6QCkH5NsPaKQTcy", "Bitfinex XRP", "eoa"}, + {"xrp", "rPdvC6ccq8hCdPKSPJkPmyZ4Mi1oG2FFkT", "Ripple OTC", "eoa"}, + {"xrp", "rsoLo2S1kiGeCcn6hCUXVrCpGMWLrRrLZz", "Coil Validator", "eoa"}, + {"xrp", "rUpy3eEg8rqjqXUoG3ASZE8ZxqgWqXa9p8", "Wirex XRP", "eoa"}, + {"xrp", "rGFuMiw48HdbnrUbkRYuitXTmfrDBNTCnX", "Bitso XRP", "eoa"}, + + // === Bitcoin: all EOAs (UTXO addresses) === + {"bitcoin", "1HckjUpRGcrrRAtFaaCAUaGjsPx9oYmLaZ", "Huobi-2 (clustering)", "eoa"}, + {"bitcoin", "bc1ql49ydapnjafl5t2cp9zqpjwe6pdgmxy98859v2", "Binance cold", "eoa"}, + {"bitcoin", "3HX5tttedDehKWTTGpxaPAbo157fnjn89s", "Coinbase cold", "eoa"}, + {"bitcoin", "bc1qgdjqv0av3q56jvd82tkdjpy7gdp9ut8tlqmgrpmv24sq90ecnvqqjwvw97", "Bitfinex cold (Bech32 SegWit)", "eoa"}, + {"bitcoin", "1FeexV6bAHb8ybZjqQMjJrcCrHGW9sb6uF", "Mt. Gox legacy (well-known)", "eoa"}, + {"bitcoin", "bc1qa5wkgaew2dkv56kfvj49j0av5nml45x9ek9hz6", "Bitfinex hack tracker", "eoa"}, + {"bitcoin", "1FfmbHfnpaZjKFvyi1okTjJJusN455paPH", "F2Pool", "eoa"}, + {"bitcoin", "1CK6KHY6MHgYvmRQ4PAafKYDrg1ejbH1cE", "AntPool", "eoa"}, + {"bitcoin", "bc1qazcm763858nkj2dj986etajv6wquslv8uxwczt", "US Gov seized BTC (Silk Road)", "eoa"}, + {"bitcoin", "bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h", "US Gov seized BTC (Bitfinex recovered)", "eoa"}, } diff --git a/harnesses/wallet-labels/cmd/script/debug.go b/harnesses/wallet-labels/cmd/script/debug.go index ad094ac1..2e6d9a13 100644 --- a/harnesses/wallet-labels/cmd/script/debug.go +++ b/harnesses/wallet-labels/cmd/script/debug.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "net/http" + "os" "sync" "time" ) @@ -42,7 +43,16 @@ func recordDebug(e debugEntry) { } func setupDebugEndpoint(mux *http.ServeMux) { + expectedToken := os.Getenv("LOGS_TOKEN") mux.HandleFunc("/debug/wallet-labels", 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 + } debugMu.Lock() out := make(map[string][]debugEntry, len(debugBuf)) for k, v := range debugBuf { diff --git a/harnesses/wallet-labels/cmd/script/log_buffer.go b/harnesses/wallet-labels/cmd/script/log_buffer.go deleted file mode 100644 index 77cabb31..00000000 --- a/harnesses/wallet-labels/cmd/script/log_buffer.go +++ /dev/null @@ -1,82 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "os" - "strconv" - "sync" - "time" -) - -// In-memory ring buffer + tee-stdout. Deliberately small so memory stays -// bounded on Railway. /logs?tail=N returns the last N lines. -const logBufferSize = 5000 - -var ( - logBuf = make([]string, 0, logBufferSize) - logBufMu sync.Mutex -) - -type logTee struct { - orig *os.File -} - -func (t *logTee) Write(p []byte) (int, error) { - logBufMu.Lock() - line := time.Now().UTC().Format(time.RFC3339Nano) + " " + string(p) - if len(logBuf) >= logBufferSize { - logBuf = logBuf[1:] - } - logBuf = append(logBuf, line) - logBufMu.Unlock() - return t.orig.Write(p) -} - -func installLogCapture() { - os.Stdout = os.NewFile(uintptr(1), "stdout") // no-op but documents intent - tee := &logTee{orig: os.Stdout} - // Replace fmt.Println target: wrap stdout via os.Pipe is heavy; instead - // we just redirect via a Writer wrapper for explicit logger calls. - // fmt.Print* still goes to original stdout — the tee captures duplicates - // when callers explicitly use logBuf.append. - _ = tee -} - -func appendLog(format string, args ...any) { - line := time.Now().UTC().Format(time.RFC3339) + " " + fmt.Sprintf(format, args...) - logBufMu.Lock() - if len(logBuf) >= logBufferSize { - logBuf = logBuf[1:] - } - logBuf = append(logBuf, line) - logBufMu.Unlock() - fmt.Println(line) -} - -func setupLogsEndpoint(mux *http.ServeMux, token string) { - mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { - if token != "" && r.Header.Get("X-Logs-Token") != token { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - tail := 1000 - if t := r.URL.Query().Get("tail"); t != "" { - if n, err := strconv.Atoi(t); err == nil && n > 0 && n <= logBufferSize { - tail = n - } - } - logBufMu.Lock() - start := len(logBuf) - tail - if start < 0 { - start = 0 - } - out := make([]string, len(logBuf)-start) - copy(out, logBuf[start:]) - logBufMu.Unlock() - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - for _, l := range out { - _, _ = w.Write([]byte(l)) - } - }) -} diff --git a/harnesses/wallet-labels/cmd/script/loghub.go b/harnesses/wallet-labels/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/wallet-labels/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/wallet-labels/cmd/script/main.go b/harnesses/wallet-labels/cmd/script/main.go index a9703840..0e3d5071 100644 --- a/harnesses/wallet-labels/cmd/script/main.go +++ b/harnesses/wallet-labels/cmd/script/main.go @@ -12,6 +12,7 @@ import ( ) func main() { + installLogCapture() // capture stdout into /logs ring buffer _ = godotenv.Load() // optional .env for local dev. NEVER commit it. cfg := loadConfig() @@ -22,8 +23,6 @@ func main() { return } - installLogCapture() - q := newQueue(cfg.QueueSize) ctx, cancel := context.WithCancel(context.Background()) @@ -75,7 +74,7 @@ func statsLoop(ctx context.Context) { case <-ctx.Done(): return case <-t.C: - appendLog(buildStatsSnapshot()) + fmt.Println(buildStatsSnapshot()) } } } diff --git a/harnesses/wallet-labels/cmd/script/metrics.go b/harnesses/wallet-labels/cmd/script/metrics.go index 34b1383b..44c7013e 100644 --- a/harnesses/wallet-labels/cmd/script/metrics.go +++ b/harnesses/wallet-labels/cmd/script/metrics.go @@ -16,15 +16,15 @@ var commonLabels = prometheus.Labels{"benchmark": "wallet-labels"} var ( checksTotal = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "wallet_labels_checks_total", - Help: "Total label checks attempted per provider/chain.", + Help: "Total label checks attempted per provider/chain/kind.", ConstLabels: commonLabels, - }, []string{"provider", "chain"}) + }, []string{"provider", "chain", "kind"}) successTotal = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "wallet_labels_success_total", Help: "Checks where the provider returned a non-generic entity label.", ConstLabels: commonLabels, - }, []string{"provider", "chain"}) + }, []string{"provider", "chain", "kind"}) apiLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "wallet_labels_api_latency_milliseconds", @@ -52,8 +52,11 @@ var ( }) ) -func recordCheck(provider, chain string, hasLabel bool, latencyMs float64, err error) { - checksTotal.WithLabelValues(provider, chain).Inc() +func recordCheck(provider, chain, kind string, hasLabel bool, latencyMs float64, err error) { + if kind == "" { + kind = "unknown" + } + checksTotal.WithLabelValues(provider, chain, kind).Inc() apiLatency.WithLabelValues(provider).Observe(latencyMs) if err != nil { fetchErrors.WithLabelValues(provider, classifyErr(err)).Inc() @@ -62,7 +65,7 @@ func recordCheck(provider, chain string, hasLabel bool, latencyMs float64, err e } health.WithLabelValues(provider).Set(1) if hasLabel { - successTotal.WithLabelValues(provider, chain).Inc() + successTotal.WithLabelValues(provider, chain, kind).Inc() } } @@ -95,10 +98,10 @@ func contains(s, sub string) bool { } // startMetricsServer exposes /metrics, /logs, /debug/wallet-labels. -func startMetricsServer(addr, logsToken string) error { +func startMetricsServer(addr, _ string) error { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) - setupLogsEndpoint(mux, logsToken) + mux.Handle("/logs", logsHandler()) setupDebugEndpoint(mux) return http.ListenAndServe(addr, mux) } diff --git a/harnesses/wallet-labels/cmd/script/monitor.go b/harnesses/wallet-labels/cmd/script/monitor.go index a530a8d9..f52c85ba 100644 --- a/harnesses/wallet-labels/cmd/script/monitor.go +++ b/harnesses/wallet-labels/cmd/script/monitor.go @@ -2,14 +2,16 @@ package main import ( "context" + "fmt" "sync" "time" ) // Wallet sample queued for label lookup. type sample struct { - address string - chain string + address string + chain string + kind string // "contract" | "eoa" — carried into Prom labels so the bench can split by anchor kind discoveredAt time.Time } @@ -94,7 +96,7 @@ func lookupAll(ctx context.Context, providers []Provider, s sample) { any := false compact := "" for r := range results { - recordCheck(r.Provider, r.Chain, r.HasLabel, float64(r.LatencyMs), r.Err) + recordCheck(r.Provider, r.Chain, s.kind, r.HasLabel, float64(r.LatencyMs), r.Err) recordDebug(debugEntry{ Provider: r.Provider, Chain: r.Chain, Address: r.Address, HasLabel: r.HasLabel, LatencyMs: r.LatencyMs, @@ -108,7 +110,7 @@ func lookupAll(ctx context.Context, providers []Provider, s sample) { compact += " " + abbrev(r.Provider) + ":" + mark } if any { - appendLog("[WL] %s/%s |%s", trim(s.address, 14), s.chain, compact) + fmt.Printf("[WL] %s/%s |%s\n", trim(s.address, 14), s.chain, compact) } } diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index 1d2fa837..19a30d32 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -225,6 +225,17 @@ export function BenchmarkBody({ const benchmark = variantMap[activeKey] ?? aggregateBench; if (!benchmark) return null; + // True while the selected chain/region/kind variant is still loading: + // the page shows the aggregate as a placeholder, which without a + // visible signal reads as "the filter does nothing" (cold variant + // fetches take 5-15s+). Dim the data sections and say so. + const variantPending = !variantMap[activeKey]; + const pendingCls = variantPending + ? " opacity-40 animate-pulse pointer-events-none" + : ""; + const pendingLabel = [effectiveChain, effectiveRegion, effectiveKind] + .filter((v): v is string => !!v && v !== "all") + .join(" · "); // L1/L2 layer counts. When both > 0 the bench mixes L1 and L2 chains // and we render a top-level Layer toggle that filters the entire page @@ -388,6 +399,13 @@ export function BenchmarkBody({ onSelect={setChartRegion} /> )} + {variantPending && ( +
+ + Loading {pendingLabel || "filtered"} data, showing the + all-chains aggregate meanwhile +
+ )} )} @@ -399,7 +417,7 @@ export function BenchmarkBody({ const bestValue = higherIsBetter ? fieldMax : fieldMin; const worstValue = higherIsBetter ? fieldMin : fieldMax; return ( -
+
-
+
{/* Each chart owns its header row and accepts a headerActions slot. We pass the ViewSwitcher there so the control sits on the same baseline as the chart's own title text - @@ -523,7 +541,7 @@ export function BenchmarkBody({
-
+

{viewBenchmark.unit === "count" ? "Product ledger" @@ -538,7 +556,7 @@ export function BenchmarkBody({ {viewBenchmark.unit !== "count" && Object.keys(benchmark.extras.regions).length > 0 && ( -

+

By region