Compare commits

...

25 Commits

Author SHA1 Message Date
defiQUG
2eae47b0d1 feat: bridge lane health API and dual-chain token list updates
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 13s
Validate Explorer / frontend (push) Failing after 19s
Validate Explorer / smoke-e2e (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 00:33:08 -07:00
defiQUG
228fa0eef6 Add bridge lane health API and config-ready lane UI for Tier A Week 3.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 13s
Validate Explorer / frontend (push) Failing after 21s
Validate Explorer / smoke-e2e (push) Has been skipped
Probe LINK balances on CCIP bridge contracts, expose proof-transfer metadata on bridge status, and render funded/unfunded lane health on /bridge with extended smoke coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 04:21:44 -07:00
defiQUG
763ca75c21 Ship Tier A Week 1–2: posture glossary, delivery mode, freshness UI, canonical tokens.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 13s
Validate Explorer / frontend (push) Failing after 18s
Validate Explorer / smoke-e2e (push) Has been skipped
Expose mission-control mode on home/bridge/analytics, quiet-chain freshness copy, and a canonical-first indexed token list with WETH9 metadata override and non-canonical warnings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 03:48:22 -07:00
defiQUG
ab9c1f9f98 Ship bridge lanes, public API access doc, and WalletConnect client stack.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 15s
Validate Explorer / frontend (push) Failing after 20s
Validate Explorer / smoke-e2e (push) Has been skipped
Align CCIP catalog UX with 11-lane config-ready routes, document the no-key public API decision, and enable browser WalletConnect pairing with backend session registration and deploy-time project ID wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 02:21:37 -07:00
defiQUG
efd7c8bbcb Complete UX audit P3: API copy URLs, labels, retry, and smoke sync.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 13s
Validate Explorer / frontend (push) Successful in 1m25s
Validate Explorer / smoke-e2e (push) Failing after 2m46s
Add footer copy-to-clipboard for public APIs, align ops page labels, improve mobile brand lockup, surface WalletConnect posture on wallet tools, add account access discovery, liquidity retry alerts, and refresh smoke-route expectations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 22:54:08 -07:00
defiQUG
4fac5e4856 Fix UX audit gaps: tablet nav, footer, wallet connect, legacy demotion.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 13s
Validate Explorer / frontend (push) Successful in 1m29s
Validate Explorer / smoke-e2e (push) Failing after 2m27s
Close the 1024–1279px nav dead zone, align ops/footer labels, split homepage quick links, route successful wallet connect to /wallet with inline errors, add WETH to ops sub-nav, and demote legacy SPA with noindex plus banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 22:30:35 -07:00
defiQUG
b213c6547d Add wallet auth smoke e2e and include WalletConnect in parity checks.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 15s
Validate Explorer / frontend (push) Successful in 1m29s
Validate Explorer / smoke-e2e (push) Failing after 2m21s
Live API check confirms walletconnect config; dual-domain verifier now covers the deployed endpoint by default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 22:06:15 -07:00
defiQUG
567b4647c0 Fix wallet connect signature mismatch on mobile and desktop.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Validate Explorer / frontend (push) Successful in 1m26s
Validate Explorer / smoke-e2e (push) Failing after 2m19s
Align backend EIP-191 auth message with the DBIS Explorer text the frontend and legacy SPA already sign, instead of the stale SolaceScan string.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 22:01:11 -07:00
defiQUG
8a61b1bde2 Make WalletConnect parity check opt-in until backend deploy.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 15s
Default dual-domain verifier skips walletconnect/config; set INCLUDE_WALLETCONNECT=1 after backend rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 21:56:38 -07:00
defiQUG
f2ebe824bd Add WalletConnect stub, track surfaces, legacy SPA retirement, and dual-domain checks.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Validate Explorer / frontend (push) Successful in 1m27s
Validate Explorer / smoke-e2e (push) Failing after 2m19s
Publish walletconnect config endpoints, Track 3/4 notes on analytics/operator pages, legacy SPA at /legacy/index.html with root redirect, and a parity verifier for explorer.d-bis.org vs blockscout.defi-oracle.io.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 21:55:42 -07:00
defiQUG
991d1bb07c Add mobile ops surface nav and footer public API links.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Validate Explorer / frontend (push) Successful in 1m32s
Validate Explorer / smoke-e2e (push) Failing after 1m52s
Operations pages get collapsible surface navigation on small screens and a shared action-card accordion; the footer surfaces read-only JSON endpoints with e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 21:39:08 -07:00
defiQUG
847cfeb48b feat(explorer): API-driven CCIP route catalog on bridge page
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Validate Explorer / frontend (push) Successful in 1m35s
Validate Explorer / smoke-e2e (push) Failing after 1m38s
Load destination bridge contracts from token-aggregation, add fallback polling,
extend smoke tests, and document bridge routes client helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 21:12:21 -07:00
defiQUG
6a64d2fec6 fix(explorer): harden operations smoke test and surface note placement
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 13s
Validate Explorer / frontend (push) Successful in 1m26s
Validate Explorer / smoke-e2e (push) Failing after 1m29s
Move extended token-list label to the operations intro, wait for network idle
before asserting, and clear conflicting NO_COLOR/FORCE_COLOR in Playwright config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:53:27 -07:00
defiQUG
7a7dfca221 feat(explorer): mission-control resilience, ops token labels, and CI validate
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Validate Explorer / frontend (push) Successful in 1m34s
Validate Explorer / smoke-e2e (push) Failing after 1m26s
Add SSE reconnect with backoff, fallback REST polling, visibility-aware refresh,
extended token-list labels on operations pages, validate-on-pr workflow, and smoke coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:40:11 -07:00
defiQUG
e3ec87c324 feat(explorer): token-list surfaces, homepage trim, and sprint smoke tests
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 14s
Unify wallet/catalog/extended token-list policy, add contract verification CTA,
trim the homepage dashboard with status strip and recent activity, and add Playwright smoke coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:22:45 -07:00
defiQUG
0778c18e59 fix(explorer): read SSE stream until event and data lines arrive
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 15s
The health check stopped after two non-empty lines and missed the data line that follows event: ping on mission-control streams.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 18:05:47 -07:00
defiQUG
4b747f0309 feat(explorer): dynamic feeds, wallet SSR alignment, and detail pagination
Align wallet SSR with report token-list, dedupe featured v1 tokens, refresh home and wallet snapshots on a 60s cadence, and drive vanilla SPA chain add/watch from API metadata. Add shared pagination/tabs for address, token, and transaction pages, extend token aggregation helpers, and harden stats API with tests and health checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 17:58:27 -07:00
defiQUG
ca1394c579 chore(explorer): run vitest in npm test
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 17s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 21:41:25 -07:00
defiQUG
e14b43e3fe test(explorer): expect recentTransactionTrend in loadDashboardData fixtures
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 17s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 21:40:44 -07:00
defiQUG
64e78dad47 feat(explorer): token signing surface card, ERC-5267 domain read, tabular top holders
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 15s
- Add TokenSigningSurfaceCard: ABI flags, eip712Domain eth_call decode, verification metadata
- Pass contract profile into GRU standards detection on token page
- Table layout=tabular for Top Holders column layout at all breakpoints
- Fallback provenance name/symbol; show signing card when token API empty
- eip712Domain.ts: decode ERC-5267 tuple return data

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 21:09:40 -07:00
defiQUG
654933cb36 fix(explorer): normalize token market liquidityUsd client-side
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 13s
- Mirror token-aggregation liquidity scaling in tokenAggregation API layer
- Tokens page and shared brand/layout tweaks
- deploy-live workflow adjustment

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 12:55:08 -07:00
defiQUG
d4f922c26e chore: metamask networks, explorer SPA, nginx scripts; ignore Python cache
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 12s
- Dual-chain / GRU deployment JSON sync
- Frontend explorer SPA + MetaMask components
- Scripts: nginx fixes, link deploy, local SPA serve helper
- Token icon chain-138.png; .gitignore __pycache__

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 13:00:43 -07:00
e5df7c2ea3 Merge pull request 'feat: institutional membership tiers and corrected member directory' (#16) from devin/1778358341-institutional-membership-tiers into master
All checks were successful
phoenix-deploy Deployed to explorer-live
Deploy Explorer Live / deploy (push) Successful in 2m58s
2026-05-09 21:01:16 +00:00
Devin AI
9e17ed8ceb fix: remove BIS Innovation Hub from member directory
Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-05-09 20:48:21 +00:00
Devin AI
55a209646a feat: add institutional membership tiers and correct member directory
Corrections per 2026-04 institutional review:
- MLFO reclassified as Global Family Office (was incorrectly labeled central bank)
- BIS Innovation Hub reclassified as Standards Body (does not hold observer seat)
- Added missing entities: ICCC, SAID, PANDA, Order of Hospitallers (XOM)
- Added BRICS founding + expanded member central banks (10 entries)

New institutional tier taxonomy (7 tiers):
  sovereign_central_bank, global_family_office, settlement_member,
  infrastructure_operator, oversight_judicial, delegated_authority,
  standards_body

Backend changes:
- New auth/membership.go: tier types, DefaultTrackForTier mapping,
  MembershipStore with DB queries for member directory
- New migration 0017: institutional_members + institutional_member_wallets
  tables with seed data for all corrected members
- Updated wallet_auth.go getUserTrack(): now resolves institutional
  membership (via wallet junction table) before defaulting to Track 1
- WalletAuthResponse now includes institutional_tier and institution_name
- New REST endpoints: GET /api/v1/membership/{tiers,members,members/:slug}
- Added TrackLabel() helper in featureflags

Frontend changes:
- Added InstitutionalTier type and label map to access.ts
- WalletAccessSession extended with institutionalTier/institutionName
- Navbar getAccessTier() now displays institutional tier label when present
- Session summary shows institution name

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-05-09 20:32:06 +00:00
131 changed files with 13152 additions and 2798 deletions

View File

@@ -37,7 +37,8 @@ jobs:
if [ -z "$BRANCH" ] || [ "$BRANCH" = "HEAD" ]; then
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
fi
curl -sSf -X POST "${{ secrets.PHOENIX_DEPLOY_URL }}" \
curl -sSf --connect-timeout 10 --max-time 3600 \
-X POST "${{ secrets.PHOENIX_DEPLOY_URL }}" \
-H "Authorization: Bearer ${{ secrets.PHOENIX_DEPLOY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"repo\":\"${{ gitea.repository }}\",\"sha\":\"${SHA}\",\"branch\":\"${BRANCH}\",\"target\":\"explorer-live\"}"

View 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
View File

@@ -55,6 +55,10 @@ backend/bin/
backend/api/rest/cmd/api-server
backend/cmd
# Python
__pycache__/
*.py[cod]
# Tooling / scratch directories
out/
cache/

View File

@@ -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",
"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"]},

View File

@@ -1666,8 +1666,23 @@
"tags": [
"gru",
"compliant",
"electronic-money"
]
"electronic-money",
"fiat",
"cash"
],
"extensions": {
"assetClass": "Cash & Equivalents",
"assetGroup": "MMF / Repo",
"instrumentType": "eMoney",
"underlying": "USD",
"gruLayer": "M1",
"rwaEligible": false,
"category": "gru-emoney",
"currency": "USD",
"settlement": "fiat",
"cashLike": true,
"backing": "cash,cash-equivalents"
}
},
{
"chainId": 138,
@@ -1679,8 +1694,23 @@
"tags": [
"gru",
"compliant",
"treasury-bond"
]
"treasury-bond",
"fiat",
"cash"
],
"extensions": {
"assetClass": "Cash & Equivalents",
"assetGroup": "MMF / Repo",
"instrumentType": "eMoney",
"underlying": "USD",
"gruLayer": "M1",
"rwaEligible": false,
"category": "gru-emoney",
"currency": "USD",
"settlement": "fiat",
"cashLike": true,
"backing": "cash,cash-equivalents"
}
},
{
"chainId": 138,
@@ -1691,12 +1721,19 @@
"logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png",
"tags": [
"gru",
"compliant",
"commodity"
"compliant"
],
"extensions": {
"unitOfAccount": "troy_ounce",
"unitDescription": "1 full token (10^decimals base units) = 1 troy oz fine gold"
"unitDescription": "1 full token (10^decimals base units) = 1 troy oz fine gold",
"assetClass": "Commodities",
"assetGroup": "Precious Metals",
"instrumentType": "eMoney",
"underlying": "Gold",
"gruLayer": "M1",
"rwaEligible": false,
"category": "gru-emoney",
"cashLike": false
}
},
{
@@ -1708,12 +1745,19 @@
"logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png",
"tags": [
"gru",
"compliant",
"commodity"
"compliant"
],
"extensions": {
"unitOfAccount": "troy_ounce",
"unitDescription": "1 full token (10^decimals base units) = 1 troy oz fine gold"
"unitDescription": "1 full token (10^decimals base units) = 1 troy oz fine gold",
"assetClass": "Commodities",
"assetGroup": "Precious Metals",
"instrumentType": "eMoney",
"underlying": "Gold",
"gruLayer": "M1",
"rwaEligible": false,
"category": "gru-emoney",
"cashLike": false
}
},
{

View File

@@ -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": [

View 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,
})
}

View File

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

View File

@@ -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")

View File

@@ -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")
}

View File

