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
echo "=========================================="
echo " SolaceScanScout Deployment"
echo " SolaceScan Deployment"
echo "=========================================="
echo ""
@@ -140,4 +140,3 @@ echo " 3. Monitor: tail -f backend/logs/api-server.log"
echo ""
unset PGPASSWORD

View File

@@ -1,4 +1,4 @@
# SolaceScanScout Explorer - Tiered Architecture
# SolaceScan Explorer - Tiered Architecture
## 🚀 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).
- **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.
- **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

View File

@@ -1,7 +1,7 @@
# Testing Guide
## 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)

View File

@@ -78,7 +78,7 @@ func (g *Gateway) handleRequest(proxy *httputil.ReverseProxy) http.HandlerFunc {
}
// 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")
// 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) {
// Content Security Policy
// 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)
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
- `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
- `transactions.go` - Transaction-related endpoints
- `addresses.go` - Address-related endpoints
@@ -17,6 +18,12 @@ REST API implementation for the ChainID 138 Explorer Platform.
## 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
- `GET /api/v1/blocks` - List blocks (paginated)
- `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/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
- `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
- Error handling with consistent error format
- 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
@@ -85,6 +112,66 @@ Set environment variables:
- `OPERATOR_SCRIPTS_ROOT` - Root directory for allowlisted Track 4 scripts
- `OPERATOR_SCRIPT_ALLOWLIST` - Comma-separated list of permitted script names or relative paths
- `OPERATOR_SCRIPT_TIMEOUT_SEC` - Optional Track 4 script timeout in seconds (max 599)
- `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

View File

@@ -241,7 +241,7 @@ func (s *Server) buildAIContext(ctx context.Context, query string, pageContext m
warnings := []string{}
envelope := AIContextEnvelope{
ChainID: s.chainID,
Explorer: "SolaceScanScout",
Explorer: "SolaceScan",
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.",
}
@@ -899,7 +899,7 @@ func (s *Server) callXAIChatCompletions(ctx context.Context, messages []AIChatMe
contextJSON, _ := json.MarshalIndent(contextEnvelope, "", " ")
contextText := clipString(string(contextJSON), maxExplorerAIContextChars)
baseSystem := "You are the SolaceScanScout ecosystem assistant for Chain 138. Answer using the supplied indexed explorer data, route inventory, and workspace documentation. Be concise, operationally useful, and explicit about uncertainty. Never claim a route, deployment, or production status is live unless the provided context says it is live. If data is missing, say exactly what is missing."
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() {
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"])
}
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) {
_, mux := setupTestServer(t)

View File

@@ -3,9 +3,16 @@ package rest
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/explorer/backend/auth"
"github.com/golang-jwt/jwt/v4"
)
// 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")
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,
"patch": 0
},
"generatedBy": "SolaceScanScout",
"generatedBy": "SolaceScan",
"timestamp": "2026-03-28T00:00:00Z",
"chainId": 138,
"chainName": "DeFi Oracle Meta Mainnet",

View File

@@ -4,9 +4,9 @@
"defaultChainId": 138,
"explorerUrl": "https://explorer.d-bis.org",
"tokenListUrl": "https://explorer.d-bis.org/api/config/token-list",
"generatedBy": "SolaceScanScout",
"generatedBy": "SolaceScan",
"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":"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"]},

View File

@@ -52,6 +52,18 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
// Auth endpoints
mux.HandleFunc("/api/v1/auth/nonce", s.handleAuthNonce)
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)
// Note: Track 1 endpoints should be registered with OptionalAuth middleware

View File

