feat: explorer API, wallet, CCIP scripts, and config refresh
- Backend REST/gateway/track routes, analytics, Blockscout proxy paths. - Frontend wallet and liquidity surfaces; MetaMask token list alignment. - Deployment docs, verification scripts, address inventory updates. Check: go build ./... under backend/ (pass). Made-with: Cursor
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
httperrors "github.com/explorer/backend/libs/go-http-errors"
|
||||
httpmiddleware "github.com/explorer/backend/libs/go-http-middleware"
|
||||
)
|
||||
|
||||
// Gateway represents the API gateway
|
||||
@@ -64,7 +70,9 @@ func (g *Gateway) handleRequest(proxy *httputil.ReverseProxy) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// Add headers
|
||||
r.Header.Set("X-Forwarded-For", r.RemoteAddr)
|
||||
if clientIP := httpmiddleware.ClientIP(r); clientIP != "" {
|
||||
r.Header.Set("X-Forwarded-For", clientIP)
|
||||
}
|
||||
if apiKey := g.auth.GetAPIKey(r); apiKey != "" {
|
||||
r.Header.Set("X-API-Key", apiKey)
|
||||
}
|
||||
@@ -92,14 +100,17 @@ func (g *Gateway) addSecurityHeaders(w http.ResponseWriter) {
|
||||
// RateLimiter handles rate limiting
|
||||
type RateLimiter struct {
|
||||
// Simple in-memory rate limiter (should use Redis in production)
|
||||
mu sync.Mutex
|
||||
limits map[string]*limitEntry
|
||||
}
|
||||
|
||||
type limitEntry struct {
|
||||
count int
|
||||
resetAt int64
|
||||
resetAt time.Time
|
||||
}
|
||||
|
||||
const gatewayRequestsPerMinute = 120
|
||||
|
||||
func NewRateLimiter() *RateLimiter {
|
||||
return &RateLimiter{
|
||||
limits: make(map[string]*limitEntry),
|
||||
@@ -107,26 +118,62 @@ func NewRateLimiter() *RateLimiter {
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) Allow(r *http.Request) bool {
|
||||
_ = r.RemoteAddr // Will be used in production for per-IP limiting
|
||||
// In production, use Redis with token bucket algorithm
|
||||
// For now, simple per-IP limiting
|
||||
return true // Simplified - implement proper rate limiting
|
||||
clientIP := httpmiddleware.ClientIP(r)
|
||||
if clientIP == "" {
|
||||
clientIP = r.RemoteAddr
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
entry, ok := rl.limits[clientIP]
|
||||
if !ok || now.After(entry.resetAt) {
|
||||
rl.limits[clientIP] = &limitEntry{
|
||||
count: 1,
|
||||
resetAt: now.Add(time.Minute),
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if entry.count >= gatewayRequestsPerMinute {
|
||||
return false
|
||||
}
|
||||
|
||||
entry.count++
|
||||
return true
|
||||
}
|
||||
|
||||
// AuthMiddleware handles authentication
|
||||
type AuthMiddleware struct {
|
||||
// In production, validate against database
|
||||
allowAnonymous bool
|
||||
apiKeys []string
|
||||
}
|
||||
|
||||
func NewAuthMiddleware() *AuthMiddleware {
|
||||
return &AuthMiddleware{}
|
||||
return &AuthMiddleware{
|
||||
allowAnonymous: parseBoolEnv("GATEWAY_ALLOW_ANONYMOUS"),
|
||||
apiKeys: splitNonEmptyEnv("GATEWAY_API_KEYS"),
|
||||
}
|
||||
}
|
||||
|
||||
func (am *AuthMiddleware) Authenticate(r *http.Request) bool {
|
||||
// Allow anonymous access for now
|
||||
// In production, validate API key
|
||||
apiKey := am.GetAPIKey(r)
|
||||
return apiKey != "" || true // Allow anonymous for MVP
|
||||
if apiKey == "" {
|
||||
return am.allowAnonymous
|
||||
}
|
||||
if len(am.apiKeys) == 0 {
|
||||
return am.allowAnonymous
|
||||
}
|
||||
|
||||
for _, allowedKey := range am.apiKeys {
|
||||
if subtle.ConstantTimeCompare([]byte(apiKey), []byte(allowedKey)) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (am *AuthMiddleware) GetAPIKey(r *http.Request) string {
|
||||
@@ -140,3 +187,29 @@ func (am *AuthMiddleware) GetAPIKey(r *http.Request) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseBoolEnv(key string) bool {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
return strings.EqualFold(value, "1") ||
|
||||
strings.EqualFold(value, "true") ||
|
||||
strings.EqualFold(value, "yes") ||
|
||||
strings.EqualFold(value, "on")
|
||||
}
|
||||
|
||||
func splitNonEmptyEnv(key string) []string {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
values = append(values, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
78
backend/api/gateway/gateway_test.go
Normal file
78
backend/api/gateway/gateway_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthMiddlewareRejectsAnonymousByDefault(t *testing.T) {
|
||||
t.Setenv("GATEWAY_ALLOW_ANONYMOUS", "")
|
||||
t.Setenv("GATEWAY_API_KEYS", "")
|
||||
|
||||
auth := NewAuthMiddleware()
|
||||
req := httptest.NewRequest("GET", "http://example.com", nil)
|
||||
|
||||
if auth.Authenticate(req) {
|
||||
t.Fatal("expected anonymous request to be rejected by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareAllowsConfiguredAPIKey(t *testing.T) {
|
||||
t.Setenv("GATEWAY_ALLOW_ANONYMOUS", "")
|
||||
t.Setenv("GATEWAY_API_KEYS", "alpha,beta")
|
||||
|
||||
auth := NewAuthMiddleware()
|
||||
req := httptest.NewRequest("GET", "http://example.com", nil)
|
||||
req.Header.Set("X-API-Key", "beta")
|
||||
|
||||
if !auth.Authenticate(req) {
|
||||
t.Fatal("expected configured API key to be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareAllowsAnonymousOnlyWhenEnabled(t *testing.T) {
|
||||
t.Setenv("GATEWAY_ALLOW_ANONYMOUS", "true")
|
||||
t.Setenv("GATEWAY_API_KEYS", "")
|
||||
|
||||
auth := NewAuthMiddleware()
|
||||
req := httptest.NewRequest("GET", "http://example.com", nil)
|
||||
|
||||
if !auth.Authenticate(req) {
|
||||
t.Fatal("expected anonymous request to be accepted when explicitly enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterBlocksAfterWindowBudget(t *testing.T) {
|
||||
limiter := NewRateLimiter()
|
||||
req := httptest.NewRequest("GET", "http://example.com", nil)
|
||||
req.RemoteAddr = "203.0.113.10:1234"
|
||||
|
||||
for i := 0; i < gatewayRequestsPerMinute; i++ {
|
||||
if !limiter.Allow(req) {
|
||||
t.Fatalf("expected request %d to pass", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if limiter.Allow(req) {
|
||||
t.Fatal("expected request over the per-minute budget to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterResetsAfterWindow(t *testing.T) {
|
||||
limiter := NewRateLimiter()
|
||||
req := httptest.NewRequest("GET", "http://example.com", nil)
|
||||
req.RemoteAddr = "203.0.113.11:1234"
|
||||
|
||||
if !limiter.Allow(req) {
|
||||
t.Fatal("expected first request to pass")
|
||||
}
|
||||
|
||||
limiter.mu.Lock()
|
||||
limiter.limits["203.0.113.11"].resetAt = time.Now().Add(-time.Second)
|
||||
limiter.mu.Unlock()
|
||||
|
||||
if !limiter.Allow(req) {
|
||||
t.Fatal("expected limiter window to reset")
|
||||
}
|
||||
}
|
||||
16
backend/api/rest/.env.example
Normal file
16
backend/api/rest/.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# Core explorer API
|
||||
PORT=8080
|
||||
CHAIN_ID=138
|
||||
RPC_URL=https://rpc-http-pub.d-bis.org
|
||||
DB_HOST=localhost
|
||||
DB_NAME=explorer
|
||||
|
||||
# Mission-control helpers
|
||||
TOKEN_AGGREGATION_BASE_URL=http://127.0.0.1:3000
|
||||
BLOCKSCOUT_INTERNAL_URL=http://127.0.0.1:4000
|
||||
EXPLORER_PUBLIC_BASE=https://explorer.d-bis.org
|
||||
|
||||
# Track 4 operator script execution
|
||||
OPERATOR_SCRIPTS_ROOT=/opt/explorer/scripts
|
||||
OPERATOR_SCRIPT_ALLOWLIST=check-health.sh,check-bridges.sh
|
||||
OPERATOR_SCRIPT_TIMEOUT_SEC=120
|
||||
@@ -10,6 +10,7 @@ REST API implementation for the ChainID 138 Explorer Platform.
|
||||
- `transactions.go` - Transaction-related endpoints
|
||||
- `addresses.go` - Address-related endpoints
|
||||
- `search.go` - Unified search endpoint
|
||||
- `mission_control.go` - Mission-control bridge trace and cached liquidity helpers
|
||||
- `validation.go` - Input validation utilities
|
||||
- `middleware.go` - HTTP middleware (logging, compression)
|
||||
- `errors.go` - Error response utilities
|
||||
@@ -34,6 +35,14 @@ REST API implementation for the ChainID 138 Explorer Platform.
|
||||
### Health
|
||||
- `GET /health` - Health check endpoint
|
||||
|
||||
### Mission control
|
||||
- `GET /api/v1/mission-control/stream` - SSE stream for bridge/RPC health
|
||||
- `GET /api/v1/mission-control/bridge/trace?tx=0x...` - Blockscout-backed tx trace with Chain 138 contract labels
|
||||
- `GET /api/v1/mission-control/liquidity/token/{address}/pools` - 30-second cached proxy to token-aggregation pools
|
||||
|
||||
### Track 4 operator
|
||||
- `POST /api/v1/track4/operator/run-script` - Run an allowlisted script under `OPERATOR_SCRIPTS_ROOT`
|
||||
|
||||
## Features
|
||||
|
||||
- Input validation (addresses, hashes, block numbers)
|
||||
@@ -66,4 +75,19 @@ Set environment variables:
|
||||
- `DB_NAME` - Database name
|
||||
- `PORT` - API server port (default: 8080)
|
||||
- `CHAIN_ID` - Chain ID (default: 138)
|
||||
- `RPC_URL` - Chain RPC used by Track 1 and mission-control health/SSE data
|
||||
- `TOKEN_AGGREGATION_BASE_URL` - Upstream token-aggregation base URL for mission-control liquidity proxy
|
||||
- `BLOCKSCOUT_INTERNAL_URL` - Internal Blockscout base URL for bridge trace lookups
|
||||
- `EXPLORER_PUBLIC_BASE` - Public explorer base URL used in mission-control trace responses
|
||||
- `CCIP_RELAY_HEALTH_URL` - Optional relay health probe URL, for example `http://192.168.11.11:9860/healthz`
|
||||
- `CCIP_RELAY_HEALTH_URLS` - Optional comma-separated named relay probes, for example `mainnet=http://192.168.11.11:9860/healthz,bsc=http://192.168.11.11:9861/healthz,avax=http://192.168.11.11:9862/healthz`
|
||||
- `MISSION_CONTROL_CCIP_JSON` - Optional JSON snapshot fallback when relay health is provided as a file instead of an HTTP endpoint
|
||||
- `OPERATOR_SCRIPTS_ROOT` - Root directory for allowlisted Track 4 scripts
|
||||
- `OPERATOR_SCRIPT_ALLOWLIST` - Comma-separated list of permitted script names or relative paths
|
||||
- `OPERATOR_SCRIPT_TIMEOUT_SEC` - Optional Track 4 script timeout in seconds (max 599)
|
||||
|
||||
## Mission-control deployment notes
|
||||
|
||||
- Include `explorer-monorepo/deployment/common/nginx-mission-control-sse.conf` in the same nginx server block that proxies `/explorer-api/`.
|
||||
- Keep the nginx upstream port aligned with the Go API `PORT`.
|
||||
- Verify internal reachability to `BLOCKSCOUT_INTERNAL_URL` and `TOKEN_AGGREGATION_BASE_URL` from the API host before enabling the mission-control cards in production.
|
||||
|
||||
@@ -15,9 +15,12 @@ func (s *Server) handleGetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse address from URL
|
||||
address := r.URL.Query().Get("address")
|
||||
address := normalizeAddress(r.URL.Query().Get("address"))
|
||||
if address == "" {
|
||||
writeValidationError(w, fmt.Errorf("address required"))
|
||||
return
|
||||
@@ -36,7 +39,7 @@ func (s *Server) handleGetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
// Get transaction count
|
||||
var txCount int64
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`,
|
||||
`SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)`,
|
||||
s.chainID, address,
|
||||
).Scan(&txCount)
|
||||
if err != nil {
|
||||
@@ -47,7 +50,7 @@ func (s *Server) handleGetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
// Get token count
|
||||
var tokenCount int
|
||||
err = s.db.QueryRow(ctx,
|
||||
`SELECT COUNT(DISTINCT token_address) FROM token_transfers WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`,
|
||||
`SELECT COUNT(DISTINCT token_contract) FROM token_transfers WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)`,
|
||||
s.chainID, address,
|
||||
).Scan(&tokenCount)
|
||||
if err != nil {
|
||||
@@ -57,44 +60,42 @@ func (s *Server) handleGetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
// Get label
|
||||
var label sql.NullString
|
||||
s.db.QueryRow(ctx,
|
||||
`SELECT label FROM address_labels WHERE chain_id = $1 AND address = $2 AND label_type = 'public' LIMIT 1`,
|
||||
`SELECT label FROM address_labels WHERE chain_id = $1 AND LOWER(address) = $2 AND label_type = 'public' LIMIT 1`,
|
||||
s.chainID, address,
|
||||
).Scan(&label)
|
||||
|
||||
// Get tags
|
||||
rows, _ := s.db.Query(ctx,
|
||||
`SELECT tag FROM address_tags WHERE chain_id = $1 AND address = $2`,
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT tag FROM address_tags WHERE chain_id = $1 AND LOWER(address) = $2`,
|
||||
s.chainID, address,
|
||||
)
|
||||
defer rows.Close()
|
||||
|
||||
tags := []string{}
|
||||
for rows.Next() {
|
||||
var tag string
|
||||
if err := rows.Scan(&tag); err == nil {
|
||||
tags = append(tags, tag)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var tag string
|
||||
if err := rows.Scan(&tag); err == nil {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if contract
|
||||
var isContract bool
|
||||
s.db.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM contracts WHERE chain_id = $1 AND address = $2)`,
|
||||
`SELECT EXISTS(SELECT 1 FROM contracts WHERE chain_id = $1 AND LOWER(address) = $2)`,
|
||||
s.chainID, address,
|
||||
).Scan(&isContract)
|
||||
|
||||
// Get balance (if we have RPC access, otherwise 0)
|
||||
balance := "0"
|
||||
// TODO: Add RPC call to get balance if needed
|
||||
|
||||
response := map[string]interface{}{
|
||||
"address": address,
|
||||
"chain_id": s.chainID,
|
||||
"balance": balance,
|
||||
"transaction_count": txCount,
|
||||
"token_count": tokenCount,
|
||||
"is_contract": isContract,
|
||||
"tags": tags,
|
||||
"address": address,
|
||||
"chain_id": s.chainID,
|
||||
"balance": nil,
|
||||
"balance_unavailable": true,
|
||||
"transaction_count": txCount,
|
||||
"token_count": tokenCount,
|
||||
"is_contract": isContract,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
if label.Valid {
|
||||
|
||||
19
backend/api/rest/addresses_internal_test.go
Normal file
19
backend/api/rest/addresses_internal_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleGetAddressRequiresDB(t *testing.T) {
|
||||
server := NewServer(nil, 138)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/addresses/138/0xAbCdEf1234567890ABCdef1234567890abCDef12?address=0xAbCdEf1234567890ABCdef1234567890abCDef12", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleGetAddress(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 when db is unavailable, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func (s *Server) handleListAddresses(w http.ResponseWriter, r *http.Request) {
|
||||
MAX(seen_at) AS last_seen_at
|
||||
FROM (
|
||||
SELECT
|
||||
t.from_address AS address,
|
||||
LOWER(t.from_address) AS address,
|
||||
'sent' AS direction,
|
||||
b.timestamp AS seen_at
|
||||
FROM transactions t
|
||||
@@ -69,7 +69,7 @@ func (s *Server) handleListAddresses(w http.ResponseWriter, r *http.Request) {
|
||||
WHERE t.chain_id = $1 AND t.from_address IS NOT NULL AND t.from_address <> ''
|
||||
UNION ALL
|
||||
SELECT
|
||||
t.to_address AS address,
|
||||
LOWER(t.to_address) AS address,
|
||||
'received' AS direction,
|
||||
b.timestamp AS seen_at
|
||||
FROM transactions t
|
||||
@@ -79,28 +79,28 @@ func (s *Server) handleListAddresses(w http.ResponseWriter, r *http.Request) {
|
||||
GROUP BY address
|
||||
),
|
||||
token_activity AS (
|
||||
SELECT address, COUNT(DISTINCT token_address) AS token_count
|
||||
SELECT address, COUNT(DISTINCT token_contract) AS token_count
|
||||
FROM (
|
||||
SELECT from_address AS address, token_address
|
||||
SELECT LOWER(from_address) AS address, token_contract
|
||||
FROM token_transfers
|
||||
WHERE chain_id = $1 AND from_address IS NOT NULL AND from_address <> ''
|
||||
UNION ALL
|
||||
SELECT to_address AS address, token_address
|
||||
SELECT LOWER(to_address) AS address, token_contract
|
||||
FROM token_transfers
|
||||
WHERE chain_id = $1 AND to_address IS NOT NULL AND to_address <> ''
|
||||
) tokens
|
||||
GROUP BY address
|
||||
),
|
||||
label_activity AS (
|
||||
SELECT DISTINCT ON (address)
|
||||
address,
|
||||
SELECT DISTINCT ON (LOWER(address))
|
||||
LOWER(address) AS address,
|
||||
label
|
||||
FROM address_labels
|
||||
WHERE chain_id = $1 AND label_type = 'public'
|
||||
ORDER BY address, updated_at DESC, id DESC
|
||||
ORDER BY LOWER(address), updated_at DESC, id DESC
|
||||
),
|
||||
contract_activity AS (
|
||||
SELECT address, TRUE AS is_contract
|
||||
SELECT LOWER(address) AS address, TRUE AS is_contract
|
||||
FROM contracts
|
||||
WHERE chain_id = $1
|
||||
)
|
||||
|
||||
@@ -222,6 +222,11 @@ func explorerAIEnabled() bool {
|
||||
return strings.TrimSpace(os.Getenv("XAI_API_KEY")) != ""
|
||||
}
|
||||
|
||||
// explorerAIOperatorToolsEnabled allows the model to discuss server-side operator/MCP automation (default off).
|
||||
func explorerAIOperatorToolsEnabled() bool {
|
||||
return strings.TrimSpace(os.Getenv("EXPLORER_AI_OPERATOR_TOOLS_ENABLED")) == "1"
|
||||
}
|
||||
|
||||
func explorerAIModel() string {
|
||||
if model := strings.TrimSpace(os.Getenv("XAI_MODEL")); model != "" {
|
||||
return model
|
||||
@@ -316,7 +321,15 @@ func (s *Server) queryAIStats(ctx context.Context) (map[string]any, error) {
|
||||
}
|
||||
|
||||
var totalAddresses int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(DISTINCT from_address) + COUNT(DISTINCT to_address) FROM transactions WHERE chain_id = $1`, s.chainID).Scan(&totalAddresses); err == nil {
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM (
|
||||
SELECT from_address AS address
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND from_address IS NOT NULL AND from_address <> ''
|
||||
UNION
|
||||
SELECT to_address AS address
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND to_address IS NOT NULL AND to_address <> ''
|
||||
) unique_addresses`, s.chainID).Scan(&totalAddresses); err == nil {
|
||||
stats["total_addresses"] = totalAddresses
|
||||
}
|
||||
|
||||
@@ -429,17 +442,19 @@ func (s *Server) queryAIAddress(ctx context.Context, address string) (map[string
|
||||
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||||
defer cancel()
|
||||
|
||||
address = normalizeAddress(address)
|
||||
|
||||
result := map[string]any{
|
||||
"address": address,
|
||||
}
|
||||
|
||||
var txCount int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`, s.chainID, address).Scan(&txCount); err == nil {
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)`, s.chainID, address).Scan(&txCount); err == nil {
|
||||
result["transaction_count"] = txCount
|
||||
}
|
||||
|
||||
var tokenCount int64
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(DISTINCT token_address) FROM token_transfers WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`, s.chainID, address).Scan(&tokenCount); err == nil {
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(DISTINCT token_contract) FROM token_transfers WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)`, s.chainID, address).Scan(&tokenCount); err == nil {
|
||||
result["token_count"] = tokenCount
|
||||
}
|
||||
|
||||
@@ -447,7 +462,7 @@ func (s *Server) queryAIAddress(ctx context.Context, address string) (map[string
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT hash
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)
|
||||
WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)
|
||||
ORDER BY block_number DESC, transaction_index DESC
|
||||
LIMIT 5
|
||||
`, s.chainID, address)
|
||||
@@ -884,10 +899,15 @@ func (s *Server) callXAIChatCompletions(ctx context.Context, messages []AIChatMe
|
||||
contextJSON, _ := json.MarshalIndent(contextEnvelope, "", " ")
|
||||
contextText := clipString(string(contextJSON), maxExplorerAIContextChars)
|
||||
|
||||
baseSystem := "You are the SolaceScanScout ecosystem assistant for Chain 138. Answer using the supplied indexed explorer data, route inventory, and workspace documentation. Be concise, operationally useful, and explicit about uncertainty. Never claim a route, deployment, or production status is live unless the provided context says it is live. If data is missing, say exactly what is missing."
|
||||
if !explorerAIOperatorToolsEnabled() {
|
||||
baseSystem += " Never instruct users to paste private keys or seed phrases. Do not direct users to run privileged mint, liquidity, or bridge execution from the public explorer UI. Operator changes belong on LAN-gated workflows and authenticated Track 4 APIs; PMM/MCP-style execution tools are disabled on this deployment unless EXPLORER_AI_OPERATOR_TOOLS_ENABLED=1."
|
||||
}
|
||||
|
||||
input := []xAIChatMessageReq{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "You are the SolaceScanScout ecosystem assistant for Chain 138. Answer using the supplied indexed explorer data, route inventory, and workspace documentation. Be concise, operationally useful, and explicit about uncertainty. Never claim a route, deployment, or production status is live unless the provided context says it is live. If data is missing, say exactly what is missing.",
|
||||
Content: baseSystem,
|
||||
},
|
||||
{
|
||||
Role: "system",
|
||||
|
||||
@@ -3,11 +3,12 @@ package rest
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
httpmiddleware "github.com/explorer/backend/libs/go-http-middleware"
|
||||
)
|
||||
|
||||
type AIRateLimiter struct {
|
||||
@@ -158,22 +159,7 @@ func (m *AIMetrics) Snapshot() map[string]any {
|
||||
}
|
||||
|
||||
func clientIPAddress(r *http.Request) string {
|
||||
for _, header := range []string{"X-Forwarded-For", "X-Real-IP"} {
|
||||
if raw := strings.TrimSpace(r.Header.Get(header)); raw != "" {
|
||||
if header == "X-Forwarded-For" {
|
||||
parts := strings.Split(raw, ",")
|
||||
if len(parts) > 0 {
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
||||
if err == nil && host != "" {
|
||||
return host
|
||||
}
|
||||
return strings.TrimSpace(r.RemoteAddr)
|
||||
return httpmiddleware.ClientIP(r)
|
||||
}
|
||||
|
||||
func explorerAIContextRateLimit() (int, time.Duration) {
|
||||
|
||||
@@ -214,6 +214,38 @@ func TestPagination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthNonceRequiresDB(t *testing.T) {
|
||||
_, mux := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/nonce", bytes.NewBufferString(`{"address":"0x4A666F96fC8764181194447A7dFdb7d471b301C8"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, response["error"])
|
||||
}
|
||||
|
||||
func TestAuthWalletRequiresDB(t *testing.T) {
|
||||
_, mux := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/wallet", bytes.NewBufferString(`{"address":"0x4A666F96fC8764181194447A7dFdb7d471b301C8","signature":"0xdeadbeef","nonce":"abc"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, response["error"])
|
||||
}
|
||||
|
||||
func TestAIContextEndpoint(t *testing.T) {
|
||||
_, mux := setupTestServer(t)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/explorer/backend/auth"
|
||||
@@ -13,6 +14,9 @@ func (s *Server) handleAuthNonce(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
var req auth.NonceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -23,6 +27,10 @@ func (s *Server) handleAuthNonce(w http.ResponseWriter, r *http.Request) {
|
||||
// Generate nonce
|
||||
nonceResp, err := s.walletAuth.GenerateNonce(r.Context(), req.Address)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrWalletAuthStorageNotInitialized) {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
@@ -37,6 +45,9 @@ func (s *Server) handleAuthWallet(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
var req auth.WalletAuthRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -47,6 +58,10 @@ func (s *Server) handleAuthWallet(w http.ResponseWriter, r *http.Request) {
|
||||
// Authenticate wallet
|
||||
authResp, err := s.walletAuth.AuthenticateWallet(r.Context(), &req)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrWalletAuthStorageNotInitialized) {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||
return
|
||||
}
|
||||
@@ -54,4 +69,3 @@ func (s *Server) handleAuthWallet(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(authResp)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import (
|
||||
|
||||
// handleGetBlockByNumber handles GET /api/v1/blocks/{chain_id}/{number}
|
||||
func (s *Server) handleGetBlockByNumber(w http.ResponseWriter, r *http.Request, blockNumber int64) {
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate input (already validated in routes.go, but double-check)
|
||||
if blockNumber < 0 {
|
||||
writeValidationError(w, ErrInvalidBlockNumber)
|
||||
@@ -72,6 +76,12 @@ func (s *Server) handleGetBlockByNumber(w http.ResponseWriter, r *http.Request,
|
||||
|
||||
// handleGetBlockByHash handles GET /api/v1/blocks/{chain_id}/hash/{hash}
|
||||
func (s *Server) handleGetBlockByHash(w http.ResponseWriter, r *http.Request, hash string) {
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
hash = normalizeHash(hash)
|
||||
|
||||
// Validate hash format (already validated in routes.go, but double-check)
|
||||
if !isValidHash(hash) {
|
||||
writeValidationError(w, ErrInvalidHash)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed config/metamask/DUAL_CHAIN_NETWORKS.json
|
||||
@@ -14,6 +20,111 @@ var dualChainTokenListJSON []byte
|
||||
//go:embed config/metamask/CHAIN138_RPC_CAPABILITIES.json
|
||||
var chain138RPCCapabilitiesJSON []byte
|
||||
|
||||
type configPayload struct {
|
||||
body []byte
|
||||
source string
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
func uniqueConfigPaths(paths []string) []string {
|
||||
seen := make(map[string]struct{}, len(paths))
|
||||
out := make([]string, 0, len(paths))
|
||||
for _, candidate := range paths {
|
||||
trimmed := strings.TrimSpace(candidate)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildConfigCandidates(envKeys []string, defaults []string) []string {
|
||||
candidates := make([]string, 0, len(envKeys)+len(defaults)*4)
|
||||
for _, key := range envKeys {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
candidates = append(candidates, value)
|
||||
}
|
||||
}
|
||||
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
for _, rel := range defaults {
|
||||
if filepath.IsAbs(rel) {
|
||||
candidates = append(candidates, rel)
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, filepath.Join(cwd, rel))
|
||||
candidates = append(candidates, rel)
|
||||
}
|
||||
}
|
||||
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
exeDir := filepath.Dir(exe)
|
||||
for _, rel := range defaults {
|
||||
if filepath.IsAbs(rel) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates,
|
||||
filepath.Join(exeDir, rel),
|
||||
filepath.Join(exeDir, "..", rel),
|
||||
filepath.Join(exeDir, "..", "..", rel),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueConfigPaths(candidates)
|
||||
}
|
||||
|
||||
func loadConfigPayload(envKeys []string, defaults []string, embedded []byte) configPayload {
|
||||
for _, candidate := range buildConfigCandidates(envKeys, defaults) {
|
||||
body, err := os.ReadFile(candidate)
|
||||
if err != nil || len(body) == 0 {
|
||||
continue
|
||||
}
|
||||
payload := configPayload{
|
||||
body: body,
|
||||
source: "runtime-file",
|
||||
}
|
||||
if info, statErr := os.Stat(candidate); statErr == nil {
|
||||
payload.modTime = info.ModTime().UTC()
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
return configPayload{
|
||||
body: embedded,
|
||||
source: "embedded",
|
||||
}
|
||||
}
|
||||
|
||||
func payloadETag(body []byte) string {
|
||||
sum := sha256.Sum256(body)
|
||||
return `W/"` + hex.EncodeToString(sum[:]) + `"`
|
||||
}
|
||||
|
||||
func serveJSONConfig(w http.ResponseWriter, r *http.Request, payload configPayload, cacheControl string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", cacheControl)
|
||||
w.Header().Set("X-Config-Source", payload.source)
|
||||
|
||||
etag := payloadETag(payload.body)
|
||||
w.Header().Set("ETag", etag)
|
||||
if !payload.modTime.IsZero() {
|
||||
w.Header().Set("Last-Modified", payload.modTime.Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
if match := strings.TrimSpace(r.Header.Get("If-None-Match")); match != "" && strings.Contains(match, etag) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write(payload.body)
|
||||
}
|
||||
|
||||
// handleConfigNetworks serves GET /api/config/networks (Chain 138 + Ethereum Mainnet params for wallet_addEthereumChain).
|
||||
func (s *Server) handleConfigNetworks(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -21,9 +132,17 @@ func (s *Server) handleConfigNetworks(w http.ResponseWriter, r *http.Request) {
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write(dualChainNetworksJSON)
|
||||
payload := loadConfigPayload(
|
||||
[]string{"CONFIG_NETWORKS_JSON_PATH", "NETWORKS_CONFIG_JSON_PATH"},
|
||||
[]string{
|
||||
"explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json",
|
||||
"backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json",
|
||||
"api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json",
|
||||
"config/metamask/DUAL_CHAIN_NETWORKS.json",
|
||||
},
|
||||
dualChainNetworksJSON,
|
||||
)
|
||||
serveJSONConfig(w, r, payload, "public, max-age=0, must-revalidate")
|
||||
}
|
||||
|
||||
// handleConfigTokenList serves GET /api/config/token-list (Uniswap token list format for MetaMask).
|
||||
@@ -33,9 +152,17 @@ func (s *Server) handleConfigTokenList(w http.ResponseWriter, r *http.Request) {
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write(dualChainTokenListJSON)
|
||||
payload := loadConfigPayload(
|
||||
[]string{"CONFIG_TOKEN_LIST_JSON_PATH", "TOKEN_LIST_CONFIG_JSON_PATH"},
|
||||
[]string{
|
||||
"explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json",
|
||||
"backend/api/rest/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json",
|
||||
"api/rest/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json",
|
||||
"config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json",
|
||||
},
|
||||
dualChainTokenListJSON,
|
||||
)
|
||||
serveJSONConfig(w, r, payload, "public, max-age=0, must-revalidate")
|
||||
}
|
||||
|
||||
// handleConfigCapabilities serves GET /api/config/capabilities (Chain 138 wallet/RPC capability matrix).
|
||||
@@ -45,7 +172,15 @@ func (s *Server) handleConfigCapabilities(w http.ResponseWriter, r *http.Request
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=900")
|
||||
w.Write(chain138RPCCapabilitiesJSON)
|
||||
payload := loadConfigPayload(
|
||||
[]string{"CONFIG_CAPABILITIES_JSON_PATH", "RPC_CAPABILITIES_JSON_PATH"},
|
||||
[]string{
|
||||
"explorer-monorepo/backend/api/rest/config/metamask/CHAIN138_RPC_CAPABILITIES.json",
|
||||
"backend/api/rest/config/metamask/CHAIN138_RPC_CAPABILITIES.json",
|
||||
"api/rest/config/metamask/CHAIN138_RPC_CAPABILITIES.json",
|
||||
"config/metamask/CHAIN138_RPC_CAPABILITIES.json",
|
||||
},
|
||||
chain138RPCCapabilitiesJSON,
|
||||
)
|
||||
serveJSONConfig(w, r, payload, "public, max-age=0, must-revalidate")
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
842
backend/api/rest/config/metamask/GRU_V2_DEPLOYMENT_QUEUE.json
Normal file
842
backend/api/rest/config/metamask/GRU_V2_DEPLOYMENT_QUEUE.json
Normal file
@@ -0,0 +1,842 @@
|
||||
{
|
||||
"generatedAt": "2026-04-04T16:10:52.278Z",
|
||||
"summary": {
|
||||
"wave1Assets": 7,
|
||||
"wave1TransportActive": 0,
|
||||
"wave1TransportPending": 7,
|
||||
"wave1WrappedSymbols": 10,
|
||||
"wave1WrappedSymbolsCoveredByPoolMatrix": 10,
|
||||
"wave1WrappedSymbolsMissingFromPoolMatrix": 0,
|
||||
"desiredPublicEvmTargets": 11,
|
||||
"chainsWithLoadedCwSuites": 10,
|
||||
"chainsMissingCwSuites": 1,
|
||||
"firstTierWave1PoolsPlanned": 110,
|
||||
"firstTierWave1PoolsRecordedLive": 6,
|
||||
"protocolsTracked": 5,
|
||||
"protocolsLive": 1
|
||||
},
|
||||
"assetQueue": [
|
||||
{
|
||||
"code": "EUR",
|
||||
"name": "Euro",
|
||||
"canonicalSymbols": [
|
||||
"cEURC",
|
||||
"cEURT"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWEURC",
|
||||
"cWEURT"
|
||||
],
|
||||
"transportActive": false,
|
||||
"canonicalDeployed": true,
|
||||
"x402Ready": false,
|
||||
"coveredByPoolMatrix": true,
|
||||
"nextSteps": [
|
||||
"enable_bridge_controls",
|
||||
"set_max_outstanding",
|
||||
"promote_transport_overlay",
|
||||
"deploy_public_pools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "JPY",
|
||||
"name": "Japanese Yen",
|
||||
"canonicalSymbols": [
|
||||
"cJPYC"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWJPYC"
|
||||
],
|
||||
"transportActive": false,
|
||||
"canonicalDeployed": true,
|
||||
"x402Ready": false,
|
||||
"coveredByPoolMatrix": true,
|
||||
"nextSteps": [
|
||||
"enable_bridge_controls",
|
||||
"set_max_outstanding",
|
||||
"promote_transport_overlay",
|
||||
"deploy_public_pools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "GBP",
|
||||
"name": "Pound Sterling",
|
||||
"canonicalSymbols": [
|
||||
"cGBPC",
|
||||
"cGBPT"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWGBPC",
|
||||
"cWGBPT"
|
||||
],
|
||||
"transportActive": false,
|
||||
"canonicalDeployed": true,
|
||||
"x402Ready": false,
|
||||
"coveredByPoolMatrix": true,
|
||||
"nextSteps": [
|
||||
"enable_bridge_controls",
|
||||
"set_max_outstanding",
|
||||
"promote_transport_overlay",
|
||||
"deploy_public_pools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "AUD",
|
||||
"name": "Australian Dollar",
|
||||
"canonicalSymbols": [
|
||||
"cAUDC"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWAUDC"
|
||||
],
|
||||
"transportActive": false,
|
||||
"canonicalDeployed": true,
|
||||
"x402Ready": false,
|
||||
"coveredByPoolMatrix": true,
|
||||
"nextSteps": [
|
||||
"enable_bridge_controls",
|
||||
"set_max_outstanding",
|
||||
"promote_transport_overlay",
|
||||
"deploy_public_pools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "CAD",
|
||||
"name": "Canadian Dollar",
|
||||
"canonicalSymbols": [
|
||||
"cCADC"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWCADC"
|
||||
],
|
||||
"transportActive": false,
|
||||
"canonicalDeployed": true,
|
||||
"x402Ready": false,
|
||||
"coveredByPoolMatrix": true,
|
||||
"nextSteps": [
|
||||
"enable_bridge_controls",
|
||||
"set_max_outstanding",
|
||||
"promote_transport_overlay",
|
||||
"deploy_public_pools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "CHF",
|
||||
"name": "Swiss Franc",
|
||||
"canonicalSymbols": [
|
||||
"cCHFC"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWCHFC"
|
||||
],
|
||||
"transportActive": false,
|
||||
"canonicalDeployed": true,
|
||||
"x402Ready": false,
|
||||
"coveredByPoolMatrix": true,
|
||||
"nextSteps": [
|
||||
"enable_bridge_controls",
|
||||
"set_max_outstanding",
|
||||
"promote_transport_overlay",
|
||||
"deploy_public_pools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "XAU",
|
||||
"name": "Gold",
|
||||
"canonicalSymbols": [
|
||||
"cXAUC",
|
||||
"cXAUT"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWXAUC",
|
||||
"cWXAUT"
|
||||
],
|
||||
"transportActive": false,
|
||||
"canonicalDeployed": true,
|
||||
"x402Ready": false,
|
||||
"coveredByPoolMatrix": true,
|
||||
"nextSteps": [
|
||||
"enable_bridge_controls",
|
||||
"set_max_outstanding",
|
||||
"promote_transport_overlay",
|
||||
"deploy_public_pools"
|
||||
]
|
||||
}
|
||||
],
|
||||
"chainQueue": [
|
||||
{
|
||||
"chainId": 1,
|
||||
"name": "Ethereum Mainnet",
|
||||
"hubStable": "USDC",
|
||||
"bridgeAvailable": true,
|
||||
"cwTokenCount": 12,
|
||||
"wave1WrappedCoverage": 10,
|
||||
"plannedWave1Pairs": [
|
||||
"cWEURC/USDC",
|
||||
"cWEURT/USDC",
|
||||
"cWGBPC/USDC",
|
||||
"cWGBPT/USDC",
|
||||
"cWAUDC/USDC",
|
||||
"cWJPYC/USDC",
|
||||
"cWCHFC/USDC",
|
||||
"cWCADC/USDC",
|
||||
"cWXAUC/USDC",
|
||||
"cWXAUT/USDC"
|
||||
],
|
||||
"recordedWave1Pairs": [
|
||||
"cWEURC/USDC",
|
||||
"cWGBPC/USDC",
|
||||
"cWAUDC/USDC",
|
||||
"cWJPYC/USDC",
|
||||
"cWCHFC/USDC",
|
||||
"cWCADC/USDC"
|
||||
],
|
||||
"nextStep": "deploy_first_tier_wave1_pools"
|
||||
},
|
||||
{
|
||||
"chainId": 10,
|
||||
"name": "Optimism",
|
||||
"hubStable": "USDC",
|
||||
"bridgeAvailable": true,
|
||||
"cwTokenCount": 12,
|
||||
"wave1WrappedCoverage": 10,
|
||||
"plannedWave1Pairs": [
|
||||
"cWEURC/USDC",
|
||||
"cWEURT/USDC",
|
||||
"cWGBPC/USDC",
|
||||
"cWGBPT/USDC",
|
||||
"cWAUDC/USDC",
|
||||
"cWJPYC/USDC",
|
||||
"cWCHFC/USDC",
|
||||
"cWCADC/USDC",
|
||||
"cWXAUC/USDC",
|
||||
"cWXAUT/USDC"
|
||||
],
|
||||
"recordedWave1Pairs": [],
|
||||
"nextStep": "deploy_first_tier_wave1_pools"
|
||||
},
|
||||
{
|
||||
"chainId": 25,
|
||||
"name": "Cronos",
|
||||
"hubStable": "USDT",
|
||||
"bridgeAvailable": true,
|
||||
"cwTokenCount": 12,
|
||||
"wave1WrappedCoverage": 10,
|
||||
"plannedWave1Pairs": [
|
||||
"cWEURC/USDT",
|
||||
"cWEURT/USDT",
|
||||
"cWGBPC/USDT",
|
||||
"cWGBPT/USDT",
|
||||
"cWAUDC/USDT",
|
||||
"cWJPYC/USDT",
|
||||
"cWCHFC/USDT",
|
||||
"cWCADC/USDT",
|
||||
"cWXAUC/USDT",
|
||||
"cWXAUT/USDT"
|
||||
],
|
||||
"recordedWave1Pairs": [],
|
||||
"nextStep": "deploy_first_tier_wave1_pools"
|
||||
},
|
||||
{
|
||||
"chainId": 56,
|
||||
"name": "BSC",
|
||||
"hubStable": "USDT",
|
||||
"bridgeAvailable": true,
|
||||
"cwTokenCount": 14,
|
||||
"wave1WrappedCoverage": 10,
|
||||
"plannedWave1Pairs": [
|
||||
"cWEURC/USDT",
|
||||
"cWEURT/USDT",
|
||||
"cWGBPC/USDT",
|
||||
"cWGBPT/USDT",
|
||||
"cWAUDC/USDT",
|
||||
"cWJPYC/USDT",
|
||||
"cWCHFC/USDT",
|
||||
"cWCADC/USDT",
|
||||
"cWXAUC/USDT",
|
||||
"cWXAUT/USDT"
|
||||
],
|
||||
"recordedWave1Pairs": [],
|
||||
"nextStep": "deploy_first_tier_wave1_pools"
|
||||
},
|
||||
{
|
||||
"chainId": 100,
|
||||
"name": "Gnosis",
|
||||
"hubStable": "USDC",
|
||||
"bridgeAvailable": true,
|
||||
"cwTokenCount": 12,
|
||||
"wave1WrappedCoverage": 10,
|
||||
"plannedWave1Pairs": [
|
||||
"cWEURC/USDC",
|
||||
"cWEURT/USDC",
|
||||
"cWGBPC/USDC",
|
||||
"cWGBPT/USDC",
|
||||
"cWAUDC/USDC",
|
||||
"cWJPYC/USDC",
|
||||
"cWCHFC/USDC",
|
||||
"cWCADC/USDC",
|
||||
"cWXAUC/USDC",
|
||||
"cWXAUT/USDC"
|
||||
],
|
||||
"recordedWave1Pairs": [],
|
||||
"nextStep": "deploy_first_tier_wave1_pools"
|
||||
},
|
||||
{
|
||||
"chainId": 137,
|
||||
"name": "Polygon",
|
||||
"hubStable": "USDC",
|
||||
"bridgeAvailable": true,
|
||||
"cwTokenCount": 13,
|
||||
"wave1WrappedCoverage": 10,
|
||||
"plannedWave1Pairs": [
|
||||
"cWEURC/USDC",
|
||||
"cWEURT/USDC",
|
||||
"cWGBPC/USDC",
|
||||
"cWGBPT/USDC",
|
||||
"cWAUDC/USDC",
|
||||
"cWJPYC/USDC",
|
||||
"cWCHFC/USDC",
|
||||
"cWCADC/USDC",
|
||||
"cWXAUC/USDC",
|
||||
"cWXAUT/USDC"
|
||||
],
|
||||
"recordedWave1Pairs": [],
|
||||
"nextStep": "deploy_first_tier_wave1_pools"
|
||||
},
|
||||
{
|
||||
"chainId": 42161,
|
||||
"name": "Arbitrum One",
|
||||
"hubStable": "USDC",
|
||||
"bridgeAvailable": true,
|
||||
"cwTokenCount": 12,
|
||||
"wave1WrappedCoverage": 10,
|
||||
"plannedWave1Pairs": [
|
||||
"cWEURC/USDC",
|
||||
"cWEURT/USDC",
|
||||
"cWGBPC/USDC",
|
||||
"cWGBPT/USDC",
|
||||
"cWAUDC/USDC",
|
||||
"cWJPYC/USDC",
|
||||
"cWCHFC/USDC",
|
||||
"cWCADC/USDC",
|
||||
"cWXAUC/USDC",
|
||||
"cWXAUT/USDC"
|
||||
],
|
||||
"recordedWave1Pairs": [],
|
||||
"nextStep": "deploy_first_tier_wave1_pools"
|
||||
},
|
||||
{
|
||||
"chainId": 42220,
|
||||
"name": "Celo",
|
||||
"hubStable": "USDC",
|
||||
"bridgeAvailable": true,
|
||||
"cwTokenCount": 14,
|
||||
"wave1WrappedCoverage": 10,
|
||||
"plannedWave1Pairs": [
|
||||
"cWEURC/USDC",
|
||||
"cWEURT/USDC",
|
||||
"cWGBPC/USDC",
|
||||
"cWGBPT/USDC",
|
||||
"cWAUDC/USDC",
|
||||
"cWJPYC/USDC",
|
||||
"cWCHFC/USDC",
|
||||
"cWCADC/USDC",
|
||||
"cWXAUC/USDC",
|
||||
"cWXAUT/USDC"
|
||||
],
|
||||
"recordedWave1Pairs": [],
|
||||
"nextStep": "deploy_first_tier_wave1_pools"
|
||||
},
|
||||
{
|
||||
"chainId": 43114,
|
||||
"name": "Avalanche C-Chain",
|
||||
"hubStable": "USDC",
|
||||
"bridgeAvailable": true,
|
||||
"cwTokenCount": 14,
|
||||
"wave1WrappedCoverage": 10,
|
||||
"plannedWave1Pairs": [
|
||||
"cWEURC/USDC",
|
||||
"cWEURT/USDC",
|
||||
"cWGBPC/USDC",
|
||||
"cWGBPT/USDC",
|
||||
"cWAUDC/USDC",
|
||||
"cWJPYC/USDC",
|
||||
"cWCHFC/USDC",
|
||||
"cWCADC/USDC",
|
||||
"cWXAUC/USDC",
|
||||
"cWXAUT/USDC"
|
||||
],
|
||||
"recordedWave1Pairs": [],
|
||||
"nextStep": "deploy_first_tier_wave1_pools"
|
||||
},
|
||||
{
|
||||
"chainId": 8453,
|
||||
"name": "Base",
|
||||
"hubStable": "USDC",
|
||||
"bridgeAvailable": true,
|
||||
"cwTokenCount": 12,
|
||||
"wave1WrappedCoverage": 10,
|
||||
"plannedWave1Pairs": [
|
||||
"cWEURC/USDC",
|
||||
"cWEURT/USDC",
|
||||
"cWGBPC/USDC",
|
||||
"cWGBPT/USDC",
|
||||
"cWAUDC/USDC",
|
||||
"cWJPYC/USDC",
|
||||
"cWCHFC/USDC",
|
||||
"cWCADC/USDC",
|
||||
"cWXAUC/USDC",
|
||||
"cWXAUT/USDC"
|
||||
],
|
||||
"recordedWave1Pairs": [],
|
||||
"nextStep": "deploy_first_tier_wave1_pools"
|
||||
},
|
||||
{
|
||||
"chainId": 1111,
|
||||
"name": "Wemix",
|
||||
"hubStable": "USDT",
|
||||
"bridgeAvailable": false,
|
||||
"cwTokenCount": 0,
|
||||
"wave1WrappedCoverage": 0,
|
||||
"plannedWave1Pairs": [
|
||||
"cWEURC/USDT",
|
||||
"cWEURT/USDT",
|
||||
"cWGBPC/USDT",
|
||||
"cWGBPT/USDT",
|
||||
"cWAUDC/USDT",
|
||||
"cWJPYC/USDT",
|
||||
"cWCHFC/USDT",
|
||||
"cWCADC/USDT",
|
||||
"cWXAUC/USDT",
|
||||
"cWXAUT/USDT"
|
||||
],
|
||||
"recordedWave1Pairs": [],
|
||||
"nextStep": "complete_cw_suite_then_deploy_pools"
|
||||
}
|
||||
],
|
||||
"protocolQueue": [
|
||||
{
|
||||
"key": "uniswap_v3",
|
||||
"name": "Uniswap v3",
|
||||
"role": "primary_public_pool_venue",
|
||||
"deploymentStage": "stage1_first_tier_pools",
|
||||
"activePublicPools": 0,
|
||||
"currentState": "queued_not_live",
|
||||
"activationDependsOn": [
|
||||
"cW token suite deployed on destination chain",
|
||||
"first-tier cW/hub pools created",
|
||||
"pool addresses written to deployment-status.json",
|
||||
"token-aggregation/indexer visibility enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "dodo_pmm",
|
||||
"name": "DODO PMM",
|
||||
"role": "primary_public_pmm_edge_venue",
|
||||
"deploymentStage": "stage1_first_tier_pools",
|
||||
"activePublicPools": 10,
|
||||
"currentState": "partially_live_on_public_cw_mesh",
|
||||
"activationDependsOn": [
|
||||
"cW token suite deployed on destination chain",
|
||||
"first-tier cW/hub pools created",
|
||||
"pool addresses written to deployment-status.json",
|
||||
"policy controls and MCP visibility attached"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "balancer",
|
||||
"name": "Balancer",
|
||||
"role": "secondary_basket_liquidity",
|
||||
"deploymentStage": "stage2_post_first_tier_liquidity",
|
||||
"activePublicPools": 0,
|
||||
"currentState": "queued_not_live",
|
||||
"activationDependsOn": [
|
||||
"first-tier Uniswap v3 or DODO PMM liquidity live",
|
||||
"basket design approved for the destination chain",
|
||||
"pool addresses written to deployment-status.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "curve_3",
|
||||
"name": "Curve 3",
|
||||
"role": "secondary_stable_curve",
|
||||
"deploymentStage": "stage2_post_first_tier_liquidity",
|
||||
"activePublicPools": 0,
|
||||
"currentState": "queued_not_live",
|
||||
"activationDependsOn": [
|
||||
"first-tier stable liquidity live",
|
||||
"stable basket design approved for the destination chain",
|
||||
"pool addresses written to deployment-status.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "one_inch",
|
||||
"name": "1inch",
|
||||
"role": "routing_aggregation_layer",
|
||||
"deploymentStage": "stage3_after_underlying_pools_live",
|
||||
"activePublicPools": 0,
|
||||
"currentState": "queued_not_live",
|
||||
"activationDependsOn": [
|
||||
"underlying public pools already live",
|
||||
"router/indexer visibility enabled",
|
||||
"token-aggregation/provider capability surfaced publicly"
|
||||
]
|
||||
}
|
||||
],
|
||||
"blockers": [
|
||||
"Desired public EVM targets still missing cW suites: Wemix.",
|
||||
"Wave 1 transport is still pending for: EUR, JPY, GBP, AUD, CAD, CHF, XAU.",
|
||||
"Arbitrum bootstrap remains blocked on the current Mainnet hub leg: tx 0x97df657f0e31341ca852666766e553650531bbcc86621246d041985d7261bb07 reverted before any bridge event was emitted."
|
||||
],
|
||||
"resolutionMatrix": [
|
||||
{
|
||||
"key": "mainnet_arbitrum_hub_blocked",
|
||||
"state": "open",
|
||||
"blocker": "Arbitrum bootstrap remains blocked on the current Mainnet hub leg: tx 0x97df657f0e31341ca852666766e553650531bbcc86621246d041985d7261bb07 reverted from 0xc9901ce2Ddb6490FAA183645147a87496d8b20B6 before any bridge event was emitted.",
|
||||
"targets": [
|
||||
{
|
||||
"fromChain": 138,
|
||||
"viaChain": 1,
|
||||
"toChain": 42161,
|
||||
"currentPath": "138 -> Mainnet -> Arbitrum"
|
||||
}
|
||||
],
|
||||
"resolution": [
|
||||
"Repair or replace the current Mainnet WETH9 fan-out bridge before treating Arbitrum as an available public bootstrap target.",
|
||||
"Retest 138 -> Mainnet first-hop delivery, then rerun a smaller Mainnet -> Arbitrum send and require destination bridge events before promoting the route.",
|
||||
"Keep Arbitrum marked blocked in the explorer and status surfaces until the hub leg emits and completes normally."
|
||||
],
|
||||
"runbooks": [
|
||||
"docs/07-ccip/CROSS_NETWORK_FUNDING_BOOTSTRAP_STRATEGY.md",
|
||||
"docs/07-ccip/CHAIN138_PUBLIC_CHAIN_UNLOAD_ROUTES.md",
|
||||
"docs/00-meta/REQUIRED_FIXES_GAPS_AND_DEPLOYMENTS_LIST.md"
|
||||
],
|
||||
"exitCriteria": "A fresh Mainnet -> Arbitrum WETH9 send emits bridge events and completes destination delivery successfully."
|
||||
},
|
||||
{
|
||||
"key": "missing_public_cw_suites",
|
||||
"state": "open",
|
||||
"blocker": "Desired public EVM targets still missing cW suites: Wemix.",
|
||||
"targets": [
|
||||
{
|
||||
"chainId": 1111,
|
||||
"name": "Wemix",
|
||||
"nextStep": "complete_cw_suite_then_deploy_pools"
|
||||
}
|
||||
],
|
||||
"resolution": [
|
||||
"Deploy the full cW core suite on each missing destination chain using the existing CW deploy-and-wire flow.",
|
||||
"Grant bridge mint/burn roles and mark the corridor live in cross-chain-pmm-lps/config/deployment-status.json.",
|
||||
"Update public token lists / explorer config, then rerun check-cw-evm-deployment-mesh.sh and check-cw-public-pool-status.sh."
|
||||
],
|
||||
"runbooks": [
|
||||
"docs/07-ccip/CW_DEPLOY_AND_WIRE_RUNBOOK.md",
|
||||
"docs/03-deployment/PHASE_C_CW_AND_EDGE_POOLS_RUNBOOK.md",
|
||||
"scripts/deployment/run-cw-remaining-steps.sh",
|
||||
"scripts/verify/check-cw-evm-deployment-mesh.sh"
|
||||
],
|
||||
"exitCriteria": "Wemix report non-zero cW suites and become bridgeAvailable in deployment-status.json."
|
||||
},
|
||||
{
|
||||
"key": "wave1_transport_pending",
|
||||
"state": "open",
|
||||
"blocker": "Wave 1 transport is still pending for: EUR, JPY, GBP, AUD, CAD, CHF, XAU.",
|
||||
"targets": [
|
||||
{
|
||||
"code": "EUR",
|
||||
"canonicalSymbols": [
|
||||
"cEURC",
|
||||
"cEURT"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWEURC",
|
||||
"cWEURT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "JPY",
|
||||
"canonicalSymbols": [
|
||||
"cJPYC"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWJPYC"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "GBP",
|
||||
"canonicalSymbols": [
|
||||
"cGBPC",
|
||||
"cGBPT"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWGBPC",
|
||||
"cWGBPT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "AUD",
|
||||
"canonicalSymbols": [
|
||||
"cAUDC"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWAUDC"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "CAD",
|
||||
"canonicalSymbols": [
|
||||
"cCADC"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWCADC"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "CHF",
|
||||
"canonicalSymbols": [
|
||||
"cCHFC"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWCHFC"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "XAU",
|
||||
"canonicalSymbols": [
|
||||
"cXAUC",
|
||||
"cXAUT"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWXAUC",
|
||||
"cWXAUT"
|
||||
]
|
||||
}
|
||||
],
|
||||
"resolution": [
|
||||
"Enable bridge controls and supervision policy for each Wave 1 canonical asset on Chain 138.",
|
||||
"Set max-outstanding / capacity controls, then promote the canonical symbols into config/gru-transport-active.json.",
|
||||
"Verify the overlay promotion with check-gru-global-priority-rollout.sh and check-gru-v2-chain138-readiness.sh before attaching public liquidity."
|
||||
],
|
||||
"runbooks": [
|
||||
"docs/04-configuration/GRU_GLOBAL_PRIORITY_CROSS_CHAIN_ROLLOUT.md",
|
||||
"docs/04-configuration/GRU_TRANSPORT_ACTIVE_JSON.md",
|
||||
"scripts/verify/check-gru-global-priority-rollout.sh",
|
||||
"scripts/verify/check-gru-v2-chain138-readiness.sh"
|
||||
],
|
||||
"exitCriteria": "Wave 1 transport pending count reaches zero and the overlay reports the seven non-USD assets as live_transport."
|
||||
},
|
||||
{
|
||||
"key": "first_tier_public_pools_not_live",
|
||||
"state": "in_progress",
|
||||
"blocker": "Some first-tier Wave 1 public cW pools are live, but the rollout is incomplete.",
|
||||
"targets": [
|
||||
{
|
||||
"chainId": 1,
|
||||
"name": "Ethereum Mainnet",
|
||||
"hubStable": "USDC",
|
||||
"plannedWave1Pairs": 10,
|
||||
"recordedWave1Pairs": 6
|
||||
},
|
||||
{
|
||||
"chainId": 10,
|
||||
"name": "Optimism",
|
||||
"hubStable": "USDC",
|
||||
"plannedWave1Pairs": 10,
|
||||
"recordedWave1Pairs": 0
|
||||
},
|
||||
{
|
||||
"chainId": 25,
|
||||
"name": "Cronos",
|
||||
"hubStable": "USDT",
|
||||
"plannedWave1Pairs": 10,
|
||||
"recordedWave1Pairs": 0
|
||||
},
|
||||
{
|
||||
"chainId": 56,
|
||||
"name": "BSC",
|
||||
"hubStable": "USDT",
|
||||
"plannedWave1Pairs": 10,
|
||||
"recordedWave1Pairs": 0
|
||||
},
|
||||
{
|
||||
"chainId": 100,
|
||||
"name": "Gnosis",
|
||||
"hubStable": "USDC",
|
||||
"plannedWave1Pairs": 10,
|
||||
"recordedWave1Pairs": 0
|
||||
},
|
||||
{
|
||||
"chainId": 137,
|
||||
"name": "Polygon",
|
||||
"hubStable": "USDC",
|
||||
"plannedWave1Pairs": 10,
|
||||
"recordedWave1Pairs": 0
|
||||
},
|
||||
{
|
||||
"chainId": 42161,
|
||||
"name": "Arbitrum One",
|
||||
"hubStable": "USDC",
|
||||
"plannedWave1Pairs": 10,
|
||||
"recordedWave1Pairs": 0
|
||||
},
|
||||
{
|
||||
"chainId": 42220,
|
||||
"name": "Celo",
|
||||
"hubStable": "USDC",
|
||||
"plannedWave1Pairs": 10,
|
||||
"recordedWave1Pairs": 0
|
||||
},
|
||||
{
|
||||
"chainId": 43114,
|
||||
"name": "Avalanche C-Chain",
|
||||
"hubStable": "USDC",
|
||||
"plannedWave1Pairs": 10,
|
||||
"recordedWave1Pairs": 0
|
||||
},
|
||||
{
|
||||
"chainId": 8453,
|
||||
"name": "Base",
|
||||
"hubStable": "USDC",
|
||||
"plannedWave1Pairs": 10,
|
||||
"recordedWave1Pairs": 0
|
||||
},
|
||||
{
|
||||
"chainId": 1111,
|
||||
"name": "Wemix",
|
||||
"hubStable": "USDT",
|
||||
"plannedWave1Pairs": 10,
|
||||
"recordedWave1Pairs": 0
|
||||
}
|
||||
],
|
||||
"resolution": [
|
||||
"Deploy the first-tier cW/hub-stable pairs from pool-matrix.json on every chain with a loaded cW suite.",
|
||||
"Seed the new pools with initial liquidity and record the resulting pool addresses in cross-chain-pmm-lps/config/deployment-status.json.",
|
||||
"Use check-cw-public-pool-status.sh to verify the mesh is no longer empty before surfacing the venues publicly."
|
||||
],
|
||||
"runbooks": [
|
||||
"docs/03-deployment/SINGLE_SIDED_LPS_PUBLIC_NETWORKS_RUNBOOK.md",
|
||||
"docs/03-deployment/PMM_FULL_MESH_AND_PUBLIC_SINGLE_SIDED_PLAN.md",
|
||||
"cross-chain-pmm-lps/config/pool-matrix.json",
|
||||
"scripts/verify/check-cw-public-pool-status.sh"
|
||||
],
|
||||
"exitCriteria": "First-tier Wave 1 pools are recorded live in deployment-status.json and check-cw-public-pool-status.sh reports non-zero pool coverage."
|
||||
},
|
||||
{
|
||||
"key": "public_protocols_queued",
|
||||
"state": "in_progress",
|
||||
"blocker": "Some tracked public protocols have begun activation, but the full protocol stack is not live yet.",
|
||||
"targets": [
|
||||
{
|
||||
"key": "uniswap_v3",
|
||||
"name": "Uniswap v3",
|
||||
"deploymentStage": "stage1_first_tier_pools",
|
||||
"activationDependsOn": [
|
||||
"cW token suite deployed on destination chain",
|
||||
"first-tier cW/hub pools created",
|
||||
"pool addresses written to deployment-status.json",
|
||||
"token-aggregation/indexer visibility enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "dodo_pmm",
|
||||
"name": "DODO PMM",
|
||||
"deploymentStage": "stage1_first_tier_pools",
|
||||
"activationDependsOn": [
|
||||
"cW token suite deployed on destination chain",
|
||||
"first-tier cW/hub pools created",
|
||||
"pool addresses written to deployment-status.json",
|
||||
"policy controls and MCP visibility attached"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "balancer",
|
||||
"name": "Balancer",
|
||||
"deploymentStage": "stage2_post_first_tier_liquidity",
|
||||
"activationDependsOn": [
|
||||
"first-tier Uniswap v3 or DODO PMM liquidity live",
|
||||
"basket design approved for the destination chain",
|
||||
"pool addresses written to deployment-status.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "curve_3",
|
||||
"name": "Curve 3",
|
||||
"deploymentStage": "stage2_post_first_tier_liquidity",
|
||||
"activationDependsOn": [
|
||||
"first-tier stable liquidity live",
|
||||
"stable basket design approved for the destination chain",
|
||||
"pool addresses written to deployment-status.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "one_inch",
|
||||
"name": "1inch",
|
||||
"deploymentStage": "stage3_after_underlying_pools_live",
|
||||
"activationDependsOn": [
|
||||
"underlying public pools already live",
|
||||
"router/indexer visibility enabled",
|
||||
"token-aggregation/provider capability surfaced publicly"
|
||||
]
|
||||
}
|
||||
],
|
||||
"resolution": [
|
||||
"Stage 1: activate Uniswap v3 and DODO PMM once first-tier cW pools exist on the public mesh.",
|
||||
"Stage 2: activate Balancer and Curve 3 only after first-tier stable liquidity is already live.",
|
||||
"Stage 3: expose 1inch after the underlying pools, routing/indexer visibility, and public provider-capability wiring are in place."
|
||||
],
|
||||
"runbooks": [
|
||||
"config/gru-v2-public-protocol-rollout-plan.json",
|
||||
"docs/11-references/GRU_V2_PUBLIC_PROTOCOL_DEPLOYMENT_STATUS.md",
|
||||
"scripts/verify/check-gru-v2-public-protocols.sh"
|
||||
],
|
||||
"exitCriteria": "The public protocol status surface reports non-zero active cW pools for the staged venues."
|
||||
},
|
||||
{
|
||||
"key": "global_priority_backlog",
|
||||
"state": "open",
|
||||
"blocker": "The ranked GRU global rollout still has 29 backlog assets outside the live manifest.",
|
||||
"targets": [
|
||||
{
|
||||
"backlogAssets": 29
|
||||
}
|
||||
],
|
||||
"resolution": [
|
||||
"Complete Wave 1 transport and first-tier public liquidity before promoting the remaining ranked assets.",
|
||||
"For each backlog asset, add canonical + wrapped symbols to the manifest/rollout plan, deploy contracts, and extend the public pool matrix.",
|
||||
"Promote each new asset through the same transport and public-liquidity gates used for Wave 1."
|
||||
],
|
||||
"runbooks": [
|
||||
"config/gru-global-priority-currency-rollout.json",
|
||||
"config/gru-iso4217-currency-manifest.json",
|
||||
"docs/04-configuration/GRU_GLOBAL_PRIORITY_CROSS_CHAIN_ROLLOUT.md",
|
||||
"scripts/verify/check-gru-global-priority-rollout.sh"
|
||||
],
|
||||
"exitCriteria": "Backlog assets count reaches zero in check-gru-global-priority-rollout.sh."
|
||||
},
|
||||
{
|
||||
"key": "solana_non_evm_program",
|
||||
"state": "planned",
|
||||
"blocker": "Desired non-EVM GRU targets remain planned / relay-dependent: Solana.",
|
||||
"targets": [
|
||||
{
|
||||
"identifier": "Solana",
|
||||
"label": "Solana"
|
||||
}
|
||||
],
|
||||
"resolution": [
|
||||
"Define the destination-chain token/program model first: SPL or wrapped-account representation, authority model, and relay custody surface.",
|
||||
"Implement the relay/program path and only then promote Solana from desired-target status into the active transport inventory.",
|
||||
"Add dedicated verifier coverage before marking Solana live anywhere in the explorer or status docs."
|
||||
],
|
||||
"runbooks": [
|
||||
"docs/04-configuration/ADDITIONAL_PATHS_AND_EXTENSIONS.md",
|
||||
"docs/04-configuration/GRU_GLOBAL_PRIORITY_CROSS_CHAIN_ROLLOUT.md"
|
||||
],
|
||||
"exitCriteria": "Solana has a real relay/program surface, a verifier, and is no longer only listed as a desired non-EVM target."
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"This queue is an operator/deployment planning surface. It does not mark queued pools or transports as live.",
|
||||
"Chain 138 canonical venues remain a separate live surface from the public cW mesh."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
{
|
||||
"generatedAt": "2026-04-04T16:10:52.261Z",
|
||||
"canonicalChainId": 138,
|
||||
"summary": {
|
||||
"desiredPublicEvmTargets": 11,
|
||||
"loadedPublicEvmChains": 10,
|
||||
"loadedPublicEvmFullCoreSuite": 10,
|
||||
"desiredButNotLoaded": 1,
|
||||
"publicProtocolsTracked": 5,
|
||||
"publicProtocolsWithActiveCwPools": 1,
|
||||
"chainsWithAnyRecordedPublicCwPools": 1,
|
||||
"liveTransportAssets": 1,
|
||||
"wave1CanonicalOnly": 7,
|
||||
"backlogAssets": 29
|
||||
},
|
||||
"publicEvmMesh": {
|
||||
"coreCwSuite": [
|
||||
"cWUSDT",
|
||||
"cWUSDC",
|
||||
"cWEURC",
|
||||
"cWEURT",
|
||||
"cWGBPC",
|
||||
"cWGBPT",
|
||||
"cWAUDC",
|
||||
"cWJPYC",
|
||||
"cWCHFC",
|
||||
"cWCADC",
|
||||
"cWXAUC",
|
||||
"cWXAUT"
|
||||
],
|
||||
"desiredChains": [
|
||||
{
|
||||
"chainId": 1,
|
||||
"name": "Ethereum Mainnet",
|
||||
"cwTokenCount": 12,
|
||||
"hasFullCoreSuite": true,
|
||||
"bridgeAvailable": true,
|
||||
"pmmPoolCount": 10
|
||||
},
|
||||
{
|
||||
"chainId": 10,
|
||||
"name": "Optimism",
|
||||
"cwTokenCount": 12,
|
||||
"hasFullCoreSuite": true,
|
||||
"bridgeAvailable": true,
|
||||
"pmmPoolCount": 0
|
||||
},
|
||||
{
|
||||
"chainId": 25,
|
||||
"name": "Cronos",
|
||||
"cwTokenCount": 12,
|
||||
"hasFullCoreSuite": true,
|
||||
"bridgeAvailable": true,
|
||||
"pmmPoolCount": 0
|
||||
},
|
||||
{
|
||||
"chainId": 56,
|
||||
"name": "BSC (BNB Chain)",
|
||||
"cwTokenCount": 14,
|
||||
"hasFullCoreSuite": true,
|
||||
"bridgeAvailable": true,
|
||||
"pmmPoolCount": 0
|
||||
},
|
||||
{
|
||||
"chainId": 100,
|
||||
"name": "Gnosis Chain",
|
||||
"cwTokenCount": 12,
|
||||
"hasFullCoreSuite": true,
|
||||
"bridgeAvailable": true,
|
||||
"pmmPoolCount": 0
|
||||
},
|
||||
{
|
||||
"chainId": 137,
|
||||
"name": "Polygon",
|
||||
"cwTokenCount": 13,
|
||||
"hasFullCoreSuite": true,
|
||||
"bridgeAvailable": true,
|
||||
"pmmPoolCount": 0
|
||||
},
|
||||
{
|
||||
"chainId": 42161,
|
||||
"name": "Arbitrum One",
|
||||
"cwTokenCount": 12,
|
||||
"hasFullCoreSuite": true,
|
||||
"bridgeAvailable": true,
|
||||
"pmmPoolCount": 0
|
||||
},
|
||||
{
|
||||
"chainId": 42220,
|
||||
"name": "Celo",
|
||||
"cwTokenCount": 14,
|
||||
"hasFullCoreSuite": true,
|
||||
"bridgeAvailable": true,
|
||||
"pmmPoolCount": 0
|
||||
},
|
||||
{
|
||||
"chainId": 43114,
|
||||
"name": "Avalanche C-Chain",
|
||||
"cwTokenCount": 14,
|
||||
"hasFullCoreSuite": true,
|
||||
"bridgeAvailable": true,
|
||||
"pmmPoolCount": 0
|
||||
},
|
||||
{
|
||||
"chainId": 8453,
|
||||
"name": "Base",
|
||||
"cwTokenCount": 12,
|
||||
"hasFullCoreSuite": true,
|
||||
"bridgeAvailable": true,
|
||||
"pmmPoolCount": 0
|
||||
},
|
||||
{
|
||||
"chainId": 1111,
|
||||
"name": "Wemix",
|
||||
"cwTokenCount": 0,
|
||||
"hasFullCoreSuite": false,
|
||||
"bridgeAvailable": false,
|
||||
"pmmPoolCount": 0
|
||||
}
|
||||
],
|
||||
"desiredButNotLoaded": [
|
||||
{
|
||||
"chainId": 1111,
|
||||
"name": "Wemix"
|
||||
}
|
||||
],
|
||||
"wave1PoolMatrixCoverage": {
|
||||
"totalWrappedSymbols": 10,
|
||||
"coveredSymbols": 10,
|
||||
"missingSymbols": []
|
||||
},
|
||||
"note": "The public EVM cW token mesh is complete on the currently loaded 10-chain set, but Wemix remains a desired target without a cW suite in deployment-status.json."
|
||||
},
|
||||
"transport": {
|
||||
"liveTransportAssets": [
|
||||
{
|
||||
"code": "USD",
|
||||
"name": "US Dollar"
|
||||
}
|
||||
],
|
||||
"wave1": [
|
||||
{
|
||||
"code": "EUR",
|
||||
"name": "Euro",
|
||||
"wave": "wave1",
|
||||
"manifestPresent": true,
|
||||
"deployed": true,
|
||||
"transportActive": false,
|
||||
"x402Ready": false,
|
||||
"canonicalSymbols": [
|
||||
"cEURC",
|
||||
"cEURT"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWEURC",
|
||||
"cWEURT"
|
||||
],
|
||||
"currentState": "canonical_only",
|
||||
"nextStep": "activate_transport_and_attach_public_liquidity"
|
||||
},
|
||||
{
|
||||
"code": "JPY",
|
||||
"name": "Japanese Yen",
|
||||
"wave": "wave1",
|
||||
"manifestPresent": true,
|
||||
"deployed": true,
|
||||
"transportActive": false,
|
||||
"x402Ready": false,
|
||||
"canonicalSymbols": [
|
||||
"cJPYC"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWJPYC"
|
||||
],
|
||||
"currentState": "canonical_only",
|
||||
"nextStep": "activate_transport_and_attach_public_liquidity"
|
||||
},
|
||||
{
|
||||
"code": "GBP",
|
||||
"name": "Pound Sterling",
|
||||
"wave": "wave1",
|
||||
"manifestPresent": true,
|
||||
"deployed": true,
|
||||
"transportActive": false,
|
||||
"x402Ready": false,
|
||||
"canonicalSymbols": [
|
||||
"cGBPC",
|
||||
"cGBPT"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWGBPC",
|
||||
"cWGBPT"
|
||||
],
|
||||
"currentState": "canonical_only",
|
||||
"nextStep": "activate_transport_and_attach_public_liquidity"
|
||||
},
|
||||
{
|
||||
"code": "AUD",
|
||||
"name": "Australian Dollar",
|
||||
"wave": "wave1",
|
||||
"manifestPresent": true,
|
||||
"deployed": true,
|
||||
"transportActive": false,
|
||||
"x402Ready": false,
|
||||
"canonicalSymbols": [
|
||||
"cAUDC"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWAUDC"
|
||||
],
|
||||
"currentState": "canonical_only",
|
||||
"nextStep": "activate_transport_and_attach_public_liquidity"
|
||||
},
|
||||
{
|
||||
"code": "CAD",
|
||||
"name": "Canadian Dollar",
|
||||
"wave": "wave1",
|
||||
"manifestPresent": true,
|
||||
"deployed": true,
|
||||
"transportActive": false,
|
||||
"x402Ready": false,
|
||||
"canonicalSymbols": [
|
||||
"cCADC"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWCADC"
|
||||
],
|
||||
"currentState": "canonical_only",
|
||||
"nextStep": "activate_transport_and_attach_public_liquidity"
|
||||
},
|
||||
{
|
||||
"code": "CHF",
|
||||
"name": "Swiss Franc",
|
||||
"wave": "wave1",
|
||||
"manifestPresent": true,
|
||||
"deployed": true,
|
||||
"transportActive": false,
|
||||
"x402Ready": false,
|
||||
"canonicalSymbols": [
|
||||
"cCHFC"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWCHFC"
|
||||
],
|
||||
"currentState": "canonical_only",
|
||||
"nextStep": "activate_transport_and_attach_public_liquidity"
|
||||
},
|
||||
{
|
||||
"code": "XAU",
|
||||
"name": "Gold",
|
||||
"wave": "wave1",
|
||||
"manifestPresent": true,
|
||||
"deployed": true,
|
||||
"transportActive": false,
|
||||
"x402Ready": false,
|
||||
"canonicalSymbols": [
|
||||
"cXAUC",
|
||||
"cXAUT"
|
||||
],
|
||||
"wrappedSymbols": [
|
||||
"cWXAUC",
|
||||
"cWXAUT"
|
||||
],
|
||||
"currentState": "canonical_only",
|
||||
"nextStep": "activate_transport_and_attach_public_liquidity"
|
||||
}
|
||||
],
|
||||
"note": "USD is the only live transport asset today. Wave 1 non-USD assets are deployed canonically on Chain 138 but are not yet promoted into the active transport overlay."
|
||||
},
|
||||
"protocols": {
|
||||
"publicCwMesh": [
|
||||
{
|
||||
"key": "uniswap_v3",
|
||||
"name": "Uniswap v3",
|
||||
"activePublicCwPools": 0,
|
||||
"destinationChainsWithPools": 0,
|
||||
"status": "not_deployed_on_public_cw_mesh",
|
||||
"notes": "No live public-chain cW* venue is recorded for this protocol in deployment-status.json yet."
|
||||
},
|
||||
{
|
||||
"key": "balancer",
|
||||
"name": "Balancer",
|
||||
"activePublicCwPools": 0,
|
||||
"destinationChainsWithPools": 0,
|
||||
"status": "not_deployed_on_public_cw_mesh",
|
||||
"notes": "No live public-chain cW* venue is recorded for this protocol in deployment-status.json yet."
|
||||
},
|
||||
{
|
||||
"key": "curve_3",
|
||||
"name": "Curve 3",
|
||||
"activePublicCwPools": 0,
|
||||
"destinationChainsWithPools": 0,
|
||||
"status": "not_deployed_on_public_cw_mesh",
|
||||
"notes": "No live public-chain cW* venue is recorded for this protocol in deployment-status.json yet."
|
||||
},
|
||||
{
|
||||
"key": "dodo_pmm",
|
||||
"name": "DODO PMM",
|
||||
"activePublicCwPools": 10,
|
||||
"destinationChainsWithPools": 1,
|
||||
"status": "partial_live_on_public_cw_mesh",
|
||||
"notes": "deployment-status.json now records live public-chain cW* DODO PMM pools on Mainnet, including recorded non-USD Wave 1 rows, and the recorded Mainnet pools now have bidirectional live execution proof. The broader public cW mesh is still partial."
|
||||
},
|
||||
{
|
||||
"key": "one_inch",
|
||||
"name": "1inch",
|
||||
"activePublicCwPools": 0,
|
||||
"destinationChainsWithPools": 0,
|
||||
"status": "not_deployed_on_public_cw_mesh",
|
||||
"notes": "No live public-chain cW* venue is recorded for this protocol in deployment-status.json yet."
|
||||
}
|
||||
],
|
||||
"chain138CanonicalVenues": {
|
||||
"note": "Chain 138 canonical routing is a separate surface: DODO PMM plus upstream-native Uniswap v3 and the funded pilot-compatible Balancer, Curve 3, and 1inch venues are live there.",
|
||||
"liveProtocols": [
|
||||
"DODO PMM",
|
||||
"Uniswap v3",
|
||||
"Balancer",
|
||||
"Curve 3",
|
||||
"1inch"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bridgeRouteHealth": {
|
||||
"arbitrumHubBlocker": {
|
||||
"active": true,
|
||||
"fromChain": 138,
|
||||
"viaChain": 1,
|
||||
"toChain": 42161,
|
||||
"currentPath": "138 -> Mainnet -> Arbitrum",
|
||||
"sourceBridge": "0xc9901ce2Ddb6490FAA183645147a87496d8b20B6",
|
||||
"failedTxHash": "0x97df657f0e31341ca852666766e553650531bbcc86621246d041985d7261bb07",
|
||||
"note": "Use Mainnet hub; direct 138 first hop to Arbitrum emitted MessageSent on 2026-04-04 without destination delivery."
|
||||
}
|
||||
},
|
||||
"explorer": {
|
||||
"tokenListApi": "https://explorer.d-bis.org/api/config/token-list",
|
||||
"staticStatusPath": "https://explorer.d-bis.org/config/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json"
|
||||
},
|
||||
"blockers": [
|
||||
"Desired public EVM targets still lack cW token suites: Wemix.",
|
||||
"Wave 1 GRU assets are still canonical-only on Chain 138: EUR, JPY, GBP, AUD, CAD, CHF, XAU.",
|
||||
"Public cW* protocol rollout is now partial: DODO PMM has recorded pools, while Uniswap v3, Balancer, Curve 3, and 1inch remain not live on the public cW mesh.",
|
||||
"The ranked GRU global rollout still has 29 backlog assets outside the live manifest.",
|
||||
"Desired non-EVM GRU targets remain planned / relay-dependent: Solana.",
|
||||
"Arbitrum public-network bootstrap remains blocked on the current Mainnet hub leg: tx 0x97df657f0e31341ca852666766e553650531bbcc86621246d041985d7261bb07 reverted from 0xc9901ce2Ddb6490FAA183645147a87496d8b20B6 before any bridge event was emitted."
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -204,14 +206,100 @@ func TestConfigCapabilitiesEndpointProvidesRPCCapabilityMatrix(t *testing.T) {
|
||||
if !containsString(payload.HTTP.SupportedMethods, "eth_feeHistory") {
|
||||
t.Fatal("expected eth_feeHistory support to be documented")
|
||||
}
|
||||
if !containsString(payload.HTTP.UnsupportedMethods, "eth_maxPriorityFeePerGas") {
|
||||
t.Fatal("expected missing eth_maxPriorityFeePerGas support to be documented")
|
||||
if !containsString(payload.HTTP.SupportedMethods, "eth_maxPriorityFeePerGas") {
|
||||
t.Fatal("expected eth_maxPriorityFeePerGas support to be documented")
|
||||
}
|
||||
if !containsString(payload.Tracing.SupportedMethods, "trace_block") {
|
||||
t.Fatal("expected trace_block support to be documented")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigTokenListEndpointReloadsRuntimeFileWithoutRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "token-list.json")
|
||||
first := `{"name":"Runtime Token List v1","tokens":[{"chainId":138,"address":"0x1111111111111111111111111111111111111111","symbol":"RT1","name":"Runtime One","decimals":6}]}`
|
||||
second := `{"name":"Runtime Token List v2","tokens":[{"chainId":138,"address":"0x2222222222222222222222222222222222222222","symbol":"RT2","name":"Runtime Two","decimals":6}]}`
|
||||
|
||||
if err := os.WriteFile(file, []byte(first), 0o644); err != nil {
|
||||
t.Fatalf("failed to write initial runtime file: %v", err)
|
||||
}
|
||||
t.Setenv("CONFIG_TOKEN_LIST_JSON_PATH", file)
|
||||
|
||||
handler := setupConfigHandler()
|
||||
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/api/config/token-list", nil)
|
||||
w1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w1, req1)
|
||||
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w1.Code)
|
||||
}
|
||||
if got := w1.Header().Get("X-Config-Source"); got != "runtime-file" {
|
||||
t.Fatalf("expected runtime-file config source, got %q", got)
|
||||
}
|
||||
etag1 := w1.Header().Get("ETag")
|
||||
if etag1 == "" {
|
||||
t.Fatal("expected ETag header on runtime-backed response")
|
||||
}
|
||||
|
||||
var body1 testTokenList
|
||||
if err := json.Unmarshal(w1.Body.Bytes(), &body1); err != nil {
|
||||
t.Fatalf("failed to parse runtime token list v1: %v", err)
|
||||
}
|
||||
if body1.Name != "Runtime Token List v1" {
|
||||
t.Fatalf("expected first runtime payload, got %q", body1.Name)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(file, []byte(second), 0o644); err != nil {
|
||||
t.Fatalf("failed to write updated runtime file: %v", err)
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/config/token-list", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 after runtime update, got %d", w2.Code)
|
||||
}
|
||||
if got := w2.Header().Get("ETag"); got == "" || got == etag1 {
|
||||
t.Fatalf("expected changed ETag after runtime update, got %q", got)
|
||||
}
|
||||
|
||||
var body2 testTokenList
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &body2); err != nil {
|
||||
t.Fatalf("failed to parse runtime token list v2: %v", err)
|
||||
}
|
||||
if body2.Name != "Runtime Token List v2" {
|
||||
t.Fatalf("expected updated runtime payload, got %q", body2.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigTokenListEndpointSupportsETagRevalidation(t *testing.T) {
|
||||
handler := setupConfigHandler()
|
||||
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/api/config/token-list", nil)
|
||||
w1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w1, req1)
|
||||
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w1.Code)
|
||||
}
|
||||
|
||||
etag := w1.Header().Get("ETag")
|
||||
if etag == "" {
|
||||
t.Fatal("expected ETag header")
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/config/token-list", nil)
|
||||
req2.Header.Set("If-None-Match", etag)
|
||||
w2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusNotModified {
|
||||
t.Fatalf("expected 304, got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigEndpointsSupportOptionsPreflight(t *testing.T) {
|
||||
handler := setupConfigHandler()
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/config/token-list", nil)
|
||||
|
||||
@@ -2,10 +2,13 @@ package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -122,7 +125,7 @@ func (s *Server) handleEtherscanAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var timestamp time.Time
|
||||
var transactionCount int
|
||||
var gasUsed, gasLimit int64
|
||||
var transactions []string
|
||||
var transactions interface{}
|
||||
|
||||
query := `
|
||||
SELECT hash, parent_hash, timestamp, miner, transaction_count, gas_used, gas_limit
|
||||
@@ -142,40 +145,28 @@ func (s *Server) handleEtherscanAPI(w http.ResponseWriter, r *http.Request) {
|
||||
break
|
||||
}
|
||||
|
||||
// If boolean is true, get full transaction objects
|
||||
if boolean {
|
||||
txQuery := `
|
||||
SELECT hash FROM transactions
|
||||
WHERE chain_id = $1 AND block_number = $2
|
||||
ORDER BY transaction_index
|
||||
`
|
||||
rows, err := s.db.Query(ctx, txQuery, s.chainID, blockNumber)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var txHash string
|
||||
if err := rows.Scan(&txHash); err == nil {
|
||||
transactions = append(transactions, txHash)
|
||||
}
|
||||
txObjects, err := s.loadEtherscanBlockTransactions(ctx, blockNumber)
|
||||
if err != nil {
|
||||
response = EtherscanResponse{
|
||||
Status: "0",
|
||||
Message: "Error",
|
||||
Result: nil,
|
||||
}
|
||||
break
|
||||
}
|
||||
transactions = txObjects
|
||||
} else {
|
||||
// Just get transaction hashes
|
||||
txQuery := `
|
||||
SELECT hash FROM transactions
|
||||
WHERE chain_id = $1 AND block_number = $2
|
||||
ORDER BY transaction_index
|
||||
`
|
||||
rows, err := s.db.Query(ctx, txQuery, s.chainID, blockNumber)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var txHash string
|
||||
if err := rows.Scan(&txHash); err == nil {
|
||||
transactions = append(transactions, txHash)
|
||||
}
|
||||
txHashes, err := s.loadEtherscanBlockTransactionHashes(ctx, blockNumber)
|
||||
if err != nil {
|
||||
response = EtherscanResponse{
|
||||
Status: "0",
|
||||
Message: "Error",
|
||||
Result: nil,
|
||||
}
|
||||
break
|
||||
}
|
||||
transactions = txHashes
|
||||
}
|
||||
|
||||
blockResult := map[string]interface{}{
|
||||
@@ -216,3 +207,92 @@ func (s *Server) handleEtherscanAPI(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (s *Server) loadEtherscanBlockTransactionHashes(ctx context.Context, blockNumber int64) ([]string, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT hash
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND block_number = $2
|
||||
ORDER BY transaction_index
|
||||
`, s.chainID, blockNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
hashes := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var txHash string
|
||||
if err := rows.Scan(&txHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hashes = append(hashes, txHash)
|
||||
}
|
||||
|
||||
return hashes, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) loadEtherscanBlockTransactions(ctx context.Context, blockNumber int64) ([]map[string]interface{}, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT hash, block_hash, transaction_index, from_address, to_address, value::text,
|
||||
COALESCE(gas_price, 0), gas_limit, nonce, COALESCE(input_data, '')
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND block_number = $2
|
||||
ORDER BY transaction_index
|
||||
`, s.chainID, blockNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
transactions := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var hash, blockHash, fromAddress, value, inputData string
|
||||
var toAddress sql.NullString
|
||||
var transactionIndex int
|
||||
var gasPrice, gasLimit, nonce int64
|
||||
if err := rows.Scan(&hash, &blockHash, &transactionIndex, &fromAddress, &toAddress, &value, &gasPrice, &gasLimit, &nonce, &inputData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx := map[string]interface{}{
|
||||
"hash": hash,
|
||||
"blockHash": blockHash,
|
||||
"blockNumber": fmt.Sprintf("0x%x", blockNumber),
|
||||
"transactionIndex": fmt.Sprintf("0x%x", transactionIndex),
|
||||
"from": fromAddress,
|
||||
"value": decimalStringToHex(value),
|
||||
"gasPrice": fmt.Sprintf("0x%x", gasPrice),
|
||||
"gas": fmt.Sprintf("0x%x", gasLimit),
|
||||
"nonce": fmt.Sprintf("0x%x", nonce),
|
||||
"input": normalizeHexInput(inputData),
|
||||
}
|
||||
if toAddress.Valid && toAddress.String != "" {
|
||||
tx["to"] = toAddress.String
|
||||
} else {
|
||||
tx["to"] = nil
|
||||
}
|
||||
|
||||
transactions = append(transactions, tx)
|
||||
}
|
||||
|
||||
return transactions, rows.Err()
|
||||
}
|
||||
|
||||
func decimalStringToHex(value string) string {
|
||||
parsed, ok := new(big.Int).SetString(value, 10)
|
||||
if !ok {
|
||||
return "0x0"
|
||||
}
|
||||
return "0x" + parsed.Text(16)
|
||||
}
|
||||
|
||||
func normalizeHexInput(input string) string {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
return "0x"
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "0x") {
|
||||
return trimmed
|
||||
}
|
||||
return "0x" + trimmed
|
||||
}
|
||||
|
||||
24
backend/api/rest/etherscan_internal_test.go
Normal file
24
backend/api/rest/etherscan_internal_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package rest
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecimalStringToHex(t *testing.T) {
|
||||
got := decimalStringToHex("1000000000000000000")
|
||||
if got != "0xde0b6b3a7640000" {
|
||||
t.Fatalf("decimalStringToHex() = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHexInput(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": "0x",
|
||||
"deadbeef": "0xdeadbeef",
|
||||
"0x1234": "0x1234",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := normalizeHexInput(input); got != want {
|
||||
t.Fatalf("normalizeHexInput(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,16 @@ func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Unwrap() http.ResponseWriter {
|
||||
return rw.ResponseWriter
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Flush() {
|
||||
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// loggingMiddleware logs requests with timing
|
||||
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
479
backend/api/rest/mission_control.go
Normal file
479
backend/api/rest/mission_control.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
hexAddrRe = regexp.MustCompile(`(?i)^0x[0-9a-f]{40}$`)
|
||||
hexTxRe = regexp.MustCompile(`(?i)^0x[0-9a-f]{64}$`)
|
||||
)
|
||||
|
||||
type liquidityCacheEntry struct {
|
||||
body []byte
|
||||
until time.Time
|
||||
ctype string
|
||||
}
|
||||
|
||||
var liquidityPoolsCache sync.Map // string -> liquidityCacheEntry
|
||||
|
||||
var missionControlMetrics struct {
|
||||
liquidityCacheHits uint64
|
||||
liquidityCacheMisses uint64
|
||||
liquidityUpstreamFailure uint64
|
||||
bridgeTraceRequests uint64
|
||||
bridgeTraceFailures uint64
|
||||
}
|
||||
|
||||
func tokenAggregationBase() string {
|
||||
for _, k := range []string{"TOKEN_AGGREGATION_BASE_URL", "TOKEN_AGGREGATION_URL"} {
|
||||
if u := strings.TrimSpace(os.Getenv(k)); u != "" {
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func blockscoutInternalBase() string {
|
||||
u := strings.TrimSpace(os.Getenv("BLOCKSCOUT_INTERNAL_URL"))
|
||||
if u == "" {
|
||||
u = "http://127.0.0.1:4000"
|
||||
}
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
|
||||
func missionControlChainID() string {
|
||||
if s := strings.TrimSpace(os.Getenv("CHAIN_ID")); s != "" {
|
||||
return s
|
||||
}
|
||||
return "138"
|
||||
}
|
||||
|
||||
func rpcURL() string {
|
||||
if s := strings.TrimSpace(os.Getenv("RPC_URL")); s != "" {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleMissionControlLiquidityTokenPath serves GET .../mission-control/liquidity/token/{addr}/pools (cached proxy to token-aggregation).
|
||||
func (s *Server) handleMissionControlLiquidityTokenPath(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/api/v1/mission-control/liquidity/token/")
|
||||
rest = strings.Trim(rest, "/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) < 2 || parts[1] != "pools" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "expected /liquidity/token/{address}/pools")
|
||||
return
|
||||
}
|
||||
addr := strings.TrimSpace(parts[0])
|
||||
if !hexAddrRe.MatchString(addr) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid token address")
|
||||
return
|
||||
}
|
||||
base := tokenAggregationBase()
|
||||
if base == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "TOKEN_AGGREGATION_BASE_URL not configured")
|
||||
return
|
||||
}
|
||||
chain := missionControlChainID()
|
||||
cacheKey := strings.ToLower(addr) + "|" + chain
|
||||
bypassCache := r.URL.Query().Get("refresh") == "1" ||
|
||||
r.URL.Query().Get("noCache") == "1" ||
|
||||
strings.Contains(strings.ToLower(r.Header.Get("Cache-Control")), "no-cache") ||
|
||||
strings.Contains(strings.ToLower(r.Header.Get("Cache-Control")), "no-store")
|
||||
if ent, ok := liquidityPoolsCache.Load(cacheKey); ok && !bypassCache {
|
||||
e := ent.(liquidityCacheEntry)
|
||||
if time.Now().Before(e.until) {
|
||||
atomic.AddUint64(&missionControlMetrics.liquidityCacheHits, 1)
|
||||
w.Header().Set("X-Mission-Control-Cache", "hit")
|
||||
if e.ctype != "" {
|
||||
w.Header().Set("Content-Type", e.ctype)
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(e.body)
|
||||
return
|
||||
}
|
||||
}
|
||||
atomic.AddUint64(&missionControlMetrics.liquidityCacheMisses, 1)
|
||||
if bypassCache {
|
||||
w.Header().Set("X-Mission-Control-Cache", "bypass")
|
||||
} else {
|
||||
w.Header().Set("X-Mission-Control-Cache", "miss")
|
||||
}
|
||||
|
||||
up, err := url.Parse(base + "/api/v1/tokens/" + url.PathEscape(addr) + "/pools")
|
||||
if err != nil {
|
||||
writeInternalError(w, "bad upstream URL")
|
||||
return
|
||||
}
|
||||
q := up.Query()
|
||||
q.Set("chainId", chain)
|
||||
up.RawQuery = q.Encode()
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, up.String(), nil)
|
||||
if err != nil {
|
||||
writeInternalError(w, err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
atomic.AddUint64(&missionControlMetrics.liquidityUpstreamFailure, 1)
|
||||
log.Printf("mission_control liquidity_proxy addr=%s chain=%s cache=miss upstream_error=%v", strings.ToLower(addr), chain, err)
|
||||
writeError(w, http.StatusBadGateway, "bad_gateway", err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
atomic.AddUint64(&missionControlMetrics.liquidityUpstreamFailure, 1)
|
||||
log.Printf("mission_control liquidity_proxy addr=%s chain=%s cache=miss read_error=%v", strings.ToLower(addr), chain, err)
|
||||
writeError(w, http.StatusBadGateway, "bad_gateway", "read upstream body failed")
|
||||
return
|
||||
}
|
||||
ctype := resp.Header.Get("Content-Type")
|
||||
if ctype == "" {
|
||||
ctype = "application/json"
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
liquidityPoolsCache.Store(cacheKey, liquidityCacheEntry{
|
||||
body: body,
|
||||
until: time.Now().Add(30 * time.Second),
|
||||
ctype: ctype,
|
||||
})
|
||||
cacheMode := "miss"
|
||||
if bypassCache {
|
||||
cacheMode = "bypass-refresh"
|
||||
}
|
||||
log.Printf("mission_control liquidity_proxy addr=%s chain=%s cache=%s stored_ttl_sec=30", strings.ToLower(addr), chain, cacheMode)
|
||||
} else {
|
||||
atomic.AddUint64(&missionControlMetrics.liquidityUpstreamFailure, 1)
|
||||
log.Printf("mission_control liquidity_proxy addr=%s chain=%s cache=miss upstream_status=%d", strings.ToLower(addr), chain, resp.StatusCode)
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
var (
|
||||
registryOnce sync.Once
|
||||
registryAddrToKey map[string]string
|
||||
registryLoadErr error
|
||||
)
|
||||
|
||||
func firstReadableFile(paths []string) ([]byte, string, error) {
|
||||
for _, p := range paths {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err == nil && len(b) > 0 {
|
||||
return b, p, nil
|
||||
}
|
||||
}
|
||||
return nil, "", fmt.Errorf("no readable file found")
|
||||
}
|
||||
|
||||
func loadAddressRegistry138() map[string]string {
|
||||
registryOnce.Do(func() {
|
||||
registryAddrToKey = make(map[string]string)
|
||||
var masterPaths []string
|
||||
if p := strings.TrimSpace(os.Getenv("SMART_CONTRACTS_MASTER_JSON")); p != "" {
|
||||
masterPaths = append(masterPaths, p)
|
||||
}
|
||||
masterPaths = append(masterPaths,
|
||||
"config/smart-contracts-master.json",
|
||||
"../config/smart-contracts-master.json",
|
||||
"../../config/smart-contracts-master.json",
|
||||
)
|
||||
raw, masterPath, _ := firstReadableFile(masterPaths)
|
||||
if len(raw) == 0 {
|
||||
registryLoadErr = fmt.Errorf("smart-contracts-master.json not found")
|
||||
return
|
||||
}
|
||||
var root map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &root); err != nil {
|
||||
registryLoadErr = err
|
||||
return
|
||||
}
|
||||
chains, _ := root["chains"].(map[string]interface{})
|
||||
c138, _ := chains["138"].(map[string]interface{})
|
||||
contracts, _ := c138["contracts"].(map[string]interface{})
|
||||
for k, v := range contracts {
|
||||
s, ok := v.(string)
|
||||
if !ok || !hexAddrRe.MatchString(s) {
|
||||
continue
|
||||
}
|
||||
registryAddrToKey[strings.ToLower(s)] = k
|
||||
}
|
||||
|
||||
var inventoryPaths []string
|
||||
if p := strings.TrimSpace(os.Getenv("EXPLORER_ADDRESS_INVENTORY_FILE")); p != "" {
|
||||
inventoryPaths = append(inventoryPaths, p)
|
||||
}
|
||||
if masterPath != "" {
|
||||
inventoryPaths = append(inventoryPaths, filepath.Join(filepath.Dir(masterPath), "address-inventory.json"))
|
||||
}
|
||||
inventoryPaths = append(inventoryPaths,
|
||||
"explorer-monorepo/config/address-inventory.json",
|
||||
"config/address-inventory.json",
|
||||
"../config/address-inventory.json",
|
||||
"../../config/address-inventory.json",
|
||||
)
|
||||
inventoryRaw, _, invErr := firstReadableFile(inventoryPaths)
|
||||
if invErr != nil || len(inventoryRaw) == 0 {
|
||||
return
|
||||
}
|
||||
var inventoryRoot struct {
|
||||
Inventory map[string]string `json:"inventory"`
|
||||
}
|
||||
if err := json.Unmarshal(inventoryRaw, &inventoryRoot); err != nil {
|
||||
return
|
||||
}
|
||||
for k, v := range inventoryRoot.Inventory {
|
||||
if !hexAddrRe.MatchString(v) {
|
||||
continue
|
||||
}
|
||||
addr := strings.ToLower(v)
|
||||
if _, exists := registryAddrToKey[addr]; exists {
|
||||
continue
|
||||
}
|
||||
registryAddrToKey[addr] = k
|
||||
}
|
||||
})
|
||||
return registryAddrToKey
|
||||
}
|
||||
|
||||
func jsonStringField(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractEthAddress(val interface{}) string {
|
||||
switch t := val.(type) {
|
||||
case string:
|
||||
if hexAddrRe.MatchString(strings.TrimSpace(t)) {
|
||||
return strings.ToLower(strings.TrimSpace(t))
|
||||
}
|
||||
case map[string]interface{}:
|
||||
if h := jsonStringField(t, "hash", "address"); h != "" && hexAddrRe.MatchString(h) {
|
||||
return strings.ToLower(h)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchBlockscoutTransaction(ctx context.Context, tx string) ([]byte, int, error) {
|
||||
fetchURL := blockscoutInternalBase() + "/api/v2/transactions/" + url.PathEscape(tx)
|
||||
timeouts := []time.Duration{15 * time.Second, 25 * time.Second}
|
||||
var lastBody []byte
|
||||
var lastStatus int
|
||||
var lastErr error
|
||||
|
||||
for idx, timeout := range timeouts {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
req, err := http.NewRequestWithContext(attemptCtx, http.MethodGet, fetchURL, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, 0, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
cancel()
|
||||
lastErr = err
|
||||
if idx == len(timeouts)-1 {
|
||||
return nil, 0, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
if readErr != nil {
|
||||
lastErr = readErr
|
||||
if idx == len(timeouts)-1 {
|
||||
return nil, 0, readErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
lastBody = body
|
||||
lastStatus = resp.StatusCode
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
if resp.StatusCode < 500 || idx == len(timeouts)-1 {
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
}
|
||||
|
||||
return lastBody, lastStatus, lastErr
|
||||
}
|
||||
|
||||
func fetchTransactionViaRPC(ctx context.Context, tx string) (string, string, error) {
|
||||
base := rpcURL()
|
||||
if base == "" {
|
||||
return "", "", fmt.Errorf("RPC_URL not configured")
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "eth_getTransactionByHash",
|
||||
"params": []interface{}{tx},
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("rpc HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rpcResp struct {
|
||||
Result map[string]interface{} `json:"result"`
|
||||
Error map[string]interface{} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &rpcResp); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if rpcResp.Error != nil {
|
||||
return "", "", fmt.Errorf("rpc error")
|
||||
}
|
||||
if rpcResp.Result == nil {
|
||||
return "", "", fmt.Errorf("transaction not found")
|
||||
}
|
||||
|
||||
fromAddr := extractEthAddress(jsonStringField(rpcResp.Result, "from"))
|
||||
toAddr := extractEthAddress(jsonStringField(rpcResp.Result, "to"))
|
||||
if fromAddr == "" && toAddr == "" {
|
||||
return "", "", fmt.Errorf("transaction missing from/to")
|
||||
}
|
||||
return fromAddr, toAddr, nil
|
||||
}
|
||||
|
||||
// HandleMissionControlBridgeTrace handles GET /api/v1/mission-control/bridge/trace?tx=0x...
|
||||
func (s *Server) HandleMissionControlBridgeTrace(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddUint64(&missionControlMetrics.bridgeTraceRequests, 1)
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
tx := strings.TrimSpace(r.URL.Query().Get("tx"))
|
||||
if tx == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing tx query parameter")
|
||||
return
|
||||
}
|
||||
if !hexTxRe.MatchString(tx) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid transaction hash")
|
||||
return
|
||||
}
|
||||
|
||||
reg := loadAddressRegistry138()
|
||||
publicBase := strings.TrimRight(strings.TrimSpace(os.Getenv("EXPLORER_PUBLIC_BASE")), "/")
|
||||
if publicBase == "" {
|
||||
publicBase = "https://explorer.d-bis.org"
|
||||
}
|
||||
|
||||
fromAddr := ""
|
||||
toAddr := ""
|
||||
fromLabel := ""
|
||||
toLabel := ""
|
||||
source := "blockscout"
|
||||
|
||||
body, statusCode, err := fetchBlockscoutTransaction(r.Context(), tx)
|
||||
if err == nil && statusCode == http.StatusOK {
|
||||
var txDoc map[string]interface{}
|
||||
if err := json.Unmarshal(body, &txDoc); err != nil {
|
||||
err = fmt.Errorf("invalid blockscout JSON")
|
||||
} else {
|
||||
fromAddr = extractEthAddress(txDoc["from"])
|
||||
toAddr = extractEthAddress(txDoc["to"])
|
||||
}
|
||||
}
|
||||
|
||||
if fromAddr == "" && toAddr == "" {
|
||||
rpcFrom, rpcTo, rpcErr := fetchTransactionViaRPC(r.Context(), tx)
|
||||
if rpcErr == nil {
|
||||
fromAddr = rpcFrom
|
||||
toAddr = rpcTo
|
||||
source = "rpc_fallback"
|
||||
} else {
|
||||
atomic.AddUint64(&missionControlMetrics.bridgeTraceFailures, 1)
|
||||
if err != nil {
|
||||
log.Printf("mission_control bridge_trace tx=%s fetch_error=%v rpc_fallback_error=%v", strings.ToLower(tx), err, rpcErr)
|
||||
writeError(w, http.StatusBadGateway, "bad_gateway", err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("mission_control bridge_trace tx=%s upstream_status=%d rpc_fallback_error=%v", strings.ToLower(tx), statusCode, rpcErr)
|
||||
writeError(w, http.StatusBadGateway, "blockscout_error",
|
||||
fmt.Sprintf("blockscout HTTP %d", statusCode))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if fromAddr != "" {
|
||||
fromLabel = reg[fromAddr]
|
||||
}
|
||||
if toAddr != "" {
|
||||
toLabel = reg[toAddr]
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"tx_hash": strings.ToLower(tx),
|
||||
"from": fromAddr,
|
||||
"from_registry": fromLabel,
|
||||
"to": toAddr,
|
||||
"to_registry": toLabel,
|
||||
"blockscout_url": publicBase + "/tx/" + strings.ToLower(tx),
|
||||
"source": source,
|
||||
}
|
||||
if registryLoadErr != nil && len(reg) == 0 {
|
||||
out["registry_warning"] = registryLoadErr.Error()
|
||||
}
|
||||
log.Printf("mission_control bridge_trace tx=%s from=%s to=%s from_label=%s to_label=%s", strings.ToLower(tx), fromAddr, toAddr, fromLabel, toLabel)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"data": out})
|
||||
}
|
||||
218
backend/api/rest/mission_control_test.go
Normal file
218
backend/api/rest/mission_control_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func resetMissionControlTestGlobals() {
|
||||
liquidityPoolsCache = sync.Map{}
|
||||
registryOnce = sync.Once{}
|
||||
registryAddrToKey = nil
|
||||
registryLoadErr = nil
|
||||
}
|
||||
|
||||
func TestHandleMissionControlLiquidityTokenPathRequiresEnv(t *testing.T) {
|
||||
resetMissionControlTestGlobals()
|
||||
t.Setenv("TOKEN_AGGREGATION_BASE_URL", "")
|
||||
t.Setenv("TOKEN_AGGREGATION_URL", "")
|
||||
|
||||
s := NewServer(nil, 138)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.handleMissionControlLiquidityTokenPath(w, req)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
require.Contains(t, w.Body.String(), "TOKEN_AGGREGATION_BASE_URL not configured")
|
||||
}
|
||||
|
||||
func TestHandleMissionControlLiquidityTokenPathCachesSuccess(t *testing.T) {
|
||||
resetMissionControlTestGlobals()
|
||||
|
||||
var hitCount int
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hitCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"count":1,"pools":[]}}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
t.Setenv("TOKEN_AGGREGATION_BASE_URL", upstream.URL)
|
||||
t.Setenv("CHAIN_ID", "138")
|
||||
|
||||
s := NewServer(nil, 138)
|
||||
path := "/api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools"
|
||||
|
||||
w1 := httptest.NewRecorder()
|
||||
s.handleMissionControlLiquidityTokenPath(w1, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
require.Equal(t, http.StatusOK, w1.Code)
|
||||
require.Equal(t, "miss", w1.Header().Get("X-Mission-Control-Cache"))
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
s.handleMissionControlLiquidityTokenPath(w2, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
require.Equal(t, http.StatusOK, w2.Code)
|
||||
require.Equal(t, "hit", w2.Header().Get("X-Mission-Control-Cache"))
|
||||
|
||||
require.Equal(t, 1, hitCount, "second request should be served from the in-memory cache")
|
||||
require.JSONEq(t, w1.Body.String(), w2.Body.String())
|
||||
}
|
||||
|
||||
func TestHandleMissionControlLiquidityTokenPathBypassesCacheWhenRequested(t *testing.T) {
|
||||
resetMissionControlTestGlobals()
|
||||
|
||||
var hitCount int
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hitCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"count":1,"pools":[]}}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
t.Setenv("TOKEN_AGGREGATION_BASE_URL", upstream.URL)
|
||||
t.Setenv("CHAIN_ID", "138")
|
||||
|
||||
s := NewServer(nil, 138)
|
||||
path := "/api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools"
|
||||
|
||||
w1 := httptest.NewRecorder()
|
||||
s.handleMissionControlLiquidityTokenPath(w1, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
require.Equal(t, http.StatusOK, w1.Code)
|
||||
require.Equal(t, "miss", w1.Header().Get("X-Mission-Control-Cache"))
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodGet, path+"?refresh=1", nil)
|
||||
req2.Header.Set("Cache-Control", "no-cache")
|
||||
w2 := httptest.NewRecorder()
|
||||
s.handleMissionControlLiquidityTokenPath(w2, req2)
|
||||
require.Equal(t, http.StatusOK, w2.Code)
|
||||
require.Equal(t, "bypass", w2.Header().Get("X-Mission-Control-Cache"))
|
||||
|
||||
require.Equal(t, 2, hitCount, "refresh=1 should force a fresh upstream read")
|
||||
}
|
||||
|
||||
func TestHandleMissionControlBridgeTraceLabelsFromRegistry(t *testing.T) {
|
||||
resetMissionControlTestGlobals()
|
||||
|
||||
fromAddr := "0x1111111111111111111111111111111111111111"
|
||||
toAddr := "0x2222222222222222222222222222222222222222"
|
||||
|
||||
registryJSON := `{
|
||||
"chains": {
|
||||
"138": {
|
||||
"contracts": {
|
||||
"CHAIN138_SOURCE_BRIDGE": "` + fromAddr + `",
|
||||
"CHAIN138_DEST_BRIDGE": "` + toAddr + `"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
registryPath := filepath.Join(t.TempDir(), "smart-contracts-master.json")
|
||||
require.NoError(t, os.WriteFile(registryPath, []byte(registryJSON), 0o644))
|
||||
|
||||
blockscout := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/api/v2/transactions/0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"from": {"hash":"` + fromAddr + `"},
|
||||
"to": {"hash":"` + toAddr + `"}
|
||||
}`))
|
||||
}))
|
||||
defer blockscout.Close()
|
||||
|
||||
t.Setenv("SMART_CONTRACTS_MASTER_JSON", registryPath)
|
||||
t.Setenv("BLOCKSCOUT_INTERNAL_URL", blockscout.URL)
|
||||
t.Setenv("EXPLORER_PUBLIC_BASE", "https://explorer.example.org")
|
||||
|
||||
s := NewServer(nil, 138)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/mission-control/bridge/trace?tx=0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.HandleMissionControlBridgeTrace(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var out struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
|
||||
require.Equal(t, strings.ToLower(fromAddr), out.Data["from"])
|
||||
require.Equal(t, strings.ToLower(toAddr), out.Data["to"])
|
||||
require.Equal(t, "CHAIN138_SOURCE_BRIDGE", out.Data["from_registry"])
|
||||
require.Equal(t, "CHAIN138_DEST_BRIDGE", out.Data["to_registry"])
|
||||
require.Equal(t, "https://explorer.example.org/tx/0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", out.Data["blockscout_url"])
|
||||
}
|
||||
|
||||
func TestHandleMissionControlBridgeTraceFallsBackToAddressInventoryLabels(t *testing.T) {
|
||||
resetMissionControlTestGlobals()
|
||||
|
||||
fromAddr := "0x4A666F96fC8764181194447A7dFdb7d471b301C8"
|
||||
toAddr := "0x152ed3e9912161b76bdfd368d0c84b7c31c10de7"
|
||||
|
||||
tempDir := t.TempDir()
|
||||
registryPath := filepath.Join(tempDir, "smart-contracts-master.json")
|
||||
inventoryPath := filepath.Join(tempDir, "address-inventory.json")
|
||||
|
||||
require.NoError(t, os.WriteFile(registryPath, []byte(`{
|
||||
"chains": {
|
||||
"138": {
|
||||
"contracts": {
|
||||
"CCIP_Router": "0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`), 0o644))
|
||||
require.NoError(t, os.WriteFile(inventoryPath, []byte(`{
|
||||
"inventory": {
|
||||
"DEPLOYER_ADMIN_138": "`+fromAddr+`",
|
||||
"CW_L1_BRIDGE_CHAIN138": "`+toAddr+`"
|
||||
}
|
||||
}`), 0o644))
|
||||
|
||||
blockscout := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"from": {"hash":"` + fromAddr + `"},
|
||||
"to": {"hash":"` + toAddr + `"}
|
||||
}`))
|
||||
}))
|
||||
defer blockscout.Close()
|
||||
|
||||
t.Setenv("SMART_CONTRACTS_MASTER_JSON", registryPath)
|
||||
t.Setenv("EXPLORER_ADDRESS_INVENTORY_FILE", inventoryPath)
|
||||
t.Setenv("BLOCKSCOUT_INTERNAL_URL", blockscout.URL)
|
||||
|
||||
s := NewServer(nil, 138)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/mission-control/bridge/trace?tx=0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.HandleMissionControlBridgeTrace(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var out struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
|
||||
require.Equal(t, strings.ToLower(fromAddr), out.Data["from"])
|
||||
require.Equal(t, strings.ToLower(toAddr), out.Data["to"])
|
||||
require.Equal(t, "DEPLOYER_ADMIN_138", out.Data["from_registry"])
|
||||
require.Equal(t, "CW_L1_BRIDGE_CHAIN138", out.Data["to_registry"])
|
||||
}
|
||||
|
||||
func TestHandleMissionControlBridgeTraceRejectsBadHash(t *testing.T) {
|
||||
resetMissionControlTestGlobals()
|
||||
s := NewServer(nil, 138)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/mission-control/bridge/trace?tx=not-a-tx", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.HandleMissionControlBridgeTrace(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
require.Contains(t, w.Body.String(), "invalid transaction hash")
|
||||
}
|
||||
@@ -104,12 +104,13 @@ func (s *Server) handleBlockDetail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if parts[1] == "hash" && len(parts) == 3 {
|
||||
// Validate hash format
|
||||
if !isValidHash(parts[2]) {
|
||||
hash := normalizeHash(parts[2])
|
||||
if !isValidHash(hash) {
|
||||
writeValidationError(w, ErrInvalidHash)
|
||||
return
|
||||
}
|
||||
// Get by hash
|
||||
s.handleGetBlockByHash(w, r, parts[2])
|
||||
s.handleGetBlockByHash(w, r, hash)
|
||||
} else {
|
||||
// Validate and parse block number
|
||||
blockNumber, err := validateBlockNumber(parts[1])
|
||||
@@ -143,7 +144,7 @@ func (s *Server) handleTransactionDetail(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Validate hash format
|
||||
hash := parts[1]
|
||||
hash := normalizeHash(parts[1])
|
||||
if !isValidHash(hash) {
|
||||
writeValidationError(w, ErrInvalidHash)
|
||||
return
|
||||
@@ -174,13 +175,15 @@ func (s *Server) handleAddressDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Validate address format
|
||||
address := parts[1]
|
||||
address := normalizeAddress(parts[1])
|
||||
if !isValidAddress(address) {
|
||||
writeValidationError(w, ErrInvalidAddress)
|
||||
return
|
||||
}
|
||||
|
||||
// Set address in query and call handler
|
||||
r.URL.RawQuery = "address=" + address
|
||||
query := r.URL.Query()
|
||||
query.Set("address", address)
|
||||
r.URL.RawQuery = query.Encode()
|
||||
s.handleGetAddress(w, r)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ func (s *Server) proxyRouteTreeEndpoint(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(req *http.Request) {
|
||||
originalDirector(req)
|
||||
req.URL.Path = joinProxyPath(target.Path, path)
|
||||
req.URL.RawPath = req.URL.Path
|
||||
}
|
||||
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, proxyErr error) {
|
||||
writeError(rw, http.StatusBadGateway, "bad_gateway", fmt.Sprintf("route tree proxy failed for %s: %v", path, proxyErr))
|
||||
}
|
||||
@@ -47,6 +53,17 @@ func (s *Server) proxyRouteTreeEndpoint(w http.ResponseWriter, r *http.Request,
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func joinProxyPath(basePath, path string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(basePath, "/") && strings.HasPrefix(path, "/"):
|
||||
return basePath + path[1:]
|
||||
case !strings.HasSuffix(basePath, "/") && !strings.HasPrefix(path, "/"):
|
||||
return basePath + "/" + path
|
||||
default:
|
||||
return basePath + path
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyEnv(keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
|
||||
54
backend/api/rest/routes_proxy_test.go
Normal file
54
backend/api/rest/routes_proxy_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRouteProxyPreservesTargetBasePath(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotQuery string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotQuery = r.URL.RawQuery
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
t.Setenv("TOKEN_AGGREGATION_API_BASE", upstream.URL+"/token-aggregation")
|
||||
|
||||
server := NewServer(nil, 138)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/routes/tree?chainId=138&amountIn=1000000", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleRouteDecisionTree(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, "/token-aggregation/api/v1/routes/tree", gotPath)
|
||||
require.Equal(t, "chainId=138&amountIn=1000000", gotQuery)
|
||||
}
|
||||
|
||||
func TestRouteProxyHandlesBaseURLWithoutPath(t *testing.T) {
|
||||
var gotPath string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
t.Setenv("TOKEN_AGGREGATION_API_BASE", upstream.URL)
|
||||
|
||||
server := NewServer(nil, 138)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/routes/depth?chainId=138", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleRouteDepth(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, "/api/v1/routes/depth", gotPath)
|
||||
}
|
||||
@@ -38,17 +38,21 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.handleGetBlockByNumber(w, r, blockNumber)
|
||||
case "transaction":
|
||||
value = normalizeHash(value)
|
||||
if !isValidHash(value) {
|
||||
writeValidationError(w, ErrInvalidHash)
|
||||
return
|
||||
}
|
||||
s.handleGetTransactionByHash(w, r, value)
|
||||
case "address":
|
||||
value = normalizeAddress(value)
|
||||
if !isValidAddress(value) {
|
||||
writeValidationError(w, ErrInvalidAddress)
|
||||
return
|
||||
}
|
||||
r.URL.RawQuery = "address=" + value
|
||||
query := r.URL.Query()
|
||||
query.Set("address", value)
|
||||
r.URL.RawQuery = query.Encode()
|
||||
s.handleGetAddress(w, r)
|
||||
default:
|
||||
writeValidationError(w, fmt.Errorf("unsupported search type"))
|
||||
|
||||
@@ -2,6 +2,7 @@ package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -29,11 +30,11 @@ type Server struct {
|
||||
|
||||
// NewServer creates a new REST API server
|
||||
func NewServer(db *pgxpool.Pool, chainID int) *Server {
|
||||
// Get JWT secret from environment or use default
|
||||
// Get JWT secret from environment or generate an ephemeral secret.
|
||||
jwtSecret := []byte(os.Getenv("JWT_SECRET"))
|
||||
if len(jwtSecret) == 0 {
|
||||
jwtSecret = []byte("change-me-in-production-use-strong-random-secret")
|
||||
log.Println("WARNING: Using default JWT secret. Set JWT_SECRET environment variable in production!")
|
||||
jwtSecret = generateEphemeralJWTSecret()
|
||||
log.Println("WARNING: JWT_SECRET is unset. Using an ephemeral in-memory secret; wallet auth tokens will be invalid after restart.")
|
||||
}
|
||||
|
||||
walletAuth := auth.NewWalletAuth(db, jwtSecret)
|
||||
@@ -48,6 +49,17 @@ func NewServer(db *pgxpool.Pool, chainID int) *Server {
|
||||
}
|
||||
}
|
||||
|
||||
func generateEphemeralJWTSecret() []byte {
|
||||
secret := make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err == nil {
|
||||
return secret
|
||||
}
|
||||
|
||||
fallback := []byte(fmt.Sprintf("ephemeral-jwt-secret-%d", time.Now().UnixNano()))
|
||||
log.Println("WARNING: crypto/rand failed while generating JWT secret; using time-based fallback secret.")
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
func (s *Server) Start(port int) error {
|
||||
mux := http.NewServeMux()
|
||||
@@ -99,7 +111,7 @@ func (s *Server) addMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key")
|
||||
|
||||
// Handle preflight
|
||||
if r.Method == "OPTIONS" {
|
||||
|
||||
19
backend/api/rest/server_internal_test.go
Normal file
19
backend/api/rest/server_internal_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewServerUsesEphemeralJWTSecretWhenUnset(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "")
|
||||
|
||||
first := NewServer(nil, 138)
|
||||
second := NewServer(nil, 138)
|
||||
|
||||
require.NotEmpty(t, first.jwtSecret)
|
||||
require.NotEmpty(t, second.jwtSecret)
|
||||
require.NotEqual(t, []byte("change-me-in-production-use-strong-random-secret"), first.jwtSecret)
|
||||
require.NotEqual(t, string(first.jwtSecret), string(second.jwtSecret))
|
||||
}
|
||||
@@ -3,10 +3,64 @@ package rest
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type explorerStats struct {
|
||||
TotalBlocks int64 `json:"total_blocks"`
|
||||
TotalTransactions int64 `json:"total_transactions"`
|
||||
TotalAddresses int64 `json:"total_addresses"`
|
||||
LatestBlock int64 `json:"latest_block"`
|
||||
}
|
||||
|
||||
type statsQueryFunc func(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
|
||||
func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc) (explorerStats, error) {
|
||||
var stats explorerStats
|
||||
|
||||
if err := queryRow(ctx,
|
||||
`SELECT COUNT(*) FROM blocks WHERE chain_id = $1`,
|
||||
chainID,
|
||||
).Scan(&stats.TotalBlocks); err != nil {
|
||||
return explorerStats{}, fmt.Errorf("query total blocks: %w", err)
|
||||
}
|
||||
|
||||
if err := queryRow(ctx,
|
||||
`SELECT COUNT(*) FROM transactions WHERE chain_id = $1`,
|
||||
chainID,
|
||||
).Scan(&stats.TotalTransactions); err != nil {
|
||||
return explorerStats{}, fmt.Errorf("query total transactions: %w", err)
|
||||
}
|
||||
|
||||
if err := queryRow(ctx,
|
||||
`SELECT COUNT(*) FROM (
|
||||
SELECT from_address AS address
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND from_address IS NOT NULL AND from_address <> ''
|
||||
UNION
|
||||
SELECT to_address AS address
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND to_address IS NOT NULL AND to_address <> ''
|
||||
) unique_addresses`,
|
||||
chainID,
|
||||
).Scan(&stats.TotalAddresses); err != nil {
|
||||
return explorerStats{}, fmt.Errorf("query total addresses: %w", err)
|
||||
}
|
||||
|
||||
if err := queryRow(ctx,
|
||||
`SELECT COALESCE(MAX(number), 0) FROM blocks WHERE chain_id = $1`,
|
||||
chainID,
|
||||
).Scan(&stats.LatestBlock); err != nil {
|
||||
return explorerStats{}, fmt.Errorf("query latest block: %w", err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// handleStats handles GET /api/v2/stats
|
||||
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -20,43 +74,12 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get total blocks
|
||||
var totalBlocks int64
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM blocks WHERE chain_id = $1`,
|
||||
s.chainID,
|
||||
).Scan(&totalBlocks)
|
||||
stats, err := loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
|
||||
if err != nil {
|
||||
totalBlocks = 0
|
||||
}
|
||||
|
||||
// Get total transactions
|
||||
var totalTransactions int64
|
||||
err = s.db.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM transactions WHERE chain_id = $1`,
|
||||
s.chainID,
|
||||
).Scan(&totalTransactions)
|
||||
if err != nil {
|
||||
totalTransactions = 0
|
||||
}
|
||||
|
||||
// Get total addresses
|
||||
var totalAddresses int64
|
||||
err = s.db.QueryRow(ctx,
|
||||
`SELECT COUNT(DISTINCT from_address) + COUNT(DISTINCT to_address) FROM transactions WHERE chain_id = $1`,
|
||||
s.chainID,
|
||||
).Scan(&totalAddresses)
|
||||
if err != nil {
|
||||
totalAddresses = 0
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"total_blocks": totalBlocks,
|
||||
"total_transactions": totalTransactions,
|
||||
"total_addresses": totalAddresses,
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer stats are temporarily unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
}
|
||||
|
||||
|
||||
73
backend/api/rest/stats_internal_test.go
Normal file
73
backend/api/rest/stats_internal_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeStatsRow struct {
|
||||
scan func(dest ...any) error
|
||||
}
|
||||
|
||||
func (r fakeStatsRow) Scan(dest ...any) error {
|
||||
return r.scan(dest...)
|
||||
}
|
||||
|
||||
func TestLoadExplorerStatsReturnsValues(t *testing.T) {
|
||||
var call int
|
||||
queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
|
||||
call++
|
||||
return fakeStatsRow{
|
||||
scan: func(dest ...any) error {
|
||||
target, ok := dest[0].(*int64)
|
||||
require.True(t, ok)
|
||||
|
||||
switch call {
|
||||
case 1:
|
||||
*target = 11
|
||||
case 2:
|
||||
*target = 22
|
||||
case 3:
|
||||
*target = 33
|
||||
case 4:
|
||||
*target = 44
|
||||
default:
|
||||
t.Fatalf("unexpected query call %d", call)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := loadExplorerStats(context.Background(), 138, queryRow)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(11), stats.TotalBlocks)
|
||||
require.Equal(t, int64(22), stats.TotalTransactions)
|
||||
require.Equal(t, int64(33), stats.TotalAddresses)
|
||||
require.Equal(t, int64(44), stats.LatestBlock)
|
||||
}
|
||||
|
||||
func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) {
|
||||
queryRow := func(_ context.Context, query string, _ ...any) pgx.Row {
|
||||
return fakeStatsRow{
|
||||
scan: func(dest ...any) error {
|
||||
if strings.Contains(query, "COUNT(*) FROM transactions") {
|
||||
return errors.New("boom")
|
||||
}
|
||||
target, ok := dest[0].(*int64)
|
||||
require.True(t, ok)
|
||||
*target = 1
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
_, err := loadExplorerStats(context.Background(), 138, queryRow)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "query total transactions")
|
||||
}
|
||||
@@ -41,6 +41,8 @@ tags:
|
||||
description: Unified search endpoints
|
||||
- name: Track1
|
||||
description: Public RPC gateway endpoints (no auth required)
|
||||
- name: MissionControl
|
||||
description: Public mission-control health, bridge trace, and cached liquidity helpers
|
||||
- name: Track2
|
||||
description: Indexed explorer endpoints (auth required)
|
||||
- name: Track3
|
||||
@@ -232,6 +234,105 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BlockListResponse'
|
||||
|
||||
/api/v1/mission-control/stream:
|
||||
get:
|
||||
tags:
|
||||
- MissionControl
|
||||
summary: Mission-control SSE stream
|
||||
description: |
|
||||
Server-Sent Events stream with the same inner `data` payload as `GET /api/v1/track1/bridge/status`.
|
||||
Emits one event immediately, then refreshes every 20 seconds. Configure nginx with `proxy_buffering off`.
|
||||
operationId: getMissionControlStream
|
||||
responses:
|
||||
'200':
|
||||
description: SSE stream
|
||||
content:
|
||||
text/event-stream:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/api/v1/mission-control/liquidity/token/{address}/pools:
|
||||
get:
|
||||
tags:
|
||||
- MissionControl
|
||||
summary: Cached liquidity proxy
|
||||
description: |
|
||||
30-second in-memory cached proxy to the token-aggregation pools endpoint for the configured `CHAIN_ID`.
|
||||
operationId: getMissionControlLiquidityPools
|
||||
parameters:
|
||||
- name: address
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^0x[a-fA-F0-9]{40}$'
|
||||
responses:
|
||||
'200':
|
||||
description: Upstream JSON response
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'503':
|
||||
description: `TOKEN_AGGREGATION_BASE_URL` not configured
|
||||
|
||||
/api/v1/mission-control/bridge/trace:
|
||||
get:
|
||||
tags:
|
||||
- MissionControl
|
||||
summary: Resolve a transaction through Blockscout and label 138-side contracts
|
||||
description: |
|
||||
Queries Blockscout using `BLOCKSCOUT_INTERNAL_URL` and labels the `from` and `to` addresses using Chain 138 entries from `SMART_CONTRACTS_MASTER_JSON`.
|
||||
operationId: getMissionControlBridgeTrace
|
||||
parameters:
|
||||
- name: tx
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^0x[a-fA-F0-9]{64}$'
|
||||
responses:
|
||||
'200':
|
||||
description: Labeled bridge trace
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'502':
|
||||
description: Blockscout lookup failed
|
||||
|
||||
/api/v1/track4/operator/run-script:
|
||||
post:
|
||||
tags:
|
||||
- Track4
|
||||
summary: Run an allowlisted operator script
|
||||
description: |
|
||||
Track 4 endpoint. Requires authenticated wallet, IP allowlisting, `OPERATOR_SCRIPTS_ROOT`, and `OPERATOR_SCRIPT_ALLOWLIST`.
|
||||
operationId: runOperatorScript
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [script]
|
||||
properties:
|
||||
script:
|
||||
type: string
|
||||
description: Path relative to `OPERATOR_SCRIPTS_ROOT`
|
||||
args:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
maxItems: 24
|
||||
responses:
|
||||
'200':
|
||||
description: Script execution result
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'503':
|
||||
description: Script root or allowlist not configured
|
||||
|
||||
/api/v1/track2/search:
|
||||
get:
|
||||
tags:
|
||||
@@ -427,4 +528,3 @@ components:
|
||||
error:
|
||||
code: "internal_error"
|
||||
message: "An internal error occurred"
|
||||
|
||||
|
||||
@@ -56,20 +56,23 @@ func (s *Server) SetupTrackRoutes(mux *http.ServeMux, authMiddleware *middleware
|
||||
mux.HandleFunc("/api/v1/track1/tx/", track1Server.HandleTransactionDetail)
|
||||
mux.HandleFunc("/api/v1/track1/address/", track1Server.HandleAddressBalance)
|
||||
mux.HandleFunc("/api/v1/track1/bridge/status", track1Server.HandleBridgeStatus)
|
||||
mux.HandleFunc("/api/v1/mission-control/stream", track1Server.HandleMissionControlStream)
|
||||
mux.HandleFunc("/api/v1/mission-control/liquidity/token/", s.handleMissionControlLiquidityTokenPath)
|
||||
mux.HandleFunc("/api/v1/mission-control/bridge/trace", s.HandleMissionControlBridgeTrace)
|
||||
|
||||
// Initialize Track 2 server
|
||||
track2Server := track2.NewServer(s.db, s.chainID)
|
||||
|
||||
// Track 2 routes (require Track 2+)
|
||||
track2Middleware := authMiddleware.RequireTrack(2)
|
||||
|
||||
|
||||
// Track 2 route handlers with auth
|
||||
track2AuthHandler := func(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return authMiddleware.RequireAuth(track2Middleware(http.HandlerFunc(handler))).ServeHTTP
|
||||
}
|
||||
|
||||
|
||||
mux.HandleFunc("/api/v1/track2/search", track2AuthHandler(track2Server.HandleSearch))
|
||||
|
||||
|
||||
// Address routes - need to parse path
|
||||
mux.HandleFunc("/api/v1/track2/address/", track2AuthHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
@@ -77,14 +80,19 @@ func (s *Server) SetupTrackRoutes(mux *http.ServeMux, authMiddleware *middleware
|
||||
if len(parts) >= 2 {
|
||||
if parts[1] == "txs" {
|
||||
track2Server.HandleAddressTransactions(w, r)
|
||||
return
|
||||
} else if parts[1] == "tokens" {
|
||||
track2Server.HandleAddressTokens(w, r)
|
||||
return
|
||||
} else if parts[1] == "internal-txs" {
|
||||
track2Server.HandleInternalTransactions(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid Track 2 address path")
|
||||
}))
|
||||
|
||||
|
||||
mux.HandleFunc("/api/v1/track2/token/", track2AuthHandler(track2Server.HandleTokenInfo))
|
||||
|
||||
// Initialize Track 3 server
|
||||
@@ -95,7 +103,7 @@ func (s *Server) SetupTrackRoutes(mux *http.ServeMux, authMiddleware *middleware
|
||||
track3AuthHandler := func(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return authMiddleware.RequireAuth(track3Middleware(http.HandlerFunc(handler))).ServeHTTP
|
||||
}
|
||||
|
||||
|
||||
mux.HandleFunc("/api/v1/track3/analytics/flows", track3AuthHandler(track3Server.HandleFlows))
|
||||
mux.HandleFunc("/api/v1/track3/analytics/bridge", track3AuthHandler(track3Server.HandleBridge))
|
||||
mux.HandleFunc("/api/v1/track3/analytics/token-distribution/", track3AuthHandler(track3Server.HandleTokenDistribution))
|
||||
@@ -109,10 +117,10 @@ func (s *Server) SetupTrackRoutes(mux *http.ServeMux, authMiddleware *middleware
|
||||
track4AuthHandler := func(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return authMiddleware.RequireAuth(track4Middleware(http.HandlerFunc(handler))).ServeHTTP
|
||||
}
|
||||
|
||||
|
||||
mux.HandleFunc("/api/v1/track4/operator/bridge/events", track4AuthHandler(track4Server.HandleBridgeEvents))
|
||||
mux.HandleFunc("/api/v1/track4/operator/validators", track4AuthHandler(track4Server.HandleValidators))
|
||||
mux.HandleFunc("/api/v1/track4/operator/contracts", track4AuthHandler(track4Server.HandleContracts))
|
||||
mux.HandleFunc("/api/v1/track4/operator/protocol-state", track4AuthHandler(track4Server.HandleProtocolState))
|
||||
mux.HandleFunc("/api/v1/track4/operator/run-script", track4AuthHandler(track4Server.HandleRunScript))
|
||||
}
|
||||
|
||||
|
||||
@@ -52,14 +52,22 @@ func (s *Server) handleListTransactions(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
if fromAddress := r.URL.Query().Get("from_address"); fromAddress != "" {
|
||||
query += fmt.Sprintf(" AND from_address = $%d", argIndex)
|
||||
args = append(args, fromAddress)
|
||||
if !isValidAddress(fromAddress) {
|
||||
writeValidationError(w, ErrInvalidAddress)
|
||||
return
|
||||
}
|
||||
query += fmt.Sprintf(" AND LOWER(from_address) = $%d", argIndex)
|
||||
args = append(args, normalizeAddress(fromAddress))
|
||||
argIndex++
|
||||
}
|
||||
|
||||
if toAddress := r.URL.Query().Get("to_address"); toAddress != "" {
|
||||
query += fmt.Sprintf(" AND to_address = $%d", argIndex)
|
||||
args = append(args, toAddress)
|
||||
if !isValidAddress(toAddress) {
|
||||
writeValidationError(w, ErrInvalidAddress)
|
||||
return
|
||||
}
|
||||
query += fmt.Sprintf(" AND LOWER(to_address) = $%d", argIndex)
|
||||
args = append(args, normalizeAddress(toAddress))
|
||||
argIndex++
|
||||
}
|
||||
|
||||
@@ -139,6 +147,12 @@ func (s *Server) handleListTransactions(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// handleGetTransactionByHash handles GET /api/v1/transactions/{chain_id}/{hash}
|
||||
func (s *Server) handleGetTransactionByHash(w http.ResponseWriter, r *http.Request, hash string) {
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
hash = normalizeHash(hash)
|
||||
|
||||
// Validate hash format (already validated in routes.go, but double-check)
|
||||
if !isValidHash(hash) {
|
||||
writeValidationError(w, ErrInvalidHash)
|
||||
|
||||
@@ -41,6 +41,14 @@ func isValidAddress(address string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func normalizeHash(hash string) string {
|
||||
return strings.ToLower(strings.TrimSpace(hash))
|
||||
}
|
||||
|
||||
func normalizeAddress(address string) string {
|
||||
return strings.ToLower(strings.TrimSpace(address))
|
||||
}
|
||||
|
||||
// validateBlockNumber validates and parses block number
|
||||
func validateBlockNumber(blockStr string) (int64, error) {
|
||||
blockNumber, err := strconv.ParseInt(blockStr, 10, 64)
|
||||
|
||||
23
backend/api/rest/validation_test.go
Normal file
23
backend/api/rest/validation_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package rest
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeAddress(t *testing.T) {
|
||||
input := " 0xAbCdEf1234567890ABCdef1234567890abCDef12 "
|
||||
got := normalizeAddress(input)
|
||||
want := "0xabcdef1234567890abcdef1234567890abcdef12"
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("normalizeAddress() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHash(t *testing.T) {
|
||||
input := " 0xABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890 "
|
||||
got := normalizeHash(input)
|
||||
want := "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("normalizeHash() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
146
backend/api/track1/bridge_status_data.go
Normal file
146
backend/api/track1/bridge_status_data.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package track1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func relaySnapshotStatus(relay map[string]interface{}) string {
|
||||
if relay == nil {
|
||||
return ""
|
||||
}
|
||||
if probe, ok := relay["url_probe"].(map[string]interface{}); ok {
|
||||
if okValue, exists := probe["ok"].(bool); exists && !okValue {
|
||||
return "down"
|
||||
}
|
||||
if body, ok := probe["body"].(map[string]interface{}); ok {
|
||||
if status, ok := body["status"].(string); ok {
|
||||
return strings.ToLower(strings.TrimSpace(status))
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ok := relay["file_snapshot_error"].(string); ok {
|
||||
return "down"
|
||||
}
|
||||
if snapshot, ok := relay["file_snapshot"].(map[string]interface{}); ok {
|
||||
if status, ok := snapshot["status"].(string); ok {
|
||||
return strings.ToLower(strings.TrimSpace(status))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func relayNeedsAttention(relay map[string]interface{}) bool {
|
||||
status := relaySnapshotStatus(relay)
|
||||
switch status {
|
||||
case "degraded", "stale", "stopped", "down":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// BuildBridgeStatusData builds the inner `data` object for bridge/status and SSE payloads.
|
||||
func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface{} {
|
||||
rpc138 := strings.TrimSpace(os.Getenv("RPC_URL"))
|
||||
if rpc138 == "" {
|
||||
rpc138 = "http://localhost:8545"
|
||||
}
|
||||
|
||||
var probes []RPCProbeResult
|
||||
p138 := ProbeEVMJSONRPC(ctx, "chain-138", "138", rpc138)
|
||||
probes = append(probes, p138)
|
||||
|
||||
if eth := strings.TrimSpace(os.Getenv("ETH_MAINNET_RPC_URL")); eth != "" {
|
||||
probes = append(probes, ProbeEVMJSONRPC(ctx, "ethereum-mainnet", "1", eth))
|
||||
}
|
||||
|
||||
for _, row := range ParseExtraRPCProbes() {
|
||||
name, u, ck := row[0], row[1], row[2]
|
||||
probes = append(probes, ProbeEVMJSONRPC(ctx, name, ck, u))
|
||||
}
|
||||
|
||||
overall := "operational"
|
||||
if !p138.OK {
|
||||
overall = "degraded"
|
||||
} else {
|
||||
for _, p := range probes {
|
||||
if !p.OK {
|
||||
overall = "degraded"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
chains := map[string]interface{}{
|
||||
"138": map[string]interface{}{
|
||||
"name": "Defi Oracle Meta Mainnet",
|
||||
"status": chainStatusFromProbe(p138),
|
||||
"last_sync": now,
|
||||
"latency_ms": p138.LatencyMs,
|
||||
"head_age_sec": p138.HeadAgeSeconds,
|
||||
"block_number": p138.BlockNumberDec,
|
||||
"endpoint": p138.Endpoint,
|
||||
"probe_error": p138.Error,
|
||||
},
|
||||
}
|
||||
|
||||
for _, p := range probes {
|
||||
if p.ChainKey != "1" && p.Name != "ethereum-mainnet" {
|
||||
continue
|
||||
}
|
||||
chains["1"] = map[string]interface{}{
|
||||
"name": "Ethereum Mainnet",
|
||||
"status": chainStatusFromProbe(p),
|
||||
"last_sync": now,
|
||||
"latency_ms": p.LatencyMs,
|
||||
"head_age_sec": p.HeadAgeSeconds,
|
||||
"block_number": p.BlockNumberDec,
|
||||
"endpoint": p.Endpoint,
|
||||
"probe_error": p.Error,
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
probeJSON := make([]map[string]interface{}, 0, len(probes))
|
||||
for _, p := range probes {
|
||||
probeJSON = append(probeJSON, map[string]interface{}{
|
||||
"name": p.Name,
|
||||
"chainKey": p.ChainKey,
|
||||
"endpoint": p.Endpoint,
|
||||
"ok": p.OK,
|
||||
"latencyMs": p.LatencyMs,
|
||||
"blockNumber": p.BlockNumber,
|
||||
"blockNumberDec": p.BlockNumberDec,
|
||||
"headAgeSeconds": p.HeadAgeSeconds,
|
||||
"error": p.Error,
|
||||
})
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"status": overall,
|
||||
"chains": chains,
|
||||
"rpc_probe": probeJSON,
|
||||
"checked_at": now,
|
||||
}
|
||||
if ov := readOptionalVerifyJSON(); ov != nil {
|
||||
data["operator_verify"] = ov
|
||||
}
|
||||
if relays := FetchCCIPRelayHealths(ctx); relays != nil {
|
||||
data["ccip_relays"] = relays
|
||||
if ccip := primaryRelayHealth(relays); ccip != nil {
|
||||
data["ccip_relay"] = ccip
|
||||
}
|
||||
for _, value := range relays {
|
||||
relay, ok := value.(map[string]interface{})
|
||||
if ok && relayNeedsAttention(relay) {
|
||||
data["status"] = "degraded"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
182
backend/api/track1/ccip_health.go
Normal file
182
backend/api/track1/ccip_health.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package track1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type relayHealthTarget struct {
|
||||
Name string
|
||||
URL string
|
||||
}
|
||||
|
||||
func fetchRelayHealthURL(ctx context.Context, u string) map[string]interface{} {
|
||||
out := make(map[string]interface{})
|
||||
|
||||
c := &http.Client{Timeout: 4 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
out["url_probe"] = map[string]interface{}{"ok": false, "error": err.Error()}
|
||||
} else {
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
out["url_probe"] = map[string]interface{}{"ok": false, "error": err.Error()}
|
||||
} else {
|
||||
func() {
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||
ok := resp.StatusCode >= 200 && resp.StatusCode < 300
|
||||
var j interface{}
|
||||
if json.Unmarshal(b, &j) == nil {
|
||||
out["url_probe"] = map[string]interface{}{"ok": ok, "status": resp.StatusCode, "body": j}
|
||||
} else {
|
||||
out["url_probe"] = map[string]interface{}{"ok": ok, "status": resp.StatusCode, "raw": string(b)}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func fetchRelayHealthFileSnapshot(p string) map[string]interface{} {
|
||||
out := make(map[string]interface{})
|
||||
if p != "" {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
out["file_snapshot_error"] = err.Error()
|
||||
} else if len(b) > 512*1024 {
|
||||
out["file_snapshot_error"] = "file too large"
|
||||
} else {
|
||||
var j interface{}
|
||||
if err := json.Unmarshal(b, &j); err != nil {
|
||||
out["file_snapshot_error"] = err.Error()
|
||||
} else {
|
||||
out["file_snapshot"] = j
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildRelayHealthSignal(ctx context.Context, url, filePath string) map[string]interface{} {
|
||||
out := make(map[string]interface{})
|
||||
if strings.TrimSpace(url) != "" {
|
||||
for key, value := range fetchRelayHealthURL(ctx, url) {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(filePath) != "" {
|
||||
for key, value := range fetchRelayHealthFileSnapshot(filePath) {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeRelayHealthName(raw string, index int) string {
|
||||
name := strings.TrimSpace(strings.ToLower(raw))
|
||||
if name == "" {
|
||||
return "relay_" + strconv.Itoa(index)
|
||||
}
|
||||
replacer := strings.NewReplacer(" ", "_", "-", "_", "/", "_")
|
||||
name = replacer.Replace(name)
|
||||
return name
|
||||
}
|
||||
|
||||
func parseRelayHealthTargets() []relayHealthTarget {
|
||||
raw := strings.TrimSpace(os.Getenv("CCIP_RELAY_HEALTH_URLS"))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalized := strings.NewReplacer("\n", ",", ";", ",").Replace(raw)
|
||||
parts := strings.Split(normalized, ",")
|
||||
targets := make([]relayHealthTarget, 0, len(parts))
|
||||
for idx, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
name := ""
|
||||
url := part
|
||||
if strings.Contains(part, "=") {
|
||||
chunks := strings.SplitN(part, "=", 2)
|
||||
name = normalizeRelayHealthName(chunks[0], idx+1)
|
||||
url = strings.TrimSpace(chunks[1])
|
||||
} else {
|
||||
name = normalizeRelayHealthName("", idx+1)
|
||||
}
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, relayHealthTarget{Name: name, URL: url})
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// FetchCCIPRelayHealths returns optional named CCIP / relay signals from URL probes and/or operator JSON files.
|
||||
// Safe defaults: short timeouts, small body cap. Omit from payload when nothing is configured.
|
||||
func FetchCCIPRelayHealths(ctx context.Context) map[string]interface{} {
|
||||
relays := make(map[string]interface{})
|
||||
|
||||
if legacy := buildRelayHealthSignal(
|
||||
ctx,
|
||||
strings.TrimSpace(os.Getenv("CCIP_RELAY_HEALTH_URL")),
|
||||
strings.TrimSpace(os.Getenv("MISSION_CONTROL_CCIP_JSON")),
|
||||
); legacy != nil {
|
||||
relays["mainnet"] = legacy
|
||||
}
|
||||
|
||||
for _, target := range parseRelayHealthTargets() {
|
||||
if _, exists := relays[target.Name]; exists {
|
||||
continue
|
||||
}
|
||||
if relay := buildRelayHealthSignal(ctx, target.URL, ""); relay != nil {
|
||||
relays[target.Name] = relay
|
||||
}
|
||||
}
|
||||
|
||||
if len(relays) == 0 {
|
||||
return nil
|
||||
}
|
||||
return relays
|
||||
}
|
||||
|
||||
func primaryRelayHealth(relays map[string]interface{}) map[string]interface{} {
|
||||
if len(relays) == 0 {
|
||||
return nil
|
||||
}
|
||||
preferred := []string{"mainnet_cw", "mainnet_weth", "mainnet"}
|
||||
for _, key := range preferred {
|
||||
if relay, ok := relays[key].(map[string]interface{}); ok {
|
||||
return relay
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(relays))
|
||||
for key := range relays {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if relay, ok := relays[key].(map[string]interface{}); ok {
|
||||
return relay
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchCCIPRelayHealth returns the primary relay signal for legacy callers.
|
||||
func FetchCCIPRelayHealth(ctx context.Context) map[string]interface{} {
|
||||
return primaryRelayHealth(FetchCCIPRelayHealths(ctx))
|
||||
}
|
||||
203
backend/api/track1/ccip_health_test.go
Normal file
203
backend/api/track1/ccip_health_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package track1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFetchCCIPRelayHealthFromURL(t *testing.T) {
|
||||
relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"status":"operational","destination":{"chain_name":"Ethereum Mainnet"},"queue":{"size":0}}`))
|
||||
}))
|
||||
defer relay.Close()
|
||||
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URL", relay.URL+"/healthz")
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URLS", "")
|
||||
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
|
||||
|
||||
got := FetchCCIPRelayHealth(context.Background())
|
||||
require.NotNil(t, got)
|
||||
|
||||
probe, ok := got["url_probe"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, true, probe["ok"])
|
||||
|
||||
body, ok := probe["body"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "operational", body["status"])
|
||||
|
||||
dest, ok := body["destination"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "Ethereum Mainnet", dest["chain_name"])
|
||||
}
|
||||
|
||||
func TestFetchCCIPRelayHealthsFromNamedURLs(t *testing.T) {
|
||||
mainnet := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"operational","destination":{"chain_name":"Ethereum Mainnet"},"queue":{"size":0}}`))
|
||||
}))
|
||||
defer mainnet.Close()
|
||||
|
||||
bsc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"operational","destination":{"chain_name":"BSC"},"queue":{"size":1}}`))
|
||||
}))
|
||||
defer bsc.Close()
|
||||
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URL", "")
|
||||
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URLS", "mainnet="+mainnet.URL+"/healthz,bsc="+bsc.URL+"/healthz")
|
||||
|
||||
got := FetchCCIPRelayHealths(context.Background())
|
||||
require.NotNil(t, got)
|
||||
|
||||
mainnetRelay, ok := got["mainnet"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
mainnetProbe, ok := mainnetRelay["url_probe"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, true, mainnetProbe["ok"])
|
||||
|
||||
bscRelay, ok := got["bsc"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
bscProbe, ok := bscRelay["url_probe"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
body, ok := bscProbe["body"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
dest, ok := body["destination"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "BSC", dest["chain_name"])
|
||||
}
|
||||
|
||||
func TestFetchCCIPRelayHealthPrefersMainnetCW(t *testing.T) {
|
||||
relays := map[string]interface{}{
|
||||
"mainnet_weth": map[string]interface{}{"url_probe": map[string]interface{}{"ok": true}},
|
||||
"mainnet_cw": map[string]interface{}{"url_probe": map[string]interface{}{"ok": true, "body": map[string]interface{}{"status": "operational"}}},
|
||||
"bsc": map[string]interface{}{"url_probe": map[string]interface{}{"ok": true}},
|
||||
}
|
||||
|
||||
got := primaryRelayHealth(relays)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, relays["mainnet_cw"], got)
|
||||
}
|
||||
|
||||
func TestFetchCCIPRelayHealthFromFileSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "relay-health.json")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{"status":"paused","queue":{"size":3}}`), 0o644))
|
||||
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URL", "")
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URLS", "")
|
||||
t.Setenv("MISSION_CONTROL_CCIP_JSON", path)
|
||||
|
||||
got := FetchCCIPRelayHealth(context.Background())
|
||||
require.NotNil(t, got)
|
||||
|
||||
snapshot, ok := got["file_snapshot"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "paused", snapshot["status"])
|
||||
|
||||
queue, ok := snapshot["queue"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, float64(3), queue["size"])
|
||||
}
|
||||
|
||||
func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
|
||||
rpc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "eth_blockNumber":
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x10"}`))
|
||||
case "eth_getBlockByNumber":
|
||||
ts := strconv.FormatInt(time.Now().Add(-2*time.Second).Unix(), 16)
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x` + ts + `"}}`))
|
||||
default:
|
||||
http.Error(w, `{"jsonrpc":"2.0","id":1,"error":{"message":"unsupported"}}`, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer rpc.Close()
|
||||
|
||||
relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"status":"operational","queue":{"size":0}}`))
|
||||
}))
|
||||
defer relay.Close()
|
||||
|
||||
t.Setenv("RPC_URL", rpc.URL)
|
||||
t.Setenv("ETH_MAINNET_RPC_URL", "")
|
||||
t.Setenv("MISSION_CONTROL_EXTRA_RPCS", "")
|
||||
t.Setenv("MISSION_CONTROL_VERIFY_JSON", "")
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URL", relay.URL+"/healthz")
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URLS", "")
|
||||
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
|
||||
|
||||
s := &Server{}
|
||||
got := s.BuildBridgeStatusData(context.Background())
|
||||
ccip, ok := got["ccip_relay"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
relays, ok := got["ccip_relays"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Contains(t, relays, "mainnet")
|
||||
|
||||
probe, ok := ccip["url_probe"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, true, probe["ok"])
|
||||
}
|
||||
|
||||
func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) {
|
||||
rpc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "eth_blockNumber":
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x10"}`))
|
||||
case "eth_getBlockByNumber":
|
||||
ts := strconv.FormatInt(time.Now().Add(-2*time.Second).Unix(), 16)
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x` + ts + `"}}`))
|
||||
default:
|
||||
http.Error(w, `{"jsonrpc":"2.0","id":1,"error":{"message":"unsupported"}}`, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer rpc.Close()
|
||||
|
||||
mainnet := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"operational","queue":{"size":0}}`))
|
||||
}))
|
||||
defer mainnet.Close()
|
||||
|
||||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"status":"degraded"}`, http.StatusBadGateway)
|
||||
}))
|
||||
defer bad.Close()
|
||||
|
||||
t.Setenv("RPC_URL", rpc.URL)
|
||||
t.Setenv("ETH_MAINNET_RPC_URL", "")
|
||||
t.Setenv("MISSION_CONTROL_EXTRA_RPCS", "")
|
||||
t.Setenv("MISSION_CONTROL_VERIFY_JSON", "")
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URL", "")
|
||||
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URLS", "mainnet="+mainnet.URL+"/healthz,bsc="+bad.URL+"/healthz")
|
||||
|
||||
s := &Server{}
|
||||
got := s.BuildBridgeStatusData(context.Background())
|
||||
require.Equal(t, "degraded", got["status"])
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
package track1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/explorer/backend/libs/go-rpc-gateway"
|
||||
)
|
||||
|
||||
var track1HashPattern = regexp.MustCompile(`^0x[a-fA-F0-9]{64}$`)
|
||||
|
||||
// Server handles Track 1 endpoints (uses RPC gateway from lib)
|
||||
type Server struct {
|
||||
rpcGateway *gateway.RPCGateway
|
||||
@@ -173,7 +178,12 @@ func (s *Server) HandleBlockDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track1/block/")
|
||||
blockNumStr := fmt.Sprintf("0x%x", parseBlockNumber(path))
|
||||
blockNumber, err := strconv.ParseInt(strings.TrimSpace(path), 10, 64)
|
||||
if err != nil || blockNumber < 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid block number")
|
||||
return
|
||||
}
|
||||
blockNumStr := fmt.Sprintf("0x%x", blockNumber)
|
||||
|
||||
blockResp, err := s.rpcGateway.GetBlockByNumber(r.Context(), blockNumStr, false)
|
||||
if err != nil {
|
||||
@@ -203,7 +213,11 @@ func (s *Server) HandleTransactionDetail(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track1/tx/")
|
||||
txHash := path
|
||||
txHash := strings.TrimSpace(path)
|
||||
if !track1HashPattern.MatchString(txHash) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid transaction hash")
|
||||
return
|
||||
}
|
||||
|
||||
txResp, err := s.rpcGateway.GetTransactionByHash(r.Context(), txHash)
|
||||
if err != nil {
|
||||
@@ -239,7 +253,11 @@ func (s *Server) HandleAddressBalance(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
address := parts[0]
|
||||
address := strings.TrimSpace(parts[0])
|
||||
if !common.IsHexAddress(address) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid address")
|
||||
return
|
||||
}
|
||||
balanceResp, err := s.rpcGateway.GetBalance(r.Context(), address, "latest")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "rpc_error", err.Error())
|
||||
@@ -278,31 +296,25 @@ func (s *Server) HandleBridgeStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Return bridge status (simplified - in production, query bridge contracts)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data := s.BuildBridgeStatusData(ctx)
|
||||
response := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"status": "operational",
|
||||
"chains": map[string]interface{}{
|
||||
"138": map[string]interface{}{
|
||||
"name": "Defi Oracle Meta Mainnet",
|
||||
"status": "operational",
|
||||
"last_sync": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
"1": map[string]interface{}{
|
||||
"name": "Ethereum Mainnet",
|
||||
"status": "operational",
|
||||
"last_sync": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
"total_transfers_24h": 150,
|
||||
"total_volume_24h": "5000000000000000000000",
|
||||
},
|
||||
"data": data,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func chainStatusFromProbe(p RPCProbeResult) string {
|
||||
if p.OK {
|
||||
return "operational"
|
||||
}
|
||||
return "unreachable"
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func writeError(w http.ResponseWriter, statusCode int, code, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -320,14 +332,6 @@ func hexToInt(hex string) (int64, error) {
|
||||
return strconv.ParseInt(hex, 16, 64)
|
||||
}
|
||||
|
||||
func parseBlockNumber(s string) int64 {
|
||||
num, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
func transformBlock(blockData map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"number": parseHexField(blockData["number"]),
|
||||
|
||||
43
backend/api/track1/endpoints_validation_test.go
Normal file
43
backend/api/track1/endpoints_validation_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package track1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleBlockDetailRejectsInvalidBlockNumber(t *testing.T) {
|
||||
server := &Server{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/track1/block/not-a-number", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.HandleBlockDetail(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid block number, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTransactionDetailRejectsInvalidHash(t *testing.T) {
|
||||
server := &Server{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/track1/tx/not-a-hash", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.HandleTransactionDetail(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid tx hash, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAddressBalanceRejectsInvalidAddress(t *testing.T) {
|
||||
server := &Server{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/track1/address/not-an-address/balance", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.HandleAddressBalance(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid address, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
54
backend/api/track1/mission_control_sse.go
Normal file
54
backend/api/track1/mission_control_sse.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package track1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HandleMissionControlStream sends periodic text/event-stream payloads with full bridge/status data (for SPA or tooling).
|
||||
func (s *Server) HandleMissionControlStream(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
controller := http.NewResponseController(w)
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
tick := time.NewTicker(20 * time.Second)
|
||||
defer tick.Stop()
|
||||
|
||||
send := func() bool {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
defer cancel()
|
||||
data := s.BuildBridgeStatusData(ctx)
|
||||
payload, err := json.Marshal(map[string]interface{}{"data": data})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "event: mission-control\ndata: %s\n\n", payload)
|
||||
return controller.Flush() == nil
|
||||
}
|
||||
|
||||
if !send() {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
if !send() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
backend/api/track1/mission_control_sse_test.go
Normal file
72
backend/api/track1/mission_control_sse_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package track1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHandleMissionControlStreamSendsInitialEvent(t *testing.T) {
|
||||
rpc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "eth_blockNumber":
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x10"}`))
|
||||
case "eth_getBlockByNumber":
|
||||
ts := strconv.FormatInt(time.Now().Add(-2*time.Second).Unix(), 16)
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x` + ts + `"}}`))
|
||||
default:
|
||||
http.Error(w, `{"jsonrpc":"2.0","id":1,"error":{"message":"unsupported"}}`, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer rpc.Close()
|
||||
|
||||
t.Setenv("RPC_URL", rpc.URL)
|
||||
t.Setenv("ETH_MAINNET_RPC_URL", "")
|
||||
t.Setenv("MISSION_CONTROL_EXTRA_RPCS", "")
|
||||
t.Setenv("MISSION_CONTROL_VERIFY_JSON", "")
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URL", "")
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URLS", "")
|
||||
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
|
||||
|
||||
s := &Server{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/mission-control/stream", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.HandleMissionControlStream(w, req)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if strings.Contains(w.Body.String(), "event: mission-control") {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
require.Contains(t, w.Header().Get("Content-Type"), "text/event-stream")
|
||||
require.Contains(t, w.Body.String(), "event: mission-control")
|
||||
require.Contains(t, w.Body.String(), `"status":"operational"`)
|
||||
require.Contains(t, w.Body.String(), `"chain-138"`)
|
||||
}
|
||||
204
backend/api/track1/rpcping.go
Normal file
204
backend/api/track1/rpcping.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package track1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RPCProbeResult is one JSON-RPC health check (URLs are redacted to origin only in JSON).
|
||||
type RPCProbeResult struct {
|
||||
Name string `json:"name"`
|
||||
ChainKey string `json:"chainKey,omitempty"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
OK bool `json:"ok"`
|
||||
LatencyMs int64 `json:"latencyMs"`
|
||||
BlockNumber string `json:"blockNumber,omitempty"`
|
||||
BlockNumberDec string `json:"blockNumberDec,omitempty"`
|
||||
HeadAgeSeconds float64 `json:"headAgeSeconds,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type jsonRPCReq struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params []interface{} `json:"params"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type jsonRPCResp struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func redactRPCOrigin(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return "hidden"
|
||||
}
|
||||
if u.Scheme == "" {
|
||||
return u.Host
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
func postJSONRPC(ctx context.Context, client *http.Client, rpcURL string, method string, params []interface{}) (json.RawMessage, int64, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
body, err := json.Marshal(jsonRPCReq{JSONRPC: "2.0", Method: method, Params: params, ID: 1})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
latency := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return nil, latency, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, latency, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, latency, fmt.Errorf("http %d", resp.StatusCode)
|
||||
}
|
||||
var out jsonRPCResp
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil, latency, err
|
||||
}
|
||||
if out.Error != nil && out.Error.Message != "" {
|
||||
return nil, latency, fmt.Errorf("rpc error: %s", out.Error.Message)
|
||||
}
|
||||
return out.Result, latency, nil
|
||||
}
|
||||
|
||||
// ProbeEVMJSONRPC runs eth_blockNumber and eth_getBlockByNumber(latest) for head age.
|
||||
func ProbeEVMJSONRPC(ctx context.Context, name, chainKey, rpcURL string) RPCProbeResult {
|
||||
rpcURL = strings.TrimSpace(rpcURL)
|
||||
res := RPCProbeResult{
|
||||
Name: name,
|
||||
ChainKey: chainKey,
|
||||
Endpoint: redactRPCOrigin(rpcURL),
|
||||
}
|
||||
if rpcURL == "" {
|
||||
res.Error = "empty rpc url"
|
||||
return res
|
||||
}
|
||||
client := &http.Client{Timeout: 6 * time.Second}
|
||||
|
||||
numRaw, lat1, err := postJSONRPC(ctx, client, rpcURL, "eth_blockNumber", []interface{}{})
|
||||
if err != nil {
|
||||
res.LatencyMs = lat1
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
var numHex string
|
||||
if err := json.Unmarshal(numRaw, &numHex); err != nil {
|
||||
res.LatencyMs = lat1
|
||||
res.Error = "blockNumber decode: " + err.Error()
|
||||
return res
|
||||
}
|
||||
res.BlockNumber = numHex
|
||||
if n, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(numHex), "0x"), 16, 64); err == nil {
|
||||
res.BlockNumberDec = strconv.FormatInt(n, 10)
|
||||
}
|
||||
|
||||
blockRaw, lat2, err := postJSONRPC(ctx, client, rpcURL, "eth_getBlockByNumber", []interface{}{"latest", false})
|
||||
res.LatencyMs = lat1 + lat2
|
||||
if err != nil {
|
||||
res.OK = true
|
||||
res.Error = "head block timestamp unavailable: " + err.Error()
|
||||
return res
|
||||
}
|
||||
var block struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
if err := json.Unmarshal(blockRaw, &block); err != nil || block.Timestamp == "" {
|
||||
res.OK = true
|
||||
if err != nil {
|
||||
res.Error = "block decode: " + err.Error()
|
||||
}
|
||||
return res
|
||||
}
|
||||
tsHex := strings.TrimSpace(block.Timestamp)
|
||||
ts, err := strconv.ParseInt(strings.TrimPrefix(tsHex, "0x"), 16, 64)
|
||||
if err != nil {
|
||||
res.OK = true
|
||||
res.Error = "timestamp parse: " + err.Error()
|
||||
return res
|
||||
}
|
||||
bt := time.Unix(ts, 0)
|
||||
res.HeadAgeSeconds = time.Since(bt).Seconds()
|
||||
res.OK = true
|
||||
return res
|
||||
}
|
||||
|
||||
func readOptionalVerifyJSON() map[string]interface{} {
|
||||
path := strings.TrimSpace(os.Getenv("MISSION_CONTROL_VERIFY_JSON"))
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil || len(b) == 0 {
|
||||
return map[string]interface{}{"error": "unreadable or empty", "path": path}
|
||||
}
|
||||
if len(b) > 512*1024 {
|
||||
return map[string]interface{}{"error": "file too large", "path": path}
|
||||
}
|
||||
var v map[string]interface{}
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "path": path}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ParseExtraRPCProbes reads MISSION_CONTROL_EXTRA_RPCS lines "name|url" or "name|url|chainKey".
|
||||
func ParseExtraRPCProbes() [][3]string {
|
||||
raw := strings.TrimSpace(os.Getenv("MISSION_CONTROL_EXTRA_RPCS"))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var out [][3]string
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "|")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(parts[0])
|
||||
u := strings.TrimSpace(parts[1])
|
||||
ck := ""
|
||||
if len(parts) > 2 {
|
||||
ck = strings.TrimSpace(parts[2])
|
||||
}
|
||||
if name != "" && u != "" {
|
||||
out = append(out, [3]string{name, u, ck})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
package track2
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var track2HashPattern = regexp.MustCompile(`^0x[0-9a-fA-F]{64}$`)
|
||||
|
||||
// Server handles Track 2 endpoints
|
||||
type Server struct {
|
||||
db *pgxpool.Pool
|
||||
@@ -29,6 +35,9 @@ func (s *Server) HandleAddressTransactions(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track2/address/")
|
||||
parts := strings.Split(path, "/")
|
||||
@@ -37,7 +46,11 @@ func (s *Server) HandleAddressTransactions(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
address := strings.ToLower(parts[0])
|
||||
address, err := normalizeTrack2Address(parts[0])
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -51,7 +64,7 @@ func (s *Server) HandleAddressTransactions(w http.ResponseWriter, r *http.Reques
|
||||
query := `
|
||||
SELECT hash, from_address, to_address, value, block_number, timestamp, status
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)
|
||||
WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)
|
||||
ORDER BY block_number DESC, timestamp DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
@@ -92,7 +105,7 @@ func (s *Server) HandleAddressTransactions(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
// Get total count
|
||||
var total int
|
||||
countQuery := `SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`
|
||||
countQuery := `SELECT COUNT(*) FROM transactions WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)`
|
||||
s.db.QueryRow(r.Context(), countQuery, s.chainID, address).Scan(&total)
|
||||
|
||||
response := map[string]interface{}{
|
||||
@@ -115,6 +128,9 @@ func (s *Server) HandleAddressTokens(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track2/address/")
|
||||
parts := strings.Split(path, "/")
|
||||
@@ -123,12 +139,16 @@ func (s *Server) HandleAddressTokens(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
address := strings.ToLower(parts[0])
|
||||
address, err := normalizeTrack2Address(parts[0])
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT token_contract, balance, last_updated_timestamp
|
||||
FROM token_balances
|
||||
WHERE address = $1 AND chain_id = $2 AND balance > 0
|
||||
WHERE LOWER(address) = $1 AND chain_id = $2 AND balance > 0
|
||||
ORDER BY balance DESC
|
||||
`
|
||||
|
||||
@@ -151,7 +171,7 @@ func (s *Server) HandleAddressTokens(w http.ResponseWriter, r *http.Request) {
|
||||
tokens = append(tokens, map[string]interface{}{
|
||||
"contract": contract,
|
||||
"balance": balance,
|
||||
"balance_formatted": balance, // TODO: Format with decimals
|
||||
"balance_formatted": nil,
|
||||
"last_updated": lastUpdated,
|
||||
})
|
||||
}
|
||||
@@ -174,14 +194,40 @@ func (s *Server) HandleTokenInfo(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track2/token/")
|
||||
contract := strings.ToLower(path)
|
||||
contract, err := normalizeTrack2Address(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Get token info from token_transfers
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(DISTINCT from_address) + COUNT(DISTINCT to_address) as holders,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM (
|
||||
SELECT from_address AS address
|
||||
FROM token_transfers
|
||||
WHERE token_contract = $1
|
||||
AND chain_id = $2
|
||||
AND timestamp >= NOW() - INTERVAL '24 hours'
|
||||
AND from_address IS NOT NULL
|
||||
AND from_address <> ''
|
||||
UNION
|
||||
SELECT to_address AS address
|
||||
FROM token_transfers
|
||||
WHERE token_contract = $1
|
||||
AND chain_id = $2
|
||||
AND timestamp >= NOW() - INTERVAL '24 hours'
|
||||
AND to_address IS NOT NULL
|
||||
AND to_address <> ''
|
||||
) holder_addresses
|
||||
) as holders,
|
||||
COUNT(*) as transfers_24h,
|
||||
SUM(value) as volume_24h
|
||||
FROM token_transfers
|
||||
@@ -191,7 +237,7 @@ func (s *Server) HandleTokenInfo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var holders, transfers24h int
|
||||
var volume24h string
|
||||
err := s.db.QueryRow(r.Context(), query, contract, s.chainID).Scan(&holders, &transfers24h, &volume24h)
|
||||
err = s.db.QueryRow(r.Context(), query, contract, s.chainID).Scan(&holders, &transfers24h, &volume24h)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Token not found")
|
||||
return
|
||||
@@ -216,15 +262,16 @@ func (s *Server) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query().Get("q")
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if query == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Query parameter 'q' is required")
|
||||
return
|
||||
}
|
||||
|
||||
query = strings.ToLower(strings.TrimPrefix(query, "0x"))
|
||||
|
||||
// Try to detect type and search
|
||||
var result map[string]interface{}
|
||||
|
||||
@@ -241,13 +288,14 @@ func (s *Server) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
}
|
||||
}
|
||||
} else if len(query) == 64 || len(query) == 40 {
|
||||
// Could be address or transaction hash
|
||||
fullQuery := "0x" + query
|
||||
|
||||
// Check transaction
|
||||
} else if track2HashPattern.MatchString(query) {
|
||||
hash, err := normalizeTrack2Hash(query)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
var txHash string
|
||||
err := s.db.QueryRow(r.Context(), `SELECT hash FROM transactions WHERE chain_id = $1 AND hash = $2`, s.chainID, fullQuery).Scan(&txHash)
|
||||
err = s.db.QueryRow(r.Context(), `SELECT hash FROM transactions WHERE chain_id = $1 AND LOWER(hash) = $2`, s.chainID, hash).Scan(&txHash)
|
||||
if err == nil {
|
||||
result = map[string]interface{}{
|
||||
"type": "transaction",
|
||||
@@ -255,18 +303,44 @@ func (s *Server) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
"hash": txHash,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Check address
|
||||
}
|
||||
} else if common.IsHexAddress(query) {
|
||||
address, err := normalizeTrack2Address(query)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var exists bool
|
||||
existsQuery := `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM addresses
|
||||
WHERE chain_id = $1 AND LOWER(address) = $2
|
||||
UNION
|
||||
SELECT 1
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)
|
||||
UNION
|
||||
SELECT 1
|
||||
FROM token_balances
|
||||
WHERE chain_id = $1 AND LOWER(address) = $2
|
||||
)
|
||||
`
|
||||
err = s.db.QueryRow(r.Context(), existsQuery, s.chainID, address).Scan(&exists)
|
||||
if err == nil && exists {
|
||||
var balance string
|
||||
err := s.db.QueryRow(r.Context(), `SELECT COALESCE(SUM(balance), '0') FROM token_balances WHERE address = $1 AND chain_id = $2`, fullQuery, s.chainID).Scan(&balance)
|
||||
if err == nil {
|
||||
result = map[string]interface{}{
|
||||
"type": "address",
|
||||
"result": map[string]interface{}{
|
||||
"address": fullQuery,
|
||||
"balance": balance,
|
||||
},
|
||||
}
|
||||
err = s.db.QueryRow(r.Context(), `SELECT COALESCE(SUM(balance), '0') FROM token_balances WHERE LOWER(address) = $1 AND chain_id = $2`, address, s.chainID).Scan(&balance)
|
||||
if err != nil {
|
||||
balance = "0"
|
||||
}
|
||||
|
||||
result = map[string]interface{}{
|
||||
"type": "address",
|
||||
"result": map[string]interface{}{
|
||||
"address": address,
|
||||
"balance": balance,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +364,9 @@ func (s *Server) HandleInternalTransactions(w http.ResponseWriter, r *http.Reque
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track2/address/")
|
||||
parts := strings.Split(path, "/")
|
||||
@@ -298,7 +375,11 @@ func (s *Server) HandleInternalTransactions(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
address := strings.ToLower(parts[0])
|
||||
address, err := normalizeTrack2Address(parts[0])
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -312,7 +393,7 @@ func (s *Server) HandleInternalTransactions(w http.ResponseWriter, r *http.Reque
|
||||
query := `
|
||||
SELECT transaction_hash, from_address, to_address, value, block_number, timestamp
|
||||
FROM internal_transactions
|
||||
WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)
|
||||
WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)
|
||||
ORDER BY block_number DESC, timestamp DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
@@ -345,7 +426,7 @@ func (s *Server) HandleInternalTransactions(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
var total int
|
||||
countQuery := `SELECT COUNT(*) FROM internal_transactions WHERE chain_id = $1 AND (from_address = $2 OR to_address = $2)`
|
||||
countQuery := `SELECT COUNT(*) FROM internal_transactions WHERE chain_id = $1 AND (LOWER(from_address) = $2 OR LOWER(to_address) = $2)`
|
||||
s.db.QueryRow(r.Context(), countQuery, s.chainID, address).Scan(&total)
|
||||
|
||||
response := map[string]interface{}{
|
||||
@@ -372,3 +453,30 @@ func writeError(w http.ResponseWriter, statusCode int, code, message string) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireDB(w http.ResponseWriter) bool {
|
||||
if s.db == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "database not configured")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeTrack2Address(value string) (string, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if !common.IsHexAddress(trimmed) {
|
||||
return "", fmt.Errorf("invalid address format")
|
||||
}
|
||||
return strings.ToLower(common.HexToAddress(trimmed).Hex()), nil
|
||||
}
|
||||
|
||||
func normalizeTrack2Hash(value string) (string, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if !track2HashPattern.MatchString(trimmed) {
|
||||
return "", fmt.Errorf("invalid transaction hash")
|
||||
}
|
||||
if _, err := hex.DecodeString(trimmed[2:]); err != nil {
|
||||
return "", fmt.Errorf("invalid transaction hash")
|
||||
}
|
||||
return strings.ToLower(trimmed), nil
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package track3
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/explorer/backend/analytics"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -35,9 +37,29 @@ func NewServer(db *pgxpool.Pool, chainID int) *Server {
|
||||
|
||||
// HandleFlows handles GET /api/v1/track3/analytics/flows
|
||||
func (s *Server) HandleFlows(w http.ResponseWriter, r *http.Request) {
|
||||
from := r.URL.Query().Get("from")
|
||||
to := r.URL.Query().Get("to")
|
||||
token := r.URL.Query().Get("token")
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
from, err := normalizeTrack3OptionalAddress(r.URL.Query().Get("from"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
to, err := normalizeTrack3OptionalAddress(r.URL.Query().Get("to"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
token, err := normalizeTrack3OptionalAddress(r.URL.Query().Get("token"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit < 1 || limit > 200 {
|
||||
limit = 50
|
||||
@@ -45,14 +67,20 @@ func (s *Server) HandleFlows(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var startDate, endDate *time.Time
|
||||
if startStr := r.URL.Query().Get("start_date"); startStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
|
||||
startDate = &t
|
||||
t, err := time.Parse(time.RFC3339, startStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid start_date")
|
||||
return
|
||||
}
|
||||
startDate = &t
|
||||
}
|
||||
if endStr := r.URL.Query().Get("end_date"); endStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
|
||||
endDate = &t
|
||||
t, err := time.Parse(time.RFC3339, endStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid end_date")
|
||||
return
|
||||
}
|
||||
endDate = &t
|
||||
}
|
||||
|
||||
flows, err := s.flowTracker.GetFlows(r.Context(), from, to, token, startDate, endDate, limit)
|
||||
@@ -73,28 +101,48 @@ func (s *Server) HandleFlows(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleBridge handles GET /api/v1/track3/analytics/bridge
|
||||
func (s *Server) HandleBridge(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
var chainFrom, chainTo *int
|
||||
if cf := r.URL.Query().Get("chain_from"); cf != "" {
|
||||
if c, err := strconv.Atoi(cf); err == nil {
|
||||
chainFrom = &c
|
||||
c, err := strconv.Atoi(cf)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid chain_from")
|
||||
return
|
||||
}
|
||||
chainFrom = &c
|
||||
}
|
||||
if ct := r.URL.Query().Get("chain_to"); ct != "" {
|
||||
if c, err := strconv.Atoi(ct); err == nil {
|
||||
chainTo = &c
|
||||
c, err := strconv.Atoi(ct)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid chain_to")
|
||||
return
|
||||
}
|
||||
chainTo = &c
|
||||
}
|
||||
|
||||
var startDate, endDate *time.Time
|
||||
if startStr := r.URL.Query().Get("start_date"); startStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
|
||||
startDate = &t
|
||||
t, err := time.Parse(time.RFC3339, startStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid start_date")
|
||||
return
|
||||
}
|
||||
startDate = &t
|
||||
}
|
||||
if endStr := r.URL.Query().Get("end_date"); endStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
|
||||
endDate = &t
|
||||
t, err := time.Parse(time.RFC3339, endStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid end_date")
|
||||
return
|
||||
}
|
||||
endDate = &t
|
||||
}
|
||||
|
||||
stats, err := s.bridgeAnalytics.GetBridgeStats(r.Context(), chainFrom, chainTo, startDate, endDate)
|
||||
@@ -113,8 +161,20 @@ func (s *Server) HandleBridge(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleTokenDistribution handles GET /api/v1/track3/analytics/token-distribution
|
||||
func (s *Server) HandleTokenDistribution(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track3/analytics/token-distribution/")
|
||||
contract := strings.ToLower(path)
|
||||
contract, err := normalizeTrack3RequiredAddress(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
topN, _ := strconv.Atoi(r.URL.Query().Get("top_n"))
|
||||
if topN < 1 || topN > 1000 {
|
||||
@@ -137,8 +197,20 @@ func (s *Server) HandleTokenDistribution(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// HandleAddressRisk handles GET /api/v1/track3/analytics/address-risk/:addr
|
||||
func (s *Server) HandleAddressRisk(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/track3/analytics/address-risk/")
|
||||
address := strings.ToLower(path)
|
||||
address, err := normalizeTrack3RequiredAddress(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
analysis, err := s.riskAnalyzer.AnalyzeAddress(r.Context(), address)
|
||||
if err != nil {
|
||||
@@ -165,3 +237,32 @@ func writeError(w http.ResponseWriter, statusCode int, code, message string) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireDB(w http.ResponseWriter) bool {
|
||||
if s.db == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "database not configured")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeTrack3OptionalAddress(value string) (string, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !common.IsHexAddress(trimmed) {
|
||||
return "", fmt.Errorf("invalid address format")
|
||||
}
|
||||
return strings.ToLower(common.HexToAddress(trimmed).Hex()), nil
|
||||
}
|
||||
|
||||
func normalizeTrack3RequiredAddress(value string) (string, error) {
|
||||
normalized, err := normalizeTrack3OptionalAddress(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if normalized == "" {
|
||||
return "", fmt.Errorf("address required")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package track4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/explorer/backend/auth"
|
||||
@@ -11,48 +18,52 @@ import (
|
||||
|
||||
// Server handles Track 4 endpoints
|
||||
type Server struct {
|
||||
db *pgxpool.Pool
|
||||
roleMgr *auth.RoleManager
|
||||
chainID int
|
||||
db *pgxpool.Pool
|
||||
roleMgr roleManager
|
||||
chainID int
|
||||
}
|
||||
|
||||
// NewServer creates a new Track 4 server
|
||||
func NewServer(db *pgxpool.Pool, chainID int) *Server {
|
||||
return &Server{
|
||||
db: db,
|
||||
roleMgr: auth.NewRoleManager(db),
|
||||
chainID: chainID,
|
||||
db: db,
|
||||
roleMgr: auth.NewRoleManager(db),
|
||||
chainID: chainID,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleBridgeEvents handles GET /api/v1/track4/operator/bridge/events
|
||||
func (s *Server) HandleBridgeEvents(w http.ResponseWriter, r *http.Request) {
|
||||
// Get operator address from context
|
||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
||||
if operatorAddr == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Operator address required")
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
// Check IP whitelist
|
||||
ipAddr := r.RemoteAddr
|
||||
if whitelisted, _ := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr); !whitelisted {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted")
|
||||
operatorAddr, ipAddr, ok := s.requireOperatorAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Log operator event
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "bridge_events_read", &s.chainID, operatorAddr, "bridge/events", "read", map[string]interface{}{}, ipAddr, r.UserAgent())
|
||||
events, lastUpdate, err := s.loadBridgeEvents(r.Context(), 100)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "bridge_events_read", &s.chainID, operatorAddr, "bridge/events", "read", map[string]interface{}{"event_count": len(events)}, ipAddr, r.UserAgent())
|
||||
|
||||
controlState := map[string]interface{}{
|
||||
"paused": nil,
|
||||
"maintenance_mode": nil,
|
||||
"bridge_control_unavailable": true,
|
||||
}
|
||||
if !lastUpdate.IsZero() {
|
||||
controlState["last_update"] = lastUpdate.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// Return bridge events (simplified)
|
||||
response := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"events": []map[string]interface{}{},
|
||||
"control_state": map[string]interface{}{
|
||||
"paused": false,
|
||||
"maintenance_mode": false,
|
||||
"last_update": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
"events": events,
|
||||
"control_state": controlState,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -62,21 +73,29 @@ func (s *Server) HandleBridgeEvents(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleValidators handles GET /api/v1/track4/operator/validators
|
||||
func (s *Server) HandleValidators(w http.ResponseWriter, r *http.Request) {
|
||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
||||
ipAddr := r.RemoteAddr
|
||||
|
||||
if whitelisted, _ := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr); !whitelisted {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted")
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "validators_read", &s.chainID, operatorAddr, "validators", "read", map[string]interface{}{}, ipAddr, r.UserAgent())
|
||||
operatorAddr, ipAddr, ok := s.requireOperatorAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
validators, err := s.loadValidatorStatus(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "validators_read", &s.chainID, operatorAddr, "validators", "read", map[string]interface{}{"validator_count": len(validators)}, ipAddr, r.UserAgent())
|
||||
|
||||
response := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"validators": []map[string]interface{}{},
|
||||
"total_validators": 0,
|
||||
"active_validators": 0,
|
||||
"validators": validators,
|
||||
"total_validators": len(validators),
|
||||
"active_validators": len(validators),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -86,19 +105,38 @@ func (s *Server) HandleValidators(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleContracts handles GET /api/v1/track4/operator/contracts
|
||||
func (s *Server) HandleContracts(w http.ResponseWriter, r *http.Request) {
|
||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
||||
ipAddr := r.RemoteAddr
|
||||
|
||||
if whitelisted, _ := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr); !whitelisted {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted")
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "contracts_read", &s.chainID, operatorAddr, "contracts", "read", map[string]interface{}{}, ipAddr, r.UserAgent())
|
||||
operatorAddr, ipAddr, ok := s.requireOperatorAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
chainID := s.chainID
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("chain_id")); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed < 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid chain_id")
|
||||
return
|
||||
}
|
||||
chainID = parsed
|
||||
}
|
||||
|
||||
typeFilter := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("type")))
|
||||
contracts, err := s.loadContractStatus(r.Context(), chainID, typeFilter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "contracts_read", &s.chainID, operatorAddr, "contracts", "read", map[string]interface{}{"contract_count": len(contracts), "chain_id": chainID, "type": typeFilter}, ipAddr, r.UserAgent())
|
||||
|
||||
response := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"contracts": []map[string]interface{}{},
|
||||
"contracts": contracts,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -108,35 +146,26 @@ func (s *Server) HandleContracts(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleProtocolState handles GET /api/v1/track4/operator/protocol-state
|
||||
func (s *Server) HandleProtocolState(w http.ResponseWriter, r *http.Request) {
|
||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
||||
ipAddr := r.RemoteAddr
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
if whitelisted, _ := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr); !whitelisted {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted")
|
||||
operatorAddr, ipAddr, ok := s.requireOperatorAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
state, err := s.loadProtocolState(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "protocol_state_read", &s.chainID, operatorAddr, "protocol/state", "read", map[string]interface{}{}, ipAddr, r.UserAgent())
|
||||
|
||||
response := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"protocol_version": "1.0.0",
|
||||
"chain_id": s.chainID,
|
||||
"config": map[string]interface{}{
|
||||
"bridge_enabled": true,
|
||||
"max_transfer_amount": "1000000000000000000000000",
|
||||
},
|
||||
"state": map[string]interface{}{
|
||||
"total_locked": "50000000000000000000000000",
|
||||
"total_bridged": "10000000000000000000000000",
|
||||
"active_bridges": 2,
|
||||
},
|
||||
"last_updated": time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"data": state})
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, statusCode int, code, message string) {
|
||||
@@ -150,3 +179,406 @@ func writeError(w http.ResponseWriter, statusCode int, code, message string) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireOperatorAccess(w http.ResponseWriter, r *http.Request) (string, string, bool) {
|
||||
if s.db == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "database not configured")
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
||||
operatorAddr = strings.TrimSpace(operatorAddr)
|
||||
if operatorAddr == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Operator address required")
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
ipAddr := clientIPAddress(r)
|
||||
whitelisted, err := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database_error", err.Error())
|
||||
return "", "", false
|
||||
}
|
||||
if !whitelisted {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted")
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
return operatorAddr, ipAddr, true
|
||||
}
|
||||
|
||||
func (s *Server) loadBridgeEvents(ctx context.Context, limit int) ([]map[string]interface{}, time.Time, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT event_type, operator_address, target_resource, action, details, COALESCE(ip_address::text, ''), COALESCE(user_agent, ''), timestamp
|
||||
FROM operator_events
|
||||
WHERE (chain_id = $1 OR chain_id IS NULL)
|
||||
AND (
|
||||
event_type ILIKE '%bridge%'
|
||||
OR target_resource ILIKE 'bridge%'
|
||||
OR target_resource ILIKE '%bridge%'
|
||||
)
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT $2
|
||||
`, s.chainID, limit)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, fmt.Errorf("failed to query bridge events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := make([]map[string]interface{}, 0, limit)
|
||||
var latest time.Time
|
||||
for rows.Next() {
|
||||
var eventType, operatorAddress, targetResource, action, ipAddress, userAgent string
|
||||
var detailsBytes []byte
|
||||
var timestamp time.Time
|
||||
if err := rows.Scan(&eventType, &operatorAddress, &targetResource, &action, &detailsBytes, &ipAddress, &userAgent, ×tamp); err != nil {
|
||||
return nil, time.Time{}, fmt.Errorf("failed to scan bridge event: %w", err)
|
||||
}
|
||||
|
||||
details := map[string]interface{}{}
|
||||
if len(detailsBytes) > 0 && string(detailsBytes) != "null" {
|
||||
_ = json.Unmarshal(detailsBytes, &details)
|
||||
}
|
||||
|
||||
if latest.IsZero() {
|
||||
latest = timestamp
|
||||
}
|
||||
events = append(events, map[string]interface{}{
|
||||
"event_type": eventType,
|
||||
"operator_address": operatorAddress,
|
||||
"target_resource": targetResource,
|
||||
"action": action,
|
||||
"details": details,
|
||||
"ip_address": ipAddress,
|
||||
"user_agent": userAgent,
|
||||
"timestamp": timestamp.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
return events, latest, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) loadValidatorStatus(ctx context.Context) ([]map[string]interface{}, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT r.address, COALESCE(r.roles, '{}'), COALESCE(oe.last_seen, r.updated_at, r.approved_at), r.track_level
|
||||
FROM operator_roles r
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MAX(timestamp) AS last_seen
|
||||
FROM operator_events
|
||||
WHERE operator_address = r.address
|
||||
) oe ON TRUE
|
||||
WHERE r.approved = TRUE AND r.track_level >= 4
|
||||
ORDER BY COALESCE(oe.last_seen, r.updated_at, r.approved_at) DESC NULLS LAST, r.address
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query validator status: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
validators := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var address string
|
||||
var roles []string
|
||||
var lastSeen time.Time
|
||||
var trackLevel int
|
||||
if err := rows.Scan(&address, &roles, &lastSeen, &trackLevel); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan validator row: %w", err)
|
||||
}
|
||||
|
||||
roleScope := "operator"
|
||||
if inferred := inferOperatorScope(roles); inferred != "" {
|
||||
roleScope = inferred
|
||||
}
|
||||
|
||||
row := map[string]interface{}{
|
||||
"address": address,
|
||||
"status": "active",
|
||||
"stake": nil,
|
||||
"uptime": nil,
|
||||
"last_block": nil,
|
||||
"track_level": trackLevel,
|
||||
"roles": roles,
|
||||
"role_scope": roleScope,
|
||||
}
|
||||
if !lastSeen.IsZero() {
|
||||
row["last_seen"] = lastSeen.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
validators = append(validators, row)
|
||||
}
|
||||
|
||||
return validators, rows.Err()
|
||||
}
|
||||
|
||||
type contractRegistryEntry struct {
|
||||
Address string
|
||||
ChainID int
|
||||
Name string
|
||||
Type string
|
||||
}
|
||||
|
||||
func (s *Server) loadContractStatus(ctx context.Context, chainID int, typeFilter string) ([]map[string]interface{}, error) {
|
||||
type contractRow struct {
|
||||
Name string
|
||||
Status string
|
||||
Compiler string
|
||||
LastVerified *time.Time
|
||||
}
|
||||
|
||||
dbRows := map[string]contractRow{}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT LOWER(address), COALESCE(name, ''), verification_status, compiler_version, verified_at
|
||||
FROM contracts
|
||||
WHERE chain_id = $1
|
||||
`, chainID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query contracts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var address string
|
||||
var row contractRow
|
||||
if err := rows.Scan(&address, &row.Name, &row.Status, &row.Compiler, &row.LastVerified); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan contract row: %w", err)
|
||||
}
|
||||
dbRows[address] = row
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registryEntries, err := loadContractRegistry(chainID)
|
||||
if err != nil {
|
||||
registryEntries = nil
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
contracts := make([]map[string]interface{}, 0, len(registryEntries)+len(dbRows))
|
||||
appendRow := func(address, name, contractType, status, version string, lastVerified *time.Time) {
|
||||
if typeFilter != "" && contractType != typeFilter {
|
||||
return
|
||||
}
|
||||
row := map[string]interface{}{
|
||||
"address": address,
|
||||
"chain_id": chainID,
|
||||
"type": contractType,
|
||||
"name": name,
|
||||
"status": status,
|
||||
}
|
||||
if version != "" {
|
||||
row["version"] = version
|
||||
}
|
||||
if lastVerified != nil && !lastVerified.IsZero() {
|
||||
row["last_verified"] = lastVerified.UTC().Format(time.RFC3339)
|
||||
}
|
||||
contracts = append(contracts, row)
|
||||
seen[address] = true
|
||||
}
|
||||
|
||||
for _, entry := range registryEntries {
|
||||
lowerAddress := strings.ToLower(entry.Address)
|
||||
dbRow, ok := dbRows[lowerAddress]
|
||||
status := "registry_only"
|
||||
version := ""
|
||||
name := entry.Name
|
||||
var lastVerified *time.Time
|
||||
if ok {
|
||||
if dbRow.Name != "" {
|
||||
name = dbRow.Name
|
||||
}
|
||||
status = dbRow.Status
|
||||
version = dbRow.Compiler
|
||||
lastVerified = dbRow.LastVerified
|
||||
}
|
||||
appendRow(lowerAddress, name, entry.Type, status, version, lastVerified)
|
||||
}
|
||||
|
||||
for address, row := range dbRows {
|
||||
if seen[address] {
|
||||
continue
|
||||
}
|
||||
contractType := inferContractType(row.Name)
|
||||
appendRow(address, fallbackString(row.Name, address), contractType, row.Status, row.Compiler, row.LastVerified)
|
||||
}
|
||||
|
||||
sort.Slice(contracts, func(i, j int) bool {
|
||||
left, _ := contracts[i]["name"].(string)
|
||||
right, _ := contracts[j]["name"].(string)
|
||||
if left == right {
|
||||
return contracts[i]["address"].(string) < contracts[j]["address"].(string)
|
||||
}
|
||||
return left < right
|
||||
})
|
||||
|
||||
return contracts, nil
|
||||
}
|
||||
|
||||
func (s *Server) loadProtocolState(ctx context.Context) (map[string]interface{}, error) {
|
||||
var totalBridged string
|
||||
var activeBridges int
|
||||
var lastBridgeAt *time.Time
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE(SUM(amount)::text, '0'),
|
||||
COUNT(DISTINCT CONCAT(chain_from, ':', chain_to)),
|
||||
MAX(timestamp)
|
||||
FROM analytics_bridge_history
|
||||
WHERE status ILIKE 'success%'
|
||||
AND (chain_from = $1 OR chain_to = $1)
|
||||
`, s.chainID).Scan(&totalBridged, &activeBridges, &lastBridgeAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query protocol state: %w", err)
|
||||
}
|
||||
|
||||
registryEntries, _ := loadContractRegistry(s.chainID)
|
||||
bridgeEnabled := activeBridges > 0
|
||||
if !bridgeEnabled {
|
||||
for _, entry := range registryEntries {
|
||||
if entry.Type == "bridge" {
|
||||
bridgeEnabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocolVersion := strings.TrimSpace(os.Getenv("EXPLORER_PROTOCOL_VERSION"))
|
||||
if protocolVersion == "" {
|
||||
protocolVersion = strings.TrimSpace(os.Getenv("PROTOCOL_VERSION"))
|
||||
}
|
||||
if protocolVersion == "" {
|
||||
protocolVersion = "unknown"
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"protocol_version": protocolVersion,
|
||||
"chain_id": s.chainID,
|
||||
"config": map[string]interface{}{
|
||||
"bridge_enabled": bridgeEnabled,
|
||||
"max_transfer_amount": nil,
|
||||
"max_transfer_amount_unavailable": true,
|
||||
"fee_structure": nil,
|
||||
},
|
||||
"state": map[string]interface{}{
|
||||
"total_locked": nil,
|
||||
"total_locked_unavailable": true,
|
||||
"total_bridged": totalBridged,
|
||||
"active_bridges": activeBridges,
|
||||
},
|
||||
}
|
||||
|
||||
if lastBridgeAt != nil && !lastBridgeAt.IsZero() {
|
||||
data["last_updated"] = lastBridgeAt.UTC().Format(time.RFC3339)
|
||||
} else {
|
||||
data["last_updated"] = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func loadContractRegistry(chainID int) ([]contractRegistryEntry, error) {
|
||||
chainKey := strconv.Itoa(chainID)
|
||||
candidates := []string{}
|
||||
if env := strings.TrimSpace(os.Getenv("SMART_CONTRACTS_MASTER_JSON")); env != "" {
|
||||
candidates = append(candidates, env)
|
||||
}
|
||||
candidates = append(candidates,
|
||||
"config/smart-contracts-master.json",
|
||||
"../config/smart-contracts-master.json",
|
||||
"../../config/smart-contracts-master.json",
|
||||
filepath.Join("explorer-monorepo", "config", "smart-contracts-master.json"),
|
||||
)
|
||||
|
||||
var raw []byte
|
||||
for _, candidate := range candidates {
|
||||
if strings.TrimSpace(candidate) == "" {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(candidate)
|
||||
if err == nil && len(body) > 0 {
|
||||
raw = body
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("smart-contracts-master.json not found")
|
||||
}
|
||||
|
||||
var root struct {
|
||||
Chains map[string]struct {
|
||||
Contracts map[string]string `json:"contracts"`
|
||||
} `json:"chains"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &root); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse contract registry: %w", err)
|
||||
}
|
||||
|
||||
chain, ok := root.Chains[chainKey]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
entries := make([]contractRegistryEntry, 0, len(chain.Contracts))
|
||||
for name, address := range chain.Contracts {
|
||||
addr := strings.TrimSpace(address)
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, contractRegistryEntry{
|
||||
Address: addr,
|
||||
ChainID: chainID,
|
||||
Name: name,
|
||||
Type: inferContractType(name),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].Name == entries[j].Name {
|
||||
return strings.ToLower(entries[i].Address) < strings.ToLower(entries[j].Address)
|
||||
}
|
||||
return entries[i].Name < entries[j].Name
|
||||
})
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func inferOperatorScope(roles []string) string {
|
||||
for _, role := range roles {
|
||||
lower := strings.ToLower(role)
|
||||
switch {
|
||||
case strings.Contains(lower, "validator"):
|
||||
return "validator"
|
||||
case strings.Contains(lower, "sequencer"):
|
||||
return "sequencer"
|
||||
case strings.Contains(lower, "bridge"):
|
||||
return "bridge"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func inferContractType(name string) string {
|
||||
lower := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.Contains(lower, "bridge"):
|
||||
return "bridge"
|
||||
case strings.Contains(lower, "router"):
|
||||
return "router"
|
||||
case strings.Contains(lower, "pool"), strings.Contains(lower, "pmm"), strings.Contains(lower, "amm"):
|
||||
return "liquidity"
|
||||
case strings.Contains(lower, "oracle"):
|
||||
return "oracle"
|
||||
case strings.Contains(lower, "vault"):
|
||||
return "vault"
|
||||
case strings.Contains(lower, "token"), strings.Contains(lower, "weth"), strings.Contains(lower, "cw"), strings.Contains(lower, "usdt"), strings.Contains(lower, "usdc"):
|
||||
return "token"
|
||||
default:
|
||||
return "contract"
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackString(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
63
backend/api/track4/endpoints_test.go
Normal file
63
backend/api/track4/endpoints_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package track4
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleValidatorsRejectsNonGET(t *testing.T) {
|
||||
server := NewServer(nil, 138)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/validators", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.HandleValidators(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405 for non-GET validators request, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleContractsRequiresDatabase(t *testing.T) {
|
||||
server := NewServer(nil, 138)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/track4/operator/contracts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.HandleContracts(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503 when track4 DB is missing, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadContractRegistryReadsConfiguredFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
registryPath := filepath.Join(tempDir, "smart-contracts-master.json")
|
||||
err := os.WriteFile(registryPath, []byte(`{
|
||||
"chains": {
|
||||
"138": {
|
||||
"contracts": {
|
||||
"CCIP_ROUTER": "0x1111111111111111111111111111111111111111",
|
||||
"CHAIN138_BRIDGE": "0x2222222222222222222222222222222222222222"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write temp registry: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("SMART_CONTRACTS_MASTER_JSON", registryPath)
|
||||
entries, err := loadContractRegistry(138)
|
||||
if err != nil {
|
||||
t.Fatalf("loadContractRegistry returned error: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 registry entries, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Type == "" || entries[1].Type == "" {
|
||||
t.Fatal("expected contract types to be inferred")
|
||||
}
|
||||
}
|
||||
209
backend/api/track4/operator_scripts.go
Normal file
209
backend/api/track4/operator_scripts.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package track4
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type runScriptRequest struct {
|
||||
Script string `json:"script"`
|
||||
Args []string `json:"args"`
|
||||
}
|
||||
|
||||
// HandleRunScript handles POST /api/v1/track4/operator/run-script
|
||||
// Requires Track 4 auth, IP whitelist, OPERATOR_SCRIPTS_ROOT, and OPERATOR_SCRIPT_ALLOWLIST.
|
||||
func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
operatorAddr, _ := r.Context().Value("user_address").(string)
|
||||
if operatorAddr == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Operator address required")
|
||||
return
|
||||
}
|
||||
ipAddr := clientIPAddress(r)
|
||||
if whitelisted, _ := s.roleMgr.IsIPWhitelisted(r.Context(), operatorAddr, ipAddr); !whitelisted {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "IP address not whitelisted")
|
||||
return
|
||||
}
|
||||
|
||||
root := strings.TrimSpace(os.Getenv("OPERATOR_SCRIPTS_ROOT"))
|
||||
if root == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "OPERATOR_SCRIPTS_ROOT not configured")
|
||||
return
|
||||
}
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil || rootAbs == "" {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "invalid OPERATOR_SCRIPTS_ROOT")
|
||||
return
|
||||
}
|
||||
|
||||
allowRaw := strings.TrimSpace(os.Getenv("OPERATOR_SCRIPT_ALLOWLIST"))
|
||||
if allowRaw == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "OPERATOR_SCRIPT_ALLOWLIST not configured")
|
||||
return
|
||||
}
|
||||
var allow []string
|
||||
for _, p := range strings.Split(allowRaw, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
allow = append(allow, p)
|
||||
}
|
||||
}
|
||||
if len(allow) == 0 {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "OPERATOR_SCRIPT_ALLOWLIST empty")
|
||||
return
|
||||
}
|
||||
|
||||
var reqBody runScriptRequest
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&reqBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
script := strings.TrimSpace(reqBody.Script)
|
||||
if script == "" || strings.Contains(script, "..") {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid script path")
|
||||
return
|
||||
}
|
||||
if len(reqBody.Args) > 24 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "too many args (max 24)")
|
||||
return
|
||||
}
|
||||
for _, a := range reqBody.Args {
|
||||
if strings.Contains(a, "\x00") {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid arg")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
candidate := filepath.Join(rootAbs, filepath.Clean(script))
|
||||
if rel, err := filepath.Rel(rootAbs, candidate); err != nil || strings.HasPrefix(rel, "..") {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "script outside OPERATOR_SCRIPTS_ROOT")
|
||||
return
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(rootAbs, candidate)
|
||||
allowed := false
|
||||
base := filepath.Base(relPath)
|
||||
for _, a := range allow {
|
||||
if a == relPath || a == base || filepath.Clean(a) == relPath {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "script not in OPERATOR_SCRIPT_ALLOWLIST")
|
||||
return
|
||||
}
|
||||
|
||||
st, err := os.Stat(candidate)
|
||||
if err != nil || st.IsDir() {
|
||||
writeError(w, http.StatusNotFound, "not_found", "script not found")
|
||||
return
|
||||
}
|
||||
isShell := strings.HasSuffix(strings.ToLower(candidate), ".sh")
|
||||
if !isShell && st.Mode()&0o111 == 0 {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "refusing to run non-executable file (use .sh or chmod +x)")
|
||||
return
|
||||
}
|
||||
|
||||
timeout := 120 * time.Second
|
||||
if v := strings.TrimSpace(os.Getenv("OPERATOR_SCRIPT_TIMEOUT_SEC")); v != "" {
|
||||
if sec, err := parsePositiveInt(v); err == nil && sec > 0 && sec < 600 {
|
||||
timeout = time.Duration(sec) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "operator_script_run", &s.chainID, operatorAddr, "operator/run-script", "execute",
|
||||
map[string]interface{}{
|
||||
"script": relPath,
|
||||
"argc": len(reqBody.Args),
|
||||
}, ipAddr, r.UserAgent())
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if isShell {
|
||||
args := append([]string{candidate}, reqBody.Args...)
|
||||
cmd = exec.CommandContext(ctx, "/bin/bash", args...)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, candidate, reqBody.Args...)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
runErr := cmd.Run()
|
||||
|
||||
exit := 0
|
||||
timedOut := errors.Is(ctx.Err(), context.DeadlineExceeded)
|
||||
if runErr != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(runErr, &ee) {
|
||||
exit = ee.ExitCode()
|
||||
} else if timedOut {
|
||||
exit = -1
|
||||
} else {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", runErr.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
status := "ok"
|
||||
if timedOut {
|
||||
status = "timed_out"
|
||||
} else if exit != 0 {
|
||||
status = "nonzero_exit"
|
||||
}
|
||||
s.roleMgr.LogOperatorEvent(r.Context(), "operator_script_result", &s.chainID, operatorAddr, "operator/run-script", status,
|
||||
map[string]interface{}{
|
||||
"script": relPath,
|
||||
"argc": len(reqBody.Args),
|
||||
"exit_code": exit,
|
||||
"timed_out": timedOut,
|
||||
"stdout_bytes": stdout.Len(),
|
||||
"stderr_bytes": stderr.Len(),
|
||||
}, ipAddr, r.UserAgent())
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"script": relPath,
|
||||
"exit_code": exit,
|
||||
"stdout": strings.TrimSpace(stdout.String()),
|
||||
"stderr": strings.TrimSpace(stderr.String()),
|
||||
"timed_out": timedOut,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func parsePositiveInt(s string) (int, error) {
|
||||
var n int
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, errors.New("not digits")
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
if n > 1e6 {
|
||||
return 0, errors.New("too large")
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, errors.New("zero")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
88
backend/api/track4/operator_scripts_test.go
Normal file
88
backend/api/track4/operator_scripts_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package track4
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubRoleManager struct {
|
||||
allowed bool
|
||||
gotIP string
|
||||
logs int
|
||||
}
|
||||
|
||||
func (s *stubRoleManager) IsIPWhitelisted(_ context.Context, _ string, ipAddress string) (bool, error) {
|
||||
s.gotIP = ipAddress
|
||||
return s.allowed, nil
|
||||
}
|
||||
|
||||
func (s *stubRoleManager) LogOperatorEvent(_ context.Context, _ string, _ *int, _ string, _ string, _ string, _ map[string]interface{}, _ string, _ string) error {
|
||||
s.logs++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHandleRunScriptUsesForwardedClientIPAndRunsAllowlistedScript(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
scriptPath := filepath.Join(root, "echo.sh")
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte("#!/usr/bin/env bash\necho hello \"$1\"\n"), 0o644))
|
||||
|
||||
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
|
||||
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "echo.sh")
|
||||
t.Setenv("OPERATOR_SCRIPT_TIMEOUT_SEC", "30")
|
||||
t.Setenv("TRUST_PROXY_CIDRS", "10.0.0.0/8")
|
||||
|
||||
roleMgr := &stubRoleManager{allowed: true}
|
||||
s := &Server{roleMgr: roleMgr, chainID: 138}
|
||||
|
||||
reqBody := []byte(`{"script":"echo.sh","args":["world"]}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader(reqBody))
|
||||
req = req.WithContext(context.WithValue(req.Context(), "user_address", "0x4A666F96fC8764181194447A7dFdb7d471b301C8"))
|
||||
req.RemoteAddr = "10.0.0.10:8080"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.10")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.HandleRunScript(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, "203.0.113.9", roleMgr.gotIP)
|
||||
require.Equal(t, 2, roleMgr.logs)
|
||||
|
||||
var out struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
|
||||
require.Equal(t, "echo.sh", out.Data["script"])
|
||||
require.Equal(t, float64(0), out.Data["exit_code"])
|
||||
require.Equal(t, "hello world", out.Data["stdout"])
|
||||
require.Equal(t, false, out.Data["timed_out"])
|
||||
}
|
||||
|
||||
func TestHandleRunScriptRejectsNonAllowlistedScript(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "allowed.sh"), []byte("#!/usr/bin/env bash\necho ok\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "blocked.sh"), []byte("#!/usr/bin/env bash\necho blocked\n"), 0o644))
|
||||
|
||||
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
|
||||
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "allowed.sh")
|
||||
|
||||
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"blocked.sh"}`)))
|
||||
req = req.WithContext(context.WithValue(req.Context(), "user_address", "0x4A666F96fC8764181194447A7dFdb7d471b301C8"))
|
||||
req.RemoteAddr = "127.0.0.1:9999"
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.HandleRunScript(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
require.Contains(t, w.Body.String(), "script not in OPERATOR_SCRIPT_ALLOWLIST")
|
||||
}
|
||||
17
backend/api/track4/request_ip.go
Normal file
17
backend/api/track4/request_ip.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package track4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
httpmiddleware "github.com/explorer/backend/libs/go-http-middleware"
|
||||
)
|
||||
|
||||
type roleManager interface {
|
||||
IsIPWhitelisted(ctx context.Context, operatorAddress string, ipAddress string) (bool, error)
|
||||
LogOperatorEvent(ctx context.Context, eventType string, chainID *int, operatorAddress string, targetResource string, action string, details map[string]interface{}, ipAddress string, userAgent string) error
|
||||
}
|
||||
|
||||
func clientIPAddress(r *http.Request) string {
|
||||
return httpmiddleware.ClientIP(r)
|
||||
}
|
||||
@@ -3,7 +3,11 @@ package websocket
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -12,10 +16,62 @@ import (
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Allow all origins in development
|
||||
return websocketOriginAllowed(r)
|
||||
},
|
||||
}
|
||||
|
||||
func websocketOriginAllowed(r *http.Request) bool {
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
allowedOrigins := splitAllowedOrigins(os.Getenv("WEBSOCKET_ALLOWED_ORIGINS"))
|
||||
if len(allowedOrigins) == 0 {
|
||||
return sameOriginHost(origin, r.Host)
|
||||
}
|
||||
|
||||
for _, allowed := range allowedOrigins {
|
||||
if allowed == "*" || strings.EqualFold(allowed, origin) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func splitAllowedOrigins(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
origins := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
origins = append(origins, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return origins
|
||||
}
|
||||
|
||||
func sameOriginHost(origin, requestHost string) bool {
|
||||
parsedOrigin, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
originHost := parsedOrigin.Hostname()
|
||||
requestHostname := requestHost
|
||||
if host, _, err := net.SplitHostPort(requestHost); err == nil {
|
||||
requestHostname = host
|
||||
}
|
||||
|
||||
return strings.EqualFold(originHost, requestHostname)
|
||||
}
|
||||
|
||||
// Server represents the WebSocket server
|
||||
type Server struct {
|
||||
clients map[*Client]bool
|
||||
@@ -27,9 +83,9 @@ type Server struct {
|
||||
|
||||
// Client represents a WebSocket client
|
||||
type Client struct {
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
server *Server
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
server *Server
|
||||
subscriptions map[string]bool
|
||||
}
|
||||
|
||||
@@ -50,8 +106,9 @@ func (s *Server) Start() {
|
||||
case client := <-s.register:
|
||||
s.mu.Lock()
|
||||
s.clients[client] = true
|
||||
count := len(s.clients)
|
||||
s.mu.Unlock()
|
||||
log.Printf("Client connected. Total clients: %d", len(s.clients))
|
||||
log.Printf("Client connected. Total clients: %d", count)
|
||||
|
||||
case client := <-s.unregister:
|
||||
s.mu.Lock()
|
||||
@@ -59,11 +116,12 @@ func (s *Server) Start() {
|
||||
delete(s.clients, client)
|
||||
close(client.send)
|
||||
}
|
||||
count := len(s.clients)
|
||||
s.mu.Unlock()
|
||||
log.Printf("Client disconnected. Total clients: %d", len(s.clients))
|
||||
log.Printf("Client disconnected. Total clients: %d", count)
|
||||
|
||||
case message := <-s.broadcast:
|
||||
s.mu.RLock()
|
||||
s.mu.Lock()
|
||||
for client := range s.clients {
|
||||
select {
|
||||
case client.send <- message:
|
||||
@@ -72,7 +130,7 @@ func (s *Server) Start() {
|
||||
delete(s.clients, client)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,7 +247,7 @@ func (c *Client) handleMessage(msg map[string]interface{}) {
|
||||
channel, _ := msg["channel"].(string)
|
||||
c.subscriptions[channel] = true
|
||||
c.sendMessage(map[string]interface{}{
|
||||
"type": "subscribed",
|
||||
"type": "subscribed",
|
||||
"channel": channel,
|
||||
})
|
||||
|
||||
@@ -197,13 +255,13 @@ func (c *Client) handleMessage(msg map[string]interface{}) {
|
||||
channel, _ := msg["channel"].(string)
|
||||
delete(c.subscriptions, channel)
|
||||
c.sendMessage(map[string]interface{}{
|
||||
"type": "unsubscribed",
|
||||
"type": "unsubscribed",
|
||||
"channel": channel,
|
||||
})
|
||||
|
||||
case "ping":
|
||||
c.sendMessage(map[string]interface{}{
|
||||
"type": "pong",
|
||||
"type": "pong",
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
@@ -222,4 +280,3 @@ func (c *Client) sendMessage(msg map[string]interface{}) {
|
||||
close(c.send)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
42
backend/api/websocket/server_test.go
Normal file
42
backend/api/websocket/server_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebsocketOriginAllowedDefaultsToSameHost(t *testing.T) {
|
||||
t.Setenv("WEBSOCKET_ALLOWED_ORIGINS", "")
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/ws", nil)
|
||||
req.Host = "example.com:8080"
|
||||
req.Header.Set("Origin", "https://example.com")
|
||||
|
||||
if !websocketOriginAllowed(req) {
|
||||
t.Fatal("expected same-host websocket origin to be allowed by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebsocketOriginAllowedRejectsCrossOriginByDefault(t *testing.T) {
|
||||
t.Setenv("WEBSOCKET_ALLOWED_ORIGINS", "")
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/ws", nil)
|
||||
req.Host = "example.com:8080"
|
||||
req.Header.Set("Origin", "https://attacker.example")
|
||||
|
||||
if websocketOriginAllowed(req) {
|
||||
t.Fatal("expected cross-origin websocket request to be rejected by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebsocketOriginAllowedHonorsExplicitAllowlist(t *testing.T) {
|
||||
t.Setenv("WEBSOCKET_ALLOWED_ORIGINS", "https://app.example, https://ops.example")
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/ws", nil)
|
||||
req.Host = "example.com:8080"
|
||||
req.Header.Set("Origin", "https://ops.example")
|
||||
|
||||
if !websocketOriginAllowed(req) {
|
||||
t.Fatal("expected allowlisted websocket origin to be accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user