@@ -0,0 +1,136 @@
package rest
import (
"encoding/json"
"net/http"
"strings"
"github.com/explorer/backend/wallet"
)
func (s *Server) walletConnectHandler() *wallet.WalletConnect {
return wallet.NewWalletConnect(s.chainID)
}
// handleWalletConnectConfig handles GET /api/v1/walletconnect/config
func (s *Server) handleWalletConnectConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
writeJSON(w, http.StatusOK, s.walletConnectHandler().PublicConfig())
}
// handleWalletConnectMetadata handles GET /api/v1/walletconnect/metadata
func (s *Server) handleWalletConnectMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"name": "DBIS Explorer",
"description": "Chain 138 explorer by DBIS",
"url": "https://explorer.d-bis.org",
"icons": []string{"https://explorer.d-bis.org/favicon.ico"},
})
}
// handleWalletConnectConnect handles POST /api/v1/walletconnect/connect
func (s *Server) handleWalletConnectConnect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
response, err := s.walletConnectHandler().Connect(r.Context())
if err != nil {
writeJSON(w, http.StatusNotImplemented, response)
return
}
writeJSON(w, http.StatusOK, response)
}
// handleWalletConnectSessionRegister handles POST /api/v1/walletconnect/session
func (s *Server) handleWalletConnectSessionRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
var req struct {
SessionID string `json:"sessionId"`
Address string `json:"address"`
ChainID int `json:"chainId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
return
}
session, err := s.walletConnectHandler().RegisterSession(r.Context(), req.SessionID, req.Address, req.ChainID)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeJSON(w, http.StatusOK, session)
}
// handleWalletConnectSession handles GET /api/v1/walletconnect/session/{id}
func (s *Server) handleWalletConnectSession(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
sessionID := strings.TrimPrefix(r.URL.Path, "/api/v1/walletconnect/session/")
sessionID = strings.Trim(sessionID, "/")
if sessionID == "" || strings.Contains(sessionID, "/") {
writeError(w, http.StatusBadRequest, "bad_request", "session id is required")
return
}
session, err := s.walletConnectHandler().GetSession(r.Context(), sessionID)
if err != nil {
writeJSON(w, http.StatusNotFound, session)
return
}
writeJSON(w, http.StatusOK, session)
}
// handleWalletConnectRoot dispatches walletconnect subroutes.
func (s *Server) handleWalletConnectRoot(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/walletconnect")
path = strings.Trim(path, "/")
switch path {
case "config":
s.handleWalletConnectConfig(w, r)
case "metadata":
s.handleWalletConnectMetadata(w, r)
case "connect":
s.handleWalletConnectConnect(w, r)
case "session":
s.handleWalletConnectSessionRegister(w, r)
case "":
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"endpoints": []string{
"/api/v1/walletconnect/config",
"/api/v1/walletconnect/metadata",
"/api/v1/walletconnect/connect",
"POST /api/v1/walletconnect/session",
"/api/v1/walletconnect/session/{sessionId}",
},
"fallbackAuth": "/api/v1/auth/wallet",
})
default:
if strings.HasPrefix(path, "session/") {
s.handleWalletConnectSession(w, r)
return
}
writeError(w, http.StatusNotFound, "not_found", "walletconnect route not found")
}
}

View File

@@ -0,0 +1,113 @@
package rest
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestHandleWalletConnectConfig(t *testing.T) {
t.Setenv("WALLETCONNECT_PROJECT_ID", "test-project-id")
server := NewServer(nil, 138)
req := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect/config", nil)
rec := httptest.NewRecorder()
server.handleWalletConnectConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var payload map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["projectId"] != "test-project-id" {
t.Fatalf("expected project id, got %#v", payload["projectId"])
}
if payload["fallbackAuth"] != "/api/v1/auth/wallet" {
t.Fatalf("expected fallback auth path, got %#v", payload["fallbackAuth"])
}
}
func TestHandleWalletConnectConnectDisabled(t *testing.T) {
t.Setenv("WALLETCONNECT_PROJECT_ID", "")
server := NewServer(nil, 138)
req := httptest.NewRequest(http.MethodPost, "/api/v1/walletconnect/connect", strings.NewReader("{}"))
rec := httptest.NewRecorder()
server.handleWalletConnectConnect(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("expected 501, got %d", rec.Code)
}
}
func TestHandleWalletConnectConnectClientMode(t *testing.T) {
t.Setenv("WALLETCONNECT_PROJECT_ID", "test-project-id")
server := NewServer(nil, 138)
req := httptest.NewRequest(http.MethodPost, "/api/v1/walletconnect/connect", strings.NewReader("{}"))
rec := httptest.NewRecorder()
server.handleWalletConnectConnect(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var payload map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["status"] != "client" {
t.Fatalf("expected client status, got %#v", payload["status"])
}
}
func TestHandleWalletConnectSessionMissing(t *testing.T) {
server := NewServer(nil, 138)
req := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect/session/demo-session", nil)
rec := httptest.NewRecorder()
server.handleWalletConnectSession(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
func TestHandleWalletConnectSessionRegister(t *testing.T) {
server := NewServer(nil, 138)
body := strings.NewReader(`{"sessionId":"wc-demo","address":"0x4A666F96fC8764181194447A7dFdb7d471b301C8","chainId":138}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/walletconnect/session", body)
rec := httptest.NewRecorder()
server.handleWalletConnectSessionRegister(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect/session/wc-demo", nil)
getRec := httptest.NewRecorder()
server.handleWalletConnectSession(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("expected lookup 200, got %d", getRec.Code)
}
}
func TestHandleWalletConnectRootIndex(t *testing.T) {
_ = os.Setenv("WALLETCONNECT_PROJECT_ID", "")
server := NewServer(nil, 138)
req := httptest.NewRequest(http.MethodGet, "/api/v1/walletconnect", nil)
rec := httptest.NewRecorder()
server.handleWalletConnectRoot(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
}

View File

@@ -0,0 +1,255 @@
package track1
import (
"context"
_ "embed"
"encoding/json"
"math/big"
"net/http"
"os"
"strconv"
"strings"
"time"
)
//go:embed bridge_lanes_default.json
var defaultBridgeLanesJSON []byte
type bridgeLaneDefinition struct {
Key string `json:"key"`
ChainName string `json:"chain_name"`
ChainID int64 `json:"chain_id"`
ConfigReady bool `json:"config_ready"`
RPCEnvs []string `json:"rpc_envs"`
RPCDefault string `json:"rpc_default"`
LinkToken string `json:"link_token"`
WETH9Bridge string `json:"weth9_bridge"`
WETH10Bridge string `json:"weth10_bridge"`
}
type bridgeLanesConfig struct {
Updated string `json:"updated"`
MinLinkWei string `json:"min_link_wei"`
Lanes []bridgeLaneDefinition `json:"lanes"`
}
func loadBridgeLanesConfig() bridgeLanesConfig {
path := strings.TrimSpace(os.Getenv("MISSION_CONTROL_BRIDGE_LANES_JSON"))
if path != "" {
if b, err := os.ReadFile(path); err == nil && len(b) > 0 {
var cfg bridgeLanesConfig
if json.Unmarshal(b, &cfg) == nil && len(cfg.Lanes) > 0 {
return cfg
}
}
}
var cfg bridgeLanesConfig
_ = json.Unmarshal(defaultBridgeLanesJSON, &cfg)
return cfg
}
func resolveLaneRPC(def bridgeLaneDefinition) string {
for _, key := range def.RPCEnvs {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
}
for _, row := range ParseExtraRPCProbes() {
chainKey := row[2]
if chainKey == strconv.FormatInt(def.ChainID, 10) {
return row[1]
}
}
return strings.TrimSpace(def.RPCDefault)
}
func erc20BalanceOf(ctx context.Context, rpcURL, tokenAddress, holderAddress string) (string, error) {
tokenAddress = strings.ToLower(strings.TrimSpace(tokenAddress))
holderAddress = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(holderAddress), "0x"))
if len(holderAddress) != 40 {
return "0", nil
}
data := "0x70a08231" + strings.Repeat("0", 24) + holderAddress
raw, _, err := postJSONRPC(ctx, bridgeLaneHTTPClient(), rpcURL, "eth_call", []interface{}{
map[string]interface{}{
"to": tokenAddress,
"data": data,
},
"latest",
})
if err != nil {
return "", err
}
var hex string
if err := json.Unmarshal(raw, &hex); err != nil {
return "", err
}
hex = strings.TrimSpace(hex)
if hex == "" || hex == "0x" {
return "0", nil
}
value := new(big.Int)
if _, ok := value.SetString(strings.TrimPrefix(hex, "0x"), 16); !ok {
return "0", nil
}
return value.String(), nil
}
func bridgeLaneHTTPClient() *http.Client {
return &http.Client{Timeout: 6 * time.Second}
}
func bridgeFundingStatus(linkBalanceWei, minLinkWei string) string {
balance, okBalance := new(big.Int).SetString(strings.TrimSpace(linkBalanceWei), 10)
minimum, okMin := new(big.Int).SetString(strings.TrimSpace(minLinkWei), 10)
if !okBalance || !okMin {
return "unknown"
}
if balance.Cmp(minimum) >= 0 {
return "funded"
}
if balance.Sign() > 0 {
return "degraded"
}
return "unfunded"
}
func proofStatusForLane(key string, proofs map[string]interface{}) string {
if proofs == nil {
return "proof-pending"
}
laneProofs, ok := proofs[key].([]interface{})
if !ok || len(laneProofs) == 0 {
return "proof-pending"
}
for _, item := range laneProofs {
row, ok := item.(map[string]interface{})
if !ok {
continue
}
if tx, ok := row["tx_hash"].(string); ok && strings.TrimSpace(tx) != "" {
return "proof-recorded"
}
}
return "proof-pending"
}
func readProofTransfersJSON() map[string]interface{} {
path := strings.TrimSpace(os.Getenv("MISSION_CONTROL_PROOF_TRANSFERS_JSON"))
if path == "" {
return nil
}
b, err := os.ReadFile(path)
if err != nil || len(b) == 0 {
return map[string]interface{}{"error": "unreadable or empty", "path": path}
}
if len(b) > 512*1024 {
return map[string]interface{}{"error": "file too large", "path": path}
}
var payload map[string]interface{}
if err := json.Unmarshal(b, &payload); err != nil {
return map[string]interface{}{"error": err.Error(), "path": path}
}
return payload
}
func probeBridgeContract(ctx context.Context, rpcURL, linkToken, bridgeAddress, minLinkWei string) map[string]interface{} {
result := map[string]interface{}{
"bridge": strings.TrimSpace(bridgeAddress),
}
if rpcURL == "" {
result["status"] = "unknown"
result["error"] = "rpc unavailable"
return result
}
if linkToken == "" || bridgeAddress == "" {
result["status"] = "unknown"
result["error"] = "missing link token or bridge address"
return result
}
balance, err := erc20BalanceOf(ctx, rpcURL, linkToken, bridgeAddress)
if err != nil {
result["status"] = "unknown"
result["error"] = err.Error()
return result
}
result["link_balance_wei"] = balance
result["status"] = bridgeFundingStatus(balance, minLinkWei)
return result
}
func aggregateLaneStatus(weth9Status, weth10Status, proofStatus string) string {
statuses := []string{weth9Status, weth10Status}
hasUnfunded := false
hasDegraded := false
hasUnknown := false
for _, status := range statuses {
switch status {
case "unfunded":
hasUnfunded = true
case "degraded":
hasDegraded = true
case "unknown":
hasUnknown = true
}
}
if hasUnfunded {
return "unfunded"
}
if hasDegraded {
return "degraded"
}
if hasUnknown {
return "unknown"
}
if proofStatus == "proof-pending" {
return "proof-pending"
}
return "funded"
}
func BuildBridgeLaneHealth(ctx context.Context) (map[string]interface{}, map[string]interface{}) {
cfg := loadBridgeLanesConfig()
minLinkWei := strings.TrimSpace(cfg.MinLinkWei)
if minLinkWei == "" {
minLinkWei = "1000000000000000000"
}
proofPayload := readProofTransfersJSON()
proofByLane := map[string]interface{}{}
if proofPayload != nil {
if lanes, ok := proofPayload["lanes"].(map[string]interface{}); ok {
proofByLane = lanes
}
}
lanes := make([]map[string]interface{}, 0, len(cfg.Lanes))
for _, def := range cfg.Lanes {
rpcURL := resolveLaneRPC(def)
weth9 := probeBridgeContract(ctx, rpcURL, def.LinkToken, def.WETH9Bridge, minLinkWei)
weth10 := probeBridgeContract(ctx, rpcURL, def.LinkToken, def.WETH10Bridge, minLinkWei)
weth9Status, _ := weth9["status"].(string)
weth10Status, _ := weth10["status"].(string)
proofStatus := proofStatusForLane(def.Key, proofByLane)
lanes = append(lanes, map[string]interface{}{
"key": def.Key,
"chain_name": def.ChainName,
"chain_id": def.ChainID,
"config_ready": def.ConfigReady,
"link_token": def.LinkToken,
"status": aggregateLaneStatus(weth9Status, weth10Status, proofStatus),
"proof_status": proofStatus,
"weth9": weth9,
"weth10": weth10,
"rpc_endpoint": redactRPCOrigin(rpcURL),
})
}
laneHealth := map[string]interface{}{
"updated_at": time.Now().UTC().Format(time.RFC3339),
"min_link_wei": minLinkWei,
"lanes": lanes,
}
return laneHealth, proofPayload
}

View File

@@ -0,0 +1,61 @@
{
"updated": "2026-05-23",
"min_link_wei": "1000000000000000000",
"lanes": [
{
"key": "chain138",
"chain_name": "Defi Oracle Meta Mainnet (138)",
"chain_id": 138,
"config_ready": true,
"rpc_envs": ["RPC_URL", "RPC_URL_138"],
"rpc_default": "http://192.168.11.211:8545",
"link_token": "0xb7721dd53a8c629d9f1ba31a5819afe250002b03",
"weth9_bridge": "0xcacfd227A040002e49e2e01626363071324f820a",
"weth10_bridge": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0"
},
{
"key": "gnosis",
"chain_name": "Gnosis (100)",
"chain_id": 100,
"config_ready": true,
"rpc_envs": ["GNOSIS_RPC", "GNOSIS_MAINNET_RPC", "GNOSIS_RPC_URL"],
"rpc_default": "https://rpc.gnosischain.com",
"link_token": "0xE2e73A1c69ecF83F464EFCE6A5be353a37cA09b2",
"weth9_bridge": "0xc8656F24488cb90c452058da92d1a25BA464eaAE",
"weth10_bridge": "0xa846aeAD3071df1b6439d5D813156aCE7C2c1DA1"
},
{
"key": "cronos",
"chain_name": "Cronos (25)",
"chain_id": 25,
"config_ready": true,
"rpc_envs": ["CRONOS_RPC", "CRONOS_RPC_URL", "CRONOS_MAINNET_RPC"],
"rpc_default": "https://evm.cronos.org",
"link_token": "0x8c80A01F461f297Df7F9DA3A4f740D7297C8Ac85",
"weth9_bridge": "0x3Cc23d086fCcbAe1e5f3FE2bA4A263E1D27d8Cab",
"weth10_bridge": "0x105F8A15b819948a89153505762444Ee9f324684"
},
{
"key": "celo",
"chain_name": "Celo (42220)",
"chain_id": 42220,
"config_ready": true,
"rpc_envs": ["CELO_RPC", "CELO_MAINNET_RPC"],
"rpc_default": "https://forno.celo.org",
"link_token": "0xd07294e6E917e07dfDcee882dd1e2565085C2ae0",
"weth9_bridge": "0xAb57BF30F1354CA0590af22D8974c7f24DB2DbD7",
"weth10_bridge": "0xa780ef19A041745d353c9432f2a7f5A241335ffE"
},
{
"key": "wemix",
"chain_name": "Wemix (1111)",
"chain_id": 1111,
"config_ready": true,
"rpc_envs": ["WEMIX_RPC", "WEMIX_MAINNET_RPC"],
"rpc_default": "https://api.wemix.com",
"link_token": "0x80f1FcdC96B55e459BF52b998aBBE2c364935d69",
"weth9_bridge": "0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04",
"weth10_bridge": "0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08"
}
]
}

View File

@@ -0,0 +1,37 @@
package track1
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestBridgeFundingStatus(t *testing.T) {
require.Equal(t, "funded", bridgeFundingStatus("2000000000000000000", "1000000000000000000"))
require.Equal(t, "degraded", bridgeFundingStatus("500000000000000000", "1000000000000000000"))
require.Equal(t, "unfunded", bridgeFundingStatus("0", "1000000000000000000"))
}
func TestAggregateLaneStatus(t *testing.T) {
require.Equal(t, "unfunded", aggregateLaneStatus("unfunded", "funded", "proof-recorded"))
require.Equal(t, "degraded", aggregateLaneStatus("degraded", "funded", "proof-recorded"))
require.Equal(t, "proof-pending", aggregateLaneStatus("funded", "funded", "proof-pending"))
require.Equal(t, "funded", aggregateLaneStatus("funded", "funded", "proof-recorded"))
}
func TestProofStatusForLane(t *testing.T) {
proofs := map[string]interface{}{
"gnosis": []interface{}{
map[string]interface{}{"tx_hash": "0xabc"},
},
}
require.Equal(t, "proof-recorded", proofStatusForLane("gnosis", proofs))
require.Equal(t, "proof-pending", proofStatusForLane("cronos", proofs))
}
func TestLoadBridgeLanesConfigDefault(t *testing.T) {
t.Setenv("MISSION_CONTROL_BRIDGE_LANES_JSON", "")
cfg := loadBridgeLanesConfig()
require.NotEmpty(t, cfg.Lanes)
require.NotEmpty(t, cfg.MinLinkWei)
}

View File

@@ -0,0 +1,78 @@
package track1
import (
"strings"
"github.com/explorer/backend/api/freshness"
)
type bridgeDeliveryMode struct {
Kind string
Reason any
Scope any
}
func resolveBridgeDeliveryMode(hasRelays bool, diagnostics *freshness.Diagnostics, txFeed freshness.Completeness) bridgeDeliveryMode {
if !hasRelays {
if diagnostics != nil && isStaleTransactionVisibility(diagnostics) {
return bridgeDeliveryMode{
Kind: "mixed",
Reason: "partial_observability_inputs",
Scope: "homepage_summary_only",
}
}
return bridgeDeliveryMode{
Kind: "live",
Reason: nil,
Scope: nil,
}
}
if diagnostics != nil && isStaleTransactionVisibility(diagnostics) {
return bridgeDeliveryMode{
Kind: "mixed",
Reason: "relay_snapshot_only_source",
Scope: "bridge_monitoring_and_homepage",
}
}
if txFeed == freshness.CompletenessPartial || txFeed == freshness.CompletenessStale {
return bridgeDeliveryMode{
Kind: "mixed",
Reason: "partial_observability_inputs",
Scope: "bridge_monitoring_and_homepage",
}
}
return bridgeDeliveryMode{
Kind: "snapshot",
Reason: "live_homepage_stream_not_attached",
Scope: "relay_monitoring_homepage_card_only",
}
}
func isStaleTransactionVisibility(diagnostics *freshness.Diagnostics) bool {
if diagnostics == nil {
return false
}
state := strings.ToLower(strings.TrimSpace(diagnostics.ActivityState))
switch state {
case "fresh_head_stale_transaction_visibility", "lagging", "stale_transaction_visibility":
return true
default:
return strings.Contains(state, "stale") && strings.Contains(state, "transaction")
}
}
func buildBridgeModePayload(now string, resolved bridgeDeliveryMode) map[string]interface{} {
return map[string]interface{}{
"kind": resolved.Kind,
"updated_at": now,
"age_seconds": int64(0),
"reason": resolved.Reason,
"scope": resolved.Scope,
"source": freshness.SourceReported,
"confidence": freshness.ConfidenceHigh,
"provenance": freshness.ProvenanceMissionFeed,
}
}

View File

@@ -0,0 +1,44 @@
package track1
import (
"testing"
"github.com/explorer/backend/api/freshness"
"github.com/stretchr/testify/require"
)
func TestResolveBridgeDeliveryModeLiveWithoutRelays(t *testing.T) {
got := resolveBridgeDeliveryMode(false, nil, freshness.CompletenessComplete)
require.Equal(t, "live", got.Kind)
require.Nil(t, got.Reason)
}
func TestResolveBridgeDeliveryModeSnapshotWithRelays(t *testing.T) {
got := resolveBridgeDeliveryMode(true, nil, freshness.CompletenessComplete)
require.Equal(t, "snapshot", got.Kind)
require.Equal(t, "live_homepage_stream_not_attached", got.Reason)
require.Equal(t, "relay_monitoring_homepage_card_only", got.Scope)
}
func TestResolveBridgeDeliveryModeMixedWhenTransactionVisibilityStale(t *testing.T) {
diagnostics := &freshness.Diagnostics{
ActivityState: "fresh_head_stale_transaction_visibility",
}
got := resolveBridgeDeliveryMode(true, diagnostics, freshness.CompletenessPartial)
require.Equal(t, "mixed", got.Kind)
require.Equal(t, "relay_snapshot_only_source", got.Reason)
require.Equal(t, "bridge_monitoring_and_homepage", got.Scope)
}
func TestResolveBridgeDeliveryModeMixedWhenQuietChain(t *testing.T) {
diagnostics := &freshness.Diagnostics{
ActivityState: "quiet_chain",
}
got := resolveBridgeDeliveryMode(false, diagnostics, freshness.CompletenessComplete)
require.Equal(t, "live", got.Kind)
}
func TestIsStaleTransactionVisibility(t *testing.T) {
require.True(t, isStaleTransactionVisibility(&freshness.Diagnostics{ActivityState: "fresh_head_stale_transaction_visibility"}))
require.False(t, isStaleTransactionVisibility(&freshness.Diagnostics{ActivityState: "healthy"}))
}

View File