@@ -22,6 +22,7 @@ import (
type Server struct {
db *pgxpool.Pool
chainID int
userAuth *auth.Auth
walletAuth *auth.WalletAuth
jwtSecret []byte
aiLimiter *AIRateLimiter
@@ -42,6 +43,7 @@ func NewServer(db *pgxpool.Pool, chainID int) *Server {
return &Server{
db: db,
chainID: chainID,
userAuth: auth.NewAuth(db),
walletAuth: walletAuth,
jwtSecret: jwtSecret,
aiLimiter: NewAIRateLimiter(),
@@ -74,7 +76,7 @@ func (s *Server) Start(port int) error {
// Security headers (reusable lib; CSP from env or explorer default)
csp := os.Getenv("CSP_HEADER")
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)
@@ -90,7 +92,7 @@ func (s *Server) Start(port int) error {
)
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)")
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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 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-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/") {
origin := os.Getenv("CORS_ALLOWED_ORIGIN")
if origin == "" {
@@ -224,7 +226,7 @@ func (s *Server) handleListBlocks(w http.ResponseWriter, r *http.Request) {
// handleHealth handles GET /health
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
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")
// Check database connection
@@ -248,7 +250,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
},
"chain_id": s.chainID,
"explorer": map[string]string{
"name": "SolaceScanScout",
"name": "SolaceScan",
"version": "1.0.0",
},
}

View File

@@ -1,8 +1,8 @@
openapi: 3.0.3
info:
title: SolaceScanScout API
title: SolaceScan API
description: |
Blockchain Explorer API for ChainID 138 with tiered access control.
SolaceScan public explorer API for Chain 138 with tiered access control.
## Authentication
@@ -31,6 +31,10 @@ servers:
tags:
- name: Health
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
description: Block-related endpoints
- name: Transactions
@@ -76,6 +80,542 @@ paths:
type: string
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:
get:
tags:
@@ -272,7 +812,7 @@ paths:
'400':
$ref: '#/components/responses/BadRequest'
'503':
description: `TOKEN_AGGREGATION_BASE_URL` not configured
description: "`TOKEN_AGGREGATION_BASE_URL` not configured"
/api/v1/mission-control/bridge/trace:
get:
@@ -317,7 +857,7 @@ paths:
properties:
script:
type: string
description: Path relative to `OPERATOR_SCRIPTS_ROOT`
description: "Path relative to `OPERATOR_SCRIPTS_ROOT`"
args:
type: array
items:
@@ -363,8 +903,413 @@ components:
scheme: bearer
bearerFormat: JWT
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:
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:
type: object
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("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)
defer tick.Stop()

View File

@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
@@ -19,6 +20,45 @@ type runScriptRequest struct {
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
// Requires Track 4 auth, IP whitelist, OPERATOR_SCRIPTS_ROOT, and OPERATOR_SCRIPT_ALLOWLIST.
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.Clean(filepath.ToSlash(relPath))
allowed := false
base := filepath.Base(relPath)
for _, a := range allow {
if a == relPath || a == base || filepath.Clean(a) == relPath {
normalizedAllow := filepath.Clean(filepath.ToSlash(a))
if normalizedAllow == relPath {
allowed = true
break
}
@@ -143,7 +184,9 @@ func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) {
} else {
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.Stderr = &stderr
runErr := cmd.Run()
@@ -176,15 +219,19 @@ func (s *Server) HandleRunScript(w http.ResponseWriter, r *http.Request) {
"timed_out": timedOut,
"stdout_bytes": stdout.Len(),
"stderr_bytes": stderr.Len(),
"stdout_truncated": stdout.truncated,
"stderr_truncated": stderr.truncated,
}, ipAddr, r.UserAgent())
resp := map[string]interface{}{
"data": map[string]interface{}{
"script": relPath,
"exit_code": exit,
"stdout": strings.TrimSpace(stdout.String()),
"stderr": strings.TrimSpace(stderr.String()),
"timed_out": timedOut,
"script": relPath,
"exit_code": exit,
"stdout": strings.TrimSpace(stdout.String()),
"stderr": strings.TrimSpace(stderr.String()),
"timed_out": timedOut,
"stdout_truncated": stdout.truncated,
"stderr_truncated": stderr.truncated,
},
}
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.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
}
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
func (a *Auth) RegisterUser(ctx context.Context, email, username, password string) (*User, error) {
// Hash password
@@ -76,11 +225,17 @@ func (a *Auth) AuthenticateUser(ctx context.Context, email, password string) (*U
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
}
// GenerateAPIKey generates a new API key for a user
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
keyBytes := make([]byte, 32)
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
}
var expiresAt *time.Time
if expiresDays > 0 {
expires := time.Now().Add(time.Duration(expiresDays) * 24 * time.Hour)
expiresAt = &expires
}
// Store API key
query := `
INSERT INTO api_keys (user_id, key_hash, name, tier, rate_limit_per_second, rate_limit_per_minute)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO api_keys (
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 {
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[:])
var userID string
var revoked bool
query := `SELECT user_id, revoked FROM api_keys WHERE key_hash = $1`
err := a.db.QueryRow(ctx, query, hashedKeyHex).Scan(&userID, &revoked)
var revoked, approved bool
var expiresAt *time.Time
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 {
return "", fmt.Errorf("invalid API key")
@@ -141,6 +306,12 @@ func (a *Auth) ValidateAPIKey(ctx context.Context, apiKey string) (string, error
if 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
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
}
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
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))
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:
- [ ] `explorer-indexer.service`
- [ ] `explorer-api.service`
- [ ] `explorer-frontend.service`
- [ ] `solacescanscout-frontend.service`
- [ ] Services enabled
- [ ] Services started
- [ ] Service status verified
@@ -201,4 +201,3 @@ _Use this space for deployment-specific notes and issues encountered._
**Deployed By**: _______________
**Container ID**: _______________
**Domain**: explorer.d-bis.org

View File

@@ -477,24 +477,26 @@ EOF
#### Frontend Service
```bash
cat > /etc/systemd/system/explorer-frontend.service << 'EOF'
cat > /etc/systemd/system/solacescanscout-frontend.service << 'EOF'
[Unit]
Description=Explorer Frontend Service
Description=SolaceScan Next Frontend Service
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
User=www-data
Group=www-data
WorkingDirectory=/opt/solacescanscout/frontend/current
Environment=NODE_ENV=production
Environment=HOSTNAME=127.0.0.1
Environment=PORT=3000
ExecStart=/usr/bin/node /opt/solacescanscout/frontend/current/server.js
Restart=always
RestartSec=10
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=explorer-frontend
SyslogIdentifier=solacescanscout-frontend
[Install]
WantedBy=multi-user.target
@@ -510,17 +512,17 @@ systemctl daemon-reload
# Enable services
systemctl enable explorer-indexer
systemctl enable explorer-api
systemctl enable explorer-frontend
systemctl enable solacescanscout-frontend
# Start services
systemctl start explorer-indexer
systemctl start explorer-api
systemctl start explorer-frontend
systemctl start solacescanscout-frontend
# Check status
systemctl status explorer-indexer
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
sharedscripts
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
}
EOF
@@ -1079,4 +1081,3 @@ journalctl -u cloudflared -f
**Last Updated**: 2024-12-23
**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).
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:
| 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-e2e.sh`](../../scripts/verify/check-explorer-e2e.sh)
- `https://explorer.d-bis.org/api/config/capabilities`
- `https://explorer.d-bis.org/explorer-api/v1/track1/bridge/status`
- `https://explorer.d-bis.org/explorer-api/v1/mission-control/stream`
- [`scripts/verify-explorer-access-edge-hook.sh`](../scripts/verify-explorer-access-edge-hook.sh)
- `https://blockscout.defi-oracle.io/api/config/capabilities`
- `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
@@ -35,6 +40,6 @@ These files remain in the repo, but they describe an older generalized package:
- `DEPLOYMENT_CHECKLIST.md`
- `QUICK_DEPLOY.md`
- `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.

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
- [ ] Create `/etc/systemd/system/explorer-indexer.service`
- [ ] Create `/etc/systemd/system/explorer-api.service`
- [ ] Create `/etc/systemd/system/explorer-frontend.service`
- [ ] Set proper ownership: `chown root:root /etc/systemd/system/explorer-*.service`
- [ ] Set proper permissions: `chmod 644 /etc/systemd/system/explorer-*.service`
- [ ] Create `/etc/systemd/system/solacescanscout-frontend.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 /etc/systemd/system/solacescanscout-frontend.service`
#### Task 22: Enable and Start Services
- [ ] Reload systemd: `systemctl daemon-reload`
- [ ] Enable indexer: `systemctl enable explorer-indexer`
- [ ] 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 API: `systemctl start explorer-api`
- [ ] Start frontend: `systemctl start explorer-frontend`
- [ ] Start frontend: `systemctl start solacescanscout-frontend`
#### Task 23: Verify Services
- [ ] Check indexer status: `systemctl status explorer-indexer`
- [ ] 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 API logs: `journalctl -u explorer-api -f`
- [ ] Check frontend logs: `journalctl -u solacescanscout-frontend -f`
- [ ] Verify API responds: `curl http://localhost:8080/health`
- [ ] 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
**Version**: 1.0.0

View File

@@ -110,6 +110,8 @@ SOUL_MACHINES_API_SECRET=
CORS_ALLOWED_ORIGIN=
JWT_SECRET=CHANGE_THIS_JWT_SECRET
ENCRYPTION_KEY=CHANGE_THIS_ENCRYPTION_KEY_32_BYTES
ACCESS_ADMIN_EMAILS=
ACCESS_INTERNAL_SECRET=CHANGE_THIS_INTERNAL_ACCESS_SECRET
# ============================================
# Monitoring (Optional)
@@ -126,4 +128,3 @@ ENABLE_WEBSOCKET=true
ENABLE_ANALYTICS=true
ENABLE_VTM=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_CHECKLIST.md` | Interactive deployment checklist | 204 |
| `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 | - |
| `README.md` | Documentation overview | - |
| `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-health-check.sh` | Setup health monitoring | ✅ |
| `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 | ✅ |
## ⚙️ Configuration Files
### Nginx
- `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/tunnel-config.yml` - Cloudflare Tunnel configuration template
@@ -41,7 +46,7 @@ Complete index of all deployment files and their purposes.
### Systemd Services
- `systemd/explorer-indexer.service` - Indexer 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
### Fail2ban
@@ -125,8 +130,8 @@ deployment/
# Install services
sudo ./deployment/scripts/install-services.sh
sudo systemctl enable explorer-indexer explorer-api explorer-frontend
sudo systemctl start explorer-indexer explorer-api explorer-frontend
sudo systemctl enable explorer-indexer explorer-api solacescanscout-frontend
sudo systemctl start explorer-indexer explorer-api solacescanscout-frontend
# Setup Nginx
sudo ./deployment/scripts/setup-nginx.sh
@@ -142,7 +147,7 @@ sudo ./deployment/scripts/setup-cloudflare-tunnel.sh
```bash
# Check status
systemctl status explorer-indexer explorer-api explorer-frontend
systemctl status explorer-indexer explorer-api solacescanscout-frontend
# View logs
journalctl -u explorer-api -f
@@ -193,4 +198,3 @@ sudo ./deployment/scripts/full-deploy.sh
---
**All deployment files are ready and documented!**

View File

@@ -1,12 +1,13 @@
# 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?"
## 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`)
- 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` |
| 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 |
| 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
@@ -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:
- `https://explorer.d-bis.org/`
- `https://explorer.d-bis.org/bridge`
- `https://explorer.d-bis.org/routes`
- `https://explorer.d-bis.org/liquidity`
- `https://explorer.d-bis.org/api/config/capabilities`
- `https://explorer.d-bis.org/config/CHAIN138_RPC_CAPABILITIES.json`
- `https://explorer.d-bis.org/explorer-api/v1/features`
- `https://explorer.d-bis.org/explorer-api/v1/track1/bridge/status`
- `https://explorer.d-bis.org/explorer-api/v1/mission-control/stream`
- `https://explorer.d-bis.org/token-aggregation/api/v1/routes/matrix`
- `https://blockscout.defi-oracle.io/`
- `https://blockscout.defi-oracle.io/bridge`
- `https://blockscout.defi-oracle.io/routes`
- `https://blockscout.defi-oracle.io/liquidity`
- `https://blockscout.defi-oracle.io/api/config/capabilities`
- `https://blockscout.defi-oracle.io/config/CHAIN138_RPC_CAPABILITIES.json`
- `https://blockscout.defi-oracle.io/explorer-api/v1/features`
- `https://blockscout.defi-oracle.io/explorer-api/v1/track1/bridge/status`
- `https://blockscout.defi-oracle.io/explorer-api/v1/mission-control/stream`
- `https://blockscout.defi-oracle.io/token-aggregation/api/v1/routes/matrix`
## Recommended Rollout Order
@@ -78,7 +80,7 @@ When a change spans relays as well:
## 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.
- 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.

View File

@@ -26,10 +26,11 @@ pct enter 100
### Services
```bash
# Start all services
systemctl start explorer-indexer explorer-api explorer-frontend
systemctl start explorer-indexer explorer-api solacescanscout-frontend
# Check status
systemctl status explorer-indexer
journalctl -u solacescanscout-frontend -f
journalctl -u explorer-indexer -f
# Restart
@@ -83,13 +84,13 @@ curl http://localhost:3000
curl http://localhost/api/health
# Through Cloudflare
curl https://explorer.d-bis.org/api/health
curl https://blockscout.defi-oracle.io/api/health
```
## File Locations
- **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`
- **Tunnel**: `/etc/cloudflared/config.yml`
- **Logs**: `/var/log/explorer/` and `journalctl -u explorer-*`
@@ -127,12 +128,11 @@ journalctl -u cloudflared -f
```bash
# Stop all services
systemctl stop explorer-indexer explorer-api explorer-frontend
systemctl stop explorer-indexer explorer-api solacescanscout-frontend
# Restore from backup
gunzip < backup.sql.gz | psql -U explorer explorer
# 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)
- 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)
- 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)
- 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 = /
sed -i '/location = \/ {/,/try_files \/index.html =404;/{
/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"
echo "Added CSP to HTTP location = /"
fi

View File

@@ -6,7 +6,9 @@ Use as reference or copy into your project.
## Contents
- **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).
- **../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.
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:
# - /api/
# - /api/config/token-list
@@ -32,5 +32,6 @@ location / {
proxy_buffering off;
proxy_hide_header Cache-Control;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: https:; font-src 'self' https://cdnjs.cloudflare.com; connect-src 'self' https://explorer.d-bis.org wss://explorer.d-bis.org https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org http://192.168.11.221:8545 ws://192.168.11.221:8546;" always;
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=BLOCKSCOUT_INTERNAL_URL=http://127.0.0.1:4000
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_SCRIPT_ALLOWLIST=check-health.sh,check-bridges.sh
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 "2. Run database migrations"
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 ""
echo "See DEPLOYMENT_GUIDE.md for detailed instructions"

View File

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

View File

@@ -15,7 +15,7 @@ ERRORS=0
# Check 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
echo -e "${GREEN}${NC} $service is running"
else
@@ -100,4 +100,3 @@ else
echo -e "${RED}$ERRORS critical check(s) failed${NC}"
exit 1
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]
Description=SolaceScanScout Next Frontend Service
Description=SolaceScan Next Frontend Service
After=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.

View File

@@ -300,7 +300,7 @@ Once the backend is running:
### Backend Logs
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`
- Errors: Database connection errors, query failures, etc.
@@ -330,7 +330,7 @@ Expected response:
},
"chain_id": 138,
"explorer": {
"name": "SolaceScanScout",
"name": "SolaceScan",
"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.

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).

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
- **Same-origin:** `window.location.origin + '/api'` (e.g. `https://explorer.d-bis.org/api`)
- **Fallback:** `https://explorer.d-bis.org/api`
- **Same-origin:** `window.location.origin + '/api'` (e.g. `https://blockscout.defi-oracle.io/api`)
- **Fallback:** `https://blockscout.defi-oracle.io/api`
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
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

