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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ©
|
||||
}
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
¬es,
|
||||
&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,
|
||||
¬esPtr,
|
||||
&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,
|
||||
¬esPtr,
|
||||
&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,
|
||||
¬es,
|
||||
&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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
171
deployment/ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md
Normal file
171
deployment/ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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!**
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
56
deployment/common/nginx-rpc-api-key-gate.conf
Normal file
56
deployment/common/nginx-rpc-api-key-gate.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=SolaceScanScout Next Frontend Service
|
||||
Description=SolaceScan Next Frontend Service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Changelog — SolaceScanScout Explorer
|
||||
# Changelog — SolaceScan Explorer
|
||||
|
||||
All notable frontend and docs changes are listed here.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SolaceScanScout — Additional Recommendations
|
||||
# SolaceScan — Additional Recommendations
|
||||
|
||||
This document lists **further improvements** beyond the upgrades already implemented (Tier 1–3 frontend, API docs, watchlist, labels, i18n, etc.). Items are grouped by effort and dependency (frontend-only vs backend).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: []
|
||||
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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": [...]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
34
frontend/ROUTING_CONVENTIONS.md
Normal file
34
frontend/ROUTING_CONVENTIONS.md
Normal 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.
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
69
frontend/package-lock.json
generated
69
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & Services</div>';
|
||||
html += '<div style="color:var(--text-light); line-height:1.7; margin-bottom:1rem;">Discover more of SolaceScanScout'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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 explorer’s 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>
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import LiquidityOperationsPage from '@/components/explorer/LiquidityOperationsPage'
|
||||
|
||||
export default function LiquidityPage() {
|
||||
return <LiquidityOperationsPage />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
873
frontend/src/components/access/AccessManagementPage.tsx
Normal file
873
frontend/src/components/access/AccessManagementPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
frontend/src/components/common/EntityBadge.tsx
Normal file
52
frontend/src/components/common/EntityBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
178
frontend/src/components/common/ExplorerAgentTool.tsx
Normal file
178
frontend/src/components/common/ExplorerAgentTool.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
214
frontend/src/components/common/GruStandardsCard.tsx
Normal file
214
frontend/src/components/common/GruStandardsCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
45
frontend/src/components/common/PageIntro.tsx
Normal file
45
frontend/src/components/common/PageIntro.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
427
frontend/src/components/home/HomePage.tsx
Normal file
427
frontend/src/components/home/HomePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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">
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
5
frontend/src/pages/access/index.tsx
Normal file
5
frontend/src/pages/access/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AccessManagementPage from '@/components/access/AccessManagementPage'
|
||||
|
||||
export default function AccessPage() {
|
||||
return <AccessManagementPage />
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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))
|
||||
: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
134
frontend/src/pages/docs/gru.tsx
Normal file
134
frontend/src/pages/docs/gru.tsx
Normal 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
Reference in New Issue
Block a user