@@ -133,6 +133,8 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
}
if s.freshnessLoader != nil {
if snapshot, completeness, sampling, diagnostics, err := s.freshnessLoader(ctx); err == nil && snapshot != nil {
txFeed := completeness.TransactionsFeed
resolvedMode := resolveBridgeDeliveryMode(false, diagnostics, txFeed)
subsystems := map[string]interface{}{
"rpc_head": map[string]interface{}{
"status": chainStatusFromProbe(p138),
@@ -174,39 +176,13 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
"issues": sampling.Issues,
}
}
modeKind := "live"
modeReason := any(nil)
modeScope := any(nil)
if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
modeKind = "snapshot"
modeReason = "live_homepage_stream_not_attached"
modeScope = "relay_monitoring_homepage_card_only"
subsystems["bridge_relay_monitoring"] = map[string]interface{}{
"status": overall,
"updated_at": now,
"age_seconds": int64(0),
"source": freshness.SourceReported,
"confidence": freshness.ConfidenceHigh,
"provenance": freshness.ProvenanceMissionFeed,
"completeness": freshness.CompletenessComplete,
}
}
data["freshness"] = snapshot
data["subsystems"] = subsystems
data["sampling"] = sampling
if diagnostics != nil {
data["diagnostics"] = diagnostics
}
data["mode"] = map[string]interface{}{
"kind": modeKind,
"updated_at": now,
"age_seconds": int64(0),
"reason": modeReason,
"scope": modeScope,
"source": freshness.SourceReported,
"confidence": freshness.ConfidenceHigh,
"provenance": freshness.ProvenanceMissionFeed,
}
data["mode"] = buildBridgeModePayload(now, resolvedMode)
}
}
if relays := FetchCCIPRelayHealths(ctx); relays != nil {
@@ -222,11 +198,30 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
}
}
}
if laneHealth, proofTransfers := BuildBridgeLaneHealth(ctx); laneHealth != nil {
data["bridge_lanes"] = laneHealth
if proofTransfers != nil {
data["proof_transfers"] = proofTransfers
}
}
if mode, ok := data["mode"].(map[string]interface{}); ok {
if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
mode["kind"] = "snapshot"
mode["reason"] = "live_homepage_stream_not_attached"
mode["scope"] = "relay_monitoring_homepage_card_only"
var diagnostics *freshness.Diagnostics
if diag, ok := data["diagnostics"].(*freshness.Diagnostics); ok {
diagnostics = diag
}
txFeed := freshness.CompletenessUnavailable
if subsystems, ok := data["subsystems"].(map[string]interface{}); ok {
if txIndex, ok := subsystems["tx_index"].(map[string]interface{}); ok {
if feed, ok := txIndex["completeness"].(freshness.Completeness); ok {
txFeed = feed
}
}
}
resolved := resolveBridgeDeliveryMode(true, diagnostics, txFeed)
mode["kind"] = resolved.Kind
mode["reason"] = resolved.Reason
mode["scope"] = resolved.Scope
if subsystems, ok := data["subsystems"].(map[string]interface{}); ok {
subsystems["bridge_relay_monitoring"] = map[string]interface{}{
"status": data["status"],
@@ -239,6 +234,9 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
}
}
}
} else if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
resolved := resolveBridgeDeliveryMode(true, nil, freshness.CompletenessUnavailable)
data["mode"] = buildBridgeModePayload(now, resolved)
}
return data
}

View File

@@ -212,6 +212,11 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
require.Contains(t, got, "diagnostics")
require.Contains(t, got, "subsystems")
require.Contains(t, got, "mode")
mode, ok := got["mode"].(map[string]interface{})
require.True(t, ok)
require.Equal(t, "mixed", mode["kind"])
require.Equal(t, "relay_snapshot_only_source", mode["reason"])
require.Equal(t, "bridge_monitoring_and_homepage", mode["scope"])
}
func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) {

213
backend/auth/membership.go Normal file
View 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
}

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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 @@
]
}
]
}
}

View File

@@ -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;

View 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;

View File

@@ -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"
}
}

View File

