Compare commits
33 Commits
devin/1778
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eae47b0d1 | ||
|
|
228fa0eef6 | ||
|
|
763ca75c21 | ||
|
|
ab9c1f9f98 | ||
|
|
efd7c8bbcb | ||
|
|
4fac5e4856 | ||
|
|
b213c6547d | ||
|
|
567b4647c0 | ||
|
|
8a61b1bde2 | ||
|
|
f2ebe824bd | ||
|
|
991d1bb07c | ||
|
|
847cfeb48b | ||
|
|
6a64d2fec6 | ||
|
|
7a7dfca221 | ||
|
|
e3ec87c324 | ||
|
|
0778c18e59 | ||
|
|
4b747f0309 | ||
|
|
ca1394c579 | ||
|
|
e14b43e3fe | ||
|
|
64e78dad47 | ||
|
|
654933cb36 | ||
|
|
d4f922c26e | ||
| e5df7c2ea3 | |||
|
|
e397245ec9 | ||
|
|
8cd8bfa195 | ||
|
|
3b7e24080f | ||
|
|
ba08199051 | ||
|
|
0ba2a70c34 | ||
|
|
ac40184d6b | ||
|
|
7a16ddccf7 | ||
|
|
1f5167aded | ||
|
|
f5eb874210 | ||
|
|
1aa81f454a |
@@ -37,7 +37,8 @@ jobs:
|
||||
if [ -z "$BRANCH" ] || [ "$BRANCH" = "HEAD" ]; then
|
||||
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||
fi
|
||||
curl -sSf -X POST "${{ secrets.PHOENIX_DEPLOY_URL }}" \
|
||||
curl -sSf --connect-timeout 10 --max-time 3600 \
|
||||
-X POST "${{ secrets.PHOENIX_DEPLOY_URL }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.PHOENIX_DEPLOY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"repo\":\"${{ gitea.repository }}\",\"sha\":\"${SHA}\",\"branch\":\"${BRANCH}\",\"target\":\"explorer-live\"}"
|
||||
|
||||
70
.gitea/workflows/validate-on-pr.yml
Normal file
70
.gitea/workflows/validate-on-pr.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Validate Explorer
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- '.gitea/workflows/validate-on-pr.yml'
|
||||
- 'frontend/**'
|
||||
- 'scripts/e2e-*.spec.ts'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'playwright.config.ts'
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- '.gitea/workflows/validate-on-pr.yml'
|
||||
- 'frontend/**'
|
||||
- 'scripts/e2e-*.spec.ts'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'playwright.config.ts'
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint, type-check, and unit tests
|
||||
run: npm test
|
||||
|
||||
smoke-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: frontend
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Run live sprint smoke tests
|
||||
env:
|
||||
EXPLORER_URL: https://explorer.d-bis.org
|
||||
run: npm run e2e -- scripts/e2e-sprint-smoke.spec.ts
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -55,6 +55,10 @@ backend/bin/
|
||||
backend/api/rest/cmd/api-server
|
||||
backend/cmd
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Tooling / scratch directories
|
||||
out/
|
||||
cache/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"minor": 1,
|
||||
"patch": 0
|
||||
},
|
||||
"generatedBy": "SolaceScan",
|
||||
"generatedBy": "DBIS Explorer",
|
||||
"timestamp": "2026-03-28T00:00:00Z",
|
||||
"chainId": 138,
|
||||
"chainName": "DeFi Oracle Meta Mainnet",
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
"version": {"major": 1, "minor": 2, "patch": 0},
|
||||
"defaultChainId": 138,
|
||||
"explorerUrl": "https://explorer.d-bis.org",
|
||||
"tokenListUrl": "https://explorer.d-bis.org/api/config/token-list",
|
||||
"generatedBy": "SolaceScan",
|
||||
"tokenListUrl": "https://explorer.d-bis.org/api/v1/report/token-list?chainId=138",
|
||||
"generatedBy": "DBIS Explorer",
|
||||
"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","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":"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://explorer.d-bis.org/token-icons/chain-138.png","https://explorer.d-bis.org/api/v1/report/logo/chain-138","https://explorer.d-bis.org/favicon.ico"],"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"]},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -485,7 +485,7 @@
|
||||
],
|
||||
"blockers": [
|
||||
"Desired public EVM targets still missing cW suites: Wemix.",
|
||||
"Wave 1 transport is still pending for: EUR, JPY, GBP, AUD, CAD, CHF, XAU.",
|
||||
"Wave 1 public-network activation is still pending for: EUR, JPY, GBP, AUD, CAD, CHF, XAU.",
|
||||
"Arbitrum bootstrap remains blocked on the current Mainnet hub leg: tx 0x97df657f0e31341ca852666766e553650531bbcc86621246d041985d7261bb07 reverted before any bridge event was emitted."
|
||||
],
|
||||
"resolutionMatrix": [
|
||||
@@ -540,7 +540,7 @@
|
||||
{
|
||||
"key": "wave1_transport_pending",
|
||||
"state": "open",
|
||||
"blocker": "Wave 1 transport is still pending for: EUR, JPY, GBP, AUD, CAD, CHF, XAU.",
|
||||
"blocker": "Wave 1 public-network activation is still pending for: EUR, JPY, GBP, AUD, CAD, CHF, XAU.",
|
||||
"targets": [
|
||||
{
|
||||
"code": "EUR",
|
||||
@@ -614,7 +614,7 @@
|
||||
],
|
||||
"resolution": [
|
||||
"Enable bridge controls and supervision policy for each Wave 1 canonical asset on Chain 138.",
|
||||
"Set max-outstanding / capacity controls, then promote the canonical symbols into config/gru-transport-active.json.",
|
||||
"Set max-outstanding / capacity controls, then promote the canonical symbols into the GRU public-network overlay.",
|
||||
"Verify the overlay promotion with check-gru-global-priority-rollout.sh and check-gru-v2-chain138-readiness.sh before attaching public liquidity."
|
||||
],
|
||||
"runbooks": [
|
||||
@@ -623,7 +623,7 @@
|
||||
"scripts/verify/check-gru-global-priority-rollout.sh",
|
||||
"scripts/verify/check-gru-v2-chain138-readiness.sh"
|
||||
],
|
||||
"exitCriteria": "Wave 1 transport pending count reaches zero and the overlay reports the seven non-USD assets as live_transport."
|
||||
"exitCriteria": "Wave 1 public-network pending count reaches zero and the overlay reports the seven non-USD assets as live cW public-network representations."
|
||||
},
|
||||
{
|
||||
"key": "first_tier_public_pools_not_live",
|
||||
@@ -801,9 +801,9 @@
|
||||
}
|
||||
],
|
||||
"resolution": [
|
||||
"Complete Wave 1 transport and first-tier public liquidity before promoting the remaining ranked assets.",
|
||||
"Complete Wave 1 public-network activation and first-tier public liquidity before promoting the remaining ranked assets.",
|
||||
"For each backlog asset, add canonical + wrapped symbols to the manifest/rollout plan, deploy contracts, and extend the public pool matrix.",
|
||||
"Promote each new asset through the same transport and public-liquidity gates used for Wave 1."
|
||||
"Promote each new asset through the same public-network and public-liquidity gates used for Wave 1."
|
||||
],
|
||||
"runbooks": [
|
||||
"config/gru-global-priority-currency-rollout.json",
|
||||
@@ -827,7 +827,7 @@
|
||||
"Completed in-repo: 13-asset Chain 138 → SPL target table (WETH + twelve c* → cW* symbols) in config/solana-gru-bridge-lineup.json and docs/03-deployment/CHAIN138_TO_SOLANA_GRU_TOKEN_DEPLOYMENT_LINEUP.md.",
|
||||
"Define and implement SPL mint authority / bridge program wiring; record solanaMint for each asset.",
|
||||
"Replace SolanaRelayService stub with production relay; mainnet-beta E2E both directions.",
|
||||
"Add dedicated verifier coverage and only then promote Solana into active transport inventory and public status surfaces."
|
||||
"Add dedicated verifier coverage and only then promote Solana into active public-network inventory and public status surfaces."
|
||||
],
|
||||
"runbooks": [
|
||||
"config/solana-gru-bridge-lineup.json",
|
||||
@@ -842,7 +842,7 @@
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"This queue is an operator/deployment planning surface. It does not mark queued pools or transports as live.",
|
||||
"This queue is an operator/deployment planning surface. It does not mark queued pools or public-network representations as live.",
|
||||
"Chain 138 canonical venues remain a separate live surface from the public cW mesh."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
"coveredSymbols": 10,
|
||||
"missingSymbols": []
|
||||
},
|
||||
"note": "The public EVM cW token mesh is complete on the currently loaded 10-chain set, but Wemix remains a desired target without a cW suite in deployment-status.json."
|
||||
"note": "The public EVM cW token mesh is aligned to the nine-chain promoted surface (Cronos excluded from that count); Wemix remains a desired target without a cW suite in deployment-status.json."
|
||||
},
|
||||
"transport": {
|
||||
"liveTransportAssets": [
|
||||
@@ -265,7 +265,7 @@
|
||||
"nextStep": "activate_transport_and_attach_public_liquidity"
|
||||
}
|
||||
],
|
||||
"note": "USD is the only live transport asset today. Wave 1 non-USD assets are deployed canonically on Chain 138 but are not yet promoted into the active transport overlay."
|
||||
"note": "USD is the only live cW public-network asset today. Wave 1 non-USD assets are deployed canonically on Chain 138 but are not yet promoted into the active public-network overlay."
|
||||
},
|
||||
"protocols": {
|
||||
"publicCwMesh": [
|
||||
|
||||
@@ -520,7 +520,7 @@ func (s *Server) HandleMissionControlBridgeTrace(w http.ResponseWriter, r *http.
|
||||
"from_registry": fromLabel,
|
||||
"to": toAddr,
|
||||
"to_registry": toLabel,
|
||||
"blockscout_url": publicBase + "/tx/" + strings.ToLower(tx),
|
||||
"blockscout_url": publicBase + "/transactions/" + strings.ToLower(tx),
|
||||
"source": source,
|
||||
}
|
||||
if registryLoadErr != nil && len(reg) == 0 {
|
||||
|
||||
@@ -177,7 +177,7 @@ func TestHandleMissionControlBridgeTraceLabelsFromRegistry(t *testing.T) {
|
||||
require.Equal(t, strings.ToLower(toAddr), out.Data["to"])
|
||||
require.Equal(t, "CHAIN138_SOURCE_BRIDGE", out.Data["from_registry"])
|
||||
require.Equal(t, "CHAIN138_DEST_BRIDGE", out.Data["to_registry"])
|
||||
require.Equal(t, "https://explorer.example.org/tx/0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", out.Data["blockscout_url"])
|
||||
require.Equal(t, "https://explorer.example.org/transactions/0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", out.Data["blockscout_url"])
|
||||
}
|
||||
|
||||
func TestHandleMissionControlBridgeTraceFallsBackToAddressInventoryLabels(t *testing.T) {
|
||||
|
||||
@@ -54,6 +54,8 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/auth/wallet", s.handleAuthWallet)
|
||||
mux.HandleFunc("/api/v1/auth/refresh", s.handleAuthRefresh)
|
||||
mux.HandleFunc("/api/v1/auth/logout", s.handleAuthLogout)
|
||||
mux.HandleFunc("/api/v1/walletconnect/", s.handleWalletConnectRoot)
|
||||
mux.HandleFunc("/api/v1/walletconnect", s.handleWalletConnectRoot)
|
||||
mux.HandleFunc("/api/v1/auth/register", s.handleAuthRegister)
|
||||
mux.HandleFunc("/api/v1/auth/login", s.handleAuthLogin)
|
||||
mux.HandleFunc("/api/v1/access/me", s.handleAccessMe)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/explorer/backend/api/freshness"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type explorerStats struct {
|
||||
@@ -34,6 +35,14 @@ type explorerGasPrices struct {
|
||||
|
||||
type statsQueryFunc = freshness.QueryRowFunc
|
||||
|
||||
type statsErrorRow struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r statsErrorRow) Scan(dest ...any) error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
func queryNullableFloat64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*float64, error) {
|
||||
var value sql.NullFloat64
|
||||
if err := queryRow(ctx, query, args...).Scan(&value); err != nil {
|
||||
@@ -191,23 +200,72 @@ func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func loadExplorerStatsFallback(ctx context.Context, chainID int, cause error) explorerStats {
|
||||
rpcURL := strings.TrimSpace(os.Getenv("RPC_URL"))
|
||||
now := time.Now().UTC()
|
||||
queryErr := fmt.Errorf("blockscout database unavailable")
|
||||
if cause != nil {
|
||||
queryErr = cause
|
||||
}
|
||||
queryRow := func(context.Context, string, ...any) pgx.Row {
|
||||
return statsErrorRow{err: queryErr}
|
||||
}
|
||||
|
||||
snapshot, completeness, sampling, diagnostics, err := freshness.BuildSnapshot(
|
||||
ctx,
|
||||
chainID,
|
||||
queryRow,
|
||||
func(ctx context.Context) (*freshness.Reference, error) {
|
||||
return freshness.ProbeChainHead(ctx, rpcURL)
|
||||
},
|
||||
now,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
if sampling.Issues == nil {
|
||||
sampling.Issues = map[string]string{}
|
||||
}
|
||||
sampling.Issues["fallback_freshness"] = err.Error()
|
||||
}
|
||||
if sampling.Issues == nil {
|
||||
sampling.Issues = map[string]string{}
|
||||
}
|
||||
if cause != nil {
|
||||
sampling.Issues["stats_database"] = cause.Error()
|
||||
}
|
||||
|
||||
stats := explorerStats{
|
||||
Freshness: snapshot,
|
||||
Completeness: completeness,
|
||||
Sampling: sampling,
|
||||
Diagnostics: diagnostics,
|
||||
}
|
||||
if snapshot.ChainHead.BlockNumber != nil {
|
||||
stats.LatestBlock = *snapshot.ChainHead.BlockNumber
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// handleStats handles GET /api/v2/stats
|
||||
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats, err := loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer stats are temporarily unavailable")
|
||||
return
|
||||
var stats explorerStats
|
||||
if s.db == nil {
|
||||
stats = loadExplorerStatsFallback(ctx, s.chainID, fmt.Errorf("database pool is not configured"))
|
||||
} else {
|
||||
var err error
|
||||
stats, err = loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
|
||||
if err != nil {
|
||||
stats = loadExplorerStatsFallback(ctx, s.chainID, err)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -136,3 +136,33 @@ func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "query total transactions")
|
||||
}
|
||||
|
||||
func TestLoadExplorerStatsFallbackUsesRPCHead(t *testing.T) {
|
||||
rpc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "eth_blockNumber":
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x4d2"}`))
|
||||
case "eth_getBlockByNumber":
|
||||
ts := time.Now().Add(-3 * time.Second).Unix()
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x` + strconv.FormatInt(ts, 16) + `"}}`))
|
||||
default:
|
||||
http.Error(w, `{"jsonrpc":"2.0","id":1,"error":{"message":"unsupported"}}`, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer rpc.Close()
|
||||
t.Setenv("RPC_URL", rpc.URL)
|
||||
|
||||
stats := loadExplorerStatsFallback(context.Background(), 138, errors.New("database down"))
|
||||
|
||||
require.Equal(t, int64(1234), stats.LatestBlock)
|
||||
require.NotNil(t, stats.Freshness.ChainHead.BlockNumber)
|
||||
require.Equal(t, int64(1234), *stats.Freshness.ChainHead.BlockNumber)
|
||||
require.Equal(t, freshness.CompletenessUnavailable, stats.Completeness.TransactionsFeed)
|
||||
require.Contains(t, stats.Sampling.Issues, "stats_database")
|
||||
require.Contains(t, stats.Sampling.Issues["latest_indexed_block"], "database down")
|
||||
}
|
||||
|
||||
136
backend/api/rest/walletconnect.go
Normal file
136
backend/api/rest/walletconnect.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/explorer/backend/wallet"
|
||||
)
|
||||
|
||||
func (s *Server) walletConnectHandler() *wallet.WalletConnect {
|
||||
return wallet.NewWalletConnect(s.chainID)
|
||||
}
|
||||
|
||||
// handleWalletConnectConfig handles GET /api/v1/walletconnect/config
|
||||
func (s *Server) handleWalletConnectConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.walletConnectHandler().PublicConfig())
|
||||
}
|
||||
|
||||
// handleWalletConnectMetadata handles GET /api/v1/walletconnect/metadata
|
||||
func (s *Server) handleWalletConnectMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"name": "DBIS Explorer",
|
||||
"description": "Chain 138 explorer by DBIS",
|
||||
"url": "https://explorer.d-bis.org",
|
||||
"icons": []string{"https://explorer.d-bis.org/favicon.ico"},
|
||||
})
|
||||
}
|
||||
|
||||
// handleWalletConnectConnect handles POST /api/v1/walletconnect/connect
|
||||
func (s *Server) handleWalletConnectConnect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := s.walletConnectHandler().Connect(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotImplemented, response)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleWalletConnectSessionRegister handles POST /api/v1/walletconnect/session
|
||||
func (s *Server) handleWalletConnectSessionRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Address string `json:"address"`
|
||||
ChainID int `json:"chainId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := s.walletConnectHandler().RegisterSession(r.Context(), req.SessionID, req.Address, req.ChainID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
}
|
||||
|
||||
// handleWalletConnectSession handles GET /api/v1/walletconnect/session/{id}
|
||||
func (s *Server) handleWalletConnectSession(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := strings.TrimPrefix(r.URL.Path, "/api/v1/walletconnect/session/")
|
||||
sessionID = strings.Trim(sessionID, "/")
|
||||
if sessionID == "" || strings.Contains(sessionID, "/") {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "session id is required")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := s.walletConnectHandler().GetSession(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, session)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
}
|
||||
|
||||
// handleWalletConnectRoot dispatches walletconnect subroutes.
|
||||
func (s *Server) handleWalletConnectRoot(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/walletconnect")
|
||||
path = strings.Trim(path, "/")
|
||||
|
||||
switch path {
|
||||
case "config":
|
||||
s.handleWalletConnectConfig(w, r)
|
||||
case "metadata":
|
||||
s.handleWalletConnectMetadata(w, r)
|
||||
case "connect":
|
||||
s.handleWalletConnectConnect(w, r)
|
||||
case "session":
|
||||
s.handleWalletConnectSessionRegister(w, r)
|
||||
case "":
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"endpoints": []string{
|
||||
"/api/v1/walletconnect/config",
|
||||
"/api/v1/walletconnect/metadata",
|
||||
"/api/v1/walletconnect/connect",
|
||||
"POST /api/v1/walletconnect/session",
|
||||
"/api/v1/walletconnect/session/{sessionId}",
|
||||
},
|
||||
"fallbackAuth": "/api/v1/auth/wallet",
|
||||
})
|
||||
default:
|
||||
if strings.HasPrefix(path, "session/") {
|
||||
s.handleWalletConnectSession(w, r)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not_found", "walletconnect route not found")
|
||||
}
|
||||
}
|
||||
113
backend/api/rest/walletconnect_internal_test.go
Normal file
113
backend/api/rest/walletconnect_internal_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleWalletConnectConfig(t *testing.T) {
|
||||
t.Setenv("WALLETCONNECT_PROJECT_ID", "test-project-id")
|
||||
server := NewServer(nil, 138)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleWalletConnectConfig(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload["projectId"] != "test-project-id" {
|
||||
t.Fatalf("expected project id, got %#v", payload["projectId"])
|
||||
}
|
||||
if payload["fallbackAuth"] != "/api/v1/auth/wallet" {
|
||||
t.Fatalf("expected fallback auth path, got %#v", payload["fallbackAuth"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWalletConnectConnectDisabled(t *testing.T) {
|
||||
t.Setenv("WALLETCONNECT_PROJECT_ID", "")
|
||||
server := NewServer(nil, 138)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/walletconnect/connect", strings.NewReader("{}"))
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleWalletConnectConnect(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("expected 501, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWalletConnectConnectClientMode(t *testing.T) {
|
||||
t.Setenv("WALLETCONNECT_PROJECT_ID", "test-project-id")
|
||||
server := NewServer(nil, 138)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/walletconnect/connect", strings.NewReader("{}"))
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleWalletConnectConnect(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload["status"] != "client" {
|
||||
t.Fatalf("expected client status, got %#v", payload["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWalletConnectSessionMissing(t *testing.T) {
|
||||
server := NewServer(nil, 138)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect/session/demo-session", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleWalletConnectSession(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWalletConnectSessionRegister(t *testing.T) {
|
||||
server := NewServer(nil, 138)
|
||||
|
||||
body := strings.NewReader(`{"sessionId":"wc-demo","address":"0x4A666F96fC8764181194447A7dFdb7d471b301C8","chainId":138}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/walletconnect/session", body)
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleWalletConnectSessionRegister(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect/session/wc-demo", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
server.handleWalletConnectSession(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected lookup 200, got %d", getRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWalletConnectRootIndex(t *testing.T) {
|
||||
_ = os.Setenv("WALLETCONNECT_PROJECT_ID", "")
|
||||
server := NewServer(nil, 138)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleWalletConnectRoot(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
255
backend/api/track1/bridge_lanes.go
Normal file
255
backend/api/track1/bridge_lanes.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package track1
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed bridge_lanes_default.json
|
||||
var defaultBridgeLanesJSON []byte
|
||||
|
||||
type bridgeLaneDefinition struct {
|
||||
Key string `json:"key"`
|
||||
ChainName string `json:"chain_name"`
|
||||
ChainID int64 `json:"chain_id"`
|
||||
ConfigReady bool `json:"config_ready"`
|
||||
RPCEnvs []string `json:"rpc_envs"`
|
||||
RPCDefault string `json:"rpc_default"`
|
||||
LinkToken string `json:"link_token"`
|
||||
WETH9Bridge string `json:"weth9_bridge"`
|
||||
WETH10Bridge string `json:"weth10_bridge"`
|
||||
}
|
||||
|
||||
type bridgeLanesConfig struct {
|
||||
Updated string `json:"updated"`
|
||||
MinLinkWei string `json:"min_link_wei"`
|
||||
Lanes []bridgeLaneDefinition `json:"lanes"`
|
||||
}
|
||||
|
||||
func loadBridgeLanesConfig() bridgeLanesConfig {
|
||||
path := strings.TrimSpace(os.Getenv("MISSION_CONTROL_BRIDGE_LANES_JSON"))
|
||||
if path != "" {
|
||||
if b, err := os.ReadFile(path); err == nil && len(b) > 0 {
|
||||
var cfg bridgeLanesConfig
|
||||
if json.Unmarshal(b, &cfg) == nil && len(cfg.Lanes) > 0 {
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
}
|
||||
var cfg bridgeLanesConfig
|
||||
_ = json.Unmarshal(defaultBridgeLanesJSON, &cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func resolveLaneRPC(def bridgeLaneDefinition) string {
|
||||
for _, key := range def.RPCEnvs {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for _, row := range ParseExtraRPCProbes() {
|
||||
chainKey := row[2]
|
||||
if chainKey == strconv.FormatInt(def.ChainID, 10) {
|
||||
return row[1]
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(def.RPCDefault)
|
||||
}
|
||||
|
||||
func erc20BalanceOf(ctx context.Context, rpcURL, tokenAddress, holderAddress string) (string, error) {
|
||||
tokenAddress = strings.ToLower(strings.TrimSpace(tokenAddress))
|
||||
holderAddress = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(holderAddress), "0x"))
|
||||
if len(holderAddress) != 40 {
|
||||
return "0", nil
|
||||
}
|
||||
data := "0x70a08231" + strings.Repeat("0", 24) + holderAddress
|
||||
raw, _, err := postJSONRPC(ctx, bridgeLaneHTTPClient(), rpcURL, "eth_call", []interface{}{
|
||||
map[string]interface{}{
|
||||
"to": tokenAddress,
|
||||
"data": data,
|
||||
},
|
||||
"latest",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var hex string
|
||||
if err := json.Unmarshal(raw, &hex); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hex = strings.TrimSpace(hex)
|
||||
if hex == "" || hex == "0x" {
|
||||
return "0", nil
|
||||
}
|
||||
value := new(big.Int)
|
||||
if _, ok := value.SetString(strings.TrimPrefix(hex, "0x"), 16); !ok {
|
||||
return "0", nil
|
||||
}
|
||||
return value.String(), nil
|
||||
}
|
||||
|
||||
func bridgeLaneHTTPClient() *http.Client {
|
||||
return &http.Client{Timeout: 6 * time.Second}
|
||||
}
|
||||
|
||||
func bridgeFundingStatus(linkBalanceWei, minLinkWei string) string {
|
||||
balance, okBalance := new(big.Int).SetString(strings.TrimSpace(linkBalanceWei), 10)
|
||||
minimum, okMin := new(big.Int).SetString(strings.TrimSpace(minLinkWei), 10)
|
||||
if !okBalance || !okMin {
|
||||
return "unknown"
|
||||
}
|
||||
if balance.Cmp(minimum) >= 0 {
|
||||
return "funded"
|
||||
}
|
||||
if balance.Sign() > 0 {
|
||||
return "degraded"
|
||||
}
|
||||
return "unfunded"
|
||||
}
|
||||
|
||||
func proofStatusForLane(key string, proofs map[string]interface{}) string {
|
||||
if proofs == nil {
|
||||
return "proof-pending"
|
||||
}
|
||||
laneProofs, ok := proofs[key].([]interface{})
|
||||
if !ok || len(laneProofs) == 0 {
|
||||
return "proof-pending"
|
||||
}
|
||||
for _, item := range laneProofs {
|
||||
row, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tx, ok := row["tx_hash"].(string); ok && strings.TrimSpace(tx) != "" {
|
||||
return "proof-recorded"
|
||||
}
|
||||
}
|
||||
return "proof-pending"
|
||||
}
|
||||
|
||||
func readProofTransfersJSON() map[string]interface{} {
|
||||
path := strings.TrimSpace(os.Getenv("MISSION_CONTROL_PROOF_TRANSFERS_JSON"))
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil || len(b) == 0 {
|
||||
return map[string]interface{}{"error": "unreadable or empty", "path": path}
|
||||
}
|
||||
if len(b) > 512*1024 {
|
||||
return map[string]interface{}{"error": "file too large", "path": path}
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(b, &payload); err != nil {
|
||||
return map[string]interface{}{"error": err.Error(), "path": path}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func probeBridgeContract(ctx context.Context, rpcURL, linkToken, bridgeAddress, minLinkWei string) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
"bridge": strings.TrimSpace(bridgeAddress),
|
||||
}
|
||||
if rpcURL == "" {
|
||||
result["status"] = "unknown"
|
||||
result["error"] = "rpc unavailable"
|
||||
return result
|
||||
}
|
||||
if linkToken == "" || bridgeAddress == "" {
|
||||
result["status"] = "unknown"
|
||||
result["error"] = "missing link token or bridge address"
|
||||
return result
|
||||
}
|
||||
balance, err := erc20BalanceOf(ctx, rpcURL, linkToken, bridgeAddress)
|
||||
if err != nil {
|
||||
result["status"] = "unknown"
|
||||
result["error"] = err.Error()
|
||||
return result
|
||||
}
|
||||
result["link_balance_wei"] = balance
|
||||
result["status"] = bridgeFundingStatus(balance, minLinkWei)
|
||||
return result
|
||||
}
|
||||
|
||||
func aggregateLaneStatus(weth9Status, weth10Status, proofStatus string) string {
|
||||
statuses := []string{weth9Status, weth10Status}
|
||||
hasUnfunded := false
|
||||
hasDegraded := false
|
||||
hasUnknown := false
|
||||
for _, status := range statuses {
|
||||
switch status {
|
||||
case "unfunded":
|
||||
hasUnfunded = true
|
||||
case "degraded":
|
||||
hasDegraded = true
|
||||
case "unknown":
|
||||
hasUnknown = true
|
||||
}
|
||||
}
|
||||
if hasUnfunded {
|
||||
return "unfunded"
|
||||
}
|
||||
if hasDegraded {
|
||||
return "degraded"
|
||||
}
|
||||
if hasUnknown {
|
||||
return "unknown"
|
||||
}
|
||||
if proofStatus == "proof-pending" {
|
||||
return "proof-pending"
|
||||
}
|
||||
return "funded"
|
||||
}
|
||||
|
||||
func BuildBridgeLaneHealth(ctx context.Context) (map[string]interface{}, map[string]interface{}) {
|
||||
cfg := loadBridgeLanesConfig()
|
||||
minLinkWei := strings.TrimSpace(cfg.MinLinkWei)
|
||||
if minLinkWei == "" {
|
||||
minLinkWei = "1000000000000000000"
|
||||
}
|
||||
|
||||
proofPayload := readProofTransfersJSON()
|
||||
proofByLane := map[string]interface{}{}
|
||||
if proofPayload != nil {
|
||||
if lanes, ok := proofPayload["lanes"].(map[string]interface{}); ok {
|
||||
proofByLane = lanes
|
||||
}
|
||||
}
|
||||
|
||||
lanes := make([]map[string]interface{}, 0, len(cfg.Lanes))
|
||||
for _, def := range cfg.Lanes {
|
||||
rpcURL := resolveLaneRPC(def)
|
||||
weth9 := probeBridgeContract(ctx, rpcURL, def.LinkToken, def.WETH9Bridge, minLinkWei)
|
||||
weth10 := probeBridgeContract(ctx, rpcURL, def.LinkToken, def.WETH10Bridge, minLinkWei)
|
||||
weth9Status, _ := weth9["status"].(string)
|
||||
weth10Status, _ := weth10["status"].(string)
|
||||
proofStatus := proofStatusForLane(def.Key, proofByLane)
|
||||
|
||||
lanes = append(lanes, map[string]interface{}{
|
||||
"key": def.Key,
|
||||
"chain_name": def.ChainName,
|
||||
"chain_id": def.ChainID,
|
||||
"config_ready": def.ConfigReady,
|
||||
"link_token": def.LinkToken,
|
||||
"status": aggregateLaneStatus(weth9Status, weth10Status, proofStatus),
|
||||
"proof_status": proofStatus,
|
||||
"weth9": weth9,
|
||||
"weth10": weth10,
|
||||
"rpc_endpoint": redactRPCOrigin(rpcURL),
|
||||
})
|
||||
}
|
||||
|
||||
laneHealth := map[string]interface{}{
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"min_link_wei": minLinkWei,
|
||||
"lanes": lanes,
|
||||
}
|
||||
return laneHealth, proofPayload
|
||||
}
|
||||
61
backend/api/track1/bridge_lanes_default.json
Normal file
61
backend/api/track1/bridge_lanes_default.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"updated": "2026-05-23",
|
||||
"min_link_wei": "1000000000000000000",
|
||||
"lanes": [
|
||||
{
|
||||
"key": "chain138",
|
||||
"chain_name": "Defi Oracle Meta Mainnet (138)",
|
||||
"chain_id": 138,
|
||||
"config_ready": true,
|
||||
"rpc_envs": ["RPC_URL", "RPC_URL_138"],
|
||||
"rpc_default": "http://192.168.11.211:8545",
|
||||
"link_token": "0xb7721dd53a8c629d9f1ba31a5819afe250002b03",
|
||||
"weth9_bridge": "0xcacfd227A040002e49e2e01626363071324f820a",
|
||||
"weth10_bridge": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0"
|
||||
},
|
||||
{
|
||||
"key": "gnosis",
|
||||
"chain_name": "Gnosis (100)",
|
||||
"chain_id": 100,
|
||||
"config_ready": true,
|
||||
"rpc_envs": ["GNOSIS_RPC", "GNOSIS_MAINNET_RPC", "GNOSIS_RPC_URL"],
|
||||
"rpc_default": "https://rpc.gnosischain.com",
|
||||
"link_token": "0xE2e73A1c69ecF83F464EFCE6A5be353a37cA09b2",
|
||||
"weth9_bridge": "0xc8656F24488cb90c452058da92d1a25BA464eaAE",
|
||||
"weth10_bridge": "0xa846aeAD3071df1b6439d5D813156aCE7C2c1DA1"
|
||||
},
|
||||
{
|
||||
"key": "cronos",
|
||||
"chain_name": "Cronos (25)",
|
||||
"chain_id": 25,
|
||||
"config_ready": true,
|
||||
"rpc_envs": ["CRONOS_RPC", "CRONOS_RPC_URL", "CRONOS_MAINNET_RPC"],
|
||||
"rpc_default": "https://evm.cronos.org",
|
||||
"link_token": "0x8c80A01F461f297Df7F9DA3A4f740D7297C8Ac85",
|
||||
"weth9_bridge": "0x3Cc23d086fCcbAe1e5f3FE2bA4A263E1D27d8Cab",
|
||||
"weth10_bridge": "0x105F8A15b819948a89153505762444Ee9f324684"
|
||||
},
|
||||
{
|
||||
"key": "celo",
|
||||
"chain_name": "Celo (42220)",
|
||||
"chain_id": 42220,
|
||||
"config_ready": true,
|
||||
"rpc_envs": ["CELO_RPC", "CELO_MAINNET_RPC"],
|
||||
"rpc_default": "https://forno.celo.org",
|
||||
"link_token": "0xd07294e6E917e07dfDcee882dd1e2565085C2ae0",
|
||||
"weth9_bridge": "0xAb57BF30F1354CA0590af22D8974c7f24DB2DbD7",
|
||||
"weth10_bridge": "0xa780ef19A041745d353c9432f2a7f5A241335ffE"
|
||||
},
|
||||
{
|
||||
"key": "wemix",
|
||||
"chain_name": "Wemix (1111)",
|
||||
"chain_id": 1111,
|
||||
"config_ready": true,
|
||||
"rpc_envs": ["WEMIX_RPC", "WEMIX_MAINNET_RPC"],
|
||||
"rpc_default": "https://api.wemix.com",
|
||||
"link_token": "0x80f1FcdC96B55e459BF52b998aBBE2c364935d69",
|
||||
"weth9_bridge": "0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04",
|
||||
"weth10_bridge": "0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08"
|
||||
}
|
||||
]
|
||||
}
|
||||
37
backend/api/track1/bridge_lanes_test.go
Normal file
37
backend/api/track1/bridge_lanes_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package track1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBridgeFundingStatus(t *testing.T) {
|
||||
require.Equal(t, "funded", bridgeFundingStatus("2000000000000000000", "1000000000000000000"))
|
||||
require.Equal(t, "degraded", bridgeFundingStatus("500000000000000000", "1000000000000000000"))
|
||||
require.Equal(t, "unfunded", bridgeFundingStatus("0", "1000000000000000000"))
|
||||
}
|
||||
|
||||
func TestAggregateLaneStatus(t *testing.T) {
|
||||
require.Equal(t, "unfunded", aggregateLaneStatus("unfunded", "funded", "proof-recorded"))
|
||||
require.Equal(t, "degraded", aggregateLaneStatus("degraded", "funded", "proof-recorded"))
|
||||
require.Equal(t, "proof-pending", aggregateLaneStatus("funded", "funded", "proof-pending"))
|
||||
require.Equal(t, "funded", aggregateLaneStatus("funded", "funded", "proof-recorded"))
|
||||
}
|
||||
|
||||
func TestProofStatusForLane(t *testing.T) {
|
||||
proofs := map[string]interface{}{
|
||||
"gnosis": []interface{}{
|
||||
map[string]interface{}{"tx_hash": "0xabc"},
|
||||
},
|
||||
}
|
||||
require.Equal(t, "proof-recorded", proofStatusForLane("gnosis", proofs))
|
||||
require.Equal(t, "proof-pending", proofStatusForLane("cronos", proofs))
|
||||
}
|
||||
|
||||
func TestLoadBridgeLanesConfigDefault(t *testing.T) {
|
||||
t.Setenv("MISSION_CONTROL_BRIDGE_LANES_JSON", "")
|
||||
cfg := loadBridgeLanesConfig()
|
||||
require.NotEmpty(t, cfg.Lanes)
|
||||
require.NotEmpty(t, cfg.MinLinkWei)
|
||||
}
|
||||
78
backend/api/track1/bridge_mode.go
Normal file
78
backend/api/track1/bridge_mode.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package track1
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/explorer/backend/api/freshness"
|
||||
)
|
||||
|
||||
type bridgeDeliveryMode struct {
|
||||
Kind string
|
||||
Reason any
|
||||
Scope any
|
||||
}
|
||||
|
||||
func resolveBridgeDeliveryMode(hasRelays bool, diagnostics *freshness.Diagnostics, txFeed freshness.Completeness) bridgeDeliveryMode {
|
||||
if !hasRelays {
|
||||
if diagnostics != nil && isStaleTransactionVisibility(diagnostics) {
|
||||
return bridgeDeliveryMode{
|
||||
Kind: "mixed",
|
||||
Reason: "partial_observability_inputs",
|
||||
Scope: "homepage_summary_only",
|
||||
}
|
||||
}
|
||||
return bridgeDeliveryMode{
|
||||
Kind: "live",
|
||||
Reason: nil,
|
||||
Scope: nil,
|
||||
}
|
||||
}
|
||||
|
||||
if diagnostics != nil && isStaleTransactionVisibility(diagnostics) {
|
||||
return bridgeDeliveryMode{
|
||||
Kind: "mixed",
|
||||
Reason: "relay_snapshot_only_source",
|
||||
Scope: "bridge_monitoring_and_homepage",
|
||||
}
|
||||
}
|
||||
|
||||
if txFeed == freshness.CompletenessPartial || txFeed == freshness.CompletenessStale {
|
||||
return bridgeDeliveryMode{
|
||||
Kind: "mixed",
|
||||
Reason: "partial_observability_inputs",
|
||||
Scope: "bridge_monitoring_and_homepage",
|
||||
}
|
||||
}
|
||||
|
||||
return bridgeDeliveryMode{
|
||||
Kind: "snapshot",
|
||||
Reason: "live_homepage_stream_not_attached",
|
||||
Scope: "relay_monitoring_homepage_card_only",
|
||||
}
|
||||
}
|
||||
|
||||
func isStaleTransactionVisibility(diagnostics *freshness.Diagnostics) bool {
|
||||
if diagnostics == nil {
|
||||
return false
|
||||
}
|
||||
state := strings.ToLower(strings.TrimSpace(diagnostics.ActivityState))
|
||||
switch state {
|
||||
case "fresh_head_stale_transaction_visibility", "lagging", "stale_transaction_visibility":
|
||||
return true
|
||||
default:
|
||||
return strings.Contains(state, "stale") && strings.Contains(state, "transaction")
|
||||
}
|
||||
}
|
||||
|
||||
func buildBridgeModePayload(now string, resolved bridgeDeliveryMode) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"kind": resolved.Kind,
|
||||
"updated_at": now,
|
||||
"age_seconds": int64(0),
|
||||
"reason": resolved.Reason,
|
||||
"scope": resolved.Scope,
|
||||
"source": freshness.SourceReported,
|
||||
"confidence": freshness.ConfidenceHigh,
|
||||
"provenance": freshness.ProvenanceMissionFeed,
|
||||
}
|
||||
}
|
||||
44
backend/api/track1/bridge_mode_test.go
Normal file
44
backend/api/track1/bridge_mode_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package track1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/explorer/backend/api/freshness"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResolveBridgeDeliveryModeLiveWithoutRelays(t *testing.T) {
|
||||
got := resolveBridgeDeliveryMode(false, nil, freshness.CompletenessComplete)
|
||||
require.Equal(t, "live", got.Kind)
|
||||
require.Nil(t, got.Reason)
|
||||
}
|
||||
|
||||
func TestResolveBridgeDeliveryModeSnapshotWithRelays(t *testing.T) {
|
||||
got := resolveBridgeDeliveryMode(true, nil, freshness.CompletenessComplete)
|
||||
require.Equal(t, "snapshot", got.Kind)
|
||||
require.Equal(t, "live_homepage_stream_not_attached", got.Reason)
|
||||
require.Equal(t, "relay_monitoring_homepage_card_only", got.Scope)
|
||||
}
|
||||
|
||||
func TestResolveBridgeDeliveryModeMixedWhenTransactionVisibilityStale(t *testing.T) {
|
||||
diagnostics := &freshness.Diagnostics{
|
||||
ActivityState: "fresh_head_stale_transaction_visibility",
|
||||
}
|
||||
got := resolveBridgeDeliveryMode(true, diagnostics, freshness.CompletenessPartial)
|
||||
require.Equal(t, "mixed", got.Kind)
|
||||
require.Equal(t, "relay_snapshot_only_source", got.Reason)
|
||||
require.Equal(t, "bridge_monitoring_and_homepage", got.Scope)
|
||||
}
|
||||
|
||||
func TestResolveBridgeDeliveryModeMixedWhenQuietChain(t *testing.T) {
|
||||
diagnostics := &freshness.Diagnostics{
|
||||
ActivityState: "quiet_chain",
|
||||
}
|
||||
got := resolveBridgeDeliveryMode(false, diagnostics, freshness.CompletenessComplete)
|
||||
require.Equal(t, "live", got.Kind)
|
||||
}
|
||||
|
||||
func TestIsStaleTransactionVisibility(t *testing.T) {
|
||||
require.True(t, isStaleTransactionVisibility(&freshness.Diagnostics{ActivityState: "fresh_head_stale_transaction_visibility"}))
|
||||
require.False(t, isStaleTransactionVisibility(&freshness.Diagnostics{ActivityState: "healthy"}))
|
||||
}
|
||||
@@ -133,6 +133,8 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
|
||||
}
|
||||
if s.freshnessLoader != nil {
|
||||
if snapshot, completeness, sampling, diagnostics, err := s.freshnessLoader(ctx); err == nil && snapshot != nil {
|
||||
txFeed := completeness.TransactionsFeed
|
||||
resolvedMode := resolveBridgeDeliveryMode(false, diagnostics, txFeed)
|
||||
subsystems := map[string]interface{}{
|
||||
"rpc_head": map[string]interface{}{
|
||||
"status": chainStatusFromProbe(p138),
|
||||
@@ -174,39 +176,13 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
|
||||
"issues": sampling.Issues,
|
||||
}
|
||||
}
|
||||
modeKind := "live"
|
||||
modeReason := any(nil)
|
||||
modeScope := any(nil)
|
||||
if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
|
||||
modeKind = "snapshot"
|
||||
modeReason = "live_homepage_stream_not_attached"
|
||||
modeScope = "relay_monitoring_homepage_card_only"
|
||||
subsystems["bridge_relay_monitoring"] = map[string]interface{}{
|
||||
"status": overall,
|
||||
"updated_at": now,
|
||||
"age_seconds": int64(0),
|
||||
"source": freshness.SourceReported,
|
||||
"confidence": freshness.ConfidenceHigh,
|
||||
"provenance": freshness.ProvenanceMissionFeed,
|
||||
"completeness": freshness.CompletenessComplete,
|
||||
}
|
||||
}
|
||||
data["freshness"] = snapshot
|
||||
data["subsystems"] = subsystems
|
||||
data["sampling"] = sampling
|
||||
if diagnostics != nil {
|
||||
data["diagnostics"] = diagnostics
|
||||
}
|
||||
data["mode"] = map[string]interface{}{
|
||||
"kind": modeKind,
|
||||
"updated_at": now,
|
||||
"age_seconds": int64(0),
|
||||
"reason": modeReason,
|
||||
"scope": modeScope,
|
||||
"source": freshness.SourceReported,
|
||||
"confidence": freshness.ConfidenceHigh,
|
||||
"provenance": freshness.ProvenanceMissionFeed,
|
||||
}
|
||||
data["mode"] = buildBridgeModePayload(now, resolvedMode)
|
||||
}
|
||||
}
|
||||
if relays := FetchCCIPRelayHealths(ctx); relays != nil {
|
||||
@@ -222,11 +198,30 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
|
||||
}
|
||||
}
|
||||
}
|
||||
if laneHealth, proofTransfers := BuildBridgeLaneHealth(ctx); laneHealth != nil {
|
||||
data["bridge_lanes"] = laneHealth
|
||||
if proofTransfers != nil {
|
||||
data["proof_transfers"] = proofTransfers
|
||||
}
|
||||
}
|
||||
if mode, ok := data["mode"].(map[string]interface{}); ok {
|
||||
if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
|
||||
mode["kind"] = "snapshot"
|
||||
mode["reason"] = "live_homepage_stream_not_attached"
|
||||
mode["scope"] = "relay_monitoring_homepage_card_only"
|
||||
var diagnostics *freshness.Diagnostics
|
||||
if diag, ok := data["diagnostics"].(*freshness.Diagnostics); ok {
|
||||
diagnostics = diag
|
||||
}
|
||||
txFeed := freshness.CompletenessUnavailable
|
||||
if subsystems, ok := data["subsystems"].(map[string]interface{}); ok {
|
||||
if txIndex, ok := subsystems["tx_index"].(map[string]interface{}); ok {
|
||||
if feed, ok := txIndex["completeness"].(freshness.Completeness); ok {
|
||||
txFeed = feed
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved := resolveBridgeDeliveryMode(true, diagnostics, txFeed)
|
||||
mode["kind"] = resolved.Kind
|
||||
mode["reason"] = resolved.Reason
|
||||
mode["scope"] = resolved.Scope
|
||||
if subsystems, ok := data["subsystems"].(map[string]interface{}); ok {
|
||||
subsystems["bridge_relay_monitoring"] = map[string]interface{}{
|
||||
"status": data["status"],
|
||||
@@ -239,6 +234,9 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
|
||||
resolved := resolveBridgeDeliveryMode(true, nil, freshness.CompletenessUnavailable)
|
||||
data["mode"] = buildBridgeModePayload(now, resolved)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -212,6 +212,11 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
|
||||
require.Contains(t, got, "diagnostics")
|
||||
require.Contains(t, got, "subsystems")
|
||||
require.Contains(t, got, "mode")
|
||||
mode, ok := got["mode"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "mixed", mode["kind"])
|
||||
require.Equal(t, "relay_snapshot_only_source", mode["reason"])
|
||||
require.Equal(t, "bridge_monitoring_and_homepage", mode["scope"])
|
||||
}
|
||||
|
||||
func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) {
|
||||
|
||||
@@ -110,6 +110,12 @@ type WalletAuthResponse struct {
|
||||
InstitutionName string `json:"institution_name,omitempty"`
|
||||
}
|
||||
|
||||
// walletAuthSignMessage returns the EIP-191 plaintext users sign during wallet login.
|
||||
// Must stay in sync with frontend buildWalletMessage() in access.ts and explorer-spa.js.
|
||||
func walletAuthSignMessage(nonce string) string {
|
||||
return fmt.Sprintf("Sign this message to authenticate with DBIS Explorer.\n\nNonce: %s", nonce)
|
||||
}
|
||||
|
||||
// GenerateNonce generates a random nonce for wallet authentication
|
||||
func (w *WalletAuth) GenerateNonce(ctx context.Context, address string) (*NonceResponse, error) {
|
||||
// Validate address format
|
||||
@@ -184,7 +190,7 @@ func (w *WalletAuth) AuthenticateWallet(ctx context.Context, req *WalletAuthRequ
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
message := fmt.Sprintf("Sign this message to authenticate with SolaceScan.\n\nNonce: %s", req.Nonce)
|
||||
message := walletAuthSignMessage(req.Nonce)
|
||||
messageHash := accounts.TextHash([]byte(message))
|
||||
|
||||
sigBytes, err := decodeWalletSignature(req.Signature)
|
||||
|
||||
@@ -5,9 +5,42 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/accounts"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWalletAuthSignMessageMatchesFrontend(t *testing.T) {
|
||||
nonce := "abc123def456"
|
||||
require.Equal(
|
||||
t,
|
||||
"Sign this message to authenticate with DBIS Explorer.\n\nNonce: abc123def456",
|
||||
walletAuthSignMessage(nonce),
|
||||
)
|
||||
}
|
||||
|
||||
func TestAuthenticateWalletRecoversSignerFromFrontendMessage(t *testing.T) {
|
||||
privateKey, err := crypto.GenerateKey()
|
||||
require.NoError(t, err)
|
||||
address := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
|
||||
nonce := "test-nonce-001"
|
||||
|
||||
message := walletAuthSignMessage(nonce)
|
||||
messageHash := accounts.TextHash([]byte(message))
|
||||
signature, err := crypto.Sign(messageHash, privateKey)
|
||||
require.NoError(t, err)
|
||||
signature[64] += 27
|
||||
|
||||
sigBytes := make([]byte, len(signature))
|
||||
copy(sigBytes, signature)
|
||||
if sigBytes[64] >= 27 {
|
||||
sigBytes[64] -= 27
|
||||
}
|
||||
pubKey, err := crypto.SigToPub(messageHash, sigBytes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, address, crypto.PubkeyToAddress(*pubKey).Hex())
|
||||
}
|
||||
|
||||
func TestDecodeWalletSignatureRejectsMalformedValues(t *testing.T) {
|
||||
_, err := decodeWalletSignature("deadbeef")
|
||||
require.ErrorContains(t, err, "signature must start with 0x")
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
"https://explorer.d-bis.org"
|
||||
],
|
||||
"iconUrls": [
|
||||
"https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"
|
||||
"https://explorer.d-bis.org/token-icons/chain-138.png",
|
||||
"https://explorer.d-bis.org/api/v1/report/logo/chain-138",
|
||||
"https://explorer.d-bis.org/favicon.ico"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -90,4 +92,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
DROP INDEX IF EXISTS idx_swap_events_token1_price;
|
||||
DROP INDEX IF EXISTS idx_swap_events_token0_price;
|
||||
DROP INDEX IF EXISTS idx_swap_events_chain_tx_log;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_swap_events_unique_log
|
||||
ON swap_events (
|
||||
chain_id,
|
||||
pool_address,
|
||||
COALESCE(transaction_hash, ''),
|
||||
COALESCE(log_index, -1)
|
||||
);
|
||||
|
||||
ALTER TABLE IF EXISTS swap_events
|
||||
DROP COLUMN IF EXISTS to_address,
|
||||
DROP COLUMN IF EXISTS sender,
|
||||
DROP COLUMN IF EXISTS token1_price_usd,
|
||||
DROP COLUMN IF EXISTS token0_price_usd,
|
||||
DROP COLUMN IF EXISTS price_usd,
|
||||
DROP COLUMN IF EXISTS amount1_out,
|
||||
DROP COLUMN IF EXISTS amount0_out,
|
||||
DROP COLUMN IF EXISTS amount1_in,
|
||||
DROP COLUMN IF EXISTS amount0_in;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Migration: Add per-token USD price columns to swap_events
|
||||
-- Description: Aligns lightweight swap_events schema with token-aggregation writer and
|
||||
-- enables historical OHLCV generation to derive token-specific candles
|
||||
|
||||
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS amount0_in NUMERIC(78, 0) DEFAULT 0;
|
||||
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS amount1_in NUMERIC(78, 0) DEFAULT 0;
|
||||
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS amount0_out NUMERIC(78, 0) DEFAULT 0;
|
||||
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS amount1_out NUMERIC(78, 0) DEFAULT 0;
|
||||
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS price_usd NUMERIC(30, 8);
|
||||
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS token0_price_usd NUMERIC(30, 8);
|
||||
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS token1_price_usd NUMERIC(30, 8);
|
||||
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS sender VARCHAR(42);
|
||||
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS to_address VARCHAR(42);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_swap_events_token0_price
|
||||
ON swap_events (chain_id, token0_address, timestamp DESC)
|
||||
WHERE token0_price_usd IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_swap_events_token1_price
|
||||
ON swap_events (chain_id, token1_address, timestamp DESC)
|
||||
WHERE token1_price_usd IS NOT NULL;
|
||||
|
||||
DROP INDEX IF EXISTS idx_swap_events_unique_log;
|
||||
DROP INDEX IF EXISTS idx_swap_events_chain_tx_log;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_swap_events_chain_tx_log
|
||||
ON swap_events (chain_id, transaction_hash, log_index);
|
||||
@@ -3,35 +3,148 @@ package wallet
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WalletConnect handles WalletConnect v2 integration
|
||||
const (
|
||||
WalletConnectStatusStub = "stub"
|
||||
WalletConnectStatusClient = "client"
|
||||
WalletConnectStatusDisabled = "disabled"
|
||||
)
|
||||
|
||||
// Config describes the public WalletConnect v2 posture exposed to clients.
|
||||
type Config struct {
|
||||
Status string `json:"status"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ProjectID string `json:"projectId"`
|
||||
RelayURL string `json:"relayUrl"`
|
||||
MetadataURL string `json:"metadataUrl"`
|
||||
RequiredNamespaces []string `json:"requiredNamespaces"`
|
||||
SupportedChains []int `json:"supportedChains"`
|
||||
FallbackAuth string `json:"fallbackAuth"`
|
||||
Message string `json:"message"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ConnectResponse is returned while WalletConnect session bridging remains a stub.
|
||||
type ConnectResponse struct {
|
||||
Status string `json:"status"`
|
||||
Enabled bool `json:"enabled"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
FallbackAuth string `json:"fallbackAuth"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Session represents a wallet session snapshot for future WalletConnect integration.
|
||||
type Session struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Address string `json:"address,omitempty"`
|
||||
ChainID int `json:"chainId,omitempty"`
|
||||
Connected bool `json:"connected"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// WalletConnect handles WalletConnect v2 integration posture for the explorer API.
|
||||
type WalletConnect struct {
|
||||
projectID string
|
||||
relayURL string
|
||||
chainID int
|
||||
}
|
||||
|
||||
// NewWalletConnect creates a new WalletConnect handler
|
||||
func NewWalletConnect(projectID string) *WalletConnect {
|
||||
return &WalletConnect{projectID: projectID}
|
||||
// NewWalletConnect creates a WalletConnect handler using deployment env vars.
|
||||
func NewWalletConnect(chainID int) *WalletConnect {
|
||||
projectID := strings.TrimSpace(os.Getenv("WALLETCONNECT_PROJECT_ID"))
|
||||
relayURL := strings.TrimSpace(os.Getenv("WALLETCONNECT_RELAY_URL"))
|
||||
if relayURL == "" {
|
||||
relayURL = "wss://relay.walletconnect.org"
|
||||
}
|
||||
return &WalletConnect{
|
||||
projectID: projectID,
|
||||
relayURL: relayURL,
|
||||
chainID: chainID,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect initiates a wallet connection
|
||||
func (wc *WalletConnect) Connect(ctx context.Context) (string, error) {
|
||||
// Implementation would use WalletConnect v2 SDK
|
||||
// Returns connection URI for QR code display
|
||||
return "", fmt.Errorf("not implemented - requires WalletConnect SDK")
|
||||
func (wc *WalletConnect) enabled() bool {
|
||||
return wc.projectID != ""
|
||||
}
|
||||
|
||||
// Session represents a wallet session
|
||||
type Session struct {
|
||||
Address string
|
||||
ChainID int
|
||||
Connected bool
|
||||
// PublicConfig returns the read-only WalletConnect config surface for clients.
|
||||
func (wc *WalletConnect) PublicConfig() Config {
|
||||
status := WalletConnectStatusDisabled
|
||||
if wc.enabled() {
|
||||
status = WalletConnectStatusClient
|
||||
}
|
||||
return Config{
|
||||
Status: status,
|
||||
Enabled: wc.enabled(),
|
||||
ProjectID: wc.projectID,
|
||||
RelayURL: wc.relayURL,
|
||||
MetadataURL: "/api/v1/walletconnect/metadata",
|
||||
RequiredNamespaces: []string{"eip155"},
|
||||
SupportedChains: []int{wc.chainID, 1},
|
||||
FallbackAuth: "/api/v1/auth/wallet",
|
||||
Message: wc.publicMessage(),
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// GetSession gets current wallet session
|
||||
func (wc *WalletConnect) GetSession(ctx context.Context, sessionID string) (*Session, error) {
|
||||
// Implementation would retrieve session from WalletConnect
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
func (wc *WalletConnect) publicMessage() string {
|
||||
if wc.enabled() {
|
||||
return "WalletConnect v2 is enabled. Use the WalletConnect button on /wallet for mobile QR pairing; browser extension wallets can continue using /api/v1/auth/wallet."
|
||||
}
|
||||
return "WalletConnect v2 is not configured. Set WALLETCONNECT_PROJECT_ID to publish relay config; browser wallet auth remains available at /api/v1/auth/wallet."
|
||||
}
|
||||
|
||||
// Connect reports client-side WalletConnect posture. Pairing runs in the browser when projectId is published.
|
||||
func (wc *WalletConnect) Connect(_ context.Context) (*ConnectResponse, error) {
|
||||
if !wc.enabled() {
|
||||
return &ConnectResponse{
|
||||
Status: WalletConnectStatusDisabled,
|
||||
Enabled: false,
|
||||
FallbackAuth: "/api/v1/auth/wallet",
|
||||
Message: wc.publicMessage(),
|
||||
}, fmt.Errorf("walletconnect is disabled")
|
||||
}
|
||||
return &ConnectResponse{
|
||||
Status: "client",
|
||||
Enabled: true,
|
||||
FallbackAuth: "/api/v1/auth/wallet",
|
||||
Message: "Initialize WalletConnect in the browser via /wallet using the published projectId; authenticate with /api/v1/auth/wallet after pairing.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetSession returns a registered browser-paired WalletConnect session snapshot.
|
||||
func (wc *WalletConnect) GetSession(_ context.Context, sessionID string) (*Session, error) {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return nil, fmt.Errorf("session id is required")
|
||||
}
|
||||
if session, ok := lookupWalletConnectSession(sessionID); ok {
|
||||
return session, nil
|
||||
}
|
||||
return &Session{
|
||||
SessionID: sessionID,
|
||||
Connected: false,
|
||||
Status: WalletConnectStatusClient,
|
||||
Message: "Session not registered yet. Pair on /wallet, then POST /api/v1/walletconnect/session with sessionId and address.",
|
||||
}, fmt.Errorf("walletconnect session not found")
|
||||
}
|
||||
|
||||
// RegisterSession stores a client-paired WalletConnect session for operator lookup.
|
||||
func (wc *WalletConnect) RegisterSession(_ context.Context, sessionID, address string, chainID int) (*Session, error) {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return nil, fmt.Errorf("session id is required")
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(address), "0x") || len(address) != 42 {
|
||||
return nil, fmt.Errorf("valid address is required")
|
||||
}
|
||||
if chainID <= 0 {
|
||||
chainID = wc.chainID
|
||||
}
|
||||
return RegisterClientSession(sessionID, address, chainID), nil
|
||||
}
|
||||
|
||||
88
backend/wallet/walletconnect_sessions.go
Normal file
88
backend/wallet/walletconnect_sessions.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const walletConnectSessionTTL = 24 * time.Hour
|
||||
|
||||
type storedWalletConnectSession struct {
|
||||
SessionID string
|
||||
Address string
|
||||
ChainID int
|
||||
Connected bool
|
||||
Status string
|
||||
Message string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
walletConnectSessionMu sync.RWMutex
|
||||
walletConnectSessions = map[string]storedWalletConnectSession{}
|
||||
)
|
||||
|
||||
func purgeExpiredWalletConnectSessions(now time.Time) {
|
||||
for id, session := range walletConnectSessions {
|
||||
if now.Sub(session.UpdatedAt) > walletConnectSessionTTL {
|
||||
delete(walletConnectSessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterClientSession records a browser-paired WalletConnect session snapshot.
|
||||
func RegisterClientSession(sessionID, address string, chainID int) *Session {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
address = strings.TrimSpace(address)
|
||||
now := time.Now().UTC()
|
||||
|
||||
walletConnectSessionMu.Lock()
|
||||
defer walletConnectSessionMu.Unlock()
|
||||
purgeExpiredWalletConnectSessions(now)
|
||||
|
||||
record := storedWalletConnectSession{
|
||||
SessionID: sessionID,
|
||||
Address: address,
|
||||
ChainID: chainID,
|
||||
Connected: true,
|
||||
Status: WalletConnectStatusClient,
|
||||
Message: "WalletConnect session registered by browser client after pairing.",
|
||||
UpdatedAt: now,
|
||||
}
|
||||
walletConnectSessions[sessionID] = record
|
||||
return sessionFromStored(record)
|
||||
}
|
||||
|
||||
func lookupWalletConnectSession(sessionID string) (*Session, bool) {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
walletConnectSessionMu.Lock()
|
||||
defer walletConnectSessionMu.Unlock()
|
||||
purgeExpiredWalletConnectSessions(now)
|
||||
|
||||
record, ok := walletConnectSessions[sessionID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if now.Sub(record.UpdatedAt) > walletConnectSessionTTL {
|
||||
delete(walletConnectSessions, sessionID)
|
||||
return nil, false
|
||||
}
|
||||
return sessionFromStored(record), true
|
||||
}
|
||||
|
||||
func sessionFromStored(record storedWalletConnectSession) *Session {
|
||||
return &Session{
|
||||
SessionID: record.SessionID,
|
||||
Address: record.Address,
|
||||
ChainID: record.ChainID,
|
||||
Connected: record.Connected,
|
||||
Status: record.Status,
|
||||
Message: record.Message,
|
||||
}
|
||||
}
|
||||
25
backend/wallet/walletconnect_sessions_test.go
Normal file
25
backend/wallet/walletconnect_sessions_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegisterAndLookupWalletConnectSession(t *testing.T) {
|
||||
sessionID := "wc-test-topic-123"
|
||||
address := "0x4A666F96fC8764181194447A7dFdb7d471b301C8"
|
||||
|
||||
registered := RegisterClientSession(sessionID, address, 138)
|
||||
if registered == nil || !registered.Connected {
|
||||
t.Fatalf("expected connected session, got %#v", registered)
|
||||
}
|
||||
|
||||
wc := NewWalletConnect(138)
|
||||
session, err := wc.GetSession(context.Background(), sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if session.Address != address {
|
||||
t.Fatalf("expected address %s, got %s", address, session.Address)
|
||||
}
|
||||
}
|
||||
61
config/explorer-bridge-lanes.v1.json
Normal file
61
config/explorer-bridge-lanes.v1.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"updated": "2026-05-23",
|
||||
"min_link_wei": "1000000000000000000",
|
||||
"lanes": [
|
||||
{
|
||||
"key": "chain138",
|
||||
"chain_name": "Defi Oracle Meta Mainnet (138)",
|
||||
"chain_id": 138,
|
||||
"config_ready": true,
|
||||
"rpc_envs": ["RPC_URL", "RPC_URL_138"],
|
||||
"rpc_default": "http://192.168.11.211:8545",
|
||||
"link_token": "0xb7721dd53a8c629d9f1ba31a5819afe250002b03",
|
||||
"weth9_bridge": "0xcacfd227A040002e49e2e01626363071324f820a",
|
||||
"weth10_bridge": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0"
|
||||
},
|
||||
{
|
||||
"key": "gnosis",
|
||||
"chain_name": "Gnosis (100)",
|
||||
"chain_id": 100,
|
||||
"config_ready": true,
|
||||
"rpc_envs": ["GNOSIS_RPC", "GNOSIS_MAINNET_RPC", "GNOSIS_RPC_URL"],
|
||||
"rpc_default": "https://rpc.gnosischain.com",
|
||||
"link_token": "0xE2e73A1c69ecF83F464EFCE6A5be353a37cA09b2",
|
||||
"weth9_bridge": "0xc8656F24488cb90c452058da92d1a25BA464eaAE",
|
||||
"weth10_bridge": "0xa846aeAD3071df1b6439d5D813156aCE7C2c1DA1"
|
||||
},
|
||||
{
|
||||
"key": "cronos",
|
||||
"chain_name": "Cronos (25)",
|
||||
"chain_id": 25,
|
||||
"config_ready": true,
|
||||
"rpc_envs": ["CRONOS_RPC", "CRONOS_RPC_URL", "CRONOS_MAINNET_RPC"],
|
||||
"rpc_default": "https://evm.cronos.org",
|
||||
"link_token": "0x8c80A01F461f297Df7F9DA3A4f740D7297C8Ac85",
|
||||
"weth9_bridge": "0x3Cc23d086fCcbAe1e5f3FE2bA4A263E1D27d8Cab",
|
||||
"weth10_bridge": "0x105F8A15b819948a89153505762444Ee9f324684"
|
||||
},
|
||||
{
|
||||
"key": "celo",
|
||||
"chain_name": "Celo (42220)",
|
||||
"chain_id": 42220,
|
||||
"config_ready": true,
|
||||
"rpc_envs": ["CELO_RPC", "CELO_MAINNET_RPC"],
|
||||
"rpc_default": "https://forno.celo.org",
|
||||
"link_token": "0xd07294e6E917e07dfDcee882dd1e2565085C2ae0",
|
||||
"weth9_bridge": "0xAb57BF30F1354CA0590af22D8974c7f24DB2DbD7",
|
||||
"weth10_bridge": "0xa780ef19A041745d353c9432f2a7f5A241335ffE"
|
||||
},
|
||||
{
|
||||
"key": "wemix",
|
||||
"chain_name": "Wemix (1111)",
|
||||
"chain_id": 1111,
|
||||
"config_ready": true,
|
||||
"rpc_envs": ["WEMIX_RPC", "WEMIX_MAINNET_RPC"],
|
||||
"rpc_default": "https://api.wemix.com",
|
||||
"link_token": "0x80f1FcdC96B55e459BF52b998aBBE2c364935d69",
|
||||
"weth9_bridge": "0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04",
|
||||
"weth10_bridge": "0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
config/explorer-bridge-proof-transfers.example.json
Normal file
21
config/explorer-bridge-proof-transfers.example.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"updated": "2026-05-23T13:07:00Z",
|
||||
"sprint": "tier-a-week-3",
|
||||
"note": "Example shape for MISSION_CONTROL_PROOF_TRANSFERS_JSON. Operator live file: reports/status/bridge-lane-proof-transfers-latest.json (gitignored). Populate with scripts/bridge/run-lane-proof-transfers.sh --execute.",
|
||||
"lanes": {
|
||||
"chain138": [
|
||||
{
|
||||
"tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"amount_eth": "0.001",
|
||||
"dest_chain": "mainnet",
|
||||
"dest_selector": "5009297550715157269",
|
||||
"bridge": "0xcacfd227A040002e49e2e01626363071324f820a",
|
||||
"recorded_at": "2026-05-23T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"gnosis": [],
|
||||
"cronos": [],
|
||||
"celo": [],
|
||||
"wemix": []
|
||||
}
|
||||
}
|
||||
@@ -479,7 +479,7 @@ EOF
|
||||
```bash
|
||||
cat > /etc/systemd/system/solacescanscout-frontend.service << 'EOF'
|
||||
[Unit]
|
||||
Description=SolaceScan Next Frontend Service
|
||||
Description=DBIS Explorer Next Frontend Service
|
||||
After=network.target explorer-api.service
|
||||
Requires=explorer-api.service
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Live Deployment Map
|
||||
|
||||
Current production deployment map for the SolaceScan public explorer surface.
|
||||
Current production deployment map for the DBIS Explorer 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?"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Next.js frontend proxy locations for SolaceScan.
|
||||
# Next.js frontend proxy locations for DBIS Explorer.
|
||||
# Keep the existing higher-priority locations for:
|
||||
# - /api/
|
||||
# - /api/config/token-list
|
||||
@@ -12,6 +12,12 @@
|
||||
# Include these locations after those API/static locations and before any legacy
|
||||
# catch-all that serves /var/www/html/index.html directly.
|
||||
|
||||
location ^~ /legacy/ {
|
||||
alias /var/www/html/legacy/;
|
||||
try_files $uri $uri/ /legacy/index.html;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
}
|
||||
|
||||
location ^~ /_next/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=SolaceScan Next Frontend Service
|
||||
Description=DBIS Explorer Next Frontend Service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ User → chainlist.org → Search "DBIS" → Click "Add to MetaMask"
|
||||
|
||||
```
|
||||
User → MetaMask → Click "View on Explorer"
|
||||
→ MetaMask opens: https://explorer.d-bis.org/tx/{hash}
|
||||
→ MetaMask opens: https://explorer.d-bis.org/transactions/{hash}
|
||||
→ Blockscout displays transaction details
|
||||
→ Blockscout API provides the data
|
||||
```
|
||||
@@ -285,4 +285,3 @@ User → MetaMask → View Token Balance
|
||||
|
||||
**Last Updated**: 2025-12-24
|
||||
**Status**: Analysis Complete
|
||||
|
||||
|
||||
28
docs/EXPLORER_PUBLIC_API_ACCESS.md
Normal file
28
docs/EXPLORER_PUBLIC_API_ACCESS.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Explorer public API access (decision record)
|
||||
|
||||
**Date:** 2026-05-23
|
||||
**Live page:** `/docs/public-api-access`
|
||||
|
||||
## Summary
|
||||
|
||||
| Surface | Auth today | Notes |
|
||||
|---------|------------|-------|
|
||||
| Blockscout read API (`/api/v2/*`) | None | Same-origin proxy to Blockscout |
|
||||
| Public JSON (stats, bridge routes, token lists, etc.) | None | Listed in footer **Public APIs** |
|
||||
| Managed RPC keys | Wallet session on `/access` | `POST /api/v1/access/api-keys` after `/api/v1/auth/wallet` |
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Keep Blockscout and public JSON unauthenticated** for integrators on the public explorer domain.
|
||||
2. **Managed RPC keys** remain the wallet-authenticated product on `/access` — not a Blockscout API-key layer.
|
||||
3. **Future path (Option B):** nginx/API-gateway throttling with optional `X-API-Key` for higher quotas if abuse appears. Full external developer portal remains optional.
|
||||
|
||||
## Integrator flow
|
||||
|
||||
- Read-only: use footer links or `/docs/public-api-access` endpoint list.
|
||||
- Higher limits / RPC: connect wallet on `/wallet`, open `/access`, create scoped keys (tier, product, expiry, quota).
|
||||
|
||||
## Operator
|
||||
|
||||
- No nginx key gate required until rate-limit policy changes.
|
||||
- Support contact: `support@d-bis.org`
|
||||
19
docs/TOKEN_LIST_SURFACES.md
Normal file
19
docs/TOKEN_LIST_SURFACES.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Token list surfaces
|
||||
|
||||
The explorer uses two public token-list endpoints. Application code should pick the list through `getTokenListForSurface()` / `tokensApi.listForSurface()` rather than hard-coding `/api/config/token-list`.
|
||||
|
||||
| Surface | Endpoint | Use when |
|
||||
|---------|----------|----------|
|
||||
| `wallet` | `/api/v1/report/token-list?chainId=138` (fallback: config) | Wallet SSR, MetaMask watch list, featured-token dedup inputs |
|
||||
| `catalog` | report (fallback: config) | `/tokens`, search token inference, homepage price feed curation |
|
||||
| `extended` | `/api/config/token-list` | Full Metamask dual-chain catalog, provenance lookup merge, operations/liquidity/system/pools inventory |
|
||||
|
||||
Report list is the canonical Chain 138 trading set (31 tokens live). Config list is the extended catalog (190+ entries across chains).
|
||||
|
||||
## Page mapping
|
||||
|
||||
| Page / surface | Surface | Notes |
|
||||
|----------------|---------|-------|
|
||||
| `/wallet` | `wallet` | SSR + MetaMask watch list |
|
||||
| `/tokens`, `/search`, homepage price feed | `catalog` | Canonical trading set with config fallback |
|
||||
| `/liquidity`, `/operations`, `/system`, `/pools` | `extended` | Full catalog with `TokenListSurfaceNote` label |
|
||||
@@ -97,7 +97,7 @@
|
||||
- **Wallet status (1639, 1722)** – `statusEl.innerHTML` uses `shortenHash(userAddress)`. If `userAddress` were ever from an untrusted source, it should be escaped. **Action:** Use `escapeHtml(shortenHash(userAddress))` for consistency (in **H1**).
|
||||
- **loadGasAndNetworkStats (2509)** – `el.innerHTML` uses `gasGwei`, `blockTimeSec`, `tps`. These are from API; escaping is low risk but recommended for defense in depth. **Action:** Escape these values (in **H1** or small follow-up).
|
||||
- **Token list: `#/token/' + contract`** – The `contract` in `href="#/token/' + contract + '"` can break the attribute if it contains a quote. **Action:** Encode or validate; include in **H2** (safe href/attributes).
|
||||
- **External link (3800)** – `'https://explorer.d-bis.org/address/' + addr + '/contract'` – `addr` should be validated or encoded so the URL cannot be malformed. **Action:** Use `encodeURIComponent(addr)` for the path segment (in **H2**).
|
||||
- **External link (3800)** – `'https://explorer.d-bis.org/addresses/' + addr + '/contract'` – `addr` should be validated or encoded so the URL cannot be malformed. **Action:** Use `encodeURIComponent(addr)` for the path segment (in **H2**).
|
||||
|
||||
### 2.3 SPA: onclick and attribute injection
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ describe('resolveExplorerApiBase', () => {
|
||||
).toBe('https://blockscout.defi-oracle.io')
|
||||
})
|
||||
|
||||
it('uses browser HTTPS origin when an explicit same-host HTTP value is present', () => {
|
||||
expect(
|
||||
resolveExplorerApiBase({
|
||||
envValue: 'http://explorer.d-bis.org',
|
||||
browserOrigin: 'https://explorer.d-bis.org',
|
||||
})
|
||||
).toBe('https://explorer.d-bis.org')
|
||||
})
|
||||
|
||||
it('falls back to same-origin in the browser when env is empty', () => {
|
||||
expect(
|
||||
resolveExplorerApiBase({
|
||||
|
||||
@@ -4,19 +4,35 @@ function normalizeApiBase(value: string | null | undefined): string {
|
||||
return (value || '').trim().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function preferBrowserOriginForSameHost(explicitBase: string, browserOrigin: string): string {
|
||||
if (!explicitBase || !browserOrigin) return explicitBase
|
||||
|
||||
try {
|
||||
const explicitUrl = new URL(explicitBase)
|
||||
const browserUrl = new URL(browserOrigin)
|
||||
if (explicitUrl.hostname === browserUrl.hostname && explicitUrl.protocol !== browserUrl.protocol) {
|
||||
return browserOrigin
|
||||
}
|
||||
} catch {
|
||||
return explicitBase
|
||||
}
|
||||
|
||||
return explicitBase
|
||||
}
|
||||
|
||||
export function resolveExplorerApiBase(options: {
|
||||
envValue?: string | null
|
||||
browserOrigin?: string | null
|
||||
serverFallback?: string
|
||||
} = {}): string {
|
||||
const explicitBase = normalizeApiBase(options.envValue ?? process.env.NEXT_PUBLIC_API_URL ?? '')
|
||||
if (explicitBase) {
|
||||
return explicitBase
|
||||
}
|
||||
|
||||
const browserOrigin = normalizeApiBase(
|
||||
options.browserOrigin ?? (typeof window !== 'undefined' ? window.location.origin : '')
|
||||
)
|
||||
if (explicitBase) {
|
||||
return preferBrowserOriginForSameHost(explicitBase, browserOrigin)
|
||||
}
|
||||
|
||||
if (browserOrigin) {
|
||||
return browserOrigin
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ export function Card({ children, className, title }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-xl bg-white p-4 shadow-md dark:bg-gray-800 sm:p-6',
|
||||
'rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70 sm:p-5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-white sm:mb-4 sm:text-xl">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900 dark:text-white sm:text-lg">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,11 @@ interface TableProps<T> {
|
||||
data: T[]
|
||||
className?: string
|
||||
emptyMessage?: string
|
||||
/**
|
||||
* responsive: stacked cards below `md`, table at md+.
|
||||
* tabular: always use columnar HTML table (holder lists, dense numeric tables).
|
||||
*/
|
||||
layout?: 'responsive' | 'tabular'
|
||||
/** Stable key for each row (e.g. row => row.id or row => row.hash). Falls back to index if not provided. */
|
||||
keyExtractor?: (row: T) => string | number
|
||||
}
|
||||
@@ -21,6 +26,7 @@ export function Table<T>({
|
||||
data,
|
||||
className,
|
||||
emptyMessage = 'No data available right now.',
|
||||
layout = 'responsive',
|
||||
keyExtractor,
|
||||
}: TableProps<T>) {
|
||||
if (data.length === 0) {
|
||||
@@ -36,9 +42,12 @@ export function Table<T>({
|
||||
)
|
||||
}
|
||||
|
||||
const stackedClass = layout === 'tabular' ? 'hidden' : 'grid gap-3 md:hidden'
|
||||
const tableWrapperClass = layout === 'tabular' ? 'overflow-x-auto' : 'hidden overflow-x-auto md:block'
|
||||
|
||||
return (
|
||||
<div className={clsx('space-y-3', className)}>
|
||||
<div className="grid gap-3 md:hidden">
|
||||
<div className={stackedClass}>
|
||||
{data.map((row, rowIndex) => (
|
||||
<div
|
||||
key={keyExtractor ? keyExtractor(row) : rowIndex}
|
||||
@@ -60,7 +69,7 @@ export function Table<T>({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<div className={tableWrapperClass}>
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
|
||||
3
frontend/next-env.d.ts
vendored
3
frontend/next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
const path = require('path')
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: 'standalone',
|
||||
outputFileTracingRoot: path.resolve(__dirname, '..', '..'),
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/tx/:hash',
|
||||
destination: '/transactions/:hash',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/more',
|
||||
destination: '/operations',
|
||||
|
||||
19071
frontend/package-lock.json
generated
19071
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,20 +14,21 @@
|
||||
"smoke:routes": "node ./scripts/smoke-routes.mjs",
|
||||
"start": "PORT=${PORT:-3000} node ./scripts/start-standalone.mjs",
|
||||
"start:next": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint src libs next.config.js --ext .js,.jsx,.ts,.tsx",
|
||||
"type-check": "tsc --noEmit -p tsconfig.check.json",
|
||||
"test": "npm run lint && npm run type-check",
|
||||
"test": "npm run lint && npm run type-check && npm run test:unit",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.14.2",
|
||||
"@walletconnect/ethereum-provider": "^2.21.10",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"axios": "^1.6.2",
|
||||
"axios": "^1.15.2",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^3.0.6",
|
||||
"js-sha3": "^0.9.3",
|
||||
"next": "^14.0.4",
|
||||
"postcss": "^8.4.32",
|
||||
"next": "^15.5.15",
|
||||
"postcss": "^8.5.10",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwindcss": "^3.3.6",
|
||||
@@ -35,11 +36,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^14.0.4",
|
||||
"eslint-config-next": "^15.5.15",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.6.1"
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.28.0",
|
||||
"postcss": "^8.5.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Acknowledgments | SolaceScan</title>
|
||||
<meta name="description" content="Acknowledgments for the SolaceScan Chain 138 explorer.">
|
||||
<title>Acknowledgments | DBIS Explorer</title>
|
||||
<meta name="description" content="Acknowledgments for the DBIS Explorer 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,7 +19,7 @@
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="topbar">
|
||||
<div class="brand">SolaceScan Acknowledgments</div>
|
||||
<div class="brand">DBIS Explorer Acknowledgments</div>
|
||||
<a href="/">Back to explorer</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
@@ -28,10 +28,10 @@
|
||||
<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, transport, and companion operational surfaces where applicable.</li>
|
||||
<li><strong>Chainlink CCIP</strong> for bridge-related routing, cW public-network representations, 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 supporting the DBIS / Defi Oracle explorer experience.</li>
|
||||
<li><strong>Next.js</strong> and the frontend contributors supporting the DBIS 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>
|
||||
|
||||
@@ -152,8 +152,8 @@
|
||||
|
||||
<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="/operations">Operations hub</a>,
|
||||
<a href="/bridge">Bridge</a>,
|
||||
<a href="/routes">Routes</a>,
|
||||
<a href="/system">System</a>,
|
||||
and <a href="/operator">Operator</a>.
|
||||
@@ -206,7 +206,7 @@ flowchart TB
|
||||
|
||||
subgraph CCIP_L2["Other live CCIP EVM destinations"]
|
||||
L2CLU["OP 10 · Base 8453 · Arb 42161 · Polygon 137 · BSC 56 · Avax 43114 · Gnosis 100 · Celo 42220 · Cronos 25"]
|
||||
LEAF_L2["Leaf — per-chain native DEX · cW token transport · partial edge pools"]
|
||||
LEAF_L2["Leaf — per-chain native DEX · cW public-network representation · partial edge pools"]
|
||||
end
|
||||
|
||||
subgraph ALLTRA["ALL Mainnet 651940"]
|
||||
@@ -404,9 +404,9 @@ flowchart LR
|
||||
|
||||
<!-- 4 Cross-chain -->
|
||||
<div class="content" id="panel-4" role="tabpanel" aria-labelledby="tab-4" hidden>
|
||||
<p class="panel-desc">CCIP transport, Alltra round-trip, the dedicated c-to-cW mint corridors, and the orchestrated swap-bridge-swap target.</p>
|
||||
<p class="panel-desc">CCIP routing, Alltra round-trip, the dedicated c-to-cW mint corridors, and the orchestrated swap-bridge-swap target.</p>
|
||||
<div class="mermaid-wrap">
|
||||
<h3>CCIP — WETH primary transport</h3>
|
||||
<h3>CCIP — WETH primary routing lane</h3>
|
||||
<div class="mermaid">
|
||||
sequenceDiagram
|
||||
participant U as User or bot
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Documentation Redirect | SolaceScan</title>
|
||||
<meta name="description" content="Redirecting to the canonical SolaceScan documentation hub.">
|
||||
<title>Documentation Redirect | DBIS Explorer</title>
|
||||
<meta name="description" content="Redirecting to the canonical DBIS Explorer documentation hub.">
|
||||
<meta http-equiv="refresh" content="0; url=/docs">
|
||||
<link rel="canonical" href="https://blockscout.defi-oracle.io/docs">
|
||||
<style>
|
||||
@@ -23,7 +23,7 @@
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="topbar">
|
||||
<div class="brand">SolaceScan Documentation</div>
|
||||
<div class="brand">DBIS Explorer Documentation</div>
|
||||
<a href="/">Back to explorer</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1968
frontend/public/legacy/index.html
Normal file
1968
frontend/public/legacy/index.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Privacy Policy | SolaceScan</title>
|
||||
<meta name="description" content="Privacy policy for the SolaceScan Chain 138 explorer operated by DBIS / Defi Oracle.">
|
||||
<title>Privacy Policy | DBIS Explorer</title>
|
||||
<meta name="description" content="Privacy policy for the DBIS Explorer Chain 138 explorer operated by DBIS.">
|
||||
<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,13 +19,13 @@
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="topbar">
|
||||
<div class="brand">SolaceScan Privacy Policy</div>
|
||||
<div class="brand">DBIS Explorer 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>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>
|
||||
<p>DBIS Explorer is the public Chain 138 explorer surface operated by DBIS. 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>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Terms of Service | SolaceScan</title>
|
||||
<meta name="description" content="Terms of service for the SolaceScan Chain 138 explorer operated by DBIS / Defi Oracle.">
|
||||
<title>Terms of Service | DBIS Explorer</title>
|
||||
<meta name="description" content="Terms of service for the DBIS Explorer Chain 138 explorer operated by DBIS.">
|
||||
<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,13 +19,13 @@
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="topbar">
|
||||
<div class="brand">SolaceScan Terms of Service</div>
|
||||
<div class="brand">DBIS Explorer 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>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>
|
||||
<p>DBIS Explorer is provided for informational and operational purposes by DBIS. 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>
|
||||
@@ -55,7 +55,7 @@
|
||||
<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>
|
||||
<p>DBIS Explorer is operated by DBIS. 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>
|
||||
|
||||
BIN
frontend/public/token-icons/chain-138.png
Normal file
BIN
frontend/public/token-icons/chain-138.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 213 KiB |
@@ -4,15 +4,15 @@ const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/
|
||||
const addressUnderTest = process.env.SMOKE_ADDRESS || '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506'
|
||||
|
||||
const checks = [
|
||||
{ path: '/', expectTexts: ['SolaceScan', 'Recent Blocks', 'Open wallet tools'] },
|
||||
{ path: '/', expectTexts: ['DBIS Explorer', 'Recent Blocks', 'Network overview'] },
|
||||
{ path: '/blocks', expectTexts: ['Blocks'] },
|
||||
{ path: '/transactions', expectTexts: ['Transactions'] },
|
||||
{ path: '/addresses', expectTexts: ['Addresses', 'Open An Address'] },
|
||||
{ path: '/watchlist', expectTexts: ['Watchlist', 'Saved Addresses'] },
|
||||
{ path: '/pools', expectTexts: ['Pools', 'Pool operation shortcuts'] },
|
||||
{ path: '/liquidity', expectTexts: ['Chain 138 Liquidity Access', 'Explorer Access Points'] },
|
||||
{ path: '/wallet', expectTexts: ['Wallet & MetaMask', 'Install Open Snap'] },
|
||||
{ path: '/tokens', expectTexts: ['Tokens', 'Find A Token'] },
|
||||
{ path: '/liquidity', expectTexts: ['Liquidity', 'Explorer Access Points'] },
|
||||
{ path: '/wallet', expectTexts: ['Wallet Tools', 'WalletConnect v2 posture'] },
|
||||
{ path: '/tokens', expectTexts: ['Tokens', 'Find a token'] },
|
||||
{ path: '/search', expectTexts: ['Search'], placeholder: 'Search by address, transaction hash, block number...' },
|
||||
{ path: `/addresses/${addressUnderTest}`, expectTexts: [], anyOfTexts: ['Back to addresses', 'Address not found'] },
|
||||
]
|
||||
@@ -22,7 +22,10 @@ async function bodyText(page) {
|
||||
}
|
||||
|
||||
async function hasShell(page) {
|
||||
const homeLink = await page.getByRole('link', { name: /Go to explorer home/i }).isVisible().catch(() => false)
|
||||
const homeLink = await page
|
||||
.getByRole('link', { name: /Go to DBIS Explorer home|Go to explorer home/i })
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const supportText = await page.getByText(/Support:/i).isVisible().catch(() => false)
|
||||
return homeLink && supportText
|
||||
}
|
||||
|
||||
@@ -7,7 +7,20 @@ import process from 'node:process'
|
||||
const projectRoot = process.cwd()
|
||||
const standaloneRoot = path.join(projectRoot, '.next', 'standalone')
|
||||
const standaloneNextRoot = path.join(standaloneRoot, '.next')
|
||||
const standaloneServer = path.join(standaloneRoot, 'server.js')
|
||||
|
||||
function resolveStandaloneServer() {
|
||||
const directServer = path.join(standaloneRoot, 'server.js')
|
||||
if (existsSync(directServer)) {
|
||||
return { serverPath: directServer, appRoot: standaloneRoot }
|
||||
}
|
||||
|
||||
const nestedServer = path.join(standaloneRoot, 'explorer-monorepo', 'frontend', 'server.js')
|
||||
if (existsSync(nestedServer)) {
|
||||
return { serverPath: nestedServer, appRoot: path.dirname(nestedServer) }
|
||||
}
|
||||
|
||||
return { serverPath: directServer, appRoot: standaloneRoot }
|
||||
}
|
||||
|
||||
async function copyIfPresent(sourcePath, destinationPath) {
|
||||
if (!existsSync(sourcePath)) {
|
||||
@@ -19,15 +32,16 @@ async function copyIfPresent(sourcePath, destinationPath) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!existsSync(standaloneServer)) {
|
||||
const { serverPath, appRoot } = resolveStandaloneServer()
|
||||
if (!existsSync(serverPath)) {
|
||||
console.error('Standalone server build is missing. Run `npm run build` first.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await copyIfPresent(path.join(projectRoot, '.next', 'static'), path.join(standaloneNextRoot, 'static'))
|
||||
await copyIfPresent(path.join(projectRoot, 'public'), path.join(standaloneRoot, 'public'))
|
||||
await copyIfPresent(path.join(projectRoot, 'public'), path.join(appRoot, 'public'))
|
||||
|
||||
const child = spawn(process.execPath, [standaloneServer], {
|
||||
const child = spawn(process.execPath, [serverPath], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
})
|
||||
|
||||
@@ -75,6 +75,9 @@ export default function ActivityContextPanel({
|
||||
</Explain>
|
||||
</div>
|
||||
<EntityBadge label={resolveLabel(context.state)} tone={tone} />
|
||||
{context.head_is_idle && context.state === 'low' ? (
|
||||
<EntityBadge label="quiet chain" tone="info" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{compact ? (
|
||||
@@ -130,6 +133,11 @@ export default function ActivityContextPanel({
|
||||
Open last non-empty block →
|
||||
</Link>
|
||||
) : null}
|
||||
{context.block_gap_to_latest_transaction != null ? (
|
||||
<span>
|
||||
Block gap to latest visible transaction: {context.block_gap_to_latest_transaction.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
{context.latest_transaction_timestamp ? (
|
||||
<span>Latest visible transaction time: {formatTimestamp(context.latest_transaction_timestamp)}</span>
|
||||
) : null}
|
||||
|
||||
@@ -8,15 +8,15 @@ export default function BrandLockup({ compact = false }: { compact?: boolean })
|
||||
<span
|
||||
className={[
|
||||
'block truncate font-semibold tracking-[-0.02em] text-gray-950 dark:text-white',
|
||||
compact ? 'text-[1.45rem]' : 'text-[1.65rem]',
|
||||
compact ? 'text-[1.2rem]' : 'text-[1.35rem]',
|
||||
].join(' ')}
|
||||
>
|
||||
SolaceScan
|
||||
DBIS Explorer
|
||||
</span>
|
||||
<span
|
||||
className={[
|
||||
'block truncate font-medium uppercase text-gray-500 dark:text-gray-400',
|
||||
compact ? 'text-[0.72rem] tracking-[0.14em]' : 'text-[0.8rem] tracking-[0.12em]',
|
||||
'block truncate font-medium uppercase text-gray-500 dark:text-gray-400 max-sm:hidden',
|
||||
compact ? 'text-[0.64rem] tracking-[0.13em]' : 'text-[0.68rem] tracking-[0.12em]',
|
||||
].join(' ')}
|
||||
>
|
||||
Chain 138 Explorer by DBIS
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export default function BrandMark({ size = 'default' }: { size?: 'default' | 'compact' }) {
|
||||
const containerClassName =
|
||||
size === 'compact'
|
||||
? 'h-10 w-10 rounded-xl'
|
||||
: 'h-11 w-11 rounded-2xl'
|
||||
const iconClassName = size === 'compact' ? 'h-6 w-6' : 'h-7 w-7'
|
||||
? 'h-9 w-9 rounded-lg'
|
||||
: 'h-10 w-10 rounded-lg'
|
||||
const iconClassName = size === 'compact' ? 'h-5 w-5' : 'h-6 w-6'
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -13,26 +13,44 @@ function toneClasses(tone: 'neutral' | 'success' | 'warning' | 'info') {
|
||||
}
|
||||
}
|
||||
|
||||
export function getEntityBadgeTone(tag: string): 'neutral' | 'success' | 'warning' | 'info' {
|
||||
const normalized = tag.toLowerCase()
|
||||
if (normalized === 'compliant' || normalized === 'listed' || normalized === 'verified') {
|
||||
function normalizeBadgeLabel(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') return String(value)
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function getEntityBadgeTone(tag: unknown): 'neutral' | 'success' | 'warning' | 'info' {
|
||||
const normalized = normalizeBadgeLabel(tag).toLowerCase()
|
||||
if (normalized === 'compliant' || normalized === 'listed' || normalized === 'verified' || normalized === 'gru') {
|
||||
return 'success'
|
||||
}
|
||||
if (normalized === 'wrapped') {
|
||||
if (normalized === 'wrapped' || normalized === 'treasury-bond') {
|
||||
return 'warning'
|
||||
}
|
||||
if (normalized === 'bridge' || normalized === 'canonical' || normalized === 'official') {
|
||||
if (normalized === 'bridge' || normalized === 'canonical' || normalized === 'official' || normalized === 'electronic-money' || normalized === 'commodity') {
|
||||
return 'info'
|
||||
}
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
export function formatEntityBadgeLabel(label: unknown): string {
|
||||
const resolvedLabel = normalizeBadgeLabel(label)
|
||||
const normalized = resolvedLabel.toLowerCase()
|
||||
const labels: Record<string, string> = {
|
||||
'reference-asset': 'reference asset',
|
||||
'electronic-money': 'cash e-money',
|
||||
'treasury-bond': 'treasury / gov bond',
|
||||
gru: 'GRU',
|
||||
}
|
||||
return labels[normalized] || resolvedLabel
|
||||
}
|
||||
|
||||
export default function EntityBadge({
|
||||
label,
|
||||
tone,
|
||||
className,
|
||||
}: {
|
||||
label: string
|
||||
label: unknown
|
||||
tone?: 'neutral' | 'success' | 'warning' | 'info'
|
||||
className?: string
|
||||
}) {
|
||||
@@ -46,7 +64,7 @@ export default function EntityBadge({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{formatEntityBadgeLabel(label)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function ExplorerAgentTool() {
|
||||
{
|
||||
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.',
|
||||
'DBIS Explorer AI Assist is ready. I can explain this page, summarize what you are looking at, and help investigate transactions, contracts, routes, and system surfaces.',
|
||||
},
|
||||
])
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function ExplorerAgentTool() {
|
||||
<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>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">DBIS Explorer AI Assist</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>
|
||||
@@ -163,15 +163,16 @@ export default function ExplorerAgentTool() {
|
||||
<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"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-primary-600 p-3 text-sm font-semibold text-white shadow-lg transition hover:bg-primary-700 lg:px-4 lg:py-3"
|
||||
aria-expanded={open}
|
||||
aria-label="Open DBIS Explorer AI Assist"
|
||||
>
|
||||
<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
|
||||
<span className="hidden lg:inline">AI Assist</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,10 +3,12 @@ import Navbar from './Navbar'
|
||||
import Footer from './Footer'
|
||||
import ExplorerAgentTool from './ExplorerAgentTool'
|
||||
import { UiModeProvider } from './UiModeContext'
|
||||
import { PostureGlossaryProvider } from './PostureGlossaryProvider'
|
||||
|
||||
export default function ExplorerChrome({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<UiModeProvider>
|
||||
<PostureGlossaryProvider>
|
||||
<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"
|
||||
@@ -21,6 +23,7 @@ export default function ExplorerChrome({ children }: { children: ReactNode }) {
|
||||
<ExplorerAgentTool />
|
||||
<Footer />
|
||||
</div>
|
||||
</PostureGlossaryProvider>
|
||||
</UiModeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
34
frontend/src/components/common/ExplorerRetryAlert.tsx
Normal file
34
frontend/src/components/common/ExplorerRetryAlert.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
interface ExplorerRetryAlertProps {
|
||||
message: string
|
||||
onRetry?: () => void
|
||||
retryLabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function ExplorerRetryAlert({
|
||||
message,
|
||||
onRetry,
|
||||
retryLabel = 'Retry',
|
||||
className = '',
|
||||
}: ExplorerRetryAlertProps) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={[
|
||||
'flex flex-col gap-3 rounded-xl border border-red-200 bg-red-50/70 px-4 py-3 dark:border-red-900/50 dark:bg-red-950/20 sm:flex-row sm:items-center sm:justify-between',
|
||||
className,
|
||||
].join(' ')}
|
||||
>
|
||||
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{message}</p>
|
||||
{onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="shrink-0 rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-semibold text-red-800 transition-colors hover:bg-red-50 dark:border-red-800 dark:bg-red-950 dark:text-red-100 dark:hover:bg-red-900/40"
|
||||
>
|
||||
{retryLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link'
|
||||
import FooterPublicApiLinks from '@/components/common/FooterPublicApiLinks'
|
||||
|
||||
const footerLinkClass =
|
||||
'text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors'
|
||||
@@ -9,21 +10,21 @@ export default function Footer() {
|
||||
return (
|
||||
<footer className="mt-auto border-t border-gray-200 dark:border-gray-700 bg-white/90 dark:bg-gray-900/90 backdrop-blur">
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="grid gap-4 sm:gap-6 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="grid gap-4 sm:gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
<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">
|
||||
SolaceScan
|
||||
DBIS Explorer
|
||||
</div>
|
||||
<p className="max-w-xl text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Built on Blockscout for the DBIS / Defi Oracle Chain 138 explorer surface.
|
||||
Built on Blockscout for the DBIS 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.
|
||||
Primary public explorer access is served at <code>explorer.d-bis.org</code>.
|
||||
<code> blockscout.defi-oracle.io</code> is the Blockscout companion domain for the same Chain 138 explorer surface.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
© {year} DBIS / Defi Oracle. All rights reserved.
|
||||
© {year} DBIS. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -34,18 +35,39 @@ export default function Footer() {
|
||||
<ul className="space-y-2 text-sm">
|
||||
<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="/operations">Operations Hub</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/blocks">Blocks</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/transactions">Transactions</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/tokens">Tokens</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/addresses">Addresses</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/watchlist">Watchlist</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/access">Account access</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/wallet">Wallet tools</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/operations">Operations hub</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/bridge">Bridge</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/liquidity">Liquidity</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/pools">Pools</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/analytics">Analytics</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/operator">Operator</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/system">System</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/weth">WETH</Link></li>
|
||||
<li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li>
|
||||
<li><a className={footerLinkClass} href="/terms.html">Terms of Service</a></li>
|
||||
<li><a className={footerLinkClass} href="/acknowledgments.html">Acknowledgments</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="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="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Public APIs
|
||||
</div>
|
||||
<FooterPublicApiLinks />
|
||||
<p className="mt-3 text-xs leading-5 text-gray-500 dark:text-gray-500">
|
||||
Read-only JSON endpoints on the public explorer domain. No API key required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="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="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Contact
|
||||
|
||||
53
frontend/src/components/common/FooterPublicApiLinks.tsx
Normal file
53
frontend/src/components/common/FooterPublicApiLinks.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { explorerPublicApiLinks } from '@/data/explorerOperations'
|
||||
|
||||
const footerLinkClass =
|
||||
'text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors'
|
||||
|
||||
function absoluteApiUrl(href: string): string {
|
||||
if (typeof window === 'undefined') return href
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) return href
|
||||
return `${window.location.origin}${href.startsWith('/') ? href : `/${href}`}`
|
||||
}
|
||||
|
||||
export default function FooterPublicApiLinks() {
|
||||
const [copiedHref, setCopiedHref] = useState<string | null>(null)
|
||||
|
||||
const copyUrl = async (href: string) => {
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(absoluteApiUrl(href))
|
||||
setCopiedHref(href)
|
||||
window.setTimeout(() => setCopiedHref((current) => (current === href ? null : current)), 1500)
|
||||
} catch {
|
||||
setCopiedHref(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-3 text-sm">
|
||||
{explorerPublicApiLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<a className={footerLinkClass} href={link.href} target="_blank" rel="noopener noreferrer">
|
||||
{link.label}
|
||||
</a>
|
||||
<p className="mt-0.5 text-xs leading-5 text-gray-500 dark:text-gray-500">{link.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyUrl(link.href)}
|
||||
className="shrink-0 rounded-lg border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 transition-colors hover:border-primary-300 hover:text-primary-700 dark:border-gray-700 dark:text-gray-200 dark:hover:border-primary-500 dark:hover:text-primary-300"
|
||||
aria-label={`Copy URL for ${link.label}`}
|
||||
>
|
||||
{copiedHref === link.href ? 'Copied' : 'Copy URL'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -8,11 +8,15 @@ import {
|
||||
import { formatRelativeAge } from '@/utils/format'
|
||||
import { useUiMode } from './UiModeContext'
|
||||
|
||||
function buildSummary(context: ChainActivityContext) {
|
||||
function buildSummary(context: ChainActivityContext, activityState?: string | null) {
|
||||
if (context.transaction_visibility_unavailable) {
|
||||
return 'Chain-head visibility is current, while transaction freshness is currently unavailable.'
|
||||
}
|
||||
|
||||
if (activityState === 'quiet_chain' || (context.head_is_idle && context.state === 'low')) {
|
||||
return 'The chain head is current, but recent head blocks are quiet — this is normal low-activity visibility, not a broken index.'
|
||||
}
|
||||
|
||||
if (context.state === 'active') {
|
||||
return 'Chain head and latest indexed transactions are closely aligned.'
|
||||
}
|
||||
@@ -28,11 +32,21 @@ function buildSummary(context: ChainActivityContext) {
|
||||
return 'Freshness context is based on the latest visible public explorer evidence.'
|
||||
}
|
||||
|
||||
function buildDetail(context: ChainActivityContext, diagnosticExplanation?: string | null) {
|
||||
function buildDetail(context: ChainActivityContext, diagnosticExplanation?: string | null, activityState?: string | null) {
|
||||
if (diagnosticExplanation) {
|
||||
return diagnosticExplanation
|
||||
}
|
||||
|
||||
if (activityState === 'quiet_chain') {
|
||||
const latestNonEmptyBlock =
|
||||
context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown'
|
||||
const blockGap =
|
||||
context.block_gap_to_latest_transaction != null
|
||||
? `${context.block_gap_to_latest_transaction.toLocaleString()} blocks`
|
||||
: 'unknown'
|
||||
return `Quiet-chain signal: head blocks may be empty while the chain remains current. Block gap to latest visible transaction: ${blockGap}. Last non-empty block: ${latestNonEmptyBlock}.`
|
||||
}
|
||||
|
||||
if (context.transaction_visibility_unavailable) {
|
||||
return 'Use chain-head visibility and the last non-empty block as the current trust anchors.'
|
||||
}
|
||||
@@ -40,9 +54,13 @@ function buildDetail(context: ChainActivityContext, diagnosticExplanation?: stri
|
||||
const latestTxAge = formatRelativeAge(context.latest_transaction_timestamp)
|
||||
const latestNonEmptyBlock =
|
||||
context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown'
|
||||
const blockGap =
|
||||
context.block_gap_to_latest_transaction != null
|
||||
? `${context.block_gap_to_latest_transaction.toLocaleString()} blocks`
|
||||
: null
|
||||
|
||||
if (context.head_is_idle) {
|
||||
return `Latest visible transaction: ${latestTxAge}. Last non-empty block: ${latestNonEmptyBlock}.`
|
||||
return `Latest visible transaction: ${latestTxAge}. Block gap: ${blockGap || 'unknown'}. Last non-empty block: ${latestNonEmptyBlock}.`
|
||||
}
|
||||
|
||||
if (context.state === 'active') {
|
||||
@@ -74,13 +92,14 @@ export default function FreshnessTrustNote({
|
||||
const sourceLabel = resolveFreshnessSourceLabel(stats, bridgeStatus)
|
||||
const confidenceBadges = summarizeFreshnessConfidence(stats, bridgeStatus)
|
||||
const diagnosticExplanation = stats?.diagnostics?.explanation || bridgeStatus?.data?.diagnostics?.explanation || null
|
||||
const activityState = stats?.diagnostics?.activity_state || bridgeStatus?.data?.diagnostics?.activity_state || null
|
||||
const normalizedClassName = className ? ` ${className}` : ''
|
||||
|
||||
if (mode === 'expert') {
|
||||
return (
|
||||
<div className={`rounded-2xl border border-gray-200 bg-white/80 px-4 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/40${normalizedClassName}`}>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context)}</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context, activityState)}</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">{sourceLabel}</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -99,9 +118,9 @@ export default function FreshnessTrustNote({
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border border-gray-200 bg-white/80 px-4 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/40${normalizedClassName}`}>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context)}</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context, activityState)}</div>
|
||||
<div className="mt-1 text-gray-600 dark:text-gray-400">
|
||||
{normalizeSentence(buildDetail(context, diagnosticExplanation))}.{' '}
|
||||
{normalizeSentence(buildDetail(context, diagnosticExplanation, activityState))}.{' '}
|
||||
{scopeLabel ? `${normalizeSentence(scopeLabel)}. ` : ''}
|
||||
{normalizeSentence(sourceLabel)}.
|
||||
</div>
|
||||
|
||||
@@ -12,11 +12,26 @@ const STANDARD_EXPLANATIONS: Record<string, string> = {
|
||||
'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.',
|
||||
CashElectronicMoneyInterface: 'Repo-native GRU instrument 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.',
|
||||
}
|
||||
|
||||
const STANDARD_DISPLAY_LABELS: Record<string, string> = {
|
||||
CashElectronicMoneyInterface: 'Cash electronic-money interface',
|
||||
DeterministicStorageNamespace: 'Deterministic storage namespace',
|
||||
JurisdictionAndSupervisionMetadata: 'Jurisdiction and supervision metadata',
|
||||
}
|
||||
|
||||
function formatStandardLabel(id: string): string {
|
||||
return STANDARD_DISPLAY_LABELS[id] || id
|
||||
}
|
||||
|
||||
function formatProfileLabel(id: string): string {
|
||||
if (id === 'gru-c-star-v2-public-network-and-payment') return 'GRU C* v2 payment profile'
|
||||
return id
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string | null {
|
||||
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return null
|
||||
const units = [
|
||||
@@ -56,16 +71,16 @@ export default function GruStandardsCard({
|
||||
? `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.',
|
||||
? 'This looks like a cW public-network representation, 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 bridge 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.'
|
||||
? 'This version is marked forward-canonical, so it should be treated as the preferred successor surface even if older liquidity or bridge 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.',
|
||||
? 'This version is not forward-canonical, which usually means it is legacy, staged, or bridge-only relative to the intended primary canonical surface.'
|
||||
: 'Forward-canonical posture is not directly detectable on this contract, so rely on the bridge 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.',
|
||||
@@ -78,9 +93,9 @@ export default function GruStandardsCard({
|
||||
<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={formatProfileLabel(profile.profileId)} tone="info" className="normal-case tracking-normal" />
|
||||
<EntityBadge
|
||||
label={profile.wrappedTransport ? 'wrapped transport' : 'canonical GRU'}
|
||||
label={profile.wrappedTransport ? 'cW public-network' : 'canonical GRU'}
|
||||
tone={profile.wrappedTransport ? 'warning' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
@@ -94,14 +109,14 @@ export default function GruStandardsCard({
|
||||
{profile.standards.map((standard) => (
|
||||
<EntityBadge
|
||||
key={standard.id}
|
||||
label={standard.detected ? `${standard.id} detected` : `${standard.id} missing`}
|
||||
label={standard.detected ? `${formatStandardLabel(standard.id)} detected` : `${formatStandardLabel(standard.id)} missing`}
|
||||
tone={standard.detected ? 'success' : 'warning'}
|
||||
className="normal-case tracking-normal"
|
||||
/>
|
||||
))}
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow label="Transport Posture">
|
||||
<DetailRow label="Bridge Posture">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge
|
||||
@@ -134,8 +149,8 @@ export default function GruStandardsCard({
|
||||
<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.'}
|
||||
? 'This contract presents itself like a cW public-network representation instead of the canonical Chain 138 GRU surface.'
|
||||
: 'This contract presents itself like the canonical Chain 138 GRU surface instead of a cW public-network representation.'}
|
||||
</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">
|
||||
@@ -150,7 +165,7 @@ export default function GruStandardsCard({
|
||||
<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'}.`
|
||||
? `Active liquidity/bridge 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>
|
||||
@@ -163,7 +178,7 @@ export default function GruStandardsCard({
|
||||
{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>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{formatStandardLabel(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">
|
||||
@@ -190,12 +205,12 @@ export default function GruStandardsCard({
|
||||
<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>Canonical profile: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">{formatProfileLabel(profile.profileId)}</code></div>
|
||||
<div>Standards matrix: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">GRU C* v2 implementation plan</code></div>
|
||||
<div>Machine-readable profile: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">GRU standards profile</code></div>
|
||||
<div>Public-network overlay: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">GRU cW representation registry</code></div>
|
||||
<div>x402 support note: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">Chain 138 x402 token support</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">GRU v2 Chain 138 readiness</code></div>
|
||||
</div>
|
||||
</DetailRow>
|
||||
|
||||
|
||||
39
frontend/src/components/common/MarketEvidenceNote.tsx
Normal file
39
frontend/src/components/common/MarketEvidenceNote.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { formatRelativeAge, formatTimestamp } from '@/utils/format'
|
||||
|
||||
function formatSource(source?: string | null): string {
|
||||
switch (source) {
|
||||
case 'token-aggregation':
|
||||
return 'token aggregation API'
|
||||
case 'blockscout':
|
||||
return 'Blockscout index'
|
||||
case 'derived':
|
||||
return 'derived from indexed supply and price inputs'
|
||||
case 'mission-control':
|
||||
return 'mission-control liquidity inventory'
|
||||
default:
|
||||
return source || 'source unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
export default function MarketEvidenceNote({
|
||||
source = 'token-aggregation',
|
||||
lastUpdated,
|
||||
method = 'DEX route and pool aggregation; visible liquidity only where indexed.',
|
||||
compact = false,
|
||||
}: {
|
||||
source?: string | null
|
||||
lastUpdated?: string | null
|
||||
method?: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
const freshness = lastUpdated ? `${formatRelativeAge(lastUpdated)} (${formatTimestamp(lastUpdated)})` : 'timestamp unavailable'
|
||||
const text = compact
|
||||
? `Updated ${freshness} · ${formatSource(source)}`
|
||||
: `Source: ${formatSource(source)}. Updated: ${freshness}. Method: ${method}`
|
||||
|
||||
return (
|
||||
<p className={`${compact ? 'mt-1' : 'mt-3'} text-xs leading-5 text-gray-500 dark:text-gray-400`}>
|
||||
{text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
76
frontend/src/components/common/MissionDeliveryModePanel.tsx
Normal file
76
frontend/src/components/common/MissionDeliveryModePanel.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import type { MissionControlMode } from '@/services/api/missionControl'
|
||||
import { formatRelativeAge } from '@/utils/format'
|
||||
|
||||
const REASON_LABELS: Record<string, string> = {
|
||||
live_homepage_stream_not_attached: 'Live homepage stream is not attached; relay posture uses snapshot polling.',
|
||||
relay_snapshot_only_source: 'Relay monitoring uses snapshot sources while other explorer feeds remain live.',
|
||||
partial_observability_inputs: 'Some freshness inputs are partial, so posture is reported conservatively.',
|
||||
}
|
||||
|
||||
const SCOPE_LABELS: Record<string, string> = {
|
||||
relay_monitoring_homepage_card_only: 'Affects relay monitoring and the homepage mission card only.',
|
||||
bridge_monitoring_and_homepage: 'Affects bridge monitoring and homepage summary surfaces.',
|
||||
homepage_summary_only: 'Affects homepage summary messaging only.',
|
||||
}
|
||||
|
||||
function humanizeKey(value?: string | null): string {
|
||||
if (!value) return 'Not specified'
|
||||
return SCOPE_LABELS[value] || REASON_LABELS[value] || value.replaceAll('_', ' ')
|
||||
}
|
||||
|
||||
function modeTone(kind?: string | null): 'success' | 'warning' | 'info' | 'neutral' {
|
||||
switch (String(kind || '').toLowerCase()) {
|
||||
case 'live':
|
||||
return 'success'
|
||||
case 'mixed':
|
||||
return 'warning'
|
||||
case 'snapshot':
|
||||
return 'info'
|
||||
default:
|
||||
return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
export default function MissionDeliveryModePanel({
|
||||
mode,
|
||||
title = 'Delivery mode',
|
||||
className = '',
|
||||
}: {
|
||||
mode?: MissionControlMode | null
|
||||
title?: string
|
||||
className?: string
|
||||
}) {
|
||||
if (!mode?.kind) return null
|
||||
|
||||
const normalizedClassName = className ? ` ${className}` : ''
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`border border-indigo-200 bg-indigo-50/60 dark:border-indigo-900/40 dark:bg-indigo-950/20${normalizedClassName}`}
|
||||
title={title}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={`mode ${mode.kind}`} tone={modeTone(mode.kind)} />
|
||||
{mode.updated_at ? (
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Updated {formatRelativeAge(mode.updated_at)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{mode.reason ? (
|
||||
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
|
||||
<span className="font-medium text-gray-900 dark:text-white">Reason:</span> {humanizeKey(String(mode.reason))}
|
||||
</p>
|
||||
) : null}
|
||||
{mode.scope ? (
|
||||
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
|
||||
<span className="font-medium text-gray-900 dark:text-white">Scope:</span> {humanizeKey(String(mode.scope))}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -364,6 +364,7 @@ function UiModeToggle({ mobile = false }: { mobile?: boolean }) {
|
||||
function AccountButton({
|
||||
walletSession,
|
||||
connectingWallet,
|
||||
connectError,
|
||||
onConnect,
|
||||
onCopyAddress,
|
||||
onSwitchWallet,
|
||||
@@ -371,6 +372,7 @@ function AccountButton({
|
||||
}: {
|
||||
walletSession: WalletAccessSession | null
|
||||
connectingWallet: boolean
|
||||
connectError?: string | null
|
||||
onConnect: () => void
|
||||
onCopyAddress: () => void
|
||||
onSwitchWallet: () => void
|
||||
@@ -385,7 +387,7 @@ function AccountButton({
|
||||
},
|
||||
{
|
||||
href: '/wallet',
|
||||
label: 'Settings',
|
||||
label: 'Wallet tools',
|
||||
description: 'Review network, token-list, and wallet configuration guidance.',
|
||||
},
|
||||
{
|
||||
@@ -407,14 +409,21 @@ function AccountButton({
|
||||
|
||||
if (!walletSession) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnect}
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-gray-950 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-100 dark:focus-visible:ring-offset-gray-900"
|
||||
>
|
||||
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" aria-hidden />
|
||||
<span>{connectingWallet ? 'Connecting…' : 'Connect Wallet'}</span>
|
||||
</button>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnect}
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-gray-950 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-100 dark:focus-visible:ring-offset-gray-900"
|
||||
>
|
||||
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" aria-hidden />
|
||||
<span>{connectingWallet ? 'Connecting…' : 'Connect Wallet'}</span>
|
||||
</button>
|
||||
{connectError ? (
|
||||
<p role="alert" className="max-w-xs text-right text-xs text-red-600 dark:text-red-400">
|
||||
{connectError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -469,6 +478,7 @@ export default function Navbar() {
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
|
||||
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
|
||||
const [connectingWallet, setConnectingWallet] = useState(false)
|
||||
const [walletConnectError, setWalletConnectError] = useState<string | null>(null)
|
||||
const mobilePanelId = useId()
|
||||
|
||||
const isExploreActive =
|
||||
@@ -528,13 +538,14 @@ export default function Navbar() {
|
||||
const handleConnectWallet = async () => {
|
||||
try {
|
||||
setConnectingWallet(true)
|
||||
setWalletConnectError(null)
|
||||
await accessApi.connectWalletSession()
|
||||
router.push('/access')
|
||||
setMobileMenuOpen(false)
|
||||
router.push('/wallet')
|
||||
} catch (error) {
|
||||
console.error('Wallet connect failed', error)
|
||||
router.push('/access')
|
||||
setMobileMenuOpen(false)
|
||||
const message = error instanceof Error ? error.message : 'Wallet connection failed.'
|
||||
setWalletConnectError(message)
|
||||
} finally {
|
||||
setConnectingWallet(false)
|
||||
}
|
||||
@@ -588,13 +599,13 @@ export default function Navbar() {
|
||||
)
|
||||
const operationsItems: MenuItem[] = useMemo(
|
||||
() => [
|
||||
{ href: '/operations', label: 'Operations Hub', description: 'Open the consolidated operator surface for live support workflows.' },
|
||||
{ href: '/bridge', label: 'Bridge Monitoring', description: 'Inspect relay lanes, queue posture, and bridge trace tooling.' },
|
||||
{ href: '/operations', label: 'Operations hub', description: 'Open the consolidated operator surface for live support workflows.' },
|
||||
{ href: '/bridge', label: 'Bridge', description: 'Inspect relay lanes, queue posture, and bridge trace tooling.' },
|
||||
{ href: '/routes', label: 'Routes', description: 'Review live route coverage, same-chain lanes, and bridge paths.' },
|
||||
{ href: '/liquidity', label: 'Liquidity', description: 'Check planner-backed route access and live liquidity posture.' },
|
||||
{ href: '/system', label: 'System', description: 'Inspect topology, RPC capability, and public integration inventory.' },
|
||||
{ href: '/operator', label: 'Operator Surface', description: 'Open planner, route, and relay shortcuts in one public page.' },
|
||||
{ href: '/weth', label: 'WETH References', description: 'Review wrapped-asset references and bridge-oriented WETH context.' },
|
||||
{ href: '/operator', label: 'Operator', description: 'Open planner, route, and relay shortcuts in one public page.' },
|
||||
{ href: '/weth', label: 'WETH', description: 'Review wrapped-asset references and bridge-oriented WETH context.' },
|
||||
{ href: '/chain138-command-center.html', label: 'Command Center', description: 'Open the visual command-center reference.', external: true },
|
||||
],
|
||||
[],
|
||||
@@ -703,12 +714,12 @@ export default function Navbar() {
|
||||
<>
|
||||
<header className="sticky top-0 z-40 border-b border-gray-200/90 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/88 dark:border-gray-800 dark:bg-gray-950/92">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex min-h-[76px] items-center gap-4 lg:min-h-[84px]">
|
||||
<div className="flex min-h-[60px] items-center gap-3 lg:min-h-[64px]">
|
||||
<Link
|
||||
href="/"
|
||||
className="group inline-flex min-w-0 items-center gap-3 rounded-2xl py-2 pr-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950"
|
||||
className="group inline-flex shrink-0 items-center gap-2 rounded-lg py-1.5 pr-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950 max-sm:max-w-[9.5rem] sm:max-w-none"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label="Go to SolaceScan home"
|
||||
aria-label="Go to DBIS Explorer home"
|
||||
>
|
||||
<BrandLockup />
|
||||
</Link>
|
||||
@@ -727,12 +738,13 @@ export default function Navbar() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto hidden items-center gap-3 lg:flex">
|
||||
<div className="ml-auto hidden items-center gap-3 xl:flex">
|
||||
<SearchControl active={isSearchActive} onSelect={() => setCommandPaletteOpen(true)} />
|
||||
<UiModeToggle />
|
||||
<AccountButton
|
||||
walletSession={walletSession}
|
||||
connectingWallet={connectingWallet}
|
||||
connectError={walletConnectError}
|
||||
onConnect={() => void handleConnectWallet()}
|
||||
onCopyAddress={() => void handleCopyAddress()}
|
||||
onSwitchWallet={() => void handleSwitchWallet()}
|
||||
@@ -740,7 +752,7 @@ export default function Navbar() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 lg:hidden">
|
||||
<div className="ml-auto flex items-center gap-2 xl:hidden">
|
||||
{walletSession ? (
|
||||
<Link
|
||||
href="/access"
|
||||
@@ -793,7 +805,7 @@ export default function Navbar() {
|
||||
{mobileMenuOpen ? (
|
||||
<div
|
||||
id={mobilePanelId}
|
||||
className="border-t border-gray-200 py-4 dark:border-gray-800 lg:hidden"
|
||||
className="border-t border-gray-200 py-4 dark:border-gray-800 xl:hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SearchControl
|
||||
@@ -806,6 +818,12 @@ export default function Navbar() {
|
||||
/>
|
||||
<UiModeToggle mobile />
|
||||
|
||||
{walletConnectError ? (
|
||||
<p role="alert" className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
|
||||
{walletConnectError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||
|
||||
@@ -17,29 +17,33 @@ export default function PageIntro({
|
||||
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}
|
||||
<section className="mb-5 border-b border-gray-200 pb-5 dark:border-gray-800 sm:mb-6 sm:pb-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
{eyebrow ? (
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary-700 dark:text-primary-300">
|
||||
{eyebrow}
|
||||
</div>
|
||||
) : null}
|
||||
<h1 className="text-2xl font-semibold tracking-normal text-gray-950 dark:text-white sm:text-3xl">{title}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</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">
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
{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"
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:text-primary-300"
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
60
frontend/src/components/common/PaginationControls.tsx
Normal file
60
frontend/src/components/common/PaginationControls.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
interface PaginationControlsProps {
|
||||
page: number
|
||||
pageCount: number
|
||||
onPageChange: (page: number) => void
|
||||
label?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function PaginationControls({
|
||||
page,
|
||||
pageCount,
|
||||
onPageChange,
|
||||
label = 'Rows',
|
||||
className = '',
|
||||
}: PaginationControlsProps) {
|
||||
if (pageCount <= 1) return null
|
||||
|
||||
const pages = Array.from({ length: pageCount }, (_, index) => index + 1)
|
||||
|
||||
return (
|
||||
<div className={`mt-4 flex flex-wrap items-center justify-between gap-3 ${className}`}>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{label}: page {page} of {pageCount}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{pages.map((candidate) => (
|
||||
<button
|
||||
key={candidate}
|
||||
type="button"
|
||||
onClick={() => onPageChange(candidate)}
|
||||
aria-current={candidate === page ? 'page' : undefined}
|
||||
className={
|
||||
candidate === page
|
||||
? 'rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
: 'rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
}
|
||||
>
|
||||
{candidate}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(Math.min(pageCount, page + 1))}
|
||||
disabled={page >= pageCount}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/common/PostureBadge.tsx
Normal file
33
frontend/src/components/common/PostureBadge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import { usePostureGlossary } from '@/components/common/PostureGlossaryProvider'
|
||||
import { resolvePostureTermId } from '@/data/postureGlossary'
|
||||
|
||||
export default function PostureBadge({
|
||||
label,
|
||||
tone,
|
||||
className,
|
||||
}: {
|
||||
label: string
|
||||
tone?: 'neutral' | 'success' | 'warning' | 'info'
|
||||
className?: string
|
||||
}) {
|
||||
const { openTerm } = usePostureGlossary()
|
||||
const termId = resolvePostureTermId(label)
|
||||
|
||||
if (!termId) {
|
||||
return <EntityBadge label={label} tone={tone} className={className} />
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openTerm(termId)}
|
||||
title="Open posture glossary"
|
||||
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
>
|
||||
<EntityBadge label={label} tone={tone} className={className} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
82
frontend/src/components/common/PostureGlossaryProvider.tsx
Normal file
82
frontend/src/components/common/PostureGlossaryProvider.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { getPostureGlossaryTerm, type PostureGlossaryTermId } from '@/data/postureGlossary'
|
||||
|
||||
interface PostureGlossaryContextValue {
|
||||
openTerm: (termId: PostureGlossaryTermId) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
const PostureGlossaryContext = createContext<PostureGlossaryContextValue | null>(null)
|
||||
|
||||
export function PostureGlossaryProvider({ children }: { children: ReactNode }) {
|
||||
const [activeTermId, setActiveTermId] = useState<PostureGlossaryTermId | null>(null)
|
||||
const activeTerm = useMemo(
|
||||
() => (activeTermId ? getPostureGlossaryTerm(activeTermId) ?? null : null),
|
||||
[activeTermId],
|
||||
)
|
||||
|
||||
const openTerm = useCallback((termId: PostureGlossaryTermId) => {
|
||||
setActiveTermId(termId)
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
setActiveTermId(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PostureGlossaryContext.Provider value={{ openTerm, close }}>
|
||||
{children}
|
||||
{activeTerm ? (
|
||||
<div className="fixed inset-0 z-[80] flex items-end justify-center bg-black/45 p-4 sm:items-center" onClick={close}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="posture-glossary-title"
|
||||
className="max-h-[85vh] w-full max-w-xl overflow-y-auto rounded-2xl border border-gray-200 bg-white p-6 shadow-2xl dark:border-gray-700 dark:bg-gray-950"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Posture glossary</p>
|
||||
<h2 id="posture-glossary-title" className="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{activeTerm.title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-4 text-sm leading-6 text-gray-700 dark:text-gray-300">{activeTerm.summary}</p>
|
||||
<div className="mt-4 rounded-xl border border-sky-200 bg-sky-50/70 p-4 text-sm leading-6 text-sky-950 dark:border-sky-900/50 dark:bg-sky-950/20 dark:text-sky-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-sky-700 dark:text-sky-300">Methodology</p>
|
||||
<p className="mt-2">{activeTerm.methodology}</p>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/docs/posture-glossary" className="font-medium text-primary-600 hover:underline" onClick={close}>
|
||||
Full glossary
|
||||
</Link>
|
||||
<Link href="/docs/gru" className="font-medium text-primary-600 hover:underline" onClick={close}>
|
||||
GRU guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</PostureGlossaryContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePostureGlossary() {
|
||||
const context = useContext(PostureGlossaryContext)
|
||||
if (!context) {
|
||||
throw new Error('usePostureGlossary must be used within PostureGlossaryProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
45
frontend/src/components/common/SectionTabs.tsx
Normal file
45
frontend/src/components/common/SectionTabs.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
export interface SectionTab<T extends string> {
|
||||
id: T
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface SectionTabsProps<T extends string> {
|
||||
tabs: SectionTab<T>[]
|
||||
activeTab: T
|
||||
onChange: (tab: T) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function SectionTabs<T extends string>({
|
||||
tabs,
|
||||
activeTab,
|
||||
onChange,
|
||||
className = '',
|
||||
}: SectionTabsProps<T>) {
|
||||
return (
|
||||
<div className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}>
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={
|
||||
activeTab === tab.id
|
||||
? 'whitespace-nowrap rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
: 'whitespace-nowrap rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.count === 'number' ? (
|
||||
<span className={activeTab === tab.id ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
|
||||
{tab.count.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
frontend/src/components/common/TokenListSurfaceNote.tsx
Normal file
13
frontend/src/components/common/TokenListSurfaceNote.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { TOKEN_LIST_SURFACE_LABELS, type TokenListSurface } from '@/services/api/tokenListSurfaces'
|
||||
|
||||
interface TokenListSurfaceNoteProps {
|
||||
surface?: TokenListSurface
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function TokenListSurfaceNote({
|
||||
surface = 'extended',
|
||||
className = 'text-sm text-gray-600 dark:text-gray-400',
|
||||
}: TokenListSurfaceNoteProps) {
|
||||
return <p className={className}>{TOKEN_LIST_SURFACE_LABELS[surface]}</p>
|
||||
}
|
||||
217
frontend/src/components/common/TokenSigningSurfaceCard.tsx
Normal file
217
frontend/src/components/common/TokenSigningSurfaceCard.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import type { ContractProfile } from '@/services/api/contracts'
|
||||
import { fetchEip712DomainDecoded, type DecodedEip712Domain } from '@/services/api/eip712Domain'
|
||||
|
||||
function hasMethod(profile: ContractProfile | null | undefined, name: string): boolean {
|
||||
if (!profile) return false
|
||||
const all = [...(profile.read_methods || []), ...(profile.write_methods || [])]
|
||||
return all.some((m) => m.name === name)
|
||||
}
|
||||
|
||||
const ERC5267_EXPLANATION =
|
||||
'ERC-5267 defines eip712Domain() so wallets and relayers can discover the EIP-712 signing domain without guessing types or replay parameters.'
|
||||
|
||||
export default function TokenSigningSurfaceCard({
|
||||
address,
|
||||
contractProfile,
|
||||
}: {
|
||||
address: string
|
||||
contractProfile: ContractProfile | null
|
||||
}) {
|
||||
const [domain, setDomain] = useState<DecodedEip712Domain | null>(null)
|
||||
const [domainError, setDomainError] = useState<string | null>(null)
|
||||
|
||||
const abiHasEip712Domain = hasMethod(contractProfile, 'eip712Domain')
|
||||
|
||||
useEffect(() => {
|
||||
if (!abiHasEip712Domain) {
|
||||
setDomain(null)
|
||||
setDomainError(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setDomainError(null)
|
||||
void (async () => {
|
||||
try {
|
||||
const decoded = await fetchEip712DomainDecoded(address)
|
||||
if (!cancelled) {
|
||||
setDomain(decoded)
|
||||
if (!decoded) setDomainError('eip712Domain() is present in the ABI but the live call did not return decodable data (proxy, revert, or RPC).')
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setDomain(null)
|
||||
setDomainError(e instanceof Error ? e.message : 'Failed to read eip712Domain.')
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [address, abiHasEip712Domain])
|
||||
|
||||
const standards = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'ERC-20',
|
||||
detected:
|
||||
hasMethod(contractProfile, 'name') ||
|
||||
hasMethod(contractProfile, 'symbol') ||
|
||||
hasMethod(contractProfile, 'decimals') ||
|
||||
hasMethod(contractProfile, 'totalSupply'),
|
||||
note: 'Standard fungible token interface expected by explorers and wallets.',
|
||||
},
|
||||
{
|
||||
id: 'EIP-712',
|
||||
detected: hasMethod(contractProfile, 'DOMAIN_SEPARATOR') || abiHasEip712Domain,
|
||||
note: 'Typed structured data hashing for signatures.',
|
||||
},
|
||||
{
|
||||
id: 'ERC-2612',
|
||||
detected: hasMethod(contractProfile, 'permit') || hasMethod(contractProfile, 'nonces'),
|
||||
note: 'Permit-style allowance via signature.',
|
||||
},
|
||||
{
|
||||
id: 'ERC-3009',
|
||||
detected:
|
||||
hasMethod(contractProfile, 'authorizationState') ||
|
||||
hasMethod(contractProfile, 'transferWithAuthorization') ||
|
||||
hasMethod(contractProfile, 'receiveWithAuthorization'),
|
||||
note: 'Transfer authorization without prior allowance.',
|
||||
},
|
||||
{
|
||||
id: 'ERC-5267',
|
||||
detected: abiHasEip712Domain,
|
||||
note: ERC5267_EXPLANATION,
|
||||
},
|
||||
],
|
||||
[contractProfile, abiHasEip712Domain],
|
||||
)
|
||||
|
||||
const verificationMeta = useMemo(() => {
|
||||
if (!contractProfile) return []
|
||||
const rows: { label: string; value: string }[] = []
|
||||
if (contractProfile.contract_name) rows.push({ label: 'Verified name', value: contractProfile.contract_name })
|
||||
if (contractProfile.compiler_version) rows.push({ label: 'Compiler', value: contractProfile.compiler_version })
|
||||
if (contractProfile.license_type) rows.push({ label: 'License', value: contractProfile.license_type })
|
||||
if (contractProfile.evm_version) rows.push({ label: 'EVM version', value: contractProfile.evm_version })
|
||||
if (contractProfile.optimization_enabled != null) {
|
||||
rows.push({
|
||||
label: 'Optimization',
|
||||
value: `${contractProfile.optimization_enabled ? 'On' : 'Off'}${contractProfile.optimization_runs != null ? ` · ${contractProfile.optimization_runs} runs` : ''}`,
|
||||
})
|
||||
}
|
||||
if (contractProfile.source_status_text) rows.push({ label: 'Source status', value: contractProfile.source_status_text })
|
||||
return rows
|
||||
}, [contractProfile])
|
||||
|
||||
if (!contractProfile) {
|
||||
return (
|
||||
<Card title="Signing surface & verification metadata">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Contract ABI and verification metadata were not available. Open the contract address page after Blockscout indexes this token, or verify the contract on the explorer.
|
||||
</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Signing surface & verification metadata">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="ABI coverage" valueClassName="flex flex-wrap gap-2">
|
||||
<EntityBadge label={contractProfile.abi_available ? 'ABI available' : 'ABI unavailable'} tone={contractProfile.abi_available ? 'success' : 'warning'} />
|
||||
<EntityBadge label={contractProfile.source_verified ? 'Source verified' : 'Source not verified'} tone={contractProfile.source_verified ? 'success' : 'warning'} />
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow label="ERC-5267 (EIP-712 domain introspection)" valueClassName="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge
|
||||
label={abiHasEip712Domain ? 'eip712Domain() in ABI' : 'eip712Domain() not in ABI'}
|
||||
tone={abiHasEip712Domain ? 'success' : 'warning'}
|
||||
/>
|
||||
{domain ? <EntityBadge label="Live domain decoded" tone="success" /> : null}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{ERC5267_EXPLANATION}</p>
|
||||
{domain ? (
|
||||
<div className="grid gap-3 sm: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">Domain fields</div>
|
||||
<dl className="mt-2 space-y-1.5 text-gray-900 dark:text-white">
|
||||
<div><span className="text-gray-500 dark:text-gray-400">fields </span>{domain.fields}</div>
|
||||
<div><span className="text-gray-500 dark:text-gray-400">name </span>{domain.name || '—'}</div>
|
||||
<div><span className="text-gray-500 dark:text-gray-400">version </span>{domain.version || '—'}</div>
|
||||
<div><span className="text-gray-500 dark:text-gray-400">chainId </span>{domain.chainId}</div>
|
||||
<div className="break-all">
|
||||
<span className="text-gray-500 dark:text-gray-400">verifyingContract </span>
|
||||
{domain.verifyingContract}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
<span className="text-gray-500 dark:text-gray-400">salt </span>
|
||||
{domain.salt}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
<span className="text-gray-500 dark:text-gray-400">extensions </span>
|
||||
{domain.extensionsSummary}
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
) : abiHasEip712Domain && domainError ? (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">{domainError}</p>
|
||||
) : !abiHasEip712Domain ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
This contract’s verified ABI does not expose eip712Domain(). ERC-5267 introspection is unavailable from the explorer surface until the implementation adds it.
|
||||
</p>
|
||||
) : null}
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow label="Related interfaces" valueClassName="flex flex-wrap gap-2">
|
||||
{standards
|
||||
.filter((s) => s.id !== 'ERC-5267')
|
||||
.map((s) => (
|
||||
<EntityBadge
|
||||
key={s.id}
|
||||
label={`${s.id} ${s.detected ? 'detected' : 'not detected'}`}
|
||||
tone={s.detected ? 'success' : 'warning'}
|
||||
className="normal-case tracking-normal"
|
||||
/>
|
||||
))}
|
||||
</DetailRow>
|
||||
|
||||
{verificationMeta.length > 0 ? (
|
||||
<DetailRow label="Verification metadata">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{verificationMeta.map((row) => (
|
||||
<div key={row.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">{row.label}</div>
|
||||
<div className="mt-2 break-words text-gray-900 dark:text-white">{row.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailRow>
|
||||
) : (
|
||||
<DetailRow label="Verification metadata">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">No compiler or naming metadata was returned with this contract record.</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
<DetailRow label="Interpretation">
|
||||
<div className="space-y-3">
|
||||
{standards.map((s) => (
|
||||
<div key={s.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">
|
||||
<span className="font-medium text-gray-900 dark:text-white">{s.id}</span>
|
||||
<EntityBadge label={s.detected ? 'detected' : 'not detected'} tone={s.detected ? 'success' : 'warning'} />
|
||||
</div>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">{s.note}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { formatWeiAsEth } from '@/utils/format'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
|
||||
import OperationsPageShell, {
|
||||
@@ -156,6 +157,7 @@ export default function AnalyticsOperationsPage({
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel="This page combines public stats, recent block samples, and indexed transactions."
|
||||
/>
|
||||
<MissionDeliveryModePanel className="mt-3" mode={bridgeStatus?.data?.mode} title="Analytics delivery mode" />
|
||||
<SubsystemPosturePanel
|
||||
className="mt-3"
|
||||
subsystems={bridgeStatus?.data?.subsystems}
|
||||
|
||||
107
frontend/src/components/explorer/BridgeLaneHealthPanel.tsx
Normal file
107
frontend/src/components/explorer/BridgeLaneHealthPanel.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import type { MissionControlBridgeLane, MissionControlBridgeLaneHealth } from '@/services/api/missionControl'
|
||||
|
||||
function laneTone(status?: string | null): 'success' | 'warning' | 'info' | 'neutral' {
|
||||
switch (String(status || '').toLowerCase()) {
|
||||
case 'funded':
|
||||
case 'proof-recorded':
|
||||
return 'success'
|
||||
case 'degraded':
|
||||
case 'proof-pending':
|
||||
return 'warning'
|
||||
case 'unfunded':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
function formatLinkBalance(wei?: string | null): string {
|
||||
if (!wei) return 'Unknown'
|
||||
try {
|
||||
const value = BigInt(wei)
|
||||
const whole = value / 10n ** 18n
|
||||
const fractional = (value % 10n ** 18n).toString().padStart(18, '0').slice(0, 4).replace(/0+$/, '')
|
||||
return fractional ? `${whole}.${fractional} LINK` : `${whole} LINK`
|
||||
} catch {
|
||||
return wei
|
||||
}
|
||||
}
|
||||
|
||||
export default function BridgeLaneHealthPanel({
|
||||
laneHealth,
|
||||
className = '',
|
||||
}: {
|
||||
laneHealth?: MissionControlBridgeLaneHealth | null
|
||||
className?: string
|
||||
}) {
|
||||
const lanes = laneHealth?.lanes || []
|
||||
if (lanes.length === 0) return null
|
||||
|
||||
const normalizedClassName = className ? ` ${className}` : ''
|
||||
|
||||
return (
|
||||
<Card title="Config-ready lane health" className={`mb-8${normalizedClassName}`}>
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
LINK balances are read from each remote CCIP bridge contract. Proof-transfer status comes from operator-recorded CCIP message hashes when available.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Operator runbook: fund LINK with{' '}
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">fund-ccip-bridges-with-link.sh</code>
|
||||
{' '}· lane probe{' '}
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">probe-bridge-lane-link-balances.sh</code>
|
||||
{' '}· routing reference{' '}
|
||||
<Link href="/docs/public-api-access" className="text-primary-600 hover:underline">
|
||||
public API access
|
||||
</Link>
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
<th className="py-2 pr-4">Lane</th>
|
||||
<th className="py-2 pr-4">Overall</th>
|
||||
<th className="py-2 pr-4">Proof</th>
|
||||
<th className="py-2 pr-4">WETH9 bridge</th>
|
||||
<th className="py-2 pr-4">WETH10 bridge</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lanes.map((lane: MissionControlBridgeLane) => (
|
||||
<tr key={lane.key} className="border-b border-gray-100 align-top last:border-0 dark:border-gray-800">
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{lane.chain_name || lane.key}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">chain {lane.chain_id ?? '?'}</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<EntityBadge label={lane.status || 'unknown'} tone={laneTone(lane.status)} />
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<EntityBadge label={lane.proof_status || 'proof-pending'} tone={laneTone(lane.proof_status)} />
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-mono text-xs text-gray-700 dark:text-gray-300">{lane.weth9?.bridge || '—'}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={lane.weth9?.status || 'unknown'} tone={laneTone(lane.weth9?.status)} className="px-2 py-0.5 text-[11px]" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">{formatLinkBalance(lane.weth9?.link_balance_wei)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-mono text-xs text-gray-700 dark:text-gray-300">{lane.weth10?.bridge || '—'}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={lane.weth10?.status || 'unknown'} tone={laneTone(lane.weth10?.status)} className="px-2 py-0.5 text-[11px]" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">{formatLinkBalance(lane.weth10?.link_balance_wei)}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -14,8 +14,15 @@ import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
import { bridgeRoutesApi, normalizeBridgeRouteEntries, type BridgeRoutesResponse } from '@/services/api/bridgeRoutes'
|
||||
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
|
||||
import { HOME_DASHBOARD_REFRESH_MS } from '@/utils/featuredTokens'
|
||||
import BridgeLaneHealthPanel from '@/components/explorer/BridgeLaneHealthPanel'
|
||||
import OperationsSurfaceNav from './OperationsSurfaceNav'
|
||||
import OperationsActionGrid from './OperationsActionGrid'
|
||||
|
||||
type FeedState = 'connecting' | 'live' | 'fallback'
|
||||
|
||||
@@ -146,6 +153,7 @@ export default function BridgeMonitoringPage({
|
||||
}) {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||
const [bridgeRoutes, setBridgeRoutes] = useState<BridgeRoutesResponse | null>(null)
|
||||
const [feedState, setFeedState] = useState<FeedState>(initialBridgeStatus ? 'fallback' : 'connecting')
|
||||
const page = explorerFeaturePages.bridge
|
||||
|
||||
@@ -196,6 +204,49 @@ export default function BridgeMonitoringPage({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
bridgeRoutesApi.getRoutesSafe().then(({ ok, data }) => {
|
||||
if (!cancelled && ok) {
|
||||
setBridgeRoutes(data)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (feedState !== 'fallback') return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const refreshSnapshot = async () => {
|
||||
try {
|
||||
const snapshot = await missionControlApi.getBridgeStatus()
|
||||
if (!cancelled) {
|
||||
setBridgeStatus(snapshot)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to refresh bridge monitoring snapshot:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createVisibilityAwarePoller({
|
||||
intervalMs: HOME_DASHBOARD_REFRESH_MS,
|
||||
task: refreshSnapshot,
|
||||
})
|
||||
}, [feedState])
|
||||
|
||||
const routeEntries = useMemo(
|
||||
() => normalizeBridgeRouteEntries(bridgeRoutes?.routes),
|
||||
[bridgeRoutes?.routes],
|
||||
)
|
||||
|
||||
const activityContext = useMemo(
|
||||
() =>
|
||||
summarizeChainActivity({
|
||||
@@ -280,6 +331,8 @@ export default function BridgeMonitoringPage({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<OperationsSurfaceNav />
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Bridge Freshness Context" />
|
||||
<FreshnessTrustNote
|
||||
@@ -289,6 +342,7 @@ export default function BridgeMonitoringPage({
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel="Bridge relay posture is shown alongside the same explorer freshness model used on the homepage and core explorer routes"
|
||||
/>
|
||||
<MissionDeliveryModePanel className="mt-3" mode={bridgeStatus?.data?.mode} title="Bridge delivery mode" />
|
||||
<SubsystemPosturePanel
|
||||
className="mt-3"
|
||||
subsystems={bridgeStatus?.data?.subsystems}
|
||||
@@ -407,27 +461,56 @@ export default function BridgeMonitoringPage({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{action.title}
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink
|
||||
href={action.href}
|
||||
label={action.label}
|
||||
external={'external' in action ? action.external : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<BridgeLaneHealthPanel laneHealth={bridgeStatus?.data?.bridge_lanes} />
|
||||
|
||||
{routeEntries.length > 0 ? (
|
||||
<Card title="CCIP route catalog" className="mb-8">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Destination bridge contracts from{' '}
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">
|
||||
/token-aggregation/api/v1/bridge/routes
|
||||
</code>
|
||||
{bridgeRoutes?.source ? (
|
||||
<>
|
||||
{' '}
|
||||
(source: {bridgeRoutes.source}
|
||||
{bridgeRoutes.lastModified ? ` · updated ${relativeAge(bridgeRoutes.lastModified)}` : ''})
|
||||
</>
|
||||
) : null}
|
||||
. Gnosis, Cronos, Celo, and Wemix lanes are aligned to deployed CCIP receivers — fund LINK on each remote bridge before live traffic.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Operator runbook:{' '}
|
||||
<Link href="/docs/public-api-access" className="text-primary-600 hover:underline">
|
||||
public API access
|
||||
</Link>{' '}
|
||||
· config-ready chain completion in repo{' '}
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">CONFIG_READY_CHAINS_COMPLETION_RUNBOOK.md</code>
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
<th className="py-2 pr-4">Bridge</th>
|
||||
<th className="py-2 pr-4">Destination</th>
|
||||
<th className="py-2">Contract</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{routeEntries.map((entry) => (
|
||||
<tr key={`${entry.bridge}-${entry.destination}`} className="border-b border-gray-100 last:border-0 dark:border-gray-800">
|
||||
<td className="py-2 pr-4 font-medium text-gray-900 dark:text-white">{entry.bridge}</td>
|
||||
<td className="py-2 pr-4 text-gray-700 dark:text-gray-300">{entry.destination}</td>
|
||||
<td className="py-2 font-mono text-xs text-gray-600 dark:text-gray-400">{entry.address}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<OperationsActionGrid actions={page.actions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
357
frontend/src/components/explorer/ContractCodeWorkspace.tsx
Normal file
357
frontend/src/components/explorer/ContractCodeWorkspace.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import { getExplorerApiBase } from '@/services/api/blockscout'
|
||||
import type { ContractProfile, ContractSourceFile } from '@/services/api/contracts'
|
||||
|
||||
interface ContractCodeWorkspaceProps {
|
||||
address: string
|
||||
profile: ContractProfile
|
||||
}
|
||||
|
||||
interface OutlineEntry {
|
||||
type: 'contract' | 'interface' | 'library' | 'function' | 'event' | 'error'
|
||||
name: string
|
||||
line: number
|
||||
}
|
||||
|
||||
const QUICK_PROMPTS = [
|
||||
'What does this contract do?',
|
||||
'What are the functions available in this contract?',
|
||||
'Which functions can change state or move funds?',
|
||||
'Who has special permissions or control in this contract?',
|
||||
'What are potential risks or red flags in this contract?',
|
||||
] as const
|
||||
|
||||
function makeFallbackSourceFile(profile: ContractProfile): ContractSourceFile | null {
|
||||
if (!profile.source_code_preview && !profile.abi_full && !profile.abi) return null
|
||||
return {
|
||||
path: profile.contract_name ? `${profile.contract_name}.sol` : 'Contract.sol',
|
||||
content: profile.source_code_full || profile.source_code_preview || profile.abi_full || profile.abi || '',
|
||||
}
|
||||
}
|
||||
|
||||
function parseOutline(content: string): OutlineEntry[] {
|
||||
const entries: OutlineEntry[] = []
|
||||
content.split('\n').forEach((line, index) => {
|
||||
const lineNumber = index + 1
|
||||
const typeMatch = line.match(/^\s*(?:abstract\s+)?(contract|interface|library)\s+([A-Za-z_][A-Za-z0-9_]*)/)
|
||||
if (typeMatch) {
|
||||
entries.push({
|
||||
type: typeMatch[1] as OutlineEntry['type'],
|
||||
name: typeMatch[2],
|
||||
line: lineNumber,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const memberMatch = line.match(/^\s*(function|event|error)\s+([A-Za-z_][A-Za-z0-9_]*)/)
|
||||
if (memberMatch) {
|
||||
entries.push({
|
||||
type: memberMatch[1] as OutlineEntry['type'],
|
||||
name: memberMatch[2],
|
||||
line: lineNumber,
|
||||
})
|
||||
}
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
function sourceExcerptForPrompt(files: ContractSourceFile[]): string {
|
||||
return files
|
||||
.slice(0, 4)
|
||||
.map((file) => `File: ${file.path}\n${file.content.slice(0, 2600)}`)
|
||||
.join('\n\n')
|
||||
.slice(0, 5200)
|
||||
}
|
||||
|
||||
export default function ContractCodeWorkspace({ address, profile }: ContractCodeWorkspaceProps) {
|
||||
const files = useMemo(() => {
|
||||
const normalized = profile.source_files?.length ? profile.source_files : []
|
||||
const fallback = makeFallbackSourceFile(profile)
|
||||
return normalized.length > 0 ? normalized : fallback ? [fallback] : []
|
||||
}, [profile])
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'source' | 'reader'>('source')
|
||||
const [activePath, setActivePath] = useState(files[0]?.path || '')
|
||||
const [prompt, setPrompt] = useState('What does this contract do?')
|
||||
const [model, setModel] = useState('Explorer AI')
|
||||
const [saveHistory, setSaveHistory] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [readerAnswer, setReaderAnswer] = useState('')
|
||||
const [readerError, setReaderError] = useState('')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const activeFile = files.find((file) => file.path === activePath) || files[0]
|
||||
const outline = useMemo(() => parseOutline(activeFile?.content || ''), [activeFile?.content])
|
||||
const sourceLines = useMemo(() => (activeFile?.content || '').split('\n'), [activeFile?.content])
|
||||
|
||||
const selectedFiles = files
|
||||
const sourceAvailable = files.length > 0 && Boolean(activeFile?.content)
|
||||
|
||||
const handleCopySource = async () => {
|
||||
if (!activeFile?.content || typeof navigator === 'undefined') return
|
||||
await navigator.clipboard?.writeText(activeFile.content)
|
||||
}
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
if (typeof navigator === 'undefined' || typeof window === 'undefined') return
|
||||
await navigator.clipboard?.writeText(`${window.location.href.split('#')[0]}#contract-source`)
|
||||
}
|
||||
|
||||
const askReader = async (question: string) => {
|
||||
const trimmed = question.trim()
|
||||
if (!trimmed || submitting) return
|
||||
setPrompt(trimmed)
|
||||
setReaderError('')
|
||||
setReaderAnswer('')
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const context = [
|
||||
`Contract address: ${address}`,
|
||||
profile.contract_name ? `Contract name: ${profile.contract_name}` : '',
|
||||
profile.compiler_version ? `Compiler: ${profile.compiler_version}` : '',
|
||||
profile.license_type ? `License: ${profile.license_type}` : '',
|
||||
profile.proxy_type ? `Proxy type: ${profile.proxy_type}` : '',
|
||||
`Read methods: ${profile.read_methods.map((method) => method.signature).slice(0, 24).join(', ') || 'none reported'}`,
|
||||
`Write methods: ${profile.write_methods.map((method) => method.signature).slice(0, 24).join(', ') || 'none reported'}`,
|
||||
sourceAvailable ? `Verified source excerpts:\n${sourceExcerptForPrompt(selectedFiles)}` : 'Verified source text is not available.',
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
const response = await fetch(`${getExplorerApiBase()}/api/v1/ai/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `${trimmed}\n\nUse this contract context and answer concisely. Do not invent behavior that is not supported by the ABI or source.\n\n${context}`,
|
||||
},
|
||||
],
|
||||
pageContext: {
|
||||
path: `/addresses/${address}`,
|
||||
view: 'contract-code-reader',
|
||||
address,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || `AI reader returned HTTP ${response.status}`)
|
||||
}
|
||||
setReaderAnswer(String(payload?.reply || payload?.message?.content || 'No answer returned.'))
|
||||
} catch (error) {
|
||||
setReaderError(error instanceof Error ? error.message : 'Code Reader is temporarily unavailable.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
await askReader(prompt)
|
||||
}
|
||||
|
||||
if (!sourceAvailable && !profile.abi_available) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-6" title="Contract Source Code">
|
||||
<section id="contract-source" className="space-y-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('source')}
|
||||
className={clsx(
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition',
|
||||
activeTab === 'source'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
Source
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('reader')}
|
||||
className={clsx(
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition',
|
||||
activeTab === 'reader'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
Code Reader
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{profile.source_verified ? <EntityBadge label="verified source" tone="success" /> : null}
|
||||
{profile.abi_available ? <EntityBadge label="abi available" tone="info" /> : null}
|
||||
{profile.compiler_version ? <EntityBadge label={profile.compiler_version} tone="neutral" className="normal-case tracking-normal" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'source' ? (
|
||||
<div className={clsx('overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700', expanded ? 'min-h-[46rem]' : '')}>
|
||||
<div className="grid lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<aside className="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 lg:border-b-0 lg:border-r">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 text-xs font-semibold uppercase text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
<span>Explorer</span>
|
||||
<span>{files.length} file{files.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-auto p-2 lg:max-h-[34rem]">
|
||||
{files.map((file) => (
|
||||
<button
|
||||
type="button"
|
||||
key={file.path}
|
||||
onClick={() => setActivePath(file.path)}
|
||||
className={clsx(
|
||||
'block w-full rounded-md px-3 py-2 text-left text-sm transition',
|
||||
file.path === activeFile?.path
|
||||
? 'bg-white font-semibold text-gray-950 shadow-sm dark:bg-gray-800 dark:text-white'
|
||||
: 'text-gray-700 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-800',
|
||||
)}
|
||||
>
|
||||
<span className="block truncate">{file.path}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{outline.length > 0 ? (
|
||||
<div className="border-t border-gray-200 p-2 dark:border-gray-700">
|
||||
<div className="px-3 py-2 text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">Outline</div>
|
||||
<div className="max-h-64 overflow-auto">
|
||||
{outline.slice(0, 80).map((entry) => (
|
||||
<button
|
||||
key={`${entry.type}-${entry.name}-${entry.line}`}
|
||||
type="button"
|
||||
onClick={() => document.getElementById(`source-line-${entry.line}`)?.scrollIntoView({ block: 'center' })}
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span className="w-16 uppercase text-gray-400">{entry.type}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono">{entry.name}</span>
|
||||
<span className="text-gray-400">{entry.line}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 bg-gray-950 text-gray-100">
|
||||
<div className="flex flex-col gap-3 border-b border-gray-800 bg-gray-900 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-sm text-white">{activeFile?.path || 'Source'}</div>
|
||||
<div className="mt-1 text-xs text-gray-400">{sourceLines.length} lines</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={handleCopySource} className="rounded-md border border-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-200 hover:bg-gray-800">
|
||||
Copy
|
||||
</button>
|
||||
<button type="button" onClick={handleCopyLink} className="rounded-md border border-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-200 hover:bg-gray-800">
|
||||
Link
|
||||
</button>
|
||||
<button type="button" onClick={() => setExpanded((value) => !value)} className="rounded-md border border-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-200 hover:bg-gray-800">
|
||||
{expanded ? 'Collapse' : 'Expand'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className={clsx('overflow-auto p-0 text-xs leading-5', expanded ? 'max-h-[52rem]' : 'max-h-[34rem]')}>
|
||||
<code className="block min-w-max py-4">
|
||||
{sourceLines.map((line, index) => (
|
||||
<span id={`source-line-${index + 1}`} key={`${activeFile?.path}-${index}`} className="grid grid-cols-[4.5rem_minmax(0,1fr)] px-4 hover:bg-white/5">
|
||||
<span className="select-none pr-4 text-right text-gray-500">{index + 1}</span>
|
||||
<span className="whitespace-pre text-gray-100">{line || ' '}</span>
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<form onSubmit={handleSubmit} className="grid gap-5 lg:grid-cols-[28rem_minmax(0,1fr)]">
|
||||
<div className="space-y-4 lg:border-r lg:border-gray-200 lg:pr-5 lg:dark:border-gray-700">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-gray-900 dark:text-white">Choose Model</span>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(event) => setModel(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-950 dark:text-white"
|
||||
>
|
||||
<option>Explorer AI</option>
|
||||
<option>Grok</option>
|
||||
</select>
|
||||
</label>
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">File Browser</div>
|
||||
<div className="space-y-2 rounded-lg bg-gray-50 p-3 dark:bg-gray-900">
|
||||
{files.map((file) => (
|
||||
<label key={file.path} className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
<input type="checkbox" checked readOnly className="h-4 w-4 rounded border-gray-300 text-primary-600" />
|
||||
<span className="truncate">{file.path}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Prompt</div>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<input type="checkbox" checked={saveHistory} onChange={(event) => setSaveHistory(event.target.checked)} className="h-4 w-4 rounded border-gray-300 text-primary-600" />
|
||||
Save History
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
rows={3}
|
||||
className="min-h-24 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-950 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !prompt.trim()}
|
||||
className="h-12 rounded-lg bg-primary-600 px-4 text-sm font-semibold text-white transition hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{submitting ? '...' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{QUICK_PROMPTS.map((quickPrompt) => (
|
||||
<button
|
||||
key={quickPrompt}
|
||||
type="button"
|
||||
onClick={() => void askReader(quickPrompt)}
|
||||
className="rounded-full border border-gray-300 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
{quickPrompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{readerAnswer ? (
|
||||
<div className="whitespace-pre-wrap rounded-lg bg-gray-50 p-4 text-sm text-gray-800 dark:bg-gray-900 dark:text-gray-100">
|
||||
{readerAnswer}
|
||||
</div>
|
||||
) : null}
|
||||
{readerError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-200">
|
||||
{readerError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
|
||||
export const CONTRACT_VERIFICATION_GUIDE_URL =
|
||||
'https://gitea.d-bis.org/d-bis/proxmox/src/branch/master/docs/08-monitoring/BLOCKSCOUT_VERIFICATION_GUIDE.md'
|
||||
|
||||
export const FORGE_VERIFY_COMMAND =
|
||||
'source scripts/lib/load-project-env.sh && ./scripts/verify/run-contract-verification-with-proxy.sh'
|
||||
|
||||
interface ContractVerificationCalloutProps {
|
||||
address: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
export default function ContractVerificationCallout({ address, verified }: ContractVerificationCalloutProps) {
|
||||
if (verified) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Verify & Publish Contract" className="mb-6">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
This contract is not verified on the public explorer yet. Verified source improves read/write tooling,
|
||||
ABI decoding, and auditability for{' '}
|
||||
<span className="font-mono text-xs">{address}</span>.
|
||||
</p>
|
||||
<ul className="mt-4 list-disc space-y-2 pl-5 text-sm text-gray-700 dark:text-gray-300">
|
||||
<li>
|
||||
<strong>Forge batch (recommended):</strong>{' '}
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">{FORGE_VERIFY_COMMAND}</code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Operator guide:</strong>{' '}
|
||||
<Link href={CONTRACT_VERIFICATION_GUIDE_URL} className="text-primary-600 hover:underline" target="_blank" rel="noopener noreferrer">
|
||||
Blockscout verification guide
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Explorer contract tab:</strong>{' '}
|
||||
<Link href={`/addresses/${address}`} className="text-primary-600 hover:underline">
|
||||
Open this address and review the Contract tab
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { configApi, type TokenListResponse } from '@/services/api/config'
|
||||
import ExplorerRetryAlert from '@/components/common/ExplorerRetryAlert'
|
||||
import { type TokenListResponse } from '@/services/api/config'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import {
|
||||
aggregateLiquidityPools,
|
||||
featuredLiquiditySymbols,
|
||||
@@ -20,7 +22,10 @@ import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
|
||||
import OperationsSurfaceNav from './OperationsSurfaceNav'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
import {
|
||||
formatCurrency,
|
||||
@@ -75,6 +80,7 @@ export default function LiquidityOperationsPage({
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -95,9 +101,10 @@ export default function LiquidityOperationsPage({
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
setLoadingError(null)
|
||||
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult, statsResult, bridgeResult] =
|
||||
await Promise.allSettled([
|
||||
configApi.getTokenList(),
|
||||
tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
|
||||
routesApi.getRouteMatrix(),
|
||||
plannerApi.getCapabilities(),
|
||||
plannerApi.getInternalExecutionPlan(),
|
||||
@@ -159,6 +166,7 @@ export default function LiquidityOperationsPage({
|
||||
initialStats,
|
||||
initialTokenList,
|
||||
initialTokenPoolRecords,
|
||||
reloadKey,
|
||||
])
|
||||
|
||||
const featuredTokens = useMemo(
|
||||
@@ -197,6 +205,11 @@ export default function LiquidityOperationsPage({
|
||||
}),
|
||||
[bridgeStatus, stats],
|
||||
)
|
||||
const liquidityInventoryUpdatedAt =
|
||||
stats?.sampling?.stats_generated_at ||
|
||||
stats?.freshness?.chain_head?.timestamp ||
|
||||
routeMatrix?.generatedAt ||
|
||||
routeMatrix?.updated
|
||||
|
||||
const insightLines = useMemo(
|
||||
() => [
|
||||
@@ -233,6 +246,12 @@ export default function LiquidityOperationsPage({
|
||||
href: `/explorer-api/v1/mission-control/liquidity/token/${featuredTokens[0]?.address || '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'}/pools`,
|
||||
notes: 'Cached public pool inventory for a specific Chain 138 token.',
|
||||
},
|
||||
{
|
||||
name: 'External indexer readiness',
|
||||
method: 'GET',
|
||||
href: `/api/v1/report/external-indexer-readiness?chainId=138`,
|
||||
notes: 'One JSON posture for DefiLlama, CoinGecko, CoinMarketCap, and Dexscreener readiness.',
|
||||
},
|
||||
]
|
||||
|
||||
const copyEndpoint = async (endpoint: EndpointCard) => {
|
||||
@@ -251,7 +270,7 @@ export default function LiquidityOperationsPage({
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="mb-8 max-w-4xl">
|
||||
<div className="mb-3 inline-flex rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">
|
||||
Chain 138 Liquidity Access
|
||||
Liquidity
|
||||
</div>
|
||||
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
|
||||
Public liquidity, route discovery, and execution access points
|
||||
@@ -261,12 +280,20 @@ export default function LiquidityOperationsPage({
|
||||
public route matrix, planner capabilities, and mission-control token pool inventory together
|
||||
so integrators can inspect what Chain 138 is actually serving right now.
|
||||
</p>
|
||||
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
|
||||
<OperationsSurfaceNav />
|
||||
|
||||
{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>
|
||||
</Card>
|
||||
<ExplorerRetryAlert
|
||||
className="mb-6"
|
||||
message={loadingError}
|
||||
onRetry={() => {
|
||||
setLoadingError(null)
|
||||
setReloadKey((value) => value + 1)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6">
|
||||
@@ -318,6 +345,12 @@ export default function LiquidityOperationsPage({
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatNumber(dexCount)} DEX families in the current discovered pools.
|
||||
</div>
|
||||
<MarketEvidenceNote
|
||||
source="mission-control"
|
||||
lastUpdated={liquidityInventoryUpdatedAt}
|
||||
method="Route matrix, provider capabilities, and mission-control pool inventory are reconciled for visible public liquidity only."
|
||||
compact
|
||||
/>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Fallback posture</div>
|
||||
@@ -354,6 +387,12 @@ export default function LiquidityOperationsPage({
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Seen from {pool.sourceSymbols.join(', ')}
|
||||
</div>
|
||||
<MarketEvidenceNote
|
||||
source="mission-control"
|
||||
lastUpdated={liquidityInventoryUpdatedAt}
|
||||
method="Pool TVL is the visible mission-control value for discovered route-backed liquidity."
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{aggregatedPools.length === 0 ? (
|
||||
|
||||
73
frontend/src/components/explorer/OperationsActionGrid.tsx
Normal file
73
frontend/src/components/explorer/OperationsActionGrid.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import type { ExplorerFeatureAction } from '@/data/explorerOperations'
|
||||
|
||||
export function OperationsActionLink({ action }: { action: ExplorerFeatureAction }) {
|
||||
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
|
||||
const label = `${action.label} ->`
|
||||
|
||||
if (action.external) {
|
||||
return (
|
||||
<a href={action.href} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={action.href} className={className}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionCard({ action }: { action: ExplorerFeatureAction }) {
|
||||
return (
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{action.title}</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">{action.description}</p>
|
||||
<div className="mt-4">
|
||||
<OperationsActionLink action={action} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OperationsActionGrid({
|
||||
actions,
|
||||
title = 'Quick actions',
|
||||
}: {
|
||||
actions: ExplorerFeatureAction[]
|
||||
title?: string
|
||||
}) {
|
||||
if (actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<details className="group mb-6 rounded-2xl border border-gray-200 bg-gray-50/80 dark:border-gray-800 dark:bg-gray-900/40 md:hidden">
|
||||
<summary className="cursor-pointer list-none px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white [&::-webkit-details-marker]:hidden">
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
{title}
|
||||
<span className="text-xs font-normal uppercase tracking-wide text-gray-500">
|
||||
{actions.length} links · <span className="group-open:hidden">Show</span>
|
||||
<span className="hidden group-open:inline">Hide</span>
|
||||
</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="space-y-3 border-t border-gray-200 px-3 py-3 dark:border-gray-800">
|
||||
{actions.map((action) => (
|
||||
<ActionCard key={`${action.title}-${action.href}`} action={action} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="hidden gap-4 md:grid lg:grid-cols-2">
|
||||
{actions.map((action) => (
|
||||
<ActionCard key={`${action.title}-${action.href}`} action={action} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import { useUiMode } from '@/components/common/UiModeContext'
|
||||
@@ -10,6 +11,10 @@ import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
|
||||
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
|
||||
import OperationsActionGrid from '@/components/explorer/OperationsActionGrid'
|
||||
import BridgeLaneHealthPanel from '@/components/explorer/BridgeLaneHealthPanel'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
|
||||
@@ -88,7 +93,7 @@ export default function OperationsHubPage({
|
||||
missionControlApi.getBridgeStatus(),
|
||||
routesApi.getRouteMatrix(),
|
||||
configApi.getNetworks(),
|
||||
configApi.getTokenList(),
|
||||
tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
|
||||
configApi.getCapabilities(),
|
||||
statsApi.get(),
|
||||
])
|
||||
@@ -152,6 +157,25 @@ export default function OperationsHubPage({
|
||||
new Set((tokenList?.tokens || []).map((token) => token.symbol).filter(Boolean) as string[])
|
||||
).slice(0, 8)
|
||||
}, [tokenList])
|
||||
const laneSummary = useMemo(() => {
|
||||
const lanes = bridgeStatus?.data?.bridge_lanes?.lanes || []
|
||||
if (lanes.length === 0) return null
|
||||
|
||||
const funded = lanes.filter((lane) => String(lane.status || '').toLowerCase() === 'funded').length
|
||||
const unfunded = lanes.filter((lane) => String(lane.status || '').toLowerCase() === 'unfunded').length
|
||||
const proofRecorded = lanes.filter(
|
||||
(lane) => String(lane.proof_status || '').toLowerCase() === 'proof-recorded'
|
||||
).length
|
||||
|
||||
return {
|
||||
total: lanes.length,
|
||||
funded,
|
||||
unfunded,
|
||||
proofRecorded,
|
||||
updatedAt: bridgeStatus?.data?.bridge_lanes?.updated_at,
|
||||
}
|
||||
}, [bridgeStatus])
|
||||
|
||||
const activityContext = useMemo(
|
||||
() =>
|
||||
summarizeChainActivity({
|
||||
@@ -179,6 +203,7 @@ export default function OperationsHubPage({
|
||||
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
|
||||
{page.description}
|
||||
</p>
|
||||
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
|
||||
{page.note ? (
|
||||
@@ -189,6 +214,8 @@ export default function OperationsHubPage({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<OperationsSurfaceNav />
|
||||
|
||||
{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>
|
||||
@@ -224,6 +251,14 @@ export default function OperationsHubPage({
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{relayCount} managed lanes · queue {totalQueue}
|
||||
</div>
|
||||
{laneSummary ? (
|
||||
<div className="mt-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
{laneSummary.funded}/{laneSummary.total} config-ready funded
|
||||
{laneSummary.unfunded > 0 ? ` · ${laneSummary.unfunded} unfunded` : ''}
|
||||
{' · '}
|
||||
{laneSummary.proofRecorded}/{laneSummary.total} proof-recorded
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
@@ -305,6 +340,23 @@ export default function OperationsHubPage({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{laneSummary ? (
|
||||
<div className="mt-4 rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Config-ready lanes</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{laneSummary.funded}/{laneSummary.total} funded · {laneSummary.proofRecorded}/{laneSummary.total} proof-recorded
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{mode === 'guided'
|
||||
? 'Remote CCIP bridge LINK balances and operator proof hashes. Full lane table below.'
|
||||
: 'Lane funding and proof posture from bridge status API.'}
|
||||
{' '}
|
||||
<Link href="/bridge" className="font-semibold text-primary-600 hover:underline">
|
||||
Open bridge monitoring
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card title="Public Config Highlights">
|
||||
@@ -335,27 +387,9 @@ export default function OperationsHubPage({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{action.title}
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink
|
||||
href={action.href}
|
||||
label={action.label}
|
||||
external={'external' in action ? action.external : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<BridgeLaneHealthPanel laneHealth={bridgeStatus?.data?.bridge_lanes} />
|
||||
|
||||
<OperationsActionGrid actions={page.actions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import type { ExplorerFeatureAction, ExplorerFeaturePage } from '@/data/explorerOperations'
|
||||
import type { ExplorerFeaturePage } from '@/data/explorerOperations'
|
||||
import OperationsSurfaceNav from './OperationsSurfaceNav'
|
||||
import OperationsActionGrid from './OperationsActionGrid'
|
||||
import OperationsTrackNote from './OperationsTrackNote'
|
||||
|
||||
export type StatusTone = 'normal' | 'warning' | 'danger'
|
||||
|
||||
function ActionLink({ action }: { action: ExplorerFeatureAction }) {
|
||||
const className = 'inline-flex items-center text-sm font-semibold text-primary-600 hover:underline'
|
||||
const label = `${action.label} ->`
|
||||
|
||||
if (action.external) {
|
||||
return (
|
||||
<a href={action.href} className={className} target="_blank" rel="noopener noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={action.href} className={className}>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function relativeAge(isoString?: string): string {
|
||||
if (!isoString) return 'Unknown'
|
||||
const parsed = Date.parse(isoString)
|
||||
@@ -126,23 +109,15 @@ export default function OperationsPageShell({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{page.accessTrack && page.accessNote ? (
|
||||
<OperationsTrackNote track={page.accessTrack} note={page.accessNote} />
|
||||
) : null}
|
||||
|
||||
<OperationsSurfaceNav />
|
||||
|
||||
{children}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">{action.title}</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink action={action} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<OperationsActionGrid actions={page.actions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
79
frontend/src/components/explorer/OperationsSurfaceNav.tsx
Normal file
79
frontend/src/components/explorer/OperationsSurfaceNav.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import clsx from 'clsx'
|
||||
import { explorerOperationsSurfaces } from '@/data/explorerOperations'
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
if (path.length > 1 && path.endsWith('/')) {
|
||||
return path.slice(0, -1)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export default function OperationsSurfaceNav({ className }: { className?: string }) {
|
||||
const router = useRouter()
|
||||
const currentPath = normalizePath(router.pathname)
|
||||
|
||||
return (
|
||||
<nav aria-label="Operations surfaces" className={clsx('mb-6', className)}>
|
||||
<details className="group rounded-2xl border border-gray-200 bg-gray-50/80 dark:border-gray-800 dark:bg-gray-900/40 md:hidden">
|
||||
<summary className="cursor-pointer list-none px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white [&::-webkit-details-marker]:hidden">
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
Jump to operations surface
|
||||
<span className="text-xs font-normal uppercase tracking-wide text-gray-500 group-open:hidden">Show</span>
|
||||
<span className="hidden text-xs font-normal uppercase tracking-wide text-gray-500 group-open:inline">Hide</span>
|
||||
</span>
|
||||
</summary>
|
||||
<ul className="space-y-1 border-t border-gray-200 px-2 py-2 dark:border-gray-800">
|
||||
{explorerOperationsSurfaces.map((surface) => {
|
||||
const active = currentPath === surface.href
|
||||
return (
|
||||
<li key={surface.href}>
|
||||
<Link
|
||||
href={surface.href}
|
||||
className={clsx(
|
||||
'block rounded-xl px-3 py-2 transition',
|
||||
active
|
||||
? 'bg-primary-50 text-primary-700 dark:bg-primary-950/40 dark:text-primary-200'
|
||||
: 'text-gray-700 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-950/60',
|
||||
)}
|
||||
>
|
||||
<div className="text-sm font-semibold">{surface.label}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{surface.description}</div>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">
|
||||
Operations surfaces
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{explorerOperationsSurfaces.map((surface) => {
|
||||
const active = currentPath === surface.href
|
||||
return (
|
||||
<Link
|
||||
key={surface.href}
|
||||
href={surface.href}
|
||||
title={surface.description}
|
||||
className={clsx(
|
||||
'rounded-full border px-3 py-1.5 text-sm font-medium transition',
|
||||
active
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-950/40 dark:text-primary-200'
|
||||
: 'border-gray-200 text-gray-700 hover:border-primary-300 hover:text-primary-600 dark:border-gray-700 dark:text-gray-300 dark:hover:border-primary-500 dark:hover:text-primary-300',
|
||||
)}
|
||||
>
|
||||
{surface.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
20
frontend/src/components/explorer/OperationsTrackNote.tsx
Normal file
20
frontend/src/components/explorer/OperationsTrackNote.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
|
||||
export default function OperationsTrackNote({
|
||||
track,
|
||||
note,
|
||||
className,
|
||||
}: {
|
||||
track: number
|
||||
note: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Card className={className ?? 'mb-6 border border-violet-200 bg-violet-50/70 dark:border-violet-900/50 dark:bg-violet-950/20'}>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-violet-700 dark:text-violet-200">
|
||||
Track {track} public surface
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-violet-950 dark:text-violet-100">{note}</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { configApi, type TokenListResponse } from '@/services/api/config'
|
||||
import { type TokenListResponse } from '@/services/api/config'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
|
||||
import OperationsSurfaceNav from './OperationsSurfaceNav'
|
||||
import {
|
||||
aggregateLiquidityPools,
|
||||
getRouteBackedPoolAddresses,
|
||||
@@ -28,7 +31,7 @@ export default function PoolsOperationsPage() {
|
||||
|
||||
const load = async () => {
|
||||
const [tokenListResult, routeMatrixResult] = await Promise.allSettled([
|
||||
configApi.getTokenList(),
|
||||
tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
|
||||
routesApi.getRouteMatrix(),
|
||||
])
|
||||
|
||||
@@ -100,8 +103,11 @@ export default function PoolsOperationsPage() {
|
||||
This page now summarizes the live pool inventory discovered through mission-control token
|
||||
pool endpoints and cross-checks it against the current route matrix.
|
||||
</p>
|
||||
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
|
||||
<OperationsSurfaceNav />
|
||||
|
||||
{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>
|
||||
|
||||
@@ -16,6 +16,8 @@ import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
import OperationsSurfaceNav from './OperationsSurfaceNav'
|
||||
import OperationsActionGrid from './OperationsActionGrid'
|
||||
|
||||
interface RoutesMonitoringPageProps {
|
||||
initialRouteMatrix?: RouteMatrixResponse | null
|
||||
@@ -224,6 +226,8 @@ export default function RoutesMonitoringPage({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<OperationsSurfaceNav />
|
||||
|
||||
{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>
|
||||
@@ -438,27 +442,7 @@ export default function RoutesMonitoringPage({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{page.actions.map((action) => (
|
||||
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{action.title}
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<ActionLink
|
||||
href={action.href}
|
||||
label={action.label}
|
||||
external={Boolean((action as { external?: boolean }).external)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<OperationsActionGrid actions={page.actions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||
import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
|
||||
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
@@ -11,6 +12,7 @@ import OperationsPageShell, {
|
||||
formatNumber,
|
||||
relativeAge,
|
||||
} from './OperationsPageShell'
|
||||
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
|
||||
|
||||
interface SystemOperationsPageProps {
|
||||
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||
@@ -46,7 +48,7 @@ export default function SystemOperationsPage({
|
||||
await Promise.allSettled([
|
||||
missionControlApi.getBridgeStatus(),
|
||||
configApi.getNetworks(),
|
||||
configApi.getTokenList(),
|
||||
tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
|
||||
configApi.getCapabilities(),
|
||||
routesApi.getRouteMatrix(),
|
||||
statsApi.get(),
|
||||
@@ -125,6 +127,7 @@ export default function SystemOperationsPage({
|
||||
description={`${formatNumber(capabilities?.tracing?.supportedMethods?.length)} tracing methods published.`}
|
||||
/>
|
||||
</div>
|
||||
<TokenListSurfaceNote className="mb-6 text-xs text-gray-500 dark:text-gray-400" />
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
|
||||
<Card title="Topology Snapshot">
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function WethOperationsPage({
|
||||
<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.
|
||||
These WETH references are bridge-lane and public-network representation 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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives/Card'
|
||||
import { Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { blocksApi, type Block } from '@/services/api/blocks'
|
||||
import {
|
||||
@@ -21,8 +22,18 @@ import { transactionsApi, type Transaction } from '@/services/api/transactions'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel'
|
||||
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
||||
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
|
||||
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import {
|
||||
HOME_DASHBOARD_REFRESH_MS,
|
||||
HOME_PRICE_FEED_REFRESH_MS,
|
||||
resolveHomePriceFeedAddresses,
|
||||
} from '@/utils/featuredTokens'
|
||||
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
|
||||
|
||||
type HomeStats = ExplorerStats
|
||||
|
||||
@@ -92,6 +103,15 @@ function compactStatNote(guided: string, expert: string, mode: 'guided' | 'exper
|
||||
return mode === 'guided' ? guided : expert
|
||||
}
|
||||
|
||||
function formatUsd(value: number | undefined) {
|
||||
if (value == null || !Number.isFinite(value)) return 'Unavailable'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: value >= 100 ? 0 : 2,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export default function Home({
|
||||
initialStats = null,
|
||||
initialRecentBlocks = [],
|
||||
@@ -109,8 +129,10 @@ export default function Home({
|
||||
const [activitySnapshot, setActivitySnapshot] = useState<ExplorerRecentActivitySnapshot | null>(initialActivitySnapshot)
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [relaySummary, setRelaySummary] = useState<MissionControlRelaySummary | null>(initialRelaySummary)
|
||||
const [featuredPrices, setFeaturedPrices] = useState<TokenAggregationTokenSnapshot[]>([])
|
||||
const [missionExpanded, setMissionExpanded] = useState(false)
|
||||
const [relayExpanded, setRelayExpanded] = useState(false)
|
||||
const [statsDetailsExpanded, setStatsDetailsExpanded] = useState(false)
|
||||
const [relayPage, setRelayPage] = useState(1)
|
||||
const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>(
|
||||
initialRelaySummary || initialBridgeStatus ? 'fallback' : 'connecting'
|
||||
@@ -144,8 +166,27 @@ export default function Home({
|
||||
)
|
||||
}, [chainId])
|
||||
|
||||
const loadFeaturedPrices = useCallback(async () => {
|
||||
const [catalogResult, reportResult] = await Promise.all([
|
||||
tokensApi.listForSurface('catalog', chainId),
|
||||
tokensApi.listReportSafe(chainId),
|
||||
])
|
||||
|
||||
const addresses = resolveHomePriceFeedAddresses(
|
||||
catalogResult.ok ? catalogResult.data : [],
|
||||
reportResult.ok ? reportResult.data : [],
|
||||
)
|
||||
|
||||
const { data } = await tokenAggregationApi.getTokensByAddressSafe(chainId, addresses)
|
||||
setFeaturedPrices(data)
|
||||
}, [chainId])
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard()
|
||||
void loadDashboard()
|
||||
return createVisibilityAwarePoller({
|
||||
intervalMs: HOME_DASHBOARD_REFRESH_MS,
|
||||
task: loadDashboard,
|
||||
})
|
||||
}, [loadDashboard])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -166,6 +207,33 @@ export default function Home({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const refreshFeaturedPrices = async () => {
|
||||
try {
|
||||
if (!cancelled) {
|
||||
await loadFeaturedPrices()
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to load featured token prices:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void refreshFeaturedPrices()
|
||||
const stop = createVisibilityAwarePoller({
|
||||
intervalMs: HOME_PRICE_FEED_REFRESH_MS,
|
||||
task: refreshFeaturedPrices,
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
stop()
|
||||
}
|
||||
}, [loadFeaturedPrices])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
@@ -235,6 +303,31 @@ export default function Home({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (relayFeedState !== 'fallback') return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const refreshSnapshot = async () => {
|
||||
try {
|
||||
const status = await missionControlApi.getBridgeStatus()
|
||||
if (!cancelled) {
|
||||
setBridgeStatus(status)
|
||||
setRelaySummary(summarizeMissionControlRelay(status))
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to refresh mission control snapshot:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createVisibilityAwarePoller({
|
||||
intervalMs: HOME_DASHBOARD_REFRESH_MS,
|
||||
task: refreshSnapshot,
|
||||
})
|
||||
}, [relayFeedState])
|
||||
|
||||
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'
|
||||
@@ -558,7 +651,7 @@ export default function Home({
|
||||
href="/bridge"
|
||||
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 bridge monitoring
|
||||
Open bridge
|
||||
</Link>
|
||||
<Link
|
||||
href="/operations"
|
||||
@@ -575,7 +668,7 @@ export default function Home({
|
||||
<div 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="text-xs font-semibold uppercase tracking-wide opacity-70">Chain 138 Status</div>
|
||||
<div className="mt-2 text-lg font-semibold">{chainStatus.status || 'unknown'}</div>
|
||||
<div className="mt-1 text-sm opacity-80">{chainStatus.name || 'Defi Oracle Meta Mainnet'}</div>
|
||||
<div className="mt-1 text-sm opacity-80">{chainStatus.name || 'DeFi Oracle Meta Mainnet'}</div>
|
||||
</div>
|
||||
<div 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="text-xs font-semibold uppercase tracking-wide opacity-70">Head Age</div>
|
||||
@@ -681,9 +774,20 @@ export default function Home({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(relaySummary || bridgeStatus || stats) && (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-gray-200 bg-white/80 px-4 py-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<EntityBadge label={`block ${latestBlock != null ? latestBlock.toLocaleString() : 'unknown'}`} tone="info" />
|
||||
{chainStatus?.status ? <EntityBadge label={`chain ${chainStatus.status}`} tone={chainStatus.status === 'operational' ? 'success' : 'warning'} /> : null}
|
||||
{relaySummary ? <EntityBadge label={`${relayOperationalCount} relays ok`} tone="success" /> : null}
|
||||
<EntityBadge label={relayFeedState === 'live' ? 'live feed' : relayFeedState === 'fallback' ? 'snapshot feed' : 'connecting'} tone={relayFeedState === 'live' ? 'success' : 'info'} />
|
||||
<span className="text-gray-600 dark:text-gray-400">{latestTransactionAgeLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="mb-8 space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
|
||||
<div className="mb-6 space-y-4">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">Network overview</p>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{primaryMetricCards.map((card) => (
|
||||
<Card key={card.label}>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
|
||||
@@ -693,69 +797,111 @@ export default function Home({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-3">
|
||||
{activityMetricCards.map((card) => (
|
||||
<Card key={card.label}>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">{card.note}</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.detail}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<MissionDeliveryModePanel className="mt-1" mode={missionMode} title="Homepage delivery mode" />
|
||||
<ActivityContextPanel
|
||||
context={activityContext}
|
||||
title="Freshness Interpretation"
|
||||
compact
|
||||
/>
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel={
|
||||
mode === 'guided'
|
||||
? 'Homepage status combines chain freshness, transaction visibility, and mission-control posture.'
|
||||
: 'Homepage freshness view aligns chain, transaction, and mission-control posture.'
|
||||
}
|
||||
/>
|
||||
|
||||
{mode === 'guided' ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
|
||||
{secondaryMetricCards.map((card) => (
|
||||
<Card key={card.label}>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Telemetry Snapshot</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Secondary public stats in a denser expert layout.
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid flex-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatsDetailsExpanded((current) => !current)}
|
||||
className="text-sm font-semibold text-primary-600 hover:underline"
|
||||
>
|
||||
{statsDetailsExpanded ? 'Hide extended telemetry' : 'Show extended telemetry'}
|
||||
</button>
|
||||
|
||||
{statsDetailsExpanded ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-3">
|
||||
{activityMetricCards.map((card) => (
|
||||
<Card key={card.label}>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">{card.note}</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.detail}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === 'guided' ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
|
||||
{secondaryMetricCards.map((card) => (
|
||||
<div key={card.label} className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{card.label}</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{card.value}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
|
||||
</div>
|
||||
<Card key={card.label}>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
|
||||
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
) : (
|
||||
<Card>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Telemetry Snapshot</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Secondary public stats in a denser expert layout.
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid flex-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{secondaryMetricCards.map((card) => (
|
||||
<div key={card.label} className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{card.label}</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{card.value}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<ActivityContextPanel
|
||||
context={activityContext}
|
||||
title="Freshness Interpretation"
|
||||
compact
|
||||
/>
|
||||
<FreshnessTrustNote
|
||||
className="mt-3"
|
||||
context={activityContext}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel={
|
||||
mode === 'guided'
|
||||
? 'Homepage status combines chain freshness, transaction visibility, and mission-control posture.'
|
||||
: 'Homepage freshness view aligns chain, transaction, and mission-control posture.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{featuredPrices.length > 0 ? (
|
||||
<div className="mb-8">
|
||||
<Card title="Live Price Feed">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{featuredPrices.map((token) => (
|
||||
<Link
|
||||
key={token.address}
|
||||
href={`/tokens/${token.address}`}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-800 dark:bg-gray-900/40"
|
||||
>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{token.symbol || token.name || 'Token'}
|
||||
</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{formatUsd(token.market?.priceUsd)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Visible liquidity: {formatUsd(token.market?.liquidityUsd)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{token.market?.lastUpdated ? `Updated ${formatRelativeAge(token.market.lastUpdated)}` : 'Update time unavailable'}
|
||||
</div>
|
||||
<MarketEvidenceNote lastUpdated={token.market?.lastUpdated} compact />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!stats && (
|
||||
<Card className="mb-8">
|
||||
@@ -765,7 +911,46 @@ export default function Home({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Recent Blocks">
|
||||
<div className="mb-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card title="Recent Transactions">
|
||||
{recentTransactions.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Recent transactions are unavailable right now.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentTransactions.map((transaction) => (
|
||||
<div key={transaction.hash} 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={`/transactions/${transaction.hash}`} className="text-primary-600 hover:underline">
|
||||
{transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
|
||||
</Link>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Block #{transaction.block_number} · from{' '}
|
||||
<Address address={transaction.from_address} truncate showCopy={false} />
|
||||
{transaction.to_address ? (
|
||||
<>
|
||||
{' '}→ <Address address={transaction.to_address} truncate showCopy={false} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
|
||||
<div>{formatWeiAsEth(transaction.value, 4)}</div>
|
||||
<div className="text-xs">{formatTimestamp(transaction.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
View all transactions →
|
||||
</Link>
|
||||
</div>
|
||||
</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.
|
||||
@@ -803,120 +988,41 @@ export default function Home({
|
||||
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">
|
||||
{mode === 'guided'
|
||||
? 'A concise public view of chain activity, index coverage, and recent execution patterns.'
|
||||
: 'Public chain activity and index posture.'}
|
||||
</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>
|
||||
|
||||
<Card title="Quick links" className="mt-8">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Jump to the explorer surfaces used most often for discovery, liquidity, wallet setup, and bridge monitoring.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Link href="/search" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Search
|
||||
</Link>
|
||||
<Link href="/access" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Account access
|
||||
</Link>
|
||||
<Link href="/tokens" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Tokens
|
||||
</Link>
|
||||
<Link href="/wallet" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Wallet & MetaMask
|
||||
</Link>
|
||||
<Link href="/routes" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Routes
|
||||
</Link>
|
||||
<Link href="/liquidity" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Liquidity
|
||||
</Link>
|
||||
<Link href="/bridge" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Bridge
|
||||
</Link>
|
||||
<Link href="/analytics" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Analytics
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import { selectWalletFeaturedTokens } from '@/utils/featuredTokens'
|
||||
|
||||
export type WalletChain = {
|
||||
chainId: string
|
||||
@@ -84,6 +86,32 @@ export type CapabilitiesCatalog = {
|
||||
}
|
||||
}
|
||||
|
||||
type WatchAssetEntry = {
|
||||
type: 'ERC20'
|
||||
options: {
|
||||
address: string
|
||||
symbol: string
|
||||
decimals: number
|
||||
image?: string
|
||||
}
|
||||
metadata?: {
|
||||
name?: string
|
||||
registryFamily?: string
|
||||
familySymbol?: string
|
||||
deploymentVersion?: string
|
||||
deploymentStatus?: string
|
||||
}
|
||||
}
|
||||
|
||||
type MetaMaskConfig = {
|
||||
source?: string
|
||||
version?: string
|
||||
chainId?: number
|
||||
addEthereumChain?: WalletChain
|
||||
watchAssets?: WatchAssetEntry[]
|
||||
caveats?: string[]
|
||||
}
|
||||
|
||||
export type FetchMetadata = {
|
||||
source?: string | null
|
||||
lastModified?: string | null
|
||||
@@ -109,7 +137,11 @@ const FALLBACK_CHAIN_138: WalletChain = {
|
||||
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', 'https://blockscout.defi-oracle.io'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'],
|
||||
iconUrls: [
|
||||
'https://explorer.d-bis.org/api/v1/report/logo/chain-138',
|
||||
'https://explorer.d-bis.org/token-icons/chain-138.png',
|
||||
'https://explorer.d-bis.org/favicon.ico',
|
||||
],
|
||||
shortName: 'dbis',
|
||||
infoURL: 'https://explorer.d-bis.org',
|
||||
explorerApiUrl: 'https://explorer.d-bis.org/api/v2',
|
||||
@@ -139,7 +171,21 @@ const FALLBACK_ALL_MAINNET: WalletChain = {
|
||||
infoURL: 'https://alltra.global',
|
||||
}
|
||||
|
||||
const FEATURED_TOKEN_SYMBOLS = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT']
|
||||
const MAINNET_CWUSDC_TOKEN: TokenListToken = {
|
||||
chainId: 1,
|
||||
address: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a',
|
||||
symbol: 'cWUSDC',
|
||||
name: 'Wrapped cUSDC',
|
||||
decimals: 6,
|
||||
logoURI: 'https://explorer.d-bis.org/api/v1/report/logo/cUSDC?v=20260510',
|
||||
tags: ['mainnet', 'cw', 'usd'],
|
||||
extensions: {
|
||||
registryFamily: 'iso4217',
|
||||
familySymbol: 'USD',
|
||||
canonicalSourceChainId: 138,
|
||||
canonicalSourceSymbol: 'cUSDC',
|
||||
},
|
||||
}
|
||||
|
||||
/** npm-published Snap using open Snap permissions only; stable MetaMask still requires MetaMask’s install allowlist. */
|
||||
const CHAIN138_OPEN_SNAP_ID = 'npm:chain138-open-snap' as const
|
||||
@@ -148,7 +194,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: 'SolaceScan',
|
||||
generatedBy: 'DBIS Explorer',
|
||||
chainId: 138,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
rpcUrl: 'https://rpc-http-pub.d-bis.org',
|
||||
@@ -218,12 +264,62 @@ function isCapabilitiesCatalog(value: unknown): value is CapabilitiesCatalog {
|
||||
)
|
||||
}
|
||||
|
||||
function isWatchAssetEntry(value: unknown): value is WatchAssetEntry {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
|
||||
const candidate = value as Partial<WatchAssetEntry>
|
||||
const options = (candidate.options || {}) as Partial<WatchAssetEntry['options']>
|
||||
return (
|
||||
candidate.type === 'ERC20' &&
|
||||
typeof options.address === 'string' &&
|
||||
options.address.trim().length > 0 &&
|
||||
typeof options.symbol === 'string' &&
|
||||
options.symbol.trim().length > 0 &&
|
||||
typeof options.decimals === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isMetaMaskConfig(value: unknown): value is MetaMaskConfig {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
|
||||
const candidate = value as Partial<MetaMaskConfig>
|
||||
return (
|
||||
typeof candidate.chainId === 'number' &&
|
||||
!!candidate.addEthereumChain &&
|
||||
Array.isArray(candidate.watchAssets)
|
||||
)
|
||||
}
|
||||
|
||||
function watchAssetToToken(entry: WatchAssetEntry): TokenListToken {
|
||||
return {
|
||||
chainId: 138,
|
||||
address: entry.options.address,
|
||||
symbol: entry.options.symbol,
|
||||
name: entry.metadata?.name || entry.options.symbol,
|
||||
decimals: entry.options.decimals,
|
||||
logoURI: entry.options.image,
|
||||
extensions: {
|
||||
registryFamily: entry.metadata?.registryFamily,
|
||||
familySymbol: entry.metadata?.familySymbol,
|
||||
deploymentVersion: entry.metadata?.deploymentVersion,
|
||||
deploymentStatus: entry.metadata?.deploymentStatus,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getApiBase() {
|
||||
return resolveExplorerApiBase({
|
||||
serverFallback: 'https://blockscout.defi-oracle.io',
|
||||
browserOrigin: '',
|
||||
serverFallback: 'https://explorer.d-bis.org',
|
||||
})
|
||||
}
|
||||
|
||||
function formatStableTimestamp(value: string): string {
|
||||
const timestamp = Date.parse(value)
|
||||
if (Number.isNaN(timestamp)) return value
|
||||
return new Date(timestamp).toISOString()
|
||||
}
|
||||
|
||||
export function AddToMetaMask({
|
||||
initialNetworks = null,
|
||||
initialTokenList = null,
|
||||
@@ -253,19 +349,21 @@ export function AddToMetaMask({
|
||||
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
|
||||
}),
|
||||
)
|
||||
const [metamaskConfig, setMetamaskConfig] = useState<MetaMaskConfig | null>(null)
|
||||
const [metamaskConfigMeta, setMetamaskConfigMeta] = useState<FetchMetadata | null>(null)
|
||||
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>([])
|
||||
const [watchAssetProgress, setWatchAssetProgress] = useState<{ current: number; total: number } | null>(null)
|
||||
|
||||
const ethereum = typeof window !== 'undefined'
|
||||
? (window as unknown as { ethereum?: EthereumProvider }).ethereum
|
||||
: undefined
|
||||
|
||||
const apiBase = getApiBase().replace(/\/$/, '')
|
||||
const tokenListUrl = `${apiBase}/api/config/token-list`
|
||||
const tokenListUrl = `${apiBase}/api/v1/report/token-list?chainId=138`
|
||||
const networksUrl = `${apiBase}/api/config/networks`
|
||||
const metamaskConfigUrl = `${apiBase}/api/v1/config/metamask?chainId=138`
|
||||
const capabilitiesUrl = `${apiBase}/api/config/capabilities`
|
||||
const staticCapabilitiesUrl =
|
||||
typeof window !== 'undefined'
|
||||
? `${window.location.origin.replace(/\/$/, '')}/config/CHAIN138_RPC_CAPABILITIES.json`
|
||||
: `${apiBase}/config/CHAIN138_RPC_CAPABILITIES.json`
|
||||
const staticCapabilitiesUrl = `${apiBase}/config/CHAIN138_RPC_CAPABILITIES.json`
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
@@ -293,6 +391,7 @@ export function AddToMetaMask({
|
||||
fetchJson(tokenListUrl),
|
||||
fetchJson(capabilitiesUrl),
|
||||
])
|
||||
const metamaskConfigResponse = await fetchJson(metamaskConfigUrl).catch(() => null)
|
||||
|
||||
let resolvedCapabilities = capabilitiesResponse
|
||||
if (!isCapabilitiesCatalog(resolvedCapabilities.json)) {
|
||||
@@ -320,6 +419,10 @@ export function AddToMetaMask({
|
||||
setNetworks(networksResponse.json)
|
||||
setTokenList(tokenListResponse.json)
|
||||
setCapabilities(resolvedCapabilities.json)
|
||||
if (isMetaMaskConfig(metamaskConfigResponse?.json)) {
|
||||
setMetamaskConfig(metamaskConfigResponse.json)
|
||||
setMetamaskConfigMeta(metamaskConfigResponse.meta)
|
||||
}
|
||||
setNetworksMeta(networksResponse.meta)
|
||||
setTokenListMeta(tokenListResponse.meta)
|
||||
setCapabilitiesMeta(resolvedCapabilities.meta)
|
||||
@@ -328,6 +431,7 @@ export function AddToMetaMask({
|
||||
setNetworks((current) => current)
|
||||
setTokenList((current) => current)
|
||||
setCapabilities((current) => current || FALLBACK_CAPABILITIES_138)
|
||||
setMetamaskConfig((current) => current)
|
||||
setNetworksMeta((current) => current)
|
||||
setTokenListMeta((current) => current)
|
||||
setCapabilitiesMeta((current) =>
|
||||
@@ -351,7 +455,25 @@ export function AddToMetaMask({
|
||||
active = false
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [capabilitiesUrl, networksUrl, staticCapabilitiesUrl, tokenListUrl])
|
||||
}, [capabilitiesUrl, metamaskConfigUrl, networksUrl, staticCapabilitiesUrl, tokenListUrl])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
tokensApi.listForSurface('wallet', 138).then(({ ok, data }) => {
|
||||
if (active) {
|
||||
setCuratedTokens(ok ? (data as TokenListToken[]) : [])
|
||||
}
|
||||
}).catch(() => {
|
||||
if (active) {
|
||||
setCuratedTokens([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const catalogTokens = useMemo(
|
||||
() => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []),
|
||||
@@ -367,25 +489,26 @@ export function AddToMetaMask({
|
||||
}
|
||||
|
||||
return {
|
||||
chain138: chainMap.get(138) || FALLBACK_CHAIN_138,
|
||||
chain138: metamaskConfig?.addEthereumChain || chainMap.get(138) || FALLBACK_CHAIN_138,
|
||||
ethereum: chainMap.get(1) || FALLBACK_ETHEREUM,
|
||||
allMainnet: chainMap.get(651940) || FALLBACK_ALL_MAINNET,
|
||||
total: (networks?.chains || []).length,
|
||||
}
|
||||
}, [networks])
|
||||
}, [metamaskConfig, networks])
|
||||
|
||||
const featuredTokens = useMemo(() => {
|
||||
const tokenMap = new Map<string, TokenListToken>()
|
||||
for (const token of catalogTokens) {
|
||||
if (token.chainId !== 138) continue
|
||||
if (!FEATURED_TOKEN_SYMBOLS.includes(token.symbol)) continue
|
||||
tokenMap.set(token.symbol, token)
|
||||
}
|
||||
const featuredTokens = useMemo(
|
||||
() => selectWalletFeaturedTokens(catalogTokens, curatedTokens) as TokenListToken[],
|
||||
[catalogTokens, curatedTokens],
|
||||
)
|
||||
|
||||
return FEATURED_TOKEN_SYMBOLS
|
||||
.map((symbol) => tokenMap.get(symbol))
|
||||
.filter((token): token is TokenListToken => !!token)
|
||||
}, [catalogTokens])
|
||||
const watchAssetTokens = useMemo(() => {
|
||||
const endpointTokens = (metamaskConfig?.watchAssets || [])
|
||||
.filter(isWatchAssetEntry)
|
||||
.map(watchAssetToToken)
|
||||
|
||||
if (endpointTokens.length > 0) return endpointTokens
|
||||
return catalogTokens.filter((token) => token.chainId === 138)
|
||||
}, [catalogTokens, metamaskConfig])
|
||||
|
||||
const addChain = async (chain: WalletChain) => {
|
||||
setError(null)
|
||||
@@ -412,6 +535,39 @@ export function AddToMetaMask({
|
||||
}
|
||||
}
|
||||
|
||||
const switchOrAddChain = async (chain: WalletChain) => {
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await ethereum.request({
|
||||
method: 'wallet_switchEthereumChain',
|
||||
params: [{ chainId: chain.chainId }],
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
const err = e as { code?: number; message?: string }
|
||||
if (err.code !== 4902) {
|
||||
setError(err.message || `Failed to switch to ${chain.chainName}.`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ethereum.request({
|
||||
method: 'wallet_addEthereumChain',
|
||||
params: [chain],
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setError(err.message || `Failed to add ${chain.chainName}.`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const installOpenSnap = async () => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
@@ -435,7 +591,7 @@ export function AddToMetaMask({
|
||||
const allowlistBlocked = /allowlist/i.test(msg)
|
||||
if (allowlistBlocked && msg) {
|
||||
setError(
|
||||
`${msg} Production MetaMask only installs allowlisted Snaps from npm. Use MetaMask Flask for unrestricted installs during development, or request allowlisting via MetaMask’s Snaps documentation.`,
|
||||
`${msg} This is expected on Stable MetaMask until this exact Snap package and version are accepted on MetaMask's install allowlist. The production path on this page is Add Chain 138 plus EIP-747 Add Tokens; use MetaMask Flask for Snap testing or submit/update the Snap allowlist request before using this button with Stable MetaMask.`,
|
||||
)
|
||||
} else {
|
||||
setError(
|
||||
@@ -481,6 +637,63 @@ export function AddToMetaMask({
|
||||
}
|
||||
}
|
||||
|
||||
const refreshMainnetCwusdc = async () => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
|
||||
const switched = await switchOrAddChain(chains.ethereum)
|
||||
if (!switched) return
|
||||
|
||||
await watchToken(MAINNET_CWUSDC_TOKEN)
|
||||
}
|
||||
|
||||
const watchTokensSequentially = async (tokens: TokenListToken[], label: string) => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
setWatchAssetProgress(null)
|
||||
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
return
|
||||
}
|
||||
|
||||
const validTokens = tokens.filter(isTokenListToken)
|
||||
if (validTokens.length === 0) {
|
||||
setError('No complete token metadata is available for wallet_watchAsset right now.')
|
||||
return
|
||||
}
|
||||
|
||||
let addedCount = 0
|
||||
for (let index = 0; index < validTokens.length; index += 1) {
|
||||
const token = validTokens[index]
|
||||
setWatchAssetProgress({ current: index + 1, total: validTokens.length })
|
||||
try {
|
||||
const added = await ethereum.request({
|
||||
method: 'wallet_watchAsset',
|
||||
params: {
|
||||
type: 'ERC20',
|
||||
options: {
|
||||
address: token.address,
|
||||
symbol: token.symbol,
|
||||
decimals: token.decimals,
|
||||
image: token.logoURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (added) addedCount += 1
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setError(err.message || `Stopped while adding ${token.symbol}.`)
|
||||
setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted before the flow stopped.`)
|
||||
setWatchAssetProgress(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setWatchAssetProgress(null)
|
||||
setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted by the wallet.`)
|
||||
}
|
||||
|
||||
const copyText = async (value: string, label: string) => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
@@ -510,8 +723,8 @@ export function AddToMetaMask({
|
||||
The wallet tools now read the same explorer-served network catalog and token list that MetaMask can consume.
|
||||
That keeps chain metadata, token metadata, and optional extensions aligned with the live explorer API instead of
|
||||
relying on stale frontend-only defaults. MetaMask does not run built-in token detection on custom networks such
|
||||
as Chain 138: add the token list URL below under Settings → Security & privacy → Token lists so tokens and
|
||||
icons load automatically when you are on this chain.
|
||||
as Chain 138, so this page uses EIP-747 wallet_watchAsset prompts from the live MetaMask payload to add token
|
||||
metadata directly to the wallet.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
@@ -538,17 +751,19 @@ export function AddToMetaMask({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-primary-200 bg-primary-50/40 p-4 dark:border-primary-900 dark:bg-primary-950/20">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Chain 138 Open Snap</div>
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50/50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Optional Chain 138 Open Snap</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Optional MetaMask Snap that uses{' '}
|
||||
This is <span className="font-medium text-gray-800 dark:text-gray-200">not required</span> for the production
|
||||
wallet flow above. The normal production path is to add Chain 138, then add tokens through EIP-747
|
||||
wallet_watchAsset prompts. The optional Snap uses{' '}
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">only open Snap permissions</span> (minimal
|
||||
privileged APIs in the Snap itself).{' '}
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">Stable MetaMask</span> still only installs npm
|
||||
Snaps that appear on MetaMask's install allowlist; if install fails with "not on the allowlist",
|
||||
use <span className="font-medium text-gray-800 dark:text-gray-200">MetaMask Flask</span> for development or apply
|
||||
for allowlisting. It adds in-wallet weekly reminders, Chain 138 transaction/signature hints, and the token list
|
||||
URL on the Snap home page. The package on npm is{' '}
|
||||
Snaps that appear on MetaMask's install allowlist; if install fails with "not on the allowlist", that is
|
||||
an external MetaMask review gate rather than an explorer/network failure. Use{' '}
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">MetaMask Flask</span> for development or apply
|
||||
for allowlisting before using this with Stable MetaMask. The package on npm is{' '}
|
||||
<code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">{CHAIN138_OPEN_SNAP_ID}</code>
|
||||
— publish from the repo with <code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">scripts/deployment/publish-chain138-open-snap.sh</code> after{' '}
|
||||
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">npm login</code>.
|
||||
@@ -556,9 +771,9 @@ export function AddToMetaMask({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void installOpenSnap()}
|
||||
className="mt-3 rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
className="mt-3 rounded bg-amber-700 px-4 py-2 text-sm font-medium text-white hover:bg-amber-800"
|
||||
>
|
||||
Install Open Snap
|
||||
Install Snap (Flask or allowlisted Stable)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -568,8 +783,10 @@ export function AddToMetaMask({
|
||||
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}</p>
|
||||
<p>Chain 138 token entries: {tokenCount138}</p>
|
||||
<p>EIP-747 watchAsset entries: {watchAssetTokens.length}</p>
|
||||
<p>Networks source: {networksMeta?.source || 'unknown'}</p>
|
||||
<p>Token list source: {tokenListMeta?.source || 'unknown'}</p>
|
||||
<p>MetaMask payload source: {metamaskConfigMeta?.source || 'unknown'}</p>
|
||||
{metadataKeywordString ? <p>Keywords: {metadataKeywordString}</p> : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
@@ -597,6 +814,18 @@ export function AddToMetaMask({
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">EIP-747 MetaMask payload URL</p>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{metamaskConfigUrl}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => copyText(metamaskConfigUrl, 'MetaMask payload URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
Copy URL
|
||||
</button>
|
||||
<a href={metamaskConfigUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
Open JSON
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token list URL</p>
|
||||
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{tokenListUrl}</code>
|
||||
@@ -653,7 +882,7 @@ export function AddToMetaMask({
|
||||
))}
|
||||
{capabilitiesMeta?.lastModified ? (
|
||||
<p className="text-xs">
|
||||
Last modified: {new Date(capabilitiesMeta.lastModified).toLocaleString()}
|
||||
Last modified: {formatStableTimestamp(capabilitiesMeta.lastModified)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -662,9 +891,31 @@ export function AddToMetaMask({
|
||||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Featured Chain 138 tokens</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
These tokens come from the explorer token list and use `wallet_watchAsset` so the wallet gets the same symbol,
|
||||
decimals, image, and optional token metadata that the explorer publishes.
|
||||
These tokens come from the explorer MetaMask payload and use wallet_watchAsset so the wallet gets the same
|
||||
symbol, decimals, image, and optional token metadata that the explorer publishes. MetaMask requires a user
|
||||
approval for each token, so the bulk actions below run as a guided sequence of wallet prompts.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void watchTokensSequentially(featuredTokens, 'featured Chain 138')}
|
||||
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Add featured tokens
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void watchTokensSequentially(watchAssetTokens, 'Chain 138')}
|
||||
className="rounded bg-gray-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-gray-700"
|
||||
>
|
||||
Add all Chain 138 tokens
|
||||
</button>
|
||||
{watchAssetProgress ? (
|
||||
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Prompt {watchAssetProgress.current} of {watchAssetProgress.total}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{featuredTokens.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Featured token metadata is not available right now.</p>
|
||||
@@ -698,6 +949,35 @@ export function AddToMetaMask({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Ethereum Mainnet cWUSDC</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
This refreshes the Mainnet cWUSDC custom asset metadata with the DBIS-hosted image URL. MetaMask fiat price
|
||||
display still depends on MetaMask and upstream asset/price providers accepting the Mainnet listing.
|
||||
</p>
|
||||
<div className="mt-4 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{MAINNET_CWUSDC_TOKEN.symbol}{' '}
|
||||
<span className="font-normal text-gray-500 dark:text-gray-400">({MAINNET_CWUSDC_TOKEN.name})</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{MAINNET_CWUSDC_TOKEN.address}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Ethereum Mainnet • Decimals: {MAINNET_CWUSDC_TOKEN.decimals}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshMainnetCwusdc()}
|
||||
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Refresh Mainnet cWUSDC
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
|
||||
|
||||
32
frontend/src/components/wallet/WalletConnectPostureNote.tsx
Normal file
32
frontend/src/components/wallet/WalletConnectPostureNote.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getWalletConnectConfig, type WalletConnectConfigResponse } from '@/services/api/walletConnect'
|
||||
|
||||
export default function WalletConnectPostureNote() {
|
||||
const [config, setConfig] = useState<WalletConnectConfigResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
getWalletConnectConfig()
|
||||
.then((value) => {
|
||||
if (!cancelled) setConfig(value)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setConfig(null)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!config) return null
|
||||
|
||||
return (
|
||||
<p className="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
WalletConnect v2 posture: <strong>{config.enabled ? 'enabled' : config.status}</strong>. Browser extension wallets use{' '}
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">{config.fallbackAuth}</code> today.
|
||||
{config.message ? ` ${config.message}` : ''}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type {
|
||||
CapabilitiesCatalog,
|
||||
FetchMetadata,
|
||||
@@ -6,6 +6,9 @@ import type {
|
||||
TokenListCatalog,
|
||||
} from '@/components/wallet/AddToMetaMask'
|
||||
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
|
||||
import WalletConnectPostureNote from '@/components/wallet/WalletConnectPostureNote'
|
||||
import { connectAndAuthenticateWalletConnect, getActiveWalletConnectSessionId, isWalletConnectClientReady, loadWalletConnectConfig } from '@/services/wallet/walletConnectClient'
|
||||
import { registerWalletConnectSession } from '@/services/api/walletConnect'
|
||||
import Link from 'next/link'
|
||||
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
||||
import { accessApi, type WalletAccessSession } from '@/services/api/access'
|
||||
@@ -17,6 +20,8 @@ import {
|
||||
type AddressTokenTransfer,
|
||||
type TransactionSummary,
|
||||
} from '@/services/api/addresses'
|
||||
import { WALLET_SNAPSHOT_REFRESH_MS } from '@/utils/featuredTokens'
|
||||
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
|
||||
import { formatRelativeAge, formatTokenAmount } from '@/utils/format'
|
||||
import {
|
||||
isWatchlistEntry,
|
||||
@@ -45,6 +50,8 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
|
||||
const [connectingWallet, setConnectingWallet] = useState(false)
|
||||
const [walletError, setWalletError] = useState<string | null>(null)
|
||||
const [walletConnectReady, setWalletConnectReady] = useState(false)
|
||||
const [connectingWalletConnect, setConnectingWalletConnect] = useState(false)
|
||||
const [copiedAddress, setCopiedAddress] = useState(false)
|
||||
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
|
||||
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
|
||||
@@ -65,6 +72,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
|
||||
syncSession()
|
||||
syncWatchlist()
|
||||
void loadWalletConnectConfig().then((config) => setWalletConnectReady(isWalletConnectClientReady(config)))
|
||||
window.addEventListener('explorer-access-session-changed', syncSession)
|
||||
window.addEventListener('storage', syncWatchlist)
|
||||
return () => {
|
||||
@@ -73,6 +81,30 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConnectWalletConnect = async () => {
|
||||
setConnectingWalletConnect(true)
|
||||
setWalletError(null)
|
||||
try {
|
||||
const config = await loadWalletConnectConfig()
|
||||
if (!isWalletConnectClientReady(config)) {
|
||||
throw new Error('WalletConnect is not enabled. Set WALLETCONNECT_PROJECT_ID on the explorer backend.')
|
||||
}
|
||||
const result = await connectAndAuthenticateWalletConnect(config, async (resolveAddress, signMessage) => {
|
||||
const session = await accessApi.connectWalletSessionWithSigner(resolveAddress, signMessage)
|
||||
setWalletSession(session)
|
||||
return { address: session.address }
|
||||
})
|
||||
const sessionId = getActiveWalletConnectSessionId()
|
||||
if (sessionId && result.address) {
|
||||
await registerWalletConnectSession({ sessionId, address: result.address, chainId: 138 })
|
||||
}
|
||||
} catch (error) {
|
||||
setWalletError(error instanceof Error ? error.message : 'WalletConnect connection failed.')
|
||||
} finally {
|
||||
setConnectingWalletConnect(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConnectWallet = async () => {
|
||||
setConnectingWallet(true)
|
||||
setWalletError(null)
|
||||
@@ -110,9 +142,41 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
? isWatchlistEntry(watchlistEntries, walletSession.address)
|
||||
: false
|
||||
|
||||
const loadWalletSnapshot = useCallback(async (address: string) => {
|
||||
const [infoResponse, transactionsResponse, balancesResponse, transfersResponse] = await Promise.all([
|
||||
addressesApi.getSafe(138, address),
|
||||
addressesApi.getTransactionsSafe(138, address, 1, 3),
|
||||
addressesApi.getTokenBalancesSafe(address),
|
||||
addressesApi.getTokenTransfersSafe(address, 1, 4),
|
||||
])
|
||||
|
||||
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
|
||||
setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
|
||||
setTokenBalances(
|
||||
balancesResponse.ok
|
||||
? [...balancesResponse.data]
|
||||
.filter((balance) => {
|
||||
try {
|
||||
return BigInt(balance.value || '0') > 0n
|
||||
} catch {
|
||||
return Boolean(balance.value)
|
||||
}
|
||||
})
|
||||
.sort((left, right) => {
|
||||
try {
|
||||
return Number(BigInt(right.value || '0') - BigInt(left.value || '0'))
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
.slice(0, 4)
|
||||
: [],
|
||||
)
|
||||
setTokenTransfers(transfersResponse.ok ? transfersResponse.data : [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
if (!walletSession?.address) {
|
||||
setAddressInfo(null)
|
||||
setRecentAddressTransactions([])
|
||||
@@ -123,50 +187,32 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
addressesApi.getSafe(138, walletSession.address),
|
||||
addressesApi.getTransactionsSafe(138, walletSession.address, 1, 3),
|
||||
addressesApi.getTokenBalancesSafe(walletSession.address),
|
||||
addressesApi.getTokenTransfersSafe(walletSession.address, 1, 4),
|
||||
])
|
||||
.then(([infoResponse, transactionsResponse, balancesResponse, transfersResponse]) => {
|
||||
if (cancelled) return
|
||||
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
|
||||
setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
|
||||
setTokenBalances(
|
||||
balancesResponse.ok
|
||||
? [...balancesResponse.data]
|
||||
.filter((balance) => {
|
||||
try {
|
||||
return BigInt(balance.value || '0') > 0n
|
||||
} catch {
|
||||
return Boolean(balance.value)
|
||||
}
|
||||
})
|
||||
.sort((left, right) => {
|
||||
try {
|
||||
return Number(BigInt(right.value || '0') - BigInt(left.value || '0'))
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
.slice(0, 4)
|
||||
: [],
|
||||
)
|
||||
setTokenTransfers(transfersResponse.ok ? transfersResponse.data : [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setAddressInfo(null)
|
||||
setRecentAddressTransactions([])
|
||||
setTokenBalances([])
|
||||
setTokenTransfers([])
|
||||
})
|
||||
const refreshSnapshot = async () => {
|
||||
try {
|
||||
if (!cancelled) {
|
||||
await loadWalletSnapshot(walletSession.address)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setAddressInfo(null)
|
||||
setRecentAddressTransactions([])
|
||||
setTokenBalances([])
|
||||
setTokenTransfers([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void refreshSnapshot()
|
||||
const stop = createVisibilityAwarePoller({
|
||||
intervalMs: WALLET_SNAPSHOT_REFRESH_MS,
|
||||
task: refreshSnapshot,
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
stop()
|
||||
}
|
||||
}, [walletSession?.address])
|
||||
}, [loadWalletSnapshot, walletSession?.address])
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
@@ -176,6 +222,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
? 'Use the explorer-served network catalog, token list, and capability metadata to connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets.'
|
||||
: 'Use explorer-served network and token metadata to connect Chain 138 and Ethereum Mainnet wallets.'}
|
||||
</p>
|
||||
<WalletConnectPostureNote />
|
||||
<div className="mb-6 rounded-2xl border border-sky-200 bg-sky-50/60 p-5 dark:border-sky-900/40 dark:bg-sky-950/20">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
@@ -261,12 +308,25 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConnectWallet}
|
||||
onClick={() => void handleConnectWallet()}
|
||||
disabled={connectingWallet}
|
||||
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConnectWalletConnect()}
|
||||
disabled={!walletConnectReady || connectingWalletConnect}
|
||||
title={
|
||||
walletConnectReady
|
||||
? 'Pair a mobile wallet via WalletConnect QR'
|
||||
: 'Set WALLETCONNECT_PROJECT_ID on the explorer backend to enable WalletConnect'
|
||||
}
|
||||
className="rounded-lg border border-indigo-300 px-3 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-indigo-800 dark:text-indigo-300 dark:hover:bg-indigo-950/20"
|
||||
>
|
||||
{connectingWalletConnect ? 'Opening WalletConnect…' : 'WalletConnect'}
|
||||
</button>
|
||||
<Link
|
||||
href="/access"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||
@@ -467,7 +527,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
<>
|
||||
Need swap and liquidity discovery too? Visit the{' '}
|
||||
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
|
||||
Liquidity Access
|
||||
Liquidity
|
||||
</Link>{' '}
|
||||
page for live Chain 138 pools, route matrix links, partner payload templates, and the internal fallback execution plan endpoints.
|
||||
</>
|
||||
@@ -476,7 +536,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
<>
|
||||
Liquidity and planner posture lives on the{' '}
|
||||
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
|
||||
Liquidity Access
|
||||
Liquidity
|
||||
</Link>{' '}
|
||||
surface.
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user