refactor: rename SolaceScanScout to Solace and update related configurations

- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation.
- Changed default base URL for Playwright tests and updated security headers to reflect the new branding.
- Enhanced README and API documentation to include new authentication endpoints and product access details.

This refactor aligns the project branding and improves clarity in the API documentation.
This commit is contained in:
defiQUG
2026-04-10 12:52:17 -07:00
parent bdae5a9f6e
commit f46bd213ba
160 changed files with 13274 additions and 1061 deletions

View File

@@ -5,7 +5,7 @@
set -e set -e
echo "==========================================" echo "=========================================="
echo " SolaceScanScout Deployment" echo " SolaceScan Deployment"
echo "==========================================" echo "=========================================="
echo "" echo ""
@@ -140,4 +140,3 @@ echo " 3. Monitor: tail -f backend/logs/api-server.log"
echo "" echo ""
unset PGPASSWORD unset PGPASSWORD

View File

@@ -1,4 +1,4 @@
# SolaceScanScout Explorer - Tiered Architecture # SolaceScan Explorer - Tiered Architecture
## 🚀 Quick Start - Complete Deployment ## 🚀 Quick Start - Complete Deployment
@@ -75,7 +75,7 @@ See [docs/REUSABLE_COMPONENTS_EXTRACTION_PLAN.md](docs/REUSABLE_COMPONENTS_EXTRA
- **All unit/lint:** `make test` — backend `go test ./...` and frontend `npm test` (lint + type-check). - **All unit/lint:** `make test` — backend `go test ./...` and frontend `npm test` (lint + type-check).
- **Backend:** `cd backend && go test ./...` — API tests run without a real DB; health returns 200 or 503, DB-dependent endpoints return 503 when DB is nil. - **Backend:** `cd backend && go test ./...` — API tests run without a real DB; health returns 200 or 503, DB-dependent endpoints return 503 when DB is nil.
- **Frontend:** `cd frontend && npm run build` or `npm test` — Next.js build (includes lint) or lint + type-check only. - **Frontend:** `cd frontend && npm run build` or `npm test` — Next.js build (includes lint) or lint + type-check only.
- **E2E:** `make test-e2e` or `npm run e2e` from repo root — Playwright tests against https://explorer.d-bis.org by default; use `EXPLORER_URL=http://localhost:3000` for local. - **E2E:** `make test-e2e` or `npm run e2e` from repo root — Playwright tests against https://blockscout.defi-oracle.io by default; use `EXPLORER_URL=http://localhost:3000` for local.
## Status ## Status

View File

@@ -1,7 +1,7 @@
# Testing Guide # Testing Guide
## Backend API Testing Documentation ## Backend API Testing Documentation
This document describes the testing infrastructure for the SolaceScanScout backend. This document describes the testing infrastructure for the SolaceScan backend.
--- ---
@@ -226,4 +226,3 @@ jobs:
--- ---
**Last Updated**: $(date) **Last Updated**: $(date)

View File

@@ -78,7 +78,7 @@ func (g *Gateway) handleRequest(proxy *httputil.ReverseProxy) http.HandlerFunc {
} }
// Add branding header // Add branding header
w.Header().Set("X-Explorer-Name", "SolaceScanScout") w.Header().Set("X-Explorer-Name", "SolaceScan")
w.Header().Set("X-Explorer-Version", "1.0.0") w.Header().Set("X-Explorer-Version", "1.0.0")
// Proxy request // Proxy request

View File

@@ -20,7 +20,7 @@ func (m *SecurityMiddleware) AddSecurityHeaders(next http.Handler) http.Handler
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Content Security Policy // Content Security Policy
// unsafe-eval required by ethers.js v5 UMD from CDN (ABI decoding) // unsafe-eval required by ethers.js v5 UMD from CDN (ABI decoding)
w.Header().Set("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; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://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;") w.Header().Set("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; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://blockscout.defi-oracle.io https://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;")
// X-Frame-Options (click-jacking protection) // X-Frame-Options (click-jacking protection)
w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Frame-Options", "DENY")

View File

@@ -6,6 +6,7 @@ REST API implementation for the ChainID 138 Explorer Platform.
- `server.go` - Main server setup and route configuration - `server.go` - Main server setup and route configuration
- `routes.go` - Route handlers and URL parsing - `routes.go` - Route handlers and URL parsing
- `auth.go` - Wallet auth, user-session auth, RPC product access, subscriptions, and API keys
- `blocks.go` - Block-related endpoints - `blocks.go` - Block-related endpoints
- `transactions.go` - Transaction-related endpoints - `transactions.go` - Transaction-related endpoints
- `addresses.go` - Address-related endpoints - `addresses.go` - Address-related endpoints
@@ -17,6 +18,12 @@ REST API implementation for the ChainID 138 Explorer Platform.
## API Endpoints ## API Endpoints
### Auth
- `POST /api/v1/auth/nonce` - Create a wallet-signature nonce
- `POST /api/v1/auth/wallet` - Authenticate a wallet and receive a track JWT
- `POST /api/v1/auth/register` - Create an access-console user session
- `POST /api/v1/auth/login` - Log in to the access console
### Blocks ### Blocks
- `GET /api/v1/blocks` - List blocks (paginated) - `GET /api/v1/blocks` - List blocks (paginated)
- `GET /api/v1/blocks/{chain_id}/{number}` - Get block by number - `GET /api/v1/blocks/{chain_id}/{number}` - Get block by number
@@ -40,6 +47,23 @@ REST API implementation for the ChainID 138 Explorer Platform.
- `GET /api/v1/mission-control/bridge/trace?tx=0x...` - Blockscout-backed tx trace with Chain 138 contract labels - `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 - `GET /api/v1/mission-control/liquidity/token/{address}/pools` - 30-second cached proxy to token-aggregation pools
### Access and API keys
- `GET /api/v1/access/me` - Current signed-in access user and subscriptions
- `GET /api/v1/access/products` - RPC product catalog for Core, Alltra, and Thirdweb lanes
- `GET /api/v1/access/subscriptions` - List product subscriptions
- `POST /api/v1/access/subscriptions` - Request or activate a product subscription
- `GET /api/v1/access/admin/subscriptions` - List pending or filtered subscriptions for admin review
- `POST /api/v1/access/admin/subscriptions` - Approve, suspend, or revoke a subscription as an admin
- `GET /api/v1/access/api-keys` - List issued API keys
- `POST /api/v1/access/api-keys` - Create an API key for a tier, product, scopes, expiry, and optional quota override
- `POST /api/v1/access/api-keys/{id}` - Revoke an API key
- `DELETE /api/v1/access/api-keys/{id}` - Alternate revoke verb
- `GET /api/v1/access/usage` - Per-product usage summary
- `GET /api/v1/access/audit` - Recent validated API-key usage rows for the signed-in user
- `GET /api/v1/access/admin/audit` - Admin view of recent validated API-key usage rows, optionally filtered by product
- `POST /api/v1/access/internal/validate-key` - Internal edge validation hook for API-key enforcement and usage logging
- `GET /api/v1/access/internal/validate-key` - `auth_request`-friendly validator for nginx or similar proxies
### Track 4 operator ### Track 4 operator
- `POST /api/v1/track4/operator/run-script` - Run an allowlisted script under `OPERATOR_SCRIPTS_ROOT` - `POST /api/v1/track4/operator/run-script` - Run an allowlisted script under `OPERATOR_SCRIPTS_ROOT`
@@ -52,6 +76,9 @@ REST API implementation for the ChainID 138 Explorer Platform.
- Request logging - Request logging
- Error handling with consistent error format - Error handling with consistent error format
- Health checks with database connectivity - Health checks with database connectivity
- Wallet JWT auth for track endpoints
- Email/password user sessions for the explorer access console
- RPC product catalog, subscription state, API key issuance, revocation, and usage summaries
## Running ## Running
@@ -85,6 +112,66 @@ Set environment variables:
- `OPERATOR_SCRIPTS_ROOT` - Root directory for allowlisted Track 4 scripts - `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_ALLOWLIST` - Comma-separated list of permitted script names or relative paths
- `OPERATOR_SCRIPT_TIMEOUT_SEC` - Optional Track 4 script timeout in seconds (max 599) - `OPERATOR_SCRIPT_TIMEOUT_SEC` - Optional Track 4 script timeout in seconds (max 599)
- `JWT_SECRET` - Shared secret for wallet and user-session JWT signing
- `ACCESS_ADMIN_EMAILS` - Comma-separated email allowlist for access-console admins
- `ACCESS_INTERNAL_SECRET` - Shared secret used by internal edge validators calling `/api/v1/access/internal/validate-key`
## Auth model
There are now two distinct auth planes:
1. Wallet auth
- `POST /api/v1/auth/nonce`
- `POST /api/v1/auth/wallet`
- Used for wallet-oriented explorer tracks and operator features.
2. Access-console user auth
- `POST /api/v1/auth/register`
- `POST /api/v1/auth/login`
- Used for `/api/v1/access/*` endpoints and the frontend `/access` console.
## RPC access model
The access layer currently models three RPC products:
- `core-rpc`
- Provider: `besu-core`
- VMID: `2101`
- Approval required
- Intended for operator-grade and sensitive use
- `alltra-rpc`
- Provider: `alltra`
- VMID: `2102`
- Self-service subscription model
- `thirdweb-rpc`
- Provider: `thirdweb`
- VMID: `2103`
- Self-service subscription model
The explorer can now:
- register and authenticate users
- publish an RPC product catalog
- create product subscriptions
- issue scoped API keys
- set expiry presets and quota overrides
- rotate keys by minting a replacement and revoking the old one
- review approval-gated subscriptions through an admin surface
- revoke keys
- show usage summaries
- show recent audit activity for users and admins
- validate keys for internal edge enforcement and append usage records
- support nginx `auth_request` integration through the `GET /api/v1/access/internal/validate-key` form
Current limitation:
- the internal validation hook exists, but nginx/Besu/relay still need to call it or replicate its rules to enforce traffic at the edge
- billing collection and invoicing are not yet handled by this package
Operational reference:
- `explorer-monorepo/deployment/ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md`
- `explorer-monorepo/deployment/common/nginx-rpc-api-key-gate.conf`
## Mission-control deployment notes ## Mission-control deployment notes

View File

@@ -241,7 +241,7 @@ func (s *Server) buildAIContext(ctx context.Context, query string, pageContext m
warnings := []string{} warnings := []string{}
envelope := AIContextEnvelope{ envelope := AIContextEnvelope{
ChainID: s.chainID, ChainID: s.chainID,
Explorer: "SolaceScanScout", Explorer: "SolaceScan",
PageContext: compactStringMap(pageContext), PageContext: compactStringMap(pageContext),
CapabilityNotice: "This assistant is wired for read-only explorer analysis. It can summarize indexed chain data, liquidity routes, and curated workspace docs, but it does not sign transactions or execute private operations.", CapabilityNotice: "This assistant is wired for read-only explorer analysis. It can summarize indexed chain data, liquidity routes, and curated workspace docs, but it does not sign transactions or execute private operations.",
} }
@@ -899,7 +899,7 @@ func (s *Server) callXAIChatCompletions(ctx context.Context, messages []AIChatMe
contextJSON, _ := json.MarshalIndent(contextEnvelope, "", " ") contextJSON, _ := json.MarshalIndent(contextEnvelope, "", " ")
contextText := clipString(string(contextJSON), maxExplorerAIContextChars) 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." baseSystem := "You are the SolaceScan 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() { 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." 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."
} }

View File

@@ -246,6 +246,86 @@ func TestAuthWalletRequiresDB(t *testing.T) {
assert.NotNil(t, response["error"]) assert.NotNil(t, response["error"])
} }
func TestAccessProductsEndpoint(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/products", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotNil(t, response["products"])
}
func TestAccessMeRequiresUserSession(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/me", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotNil(t, response["error"])
}
func TestAccessSubscriptionsRequiresUserSession(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/subscriptions", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestAccessUsageRequiresUserSession(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/usage", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestAccessAuditRequiresUserSession(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/audit", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestAccessAdminAuditRequiresUserSession(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/admin/audit", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestAccessInternalValidateKeyRequiresDB(t *testing.T) {
_, mux := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/v1/access/internal/validate-key", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestAIContextEndpoint(t *testing.T) { func TestAIContextEndpoint(t *testing.T) {
_, mux := setupTestServer(t) _, mux := setupTestServer(t)

View File

@@ -3,9 +3,16 @@ package rest
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io"
"net/http" "net/http"
"os"
"strconv"
"strings"
"time"
"github.com/explorer/backend/auth" "github.com/explorer/backend/auth"
"github.com/golang-jwt/jwt/v4"
) )
// handleAuthNonce handles POST /api/v1/auth/nonce // handleAuthNonce handles POST /api/v1/auth/nonce
@@ -69,3 +76,851 @@ func (s *Server) handleAuthWallet(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(authResp) json.NewEncoder(w).Encode(authResp)
} }
type userAuthRequest struct {
Email string `json:"email"`
Username string `json:"username"`
Password string `json:"password"`
}
type accessProduct struct {
Slug string `json:"slug"`
Name string `json:"name"`
Provider string `json:"provider"`
VMID int `json:"vmid"`
HTTPURL string `json:"http_url"`
WSURL string `json:"ws_url,omitempty"`
DefaultTier string `json:"default_tier"`
RequiresApproval bool `json:"requires_approval"`
BillingModel string `json:"billing_model"`
Description string `json:"description"`
UseCases []string `json:"use_cases"`
ManagementFeatures []string `json:"management_features"`
}
type userSessionClaims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
jwt.RegisteredClaims
}
type createAPIKeyRequest struct {
Name string `json:"name"`
Tier string `json:"tier"`
ProductSlug string `json:"product_slug"`
ExpiresDays int `json:"expires_days"`
MonthlyQuota int `json:"monthly_quota"`
Scopes []string `json:"scopes"`
}
type createSubscriptionRequest struct {
ProductSlug string `json:"product_slug"`
Tier string `json:"tier"`
}
type accessUsageSummary struct {
ProductSlug string `json:"product_slug"`
ActiveKeys int `json:"active_keys"`
RequestsUsed int `json:"requests_used"`
MonthlyQuota int `json:"monthly_quota"`
}
type accessAuditEntry = auth.APIKeyUsageLog
type adminSubscriptionActionRequest struct {
SubscriptionID string `json:"subscription_id"`
Status string `json:"status"`
Notes string `json:"notes"`
}
type internalValidateAPIKeyRequest struct {
APIKey string `json:"api_key"`
MethodName string `json:"method_name"`
RequestCount int `json:"request_count"`
LastIP string `json:"last_ip"`
}
var rpcAccessProducts = []accessProduct{
{
Slug: "core-rpc",
Name: "Core RPC",
Provider: "besu-core",
VMID: 2101,
HTTPURL: "https://rpc-http-prv.d-bis.org",
WSURL: "wss://rpc-ws-prv.d-bis.org",
DefaultTier: "enterprise",
RequiresApproval: true,
BillingModel: "contract",
Description: "Private Chain 138 Core RPC for operator-grade administration and sensitive workloads.",
UseCases: []string{"core deployments", "operator automation", "private infrastructure integration"},
ManagementFeatures: []string{"dedicated API key", "higher rate ceiling", "operator-oriented access controls"},
},
{
Slug: "alltra-rpc",
Name: "Alltra RPC",
Provider: "alltra",
VMID: 2102,
HTTPURL: "http://192.168.11.212:8545",
WSURL: "ws://192.168.11.212:8546",
DefaultTier: "pro",
RequiresApproval: false,
BillingModel: "subscription",
Description: "Dedicated Alltra-managed RPC lane for partner traffic, subscription access, and API-key-gated usage.",
UseCases: []string{"tenant RPC access", "managed partner workloads", "metered commercial usage"},
ManagementFeatures: []string{"subscription-ready key issuance", "rate governance", "partner-specific traffic lane"},
},
{
Slug: "thirdweb-rpc",
Name: "Thirdweb RPC",
Provider: "thirdweb",
VMID: 2103,
HTTPURL: "http://192.168.11.217:8545",
WSURL: "ws://192.168.11.217:8546",
DefaultTier: "pro",
RequiresApproval: false,
BillingModel: "subscription",
Description: "Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access and API-token paywalling.",
UseCases: []string{"thirdweb integrations", "commercial API access", "managed dApp traffic"},
ManagementFeatures: []string{"API token issuance", "usage tiering", "future paywall/subscription hooks"},
},
}
func (s *Server) generateUserJWT(user *auth.User) (string, time.Time, error) {
expiresAt := time.Now().Add(7 * 24 * time.Hour)
claims := userSessionClaims{
UserID: user.ID,
Email: user.Email,
Username: user.Username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: user.ID,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(s.jwtSecret)
if err != nil {
return "", time.Time{}, err
}
return tokenString, expiresAt, nil
}
func (s *Server) validateUserJWT(tokenString string) (*userSessionClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &userSessionClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return s.jwtSecret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*userSessionClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return ""
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return ""
}
return strings.TrimSpace(parts[1])
}
func (s *Server) requireUserSession(w http.ResponseWriter, r *http.Request) (*userSessionClaims, bool) {
token := extractBearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "unauthorized", "User session required")
return nil, false
}
claims, err := s.validateUserJWT(token)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or expired session token")
return nil, false
}
return claims, true
}
func isEmailInCSVAllowlist(email string, raw string) bool {
if strings.TrimSpace(email) == "" || strings.TrimSpace(raw) == "" {
return false
}
for _, candidate := range strings.Split(raw, ",") {
if strings.EqualFold(strings.TrimSpace(candidate), strings.TrimSpace(email)) {
return true
}
}
return false
}
func (s *Server) isAccessAdmin(claims *userSessionClaims) bool {
return claims != nil && isEmailInCSVAllowlist(claims.Email, os.Getenv("ACCESS_ADMIN_EMAILS"))
}
func (s *Server) requireInternalAccessSecret(w http.ResponseWriter, r *http.Request) bool {
configured := strings.TrimSpace(os.Getenv("ACCESS_INTERNAL_SECRET"))
if configured == "" {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Internal access secret is not configured")
return false
}
presented := strings.TrimSpace(r.Header.Get("X-Access-Internal-Secret"))
if presented == "" || presented != configured {
writeError(w, http.StatusUnauthorized, "unauthorized", "Internal access secret required")
return false
}
return true
}
func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
if !s.requireDB(w) {
return
}
var req userAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if strings.TrimSpace(req.Email) == "" || strings.TrimSpace(req.Username) == "" || len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "bad_request", "Email, username, and an 8+ character password are required")
return
}
user, err := s.userAuth.RegisterUser(r.Context(), strings.TrimSpace(req.Email), strings.TrimSpace(req.Username), req.Password)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
token, expiresAt, err := s.generateUserJWT(user)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create session")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"user": map[string]any{
"id": user.ID,
"email": user.Email,
"username": user.Username,
},
"token": token,
"expires_at": expiresAt,
})
}
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
if !s.requireDB(w) {
return
}
var req userAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
user, err := s.userAuth.AuthenticateUser(r.Context(), strings.TrimSpace(req.Email), req.Password)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
token, expiresAt, err := s.generateUserJWT(user)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create session")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"user": map[string]any{
"id": user.ID,
"email": user.Email,
"username": user.Username,
},
"token": token,
"expires_at": expiresAt,
})
}
func (s *Server) handleAccessProducts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"products": rpcAccessProducts,
"note": "Products are ready for auth, API key, and subscription gating. Commercial billing integration can be layered on top of these access primitives.",
})
}
func (s *Server) handleAccessMe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
subscriptions, _ := s.userAuth.ListSubscriptions(r.Context(), claims.UserID)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"user": map[string]any{
"id": claims.UserID,
"email": claims.Email,
"username": claims.Username,
"is_admin": s.isAccessAdmin(claims),
},
"subscriptions": subscriptions,
})
}
func (s *Server) handleAccessAPIKeys(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
switch r.Method {
case http.MethodGet:
keys, err := s.userAuth.ListAPIKeys(r.Context(), claims.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"api_keys": keys})
case http.MethodPost:
var req createAPIKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Key name is required")
return
}
tier := strings.ToLower(strings.TrimSpace(req.Tier))
if tier == "" {
tier = "free"
}
productSlug := strings.TrimSpace(req.ProductSlug)
product := findAccessProduct(productSlug)
if productSlug != "" && product == nil {
writeError(w, http.StatusBadRequest, "bad_request", "Unknown product")
return
}
subscriptions, err := s.userAuth.ListSubscriptions(r.Context(), claims.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
var subscriptionStatus string
for _, subscription := range subscriptions {
if subscription.ProductSlug == productSlug {
subscriptionStatus = subscription.Status
break
}
}
if product != nil {
if subscriptionStatus == "" {
status := "active"
if product.RequiresApproval {
status = "pending"
}
_, err := s.userAuth.UpsertProductSubscription(
r.Context(),
claims.UserID,
productSlug,
tier,
status,
defaultQuotaForTier(tier),
product.RequiresApproval,
"",
"",
)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
subscriptionStatus = status
}
if subscriptionStatus != "active" {
writeError(w, http.StatusForbidden, "subscription_required", "Product access is pending approval or inactive")
return
}
}
fullName := req.Name
if productSlug != "" {
fullName = fmt.Sprintf("%s [%s]", req.Name, productSlug)
}
monthlyQuota := req.MonthlyQuota
if monthlyQuota <= 0 {
monthlyQuota = defaultQuotaForTier(tier)
}
scopes := req.Scopes
if len(scopes) == 0 {
scopes = defaultScopesForProduct(productSlug)
}
apiKey, err := s.userAuth.GenerateScopedAPIKey(
r.Context(),
claims.UserID,
fullName,
tier,
productSlug,
scopes,
monthlyQuota,
product == nil || !product.RequiresApproval,
req.ExpiresDays,
)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
keys, _ := s.userAuth.ListAPIKeys(r.Context(), claims.UserID)
var latest any
if len(keys) > 0 {
latest = keys[0]
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"api_key": apiKey,
"record": latest,
})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
}
}
func (s *Server) handleAccessInternalValidateAPIKey(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
if r.Method != http.MethodPost && r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
if !s.requireInternalAccessSecret(w, r) {
return
}
req, err := parseInternalValidateAPIKeyRequest(r)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if strings.TrimSpace(req.APIKey) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "API key is required")
return
}
info, err := s.userAuth.ValidateAPIKeyDetailed(
r.Context(),
strings.TrimSpace(req.APIKey),
strings.TrimSpace(req.MethodName),
req.RequestCount,
strings.TrimSpace(req.LastIP),
)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
w.Header().Set("X-Validated-Product", info.ProductSlug)
w.Header().Set("X-Validated-Tier", info.Tier)
w.Header().Set("X-Validated-User", info.UserID)
w.Header().Set("X-Validated-Scopes", strings.Join(info.Scopes, ","))
if info.MonthlyQuota > 0 {
remaining := info.MonthlyQuota - info.RequestsUsed
if remaining < 0 {
remaining = 0
}
w.Header().Set("X-Quota-Remaining", strconv.Itoa(remaining))
}
if r.Method == http.MethodGet {
w.WriteHeader(http.StatusOK)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"valid": true,
"key": info,
})
}
func parseInternalValidateAPIKeyRequest(r *http.Request) (internalValidateAPIKeyRequest, error) {
var req internalValidateAPIKeyRequest
if r.Method == http.MethodGet {
req.APIKey = firstNonEmpty(
r.Header.Get("X-API-Key"),
extractBearerToken(r),
r.URL.Query().Get("api_key"),
)
req.MethodName = firstNonEmpty(
r.Header.Get("X-Access-Method"),
r.URL.Query().Get("method_name"),
r.Method,
)
req.LastIP = firstNonEmpty(
r.Header.Get("X-Real-IP"),
r.Header.Get("X-Forwarded-For"),
r.URL.Query().Get("last_ip"),
)
req.RequestCount = 1
if rawCount := firstNonEmpty(r.Header.Get("X-Access-Request-Count"), r.URL.Query().Get("request_count")); rawCount != "" {
parsed, err := strconv.Atoi(strings.TrimSpace(rawCount))
if err != nil {
return req, fmt.Errorf("invalid request_count")
}
req.RequestCount = parsed
}
return req, nil
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if errors.Is(err, io.EOF) {
return req, fmt.Errorf("invalid request body")
}
return req, fmt.Errorf("invalid request body")
}
if strings.TrimSpace(req.MethodName) == "" {
req.MethodName = r.Method
}
return req, nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func findAccessProduct(slug string) *accessProduct {
for _, product := range rpcAccessProducts {
if product.Slug == slug {
copy := product
return &copy
}
}
return nil
}
func defaultQuotaForTier(tier string) int {
switch tier {
case "enterprise":
return 1000000
case "pro":
return 100000
default:
return 10000
}
}
func defaultScopesForProduct(productSlug string) []string {
switch productSlug {
case "core-rpc":
return []string{"rpc:read", "rpc:write", "rpc:admin"}
case "alltra-rpc", "thirdweb-rpc":
return []string{"rpc:read", "rpc:write"}
default:
return []string{"rpc:read"}
}
}
func (s *Server) handleAccessSubscriptions(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
switch r.Method {
case http.MethodGet:
subscriptions, err := s.userAuth.ListSubscriptions(r.Context(), claims.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"subscriptions": subscriptions})
case http.MethodPost:
var req createSubscriptionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
product := findAccessProduct(strings.TrimSpace(req.ProductSlug))
if product == nil {
writeError(w, http.StatusBadRequest, "bad_request", "Unknown product")
return
}
tier := strings.ToLower(strings.TrimSpace(req.Tier))
if tier == "" {
tier = product.DefaultTier
}
status := "active"
notes := "Self-service activation"
if product.RequiresApproval {
status = "pending"
notes = "Awaiting manual approval for restricted product"
}
subscription, err := s.userAuth.UpsertProductSubscription(
r.Context(),
claims.UserID,
product.Slug,
tier,
status,
defaultQuotaForTier(tier),
product.RequiresApproval,
"",
notes,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"subscription": subscription})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
}
}
func (s *Server) handleAccessAdminSubscriptions(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
if !s.isAccessAdmin(claims) {
writeError(w, http.StatusForbidden, "forbidden", "Access admin privileges required")
return
}
switch r.Method {
case http.MethodGet:
status := strings.TrimSpace(r.URL.Query().Get("status"))
subscriptions, err := s.userAuth.ListAllSubscriptions(r.Context(), status)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"subscriptions": subscriptions})
case http.MethodPost:
var req adminSubscriptionActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
status := strings.ToLower(strings.TrimSpace(req.Status))
switch status {
case "active", "suspended", "revoked":
default:
writeError(w, http.StatusBadRequest, "bad_request", "Status must be active, suspended, or revoked")
return
}
if strings.TrimSpace(req.SubscriptionID) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Subscription id is required")
return
}
subscription, err := s.userAuth.UpdateSubscriptionStatus(
r.Context(),
strings.TrimSpace(req.SubscriptionID),
status,
claims.Email,
strings.TrimSpace(req.Notes),
)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"subscription": subscription})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
}
}
func (s *Server) handleAccessUsage(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
keys, err := s.userAuth.ListAPIKeys(r.Context(), claims.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
grouped := map[string]*accessUsageSummary{}
for _, key := range keys {
slug := key.ProductSlug
if slug == "" {
slug = "unscoped"
}
if _, ok := grouped[slug]; !ok {
grouped[slug] = &accessUsageSummary{ProductSlug: slug}
}
summary := grouped[slug]
if !key.Revoked {
summary.ActiveKeys++
}
summary.RequestsUsed += key.RequestsUsed
summary.MonthlyQuota += key.MonthlyQuota
}
summaries := make([]accessUsageSummary, 0, len(grouped))
for _, summary := range grouped {
summaries = append(summaries, *summary)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"usage": summaries})
}
func (s *Server) handleAccessAudit(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
limit := 20
if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
parsed, err := strconv.Atoi(rawLimit)
if err != nil || parsed < 1 || parsed > 200 {
writeError(w, http.StatusBadRequest, "bad_request", "Limit must be between 1 and 200")
return
}
limit = parsed
}
entries, err := s.userAuth.ListUsageLogs(r.Context(), claims.UserID, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"entries": entries})
}
func (s *Server) handleAccessAdminAudit(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
if !s.isAccessAdmin(claims) {
writeError(w, http.StatusForbidden, "forbidden", "Access admin privileges required")
return
}
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
limit := 50
if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
parsed, err := strconv.Atoi(rawLimit)
if err != nil || parsed < 1 || parsed > 500 {
writeError(w, http.StatusBadRequest, "bad_request", "Limit must be between 1 and 500")
return
}
limit = parsed
}
productSlug := strings.TrimSpace(r.URL.Query().Get("product"))
if productSlug != "" && findAccessProduct(productSlug) == nil {
writeError(w, http.StatusBadRequest, "bad_request", "Unknown product")
return
}
entries, err := s.userAuth.ListAllUsageLogs(r.Context(), productSlug, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"entries": entries})
}
func (s *Server) handleAccessAPIKeyAction(w http.ResponseWriter, r *http.Request) {
if !s.requireDB(w) {
return
}
claims, ok := s.requireUserSession(w, r)
if !ok {
return
}
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/access/api-keys/")
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) == 0 || parts[0] == "" {
writeError(w, http.StatusBadRequest, "bad_request", "API key id is required")
return
}
keyID := parts[0]
if err := s.userAuth.RevokeAPIKey(r.Context(), claims.UserID, keyID); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"revoked": true,
"api_key_id": keyID,
})
}

View File

@@ -5,7 +5,7 @@
"minor": 1, "minor": 1,
"patch": 0 "patch": 0
}, },
"generatedBy": "SolaceScanScout", "generatedBy": "SolaceScan",
"timestamp": "2026-03-28T00:00:00Z", "timestamp": "2026-03-28T00:00:00Z",
"chainId": 138, "chainId": 138,
"chainName": "DeFi Oracle Meta Mainnet", "chainName": "DeFi Oracle Meta Mainnet",

View File

@@ -4,9 +4,9 @@
"defaultChainId": 138, "defaultChainId": 138,
"explorerUrl": "https://explorer.d-bis.org", "explorerUrl": "https://explorer.d-bis.org",
"tokenListUrl": "https://explorer.d-bis.org/api/config/token-list", "tokenListUrl": "https://explorer.d-bis.org/api/config/token-list",
"generatedBy": "SolaceScanScout", "generatedBy": "SolaceScan",
"chains": [ "chains": [
{"chainId":"0x8a","chainIdDecimal":138,"chainName":"DeFi Oracle Meta Mainnet","shortName":"dbis","rpcUrls":["https://rpc-http-pub.d-bis.org","https://rpc.d-bis.org","https://rpc2.d-bis.org","https://rpc.defi-oracle.io"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://explorer.d-bis.org"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://explorer.d-bis.org","explorerApiUrl":"https://explorer.d-bis.org/api/v2","testnet":false}, {"chainId":"0x8a","chainIdDecimal":138,"chainName":"DeFi Oracle Meta Mainnet","shortName":"dbis","rpcUrls":["https://rpc-http-pub.d-bis.org","https://rpc.d-bis.org","https://rpc2.d-bis.org","https://rpc.defi-oracle.io"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://explorer.d-bis.org","https://blockscout.defi-oracle.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://explorer.d-bis.org","explorerApiUrl":"https://explorer.d-bis.org/api/v2","testnet":false},
{"chainId":"0x1","chainIdDecimal":1,"chainName":"Ethereum Mainnet","shortName":"eth","rpcUrls":["https://eth.llamarpc.com","https://rpc.ankr.com/eth","https://ethereum.publicnode.com","https://1rpc.io/eth"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://etherscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://ethereum.org","testnet":false}, {"chainId":"0x1","chainIdDecimal":1,"chainName":"Ethereum Mainnet","shortName":"eth","rpcUrls":["https://eth.llamarpc.com","https://rpc.ankr.com/eth","https://ethereum.publicnode.com","https://1rpc.io/eth"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://etherscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://ethereum.org","testnet":false},
{"chainId":"0x9f2c4","chainIdDecimal":651940,"chainName":"ALL Mainnet","shortName":"all","rpcUrls":["https://mainnet-rpc.alltra.global"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://alltra.global"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://alltra.global","testnet":false}, {"chainId":"0x9f2c4","chainIdDecimal":651940,"chainName":"ALL Mainnet","shortName":"all","rpcUrls":["https://mainnet-rpc.alltra.global"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://alltra.global"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://alltra.global","testnet":false},
{"chainId":"0x19","chainIdDecimal":25,"chainName":"Cronos Mainnet","rpcUrls":["https://evm.cronos.org","https://cronos-rpc.publicnode.com"],"nativeCurrency":{"name":"CRO","symbol":"CRO","decimals":18},"blockExplorerUrls":["https://cronos.org/explorer"],"iconUrls":["https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong"]}, {"chainId":"0x19","chainIdDecimal":25,"chainName":"Cronos Mainnet","rpcUrls":["https://evm.cronos.org","https://cronos-rpc.publicnode.com"],"nativeCurrency":{"name":"CRO","symbol":"CRO","decimals":18},"blockExplorerUrls":["https://cronos.org/explorer"],"iconUrls":["https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong"]},

View File

@@ -52,6 +52,18 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
// Auth endpoints // Auth endpoints
mux.HandleFunc("/api/v1/auth/nonce", s.handleAuthNonce) mux.HandleFunc("/api/v1/auth/nonce", s.handleAuthNonce)
mux.HandleFunc("/api/v1/auth/wallet", s.handleAuthWallet) mux.HandleFunc("/api/v1/auth/wallet", s.handleAuthWallet)
mux.HandleFunc("/api/v1/auth/register", s.handleAuthRegister)
mux.HandleFunc("/api/v1/auth/login", s.handleAuthLogin)
mux.HandleFunc("/api/v1/access/me", s.handleAccessMe)
mux.HandleFunc("/api/v1/access/products", s.handleAccessProducts)
mux.HandleFunc("/api/v1/access/subscriptions", s.handleAccessSubscriptions)
mux.HandleFunc("/api/v1/access/admin/subscriptions", s.handleAccessAdminSubscriptions)
mux.HandleFunc("/api/v1/access/admin/audit", s.handleAccessAdminAudit)
mux.HandleFunc("/api/v1/access/internal/validate-key", s.handleAccessInternalValidateAPIKey)
mux.HandleFunc("/api/v1/access/api-keys", s.handleAccessAPIKeys)
mux.HandleFunc("/api/v1/access/api-keys/", s.handleAccessAPIKeyAction)
mux.HandleFunc("/api/v1/access/usage", s.handleAccessUsage)
mux.HandleFunc("/api/v1/access/audit", s.handleAccessAudit)
// Track 1 routes (public, optional auth) // Track 1 routes (public, optional auth)
// Note: Track 1 endpoints should be registered with OptionalAuth middleware // Note: Track 1 endpoints should be registered with OptionalAuth middleware

View File

@@ -22,6 +22,7 @@ import (
type Server struct { type Server struct {
db *pgxpool.Pool db *pgxpool.Pool
chainID int chainID int
userAuth *auth.Auth
walletAuth *auth.WalletAuth walletAuth *auth.WalletAuth
jwtSecret []byte jwtSecret []byte
aiLimiter *AIRateLimiter aiLimiter *AIRateLimiter
@@ -42,6 +43,7 @@ func NewServer(db *pgxpool.Pool, chainID int) *Server {
return &Server{ return &Server{
db: db, db: db,
chainID: chainID, chainID: chainID,
userAuth: auth.NewAuth(db),
walletAuth: walletAuth, walletAuth: walletAuth,
jwtSecret: jwtSecret, jwtSecret: jwtSecret,
aiLimiter: NewAIRateLimiter(), aiLimiter: NewAIRateLimiter(),
@@ -74,7 +76,7 @@ func (s *Server) Start(port int) error {
// Security headers (reusable lib; CSP from env or explorer default) // Security headers (reusable lib; CSP from env or explorer default)
csp := os.Getenv("CSP_HEADER") csp := os.Getenv("CSP_HEADER")
if csp == "" { if csp == "" {
csp = "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; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://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;" csp = "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; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://blockscout.defi-oracle.io https://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;"
} }
securityMiddleware := httpmiddleware.NewSecurity(csp) securityMiddleware := httpmiddleware.NewSecurity(csp)
@@ -90,7 +92,7 @@ func (s *Server) Start(port int) error {
) )
addr := fmt.Sprintf(":%d", port) addr := fmt.Sprintf(":%d", port)
log.Printf("Starting SolaceScanScout REST API server on %s", addr) log.Printf("Starting SolaceScan REST API server on %s", addr)
log.Printf("Tiered architecture enabled: Track 1 (public), Track 2-4 (authenticated)") log.Printf("Tiered architecture enabled: Track 1 (public), Track 2-4 (authenticated)")
return http.ListenAndServe(addr, handler) return http.ListenAndServe(addr, handler)
} }
@@ -99,11 +101,11 @@ func (s *Server) Start(port int) error {
func (s *Server) addMiddleware(next http.Handler) http.Handler { func (s *Server) addMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Add branding headers // Add branding headers
w.Header().Set("X-Explorer-Name", "SolaceScanScout") w.Header().Set("X-Explorer-Name", "SolaceScan")
w.Header().Set("X-Explorer-Version", "1.0.0") w.Header().Set("X-Explorer-Version", "1.0.0")
w.Header().Set("X-Powered-By", "SolaceScanScout") w.Header().Set("X-Powered-By", "SolaceScan")
// Add CORS headers for API routes (optional: set CORS_ALLOWED_ORIGIN to restrict, e.g. https://explorer.d-bis.org) // Add CORS headers for API routes (optional: set CORS_ALLOWED_ORIGIN to restrict, e.g. https://blockscout.defi-oracle.io)
if strings.HasPrefix(r.URL.Path, "/api/") { if strings.HasPrefix(r.URL.Path, "/api/") {
origin := os.Getenv("CORS_ALLOWED_ORIGIN") origin := os.Getenv("CORS_ALLOWED_ORIGIN")
if origin == "" { if origin == "" {
@@ -224,7 +226,7 @@ func (s *Server) handleListBlocks(w http.ResponseWriter, r *http.Request) {
// handleHealth handles GET /health // handleHealth handles GET /health
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Explorer-Name", "SolaceScanScout") w.Header().Set("X-Explorer-Name", "SolaceScan")
w.Header().Set("X-Explorer-Version", "1.0.0") w.Header().Set("X-Explorer-Version", "1.0.0")
// Check database connection // Check database connection
@@ -248,7 +250,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
}, },
"chain_id": s.chainID, "chain_id": s.chainID,
"explorer": map[string]string{ "explorer": map[string]string{
"name": "SolaceScanScout", "name": "SolaceScan",
"version": "1.0.0", "version": "1.0.0",
}, },
} }

View File

@@ -1,8 +1,8 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: SolaceScanScout API title: SolaceScan API
description: | description: |
Blockchain Explorer API for ChainID 138 with tiered access control. SolaceScan public explorer API for Chain 138 with tiered access control.
## Authentication ## Authentication
@@ -31,6 +31,10 @@ servers:
tags: tags:
- name: Health - name: Health
description: Health check endpoints description: Health check endpoints
- name: Auth
description: Wallet and user-session authentication endpoints
- name: Access
description: RPC product catalog, subscriptions, and API key lifecycle
- name: Blocks - name: Blocks
description: Block-related endpoints description: Block-related endpoints
- name: Transactions - name: Transactions
@@ -76,6 +80,542 @@ paths:
type: string type: string
example: connected example: connected
/api/v1/auth/nonce:
post:
tags:
- Auth
summary: Generate wallet auth nonce
description: Creates a nonce challenge for wallet-signature authentication.
operationId: createWalletAuthNonce
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WalletNonceRequest'
responses:
'200':
description: Nonce generated
content:
application/json:
schema:
$ref: '#/components/schemas/WalletNonceResponse'
'400':
$ref: '#/components/responses/BadRequest'
'503':
description: Wallet auth storage or database not available
/api/v1/auth/wallet:
post:
tags:
- Auth
summary: Authenticate with wallet signature
description: Exchanges an address, signature, and nonce for a JWT used by wallet-authenticated track endpoints.
operationId: authenticateWallet
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WalletAuthRequest'
responses:
'200':
description: Wallet authenticated
content:
application/json:
schema:
$ref: '#/components/schemas/WalletAuthResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Wallet auth storage or database not available
/api/v1/auth/register:
post:
tags:
- Auth
summary: Register an explorer access user
description: "Creates an email/password account for the `/access` console and returns a user session token."
operationId: registerAccessUser
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserRegisterRequest'
responses:
'200':
description: User created and session issued
content:
application/json:
schema:
$ref: '#/components/schemas/UserSessionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'503':
description: Database not available
/api/v1/auth/login:
post:
tags:
- Auth
summary: Log in to the explorer access console
description: "Authenticates an email/password user and returns a user session token for `/api/v1/access/*` endpoints."
operationId: loginAccessUser
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserLoginRequest'
responses:
'200':
description: Session issued
content:
application/json:
schema:
$ref: '#/components/schemas/UserSessionResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/me:
get:
tags:
- Access
summary: Get current access-console user
description: Returns the signed-in user profile and any known product subscriptions.
operationId: getAccessMe
security:
- userSessionAuth: []
responses:
'200':
description: Current user and subscriptions
content:
application/json:
schema:
$ref: '#/components/schemas/AccessMeResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/products:
get:
tags:
- Access
summary: List available RPC access products
description: Returns the commercial and operational RPC products currently modeled by the explorer access layer.
operationId: listAccessProducts
responses:
'200':
description: Product catalog
content:
application/json:
schema:
$ref: '#/components/schemas/AccessProductsResponse'
/api/v1/access/subscriptions:
get:
tags:
- Access
summary: List subscriptions for the signed-in user
operationId: listAccessSubscriptions
security:
- userSessionAuth: []
responses:
'200':
description: Subscription list
content:
application/json:
schema:
$ref: '#/components/schemas/AccessSubscriptionsResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/admin/subscriptions:
get:
tags:
- Access
summary: List subscriptions for admin review
description: Returns pending or filtered subscriptions for users whose email is allowlisted in `ACCESS_ADMIN_EMAILS`.
operationId: listAccessAdminSubscriptions
security:
- userSessionAuth: []
parameters:
- name: status
in: query
required: false
schema:
type: string
enum: [pending, active, suspended, revoked]
responses:
'200':
description: Subscription list
content:
application/json:
schema:
$ref: '#/components/schemas/AccessSubscriptionsResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Admin privileges required
'503':
description: Database not available
post:
tags:
- Access
summary: Approve, suspend, or revoke a subscription
operationId: updateAccessAdminSubscription
security:
- userSessionAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AdminSubscriptionActionRequest'
responses:
'200':
description: Subscription updated
content:
application/json:
schema:
$ref: '#/components/schemas/AccessSubscriptionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Admin privileges required
'503':
description: Database not available
post:
tags:
- Access
summary: Request or activate product access
description: |
Creates or updates a product subscription. Self-service products become `active` immediately.
Approval-gated products such as Core RPC are created in `pending` state.
operationId: createAccessSubscription
security:
- userSessionAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateSubscriptionRequest'
responses:
'200':
description: Subscription saved
content:
application/json:
schema:
$ref: '#/components/schemas/AccessSubscriptionResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/api-keys:
get:
tags:
- Access
summary: List API keys for the signed-in user
operationId: listAccessApiKeys
security:
- userSessionAuth: []
responses:
'200':
description: API key records
content:
application/json:
schema:
$ref: '#/components/schemas/AccessAPIKeysResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
post:
tags:
- Access
summary: Create an API key
description: |
Issues an API key for the chosen tier and product. If the product is approval-gated and not already active
for the user, this endpoint returns `subscription_required`.
operationId: createAccessApiKey
security:
- userSessionAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateAPIKeyRequest'
responses:
'200':
description: API key created
content:
application/json:
schema:
$ref: '#/components/schemas/CreateAPIKeyResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Product access is pending approval or inactive
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: subscription_required
message: Product access is pending approval or inactive
'503':
description: Database not available
/api/v1/access/api-keys/{id}:
post:
tags:
- Access
summary: Revoke an API key
description: "Revokes the identified API key. `DELETE` is also accepted by the handler, but the current frontend uses `POST`."
operationId: revokeAccessApiKey
security:
- userSessionAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: API key revoked
content:
application/json:
schema:
$ref: '#/components/schemas/RevokeAPIKeyResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
delete:
tags:
- Access
summary: Revoke an API key
description: Alternate HTTP verb for API key revocation.
operationId: revokeAccessApiKeyDelete
security:
- userSessionAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: API key revoked
content:
application/json:
schema:
$ref: '#/components/schemas/RevokeAPIKeyResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/usage:
get:
tags:
- Access
summary: Get usage summary for the signed-in user
description: Returns aggregated per-product usage derived from issued API keys.
operationId: getAccessUsage
security:
- userSessionAuth: []
responses:
'200':
description: Usage summary
content:
application/json:
schema:
$ref: '#/components/schemas/AccessUsageResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/audit:
get:
tags:
- Access
summary: Get recent API activity for the signed-in user
description: Returns recent validated API-key usage log rows for the current user.
operationId: getAccessAudit
security:
- userSessionAuth: []
parameters:
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 20
responses:
'200':
description: Audit entries
content:
application/json:
schema:
$ref: '#/components/schemas/AccessAuditResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/access/admin/audit:
get:
tags:
- Access
summary: Get recent API activity across users for admin review
description: Returns recent validated API-key usage log rows for access admins, optionally filtered by product.
operationId: getAccessAdminAudit
security:
- userSessionAuth: []
parameters:
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 500
default: 50
- name: product
in: query
required: false
schema:
type: string
responses:
'200':
description: Audit entries
content:
application/json:
schema:
$ref: '#/components/schemas/AccessAuditResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'503':
description: Database not available
/api/v1/access/internal/validate-key:
get:
tags:
- Access
summary: Validate an API key for nginx auth_request or similar edge subrequests
description: >-
Requires `X-Access-Internal-Secret` and accepts the presented API key in
`X-API-Key` or `Authorization: Bearer ...`. Returns `200` or `401` and
emits validation metadata in response headers.
operationId: validateAccessApiKeyInternalGet
parameters:
- name: X-Access-Internal-Secret
in: header
required: true
schema:
type: string
- name: X-API-Key
in: header
required: false
schema:
type: string
- name: Authorization
in: header
required: false
schema:
type: string
- name: X-Access-Method
in: header
required: false
schema:
type: string
- name: X-Access-Request-Count
in: header
required: false
schema:
type: integer
responses:
'200':
description: Key validated
headers:
X-Validated-Product:
schema:
type: string
X-Validated-Tier:
schema:
type: string
X-Validated-Scopes:
schema:
type: string
X-Quota-Remaining:
schema:
type: string
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
post:
tags:
- Access
summary: Validate an API key for internal edge enforcement
description: Requires `X-Access-Internal-Secret` and returns validated key metadata while incrementing usage counters.
operationId: validateAccessApiKeyInternal
parameters:
- name: X-Access-Internal-Secret
in: header
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/InternalValidateAPIKeyRequest'
responses:
'200':
description: Key validated
content:
application/json:
schema:
$ref: '#/components/schemas/InternalValidateAPIKeyResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Database not available
/api/v1/blocks: /api/v1/blocks:
get: get:
tags: tags:
@@ -272,7 +812,7 @@ paths:
'400': '400':
$ref: '#/components/responses/BadRequest' $ref: '#/components/responses/BadRequest'
'503': '503':
description: `TOKEN_AGGREGATION_BASE_URL` not configured description: "`TOKEN_AGGREGATION_BASE_URL` not configured"
/api/v1/mission-control/bridge/trace: /api/v1/mission-control/bridge/trace:
get: get:
@@ -317,7 +857,7 @@ paths:
properties: properties:
script: script:
type: string type: string
description: Path relative to `OPERATOR_SCRIPTS_ROOT` description: "Path relative to `OPERATOR_SCRIPTS_ROOT`"
args: args:
type: array type: array
items: items:
@@ -363,8 +903,413 @@ components:
scheme: bearer scheme: bearer
bearerFormat: JWT bearerFormat: JWT
description: JWT token obtained from /api/v1/auth/wallet description: JWT token obtained from /api/v1/auth/wallet
userSessionAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: User session token obtained from /api/v1/auth/register or /api/v1/auth/login
schemas: schemas:
WalletNonceRequest:
type: object
required: [address]
properties:
address:
type: string
pattern: '^0x[a-fA-F0-9]{40}$'
WalletNonceResponse:
type: object
properties:
address:
type: string
nonce:
type: string
message:
type: string
WalletAuthRequest:
type: object
required: [address, signature, nonce]
properties:
address:
type: string
pattern: '^0x[a-fA-F0-9]{40}$'
signature:
type: string
nonce:
type: string
WalletAuthResponse:
type: object
properties:
token:
type: string
expires_at:
type: string
format: date-time
user:
type: object
additionalProperties: true
User:
type: object
properties:
id:
type: string
email:
type: string
format: email
username:
type: string
is_admin:
type: boolean
UserRegisterRequest:
type: object
required: [email, username, password]
properties:
email:
type: string
format: email
username:
type: string
password:
type: string
minLength: 8
UserLoginRequest:
type: object
required: [email, password]
properties:
email:
type: string
format: email
password:
type: string
UserSessionResponse:
type: object
properties:
user:
$ref: '#/components/schemas/User'
token:
type: string
expires_at:
type: string
format: date-time
AccessProduct:
type: object
properties:
slug:
type: string
name:
type: string
provider:
type: string
vmid:
type: integer
http_url:
type: string
ws_url:
type: string
default_tier:
type: string
requires_approval:
type: boolean
billing_model:
type: string
description:
type: string
use_cases:
type: array
items:
type: string
management_features:
type: array
items:
type: string
AccessProductsResponse:
type: object
properties:
products:
type: array
items:
$ref: '#/components/schemas/AccessProduct'
note:
type: string
AccessAPIKeyRecord:
type: object
properties:
id:
type: string
name:
type: string
tier:
type: string
productSlug:
type: string
scopes:
type: array
items:
type: string
monthlyQuota:
type: integer
requestsUsed:
type: integer
approved:
type: boolean
approvedAt:
type: string
format: date-time
nullable: true
rateLimitPerSecond:
type: integer
rateLimitPerMinute:
type: integer
lastUsedAt:
type: string
format: date-time
nullable: true
expiresAt:
type: string
format: date-time
nullable: true
revoked:
type: boolean
createdAt:
type: string
format: date-time
AccessSubscription:
type: object
properties:
id:
type: string
productSlug:
type: string
tier:
type: string
status:
type: string
enum: [active, pending, suspended, revoked]
monthlyQuota:
type: integer
requestsUsed:
type: integer
requiresApproval:
type: boolean
approvedAt:
type: string
format: date-time
nullable: true
approvedBy:
type: string
nullable: true
notes:
type: string
nullable: true
createdAt:
type: string
format: date-time
AccessUsageSummary:
type: object
properties:
product_slug:
type: string
active_keys:
type: integer
requests_used:
type: integer
monthly_quota:
type: integer
AccessMeResponse:
type: object
properties:
user:
$ref: '#/components/schemas/User'
subscriptions:
type: array
items:
$ref: '#/components/schemas/AccessSubscription'
AccessSubscriptionsResponse:
type: object
properties:
subscriptions:
type: array
items:
$ref: '#/components/schemas/AccessSubscription'
AccessSubscriptionResponse:
type: object
properties:
subscription:
$ref: '#/components/schemas/AccessSubscription'
AccessAPIKeysResponse:
type: object
properties:
api_keys:
type: array
items:
$ref: '#/components/schemas/AccessAPIKeyRecord'
CreateSubscriptionRequest:
type: object
required: [product_slug]
properties:
product_slug:
type: string
tier:
type: string
CreateAPIKeyRequest:
type: object
required: [name]
properties:
name:
type: string
tier:
type: string
product_slug:
type: string
expires_days:
type: integer
monthly_quota:
type: integer
scopes:
type: array
items:
type: string
AdminSubscriptionActionRequest:
type: object
required: [subscription_id, status]
properties:
subscription_id:
type: string
status:
type: string
enum: [active, suspended, revoked]
notes:
type: string
CreateAPIKeyResponse:
type: object
properties:
api_key:
type: string
description: Plaintext key is only returned at creation time.
record:
$ref: '#/components/schemas/AccessAPIKeyRecord'
RevokeAPIKeyResponse:
type: object
properties:
revoked:
type: boolean
api_key_id:
type: string
AccessUsageResponse:
type: object
properties:
usage:
type: array
items:
$ref: '#/components/schemas/AccessUsageSummary'
AccessAuditEntry:
type: object
properties:
id:
type: integer
apiKeyId:
type: string
keyName:
type: string
productSlug:
type: string
methodName:
type: string
requestCount:
type: integer
lastIp:
type: string
nullable: true
createdAt:
type: string
format: date-time
AccessAuditResponse:
type: object
properties:
entries:
type: array
items:
$ref: '#/components/schemas/AccessAuditEntry'
InternalValidatedAPIKey:
type: object
properties:
apiKeyId:
type: string
userId:
type: string
name:
type: string
tier:
type: string
productSlug:
type: string
scopes:
type: array
items:
type: string
monthlyQuota:
type: integer
requestsUsed:
type: integer
rateLimitPerSecond:
type: integer
rateLimitPerMinute:
type: integer
lastUsedAt:
type: string
format: date-time
nullable: true
expiresAt:
type: string
format: date-time
nullable: true
InternalValidateAPIKeyRequest:
type: object
required: [api_key]
properties:
api_key:
type: string
method_name:
type: string
request_count:
type: integer
last_ip:
type: string
InternalValidateAPIKeyResponse:
type: object
properties:
valid:
type: boolean
key:
$ref: '#/components/schemas/InternalValidatedAPIKey'
Block: Block:
type: object type: object
properties: properties:

View File

@@ -22,6 +22,10 @@ func (s *Server) HandleMissionControlStream(w http.ResponseWriter, r *http.Reque
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("X-Accel-Buffering", "no")
// Immediate event so nginx unbuffers and short curl probes see `event:`/`data:` before RPC probes finish.
_, _ = fmt.Fprintf(w, ": mission-control stream\n\nevent: ping\ndata: {}\n\n")
_ = controller.Flush()
tick := time.NewTicker(20 * time.Second) tick := time.NewTicker(20 * time.Second)
defer tick.Stop() defer tick.Stop()

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
@@ -19,6 +20,45 @@ type runScriptRequest struct {
Args []string `json:"args"` Args []string `json:"args"`
} }
const maxOperatorScriptOutputBytes = 64 << 10
type cappedBuffer struct {
buf bytes.Buffer
maxBytes int
truncated bool
}
func (c *cappedBuffer) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
remaining := c.maxBytes - c.buf.Len()
if remaining > 0 {
if len(p) > remaining {
_, _ = c.buf.Write(p[:remaining])
c.truncated = true
return len(p), nil
}
_, _ = c.buf.Write(p)
return len(p), nil
}
c.truncated = true
return len(p), nil
}
func (c *cappedBuffer) String() string {
if !c.truncated {
return c.buf.String()
}
return fmt.Sprintf("%s\n[truncated after %d bytes]", c.buf.String(), c.maxBytes)
}
func (c *cappedBuffer) Len() int {
return c.buf.Len()
}
// HandleRunScript handles POST /api/v1/track4/operator/run-script // HandleRunScript handles POST /api/v1/track4/operator/run-script
// Requires Track 4 auth, IP whitelist, OPERATOR_SCRIPTS_ROOT, and OPERATOR_SCRIPT_ALLOWLIST. // Requires Track 4 auth, IP whitelist, OPERATOR_SCRIPTS_ROOT, and OPERATOR_SCRIPT_ALLOWLIST.
func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) { func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) {
@@ -96,10 +136,11 @@ func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) {
} }
relPath, _ := filepath.Rel(rootAbs, candidate) relPath, _ := filepath.Rel(rootAbs, candidate)
relPath = filepath.Clean(filepath.ToSlash(relPath))
allowed := false allowed := false
base := filepath.Base(relPath)
for _, a := range allow { for _, a := range allow {
if a == relPath || a == base || filepath.Clean(a) == relPath { normalizedAllow := filepath.Clean(filepath.ToSlash(a))
if normalizedAllow == relPath {
allowed = true allowed = true
break break
} }
@@ -143,7 +184,9 @@ func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) {
} else { } else {
cmd = exec.CommandContext(ctx, candidate, reqBody.Args...) cmd = exec.CommandContext(ctx, candidate, reqBody.Args...)
} }
var stdout, stderr bytes.Buffer var stdout, stderr cappedBuffer
stdout.maxBytes = maxOperatorScriptOutputBytes
stderr.maxBytes = maxOperatorScriptOutputBytes
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
runErr := cmd.Run() runErr := cmd.Run()
@@ -176,15 +219,19 @@ func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) {
"timed_out": timedOut, "timed_out": timedOut,
"stdout_bytes": stdout.Len(), "stdout_bytes": stdout.Len(),
"stderr_bytes": stderr.Len(), "stderr_bytes": stderr.Len(),
"stdout_truncated": stdout.truncated,
"stderr_truncated": stderr.truncated,
}, ipAddr, r.UserAgent()) }, ipAddr, r.UserAgent())
resp := map[string]interface{}{ resp := map[string]interface{}{
"data": map[string]interface{}{ "data": map[string]interface{}{
"script": relPath, "script": relPath,
"exit_code": exit, "exit_code": exit,
"stdout": strings.TrimSpace(stdout.String()), "stdout": strings.TrimSpace(stdout.String()),
"stderr": strings.TrimSpace(stderr.String()), "stderr": strings.TrimSpace(stderr.String()),
"timed_out": timedOut, "timed_out": timedOut,
"stdout_truncated": stdout.truncated,
"stderr_truncated": stderr.truncated,
}, },
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View File

@@ -86,3 +86,60 @@ func TestHandleRunScriptRejectsNonAllowlistedScript(t *testing.T) {
require.Equal(t, http.StatusForbidden, w.Code) require.Equal(t, http.StatusForbidden, w.Code)
require.Contains(t, w.Body.String(), "script not in OPERATOR_SCRIPT_ALLOWLIST") require.Contains(t, w.Body.String(), "script not in OPERATOR_SCRIPT_ALLOWLIST")
} }
func TestHandleRunScriptRejectsFilenameCollisionOutsideAllowlistedPath(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "safe"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(root, "unsafe"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(root, "safe", "backup.sh"), []byte("#!/usr/bin/env bash\necho safe\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "unsafe", "backup.sh"), []byte("#!/usr/bin/env bash\necho unsafe\n"), 0o644))
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "safe/backup.sh")
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"unsafe/backup.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")
}
func TestHandleRunScriptTruncatesLargeOutput(t *testing.T) {
root := t.TempDir()
scriptPath := filepath.Join(root, "large.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("#!/usr/bin/env bash\npython3 - <<'PY'\nprint('x' * 70000)\nPY\n"), 0o644))
t.Setenv("OPERATOR_SCRIPTS_ROOT", root)
t.Setenv("OPERATOR_SCRIPT_ALLOWLIST", "large.sh")
t.Setenv("OPERATOR_SCRIPT_TIMEOUT_SEC", "30")
s := &Server{roleMgr: &stubRoleManager{allowed: true}, chainID: 138}
req := httptest.NewRequest(http.MethodPost, "/api/v1/track4/operator/run-script", bytes.NewReader([]byte(`{"script":"large.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.StatusOK, w.Code)
var out struct {
Data struct {
ExitCode float64 `json:"exit_code"`
Stdout string `json:"stdout"`
StdoutTruncated bool `json:"stdout_truncated"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
require.Equal(t, float64(0), out.Data.ExitCode)
require.True(t, out.Data.StdoutTruncated)
require.Contains(t, out.Data.Stdout, "[truncated after")
require.LessOrEqual(t, len(out.Data.Stdout), maxOperatorScriptOutputBytes+64)
}

View File

@@ -30,6 +30,155 @@ type User struct {
CreatedAt time.Time CreatedAt time.Time
} }
type APIKeyInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Tier string `json:"tier"`
ProductSlug string `json:"productSlug"`
Scopes []string `json:"scopes"`
MonthlyQuota int `json:"monthlyQuota"`
RequestsUsed int `json:"requestsUsed"`
Approved bool `json:"approved"`
ApprovedAt *time.Time `json:"approvedAt"`
RateLimitPerSecond int `json:"rateLimitPerSecond"`
RateLimitPerMinute int `json:"rateLimitPerMinute"`
LastUsedAt *time.Time `json:"lastUsedAt"`
ExpiresAt *time.Time `json:"expiresAt"`
Revoked bool `json:"revoked"`
CreatedAt time.Time `json:"createdAt"`
}
type ValidatedAPIKey struct {
UserID string `json:"userId"`
APIKeyID string `json:"apiKeyId"`
Name string `json:"name"`
Tier string `json:"tier"`
ProductSlug string `json:"productSlug"`
Scopes []string `json:"scopes"`
MonthlyQuota int `json:"monthlyQuota"`
RequestsUsed int `json:"requestsUsed"`
RateLimitPerSecond int `json:"rateLimitPerSecond"`
RateLimitPerMinute int `json:"rateLimitPerMinute"`
LastUsedAt *time.Time `json:"lastUsedAt"`
ExpiresAt *time.Time `json:"expiresAt"`
}
type ProductSubscription struct {
ID string `json:"id"`
ProductSlug string `json:"productSlug"`
Tier string `json:"tier"`
Status string `json:"status"`
MonthlyQuota int `json:"monthlyQuota"`
RequestsUsed int `json:"requestsUsed"`
RequiresApproval bool `json:"requiresApproval"`
ApprovedAt *time.Time `json:"approvedAt"`
ApprovedBy *string `json:"approvedBy"`
Notes *string `json:"notes"`
CreatedAt time.Time `json:"createdAt"`
}
type APIKeyUsageLog struct {
ID int64 `json:"id"`
APIKeyID string `json:"apiKeyId"`
KeyName string `json:"keyName"`
ProductSlug string `json:"productSlug"`
MethodName string `json:"methodName"`
RequestCount int `json:"requestCount"`
LastIP *string `json:"lastIp"`
CreatedAt time.Time `json:"createdAt"`
}
func (a *Auth) ListAllSubscriptions(ctx context.Context, status string) ([]ProductSubscription, error) {
query := `
SELECT id, product_slug, tier, status, COALESCE(monthly_quota, 0), COALESCE(requests_used, 0),
requires_approval, approved_at, approved_by, notes, created_at
FROM user_product_subscriptions
`
args := []any{}
if status != "" {
query += ` WHERE status = $1`
args = append(args, status)
}
query += ` ORDER BY created_at DESC`
rows, err := a.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to list all subscriptions: %w", err)
}
defer rows.Close()
subs := make([]ProductSubscription, 0)
for rows.Next() {
var sub ProductSubscription
var approvedAt *time.Time
var approvedBy, notes *string
if err := rows.Scan(
&sub.ID,
&sub.ProductSlug,
&sub.Tier,
&sub.Status,
&sub.MonthlyQuota,
&sub.RequestsUsed,
&sub.RequiresApproval,
&approvedAt,
&approvedBy,
&notes,
&sub.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan subscription: %w", err)
}
sub.ApprovedAt = approvedAt
sub.ApprovedBy = approvedBy
sub.Notes = notes
subs = append(subs, sub)
}
return subs, nil
}
func (a *Auth) UpdateSubscriptionStatus(
ctx context.Context,
subscriptionID string,
status string,
approvedBy string,
notes string,
) (*ProductSubscription, error) {
query := `
UPDATE user_product_subscriptions
SET status = $2,
approved_at = CASE WHEN $2 = 'active' THEN NOW() ELSE approved_at END,
approved_by = CASE WHEN $2 = 'active' THEN NULLIF($3, '') ELSE approved_by END,
notes = CASE WHEN NULLIF($4, '') IS NOT NULL THEN $4 ELSE notes END,
updated_at = NOW()
WHERE id = $1
RETURNING id, product_slug, tier, status, COALESCE(monthly_quota, 0), COALESCE(requests_used, 0),
requires_approval, approved_at, approved_by, notes, created_at
`
var sub ProductSubscription
var approvedAt *time.Time
var approvedByPtr, notesPtr *string
if err := a.db.QueryRow(ctx, query, subscriptionID, status, approvedBy, notes).Scan(
&sub.ID,
&sub.ProductSlug,
&sub.Tier,
&sub.Status,
&sub.MonthlyQuota,
&sub.RequestsUsed,
&sub.RequiresApproval,
&approvedAt,
&approvedByPtr,
&notesPtr,
&sub.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to update subscription: %w", err)
}
sub.ApprovedAt = approvedAt
sub.ApprovedBy = approvedByPtr
sub.Notes = notesPtr
return &sub, nil
}
// RegisterUser registers a new user // RegisterUser registers a new user
func (a *Auth) RegisterUser(ctx context.Context, email, username, password string) (*User, error) { func (a *Auth) RegisterUser(ctx context.Context, email, username, password string) (*User, error) {
// Hash password // Hash password
@@ -76,11 +225,17 @@ func (a *Auth) AuthenticateUser(ctx context.Context, email, password string) (*U
return nil, fmt.Errorf("invalid credentials") return nil, fmt.Errorf("invalid credentials")
} }
_, _ = a.db.Exec(ctx, `UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1`, user.ID)
return &user, nil return &user, nil
} }
// GenerateAPIKey generates a new API key for a user // GenerateAPIKey generates a new API key for a user
func (a *Auth) GenerateAPIKey(ctx context.Context, userID, name string, tier string) (string, error) { func (a *Auth) GenerateAPIKey(ctx context.Context, userID, name string, tier string) (string, error) {
return a.GenerateScopedAPIKey(ctx, userID, name, tier, "", nil, 0, false, 0)
}
func (a *Auth) GenerateScopedAPIKey(ctx context.Context, userID, name string, tier string, productSlug string, scopes []string, monthlyQuota int, approved bool, expiresDays int) (string, error) {
// Generate random key // Generate random key
keyBytes := make([]byte, 32) keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil { if _, err := rand.Read(keyBytes); err != nil {
@@ -110,13 +265,22 @@ func (a *Auth) GenerateAPIKey(ctx context.Context, userID, name string, tier str
rateLimitPerMinute = 100 rateLimitPerMinute = 100
} }
var expiresAt *time.Time
if expiresDays > 0 {
expires := time.Now().Add(time.Duration(expiresDays) * 24 * time.Hour)
expiresAt = &expires
}
// Store API key // Store API key
query := ` query := `
INSERT INTO api_keys (user_id, key_hash, name, tier, rate_limit_per_second, rate_limit_per_minute) INSERT INTO api_keys (
VALUES ($1, $2, $3, $4, $5, $6) user_id, key_hash, name, tier, product_slug, scopes, monthly_quota,
rate_limit_per_second, rate_limit_per_minute, approved, approved_at, expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CASE WHEN $10 THEN NOW() ELSE NULL END, $11)
` `
_, err := a.db.Exec(ctx, query, userID, hashedKeyHex, name, tier, rateLimitPerSecond, rateLimitPerMinute) _, err := a.db.Exec(ctx, query, userID, hashedKeyHex, name, tier, productSlug, scopes, monthlyQuota, rateLimitPerSecond, rateLimitPerMinute, approved, expiresAt)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to store API key: %w", err) return "", fmt.Errorf("failed to store API key: %w", err)
} }
@@ -130,9 +294,10 @@ func (a *Auth) ValidateAPIKey(ctx context.Context, apiKey string) (string, error
hashedKeyHex := hex.EncodeToString(hashedKey[:]) hashedKeyHex := hex.EncodeToString(hashedKey[:])
var userID string var userID string
var revoked bool var revoked, approved bool
query := `SELECT user_id, revoked FROM api_keys WHERE key_hash = $1` var expiresAt *time.Time
err := a.db.QueryRow(ctx, query, hashedKeyHex).Scan(&userID, &revoked) query := `SELECT user_id, revoked, approved, expires_at FROM api_keys WHERE key_hash = $1`
err := a.db.QueryRow(ctx, query, hashedKeyHex).Scan(&userID, &revoked, &approved, &expiresAt)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid API key") return "", fmt.Errorf("invalid API key")
@@ -141,6 +306,12 @@ func (a *Auth) ValidateAPIKey(ctx context.Context, apiKey string) (string, error
if revoked { if revoked {
return "", fmt.Errorf("API key revoked") return "", fmt.Errorf("API key revoked")
} }
if !approved {
return "", fmt.Errorf("API key pending approval")
}
if expiresAt != nil && time.Now().After(*expiresAt) {
return "", fmt.Errorf("API key expired")
}
// Update last used // Update last used
a.db.Exec(ctx, `UPDATE api_keys SET last_used_at = NOW() WHERE key_hash = $1`, hashedKeyHex) a.db.Exec(ctx, `UPDATE api_keys SET last_used_at = NOW() WHERE key_hash = $1`, hashedKeyHex)
@@ -148,3 +319,313 @@ func (a *Auth) ValidateAPIKey(ctx context.Context, apiKey string) (string, error
return userID, nil return userID, nil
} }
func (a *Auth) ValidateAPIKeyDetailed(ctx context.Context, apiKey string, methodName string, requestCount int, lastIPAddress string) (*ValidatedAPIKey, error) {
hashedKey := sha256.Sum256([]byte(apiKey))
hashedKeyHex := hex.EncodeToString(hashedKey[:])
query := `
SELECT id, user_id, COALESCE(name, ''), tier, COALESCE(product_slug, ''), COALESCE(scopes, ARRAY[]::TEXT[]),
COALESCE(monthly_quota, 0), COALESCE(requests_used, 0), approved,
COALESCE(rate_limit_per_second, 0), COALESCE(rate_limit_per_minute, 0),
last_used_at, expires_at, revoked
FROM api_keys
WHERE key_hash = $1
`
var validated ValidatedAPIKey
var approved, revoked bool
var lastUsedAt, expiresAt *time.Time
if err := a.db.QueryRow(ctx, query, hashedKeyHex).Scan(
&validated.APIKeyID,
&validated.UserID,
&validated.Name,
&validated.Tier,
&validated.ProductSlug,
&validated.Scopes,
&validated.MonthlyQuota,
&validated.RequestsUsed,
&approved,
&validated.RateLimitPerSecond,
&validated.RateLimitPerMinute,
&lastUsedAt,
&expiresAt,
&revoked,
); err != nil {
return nil, fmt.Errorf("invalid API key")
}
if revoked {
return nil, fmt.Errorf("API key revoked")
}
if !approved {
return nil, fmt.Errorf("API key pending approval")
}
if expiresAt != nil && time.Now().After(*expiresAt) {
return nil, fmt.Errorf("API key expired")
}
if requestCount <= 0 {
requestCount = 1
}
_, _ = a.db.Exec(ctx, `
UPDATE api_keys
SET last_used_at = NOW(),
requests_used = COALESCE(requests_used, 0) + $2,
last_ip_address = NULLIF($3, '')::inet
WHERE key_hash = $1
`, hashedKeyHex, requestCount, lastIPAddress)
_, _ = a.db.Exec(ctx, `
INSERT INTO api_key_usage_logs (api_key_id, product_slug, method_name, request_count, window_start, window_end, last_ip_address)
VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), $4, NOW(), NOW(), NULLIF($5, '')::inet)
`, validated.APIKeyID, validated.ProductSlug, methodName, requestCount, lastIPAddress)
validated.RequestsUsed += requestCount
validated.LastUsedAt = lastUsedAt
validated.ExpiresAt = expiresAt
return &validated, nil
}
func (a *Auth) ListAPIKeys(ctx context.Context, userID string) ([]APIKeyInfo, error) {
rows, err := a.db.Query(ctx, `
SELECT id, COALESCE(name, ''), tier, COALESCE(product_slug, ''), COALESCE(scopes, ARRAY[]::TEXT[]),
COALESCE(monthly_quota, 0), COALESCE(requests_used, 0), approved, approved_at,
COALESCE(rate_limit_per_second, 0), COALESCE(rate_limit_per_minute, 0),
last_used_at, expires_at, revoked, created_at
FROM api_keys
WHERE user_id = $1
ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("failed to list API keys: %w", err)
}
defer rows.Close()
keys := make([]APIKeyInfo, 0)
for rows.Next() {
var key APIKeyInfo
var lastUsedAt, expiresAt, approvedAt *time.Time
if err := rows.Scan(
&key.ID,
&key.Name,
&key.Tier,
&key.ProductSlug,
&key.Scopes,
&key.MonthlyQuota,
&key.RequestsUsed,
&key.Approved,
&approvedAt,
&key.RateLimitPerSecond,
&key.RateLimitPerMinute,
&lastUsedAt,
&expiresAt,
&key.Revoked,
&key.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan API key: %w", err)
}
key.ApprovedAt = approvedAt
key.LastUsedAt = lastUsedAt
key.ExpiresAt = expiresAt
keys = append(keys, key)
}
return keys, nil
}
func (a *Auth) ListUsageLogs(ctx context.Context, userID string, limit int) ([]APIKeyUsageLog, error) {
if limit <= 0 {
limit = 20
}
rows, err := a.db.Query(ctx, `
SELECT logs.id, logs.api_key_id, COALESCE(keys.name, ''), COALESCE(logs.product_slug, ''),
COALESCE(logs.method_name, ''), logs.request_count,
CASE WHEN logs.last_ip_address IS NOT NULL THEN host(logs.last_ip_address) ELSE NULL END,
logs.created_at
FROM api_key_usage_logs logs
INNER JOIN api_keys keys ON keys.id = logs.api_key_id
WHERE keys.user_id = $1
ORDER BY logs.created_at DESC
LIMIT $2
`, userID, limit)
if err != nil {
return nil, fmt.Errorf("failed to list usage logs: %w", err)
}
defer rows.Close()
entries := make([]APIKeyUsageLog, 0)
for rows.Next() {
var entry APIKeyUsageLog
var lastIP *string
if err := rows.Scan(
&entry.ID,
&entry.APIKeyID,
&entry.KeyName,
&entry.ProductSlug,
&entry.MethodName,
&entry.RequestCount,
&lastIP,
&entry.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan usage log: %w", err)
}
entry.LastIP = lastIP
entries = append(entries, entry)
}
return entries, nil
}
func (a *Auth) ListAllUsageLogs(ctx context.Context, productSlug string, limit int) ([]APIKeyUsageLog, error) {
if limit <= 0 {
limit = 50
}
query := `
SELECT logs.id, logs.api_key_id, COALESCE(keys.name, ''), COALESCE(logs.product_slug, ''),
COALESCE(logs.method_name, ''), logs.request_count,
CASE WHEN logs.last_ip_address IS NOT NULL THEN host(logs.last_ip_address) ELSE NULL END,
logs.created_at
FROM api_key_usage_logs logs
INNER JOIN api_keys keys ON keys.id = logs.api_key_id
`
args := []any{}
if productSlug != "" {
query += ` WHERE logs.product_slug = $1`
args = append(args, productSlug)
}
query += fmt.Sprintf(" ORDER BY logs.created_at DESC LIMIT $%d", len(args)+1)
args = append(args, limit)
rows, err := a.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to list all usage logs: %w", err)
}
defer rows.Close()
entries := make([]APIKeyUsageLog, 0)
for rows.Next() {
var entry APIKeyUsageLog
var lastIP *string
if err := rows.Scan(
&entry.ID,
&entry.APIKeyID,
&entry.KeyName,
&entry.ProductSlug,
&entry.MethodName,
&entry.RequestCount,
&lastIP,
&entry.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan usage log: %w", err)
}
entry.LastIP = lastIP
entries = append(entries, entry)
}
return entries, nil
}
func (a *Auth) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
tag, err := a.db.Exec(ctx, `UPDATE api_keys SET revoked = true WHERE id = $1 AND user_id = $2`, keyID, userID)
if err != nil {
return fmt.Errorf("failed to revoke API key: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("api key not found")
}
return nil
}
func (a *Auth) UpsertProductSubscription(
ctx context.Context,
userID, productSlug, tier, status string,
monthlyQuota int,
requiresApproval bool,
approvedBy string,
notes string,
) (*ProductSubscription, error) {
query := `
INSERT INTO user_product_subscriptions (
user_id, product_slug, tier, status, monthly_quota, requires_approval, approved_at, approved_by, notes
)
VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN $4 = 'active' THEN NOW() ELSE NULL END, NULLIF($7, ''), NULLIF($8, ''))
ON CONFLICT (user_id, product_slug) DO UPDATE SET
tier = EXCLUDED.tier,
status = EXCLUDED.status,
monthly_quota = EXCLUDED.monthly_quota,
requires_approval = EXCLUDED.requires_approval,
approved_at = CASE WHEN EXCLUDED.status = 'active' THEN NOW() ELSE user_product_subscriptions.approved_at END,
approved_by = NULLIF(EXCLUDED.approved_by, ''),
notes = NULLIF(EXCLUDED.notes, ''),
updated_at = NOW()
RETURNING id, product_slug, tier, status, COALESCE(monthly_quota, 0), COALESCE(requests_used, 0),
requires_approval, approved_at, approved_by, notes, created_at
`
var sub ProductSubscription
var approvedAt *time.Time
var approvedByPtr, notesPtr *string
if err := a.db.QueryRow(ctx, query, userID, productSlug, tier, status, monthlyQuota, requiresApproval, approvedBy, notes).Scan(
&sub.ID,
&sub.ProductSlug,
&sub.Tier,
&sub.Status,
&sub.MonthlyQuota,
&sub.RequestsUsed,
&sub.RequiresApproval,
&approvedAt,
&approvedByPtr,
&notesPtr,
&sub.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to save subscription: %w", err)
}
sub.ApprovedAt = approvedAt
sub.ApprovedBy = approvedByPtr
sub.Notes = notesPtr
return &sub, nil
}
func (a *Auth) ListSubscriptions(ctx context.Context, userID string) ([]ProductSubscription, error) {
rows, err := a.db.Query(ctx, `
SELECT id, product_slug, tier, status, COALESCE(monthly_quota, 0), COALESCE(requests_used, 0),
requires_approval, approved_at, approved_by, notes, created_at
FROM user_product_subscriptions
WHERE user_id = $1
ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("failed to list subscriptions: %w", err)
}
defer rows.Close()
subs := make([]ProductSubscription, 0)
for rows.Next() {
var sub ProductSubscription
var approvedAt *time.Time
var approvedBy, notes *string
if err := rows.Scan(
&sub.ID,
&sub.ProductSlug,
&sub.Tier,
&sub.Status,
&sub.MonthlyQuota,
&sub.RequestsUsed,
&sub.RequiresApproval,
&approvedAt,
&approvedBy,
&notes,
&sub.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan subscription: %w", err)
}
sub.ApprovedAt = approvedAt
sub.ApprovedBy = approvedBy
sub.Notes = notes
subs = append(subs, sub)
}
return subs, nil
}

View File

@@ -141,7 +141,7 @@ func (w *WalletAuth) AuthenticateWallet(ctx context.Context, req *WalletAuthRequ
} }
// Verify signature // Verify signature
message := fmt.Sprintf("Sign this message to authenticate with SolaceScanScout Explorer.\n\nNonce: %s", req.Nonce) message := fmt.Sprintf("Sign this message to authenticate with SolaceScan.\n\nNonce: %s", req.Nonce)
messageHash := accounts.TextHash([]byte(message)) messageHash := accounts.TextHash([]byte(message))
sigBytes, err := decodeWalletSignature(req.Signature) sigBytes, err := decodeWalletSignature(req.Signature)

View File

@@ -0,0 +1,13 @@
DROP TABLE IF EXISTS api_key_usage_logs;
DROP TABLE IF EXISTS user_product_subscriptions;
DROP TABLE IF EXISTS rpc_products;
ALTER TABLE api_keys
DROP COLUMN IF EXISTS product_slug,
DROP COLUMN IF EXISTS scopes,
DROP COLUMN IF EXISTS monthly_quota,
DROP COLUMN IF EXISTS requests_used,
DROP COLUMN IF EXISTS approved,
DROP COLUMN IF EXISTS approved_at,
DROP COLUMN IF EXISTS approved_by,
DROP COLUMN IF EXISTS last_ip_address;

View File

@@ -0,0 +1,79 @@
-- Migration: Access Management Schema
-- Description: Adds RPC product subscriptions, richer API key metadata, and usage logging.
ALTER TABLE api_keys
ADD COLUMN IF NOT EXISTS product_slug VARCHAR(100),
ADD COLUMN IF NOT EXISTS scopes TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN IF NOT EXISTS monthly_quota INTEGER,
ADD COLUMN IF NOT EXISTS requests_used INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS approved BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS approved_by VARCHAR(255),
ADD COLUMN IF NOT EXISTS last_ip_address INET;
CREATE TABLE IF NOT EXISTS rpc_products (
slug VARCHAR(100) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
provider VARCHAR(100) NOT NULL,
vmid INTEGER NOT NULL,
http_url TEXT NOT NULL,
ws_url TEXT,
default_tier VARCHAR(20) NOT NULL,
requires_approval BOOLEAN NOT NULL DEFAULT false,
billing_model VARCHAR(50) NOT NULL DEFAULT 'subscription',
description TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_product_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
product_slug VARCHAR(100) NOT NULL REFERENCES rpc_products(slug) ON DELETE CASCADE,
tier VARCHAR(20) NOT NULL CHECK (tier IN ('free', 'pro', 'enterprise')),
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'suspended', 'revoked')),
monthly_quota INTEGER,
requests_used INTEGER NOT NULL DEFAULT 0,
requires_approval BOOLEAN NOT NULL DEFAULT false,
approved_at TIMESTAMP,
approved_by VARCHAR(255),
notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, product_slug)
);
CREATE TABLE IF NOT EXISTS api_key_usage_logs (
id BIGSERIAL PRIMARY KEY,
api_key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
product_slug VARCHAR(100),
method_name VARCHAR(100),
request_count INTEGER NOT NULL DEFAULT 1,
window_start TIMESTAMP NOT NULL DEFAULT NOW(),
window_end TIMESTAMP,
last_ip_address INET,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_product_subscriptions_user ON user_product_subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_product_subscriptions_product ON user_product_subscriptions(product_slug);
CREATE INDEX IF NOT EXISTS idx_user_product_subscriptions_status ON user_product_subscriptions(status);
CREATE INDEX IF NOT EXISTS idx_api_key_usage_logs_key ON api_key_usage_logs(api_key_id);
CREATE INDEX IF NOT EXISTS idx_api_key_usage_logs_product ON api_key_usage_logs(product_slug);
INSERT INTO rpc_products (slug, name, provider, vmid, http_url, ws_url, default_tier, requires_approval, billing_model, description)
VALUES
('core-rpc', 'Core RPC', 'besu-core', 2101, 'https://rpc-http-prv.d-bis.org', 'wss://rpc-ws-prv.d-bis.org', 'enterprise', true, 'contract', 'Private Chain 138 Core RPC for operator-grade administration and sensitive workloads.'),
('alltra-rpc', 'Alltra RPC', 'alltra', 2102, 'http://192.168.11.212:8545', 'ws://192.168.11.212:8546', 'pro', false, 'subscription', 'Dedicated Alltra RPC lane for partner traffic, subscription access, and API-key-gated usage.'),
('thirdweb-rpc', 'Thirdweb RPC', 'thirdweb', 2103, 'http://192.168.11.217:8545', 'ws://192.168.11.217:8546', 'pro', false, 'subscription', 'Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access and API-token paywalling.')
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
provider = EXCLUDED.provider,
vmid = EXCLUDED.vmid,
http_url = EXCLUDED.http_url,
ws_url = EXCLUDED.ws_url,
default_tier = EXCLUDED.default_tier,
requires_approval = EXCLUDED.requires_approval,
billing_model = EXCLUDED.billing_model,
description = EXCLUDED.description,
updated_at = NOW();

View File

@@ -0,0 +1,171 @@
# Explorer Access Edge Enforcement Runbook
Operational runbook for enforcing explorer-issued API keys at the RPC edge for Chain 138 service lanes such as:
- `alltra-rpc` on VMID `2102`
- `thirdweb-rpc` on VMID `2103`
- approval-gated `core-rpc` on VMID `2101`
This complements the explorer access console and backend access APIs. The explorer can already issue, rotate, revoke, and validate keys; this runbook covers how to enforce those keys on nginx-facing RPC endpoints.
## Preconditions
- Explorer config/API backend is running on VMID `5000` and reachable at `127.0.0.1:8081`
- `ACCESS_INTERNAL_SECRET` is configured on the explorer API service
- Users and subscriptions are already managed through `/access`
- The target RPC lane is behind nginx or another proxy that can make a subrequest to the explorer API
## Canonical validator endpoint
- Internal: `http://127.0.0.1:8081/api/v1/access/internal/validate-key`
- Public-prefixed equivalent through explorer nginx: `https://explorer.d-bis.org/explorer-api/v1/access/internal/validate-key`
### Validator modes
- `GET` for nginx `auth_request`
- supply `X-API-Key` or `Authorization: Bearer ...`
- supply `X-Access-Internal-Secret`
- returns `200` on success or `401` on rejection
- includes headers such as:
- `X-Validated-Product`
- `X-Validated-Tier`
- `X-Validated-Scopes`
- `X-Quota-Remaining`
- `POST` for richer internal clients
- JSON body with `api_key`, `method_name`, `request_count`, `last_ip`
- returns JSON payload with validated key metadata
## Canonical nginx pattern
Use [`common/nginx-rpc-api-key-gate.conf`](./common/nginx-rpc-api-key-gate.conf) as the starting template.
For lane-specific rendered configs, use [`../scripts/render-rpc-access-gate-nginx.sh`](../scripts/render-rpc-access-gate-nginx.sh).
The important behavior is:
1. nginx receives user traffic
2. nginx subrequests `/__access_validate_rpc`
3. that subrequest calls the explorer validator with:
- the client API key
- the shared internal secret
- request method and source IP
4. only validated requests are proxied to the protected RPC upstream
## Render a product-specific config
Instead of editing the template manually, render a concrete config for the target lane:
```bash
bash explorer-monorepo/scripts/render-rpc-access-gate-nginx.sh \
--product thirdweb-rpc \
--server-name thirdweb-rpc.example.org \
--internal-secret "$ACCESS_INTERNAL_SECRET" \
--output /etc/nginx/conf.d/thirdweb-rpc-gated.conf
```
Example for `alltra-rpc`:
```bash
bash explorer-monorepo/scripts/render-rpc-access-gate-nginx.sh \
--product alltra-rpc \
--server-name alltra-rpc.example.org \
--internal-secret "$ACCESS_INTERNAL_SECRET" \
--output /etc/nginx/conf.d/alltra-rpc-gated.conf
```
Example for `core-rpc` with an explicit upstream override:
```bash
bash explorer-monorepo/scripts/render-rpc-access-gate-nginx.sh \
--product core-rpc \
--server-name rpc-http-prv.d-bis.org \
--internal-secret "$ACCESS_INTERNAL_SECRET" \
--upstream http://192.168.11.211:8545 \
--output /etc/nginx/conf.d/core-rpc-gated.conf
```
After rendering, verify syntax before reload:
```bash
nginx -t
systemctl reload nginx
```
## Recommended product mapping
| Product | Suggested public host | Upstream target |
|---|---|---|
| `core-rpc` | `rpc-http-prv.d-bis.org` | `http://192.168.11.211:8545` |
| `alltra-rpc` | partner/internal hostname | `http://192.168.11.212:8545` |
| `thirdweb-rpc` | managed SaaS/internal hostname | `http://192.168.11.217:8545` |
For `core-rpc`, keep manual approval enabled and consider IP allowlists in addition to API keys.
## Safe remote install workflow
For an operator-friendly rollout, use the dry-run-first installer:
```bash
bash explorer-monorepo/scripts/install-rpc-access-gate-nginx-via-ssh.sh \
--product thirdweb-rpc \
--server-name thirdweb-rpc.example.org \
--ssh-host root@192.168.11.217 \
--internal-secret "$ACCESS_INTERNAL_SECRET"
```
That prints the rendered config and planned remote target without mutating anything.
Apply only after review:
```bash
bash explorer-monorepo/scripts/install-rpc-access-gate-nginx-via-ssh.sh \
--product thirdweb-rpc \
--server-name thirdweb-rpc.example.org \
--ssh-host root@192.168.11.217 \
--internal-secret "$ACCESS_INTERNAL_SECRET" \
--apply
```
By default the installer copies the config, runs `nginx -t`, and only then reloads nginx.
## Explorer API service env
At minimum, set:
```dotenv
ACCESS_ADMIN_EMAILS=ops@example.org,platform@example.org
ACCESS_INTERNAL_SECRET=replace-with-long-random-secret
```
## Verification
Use the dedicated verifier:
```bash
bash explorer-monorepo/scripts/verify-explorer-access-edge-hook.sh \
--base-url https://explorer.d-bis.org \
--internal-secret "$ACCESS_INTERNAL_SECRET"
```
To test a real key:
```bash
bash explorer-monorepo/scripts/verify-explorer-access-edge-hook.sh \
--base-url https://explorer.d-bis.org \
--internal-secret "$ACCESS_INTERNAL_SECRET" \
--api-key "sk_live_example"
```
## Rollout order
1. Deploy explorer config/API backend so the validator endpoint is live
2. Confirm `ACCESS_INTERNAL_SECRET` is loaded in the service env
3. Apply nginx config for one protected lane first, usually `thirdweb-rpc`
4. Verify validation responses and upstream reachability
5. Expand to `alltra-rpc`
6. Apply stricter controls for `core-rpc` only after admin approval flow is tested
## Honest limits
- This repo now provides the validator hook, operator docs, and example edge config
- Actual enforcement still depends on where the RPC traffic is terminated
- Billing settlement, Stripe, or x402 monetization is a separate commercial layer

View File

@@ -54,7 +54,7 @@ Use this checklist to track deployment progress.
- [ ] Systemd service files created: - [ ] Systemd service files created:
- [ ] `explorer-indexer.service` - [ ] `explorer-indexer.service`
- [ ] `explorer-api.service` - [ ] `explorer-api.service`
- [ ] `explorer-frontend.service` - [ ] `solacescanscout-frontend.service`
- [ ] Services enabled - [ ] Services enabled
- [ ] Services started - [ ] Services started
- [ ] Service status verified - [ ] Service status verified
@@ -201,4 +201,3 @@ _Use this space for deployment-specific notes and issues encountered._
**Deployed By**: _______________ **Deployed By**: _______________
**Container ID**: _______________ **Container ID**: _______________
**Domain**: explorer.d-bis.org **Domain**: explorer.d-bis.org

View File

@@ -477,24 +477,26 @@ EOF
#### Frontend Service #### Frontend Service
```bash ```bash
cat > /etc/systemd/system/explorer-frontend.service << 'EOF' cat > /etc/systemd/system/solacescanscout-frontend.service << 'EOF'
[Unit] [Unit]
Description=Explorer Frontend Service Description=SolaceScan Next Frontend Service
After=network.target explorer-api.service After=network.target explorer-api.service
Requires=explorer-api.service Requires=explorer-api.service
[Service] [Service]
Type=simple Type=simple
User=explorer User=www-data
Group=explorer Group=www-data
WorkingDirectory=/home/explorer/explorer-monorepo/frontend WorkingDirectory=/opt/solacescanscout/frontend/current
EnvironmentFile=/home/explorer/explorer-monorepo/.env Environment=NODE_ENV=production
ExecStart=/usr/bin/npm start Environment=HOSTNAME=127.0.0.1
Environment=PORT=3000
ExecStart=/usr/bin/node /opt/solacescanscout/frontend/current/server.js
Restart=always Restart=always
RestartSec=10 RestartSec=5
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
SyslogIdentifier=explorer-frontend SyslogIdentifier=solacescanscout-frontend
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@@ -510,17 +512,17 @@ systemctl daemon-reload
# Enable services # Enable services
systemctl enable explorer-indexer systemctl enable explorer-indexer
systemctl enable explorer-api systemctl enable explorer-api
systemctl enable explorer-frontend systemctl enable solacescanscout-frontend
# Start services # Start services
systemctl start explorer-indexer systemctl start explorer-indexer
systemctl start explorer-api systemctl start explorer-api
systemctl start explorer-frontend systemctl start solacescanscout-frontend
# Check status # Check status
systemctl status explorer-indexer systemctl status explorer-indexer
systemctl status explorer-api systemctl status explorer-api
systemctl status explorer-frontend systemctl status solacescanscout-frontend
``` ```
--- ---
@@ -892,7 +894,7 @@ cat > /etc/logrotate.d/explorer << 'EOF'
create 0640 explorer explorer create 0640 explorer explorer
sharedscripts sharedscripts
postrotate postrotate
systemctl reload explorer-indexer explorer-api explorer-frontend > /dev/null 2>&1 || true systemctl reload explorer-indexer explorer-api solacescanscout-frontend > /dev/null 2>&1 || true
endscript endscript
} }
EOF EOF
@@ -1079,4 +1081,3 @@ journalctl -u cloudflared -f
**Last Updated**: 2024-12-23 **Last Updated**: 2024-12-23
**Version**: 1.0.0 **Version**: 1.0.0

View File

@@ -9,6 +9,10 @@ This directory contains two different kinds of deployment material:
Start with [`LIVE_DEPLOYMENT_MAP.md`](./LIVE_DEPLOYMENT_MAP.md). Start with [`LIVE_DEPLOYMENT_MAP.md`](./LIVE_DEPLOYMENT_MAP.md).
Primary public explorer surface: `https://blockscout.defi-oracle.io`
Companion explorer-facing properties may still exist under `https://explorer.d-bis.org` for Snap and related tooling, but the public explorer verification flow should treat `blockscout.defi-oracle.io` as canonical unless a task explicitly targets a companion surface.
The live explorer is currently assembled from separate deployment paths: The live explorer is currently assembled from separate deployment paths:
| Component | Live service | Canonical deploy path | | Component | Live service | Canonical deploy path |
@@ -22,9 +26,10 @@ The live explorer is currently assembled from separate deployment paths:
- [`check-explorer-health.sh`](../scripts/check-explorer-health.sh) - [`check-explorer-health.sh`](../scripts/check-explorer-health.sh)
- [`check-explorer-e2e.sh`](../../scripts/verify/check-explorer-e2e.sh) - [`check-explorer-e2e.sh`](../../scripts/verify/check-explorer-e2e.sh)
- `https://explorer.d-bis.org/api/config/capabilities` - [`scripts/verify-explorer-access-edge-hook.sh`](../scripts/verify-explorer-access-edge-hook.sh)
- `https://explorer.d-bis.org/explorer-api/v1/track1/bridge/status` - `https://blockscout.defi-oracle.io/api/config/capabilities`
- `https://explorer.d-bis.org/explorer-api/v1/mission-control/stream` - `https://blockscout.defi-oracle.io/explorer-api/v1/track1/bridge/status`
- `https://blockscout.defi-oracle.io/explorer-api/v1/mission-control/stream`
## Legacy Material In This Directory ## Legacy Material In This Directory
@@ -35,6 +40,6 @@ These files remain in the repo, but they describe an older generalized package:
- `DEPLOYMENT_CHECKLIST.md` - `DEPLOYMENT_CHECKLIST.md`
- `QUICK_DEPLOY.md` - `QUICK_DEPLOY.md`
- `systemd/explorer-api.service` - `systemd/explorer-api.service`
- `systemd/explorer-frontend.service` - `systemd/solacescanscout-frontend.service`
Treat those as scaffold or historical reference unless they have been explicitly updated to match the live split architecture. Treat those as scaffold or historical reference unless they have been explicitly updated to match the live split architecture.

View File

@@ -172,25 +172,26 @@ This document provides a detailed checklist of all tasks required to deploy the
#### Task 21: Create Systemd Service Files #### Task 21: Create Systemd Service Files
- [ ] Create `/etc/systemd/system/explorer-indexer.service` - [ ] Create `/etc/systemd/system/explorer-indexer.service`
- [ ] Create `/etc/systemd/system/explorer-api.service` - [ ] Create `/etc/systemd/system/explorer-api.service`
- [ ] Create `/etc/systemd/system/explorer-frontend.service` - [ ] Create `/etc/systemd/system/solacescanscout-frontend.service`
- [ ] Set proper ownership: `chown root:root /etc/systemd/system/explorer-*.service` - [ ] Set proper ownership: `chown root:root /etc/systemd/system/explorer-*.service /etc/systemd/system/solacescanscout-frontend.service`
- [ ] Set proper permissions: `chmod 644 /etc/systemd/system/explorer-*.service` - [ ] Set proper permissions: `chmod 644 /etc/systemd/system/explorer-*.service /etc/systemd/system/solacescanscout-frontend.service`
#### Task 22: Enable and Start Services #### Task 22: Enable and Start Services
- [ ] Reload systemd: `systemctl daemon-reload` - [ ] Reload systemd: `systemctl daemon-reload`
- [ ] Enable indexer: `systemctl enable explorer-indexer` - [ ] Enable indexer: `systemctl enable explorer-indexer`
- [ ] Enable API: `systemctl enable explorer-api` - [ ] Enable API: `systemctl enable explorer-api`
- [ ] Enable frontend: `systemctl enable explorer-frontend` - [ ] Enable frontend: `systemctl enable solacescanscout-frontend`
- [ ] Start indexer: `systemctl start explorer-indexer` - [ ] Start indexer: `systemctl start explorer-indexer`
- [ ] Start API: `systemctl start explorer-api` - [ ] Start API: `systemctl start explorer-api`
- [ ] Start frontend: `systemctl start explorer-frontend` - [ ] Start frontend: `systemctl start solacescanscout-frontend`
#### Task 23: Verify Services #### Task 23: Verify Services
- [ ] Check indexer status: `systemctl status explorer-indexer` - [ ] Check indexer status: `systemctl status explorer-indexer`
- [ ] Check API status: `systemctl status explorer-api` - [ ] Check API status: `systemctl status explorer-api`
- [ ] Check frontend status: `systemctl status explorer-frontend` - [ ] Check frontend status: `systemctl status solacescanscout-frontend`
- [ ] Check indexer logs: `journalctl -u explorer-indexer -f` - [ ] Check indexer logs: `journalctl -u explorer-indexer -f`
- [ ] Check API logs: `journalctl -u explorer-api -f` - [ ] Check API logs: `journalctl -u explorer-api -f`
- [ ] Check frontend logs: `journalctl -u solacescanscout-frontend -f`
- [ ] Verify API responds: `curl http://localhost:8080/health` - [ ] Verify API responds: `curl http://localhost:8080/health`
- [ ] Verify frontend responds: `curl http://localhost:3000` - [ ] Verify frontend responds: `curl http://localhost:3000`
@@ -558,4 +559,3 @@ This document provides a detailed checklist of all tasks required to deploy the
**Last Updated**: 2024-12-23 **Last Updated**: 2024-12-23
**Version**: 1.0.0 **Version**: 1.0.0

View File

@@ -110,6 +110,8 @@ SOUL_MACHINES_API_SECRET=
CORS_ALLOWED_ORIGIN= CORS_ALLOWED_ORIGIN=
JWT_SECRET=CHANGE_THIS_JWT_SECRET JWT_SECRET=CHANGE_THIS_JWT_SECRET
ENCRYPTION_KEY=CHANGE_THIS_ENCRYPTION_KEY_32_BYTES ENCRYPTION_KEY=CHANGE_THIS_ENCRYPTION_KEY_32_BYTES
ACCESS_ADMIN_EMAILS=
ACCESS_INTERNAL_SECRET=CHANGE_THIS_INTERNAL_ACCESS_SECRET
# ============================================ # ============================================
# Monitoring (Optional) # Monitoring (Optional)
@@ -126,4 +128,3 @@ ENABLE_WEBSOCKET=true
ENABLE_ANALYTICS=true ENABLE_ANALYTICS=true
ENABLE_VTM=false ENABLE_VTM=false
ENABLE_XR=false ENABLE_XR=false

View File

@@ -10,6 +10,7 @@ Complete index of all deployment files and their purposes.
| `DEPLOYMENT_TASKS.md` | Detailed 71-task checklist | 561 | | `DEPLOYMENT_TASKS.md` | Detailed 71-task checklist | 561 |
| `DEPLOYMENT_CHECKLIST.md` | Interactive deployment checklist | 204 | | `DEPLOYMENT_CHECKLIST.md` | Interactive deployment checklist | 204 |
| `DEPLOYMENT_SUMMARY.md` | Deployment package summary | - | | `DEPLOYMENT_SUMMARY.md` | Deployment package summary | - |
| `ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md` | RPC/API-key edge enforcement for protected lanes | - |
| `QUICK_DEPLOY.md` | Quick command reference | - | | `QUICK_DEPLOY.md` | Quick command reference | - |
| `README.md` | Documentation overview | - | | `README.md` | Documentation overview | - |
| `INDEX.md` | This file | - | | `INDEX.md` | This file | - |
@@ -28,12 +29,16 @@ Complete index of all deployment files and their purposes.
| `scripts/setup-backup.sh` | Setup backup system | ✅ | | `scripts/setup-backup.sh` | Setup backup system | ✅ |
| `scripts/setup-health-check.sh` | Setup health monitoring | ✅ | | `scripts/setup-health-check.sh` | Setup health monitoring | ✅ |
| `scripts/verify-deployment.sh` | Verify deployment | ✅ | | `scripts/verify-deployment.sh` | Verify deployment | ✅ |
| `../scripts/render-rpc-access-gate-nginx.sh` | Render lane-specific nginx gate configs for `2101` / `2102` / `2103` | ✅ |
| `../scripts/install-rpc-access-gate-nginx-via-ssh.sh` | Dry-run-first remote installer for rendered RPC gate configs | ✅ |
| `scripts/full-deploy.sh` | Full automated deployment | ✅ | | `scripts/full-deploy.sh` | Full automated deployment | ✅ |
## ⚙️ Configuration Files ## ⚙️ Configuration Files
### Nginx ### Nginx
- `nginx/explorer.conf` - Complete Nginx reverse proxy configuration - `nginx/explorer.conf` - Complete Nginx reverse proxy configuration
- `common/nginx-rpc-api-key-gate.conf` - Example auth-gated RPC upstream template
- `../scripts/render-rpc-access-gate-nginx.sh` - Concrete renderer for auth-gated RPC upstream configs
### Cloudflare ### Cloudflare
- `cloudflare/tunnel-config.yml` - Cloudflare Tunnel configuration template - `cloudflare/tunnel-config.yml` - Cloudflare Tunnel configuration template
@@ -41,7 +46,7 @@ Complete index of all deployment files and their purposes.
### Systemd Services ### Systemd Services
- `systemd/explorer-indexer.service` - Indexer service file - `systemd/explorer-indexer.service` - Indexer service file
- `systemd/explorer-api.service` - API service file - `systemd/explorer-api.service` - API service file
- `systemd/explorer-frontend.service` - Frontend service file - `systemd/solacescanscout-frontend.service` - Next frontend service file
- `systemd/cloudflared.service` - Cloudflare Tunnel service file - `systemd/cloudflared.service` - Cloudflare Tunnel service file
### Fail2ban ### Fail2ban
@@ -125,8 +130,8 @@ deployment/
# Install services # Install services
sudo ./deployment/scripts/install-services.sh sudo ./deployment/scripts/install-services.sh
sudo systemctl enable explorer-indexer explorer-api explorer-frontend sudo systemctl enable explorer-indexer explorer-api solacescanscout-frontend
sudo systemctl start explorer-indexer explorer-api explorer-frontend sudo systemctl start explorer-indexer explorer-api solacescanscout-frontend
# Setup Nginx # Setup Nginx
sudo ./deployment/scripts/setup-nginx.sh sudo ./deployment/scripts/setup-nginx.sh
@@ -142,7 +147,7 @@ sudo ./deployment/scripts/setup-cloudflare-tunnel.sh
```bash ```bash
# Check status # Check status
systemctl status explorer-indexer explorer-api explorer-frontend systemctl status explorer-indexer explorer-api solacescanscout-frontend
# View logs # View logs
journalctl -u explorer-api -f journalctl -u explorer-api -f
@@ -193,4 +198,3 @@ sudo ./deployment/scripts/full-deploy.sh
--- ---
**All deployment files are ready and documented!** **All deployment files are ready and documented!**

View File

@@ -1,12 +1,13 @@
# Live Deployment Map # Live Deployment Map
Current production deployment map for `explorer.d-bis.org`. Current production deployment map for the SolaceScan public explorer surface.
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?" 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 Entry Point
- Public domain: `https://explorer.d-bis.org` - Canonical public domain: `https://blockscout.defi-oracle.io`
- Companion surface: `https://explorer.d-bis.org`
- Primary container: VMID `5000` (`192.168.11.140`, `blockscout-1`) - Primary container: VMID `5000` (`192.168.11.140`, `blockscout-1`)
- Public edge: nginx on VMID `5000` - Public edge: nginx on VMID `5000`
@@ -28,6 +29,7 @@ This file is the authoritative reference for the live explorer stack as of `2026
| 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` | | 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 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 | | 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 |
| RPC/API-key edge enforcement | [`ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md`](./ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md), [`render-rpc-access-gate-nginx.sh`](../scripts/render-rpc-access-gate-nginx.sh) | Canonical nginx `auth_request` pattern plus renderer for `2101` / `2102` / `2103` lanes using the explorer validator |
## Relay Topology ## Relay Topology
@@ -48,16 +50,16 @@ The explorer backend reads these through `CCIP_RELAY_HEALTH_URL` or `CCIP_RELAY_
The following endpoints currently describe the live deployment contract: The following endpoints currently describe the live deployment contract:
- `https://explorer.d-bis.org/` - `https://blockscout.defi-oracle.io/`
- `https://explorer.d-bis.org/bridge` - `https://blockscout.defi-oracle.io/bridge`
- `https://explorer.d-bis.org/routes` - `https://blockscout.defi-oracle.io/routes`
- `https://explorer.d-bis.org/liquidity` - `https://blockscout.defi-oracle.io/liquidity`
- `https://explorer.d-bis.org/api/config/capabilities` - `https://blockscout.defi-oracle.io/api/config/capabilities`
- `https://explorer.d-bis.org/config/CHAIN138_RPC_CAPABILITIES.json` - `https://blockscout.defi-oracle.io/config/CHAIN138_RPC_CAPABILITIES.json`
- `https://explorer.d-bis.org/explorer-api/v1/features` - `https://blockscout.defi-oracle.io/explorer-api/v1/features`
- `https://explorer.d-bis.org/explorer-api/v1/track1/bridge/status` - `https://blockscout.defi-oracle.io/explorer-api/v1/track1/bridge/status`
- `https://explorer.d-bis.org/explorer-api/v1/mission-control/stream` - `https://blockscout.defi-oracle.io/explorer-api/v1/mission-control/stream`
- `https://explorer.d-bis.org/token-aggregation/api/v1/routes/matrix` - `https://blockscout.defi-oracle.io/token-aggregation/api/v1/routes/matrix`
## Recommended Rollout Order ## Recommended Rollout Order
@@ -78,7 +80,7 @@ When a change spans relays as well:
## Current Gaps And Legacy Footguns ## 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. - Older docs in this directory still describe a retired monolithic API-plus-frontend 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. - [`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. - 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. - `mainnet-weth` is deployed but intentionally paused until that bridge lane is funded again.

View File

@@ -26,10 +26,11 @@ pct enter 100
### Services ### Services
```bash ```bash
# Start all services # Start all services
systemctl start explorer-indexer explorer-api explorer-frontend systemctl start explorer-indexer explorer-api solacescanscout-frontend
# Check status # Check status
systemctl status explorer-indexer systemctl status explorer-indexer
journalctl -u solacescanscout-frontend -f
journalctl -u explorer-indexer -f journalctl -u explorer-indexer -f
# Restart # Restart
@@ -83,13 +84,13 @@ curl http://localhost:3000
curl http://localhost/api/health curl http://localhost/api/health
# Through Cloudflare # Through Cloudflare
curl https://explorer.d-bis.org/api/health curl https://blockscout.defi-oracle.io/api/health
``` ```
## File Locations ## File Locations
- **Config**: `/home/explorer/explorer-monorepo/.env` - **Config**: `/home/explorer/explorer-monorepo/.env`
- **Services**: `/etc/systemd/system/explorer-*.service` - **Services**: `/etc/systemd/system/explorer-*.service` and `/etc/systemd/system/solacescanscout-frontend.service`
- **Nginx**: `/etc/nginx/sites-available/explorer` - **Nginx**: `/etc/nginx/sites-available/explorer`
- **Tunnel**: `/etc/cloudflared/config.yml` - **Tunnel**: `/etc/cloudflared/config.yml`
- **Logs**: `/var/log/explorer/` and `journalctl -u explorer-*` - **Logs**: `/var/log/explorer/` and `journalctl -u explorer-*`
@@ -127,12 +128,11 @@ journalctl -u cloudflared -f
```bash ```bash
# Stop all services # Stop all services
systemctl stop explorer-indexer explorer-api explorer-frontend systemctl stop explorer-indexer explorer-api solacescanscout-frontend
# Restore from backup # Restore from backup
gunzip < backup.sql.gz | psql -U explorer explorer gunzip < backup.sql.gz | psql -U explorer explorer
# Restart services # Restart services
systemctl start explorer-indexer explorer-api explorer-frontend systemctl start explorer-indexer explorer-api solacescanscout-frontend
``` ```

View File

@@ -20,6 +20,7 @@ That file reflects the live split deployment now in production:
- Frontend deploy: [`scripts/deploy-next-frontend-to-vmid5000.sh`](../scripts/deploy-next-frontend-to-vmid5000.sh) - 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) - 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) - Explorer config/API deploy: [`scripts/deploy-explorer-ai-to-vmid5000.sh`](../scripts/deploy-explorer-ai-to-vmid5000.sh)
- RPC/API-key edge enforcement: [`ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md`](./ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md)
- Public health audit: [`scripts/check-explorer-health.sh`](../scripts/check-explorer-health.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) - Full public smoke: [`check-explorer-e2e.sh`](../../scripts/verify/check-explorer-e2e.sh)

View File

@@ -8,7 +8,8 @@ else
# Insert CSP line after add_header Cache-Control in first location = / # Insert CSP line after add_header Cache-Control in first location = /
sed -i '/location = \/ {/,/try_files \/index.html =404;/{ sed -i '/location = \/ {/,/try_files \/index.html =404;/{
/add_header Cache-Control "no-store, no-cache, must-revalidate"/a\ /add_header Cache-Control "no-store, no-cache, must-revalidate"/a\
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; add_header Referrer-Policy "strict-origin-when-cross-origin" 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://blockscout.defi-oracle.io wss://blockscout.defi-oracle.io 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;
}' "$CONFIG" }' "$CONFIG"
echo "Added CSP to HTTP location = /" echo "Added CSP to HTTP location = /"
fi fi

View File

@@ -6,7 +6,9 @@ Use as reference or copy into your project.
## Contents ## Contents
- **nginx-api-location.conf** Generic `location /api/` proxy snippet (upstream host/port to be adjusted). - **nginx-api-location.conf** Generic `location /api/` proxy snippet (upstream host/port to be adjusted).
- **nginx-rpc-api-key-gate.conf** Example `auth_request` pattern for API-key-protected RPC lanes using the explorer access validator.
- **systemd-api-service.example** Example systemd unit for a REST API (env and paths to be adjusted). - **systemd-api-service.example** Example systemd unit for a REST API (env and paths to be adjusted).
- **../scripts/render-rpc-access-gate-nginx.sh** Render a concrete nginx gate config for `core-rpc`, `alltra-rpc`, or `thirdweb-rpc`.
- **cloudflare / fail2ban** See parent `../cloudflare/` and `../fail2ban/` for full configs. - **cloudflare / fail2ban** See parent `../cloudflare/` and `../fail2ban/` for full configs.
When this is a separate repo, add as submodule at `deployment/common`. When this is a separate repo, add as submodule at `deployment/common`.

View File

@@ -1,4 +1,4 @@
# Next.js frontend proxy locations for SolaceScanScout. # Next.js frontend proxy locations for SolaceScan.
# Keep the existing higher-priority locations for: # Keep the existing higher-priority locations for:
# - /api/ # - /api/
# - /api/config/token-list # - /api/config/token-list
@@ -32,5 +32,6 @@ location / {
proxy_buffering off; proxy_buffering off;
proxy_hide_header Cache-Control; proxy_hide_header Cache-Control;
add_header Cache-Control "no-store, no-cache, must-revalidate" always; 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; add_header Referrer-Policy "strict-origin-when-cross-origin" 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://blockscout.defi-oracle.io wss://blockscout.defi-oracle.io 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;
} }

View File

@@ -0,0 +1,56 @@
# Example nginx gate for API-key-protected RPC upstreams using the explorer access API.
# This pattern assumes the explorer config/API backend listens on 127.0.0.1:8081 and
# exposes GET /api/v1/access/internal/validate-key for nginx auth_request.
#
# Replace:
# - ACCESS_INTERNAL_SECRET_VALUE with a real shared secret
# - protected-rpc.example.org with the public host you are protecting
# - upstream IP:port with the actual RPC lane (e.g. 192.168.11.212:8545 or 192.168.11.217:8545)
#
# Clients should send the API key as:
# - X-API-Key: sk_live_...
# or
# - Authorization: Bearer sk_live_...
server {
listen 443 ssl http2;
server_name protected-rpc.example.org;
# Internal subrequest used by auth_request.
location = /__access_validate_rpc {
internal;
proxy_pass http://127.0.0.1:8081/api/v1/access/internal/validate-key;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Access-Internal-Secret "ACCESS_INTERNAL_SECRET_VALUE";
proxy_set_header X-API-Key $http_x_api_key;
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Access-Method $request_method;
proxy_set_header X-Access-Request-Count "1";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
auth_request /__access_validate_rpc;
# Optional metadata exported from the validator for logging or rate decisions.
auth_request_set $validated_product $upstream_http_x_validated_product;
auth_request_set $validated_tier $upstream_http_x_validated_tier;
auth_request_set $validated_scopes $upstream_http_x_validated_scopes;
auth_request_set $quota_remaining $upstream_http_x_quota_remaining;
proxy_pass http://192.168.11.217:8545;
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;
# Helpful for downstream logs and operational tracing.
proxy_set_header X-Validated-Product $validated_product;
proxy_set_header X-Validated-Tier $validated_tier;
proxy_set_header X-Validated-Scopes $validated_scopes;
proxy_set_header X-Quota-Remaining $quota_remaining;
}
}

View File

@@ -17,6 +17,8 @@ Environment=RPC_URL=https://rpc-http-pub.d-bis.org
Environment=TOKEN_AGGREGATION_BASE_URL=http://127.0.0.1:3000 Environment=TOKEN_AGGREGATION_BASE_URL=http://127.0.0.1:3000
Environment=BLOCKSCOUT_INTERNAL_URL=http://127.0.0.1:4000 Environment=BLOCKSCOUT_INTERNAL_URL=http://127.0.0.1:4000
Environment=EXPLORER_PUBLIC_BASE=https://explorer.d-bis.org Environment=EXPLORER_PUBLIC_BASE=https://explorer.d-bis.org
Environment=ACCESS_ADMIN_EMAILS=ops@example.org
Environment=ACCESS_INTERNAL_SECRET=CHANGE_THIS_INTERNAL_ACCESS_SECRET
Environment=OPERATOR_SCRIPTS_ROOT=/opt/explorer/scripts Environment=OPERATOR_SCRIPTS_ROOT=/opt/explorer/scripts
Environment=OPERATOR_SCRIPT_ALLOWLIST=check-health.sh,check-bridges.sh Environment=OPERATOR_SCRIPT_ALLOWLIST=check-health.sh,check-bridges.sh
Environment=OPERATOR_SCRIPT_TIMEOUT_SEC=120 Environment=OPERATOR_SCRIPT_TIMEOUT_SEC=120

View File

@@ -74,8 +74,7 @@ echo "Next steps:"
echo "1. Configure .env file: /home/explorer/explorer-monorepo/.env" echo "1. Configure .env file: /home/explorer/explorer-monorepo/.env"
echo "2. Run database migrations" echo "2. Run database migrations"
echo "3. Build applications" echo "3. Build applications"
echo "4. Start services: systemctl start explorer-indexer explorer-api explorer-frontend" echo "4. Start services: systemctl start explorer-indexer explorer-api solacescanscout-frontend"
echo "5. Configure Cloudflare DNS and SSL" echo "5. Configure Cloudflare DNS and SSL"
echo "" echo ""
echo "See DEPLOYMENT_GUIDE.md for detailed instructions" echo "See DEPLOYMENT_GUIDE.md for detailed instructions"

View File

@@ -11,17 +11,17 @@ echo "Installing systemd service files..."
# Copy service files # Copy service files
cp "$DEPLOYMENT_DIR/systemd/explorer-indexer.service" /etc/systemd/system/ cp "$DEPLOYMENT_DIR/systemd/explorer-indexer.service" /etc/systemd/system/
cp "$DEPLOYMENT_DIR/systemd/explorer-api.service" /etc/systemd/system/ cp "$DEPLOYMENT_DIR/systemd/explorer-api.service" /etc/systemd/system/
cp "$DEPLOYMENT_DIR/systemd/explorer-frontend.service" /etc/systemd/system/ cp "$DEPLOYMENT_DIR/systemd/solacescanscout-frontend.service" /etc/systemd/system/
cp "$DEPLOYMENT_DIR/systemd/cloudflared.service" /etc/systemd/system/ cp "$DEPLOYMENT_DIR/systemd/cloudflared.service" /etc/systemd/system/
# Set permissions # Set permissions
chmod 644 /etc/systemd/system/explorer-*.service chmod 644 /etc/systemd/system/explorer-*.service
chmod 644 /etc/systemd/system/solacescanscout-frontend.service
chmod 644 /etc/systemd/system/cloudflared.service chmod 644 /etc/systemd/system/cloudflared.service
# Reload systemd # Reload systemd
systemctl daemon-reload systemctl daemon-reload
echo "Service files installed. Enable with:" echo "Service files installed. Enable with:"
echo " systemctl enable explorer-indexer explorer-api explorer-frontend" echo " systemctl enable explorer-indexer explorer-api solacescanscout-frontend"
echo " systemctl start explorer-indexer explorer-api explorer-frontend" echo " systemctl start explorer-indexer explorer-api solacescanscout-frontend"

View File

@@ -15,7 +15,7 @@ ERRORS=0
# Check services # Check services
echo "Checking services..." echo "Checking services..."
for service in explorer-indexer explorer-api explorer-frontend nginx postgresql; do for service in explorer-indexer explorer-api solacescanscout-frontend nginx postgresql; do
if systemctl is-active --quiet $service; then if systemctl is-active --quiet $service; then
echo -e "${GREEN}${NC} $service is running" echo -e "${GREEN}${NC} $service is running"
else else
@@ -100,4 +100,3 @@ else
echo -e "${RED}$ERRORS critical check(s) failed${NC}" echo -e "${RED}$ERRORS critical check(s) failed${NC}"
exit 1 exit 1
fi fi

View File

@@ -1,33 +0,0 @@
[Unit]
Description=ChainID 138 Explorer Frontend Service
Documentation=https://github.com/explorer/frontend
After=network.target explorer-api.service
Requires=explorer-api.service
[Service]
Type=simple
User=explorer
Group=explorer
WorkingDirectory=/home/explorer/explorer-monorepo/frontend
EnvironmentFile=/home/explorer/explorer-monorepo/.env
ExecStart=/usr/bin/npm start
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=explorer-frontend
# Security settings
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/explorer/explorer-monorepo/frontend
# Resource limits
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=multi-user.target

View File

@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=SolaceScanScout Next Frontend Service Description=SolaceScan Next Frontend Service
After=network.target After=network.target
Wants=network.target Wants=network.target

View File

@@ -1,4 +1,4 @@
# Changelog — SolaceScanScout Explorer # Changelog — SolaceScan Explorer
All notable frontend and docs changes are listed here. All notable frontend and docs changes are listed here.

View File

@@ -300,7 +300,7 @@ Once the backend is running:
### Backend Logs ### Backend Logs
The backend uses Go's standard `log` package. Logs will show: The backend uses Go's standard `log` package. Logs will show:
- Server startup: `Starting SolaceScanScout REST API server on :8080` - Server startup: `Starting SolaceScan REST API server on :8080`
- Request logs: `GET /api/v2/stats 200 2.5ms` - Request logs: `GET /api/v2/stats 200 2.5ms`
- Errors: Database connection errors, query failures, etc. - Errors: Database connection errors, query failures, etc.
@@ -330,7 +330,7 @@ Expected response:
}, },
"chain_id": 138, "chain_id": 138,
"explorer": { "explorer": {
"name": "SolaceScanScout", "name": "SolaceScan",
"version": "1.0.0" "version": "1.0.0"
} }
} }
@@ -359,4 +359,3 @@ Expected response:
--- ---
**Next Steps**: Start the backend server and re-run the diagnostic script to verify all issues are resolved. **Next Steps**: Start the backend server and re-run the diagnostic script to verify all issues are resolved.

View File

@@ -1,4 +1,4 @@
# SolaceScanScout — Additional Recommendations # SolaceScan — Additional Recommendations
This document lists **further improvements** beyond the upgrades already implemented (Tier 13 frontend, API docs, watchlist, labels, i18n, etc.). Items are grouped by effort and dependency (frontend-only vs backend). This document lists **further improvements** beyond the upgrades already implemented (Tier 13 frontend, API docs, watchlist, labels, i18n, etc.). Items are grouped by effort and dependency (frontend-only vs backend).

View File

@@ -1,11 +1,11 @@
# SolaceScanScout Explorer — API Reference # SolaceScan Explorer — API Reference
The SolaceScanScout frontend uses the **Blockscout v2 API** for chain data. When the explorer is served from the same origin (e.g. `https://explorer.d-bis.org` or VM IP), requests go to `/api` and are proxied to Blockscout (port 4000). This document lists the endpoints used by the frontend. The SolaceScan frontend uses the **Blockscout v2 API** for chain data. When the explorer is served from the same origin (e.g. `https://blockscout.defi-oracle.io` or VM IP), requests go to `/api` and are proxied to Blockscout (port 4000). This document lists the endpoints used by the frontend.
## Base URL ## Base URL
- **Same-origin:** `window.location.origin + '/api'` (e.g. `https://explorer.d-bis.org/api`) - **Same-origin:** `window.location.origin + '/api'` (e.g. `https://blockscout.defi-oracle.io/api`)
- **Fallback:** `https://explorer.d-bis.org/api` - **Fallback:** `https://blockscout.defi-oracle.io/api`
All paths below are relative to this base (e.g. `/v2/stats``{base}/v2/stats`). All paths below are relative to this base (e.g. `/v2/stats``{base}/v2/stats`).
@@ -81,7 +81,7 @@ The frontend does not send API keys. Rate limits are determined by the Blockscou
## OpenAPI / Swagger ## OpenAPI / Swagger
If your Blockscout instance exposes an OpenAPI (Swagger) spec, it is often at `{base}/api-docs` or `{base}/swagger`. Document that URL for your deployment (e.g. `https://explorer.d-bis.org/api-docs` if enabled). If your Blockscout instance exposes an OpenAPI (Swagger) spec, it is often at `{base}/api-docs` or `{base}/swagger`. Document that URL for your deployment (e.g. `https://blockscout.defi-oracle.io/api-docs` if enabled).
## Recent changes ## Recent changes

View File

@@ -5,7 +5,7 @@
## Executive Summary ## Executive Summary
The SolaceScanScout tiered architecture has been successfully deployed and tested. The API server is running and all core functionality is operational. The SolaceScan tiered architecture has been successfully deployed and tested. The API server is running and all core functionality is operational.
## Deployment Status ## Deployment Status

View File

@@ -1,13 +1,13 @@
# MetaMask and Dual-Chain Provider Integration # MetaMask and Dual-Chain Provider Integration
The explorer (SolaceScanScout) provides add-to-MetaMask and token list discovery for **Chain 138** (DeFi Oracle Meta Mainnet), **Ethereum Mainnet**, and **ALL Mainnet** (651940). The explorer (SolaceScan) provides add-to-MetaMask and token list discovery for **Chain 138** (DeFi Oracle Meta Mainnet), **Ethereum Mainnet**, and **ALL Mainnet** (651940).
## Explorer as discovery source ## Explorer as discovery source
- **Add to MetaMask:** Use the [Wallet](/wallet) page to add Chain 138, Ethereum Mainnet, or ALL Mainnet to your wallet via `wallet_addEthereumChain`. - **Add to MetaMask:** Use the [Wallet](/wallet) page to add Chain 138, Ethereum Mainnet, or ALL Mainnet to your wallet via `wallet_addEthereumChain`.
- **Token list URL:** The explorer API serves the dual-chain token list at: - **Token list URL:** The explorer API serves the dual-chain token list at:
- **Path:** `/api/config/token-list` - **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). - **Full URL:** `{EXPLORER_API_BASE}/api/config/token-list` (e.g. `https://blockscout.defi-oracle.io/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. 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. 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. - **Networks config:** `/api/config/networks` returns the same chain params (Chain 138 + Ethereum Mainnet) in JSON for programmatic use.
@@ -29,13 +29,13 @@ Discovery is via **token list** (hosted at the explorer token list URL above), *
- **Custom MetaMask Snap:** For in-wallet swap quotes, bridge routes, and pricing on Chain 138, see [SNAP_IMPLEMENTATION_ROADMAP.md](../../docs/04-configuration/metamask/SNAP_IMPLEMENTATION_ROADMAP.md). - **Custom MetaMask Snap:** For in-wallet swap quotes, bridge routes, and pricing on Chain 138, see [SNAP_IMPLEMENTATION_ROADMAP.md](../../docs/04-configuration/metamask/SNAP_IMPLEMENTATION_ROADMAP.md).
- **Feature parity and optional actions:** [METAMASK_CHAIN138_FEATURE_PARITY_ANALYSIS.md](../../docs/04-configuration/metamask/METAMASK_CHAIN138_FEATURE_PARITY_ANALYSIS.md) — Section 7 lists optional next steps (Snap, CoinGecko, Consensys outreach, market data API). - **Feature parity and optional actions:** [METAMASK_CHAIN138_FEATURE_PARITY_ANALYSIS.md](../../docs/04-configuration/metamask/METAMASK_CHAIN138_FEATURE_PARITY_ANALYSIS.md) — Section 7 lists optional next steps (Snap, CoinGecko, Consensys outreach, market data API).
## Live explorer (https://explorer.d-bis.org) ## Live explorer (https://blockscout.defi-oracle.io)
- **Wallet page:** https://explorer.d-bis.org/wallet - **Wallet page:** https://blockscout.defi-oracle.io/wallet
- **Token list URL:** https://explorer.d-bis.org/api/config/token-list - **Token list URL:** https://blockscout.defi-oracle.io/api/config/token-list
- **Networks config:** https://explorer.d-bis.org/api/config/networks - **Networks config:** https://blockscout.defi-oracle.io/api/config/networks
- **GRU v2 public rollout status:** https://explorer.d-bis.org/config/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json - **GRU v2 public rollout status:** https://blockscout.defi-oracle.io/config/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json
- **GRU v2 deployment queue:** https://explorer.d-bis.org/config/GRU_V2_DEPLOYMENT_QUEUE.json - **GRU v2 deployment queue:** https://blockscout.defi-oracle.io/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 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. 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.

View File

@@ -1,6 +1,6 @@
# Explorer Monorepo Documentation # Explorer Monorepo Documentation
Overview of documentation for the ChainID 138 Explorer (SolaceScanScout). Overview of documentation for the ChainID 138 Explorer (SolaceScan).
--- ---
@@ -9,7 +9,7 @@ Overview of documentation for the ChainID 138 Explorer (SolaceScanScout).
| Doc | Description | | Doc | Description |
|-----|-------------| |-----|-------------|
| **[INDEX.md](./INDEX.md)** | Full documentation index (bridge, setup, verification, operations) | | **[INDEX.md](./INDEX.md)** | Full documentation index (bridge, setup, verification, operations) |
| **[EXPLORER_API_ACCESS.md](./EXPLORER_API_ACCESS.md)** | API access, 502 fix, CSP, frontend deploy for https://explorer.d-bis.org | | **[EXPLORER_API_ACCESS.md](./EXPLORER_API_ACCESS.md)** | API access, 502 fix, CSP, frontend deploy for https://blockscout.defi-oracle.io |
| **[../README.md](../README.md)** | Project README: quick start, frontend, architecture, config | | **[../README.md](../README.md)** | Project README: quick start, frontend, architecture, config |
--- ---

View File

@@ -2,7 +2,7 @@
## Overview ## Overview
The SolaceScanScout Explorer has been successfully upgraded to a 4-track tiered architecture with feature-gated access control. The SolaceScan Explorer has been successfully upgraded to a 4-track tiered architecture with feature-gated access control.
## Implementation Status: ✅ COMPLETE ## Implementation Status: ✅ COMPLETE

View File

@@ -1,6 +1,6 @@
# Tiered Architecture Setup Guide # Tiered Architecture Setup Guide
Complete setup and integration guide for SolaceScanScout tiered architecture. Complete setup and integration guide for SolaceScan tiered architecture.
## Quick Start ## Quick Start

View File

@@ -1,21 +1,21 @@
openapi: 3.0.0 openapi: 3.0.0
info: info:
title: SolaceScanScout API title: SolaceScan API
version: 1.0.0 version: 1.0.0
description: | description: |
SolaceScanScout - The Defi Oracle Meta Explorer API SolaceScan API for the Chain 138 explorer surface
Comprehensive blockchain explorer API for ChainID 138 with cross-chain bridge monitoring, Comprehensive blockchain explorer API for ChainID 138 with cross-chain bridge monitoring,
WETH utilities, and real-time transaction tracking. WETH utilities, and real-time transaction tracking.
contact: contact:
name: SolaceScanScout Support name: SolaceScan Support
url: https://explorer.d-bis.org url: https://blockscout.defi-oracle.io
license: license:
name: MIT name: MIT
url: https://opensource.org/licenses/MIT url: https://opensource.org/licenses/MIT
servers: servers:
- url: https://explorer.d-bis.org/api - url: https://blockscout.defi-oracle.io/api
description: Production server description: Production server
- url: http://localhost:8080/api - url: http://localhost:8080/api
description: Local development server description: Local development server
@@ -307,4 +307,3 @@ components:
security: security:
- ApiKeyAuth: [] - ApiKeyAuth: []

View File

@@ -1,6 +1,6 @@
# Track API Contracts # Track API Contracts
Complete API contract definitions for all 4 tracks of SolaceScanScout Explorer. Complete API contract definitions for all 4 tracks of SolaceScan Explorer.
## Track 1: Public Meta Explorer (No Auth Required) ## Track 1: Public Meta Explorer (No Auth Required)
@@ -778,4 +778,3 @@ Paginated endpoints use consistent pagination:
} }
} }
``` ```

View File

@@ -1,6 +1,6 @@
# Track Feature Matrix # Track Feature Matrix
Feature flag mapping for SolaceScanScout Explorer tiered architecture. Feature flag mapping for SolaceScan Explorer tiered architecture.
## Overview ## Overview
@@ -278,4 +278,3 @@ Get available features for current user.
"permissions": [...] "permissions": [...]
} }
``` ```

View File

@@ -133,7 +133,7 @@ The frontend has two delivery paths:
## 6. Files Reviewed ## 6. Files Reviewed
- `public/index.html` full read and grep for escapeHtml, innerHTML, fetch, navigation, wallet. - `public/index.html` full read and grep for escapeHtml, innerHTML, fetch, navigation, wallet.
- `src/app/layout.tsx`, `src/app/page.tsx`, `src/app/wallet/page.tsx` - Historical note: the reviewed home and wallet surfaces were later consolidated into the Pages Router and now live under `src/pages` with shared components in `src/components`.
- `src/pages/_app.tsx`, `src/pages/blocks/index.tsx`, `src/pages/blocks/[number].tsx`, `src/pages/transactions/index.tsx`, `src/pages/transactions/[hash].tsx`, `src/pages/addresses/[address].tsx`, `src/pages/search/index.tsx` - `src/pages/_app.tsx`, `src/pages/blocks/index.tsx`, `src/pages/blocks/[number].tsx`, `src/pages/transactions/index.tsx`, `src/pages/transactions/[hash].tsx`, `src/pages/addresses/[address].tsx`, `src/pages/search/index.tsx`
- `src/components/common/Card.tsx`, `Button.tsx`, `Table.tsx` - `src/components/common/Card.tsx`, `Button.tsx`, `Table.tsx`
- `src/components/blockchain/Address.tsx`, `src/components/wallet/AddToMetaMask.tsx` - `src/components/blockchain/Address.tsx`, `src/components/wallet/AddToMetaMask.tsx`

View File

@@ -0,0 +1,34 @@
# Explorer Routing Conventions
This frontend intentionally uses one canonical public route per explorer surface.
## Canonical Paths
- Collections are plural: `/blocks`, `/transactions`, `/addresses`, `/tokens`, `/operations`
- Dynamic page segments are named for the identifier they accept:
- `/blocks/[number]`
- `/transactions/[hash]`
- `/addresses/[address]`
- `/tokens/[address]`
- Search is first-class and canonical at `/search`
## Legacy Aliases
- `/more` is a compatibility alias only.
- The canonical route is `/operations`.
- New links, UI copy, docs, and static assets should point to `/operations`.
## Navigation Rules
- Use named buckets instead of vague overflow labels.
- Prefer `Explore`, `Data`, and `Operations` over catch-all labels like `More`.
- If a route appears in the navbar, use the same label everywhere else unless there is a strong product reason not to.
## Router Guardrail
The canonical public router is `src/pages`.
- New public routes should be added in `src/pages` unless there is a compelling architectural reason not to.
- `src/app/globals.css` remains the shared stylesheet source and is imported from `src/pages/_app.tsx`.
- New route aliases should be handled centrally in `next.config.js` redirects.
- Avoid introducing duplicate public routes that expose the same content under different names.

View File

@@ -5,10 +5,10 @@ describe('resolveExplorerApiBase', () => {
it('prefers an explicit env value when present', () => { it('prefers an explicit env value when present', () => {
expect( expect(
resolveExplorerApiBase({ resolveExplorerApiBase({
envValue: 'https://explorer.d-bis.org/', envValue: 'https://blockscout.defi-oracle.io/',
browserOrigin: 'http://127.0.0.1:3000', browserOrigin: 'http://127.0.0.1:3000',
}) })
).toBe('https://explorer.d-bis.org') ).toBe('https://blockscout.defi-oracle.io')
}) })
it('falls back to same-origin in the browser when env is empty', () => { it('falls back to same-origin in the browser when env is empty', () => {

View File

@@ -2,6 +2,25 @@
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
output: 'standalone', output: 'standalone',
async redirects() {
return [
{
source: '/more',
destination: '/operations',
permanent: true,
},
{
source: '/docs.html',
destination: '/docs',
permanent: true,
},
{
source: '/docs/transaction-compliance',
destination: '/docs/transaction-review',
permanent: true,
},
]
},
// If you see a workspace lockfile warning: align on one package manager (npm or pnpm) in frontend, or ignore for dev/build. // If you see a workspace lockfile warning: align on one package manager (npm or pnpm) in frontend, or ignore for dev/build.
env: { env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? '', NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? '',

View File

@@ -13,6 +13,7 @@
"axios": "^1.6.2", "axios": "^1.6.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"date-fns": "^3.0.6", "date-fns": "^3.0.6",
"js-sha3": "^0.9.3",
"next": "^14.0.4", "next": "^14.0.4",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"react": "^18.2.0", "react": "^18.2.0",
@@ -1344,14 +1345,14 @@
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.28", "version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
@@ -2114,6 +2115,18 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/arg": { "node_modules/arg": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -2792,7 +2805,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
@@ -4912,6 +4925,12 @@
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
}, },
"node_modules/js-sha3": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.9.3.tgz",
"integrity": "sha512-BcJPCQeLg6WjEx3FE591wVAevlli8lxsxm9/FzV4HXkV49TmBH38Yvrpce6fjbADGMKFrBMGTqrVz3qPIZ88Gg==",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5160,6 +5179,18 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -5743,12 +5774,12 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=12"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
@@ -6103,6 +6134,18 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -7025,18 +7068,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinypool": { "node_modules/tinypool": {
"version": "0.8.4", "version": "0.8.4",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz",

View File

@@ -21,6 +21,7 @@
"axios": "^1.6.2", "axios": "^1.6.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"date-fns": "^3.0.6", "date-fns": "^3.0.6",
"js-sha3": "^0.9.3",
"next": "^14.0.4", "next": "^14.0.4",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"react": "^18.2.0", "react": "^18.2.0",

View File

@@ -3,8 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Acknowledgments | SolaceScanScout</title> <title>Acknowledgments | SolaceScan</title>
<meta name="description" content="Acknowledgments for the SolaceScanScout explorer."> <meta name="description" content="Acknowledgments for the SolaceScan Chain 138 explorer.">
<style> <style>
body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: linear-gradient(180deg, #0f172a 0%, #111827 45%, #f8fafc 46%, #ffffff 100%); color: #0f172a; } body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: linear-gradient(180deg, #0f172a 0%, #111827 45%, #f8fafc 46%, #ffffff 100%); color: #0f172a; }
.shell { max-width: 980px; margin: 0 auto; padding: 2rem 1rem 3rem; } .shell { max-width: 980px; margin: 0 auto; padding: 2rem 1rem 3rem; }
@@ -19,19 +19,19 @@
<body> <body>
<div class="shell"> <div class="shell">
<div class="topbar"> <div class="topbar">
<div class="brand">SolaceScanScout Acknowledgments</div> <div class="brand">SolaceScan Acknowledgments</div>
<a href="/">Back to explorer</a> <a href="/">Back to explorer</a>
</div> </div>
<div class="card"> <div class="card">
<h1 style="margin-top:0;">Acknowledgments</h1> <h1 style="margin-top:0;">Acknowledgments</h1>
<p class="muted">This explorer and its companion tools are built with help from the open-source and infrastructure tools below.</p> <p class="muted">This explorer and its companion tools are built with help from the open-source and infrastructure tools below. Inclusion here means the project depends on or interoperates with these tools; it does not imply that every related public workflow is fully implemented on every explorer page.</p>
<ul> <ul>
<li><strong>Blockscout</strong> for explorer indexing and API compatibility.</li> <li><strong>Blockscout</strong> for explorer indexing and API compatibility.</li>
<li><strong>MetaMask</strong> for wallet connectivity and Snap support.</li> <li><strong>MetaMask</strong> for wallet connectivity and Snap support.</li>
<li><strong>Chainlink CCIP</strong> for bridge-related routing and transport.</li> <li><strong>Chainlink CCIP</strong> for bridge-related routing, transport, and companion operational surfaces where applicable.</li>
<li><strong>ethers.js</strong> for wallet and Ethereum interaction support.</li> <li><strong>ethers.js</strong> for wallet and Ethereum interaction support.</li>
<li><strong>Font Awesome</strong> for iconography.</li> <li><strong>Font Awesome</strong> for iconography.</li>
<li><strong>Next.js</strong> and the frontend contributors at Solace Bank Group PLC.</li> <li><strong>Next.js</strong> and the frontend contributors supporting the DBIS / Defi Oracle explorer experience.</li>
</ul> </ul>
<p class="muted">If we have missed a contributor or dependency, please let us know at <a href="mailto:support@d-bis.org">support@d-bis.org</a>.</p> <p class="muted">If we have missed a contributor or dependency, please let us know at <a href="mailto:support@d-bis.org">support@d-bis.org</a>.</p>
</div> </div>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chain 138 — Visual Command Center</title> <title>Chain 138 — Visual Command Center</title>
<!-- Mermaid: local copy (vendor via explorer-monorepo/scripts/vendor-mermaid-for-command-center.sh). CDN fallback: jsdelivr mermaid@10 --> <!-- Mermaid: local copy preferred; runtime fallback loader below -->
<script src="/thirdparty/mermaid.min.js"></script> <script src="/thirdparty/mermaid.min.js"></script>
<style> <style>
:root { :root {
@@ -122,15 +122,43 @@
text-align: center; text-align: center;
} }
footer code { color: #a5b4fc; } footer code { color: #a5b4fc; }
.status-note {
margin: 0.75rem 1.25rem 0;
padding: 0.85rem 1rem;
border-radius: 12px;
border: 1px solid #334155;
background: rgba(15, 23, 42, 0.8);
color: var(--muted);
font-size: 0.875rem;
line-height: 1.5;
}
.status-note a {
color: #93c5fd;
text-decoration: none;
}
.status-note a:hover { text-decoration: underline; }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>Chain 138 — deployment and liquidity topology</h1> <h1>Chain 138 — deployment and liquidity topology</h1>
<p>Operator-style view of the architecture in <code>docs/02-architecture/SMOM_DBIS_138_FULL_DEPLOYMENT_FLOW_MAP.md</code>. 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: <code>?tab=mission-control</code> or numeric <code>?tab=0</code><code>8</code> (slug per tab).</p> <p>Operator-style view of the architecture in <code>docs/02-architecture/SMOM_DBIS_138_FULL_DEPLOYMENT_FLOW_MAP.md</code>. Diagrams are informational only; contract addresses live in explorer config and repo references. The main explorer remains the canonical live operational surface. Deep links: <code>?tab=mission-control</code> or numeric <code>?tab=0</code><code>8</code> (slug per tab).</p>
</header> </header>
<div class="status-note" id="mermaid-status">
Loading local diagram assets. If the local Mermaid bundle is unavailable, the page will try a trusted CDN fallback automatically.
</div>
<div class="status-note" id="command-center-fallback">
If diagram rendering is unavailable, use the main explorer operational surfaces directly:
<a href="/operations">Operations Hub</a>,
<a href="/bridge">Bridge Monitoring</a>,
<a href="/routes">Routes</a>,
<a href="/system">System</a>,
and <a href="/operator">Operator</a>.
</div>
<div class="toolbar"> <div class="toolbar">
<div class="tabs" role="tablist" aria-label="Topology panels"> <div class="tabs" role="tablist" aria-label="Topology panels">
<button type="button" id="tab-0" class="tab active" role="tab" aria-selected="true" aria-controls="panel-0" data-tab="0" tabindex="0">Master map</button> <button type="button" id="tab-0" class="tab active" role="tab" aria-selected="true" aria-controls="panel-0" data-tab="0" tabindex="0">Master map</button>
@@ -143,7 +171,7 @@
<button type="button" id="tab-7" class="tab" role="tab" aria-selected="false" aria-controls="panel-7" data-tab="7" tabindex="-1">Integrations</button> <button type="button" id="tab-7" class="tab" role="tab" aria-selected="false" aria-controls="panel-7" data-tab="7" tabindex="-1">Integrations</button>
<button type="button" id="tab-8" class="tab" role="tab" aria-selected="false" aria-controls="panel-8" data-tab="8" tabindex="-1">Mission Control</button> <button type="button" id="tab-8" class="tab" role="tab" aria-selected="false" aria-controls="panel-8" data-tab="8" tabindex="-1">Mission Control</button>
</div> </div>
<a class="back" href="/more">Back to More</a> <a class="back" href="/operations">Back to Operations</a>
</div> </div>
<!-- 0 Master --> <!-- 0 Master -->
@@ -594,6 +622,25 @@ flowchart LR
return 0; return 0;
} }
function ensureMermaid() {
if (window.mermaid && typeof window.mermaid.run === 'function') {
return Promise.resolve(window.mermaid);
}
return new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js';
script.async = true;
script.onload = function () {
if (window.mermaid && typeof window.mermaid.run === 'function') resolve(window.mermaid);
else reject(new Error('Mermaid fallback loaded without runtime'));
};
script.onerror = function () {
reject(new Error('Mermaid fallback failed to load'));
};
document.head.appendChild(script);
});
}
function syncUrl(index) { function syncUrl(index) {
var slug = TAB_SLUGS[index] != null ? TAB_SLUGS[index] : String(index); var slug = TAB_SLUGS[index] != null ? TAB_SLUGS[index] : String(index);
try { try {
@@ -627,9 +674,14 @@ flowchart LR
var nodes = panel.querySelectorAll('.mermaid'); var nodes = panel.querySelectorAll('.mermaid');
if (nodes.length) { if (nodes.length) {
try { try {
await ensureMermaid();
await mermaid.run({ nodes: nodes }); await mermaid.run({ nodes: nodes });
var status = document.getElementById('mermaid-status');
if (status) status.textContent = 'Diagram assets loaded. This page is a public reference surface; the main explorer remains the canonical live operational view.';
} catch (e) { } catch (e) {
console.error('Mermaid render failed for panel', index, e); console.error('Mermaid render failed for panel', index, e);
var statusError = document.getElementById('mermaid-status');
if (statusError) statusError.textContent = 'Diagram rendering failed. Use the Operations Hub or the main explorer for live operational surfaces.';
} }
} }
} }

View File

@@ -7,280 +7,280 @@
"data": { "data": {
"id": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "id": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"label": "WETH9 (0xC02aaA39…)", "label": "WETH9 (0xC02aaA39…)",
"href": "https://explorer.d-bis.org/address/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" "href": "/addresses/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
} }
}, },
{ {
"data": { "data": {
"id": "0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f", "id": "0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f",
"label": "WETH10 (0xf4BB2e28…)", "label": "WETH10 (0xf4BB2e28…)",
"href": "https://explorer.d-bis.org/address/0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f" "href": "/addresses/0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f"
} }
}, },
{ {
"data": { "data": {
"id": "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506", "id": "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506",
"label": "Oracle_Aggregator (0x99b3511a…)", "label": "Oracle_Aggregator (0x99b3511a…)",
"href": "https://explorer.d-bis.org/address/0x99b3511a2d315a497c8112c1fdd8d508d4b1e506" "href": "/addresses/0x99b3511a2d315a497c8112c1fdd8d508d4b1e506"
} }
}, },
{ {
"data": { "data": {
"id": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6", "id": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6",
"label": "Oracle_Proxy (0x3304b747…)", "label": "Oracle_Proxy (0x3304b747…)",
"href": "https://explorer.d-bis.org/address/0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6" "href": "/addresses/0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6"
} }
}, },
{ {
"data": { "data": {
"id": "0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817", "id": "0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817",
"label": "CCIP_Router (0x42DAb7b8…)", "label": "CCIP_Router (0x42DAb7b8…)",
"href": "https://explorer.d-bis.org/address/0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817" "href": "/addresses/0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817"
} }
}, },
{ {
"data": { "data": {
"id": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e", "id": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e",
"label": "CCIP_Router_Direct_Legacy (0x8078A096…)", "label": "CCIP_Router_Direct_Legacy (0x8078A096…)",
"href": "https://explorer.d-bis.org/address/0x8078a09637e47fa5ed34f626046ea2094a5cde5e" "href": "/addresses/0x8078a09637e47fa5ed34f626046ea2094a5cde5e"
} }
}, },
{ {
"data": { "data": {
"id": "0x105f8a15b819948a89153505762444ee9f324684", "id": "0x105f8a15b819948a89153505762444ee9f324684",
"label": "CCIP_Sender (0x105F8A15…)", "label": "CCIP_Sender (0x105F8A15…)",
"href": "https://explorer.d-bis.org/address/0x105f8a15b819948a89153505762444ee9f324684" "href": "/addresses/0x105f8a15b819948a89153505762444ee9f324684"
} }
}, },
{ {
"data": { "data": {
"id": "0xcacfd227a040002e49e2e01626363071324f820a", "id": "0xcacfd227a040002e49e2e01626363071324f820a",
"label": "CCIPWETH9_Bridge (0xcacfd227…)", "label": "CCIPWETH9_Bridge (0xcacfd227…)",
"href": "https://explorer.d-bis.org/address/0xcacfd227a040002e49e2e01626363071324f820a" "href": "/addresses/0xcacfd227a040002e49e2e01626363071324f820a"
} }
}, },
{ {
"data": { "data": {
"id": "0x971cd9d156f193df8051e48043c476e53ecd4693", "id": "0x971cd9d156f193df8051e48043c476e53ecd4693",
"label": "CCIPWETH9_Bridge_Direct_Legacy (0x971cD9D1…)", "label": "CCIPWETH9_Bridge_Direct_Legacy (0x971cD9D1…)",
"href": "https://explorer.d-bis.org/address/0x971cd9d156f193df8051e48043c476e53ecd4693" "href": "/addresses/0x971cd9d156f193df8051e48043c476e53ecd4693"
} }
}, },
{ {
"data": { "data": {
"id": "0xe0e93247376aa097db308b92e6ba36ba015535d0", "id": "0xe0e93247376aa097db308b92e6ba36ba015535d0",
"label": "CCIPWETH10_Bridge (0xe0E93247…)", "label": "CCIPWETH10_Bridge (0xe0E93247…)",
"href": "https://explorer.d-bis.org/address/0xe0e93247376aa097db308b92e6ba36ba015535d0" "href": "/addresses/0xe0e93247376aa097db308b92e6ba36ba015535d0"
} }
}, },
{ {
"data": { "data": {
"id": "0xb7721dd53a8c629d9f1ba31a5819afe250002b03", "id": "0xb7721dd53a8c629d9f1ba31a5819afe250002b03",
"label": "LINK (0xb7721dD5…)", "label": "LINK (0xb7721dD5…)",
"href": "https://explorer.d-bis.org/address/0xb7721dd53a8c629d9f1ba31a5819afe250002b03" "href": "/addresses/0xb7721dd53a8c629d9f1ba31a5819afe250002b03"
} }
}, },
{ {
"data": { "data": {
"id": "0x93e66202a11b1772e55407b32b44e5cd8eda7f22", "id": "0x93e66202a11b1772e55407b32b44e5cd8eda7f22",
"label": "cUSDT (0x93E66202…)", "label": "cUSDT (0x93E66202…)",
"href": "https://explorer.d-bis.org/address/0x93e66202a11b1772e55407b32b44e5cd8eda7f22" "href": "/addresses/0x93e66202a11b1772e55407b32b44e5cd8eda7f22"
} }
}, },
{ {
"data": { "data": {
"id": "0xf22258f57794cc8e06237084b353ab30fffa640b", "id": "0xf22258f57794cc8e06237084b353ab30fffa640b",
"label": "cUSDC (0xf22258f5…)", "label": "cUSDC (0xf22258f5…)",
"href": "https://explorer.d-bis.org/address/0xf22258f57794cc8e06237084b353ab30fffa640b" "href": "/addresses/0xf22258f57794cc8e06237084b353ab30fffa640b"
} }
}, },
{ {
"data": { "data": {
"id": "0x9fbfab33882efe0038daa608185718b772ee5660", "id": "0x9fbfab33882efe0038daa608185718b772ee5660",
"label": "cUSDT_V2 (0x9FBfab33…)", "label": "cUSDT_V2 (0x9FBfab33…)",
"href": "https://explorer.d-bis.org/address/0x9fbfab33882efe0038daa608185718b772ee5660" "href": "/addresses/0x9fbfab33882efe0038daa608185718b772ee5660"
} }
}, },
{ {
"data": { "data": {
"id": "0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d", "id": "0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d",
"label": "cUSDC_V2 (0x219522c6…)", "label": "cUSDC_V2 (0x219522c6…)",
"href": "https://explorer.d-bis.org/address/0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d" "href": "/addresses/0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d"
} }
}, },
{ {
"data": { "data": {
"id": "0x91efe92229dbf7c5b38d422621300956b55870fa", "id": "0x91efe92229dbf7c5b38d422621300956b55870fa",
"label": "TokenRegistry (0x91Efe922…)", "label": "TokenRegistry (0x91Efe922…)",
"href": "https://explorer.d-bis.org/address/0x91efe92229dbf7c5b38d422621300956b55870fa" "href": "/addresses/0x91efe92229dbf7c5b38d422621300956b55870fa"
} }
}, },
{ {
"data": { "data": {
"id": "0xebfb5c60de5f7c4baae180ca328d3bb39e1a5133", "id": "0xebfb5c60de5f7c4baae180ca328d3bb39e1a5133",
"label": "TokenFactory (0xEBFb5C60…)", "label": "TokenFactory (0xEBFb5C60…)",
"href": "https://explorer.d-bis.org/address/0xebfb5c60de5f7c4baae180ca328d3bb39e1a5133" "href": "/addresses/0xebfb5c60de5f7c4baae180ca328d3bb39e1a5133"
} }
}, },
{ {
"data": { "data": {
"id": "0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1", "id": "0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1",
"label": "ComplianceRegistry (0xbc54fe2b…)", "label": "ComplianceRegistry (0xbc54fe2b…)",
"href": "https://explorer.d-bis.org/address/0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1" "href": "/addresses/0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1"
} }
}, },
{ {
"data": { "data": {
"id": "0x31884f84555210ffb36a19d2471b8ebc7372d0a8", "id": "0x31884f84555210ffb36a19d2471b8ebc7372d0a8",
"label": "BridgeVault (0x31884f84…)", "label": "BridgeVault (0x31884f84…)",
"href": "https://explorer.d-bis.org/address/0x31884f84555210ffb36a19d2471b8ebc7372d0a8" "href": "/addresses/0x31884f84555210ffb36a19d2471b8ebc7372d0a8"
} }
}, },
{ {
"data": { "data": {
"id": "0xf78246eb94c6cb14018e507e60661314e5f4c53f", "id": "0xf78246eb94c6cb14018e507e60661314e5f4c53f",
"label": "FeeCollector (0xF78246eB…)", "label": "FeeCollector (0xF78246eB…)",
"href": "https://explorer.d-bis.org/address/0xf78246eb94c6cb14018e507e60661314e5f4c53f" "href": "/addresses/0xf78246eb94c6cb14018e507e60661314e5f4c53f"
} }
}, },
{ {
"data": { "data": {
"id": "0x95bc4a997c0670d5dac64d55cdf3769b53b63c28", "id": "0x95bc4a997c0670d5dac64d55cdf3769b53b63c28",
"label": "DebtRegistry (0x95BC4A99…)", "label": "DebtRegistry (0x95BC4A99…)",
"href": "https://explorer.d-bis.org/address/0x95bc4a997c0670d5dac64d55cdf3769b53b63c28" "href": "/addresses/0x95bc4a997c0670d5dac64d55cdf3769b53b63c28"
} }
}, },
{ {
"data": { "data": {
"id": "0x0c4fd27018130a00762a802f91a72d6a64a60f14", "id": "0x0c4fd27018130a00762a802f91a72d6a64a60f14",
"label": "PolicyManager (0x0C4FD270…)", "label": "PolicyManager (0x0C4FD270…)",
"href": "https://explorer.d-bis.org/address/0x0c4fd27018130a00762a802f91a72d6a64a60f14" "href": "/addresses/0x0c4fd27018130a00762a802f91a72d6a64a60f14"
} }
}, },
{ {
"data": { "data": {
"id": "0x0059e237973179146237ab49f1322e8197c22b21", "id": "0x0059e237973179146237ab49f1322e8197c22b21",
"label": "TokenImplementation (0x0059e237…)", "label": "TokenImplementation (0x0059e237…)",
"href": "https://explorer.d-bis.org/address/0x0059e237973179146237ab49f1322e8197c22b21" "href": "/addresses/0x0059e237973179146237ab49f1322e8197c22b21"
} }
}, },
{ {
"data": { "data": {
"id": "0xd3ad6831aacb5386b8a25bb8d8176a6c8a026f04", "id": "0xd3ad6831aacb5386b8a25bb8d8176a6c8a026f04",
"label": "PriceFeed_Keeper (0xD3AD6831…)", "label": "PriceFeed_Keeper (0xD3AD6831…)",
"href": "https://explorer.d-bis.org/address/0xd3ad6831aacb5386b8a25bb8d8176a6c8a026f04" "href": "/addresses/0xd3ad6831aacb5386b8a25bb8d8176a6c8a026f04"
} }
}, },
{ {
"data": { "data": {
"id": "0x8918ee0819fd687f4eb3e8b9b7d0ef7557493cfa", "id": "0x8918ee0819fd687f4eb3e8b9b7d0ef7557493cfa",
"label": "OraclePriceFeed (0x8918eE08…)", "label": "OraclePriceFeed (0x8918eE08…)",
"href": "https://explorer.d-bis.org/address/0x8918ee0819fd687f4eb3e8b9b7d0ef7557493cfa" "href": "/addresses/0x8918ee0819fd687f4eb3e8b9b7d0ef7557493cfa"
} }
}, },
{ {
"data": { "data": {
"id": "0x3e8725b8de386fef3efe5678c92ea6adb41992b2", "id": "0x3e8725b8de386fef3efe5678c92ea6adb41992b2",
"label": "WETH_MockPriceFeed (0x3e8725b8…)", "label": "WETH_MockPriceFeed (0x3e8725b8…)",
"href": "https://explorer.d-bis.org/address/0x3e8725b8de386fef3efe5678c92ea6adb41992b2" "href": "/addresses/0x3e8725b8de386fef3efe5678c92ea6adb41992b2"
} }
}, },
{ {
"data": { "data": {
"id": "0x16d9a2cb94a0b92721d93db4a6cd8023d3338800", "id": "0x16d9a2cb94a0b92721d93db4a6cd8023d3338800",
"label": "MerchantSettlementRegistry (0x16D9A2cB…)", "label": "MerchantSettlementRegistry (0x16D9A2cB…)",
"href": "https://explorer.d-bis.org/address/0x16d9a2cb94a0b92721d93db4a6cd8023d3338800" "href": "/addresses/0x16d9a2cb94a0b92721d93db4a6cd8023d3338800"
} }
}, },
{ {
"data": { "data": {
"id": "0xe77cb26ea300e2f5304b461b0ec94c8ad6a7e46d", "id": "0xe77cb26ea300e2f5304b461b0ec94c8ad6a7e46d",
"label": "WithdrawalEscrow (0xe77cb26e…)", "label": "WithdrawalEscrow (0xe77cb26e…)",
"href": "https://explorer.d-bis.org/address/0xe77cb26ea300e2f5304b461b0ec94c8ad6a7e46d" "href": "/addresses/0xe77cb26ea300e2f5304b461b0ec94c8ad6a7e46d"
} }
}, },
{ {
"data": { "data": {
"id": "0xaee4b7fbe82e1f8295951584cbc772b8bbd68575", "id": "0xaee4b7fbe82e1f8295951584cbc772b8bbd68575",
"label": "UniversalAssetRegistry (0xAEE4b7fB…)", "label": "UniversalAssetRegistry (0xAEE4b7fB…)",
"href": "https://explorer.d-bis.org/address/0xaee4b7fbe82e1f8295951584cbc772b8bbd68575" "href": "/addresses/0xaee4b7fbe82e1f8295951584cbc772b8bbd68575"
} }
}, },
{ {
"data": { "data": {
"id": "0xa6891d5229f2181a34d4ff1b515c3aa37dd90e0e", "id": "0xa6891d5229f2181a34d4ff1b515c3aa37dd90e0e",
"label": "GovernanceController (0xA6891D52…)", "label": "GovernanceController (0xA6891D52…)",
"href": "https://explorer.d-bis.org/address/0xa6891d5229f2181a34d4ff1b515c3aa37dd90e0e" "href": "/addresses/0xa6891d5229f2181a34d4ff1b515c3aa37dd90e0e"
} }
}, },
{ {
"data": { "data": {
"id": "0xcd42e8ed79dc50599535d1de48d3dafa0be156f8", "id": "0xcd42e8ed79dc50599535d1de48d3dafa0be156f8",
"label": "UniversalCCIPBridge (0xCd42e8eD…)", "label": "UniversalCCIPBridge (0xCd42e8eD…)",
"href": "https://explorer.d-bis.org/address/0xcd42e8ed79dc50599535d1de48d3dafa0be156f8" "href": "/addresses/0xcd42e8ed79dc50599535d1de48d3dafa0be156f8"
} }
}, },
{ {
"data": { "data": {
"id": "0xbe9e0b2d4cf6a3b2994d6f2f0904d2b165eb8ffc", "id": "0xbe9e0b2d4cf6a3b2994d6f2f0904d2b165eb8ffc",
"label": "UniversalCCIPFlashBridgeAdapter (0xBe9e0B2d…)", "label": "UniversalCCIPFlashBridgeAdapter (0xBe9e0B2d…)",
"href": "https://explorer.d-bis.org/address/0xbe9e0b2d4cf6a3b2994d6f2f0904d2b165eb8ffc" "href": "/addresses/0xbe9e0b2d4cf6a3b2994d6f2f0904d2b165eb8ffc"
} }
}, },
{ {
"data": { "data": {
"id": "0xd084b68cb4b1ef2cba09cf99fb1b6552fd9b4859", "id": "0xd084b68cb4b1ef2cba09cf99fb1b6552fd9b4859",
"label": "CrossChainFlashRepayReceiver (0xD084b68c…)", "label": "CrossChainFlashRepayReceiver (0xD084b68c…)",
"href": "https://explorer.d-bis.org/address/0xd084b68cb4b1ef2cba09cf99fb1b6552fd9b4859" "href": "/addresses/0xd084b68cb4b1ef2cba09cf99fb1b6552fd9b4859"
} }
}, },
{ {
"data": { "data": {
"id": "0x89f7a1fcbbe104bee96da4b4b6b7d3af85f7e661", "id": "0x89f7a1fcbbe104bee96da4b4b6b7d3af85f7e661",
"label": "CrossChainFlashVaultCreditReceiver (0x89F7a1fc…)", "label": "CrossChainFlashVaultCreditReceiver (0x89F7a1fc…)",
"href": "https://explorer.d-bis.org/address/0x89f7a1fcbbe104bee96da4b4b6b7d3af85f7e661" "href": "/addresses/0x89f7a1fcbbe104bee96da4b4b6b7d3af85f7e661"
} }
}, },
{ {
"data": { "data": {
"id": "0x89ab428c437f23bab9781ff8db8d3848e27eed6c", "id": "0x89ab428c437f23bab9781ff8db8d3848e27eed6c",
"label": "BridgeOrchestrator (0x89aB428c…)", "label": "BridgeOrchestrator (0x89aB428c…)",
"href": "https://explorer.d-bis.org/address/0x89ab428c437f23bab9781ff8db8d3848e27eed6c" "href": "/addresses/0x89ab428c437f23bab9781ff8db8d3848e27eed6c"
} }
}, },
{ {
"data": { "data": {
"id": "0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce", "id": "0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce",
"label": "EnhancedSwapRouterV2 (0xF1c93F54…)", "label": "EnhancedSwapRouterV2 (0xF1c93F54…)",
"href": "https://explorer.d-bis.org/address/0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce" "href": "/addresses/0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce"
} }
}, },
{ {
"data": { "data": {
"id": "0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7", "id": "0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7",
"label": "IntentBridgeCoordinatorV2 (0x7D0022B7…)", "label": "IntentBridgeCoordinatorV2 (0x7D0022B7…)",
"href": "https://explorer.d-bis.org/address/0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7" "href": "/addresses/0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7"
} }
}, },
{ {
"data": { "data": {
"id": "0x88495b3dccea93b0633390fde71992683121fa62", "id": "0x88495b3dccea93b0633390fde71992683121fa62",
"label": "DodoRouteExecutorAdapter (0x88495B3d…)", "label": "DodoRouteExecutorAdapter (0x88495B3d…)",
"href": "https://explorer.d-bis.org/address/0x88495b3dccea93b0633390fde71992683121fa62" "href": "/addresses/0x88495b3dccea93b0633390fde71992683121fa62"
} }
}, },
{ {
"data": { "data": {
"id": "0x9cb97add29c52e3b81989bca2e33d46074b530ef", "id": "0x9cb97add29c52e3b81989bca2e33d46074b530ef",
"label": "DodoV3RouteExecutorAdapter (0x9Cb97adD…)", "label": "DodoV3RouteExecutorAdapter (0x9Cb97adD…)",
"href": "https://explorer.d-bis.org/address/0x9cb97add29c52e3b81989bca2e33d46074b530ef" "href": "/addresses/0x9cb97add29c52e3b81989bca2e33d46074b530ef"
} }
}, },
{ {
"data": { "data": {
"id": "0x960d6db4e78705f82995690548556fb2266308ea", "id": "0x960d6db4e78705f82995690548556fb2266308ea",
"label": "UniswapV3RouteExecutorAdapter (0x960D6db4…)", "label": "UniswapV3RouteExecutorAdapter (0x960D6db4…)",
"href": "https://explorer.d-bis.org/address/0x960d6db4e78705f82995690548556fb2266308ea" "href": "/addresses/0x960d6db4e78705f82995690548556fb2266308ea"
} }
}, },
{ {

View File

@@ -3,8 +3,10 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documentation | SolaceScanScout</title> <title>Documentation Redirect | SolaceScan</title>
<meta name="description" content="Documentation landing page for the SolaceScanScout explorer."> <meta name="description" content="Redirecting to the canonical SolaceScan documentation hub.">
<meta http-equiv="refresh" content="0; url=/docs">
<link rel="canonical" href="https://blockscout.defi-oracle.io/docs">
<style> <style>
body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: linear-gradient(180deg, #0f172a 0%, #111827 45%, #f8fafc 46%, #ffffff 100%); color: #0f172a; } body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: linear-gradient(180deg, #0f172a 0%, #111827 45%, #f8fafc 46%, #ffffff 100%); color: #0f172a; }
.shell { max-width: 980px; margin: 0 auto; padding: 2rem 1rem 3rem; } .shell { max-width: 980px; margin: 0 auto; padding: 2rem 1rem 3rem; }
@@ -21,32 +23,22 @@
<body> <body>
<div class="shell"> <div class="shell">
<div class="topbar"> <div class="topbar">
<div class="brand">SolaceScanScout Documentation</div> <div class="brand">SolaceScan Documentation</div>
<a href="/">Back to explorer</a> <a href="/">Back to explorer</a>
</div> </div>
<div class="card"> <div class="card">
<h1 style="margin-top:0;">Documentation</h1> <h1 style="margin-top:0;">Documentation Has Moved</h1>
<p class="muted">This landing page collects the key explorer and deployment references used by the SolaceScanScout stack.</p> <p class="muted">The canonical documentation hub now lives at <code>/docs</code> inside the main explorer experience.</p>
<div class="grid" style="margin-top:1rem;"> <div class="grid" style="margin-top:1rem;">
<a class="link" href="/docs">
<strong>Open documentation hub</strong>
<div class="muted" style="margin-top:0.35rem;">GRU guide, transaction review matrix, public explorer references, and navigation into adjacent operational surfaces.</div>
</a>
<a class="link" href="/docs/gru">GRU guide</a>
<a class="link" href="/docs/transaction-review">Transaction review matrix</a>
<a class="link" href="/privacy.html">Privacy Policy</a> <a class="link" href="/privacy.html">Privacy Policy</a>
<a class="link" href="/terms.html">Terms of Service</a> <a class="link" href="/terms.html">Terms of Service</a>
<a class="link" href="/acknowledgments.html">Acknowledgments</a> <a class="link" href="/acknowledgments.html">Acknowledgments</a>
<a class="link" href="/liquidity">
<strong>Liquidity access</strong>
<div class="muted" style="margin-top:0.35rem;">Public Chain 138 pool snapshot, live Mainnet stable bridge paths, route matrix links, partner payload templates, and the internal fallback execution plan endpoint.</div>
</a>
<div class="link">
<strong>Repository docs</strong>
<div class="muted" style="margin-top:0.35rem;">The full technical documentation lives in the repository's <code>docs/</code> directory, including API access, deployment, and explorer guidance.</div>
</div>
<div class="link">
<strong>Public routing API base</strong>
<div class="muted" style="margin-top:0.35rem;"><code>/token-aggregation/api/v1</code> on <code>explorer.d-bis.org</code> is the public access path for route discovery, partner payload generation, and internal execution planning.</div>
</div>
<div class="link">
<strong>Need help?</strong>
<div class="muted" style="margin-top:0.35rem;">Email <a href="mailto:support@d-bis.org">support@d-bis.org</a> for explorer-related questions.</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -73,13 +73,13 @@
if (j.error) throw new Error(j.error.message || 'RPC error'); if (j.error) throw new Error(j.error.message || 'RPC error');
return j.result; return j.result;
} }
const BLOCKSCOUT_API_ORIGIN = 'https://explorer.d-bis.org/api'; // fallback when not on explorer host const BLOCKSCOUT_API_ORIGIN = 'https://blockscout.defi-oracle.io/api'; // fallback when not on explorer host
// Use relative /api when on explorer host so API always hits same host (avoids CORS/origin mismatch with www, port, or proxy) // Use relative /api when on explorer host so API always hits same host (avoids CORS/origin mismatch with www, port, or proxy)
const EXPLORER_HOSTS = ['explorer.d-bis.org', '192.168.11.140']; const EXPLORER_HOSTS = ['explorer.d-bis.org', 'blockscout.defi-oracle.io', '192.168.11.140'];
const isOnExplorerHost = (typeof window !== 'undefined' && window.location && window.location.hostname && EXPLORER_HOSTS.indexOf(window.location.hostname) !== -1); const isOnExplorerHost = (typeof window !== 'undefined' && window.location && window.location.hostname && EXPLORER_HOSTS.indexOf(window.location.hostname) !== -1);
const BLOCKSCOUT_API = isOnExplorerHost ? '/api' : BLOCKSCOUT_API_ORIGIN; const BLOCKSCOUT_API = isOnExplorerHost ? '/api' : BLOCKSCOUT_API_ORIGIN;
const EXPLORER_ORIGINS = ['https://explorer.d-bis.org', 'http://explorer.d-bis.org', 'http://192.168.11.140', 'https://192.168.11.140']; const EXPLORER_ORIGINS = ['https://explorer.d-bis.org', 'http://explorer.d-bis.org', 'https://blockscout.defi-oracle.io', 'http://blockscout.defi-oracle.io', 'http://192.168.11.140', 'https://192.168.11.140'];
const EXPLORER_ORIGIN = (typeof window !== 'undefined' && window.location && EXPLORER_ORIGINS.includes(window.location.origin)) ? window.location.origin : 'https://explorer.d-bis.org'; const EXPLORER_ORIGIN = (typeof window !== 'undefined' && window.location && EXPLORER_ORIGINS.includes(window.location.origin)) ? window.location.origin : 'https://blockscout.defi-oracle.io';
var I18N = { var I18N = {
en: { home: 'Home', blocks: 'Blocks', transactions: 'Transactions', addresses: 'Addresses', bridge: 'Bridge', weth: 'WETH', tokens: 'Tokens', pools: 'Pools', more: 'More', analytics: 'Analytics', operator: 'Operator', watchlist: 'Watchlist', searchPlaceholder: 'Address, tx hash, block number, or token/contract name...', connectWallet: 'Connect Wallet', darkMode: 'Dark mode', lightMode: 'Light mode', back: 'Back', exportCsv: 'Export CSV', tokenBalances: 'Token Balances', internalTxns: 'Internal Txns', readContract: 'Read contract', writeContract: 'Write contract', addToWatchlist: 'Add to watchlist', removeFromWatchlist: 'Remove from watchlist', checkApprovals: 'Check token approvals', copied: 'Copied' }, en: { home: 'Home', blocks: 'Blocks', transactions: 'Transactions', addresses: 'Addresses', bridge: 'Bridge', weth: 'WETH', tokens: 'Tokens', pools: 'Pools', more: 'More', analytics: 'Analytics', operator: 'Operator', watchlist: 'Watchlist', searchPlaceholder: 'Address, tx hash, block number, or token/contract name...', connectWallet: 'Connect Wallet', darkMode: 'Dark mode', lightMode: 'Light mode', back: 'Back', exportCsv: 'Export CSV', tokenBalances: 'Token Balances', internalTxns: 'Internal Txns', readContract: 'Read contract', writeContract: 'Write contract', addToWatchlist: 'Add to watchlist', removeFromWatchlist: 'Remove from watchlist', checkApprovals: 'Check token approvals', copied: 'Copied' },
de: { home: 'Start', blocks: 'Blöcke', transactions: 'Transaktionen', addresses: 'Adressen', bridge: 'Brücke', weth: 'WETH', tokens: 'Token', pools: 'Pools', more: 'Mehr', analytics: 'Analysen', operator: 'Operator', watchlist: 'Beobachtungsliste', searchPlaceholder: 'Adresse, Tx-Hash, Blocknummer oder Token/Vertrag…', connectWallet: 'Wallet verbinden', darkMode: 'Dunkelmodus', lightMode: 'Hellmodus', back: 'Zurück', exportCsv: 'CSV exportieren', tokenBalances: 'Token-Bestände', internalTxns: 'Interne Transaktionen', readContract: 'Vertrag lesen', writeContract: 'Vertrag schreiben', addToWatchlist: 'Zur Beobachtungsliste', removeFromWatchlist: 'Aus Beobachtungsliste entfernen', checkApprovals: 'Token-Freigaben prüfen', copied: 'Kopiert' }, de: { home: 'Start', blocks: 'Blöcke', transactions: 'Transaktionen', addresses: 'Adressen', bridge: 'Brücke', weth: 'WETH', tokens: 'Token', pools: 'Pools', more: 'Mehr', analytics: 'Analysen', operator: 'Operator', watchlist: 'Beobachtungsliste', searchPlaceholder: 'Adresse, Tx-Hash, Blocknummer oder Token/Vertrag…', connectWallet: 'Wallet verbinden', darkMode: 'Dunkelmodus', lightMode: 'Hellmodus', back: 'Zurück', exportCsv: 'CSV exportieren', tokenBalances: 'Token-Bestände', internalTxns: 'Interne Transaktionen', readContract: 'Vertrag lesen', writeContract: 'Vertrag schreiben', addToWatchlist: 'Zur Beobachtungsliste', removeFromWatchlist: 'Aus Beobachtungsliste entfernen', checkApprovals: 'Token-Freigaben prüfen', copied: 'Kopiert' },
@@ -1124,7 +1124,7 @@
} }
// Sign message // Sign message
const message = `Sign this message to authenticate with SolaceScanScout Explorer.\n\nNonce: ${nonceData.nonce}`; const message = `Sign this message to authenticate with SolaceScan.\n\nNonce: ${nonceData.nonce}`;
const signer = provider.getSigner(); const signer = provider.getSigner();
const signature = await signer.signMessage(message); const signature = await signer.signMessage(message);
@@ -1312,6 +1312,18 @@
}; };
} }
function mergeAddressTabsCounters(addressDetail, counters) {
if (!addressDetail || !counters || typeof counters !== 'object') return addressDetail;
var merged = Object.assign({}, addressDetail);
if (counters.transactions_count != null) {
merged.transaction_count = Number(counters.transactions_count) || 0;
}
if (counters.token_balances_count != null) {
merged.token_count = Number(counters.token_balances_count) || 0;
}
return merged;
}
function hexToDecimalString(value) { function hexToDecimalString(value) {
if (value == null || value === '') return '0'; if (value == null || value === '') return '0';
var stringValue = String(value); var stringValue = String(value);
@@ -1494,7 +1506,24 @@
} }
var rpcTx = await rpcCall('eth_getTransactionByHash', [txHash]); var rpcTx = await rpcCall('eth_getTransactionByHash', [txHash]);
if (!rpcTx) { if (!rpcTx) {
return { transaction: null, rawTransaction: null }; var latestBlock = null;
try {
latestBlock = await rpcCall('eth_blockNumber', []);
} catch (error) {
latestBlock = null;
}
return {
transaction: null,
rawTransaction: {
source: 'unavailable',
diagnostics: {
blockscout_indexed: false,
rpc_transaction_found: false,
rpc_receipt_found: false,
latest_block_number: latestBlock ? parseInt(latestBlock, 16) : null
}
}
};
} }
var receipt = await rpcCall('eth_getTransactionReceipt', [txHash]).catch(function() { return null; }); var receipt = await rpcCall('eth_getTransactionReceipt', [txHash]).catch(function() { return null; });
var block = rpcTx.blockNumber ? await rpcCall('eth_getBlockByNumber', [rpcTx.blockNumber, false]).catch(function() { return null; }) : null; var block = rpcTx.blockNumber ? await rpcCall('eth_getBlockByNumber', [rpcTx.blockNumber, false]).catch(function() { return null; }) : null;
@@ -1517,6 +1546,16 @@
var response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/addresses/${normalizedAddress}`, 1, RETRY_DELAY_MS, ADDRESS_DETAIL_BLOCKSCOUT_TIMEOUT_MS); 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 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); var normalized = normalizeAddress(raw);
var needsCounters = raw && raw.hash && raw.transactions_count == null && raw.transaction_count == null && raw.tx_count == null;
needsCounters = needsCounters || (raw && raw.hash && raw.token_count == null);
if (normalized && normalized.hash && needsCounters) {
try {
var counters = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/addresses/${normalizedAddress}/tabs-counters`, 1, RETRY_DELAY_MS, ADDRESS_DETAIL_BLOCKSCOUT_TIMEOUT_MS);
normalized = mergeAddressTabsCounters(normalized, counters);
} catch (counterError) {
console.warn('Address counters unavailable:', counterError.message || counterError);
}
}
if (normalized && normalized.hash) { if (normalized && normalized.hash) {
return { return {
address: normalized, address: normalized,
@@ -2032,7 +2071,7 @@
decimals: 18 decimals: 18
}, },
rpcUrls: RPC_URLS.length > 0 ? RPC_URLS : [RPC_URL], rpcUrls: RPC_URLS.length > 0 ? RPC_URLS : [RPC_URL],
blockExplorerUrls: [window.location.origin || 'https://explorer.d-bis.org'] blockExplorerUrls: [window.location.origin || 'https://blockscout.defi-oracle.io']
}], }],
}); });
} catch (addError) { } catch (addError) {
@@ -2786,7 +2825,7 @@
var decode = function(s) { try { return decodeURIComponent(s); } catch (e) { return s; } }; var decode = function(s) { try { return decodeURIComponent(s); } catch (e) { return s; } };
if (parts[0] === 'block' && parts[1]) { var p1 = decode(parts[1]); var key = 'block:' + p1; if (currentDetailKey === key) return; currentDetailKey = key; setTimeout(function() { showBlockDetail(p1); }, 0); return; } if (parts[0] === 'block' && parts[1]) { var p1 = decode(parts[1]); var key = 'block:' + p1; if (currentDetailKey === key) return; currentDetailKey = key; setTimeout(function() { showBlockDetail(p1); }, 0); return; }
if (parts[0] === 'tx' && parts[1]) { var p1 = decode(parts[1]); var txKey = 'tx:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)); if (currentDetailKey === txKey) return; currentDetailKey = txKey; setTimeout(function() { showTransactionDetail(p1); }, 0); return; } if (parts[0] === 'tx' && parts[1]) { var p1 = decode(parts[1]); var txKey = 'tx:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)); if (currentDetailKey === txKey) return; currentDetailKey = txKey; setTimeout(function() { showTransactionDetail(p1); }, 0); return; }
if (parts[0] === 'address' && parts[1]) { var p1 = decode(parts[1]); var addrKey = 'address:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)); if (currentDetailKey === addrKey) return; currentDetailKey = addrKey; setTimeout(function() { showAddressDetail(p1); }, 0); return; } if ((parts[0] === 'address' || parts[0] === 'addresses') && parts[1]) { var p1 = decode(parts[1]); var addrKey = 'address:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)); if (currentDetailKey === addrKey) return; currentDetailKey = addrKey; setTimeout(function() { showAddressDetail(p1); }, 0); return; }
if (parts[0] === 'token' && parts[1]) { var p1 = decode(parts[1]); var tokKey = 'token:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)); if (currentDetailKey === tokKey) return; currentDetailKey = tokKey; setTimeout(function() { showTokenDetail(p1); }, 0); return; } if (parts[0] === 'token' && parts[1]) { var p1 = decode(parts[1]); var tokKey = 'token:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)); if (currentDetailKey === tokKey) return; currentDetailKey = tokKey; setTimeout(function() { showTokenDetail(p1); }, 0); return; }
if (parts[0] === 'nft' && parts[1] && parts[2]) { var p1 = decode(parts[1]), p2 = decode(parts[2]); var nftKey = 'nft:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)) + ':' + p2; if (currentDetailKey === nftKey) return; currentDetailKey = nftKey; setTimeout(function() { showNftDetail(p1, p2); }, 0); return; } if (parts[0] === 'nft' && parts[1] && parts[2]) { var p1 = decode(parts[1]), p2 = decode(parts[2]); var nftKey = 'nft:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)) + ':' + p2; if (currentDetailKey === nftKey) return; currentDetailKey = nftKey; setTimeout(function() { showNftDetail(p1, p2); }, 0); return; }
if (parts[0] === 'home') { if (currentView !== 'home') showHome(); return; } if (parts[0] === 'home') { if (currentView !== 'home') showHome(); return; }
@@ -2922,7 +2961,7 @@
case 'nft': case 'nft':
breadcrumbContainer = document.getElementById('nftDetailBreadcrumb'); breadcrumbContainer = document.getElementById('nftDetailBreadcrumb');
breadcrumbHTML += '<span class="breadcrumb-separator">/</span>'; breadcrumbHTML += '<span class="breadcrumb-separator">/</span>';
breadcrumbHTML += '<a href="/address/' + encodeURIComponent(identifier) + '">' + escapeHtml(shortenHash(identifier)) + '</a>'; breadcrumbHTML += '<a href="/addresses/' + encodeURIComponent(identifier) + '">' + escapeHtml(shortenHash(identifier)) + '</a>';
breadcrumbHTML += '<span class="breadcrumb-separator">/</span>'; breadcrumbHTML += '<span class="breadcrumb-separator">/</span>';
breadcrumbHTML += '<span class="breadcrumb-current">Token ID ' + (identifierExtra != null ? escapeHtml(String(identifierExtra)) : '') + '</span>'; breadcrumbHTML += '<span class="breadcrumb-current">Token ID ' + (identifierExtra != null ? escapeHtml(String(identifierExtra)) : '') + '</span>';
break; break;
@@ -3614,7 +3653,7 @@
filteredBlocks.forEach(function(block) { filteredBlocks.forEach(function(block) {
var d = normalizeBlockDisplay(block); var d = normalizeBlockDisplay(block);
var blockNumber = escapeHtml(String(d.blockNum)); var blockNumber = escapeHtml(String(d.blockNum));
var blockHref = '/block/' + encodeURIComponent(String(d.blockNum)); var blockHref = '/blocks/' + encodeURIComponent(String(d.blockNum));
var blockLink = '<a href="' + blockHref + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + blockNumber + '\')" style="color: inherit; text-decoration: none; font-weight: 600;">' + blockNumber + '</a>'; var blockLink = '<a href="' + blockHref + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + blockNumber + '\')" style="color: inherit; text-decoration: none; font-weight: 600;">' + blockNumber + '</a>';
var hashLink = safeBlockNumber(d.blockNum) ? '<a class="hash" href="' + blockHref + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + blockNumber + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(shortenHash(d.hash)) + '</a>' : '<span class="hash">' + escapeHtml(shortenHash(d.hash)) + '</span>'; var hashLink = safeBlockNumber(d.blockNum) ? '<a class="hash" href="' + blockHref + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + blockNumber + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(shortenHash(d.hash)) + '</a>' : '<span class="hash">' + escapeHtml(shortenHash(d.hash)) + '</span>';
html += '<tr onclick="showBlockDetail(\'' + blockNumber + '\')" style="cursor: pointer;"><td>' + blockLink + '</td><td>' + hashLink + '</td><td>' + escapeHtml(String(d.txCount)) + '</td><td>' + escapeHtml(d.timestampFormatted) + '</td></tr>'; html += '<tr onclick="showBlockDetail(\'' + blockNumber + '\')" style="cursor: pointer;"><td>' + blockLink + '</td><td>' + hashLink + '</td><td>' + escapeHtml(String(d.txCount)) + '</td><td>' + escapeHtml(d.timestampFormatted) + '</td></tr>';
@@ -3705,11 +3744,11 @@
const blockNumber = tx.block_number || 'N/A'; const blockNumber = tx.block_number || 'N/A';
const valueFormatted = formatEther(value); const valueFormatted = formatEther(value);
var safeHash = escapeHtml(hash); var safeHash = escapeHtml(hash);
var txHref = '/tx/' + encodeURIComponent(hash); var txHref = '/transactions/' + encodeURIComponent(hash);
var hashLink = '<a class="hash" href="' + txHref + '" onclick="event.preventDefault(); event.stopPropagation(); showTransactionDetail(\'' + safeHash + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(shortenHash(hash)) + '</a>'; var hashLink = '<a class="hash" href="' + txHref + '" onclick="event.preventDefault(); event.stopPropagation(); showTransactionDetail(\'' + safeHash + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(shortenHash(hash)) + '</a>';
var fromLink = safeAddress(from) ? '<a class="hash" href="/address/' + encodeURIComponent(from) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeHtml(from) + '\')" style="color: inherit; text-decoration: none;">' + formatAddressWithLabel(from) + '</a>' : formatAddressWithLabel(from); var fromLink = safeAddress(from) ? '<a class="hash" href="/addresses/' + encodeURIComponent(from) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeHtml(from) + '\')" style="color: inherit; text-decoration: none;">' + formatAddressWithLabel(from) + '</a>' : formatAddressWithLabel(from);
var toLink = safeAddress(to) ? '<a class="hash" href="/address/' + encodeURIComponent(to) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeHtml(to || '') + '\')" style="color: inherit; text-decoration: none;">' + formatAddressWithLabel(to) + '</a>' : (to ? formatAddressWithLabel(to) : '-'); var toLink = safeAddress(to) ? '<a class="hash" href="/addresses/' + encodeURIComponent(to) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeHtml(to || '') + '\')" style="color: inherit; text-decoration: none;">' + formatAddressWithLabel(to) + '</a>' : (to ? formatAddressWithLabel(to) : '-');
var blockLink = safeBlockNumber(blockNumber) ? '<a href="/block/' + encodeURIComponent(String(blockNumber)) + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + escapeHtml(String(blockNumber)) + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(String(blockNumber)) + '</a>' : escapeHtml(String(blockNumber)); var blockLink = safeBlockNumber(blockNumber) ? '<a href="/blocks/' + encodeURIComponent(String(blockNumber)) + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + escapeHtml(String(blockNumber)) + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(String(blockNumber)) + '</a>' : escapeHtml(String(blockNumber));
html += '<tr onclick="showTransactionDetail(\'' + safeHash + '\')" style="cursor: pointer;"><td>' + hashLink + '</td><td>' + fromLink + '</td><td>' + toLink + '</td><td>' + escapeHtml(valueFormatted) + ' ETH</td><td>' + blockLink + '</td></tr>'; html += '<tr onclick="showTransactionDetail(\'' + safeHash + '\')" style="cursor: pointer;"><td>' + hashLink + '</td><td>' + fromLink + '</td><td>' + toLink + '</td><td>' + escapeHtml(valueFormatted) + ' ETH</td><td>' + blockLink + '</td></tr>';
}); });
} }
@@ -3789,7 +3828,7 @@
var tokenCount = Number(item.token_count || 0); var tokenCount = Number(item.token_count || 0);
var lastSeen = String(item.last_seen_at || '—'); var lastSeen = String(item.last_seen_at || '—');
html += '<tr style="cursor: pointer;" onclick="showAddressDetail(\'' + escapeHtml(addr) + '\')">'; html += '<tr style="cursor: pointer;" onclick="showAddressDetail(\'' + escapeHtml(addr) + '\')">';
html += '<td><a class="hash" href="/address/' + encodeURIComponent(addr) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeHtml(addr) + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(shortenHash(addr)) + '</a></td>'; html += '<td><a class="hash" href="/addresses/' + encodeURIComponent(addr) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeHtml(addr) + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(shortenHash(addr)) + '</a></td>';
html += '<td>' + escapeHtml(label || '—') + '</td>'; html += '<td>' + escapeHtml(label || '—') + '</td>';
html += '<td>' + escapeHtml(type) + '</td>'; html += '<td>' + escapeHtml(type) + '</td>';
html += '<td>' + escapeHtml(String(txSent)) + '</td>'; html += '<td>' + escapeHtml(String(txSent)) + '</td>';
@@ -4460,7 +4499,7 @@
html += '<button type="button" class="btn btn-primary" onclick="showRoutes(); updatePath(\'/routes\')" aria-label="Open routes view"><i class="fas fa-diagram-project"></i> Routes view</button>'; html += '<button type="button" class="btn btn-primary" onclick="showRoutes(); updatePath(\'/routes\')" aria-label="Open routes view"><i class="fas fa-diagram-project"></i> Routes view</button>';
html += '<button type="button" class="btn btn-secondary" onclick="showPools(); updatePath(\'/pools\')" aria-label="Open pools view"><i class="fas fa-water"></i> Pools view</button>'; html += '<button type="button" class="btn btn-secondary" onclick="showPools(); updatePath(\'/pools\')" aria-label="Open pools view"><i class="fas fa-water"></i> Pools view</button>';
html += '<button type="button" class="btn btn-secondary" onclick="showWETHUtilities(); updatePath(\'/weth\')" aria-label="Open WETH tools"><i class="fas fa-coins"></i> WETH tools</button>'; html += '<button type="button" class="btn btn-secondary" onclick="showWETHUtilities(); updatePath(\'/weth\')" aria-label="Open WETH tools"><i class="fas fa-coins"></i> WETH tools</button>';
html += '<a class="btn btn-secondary" href="/docs.html" style="text-decoration:none;"><i class="fas fa-book"></i> Explorer docs</a>'; html += '<a class="btn btn-secondary" href="/docs" style="text-decoration:none;"><i class="fas fa-book"></i> Explorer docs</a>';
html += '</div></div></div>'; html += '</div></div></div>';
html += '</div>'; html += '</div>';
@@ -4470,7 +4509,7 @@
function renderMoreView() { function renderMoreView() {
showView('more'); showView('more');
if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'more') updatePath('/more'); if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'operations') updatePath('/operations');
var container = document.getElementById('moreContent'); var container = document.getElementById('moreContent');
if (!container) return; if (!container) return;
var groups = [ var groups = [
@@ -4479,7 +4518,7 @@
title: 'Tools', title: 'Tools',
items: [ items: [
{ title: 'Input Data Decoder', icon: 'fa-file-code', status: 'Live', badgeClass: 'badge-info', desc: 'Open transaction detail pages to decode calldata, logs, and contract interactions already exposed by the explorer.', action: 'showTransactionsList();', href: '/transactions' }, { title: 'Input Data Decoder', icon: 'fa-file-code', status: 'Live', badgeClass: 'badge-info', desc: 'Open transaction detail pages to decode calldata, logs, and contract interactions already exposed by the explorer.', action: 'showTransactionsList();', href: '/transactions' },
{ title: 'Unit Converter', icon: 'fa-scale-balanced', status: 'Live', badgeClass: 'badge-success', desc: 'Convert wei, gwei, ether, and common Chain 138 stablecoin units with a quick in-page helper.', action: 'showUnitConverterModal();', href: '/more' }, { title: 'Unit Converter', icon: 'fa-scale-balanced', status: 'Live', badgeClass: 'badge-success', desc: 'Convert wei, gwei, ether, and common Chain 138 stablecoin units with a quick in-page helper.', action: 'showUnitConverterModal();', href: '/operations' },
{ title: 'CSV Export', icon: 'fa-file-csv', status: 'Live', badgeClass: 'badge-success', desc: 'Export pool state and route inventory snapshots for operator review and downstream ingestion.', action: 'showPools(); updatePath(\'/pools\'); setTimeout(function(){ if (typeof exportPoolsCSV === \"function\") exportPoolsCSV(); }, 200);', href: '/pools' }, { title: 'CSV Export', icon: 'fa-file-csv', status: 'Live', badgeClass: 'badge-success', desc: 'Export pool state and route inventory snapshots for operator review and downstream ingestion.', action: 'showPools(); updatePath(\'/pools\'); setTimeout(function(){ if (typeof exportPoolsCSV === \"function\") exportPoolsCSV(); }, 200);', href: '/pools' },
{ title: 'Account Balance Checker', icon: 'fa-wallet', status: 'Live', badgeClass: 'badge-success', desc: 'Jump into indexed addresses to inspect balances, token inventory, internal transfers, and recent activity.', action: 'showAddresses();', href: '/addresses' } { title: 'Account Balance Checker', icon: 'fa-wallet', status: 'Live', badgeClass: 'badge-success', desc: 'Jump into indexed addresses to inspect balances, token inventory, internal transfers, and recent activity.', action: 'showAddresses();', href: '/addresses' }
] ]
@@ -4493,7 +4532,7 @@
{ 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: '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: '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' }, { 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' },
{ title: 'Domain Name Lookup', icon: 'fa-magnifying-glass', status: 'Live', badgeClass: 'badge-success', desc: 'Use the smart search launcher to resolve ENS-style names, domains, addresses, hashes, and token symbols.', action: 'openSmartSearchModal(\'\');', href: '/more' } { title: 'Domain Name Lookup', icon: 'fa-magnifying-glass', status: 'Live', badgeClass: 'badge-success', desc: 'Use the smart search launcher to resolve ENS-style names, domains, addresses, hashes, and token symbols.', action: 'openSmartSearchModal(\'\');', href: '/operations' }
] ]
}, },
{ {
@@ -4501,7 +4540,7 @@
title: 'Services', title: 'Services',
items: [ items: [
{ title: 'Token Approvals', icon: 'fa-shield-halved', status: 'External', badgeClass: 'badge-warning', desc: 'Jump to revoke.cash for wallet approval review. Address detail pages also expose approval shortcuts directly.', action: 'openExternalMoreLink(\'https://revoke.cash/\');', href: '#' }, { title: 'Token Approvals', icon: 'fa-shield-halved', status: 'External', badgeClass: 'badge-warning', desc: 'Jump to revoke.cash for wallet approval review. Address detail pages also expose approval shortcuts directly.', action: 'openExternalMoreLink(\'https://revoke.cash/\');', href: '#' },
{ title: 'Verified Signature', icon: 'fa-signature', status: 'Live', badgeClass: 'badge-success', desc: 'Use wallet sign-in and verified address flows already built into the explorer authentication surfaces.', action: 'showWalletModal();', href: '/more' }, { title: 'Verified Signature', icon: 'fa-signature', status: 'Live', badgeClass: 'badge-success', desc: 'Use wallet sign-in and verified address flows already built into the explorer authentication surfaces.', action: 'showWalletModal();', href: '/operations' },
{ title: 'Input Data Messages', icon: 'fa-message', status: 'Live', badgeClass: 'badge-info', desc: 'Transaction detail pages already surface decoded input data, event logs, and contract interaction context.', action: 'showTransactionsList();', href: '/transactions' }, { title: 'Input Data Messages', icon: 'fa-message', status: 'Live', badgeClass: 'badge-info', desc: 'Transaction detail pages already surface decoded input data, event logs, and contract interaction context.', action: 'showTransactionsList();', href: '/transactions' },
{ title: 'Advanced Filter', icon: 'fa-filter', status: 'Live', badgeClass: 'badge-success', desc: 'Block, transaction, address, token, pool, bridge, and watchlist screens all support focused page-level filtering.', action: 'showTransactionsList();', href: '/transactions' }, { title: 'Advanced Filter', icon: 'fa-filter', status: 'Live', badgeClass: 'badge-success', desc: 'Block, transaction, address, token, pool, bridge, and watchlist screens all support focused page-level filtering.', action: 'showTransactionsList();', href: '/transactions' },
{ title: 'MetaMask Snap', icon: 'fa-wallet', status: 'Live', badgeClass: 'badge-success', desc: 'Open the Chain 138 MetaMask Snap companion for network setup, token list access, and wallet integration guidance.', action: 'window.location.href=\'/snap/\';', href: '/snap/' } { title: 'MetaMask Snap', icon: 'fa-wallet', status: 'Live', badgeClass: 'badge-success', desc: 'Open the Chain 138 MetaMask Snap companion for network setup, token list access, and wallet integration guidance.', action: 'window.location.href=\'/snap/\';', href: '/snap/' }
@@ -4511,8 +4550,8 @@
var html = '<div style="display:grid; grid-template-columns:minmax(240px, 0.9fr) repeat(3, minmax(220px, 1fr)); gap:1rem; align-items:start;">'; var html = '<div style="display:grid; grid-template-columns:minmax(240px, 0.9fr) repeat(3, minmax(220px, 1fr)); gap:1rem; align-items:start;">';
html += '<div style="border:1px solid var(--border); border-radius:18px; padding:1.25rem; background:linear-gradient(180deg, rgba(59,130,246,0.08), rgba(15,23,42,0.02)); min-height:100%;">'; html += '<div style="border:1px solid var(--border); border-radius:18px; padding:1.25rem; background:linear-gradient(180deg, rgba(59,130,246,0.08), rgba(15,23,42,0.02)); min-height:100%;">';
html += '<div style="font-size:1.25rem; font-weight:800; margin-bottom:0.75rem;">Tools &amp; Services</div>'; html += '<div style="font-size:1.25rem; font-weight:800; margin-bottom:0.75rem;">Operations Hub</div>';
html += '<div style="color:var(--text-light); line-height:1.7; margin-bottom:1rem;">Discover more of SolaceScanScout&apos;s explorer tools in one place, grouped the way users expect from Etherscan-style explorers.</div>'; html += '<div style="color:var(--text-light); line-height:1.7; margin-bottom:1rem;">Discover SolaceScan operational explorer tools in one place, grouped the way users expect from a polished specialist explorer.</div>';
html += '<div style="display:grid; gap:0.75rem;">'; html += '<div style="display:grid; gap:0.75rem;">';
html += '<div style="padding:0.85rem; border:1px solid var(--border); border-radius:14px; background:var(--muted-surface);"><div style="font-size:0.82rem; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-light); margin-bottom:0.35rem;">Now live</div><div style="font-weight:700;">Route matrix, ingestion APIs, smart search, pool exports, and live Mainnet stable bridge discovery.</div></div>'; html += '<div style="padding:0.85rem; border:1px solid var(--border); border-radius:14px; background:var(--muted-surface);"><div style="font-size:0.82rem; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-light); margin-bottom:0.35rem;">Now live</div><div style="font-weight:700;">Route matrix, ingestion APIs, smart search, pool exports, and live Mainnet stable bridge discovery.</div></div>';
html += '<div style="padding:0.85rem; border:1px solid var(--border); border-radius:14px; background:var(--muted-surface);"><div style="font-size:0.82rem; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-light); margin-bottom:0.35rem;">Good entry points</div><div style="display:flex; flex-wrap:wrap; gap:0.5rem;">'; html += '<div style="padding:0.85rem; border:1px solid var(--border); border-radius:14px; background:var(--muted-surface);"><div style="font-size:0.82rem; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-light); margin-bottom:0.35rem;">Good entry points</div><div style="display:flex; flex-wrap:wrap; gap:0.5rem;">';
@@ -4535,7 +4574,7 @@
: (item.href === '#' : (item.href === '#'
? ('event.preventDefault(); ' + item.action + ' closeNavMenu();') ? ('event.preventDefault(); ' + item.action + ' closeNavMenu();')
: ('event.preventDefault(); ' + item.action + ' updatePath(' + JSON.stringify(item.href) + '); closeNavMenu();')); : ('event.preventDefault(); ' + item.action + ' updatePath(' + JSON.stringify(item.href) + '); closeNavMenu();'));
var href = disabled ? '/more' : item.href; var href = disabled ? '/operations' : item.href;
html += '<a href="' + escapeAttr(href) + '" onclick="' + onclick + '" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:0.9rem; background:' + (disabled ? 'rgba(148,163,184,0.08)' : 'var(--muted-surface)') + '; opacity:' + (disabled ? '0.78' : '1') + ';">'; html += '<a href="' + escapeAttr(href) + '" onclick="' + onclick + '" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:0.9rem; background:' + (disabled ? 'rgba(148,163,184,0.08)' : 'var(--muted-surface)') + '; opacity:' + (disabled ? '0.78' : '1') + ';">';
html += '<div style="display:flex; justify-content:space-between; gap:0.75rem; align-items:flex-start; margin-bottom:0.45rem;">'; html += '<div style="display:flex; justify-content:space-between; gap:0.75rem; align-items:flex-start; margin-bottom:0.45rem;">';
html += '<div style="display:flex; align-items:center; gap:0.65rem; min-width:0;">'; html += '<div style="display:flex; align-items:center; gap:0.65rem; min-width:0;">';
@@ -4907,17 +4946,17 @@
function explorerAddressLink(address, content, style) { function explorerAddressLink(address, content, style) {
var safe = safeAddress(address); var safe = safeAddress(address);
if (!safe) return content || 'N/A'; if (!safe) return content || 'N/A';
return '<a class="hash" href="/address/' + encodeURIComponent(safe) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeJsSingleQuoted(safe) + '\')" style="' + escapeAttr(style || 'color: inherit; text-decoration: none;') + '">' + (content || escapeHtml(shortenHash(safe))) + '</a>'; return '<a class="hash" href="/addresses/' + encodeURIComponent(safe) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeJsSingleQuoted(safe) + '\')" style="' + escapeAttr(style || 'color: inherit; text-decoration: none;') + '">' + (content || escapeHtml(shortenHash(safe))) + '</a>';
} }
function explorerTransactionLink(txHash, content, style) { function explorerTransactionLink(txHash, content, style) {
var safe = safeTxHash(txHash); var safe = safeTxHash(txHash);
if (!safe) return content || 'N/A'; if (!safe) return content || 'N/A';
return '<a class="hash" href="/tx/' + encodeURIComponent(safe) + '" onclick="event.preventDefault(); event.stopPropagation(); showTransactionDetail(\'' + escapeJsSingleQuoted(safe) + '\')" style="' + escapeAttr(style || 'color: inherit; text-decoration: none;') + '">' + (content || escapeHtml(shortenHash(safe))) + '</a>'; return '<a class="hash" href="/transactions/' + encodeURIComponent(safe) + '" onclick="event.preventDefault(); event.stopPropagation(); showTransactionDetail(\'' + escapeJsSingleQuoted(safe) + '\')" style="' + escapeAttr(style || 'color: inherit; text-decoration: none;') + '">' + (content || escapeHtml(shortenHash(safe))) + '</a>';
} }
function explorerBlockLink(blockNumber, content, style) { function explorerBlockLink(blockNumber, content, style) {
var safe = safeBlockNumber(blockNumber); var safe = safeBlockNumber(blockNumber);
if (!safe) return content || 'N/A'; if (!safe) return content || 'N/A';
return '<a href="/block/' + encodeURIComponent(safe) + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + escapeJsSingleQuoted(safe) + '\')" style="' + escapeAttr(style || 'color: inherit; text-decoration: none;') + '">' + (content || escapeHtml(String(safe))) + '</a>'; return '<a href="/blocks/' + encodeURIComponent(safe) + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + escapeJsSingleQuoted(safe) + '\')" style="' + escapeAttr(style || 'color: inherit; text-decoration: none;') + '">' + (content || escapeHtml(String(safe))) + '</a>';
} }
function toBigIntSafe(value) { function toBigIntSafe(value) {
if (value == null || value === '') return null; if (value == null || value === '') return null;
@@ -4980,16 +5019,19 @@
if (value == null || value === '') return ''; if (value == null || value === '') return '';
return ' <button type="button" class="btn-copy" onclick="event.stopPropagation(); copyToClipboard(\'' + escapeJsSingleQuoted(String(value)) + '\', \'Copied\');" aria-label="' + escapeAttr(ariaLabel || 'Copy value') + '"><i class="fas fa-copy"></i></button>'; return ' <button type="button" class="btn-copy" onclick="event.stopPropagation(); copyToClipboard(\'' + escapeJsSingleQuoted(String(value)) + '\', \'Copied\');" aria-label="' + escapeAttr(ariaLabel || 'Copy value') + '"><i class="fas fa-copy"></i></button>';
} }
function renderInspectorCopyRow(valueHtml, copyValue, ariaLabel) {
return '<div class="tx-inspector-copy-row"><div class="tx-inspector-scroll">' + valueHtml + '</div>' + (copyValue != null && copyValue !== '' ? '<div class="tx-inspector-copy-action">' + renderCopyButtonHtml(copyValue, ariaLabel) + '</div>' : '') + '</div>';
}
function renderInspectorHtmlLine(label, valueHtml) { function renderInspectorHtmlLine(label, valueHtml) {
return '<div class="tx-inspector-line"><div class="tx-inspector-label">' + escapeHtml(label) + '</div><div class="tx-inspector-content">' + (valueHtml || '<span class="tx-empty">N/A</span>') + '</div></div>'; return '<div class="tx-inspector-line"><div class="tx-inspector-label">' + escapeHtml(label) + '</div><div class="tx-inspector-content">' + (valueHtml || '<span class="tx-empty">N/A</span>') + '</div></div>';
} }
function renderInspectorTextLine(label, value, copyValue) { function renderInspectorTextLine(label, value, copyValue) {
if (value == null || value === '') return renderInspectorHtmlLine(label, '<span class="tx-empty">N/A</span>'); if (value == null || value === '') return renderInspectorHtmlLine(label, '<span class="tx-empty">N/A</span>');
return renderInspectorHtmlLine(label, '<span>' + escapeHtml(String(value)) + '</span>' + (copyValue != null ? renderCopyButtonHtml(copyValue, 'Copy ' + label) : '')); return renderInspectorHtmlLine(label, renderInspectorCopyRow('<span>' + escapeHtml(String(value)) + '</span>', copyValue, 'Copy ' + label));
} }
function renderInspectorCodeLine(label, value, copyValue) { function renderInspectorCodeLine(label, value, copyValue) {
if (value == null || value === '') return renderInspectorHtmlLine(label, '<span class="tx-empty">N/A</span>'); if (value == null || value === '') return renderInspectorHtmlLine(label, '<span class="tx-empty">N/A</span>');
return renderInspectorHtmlLine(label, '<div class="tx-inspector-scroll"><code class="tx-inspector-mono">' + escapeHtml(String(value)) + '</code>' + renderCopyButtonHtml(copyValue != null ? copyValue : value, 'Copy ' + label) + '</div>'); return renderInspectorHtmlLine(label, renderInspectorCopyRow('<code class="tx-inspector-mono">' + escapeHtml(String(value)) + '</code>', copyValue != null ? copyValue : value, 'Copy ' + label));
} }
function renderNumericInspectorEntry(label, value, note, openByDefault) { function renderNumericInspectorEntry(label, value, note, openByDefault) {
var repr = buildNumericRepresentations(value); var repr = buildNumericRepresentations(value);
@@ -5027,6 +5069,155 @@
if (value == null || value === '') return ''; if (value == null || value === '') return '';
return typeof value === 'string' ? value : safeJsonStringify(value); return typeof value === 'string' ? value : safeJsonStringify(value);
} }
var KNOWN_LOG_SIGNATURES = {
transfer: '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
approval: '0x8c5be1e5ebec7d5bd14f714f7e582d5c3b27c1d03c7d98cfc9b7c6f7d3a5b5d',
approvalForAll: '0x17307eab39ab6107e8899845ad3d59bd9653f200f220920489ca2b5937696c31',
transferSingle: '0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62'
};
function formatTopicSignaturePreview(topic) {
if (!topic) return '';
var value = String(topic);
return value.length > 20 ? value.slice(0, 12) + '…' + value.slice(-6) : value;
}
function normalizeHexWord(value) {
if (!value) return '';
var normalized = String(value).toLowerCase();
if (!normalized.startsWith('0x')) normalized = '0x' + normalized;
return normalized;
}
function splitHexDataWords(dataValue) {
var normalized = normalizeHexWord(dataValue);
if (!/^0x[0-9a-f]*$/i.test(normalized)) return [];
var payload = normalized.slice(2);
var words = [];
for (var i = 0; i < payload.length; i += 64) {
var word = payload.slice(i, i + 64);
if (word.length === 64) words.push('0x' + word);
}
return words;
}
function extractAddressFromTopic(topicValue) {
var normalized = normalizeHexWord(topicValue);
if (!/^0x[0-9a-f]{64}$/i.test(normalized)) return '';
return safeAddress('0x' + normalized.slice(-40)) || '';
}
function extractBoolFromWord(wordValue) {
var parsed = toBigIntSafe(wordValue);
if (parsed == null) return '';
return parsed === 0n ? 'false' : 'true';
}
function formatUintWord(wordValue) {
var parsed = toBigIntSafe(wordValue);
if (parsed == null) return '';
return formatGroupedDigits(parsed.toString(), 3, ',');
}
function detectKnownLogEvent(topics, dataValue) {
var topic0 = topics && topics[0] ? normalizeHexWord(topics[0]) : '';
var words = splitHexDataWords(dataValue);
if (!topic0) return null;
if (topic0 === KNOWN_LOG_SIGNATURES.transfer) {
if (topics.length >= 4) {
return {
name: 'Transfer',
standard: 'ERC-721',
fields: [
{ label: 'From', type: 'address', value: extractAddressFromTopic(topics[1]) },
{ label: 'To', type: 'address', value: extractAddressFromTopic(topics[2]) },
{ label: 'Token ID', type: 'uint', value: normalizeHexWord(topics[3]) }
]
};
}
if (topics.length >= 3 && words.length >= 1) {
return {
name: 'Transfer',
standard: 'ERC-20',
fields: [
{ label: 'From', type: 'address', value: extractAddressFromTopic(topics[1]) },
{ label: 'To', type: 'address', value: extractAddressFromTopic(topics[2]) },
{ label: 'Value', type: 'uint', value: words[0] }
]
};
}
}
if (topic0 === KNOWN_LOG_SIGNATURES.approval) {
if (topics.length >= 4) {
return {
name: 'Approval',
standard: 'ERC-721',
fields: [
{ label: 'Owner', type: 'address', value: extractAddressFromTopic(topics[1]) },
{ label: 'Approved', type: 'address', value: extractAddressFromTopic(topics[2]) },
{ label: 'Token ID', type: 'uint', value: normalizeHexWord(topics[3]) }
]
};
}
if (topics.length >= 3 && words.length >= 1) {
return {
name: 'Approval',
standard: 'ERC-20',
fields: [
{ label: 'Owner', type: 'address', value: extractAddressFromTopic(topics[1]) },
{ label: 'Spender', type: 'address', value: extractAddressFromTopic(topics[2]) },
{ label: 'Value', type: 'uint', value: words[0] }
]
};
}
}
if (topic0 === KNOWN_LOG_SIGNATURES.approvalForAll && topics.length >= 3 && words.length >= 1) {
return {
name: 'ApprovalForAll',
standard: 'ERC-721 / ERC-1155',
fields: [
{ label: 'Owner', type: 'address', value: extractAddressFromTopic(topics[1]) },
{ label: 'Operator', type: 'address', value: extractAddressFromTopic(topics[2]) },
{ label: 'Approved', type: 'bool', value: words[0] }
]
};
}
if (topic0 === KNOWN_LOG_SIGNATURES.transferSingle && topics.length >= 4 && words.length >= 2) {
return {
name: 'TransferSingle',
standard: 'ERC-1155',
fields: [
{ label: 'Operator', type: 'address', value: extractAddressFromTopic(topics[1]) },
{ label: 'From', type: 'address', value: extractAddressFromTopic(topics[2]) },
{ label: 'To', type: 'address', value: extractAddressFromTopic(topics[3]) },
{ label: 'Token ID', type: 'uint', value: words[0] },
{ label: 'Value', type: 'uint', value: words[1] }
]
};
}
return null;
}
function renderStructuredLogFields(eventInfo) {
if (!eventInfo || !Array.isArray(eventInfo.fields) || eventInfo.fields.length === 0) return '';
var lines = eventInfo.fields.map(function(field) {
if (!field || !field.value) return '';
if (field.type === 'address') {
return renderInspectorHtmlLine(field.label, renderInspectorCopyRow(explorerAddressLink(field.value, escapeHtml(field.value), 'color: inherit; text-decoration: none;'), field.value, 'Copy ' + field.label));
}
if (field.type === 'bool') {
var boolValue = extractBoolFromWord(field.value);
return renderInspectorTextLine(field.label, boolValue || 'N/A', boolValue || '');
}
if (field.type === 'uint') {
var numeric = formatUintWord(field.value);
return renderInspectorTextLine(field.label, numeric || 'N/A', field.value);
}
return renderInspectorTextLine(field.label, field.value, field.value);
}).filter(function(line) { return line; }).join('');
if (!lines) return '';
return '<div class="tx-inspector-structured">' +
'<div class="tx-inspector-structured-title">Structured Fields</div>' +
lines +
'</div>';
}
function renderTransactionLogEntry(log, idx) { function renderTransactionLogEntry(log, idx) {
var addressValue = (log.address && (log.address.hash || log.address)) || log.address || ''; 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 topics = Array.isArray(log.topics) ? log.topics.filter(function(topic) { return topic != null; }) : [];
@@ -5035,28 +5226,33 @@
var dataBytes = /^0x[0-9a-f]*$/i.test(String(dataValue || '')) ? Math.max(0, (String(dataValue).length - 2) / 2) : 0; 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 blockNumber = log.block_number != null ? String(log.block_number) : '';
var txHash = log.transaction_hash || log.transactionHash || ''; var txHash = log.transaction_hash || log.transactionHash || '';
var knownEvent = detectKnownLogEvent(topics, dataValue);
var topicRows = topics.length ? '<div class="tx-inspector-topic-list">' + topics.map(function(topic, topicIndex) { var topicRows = topics.length ? '<div class="tx-inspector-topic-list">' + topics.map(function(topic, topicIndex) {
return '<div class="tx-inspector-topic-row"><div class="tx-inspector-topic-index">Topic ' + topicIndex + '</div><div class="tx-inspector-scroll"><code class="tx-inspector-mono">' + escapeHtml(String(topic)) + '</code>' + renderCopyButtonHtml(String(topic), 'Copy topic ' + topicIndex) + '</div></div>'; return '<div class="tx-inspector-topic-row"><div class="tx-inspector-topic-index">Topic ' + topicIndex + '</div>' + renderInspectorCopyRow('<code class="tx-inspector-mono">' + escapeHtml(String(topic)) + '</code>', String(topic), 'Copy topic ' + topicIndex) + '</div>';
}).join('') + '</div>' : '<span class="tx-empty">No topics</span>'; }).join('') + '</div>' : '<span class="tx-empty">No topics</span>';
var metaChips = '<div class="tx-chip-row">' + var metaChips = '<div class="tx-chip-row">' +
'<span class="tx-chip"><span class="tx-chip-label">Index</span><span>' + escapeHtml(String(log.index != null ? log.index : idx)) + '</span></span>' + '<span class="tx-chip"><span class="tx-chip-label">Index</span><span>' + escapeHtml(String(log.index != null ? log.index : idx)) + '</span></span>' +
'<span class="tx-chip"><span class="tx-chip-label">Topics</span><span>' + escapeHtml(String(topics.length)) + '</span></span>' + '<span class="tx-chip"><span class="tx-chip-label">Topics</span><span>' + escapeHtml(String(topics.length)) + '</span></span>' +
'<span class="tx-chip"><span class="tx-chip-label">Data</span><span>' + escapeHtml(String(dataBytes)) + ' bytes</span></span>' + '<span class="tx-chip"><span class="tx-chip-label">Data</span><span>' + escapeHtml(String(dataBytes)) + ' bytes</span></span>' +
'<span class="tx-chip tx-chip-emphasis" id="txLogEventChip' + idx + '"><span class="tx-chip-label">Event</span><span>' + escapeHtml(knownEvent && knownEvent.name ? knownEvent.name : (decodedValue ? 'decoded' : (topics[0] ? formatTopicSignaturePreview(topics[0]) : 'raw log'))) + '</span></span>' +
(knownEvent && knownEvent.standard ? '<span class="tx-chip"><span class="tx-chip-label">Standard</span><span>' + escapeHtml(knownEvent.standard) + '</span></span>' : '') +
'</div>'; '</div>';
var summaryTitle = 'Log #' + escapeHtml(String(log.index != null ? log.index : idx)) + ' • ' + escapeHtml(knownEvent && knownEvent.name ? knownEvent.name : (addressValue ? shortenHash(addressValue) : 'Unknown address'));
var html = '<details class="tx-inspector-entry"' + (idx === 0 ? ' open' : '') + '>'; var html = '<details class="tx-inspector-entry"' + (idx === 0 ? ' open' : '') + '>';
html += '<summary><span>Log #' + escapeHtml(String(log.index != null ? log.index : idx)) + '' + escapeHtml(addressValue ? shortenHash(addressValue) : 'Unknown address') + '</span><span class="tx-inspector-summary-value">' + escapeHtml(String(topics.length)) + ' topics / ' + escapeHtml(String(dataBytes)) + ' bytes</span></summary>'; html += '<summary><span id="txLogSummaryTitle' + idx + '">' + summaryTitle + '</span><span class="tx-inspector-summary-value">' + escapeHtml(String(topics.length)) + ' topics / ' + escapeHtml(String(dataBytes)) + ' bytes</span></summary>';
html += '<div class="tx-inspector-entry-body">'; html += '<div class="tx-inspector-entry-body">';
html += metaChips; html += metaChips;
html += renderInspectorHtmlLine('Address', addressValue ? explorerAddressLink(addressValue, escapeHtml(addressValue), 'color: inherit; text-decoration: none;') + renderCopyButtonHtml(addressValue, 'Copy log address') : '<span class="tx-empty">N/A</span>'); html += renderInspectorHtmlLine('Address', addressValue ? renderInspectorCopyRow(explorerAddressLink(addressValue, escapeHtml(addressValue), 'color: inherit; text-decoration: none;'), addressValue, 'Copy log address') : '<span class="tx-empty">N/A</span>');
if (blockNumber) { if (blockNumber) {
html += renderInspectorHtmlLine('Block', explorerBlockLink(blockNumber, escapeHtml(blockNumber), 'color: inherit; text-decoration: none;')); html += renderInspectorHtmlLine('Block', renderInspectorCopyRow(explorerBlockLink(blockNumber, escapeHtml(blockNumber), 'color: inherit; text-decoration: none;'), blockNumber, 'Copy block number'));
} }
if (txHash) { if (txHash) {
html += renderInspectorHtmlLine('Tx Hash', explorerTransactionLink(txHash, escapeHtml(txHash), 'color: inherit; text-decoration: none;')); html += renderInspectorHtmlLine('Tx Hash', renderInspectorCopyRow(explorerTransactionLink(txHash, escapeHtml(txHash), 'color: inherit; text-decoration: none;'), txHash, 'Copy tx hash'));
} }
html += renderStructuredLogFields(knownEvent);
html += renderInspectorHtmlLine('Topics', topicRows); html += renderInspectorHtmlLine('Topics', topicRows);
html += renderInspectorCodeLine('Data', dataValue, dataValue); html += renderInspectorCodeLine('Data', dataValue, dataValue);
html += renderInspectorHtmlLine('Decoded', '<div id="txLogDecoded' + idx + '" class="tx-inspector-mono">' + (decodedValue ? escapeHtml(decodedValue) : '—') + '</div>' + (decodedValue ? renderCopyButtonHtml(decodedValue, 'Copy decoded log') : '')); html += renderInspectorHtmlLine('Decoded', renderInspectorCopyRow('<div id="txLogDecoded' + idx + '" class="tx-inspector-mono">' + (decodedValue ? escapeHtml(decodedValue) : '—') + '</div>', decodedValue || '', 'Copy decoded log'));
html += '</div></details>'; html += '</div></details>';
return html; return html;
} }
@@ -5089,7 +5285,7 @@
blockNumber = bn; blockNumber = bn;
currentDetailKey = 'block:' + blockNumber; currentDetailKey = 'block:' + blockNumber;
showView('blockDetail'); showView('blockDetail');
updatePath('/block/' + blockNumber); updatePath('/blocks/' + blockNumber);
const container = document.getElementById('blockDetail'); const container = document.getElementById('blockDetail');
updateBreadcrumb('block', blockNumber); updateBreadcrumb('block', blockNumber);
container.innerHTML = createSkeletonLoader('detail'); container.innerHTML = createSkeletonLoader('detail');
@@ -5207,7 +5403,7 @@
txHash = th; txHash = th;
currentDetailKey = 'tx:' + txHash.toLowerCase(); currentDetailKey = 'tx:' + txHash.toLowerCase();
showView('transactionDetail'); showView('transactionDetail');
updatePath('/tx/' + txHash); updatePath('/transactions/' + txHash);
const container = document.getElementById('transactionDetail'); const container = document.getElementById('transactionDetail');
updateBreadcrumb('transaction', txHash); updateBreadcrumb('transaction', txHash);
container.innerHTML = createSkeletonLoader('detail'); container.innerHTML = createSkeletonLoader('detail');
@@ -5221,7 +5417,13 @@
var detailResult = await fetchChain138TransactionDetail(txHash); var detailResult = await fetchChain138TransactionDetail(txHash);
rawTx = detailResult.rawTransaction; rawTx = detailResult.rawTransaction;
t = detailResult.transaction; t = detailResult.transaction;
if (!t) throw new Error('Transaction not found'); if (!t) {
var diagnostics = rawTx && rawTx.diagnostics ? rawTx.diagnostics : null;
if (diagnostics && diagnostics.rpc_transaction_found === false) {
throw new Error('Transaction not found in Blockscout or the Chain 138 public RPC. It may belong to a different network, have been replaced, or never broadcast successfully' + (diagnostics.latest_block_number ? ' (latest block #' + diagnostics.latest_block_number + ')' : ''));
}
throw new Error('Transaction not found');
}
} catch (error) { } catch (error) {
container.innerHTML = '<div class="error">Failed to load transaction: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showTransactionDetail(\'' + escapeHtml(String(txHash)) + '\')" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>'; container.innerHTML = '<div class="error">Failed to load transaction: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showTransactionDetail(\'' + escapeHtml(String(txHash)) + '\')" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>';
return; return;
@@ -5579,6 +5781,15 @@
var args = parsed.args && parsed.args.length ? parsed.args.map(function(a) { return String(a); }).join(', ') : ''; var args = parsed.args && parsed.args.length ? parsed.args.map(function(a) { return String(a); }).join(', ') : '';
decodedEl.textContent = parsed.name + '(' + args + ')'; decodedEl.textContent = parsed.name + '(' + args + ')';
decodedEl.title = parsed.signature || ''; decodedEl.title = parsed.signature || '';
var summaryTitleEl = document.getElementById('txLogSummaryTitle' + idx);
if (summaryTitleEl) {
summaryTitleEl.textContent = 'Log #' + String(log.index != null ? log.index : idx) + ' • ' + parsed.name;
}
var eventChipEl = document.getElementById('txLogEventChip' + idx);
if (eventChipEl) {
eventChipEl.innerHTML = '<span class="tx-chip-label">Event</span><span>' + escapeHtml(parsed.name) + '</span>';
eventChipEl.title = parsed.signature || '';
}
} }
} catch (e) {} } catch (e) {}
}); });
@@ -5723,7 +5934,7 @@
address = addr; address = addr;
currentDetailKey = 'address:' + address.toLowerCase(); currentDetailKey = 'address:' + address.toLowerCase();
showView('addressDetail'); showView('addressDetail');
updatePath('/address/' + address); updatePath('/addresses/' + address);
const container = document.getElementById('addressDetail'); const container = document.getElementById('addressDetail');
updateBreadcrumb('address', address); updateBreadcrumb('address', address);
container.innerHTML = createSkeletonLoader('detail'); container.innerHTML = createSkeletonLoader('detail');
@@ -5908,7 +6119,7 @@
const decimals = token.decimals != null ? token.decimals : 18; const decimals = token.decimals != null ? token.decimals : 18;
const displayBalance = formatUnitsLocalized(balance, decimals, 6); const displayBalance = formatUnitsLocalized(balance, decimals, 6);
const type = token.type || b.token_type || 'ERC-20'; const type = token.type || b.token_type || 'ERC-20';
tbl += '<tr><td><a href="/token/' + encodeURIComponent(contract) + '">' + escapeHtml(symbol) + '</a></td><td>' + explorerAddressLink(contract, escapeHtml(shortenHash(contract)), 'color: inherit; text-decoration: none;') + '</td><td>' + escapeHtml(displayBalance) + '</td><td>' + escapeHtml(type) + '</td></tr>'; tbl += '<tr><td><a href="/tokens/' + encodeURIComponent(contract) + '">' + escapeHtml(symbol) + '</a></td><td>' + explorerAddressLink(contract, escapeHtml(shortenHash(contract)), 'color: inherit; text-decoration: none;') + '</td><td>' + escapeHtml(displayBalance) + '</td><td>' + escapeHtml(type) + '</td></tr>';
}); });
if (filteredItems.length === 0) { if (filteredItems.length === 0) {
tbl += '<tr><td colspan="4" style="text-align:center; padding: 1rem;">No token balances match the current filter.</td></tr>'; tbl += '<tr><td colspan="4" style="text-align:center; padding: 1rem;">No token balances match the current filter.</td></tr>';
@@ -6236,7 +6447,7 @@
if (!/^0x[a-fA-F0-9]{40}$/.test(tokenAddress)) return; if (!/^0x[a-fA-F0-9]{40}$/.test(tokenAddress)) return;
currentDetailKey = 'token:' + tokenAddress.toLowerCase(); currentDetailKey = 'token:' + tokenAddress.toLowerCase();
showView('tokenDetail'); showView('tokenDetail');
updatePath('/token/' + tokenAddress); updatePath('/tokens/' + tokenAddress);
var container = document.getElementById('tokenDetail'); var container = document.getElementById('tokenDetail');
updateBreadcrumb('token', tokenAddress); updateBreadcrumb('token', tokenAddress);
container.innerHTML = createSkeletonLoader('detail'); container.innerHTML = createSkeletonLoader('detail');
@@ -6251,7 +6462,7 @@
} catch (e) {} } catch (e) {}
} }
if (!data) { if (!data) {
container.innerHTML = '<p class="error">Token not found or not indexed.</p><p><a href="/address/' + encodeURIComponent(tokenAddress) + '">View as address</a></p>'; container.innerHTML = '<p class="error">Token not found or not indexed.</p><p><a href="/addresses/' + encodeURIComponent(tokenAddress) + '">View as address</a></p>';
return; return;
} }
var knownTokenDetail = { var knownTokenDetail = {

View File

@@ -7,30 +7,30 @@
<meta http-equiv="Pragma" content="no-cache"> <meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0"> <meta http-equiv="Expires" content="0">
<!-- CSP: unsafe-eval required by ethers.js v5 UMD from CDN (uses new Function for ABI). Our code avoids eval/string setTimeout. Can be removed when moving to ethers v6 build (no UMD eval). --> <!-- CSP: unsafe-eval required by ethers.js v5 UMD from CDN (uses new Function for ABI). Our code avoids eval/string setTimeout. Can be removed when moving to ethers v6 build (no UMD eval). -->
<meta http-equiv="Content-Security-Policy" content="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; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://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;"> <meta http-equiv="Content-Security-Policy" content="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; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://blockscout.defi-oracle.io https://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;">
<title>SolaceScanScout | The Defi Oracle Meta Explorer | d-bis.org</title> <title>SolaceScan | Chain 138 Explorer by DBIS</title>
<meta name="description" content="SolaceScanScout - The Defi Oracle Meta Explorer. Comprehensive blockchain explorer for ChainID 138 with cross-chain bridge monitoring, WETH utilities, and real-time transaction tracking."> <meta name="description" content="SolaceScan - Chain 138 Explorer by DBIS. Public explorer surfaces for blocks, transactions, addresses, routes, wallet tools, and bridge monitoring.">
<meta name="keywords" content="blockchain explorer, ChainID 138, CCIP bridge, WETH, DeFi Oracle, SolaceScanScout, blockchain, ethereum, blockscout"> <meta name="keywords" content="blockchain explorer, ChainID 138, DBIS, SolaceScan, bridge monitoring, wallet tools, blockchain, ethereum, blockscout">
<meta name="author" content="SolaceScanScout"> <meta name="author" content="SolaceScan">
<meta name="application-name" content="SolaceScanScout"> <meta name="application-name" content="SolaceScan">
<meta name="theme-color" content="#667eea"> <meta name="theme-color" content="#667eea">
<script> <script>
(function(){function t(){var e=document.getElementById('navLinks'),n=document.getElementById('navToggle'),r=document.getElementById('navToggleIcon');if(e&&n){var o=e.classList.toggle('nav-open');n.setAttribute('aria-expanded',o?'true':'false');if(r)r.className=o?'fas fa-times':'fas fa-bars';}}function c(){var e=document.getElementById('navLinks'),n=document.getElementById('navToggle'),r=document.getElementById('navToggleIcon');if(e)e.classList.remove('nav-open');if(n)n.setAttribute('aria-expanded','false');if(r)r.className='fas fa-bars';}window.toggleNavMenu=t;window.closeNavMenu=c;})(); (function(){function t(){var e=document.getElementById('navLinks'),n=document.getElementById('navToggle'),r=document.getElementById('navToggleIcon');if(e&&n){var o=e.classList.toggle('nav-open');n.setAttribute('aria-expanded',o?'true':'false');if(r)r.className=o?'fas fa-times':'fas fa-bars';}}function c(){var e=document.getElementById('navLinks'),n=document.getElementById('navToggle'),r=document.getElementById('navToggleIcon');if(e)e.classList.remove('nav-open');if(n)n.setAttribute('aria-expanded','false');if(r)r.className='fas fa-bars';}window.toggleNavMenu=t;window.closeNavMenu=c;})();
</script> </script>
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://explorer.d-bis.org/"> <meta property="og:url" content="https://blockscout.defi-oracle.io/">
<meta property="og:title" content="SolaceScanScout - The Defi Oracle Meta Explorer"> <meta property="og:title" content="SolaceScan - Chain 138 Explorer by DBIS">
<meta property="og:description" content="Comprehensive blockchain explorer for ChainID 138 with cross-chain bridge monitoring and WETH utilities."> <meta property="og:description" content="Comprehensive blockchain explorer for ChainID 138 with cross-chain bridge monitoring and WETH utilities.">
<meta property="og:image" content="https://explorer.d-bis.org/og-image.png"> <meta property="og:image" content="https://blockscout.defi-oracle.io/og-image.png">
<meta property="og:site_name" content="SolaceScanScout"> <meta property="og:site_name" content="SolaceScan">
<!-- Twitter --> <!-- Twitter -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://explorer.d-bis.org/"> <meta name="twitter:url" content="https://blockscout.defi-oracle.io/">
<meta name="twitter:title" content="SolaceScanScout - The Defi Oracle Meta Explorer"> <meta name="twitter:title" content="SolaceScan - Chain 138 Explorer by DBIS">
<meta name="twitter:description" content="Comprehensive blockchain explorer for ChainID 138 with cross-chain bridge monitoring and WETH utilities."> <meta name="twitter:description" content="Comprehensive blockchain explorer for ChainID 138 with cross-chain bridge monitoring and WETH utilities.">
<meta name="twitter:image" content="https://explorer.d-bis.org/og-image.png"> <meta name="twitter:image" content="https://blockscout.defi-oracle.io/og-image.png">
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="icon" type="image/x-icon" href="/favicon.ico">
@@ -917,8 +917,28 @@
opacity: 0.85; opacity: 0.85;
} }
.gas-network-subtle { color: var(--text-light); font-size: 0.82rem; white-space: nowrap; } .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 {
.btn-copy:hover { color: var(--primary); } display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
padding: 0;
margin: 0;
color: var(--text-light);
vertical-align: middle;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
flex: 0 0 auto;
}
.btn-copy:hover {
color: var(--primary);
border-color: rgba(59, 130, 246, 0.35);
background: rgba(59, 130, 246, 0.08);
}
.tx-chip-row { .tx-chip-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -935,6 +955,10 @@
color: var(--text); color: var(--text);
font-size: 0.8rem; font-size: 0.8rem;
} }
.tx-chip-emphasis {
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.18);
}
.tx-chip-label { .tx-chip-label {
color: var(--text-light); color: var(--text-light);
text-transform: uppercase; text-transform: uppercase;
@@ -983,6 +1007,21 @@
font-size: 0.84rem; font-size: 0.84rem;
line-height: 1.5; line-height: 1.5;
} }
.tx-inspector-structured {
display: grid;
gap: 0.6rem;
padding: 0.85rem;
border-radius: 12px;
border: 1px solid rgba(59, 130, 246, 0.16);
background: rgba(59, 130, 246, 0.05);
}
.tx-inspector-structured-title {
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.72rem;
font-weight: 700;
}
.tx-inspector-line { .tx-inspector-line {
display: grid; display: grid;
grid-template-columns: minmax(110px, 140px) minmax(0, 1fr); grid-template-columns: minmax(110px, 140px) minmax(0, 1fr);
@@ -999,8 +1038,20 @@
.tx-inspector-content { .tx-inspector-content {
min-width: 0; min-width: 0;
} }
.tx-inspector-copy-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.65rem;
align-items: start;
}
.tx-inspector-copy-action {
display: flex;
align-items: flex-start;
justify-content: flex-end;
}
.tx-inspector-scroll { .tx-inspector-scroll {
overflow-x: auto; overflow-x: auto;
min-width: 0;
} }
.tx-inspector-mono { .tx-inspector-mono {
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
@@ -1028,7 +1079,7 @@
.tx-inspector-topic-row { .tx-inspector-topic-row {
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
padding: 0.7rem; padding: 0.8rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.45); background: rgba(255, 255, 255, 0.45);
@@ -1049,11 +1100,21 @@
body.dark-theme .tx-inspector-topic-row { body.dark-theme .tx-inspector-topic-row {
background: rgba(15, 23, 42, 0.44); background: rgba(15, 23, 42, 0.44);
} }
body.dark-theme .tx-inspector-structured {
background: rgba(30, 41, 59, 0.45);
border-color: rgba(96, 165, 250, 0.18);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.tx-inspector-line { .tx-inspector-line {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.35rem; gap: 0.35rem;
} }
.tx-inspector-copy-row {
grid-template-columns: 1fr;
}
.tx-inspector-copy-action {
justify-content: flex-start;
}
.tx-inspector-entry summary { .tx-inspector-entry summary {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@@ -1153,8 +1214,8 @@
<a class="logo" href="/" aria-label="Go to explorer home" style="text-decoration:none; color:inherit;"> <a class="logo" href="/" aria-label="Go to explorer home" style="text-decoration:none; color:inherit;">
<i class="fas fa-cube"></i> <i class="fas fa-cube"></i>
<div style="display: flex; flex-direction: column; gap: 0.25rem;"> <div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>SolaceScanScout</span> <span>SolaceScan</span>
<span style="font-size: 0.75rem; font-weight: normal; opacity: 0.9;">The Defi Oracle Meta Explorer</span> <span style="font-size: 0.75rem; font-weight: normal; opacity: 0.9;">Chain 138 Explorer by DBIS</span>
</div> </div>
</a> </a>
<div class="search-box" style="display: flex; gap: 0.5rem; align-items: center;"> <div class="search-box" style="display: flex; gap: 0.5rem; align-items: center;">
@@ -1189,7 +1250,7 @@
</ul> </ul>
</li> </li>
<li><a href="/snap/" aria-label="Chain 138 MetaMask Snap"><i class="fas fa-wallet" aria-hidden="true"></i> <span>MetaMask Snap</span></a></li> <li><a href="/snap/" aria-label="Chain 138 MetaMask Snap"><i class="fas fa-wallet" aria-hidden="true"></i> <span>MetaMask Snap</span></a></li>
<li role="none"><a href="/more" role="menuitem" onclick="event.preventDefault(); showMore(); updatePath('/more'); closeNavMenu();" aria-label="View more pages"><i class="fas fa-ellipsis-h" aria-hidden="true"></i> <span data-i18n="more">More</span></a></li> <li role="none"><a href="/operations" role="menuitem" onclick="event.preventDefault(); showMore(); updatePath('/operations'); closeNavMenu();" aria-label="View operations hub"><i class="fas fa-compass-drafting" aria-hidden="true"></i> <span>Operations</span></a></li>
</ul> </ul>
<div class="nav-actions"> <div class="nav-actions">
<select id="localeSelect" onchange="setLocale(this.value)" style="padding: 0.35rem 0.5rem; border-radius: 6px; background: rgba(255,255,255,0.2); color: white; border: 1px solid rgba(255,255,255,0.3); font-size: 0.875rem;" aria-label="Language"> <select id="localeSelect" onchange="setLocale(this.value)" style="padding: 0.35rem 0.5rem; border-radius: 6px; background: rgba(255,255,255,0.2); color: white; border: 1px solid rgba(255,255,255,0.3); font-size: 0.875rem;" aria-label="Language">
@@ -1345,7 +1406,7 @@
<div class="weth-card"> <div class="weth-card">
<div class="chain-name">WETH9 Token</div> <div class="chain-name">WETH9 Token</div>
<div style="color: var(--text-light); margin-bottom: 1rem;"> <div style="color: var(--text-light); margin-bottom: 1rem;">
Contract: <a class="hash" href="/address/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" onclick="event.preventDefault(); showAddressDetail('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')" style="color: inherit; text-decoration: none;">0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2</a> Contract: <a class="hash" href="/addresses/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" onclick="event.preventDefault(); showAddressDetail('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')" style="color: inherit; text-decoration: none;">0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2</a>
<button type="button" class="btn-add-token-wallet" onclick="event.stopPropagation(); window.addTokenToWallet && window.addTokenToWallet('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', 'WETH', 18, 'Wrapped Ether');" aria-label="Add WETH9 to wallet" title="Add to wallet"><i class="fas fa-wallet" aria-hidden="true"></i></button> <button type="button" class="btn-add-token-wallet" onclick="event.stopPropagation(); window.addTokenToWallet && window.addTokenToWallet('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', 'WETH', 18, 'Wrapped Ether');" aria-label="Add WETH9 to wallet" title="Add to wallet"><i class="fas fa-wallet" aria-hidden="true"></i></button>
</div> </div>
@@ -1395,7 +1456,7 @@
<div class="weth-card"> <div class="weth-card">
<div class="chain-name">WETH10 Token</div> <div class="chain-name">WETH10 Token</div>
<div style="color: var(--text-light); margin-bottom: 1rem;"> <div style="color: var(--text-light); margin-bottom: 1rem;">
Contract: <a class="hash" href="/address/0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f" onclick="event.preventDefault(); showAddressDetail('0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f')" style="color: inherit; text-decoration: none;">0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f</a> Contract: <a class="hash" href="/addresses/0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f" onclick="event.preventDefault(); showAddressDetail('0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f')" style="color: inherit; text-decoration: none;">0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f</a>
<button type="button" class="btn-add-token-wallet" onclick="event.stopPropagation(); window.addTokenToWallet && window.addTokenToWallet('0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f', 'WETH', 18, 'Wrapped Ether v10');" aria-label="Add WETH10 to wallet" title="Add to wallet"><i class="fas fa-wallet" aria-hidden="true"></i></button> <button type="button" class="btn-add-token-wallet" onclick="event.stopPropagation(); window.addTokenToWallet && window.addTokenToWallet('0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f', 'WETH', 18, 'Wrapped Ether v10');" aria-label="Add WETH10 to wallet" title="Add to wallet"><i class="fas fa-wallet" aria-hidden="true"></i></button>
</div> </div>
@@ -1452,8 +1513,8 @@
<h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">Contract Addresses</h4> <h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">Contract Addresses</h4>
<ul style="margin-left: 2rem; margin-top: 0.5rem;"> <ul style="margin-left: 2rem; margin-top: 0.5rem;">
<li><strong>WETH9:</strong> <a class="hash" href="/address/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" onclick="event.preventDefault(); showAddressDetail('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')" style="color: inherit; text-decoration: none;">0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2</a> <button type="button" class="btn-add-token-wallet" onclick="event.stopPropagation(); window.addTokenToWallet && window.addTokenToWallet('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', 'WETH', 18);" aria-label="Add WETH9 to wallet" title="Add to wallet"><i class="fas fa-wallet"></i></button></li> <li><strong>WETH9:</strong> <a class="hash" href="/addresses/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" onclick="event.preventDefault(); showAddressDetail('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')" style="color: inherit; text-decoration: none;">0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2</a> <button type="button" class="btn-add-token-wallet" onclick="event.stopPropagation(); window.addTokenToWallet && window.addTokenToWallet('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', 'WETH', 18);" aria-label="Add WETH9 to wallet" title="Add to wallet"><i class="fas fa-wallet"></i></button></li>
<li><strong>WETH10:</strong> <a class="hash" href="/address/0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f" onclick="event.preventDefault(); showAddressDetail('0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f')" style="color: inherit; text-decoration: none;">0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f</a> <button type="button" class="btn-add-token-wallet" onclick="event.stopPropagation(); window.addTokenToWallet && window.addTokenToWallet('0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f', 'WETH', 18);" aria-label="Add WETH10 to wallet" title="Add to wallet"><i class="fas fa-wallet"></i></button></li> <li><strong>WETH10:</strong> <a class="hash" href="/addresses/0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f" onclick="event.preventDefault(); showAddressDetail('0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f')" style="color: inherit; text-decoration: none;">0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f</a> <button type="button" class="btn-add-token-wallet" onclick="event.stopPropagation(); window.addTokenToWallet && window.addTokenToWallet('0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f', 'WETH', 18);" aria-label="Add WETH10 to wallet" title="Add to wallet"><i class="fas fa-wallet"></i></button></li>
</ul> </ul>
<h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">How to Use</h4> <h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">How to Use</h4>
@@ -1723,20 +1784,20 @@
<div class="container"> <div class="container">
<div class="site-footer-grid"> <div class="site-footer-grid">
<div> <div>
<div style="font-size: 1.05rem; font-weight: 700; margin-bottom: 0.5rem;">SolaceScanScout</div> <div style="font-size: 1.05rem; font-weight: 700; margin-bottom: 0.5rem;">SolaceScan</div>
<div class="site-footer-note"> <div class="site-footer-note">
Built on Blockscout foundations and Solace Bank Group PLC frontend development. Built on Blockscout foundations for the DBIS / Defi Oracle Chain 138 explorer surface.
Explorer data, block indexing, and public chain visibility are powered by Blockscout, Explorer data, block indexing, and public chain visibility are powered by Blockscout,
Chain 138 RPC, and the MetaMask Snap companion. Chain 138 RPC, and the MetaMask Snap companion.
</div> </div>
<div class="site-footer-note" style="margin-top: 0.8rem;"> <div class="site-footer-note" style="margin-top: 0.8rem;">
© 2026 Solace Bank Group PLC. All rights reserved. © 2026 DBIS / Defi Oracle. All rights reserved.
</div> </div>
</div> </div>
<div> <div>
<div class="site-footer-title">Documentation</div> <div class="site-footer-title">Documentation</div>
<div class="site-footer-links"> <div class="site-footer-links">
<a href="/docs.html">Docs landing page</a> <a href="/docs">Docs landing page</a>
<a href="/liquidity">Liquidity access</a> <a href="/liquidity">Liquidity access</a>
<a href="/routes">Routes</a> <a href="/routes">Routes</a>
<a href="/privacy.html">Privacy Policy</a> <a href="/privacy.html">Privacy Policy</a>
@@ -1755,6 +1816,6 @@
</div> </div>
</footer> </footer>
<script src="/explorer-spa.js?v=34"></script> <script src="/explorer-spa.js?v=35"></script>
</body> </body>
</html> </html>

View File

@@ -3,8 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy | SolaceScanScout</title> <title>Privacy Policy | SolaceScan</title>
<meta name="description" content="Privacy policy for the SolaceScanScout explorer."> <meta name="description" content="Privacy policy for the SolaceScan Chain 138 explorer operated by DBIS / Defi Oracle.">
<style> <style>
body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: linear-gradient(180deg, #0f172a 0%, #111827 45%, #f8fafc 46%, #ffffff 100%); color: #0f172a; } body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: linear-gradient(180deg, #0f172a 0%, #111827 45%, #f8fafc 46%, #ffffff 100%); color: #0f172a; }
.shell { max-width: 980px; margin: 0 auto; padding: 2rem 1rem 3rem; } .shell { max-width: 980px; margin: 0 auto; padding: 2rem 1rem 3rem; }
@@ -19,19 +19,61 @@
<body> <body>
<div class="shell"> <div class="shell">
<div class="topbar"> <div class="topbar">
<div class="brand">SolaceScanScout Privacy Policy</div> <div class="brand">SolaceScan Privacy Policy</div>
<a href="/">Back to explorer</a> <a href="/">Back to explorer</a>
</div> </div>
<div class="card"> <div class="card">
<h1 style="margin-top:0;">Privacy Policy</h1> <h1 style="margin-top:0;">Privacy Policy</h1>
<p class="muted">Last updated: 2026-03-25</p> <p class="muted">Last updated: 2026-03-25</p>
<p>SolaceScanScout is a blockchain explorer. Most content you view comes from public blockchain data and public APIs. We do not ask for personal information to browse the explorer.</p> <p>SolaceScan is the public Chain 138 explorer surface operated by DBIS / Defi Oracle. Most content you view comes from public blockchain data, explorer indexers, route services, and public configuration endpoints. We do not ask for personal information to browse the public explorer.</p>
<h2>What we store locally</h2>
<ul> <ul>
<li>We may store theme preference, locale, recent searches, and similar local UI settings in your browser.</li> <li>We may store theme preference, locale, recent searches, and similar local UI settings in your browser.</li>
<li>When you use wallet features or the Snap companion, the app may interact with your wallet provider to complete the request you initiate.</li> <li>Watchlist entries, saved searches, and similar convenience features may also be stored in browser local storage on the device you use.</li>
<li>Explorer queries are sent to the configured blockchain APIs and RPC endpoints so the site can display blocks, transactions, addresses, and related data.</li>
<li>We do not sell personal data. We also do not intentionally track users with advertising cookies on this explorer.</li>
</ul> </ul>
<h2>Wallet and account interactions</h2>
<ul>
<li>When you use wallet features, sign in with a wallet, or request wallet actions such as <code>wallet_addEthereumChain</code> or <code>wallet_watchAsset</code>, the explorer interacts with your wallet provider only for the action you initiate.</li>
<li>Wallet requests are handled by your wallet software. We do not take custody of private keys through the public explorer.</li>
<li>If you use authenticated access features, a session token may be stored locally in your browser to keep you signed in.</li>
</ul>
<h2>Explorer, RPC, and companion services</h2>
<ul>
<li>Explorer queries are sent to configured blockchain APIs, explorer APIs, route services, and RPC endpoints so the site can display blocks, transactions, addresses, tokens, routes, bridge monitoring, and related operational data.</li>
<li>Some pages also link to companion resources such as the Chain 138 Snap site and machine-readable configuration endpoints.</li>
<li>Operational services may record standard server logs for security, availability, abuse prevention, and troubleshooting.</li>
<li>Operational logs may include timestamps, requested URLs, response codes, and similar service-health data. These records are used for security and service maintenance rather than advertising.</li>
</ul>
<h2>Cookies, analytics, and advertising</h2>
<ul>
<li>We do not intentionally use advertising cookies on this explorer.</li>
<li>We do not sell personal data.</li>
<li>If telemetry or monitoring is enabled for service health, it is used for product operations rather than targeted advertising.</li>
</ul>
<h2>Domains and operator identity</h2>
<p>The explorer may be reached through <code>blockscout.defi-oracle.io</code> and companion DBIS domains such as <code>explorer.d-bis.org</code>. Those domains are part of the same Chain 138 explorer and companion tooling surface.</p>
<h2>Third-party services</h2>
<ul>
<li>Wallet-provider software, RPC endpoints, Snap delivery, and related blockchain infrastructure may be operated by third parties or companion services with their own terms and availability posture.</li>
<li>When you leave the public explorer for a companion site or third-party wallet flow, their policies and operational controls may differ from those of the explorer itself.</li>
</ul>
<h2>Retention and abuse prevention</h2>
<ul>
<li>We retain only the minimum service and security records needed to operate, troubleshoot, and protect the public explorer and related APIs.</li>
<li>We may restrict, rate-limit, or block abusive traffic in order to preserve service integrity.</li>
<li>Browser-local settings such as recent searches or watchlist entries remain under your browser profile until you clear them.</li>
</ul>
<h2>Operational subprocessors and infrastructure categories</h2>
<ul>
<li>The explorer may rely on infrastructure categories such as CDN delivery, DNS providers, reverse proxies, RPC endpoints, explorer indexers, and wallet-provider software.</li>
<li>Those services process requests only to the extent needed to deliver explorer pages, static assets, blockchain data, wallet flows, and service-health monitoring.</li>
</ul>
<h2>Jurisdiction and service posture</h2>
<ul>
<li>This public explorer is an informational and operational infrastructure surface, not a consumer banking product.</li>
<li>Questions about service operation, data handling, or policy notices should be directed to the support mailbox below so they can be routed to the correct operator team.</li>
</ul>
<h2>Contact</h2>
<p>If you have privacy questions, contact <a href="mailto:support@d-bis.org">support@d-bis.org</a>.</p> <p>If you have privacy questions, contact <a href="mailto:support@d-bis.org">support@d-bis.org</a>.</p>
</div> </div>
</div> </div>

View File

@@ -3,8 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terms of Service | SolaceScanScout</title> <title>Terms of Service | SolaceScan</title>
<meta name="description" content="Terms of service for the SolaceScanScout explorer."> <meta name="description" content="Terms of service for the SolaceScan Chain 138 explorer operated by DBIS / Defi Oracle.">
<style> <style>
body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: linear-gradient(180deg, #0f172a 0%, #111827 45%, #f8fafc 46%, #ffffff 100%); color: #0f172a; } body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: linear-gradient(180deg, #0f172a 0%, #111827 45%, #f8fafc 46%, #ffffff 100%); color: #0f172a; }
.shell { max-width: 980px; margin: 0 auto; padding: 2rem 1rem 3rem; } .shell { max-width: 980px; margin: 0 auto; padding: 2rem 1rem 3rem; }
@@ -19,20 +19,55 @@
<body> <body>
<div class="shell"> <div class="shell">
<div class="topbar"> <div class="topbar">
<div class="brand">SolaceScanScout Terms of Service</div> <div class="brand">SolaceScan Terms of Service</div>
<a href="/">Back to explorer</a> <a href="/">Back to explorer</a>
</div> </div>
<div class="card"> <div class="card">
<h1 style="margin-top:0;">Terms of Service</h1> <h1 style="margin-top:0;">Terms of Service</h1>
<p class="muted">Last updated: 2026-03-25</p> <p class="muted">Last updated: 2026-03-25</p>
<p>This explorer is provided for informational and operational purposes. By using it, you agree that:</p> <p>SolaceScan is provided for informational and operational purposes by DBIS / Defi Oracle. By using the public explorer, wallet tools, docs, and linked companion resources, you agree that:</p>
<h2>Service scope</h2>
<ul> <ul>
<li>Blockchain data may be delayed, incomplete, or temporarily unavailable.</li> <li>Blockchain data may be delayed, incomplete, or temporarily unavailable.</li>
<li>You are responsible for verifying addresses, transactions, and contract details before acting on them.</li> <li>You are responsible for verifying addresses, transactions, and contract details before acting on them.</li>
<li>We may update features, endpoints, and policies as the explorer evolves.</li> <li>We may update features, endpoints, and policies as the explorer evolves.</li>
<li>The explorer is not legal, financial, or tax advice.</li> <li>The explorer is not legal, financial, tax, or regulatory advice.</li>
</ul>
<h2>Wallet and tool usage</h2>
<ul>
<li>Wallet actions, signatures, and network additions are initiated by you through your own wallet software.</li>
<li>Public route, bridge, and operations pages are informational and investigative surfaces unless a page explicitly provides an authenticated management workflow.</li>
<li>Machine-readable configuration endpoints, token lists, and capability documents are provided on an as-is basis.</li>
</ul>
<h2>No guarantee of completeness</h2>
<ul>
<li>Explorer indexes, route inventory, bridge monitoring, and analytics surfaces may omit events, lag behind the chain, or reflect temporary service degradation.</li>
<li>Third-party services such as wallets, RPC providers, bridge infrastructure, and browser extensions operate under their own terms and availability constraints.</li>
</ul>
<h2>Acceptable use</h2>
<ul>
<li>You may not use the explorer or its public APIs to abuse service capacity, interfere with normal operations, or attempt unauthorized access to operator-only functions.</li>
<li>You remain responsible for your own transactions, wallet actions, and any downstream use of exported or copied explorer data.</li>
</ul>
<h2>Availability and service boundaries</h2>
<ul>
<li>The public explorer is provided on an as-is and as-available basis. We do not promise uninterrupted uptime, perfect indexing, or immediate data freshness.</li>
<li>Bridge, route, liquidity, and operational surfaces are investigative and informational unless a page explicitly presents an authenticated management workflow.</li>
</ul>
<h2>Operator identity</h2>
<p>SolaceScan is operated by DBIS / Defi Oracle. Public explorer access may appear under <code>blockscout.defi-oracle.io</code>, while companion resources may appear under <code>explorer.d-bis.org</code> and related DBIS domains.</p>
<h2>Support and notices</h2>
<p>For service questions, operational issues, or policy notices, contact <a href="mailto:support@d-bis.org">support@d-bis.org</a>.</p>
<h2>Disputes and interpretation</h2>
<ul>
<li>These public terms describe the explorers operational posture and do not replace any separately negotiated service terms for private infrastructure, managed APIs, or enterprise support.</li>
<li>If a public page and a machine-readable endpoint differ during service degradation, the machine-readable endpoint or underlying chain state should be treated as the more authoritative source.</li>
</ul>
<h2>Changes to the service</h2>
<ul>
<li>We may add, remove, rename, or reorganize routes, docs, and operational surfaces as the explorer evolves.</li>
<li>Compatibility redirects may be kept for continuity, but older slugs or companion pages should not be treated as permanent API contracts unless explicitly documented as such.</li>
</ul> </ul>
<p>For service questions, contact <a href="mailto:support@d-bis.org">support@d-bis.org</a>.</p>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -4,7 +4,7 @@ const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/
const addressUnderTest = process.env.SMOKE_ADDRESS || '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506' const addressUnderTest = process.env.SMOKE_ADDRESS || '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506'
const checks = [ const checks = [
{ path: '/', expectTexts: ['SolaceScanScout', 'Recent Blocks', 'Open wallet tools'] }, { path: '/', expectTexts: ['SolaceScan', 'Recent Blocks', 'Open wallet tools'] },
{ path: '/blocks', expectTexts: ['Blocks'] }, { path: '/blocks', expectTexts: ['Blocks'] },
{ path: '/transactions', expectTexts: ['Transactions'] }, { path: '/transactions', expectTexts: ['Transactions'] },
{ path: '/addresses', expectTexts: ['Addresses', 'Open An Address'] }, { path: '/addresses', expectTexts: ['Addresses', 'Open An Address'] },

View File

@@ -1,13 +0,0 @@
import './globals.css'
import type { ReactNode } from 'react'
import ExplorerChrome from '@/components/common/ExplorerChrome'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<ExplorerChrome>{children}</ExplorerChrome>
</body>
</html>
)
}

View File

@@ -1,5 +0,0 @@
import LiquidityOperationsPage from '@/components/explorer/LiquidityOperationsPage'
export default function LiquidityPage() {
return <LiquidityOperationsPage />
}

View File

@@ -1,234 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives/Card'
import Link from 'next/link'
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'
type HomeStats = ExplorerStats
export default function Home() {
const [stats, setStats] = useState<HomeStats | null>(null)
const [recentBlocks, setRecentBlocks] = useState<Block[]>([])
const [relaySummary, setRelaySummary] = useState<MissionControlRelaySummary | null>(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 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)
}
},
})
setStats(dashboardData.stats)
setRecentBlocks(dashboardData.recentBlocks)
}, [chainId])
useEffect(() => {
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 (
<main className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-6 sm:mb-8">
<h1 className="mb-2 text-3xl font-bold sm:text-4xl">SolaceScanScout</h1>
<p className="text-base text-gray-600 dark:text-gray-400 sm:text-lg">The Defi Oracle Meta Explorer</p>
</div>
{relaySummary && (
<Card className={`mb-6 border shadow-sm ${relayToneClasses}`}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-semibold uppercase tracking-wide opacity-80">Mission Control</div>
<div className="mt-1 text-sm sm:text-base">{relaySummary.text}</div>
<div className="mt-2 text-xs font-medium uppercase tracking-wide opacity-75">
Feed: {relayFeedState === 'live' ? 'Live SSE' : relayFeedState === 'fallback' ? 'Snapshot fallback' : 'Connecting'}
</div>
{relaySummary.items.length > 1 && (
<div className="mt-3 space-y-1 text-sm opacity-90">
{relaySummary.items.map((item) => (
<div key={item.key}>{item.text}</div>
))}
</div>
)}
</div>
<Link href="/explorer-api/v1/mission-control/stream" className="text-sm font-semibold underline-offset-4 hover:underline">
Open live stream
</Link>
</div>
</Card>
)}
{stats && (
<div className="mb-8 grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Latest Block</div>
<div className="text-xl font-bold sm:text-2xl">
{latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'}
</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Blocks</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_blocks.toLocaleString()}</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Transactions</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_transactions.toLocaleString()}</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Addresses</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_addresses.toLocaleString()}</div>
</Card>
</div>
)}
{!stats && (
<Card className="mb-8">
<p className="text-sm text-gray-600 dark:text-gray-400">
Live network stats are temporarily unavailable. Recent blocks and explorer tools are still available below.
</p>
</Card>
)}
<Card title="Recent Blocks">
{recentBlocks.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent blocks are unavailable right now.
</p>
) : (
<div className="space-y-2">
{recentBlocks.map((block) => (
<div key={block.number} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<Link href={`/blocks/${block.number}`} className="text-primary-600 hover:underline">
Block #{block.number}
</Link>
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
{block.transaction_count} transactions
</div>
</div>
))}
</div>
)}
<div className="mt-4">
<Link href="/blocks" className="text-primary-600 hover:underline">
View all blocks
</Link>
</div>
</Card>
<div className="mt-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="Liquidity & Routes">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Explore the public Chain 138 DODO PMM liquidity mesh, the canonical route matrix, and the
partner payload endpoints exposed through the explorer.
</p>
<div className="mt-4">
<Link href="/routes" className="text-primary-600 hover:underline">
Open routes and liquidity
</Link>
</div>
</Card>
<Card title="Wallet & Token Discovery">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Add Chain 138, Ethereum Mainnet, and ALL Mainnet to MetaMask, then use the explorer token
list URL so supported tokens appear automatically.
</p>
<div className="mt-4">
<Link href="/wallet" className="text-primary-600 hover:underline">
Open wallet tools
</Link>
</div>
</Card>
<Card title="Bridge & Relay Monitoring">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Open the public bridge monitoring surface for relay status, mission-control links, bridge trace tooling,
and the visual command center entry points.
</p>
<div className="mt-4">
<Link href="/bridge" className="text-primary-600 hover:underline">
Open bridge monitoring
</Link>
</div>
</Card>
<Card title="More Explorer Tools">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
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.
</p>
<div className="mt-4">
<Link href="/more" className="text-primary-600 hover:underline">
Open operations hub
</Link>
</div>
</Card>
</div>
</main>
)
}

View File

@@ -0,0 +1,873 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
import EntityBadge from '@/components/common/EntityBadge'
import {
accessApi,
type AccessAPIKeyRecord,
type AccessAuditEntry,
type AccessProduct,
type AccessSubscription,
type AccessUsageSummary,
type AccessUser,
type WalletAccessSession,
} from '@/services/api/access'
const ACCESS_SCOPE_OPTIONS = ['rpc:read', 'rpc:write', 'rpc:admin'] as const
const OPERATOR_IDENTITIES = [
{
slug: 'thirdweb-rpc',
label: 'ThirdWeb',
vmid: 2103,
address: '0xB2dEA0e264ddfFf91057A3415112e57A1a5Eac14',
},
{
slug: 'alltra-rpc',
label: 'Alltra/HYBX',
vmid: 2102,
address: '0xaf6e3444AEaf7855cf41b557C94A96dc7fcF49C1',
},
{
slug: 'core-rpc',
label: 'DBIS',
vmid: 2101,
address: '0x4A666F96fC8764181194447A7dFdb7d471b301C8',
},
] as const
function Field({
label,
value,
onChange,
type = 'text',
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
}) {
return (
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</span>
<input
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
</label>
)
}
export default function AccessManagementPage() {
const [products, setProducts] = useState<AccessProduct[]>([])
const [user, setUser] = useState<AccessUser | null>(null)
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
const [connectingWallet, setConnectingWallet] = useState(false)
const [apiKeys, setApiKeys] = useState<AccessAPIKeyRecord[]>([])
const [subscriptions, setSubscriptions] = useState<AccessSubscription[]>([])
const [usage, setUsage] = useState<AccessUsageSummary[]>([])
const [auditEntries, setAuditEntries] = useState<AccessAuditEntry[]>([])
const [adminSubscriptions, setAdminSubscriptions] = useState<AccessSubscription[]>([])
const [adminAuditEntries, setAdminAuditEntries] = useState<AccessAuditEntry[]>([])
const [auditLimit, setAuditLimit] = useState('20')
const [adminAuditLimit, setAdminAuditLimit] = useState('50')
const [adminSubscriptionStatus, setAdminSubscriptionStatus] = useState('pending')
const [adminAuditProduct, setAdminAuditProduct] = useState('')
const [adminActionNotes, setAdminActionNotes] = useState<Record<string, string>>({})
const [email, setEmail] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [apiKeyName, setAPIKeyName] = useState('Core RPC key')
const [apiKeyTier, setAPIKeyTier] = useState('pro')
const [apiKeyProduct, setAPIKeyProduct] = useState('thirdweb-rpc')
const [apiKeyExpiresDays, setAPIKeyExpiresDays] = useState('30')
const [apiKeyMonthlyQuota, setAPIKeyMonthlyQuota] = useState('')
const [apiKeyScopes, setAPIKeyScopes] = useState<string[]>(['rpc:read', 'rpc:write'])
const [createdKey, setCreatedKey] = useState('')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const clearSessionState = useCallback(() => {
setUser(null)
setApiKeys([])
setSubscriptions([])
setUsage([])
setAuditEntries([])
setAdminSubscriptions([])
setAdminAuditEntries([])
}, [])
const syncWalletSession = useCallback(() => {
setWalletSession(accessApi.getStoredWalletSession())
}, [])
const loadAdminData = useCallback(async (
isAdmin: boolean,
nextSubscriptionStatus = adminSubscriptionStatus,
nextAuditProduct = adminAuditProduct,
nextAuditLimit = Number(adminAuditLimit),
) => {
if (!isAdmin) {
setAdminSubscriptions([])
setAdminAuditEntries([])
return
}
const [adminResponse, adminAuditResponse] = await Promise.all([
accessApi.listAdminSubscriptions(nextSubscriptionStatus).catch(() => ({ subscriptions: [] })),
accessApi.listAdminAudit(nextAuditLimit, nextAuditProduct).catch(() => ({ entries: [] })),
])
setAdminSubscriptions(adminResponse.subscriptions || [])
setAdminAuditEntries(adminAuditResponse.entries || [])
}, [adminAuditLimit, adminAuditProduct, adminSubscriptionStatus])
const loadSignedInData = useCallback(async () => {
const [me, keys, usageResponse, auditResponse] = await Promise.all([
accessApi.getMe(),
accessApi.listAPIKeys(),
accessApi.getUsage().catch(() => ({ usage: [] })),
accessApi.listAudit(Number(auditLimit)).catch(() => ({ entries: [] })),
])
setUser(me.user)
setSubscriptions(me.subscriptions || [])
setApiKeys(keys.api_keys || [])
setUsage(usageResponse.usage || [])
setAuditEntries(auditResponse.entries || [])
await loadAdminData(Boolean(me.user?.is_admin))
}, [auditLimit, loadAdminData])
const loadAccessData = useCallback(async () => {
const productResponse = await accessApi.listProducts()
setProducts(productResponse.products || [])
syncWalletSession()
const token = accessApi.getStoredAccessToken()
if (!token) {
clearSessionState()
return
}
try {
await loadSignedInData()
} catch {
accessApi.clearSession()
clearSessionState()
}
}, [clearSessionState, loadSignedInData, syncWalletSession])
useEffect(() => {
void loadAccessData()
}, [loadAccessData])
useEffect(() => {
syncWalletSession()
window.addEventListener('storage', syncWalletSession)
window.addEventListener('explorer-access-session-changed', syncWalletSession)
return () => {
window.removeEventListener('storage', syncWalletSession)
window.removeEventListener('explorer-access-session-changed', syncWalletSession)
}
}, [syncWalletSession])
useEffect(() => {
if (!user) return
void accessApi
.listAudit(Number(auditLimit))
.then((response) => setAuditEntries(response.entries || []))
.catch(() => {})
}, [auditLimit, user])
useEffect(() => {
if (!user?.is_admin) return
void loadAdminData(true)
}, [adminSubscriptionStatus, adminAuditLimit, adminAuditProduct, loadAdminData, user?.is_admin])
useEffect(() => {
if (apiKeyProduct === 'core-rpc') {
setAPIKeyScopes((current) =>
current.includes('rpc:admin') ? current : [...current, 'rpc:admin'],
)
} else {
setAPIKeyScopes((current) => current.filter((scope) => scope !== 'rpc:admin'))
}
}, [apiKeyProduct])
const handleRegister = async (event: FormEvent) => {
event.preventDefault()
setError('')
setMessage('')
try {
const response = await accessApi.register(email, username, password)
setUser(response.user)
setMessage('Account created. You can now issue API keys for managed RPC access.')
await loadSignedInData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed')
}
}
const handleLogin = async (event: FormEvent) => {
event.preventDefault()
setError('')
setMessage('')
try {
const response = await accessApi.login(email, password)
setUser(response.user)
await loadSignedInData()
setMessage('Signed in successfully.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
}
}
const handleCreateAPIKey = async (event: FormEvent) => {
event.preventDefault()
setError('')
setMessage('')
setCreatedKey('')
try {
const response = await accessApi.createAPIKey({
name: apiKeyName,
tier: apiKeyTier,
productSlug: apiKeyProduct,
expiresDays: apiKeyExpiresDays === 'never' ? 0 : Number(apiKeyExpiresDays || 0),
monthlyQuota: apiKeyMonthlyQuota.trim() ? Number(apiKeyMonthlyQuota) : undefined,
scopes: apiKeyScopes,
})
setCreatedKey(response.api_key)
setMessage('API key created. This is the only time the plaintext key will be shown.')
await loadSignedInData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create API key')
}
}
const handleRotate = async (key: AccessAPIKeyRecord) => {
setError('')
setMessage('')
setCreatedKey('')
try {
const response = await accessApi.createAPIKey({
name: key.name.replace(/\s+\[[^\]]+\]$/, ''),
tier: key.tier,
productSlug: key.productSlug,
monthlyQuota: key.monthlyQuota,
scopes: key.scopes,
})
await accessApi.revokeAPIKey(key.id)
setCreatedKey(response.api_key)
setMessage('API key rotated. The old key has been revoked and the new plaintext key is shown below once.')
await loadSignedInData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to rotate API key')
}
}
const handleRevoke = async (id: string) => {
setError('')
setMessage('')
try {
await accessApi.revokeAPIKey(id)
setMessage('API key revoked.')
await loadSignedInData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to revoke API key')
}
}
const handleSignOut = () => {
accessApi.clearSession()
clearSessionState()
setCreatedKey('')
setMessage('Signed out.')
}
const handleWalletConnect = async () => {
setError('')
setMessage('')
try {
setConnectingWallet(true)
const session = await accessApi.connectWalletSession()
setWalletSession(session)
await loadSignedInData()
setMessage('Wallet connected. Account sign-in is active and authenticated explorer access is now available.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Wallet connection failed')
} finally {
setConnectingWallet(false)
}
}
const handleWalletDisconnect = () => {
accessApi.clearWalletSession()
syncWalletSession()
clearSessionState()
setCreatedKey('')
setMessage('Wallet session disconnected.')
}
const handleRequestSubscription = async (productSlug: string, tier: string) => {
setError('')
setMessage('')
try {
await accessApi.requestSubscription(productSlug, tier)
await loadSignedInData()
setMessage('Access request saved.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to request access')
}
}
const handleAdminSubscriptionAction = async (subscriptionId: string, status: string) => {
setError('')
setMessage('')
try {
await accessApi.updateAdminSubscription(subscriptionId, status, adminActionNotes[subscriptionId] || '')
await loadSignedInData()
setAdminActionNotes((current) => ({ ...current, [subscriptionId]: '' }))
setMessage(`Subscription ${status === 'active' ? 'approved' : status}.`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update subscription')
}
}
const getSubscriptionForProduct = (productSlug: string) =>
subscriptions.find((subscription) => subscription.productSlug === productSlug)
const handleScopeToggle = (scope: string) => {
setAPIKeyScopes((current) =>
current.includes(scope) ? current.filter((entry) => entry !== scope) : [...current, scope],
)
}
const handleAdminAuditProductChange = async (value: string) => {
setAdminAuditProduct(value)
}
const getOperatorIdentity = (productSlug: string) =>
OPERATOR_IDENTITIES.find((entry) => entry.slug === productSlug)
return (
<main className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
eyebrow="Access Control"
title="Wallet Login, RPC Access & API Tokens"
description="Connect a wallet for standard account sign-in, manage authenticated access, issue API keys, and prepare subscription-gated RPC products for DBIS, ThirdWeb, and Alltra."
actions={[
{ href: '/wallet', label: 'Wallet tools' },
{ href: '/system', label: 'System status' },
{ href: '/search', label: 'Search explorer' },
]}
/>
{message ? (
<Card className="mb-6 border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
<p className="text-sm text-emerald-900 dark:text-emerald-100">{message}</p>
</Card>
) : null}
{error ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm text-red-900 dark:text-red-100">{error}</p>
</Card>
) : null}
<div className="mb-8 grid gap-6 lg:grid-cols-3">
{products.map((product) => (
<Card key={product.slug} title={product.name}>
<div className="space-y-3 text-sm text-gray-700 dark:text-gray-300">
<div className="flex flex-wrap gap-2">
<EntityBadge label={product.provider} tone="info" />
<EntityBadge label={`vmid ${product.vmid}`} />
<EntityBadge label={product.default_tier} tone="success" />
<EntityBadge label={product.billing_model} tone="warning" />
{product.requires_approval ? <EntityBadge label="approval required" tone="warning" /> : <EntityBadge label="self-service" tone="success" />}
</div>
<p>{product.description}</p>
<div>
<div className="font-semibold text-gray-900 dark:text-white">HTTP</div>
<code className="break-all text-xs">{product.http_url}</code>
</div>
{product.ws_url ? (
<div>
<div className="font-semibold text-gray-900 dark:text-white">WS</div>
<code className="break-all text-xs">{product.ws_url}</code>
</div>
) : null}
<div>
<div className="font-semibold text-gray-900 dark:text-white">Use cases</div>
<div className="mt-2 flex flex-wrap gap-2">
{product.use_cases.map((item) => (
<EntityBadge key={item} label={item} className="normal-case tracking-normal" />
))}
</div>
</div>
{getOperatorIdentity(product.slug) ? (
<div>
<div className="font-semibold text-gray-900 dark:text-white">Primary operator / deployer</div>
<div className="mt-2 flex flex-wrap gap-2">
<EntityBadge label={getOperatorIdentity(product.slug)?.label || product.provider} tone="info" />
<EntityBadge label={`vmid ${getOperatorIdentity(product.slug)?.vmid || product.vmid}`} />
</div>
<code className="mt-2 block break-all text-xs">{getOperatorIdentity(product.slug)?.address}</code>
</div>
) : null}
{user ? (
<div className="border-t border-gray-200 pt-3 dark:border-gray-700">
{(() => {
const subscription = getSubscriptionForProduct(product.slug)
if (subscription) {
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<EntityBadge label={subscription.status} tone={subscription.status === 'active' ? 'success' : 'warning'} />
<EntityBadge label={subscription.tier} />
<EntityBadge label={`${subscription.requestsUsed}/${subscription.monthlyQuota || 0}`} tone="info" />
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{subscription.notes || 'Subscription record present.'}
</div>
</div>
)
}
return (
<button
type="button"
onClick={() => void handleRequestSubscription(product.slug, product.default_tier)}
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
{product.requires_approval ? 'Request access' : 'Activate access'}
</button>
)
})()}
</div>
) : null}
</div>
</Card>
))}
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_1.2fr]">
<div className="space-y-6">
<Card title="Wallet Authentication">
<div className="space-y-4 text-sm text-gray-700 dark:text-gray-300">
<p>
Use a connected wallet for standard account sign-in, then access subscriptions, API keys, and managed RPC controls with the same authenticated session.
</p>
{walletSession ? (
<div className="space-y-3 rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap gap-2">
<EntityBadge label="wallet sign-in active" tone="success" />
<EntityBadge label={walletSession.track} tone="info" />
{walletSession.permissions.map((permission) => (
<EntityBadge key={permission} label={permission} className="normal-case tracking-normal" />
))}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Your wallet address remains private within the access console. This session is treated as account sign-in, not a public identifier.
</p>
<div className="text-xs text-gray-500 dark:text-gray-400">
Session expires {new Date(walletSession.expiresAt).toLocaleString()}
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleWalletDisconnect}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
>
Disconnect wallet
</button>
<Link href="/wallet" className="rounded-lg border border-primary-300 px-4 py-2 text-sm font-medium text-primary-700 hover:bg-primary-50 dark:border-primary-800 dark:text-primary-300 dark:hover:bg-primary-950/20">
Open wallet tools
</Link>
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed border-primary-300 bg-primary-50/60 p-4 dark:border-primary-700/50 dark:bg-primary-950/20">
<div className="mb-3 text-sm text-primary-900 dark:text-primary-100">
No wallet session is active. Connect a browser wallet to sign in to your account and unlock the access-management plane.
</div>
<button
type="button"
onClick={() => void handleWalletConnect()}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
</button>
</div>
)}
</div>
</Card>
<Card title="Operator Identities">
<div className="space-y-4">
{OPERATOR_IDENTITIES.map((identity) => (
<div key={identity.slug} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap gap-2">
<EntityBadge label={identity.label} tone="info" />
<EntityBadge label={identity.slug} />
<EntityBadge label={`vmid ${identity.vmid}`} tone="warning" />
</div>
<code className="mt-3 block break-all text-xs text-gray-700 dark:text-gray-300">{identity.address}</code>
</div>
))}
</div>
</Card>
<Card title={user ? `Signed in as ${user.username}` : 'Create or Access Account'}>
{user ? (
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
{user.email}
</p>
<button
type="button"
onClick={handleSignOut}
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
>
Sign out
</button>
</div>
) : (
<div className="space-y-6">
<form onSubmit={handleRegister} className="space-y-3">
<Field label="Email" value={email} onChange={setEmail} type="email" />
<Field label="Username" value={username} onChange={setUsername} />
<Field label="Password" value={password} onChange={setPassword} type="password" />
<button type="submit" className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700">
Register
</button>
</form>
<form onSubmit={handleLogin} className="space-y-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<Field label="Email" value={email} onChange={setEmail} type="email" />
<Field label="Password" value={password} onChange={setPassword} type="password" />
<button type="submit" className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white">
Sign in
</button>
</form>
</div>
)}
</Card>
{user ? (
<Card title="Create API Key">
<form onSubmit={handleCreateAPIKey} className="space-y-3">
<Field label="Key name" value={apiKeyName} onChange={setAPIKeyName} />
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tier</span>
<select value={apiKeyTier} onChange={(event) => setAPIKeyTier(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
<option value="free">free</option>
<option value="pro">pro</option>
<option value="enterprise">enterprise</option>
</select>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Product</span>
<select value={apiKeyProduct} onChange={(event) => setAPIKeyProduct(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
{products.map((product) => (
<option key={product.slug} value={product.slug}>{product.name}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Expiry</span>
<select value={apiKeyExpiresDays} onChange={(event) => setAPIKeyExpiresDays(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="365">365 days</option>
<option value="never">No expiry</option>
</select>
</label>
<Field label="Monthly quota override (optional)" value={apiKeyMonthlyQuota} onChange={setAPIKeyMonthlyQuota} />
<div>
<span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Scopes</span>
<div className="flex flex-wrap gap-2">
{ACCESS_SCOPE_OPTIONS.map((scope) => (
<label key={scope} className="inline-flex items-center gap-2 rounded-full border border-gray-300 px-3 py-2 text-sm dark:border-gray-700">
<input
type="checkbox"
checked={apiKeyScopes.includes(scope)}
onChange={() => handleScopeToggle(scope)}
/>
<span>{scope}</span>
</label>
))}
</div>
</div>
<button type="submit" className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700">
Issue key
</button>
</form>
{createdKey ? (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-950/20">
<div className="mb-2 text-sm font-semibold text-amber-900 dark:text-amber-100">Plaintext API key</div>
<code className="block break-all text-xs text-amber-900 dark:text-amber-100">{createdKey}</code>
</div>
) : null}
</Card>
) : null}
{user?.is_admin ? (
<Card title="Pending Access Review">
<div className="mb-4 flex flex-wrap items-end gap-3">
<label className="block min-w-[12rem]">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription status</span>
<select
value={adminSubscriptionStatus}
onChange={(event) => setAdminSubscriptionStatus(event.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="revoked">Revoked</option>
<option value="">All statuses</option>
</select>
</label>
</div>
{adminSubscriptions.length > 0 ? (
<div className="space-y-4">
{adminSubscriptions.map((subscription) => (
<div key={subscription.id} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="font-semibold text-gray-900 dark:text-white">{subscription.productSlug}</div>
<div className="mt-2 flex flex-wrap gap-2">
<EntityBadge label={subscription.status} tone={subscription.status === 'active' ? 'success' : 'warning'} />
<EntityBadge label={subscription.tier} />
<EntityBadge label={`${subscription.monthlyQuota.toLocaleString()} quota`} tone="info" />
{subscription.requiresApproval ? <EntityBadge label="restricted product" tone="warning" /> : null}
</div>
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
Requested {new Date(subscription.createdAt).toLocaleString()}
{subscription.notes ? ` · ${subscription.notes}` : ''}
</div>
<label className="mt-3 block">
<span className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">Admin note</span>
<input
type="text"
value={adminActionNotes[subscription.id] || ''}
onChange={(event) =>
setAdminActionNotes((current) => ({
...current,
[subscription.id]: event.target.value,
}))
}
placeholder="Reason, approval scope, or operator note"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
</label>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void handleAdminSubscriptionAction(subscription.id, 'active')}
className="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700"
>
Approve
</button>
<button
type="button"
onClick={() => void handleAdminSubscriptionAction(subscription.id, 'suspended')}
className="rounded-lg border border-amber-300 px-3 py-2 text-sm font-medium text-amber-800 hover:bg-amber-50 dark:border-amber-800 dark:text-amber-300 dark:hover:bg-amber-950/20"
>
Suspend
</button>
<button
type="button"
onClick={() => void handleAdminSubscriptionAction(subscription.id, 'revoked')}
className="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950/20"
>
Revoke
</button>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">No subscriptions match the current review filter.</p>
)}
</Card>
) : null}
{user?.is_admin ? (
<Card title="Platform Audit Feed">
<div className="mb-4 grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Filter by product</span>
<select value={adminAuditProduct} onChange={(event) => void handleAdminAuditProductChange(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
<option value="">All products</option>
{products.map((product) => (
<option key={`audit-${product.slug}`} value={product.slug}>{product.name}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Entries shown</span>
<select
value={adminAuditLimit}
onChange={(event) => setAdminAuditLimit(event.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
</div>
{adminAuditEntries.length > 0 ? (
<div className="space-y-3">
{adminAuditEntries.map((entry) => (
<div key={`admin-audit-${entry.id}`} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={entry.productSlug || 'unscoped'} tone="info" />
<EntityBadge label={entry.methodName || 'unknown method'} />
<EntityBadge label={`${entry.requestCount} req`} />
</div>
<div className="mt-2 font-medium text-gray-900 dark:text-white">{entry.keyName || entry.apiKeyId}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{new Date(entry.createdAt).toLocaleString()}
{entry.lastIp ? ` · ${entry.lastIp}` : ''}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">No recent validated RPC traffic matches the current filter.</p>
)}
</Card>
) : null}
</div>
<Card title="Issued API Keys">
{user ? (
apiKeys.length > 0 ? (
<div className="space-y-4">
{apiKeys.map((key) => (
<div key={key.id} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="font-semibold text-gray-900 dark:text-white">{key.name}</div>
<div className="mt-2 flex flex-wrap gap-2">
<EntityBadge label={key.tier} tone="success" />
{key.productSlug ? <EntityBadge label={key.productSlug} tone="info" /> : null}
<EntityBadge label={`${key.rateLimitPerSecond}/s`} tone="info" />
<EntityBadge label={`${key.rateLimitPerMinute}/min`} />
<EntityBadge label={`${key.requestsUsed}/${key.monthlyQuota || 0}`} />
{key.approved ? <EntityBadge label="approved" tone="success" /> : <EntityBadge label="pending" tone="warning" />}
{key.revoked ? <EntityBadge label="revoked" tone="warning" /> : <EntityBadge label="active" tone="success" />}
{key.expiresAt ? <EntityBadge label={`expires ${new Date(key.expiresAt).toLocaleDateString()}`} /> : <EntityBadge label="no expiry" />}
</div>
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
Created {new Date(key.createdAt).toLocaleString()}
{key.lastUsedAt ? ` · Last used ${new Date(key.lastUsedAt).toLocaleString()}` : ' · Not used yet'}
</div>
</div>
{!key.revoked ? (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void handleRotate(key)}
className="rounded-lg border border-primary-300 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-50 dark:border-primary-800 dark:text-primary-300 dark:hover:bg-primary-950/20"
>
Rotate
</button>
<button
type="button"
onClick={() => void handleRevoke(key.id)}
className="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950/20"
>
Revoke
</button>
</div>
) : null}
</div>
{key.scopes.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{key.scopes.map((scope) => (
<EntityBadge key={`${key.id}-${scope}`} label={scope} className="normal-case tracking-normal" />
))}
</div>
) : null}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">No API keys issued yet.</p>
)
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">
Sign in to issue and manage RPC access keys for Core, Thirdweb, and Alltra products.
</p>
)}
<div className="mt-6 border-t border-gray-200 pt-4 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400">
Billing, quotas, and paywalls can be layered onto this access plane next. The current slice establishes identity, product discovery, and key lifecycle management.
</div>
{user && usage.length > 0 ? (
<div className="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<div className="mb-3 text-sm font-semibold text-gray-900 dark:text-white">Usage Summary</div>
<div className="space-y-3">
{usage.map((item) => (
<div key={item.product_slug} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={item.product_slug} tone="info" />
<EntityBadge label={`${item.active_keys} active keys`} />
</div>
<div className="mt-2 text-gray-600 dark:text-gray-400">
{item.requests_used.toLocaleString()} requests used / {item.monthly_quota.toLocaleString()} monthly quota
</div>
</div>
))}
</div>
</div>
) : null}
{user ? (
<div className="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<div className="mb-3 flex flex-wrap items-end justify-between gap-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Recent API Activity</div>
<label className="block min-w-[10rem]">
<span className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">Entries shown</span>
<select
value={auditLimit}
onChange={(event) => setAuditLimit(event.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
</label>
</div>
{auditEntries.length > 0 ? (
<div className="space-y-3">
{auditEntries.map((entry) => (
<div key={`audit-${entry.id}`} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={entry.productSlug || 'unscoped'} tone="info" />
<EntityBadge label={entry.methodName || 'unknown method'} />
<EntityBadge label={`${entry.requestCount} req`} />
</div>
<div className="mt-2 font-medium text-gray-900 dark:text-white">{entry.keyName || entry.apiKeyId}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{new Date(entry.createdAt).toLocaleString()}
{entry.lastIp ? ` · ${entry.lastIp}` : ''}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">No API usage has been logged yet for this account.</p>
)}
</div>
) : null}
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/wallet" className="text-primary-600 hover:underline">Wallet </Link>
<Link href="/system" className="text-primary-600 hover:underline">System </Link>
</div>
</Card>
</div>
</main>
)
}

View File

@@ -0,0 +1,52 @@
import clsx from 'clsx'
function toneClasses(tone: 'neutral' | 'success' | 'warning' | 'info') {
switch (tone) {
case 'success':
return 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200'
case 'warning':
return 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200'
case 'info':
return 'border-sky-200 bg-sky-50 text-sky-800 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-200'
default:
return 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300'
}
}
export function getEntityBadgeTone(tag: string): 'neutral' | 'success' | 'warning' | 'info' {
const normalized = tag.toLowerCase()
if (normalized === 'compliant' || normalized === 'listed' || normalized === 'verified') {
return 'success'
}
if (normalized === 'wrapped') {
return 'warning'
}
if (normalized === 'bridge' || normalized === 'canonical' || normalized === 'official') {
return 'info'
}
return 'neutral'
}
export default function EntityBadge({
label,
tone,
className,
}: {
label: string
tone?: 'neutral' | 'success' | 'warning' | 'info'
className?: string
}) {
const resolvedTone = tone || getEntityBadgeTone(label)
return (
<span
className={clsx(
'rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-wide',
toneClasses(resolvedTone),
className,
)}
>
{label}
</span>
)
}

View File

@@ -0,0 +1,178 @@
'use client'
import { FormEvent, useMemo, useState } from 'react'
import { usePathname } from 'next/navigation'
import { getExplorerApiBase } from '@/services/api/blockscout'
interface AgentMessage {
role: 'assistant' | 'user'
content: string
}
const QUICK_PROMPTS = [
'Explain this page',
'Summarize the chain status',
'Help me inspect a contract',
'Find likely navigation issues',
] as const
export default function ExplorerAgentTool() {
const pathname = usePathname() ?? '/'
const [open, setOpen] = useState(false)
const [input, setInput] = useState('')
const [submitting, setSubmitting] = useState(false)
const [messages, setMessages] = useState<AgentMessage[]>([
{
role: 'assistant',
content:
'Explorer AI Agent Tool is ready. I can explain this page, summarize what you are looking at, and help investigate transactions, contracts, routes, and system surfaces.',
},
])
const pageContext = useMemo(
() => ({
path: pathname,
view: 'explorer',
}),
[pathname],
)
const sendMessage = async (content: string) => {
const trimmed = content.trim()
if (!trimmed || submitting) return
const nextMessages: AgentMessage[] = [...messages, { role: 'user', content: trimmed }]
setMessages(nextMessages)
setInput('')
setSubmitting(true)
try {
const response = await fetch(`${getExplorerApiBase()}/api/v1/ai/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: nextMessages,
pageContext,
}),
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(payload?.error?.message || `HTTP ${response.status}`)
}
const reply =
payload?.message?.content ||
payload?.reply ||
'The agent did not return a readable reply.'
setMessages((current) => [...current, { role: 'assistant', content: String(reply) }])
} catch (error) {
setMessages((current) => [
...current,
{
role: 'assistant',
content:
error instanceof Error
? `Agent tool is temporarily unavailable: ${error.message}`
: 'Agent tool is temporarily unavailable.',
},
])
} finally {
setSubmitting(false)
}
}
const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
await sendMessage(input)
}
return (
<div className="fixed bottom-5 right-5 z-40 flex max-w-[calc(100vw-1.5rem)] flex-col items-end gap-3">
{open ? (
<section className="w-[min(24rem,calc(100vw-1.5rem))] overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-start justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Explorer AI Agent Tool</h2>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Page-aware guidance for the explorer. Helpful, read-only, and designed for quick investigation.
</p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
Close
</button>
</div>
<div className="flex flex-wrap gap-2 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
{QUICK_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => void sendMessage(prompt)}
className="rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 dark:border-primary-500/30 dark:bg-primary-500/10 dark:text-primary-300 dark:hover:bg-primary-500/20"
>
{prompt}
</button>
))}
</div>
<div className="max-h-[22rem] space-y-3 overflow-y-auto px-4 py-3">
{messages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={`rounded-2xl px-3 py-2 text-sm ${
message.role === 'assistant'
? 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100'
: 'ml-6 bg-primary-600 text-white'
}`}
>
{message.content}
</div>
))}
</div>
<form onSubmit={handleSubmit} className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
<label className="block">
<span className="sr-only">Ask the explorer agent</span>
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
rows={3}
placeholder="Ask about this page, a transaction, a token, or an access-control flow."
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-gray-700 dark:bg-gray-950 dark:text-white"
/>
</label>
<div className="mt-3 flex items-center justify-between gap-3">
<p className="text-xs text-gray-500 dark:text-gray-400">Current view: {pathname}</p>
<button
type="submit"
disabled={submitting || !input.trim()}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? 'Thinking…' : 'Send'}
</button>
</div>
</form>
</section>
) : null}
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="inline-flex items-center gap-2 rounded-full bg-primary-600 px-4 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-primary-700"
aria-expanded={open}
>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-white/15">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-4l-4 4v-4Z" />
</svg>
</span>
Agent Tool
</button>
</div>
)
}

View File

@@ -1,12 +1,22 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import Navbar from './Navbar' import Navbar from './Navbar'
import Footer from './Footer' import Footer from './Footer'
import ExplorerAgentTool from './ExplorerAgentTool'
export default function ExplorerChrome({ children }: { children: ReactNode }) { export default function ExplorerChrome({ children }: { children: ReactNode }) {
return ( return (
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100"> <div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-primary-600 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white"
>
Skip to content
</a>
<Navbar /> <Navbar />
<div className="flex-1">{children}</div> <div id="main-content" className="flex-1">
{children}
</div>
<ExplorerAgentTool />
<Footer /> <Footer />
</div> </div>
) )

View File

@@ -12,15 +12,18 @@ export default function Footer() {
<div className="grid gap-4 sm:gap-6 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)]"> <div className="grid gap-4 sm:gap-6 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0"> <div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="text-base font-semibold text-gray-900 dark:text-white sm:text-lg"> <div className="text-base font-semibold text-gray-900 dark:text-white sm:text-lg">
SolaceScanScout SolaceScan
</div> </div>
<p className="max-w-xl text-sm leading-6 text-gray-600 dark:text-gray-400"> <p className="max-w-xl text-sm leading-6 text-gray-600 dark:text-gray-400">
Built from Blockscout foundations and Solace Bank Group PLC frontend Built on Blockscout for the DBIS / Defi Oracle Chain 138 explorer surface.
work. Explorer data is powered by Blockscout, Chain 138 RPC, and the Explorer data is powered by Blockscout, Chain 138 RPC, and the companion MetaMask Snap.
companion MetaMask Snap. </p>
<p className="max-w-xl text-xs leading-5 text-gray-500 dark:text-gray-500">
Public explorer access may appear under <code>blockscout.defi-oracle.io</code> or <code>explorer.d-bis.org</code>.
Both domains belong to the same DBIS / Defi Oracle explorer surface.
</p> </p>
<p className="text-xs text-gray-500 dark:text-gray-500"> <p className="text-xs text-gray-500 dark:text-gray-500">
© {year} Solace Bank Group PLC. All rights reserved. © {year} DBIS / Defi Oracle. All rights reserved.
</p> </p>
</div> </div>
@@ -29,11 +32,12 @@ export default function Footer() {
Resources Resources
</div> </div>
<ul className="space-y-2 text-sm"> <ul className="space-y-2 text-sm">
<li><a className={footerLinkClass} href="/docs.html">Documentation</a></li> <li><Link className={footerLinkClass} href="/search">Search</Link></li>
<li><Link className={footerLinkClass} href="/docs">Documentation</Link></li>
<li><Link className={footerLinkClass} href="/bridge">Bridge Monitoring</Link></li> <li><Link className={footerLinkClass} href="/bridge">Bridge Monitoring</Link></li>
<li><Link className={footerLinkClass} href="/liquidity">Liquidity Access</Link></li> <li><Link className={footerLinkClass} href="/liquidity">Liquidity Access</Link></li>
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li> <li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
<li><Link className={footerLinkClass} href="/more">More Tools</Link></li> <li><Link className={footerLinkClass} href="/operations">Operations Hub</Link></li>
<li><Link className={footerLinkClass} href="/addresses">Addresses</Link></li> <li><Link className={footerLinkClass} href="/addresses">Addresses</Link></li>
<li><Link className={footerLinkClass} href="/watchlist">Watchlist</Link></li> <li><Link className={footerLinkClass} href="/watchlist">Watchlist</Link></li>
<li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li> <li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li>
@@ -55,8 +59,8 @@ export default function Footer() {
</p> </p>
<p> <p>
Snap site:{' '} Snap site:{' '}
<a className={footerLinkClass} href="https://explorer.d-bis.org/snap/" target="_blank" rel="noopener noreferrer"> <a className={footerLinkClass} href="/snap/" target="_blank" rel="noopener noreferrer">
explorer.d-bis.org/snap/ /snap/ on the current explorer domain
</a> </a>
</p> </p>
<p> <p>

View File

@@ -0,0 +1,214 @@
import { Card } from '@/libs/frontend-ui-primitives'
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import type { GruStandardsProfile } from '@/services/api/gru'
import Link from 'next/link'
const STANDARD_EXPLANATIONS: Record<string, string> = {
'ERC-20': 'Base fungible-token surface for wallets, DEXs, explorers, and accounting systems.',
AccessControl: 'Role-governed administration for mint, burn, pause, and supervised operations.',
Pausable: 'Emergency intervention surface for freezing activity during incidents or policy actions.',
'EIP-712': 'Typed signing domain for structured off-chain approvals and payment flows.',
'ERC-2612': 'Permit support for signature-based approvals without a separate on-chain approve transaction.',
'ERC-3009': 'Authorization-based transfer model for signed payment flows without prior allowances.',
'ERC-5267': 'Discoverable EIP-712 domain introspection so wallets and relayers can inspect the signing domain cleanly.',
IeMoneyToken: 'Repo-native eMoney token methodology for issuance and redemption semantics.',
DeterministicStorageNamespace: 'Stable namespace for upgrade-aware policy, registry, and audit resolution.',
JurisdictionAndSupervisionMetadata: 'Governance, supervisory, disclosure, and reporting metadata required by the GRU operating model.',
}
function formatDuration(seconds: number | null): string | null {
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return null
const units = [
{ label: 'day', value: 86400 },
{ label: 'hour', value: 3600 },
{ label: 'minute', value: 60 },
]
const parts: string[] = []
let remaining = Math.floor(seconds)
for (const unit of units) {
if (remaining >= unit.value) {
const count = Math.floor(remaining / unit.value)
remaining -= count * unit.value
parts.push(`${count} ${unit.label}${count === 1 ? '' : 's'}`)
}
if (parts.length === 2) break
}
if (parts.length === 0) {
return `${remaining} second${remaining === 1 ? '' : 's'}`
}
return parts.join(' ')
}
export default function GruStandardsCard({
profile,
title = 'GRU v2 Standards',
}: {
profile: GruStandardsProfile
title?: string
}) {
const detectedCount = profile.standards.filter((standard) => standard.detected).length
const requiredCount = profile.standards.filter((standard) => standard.required).length
const missingRequired = profile.standards.filter((standard) => standard.required && !standard.detected)
const noticePeriod = formatDuration(profile.minimumUpgradeNoticePeriodSeconds)
const recommendations = [
missingRequired.length > 0
? `Review the live contract ABI and deployment against the GRU v2 base-token matrix before treating this asset as fully canonical.`
: `The live contract exposes the full required GRU v2 base-token surface currently checked by the explorer.`,
profile.wrappedTransport
? 'This looks like a wrapped transport asset, so confirm the corresponding bridge lane and reserve-verifier posture in addition to the token ABI.'
: 'This looks like a canonical GRU asset, so the next meaningful checks are reserve, governance, and transport activation beyond the token interface itself.',
profile.x402Ready
? 'This contract appears ready for x402-style payment flows because the explorer can see the required signature and domain surfaces.'
: 'This contract does not currently look x402-ready from the live explorer surface; verify EIP-712, ERC-5267, and permit or authorization flow exposure before using it as a payment rail.',
profile.forwardCanonical === true
? 'This version is marked forward-canonical, so it should be treated as the preferred successor surface even if older liquidity or transport versions still coexist.'
: profile.forwardCanonical === false
? 'This version is not forward-canonical, which usually means it is legacy, staged, or transport-only relative to the intended primary canonical surface.'
: 'Forward-canonical posture is not directly detectable on this contract, so rely on the transport overlay and deployment records before making promotion assumptions.',
profile.legacyAliasSupport
? 'Legacy alias support is exposed, which is useful during version cutovers and explorer/search reconciliation.'
: 'Legacy alias support is not visible from the current explorer contract surface, so name/version migration may need registry or deployment-record cross-checks.',
'Use the repo standards references to reconcile any missing surface with the intended GRU profile and rollout phase.',
]
return (
<Card title={title}>
<dl className="space-y-4">
<DetailRow label="Profile">
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<EntityBadge label={profile.profileId} tone="info" className="normal-case tracking-normal" />
<EntityBadge
label={profile.wrappedTransport ? 'wrapped transport' : 'canonical GRU'}
tone={profile.wrappedTransport ? 'warning' : 'success'}
/>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{detectedCount} of {requiredCount} required base-token standards are currently detectable from the live contract surface.
</div>
</div>
</DetailRow>
<DetailRow label="Standards" valueClassName="flex flex-wrap gap-2">
{profile.standards.map((standard) => (
<EntityBadge
key={standard.id}
label={standard.detected ? `${standard.id} detected` : `${standard.id} missing`}
tone={standard.detected ? 'success' : 'warning'}
className="normal-case tracking-normal"
/>
))}
</DetailRow>
<DetailRow label="Transport Posture">
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<EntityBadge
label={profile.x402Ready ? 'x402 ready' : 'x402 not ready'}
tone={profile.x402Ready ? 'success' : 'warning'}
/>
<EntityBadge
label={
profile.forwardCanonical === true
? 'forward canonical'
: profile.forwardCanonical === false
? 'not forward canonical'
: 'forward canonical unknown'
}
tone={
profile.forwardCanonical === true
? 'success'
: profile.forwardCanonical === false
? 'warning'
: 'info'
}
/>
<EntityBadge
label={profile.legacyAliasSupport ? 'legacy aliases exposed' : 'no alias surface'}
tone={profile.legacyAliasSupport ? 'info' : 'warning'}
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Settlement posture</div>
<div className="mt-2 text-gray-900 dark:text-white">
{profile.wrappedTransport
? 'This contract presents itself like a wrapped public-transport asset instead of the canonical Chain 138 money surface.'
: 'This contract presents itself like the canonical Chain 138 GRU money surface instead of a wrapped transport mirror.'}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Upgrade notice</div>
<div className="mt-2 text-gray-900 dark:text-white">
{noticePeriod
? `${noticePeriod} (${profile.minimumUpgradeNoticePeriodSeconds} seconds)`
: 'No readable minimum upgrade notice period was detected from the current explorer surface.'}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Version posture</div>
<div className="mt-2 text-gray-900 dark:text-white">
{profile.activeVersion || profile.forwardVersion
? `Active liquidity/transport version: ${profile.activeVersion || 'unknown'}; preferred forward version: ${profile.forwardVersion || 'unknown'}.`
: 'No explicit active-versus-forward version posture is available from the local GRU catalog yet.'}
</div>
</div>
</div>
</div>
</DetailRow>
<DetailRow label="Interpretation">
<div className="space-y-3">
{profile.standards.map((standard) => (
<div key={`${standard.id}-explanation`} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium text-gray-900 dark:text-white">{standard.id}</div>
<EntityBadge label={standard.detected ? 'detected' : 'missing'} tone={standard.detected ? 'success' : 'warning'} />
</div>
<div className="mt-2 text-gray-600 dark:text-gray-400">
{STANDARD_EXPLANATIONS[standard.id] || 'GRU-specific standard surfaced by the repo standards profile.'}
</div>
</div>
))}
</div>
</DetailRow>
{profile.metadata.length > 0 ? (
<DetailRow label="Metadata">
<div className="space-y-3">
{profile.metadata.map((field) => (
<div key={field.label} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{field.label}</div>
<div className="mt-2 break-all text-gray-900 dark:text-white">{field.value}</div>
</div>
))}
</div>
</DetailRow>
) : null}
<DetailRow label="References">
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div><Link href="/docs/gru" className="text-primary-600 hover:underline">Explorer GRU guide</Link></div>
<div>Canonical profile: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">{profile.profileId}</code></div>
<div>Repo standards matrix: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">docs/04-configuration/GRU_C_STAR_V2_STANDARDS_MATRIX_AND_IMPLEMENTATION_PLAN.md</code></div>
<div>Machine-readable profile: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">config/gru-standards-profile.json</code></div>
<div>Transport overlay: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">config/gru-transport-active.json</code></div>
<div>x402 support note: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">docs/04-configuration/CHAIN138_X402_TOKEN_SUPPORT.md</code></div>
<div>Chain 138 readiness guide: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">docs/04-configuration/GRU_V2_CHAIN138_READINESS.md</code></div>
</div>
</DetailRow>
<DetailRow label="Recommendations">
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
{recommendations.map((item) => (
<div key={item} className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
{item}
</div>
))}
</div>
</DetailRow>
</dl>
</Card>
)
}

View File

@@ -1,44 +1,96 @@
'use client' 'use client'
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { useState } from 'react' import { useEffect, useId, useRef, useState } from 'react'
import { accessApi, type WalletAccessSession } from '@/services/api/access'
const navLink = 'text-gray-700 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors' const navItemBase =
const navLinkActive = 'text-primary-600 dark:text-primary-400 font-medium' 'rounded-xl px-3 py-2 text-[15px] font-medium transition-all duration-150'
const navLink =
`${navItemBase} text-gray-700 dark:text-gray-300 hover:bg-primary-50 hover:text-primary-700 dark:hover:bg-gray-700/70 dark:hover:text-primary-300`
const navLinkActive =
`${navItemBase} bg-primary-50 text-primary-700 shadow-sm ring-1 ring-primary-100 dark:bg-primary-500/10 dark:text-primary-300 dark:ring-primary-500/20`
function NavDropdown({ function NavDropdown({
label, label,
icon, icon,
active,
children, children,
}: { }: {
label: string label: string
icon: React.ReactNode icon: React.ReactNode
active?: boolean
children: React.ReactNode children: React.ReactNode
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const wrapperRef = useRef<HTMLDivElement | null>(null)
const menuId = useId()
useEffect(() => {
if (!open) return
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null
if (!target || !wrapperRef.current?.contains(target)) {
setOpen(false)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('touchstart', handlePointerDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('touchstart', handlePointerDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open])
return ( return (
<div <div
ref={wrapperRef}
className="relative" className="relative"
onMouseEnter={() => setOpen(true)} onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)} onMouseLeave={() => setOpen(false)}
onBlurCapture={(event) => {
const nextTarget = event.relatedTarget as Node | null
if (nextTarget && wrapperRef.current?.contains(nextTarget)) {
return
}
setOpen(false)
}}
> >
<button <button
type="button" type="button"
className={`flex items-center gap-1.5 px-3 py-2 rounded-md ${navLink}`} className={`flex items-center gap-1.5 ${active && !open ? navLinkActive : navLink}`}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((value) => !value)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setOpen(true)
}
}}
aria-expanded={open} aria-expanded={open}
aria-haspopup="true" aria-haspopup="true"
aria-controls={menuId}
> >
{icon} {icon}
<span>{label}</span> <span>{label}</span>
<svg className={`w-3.5 h-3.5 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden> <svg className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</button> </button>
{open && ( {open && (
<ul <ul
className="absolute left-0 top-full mt-1 min-w-[200px] rounded-lg bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50" id={menuId}
className="absolute left-0 top-full z-50 mt-1 min-w-[220px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
role="menu" role="menu"
> >
{children} {children}
@@ -59,7 +111,8 @@ function DropdownItem({
children: React.ReactNode children: React.ReactNode
external?: boolean external?: boolean
}) { }) {
const className = `flex items-center gap-2 px-4 py-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 ${navLink}` const className =
'flex items-center gap-2 px-4 py-2.5 text-gray-700 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-primary-400'
if (external) { if (external) {
return ( return (
<li role="none"> <li role="none">
@@ -81,30 +134,91 @@ function DropdownItem({
} }
export default function Navbar() { export default function Navbar() {
const router = useRouter()
const pathname = usePathname() ?? '' const pathname = usePathname() ?? ''
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [exploreOpen, setExploreOpen] = useState(false) const [exploreOpen, setExploreOpen] = useState(false)
const [toolsOpen, setToolsOpen] = useState(false) const [dataOpen, setDataOpen] = useState(false)
const [operationsOpen, setOperationsOpen] = useState(false)
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
const [connectingWallet, setConnectingWallet] = useState(false)
const isExploreActive =
pathname === '/' ||
pathname.startsWith('/blocks') ||
pathname.startsWith('/transactions') ||
pathname.startsWith('/addresses')
const isDataActive =
pathname.startsWith('/tokens') ||
pathname.startsWith('/pools') ||
pathname.startsWith('/analytics') ||
pathname.startsWith('/watchlist')
const isOperationsActive =
pathname.startsWith('/bridge') ||
pathname.startsWith('/routes') ||
pathname.startsWith('/liquidity') ||
pathname.startsWith('/operations') ||
pathname.startsWith('/operator') ||
pathname.startsWith('/system') ||
pathname.startsWith('/weth')
const isDocsActive = pathname.startsWith('/docs')
const isAccessActive = pathname.startsWith('/access')
useEffect(() => {
const syncWalletSession = () => {
setWalletSession(accessApi.getStoredWalletSession())
}
syncWalletSession()
window.addEventListener('storage', syncWalletSession)
window.addEventListener('explorer-access-session-changed', syncWalletSession)
return () => {
window.removeEventListener('storage', syncWalletSession)
window.removeEventListener('explorer-access-session-changed', syncWalletSession)
}
}, [])
const handleAccessClick = async () => {
if (walletSession) {
router.push('/access')
setMobileMenuOpen(false)
return
}
try {
setConnectingWallet(true)
await accessApi.connectWalletSession()
router.push('/access')
setMobileMenuOpen(false)
} catch (error) {
console.error('Wallet connect failed', error)
router.push('/access')
setMobileMenuOpen(false)
} finally {
setConnectingWallet(false)
}
}
const toggleMobileMenu = () => { const toggleMobileMenu = () => {
setMobileMenuOpen((open) => { setMobileMenuOpen((open) => {
const nextOpen = !open const nextOpen = !open
if (!nextOpen) { if (!nextOpen) {
setExploreOpen(false) setExploreOpen(false)
setToolsOpen(false) setDataOpen(false)
setOperationsOpen(false)
} }
return nextOpen return nextOpen
}) })
} }
return ( return (
<nav className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700"> <nav className="border-b border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="flex h-14 items-center justify-between sm:h-16"> <div className="flex h-14 items-center justify-between sm:h-16">
<div className="flex min-w-0 items-center gap-3 md:gap-8"> <div className="flex min-w-0 items-center gap-3 md:gap-6">
<Link <Link
href="/" href="/"
className="group inline-flex max-w-[calc(100vw-5rem)] flex-col rounded-xl px-2 py-1.5 text-base font-bold text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-gray-700/70 sm:max-w-none sm:px-3 sm:py-2 sm:text-xl" className="group inline-flex max-w-[calc(100vw-5rem)] flex-col rounded-xl px-2 py-1.5 text-base font-bold text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-gray-700/70 sm:max-w-none sm:px-2.5 sm:py-1.5 sm:text-[1.05rem]"
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
aria-label="Go to explorer home" aria-label="Go to explorer home"
> >
@@ -116,58 +230,87 @@ export default function Navbar() {
</span> </span>
<span className="min-w-0 truncate"> <span className="min-w-0 truncate">
<span className="sm:hidden">SolaceScan</span> <span className="sm:hidden">SolaceScan</span>
<span className="hidden sm:inline">SolaceScanScout</span> <span className="hidden sm:inline">SolaceScan</span>
</span> </span>
</span> </span>
<span className="mt-0.5 hidden text-xs font-normal text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200 sm:block"> <span className="mt-0.5 hidden text-[0.78rem] font-normal text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200 sm:block">
The Defi Oracle Meta Explorer Chain 138 Explorer by DBIS
</span> </span>
</Link> </Link>
<div className="hidden md:flex items-center gap-1"> <div className="hidden items-center gap-1.5 md:flex">
<NavDropdown <NavDropdown
label="Explore" label="Explore"
icon={<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0h.5a2.5 2.5 0 002.5-2.5V3.935M12 12a2 2 0 104 0 2 2 0 00-4 0z" /></svg>} active={isExploreActive}
icon={<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0h.5a2.5 2.5 0 002.5-2.5V3.935M12 12a2 2 0 104 0 2 2 0 00-4 0z" /></svg>}
> >
<DropdownItem href="/" icon={<span className="text-gray-400"></span>}>Home</DropdownItem> <DropdownItem href="/" icon={<span className="text-gray-400"></span>}>Home</DropdownItem>
<DropdownItem href="/blocks" icon={<span className="text-gray-400"></span>}>Blocks</DropdownItem> <DropdownItem href="/blocks" icon={<span className="text-gray-400"></span>}>Blocks</DropdownItem>
<DropdownItem href="/transactions" icon={<span className="text-gray-400"></span>}>Transactions</DropdownItem> <DropdownItem href="/transactions" icon={<span className="text-gray-400"></span>}>Transactions</DropdownItem>
<DropdownItem href="/addresses" icon={<span className="text-gray-400"></span>}>Addresses</DropdownItem> <DropdownItem href="/addresses" icon={<span className="text-gray-400"></span>}>Addresses</DropdownItem>
</NavDropdown> </NavDropdown>
<Link
href="/search"
className={`hidden md:inline-flex items-center ${pathname.startsWith('/search') ? navLinkActive : navLink}`}
>
Search
</Link>
<NavDropdown
label="Data"
active={isDataActive}
icon={<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7h16M4 12h16M4 17h10" /></svg>}
>
<DropdownItem href="/tokens">Tokens</DropdownItem>
<DropdownItem href="/analytics">Analytics</DropdownItem>
<DropdownItem href="/pools">Pools</DropdownItem>
<DropdownItem href="/watchlist">Watchlist</DropdownItem>
</NavDropdown>
<Link
href="/docs"
className={`hidden md:inline-flex items-center ${isDocsActive ? navLinkActive : navLink}`}
>
Docs
</Link>
<NavDropdown
label="Operations"
active={isOperationsActive}
icon={<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>}
>
<DropdownItem href="/operations">Operations Hub</DropdownItem>
<DropdownItem href="/bridge">Bridge</DropdownItem>
<DropdownItem href="/routes">Routes</DropdownItem>
<DropdownItem href="/liquidity">Liquidity</DropdownItem>
<DropdownItem href="/system">System</DropdownItem>
<DropdownItem href="/operator">Operator</DropdownItem>
<DropdownItem href="/weth">WETH</DropdownItem>
<DropdownItem href="/chain138-command-center.html" external>Command Center</DropdownItem>
</NavDropdown>
<Link <Link
href="/wallet" href="/wallet"
className={`hidden md:inline-flex items-center px-3 py-2 rounded-md ${pathname.startsWith('/wallet') ? navLinkActive : navLink}`} className={`hidden md:inline-flex items-center ${pathname.startsWith('/wallet') ? navLinkActive : navLink}`}
> >
Wallet Wallet
</Link> </Link>
<NavDropdown <button
label="Tools" type="button"
icon={<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>} onClick={() => void handleAccessClick()}
className={`hidden md:inline-flex items-center ${isAccessActive ? navLinkActive : navLink}`}
> >
<DropdownItem href="/search">Search</DropdownItem> {connectingWallet ? 'Connecting…' : walletSession ? 'Access' : 'Connect Wallet'}
<DropdownItem href="/tokens">Tokens</DropdownItem> </button>
<DropdownItem href="/pools">Pools</DropdownItem>
<DropdownItem href="/watchlist">Watchlist</DropdownItem>
<DropdownItem href="/wallet">Wallet</DropdownItem>
<DropdownItem href="/liquidity">Liquidity</DropdownItem>
<DropdownItem href="/bridge">Bridge</DropdownItem>
<DropdownItem href="/routes">Routes</DropdownItem>
<DropdownItem href="/more">More</DropdownItem>
<DropdownItem href="/chain138-command-center.html" external>Command Center</DropdownItem>
</NavDropdown>
</div> </div>
</div> </div>
<div className="flex items-center md:hidden"> <div className="flex items-center md:hidden">
<button <button
type="button" type="button"
className="p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700" className="rounded-md p-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
onClick={toggleMobileMenu} onClick={toggleMobileMenu}
aria-expanded={mobileMenuOpen} aria-expanded={mobileMenuOpen}
aria-label="Toggle menu" aria-label="Toggle menu"
> >
{mobileMenuOpen ? ( {mobileMenuOpen ? (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg> <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
) : ( ) : (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg> <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>
)} )}
</button> </button>
</div> </div>
@@ -175,40 +318,62 @@ export default function Navbar() {
{mobileMenuOpen && ( {mobileMenuOpen && (
<div className="border-t border-gray-200 py-2 pb-3 dark:border-gray-700 md:hidden"> <div className="border-t border-gray-200 py-2 pb-3 dark:border-gray-700 md:hidden">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Link href="/" className={`px-3 py-2.5 rounded-md ${pathname === '/' ? navLinkActive : navLink}`} onClick={() => setMobileMenuOpen(false)}>Home</Link> <Link href="/" className={pathname === '/' ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Home</Link>
<Link href="/search" className={pathname.startsWith('/search') ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Search</Link>
<div className="relative"> <div className="relative">
<button type="button" className={`flex items-center justify-between w-full px-3 py-2.5 rounded-md ${navLink}`} onClick={() => setExploreOpen((o) => !o)} aria-expanded={exploreOpen}> <button type="button" className={`flex w-full items-center justify-between ${navLink}`} onClick={() => setExploreOpen((value) => !value)} aria-expanded={exploreOpen}>
<span>Explore</span> <span>Explore</span>
<svg className={`w-4 h-4 transition-transform ${exploreOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg> <svg className={`h-4 w-4 transition-transform ${exploreOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button> </button>
{exploreOpen && ( {exploreOpen && (
<ul className="pl-4 mt-1 space-y-0.5"> <ul className="mt-1 space-y-0.5 pl-4">
<li><Link href="/blocks" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Blocks</Link></li> <li><Link href="/blocks" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Blocks</Link></li>
<li><Link href="/transactions" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Transactions</Link></li> <li><Link href="/transactions" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Transactions</Link></li>
<li><Link href="/addresses" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Addresses</Link></li> <li><Link href="/addresses" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Addresses</Link></li>
</ul> </ul>
)} )}
</div> </div>
<div className="relative"> <div className="relative">
<button type="button" className={`flex items-center justify-between w-full px-3 py-2.5 rounded-md ${navLink}`} onClick={() => setToolsOpen((o) => !o)} aria-expanded={toolsOpen}> <button type="button" className={`flex w-full items-center justify-between ${navLink}`} onClick={() => setDataOpen((value) => !value)} aria-expanded={dataOpen}>
<span>Tools</span> <span>Data</span>
<svg className={`w-4 h-4 transition-transform ${toolsOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg> <svg className={`h-4 w-4 transition-transform ${dataOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button> </button>
{toolsOpen && ( {dataOpen && (
<ul className="pl-4 mt-1 space-y-0.5"> <ul className="mt-1 space-y-0.5 pl-4">
<li><Link href="/search" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Search</Link></li> <li><Link href="/tokens" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Tokens</Link></li>
<li><Link href="/tokens" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Tokens</Link></li> <li><Link href="/analytics" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Analytics</Link></li>
<li><Link href="/pools" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Pools</Link></li> <li><Link href="/pools" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Pools</Link></li>
<li><Link href="/watchlist" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Watchlist</Link></li> <li><Link href="/watchlist" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Watchlist</Link></li>
<li><Link href="/wallet" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Wallet</Link></li>
<li><Link href="/liquidity" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Liquidity</Link></li>
<li><Link href="/bridge" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Bridge</Link></li>
<li><Link href="/routes" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Routes</Link></li>
<li><Link href="/more" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>More</Link></li>
<li><a href="/chain138-command-center.html" className={`block px-3 py-2 rounded-md ${navLink}`} target="_blank" rel="noopener noreferrer" onClick={() => setMobileMenuOpen(false)}>Command Center</a></li>
</ul> </ul>
)} )}
</div> </div>
<Link href="/docs" className={isDocsActive ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Docs</Link>
<div className="relative">
<button type="button" className={`flex w-full items-center justify-between ${navLink}`} onClick={() => setOperationsOpen((value) => !value)} aria-expanded={operationsOpen}>
<span>Operations</span>
<svg className={`h-4 w-4 transition-transform ${operationsOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
{operationsOpen && (
<ul className="mt-1 space-y-0.5 pl-4">
<li><Link href="/operations" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Operations Hub</Link></li>
<li><Link href="/bridge" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Bridge</Link></li>
<li><Link href="/routes" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Routes</Link></li>
<li><Link href="/liquidity" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Liquidity</Link></li>
<li><Link href="/system" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>System</Link></li>
<li><Link href="/operator" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Operator</Link></li>
<li><Link href="/weth" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>WETH</Link></li>
<li><a href="/chain138-command-center.html" className={`block rounded-md px-3 py-2 ${navLink}`} target="_blank" rel="noopener noreferrer" onClick={() => setMobileMenuOpen(false)}>Command Center</a></li>
</ul>
)}
</div>
<Link href="/wallet" className={pathname.startsWith('/wallet') ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Wallet</Link>
<button
type="button"
className={`w-full text-left ${isAccessActive ? navLinkActive : navLink}`}
onClick={() => void handleAccessClick()}
>
{connectingWallet ? 'Connecting wallet…' : walletSession ? 'Access' : 'Connect wallet'}
</button>
</div> </div>
</div> </div>
)} )}

View File

@@ -0,0 +1,45 @@
import Link from 'next/link'
export interface PageIntroAction {
href: string
label: string
}
export default function PageIntro({
eyebrow,
title,
description,
actions = [],
}: {
eyebrow?: string
title: string
description: string
actions?: PageIntroAction[]
}) {
return (
<div className="mb-6 rounded-3xl border border-gray-200 bg-white/90 p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800/80 sm:mb-8 sm:p-6">
{eyebrow ? (
<div className="mb-3 inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-300">
{eyebrow}
</div>
) : null}
<h1 className="text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">{title}</h1>
<p className="mt-3 max-w-4xl text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base">
{description}
</p>
{actions.length > 0 ? (
<div className="mt-5 flex flex-wrap gap-3">
{actions.map((action) => (
<Link
key={`${action.href}-${action.label}`}
href={action.href}
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-200 dark:hover:text-primary-300"
>
{action.label}
</Link>
))}
</div>
) : null}
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives' import { Card } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { explorerFeaturePages } from '@/data/explorerOperations' import { explorerFeaturePages } from '@/data/explorerOperations'
import { blocksApi, type Block } from '@/services/api/blocks' import { blocksApi, type Block } from '@/services/api/blocks'
import { import {
@@ -7,8 +8,14 @@ import {
type MissionControlBridgeStatusResponse, type MissionControlBridgeStatusResponse,
type MissionControlChainStatus, type MissionControlChainStatus,
} from '@/services/api/missionControl' } from '@/services/api/missionControl'
import { statsApi, type ExplorerStats } from '@/services/api/stats' import {
statsApi,
type ExplorerRecentActivitySnapshot,
type ExplorerStats,
type ExplorerTransactionTrendPoint,
} from '@/services/api/stats'
import { transactionsApi, type Transaction } from '@/services/api/transactions' import { transactionsApi, type Transaction } from '@/services/api/transactions'
import { formatWeiAsEth } from '@/utils/format'
import OperationsPageShell, { import OperationsPageShell, {
MetricCard, MetricCard,
StatusBadge, StatusBadge,
@@ -17,6 +24,15 @@ import OperationsPageShell, {
truncateMiddle, truncateMiddle,
} from './OperationsPageShell' } from './OperationsPageShell'
interface AnalyticsOperationsPageProps {
initialStats?: ExplorerStats | null
initialTransactionTrend?: ExplorerTransactionTrendPoint[]
initialActivitySnapshot?: ExplorerRecentActivitySnapshot | null
initialBlocks?: Block[]
initialTransactions?: Transaction[]
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
}
function getChainStatus(bridgeStatus: MissionControlBridgeStatusResponse | null): MissionControlChainStatus | null { function getChainStatus(bridgeStatus: MissionControlBridgeStatusResponse | null): MissionControlChainStatus | null {
const chains = bridgeStatus?.data?.chains const chains = bridgeStatus?.data?.chains
if (!chains) return null if (!chains) return null
@@ -24,11 +40,20 @@ function getChainStatus(bridgeStatus: MissionControlBridgeStatusResponse | null)
return firstChain || null return firstChain || null
} }
export default function AnalyticsOperationsPage() { export default function AnalyticsOperationsPage({
const [stats, setStats] = useState<ExplorerStats | null>(null) initialStats = null,
const [blocks, setBlocks] = useState<Block[]>([]) initialTransactionTrend = [],
const [transactions, setTransactions] = useState<Transaction[]>([]) initialActivitySnapshot = null,
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null) initialBlocks = [],
initialTransactions = [],
initialBridgeStatus = null,
}: AnalyticsOperationsPageProps) {
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [transactionTrend, setTransactionTrend] = useState<ExplorerTransactionTrendPoint[]>(initialTransactionTrend)
const [activitySnapshot, setActivitySnapshot] = useState<ExplorerRecentActivitySnapshot | null>(initialActivitySnapshot)
const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [loadingError, setLoadingError] = useState<string | null>(null) const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.analytics const page = explorerFeaturePages.analytics
@@ -36,8 +61,10 @@ export default function AnalyticsOperationsPage() {
let cancelled = false let cancelled = false
const load = async () => { const load = async () => {
const [statsResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([ const [statsResult, trendResult, snapshotResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
statsApi.get(), statsApi.get(),
statsApi.getTransactionTrend(),
statsApi.getRecentActivitySnapshot(),
blocksApi.list({ chain_id: 138, page: 1, page_size: 5 }), blocksApi.list({ chain_id: 138, page: 1, page_size: 5 }),
transactionsApi.list(138, 1, 5), transactionsApi.list(138, 1, 5),
missionControlApi.getBridgeStatus(), missionControlApi.getBridgeStatus(),
@@ -46,15 +73,17 @@ export default function AnalyticsOperationsPage() {
if (cancelled) return if (cancelled) return
if (statsResult.status === 'fulfilled') setStats(statsResult.value) if (statsResult.status === 'fulfilled') setStats(statsResult.value)
if (trendResult.status === 'fulfilled') setTransactionTrend(trendResult.value)
if (snapshotResult.status === 'fulfilled') setActivitySnapshot(snapshotResult.value)
if (blocksResult.status === 'fulfilled') setBlocks(blocksResult.value.data) if (blocksResult.status === 'fulfilled') setBlocks(blocksResult.value.data)
if (transactionsResult.status === 'fulfilled') setTransactions(transactionsResult.value.data) if (transactionsResult.status === 'fulfilled') setTransactions(transactionsResult.value.data)
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value) if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
const failedCount = [statsResult, blocksResult, transactionsResult, bridgeResult].filter( const failedCount = [statsResult, trendResult, snapshotResult, blocksResult, transactionsResult, bridgeResult].filter(
(result) => result.status === 'rejected' (result) => result.status === 'rejected'
).length ).length
if (failedCount === 4) { if (failedCount === 6) {
setLoadingError('Analytics data is temporarily unavailable from the public explorer APIs.') setLoadingError('Analytics data is temporarily unavailable from the public explorer APIs.')
} }
} }
@@ -71,6 +100,27 @@ export default function AnalyticsOperationsPage() {
}, []) }, [])
const chainStatus = useMemo(() => getChainStatus(bridgeStatus), [bridgeStatus]) const chainStatus = useMemo(() => getChainStatus(bridgeStatus), [bridgeStatus])
const trailingWindow = useMemo(() => transactionTrend.slice(0, 7), [transactionTrend])
const sevenDayAverage = useMemo(() => {
if (trailingWindow.length === 0) return 0
const total = trailingWindow.reduce((sum, point) => sum + point.transaction_count, 0)
return total / trailingWindow.length
}, [trailingWindow])
const topDay = useMemo(() => {
if (trailingWindow.length === 0) return null
return trailingWindow.reduce((best, point) => (point.transaction_count > best.transaction_count ? point : best))
}, [trailingWindow])
const averageGasUtilization = useMemo(() => {
if (blocks.length === 0) return 0
return blocks.reduce((sum, block) => {
const ratio = block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0
return sum + ratio
}, 0) / blocks.length
}, [blocks])
const trendPeak = useMemo(
() => trailingWindow.reduce((max, point) => Math.max(max, point.transaction_count), 0),
[trailingWindow],
)
return ( return (
<OperationsPageShell page={page}> <OperationsPageShell page={page}>
@@ -107,9 +157,111 @@ export default function AnalyticsOperationsPage() {
: 'Latest public RPC head age from mission control.' : 'Latest public RPC head age from mission control.'
} }
/> />
<MetricCard
title="7d Avg Tx"
value={formatNumber(Math.round(sevenDayAverage))}
description="Average daily transactions over the latest seven charted days."
className="border border-violet-200 bg-violet-50/70 dark:border-violet-900/50 dark:bg-violet-950/20"
/>
<MetricCard
title="Recent Success Rate"
value={activitySnapshot ? `${Math.round(activitySnapshot.success_rate * 100)}%` : 'Unknown'}
description="Success rate across the public main-page transaction sample."
/>
<MetricCard
title="Failure Rate"
value={activitySnapshot ? `${Math.round(activitySnapshot.failure_rate * 100)}%` : 'Unknown'}
description="The complement to the recent success rate in the visible sample."
className="border border-rose-200 bg-rose-50/70 dark:border-rose-900/50 dark:bg-rose-950/20"
/>
<MetricCard
title="Avg Gas Used"
value={activitySnapshot ? formatNumber(Math.round(activitySnapshot.average_gas_used)) : 'Unknown'}
description="Average gas used in the recent sampled transactions."
/>
<MetricCard
title="Avg Block Gas"
value={`${Math.round(averageGasUtilization * 100)}%`}
description="Average gas utilization across the latest visible blocks."
className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20"
/>
</div> </div>
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]"> <div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
<Card title="Activity Trend">
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Peak Day</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{topDay ? formatNumber(topDay.transaction_count) : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{topDay?.date || 'No trend data yet'}</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Contract Creations</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{formatNumber(activitySnapshot?.contract_creations)}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Within the sampled recent transaction feed.</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Avg Sample Fee</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{activitySnapshot ? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6) : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Average fee from the recent public transaction sample.</div>
</div>
</div>
{activitySnapshot ? (
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token Transfer Share</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{Math.round(activitySnapshot.token_transfer_share * 100)}%
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions involving token transfers.</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Contract Call Share</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{Math.round(activitySnapshot.contract_call_share * 100)}%
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions calling contracts.</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Creation Share</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{Math.round(activitySnapshot.contract_creation_share * 100)}%
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions deploying contracts.</div>
</div>
</div>
) : null}
<div className="space-y-3">
{trailingWindow.map((point) => {
const width = trendPeak > 0 ? Math.max(8, Math.round((point.transaction_count / trendPeak) * 100)) : 0
return (
<div key={point.date}>
<div className="mb-1 flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span>{point.date}</span>
<span>{formatNumber(point.transaction_count)} tx</span>
</div>
<div className="h-2 rounded-full bg-gray-200 dark:bg-gray-800">
<div className="h-2 rounded-full bg-primary-600" style={{ width: `${width}%` }} />
</div>
</div>
)
})}
{trailingWindow.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Trend data is temporarily unavailable.</p>
) : null}
</div>
</div>
</Card>
<Card title="Recent Blocks"> <Card title="Recent Blocks">
<div className="space-y-4"> <div className="space-y-4">
{blocks.map((block) => ( {blocks.map((block) => (
@@ -119,15 +271,18 @@ export default function AnalyticsOperationsPage() {
> >
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<div className="text-base font-semibold text-gray-900 dark:text-white"> <Link href={`/blocks/${block.number}`} className="text-base font-semibold text-primary-600 hover:underline">
Block {formatNumber(block.number)} Block {formatNumber(block.number)}
</div> </Link>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{truncateMiddle(block.hash)} · miner {truncateMiddle(block.miner)} {truncateMiddle(block.hash)} · miner{' '}
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
{truncateMiddle(block.miner)}
</Link>
</div> </div>
</div> </div>
<div className="text-sm text-gray-600 dark:text-gray-400"> <div className="text-sm text-gray-600 dark:text-gray-400">
{formatNumber(block.transaction_count)} tx · {relativeAge(block.timestamp)} {formatNumber(block.transaction_count)} tx · {Math.round((block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0) * 100)}% gas · {relativeAge(block.timestamp)}
</div> </div>
</div> </div>
</div> </div>
@@ -147,11 +302,26 @@ export default function AnalyticsOperationsPage() {
> >
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<div className="text-base font-semibold text-gray-900 dark:text-white"> <Link href={`/transactions/${transaction.hash}`} className="text-base font-semibold text-primary-600 hover:underline">
{truncateMiddle(transaction.hash, 12, 10)} {truncateMiddle(transaction.hash, 12, 10)}
</div> </Link>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Block {formatNumber(transaction.block_number)} · from {truncateMiddle(transaction.from_address)} Block{' '}
<Link href={`/blocks/${transaction.block_number}`} className="text-primary-600 hover:underline">
{formatNumber(transaction.block_number)}
</Link>
{' '}· from{' '}
<Link href={`/addresses/${transaction.from_address}`} className="text-primary-600 hover:underline">
{truncateMiddle(transaction.from_address)}
</Link>
{transaction.to_address ? (
<>
{' '}· to{' '}
<Link href={`/addresses/${transaction.to_address}`} className="text-primary-600 hover:underline">
{truncateMiddle(transaction.to_address)}
</Link>
</>
) : null}
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -164,6 +334,13 @@ export default function AnalyticsOperationsPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
{transaction.method ? <StatusBadge status={transaction.method} tone="warning" /> : null}
{transaction.contract_address ? <StatusBadge status="contract creation" tone="warning" /> : null}
{transaction.token_transfers && transaction.token_transfers.length > 0 ? (
<StatusBadge status={`${transaction.token_transfers.length} token transfer${transaction.token_transfers.length === 1 ? '' : 's'}`} />
) : null}
</div>
</div> </div>
))} ))}
{transactions.length === 0 ? ( {transactions.length === 0 ? (

View File

@@ -100,9 +100,13 @@ function ActionLink({
) )
} }
export default function BridgeMonitoringPage() { export default function BridgeMonitoringPage({
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null) initialBridgeStatus = null,
const [feedState, setFeedState] = useState<FeedState>('connecting') }: {
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
}) {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [feedState, setFeedState] = useState<FeedState>(initialBridgeStatus ? 'fallback' : 'connecting')
const page = explorerFeaturePages.bridge const page = explorerFeaturePages.bridge
useEffect(() => { useEffect(() => {

View File

@@ -30,21 +30,55 @@ interface TokenPoolRecord {
pools: MissionControlLiquidityPool[] pools: MissionControlLiquidityPool[]
} }
interface EndpointCard {
name: string
method: string
href: string
notes: string
}
interface LiquidityOperationsPageProps {
initialTokenList?: TokenListResponse | null
initialRouteMatrix?: RouteMatrixResponse | null
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
initialInternalPlan?: InternalExecutionPlanResponse | null
initialTokenPoolRecords?: TokenPoolRecord[]
}
function routePairLabel(routeId: string, routeLabel: string, tokenIn?: string, tokenOut?: string): string { function routePairLabel(routeId: string, routeLabel: string, tokenIn?: string, tokenOut?: string): string {
return [tokenIn, tokenOut].filter(Boolean).join(' / ') || routeLabel || routeId return [tokenIn, tokenOut].filter(Boolean).join(' / ') || routeLabel || routeId
} }
export default function LiquidityOperationsPage() { export default function LiquidityOperationsPage({
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null) initialTokenList = null,
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null) initialRouteMatrix = null,
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null) initialPlannerCapabilities = null,
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null) initialInternalPlan = null,
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>([]) initialTokenPoolRecords = [],
}: LiquidityOperationsPageProps) {
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>(initialTokenPoolRecords)
const [loadingError, setLoadingError] = useState<string | null>(null) const [loadingError, setLoadingError] = useState<string | null>(null)
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
if (
initialTokenList &&
initialRouteMatrix &&
initialPlannerCapabilities &&
initialInternalPlan &&
initialTokenPoolRecords.length > 0
) {
return () => {
cancelled = true
}
}
const load = async () => { const load = async () => {
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult] = const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult] =
await Promise.allSettled([ await Promise.allSettled([
@@ -102,7 +136,13 @@ export default function LiquidityOperationsPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [
initialInternalPlan,
initialPlannerCapabilities,
initialRouteMatrix,
initialTokenList,
initialTokenPoolRecords,
])
const featuredTokens = useMemo( const featuredTokens = useMemo(
() => selectFeaturedLiquidityTokens(tokenList?.tokens || []), () => selectFeaturedLiquidityTokens(tokenList?.tokens || []),
@@ -139,7 +179,7 @@ export default function LiquidityOperationsPage() {
[routeMatrix, aggregatedPools.length, featuredTokens.length, livePlannerProviders.length, internalPlan?.plannerResponse?.decision, routeBackedPoolAddresses.length] [routeMatrix, aggregatedPools.length, featuredTokens.length, livePlannerProviders.length, internalPlan?.plannerResponse?.decision, routeBackedPoolAddresses.length]
) )
const endpointCards = [ const endpointCards: EndpointCard[] = [
{ {
name: 'Canonical route matrix', name: 'Canonical route matrix',
method: 'GET', method: 'GET',
@@ -166,6 +206,18 @@ export default function LiquidityOperationsPage() {
}, },
] ]
const copyEndpoint = async (endpoint: EndpointCard) => {
try {
await navigator.clipboard.writeText(endpoint.href)
setCopiedEndpoint(endpoint.name)
window.setTimeout(() => {
setCopiedEndpoint((current) => (current === endpoint.name ? null : current))
}, 1500)
} catch {
setCopiedEndpoint(null)
}
}
return ( return (
<main className="container mx-auto px-4 py-6 sm:py-8"> <main className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-8 max-w-4xl"> <div className="mb-8 max-w-4xl">
@@ -258,9 +310,16 @@ export default function LiquidityOperationsPage() {
</div> </div>
))} ))}
{aggregatedPools.length === 0 ? ( {aggregatedPools.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400"> <div className="rounded-2xl border border-amber-200 bg-amber-50/70 p-4 dark:border-amber-900/50 dark:bg-amber-950/20">
No live pool inventory is available right now. <p className="text-sm leading-6 text-amber-900 dark:text-amber-100">
</p> Mission-control pool inventory is currently empty, but the live route matrix still references{' '}
{formatNumber(routeBackedPoolAddresses.length)} pool-backed legs across{' '}
{formatNumber(routeMatrix?.counts?.filteredLiveRoutes)} published live routes.
</p>
<p className="mt-2 text-sm leading-6 text-amber-900/80 dark:text-amber-100/80">
Use the highlighted route-backed paths below and the public route matrix endpoint while pool inventory catches up.
</p>
</div>
) : null} ) : null}
</div> </div>
</Card> </Card>
@@ -339,12 +398,9 @@ export default function LiquidityOperationsPage() {
<Card title="Explorer Access Points"> <Card title="Explorer Access Points">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{endpointCards.map((endpoint) => ( {endpointCards.map((endpoint) => (
<a <div
key={endpoint.href} key={endpoint.href}
href={endpoint.href} className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
target="_blank"
rel="noopener noreferrer"
className="rounded-2xl border border-gray-200 bg-white p-5 transition hover:border-primary-400 hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
> >
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-base font-semibold text-gray-900 dark:text-white">{endpoint.name}</div> <div className="text-base font-semibold text-gray-900 dark:text-white">{endpoint.name}</div>
@@ -356,7 +412,24 @@ export default function LiquidityOperationsPage() {
{endpoint.href} {endpoint.href}
</div> </div>
<div className="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-400">{endpoint.notes}</div> <div className="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-400">{endpoint.notes}</div>
</a> <div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
onClick={() => void copyEndpoint(endpoint)}
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
>
{copiedEndpoint === endpoint.name ? 'Copied' : 'Copy endpoint'}
</button>
{endpoint.name === 'Mission-control token pools' ? (
<Link
href="/pools"
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
>
Open pools page
</Link>
) : null}
</div>
</div>
))} ))}
</div> </div>
</Card> </Card>
@@ -404,12 +477,12 @@ export default function LiquidityOperationsPage() {
> >
Open wallet tools Open wallet tools
</Link> </Link>
<a <Link
href="/docs.html" href="/docs"
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300" className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
> >
Explorer docs Explorer docs
</a> </Link>
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -45,14 +45,28 @@ function ActionLink({
) )
} }
export default function MoreOperationsPage() { interface OperationsHubPageProps {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null) initialBridgeStatus?: MissionControlBridgeStatusResponse | null
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null) initialRouteMatrix?: RouteMatrixResponse | null
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(null) initialNetworksConfig?: NetworksConfigResponse | null
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null) initialTokenList?: TokenListResponse | null
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(null) initialCapabilities?: CapabilitiesResponse | null
}
export default function OperationsHubPage({
initialBridgeStatus = null,
initialRouteMatrix = null,
initialNetworksConfig = null,
initialTokenList = null,
initialCapabilities = null,
}: OperationsHubPageProps) {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(initialNetworksConfig)
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(initialCapabilities)
const [loadingError, setLoadingError] = useState<string | null>(null) const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.more const page = explorerFeaturePages.operations
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false

View File

@@ -17,6 +17,13 @@ import OperationsPageShell, {
truncateMiddle, truncateMiddle,
} from './OperationsPageShell' } from './OperationsPageShell'
interface OperatorOperationsPageProps {
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
initialRouteMatrix?: RouteMatrixResponse | null
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
initialInternalPlan?: InternalExecutionPlanResponse | null
}
function relayTone(status?: string): 'normal' | 'warning' | 'danger' { function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
const normalized = String(status || 'unknown').toLowerCase() const normalized = String(status || 'unknown').toLowerCase()
if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger' if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger'
@@ -24,11 +31,16 @@ function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
return 'normal' return 'normal'
} }
export default function OperatorOperationsPage() { export default function OperatorOperationsPage({
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null) initialBridgeStatus = null,
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null) initialRouteMatrix = null,
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null) initialPlannerCapabilities = null,
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null) initialInternalPlan = null,
}: OperatorOperationsPageProps) {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
const [loadingError, setLoadingError] = useState<string | null>(null) const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.operator const page = explorerFeaturePages.operator

View File

@@ -200,7 +200,7 @@ export default function PoolsOperationsPage() {
</div> </div>
</Card> </Card>
<Card title="Liquidity Shortcuts"> <Card title="Pool operation shortcuts">
<div className="space-y-4 text-sm leading-6 text-gray-600 dark:text-gray-400"> <div className="space-y-4 text-sm leading-6 text-gray-600 dark:text-gray-400">
<p> <p>
The broader liquidity page now shows live route, planner, and pool access together. The broader liquidity page now shows live route, planner, and pool access together.

View File

@@ -10,6 +10,12 @@ import {
type RouteMatrixResponse, type RouteMatrixResponse,
} from '@/services/api/routes' } from '@/services/api/routes'
interface RoutesMonitoringPageProps {
initialRouteMatrix?: RouteMatrixResponse | null
initialNetworks?: ExplorerNetwork[]
initialPools?: MissionControlLiquidityPool[]
}
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22' const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
function relativeAge(isoString?: string): string { function relativeAge(isoString?: string): string {
@@ -80,10 +86,14 @@ function ActionLink({
) )
} }
export default function RoutesMonitoringPage() { export default function RoutesMonitoringPage({
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null) initialRouteMatrix = null,
const [networks, setNetworks] = useState<ExplorerNetwork[]>([]) initialNetworks = [],
const [pools, setPools] = useState<MissionControlLiquidityPool[]>([]) initialPools = [],
}: RoutesMonitoringPageProps) {
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [networks, setNetworks] = useState<ExplorerNetwork[]>(initialNetworks)
const [pools, setPools] = useState<MissionControlLiquidityPool[]>(initialPools)
const [loadingError, setLoadingError] = useState<string | null>(null) const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.routes const page = explorerFeaturePages.routes
@@ -389,7 +399,7 @@ export default function RoutesMonitoringPage() {
<ActionLink <ActionLink
href={action.href} href={action.href}
label={action.label} label={action.label}
external={'external' in action ? action.external : undefined} external={Boolean((action as { external?: boolean }).external)}
/> />
</div> </div>
</div> </div>

View File

@@ -12,13 +12,29 @@ import OperationsPageShell, {
relativeAge, relativeAge,
} from './OperationsPageShell' } from './OperationsPageShell'
export default function SystemOperationsPage() { interface SystemOperationsPageProps {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null) initialBridgeStatus?: MissionControlBridgeStatusResponse | null
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(null) initialNetworksConfig?: NetworksConfigResponse | null
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null) initialTokenList?: TokenListResponse | null
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(null) initialCapabilities?: CapabilitiesResponse | null
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null) initialRouteMatrix?: RouteMatrixResponse | null
const [stats, setStats] = useState<ExplorerStats | null>(null) initialStats?: ExplorerStats | null
}
export default function SystemOperationsPage({
initialBridgeStatus = null,
initialNetworksConfig = null,
initialTokenList = null,
initialCapabilities = null,
initialRouteMatrix = null,
initialStats = null,
}: SystemOperationsPageProps) {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(initialNetworksConfig)
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(initialCapabilities)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [loadingError, setLoadingError] = useState<string | null>(null) const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.system const page = explorerFeaturePages.system

View File

@@ -16,6 +16,12 @@ import OperationsPageShell, {
truncateMiddle, truncateMiddle,
} from './OperationsPageShell' } from './OperationsPageShell'
interface WethOperationsPageProps {
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
initialInternalPlan?: InternalExecutionPlanResponse | null
}
function relayTone(status?: string): 'normal' | 'warning' | 'danger' { function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
const normalized = String(status || 'unknown').toLowerCase() const normalized = String(status || 'unknown').toLowerCase()
if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger' if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger'
@@ -27,10 +33,14 @@ function relaySnapshot(relay: MissionControlRelayPayload | undefined) {
return relay?.url_probe?.body || relay?.file_snapshot return relay?.url_probe?.body || relay?.file_snapshot
} }
export default function WethOperationsPage() { export default function WethOperationsPage({
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null) initialBridgeStatus = null,
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null) initialPlannerCapabilities = null,
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null) initialInternalPlan = null,
}: WethOperationsPageProps) {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
const [loadingError, setLoadingError] = useState<string | null>(null) const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.weth const page = explorerFeaturePages.weth
@@ -85,6 +95,13 @@ export default function WethOperationsPage() {
return ( return (
<OperationsPageShell page={page}> <OperationsPageShell page={page}>
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
These WETH references are bridge and transport surfaces, not a claim that Ethereum mainnet WETH contracts are native Chain 138 assets.
Use this page to review wrapped-asset lane posture, counterpart contracts, and operational dependencies.
</p>
</Card>
{loadingError ? ( {loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20"> <Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p> <p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>

View File

@@ -0,0 +1,427 @@
import { useCallback, useEffect, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives/Card'
import Link from 'next/link'
import { blocksApi, type Block } from '@/services/api/blocks'
import {
statsApi,
type ExplorerRecentActivitySnapshot,
type ExplorerStats,
type ExplorerTransactionTrendPoint,
} from '@/services/api/stats'
import {
missionControlApi,
type MissionControlRelaySummary,
} from '@/services/api/missionControl'
import { loadDashboardData } from '@/utils/dashboard'
import EntityBadge from '@/components/common/EntityBadge'
import { formatTimestamp, formatWeiAsEth } from '@/utils/format'
type HomeStats = ExplorerStats
interface HomePageProps {
initialStats?: HomeStats | null
initialRecentBlocks?: Block[]
initialTransactionTrend?: ExplorerTransactionTrendPoint[]
initialActivitySnapshot?: ExplorerRecentActivitySnapshot | null
initialRelaySummary?: MissionControlRelaySummary | null
}
export default function Home({
initialStats = null,
initialRecentBlocks = [],
initialTransactionTrend = [],
initialActivitySnapshot = null,
initialRelaySummary = null,
}: HomePageProps) {
const [stats, setStats] = useState<HomeStats | null>(initialStats)
const [recentBlocks, setRecentBlocks] = useState<Block[]>(initialRecentBlocks)
const [transactionTrend, setTransactionTrend] = useState<ExplorerTransactionTrendPoint[]>(initialTransactionTrend)
const [activitySnapshot, setActivitySnapshot] = useState<ExplorerRecentActivitySnapshot | null>(initialActivitySnapshot)
const [relaySummary, setRelaySummary] = useState<MissionControlRelaySummary | null>(initialRelaySummary)
const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>(
initialRelaySummary ? 'fallback' : 'connecting'
)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const latestBlock = stats?.latest_block ?? recentBlocks[0]?.number ?? null
const loadDashboard = useCallback(async () => {
const dashboardData = await loadDashboardData({
loadStats: () => statsApi.get(),
loadRecentTransactionTrend: () => statsApi.getTransactionTrend(),
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)
}
},
})
setStats((current) => dashboardData.stats ?? current)
setRecentBlocks((current) => (dashboardData.recentBlocks.length > 0 ? dashboardData.recentBlocks : current))
setTransactionTrend((current) =>
(dashboardData.recentTransactionTrend || []).length > 0 ? dashboardData.recentTransactionTrend : current,
)
}, [chainId])
useEffect(() => {
loadDashboard()
}, [loadDashboard])
useEffect(() => {
let cancelled = false
statsApi.getRecentActivitySnapshot().then((snapshot) => {
if (!cancelled) {
setActivitySnapshot(snapshot)
}
}).catch((error) => {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to load recent activity snapshot:', error)
}
})
return () => {
cancelled = true
}
}, [])
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'
const latestTrendPoint = transactionTrend[0] || null
const peakTrendPoint = transactionTrend.reduce<ExplorerTransactionTrendPoint | null>(
(best, point) => (!best || point.transaction_count > best.transaction_count ? point : best),
null,
)
const relayAttentionCount = relaySummary?.items.filter((item) => item.tone !== 'normal').length || 0
const relayOperationalCount = relaySummary?.items.filter((item) => item.tone === 'normal').length || 0
const relayPrimaryItems = relaySummary?.items.slice(0, 6) || []
return (
<main className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-6 sm:mb-8">
<h1 className="mb-2 text-3xl font-bold sm:text-4xl">SolaceScan</h1>
<p className="text-base text-gray-600 dark:text-gray-400 sm:text-lg">Chain 138 Explorer by DBIS</p>
</div>
{relaySummary && (
<Card className={`mb-6 border shadow-sm ${relayToneClasses}`}>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-3xl">
<div className="text-sm font-semibold uppercase tracking-[0.22em] opacity-75">Mission Control</div>
<div className="mt-2 text-xl font-semibold sm:text-2xl">
{relaySummary.tone === 'danger'
? 'Relay lanes need attention'
: relaySummary.tone === 'warning'
? 'Relay lanes are degraded'
: 'Relay lanes are operational'}
</div>
<p className="mt-2 text-sm leading-6 opacity-90 sm:text-base">
{relaySummary.text}. This surface summarizes the public relay posture in a compact operator-friendly format.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<EntityBadge
label={relayFeedState === 'live' ? 'live sse' : relayFeedState === 'fallback' ? 'snapshot fallback' : 'connecting'}
tone={relayFeedState === 'fallback' ? 'warning' : relayFeedState === 'connecting' ? 'info' : 'success'}
/>
<EntityBadge
label={relaySummary.tone === 'danger' ? 'attention needed' : relaySummary.tone === 'warning' ? 'degraded' : 'operational'}
tone={relaySummary.tone === 'danger' ? 'warning' : relaySummary.tone === 'warning' ? 'info' : 'success'}
/>
<EntityBadge label={`${relayOperationalCount} operational`} tone="success" />
<EntityBadge label={`${relayAttentionCount} flagged`} tone={relayAttentionCount > 0 ? 'warning' : 'info'} />
</div>
</div>
<div className="grid min-w-[220px] gap-3 sm:grid-cols-2 lg:w-[290px] lg:grid-cols-1">
<div className="rounded-2xl border border-white/40 bg-white/50 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide opacity-70">Live Feed</div>
<div className="mt-2 text-lg font-semibold">
{relayFeedState === 'live' ? 'Streaming' : relayFeedState === 'fallback' ? 'Snapshot mode' : 'Connecting'}
</div>
<div className="mt-1 text-sm opacity-80">
{relayFeedState === 'live'
? 'Receiving named mission-control events.'
: relayFeedState === 'fallback'
? 'Using the latest available snapshot.'
: 'Negotiating the event stream.'}
</div>
</div>
<div className="flex flex-col gap-2">
<Link
href="/operations"
className="inline-flex items-center justify-center rounded-xl bg-gray-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-black dark:bg-white dark:text-gray-900 dark:hover:bg-gray-100"
>
Open operations hub
</Link>
<Link
href="/explorer-api/v1/mission-control/stream"
className="inline-flex items-center justify-center rounded-xl border border-current/20 px-4 py-2.5 text-sm font-semibold hover:bg-white/40 dark:hover:bg-black/10"
>
Open live stream
</Link>
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{relayPrimaryItems.map((item) => (
<div
key={item.key}
className="rounded-2xl border border-white/40 bg-white/55 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold">{item.label}</div>
<div className="mt-1 text-xs uppercase tracking-wide opacity-70">{item.status}</div>
</div>
<EntityBadge
label={item.tone === 'danger' ? 'flagged' : item.tone === 'warning' ? 'degraded' : 'live'}
tone={item.tone === 'danger' ? 'warning' : item.tone === 'warning' ? 'info' : 'success'}
/>
</div>
<p className="mt-3 text-sm leading-6 opacity-90">{item.text}</p>
</div>
))}
</div>
{relaySummary.items.length > relayPrimaryItems.length ? (
<div className="text-sm opacity-80">
Showing {relayPrimaryItems.length} of {relaySummary.items.length} relay lanes. The live stream and operations hub carry the fuller view.
</div>
) : null}
</div>
</Card>
)}
{stats && (
<div className="mb-8 grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Latest Block</div>
<div className="text-xl font-bold sm:text-2xl">
{latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'}
</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Blocks</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_blocks.toLocaleString()}</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Transactions</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_transactions.toLocaleString()}</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Addresses</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_addresses.toLocaleString()}</div>
</Card>
</div>
)}
{!stats && (
<Card className="mb-8">
<p className="text-sm text-gray-600 dark:text-gray-400">
Live network stats are temporarily unavailable. Recent blocks and explorer tools are still available below.
</p>
</Card>
)}
<Card title="Recent Blocks">
{recentBlocks.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent blocks are unavailable right now.
</p>
) : (
<div className="space-y-2">
{recentBlocks.map((block) => (
<div key={block.number} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link href={`/blocks/${block.number}`} className="text-primary-600 hover:underline">
Block #{block.number}
</Link>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Mined by{' '}
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
{block.miner.slice(0, 10)}...{block.miner.slice(-6)}
</Link>
</div>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
<div>{block.transaction_count} transactions</div>
<div className="text-xs">{formatTimestamp(block.timestamp)}</div>
</div>
</div>
))}
</div>
)}
<div className="mt-4">
<Link href="/blocks" className="text-primary-600 hover:underline">
View all blocks
</Link>
</div>
</Card>
<div className="mt-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="Activity Pulse">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
A concise public view of chain activity, index coverage, and recent execution patterns.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Latest Daily Volume</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{latestTrendPoint ? latestTrendPoint.transaction_count.toLocaleString() : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{latestTrendPoint?.date || 'Trend feed unavailable'}</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent Success Rate</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{activitySnapshot ? `${Math.round(activitySnapshot.success_rate * 100)}%` : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{activitySnapshot ? `${activitySnapshot.sample_size} sampled transactions` : 'Recent activity snapshot unavailable'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Avg Recent Fee</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{activitySnapshot ? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6) : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Average fee from the recent public sample.</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Peak Charted Day</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{peakTrendPoint ? peakTrendPoint.transaction_count.toLocaleString() : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{peakTrendPoint?.date || 'No trend data yet'}</div>
</div>
</div>
<div className="mt-4">
<Link href="/analytics" className="text-primary-600 hover:underline">
Open full analytics
</Link>
</div>
</Card>
<Card title="Explorer Shortcuts">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Go directly to the explorer surfaces that provide the strongest operational and discovery context.
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/search" className="text-primary-600 hover:underline">
Search
</Link>
<Link href="/transactions" className="text-primary-600 hover:underline">
Transactions
</Link>
<Link href="/tokens" className="text-primary-600 hover:underline">
Tokens
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Addresses
</Link>
<Link href="/analytics" className="text-primary-600 hover:underline">
Analytics
</Link>
</div>
</Card>
<Card title="Liquidity & Routes">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Explore the public Chain 138 DODO PMM liquidity mesh, the canonical route matrix, and the
partner payload endpoints exposed through the explorer.
</p>
<div className="mt-4">
<Link href="/routes" className="text-primary-600 hover:underline">
Open routes and liquidity
</Link>
</div>
</Card>
<Card title="Wallet & Token Discovery">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Add Chain 138, Ethereum Mainnet, and ALL Mainnet to MetaMask, then use the explorer token
list URL so supported tokens appear automatically.
</p>
<div className="mt-4">
<Link href="/wallet" className="text-primary-600 hover:underline">
Open wallet tools
</Link>
</div>
</Card>
<Card title="Bridge & Relay Monitoring">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Open the public bridge monitoring surface for relay status, mission-control links, bridge trace tooling,
and the visual command center entry points.
</p>
<div className="mt-4">
<Link href="/bridge" className="text-primary-600 hover:underline">
Open bridge monitoring
</Link>
</div>
</Card>
<Card title="Operations Hub">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Open the public operations surface for wrapped-asset references, analytics shortcuts, operator links,
system topology views, and other Chain 138 support tools.
</p>
<div className="mt-4">
<Link href="/operations" className="text-primary-600 hover:underline">
Open operations hub
</Link>
</div>
</Card>
</div>
</main>
)
}

View File

@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base' import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
type WalletChain = { export type WalletChain = {
chainId: string chainId: string
chainIdDecimal?: number chainIdDecimal?: number
chainName: string chainName: string
@@ -20,7 +20,7 @@ type WalletChain = {
explorerApiUrl?: string explorerApiUrl?: string
} }
type TokenListToken = { export type TokenListToken = {
chainId: number chainId: number
address: string address: string
name: string name: string
@@ -31,7 +31,7 @@ type TokenListToken = {
extensions?: Record<string, unknown> extensions?: Record<string, unknown>
} }
type NetworksCatalog = { export type NetworksCatalog = {
name?: string name?: string
version?: { version?: {
major?: number major?: number
@@ -42,7 +42,7 @@ type NetworksCatalog = {
chains?: WalletChain[] chains?: WalletChain[]
} }
type TokenListCatalog = { export type TokenListCatalog = {
name?: string name?: string
version?: { version?: {
major?: number major?: number
@@ -53,7 +53,7 @@ type TokenListCatalog = {
tokens?: TokenListToken[] tokens?: TokenListToken[]
} }
type CapabilitiesCatalog = { export type CapabilitiesCatalog = {
name?: string name?: string
version?: { version?: {
major?: number major?: number
@@ -84,11 +84,20 @@ type CapabilitiesCatalog = {
} }
} }
type FetchMetadata = { export type FetchMetadata = {
source?: string | null source?: string | null
lastModified?: string | null lastModified?: string | null
} }
interface AddToMetaMaskProps {
initialNetworks?: NetworksCatalog | null
initialTokenList?: TokenListCatalog | null
initialCapabilities?: CapabilitiesCatalog | null
initialNetworksMeta?: FetchMetadata | null
initialTokenListMeta?: FetchMetadata | null
initialCapabilitiesMeta?: FetchMetadata | null
}
type EthereumProvider = { type EthereumProvider = {
request: (args: { method: string; params?: unknown }) => Promise<unknown> request: (args: { method: string; params?: unknown }) => Promise<unknown>
} }
@@ -99,7 +108,7 @@ const FALLBACK_CHAIN_138: WalletChain = {
chainName: 'DeFi Oracle Meta Mainnet', chainName: 'DeFi Oracle Meta Mainnet',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'], rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'],
blockExplorerUrls: ['https://explorer.d-bis.org'], blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'],
iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'], iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'],
shortName: 'dbis', shortName: 'dbis',
infoURL: 'https://explorer.d-bis.org', infoURL: 'https://explorer.d-bis.org',
@@ -139,7 +148,7 @@ const FALLBACK_CAPABILITIES_138: CapabilitiesCatalog = {
name: 'Chain 138 RPC Capabilities', name: 'Chain 138 RPC Capabilities',
version: { major: 1, minor: 1, patch: 0 }, version: { major: 1, minor: 1, patch: 0 },
timestamp: '2026-03-28T00:00:00Z', timestamp: '2026-03-28T00:00:00Z',
generatedBy: 'SolaceScanScout', generatedBy: 'SolaceScan',
chainId: 138, chainId: 138,
chainName: 'DeFi Oracle Meta Mainnet', chainName: 'DeFi Oracle Meta Mainnet',
rpcUrl: 'https://rpc-http-pub.d-bis.org', rpcUrl: 'https://rpc-http-pub.d-bis.org',
@@ -211,19 +220,39 @@ function isCapabilitiesCatalog(value: unknown): value is CapabilitiesCatalog {
function getApiBase() { function getApiBase() {
return resolveExplorerApiBase({ return resolveExplorerApiBase({
serverFallback: 'https://explorer.d-bis.org', serverFallback: 'https://blockscout.defi-oracle.io',
}) })
} }
export function AddToMetaMask() { export function AddToMetaMask({
initialNetworks = null,
initialTokenList = null,
initialCapabilities = null,
initialNetworksMeta = null,
initialTokenListMeta = null,
initialCapabilitiesMeta = null,
}: AddToMetaMaskProps) {
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [networks, setNetworks] = useState<NetworksCatalog | null>(null) const [networks, setNetworks] = useState<NetworksCatalog | null>(initialNetworks)
const [tokenList, setTokenList] = useState<TokenListCatalog | null>(null) const [tokenList, setTokenList] = useState<TokenListCatalog | null>(initialTokenList)
const [capabilities, setCapabilities] = useState<CapabilitiesCatalog | null>(null) const [capabilities, setCapabilities] = useState<CapabilitiesCatalog | null>(
const [networksMeta, setNetworksMeta] = useState<FetchMetadata | null>(null) initialCapabilities || FALLBACK_CAPABILITIES_138,
const [tokenListMeta, setTokenListMeta] = useState<FetchMetadata | null>(null) )
const [capabilitiesMeta, setCapabilitiesMeta] = useState<FetchMetadata | null>(null) const [networksMeta, setNetworksMeta] = useState<FetchMetadata | null>(initialNetworksMeta)
const [tokenListMeta, setTokenListMeta] = useState<FetchMetadata | null>(initialTokenListMeta)
const [capabilitiesMeta, setCapabilitiesMeta] = useState<FetchMetadata | null>(
initialCapabilitiesMeta ||
(initialCapabilities
? {
source: 'explorer-api',
lastModified: initialCapabilities.timestamp || null,
}
: {
source: 'frontend-fallback',
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
}),
)
const ethereum = typeof window !== 'undefined' const ethereum = typeof window !== 'undefined'
? (window as unknown as { ethereum?: EthereumProvider }).ethereum ? (window as unknown as { ethereum?: EthereumProvider }).ethereum
@@ -251,7 +280,7 @@ export function AddToMetaMask() {
}) })
const json = response.ok ? await response.json() : null const json = response.ok ? await response.json() : null
const meta: FetchMetadata = { const meta: FetchMetadata = {
source: response.headers.get('X-Config-Source'), source: response.headers.get('X-Config-Source') || 'explorer-api',
lastModified: response.headers.get('Last-Modified'), lastModified: response.headers.get('Last-Modified'),
} }
return { json, meta } return { json, meta }
@@ -296,15 +325,17 @@ export function AddToMetaMask() {
setCapabilitiesMeta(resolvedCapabilities.meta) setCapabilitiesMeta(resolvedCapabilities.meta)
} catch { } catch {
if (!active) return if (!active) return
setNetworks(null) setNetworks((current) => current)
setTokenList(null) setTokenList((current) => current)
setCapabilities(FALLBACK_CAPABILITIES_138) setCapabilities((current) => current || FALLBACK_CAPABILITIES_138)
setNetworksMeta(null) setNetworksMeta((current) => current)
setTokenListMeta(null) setTokenListMeta((current) => current)
setCapabilitiesMeta({ setCapabilitiesMeta((current) =>
source: 'frontend-fallback', current || {
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null, source: 'frontend-fallback',
}) lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
},
)
} finally { } finally {
if (active) { if (active) {
timer = setTimeout(() => { timer = setTimeout(() => {

View File

@@ -1,14 +1,29 @@
import type {
CapabilitiesCatalog,
FetchMetadata,
NetworksCatalog,
TokenListCatalog,
} from '@/components/wallet/AddToMetaMask'
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask' import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
import Link from 'next/link' import Link from 'next/link'
export default function WalletPage() { interface WalletPageProps {
initialNetworks?: NetworksCatalog | null
initialTokenList?: TokenListCatalog | null
initialCapabilities?: CapabilitiesCatalog | null
initialNetworksMeta?: FetchMetadata | null
initialTokenListMeta?: FetchMetadata | null
initialCapabilitiesMeta?: FetchMetadata | null
}
export default function WalletPage(props: WalletPageProps) {
return ( return (
<main className="container mx-auto px-4 py-6 sm:py-8"> <main className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-4 text-2xl font-bold sm:text-3xl">Wallet & MetaMask</h1> <h1 className="mb-4 text-2xl font-bold sm:text-3xl">Wallet & MetaMask</h1>
<p className="mb-6 text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base"> <p className="mb-6 text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base">
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. 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.
</p> </p>
<AddToMetaMask /> <AddToMetaMask {...props} />
<div className="mt-6 rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400"> <div className="mt-6 rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
Need swap and liquidity discovery too? Visit the{' '} Need swap and liquidity discovery too? Visit the{' '}
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400"> <Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">

View File

@@ -15,7 +15,7 @@ export interface ExplorerFeaturePage {
} }
const legacyNote = 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.' 'These pages collect the public monitoring, route, wallet, and topology surfaces that support Chain 138 operations and investigation.'
export const explorerFeaturePages = { export const explorerFeaturePages = {
bridge: { bridge: {
@@ -72,7 +72,7 @@ export const explorerFeaturePages = {
eyebrow: 'Route Coverage', eyebrow: 'Route Coverage',
title: 'Routes, Pools, and Execution Access', title: 'Routes, Pools, and Execution Access',
description: 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.', 'Surface the route matrix, live pool inventory, public liquidity endpoints, and bridge-adjacent execution paths from one public explorer surface.',
note: legacyNote, note: legacyNote,
actions: [ actions: [
{ {
@@ -88,11 +88,10 @@ export const explorerFeaturePages = {
label: 'Open pools page', label: 'Open pools page',
}, },
{ {
title: 'Liquidity mission-control example', title: 'Pools inventory',
description: 'Open a live mission-control liquidity lookup for a canonical Chain 138 token.', description: 'Open the live pools page instead of dropping into a raw backend response.',
href: '/explorer-api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools', href: '/pools',
label: 'Open liquidity JSON', label: 'Open pools inventory',
external: true,
}, },
{ {
title: 'Bridge monitoring', title: 'Bridge monitoring',
@@ -103,7 +102,7 @@ export const explorerFeaturePages = {
{ {
title: 'Operations hub', title: 'Operations hub',
description: 'Open the consolidated page for WETH utilities, analytics, operator shortcuts, and system views.', description: 'Open the consolidated page for WETH utilities, analytics, operator shortcuts, and system views.',
href: '/more', href: '/operations',
label: 'Open operations hub', label: 'Open operations hub',
}, },
], ],
@@ -137,7 +136,7 @@ export const explorerFeaturePages = {
{ {
title: 'Operations hub', title: 'Operations hub',
description: 'Return to the larger operations landing page for adjacent route, analytics, and system shortcuts.', description: 'Return to the larger operations landing page for adjacent route, analytics, and system shortcuts.',
href: '/more', href: '/operations',
label: 'Open operations hub', label: 'Open operations hub',
}, },
], ],
@@ -180,7 +179,7 @@ export const explorerFeaturePages = {
eyebrow: 'Operator Shortcuts', eyebrow: 'Operator Shortcuts',
title: 'Operator Panel Shortcuts', title: 'Operator Panel Shortcuts',
description: description:
'Expose the public operational shortcuts that were restored in the legacy explorer for bridge checks, route validation, liquidity entry points, and documentation.', 'Expose the public operational shortcuts for bridge checks, route validation, liquidity entry points, and documentation.',
note: legacyNote, note: legacyNote,
actions: [ actions: [
{ {
@@ -203,10 +202,9 @@ export const explorerFeaturePages = {
}, },
{ {
title: 'Explorer docs', title: 'Explorer docs',
description: 'Use the static documentation landing page for explorer-specific reference material.', description: 'Open the canonical explorer documentation hub for GRU guidance, transaction evidence notes, and public reference material.',
href: '/docs.html', href: '/docs',
label: 'Open docs', label: 'Open docs',
external: true,
}, },
{ {
title: 'Visual command center', title: 'Visual command center',
@@ -239,24 +237,23 @@ export const explorerFeaturePages = {
}, },
{ {
title: 'Explorer docs', title: 'Explorer docs',
description: 'Open the documentation landing page for static reference material shipped with the explorer.', description: 'Open the canonical explorer documentation hub for public reference material and guide pages.',
href: '/docs.html', href: '/docs',
label: 'Open docs', label: 'Open docs',
external: true,
}, },
{ {
title: 'Operations hub', title: 'Operations hub',
description: 'Return to the consolidated operations landing page for adjacent public tools.', description: 'Return to the consolidated operations landing page for adjacent public tools.',
href: '/more', href: '/operations',
label: 'Open operations hub', label: 'Open operations hub',
}, },
], ],
}, },
more: { operations: {
eyebrow: 'Operations Hub', eyebrow: 'Operations Hub',
title: 'More Explorer Tools', title: 'Operations Hub',
description: 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.', 'This hub exposes the public operational surfaces for bridge monitoring, routes, wrapped-asset references, analytics shortcuts, operator links, and topology views.',
note: legacyNote, note: legacyNote,
actions: [ actions: [
{ {

View File

@@ -0,0 +1,5 @@
import AccessManagementPage from '@/components/access/AccessManagementPage'
export default function AccessPage() {
return <AccessManagementPage />
}

View File

@@ -4,24 +4,53 @@ import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Card, Table, Address } from '@/libs/frontend-ui-primitives' import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link' import Link from 'next/link'
import { addressesApi, AddressInfo, TransactionSummary } from '@/services/api/addresses' import {
import { formatWeiAsEth } from '@/utils/format' addressesApi,
AddressInfo,
AddressTokenBalance,
AddressTokenTransfer,
TransactionSummary,
} from '@/services/api/addresses'
import {
encodeMethodCalldata,
callSimpleReadMethod,
contractsApi,
type ContractMethod,
type ContractProfile,
} from '@/services/api/contracts'
import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format'
import { DetailRow } from '@/components/common/DetailRow' import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import { import {
isWatchlistEntry, isWatchlistEntry,
readWatchlistFromStorage, readWatchlistFromStorage,
writeWatchlistToStorage, writeWatchlistToStorage,
normalizeWatchlistAddress, normalizeWatchlistAddress,
} from '@/utils/watchlist' } from '@/utils/watchlist'
import PageIntro from '@/components/common/PageIntro'
import GruStandardsCard from '@/components/common/GruStandardsCard'
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
function isValidAddress(value: string) {
return /^0x[a-fA-F0-9]{40}$/.test(value)
}
export default function AddressDetailPage() { export default function AddressDetailPage() {
const router = useRouter() const router = useRouter()
const address = typeof router.query.address === 'string' ? router.query.address : '' const address = typeof router.query.address === 'string' ? router.query.address : ''
const isValidAddressParam = address !== '' && isValidAddress(address)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null) const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
const [transactions, setTransactions] = useState<TransactionSummary[]>([]) const [transactions, setTransactions] = useState<TransactionSummary[]>([])
const [tokenBalances, setTokenBalances] = useState<AddressTokenBalance[]>([])
const [tokenTransfers, setTokenTransfers] = useState<AddressTokenTransfer[]>([])
const [contractProfile, setContractProfile] = useState<ContractProfile | null>(null)
const [gruProfile, setGruProfile] = useState<GruStandardsProfile | null>(null)
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([]) const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
const [methodResults, setMethodResults] = useState<Record<string, { loading: boolean; value?: string; error?: string }>>({})
const [methodInputs, setMethodInputs] = useState<Record<string, string[]>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const loadAddressInfo = useCallback(async () => { const loadAddressInfo = useCallback(async () => {
@@ -29,22 +58,49 @@ export default function AddressDetailPage() {
const { ok, data } = await addressesApi.getSafe(chainId, address) const { ok, data } = await addressesApi.getSafe(chainId, address)
if (!ok) { if (!ok) {
setAddressInfo(null) setAddressInfo(null)
setContractProfile(null)
return return
} }
setAddressInfo(data ?? null) setAddressInfo(data ?? null)
if (data?.is_contract) {
const contractResult = await contractsApi.getProfileSafe(address)
const resolvedContractProfile = contractResult.ok ? contractResult.data : null
setContractProfile(resolvedContractProfile)
const gruResult = await getGruStandardsProfileSafe({
address,
symbol: data?.token_contract?.symbol || data?.token_contract?.name || '',
tags: data?.tags || [],
contractProfile: resolvedContractProfile,
})
setGruProfile(gruResult.ok ? gruResult.data : null)
} else {
setContractProfile(null)
setGruProfile(null)
}
} catch (error) { } catch (error) {
console.error('Failed to load address info:', error) console.error('Failed to load address info:', error)
setAddressInfo(null) setAddressInfo(null)
setContractProfile(null)
setGruProfile(null)
} }
}, [chainId, address]) }, [chainId, address])
const loadTransactions = useCallback(async () => { const loadTransactions = useCallback(async () => {
try { try {
const { ok, data } = await addressesApi.getTransactionsSafe(chainId, address, 1, 20) const [transactionsResult, balancesResult, transfersResult] = await Promise.all([
addressesApi.getTransactionsSafe(chainId, address, 1, 20),
addressesApi.getTokenBalancesSafe(address),
addressesApi.getTokenTransfersSafe(address, 1, 10),
])
const { ok, data } = transactionsResult
setTransactions(ok ? data : []) setTransactions(ok ? data : [])
setTokenBalances(balancesResult.ok ? balancesResult.data : [])
setTokenTransfers(transfersResult.ok ? transfersResult.data : [])
} catch (error) { } catch (error) {
console.error('Failed to load transactions:', error) console.error('Failed to load transactions:', error)
setTransactions([]) setTransactions([])
setTokenBalances([])
setTokenTransfers([])
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -59,9 +115,15 @@ export default function AddressDetailPage() {
} }
return return
} }
if (!isValidAddressParam) {
setLoading(false)
setAddressInfo(null)
setTransactions([])
return
}
loadAddressInfo() loadAddressInfo()
loadTransactions() loadTransactions()
}, [address, loadAddressInfo, loadTransactions, router.isReady]) }, [address, isValidAddressParam, loadAddressInfo, loadTransactions, router.isReady])
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -98,6 +160,91 @@ export default function AddressDetailPage() {
}) })
} }
const handleReadMethod = async (method: ContractMethod) => {
const values = methodInputs[method.signature] || method.inputs.map(() => '')
setMethodResults((current) => ({
...current,
[method.signature]: { loading: true },
}))
try {
const value = await callSimpleReadMethod(address, method, values)
setMethodResults((current) => ({
...current,
[method.signature]: { loading: false, value },
}))
} catch (error) {
setMethodResults((current) => ({
...current,
[method.signature]: {
loading: false,
error: error instanceof Error ? error.message : 'Read call failed',
},
}))
}
}
const handleMethodInputChange = (signature: string, index: number, value: string) => {
setMethodInputs((current) => {
const next = [...(current[signature] || [])]
next[index] = value
return {
...current,
[signature]: next,
}
})
}
const handleWriteMethod = async (method: ContractMethod) => {
const provider = typeof window !== 'undefined'
? (window as unknown as { ethereum?: { request: (args: { method: string; params?: unknown[] }) => Promise<unknown> } }).ethereum
: undefined
if (!provider) {
setMethodResults((current) => ({
...current,
[method.signature]: { loading: false, error: 'A wallet provider is required for write methods.' },
}))
return
}
const values = methodInputs[method.signature] || method.inputs.map(() => '')
setMethodResults((current) => ({
...current,
[method.signature]: { loading: true },
}))
try {
const data = encodeMethodCalldata(method, values)
const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as string[]
const from = accounts?.[0]
if (!from) {
throw new Error('No wallet account was returned by the provider.')
}
const txHash = await provider.request({
method: 'eth_sendTransaction',
params: [
{
from,
to: address,
data,
},
],
})
setMethodResults((current) => ({
...current,
[method.signature]: { loading: false, value: typeof txHash === 'string' ? txHash : 'Transaction submitted' },
}))
} catch (error) {
setMethodResults((current) => ({
...current,
[method.signature]: {
loading: false,
error: error instanceof Error ? error.message : 'Write call failed',
},
}))
}
}
const transactionColumns = [ const transactionColumns = [
{ {
header: 'Hash', header: 'Hash',
@@ -137,11 +284,138 @@ export default function AddressDetailPage() {
}, },
] ]
const tokenBalanceColumns = [
{
header: 'Token',
accessor: (balance: AddressTokenBalance) => {
const gruMetadata = getGruExplorerMetadata({
address: balance.token_address,
symbol: balance.token_symbol,
})
return (
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
{balance.token_address ? (
<Link href={`/tokens/${balance.token_address}`} className="text-primary-600 hover:underline">
{balance.token_symbol || balance.token_name || <Address address={balance.token_address} truncate showCopy={false} />}
</Link>
) : (
<span>{balance.token_symbol || balance.token_name || 'Token'}</span>
)}
{gruMetadata ? <EntityBadge label="GRU" tone="success" /> : null}
{gruMetadata?.x402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
{gruMetadata?.iso20022Ready ? <EntityBadge label="ISO-20022" tone="info" /> : null}
</div>
{balance.token_name && balance.token_symbol && (
<div className="text-xs text-gray-500 dark:text-gray-400">{balance.token_name}</div>
)}
</div>
)
},
},
{
header: 'Balance',
accessor: (balance: AddressTokenBalance) => (
formatTokenAmount(balance.value, balance.token_decimals, balance.token_symbol)
),
},
{
header: 'Supply',
accessor: (balance: AddressTokenBalance) => (
balance.total_supply
? formatTokenAmount(balance.total_supply, balance.token_decimals, balance.token_symbol)
: 'N/A'
),
},
]
const tokenTransferColumns = [
{
header: 'Token',
accessor: (transfer: AddressTokenTransfer) => {
const gruMetadata = getGruExplorerMetadata({
address: transfer.token_address,
symbol: transfer.token_symbol,
})
return (
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium text-gray-900 dark:text-white">{transfer.token_symbol || transfer.token_name || 'Token'}</div>
{gruMetadata ? <EntityBadge label="GRU" tone="success" /> : null}
{gruMetadata?.x402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
{gruMetadata?.iso20022Ready ? <EntityBadge label="ISO-20022" tone="info" /> : null}
{gruMetadata?.transportActiveVersion ? <EntityBadge label={`transport ${gruMetadata.transportActiveVersion}`} tone="warning" /> : null}
</div>
{transfer.token_address && (
<Link href={`/tokens/${transfer.token_address}`} className="text-primary-600 hover:underline">
<Address address={transfer.token_address} truncate showCopy={false} />
</Link>
)}
</div>
)
},
},
{
header: 'Direction',
accessor: (transfer: AddressTokenTransfer) =>
transfer.to_address.toLowerCase() === address.toLowerCase() ? 'Incoming' : 'Outgoing',
},
{
header: 'Counterparty',
accessor: (transfer: AddressTokenTransfer) => {
const incoming = transfer.to_address.toLowerCase() === address.toLowerCase()
const counterparty = incoming ? transfer.from_address : transfer.to_address
const label = incoming ? transfer.from_label : transfer.to_label
return (
<Link href={`/addresses/${counterparty}`} className="text-primary-600 hover:underline">
{label || <Address address={counterparty} truncate showCopy={false} />}
</Link>
)
},
},
{
header: 'Amount',
accessor: (transfer: AddressTokenTransfer) => (
formatTokenAmount(transfer.value, transfer.token_decimals, transfer.token_symbol)
),
},
{
header: 'When',
accessor: (transfer: AddressTokenTransfer) => formatTimestamp(transfer.timestamp),
},
]
const incomingTransactions = transactions.filter(
(tx) => tx.to_address?.toLowerCase() === address.toLowerCase()
).length
const outgoingTransactions = transactions.filter(
(tx) => tx.from_address.toLowerCase() === address.toLowerCase()
).length
const incomingTokenTransfers = tokenTransfers.filter(
(transfer) => transfer.to_address.toLowerCase() === address.toLowerCase()
).length
const outgoingTokenTransfers = tokenTransfers.filter(
(transfer) => transfer.from_address.toLowerCase() === address.toLowerCase()
).length
const gruBalanceCount = tokenBalances.filter((balance) =>
Boolean(getGruExplorerMetadata({ address: balance.token_address, symbol: balance.token_symbol })),
).length
const gruTransferCount = tokenTransfers.filter((transfer) =>
Boolean(getGruExplorerMetadata({ address: transfer.token_address, symbol: transfer.token_symbol })),
).length
return ( return (
<div className="container mx-auto px-4 py-6 sm:py-8"> <div className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-6 text-3xl font-bold"> <PageIntro
{addressInfo?.label || 'Address'} eyebrow="Address Detail"
</h1> title={addressInfo?.label || 'Address'}
description="Inspect a Chain 138 address, move into related transactions, and save important counterparties into the shared explorer watchlist."
actions={[
{ href: '/addresses', label: 'All addresses' },
{ href: '/watchlist', label: 'Open watchlist' },
{ href: '/search', label: 'Search explorer' },
]}
/>
<div className="mb-6 flex flex-wrap gap-3 text-sm"> <div className="mb-6 flex flex-wrap gap-3 text-sm">
<Link href="/addresses" className="text-primary-600 hover:underline"> <Link href="/addresses" className="text-primary-600 hover:underline">
@@ -167,9 +441,29 @@ export default function AddressDetailPage() {
<Card className="mb-6"> <Card className="mb-6">
<p className="text-sm text-gray-600 dark:text-gray-400">Loading address...</p> <p className="text-sm text-gray-600 dark:text-gray-400">Loading address...</p>
</Card> </Card>
) : !isValidAddressParam ? (
<Card className="mb-6">
<p className="text-sm text-gray-600 dark:text-gray-400">Invalid address. Please use a full 42-character 0x-prefixed address.</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/addresses" className="text-primary-600 hover:underline">
Back to addresses
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search the explorer
</Link>
</div>
</Card>
) : !addressInfo ? ( ) : !addressInfo ? (
<Card className="mb-6"> <Card className="mb-6">
<p className="text-sm text-gray-600 dark:text-gray-400">Address not found.</p> <p className="text-sm text-gray-600 dark:text-gray-400">Address not found.</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/addresses" className="text-primary-600 hover:underline">
Browse recent addresses
</Link>
<Link href="/watchlist" className="text-primary-600 hover:underline">
Open watchlist
</Link>
</div>
</Card> </Card>
) : ( ) : (
<> <>
@@ -178,24 +472,333 @@ export default function AddressDetailPage() {
<DetailRow label="Address"> <DetailRow label="Address">
<Address address={addressInfo.address} /> <Address address={addressInfo.address} />
</DetailRow> </DetailRow>
{addressInfo.balance && (
<DetailRow label="Coin Balance">{formatWeiAsEth(addressInfo.balance)}</DetailRow>
)}
<DetailRow label="Watchlist"> <DetailRow label="Watchlist">
{isSavedToWatchlist ? 'Saved for quick access' : 'Not saved yet'} {isSavedToWatchlist ? 'Saved for quick access' : 'Not saved yet'}
</DetailRow> </DetailRow>
<DetailRow label="Verification">
<div className="flex flex-wrap gap-2">
<EntityBadge label={addressInfo.is_contract ? (addressInfo.is_verified ? 'verified' : 'contract') : 'eoa'} />
{contractProfile?.source_verified ? <EntityBadge label="source available" tone="success" /> : null}
{contractProfile?.abi_available ? <EntityBadge label="abi available" tone="info" /> : null}
{addressInfo.token_contract ? <EntityBadge label={addressInfo.token_contract.type || 'token'} tone="info" /> : null}
</div>
</DetailRow>
{addressInfo.token_contract && (
<DetailRow label="Token Contract">
<div className="space-y-2">
<div>
{addressInfo.token_contract.symbol || addressInfo.token_contract.name || 'Token contract'} · {addressInfo.token_contract.type || 'Token'}
</div>
<Link href={`/tokens/${addressInfo.token_contract.address}`} className="text-primary-600 hover:underline">
Open token detail
</Link>
</div>
</DetailRow>
)}
{addressInfo.tags.length > 0 && ( {addressInfo.tags.length > 0 && (
<DetailRow label="Tags" valueClassName="flex flex-wrap gap-2"> <DetailRow label="Tags" valueClassName="flex flex-wrap gap-2">
{addressInfo.tags.map((tag, i) => ( {addressInfo.tags.map((tag, i) => (
<span key={i} className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-sm"> <EntityBadge key={i} label={tag} className="px-2 py-1 text-[11px]" />
{tag}
</span>
))} ))}
</DetailRow> </DetailRow>
)} )}
<DetailRow label="Transactions">{addressInfo.transaction_count}</DetailRow> <DetailRow label="Transactions">{addressInfo.transaction_count}</DetailRow>
<DetailRow label="Tokens">{addressInfo.token_count}</DetailRow> <DetailRow label="Tokens">{addressInfo.token_count}</DetailRow>
<DetailRow label="Type">{addressInfo.is_contract ? 'Contract' : 'EOA'}</DetailRow> <DetailRow label="Type">{addressInfo.is_contract ? 'Contract' : 'EOA'}</DetailRow>
<DetailRow label="Recent Activity">
{incomingTransactions} incoming / {outgoingTransactions} outgoing txs
</DetailRow>
{addressInfo.internal_transaction_count != null && (
<DetailRow label="Internal Calls">{addressInfo.internal_transaction_count}</DetailRow>
)}
{addressInfo.logs_count != null && (
<DetailRow label="Indexed Logs">{addressInfo.logs_count}</DetailRow>
)}
<DetailRow label="Token Flow">
{incomingTokenTransfers} incoming / {outgoingTokenTransfers} outgoing token transfers
{addressInfo.token_transfer_count != null ? ` · ${addressInfo.token_transfer_count} total indexed` : ''}
</DetailRow>
{addressInfo.creation_transaction_hash && (
<DetailRow label="Created In">
<Link href={`/transactions/${addressInfo.creation_transaction_hash}`} className="text-primary-600 hover:underline">
<Address address={addressInfo.creation_transaction_hash} truncate showCopy={false} />
</Link>
</DetailRow>
)}
</dl> </dl>
</Card> </Card>
{addressInfo.is_contract && (
<Card title="Contract Profile" className="mb-6">
<dl className="space-y-4">
<DetailRow label="Interaction Surface">
<div className="flex flex-wrap gap-2">
{contractProfile?.has_custom_methods_read ? <EntityBadge label="read methods" tone="success" /> : <EntityBadge label="read unknown" /> }
{contractProfile?.has_custom_methods_write ? <EntityBadge label="write methods" tone="warning" /> : <EntityBadge label="write unknown" /> }
</div>
</DetailRow>
<DetailRow label="Proxy Type">
{contractProfile?.proxy_type || 'Not reported'}
</DetailRow>
<DetailRow label="Source Status">
<div className="space-y-2">
<div>{contractProfile?.source_status_text || 'Verification metadata was not available from the public explorer.'}</div>
<div className="flex flex-wrap gap-2">
<EntityBadge
label={contractProfile?.source_verified ? 'verified source' : 'source unavailable'}
tone={contractProfile?.source_verified ? 'success' : 'warning'}
/>
<EntityBadge
label={contractProfile?.abi_available ? 'abi present' : 'abi unavailable'}
tone={contractProfile?.abi_available ? 'info' : 'warning'}
/>
</div>
</div>
</DetailRow>
<DetailRow label="Lifecycle">
{contractProfile?.is_self_destructed ? 'Self-destructed' : 'Active'}
</DetailRow>
{(contractProfile?.contract_name ||
contractProfile?.compiler_version ||
contractProfile?.license_type ||
contractProfile?.evm_version ||
contractProfile?.optimization_enabled != null) && (
<DetailRow label="Build Metadata">
<div className="space-y-2 text-sm text-gray-700 dark:text-gray-300">
{contractProfile?.contract_name ? <div>Name: {contractProfile.contract_name}</div> : null}
{contractProfile?.compiler_version ? <div>Compiler: {contractProfile.compiler_version}</div> : null}
{contractProfile?.license_type ? <div>License: {contractProfile.license_type}</div> : null}
{contractProfile?.evm_version ? <div>EVM target: {contractProfile.evm_version}</div> : null}
{contractProfile?.optimization_enabled != null ? (
<div>
Optimization: {contractProfile.optimization_enabled ? 'Enabled' : 'Disabled'}
{contractProfile.optimization_runs != null ? ` · ${contractProfile.optimization_runs} runs` : ''}
</div>
) : null}
</div>
</DetailRow>
)}
<DetailRow label="Implementations">
{contractProfile?.implementations && contractProfile.implementations.length > 0 ? (
<div className="space-y-2">
{contractProfile.implementations.map((implementation) => (
<Link key={implementation} href={`/addresses/${implementation}`} className="block text-primary-600 hover:underline">
<Address address={implementation} truncate showCopy={false} />
</Link>
))}
</div>
) : (
'No implementation addresses were reported.'
)}
</DetailRow>
{contractProfile?.constructor_arguments && (
<DetailRow label="Constructor Args">
<code className="block break-all rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.constructor_arguments}
</code>
</DetailRow>
)}
{contractProfile?.source_code_preview && (
<DetailRow label="Source Preview">
<code className="block max-h-56 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.source_code_preview}
</code>
</DetailRow>
)}
{contractProfile?.abi && (
<DetailRow label="ABI Preview">
<code className="block max-h-56 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.abi}
</code>
</DetailRow>
)}
{contractProfile?.read_methods && contractProfile.read_methods.length > 0 && (
<DetailRow label="Read Methods">
<div className="space-y-3">
{contractProfile.read_methods.slice(0, 8).map((method) => {
const methodState = methodResults[method.signature]
const supportsQuickCall = contractsApi.supportsSimpleReadCall(method)
const inputValues = methodInputs[method.signature] || method.inputs.map(() => '')
return (
<div key={method.signature} className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="font-mono text-sm text-gray-900 dark:text-white">{method.signature}</div>
<div className="mt-1 flex flex-wrap gap-2">
<EntityBadge label={method.stateMutability} tone="success" />
{method.outputs[0]?.type ? <EntityBadge label={`returns ${method.outputs[0].type}`} tone="info" className="normal-case tracking-normal" /> : null}
{method.inputs.length > 0 ? <EntityBadge label="inputs required" tone="warning" /> : null}
</div>
</div>
{supportsQuickCall ? (
<button
type="button"
onClick={() => void handleReadMethod(method)}
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
{methodState?.loading ? 'Calling...' : 'Call'}
</button>
) : (
<span className="text-xs text-gray-500 dark:text-gray-400">Use ABI externally for parameterized reads</span>
)}
</div>
{method.inputs.length > 0 ? (
<div className="mt-3 grid gap-2">
{method.inputs.map((input, index) => (
<label key={`${method.signature}-${index}`} className="block">
<span className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">
{input.name || `arg${index + 1}`} · {input.type}
</span>
<input
type="text"
value={inputValues[index] || ''}
onChange={(event) => handleMethodInputChange(method.signature, index, event.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
</label>
))}
</div>
) : null}
{methodState?.value ? (
<code className="mt-3 block break-all rounded bg-white p-2 text-xs text-gray-900 dark:bg-gray-950 dark:text-gray-100">
{methodState.value}
</code>
) : null}
{methodState?.error ? (
<div className="mt-3 text-xs text-red-600 dark:text-red-300">{methodState.error}</div>
) : null}
</div>
)
})}
{contractProfile.read_methods.length > 8 ? (
<div className="text-xs text-gray-500 dark:text-gray-400">
Showing the first 8 read methods here for sanity. Full ABI preview remains available below.
</div>
) : null}
</div>
</DetailRow>
)}
{contractProfile?.write_methods && contractProfile.write_methods.length > 0 && (
<DetailRow label="Write Methods">
<div className="space-y-2">
{contractProfile.write_methods.slice(0, 6).map((method) => (
<div key={method.signature} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="font-mono text-gray-900 dark:text-white">{method.signature}</div>
<div className="mt-2 flex flex-wrap gap-2">
<EntityBadge label={method.stateMutability} tone="warning" />
{method.inputs.length > 0 ? <EntityBadge label={`${method.inputs.length} inputs`} /> : null}
</div>
{method.inputs.length > 0 ? (
<div className="mt-3 grid gap-2">
{method.inputs.map((input, index) => (
<label key={`${method.signature}-${index}`} className="block">
<span className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">
{input.name || `arg${index + 1}`} · {input.type}
</span>
<input
type="text"
value={(methodInputs[method.signature] || [])[index] || ''}
onChange={(event) => handleMethodInputChange(method.signature, index, event.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
</label>
))}
</div>
) : null}
{contractsApi.supportsSimpleWriteCall(method) ? (
<div className="mt-3 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => void handleWriteMethod(method)}
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
{methodResults[method.signature]?.loading ? 'Awaiting wallet...' : 'Send with wallet'}
</button>
<code className="text-xs text-gray-500 dark:text-gray-400">
Wallet confirmation required
</code>
</div>
) : (
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
This method uses input types the explorer does not encode directly yet. Use the ABI preview with an external contract UI if needed.
</div>
)}
{methodResults[method.signature]?.value ? (
<code className="mt-3 block break-all rounded bg-white p-2 text-xs text-gray-900 dark:bg-gray-950 dark:text-gray-100">
{methodResults[method.signature]?.value}
</code>
) : null}
{methodResults[method.signature]?.error ? (
<div className="mt-3 text-xs text-red-600 dark:text-red-300">{methodResults[method.signature]?.error}</div>
) : null}
</div>
))}
<div className="text-xs text-gray-500 dark:text-gray-400">
Write methods are surfaced for inspection here, but actual state-changing execution still belongs behind a wallet-confirmed contract interaction flow.
</div>
</div>
</DetailRow>
)}
{contractProfile?.creation_bytecode && (
<DetailRow label="Creation Bytecode">
<code className="block break-all rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.creation_bytecode}
</code>
</DetailRow>
)}
{contractProfile?.deployed_bytecode && (
<DetailRow label="Runtime Bytecode">
<code className="block break-all rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.deployed_bytecode}
</code>
</DetailRow>
)}
</dl>
</Card>
)}
{gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
<Card title="Token Balances" className="mb-6">
{gruBalanceCount > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>{gruBalanceCount} visible token balance{gruBalanceCount === 1 ? '' : 's'} look GRU-aware.</span>
<Link href="/docs/gru" className="text-primary-600 hover:underline">
GRU guide
</Link>
</div>
) : null}
<Table
columns={tokenBalanceColumns}
data={tokenBalances}
emptyMessage="No token balances were indexed for this address."
keyExtractor={(balance) => balance.token_address || `${balance.token_symbol}-${balance.value}`}
/>
</Card>
<Card title="Recent Token Transfers" className="mb-6">
{gruTransferCount > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>{gruTransferCount} recent transfer asset{gruTransferCount === 1 ? '' : 's'} carry GRU posture in the explorer.</span>
<Link href="/docs/gru" className="text-primary-600 hover:underline">
GRU guide
</Link>
<Link href="/docs/transaction-review" className="text-primary-600 hover:underline">
Transaction review guide
</Link>
</div>
) : null}
<Table
columns={tokenTransferColumns}
data={tokenTransfers}
emptyMessage="No token transfers were found for this address."
keyExtractor={(transfer) => `${transfer.transaction_hash}-${transfer.token_address}-${transfer.value}`}
/>
</Card>
<Card title="Transactions"> <Card title="Transactions">
<Table <Table
columns={transactionColumns} columns={transactionColumns}

View File

@@ -1,39 +1,65 @@
'use client' import type { GetServerSideProps } from 'next'
import Link from 'next/link' import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Card, Address } from '@/libs/frontend-ui-primitives' import { Card, Address } from '@/libs/frontend-ui-primitives'
import { transactionsApi, Transaction } from '@/services/api/transactions' import { transactionsApi, Transaction } from '@/services/api/transactions'
import { readWatchlistFromStorage } from '@/utils/watchlist' import { readWatchlistFromStorage } from '@/utils/watchlist'
import PageIntro from '@/components/common/PageIntro'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeTransaction } from '@/services/api/blockscout'
function normalizeAddress(value: string) { function normalizeAddress(value: string) {
const trimmed = value.trim() const trimmed = value.trim()
return /^0x[a-fA-F0-9]{40}$/.test(trimmed) ? trimmed : '' return /^0x[a-fA-F0-9]{40}$/.test(trimmed) ? trimmed : ''
} }
export default function AddressesPage() { interface AddressesPageProps {
initialRecentTransactions: Transaction[]
}
function serializeRecentTransactions(transactions: Transaction[]): Transaction[] {
return JSON.parse(
JSON.stringify(
transactions.map((transaction) => ({
hash: transaction.hash,
block_number: transaction.block_number,
from_address: transaction.from_address,
to_address: transaction.to_address ?? null,
})),
),
) as Transaction[]
}
export default function AddressesPage({ initialRecentTransactions }: AddressesPageProps) {
const router = useRouter() const router = useRouter()
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>([]) const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
const [watchlist, setWatchlist] = useState<string[]>([]) const [watchlist, setWatchlist] = useState<string[]>([])
useEffect(() => { useEffect(() => {
if (initialRecentTransactions.length > 0) {
setRecentTransactions(initialRecentTransactions)
return
}
let active = true let active = true
transactionsApi.listSafe(chainId, 1, 20).then(({ ok, data }) => { transactionsApi.listSafe(chainId, 1, 20)
if (active && ok) { .then(({ ok, data }) => {
setRecentTransactions(data) if (active && ok) {
} setRecentTransactions(data)
}).catch(() => { }
if (active) { })
setRecentTransactions([]) .catch(() => {
} if (active) {
}) setRecentTransactions([])
}
})
return () => { return () => {
active = false active = false
} }
}, [chainId]) }, [chainId, initialRecentTransactions])
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -74,7 +100,16 @@ export default function AddressesPage() {
return ( return (
<div className="container mx-auto px-4 py-6 sm:py-8"> <div className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-6 text-3xl font-bold">Addresses</h1> <PageIntro
eyebrow="Address Discovery"
title="Addresses"
description="Open any Chain 138 address directly, revisit saved watchlist entries, or branch into recent activity discovered from indexed transactions."
actions={[
{ href: '/watchlist', label: 'Open watchlist' },
{ href: '/transactions', label: 'Recent transactions' },
{ href: '/search', label: 'Search explorer' },
]}
/>
<Card className="mb-6" title="Open An Address"> <Card className="mb-6" title="Open An Address">
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row"> <form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
@@ -139,3 +174,17 @@ export default function AddressesPage() {
</div> </div>
) )
} }
export const getServerSideProps: GetServerSideProps<AddressesPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null)
const initialRecentTransactions = Array.isArray(transactionsResult?.items)
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
: []
return {
props: {
initialRecentTransactions: serializeRecentTransactions(initialRecentTransactions),
},
}
}

View File

@@ -1,9 +1,102 @@
import dynamic from 'next/dynamic' import type { GetServerSideProps } from 'next'
import AnalyticsOperationsPage from '@/components/explorer/AnalyticsOperationsPage'
import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout'
import {
normalizeExplorerStats,
normalizeTransactionTrend,
summarizeRecentTransactions,
type ExplorerRecentActivitySnapshot,
type ExplorerStats,
type ExplorerTransactionTrendPoint,
} from '@/services/api/stats'
import type { Block } from '@/services/api/blocks'
import type { Transaction } from '@/services/api/transactions'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { fetchPublicJson } from '@/utils/publicExplorer'
const AnalyticsOperationsPage = dynamic(() => import('@/components/explorer/AnalyticsOperationsPage'), { interface AnalyticsPageProps {
ssr: false, initialStats: ExplorerStats | null
}) initialTransactionTrend: ExplorerTransactionTrendPoint[]
initialActivitySnapshot: ExplorerRecentActivitySnapshot | null
export default function AnalyticsPage() { initialBlocks: Block[]
return <AnalyticsOperationsPage /> initialTransactions: Transaction[]
initialBridgeStatus: MissionControlBridgeStatusResponse | null
}
function serializeBlocks(blocks: Block[]): Block[] {
return JSON.parse(
JSON.stringify(
blocks.map((block) => ({
chain_id: block.chain_id,
number: block.number,
hash: block.hash,
timestamp: block.timestamp,
miner: block.miner,
gas_used: block.gas_used,
gas_limit: block.gas_limit,
transaction_count: block.transaction_count,
})),
),
) as Block[]
}
function serializeTransactions(transactions: Transaction[]): Transaction[] {
return JSON.parse(
JSON.stringify(
transactions.map((transaction) => ({
hash: transaction.hash,
block_number: transaction.block_number,
from_address: transaction.from_address,
to_address: transaction.to_address ?? null,
value: transaction.value,
status: transaction.status ?? null,
contract_address: transaction.contract_address ?? null,
fee: transaction.fee ?? null,
})),
),
) as Transaction[]
}
export default function AnalyticsPage(props: AnalyticsPageProps) {
return <AnalyticsOperationsPage {...props} />
}
export const getServerSideProps: GetServerSideProps<AnalyticsPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [statsResult, trendResult, activityResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
fetchPublicJson('/api/v2/stats'),
fetchPublicJson('/api/v2/stats/charts/transactions'),
fetchPublicJson('/api/v2/main-page/transactions'),
fetchPublicJson('/api/v2/blocks?page=1&page_size=5'),
fetchPublicJson('/api/v2/transactions?page=1&page_size=5'),
fetchPublicJson('/explorer-api/v1/track1/bridge/status'),
])
return {
props: {
initialStats: statsResult.status === 'fulfilled' ? normalizeExplorerStats(statsResult.value as never) : null,
initialTransactionTrend:
trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value as never) : [],
initialActivitySnapshot:
activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value as never) : null,
initialBlocks:
blocksResult.status === 'fulfilled' && Array.isArray((blocksResult.value as { items?: unknown[] })?.items)
? serializeBlocks(
((blocksResult.value as { items?: unknown[] }).items || []).map((item) =>
normalizeBlock(item as never, chainId),
),
)
: [],
initialTransactions:
transactionsResult.status === 'fulfilled' && Array.isArray((transactionsResult.value as { items?: unknown[] })?.items)
? serializeTransactions(
((transactionsResult.value as { items?: unknown[] }).items || []).map((item) =>
normalizeTransaction(item as never, chainId),
),
)
: [],
initialBridgeStatus:
bridgeResult.status === 'fulfilled' ? (bridgeResult.value as MissionControlBridgeStatusResponse) : null,
},
}
} }

View File

@@ -6,6 +6,8 @@ import { blocksApi, Block } from '@/services/api/blocks'
import { Card, Address } from '@/libs/frontend-ui-primitives' import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link' import Link from 'next/link'
import { DetailRow } from '@/components/common/DetailRow' import { DetailRow } from '@/components/common/DetailRow'
import PageIntro from '@/components/common/PageIntro'
import { formatTimestamp } from '@/utils/format'
export default function BlockDetailPage() { export default function BlockDetailPage() {
const router = useRouter() const router = useRouter()
@@ -41,9 +43,22 @@ export default function BlockDetailPage() {
loadBlock() loadBlock()
}, [isValidBlock, loadBlock, router.isReady]) }, [isValidBlock, loadBlock, router.isReady])
const gasUtilization = block && block.gas_limit > 0
? Math.round((block.gas_used / block.gas_limit) * 100)
: null
return ( return (
<div className="container mx-auto px-4 py-6 sm:py-8"> <div className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-6 text-3xl font-bold">{block ? `Block #${block.number}` : 'Block'}</h1> <PageIntro
eyebrow="Block Detail"
title={block ? `Block #${block.number}` : 'Block'}
description="Inspect a single Chain 138 block, then move into its related miner address, adjacent block numbers, or broader explorer search flows."
actions={[
{ href: '/blocks', label: 'All blocks' },
{ href: '/transactions', label: 'Recent transactions' },
{ href: '/search', label: 'Search explorer' },
]}
/>
<div className="mb-6 flex flex-wrap gap-3 text-sm"> <div className="mb-6 flex flex-wrap gap-3 text-sm">
<Link href="/blocks" className="text-primary-600 hover:underline"> <Link href="/blocks" className="text-primary-600 hover:underline">
@@ -68,10 +83,26 @@ export default function BlockDetailPage() {
) : !isValidBlock ? ( ) : !isValidBlock ? (
<Card> <Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Invalid block number. Please use a valid block number from the URL.</p> <p className="text-sm text-gray-600 dark:text-gray-400">Invalid block number. Please use a valid block number from the URL.</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/blocks" className="text-primary-600 hover:underline">
Back to blocks
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search by block number
</Link>
</div>
</Card> </Card>
) : !block ? ( ) : !block ? (
<Card> <Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Block not found.</p> <p className="text-sm text-gray-600 dark:text-gray-400">Block not found.</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/blocks" className="text-primary-600 hover:underline">
Browse recent blocks
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search the explorer
</Link>
</div>
</Card> </Card>
) : ( ) : (
<Card title="Block Information"> <Card title="Block Information">
@@ -80,7 +111,7 @@ export default function BlockDetailPage() {
<Address address={block.hash} /> <Address address={block.hash} />
</DetailRow> </DetailRow>
<DetailRow label="Timestamp"> <DetailRow label="Timestamp">
{new Date(block.timestamp).toLocaleString()} {formatTimestamp(block.timestamp)}
</DetailRow> </DetailRow>
<DetailRow label="Miner"> <DetailRow label="Miner">
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline"> <Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
@@ -88,13 +119,18 @@ export default function BlockDetailPage() {
</Link> </Link>
</DetailRow> </DetailRow>
<DetailRow label="Transactions"> <DetailRow label="Transactions">
<Link href="/transactions" className="text-primary-600 hover:underline"> <Link href={`/search?q=${block.number}`} className="text-primary-600 hover:underline">
{block.transaction_count} {block.transaction_count}
</Link> </Link>
</DetailRow> </DetailRow>
<DetailRow label="Gas Used"> <DetailRow label="Gas Used">
{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()} {block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}
</DetailRow> </DetailRow>
{gasUtilization != null && (
<DetailRow label="Gas Utilization">
{gasUtilization}%
</DetailRow>
)}
</dl> </dl>
</Card> </Card>
)} )}

View File

@@ -1,14 +1,21 @@
'use client' import type { GetServerSideProps } from 'next'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { blocksApi, Block } from '@/services/api/blocks' import { blocksApi, Block } from '@/services/api/blocks'
import { Card, Address } from '@/libs/frontend-ui-primitives' import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link' import Link from 'next/link'
import PageIntro from '@/components/common/PageIntro'
import { formatTimestamp } from '@/utils/format'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeBlock } from '@/services/api/blockscout'
export default function BlocksPage() { interface BlocksPageProps {
initialBlocks: Block[]
}
export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
const pageSize = 20 const pageSize = 20
const [blocks, setBlocks] = useState<Block[]>([]) const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(initialBlocks.length === 0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
@@ -32,15 +39,29 @@ export default function BlocksPage() {
}, [chainId, page, pageSize]) }, [chainId, page, pageSize])
useEffect(() => { useEffect(() => {
loadBlocks() if (page === 1 && initialBlocks.length > 0) {
}, [loadBlocks]) setBlocks(initialBlocks)
setLoading(false)
return
}
void loadBlocks()
}, [initialBlocks, loadBlocks, page])
const showPagination = page > 1 || blocks.length > 0 const showPagination = page > 1 || blocks.length > 0
const canGoNext = blocks.length === pageSize const canGoNext = blocks.length === pageSize
return ( return (
<div className="container mx-auto px-4 py-6 sm:py-8"> <div className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-6 text-3xl font-bold">Blocks</h1> <PageIntro
eyebrow="Chain Activity"
title="Blocks"
description="Browse recent Chain 138 blocks, then pivot into transactions, addresses, and indexed search without falling into a dead end."
actions={[
{ href: '/transactions', label: 'Open transactions' },
{ href: '/addresses', label: 'Browse addresses' },
{ href: '/search', label: 'Search explorer' },
]}
/>
{loading ? ( {loading ? (
<Card> <Card>
@@ -51,6 +72,14 @@ export default function BlocksPage() {
{blocks.length === 0 ? ( {blocks.length === 0 ? (
<Card> <Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Recent blocks are unavailable right now.</p> <p className="text-sm text-gray-600 dark:text-gray-400">Recent blocks are unavailable right now.</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/transactions" className="text-primary-600 hover:underline">
Open recent transactions
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search by block number
</Link>
</div>
</Card> </Card>
) : ( ) : (
blocks.map((block) => ( blocks.map((block) => (
@@ -66,10 +95,16 @@ export default function BlocksPage() {
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400"> <div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
<Address address={block.hash} truncate showCopy={false} /> <Address address={block.hash} truncate showCopy={false} />
</div> </div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Miner:{' '}
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
<Address address={block.miner} truncate showCopy={false} />
</Link>
</div>
</div> </div>
<div className="text-left sm:text-right"> <div className="text-left sm:text-right">
<div className="text-sm"> <div className="text-sm">
{new Date(block.timestamp).toLocaleString()} {formatTimestamp(block.timestamp)}
</div> </div>
<div className="text-sm text-gray-600 dark:text-gray-400"> <div className="text-sm text-gray-600 dark:text-gray-400">
{block.transaction_count} transactions {block.transaction_count} transactions
@@ -101,6 +136,38 @@ export default function BlocksPage() {
</button> </button>
</div> </div>
)} )}
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<Card title="Keep Exploring">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Need a different entry point? Open transaction flow, search directly by block number, or jump into recently active addresses.
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/transactions" className="text-primary-600 hover:underline">
Transactions
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Addresses
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search
</Link>
</div>
</Card>
</div>
</div> </div>
) )
} }
export const getServerSideProps: GetServerSideProps<BlocksPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const blocksResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null)
return {
props: {
initialBlocks: Array.isArray(blocksResult?.items)
? blocksResult.items.map((item) => normalizeBlock(item as never, chainId))
: [],
},
}
}

View File

@@ -1,9 +1,24 @@
import dynamic from 'next/dynamic' import type { GetStaticProps } from 'next'
import BridgeMonitoringPage from '@/components/explorer/BridgeMonitoringPage'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { fetchPublicJson } from '@/utils/publicExplorer'
const BridgeMonitoringPage = dynamic(() => import('@/components/explorer/BridgeMonitoringPage'), { interface BridgePageProps {
ssr: false, initialBridgeStatus: MissionControlBridgeStatusResponse | null
}) }
export default function BridgePage() { export default function BridgePage(props: BridgePageProps) {
return <BridgeMonitoringPage /> return <BridgeMonitoringPage {...props} />
}
export const getStaticProps: GetStaticProps<BridgePageProps> = async () => {
const bridgeResult = await fetchPublicJson<MissionControlBridgeStatusResponse>(
'/explorer-api/v1/track1/bridge/status'
).catch(() => null)
return {
props: {
initialBridgeStatus: bridgeResult,
},
}
} }

View File

@@ -0,0 +1,134 @@
'use client'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
import EntityBadge from '@/components/common/EntityBadge'
export default function GruDocsPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
eyebrow="Explorer Documentation"
title="GRU Guide"
description="A user-facing summary of the GRU standards, transport posture, and x402 readiness model, with concrete places to inspect those signals on live token, address, and search pages."
actions={[
{ href: '/tokens', label: 'Browse tokens' },
{ href: '/search?q=cUSDC', label: 'Search cUSDC' },
{ href: '/search?q=cUSDT', label: 'Search cUSDT' },
]}
/>
<div className="space-y-6">
<Card title="What The Explorer Is Showing You">
<div className="space-y-4 text-sm text-gray-600 dark:text-gray-400">
<p>
The explorer now distinguishes between canonical GRU money surfaces on Chain 138 and wrapped transport assets used on public-chain bridge lanes.
It also highlights when a token looks ready for x402-style payment flows.
</p>
<p>
You can inspect these signals directly on live examples such as
{' '}<Link href="/tokens/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22" className="text-primary-600 hover:underline">cUSDT</Link>,
{' '}<Link href="/tokens/0xf22258f57794CC8E06237084b353Ab30fFfa640b" className="text-primary-600 hover:underline">cUSDC</Link>,
and related GRU-aware search results under
{' '}<Link href="/search?q=cUSDT" className="text-primary-600 hover:underline">search</Link>.
</p>
<p>
A practical verification path is: open a token page, confirm the GRU standards card, check the x402 and ISO-20022 posture badges,
inspect the sibling-network entries under <strong>Other Networks</strong>, and then pivot into a related transaction to see how
GRU-aware transfers are labeled in the transaction evidence flow.
</p>
<div className="flex flex-wrap gap-2">
<EntityBadge label="GRU" tone="success" />
<EntityBadge label="x402 ready" tone="info" />
<EntityBadge label="forward canonical" tone="success" />
<EntityBadge label="wrapped" tone="warning" />
</div>
</div>
</Card>
<Card title="Standards Summary">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="font-medium text-gray-900 dark:text-white">Base token profile</div>
<div className="mt-2 text-gray-600 dark:text-gray-400">
Canonical GRU v2 base tokens are expected to expose ERC-20, AccessControl, Pausable, EIP-712, ERC-2612, ERC-3009,
ERC-5267, deterministic storage namespacing, and governance/supervision metadata.
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="font-medium text-gray-900 dark:text-white">x402 readiness</div>
<div className="mt-2 text-gray-600 dark:text-gray-400">
In explorer terms, x402 readiness means the contract exposes an EIP-712 domain plus ERC-5267 domain introspection and
at least one signed payment surface such as ERC-2612 permit or ERC-3009 authorization transfers.
</div>
</div>
</div>
</Card>
<Card title="Example Explorer Surfaces">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="font-medium text-gray-900 dark:text-white">Token detail</div>
<div className="mt-2">
Open <Link href="/tokens/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22" className="text-primary-600 hover:underline">cUSDT</Link> or
{' '}<Link href="/tokens/0xf22258f57794CC8E06237084b353Ab30fFfa640b" className="text-primary-600 hover:underline">cUSDC</Link>
{' '}to inspect the GRU standards card, x402 posture, ISO-20022 posture, and sibling-network mappings.
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="font-medium text-gray-900 dark:text-white">Search</div>
<div className="mt-2">
Use <Link href="/search?q=cUSDT" className="text-primary-600 hover:underline">search for cUSDT</Link> to verify that direct token
matches and curated posture cues are visible on first paint.
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="font-medium text-gray-900 dark:text-white">Transactions</div>
<div className="mt-2">
Open any recent transfer from the token page and look for GRU-aware transfer badges and the transaction evidence matrix on the transaction detail page.
</div>
</div>
</div>
</Card>
<Card title="Chain 138 Practical Reading">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
A token can be forward-canonical and x402-ready even while older liquidity or transport lanes still run on a prior version.
That is why the explorer separates active liquidity posture from forward-canonical posture.
</p>
<p>
The most important live examples today are the USD family promotions where the V2 contracts are the preferred payment and future-canonical surface,
while some V1 liquidity still coexists operationally.
</p>
<p>
On token pages, look for the GRU standards card, x402 posture badges, ISO-20022 badges, and sibling-network references. On transaction pages,
look for GRU-aware transfer badges and the transaction evidence matrix.
</p>
</div>
</Card>
<Card title="Next Places To Look">
<div className="flex flex-wrap gap-3 text-sm">
<Link href="/search" className="text-primary-600 hover:underline">
Search the explorer
</Link>
<Link href="/tokens" className="text-primary-600 hover:underline">
Inspect token pages
</Link>
<Link href="/docs/transaction-review" className="text-primary-600 hover:underline">
Transaction review guide
</Link>
<Link href="/transactions" className="text-primary-600 hover:underline">
Check transaction transfers
</Link>
<Link href="/docs" className="text-primary-600 hover:underline">
General documentation
</Link>
</div>
</Card>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More