@@ -3,35 +3,148 @@ package wallet
import (
"context"
"fmt"
"os"
"strings"
"time"
)
// WalletConnect handles WalletConnect v2 integration
const (
WalletConnectStatusStub = "stub"
WalletConnectStatusClient = "client"
WalletConnectStatusDisabled = "disabled"
)
// Config describes the public WalletConnect v2 posture exposed to clients.
type Config struct {
Status string `json:"status"`
Enabled bool `json:"enabled"`
ProjectID string `json:"projectId"`
RelayURL string `json:"relayUrl"`
MetadataURL string `json:"metadataUrl"`
RequiredNamespaces []string `json:"requiredNamespaces"`
SupportedChains []int `json:"supportedChains"`
FallbackAuth string `json:"fallbackAuth"`
Message string `json:"message"`
UpdatedAt string `json:"updatedAt"`
}
// ConnectResponse is returned while WalletConnect session bridging remains a stub.
type ConnectResponse struct {
Status string `json:"status"`
Enabled bool `json:"enabled"`
URI string `json:"uri,omitempty"`
SessionID string `json:"sessionId,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
FallbackAuth string `json:"fallbackAuth"`
Message string `json:"message"`
}
// Session represents a wallet session snapshot for future WalletConnect integration.
type Session struct {
SessionID string `json:"sessionId"`
Address string `json:"address,omitempty"`
ChainID int `json:"chainId,omitempty"`
Connected bool `json:"connected"`
Status string `json:"status"`
Message string `json:"message"`
}
// WalletConnect handles WalletConnect v2 integration posture for the explorer API.
type WalletConnect struct {
projectID string
relayURL string
chainID int
}
// NewWalletConnect creates a new WalletConnect handler
func NewWalletConnect(projectID string) *WalletConnect {
return &WalletConnect{projectID: projectID}
// NewWalletConnect creates a WalletConnect handler using deployment env vars.
func NewWalletConnect(chainID int) *WalletConnect {
projectID := strings.TrimSpace(os.Getenv("WALLETCONNECT_PROJECT_ID"))
relayURL := strings.TrimSpace(os.Getenv("WALLETCONNECT_RELAY_URL"))
if relayURL == "" {
relayURL = "wss://relay.walletconnect.org"
}
return &WalletConnect{
projectID: projectID,
relayURL: relayURL,
chainID: chainID,
}
}
// Connect initiates a wallet connection
func (wc *WalletConnect) Connect(ctx context.Context) (string, error) {
// Implementation would use WalletConnect v2 SDK
// Returns connection URI for QR code display
return "", fmt.Errorf("not implemented - requires WalletConnect SDK")
func (wc *WalletConnect) enabled() bool {
return wc.projectID != ""
}
// Session represents a wallet session
type Session struct {
Address string
ChainID int
Connected bool
// PublicConfig returns the read-only WalletConnect config surface for clients.
func (wc *WalletConnect) PublicConfig() Config {
status := WalletConnectStatusDisabled
if wc.enabled() {
status = WalletConnectStatusClient
}
return Config{
Status: status,
Enabled: wc.enabled(),
ProjectID: wc.projectID,
RelayURL: wc.relayURL,
MetadataURL: "/api/v1/walletconnect/metadata",
RequiredNamespaces: []string{"eip155"},
SupportedChains: []int{wc.chainID, 1},
FallbackAuth: "/api/v1/auth/wallet",
Message: wc.publicMessage(),
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
}
}
// GetSession gets current wallet session
func (wc *WalletConnect) GetSession(ctx context.Context, sessionID string) (*Session, error) {
// Implementation would retrieve session from WalletConnect
return nil, fmt.Errorf("not implemented")
func (wc *WalletConnect) publicMessage() string {
if wc.enabled() {
return "WalletConnect v2 is enabled. Use the WalletConnect button on /wallet for mobile QR pairing; browser extension wallets can continue using /api/v1/auth/wallet."
}
return "WalletConnect v2 is not configured. Set WALLETCONNECT_PROJECT_ID to publish relay config; browser wallet auth remains available at /api/v1/auth/wallet."
}
// Connect reports client-side WalletConnect posture. Pairing runs in the browser when projectId is published.
func (wc *WalletConnect) Connect(_ context.Context) (*ConnectResponse, error) {
if !wc.enabled() {
return &ConnectResponse{
Status: WalletConnectStatusDisabled,
Enabled: false,
FallbackAuth: "/api/v1/auth/wallet",
Message: wc.publicMessage(),
}, fmt.Errorf("walletconnect is disabled")
}
return &ConnectResponse{
Status: "client",
Enabled: true,
FallbackAuth: "/api/v1/auth/wallet",
Message: "Initialize WalletConnect in the browser via /wallet using the published projectId; authenticate with /api/v1/auth/wallet after pairing.",
}, nil
}
// GetSession returns a registered browser-paired WalletConnect session snapshot.
func (wc *WalletConnect) GetSession(_ context.Context, sessionID string) (*Session, error) {
if strings.TrimSpace(sessionID) == "" {
return nil, fmt.Errorf("session id is required")
}
if session, ok := lookupWalletConnectSession(sessionID); ok {
return session, nil
}
return &Session{
SessionID: sessionID,
Connected: false,
Status: WalletConnectStatusClient,
Message: "Session not registered yet. Pair on /wallet, then POST /api/v1/walletconnect/session with sessionId and address.",
}, fmt.Errorf("walletconnect session not found")
}
// RegisterSession stores a client-paired WalletConnect session for operator lookup.
func (wc *WalletConnect) RegisterSession(_ context.Context, sessionID, address string, chainID int) (*Session, error) {
if strings.TrimSpace(sessionID) == "" {
return nil, fmt.Errorf("session id is required")
}
if !strings.HasPrefix(strings.ToLower(address), "0x") || len(address) != 42 {
return nil, fmt.Errorf("valid address is required")
}
if chainID <= 0 {
chainID = wc.chainID
}
return RegisterClientSession(sessionID, address, chainID), nil
}

View File

@@ -0,0 +1,88 @@
package wallet
import (
"strings"
"sync"
"time"
)
const walletConnectSessionTTL = 24 * time.Hour
type storedWalletConnectSession struct {
SessionID string
Address string
ChainID int
Connected bool
Status string
Message string
UpdatedAt time.Time
}
var (
walletConnectSessionMu sync.RWMutex
walletConnectSessions = map[string]storedWalletConnectSession{}
)
func purgeExpiredWalletConnectSessions(now time.Time) {
for id, session := range walletConnectSessions {
if now.Sub(session.UpdatedAt) > walletConnectSessionTTL {
delete(walletConnectSessions, id)
}
}
}
// RegisterClientSession records a browser-paired WalletConnect session snapshot.
func RegisterClientSession(sessionID, address string, chainID int) *Session {
sessionID = strings.TrimSpace(sessionID)
address = strings.TrimSpace(address)
now := time.Now().UTC()
walletConnectSessionMu.Lock()
defer walletConnectSessionMu.Unlock()
purgeExpiredWalletConnectSessions(now)
record := storedWalletConnectSession{
SessionID: sessionID,
Address: address,
ChainID: chainID,
Connected: true,
Status: WalletConnectStatusClient,
Message: "WalletConnect session registered by browser client after pairing.",
UpdatedAt: now,
}
walletConnectSessions[sessionID] = record
return sessionFromStored(record)
}
func lookupWalletConnectSession(sessionID string) (*Session, bool) {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return nil, false
}
now := time.Now().UTC()
walletConnectSessionMu.Lock()
defer walletConnectSessionMu.Unlock()
purgeExpiredWalletConnectSessions(now)
record, ok := walletConnectSessions[sessionID]
if !ok {
return nil, false
}
if now.Sub(record.UpdatedAt) > walletConnectSessionTTL {
delete(walletConnectSessions, sessionID)
return nil, false
}
return sessionFromStored(record), true
}
func sessionFromStored(record storedWalletConnectSession) *Session {
return &Session{
SessionID: record.SessionID,
Address: record.Address,
ChainID: record.ChainID,
Connected: record.Connected,
Status: record.Status,
Message: record.Message,
}
}

View File

@@ -0,0 +1,25 @@
package wallet
import (
"context"
"testing"
)
func TestRegisterAndLookupWalletConnectSession(t *testing.T) {
sessionID := "wc-test-topic-123"
address := "0x4A666F96fC8764181194447A7dFdb7d471b301C8"
registered := RegisterClientSession(sessionID, address, 138)
if registered == nil || !registered.Connected {
t.Fatalf("expected connected session, got %#v", registered)
}
wc := NewWalletConnect(138)
session, err := wc.GetSession(context.Background(), sessionID)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if session.Address != address {
t.Fatalf("expected address %s, got %s", address, session.Address)
}
}

View File

@@ -0,0 +1,61 @@
{
"updated": "2026-05-23",
"min_link_wei": "1000000000000000000",
"lanes": [
{
"key": "chain138",
"chain_name": "Defi Oracle Meta Mainnet (138)",
"chain_id": 138,
"config_ready": true,
"rpc_envs": ["RPC_URL", "RPC_URL_138"],
"rpc_default": "http://192.168.11.211:8545",
"link_token": "0xb7721dd53a8c629d9f1ba31a5819afe250002b03",
"weth9_bridge": "0xcacfd227A040002e49e2e01626363071324f820a",
"weth10_bridge": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0"
},
{
"key": "gnosis",
"chain_name": "Gnosis (100)",
"chain_id": 100,
"config_ready": true,
"rpc_envs": ["GNOSIS_RPC", "GNOSIS_MAINNET_RPC", "GNOSIS_RPC_URL"],
"rpc_default": "https://rpc.gnosischain.com",
"link_token": "0xE2e73A1c69ecF83F464EFCE6A5be353a37cA09b2",
"weth9_bridge": "0xc8656F24488cb90c452058da92d1a25BA464eaAE",
"weth10_bridge": "0xa846aeAD3071df1b6439d5D813156aCE7C2c1DA1"
},
{
"key": "cronos",
"chain_name": "Cronos (25)",
"chain_id": 25,
"config_ready": true,
"rpc_envs": ["CRONOS_RPC", "CRONOS_RPC_URL", "CRONOS_MAINNET_RPC"],
"rpc_default": "https://evm.cronos.org",
"link_token": "0x8c80A01F461f297Df7F9DA3A4f740D7297C8Ac85",
"weth9_bridge": "0x3Cc23d086fCcbAe1e5f3FE2bA4A263E1D27d8Cab",
"weth10_bridge": "0x105F8A15b819948a89153505762444Ee9f324684"
},
{
"key": "celo",
"chain_name": "Celo (42220)",
"chain_id": 42220,
"config_ready": true,
"rpc_envs": ["CELO_RPC", "CELO_MAINNET_RPC"],
"rpc_default": "https://forno.celo.org",
"link_token": "0xd07294e6E917e07dfDcee882dd1e2565085C2ae0",
"weth9_bridge": "0xAb57BF30F1354CA0590af22D8974c7f24DB2DbD7",
"weth10_bridge": "0xa780ef19A041745d353c9432f2a7f5A241335ffE"
},
{
"key": "wemix",
"chain_name": "Wemix (1111)",
"chain_id": 1111,
"config_ready": true,
"rpc_envs": ["WEMIX_RPC", "WEMIX_MAINNET_RPC"],
"rpc_default": "https://api.wemix.com",
"link_token": "0x80f1FcdC96B55e459BF52b998aBBE2c364935d69",
"weth9_bridge": "0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04",
"weth10_bridge": "0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08"
}
]
}

View File

@@ -0,0 +1,21 @@
{
"updated": "2026-05-23T13:07:00Z",
"sprint": "tier-a-week-3",
"note": "Example shape for MISSION_CONTROL_PROOF_TRANSFERS_JSON. Operator live file: reports/status/bridge-lane-proof-transfers-latest.json (gitignored). Populate with scripts/bridge/run-lane-proof-transfers.sh --execute.",
"lanes": {
"chain138": [
{
"tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"amount_eth": "0.001",
"dest_chain": "mainnet",
"dest_selector": "5009297550715157269",
"bridge": "0xcacfd227A040002e49e2e01626363071324f820a",
"recorded_at": "2026-05-23T00:00:00Z"
}
],
"gnosis": [],
"cronos": [],
"celo": [],
"wemix": []
}
}

View File

@@ -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;

View File

@@ -0,0 +1,28 @@
# Explorer public API access (decision record)
**Date:** 2026-05-23
**Live page:** `/docs/public-api-access`
## Summary
| Surface | Auth today | Notes |
|---------|------------|-------|
| Blockscout read API (`/api/v2/*`) | None | Same-origin proxy to Blockscout |
| Public JSON (stats, bridge routes, token lists, etc.) | None | Listed in footer **Public APIs** |
| Managed RPC keys | Wallet session on `/access` | `POST /api/v1/access/api-keys` after `/api/v1/auth/wallet` |
## Decision
1. **Keep Blockscout and public JSON unauthenticated** for integrators on the public explorer domain.
2. **Managed RPC keys** remain the wallet-authenticated product on `/access` — not a Blockscout API-key layer.
3. **Future path (Option B):** nginx/API-gateway throttling with optional `X-API-Key` for higher quotas if abuse appears. Full external developer portal remains optional.
## Integrator flow
- Read-only: use footer links or `/docs/public-api-access` endpoint list.
- Higher limits / RPC: connect wallet on `/wallet`, open `/access`, create scoped keys (tier, product, expiry, quota).
## Operator
- No nginx key gate required until rate-limit policy changes.
- Support contact: `support@d-bis.org`

View 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 |

View File

@@ -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

View File

@@ -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({

View File

@@ -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
}

View File

@@ -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>
)}

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -16,11 +16,12 @@
"start:next": "next start",
"lint": "eslint src libs next.config.js --ext .js,.jsx,.ts,.tsx",
"type-check": "tsc --noEmit -p tsconfig.check.json",
"test": "npm run lint && npm run type-check",
"test": "npm run lint && npm run type-check && npm run test:unit",
"test:unit": "vitest run"
},
"dependencies": {
"@tanstack/react-query": "^5.14.2",
"@walletconnect/ethereum-provider": "^2.21.10",
"autoprefixer": "^10.4.16",
"axios": "^1.15.2",
"clsx": "^2.0.0",

View File

@@ -152,8 +152,8 @@
<div class="status-note" id="command-center-fallback">
If diagram rendering is unavailable, use the main explorer operational surfaces directly:
<a href="/operations">Operations Hub</a>,
<a href="/bridge">Bridge Monitoring</a>,
<a href="/operations">Operations hub</a>,
<a href="/bridge">Bridge</a>,
<a href="/routes">Routes</a>,
<a href="/system">System</a>,
and <a href="/operator">Operator</a>.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

View File

@@ -4,15 +4,15 @@ const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/
const addressUnderTest = process.env.SMOKE_ADDRESS || '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506'
const checks = [
{ path: '/', expectTexts: ['DBIS Explorer', 'Recent Blocks', 'Open wallet tools'] },
{ path: '/', expectTexts: ['DBIS Explorer', 'Recent Blocks', 'Network overview'] },
{ path: '/blocks', expectTexts: ['Blocks'] },
{ path: '/transactions', expectTexts: ['Transactions'] },
{ path: '/addresses', expectTexts: ['Addresses', 'Open An Address'] },
{ path: '/watchlist', expectTexts: ['Watchlist', 'Saved Addresses'] },
{ path: '/pools', expectTexts: ['Pools', 'Pool operation shortcuts'] },
{ path: '/liquidity', expectTexts: ['Chain 138 Liquidity Access', 'Explorer Access Points'] },
{ path: '/wallet', expectTexts: ['Wallet & MetaMask', 'Install Open Snap'] },
{ path: '/tokens', expectTexts: ['Tokens', 'Find A Token'] },
{ path: '/liquidity', expectTexts: ['Liquidity', 'Explorer Access Points'] },
{ path: '/wallet', expectTexts: ['Wallet Tools', 'WalletConnect v2 posture'] },
{ path: '/tokens', expectTexts: ['Tokens', 'Find a token'] },
{ path: '/search', expectTexts: ['Search'], placeholder: 'Search by address, transaction hash, block number...' },
{ path: `/addresses/${addressUnderTest}`, expectTexts: [], anyOfTexts: ['Back to addresses', 'Address not found'] },
]
@@ -22,7 +22,10 @@ async function bodyText(page) {
}
async function hasShell(page) {
const homeLink = await page.getByRole('link', { name: /Go to explorer home/i }).isVisible().catch(() => false)
const homeLink = await page
.getByRole('link', { name: /Go to DBIS Explorer home|Go to explorer home/i })
.isVisible()
.catch(() => false)
const supportText = await page.getByText(/Support:/i).isVisible().catch(() => false)
return homeLink && supportText
}

View File

@@ -75,6 +75,9 @@ export default function ActivityContextPanel({
</Explain>
</div>
<EntityBadge label={resolveLabel(context.state)} tone={tone} />
{context.head_is_idle && context.state === 'low' ? (
<EntityBadge label="quiet chain" tone="info" />
) : null}
</div>
{compact ? (
@@ -130,6 +133,11 @@ export default function ActivityContextPanel({
Open last non-empty block
</Link>
) : null}
{context.block_gap_to_latest_transaction != null ? (
<span>
Block gap to latest visible transaction: {context.block_gap_to_latest_transaction.toLocaleString()}
</span>
) : null}
{context.latest_transaction_timestamp ? (
<span>Latest visible transaction time: {formatTimestamp(context.latest_transaction_timestamp)}</span>
) : null}

View File

@@ -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(' ')}
>
DBIS Explorer
</span>
<span
className={[
'block truncate font-medium uppercase text-gray-500 dark:text-gray-400',
compact ? 'text-[0.72rem] tracking-[0.14em]' : 'text-[0.8rem] tracking-[0.12em]',
'block truncate font-medium uppercase text-gray-500 dark:text-gray-400 max-sm:hidden',
compact ? 'text-[0.64rem] tracking-[0.13em]' : 'text-[0.68rem] tracking-[0.12em]',
].join(' ')}
>
Chain 138 Explorer by DBIS

View File

@@ -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

View File

@@ -13,8 +13,14 @@ function toneClasses(tone: 'neutral' | 'success' | 'warning' | 'info') {
}
}
export function getEntityBadgeTone(tag: string): 'neutral' | 'success' | 'warning' | 'info' {
const normalized = tag.toLowerCase()
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'
}
@@ -27,15 +33,16 @@ export function getEntityBadgeTone(tag: string): 'neutral' | 'success' | 'warnin
return 'neutral'
}
export function formatEntityBadgeLabel(label: string): string {
const normalized = label.toLowerCase()
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] || label
return labels[normalized] || resolvedLabel
}
export default function EntityBadge({
@@ -43,7 +50,7 @@ export default function EntityBadge({
tone,
className,
}: {
label: string
label: unknown
tone?: 'neutral' | 'success' | 'warning' | 'info'
className?: string
}) {

View File

@@ -3,10 +3,12 @@ import Navbar from './Navbar'
import Footer from './Footer'
import ExplorerAgentTool from './ExplorerAgentTool'
import { UiModeProvider } from './UiModeContext'
import { PostureGlossaryProvider } from './PostureGlossaryProvider'
export default function ExplorerChrome({ children }: { children: ReactNode }) {
return (
<UiModeProvider>
<PostureGlossaryProvider>
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<a
href="#main-content"
@@ -21,6 +23,7 @@ export default function ExplorerChrome({ children }: { children: ReactNode }) {
<ExplorerAgentTool />
<Footer />
</div>
</PostureGlossaryProvider>
</UiModeProvider>
)
}

View File

@@ -0,0 +1,34 @@
interface ExplorerRetryAlertProps {
message: string
onRetry?: () => void
retryLabel?: string
className?: string
}
export default function ExplorerRetryAlert({
message,
onRetry,
retryLabel = 'Retry',
className = '',
}: ExplorerRetryAlertProps) {
return (
<div
role="alert"
className={[
'flex flex-col gap-3 rounded-xl border border-red-200 bg-red-50/70 px-4 py-3 dark:border-red-900/50 dark:bg-red-950/20 sm:flex-row sm:items-center sm:justify-between',
className,
].join(' ')}
>
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{message}</p>
{onRetry ? (
<button
type="button"
onClick={onRetry}
className="shrink-0 rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-semibold text-red-800 transition-colors hover:bg-red-50 dark:border-red-800 dark:bg-red-950 dark:text-red-100 dark:hover:bg-red-900/40"
>
{retryLabel}
</button>
) : null}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import Link from 'next/link'
import FooterPublicApiLinks from '@/components/common/FooterPublicApiLinks'
const footerLinkClass =
'text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors'
@@ -9,7 +10,7 @@ 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">
DBIS Explorer
@@ -34,18 +35,39 @@ export default function Footer() {
<ul className="space-y-2 text-sm">
<li><Link className={footerLinkClass} href="/search">Search</Link></li>
<li><Link className={footerLinkClass} href="/docs">Documentation</Link></li>
<li><Link className={footerLinkClass} href="/bridge">Bridge Monitoring</Link></li>
<li><Link className={footerLinkClass} href="/liquidity">Liquidity Access</Link></li>
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
<li><Link className={footerLinkClass} href="/operations">Operations Hub</Link></li>
<li><Link className={footerLinkClass} href="/blocks">Blocks</Link></li>
<li><Link className={footerLinkClass} href="/transactions">Transactions</Link></li>
<li><Link className={footerLinkClass} href="/tokens">Tokens</Link></li>
<li><Link className={footerLinkClass} href="/addresses">Addresses</Link></li>
<li><Link className={footerLinkClass} href="/watchlist">Watchlist</Link></li>
<li><Link className={footerLinkClass} href="/access">Account access</Link></li>
<li><Link className={footerLinkClass} href="/wallet">Wallet tools</Link></li>
<li><Link className={footerLinkClass} href="/operations">Operations hub</Link></li>
<li><Link className={footerLinkClass} href="/bridge">Bridge</Link></li>
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
<li><Link className={footerLinkClass} href="/liquidity">Liquidity</Link></li>
<li><Link className={footerLinkClass} href="/pools">Pools</Link></li>
<li><Link className={footerLinkClass} href="/analytics">Analytics</Link></li>
<li><Link className={footerLinkClass} href="/operator">Operator</Link></li>
<li><Link className={footerLinkClass} href="/system">System</Link></li>
<li><Link className={footerLinkClass} href="/weth">WETH</Link></li>
<li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li>
<li><a className={footerLinkClass} href="/terms.html">Terms of Service</a></li>
<li><a className={footerLinkClass} href="/acknowledgments.html">Acknowledgments</a></li>
</ul>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Public APIs
</div>
<FooterPublicApiLinks />
<p className="mt-3 text-xs leading-5 text-gray-500 dark:text-gray-500">
Read-only JSON endpoints on the public explorer domain. No API key required.
</p>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Contact

View File

@@ -0,0 +1,53 @@
'use client'
import { useState } from 'react'
import { explorerPublicApiLinks } from '@/data/explorerOperations'
const footerLinkClass =
'text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors'
function absoluteApiUrl(href: string): string {
if (typeof window === 'undefined') return href
if (href.startsWith('http://') || href.startsWith('https://')) return href
return `${window.location.origin}${href.startsWith('/') ? href : `/${href}`}`
}
export default function FooterPublicApiLinks() {
const [copiedHref, setCopiedHref] = useState<string | null>(null)
const copyUrl = async (href: string) => {
if (typeof navigator === 'undefined' || !navigator.clipboard) return
try {
await navigator.clipboard.writeText(absoluteApiUrl(href))
setCopiedHref(href)
window.setTimeout(() => setCopiedHref((current) => (current === href ? null : current)), 1500)
} catch {
setCopiedHref(null)
}
}
return (
<ul className="space-y-3 text-sm">
{explorerPublicApiLinks.map((link) => (
<li key={link.href}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<a className={footerLinkClass} href={link.href} target="_blank" rel="noopener noreferrer">
{link.label}
</a>
<p className="mt-0.5 text-xs leading-5 text-gray-500 dark:text-gray-500">{link.description}</p>
</div>
<button
type="button"
onClick={() => void copyUrl(link.href)}
className="shrink-0 rounded-lg border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 transition-colors hover:border-primary-300 hover:text-primary-700 dark:border-gray-700 dark:text-gray-200 dark:hover:border-primary-500 dark:hover:text-primary-300"
aria-label={`Copy URL for ${link.label}`}
>
{copiedHref === link.href ? 'Copied' : 'Copy URL'}
</button>
</div>
</li>
))}
</ul>
)
}

View File

@@ -8,11 +8,15 @@ import {
import { formatRelativeAge } from '@/utils/format'
import { useUiMode } from './UiModeContext'
function buildSummary(context: ChainActivityContext) {
function buildSummary(context: ChainActivityContext, activityState?: string | null) {
if (context.transaction_visibility_unavailable) {
return 'Chain-head visibility is current, while transaction freshness is currently unavailable.'
}
if (activityState === 'quiet_chain' || (context.head_is_idle && context.state === 'low')) {
return 'The chain head is current, but recent head blocks are quiet — this is normal low-activity visibility, not a broken index.'
}
if (context.state === 'active') {
return 'Chain head and latest indexed transactions are closely aligned.'
}
@@ -28,11 +32,21 @@ function buildSummary(context: ChainActivityContext) {
return 'Freshness context is based on the latest visible public explorer evidence.'
}
function buildDetail(context: ChainActivityContext, diagnosticExplanation?: string | null) {
function buildDetail(context: ChainActivityContext, diagnosticExplanation?: string | null, activityState?: string | null) {
if (diagnosticExplanation) {
return diagnosticExplanation
}
if (activityState === 'quiet_chain') {
const latestNonEmptyBlock =
context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown'
const blockGap =
context.block_gap_to_latest_transaction != null
? `${context.block_gap_to_latest_transaction.toLocaleString()} blocks`
: 'unknown'
return `Quiet-chain signal: head blocks may be empty while the chain remains current. Block gap to latest visible transaction: ${blockGap}. Last non-empty block: ${latestNonEmptyBlock}.`
}
if (context.transaction_visibility_unavailable) {
return 'Use chain-head visibility and the last non-empty block as the current trust anchors.'
}
@@ -40,9 +54,13 @@ function buildDetail(context: ChainActivityContext, diagnosticExplanation?: stri
const latestTxAge = formatRelativeAge(context.latest_transaction_timestamp)
const latestNonEmptyBlock =
context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown'
const blockGap =
context.block_gap_to_latest_transaction != null
? `${context.block_gap_to_latest_transaction.toLocaleString()} blocks`
: null
if (context.head_is_idle) {
return `Latest visible transaction: ${latestTxAge}. Last non-empty block: ${latestNonEmptyBlock}.`
return `Latest visible transaction: ${latestTxAge}. Block gap: ${blockGap || 'unknown'}. Last non-empty block: ${latestNonEmptyBlock}.`
}
if (context.state === 'active') {
@@ -74,13 +92,14 @@ export default function FreshnessTrustNote({
const sourceLabel = resolveFreshnessSourceLabel(stats, bridgeStatus)
const confidenceBadges = summarizeFreshnessConfidence(stats, bridgeStatus)
const diagnosticExplanation = stats?.diagnostics?.explanation || bridgeStatus?.data?.diagnostics?.explanation || null
const activityState = stats?.diagnostics?.activity_state || bridgeStatus?.data?.diagnostics?.activity_state || null
const normalizedClassName = className ? ` ${className}` : ''
if (mode === 'expert') {
return (
<div className={`rounded-2xl border border-gray-200 bg-white/80 px-4 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/40${normalizedClassName}`}>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context)}</div>
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context, activityState)}</div>
<div className="text-xs text-gray-600 dark:text-gray-400">{sourceLabel}</div>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
@@ -99,9 +118,9 @@ export default function FreshnessTrustNote({
return (
<div className={`rounded-2xl border border-gray-200 bg-white/80 px-4 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/40${normalizedClassName}`}>
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context)}</div>
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context, activityState)}</div>
<div className="mt-1 text-gray-600 dark:text-gray-400">
{normalizeSentence(buildDetail(context, diagnosticExplanation))}.{' '}
{normalizeSentence(buildDetail(context, diagnosticExplanation, activityState))}.{' '}
{scopeLabel ? `${normalizeSentence(scopeLabel)}. ` : ''}
{normalizeSentence(sourceLabel)}.
</div>

View File

@@ -27,7 +27,9 @@ export default function MarketEvidenceNote({
compact?: boolean
}) {
const freshness = lastUpdated ? `${formatRelativeAge(lastUpdated)} (${formatTimestamp(lastUpdated)})` : 'timestamp unavailable'
const text = `Source: ${formatSource(source)}. Updated: ${freshness}. Method: ${method}`
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`}>

View File

@@ -0,0 +1,76 @@
'use client'
import { Card } from '@/libs/frontend-ui-primitives'
import EntityBadge from '@/components/common/EntityBadge'
import type { MissionControlMode } from '@/services/api/missionControl'
import { formatRelativeAge } from '@/utils/format'
const REASON_LABELS: Record<string, string> = {
live_homepage_stream_not_attached: 'Live homepage stream is not attached; relay posture uses snapshot polling.',
relay_snapshot_only_source: 'Relay monitoring uses snapshot sources while other explorer feeds remain live.',
partial_observability_inputs: 'Some freshness inputs are partial, so posture is reported conservatively.',
}
const SCOPE_LABELS: Record<string, string> = {
relay_monitoring_homepage_card_only: 'Affects relay monitoring and the homepage mission card only.',
bridge_monitoring_and_homepage: 'Affects bridge monitoring and homepage summary surfaces.',
homepage_summary_only: 'Affects homepage summary messaging only.',
}
function humanizeKey(value?: string | null): string {
if (!value) return 'Not specified'
return SCOPE_LABELS[value] || REASON_LABELS[value] || value.replaceAll('_', ' ')
}
function modeTone(kind?: string | null): 'success' | 'warning' | 'info' | 'neutral' {
switch (String(kind || '').toLowerCase()) {
case 'live':
return 'success'
case 'mixed':
return 'warning'
case 'snapshot':
return 'info'
default:
return 'neutral'
}
}
export default function MissionDeliveryModePanel({
mode,
title = 'Delivery mode',
className = '',
}: {
mode?: MissionControlMode | null
title?: string
className?: string
}) {
if (!mode?.kind) return null
const normalizedClassName = className ? ` ${className}` : ''
return (
<Card
className={`border border-indigo-200 bg-indigo-50/60 dark:border-indigo-900/40 dark:bg-indigo-950/20${normalizedClassName}`}
title={title}
>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={`mode ${mode.kind}`} tone={modeTone(mode.kind)} />
{mode.updated_at ? (
<span className="text-xs text-gray-600 dark:text-gray-400">Updated {formatRelativeAge(mode.updated_at)}</span>
) : null}
</div>
{mode.reason ? (
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
<span className="font-medium text-gray-900 dark:text-white">Reason:</span> {humanizeKey(String(mode.reason))}
</p>
) : null}
{mode.scope ? (
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
<span className="font-medium text-gray-900 dark:text-white">Scope:</span> {humanizeKey(String(mode.scope))}
</p>
) : null}
</div>
</Card>
)
}

View File

@@ -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,10 +714,10 @@ export default function Navbar() {
<>
<header className="sticky top-0 z-40 border-b border-gray-200/90 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/88 dark:border-gray-800 dark:bg-gray-950/92">
<div className="container mx-auto px-4">
<div className="flex min-h-[76px] items-center gap-4 lg:min-h-[84px]">
<div className="flex min-h-[60px] items-center gap-3 lg:min-h-[64px]">
<Link
href="/"
className="group inline-flex min-w-0 items-center gap-3 rounded-2xl py-2 pr-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950"
className="group inline-flex shrink-0 items-center gap-2 rounded-lg py-1.5 pr-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950 max-sm:max-w-[9.5rem] sm:max-w-none"
onClick={() => setMobileMenuOpen(false)}
aria-label="Go to DBIS Explorer home"
>
@@ -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">

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import EntityBadge from '@/components/common/EntityBadge'
import { usePostureGlossary } from '@/components/common/PostureGlossaryProvider'
import { resolvePostureTermId } from '@/data/postureGlossary'
export default function PostureBadge({
label,
tone,
className,
}: {
label: string
tone?: 'neutral' | 'success' | 'warning' | 'info'
className?: string
}) {
const { openTerm } = usePostureGlossary()
const termId = resolvePostureTermId(label)
if (!termId) {
return <EntityBadge label={label} tone={tone} className={className} />
}
return (
<button
type="button"
onClick={() => openTerm(termId)}
title="Open posture glossary"
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
>
<EntityBadge label={label} tone={tone} className={className} />
</button>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
import Link from 'next/link'
import { getPostureGlossaryTerm, type PostureGlossaryTermId } from '@/data/postureGlossary'
interface PostureGlossaryContextValue {
openTerm: (termId: PostureGlossaryTermId) => void
close: () => void
}
const PostureGlossaryContext = createContext<PostureGlossaryContextValue | null>(null)
export function PostureGlossaryProvider({ children }: { children: ReactNode }) {
const [activeTermId, setActiveTermId] = useState<PostureGlossaryTermId | null>(null)
const activeTerm = useMemo(
() => (activeTermId ? getPostureGlossaryTerm(activeTermId) ?? null : null),
[activeTermId],
)
const openTerm = useCallback((termId: PostureGlossaryTermId) => {
setActiveTermId(termId)
}, [])
const close = useCallback(() => {
setActiveTermId(null)
}, [])
return (
<PostureGlossaryContext.Provider value={{ openTerm, close }}>
{children}
{activeTerm ? (
<div className="fixed inset-0 z-[80] flex items-end justify-center bg-black/45 p-4 sm:items-center" onClick={close}>
<div
role="dialog"
aria-modal="true"
aria-labelledby="posture-glossary-title"
className="max-h-[85vh] w-full max-w-xl overflow-y-auto rounded-2xl border border-gray-200 bg-white p-6 shadow-2xl dark:border-gray-700 dark:bg-gray-950"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Posture glossary</p>
<h2 id="posture-glossary-title" className="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
{activeTerm.title}
</h2>
</div>
<button
type="button"
onClick={close}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900"
>
Close
</button>
</div>
<p className="mt-4 text-sm leading-6 text-gray-700 dark:text-gray-300">{activeTerm.summary}</p>
<div className="mt-4 rounded-xl border border-sky-200 bg-sky-50/70 p-4 text-sm leading-6 text-sky-950 dark:border-sky-900/50 dark:bg-sky-950/20 dark:text-sky-100">
<p className="text-xs font-semibold uppercase tracking-wide text-sky-700 dark:text-sky-300">Methodology</p>
<p className="mt-2">{activeTerm.methodology}</p>
</div>
<div className="mt-5 flex flex-wrap gap-3 text-sm">
<Link href="/docs/posture-glossary" className="font-medium text-primary-600 hover:underline" onClick={close}>
Full glossary
</Link>
<Link href="/docs/gru" className="font-medium text-primary-600 hover:underline" onClick={close}>
GRU guide
</Link>
</div>
</div>
</div>
) : null}
</PostureGlossaryContext.Provider>
)
}
export function usePostureGlossary() {
const context = useContext(PostureGlossaryContext)
if (!context) {
throw new Error('usePostureGlossary must be used within PostureGlossaryProvider')
}
return context
}

View 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>
)
}

View 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>
}

View 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 contracts 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>
)
}

View File

@@ -19,6 +19,7 @@ import { formatWeiAsEth } from '@/utils/format'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
import OperationsPageShell, {
@@ -156,6 +157,7 @@ export default function AnalyticsOperationsPage({
bridgeStatus={bridgeStatus}
scopeLabel="This page combines public stats, recent block samples, and indexed transactions."
/>
<MissionDeliveryModePanel className="mt-3" mode={bridgeStatus?.data?.mode} title="Analytics delivery mode" />
<SubsystemPosturePanel
className="mt-3"
subsystems={bridgeStatus?.data?.subsystems}

View File

@@ -0,0 +1,107 @@
'use client'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import EntityBadge from '@/components/common/EntityBadge'
import type { MissionControlBridgeLane, MissionControlBridgeLaneHealth } from '@/services/api/missionControl'
function laneTone(status?: string | null): 'success' | 'warning' | 'info' | 'neutral' {
switch (String(status || '').toLowerCase()) {
case 'funded':
case 'proof-recorded':
return 'success'
case 'degraded':
case 'proof-pending':
return 'warning'
case 'unfunded':
return 'warning'
default:
return 'neutral'
}
}
function formatLinkBalance(wei?: string | null): string {
if (!wei) return 'Unknown'
try {
const value = BigInt(wei)
const whole = value / 10n ** 18n
const fractional = (value % 10n ** 18n).toString().padStart(18, '0').slice(0, 4).replace(/0+$/, '')
return fractional ? `${whole}.${fractional} LINK` : `${whole} LINK`
} catch {
return wei
}
}
export default function BridgeLaneHealthPanel({
laneHealth,
className = '',
}: {
laneHealth?: MissionControlBridgeLaneHealth | null
className?: string
}) {
const lanes = laneHealth?.lanes || []
if (lanes.length === 0) return null
const normalizedClassName = className ? ` ${className}` : ''
return (
<Card title="Config-ready lane health" className={`mb-8${normalizedClassName}`}>
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
LINK balances are read from each remote CCIP bridge contract. Proof-transfer status comes from operator-recorded CCIP message hashes when available.
</p>
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Operator runbook: fund LINK with{' '}
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">fund-ccip-bridges-with-link.sh</code>
{' '}· lane probe{' '}
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">probe-bridge-lane-link-balances.sh</code>
{' '}· routing reference{' '}
<Link href="/docs/public-api-access" className="text-primary-600 hover:underline">
public API access
</Link>
</p>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
<th className="py-2 pr-4">Lane</th>
<th className="py-2 pr-4">Overall</th>
<th className="py-2 pr-4">Proof</th>
<th className="py-2 pr-4">WETH9 bridge</th>
<th className="py-2 pr-4">WETH10 bridge</th>
</tr>
</thead>
<tbody>
{lanes.map((lane: MissionControlBridgeLane) => (
<tr key={lane.key} className="border-b border-gray-100 align-top last:border-0 dark:border-gray-800">
<td className="py-3 pr-4">
<div className="font-medium text-gray-900 dark:text-white">{lane.chain_name || lane.key}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">chain {lane.chain_id ?? '?'}</div>
</td>
<td className="py-3 pr-4">
<EntityBadge label={lane.status || 'unknown'} tone={laneTone(lane.status)} />
</td>
<td className="py-3 pr-4">
<EntityBadge label={lane.proof_status || 'proof-pending'} tone={laneTone(lane.proof_status)} />
</td>
<td className="py-3 pr-4">
<div className="font-mono text-xs text-gray-700 dark:text-gray-300">{lane.weth9?.bridge || '—'}</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<EntityBadge label={lane.weth9?.status || 'unknown'} tone={laneTone(lane.weth9?.status)} className="px-2 py-0.5 text-[11px]" />
<span className="text-xs text-gray-600 dark:text-gray-400">{formatLinkBalance(lane.weth9?.link_balance_wei)}</span>
</div>
</td>
<td className="py-3 pr-4">
<div className="font-mono text-xs text-gray-700 dark:text-gray-300">{lane.weth10?.bridge || '—'}</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<EntityBadge label={lane.weth10?.status || 'unknown'} tone={laneTone(lane.weth10?.status)} className="px-2 py-0.5 text-[11px]" />
<span className="text-xs text-gray-600 dark:text-gray-400">{formatLinkBalance(lane.weth10?.link_balance_wei)}</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)
}

View File

@@ -14,8 +14,15 @@ import { explorerFeaturePages } from '@/data/explorerOperations'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import { bridgeRoutesApi, normalizeBridgeRouteEntries, type BridgeRoutesResponse } from '@/services/api/bridgeRoutes'
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
import { HOME_DASHBOARD_REFRESH_MS } from '@/utils/featuredTokens'
import BridgeLaneHealthPanel from '@/components/explorer/BridgeLaneHealthPanel'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import OperationsActionGrid from './OperationsActionGrid'
type FeedState = 'connecting' | 'live' | 'fallback'
@@ -146,6 +153,7 @@ export default function BridgeMonitoringPage({
}) {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [bridgeRoutes, setBridgeRoutes] = useState<BridgeRoutesResponse | null>(null)
const [feedState, setFeedState] = useState<FeedState>(initialBridgeStatus ? 'fallback' : 'connecting')
const page = explorerFeaturePages.bridge
@@ -196,6 +204,49 @@ export default function BridgeMonitoringPage({
}
}, [])
useEffect(() => {
let cancelled = false
bridgeRoutesApi.getRoutesSafe().then(({ ok, data }) => {
if (!cancelled && ok) {
setBridgeRoutes(data)
}
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (feedState !== 'fallback') return
let cancelled = false
const refreshSnapshot = async () => {
try {
const snapshot = await missionControlApi.getBridgeStatus()
if (!cancelled) {
setBridgeStatus(snapshot)
}
} catch (error) {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to refresh bridge monitoring snapshot:', error)
}
}
}
return createVisibilityAwarePoller({
intervalMs: HOME_DASHBOARD_REFRESH_MS,
task: refreshSnapshot,
})
}, [feedState])
const routeEntries = useMemo(
() => normalizeBridgeRouteEntries(bridgeRoutes?.routes),
[bridgeRoutes?.routes],
)
const activityContext = useMemo(
() =>
summarizeChainActivity({
@@ -280,6 +331,8 @@ export default function BridgeMonitoringPage({
</Card>
) : null}
<OperationsSurfaceNav />
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Bridge Freshness Context" />
<FreshnessTrustNote
@@ -289,6 +342,7 @@ export default function BridgeMonitoringPage({
bridgeStatus={bridgeStatus}
scopeLabel="Bridge relay posture is shown alongside the same explorer freshness model used on the homepage and core explorer routes"
/>
<MissionDeliveryModePanel className="mt-3" mode={bridgeStatus?.data?.mode} title="Bridge delivery mode" />
<SubsystemPosturePanel
className="mt-3"
subsystems={bridgeStatus?.data?.subsystems}
@@ -407,27 +461,56 @@ export default function BridgeMonitoringPage({
))}
</div>
<div className="grid gap-4 lg:grid-cols-2">
{page.actions.map((action) => (
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">
{action.title}
</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
{action.description}
</p>
<div className="mt-4">
<ActionLink
href={action.href}
label={action.label}
external={'external' in action ? action.external : undefined}
/>
</div>
</div>
</Card>
))}
</div>
<BridgeLaneHealthPanel laneHealth={bridgeStatus?.data?.bridge_lanes} />
{routeEntries.length > 0 ? (
<Card title="CCIP route catalog" className="mb-8">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Destination bridge contracts from{' '}
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">
/token-aggregation/api/v1/bridge/routes
</code>
{bridgeRoutes?.source ? (
<>
{' '}
(source: {bridgeRoutes.source}
{bridgeRoutes.lastModified ? ` · updated ${relativeAge(bridgeRoutes.lastModified)}` : ''})
</>
) : null}
. Gnosis, Cronos, Celo, and Wemix lanes are aligned to deployed CCIP receivers fund LINK on each remote bridge before live traffic.
</p>
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Operator runbook:{' '}
<Link href="/docs/public-api-access" className="text-primary-600 hover:underline">
public API access
</Link>{' '}
· config-ready chain completion in repo{' '}
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">CONFIG_READY_CHAINS_COMPLETION_RUNBOOK.md</code>
</p>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
<th className="py-2 pr-4">Bridge</th>
<th className="py-2 pr-4">Destination</th>
<th className="py-2">Contract</th>
</tr>
</thead>
<tbody>
{routeEntries.map((entry) => (
<tr key={`${entry.bridge}-${entry.destination}`} className="border-b border-gray-100 last:border-0 dark:border-gray-800">
<td className="py-2 pr-4 font-medium text-gray-900 dark:text-white">{entry.bridge}</td>
<td className="py-2 pr-4 text-gray-700 dark:text-gray-300">{entry.destination}</td>
<td className="py-2 font-mono text-xs text-gray-600 dark:text-gray-400">{entry.address}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
) : null}
<OperationsActionGrid actions={page.actions} />
</div>
)
}

View File

@@ -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>
)
}

View File

@@ -3,7 +3,9 @@
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import { configApi, type TokenListResponse } from '@/services/api/config'
import ExplorerRetryAlert from '@/components/common/ExplorerRetryAlert'
import { type TokenListResponse } from '@/services/api/config'
import { tokensApi } from '@/services/api/tokens'
import {
aggregateLiquidityPools,
featuredLiquiditySymbols,
@@ -22,6 +24,8 @@ 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,
@@ -76,6 +80,7 @@ export default function LiquidityOperationsPage({
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [loadingError, setLoadingError] = useState<string | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
useEffect(() => {
@@ -96,9 +101,10 @@ export default function LiquidityOperationsPage({
}
const load = async () => {
setLoadingError(null)
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult, statsResult, bridgeResult] =
await Promise.allSettled([
configApi.getTokenList(),
tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
routesApi.getRouteMatrix(),
plannerApi.getCapabilities(),
plannerApi.getInternalExecutionPlan(),
@@ -160,6 +166,7 @@ export default function LiquidityOperationsPage({
initialStats,
initialTokenList,
initialTokenPoolRecords,
reloadKey,
])
const featuredTokens = useMemo(
@@ -198,6 +205,11 @@ export default function LiquidityOperationsPage({
}),
[bridgeStatus, stats],
)
const liquidityInventoryUpdatedAt =
stats?.sampling?.stats_generated_at ||
stats?.freshness?.chain_head?.timestamp ||
routeMatrix?.generatedAt ||
routeMatrix?.updated
const insightLines = useMemo(
() => [
@@ -234,6 +246,12 @@ export default function LiquidityOperationsPage({
href: `/explorer-api/v1/mission-control/liquidity/token/${featuredTokens[0]?.address || '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'}/pools`,
notes: 'Cached public pool inventory for a specific Chain 138 token.',
},
{
name: 'External indexer readiness',
method: 'GET',
href: `/api/v1/report/external-indexer-readiness?chainId=138`,
notes: 'One JSON posture for DefiLlama, CoinGecko, CoinMarketCap, and Dexscreener readiness.',
},
]
const copyEndpoint = async (endpoint: EndpointCard) => {
@@ -252,7 +270,7 @@ export default function LiquidityOperationsPage({
<main className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-8 max-w-4xl">
<div className="mb-3 inline-flex rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">
Chain 138 Liquidity Access
Liquidity
</div>
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
Public liquidity, route discovery, and execution access points
@@ -262,12 +280,20 @@ export default function LiquidityOperationsPage({
public route matrix, planner capabilities, and mission-control token pool inventory together
so integrators can inspect what Chain 138 is actually serving right now.
</p>
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
</div>
<OperationsSurfaceNav />
{loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
</Card>
<ExplorerRetryAlert
className="mb-6"
message={loadingError}
onRetry={() => {
setLoadingError(null)
setReloadKey((value) => value + 1)
}}
/>
) : null}
<div className="mb-6">
@@ -321,7 +347,7 @@ export default function LiquidityOperationsPage({
</div>
<MarketEvidenceNote
source="mission-control"
lastUpdated={routeMatrix?.updated}
lastUpdated={liquidityInventoryUpdatedAt}
method="Route matrix, provider capabilities, and mission-control pool inventory are reconciled for visible public liquidity only."
compact
/>
@@ -363,7 +389,7 @@ export default function LiquidityOperationsPage({
</div>
<MarketEvidenceNote
source="mission-control"
lastUpdated={routeMatrix?.updated}
lastUpdated={liquidityInventoryUpdatedAt}
method="Pool TVL is the visible mission-control value for discovered route-backed liquidity."
compact
/>

View 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>
</>
)
}

View File

@@ -3,6 +3,7 @@ import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import { explorerFeaturePages } from '@/data/explorerOperations'
import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config'
import { tokensApi } from '@/services/api/tokens'
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
import { useUiMode } from '@/components/common/UiModeContext'
@@ -10,6 +11,10 @@ import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
import OperationsActionGrid from '@/components/explorer/OperationsActionGrid'
import BridgeLaneHealthPanel from '@/components/explorer/BridgeLaneHealthPanel'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
@@ -88,7 +93,7 @@ export default function OperationsHubPage({
missionControlApi.getBridgeStatus(),
routesApi.getRouteMatrix(),
configApi.getNetworks(),
configApi.getTokenList(),
tokensApi.listForSurface('extended', 138).then(({ ok, data }) => ({ tokens: ok ? data : [] })),
configApi.getCapabilities(),
statsApi.get(),
])
@@ -152,6 +157,25 @@ export default function OperationsHubPage({
new Set((tokenList?.tokens || []).map((token) => token.symbol).filter(Boolean) as string[])
).slice(0, 8)
}, [tokenList])
const laneSummary = useMemo(() => {
const lanes = bridgeStatus?.data?.bridge_lanes?.lanes || []
if (lanes.length === 0) return null
const funded = lanes.filter((lane) => String(lane.status || '').toLowerCase() === 'funded').length
const unfunded = lanes.filter((lane) => String(lane.status || '').toLowerCase() === 'unfunded').length
const proofRecorded = lanes.filter(
(lane) => String(lane.proof_status || '').toLowerCase() === 'proof-recorded'
).length
return {
total: lanes.length,
funded,
unfunded,
proofRecorded,
updatedAt: bridgeStatus?.data?.bridge_lanes?.updated_at,
}
}, [bridgeStatus])
const activityContext = useMemo(
() =>
summarizeChainActivity({
@@ -179,6 +203,7 @@ export default function OperationsHubPage({
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
{page.description}
</p>
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
</div>
{page.note ? (
@@ -189,6 +214,8 @@ export default function OperationsHubPage({
</Card>
) : null}
<OperationsSurfaceNav />
{loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
@@ -224,6 +251,14 @@ export default function OperationsHubPage({
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
{relayCount} managed lanes · queue {totalQueue}
</div>
{laneSummary ? (
<div className="mt-1 text-sm text-gray-700 dark:text-gray-300">
{laneSummary.funded}/{laneSummary.total} config-ready funded
{laneSummary.unfunded > 0 ? ` · ${laneSummary.unfunded} unfunded` : ''}
{' · '}
{laneSummary.proofRecorded}/{laneSummary.total} proof-recorded
</div>
) : null}
</Card>
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
@@ -305,6 +340,23 @@ export default function OperationsHubPage({
</div>
</div>
</div>
{laneSummary ? (
<div className="mt-4 rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-sm text-gray-500 dark:text-gray-400">Config-ready lanes</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{laneSummary.funded}/{laneSummary.total} funded · {laneSummary.proofRecorded}/{laneSummary.total} proof-recorded
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{mode === 'guided'
? 'Remote CCIP bridge LINK balances and operator proof hashes. Full lane table below.'
: 'Lane funding and proof posture from bridge status API.'}
{' '}
<Link href="/bridge" className="font-semibold text-primary-600 hover:underline">
Open bridge monitoring
</Link>
</div>
</div>
) : null}
</Card>
<Card title="Public Config Highlights">
@@ -335,27 +387,9 @@ export default function OperationsHubPage({
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{page.actions.map((action) => (
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">
{action.title}
</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
{action.description}
</p>
<div className="mt-4">
<ActionLink
href={action.href}
label={action.label}
external={'external' in action ? action.external : undefined}
/>
</div>
</div>
</Card>
))}
</div>
<BridgeLaneHealthPanel laneHealth={bridgeStatus?.data?.bridge_lanes} />
<OperationsActionGrid actions={page.actions} />
</div>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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">

View File

@@ -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,10 +22,18 @@ import { transactionsApi, type Transaction } from '@/services/api/transactions'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel'
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import { Explain, useUiMode } from '@/components/common/UiModeContext'
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { tokensApi } from '@/services/api/tokens'
import {
HOME_DASHBOARD_REFRESH_MS,
HOME_PRICE_FEED_REFRESH_MS,
resolveHomePriceFeedAddresses,
} from '@/utils/featuredTokens'
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
type HomeStats = ExplorerStats
@@ -123,6 +132,7 @@ export default function Home({
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'
@@ -156,8 +166,27 @@ export default function Home({
)
}, [chainId])
const loadFeaturedPrices = useCallback(async () => {
const [catalogResult, reportResult] = await Promise.all([
tokensApi.listForSurface('catalog', chainId),
tokensApi.listReportSafe(chainId),
])
const addresses = resolveHomePriceFeedAddresses(
catalogResult.ok ? catalogResult.data : [],
reportResult.ok ? reportResult.data : [],
)
const { data } = await tokenAggregationApi.getTokensByAddressSafe(chainId, addresses)
setFeaturedPrices(data)
}, [chainId])
useEffect(() => {
loadDashboard()
void loadDashboard()
return createVisibilityAwarePoller({
intervalMs: HOME_DASHBOARD_REFRESH_MS,
task: loadDashboard,
})
}, [loadDashboard])
useEffect(() => {
@@ -181,25 +210,29 @@ export default function Home({
useEffect(() => {
let cancelled = false
tokenAggregationApi.getTokensByAddressSafe(138, [
'0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
'0xf22258f57794CC8E06237084b353Ab30fFfa640b',
'0x290e52a8819A4fBd0714e517225429AA2B70EC6B',
'0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
]).then(({ data }) => {
if (!cancelled) {
setFeaturedPrices(data)
}
}).catch((error) => {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to load featured token prices:', error)
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
@@ -270,6 +303,31 @@ export default function Home({
}
}, [])
useEffect(() => {
if (relayFeedState !== 'fallback') return
let cancelled = false
const refreshSnapshot = async () => {
try {
const status = await missionControlApi.getBridgeStatus()
if (!cancelled) {
setBridgeStatus(status)
setRelaySummary(summarizeMissionControlRelay(status))
}
} catch (error) {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to refresh mission control snapshot:', error)
}
}
}
return createVisibilityAwarePoller({
intervalMs: HOME_DASHBOARD_REFRESH_MS,
task: refreshSnapshot,
})
}, [relayFeedState])
const relayToneClasses =
relaySummary?.tone === 'danger'
? 'border-red-200 bg-red-50 text-red-900 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100'
@@ -593,7 +651,7 @@ export default function Home({
href="/bridge"
className="inline-flex items-center justify-center rounded-xl bg-gray-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-black dark:bg-white dark:text-gray-900 dark:hover:bg-gray-100"
>
Open bridge monitoring
Open bridge
</Link>
<Link
href="/operations"
@@ -716,9 +774,20 @@ export default function Home({
</Card>
)}
{(relaySummary || bridgeStatus || stats) && (
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-gray-200 bg-white/80 px-4 py-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-950/60">
<EntityBadge label={`block ${latestBlock != null ? latestBlock.toLocaleString() : 'unknown'}`} tone="info" />
{chainStatus?.status ? <EntityBadge label={`chain ${chainStatus.status}`} tone={chainStatus.status === 'operational' ? 'success' : 'warning'} /> : null}
{relaySummary ? <EntityBadge label={`${relayOperationalCount} relays ok`} tone="success" /> : null}
<EntityBadge label={relayFeedState === 'live' ? 'live feed' : relayFeedState === 'fallback' ? 'snapshot feed' : 'connecting'} tone={relayFeedState === 'live' ? 'success' : 'info'} />
<span className="text-gray-600 dark:text-gray-400">{latestTransactionAgeLabel}</span>
</div>
)}
{stats && (
<div className="mb-8 space-y-4">
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
<div className="mb-6 space-y-4">
<p className="text-sm font-semibold text-gray-900 dark:text-white">Network overview</p>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{primaryMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
@@ -728,48 +797,79 @@ export default function Home({
))}
</div>
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-3">
{activityMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">{card.note}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.detail}</div>
</Card>
))}
</div>
<MissionDeliveryModePanel className="mt-1" mode={missionMode} title="Homepage delivery mode" />
<ActivityContextPanel
context={activityContext}
title="Freshness Interpretation"
compact
/>
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel={
mode === 'guided'
? 'Homepage status combines chain freshness, transaction visibility, and mission-control posture.'
: 'Homepage freshness view aligns chain, transaction, and mission-control posture.'
}
/>
{mode === 'guided' ? (
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
{secondaryMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</Card>
))}
</div>
) : (
<Card>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">Telemetry Snapshot</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Secondary public stats in a denser expert layout.
</div>
</div>
<div className="grid flex-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<button
type="button"
onClick={() => setStatsDetailsExpanded((current) => !current)}
className="text-sm font-semibold text-primary-600 hover:underline"
>
{statsDetailsExpanded ? 'Hide extended telemetry' : 'Show extended telemetry'}
</button>
{statsDetailsExpanded ? (
<>
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-3">
{activityMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">{card.note}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.detail}</div>
</Card>
))}
</div>
{mode === 'guided' ? (
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
{secondaryMetricCards.map((card) => (
<div key={card.label} className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{card.label}</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{card.value}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</div>
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</Card>
))}
</div>
</div>
</Card>
)}
) : (
<Card>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">Telemetry Snapshot</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Secondary public stats in a denser expert layout.
</div>
</div>
<div className="grid flex-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{secondaryMetricCards.map((card) => (
<div key={card.label} className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{card.label}</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{card.value}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</div>
))}
</div>
</div>
</Card>
)}
</>
) : null}
</div>
)}
@@ -803,25 +903,6 @@ export default function Home({
</div>
) : null}
<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>
{!stats && (
<Card className="mb-8">
<p className="text-sm text-gray-600 dark:text-gray-400">
@@ -830,7 +911,46 @@ export default function Home({
</Card>
)}
<Card title="Recent Blocks">
<div className="mb-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="Recent Transactions">
{recentTransactions.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent transactions are unavailable right now.
</p>
) : (
<div className="space-y-2">
{recentTransactions.map((transaction) => (
<div key={transaction.hash} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link href={`/transactions/${transaction.hash}`} className="text-primary-600 hover:underline">
{transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
</Link>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Block #{transaction.block_number} · from{' '}
<Address address={transaction.from_address} truncate showCopy={false} />
{transaction.to_address ? (
<>
{' '} <Address address={transaction.to_address} truncate showCopy={false} />
</>
) : null}
</div>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
<div>{formatWeiAsEth(transaction.value, 4)}</div>
<div className="text-xs">{formatTimestamp(transaction.created_at)}</div>
</div>
</div>
))}
</div>
)}
<div className="mt-4">
<Link href="/transactions" className="text-primary-600 hover:underline">
View all transactions
</Link>
</div>
</Card>
<Card title="Recent Blocks">
{recentBlocks.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent blocks are unavailable right now.
@@ -868,120 +988,41 @@ export default function Home({
View all blocks
</Link>
</div>
</Card>
<div className="mt-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="Activity Pulse">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
{mode === 'guided'
? 'A concise public view of chain activity, index coverage, and recent execution patterns.'
: 'Public chain activity and index posture.'}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Latest Daily Volume</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{latestTrendPoint ? latestTrendPoint.transaction_count.toLocaleString() : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{latestTrendPoint?.date || 'Trend feed unavailable'}</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent Success Rate</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{activitySnapshot ? `${Math.round(activitySnapshot.success_rate * 100)}%` : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{activitySnapshot ? `${activitySnapshot.sample_size} sampled transactions` : 'Recent activity snapshot unavailable'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Avg Recent Fee</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{activitySnapshot ? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6) : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Average fee from the recent public sample.</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Peak Charted Day</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{peakTrendPoint ? peakTrendPoint.transaction_count.toLocaleString() : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{peakTrendPoint?.date || 'No trend data yet'}</div>
</div>
</div>
<div className="mt-4">
<Link href="/analytics" className="text-primary-600 hover:underline">
Open full analytics
</Link>
</div>
</Card>
<Card title="Explorer Shortcuts">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Go directly to the explorer surfaces that provide the strongest operational and discovery context.
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/search" className="text-primary-600 hover:underline">
Search
</Link>
<Link href="/transactions" className="text-primary-600 hover:underline">
Transactions
</Link>
<Link href="/tokens" className="text-primary-600 hover:underline">
Tokens
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Addresses
</Link>
<Link href="/analytics" className="text-primary-600 hover:underline">
Analytics
</Link>
</div>
</Card>
<Card title="Liquidity & Routes">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Explore the public Chain 138 DODO PMM liquidity mesh, the canonical route matrix, and the
partner payload endpoints exposed through the explorer.
</p>
<div className="mt-4">
<Link href="/routes" className="text-primary-600 hover:underline">
Open routes and liquidity
</Link>
</div>
</Card>
<Card title="Wallet & Token Discovery">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Add Chain 138, Ethereum Mainnet, and ALL Mainnet to MetaMask, then use the explorer token
list URL so supported tokens appear automatically.
</p>
<div className="mt-4">
<Link href="/wallet" className="text-primary-600 hover:underline">
Open wallet tools
</Link>
</div>
</Card>
<Card title="Bridge & Relay Monitoring">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Open the public bridge monitoring surface for relay status, mission-control links, bridge trace tooling,
and the visual command center entry points.
</p>
<div className="mt-4">
<Link href="/bridge" className="text-primary-600 hover:underline">
Open bridge monitoring
</Link>
</div>
</Card>
<Card title="Operations Hub">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Open the public operations surface for wrapped-asset references, analytics shortcuts, operator links,
system topology views, and other Chain 138 support tools.
</p>
<div className="mt-4">
<Link href="/operations" className="text-primary-600 hover:underline">
Open operations hub
</Link>
</div>
</Card>
</div>
<Card title="Quick links" className="mt-8">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Jump to the explorer surfaces used most often for discovery, liquidity, wallet setup, and bridge monitoring.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Link href="/search" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Search
</Link>
<Link href="/access" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Account access
</Link>
<Link href="/tokens" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Tokens
</Link>
<Link href="/wallet" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Wallet & MetaMask
</Link>
<Link href="/routes" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Routes
</Link>
<Link href="/liquidity" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Liquidity
</Link>
<Link href="/bridge" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Bridge
</Link>
<Link href="/analytics" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Analytics
</Link>
</div>
</Card>
</main>
)
}

View File

@@ -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 MetaMasks install allowlist. */
const CHAIN138_OPEN_SNAP_ID = 'npm:chain138-open-snap' as const
@@ -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 MetaMasks 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&apos;s install allowlist; if install fails with &quot;not on the allowlist&quot;,
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&apos;s install allowlist; if install fails with &quot;not on the allowlist&quot;, 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}