View File

@@ -5,7 +5,7 @@
## 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

View File

@@ -1,13 +1,13 @@
# 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
- **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:
- **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.
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.
@@ -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).
- **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
- **Token list URL:** https://explorer.d-bis.org/api/config/token-list
- **Networks config:** https://explorer.d-bis.org/api/config/networks
- **GRU v2 public rollout status:** https://explorer.d-bis.org/config/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json
- **GRU v2 deployment queue:** https://explorer.d-bis.org/config/GRU_V2_DEPLOYMENT_QUEUE.json
- **Wallet page:** https://blockscout.defi-oracle.io/wallet
- **Token list URL:** https://blockscout.defi-oracle.io/api/config/token-list
- **Networks config:** https://blockscout.defi-oracle.io/api/config/networks
- **GRU v2 public rollout status:** https://blockscout.defi-oracle.io/config/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.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 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
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 |
|-----|-------------|
| **[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 |
---

View File

@@ -2,7 +2,7 @@
## 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

View File

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

View File

@@ -1,21 +1,21 @@
openapi: 3.0.0
info:
title: SolaceScanScout API
title: SolaceScan API
version: 1.0.0
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,
WETH utilities, and real-time transaction tracking.
contact:
name: SolaceScanScout Support
url: https://explorer.d-bis.org
name: SolaceScan Support
url: https://blockscout.defi-oracle.io
license:
name: MIT
url: https://opensource.org/licenses/MIT
servers:
- url: https://explorer.d-bis.org/api
- url: https://blockscout.defi-oracle.io/api
description: Production server
- url: http://localhost:8080/api
description: Local development server
@@ -307,4 +307,3 @@ components:
security:
- ApiKeyAuth: []

View File

@@ -1,6 +1,6 @@
# 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)
@@ -778,4 +778,3 @@ Paginated endpoints use consistent pagination:
}
}
```

View File

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

View File

@@ -133,7 +133,7 @@ The frontend has two delivery paths:
## 6. Files Reviewed
- `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/components/common/Card.tsx`, `Button.tsx`, `Table.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', () => {
expect(
resolveExplorerApiBase({
envValue: 'https://explorer.d-bis.org/',
envValue: 'https://blockscout.defi-oracle.io/',
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', () => {

View File

@@ -2,6 +2,25 @@
const nextConfig = {
reactStrictMode: true,
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.
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? '',

View File

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

View File

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

View File

@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Acknowledgments | SolaceScanScout</title>
<meta name="description" content="Acknowledgments for the SolaceScanScout explorer.">
<title>Acknowledgments | SolaceScan</title>
<meta name="description" content="Acknowledgments for the SolaceScan Chain 138 explorer.">
<style>
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; }
@@ -19,19 +19,19 @@
<body>
<div class="shell">
<div class="topbar">
<div class="brand">SolaceScanScout Acknowledgments</div>
<div class="brand">SolaceScan Acknowledgments</div>
<a href="/">Back to explorer</a>
</div>
<div class="card">
<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>
<li><strong>Blockscout</strong> for explorer indexing and API compatibility.</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>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>
<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>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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>
<style>
:root {
@@ -122,15 +122,43 @@
text-align: center;
}
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>
</head>
<body>
<header>
<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>
<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="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>
@@ -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-8" class="tab" role="tab" aria-selected="false" aria-controls="panel-8" data-tab="8" tabindex="-1">Mission Control</button>
</div>
<a class="back" href="/more">Back to More</a>
<a class="back" href="/operations">Back to Operations</a>
</div>
<!-- 0 Master -->
@@ -594,6 +622,25 @@ flowchart LR
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) {
var slug = TAB_SLUGS[index] != null ? TAB_SLUGS[index] : String(index);
try {
@@ -627,9 +674,14 @@ flowchart LR
var nodes = panel.querySelectorAll('.mermaid');
if (nodes.length) {
try {
await ensureMermaid();
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) {
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": {
"id": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"label": "WETH9 (0xC02aaA39…)",
"href": "https://explorer.d-bis.org/address/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
"href": "/addresses/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
}
},
{
"data": {
"id": "0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f",
"label": "WETH10 (0xf4BB2e28…)",
"href": "https://explorer.d-bis.org/address/0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f"
"href": "/addresses/0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f"
}
},
{
"data": {
"id": "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506",
"label": "Oracle_Aggregator (0x99b3511a…)",
"href": "https://explorer.d-bis.org/address/0x99b3511a2d315a497c8112c1fdd8d508d4b1e506"
"href": "/addresses/0x99b3511a2d315a497c8112c1fdd8d508d4b1e506"
}
},
{
"data": {
"id": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6",
"label": "Oracle_Proxy (0x3304b747…)",
"href": "https://explorer.d-bis.org/address/0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6"
"href": "/addresses/0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6"
}
},
{
"data": {
"id": "0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817",
"label": "CCIP_Router (0x42DAb7b8…)",
"href": "https://explorer.d-bis.org/address/0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817"
"href": "/addresses/0x42dab7b888dd382bd5adcf9e038dbf1fd03b4817"
}
},
{
"data": {
"id": "0x8078a09637e47fa5ed34f626046ea2094a5cde5e",
"label": "CCIP_Router_Direct_Legacy (0x8078A096…)",
"href": "https://explorer.d-bis.org/address/0x8078a09637e47fa5ed34f626046ea2094a5cde5e"
"href": "/addresses/0x8078a09637e47fa5ed34f626046ea2094a5cde5e"
}
},
{
"data": {
"id": "0x105f8a15b819948a89153505762444ee9f324684",
"label": "CCIP_Sender (0x105F8A15…)",
"href": "https://explorer.d-bis.org/address/0x105f8a15b819948a89153505762444ee9f324684"
"href": "/addresses/0x105f8a15b819948a89153505762444ee9f324684"
}
},
{
"data": {
"id": "0xcacfd227a040002e49e2e01626363071324f820a",
"label": "CCIPWETH9_Bridge (0xcacfd227…)",
"href": "https://explorer.d-bis.org/address/0xcacfd227a040002e49e2e01626363071324f820a"
"href": "/addresses/0xcacfd227a040002e49e2e01626363071324f820a"
}
},
{
"data": {
"id": "0x971cd9d156f193df8051e48043c476e53ecd4693",
"label": "CCIPWETH9_Bridge_Direct_Legacy (0x971cD9D1…)",
"href": "https://explorer.d-bis.org/address/0x971cd9d156f193df8051e48043c476e53ecd4693"
"href": "/addresses/0x971cd9d156f193df8051e48043c476e53ecd4693"
}
},
{
"data": {
"id": "0xe0e93247376aa097db308b92e6ba36ba015535d0",
"label": "CCIPWETH10_Bridge (0xe0E93247…)",
"href": "https://explorer.d-bis.org/address/0xe0e93247376aa097db308b92e6ba36ba015535d0"
"href": "/addresses/0xe0e93247376aa097db308b92e6ba36ba015535d0"
}
},
{
"data": {
"id": "0xb7721dd53a8c629d9f1ba31a5819afe250002b03",
"label": "LINK (0xb7721dD5…)",
"href": "https://explorer.d-bis.org/address/0xb7721dd53a8c629d9f1ba31a5819afe250002b03"
"href": "/addresses/0xb7721dd53a8c629d9f1ba31a5819afe250002b03"
}
},
{
"data": {
"id": "0x93e66202a11b1772e55407b32b44e5cd8eda7f22",
"label": "cUSDT (0x93E66202…)",
"href": "https://explorer.d-bis.org/address/0x93e66202a11b1772e55407b32b44e5cd8eda7f22"
"href": "/addresses/0x93e66202a11b1772e55407b32b44e5cd8eda7f22"
}
},
{
"data": {
"id": "0xf22258f57794cc8e06237084b353ab30fffa640b",
"label": "cUSDC (0xf22258f5…)",
"href": "https://explorer.d-bis.org/address/0xf22258f57794cc8e06237084b353ab30fffa640b"
"href": "/addresses/0xf22258f57794cc8e06237084b353ab30fffa640b"
}
},
{
"data": {
"id": "0x9fbfab33882efe0038daa608185718b772ee5660",
"label": "cUSDT_V2 (0x9FBfab33…)",
"href": "https://explorer.d-bis.org/address/0x9fbfab33882efe0038daa608185718b772ee5660"
"href": "/addresses/0x9fbfab33882efe0038daa608185718b772ee5660"
}
},
{
"data": {
"id": "0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d",
"label": "cUSDC_V2 (0x219522c6…)",
"href": "https://explorer.d-bis.org/address/0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d"
"href": "/addresses/0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d"
}
},
{
"data": {
"id": "0x91efe92229dbf7c5b38d422621300956b55870fa",
"label": "TokenRegistry (0x91Efe922…)",
"href": "https://explorer.d-bis.org/address/0x91efe92229dbf7c5b38d422621300956b55870fa"
"href": "/addresses/0x91efe92229dbf7c5b38d422621300956b55870fa"
}
},
{
"data": {
"id": "0xebfb5c60de5f7c4baae180ca328d3bb39e1a5133",
"label": "TokenFactory (0xEBFb5C60…)",
"href": "https://explorer.d-bis.org/address/0xebfb5c60de5f7c4baae180ca328d3bb39e1a5133"
"href": "/addresses/0xebfb5c60de5f7c4baae180ca328d3bb39e1a5133"
}
},
{
"data": {
"id": "0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1",
"label": "ComplianceRegistry (0xbc54fe2b…)",
"href": "https://explorer.d-bis.org/address/0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1"
"href": "/addresses/0xbc54fe2b6fda157c59d59826bcfdbcc654ec9ea1"
}
},
{
"data": {
"id": "0x31884f84555210ffb36a19d2471b8ebc7372d0a8",
"label": "BridgeVault (0x31884f84…)",
"href": "https://explorer.d-bis.org/address/0x31884f84555210ffb36a19d2471b8ebc7372d0a8"
"href": "/addresses/0x31884f84555210ffb36a19d2471b8ebc7372d0a8"
}
},
{
"data": {
"id": "0xf78246eb94c6cb14018e507e60661314e5f4c53f",
"label": "FeeCollector (0xF78246eB…)",
"href": "https://explorer.d-bis.org/address/0xf78246eb94c6cb14018e507e60661314e5f4c53f"
"href": "/addresses/0xf78246eb94c6cb14018e507e60661314e5f4c53f"
}
},
{
"data": {
"id": "0x95bc4a997c0670d5dac64d55cdf3769b53b63c28",
"label": "DebtRegistry (0x95BC4A99…)",
"href": "https://explorer.d-bis.org/address/0x95bc4a997c0670d5dac64d55cdf3769b53b63c28"
"href": "/addresses/0x95bc4a997c0670d5dac64d55cdf3769b53b63c28"
}
},
{
"data": {
"id": "0x0c4fd27018130a00762a802f91a72d6a64a60f14",
"label": "PolicyManager (0x0C4FD270…)",
"href": "https://explorer.d-bis.org/address/0x0c4fd27018130a00762a802f91a72d6a64a60f14"
"href": "/addresses/0x0c4fd27018130a00762a802f91a72d6a64a60f14"
}
},
{
"data": {
"id": "0x0059e237973179146237ab49f1322e8197c22b21",
"label": "TokenImplementation (0x0059e237…)",
"href": "https://explorer.d-bis.org/address/0x0059e237973179146237ab49f1322e8197c22b21"
"href": "/addresses/0x0059e237973179146237ab49f1322e8197c22b21"
}
},
{
"data": {
"id": "0xd3ad6831aacb5386b8a25bb8d8176a6c8a026f04",
"label": "PriceFeed_Keeper (0xD3AD6831…)",
"href": "https://explorer.d-bis.org/address/0xd3ad6831aacb5386b8a25bb8d8176a6c8a026f04"
"href": "/addresses/0xd3ad6831aacb5386b8a25bb8d8176a6c8a026f04"
}
},
{
"data": {
"id": "0x8918ee0819fd687f4eb3e8b9b7d0ef7557493cfa",
"label": "OraclePriceFeed (0x8918eE08…)",
"href": "https://explorer.d-bis.org/address/0x8918ee0819fd687f4eb3e8b9b7d0ef7557493cfa"
"href": "/addresses/0x8918ee0819fd687f4eb3e8b9b7d0ef7557493cfa"
}
},
{
"data": {
"id": "0x3e8725b8de386fef3efe5678c92ea6adb41992b2",
"label": "WETH_MockPriceFeed (0x3e8725b8…)",
"href": "https://explorer.d-bis.org/address/0x3e8725b8de386fef3efe5678c92ea6adb41992b2"
"href": "/addresses/0x3e8725b8de386fef3efe5678c92ea6adb41992b2"
}
},
{
"data": {
"id": "0x16d9a2cb94a0b92721d93db4a6cd8023d3338800",
"label": "MerchantSettlementRegistry (0x16D9A2cB…)",
"href": "https://explorer.d-bis.org/address/0x16d9a2cb94a0b92721d93db4a6cd8023d3338800"
"href": "/addresses/0x16d9a2cb94a0b92721d93db4a6cd8023d3338800"
}
},
{
"data": {
"id": "0xe77cb26ea300e2f5304b461b0ec94c8ad6a7e46d",
"label": "WithdrawalEscrow (0xe77cb26e…)",
"href": "https://explorer.d-bis.org/address/0xe77cb26ea300e2f5304b461b0ec94c8ad6a7e46d"
"href": "/addresses/0xe77cb26ea300e2f5304b461b0ec94c8ad6a7e46d"
}
},
{
"data": {
"id": "0xaee4b7fbe82e1f8295951584cbc772b8bbd68575",
"label": "UniversalAssetRegistry (0xAEE4b7fB…)",
"href": "https://explorer.d-bis.org/address/0xaee4b7fbe82e1f8295951584cbc772b8bbd68575"
"href": "/addresses/0xaee4b7fbe82e1f8295951584cbc772b8bbd68575"
}
},
{
"data": {
"id": "0xa6891d5229f2181a34d4ff1b515c3aa37dd90e0e",
"label": "GovernanceController (0xA6891D52…)",
"href": "https://explorer.d-bis.org/address/0xa6891d5229f2181a34d4ff1b515c3aa37dd90e0e"
"href": "/addresses/0xa6891d5229f2181a34d4ff1b515c3aa37dd90e0e"
}
},
{
"data": {
"id": "0xcd42e8ed79dc50599535d1de48d3dafa0be156f8",
"label": "UniversalCCIPBridge (0xCd42e8eD…)",
"href": "https://explorer.d-bis.org/address/0xcd42e8ed79dc50599535d1de48d3dafa0be156f8"
"href": "/addresses/0xcd42e8ed79dc50599535d1de48d3dafa0be156f8"
}
},
{
"data": {
"id": "0xbe9e0b2d4cf6a3b2994d6f2f0904d2b165eb8ffc",
"label": "UniversalCCIPFlashBridgeAdapter (0xBe9e0B2d…)",
"href": "https://explorer.d-bis.org/address/0xbe9e0b2d4cf6a3b2994d6f2f0904d2b165eb8ffc"
"href": "/addresses/0xbe9e0b2d4cf6a3b2994d6f2f0904d2b165eb8ffc"
}
},
{
"data": {
"id": "0xd084b68cb4b1ef2cba09cf99fb1b6552fd9b4859",
"label": "CrossChainFlashRepayReceiver (0xD084b68c…)",
"href": "https://explorer.d-bis.org/address/0xd084b68cb4b1ef2cba09cf99fb1b6552fd9b4859"
"href": "/addresses/0xd084b68cb4b1ef2cba09cf99fb1b6552fd9b4859"
}
},
{
"data": {
"id": "0x89f7a1fcbbe104bee96da4b4b6b7d3af85f7e661",
"label": "CrossChainFlashVaultCreditReceiver (0x89F7a1fc…)",
"href": "https://explorer.d-bis.org/address/0x89f7a1fcbbe104bee96da4b4b6b7d3af85f7e661"
"href": "/addresses/0x89f7a1fcbbe104bee96da4b4b6b7d3af85f7e661"
}
},
{
"data": {
"id": "0x89ab428c437f23bab9781ff8db8d3848e27eed6c",
"label": "BridgeOrchestrator (0x89aB428c…)",
"href": "https://explorer.d-bis.org/address/0x89ab428c437f23bab9781ff8db8d3848e27eed6c"
"href": "/addresses/0x89ab428c437f23bab9781ff8db8d3848e27eed6c"
}
},
{
"data": {
"id": "0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce",
"label": "EnhancedSwapRouterV2 (0xF1c93F54…)",
"href": "https://explorer.d-bis.org/address/0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce"
"href": "/addresses/0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce"
}
},
{
"data": {
"id": "0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7",
"label": "IntentBridgeCoordinatorV2 (0x7D0022B7…)",
"href": "https://explorer.d-bis.org/address/0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7"
"href": "/addresses/0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7"
}
},
{
"data": {
"id": "0x88495b3dccea93b0633390fde71992683121fa62",
"label": "DodoRouteExecutorAdapter (0x88495B3d…)",
"href": "https://explorer.d-bis.org/address/0x88495b3dccea93b0633390fde71992683121fa62"
"href": "/addresses/0x88495b3dccea93b0633390fde71992683121fa62"
}
},
{
"data": {
"id": "0x9cb97add29c52e3b81989bca2e33d46074b530ef",
"label": "DodoV3RouteExecutorAdapter (0x9Cb97adD…)",
"href": "https://explorer.d-bis.org/address/0x9cb97add29c52e3b81989bca2e33d46074b530ef"
"href": "/addresses/0x9cb97add29c52e3b81989bca2e33d46074b530ef"
}
},
{
"data": {
"id": "0x960d6db4e78705f82995690548556fb2266308ea",
"label": "UniswapV3RouteExecutorAdapter (0x960D6db4…)",
"href": "https://explorer.d-bis.org/address/0x960d6db4e78705f82995690548556fb2266308ea"
"href": "/addresses/0x960d6db4e78705f82995690548556fb2266308ea"
}
},
{

View File

@@ -3,8 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documentation | SolaceScanScout</title>
<meta name="description" content="Documentation landing page for the SolaceScanScout explorer.">
<title>Documentation Redirect | SolaceScan</title>
<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>
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; }
@@ -21,32 +23,22 @@
<body>
<div class="shell">
<div class="topbar">
<div class="brand">SolaceScanScout Documentation</div>
<div class="brand">SolaceScan Documentation</div>
<a href="/">Back to explorer</a>
</div>
<div class="card">
<h1 style="margin-top:0;">Documentation</h1>
<p class="muted">This landing page collects the key explorer and deployment references used by the SolaceScanScout stack.</p>
<h1 style="margin-top:0;">Documentation Has Moved</h1>
<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;">
<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="/terms.html">Terms of Service</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>

View File

@@ -73,13 +73,13 @@
if (j.error) throw new Error(j.error.message || 'RPC error');
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)
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 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_ORIGIN = (typeof window !== 'undefined' && window.location && EXPLORER_ORIGINS.includes(window.location.origin)) ? window.location.origin : 'https://explorer.d-bis.org';
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://blockscout.defi-oracle.io';
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' },
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
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 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) {
if (value == null || value === '') return '0';
var stringValue = String(value);
@@ -1494,7 +1506,24 @@
}
var rpcTx = await rpcCall('eth_getTransactionByHash', [txHash]);
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 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 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 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) {
return {
address: normalized,
@@ -2032,7 +2071,7 @@
decimals: 18
},
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) {
@@ -2786,7 +2825,7 @@
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] === '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] === '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; }
@@ -2922,7 +2961,7 @@
case 'nft':
breadcrumbContainer = document.getElementById('nftDetailBreadcrumb');
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-current">Token ID ' + (identifierExtra != null ? escapeHtml(String(identifierExtra)) : '') + '</span>';
break;
@@ -3614,7 +3653,7 @@
filteredBlocks.forEach(function(block) {
var d = normalizeBlockDisplay(block);
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 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>';
@@ -3705,11 +3744,11 @@
const blockNumber = tx.block_number || 'N/A';
const valueFormatted = formatEther(value);
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 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 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 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 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="/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="/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>';
});
}
@@ -3789,7 +3828,7 @@
var tokenCount = Number(item.token_count || 0);
var lastSeen = String(item.last_seen_at || '—');
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(type) + '</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-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 += '<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>';
@@ -4470,7 +4509,7 @@
function renderMoreView() {
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');
if (!container) return;
var groups = [
@@ -4479,7 +4518,7 @@
title: 'Tools',
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: '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: '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: '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: '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',
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: '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: '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/' }
@@ -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;">';
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="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="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 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="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;">';
@@ -4535,7 +4574,7 @@
: (item.href === '#'
? ('event.preventDefault(); ' + item.action + ' 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 += '<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;">';
@@ -4907,17 +4946,17 @@
function explorerAddressLink(address, content, style) {
var safe = safeAddress(address);
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) {
var safe = safeTxHash(txHash);
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) {
var safe = safeBlockNumber(blockNumber);
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) {
if (value == null || value === '') return null;
@@ -4980,16 +5019,19 @@
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>';
}
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) {
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) {
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) {
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) {
var repr = buildNumericRepresentations(value);
@@ -5027,6 +5069,155 @@
if (value == null || value === '') return '';
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) {
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; }) : [];
@@ -5035,28 +5226,33 @@
var dataBytes = /^0x[0-9a-f]*$/i.test(String(dataValue || '')) ? Math.max(0, (String(dataValue).length - 2) / 2) : 0;
var blockNumber = log.block_number != null ? String(log.block_number) : '';
var txHash = log.transaction_hash || log.transactionHash || '';
var knownEvent = detectKnownLogEvent(topics, dataValue);
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>';
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">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 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>';
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' : '') + '>';
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 += 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) {
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) {
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 += 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>';
return html;
}
@@ -5089,7 +5285,7 @@
blockNumber = bn;
currentDetailKey = 'block:' + blockNumber;
showView('blockDetail');
updatePath('/block/' + blockNumber);
updatePath('/blocks/' + blockNumber);
const container = document.getElementById('blockDetail');
updateBreadcrumb('block', blockNumber);
container.innerHTML = createSkeletonLoader('detail');
@@ -5207,7 +5403,7 @@
txHash = th;
currentDetailKey = 'tx:' + txHash.toLowerCase();
showView('transactionDetail');
updatePath('/tx/' + txHash);
updatePath('/transactions/' + txHash);
const container = document.getElementById('transactionDetail');
updateBreadcrumb('transaction', txHash);
container.innerHTML = createSkeletonLoader('detail');
@@ -5221,7 +5417,13 @@
var detailResult = await fetchChain138TransactionDetail(txHash);
rawTx = detailResult.rawTransaction;
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) {
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;
@@ -5579,6 +5781,15 @@
var args = parsed.args && parsed.args.length ? parsed.args.map(function(a) { return String(a); }).join(', ') : '';
decodedEl.textContent = parsed.name + '(' + args + ')';
decodedEl.title = parsed.signature || '';
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) {}
});
@@ -5723,7 +5934,7 @@
address = addr;
currentDetailKey = 'address:' + address.toLowerCase();
showView('addressDetail');
updatePath('/address/' + address);
updatePath('/addresses/' + address);
const container = document.getElementById('addressDetail');
updateBreadcrumb('address', address);
container.innerHTML = createSkeletonLoader('detail');
@@ -5908,7 +6119,7 @@
const decimals = token.decimals != null ? token.decimals : 18;
const displayBalance = formatUnitsLocalized(balance, decimals, 6);
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) {
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;
currentDetailKey = 'token:' + tokenAddress.toLowerCase();
showView('tokenDetail');
updatePath('/token/' + tokenAddress);
updatePath('/tokens/' + tokenAddress);
var container = document.getElementById('tokenDetail');
updateBreadcrumb('token', tokenAddress);
container.innerHTML = createSkeletonLoader('detail');
@@ -6251,7 +6462,7 @@
} catch (e) {}
}
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;
}
var knownTokenDetail = {

View File

@@ -7,30 +7,30 @@
<meta http-equiv="Pragma" content="no-cache">
<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). -->
<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;">
<title>SolaceScanScout | The Defi Oracle Meta Explorer | d-bis.org</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="keywords" content="blockchain explorer, ChainID 138, CCIP bridge, WETH, DeFi Oracle, SolaceScanScout, blockchain, ethereum, blockscout">
<meta name="author" content="SolaceScanScout">
<meta name="application-name" content="SolaceScanScout">
<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>SolaceScan | Chain 138 Explorer by DBIS</title>
<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, DBIS, SolaceScan, bridge monitoring, wallet tools, blockchain, ethereum, blockscout">
<meta name="author" content="SolaceScan">
<meta name="application-name" content="SolaceScan">
<meta name="theme-color" content="#667eea">
<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;})();
</script>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://explorer.d-bis.org/">
<meta property="og:title" content="SolaceScanScout - The Defi Oracle Meta Explorer">
<meta property="og:url" content="https://blockscout.defi-oracle.io/">
<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:image" content="https://explorer.d-bis.org/og-image.png">
<meta property="og:site_name" content="SolaceScanScout">
<meta property="og:image" content="https://blockscout.defi-oracle.io/og-image.png">
<meta property="og:site_name" content="SolaceScan">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://explorer.d-bis.org/">
<meta name="twitter:title" content="SolaceScanScout - The Defi Oracle Meta Explorer">
<meta name="twitter:url" content="https://blockscout.defi-oracle.io/">
<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:image" content="https://explorer.d-bis.org/og-image.png">
<meta name="twitter:image" content="https://blockscout.defi-oracle.io/og-image.png">
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
@@ -917,8 +917,28 @@
opacity: 0.85;
}
.gas-network-subtle { color: var(--text-light); font-size: 0.82rem; white-space: nowrap; }
.btn-copy { background: none; border: none; cursor: pointer; padding: 0.25rem; margin-left: 0.35rem; color: var(--text-light); vertical-align: middle; }
.btn-copy:hover { color: var(--primary); }
.btn-copy {
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 {
display: flex;
flex-wrap: wrap;
@@ -935,6 +955,10 @@
color: var(--text);
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 {
color: var(--text-light);
text-transform: uppercase;
@@ -983,6 +1007,21 @@
font-size: 0.84rem;
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 {
display: grid;
grid-template-columns: minmax(110px, 140px) minmax(0, 1fr);
@@ -999,8 +1038,20 @@
.tx-inspector-content {
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 {
overflow-x: auto;
min-width: 0;
}
.tx-inspector-mono {
font-family: 'Courier New', monospace;
@@ -1028,7 +1079,7 @@
.tx-inspector-topic-row {
display: grid;
gap: 0.35rem;
padding: 0.7rem;
padding: 0.8rem;
border-radius: 10px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.45);
@@ -1049,11 +1100,21 @@
body.dark-theme .tx-inspector-topic-row {
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) {
.tx-inspector-line {
grid-template-columns: 1fr;
gap: 0.35rem;
}
.tx-inspector-copy-row {
grid-template-columns: 1fr;
}
.tx-inspector-copy-action {
justify-content: flex-start;
}
.tx-inspector-entry summary {
flex-direction: column;
align-items: stretch;
@@ -1153,8 +1214,8 @@
<a class="logo" href="/" aria-label="Go to explorer home" style="text-decoration:none; color:inherit;">
<i class="fas fa-cube"></i>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>SolaceScanScout</span>
<span style="font-size: 0.75rem; font-weight: normal; opacity: 0.9;">The Defi Oracle Meta Explorer</span>
<span>SolaceScan</span>
<span style="font-size: 0.75rem; font-weight: normal; opacity: 0.9;">Chain 138 Explorer by DBIS</span>
</div>
</a>
<div class="search-box" style="display: flex; gap: 0.5rem; align-items: center;">
@@ -1189,7 +1250,7 @@
</ul>
</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>
<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">
@@ -1345,7 +1406,7 @@
<div class="weth-card">
<div class="chain-name">WETH9 Token</div>
<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>
</div>
@@ -1395,7 +1456,7 @@
<div class="weth-card">
<div class="chain-name">WETH10 Token</div>
<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>
</div>
@@ -1452,8 +1513,8 @@
<h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">Contract Addresses</h4>
<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>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>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="/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>
<h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">How to Use</h4>
@@ -1723,20 +1784,20 @@
<div class="container">
<div class="site-footer-grid">
<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">
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,
Chain 138 RPC, and the MetaMask Snap companion.
</div>
<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 class="site-footer-title">Documentation</div>
<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="/routes">Routes</a>
<a href="/privacy.html">Privacy Policy</a>
@@ -1755,6 +1816,6 @@
</div>
</footer>
<script src="/explorer-spa.js?v=34"></script>
<script src="/explorer-spa.js?v=35"></script>
</body>
</html>

View File

@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy | SolaceScanScout</title>
<meta name="description" content="Privacy policy for the SolaceScanScout explorer.">
<title>Privacy Policy | SolaceScan</title>
<meta name="description" content="Privacy policy for the SolaceScan Chain 138 explorer operated by DBIS / Defi Oracle.">
<style>
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; }
@@ -19,19 +19,61 @@
<body>
<div class="shell">
<div class="topbar">
<div class="brand">SolaceScanScout Privacy Policy</div>
<div class="brand">SolaceScan Privacy Policy</div>
<a href="/">Back to explorer</a>
</div>
<div class="card">
<h1 style="margin-top:0;">Privacy Policy</h1>
<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>
<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>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>
<li>Watchlist entries, saved searches, and similar convenience features may also be stored in browser local storage on the device you use.</li>
</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>
</div>
</div>

View File

@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terms of Service | SolaceScanScout</title>
<meta name="description" content="Terms of service for the SolaceScanScout explorer.">
<title>Terms of Service | SolaceScan</title>
<meta name="description" content="Terms of service for the SolaceScan Chain 138 explorer operated by DBIS / Defi Oracle.">
<style>
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; }
@@ -19,20 +19,55 @@
<body>
<div class="shell">
<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>
</div>
<div class="card">
<h1 style="margin-top:0;">Terms of Service</h1>
<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>
<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>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>
<p>For service questions, contact <a href="mailto:support@d-bis.org">support@d-bis.org</a>.</p>
</div>
</div>
</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 checks = [
{ path: '/', expectTexts: ['SolaceScanScout', 'Recent Blocks', 'Open wallet tools'] },
{ path: '/', expectTexts: ['SolaceScan', 'Recent Blocks', 'Open wallet tools'] },
{ path: '/blocks', expectTexts: ['Blocks'] },
{ path: '/transactions', expectTexts: ['Transactions'] },
{ 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 Navbar from './Navbar'
import Footer from './Footer'
import ExplorerAgentTool from './ExplorerAgentTool'
export default function ExplorerChrome({ children }: { children: ReactNode }) {
return (
<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 />
<div className="flex-1">{children}</div>
<div id="main-content" className="flex-1">
{children}
</div>
<ExplorerAgentTool />
<Footer />
</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="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">
SolaceScanScout
SolaceScan
</div>
<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
work. Explorer data is powered by Blockscout, Chain 138 RPC, and the
companion MetaMask Snap.
Built on Blockscout for the DBIS / Defi Oracle Chain 138 explorer surface.
Explorer data is powered by Blockscout, Chain 138 RPC, and the 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 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>
</div>
@@ -29,11 +32,12 @@ export default function Footer() {
Resources
</div>
<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="/liquidity">Liquidity Access</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="/watchlist">Watchlist</Link></li>
<li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li>
@@ -55,8 +59,8 @@ export default function Footer() {
</p>
<p>
Snap site:{' '}
<a className={footerLinkClass} href="https://explorer.d-bis.org/snap/" target="_blank" rel="noopener noreferrer">
explorer.d-bis.org/snap/
<a className={footerLinkClass} href="/snap/" target="_blank" rel="noopener noreferrer">
/snap/ on the current explorer domain
</a>
</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'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
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 navLinkActive = 'text-primary-600 dark:text-primary-400 font-medium'
const navItemBase =
'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({
label,
icon,
active,
children,
}: {
label: string
icon: React.ReactNode
active?: boolean
children: React.ReactNode
}) {
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 (
<div
ref={wrapperRef}
className="relative"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onBlurCapture={(event) => {
const nextTarget = event.relatedTarget as Node | null
if (nextTarget && wrapperRef.current?.contains(nextTarget)) {
return
}
setOpen(false)
}}
>
<button
type="button"
className={`flex items-center gap-1.5 px-3 py-2 rounded-md ${navLink}`}
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 ${active && !open ? navLinkActive : navLink}`}
onClick={() => setOpen((value) => !value)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setOpen(true)
}
}}
aria-expanded={open}
aria-haspopup="true"
aria-controls={menuId}
>
{icon}
<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" />
</svg>
</button>
{open && (
<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"
>
{children}
@@ -59,7 +111,8 @@ function DropdownItem({
children: React.ReactNode
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) {
return (
<li role="none">
@@ -81,30 +134,91 @@ function DropdownItem({
}
export default function Navbar() {
const router = useRouter()
const pathname = usePathname() ?? ''
const [mobileMenuOpen, setMobileMenuOpen] = 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 = () => {
setMobileMenuOpen((open) => {
const nextOpen = !open
if (!nextOpen) {
setExploreOpen(false)
setToolsOpen(false)
setDataOpen(false)
setOperationsOpen(false)
}
return nextOpen
})
}
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="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
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)}
aria-label="Go to explorer home"
>
@@ -116,58 +230,87 @@ export default function Navbar() {
</span>
<span className="min-w-0 truncate">
<span className="sm:hidden">SolaceScan</span>
<span className="hidden sm:inline">SolaceScanScout</span>
<span className="hidden sm:inline">SolaceScan</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">
The Defi Oracle Meta Explorer
<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">
Chain 138 Explorer by DBIS
</span>
</Link>
<div className="hidden md:flex items-center gap-1">
<div className="hidden items-center gap-1.5 md:flex">
<NavDropdown
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="/blocks" icon={<span className="text-gray-400"></span>}>Blocks</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>
</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
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
</Link>
<NavDropdown
label="Tools"
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>}
<button
type="button"
onClick={() => void handleAccessClick()}
className={`hidden md:inline-flex items-center ${isAccessActive ? navLinkActive : navLink}`}
>
<DropdownItem href="/search">Search</DropdownItem>
<DropdownItem href="/tokens">Tokens</DropdownItem>
<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>
{connectingWallet ? 'Connecting…' : walletSession ? 'Access' : 'Connect Wallet'}
</button>
</div>
</div>
<div className="flex items-center md:hidden">
<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}
aria-expanded={mobileMenuOpen}
aria-label="Toggle menu"
>
{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>
</div>
@@ -175,40 +318,62 @@ export default function Navbar() {
{mobileMenuOpen && (
<div className="border-t border-gray-200 py-2 pb-3 dark:border-gray-700 md:hidden">
<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">
<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>
<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>
{exploreOpen && (
<ul className="pl-4 mt-1 space-y-0.5">
<li><Link href="/blocks" className={`block px-3 py-2 rounded-md ${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="/addresses" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Addresses</Link></li>
<ul className="mt-1 space-y-0.5 pl-4">
<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 rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Transactions</Link></li>
<li><Link href="/addresses" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Addresses</Link></li>
</ul>
)}
</div>
<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}>
<span>Tools</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>
<button type="button" className={`flex w-full items-center justify-between ${navLink}`} onClick={() => setDataOpen((value) => !value)} aria-expanded={dataOpen}>
<span>Data</span>
<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>
{toolsOpen && (
<ul className="pl-4 mt-1 space-y-0.5">
<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 px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Tokens</Link></li>
<li><Link href="/pools" className={`block px-3 py-2 rounded-md ${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="/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>
{dataOpen && (
<ul className="mt-1 space-y-0.5 pl-4">
<li><Link href="/tokens" className={`block rounded-md px-3 py-2 ${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 rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Pools</Link></li>
<li><Link href="/watchlist" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Watchlist</Link></li>
</ul>
)}
</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>
)}

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 { Card } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { explorerFeaturePages } from '@/data/explorerOperations'
import { blocksApi, type Block } from '@/services/api/blocks'
import {
@@ -7,8 +8,14 @@ import {
type MissionControlBridgeStatusResponse,
type MissionControlChainStatus,
} 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 { formatWeiAsEth } from '@/utils/format'
import OperationsPageShell, {
MetricCard,
StatusBadge,
@@ -17,6 +24,15 @@ import OperationsPageShell, {
truncateMiddle,
} 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 {
const chains = bridgeStatus?.data?.chains
if (!chains) return null
@@ -24,11 +40,20 @@ function getChainStatus(bridgeStatus: MissionControlBridgeStatusResponse | null)
return firstChain || null
}
export default function AnalyticsOperationsPage() {
const [stats, setStats] = useState<ExplorerStats | null>(null)
const [blocks, setBlocks] = useState<Block[]>([])
const [transactions, setTransactions] = useState<Transaction[]>([])
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
export default function AnalyticsOperationsPage({
initialStats = null,
initialTransactionTrend = [],
initialActivitySnapshot = 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 page = explorerFeaturePages.analytics
@@ -36,8 +61,10 @@ export default function AnalyticsOperationsPage() {
let cancelled = false
const load = async () => {
const [statsResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
const [statsResult, trendResult, snapshotResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
statsApi.get(),
statsApi.getTransactionTrend(),
statsApi.getRecentActivitySnapshot(),
blocksApi.list({ chain_id: 138, page: 1, page_size: 5 }),
transactionsApi.list(138, 1, 5),
missionControlApi.getBridgeStatus(),
@@ -46,15 +73,17 @@ export default function AnalyticsOperationsPage() {
if (cancelled) return
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 (transactionsResult.status === 'fulfilled') setTransactions(transactionsResult.value.data)
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'
).length
if (failedCount === 4) {
if (failedCount === 6) {
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 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 (
<OperationsPageShell page={page}>
@@ -107,9 +157,111 @@ export default function AnalyticsOperationsPage() {
: '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 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">
<div className="space-y-4">
{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>
<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)}
</div>
</Link>
<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 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>
@@ -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>
<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)}
</div>
</Link>
<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 className="flex items-center gap-3">
@@ -164,6 +334,13 @@ export default function AnalyticsOperationsPage() {
</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>
))}
{transactions.length === 0 ? (

View File

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

View File

@@ -30,21 +30,55 @@ interface TokenPoolRecord {
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 {
return [tokenIn, tokenOut].filter(Boolean).join(' / ') || routeLabel || routeId
}
export default function LiquidityOperationsPage() {
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>([])
export default function LiquidityOperationsPage({
initialTokenList = null,
initialRouteMatrix = null,
initialPlannerCapabilities = null,
initialInternalPlan = null,
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 [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
if (
initialTokenList &&
initialRouteMatrix &&
initialPlannerCapabilities &&
initialInternalPlan &&
initialTokenPoolRecords.length > 0
) {
return () => {
cancelled = true
}
}
const load = async () => {
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult] =
await Promise.allSettled([
@@ -102,7 +136,13 @@ export default function LiquidityOperationsPage() {
return () => {
cancelled = true
}
}, [])
}, [
initialInternalPlan,
initialPlannerCapabilities,
initialRouteMatrix,
initialTokenList,
initialTokenPoolRecords,
])
const featuredTokens = useMemo(
() => selectFeaturedLiquidityTokens(tokenList?.tokens || []),
@@ -139,7 +179,7 @@ export default function LiquidityOperationsPage() {
[routeMatrix, aggregatedPools.length, featuredTokens.length, livePlannerProviders.length, internalPlan?.plannerResponse?.decision, routeBackedPoolAddresses.length]
)
const endpointCards = [
const endpointCards: EndpointCard[] = [
{
name: 'Canonical route matrix',
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 (
<main className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-8 max-w-4xl">
@@ -258,9 +310,16 @@ export default function LiquidityOperationsPage() {
</div>
))}
{aggregatedPools.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
No live pool inventory is available right now.
</p>
<div className="rounded-2xl border border-amber-200 bg-amber-50/70 p-4 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-900 dark:text-amber-100">
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}
</div>
</Card>
@@ -339,12 +398,9 @@ export default function LiquidityOperationsPage() {
<Card title="Explorer Access Points">
<div className="grid gap-4 md:grid-cols-2">
{endpointCards.map((endpoint) => (
<a
<div
key={endpoint.href}
href={endpoint.href}
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"
className="rounded-2xl border border-gray-200 bg-white p-5 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="text-base font-semibold text-gray-900 dark:text-white">{endpoint.name}</div>
@@ -356,7 +412,24 @@ export default function LiquidityOperationsPage() {
{endpoint.href}
</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>
</Card>
@@ -404,12 +477,12 @@ export default function LiquidityOperationsPage() {
>
Open wallet tools
</Link>
<a
href="/docs.html"
<Link
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"
>
Explorer docs
</a>
</Link>
</div>
</div>
</Card>

View File

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

View File

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

View File

@@ -200,7 +200,7 @@ export default function PoolsOperationsPage() {
</div>
</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">
<p>
The broader liquidity page now shows live route, planner, and pool access together.

View File

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

View File

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

View File

@@ -16,6 +16,12 @@ import OperationsPageShell, {
truncateMiddle,
} from './OperationsPageShell'
interface WethOperationsPageProps {
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
initialInternalPlan?: InternalExecutionPlanResponse | null
}
function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
const normalized = String(status || 'unknown').toLowerCase()
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
}
export default function WethOperationsPage() {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
export default function WethOperationsPage({
initialBridgeStatus = null,
initialPlannerCapabilities = 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 page = explorerFeaturePages.weth
@@ -85,6 +95,13 @@ export default function WethOperationsPage() {
return (
<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 ? (
<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>

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 { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
type WalletChain = {
export type WalletChain = {
chainId: string
chainIdDecimal?: number
chainName: string
@@ -20,7 +20,7 @@ type WalletChain = {
explorerApiUrl?: string
}
type TokenListToken = {
export type TokenListToken = {
chainId: number
address: string
name: string
@@ -31,7 +31,7 @@ type TokenListToken = {
extensions?: Record<string, unknown>
}
type NetworksCatalog = {
export type NetworksCatalog = {
name?: string
version?: {
major?: number
@@ -42,7 +42,7 @@ type NetworksCatalog = {
chains?: WalletChain[]
}
type TokenListCatalog = {
export type TokenListCatalog = {
name?: string
version?: {
major?: number
@@ -53,7 +53,7 @@ type TokenListCatalog = {
tokens?: TokenListToken[]
}
type CapabilitiesCatalog = {
export type CapabilitiesCatalog = {
name?: string
version?: {
major?: number
@@ -84,11 +84,20 @@ type CapabilitiesCatalog = {
}
}
type FetchMetadata = {
export type FetchMetadata = {
source?: 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 = {
request: (args: { method: string; params?: unknown }) => Promise<unknown>
}
@@ -99,7 +108,7 @@ const FALLBACK_CHAIN_138: WalletChain = {
chainName: 'DeFi Oracle Meta Mainnet',
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'],
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'],
shortName: 'dbis',
infoURL: 'https://explorer.d-bis.org',
@@ -139,7 +148,7 @@ const FALLBACK_CAPABILITIES_138: CapabilitiesCatalog = {
name: 'Chain 138 RPC Capabilities',
version: { major: 1, minor: 1, patch: 0 },
timestamp: '2026-03-28T00:00:00Z',
generatedBy: 'SolaceScanScout',
generatedBy: 'SolaceScan',
chainId: 138,
chainName: 'DeFi Oracle Meta Mainnet',
rpcUrl: 'https://rpc-http-pub.d-bis.org',
@@ -211,19 +220,39 @@ function isCapabilitiesCatalog(value: unknown): value is CapabilitiesCatalog {
function getApiBase() {
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 [error, setError] = useState<string | null>(null)
const [networks, setNetworks] = useState<NetworksCatalog | null>(null)
const [tokenList, setTokenList] = useState<TokenListCatalog | null>(null)
const [capabilities, setCapabilities] = useState<CapabilitiesCatalog | null>(null)
const [networksMeta, setNetworksMeta] = useState<FetchMetadata | null>(null)
const [tokenListMeta, setTokenListMeta] = useState<FetchMetadata | null>(null)
const [capabilitiesMeta, setCapabilitiesMeta] = useState<FetchMetadata | null>(null)
const [networks, setNetworks] = useState<NetworksCatalog | null>(initialNetworks)
const [tokenList, setTokenList] = useState<TokenListCatalog | null>(initialTokenList)
const [capabilities, setCapabilities] = useState<CapabilitiesCatalog | null>(
initialCapabilities || FALLBACK_CAPABILITIES_138,
)
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'
? (window as unknown as { ethereum?: EthereumProvider }).ethereum
@@ -251,7 +280,7 @@ export function AddToMetaMask() {
})
const json = response.ok ? await response.json() : null
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'),
}
return { json, meta }
@@ -296,15 +325,17 @@ export function AddToMetaMask() {
setCapabilitiesMeta(resolvedCapabilities.meta)
} catch {
if (!active) return
setNetworks(null)
setTokenList(null)
setCapabilities(FALLBACK_CAPABILITIES_138)
setNetworksMeta(null)
setTokenListMeta(null)
setCapabilitiesMeta({
source: 'frontend-fallback',
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
})
setNetworks((current) => current)
setTokenList((current) => current)
setCapabilities((current) => current || FALLBACK_CAPABILITIES_138)
setNetworksMeta((current) => current)
setTokenListMeta((current) => current)
setCapabilitiesMeta((current) =>
current || {
source: 'frontend-fallback',
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
},
)
} finally {
if (active) {
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 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 (
<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>
<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.
</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">
Need swap and liquidity discovery too? Visit the{' '}
<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 =
'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 = {
bridge: {
@@ -72,7 +72,7 @@ export const explorerFeaturePages = {
eyebrow: 'Route Coverage',
title: 'Routes, Pools, and Execution Access',
description:
'Surface the route matrix, live pool inventory, public liquidity endpoints, and bridge-adjacent execution paths that were previously only visible in the legacy explorer shell.',
'Surface the route matrix, live pool inventory, public liquidity endpoints, and bridge-adjacent execution paths from one public explorer surface.',
note: legacyNote,
actions: [
{
@@ -88,11 +88,10 @@ export const explorerFeaturePages = {
label: 'Open pools page',
},
{
title: 'Liquidity mission-control example',
description: 'Open a live mission-control liquidity lookup for a canonical Chain 138 token.',
href: '/explorer-api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools',
label: 'Open liquidity JSON',
external: true,
title: 'Pools inventory',
description: 'Open the live pools page instead of dropping into a raw backend response.',
href: '/pools',
label: 'Open pools inventory',
},
{
title: 'Bridge monitoring',
@@ -103,7 +102,7 @@ export const explorerFeaturePages = {
{
title: 'Operations hub',
description: 'Open the consolidated page for WETH utilities, analytics, operator shortcuts, and system views.',
href: '/more',
href: '/operations',
label: 'Open operations hub',
},
],
@@ -137,7 +136,7 @@ export const explorerFeaturePages = {
{
title: 'Operations hub',
description: 'Return to the larger operations landing page for adjacent route, analytics, and system shortcuts.',
href: '/more',
href: '/operations',
label: 'Open operations hub',
},
],
@@ -180,7 +179,7 @@ export const explorerFeaturePages = {
eyebrow: 'Operator Shortcuts',
title: 'Operator Panel Shortcuts',
description:
'Expose the public operational shortcuts that were restored in the legacy explorer for bridge checks, route validation, liquidity entry points, and documentation.',
'Expose the public operational shortcuts for bridge checks, route validation, liquidity entry points, and documentation.',
note: legacyNote,
actions: [
{
@@ -203,10 +202,9 @@ export const explorerFeaturePages = {
},
{
title: 'Explorer docs',
description: 'Use the static documentation landing page for explorer-specific reference material.',
href: '/docs.html',
description: 'Open the canonical explorer documentation hub for GRU guidance, transaction evidence notes, and public reference material.',
href: '/docs',
label: 'Open docs',
external: true,
},
{
title: 'Visual command center',
@@ -239,24 +237,23 @@ export const explorerFeaturePages = {
},
{
title: 'Explorer docs',
description: 'Open the documentation landing page for static reference material shipped with the explorer.',
href: '/docs.html',
description: 'Open the canonical explorer documentation hub for public reference material and guide pages.',
href: '/docs',
label: 'Open docs',
external: true,
},
{
title: 'Operations hub',
description: 'Return to the consolidated operations landing page for adjacent public tools.',
href: '/more',
href: '/operations',
label: 'Open operations hub',
},
],
},
more: {
operations: {
eyebrow: 'Operations Hub',
title: 'More Explorer Tools',
title: 'Operations Hub',
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,
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 { Card, Table, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { addressesApi, AddressInfo, TransactionSummary } from '@/services/api/addresses'
import { formatWeiAsEth } from '@/utils/format'
import {
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 EntityBadge from '@/components/common/EntityBadge'
import {
isWatchlistEntry,
readWatchlistFromStorage,
writeWatchlistToStorage,
normalizeWatchlistAddress,
} 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() {
const router = useRouter()
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 [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
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 [methodResults, setMethodResults] = useState<Record<string, { loading: boolean; value?: string; error?: string }>>({})
const [methodInputs, setMethodInputs] = useState<Record<string, string[]>>({})
const [loading, setLoading] = useState(true)
const loadAddressInfo = useCallback(async () => {
@@ -29,22 +58,49 @@ export default function AddressDetailPage() {
const { ok, data } = await addressesApi.getSafe(chainId, address)
if (!ok) {
setAddressInfo(null)
setContractProfile(null)
return
}
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) {
console.error('Failed to load address info:', error)
setAddressInfo(null)
setContractProfile(null)
setGruProfile(null)
}
}, [chainId, address])
const loadTransactions = useCallback(async () => {
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 : [])
setTokenBalances(balancesResult.ok ? balancesResult.data : [])
setTokenTransfers(transfersResult.ok ? transfersResult.data : [])
} catch (error) {
console.error('Failed to load transactions:', error)
setTransactions([])
setTokenBalances([])
setTokenTransfers([])
} finally {
setLoading(false)
}
@@ -59,9 +115,15 @@ export default function AddressDetailPage() {
}
return
}
if (!isValidAddressParam) {
setLoading(false)
setAddressInfo(null)
setTransactions([])
return
}
loadAddressInfo()
loadTransactions()
}, [address, loadAddressInfo, loadTransactions, router.isReady])
}, [address, isValidAddressParam, loadAddressInfo, loadTransactions, router.isReady])
useEffect(() => {
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 = [
{
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 (
<div className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-6 text-3xl font-bold">
{addressInfo?.label || 'Address'}
</h1>
<PageIntro
eyebrow="Address Detail"
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">
<Link href="/addresses" className="text-primary-600 hover:underline">
@@ -167,9 +441,29 @@ export default function AddressDetailPage() {
<Card className="mb-6">
<p className="text-sm text-gray-600 dark:text-gray-400">Loading address...</p>
</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 ? (
<Card className="mb-6">
<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>
) : (
<>
@@ -178,24 +472,333 @@ export default function AddressDetailPage() {
<DetailRow label="Address">
<Address address={addressInfo.address} />
</DetailRow>
{addressInfo.balance && (
<DetailRow label="Coin Balance">{formatWeiAsEth(addressInfo.balance)}</DetailRow>
)}
<DetailRow label="Watchlist">
{isSavedToWatchlist ? 'Saved for quick access' : 'Not saved yet'}
</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 && (
<DetailRow label="Tags" valueClassName="flex flex-wrap gap-2">
{addressInfo.tags.map((tag, i) => (
<span key={i} className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-sm">
{tag}
</span>
<EntityBadge key={i} label={tag} className="px-2 py-1 text-[11px]" />
))}
</DetailRow>
)}
<DetailRow label="Transactions">{addressInfo.transaction_count}</DetailRow>
<DetailRow label="Tokens">{addressInfo.token_count}</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>
</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">
<Table
columns={transactionColumns}

View File

@@ -1,39 +1,65 @@
'use client'
import type { GetServerSideProps } from 'next'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import { transactionsApi, Transaction } from '@/services/api/transactions'
import { readWatchlistFromStorage } from '@/utils/watchlist'
import PageIntro from '@/components/common/PageIntro'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeTransaction } from '@/services/api/blockscout'
function normalizeAddress(value: string) {
const trimmed = value.trim()
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 chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [query, setQuery] = useState('')
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>([])
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
const [watchlist, setWatchlist] = useState<string[]>([])
useEffect(() => {
if (initialRecentTransactions.length > 0) {
setRecentTransactions(initialRecentTransactions)
return
}
let active = true
transactionsApi.listSafe(chainId, 1, 20).then(({ ok, data }) => {
if (active && ok) {
setRecentTransactions(data)
}
}).catch(() => {
if (active) {
setRecentTransactions([])
}
})
transactionsApi.listSafe(chainId, 1, 20)
.then(({ ok, data }) => {
if (active && ok) {
setRecentTransactions(data)
}
})
.catch(() => {
if (active) {
setRecentTransactions([])
}
})
return () => {
active = false
}
}, [chainId])
}, [chainId, initialRecentTransactions])
useEffect(() => {
if (typeof window === 'undefined') {
@@ -74,7 +100,16 @@ export default function AddressesPage() {
return (
<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">
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
@@ -139,3 +174,17 @@ export default function AddressesPage() {
</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'), {
ssr: false,
})
export default function AnalyticsPage() {
return <AnalyticsOperationsPage />
interface AnalyticsPageProps {
initialStats: ExplorerStats | null
initialTransactionTrend: ExplorerTransactionTrendPoint[]
initialActivitySnapshot: ExplorerRecentActivitySnapshot | null
initialBlocks: Block[]
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 Link from 'next/link'
import { DetailRow } from '@/components/common/DetailRow'
import PageIntro from '@/components/common/PageIntro'
import { formatTimestamp } from '@/utils/format'
export default function BlockDetailPage() {
const router = useRouter()
@@ -41,9 +43,22 @@ export default function BlockDetailPage() {
loadBlock()
}, [isValidBlock, loadBlock, router.isReady])
const gasUtilization = block && block.gas_limit > 0
? Math.round((block.gas_used / block.gas_limit) * 100)
: null
return (
<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">
<Link href="/blocks" className="text-primary-600 hover:underline">
@@ -68,10 +83,26 @@ export default function BlockDetailPage() {
) : !isValidBlock ? (
<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>
<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>
) : !block ? (
<Card>
<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 title="Block Information">
@@ -80,7 +111,7 @@ export default function BlockDetailPage() {
<Address address={block.hash} />
</DetailRow>
<DetailRow label="Timestamp">
{new Date(block.timestamp).toLocaleString()}
{formatTimestamp(block.timestamp)}
</DetailRow>
<DetailRow label="Miner">
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
@@ -88,13 +119,18 @@ export default function BlockDetailPage() {
</Link>
</DetailRow>
<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}
</Link>
</DetailRow>
<DetailRow label="Gas Used">
{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}
</DetailRow>
{gasUtilization != null && (
<DetailRow label="Gas Utilization">
{gasUtilization}%
</DetailRow>
)}
</dl>
</Card>
)}

View File

@@ -1,14 +1,21 @@
'use client'
import type { GetServerSideProps } from 'next'
import { useCallback, useEffect, useState } from 'react'
import { blocksApi, Block } from '@/services/api/blocks'
import { Card, Address } from '@/libs/frontend-ui-primitives'
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 [blocks, setBlocks] = useState<Block[]>([])
const [loading, setLoading] = useState(true)
const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
const [loading, setLoading] = useState(initialBlocks.length === 0)
const [page, setPage] = useState(1)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
@@ -32,15 +39,29 @@ export default function BlocksPage() {
}, [chainId, page, pageSize])
useEffect(() => {
loadBlocks()
}, [loadBlocks])
if (page === 1 && initialBlocks.length > 0) {
setBlocks(initialBlocks)
setLoading(false)
return
}
void loadBlocks()
}, [initialBlocks, loadBlocks, page])
const showPagination = page > 1 || blocks.length > 0
const canGoNext = blocks.length === pageSize
return (
<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 ? (
<Card>
@@ -51,6 +72,14 @@ export default function BlocksPage() {
{blocks.length === 0 ? (
<Card>
<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>
) : (
blocks.map((block) => (
@@ -66,10 +95,16 @@ export default function BlocksPage() {
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
<Address address={block.hash} truncate showCopy={false} />
</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 className="text-left sm:text-right">
<div className="text-sm">
{new Date(block.timestamp).toLocaleString()}
{formatTimestamp(block.timestamp)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{block.transaction_count} transactions
@@ -101,6 +136,38 @@ export default function BlocksPage() {
</button>
</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>
)
}
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'), {
ssr: false,
})
export default function BridgePage() {
return <BridgeMonitoringPage />
interface BridgePageProps {
initialBridgeStatus: MissionControlBridgeStatusResponse | null
}
export default function BridgePage(props: BridgePageProps) {
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