diff --git a/.gitignore b/.gitignore index b5f76c4..11f8f35 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules/ vendor/ +# Optional local Mermaid (see frontend/public/thirdparty/README.md) +frontend/public/thirdparty/mermaid.min.js + # Build outputs dist/ build/ diff --git a/README.md b/README.md index ee87de8..cf42fce 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,12 @@ If the script doesn't work, see `START_HERE.md` for step-by-step manual commands ## Frontend -- **Production (canonical):** The **SPA** (`frontend/public/index.html`) is what is deployed and served at **https://explorer.d-bis.org** (VMID 5000). -- **Next.js app** in `frontend/src/` is for **local dev and build validation only**; it is not deployed to production. -- **Deploy frontend only:** `./scripts/deploy-frontend-to-vmid5000.sh` (from repo root; copies `index.html` and assets to `/var/www/html/`) +- **Production (canonical target):** the current **Next.js standalone frontend** in `frontend/src/`, built from `frontend/` with `npm run build` and deployed to VMID 5000 as a Node service behind nginx. +- **Canonical deploy script:** `./scripts/deploy-next-frontend-to-vmid5000.sh` +- **Canonical nginx wiring:** keep `/api`, `/api/config/*`, `/explorer-api/*`, `/token-aggregation/api/v1/*`, `/snap/`, and `/health`; proxy `/` and `/_next/` to the frontend service using `deployment/common/nginx-next-frontend-proxy.conf`. +- **Legacy fallback only:** the static SPA (`frontend/public/index.html` + `explorer-spa.js`) remains in-repo for compatibility, but it is no longer the preferred deployment target. +- **Architecture command center:** `frontend/public/chain138-command-center.html` — tabbed Mermaid topology (Chain 138 hub, network, stack, flows, cross-chain, cW Mainnet, off-chain, integrations). Linked from the SPA **More → Explore → Visual Command Center**. +- **Legacy static deploy:** `./scripts/deploy-frontend-to-vmid5000.sh` (copies `index.html` and assets to `/var/www/html/`) - **Frontend review & tasks:** [frontend/FRONTEND_REVIEW.md](frontend/FRONTEND_REVIEW.md), [frontend/FRONTEND_TASKS_AND_REVIEW.md](frontend/FRONTEND_TASKS_AND_REVIEW.md) ## Documentation diff --git a/backend/analytics/bridge_analytics.go b/backend/analytics/bridge_analytics.go index d893383..12f690d 100644 --- a/backend/analytics/bridge_analytics.go +++ b/backend/analytics/bridge_analytics.go @@ -3,6 +3,7 @@ package analytics import ( "context" "fmt" + "strings" "time" "github.com/jackc/pgx/v5/pgxpool" @@ -28,60 +29,64 @@ type BridgeStats struct { // ChainStats represents chain statistics type ChainStats struct { - Outbound int - Inbound int - VolumeOut string - VolumeIn string + Outbound int + Inbound int + VolumeOut string + VolumeIn string } // TokenStats represents token statistics type TokenStats struct { - Token string - Symbol string - Transfers int - Volume string + Token string + Symbol string + Transfers int + Volume string } // GetBridgeStats gets bridge statistics func (ba *BridgeAnalytics) GetBridgeStats(ctx context.Context, chainFrom, chainTo *int, startDate, endDate *time.Time) (*BridgeStats, error) { - query := ` - SELECT - COUNT(*) as transfers_24h, - SUM(amount) as volume_24h - FROM analytics_bridge_history - WHERE timestamp >= NOW() - INTERVAL '24 hours' - ` - + clauses := []string{"timestamp >= NOW() - INTERVAL '24 hours'"} args := []interface{}{} argIndex := 1 if chainFrom != nil { - query += fmt.Sprintf(" AND chain_from = $%d", argIndex) + clauses = append(clauses, fmt.Sprintf("chain_from = $%d", argIndex)) args = append(args, *chainFrom) argIndex++ } if chainTo != nil { - query += fmt.Sprintf(" AND chain_to = $%d", argIndex) + clauses = append(clauses, fmt.Sprintf("chain_to = $%d", argIndex)) args = append(args, *chainTo) argIndex++ } if startDate != nil { - query += fmt.Sprintf(" AND timestamp >= $%d", argIndex) + clauses = append(clauses, fmt.Sprintf("timestamp >= $%d", argIndex)) args = append(args, *startDate) argIndex++ } if endDate != nil { - query += fmt.Sprintf(" AND timestamp <= $%d", argIndex) + clauses = append(clauses, fmt.Sprintf("timestamp <= $%d", argIndex)) args = append(args, *endDate) argIndex++ } + filteredCTE := fmt.Sprintf(` + WITH filtered AS ( + SELECT chain_from, chain_to, token_contract, amount + FROM analytics_bridge_history + WHERE %s + ) + `, strings.Join(clauses, " AND ")) + var transfers24h int var volume24h string - err := ba.db.QueryRow(ctx, query, args...).Scan(&transfers24h, &volume24h) + err := ba.db.QueryRow(ctx, filteredCTE+` + SELECT COUNT(*) as transfers_24h, COALESCE(SUM(amount)::text, '0') as volume_24h + FROM filtered + `, args...).Scan(&transfers24h, &volume24h) if err != nil { return nil, fmt.Errorf("failed to get bridge stats: %w", err) } @@ -93,21 +98,28 @@ func (ba *BridgeAnalytics) GetBridgeStats(ctx context.Context, chainFrom, chainT TopTokens: []TokenStats{}, } - // Get chain stats - chainQuery := ` - SELECT - chain_from, - COUNT(*) FILTER (WHERE chain_from = $1) as outbound, - COUNT(*) FILTER (WHERE chain_to = $1) as inbound, - SUM(amount) FILTER (WHERE chain_from = $1) as volume_out, - SUM(amount) FILTER (WHERE chain_to = $1) as volume_in - FROM analytics_bridge_history - WHERE (chain_from = $1 OR chain_to = $1) AND timestamp >= NOW() - INTERVAL '24 hours' - GROUP BY chain_from - ` + rows, err := ba.db.Query(ctx, filteredCTE+` + SELECT + chain_id, + SUM(outbound) as outbound, + SUM(inbound) as inbound, + COALESCE(SUM(volume_out)::text, '0') as volume_out, + COALESCE(SUM(volume_in)::text, '0') as volume_in + FROM ( + SELECT chain_from AS chain_id, 1 AS outbound, 0 AS inbound, amount AS volume_out, 0::numeric AS volume_in + FROM filtered + UNION ALL + SELECT chain_to AS chain_id, 0 AS outbound, 1 AS inbound, 0::numeric AS volume_out, amount AS volume_in + FROM filtered + ) chain_rollup + GROUP BY chain_id + ORDER BY chain_id + `, args...) + if err != nil { + return nil, fmt.Errorf("failed to get chain breakdown: %w", err) + } + defer rows.Close() - // Simplified - in production, iterate over all chains - rows, _ := ba.db.Query(ctx, chainQuery, 138) for rows.Next() { var chainID, outbound, inbound int var volumeOut, volumeIn string @@ -120,8 +132,30 @@ func (ba *BridgeAnalytics) GetBridgeStats(ctx context.Context, chainFrom, chainT } } } - rows.Close() + + tokenRows, err := ba.db.Query(ctx, filteredCTE+` + SELECT + token_contract, + COUNT(*) as transfers, + COALESCE(SUM(amount)::text, '0') as volume + FROM filtered + WHERE token_contract IS NOT NULL AND token_contract <> '' + GROUP BY token_contract + ORDER BY transfers DESC, volume DESC + LIMIT 10 + `, args...) + if err != nil { + return nil, fmt.Errorf("failed to get top bridge tokens: %w", err) + } + defer tokenRows.Close() + + for tokenRows.Next() { + var token TokenStats + if err := tokenRows.Scan(&token.Token, &token.Transfers, &token.Volume); err != nil { + continue + } + stats.TopTokens = append(stats.TopTokens, token) + } return stats, nil } - diff --git a/backend/analytics/token_distribution.go b/backend/analytics/token_distribution.go index 512f309..68070dc 100644 --- a/backend/analytics/token_distribution.go +++ b/backend/analytics/token_distribution.go @@ -3,13 +3,15 @@ package analytics import ( "context" "fmt" + "math" + "math/big" "github.com/jackc/pgx/v5/pgxpool" ) // TokenDistribution provides token distribution analytics type TokenDistribution struct { - db *pgxpool.Pool + db *pgxpool.Pool chainID int } @@ -23,12 +25,12 @@ func NewTokenDistribution(db *pgxpool.Pool, chainID int) *TokenDistribution { // DistributionStats represents token distribution statistics type DistributionStats struct { - Contract string - Symbol string - TotalSupply string - Holders int - Distribution map[string]string - TopHolders []HolderInfo + Contract string + Symbol string + TotalSupply string + Holders int + Distribution map[string]string + TopHolders []HolderInfo } // HolderInfo represents holder information @@ -76,13 +78,16 @@ func (td *TokenDistribution) GetTokenDistribution(ctx context.Context, contract defer rows.Close() topHolders := []HolderInfo{} + totalSupplyRat, ok := parseNumericString(totalSupply) + if !ok || totalSupplyRat.Sign() <= 0 { + totalSupplyRat = big.NewRat(0, 1) + } for rows.Next() { var holder HolderInfo if err := rows.Scan(&holder.Address, &holder.Balance); err != nil { continue } - // Calculate percentage (simplified) - holder.Percentage = "0.0" // TODO: Calculate from total supply + holder.Percentage = formatPercentage(holder.Balance, totalSupplyRat, 4) topHolders = append(topHolders, holder) } @@ -94,11 +99,132 @@ func (td *TokenDistribution) GetTokenDistribution(ctx context.Context, contract TopHolders: topHolders, } - // Calculate distribution metrics - stats.Distribution["top_10_percent"] = "0.0" // TODO: Calculate - stats.Distribution["top_1_percent"] = "0.0" // TODO: Calculate - stats.Distribution["gini_coefficient"] = "0.0" // TODO: Calculate + balances, err := td.loadHolderBalances(ctx, contract) + if err != nil { + return nil, fmt.Errorf("failed to compute holder metrics: %w", err) + } + + stats.Distribution["top_10_percent"] = concentrationPercent(balances, totalSupplyRat, 0.10) + stats.Distribution["top_1_percent"] = concentrationPercent(balances, totalSupplyRat, 0.01) + stats.Distribution["gini_coefficient"] = giniCoefficient(balances) return stats, nil } +func (td *TokenDistribution) loadHolderBalances(ctx context.Context, contract string) ([]*big.Rat, error) { + rows, err := td.db.Query(ctx, ` + SELECT balance + FROM token_balances + WHERE token_contract = $1 AND chain_id = $2 AND balance > 0 + ORDER BY balance DESC + `, contract, td.chainID) + if err != nil { + return nil, err + } + defer rows.Close() + + balances := make([]*big.Rat, 0) + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + continue + } + if balance, ok := parseNumericString(raw); ok && balance.Sign() > 0 { + balances = append(balances, balance) + } + } + + return balances, nil +} + +func parseNumericString(raw string) (*big.Rat, bool) { + value, ok := new(big.Rat).SetString(raw) + return value, ok +} + +func formatPercentage(raw string, total *big.Rat, decimals int) string { + value, ok := parseNumericString(raw) + if !ok { + return "0" + } + return formatRatioAsPercent(value, total, decimals) +} + +func concentrationPercent(balances []*big.Rat, total *big.Rat, percentile float64) string { + if len(balances) == 0 { + return "0" + } + + count := int(math.Ceil(float64(len(balances)) * percentile)) + if count < 1 { + count = 1 + } + if count > len(balances) { + count = len(balances) + } + + sum := new(big.Rat) + for i := 0; i < count; i++ { + sum.Add(sum, balances[i]) + } + + return formatRatioAsPercent(sum, total, 4) +} + +func formatRatioAsPercent(value, total *big.Rat, decimals int) string { + if value == nil || total == nil || total.Sign() <= 0 { + return "0" + } + + percent := new(big.Rat).Quo(value, total) + percent.Mul(percent, big.NewRat(100, 1)) + return formatRat(percent, decimals) +} + +func giniCoefficient(balances []*big.Rat) string { + if len(balances) == 0 { + return "0" + } + + total := new(big.Rat) + for _, balance := range balances { + total.Add(total, balance) + } + if total.Sign() <= 0 { + return "0" + } + + weightedSum := new(big.Rat) + n := len(balances) + for i := range balances { + index := n - i + weighted := new(big.Rat).Mul(balances[i], big.NewRat(int64(index), 1)) + weightedSum.Add(weightedSum, weighted) + } + + nRat := big.NewRat(int64(n), 1) + numerator := new(big.Rat).Mul(weightedSum, big.NewRat(2, 1)) + denominator := new(big.Rat).Mul(nRat, total) + gini := new(big.Rat).Quo(numerator, denominator) + gini.Sub(gini, new(big.Rat).Quo(big.NewRat(int64(n+1), 1), nRat)) + + if gini.Sign() < 0 { + return "0" + } + + return formatRat(gini, 6) +} + +func formatRat(value *big.Rat, decimals int) string { + if value == nil { + return "0" + } + text := new(big.Float).SetPrec(256).SetRat(value).Text('f', decimals) + for len(text) > 1 && text[len(text)-1] == '0' { + text = text[:len(text)-1] + } + if len(text) > 1 && text[len(text)-1] == '.' { + text = text[:len(text)-1] + } + return text +} diff --git a/backend/api/gateway/gateway.go b/backend/api/gateway/gateway.go index a8fa25c..dd9841b 100644 --- a/backend/api/gateway/gateway.go +++ b/backend/api/gateway/gateway.go @@ -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 +} diff --git a/backend/api/gateway/gateway_test.go b/backend/api/gateway/gateway_test.go new file mode 100644 index 0000000..43d602f --- /dev/null +++ b/backend/api/gateway/gateway_test.go @@ -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") + } +} diff --git a/backend/api/rest/.env.example b/backend/api/rest/.env.example new file mode 100644 index 0000000..f7af21e --- /dev/null +++ b/backend/api/rest/.env.example @@ -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 diff --git a/backend/api/rest/README.md b/backend/api/rest/README.md index eefd927..8b06ecc 100644 --- a/backend/api/rest/README.md +++ b/backend/api/rest/README.md @@ -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. diff --git a/backend/api/rest/addresses.go b/backend/api/rest/addresses.go index c03d589..cfce12c 100644 --- a/backend/api/rest/addresses.go +++ b/backend/api/rest/addresses.go @@ -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 { diff --git a/backend/api/rest/addresses_internal_test.go b/backend/api/rest/addresses_internal_test.go new file mode 100644 index 0000000..811c7de --- /dev/null +++ b/backend/api/rest/addresses_internal_test.go @@ -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) + } +} diff --git a/backend/api/rest/addresses_list.go b/backend/api/rest/addresses_list.go index 03032eb..bd4ee57 100644 --- a/backend/api/rest/addresses_list.go +++ b/backend/api/rest/addresses_list.go @@ -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 ) diff --git a/backend/api/rest/ai.go b/backend/api/rest/ai.go index 5860ca3..ae38609 100644 --- a/backend/api/rest/ai.go +++ b/backend/api/rest/ai.go @@ -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", diff --git a/backend/api/rest/ai_runtime.go b/backend/api/rest/ai_runtime.go index 10ec1e9..51a565b 100644 --- a/backend/api/rest/ai_runtime.go +++ b/backend/api/rest/ai_runtime.go @@ -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) { diff --git a/backend/api/rest/api_test.go b/backend/api/rest/api_test.go index 7b768a9..4d95442 100644 --- a/backend/api/rest/api_test.go +++ b/backend/api/rest/api_test.go @@ -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) diff --git a/backend/api/rest/auth.go b/backend/api/rest/auth.go index f793e4a..0e8af9c 100644 --- a/backend/api/rest/auth.go +++ b/backend/api/rest/auth.go @@ -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) } - diff --git a/backend/api/rest/blocks.go b/backend/api/rest/blocks.go index d1dbd94..ea91192 100644 --- a/backend/api/rest/blocks.go +++ b/backend/api/rest/blocks.go @@ -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) diff --git a/backend/api/rest/config.go b/backend/api/rest/config.go index 47eb201..69589b9 100644 --- a/backend/api/rest/config.go +++ b/backend/api/rest/config.go @@ -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") } diff --git a/backend/api/rest/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json b/backend/api/rest/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json index b15c032..1adf37f 100644 --- a/backend/api/rest/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json +++ b/backend/api/rest/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json @@ -3,10 +3,10 @@ "version": { "major": 1, "minor": 3, - "patch": 2 + "patch": 4 }, - "timestamp": "2026-03-26T09:17:26.866Z", - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "timestamp": "2026-04-04T04:23:46.263Z", + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/chain138-list.svg", "keywords": [ "chain138", "defi-oracle-meta", @@ -15,29 +15,37 @@ "wallet" ], "tags": { - "stablecoin": { - "name": "Stablecoin", - "description": "Fiat-pegged and fiat-mirrored assets published for explorer and wallet discovery." - }, "defi": { "name": "DeFi", - "description": "Assets surfaced across the explorer and DEX route matrix." + "description": "Decentralized Finance tokens" }, - "compliant": { - "name": "Compliant", - "description": "Compliance-oriented assets deployed on Chain 138." - }, - "oracle": { - "name": "Oracle", - "description": "Oracle or oracle-adjacent assets and price feed entries." + "bridge": { + "name": "Bridge", + "description": "Tokens bridged to other chains such as Truth Network" }, "wrapped": { "name": "Wrapped", - "description": "Wrapped representations of native or bridged assets." + "description": "Wrapped tokens representing native assets" }, - "ccip": { - "name": "CCIP", - "description": "Assets related to CCIP and bridge infrastructure." + "oracle": { + "name": "Oracle", + "description": "Oracle price feed tokens" + }, + "price-feed": { + "name": "Price Feed", + "description": "Price feed oracle tokens" + }, + "stablecoin": { + "name": "Stablecoin", + "description": "Stable value tokens pegged to fiat" + }, + "compliant": { + "name": "Compliant", + "description": "Regulatory compliant assets" + }, + "iso4217w": { + "name": "ISO4217W", + "description": "ISO 4217 compliant wrapped fiat tokens" } }, "extensions": { @@ -47,44 +55,198 @@ }, "tokens": [ { - "chainId": 138, - "address": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6", + "chainId": 1, + "address": "0x5020Db641B3Fc0dAbBc0c688C845bc4E3699f35F", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0x209FE32fe7B541751D190ae4e50cd005DcF8EDb4", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0x0F91C5E6Ddd46403746aAC970D05d70FFe404780", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0xD4aEAa8cD3fB41Dc8437FaC7639B6d91B60A5e8d", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0x855d74FFB6CF75721a9bAbc8B2ed35c8119241dC", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0xc074007dc0Bfb384B1cf6426a56287Ed23FE4D52", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0x1dDF9970F01c76A692Fdba2706203E6f16e0C46F", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0x07EEd0D7dD40984e47B9D3a3bdded1c536435582", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0xaF5017d0163ecb99D9B5D94e3b4D7b09Af44D8AE", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0x572Be0fa8CA0534d642A567CEDb398B771D8a715", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0xACE1DBF857549a11aF1322e1f91F2F64b029c906", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + "name": "Dai Stablecoin", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 1, + "address": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419", "name": "ETH/USD Price Feed", "symbol": "ETH-USD", "decimals": 8, - "logoURI": "https://ipfs.io/ipfs/QmPZuycjyJEe2otREuQ5HirvPJ8X6Yc6MBtwz1VhdD79pY", + "logoURI": "https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png", "tags": [ "oracle", "price-feed" ] }, { - "chainId": 138, - "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - "name": "Wrapped Ether", - "symbol": "WETH", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "defi", - "wrapped" - ] - }, - { - "chainId": 138, - "address": "0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9F", - "name": "Wrapped Ether v10", - "symbol": "WETH10", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/QmanDFPHxnbKd6SSNzzXHf9GbpL9dLXSphxDZSPPYE6ds4", - "tags": [ - "defi", - "wrapped" - ] - }, - { - "chainId": 138, - "address": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "chainId": 1, + "address": "0x514910771AF9Ca656af840dff83E8264EcF986CA", "name": "Chainlink Token", "symbol": "LINK", "decimals": 18, @@ -96,190 +258,16 @@ ] }, { - "chainId": 138, - "address": "0x93E66202A11B1772E55407B32B44e5Cd8eda7f22", - "name": "Compliant Tether USD", - "symbol": "cUSDT", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0xf22258f57794CC8E06237084b353Ab30fFfa640b", - "name": "Compliant USD Coin", - "symbol": "cUSDC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/QmNPq4D5JXzurmi9jAhogVMzhAQRk1PZ1r9H3qQUV9gjDm", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1", - "name": "Tether USD (Official Mirror)", - "symbol": "USDT", - "decimals": 6, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 138, - "address": "0x71D6687F38b93CCad569Fa6352c876eea967201b", - "name": "USD Coin (Official Mirror)", - "symbol": "USDC", - "decimals": 6, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 138, - "address": "0x8085961F9cF02b4d800A3c6d386D31da4B34266a", - "name": "Euro Coin (Compliant)", - "symbol": "cEURC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0xdf4b71c61E5912712C1Bdd451416B9aC26949d72", - "name": "Tether EUR (Compliant)", - "symbol": "cEURT", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0x003960f16D9d34F2e98d62723B6721Fb92074aD2", - "name": "Pound Sterling (Compliant)", - "symbol": "cGBPC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0x350f54e4D23795f86A9c03988c7135357CCaD97c", - "name": "Tether GBP (Compliant)", - "symbol": "cGBPT", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0xD51482e567c03899eecE3CAe8a058161FD56069D", - "name": "Australian Dollar (Compliant)", - "symbol": "cAUDC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0xEe269e1226a334182aace90056EE4ee5Cc8A6770", - "name": "Japanese Yen (Compliant)", - "symbol": "cJPYC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0x873990849DDa5117d7C644f0aF24370797C03885", - "name": "Swiss Franc (Compliant)", - "symbol": "cCHFC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0x54dBd40cF05e15906A2C21f600937e96787f5679", - "name": "Canadian Dollar (Compliant)", - "symbol": "cCADC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0x290E52a8819A4fbD0714E517225429aA2B70EC6b", - "name": "Gold (Compliant)", - "symbol": "cXAUC", - "decimals": 6, + "chainId": 1, + "address": "0xDAe0faFD65385E7775Cf75b1398735155EF6aCD2", + "name": "Truth Network Token", + "symbol": "TRUU", + "decimals": 10, "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", "tags": [ "defi", - "compliant" - ], - "extensions": { - "unitOfAccount": "troy_ounce", - "unitDescription": "1 full token (10^decimals base units) = 1 troy oz fine gold" - } - }, - { - "chainId": 138, - "address": "0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E", - "name": "Tether XAU (Compliant)", - "symbol": "cXAUT", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "defi", - "compliant" - ], - "extensions": { - "unitOfAccount": "troy_ounce", - "unitDescription": "1 full token (10^decimals base units) = 1 troy oz fine gold" - } + "bridge" + ] }, { "chainId": 1, @@ -318,21 +306,174 @@ ] }, { - "chainId": 1, - "address": "0x514910771AF9Ca656af840dff83E8264EcF986CA", - "name": "Chainlink Token", - "symbol": "LINK", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "chainId": 10, + "address": "0x25603ae4bff0b71d637b3573d1b6657f5f6d17ef", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", "tags": [ + "stablecoin", "defi", - "oracle", - "ccip" + "compliant", + "wrapped" ] }, { - "chainId": 1, - "address": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + "chainId": 10, + "address": "0x9f6d2578003fe04e58a9819a4943732f2a203a61", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x4d9bc6c74ba65e37c4139f0aec9fc5ddff28dcc4", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x4ab39b5bab7b463435209a9039bd40cf241f5a82", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x6f521cd9fcf7884cd4e9486c7790e818638e09dd", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x3f8c409c6072a2b6a4ff17071927ba70f80c725f", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x456373d095d6b9260f01709f93fccf1d8aa14d11", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x8e54c52d34a684e22865ac9f2d7c27c30561a7b9", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x04B2AE3c3bb3d70Df506FAd8717b0FBFC78ED7E6", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0xddc4063f770f7c49d00b5a10fb552e922aa39b2c", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x145e8e8c49b6a021969dd9d2c01c8fea44374f61", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", "name": "Dai Stablecoin", "symbol": "DAI", "decimals": 18, @@ -343,128 +484,8 @@ ] }, { - "chainId": 1, - "address": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419", - "name": "ETH/USD Price Feed", - "symbol": "ETH-USD", - "decimals": 8, - "logoURI": "https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png", - "tags": [ - "oracle", - "price-feed" - ] - }, - { - "chainId": 1, - "address": "0xDAe0faFD65385E7775Cf75b1398735155EF6aCD2", - "name": "Truth Network Token", - "symbol": "TRUU", - "decimals": 10, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "defi", - "bridge" - ] - }, - { - "chainId": 11155111, - "address": "0x6cAEfA7446E967018330cCeC5BA7A43956a45137", - "name": "Truth Network Token (Sepolia)", - "symbol": "TRUU", - "decimals": 10, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "defi", - "bridge" - ] - }, - { - "chainId": 651940, - "address": "0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881", - "name": "USD Coin", - "symbol": "USDC", - "decimals": 6, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 651940, - "address": "0x015B1897Ed5279930bC2Be46F661894d219292A6", - "name": "Tether USD", - "symbol": "USDT", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 651940, - "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - "name": "Wrapped Ether", - "symbol": "WETH", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "defi", - "wrapped" - ] - }, - { - "chainId": 25, - "address": "0xc21223249CA28397B4B6541dfFaEcC539BfF0c59", - "name": "USD Coin", - "symbol": "USDC", - "decimals": 6, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 25, - "address": "0x66e4286603D22FF153A6547700f37C7Eae42F8E2", - "name": "Tether USD", - "symbol": "USDT", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 25, - "address": "0x99B3511A2d315A497C8112C1fdd8D508d4B1E506", - "name": "Wrapped Ether (WETH9)", - "symbol": "WETH", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "defi", - "wrapped" - ] - }, - { - "chainId": 25, - "address": "0x3304b747E565a97ec8AC220b0B6A1f6ffDB837e6", - "name": "Wrapped Ether v10", - "symbol": "WETH10", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/QmanDFPHxnbKd6SSNzzXHf9GbpL9dLXSphxDZSPPYE6ds4", - "tags": [ - "defi", - "wrapped" - ] - }, - { - "chainId": 25, - "address": "0x8c80A01F461f297Df7F9DA3A4f740D7297C8Ac85", + "chainId": 10, + "address": "0x350a791Bfc2C21F9Ed5d10980Dad2e2638ffa7f6", "name": "Chainlink Token", "symbol": "LINK", "decimals": 18, @@ -475,273 +496,6 @@ "ccip" ] }, - { - "chainId": 25, - "address": "0x948690147D2e50ffe50C5d38C14125aD6a9FA036", - "name": "USD W Token", - "symbol": "USDW", - "decimals": 2, - "logoURI": "https://ipfs.io/ipfs/QmNPq4D5JXzurmi9jAhogVMzhAQRk1PZ1r9H3qQUV9gjDm", - "tags": [ - "stablecoin", - "iso4217w" - ] - }, - { - "chainId": 25, - "address": "0x58a8D8F78F1B65c06dAd7542eC46b299629A60dd", - "name": "EUR W Token", - "symbol": "EURW", - "decimals": 2, - "logoURI": "https://ipfs.io/ipfs/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS", - "tags": [ - "stablecoin", - "iso4217w" - ] - }, - { - "chainId": 25, - "address": "0xFb4B6Cc81211F7d886950158294A44C312abCA29", - "name": "GBP W Token", - "symbol": "GBPW", - "decimals": 2, - "logoURI": "https://ipfs.io/ipfs/QmT2nJ6WyhYBCsYJ6NfS1BPAqiGKkCEuMxiC8ye93Co1hF", - "tags": [ - "stablecoin", - "iso4217w" - ] - }, - { - "chainId": 25, - "address": "0xf9f5D0ACD71C76F9476F10B3F3d3E201F0883C68", - "name": "AUD W Token", - "symbol": "AUDW", - "decimals": 2, - "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", - "tags": [ - "stablecoin", - "iso4217w" - ] - }, - { - "chainId": 25, - "address": "0xeE17bB0322383fecCA2784fbE2d4CD7d02b1905B", - "name": "JPY W Token", - "symbol": "JPYW", - "decimals": 2, - "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", - "tags": [ - "stablecoin", - "iso4217w" - ] - }, - { - "chainId": 25, - "address": "0xc9750828124D4c10e7a6f4B655cA8487bD3842EB", - "name": "CHF W Token", - "symbol": "CHFW", - "decimals": 2, - "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", - "tags": [ - "stablecoin", - "iso4217w" - ] - }, - { - "chainId": 25, - "address": "0x328Cd365Bb35524297E68ED28c6fF2C9557d1363", - "name": "CAD W Token", - "symbol": "CADW", - "decimals": 2, - "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", - "tags": [ - "stablecoin", - "iso4217w" - ] - }, - { - "chainId": 56, - "address": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", - "name": "USD Coin", - "symbol": "USDC", - "decimals": 6, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 56, - "address": "0x55d398326f99059fF775485246999027B3197955", - "name": "Tether USD", - "symbol": "USDT", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 56, - "address": "0x2170Ed0880ac9A755fd29B2688956BD959F933F8", - "name": "Wrapped Ether", - "symbol": "WETH", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "defi", - "wrapped" - ] - }, - { - "chainId": 56, - "address": "0x404460C6A5EdE2D891e8297795264fDe62ADBB75", - "name": "Chainlink Token", - "symbol": "LINK", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", - "tags": [ - "defi", - "oracle", - "ccip" - ] - }, - { - "chainId": 56, - "address": "0x1AF3F329e8BE154074D8769D1FFa4eE058B1DBc3", - "name": "Dai Stablecoin", - "symbol": "DAI", - "decimals": 18, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 100, - "address": "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", - "name": "USD Coin", - "symbol": "USDC", - "decimals": 6, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 100, - "address": "0x4ECaBa5870353805a9F068101A40E0f32ed605C6", - "name": "Tether USD", - "symbol": "USDT", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 100, - "address": "0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1", - "name": "Wrapped Ether", - "symbol": "WETH", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "defi", - "wrapped" - ] - }, - { - "chainId": 100, - "address": "0xE2e73A1c69ecF83F464EFCE6A5be353a37cA09b2", - "name": "Chainlink Token", - "symbol": "LINK", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", - "tags": [ - "defi", - "oracle", - "ccip" - ] - }, - { - "chainId": 100, - "address": "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", - "name": "Dai Stablecoin", - "symbol": "DAI", - "decimals": 18, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 137, - "address": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c1369", - "name": "USD Coin", - "symbol": "USDC", - "decimals": 6, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 137, - "address": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", - "name": "Tether USD", - "symbol": "USDT", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 137, - "address": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", - "name": "Wrapped Ether", - "symbol": "WETH", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "defi", - "wrapped" - ] - }, - { - "chainId": 137, - "address": "0xb0897686c545045aFc77CF20eC7A532E3120E0F1", - "name": "Chainlink Token", - "symbol": "LINK", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", - "tags": [ - "defi", - "oracle", - "ccip" - ] - }, - { - "chainId": 137, - "address": "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063", - "name": "Dai Stablecoin", - "symbol": "DAI", - "decimals": 18, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, { "chainId": 10, "address": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", @@ -779,8 +533,246 @@ ] }, { - "chainId": 10, - "address": "0x350a791Bfc2C21F9Ed5d10980Dad2e2638ffa7f6", + "chainId": 25, + "address": "0xf9f5D0ACD71C76F9476F10B3F3d3E201F0883C68", + "name": "AUD W Token", + "symbol": "AUDW", + "decimals": 2, + "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", + "tags": [ + "stablecoin", + "iso4217w" + ] + }, + { + "chainId": 25, + "address": "0x328Cd365Bb35524297E68ED28c6fF2C9557d1363", + "name": "CAD W Token", + "symbol": "CADW", + "decimals": 2, + "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", + "tags": [ + "stablecoin", + "iso4217w" + ] + }, + { + "chainId": 25, + "address": "0xc9750828124D4c10e7a6f4B655cA8487bD3842EB", + "name": "CHF W Token", + "symbol": "CHFW", + "decimals": 2, + "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", + "tags": [ + "stablecoin", + "iso4217w" + ] + }, + { + "chainId": 25, + "address": "0xff3084410A732231472Ee9f93F5855dA89CC5254", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x32aD687F24F77bF8C86605c202c829163Ac5Ab36", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0xB55F49D6316322d5caA96D34C6e4b1003BD3E670", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x7574d37F42528B47c88962931e48FC61608a4050", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x9f833b4f1012F52eb3317b09922a79c6EdFca77D", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0xe5c65A76A541368d3061fe9E7A2140cABB903dbF", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0xBb58fa16bAc8E789f09C14243adEE6480D8213A2", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x52aD62B8bD01154e2A4E067F8Dc4144C9988d203", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x932566E5bB6BEBF6B035B94f3DE1f75f126304Ec", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x72948a7a813B60b37Cd0c920C4657DbFF54312b8", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0xf1B771c95573113E993374c0c7cB2dc1a7908B12", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0xD517C0cF7013f988946A468c880Cc9F8e2A4BCbE", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x58a8D8F78F1B65c06dAd7542eC46b299629A60dd", + "name": "EUR W Token", + "symbol": "EURW", + "decimals": 2, + "logoURI": "https://ipfs.io/ipfs/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS", + "tags": [ + "stablecoin", + "iso4217w" + ] + }, + { + "chainId": 25, + "address": "0xFb4B6Cc81211F7d886950158294A44C312abCA29", + "name": "GBP W Token", + "symbol": "GBPW", + "decimals": 2, + "logoURI": "https://ipfs.io/ipfs/QmT2nJ6WyhYBCsYJ6NfS1BPAqiGKkCEuMxiC8ye93Co1hF", + "tags": [ + "stablecoin", + "iso4217w" + ] + }, + { + "chainId": 25, + "address": "0xeE17bB0322383fecCA2784fbE2d4CD7d02b1905B", + "name": "JPY W Token", + "symbol": "JPYW", + "decimals": 2, + "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", + "tags": [ + "stablecoin", + "iso4217w" + ] + }, + { + "chainId": 25, + "address": "0x8c80A01F461f297Df7F9DA3A4f740D7297C8Ac85", "name": "Chainlink Token", "symbol": "LINK", "decimals": 18, @@ -792,20 +784,8 @@ ] }, { - "chainId": 10, - "address": "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", - "name": "Dai Stablecoin", - "symbol": "DAI", - "decimals": 18, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 42161, - "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "chainId": 25, + "address": "0xc21223249CA28397B4B6541dfFaEcC539BfF0c59", "name": "USD Coin", "symbol": "USDC", "decimals": 6, @@ -816,8 +796,8 @@ ] }, { - "chainId": 42161, - "address": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + "chainId": 25, + "address": "0x66e4286603D22FF153A6547700f37C7Eae42F8E2", "name": "Tether USD", "symbol": "USDT", "decimals": 6, @@ -828,9 +808,21 @@ ] }, { - "chainId": 42161, - "address": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", - "name": "Wrapped Ether", + "chainId": 25, + "address": "0x948690147D2e50ffe50C5d38C14125aD6a9FA036", + "name": "USD W Token", + "symbol": "USDW", + "decimals": 2, + "logoURI": "https://ipfs.io/ipfs/QmNPq4D5JXzurmi9jAhogVMzhAQRk1PZ1r9H3qQUV9gjDm", + "tags": [ + "stablecoin", + "iso4217w" + ] + }, + { + "chainId": 25, + "address": "0x99B3511A2d315A497C8112C1fdd8D508d4B1E506", + "name": "Wrapped Ether (WETH9)", "symbol": "WETH", "decimals": 18, "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", @@ -840,8 +832,198 @@ ] }, { - "chainId": 42161, - "address": "0xf97f4df75117a78c1A5a0DBb814Af92458539FB4", + "chainId": 25, + "address": "0x3304b747E565a97ec8AC220b0B6A1f6ffDB837e6", + "name": "Wrapped Ether v10", + "symbol": "WETH10", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmanDFPHxnbKd6SSNzzXHf9GbpL9dLXSphxDZSPPYE6ds4", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x7062f35567BBAb4d98dc33af03B0d14Df42294D5", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x9AE7a6B311584D60Fa93f973950d609061875775", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0xD9f8710caeeBA3b3D423D7D14a918701426B5ef3", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x50b073d0D1D2f002745cb9FC28a057d5be84911c", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x1ED9E491A5eCd53BeF21962A5FCE24880264F63f", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x8b6EE72001cAFcb21D56a6c4686D6Db951d499A6", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0xA6eFb8783C8ad2740ec880e46D4f7E608E893B1B", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x5fbCE65524211BC1bFb0309fd9EE09E786c6D097", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x5355148C4740fcc3D7a96F05EdD89AB14851206b", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x9a1D0dBEE997929ED02fD19E0E199704d20914dB", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0xCB145bA9A370681e3545F60e55621eBf218B1031", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x73E0CF8BF861D376B3a4C87c136F975027f045ff", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x1AF3F329e8BE154074D8769D1FFa4eE058B1DBc3", + "name": "Dai Stablecoin", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 56, + "address": "0x404460C6A5EdE2D891e8297795264fDe62ADBB75", "name": "Chainlink Token", "symbol": "LINK", "decimals": 18, @@ -853,8 +1035,210 @@ ] }, { - "chainId": 42161, - "address": "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", + "chainId": 56, + "address": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 56, + "address": "0x55d398326f99059fF775485246999027B3197955", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 56, + "address": "0x2170Ed0880ac9A755fd29B2688956BD959F933F8", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0xddc4063f770f7c49d00b5a10fb552e922aa39b2c", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0xa7133c78e0ec74503a5941bcbd44257615b6b4f6", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x46d90d7947f1139477c206c39268923b99cf09e4", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x25603ae4bff0b71d637b3573d1b6657f5f6d17ef", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x8e54c52d34a684e22865ac9f2d7c27c30561a7b9", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x4d9bc6c74ba65e37c4139f0aec9fc5ddff28dcc4", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x9f6d2578003fe04e58a9819a4943732f2a203a61", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x145e8e8c49b6a021969dd9d2c01c8fea44374f61", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x23873b85cfeb343eb952618e8c9e9bfb7f6a0d45", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0xc6189d404dc60cae7b48e2190e44770a03193e5f", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", "name": "Dai Stablecoin", "symbol": "DAI", "decimals": 18, @@ -864,6 +1248,732 @@ "defi" ] }, + { + "chainId": 100, + "address": "0xE2e73A1c69ecF83F464EFCE6A5be353a37cA09b2", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, + { + "chainId": 100, + "address": "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 100, + "address": "0x4ECaBa5870353805a9F068101A40E0f32ed605C6", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 100, + "address": "0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0xFb4B6Cc81211F7d886950158294A44C312abCA29", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0xc9750828124D4c10e7a6f4B655cA8487bD3842EB", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0xeE17bB0322383fecCA2784fbE2d4CD7d02b1905B", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x3CD9ee18db7ad13616FCC1c83bC6098e03968E66", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0xBeF5A0Bcc0E77740c910f197138cdD90F98d2427", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x948690147D2e50ffe50C5d38C14125aD6a9FA036", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x58a8D8F78F1B65c06dAd7542eC46b299629A60dd", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0xf9f5D0ACD71C76F9476F10B3F3d3E201F0883C68", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x328Cd365Bb35524297E68ED28c6fF2C9557d1363", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x9e6044d730d4183bF7a666293d257d035Fba6d44", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063", + "name": "Dai Stablecoin", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 137, + "address": "0xb0897686c545045aFc77CF20eC7A532E3120E0F1", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, + { + "chainId": 137, + "address": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 137, + "address": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 137, + "address": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 138, + "address": "0xD51482e567c03899eecE3CAe8a058161FD56069D", + "name": "Australian Dollar (Compliant)", + "symbol": "cAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x54dBd40cF05e15906A2C21f600937e96787f5679", + "name": "Canadian Dollar (Compliant)", + "symbol": "cCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x873990849DDa5117d7C644f0aF24370797C03885", + "name": "Swiss Franc (Compliant)", + "symbol": "cCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x8085961F9cF02b4d800A3c6d386D31da4B34266a", + "name": "Euro Coin (Compliant)", + "symbol": "cEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0xdf4b71c61E5912712C1Bdd451416B9aC26949d72", + "name": "Tether EUR (Compliant)", + "symbol": "cEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x003960f16D9d34F2e98d62723B6721Fb92074aD2", + "name": "Pound Sterling (Compliant)", + "symbol": "cGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x350f54e4D23795f86A9c03988c7135357CCaD97c", + "name": "Tether GBP (Compliant)", + "symbol": "cGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0xEe269e1226a334182aace90056EE4ee5Cc8A6770", + "name": "Japanese Yen (Compliant)", + "symbol": "cJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0xf22258f57794CC8E06237084b353Ab30fFfa640b", + "name": "Compliant USD Coin", + "symbol": "cUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x93E66202A11B1772E55407B32B44e5Cd8eda7f22", + "name": "Compliant Tether USD", + "symbol": "cUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x290E52a8819A4fbD0714E517225429aA2B70EC6b", + "name": "Gold (Compliant)", + "symbol": "cXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant" + ], + "extensions": { + "unitOfAccount": "troy_ounce", + "unitDescription": "1 full token (10^decimals base units) = 1 troy oz fine gold" + } + }, + { + "chainId": 138, + "address": "0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E", + "name": "Tether XAU (Compliant)", + "symbol": "cXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant" + ], + "extensions": { + "unitOfAccount": "troy_ounce", + "unitDescription": "1 full token (10^decimals base units) = 1 troy oz fine gold" + } + }, + { + "chainId": 138, + "address": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6", + "name": "ETH/USD Price Feed", + "symbol": "ETH-USD", + "decimals": 8, + "logoURI": "https://ipfs.io/ipfs/QmPZuycjyJEe2otREuQ5HirvPJ8X6Yc6MBtwz1VhdD79pY", + "tags": [ + "oracle", + "price-feed" + ] + }, + { + "chainId": 138, + "address": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, + { + "chainId": 138, + "address": "0x71D6687F38b93CCad569Fa6352c876eea967201b", + "name": "USD Coin (Official Mirror)", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 138, + "address": "0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1", + "name": "Tether USD (Official Mirror)", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 138, + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 138, + "address": "0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9F", + "name": "Wrapped Ether v10", + "symbol": "WETH10", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmanDFPHxnbKd6SSNzzXHf9GbpL9dLXSphxDZSPPYE6ds4", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 1111, + "address": "0xE3F5a90F9cb311505cd691a46596599aA1A0AD7D", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 1111, + "address": "0xA649325Aa7C5093d12D6F98EB4378deAe68CE23F", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 8453, + "address": "0xa846aead3071df1b6439d5d813156ace7c2c1da1", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0xdc383c489533a4dd9a6bd3007386e25d5078b878", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0xc1535e88578d984f12eab55863376b8d8b9fb05a", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0xcb145ba9a370681e3545f60e55621ebf218b1031", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x73e0cf8bf861d376b3a4c87c136f975027f045ff", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x2a0023ad5ce1ac6072b454575996dffb1bb11b16", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x22b98130ab4d9c355512b25ade4c35e75a4e7e89", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x29828e9ab2057cd3df3c9211455ae1f76e53d2af", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x04B2AE3c3bb3d70Df506FAd8717b0FBFC78ED7E6", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x7e4b4682453bcce19ec903fb69153d3031986bc4", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0xcc6ae6016d564e9ab82aaff44d65e05a9b18951c", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", + "name": "Dai Stablecoin", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 8453, + "address": "0x88Fb150BDc53A65fe94Dea0c9BA0a6dAf8C6e196", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, { "chainId": 8453, "address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", @@ -901,8 +2011,186 @@ ] }, { - "chainId": 8453, - "address": "0x88Fb150BDc53A65fe94Dea0c9BA0a6dAf8C6e196", + "chainId": 42161, + "address": "0xc1535e88578d984f12eab55863376b8d8b9fb05a", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0xcc6ae6016d564e9ab82aaff44d65e05a9b18951c", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x7e4b4682453bcce19ec903fb69153d3031986bc4", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x2a0023ad5ce1ac6072b454575996dffb1bb11b16", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x22b98130ab4d9c355512b25ade4c35e75a4e7e89", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0xa846aead3071df1b6439d5d813156ace7c2c1da1", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x29828e9ab2057cd3df3c9211455ae1f76e53d2af", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0xdc383c489533a4dd9a6bd3007386e25d5078b878", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x73ADaF7dBa95221c080db5631466d2bC54f6a76B", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0xa7762b63c4871581885ad17c5714ebb286a7480b", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x66568899ffe8f00b25dc470e878b65a478994e76", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", + "name": "Dai Stablecoin", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 42161, + "address": "0xf97f4df75117a78c1A5a0DBb814Af92458539FB4", "name": "Chainlink Token", "symbol": "LINK", "decimals": 18, @@ -914,20 +2202,8 @@ ] }, { - "chainId": 8453, - "address": "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", - "name": "Dai Stablecoin", - "symbol": "DAI", - "decimals": 18, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 43114, - "address": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", + "chainId": 42161, + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", "name": "USD Coin", "symbol": "USDC", "decimals": 6, @@ -938,8 +2214,8 @@ ] }, { - "chainId": 43114, - "address": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", + "chainId": 42161, + "address": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", "name": "Tether USD", "symbol": "USDT", "decimals": 6, @@ -950,8 +2226,8 @@ ] }, { - "chainId": 43114, - "address": "0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB", + "chainId": 42161, + "address": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", "name": "Wrapped Ether", "symbol": "WETH", "decimals": 18, @@ -962,8 +2238,8 @@ ] }, { - "chainId": 43114, - "address": "0x5947BB275c521040051D82396192181b413227A3", + "chainId": 42220, + "address": "0xd07294e6E917e07dfDcee882dd1e2565085C2ae0", "name": "Chainlink Token", "symbol": "LINK", "decimals": 18, @@ -974,18 +2250,6 @@ "ccip" ] }, - { - "chainId": 43114, - "address": "0xd586E7F844cEa2F87f50152665BCbc2C279D8d70", - "name": "Dai Stablecoin", - "symbol": "DAI", - "decimals": 18, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, { "chainId": 42220, "address": "0xcebA9300f2b948710d2653dD7B07f33A8B32118C", @@ -1023,8 +2287,186 @@ ] }, { - "chainId": 42220, - "address": "0xd07294e6E917e07dfDcee882dd1e2565085C2ae0", + "chainId": 43114, + "address": "0x04e1e22b0d41e99f4275bd40a50480219bc9a223", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x1872e033b30f3ce0498847926857433e0146394e", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0xc2fa05f12a75ac84ea778af9d6935ca807275e55", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x84353ed1f0c7a703a17abad19b0db15bc9a5e3e5", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0xfc7d256e48253f7a7e08f0e55b9ff7039eb2524c", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0xbdf0c4ea1d81e8e769b0f41389a2c733e3ff723e", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x4611d3424e059392a52b957e508273bc761c80f2", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x3714b1a312e0916c7dcdc4edf480fc0339e59a59", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x0C242b513008Cd49C89078F5aFb237A3112251EB", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x8142BA530B08f3950128601F00DaaA678213DFdf", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x4f95297c23d9f4a1032b1c6a2e553225cb175bee", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0xd2b4dbf2f6bd6704e066d752eec61fb0be953fd3", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0xd586E7F844cEa2F87f50152665BCbc2C279D8d70", + "name": "Dai Stablecoin", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 43114, + "address": "0x5947BB275c521040051D82396192181b413227A3", "name": "Chainlink Token", "symbol": "LINK", "decimals": 18, @@ -1036,8 +2478,8 @@ ] }, { - "chainId": 1111, - "address": "0xE3F5a90F9cb311505cd691a46596599aA1A0AD7D", + "chainId": 43114, + "address": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", "name": "USD Coin", "symbol": "USDC", "decimals": 6, @@ -1048,8 +2490,8 @@ ] }, { - "chainId": 1111, - "address": "0xA649325Aa7C5093d12D6F98EB4378deAe68CE23F", + "chainId": 43114, + "address": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", "name": "Tether USD", "symbol": "USDT", "decimals": 6, @@ -1058,40 +2500,66 @@ "stablecoin", "defi" ] + }, + { + "chainId": 43114, + "address": "0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 651940, + "address": "0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 651940, + "address": "0x015B1897Ed5279930bC2Be46F661894d219292A6", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 651940, + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 11155111, + "address": "0x6cAEfA7446E967018330cCeC5BA7A43956a45137", + "name": "Truth Network Token (Sepolia)", + "symbol": "TRUU", + "decimals": 10, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "bridge" + ] } - ], - "tags": { - "defi": { - "name": "DeFi", - "description": "Decentralized Finance tokens" - }, - "bridge": { - "name": "Bridge", - "description": "Tokens bridged to/from other chains (e.g. Truth Network)" - }, - "wrapped": { - "name": "Wrapped", - "description": "Wrapped tokens representing native assets" - }, - "oracle": { - "name": "Oracle", - "description": "Oracle price feed contracts" - }, - "price-feed": { - "name": "Price Feed", - "description": "Price feed oracle contracts" - }, - "stablecoin": { - "name": "Stablecoin", - "description": "Stable value tokens pegged to fiat" - }, - "compliant": { - "name": "Compliant", - "description": "Regulatory compliant tokens" - }, - "iso4217w": { - "name": "ISO4217W", - "description": "ISO 4217 compliant wrapped fiat tokens" - } - } + ] } diff --git a/backend/api/rest/config/metamask/GRU_V2_DEPLOYMENT_QUEUE.json b/backend/api/rest/config/metamask/GRU_V2_DEPLOYMENT_QUEUE.json new file mode 100644 index 0000000..3d7f51f --- /dev/null +++ b/backend/api/rest/config/metamask/GRU_V2_DEPLOYMENT_QUEUE.json @@ -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." + ] +} diff --git a/backend/api/rest/config/metamask/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json b/backend/api/rest/config/metamask/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json new file mode 100644 index 0000000..46bdb82 --- /dev/null +++ b/backend/api/rest/config/metamask/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json @@ -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." + ] +} diff --git a/backend/api/rest/config_test.go b/backend/api/rest/config_test.go index 4212729..e0c4fc0 100644 --- a/backend/api/rest/config_test.go +++ b/backend/api/rest/config_test.go @@ -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) diff --git a/backend/api/rest/etherscan.go b/backend/api/rest/etherscan.go index 021a9f4..ad93983 100644 --- a/backend/api/rest/etherscan.go +++ b/backend/api/rest/etherscan.go @@ -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 +} diff --git a/backend/api/rest/etherscan_internal_test.go b/backend/api/rest/etherscan_internal_test.go new file mode 100644 index 0000000..3e72368 --- /dev/null +++ b/backend/api/rest/etherscan_internal_test.go @@ -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) + } + } +} diff --git a/backend/api/rest/middleware.go b/backend/api/rest/middleware.go index b0aeb3e..46a5961 100644 --- a/backend/api/rest/middleware.go +++ b/backend/api/rest/middleware.go @@ -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) { diff --git a/backend/api/rest/mission_control.go b/backend/api/rest/mission_control.go new file mode 100644 index 0000000..dad26ad --- /dev/null +++ b/backend/api/rest/mission_control.go @@ -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}) +} diff --git a/backend/api/rest/mission_control_test.go b/backend/api/rest/mission_control_test.go new file mode 100644 index 0000000..3a6a1da --- /dev/null +++ b/backend/api/rest/mission_control_test.go @@ -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") +} diff --git a/backend/api/rest/routes.go b/backend/api/rest/routes.go index 5c38ed0..a8b496d 100644 --- a/backend/api/rest/routes.go +++ b/backend/api/rest/routes.go @@ -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) } diff --git a/backend/api/rest/routes_proxy.go b/backend/api/rest/routes_proxy.go index d63f677..c1fef71 100644 --- a/backend/api/rest/routes_proxy.go +++ b/backend/api/rest/routes_proxy.go @@ -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 != "" { diff --git a/backend/api/rest/routes_proxy_test.go b/backend/api/rest/routes_proxy_test.go new file mode 100644 index 0000000..1734d95 --- /dev/null +++ b/backend/api/rest/routes_proxy_test.go @@ -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) +} diff --git a/backend/api/rest/search.go b/backend/api/rest/search.go index 53b3e30..70c1ea4 100644 --- a/backend/api/rest/search.go +++ b/backend/api/rest/search.go @@ -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")) diff --git a/backend/api/rest/server.go b/backend/api/rest/server.go index 0d757ca..ea932cd 100644 --- a/backend/api/rest/server.go +++ b/backend/api/rest/server.go @@ -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" { diff --git a/backend/api/rest/server_internal_test.go b/backend/api/rest/server_internal_test.go new file mode 100644 index 0000000..45cb3b5 --- /dev/null +++ b/backend/api/rest/server_internal_test.go @@ -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)) +} diff --git a/backend/api/rest/stats.go b/backend/api/rest/stats.go index 8d12af0..4268b8d 100644 --- a/backend/api/rest/stats.go +++ b/backend/api/rest/stats.go @@ -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) } - diff --git a/backend/api/rest/stats_internal_test.go b/backend/api/rest/stats_internal_test.go new file mode 100644 index 0000000..86f7cfd --- /dev/null +++ b/backend/api/rest/stats_internal_test.go @@ -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") +} diff --git a/backend/api/rest/swagger.yaml b/backend/api/rest/swagger.yaml index e2bf9c9..de94aa1 100644 --- a/backend/api/rest/swagger.yaml +++ b/backend/api/rest/swagger.yaml @@ -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" - diff --git a/backend/api/rest/track_routes.go b/backend/api/rest/track_routes.go index f4fd57e..b49b0b5 100644 --- a/backend/api/rest/track_routes.go +++ b/backend/api/rest/track_routes.go @@ -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)) } - diff --git a/backend/api/rest/transactions.go b/backend/api/rest/transactions.go index 6857426..b546dd7 100644 --- a/backend/api/rest/transactions.go +++ b/backend/api/rest/transactions.go @@ -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) diff --git a/backend/api/rest/validation.go b/backend/api/rest/validation.go index d0a483c..38504bd 100644 --- a/backend/api/rest/validation.go +++ b/backend/api/rest/validation.go @@ -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) diff --git a/backend/api/rest/validation_test.go b/backend/api/rest/validation_test.go new file mode 100644 index 0000000..14a9f93 --- /dev/null +++ b/backend/api/rest/validation_test.go @@ -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) + } +} diff --git a/backend/api/track1/bridge_status_data.go b/backend/api/track1/bridge_status_data.go new file mode 100644 index 0000000..dde9f22 --- /dev/null +++ b/backend/api/track1/bridge_status_data.go @@ -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 +} diff --git a/backend/api/track1/ccip_health.go b/backend/api/track1/ccip_health.go new file mode 100644 index 0000000..936ebbb --- /dev/null +++ b/backend/api/track1/ccip_health.go @@ -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)) +} diff --git a/backend/api/track1/ccip_health_test.go b/backend/api/track1/ccip_health_test.go new file mode 100644 index 0000000..07a9d94 --- /dev/null +++ b/backend/api/track1/ccip_health_test.go @@ -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"]) +} diff --git a/backend/api/track1/endpoints.go b/backend/api/track1/endpoints.go index aec6f93..b5bcb2c 100644 --- a/backend/api/track1/endpoints.go +++ b/backend/api/track1/endpoints.go @@ -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"]), diff --git a/backend/api/track1/endpoints_validation_test.go b/backend/api/track1/endpoints_validation_test.go new file mode 100644 index 0000000..373aad1 --- /dev/null +++ b/backend/api/track1/endpoints_validation_test.go @@ -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) + } +} diff --git a/backend/api/track1/mission_control_sse.go b/backend/api/track1/mission_control_sse.go new file mode 100644 index 0000000..def025e --- /dev/null +++ b/backend/api/track1/mission_control_sse.go @@ -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 + } + } + } +} diff --git a/backend/api/track1/mission_control_sse_test.go b/backend/api/track1/mission_control_sse_test.go new file mode 100644 index 0000000..1201d46 --- /dev/null +++ b/backend/api/track1/mission_control_sse_test.go @@ -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"`) +} diff --git a/backend/api/track1/rpcping.go b/backend/api/track1/rpcping.go new file mode 100644 index 0000000..6e48f7a --- /dev/null +++ b/backend/api/track1/rpcping.go @@ -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 +} diff --git a/backend/api/track2/endpoints.go b/backend/api/track2/endpoints.go index 5986c77..1198bc9 100644 --- a/backend/api/track2/endpoints.go +++ b/backend/api/track2/endpoints.go @@ -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 +} diff --git a/backend/api/track3/endpoints.go b/backend/api/track3/endpoints.go index 7cf3ab3..e044b5c 100644 --- a/backend/api/track3/endpoints.go +++ b/backend/api/track3/endpoints.go @@ -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 +} diff --git a/backend/api/track4/endpoints.go b/backend/api/track4/endpoints.go index 2ccf183..76a16c5 100644 --- a/backend/api/track4/endpoints.go +++ b/backend/api/track4/endpoints.go @@ -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 +} diff --git a/backend/api/track4/endpoints_test.go b/backend/api/track4/endpoints_test.go new file mode 100644 index 0000000..cd6fde9 --- /dev/null +++ b/backend/api/track4/endpoints_test.go @@ -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") + } +} diff --git a/backend/api/track4/operator_scripts.go b/backend/api/track4/operator_scripts.go new file mode 100644 index 0000000..4d60931 --- /dev/null +++ b/backend/api/track4/operator_scripts.go @@ -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 +} diff --git a/backend/api/track4/operator_scripts_test.go b/backend/api/track4/operator_scripts_test.go new file mode 100644 index 0000000..d536add --- /dev/null +++ b/backend/api/track4/operator_scripts_test.go @@ -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") +} diff --git a/backend/api/track4/request_ip.go b/backend/api/track4/request_ip.go new file mode 100644 index 0000000..46c36b2 --- /dev/null +++ b/backend/api/track4/request_ip.go @@ -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) +} diff --git a/backend/api/websocket/server.go b/backend/api/websocket/server.go index be88af0..f31eef4 100644 --- a/backend/api/websocket/server.go +++ b/backend/api/websocket/server.go @@ -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) } } - diff --git a/backend/api/websocket/server_test.go b/backend/api/websocket/server_test.go new file mode 100644 index 0000000..597f1d7 --- /dev/null +++ b/backend/api/websocket/server_test.go @@ -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") + } +} diff --git a/backend/auth/wallet_auth.go b/backend/auth/wallet_auth.go index fa34796..7e96dc2 100644 --- a/backend/auth/wallet_auth.go +++ b/backend/auth/wallet_auth.go @@ -4,7 +4,9 @@ import ( "context" "crypto/rand" "encoding/hex" + "errors" "fmt" + "strings" "time" "github.com/ethereum/go-ethereum/accounts" @@ -14,6 +16,13 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +var ( + ErrWalletAuthStorageNotInitialized = errors.New("wallet authentication storage is not initialized; run migration 0010_track_schema") + ErrWalletNonceNotFoundOrExpired = errors.New("nonce not found or expired") + ErrWalletNonceExpired = errors.New("nonce expired") + ErrWalletNonceInvalid = errors.New("invalid nonce") +) + // WalletAuth handles wallet-based authentication type WalletAuth struct { db *pgxpool.Pool @@ -28,6 +37,10 @@ func NewWalletAuth(db *pgxpool.Pool, jwtSecret []byte) *WalletAuth { } } +func isMissingWalletNonceTableError(err error) bool { + return err != nil && strings.Contains(err.Error(), `relation "wallet_nonces" does not exist`) +} + // NonceRequest represents a nonce request type NonceRequest struct { Address string `json:"address"` @@ -84,6 +97,9 @@ func (w *WalletAuth) GenerateNonce(ctx context.Context, address string) (*NonceR ` _, err := w.db.Exec(ctx, query, normalizedAddr, nonce, expiresAt) if err != nil { + if isMissingWalletNonceTableError(err) { + return nil, ErrWalletAuthStorageNotInitialized + } return nil, fmt.Errorf("failed to store nonce: %w", err) } @@ -110,22 +126,25 @@ func (w *WalletAuth) AuthenticateWallet(ctx context.Context, req *WalletAuthRequ query := `SELECT nonce, expires_at FROM wallet_nonces WHERE address = $1` err := w.db.QueryRow(ctx, query, normalizedAddr).Scan(&storedNonce, &expiresAt) if err != nil { - return nil, fmt.Errorf("nonce not found or expired") + if isMissingWalletNonceTableError(err) { + return nil, ErrWalletAuthStorageNotInitialized + } + return nil, ErrWalletNonceNotFoundOrExpired } if time.Now().After(expiresAt) { - return nil, fmt.Errorf("nonce expired") + return nil, ErrWalletNonceExpired } if storedNonce != req.Nonce { - return nil, fmt.Errorf("invalid nonce") + return nil, ErrWalletNonceInvalid } // Verify signature message := fmt.Sprintf("Sign this message to authenticate with SolaceScanScout Explorer.\n\nNonce: %s", req.Nonce) messageHash := accounts.TextHash([]byte(message)) - sigBytes, err := hex.DecodeString(req.Signature[2:]) // Remove 0x prefix + sigBytes, err := decodeWalletSignature(req.Signature) if err != nil { return nil, fmt.Errorf("invalid signature format: %w", err) } @@ -241,9 +260,45 @@ func (w *WalletAuth) ValidateJWT(tokenString string) (string, int, error) { } track := int(trackFloat) + if w.db == nil { + return address, track, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + currentTrack, err := w.getUserTrack(ctx, address) + if err != nil { + return "", 0, fmt.Errorf("failed to resolve current track: %w", err) + } + if currentTrack < track { + track = currentTrack + } + return address, track, nil } +func decodeWalletSignature(signature string) ([]byte, error) { + if len(signature) < 2 || !strings.EqualFold(signature[:2], "0x") { + return nil, fmt.Errorf("signature must start with 0x") + } + + raw := signature[2:] + if len(raw) != 130 { + return nil, fmt.Errorf("invalid signature length") + } + + sigBytes, err := hex.DecodeString(raw) + if err != nil { + return nil, err + } + if len(sigBytes) != 65 { + return nil, fmt.Errorf("invalid signature length") + } + + return sigBytes, nil +} + // getPermissionsForTrack returns permissions for a track level func getPermissionsForTrack(track int) []string { permissions := []string{ diff --git a/backend/auth/wallet_auth_test.go b/backend/auth/wallet_auth_test.go new file mode 100644 index 0000000..cfff30b --- /dev/null +++ b/backend/auth/wallet_auth_test.go @@ -0,0 +1,28 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDecodeWalletSignatureRejectsMalformedValues(t *testing.T) { + _, err := decodeWalletSignature("deadbeef") + require.ErrorContains(t, err, "signature must start with 0x") + + _, err = decodeWalletSignature("0x1234") + require.ErrorContains(t, err, "invalid signature length") +} + +func TestValidateJWTReturnsClaimsWhenDBUnavailable(t *testing.T) { + secret := []byte("test-secret") + auth := NewWalletAuth(nil, secret) + + token, _, err := auth.generateJWT("0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4) + require.NoError(t, err) + + address, track, err := auth.ValidateJWT(token) + require.NoError(t, err) + require.Equal(t, "0x4A666F96fC8764181194447A7dFdb7d471b301C8", address) + require.Equal(t, 4, track) +} diff --git a/backend/cmd b/backend/cmd new file mode 100755 index 0000000..8fb37ee Binary files /dev/null and b/backend/cmd differ diff --git a/backend/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json b/backend/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json index 7bc8659..1adf37f 100644 --- a/backend/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json +++ b/backend/config/metamask/DUAL_CHAIN_TOKEN_LIST.tokenlist.json @@ -1,282 +1,223 @@ { - "name": "Multi-Chain Token List (Chain 138 + Ethereum + ALL Mainnet + Cronos)", + "name": "Multi-Chain Token List (13 chains, 138 base)", "version": { "major": 1, - "minor": 2, - "patch": 2 + "minor": 3, + "patch": 4 + }, + "timestamp": "2026-04-04T04:23:46.263Z", + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/chain138-list.svg", + "keywords": [ + "chain138", + "defi-oracle-meta", + "multichain", + "metamask", + "wallet" + ], + "tags": { + "defi": { + "name": "DeFi", + "description": "Decentralized Finance tokens" + }, + "bridge": { + "name": "Bridge", + "description": "Tokens bridged to other chains such as Truth Network" + }, + "wrapped": { + "name": "Wrapped", + "description": "Wrapped tokens representing native assets" + }, + "oracle": { + "name": "Oracle", + "description": "Oracle price feed tokens" + }, + "price-feed": { + "name": "Price Feed", + "description": "Price feed oracle tokens" + }, + "stablecoin": { + "name": "Stablecoin", + "description": "Stable value tokens pegged to fiat" + }, + "compliant": { + "name": "Compliant", + "description": "Regulatory compliant assets" + }, + "iso4217w": { + "name": "ISO4217W", + "description": "ISO 4217 compliant wrapped fiat tokens" + } + }, + "extensions": { + "defaultChainId": 138, + "explorerUrl": "https://explorer.d-bis.org", + "networksConfigUrl": "https://explorer.d-bis.org/api/config/networks" }, - "timestamp": "2026-03-26T09:17:26.869Z", - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", "tokens": [ - { - "chainId": 138, - "address": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6", - "name": "ETH/USD Price Feed", - "symbol": "ETH-USD", - "decimals": 8, - "logoURI": "https://ipfs.io/ipfs/QmPZuycjyJEe2otREuQ5HirvPJ8X6Yc6MBtwz1VhdD79pY", - "tags": [ - "oracle", - "price-feed" - ] - }, - { - "chainId": 138, - "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - "name": "Wrapped Ether", - "symbol": "WETH", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "defi", - "wrapped" - ] - }, - { - "chainId": 138, - "address": "0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9F", - "name": "Wrapped Ether v10", - "symbol": "WETH10", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/QmanDFPHxnbKd6SSNzzXHf9GbpL9dLXSphxDZSPPYE6ds4", - "tags": [ - "defi", - "wrapped" - ] - }, - { - "chainId": 138, - "address": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", - "name": "Chainlink Token", - "symbol": "LINK", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", - "tags": [ - "defi", - "oracle", - "ccip" - ] - }, - { - "chainId": 138, - "address": "0x93E66202A11B1772E55407B32B44e5Cd8eda7f22", - "name": "Compliant Tether USD", - "symbol": "cUSDT", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0xf22258f57794CC8E06237084b353Ab30fFfa640b", - "name": "Compliant USD Coin", - "symbol": "cUSDC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/QmNPq4D5JXzurmi9jAhogVMzhAQRk1PZ1r9H3qQUV9gjDm", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1", - "name": "Tether USD (Official Mirror)", - "symbol": "USDT", - "decimals": 6, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 138, - "address": "0x71D6687F38b93CCad569Fa6352c876eea967201b", - "name": "USD Coin (Official Mirror)", - "symbol": "USDC", - "decimals": 6, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 138, - "address": "0x8085961F9cF02b4d800A3c6d386D31da4B34266a", - "name": "Euro Coin (Compliant)", - "symbol": "cEURC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0xdf4b71c61E5912712C1Bdd451416B9aC26949d72", - "name": "Tether EUR (Compliant)", - "symbol": "cEURT", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0x003960f16D9d34F2e98d62723B6721Fb92074aD2", - "name": "Pound Sterling (Compliant)", - "symbol": "cGBPC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0x350f54e4D23795f86A9c03988c7135357CCaD97c", - "name": "Tether GBP (Compliant)", - "symbol": "cGBPT", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0xD51482e567c03899eecE3CAe8a058161FD56069D", - "name": "Australian Dollar (Compliant)", - "symbol": "cAUDC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0xEe269e1226a334182aace90056EE4ee5Cc8A6770", - "name": "Japanese Yen (Compliant)", - "symbol": "cJPYC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0x873990849DDa5117d7C644f0aF24370797C03885", - "name": "Swiss Franc (Compliant)", - "symbol": "cCHFC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0x54dBd40cF05e15906A2C21f600937e96787f5679", - "name": "Canadian Dollar (Compliant)", - "symbol": "cCADC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "stablecoin", - "defi", - "compliant" - ] - }, - { - "chainId": 138, - "address": "0x290E52a8819A4fbD0714E517225429aA2B70EC6b", - "name": "Gold (Compliant)", - "symbol": "cXAUC", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "defi", - "compliant" - ], - "extensions": { - "unitOfAccount": "troy_ounce", - "unitDescription": "1 full token (10^decimals base units) = 1 troy oz fine gold" - } - }, - { - "chainId": 138, - "address": "0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E", - "name": "Tether XAU (Compliant)", - "symbol": "cXAUT", - "decimals": 6, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "defi", - "compliant" - ], - "extensions": { - "unitOfAccount": "troy_ounce", - "unitDescription": "1 full token (10^decimals base units) = 1 troy oz fine gold" - } - }, { "chainId": 1, - "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - "name": "Wrapped Ether", - "symbol": "WETH", - "decimals": 18, - "logoURI": "https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png", + "address": "0x5020Db641B3Fc0dAbBc0c688C845bc4E3699f35F", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", "tags": [ + "stablecoin", "defi", + "compliant", "wrapped" ] }, { "chainId": 1, - "address": "0xdAC17F958D2ee523a2206206994597C13D831ec7", - "name": "Tether USD", - "symbol": "USDT", + "address": "0x209FE32fe7B541751D190ae4e50cd005DcF8EDb4", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", "decimals": 6, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png", + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", "tags": [ "stablecoin", - "defi" + "defi", + "compliant", + "wrapped" ] }, { "chainId": 1, - "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "name": "USD Coin", - "symbol": "USDC", + "address": "0x0F91C5E6Ddd46403746aAC970D05d70FFe404780", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", "decimals": 6, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", "tags": [ "stablecoin", - "defi" + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0xD4aEAa8cD3fB41Dc8437FaC7639B6d91B60A5e8d", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0x855d74FFB6CF75721a9bAbc8B2ed35c8119241dC", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0xc074007dc0Bfb384B1cf6426a56287Ed23FE4D52", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0x1dDF9970F01c76A692Fdba2706203E6f16e0C46F", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0x07EEd0D7dD40984e47B9D3a3bdded1c536435582", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0xaF5017d0163ecb99D9B5D94e3b4D7b09Af44D8AE", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0x572Be0fa8CA0534d642A567CEDb398B771D8a715", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 1, + "address": "0xACE1DBF857549a11aF1322e1f91F2F64b029c906", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" ] }, { @@ -304,44 +245,8 @@ ] }, { - "chainId": 651940, - "address": "0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881", - "name": "USD Coin", - "symbol": "USDC", - "decimals": 6, - "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", - "tags": [ - "stablecoin", - "defi" - ] - }, - { - "chainId": 25, - "address": "0x99B3511A2d315A497C8112C1fdd8D508d4B1E506", - "name": "Wrapped Ether (WETH9)", - "symbol": "WETH9", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", - "tags": [ - "defi", - "wrapped" - ] - }, - { - "chainId": 25, - "address": "0x3304b747E565a97ec8AC220b0B6A1f6ffDB837e6", - "name": "Wrapped Ether v10", - "symbol": "WETH10", - "decimals": 18, - "logoURI": "https://ipfs.io/ipfs/QmanDFPHxnbKd6SSNzzXHf9GbpL9dLXSphxDZSPPYE6ds4", - "tags": [ - "defi", - "wrapped" - ] - }, - { - "chainId": 25, - "address": "0x8c80A01F461f297Df7F9DA3A4f740D7297C8Ac85", + "chainId": 1, + "address": "0x514910771AF9Ca656af840dff83E8264EcF986CA", "name": "Chainlink Token", "symbol": "LINK", "decimals": 18, @@ -352,18 +257,483 @@ "ccip" ] }, + { + "chainId": 1, + "address": "0xDAe0faFD65385E7775Cf75b1398735155EF6aCD2", + "name": "Truth Network Token", + "symbol": "TRUU", + "decimals": 10, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "bridge" + ] + }, + { + "chainId": 1, + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 1, + "address": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 1, + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x25603ae4bff0b71d637b3573d1b6657f5f6d17ef", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x9f6d2578003fe04e58a9819a4943732f2a203a61", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x4d9bc6c74ba65e37c4139f0aec9fc5ddff28dcc4", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x4ab39b5bab7b463435209a9039bd40cf241f5a82", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x6f521cd9fcf7884cd4e9486c7790e818638e09dd", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x3f8c409c6072a2b6a4ff17071927ba70f80c725f", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x456373d095d6b9260f01709f93fccf1d8aa14d11", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x8e54c52d34a684e22865ac9f2d7c27c30561a7b9", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x04B2AE3c3bb3d70Df506FAd8717b0FBFC78ED7E6", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0xddc4063f770f7c49d00b5a10fb552e922aa39b2c", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0x145e8e8c49b6a021969dd9d2c01c8fea44374f61", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 10, + "address": "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", + "name": "Dai Stablecoin", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 10, + "address": "0x350a791Bfc2C21F9Ed5d10980Dad2e2638ffa7f6", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, + { + "chainId": 10, + "address": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 10, + "address": "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 10, + "address": "0x4200000000000000000000000000000000000006", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, { "chainId": 25, - "address": "0x948690147D2e50ffe50C5d38C14125aD6a9FA036", - "name": "USD W Token", - "symbol": "USDW", + "address": "0xf9f5D0ACD71C76F9476F10B3F3d3E201F0883C68", + "name": "AUD W Token", + "symbol": "AUDW", "decimals": 2, - "logoURI": "https://ipfs.io/ipfs/QmNPq4D5JXzurmi9jAhogVMzhAQRk1PZ1r9H3qQUV9gjDm", + "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", "tags": [ "stablecoin", "iso4217w" ] }, + { + "chainId": 25, + "address": "0x328Cd365Bb35524297E68ED28c6fF2C9557d1363", + "name": "CAD W Token", + "symbol": "CADW", + "decimals": 2, + "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", + "tags": [ + "stablecoin", + "iso4217w" + ] + }, + { + "chainId": 25, + "address": "0xc9750828124D4c10e7a6f4B655cA8487bD3842EB", + "name": "CHF W Token", + "symbol": "CHFW", + "decimals": 2, + "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", + "tags": [ + "stablecoin", + "iso4217w" + ] + }, + { + "chainId": 25, + "address": "0xff3084410A732231472Ee9f93F5855dA89CC5254", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x32aD687F24F77bF8C86605c202c829163Ac5Ab36", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0xB55F49D6316322d5caA96D34C6e4b1003BD3E670", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x7574d37F42528B47c88962931e48FC61608a4050", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x9f833b4f1012F52eb3317b09922a79c6EdFca77D", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0xe5c65A76A541368d3061fe9E7A2140cABB903dbF", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0xBb58fa16bAc8E789f09C14243adEE6480D8213A2", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x52aD62B8bD01154e2A4E067F8Dc4144C9988d203", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x932566E5bB6BEBF6B035B94f3DE1f75f126304Ec", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x72948a7a813B60b37Cd0c920C4657DbFF54312b8", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0xf1B771c95573113E993374c0c7cB2dc1a7908B12", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0xD517C0cF7013f988946A468c880Cc9F8e2A4BCbE", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, { "chainId": 25, "address": "0x58a8D8F78F1B65c06dAd7542eC46b299629A60dd", @@ -388,18 +758,6 @@ "iso4217w" ] }, - { - "chainId": 25, - "address": "0xf9f5D0ACD71C76F9476F10B3F3d3E201F0883C68", - "name": "AUD W Token", - "symbol": "AUDW", - "decimals": 2, - "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", - "tags": [ - "stablecoin", - "iso4217w" - ] - }, { "chainId": 25, "address": "0xeE17bB0322383fecCA2784fbE2d4CD7d02b1905B", @@ -414,11 +772,48 @@ }, { "chainId": 25, - "address": "0xc9750828124D4c10e7a6f4B655cA8487bD3842EB", - "name": "CHF W Token", - "symbol": "CHFW", + "address": "0x8c80A01F461f297Df7F9DA3A4f740D7297C8Ac85", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, + { + "chainId": 25, + "address": "0xc21223249CA28397B4B6541dfFaEcC539BfF0c59", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 25, + "address": "0x66e4286603D22FF153A6547700f37C7Eae42F8E2", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 25, + "address": "0x948690147D2e50ffe50C5d38C14125aD6a9FA036", + "name": "USD W Token", + "symbol": "USDW", "decimals": 2, - "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", + "logoURI": "https://ipfs.io/ipfs/QmNPq4D5JXzurmi9jAhogVMzhAQRk1PZ1r9H3qQUV9gjDm", "tags": [ "stablecoin", "iso4217w" @@ -426,45 +821,1745 @@ }, { "chainId": 25, - "address": "0x328Cd365Bb35524297E68ED28c6fF2C9557d1363", - "name": "CAD W Token", - "symbol": "CADW", - "decimals": 2, - "logoURI": "https://ipfs.io/ipfs/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K", + "address": "0x99B3511A2d315A497C8112C1fdd8D508d4B1E506", + "name": "Wrapped Ether (WETH9)", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 25, + "address": "0x3304b747E565a97ec8AC220b0B6A1f6ffDB837e6", + "name": "Wrapped Ether v10", + "symbol": "WETH10", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmanDFPHxnbKd6SSNzzXHf9GbpL9dLXSphxDZSPPYE6ds4", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x7062f35567BBAb4d98dc33af03B0d14Df42294D5", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", "tags": [ "stablecoin", - "iso4217w" + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x9AE7a6B311584D60Fa93f973950d609061875775", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0xD9f8710caeeBA3b3D423D7D14a918701426B5ef3", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x50b073d0D1D2f002745cb9FC28a057d5be84911c", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x1ED9E491A5eCd53BeF21962A5FCE24880264F63f", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x8b6EE72001cAFcb21D56a6c4686D6Db951d499A6", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0xA6eFb8783C8ad2740ec880e46D4f7E608E893B1B", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x5fbCE65524211BC1bFb0309fd9EE09E786c6D097", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x5355148C4740fcc3D7a96F05EdD89AB14851206b", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x9a1D0dBEE997929ED02fD19E0E199704d20914dB", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0xCB145bA9A370681e3545F60e55621eBf218B1031", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x73E0CF8BF861D376B3a4C87c136F975027f045ff", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 56, + "address": "0x1AF3F329e8BE154074D8769D1FFa4eE058B1DBc3", + "name": "Dai Stablecoin", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 56, + "address": "0x404460C6A5EdE2D891e8297795264fDe62ADBB75", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, + { + "chainId": 56, + "address": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 56, + "address": "0x55d398326f99059fF775485246999027B3197955", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 56, + "address": "0x2170Ed0880ac9A755fd29B2688956BD959F933F8", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0xddc4063f770f7c49d00b5a10fb552e922aa39b2c", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0xa7133c78e0ec74503a5941bcbd44257615b6b4f6", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x46d90d7947f1139477c206c39268923b99cf09e4", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x25603ae4bff0b71d637b3573d1b6657f5f6d17ef", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x8e54c52d34a684e22865ac9f2d7c27c30561a7b9", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x4d9bc6c74ba65e37c4139f0aec9fc5ddff28dcc4", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x9f6d2578003fe04e58a9819a4943732f2a203a61", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x145e8e8c49b6a021969dd9d2c01c8fea44374f61", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0x23873b85cfeb343eb952618e8c9e9bfb7f6a0d45", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0xc6189d404dc60cae7b48e2190e44770a03193e5f", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 100, + "address": "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", + "name": "Dai Stablecoin", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 100, + "address": "0xE2e73A1c69ecF83F464EFCE6A5be353a37cA09b2", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, + { + "chainId": 100, + "address": "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 100, + "address": "0x4ECaBa5870353805a9F068101A40E0f32ed605C6", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 100, + "address": "0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0xFb4B6Cc81211F7d886950158294A44C312abCA29", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0xc9750828124D4c10e7a6f4B655cA8487bD3842EB", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0xeE17bB0322383fecCA2784fbE2d4CD7d02b1905B", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x3CD9ee18db7ad13616FCC1c83bC6098e03968E66", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0xBeF5A0Bcc0E77740c910f197138cdD90F98d2427", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x948690147D2e50ffe50C5d38C14125aD6a9FA036", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x58a8D8F78F1B65c06dAd7542eC46b299629A60dd", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0xf9f5D0ACD71C76F9476F10B3F3d3E201F0883C68", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x328Cd365Bb35524297E68ED28c6fF2C9557d1363", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x9e6044d730d4183bF7a666293d257d035Fba6d44", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 137, + "address": "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063", + "name": "Dai Stablecoin", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 137, + "address": "0xb0897686c545045aFc77CF20eC7A532E3120E0F1", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, + { + "chainId": 137, + "address": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 137, + "address": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 137, + "address": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 138, + "address": "0xD51482e567c03899eecE3CAe8a058161FD56069D", + "name": "Australian Dollar (Compliant)", + "symbol": "cAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x54dBd40cF05e15906A2C21f600937e96787f5679", + "name": "Canadian Dollar (Compliant)", + "symbol": "cCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x873990849DDa5117d7C644f0aF24370797C03885", + "name": "Swiss Franc (Compliant)", + "symbol": "cCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x8085961F9cF02b4d800A3c6d386D31da4B34266a", + "name": "Euro Coin (Compliant)", + "symbol": "cEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0xdf4b71c61E5912712C1Bdd451416B9aC26949d72", + "name": "Tether EUR (Compliant)", + "symbol": "cEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x003960f16D9d34F2e98d62723B6721Fb92074aD2", + "name": "Pound Sterling (Compliant)", + "symbol": "cGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x350f54e4D23795f86A9c03988c7135357CCaD97c", + "name": "Tether GBP (Compliant)", + "symbol": "cGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0xEe269e1226a334182aace90056EE4ee5Cc8A6770", + "name": "Japanese Yen (Compliant)", + "symbol": "cJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0xf22258f57794CC8E06237084b353Ab30fFfa640b", + "name": "Compliant USD Coin", + "symbol": "cUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x93E66202A11B1772E55407B32B44e5Cd8eda7f22", + "name": "Compliant Tether USD", + "symbol": "cUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant" + ] + }, + { + "chainId": 138, + "address": "0x290E52a8819A4fbD0714E517225429aA2B70EC6b", + "name": "Gold (Compliant)", + "symbol": "cXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant" + ], + "extensions": { + "unitOfAccount": "troy_ounce", + "unitDescription": "1 full token (10^decimals base units) = 1 troy oz fine gold" + } + }, + { + "chainId": 138, + "address": "0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E", + "name": "Tether XAU (Compliant)", + "symbol": "cXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant" + ], + "extensions": { + "unitOfAccount": "troy_ounce", + "unitDescription": "1 full token (10^decimals base units) = 1 troy oz fine gold" + } + }, + { + "chainId": 138, + "address": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6", + "name": "ETH/USD Price Feed", + "symbol": "ETH-USD", + "decimals": 8, + "logoURI": "https://ipfs.io/ipfs/QmPZuycjyJEe2otREuQ5HirvPJ8X6Yc6MBtwz1VhdD79pY", + "tags": [ + "oracle", + "price-feed" + ] + }, + { + "chainId": 138, + "address": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, + { + "chainId": 138, + "address": "0x71D6687F38b93CCad569Fa6352c876eea967201b", + "name": "USD Coin (Official Mirror)", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 138, + "address": "0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1", + "name": "Tether USD (Official Mirror)", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 138, + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 138, + "address": "0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9F", + "name": "Wrapped Ether v10", + "symbol": "WETH10", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmanDFPHxnbKd6SSNzzXHf9GbpL9dLXSphxDZSPPYE6ds4", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 1111, + "address": "0xE3F5a90F9cb311505cd691a46596599aA1A0AD7D", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 1111, + "address": "0xA649325Aa7C5093d12D6F98EB4378deAe68CE23F", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 8453, + "address": "0xa846aead3071df1b6439d5d813156ace7c2c1da1", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0xdc383c489533a4dd9a6bd3007386e25d5078b878", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0xc1535e88578d984f12eab55863376b8d8b9fb05a", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0xcb145ba9a370681e3545f60e55621ebf218b1031", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x73e0cf8bf861d376b3a4c87c136f975027f045ff", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x2a0023ad5ce1ac6072b454575996dffb1bb11b16", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x22b98130ab4d9c355512b25ade4c35e75a4e7e89", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x29828e9ab2057cd3df3c9211455ae1f76e53d2af", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x04B2AE3c3bb3d70Df506FAd8717b0FBFC78ED7E6", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x7e4b4682453bcce19ec903fb69153d3031986bc4", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0xcc6ae6016d564e9ab82aaff44d65e05a9b18951c", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 8453, + "address": "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", + "name": "Dai Stablecoin", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 8453, + "address": "0x88Fb150BDc53A65fe94Dea0c9BA0a6dAf8C6e196", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, + { + "chainId": 8453, + "address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 8453, + "address": "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 8453, + "address": "0x4200000000000000000000000000000000000006", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0xc1535e88578d984f12eab55863376b8d8b9fb05a", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0xcc6ae6016d564e9ab82aaff44d65e05a9b18951c", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x7e4b4682453bcce19ec903fb69153d3031986bc4", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x2a0023ad5ce1ac6072b454575996dffb1bb11b16", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x22b98130ab4d9c355512b25ade4c35e75a4e7e89", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0xa846aead3071df1b6439d5d813156ace7c2c1da1", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x29828e9ab2057cd3df3c9211455ae1f76e53d2af", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0xdc383c489533a4dd9a6bd3007386e25d5078b878", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x73ADaF7dBa95221c080db5631466d2bC54f6a76B", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0xa7762b63c4871581885ad17c5714ebb286a7480b", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0x66568899ffe8f00b25dc470e878b65a478994e76", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 42161, + "address": "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", + "name": "Dai Stablecoin", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 42161, + "address": "0xf97f4df75117a78c1A5a0DBb814Af92458539FB4", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, + { + "chainId": 42161, + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 42161, + "address": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 42161, + "address": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 42220, + "address": "0xd07294e6E917e07dfDcee882dd1e2565085C2ae0", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, + { + "chainId": 42220, + "address": "0xcebA9300f2b948710d2653dD7B07f33A8B32118C", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 42220, + "address": "0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 42220, + "address": "0x122013fd7dF1C6F636a5bb8f03108E876548b455", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x04e1e22b0d41e99f4275bd40a50480219bc9a223", + "name": "Australian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWAUDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x1872e033b30f3ce0498847926857433e0146394e", + "name": "Canadian Dollar (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCADC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0xc2fa05f12a75ac84ea778af9d6935ca807275e55", + "name": "Swiss Franc (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWCHFC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x84353ed1f0c7a703a17abad19b0db15bc9a5e3e5", + "name": "Euro Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0xfc7d256e48253f7a7e08f0e55b9ff7039eb2524c", + "name": "Tether EUR (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWEURT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0xbdf0c4ea1d81e8e769b0f41389a2c733e3ff723e", + "name": "Pound Sterling (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x4611d3424e059392a52b957e508273bc761c80f2", + "name": "Tether GBP (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWGBPT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x3714b1a312e0916c7dcdc4edf480fc0339e59a59", + "name": "Japanese Yen (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWJPYC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x0C242b513008Cd49C89078F5aFb237A3112251EB", + "name": "USD Coin (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x8142BA530B08f3950128601F00DaaA678213DFdf", + "name": "Tether USD (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWUSDT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png", + "tags": [ + "stablecoin", + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0x4f95297c23d9f4a1032b1c6a2e553225cb175bee", + "name": "Gold (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUC", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0xd2b4dbf2f6bd6704e066d752eec61fb0be953fd3", + "name": "Tether XAU (Compliant Wrapped ISO-4217 M1)", + "symbol": "cWXAUT", + "decimals": 6, + "logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png", + "tags": [ + "defi", + "compliant", + "wrapped" + ] + }, + { + "chainId": 43114, + "address": "0xd586E7F844cEa2F87f50152665BCbc2C279D8d70", + "name": "Dai Stablecoin", + "symbol": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 43114, + "address": "0x5947BB275c521040051D82396192181b413227A3", + "name": "Chainlink Token", + "symbol": "LINK", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A", + "tags": [ + "defi", + "oracle", + "ccip" + ] + }, + { + "chainId": 43114, + "address": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 43114, + "address": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 43114, + "address": "0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 651940, + "address": "0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 651940, + "address": "0x015B1897Ed5279930bC2Be46F661894d219292A6", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP", + "tags": [ + "stablecoin", + "defi" + ] + }, + { + "chainId": 651940, + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "wrapped" + ] + }, + { + "chainId": 11155111, + "address": "0x6cAEfA7446E967018330cCeC5BA7A43956a45137", + "name": "Truth Network Token (Sepolia)", + "symbol": "TRUU", + "decimals": 10, + "logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong", + "tags": [ + "defi", + "bridge" ] } - ], - "tags": { - "defi": { - "name": "DeFi", - "description": "Decentralized Finance tokens" - }, - "wrapped": { - "name": "Wrapped", - "description": "Wrapped tokens representing native assets" - }, - "oracle": { - "name": "Oracle", - "description": "Oracle price feed contracts" - }, - "price-feed": { - "name": "Price Feed", - "description": "Price feed oracle contracts" - }, - "stablecoin": { - "name": "Stablecoin", - "description": "Stable value tokens pegged to fiat" - }, - "compliant": { - "name": "Compliant", - "description": "Regulatory compliant tokens" - }, - "iso4217w": { - "name": "ISO4217W", - "description": "ISO 4217 compliant wrapped fiat tokens" - } - } + ] } diff --git a/backend/database/migrations/0010_track_schema.auth_only.sql b/backend/database/migrations/0010_track_schema.auth_only.sql new file mode 100644 index 0000000..ba1f1b0 --- /dev/null +++ b/backend/database/migrations/0010_track_schema.auth_only.sql @@ -0,0 +1,60 @@ +-- Migration: Track auth/operator tables for shared Blockscout database +-- Description: Creates only the explorer-owned auth/operator tables that do not +-- conflict with Blockscout's existing addresses/token_transfers schema. + +CREATE TABLE IF NOT EXISTS operator_events ( + id SERIAL PRIMARY KEY, + event_type VARCHAR(100) NOT NULL, + chain_id INTEGER, + operator_address VARCHAR(42) NOT NULL, + target_resource VARCHAR(200), + action VARCHAR(100) NOT NULL, + details JSONB, + ip_address INET, + user_agent TEXT, + timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_operator_events_type ON operator_events(event_type); +CREATE INDEX IF NOT EXISTS idx_operator_events_operator ON operator_events(operator_address); +CREATE INDEX IF NOT EXISTS idx_operator_events_timestamp ON operator_events(timestamp); +CREATE INDEX IF NOT EXISTS idx_operator_events_chain ON operator_events(chain_id); + +CREATE TABLE IF NOT EXISTS operator_ip_whitelist ( + id SERIAL PRIMARY KEY, + operator_address VARCHAR(42) NOT NULL, + ip_address INET NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(operator_address, ip_address) +); + +CREATE INDEX IF NOT EXISTS idx_operator_whitelist_operator ON operator_ip_whitelist(operator_address); +CREATE INDEX IF NOT EXISTS idx_operator_whitelist_ip ON operator_ip_whitelist(ip_address); + +CREATE TABLE IF NOT EXISTS operator_roles ( + id SERIAL PRIMARY KEY, + address VARCHAR(42) NOT NULL UNIQUE, + track_level INTEGER NOT NULL DEFAULT 4, + roles TEXT[], + approved BOOLEAN DEFAULT FALSE, + approved_by VARCHAR(42), + approved_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_operator_roles_address ON operator_roles(address); +CREATE INDEX IF NOT EXISTS idx_operator_roles_approved ON operator_roles(approved); + +CREATE TABLE IF NOT EXISTS wallet_nonces ( + id SERIAL PRIMARY KEY, + address VARCHAR(42) NOT NULL UNIQUE, + nonce VARCHAR(64) NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_wallet_nonces_address ON wallet_nonces(address); +CREATE INDEX IF NOT EXISTS idx_wallet_nonces_expires ON wallet_nonces(expires_at); diff --git a/backend/libs/go-http-middleware/client_ip.go b/backend/libs/go-http-middleware/client_ip.go new file mode 100644 index 0000000..ba0003f --- /dev/null +++ b/backend/libs/go-http-middleware/client_ip.go @@ -0,0 +1,111 @@ +package httpmiddleware + +import ( + "net" + "net/http" + "os" + "strings" +) + +// ClientIP returns the best-known client IP for a request. +// +// Forwarded headers are only trusted when the immediate remote address belongs +// to an explicitly trusted proxy listed in TRUST_PROXY_IPS and/or +// TRUST_PROXY_CIDRS. +func ClientIP(r *http.Request) string { + remoteIP := parseRemoteIP(r.RemoteAddr) + if remoteIP == "" { + remoteIP = strings.TrimSpace(r.RemoteAddr) + } + + if !isTrustedProxy(remoteIP) { + return remoteIP + } + + if forwarded := forwardedClientIP(r); forwarded != "" { + return forwarded + } + + return remoteIP +} + +func parseRemoteIP(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + + if host, _, err := net.SplitHostPort(trimmed); err == nil { + return host + } + + if ip := net.ParseIP(trimmed); ip != nil { + return ip.String() + } + + return trimmed +} + +func forwardedClientIP(r *http.Request) string { + for _, header := range []string{"X-Forwarded-For", "X-Real-IP"} { + raw := strings.TrimSpace(r.Header.Get(header)) + if raw == "" { + continue + } + + if header == "X-Forwarded-For" { + for _, part := range strings.Split(raw, ",") { + candidate := strings.TrimSpace(part) + if ip := net.ParseIP(candidate); ip != nil { + return ip.String() + } + } + continue + } + + if ip := net.ParseIP(raw); ip != nil { + return ip.String() + } + } + + return "" +} + +func isTrustedProxy(remoteIP string) bool { + ip := net.ParseIP(strings.TrimSpace(remoteIP)) + if ip == nil { + return false + } + + for _, exact := range splitEnvList("TRUST_PROXY_IPS") { + if trusted := net.ParseIP(exact); trusted != nil && trusted.Equal(ip) { + return true + } + } + + for _, cidr := range splitEnvList("TRUST_PROXY_CIDRS") { + _, network, err := net.ParseCIDR(cidr) + if err == nil && network.Contains(ip) { + return true + } + } + + return false +} + +func splitEnvList(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 +} diff --git a/backend/libs/go-http-middleware/client_ip_test.go b/backend/libs/go-http-middleware/client_ip_test.go new file mode 100644 index 0000000..5f9de5e --- /dev/null +++ b/backend/libs/go-http-middleware/client_ip_test.go @@ -0,0 +1,31 @@ +package httpmiddleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestClientIPFallsBackToRemoteAddrWhenProxyIsUntrusted(t *testing.T) { + t.Setenv("TRUST_PROXY_IPS", "") + t.Setenv("TRUST_PROXY_CIDRS", "") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.10:8443" + req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.10") + + require.Equal(t, "10.0.0.10", ClientIP(req)) +} + +func TestClientIPUsesForwardedHeadersFromTrustedProxy(t *testing.T) { + t.Setenv("TRUST_PROXY_IPS", "") + t.Setenv("TRUST_PROXY_CIDRS", "10.0.0.0/8") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.10:8443" + req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.10") + + require.Equal(t, "203.0.113.9", ClientIP(req)) +} diff --git a/config/address-inventory.json b/config/address-inventory.json index f23e379..0cc860b 100644 --- a/config/address-inventory.json +++ b/config/address-inventory.json @@ -1,68 +1,98 @@ { "description": "Address inventory moved out of explorer-monorepo/.env during dotenv cleanup. This file preserves the previous env-based address reference set for scripts and documentation review.", - "updated": "2026-03-27", + "updated": "2026-04-04", "inventory": { "LINK_TOKEN": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", "ORACLE_AGGREGATOR_ADDRESS": "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506", - "CCIP_ROUTER_ADDRESS": "0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e", - "CCIPWETH9_BRIDGE": "0x89dd12025bfCD38A168455A44B400e913ED33BE2", + "CCIP_ROUTER_ADDRESS": "0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817", + "CCIP_ROUTER_DIRECT_LEGACY": "0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e", + "CCIPWETH9_BRIDGE": "0xcacfd227A040002e49e2e01626363071324f820a", + "CCIPWETH9_BRIDGE_DIRECT_LEGACY": "0x971cD9D156f193df8051E48043C476e53ECd4693", "CCIPWETH10_BRIDGE": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0", "CCIP_CHAIN138_FEE_TOKEN": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", "WETH9_ADDRESS": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "WETH10_ADDRESS": "0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f", + "COMPLIANT_USDT_V2": "0x9FBfab33882Efe0038DAa608185718b772EE5660", + "COMPLIANT_USDC_V2": "0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d", + "CUSDT_V2_ADDRESS_138": "0x9FBfab33882Efe0038DAa608185718b772EE5660", + "CUSDC_V2_ADDRESS_138": "0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d", "CCIP_RECEIVER": "0x6C4BEE679d37629330daeF141BEd5b4eD2Ec14f6", "CCIP_LOGGER": "0xF597ABbe5E1544845C6Ba92a6884B4D601ffa334", + "CW_L1_BRIDGE_CHAIN138": "0x152ed3e9912161b76bdfd368d0c84b7c31c10de7", + "DEPLOYER_ADMIN_138": "0x4A666F96fC8764181194447A7dFdb7d471b301C8", "ORACLE_PROXY_ADDRESS": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6", - "CCIP_ROUTER_138": "0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e", + "CCIP_ROUTER_138": "0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817", "CCIP_SENDER_138": "0x105F8A15b819948a89153505762444Ee9f324684", "CCIP_RECEIVER_138": "0x6C4BEE679d37629330daeF141BEd5b4eD2Ec14f6", "CCIP_LOGGER_138": "0xF597ABbe5E1544845C6Ba92a6884B4D601ffa334", - "CCIPWETH9_BRIDGE_138": "0x89dd12025bfCD38A168455A44B400e913ED33BE2", + "CCIPWETH9_BRIDGE_138": "0xcacfd227A040002e49e2e01626363071324f820a", "CCIPWETH10_BRIDGE_138": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0", - "LINK_TOKEN_138": "0x514910771AF9Ca656af840dff83E8264EcF986CA", + "ENHANCED_SWAP_ROUTER_V2_ADDRESS": "0xF1c93F54A5C2fc0d7766Ccb0Ad8f157DFB4C99Ce", + "INTENT_BRIDGE_COORDINATOR_V2_ADDRESS": "0x7D0022B7e8360172fd9C0bB6778113b7Ea3674E7", + "DODO_ROUTE_EXECUTOR_ADAPTER": "0x88495B3dccEA93b0633390fDE71992683121Fa62", + "DODO_V3_ROUTE_EXECUTOR_ADAPTER": "0x9Cb97adD29c52e3B81989BcA2E33D46074B530eF", + "UNISWAP_V3_ROUTE_EXECUTOR_ADAPTER": "0x960D6db4E78705f82995690548556fb2266308EA", + "BALANCER_ROUTE_EXECUTOR_ADAPTER": "0x4E1B71B69188Ab45021c797039b4887a4924157A", + "CURVE_ROUTE_EXECUTOR_ADAPTER": "0x5f0E07071c41ACcD2A1b1032D3bd49b323b9ADE6", + "ONEINCH_ROUTE_EXECUTOR_ADAPTER": "0x8168083d29b3293F215392A49D16e7FeF4a02600", + "UNISWAP_V3_ROUTER": "0xde9cD8ee2811E6E64a41D5F68Be315d33995975E", + "UNISWAP_QUOTER_ADDRESS": "0x6abbB1CEb2468e748a03A00CD6aA9BFE893AFa1f", + "CHAIN_138_UNISWAP_V3_FACTORY": "0x2f7219276e3ce367dB9ec74C1196a8ecEe67841C", + "CHAIN_138_UNISWAP_V3_ROUTER": "0xde9cD8ee2811E6E64a41D5F68Be315d33995975E", + "UNISWAP_V3_WETH_USDT_POOL": "0xa893add35aEfe6A6d858EB01828bE4592f12C9F5", + "UNISWAP_V3_WETH_USDC_POOL": "0xEC745bfb6b3cd32f102d594E5F432d8d85B19391", + "BALANCER_VAULT": "0x96423d7C1727698D8a25EbFB88131e9422d1a3C3", + "BALANCER_WETH_USDT_POOL_ID": "0x877cd220759e8c94b82f55450c85d382ae06856c426b56d93092a420facbc324", + "BALANCER_WETH_USDC_POOL_ID": "0xd8dfb18a6baf9b29d8c2dbd74639db87ac558af120df5261dab8e2a5de69013b", + "CURVE_3POOL": "0xE440Ec15805BE4C7BabCD17A63B8C8A08a492e0f", + "ONEINCH_ROUTER": "0x500B84b1Bc6F59C1898a5Fe538eA20A758757A4F", + "CROSS_CHAIN_FLASH_BRIDGE_ADAPTER": "0xBe9e0B2d4cF6A3b2994d6f2f0904D2B165eB8ffC", + "CROSS_CHAIN_FLASH_REPAY_RECEIVER": "0xD084b68cB4B1ef2cBA09CF99FB1B6552fd9b4859", + "CROSS_CHAIN_FLASH_VAULT_CREDIT_RECEIVER": "0x89F7a1fcbBe104BeE96Da4b4b6b7d3AF85f7E661", + "LINK_TOKEN_138": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", "WETH9_138": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "WETH10_138": "0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f", "CCIP_ROUTER_MAINNET": "0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D", - "CCIPWETH9_BRIDGE_MAINNET": "0x3304b747E565a97ec8AC220b0B6A1f6ffDB837e6", - "CCIPWETH10_BRIDGE_MAINNET": "0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e", + "CCIPWETH9_BRIDGE_MAINNET": "0xc9901ce2Ddb6490FAA183645147a87496d8b20B6", + "CCIPWETH10_BRIDGE_MAINNET": "0x04E1e22B0D41e99f4275bd40A50480219bc9A223", "LINK_TOKEN_MAINNET": "0x514910771AF9Ca656af840dff83E8264EcF986CA", "WETH9_MAINNET": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "WETH10_MAINNET": "0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f", "TRANSACTION_MIRROR_MAINNET": "0x4CF42c4F1dBa748601b8938be3E7ABD732E87cE9", "MAINNET_TETHER_MAINNET": "0x15DF1D5BFDD8Aa4b380445D4e3E9B38d34283619", "CCIP_ROUTER_BSC": "0xE1053aE1857476f36F3bAdEe8D26609d1887a44A", - "CCIPWETH9_BRIDGE_BSC": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e", - "CCIPWETH10_BRIDGE_BSC": "0x105f8a15b819948a89153505762444ee9f324684", + "CCIPWETH9_BRIDGE_BSC": "0x24293CA562aE1100E60a4640FF49bd656cFf93B4", + "CCIPWETH10_BRIDGE_BSC": "0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c", "LINK_TOKEN_BSC": "0x404460C6A5EdE2D891e8297795264fDe62ADBB75", "WETH9_BSC": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0", "WETH10_BSC": "0xAb57BF30F1354CA0590af22D8974c7f24DB2DbD7", "CCIP_ROUTER_POLYGON": "0x3C3D92629A02a8D95D5CB9650fe49C3544f69B43", - "CCIPWETH9_BRIDGE_POLYGON": "0xa780ef19a041745d353c9432f2a7f5a241335ffe", - "CCIPWETH10_BRIDGE_POLYGON": "0xdab0591e5e89295ffad75a71dcfc30c5625c4fa2", + "CCIPWETH9_BRIDGE_POLYGON": "0xF7736443f02913e7e0773052103296CfE1637448", + "CCIPWETH10_BRIDGE_POLYGON": "0x0CA60e6f8589c540200daC9D9Cb27BC2e48eE66A", "LINK_TOKEN_POLYGON": "0x53E0bca35eC356BD5ddDFebbD1Fc0fD03FaBad39", "WETH9_POLYGON": "0x24293CA562aE1100E60a4640FF49bd656cFf93B4", "WETH10_POLYGON": "0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c", "CCIP_ROUTER_AVALANCHE": "0xF694E193200268f9a4868e4Aa017A0118C9a8177", - "CCIPWETH9_BRIDGE_AVALANCHE": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e", - "CCIPWETH10_BRIDGE_AVALANCHE": "0x105f8a15b819948a89153505762444ee9f324684", + "CCIPWETH9_BRIDGE_AVALANCHE": "0x24293CA562aE1100E60a4640FF49bd656cFf93B4", + "CCIPWETH10_BRIDGE_AVALANCHE": "0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c", "LINK_TOKEN_AVALANCHE": "0x5947BB275c521040051E823857d752Cac58008AD", "WETH9_AVALANCHE": "0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08", "WETH10_AVALANCHE": "0x89dd12025bfCD38A168455A44B400e913ED33BE2", "CCIP_ROUTER_BASE": "0xcc22AB6F94F1aBB4de9CCF9046f7a0AD1Ce4d716", - "CCIPWETH9_BRIDGE_BASE": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e", - "CCIPWETH10_BRIDGE_BASE": "0x105f8a15b819948a89153505762444ee9f324684", + "CCIPWETH9_BRIDGE_BASE": "0x24293CA562aE1100E60a4640FF49bd656cFf93B4", + "CCIPWETH10_BRIDGE_BASE": "0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c", "LINK_TOKEN_BASE": "0x88Fb150BDc53A65fe94Dea0c9Ba0e666F144f907", "WETH9_BASE": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0", "WETH10_BASE": "0xAb57BF30F1354CA0590af22D8974c7f24DB2DbD7", "CCIP_ROUTER_ARBITRUM": "0x1619DE6B6B20eD217a58d00f37B9d47C7663feca", - "CCIPWETH9_BRIDGE_ARBITRUM": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e", - "CCIPWETH10_BRIDGE_ARBITRUM": "0x105f8a15b819948a89153505762444ee9f324684", + "CCIPWETH9_BRIDGE_ARBITRUM": "0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c", + "CCIPWETH10_BRIDGE_ARBITRUM": "0x73376eB92c16977B126dB9112936A20Fa0De3442", "LINK_TOKEN_ARBITRUM": "0xf97f4df75117a78c1A5a0DBb814Af92458539FB4", "WETH9_ARBITRUM": "0x89dd12025bfCD38A168455A44B400e913ED33BE2", "WETH10_ARBITRUM": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0", "CCIP_ROUTER_OPTIMISM": "0x261c05167db67Be2E2dc4a347C4E6B000C677852", - "CCIPWETH9_BRIDGE_OPTIMISM": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e", - "CCIPWETH10_BRIDGE_OPTIMISM": "0x105f8a15b819948a89153505762444ee9f324684", + "CCIPWETH9_BRIDGE_OPTIMISM": "0x6e94e53F73893b2a6784Df663920D31043A6dE07", + "CCIPWETH10_BRIDGE_OPTIMISM": "0x24293CA562aE1100E60a4640FF49bd656cFf93B4", "LINK_TOKEN_OPTIMISM": "0x350a791Bfc2C21F9Ed5d10980Dad2e2638ffa7f6", "WETH9_OPTIMISM": "0x89dd12025bfCD38A168455A44B400e913ED33BE2", "WETH10_OPTIMISM": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0", diff --git a/config/ccip-destination-matrix.json b/config/ccip-destination-matrix.json index 1851b84..d5fdc8d 100644 --- a/config/ccip-destination-matrix.json +++ b/config/ccip-destination-matrix.json @@ -1,54 +1,54 @@ { "description": "Canonical CCIP destination selector and bridge matrix used by explorer-monorepo operator scripts.", - "updated": "2026-03-27", + "updated": "2026-04-03", "chains": [ { "name": "BSC", "selector": "11344663589394136015", - "weth9Bridge": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e", - "weth10Bridge": "0x105f8a15b819948a89153505762444ee9f324684", + "weth9Bridge": "0x24293CA562aE1100E60a4640FF49bd656cFf93B4", + "weth10Bridge": "0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c", "rpcUrl": "https://bsc-dataseed.binance.org" }, { "name": "Polygon", "selector": "4051577828743386545", - "weth9Bridge": "0xa780ef19a041745d353c9432f2a7f5a241335ffe", - "weth10Bridge": "0xdab0591e5e89295ffad75a71dcfc30c5625c4fa2", + "weth9Bridge": "0xF7736443f02913e7e0773052103296CfE1637448", + "weth10Bridge": "0x0CA60e6f8589c540200daC9D9Cb27BC2e48eE66A", "rpcUrl": "https://polygon-rpc.com" }, { "name": "Avalanche", "selector": "6433500567565415381", - "weth9Bridge": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e", - "weth10Bridge": "0x105f8a15b819948a89153505762444ee9f324684", + "weth9Bridge": "0x24293CA562aE1100E60a4640FF49bd656cFf93B4", + "weth10Bridge": "0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c", "rpcUrl": "https://api.avax.network/ext/bc/C/rpc" }, { "name": "Base", "selector": "15971525489660198786", - "weth9Bridge": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e", - "weth10Bridge": "0x105f8a15b819948a89153505762444ee9f324684", + "weth9Bridge": "0x24293CA562aE1100E60a4640FF49bd656cFf93B4", + "weth10Bridge": "0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c", "rpcUrl": "https://mainnet.base.org" }, { "name": "Arbitrum", "selector": "4949039107694359620", - "weth9Bridge": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e", - "weth10Bridge": "0x105f8a15b819948a89153505762444ee9f324684", + "weth9Bridge": "0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c", + "weth10Bridge": "0x73376eB92c16977B126dB9112936A20Fa0De3442", "rpcUrl": "https://arb1.arbitrum.io/rpc" }, { "name": "Optimism", "selector": "3734403246176062136", - "weth9Bridge": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e", - "weth10Bridge": "0x105f8a15b819948a89153505762444ee9f324684", + "weth9Bridge": "0x6e94e53F73893b2a6784Df663920D31043A6dE07", + "weth10Bridge": "0x24293CA562aE1100E60a4640FF49bd656cFf93B4", "rpcUrl": "https://mainnet.optimism.io" }, { "name": "Ethereum", "selector": "5009297550715157269", - "weth9Bridge": "0x2A0840e5117683b11682ac46f5CF5621E67269E3", - "weth10Bridge": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03", + "weth9Bridge": "0xc9901ce2Ddb6490FAA183645147a87496d8b20B6", + "weth10Bridge": "0x04E1e22B0D41e99f4275bd40A50480219bc9A223", "rpcUrl": "https://eth.llamarpc.com" } ] diff --git a/deployment/DEPLOYMENT_SUMMARY.md b/deployment/DEPLOYMENT_SUMMARY.md index 1a4d61f..e83d6ff 100644 --- a/deployment/DEPLOYMENT_SUMMARY.md +++ b/deployment/DEPLOYMENT_SUMMARY.md @@ -1,183 +1,40 @@ # Deployment Summary -## Complete Deployment Package +This directory contains two different kinds of deployment material: -All deployment files and scripts have been created and are ready for use. +- current production references for the live explorer stack +- older monolithic deployment scaffolding that is still useful as background, but is no longer the authoritative description of production -## 📁 File Structure +## Current Production Summary -``` -deployment/ -├── DEPLOYMENT_GUIDE.md # Complete step-by-step guide (1,079 lines) -├── DEPLOYMENT_TASKS.md # Detailed 71-task checklist (561 lines) -├── DEPLOYMENT_CHECKLIST.md # Interactive checklist (204 lines) -├── DEPLOYMENT_SUMMARY.md # This file -├── QUICK_DEPLOY.md # Quick command reference -├── README.md # Documentation overview -├── ENVIRONMENT_TEMPLATE.env # Environment variables template -│ -├── nginx/ -│ └── explorer.conf # Complete Nginx configuration -│ -├── cloudflare/ -│ └── tunnel-config.yml # Cloudflare Tunnel template -│ -├── systemd/ -│ ├── explorer-indexer.service -│ ├── explorer-api.service -│ ├── explorer-frontend.service -│ └── cloudflared.service -│ -├── fail2ban/ -│ ├── nginx.conf # Nginx filter -│ └── jail.local # Jail configuration -│ -└── scripts/ - ├── deploy-lxc.sh # Automated LXC setup - ├── install-services.sh # Install systemd services - ├── setup-nginx.sh # Setup Nginx - ├── setup-cloudflare-tunnel.sh # Setup Cloudflare Tunnel - ├── setup-firewall.sh # Configure firewall - ├── setup-fail2ban.sh # Configure Fail2ban - ├── setup-backup.sh # Setup backup system - ├── setup-health-check.sh # Setup health monitoring - ├── build-all.sh # Build all applications - ├── verify-deployment.sh # Verify deployment - └── full-deploy.sh # Full automated deployment -``` +Start with [`LIVE_DEPLOYMENT_MAP.md`](./LIVE_DEPLOYMENT_MAP.md). -## 🚀 Quick Start +The live explorer is currently assembled from separate deployment paths: -### Option 1: Automated Deployment -```bash -# Run full automated deployment -sudo ./deployment/scripts/full-deploy.sh -``` +| Component | Live service | Canonical deploy path | +|---|---|---| +| Next frontend | `solacescanscout-frontend.service` | [`scripts/deploy-next-frontend-to-vmid5000.sh`](../scripts/deploy-next-frontend-to-vmid5000.sh) | +| Explorer config/API | `explorer-config-api.service` | [`scripts/deploy-explorer-ai-to-vmid5000.sh`](../scripts/deploy-explorer-ai-to-vmid5000.sh) | +| Static config assets | nginx static files under `/var/www/html` | [`scripts/deploy-explorer-config-to-vmid5000.sh`](../scripts/deploy-explorer-config-to-vmid5000.sh) | +| Relay fleet | `ccip-relay*.service` on `r630-01` | host-side `config/systemd/ccip-relay*.service` | -### Option 2: Step-by-Step Manual -```bash -# 1. Read the guide -cat deployment/DEPLOYMENT_GUIDE.md +## Public Verification -# 2. Follow tasks -# Use deployment/DEPLOYMENT_TASKS.md +- [`check-explorer-health.sh`](../scripts/check-explorer-health.sh) +- [`check-explorer-e2e.sh`](../../scripts/verify/check-explorer-e2e.sh) +- `https://explorer.d-bis.org/api/config/capabilities` +- `https://explorer.d-bis.org/explorer-api/v1/track1/bridge/status` +- `https://explorer.d-bis.org/explorer-api/v1/mission-control/stream` -# 3. Track progress -# Use deployment/DEPLOYMENT_CHECKLIST.md -``` +## Legacy Material In This Directory -## 📋 Deployment Phases +These files remain in the repo, but they describe an older generalized package: -1. **LXC Container Setup** (8 tasks) - - Create container - - Configure resources - - Install base packages - -2. **Application Installation** (12 tasks) - - Install Go, Node.js, Docker - - Clone repository - - Build applications - -3. **Database Setup** (10 tasks) - - Install PostgreSQL + TimescaleDB - - Create database - - Run migrations - -4. **Infrastructure Services** (6 tasks) - - Deploy Elasticsearch - - Deploy Redis - -5. **Application Services** (10 tasks) - - Configure environment - - Create systemd services - - Start services - -6. **Nginx Reverse Proxy** (9 tasks) - - Install Nginx - - Configure reverse proxy - - Set up SSL - -7. **Cloudflare Configuration** (18 tasks) - - Configure DNS - - Set up SSL/TLS - - Configure Tunnel - - Set up WAF - - Configure caching - -8. **Security Hardening** (12 tasks) - - Configure firewall - - Set up Fail2ban - - Configure backups - - Harden SSH - -9. **Monitoring** (8 tasks) - - Set up health checks - - Configure logging - - Set up alerts - -## 🔧 Available Scripts - -| Script | Purpose | -|--------|---------| -| `deploy-lxc.sh` | Automated LXC container setup | -| `build-all.sh` | Build all applications | -| `install-services.sh` | Install systemd service files | -| `setup-nginx.sh` | Configure Nginx | -| `setup-cloudflare-tunnel.sh` | Setup Cloudflare Tunnel | -| `setup-firewall.sh` | Configure UFW firewall | -| `setup-fail2ban.sh` | Configure Fail2ban | -| `setup-backup.sh` | Setup backup system | -| `setup-health-check.sh` | Setup health monitoring | -| `verify-deployment.sh` | Verify deployment | -| `full-deploy.sh` | Full automated deployment | - -## 📝 Configuration Files - -- **Nginx**: `nginx/explorer.conf` -- **Cloudflare Tunnel**: `cloudflare/tunnel-config.yml` -- **Systemd Services**: `systemd/*.service` -- **Fail2ban**: `fail2ban/*.conf` -- **Environment Template**: `ENVIRONMENT_TEMPLATE.env` - -## ✅ Verification Checklist - -After deployment, verify: - -- [ ] All services running -- [ ] API responding: `curl http://localhost:8080/health` -- [ ] Frontend loading: `curl http://localhost:3000` -- [ ] Nginx proxying: `curl http://localhost/api/health` -- [ ] Database accessible -- [ ] DNS resolving -- [ ] SSL working (if direct connection) -- [ ] Cloudflare Tunnel connected (if using) -- [ ] Firewall configured -- [ ] Backups running - -## 🆘 Troubleshooting - -See `QUICK_DEPLOY.md` for: -- Common issues -- Quick fixes -- Emergency procedures - -## 📊 Statistics - -- **Total Tasks**: 71 -- **Documentation**: 1,844+ lines -- **Scripts**: 11 automation scripts -- **Config Files**: 8 configuration templates -- **Estimated Time**: 6-8 hours (first deployment) - -## 🎯 Next Steps - -1. Review `DEPLOYMENT_GUIDE.md` -2. Prepare environment (Proxmox, Cloudflare) -3. Run deployment scripts -4. Verify deployment -5. Configure monitoring - ---- - -**All deployment files are ready!** +- `DEPLOYMENT_GUIDE.md` +- `DEPLOYMENT_TASKS.md` +- `DEPLOYMENT_CHECKLIST.md` +- `QUICK_DEPLOY.md` +- `systemd/explorer-api.service` +- `systemd/explorer-frontend.service` +Treat those as scaffold or historical reference unless they have been explicitly updated to match the live split architecture. diff --git a/deployment/LIVE_DEPLOYMENT_MAP.md b/deployment/LIVE_DEPLOYMENT_MAP.md new file mode 100644 index 0000000..053aff1 --- /dev/null +++ b/deployment/LIVE_DEPLOYMENT_MAP.md @@ -0,0 +1,94 @@ +# Live Deployment Map + +Current production deployment map for `explorer.d-bis.org`. + +This file is the authoritative reference for the live explorer stack as of `2026-04-05`. It supersedes the older monolithic deployment notes in this directory when the question is "what is running in production right now?" + +## Public Entry Point + +- Public domain: `https://explorer.d-bis.org` +- Primary container: VMID `5000` (`192.168.11.140`, `blockscout-1`) +- Public edge: nginx on VMID `5000` + +## VMID 5000 Internal Topology + +| Surface | Internal listener | Owner | Public paths | +|---|---:|---|---| +| nginx | `80`, `443` | VMID `5000` | terminates public traffic | +| Next frontend | `127.0.0.1:3000` | `solacescanscout-frontend.service` | `/`, `/bridge`, `/routes`, `/more`, `/wallet`, `/liquidity`, `/pools`, `/analytics`, `/operator`, `/system`, `/weth` | +| Explorer config/API | `127.0.0.1:8081` | `explorer-config-api.service` | `/api/config/*`, `/explorer-api/v1/*` | +| Blockscout | `127.0.0.1:4000` | existing Blockscout stack | `/api/v2/*` and Blockscout-backed explorer data | +| Token aggregation | `127.0.0.1:3001` | token-aggregation service | `/token-aggregation/api/v1/*` | +| Static config assets | `/var/www/html/config`, `/var/www/html/token-icons` | nginx static files | `/config/*`, `/token-icons/*` | + +## Canonical Deploy Scripts + +| Component | Canonical deploy path | Notes | +|---|---|---| +| Next frontend | [`deploy-next-frontend-to-vmid5000.sh`](../scripts/deploy-next-frontend-to-vmid5000.sh) | Builds the Next standalone bundle and installs `solacescanscout-frontend.service` on port `3000` | +| Explorer config assets | [`deploy-explorer-config-to-vmid5000.sh`](../scripts/deploy-explorer-config-to-vmid5000.sh) | Publishes token list, networks, capabilities, topology, verification example, and token icons | +| Explorer config/API backend | [`deploy-explorer-ai-to-vmid5000.sh`](../scripts/deploy-explorer-ai-to-vmid5000.sh) | Builds and installs `explorer-config-api.service` on port `8081` and normalizes nginx `/explorer-api/v1/*` routing | + +## Relay Topology + +CCIP relay workers do not run inside VMID `5000`. They run on host `r630-01` and are consumed by the explorer API through relay-health probes. + +| Service file | Profile | Port | Current role | +|---|---|---:|---| +| [`ccip-relay.service`](../../config/systemd/ccip-relay.service) | `mainnet-weth` | `9860` | Mainnet WETH lane, intentionally paused | +| [`ccip-relay-mainnet-cw.service`](../../config/systemd/ccip-relay-mainnet-cw.service) | `mainnet-cw` | `9863` | Mainnet cW lane | +| [`ccip-relay-bsc.service`](../../config/systemd/ccip-relay-bsc.service) | `bsc` | `9861` | BSC lane | +| [`ccip-relay-avax.service`](../../config/systemd/ccip-relay-avax.service) | `avax` | `9862` | Avalanche lane | +| [`ccip-relay-avax-cw.service`](../../config/systemd/ccip-relay-avax-cw.service) | `avax-cw` | `9864` | Avalanche cW lane | +| [`ccip-relay-avax-to-138.service`](../../config/systemd/ccip-relay-avax-to-138.service) | `avax-to-138` | `9865` | Reverse Avalanche to Chain 138 lane | + +The explorer backend reads these through `CCIP_RELAY_HEALTH_URL` or `CCIP_RELAY_HEALTH_URLS`; see [`backend/api/rest/README.md`](../backend/api/rest/README.md). + +## Public Verification Points + +The following endpoints currently describe the live deployment contract: + +- `https://explorer.d-bis.org/` +- `https://explorer.d-bis.org/bridge` +- `https://explorer.d-bis.org/routes` +- `https://explorer.d-bis.org/liquidity` +- `https://explorer.d-bis.org/api/config/capabilities` +- `https://explorer.d-bis.org/config/CHAIN138_RPC_CAPABILITIES.json` +- `https://explorer.d-bis.org/explorer-api/v1/features` +- `https://explorer.d-bis.org/explorer-api/v1/track1/bridge/status` +- `https://explorer.d-bis.org/explorer-api/v1/mission-control/stream` +- `https://explorer.d-bis.org/token-aggregation/api/v1/routes/matrix` + +## Recommended Rollout Order + +When a change spans multiple explorer surfaces, use this order: + +1. Deploy static config assets with [`deploy-explorer-config-to-vmid5000.sh`](../scripts/deploy-explorer-config-to-vmid5000.sh). +2. Deploy the explorer config/API backend with [`deploy-explorer-ai-to-vmid5000.sh`](../scripts/deploy-explorer-ai-to-vmid5000.sh). +3. Deploy the Next frontend with [`deploy-next-frontend-to-vmid5000.sh`](../scripts/deploy-next-frontend-to-vmid5000.sh). +4. If nginx routing changed, verify the VMID `5000` nginx site before reload. +5. Run [`check-explorer-health.sh`](../scripts/check-explorer-health.sh) against the public domain. +6. Confirm relay visibility on `/explorer-api/v1/track1/bridge/status` and mission-control SSE. + +When a change spans relays as well: + +1. Deploy or restart the relevant `ccip-relay*.service` unit on `r630-01`. +2. Ensure the explorer backend relay probe env still matches the active host ports. +3. Recheck `/explorer-api/v1/track1/bridge/status` and `/explorer-api/v1/mission-control/stream`. + +## Current Gaps And Legacy Footguns + +- Older docs in this directory still describe a monolithic `explorer-api.service` plus `explorer-frontend.service` package. That is no longer the production deployment shape. +- [`ALL_VMIDS_ENDPOINTS.md`](../../docs/04-configuration/ALL_VMIDS_ENDPOINTS.md) is still correct at the public ingress level, but it intentionally compresses the explorer into `:80/:443` and Blockscout `:4000`. Use this file for the detailed internal listener split. +- There is no single one-shot script in this repo that fully deploys Blockscout, nginx, token aggregation, explorer-config-api, Next frontend, and host-side relays together. Production is currently assembled from the component deploy scripts above. +- `mainnet-weth` is deployed but intentionally paused until that bridge lane is funded again. +- `Etherlink` and `XDC Zero` remain separate bridge programs; they are not part of the current CCIP relay fleet described here. + +## Source Of Truth + +Use these in order: + +1. This file for the live explorer deployment map. +2. [`ALL_VMIDS_ENDPOINTS.md`](../../docs/04-configuration/ALL_VMIDS_ENDPOINTS.md) for VMID, IP, and public ingress inventory. +3. The deploy scripts themselves for exact install behavior. +4. [`check-explorer-health.sh`](../scripts/check-explorer-health.sh) plus [`check-explorer-e2e.sh`](../../scripts/verify/check-explorer-e2e.sh) for public verification. diff --git a/deployment/README.md b/deployment/README.md index 2d38b91..c30c058 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -1,118 +1,41 @@ # Deployment Documentation -Complete deployment documentation for the ChainID 138 Explorer Platform. +Deployment docs for the Chain 138 explorer stack. -## Documentation Files +## Read This First -### 📘 DEPLOYMENT_GUIDE.md -**Complete step-by-step guide** with detailed instructions for: -- LXC container setup -- Application installation -- Database configuration -- Nginx reverse proxy setup -- Cloudflare DNS, SSL, and Tunnel configuration -- Security hardening -- Monitoring setup +For the current production deployment shape, start with [`LIVE_DEPLOYMENT_MAP.md`](./LIVE_DEPLOYMENT_MAP.md). -**Use this for**: Full deployment walkthrough +That file reflects the live split deployment now in production: -### 📋 DEPLOYMENT_TASKS.md -**Detailed task checklist** with all 71 tasks organized by phase: -- Pre-deployment (5 tasks) -- Phase 1: LXC Setup (8 tasks) -- Phase 2: Application Installation (12 tasks) -- Phase 3: Database Setup (10 tasks) -- Phase 4: Infrastructure Services (6 tasks) -- Phase 5: Application Services (10 tasks) -- Phase 6: Nginx Reverse Proxy (9 tasks) -- Phase 7: Cloudflare Configuration (18 tasks) -- Phase 8: Security Hardening (12 tasks) -- Phase 9: Monitoring (8 tasks) -- Post-Deployment Verification (13 tasks) -- Optional Enhancements (8 tasks) +- Next frontend on `127.0.0.1:3000` via `solacescanscout-frontend.service` +- explorer config/API on `127.0.0.1:8081` via `explorer-config-api.service` +- Blockscout on `127.0.0.1:4000` +- token aggregation on `127.0.0.1:3001` +- static config assets under `/var/www/html/config` +- CCIP relay workers on host `r630-01`, outside VMID `5000` -**Use this for**: Tracking deployment progress +## Current Canonical Deployment Paths -### ✅ DEPLOYMENT_CHECKLIST.md -**Interactive checklist** for tracking deployment completion. +- Frontend deploy: [`scripts/deploy-next-frontend-to-vmid5000.sh`](../scripts/deploy-next-frontend-to-vmid5000.sh) +- Config deploy: [`scripts/deploy-explorer-config-to-vmid5000.sh`](../scripts/deploy-explorer-config-to-vmid5000.sh) +- Explorer config/API deploy: [`scripts/deploy-explorer-ai-to-vmid5000.sh`](../scripts/deploy-explorer-ai-to-vmid5000.sh) +- Public health audit: [`scripts/check-explorer-health.sh`](../scripts/check-explorer-health.sh) +- Full public smoke: [`check-explorer-e2e.sh`](../../scripts/verify/check-explorer-e2e.sh) -**Use this for**: Marking off completed items +## Legacy And Greenfield Docs -### ⚡ QUICK_DEPLOY.md -**Quick reference** with essential commands and common issues. +The rest of this directory is still useful, but it should be treated as legacy scaffold or greenfield reference unless it explicitly matches the live split architecture above. -**Use this for**: Quick command lookup during deployment +- `DEPLOYMENT_GUIDE.md`: older full-stack walkthrough +- `DEPLOYMENT_TASKS.md`: older monolithic deployment checklist +- `DEPLOYMENT_CHECKLIST.md`: older tracking checklist +- `QUICK_DEPLOY.md`: command reference for the legacy package -## Configuration Files +## Practical Rule -### nginx/explorer.conf -Complete Nginx configuration with: -- Rate limiting -- SSL/TLS settings -- Reverse proxy configuration -- Security headers -- Caching rules -- WebSocket support - -### cloudflare/tunnel-config.yml -Cloudflare Tunnel configuration template. - -### scripts/deploy-lxc.sh -Automated deployment script for initial setup. - -## Deployment Architecture - -``` -Internet - ↓ -Cloudflare (DNS, SSL, WAF, CDN) - ↓ -Cloudflare Tunnel (optional) - ↓ -LXC Container - ├── Nginx (Reverse Proxy) - │ ├── → Frontend (Port 3000) - │ └── → API (Port 8080) - ├── PostgreSQL + TimescaleDB - ├── Elasticsearch - ├── Redis - └── Application Services - ├── Indexer - ├── API Server - └── Frontend Server -``` - -## Quick Start - -1. **Read the deployment guide**: `DEPLOYMENT_GUIDE.md` -2. **Use the task list**: `DEPLOYMENT_TASKS.md` -3. **Track progress**: `DEPLOYMENT_CHECKLIST.md` -4. **Quick reference**: `QUICK_DEPLOY.md` - -## Prerequisites - -- Proxmox VE with LXC support -- Cloudflare account with domain -- 16GB+ RAM, 4+ CPU cores, 100GB+ storage -- Ubuntu 22.04 LTS template -- SSH access to Proxmox host - -## Estimated Time - -- **First deployment**: 6-8 hours -- **Subsequent deployments**: 2-3 hours -- **Updates**: 30-60 minutes - -## Support - -For issues during deployment: -1. Check `QUICK_DEPLOY.md` for common issues -2. Review service logs: `journalctl -u -f` -3. Check Nginx logs: `tail -f /var/log/nginx/explorer-error.log` -4. Verify Cloudflare tunnel: `systemctl status cloudflared` - -## Version - -**Version**: 1.0.0 -**Last Updated**: 2024-12-23 +If the question is "how do we update production today?", use: +1. [`LIVE_DEPLOYMENT_MAP.md`](./LIVE_DEPLOYMENT_MAP.md) +2. the specific deploy script for the component being changed +3. the public health scripts for verification diff --git a/deployment/common/nginx-mission-control-sse.conf b/deployment/common/nginx-mission-control-sse.conf new file mode 100644 index 0000000..c3279e7 --- /dev/null +++ b/deployment/common/nginx-mission-control-sse.conf @@ -0,0 +1,17 @@ +# Include inside the same server block as /explorer-api/ (or equivalent Go upstream). +# SSE responses must not be buffered by nginx or clients stall until the ticker fires. + +location = /explorer-api/v1/mission-control/stream { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Connection ''; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_cache off; + gzip off; + proxy_read_timeout 3600s; + add_header X-Accel-Buffering no; +} diff --git a/deployment/common/nginx-next-frontend-proxy.conf b/deployment/common/nginx-next-frontend-proxy.conf new file mode 100644 index 0000000..ee72b06 --- /dev/null +++ b/deployment/common/nginx-next-frontend-proxy.conf @@ -0,0 +1,36 @@ +# Next.js frontend proxy locations for SolaceScanScout. +# Keep the existing higher-priority locations for: +# - /api/ +# - /api/config/token-list +# - /api/config/networks +# - /api/config/capabilities +# - /explorer-api/v1/ +# - /token-aggregation/api/v1/ +# - /snap/ +# - /health +# +# Include these locations after those API/static locations and before any legacy +# catch-all that serves /var/www/html/index.html directly. + +location ^~ /_next/ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_hide_header Cache-Control; + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: https:; font-src 'self' https://cdnjs.cloudflare.com; connect-src 'self' https://explorer.d-bis.org wss://explorer.d-bis.org https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org http://192.168.11.221:8545 ws://192.168.11.221:8546;" always; +} diff --git a/deployment/common/systemd-api-service.example b/deployment/common/systemd-api-service.example index 5327e2c..15b79af 100644 --- a/deployment/common/systemd-api-service.example +++ b/deployment/common/systemd-api-service.example @@ -13,6 +13,13 @@ Environment=PORT=8080 Environment=DB_HOST=localhost Environment=DB_NAME=explorer Environment=CHAIN_ID=138 +Environment=RPC_URL=https://rpc-http-pub.d-bis.org +Environment=TOKEN_AGGREGATION_BASE_URL=http://127.0.0.1:3000 +Environment=BLOCKSCOUT_INTERNAL_URL=http://127.0.0.1:4000 +Environment=EXPLORER_PUBLIC_BASE=https://explorer.d-bis.org +Environment=OPERATOR_SCRIPTS_ROOT=/opt/explorer/scripts +Environment=OPERATOR_SCRIPT_ALLOWLIST=check-health.sh,check-bridges.sh +Environment=OPERATOR_SCRIPT_TIMEOUT_SEC=120 ExecStart=/opt/explorer/bin/api-server Restart=on-failure RestartSec=5 diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index c3be28b..1ee17f0 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -93,6 +93,9 @@ services: - PORT=8080 - CHAIN_ID=138 - REDIS_URL=redis://redis:6379 + # Optional relay health for mission-control / bridge UI (see backend CCIP_RELAY_HEALTH_URLS) + - CCIP_RELAY_HEALTH_URL=${CCIP_RELAY_HEALTH_URL:-} + - CCIP_RELAY_HEALTH_URLS=${CCIP_RELAY_HEALTH_URLS:-} ports: - "8080:8080" depends_on: diff --git a/deployment/systemd/solacescanscout-frontend.service b/deployment/systemd/solacescanscout-frontend.service new file mode 100644 index 0000000..9bc2044 --- /dev/null +++ b/deployment/systemd/solacescanscout-frontend.service @@ -0,0 +1,28 @@ +[Unit] +Description=SolaceScanScout Next Frontend Service +After=network.target +Wants=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/solacescanscout/frontend/current +Environment=NODE_ENV=production +Environment=HOSTNAME=127.0.0.1 +Environment=PORT=3000 +ExecStart=/usr/bin/node /opt/solacescanscout/frontend/current/server.js +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=solacescanscout-frontend +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/solacescanscout/frontend +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target diff --git a/docs/COMPLETE_BRIDGE_FIX_GUIDE.md b/docs/COMPLETE_BRIDGE_FIX_GUIDE.md index ae0c067..8bbc761 100644 --- a/docs/COMPLETE_BRIDGE_FIX_GUIDE.md +++ b/docs/COMPLETE_BRIDGE_FIX_GUIDE.md @@ -131,25 +131,27 @@ All checks should now pass. | Chain | Selector | Bridge Address | |-------|----------|----------------| -| BSC | 11344663589394136015 | `0x8078a09637e47fa5ed34f626046ea2094a5cde5e` | -| Polygon | 4051577828743386545 | `0xa780ef19a041745d353c9432f2a7f5a241335ffe` | -| Avalanche | 6433500567565415381 | `0x8078a09637e47fa5ed34f626046ea2094a5cde5e` | -| Base | 15971525489660198786 | `0x8078a09637e47fa5ed34f626046ea2094a5cde5e` | -| Arbitrum | 4949039107694359620 | `0x8078a09637e47fa5ed34f626046ea2094a5cde5e` | -| Optimism | 3734403246176062136 | `0x8078a09637e47fa5ed34f626046ea2094a5cde5e` | -| Ethereum Mainnet | 5009297550715157269 | **TBD** (needs deployment/address) | +| BSC | 11344663589394136015 | `0x24293CA562aE1100E60a4640FF49bd656cFf93B4` | +| Polygon | 4051577828743386545 | `0xF7736443f02913e7e0773052103296CfE1637448` | +| Avalanche | 6433500567565415381 | `0x24293CA562aE1100E60a4640FF49bd656cFf93B4` | +| Base | 15971525489660198786 | `0x24293CA562aE1100E60a4640FF49bd656cFf93B4` | +| Arbitrum | 4949039107694359620 | `0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c` | +| Optimism | 3734403246176062136 | `0x6e94e53F73893b2a6784Df663920D31043A6dE07` | +| Ethereum Mainnet | 5009297550715157269 | `0xc9901ce2Ddb6490FAA183645147a87496d8b20B6` | ### WETH10 Bridge Destinations | Chain | Selector | Bridge Address | |-------|----------|----------------| -| BSC | 11344663589394136015 | `0x105f8a15b819948a89153505762444ee9f324684` | -| Polygon | 4051577828743386545 | `0xdab0591e5e89295ffad75a71dcfc30c5625c4fa2` | -| Avalanche | 6433500567565415381 | `0x105f8a15b819948a89153505762444ee9f324684` | -| Base | 15971525489660198786 | `0x105f8a15b819948a89153505762444ee9f324684` | -| Arbitrum | 4949039107694359620 | `0x105f8a15b819948a89153505762444ee9f324684` | -| Optimism | 3734403246176062136 | `0x105f8a15b819948a89153505762444ee9f324684` | -| Ethereum Mainnet | 5009297550715157269 | **TBD** (needs deployment/address) | +| BSC | 11344663589394136015 | `0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c` | +| Polygon | 4051577828743386545 | `0x0CA60e6f8589c540200daC9D9Cb27BC2e48eE66A` | +| Avalanche | 6433500567565415381 | `0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c` | +| Base | 15971525489660198786 | `0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c` | +| Arbitrum | 4949039107694359620 | `0x73376eB92c16977B126dB9112936A20Fa0De3442` | +| Optimism | 3734403246176062136 | `0x24293CA562aE1100E60a4640FF49bd656cFf93B4` | +| Ethereum Mainnet | 5009297550715157269 | `0x04E1e22B0D41e99f4275bd40A50480219bc9A223` | + +Note: Arbitrum remains operationally blocked on the current Mainnet hub leg until the `0xc990... -> 42161` WETH9 path is repaired, even though the destination bridge addresses are known. --- @@ -203,4 +205,3 @@ All checks should now pass. --- **Last Updated**: $(date) - diff --git a/docs/COMPLETION_SUMMARY.md b/docs/COMPLETION_SUMMARY.md index 8105354..a63ac81 100644 --- a/docs/COMPLETION_SUMMARY.md +++ b/docs/COMPLETION_SUMMARY.md @@ -7,9 +7,10 @@ All implementation steps have been completed successfully. The tiered architectu ## Completed Components ### 1. ✅ Database Schema -- Migration file: `backend/database/migrations/0010_track_schema.up.sql` +- Full migration file: `backend/database/migrations/0010_track_schema.up.sql` +- Shared-DB auth/operator migration: `backend/database/migrations/0010_track_schema.auth_only.sql` - Rollback file: `backend/database/migrations/0010_track_schema.down.sql` -- Script: `scripts/run-migration-0010.sh` +- Helper script: `scripts/run-migration-0010.sh` (auto-detects DB mode) ### 2. ✅ JWT Secret Configuration - Server reads `JWT_SECRET` from environment variable @@ -65,7 +66,7 @@ bash scripts/setup-tiered-architecture.sh export JWT_SECRET="your-strong-secret-here" export RPC_URL="http://192.168.11.250:8545" -# 3. Run migration +# 3. Run migration helper bash scripts/run-migration-0010.sh # 4. Start server @@ -94,4 +95,3 @@ The implementation is complete and ready for: 5. Production deployment All code has been verified, linter errors resolved, and documentation completed. - diff --git a/docs/DATABASE_CONNECTION_GUIDE.md b/docs/DATABASE_CONNECTION_GUIDE.md index 121da3d..796e4db 100644 --- a/docs/DATABASE_CONNECTION_GUIDE.md +++ b/docs/DATABASE_CONNECTION_GUIDE.md @@ -1,30 +1,45 @@ # Database Connection Guide -## Important: Two Different Database Users +## Supported Database Layouts -There are **two separate database systems**: +The explorer backend supports **two deployment modes**: -1. **Blockscout Database** (for Blockscout explorer) - - User: `blockscout` - - Password: `blockscout` - - Database: `blockscout` +1. **Standalone explorer DB** + - User: usually `explorer` + - Database: usually `explorer` + - Migration mode: full Track 2-4 schema -2. **Custom Explorer Backend Database** (for tiered architecture) - - User: `explorer` - - Password: `L@ker$2010` - - Database: `explorer` +2. **Shared Blockscout DB** + - User: usually `blockscout` + - Database: usually `blockscout` + - Migration mode: explorer auth/operator subset only + +Use `bash scripts/run-migration-0010.sh` for both modes. The helper auto-detects whether it is connected to a standalone explorer database or a shared Blockscout database and chooses the safe migration path automatically. ## Correct Connection Command -For the **custom explorer backend** (tiered architecture), use: +For a **standalone explorer database**, use: ```bash -PGPASSWORD='L@ker$2010' psql -h localhost -U explorer -d explorer -c "SELECT 1;" +export DB_PASSWORD='' +PGPASSWORD="$DB_PASSWORD" psql -h localhost -U explorer -d explorer -c "SELECT 1;" ``` +For a **shared Blockscout database**, use: + +```bash +export DB_HOST=localhost +export DB_USER=blockscout +export DB_NAME=blockscout +export DB_PASSWORD='' +PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" +``` + +Do **not** run the full `0010_track_schema.up.sql` directly against the shared Blockscout DB. + **NOT:** ```bash -# ❌ Wrong - this is for Blockscout +# ❌ Wrong - mismatched user/database pair PGPASSWORD='blockscout' psql -h localhost -U blockscout -d explorer -c "SELECT 1;" ``` @@ -34,40 +49,32 @@ PGPASSWORD='blockscout' psql -h localhost -U blockscout -d explorer -c "SELECT 1 ```bash # Test connection to custom explorer database -PGPASSWORD='L@ker$2010' psql -h localhost -U explorer -d explorer -c "SELECT version();" +export DB_PASSWORD='' +PGPASSWORD="$DB_PASSWORD" psql -h localhost -U explorer -d explorer -c "SELECT version();" ``` ### 2. Check if Tables Exist ```bash -# Check for track schema tables -PGPASSWORD='L@ker$2010' psql -h localhost -U explorer -d explorer -c " -SELECT table_name -FROM information_schema.tables -WHERE table_schema = 'public' -AND table_name IN ('wallet_nonces', 'operator_roles', 'addresses', 'token_transfers') -ORDER BY table_name; -" +# Check the database mode and required tables +bash scripts/check-database-connection.sh ``` ### 3. Run Migration (if tables don't exist) ```bash cd explorer-monorepo -PGPASSWORD='L@ker$2010' psql -h localhost -U explorer -d explorer \ - -f backend/database/migrations/0010_track_schema.up.sql +export DB_PASSWORD='' +bash scripts/run-migration-0010.sh ``` ### 4. Verify Migration ```bash -# Should return 4 or more -PGPASSWORD='L@ker$2010' psql -h localhost -U explorer -d explorer -c " -SELECT COUNT(*) as table_count -FROM information_schema.tables -WHERE table_schema = 'public' -AND table_name IN ('wallet_nonces', 'operator_roles', 'addresses', 'token_transfers', 'analytics_flows', 'operator_events'); -" +# Standalone explorer DB should include Track 2-4 tables plus auth/operator tables. +# Shared Blockscout DB should include at least: +# wallet_nonces, operator_roles, operator_events, operator_ip_whitelist +bash scripts/check-database-connection.sh ``` ## Troubleshooting @@ -94,10 +101,10 @@ AND table_name IN ('wallet_nonces', 'operator_roles', 'addresses', 'token_transf You should see both `blockscout` and `explorer` databases. -4. **Create user and database if missing:** +4. **Create a standalone explorer user and database if you want a dedicated backend DB:** ```bash sudo -u postgres psql << EOF - CREATE USER explorer WITH PASSWORD 'L@ker\$2010'; + CREATE USER explorer WITH PASSWORD ''; CREATE DATABASE explorer OWNER explorer; GRANT ALL PRIVILEGES ON DATABASE explorer TO explorer; \q @@ -106,9 +113,10 @@ AND table_name IN ('wallet_nonces', 'operator_roles', 'addresses', 'token_transf ### If Password Authentication Fails -1. **Verify password is correct:** - - Custom explorer: `L@ker$2010` - - Blockscout: `blockscout` +1. **Verify the correct password is exported in `DB_PASSWORD`** +2. **Confirm you are connecting with the right mode pair** + - standalone explorer DB: `explorer` / `explorer` + - shared Blockscout DB: `blockscout` / `blockscout` 2. **Check pg_hba.conf:** ```bash @@ -128,7 +136,7 @@ Use the provided script: ```bash cd explorer-monorepo -export DB_PASSWORD='L@ker$2010' +export DB_PASSWORD='' bash scripts/fix-database-connection.sh ``` @@ -144,7 +152,7 @@ This script will: ```bash pkill -f api-server cd explorer-monorepo/backend - export DB_PASSWORD='L@ker$2010' + export DB_PASSWORD='' export JWT_SECRET='your-secret-here' ./bin/api-server ``` @@ -162,10 +170,10 @@ This script will: -H 'Content-Type: application/json' \ -d '{"address":"0x1234567890123456789012345678901234567890"}' ``` + If the response mentions `wallet_nonces`, returns `service_unavailable`, or the wallet popup shows `Nonce: undefined`, rerun `bash scripts/run-migration-0010.sh`, restart the backend, and retry. ## Summary -- **Custom Explorer Backend:** Use `explorer` user with password `L@ker$2010` -- **Blockscout:** Use `blockscout` user with password `blockscout` -- **They are separate systems** with separate databases - +- **Standalone explorer DB:** use the `explorer` user/database pair and the full Track 2-4 schema +- **Shared Blockscout DB:** use the Blockscout credentials and let `scripts/run-migration-0010.sh` apply only the auth/operator subset +- **Do not** apply `0010_track_schema.up.sql` directly to the shared Blockscout DB diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index bb7dd89..29b3d3d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -10,6 +10,25 @@ ### Quick Deploy +For the current frontend, use the Next standalone deploy path: + +```bash +# From explorer-monorepo root +./scripts/deploy-next-frontend-to-vmid5000.sh +``` + +This builds `frontend/`, uploads the standalone bundle, installs the +`solacescanscout-frontend.service` unit, and starts the frontend on +`127.0.0.1:3000` inside VMID 5000. + +Nginx should keep the existing explorer API routes and proxy `/` plus `/_next/` +to the frontend service. Use +[nginx-next-frontend-proxy.conf](/home/intlc/projects/proxmox/explorer-monorepo/deployment/common/nginx-next-frontend-proxy.conf) +inside the explorer server block after `/api`, `/api/config/*`, `/explorer-api/*`, +`/token-aggregation/api/v1/*`, `/snap/`, and `/health`. + +### Legacy Static Deploy + ```bash # From explorer-monorepo root ./scripts/deploy.sh @@ -18,7 +37,10 @@ ### Manual Deploy ```bash -# Copy files manually +# Canonical Next deployment: +./scripts/deploy-next-frontend-to-vmid5000.sh + +# Legacy static fallback only: scp frontend/public/index.html root@192.168.11.140:/var/www/html/index.html ``` @@ -34,6 +56,33 @@ The deployment script uses these environment variables: IP=192.168.11.140 DOMAIN=explorer.d-bis.org ./scripts/deploy.sh ``` +## Mission-control and Track 4 runtime wiring + +If you are deploying the Go explorer API with the mission-control additions enabled, set these backend env vars as well: + +- `RPC_URL` - Chain 138 RPC for Track 1 and mission-control status/SSE data +- `TOKEN_AGGREGATION_BASE_URL` - used by `GET /api/v1/mission-control/liquidity/token/{address}/pools` +- `BLOCKSCOUT_INTERNAL_URL` - used by `GET /api/v1/mission-control/bridge/trace` +- `EXPLORER_PUBLIC_BASE` - public base URL returned in bridge trace links +- `CCIP_RELAY_HEALTH_URL` - optional relay 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-file fallback for relay health snapshots +- `OPERATOR_SCRIPTS_ROOT` - root directory for Track 4 script execution +- `OPERATOR_SCRIPT_ALLOWLIST` - comma-separated allowlist for `POST /api/v1/track4/operator/run-script` +- `OPERATOR_SCRIPT_TIMEOUT_SEC` - optional per-script timeout in seconds + +For nginx, include [nginx-mission-control-sse.conf](/home/intlc/projects/proxmox/explorer-monorepo/deployment/common/nginx-mission-control-sse.conf) inside the same server block that proxies `/explorer-api/`, and update the `proxy_pass` target if your Go API is not listening on `127.0.0.1:8080`. + +### Quick verification + +```bash +curl -N https://explorer.d-bis.org/explorer-api/v1/mission-control/stream +curl "https://explorer.d-bis.org/explorer-api/v1/mission-control/bridge/trace?tx=0x..." +curl "https://explorer.d-bis.org/explorer-api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools" +# Optional relay probe from the explorer host: +curl http://192.168.11.11:9860/healthz +``` + ## Rollback If deployment fails, rollback to previous version: @@ -43,6 +92,10 @@ ssh root@192.168.11.140 cp /var/www/html/index.html.backup.* /var/www/html/index.html ``` +For the Next standalone path, restart the previous release by repointing +`/opt/solacescanscout/frontend/current` to the prior release and restarting +`solacescanscout-frontend`. + ## Testing After deployment, test the explorer: @@ -56,4 +109,3 @@ Or manually: ```bash curl -k -I https://explorer.d-bis.org/ ``` - diff --git a/docs/DEPLOYMENT_COMPLETE.md b/docs/DEPLOYMENT_COMPLETE.md index 0106a8f..a4f7df6 100644 --- a/docs/DEPLOYMENT_COMPLETE.md +++ b/docs/DEPLOYMENT_COMPLETE.md @@ -25,7 +25,7 @@ - ✅ `/api/v1/auth/nonce` - Endpoint active - ✅ `/api/v1/auth/wallet` - Endpoint active - ✅ JWT token generation configured -- ⚠️ Requires database for nonce storage +- ⚠️ Requires database plus the `run-migration-0010.sh` helper for nonce storage ### 4. Feature Flags - ✅ `/api/v1/features` - Working @@ -120,7 +120,7 @@ DB_NAME=explorer # Set correct password export DB_PASSWORD='your-actual-password' -# Run migration +# Run migration helper bash scripts/run-migration-0010.sh # Restart server @@ -143,6 +143,8 @@ curl -X POST http://localhost:8080/api/v1/auth/wallet \ -d '{"address":"...","signature":"...","nonce":"..."}' ``` +If the nonce request returns `service_unavailable`, mentions `wallet_nonces`, or the wallet signature popup shows `Nonce: undefined`, the backend is still missing the wallet-auth schema. Run `bash scripts/run-migration-0010.sh`, restart the backend, and retry. The helper auto-detects standalone explorer DB vs shared Blockscout DB and uses the safe migration path for each. + ### 3. Approve Users ```bash # Approve for Track 2 @@ -217,4 +219,3 @@ The tiered architecture has been **successfully deployed and tested**. The API s 5. Production deployment **Deployment Status: ✅ COMPLETE** - diff --git a/docs/DEPLOYMENT_STATUS.md b/docs/DEPLOYMENT_STATUS.md index 1356e54..f77a314 100644 --- a/docs/DEPLOYMENT_STATUS.md +++ b/docs/DEPLOYMENT_STATUS.md @@ -34,7 +34,7 @@ 1. **Database Connection** - Status: ⚠️ Not connected - Impact: Track 1 endpoints work (use RPC), Track 2-4 require database - - Solution: Set `DB_PASSWORD` environment variable and run migration + - Solution: Set `DB_PASSWORD` environment variable and run `bash scripts/run-migration-0010.sh` 2. **Health Endpoint** - Status: ⚠️ Returns degraded status (due to database) @@ -49,7 +49,7 @@ | `/api/v1/features` | ✅ Working | Returns track level and features | | `/api/v1/track1/blocks/latest` | ✅ Working | HTTP 200 | | `/api/v1/track1/bridge/status` | ✅ Working | Returns bridge status | -| `/api/v1/auth/nonce` | ⚠️ HTTP 400 | Requires valid address format | +| `/api/v1/auth/nonce` | ⚠️ DB-backed | Requires both a valid address and the `wallet_nonces` table created by `scripts/run-migration-0010.sh` | | `/api/v1/track2/search` | ✅ Working | Correctly requires auth (401) | ### Environment Configuration @@ -94,6 +94,7 @@ DB_NAME=explorer -H 'Content-Type: application/json' \ -d '{"address":"...","signature":"...","nonce":"..."}' ``` + If the response mentions `wallet_nonces` or the wallet popup shows `Nonce: undefined`, rerun `bash scripts/run-migration-0010.sh` and restart the backend before retrying. ### Production Deployment @@ -104,7 +105,7 @@ DB_NAME=explorer 2. **Configure Database** - Set proper `DB_PASSWORD` - - Run migration: `bash scripts/run-migration-0010.sh` + - Run migration helper: `bash scripts/run-migration-0010.sh` - Verify connection: `bash scripts/check-database-connection.sh` 3. **Start as Service** @@ -156,4 +157,3 @@ curl http://localhost:8080/api/v1/features The tiered architecture has been successfully deployed. The API server is running and responding to requests. Track 1 endpoints (public RPC gateway) are fully functional. Track 2-4 endpoints are configured but require database connectivity for full functionality. **Deployment Status: ✅ SUCCESSFUL** - diff --git a/docs/ENV_COMPREHENSIVE_VERIFICATION.md b/docs/ENV_COMPREHENSIVE_VERIFICATION.md index b0b8c7c..89da635 100644 --- a/docs/ENV_COMPREHENSIVE_VERIFICATION.md +++ b/docs/ENV_COMPREHENSIVE_VERIFICATION.md @@ -63,8 +63,8 @@ | Contract | Address | In .env | Variable Name | Status | |----------|---------|---------|---------------|--------| -| **CCIPWETH9Bridge** | `0x2A0840e5117683b11682ac46f5CF5621E67269E3` | ✅ | `CCIPWETH9_BRIDGE_MAINNET` | ✅ Verified | -| **CCIPWETH10Bridge** | `0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03` | ✅ | `CCIPWETH10_BRIDGE_MAINNET` | ✅ Verified | +| **CCIPWETH9Bridge** | `0xc9901ce2Ddb6490FAA183645147a87496d8b20B6` | ✅ | `CCIPWETH9_BRIDGE_MAINNET` | ✅ Verified | +| **CCIPWETH10Bridge** | `0x04E1e22B0D41e99f4275bd40A50480219bc9A223` | ✅ | `CCIPWETH10_BRIDGE_MAINNET` | ✅ Verified | ### Cross-Chain Contracts diff --git a/docs/ENV_FINAL_COMPREHENSIVE_UPDATE.md b/docs/ENV_FINAL_COMPREHENSIVE_UPDATE.md index 29bad9f..953006a 100644 --- a/docs/ENV_FINAL_COMPREHENSIVE_UPDATE.md +++ b/docs/ENV_FINAL_COMPREHENSIVE_UPDATE.md @@ -156,8 +156,8 @@ PRICEFEED_KEEPER_138=0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04 ### Ethereum Mainnet Variables ```bash # Bridges -CCIPWETH9_BRIDGE_MAINNET=0x2A0840e5117683b11682ac46f5CF5621E67269E3 -CCIPWETH10_BRIDGE_MAINNET=0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03 +CCIPWETH9_BRIDGE_MAINNET=0xc9901ce2Ddb6490FAA183645147a87496d8b20B6 +CCIPWETH10_BRIDGE_MAINNET=0x04E1e22B0D41e99f4275bd40A50480219bc9A223 # Cross-Chain TRANSACTION_MIRROR_MAINNET=0x4CF42c4F1dBa748601b8938be3E7ABD732E87cE9 diff --git a/docs/EXPLORER_API_ACCESS.md b/docs/EXPLORER_API_ACCESS.md index 3c05f0f..a98fe94 100644 --- a/docs/EXPLORER_API_ACCESS.md +++ b/docs/EXPLORER_API_ACCESS.md @@ -13,6 +13,50 @@ The frontend is reachable at **https://explorer.d-bis.org** (FQDN) or by **VM IP 2. **Same-origin /api** – When the site is served from the explorer host (FQDN `https://explorer.d-bis.org` or VM IP `http://192.168.11.140` / `https://192.168.11.140`), the frontend uses relative `/api` so all requests go through the same nginx proxy. If you open the frontend from elsewhere, the code falls back to the full Blockscout URL (CORS must allow it). - If the API returns **200** but the UI still shows no data, check the browser console for JavaScript errors (e.g. CSP or network errors). +## Wallet connect fails with “nonce not found or expired” + +If the explorer shows a MetaMask sign-in failure such as: + +```text +Authentication failed: nonce not found or expired +``` + +or the wallet signature request itself shows: + +```text +Nonce: undefined +``` + +check the nonce endpoint directly: + +```bash +curl -sS -H 'Content-Type: application/json' \ + -X POST https://explorer.d-bis.org/explorer-api/v1/auth/nonce \ + --data '{"address":"0x4A666F96fC8764181194447A7dFdb7d471b301C8"}' +``` + +If that returns an error mentioning: + +```text +relation "wallet_nonces" does not exist +``` + +then the explorer backend is running without the wallet-auth schema migration. Run: + +```bash +cd explorer-monorepo +bash scripts/run-migration-0010.sh +``` + +`scripts/run-migration-0010.sh` now auto-detects the database layout: + +- **Standalone explorer DB**: applies the full Track 2-4 schema from `0010_track_schema.up.sql` +- **Shared Blockscout DB**: applies only the explorer-owned auth/operator tables from `0010_track_schema.auth_only.sql` + +Do **not** pipe `0010_track_schema.up.sql` directly into the shared Blockscout database on VMID 5000; its `addresses` and `token_transfers` tables already exist with Blockscout's schema and the full migration will collide with them. + +The `Nonce: undefined` popup means the frontend asked for a nonce, got back an error instead of a nonce, and the old deployed frontend still opened the signature request anyway. After the helper migration, retry the nonce request and then retry wallet connect in the browser. + ### Frontend env contract For the Next frontend in `frontend/`, keep the runtime base URL at the **host origin**, not the `/api` subpath: @@ -340,6 +384,11 @@ The script checks: - HTTP 200 on `/api/v2/stats`, `/api/v2/blocks`, `/api/v2/transactions`. - Explorer frontend at `/` returns 200. - Chain 138 Snap companion site at `/snap/` returns 200 or 301 and contains expected content when 200. +- The static Visual Command Center at `/chain138-command-center.html` returns 200 and contains expected architecture text. +- Mission Control endpoints return healthy responses: + - `/explorer-api/v1/mission-control/stream` + - `/explorer-api/v1/mission-control/bridge/trace` + - `/explorer-api/v1/mission-control/liquidity/token/{address}/pools` **Full verification (single place for all checks — API, explorer, Snap):** @@ -347,7 +396,7 @@ The script checks: bash scripts/verify-vmid5000-all.sh [BASE_URL] ``` -Run this after every deploy or nginx change to confirm explorer and Snap site are reachable and correct. +Run this after every deploy or nginx change to confirm explorer, Snap site, Visual Command Center, and Mission Control are reachable and correct. --- diff --git a/docs/FINAL_DEPLOYMENT_REPORT.md b/docs/FINAL_DEPLOYMENT_REPORT.md index d3589dd..f601f93 100644 --- a/docs/FINAL_DEPLOYMENT_REPORT.md +++ b/docs/FINAL_DEPLOYMENT_REPORT.md @@ -23,9 +23,10 @@ The SolaceScanScout tiered architecture has been successfully deployed and teste - ✅ RPC integration working 3. **Authentication System** - - ✅ Nonce endpoint active + - ✅ Nonce endpoint wired - ✅ Wallet authentication configured - ✅ JWT token generation ready + - ⚠️ Wallet sign-in requires database connectivity plus the `run-migration-0010.sh` helper (`wallet_nonces`) 4. **Feature Flags** - ✅ Endpoint operational @@ -39,8 +40,8 @@ The SolaceScanScout tiered architecture has been successfully deployed and teste ### ⚠️ Database Connection -**Status:** Password authentication issue -**Impact:** Track 2-4 endpoints require database for full functionality +**Status:** Password authentication or schema issue +**Impact:** Track 2-4 endpoints and wallet sign-in require database for full functionality **Workaround:** Track 1 endpoints work without database **To Fix:** @@ -48,14 +49,16 @@ The SolaceScanScout tiered architecture has been successfully deployed and teste # Verify PostgreSQL is running systemctl status postgresql -# Test connection with password -PGPASSWORD='L@ker$2010' psql -h localhost -U explorer -d explorer -c "SELECT 1;" +# Test connection with the configured explorer DB password +export DB_PASSWORD='' +PGPASSWORD="$DB_PASSWORD" psql -h localhost -U explorer -d explorer -c "SELECT 1;" -# If connection works, run migration -PGPASSWORD='L@ker$2010' psql -h localhost -U explorer -d explorer \ - -f backend/database/migrations/0010_track_schema.up.sql +# If connection works, run the migration helper +bash scripts/run-migration-0010.sh ``` +The helper auto-detects standalone explorer DB vs shared Blockscout DB and picks the safe migration path. + ## Test Results ### ✅ Passing Tests @@ -68,7 +71,7 @@ PGPASSWORD='L@ker$2010' psql -h localhost -U explorer -d explorer \ | Track 1 | Blocks | ✅ PASS | | Track 1 | Transactions | ✅ PASS | | Track 1 | Bridge | ✅ PASS | -| Auth | Nonce | ✅ PASS | +| Auth | Nonce | ⚠️ PASS only when DB is reachable and `wallet_nonces` exists | | Track 2 | Auth Check | ✅ PASS (401) | | Track 3 | Auth Check | ✅ PASS (401) | | Track 4 | Auth Check | ✅ PASS (401) | @@ -100,7 +103,7 @@ CHAIN_ID=138 PORT=8080 DB_HOST=localhost DB_USER=explorer -DB_PASSWORD=L@ker$2010 +DB_PASSWORD= DB_NAME=explorer ``` @@ -120,7 +123,8 @@ sudo systemctl start postgresql **Option B: Verify Credentials** ```bash # Test connection -PGPASSWORD='L@ker$2010' psql -h localhost -U explorer -d explorer -c "SELECT 1;" +export DB_PASSWORD='' +PGPASSWORD="$DB_PASSWORD" psql -h localhost -U explorer -d explorer -c "SELECT 1;" # If this fails, check: # 1. User exists: psql -U postgres -c "\du" @@ -128,12 +132,11 @@ PGPASSWORD='L@ker$2010' psql -h localhost -U explorer -d explorer -c "SELECT 1;" # 3. Password is correct ``` -**Option C: Run Migration** +**Option C: Run Migration Helper** ```bash cd explorer-monorepo -export DB_PASSWORD='L@ker$2010' -PGPASSWORD='L@ker$2010' psql -h localhost -U explorer -d explorer \ - -f backend/database/migrations/0010_track_schema.up.sql +export DB_PASSWORD='' +bash scripts/run-migration-0010.sh ``` ### 2. Restart Server with Database @@ -144,7 +147,7 @@ pkill -f api-server # Start with database cd backend -export DB_PASSWORD='L@ker$2010' +export DB_PASSWORD='' export JWT_SECRET='your-secret-here' ./bin/api-server ``` @@ -169,10 +172,12 @@ curl http://localhost:8080/api/v1/track2/search?q=test \ ```bash # After database is connected -export DB_PASSWORD='L@ker$2010' +export DB_PASSWORD='' bash scripts/approve-user.sh
``` +If the nonce request mentions `wallet_nonces`, returns `service_unavailable`, or the wallet popup shows `Nonce: undefined`, rerun `bash scripts/run-migration-0010.sh`, restart the backend, and retry. On the shared VMID 5000 Blockscout database, this helper applies only the auth/operator subset and avoids colliding with Blockscout's existing `addresses` schema. + ## Monitoring ### Server Logs @@ -213,4 +218,3 @@ The tiered architecture deployment is **complete and operational**. Track 1 (pub - User authentication testing - User approval workflow - Indexer startup - diff --git a/docs/METAMASK_AND_PROVIDER_INTEGRATION.md b/docs/METAMASK_AND_PROVIDER_INTEGRATION.md index 2fa6b8c..4f0c9ad 100644 --- a/docs/METAMASK_AND_PROVIDER_INTEGRATION.md +++ b/docs/METAMASK_AND_PROVIDER_INTEGRATION.md @@ -9,7 +9,9 @@ The explorer (SolaceScanScout) provides add-to-MetaMask and token list discovery - **Path:** `/api/config/token-list` - **Full URL:** `{EXPLORER_API_BASE}/api/config/token-list` (e.g. `https://explorer.d-bis.org/api/config/token-list` if the API is on the same origin). Add this URL in MetaMask **Settings → Token lists** so tokens for Chain 138 and Mainnet appear automatically. + As of April 3, 2026, the public explorer token list exposes `190` entries, including the full Mainnet `cW*` suite. - **Networks config:** `/api/config/networks` returns the same chain params (Chain 138 + Ethereum Mainnet) in JSON for programmatic use. +- **GRU v2 public rollout status:** the explorer also publishes static status surfaces at `/config/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json` and `/config/GRU_V2_DEPLOYMENT_QUEUE.json`. Together they summarize the public EVM `cW*` mesh, Wave 1 transport posture, the current public-protocol truth for `Uniswap v3`, `Balancer`, `Curve 3`, `DODO PMM`, and `1inch`, and the remaining operator queue by asset/chain/protocol. ## Provider and feature parity @@ -32,8 +34,11 @@ Discovery is via **token list** (hosted at the explorer token list URL above), * - **Wallet page:** https://explorer.d-bis.org/wallet - **Token list URL:** https://explorer.d-bis.org/api/config/token-list - **Networks config:** https://explorer.d-bis.org/api/config/networks +- **GRU v2 public rollout status:** https://explorer.d-bis.org/config/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json +- **GRU v2 deployment queue:** https://explorer.d-bis.org/config/GRU_V2_DEPLOYMENT_QUEUE.json For backend deployment and integration tests, see [EXPLORER_D_BIS_ORG_INTEGRATION.md](../../docs/04-configuration/metamask/EXPLORER_D_BIS_ORG_INTEGRATION.md). +For token-list publishing, use `explorer-monorepo/scripts/deploy-explorer-config-to-vmid5000.sh`; it now falls back through the Proxmox host automatically when local `pct` is not installed. ## Related diff --git a/docs/PRODUCTION_CHECKLIST.md b/docs/PRODUCTION_CHECKLIST.md index 35be91f..31f0803 100644 --- a/docs/PRODUCTION_CHECKLIST.md +++ b/docs/PRODUCTION_CHECKLIST.md @@ -10,11 +10,12 @@ Before running the Explorer API and indexer in production: See `deployment/ENVIRONMENT_TEMPLATE.env` for all required variables. 2. **Run database migrations** - Apply migrations before starting the API and indexer, e.g.: + Apply migrations before starting the API and indexer: ```bash - psql -U explorer -d explorer -f backend/database/migrations/0010_track_schema.up.sql + export DB_PASSWORD='' + bash scripts/run-migration-0010.sh ``` - Or use your migration runner (e.g. `go run backend/database/migrations/migrate.go --up` if applicable). + `scripts/run-migration-0010.sh` auto-detects standalone explorer DB vs shared Blockscout DB. Do **not** apply `backend/database/migrations/0010_track_schema.up.sql` directly to a shared Blockscout database. 3. **Configure DB and RPC** Ensure `DB_*`, `RPC_URL`, `WS_URL`, and `CHAIN_ID` are set correctly for the target environment. diff --git a/docs/README.md b/docs/README.md index b423e82..a199905 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,16 +18,24 @@ Overview of documentation for the ChainID 138 Explorer (SolaceScanScout). | Doc | Description | |-----|-------------| -| **[../frontend/FRONTEND_REVIEW.md](../frontend/FRONTEND_REVIEW.md)** | Frontend code review (SPA + React) | +| **[../frontend/FRONTEND_REVIEW.md](../frontend/FRONTEND_REVIEW.md)** | Frontend code review (Next app + legacy SPA) | | **[../frontend/FRONTEND_TASKS_AND_REVIEW.md](../frontend/FRONTEND_TASKS_AND_REVIEW.md)** | Task list C1–L4 and detail review | -**Deploy the live SPA (VMID 5000):** +**Deploy the current Next standalone frontend (VMID 5000):** + +```bash +./scripts/deploy-next-frontend-to-vmid5000.sh +``` + +Nginx should preserve `/api`, `/api/config/*`, `/explorer-api/*`, `/token-aggregation/api/v1/*`, `/snap/`, and `/health`, then proxy `/` and `/_next/` using [deployment/common/nginx-next-frontend-proxy.conf](/home/intlc/projects/proxmox/explorer-monorepo/deployment/common/nginx-next-frontend-proxy.conf). + +**Legacy static SPA deploy (fallback only):** ```bash ./scripts/deploy-frontend-to-vmid5000.sh ``` -**Full fix (Blockscout + nginx + frontend):** +**Full explorer/API fix (Blockscout + nginx + frontend):** ```bash ./scripts/complete-explorer-api-access.sh diff --git a/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md b/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md index a7a9524..406bcd0 100644 --- a/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md +++ b/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md @@ -30,7 +30,7 @@ All components have been implemented according to the plan: ### ✅ Phase 4: Track 2 (Full Indexed Explorer) - **Indexers**: Block, transaction, and token indexers (`backend/indexer/track2/`) - **Track 2 API**: All endpoints implemented (`backend/api/track2/endpoints.go`) -- **Database Schema**: Complete schema for indexed data (`backend/database/migrations/0010_track_schema.up.sql`) +- **Database Schema**: Full Track 2-4 schema plus shared-DB-safe auth/operator subset (`backend/database/migrations/0010_track_schema.up.sql`, `backend/database/migrations/0010_track_schema.auth_only.sql`) ### ✅ Phase 5: Track 3 (Analytics) - **Analytics Engine**: Flow tracking, bridge analytics, token distribution (`backend/analytics/`) @@ -80,7 +80,8 @@ Backend - `backend/analytics/` - Analytics engine ### Database -- `backend/database/migrations/0010_track_schema.up.sql` - Track 2-4 schema +- `backend/database/migrations/0010_track_schema.up.sql` - full Track 2-4 schema +- `backend/database/migrations/0010_track_schema.auth_only.sql` - shared Blockscout DB auth/operator subset ### Frontend - Updated `frontend/public/index.html` with feature gating @@ -89,9 +90,10 @@ Backend 1. **Run Database Migrations**: ```bash - cd explorer-monorepo/backend/database/migrations - # Run migration 0010_track_schema.up.sql + cd explorer-monorepo + bash scripts/run-migration-0010.sh ``` + The helper auto-detects standalone explorer DB vs shared Blockscout DB and chooses the safe migration path automatically. 2. **Configure JWT Secret**: - Update `backend/api/rest/auth.go` to use environment variable for JWT secret @@ -126,4 +128,3 @@ Test each track level: - JWT secret is hardcoded in auth.go - move to environment variable - Track routes are commented in routes.go - uncomment and wire up middleware when ready - Frontend feature gating is implemented but needs testing with actual API responses - diff --git a/docs/TIERED_ARCHITECTURE_SETUP.md b/docs/TIERED_ARCHITECTURE_SETUP.md index b8d7026..34f577d 100644 --- a/docs/TIERED_ARCHITECTURE_SETUP.md +++ b/docs/TIERED_ARCHITECTURE_SETUP.md @@ -26,9 +26,9 @@ cd backend ### Required - `DB_HOST` - PostgreSQL host (default: localhost) -- `DB_USER` - Database user (default: explorer) +- `DB_USER` - Database user (default: explorer; use `blockscout` for the shared Blockscout DB mode) - `DB_PASSWORD` - Database password (default: changeme) -- `DB_NAME` - Database name (default: explorer) +- `DB_NAME` - Database name (default: explorer; use `blockscout` for the shared Blockscout DB mode) ### Recommended (Production) - `JWT_SECRET` - Strong random secret for JWT signing (required for production) @@ -38,17 +38,18 @@ cd backend ## Database Migration -Run the Track 2-4 schema migration: +Run the migration helper: ```bash bash scripts/run-migration-0010.sh ``` -This creates: -- Track 2 tables: `addresses`, `token_transfers`, `token_balances`, `internal_transactions` -- Track 3 tables: `analytics_flows`, `analytics_bridge_history`, `token_distribution` (materialized view) -- Track 4 tables: `operator_events`, `operator_ip_whitelist`, `operator_roles` -- Auth table: `wallet_nonces` +The helper auto-detects the database layout: + +- **Standalone explorer DB**: creates the full Track 2-4 schema +- **Shared Blockscout DB**: creates only `operator_events`, `operator_ip_whitelist`, `operator_roles`, and `wallet_nonces` + +Do **not** apply `backend/database/migrations/0010_track_schema.up.sql` directly to the shared Blockscout DB. ## User Management @@ -152,11 +153,13 @@ The indexers will: ### Migration Fails - Check database connection: `psql -h $DB_HOST -U $DB_USER -d $DB_NAME -c "SELECT 1"` - Verify user has CREATE TABLE permissions +- If you are using the shared Blockscout DB, keep `DB_USER` and `DB_NAME` aligned to `blockscout` ### Authentication Fails - Check JWT_SECRET is set - Verify wallet_nonces table exists - Check database connection in auth handlers +- If the wallet popup shows `Nonce: undefined`, the nonce request failed before signing. Run `bash scripts/run-migration-0010.sh`, restart the backend, and retry. ### Track Routes Not Working - Verify user is approved: Check `operator_roles` table @@ -179,4 +182,3 @@ The indexers will: 7. ✅ Verify feature gating For detailed API documentation, see: `docs/api/track-api-contracts.md` - diff --git a/docs/openapi/mission-control.openapi.yaml b/docs/openapi/mission-control.openapi.yaml new file mode 100644 index 0000000..d9bbd2f --- /dev/null +++ b/docs/openapi/mission-control.openapi.yaml @@ -0,0 +1,104 @@ +openapi: 3.0.3 +info: + title: Explorer mission-control API + version: "1.0" + description: | + Public health, liquidity proxy, and bridge-trace helpers on the Go REST service. + SSE for `/mission-control/stream` should be proxied with **proxy_buffering off** so chunks flush (see `deployment/common/nginx-mission-control-sse.conf`). +servers: + - url: /explorer-api/v1 +paths: + /mission-control/stream: + get: + summary: Server-Sent Events stream of bridge/RPC health + description: | + `Content-Type: text/event-stream`. Emits `event: mission-control` with JSON `{"data":{...}}` immediately, then every 20s. + Same inner `data` shape as `GET /track1/bridge/status`. + responses: + "200": + description: SSE stream + content: + text/event-stream: + schema: + type: string + /mission-control/liquidity/token/{address}/pools: + get: + summary: Cached proxy to token-aggregation pools + parameters: + - name: address + in: path + required: true + schema: + type: string + pattern: '^0x[a-fA-F0-9]{40}$' + responses: + "200": + description: Upstream JSON (pass-through) + "503": + description: TOKEN_AGGREGATION_BASE_URL not set + /mission-control/bridge/trace: + get: + summary: Resolve tx `to`/`from` via Blockscout and label with smart-contracts-master + parameters: + - name: tx + in: query + required: true + schema: + type: string + pattern: '^0x[a-fA-F0-9]{64}$' + responses: + "200": + description: Labeled trace + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + tx_hash: + type: string + from: + type: string + to: + type: string + from_registry: + type: string + to_registry: + type: string + blockscout_url: + type: string + /track4/operator/run-script: + post: + summary: Run an allowlisted script under OPERATOR_SCRIPTS_ROOT (Track 4 + IP whitelist) + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [script] + properties: + script: + type: string + description: Path relative to OPERATOR_SCRIPTS_ROOT (no ..) + args: + type: array + items: + type: string + maxItems: 24 + responses: + "200": + description: stdout/stderr and exit code + "403": + description: Not allowlisted or not whitelisted IP + "503": + description: Root or allowlist not configured +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer diff --git a/frontend/.env.example b/frontend/.env.example index b7304d7..db21e91 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,7 +1,7 @@ # Explorer API base URL (used for blocks, transactions, addresses, and /api/config/token-list). -# Production at https://explorer.d-bis.org: leave empty or set to https://explorer.d-bis.org (same origin). -# Local dev: http://localhost:8080 (or your API port). -NEXT_PUBLIC_API_URL=https://explorer.d-bis.org +# Production behind the nginx proxy: leave empty to use same-origin automatically. +# Local dev against a standalone backend: http://localhost:8080 (or your API port). +NEXT_PUBLIC_API_URL= # Chain ID for the explorer (default: Chain 138 - DeFi Oracle Meta Mainnet). NEXT_PUBLIC_CHAIN_ID=138 diff --git a/frontend/.env.production b/frontend/.env.production index 0fab37d..b63597c 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -1,2 +1,2 @@ -NEXT_PUBLIC_API_URL=https://explorer.d-bis.org +NEXT_PUBLIC_API_URL= NEXT_PUBLIC_CHAIN_ID=138 diff --git a/frontend/libs/frontend-api-client/api-base.test.ts b/frontend/libs/frontend-api-client/api-base.test.ts new file mode 100644 index 0000000..820c420 --- /dev/null +++ b/frontend/libs/frontend-api-client/api-base.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { resolveExplorerApiBase } from './api-base' + +describe('resolveExplorerApiBase', () => { + it('prefers an explicit env value when present', () => { + expect( + resolveExplorerApiBase({ + envValue: 'https://explorer.d-bis.org/', + browserOrigin: 'http://127.0.0.1:3000', + }) + ).toBe('https://explorer.d-bis.org') + }) + + it('falls back to same-origin in the browser when env is empty', () => { + expect( + resolveExplorerApiBase({ + envValue: '', + browserOrigin: 'http://127.0.0.1:3000/', + }) + ).toBe('http://127.0.0.1:3000') + }) + + it('falls back to the local backend on the server when no other base is available', () => { + expect( + resolveExplorerApiBase({ + envValue: '', + browserOrigin: '', + }) + ).toBe('http://localhost:8080') + }) +}) diff --git a/frontend/libs/frontend-api-client/client.ts b/frontend/libs/frontend-api-client/client.ts index 132948d..cb17327 100644 --- a/frontend/libs/frontend-api-client/client.ts +++ b/frontend/libs/frontend-api-client/client.ts @@ -1,4 +1,5 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' +import { resolveExplorerApiBase } from './api-base' export interface ApiResponse { data: T @@ -21,9 +22,9 @@ export interface ApiError { } } -export function createApiClient(baseURL: string = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080', getApiKey?: () => string | null) { +export function createApiClient(baseURL?: string, getApiKey?: () => string | null) { const client = axios.create({ - baseURL, + baseURL: baseURL || resolveExplorerApiBase(), timeout: 30000, headers: { 'Content-Type': 'application/json' }, }) diff --git a/frontend/libs/frontend-ui-primitives/Address.tsx b/frontend/libs/frontend-ui-primitives/Address.tsx index 16702ab..0cc26e1 100644 --- a/frontend/libs/frontend-ui-primitives/Address.tsx +++ b/frontend/libs/frontend-ui-primitives/Address.tsx @@ -25,24 +25,51 @@ export function Address({ : address const handleCopy = async () => { - await navigator.clipboard.writeText(address) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + try { + await navigator.clipboard.writeText(address) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + setCopied(false) + } } return ( -
- {displayAddress} +
+ + {displayAddress} + {showCopy && ( )}
) } - diff --git a/frontend/libs/frontend-ui-primitives/Button.tsx b/frontend/libs/frontend-ui-primitives/Button.tsx index 7561b8a..904b833 100644 --- a/frontend/libs/frontend-ui-primitives/Button.tsx +++ b/frontend/libs/frontend-ui-primitives/Button.tsx @@ -17,7 +17,7 @@ export function Button({ return (
) } - diff --git a/frontend/next.config.js b/frontend/next.config.js index 4f53eee..4c442e3 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -4,8 +4,8 @@ const nextConfig = { output: 'standalone', // If you see a workspace lockfile warning: align on one package manager (npm or pnpm) in frontend, or ignore for dev/build. env: { - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080', - NEXT_PUBLIC_CHAIN_ID: process.env.NEXT_PUBLIC_CHAIN_ID || '138', + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? '', + NEXT_PUBLIC_CHAIN_ID: process.env.NEXT_PUBLIC_CHAIN_ID ?? '138', }, } diff --git a/frontend/package.json b/frontend/package.json index 8965767..259507f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ "build": "next build", "build:check": "npm run lint && npm run type-check && npm run build", "smoke:routes": "node ./scripts/smoke-routes.mjs", - "start": "PORT=${PORT:-3000} node .next/standalone/server.js", + "start": "PORT=${PORT:-3000} node ./scripts/start-standalone.mjs", "start:next": "next start", "lint": "next lint", "type-check": "tsc --noEmit -p tsconfig.check.json", diff --git a/frontend/public/chain138-command-center.html b/frontend/public/chain138-command-center.html new file mode 100644 index 0000000..4480060 --- /dev/null +++ b/frontend/public/chain138-command-center.html @@ -0,0 +1,696 @@ + + + + + + Chain 138 — Visual Command Center + + + + + + +
+

Chain 138 — deployment and liquidity topology

+

Operator-style view of the architecture in docs/02-architecture/SMOM_DBIS_138_FULL_DEPLOYMENT_FLOW_MAP.md. Diagrams are informational only; contract addresses live in explorer config and repo references. The live Mission Control visual surfaces remain in the main explorer SPA. Deep links: ?tab=mission-control or numeric ?tab=08 (slug per tab).

+
+ +
+
+ + + + + + + + + +
+ Back to More +
+ + +
+

Hub, leaf endings, CCIP destinations, Alltra, the dedicated Avalanche cW corridor, the public cW mesh, and pending programs. Mainnet cW mint corridors and the optional TRUU rail are summarized under the Ethereum anchor.

+
+flowchart TB + subgraph LEAF_INGRESS["Leaves — access to 138"] + WU[Wallets · MetaMask Snaps · Ledger · Chainlist · SDKs · ethers.js] + OPS[Operators · Foundry scripts · relay · systemd · deploy hooks] + RPCPUB[Public RPC FQDNs · thirdweb mirrors] + FB[Fireblocks Web3 RPC] + end + + subgraph LEAF_EDGE["Leaves — services that index or front 138"] + EXP[Explorer · Blockscout · token-aggregation] + INFO[info.defi-oracle.io] + DAPP[dapp.d-bis.org bridge UI] + DBIS[dbis-api Core hosts] + X402[x402 payment API] + MCP[MCP PMM controller] + end + + subgraph HUB["CHAIN 138 — origin hub"] + C138["Besu EVM · tokens core · DODO PMM V2/V3 · RouterV2 · UniV3 / Balancer / Curve / 1inch pilots · CCIP bridges + router · AlltraAdapter · BridgeVault · ISO channels · mirror reserve vault settlement · Lockbox · Truth / Tron / Solana adapters"] + end + + subgraph CCIP_ETH["Ethereum 1 — CCIP anchor"] + ETH1["WETH9 / WETH10 bridges · CCIPRelayRouter · RelayBridge · Logger · optional trustless stack"] + LEAF_ETH["Leaf — Mainnet native DEX venues · Li.Fi touchpoints on other chains · first-wave cW DODO pools · optional TRUU PMM rail"] + end + + subgraph CCIP_L2["Other live CCIP EVM destinations"] + L2CLU["OP 10 · Base 8453 · Arb 42161 · Polygon 137 · BSC 56 · Avax 43114 · Gnosis 100 · Celo 42220 · Cronos 25"] + LEAF_L2["Leaf — per-chain native DEX · cW token transport · partial edge pools"] + end + + subgraph ALLTRA["ALL Mainnet 651940"] + A651["AlltraAdapter peer · AUSDT · WETH · WALL · HYDX · DEX env placeholders"] + LEAF_651["Leaf — ALL native venues when configured"] + end + + subgraph SPECIAL["Dedicated corridor from 138"] + AVAXCW["138 cUSDT to Avax cWUSDT mint path"] + LEAF_AVAX["Leaf — recipient on 43114"] + end + + subgraph CW_MESH["Public cW GRU mesh"] + CW["Cross-public-EVM token matrix · pool design · Mainnet DODO concentration"] + end + + subgraph PENDING["Pending separate scaffold"] + WEMIX[Wemix 1111 CCIP pending] + XDC[XDC Zero parallel program] + SCAFF[Etherlink Tezos OP L2 design] + PNON[Truth pointer · Tron adapter · Solana partial] + end + + WU --> RPCPUB + RPCPUB --> C138 + WU --> C138 + OPS --> C138 + EXP --> C138 + INFO --> C138 + DAPP --> C138 + DBIS --> C138 + X402 --> C138 + MCP --> C138 + FB --> C138 + + C138 <--> ETH1 + C138 <--> L2CLU + C138 <--> A651 + C138 --> AVAXCW + AVAXCW --> LEAF_AVAX + + ETH1 <--> L2CLU + ETH1 --> LEAF_ETH + L2CLU --> LEAF_L2 + A651 --> LEAF_651 + + CW -.->|pool and peg design| LEAF_ETH + CW -.->|token mesh| L2CLU + + C138 -.-> WEMIX + C138 -.-> XDC + C138 -.-> SCAFF + C138 -.-> PNON +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Source: proxmox/docs/02-architecture/SMOM_DBIS_138_FULL_DEPLOYMENT_FLOW_MAP.md — addresses: config/smart-contracts-master.json and CONTRACT_ADDRESSES_REFERENCE. +
+ + + + diff --git a/frontend/public/config/mission-control-verify.example.json b/frontend/public/config/mission-control-verify.example.json new file mode 100644 index 0000000..1f16071 --- /dev/null +++ b/frontend/public/config/mission-control-verify.example.json @@ -0,0 +1,8 @@ +{ + "publishedAt": "2026-04-04T00:00:00Z", + "source": "operator-verify-scripts", + "summary": "Copy to mission-control-verify.json on the explorer host and set MISSION_CONTROL_VERIFY_JSON on the Go service.", + "checks": [ + { "name": "example", "ok": true, "detail": "Replace with real verify output." } + ] +} diff --git a/frontend/public/config/topology-graph.json b/frontend/public/config/topology-graph.json new file mode 100644 index 0000000..280633e --- /dev/null +++ b/frontend/public/config/topology-graph.json @@ -0,0 +1,377 @@ +{ + "generatedAt": "2026-04-04T21:38:48Z", + "description": "Auto-generated from config/smart-contracts-master.json (subset)", + "liquiditySample": null, + "elements": [ + { + "data": { + "id": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "label": "WETH9 (0xC02aaA39…)", + "href": "https://explorer.d-bis.org/address/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + } + }, + { + "data": { + "id": "0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f", + "label": "WETH10 (0xf4BB2e28…)", + "href": "https://explorer.d-bis.org/address/0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f" + } + }, + { + "data": { + "id": "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506", + "label": "Oracle_Aggregator (0x99b3511a…)", + "href": "https://explorer.d-bis.org/address/0x99b3511a2d315a497c8112c1fdd8d508d4b1e506" + } + }, + { + "data": { + "id": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6", + "label": "Oracle_Proxy (0x3304b747…)", + "href": "https://explorer.d-bis.org/address/0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6" + } + }, + { + "data": { + "id": "0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817", + "label": "CCIP_Router (0x42DAb7b8…)", + "href": "https://explorer.d-bis.org/address/0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817" + } + }, + { + "data": { + "id": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e", + "label": "CCIP_Router_Direct_Legacy (0x8078A096…)", + "href": "https://explorer.d-bis.org/address/0x8078a09637e47fa5ed34f626046ea2094a5cde5e" + } + }, + { + "data": { + "id": "0x105f8a15b819948a89153505762444ee9f324684", + "label": "CCIP_Sender (0x105F8A15…)", + "href": "https://explorer.d-bis.org/address/0x105f8a15b819948a89153505762444ee9f324684" + } + }, + { + "data": { + "id": "0xcacfd227a040002e49e2e01626363071324f820a", + "label": "CCIPWETH9_Bridge (0xcacfd227…)", + "href": "https://explorer.d-bis.org/address/0xcacfd227a040002e49e2e01626363071324f820a" + } + }, + { + "data": { + "id": "0x971cd9d156f193df8051e48043c476e53ecd4693", + "label": "CCIPWETH9_Bridge_Direct_Legacy (0x971cD9D1…)", + "href": "https://explorer.d-bis.org/address/0x971cd9d156f193df8051e48043c476e53ecd4693" + } + }, + { + "data": { + "id": "0xe0e93247376aa097db308b92e6ba36ba015535d0", + "label": "CCIPWETH10_Bridge (0xe0E93247…)", + "href": "https://explorer.d-bis.org/address/0xe0e93247376aa097db308b92e6ba36ba015535d0" + } + }, + { + "data": { + "id": "0xb7721dd53a8c629d9f1ba31a5819afe250002b03", + "label": "LINK (0xb7721dD5…)", + "href": "https://explorer.d-bis.org/address/0xb7721dd53a8c629d9f1ba31a5819afe250002b03" + } + }, + { + "data": { + "id": "0x93e66202a11b1772e55407b32b44e5cd8eda7f22", + "label": "cUSDT (0x93E66202…)", + "href": "https://explorer.d-bis.org/address/0x93e66202a11b1772e55407b32b44e5cd8eda7f22" + } + }, + { + "data": { + "id": "0xf22258f57794cc8e06237084b353ab30fffa640b", + "label": "cUSDC (0xf22258f5…)", + "href": "https://explorer.d-bis.org/address/0xf22258f57794cc8e06237084b353ab30fffa640b" + } + }, + { + "data": { + "id": "0x9fbfab33882efe0038daa608185718b772ee5660", + "label": "cUSDT_V2 (0x9FBfab33…)", + "href": "https://explorer.d-bis.org/address/0x9fbfab33882efe0038daa608185718b772ee5660" + } + }, + { + "data": { + "id": "0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d", + "label": "cUSDC_V2 (0x219522c6…)", + "href": "https://explorer.d-bis.org/address/0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d" + } + }, + { + "data": { + "id": "0x91efe92229dbf7c5b38d422621300956b55870fa", + "label": "TokenRegistry (0x91Efe922…)", + "href": "https://explorer.d-bis.org/address/0x91efe92229dbf7c5b38d422621300956b55870fa" + } + }, + { + "data": { + "id": "0xebfb5c60de5f7c4baae180ca328d3bb39e1a5133", + "label": "TokenFactory (0xEBFb5C60…)", + "href": "https://explorer.d-bis.org/address/0xebfb5c60de5f7c4baae180ca328d3bb39e1a5133" + } + }, + { + "data": { + "id": "0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1", + "label": "ComplianceRegistry (0xbc54fe2b…)", + "href": "https://explorer.d-bis.org/address/0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1" + } + }, + { + "data": { + "id": "0x31884f84555210ffb36a19d2471b8ebc7372d0a8", + "label": "BridgeVault (0x31884f84…)", + "href": "https://explorer.d-bis.org/address/0x31884f84555210ffb36a19d2471b8ebc7372d0a8" + } + }, + { + "data": { + "id": "0xf78246eb94c6cb14018e507e60661314e5f4c53f", + "label": "FeeCollector (0xF78246eB…)", + "href": "https://explorer.d-bis.org/address/0xf78246eb94c6cb14018e507e60661314e5f4c53f" + } + }, + { + "data": { + "id": "0x95bc4a997c0670d5dac64d55cdf3769b53b63c28", + "label": "DebtRegistry (0x95BC4A99…)", + "href": "https://explorer.d-bis.org/address/0x95bc4a997c0670d5dac64d55cdf3769b53b63c28" + } + }, + { + "data": { + "id": "0x0c4fd27018130a00762a802f91a72d6a64a60f14", + "label": "PolicyManager (0x0C4FD270…)", + "href": "https://explorer.d-bis.org/address/0x0c4fd27018130a00762a802f91a72d6a64a60f14" + } + }, + { + "data": { + "id": "0x0059e237973179146237ab49f1322e8197c22b21", + "label": "TokenImplementation (0x0059e237…)", + "href": "https://explorer.d-bis.org/address/0x0059e237973179146237ab49f1322e8197c22b21" + } + }, + { + "data": { + "id": "0xd3ad6831aacb5386b8a25bb8d8176a6c8a026f04", + "label": "PriceFeed_Keeper (0xD3AD6831…)", + "href": "https://explorer.d-bis.org/address/0xd3ad6831aacb5386b8a25bb8d8176a6c8a026f04" + } + }, + { + "data": { + "id": "0x8918ee0819fd687f4eb3e8b9b7d0ef7557493cfa", + "label": "OraclePriceFeed (0x8918eE08…)", + "href": "https://explorer.d-bis.org/address/0x8918ee0819fd687f4eb3e8b9b7d0ef7557493cfa" + } + }, + { + "data": { + "id": "0x3e8725b8de386fef3efe5678c92ea6adb41992b2", + "label": "WETH_MockPriceFeed (0x3e8725b8…)", + "href": "https://explorer.d-bis.org/address/0x3e8725b8de386fef3efe5678c92ea6adb41992b2" + } + }, + { + "data": { + "id": "0x16d9a2cb94a0b92721d93db4a6cd8023d3338800", + "label": "MerchantSettlementRegistry (0x16D9A2cB…)", + "href": "https://explorer.d-bis.org/address/0x16d9a2cb94a0b92721d93db4a6cd8023d3338800" + } + }, + { + "data": { + "id": "0xe77cb26ea300e2f5304b461b0ec94c8ad6a7e46d", + "label": "WithdrawalEscrow (0xe77cb26e…)", + "href": "https://explorer.d-bis.org/address/0xe77cb26ea300e2f5304b461b0ec94c8ad6a7e46d" + } + }, + { + "data": { + "id": "0xaee4b7fbe82e1f8295951584cbc772b8bbd68575", + "label": "UniversalAssetRegistry (0xAEE4b7fB…)", + "href": "https://explorer.d-bis.org/address/0xaee4b7fbe82e1f8295951584cbc772b8bbd68575" + } + }, + { + "data": { + "id": "0xa6891d5229f2181a34d4ff1b515c3aa37dd90e0e", + "label": "GovernanceController (0xA6891D52…)", + "href": "https://explorer.d-bis.org/address/0xa6891d5229f2181a34d4ff1b515c3aa37dd90e0e" + } + }, + { + "data": { + "id": "0xcd42e8ed79dc50599535d1de48d3dafa0be156f8", + "label": "UniversalCCIPBridge (0xCd42e8eD…)", + "href": "https://explorer.d-bis.org/address/0xcd42e8ed79dc50599535d1de48d3dafa0be156f8" + } + }, + { + "data": { + "id": "0xbe9e0b2d4cf6a3b2994d6f2f0904d2b165eb8ffc", + "label": "UniversalCCIPFlashBridgeAdapter (0xBe9e0B2d…)", + "href": "https://explorer.d-bis.org/address/0xbe9e0b2d4cf6a3b2994d6f2f0904d2b165eb8ffc" + } + }, + { + "data": { + "id": "0xd084b68cb4b1ef2cba09cf99fb1b6552fd9b4859", + "label": "CrossChainFlashRepayReceiver (0xD084b68c…)", + "href": "https://explorer.d-bis.org/address/0xd084b68cb4b1ef2cba09cf99fb1b6552fd9b4859" + } + }, + { + "data": { + "id": "0x89f7a1fcbbe104bee96da4b4b6b7d3af85f7e661", + "label": "CrossChainFlashVaultCreditReceiver (0x89F7a1fc…)", + "href": "https://explorer.d-bis.org/address/0x89f7a1fcbbe104bee96da4b4b6b7d3af85f7e661" + } + }, + { + "data": { + "id": "0x89ab428c437f23bab9781ff8db8d3848e27eed6c", + "label": "BridgeOrchestrator (0x89aB428c…)", + "href": "https://explorer.d-bis.org/address/0x89ab428c437f23bab9781ff8db8d3848e27eed6c" + } + }, + { + "data": { + "id": "0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce", + "label": "EnhancedSwapRouterV2 (0xF1c93F54…)", + "href": "https://explorer.d-bis.org/address/0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce" + } + }, + { + "data": { + "id": "0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7", + "label": "IntentBridgeCoordinatorV2 (0x7D0022B7…)", + "href": "https://explorer.d-bis.org/address/0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7" + } + }, + { + "data": { + "id": "0x88495b3dccea93b0633390fde71992683121fa62", + "label": "DodoRouteExecutorAdapter (0x88495B3d…)", + "href": "https://explorer.d-bis.org/address/0x88495b3dccea93b0633390fde71992683121fa62" + } + }, + { + "data": { + "id": "0x9cb97add29c52e3b81989bca2e33d46074b530ef", + "label": "DodoV3RouteExecutorAdapter (0x9Cb97adD…)", + "href": "https://explorer.d-bis.org/address/0x9cb97add29c52e3b81989bca2e33d46074b530ef" + } + }, + { + "data": { + "id": "0x960d6db4e78705f82995690548556fb2266308ea", + "label": "UniswapV3RouteExecutorAdapter (0x960D6db4…)", + "href": "https://explorer.d-bis.org/address/0x960d6db4e78705f82995690548556fb2266308ea" + } + }, + { + "data": { + "id": "stack_rpc", + "label": "Chain 138 RPC (logical)" + } + }, + { + "data": { + "source": "stack_rpc", + "target": "0x105f8a15b819948a89153505762444ee9f324684", + "label": "settlement" + } + }, + { + "data": { + "source": "stack_rpc", + "target": "0x31884f84555210ffb36a19d2471b8ebc7372d0a8", + "label": "settlement" + } + }, + { + "data": { + "source": "stack_rpc", + "target": "0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817", + "label": "settlement" + } + }, + { + "data": { + "source": "stack_rpc", + "target": "0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7", + "label": "settlement" + } + }, + { + "data": { + "source": "stack_rpc", + "target": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e", + "label": "settlement" + } + }, + { + "data": { + "source": "stack_rpc", + "target": "0x89ab428c437f23bab9781ff8db8d3848e27eed6c", + "label": "settlement" + } + }, + { + "data": { + "source": "stack_rpc", + "target": "0x971cd9d156f193df8051e48043c476e53ecd4693", + "label": "settlement" + } + }, + { + "data": { + "source": "stack_rpc", + "target": "0xbe9e0b2d4cf6a3b2994d6f2f0904d2b165eb8ffc", + "label": "settlement" + } + }, + { + "data": { + "source": "stack_rpc", + "target": "0xcacfd227a040002e49e2e01626363071324f820a", + "label": "settlement" + } + }, + { + "data": { + "source": "stack_rpc", + "target": "0xcd42e8ed79dc50599535d1de48d3dafa0be156f8", + "label": "settlement" + } + }, + { + "data": { + "source": "stack_rpc", + "target": "0xe0e93247376aa097db308b92e6ba36ba015535d0", + "label": "settlement" + } + }, + { + "data": { + "source": "stack_rpc", + "target": "0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce", + "label": "settlement" + } + } + ] +} diff --git a/frontend/public/explorer-spa.js b/frontend/public/explorer-spa.js index 2e4ffa0..c9843a4 100644 --- a/frontend/public/explorer-spa.js +++ b/frontend/public/explorer-spa.js @@ -1,9 +1,11 @@ const API_BASE = '/api'; const EXPLORER_API_BASE = '/explorer-api'; const EXPLORER_API_V1_BASE = EXPLORER_API_BASE + '/v1'; + const EXPLORER_TRACK1_BASE = EXPLORER_API_V1_BASE + '/track1'; const TOKEN_AGGREGATION_API_BASE = '/token-aggregation/api'; const EXPLORER_AI_API_BASE = EXPLORER_API_V1_BASE + '/ai'; const FETCH_TIMEOUT_MS = 15000; + const ADDRESS_DETAIL_BLOCKSCOUT_TIMEOUT_MS = 4000; const RPC_HEALTH_TIMEOUT_MS = 5000; const FETCH_MAX_RETRIES = 3; const RETRY_DELAY_MS = 1000; @@ -31,8 +33,9 @@ const RPC_WS_IP = 'ws://192.168.11.221:8546'; const RPC_FQDN = 'https://rpc-http-pub.d-bis.org'; // VMID 2201 - HTTPS const RPC_WS_FQDN = 'wss://rpc-ws-pub.d-bis.org'; + const RPC_URLS_HTTPS = [RPC_FQDN, 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org', 'https://rpc.defi-oracle.io']; const RPC_URLS = (typeof window !== 'undefined' && window.location && window.location.protocol === 'https:') - ? [RPC_FQDN] : [RPC_IP]; + ? RPC_URLS_HTTPS : [RPC_IP]; const RPC_URL = (typeof window !== 'undefined' && window.location && window.location.protocol === 'https:') ? RPC_FQDN : RPC_IP; const RPC_WS_URL = (typeof window !== 'undefined' && window.location && window.location.protocol === 'https:') ? RPC_WS_FQDN : RPC_WS_IP; let _rpcUrlIndex = 0; @@ -370,11 +373,81 @@ html += ''; container.innerHTML = html; }; - var KNOWN_ADDRESS_LABELS = { '0x89dd12025bfcd38a168455a44b400e913ed33be2': 'CCIP WETH9 Bridge', '0xe0e93247376aa097db308b92e6ba36ba015535d0': 'CCIP WETH10 Bridge', '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': 'WETH9', '0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f': 'WETH10', '0x8078a09637e47fa5ed34f626046ea2094a5cde5e': 'CCIP Router', '0x105f8a15b819948a89153505762444ee9f324684': 'CCIP Sender' }; - const CHAIN_138_PMM_INTEGRATION_ADDRESS = '0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d'; + var KNOWN_ADDRESS_LABELS = { + '0xcacfd227a040002e49e2e01626363071324f820a': 'CCIP WETH9 Bridge (Chain 138)', + '0xe0e93247376aa097db308b92e6ba36ba015535d0': 'CCIP WETH10 Bridge (Chain 138)', + '0xc9901ce2ddb6490faa183645147a87496d8b20b6': 'CCIP WETH9 Bridge (Mainnet current)', + '0x04e1e22b0d41e99f4275bd40a50480219bc9a223': 'CCIP WETH10 Bridge (Mainnet current)', + '0x2a0840e5117683b11682ac46f5cf5621e67269e3': 'CCIP WETH9 Bridge (Mainnet legacy)', + '0x89dd12025bfcd38a168455a44b400e913ed33be2': 'WETH9 (Arbitrum custom)', + '0x73376eb92c16977b126db9112936a20fa0de3442': 'CCIP WETH10 Bridge (Arbitrum)', + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': 'WETH9', + '0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f': 'WETH10', + '0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817': 'CCIP Router', + '0x8078a09637e47fa5ed34f626046ea2094a5cde5e': 'CCIP Router (legacy direct)', + '0x105f8a15b819948a89153505762444ee9f324684': 'CCIP Sender (legacy direct)' + }; + const BRIDGE_TRACE_EXTRA = [ + '0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d', + '0xF1c93F54A5C2fc0d7766Ccb0Ad8f157DFb4C99Ce', + '0xCd42e8eD79Dc50599535d1de48d3dAFa0BE156F8', + '0x7D0022B7e8360172fd9C0bB6778113b7Ea3674E7', + '0x88495B3dccEA93b0633390fDE71992683121Fa62', + '0x9Cb97adD29c52e3B81989BcA2E33D46074B530eF' + ]; + function getBridgeRouterAddressesLower() { + var o = {}; + Object.keys(KNOWN_ADDRESS_LABELS || {}).forEach(function(k) { o[k.toLowerCase()] = true; }); + (BRIDGE_TRACE_EXTRA || []).forEach(function(a) { o[String(a).toLowerCase()] = true; }); + return o; + } + function describeLogTopicsForBridge(logs) { + var hints = []; + var xfer = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + var routers = getBridgeRouterAddressesLower(); + (logs || []).forEach(function(log) { + var topics = log.topics && Array.isArray(log.topics) ? log.topics : (log.topic0 ? [log.topic0] : []); + var t0 = String(topics[0] || '').toLowerCase(); + var addr = log.address && (log.address.hash || log.address) || log.address || ''; + addr = String(addr).toLowerCase(); + if (t0 === xfer && routers[addr]) { + hints.push('ERC-20 Transfer from a known bridge/router contract (' + shortenHash(addr) + ').'); + } + }); + return hints; + } + function buildBridgeTraceInnerHtml(toAddr, logs) { + var lines = []; + var to = (toAddr || '').toLowerCase(); + if (to && (KNOWN_ADDRESS_LABELS[to] || getBridgeRouterAddressesLower()[to])) { + lines.push('The transaction targets a known bridge, router, or PMM-related contract.'); + if (KNOWN_ADDRESS_LABELS[to]) lines.push('Label: ' + KNOWN_ADDRESS_LABELS[to]); + } else if (to) { + lines.push('To address is not in the explorer allowlist; inspect logs and internal transactions for cross-chain or swap activity.'); + } else { + lines.push('Contract creation or empty "to"; use internal transactions and logs for interpretation.'); + } + var lh = describeLogTopicsForBridge(logs); + lh.forEach(function(x) { lines.push(x); }); + if (!lines.length) { + return '

No bridge heuristics matched.

'; + } + return '
    ' + lines.map(function(l) { return '
  • ' + escapeHtml(l) + '
  • '; }).join('') + '
'; + } + function buildBridgeTraceCardHtml(toAddr, logs) { + return '
' + + '

Bridge / route interpretation

' + + '

Heuristic summary only — not proof of settlement or liquidity.

' + + '
' + buildBridgeTraceInnerHtml(toAddr, logs) + '
' + + '

Visual command center (new tab)

' + + '
'; + } + const CHAIN_138_PMM_INTEGRATION_ADDRESS = '0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895'; const CHAIN_138_PRIVATE_POOL_REGISTRY = '0xb27057B27db09e8Df353AF722c299f200519882A'; const CHAIN_138_CUSDT_ADDRESS = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'; const CHAIN_138_CUSDC_ADDRESS = '0xf22258f57794CC8E06237084b353Ab30fFfa640b'; + const CHAIN_138_CUSDT_V2_ADDRESS = '0x9FBfab33882Efe0038DAa608185718b772EE5660'; + const CHAIN_138_CUSDC_V2_ADDRESS = '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d'; const CHAIN_138_CEURT_ADDRESS = '0xdf4b71c61E5912712C1Bdd451416B9aC26949d72'; const CHAIN_138_CXAUC_ADDRESS = '0x290E52a8819A4fbD0714E517225429aA2B70EC6b'; const CHAIN_138_CXAUT_ADDRESS = '0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E'; @@ -566,7 +639,7 @@ var out = await rpcEthCall(token, SELECTOR_SYMBOL); var hex = stripHexPrefix(out); if (!hex || hex.length < 128) return ''; - var len = Number(BigInt('0x' + hex.slice(64, 128))); + var len = hexToNumber('0x' + hex.slice(64, 128)); if (!len || !Number.isFinite(len)) return ''; var dataHex = hex.slice(128, 128 + (len * 2)); var bytes = []; @@ -579,15 +652,42 @@ } } function formatTokenUnits(value, decimals, precision) { - var amount = typeof value === 'bigint' ? value : BigInt(value || 0); - var scale = BigInt(Math.pow(10, Number(decimals || 0))); - if (scale === 0n) return '0'; - var whole = amount / scale; - var fraction = amount % scale; - if (fraction === 0n) return whole.toString(); - var fracStr = fraction.toString().padStart(Number(decimals || 0), '0'); - var trimmed = fracStr.slice(0, Math.max(0, Number(precision == null ? 3 : precision))).replace(/0+$/, ''); - return trimmed ? (whole.toString() + '.' + trimmed) : whole.toString(); + var safeDecimals = parseInt(decimals, 10); + if (!Number.isFinite(safeDecimals) || safeDecimals < 0) safeDecimals = 0; + if (safeDecimals > 255) safeDecimals = 255; + return formatUnits(value, safeDecimals, precision == null ? 3 : precision); + } + function parseTokenUnitsToNumber(value, decimals) { + var parsed = Number(formatTokenUnits(value, decimals, Number(decimals || 0))); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + } + function isChain138UsdFallbackToken(address, ctx) { + var lower = String(address || '').toLowerCase(); + if (!lower) return false; + var candidates = [ + (ctx && ctx.compliantUSDT) || CHAIN_138_CUSDT_ADDRESS, + (ctx && ctx.compliantUSDC) || CHAIN_138_CUSDC_ADDRESS, + (ctx && ctx.officialUSDT) || '', + (ctx && ctx.officialUSDC) || '', + CHAIN_138_CUSDT_V2_ADDRESS, + CHAIN_138_CUSDC_V2_ADDRESS + ].map(function(value) { return String(value || '').toLowerCase(); }).filter(Boolean); + return candidates.indexOf(lower) !== -1; + } + function estimateChain138FallbackDepthUsd(tokenIn, reserveIn, tokenOut, reserveOut, ctx) { + if (!(reserveIn > 0n && reserveOut > 0n)) { + return { tvlUsd: 0, estimatedTradeCapacityUsd: 0 }; + } + var reserveInUsd = isChain138UsdFallbackToken(tokenIn, ctx) ? parseTokenUnitsToNumber(reserveIn, CHAIN_138_POOL_BALANCE_DECIMALS) : 0; + var reserveOutUsd = isChain138UsdFallbackToken(tokenOut, ctx) ? parseTokenUnitsToNumber(reserveOut, CHAIN_138_POOL_BALANCE_DECIMALS) : 0; + var tvlUsd = 0; + if (reserveInUsd > 0 && reserveOutUsd > 0) tvlUsd = reserveInUsd + reserveOutUsd; + else if (reserveInUsd > 0) tvlUsd = reserveInUsd * 2; + else if (reserveOutUsd > 0) tvlUsd = reserveOutUsd * 2; + return { + tvlUsd: tvlUsd, + estimatedTradeCapacityUsd: tvlUsd > 0 ? Math.min(tvlUsd, tvlUsd * 0.2) : 0 + }; } async function fetchCurrentPmmContext() { var integration = CHAIN_138_PMM_INTEGRATION_ADDRESS; @@ -627,6 +727,7 @@ var tokenOutSymbol = inferKnownTokenSymbol(query.tokenOut, ctx) || await rpcReadSymbol(query.tokenOut) || shortenHash(query.tokenOut); var reserveInFormatted = formatTokenUnits(reserveIn, CHAIN_138_POOL_BALANCE_DECIMALS, 3); var reserveOutFormatted = formatTokenUnits(reserveOut, CHAIN_138_POOL_BALANCE_DECIMALS, 3); + var depthUsd = estimateChain138FallbackDepthUsd(query.tokenIn, reserveIn, query.tokenOut, reserveOut, ctx); return { generatedAt: new Date().toISOString(), @@ -654,10 +755,10 @@ chainName: 'DeFi Oracle Meta Mainnet', status: status, depth: { - tvlUsd: 0, + tvlUsd: depthUsd.tvlUsd, reserve0: reserveInFormatted, reserve1: reserveOutFormatted, - estimatedTradeCapacityUsd: 0, + estimatedTradeCapacityUsd: depthUsd.estimatedTradeCapacityUsd, freshnessSeconds: 0, status: funded ? 'live' : (partial ? 'stale' : 'unavailable') }, @@ -702,10 +803,10 @@ source: 'live-rpc' }, depth: { - tvlUsd: 0, + tvlUsd: depthUsd.tvlUsd, reserve0: reserveInFormatted, reserve1: reserveOutFormatted, - estimatedTradeCapacityUsd: 0, + estimatedTradeCapacityUsd: depthUsd.estimatedTradeCapacityUsd, freshnessSeconds: 0, status: funded ? 'live' : (partial ? 'stale' : 'unavailable') } @@ -843,14 +944,16 @@ } function getActiveBridgeContractCount() { return new Set([ - '0x971cD9D156f193df8051E48043C476e53ECd4693', + '0xcacfd227A040002e49e2e01626363071324f820a', '0xe0E93247376aa097dB308B92e6Ba36bA015535D0', - '0x2A0840e5117683b11682ac46f5CF5621E67269E3', - '0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03', - '0x8078a09637e47fa5ed34f626046ea2094a5cde5e', - '0xa780ef19a041745d353c9432f2a7f5a241335ffe', - '0x105f8a15b819948a89153505762444ee9f324684', - '0xdab0591e5e89295ffad75a71dcfc30c5625c4fa2' + '0xc9901ce2Ddb6490FAA183645147a87496d8b20B6', + '0x04E1e22B0D41e99f4275bd40A50480219bc9A223', + '0x24293CA562aE1100E60a4640FF49bd656cFf93B4', + '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c', + '0xF7736443f02913e7e0773052103296CfE1637448', + '0x0CA60e6f8589c540200daC9D9Cb27BC2e48eE66A', + '0x73376eB92c16977B126dB9112936A20Fa0De3442', + '0x6e94e53F73893b2a6784Df663920D31043A6dE07' ].map(function(addr) { return String(addr || '').toLowerCase(); })).size; } function getAddressLabel(addr) { if (!addr) return ''; var lower = addr.toLowerCase(); if (KNOWN_ADDRESS_LABELS[lower]) return KNOWN_ADDRESS_LABELS[lower]; try { var j = localStorage.getItem('explorerAddressLabels'); if (!j) return ''; var m = JSON.parse(j); return m[lower] || ''; } catch(e){ return ''; } } @@ -885,7 +988,7 @@ _blocksScrollAnimationId = null; } currentView = viewName; - var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail','watchlist','searchResults','tokens','addresses','pools','routes','liquidity','more']; + var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail','watchlist','searchResults','tokens','addresses','pools','routes','liquidity','more','system']; if (detailViews.indexOf(viewName) === -1) currentDetailKey = ''; var homeEl = document.getElementById('homeView'); if (homeEl) homeEl.style.display = viewName === 'home' ? 'block' : 'none'; @@ -912,6 +1015,7 @@ window.showTokensList = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('tokens'); if (window._loadTokensList) window._loadTokensList(); } finally { _inNavHandler = false; } }; window.showAnalytics = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('analytics'); if (window._showAnalytics) window._showAnalytics(); } finally { _inNavHandler = false; } }; window.showOperator = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('operator'); if (window._showOperator) window._showOperator(); } finally { _inNavHandler = false; } }; + window.showSystemTopology = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('system'); updateBreadcrumb('system'); updatePath('/system'); if (window._showSystemTopology) window._showSystemTopology(); } finally { _inNavHandler = false; } }; // Compatibility wrappers for detail views; defer to the next tick to avoid synchronous recursion. window.showBlockDetail = function(n) { if (window._showBlockDetail) setTimeout(function() { window._showBlockDetail(n); }, 0); }; window.showTransactionDetail = function(h) { if (window._showTransactionDetail) setTimeout(function() { window._showTransactionDetail(h); }, 0); }; @@ -965,13 +1069,34 @@ if (operatorNav) operatorNav.style.display = hasAccess(4) ? 'block' : 'none'; } + let connectingWallet = false; + async function readApiErrorMessage(response, fallbackMessage) { + try { + const errorData = await response.json(); + return (errorData && errorData.error && errorData.error.message) ? errorData.error.message : fallbackMessage; + } catch (e) { + return fallbackMessage; + } + } + // Wallet authentication async function connectWallet() { + if (connectingWallet) { + console.log('connectWallet already in progress, skipping...'); + return; + } if (typeof ethers === 'undefined') { alert('Ethers.js not loaded. Please refresh the page.'); return; } + const walletBtn = document.getElementById('walletConnectBtn'); + const originalWalletBtnText = walletBtn ? walletBtn.textContent : ''; + if (walletBtn) { + walletBtn.disabled = true; + walletBtn.textContent = 'Connecting...'; + } + connectingWallet = true; try { if (!window.ethereum) { alert('MetaMask not detected. Please install MetaMask.'); @@ -988,7 +1113,15 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address }) }); - const nonceData = await nonceResp.json(); + const nonceData = await nonceResp.json().catch(() => null); + if (!nonceResp.ok || !nonceData || !nonceData.nonce) { + const nonceError = + (nonceData && nonceData.error && nonceData.error.message) + ? nonceData.error.message + : 'Failed to request wallet sign-in nonce'; + alert('Wallet sign-in is temporarily unavailable: ' + nonceError); + return; + } // Sign message const message = `Sign this message to authenticate with SolaceScanScout Explorer.\n\nNonce: ${nonceData.nonce}`; @@ -1010,7 +1143,6 @@ localStorage.setItem('authToken', authToken); localStorage.setItem('userAddress', userAddress); updateUIForTrack(); - const walletBtn = document.getElementById('walletConnectBtn'); const walletStatus = document.getElementById('walletStatus'); const walletAddress = document.getElementById('walletAddress'); if (walletBtn) walletBtn.style.display = 'none'; @@ -1019,8 +1151,8 @@ await loadFeatureFlags(); showToast('Wallet connected successfully!', 'success'); } else { - const errorData = await authResp.json(); - alert('Authentication failed: ' + (errorData.error?.message || 'Unknown error')); + const errorMessage = await readApiErrorMessage(authResp, 'Unknown error'); + alert('Authentication failed: ' + errorMessage); } } catch (error) { var msg = (error && error.message) ? String(error.message) : ''; @@ -1028,6 +1160,12 @@ ? 'Connection was rejected. Please approve the MetaMask popup to connect.' : ('Failed to connect wallet: ' + (msg || 'Unknown error')); alert(friendly); + } finally { + connectingWallet = false; + if (walletBtn && walletBtn.style.display !== 'none') { + walletBtn.disabled = false; + walletBtn.textContent = originalWalletBtnText || t('connectWallet'); + } } } @@ -1150,6 +1288,29 @@ last_seen_at: lastSeen }; } + function normalizeRpcAddress(address, balanceHex, txCountHex, codeHex) { + var normalizedAddress = safeAddress(address); + if (!normalizedAddress) return null; + var txCount = hexToNumber(txCountHex || '0x0'); + var code = String(codeHex || '0x'); + return { + address: normalizedAddress, + hash: normalizedAddress, + balance: hexToDecimalString(balanceHex || '0x0'), + transaction_count: txCount, + token_count: 0, + is_contract: !!(code && code !== '0x' && code !== '0x0'), + is_verified: false, + tx_sent: txCount, + tx_received: 0, + label: getAddressLabel(normalizedAddress) || null, + name: null, + ens_domain_name: null, + creation_tx_hash: null, + first_seen_at: null, + last_seen_at: null + }; + } function hexToDecimalString(value) { if (value == null || value === '') return '0'; @@ -1166,7 +1327,10 @@ if (value == null || value === '') return 0; if (typeof value === 'number') return value; try { - return Number(BigInt(String(value))); + var bigintValue = BigInt(String(value)); + if (bigintValue > BigInt(Number.MAX_SAFE_INTEGER)) return Number.MAX_SAFE_INTEGER; + if (bigintValue < BigInt(Number.MIN_SAFE_INTEGER)) return Number.MIN_SAFE_INTEGER; + return Number(bigintValue); } catch (e) { return Number(value) || 0; } @@ -1223,6 +1387,20 @@ }); } + function normalizeRpcLog(log) { + if (!log || typeof log !== 'object') return null; + return { + address: log.address || null, + block_hash: log.blockHash || null, + block_number: log.blockNumber != null ? hexToNumber(log.blockNumber) : null, + data: log.data || '0x', + decoded: null, + index: log.logIndex != null ? hexToNumber(log.logIndex) : null, + topics: Array.isArray(log.topics) ? log.topics : [], + transaction_hash: log.transactionHash || null + }; + } + async function fetchChain138BlocksPage(page, pageSize) { try { var response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/blocks?page=${page}&page_size=${pageSize}`); @@ -1309,7 +1487,7 @@ var response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions/${txHash}`); return { transaction: normalizeTransaction(response), - rawTransaction: response + rawTransaction: Object.assign({ source: 'blockscout' }, response) }; } catch (error) { console.warn('Falling back to RPC transaction detail:', error.message || error); @@ -1322,7 +1500,47 @@ var block = rpcTx.blockNumber ? await rpcCall('eth_getBlockByNumber', [rpcTx.blockNumber, false]).catch(function() { return null; }) : null; return { transaction: normalizeRpcTransaction(rpcTx, receipt, block), - rawTransaction: null + rawTransaction: { + source: 'rpc_fallback', + rpc_transaction: rpcTx, + receipt: receipt, + block: block + } + }; + } + async function fetchChain138AddressDetail(address) { + var normalizedAddress = safeAddress(address); + if (!normalizedAddress) { + return { address: null, rawAddress: null, source: 'invalid' }; + } + try { + var response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/addresses/${normalizedAddress}`, 1, RETRY_DELAY_MS, ADDRESS_DETAIL_BLOCKSCOUT_TIMEOUT_MS); + var raw = response && (response.data !== undefined ? response.data : response.address !== undefined ? response.address : response.items && response.items[0] !== undefined ? response.items[0] : response); + var normalized = normalizeAddress(raw); + if (normalized && normalized.hash) { + return { + address: normalized, + rawAddress: response, + source: 'blockscout' + }; + } + } catch (error) { + console.warn('Falling back to RPC address detail:', error.message || error); + } + var results = await Promise.all([ + rpcCall('eth_getBalance', [normalizedAddress, 'latest']).catch(function() { return '0x0'; }), + rpcCall('eth_getTransactionCount', [normalizedAddress, 'latest']).catch(function() { return '0x0'; }), + rpcCall('eth_getCode', [normalizedAddress, 'latest']).catch(function() { return '0x'; }) + ]); + return { + address: normalizeRpcAddress(normalizedAddress, results[0], results[1], results[2]), + rawAddress: { + source: 'rpc_fallback', + balance: results[0], + transaction_count: results[1], + code: results[2] + }, + source: 'rpc_fallback' }; } @@ -2200,6 +2418,41 @@ } window._showBridgeMonitoring = renderBridgeView; + async function runMissionControlBridgeTrace() { + var input = document.getElementById('missionControlBridgeTraceInput'); + var result = document.getElementById('missionControlBridgeTraceResult'); + if (!input || !result) return; + var tx = String(input.value || '').trim(); + if (!/^0x[a-fA-F0-9]{64}$/.test(tx)) { + result.innerHTML = '
Enter a valid 32-byte transaction hash.
'; + return; + } + result.innerHTML = '
Resolving trace...
'; + try { + var res = await fetch(EXPLORER_API_V1_BASE + '/mission-control/bridge/trace?tx=' + encodeURIComponent(tx)); + var body = await res.json(); + if (!res.ok) { + throw new Error((body && body.error && body.error.message) || ('HTTP ' + res.status)); + } + var data = body && body.data ? body.data : {}; + var html = ''; + html += '
'; + html += '
Mission-control trace resolved the tx through Blockscout and labeled any matching Chain 138 contracts from smart-contracts-master.json.
'; + html += '
'; + html += '
From
' + escapeHtml(data.from_registry || 'Unlabeled') + '
' + escapeHtml(data.from || '—') + '
'; + html += '
To
' + escapeHtml(data.to_registry || 'Unlabeled') + '
' + escapeHtml(data.to || '—') + '
'; + html += '
'; + if (data.blockscout_url) { + html += ''; + } + html += '
'; + result.innerHTML = html; + } catch (err) { + result.innerHTML = '
Bridge trace failed: ' + escapeHtml(err.message || 'Unknown error') + '
'; + } + } + window.runMissionControlBridgeTrace = runMissionControlBridgeTrace; + async function renderHomeView() { showView('home'); if ((window.location.pathname || '').replace(/\/$/, '') !== '') updatePath('/'); @@ -2269,6 +2522,7 @@ function buildOperatorViewHtml() { var html = ''; html += '
'; + html += '
Track 4 APIs require authenticated wallet + IP allowlisting on the Go backend. This browser UI never holds deployer keys. Do not paste secrets into Explorer AI; PMM/MCP execution is off unless the server enables it explicitly.
'; html += '
'; html += '
'; html += '
'; @@ -2285,6 +2539,8 @@ html += '
Liquidity access
Jump to partner payload routes, ingestion APIs, and public execution-plan endpoints without leaving the explorer.
'; html += '
Pool inventory
Review canonical PMM addresses, funding state, registry status, and exportable pool snapshots.
'; html += '
WETH utilities
Open the WETH9/WETH10 utilities, bridge contract references, and balance tools that operators often need during support.
'; + html += '
Mission-control APIs
GET /explorer-api/v1/mission-control/stream
GET /explorer-api/v1/mission-control/bridge/trace?tx=0x...
GET /explorer-api/v1/mission-control/liquidity/token/{address}/pools
'; + html += '
Track 4 script API
POST /explorer-api/v1/track4/operator/run-script
Requires authenticated wallet, IP allowlisting, and backend allowlist configuration.
'; html += '
'; html += '
'; return html; @@ -2299,10 +2555,186 @@ container.innerHTML = buildOperatorViewHtml(); } window._showOperator = renderOperatorView; + + function renderTopologyCytoscape(graph) { + var c = document.getElementById('systemTopologyContent'); + if (!c) return; + var elements = (graph && graph.elements) ? graph.elements : []; + var liqNote = ''; + var ls = graph && graph.liquiditySample; + if (ls && typeof ls.poolCount === 'number') { + liqNote = '

Token-aggregation sample at generation: ' + ls.poolCount + ' pools for sample token (set TOKEN_AGGREGATION_BASE_URL when running the generator).

'; + } + c.innerHTML = '
' + + liqNote + + '

Regenerate: bash explorer-monorepo/scripts/generate-topology-graph.sh from repo root, then deploy /var/www/html/config/topology-graph.json. Click a contract node to open its address page when href is present.

'; + function runLayout() { + if (typeof cytoscape === 'undefined') return; + var cyInst = cytoscape({ + container: document.getElementById('cyTopology'), + elements: elements, + style: [ + { selector: 'node', style: { 'label': 'data(label)', 'font-size': '10px', 'text-wrap': 'wrap', 'text-max-width': '120px', 'background-color': '#2563eb', 'color': '#fff' } }, + { selector: 'edge', style: { 'label': 'data(label)', 'font-size': '9px', 'curve-style': 'bezier', 'target-arrow-shape': 'triangle', 'line-color': '#94a3b8', 'target-arrow-color': '#94a3b8' } } + ], + layout: { name: 'cose', animate: true, padding: 12 } + }); + cyInst.on('tap', 'node', function(evt) { + var href = evt.target.data('href'); + if (href) window.open(href, '_blank', 'noopener'); + }); + } + if (window.cytoscape) { + runLayout(); + return; + } + var s = document.createElement('script'); + s.src = 'https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js'; + s.onload = runLayout; + s.onerror = function() { c.innerHTML = '

Could not load graph library (CDN).

'; }; + document.head.appendChild(s); + } + + function renderSystemTopologyView() { + var c = document.getElementById('systemTopologyContent'); + if (!c) return; + c.innerHTML = '
Loading topology…
'; + fetch('/config/topology-graph.json?_=' + Date.now()).then(function(r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }).then(function(g) { + renderTopologyCytoscape(g); + }).catch(function() { + c.innerHTML = '

Missing /config/topology-graph.json. Run explorer-monorepo/scripts/generate-topology-graph.sh and deploy to the explorer host.

'; + }); + } + window._showSystemTopology = renderSystemTopologyView; + + function ensureMissionControlHealthStrip() { + var main = document.getElementById('mainContent'); + if (!main || document.getElementById('missionControlHealthStrip')) return; + var el = document.createElement('div'); + el.id = 'missionControlHealthStrip'; + el.setAttribute('role', 'status'); + el.style.cssText = 'margin: 0 0 0.75rem 0; padding: 0.45rem 0.75rem; border-radius: 8px; font-size: 0.82rem; color: var(--text); border: 1px solid var(--border);'; + el.textContent = 'Mission control: loading RPC health…'; + main.insertBefore(el, main.firstChild); + } + + function missionControlRelativeAge(isoString) { + if (!isoString) return ''; + var ts = Date.parse(isoString); + if (!Number.isFinite(ts)) return ''; + var diffSec = Math.max(0, Math.round((Date.now() - ts) / 1000)); + if (diffSec < 60) return diffSec + 's ago'; + var diffMin = Math.round(diffSec / 60); + if (diffMin < 60) return diffMin + 'm ago'; + var diffHr = Math.round(diffMin / 60); + return diffHr + 'h ago'; + } + + function summarizeMissionControlRelay(relayData) { + if (!relayData || typeof relayData !== 'object') return null; + if (relayData.url_probe && typeof relayData.url_probe === 'object' && relayData.url_probe.ok === false) { + return { text: 'CCIP relay: down', degraded: true, paused: false }; + } + if (relayData.file_snapshot_error) { + return { text: 'CCIP relay snapshot error', degraded: true, paused: false }; + } + var snapshot = null; + if (relayData.url_probe && relayData.url_probe.body && typeof relayData.url_probe.body === 'object') { + snapshot = relayData.url_probe.body; + } else if (relayData.file_snapshot && typeof relayData.file_snapshot === 'object') { + snapshot = relayData.file_snapshot; + } + if (!snapshot) { + return { text: 'CCIP relay: probe configured', degraded: false, paused: false }; + } + var status = String(snapshot.status || 'unknown').toLowerCase(); + var dest = snapshot.destination && (snapshot.destination.chain_name || snapshot.destination.chainName); + var queueSize = snapshot.queue && snapshot.queue.size != null ? snapshot.queue.size : null; + var pollAge = missionControlRelativeAge(snapshot.last_source_poll && snapshot.last_source_poll.at); + var text = 'CCIP relay: ' + status; + if (dest) text += ' -> ' + dest; + if (queueSize != null) text += ' · q ' + queueSize; + if (pollAge) text += ' · ' + pollAge + ' poll'; + return { + text: text, + degraded: ['degraded', 'stale', 'stopped', 'down'].indexOf(status) >= 0, + paused: status === 'paused' + }; + } + + function applyMissionControlHealthData(d) { + var el = document.getElementById('missionControlHealthStrip'); + if (!el || !d) return; + var probes = d.rpc_probe || []; + var parts = []; + var degraded = (d.status || '') === 'degraded'; + var paused = false; + probes.forEach(function(p) { + var st = p.ok ? 'ok' : 'down'; + var lat = p.latencyMs != null ? p.latencyMs + 'ms' : '—'; + var head = p.headAgeSeconds != null ? Math.round(p.headAgeSeconds) + 's head' : ''; + parts.push(p.name + ': ' + st + ' · ' + lat + (head ? ' · ' + head : '')); + }); + if (d.operator_verify && typeof d.operator_verify === 'object' && !d.operator_verify.error) { + parts.push('operator verify JSON'); + } + var relaySummary = summarizeMissionControlRelay(d.ccip_relay); + if (relaySummary) { + parts.push(relaySummary.text); + degraded = degraded || relaySummary.degraded; + paused = paused || relaySummary.paused; + } + el.textContent = 'Mission control · ' + (parts.join(' | ') || 'no RPC probes'); + el.style.background = degraded + ? 'rgba(200,100,80,0.18)' + : (paused ? 'rgba(180,120,0,0.15)' : 'rgba(60,140,200,0.12)'); + } + + var _missionControlEventSource = null; + function startMissionControlEventSource() { + if (typeof EventSource === 'undefined') return; + if (_missionControlEventSource) return; + try { + var url = EXPLORER_API_V1_BASE + '/mission-control/stream'; + var es = new EventSource(url); + es.addEventListener('mission-control', function(ev) { + try { + var o = JSON.parse(ev.data); + applyMissionControlHealthData(o && o.data ? o.data : {}); + } catch (err) {} + }); + es.onerror = function() { + es.close(); + _missionControlEventSource = null; + }; + _missionControlEventSource = es; + } catch (e) {} + } + + async function refreshMissionControlHealthStrip() { + var el = document.getElementById('missionControlHealthStrip'); + if (!el) return; + try { + var r = await fetch(EXPLORER_TRACK1_BASE + '/bridge/status', { credentials: 'omit' }); + if (!r.ok) { + el.textContent = 'Mission control: operator API unreachable (HTTP ' + r.status + ').'; + el.style.background = 'rgba(180,120,0,0.15)'; + return; + } + var j = await r.json(); + applyMissionControlHealthData(j && j.data ? j.data : {}); + } catch (e) { + el.textContent = 'Mission control: could not load /explorer-api/v1/track1/bridge/status.'; + el.style.background = 'rgba(120,120,120,0.15)'; + } + } function showView(viewName) { currentView = viewName; - var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail','watchlist','searchResults','tokens','addresses','pools','routes','liquidity','more','analytics','operator']; + var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail','watchlist','searchResults','tokens','addresses','pools','routes','liquidity','more','analytics','operator','system']; if (detailViews.indexOf(viewName) === -1) currentDetailKey = ''; document.querySelectorAll('.detail-view').forEach(v => v.classList.remove('active')); const homeView = document.getElementById('homeView'); @@ -2371,6 +2803,7 @@ if (parts[0] === 'tokens') { if (typeof showTokensList === 'function') showTokensList(); else focusSearchWithHint('token'); return; } if (parts[0] === 'analytics') { if (currentView !== 'analytics') showAnalytics(); return; } if (parts[0] === 'operator') { if (currentView !== 'operator') showOperator(); return; } + if (parts[0] === 'system') { if (currentView !== 'system') showSystemTopology(); return; } } window.applyHashRoute = applyHashRoute; var hasRouteOnReady = window.location.hash || ((window.location.pathname || '').replace(/^\//, '').replace(/\/$/, '')); @@ -2481,6 +2914,11 @@ breadcrumbHTML += '/'; breadcrumbHTML += 'More'; break; + case 'system': + breadcrumbContainer = document.getElementById('systemBreadcrumb'); + breadcrumbHTML += '/'; + breadcrumbHTML += 'System'; + break; case 'nft': breadcrumbContainer = document.getElementById('nftDetailBreadcrumb'); breadcrumbHTML += '/'; @@ -2497,10 +2935,10 @@ } // Retry logic with exponential backoff - async function fetchAPIWithRetry(url, maxRetries = FETCH_MAX_RETRIES, retryDelay = RETRY_DELAY_MS) { + async function fetchAPIWithRetry(url, maxRetries = FETCH_MAX_RETRIES, retryDelay = RETRY_DELAY_MS, timeoutMs = FETCH_TIMEOUT_MS) { for (let attempt = 0; attempt < maxRetries; attempt++) { try { - return await fetchAPI(url); + return await fetchAPI(url, timeoutMs); } catch (error) { const isLastAttempt = attempt === maxRetries - 1; const isRetryable = error.name === 'AbortError' || @@ -2523,9 +2961,9 @@ } } - async function fetchAPI(url) { + async function fetchAPI(url, timeoutMs = FETCH_TIMEOUT_MS) { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { @@ -3914,6 +4352,12 @@ } ]; var endpointCards = [ + { + title: 'Mission-control cached token pools', + method: 'GET', + href: EXPLORER_API_V1_BASE + '/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools', + notes: '30-second cached proxy to token-aggregation pools for the configured chain. Useful for fast operator checks and UI panels.' + }, { title: 'Canonical route matrix', method: 'GET', @@ -3952,6 +4396,7 @@ } ]; var requestExamples = [ + 'GET ' + EXPLORER_API_V1_BASE + '/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools', 'GET ' + publicApiBase + '/routes/matrix?includeNonLive=true', 'GET ' + publicApiBase + '/routes/ingestion?fromChainId=138&routeType=swap', 'GET ' + publicApiBase + '/routes/partner-payloads?partner=LiFi&amount=1000000&includeUnsupported=true', @@ -4044,6 +4489,7 @@ title: 'Explore', items: [ { title: 'Gas Tracker', icon: 'fa-gas-pump', status: 'Live', badgeClass: 'badge-success', desc: 'Review live gas, block time, TPS, and chain health from the home network dashboard.', action: 'showHome();', href: '/' }, + { title: 'Visual Command Center', icon: 'fa-satellite-dish', status: 'Live', badgeClass: 'badge-info', desc: 'Interactive Mermaid topology: Chain 138 hub, CCIP, Alltra, stack, flows, cross-chain, cW Mainnet, and off-chain integrations (from SMOM_DBIS_138_FULL_DEPLOYMENT_FLOW_MAP).', action: 'window.location.href=\'/chain138-command-center.html\';', href: '/chain138-command-center.html' }, { title: 'DEX Tracker', icon: 'fa-chart-line', status: 'Live', badgeClass: 'badge-success', desc: 'Open liquidity discovery, PMM pool status, live route trees, and partner payload access points.', action: 'showRoutes();', href: '/routes' }, { title: 'Node Tracker', icon: 'fa-server', status: 'Live', badgeClass: 'badge-success', desc: 'Inspect bridge balances, destination configuration, and operator-facing chain references from the live bridge monitoring panel.', action: 'showBridgeMonitoring();', href: '/bridge' }, { title: 'Label Cloud', icon: 'fa-tags', status: 'Live', badgeClass: 'badge-success', desc: 'Browse labeled addresses, contracts, and address activity through the explorer address index.', action: 'showAddresses();', href: '/addresses' }, @@ -4133,22 +4579,20 @@ var unitEl = document.getElementById('unitConverterUnit'); var resultsEl = document.getElementById('unitConverterResults'); if (!amountEl || !unitEl || !resultsEl) return; - var amount = Number(amountEl.value || '0'); + var amount = String(amountEl.value || '').trim(); var unit = unitEl.value; - if (!isFinite(amount) || amount < 0) { + var decimals = unit === 'ether' ? 18 : (unit === 'gwei' ? 9 : (unit === 'stable' ? 6 : 0)); + var baseUnits = parseDecimalToUnits(amount, decimals); + if (baseUnits == null) { resultsEl.innerHTML = '
Enter a non-negative amount to convert.
'; return; } - var wei = 0; - if (unit === 'ether') wei = amount * 1e18; - else if (unit === 'gwei') wei = amount * 1e9; - else if (unit === 'wei') wei = amount; - else if (unit === 'stable') wei = amount * 1e6; - var etherValue = unit === 'stable' ? 'N/A' : (wei / 1e18).toLocaleString(undefined, { maximumFractionDigits: 18 }); - var gweiValue = unit === 'stable' ? 'N/A' : (wei / 1e9).toLocaleString(undefined, { maximumFractionDigits: 9 }); - var stableValue = (unit === 'stable' ? amount : wei / 1e6).toLocaleString(undefined, { maximumFractionDigits: 6 }); + var etherValue = unit === 'stable' ? 'N/A' : formatUnits(baseUnits, 18, 18); + var gweiValue = unit === 'stable' ? 'N/A' : formatUnits(baseUnits, 9, 9); + var stableValue = formatUnits(baseUnits, 6, 6); + var baseUnitsLabel = unit === 'stable' ? 'Stable base units' : 'Wei'; resultsEl.innerHTML = - '
Wei: ' + escapeHtml(Math.round(wei).toString()) + '
' + + '
' + escapeHtml(baseUnitsLabel) + ': ' + escapeHtml(baseUnits.toString()) + '
' + '
Gwei: ' + escapeHtml(gweiValue) + '
' + '
Ether / WETH: ' + escapeHtml(etherValue) + '
' + '
6-decimal stable amount: ' + escapeHtml(stableValue) + '
'; @@ -4178,12 +4622,12 @@ container.innerHTML = '
Loading bridge data...
'; // Chain 138 Bridge Contracts - const WETH9_BRIDGE_138 = '0x971cD9D156f193df8051E48043C476e53ECd4693'; + const WETH9_BRIDGE_138 = '0xcacfd227A040002e49e2e01626363071324f820a'; const WETH10_BRIDGE_138 = '0xe0E93247376aa097dB308B92e6Ba36bA015535D0'; // Ethereum Mainnet Bridge Contracts - const WETH9_BRIDGE_MAINNET = '0x2A0840e5117683b11682ac46f5CF5621E67269E3'; - const WETH10_BRIDGE_MAINNET = '0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03'; + const WETH9_BRIDGE_MAINNET = '0xc9901ce2Ddb6490FAA183645147a87496d8b20B6'; + const WETH10_BRIDGE_MAINNET = '0x04E1e22B0D41e99f4275bd40A50480219bc9A223'; const explorerLinks = { 'BSC (56)': { label: 'BscScan', baseUrl: 'https://bscscan.com/address/' }, @@ -4204,21 +4648,21 @@ // Bridge routes configuration const routes = { weth9: { - 'BSC (56)': '0x8078a09637e47fa5ed34f626046ea2094a5cde5e', - 'Polygon (137)': '0xa780ef19a041745d353c9432f2a7f5a241335ffe', - 'Avalanche (43114)': '0x8078a09637e47fa5ed34f626046ea2094a5cde5e', - 'Base (8453)': '0x8078a09637e47fa5ed34f626046ea2094a5cde5e', - 'Arbitrum (42161)': '0x8078a09637e47fa5ed34f626046ea2094a5cde5e', - 'Optimism (10)': '0x8078a09637e47fa5ed34f626046ea2094a5cde5e', + 'BSC (56)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4', + 'Polygon (137)': '0xF7736443f02913e7e0773052103296CfE1637448', + 'Avalanche (43114)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4', + 'Base (8453)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4', + 'Arbitrum (42161)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c', + 'Optimism (10)': '0x6e94e53F73893b2a6784Df663920D31043A6dE07', 'Ethereum Mainnet (1)': WETH9_BRIDGE_MAINNET }, weth10: { - 'BSC (56)': '0x105f8a15b819948a89153505762444ee9f324684', - 'Polygon (137)': '0xdab0591e5e89295ffad75a71dcfc30c5625c4fa2', - 'Avalanche (43114)': '0x105f8a15b819948a89153505762444ee9f324684', - 'Base (8453)': '0x105f8a15b819948a89153505762444ee9f324684', - 'Arbitrum (42161)': '0x105f8a15b819948a89153505762444ee9f324684', - 'Optimism (10)': '0x105f8a15b819948a89153505762444ee9f324684', + 'BSC (56)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c', + 'Polygon (137)': '0x0CA60e6f8589c540200daC9D9Cb27BC2e48eE66A', + 'Avalanche (43114)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c', + 'Base (8453)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c', + 'Arbitrum (42161)': '0x73376eB92c16977B126dB9112936A20Fa0De3442', + 'Optimism (10)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4', 'Ethereum Mainnet (1)': WETH10_BRIDGE_MAINNET } }; @@ -4241,6 +4685,24 @@
Cross-chain interoperability powered by Chainlink CCIP
+
+ Arbitrum remains route-blocked on the current Mainnet hub leg. The latest Mainnet -> Arbitrum WETH9 send reverted before any bridge event was emitted, so treat Arbitrum as unavailable until that hub path is repaired. +
+
+ +
+
+

Mission-control bridge trace

+ Open SSE stream +
+
+
Paste a transaction hash to resolve the from and to addresses through the mission-control bridge-trace API. The response is labeled with Chain 138 contract names from the smart-contracts registry when available.
+
+ + +
+
+
@@ -4404,9 +4866,9 @@
  • Polygon - Polygon PoS
  • Avalanche - Avalanche C-Chain
  • Base - Base L2
  • -
  • Arbitrum - Arbitrum One
  • +
  • Arbitrum - Arbitrum One (configured, but the current Mainnet hub leg is blocked)
  • Optimism - Optimism Mainnet
  • -
  • Cronos - Cronos (25); see routing table for full list and config-ready chains (Gnosis, Celo, Wemix)
  • +
  • Cronos - Cronos (25); see routing table for the full destination list, including loaded CCIP destinations such as Gnosis and Celo, plus pending Wemix
  • How to Use:

    @@ -4418,7 +4880,7 @@

    CCIP Infrastructure:

      -
    • CCIP Router (Chain 138): ${explorerAddressLink('0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e', '0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e', 'color: inherit; text-decoration: none;')}
    • +
    • CCIP Router (Chain 138): ${explorerAddressLink('0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817', '0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817', 'color: inherit; text-decoration: none;')}
    • CCIP Sender (Chain 138): ${explorerAddressLink('0x105F8A15b819948a89153505762444Ee9f324684', '0x105F8A15b819948a89153505762444Ee9f324684', 'color: inherit; text-decoration: none;')}
    @@ -4457,6 +4919,170 @@ if (!safe) return content || 'N/A'; return '' + (content || escapeHtml(String(safe))) + ''; } + function toBigIntSafe(value) { + if (value == null || value === '') return null; + if (typeof value === 'bigint') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) return null; + return BigInt(Math.trunc(value)); + } + var stringValue = String(value).trim(); + if (!stringValue) return null; + if (/^-?0x[0-9a-f]+$/i.test(stringValue) || /^-?\d+$/.test(stringValue)) { + try { + return BigInt(stringValue); + } catch (e) { + return null; + } + } + return null; + } + function formatGroupedDigits(value, groupSize, separator) { + var stringValue = String(value == null ? '' : value); + if (!stringValue) return ''; + var negative = stringValue[0] === '-'; + var digits = negative ? stringValue.slice(1) : stringValue; + var groups = []; + for (var i = digits.length; i > 0; i -= groupSize) { + groups.unshift(digits.slice(Math.max(0, i - groupSize), i)); + } + return (negative ? '-' : '') + groups.join(separator || ' '); + } + function summarizeInspectorValue(value, head, tail) { + var stringValue = String(value == null ? '' : value); + var start = head || 18; + var end = tail || 12; + if (stringValue.length <= start + end + 1) return stringValue; + return stringValue.slice(0, start) + '...' + stringValue.slice(-end); + } + function buildNumericRepresentations(value) { + var parsed = toBigIntSafe(value); + if (parsed == null) return null; + var negative = parsed < 0n; + var absolute = negative ? -parsed : parsed; + var decimalPlain = parsed.toString(); + var hexDigits = absolute.toString(16); + var binaryDigits = absolute.toString(2); + var bitLength = absolute === 0n ? 1 : binaryDigits.length; + var byteLength = absolute === 0n ? 1 : Math.ceil(bitLength / 8); + return { + decimal: formatGroupedDigits(decimalPlain, 3, ','), + decimal_plain: decimalPlain, + hex: (negative ? '-0x' : '0x') + formatGroupedDigits(hexDigits, 4, ' '), + hex_plain: (negative ? '-0x' : '0x') + hexDigits, + binary: (negative ? '-0b' : '0b') + formatGroupedDigits(binaryDigits, 8, ' '), + binary_plain: (negative ? '-0b' : '0b') + binaryDigits, + bit_length: bitLength, + byte_length: byteLength + }; + } + function renderCopyButtonHtml(value, ariaLabel) { + if (value == null || value === '') return ''; + return ' '; + } + function renderInspectorHtmlLine(label, valueHtml) { + return '
    ' + escapeHtml(label) + '
    ' + (valueHtml || 'N/A') + '
    '; + } + function renderInspectorTextLine(label, value, copyValue) { + if (value == null || value === '') return renderInspectorHtmlLine(label, 'N/A'); + return renderInspectorHtmlLine(label, '' + escapeHtml(String(value)) + '' + (copyValue != null ? renderCopyButtonHtml(copyValue, 'Copy ' + label) : '')); + } + function renderInspectorCodeLine(label, value, copyValue) { + if (value == null || value === '') return renderInspectorHtmlLine(label, 'N/A'); + return renderInspectorHtmlLine(label, '
    ' + escapeHtml(String(value)) + '' + renderCopyButtonHtml(copyValue != null ? copyValue : value, 'Copy ' + label) + '
    '); + } + function renderNumericInspectorEntry(label, value, note, openByDefault) { + var repr = buildNumericRepresentations(value); + if (!repr) return ''; + var summary = repr.hex_plain.length <= 22 ? repr.hex_plain : summarizeInspectorValue(repr.hex_plain, 18, 10); + var html = '
    '; + html += '' + escapeHtml(label) + '' + escapeHtml(summary) + ''; + html += '
    '; + if (note) { + html += '
    ' + escapeHtml(note) + '
    '; + } + html += renderInspectorCodeLine('Dec', repr.decimal, repr.decimal_plain); + html += renderInspectorCodeLine('Hex', repr.hex, repr.hex_plain); + html += renderInspectorCodeLine('Bin', repr.binary, repr.binary_plain); + html += renderInspectorTextLine('Size', repr.bit_length + ' bits / ' + repr.byte_length + ' bytes'); + html += '
    '; + return html; + } + function safeJsonStringify(value) { + try { + return JSON.stringify(value, function(key, item) { + return typeof item === 'bigint' ? item.toString() : item; + }, 2); + } catch (error) { + return String(value == null ? '' : value); + } + } + function formatDurationValue(value) { + if (!Array.isArray(value) || value.length === 0) return ''; + return value.map(function(item) { + return typeof item === 'number' ? item.toLocaleString() : String(item); + }).join(' -> ') + ' ms'; + } + function formatLogDecodedValue(value) { + if (value == null || value === '') return ''; + return typeof value === 'string' ? value : safeJsonStringify(value); + } + function renderTransactionLogEntry(log, idx) { + var addressValue = (log.address && (log.address.hash || log.address)) || log.address || ''; + var topics = Array.isArray(log.topics) ? log.topics.filter(function(topic) { return topic != null; }) : []; + var dataValue = log.data || log.raw_data || '0x'; + var decodedValue = formatLogDecodedValue(log.decoded || log.decoded_text || ''); + var dataBytes = /^0x[0-9a-f]*$/i.test(String(dataValue || '')) ? Math.max(0, (String(dataValue).length - 2) / 2) : 0; + var blockNumber = log.block_number != null ? String(log.block_number) : ''; + var txHash = log.transaction_hash || log.transactionHash || ''; + var topicRows = topics.length ? '
    ' + topics.map(function(topic, topicIndex) { + return '
    Topic ' + topicIndex + '
    ' + escapeHtml(String(topic)) + '' + renderCopyButtonHtml(String(topic), 'Copy topic ' + topicIndex) + '
    '; + }).join('') + '
    ' : 'No topics'; + var metaChips = '
    ' + + 'Index' + escapeHtml(String(log.index != null ? log.index : idx)) + '' + + 'Topics' + escapeHtml(String(topics.length)) + '' + + 'Data' + escapeHtml(String(dataBytes)) + ' bytes' + + '
    '; + var html = '
    '; + html += 'Log #' + escapeHtml(String(log.index != null ? log.index : idx)) + ' • ' + escapeHtml(addressValue ? shortenHash(addressValue) : 'Unknown address') + '' + escapeHtml(String(topics.length)) + ' topics / ' + escapeHtml(String(dataBytes)) + ' bytes'; + html += '
    '; + html += metaChips; + html += renderInspectorHtmlLine('Address', addressValue ? explorerAddressLink(addressValue, escapeHtml(addressValue), 'color: inherit; text-decoration: none;') + renderCopyButtonHtml(addressValue, 'Copy log address') : 'N/A'); + if (blockNumber) { + html += renderInspectorHtmlLine('Block', explorerBlockLink(blockNumber, escapeHtml(blockNumber), 'color: inherit; text-decoration: none;')); + } + if (txHash) { + html += renderInspectorHtmlLine('Tx Hash', explorerTransactionLink(txHash, escapeHtml(txHash), 'color: inherit; text-decoration: none;')); + } + html += renderInspectorHtmlLine('Topics', topicRows); + html += renderInspectorCodeLine('Data', dataValue, dataValue); + html += renderInspectorHtmlLine('Decoded', '
    ' + (decodedValue ? escapeHtml(decodedValue) : '—') + '
    ' + (decodedValue ? renderCopyButtonHtml(decodedValue, 'Copy decoded log') : '')); + html += '
    '; + return html; + } + function renderInputWordTable(inputHex) { + if (!inputHex || !/^0x[0-9a-f]+$/i.test(String(inputHex))) return ''; + var normalized = String(inputHex).slice(2); + if (normalized.length <= 8) return ''; + var payload = normalized.slice(8); + if (!payload) return ''; + var words = []; + for (var i = 0; i < payload.length; i += 64) { + words.push(payload.slice(i, i + 64)); + } + if (!words.length) return ''; + var rows = words.map(function(word, index) { + var offsetBytes = 4 + (index * 32); + var hexWord = '0x' + word; + var decimalPreview = ''; + var parsed = toBigIntSafe(hexWord); + if (parsed != null) { + decimalPreview = summarizeInspectorValue(parsed.toString(), 18, 10); + } + return '' + escapeHtml(String(index)) + '' + escapeHtml(String(offsetBytes)) + ' / 0x' + escapeHtml(offsetBytes.toString(16)) + '' + escapeHtml(hexWord) + '' + (decimalPreview ? escapeHtml(decimalPreview) : 'N/A') + ''; + }).join(''); + return '
    ABI Word Breakdown' + escapeHtml(String(words.length)) + ' words
    Offsets start after the 4-byte method selector.
    ' + rows + '
    WordOffsetHexDec Preview
    '; + } async function renderBlockDetail(blockNumber) { const bn = safeBlockNumber(blockNumber); if (!bn) { showToast('Invalid block number', 'error'); return; } @@ -4615,16 +5241,72 @@ const valueEth = formatEther(t.value || '0'); const gasPriceGwei = t.gas_price ? (parseInt(t.gas_price) / 1e9).toFixed(2) : 'N/A'; const maxFeeGwei = t.max_fee_per_gas ? (parseInt(t.max_fee_per_gas) / 1e9).toFixed(2) : 'N/A'; - const priorityFeeGwei = t.max_priority_fee_per_gas ? (parseInt(t.max_priority_fee_per_gas) / 1e9).toFixed(2) : 'N/A'; + const priorityFeeValue = t.max_priority_fee_per_gas || (rawTx && rawTx.priority_fee) || null; + const priorityFeeGwei = priorityFeeValue ? (parseInt(priorityFeeValue, 10) / 1e9).toFixed(2) : 'N/A'; const burntFeeEth = t.tx_burnt_fee ? formatEther(t.tx_burnt_fee) : '0'; - const totalFee = t.gas_used && t.gas_price ? formatEther((BigInt(t.gas_used) * BigInt(t.gas_price)).toString()) : '0'; + const totalFeeWei = rawTx && rawTx.fee && rawTx.fee.value + ? String(rawTx.fee.value) + : (t.gas_used && t.gas_price ? (BigInt(t.gas_used) * BigInt(t.gas_price)).toString() : '0'); + const totalFee = totalFeeWei ? formatEther(totalFeeWei) : '0'; const txType = t.type === 2 ? 'EIP-1559' : t.type === 1 ? 'EIP-2930' : 'Legacy'; const revertReason = t.revert_reason || (rawTx && (rawTx.revert_reason || rawTx.error || rawTx.result)); const inputHex = (t.input && t.input !== '0x') ? t.input : null; const decodedInput = t.decoded_input || (rawTx && rawTx.decoded_input); - const toCellContent = t.to ? explorerAddressLink(t.to, formatAddressWithLabel(t.to), 'color: inherit; text-decoration: none;') + ' ' : 'N/A'; + const methodSelector = t.method_id || (rawTx && rawTx.method) || (inputHex ? inputHex.slice(0, 10) : null); + const confirmationDuration = rawTx ? formatDurationValue(rawTx.confirmation_duration) : ''; + const transactionTypeTags = rawTx && Array.isArray(rawTx.transaction_types) ? rawTx.transaction_types : []; + const actionsCount = rawTx && Array.isArray(rawTx.actions) ? rawTx.actions.length : 0; + const tokenTransferCount = rawTx && Array.isArray(rawTx.token_transfers) ? rawTx.token_transfers.length : 0; + const authorizationCount = rawTx && Array.isArray(rawTx.authorization_list) ? rawTx.authorization_list.length : 0; + const inputBytes = inputHex ? Math.max(0, (inputHex.length - 2) / 2) : 0; + const rawSource = rawTx && rawTx.source ? rawTx.source : (CHAIN_ID === 138 ? 'blockscout' : 'api'); + const rawSourceLabel = rawSource === 'rpc_fallback' ? 'RPC fallback' : 'Blockscout detail'; + const rawPayloadId = 'txRawPayload_' + txHash.slice(2, 10); + const statusText = t.status === 1 ? 'Success' : t.status === 0 ? 'Failed' : 'Pending'; + const statusClass = t.status === 1 ? 'badge-success' : t.status === 0 ? 'badge-danger' : 'badge-warning'; + const baseFeeGwei = rawTx && rawTx.base_fee_per_gas ? formatEther(rawTx.base_fee_per_gas, 'gwei') : ''; + const rawPayload = safeJsonStringify({ + normalized: t, + raw: rawTx + }); + const numericInspectorFields = [ + { label: 'Block Number', value: t.block_number, note: 'Canonical included block height.' }, + { label: 'Transaction Index', value: t.transaction_index != null ? t.transaction_index : (rawTx && rawTx.position != null ? rawTx.position : null), note: 'Zero-based position within the block.' }, + { label: 'Nonce', value: t.nonce, note: 'Account nonce used for this transaction.' }, + { label: 'Confirmations', value: t.confirmations, note: 'Current confirmation count from the explorer index.' }, + { label: 'Value (Wei)', value: t.value, note: 'Native value transferred, expressed in wei.' }, + { label: 'Gas Limit', value: t.gas_limit, note: 'Maximum gas supplied for execution.' }, + { label: 'Gas Used', value: t.gas_used, note: 'Actual gas consumed during execution.' }, + { label: 'Gas Price (Wei)', value: t.gas_price, note: 'Effective gas price used for fee settlement.' }, + { label: 'Max Fee Per Gas (Wei)', value: t.max_fee_per_gas, note: 'EIP-1559 max fee cap.' }, + { label: 'Max Priority Fee (Wei)', value: priorityFeeValue, note: 'Miner / validator tip component.' }, + { label: 'Base Fee (Wei)', value: rawTx && rawTx.base_fee_per_gas ? rawTx.base_fee_per_gas : null, note: 'Base fee returned by the explorer detail payload.' }, + { label: 'Total Fee Paid (Wei)', value: totalFeeWei, note: 'Fee paid for this transaction in wei.' }, + { label: 'Burnt Fee (Wei)', value: t.tx_burnt_fee, note: 'Fee component marked as burnt by the explorer.' }, + { label: 'Type', value: t.type, note: 'Transaction type identifier.' } + ]; + const numericInspectorHtml = numericInspectorFields.filter(function(field) { + return field.value != null && field.value !== ''; + }).map(function(field, index) { + return renderNumericInspectorEntry(field.label, field.value, field.note, index === 0); + }).join(''); + const executionMetaChips = []; + executionMetaChips.push('Source' + escapeHtml(rawSourceLabel) + ''); + executionMetaChips.push('Status' + escapeHtml(statusText) + ''); + if (rawTx && rawTx.fee && rawTx.fee.type) { + executionMetaChips.push('Fee Mode' + escapeHtml(String(rawTx.fee.type)) + ''); + } + if (transactionTypeTags.length) { + transactionTypeTags.forEach(function(tag) { + executionMetaChips.push('Tx Tag' + escapeHtml(String(tag)) + ''); + }); + } + const executionMetaHtml = executionMetaChips.length ? '
    ' + executionMetaChips.join('') + '
    ' : ''; + const fromCellContent = explorerAddressLink(t.from || '', formatAddressWithLabel(t.from || ''), 'color: inherit; text-decoration: none;') + renderCopyButtonHtml(t.from || '', 'Copy from address'); + const toCellContent = t.to ? explorerAddressLink(t.to, formatAddressWithLabel(t.to), 'color: inherit; text-decoration: none;') + renderCopyButtonHtml(t.to, 'Copy to address') : 'N/A'; let mainHtml = ` +

    Transaction

    @@ -4636,19 +5318,19 @@
    -
    +
    Transaction Hash
    -
    ${escapeHtml(t.hash)}
    +
    ${escapeHtml(t.hash)}${renderCopyButtonHtml(t.hash, 'Copy transaction hash')}
    Type
    -
    ${txType}
    +
    ${txType}
    Status
    - - ${t.status === 1 ? 'Success' : t.status === 0 ? 'Failed' : 'Pending'} + + ${statusText}
    @@ -4658,11 +5340,11 @@
    Block Hash
    -
    ${escapeHtml(t.block_hash || 'N/A')}
    +
    ${escapeHtml(t.block_hash || 'N/A')}${t.block_hash ? renderCopyButtonHtml(t.block_hash, 'Copy block hash') : ''}
    From
    -
    ${explorerAddressLink(t.from || '', formatAddressWithLabel(t.from || ''), 'color: inherit; text-decoration: none;')}
    +
    ${fromCellContent}
    To
    @@ -4681,7 +5363,7 @@
    ${t.gas_limit ? formatNumber(t.gas_limit) : 'N/A'}
    ${t.max_fee_per_gas ? `
    Max Fee Per Gas
    ${maxFeeGwei} Gwei
    ` : ''} - ${t.max_priority_fee_per_gas ? `
    Max Priority Fee
    ${priorityFeeGwei} Gwei
    ` : ''} + ${priorityFeeValue ? `
    Max Priority Fee
    ${priorityFeeGwei} Gwei
    ` : ''} ${!t.max_fee_per_gas && t.gas_price ? `
    Gas Price
    ${gasPriceGwei} Gwei
    ` : ''}
    Total Fee
    @@ -4691,6 +5373,31 @@
    Nonce
    ${t.nonce || 'N/A'}
    Timestamp
    ${timestamp}
    ${t.contract_address ? `
    Contract Address
    ${explorerAddressLink(t.contract_address, escapeHtml(t.contract_address), 'color: inherit; text-decoration: none;')}
    ` : ''} +
    + `; + + if (CHAIN_ID === 138) { + mainHtml += buildBridgeTraceCardHtml(t.to, []); + } + + mainHtml += ` +
    +
    +

    Execution Details

    + ${escapeHtml(rawSourceLabel)} +
    + ${executionMetaHtml} + ${renderInspectorTextLine('Method Selector', methodSelector, methodSelector)} + ${renderInspectorTextLine('Confirmations', t.confirmations != null ? String(t.confirmations) : '', t.confirmations != null ? String(t.confirmations) : null)} + ${renderInspectorTextLine('Confirmation Duration', confirmationDuration)} + ${renderInspectorTextLine('Transaction Tag', rawTx && rawTx.transaction_tag ? String(rawTx.transaction_tag) : '')} + ${renderInspectorTextLine('Actions Indexed', rawTx && Array.isArray(rawTx.actions) ? String(actionsCount) : '')} + ${renderInspectorTextLine('Token Transfers Indexed', rawTx && Array.isArray(rawTx.token_transfers) ? String(tokenTransferCount) : '')} + ${renderInspectorTextLine('Authorization Entries', rawTx && Array.isArray(rawTx.authorization_list) ? String(authorizationCount) : '')} + ${renderInspectorTextLine('Exchange Rate', rawTx && rawTx.exchange_rate ? String(rawTx.exchange_rate) + ' USD/ETH' : '')} + ${renderInspectorTextLine('Base Fee', baseFeeGwei ? baseFeeGwei + ' Gwei' : '')} + ${renderInspectorTextLine('Internal Trace State', rawTx && rawTx.has_error_in_internal_transactions != null ? (rawTx.has_error_in_internal_transactions ? 'Errors recorded in internal transactions' : 'Clean / no internal errors flagged') : '')} +
    `; if (revertReason && t.status !== 1) { @@ -4703,11 +5410,15 @@ `; } - if (inputHex || decodedInput) { - mainHtml += `

    Input Data

    `; + if (inputHex || decodedInput || methodSelector) { + mainHtml += `

    Input Data

    ${inputBytes} bytes
    `; + mainHtml += '
    '; + if (methodSelector) { + mainHtml += renderInspectorCodeLine('Selector', methodSelector, methodSelector); + } if (decodedInput && (decodedInput.method || decodedInput.params)) { const method = decodedInput.method || decodedInput.name || 'Unknown'; - mainHtml += `

    Method: ${escapeHtml(method)}

    `; + mainHtml += renderInspectorTextLine('Decoded Method', method, method); if (decodedInput.params && Array.isArray(decodedInput.params)) { mainHtml += ''; decodedInput.params.forEach(function(p) { @@ -4719,11 +5430,44 @@ } } if (inputHex) { - mainHtml += `

    Hex: ${escapeHtml(inputHex)}

    `; + mainHtml += renderInspectorCodeLine('Hex', inputHex, inputHex); + mainHtml += renderInputWordTable(inputHex); } - mainHtml += ''; + mainHtml += ''; } + if (numericInspectorHtml) { + mainHtml += ` +
    +
    +

    Numeric Inspector

    + DEC / HEX / BIN +
    +
    ${numericInspectorHtml}
    +
    + `; + } + + mainHtml += ` +
    +
    +

    Raw Payload

    + ${escapeHtml(rawSourceLabel)} +
    +
    + + Normalized + raw transaction payload + ${escapeHtml(rawSourceLabel)} / ${escapeHtml(summarizeInspectorValue(t.hash, 12, 10))} + +
    +
    This includes the normalized transaction fields used by the explorer plus the underlying Blockscout or RPC fallback payload so nothing important stays hidden behind the UI.
    +
    ${escapeHtml(rawPayload)}
    +
    +
    +
    +
    + `; + container.innerHTML = mainHtml; if (CHAIN_ID === 138) { @@ -4748,7 +5492,13 @@ const internalResp = results[0].items ? results[0] : results[1]; const logsResp = results[2].items ? results[2] : results[3]; const internals = internalResp.items || []; - const logs = logsResp.items || logsResp.log_entries || []; + const rpcReceiptLogs = rawTx && rawTx.receipt && Array.isArray(rawTx.receipt.logs) + ? rawTx.receipt.logs.map(normalizeRpcLog).filter(function(log) { return log !== null; }) + : []; + let logs = logsResp.items || logsResp.log_entries || []; + if ((!logs || logs.length === 0) && rpcReceiptLogs.length) { + logs = rpcReceiptLogs; + } const internalEl = document.getElementById('txInternalTxs'); if (internalEl) { @@ -4794,56 +5544,55 @@ const topics = (log.topics && Array.isArray(log.topics)) ? log.topics : (log.topic0 ? [log.topic0] : []); const topicsStr = topics.join(', '); const data = log.data || log.raw_data || '0x'; - const decoded = log.decoded || log.decoded_text || ''; + const decoded = formatLogDecodedValue(log.decoded || log.decoded_text || ''); return matchesExplorerFilter([addr, topicsStr, data, decoded].join(' '), logsFilter); }) : logs; - let tbl = logsFilterBar + '
    ParamValue
    '; - filteredLogs.forEach(function(log, idx) { - const addr = log.address?.hash || log.address || 'N/A'; - const topics = (log.topics && Array.isArray(log.topics)) ? log.topics : (log.topic0 ? [log.topic0] : []); - const topicsStr = topics.join(', '); - const data = log.data || log.raw_data || '0x'; - tbl += ''; - }); if (filteredLogs.length === 0) { - tbl += ''; - } - tbl += '
    AddressTopicsDataDecoded
    ' + explorerAddressLink(addr, escapeHtml(shortenHash(addr)), 'color: inherit; text-decoration: none;') + '' + escapeHtml(String(topicsStr).substring(0, 80)) + (String(topicsStr).length > 80 ? '...' : '') + '' + escapeHtml(String(data).substring(0, 66)) + (String(data).length > 66 ? '...' : '') + '
    No event logs match the current filter.
    '; - logsEl.innerHTML = tbl; - if (typeof ethers !== 'undefined' && ethers.utils) { - (function(logsList, txHash) { - var addrs = []; - logsList.forEach(function(l) { var a = l.address && (l.address.hash || l.address) || l.address; if (a && addrs.indexOf(a) === -1) addrs.push(a); }); - var abiCache = {}; - Promise.all(addrs.map(function(addr) { - if (!/^0x[a-f0-9]{40}$/i.test(addr)) return Promise.resolve(); - return fetch(BLOCKSCOUT_API + '/v2/smart-contracts/' + addr).then(function(r) { return r.json(); }).catch(function() { return null; }).then(function(res) { - var abi = res && (res.abi || res.abi_json); - if (abi) abiCache[addr.toLowerCase()] = Array.isArray(abi) ? abi : (typeof abi === 'string' ? JSON.parse(abi) : abi); + logsEl.innerHTML = logsFilterBar + '

    No event logs match the current filter.

    '; + } else { + logsEl.innerHTML = logsFilterBar + '
    ' + filteredLogs.map(function(log, idx) { + return renderTransactionLogEntry(log, idx); + }).join('') + '
    '; + if (typeof ethers !== 'undefined' && ethers.utils) { + (function(logsList) { + var addrs = []; + logsList.forEach(function(l) { var a = l.address && (l.address.hash || l.address) || l.address; if (a && addrs.indexOf(a) === -1) addrs.push(a); }); + var abiCache = {}; + Promise.all(addrs.map(function(addr) { + if (!/^0x[a-f0-9]{40}$/i.test(addr)) return Promise.resolve(); + return fetch(BLOCKSCOUT_API + '/v2/smart-contracts/' + addr).then(function(r) { return r.json(); }).catch(function() { return null; }).then(function(res) { + var abi = res && (res.abi || res.abi_json); + if (abi) abiCache[addr.toLowerCase()] = Array.isArray(abi) ? abi : (typeof abi === 'string' ? JSON.parse(abi) : abi); + }); + })).then(function() { + logsList.forEach(function(log, idx) { + var addr = (log.address && (log.address.hash || log.address)) || log.address; + var topics = log.topics && Array.isArray(log.topics) ? log.topics.filter(function(topic) { return topic != null; }) : (log.topic0 ? [log.topic0] : []); + var data = log.data || log.raw_data || '0x'; + var abi = addr ? abiCache[(addr + '').toLowerCase()] : null; + var decodedEl = document.getElementById('txLogDecoded' + idx); + if (!decodedEl || !abi) return; + try { + var iface = new ethers.utils.Interface(abi); + var parsed = iface.parseLog({ topics: topics, data: data }); + if (parsed && parsed.name) { + var args = parsed.args && parsed.args.length ? parsed.args.map(function(a) { return String(a); }).join(', ') : ''; + decodedEl.textContent = parsed.name + '(' + args + ')'; + decodedEl.title = parsed.signature || ''; + } + } catch (e) {} + }); }); - })).then(function() { - logsList.forEach(function(log, idx) { - var addr = (log.address && (log.address.hash || log.address)) || log.address; - var topics = log.topics && Array.isArray(log.topics) ? log.topics : (log.topic0 ? [log.topic0] : []); - var data = log.data || log.raw_data || '0x'; - var abi = addr ? abiCache[(addr + '').toLowerCase()] : null; - var decodedEl = document.getElementById('txLogDecoded' + idx); - if (!decodedEl || !abi) return; - try { - var iface = new ethers.utils.Interface(abi); - var parsed = iface.parseLog({ topics: topics, data: data }); - if (parsed && parsed.name) { - var args = parsed.args && parsed.args.length ? parsed.args.map(function(a) { return String(a); }).join(', ') : ''; - decodedEl.textContent = parsed.name + '(' + args + ')'; - decodedEl.title = parsed.signature || ''; - } - } catch (e) {} - }); - }); - })(logs, txHash); + })(filteredLogs); + } } } } + + var bridgeTraceBody = document.getElementById('txBridgeTraceBody'); + if (bridgeTraceBody && CHAIN_ID === 138) { + bridgeTraceBody.innerHTML = buildBridgeTraceInnerHtml(t.to, logs || []); + } }); } } catch (error) { @@ -4858,10 +5607,33 @@ return div.innerHTML; } function exportTransactionCSV(txHash) { - fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/transactions/' + txHash).then(function(r) { - var t = normalizeTransaction(r); + Promise.resolve(CHAIN_ID === 138 ? fetchChain138TransactionDetail(txHash) : fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/transactions/' + txHash).then(function(r) { + return { transaction: normalizeTransaction(r), rawTransaction: r }; + })).then(function(result) { + var t = result && result.transaction ? result.transaction : null; + var raw = result && result.rawTransaction ? result.rawTransaction : null; if (!t) return; - var rows = [['Field', 'Value'], ['hash', t.hash], ['from', t.from], ['to', t.to || ''], ['value', t.value || '0'], ['block_number', t.block_number || ''], ['status', t.status], ['gas_used', t.gas_used || ''], ['gas_limit', t.gas_limit || '']]; + var totalFeeWei = raw && raw.fee && raw.fee.value + ? String(raw.fee.value) + : (t.gas_used && t.gas_price ? (BigInt(t.gas_used) * BigInt(t.gas_price)).toString() : '0'); + var rows = [ + ['Field', 'Value'], + ['hash', t.hash], + ['status', t.status], + ['type', t.type != null ? t.type : ''], + ['from', t.from], + ['to', t.to || ''], + ['value_wei', t.value || '0'], + ['block_number', t.block_number || ''], + ['transaction_index', t.transaction_index != null ? t.transaction_index : ''], + ['nonce', t.nonce || ''], + ['gas_price_wei', t.gas_price || ''], + ['gas_limit', t.gas_limit || ''], + ['gas_used', t.gas_used || ''], + ['confirmations', t.confirmations != null ? t.confirmations : ''], + ['method_selector', t.method_id || (raw && raw.method) || ''], + ['total_fee_wei', totalFeeWei] + ]; var csv = rows.map(function(row) { return row.map(function(cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(','); }).join('\n'); var blob = new Blob([csv], { type: 'text/csv' }); var url = URL.createObjectURL(blob); @@ -4930,8 +5702,7 @@ var symbol = token.symbol || token.name || '-'; var balance = b.value || b.balance || '0'; var decimals = token.decimals != null ? token.decimals : 18; - var divisor = Math.pow(10, parseInt(decimals, 10)); - var displayBalance = (Number(balance) / divisor).toLocaleString(undefined, { maximumFractionDigits: 6 }); + var displayBalance = formatUnitsLocalized(balance, decimals, 6); var type = token.type || b.token_type || 'ERC-20'; rows.push([symbol, contract, displayBalance, type]); }); @@ -4965,13 +5736,14 @@ } let a; + let addressDetailSource = 'blockscout'; // For ChainID 138, use Blockscout API directly if (CHAIN_ID === 138) { try { - const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/addresses/${address}`); - var raw = response && (response.data !== undefined ? response.data : response.address !== undefined ? response.address : response.items && response.items[0] !== undefined ? response.items[0] : response); - a = normalizeAddress(raw); + const detailResult = await fetchChain138AddressDetail(address); + a = detailResult.address; + addressDetailSource = detailResult.source || 'blockscout'; if (!a || !a.hash) { throw new Error('Address not found'); } @@ -4999,8 +5771,12 @@ const contractLink = isContract ? `View contract on Blockscout` : ''; const savedLabel = getAddressLabel(address); const inWatchlist = isInWatchlist(address); + const fallbackNotice = addressDetailSource === 'rpc_fallback' + ? `
    RPC fallback mode. Indexed address metadata is temporarily unavailable or responding too slowly, so this view is using live RPC for balance, nonce-derived transaction count, and contract detection. Transactions, token balances, internal txns, and NFTs remain best-effort while the explorer API recovers.
    ` + : ''; container.innerHTML = ` + ${fallbackNotice}
    Address
    ${escapedAddress}
    @@ -5119,8 +5895,7 @@ const symbol = token.symbol || token.name || '-'; const balance = b.value || b.balance || '0'; const decimals = token.decimals != null ? token.decimals : 18; - const divisor = Math.pow(10, parseInt(decimals, 10)); - const displayBalance = (Number(balance) / divisor).toLocaleString(undefined, { maximumFractionDigits: 6 }); + const displayBalance = formatUnitsLocalized(balance, decimals, 6); const type = token.type || b.token_type || 'ERC-20'; return matchesExplorerFilter([symbol, contract, displayBalance, type].join(' '), filter); }) : items; @@ -5131,8 +5906,7 @@ const symbol = token.symbol || token.name || '-'; const balance = b.value || b.balance || '0'; const decimals = token.decimals != null ? token.decimals : 18; - const divisor = Math.pow(10, parseInt(decimals, 10)); - const displayBalance = (Number(balance) / divisor).toLocaleString(undefined, { maximumFractionDigits: 6 }); + const displayBalance = formatUnitsLocalized(balance, decimals, 6); const type = token.type || b.token_type || 'ERC-20'; tbl += '' + escapeHtml(symbol) + '' + explorerAddressLink(contract, escapeHtml(shortenHash(contract)), 'color: inherit; text-decoration: none;') + '' + escapeHtml(displayBalance) + '' + escapeHtml(type) + ''; }); @@ -5491,7 +6265,7 @@ decimals = parseInt(decimals, 10); if (isNaN(decimals) || decimals < 0 || decimals > 255) decimals = 18; var supply = data.total_supply != null ? data.total_supply : (data.total_supply_raw || '0'); - var supplyNum = Number(supply) / Math.pow(10, decimals); + var supplyDisplay = formatUnitsLocalized(supply, decimals, 6); var holders = data.holders_count != null ? data.holders_count : (data.holder_count || '-'); var transfersResp = null; try { @@ -5505,8 +6279,38 @@ html += '
    Name
    ' + escapeHtml(name) + '
    '; html += '
    Symbol
    ' + escapeHtml(symbol) + '
    '; html += '
    Decimals
    ' + decimals + '
    '; - html += '
    Total Supply
    ' + supplyNum.toLocaleString(undefined, { maximumFractionDigits: 6 }) + '
    '; + html += '
    Total Supply
    ' + escapeHtml(supplyDisplay) + '
    '; html += '
    Holders
    ' + (holders !== '-' ? formatNumber(holders) : '-') + '
    '; + if (CHAIN_ID === 138) { + try { + var pRes = await fetch(TOKEN_AGGREGATION_API_BASE + '/v1/tokens/' + encodeURIComponent(tokenAddress) + '/pools?chainId=138'); + if (pRes.ok) { + var pJ = await pRes.json(); + var plist = (pJ && pJ.pools) ? pJ.pools : []; + html += '

    Pools (token-aggregation)

    '; + html += '

    Indexed PMM/DEX pools for this token on Chain 138. Open Routes for path quotes or Liquidity for access tooling.

    '; + html += '
    '; + html += ''; + html += ''; + html += '
    '; + if (plist.length === 0) { + html += '

    No indexed pools for this token.

    '; + } else { + html += ''; + plist.forEach(function(pl) { + var pAddr = pl.address || ''; + var dex = pl.dex || ''; + var t0s = pl.token0 && pl.token0.symbol ? pl.token0.symbol : shortenHash(pl.token0 && pl.token0.address || ''); + var t1s = pl.token1 && pl.token1.symbol ? pl.token1.symbol : shortenHash(pl.token1 && pl.token1.address || ''); + var tvl = pl.tvl != null ? Number(pl.tvl).toLocaleString(undefined, { maximumFractionDigits: 2 }) : '—'; + html += ''; + }); + html += '
    PoolDEXPairTVL (USD)
    ' + explorerAddressLink(pAddr, escapeHtml(shortenHash(pAddr)), 'color: inherit; text-decoration: none;') + '' + escapeHtml(String(dex)) + '' + escapeHtml(t0s + ' / ' + t1s) + '' + escapeHtml(tvl) + '
    '; + } + html += '
    '; + } + } catch (poolsErr) {} + } const transfersFilter = getExplorerPageFilter('tokenTransfers'); const transfersFilterBar = renderPageFilterBar('tokenTransfers', 'Filter by from, to, value, or tx hash...', 'Filters the recent transfers below.', 'showTokenDetail(\'' + addrEsc + '\')'); html += '

    Recent Transfers

    '; @@ -5518,9 +6322,9 @@ var to = tr.to?.hash || tr.to || '-'; var val = tr.total?.value != null ? tr.total.value : (tr.value || '0'); var dec = tr.token?.decimals != null ? tr.token.decimals : decimals; - var v = Number(val) / Math.pow(10, parseInt(dec, 10)); + var displayValue = formatUnitsLocalized(val, dec, 6); var txHash = tr.transaction_hash || tr.tx_hash || ''; - return matchesExplorerFilter([from, to, v.toLocaleString(undefined, { maximumFractionDigits: 6 }), txHash].join(' '), transfersFilter); + return matchesExplorerFilter([from, to, displayValue, txHash].join(' '), transfersFilter); }) : transfers; html += transfersFilterBar + ''; filteredTransfers.forEach(function(tr) { @@ -5528,9 +6332,9 @@ var to = tr.to?.hash || tr.to || '-'; var val = tr.total?.value != null ? tr.total.value : (tr.value || '0'); var dec = tr.token?.decimals != null ? tr.token.decimals : decimals; - var v = Number(val) / Math.pow(10, parseInt(dec, 10)); + var displayValue = formatUnitsLocalized(val, dec, 6); var txHash = tr.transaction_hash || tr.tx_hash || ''; - html += ''; + html += ''; }); if (filteredTransfers.length === 0) { html += ''; @@ -5732,13 +6536,70 @@ return hashStr.substring(0, length + 2) + '...' + hashStr.substring(hashStr.length - length); } - function formatEther(wei, unit = 'ether') { - if (typeof wei === 'string' && wei.startsWith('0x')) { - wei = BigInt(wei); + function toBigIntValue(value) { + if (typeof value === 'bigint') return value; + if (typeof value === 'number') { + if (!isFinite(value)) return 0n; + return BigInt(Math.trunc(value)); } - const weiNum = typeof wei === 'bigint' ? Number(wei) : parseFloat(wei); - const ether = weiNum / Math.pow(10, unit === 'gwei' ? 9 : 18); - return ether.toFixed(6).replace(/\.?0+$/, ''); + var stringValue = String(value == null ? '0' : value).trim(); + if (!stringValue) return 0n; + try { + if (/^0x/i.test(stringValue) || /^-?\d+$/.test(stringValue)) { + return BigInt(stringValue); + } + } catch (e) {} + var parsed = Number(stringValue); + if (!isFinite(parsed)) return 0n; + return BigInt(Math.trunc(parsed)); + } + + function formatUnits(value, decimals, fractionDigits) { + try { + var bigValue = toBigIntValue(value); + var precision = Math.max(0, Math.min(decimals, fractionDigits == null ? 6 : fractionDigits)); + var divisor = 10n ** BigInt(decimals); + var whole = bigValue / divisor; + var fraction = bigValue % divisor; + if (fraction < 0) fraction = -fraction; + if (precision === 0) return whole.toString(); + var scale = 10n ** BigInt(decimals - precision); + var truncatedFraction = (fraction / scale).toString().padStart(precision, '0').replace(/0+$/, ''); + if (!truncatedFraction) return whole.toString(); + return whole.toString() + '.' + truncatedFraction; + } catch (e) { + return '0'; + } + } + + function formatDecimalStringWithGrouping(value) { + var stringValue = String(value == null ? '0' : value).trim(); + if (!stringValue) return '0'; + var negative = stringValue.charAt(0) === '-'; + if (negative) stringValue = stringValue.slice(1); + var parts = stringValue.split('.'); + var whole = (parts[0] || '0').replace(/\B(?=(\d{3})+(?!\d))/g, ','); + var fraction = parts.length > 1 && parts[1] ? '.' + parts[1] : ''; + return (negative ? '-' : '') + whole + fraction; + } + + function formatUnitsLocalized(value, decimals, fractionDigits) { + return formatDecimalStringWithGrouping(formatUnits(value, decimals, fractionDigits)); + } + + function parseDecimalToUnits(value, decimals) { + var stringValue = String(value == null ? '' : value).trim(); + if (!stringValue || !/^\d+(\.\d+)?$/.test(stringValue)) return null; + var parts = stringValue.split('.'); + var whole = BigInt(parts[0] || '0'); + var fraction = parts[1] || ''; + if (fraction.length > decimals) return null; + var paddedFraction = decimals > 0 ? fraction.padEnd(decimals, '0') : ''; + return whole * (10n ** BigInt(decimals)) + BigInt(paddedFraction || '0'); + } + + function formatEther(wei, unit = 'ether') { + return formatUnits(wei, unit === 'gwei' ? 9 : 18, 6); } function getExplorerAIPageContext() { @@ -5935,7 +6796,7 @@ '' + '
    ' + '
    ' + - '
    Public explorer and route data only. No private key handling, no transaction execution.
    ' + + '
    Read-only: indexed explorer + route APIs + docs. No private keys. Server-side operator or PMM/MCP tools stay disabled unless EXPLORER_AI_OPERATOR_TOOLS_ENABLED=1 on the backend.
    ' + '' + '
    ' + '
    Shift+Enter for a new line. Enter to send.
    ' + @@ -5986,11 +6847,15 @@ } function exportTransactionData(txHash) { - // Fetch transaction data and export as JSON - fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions/${txHash}`) - .then(response => { - const tx = normalizeTransaction(response); - const dataStr = JSON.stringify(tx, null, 2); + Promise.resolve(CHAIN_ID === 138 ? fetchChain138TransactionDetail(txHash) : fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions/${txHash}`).then(function(response) { + return { transaction: normalizeTransaction(response), rawTransaction: response }; + })) + .then(result => { + const payload = { + normalized: result && result.transaction ? result.transaction : null, + raw: result && result.rawTransaction ? result.rawTransaction : null + }; + const dataStr = safeJsonStringify(payload); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); @@ -6128,4 +6993,8 @@ }); initNavDropdowns(); } + ensureMissionControlHealthStrip(); + startMissionControlEventSource(); + refreshMissionControlHealthStrip(); + setInterval(refreshMissionControlHealthStrip, 120000); }); diff --git a/frontend/public/index.html b/frontend/public/index.html index 372417a..7c80a3f 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -919,6 +919,149 @@ .gas-network-subtle { color: var(--text-light); font-size: 0.82rem; white-space: nowrap; } .btn-copy { background: none; border: none; cursor: pointer; padding: 0.25rem; margin-left: 0.35rem; color: var(--text-light); vertical-align: middle; } .btn-copy:hover { color: var(--primary); } + .tx-chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + .tx-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.3rem 0.6rem; + border-radius: 999px; + background: var(--muted-surface); + border: 1px solid var(--border); + color: var(--text); + font-size: 0.8rem; + } + .tx-chip-label { + color: var(--text-light); + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 0.72rem; + } + .tx-inspector-stack { + display: grid; + gap: 0.75rem; + } + .tx-inspector-entry { + border: 1px solid var(--border); + border-radius: 12px; + background: var(--muted-surface); + overflow: hidden; + } + .tx-inspector-entry summary { + list-style: none; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 0.85rem 1rem; + font-weight: 600; + } + .tx-inspector-entry summary::-webkit-details-marker { + display: none; + } + .tx-inspector-entry[open] summary { + border-bottom: 1px solid var(--border); + } + .tx-inspector-summary-value { + color: var(--text-light); + font-size: 0.82rem; + text-align: right; + font-weight: 500; + } + .tx-inspector-entry-body { + display: grid; + gap: 0.75rem; + padding: 0.9rem 1rem 1rem; + } + .tx-inspector-note { + color: var(--text-light); + font-size: 0.84rem; + line-height: 1.5; + } + .tx-inspector-line { + display: grid; + grid-template-columns: minmax(110px, 140px) minmax(0, 1fr); + gap: 0.75rem; + align-items: start; + } + .tx-inspector-label { + color: var(--text-light); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.72rem; + padding-top: 0.15rem; + } + .tx-inspector-content { + min-width: 0; + } + .tx-inspector-scroll { + overflow-x: auto; + } + .tx-inspector-mono { + font-family: 'Courier New', monospace; + font-size: 0.82rem; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-all; + } + .tx-inspector-pre { + margin: 0; + padding: 0.85rem; + border-radius: 10px; + background: rgba(15, 23, 42, 0.06); + border: 1px solid var(--border); + font-family: 'Courier New', monospace; + font-size: 0.8rem; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + } + .tx-inspector-topic-list { + display: grid; + gap: 0.55rem; + } + .tx-inspector-topic-row { + display: grid; + gap: 0.35rem; + padding: 0.7rem; + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.45); + } + .tx-inspector-topic-index { + color: var(--text-light); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.7rem; + } + .tx-empty { + color: var(--text-light); + font-size: 0.9rem; + } + body.dark-theme .tx-inspector-pre { + background: rgba(15, 23, 42, 0.72); + } + body.dark-theme .tx-inspector-topic-row { + background: rgba(15, 23, 42, 0.44); + } + @media (max-width: 768px) { + .tx-inspector-line { + grid-template-columns: 1fr; + gap: 0.35rem; + } + .tx-inspector-entry summary { + flex-direction: column; + align-items: stretch; + } + .tx-inspector-summary-value { + text-align: left; + } + } .site-footer { margin-top: 2.5rem; padding: 2rem 0 2.5rem; @@ -1041,6 +1184,7 @@
  • Routes
  • Tokens
  • Pools
  • +
  • System
  • Watchlist
  • @@ -1556,6 +1700,23 @@
    + +
    + +
    +
    + +

    System topology

    +
    + Command center + +
    +
    +
    +
    Loading graph…
    +
    +
    +
    @@ -1594,6 +1755,6 @@
    - + diff --git a/frontend/public/thirdparty/README.md b/frontend/public/thirdparty/README.md new file mode 100644 index 0000000..014bacc --- /dev/null +++ b/frontend/public/thirdparty/README.md @@ -0,0 +1,17 @@ +# Third-party bundles (optional) + +## Mermaid (Visual Command Center) + +`chain138-command-center.html` loads Mermaid from jsDelivr by default. If your explorer host blocks external script origins (CSP) or you need a fully offline doc path: + +1. From repo root: + ```bash + bash explorer-monorepo/scripts/vendor-mermaid-for-command-center.sh + ``` +2. Edit `chain138-command-center.html` and change the Mermaid ` + ``` +3. Deploy with `deploy-frontend-to-vmid5000.sh` — it copies `thirdparty/mermaid.min.js` when the file exists. + +The minified file is gitignored (~3.3 MB); do not commit it. diff --git a/frontend/public/token-icons/cUSDC.png b/frontend/public/token-icons/cUSDC.png new file mode 100644 index 0000000..299f611 Binary files /dev/null and b/frontend/public/token-icons/cUSDC.png differ diff --git a/frontend/public/token-icons/cUSDT.png b/frontend/public/token-icons/cUSDT.png new file mode 100644 index 0000000..1c14d2a Binary files /dev/null and b/frontend/public/token-icons/cUSDT.png differ diff --git a/frontend/public/token-icons/cXAUC.png b/frontend/public/token-icons/cXAUC.png new file mode 100644 index 0000000..59d0980 Binary files /dev/null and b/frontend/public/token-icons/cXAUC.png differ diff --git a/frontend/public/token-icons/cXAUT.png b/frontend/public/token-icons/cXAUT.png new file mode 100644 index 0000000..59d0980 Binary files /dev/null and b/frontend/public/token-icons/cXAUT.png differ diff --git a/frontend/scripts/smoke-routes.mjs b/frontend/scripts/smoke-routes.mjs index 7370017..7550bd1 100644 --- a/frontend/scripts/smoke-routes.mjs +++ b/frontend/scripts/smoke-routes.mjs @@ -1,96 +1,116 @@ -import { chromium } from 'playwright'; +import { chromium } from 'playwright' -const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/\/$/, ''); +const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/\/$/, '') +const addressUnderTest = process.env.SMOKE_ADDRESS || '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506' const checks = [ - { path: '/', homeVisible: true, expectTexts: ['Gas & Network', 'Latest Blocks', 'Latest Transactions'] }, - { path: '/blocks', activeView: 'blocksView', expectTexts: ['All Blocks'] }, - { path: '/transactions', activeView: 'transactionsView', expectTexts: ['All Transactions'] }, - { path: '/addresses', activeView: 'addressesView', expectTexts: ['All Addresses'] }, - { path: '/tokens', activeView: 'tokensView', expectTexts: ['Tokens'] }, - { path: '/pools', activeView: 'poolsView', expectTexts: ['Pools', 'Canonical PMM routes'] }, - { path: '/routes', activeView: 'routesView', expectTexts: ['Routes', 'Live Route Decision Tree'] }, - { path: '/watchlist', activeView: 'watchlistView', expectTexts: ['Watchlist'] }, - { path: '/bridge', activeView: 'bridgeView', expectTexts: ['Bridge Monitoring'] }, - { path: '/weth', activeView: 'wethView', expectTexts: ['WETH', 'Wrap ETH to WETH9'] }, - { path: '/liquidity', activeView: 'liquidityView', expectTexts: ['Liquidity Access', 'Public Explorer Access Points'] }, - { path: '/more', activeView: 'moreView', expectTexts: ['More', 'Tools & Services'] }, - { path: '/analytics', activeView: 'analyticsView', expectTexts: ['Analytics Dashboard', 'Live Network Analytics'] }, - { path: '/operator', activeView: 'operatorView', expectTexts: ['Operator Panel', 'Operator Access Hub'] }, - { path: '/block/1', activeView: 'blockDetailView', expectTexts: ['Block Details'] }, - { - path: '/tx/0x0000000000000000000000000000000000000000000000000000000000000000', - activeView: 'transactionDetailView', - expectTexts: ['Transaction Details', 'Transaction not found'], - }, - { - path: '/address/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22', - activeView: 'addressDetailView', - expectTexts: ['Address Details', 'Address'], - }, -]; + { path: '/', expectTexts: ['SolaceScanScout', 'Recent Blocks', 'Open wallet tools'] }, + { path: '/blocks', expectTexts: ['Blocks'] }, + { path: '/transactions', expectTexts: ['Transactions'] }, + { path: '/addresses', expectTexts: ['Addresses', 'Open An Address'] }, + { path: '/watchlist', expectTexts: ['Watchlist', 'Saved Addresses'] }, + { path: '/pools', expectTexts: ['Pools', 'Pool operation shortcuts'] }, + { path: '/liquidity', expectTexts: ['Chain 138 Liquidity Access', 'Explorer Access Points'] }, + { path: '/wallet', expectTexts: ['Wallet & MetaMask', 'Install Open Snap'] }, + { path: '/tokens', expectTexts: ['Tokens', 'Find A Token'] }, + { path: '/search', expectTexts: ['Search'], placeholder: 'Search by address, transaction hash, block number...' }, + { path: `/addresses/${addressUnderTest}`, expectTexts: [], anyOfTexts: ['Back to addresses', 'Address not found'] }, +] -function hasExpectedText(text, snippets) { - return snippets.some((snippet) => text.includes(snippet)); +async function bodyText(page) { + return (await page.textContent('body')) || '' +} + +async function hasShell(page) { + const homeLink = await page.getByRole('link', { name: /Go to explorer home/i }).isVisible().catch(() => false) + const supportText = await page.getByText(/Support:/i).isVisible().catch(() => false) + return homeLink && supportText +} + +async function waitForBodyText(page, snippets, timeoutMs = 15000) { + if (!snippets || snippets.length === 0) { + return bodyText(page) + } + + const deadline = Date.now() + timeoutMs + let lastText = '' + + while (Date.now() < deadline) { + lastText = await bodyText(page) + if (snippets.every((snippet) => lastText.includes(snippet))) { + return lastText + } + await page.waitForTimeout(250) + } + + return lastText } async function main() { - const browser = await chromium.launch({ headless: true }); - const page = await browser.newPage(); - let failures = 0; + const browser = await chromium.launch({ headless: true }) + const page = await browser.newPage() + let failures = 0 for (const check of checks) { - const url = `${baseUrl}${check.path}`; + const url = `${baseUrl}${check.path}` try { - const response = await page.goto(url, { waitUntil: 'networkidle' }); + const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + if (!response || !response.ok()) { - console.error(`FAIL ${check.path}: HTTP ${response ? response.status() : 'no-response'}`); - failures += 1; - continue; + console.error(`FAIL ${check.path}: HTTP ${response ? response.status() : 'no-response'}`) + failures += 1 + continue } - const bodyText = await page.textContent('body'); - if (check.homeVisible) { - const homeVisible = await page.$eval('#homeView', (el) => getComputedStyle(el).display !== 'none').catch(() => false); - if (!homeVisible) { - console.error(`FAIL ${check.path}: home view not visible`); - failures += 1; - continue; - } - } else { - const activeView = await page.$eval('.detail-view.active', (el) => el.id).catch(() => null); - if (activeView !== check.activeView) { - console.error(`FAIL ${check.path}: expected active view ${check.activeView}, got ${activeView}`); - failures += 1; - continue; + const text = await waitForBodyText(page, check.expectTexts) + const missing = check.expectTexts.filter((snippet) => !text.includes(snippet)) + if (missing.length > 0) { + console.error(`FAIL ${check.path}: missing text ${missing.join(' | ')}`) + failures += 1 + continue + } + + if (check.anyOfTexts && !check.anyOfTexts.some((snippet) => text.includes(snippet))) { + console.error(`FAIL ${check.path}: expected one of ${check.anyOfTexts.join(' | ')}`) + failures += 1 + continue + } + + if (check.placeholder) { + const placeholderVisible = await page.getByPlaceholder(check.placeholder).isVisible().catch(() => false) + if (!placeholderVisible) { + console.error(`FAIL ${check.path}: missing placeholder ${check.placeholder}`) + failures += 1 + continue } } - if (!hasExpectedText(bodyText || '', check.expectTexts)) { - console.error(`FAIL ${check.path}: expected one of ${check.expectTexts.join(' | ')}`); - failures += 1; - continue; + if (!(await hasShell(page))) { + console.error(`FAIL ${check.path}: shared explorer shell not visible`) + failures += 1 + continue } - console.log(`OK ${check.path}`); + console.log(`OK ${check.path}`) } catch (error) { - console.error(`FAIL ${check.path}: ${error instanceof Error ? error.message : String(error)}`); - failures += 1; + console.error(`FAIL ${check.path}: ${error instanceof Error ? error.message : String(error)}`) + failures += 1 } } - await browser.close(); + await browser.close() if (failures > 0) { - process.exitCode = 1; - return; + process.exitCode = 1 + return } - console.log(`All ${checks.length} route checks passed for ${baseUrl}`); + console.log(`All ${checks.length} route checks passed for ${baseUrl}`) } main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exitCode = 1; -}); + console.error(error instanceof Error ? error.stack || error.message : String(error)) + process.exitCode = 1 +}) diff --git a/frontend/scripts/start-standalone.mjs b/frontend/scripts/start-standalone.mjs new file mode 100644 index 0000000..f8dee39 --- /dev/null +++ b/frontend/scripts/start-standalone.mjs @@ -0,0 +1,47 @@ +import { spawn } from 'node:child_process' +import { cp, mkdir } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +const projectRoot = process.cwd() +const standaloneRoot = path.join(projectRoot, '.next', 'standalone') +const standaloneNextRoot = path.join(standaloneRoot, '.next') +const standaloneServer = path.join(standaloneRoot, 'server.js') + +async function copyIfPresent(sourcePath, destinationPath) { + if (!existsSync(sourcePath)) { + return + } + + await mkdir(path.dirname(destinationPath), { recursive: true }) + await cp(sourcePath, destinationPath, { recursive: true, force: true }) +} + +async function main() { + if (!existsSync(standaloneServer)) { + console.error('Standalone server build is missing. Run `npm run build` first.') + process.exit(1) + } + + await copyIfPresent(path.join(projectRoot, '.next', 'static'), path.join(standaloneNextRoot, 'static')) + await copyIfPresent(path.join(projectRoot, 'public'), path.join(standaloneRoot, 'public')) + + const child = spawn(process.execPath, [standaloneServer], { + stdio: 'inherit', + env: process.env, + }) + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + process.exit(code ?? 0) + }) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 07e2db2..c1f48be 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,9 +1,13 @@ +import './globals.css' import type { ReactNode } from 'react' +import ExplorerChrome from '@/components/common/ExplorerChrome' export default function RootLayout({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ) } diff --git a/frontend/src/app/liquidity/page.tsx b/frontend/src/app/liquidity/page.tsx index 276ed21..a4ca0cc 100644 --- a/frontend/src/app/liquidity/page.tsx +++ b/frontend/src/app/liquidity/page.tsx @@ -1,261 +1,5 @@ -import Link from 'next/link' -import { Card } from '@/libs/frontend-ui-primitives/Card' - -const publicApiBase = '/token-aggregation/api/v1' - -const livePools = [ - { - pair: 'cUSDT / cUSDC', - poolAddress: '0xff8d3b8fDF7B112759F076B69f4271D4209C0849', - reserves: '10,000,000 / 10,000,000', - }, - { - pair: 'cUSDT / USDT', - poolAddress: '0x6fc60DEDc92a2047062294488539992710b99D71', - reserves: '10,000,000 / 10,000,000', - }, - { - pair: 'cUSDC / USDC', - poolAddress: '0x0309178ae30302D83c76d6Dd402a684eF3160eec', - reserves: '10,000,000 / 10,000,000', - }, - { - pair: 'cUSDT / cXAUC', - poolAddress: '0x1AA55E2001E5651349AfF5A63FD7A7Ae44f0F1b0', - reserves: '2,666,965 / 519.477000', - }, - { - pair: 'cUSDC / cXAUC', - poolAddress: '0xEA9Ac6357CaCB42a83b9082B870610363B177cBa', - reserves: '1,000,000 / 194.782554', - }, - { - pair: 'cEURT / cXAUC', - poolAddress: '0xbA99bc1eAAC164569d5AcA96C806934DDaF970Cf', - reserves: '1,000,000 / 225.577676', - }, -] - -const publicEndpoints = [ - { - name: 'Canonical route matrix', - method: 'GET', - href: `${publicApiBase}/routes/matrix`, - notes: 'All live and optional non-live route inventory with counts and filters.', - }, - { - name: 'Live ingestion export', - method: 'GET', - href: `${publicApiBase}/routes/ingestion?family=LiFi`, - notes: 'Flat live-route export for adapter ingestion and route discovery.', - }, - { - name: 'Partner payload templates', - method: 'GET', - href: `${publicApiBase}/routes/partner-payloads?partner=0x&amount=1000000&includeUnsupported=true`, - notes: 'Builds exact 1inch, 0x, and LiFi request templates from live routes.', - }, - { - name: 'Resolve supported partner payloads', - method: 'POST', - href: `${publicApiBase}/routes/partner-payloads/resolve`, - notes: 'Accepts partner, amount, and addresses and returns supported payloads by default.', - }, - { - name: 'Dispatch supported partner payload', - method: 'POST', - href: `${publicApiBase}/routes/partner-payloads/dispatch`, - notes: 'Resolves then dispatches a single supported partner payload when the chain is supported.', - }, - { - name: 'Internal Chain 138 execution plan', - method: 'POST', - href: `${publicApiBase}/routes/internal-execution-plan`, - notes: 'Returns the internal DODO PMM fallback plan when external partner support is unavailable.', - }, -] - -const routeHighlights = [ - 'Direct live routes: cUSDT <-> cUSDC, cUSDT <-> USDT, cUSDC <-> USDC, cUSDT <-> cXAUC, cUSDC <-> cXAUC, cEURT <-> cXAUC.', - 'Multi-hop public routes exist through cXAUC for cEURT <-> cUSDT, cEURT <-> cUSDC, and an alternate cUSDT <-> cUSDC path.', - 'Mainnet bridge discovery is live for cUSDT -> USDT and cUSDC -> USDC through the configured UniversalCCIPBridge lane.', - 'External partner templates are available for 1inch, 0x, and LiFi, but Chain 138 remains unsupported on those public partner networks today.', - 'When partner support is unavailable, the explorer can surface the internal DODO PMM execution plan instead of a dead end.', -] - -const requestExamples = [ - { - title: 'Inspect the full route matrix', - code: `GET ${publicApiBase}/routes/matrix?includeNonLive=true`, - }, - { - title: 'Filter live same-chain swap routes on Chain 138', - code: `GET ${publicApiBase}/routes/ingestion?fromChainId=138&routeType=swap`, - }, - { - title: 'Generate partner templates for review', - code: `GET ${publicApiBase}/routes/partner-payloads?partner=LiFi&amount=1000000&includeUnsupported=true`, - }, - { - title: 'Resolve a dispatch candidate', - code: `POST ${publicApiBase}/routes/partner-payloads/resolve`, - }, - { - title: 'Build the internal fallback plan', - code: `POST ${publicApiBase}/routes/internal-execution-plan`, - }, -] +import LiquidityOperationsPage from '@/components/explorer/LiquidityOperationsPage' export default function LiquidityPage() { - return ( -
    -
    -
    - Chain 138 Liquidity Access -
    -

    - Public liquidity, route discovery, and execution access points -

    -

    - This explorer page pulls together the live public DODO PMM liquidity on Chain 138 and the - token-aggregation endpoints that DEX aggregators, integrators, and operators can use for - route discovery, payload generation, and internal fallback execution planning. -

    -
    - -
    - -
    Live public pools
    -
    6
    -
    - Verified public DODO PMM pools on Chain 138. -
    -
    - -
    Public access path
    -
    /token-aggregation/api/v1
    -
    - Explorer-hosted proxy path for route, quote, and reporting APIs. -
    -
    - -
    Partner status
    -
    Fallback Ready
    -
    - Mainnet stable bridge routing is live; 1inch, 0x, and LiFi templates remain available for partner integrations, with internal fallback for unsupported Chain 138 execution. -
    -
    -
    - -
    - -
    - {livePools.map((pool) => ( -
    -
    -
    -
    {pool.pair}
    -
    - Pool: {pool.poolAddress} -
    -
    -
    - Reserves: {pool.reserves} -
    -
    -
    - ))} -
    -
    - - -
    - {routeHighlights.map((item) => ( -

    {item}

    - ))} -
    -
    -
    - - - -
    - -
    - {requestExamples.map((example) => ( -
    -
    {example.title}
    - - {example.code} - -
    - ))} -
    -
    - - -
    -

    - Use the wallet page for network onboarding and the explorer token list URL, then use this - page for route and execution discovery. -

    -

    - The route APIs complement the existing route decision tree and market-data APIs already - proxied through the explorer. -

    -
    - - Open wallet tools - - - Route tree API - - - Explorer docs - -
    -
    -
    -
    -
    - ) + return } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 5c4dcf0..b54fd94 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -3,97 +3,180 @@ import { useCallback, useEffect, useState } from 'react' import { Card } from '@/libs/frontend-ui-primitives/Card' import Link from 'next/link' -import { blocksApi } from '@/services/api/blocks' +import { blocksApi, type Block } from '@/services/api/blocks' +import { statsApi, type ExplorerStats } from '@/services/api/stats' +import { + missionControlApi, + type MissionControlRelaySummary, +} from '@/services/api/missionControl' +import { loadDashboardData } from '@/utils/dashboard' -interface NetworkStats { - current_block: number - tps: number - gps: number - avg_gas_price: number - pending_transactions: number -} +type HomeStats = ExplorerStats export default function Home() { - const [stats, setStats] = useState(null) - const [recentBlocks, setRecentBlocks] = useState([]) + const [stats, setStats] = useState(null) + const [recentBlocks, setRecentBlocks] = useState([]) + const [relaySummary, setRelaySummary] = useState(null) + const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>('connecting') const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') + const latestBlock = stats?.latest_block ?? recentBlocks[0]?.number ?? null - const loadStats = useCallback(async () => { - try { - // This would call analytics API - // For now, placeholder - setStats({ - current_block: 0, - tps: 0, - gps: 0, - avg_gas_price: 0, - pending_transactions: 0, - }) - } catch (error) { - console.error('Failed to load stats:', error) - } - }, []) + const loadDashboard = useCallback(async () => { + const dashboardData = await loadDashboardData({ + loadStats: () => statsApi.get(), + loadRecentBlocks: async () => { + const response = await blocksApi.list({ + chain_id: chainId, + page: 1, + page_size: 10, + }) + return response.data + }, + onError: (scope, error) => { + if (process.env.NODE_ENV !== 'production') { + console.warn(`Failed to load dashboard ${scope}:`, error) + } + }, + }) - const loadRecentBlocks = useCallback(async () => { - try { - const response = await blocksApi.list({ - chain_id: chainId, - page: 1, - page_size: 10, - }) - setRecentBlocks(response.data) - } catch (error) { - console.error('Failed to load recent blocks:', error) - } + setStats(dashboardData.stats) + setRecentBlocks(dashboardData.recentBlocks) }, [chainId]) useEffect(() => { - loadStats() - loadRecentBlocks() - }, [loadStats, loadRecentBlocks]) + loadDashboard() + }, [loadDashboard]) + + useEffect(() => { + let cancelled = false + + const loadSnapshot = async () => { + try { + const summary = await missionControlApi.getRelaySummary() + if (!cancelled) { + setRelaySummary(summary) + } + } catch (error) { + if (!cancelled && process.env.NODE_ENV !== 'production') { + console.warn('Failed to load mission control relay summary:', error) + } + } + } + + loadSnapshot() + + const unsubscribe = missionControlApi.subscribeRelaySummary( + (summary) => { + if (!cancelled) { + setRelaySummary(summary) + setRelayFeedState('live') + } + }, + (error) => { + if (!cancelled) { + setRelayFeedState('fallback') + } + if (process.env.NODE_ENV !== 'production') { + console.warn('Mission control live stream update issue:', error) + } + } + ) + + return () => { + cancelled = true + unsubscribe() + } + }, []) + + const relayToneClasses = + relaySummary?.tone === 'danger' + ? 'border-red-200 bg-red-50 text-red-900 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100' + : relaySummary?.tone === 'warning' + ? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100' + : 'border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-100' return ( -
    -
    -

    SolaceScanScout

    -

    The Defi Oracle Meta Explorer

    +
    +
    +

    SolaceScanScout

    +

    The Defi Oracle Meta Explorer

    + {relaySummary && ( + +
    +
    +
    Mission Control
    +
    {relaySummary.text}
    +
    + Feed: {relayFeedState === 'live' ? 'Live SSE' : relayFeedState === 'fallback' ? 'Snapshot fallback' : 'Connecting'} +
    + {relaySummary.items.length > 1 && ( +
    + {relaySummary.items.map((item) => ( +
    {item.text}
    + ))} +
    + )} +
    + + Open live stream + +
    +
    + )} + {stats && ( -
    +
    -
    Current Block
    -
    {stats.current_block.toLocaleString()}
    +
    Latest Block
    +
    + {latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'} +
    -
    TPS
    -
    {stats.tps.toFixed(2)}
    +
    Total Blocks
    +
    {stats.total_blocks.toLocaleString()}
    -
    Gas Price
    -
    {stats.avg_gas_price.toLocaleString()} Gwei
    +
    Total Transactions
    +
    {stats.total_transactions.toLocaleString()}
    -
    Pending Tx
    -
    {stats.pending_transactions}
    +
    Total Addresses
    +
    {stats.total_addresses.toLocaleString()}
    )} + {!stats && ( + +

    + Live network stats are temporarily unavailable. Recent blocks and explorer tools are still available below. +

    +
    + )} + -
    - {recentBlocks.map((block) => ( -
    - - Block #{block.number} - -
    - {block.transaction_count} transactions + {recentBlocks.length === 0 ? ( +

    + Recent blocks are unavailable right now. +

    + ) : ( +
    + {recentBlocks.map((block) => ( +
    + + Block #{block.number} + +
    + {block.transaction_count} transactions +
    -
    - ))} -
    -
    + ))} +
    + )} +
    View all blocks → @@ -107,8 +190,8 @@ export default function Home() { partner payload endpoints exposed through the explorer.

    - - Open liquidity access → + + Open routes and liquidity →
    @@ -123,6 +206,28 @@ export default function Home() {
    + +

    + Open the public bridge monitoring surface for relay status, mission-control links, bridge trace tooling, + and the visual command center entry points. +

    +
    + + Open bridge monitoring → + +
    +
    + +

    + Surface the restored WETH utilities, analytics shortcuts, operator links, system topology views, and + other public tools that were previously hidden in the legacy explorer shell. +

    +
    + + Open operations hub → + +
    +
    ) diff --git a/frontend/src/app/wallet/page.tsx b/frontend/src/app/wallet/page.tsx index a2af1c1..4ba8b12 100644 --- a/frontend/src/app/wallet/page.tsx +++ b/frontend/src/app/wallet/page.tsx @@ -3,9 +3,9 @@ import Link from 'next/link' export default function WalletPage() { return ( -
    -

    Wallet & MetaMask

    -

    +

    +

    Wallet & MetaMask

    +

    Connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets. Use the token list URL so tokens and oracles are discoverable.

    diff --git a/frontend/src/components/blockchain/Address.tsx b/frontend/src/components/blockchain/Address.tsx index 16702ab..d8f5c34 100644 --- a/frontend/src/components/blockchain/Address.tsx +++ b/frontend/src/components/blockchain/Address.tsx @@ -1,48 +1 @@ -import { useState } from 'react' -import clsx from 'clsx' - -interface AddressProps { - address: string - chainId?: number - showCopy?: boolean - showENS?: boolean - truncate?: boolean - className?: string -} - -export function Address({ - address, - chainId, - showCopy = true, - showENS = false, - truncate = false, - className, -}: AddressProps) { - const [copied, setCopied] = useState(false) - - const displayAddress = truncate - ? `${address.slice(0, 6)}...${address.slice(-4)}` - : address - - const handleCopy = async () => { - await navigator.clipboard.writeText(address) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - return ( -
    - {displayAddress} - {showCopy && ( - - )} -
    - ) -} - +export { Address } from '@/libs/frontend-ui-primitives/Address' diff --git a/frontend/src/components/common/Button.tsx b/frontend/src/components/common/Button.tsx index 7561b8a..61ba09a 100644 --- a/frontend/src/components/common/Button.tsx +++ b/frontend/src/components/common/Button.tsx @@ -1,37 +1 @@ -import { ButtonHTMLAttributes, ReactNode } from 'react' -import clsx from 'clsx' - -interface ButtonProps extends ButtonHTMLAttributes { - variant?: 'primary' | 'secondary' | 'danger' - size?: 'sm' | 'md' | 'lg' - children: ReactNode -} - -export function Button({ - variant = 'primary', - size = 'md', - className, - children, - ...props -}: ButtonProps) { - return ( - - ) -} - +export { Button } from '@/libs/frontend-ui-primitives/Button' diff --git a/frontend/src/components/common/Card.tsx b/frontend/src/components/common/Card.tsx index f8be160..815d159 100644 --- a/frontend/src/components/common/Card.tsx +++ b/frontend/src/components/common/Card.tsx @@ -1,27 +1 @@ -import { ReactNode } from 'react' -import clsx from 'clsx' - -interface CardProps { - children: ReactNode - className?: string - title?: string -} - -export function Card({ children, className, title }: CardProps) { - return ( -
    - {title && ( -

    - {title} -

    - )} - {children} -
    - ) -} - +export { Card } from '@/libs/frontend-ui-primitives/Card' diff --git a/frontend/src/components/common/DetailRow.tsx b/frontend/src/components/common/DetailRow.tsx new file mode 100644 index 0000000..ebeda78 --- /dev/null +++ b/frontend/src/components/common/DetailRow.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react' +import clsx from 'clsx' + +interface DetailRowProps { + label: string + children: ReactNode + className?: string + labelClassName?: string + valueClassName?: string +} + +export function DetailRow({ + label, + children, + className, + labelClassName, + valueClassName, +}: DetailRowProps) { + return ( +
    +
    + {label} +
    +
    + {children} +
    +
    + ) +} diff --git a/frontend/src/components/common/ExplorerChrome.tsx b/frontend/src/components/common/ExplorerChrome.tsx new file mode 100644 index 0000000..ac13a82 --- /dev/null +++ b/frontend/src/components/common/ExplorerChrome.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' +import Navbar from './Navbar' +import Footer from './Footer' + +export default function ExplorerChrome({ children }: { children: ReactNode }) { + return ( +
    + +
    {children}
    +
    +
    + ) +} diff --git a/frontend/src/components/common/Footer.tsx b/frontend/src/components/common/Footer.tsx index 751a8a1..6b0813a 100644 --- a/frontend/src/components/common/Footer.tsx +++ b/frontend/src/components/common/Footer.tsx @@ -8,10 +8,10 @@ export default function Footer() { return (
    -
    -
    -
    -
    +
    +
    +
    +
    SolaceScanScout

    @@ -24,13 +24,16 @@ export default function Footer() {

    -
    +
    Resources
    • Documentation
    • +
    • Bridge Monitoring
    • Liquidity Access
    • +
    • Routes
    • +
    • More Tools
    • Addresses
    • Watchlist
    • Privacy Policy
    • @@ -39,7 +42,7 @@ export default function Footer() {
    -
    +
    Contact
    @@ -56,6 +59,12 @@ export default function Footer() { explorer.d-bis.org/snap/

    +

    + Command center:{' '} + + Chain 138 visual map + +

    Questions about the explorer, chain metadata, route discovery, or liquidity access can be sent to the support mailbox above. diff --git a/frontend/src/components/common/Navbar.tsx b/frontend/src/components/common/Navbar.tsx index d564895..2c6536f 100644 --- a/frontend/src/components/common/Navbar.tsx +++ b/frontend/src/components/common/Navbar.tsx @@ -86,26 +86,42 @@ export default function Navbar() { const [exploreOpen, setExploreOpen] = useState(false) const [toolsOpen, setToolsOpen] = useState(false) + const toggleMobileMenu = () => { + setMobileMenuOpen((open) => { + const nextOpen = !open + if (!nextOpen) { + setExploreOpen(false) + setToolsOpen(false) + } + return nextOpen + }) + } + return (

    FromToValueTx
    ' + explorerAddressLink(from, escapeHtml(shortenHash(from)), 'color: inherit; text-decoration: none;') + '' + explorerAddressLink(to, escapeHtml(shortenHash(to)), 'color: inherit; text-decoration: none;') + '' + escapeHtml(v.toLocaleString(undefined, { maximumFractionDigits: 6 })) + '' + (txHash ? explorerTransactionLink(txHash, escapeHtml(shortenHash(txHash)), 'color: inherit; text-decoration: none;') : '-') + '
    ' + explorerAddressLink(from, escapeHtml(shortenHash(from)), 'color: inherit; text-decoration: none;') + '' + explorerAddressLink(to, escapeHtml(shortenHash(to)), 'color: inherit; text-decoration: none;') + '' + escapeHtml(displayValue) + '' + (txHash ? explorerTransactionLink(txHash, escapeHtml(shortenHash(txHash)), 'color: inherit; text-decoration: none;') : '-') + '
    No transfers match the current filter.
    - - - {columns.map((column, index) => ( - - ))} - - - - {data.map((row, rowIndex) => ( - - {columns.map((column, colIndex) => ( - - ))} - - ))} - -
    - {column.header} -
    - {column.accessor(row)} -
    -
    - ) -} - +export { Table } from '@/libs/frontend-ui-primitives/Table' diff --git a/frontend/src/components/explorer/AnalyticsOperationsPage.tsx b/frontend/src/components/explorer/AnalyticsOperationsPage.tsx new file mode 100644 index 0000000..571b6cf --- /dev/null +++ b/frontend/src/components/explorer/AnalyticsOperationsPage.tsx @@ -0,0 +1,177 @@ +import { useEffect, useMemo, useState } from 'react' +import { Card } from '@/libs/frontend-ui-primitives' +import { explorerFeaturePages } from '@/data/explorerOperations' +import { blocksApi, type Block } from '@/services/api/blocks' +import { + missionControlApi, + type MissionControlBridgeStatusResponse, + type MissionControlChainStatus, +} from '@/services/api/missionControl' +import { statsApi, type ExplorerStats } from '@/services/api/stats' +import { transactionsApi, type Transaction } from '@/services/api/transactions' +import OperationsPageShell, { + MetricCard, + StatusBadge, + formatNumber, + relativeAge, + truncateMiddle, +} from './OperationsPageShell' + +function getChainStatus(bridgeStatus: MissionControlBridgeStatusResponse | null): MissionControlChainStatus | null { + const chains = bridgeStatus?.data?.chains + if (!chains) return null + const [firstChain] = Object.values(chains) + return firstChain || null +} + +export default function AnalyticsOperationsPage() { + const [stats, setStats] = useState(null) + const [blocks, setBlocks] = useState([]) + const [transactions, setTransactions] = useState([]) + const [bridgeStatus, setBridgeStatus] = useState(null) + const [loadingError, setLoadingError] = useState(null) + const page = explorerFeaturePages.analytics + + useEffect(() => { + let cancelled = false + + const load = async () => { + const [statsResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([ + statsApi.get(), + blocksApi.list({ chain_id: 138, page: 1, page_size: 5 }), + transactionsApi.list(138, 1, 5), + missionControlApi.getBridgeStatus(), + ]) + + if (cancelled) return + + if (statsResult.status === 'fulfilled') setStats(statsResult.value) + if (blocksResult.status === 'fulfilled') setBlocks(blocksResult.value.data) + if (transactionsResult.status === 'fulfilled') setTransactions(transactionsResult.value.data) + if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value) + + const failedCount = [statsResult, blocksResult, transactionsResult, bridgeResult].filter( + (result) => result.status === 'rejected' + ).length + + if (failedCount === 4) { + setLoadingError('Analytics data is temporarily unavailable from the public explorer APIs.') + } + } + + load().catch((error) => { + if (!cancelled) { + setLoadingError(error instanceof Error ? error.message : 'Analytics data is temporarily unavailable from the public explorer APIs.') + } + }) + + return () => { + cancelled = true + } + }, []) + + const chainStatus = useMemo(() => getChainStatus(bridgeStatus), [bridgeStatus]) + + return ( + + {loadingError ? ( + +

    {loadingError}

    +
    + ) : null} + +
    + + + + +
    + +
    + +
    + {blocks.map((block) => ( +
    +
    +
    +
    + Block {formatNumber(block.number)} +
    +
    + {truncateMiddle(block.hash)} · miner {truncateMiddle(block.miner)} +
    +
    +
    + {formatNumber(block.transaction_count)} tx · {relativeAge(block.timestamp)} +
    +
    +
    + ))} + {blocks.length === 0 ? ( +

    No recent block data available.

    + ) : null} +
    +
    + + +
    + {transactions.map((transaction) => ( +
    +
    +
    +
    + {truncateMiddle(transaction.hash, 12, 10)} +
    +
    + Block {formatNumber(transaction.block_number)} · from {truncateMiddle(transaction.from_address)} +
    +
    +
    + +
    + {relativeAge(transaction.created_at)} +
    +
    +
    +
    + ))} + {transactions.length === 0 ? ( +

    No recent transaction data available.

    + ) : null} +
    +
    +
    +
    + ) +} diff --git a/frontend/src/components/explorer/BridgeMonitoringPage.tsx b/frontend/src/components/explorer/BridgeMonitoringPage.tsx new file mode 100644 index 0000000..b5b75b2 --- /dev/null +++ b/frontend/src/components/explorer/BridgeMonitoringPage.tsx @@ -0,0 +1,326 @@ +import { useEffect, useMemo, useState } from 'react' +import Link from 'next/link' +import { Card } from '@/libs/frontend-ui-primitives' +import { + getMissionControlRelayLabel, + getMissionControlRelays, + missionControlApi, + type MissionControlBridgeStatusResponse, + type MissionControlRelayPayload, + type MissionControlRelaySnapshot, +} from '@/services/api/missionControl' +import { explorerFeaturePages } from '@/data/explorerOperations' + +type FeedState = 'connecting' | 'live' | 'fallback' + +interface RelayLaneCard { + key: string + label: string + status: string + profile: string + sourceChain: string + destinationChain: string + queueSize: number + processed: number + failed: number + lastPolled: string + bridgeAddress: string +} + +const relayOrder = ['mainnet_cw', 'mainnet_weth', 'bsc', 'avax', 'avax_cw', 'avax_to_138'] + +function relativeAge(isoString?: string): string { + if (!isoString) return 'Unknown' + const parsed = Date.parse(isoString) + if (!Number.isFinite(parsed)) return 'Unknown' + const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000)) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.round(minutes / 60) + return `${hours}h ago` +} + +function shortAddress(value?: string): string { + if (!value) return 'Unspecified' + if (value.length <= 14) return value + return `${value.slice(0, 6)}...${value.slice(-4)}` +} + +function resolveSnapshot(relay?: MissionControlRelayPayload): MissionControlRelaySnapshot | null { + return relay?.url_probe?.body || relay?.file_snapshot || null +} + +function laneToneClasses(status: string): string { + const normalized = status.toLowerCase() + if (['degraded', 'stale', 'stopped', 'down', 'snapshot-error'].includes(normalized)) { + return 'border-red-200 bg-red-50/80 dark:border-red-900/60 dark:bg-red-950/20' + } + if (['paused', 'starting'].includes(normalized)) { + return 'border-amber-200 bg-amber-50/80 dark:border-amber-900/60 dark:bg-amber-950/20' + } + return 'border-emerald-200 bg-emerald-50/80 dark:border-emerald-900/60 dark:bg-emerald-950/20' +} + +function statusPillClasses(status: string): string { + const normalized = status.toLowerCase() + if (['degraded', 'stale', 'stopped', 'down', 'snapshot-error'].includes(normalized)) { + return 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-100' + } + if (['paused', 'starting'].includes(normalized)) { + return 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-100' + } + return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-100' +} + +function ActionLink({ + href, + label, + external, +}: { + href: string + label: string + external?: boolean +}) { + const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline' + const text = `${label} ->` + + if (external) { + return ( + + {text} + + ) + } + + return ( + + {text} + + ) +} + +export default function BridgeMonitoringPage() { + const [bridgeStatus, setBridgeStatus] = useState(null) + const [feedState, setFeedState] = useState('connecting') + const page = explorerFeaturePages.bridge + + useEffect(() => { + let cancelled = false + + const loadSnapshot = async () => { + try { + const snapshot = await missionControlApi.getBridgeStatus() + if (!cancelled) { + setBridgeStatus(snapshot) + } + } catch (error) { + if (!cancelled && process.env.NODE_ENV !== 'production') { + console.warn('Failed to load bridge monitoring snapshot:', error) + } + } + } + + loadSnapshot() + + const unsubscribe = missionControlApi.subscribeBridgeStatus( + (status) => { + if (!cancelled) { + setBridgeStatus(status) + setFeedState('live') + } + }, + (error) => { + if (!cancelled) { + setFeedState('fallback') + } + if (process.env.NODE_ENV !== 'production') { + console.warn('Bridge monitoring live stream issue:', error) + } + } + ) + + return () => { + cancelled = true + unsubscribe() + } + }, []) + + const relayLanes = useMemo((): RelayLaneCard[] => { + const relays = getMissionControlRelays(bridgeStatus) + if (!relays) return [] + + const orderIndex = new Map(relayOrder.map((key, index) => [key, index])) + + return Object.entries(relays) + .map(([key, relay]) => { + const snapshot = resolveSnapshot(relay) + const status = String(snapshot?.status || (relay.file_snapshot_error ? 'snapshot-error' : 'configured')).toLowerCase() + return { + key, + label: getMissionControlRelayLabel(key), + status, + profile: snapshot?.service?.profile || key, + sourceChain: snapshot?.source?.chain_name || 'Unknown', + destinationChain: snapshot?.destination?.chain_name || 'Unknown', + queueSize: snapshot?.queue?.size ?? 0, + processed: snapshot?.queue?.processed ?? 0, + failed: snapshot?.queue?.failed ?? 0, + lastPolled: relativeAge(snapshot?.last_source_poll?.at), + bridgeAddress: + snapshot?.destination?.relay_bridge_default || + snapshot?.destination?.relay_bridge || + snapshot?.source?.bridge_filter || + '', + } + }) + .sort((left, right) => { + const leftIndex = orderIndex.get(left.key) ?? Number.MAX_SAFE_INTEGER + const rightIndex = orderIndex.get(right.key) ?? Number.MAX_SAFE_INTEGER + return leftIndex - rightIndex || left.label.localeCompare(right.label) + }) + }, [bridgeStatus]) + + const chainStatus = bridgeStatus?.data?.chains?.['138'] + const overallStatus = bridgeStatus?.data?.status || 'unknown' + const checkedAt = relativeAge(bridgeStatus?.data?.checked_at) + + return ( +
    +
    +
    + {page.eyebrow} +
    +

    + {page.title} +

    +

    + {page.description} +

    +
    + + {page.note ? ( + +

    + {page.note} +

    +
    + ) : null} + +
    + +
    + Relay Fleet +
    +
    + {overallStatus} +
    +
    + {relayLanes.length} managed lanes visible +
    +
    + Feed: {feedState === 'live' ? 'Live SSE' : feedState === 'fallback' ? 'Snapshot fallback' : 'Connecting'} +
    +
    + + +
    + Chain 138 RPC +
    +
    + {chainStatus?.status || 'unknown'} +
    +
    + Head age: {chainStatus?.head_age_sec != null ? `${chainStatus.head_age_sec.toFixed(1)}s` : 'Unknown'} +
    +
    + Latency: {chainStatus?.latency_ms != null ? `${chainStatus.latency_ms}ms` : 'Unknown'} +
    +
    + + +
    + Last Check +
    +
    + {checkedAt} +
    +
    + Public status JSON and live stream are both active. +
    +
    + +
    +
    +
    + +
    + {relayLanes.map((lane) => ( + +
    +
    +
    + {lane.label} +
    +
    + {`${lane.sourceChain} -> ${lane.destinationChain}`} +
    +
    +
    + {lane.status} +
    +
    + +
    +
    +
    Profile
    +
    {lane.profile}
    +
    +
    +
    Queue
    +
    {lane.queueSize}
    +
    +
    +
    Processed
    +
    {lane.processed}
    +
    +
    +
    Failed
    +
    {lane.failed}
    +
    +
    + +
    + Last polled: {lane.lastPolled} +
    +
    + Bridge: {shortAddress(lane.bridgeAddress)} +
    +
    + ))} +
    + +
    + {page.actions.map((action) => ( + +
    +
    + {action.title} +
    +

    + {action.description} +

    +
    + +
    +
    +
    + ))} +
    +
    + ) +} diff --git a/frontend/src/components/explorer/FeatureLandingPage.tsx b/frontend/src/components/explorer/FeatureLandingPage.tsx new file mode 100644 index 0000000..6cc9a68 --- /dev/null +++ b/frontend/src/components/explorer/FeatureLandingPage.tsx @@ -0,0 +1,66 @@ +import Link from 'next/link' +import { Card } from '@/libs/frontend-ui-primitives' +import type { ExplorerFeatureAction, ExplorerFeaturePage } from '@/data/explorerOperations' + +function ActionLink({ action }: { action: ExplorerFeatureAction }) { + const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline' + const label = `${action.label} ->` + + if (action.external) { + return ( + + {label} + + ) + } + + return ( + + {label} + + ) +} + +export default function FeatureLandingPage({ page }: { page: ExplorerFeaturePage }) { + return ( +
    +
    +
    + {page.eyebrow} +
    +

    + {page.title} +

    +

    + {page.description} +

    +
    + + {page.note ? ( + +

    + {page.note} +

    +
    + ) : null} + +
    + {page.actions.map((action) => ( + +
    +
    + {action.title} +
    +

    + {action.description} +

    +
    + +
    +
    +
    + ))} +
    +
    + ) +} diff --git a/frontend/src/components/explorer/LiquidityOperationsPage.tsx b/frontend/src/components/explorer/LiquidityOperationsPage.tsx new file mode 100644 index 0000000..3d3b87d --- /dev/null +++ b/frontend/src/components/explorer/LiquidityOperationsPage.tsx @@ -0,0 +1,419 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import Link from 'next/link' +import { Card } from '@/libs/frontend-ui-primitives' +import { configApi, type TokenListResponse } from '@/services/api/config' +import { + aggregateLiquidityPools, + featuredLiquiditySymbols, + getLivePlannerProviders, + getRouteBackedPoolAddresses, + getTopLiquidityRoutes, + selectFeaturedLiquidityTokens, + type AggregatedLiquidityPool, +} from '@/services/api/liquidity' +import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner' +import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } from '@/services/api/routes' +import { + formatCurrency, + formatNumber, + relativeAge, + truncateMiddle, +} from './OperationsPageShell' + +const tokenAggregationV1Base = '/token-aggregation/api/v1' +const tokenAggregationV2Base = '/token-aggregation/api/v2' + +interface TokenPoolRecord { + symbol: string + pools: MissionControlLiquidityPool[] +} + +function routePairLabel(routeId: string, routeLabel: string, tokenIn?: string, tokenOut?: string): string { + return [tokenIn, tokenOut].filter(Boolean).join(' / ') || routeLabel || routeId +} + +export default function LiquidityOperationsPage() { + const [tokenList, setTokenList] = useState(null) + const [routeMatrix, setRouteMatrix] = useState(null) + const [plannerCapabilities, setPlannerCapabilities] = useState(null) + const [internalPlan, setInternalPlan] = useState(null) + const [tokenPoolRecords, setTokenPoolRecords] = useState([]) + const [loadingError, setLoadingError] = useState(null) + + useEffect(() => { + let cancelled = false + + const load = async () => { + const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult] = + await Promise.allSettled([ + configApi.getTokenList(), + routesApi.getRouteMatrix(), + plannerApi.getCapabilities(), + plannerApi.getInternalExecutionPlan(), + ]) + + if (cancelled) return + + if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value) + if (routeMatrixResult.status === 'fulfilled') setRouteMatrix(routeMatrixResult.value) + if (plannerCapabilitiesResult.status === 'fulfilled') setPlannerCapabilities(plannerCapabilitiesResult.value) + if (planResult.status === 'fulfilled') setInternalPlan(planResult.value) + + if (tokenListResult.status === 'fulfilled') { + const featuredTokens = selectFeaturedLiquidityTokens(tokenListResult.value.tokens || []) + const poolResults = await Promise.allSettled( + featuredTokens.map(async (token) => ({ + symbol: token.symbol, + pools: (await routesApi.getTokenPools(token.address)).pools || [], + })) + ) + + if (!cancelled) { + setTokenPoolRecords( + poolResults + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .map((result) => result.value) + ) + } + } + + const failedCount = [ + tokenListResult, + routeMatrixResult, + plannerCapabilitiesResult, + planResult, + ].filter((result) => result.status === 'rejected').length + + if (failedCount === 4) { + setLoadingError('Live liquidity data is temporarily unavailable from the public explorer APIs.') + } + } + + load().catch((error) => { + if (!cancelled) { + setLoadingError( + error instanceof Error ? error.message : 'Live liquidity data is temporarily unavailable from the public explorer APIs.' + ) + } + }) + + return () => { + cancelled = true + } + }, []) + + const featuredTokens = useMemo( + () => selectFeaturedLiquidityTokens(tokenList?.tokens || []), + [tokenList?.tokens] + ) + const aggregatedPools = useMemo( + () => aggregateLiquidityPools(tokenPoolRecords), + [tokenPoolRecords] + ) + const livePlannerProviders = useMemo( + () => getLivePlannerProviders(plannerCapabilities), + [plannerCapabilities] + ) + const routeBackedPoolAddresses = useMemo( + () => getRouteBackedPoolAddresses(routeMatrix), + [routeMatrix] + ) + const highlightedRoutes = useMemo( + () => getTopLiquidityRoutes(routeMatrix, 6), + [routeMatrix] + ) + const dexCount = useMemo( + () => new Set(aggregatedPools.map((pool) => pool.dex).filter(Boolean)).size, + [aggregatedPools] + ) + + const insightLines = useMemo( + () => [ + `${formatNumber(routeMatrix?.counts?.liveSwapRoutes)} live swap routes and ${formatNumber(routeMatrix?.counts?.liveBridgeRoutes)} bridge routes are currently published in the public route matrix.`, + `${formatNumber(aggregatedPools.length)} unique pools were discovered across ${formatNumber(featuredTokens.length)} featured Chain 138 liquidity tokens.`, + `${formatNumber(livePlannerProviders.length)} planner providers are live, and the current internal fallback decision is ${internalPlan?.plannerResponse?.decision || 'unknown'}.`, + `${formatNumber(routeBackedPoolAddresses.length)} unique pool addresses are referenced directly by the current live route legs.`, + ], + [routeMatrix, aggregatedPools.length, featuredTokens.length, livePlannerProviders.length, internalPlan?.plannerResponse?.decision, routeBackedPoolAddresses.length] + ) + + const endpointCards = [ + { + name: 'Canonical route matrix', + method: 'GET', + href: `${tokenAggregationV1Base}/routes/matrix?includeNonLive=true`, + notes: 'All live and non-live route inventory with counts and pool-backed legs.', + }, + { + name: 'Planner capabilities', + method: 'GET', + href: `${tokenAggregationV2Base}/providers/capabilities?chainId=138`, + notes: 'Live provider inventory, published pair coverage, and execution modes.', + }, + { + name: 'Internal execution plan', + method: 'POST', + href: `${tokenAggregationV2Base}/routes/internal-execution-plan`, + notes: 'Returns the direct-pool fallback posture that the operator surfaces already verify.', + }, + { + name: 'Mission-control token pools', + method: 'GET', + href: `/explorer-api/v1/mission-control/liquidity/token/${featuredTokens[0]?.address || '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'}/pools`, + notes: 'Cached public pool inventory for a specific Chain 138 token.', + }, + ] + + return ( +
    +
    +
    + Chain 138 Liquidity Access +
    +

    + Public liquidity, route discovery, and execution access points +

    +

    + This page now reads the live explorer APIs instead of hardcoded pool snapshots. It pulls the + public route matrix, planner capabilities, and mission-control token pool inventory together + so integrators can inspect what Chain 138 is actually serving right now. +

    +
    + + {loadingError ? ( + +

    {loadingError}

    +
    + ) : null} + +
    + +
    + Live Pools +
    +
    + {formatNumber(aggregatedPools.length)} +
    +
    + Dedupe of mission-control pool inventory across featured Chain 138 tokens. +
    +
    + +
    + Route Coverage +
    +
    + {formatNumber(routeMatrix?.counts?.filteredLiveRoutes)} +
    +
    + {formatNumber(routeMatrix?.counts?.liveSwapRoutes)} swaps and {formatNumber(routeMatrix?.counts?.liveBridgeRoutes)} bridges. +
    +
    + +
    Planner providers
    +
    + {formatNumber(livePlannerProviders.length)} +
    +
    + {formatNumber(dexCount)} DEX families in the current discovered pools. +
    +
    + +
    Fallback posture
    +
    + {internalPlan?.plannerResponse?.decision || 'unknown'} +
    +
    + Execution contract {truncateMiddle(internalPlan?.execution?.contractAddress)} +
    +
    +
    + +
    + +
    + {aggregatedPools.slice(0, 8).map((pool) => ( +
    +
    +
    +
    + {(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')} +
    +
    + Pool: {pool.address} +
    +
    +
    + {pool.dex || 'Unknown DEX'} · TVL {formatCurrency(pool.tvl)} +
    +
    +
    + Seen from {pool.sourceSymbols.join(', ')} +
    +
    + ))} + {aggregatedPools.length === 0 ? ( +

    + No live pool inventory is available right now. +

    + ) : null} +
    +
    + + +
    + {insightLines.map((item) => ( +

    {item}

    + ))} +

    + Featured symbols in this view: {featuredLiquiditySymbols.join(', ')}. +

    +
    +
    +
    + +
    + +
    + {highlightedRoutes.map((route) => ( +
    +
    +
    +
    + {routePairLabel(route.routeId, route.label || '', route.tokenInSymbol, route.tokenOutSymbol)} +
    +
    + {formatNumber((route.legs || []).length)} legs · {formatNumber((route.legs || []).filter((leg) => leg.poolAddress).length)} pool-backed +
    +
    +
    + {route.aggregatorFamilies?.join(', ') || 'No provider families listed'} +
    +
    +
    + {(route.legs || []) + .map((leg) => leg.poolAddress) + .filter(Boolean) + .map((address) => truncateMiddle(address)) + .join(' · ') || 'No pool addresses published'} +
    +
    + ))} +
    +
    + + +
    + {featuredTokens.map((token) => { + const matchingRecord = tokenPoolRecords.find((record) => record.symbol === token.symbol) + return ( +
    +
    + {token.symbol} +
    +
    + {token.name || 'Unnamed token'} · {formatNumber(matchingRecord?.pools.length || 0)} mission-control pools +
    +
    + {truncateMiddle(token.address, 10, 8)} +
    +
    + ) + })} +
    +
    +
    + + + +
    + +
    + {[ + `GET ${tokenAggregationV1Base}/routes/matrix?includeNonLive=true`, + `GET ${tokenAggregationV2Base}/providers/capabilities?chainId=138`, + `POST ${tokenAggregationV2Base}/routes/internal-execution-plan`, + `GET /explorer-api/v1/mission-control/liquidity/token/${featuredTokens[0]?.address || '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'}/pools`, + ].map((example) => ( +
    + + {example} + +
    + ))} +
    +
    + + +
    +

    + Use the wallet page for network onboarding, the pools page for a tighter live inventory + view, and this page for the broader route and execution surfaces. +

    +

    + The live route matrix was updated {relativeAge(routeMatrix?.updated)}, and the current + route-backed pool set references {formatNumber(routeBackedPoolAddresses.length)} unique + pool addresses. +

    +
    + + Open pools page + + + Open wallet tools + + + Explorer docs + +
    +
    +
    +
    +
    + ) +} diff --git a/frontend/src/components/explorer/MoreOperationsPage.tsx b/frontend/src/components/explorer/MoreOperationsPage.tsx new file mode 100644 index 0000000..cee0ac6 --- /dev/null +++ b/frontend/src/components/explorer/MoreOperationsPage.tsx @@ -0,0 +1,301 @@ +import { useEffect, useMemo, useState } from 'react' +import Link from 'next/link' +import { Card } from '@/libs/frontend-ui-primitives' +import { explorerFeaturePages } from '@/data/explorerOperations' +import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config' +import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl' +import { routesApi, type RouteMatrixResponse } from '@/services/api/routes' + +function relativeAge(isoString?: string): string { + if (!isoString) return 'Unknown' + const parsed = Date.parse(isoString) + if (!Number.isFinite(parsed)) return 'Unknown' + const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000)) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.round(minutes / 60) + return `${hours}h ago` +} + +function ActionLink({ + href, + label, + external, +}: { + href: string + label: string + external?: boolean +}) { + const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline' + const text = `${label} ->` + + if (external) { + return ( + + {text} + + ) + } + + return ( + + {text} + + ) +} + +export default function MoreOperationsPage() { + const [bridgeStatus, setBridgeStatus] = useState(null) + const [routeMatrix, setRouteMatrix] = useState(null) + const [networksConfig, setNetworksConfig] = useState(null) + const [tokenList, setTokenList] = useState(null) + const [capabilities, setCapabilities] = useState(null) + const [loadingError, setLoadingError] = useState(null) + const page = explorerFeaturePages.more + + useEffect(() => { + let cancelled = false + + const load = async () => { + const [bridgeResult, routesResult, networksResult, tokenListResult, capabilitiesResult] = + await Promise.allSettled([ + missionControlApi.getBridgeStatus(), + routesApi.getRouteMatrix(), + configApi.getNetworks(), + configApi.getTokenList(), + configApi.getCapabilities(), + ]) + + if (cancelled) return + + if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value) + if (routesResult.status === 'fulfilled') setRouteMatrix(routesResult.value) + if (networksResult.status === 'fulfilled') setNetworksConfig(networksResult.value) + if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value) + if (capabilitiesResult.status === 'fulfilled') setCapabilities(capabilitiesResult.value) + + const failedCount = [ + bridgeResult, + routesResult, + networksResult, + tokenListResult, + capabilitiesResult, + ].filter((result) => result.status === 'rejected').length + + if (failedCount === 5) { + setLoadingError('Public explorer operations data is temporarily unavailable.') + } + } + + load().catch((error) => { + if (!cancelled) { + setLoadingError( + error instanceof Error ? error.message : 'Public explorer operations data is temporarily unavailable.' + ) + } + }) + + return () => { + cancelled = true + } + }, []) + + const relayCount = useMemo(() => { + const relays = getMissionControlRelays(bridgeStatus) + return relays ? Object.keys(relays).length : 0 + }, [bridgeStatus]) + + const totalQueue = useMemo(() => { + const relays = getMissionControlRelays(bridgeStatus) + if (!relays) return 0 + return Object.values(relays).reduce((sum, relay) => { + const queueSize = relay.url_probe?.body?.queue?.size ?? relay.file_snapshot?.queue?.size ?? 0 + return sum + queueSize + }, 0) + }, [bridgeStatus]) + + const tokenChainCoverage = useMemo(() => { + return new Set((tokenList?.tokens || []).map((token) => token.chainId).filter(Boolean)).size + }, [tokenList]) + + const topSymbols = useMemo(() => { + return Array.from( + new Set((tokenList?.tokens || []).map((token) => token.symbol).filter(Boolean) as string[]) + ).slice(0, 8) + }, [tokenList]) + + return ( +
    +
    +
    + {page.eyebrow} +
    +

    + {page.title} +

    +

    + {page.description} +

    +
    + + {page.note ? ( + +

    + {page.note} +

    +
    + ) : null} + + {loadingError ? ( + +

    {loadingError}

    +
    + ) : null} + +
    + +
    + Bridge Fleet +
    +
    + {bridgeStatus?.data?.status || 'unknown'} +
    +
    + {relayCount} managed lanes · queue {totalQueue} +
    +
    + + +
    + Route Coverage +
    +
    + {routeMatrix?.counts?.filteredLiveRoutes ?? 0} +
    +
    + {routeMatrix?.counts?.liveSwapRoutes ?? 0} swaps · {routeMatrix?.counts?.liveBridgeRoutes ?? 0} bridges +
    +
    + + +
    + Wallet Surface +
    +
    + {networksConfig?.chains?.length ?? 0} +
    +
    + Chains · {(tokenList?.tokens || []).length} tokens across {tokenChainCoverage} networks +
    +
    + + +
    + RPC Capabilities +
    +
    + {capabilities?.http?.supportedMethods?.length ?? 0} +
    +
    + HTTP methods · {capabilities?.tracing?.supportedMethods?.length ?? 0} tracing methods +
    +
    +
    + +
    + +
    +
    +
    Bridge checked
    +
    + {relativeAge(bridgeStatus?.data?.checked_at)} +
    +
    + Public mission-control snapshot freshness. +
    +
    +
    +
    Route matrix updated
    +
    + {relativeAge(routeMatrix?.updated)} +
    +
    + Token-aggregation route inventory timestamp. +
    +
    +
    +
    Default chain
    +
    + {networksConfig?.defaultChainId ?? 'Unknown'} +
    +
    + Wallet onboarding points at Chain 138 by default. +
    +
    +
    +
    Wallet support
    +
    + {capabilities?.walletSupport?.walletAddEthereumChain && capabilities?.walletSupport?.walletWatchAsset + ? 'Ready' + : 'Partial'} +
    +
    + `wallet_addEthereumChain` and `wallet_watchAsset` compatibility. +
    +
    +
    +
    + + +
    +
    +
    Featured symbols
    +
    + {topSymbols.map((symbol) => ( + + {symbol} + + ))} +
    +
    +
    +
    Tracing posture
    +
    + Supported: {(capabilities?.tracing?.supportedMethods || []).join(', ') || 'None'} +
    +
    + Unsupported: {(capabilities?.tracing?.unsupportedMethods || []).join(', ') || 'None'} +
    +
    +
    +
    +
    + +
    + {page.actions.map((action) => ( + +
    +
    + {action.title} +
    +

    + {action.description} +

    +
    + +
    +
    +
    + ))} +
    +
    + ) +} diff --git a/frontend/src/components/explorer/OperationsPageShell.tsx b/frontend/src/components/explorer/OperationsPageShell.tsx new file mode 100644 index 0000000..aa00091 --- /dev/null +++ b/frontend/src/components/explorer/OperationsPageShell.tsx @@ -0,0 +1,148 @@ +import type { ReactNode } from 'react' +import Link from 'next/link' +import { Card } from '@/libs/frontend-ui-primitives' +import type { ExplorerFeatureAction, ExplorerFeaturePage } from '@/data/explorerOperations' + +export type StatusTone = 'normal' | 'warning' | 'danger' + +function ActionLink({ action }: { action: ExplorerFeatureAction }) { + const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline' + const label = `${action.label} ->` + + if (action.external) { + return ( + + {label} + + ) + } + + return ( + + {label} + + ) +} + +export function relativeAge(isoString?: string): string { + if (!isoString) return 'Unknown' + const parsed = Date.parse(isoString) + if (!Number.isFinite(parsed)) return 'Unknown' + const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000)) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.round(minutes / 60) + return `${hours}h ago` +} + +export function formatNumber(value?: number | null): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return '0' + return new Intl.NumberFormat('en-US').format(value) +} + +export function formatCurrency(value?: number | null): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return 'Unknown' + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(value) +} + +export function truncateMiddle(value?: string, start = 8, end = 6): string { + if (!value) return 'Unknown' + if (value.length <= start + end + 3) return value + return `${value.slice(0, start)}...${value.slice(-end)}` +} + +export function StatusBadge({ + status, + tone = 'normal', +}: { + status: string + tone?: StatusTone +}) { + const toneClass = + tone === 'danger' + ? 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-300' + : tone === 'warning' + ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300' + : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300' + + return ( + + {status} + + ) +} + +export function MetricCard({ + title, + value, + description, + className, +}: { + title: string + value: string + description: string + className?: string +}) { + return ( + +
    + {title} +
    +
    {value}
    +
    {description}
    +
    + ) +} + +export default function OperationsPageShell({ + page, + children, +}: { + page: ExplorerFeaturePage + children: ReactNode +}) { + return ( +
    +
    +
    + {page.eyebrow} +
    +

    + {page.title} +

    +

    + {page.description} +

    +
    + + {page.note ? ( + +

    {page.note}

    +
    + ) : null} + + {children} + +
    + {page.actions.map((action) => ( + +
    +
    {action.title}
    +

    + {action.description} +

    +
    + +
    +
    +
    + ))} +
    +
    + ) +} diff --git a/frontend/src/components/explorer/OperatorOperationsPage.tsx b/frontend/src/components/explorer/OperatorOperationsPage.tsx new file mode 100644 index 0000000..16e8a0a --- /dev/null +++ b/frontend/src/components/explorer/OperatorOperationsPage.tsx @@ -0,0 +1,193 @@ +import { useEffect, useMemo, useState } from 'react' +import { Card } from '@/libs/frontend-ui-primitives' +import { explorerFeaturePages } from '@/data/explorerOperations' +import { + getMissionControlRelays, + getMissionControlRelayLabel, + missionControlApi, + type MissionControlBridgeStatusResponse, +} from '@/services/api/missionControl' +import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner' +import { routesApi, type RouteMatrixResponse } from '@/services/api/routes' +import OperationsPageShell, { + MetricCard, + StatusBadge, + formatNumber, + relativeAge, + truncateMiddle, +} from './OperationsPageShell' + +function relayTone(status?: string): 'normal' | 'warning' | 'danger' { + const normalized = String(status || 'unknown').toLowerCase() + if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger' + if (['paused', 'starting', 'unknown'].includes(normalized)) return 'warning' + return 'normal' +} + +export default function OperatorOperationsPage() { + const [bridgeStatus, setBridgeStatus] = useState(null) + const [routeMatrix, setRouteMatrix] = useState(null) + const [plannerCapabilities, setPlannerCapabilities] = useState(null) + const [internalPlan, setInternalPlan] = useState(null) + const [loadingError, setLoadingError] = useState(null) + const page = explorerFeaturePages.operator + + useEffect(() => { + let cancelled = false + + const load = async () => { + const [bridgeResult, routesResult, capabilitiesResult, planResult] = await Promise.allSettled([ + missionControlApi.getBridgeStatus(), + routesApi.getRouteMatrix(), + plannerApi.getCapabilities(), + plannerApi.getInternalExecutionPlan(), + ]) + + if (cancelled) return + + if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value) + if (routesResult.status === 'fulfilled') setRouteMatrix(routesResult.value) + if (capabilitiesResult.status === 'fulfilled') setPlannerCapabilities(capabilitiesResult.value) + if (planResult.status === 'fulfilled') setInternalPlan(planResult.value) + + const failedCount = [bridgeResult, routesResult, capabilitiesResult, planResult].filter( + (result) => result.status === 'rejected' + ).length + + if (failedCount === 4) { + setLoadingError('Operator telemetry is temporarily unavailable from the public explorer APIs.') + } + } + + load().catch((error) => { + if (!cancelled) { + setLoadingError(error instanceof Error ? error.message : 'Operator telemetry is temporarily unavailable from the public explorer APIs.') + } + }) + + return () => { + cancelled = true + } + }, []) + + const relays = useMemo(() => getMissionControlRelays(bridgeStatus), [bridgeStatus]) + const relayEntries = useMemo(() => Object.entries(relays || {}), [relays]) + const totalQueue = useMemo( + () => + relayEntries.reduce((sum, [, relay]) => { + const queueSize = relay.url_probe?.body?.queue?.size ?? relay.file_snapshot?.queue?.size ?? 0 + return sum + queueSize + }, 0), + [relayEntries] + ) + const providers = plannerCapabilities?.providers || [] + const liveProviders = providers.filter((provider) => provider.live) + + return ( + + {loadingError ? ( + +

    {loadingError}

    +
    + ) : null} + +
    + + + + +
    + +
    + +
    + {relayEntries.map(([key, relay]) => { + const snapshot = relay.url_probe?.body || relay.file_snapshot + const status = snapshot?.status || 'unknown' + return ( +
    +
    +
    +
    + {getMissionControlRelayLabel(key)} +
    +
    + {snapshot?.destination?.chain_name || 'Unknown destination'} · queue {formatNumber(snapshot?.queue?.size ?? 0)} +
    +
    + +
    +
    + Last source poll {relativeAge(snapshot?.last_source_poll?.at)} · processed {formatNumber(snapshot?.queue?.processed ?? 0)} +
    +
    + ) + })} + {relayEntries.length === 0 ? ( +

    No relay lane data available.

    + ) : null} +
    +
    + + +
    +
    +
    Internal execution plan
    +
    + +
    +
    + Contract {truncateMiddle(internalPlan?.execution?.contractAddress)} · {formatNumber(internalPlan?.plannerResponse?.steps?.length)} planner steps +
    +
    + +
    +
    Live providers
    +
    + {liveProviders.map((provider) => ( + + {provider.provider} + + ))} + {liveProviders.length === 0 ? ( + No live providers reported. + ) : null} +
    +
    +
    +
    +
    +
    + ) +} diff --git a/frontend/src/components/explorer/PoolsOperationsPage.tsx b/frontend/src/components/explorer/PoolsOperationsPage.tsx new file mode 100644 index 0000000..64cea22 --- /dev/null +++ b/frontend/src/components/explorer/PoolsOperationsPage.tsx @@ -0,0 +1,237 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import Link from 'next/link' +import { Card } from '@/libs/frontend-ui-primitives' +import { configApi, type TokenListResponse } from '@/services/api/config' +import { + aggregateLiquidityPools, + getRouteBackedPoolAddresses, + selectFeaturedLiquidityTokens, +} from '@/services/api/liquidity' +import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } from '@/services/api/routes' +import { formatCurrency, formatNumber, truncateMiddle } from './OperationsPageShell' + +interface TokenPoolRecord { + symbol: string + pools: MissionControlLiquidityPool[] +} + +export default function PoolsOperationsPage() { + const [tokenList, setTokenList] = useState(null) + const [routeMatrix, setRouteMatrix] = useState(null) + const [tokenPoolRecords, setTokenPoolRecords] = useState([]) + const [loadingError, setLoadingError] = useState(null) + + useEffect(() => { + let cancelled = false + + const load = async () => { + const [tokenListResult, routeMatrixResult] = await Promise.allSettled([ + configApi.getTokenList(), + routesApi.getRouteMatrix(), + ]) + + if (cancelled) return + + if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value) + if (routeMatrixResult.status === 'fulfilled') setRouteMatrix(routeMatrixResult.value) + + if (tokenListResult.status === 'fulfilled') { + const featuredTokens = selectFeaturedLiquidityTokens(tokenListResult.value.tokens || []) + const poolResults = await Promise.allSettled( + featuredTokens.map(async (token) => ({ + symbol: token.symbol, + pools: (await routesApi.getTokenPools(token.address)).pools || [], + })) + ) + + if (!cancelled) { + setTokenPoolRecords( + poolResults + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .map((result) => result.value) + ) + } + } + + if (tokenListResult.status === 'rejected' && routeMatrixResult.status === 'rejected') { + setLoadingError('Live pool inventory is temporarily unavailable from the public explorer APIs.') + } + } + + load().catch((error) => { + if (!cancelled) { + setLoadingError( + error instanceof Error ? error.message : 'Live pool inventory is temporarily unavailable from the public explorer APIs.' + ) + } + }) + + return () => { + cancelled = true + } + }, []) + + const featuredTokens = useMemo( + () => selectFeaturedLiquidityTokens(tokenList?.tokens || []), + [tokenList?.tokens] + ) + const aggregatedPools = useMemo( + () => aggregateLiquidityPools(tokenPoolRecords), + [tokenPoolRecords] + ) + const routeBackedPoolAddresses = useMemo( + () => getRouteBackedPoolAddresses(routeMatrix), + [routeMatrix] + ) + const topPools = aggregatedPools.slice(0, 9) + + return ( +
    +
    +
    + Live Pool Inventory +
    +

    + Pools +

    +

    + This page now summarizes the live pool inventory discovered through mission-control token + pool endpoints and cross-checks it against the current route matrix. +

    +
    + + {loadingError ? ( + +

    {loadingError}

    +
    + ) : null} + +
    + +
    + Unique pools +
    +
    + {formatNumber(aggregatedPools.length)} +
    +
    + Discovered across {formatNumber(featuredTokens.length)} featured Chain 138 tokens. +
    +
    + +
    + Route-backed pools +
    +
    + {formatNumber(routeBackedPoolAddresses.length)} +
    +
    + Unique pool addresses referenced by the live route matrix. +
    +
    + +
    Featured coverage
    +
    + {formatNumber(tokenPoolRecords.filter((record) => record.pools.length > 0).length)} +
    +
    + Featured tokens currently returning at least one live pool. +
    +
    +
    + +
    + +
    + {topPools.map((pool) => ( +
    +
    + {(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')} +
    +
    + {pool.dex || 'Unknown DEX'} · TVL {formatCurrency(pool.tvl)} +
    +
    + {truncateMiddle(pool.address, 10, 8)} +
    +
    + Seen from {pool.sourceSymbols.join(', ')} +
    +
    + ))} + {topPools.length === 0 ? ( +

    No live pools available right now.

    + ) : null} +
    +
    +
    + +
    + +
    + {featuredTokens.map((token) => { + const record = tokenPoolRecords.find((entry) => entry.symbol === token.symbol) + return ( +
    +
    +
    +
    + {token.symbol} +
    +
    + {token.name || 'Unnamed token'} +
    +
    +
    + {formatNumber(record?.pools.length || 0)} pools +
    +
    +
    + ) + })} +
    +
    + + +
    +

    + The broader liquidity page now shows live route, planner, and pool access together. +

    +

    + The current route matrix publishes {formatNumber(routeMatrix?.counts?.liveSwapRoutes)} live + swap routes and {formatNumber(routeMatrix?.counts?.liveBridgeRoutes)} bridge routes. +

    +
    + + Open liquidity access + + + Open routes page + + + Open wallet tools + +
    +
    +
    +
    +
    + ) +} diff --git a/frontend/src/components/explorer/RoutesMonitoringPage.tsx b/frontend/src/components/explorer/RoutesMonitoringPage.tsx new file mode 100644 index 0000000..ea18ad4 --- /dev/null +++ b/frontend/src/components/explorer/RoutesMonitoringPage.tsx @@ -0,0 +1,401 @@ +import { useEffect, useMemo, useState } from 'react' +import Link from 'next/link' +import { Card } from '@/libs/frontend-ui-primitives' +import { explorerFeaturePages } from '@/data/explorerOperations' +import { + routesApi, + type ExplorerNetwork, + type MissionControlLiquidityPool, + type RouteMatrixRoute, + type RouteMatrixResponse, +} from '@/services/api/routes' + +const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22' + +function relativeAge(isoString?: string): string { + if (!isoString) return 'Unknown' + const parsed = Date.parse(isoString) + if (!Number.isFinite(parsed)) return 'Unknown' + const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000)) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.round(minutes / 60) + return `${hours}h ago` +} + +function compactAddress(value?: string): string { + if (!value) return 'Unspecified' + if (value.length <= 14) return value + return `${value.slice(0, 6)}...${value.slice(-4)}` +} + +function formatUsd(value?: number): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return 'Unknown' + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(value) +} + +function protocolList(route: RouteMatrixRoute): string { + const protocols = Array.from( + new Set((route.legs || []).map((leg) => leg.protocol || leg.executor || '').filter(Boolean)) + ) + return protocols.length > 0 ? protocols.join(', ') : 'Unspecified' +} + +function routeAssetPair(route: RouteMatrixRoute): string { + if (route.routeType === 'bridge') { + return route.assetSymbol || 'Bridge asset' + } + return [route.tokenInSymbol, route.tokenOutSymbol].filter(Boolean).join(' -> ') || 'Swap route' +} + +function ActionLink({ + href, + label, + external, +}: { + href: string + label: string + external?: boolean +}) { + const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline' + const text = `${label} ->` + + if (external) { + return ( + + {text} + + ) + } + + return ( + + {text} + + ) +} + +export default function RoutesMonitoringPage() { + const [routeMatrix, setRouteMatrix] = useState(null) + const [networks, setNetworks] = useState([]) + const [pools, setPools] = useState([]) + const [loadingError, setLoadingError] = useState(null) + const page = explorerFeaturePages.routes + + useEffect(() => { + let cancelled = false + + const load = async () => { + const [matrixResult, networksResult, poolsResult] = await Promise.allSettled([ + routesApi.getRouteMatrix(), + routesApi.getNetworks(), + routesApi.getTokenPools(canonicalLiquidityToken), + ]) + + if (cancelled) return + + if (matrixResult.status === 'fulfilled') { + setRouteMatrix(matrixResult.value) + } + if (networksResult.status === 'fulfilled') { + setNetworks(networksResult.value.networks || []) + } + if (poolsResult.status === 'fulfilled') { + setPools(poolsResult.value.pools || []) + } + + if ( + matrixResult.status === 'rejected' && + networksResult.status === 'rejected' && + poolsResult.status === 'rejected' + ) { + setLoadingError('Live route inventory is temporarily unavailable.') + } + } + + load().catch((error) => { + if (!cancelled) { + setLoadingError( + error instanceof Error ? error.message : 'Live route inventory is temporarily unavailable.' + ) + } + }) + + return () => { + cancelled = true + } + }, []) + + const liveRoutes = useMemo(() => routeMatrix?.liveRoutes || [], [routeMatrix?.liveRoutes]) + const plannedRoutes = useMemo( + () => routeMatrix?.blockedOrPlannedRoutes || [], + [routeMatrix?.blockedOrPlannedRoutes] + ) + + const familyCount = useMemo(() => { + return new Set(liveRoutes.flatMap((route) => route.aggregatorFamilies || [])).size + }, [liveRoutes]) + + const topRoutes = useMemo(() => { + const ordered = [...liveRoutes].sort((left, right) => { + if ((left.routeType || '') !== (right.routeType || '')) { + return left.routeType === 'bridge' ? -1 : 1 + } + return (left.label || '').localeCompare(right.label || '') + }) + return ordered.slice(0, 8) + }, [liveRoutes]) + + const highlightedNetworks = useMemo(() => { + return networks + .filter((network) => [138, 1, 651940, 56, 43114].includes(network.chainIdDecimal || 0)) + .sort((left, right) => (left.chainIdDecimal || 0) - (right.chainIdDecimal || 0)) + }, [networks]) + + return ( +
    +
    +
    + {page.eyebrow} +
    +

    + {page.title} +

    +

    + {page.description} +

    +
    + + {page.note ? ( + +

    + {page.note} +

    +
    + ) : null} + + {loadingError ? ( + +

    {loadingError}

    +
    + ) : null} + +
    + +
    + Live Swap Routes +
    +
    + {routeMatrix?.counts?.liveSwapRoutes ?? liveRoutes.filter((route) => route.routeType === 'swap').length} +
    +
    + Planner-visible same-chain routes on Chain 138. +
    +
    + + +
    + Live Bridge Routes +
    +
    + {routeMatrix?.counts?.liveBridgeRoutes ?? liveRoutes.filter((route) => route.routeType === 'bridge').length} +
    +
    + Bridge routes exposed through the current route matrix. +
    +
    + + +
    + Network Catalog +
    +
    + {networks.length} +
    +
    + Published networks available through the explorer config surface. +
    +
    + + +
    + cUSDT Pool View +
    +
    + {pools.length} +
    +
    + Live mission-control pools for the canonical cUSDT token. +
    +
    +
    + +
    + +
    +
    +
    Generated
    +
    + {relativeAge(routeMatrix?.generatedAt)} +
    +
    + Matrix version {routeMatrix?.version || 'unknown'} +
    +
    +
    +
    Updated Source
    +
    + {relativeAge(routeMatrix?.updated)} +
    +
    + {familyCount} partner families surfaced in live routes. +
    +
    +
    +
    Filtered Live Routes
    +
    + {routeMatrix?.counts?.filteredLiveRoutes ?? liveRoutes.length} +
    +
    + Includes swap and bridge lanes currently in the public matrix. +
    +
    +
    +
    Planned / Blocked
    +
    + {routeMatrix?.counts?.blockedOrPlannedRoutes ?? plannedRoutes.length} +
    +
    + Remaining lanes still waiting on pools, funding, or routing support. +
    +
    +
    +
    + + +
    + {highlightedNetworks.map((network) => ( +
    +
    + {network.chainName || 'Unknown chain'} +
    +
    + Chain ID {network.chainIdDecimal ?? 'Unknown'} · {network.shortName || 'n/a'} +
    +
    + ))} +
    +
    +
    + +
    + +
    + {topRoutes.map((route) => ( +
    +
    +
    +
    + {route.label || route.routeId} +
    +
    + {routeAssetPair(route)} +
    +
    +
    + {route.routeType || 'route'} +
    +
    +
    + {`${route.fromChainId} -> ${route.toChainId} · ${route.hopCount ?? 0} hop${ + (route.hopCount ?? 0) === 1 ? '' : 's' + }`} +
    +
    + Protocols: {protocolList(route)} +
    +
    + ))} +
    +
    + + +
    + {pools.map((pool) => ( +
    +
    + {(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')} +
    +
    + {pool.dex || 'Unknown DEX'} · TVL {formatUsd(pool.tvl)} +
    +
    + Pool {compactAddress(pool.address)} +
    +
    + ))} +
    +
    +
    + +
    + +
    + {plannedRoutes.slice(0, 6).map((route) => ( +
    +
    + {route.routeId} +
    +
    + {(route.tokenInSymbols || []).join(' / ') || routeAssetPair(route)} +
    +
    + {route.reason || 'Pending additional deployment or routing work.'} +
    +
    + ))} +
    +
    +
    + +
    + {page.actions.map((action) => ( + +
    +
    + {action.title} +
    +

    + {action.description} +

    +
    + +
    +
    +
    + ))} +
    +
    + ) +} diff --git a/frontend/src/components/explorer/SystemOperationsPage.tsx b/frontend/src/components/explorer/SystemOperationsPage.tsx new file mode 100644 index 0000000..935fa87 --- /dev/null +++ b/frontend/src/components/explorer/SystemOperationsPage.tsx @@ -0,0 +1,189 @@ +import { useEffect, useMemo, useState } from 'react' +import { Card } from '@/libs/frontend-ui-primitives' +import { explorerFeaturePages } from '@/data/explorerOperations' +import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config' +import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl' +import { routesApi, type RouteMatrixResponse } from '@/services/api/routes' +import { statsApi, type ExplorerStats } from '@/services/api/stats' +import OperationsPageShell, { + MetricCard, + StatusBadge, + formatNumber, + relativeAge, +} from './OperationsPageShell' + +export default function SystemOperationsPage() { + const [bridgeStatus, setBridgeStatus] = useState(null) + const [networksConfig, setNetworksConfig] = useState(null) + const [tokenList, setTokenList] = useState(null) + const [capabilities, setCapabilities] = useState(null) + const [routeMatrix, setRouteMatrix] = useState(null) + const [stats, setStats] = useState(null) + const [loadingError, setLoadingError] = useState(null) + const page = explorerFeaturePages.system + + useEffect(() => { + let cancelled = false + + const load = async () => { + const [bridgeResult, networksResult, tokenListResult, capabilitiesResult, routesResult, statsResult] = + await Promise.allSettled([ + missionControlApi.getBridgeStatus(), + configApi.getNetworks(), + configApi.getTokenList(), + configApi.getCapabilities(), + routesApi.getRouteMatrix(), + statsApi.get(), + ]) + + if (cancelled) return + + if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value) + if (networksResult.status === 'fulfilled') setNetworksConfig(networksResult.value) + if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value) + if (capabilitiesResult.status === 'fulfilled') setCapabilities(capabilitiesResult.value) + if (routesResult.status === 'fulfilled') setRouteMatrix(routesResult.value) + if (statsResult.status === 'fulfilled') setStats(statsResult.value) + + const failedCount = [ + bridgeResult, + networksResult, + tokenListResult, + capabilitiesResult, + routesResult, + statsResult, + ].filter((result) => result.status === 'rejected').length + + if (failedCount === 6) { + setLoadingError('System inventory data is temporarily unavailable from the public explorer APIs.') + } + } + + load().catch((error) => { + if (!cancelled) { + setLoadingError(error instanceof Error ? error.message : 'System inventory data is temporarily unavailable from the public explorer APIs.') + } + }) + + return () => { + cancelled = true + } + }, []) + + const relays = useMemo(() => getMissionControlRelays(bridgeStatus), [bridgeStatus]) + const chainStatus = bridgeStatus?.data?.chains?.['138'] + const chainCoverage = useMemo( + () => new Set((tokenList?.tokens || []).map((token) => token.chainId).filter(Boolean)).size, + [tokenList] + ) + + return ( + + {loadingError ? ( + +

    {loadingError}

    +
    + ) : null} + +
    + + + + +
    + +
    + +
    +
    +
    Chain 138 RPC
    +
    + +
    +
    + Head age {chainStatus?.head_age_sec != null ? `${Math.round(chainStatus.head_age_sec)}s` : 'Unknown'} · latency {chainStatus?.latency_ms != null ? `${Math.round(chainStatus.latency_ms)}ms` : 'Unknown'} +
    +
    +
    +
    Route matrix
    +
    + {formatNumber(routeMatrix?.counts?.filteredLiveRoutes)} live routes +
    +
    + Updated {relativeAge(routeMatrix?.updated)} · {formatNumber(routeMatrix?.counts?.blockedOrPlannedRoutes)} planned or blocked +
    +
    +
    +
    Explorer index
    +
    + {formatNumber(stats?.total_blocks)} blocks +
    +
    + {formatNumber(stats?.total_transactions)} transactions · {formatNumber(stats?.total_addresses)} addresses +
    +
    +
    +
    Wallet compatibility
    +
    + +
    +
    + Public capabilities JSON is wired for chain-add and token-add flows. +
    +
    +
    +
    + + +
    + {(networksConfig?.chains || []).map((chain) => ( +
    +
    + {chain.chainName || chain.shortName || `Chain ${chain.chainIdDecimal}`} +
    +
    + Chain ID {chain.chainIdDecimal ?? 'Unknown'} · short name {chain.shortName || 'Unknown'} +
    +
    + ))} + {(networksConfig?.chains || []).length === 0 ? ( +

    No network inventory available.

    + ) : null} +
    +
    +
    +
    + ) +} diff --git a/frontend/src/components/explorer/WethOperationsPage.tsx b/frontend/src/components/explorer/WethOperationsPage.tsx new file mode 100644 index 0000000..1b6c920 --- /dev/null +++ b/frontend/src/components/explorer/WethOperationsPage.tsx @@ -0,0 +1,188 @@ +import { useEffect, useMemo, useState } from 'react' +import { Card } from '@/libs/frontend-ui-primitives' +import { explorerFeaturePages } from '@/data/explorerOperations' +import { + getMissionControlRelays, + missionControlApi, + type MissionControlBridgeStatusResponse, + type MissionControlRelayPayload, +} from '@/services/api/missionControl' +import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner' +import OperationsPageShell, { + MetricCard, + StatusBadge, + formatNumber, + relativeAge, + truncateMiddle, +} from './OperationsPageShell' + +function relayTone(status?: string): 'normal' | 'warning' | 'danger' { + const normalized = String(status || 'unknown').toLowerCase() + if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger' + if (['paused', 'starting', 'unknown'].includes(normalized)) return 'warning' + return 'normal' +} + +function relaySnapshot(relay: MissionControlRelayPayload | undefined) { + return relay?.url_probe?.body || relay?.file_snapshot +} + +export default function WethOperationsPage() { + const [bridgeStatus, setBridgeStatus] = useState(null) + const [plannerCapabilities, setPlannerCapabilities] = useState(null) + const [internalPlan, setInternalPlan] = useState(null) + const [loadingError, setLoadingError] = useState(null) + const page = explorerFeaturePages.weth + + useEffect(() => { + let cancelled = false + + const load = async () => { + const [bridgeResult, capabilitiesResult, planResult] = await Promise.allSettled([ + missionControlApi.getBridgeStatus(), + plannerApi.getCapabilities(), + plannerApi.getInternalExecutionPlan(), + ]) + + if (cancelled) return + + if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value) + if (capabilitiesResult.status === 'fulfilled') setPlannerCapabilities(capabilitiesResult.value) + if (planResult.status === 'fulfilled') setInternalPlan(planResult.value) + + const failedCount = [bridgeResult, capabilitiesResult, planResult].filter( + (result) => result.status === 'rejected' + ).length + + if (failedCount === 3) { + setLoadingError('WETH operations data is temporarily unavailable from the public explorer APIs.') + } + } + + load().catch((error) => { + if (!cancelled) { + setLoadingError(error instanceof Error ? error.message : 'WETH operations data is temporarily unavailable from the public explorer APIs.') + } + }) + + return () => { + cancelled = true + } + }, []) + + const relays = useMemo(() => getMissionControlRelays(bridgeStatus), [bridgeStatus]) + const mainnetWeth = relaySnapshot(relays?.mainnet_weth) + const mainnetCw = relaySnapshot(relays?.mainnet_cw) + const wethProviders = useMemo( + () => + (plannerCapabilities?.providers || []).filter((provider) => + (provider.pairs || []).some( + (pair) => pair.tokenInSymbol === 'WETH' || pair.tokenOutSymbol === 'WETH' + ) + ), + [plannerCapabilities] + ) + + return ( + + {loadingError ? ( + +

    {loadingError}

    +
    + ) : null} + +
    + + + + +
    + +
    + +
    + {[ + { label: 'Mainnet WETH', snapshot: mainnetWeth }, + { label: 'Mainnet cW', snapshot: mainnetCw }, + ].map(({ label, snapshot }) => ( +
    +
    +
    +
    {label}
    +
    + Source {snapshot?.source?.chain_name || 'Unknown'} · destination {snapshot?.destination?.chain_name || 'Unknown'} +
    +
    + +
    +
    + Queue {formatNumber(snapshot?.queue?.size ?? 0)} · last poll {relativeAge(snapshot?.last_source_poll?.at)} +
    +
    + ))} +
    +
    + + +
    + {wethProviders.map((provider) => { + const samplePairs = (provider.pairs || []) + .filter((pair) => pair.tokenInSymbol === 'WETH' || pair.tokenOutSymbol === 'WETH') + .slice(0, 3) + + return ( +
    +
    +
    +
    + {provider.provider} +
    +
    + {provider.executionMode || 'unknown mode'} · {(provider.supportedLegTypes || []).join(', ') || 'no leg types'} +
    +
    + +
    +
    + {samplePairs.map((pair) => `${pair.tokenInSymbol} -> ${pair.tokenOutSymbol}`).join(' · ') || 'No WETH pairs published'} +
    +
    + ) + })} + {wethProviders.length === 0 ? ( +

    No WETH-aware providers reported.

    + ) : null} +
    +
    +
    +
    + ) +} diff --git a/frontend/src/components/wallet/AddToMetaMask.tsx b/frontend/src/components/wallet/AddToMetaMask.tsx index 6be823a..2f47a04 100644 --- a/frontend/src/components/wallet/AddToMetaMask.tsx +++ b/frontend/src/components/wallet/AddToMetaMask.tsx @@ -55,9 +55,16 @@ type TokenListCatalog = { type CapabilitiesCatalog = { name?: string + version?: { + major?: number + minor?: number + patch?: number + } + timestamp?: string chainId?: number chainName?: string rpcUrl?: string + explorerUrl?: string explorerApiUrl?: string generatedBy?: string walletSupport?: { @@ -128,6 +135,80 @@ const FEATURED_TOKEN_SYMBOLS = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAU /** npm-published Snap using open Snap permissions only; stable MetaMask still requires MetaMask’s install allowlist. */ const CHAIN138_OPEN_SNAP_ID = 'npm:chain138-open-snap' as const +const FALLBACK_CAPABILITIES_138: CapabilitiesCatalog = { + name: 'Chain 138 RPC Capabilities', + version: { major: 1, minor: 1, patch: 0 }, + timestamp: '2026-03-28T00:00:00Z', + generatedBy: 'SolaceScanScout', + chainId: 138, + chainName: 'DeFi Oracle Meta Mainnet', + rpcUrl: 'https://rpc-http-pub.d-bis.org', + explorerUrl: 'https://explorer.d-bis.org', + explorerApiUrl: 'https://explorer.d-bis.org/api/v2', + walletSupport: { + walletAddEthereumChain: true, + walletWatchAsset: true, + notes: [ + 'MetaMask primarily relies on JSON-RPC correctness for balances, gas estimation, calls, and transaction submission.', + 'Explorer-served network metadata and token list metadata complement wallet UX but do not replace RPC method support.', + ], + }, + http: { + supportedMethods: [ + 'web3_clientVersion', + 'net_version', + 'eth_chainId', + 'eth_blockNumber', + 'eth_syncing', + 'eth_gasPrice', + 'eth_maxPriorityFeePerGas', + 'eth_feeHistory', + 'eth_estimateGas', + 'eth_getCode', + ], + unsupportedMethods: [], + notes: [ + 'eth_feeHistory is available for wallet fee estimation.', + 'eth_maxPriorityFeePerGas is exposed on the public RPC for wallet-grade fee suggestion compatibility.', + ], + }, + tracing: { + supportedMethods: ['trace_block', 'trace_replayBlockTransactions'], + unsupportedMethods: ['debug_traceBlockByNumber'], + notes: [ + 'TRACE support is enabled for explorer-grade indexing and internal transaction analysis.', + 'Debug tracing is intentionally not enabled on the public RPC tier.', + ], + }, +} + +function isTokenListToken(value: unknown): value is TokenListToken { + if (!value || typeof value !== 'object') return false + + const candidate = value as Partial + return ( + typeof candidate.chainId === 'number' && + typeof candidate.address === 'string' && + candidate.address.trim().length > 0 && + typeof candidate.name === 'string' && + typeof candidate.symbol === 'string' && + typeof candidate.decimals === 'number' + ) +} + +function isCapabilitiesCatalog(value: unknown): value is CapabilitiesCatalog { + if (!value || typeof value !== 'object') return false + + const candidate = value as Partial + return ( + typeof candidate.chainId === 'number' && + typeof candidate.chainName === 'string' && + candidate.chainName.trim().length > 0 && + typeof candidate.rpcUrl === 'string' && + candidate.rpcUrl.trim().length > 0 + ) +} + function getApiBase() { return resolveExplorerApiBase({ serverFallback: 'https://explorer.d-bis.org', @@ -152,6 +233,10 @@ export function AddToMetaMask() { const tokenListUrl = `${apiBase}/api/config/token-list` const networksUrl = `${apiBase}/api/config/networks` const capabilitiesUrl = `${apiBase}/api/config/capabilities` + const staticCapabilitiesUrl = + typeof window !== 'undefined' + ? `${window.location.origin.replace(/\/$/, '')}/config/CHAIN138_RPC_CAPABILITIES.json` + : `${apiBase}/config/CHAIN138_RPC_CAPABILITIES.json` useEffect(() => { let active = true @@ -180,21 +265,46 @@ export function AddToMetaMask() { fetchJson(capabilitiesUrl), ]) + let resolvedCapabilities = capabilitiesResponse + if (!isCapabilitiesCatalog(resolvedCapabilities.json)) { + const staticCapabilitiesResponse = await fetchJson(staticCapabilitiesUrl) + if (isCapabilitiesCatalog(staticCapabilitiesResponse.json)) { + resolvedCapabilities = { + json: staticCapabilitiesResponse.json, + meta: { + source: staticCapabilitiesResponse.meta.source || 'public-static-fallback', + lastModified: staticCapabilitiesResponse.meta.lastModified, + }, + } + } else { + resolvedCapabilities = { + json: FALLBACK_CAPABILITIES_138, + meta: { + source: 'frontend-fallback', + lastModified: FALLBACK_CAPABILITIES_138.timestamp || null, + }, + } + } + } + if (!active) return setNetworks(networksResponse.json) setTokenList(tokenListResponse.json) - setCapabilities(capabilitiesResponse.json) + setCapabilities(resolvedCapabilities.json) setNetworksMeta(networksResponse.meta) setTokenListMeta(tokenListResponse.meta) - setCapabilitiesMeta(capabilitiesResponse.meta) + setCapabilitiesMeta(resolvedCapabilities.meta) } catch { if (!active) return setNetworks(null) setTokenList(null) - setCapabilities(null) + setCapabilities(FALLBACK_CAPABILITIES_138) setNetworksMeta(null) setTokenListMeta(null) - setCapabilitiesMeta(null) + setCapabilitiesMeta({ + source: 'frontend-fallback', + lastModified: FALLBACK_CAPABILITIES_138.timestamp || null, + }) } finally { if (active) { timer = setTimeout(() => { @@ -210,7 +320,12 @@ export function AddToMetaMask() { active = false if (timer) clearTimeout(timer) } - }, [capabilitiesUrl, networksUrl, tokenListUrl]) + }, [capabilitiesUrl, networksUrl, staticCapabilitiesUrl, tokenListUrl]) + + const catalogTokens = useMemo( + () => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []), + [tokenList], + ) const chains = useMemo(() => { const chainMap = new Map() @@ -230,7 +345,7 @@ export function AddToMetaMask() { const featuredTokens = useMemo(() => { const tokenMap = new Map() - for (const token of tokenList?.tokens || []) { + for (const token of catalogTokens) { if (token.chainId !== 138) continue if (!FEATURED_TOKEN_SYMBOLS.includes(token.symbol)) continue tokenMap.set(token.symbol, token) @@ -239,7 +354,7 @@ export function AddToMetaMask() { return FEATURED_TOKEN_SYMBOLS .map((symbol) => tokenMap.get(symbol)) .filter((token): token is TokenListToken => !!token) - }, [tokenList]) + }, [catalogTokens]) const addChain = async (chain: WalletChain) => { setError(null) @@ -304,6 +419,11 @@ export function AddToMetaMask() { setError(null) setStatus(null) + if (!isTokenListToken(token)) { + setError('Token metadata is incomplete right now. Refresh the page and try again.') + return + } + if (!ethereum) { setError('MetaMask or another Web3 wallet is not installed.') return @@ -312,7 +432,7 @@ export function AddToMetaMask() { try { const added = await ethereum.request({ method: 'wallet_watchAsset', - params: [{ + params: { type: 'ERC20', options: { address: token.address, @@ -320,7 +440,7 @@ export function AddToMetaMask() { decimals: token.decimals, image: token.logoURI, }, - }], + }, }) setStatus(added ? `Added ${token.symbol} to your wallet.` : `${token.symbol} request was dismissed.`) @@ -342,11 +462,15 @@ export function AddToMetaMask() { } } - const tokenCount138 = (tokenList?.tokens || []).filter((token) => token.chainId === 138).length + const tokenCount138 = catalogTokens.filter((token) => token.chainId === 138).length const metadataKeywordString = (tokenList?.keywords || []).join(', ') const supportedHTTPMethods = capabilities?.http?.supportedMethods || [] const unsupportedHTTPMethods = capabilities?.http?.unsupportedMethods || [] const supportedTraceMethods = capabilities?.tracing?.supportedMethods || [] + const displayedCapabilitiesUrl = + capabilitiesMeta?.source === 'public-static-fallback' || capabilitiesMeta?.source === 'frontend-fallback' + ? staticCapabilitiesUrl + : capabilitiesUrl return (
    @@ -432,12 +556,12 @@ export function AddToMetaMask() {

    Capabilities URL

    - {capabilitiesUrl} + {displayedCapabilitiesUrl}
    - - + Open JSON
    diff --git a/frontend/src/data/explorerOperations.ts b/frontend/src/data/explorerOperations.ts new file mode 100644 index 0000000..b17db38 --- /dev/null +++ b/frontend/src/data/explorerOperations.ts @@ -0,0 +1,307 @@ +export interface ExplorerFeatureAction { + title: string + description: string + href: string + label: string + external?: boolean +} + +export interface ExplorerFeaturePage { + eyebrow: string + title: string + description: string + note?: string + actions: ExplorerFeatureAction[] +} + +const legacyNote = + 'These tools were restored in the legacy explorer asset first. The live Next explorer now exposes them here so they are reachable from the public UI without falling back to hidden static routes.' + +export const explorerFeaturePages = { + bridge: { + eyebrow: 'Bridge Monitoring', + title: 'Bridge & Relay Monitoring', + description: + 'Inspect the CCIP relay status, follow the live mission-control stream, trace bridge transactions, and review the managed Mainnet, BSC, Avalanche, Avalanche cW, and Avalanche to Chain 138 lanes.', + note: legacyNote, + actions: [ + { + title: 'Mission-control live stream', + description: 'Open the server-sent event stream that powers live relay and RPC monitoring.', + href: '/explorer-api/v1/mission-control/stream', + label: 'Open SSE stream', + external: true, + }, + { + title: 'Bridge status snapshot', + description: 'Review the current relay health payload, queue posture, and destination summary.', + href: '/explorer-api/v1/track1/bridge/status', + label: 'Open status JSON', + external: true, + }, + { + title: 'Reverse AVAX lane', + description: 'Check the managed Avalanche cW burn-back lane to Chain 138 that now runs as its own relay service.', + href: '/explorer-api/v1/track1/bridge/status', + label: 'Review AVAX -> 138 lane', + external: true, + }, + { + title: 'Bridge trace API', + description: 'Resolve source and destination addresses for a bridge transaction through mission control.', + href: '/explorer-api/v1/mission-control/bridge/trace?tx=0x2f31d4f9a97be754b800f4af1a9eedf3b107d353bfa1a19e81417497a76c05c2', + label: 'Open trace example', + external: true, + }, + { + title: 'Visual command center', + description: 'Open the interactive topology map for Chain 138, CCIP, Alltra, and adjacent integrations.', + href: '/chain138-command-center.html', + label: 'Open command center', + external: true, + }, + { + title: 'Routes & liquidity', + description: 'Move from bridge health into route coverage, pools, and execution access points.', + href: '/routes', + label: 'Open routes page', + }, + ], + }, + routes: { + eyebrow: 'Route Coverage', + title: 'Routes, Pools, and Execution Access', + description: + 'Surface the route matrix, live pool inventory, public liquidity endpoints, and bridge-adjacent execution paths that were previously only visible in the legacy explorer shell.', + note: legacyNote, + actions: [ + { + title: 'Liquidity access', + description: 'Review the public Chain 138 PMM access points, route helpers, and fallback execution endpoints.', + href: '/liquidity', + label: 'Open liquidity access', + }, + { + title: 'Pools inventory', + description: 'Jump to the pool overview page for quick PMM route and asset discovery.', + href: '/pools', + label: 'Open pools page', + }, + { + title: 'Liquidity mission-control example', + description: 'Open a live mission-control liquidity lookup for a canonical Chain 138 token.', + href: '/explorer-api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools', + label: 'Open liquidity JSON', + external: true, + }, + { + title: 'Bridge monitoring', + description: 'Cross-check route availability with live relay and bridge health before operator actions.', + href: '/bridge', + label: 'Open bridge monitoring', + }, + { + title: 'Operations hub', + description: 'Open the consolidated page for WETH utilities, analytics, operator shortcuts, and system views.', + href: '/more', + label: 'Open operations hub', + }, + ], + }, + weth: { + eyebrow: 'WETH Utilities', + title: 'WETH Utilities & Bridge References', + description: + 'Reach the WETH-focused tooling that operators use during support and bridge investigation without depending on the hidden legacy explorer navigation.', + note: legacyNote, + actions: [ + { + title: 'Bridge monitoring', + description: 'Start with relay and bridge health before reviewing WETH-specific flows.', + href: '/bridge', + label: 'Open bridge monitoring', + }, + { + title: 'Visual command center', + description: 'Use the interactive topology map for contract placement, hub flow, and system context.', + href: '/chain138-command-center.html', + label: 'Open command center', + external: true, + }, + { + title: 'Wallet tools', + description: 'Open the wallet page if you need supported network and token setup before testing flows.', + href: '/wallet', + label: 'Open wallet tools', + }, + { + title: 'Operations hub', + description: 'Return to the larger operations landing page for adjacent route, analytics, and system shortcuts.', + href: '/more', + label: 'Open operations hub', + }, + ], + }, + analytics: { + eyebrow: 'Analytics Access', + title: 'Analytics & Network Activity', + description: + 'Use the public explorer pages and live monitoring endpoints as the visible analytics surface for chain activity, recent blocks, and transaction flow.', + note: legacyNote, + actions: [ + { + title: 'Blocks', + description: 'Inspect recent block production, timestamps, and miner attribution.', + href: '/blocks', + label: 'Open blocks', + }, + { + title: 'Transactions', + description: 'Review recent transactions, status, and linked address flow.', + href: '/transactions', + label: 'Open transactions', + }, + { + title: 'Addresses', + description: 'Browse saved and active addresses as part of the explorer activity surface.', + href: '/addresses', + label: 'Open addresses', + }, + { + title: 'Mission-control stream', + description: 'Supplement the explorer pages with the live relay and RPC event feed.', + href: '/explorer-api/v1/mission-control/stream', + label: 'Open SSE stream', + external: true, + }, + ], + }, + operator: { + eyebrow: 'Operator Shortcuts', + title: 'Operator Panel Shortcuts', + description: + 'Expose the public operational shortcuts that were restored in the legacy explorer for bridge checks, route validation, liquidity entry points, and documentation.', + note: legacyNote, + actions: [ + { + title: 'Bridge monitoring', + description: 'Open relay status, queue posture, and bridge trace tools.', + href: '/bridge', + label: 'Open bridge monitoring', + }, + { + title: 'Routes', + description: 'Inspect route coverage and liquidity path access before operator intervention.', + href: '/routes', + label: 'Open routes page', + }, + { + title: 'Liquidity access', + description: 'Open partner payload helpers, route APIs, and execution-plan endpoints.', + href: '/liquidity', + label: 'Open liquidity access', + }, + { + title: 'Explorer docs', + description: 'Use the static documentation landing page for explorer-specific reference material.', + href: '/docs.html', + label: 'Open docs', + external: true, + }, + { + title: 'Visual command center', + description: 'Open the graphical deployment and integration topology in a dedicated page.', + href: '/chain138-command-center.html', + label: 'Open command center', + external: true, + }, + ], + }, + system: { + eyebrow: 'System Topology', + title: 'System & Topology', + description: + 'Jump straight into the public topology and reference surfaces that describe how Chain 138, bridge monitoring, and adjacent systems fit together.', + note: legacyNote, + actions: [ + { + title: 'Visual command center', + description: 'Open the topology map for Chain 138, CCIP, Alltra, OP Stack, and service flows.', + href: '/chain138-command-center.html', + label: 'Open command center', + external: true, + }, + { + title: 'Bridge monitoring', + description: 'Correlate topology context with the live bridge and relay status surface.', + href: '/bridge', + label: 'Open bridge monitoring', + }, + { + title: 'Explorer docs', + description: 'Open the documentation landing page for static reference material shipped with the explorer.', + href: '/docs.html', + label: 'Open docs', + external: true, + }, + { + title: 'Operations hub', + description: 'Return to the consolidated operations landing page for adjacent public tools.', + href: '/more', + label: 'Open operations hub', + }, + ], + }, + more: { + eyebrow: 'Operations Hub', + title: 'More Explorer Tools', + description: + 'This hub exposes the restored public tools that were previously buried in the legacy explorer shell: bridge monitoring, routes, WETH utilities, analytics shortcuts, operator links, and topology views.', + note: legacyNote, + actions: [ + { + title: 'Bridge & relay monitoring', + description: 'Open mission-control status, SSE monitoring, and bridge trace helpers.', + href: '/bridge', + label: 'Open bridge monitoring', + }, + { + title: 'Routes & liquidity', + description: 'Open route coverage, pools, and public liquidity access points.', + href: '/routes', + label: 'Open routes page', + }, + { + title: 'WETH utilities', + description: 'Open the WETH-focused landing page and bridge-adjacent shortcuts.', + href: '/weth', + label: 'Open WETH utilities', + }, + { + title: 'Analytics', + description: 'Open the public analytics landing page for blocks, transactions, and live monitoring.', + href: '/analytics', + label: 'Open analytics page', + }, + { + title: 'Operator panel shortcuts', + description: 'Open the operator landing page for bridge, route, liquidity, and docs shortcuts.', + href: '/operator', + label: 'Open operator page', + }, + { + title: 'System topology', + description: 'Open the system landing page for topology references and command-center access.', + href: '/system', + label: 'Open system page', + }, + { + title: 'Visual command center', + description: 'Open the dedicated interactive topology asset in a new tab.', + href: '/chain138-command-center.html', + label: 'Open command center', + external: true, + }, + ], + }, +} as const satisfies Record diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 46bb578..45f1791 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -1,7 +1,11 @@ import type { AppProps } from 'next/app' import '../app/globals.css' +import ExplorerChrome from '@/components/common/ExplorerChrome' export default function App({ Component, pageProps }: AppProps) { - return + return ( + + + + ) } - diff --git a/frontend/src/pages/addresses/[address].tsx b/frontend/src/pages/addresses/[address].tsx index f7ef108..14d7399 100644 --- a/frontend/src/pages/addresses/[address].tsx +++ b/frontend/src/pages/addresses/[address].tsx @@ -5,6 +5,14 @@ import { useRouter } from 'next/router' import { Card, Table, Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' import { addressesApi, AddressInfo, TransactionSummary } from '@/services/api/addresses' +import { formatWeiAsEth } from '@/utils/format' +import { DetailRow } from '@/components/common/DetailRow' +import { + isWatchlistEntry, + readWatchlistFromStorage, + writeWatchlistToStorage, + normalizeWatchlistAddress, +} from '@/utils/watchlist' export default function AddressDetailPage() { const router = useRouter() @@ -13,6 +21,7 @@ export default function AddressDetailPage() { const [addressInfo, setAddressInfo] = useState(null) const [transactions, setTransactions] = useState([]) + const [watchlistEntries, setWatchlistEntries] = useState([]) const [loading, setLoading] = useState(true) const loadAddressInfo = useCallback(async () => { @@ -54,16 +63,39 @@ export default function AddressDetailPage() { loadTransactions() }, [address, loadAddressInfo, loadTransactions, router.isReady]) - if (!router.isReady) { - return
    Loading address...
    - } + useEffect(() => { + if (typeof window === 'undefined') { + return + } - if (loading) { - return
    Loading address...
    - } + try { + setWatchlistEntries(readWatchlistFromStorage(window.localStorage)) + } catch { + setWatchlistEntries([]) + } + }, []) - if (!addressInfo) { - return
    Address not found
    + const watchlistAddress = normalizeWatchlistAddress(addressInfo?.address || address) + const isSavedToWatchlist = watchlistAddress + ? isWatchlistEntry(watchlistEntries, watchlistAddress) + : false + + const handleWatchlistToggle = () => { + if (!watchlistAddress || typeof window === 'undefined') { + return + } + + setWatchlistEntries((current) => { + const next = isSavedToWatchlist + ? current.filter((entry) => entry.toLowerCase() !== watchlistAddress.toLowerCase()) + : [...current, watchlistAddress] + + try { + writeWatchlistToStorage(window.localStorage, next) + } catch {} + + return next + }) } const transactionColumns = [ @@ -71,7 +103,7 @@ export default function AddressDetailPage() { header: 'Hash', accessor: (tx: TransactionSummary) => ( -
    +
    ), }, @@ -87,17 +119,13 @@ export default function AddressDetailPage() { header: 'To', accessor: (tx: TransactionSummary) => tx.to_address ? ( -
    +
    ) : 'Contract Creation', }, { header: 'Value', - accessor: (tx: TransactionSummary) => { - const value = BigInt(tx.value) - const eth = Number(value) / 1e18 - return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH' - }, + accessor: (tx: TransactionSummary) => formatWeiAsEth(tx.value), }, { header: 'Status', @@ -110,56 +138,74 @@ export default function AddressDetailPage() { ] return ( -
    -

    - {addressInfo.label || 'Address'} +
    +

    + {addressInfo?.label || 'Address'}

    Back to addresses - - Search this address - + {(addressInfo?.address || address) && ( + + Search this address + + )} + {watchlistAddress && router.isReady && !loading && ( + + )}
    - -
    -
    - Address: -
    -
    - {addressInfo.tags.length > 0 && ( -
    - Tags: -
    - {addressInfo.tags.map((tag, i) => ( - - {tag} - - ))} -
    -
    - )} -
    - Transactions: - {addressInfo.transaction_count} -
    -
    - Tokens: - {addressInfo.token_count} -
    -
    - Type: - {addressInfo.is_contract ? 'Contract' : 'EOA'} -
    -
    -
    + {!router.isReady || loading ? ( + +

    Loading address...

    +
    + ) : !addressInfo ? ( + +

    Address not found.

    +
    + ) : ( + <> + +
    + +
    + + + {isSavedToWatchlist ? 'Saved for quick access' : 'Not saved yet'} + + {addressInfo.tags.length > 0 && ( + + {addressInfo.tags.map((tag, i) => ( + + {tag} + + ))} + + )} + {addressInfo.transaction_count} + {addressInfo.token_count} + {addressInfo.is_contract ? 'Contract' : 'EOA'} +
    +
    - - tx.hash} /> - + +
    tx.hash} + /> + + + )} ) } diff --git a/frontend/src/pages/addresses/index.tsx b/frontend/src/pages/addresses/index.tsx index 6761951..4112a65 100644 --- a/frontend/src/pages/addresses/index.tsx +++ b/frontend/src/pages/addresses/index.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/router' import { Card, Address } from '@/libs/frontend-ui-primitives' import { transactionsApi, Transaction } from '@/services/api/transactions' +import { readWatchlistFromStorage } from '@/utils/watchlist' function normalizeAddress(value: string) { const trimmed = value.trim() @@ -35,10 +36,12 @@ export default function AddressesPage() { }, [chainId]) useEffect(() => { + if (typeof window === 'undefined') { + return + } + try { - const raw = window.localStorage.getItem('explorerWatchlist') - const entries = raw ? JSON.parse(raw) : [] - setWatchlist(Array.isArray(entries) ? entries.filter((entry): entry is string => typeof entry === 'string') : []) + setWatchlist(readWatchlistFromStorage(window.localStorage)) } catch { setWatchlist([]) } @@ -70,8 +73,8 @@ export default function AddressesPage() { } return ( -
    -

    Addresses

    +
    +

    Addresses

    @@ -85,7 +88,7 @@ export default function AddressesPage() { @@ -105,7 +108,7 @@ export default function AddressesPage() {
    {watchlist.map((entry) => ( -
    +
    ))}
    @@ -126,7 +129,7 @@ export default function AddressesPage() {
    {activeAddresses.map((entry) => ( -
    +
    ))}
    diff --git a/frontend/src/pages/analytics/index.tsx b/frontend/src/pages/analytics/index.tsx new file mode 100644 index 0000000..47dee31 --- /dev/null +++ b/frontend/src/pages/analytics/index.tsx @@ -0,0 +1,9 @@ +import dynamic from 'next/dynamic' + +const AnalyticsOperationsPage = dynamic(() => import('@/components/explorer/AnalyticsOperationsPage'), { + ssr: false, +}) + +export default function AnalyticsPage() { + return +} diff --git a/frontend/src/pages/blocks/[number].tsx b/frontend/src/pages/blocks/[number].tsx index 9cb8a94..df9f612 100644 --- a/frontend/src/pages/blocks/[number].tsx +++ b/frontend/src/pages/blocks/[number].tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/router' import { blocksApi, Block } from '@/services/api/blocks' import { Card, Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' +import { DetailRow } from '@/components/common/DetailRow' export default function BlockDetailPage() { const router = useRouter() @@ -40,68 +41,63 @@ export default function BlockDetailPage() { loadBlock() }, [isValidBlock, loadBlock, router.isReady]) - if (!router.isReady) { - return
    Loading block...
    - } - - if (!isValidBlock) { - return
    Invalid block number. Please use a valid block number from the URL.
    - } - - if (loading) { - return
    Loading block...
    - } - - if (!block) { - return
    Block not found
    - } - return ( -
    -

    Block #{block.number}

    +
    +

    {block ? `Block #${block.number}` : 'Block'}

    Back to blocks - {block.number > 0 ? ( + {block && block.number > 0 ? ( Previous block ) : null} - - Next block - + {block && ( + + Next block + + )}
    - -
    -
    - Hash: -
    -
    -
    - Timestamp: - {new Date(block.timestamp).toLocaleString()} -
    -
    - Miner: - -
    - -
    -
    - Transactions: - - {block.transaction_count} - -
    -
    - Gas Used: - {block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()} -
    -
    -
    + {!router.isReady || loading ? ( + +

    Loading block...

    +
    + ) : !isValidBlock ? ( + +

    Invalid block number. Please use a valid block number from the URL.

    +
    + ) : !block ? ( + +

    Block not found.

    +
    + ) : ( + +
    + +
    + + + {new Date(block.timestamp).toLocaleString()} + + + +
    + + + + + {block.transaction_count} + + + + {block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()} + +
    +
    + )}
    ) } diff --git a/frontend/src/pages/blocks/index.tsx b/frontend/src/pages/blocks/index.tsx index e0ccd1e..5d7da9f 100644 --- a/frontend/src/pages/blocks/index.tsx +++ b/frontend/src/pages/blocks/index.tsx @@ -6,6 +6,7 @@ import { Card, Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' export default function BlocksPage() { + const pageSize = 20 const [blocks, setBlocks] = useState([]) const [loading, setLoading] = useState(true) const [page, setPage] = useState(1) @@ -17,75 +18,89 @@ export default function BlocksPage() { const response = await blocksApi.list({ chain_id: chainId, page, - page_size: 20, + page_size: pageSize, sort: 'number', order: 'desc', }) setBlocks(response.data) } catch (error) { console.error('Failed to load blocks:', error) + setBlocks([]) } finally { setLoading(false) } - }, [chainId, page]) + }, [chainId, page, pageSize]) useEffect(() => { loadBlocks() }, [loadBlocks]) - if (loading) { - return
    Loading blocks...
    - } + const showPagination = page > 1 || blocks.length > 0 + const canGoNext = blocks.length === pageSize return ( -
    -

    Blocks

    +
    +

    Blocks

    -
    - {blocks.map((block) => ( - -
    -
    - - Block #{block.number} - -
    -
    + {loading ? ( + +

    Loading blocks...

    +
    + ) : ( +
    + {blocks.length === 0 ? ( + +

    Recent blocks are unavailable right now.

    +
    + ) : ( + blocks.map((block) => ( + +
    +
    + + Block #{block.number} + +
    +
    +
    +
    +
    +
    + {new Date(block.timestamp).toLocaleString()} +
    +
    + {block.transaction_count} transactions +
    +
    -
    -
    -
    - {new Date(block.timestamp).toLocaleString()} -
    -
    - {block.transaction_count} transactions -
    -
    -
    - - ))} -
    + + )) + )} +
    + )} -
    - - Page {page} - -
    + {showPagination && ( +
    + + Page {page} + +
    + )}
    ) } - diff --git a/frontend/src/pages/bridge/index.tsx b/frontend/src/pages/bridge/index.tsx new file mode 100644 index 0000000..05b4e44 --- /dev/null +++ b/frontend/src/pages/bridge/index.tsx @@ -0,0 +1,9 @@ +import dynamic from 'next/dynamic' + +const BridgeMonitoringPage = dynamic(() => import('@/components/explorer/BridgeMonitoringPage'), { + ssr: false, +}) + +export default function BridgePage() { + return +} diff --git a/frontend/src/pages/home/index.tsx b/frontend/src/pages/home/index.tsx new file mode 100644 index 0000000..ab831aa --- /dev/null +++ b/frontend/src/pages/home/index.tsx @@ -0,0 +1,28 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect } from 'react' + +export default function HomeAliasPage() { + const router = useRouter() + + useEffect(() => { + void router.replace('/') + }, [router]) + + return ( +
    +
    +

    Redirecting to SolaceScanScout

    +

    + The legacy /home route now redirects to the main explorer landing page. +

    + + Continue to the explorer + +
    +
    + ) +} diff --git a/frontend/src/pages/more/index.tsx b/frontend/src/pages/more/index.tsx new file mode 100644 index 0000000..ad7376c --- /dev/null +++ b/frontend/src/pages/more/index.tsx @@ -0,0 +1,9 @@ +import dynamic from 'next/dynamic' + +const MoreOperationsPage = dynamic(() => import('@/components/explorer/MoreOperationsPage'), { + ssr: false, +}) + +export default function MorePage() { + return +} diff --git a/frontend/src/pages/operator/index.tsx b/frontend/src/pages/operator/index.tsx new file mode 100644 index 0000000..4339bb2 --- /dev/null +++ b/frontend/src/pages/operator/index.tsx @@ -0,0 +1,9 @@ +import dynamic from 'next/dynamic' + +const OperatorOperationsPage = dynamic(() => import('@/components/explorer/OperatorOperationsPage'), { + ssr: false, +}) + +export default function OperatorPage() { + return +} diff --git a/frontend/src/pages/pools/index.tsx b/frontend/src/pages/pools/index.tsx index 8889dd9..8423e16 100644 --- a/frontend/src/pages/pools/index.tsx +++ b/frontend/src/pages/pools/index.tsx @@ -1,88 +1,7 @@ 'use client' -import Link from 'next/link' -import { Card } from '@/libs/frontend-ui-primitives' - -const poolCards = [ - { - title: 'Canonical PMM routes', - description: 'Review the public Chain 138 DODO PMM route matrix, live pool freshness, and payload examples.', - href: '/liquidity', - label: 'Open liquidity access', - }, - { - title: 'Wallet Funding Path', - description: 'Open wallet tools first if you need Chain 138 setup, token import links, or a quick route into supported assets.', - href: '/wallet', - label: 'Open wallet tools', - }, - { - title: 'Explorer Docs', - description: 'Static documentation covers the live pool map, expected web content, and route access details.', - href: '/docs.html', - label: 'Open docs landing page', - external: true, - }, -] - -const shortcutCards = [ - { - title: 'cUSDT / USDT', - description: 'Open the canonical direct stable route coverage and compare the live pool snapshot.', - href: '/liquidity', - }, - { - title: 'cUSDC / USDC', - description: 'Check the public stable bridge route and inspect the live reserves block.', - href: '/liquidity', - }, - { - title: 'cUSDT / cXAUC', - description: 'Review one of the live gold-backed route families from the liquidity access page.', - href: '/liquidity', - }, -] +import PoolsOperationsPage from '@/components/explorer/PoolsOperationsPage' export default function PoolsPage() { - return ( -
    -

    Pools

    - -
    - {poolCards.map((card) => ( - -

    {card.description}

    -
    - {card.external ? ( - - {card.label} → - - ) : ( - - {card.label} → - - )} -
    -
    - ))} -
    - -
    - -
    - {shortcutCards.map((card) => ( - -
    {card.title}
    -

    {card.description}

    - - ))} -
    -
    -
    -
    - ) + return } diff --git a/frontend/src/pages/routes/index.tsx b/frontend/src/pages/routes/index.tsx new file mode 100644 index 0000000..8083c72 --- /dev/null +++ b/frontend/src/pages/routes/index.tsx @@ -0,0 +1,9 @@ +import dynamic from 'next/dynamic' + +const RoutesMonitoringPage = dynamic(() => import('@/components/explorer/RoutesMonitoringPage'), { + ssr: false, +}) + +export default function RoutesPage() { + return +} diff --git a/frontend/src/pages/search/index.tsx b/frontend/src/pages/search/index.tsx index 5a09f5c..6bd718c 100644 --- a/frontend/src/pages/search/index.tsx +++ b/frontend/src/pages/search/index.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/router' import { Card, Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' import { getExplorerApiBase } from '@/services/api/blockscout' +import { inferDirectSearchTarget } from '@/utils/search' interface SearchResult { type: string @@ -24,17 +25,29 @@ export default function SearchPage() { const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [loading, setLoading] = useState(false) + const [hasSearched, setHasSearched] = useState(false) + const [error, setError] = useState(null) const runSearch = async (rawQuery: string) => { - if (!rawQuery.trim()) return + const trimmedQuery = rawQuery.trim() + if (!trimmedQuery) { + setHasSearched(false) + setResults([]) + setError(null) + return + } + + setHasSearched(true) setLoading(true) + setError(null) try { const response = await fetch( - `${getExplorerApiBase()}/api/v2/search?q=${encodeURIComponent(rawQuery)}` + `${getExplorerApiBase()}/api/v2/search?q=${encodeURIComponent(trimmedQuery)}` ) - const data = await response.json() + const data = await response.json().catch(() => null) if (!response.ok) { setResults([]) + setError('Search is temporarily unavailable right now.') return } const normalizedResults = Array.isArray(data?.items) @@ -59,6 +72,7 @@ export default function SearchPage() { } catch (error) { console.error('Search failed:', error) setResults([]) + setError('Search is temporarily unavailable right now.') } finally { setLoading(false) } @@ -73,15 +87,37 @@ export default function SearchPage() { const handleSearch = async (e: React.FormEvent) => { e.preventDefault() - await runSearch(query) + const trimmedQuery = query.trim() + if (!trimmedQuery) { + return + } + + const directTarget = inferDirectSearchTarget(trimmedQuery) + if (directTarget) { + void router.push(directTarget.href) + return + } + + void router.replace( + { + pathname: router.pathname, + query: { q: trimmedQuery }, + }, + undefined, + { shallow: true }, + ) + await runSearch(trimmedQuery) } + const trimmedQuery = query.trim() + const directTarget = inferDirectSearchTarget(trimmedQuery) + return ( -
    -

    Search

    +
    +

    Search

    - + + {!loading && error && ( + +

    {error}

    +
    + )} + + {!loading && directTarget && ( + +

    + This looks like a direct explorer identifier. You can open it without waiting for indexed search results. +

    +
    + + {directTarget.label} → + +
    +
    + )} + {results.length > 0 && (
    {results.map((result, index) => ( -
    +
    {result.type === 'block' && result.data.number && ( - + + Block Block #{result.data.number} )} {result.type === 'transaction' && result.data.hash && ( - - Transaction
    + + Transaction +
    )} {result.type === 'address' && result.data.address && ( - - Address
    + + Address +
    )} -
    - Type: {result.type} | Chain: {result.chain_id ?? 138} | Score: {(result.score ?? 0).toFixed(2)} +
    + Type: {result.type} + Chain: {result.chain_id ?? 138} + Score: {(result.score ?? 0).toFixed(2)}
    ))}
    )} + + {!loading && hasSearched && !error && results.length === 0 && ( + +

    + No explorer results matched {trimmedQuery}. + Try a full address, transaction hash, token symbol, or block number. +

    +
    + )}
    ) } diff --git a/frontend/src/pages/system/index.tsx b/frontend/src/pages/system/index.tsx new file mode 100644 index 0000000..a131209 --- /dev/null +++ b/frontend/src/pages/system/index.tsx @@ -0,0 +1,9 @@ +import dynamic from 'next/dynamic' + +const SystemOperationsPage = dynamic(() => import('@/components/explorer/SystemOperationsPage'), { + ssr: false, +}) + +export default function SystemPage() { + return +} diff --git a/frontend/src/pages/tokens/index.tsx b/frontend/src/pages/tokens/index.tsx index c551c55..ccb3227 100644 --- a/frontend/src/pages/tokens/index.tsx +++ b/frontend/src/pages/tokens/index.tsx @@ -45,7 +45,7 @@ export default function TokensPage() { diff --git a/frontend/src/pages/transactions/[hash].tsx b/frontend/src/pages/transactions/[hash].tsx index d45750b..be461b4 100644 --- a/frontend/src/pages/transactions/[hash].tsx +++ b/frontend/src/pages/transactions/[hash].tsx @@ -5,6 +5,8 @@ import { useRouter } from 'next/router' import { Card, Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' import { transactionsApi, Transaction } from '@/services/api/transactions' +import { formatWeiAsEth } from '@/utils/format' +import { DetailRow } from '@/components/common/DetailRow' export default function TransactionDetailPage() { const router = useRouter() @@ -42,92 +44,76 @@ export default function TransactionDetailPage() { loadTransaction() }, [hash, loadTransaction, router.isReady]) - if (!router.isReady) { - return
    Loading transaction...
    - } - - if (loading) { - return
    Loading transaction...
    - } - - if (!transaction) { - return
    Transaction not found
    - } - - const value = BigInt(transaction.value) - const ethValue = Number(value) / 1e18 - return ( -
    -

    Transaction

    +
    +

    Transaction

    Back to transactions - - Search this hash - + {(transaction?.hash || hash) && ( + + Search this hash + + )}
    - -
    -
    - Hash: -
    -
    -
    - Block: - - #{transaction.block_number} - -
    -
    - From: - -
    - -
    - {transaction.to_address && ( -
    - To: - -
    + {!router.isReady || loading ? ( + +

    Loading transaction...

    +
    + ) : !transaction ? ( + +

    Transaction not found.

    +
    + ) : ( + +
    + +
    + + + + #{transaction.block_number} -
    - )} -
    - Value: - {ethValue.toFixed(4)} ETH -
    - {transaction.gas_price && ( -
    - Gas Price: - {transaction.gas_price / 1e9} Gwei -
    - )} - {transaction.gas_used && ( -
    - Gas Used: - {transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()} -
    - )} -
    - Status: - - {transaction.status === 1 ? 'Success' : 'Failed'} - -
    - {transaction.contract_address && ( -
    - Contract Created: - -
    + + + +
    -
    - )} -
    -
    + + {transaction.to_address && ( + + +
    + + + )} + {formatWeiAsEth(transaction.value)} + + {transaction.gas_price != null ? `${transaction.gas_price / 1e9} Gwei` : 'N/A'} + + {transaction.gas_used != null && ( + + {transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()} + + )} + + + {transaction.status === 1 ? 'Success' : 'Failed'} + + + {transaction.contract_address && ( + + +
    + + + )} + + + )}
    ) } diff --git a/frontend/src/pages/transactions/index.tsx b/frontend/src/pages/transactions/index.tsx index 55ffd69..6a820bd 100644 --- a/frontend/src/pages/transactions/index.tsx +++ b/frontend/src/pages/transactions/index.tsx @@ -1,11 +1,13 @@ 'use client' import { useCallback, useEffect, useState } from 'react' -import { Table, Address } from '@/libs/frontend-ui-primitives' +import { Card, Table, Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' import { transactionsApi, Transaction } from '@/services/api/transactions' +import { formatWeiAsEth } from '@/utils/format' export default function TransactionsPage() { + const pageSize = 20 const [transactions, setTransactions] = useState([]) const [loading, setLoading] = useState(true) const [page, setPage] = useState(1) @@ -14,7 +16,7 @@ export default function TransactionsPage() { const loadTransactions = useCallback(async () => { setLoading(true) try { - const { ok, data } = await transactionsApi.listSafe(chainId, page, 20) + const { ok, data } = await transactionsApi.listSafe(chainId, page, pageSize) setTransactions(ok ? data : []) } catch (error) { console.error('Failed to load transactions:', error) @@ -22,18 +24,21 @@ export default function TransactionsPage() { } finally { setLoading(false) } - }, [chainId, page]) + }, [chainId, page, pageSize]) useEffect(() => { loadTransactions() }, [loadTransactions]) + const showPagination = page > 1 || transactions.length > 0 + const canGoNext = transactions.length === pageSize + const columns = [ { header: 'Hash', accessor: (tx: Transaction) => ( -
    +
    ), }, @@ -49,7 +54,7 @@ export default function TransactionsPage() { header: 'From', accessor: (tx: Transaction) => ( -
    +
    ), }, @@ -57,17 +62,13 @@ export default function TransactionsPage() { header: 'To', accessor: (tx: Transaction) => tx.to_address ? ( -
    +
    ) : Contract Creation, }, { header: 'Value', - accessor: (tx: Transaction) => { - const value = BigInt(tx.value) - const eth = Number(value) / 1e18 - return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH' - }, + accessor: (tx: Transaction) => formatWeiAsEth(tx.value), }, { header: 'Status', @@ -79,32 +80,42 @@ export default function TransactionsPage() { }, ] - if (loading) { - return
    Loading transactions...
    - } - return ( -
    -

    Transactions

    +
    +

    Transactions

    -
    tx.hash} /> + {loading ? ( + +

    Loading transactions...

    +
    + ) : ( +
    tx.hash} + /> + )} -
    - - Page {page} - -
    + {showPagination && ( +
    + + Page {page} + +
    + )} ) } diff --git a/frontend/src/pages/watchlist/index.tsx b/frontend/src/pages/watchlist/index.tsx index 375f7f2..8136f04 100644 --- a/frontend/src/pages/watchlist/index.tsx +++ b/frontend/src/pages/watchlist/index.tsx @@ -3,15 +3,22 @@ import Link from 'next/link' import { useEffect, useState } from 'react' import { Card, Address } from '@/libs/frontend-ui-primitives' +import { + readWatchlistFromStorage, + writeWatchlistToStorage, + sanitizeWatchlistEntries, +} from '@/utils/watchlist' export default function WatchlistPage() { const [entries, setEntries] = useState([]) useEffect(() => { + if (typeof window === 'undefined') { + return + } + try { - const raw = window.localStorage.getItem('explorerWatchlist') - const parsed = raw ? JSON.parse(raw) : [] - setEntries(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : []) + setEntries(readWatchlistFromStorage(window.localStorage)) } catch { setEntries([]) } @@ -21,13 +28,17 @@ export default function WatchlistPage() { setEntries((current) => { const next = current.filter((entry) => entry.toLowerCase() !== address.toLowerCase()) try { - window.localStorage.setItem('explorerWatchlist', JSON.stringify(next)) + writeWatchlistToStorage(window.localStorage, next) } catch {} return next }) } const exportWatchlist = () => { + if (entries.length === 0) { + return + } + try { const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) @@ -45,12 +56,9 @@ export default function WatchlistPage() { file.text().then((text) => { try { - const parsed = JSON.parse(text) - const next = Array.isArray(parsed) - ? parsed.filter((entry): entry is string => typeof entry === 'string') - : [] + const next = sanitizeWatchlistEntries(JSON.parse(text)) setEntries(next) - window.localStorage.setItem('explorerWatchlist', JSON.stringify(next)) + writeWatchlistToStorage(window.localStorage, next) } catch {} }).catch(() => {}) @@ -58,8 +66,8 @@ export default function WatchlistPage() { } return ( -
    -

    Watchlist

    +
    +

    Watchlist

    @@ -75,7 +83,7 @@ export default function WatchlistPage() { type="button" onClick={exportWatchlist} disabled={entries.length === 0} - className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700 disabled:opacity-50" + className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 disabled:opacity-100 dark:disabled:bg-gray-700 dark:disabled:text-gray-400" > Export JSON @@ -98,7 +106,7 @@ export default function WatchlistPage() { {entries.map((entry) => (
    -
    +