Compare commits
34 Commits
devin/1776
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fac5e4856 | ||
|
|
b213c6547d | ||
|
|
567b4647c0 | ||
|
|
8a61b1bde2 | ||
|
|
f2ebe824bd | ||
|
|
991d1bb07c | ||
|
|
847cfeb48b | ||
|
|
6a64d2fec6 | ||
|
|
7a7dfca221 | ||
|
|
e3ec87c324 | ||
|
|
0778c18e59 | ||
|
|
4b747f0309 | ||
|
|
ca1394c579 | ||
|
|
e14b43e3fe | ||
|
|
64e78dad47 | ||
|
|
654933cb36 | ||
|
|
d4f922c26e | ||
| e5df7c2ea3 | |||
|
|
9e17ed8ceb | ||
|
|
55a209646a | ||
|
|
e397245ec9 | ||
|
|
8cd8bfa195 | ||
|
|
3b7e24080f | ||
|
|
ba08199051 | ||
|
|
0ba2a70c34 | ||
|
|
ac40184d6b | ||
|
|
7a16ddccf7 | ||
|
|
1f5167aded | ||
|
|
f5eb874210 | ||
|
|
1aa81f454a | ||
|
|
1b5cebf505 | ||
| fe9edd842b | |||
| fdb14dc420 | |||
| 7c018965eb |
44
.gitea/workflows/deploy-live.yml
Normal file
44
.gitea/workflows/deploy-live.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Deploy Explorer Live
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- '.gitea/workflows/deploy-live.yml'
|
||||
- 'backend/**'
|
||||
- 'config/**'
|
||||
- 'deployment/**'
|
||||
- 'docs/**'
|
||||
- 'frontend/**'
|
||||
- 'scripts/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'Makefile'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate live deploy assets
|
||||
run: |
|
||||
test -f scripts/deploy-explorer-config-to-vmid5000.sh
|
||||
test -f scripts/deploy-explorer-ai-to-vmid5000.sh
|
||||
test -f scripts/deploy-next-frontend-to-vmid5000.sh
|
||||
test -f deployment/LIVE_DEPLOYMENT_MAP.md
|
||||
|
||||
- name: Trigger explorer-live deployment
|
||||
run: |
|
||||
SHA="$(git rev-parse HEAD)"
|
||||
BRANCH="${GITHUB_REF_NAME:-}"
|
||||
if [ -z "$BRANCH" ] || [ "$BRANCH" = "HEAD" ]; then
|
||||
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||
fi
|
||||
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/
|
||||
|
||||
@@ -12,8 +12,8 @@ useDefault = true
|
||||
|
||||
[[rules]]
|
||||
id = "explorer-legacy-db-password-L@ker"
|
||||
description = "Legacy hardcoded Postgres / SSH password (***REDACTED-LEGACY-PW*** / ***REDACTED-LEGACY-PW***)"
|
||||
regex = '''L@kers?\$?2010'''
|
||||
description = "Legacy hardcoded Postgres / SSH password (redacted). Matches both the expanded form and the shell-escaped form (backslash-dollar) that appeared in scripts/setup-database.sh."
|
||||
regex = '''L@kers?\\?\$?2010'''
|
||||
tags = ["password", "explorer-legacy"]
|
||||
|
||||
[allowlist]
|
||||
|
||||
@@ -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": [
|
||||
|
||||
89
backend/api/rest/membership.go
Normal file
89
backend/api/rest/membership.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/explorer/backend/auth"
|
||||
)
|
||||
|
||||
// handleMembershipTiers returns the canonical set of institutional tiers
|
||||
// with their labels and default explorer access tracks.
|
||||
// GET /api/v1/membership/tiers
|
||||
func (s *Server) handleMembershipTiers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
|
||||
tiers := auth.ListTiers()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"tiers": tiers,
|
||||
})
|
||||
}
|
||||
|
||||
// handleMembershipMembers returns all active institutional members.
|
||||
// GET /api/v1/membership/members
|
||||
func (s *Server) handleMembershipMembers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
store := auth.NewMembershipStore(s.db)
|
||||
members, err := store.ListMembers(r.Context())
|
||||
if err != nil {
|
||||
writeInternalError(w, err.Error())
|
||||
return
|
||||
}
|
||||
if members == nil {
|
||||
members = []auth.InstitutionalMember{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"members": members,
|
||||
"total": len(members),
|
||||
})
|
||||
}
|
||||
|
||||
// handleMembershipMemberDetail returns a single member by slug.
|
||||
// GET /api/v1/membership/members/{slug}
|
||||
func (s *Server) handleMembershipMemberDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
slug := strings.TrimPrefix(r.URL.Path, "/api/v1/membership/members/")
|
||||
if slug == "" {
|
||||
writeError(w, http.StatusBadRequest, "MISSING_SLUG", "Member slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
store := auth.NewMembershipStore(s.db)
|
||||
member, err := store.GetMemberBySlug(r.Context(), slug)
|
||||
if err != nil {
|
||||
writeInternalError(w, err.Error())
|
||||
return
|
||||
}
|
||||
if member == nil {
|
||||
writeNotFound(w, "Member")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"member": member,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
@@ -67,6 +69,11 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/access/usage", s.handleAccessUsage)
|
||||
mux.HandleFunc("/api/v1/access/audit", s.handleAccessAudit)
|
||||
|
||||
// Institutional membership directory (public, read-only)
|
||||
mux.HandleFunc("/api/v1/membership/tiers", s.handleMembershipTiers)
|
||||
mux.HandleFunc("/api/v1/membership/members", s.handleMembershipMembers)
|
||||
mux.HandleFunc("/api/v1/membership/members/", s.handleMembershipMemberDetail)
|
||||
|
||||
// Track 1 routes (public, optional auth)
|
||||
// Note: Track 1 endpoints should be registered with OptionalAuth middleware
|
||||
// mux.HandleFunc("/api/v1/track1/blocks/latest", s.track1Server.handleLatestBlocks)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
107
backend/api/rest/walletconnect.go
Normal file
107
backend/api/rest/walletconnect.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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.StatusNotImplemented, 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 "":
|
||||
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",
|
||||
"/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")
|
||||
}
|
||||
}
|
||||
79
backend/api/rest/walletconnect_internal_test.go
Normal file
79
backend/api/rest/walletconnect_internal_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
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 TestHandleWalletConnectConnectStub(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload["status"] != "stub" {
|
||||
t.Fatalf("expected stub status, got %#v", payload["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWalletConnectSessionStub(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.StatusNotImplemented {
|
||||
t.Fatalf("expected 501, got %d", rec.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)
|
||||
}
|
||||
}
|
||||
213
backend/auth/membership.go
Normal file
213
backend/auth/membership.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// InstitutionalTier represents a DBIS institutional membership tier.
|
||||
// These are the canonical tiers from d-bis.org/members#tiers.
|
||||
type InstitutionalTier string
|
||||
|
||||
const (
|
||||
TierSovereignCentralBank InstitutionalTier = "sovereign_central_bank"
|
||||
TierGlobalFamilyOffice InstitutionalTier = "global_family_office"
|
||||
TierSettlementMember InstitutionalTier = "settlement_member"
|
||||
TierInfrastructureOp InstitutionalTier = "infrastructure_operator"
|
||||
TierOversightJudicial InstitutionalTier = "oversight_judicial"
|
||||
TierDelegatedAuthority InstitutionalTier = "delegated_authority"
|
||||
TierStandardsBody InstitutionalTier = "standards_body"
|
||||
)
|
||||
|
||||
// InstitutionalTierLabel returns the human-readable label for a tier.
|
||||
func InstitutionalTierLabel(t InstitutionalTier) string {
|
||||
switch t {
|
||||
case TierSovereignCentralBank:
|
||||
return "Sovereign Central Bank"
|
||||
case TierGlobalFamilyOffice:
|
||||
return "Global Family Office"
|
||||
case TierSettlementMember:
|
||||
return "Settlement Member"
|
||||
case TierInfrastructureOp:
|
||||
return "Infrastructure Operator"
|
||||
case TierOversightJudicial:
|
||||
return "Oversight & Judicial"
|
||||
case TierDelegatedAuthority:
|
||||
return "Delegated Authority"
|
||||
case TierStandardsBody:
|
||||
return "Standards Body"
|
||||
default:
|
||||
return string(t)
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultTrackForTier maps an institutional membership tier to the default
|
||||
// explorer access track. Higher tracks inherit all lower-track permissions.
|
||||
//
|
||||
// Track 1 — Public explorer (read blocks, txs, basic address)
|
||||
// Track 2 — Enhanced explorer (full address, tokens, tx history, search)
|
||||
// Track 3 — Analytics (flows, bridge analytics, risk, distribution)
|
||||
// Track 4 — Operator (bridge control, validators, protocol config)
|
||||
func DefaultTrackForTier(tier InstitutionalTier) int {
|
||||
switch tier {
|
||||
case TierSovereignCentralBank:
|
||||
return 3 // analytics access; operator granted per-address
|
||||
case TierGlobalFamilyOffice:
|
||||
return 3
|
||||
case TierSettlementMember:
|
||||
return 2
|
||||
case TierInfrastructureOp:
|
||||
return 4
|
||||
case TierOversightJudicial:
|
||||
return 3
|
||||
case TierDelegatedAuthority:
|
||||
return 3
|
||||
case TierStandardsBody:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// InstitutionalMember represents an entity in the DBIS member directory.
|
||||
type InstitutionalMember struct {
|
||||
ID int `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Abbreviation string `json:"abbreviation"`
|
||||
Name string `json:"name"`
|
||||
Tier InstitutionalTier `json:"tier"`
|
||||
Description string `json:"description"`
|
||||
Jurisdiction string `json:"jurisdiction,omitempty"`
|
||||
LEI string `json:"lei,omitempty"`
|
||||
Latitude float64 `json:"latitude,omitempty"`
|
||||
Longitude float64 `json:"longitude,omitempty"`
|
||||
MapLabel string `json:"map_label,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// MembershipStore provides read/write access to the institutional members table.
|
||||
type MembershipStore struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewMembershipStore creates a new MembershipStore.
|
||||
func NewMembershipStore(db *pgxpool.Pool) *MembershipStore {
|
||||
return &MembershipStore{db: db}
|
||||
}
|
||||
|
||||
func isMissingMembershipTableError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), `relation "institutional_members" does not exist`)
|
||||
}
|
||||
|
||||
// ListMembers returns all active institutional members.
|
||||
func (s *MembershipStore) ListMembers(ctx context.Context) ([]InstitutionalMember, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, slug, abbreviation, name, tier, description,
|
||||
COALESCE(jurisdiction, ''), COALESCE(lei, ''),
|
||||
COALESCE(latitude, 0), COALESCE(longitude, 0),
|
||||
COALESCE(map_label, ''), active
|
||||
FROM institutional_members
|
||||
WHERE active = TRUE
|
||||
ORDER BY tier, name
|
||||
`)
|
||||
if err != nil {
|
||||
if isMissingMembershipTableError(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("list members: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []InstitutionalMember
|
||||
for rows.Next() {
|
||||
var m InstitutionalMember
|
||||
if err := rows.Scan(
|
||||
&m.ID, &m.Slug, &m.Abbreviation, &m.Name, &m.Tier, &m.Description,
|
||||
&m.Jurisdiction, &m.LEI, &m.Latitude, &m.Longitude, &m.MapLabel, &m.Active,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan member: %w", err)
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
// GetMemberBySlug returns a single member by URL slug.
|
||||
func (s *MembershipStore) GetMemberBySlug(ctx context.Context, slug string) (*InstitutionalMember, error) {
|
||||
var m InstitutionalMember
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, slug, abbreviation, name, tier, description,
|
||||
COALESCE(jurisdiction, ''), COALESCE(lei, ''),
|
||||
COALESCE(latitude, 0), COALESCE(longitude, 0),
|
||||
COALESCE(map_label, ''), active
|
||||
FROM institutional_members
|
||||
WHERE slug = $1
|
||||
`, slug).Scan(
|
||||
&m.ID, &m.Slug, &m.Abbreviation, &m.Name, &m.Tier, &m.Description,
|
||||
&m.Jurisdiction, &m.LEI, &m.Latitude, &m.Longitude, &m.MapLabel, &m.Active,
|
||||
)
|
||||
if err != nil {
|
||||
if isMissingMembershipTableError(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get member by slug: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// GetMemberByAddress looks up the institutional member linked to a wallet
|
||||
// address via the institutional_member_wallets junction table.
|
||||
func (s *MembershipStore) GetMemberByAddress(ctx context.Context, address string) (*InstitutionalMember, error) {
|
||||
var m InstitutionalMember
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT m.id, m.slug, m.abbreviation, m.name, m.tier, m.description,
|
||||
COALESCE(m.jurisdiction, ''), COALESCE(m.lei, ''),
|
||||
COALESCE(m.latitude, 0), COALESCE(m.longitude, 0),
|
||||
COALESCE(m.map_label, ''), m.active
|
||||
FROM institutional_members m
|
||||
JOIN institutional_member_wallets w ON w.member_id = m.id
|
||||
WHERE LOWER(w.address) = LOWER($1) AND w.active = TRUE AND m.active = TRUE
|
||||
`, address).Scan(
|
||||
&m.ID, &m.Slug, &m.Abbreviation, &m.Name, &m.Tier, &m.Description,
|
||||
&m.Jurisdiction, &m.LEI, &m.Latitude, &m.Longitude, &m.MapLabel, &m.Active,
|
||||
)
|
||||
if err != nil {
|
||||
if isMissingMembershipTableError(err) || strings.Contains(err.Error(), "no rows") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get member by address: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// ListTiers returns the canonical set of institutional membership tiers
|
||||
// with their labels and default access tracks.
|
||||
func ListTiers() []struct {
|
||||
Tier InstitutionalTier `json:"tier"`
|
||||
Label string `json:"label"`
|
||||
DefaultTrack int `json:"default_track"`
|
||||
} {
|
||||
tiers := []InstitutionalTier{
|
||||
TierSovereignCentralBank,
|
||||
TierGlobalFamilyOffice,
|
||||
TierSettlementMember,
|
||||
TierInfrastructureOp,
|
||||
TierOversightJudicial,
|
||||
TierDelegatedAuthority,
|
||||
TierStandardsBody,
|
||||
}
|
||||
result := make([]struct {
|
||||
Tier InstitutionalTier `json:"tier"`
|
||||
Label string `json:"label"`
|
||||
DefaultTrack int `json:"default_track"`
|
||||
}, len(tiers))
|
||||
for i, t := range tiers {
|
||||
result[i].Tier = t
|
||||
result[i].Label = InstitutionalTierLabel(t)
|
||||
result[i].DefaultTrack = DefaultTrackForTier(t)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -102,10 +102,18 @@ type WalletAuthRequest struct {
|
||||
|
||||
// WalletAuthResponse represents a wallet authentication response
|
||||
type WalletAuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Track int `json:"track"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Track int `json:"track"`
|
||||
Permissions []string `json:"permissions"`
|
||||
InstitutionalTier *InstitutionalTier `json:"institutional_tier,omitempty"`
|
||||
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
|
||||
@@ -182,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)
|
||||
@@ -223,17 +231,30 @@ func (w *WalletAuth) AuthenticateWallet(ctx context.Context, req *WalletAuthRequ
|
||||
// Get permissions for track
|
||||
permissions := getPermissionsForTrack(track)
|
||||
|
||||
return &WalletAuthResponse{
|
||||
resp := &WalletAuthResponse{
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
Track: track,
|
||||
Permissions: permissions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Attach institutional membership info if present
|
||||
store := NewMembershipStore(w.db)
|
||||
if member, err := store.GetMemberByAddress(ctx, normalizedAddr); err == nil && member != nil {
|
||||
resp.InstitutionalTier = &member.Tier
|
||||
resp.InstitutionName = member.Name
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// getUserTrack gets the track level for a user address
|
||||
// getUserTrack gets the track level for a user address.
|
||||
// Resolution order:
|
||||
// 1. Explicit per-address assignment in operator_roles (highest priority).
|
||||
// 2. Institutional membership via institutional_member_wallets → tier default.
|
||||
// 3. Fallback to Track 1 (public).
|
||||
func (w *WalletAuth) getUserTrack(ctx context.Context, address string) (int, error) {
|
||||
// Check if user exists in operator_roles (Track 4)
|
||||
// 1. Check explicit per-address assignment in operator_roles
|
||||
var track int
|
||||
var approved bool
|
||||
query := `SELECT track_level, approved FROM operator_roles WHERE address = $1`
|
||||
@@ -242,9 +263,20 @@ func (w *WalletAuth) getUserTrack(ctx context.Context, address string) (int, err
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// Check if user is approved for Track 2 or 3
|
||||
// For now, default to Track 1 (public)
|
||||
// In production, you'd have an approval table
|
||||
// 2. Check institutional membership
|
||||
var tier string
|
||||
memberQuery := `
|
||||
SELECT m.tier
|
||||
FROM institutional_members m
|
||||
JOIN institutional_member_wallets w ON w.member_id = m.id
|
||||
WHERE LOWER(w.address) = LOWER($1) AND w.active = TRUE AND m.active = TRUE
|
||||
`
|
||||
err = w.db.QueryRow(ctx, memberQuery, address).Scan(&tier)
|
||||
if err == nil {
|
||||
return DefaultTrackForTier(InstitutionalTier(tier)), nil
|
||||
}
|
||||
|
||||
// 3. Default to Track 1 (public)
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 0017_institutional_membership.down.sql
|
||||
DROP TRIGGER IF EXISTS update_institutional_members_updated_at ON institutional_members;
|
||||
DROP TABLE IF EXISTS institutional_member_wallets;
|
||||
DROP TABLE IF EXISTS institutional_members;
|
||||
DROP TYPE IF EXISTS institutional_tier;
|
||||
178
backend/database/migrations/0017_institutional_membership.up.sql
Normal file
178
backend/database/migrations/0017_institutional_membership.up.sql
Normal file
@@ -0,0 +1,178 @@
|
||||
-- 0017_institutional_membership.up.sql
|
||||
--
|
||||
-- Adds institutional membership tables and seeds the canonical DBIS member
|
||||
-- directory. The tier taxonomy comes from https://d-bis.org/members#tiers
|
||||
-- with corrections per institutional review (2026-04).
|
||||
|
||||
-- Tier enum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE institutional_tier AS ENUM (
|
||||
'sovereign_central_bank',
|
||||
'global_family_office',
|
||||
'settlement_member',
|
||||
'infrastructure_operator',
|
||||
'oversight_judicial',
|
||||
'delegated_authority',
|
||||
'standards_body'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- Members directory
|
||||
CREATE TABLE IF NOT EXISTS institutional_members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug VARCHAR(64) NOT NULL UNIQUE,
|
||||
abbreviation VARCHAR(16) NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
tier institutional_tier NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
jurisdiction TEXT,
|
||||
lei VARCHAR(20),
|
||||
latitude DOUBLE PRECISION,
|
||||
longitude DOUBLE PRECISION,
|
||||
map_label TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_institutional_members_tier ON institutional_members(tier);
|
||||
CREATE INDEX IF NOT EXISTS idx_institutional_members_active ON institutional_members(active);
|
||||
|
||||
-- Junction: wallet addresses linked to institutional members
|
||||
CREATE TABLE IF NOT EXISTS institutional_member_wallets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
member_id INTEGER NOT NULL REFERENCES institutional_members(id) ON DELETE CASCADE,
|
||||
address VARCHAR(42) NOT NULL,
|
||||
label TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(member_id, address)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_imw_address ON institutional_member_wallets(address);
|
||||
CREATE INDEX IF NOT EXISTS idx_imw_member_id ON institutional_member_wallets(member_id);
|
||||
|
||||
-- Triggers
|
||||
CREATE TRIGGER update_institutional_members_updated_at
|
||||
BEFORE UPDATE ON institutional_members
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- ============================================================
|
||||
-- Seed data — canonical member directory
|
||||
-- ============================================================
|
||||
-- Corrections from 2026-04 review:
|
||||
-- • MLFO is a Global Family Office, NOT a central bank
|
||||
-- • BIS Innovation Hub removed from directory entirely
|
||||
-- • Added: ICCC, SAID, PANDA, Order of Hospitallers (XOM)
|
||||
-- • Placeholder rows for BRICS founding central banks
|
||||
|
||||
INSERT INTO institutional_members
|
||||
(slug, abbreviation, name, tier, description, jurisdiction, lei, latitude, longitude, map_label)
|
||||
VALUES
|
||||
-- Existing (corrected)
|
||||
('omnl', 'OMNL', 'Organisation Mondiale du Numérique',
|
||||
'sovereign_central_bank',
|
||||
'Participating central bank — OMNL Head Office ledger and journal operator (Fineract / OMNL tenant). ARIN OrgId: OMNL.',
|
||||
'International / participating monetary union', '98450070C57395F6B906',
|
||||
39.61, -104.89, 'Greenwood Village, CO'),
|
||||
|
||||
('mlfo', 'MLFO', 'Mann Li Family Office',
|
||||
'global_family_office',
|
||||
'Founding family office (L.P.B.C., Colorado Entity 20241969162). Capital structure sponsor and BIS debit performance beneficiary. Registered agent: Pandora C. Walker.',
|
||||
'US-CO (Colorado)', NULL,
|
||||
40.02, -105.27, 'Boulder, CO'),
|
||||
|
||||
('defi-oracle', 'DFO', 'DeFi Oracle',
|
||||
'infrastructure_operator',
|
||||
'Infrastructure operator for Chain 138 ecosystem. Manages smart contract deployment (131 contracts), cross-chain bridges, PMM pools, and wallet integrations (MetaMask Snap, Ledger Live).',
|
||||
'US-CO (Colorado)', NULL,
|
||||
39.61, -104.89, 'Greenwood Village, CO'),
|
||||
|
||||
-- Added entities
|
||||
('iccc', 'ICCC', 'International Criminal Court of Commerce',
|
||||
'oversight_judicial',
|
||||
'International court with oversight authority over DBIS ecosystem commercial disputes and enforcement.',
|
||||
'International', NULL,
|
||||
NULL, NULL, NULL),
|
||||
|
||||
('said', 'SAID', 'SAID',
|
||||
'standards_body',
|
||||
'Standards and identity body within the DBIS institutional framework.',
|
||||
'International', NULL,
|
||||
NULL, NULL, NULL),
|
||||
|
||||
('panda', 'PANDA', 'PANDA',
|
||||
'standards_body',
|
||||
'Standards and coordination body within the DBIS institutional framework.',
|
||||
'International', NULL,
|
||||
NULL, NULL, NULL),
|
||||
|
||||
('xom', 'XOM', 'Sovereign Military Hospitaller Order of St. John of Jerusalem of Rhodes and of Malta',
|
||||
'delegated_authority',
|
||||
'The sovereign entity (Order of Hospitallers) extending DBIS the agency authority under which it operates. Recognised UN observer state.',
|
||||
'International (Rome)', NULL,
|
||||
41.90, 12.48, 'Rome, Italy'),
|
||||
|
||||
-- BRICS founding member central banks (representative set)
|
||||
('cb-brazil', 'BCB', 'Banco Central do Brasil',
|
||||
'sovereign_central_bank',
|
||||
'Central Bank of Brazil — BRICS founding member.',
|
||||
'Brazil', NULL,
|
||||
-15.79, -47.88, 'Brasília, Brazil'),
|
||||
|
||||
('cb-russia', 'CBR', 'Central Bank of the Russian Federation',
|
||||
'sovereign_central_bank',
|
||||
'Bank of Russia — BRICS founding member.',
|
||||
'Russia', NULL,
|
||||
55.76, 37.62, 'Moscow, Russia'),
|
||||
|
||||
('cb-india', 'RBI', 'Reserve Bank of India',
|
||||
'sovereign_central_bank',
|
||||
'Reserve Bank of India — BRICS founding member.',
|
||||
'India', NULL,
|
||||
18.93, 72.83, 'Mumbai, India'),
|
||||
|
||||
('cb-china', 'PBOC', 'People''s Bank of China',
|
||||
'sovereign_central_bank',
|
||||
'People''s Bank of China — BRICS founding member.',
|
||||
'China', NULL,
|
||||
39.91, 116.39, 'Beijing, China'),
|
||||
|
||||
('cb-south-africa', 'SARB', 'South African Reserve Bank',
|
||||
'sovereign_central_bank',
|
||||
'South African Reserve Bank — BRICS founding member.',
|
||||
'South Africa', NULL,
|
||||
-25.75, 28.19, 'Pretoria, South Africa'),
|
||||
|
||||
-- BRICS expanded members (2024+)
|
||||
('cb-egypt', 'CBE', 'Central Bank of Egypt',
|
||||
'sovereign_central_bank',
|
||||
'Central Bank of Egypt — BRICS member (2024).',
|
||||
'Egypt', NULL,
|
||||
30.04, 31.24, 'Cairo, Egypt'),
|
||||
|
||||
('cb-ethiopia', 'NBE', 'National Bank of Ethiopia',
|
||||
'sovereign_central_bank',
|
||||
'National Bank of Ethiopia — BRICS member (2024).',
|
||||
'Ethiopia', NULL,
|
||||
9.02, 38.75, 'Addis Ababa, Ethiopia'),
|
||||
|
||||
('cb-iran', 'CBI', 'Central Bank of the Islamic Republic of Iran',
|
||||
'sovereign_central_bank',
|
||||
'Central Bank of Iran — BRICS member (2024).',
|
||||
'Iran', NULL,
|
||||
35.70, 51.42, 'Tehran, Iran'),
|
||||
|
||||
('cb-uae', 'CBUAE', 'Central Bank of the UAE',
|
||||
'sovereign_central_bank',
|
||||
'Central Bank of the UAE — BRICS member (2024).',
|
||||
'United Arab Emirates', NULL,
|
||||
24.45, 54.65, 'Abu Dhabi, UAE'),
|
||||
|
||||
('cb-saudi-arabia', 'SAMA', 'Saudi Central Bank',
|
||||
'sovereign_central_bank',
|
||||
'Saudi Central Bank (formerly SAMA) — BRICS member (2024).',
|
||||
'Saudi Arabia', NULL,
|
||||
24.71, 46.68, 'Riyadh, Saudi Arabia')
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
@@ -118,3 +118,19 @@ func GetAllFeatures() map[string]FeatureFlag {
|
||||
return FeatureFlags
|
||||
}
|
||||
|
||||
// TrackLabel returns a human-readable label for an access track number.
|
||||
func TrackLabel(track int) string {
|
||||
switch track {
|
||||
case 1:
|
||||
return "Explorer"
|
||||
case 2:
|
||||
return "Enhanced Explorer"
|
||||
case 3:
|
||||
return "Analytics"
|
||||
case 4:
|
||||
return "Operator"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,35 +3,122 @@ package wallet
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WalletConnect handles WalletConnect v2 integration
|
||||
const (
|
||||
WalletConnectStatusStub = "stub"
|
||||
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 := WalletConnectStatusStub
|
||||
if !wc.enabled() {
|
||||
status = WalletConnectStatusDisabled
|
||||
}
|
||||
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 config is published, but session bridging is still stubbed. Use browser wallet auth at /api/v1/auth/wallet until mobile QR sessions ship."
|
||||
}
|
||||
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 initiates a wallet connection. Live QR sessions are not implemented yet.
|
||||
func (wc *WalletConnect) Connect(_ context.Context) (*ConnectResponse, error) {
|
||||
return &ConnectResponse{
|
||||
Status: WalletConnectStatusStub,
|
||||
Enabled: wc.enabled(),
|
||||
FallbackAuth: "/api/v1/auth/wallet",
|
||||
Message: "WalletConnect session creation is stubbed. Use browser extension wallet auth until the relay bridge is enabled.",
|
||||
}, fmt.Errorf("walletconnect session bridge not implemented")
|
||||
}
|
||||
|
||||
// GetSession gets a wallet session snapshot. Storage is not implemented yet.
|
||||
func (wc *WalletConnect) GetSession(_ context.Context, sessionID string) (*Session, error) {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return nil, fmt.Errorf("session id is required")
|
||||
}
|
||||
return &Session{
|
||||
SessionID: sessionID,
|
||||
Connected: false,
|
||||
Status: WalletConnectStatusStub,
|
||||
Message: "WalletConnect session lookup is stubbed.",
|
||||
}, fmt.Errorf("walletconnect session storage not implemented")
|
||||
}
|
||||
|
||||
@@ -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?"
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ That file reflects the live split deployment now in production:
|
||||
- Frontend deploy: [`scripts/deploy-next-frontend-to-vmid5000.sh`](../scripts/deploy-next-frontend-to-vmid5000.sh)
|
||||
- Config deploy: [`scripts/deploy-explorer-config-to-vmid5000.sh`](../scripts/deploy-explorer-config-to-vmid5000.sh)
|
||||
- Explorer config/API deploy: [`scripts/deploy-explorer-ai-to-vmid5000.sh`](../scripts/deploy-explorer-ai-to-vmid5000.sh)
|
||||
- Gitea live redeploy action: [`.gitea/workflows/deploy-live.yml`](../.gitea/workflows/deploy-live.yml), target `explorer-live`
|
||||
- RPC/API-key edge enforcement: [`ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md`](./ACCESS_EDGE_ENFORCEMENT_RUNBOOK.md)
|
||||
- Public health audit: [`scripts/check-explorer-health.sh`](../scripts/check-explorer-health.sh)
|
||||
- Full public smoke: [`check-explorer-e2e.sh`](../../scripts/verify/check-explorer-e2e.sh)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -63,6 +63,58 @@ initial public review.
|
||||
- Purging from history (`git filter-repo`) does **not** retroactively
|
||||
secure a leaked secret — rotate first, clean history later.
|
||||
|
||||
## History-purge audit trail
|
||||
|
||||
Following the rotation checklist above, the legacy `L@ker$2010` /
|
||||
`L@kers2010` / `L@ker\$2010` password strings were purged from every
|
||||
branch and tag in this repository using `git filter-repo
|
||||
--replace-text` followed by a `--replace-message` pass for commit
|
||||
message text. The rewritten history was force-pushed with
|
||||
`git push --mirror --force`.
|
||||
|
||||
Verification post-rewrite:
|
||||
|
||||
```
|
||||
git log --all -p | grep -cE 'L@ker\$2010|L@kers2010|L@ker\\\$2010'
|
||||
0
|
||||
gitleaks detect --no-git --source . --config .gitleaks.toml
|
||||
0 legacy-password findings
|
||||
```
|
||||
|
||||
### Residual server-side state (not purgable from the client)
|
||||
|
||||
Gitea's `refs/pull/*/head` refs (the read-only mirror of each PR's
|
||||
original head commit) **cannot be force-updated over HTTPS** — the
|
||||
server's `update` hook declines them. After a history rewrite the
|
||||
following cleanup must be performed **on the Gitea host** by an
|
||||
administrator:
|
||||
|
||||
1. Run `gitea admin repo-sync-release-archive` and
|
||||
`gitea doctor --run all --fix` if available.
|
||||
2. Or manually, as the gitea user on the server:
|
||||
```bash
|
||||
cd /var/lib/gitea/data/gitea-repositories/d-bis/explorer-monorepo.git
|
||||
git for-each-ref --format='%(refname)' 'refs/pull/*/head' | \
|
||||
xargs -n1 git update-ref -d
|
||||
git gc --prune=now --aggressive
|
||||
```
|
||||
3. Restart Gitea.
|
||||
|
||||
Until this server-side cleanup is performed, the 13 `refs/pull/*/head`
|
||||
refs still pin the pre-rewrite commits containing the legacy
|
||||
password. This does not affect branches, the default clone, or
|
||||
`master` — but the old commits remain reachable by SHA through the
|
||||
Gitea web UI (e.g. on the merged PR's **Files Changed** tab).
|
||||
|
||||
### Re-introduction guard
|
||||
|
||||
The `.gitleaks.toml` rule `explorer-legacy-db-password-L@ker` was
|
||||
tightened from `L@kers?\$?2010` to `L@kers?\\?\$?2010` so it also
|
||||
catches the shell-escaped form that slipped past the original PR #3
|
||||
scrub (see commit `78e1ff5`). Future attempts to paste any variant of
|
||||
the legacy password — in source, shell scripts, or env files — will
|
||||
fail the `gitleaks` CI job wired in PR #5.
|
||||
|
||||
## Build-time / CI checks (wired in PR #5)
|
||||
|
||||
- `gitleaks` pre-commit + CI gate on every PR.
|
||||
|
||||
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',
|
||||
|
||||
16155
frontend/package-lock.json
generated
16155
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,20 +14,20 @@
|
||||
"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",
|
||||
"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 +35,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>
|
||||
|
||||
@@ -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,7 +4,7 @@ const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/
|
||||
const addressUnderTest = process.env.SMOKE_ADDRESS || '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506'
|
||||
|
||||
const checks = [
|
||||
{ path: '/', expectTexts: ['SolaceScan', 'Recent Blocks', 'Open wallet tools'] },
|
||||
{ path: '/', expectTexts: ['DBIS Explorer', 'Recent Blocks', 'Open wallet tools'] },
|
||||
{ path: '/blocks', expectTexts: ['Blocks'] },
|
||||
{ path: '/transactions', expectTexts: ['Transactions'] },
|
||||
{ path: '/addresses', expectTexts: ['Addresses', 'Open An Address'] },
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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]',
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link'
|
||||
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'
|
||||
@@ -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,48 @@ 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>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{explorerPublicApiLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { accessApi, type WalletAccessSession } from '@/services/api/access'
|
||||
import { accessApi, institutionalTierLabels, type WalletAccessSession } from '@/services/api/access'
|
||||
import BrandLockup from './BrandLockup'
|
||||
import HeaderCommandPalette, { type HeaderCommandItem } from './HeaderCommandPalette'
|
||||
import { useUiMode } from './UiModeContext'
|
||||
@@ -310,6 +310,9 @@ function SearchControl({
|
||||
}
|
||||
|
||||
function getAccessTier(walletSession: WalletAccessSession) {
|
||||
if (walletSession.institutionalTier) {
|
||||
return institutionalTierLabels[walletSession.institutionalTier] ?? walletSession.institutionalTier
|
||||
}
|
||||
const permissions = walletSession.permissions || []
|
||||
if (permissions.some((permission) => permission.startsWith('operator.'))) {
|
||||
return 'Operator Tier'
|
||||
@@ -326,10 +329,11 @@ function getAccessTier(walletSession: WalletAccessSession) {
|
||||
function getSessionSummary(walletSession: WalletAccessSession) {
|
||||
const permissionCount = walletSession.permissions?.length || 0
|
||||
const tierLabel = getAccessTier(walletSession)
|
||||
const institutionSuffix = walletSession.institutionName ? ` (${walletSession.institutionName})` : ''
|
||||
if (permissionCount > 0) {
|
||||
return `${tierLabel} · ${permissionCount} permission${permissionCount === 1 ? '' : 's'}`
|
||||
return `${tierLabel}${institutionSuffix} · ${permissionCount} permission${permissionCount === 1 ? '' : 's'}`
|
||||
}
|
||||
return `${tierLabel} · Explorer access active`
|
||||
return `${tierLabel}${institutionSuffix} · Explorer access active`
|
||||
}
|
||||
|
||||
function UiModeToggle({ mobile = false }: { mobile?: boolean }) {
|
||||
@@ -360,6 +364,7 @@ function UiModeToggle({ mobile = false }: { mobile?: boolean }) {
|
||||
function AccountButton({
|
||||
walletSession,
|
||||
connectingWallet,
|
||||
connectError,
|
||||
onConnect,
|
||||
onCopyAddress,
|
||||
onSwitchWallet,
|
||||
@@ -367,6 +372,7 @@ function AccountButton({
|
||||
}: {
|
||||
walletSession: WalletAccessSession | null
|
||||
connectingWallet: boolean
|
||||
connectError?: string | null
|
||||
onConnect: () => void
|
||||
onCopyAddress: () => void
|
||||
onSwitchWallet: () => void
|
||||
@@ -381,7 +387,7 @@ function AccountButton({
|
||||
},
|
||||
{
|
||||
href: '/wallet',
|
||||
label: 'Settings',
|
||||
label: 'Wallet tools',
|
||||
description: 'Review network, token-list, and wallet configuration guidance.',
|
||||
},
|
||||
{
|
||||
@@ -403,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -465,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 =
|
||||
@@ -524,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)
|
||||
}
|
||||
@@ -584,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 },
|
||||
],
|
||||
[],
|
||||
@@ -699,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 min-w-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"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label="Go to SolaceScan home"
|
||||
aria-label="Go to DBIS Explorer home"
|
||||
>
|
||||
<BrandLockup />
|
||||
</Link>
|
||||
@@ -723,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()}
|
||||
@@ -736,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"
|
||||
@@ -789,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
|
||||
@@ -802,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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,11 @@ import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
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 OperationsSurfaceNav from './OperationsSurfaceNav'
|
||||
import OperationsActionGrid from './OperationsActionGrid'
|
||||
|
||||
type FeedState = 'connecting' | 'live' | 'fallback'
|
||||
|
||||
@@ -146,6 +151,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 +202,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 +329,8 @@ export default function BridgeMonitoringPage({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<OperationsSurfaceNav />
|
||||
|
||||
<div className="mb-6">
|
||||
<ActivityContextPanel context={activityContext} title="Bridge Freshness Context" />
|
||||
<FreshnessTrustNote
|
||||
@@ -407,27 +458,46 @@ 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>
|
||||
{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}
|
||||
.
|
||||
</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,8 @@
|
||||
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 {
|
||||
aggregateLiquidityPools,
|
||||
featuredLiquiditySymbols,
|
||||
@@ -20,7 +21,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,
|
||||
@@ -97,7 +101,7 @@ export default function LiquidityOperationsPage({
|
||||
const load = async () => {
|
||||
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(),
|
||||
@@ -197,6 +201,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 +242,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) => {
|
||||
@@ -261,8 +276,11 @@ 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>
|
||||
@@ -318,6 +336,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 +378,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,9 @@ 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 { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||
|
||||
@@ -88,7 +92,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(),
|
||||
])
|
||||
@@ -179,6 +183,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 +194,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>
|
||||
@@ -335,27 +342,7 @@ 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>
|
||||
<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,17 @@ 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 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 +102,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 +128,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 +165,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 +206,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 +302,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'
|
||||
@@ -575,7 +667,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 +773,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 +796,109 @@ 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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatsDetailsExpanded((current) => !current)}
|
||||
className="text-sm font-semibold text-primary-600 hover:underline"
|
||||
>
|
||||
{statsDetailsExpanded ? 'Hide telemetry and freshness' : 'Show telemetry and freshness'}
|
||||
</button>
|
||||
|
||||
{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">
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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.'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : 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 +908,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 +985,38 @@ 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="/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}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type {
|
||||
CapabilitiesCatalog,
|
||||
FetchMetadata,
|
||||
@@ -17,6 +17,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,
|
||||
@@ -110,9 +112,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 +157,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">
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface ExplorerFeaturePage {
|
||||
title: string
|
||||
description: string
|
||||
note?: string
|
||||
accessTrack?: number
|
||||
accessNote?: string
|
||||
actions: ExplorerFeatureAction[]
|
||||
}
|
||||
|
||||
@@ -141,6 +143,9 @@ export const explorerFeaturePages = {
|
||||
description:
|
||||
'Use the public explorer pages and live monitoring endpoints as the visible analytics surface for chain activity, recent blocks, and transaction flow.',
|
||||
note: sharedOperationsNote,
|
||||
accessTrack: 3,
|
||||
accessNote:
|
||||
'This page is the public Track 3 analytics surface. Wallet-authenticated Track 3 APIs remain available after browser wallet sign-in.',
|
||||
actions: [
|
||||
{
|
||||
title: 'Blocks',
|
||||
@@ -175,6 +180,9 @@ export const explorerFeaturePages = {
|
||||
description:
|
||||
'Expose the public operator surface for bridge checks, route validation, planner providers, liquidity entry points, and documentation.',
|
||||
note: sharedOperationsNote,
|
||||
accessTrack: 4,
|
||||
accessNote:
|
||||
'This page is the public Track 4 operator surface. Sensitive operator write APIs remain gated behind wallet auth and operator policy.',
|
||||
actions: [
|
||||
{
|
||||
title: 'Bridge monitoring',
|
||||
@@ -296,3 +304,85 @@ export const explorerFeaturePages = {
|
||||
],
|
||||
},
|
||||
} as const satisfies Record<string, ExplorerFeaturePage>
|
||||
|
||||
export interface ExplorerOperationsSurface {
|
||||
href: string
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const explorerOperationsSurfaces: ExplorerOperationsSurface[] = [
|
||||
{
|
||||
href: '/operations',
|
||||
label: 'Operations hub',
|
||||
description: 'Consolidated monitoring, config, and route inventory.',
|
||||
},
|
||||
{
|
||||
href: '/bridge',
|
||||
label: 'Bridge',
|
||||
description: 'Relay lanes, mission-control feed, and CCIP routes.',
|
||||
},
|
||||
{
|
||||
href: '/routes',
|
||||
label: 'Routes',
|
||||
description: 'Live route matrix and execution paths.',
|
||||
},
|
||||
{
|
||||
href: '/liquidity',
|
||||
label: 'Liquidity',
|
||||
description: 'PMM access points and planner capabilities.',
|
||||
},
|
||||
{
|
||||
href: '/weth',
|
||||
label: 'WETH',
|
||||
description: 'Wrapped-asset references and bridge context.',
|
||||
},
|
||||
{
|
||||
href: '/pools',
|
||||
label: 'Pools',
|
||||
description: 'Mission-control pool inventory snapshot.',
|
||||
},
|
||||
{
|
||||
href: '/system',
|
||||
label: 'System',
|
||||
description: 'Networks, RPC methods, and topology inventory.',
|
||||
},
|
||||
{
|
||||
href: '/analytics',
|
||||
label: 'Analytics',
|
||||
description: 'Track 3 activity summaries, trends, and freshness context.',
|
||||
},
|
||||
{
|
||||
href: '/operator',
|
||||
label: 'Operator',
|
||||
description: 'Track 4 relay, route, and planner shortcuts.',
|
||||
},
|
||||
]
|
||||
|
||||
export const explorerPublicApiLinks = [
|
||||
{
|
||||
href: '/api/v2/stats',
|
||||
label: 'Blockscout stats',
|
||||
description: 'Chain head, gas, and indexer summary.',
|
||||
},
|
||||
{
|
||||
href: '/explorer-api/v1/track1/bridge/status',
|
||||
label: 'Bridge status JSON',
|
||||
description: 'Mission-control relay posture snapshot.',
|
||||
},
|
||||
{
|
||||
href: '/token-aggregation/api/v1/routes/matrix?includeNonLive=true',
|
||||
label: 'Route matrix',
|
||||
description: 'Token-aggregation live and planned routes.',
|
||||
},
|
||||
{
|
||||
href: '/api/config/networks',
|
||||
label: 'Wallet networks',
|
||||
description: 'Published chain metadata for wallet onboarding.',
|
||||
},
|
||||
{
|
||||
href: '/explorer-api/v1/walletconnect/config',
|
||||
label: 'WalletConnect config',
|
||||
description: 'Published WalletConnect v2 posture and browser-auth fallback.',
|
||||
},
|
||||
] as const
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
@@ -28,14 +28,31 @@ import {
|
||||
normalizeWatchlistAddress,
|
||||
} from '@/utils/watchlist'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
|
||||
import GruStandardsCard from '@/components/common/GruStandardsCard'
|
||||
import ContractCodeWorkspace from '@/components/explorer/ContractCodeWorkspace'
|
||||
import ContractVerificationCallout from '@/components/explorer/ContractVerificationCallout'
|
||||
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
|
||||
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
|
||||
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
|
||||
import { estimateNativeUsdValue, getNativeAssetDescriptor, getNativeAssetMarketSafe } from '@/services/api/nativeAssetPricing'
|
||||
|
||||
function isValidAddress(value: string) {
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(value)
|
||||
}
|
||||
|
||||
function formatUsd(value: string | number | undefined): string {
|
||||
if (value == null) return 'Unavailable'
|
||||
const numeric = typeof value === 'number' ? value : Number(value)
|
||||
if (!Number.isFinite(numeric)) return 'Unavailable'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: numeric >= 100 ? 0 : 2,
|
||||
}).format(numeric)
|
||||
}
|
||||
|
||||
export default function AddressDetailPage() {
|
||||
const router = useRouter()
|
||||
const address = typeof router.query.address === 'string' ? router.query.address : ''
|
||||
@@ -51,7 +68,14 @@ export default function AddressDetailPage() {
|
||||
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
|
||||
const [methodResults, setMethodResults] = useState<Record<string, { loading: boolean; value?: string; error?: string }>>({})
|
||||
const [methodInputs, setMethodInputs] = useState<Record<string, string[]>>({})
|
||||
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
|
||||
const [nativeAssetPriceUsd, setNativeAssetPriceUsd] = useState<number | undefined>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'contract' | 'balances' | 'transfers' | 'transactions'>('balances')
|
||||
const [balancePage, setBalancePage] = useState(1)
|
||||
const [transferPage, setTransferPage] = useState(1)
|
||||
const [transactionPage, setTransactionPage] = useState(1)
|
||||
const pageSize = 8
|
||||
|
||||
const loadAddressInfo = useCallback(async () => {
|
||||
try {
|
||||
@@ -137,6 +161,46 @@ export default function AddressDetailPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const tokenAddresses = [
|
||||
...(addressInfo?.token_contract?.address ? [addressInfo.token_contract.address] : []),
|
||||
...tokenBalances.map((balance) => balance.token_address),
|
||||
...tokenTransfers.map((transfer) => transfer.token_address),
|
||||
].filter((candidate, index, values): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 && values.indexOf(candidate) === index)
|
||||
|
||||
tokenAggregationApi.getTokensByAddressSafe(chainId, tokenAddresses).then(({ data }) => {
|
||||
if (!active) return
|
||||
setTokenMarkets(Object.fromEntries(data.map((snapshot) => [snapshot.address.toLowerCase(), snapshot])))
|
||||
}).catch(() => {
|
||||
if (active) {
|
||||
setTokenMarkets({})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [addressInfo?.token_contract?.address, chainId, tokenBalances, tokenTransfers])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
getNativeAssetMarketSafe(chainId).then(({ data }) => {
|
||||
if (!active) return
|
||||
setNativeAssetPriceUsd(data?.market?.priceUsd)
|
||||
}).catch(() => {
|
||||
if (active) {
|
||||
setNativeAssetPriceUsd(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [chainId])
|
||||
|
||||
const watchlistAddress = normalizeWatchlistAddress(addressInfo?.address || address)
|
||||
const isSavedToWatchlist = watchlistAddress
|
||||
? isWatchlistEntry(watchlistEntries, watchlistAddress)
|
||||
@@ -272,7 +336,17 @@ export default function AddressDetailPage() {
|
||||
},
|
||||
{
|
||||
header: 'Value',
|
||||
accessor: (tx: TransactionSummary) => formatWeiAsEth(tx.value),
|
||||
accessor: (tx: TransactionSummary) => {
|
||||
const nativeValueUsd = estimateNativeUsdValue(tx.value, nativeAssetPriceUsd)
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>{formatWeiAsEth(tx.value)}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{nativeValueUsd != null ? `Current USD: ${formatUsd(nativeValueUsd)}` : 'Current USD unavailable'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
@@ -327,6 +401,20 @@ export default function AddressDetailPage() {
|
||||
: 'N/A'
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Current Price',
|
||||
accessor: (balance: AddressTokenBalance) => {
|
||||
const market = tokenMarkets[balance.token_address.toLowerCase()]?.market
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>{formatUsd(market?.priceUsd)}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Liq. {formatUsd(market?.liquidityUsd)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const tokenTransferColumns = [
|
||||
@@ -344,7 +432,7 @@ export default function AddressDetailPage() {
|
||||
{gruMetadata ? <EntityBadge label="GRU" tone="success" /> : null}
|
||||
{gruMetadata?.x402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
|
||||
{gruMetadata?.iso20022Ready ? <EntityBadge label="ISO-20022" tone="info" /> : null}
|
||||
{gruMetadata?.transportActiveVersion ? <EntityBadge label={`transport ${gruMetadata.transportActiveVersion}`} tone="warning" /> : null}
|
||||
{gruMetadata?.transportActiveVersion ? <EntityBadge label={`cW public-network ${gruMetadata.transportActiveVersion}`} tone="warning" /> : null}
|
||||
</div>
|
||||
{transfer.token_address && (
|
||||
<Link href={`/tokens/${transfer.token_address}`} className="text-primary-600 hover:underline">
|
||||
@@ -383,6 +471,20 @@ export default function AddressDetailPage() {
|
||||
header: 'When',
|
||||
accessor: (transfer: AddressTokenTransfer) => formatTimestamp(transfer.timestamp),
|
||||
},
|
||||
{
|
||||
header: 'Current Price',
|
||||
accessor: (transfer: AddressTokenTransfer) => {
|
||||
const market = tokenMarkets[transfer.token_address.toLowerCase()]?.market
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>{formatUsd(market?.priceUsd)}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Liq. {formatUsd(market?.liquidityUsd)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const incomingTransactions = transactions.filter(
|
||||
@@ -403,6 +505,36 @@ export default function AddressDetailPage() {
|
||||
const gruTransferCount = tokenTransfers.filter((transfer) =>
|
||||
Boolean(getGruExplorerMetadata({ address: transfer.token_address, symbol: transfer.token_symbol })),
|
||||
).length
|
||||
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
|
||||
const nativeBalanceUsd = estimateNativeUsdValue(addressInfo?.balance, nativeAssetPriceUsd)
|
||||
const tabs: SectionTab<typeof activeTab>[] = [
|
||||
...(addressInfo?.is_contract ? [{ id: 'contract' as const, label: 'Contract' }] : []),
|
||||
{ id: 'balances', label: 'Balances', count: tokenBalances.length },
|
||||
{ id: 'transfers', label: 'Transfers', count: tokenTransfers.length },
|
||||
{ id: 'transactions', label: 'Transactions', count: transactions.length },
|
||||
]
|
||||
const balancePageCount = Math.max(1, Math.ceil(tokenBalances.length / pageSize))
|
||||
const transferPageCount = Math.max(1, Math.ceil(tokenTransfers.length / pageSize))
|
||||
const transactionPageCount = Math.max(1, Math.ceil(transactions.length / pageSize))
|
||||
const pagedTokenBalances = useMemo(
|
||||
() => tokenBalances.slice((balancePage - 1) * pageSize, balancePage * pageSize),
|
||||
[balancePage, tokenBalances],
|
||||
)
|
||||
const pagedTokenTransfers = useMemo(
|
||||
() => tokenTransfers.slice((transferPage - 1) * pageSize, transferPage * pageSize),
|
||||
[transferPage, tokenTransfers],
|
||||
)
|
||||
const pagedTransactions = useMemo(
|
||||
() => transactions.slice((transactionPage - 1) * pageSize, transactionPage * pageSize),
|
||||
[transactionPage, transactions],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setBalancePage(1)
|
||||
setTransferPage(1)
|
||||
setTransactionPage(1)
|
||||
setActiveTab(addressInfo?.is_contract ? 'contract' : 'balances')
|
||||
}, [address, addressInfo?.is_contract])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
@@ -473,8 +605,14 @@ export default function AddressDetailPage() {
|
||||
<Address address={addressInfo.address} />
|
||||
</DetailRow>
|
||||
{addressInfo.balance && (
|
||||
<DetailRow label="Coin Balance">{formatWeiAsEth(addressInfo.balance)}</DetailRow>
|
||||
<DetailRow label="Coin Balance">
|
||||
{formatWeiAsEth(addressInfo.balance)}
|
||||
{nativeBalanceUsd != null ? ` (${formatUsd(nativeBalanceUsd)})` : ''}
|
||||
</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Current Native Asset Price">
|
||||
{nativeAssetPriceUsd != null ? `${formatUsd(nativeAssetPriceUsd)} per ${nativeAssetSymbol}` : 'Unavailable'}
|
||||
</DetailRow>
|
||||
<DetailRow label="Watchlist">
|
||||
{isSavedToWatchlist ? 'Saved for quick access' : 'Not saved yet'}
|
||||
</DetailRow>
|
||||
@@ -531,7 +669,13 @@ export default function AddressDetailPage() {
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{addressInfo.is_contract && (
|
||||
{addressInfo.is_contract ? (
|
||||
<ContractVerificationCallout address={addressInfo.address} verified={Boolean(addressInfo.is_verified)} />
|
||||
) : null}
|
||||
|
||||
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
|
||||
|
||||
{activeTab === 'contract' && addressInfo.is_contract && (
|
||||
<Card title="Contract Profile" className="mb-6">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Interaction Surface">
|
||||
@@ -601,20 +745,6 @@ export default function AddressDetailPage() {
|
||||
</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
{contractProfile?.source_code_preview && (
|
||||
<DetailRow label="Source Preview">
|
||||
<code className="block max-h-56 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
|
||||
{contractProfile.source_code_preview}
|
||||
</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
{contractProfile?.abi && (
|
||||
<DetailRow label="ABI Preview">
|
||||
<code className="block max-h-56 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
|
||||
{contractProfile.abi}
|
||||
</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
{contractProfile?.read_methods && contractProfile.read_methods.length > 0 && (
|
||||
<DetailRow label="Read Methods">
|
||||
<div className="space-y-3">
|
||||
@@ -760,9 +890,13 @@ export default function AddressDetailPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
|
||||
{activeTab === 'contract' && addressInfo.is_contract && contractProfile ? (
|
||||
<ContractCodeWorkspace address={addressInfo.address} profile={contractProfile} />
|
||||
) : null}
|
||||
|
||||
<Card title="Token Balances" className="mb-6">
|
||||
{activeTab === 'contract' && gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
|
||||
|
||||
{activeTab === 'balances' ? <Card title="Token Balances" className="mb-6">
|
||||
{gruBalanceCount > 0 ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>{gruBalanceCount} visible token balance{gruBalanceCount === 1 ? '' : 's'} look GRU-aware.</span>
|
||||
@@ -773,13 +907,19 @@ export default function AddressDetailPage() {
|
||||
) : null}
|
||||
<Table
|
||||
columns={tokenBalanceColumns}
|
||||
data={tokenBalances}
|
||||
data={pagedTokenBalances}
|
||||
emptyMessage="No token balances were indexed for this address."
|
||||
keyExtractor={(balance) => balance.token_address || `${balance.token_symbol}-${balance.value}`}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls
|
||||
page={balancePage}
|
||||
pageCount={balancePageCount}
|
||||
onPageChange={setBalancePage}
|
||||
label="Token balances"
|
||||
/>
|
||||
</Card> : null}
|
||||
|
||||
<Card title="Recent Token Transfers" className="mb-6">
|
||||
{activeTab === 'transfers' ? <Card title="Recent Token Transfers" className="mb-6">
|
||||
{gruTransferCount > 0 ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>{gruTransferCount} recent transfer asset{gruTransferCount === 1 ? '' : 's'} carry GRU posture in the explorer.</span>
|
||||
@@ -793,20 +933,32 @@ export default function AddressDetailPage() {
|
||||
) : null}
|
||||
<Table
|
||||
columns={tokenTransferColumns}
|
||||
data={tokenTransfers}
|
||||
data={pagedTokenTransfers}
|
||||
emptyMessage="No token transfers were found for this address."
|
||||
keyExtractor={(transfer) => `${transfer.transaction_hash}-${transfer.token_address}-${transfer.value}`}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls
|
||||
page={transferPage}
|
||||
pageCount={transferPageCount}
|
||||
onPageChange={setTransferPage}
|
||||
label="Token transfers"
|
||||
/>
|
||||
</Card> : null}
|
||||
|
||||
<Card title="Transactions">
|
||||
{activeTab === 'transactions' ? <Card title="Transactions">
|
||||
<Table
|
||||
columns={transactionColumns}
|
||||
data={transactions}
|
||||
data={pagedTransactions}
|
||||
emptyMessage="No recent transactions were found for this address."
|
||||
keyExtractor={(tx) => tx.hash}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls
|
||||
page={transactionPage}
|
||||
pageCount={transactionPageCount}
|
||||
onPageChange={setTransactionPage}
|
||||
label="Transactions"
|
||||
/>
|
||||
</Card> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function GruDocsPage() {
|
||||
<PageIntro
|
||||
eyebrow="Explorer Documentation"
|
||||
title="GRU Guide"
|
||||
description="A user-facing summary of the GRU standards, transport posture, and x402 readiness model, with concrete places to inspect those signals on live token, address, and search pages."
|
||||
description="A user-facing summary of the GRU standards, bridge posture, public-network representations, and x402 readiness model, with concrete places to inspect those signals on live token, address, and search pages."
|
||||
actions={[
|
||||
{ href: '/tokens', label: 'Browse tokens' },
|
||||
{ href: '/search?q=cUSDC', label: 'Search cUSDC' },
|
||||
@@ -23,7 +23,7 @@ export default function GruDocsPage() {
|
||||
<Card title="What The Explorer Is Showing You">
|
||||
<div className="space-y-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
The explorer now distinguishes between canonical GRU money surfaces on Chain 138 and wrapped transport assets used on public-chain bridge lanes.
|
||||
The explorer now distinguishes between canonical GRU surfaces on Chain 138 and cW public-network representations used on bridge lanes.
|
||||
It also highlights when a token looks ready for x402-style payment flows.
|
||||
</p>
|
||||
<p>
|
||||
@@ -49,6 +49,14 @@ export default function GruDocsPage() {
|
||||
|
||||
<Card title="Standards Summary">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 text-sm dark:border-gray-700 dark:bg-gray-900/40 md:col-span-2">
|
||||
<div className="font-medium text-gray-900 dark:text-white">Public token language</div>
|
||||
<div className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
The explorer follows the GRU monetary policy taxonomy: <strong>c</strong> means compliant instrument created by a regulated financial entity or institution,
|
||||
<strong> W</strong> means wrapped representation on a public network, <strong>XXX</strong> is the ISO-4217 currency code or ISO-style commodity code,
|
||||
<strong> C</strong> marks cash-tokenized electronic money, and <strong> T</strong> marks treasury or government bond exposure.
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 text-sm dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="font-medium text-gray-900 dark:text-white">Base token profile</div>
|
||||
<div className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
@@ -95,7 +103,7 @@ export default function GruDocsPage() {
|
||||
<Card title="Chain 138 Practical Reading">
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
A token can be forward-canonical and x402-ready even while older liquidity or transport lanes still run on a prior version.
|
||||
A token can be forward-canonical and x402-ready even while older liquidity or bridge lanes still run on a prior version.
|
||||
That is why the explorer separates active liquidity posture from forward-canonical posture.
|
||||
</p>
|
||||
<p>
|
||||
|
||||
@@ -8,7 +8,7 @@ const docsCards = [
|
||||
{
|
||||
title: 'GRU Guide',
|
||||
href: '/docs/gru',
|
||||
description: 'Understand GRU standards, x402 readiness, wrapped transport posture, and forward-canonical versioning as surfaced by the explorer.',
|
||||
description: 'Understand GRU standards, x402 readiness, cW public-network posture, and forward-canonical versioning as surfaced by the explorer.',
|
||||
},
|
||||
{
|
||||
title: 'Transaction Evidence Matrix',
|
||||
@@ -88,8 +88,8 @@ export default function DocsIndexPage() {
|
||||
<Card title="Operator & Domains">
|
||||
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
SolaceScan is the public Chain 138 explorer operated by DBIS / Defi Oracle. The explorer may be reached through
|
||||
<code> blockscout.defi-oracle.io</code> or <code> explorer.d-bis.org</code>.
|
||||
DBIS Explorer is the public Chain 138 explorer operated by DBIS. Primary public access is served at
|
||||
<code> explorer.d-bis.org</code>; <code> blockscout.defi-oracle.io</code> is the Blockscout companion domain.
|
||||
</p>
|
||||
<p>
|
||||
These domains are part of the same explorer and companion-tooling surface, including the Snap install path at
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function HomeAliasPage() {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-12">
|
||||
<div className="mx-auto max-w-xl rounded-xl border border-gray-200 bg-white p-6 text-center shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Redirecting to SolaceScan</h1>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Redirecting to DBIS Explorer</h1>
|
||||
<p className="mt-3 text-sm leading-7 text-gray-600 dark:text-gray-400">
|
||||
The legacy <code className="rounded bg-gray-100 px-1 py-0.5 text-xs dark:bg-gray-900">/home</code> route now redirects to the main explorer landing page.
|
||||
</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { MissionControlBridgeStatusResponse } from '@/services/api/missionC
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
|
||||
import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces'
|
||||
|
||||
interface TokenPoolRecord {
|
||||
symbol: string
|
||||
@@ -46,7 +47,7 @@ export default function LiquidityPage(props: LiquidityPageProps) {
|
||||
export const getServerSideProps: GetServerSideProps<LiquidityPageProps> = async () => {
|
||||
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult, truthContext] =
|
||||
await Promise.all([
|
||||
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
|
||||
loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
|
||||
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
||||
fetchPublicJson<PlannerCapabilitiesResponse>('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch(
|
||||
() => null,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse }
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
|
||||
import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces'
|
||||
|
||||
interface OperationsPageProps {
|
||||
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||
@@ -24,7 +25,7 @@ export const getStaticProps: GetStaticProps<OperationsPageProps> = async () => {
|
||||
const [routesResult, networksResult, tokenListResult, capabilitiesResult, truthContext] = await Promise.all([
|
||||
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
||||
fetchPublicJson<NetworksConfigResponse>('/api/config/networks').catch(() => null),
|
||||
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
|
||||
loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
|
||||
fetchPublicJson<CapabilitiesResponse>('/api/config/capabilities').catch(() => null),
|
||||
fetchExplorerTruthContext(),
|
||||
])
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { configApi, type TokenListToken } from '@/services/api/config'
|
||||
import type { TokenListToken } from '@/services/api/config'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import {
|
||||
inferDirectSearchTarget,
|
||||
@@ -14,7 +16,9 @@ import {
|
||||
} from '@/utils/search'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { fetchTokenListForSurface } from '@/services/api/tokenListSurfaces'
|
||||
import { useUiMode } from '@/components/common/UiModeContext'
|
||||
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
|
||||
type SearchFilterMode = 'all' | 'gru' | 'x402' | 'wrapped'
|
||||
|
||||
@@ -24,6 +28,15 @@ interface SearchPageProps {
|
||||
initialCuratedTokens: TokenListToken[]
|
||||
}
|
||||
|
||||
function formatUsd(value: number | undefined): string {
|
||||
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 SearchPage({
|
||||
initialQuery,
|
||||
initialRawResults,
|
||||
@@ -40,6 +53,7 @@ export default function SearchPage({
|
||||
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>(initialCuratedTokens)
|
||||
const [savedQueries, setSavedQueries] = useState<string[]>([])
|
||||
const [filterMode, setFilterMode] = useState<SearchFilterMode>('all')
|
||||
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
|
||||
|
||||
const runSearch = async (rawQuery: string) => {
|
||||
const trimmedQuery = rawQuery.trim()
|
||||
@@ -80,9 +94,9 @@ export default function SearchPage({
|
||||
}
|
||||
|
||||
let active = true
|
||||
configApi.getTokenList().then((response) => {
|
||||
tokensApi.listForSurface('catalog', 138).then(({ ok, data }) => {
|
||||
if (active) {
|
||||
setCuratedTokens((response.tokens || []).filter((token) => token.chainId === 138))
|
||||
setCuratedTokens(ok ? data : [])
|
||||
}
|
||||
}).catch(() => {
|
||||
if (active) {
|
||||
@@ -190,6 +204,27 @@ export default function SearchPage({
|
||||
{ label: 'Other', items: groupedResults.other },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const tokenAddresses = filteredResults
|
||||
.filter((result) => result.type === 'token' && typeof result.data.address === 'string' && result.data.address.trim().length > 0)
|
||||
.map((result) => result.data.address as string)
|
||||
|
||||
tokenAggregationApi.getTokensByAddressSafe(138, tokenAddresses).then(({ data }) => {
|
||||
if (!active) return
|
||||
setTokenMarkets(Object.fromEntries(data.map((snapshot) => [snapshot.address.toLowerCase(), snapshot])))
|
||||
}).catch(() => {
|
||||
if (active) {
|
||||
setTokenMarkets({})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [filteredResults])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<PageIntro
|
||||
@@ -341,30 +376,46 @@ export default function SearchPage({
|
||||
</Link>
|
||||
)}
|
||||
{(result.type === 'address' || result.type === 'token') && result.data.address && (
|
||||
<Link
|
||||
href={result.href || (result.type === 'token' ? `/tokens/${result.data.address}` : `/addresses/${result.data.address}`)}
|
||||
className="inline-flex flex-col gap-2 text-primary-600 hover:underline"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge
|
||||
label={result.type === 'token' ? 'token' : 'address'}
|
||||
tone={result.type === 'token' ? 'success' : 'neutral'}
|
||||
/>
|
||||
{result.symbol && <EntityBadge label={result.symbol} tone="info" />}
|
||||
{result.token_type && <EntityBadge label={result.token_type} tone="warning" />}
|
||||
{result.is_curated_token && <EntityBadge label="listed" tone="success" />}
|
||||
{result.is_gru_token && <EntityBadge label="GRU" tone="success" />}
|
||||
{result.is_x402_ready && <EntityBadge label="x402 ready" tone="info" />}
|
||||
{result.is_wrapped_transport && <EntityBadge label="wrapped" tone="warning" />}
|
||||
{result.currency_code ? <EntityBadge label={result.currency_code} tone="neutral" /> : null}
|
||||
{result.match_reason ? <EntityBadge label={result.match_reason} tone="info" className="normal-case tracking-normal" /> : null}
|
||||
{result.matched_tags?.map((tag) => <EntityBadge key={tag} label={tag} />)}
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{result.name || result.symbol || result.label}
|
||||
</span>
|
||||
<Address address={result.data.address} truncate showCopy={false} />
|
||||
</Link>
|
||||
(() => {
|
||||
const market = result.type === 'token'
|
||||
? tokenMarkets[result.data.address.toLowerCase()]?.market
|
||||
: null
|
||||
return (
|
||||
<Link
|
||||
href={result.href || (result.type === 'token' ? `/tokens/${result.data.address}` : `/addresses/${result.data.address}`)}
|
||||
className="inline-flex flex-col gap-2 text-primary-600 hover:underline"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge
|
||||
label={result.type === 'token' ? 'token' : 'address'}
|
||||
tone={result.type === 'token' ? 'success' : 'neutral'}
|
||||
/>
|
||||
{result.symbol && <EntityBadge label={result.symbol} tone="info" />}
|
||||
{result.token_type && <EntityBadge label={result.token_type} tone="warning" />}
|
||||
{result.is_curated_token && <EntityBadge label="listed" tone="success" />}
|
||||
{result.is_gru_token && <EntityBadge label="GRU" tone="success" />}
|
||||
{result.is_x402_ready && <EntityBadge label="x402 ready" tone="info" />}
|
||||
{result.is_wrapped_transport && <EntityBadge label="cW public-network" tone="warning" />}
|
||||
{result.currency_code ? <EntityBadge label={result.currency_code} tone="neutral" /> : null}
|
||||
{result.match_reason ? <EntityBadge label={result.match_reason} tone="info" className="normal-case tracking-normal" /> : null}
|
||||
{result.matched_tags?.map((tag) => <EntityBadge key={tag} label={tag} />)}
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{result.name || result.symbol || result.label}
|
||||
</span>
|
||||
<Address address={result.data.address} truncate showCopy={false} />
|
||||
{market ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<span>Live price: {formatUsd(market.priceUsd)}</span>
|
||||
<span>Visible liquidity: {formatUsd(market.liquidityUsd)}</span>
|
||||
</div>
|
||||
<MarketEvidenceNote lastUpdated={market.lastUpdated} compact />
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})()
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500">
|
||||
<span>Type: {result.type}</span>
|
||||
@@ -442,10 +493,7 @@ export default function SearchPage({
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<SearchPageProps> = async (context) => {
|
||||
const initialQuery = typeof context.query.q === 'string' ? context.query.q.trim() : ''
|
||||
const tokenListResult = await fetchPublicJson<{ tokens?: TokenListToken[] }>('/api/config/token-list').catch(() => null)
|
||||
const initialCuratedTokens = Array.isArray(tokenListResult?.tokens)
|
||||
? tokenListResult.tokens.filter((token) => token.chainId === 138)
|
||||
: []
|
||||
const { tokens: initialCuratedTokens } = await fetchTokenListForSurface('catalog', 138)
|
||||
|
||||
const shouldFetchSearch =
|
||||
Boolean(initialQuery) &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GetServerSideProps } from 'next'
|
||||
import SystemOperationsPage from '@/components/explorer/SystemOperationsPage'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces'
|
||||
import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse } from '@/services/api/config'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import type { RouteMatrixResponse } from '@/services/api/routes'
|
||||
@@ -23,7 +24,7 @@ export const getServerSideProps: GetServerSideProps<SystemPageProps> = async ()
|
||||
const [bridgeStatus, networksConfig, tokenList, capabilities, routeMatrix, stats] = await Promise.all([
|
||||
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
||||
fetchPublicJson<NetworksConfigResponse>('/api/config/networks').catch(() => null),
|
||||
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
|
||||
loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
|
||||
fetchPublicJson<CapabilitiesResponse>('/api/config/capabilities').catch(() => null),
|
||||
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
||||
fetchPublicJson('/api/v2/stats').then((value) => normalizeExplorerStats(value as never)).catch(() => null),
|
||||
|
||||
@@ -11,9 +11,14 @@ import PageIntro from '@/components/common/PageIntro'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import GruStandardsCard from '@/components/common/GruStandardsCard'
|
||||
import TokenSigningSurfaceCard from '@/components/common/TokenSigningSurfaceCard'
|
||||
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { formatTokenAmount, formatTimestamp } from '@/utils/format'
|
||||
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
|
||||
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
|
||||
import { contractsApi, type ContractProfile } from '@/services/api/contracts'
|
||||
|
||||
function isValidAddress(value: string) {
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(value)
|
||||
@@ -49,17 +54,24 @@ export default function TokenDetailPage() {
|
||||
const [transfers, setTransfers] = useState<AddressTokenTransfer[]>([])
|
||||
const [pools, setPools] = useState<MissionControlLiquidityPool[]>([])
|
||||
const [gruProfile, setGruProfile] = useState<GruStandardsProfile | null>(null)
|
||||
const [contractProfile, setContractProfile] = useState<ContractProfile | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'intelligence' | 'standards' | 'holders' | 'transfers' | 'liquidity'>('intelligence')
|
||||
const [holderPage, setHolderPage] = useState(1)
|
||||
const [transferPage, setTransferPage] = useState(1)
|
||||
const [poolPage, setPoolPage] = useState(1)
|
||||
const pageSize = 8
|
||||
|
||||
const loadToken = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [tokenResult, provenanceResult, holdersResult, transfersResult, poolsResult] = await Promise.all([
|
||||
const [tokenResult, provenanceResult, holdersResult, transfersResult, poolsResult, contractResult] = await Promise.all([
|
||||
tokensApi.getSafe(address),
|
||||
tokensApi.getProvenanceSafe(address),
|
||||
tokensApi.getHoldersSafe(address, 1, 10),
|
||||
tokensApi.getTransfersSafe(address, 1, 10),
|
||||
tokensApi.getRelatedPoolsSafe(address),
|
||||
contractsApi.getProfileSafe(address),
|
||||
])
|
||||
|
||||
setToken(tokenResult.ok ? tokenResult.data : null)
|
||||
@@ -67,11 +79,14 @@ export default function TokenDetailPage() {
|
||||
setHolders(holdersResult.ok ? holdersResult.data : [])
|
||||
setTransfers(transfersResult.ok ? transfersResult.data : [])
|
||||
setPools(poolsResult.ok ? poolsResult.data : [])
|
||||
const resolvedContractProfile = contractResult.ok ? contractResult.data : null
|
||||
setContractProfile(resolvedContractProfile)
|
||||
if (tokenResult.ok && tokenResult.data) {
|
||||
const gruResult = await getGruStandardsProfileSafe({
|
||||
address,
|
||||
symbol: tokenResult.data.symbol,
|
||||
tags: provenanceResult.ok ? provenanceResult.data?.tags || [] : [],
|
||||
contractProfile: resolvedContractProfile,
|
||||
})
|
||||
setGruProfile(gruResult.ok ? gruResult.data : null)
|
||||
} else {
|
||||
@@ -84,6 +99,7 @@ export default function TokenDetailPage() {
|
||||
setTransfers([])
|
||||
setPools([])
|
||||
setGruProfile(null)
|
||||
setContractProfile(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -97,6 +113,7 @@ export default function TokenDetailPage() {
|
||||
if (!isValidTokenAddress) {
|
||||
setLoading(false)
|
||||
setToken(null)
|
||||
setContractProfile(null)
|
||||
return
|
||||
}
|
||||
void loadToken()
|
||||
@@ -176,6 +193,35 @@ export default function TokenDetailPage() {
|
||||
() => getGruExplorerMetadata({ address: token?.address || address, symbol: token?.symbol }),
|
||||
[address, token?.address, token?.symbol],
|
||||
)
|
||||
const tabs: SectionTab<typeof activeTab>[] = [
|
||||
{ id: 'intelligence', label: 'Intelligence' },
|
||||
...(gruProfile || gruExplorerMetadata ? [{ id: 'standards' as const, label: 'Standards' }] : []),
|
||||
{ id: 'holders', label: 'Holders', count: holders.length },
|
||||
{ id: 'transfers', label: 'Transfers', count: transfers.length },
|
||||
{ id: 'liquidity', label: 'Liquidity', count: pools.length },
|
||||
]
|
||||
const holderPageCount = Math.max(1, Math.ceil(holders.length / pageSize))
|
||||
const transferPageCount = Math.max(1, Math.ceil(transfers.length / pageSize))
|
||||
const poolPageCount = Math.max(1, Math.ceil(pools.length / pageSize))
|
||||
const pagedHolders = useMemo(
|
||||
() => holders.slice((holderPage - 1) * pageSize, holderPage * pageSize),
|
||||
[holderPage, holders],
|
||||
)
|
||||
const pagedTransfers = useMemo(
|
||||
() => transfers.slice((transferPage - 1) * pageSize, transferPage * pageSize),
|
||||
[transferPage, transfers],
|
||||
)
|
||||
const pagedPools = useMemo(
|
||||
() => pools.slice((poolPage - 1) * pageSize, poolPage * pageSize),
|
||||
[poolPage, pools],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab('intelligence')
|
||||
setHolderPage(1)
|
||||
setTransferPage(1)
|
||||
setPoolPage(1)
|
||||
}, [address])
|
||||
|
||||
const holderColumns = [
|
||||
{
|
||||
@@ -273,7 +319,7 @@ export default function TokenDetailPage() {
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<PageIntro
|
||||
eyebrow="Token Detail"
|
||||
title={token?.symbol || token?.name || 'Token'}
|
||||
title={token?.symbol || token?.name || provenance?.symbol || provenance?.name || 'Token'}
|
||||
description="Inspect token supply, holders, transfers, and liquidity context with the sort of composure one normally has to borrow from a better explorer."
|
||||
actions={[
|
||||
{ href: '/tokens', label: 'Token index' },
|
||||
@@ -302,15 +348,20 @@ export default function TokenDetailPage() {
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Invalid token address. Please use a full 42-character 0x-prefixed address.</p>
|
||||
</Card>
|
||||
) : !token ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Token details were not found for this address.</p>
|
||||
</Card>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Token details were not found for this address from the token index APIs. If this is a contract, verification metadata and ERC-5267 may still be available below.
|
||||
</p>
|
||||
</Card>
|
||||
<TokenSigningSurfaceCard address={address} contractProfile={contractProfile} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Card title="Token Overview">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Name">{token.name || 'Unknown'}</DetailRow>
|
||||
<DetailRow label="Symbol">{token.symbol || 'Unknown'}</DetailRow>
|
||||
<DetailRow label="Name">{token.name || provenance?.name || 'Unknown'}</DetailRow>
|
||||
<DetailRow label="Symbol">{token.symbol || provenance?.symbol || 'Unknown'}</DetailRow>
|
||||
<DetailRow label="Address">
|
||||
<Address address={token.address} />
|
||||
</DetailRow>
|
||||
@@ -341,14 +392,26 @@ export default function TokenDetailPage() {
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card title="Token Intelligence">
|
||||
<TokenSigningSurfaceCard address={token.address} contractProfile={contractProfile} />
|
||||
|
||||
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
|
||||
|
||||
{activeTab === 'intelligence' ? <Card title="Token Intelligence">
|
||||
<div className="grid gap-4 lg: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">Market Context</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div>Indicative price: {formatUsd(token.exchange_rate)}</div>
|
||||
<div>Current price: {formatUsd(token.exchange_rate)}</div>
|
||||
<div>24h volume: {formatUsd(token.volume_24h)}</div>
|
||||
<div>Market cap: {formatUsd(token.circulating_market_cap)}</div>
|
||||
<div>Visible liquidity: {formatUsd(token.liquidity_usd)}</div>
|
||||
<div>Valuation source: {token.price_source === 'token-aggregation' ? 'live token aggregation' : token.price_source || 'unavailable'}</div>
|
||||
<div>Market snapshot: {token.market_updated_at ? formatTimestamp(token.market_updated_at) : 'Unavailable'}</div>
|
||||
<MarketEvidenceNote
|
||||
source={token.price_source}
|
||||
lastUpdated={token.market_updated_at}
|
||||
method="Merged Blockscout token profile with token aggregation price, volume, and visible-liquidity fields where available."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
@@ -378,11 +441,11 @@ export default function TokenDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card> : null}
|
||||
|
||||
{gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
|
||||
{activeTab === 'standards' && gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
|
||||
|
||||
{gruExplorerMetadata ? (
|
||||
{activeTab === 'standards' && gruExplorerMetadata ? (
|
||||
<Card title="x402 And ISO-20022 Posture">
|
||||
<div className="grid gap-4 lg: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">
|
||||
@@ -425,11 +488,11 @@ export default function TokenDetailPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? (
|
||||
{activeTab === 'standards' && gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? (
|
||||
<Card title="Other Networks">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
These are sibling representations or settlement counterparts for the same GRU asset family on other networks, drawn from the local transport and mapping posture used by this workspace.
|
||||
These are sibling representations or settlement counterparts for the same GRU asset family on other networks, drawn from the local public-network overlay and mapping posture used by this workspace.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{gruExplorerMetadata.otherNetworks.map((network) => (
|
||||
@@ -458,16 +521,18 @@ export default function TokenDetailPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="Top Holders">
|
||||
{activeTab === 'holders' ? <Card title="Top Holders">
|
||||
<Table
|
||||
layout="tabular"
|
||||
columns={holderColumns}
|
||||
data={holders}
|
||||
data={pagedHolders}
|
||||
emptyMessage="No holder data was available for this token."
|
||||
keyExtractor={(holder) => holder.address}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls page={holderPage} pageCount={holderPageCount} onPageChange={setHolderPage} label="Holders" />
|
||||
</Card> : null}
|
||||
|
||||
<Card title="Recent Transfers">
|
||||
{activeTab === 'transfers' ? <Card title="Recent Transfers">
|
||||
{gruExplorerMetadata ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
@@ -483,20 +548,22 @@ export default function TokenDetailPage() {
|
||||
) : null}
|
||||
<Table
|
||||
columns={transferColumns}
|
||||
data={transfers}
|
||||
data={pagedTransfers}
|
||||
emptyMessage="No recent token transfers were available."
|
||||
keyExtractor={(transfer) => `${transfer.transaction_hash}-${transfer.value}-${transfer.from_address}`}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Transfers" />
|
||||
</Card> : null}
|
||||
|
||||
<Card title="Related Liquidity">
|
||||
{activeTab === 'liquidity' ? <Card title="Related Liquidity">
|
||||
<Table
|
||||
columns={poolColumns}
|
||||
data={pools}
|
||||
data={pagedPools}
|
||||
emptyMessage="No related liquidity pools were exposed through mission control for this token."
|
||||
keyExtractor={(pool) => pool.address}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls page={poolPage} pageCount={poolPageCount} onPageChange={setPoolPage} label="Pools" />
|
||||
</Card> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,13 +5,16 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import type { TokenListToken } from '@/services/api/config'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
|
||||
import { fetchTokenListForSurface, TOKEN_LIST_SURFACE_LABELS } from '@/services/api/tokenListSurfaces'
|
||||
import { selectCuratedFeaturedTokens } from '@/utils/featuredTokens'
|
||||
|
||||
const quickSearches = [
|
||||
{ label: 'cUSDT', description: 'Canonical bridged USDT liquidity and address results.' },
|
||||
{ label: 'cUSDC', description: 'Canonical bridged USDC routes and address coverage.' },
|
||||
{ label: 'cUSDT', description: 'Canonical compliant USD treasury / government bond liquidity and address results.' },
|
||||
{ label: 'cUSDC', description: 'Canonical compliant USD cash electronic-money routes and address coverage.' },
|
||||
{ label: 'cXAUC', description: 'Gold-backed cXAUC pools and token references.' },
|
||||
{ label: 'cXAUT', description: 'Gold-backed cXAUT references and search coverage.' },
|
||||
{ label: 'cEURT', description: 'EUR liquidity and cXAUC-connected route coverage.' },
|
||||
@@ -27,10 +30,33 @@ interface TokensPageProps {
|
||||
initialCuratedTokens: TokenListToken[]
|
||||
}
|
||||
|
||||
function formatUsd(value: number | undefined): string {
|
||||
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)
|
||||
}
|
||||
|
||||
function tagPriority(tag: string) {
|
||||
const order: Record<string, number> = {
|
||||
gru: 0,
|
||||
compliant: 1,
|
||||
'treasury-bond': 2,
|
||||
'electronic-money': 2,
|
||||
commodity: 2,
|
||||
'reference-asset': 0,
|
||||
defi: 4,
|
||||
}
|
||||
return order[tag] ?? 3
|
||||
}
|
||||
|
||||
export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
|
||||
const router = useRouter()
|
||||
const [query, setQuery] = useState('')
|
||||
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>(initialCuratedTokens)
|
||||
const [featuredMarkets, setFeaturedMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -45,7 +71,7 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
|
||||
}
|
||||
|
||||
let active = true
|
||||
tokensApi.listCuratedSafe(138).then(({ ok, data }) => {
|
||||
tokensApi.listForSurface('catalog', 138).then(({ ok, data }) => {
|
||||
if (active) {
|
||||
setCuratedTokens(ok ? data : [])
|
||||
}
|
||||
@@ -59,21 +85,39 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
|
||||
}
|
||||
}, [initialCuratedTokens])
|
||||
|
||||
const featuredCuratedTokens = useMemo(() => {
|
||||
const preferred = ['cUSDT', 'cUSDC', 'cXAUC', 'cXAUT', 'cEURT', 'USDT']
|
||||
const selected = preferred
|
||||
.map((symbol) => curatedTokens.find((token) => token.symbol === symbol))
|
||||
.filter((token): token is TokenListToken => Boolean(token?.address))
|
||||
const featuredCuratedTokens = useMemo(
|
||||
() => selectCuratedFeaturedTokens(curatedTokens) as TokenListToken[],
|
||||
[curatedTokens],
|
||||
)
|
||||
|
||||
return selected.length > 0 ? selected : curatedTokens.slice(0, 6)
|
||||
}, [curatedTokens])
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const featuredAddresses = featuredCuratedTokens
|
||||
.map((token) => token.address)
|
||||
.filter((address): address is string => typeof address === 'string' && address.trim().length > 0)
|
||||
|
||||
tokenAggregationApi.getTokensByAddressSafe(138, featuredAddresses).then(({ data }) => {
|
||||
if (!active) return
|
||||
const next = Object.fromEntries(data.map((snapshot) => [snapshot.address.toLowerCase(), snapshot]))
|
||||
setFeaturedMarkets(next)
|
||||
}).catch(() => {
|
||||
if (active) {
|
||||
setFeaturedMarkets({})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [featuredCuratedTokens])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<PageIntro
|
||||
eyebrow="Token Discovery"
|
||||
title="Tokens"
|
||||
description="Browse curated Chain 138 assets, open token contracts directly, and move into holders, transfers, liquidity, and provenance without pretending a search box is a complete token strategy."
|
||||
description="Browse the canonical Chain 138 trading set, open token contracts directly, and review holders, transfers, liquidity, and provenance from the same institutional explorer surface."
|
||||
actions={[
|
||||
{ href: '/wallet', label: 'Wallet tools' },
|
||||
{ href: '/liquidity', label: 'Liquidity access' },
|
||||
@@ -81,98 +125,89 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card className="mb-6" title="Find A Token">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 md:flex-row">
|
||||
<Card className="mb-5" title="Find a token">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Token symbol, name, or contract address"
|
||||
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
className="min-h-10 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-950"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!query.trim()}
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="min-h-10 rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Contract addresses open dedicated token detail pages with holders, transfers, provenance, and liquidity context.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card title="Curated Registry">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Review listed Chain 138 assets with provenance tags such as compliant, wrapped, and bridge-aware before acting on a symbol match.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/tokens" className="text-primary-600 hover:underline">
|
||||
Browse curated tokens →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Wallet Discovery">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Add Chain 138 and supported token metadata to MetaMask directly from the explorer wallet tools.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/wallet" className="text-primary-600 hover:underline">
|
||||
Open wallet tools →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Liquidity Routes">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Review canonical PMM routes, partner payload templates, and token-routing examples for supported pools.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/liquidity" className="text-primary-600 hover:underline">
|
||||
Open liquidity access →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="mt-5">
|
||||
<Card title="Curated Chain 138 tokens">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{featuredCuratedTokens.map((token) => (
|
||||
<Link
|
||||
key={token.address}
|
||||
href={`/tokens/${token.address}`}
|
||||
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-700"
|
||||
>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.symbol || token.name || 'Token'}</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{token.name || 'Listed in the Chain 138 token registry.'}
|
||||
</p>
|
||||
{token.tags && token.tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{token.tags.slice(0, 3).map((tag) => (
|
||||
<EntityBadge key={`${token.address}-${tag}`} label={tag} className="px-2 py-1 text-[11px]" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{TOKEN_LIST_SURFACE_LABELS.catalog}. Showing {featuredCuratedTokens.length} featured tokens from the live report list.
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{featuredCuratedTokens
|
||||
.filter((token): token is TokenListToken & { address: string } => typeof token.address === 'string' && token.address.trim().length > 0)
|
||||
.map((token) => {
|
||||
const market = featuredMarkets[token.address.toLowerCase()]?.market
|
||||
return (
|
||||
<Link
|
||||
key={token.address}
|
||||
href={`/tokens/${token.address}`}
|
||||
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-950/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.symbol || token.name || 'Token'}</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm leading-5 text-gray-600 dark:text-gray-400">
|
||||
{token.name || 'Listed in the Chain 138 token registry.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{market ? (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-950/60">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Price</div>
|
||||
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.priceUsd)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-950/60">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Liquidity</div>
|
||||
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.liquidityUsd)}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<MarketEvidenceNote lastUpdated={market.lastUpdated} compact />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{token.tags && token.tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{[...token.tags].sort((a, b) => tagPriority(a) - tagPriority(b)).slice(0, 3).map((tag) => (
|
||||
<EntityBadge key={`${token.address}-${tag}`} label={tag} className="px-2 py-1 text-[11px]" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="mt-5">
|
||||
<Card title="Common token searches">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{quickSearches.map((token) => (
|
||||
<Link
|
||||
key={token.label}
|
||||
href={`/search?q=${encodeURIComponent(token.label)}`}
|
||||
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-700"
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 transition hover:border-primary-400 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-950/50"
|
||||
>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.label}</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{token.description}</p>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-gray-600 dark:text-gray-400">{token.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -183,15 +218,11 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps<TokensPageProps> = async () => {
|
||||
const tokenListResult = await fetchPublicJson<{ tokens?: TokenListToken[] }>('/api/config/token-list').catch(() => null)
|
||||
const { tokens } = await fetchTokenListForSurface('catalog', 138)
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialCuratedTokens: Array.isArray(tokenListResult?.tokens)
|
||||
? tokenListResult.tokens
|
||||
.filter((token) => token.chainId === 138 && typeof token.address === 'string' && token.address.trim().length > 0)
|
||||
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || right.name || ''))
|
||||
: [],
|
||||
initialCuratedTokens: tokens,
|
||||
},
|
||||
revalidate: 300,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Card, Address, Table } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
@@ -15,13 +15,55 @@ import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/form
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { getGruCatalogPosture } from '@/services/api/gruCatalog'
|
||||
import { assessTransactionCompliance } from '@/utils/transactionCompliance'
|
||||
import {
|
||||
tokenAggregationApi,
|
||||
type CheckpointTxAttestationSnapshot,
|
||||
type TokenAggregationHistoricalPriceSnapshot,
|
||||
} from '@/services/api/tokenAggregation'
|
||||
import { estimateNativeUsdValue, estimateTokenUsdValue, getNativeAssetDescriptor, getNativeAssetPriceAtSafe } from '@/services/api/nativeAssetPricing'
|
||||
|
||||
function isValidTransactionHash(value: string) {
|
||||
return /^0x[a-fA-F0-9]{64}$/.test(value)
|
||||
}
|
||||
|
||||
function formatUsd(value: string | number | undefined): string {
|
||||
if (value == null) return 'Unavailable'
|
||||
const numeric = typeof value === 'number' ? value : Number(value)
|
||||
if (!Number.isFinite(numeric)) return 'Unavailable'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: numeric >= 100 ? 0 : 2,
|
||||
}).format(numeric)
|
||||
}
|
||||
|
||||
function formatHistoricalPriceSource(source: string | undefined): string {
|
||||
switch (source) {
|
||||
case 'ohlcv_5m':
|
||||
return 'historical OHLCV 5m'
|
||||
case 'ohlcv_15m':
|
||||
return 'historical OHLCV 15m'
|
||||
case 'ohlcv_1h':
|
||||
return 'historical OHLCV 1h'
|
||||
case 'ohlcv_4h':
|
||||
return 'historical OHLCV 4h'
|
||||
case 'ohlcv_24h':
|
||||
return 'historical OHLCV 24h'
|
||||
case 'coingecko_history':
|
||||
return 'historical CoinGecko market'
|
||||
case 'current_market_fallback':
|
||||
return 'current market fallback'
|
||||
case 'canonical_fallback':
|
||||
return 'canonical fallback'
|
||||
default:
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
export default function TransactionDetailPage() {
|
||||
const router = useRouter()
|
||||
const hash = typeof router.query.hash === 'string' ? router.query.hash : ''
|
||||
@@ -31,7 +73,14 @@ export default function TransactionDetailPage() {
|
||||
const [transaction, setTransaction] = useState<Transaction | null>(null)
|
||||
const [internalCalls, setInternalCalls] = useState<TransactionInternalCall[]>([])
|
||||
const [diagnostic, setDiagnostic] = useState<TransactionLookupDiagnostic | null>(null)
|
||||
const [historicalTokenPrices, setHistoricalTokenPrices] = useState<Record<string, TokenAggregationHistoricalPriceSnapshot>>({})
|
||||
const [historicalNativePrice, setHistoricalNativePrice] = useState<TokenAggregationHistoricalPriceSnapshot | null>(null)
|
||||
const [checkpointAttestation, setCheckpointAttestation] = useState<CheckpointTxAttestationSnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'evidence' | 'details' | 'transfers' | 'internal' | 'raw'>('evidence')
|
||||
const [transferPage, setTransferPage] = useState(1)
|
||||
const [internalPage, setInternalPage] = useState(1)
|
||||
const pageSize = 8
|
||||
|
||||
const loadTransaction = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -91,6 +140,81 @@ export default function TransactionDetailPage() {
|
||||
loadTransaction()
|
||||
}, [hash, isValidHash, loadTransaction, router.isReady])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const tokenAddresses = (transaction?.token_transfers || [])
|
||||
.map((transfer) => transfer.token_address)
|
||||
.filter((candidate, index, values): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 && values.indexOf(candidate) === index)
|
||||
|
||||
if (!transaction?.created_at || tokenAddresses.length === 0) {
|
||||
setHistoricalTokenPrices({})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all(tokenAddresses.map((address) => tokenAggregationApi.getPriceAtSafe(chainId, address, transaction.created_at))).then((results) => {
|
||||
if (!active) return
|
||||
const snapshots = results
|
||||
.filter((result): result is { ok: true; data: TokenAggregationHistoricalPriceSnapshot | null } => result.ok)
|
||||
.map((result) => result.data)
|
||||
.filter((snapshot): snapshot is TokenAggregationHistoricalPriceSnapshot => Boolean(snapshot?.tokenAddress))
|
||||
setHistoricalTokenPrices(Object.fromEntries(snapshots.map((snapshot) => [snapshot.tokenAddress.toLowerCase(), snapshot])))
|
||||
}).catch(() => {
|
||||
if (active) {
|
||||
setHistoricalTokenPrices({})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [chainId, transaction?.created_at, transaction?.token_transfers])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
if (!transaction?.created_at) {
|
||||
setHistoricalNativePrice(null)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}
|
||||
|
||||
getNativeAssetPriceAtSafe(chainId, transaction.created_at).then(({ data }) => {
|
||||
if (!active) return
|
||||
setHistoricalNativePrice(data)
|
||||
}).catch(() => {
|
||||
if (active) {
|
||||
setHistoricalNativePrice(null)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [chainId, transaction?.created_at])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (!isValidHash) {
|
||||
setCheckpointAttestation(null)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}
|
||||
tokenAggregationApi.getCheckpointAttestationSafe(hash).then(({ ok, data }) => {
|
||||
if (!active) return
|
||||
setCheckpointAttestation(ok && data?.included ? data : null)
|
||||
}).catch(() => {
|
||||
if (active) setCheckpointAttestation(null)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [hash, isValidHash])
|
||||
|
||||
const tokenTransferColumns = [
|
||||
{
|
||||
header: 'Token',
|
||||
@@ -137,6 +261,32 @@ export default function TransactionDetailPage() {
|
||||
formatTokenAmount(transfer.amount, transfer.token_decimals, transfer.token_symbol)
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Transfer-Time Value',
|
||||
accessor: (transfer: TransactionTokenTransfer) => {
|
||||
const want = transfer.token_address.toLowerCase()
|
||||
const checkpointLine = checkpointAttestation?.leaf?.transfers?.find((line) => {
|
||||
const addr = (line.tokenAddress || line.token || '').toLowerCase()
|
||||
return addr === want
|
||||
})
|
||||
const historicalPrice = historicalTokenPrices[transfer.token_address.toLowerCase()]
|
||||
const totalUsd = checkpointLine?.valueUsd != null
|
||||
? Number(checkpointLine.valueUsd)
|
||||
: estimateTokenUsdValue(transfer.amount, transfer.token_decimals, historicalPrice?.priceUsd)
|
||||
const priceSource = checkpointLine?.priceSource ?? historicalPrice?.source
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>{totalUsd != null && Number.isFinite(totalUsd) ? formatUsd(totalUsd) : 'Unavailable'}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Unit price: {checkpointLine?.valueUsd != null ? 'checkpoint leaf' : formatUsd(historicalPrice?.priceUsd)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Source: {checkpointLine ? `checkpoint (${priceSource || 'enriched'})` : formatHistoricalPriceSource(priceSource)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const internalCallColumns = [
|
||||
@@ -186,6 +336,15 @@ export default function TransactionDetailPage() {
|
||||
: null
|
||||
const tokenTransferCount = transaction?.token_transfers?.length || 0
|
||||
const internalCallCount = internalCalls.length
|
||||
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
|
||||
const checkpointLeaf = checkpointAttestation?.leaf
|
||||
const checkpointNativeUsd = checkpointLeaf?.nativeValueUsd ?? checkpointLeaf?.valueUsd
|
||||
const parsedCheckpointNativeUsd = checkpointNativeUsd != null ? Number(checkpointNativeUsd) : null
|
||||
const nativeValueUsd = parsedCheckpointNativeUsd != null && Number.isFinite(parsedCheckpointNativeUsd)
|
||||
? parsedCheckpointNativeUsd
|
||||
: estimateNativeUsdValue(transaction?.value, historicalNativePrice?.priceUsd)
|
||||
const nativeFeeUsd = estimateNativeUsdValue(transaction?.fee, historicalNativePrice?.priceUsd)
|
||||
const checkpointTotalUsd = checkpointLeaf?.totalTransfersUsd ?? checkpointLeaf?.valueUsd
|
||||
const complianceAssessment = transaction
|
||||
? assessTransactionCompliance({
|
||||
transaction,
|
||||
@@ -193,6 +352,29 @@ export default function TransactionDetailPage() {
|
||||
tokenTransfers: transaction.token_transfers || [],
|
||||
})
|
||||
: null
|
||||
const tabs: SectionTab<typeof activeTab>[] = [
|
||||
{ id: 'evidence', label: 'Evidence' },
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'transfers', label: 'Transfers', count: tokenTransferCount },
|
||||
{ id: 'internal', label: 'Internal', count: internalCallCount },
|
||||
...(transaction?.input_data ? [{ id: 'raw' as const, label: 'Raw input' }] : []),
|
||||
]
|
||||
const transferPageCount = Math.max(1, Math.ceil((transaction?.token_transfers?.length || 0) / pageSize))
|
||||
const internalPageCount = Math.max(1, Math.ceil(internalCalls.length / pageSize))
|
||||
const pagedTokenTransfers = useMemo(
|
||||
() => (transaction?.token_transfers || []).slice((transferPage - 1) * pageSize, transferPage * pageSize),
|
||||
[transaction?.token_transfers, transferPage],
|
||||
)
|
||||
const pagedInternalCalls = useMemo(
|
||||
() => internalCalls.slice((internalPage - 1) * pageSize, internalPage * pageSize),
|
||||
[internalCalls, internalPage],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab('evidence')
|
||||
setTransferPage(1)
|
||||
setInternalPage(1)
|
||||
}, [hash])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
@@ -275,7 +457,10 @@ export default function TransactionDetailPage() {
|
||||
<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">Gas & Fees</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div>Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'}</div>
|
||||
<div>
|
||||
Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'}
|
||||
{nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
|
||||
</div>
|
||||
<div>Gas used: {transaction.gas_used != null ? transaction.gas_used.toLocaleString() : 'Unavailable'}</div>
|
||||
<div>Utilization: {gasUtilization != null ? `${gasUtilization}%` : 'Unavailable'}</div>
|
||||
</div>
|
||||
@@ -283,7 +468,18 @@ export default function TransactionDetailPage() {
|
||||
<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">Value Movement</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div>Native value: {formatWeiAsEth(transaction.value)}</div>
|
||||
<div>
|
||||
Native value: {formatWeiAsEth(transaction.value)}
|
||||
{nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
|
||||
</div>
|
||||
<div>Transfer-time native price: {historicalNativePrice?.priceUsd != null ? `${formatUsd(historicalNativePrice.priceUsd)} per ${nativeAssetSymbol}` : 'Unavailable'}</div>
|
||||
<div>Pricing source: {checkpointLeaf ? `checkpoint mirror (${checkpointLeaf.priceSource || 'enriched'})` : formatHistoricalPriceSource(historicalNativePrice?.source)}</div>
|
||||
{checkpointAttestation ? (
|
||||
<div>
|
||||
Checkpoint batch #{checkpointAttestation.batchId}
|
||||
{checkpointTotalUsd != null ? ` — total ${formatUsd(Number(checkpointTotalUsd))}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
<div>Token transfers: {tokenTransferCount.toLocaleString()}</div>
|
||||
<div>Internal calls: {internalCallCount.toLocaleString()}</div>
|
||||
</div>
|
||||
@@ -307,7 +503,9 @@ export default function TransactionDetailPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{complianceAssessment ? (
|
||||
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
|
||||
|
||||
{activeTab === 'evidence' && complianceAssessment ? (
|
||||
<Card title="Transaction Evidence Matrix">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -335,7 +533,7 @@ export default function TransactionDetailPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="Transaction Information">
|
||||
{activeTab === 'details' ? <Card title="Transaction Information">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Hash">
|
||||
<Address address={transaction.hash} />
|
||||
@@ -368,8 +566,16 @@ export default function TransactionDetailPage() {
|
||||
</Link>
|
||||
</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Value">{formatWeiAsEth(transaction.value)}</DetailRow>
|
||||
{transaction.fee && <DetailRow label="Fee">{formatWeiAsEth(transaction.fee)}</DetailRow>}
|
||||
<DetailRow label="Value">
|
||||
{formatWeiAsEth(transaction.value)}
|
||||
{nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
|
||||
</DetailRow>
|
||||
{transaction.fee && (
|
||||
<DetailRow label="Fee">
|
||||
{formatWeiAsEth(transaction.fee)}
|
||||
{nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
|
||||
</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Gas Price">
|
||||
{transaction.gas_price != null ? `${transaction.gas_price / 1e9} Gwei` : 'N/A'}
|
||||
</DetailRow>
|
||||
@@ -391,9 +597,9 @@ export default function TransactionDetailPage() {
|
||||
</DetailRow>
|
||||
)}
|
||||
</dl>
|
||||
</Card>
|
||||
</Card> : null}
|
||||
|
||||
{transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (
|
||||
{activeTab === 'details' && transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (
|
||||
<Card title="Decoded Input">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -422,25 +628,27 @@ export default function TransactionDetailPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Token Transfers">
|
||||
{activeTab === 'transfers' ? <Card title="Token Transfers">
|
||||
<Table
|
||||
columns={tokenTransferColumns}
|
||||
data={transaction.token_transfers || []}
|
||||
data={pagedTokenTransfers}
|
||||
emptyMessage="No token transfers were indexed for this transaction."
|
||||
keyExtractor={(transfer) => `${transfer.token_address}-${transfer.from_address}-${transfer.to_address}-${transfer.amount}`}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Token transfers" />
|
||||
</Card> : null}
|
||||
|
||||
<Card title="Internal Transactions">
|
||||
{activeTab === 'internal' ? <Card title="Internal Transactions">
|
||||
<Table
|
||||
columns={internalCallColumns}
|
||||
data={internalCalls}
|
||||
data={pagedInternalCalls}
|
||||
emptyMessage="No internal transactions were exposed for this transaction."
|
||||
keyExtractor={(call) => `${call.from_address}-${call.to_address || call.contract_address || 'unknown'}-${call.value}-${call.type || 'call'}`}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls page={internalPage} pageCount={internalPageCount} onPageChange={setInternalPage} label="Internal calls" />
|
||||
</Card> : null}
|
||||
|
||||
{transaction.input_data && (
|
||||
{activeTab === 'raw' && transaction.input_data && (
|
||||
<Card title="Raw Input Data">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all rounded-lg bg-gray-50 p-4 text-xs text-gray-800 dark:bg-gray-950 dark:text-gray-200">
|
||||
{transaction.input_data}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function WalletRoutePage(props: WalletRoutePageProps) {
|
||||
export const getServerSideProps: GetServerSideProps<WalletRoutePageProps> = async () => {
|
||||
const [networksResult, tokenListResult, capabilitiesResult] = await Promise.all([
|
||||
fetchPublicJsonWithMeta<NetworksCatalog>('/api/config/networks').catch(() => null),
|
||||
fetchPublicJsonWithMeta<TokenListCatalog>('/api/config/token-list').catch(() => null),
|
||||
fetchPublicJsonWithMeta<TokenListCatalog>('/api/v1/report/token-list?chainId=138').catch(() => null),
|
||||
fetchPublicJsonWithMeta<CapabilitiesCatalog>('/api/config/capabilities').catch(() => null),
|
||||
])
|
||||
|
||||
|
||||
@@ -21,12 +21,33 @@ export interface AccessSession {
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export type InstitutionalTier =
|
||||
| 'sovereign_central_bank'
|
||||
| 'global_family_office'
|
||||
| 'settlement_member'
|
||||
| 'infrastructure_operator'
|
||||
| 'oversight_judicial'
|
||||
| 'delegated_authority'
|
||||
| 'standards_body'
|
||||
|
||||
export const institutionalTierLabels: Record<InstitutionalTier, string> = {
|
||||
sovereign_central_bank: 'Sovereign Central Bank',
|
||||
global_family_office: 'Global Family Office',
|
||||
settlement_member: 'Settlement Member',
|
||||
infrastructure_operator: 'Infrastructure Operator',
|
||||
oversight_judicial: 'Oversight & Judicial',
|
||||
delegated_authority: 'Delegated Authority',
|
||||
standards_body: 'Standards Body',
|
||||
}
|
||||
|
||||
export interface WalletAccessSession {
|
||||
token: string
|
||||
expiresAt: string
|
||||
track: string
|
||||
permissions: string[]
|
||||
address: string
|
||||
institutionalTier?: InstitutionalTier
|
||||
institutionName?: string
|
||||
}
|
||||
|
||||
export interface AccessProduct {
|
||||
@@ -145,8 +166,9 @@ function setStoredWalletSession(session: WalletAccessSession | null) {
|
||||
window.dispatchEvent(new Event(ACCESS_SESSION_EVENT))
|
||||
}
|
||||
|
||||
// Keep in sync with walletAuthSignMessage() in backend/auth/wallet_auth.go.
|
||||
function buildWalletMessage(nonce: string) {
|
||||
return `Sign this message to authenticate with SolaceScan.\n\nNonce: ${nonce}`
|
||||
return `Sign this message to authenticate with DBIS Explorer.\n\nNonce: ${nonce}`
|
||||
}
|
||||
|
||||
async function fetchWalletJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -220,6 +242,8 @@ export const accessApi = {
|
||||
expires_at: string
|
||||
track: string
|
||||
permissions: string[]
|
||||
institutional_tier?: InstitutionalTier
|
||||
institution_name?: string
|
||||
}>(`${ACCESS_API_PREFIX}/auth/wallet`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ address, signature, nonce }),
|
||||
@@ -230,6 +254,8 @@ export const accessApi = {
|
||||
track: response.track,
|
||||
permissions: response.permissions || [],
|
||||
address,
|
||||
institutionalTier: response.institutional_tier,
|
||||
institutionName: response.institution_name,
|
||||
}
|
||||
setStoredWalletSession(session)
|
||||
return session
|
||||
|
||||
29
frontend/src/services/api/bridgeRoutes.test.ts
Normal file
29
frontend/src/services/api/bridgeRoutes.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeBridgeRouteEntries, type BridgeRouteCatalog } from './bridgeRoutes'
|
||||
|
||||
describe('bridgeRoutesApi helpers', () => {
|
||||
it('normalizes WETH9 and WETH10 route tables', () => {
|
||||
const routes: BridgeRouteCatalog = {
|
||||
weth9: {
|
||||
'Ethereum Mainnet (1)': '0x1111111111111111111111111111111111111111',
|
||||
},
|
||||
weth10: {
|
||||
'Base (8453)': '0x2222222222222222222222222222222222222222',
|
||||
},
|
||||
}
|
||||
|
||||
expect(normalizeBridgeRouteEntries(routes)).toEqual([
|
||||
{
|
||||
bridge: 'WETH10',
|
||||
destination: 'Base (8453)',
|
||||
address: '0x2222222222222222222222222222222222222222',
|
||||
},
|
||||
{
|
||||
bridge: 'WETH9',
|
||||
destination: 'Ethereum Mainnet (1)',
|
||||
address: '0x1111111111111111111111111111111111111111',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user