View File

@@ -0,0 +1,32 @@
'use client'
import { useEffect, useState } from 'react'
import { getWalletConnectConfig, type WalletConnectConfigResponse } from '@/services/api/walletConnect'
export default function WalletConnectPostureNote() {
const [config, setConfig] = useState<WalletConnectConfigResponse | null>(null)
useEffect(() => {
let cancelled = false
getWalletConnectConfig()
.then((value) => {
if (!cancelled) setConfig(value)
})
.catch(() => {
if (!cancelled) setConfig(null)
})
return () => {
cancelled = true
}
}, [])
if (!config) return null
return (
<p className="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-400">
WalletConnect v2 posture: <strong>{config.enabled ? 'enabled' : config.status}</strong>. Browser extension wallets use{' '}
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">{config.fallbackAuth}</code> today.
{config.message ? ` ${config.message}` : ''}
</p>
)
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import type {
CapabilitiesCatalog,
FetchMetadata,
@@ -6,6 +6,9 @@ import type {
TokenListCatalog,
} from '@/components/wallet/AddToMetaMask'
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
import WalletConnectPostureNote from '@/components/wallet/WalletConnectPostureNote'
import { connectAndAuthenticateWalletConnect, getActiveWalletConnectSessionId, isWalletConnectClientReady, loadWalletConnectConfig } from '@/services/wallet/walletConnectClient'
import { registerWalletConnectSession } from '@/services/api/walletConnect'
import Link from 'next/link'
import { Explain, useUiMode } from '@/components/common/UiModeContext'
import { accessApi, type WalletAccessSession } from '@/services/api/access'
@@ -17,6 +20,8 @@ import {
type AddressTokenTransfer,
type TransactionSummary,
} from '@/services/api/addresses'
import { WALLET_SNAPSHOT_REFRESH_MS } from '@/utils/featuredTokens'
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
import { formatRelativeAge, formatTokenAmount } from '@/utils/format'
import {
isWatchlistEntry,
@@ -45,6 +50,8 @@ export default function WalletPage(props: WalletPageProps) {
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
const [connectingWallet, setConnectingWallet] = useState(false)
const [walletError, setWalletError] = useState<string | null>(null)
const [walletConnectReady, setWalletConnectReady] = useState(false)
const [connectingWalletConnect, setConnectingWalletConnect] = useState(false)
const [copiedAddress, setCopiedAddress] = useState(false)
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
@@ -65,6 +72,7 @@ export default function WalletPage(props: WalletPageProps) {
syncSession()
syncWatchlist()
void loadWalletConnectConfig().then((config) => setWalletConnectReady(isWalletConnectClientReady(config)))
window.addEventListener('explorer-access-session-changed', syncSession)
window.addEventListener('storage', syncWatchlist)
return () => {
@@ -73,6 +81,30 @@ export default function WalletPage(props: WalletPageProps) {
}
}, [])
const handleConnectWalletConnect = async () => {
setConnectingWalletConnect(true)
setWalletError(null)
try {
const config = await loadWalletConnectConfig()
if (!isWalletConnectClientReady(config)) {
throw new Error('WalletConnect is not enabled. Set WALLETCONNECT_PROJECT_ID on the explorer backend.')
}
const result = await connectAndAuthenticateWalletConnect(config, async (resolveAddress, signMessage) => {
const session = await accessApi.connectWalletSessionWithSigner(resolveAddress, signMessage)
setWalletSession(session)
return { address: session.address }
})
const sessionId = getActiveWalletConnectSessionId()
if (sessionId && result.address) {
await registerWalletConnectSession({ sessionId, address: result.address, chainId: 138 })
}
} catch (error) {
setWalletError(error instanceof Error ? error.message : 'WalletConnect connection failed.')
} finally {
setConnectingWalletConnect(false)
}
}
const handleConnectWallet = async () => {
setConnectingWallet(true)
setWalletError(null)
@@ -110,9 +142,41 @@ export default function WalletPage(props: WalletPageProps) {
? isWatchlistEntry(watchlistEntries, walletSession.address)
: false
const loadWalletSnapshot = useCallback(async (address: string) => {
const [infoResponse, transactionsResponse, balancesResponse, transfersResponse] = await Promise.all([
addressesApi.getSafe(138, address),
addressesApi.getTransactionsSafe(138, address, 1, 3),
addressesApi.getTokenBalancesSafe(address),
addressesApi.getTokenTransfersSafe(address, 1, 4),
])
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
setTokenBalances(
balancesResponse.ok
? [...balancesResponse.data]
.filter((balance) => {
try {
return BigInt(balance.value || '0') > 0n
} catch {
return Boolean(balance.value)
}
})
.sort((left, right) => {
try {
return Number(BigInt(right.value || '0') - BigInt(left.value || '0'))
} catch {
return 0
}
})
.slice(0, 4)
: [],
)
setTokenTransfers(transfersResponse.ok ? transfersResponse.data : [])
}, [])
useEffect(() => {
let cancelled = false
if (!walletSession?.address) {
setAddressInfo(null)
setRecentAddressTransactions([])
@@ -123,50 +187,32 @@ export default function WalletPage(props: WalletPageProps) {
}
}
Promise.all([
addressesApi.getSafe(138, walletSession.address),
addressesApi.getTransactionsSafe(138, walletSession.address, 1, 3),
addressesApi.getTokenBalancesSafe(walletSession.address),
addressesApi.getTokenTransfersSafe(walletSession.address, 1, 4),
])
.then(([infoResponse, transactionsResponse, balancesResponse, transfersResponse]) => {
if (cancelled) return
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
setTokenBalances(
balancesResponse.ok
? [...balancesResponse.data]
.filter((balance) => {
try {
return BigInt(balance.value || '0') > 0n
} catch {
return Boolean(balance.value)
}
})
.sort((left, right) => {
try {
return Number(BigInt(right.value || '0') - BigInt(left.value || '0'))
} catch {
return 0
}
})
.slice(0, 4)
: [],
)
setTokenTransfers(transfersResponse.ok ? transfersResponse.data : [])
})
.catch(() => {
if (cancelled) return
setAddressInfo(null)
setRecentAddressTransactions([])
setTokenBalances([])
setTokenTransfers([])
})
const refreshSnapshot = async () => {
try {
if (!cancelled) {
await loadWalletSnapshot(walletSession.address)
}
} catch {
if (!cancelled) {
setAddressInfo(null)
setRecentAddressTransactions([])
setTokenBalances([])
setTokenTransfers([])
}
}
}
void refreshSnapshot()
const stop = createVisibilityAwarePoller({
intervalMs: WALLET_SNAPSHOT_REFRESH_MS,
task: refreshSnapshot,
})
return () => {
cancelled = true
stop()
}
}, [walletSession?.address])
}, [loadWalletSnapshot, walletSession?.address])
return (
<main className="container mx-auto px-4 py-6 sm:py-8">
@@ -176,6 +222,7 @@ export default function WalletPage(props: WalletPageProps) {
? 'Use the explorer-served network catalog, token list, and capability metadata to connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets.'
: 'Use explorer-served network and token metadata to connect Chain 138 and Ethereum Mainnet wallets.'}
</p>
<WalletConnectPostureNote />
<div className="mb-6 rounded-2xl border border-sky-200 bg-sky-50/60 p-5 dark:border-sky-900/40 dark:bg-sky-950/20">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
@@ -261,12 +308,25 @@ export default function WalletPage(props: WalletPageProps) {
<>
<button
type="button"
onClick={handleConnectWallet}
onClick={() => void handleConnectWallet()}
disabled={connectingWallet}
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-70"
>
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
</button>
<button
type="button"
onClick={() => void handleConnectWalletConnect()}
disabled={!walletConnectReady || connectingWalletConnect}
title={
walletConnectReady
? 'Pair a mobile wallet via WalletConnect QR'
: 'Set WALLETCONNECT_PROJECT_ID on the explorer backend to enable WalletConnect'
}
className="rounded-lg border border-indigo-300 px-3 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-indigo-800 dark:text-indigo-300 dark:hover:bg-indigo-950/20"
>
{connectingWalletConnect ? 'Opening WalletConnect…' : 'WalletConnect'}
</button>
<Link
href="/access"
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
@@ -467,7 +527,7 @@ export default function WalletPage(props: WalletPageProps) {
<>
Need swap and liquidity discovery too? Visit the{' '}
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
Liquidity Access
Liquidity
</Link>{' '}
page for live Chain 138 pools, route matrix links, partner payload templates, and the internal fallback execution plan endpoints.
</>
@@ -476,7 +536,7 @@ export default function WalletPage(props: WalletPageProps) {
<>
Liquidity and planner posture lives on the{' '}
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
Liquidity Access
Liquidity
</Link>{' '}
surface.
</>

View File

@@ -11,6 +11,8 @@ export interface ExplorerFeaturePage {
title: string
description: string
note?: string
accessTrack?: number
accessNote?: string
actions: ExplorerFeatureAction[]
}
@@ -19,7 +21,7 @@ const sharedOperationsNote =
export const explorerFeaturePages = {
bridge: {
eyebrow: 'Bridge Monitoring',
eyebrow: 'Bridge',
title: 'Bridge & Relay Monitoring',
description:
'Inspect the CCIP relay status, follow the live mission-control stream, trace bridge transactions, and review the managed Mainnet, BSC, Avalanche, Avalanche cW, and Avalanche to Chain 138 lanes.',
@@ -79,7 +81,7 @@ export const explorerFeaturePages = {
title: 'Liquidity access',
description: 'Review the public Chain 138 PMM access points, route helpers, and fallback execution endpoints.',
href: '/liquidity',
label: 'Open liquidity access',
label: 'Open liquidity',
},
{
title: 'Pools inventory',
@@ -91,7 +93,7 @@ export const explorerFeaturePages = {
title: 'Bridge monitoring',
description: 'Cross-check route availability with live relay and bridge health before operator actions.',
href: '/bridge',
label: 'Open bridge monitoring',
label: 'Open bridge',
},
{
title: 'Operations hub',
@@ -112,7 +114,7 @@ export const explorerFeaturePages = {
title: 'Bridge monitoring',
description: 'Start with relay and bridge health before reviewing WETH-specific flows.',
href: '/bridge',
label: 'Open bridge monitoring',
label: 'Open bridge',
},
{
title: 'Visual command center',
@@ -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',
@@ -170,17 +175,20 @@ export const explorerFeaturePages = {
],
},
operator: {
eyebrow: 'Operator Surface',
title: 'Operator Surface',
eyebrow: 'Operator',
title: 'Operator',
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',
description: 'Open relay status, queue posture, and bridge trace tools.',
href: '/bridge',
label: 'Open bridge monitoring',
label: 'Open bridge',
},
{
title: 'Routes',
@@ -192,7 +200,7 @@ export const explorerFeaturePages = {
title: 'Liquidity access',
description: 'Open partner payload helpers, route APIs, and execution-plan endpoints.',
href: '/liquidity',
label: 'Open liquidity access',
label: 'Open liquidity',
},
{
title: 'Explorer docs',
@@ -227,7 +235,7 @@ export const explorerFeaturePages = {
title: 'Bridge monitoring',
description: 'Correlate topology context with the live bridge and relay status surface.',
href: '/bridge',
label: 'Open bridge monitoring',
label: 'Open bridge',
},
{
title: 'Explorer docs',
@@ -244,8 +252,8 @@ export const explorerFeaturePages = {
],
},
operations: {
eyebrow: 'Operations Hub',
title: 'Operations Hub',
eyebrow: 'Operations hub',
title: 'Operations hub',
description:
'This hub exposes the public operational surfaces for bridge monitoring, routes, wrapped-asset references, analytics shortcuts, operator links, and topology views.',
note: sharedOperationsNote,
@@ -254,7 +262,7 @@ export const explorerFeaturePages = {
title: 'Bridge & relay monitoring',
description: 'Open mission-control status, SSE monitoring, and bridge trace helpers.',
href: '/bridge',
label: 'Open bridge monitoring',
label: 'Open bridge',
},
{
title: 'Routes & liquidity',
@@ -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

View File

@@ -0,0 +1,101 @@
export type PostureGlossaryTermId =
| 'x402'
| 'iso20022'
| 'forward-canonical'
| 'reference-asset'
| 'cw-public-network'
| 'transport-active'
| 'gru'
export interface PostureGlossaryTerm {
id: PostureGlossaryTermId
title: string
shortLabel: string
summary: string
methodology: string
}
export const postureGlossaryTerms: PostureGlossaryTerm[] = [
{
id: 'gru',
title: 'GRU instrument',
shortLabel: 'GRU',
summary: 'A Global Reserve Unit instrument issued under DBIS taxonomy on Chain 138 or represented on public networks.',
methodology:
'The explorer marks GRU when token metadata, registry tags, or curated catalog entries align with the GRU transport and compliance model documented in the GRU guide.',
},
{
id: 'x402',
title: 'x402 readiness',
shortLabel: 'x402 ready',
summary: 'The token surface exposes the typed-data and domain metadata commonly required for HTTP-native payment authorization flows.',
methodology:
'Readiness is inferred from detected signing surfaces (EIP-712 domain, ERC-5267, and ERC-2612 or ERC-3009). It describes technical capability — not a guarantee that a live x402 merchant endpoint exists.',
},
{
id: 'iso20022',
title: 'ISO-20022 alignment',
shortLabel: 'ISO-20022',
summary: 'The asset is modeled as part of the ISO-20022-aligned settlement and reporting posture for institutional messaging.',
methodology:
'This badge reflects GRU metadata and governance expectations around supervised disclosure — not a claim that every transfer is already formatted as an ISO-20022 message on-chain.',
},
{
id: 'forward-canonical',
title: 'Forward-canonical posture',
shortLabel: 'forward canonical',
summary: 'The asset version is the forward-looking canonical representation operators should wire for new integrations.',
methodology:
'Used when a token family has legacy or parallel deployments. Prefer forward-canonical addresses for routers, wallets, and explorer deep links unless a migration note says otherwise.',
},
{
id: 'reference-asset',
title: 'Reference asset',
shortLabel: 'reference asset',
summary: 'A non-GRU mirrored or externally issued asset shown for routing, liquidity, or price context.',
methodology:
'Reference assets help explain cross-chain routes and pool composition. They are not implied to be DBIS-issued compliant instruments unless separately tagged.',
},
{
id: 'cw-public-network',
title: 'cW public-network representation',
shortLabel: 'cW public-network',
summary: 'A wrapped GRU instrument activated on a public network (for example mainnet cWUSDC) while Chain 138 remains the program ledger.',
methodology:
'Public-network overlays use cW* naming. Liquidity and bridge lanes may reference these addresses even when the canonical compliant token lives on Chain 138.',
},
{
id: 'transport-active',
title: 'transportActive (config compatibility)',
shortLabel: 'transportActive',
summary: 'Legacy JSON key indicating whether a public-network transport overlay is active in published `/config` manifests.',
methodology:
'Machine consumers should treat this as a v1 compatibility field. A future v2 schema will expose `publicNetworkActive` aliases before old keys are removed.',
},
]
const labelToTermId: Record<string, PostureGlossaryTermId> = {
gru: 'gru',
'x402 ready': 'x402',
'x402 not ready': 'x402',
'iso-20022': 'iso20022',
'iso-20022 aligned': 'iso20022',
'iso-20022 unclear': 'iso20022',
'forward canonical': 'forward-canonical',
'reference asset': 'reference-asset',
wrapped: 'cw-public-network',
transportactive: 'transport-active',
}
export function resolvePostureTermId(label: string): PostureGlossaryTermId | null {
const normalized = label.trim().toLowerCase()
if (labelToTermId[normalized]) return labelToTermId[normalized]
if (normalized.startsWith('cw public-network')) return 'cw-public-network'
if (normalized.startsWith('forward ')) return 'forward-canonical'
if (normalized.includes('transportactive') || normalized.includes('transport active')) return 'transport-active'
return null
}
export function getPostureGlossaryTerm(id: PostureGlossaryTermId): PostureGlossaryTerm | undefined {
return postureGlossaryTerms.find((term) => term.id === id)
}

View File

@@ -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'
@@ -21,6 +21,7 @@ import {
import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format'
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import PostureBadge from '@/components/common/PostureBadge'
import {
isWatchlistEntry,
readWatchlistFromStorage,
@@ -28,8 +29,11 @@ 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'
@@ -68,6 +72,11 @@ export default function AddressDetailPage() {
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 {
@@ -369,8 +378,8 @@ export default function AddressDetailPage() {
<span>{balance.token_symbol || balance.token_name || 'Token'}</span>
)}
{gruMetadata ? <EntityBadge label="GRU" tone="success" /> : null}
{gruMetadata?.x402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
{gruMetadata?.iso20022Ready ? <EntityBadge label="ISO-20022" tone="info" /> : null}
{gruMetadata?.x402Ready ? <PostureBadge label="x402 ready" tone="info" /> : null}
{gruMetadata?.iso20022Ready ? <PostureBadge label="ISO-20022" tone="info" /> : null}
</div>
{balance.token_name && balance.token_symbol && (
<div className="text-xs text-gray-500 dark:text-gray-400">{balance.token_name}</div>
@@ -422,9 +431,9 @@ export default function AddressDetailPage() {
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium text-gray-900 dark:text-white">{transfer.token_symbol || transfer.token_name || 'Token'}</div>
{gruMetadata ? <EntityBadge label="GRU" tone="success" /> : null}
{gruMetadata?.x402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
{gruMetadata?.iso20022Ready ? <EntityBadge label="ISO-20022" tone="info" /> : null}
{gruMetadata?.transportActiveVersion ? <EntityBadge label={`cW public-network ${gruMetadata.transportActiveVersion}`} tone="warning" /> : null}
{gruMetadata?.x402Ready ? <PostureBadge label="x402 ready" tone="info" /> : null}
{gruMetadata?.iso20022Ready ? <PostureBadge label="ISO-20022" tone="info" /> : null}
{gruMetadata?.transportActiveVersion ? <PostureBadge 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">
@@ -499,6 +508,34 @@ export default function AddressDetailPage() {
).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">
@@ -633,7 +670,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">
@@ -848,13 +891,13 @@ export default function AddressDetailPage() {
</Card>
)}
{addressInfo.is_contract && contractProfile ? (
{activeTab === 'contract' && addressInfo.is_contract && contractProfile ? (
<ContractCodeWorkspace address={addressInfo.address} profile={contractProfile} />
) : null}
{gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
{activeTab === 'contract' && gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
<Card title="Token Balances" className="mb-6">
{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>
@@ -865,13 +908,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>
@@ -885,20 +934,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>

View File

@@ -5,6 +5,21 @@ import { Card } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
const docsCards = [
{
title: 'Posture glossary',
href: '/docs/posture-glossary',
description: 'First-read definitions for x402, ISO-20022, forward-canonical, cW public-network, and related explorer badges.',
},
{
title: 'Config compatibility keys',
href: '/docs/posture-glossary#transportactive-config-compatibility',
description: 'Methodology for public /config compatibility keys (transportActive, forward-canonical) and planned v2 alias mapping.',
},
{
title: 'Public API access',
href: '/docs/public-api-access',
description: 'Read-only JSON endpoints, managed RPC keys on /access, and the current no-key policy for Blockscout reads.',
},
{
title: 'GRU Guide',
href: '/docs/gru',

View File

@@ -0,0 +1,82 @@
'use client'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
import PostureBadge from '@/components/common/PostureBadge'
import { postureGlossaryTerms } from '@/data/postureGlossary'
export default function PostureGlossaryDocsPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
eyebrow="Explorer Documentation"
title="Posture glossary"
description="First-read explanations for institutional posture badges shown on token, address, transaction, and search surfaces."
actions={[
{ href: '/docs/gru', label: 'GRU guide' },
{ href: '/tokens', label: 'Browse tokens' },
{ href: '/search?q=cUSDC', label: 'Search cUSDC' },
]}
/>
<div className="space-y-6">
<Card title="How to use this glossary">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Click any posture badge on a live token, address, or transaction page to open the same definitions in a drawer.
This page is the canonical long-form reference for audit and policy reviewers.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<PostureBadge label="GRU" tone="success" />
<PostureBadge label="x402 ready" tone="info" />
<PostureBadge label="ISO-20022" tone="info" />
<PostureBadge label="forward canonical" tone="success" />
<PostureBadge label="reference asset" tone="info" />
</div>
</Card>
{postureGlossaryTerms.map((term) => (
<div key={term.id} id={term.id === 'transport-active' ? 'transportactive-config-compatibility' : undefined}>
<Card title={term.title}>
<div className="space-y-3 text-sm leading-6 text-gray-600 dark:text-gray-400">
<p>{term.summary}</p>
<div className="rounded-xl border border-sky-200 bg-sky-50/70 p-4 text-sky-950 dark:border-sky-900/50 dark:bg-sky-950/20 dark:text-sky-100">
<p className="text-xs font-semibold uppercase tracking-wide text-sky-700 dark:text-sky-300">Methodology</p>
<p className="mt-2">{term.methodology}</p>
</div>
{term.id === 'transport-active' ? (
<p>
Planned v2 aliases: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">publicNetworkActive</code>
{' '}and{' '}
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">livePublicNetworkAssets</code>.
</p>
) : null}
</div>
</Card>
</div>
))}
<Card title="Related references">
<ul className="list-disc space-y-2 pl-5 text-sm leading-6 text-gray-600 dark:text-gray-400">
<li>
<Link href="/docs/gru" className="text-primary-600 hover:underline">
GRU guide
</Link>
</li>
<li>
<Link href="/docs/public-api-access" className="text-primary-600 hover:underline">
Public API access
</Link>
</li>
<li>
Public machine config at{' '}
<a href="/config/DUAL_CHAIN_TOKEN_LIST.tokenlist.json" className="text-primary-600 hover:underline">
/config/DUAL_CHAIN_TOKEN_LIST.tokenlist.json
</a>
</li>
</ul>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,87 @@
'use client'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
import { explorerPublicApiLinks } from '@/data/explorerOperations'
export default function PublicApiAccessDocsPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
eyebrow="Explorer Documentation"
title="Public API access"
description="How integrators use read-only explorer APIs today, how managed RPC keys work on /access, and the planned path if public rate limits require API keys."
actions={[
{ href: '/access', label: 'Account access' },
{ href: '/wallet', label: 'Wallet tools' },
{ href: '/operations', label: 'Operations hub' },
]}
/>
<div className="space-y-6">
<Card title="Decision summary (2026-05-23)">
<ul className="list-disc space-y-2 pl-5 text-sm leading-6 text-gray-600 dark:text-gray-400">
<li>
<strong>Blockscout read API</strong> (<code>/api/v2/*</code>) and the public JSON surfaces listed below remain{' '}
<strong>unauthenticated</strong> for integrators on the public explorer domain.
</li>
<li>
<strong>Managed RPC product keys</strong> are issued through wallet-authenticated{' '}
<Link href="/access" className="text-primary-600 hover:underline">
Account access
</Link>{' '}
(Core RPC / thirdweb-rpc products). These keys gate managed RPC endpoints not the public Blockscout read layer.
</li>
<li>
If abuse or rate limits require change, the preferred near-term path is <strong>Option B</strong>: nginx/API-gateway
throttling with optional <code>X-API-Key</code> for higher quotas. A full external developer portal remains optional.
</li>
</ul>
</Card>
<Card title="Read-only public endpoints (no key)">
<ul className="space-y-3 text-sm">
{explorerPublicApiLinks.map((link) => (
<li key={link.href}>
<a href={link.href} className="font-medium text-primary-600 hover:underline" target="_blank" rel="noopener noreferrer">
{link.label}
</a>
<p className="mt-0.5 text-gray-600 dark:text-gray-400">{link.description}</p>
</li>
))}
<li>
<span className="font-medium text-gray-900 dark:text-white">Blockscout v2</span>
<p className="mt-0.5 text-gray-600 dark:text-gray-400">
Same-origin <code>/api/v2/stats</code>, blocks, transactions, tokens, and address endpoints proxied to Blockscout.
</p>
</li>
</ul>
</Card>
<Card title="Requesting higher limits or RPC keys">
<div className="space-y-3 text-sm leading-6 text-gray-600 dark:text-gray-400">
<p>
For managed RPC access, connect a wallet on{' '}
<Link href="/wallet" className="text-primary-600 hover:underline">
Wallet tools
</Link>{' '}
then open{' '}
<Link href="/access" className="text-primary-600 hover:underline">
Account access
</Link>{' '}
to create scoped keys with tier, product, expiry, and optional monthly quota.
</p>
<p>
For integrator questions about public JSON endpoints or future Blockscout key policy, email{' '}
<a href="mailto:support@d-bis.org" className="text-primary-600 hover:underline">
support@d-bis.org
</a>
.
</p>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -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,

View File

@@ -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(),
])

View File

@@ -3,9 +3,11 @@ 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 PostureBadge from '@/components/common/PostureBadge'
import {
inferDirectSearchTarget,
inferTokenSearchTarget,
@@ -15,6 +17,7 @@ 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'
@@ -92,9 +95,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) {
@@ -392,7 +395,7 @@ export default function SearchPage({
{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_x402_ready && <PostureBadge 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}
@@ -491,10 +494,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) &&

View File

@@ -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),

View File

@@ -10,11 +10,16 @@ import type { MissionControlLiquidityPool } from '@/services/api/routes'
import PageIntro from '@/components/common/PageIntro'
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import PostureBadge from '@/components/common/PostureBadge'
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)
@@ -50,17 +55,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)
@@ -68,11 +80,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 {
@@ -85,6 +100,7 @@ export default function TokenDetailPage() {
setTransfers([])
setPools([])
setGruProfile(null)
setContractProfile(null)
} finally {
setLoading(false)
}
@@ -98,6 +114,7 @@ export default function TokenDetailPage() {
if (!isValidTokenAddress) {
setLoading(false)
setToken(null)
setContractProfile(null)
return
}
void loadToken()
@@ -177,6 +194,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 = [
{
@@ -215,8 +261,8 @@ export default function TokenDetailPage() {
return (
<div className="flex flex-wrap gap-2">
<EntityBadge label="GRU" tone="success" />
{gruMetadata.x402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
{gruMetadata.iso20022Ready ? <EntityBadge label="ISO-20022" tone="info" /> : null}
{gruMetadata.x402Ready ? <PostureBadge label="x402 ready" tone="info" /> : null}
{gruMetadata.iso20022Ready ? <PostureBadge label="ISO-20022" tone="info" /> : null}
</div>
)
},
@@ -274,7 +320,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' },
@@ -303,15 +349,32 @@ 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">
{!provenance?.listed ? (
<Card className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<div className="flex flex-wrap items-center gap-2">
<PostureBadge label="Non-canonical indexed token" tone="warning" />
</div>
<p className="mt-3 text-sm leading-6 text-amber-950 dark:text-amber-100">
This contract is indexed by Blockscout but is not in the curated Chain 138 token registry. Prefer canonical addresses from the{' '}
<Link href="/tokens" className="font-medium text-primary-600 hover:underline">token index</Link>
{' '}and the posture glossary for trading, liquidity, and bridge routing.
</p>
</Card>
) : null}
<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>
@@ -342,7 +405,11 @@ 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>
@@ -387,19 +454,19 @@ 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">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">x402 readiness</div>
<div className="mt-3 flex flex-wrap gap-2">
<EntityBadge label={gruExplorerMetadata.x402Ready ? 'x402 ready' : 'x402 not ready'} tone={gruExplorerMetadata.x402Ready ? 'success' : 'warning'} />
<PostureBadge label={gruExplorerMetadata.x402Ready ? 'x402 ready' : 'x402 not ready'} tone={gruExplorerMetadata.x402Ready ? 'success' : 'warning'} />
{gruExplorerMetadata.x402PreferredVersion ? <EntityBadge label={`preferred ${gruExplorerMetadata.x402PreferredVersion}`} tone="info" /> : null}
{gruExplorerMetadata.canonicalForwardVersion ? <EntityBadge label={`forward ${gruExplorerMetadata.canonicalForwardVersion}`} tone="success" /> : null}
{gruExplorerMetadata.canonicalForwardVersion ? <PostureBadge label={`forward ${gruExplorerMetadata.canonicalForwardVersion}`} tone="success" /> : null}
</div>
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
{gruExplorerMetadata.x402Ready
@@ -410,7 +477,7 @@ export default function TokenDetailPage() {
<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">ISO-20022 and governance</div>
<div className="mt-3 flex flex-wrap gap-2">
<EntityBadge label={gruExplorerMetadata.iso20022Ready ? 'ISO-20022 aligned' : 'ISO-20022 unclear'} tone={gruExplorerMetadata.iso20022Ready ? 'success' : 'warning'} />
<PostureBadge label={gruExplorerMetadata.iso20022Ready ? 'ISO-20022 aligned' : 'ISO-20022 unclear'} tone={gruExplorerMetadata.iso20022Ready ? 'success' : 'warning'} />
{gruExplorerMetadata.currencyCode ? <EntityBadge label={gruExplorerMetadata.currencyCode} tone="neutral" /> : null}
</div>
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
@@ -419,6 +486,9 @@ export default function TokenDetailPage() {
: 'The explorer does not currently have a strong ISO-20022 posture signal for this asset.'}
</div>
<div className="mt-3 flex flex-wrap gap-3 text-sm">
<Link href="/docs/posture-glossary" className="text-primary-600 hover:underline">
Posture glossary
</Link>
<Link href="/docs/gru" className="text-primary-600 hover:underline">
GRU guide
</Link>
@@ -434,7 +504,7 @@ 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">
@@ -467,16 +537,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>
@@ -492,20 +564,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>

View File

@@ -6,10 +6,16 @@ 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 { tokensApi, type IndexedTokenListItem } from '@/services/api/tokens'
import type { TokenListToken } from '@/services/api/config'
import {
buildCanonicalAddressSet,
isCanonicalTokenAddress,
sortIndexedTokensCanonicalFirst,
} from '@/utils/canonicalTokens'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { fetchTokenListForSurface, TOKEN_LIST_SURFACE_LABELS } from '@/services/api/tokenListSurfaces'
import { selectCuratedFeaturedTokens } from '@/utils/featuredTokens'
const quickSearches = [
{ label: 'cUSDT', description: 'Canonical compliant USD treasury / government bond liquidity and address results.' },
@@ -55,6 +61,8 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
const router = useRouter()
const [query, setQuery] = useState('')
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>(initialCuratedTokens)
const [indexedTokens, setIndexedTokens] = useState<IndexedTokenListItem[]>([])
const [indexedLoading, setIndexedLoading] = useState(true)
const [featuredMarkets, setFeaturedMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
const handleSubmit = (event: React.FormEvent) => {
@@ -70,7 +78,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 : [])
}
@@ -84,14 +92,37 @@ 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])
const canonicalAddressSet = useMemo(() => buildCanonicalAddressSet(curatedTokens), [curatedTokens])
const sortedIndexedTokens = useMemo(
() => sortIndexedTokensCanonicalFirst(indexedTokens, canonicalAddressSet),
[canonicalAddressSet, indexedTokens],
)
useEffect(() => {
let active = true
setIndexedLoading(true)
tokensApi.listIndexedSafe(1, 50).then(({ ok, data }) => {
if (!active) return
setIndexedTokens(ok ? data : [])
setIndexedLoading(false)
}).catch(() => {
if (active) {
setIndexedTokens([])
setIndexedLoading(false)
}
})
return () => {
active = false
}
}, [])
useEffect(() => {
let active = true
@@ -120,7 +151,7 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
<PageIntro
eyebrow="Token Discovery"
title="Tokens"
description="Browse curated Chain 138 assets, open token contracts directly, and review holders, transfers, liquidity, and provenance from the same institutional explorer surface."
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' },
@@ -128,64 +159,31 @@ 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 GRU, compliant, cW public-network, and reference asset 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">
<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) => {
@@ -194,17 +192,29 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
<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"
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="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">
<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>
</p>
</div>
</div>
{market ? (
<div className="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-300">
<div>Live price: {formatUsd(market.priceUsd)}</div>
<div>Visible liquidity: {formatUsd(market.liquidityUsd)}</div>
<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 && (
@@ -221,17 +231,60 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
</Card>
</div>
<div className="mt-8">
<div className="mt-5">
<Card title="Indexed tokens (Blockscout)">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Canonical registry tokens appear first. Non-canonical indexed duplicates are labeled so operators can distinguish curated assets from stray contract listings.
</p>
{indexedLoading ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Loading indexed token feed</p>
) : sortedIndexedTokens.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Indexed token feed is temporarily unavailable.</p>
) : (
<div className="grid gap-2">
{sortedIndexedTokens.map((token) => {
const canonical = isCanonicalTokenAddress(token.address, canonicalAddressSet)
return (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="flex flex-col gap-2 rounded-lg border border-gray-200 px-3 py-3 transition hover:border-primary-400 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-950/50 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-gray-900 dark:text-white">
{token.symbol || token.name || 'Token'}
</span>
{canonical ? (
<EntityBadge label="canonical" tone="success" className="px-2 py-0.5 text-[11px]" />
) : (
<EntityBadge label="non-canonical indexed" tone="warning" className="px-2 py-0.5 text-[11px]" />
)}
</div>
<p className="mt-1 truncate text-xs text-gray-600 dark:text-gray-400">{token.name || token.address}</p>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{token.holders != null ? `${token.holders.toLocaleString()} holders` : 'Holders unavailable'}
</div>
</Link>
)
})}
</div>
)}
</Card>
</div>
<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>
@@ -242,15 +295,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,
}

View File

@@ -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'
@@ -14,10 +14,17 @@ import {
import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format'
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import PostureBadge from '@/components/common/PostureBadge'
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 TokenAggregationHistoricalPriceSnapshot } from '@/services/api/tokenAggregation'
import {
tokenAggregationApi,
type CheckpointTxAttestationSnapshot,
type TokenAggregationHistoricalPriceSnapshot,
} from '@/services/api/tokenAggregation'
import { estimateNativeUsdValue, estimateTokenUsdValue, getNativeAssetDescriptor, getNativeAssetPriceAtSafe } from '@/services/api/nativeAssetPricing'
function isValidTransactionHash(value: string) {
@@ -69,7 +76,12 @@ export default function TransactionDetailPage() {
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)
@@ -185,6 +197,25 @@ export default function TransactionDetailPage() {
}
}, [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',
@@ -198,8 +229,8 @@ export default function TransactionDetailPage() {
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium text-gray-900 dark:text-white">{transfer.token_symbol || transfer.token_name || 'Token'}</div>
{gruPosture?.isGru ? <EntityBadge label="GRU" tone="success" /> : null}
{gruPosture?.isX402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
{gruPosture?.isWrappedTransport ? <EntityBadge label="wrapped" tone="warning" /> : null}
{gruPosture?.isX402Ready ? <PostureBadge label="x402 ready" tone="info" /> : null}
{gruPosture?.isWrappedTransport ? <PostureBadge label="wrapped" tone="warning" /> : null}
</div>
{transfer.token_address && (
<Link href={`/tokens/${transfer.token_address}`} className="text-primary-600 hover:underline">
@@ -234,16 +265,24 @@ export default function TransactionDetailPage() {
{
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 = estimateTokenUsdValue(transfer.amount, transfer.token_decimals, historicalPrice?.priceUsd)
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 ? formatUsd(totalUsd) : 'Unavailable'}</div>
<div>{totalUsd != null && Number.isFinite(totalUsd) ? formatUsd(totalUsd) : 'Unavailable'}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Unit price: {formatUsd(historicalPrice?.priceUsd)}
Unit price: {checkpointLine?.valueUsd != null ? 'checkpoint leaf' : formatUsd(historicalPrice?.priceUsd)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Source: {formatHistoricalPriceSource(historicalPrice?.source)}
Source: {checkpointLine ? `checkpoint (${priceSource || 'enriched'})` : formatHistoricalPriceSource(priceSource)}
</div>
</div>
)
@@ -299,8 +338,14 @@ export default function TransactionDetailPage() {
const tokenTransferCount = transaction?.token_transfers?.length || 0
const internalCallCount = internalCalls.length
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
const nativeValueUsd = estimateNativeUsdValue(transaction?.value, historicalNativePrice?.priceUsd)
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,
@@ -308,6 +353,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">
@@ -406,7 +474,13 @@ export default function TransactionDetailPage() {
{nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
</div>
<div>Transfer-time native price: {historicalNativePrice?.priceUsd != null ? `${formatUsd(historicalNativePrice.priceUsd)} per ${nativeAssetSymbol}` : 'Unavailable'}</div>
<div>Pricing source: {formatHistoricalPriceSource(historicalNativePrice?.source)}</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>
@@ -430,7 +504,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">
@@ -458,7 +534,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} />
@@ -522,9 +598,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">
@@ -553,25 +629,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}

View File

@@ -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),
])

View File

@@ -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,6 +166,7 @@ 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 DBIS Explorer.\n\nNonce: ${nonce}`
}
@@ -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
@@ -239,21 +265,30 @@ export const accessApi = {
throw new Error('No EOA wallet detected. Please open the explorer with a browser wallet installed.')
}
const accounts = (await window.ethereum.request({
method: 'eth_requestAccounts',
})) as string[]
const address = accounts?.[0]
if (!address) {
throw new Error('Wallet connection was cancelled.')
}
return accessApi.connectWalletSessionWithSigner(async () => {
const accounts = (await window.ethereum!.request({
method: 'eth_requestAccounts',
})) as string[]
const address = accounts?.[0]
if (!address) {
throw new Error('Wallet connection was cancelled.')
}
return address
}, async (message, address) => {
return (await window.ethereum!.request({
method: 'personal_sign',
params: [message, address],
})) as string
})
},
async connectWalletSessionWithSigner(
resolveAddress: () => Promise<string>,
signMessage: (message: string, address: string) => Promise<string>,
): Promise<WalletAccessSession> {
const address = await resolveAddress()
const nonceResponse = await accessApi.createWalletNonce(address)
const message = buildWalletMessage(nonceResponse.nonce)
const signature = (await window.ethereum.request({
method: 'personal_sign',
params: [message, address],
})) as string
const signature = await signMessage(message, address)
return accessApi.authenticateWallet(address, signature, nonceResponse.nonce)
},
async getMe(): Promise<{ user: AccessUser; subscriptions?: AccessSubscription[] }> {

View 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',
},
])
})
})

View File

@@ -0,0 +1,55 @@
import { getExplorerApiBase } from './blockscout'
export interface BridgeRouteCatalog {
weth9: Record<string, string>
weth10: Record<string, string>
trustless?: Record<string, string>
}
export interface BridgeRoutesResponse {
source?: string
lastModified?: string
updated?: string
description?: string
routes: BridgeRouteCatalog
chain138Bridges?: Record<string, string>
}
const bridgeRoutesBase = `${getExplorerApiBase()}/token-aggregation/api/v1/bridge`
export function normalizeBridgeRouteEntries(
routes: BridgeRouteCatalog | null | undefined,
): Array<{ bridge: 'WETH9' | 'WETH10'; destination: string; address: string }> {
if (!routes) return []
const entries: Array<{ bridge: 'WETH9' | 'WETH10'; destination: string; address: string }> = []
for (const [destination, address] of Object.entries(routes.weth9 || {})) {
if (address) entries.push({ bridge: 'WETH9', destination, address })
}
for (const [destination, address] of Object.entries(routes.weth10 || {})) {
if (address) entries.push({ bridge: 'WETH10', destination, address })
}
return entries.sort((left, right) =>
left.destination.localeCompare(right.destination) || left.bridge.localeCompare(right.bridge),
)
}
export const bridgeRoutesApi = {
getRoutesSafe: async (): Promise<{ ok: boolean; data: BridgeRoutesResponse | null }> => {
try {
const response = await fetch(`${bridgeRoutesBase}/routes`)
if (!response.ok) {
return { ok: false, data: null }
}
const payload = (await response.json()) as BridgeRoutesResponse
if (!payload?.routes?.weth9 || !payload?.routes?.weth10) {
return { ok: false, data: null }
}
return { ok: true, data: payload }
} catch {
return { ok: false, data: null }
}
},
}

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