feat(explorer): dual-chain wallet metadata, native coin pricing, and UI refresh.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 20s
Validate Explorer / frontend (push) Failing after 24s
Validate Explorer / smoke-e2e (push) Has been skipped

Add Chain 138 wallet network metadata and stats coin-price enrichment; sync frontend explorer SPA, command center, and address/token pages with backend config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-06-19 16:16:17 -07:00
parent 0f02e6e54f
commit b87ebee6a1
94 changed files with 7648 additions and 1124 deletions

6
.gitignore vendored
View File

@@ -21,8 +21,10 @@ build/
.env.local
.env.*.local
# IDE
.vscode/
# IDE (team settings may be committed)
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
.idea/
*.swp
*.swo

11
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"go.useLanguageServer": true,
"go.toolsEnvVars": {
"GO111MODULE": "on"
},
"gopls": {
"build.env": {
"GO111MODULE": "on"
}
}
}

23
backend/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"go.useLanguageServer": true,
"go.alternateTools": {
"go": "/home/intlc/sdk/go1.23.4/bin/go",
"gopls": "/home/intlc/go/bin/gopls"
},
"go.toolsEnvVars": {
"GO111MODULE": "on",
"GOWORK": "off",
"GOROOT": "/home/intlc/sdk/go1.23.4",
"GOPATH": "/home/intlc/go",
"GOMODCACHE": "/home/intlc/go/pkg/mod"
},
"gopls": {
"build.env": {
"GO111MODULE": "on",
"GOWORK": "off",
"GOROOT": "/home/intlc/sdk/go1.23.4",
"GOPATH": "/home/intlc/go",
"GOMODCACHE": "/home/intlc/go/pkg/mod"
}
}
}

View File

@@ -1,23 +1,52 @@
{
"name": "MetaMask Multi-Chain Networks (13 chains)",
"version": {"major": 1, "minor": 2, "patch": 0},
"version": {"major": 1, "minor": 2, "patch": 1},
"defaultChainId": 138,
"explorerUrl": "https://explorer.d-bis.org",
"tokenListUrl": "https://explorer.d-bis.org/api/v1/report/token-list?chainId=138",
"tokenListUrl": "https://explorer.d-bis.org/api/config/token-list",
"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://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"]},
{"chainId":"0x38","chainIdDecimal":56,"chainName":"BNB Smart Chain","rpcUrls":["https://bsc-dataseed.binance.org","https://bsc-dataseed1.defibit.io","https://bsc-dataseed1.ninicoin.io"],"nativeCurrency":{"name":"BNB","symbol":"BNB","decimals":18},"blockExplorerUrls":["https://bscscan.com"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
{"chainId":"0x64","chainIdDecimal":100,"chainName":"Gnosis Chain","rpcUrls":["https://rpc.gnosischain.com","https://gnosis-rpc.publicnode.com","https://1rpc.io/gnosis"],"nativeCurrency":{"name":"xDAI","symbol":"xDAI","decimals":18},"blockExplorerUrls":["https://gnosisscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
{"chainId":"0x89","chainIdDecimal":137,"chainName":"Polygon","rpcUrls":["https://polygon-rpc.com","https://polygon.llamarpc.com","https://polygon-bor-rpc.publicnode.com"],"nativeCurrency":{"name":"MATIC","symbol":"MATIC","decimals":18},"blockExplorerUrls":["https://polygonscan.com"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
{"chainId":"0xa","chainIdDecimal":10,"chainName":"Optimism","rpcUrls":["https://mainnet.optimism.io","https://optimism.llamarpc.com","https://optimism-rpc.publicnode.com"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://optimistic.etherscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
{"chainId":"0xa4b1","chainIdDecimal":42161,"chainName":"Arbitrum One","rpcUrls":["https://arb1.arbitrum.io/rpc","https://arbitrum.llamarpc.com","https://arbitrum-one-rpc.publicnode.com"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://arbiscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
{"chainId":"0x2105","chainIdDecimal":8453,"chainName":"Base","rpcUrls":["https://mainnet.base.org","https://base.llamarpc.com","https://base-rpc.publicnode.com"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://basescan.org"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
{"chainId":"0xa86a","chainIdDecimal":43114,"chainName":"Avalanche C-Chain","rpcUrls":["https://api.avax.network/ext/bc/C/rpc","https://avalanche-c-chain-rpc.publicnode.com","https://1rpc.io/avax/c"],"nativeCurrency":{"name":"AVAX","symbol":"AVAX","decimals":18},"blockExplorerUrls":["https://snowtrace.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
{"chainId":"0xa4ec","chainIdDecimal":42220,"chainName":"Celo","rpcUrls":["https://forno.celo.org","https://celo-mainnet-rpc.publicnode.com","https://1rpc.io/celo"],"nativeCurrency":{"name":"CELO","symbol":"CELO","decimals":18},"blockExplorerUrls":["https://celoscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]},
{"chainId":"0x457","chainIdDecimal":1111,"chainName":"Wemix","rpcUrls":["https://api.wemix.com","https://wemix-mainnet-rpc.publicnode.com"],"nativeCurrency":{"name":"WEMIX","symbol":"WEMIX","decimals":18},"blockExplorerUrls":["https://scan.wemix.com"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"]}
{
"chainId": "0x8a",
"chainIdDecimal": 138,
"chainName": "DeFi Oracle Meta Mainnet",
"shortName": "dbis",
"rpcUrls": ["https://rpc-http-pub.d-bis.org"],
"nativeCurrency": {"name": "Ether", "symbol": "ETH", "decimals": 18},
"blockExplorerUrls": ["https://explorer.d-bis.org"],
"infoURL": "https://d-bis.org",
"explorerApiUrl": "https://explorer.d-bis.org/api/v2",
"testnet": false,
"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"
],
"oracles": [
{"name": "ETH/USD (proxy)", "address": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6", "decimals": 8},
{"name": "ETH/USD (aggregator)", "address": "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506", "decimals": 8}
]
},
{
"chainId": "0x1",
"chainIdDecimal": 1,
"chainName": "Ethereum Mainnet",
"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/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png"],
"oracles": [{"name": "ETH/USD", "address": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419", "decimals": 8}]
},
{"chainId": "0x9f2c4", "chainIdDecimal": 651940, "chainName": "ALL Mainnet", "rpcUrls": ["https://mainnet-rpc.alltra.global"], "nativeCurrency": {"name": "Ether", "symbol": "ETH", "decimals": 18}, "blockExplorerUrls": ["https://alltra.global"], "iconUrls": ["https://alltra.global/favicon.ico"], "oracles": []},
{"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"], "oracles": []},
{"chainId": "0x38", "chainIdDecimal": 56, "chainName": "BNB Smart Chain", "rpcUrls": ["https://bsc-dataseed.binance.org", "https://bsc-dataseed1.defibit.io", "https://bsc-dataseed1.ninicoin.io"], "nativeCurrency": {"name": "BNB", "symbol": "BNB", "decimals": 18}, "blockExplorerUrls": ["https://bscscan.com"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/smartchain/info/logo.png"], "oracles": []},
{"chainId": "0x64", "chainIdDecimal": 100, "chainName": "Gnosis Chain", "rpcUrls": ["https://rpc.gnosischain.com", "https://gnosis-rpc.publicnode.com", "https://1rpc.io/gnosis"], "nativeCurrency": {"name": "xDAI", "symbol": "xDAI", "decimals": 18}, "blockExplorerUrls": ["https://gnosisscan.io"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/xdai/info/logo.png"], "oracles": []},
{"chainId": "0x89", "chainIdDecimal": 137, "chainName": "Polygon", "rpcUrls": ["https://polygon-rpc.com", "https://polygon.llamarpc.com", "https://polygon-bor-rpc.publicnode.com"], "nativeCurrency": {"name": "MATIC", "symbol": "MATIC", "decimals": 18}, "blockExplorerUrls": ["https://polygonscan.com"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/polygon/info/logo.png"], "oracles": []},
{"chainId": "0xa", "chainIdDecimal": 10, "chainName": "Optimism", "rpcUrls": ["https://mainnet.optimism.io", "https://optimism.llamarpc.com", "https://optimism-rpc.publicnode.com"], "nativeCurrency": {"name": "Ether", "symbol": "ETH", "decimals": 18}, "blockExplorerUrls": ["https://optimistic.etherscan.io"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/optimism/info/logo.png"], "oracles": []},
{"chainId": "0xa4b1", "chainIdDecimal": 42161, "chainName": "Arbitrum One", "rpcUrls": ["https://arb1.arbitrum.io/rpc", "https://arbitrum.llamarpc.com", "https://arbitrum-one-rpc.publicnode.com"], "nativeCurrency": {"name": "Ether", "symbol": "ETH", "decimals": 18}, "blockExplorerUrls": ["https://arbiscan.io"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/arbitrum/info/logo.png"], "oracles": []},
{"chainId": "0x2105", "chainIdDecimal": 8453, "chainName": "Base", "rpcUrls": ["https://mainnet.base.org", "https://base.llamarpc.com", "https://base-rpc.publicnode.com"], "nativeCurrency": {"name": "Ether", "symbol": "ETH", "decimals": 18}, "blockExplorerUrls": ["https://basescan.org"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/base/info/logo.png"], "oracles": []},
{"chainId": "0xa86a", "chainIdDecimal": 43114, "chainName": "Avalanche C-Chain", "rpcUrls": ["https://api.avax.network/ext/bc/C/rpc", "https://avalanche-c-chain-rpc.publicnode.com", "https://1rpc.io/avax/c"], "nativeCurrency": {"name": "AVAX", "symbol": "AVAX", "decimals": 18}, "blockExplorerUrls": ["https://snowtrace.io"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/avalanchec/info/logo.png"], "oracles": []},
{"chainId": "0xa4ec", "chainIdDecimal": 42220, "chainName": "Celo", "rpcUrls": ["https://forno.celo.org", "https://celo-mainnet-rpc.publicnode.com", "https://1rpc.io/celo"], "nativeCurrency": {"name": "CELO", "symbol": "CELO", "decimals": 18}, "blockExplorerUrls": ["https://celoscan.io"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/celo/info/logo.png"], "oracles": []},
{"chainId": "0x457", "chainIdDecimal": 1111, "chainName": "Wemix", "rpcUrls": ["https://api.wemix.com", "https://wemix-mainnet-rpc.publicnode.com"], "nativeCurrency": {"name": "WEMIX", "symbol": "WEMIX", "decimals": 18}, "blockExplorerUrls": ["https://scan.wemix.com"], "iconUrls": ["https://scan.wemix.com/favicon.ico"], "oracles": []}
]
}

View File

@@ -23,6 +23,8 @@ type explorerStats struct {
GasPrices *explorerGasPrices `json:"gas_prices,omitempty"`
NetworkUtilizationPercentage *float64 `json:"network_utilization_percentage,omitempty"`
TransactionsToday *int64 `json:"transactions_today,omitempty"`
CoinPrice *string `json:"coin_price,omitempty"`
CoinImage *string `json:"coin_image,omitempty"`
Freshness freshness.Snapshot `json:"freshness"`
Completeness freshness.SummaryCompleteness `json:"completeness"`
Sampling freshness.Sampling `json:"sampling"`
@@ -268,6 +270,8 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
}
}
enrichNativeCoinMarket(ctx, &stats)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}

View File

@@ -0,0 +1,150 @@
package rest
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
chain138WETHAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
)
type blockscoutCoinStats struct {
CoinPrice *string `json:"coin_price"`
CoinImage *string `json:"coin_image"`
}
type tokenAggregationTokenResponse struct {
Token struct {
Market struct {
PriceUsd *float64 `json:"priceUsd"`
} `json:"market"`
} `json:"token"`
}
func enrichNativeCoinMarket(ctx context.Context, stats *explorerStats) {
if stats.CoinPrice != nil && strings.TrimSpace(*stats.CoinPrice) != "" {
return
}
// Prefer on-chain token-aggregation WETH/ETH oracle over Blockscout CoinGecko on Chain 138.
if price := fetchTokenAggregationEthUsd(ctx); price != "" {
stats.CoinPrice = &price
if image := fetchBlockscoutCoinImage(ctx); image != "" {
stats.CoinImage = &image
}
return
}
bsURL := strings.TrimSpace(os.Getenv("BLOCKSCOUT_STATS_URL"))
if bsURL == "" {
bsURL = "http://127.0.0.1:4000/api/v2/stats"
}
if price, image := fetchBlockscoutCoinMarket(ctx, bsURL); price != "" {
stats.CoinPrice = &price
if image != "" {
stats.CoinImage = &image
}
}
}
func fetchBlockscoutCoinImage(ctx context.Context) string {
bsURL := strings.TrimSpace(os.Getenv("BLOCKSCOUT_STATS_URL"))
if bsURL == "" {
bsURL = "http://127.0.0.1:4000/api/v2/stats"
}
_, image := fetchBlockscoutCoinMarket(ctx, bsURL)
return image
}
func fetchBlockscoutCoinMarket(ctx context.Context, url string) (price string, image string) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", ""
}
client := &http.Client{Timeout: 4 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", ""
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", ""
}
var payload blockscoutCoinStats
if err := json.Unmarshal(body, &payload); err != nil {
return "", ""
}
if payload.CoinPrice != nil {
price = strings.TrimSpace(*payload.CoinPrice)
}
if payload.CoinImage != nil {
image = strings.TrimSpace(*payload.CoinImage)
}
return price, image
}
func tokenAggregationAPIV1Base() string {
base := strings.TrimRight(strings.TrimSpace(firstNonEmptyEnv(
"TOKEN_AGGREGATION_API_BASE",
"TOKEN_AGGREGATION_URL",
"TOKEN_AGGREGATION_BASE_URL",
)), "/")
if base == "" {
return "http://127.0.0.1:3001/api/v1"
}
if strings.HasSuffix(base, "/api/v1") {
return base
}
return base + "/api/v1"
}
func fetchTokenAggregationEthUsd(ctx context.Context) string {
url := fmt.Sprintf("%s/tokens/%s?chainId=138", tokenAggregationAPIV1Base(), chain138WETHAddress)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return ""
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return ""
}
var payload tokenAggregationTokenResponse
if err := json.Unmarshal(body, &payload); err != nil {
return ""
}
if payload.Token.Market.PriceUsd == nil || *payload.Token.Market.PriceUsd <= 0 {
return ""
}
return formatUsdPrice(*payload.Token.Market.PriceUsd)
}
func formatUsdPrice(value float64) string {
return fmt.Sprintf("%.2f", value)
}

View File

@@ -0,0 +1,133 @@
package rest
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestEnrichNativeCoinMarketFromBlockscout(t *testing.T) {
tokenAgg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer tokenAgg.Close()
t.Setenv("TOKEN_AGGREGATION_BASE_URL", tokenAgg.URL)
blockscout := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"coin_price": "1701.77",
"coin_image": "https://example.com/eth.png",
}))
}))
defer blockscout.Close()
t.Setenv("BLOCKSCOUT_STATS_URL", blockscout.URL)
stats := explorerStats{}
enrichNativeCoinMarket(context.Background(), &stats)
require.NotNil(t, stats.CoinPrice)
require.Equal(t, "1701.77", *stats.CoinPrice)
require.NotNil(t, stats.CoinImage)
require.Equal(t, "https://example.com/eth.png", *stats.CoinImage)
}
func TestEnrichNativeCoinMarketFallsBackToTokenAggregation(t *testing.T) {
blockscout := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"coin_price": nil,
}))
}))
defer blockscout.Close()
tokenAgg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Contains(t, r.URL.Path, chain138WETHAddress)
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"token": map[string]any{
"market": map[string]any{
"priceUsd": 1678.67,
},
},
}))
}))
defer tokenAgg.Close()
t.Setenv("BLOCKSCOUT_STATS_URL", blockscout.URL)
t.Setenv("TOKEN_AGGREGATION_BASE_URL", tokenAgg.URL)
stats := explorerStats{}
enrichNativeCoinMarket(context.Background(), &stats)
require.NotNil(t, stats.CoinPrice)
require.Equal(t, "1678.67", *stats.CoinPrice)
}
func TestEnrichNativeCoinMarketPrefersTokenAggregationWithHostOnlyBaseURL(t *testing.T) {
blockscout := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"coin_price": "1740.00",
}))
}))
defer blockscout.Close()
tokenAgg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/tokens/"+chain138WETHAddress, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"token": map[string]any{
"market": map[string]any{
"priceUsd": 1732.18,
},
},
}))
}))
defer tokenAgg.Close()
t.Setenv("BLOCKSCOUT_STATS_URL", blockscout.URL)
t.Setenv("TOKEN_AGGREGATION_BASE_URL", tokenAgg.URL)
stats := explorerStats{}
enrichNativeCoinMarket(context.Background(), &stats)
require.NotNil(t, stats.CoinPrice)
require.Equal(t, "1732.18", *stats.CoinPrice)
}
func TestEnrichNativeCoinMarketPrefersTokenAggregationOverBlockscout(t *testing.T) {
blockscout := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"coin_price": "1740.00",
"coin_image": "https://example.com/eth.png",
}))
}))
defer blockscout.Close()
tokenAgg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Contains(t, r.URL.Path, chain138WETHAddress)
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"token": map[string]any{
"market": map[string]any{
"priceUsd": 1732.18,
},
},
}))
}))
defer tokenAgg.Close()
t.Setenv("BLOCKSCOUT_STATS_URL", blockscout.URL)
t.Setenv("TOKEN_AGGREGATION_BASE_URL", tokenAgg.URL)
stats := explorerStats{}
enrichNativeCoinMarket(context.Background(), &stats)
require.NotNil(t, stats.CoinPrice)
require.Equal(t, "1732.18", *stats.CoinPrice)
require.NotNil(t, stats.CoinImage)
require.Equal(t, "https://example.com/eth.png", *stats.CoinImage)
}

View File

@@ -1,95 +1,52 @@
{
"name": "MetaMask Multi-Chain Networks (Chain 138 + Ethereum + ALL Mainnet + Cronos)",
"version": {
"major": 1,
"minor": 1,
"patch": 0
},
"name": "MetaMask Multi-Chain Networks (13 chains)",
"version": {"major": 1, "minor": 2, "patch": 1},
"defaultChainId": 138,
"explorerUrl": "https://explorer.d-bis.org",
"tokenListUrl": "https://explorer.d-bis.org/api/config/token-list",
"generatedBy": "DBIS Explorer",
"chains": [
{
"chainId": "0x8a",
"chainIdDecimal": 138,
"chainName": "DeFi Oracle Meta Mainnet",
"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"
],
"shortName": "dbis",
"rpcUrls": ["https://rpc-http-pub.d-bis.org"],
"nativeCurrency": {"name": "Ether", "symbol": "ETH", "decimals": 18},
"blockExplorerUrls": ["https://explorer.d-bis.org"],
"infoURL": "https://d-bis.org",
"explorerApiUrl": "https://explorer.d-bis.org/api/v2",
"testnet": false,
"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/token-icons/chain-138.png",
"https://explorer.d-bis.org/favicon.ico"
],
"oracles": [
{"name": "ETH/USD (proxy)", "address": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6", "decimals": 8},
{"name": "ETH/USD (aggregator)", "address": "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506", "decimals": 8}
]
},
{
"chainId": "0x1",
"chainIdDecimal": 1,
"chainName": "Ethereum Mainnet",
"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"
]
"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/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png"],
"oracles": [{"name": "ETH/USD", "address": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419", "decimals": 8}]
},
{
"chainId": "0x9f2c4",
"chainIdDecimal": 651940,
"chainName": "ALL Mainnet",
"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"
]
},
{
"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"
]
}
{"chainId": "0x9f2c4", "chainIdDecimal": 651940, "chainName": "ALL Mainnet", "rpcUrls": ["https://mainnet-rpc.alltra.global"], "nativeCurrency": {"name": "Ether", "symbol": "ETH", "decimals": 18}, "blockExplorerUrls": ["https://alltra.global"], "iconUrls": ["https://alltra.global/favicon.ico"], "oracles": []},
{"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"], "oracles": []},
{"chainId": "0x38", "chainIdDecimal": 56, "chainName": "BNB Smart Chain", "rpcUrls": ["https://bsc-dataseed.binance.org", "https://bsc-dataseed1.defibit.io", "https://bsc-dataseed1.ninicoin.io"], "nativeCurrency": {"name": "BNB", "symbol": "BNB", "decimals": 18}, "blockExplorerUrls": ["https://bscscan.com"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/smartchain/info/logo.png"], "oracles": []},
{"chainId": "0x64", "chainIdDecimal": 100, "chainName": "Gnosis Chain", "rpcUrls": ["https://rpc.gnosischain.com", "https://gnosis-rpc.publicnode.com", "https://1rpc.io/gnosis"], "nativeCurrency": {"name": "xDAI", "symbol": "xDAI", "decimals": 18}, "blockExplorerUrls": ["https://gnosisscan.io"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/xdai/info/logo.png"], "oracles": []},
{"chainId": "0x89", "chainIdDecimal": 137, "chainName": "Polygon", "rpcUrls": ["https://polygon-rpc.com", "https://polygon.llamarpc.com", "https://polygon-bor-rpc.publicnode.com"], "nativeCurrency": {"name": "MATIC", "symbol": "MATIC", "decimals": 18}, "blockExplorerUrls": ["https://polygonscan.com"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/polygon/info/logo.png"], "oracles": []},
{"chainId": "0xa", "chainIdDecimal": 10, "chainName": "Optimism", "rpcUrls": ["https://mainnet.optimism.io", "https://optimism.llamarpc.com", "https://optimism-rpc.publicnode.com"], "nativeCurrency": {"name": "Ether", "symbol": "ETH", "decimals": 18}, "blockExplorerUrls": ["https://optimistic.etherscan.io"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/optimism/info/logo.png"], "oracles": []},
{"chainId": "0xa4b1", "chainIdDecimal": 42161, "chainName": "Arbitrum One", "rpcUrls": ["https://arb1.arbitrum.io/rpc", "https://arbitrum.llamarpc.com", "https://arbitrum-one-rpc.publicnode.com"], "nativeCurrency": {"name": "Ether", "symbol": "ETH", "decimals": 18}, "blockExplorerUrls": ["https://arbiscan.io"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/arbitrum/info/logo.png"], "oracles": []},
{"chainId": "0x2105", "chainIdDecimal": 8453, "chainName": "Base", "rpcUrls": ["https://mainnet.base.org", "https://base.llamarpc.com", "https://base-rpc.publicnode.com"], "nativeCurrency": {"name": "Ether", "symbol": "ETH", "decimals": 18}, "blockExplorerUrls": ["https://basescan.org"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/base/info/logo.png"], "oracles": []},
{"chainId": "0xa86a", "chainIdDecimal": 43114, "chainName": "Avalanche C-Chain", "rpcUrls": ["https://api.avax.network/ext/bc/C/rpc", "https://avalanche-c-chain-rpc.publicnode.com", "https://1rpc.io/avax/c"], "nativeCurrency": {"name": "AVAX", "symbol": "AVAX", "decimals": 18}, "blockExplorerUrls": ["https://snowtrace.io"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/avalanchec/info/logo.png"], "oracles": []},
{"chainId": "0xa4ec", "chainIdDecimal": 42220, "chainName": "Celo", "rpcUrls": ["https://forno.celo.org", "https://celo-mainnet-rpc.publicnode.com", "https://1rpc.io/celo"], "nativeCurrency": {"name": "CELO", "symbol": "CELO", "decimals": 18}, "blockExplorerUrls": ["https://celoscan.io"], "iconUrls": ["https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/celo/info/logo.png"], "oracles": []},
{"chainId": "0x457", "chainIdDecimal": 1111, "chainName": "Wemix", "rpcUrls": ["https://api.wemix.com", "https://wemix-mainnet-rpc.publicnode.com"], "nativeCurrency": {"name": "WEMIX", "symbol": "WEMIX", "decimals": 18}, "blockExplorerUrls": ["https://scan.wemix.com"], "iconUrls": ["https://scan.wemix.com/favicon.ico"], "oracles": []}
]
}

View File

@@ -3,9 +3,9 @@
"version": {
"major": 1,
"minor": 3,
"patch": 5
"patch": 4
},
"timestamp": "2026-06-02T15:13:02.229Z",
"timestamp": "2026-04-04T04:23:46.263Z",
"logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/chain138-list.svg",
"keywords": [
"chain138",
@@ -1552,6 +1552,287 @@
"wrapped"
]
},
{
"chainId": 138,
"address": "0xD51482e567c03899eecE3CAe8a058161FD56069D",
"name": "AUD Cash Electronic Money (Compliant)",
"symbol": "cAUDC",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cAUDC.svg",
"tags": [
"gru",
"compliant",
"electronic-money"
]
},
{
"chainId": 138,
"address": "0x54dBd40cF05e15906A2C21f600937e96787f5679",
"name": "CAD Cash Electronic Money (Compliant)",
"symbol": "cCADC",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCADC.svg",
"tags": [
"gru",
"compliant",
"electronic-money"
]
},
{
"chainId": 138,
"address": "0x873990849DDa5117d7C644f0aF24370797C03885",
"name": "CHF Cash Electronic Money (Compliant)",
"symbol": "cCHFC",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cCHFC.svg",
"tags": [
"gru",
"compliant",
"electronic-money"
]
},
{
"chainId": 138,
"address": "0x8085961F9cF02b4d800A3c6d386D31da4B34266a",
"name": "EUR Cash Electronic Money (Compliant)",
"symbol": "cEURC",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURC.svg",
"tags": [
"gru",
"compliant",
"electronic-money"
]
},
{
"chainId": 138,
"address": "0xdf4b71c61E5912712C1Bdd451416B9aC26949d72",
"name": "EUR Treasury / Government Bond (Compliant)",
"symbol": "cEURT",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cEURT.svg",
"tags": [
"gru",
"compliant",
"treasury-bond"
]
},
{
"chainId": 138,
"address": "0x003960f16D9d34F2e98d62723B6721Fb92074aD2",
"name": "GBP Cash Electronic Money (Compliant)",
"symbol": "cGBPC",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPC.svg",
"tags": [
"gru",
"compliant",
"electronic-money"
]
},
{
"chainId": 138,
"address": "0x350f54e4D23795f86A9c03988c7135357CCaD97c",
"name": "GBP Treasury / Government Bond (Compliant)",
"symbol": "cGBPT",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cGBPT.svg",
"tags": [
"gru",
"compliant",
"treasury-bond"
]
},
{
"chainId": 138,
"address": "0xEe269e1226a334182aace90056EE4ee5Cc8A6770",
"name": "JPY Cash Electronic Money (Compliant)",
"symbol": "cJPYC",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru/cJPYC.svg",
"tags": [
"gru",
"compliant",
"electronic-money"
]
},
{
"chainId": 138,
"address": "0xf22258f57794CC8E06237084b353Ab30fFfa640b",
"name": "USD Cash Electronic Money (Compliant)",
"symbol": "cUSDC",
"decimals": 6,
"logoURI": "https://explorer.d-bis.org/token-icons/cUSDC.png",
"tags": [
"gru",
"compliant",
"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,
"address": "0x93E66202A11B1772E55407B32B44e5Cd8eda7f22",
"name": "USD Treasury / Government Bond (Compliant)",
"symbol": "cUSDT",
"decimals": 6,
"logoURI": "https://explorer.d-bis.org/token-icons/cUSDT.png",
"tags": [
"gru",
"compliant",
"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,
"address": "0x290E52a8819A4fbD0714E517225429aA2B70EC6b",
"name": "XAU Commodity (Compliant)",
"symbol": "cXAUC",
"decimals": 6,
"logoURI": "https://explorer.d-bis.org/token-icons/cXAUC.png",
"tags": [
"gru",
"compliant"
],
"extensions": {
"unitOfAccount": "troy_ounce",
"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
}
},
{
"chainId": 138,
"address": "0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E",
"name": "XAU Commodity (Compliant)",
"symbol": "cXAUT",
"decimals": 6,
"logoURI": "https://explorer.d-bis.org/token-icons/cXAUT.png",
"tags": [
"gru",
"compliant"
],
"extensions": {
"unitOfAccount": "troy_ounce",
"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
}
},
{
"chainId": 138,
"address": "0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6",
"name": "ETH/USD Price Feed",
"symbol": "ETH-USD",
"decimals": 8,
"logoURI": "https://ipfs.io/ipfs/QmPZuycjyJEe2otREuQ5HirvPJ8X6Yc6MBtwz1VhdD79pY",
"tags": [
"oracle",
"price-feed"
]
},
{
"chainId": 138,
"address": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03",
"name": "Chainlink Token",
"symbol": "LINK",
"decimals": 18,
"logoURI": "https://ipfs.io/ipfs/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A",
"tags": [
"defi",
"oracle",
"ccip"
]
},
{
"chainId": 138,
"address": "0x71D6687F38b93CCad569Fa6352c876eea967201b",
"name": "USD Coin (Official Mirror)",
"symbol": "USDC",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png",
"tags": [
"reference-asset",
"defi"
]
},
{
"chainId": 138,
"address": "0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1",
"name": "Tether USD (Official Mirror)",
"symbol": "USDT",
"decimals": 6,
"logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png",
"tags": [
"reference-asset",
"defi"
]
},
{
"chainId": 138,
"address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"name": "Wrapped Ether",
"symbol": "WETH",
"decimals": 18,
"logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong",
"tags": [
"defi",
"wrapped"
]
},
{
"chainId": 138,
"address": "0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9F",
"name": "Wrapped Ether v10",
"symbol": "WETH10",
"decimals": 18,
"logoURI": "https://ipfs.io/ipfs/QmanDFPHxnbKd6SSNzzXHf9GbpL9dLXSphxDZSPPYE6ds4",
"tags": [
"defi",
"wrapped"
]
},
{
"chainId": 1111,
"address": "0xE3F5a90F9cb311505cd691a46596599aA1A0AD7D",
@@ -2359,542 +2640,6 @@
"defi",
"bridge"
]
},
{
"chainId": 138,
"address": "0xD51482e567c03899eecE3CAe8a058161FD56069D",
"name": "Australian Dollar (Compliant)",
"symbol": "cAUDC",
"decimals": 6,
"logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong",
"tags": [
"stablecoin",
"defi",
"compliant",
"fiat",
"cash",
"gru"
],
"extensions": {
"category": "tokenized-fiat",
"instrument": "emoney-or-fiat-backed-stablecoin",
"currency": "AUD",
"settlement": "fiat",
"cashLike": true,
"backing": "cash,cash-equivalents",
"gruVersion": "v1",
"gruFamily": "cAUDC",
"x402Ready": false,
"fwdCanon": false
}
},
{
"chainId": 138,
"address": "0x54dBd40cF05e15906A2C21f600937e96787f5679",
"name": "Canadian Dollar (Compliant)",
"symbol": "cCADC",
"decimals": 6,
"logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong",
"tags": [
"stablecoin",
"defi",
"compliant",
"fiat",
"cash",
"gru"
],
"extensions": {
"category": "tokenized-fiat",
"instrument": "emoney-or-fiat-backed-stablecoin",
"currency": "CAD",
"settlement": "fiat",
"cashLike": true,
"backing": "cash,cash-equivalents",
"gruVersion": "v1",
"gruFamily": "cCADC",
"x402Ready": false,
"fwdCanon": false
}
},
{
"chainId": 138,
"address": "0x873990849DDa5117d7C644f0aF24370797C03885",
"name": "Swiss Franc (Compliant)",
"symbol": "cCHFC",
"decimals": 6,
"logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong",
"tags": [
"stablecoin",
"defi",
"compliant",
"fiat",
"cash",
"gru"
],
"extensions": {
"category": "tokenized-fiat",
"instrument": "emoney-or-fiat-backed-stablecoin",
"currency": "CHF",
"settlement": "fiat",
"cashLike": true,
"backing": "cash,cash-equivalents",
"gruVersion": "v1",
"gruFamily": "cCHFC",
"x402Ready": false,
"fwdCanon": false
}
},
{
"chainId": 138,
"address": "0x8085961F9cF02b4d800A3c6d386D31da4B34266a",
"name": "Euro Coin (Compliant)",
"symbol": "cEURC",
"decimals": 6,
"logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong",
"tags": [
"stablecoin",
"defi",
"compliant",
"fiat",
"cash",
"gru"
],
"extensions": {
"category": "tokenized-fiat",
"instrument": "emoney-or-fiat-backed-stablecoin",
"currency": "EUR",
"settlement": "fiat",
"cashLike": true,
"backing": "cash,cash-equivalents",
"gruVersion": "v1",
"gruFamily": "cEURC",
"x402Ready": false,
"fwdCanon": false
}
},
{
"chainId": 138,
"address": "0xdf4b71c61E5912712C1Bdd451416B9aC26949d72",
"name": "Tether EUR (Compliant)",
"symbol": "cEURT",
"decimals": 6,
"logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong",
"tags": [
"stablecoin",
"defi",
"compliant",
"fiat",
"cash",
"gru"
],
"extensions": {
"category": "tokenized-fiat",
"instrument": "emoney-or-fiat-backed-stablecoin",
"currency": "EUR",
"settlement": "fiat",
"cashLike": true,
"backing": "cash,cash-equivalents",
"gruVersion": "v1",
"gruFamily": "cEURT",
"x402Ready": false,
"fwdCanon": false
}
},
{
"chainId": 138,
"address": "0x003960f16D9d34F2e98d62723B6721Fb92074aD2",
"name": "Pound Sterling (Compliant)",
"symbol": "cGBPC",
"decimals": 6,
"logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong",
"tags": [
"stablecoin",
"defi",
"compliant",
"fiat",
"cash",
"gru"
],
"extensions": {
"category": "tokenized-fiat",
"instrument": "emoney-or-fiat-backed-stablecoin",
"currency": "GBP",
"settlement": "fiat",
"cashLike": true,
"backing": "cash,cash-equivalents",
"gruVersion": "v1",
"gruFamily": "cGBPC",
"x402Ready": false,
"fwdCanon": false
}
},
{
"chainId": 138,
"address": "0x350f54e4D23795f86A9c03988c7135357CCaD97c",
"name": "Tether GBP (Compliant)",
"symbol": "cGBPT",
"decimals": 6,
"logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong",
"tags": [
"stablecoin",
"defi",
"compliant",
"fiat",
"cash",
"gru"
],
"extensions": {
"category": "tokenized-fiat",
"instrument": "emoney-or-fiat-backed-stablecoin",
"currency": "GBP",
"settlement": "fiat",
"cashLike": true,
"backing": "cash,cash-equivalents",
"gruVersion": "v1",
"gruFamily": "cGBPT",
"x402Ready": false,
"fwdCanon": false
}
},
{
"chainId": 138,
"address": "0xEe269e1226a334182aace90056EE4ee5Cc8A6770",
"name": "Japanese Yen (Compliant)",
"symbol": "cJPYC",
"decimals": 6,
"logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong",
"tags": [
"stablecoin",
"defi",
"compliant",
"fiat",
"cash",
"gru"
],
"extensions": {
"category": "tokenized-fiat",
"instrument": "emoney-or-fiat-backed-stablecoin",
"currency": "JPY",
"settlement": "fiat",
"cashLike": true,
"backing": "cash,cash-equivalents",
"gruVersion": "v1",
"gruFamily": "cJPYC",
"x402Ready": false,
"fwdCanon": false
}
},
{
"chainId": 138,
"address": "0xf22258f57794CC8E06237084b353Ab30fFfa640b",
"name": "Compliant USD Coin",
"symbol": "cUSDC",
"decimals": 6,
"logoURI": "https://ipfs.io/ipfs/QmNPq4D5JXzurmi9jAhogVMzhAQRk1PZ1r9H3qQUV9gjDm",
"tags": [
"stablecoin",
"defi",
"compliant",
"fiat",
"cash",
"gru"
],
"extensions": {
"category": "gru-emoney",
"instrument": "emoney-or-fiat-backed-stablecoin",
"currency": "USD",
"settlement": "fiat",
"cashLike": true,
"backing": "cash,cash-equivalents",
"gruVersion": "v1",
"gruFamily": "cUSDC",
"x402Ready": false,
"fwdCanon": false,
"assetClass": "Cash & Equivalents",
"assetGroup": "MMF / Repo",
"instrumentType": "eMoney",
"underlying": "USD",
"gruLayer": "M1",
"rwaEligible": false
}
},
{
"chainId": 138,
"address": "0x93E66202A11B1772E55407B32B44e5Cd8eda7f22",
"name": "Compliant Tether USD",
"symbol": "cUSDT",
"decimals": 6,
"logoURI": "https://ipfs.io/ipfs/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP",
"tags": [
"stablecoin",
"defi",
"compliant",
"fiat",
"cash",
"gru"
],
"extensions": {
"category": "gru-emoney",
"instrument": "emoney-or-fiat-backed-stablecoin",
"currency": "USD",
"settlement": "fiat",
"cashLike": true,
"backing": "cash,cash-equivalents",
"gruVersion": "v1",
"gruFamily": "cUSDT",
"x402Ready": false,
"fwdCanon": false,
"assetClass": "Cash & Equivalents",
"assetGroup": "MMF / Repo",
"instrumentType": "eMoney",
"underlying": "USD",
"gruLayer": "M1",
"rwaEligible": false
}
},
{
"chainId": 138,
"address": "0x290E52a8819A4fbD0714E517225429aA2B70EC6b",
"name": "Gold (Compliant)",
"symbol": "cXAUC",
"decimals": 6,
"logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong",
"tags": [
"defi",
"compliant",
"gru"
],
"extensions": {
"category": "gru-emoney",
"instrument": "commodity-referenced-token",
"settlement": "commodity",
"cashLike": false,
"backing": "commodity-reserves",
"gruVersion": "v1",
"gruFamily": "cXAUC",
"walletClass": "token",
"commodity": "gold",
"unit": "troy_ounce",
"assetClass": "Commodities",
"assetGroup": "Precious Metals",
"instrumentType": "eMoney",
"underlying": "Gold",
"gruLayer": "M1",
"rwaEligible": false
}
},
{
"chainId": 138,
"address": "0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E",
"name": "Tether XAU (Compliant)",
"symbol": "cXAUT",
"decimals": 6,
"logoURI": "https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong",
"tags": [
"defi",
"compliant",
"gru"
],
"extensions": {
"category": "gru-emoney",
"instrument": "commodity-referenced-token",
"settlement": "commodity",
"cashLike": false,
"backing": "commodity-reserves",
"gruVersion": "v1",
"gruFamily": "cXAUT",
"walletClass": "token",
"commodity": "gold",
"unit": "troy_ounce",
"assetClass": "Commodities",
"assetGroup": "Precious Metals",
"instrumentType": "eMoney",
"underlying": "Gold",
"gruLayer": "M1",
"rwaEligible": false
}
},
{
"chainId": 138,
"address": "0x3304b747E565a97ec8AC220b0B6A1f6ffDB837e6",
"name": "ETH/USD Price Feed",
"symbol": "ETH-USD",
"decimals": 8,
"logoURI": "https://ipfs.io/ipfs/QmPZuycjyJEe2otREuQ5HirvPJ8X6Yc6MBtwz1VhdD79pY",
"tags": [
"oracle",
"pricefeed"
]
},
{
"chainId": 138,
"address": "0xc5b802662447d1ae492a1618c3ad7161a449ebc9",
"name": "Base Metals Group Index 1 (M00)",
"symbol": "LiBMG1",
"decimals": 6,
"tags": [
"rwa",
"index",
"m00"
],
"extensions": {
"category": "rwa-index",
"gruLayer": "M00",
"rwaEligible": true,
"notEmoney": true,
"assetClass": "Commodities",
"assetGroup": "Industrial Metals",
"instrumentType": "Basket Index",
"underlying": "Base Metals",
"collateralType": "physical"
}
},
{
"chainId": 138,
"address": "0x2ca3b3e7f4f216015833b0b334273d44493c0c45",
"name": "Base Metals Group Index 2 (M00)",
"symbol": "LiBMG2",
"decimals": 6,
"tags": [
"rwa",
"index",
"m00"
],
"extensions": {
"category": "rwa-index",
"gruLayer": "M00",
"rwaEligible": true,
"notEmoney": true,
"assetClass": "Commodities",
"assetGroup": "Industrial Metals",
"instrumentType": "Basket Index",
"underlying": "Battery Materials",
"collateralType": "physical"
}
},
{
"chainId": 138,
"address": "0xb03d872196c0e84fe6fae841335001db44bda6c7",
"name": "Base Metals Group Index 3 (M00)",
"symbol": "LiBMG3",
"decimals": 6,
"tags": [
"rwa",
"index",
"m00"
],
"extensions": {
"category": "rwa-index",
"gruLayer": "M00",
"rwaEligible": true,
"notEmoney": true,
"assetClass": "Commodities",
"assetGroup": "Industrial Metals",
"instrumentType": "Basket Index",
"underlying": "Building Metals",
"collateralType": "physical"
}
},
{
"chainId": 138,
"address": "0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03",
"name": "Chainlink Token",
"symbol": "LINK",
"decimals": 18,
"logoURI": "https://explorer.d-bis.org/api/v1/report/logo/LINK",
"tags": [
"defi",
"oracle",
"ccip"
]
},
{
"chainId": 138,
"address": "0x148b0f5c6fc8c5975e9406635654d88b20db2ed6",
"name": "Precious Metals Group Index (M00)",
"symbol": "LiPMG",
"decimals": 6,
"tags": [
"rwa",
"index",
"m00"
],
"extensions": {
"category": "rwa-index",
"gruLayer": "M00",
"rwaEligible": true,
"notEmoney": true,
"assetClass": "Commodities",
"assetGroup": "Precious Metals",
"instrumentType": "Basket Index",
"underlying": "Precious Metals",
"collateralType": "physical"
}
},
{
"chainId": 138,
"address": "0xff862f0d1f96aa4882a0c2d6b4a3516fd8d68e75",
"name": "XAU Liquidity Index (M00, not cXAUC eMoney)",
"symbol": "LiXAU",
"decimals": 6,
"tags": [
"rwa",
"index",
"m00"
],
"extensions": {
"category": "rwa-index",
"gruLayer": "M00",
"rwaEligible": true,
"notEmoney": true,
"assetClass": "Commodities",
"assetGroup": "Precious Metals",
"instrumentType": "Commodity Index",
"underlying": "Gold",
"collateralType": "physical"
}
},
{
"chainId": 138,
"address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"name": "Wrapped Ether",
"symbol": "WETH",
"decimals": 18,
"logoURI": "https://explorer.d-bis.org/api/v1/report/logo/ETH",
"tags": [
"defi",
"wrapped"
],
"extensions": {
"category": "wrapped-native",
"instrument": "wrapped-native",
"settlement": "crypto-native",
"cashLike": false,
"backing": "native-gas-asset",
"walletClass": "token",
"alias": "WETH9",
"aliasNote": "Expose WETH9 as a compatibility alias on s"
}
},
{
"chainId": 138,
"address": "0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9F",
"name": "Wrapped Ether v10",
"symbol": "WETH10",
"decimals": 18,
"logoURI": "https://explorer.d-bis.org/api/v1/report/logo/ETH",
"tags": [
"defi",
"wrapped"
],
"extensions": {
"category": "wrapped-native",
"instrument": "wrapped-native",
"settlement": "crypto-native",
"cashLike": false,
"backing": "native-gas-asset",
"walletClass": "token"
}
}
]
}

View File

@@ -9,7 +9,7 @@ require (
github.com/ethereum/go-ethereum v1.13.5
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/gorilla/websocket v1.5.1
github.com/jackc/pgx/v5 v5.5.1
github.com/jackc/pgx/v5 v5.5.5
github.com/redis/go-redis/v9 v9.17.2
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.36.0
@@ -33,6 +33,7 @@ require (
github.com/ethereum/c-kzg-4844 v0.4.0 // indirect
github.com/go-ole/go-ole v1.2.5 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/holiman/uint256 v1.2.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
@@ -40,6 +41,8 @@ require (
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mmcloughlin/addchain v0.4.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
github.com/supranational/blst v0.3.11 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
@@ -51,6 +54,5 @@ require (
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.33.0 // indirect
rsc.io/tmplfunc v0.0.3 // indirect
)

View File

@@ -75,13 +75,11 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk=
github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
@@ -98,14 +96,14 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI=
github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw=
github.com/klauspost/compress v1.15.15/go.mod h1:ZcK2JAFqKOpnBlxcLsJzYfrS9X1akm9fHZNnD9+Vo/4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -120,8 +118,6 @@ github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APP
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI=
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A=
@@ -129,26 +125,28 @@ github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8oh
github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY=
github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU=
github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.12.0 h1:C+UIj/QWtmqY13Arb8kwMt5j34/0Z2iKamrJ+ryC0Gg=
github.com/prometheus/client_golang v1.12.0/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a h1:CmF68hwI0XsOQ5UwlBopMi2Ow4Pbg32akc4KIVCOm+Y=
github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@@ -198,8 +196,8 @@ golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -0,0 +1,756 @@
{
"schemaVersion": "1.0.0",
"updated": "2026-06-10",
"description": "Unified Web3 identity registry — addresses, ENS, explorer labels, and institutional identifier refs. SSOT for naming across Blockscout sync, explorer UI, and frontend-dapp.",
"policyDoc": "docs/04-configuration/WEB3_IDENTITY_AND_NAMING_POLICY.md",
"sources": [
"smom-dbis-138/config/address-inventory.chain138.json",
"config/chain138-official-protocol-contracts.json",
"config/compliance/liquidity-vault-gnosis-safe-mainnet.v1.json",
"docs/04-configuration/mifos-omnl-central-bank/OMNL_ENTITY_MASTER_DATA.json"
],
"entries": [
{
"id": "deployer-defi-oracle",
"address": "0x4A666F96fC8764181194447A7dFdb7d471b301C8",
"chainIds": [
1,
138
],
"roles": [
"deployer",
"operator"
],
"displayName": "DeFi Oracle Deployer",
"ens": {
"primary": "defi-oracle.eth",
"chainId": 1
},
"web3Domains": [],
"identifiers": [
{
"type": "inventoryKey",
"value": "DEPLOYER_ADMIN_138"
}
],
"explorer": {
"blockscoutLabel": "DeFi Oracle Deployer (defi-oracle.eth)",
"tagTypes": [
"operator",
"deployer"
]
},
"eiLabel": null
},
{
"id": "liquidity-vault-safe-mainnet",
"address": "0x93a42Bdc51BecE00a2F31C744Fceb4956C4B6f76",
"chainIds": [
1
],
"roles": [
"treasury",
"multisig"
],
"displayName": "LiquidityVault Safe",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "inventoryKey",
"value": "LIQUIDITY_VAULT_SAFE_MAINNET"
},
{
"type": "safe",
"value": "2-of-4",
"note": "Created by defi-oracle.eth"
}
],
"explorer": {
"blockscoutLabel": "LiquidityVault Safe (2-of-4)",
"tagTypes": [
"treasury",
"multisig"
]
},
"eiLabel": null
},
{
"id": "dodo-pmm-integration-stack-a",
"address": "0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895",
"chainIds": [
138
],
"roles": [
"protocol",
"dex",
"pmm"
],
"displayName": "DODO PMM Integration (Stack A)",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "inventoryKey",
"value": "DODO_PMM_INTEGRATION"
},
{
"type": "protocol",
"value": "dodo_v2"
}
],
"explorer": {
"blockscoutLabel": "DODO PMM Integration (live Stack A)",
"tagTypes": [
"protocol",
"dex",
"pmm"
]
},
"eiLabel": null
},
{
"id": "dodo-pmm-provider",
"address": "0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e",
"chainIds": [
138
],
"roles": [
"protocol",
"dex",
"pmm"
],
"displayName": "DODO PMM Provider",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "inventoryKey",
"value": "DODO_PMM_PROVIDER"
}
],
"explorer": {
"blockscoutLabel": "DODO PMM Provider (ILiquidityProvider)",
"tagTypes": [
"protocol",
"dex"
]
},
"eiLabel": null
},
{
"id": "dodo-v2-proxy",
"address": "0xEF6E6F41A522896a9EE1C580C87C05E409193F8d",
"chainIds": [
138
],
"roles": [
"protocol",
"dex",
"router"
],
"displayName": "DODOV2Proxy02",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "inventoryKey",
"value": "DODO_V2_PROXY"
},
{
"type": "protocol",
"value": "dodo_v2"
}
],
"explorer": {
"blockscoutLabel": "DODOV2Proxy02",
"tagTypes": [
"protocol",
"router"
]
},
"eiLabel": null
},
{
"id": "canonical-cusdt-138",
"address": "0x93E66202A11B1772E55407B32B44e5Cd8eda7f22",
"chainIds": [
138
],
"roles": [
"token",
"stablecoin"
],
"displayName": "cUSDT (canonical)",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "symbol",
"value": "cUSDT"
},
{
"type": "decimals",
"value": "6"
}
],
"explorer": {
"blockscoutLabel": "cUSDT (Compliant USDT)",
"tagTypes": [
"token",
"stablecoin",
"gru"
]
},
"eiLabel": null
},
{
"id": "canonical-cusdc-138",
"address": "0xf22258f57794CC8E06237084b353Ab30fFfa640b",
"chainIds": [
138
],
"roles": [
"token",
"stablecoin"
],
"displayName": "cUSDC (canonical)",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "symbol",
"value": "cUSDC"
},
{
"type": "decimals",
"value": "6"
}
],
"explorer": {
"blockscoutLabel": "cUSDC (Compliant USDC)",
"tagTypes": [
"token",
"stablecoin",
"gru"
]
},
"eiLabel": null
},
{
"id": "canonical-cbtc-138",
"address": "0xe94260c555ac1d9d3cc9e1632883452ebdf0082e",
"chainIds": [
138
],
"roles": [
"token"
],
"displayName": "cBTC (canonical)",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "symbol",
"value": "cBTC"
},
{
"type": "decimals",
"value": "8"
}
],
"explorer": {
"blockscoutLabel": "cBTC (Compliant BTC)",
"tagTypes": [
"token",
"gru"
]
},
"eiLabel": null
},
{
"id": "pmm-pool-cusdt-cusdc",
"address": "0x9e89bAe009adf128782E19e8341996c596ac40dC",
"chainIds": [
138
],
"roles": [
"pool",
"pmm"
],
"displayName": "PMM Pool cUSDT/cUSDC",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "pair",
"value": "cUSDT/cUSDC"
}
],
"explorer": {
"blockscoutLabel": "DODO PMM cUSDT/cUSDC (live)",
"tagTypes": [
"pool",
"pmm",
"dex"
]
},
"eiLabel": null
},
{
"id": "ccip-router-138",
"address": "0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817",
"chainIds": [
138
],
"roles": [
"bridge",
"ccip"
],
"displayName": "CCIP Router (Chain 138)",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "inventoryKey",
"value": "CCIP_ROUTER_138"
}
],
"explorer": {
"blockscoutLabel": "Chainlink CCIP Router",
"tagTypes": [
"bridge",
"ccip"
]
},
"eiLabel": null
},
{
"id": "oracle-aggregator-138",
"address": "0x99b3511a2d315a497c8112c1fdd8d508d4b1e506",
"chainIds": [
138
],
"roles": [
"oracle"
],
"displayName": "Oracle Aggregator",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "inventoryKey",
"value": "ORACLE_AGGREGATOR_ADDRESS"
}
],
"explorer": {
"blockscoutLabel": "Oracle Aggregator",
"tagTypes": [
"oracle"
]
},
"eiLabel": null
},
{
"id": "multicall3-138",
"address": "0xcA11bde05977b3631167028862bE2a173976CA11",
"chainIds": [
138
],
"roles": [
"utility"
],
"displayName": "Multicall3",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "inventoryKey",
"value": "MULTICALL3"
},
{
"type": "protocol",
"value": "multicall3"
}
],
"explorer": {
"blockscoutLabel": "Multicall3",
"tagTypes": [
"utility"
]
},
"eiLabel": null
},
{
"id": "liquidity-owner-adam",
"address": "0x348775A05CF5b6fC9d18830CfBd63DAE0Fb3c668",
"chainIds": [
1
],
"roles": [
"multisig-owner"
],
"displayName": "AdamMultiSig (LiquidityVault owner)",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "safeOwner",
"value": "LiquidityVault"
}
],
"explorer": {
"blockscoutLabel": "LiquidityVault Owner — AdamMultiSig",
"tagTypes": [
"multisig-owner"
]
},
"eiLabel": null
},
{
"id": "liquidity-owner-benard",
"address": "0x3F5BD2e9DA51Dc76f7F3308daa904C1B9E90460f",
"chainIds": [
1
],
"roles": [
"multisig-owner"
],
"displayName": "BenardMultiSig (LiquidityVault owner)",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "safeOwner",
"value": "LiquidityVault"
}
],
"explorer": {
"blockscoutLabel": "LiquidityVault Owner — BenardMultiSig",
"tagTypes": [
"multisig-owner"
]
},
"eiLabel": null
},
{
"id": "liquidity-owner-nathan",
"address": "0x8c906Bd27ba9Ea828B32DaE484eea5982b20CDb9",
"chainIds": [
1
],
"roles": [
"multisig-owner"
],
"displayName": "NathanMultiSig (LiquidityVault owner)",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "safeOwner",
"value": "LiquidityVault"
}
],
"explorer": {
"blockscoutLabel": "LiquidityVault Owner — NathanMultiSig",
"tagTypes": [
"multisig-owner"
]
},
"eiLabel": null
},
{
"id": "liquidity-owner-target",
"address": "0x16285b235b413bFd376ECBe66F2F9f5F6EA5313C",
"chainIds": [
1
],
"roles": [
"multisig-owner"
],
"displayName": "0xLiquidity (planned Safe owner)",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "safeOwner",
"value": "LiquidityVault",
"note": "planned owner swap target"
}
],
"explorer": {
"blockscoutLabel": "LiquidityVault Owner — 0xLiquidity (planned)",
"tagTypes": [
"multisig-owner"
]
},
"eiLabel": null
},
{
"id": "enhanced-swap-router-v2",
"address": "0xa421706768aeb7fafa2d912c5e10824ef3437ad4",
"chainIds": [
138
],
"roles": [
"protocol",
"router"
],
"displayName": "EnhancedSwapRouter V2",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "inventoryKey",
"value": "ENHANCED_SWAP_ROUTER_V2_ADDRESS"
}
],
"explorer": {
"blockscoutLabel": "EnhancedSwapRouter V2",
"tagTypes": [
"protocol",
"router"
]
},
"eiLabel": null
},
{
"id": "pmm-pool-cusdt-usdt",
"address": "0x866Cb44b59303d8dc5f4F9E3E7A8e8b0bf238d66",
"chainIds": [
138
],
"roles": [
"pool",
"pmm"
],
"displayName": "PMM Pool cUSDT/USDT",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "pair",
"value": "cUSDT/USDT"
}
],
"explorer": {
"blockscoutLabel": "DODO PMM cUSDT/USDT (live)",
"tagTypes": [
"pool",
"pmm"
]
},
"eiLabel": null
},
{
"id": "pmm-pool-cusdc-usdc",
"address": "0xc39B7D0F40838cbFb54649d327f49a6DAC964062",
"chainIds": [
138
],
"roles": [
"pool",
"pmm"
],
"displayName": "PMM Pool cUSDC/USDC",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "pair",
"value": "cUSDC/USDC"
}
],
"explorer": {
"blockscoutLabel": "DODO PMM cUSDC/USDC (live)",
"tagTypes": [
"pool",
"pmm"
]
},
"eiLabel": null
},
{
"id": "pmm-pool-cbtc-cusdt",
"address": "0x67049e7333481e2cac91af61403ac7bddfab7bcd",
"chainIds": [
138
],
"roles": [
"pool",
"pmm"
],
"displayName": "PMM Pool cBTC/cUSDT",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "pair",
"value": "cBTC/cUSDT"
}
],
"explorer": {
"blockscoutLabel": "DODO PMM cBTC/cUSDT (live)",
"tagTypes": [
"pool",
"pmm"
]
},
"eiLabel": null
},
{
"id": "pmm-pool-cbtc-cusdc",
"address": "0x72f1a0794153c3b8a1e8a731f1d8e1a52cb10dc5",
"chainIds": [
138
],
"roles": [
"pool",
"pmm"
],
"displayName": "PMM Pool cBTC/cUSDC",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "pair",
"value": "cBTC/cUSDC"
}
],
"explorer": {
"blockscoutLabel": "DODO PMM cBTC/cUSDC (live)",
"tagTypes": [
"pool",
"pmm"
]
},
"eiLabel": null
},
{
"id": "pmm-pool-weth-usdc",
"address": "0xb53a0508940b1ff90f1aad4f6cb50a7012fe5593",
"chainIds": [
138
],
"roles": [
"pool",
"pmm"
],
"displayName": "PMM Pool WETH/USDC",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "pair",
"value": "WETH/USDC"
}
],
"explorer": {
"blockscoutLabel": "DODO PMM WETH/USDC (live)",
"tagTypes": [
"pool",
"pmm"
]
},
"eiLabel": null
},
{
"id": "pmm-pool-weth-usdt",
"address": "0xe227f6c0520c0c6e8786fe56fa76c4914f861533",
"chainIds": [
138
],
"roles": [
"pool",
"pmm"
],
"displayName": "PMM Pool WETH/USDT",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "pair",
"value": "WETH/USDT"
}
],
"explorer": {
"blockscoutLabel": "DODO PMM WETH/USDT (live)",
"tagTypes": [
"pool",
"pmm"
]
},
"eiLabel": null
},
{
"id": "pmm-pool-cbtc-cxauc",
"address": "0xf3e8a07d419b61f002114e64d79f7cf8f7989433",
"chainIds": [
138
],
"roles": [
"pool",
"pmm"
],
"displayName": "PMM Pool cBTC/cXAUC",
"ens": null,
"web3Domains": [],
"identifiers": [
{
"type": "pair",
"value": "cBTC/cXAUC"
}
],
"explorer": {
"blockscoutLabel": "DODO PMM cBTC/cXAUC (live)",
"tagTypes": [
"pool",
"pmm"
]
},
"eiLabel": null
}
],
"entities": [
{
"id": "omnl-head-office",
"displayName": "OMNL Head Office (DBIS) Central Bank",
"lei": "98450070C57395F6B906",
"entityRef": "omnl-entity-master:1",
"roles": [
"entity",
"central-bank"
]
},
{
"id": "omnl-nrb",
"displayName": "Nepal Rastra Bank",
"lei": "25490000MX377HHPSR96",
"entityRef": "omnl-entity-master:18",
"roles": [
"entity",
"central-bank"
]
},
{
"id": "omnl-sanima",
"displayName": "Sanima Bank Limited",
"lei": "25490043FER1B108XE95",
"entityRef": "omnl-entity-master:19",
"roles": [
"entity",
"bank"
]
},
{
"id": "omnl-cbuae",
"displayName": "Central Bank of the UAE",
"lei": "5493006P6LOOFH8TB150",
"entityRef": "omnl-entity-master:23",
"roles": [
"entity",
"central-bank"
]
}
]
}

View File

@@ -0,0 +1,35 @@
# Chain 138 visual topology — command center source
Canonical maintainer doc for `frontend/public/chain138-command-center.html` (live at `/chain138-command-center.html` and `/topology`).
Diagrams are **informational**; live addresses and guardrails are served by the explorer:
- `/protocols` and `/token-aggregation/api/v1/report/official-protocols`
- `/pools`, `/liquidity`, `/routes`
- `/bridge` (managed relay fleet + CCIP lane health)
- `/docs/gru`, `/docs/posture-glossary`, `/docs/transaction-review`
## PMM production canon (Chain 138)
| Role | Address |
|------|---------|
| DODOPMMIntegration Stack A (live) | `0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895` |
| DODOPMMProvider Stack A (live) | `0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e` |
| EnhancedSwapRouter (live) | `0xE6Cc7643ae2A4C720A28D8263BC4972905d7DE0f` |
| Stack B integration (do not wire) | `0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d` |
EnhancedSwapRouterV2 remains a planned Phase 4b deploy; it is not the default production router.
## Managed relay lanes (explorer `/bridge`)
`mainnet_weth`, `mainnet_cw`, `bsc`, `avax`, `avax_cw`, `avax_to_138`
## External references (proxmox monorepo)
When editing diagrams, cross-check:
- `docs/11-references/PMM_DEX_ROUTING_STATUS.md`
- `docs/11-references/CONTRACT_ADDRESSES_REFERENCE.md`
- `docs/11-references/EXPLORER_TOKEN_LIST_CROSSCHECK.md` (§5 canonical tokens)
Bundle metadata: `frontend/public/chain138-command-center.meta.json` (refresh via `scripts/refresh-chain138-command-center-meta.sh`).

View File

@@ -388,7 +388,7 @@ The script checks:
- HTTP 200 on `/api/v2/stats`, `/api/v2/blocks`, `/api/v2/transactions`.
- Explorer frontend at `/` returns 200.
- Chain 138 Snap companion site at `/snap/` returns 200 or 301 and contains expected content when 200.
- The static Visual Command Center at `/chain138-command-center.html` returns 200 and contains expected architecture text.
- The static Visual Command Center at `/chain138-command-center.html` (alias `/topology`) returns 200, includes Stack A PMM canon text, and `/chain138-command-center.meta.json` exposes bundle metadata.
- Mission Control endpoints return healthy responses:
- `/explorer-api/v1/mission-control/stream`
- `/explorer-api/v1/mission-control/bridge/trace`

View File

@@ -1,9 +1,13 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import clsx from 'clsx'
import { getKnownDisplayName } from '@/utils/web3IdentityRegistry'
import { resolveEnsName } from '@/utils/ens'
interface AddressProps {
address: string
chainId?: number
/** Blockscout or caller-provided label (highest precedence). */
label?: string | null
showCopy?: boolean
showENS?: boolean
truncate?: boolean
@@ -12,14 +16,30 @@ interface AddressProps {
export function Address({
address,
chainId,
chainId: _chainId,
label,
showCopy = true,
showENS = false,
truncate = false,
className,
}: AddressProps) {
const [copied, setCopied] = useState(false)
const [ensName, setEnsName] = useState<string | null>(null)
const registryLabel = getKnownDisplayName(address)
useEffect(() => {
if (!showENS || !address) return
let active = true
void resolveEnsName(address).then((name) => {
if (active) setEnsName(name)
})
return () => {
active = false
}
}, [address, showENS])
const primaryLabel = label || registryLabel || (showENS ? ensName : null) || null
const displayAddress = truncate
? `${address.slice(0, 6)}...${address.slice(-4)}`
: address
@@ -42,14 +62,20 @@ export function Address({
className
)}
>
<span
className={clsx(
'min-w-0 font-mono text-sm leading-6 text-gray-900 dark:text-gray-100',
truncate ? 'truncate' : 'break-all'
)}
>
{displayAddress}
</span>
<div className="min-w-0">
{primaryLabel ? (
<div className="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{primaryLabel}</div>
) : null}
<span
className={clsx(
'min-w-0 font-mono text-sm leading-6 text-gray-900 dark:text-gray-100',
truncate ? 'truncate' : 'break-all',
primaryLabel ? 'text-xs text-gray-500 dark:text-gray-400' : '',
)}
>
{displayAddress}
</span>
</div>
{showCopy && (
<button
type="button"

View File

@@ -7,6 +7,11 @@ const nextConfig = {
outputFileTracingRoot: path.resolve(__dirname, '..', '..'),
async redirects() {
return [
{
source: '/address/:address',
destination: '/addresses/:address',
permanent: true,
},
{
source: '/tx/:hash',
destination: '/transactions/:hash',
@@ -27,6 +32,16 @@ const nextConfig = {
destination: '/docs/transaction-review',
permanent: true,
},
{
source: '/topology',
destination: '/chain138-command-center.html',
permanent: false,
},
{
source: '/command-center',
destination: '/chain138-command-center.html',
permanent: false,
},
]
},
// If you see a workspace lockfile warning: align on one package manager (npm or pnpm) in frontend, or ignore for dev/build.

View File

@@ -4,7 +4,6 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chain 138 — Visual Command Center</title>
<!-- Mermaid: local copy preferred; runtime fallback loader below -->
<script src="/thirdparty/mermaid.min.js"></script>
<style>
:root {
@@ -16,6 +15,9 @@
--muted: #94a3b8;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--callout: #1e293b;
--success: #10b981;
--warning: #f59e0b;
}
* { box-sizing: border-box; }
body {
@@ -25,7 +27,32 @@
color: var(--text);
min-height: 100vh;
}
header {
.explorer-chrome {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 1rem;
padding: 0.55rem 1rem;
background: #0a0e17;
border-bottom: 1px solid var(--border);
font-size: 0.8125rem;
}
.explorer-chrome a {
color: #93c5fd;
text-decoration: none;
}
.explorer-chrome a:hover { text-decoration: underline; }
.chrome-brand {
font-weight: 700;
color: var(--text) !important;
margin-right: 0.25rem;
}
.chrome-links {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.85rem;
}
header {
padding: 1rem 1.25rem;
background: var(--header);
border-bottom: 1px solid var(--border);
@@ -39,7 +66,7 @@
margin: 0.35rem 0 0;
font-size: 0.875rem;
color: var(--muted);
max-width: 52rem;
max-width: 56rem;
line-height: 1.45;
}
.toolbar {
@@ -54,10 +81,18 @@
top: 0;
z-index: 10;
}
.tabs-wrap {
flex: 1;
min-width: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.tabs {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: 0.25rem;
padding-bottom: 2px;
}
.tab {
padding: 0.5rem 0.85rem;
@@ -68,6 +103,8 @@
font-weight: 600;
color: var(--muted);
background: transparent;
white-space: nowrap;
flex-shrink: 0;
}
.tab:hover {
color: var(--text);
@@ -79,10 +116,10 @@
border-color: var(--accent-hover);
}
.toolbar a.back {
margin-left: auto;
font-size: 0.8125rem;
color: #93c5fd;
text-decoration: none;
white-space: nowrap;
}
.toolbar a.back:hover { text-decoration: underline; }
.content {
@@ -99,6 +136,20 @@
margin-bottom: 1rem;
max-width: 56rem;
}
.callout {
border: 1px solid #334155;
border-radius: 12px;
padding: 1rem 1.1rem;
background: var(--callout);
margin-bottom: 1rem;
font-size: 0.875rem;
line-height: 1.55;
max-width: 56rem;
}
.callout strong { color: #e2e8f0; }
.callout a { color: #93c5fd; text-decoration: none; }
.callout a:hover { text-decoration: underline; }
.callout-warn { border-color: #b45309; background: rgba(180, 83, 9, 0.12); }
.mermaid-wrap {
background: var(--panel);
padding: 1.25rem;
@@ -114,14 +165,47 @@
color: #cbd5e1;
}
.mermaid-wrap + .mermaid-wrap { margin-top: 0.5rem; }
.link-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
}
.link-card {
display: block;
text-decoration: none;
color: inherit;
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem;
background: var(--panel);
}
.link-card:hover { border-color: #475569; }
.link-card-title { font-weight: 700; margin-bottom: 0.3rem; }
.link-card-desc { color: var(--muted); line-height: 1.5; font-size: 0.875rem; }
.pool-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
margin-top: 0.5rem;
}
.pool-table th, .pool-table td {
border-bottom: 1px solid var(--border);
padding: 0.45rem 0.5rem;
text-align: left;
}
.pool-table th { color: var(--muted); font-weight: 600; }
.pool-table a { color: #93c5fd; text-decoration: none; }
.pool-table a:hover { text-decoration: underline; }
footer {
padding: 1.5rem;
border-top: 1px solid var(--border);
font-size: 0.75rem;
color: var(--muted);
text-align: center;
line-height: 1.6;
}
footer code { color: #a5b4fc; }
footer a { color: #93c5fd; }
.status-note {
margin: 0.75rem 1.25rem 0;
padding: 0.85rem 1rem;
@@ -132,51 +216,84 @@
font-size: 0.875rem;
line-height: 1.5;
}
.status-note a {
color: #93c5fd;
text-decoration: none;
}
.status-note a { color: #93c5fd; text-decoration: none; }
.status-note a:hover { text-decoration: underline; }
.status-note summary {
cursor: pointer;
font-weight: 600;
color: #cbd5e1;
}
</style>
</head>
<body>
<nav class="explorer-chrome" aria-label="Explorer navigation">
<a class="chrome-brand" href="/">DBIS Explorer</a>
<div class="chrome-links">
<a href="/operations">Operations</a>
<a href="/bridge">Bridge</a>
<a href="/protocols">Protocols</a>
<a href="/liquidity">Liquidity</a>
<a href="/docs">Docs</a>
<a href="/topology">Topology</a>
</div>
</nav>
<header>
<h1>Chain 138 — deployment and liquidity topology</h1>
<p>Operator-style view of the architecture in <code>docs/02-architecture/SMOM_DBIS_138_FULL_DEPLOYMENT_FLOW_MAP.md</code>. Diagrams are informational only; contract addresses live in explorer config and repo references. The main explorer remains the canonical live operational surface. Deep links: <code>?tab=mission-control</code> or numeric <code>?tab=0</code><code>8</code> (slug per tab).</p>
<p>
Static architecture companion maintained in
<code>explorer-monorepo/docs/CHAIN138_VISUAL_TOPOLOGY_SOURCE.md</code>.
Diagrams are informational only; live contract addresses and guardrails are on
<a href="/protocols">/protocols</a> and the main explorer SPA.
Deep links: <code>?tab=mission-control</code> or slugs <code>master</code><code>mission-control</code>
(numeric <code>?tab=0</code><code>8</code>).
Bundle: <span id="meta-version"></span>.
</p>
</header>
<div class="status-note" id="mermaid-status">
Loading local diagram assets. If the local Mermaid bundle is unavailable, the page will try a trusted CDN fallback automatically.
</div>
<div class="status-note" id="command-center-fallback">
If diagram rendering is unavailable, use the main explorer operational surfaces directly:
<a href="/operations">Operations hub</a>,
<a href="/bridge">Bridge</a>,
<a href="/routes">Routes</a>,
<a href="/system">System</a>,
and <a href="/operator">Operator</a>.
</div>
<details class="status-note" id="page-about">
<summary>About this page (live explorer is canonical)</summary>
<p id="mermaid-status" style="margin:0.65rem 0 0;">
Loading diagram assets from <code>/thirdparty/mermaid.min.js</code>; CDN fallback runs automatically if local Mermaid is unavailable.
</p>
<p style="margin:0.65rem 0 0;">
Operational surfaces:
<a href="/operations">Operations hub</a>,
<a href="/bridge">Bridge</a>,
<a href="/routes">Routes</a>,
<a href="/liquidity">Liquidity</a>,
<a href="/pools">Pools</a>,
<a href="/protocols">Protocols</a>,
<a href="/analytics">Analytics</a>,
<a href="/system">System</a>,
<a href="/operator">Operator</a>,
<a href="/access">Access</a>,
<a href="/docs">Docs</a>,
<a href="/snap/">MetaMask Snap</a>.
</p>
</details>
<div class="toolbar">
<div class="tabs" role="tablist" aria-label="Topology panels">
<button type="button" id="tab-0" class="tab active" role="tab" aria-selected="true" aria-controls="panel-0" data-tab="0" tabindex="0">Master map</button>
<button type="button" id="tab-1" class="tab" role="tab" aria-selected="false" aria-controls="panel-1" data-tab="1" tabindex="-1">Network</button>
<button type="button" id="tab-2" class="tab" role="tab" aria-selected="false" aria-controls="panel-2" data-tab="2" tabindex="-1">Stack</button>
<button type="button" id="tab-3" class="tab" role="tab" aria-selected="false" aria-controls="panel-3" data-tab="3" tabindex="-1">Flows</button>
<button type="button" id="tab-4" class="tab" role="tab" aria-selected="false" aria-controls="panel-4" data-tab="4" tabindex="-1">Cross-chain</button>
<button type="button" id="tab-5" class="tab" role="tab" aria-selected="false" aria-controls="panel-5" data-tab="5" tabindex="-1">Public cW</button>
<button type="button" id="tab-6" class="tab" role="tab" aria-selected="false" aria-controls="panel-6" data-tab="6" tabindex="-1">Off-chain</button>
<button type="button" id="tab-7" class="tab" role="tab" aria-selected="false" aria-controls="panel-7" data-tab="7" tabindex="-1">Integrations</button>
<button type="button" id="tab-8" class="tab" role="tab" aria-selected="false" aria-controls="panel-8" data-tab="8" tabindex="-1">Mission Control</button>
<div class="tabs-wrap">
<div class="tabs" role="tablist" aria-label="Topology panels">
<button type="button" id="tab-0" class="tab active" role="tab" aria-selected="true" aria-controls="panel-0" data-tab="0" tabindex="0">Master map</button>
<button type="button" id="tab-1" class="tab" role="tab" aria-selected="false" aria-controls="panel-1" data-tab="1" tabindex="-1">Network</button>
<button type="button" id="tab-2" class="tab" role="tab" aria-selected="false" aria-controls="panel-2" data-tab="2" tabindex="-1">Stack</button>
<button type="button" id="tab-3" class="tab" role="tab" aria-selected="false" aria-controls="panel-3" data-tab="3" tabindex="-1">Flows</button>
<button type="button" id="tab-4" class="tab" role="tab" aria-selected="false" aria-controls="panel-4" data-tab="4" tabindex="-1">Cross-chain</button>
<button type="button" id="tab-5" class="tab" role="tab" aria-selected="false" aria-controls="panel-5" data-tab="5" tabindex="-1">Public cW</button>
<button type="button" id="tab-6" class="tab" role="tab" aria-selected="false" aria-controls="panel-6" data-tab="6" tabindex="-1">Off-chain</button>
<button type="button" id="tab-7" class="tab" role="tab" aria-selected="false" aria-controls="panel-7" data-tab="7" tabindex="-1">Integrations</button>
<button type="button" id="tab-8" class="tab" role="tab" aria-selected="false" aria-controls="panel-8" data-tab="8" tabindex="-1">Mission Control</button>
</div>
</div>
<a class="back" href="/operations">Back to Operations</a>
<a class="back" href="/operations">Operations hub</a>
</div>
<!-- 0 Master -->
<div class="content active" id="panel-0" role="tabpanel" aria-labelledby="tab-0">
<p class="panel-desc">Hub, leaf endings, CCIP destinations, Alltra, the dedicated Avalanche cW corridor, the public cW mesh, and pending programs. Mainnet cW mint corridors and the optional TRUU rail are summarized under the Ethereum anchor.</p>
<p class="panel-desc">Hub, leaf endings, CCIP destinations, Alltra, Avalanche cW corridor, public cW mesh, and pending programs. Mainnet cW mint corridors and optional TRUU sit under the Ethereum anchor.</p>
<div class="mermaid-wrap"><div class="mermaid" id="g-master">
flowchart TB
subgraph LEAF_INGRESS["Leaves — access to 138"]
@@ -187,44 +304,44 @@ flowchart TB
end
subgraph LEAF_EDGE["Leaves — services that index or front 138"]
EXP[Explorer · Blockscout · token-aggregation]
EXP[explorer.d-bis.org · blockscout.defi-oracle.io · token-aggregation]
INFO[info.defi-oracle.io]
DAPP[dapp.d-bis.org bridge UI]
DAPP[dapp.d-bis.org bridge UI · atomic-swap.defi-oracle.io]
DBIS[dbis-api Core hosts]
X402[x402 payment API]
MCP[MCP PMM controller]
end
subgraph HUB["CHAIN 138 — origin hub"]
C138["Besu EVM · tokens core · DODO PMM V2/V3 · RouterV2 · UniV3 / Balancer / Curve / 1inch pilots · CCIP bridges + router · AlltraAdapter · BridgeVault · ISO channels · mirror reserve vault settlement · Lockbox · Truth / Tron / Solana adapters"]
C138["Besu EVM · tokens · DODO PMM Stack A · EnhancedSwapRouter live · UniV3 / Balancer / Curve / 1inch pilots · CCIP · AlltraAdapter · BridgeVault · ISO · reserve vault · Lockbox · Truth / Tron / Solana adapters"]
end
subgraph CCIP_ETH["Ethereum 1 — CCIP anchor"]
ETH1["WETH9 / WETH10 bridges · CCIPRelayRouter · RelayBridge · Logger · optional trustless stack"]
LEAF_ETH["Leaf — Mainnet native DEX venues · Li.Fi touchpoints on other chains · first-wave cW DODO pools · optional TRUU PMM rail"]
LEAF_ETH["Mainnet DEX · Li.Fi touchpoints · first-wave cW DODO pools · optional TRUU PMM rail"]
end
subgraph CCIP_L2["Other live CCIP EVM destinations"]
L2CLU["OP 10 · Base 8453 · Arb 42161 · Polygon 137 · BSC 56 · Avax 43114 · Gnosis 100 · Celo 42220 · Cronos 25"]
LEAF_L2["Leaf — per-chain native DEX · cW public-network representation · partial edge pools"]
LEAF_L2["Per-chain native DEX · cW representation · partial edge pools"]
end
subgraph ALLTRA["ALL Mainnet 651940"]
A651["AlltraAdapter peer · AUSDT · WETH · WALL · HYDX · DEX env placeholders"]
LEAF_651["Leaf — ALL native venues when configured"]
LEAF_651["ALL native venues when configured"]
end
subgraph SPECIAL["Dedicated corridor from 138"]
AVAXCW["138 cUSDT to Avax cWUSDT mint path"]
LEAF_AVAX["Leaf — recipient on 43114"]
LEAF_AVAX["Recipient on 43114"]
end
subgraph CW_MESH["Public cW GRU mesh"]
CW["Cross-public-EVM token matrix · pool design · Mainnet DODO concentration"]
end
subgraph PENDING["Pending separate scaffold"]
WEMIX[Wemix 1111 CCIP pending]
subgraph PENDING["Pending or degraded scaffold"]
WEMIX[Wemix 1111 CCIP lane degraded]
XDC[XDC Zero parallel program]
SCAFF[Etherlink Tezos OP L2 design]
PNON[Truth pointer · Tron adapter · Solana partial]
@@ -265,13 +382,14 @@ flowchart TB
<!-- 1 Network -->
<div class="content" id="panel-1" role="tabpanel" aria-labelledby="tab-1" hidden>
<p class="panel-desc">Chain 138 to the public EVM mesh, Alltra, pending or scaffold targets, Avalanche cW minting, and the separate Mainnet cW mint corridor that sits alongside the standard WETH-class CCIP rail.</p>
<p class="panel-desc">Chain 138 to the public EVM mesh, Alltra, scaffold targets, Avalanche cW minting, and the Mainnet cW mint corridor alongside the WETH-class CCIP rail.</p>
<div class="mermaid-wrap"><div class="mermaid">
flowchart TB
subgraph C138["Chain 138 — primary"]
CORE[Core registry vault oracle ISO router]
PMM[DODO PMM V2 DVM + pools]
R2[EnhancedSwapRouterV2]
PMM[DODO PMM Stack A V2 DVM + pools]
R1[EnhancedSwapRouter live]
R2P[EnhancedSwapRouterV2 planned]
D3[D3MM pilot]
CCIPB[CCIP WETH9 WETH10 bridges]
ALLA[AlltraAdapter]
@@ -292,9 +410,9 @@ flowchart TB
end
subgraph PEND["Pending or separate"]
WEMIX[Wemix 1111 CCIP pending]
WEMIX[Wemix 1111 lane degraded]
XDC[XDC Zero parallel program]
SCAFF[Etherlink Tezos OP L2 scaffold design]
SCAFF[Etherlink Tezos OP L2 scaffold]
end
subgraph A651["ALL Mainnet 651940"]
@@ -312,13 +430,32 @@ flowchart TB
C138 -.->|future gated| SCAFF
C138 -->|avax cw corridor| E43114
R1 -.->|Phase 4b| R2P
</div></div>
<p class="panel-desc">Topology note: Mainnet now represents two Ethereum-facing patterns in production, the standard WETH-class CCIP rail and the dedicated <code>cUSDC/cUSDT -&gt; cWUSDC/cWUSDT</code> mint corridor.</p>
<p class="panel-desc">Topology note: Mainnet has two Ethereum-facing patterns standard WETH-class CCIP and the dedicated <code>cUSDC/cUSDT cWUSDC/cWUSDT</code> mint corridor.</p>
</div>
<!-- 2 Stack -->
<div class="content" id="panel-2" role="tabpanel" aria-labelledby="tab-2" hidden>
<p class="panel-desc">On-chain layers: tokens, core, liquidity, cross-domain, reserve and settlement.</p>
<div class="callout callout-warn">
<strong>PMM Stack A (production).</strong>
Wire dApps and routers to
<code>DODOPMMIntegration</code> <code>0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895</code>
and <code>DODOPMMProvider</code> <code>0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e</code>.
</div>
<div class="callout callout-warn">
<strong>Do not wire Stack B</strong> parallel integration
<code>0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d</code> — seeded but un-traded pools.
See <a href="/protocols">official protocol registry</a>.
</div>
<div class="callout">
<strong>EnhancedSwapRouter (live)</strong> <code>0xE6Cc7643ae2A4C720A28D8263BC4972905d7DE0f</code>
DODO Stack A active; UniV3/Balancer/1inch slots wired per-pair.
<strong>EnhancedSwapRouterV2</strong> is planned (Phase 4b), not the default production router.
</div>
<div class="mermaid-wrap"><div class="mermaid">
flowchart TB
subgraph L1["Tokens and compliance"]
@@ -333,10 +470,11 @@ flowchart TB
end
subgraph L3["Liquidity and execution"]
DVM[DVMFactory VendingMachine DODOPMMIntegration]
DVM[Stack A DVMFactory VendingMachine DODOPMMIntegration]
PRV[DODOPMMProvider PrivatePoolRegistry]
R2[EnhancedSwapRouterV2]
VEN[Uniswap v3 lane Balancer Curve 1inch pilots]
R1[EnhancedSwapRouter live]
R2P[EnhancedSwapRouterV2 planned]
VEN[Uniswap v3 Balancer Curve 1inch pilots]
D3[D3Oracle D3Vault D3Proxy D3MMFactory]
end
@@ -355,17 +493,48 @@ flowchart TB
L1 --> L2
L2 --> L3
L3 --> R2
R2 --> VEN
L3 --> R1
R1 --> VEN
R1 -.-> R2P
L2 --> L4
L2 --> L5
DVM --> PRV
</div></div>
<div class="callout">
<strong>Compliance &amp; evidence (explorer surfaces).</strong>
<code>ISO20022Router</code> on-chain aligns with ISO-20022 posture badges —
<a href="/docs/posture-glossary">posture glossary</a>.
x402 readiness (EIP-712, ERC-5267, permit/3009) —
<a href="/docs/gru">GRU guide</a> and <a href="/search?q=cUSDT">token/search filters</a>.
Transaction evidence scoring —
<a href="/docs/transaction-review">transaction review matrix</a>.
Li* RWA factory is separate from c*→cW* transport —
<a href="/protocols/rwa_token_factory">RWA token factory registry</a>.
</div>
</div>
<!-- 3 Flows -->
<div class="content" id="panel-3" role="tabpanel" aria-labelledby="tab-3" hidden>
<p class="panel-desc">Same-chain 138: PMM pools, RouterV2 venues, D3 pilot.</p>
<p class="panel-desc">Same-chain 138: Stack A PMM pools, EnhancedSwapRouter (live), D3 pilot. Live pool inventory: <a href="/pools">/pools</a>.</p>
<div class="mermaid-wrap">
<h3>Stack A PMM pools (live, traded)</h3>
<table class="pool-table">
<thead><tr><th>Pair</th><th>Pool</th></tr></thead>
<tbody>
<tr><td>cUSDT/cUSDC</td><td><a href="/pools/0x9e89bAe009adf128782E19e8341996c596ac40dC">0x9e89…40dC</a></td></tr>
<tr><td>cUSDT/USDT</td><td><a href="/pools/0x866Cb44b59303d8dc5f4F9E3E7A8e8b0bf238d66">0x866C…8d66</a></td></tr>
<tr><td>cUSDC/USDC</td><td><a href="/pools/0xc39B7D0F40838cbFb54649d327f49a6DAC964062">0xc39B…4062</a></td></tr>
<tr><td>cBTC/cUSDT</td><td><a href="/pools/0x67049e7333481e2cac91af61403ac7bddfab7bcd">0x6704…7bcd</a></td></tr>
<tr><td>cBTC/cUSDC</td><td><a href="/pools/0x72f1a0794153c3b8a1e8a731f1d8e1a52cb10dc5">0x72f1…0dc5</a></td></tr>
<tr><td>WETH/USDC</td><td><a href="/pools/0xb53a0508940b1ff90f1aad4f6cb50a7012fe5593">0xb53a…5593</a></td></tr>
<tr><td>WETH/USDT</td><td><a href="/pools/0xe227f6c0520c0c6e8786fe56fa76c4914f861533">0xe227…1533</a></td></tr>
<tr><td>cBTC/cXAUC</td><td><a href="/pools/0xf3e8a07d419b61f002114e64d79f7cf8f7989433">0xf3e8…9433</a></td></tr>
</tbody>
</table>
</div>
<div class="mermaid-wrap"><div class="mermaid">
flowchart LR
subgraph inputs["Typical inputs"]
@@ -377,13 +546,13 @@ flowchart LR
U6[cXAUC]
end
subgraph path_pmm["DODO PMM"]
INT[DODOPMMIntegration]
subgraph path_pmm["DODO PMM Stack A"]
INT[DODOPMMIntegration 0x86ADA6Ef]
POOL[Stable pools XAU public pools Private XAU registry]
end
subgraph path_r2["Router v2"]
R2[EnhancedSwapRouterV2]
subgraph path_r1["EnhancedSwapRouter live"]
R1[EnhancedSwapRouter 0xE6Cc7643]
UV3[Uniswap v3 WETH stable]
PILOT[Balancer Curve 1inch]
end
@@ -394,17 +563,43 @@ flowchart LR
inputs --> INT
INT --> POOL
inputs --> R2
R2 --> UV3
R2 --> PILOT
GEN2[WETH WETH10] --> R2
inputs --> R1
R1 --> UV3
R1 --> PILOT
GEN2[WETH WETH10] --> R1
GEN2 --> D3
</div></div>
</div>
<!-- 4 Cross-chain -->
<div class="content" id="panel-4" role="tabpanel" aria-labelledby="tab-4" hidden>
<p class="panel-desc">CCIP routing, Alltra round-trip, the dedicated c-to-cW mint corridors, and the orchestrated swap-bridge-swap target.</p>
<p class="panel-desc">CCIP routing, managed relay fleet, Alltra round-trip, c→cW mint corridors, and swap-bridge-swap orchestration. Live relay posture: <a href="/bridge">/bridge</a>.</p>
<div class="mermaid-wrap">
<h3>Managed CCIP relay fleet (explorer /bridge)</h3>
<div class="mermaid">
flowchart TB
C138[Chain 138 hub]
subgraph RELAYS["Managed relay lanes"]
MW[mainnet_weth]
MC[mainnet_cw]
BSC[bsc]
AV[avax]
AVC[avax_cw]
A138[avax_to_138]
end
C138 --> MW
C138 --> MC
C138 --> BSC
C138 --> AV
C138 --> AVC
AVC --> A138
A138 --> C138
</div>
</div>
<div class="mermaid-wrap">
<h3>CCIP — WETH primary routing lane</h3>
<div class="mermaid">
@@ -454,7 +649,17 @@ flowchart LR
<!-- 5 Public cW -->
<div class="content" id="panel-5" role="tabpanel" aria-labelledby="tab-5" hidden>
<p class="panel-desc">Ethereum Mainnet first-wave cW DODO mesh, plus the separate optional TRUU PMM rail. See PMM_DEX_ROUTING_STATUS and cross-chain-pmm-lps deployment-status for live detail.</p>
<p class="panel-desc">
Ethereum Mainnet first-wave cW DODO mesh plus optional TRUU PMM rail.
<strong>c*</strong> on Chain 138 is the compliant canonical instrument;
<strong>cW*</strong> on public chains is the wrapped transport representation (not Li* RWA).
Guide: <a href="/docs/gru">GRU guide</a> · mesh search <a href="/search?q=cWUSDC">cWUSDC</a>.
</p>
<div class="callout callout-warn">
<strong>Mainnet cWUSDC/USDC guardrail.</strong>
Do not route cWUSDC→USDC through skewed legacy UniV2 pair <code>0xC28706F8…</code> when peg deviation is high.
Prefer DODO / UniV3 repair lanes and explorer <a href="/liquidity">liquidity policy</a> (bridge underlying, not LP).
</div>
<div class="mermaid-wrap"><div class="mermaid">
flowchart TB
subgraph ETH["Ethereum Mainnet"]
@@ -466,7 +671,7 @@ flowchart TB
CW <--> DODO
HUB <--> DODO
</div></div>
<p class="panel-desc">TRUU note: the optional Mainnet Truth rail is a separate volatile PMM lane and is not part of the default cW stable mesh.</p>
<p class="panel-desc">TRUU: optional Mainnet Truth volatile PMM lane not part of the default cW stable mesh.</p>
<div class="mermaid-wrap">
<h3>Mainnet TRUU PMM (volatile, optional)</h3>
<div class="mermaid">
@@ -485,20 +690,20 @@ flowchart LR
<!-- 6 Off-chain -->
<div class="content" id="panel-6" role="tabpanel" aria-labelledby="tab-6" hidden>
<p class="panel-desc">Wallets, edge FQDNs, APIs, operators feeding Chain 138 RPC, plus the explorer-hosted Mission Control visual surfaces.</p>
<p class="panel-desc">Wallets, edge FQDNs, APIs, operators, and explorer-hosted Mission Control surfaces.</p>
<div class="mermaid-wrap"><div class="mermaid">
flowchart TB
subgraph users["Wallets and tools"]
MM[MetaMask custom network Snaps]
MM[MetaMask custom network · /snap/]
MCP[MCP PMM controller allowlist 138]
end
subgraph edge["Public edge"]
EXP[explorer.d-bis.org Blockscout token-aggregation]
MC[Mission Control visual panels]
EXP[explorer.d-bis.org · blockscout.defi-oracle.io]
MC[Mission Control panels · /bridge · homepage]
INFO[info.defi-oracle.io]
DAPP[dapp.d-bis.org bridge UI]
RPC[rpc-http-pub.d-bis.org public RPC]
DAPP[dapp.d-bis.org · atomic-swap.defi-oracle.io]
RPC[rpc-http-pub.d-bis.org]
end
subgraph api["APIs"]
@@ -508,7 +713,7 @@ flowchart TB
end
subgraph ops["Operator"]
REL[CCIP relay systemd]
REL[CCIP relay systemd · mainnet_cw bsc avax lanes]
SCR[smom-dbis-138 forge scripts]
end
@@ -519,18 +724,21 @@ flowchart TB
api --> C138[Chain 138 RPC]
ops --> C138
</div></div>
<p class="panel-desc">Mission Control note: the live visual display lives in the main explorer SPA, especially the bridge-monitoring and operator surfaces. This command center stays focused on the static architecture view.</p>
<p class="panel-desc">Live Mission Control UI: <a href="/bridge">bridge monitoring</a>, <a href="/operations">operations hub</a>, homepage Mission Control card. This page stays the static topology reference.</p>
</div>
<!-- 7 Integrations -->
<div class="content" id="panel-7" role="tabpanel" aria-labelledby="tab-7" hidden>
<p class="panel-desc">Contract families vs wallet/client integrations not spelled out in every zoom diagram. Wormhole remains docs/MCP scope, not canonical 138 addresses.</p>
<p class="panel-desc">
Contract families vs wallet/client integrations.
<strong>Wormhole</strong> appears in the <a href="/protocols">official protocol registry</a> as integration scaffold / docs scope — not a live Chain 138 liquidity rail unless listed as production on <a href="/protocols/wormhole">protocol detail</a>.
</p>
<div class="mermaid-wrap"><div class="mermaid">
flowchart LR
subgraph chain138_tech["Chain 138 contract families"]
A[Besu EVM]
B[ERC-20 core registries]
C[DODO V2 V3]
C[DODO V2 V3 Stack A]
D[UniV3 Bal Curve 1inch pilots]
E[CCIP bridges router]
F[Alltra Vault ISO channels]
@@ -541,7 +749,7 @@ flowchart LR
CL[Chainlist]
TW[thirdweb RPC]
ETH[ethers.js]
MM[MetaMask Snaps]
MM[MetaMask Snaps /snap/]
end
chain138_tech --> public_integrations
@@ -550,7 +758,7 @@ flowchart LR
<!-- 8 Mission Control -->
<div class="content" id="panel-8" role="tabpanel" aria-labelledby="tab-8" hidden>
<p class="panel-desc">Mission Control is the live explorer surface for SSE health, labeled bridge traces, cached liquidity proxy results, and operator-facing API references. The interactive controls live in the main explorer SPA; this tab is the architecture companion with direct entry points.</p>
<p class="panel-desc">Mission Control: SSE health, bridge traces, liquidity proxy, and operator APIs. Interactive controls live in the explorer SPA; this tab lists entry points.</p>
<div class="mermaid-wrap">
<h3>Mission Control visual flow</h3>
<div class="mermaid">
@@ -575,22 +783,54 @@ flowchart LR
LIQ --> UP
</div>
</div>
<div class="mermaid-wrap">
<h3>Live entry points</h3>
<p class="panel-desc">Use the main explorer UI for the visual Mission Control experience, then open the raw APIs when you need direct payloads or verification.</p>
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:0.75rem;">
<a href="/operator" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--panel);"><div style="font-weight:700; margin-bottom:0.3rem;">Operator hub</div><div style="color:var(--muted); line-height:1.5;">Explorer SPA surface with Mission Control and operator-facing API references.</div></a>
<a href="/bridge" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--panel);"><div style="font-weight:700; margin-bottom:0.3rem;">Bridge monitoring</div><div style="color:var(--muted); line-height:1.5;">Includes the visible Mission Control bridge-trace card and SSE stream entry point.</div></a>
<a href="/explorer-api/v1/mission-control/stream" target="_blank" rel="noopener noreferrer" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--panel);"><div style="font-weight:700; margin-bottom:0.3rem;">SSE stream</div><div style="color:var(--muted); line-height:1.5;"><code>GET /explorer-api/v1/mission-control/stream</code></div></a>
<a href="/explorer-api/v1/mission-control/bridge/trace?tx=0x2f31d4f9a97be754b800f4af1a9eedf3b107d353bfa1a19e81417497a76c05c2" target="_blank" rel="noopener noreferrer" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--panel);"><div style="font-weight:700; margin-bottom:0.3rem;">Bridge trace example</div><div style="color:var(--muted); line-height:1.5;"><code>GET /explorer-api/v1/mission-control/bridge/trace</code></div></a>
<a href="/explorer-api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools" target="_blank" rel="noopener noreferrer" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--panel);"><div style="font-weight:700; margin-bottom:0.3rem;">Liquidity example</div><div style="color:var(--muted); line-height:1.5;"><code>GET /explorer-api/v1/mission-control/liquidity/token/{address}/pools</code></div></a>
<div style="border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--panel);"><div style="font-weight:700; margin-bottom:0.3rem;">Track 4 script API</div><div style="color:var(--muted); line-height:1.5;"><code>POST /explorer-api/v1/track4/operator/run-script</code><br>Requires wallet auth, IP allowlisting, and backend allowlist config.</div></div>
<h3>Explorer operational surfaces</h3>
<div class="link-grid">
<a class="link-card" href="/operations"><div class="link-card-title">Operations hub</div><div class="link-card-desc">Consolidated monitoring, routes, and topology shortcuts.</div></a>
<a class="link-card" href="/bridge"><div class="link-card-title">Bridge monitoring</div><div class="link-card-desc">Relay fleet, CCIP lane health, bridge trace card, SSE.</div></a>
<a class="link-card" href="/routes"><div class="link-card-title">Routes</div><div class="link-card-desc">Live route matrix and execution paths.</div></a>
<a class="link-card" href="/liquidity"><div class="link-card-title">Liquidity</div><div class="link-card-desc">PMM access, planner capabilities, pool inventory.</div></a>
<a class="link-card" href="/pools"><div class="link-card-title">Pools</div><div class="link-card-desc">Mission-control pool inventory snapshot.</div></a>
<a class="link-card" href="/protocols"><div class="link-card-title">Protocols</div><div class="link-card-desc">Official DODO, Uni, CCIP, Multicall3 registry.</div></a>
<a class="link-card" href="/weth"><div class="link-card-title">WETH utilities</div><div class="link-card-desc">Wrapped-asset references and bridge context.</div></a>
<a class="link-card" href="/analytics"><div class="link-card-title">Analytics</div><div class="link-card-desc">Track 3 blocks, transactions, activity summaries.</div></a>
<a class="link-card" href="/system"><div class="link-card-title">System</div><div class="link-card-desc">Networks, RPC capabilities, topology inventory.</div></a>
<a class="link-card" href="/operator"><div class="link-card-title">Operator</div><div class="link-card-desc">Track 4 relay, route, and planner shortcuts.</div></a>
<a class="link-card" href="/access"><div class="link-card-title">Access</div><div class="link-card-desc">Wallet-authenticated Track 3/4 features.</div></a>
<a class="link-card" href="/docs"><div class="link-card-title">Documentation</div><div class="link-card-desc">GRU, posture glossary, transaction review, APIs.</div></a>
<a class="link-card" href="/snap/"><div class="link-card-title">MetaMask Snap</div><div class="link-card-desc">Snap install path on this explorer domain.</div></a>
</div>
</div>
<div class="mermaid-wrap">
<h3>Public JSON APIs</h3>
<div class="link-grid">
<a class="link-card" href="/explorer-api/v1/mission-control/stream" target="_blank" rel="noopener noreferrer"><div class="link-card-title">SSE stream</div><div class="link-card-desc"><code>GET /explorer-api/v1/mission-control/stream</code></div></a>
<a class="link-card" href="/explorer-api/v1/track1/bridge/status" target="_blank" rel="noopener noreferrer"><div class="link-card-title">Bridge status JSON</div><div class="link-card-desc"><code>GET /explorer-api/v1/track1/bridge/status</code></div></a>
<a class="link-card" href="/explorer-api/v1/mission-control/bridge/trace?tx=0x2f31d4f9a97be754b800f4af1a9eedf3b107d353bfa1a19e81417497a76c05c2" target="_blank" rel="noopener noreferrer"><div class="link-card-title">Bridge trace example</div><div class="link-card-desc"><code>GET …/mission-control/bridge/trace</code></div></a>
<a class="link-card" href="/explorer-api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools" target="_blank" rel="noopener noreferrer"><div class="link-card-title">Liquidity pools (cUSDT)</div><div class="link-card-desc"><code>GET …/liquidity/token/{address}/pools</code></div></a>
<a class="link-card" href="/token-aggregation/api/v1/routes/matrix?includeNonLive=true" target="_blank" rel="noopener noreferrer"><div class="link-card-title">Route matrix</div><div class="link-card-desc">Token-aggregation live and planned routes.</div></a>
<a class="link-card" href="/api/v1/report/external-indexer-readiness?chainId=138" target="_blank" rel="noopener noreferrer"><div class="link-card-title">Indexer readiness</div><div class="link-card-desc">External indexer readiness report (chain 138).</div></a>
<a class="link-card" href="/token-aggregation/api/v1/report/official-protocols" target="_blank" rel="noopener noreferrer"><div class="link-card-title">Official protocols JSON</div><div class="link-card-desc">Production guardrails and contract list.</div></a>
<a class="link-card" href="/api/v2/stats" target="_blank" rel="noopener noreferrer"><div class="link-card-title">Blockscout stats</div><div class="link-card-desc">Chain head, gas, indexer summary.</div></a>
<a class="link-card" href="/api/config/networks" target="_blank" rel="noopener noreferrer"><div class="link-card-title">Wallet networks</div><div class="link-card-desc">Published chain metadata for onboarding.</div></a>
<a class="link-card" href="/explorer-api/v1/walletconnect/config" target="_blank" rel="noopener noreferrer"><div class="link-card-title">WalletConnect config</div><div class="link-card-desc">WalletConnect v2 posture.</div></a>
</div>
<p class="panel-desc" style="margin-top:1rem;">
<strong>Track 4 script API:</strong>
<code>POST /explorer-api/v1/track4/operator/run-script</code>
wallet auth, IP allowlist, and backend allowlist required.
</p>
</div>
</div>
<footer>
Source: <code>proxmox/docs/02-architecture/SMOM_DBIS_138_FULL_DEPLOYMENT_FLOW_MAP.md</code> — addresses: <code>config/smart-contracts-master.json</code> and CONTRACT_ADDRESSES_REFERENCE.
Source: <code>explorer-monorepo/docs/CHAIN138_VISUAL_TOPOLOGY_SOURCE.md</code> ·
Live addresses: <a href="/protocols">/protocols</a>,
<a href="/token-aggregation/api/v1/report/official-protocols">official-protocols JSON</a> ·
Meta: <a href="/chain138-command-center.meta.json">bundle metadata</a> ·
Alias: <a href="/topology">/topology</a>
</footer>
<script>
@@ -612,6 +852,17 @@ flowchart LR
var tablist = document.querySelector('[role="tablist"]');
var done = {};
fetch('/chain138-command-center.meta.json', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (meta) {
var el = document.getElementById('meta-version');
if (!el || !meta) return;
var parts = [meta.bundleVersion];
if (meta.routeMatrixUpdated) parts.push('routes ' + meta.routeMatrixUpdated);
el.textContent = parts.join(' · ');
})
.catch(function () {});
function parseInitialTab() {
var q = new URLSearchParams(window.location.search).get('tab');
if (q == null || q === '') return 0;
@@ -647,7 +898,7 @@ flowchart LR
var u = new URL(window.location.href);
u.searchParams.set('tab', slug);
history.replaceState(null, '', u.pathname + u.search + u.hash);
} catch (e) { /* file:// or restricted */ }
} catch (e) { /* file:// */ }
}
function setActive(index) {
@@ -677,11 +928,11 @@ flowchart LR
await ensureMermaid();
await mermaid.run({ nodes: nodes });
var status = document.getElementById('mermaid-status');
if (status) status.textContent = 'Diagram assets loaded. This page is a public reference surface; the main explorer remains the canonical live operational view.';
if (status) status.textContent = 'Diagram assets loaded. Use the main explorer for live operational data.';
} catch (e) {
console.error('Mermaid render failed for panel', index, e);
var statusError = document.getElementById('mermaid-status');
if (statusError) statusError.textContent = 'Diagram rendering failed. Use the Operations Hub or the main explorer for live operational surfaces.';
if (statusError) statusError.textContent = 'Diagram rendering failed. Open /operations or /bridge for live surfaces.';
}
}
}

View File

@@ -0,0 +1,6 @@
{
"bundleVersion": "2026-06-17",
"updatedAt": "2026-06-17T10:57:10Z",
"sourceDoc": "explorer-monorepo/docs/CHAIN138_VISUAL_TOPOLOGY_SOURCE.md",
"routeMatrixUpdated": null
}

View File

@@ -6,6 +6,52 @@
const EXPLORER_API_V1_BASE = EXPLORER_API_BASE + '/v1';
const EXPLORER_TRACK1_BASE = EXPLORER_API_V1_BASE + '/track1';
const TOKEN_AGGREGATION_API_BASE = '/token-aggregation/api';
const CHAIN138_ID = 138;
async function fetchTokenMarketBatch(addresses) {
if (!addresses || !addresses.length) return {};
try {
var resp = await fetch(TOKEN_AGGREGATION_API_BASE + '/v1/tokens/market-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
body: JSON.stringify({ chainId: CHAIN138_ID, addresses: addresses.slice(0, 120) }),
credentials: 'omit',
});
if (!resp.ok) return {};
var data = await resp.json();
var map = {};
(data.snapshots || []).forEach(function(s) {
if (s && s.address) map[String(s.address).toLowerCase()] = s;
});
return map;
} catch (e) {
return {};
}
}
function formatUsdAmount(value) {
if (value == null || !isFinite(value)) return '—';
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: value >= 100 ? 0 : 2 }).format(value);
}
function estimateTokenUsd(raw, decimals, priceUsd) {
if (priceUsd == null || !(priceUsd > 0) || raw == null) return null;
try {
var d = Number(decimals != null ? decimals : 18);
var amount = Number(raw) / Math.pow(10, d);
if (!isFinite(amount)) return null;
return amount * priceUsd;
} catch (e) {
return null;
}
}
function tokenUsdLabel(snapshot, raw, decimals, symbol) {
if (snapshot && snapshot.pricingKind === 'lp-share') return 'LP share — USD N/A';
var price = snapshot && snapshot.priceUsd != null ? Number(snapshot.priceUsd) : null;
var usd = estimateTokenUsd(raw, decimals, price);
return usd != null ? ('≈ ' + formatUsdAmount(usd)) : 'USD unavailable';
}
const EXPLORER_AI_API_BASE = EXPLORER_API_V1_BASE + '/ai';
const FETCH_TIMEOUT_MS = 15000;
const ADDRESS_DETAIL_BLOCKSCOUT_TIMEOUT_MS = 4000;
@@ -444,7 +490,7 @@
'<h3 style="margin-top:0;"><i class="fas fa-diagram-project"></i> Bridge / route interpretation</h3>' +
'<p style="color:var(--text-light); font-size:0.88rem;">Heuristic summary only — not proof of settlement or liquidity.</p>' +
'<div id="txBridgeTraceBody">' + buildBridgeTraceInnerHtml(toAddr, logs) + '</div>' +
'<p style="margin-top:0.75rem; font-size:0.85rem;"><a href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer">Visual command center (new tab)</a></p>' +
'<p style="margin-top:0.75rem; font-size:0.85rem;"><a href="/topology">Visual command center</a></p>' +
'</div>';
}
const CHAIN_138_PMM_INTEGRATION_ADDRESS = '0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895';
@@ -2110,7 +2156,7 @@
if (res.ok) {
var data = await res.json();
if (data && data.addEthereumChain) {
window._chain138AddEthereumChainCache = data.addEthereumChain;
window._chain138AddEthereumChainCache = sanitizeWalletAddEthereumChain(data.addEthereumChain);
return window._chain138AddEthereumChainCache;
}
}
@@ -2118,6 +2164,29 @@
return null;
}
function sanitizeWalletAddEthereumChain(raw) {
if (!raw || typeof raw !== 'object') return null;
var rpcUrls = Array.isArray(raw.rpcUrls)
? raw.rpcUrls.filter(function(u) { return typeof u === 'string' && u.indexOf('https://') === 0; })
: [];
if (!rpcUrls.length) rpcUrls = ['https://rpc-http-pub.d-bis.org'];
var out = {
chainId: raw.chainId,
chainName: raw.chainName,
rpcUrls: rpcUrls,
nativeCurrency: raw.nativeCurrency,
};
if (Array.isArray(raw.blockExplorerUrls)) {
var explorers = raw.blockExplorerUrls.filter(function(u) { return typeof u === 'string' && u.indexOf('https://') === 0; });
if (explorers.length) out.blockExplorerUrls = explorers;
}
if (Array.isArray(raw.iconUrls)) {
var icons = raw.iconUrls.filter(function(u) { return typeof u === 'string' && u.indexOf('https://') === 0; });
if (icons.length) out.iconUrls = icons;
}
return out;
}
async function fetchChain138ReportTokenList() {
if (window._chain138TokenListCache) return window._chain138TokenListCache;
try {
@@ -3502,7 +3571,7 @@
var decode = function(s) { try { return decodeURIComponent(s); } catch (e) { return s; } };
if (parts[0] === 'block' && parts[1]) { var p1 = decode(parts[1]); var key = 'block:' + p1; if (currentDetailKey === key) return; currentDetailKey = key; setTimeout(function() { showBlockDetail(p1); }, 0); return; }
if (parts[0] === 'tx' && parts[1]) { var p1 = decode(parts[1]); var txKey = 'tx:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)); if (currentDetailKey === txKey) return; currentDetailKey = txKey; setTimeout(function() { showTransactionDetail(p1); }, 0); return; }
if ((parts[0] === 'address' || parts[0] === 'addresses') && parts[1]) { var p1 = decode(parts[1]); var addrKey = 'address:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)); if (currentDetailKey === addrKey) return; currentDetailKey = addrKey; setTimeout(function() { showAddressDetail(p1); }, 0); return; }
if ((parts[0] === 'address' || parts[0] === 'addresses') && parts[1]) { var p1 = decode(parts[1]); if (parts[0] === 'address' && /^0x[a-fA-F0-9]{40}$/.test(p1)) { window.location.replace('/addresses/' + encodeURIComponent(p1)); return; } var addrKey = 'address:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)); if (currentDetailKey === addrKey) return; currentDetailKey = addrKey; setTimeout(function() { showAddressDetail(p1); }, 0); return; }
if (parts[0] === 'token' && parts[1]) { var p1 = decode(parts[1]); var tokKey = 'token:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)); if (currentDetailKey === tokKey) return; currentDetailKey = tokKey; setTimeout(function() { showTokenDetail(p1); }, 0); return; }
if (parts[0] === 'nft' && parts[1] && parts[2]) { var p1 = decode(parts[1]), p2 = decode(parts[2]); var nftKey = 'nft:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)) + ':' + p2; if (currentDetailKey === nftKey) return; currentDetailKey = nftKey; setTimeout(function() { showNftDetail(p1, p2); }, 0); return; }
if (parts[0] === 'home') { if (currentView !== 'home') showHome(); return; }
@@ -4297,6 +4366,20 @@
var addressesListPage = 1;
const LIST_PAGE_SIZE = 25;
function buildListPaginationHtml(options) {
var page = options.page;
var label = options.label || 'Results';
var ariaLabel = options.ariaLabel || (label + ' pagination');
var prevDisabled = page <= 1;
var nextDisabled = !options.hasNext;
return '<nav class="explorer-list-pagination" aria-label="' + String(ariaLabel).replace(/"/g, '&quot;') + '">' +
'<span class="explorer-list-pagination__status">' + label + ': page ' + page + '</span>' +
'<div class="explorer-list-pagination__actions">' +
'<button type="button" class="btn btn-secondary"' + (prevDisabled ? ' disabled' : '') + ' onclick="' + options.onPrev + '">Previous</button>' +
'<button type="button" class="btn btn-secondary"' + (nextDisabled ? ' disabled' : '') + ' onclick="' + options.onNext + '">Next</button>' +
'</div></nav>';
}
async function loadAllBlocks(page) {
if (page != null) blocksListPage = Math.max(1, parseInt(page, 10) || 1);
const container = document.getElementById('blocksList');
@@ -4356,10 +4439,14 @@
});
}
var pagination = '<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; flex-wrap: wrap; gap: 0.5rem;">';
pagination += '<span style="color: var(--text-light);">Page ' + blocksListPage + '</span>';
pagination += '<div style="display: flex; gap: 0.5rem;"><button type="button" class="btn btn-secondary" ' + (blocksListPage <= 1 ? 'disabled' : '') + ' onclick="loadAllBlocks(' + (blocksListPage - 1) + ')">Prev</button><button type="button" class="btn btn-secondary" ' + (blocks.length < LIST_PAGE_SIZE ? 'disabled' : '') + ' onclick="loadAllBlocks(' + (blocksListPage + 1) + ')">Next</button></div></div>';
html += '</tbody></table>' + pagination;
html += '</tbody></table>' + buildListPaginationHtml({
page: blocksListPage,
label: 'Blocks',
ariaLabel: 'Blocks pagination',
hasNext: blocks.length >= LIST_PAGE_SIZE,
onPrev: 'loadAllBlocks(' + (blocksListPage - 1) + ')',
onNext: 'loadAllBlocks(' + (blocksListPage + 1) + ')',
});
container.innerHTML = html;
} catch (error) {
container.innerHTML = '<div class="error">Failed to load blocks: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showBlocks()" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>';
@@ -4449,10 +4536,14 @@
});
}
var pagination = '<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; flex-wrap: wrap; gap: 0.5rem;">';
pagination += '<span style="color: var(--text-light);">Page ' + transactionsListPage + '</span>';
pagination += '<div style="display: flex; gap: 0.5rem;"><button type="button" class="btn btn-secondary" ' + (transactionsListPage <= 1 ? 'disabled' : '') + ' onclick="loadAllTransactions(' + (transactionsListPage - 1) + ')">Prev</button><button type="button" class="btn btn-secondary" ' + (transactions.length < LIST_PAGE_SIZE ? 'disabled' : '') + ' onclick="loadAllTransactions(' + (transactionsListPage + 1) + ')">Next</button></div></div>';
html += '</tbody></table>' + pagination;
html += '</tbody></table>' + buildListPaginationHtml({
page: transactionsListPage,
label: 'Transactions',
ariaLabel: 'Transactions pagination',
hasNext: transactions.length >= LIST_PAGE_SIZE,
onPrev: 'loadAllTransactions(' + (transactionsListPage - 1) + ')',
onNext: 'loadAllTransactions(' + (transactionsListPage + 1) + ')',
});
container.innerHTML = html;
} catch (error) {
container.innerHTML = '<div class="error">Failed to load transactions: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showTransactions()" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>';
@@ -4535,10 +4626,14 @@
});
}
var pagination = '<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; flex-wrap: wrap; gap: 0.5rem;">';
pagination += '<span style="color: var(--text-light);">Page ' + addressesListPage + '</span>';
pagination += '<div style="display: flex; gap: 0.5rem;"><button type="button" class="btn btn-secondary" ' + (addressesListPage <= 1 ? 'disabled' : '') + ' onclick="loadAllAddresses(' + (addressesListPage - 1) + ')">Prev</button><button type="button" class="btn btn-secondary" ' + (addresses.length < LIST_PAGE_SIZE ? 'disabled' : '') + ' onclick="loadAllAddresses(' + (addressesListPage + 1) + ')">Next</button></div></div>';
html += '</tbody></table>' + pagination;
html += '</tbody></table>' + buildListPaginationHtml({
page: addressesListPage,
label: 'Addresses',
ariaLabel: 'Addresses pagination',
hasNext: addresses.length >= LIST_PAGE_SIZE,
onPrev: 'loadAllAddresses(' + (addressesListPage - 1) + ')',
onNext: 'loadAllAddresses(' + (addressesListPage + 1) + ')',
});
container.innerHTML = html;
} catch (error) {
container.innerHTML = '<div class="error">Failed to load addresses: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showAddresses()" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>';
@@ -5224,7 +5319,7 @@
title: 'Explore',
items: [
{ title: 'Gas Tracker', icon: 'fa-gas-pump', status: 'Live', badgeClass: 'badge-success', desc: 'Review live gas, block time, TPS, and chain health from the home network dashboard.', action: 'showHome();', href: '/' },
{ title: 'Visual Command Center', icon: 'fa-satellite-dish', status: 'Live', badgeClass: 'badge-info', desc: 'Interactive Mermaid topology: Chain 138 hub, CCIP, Alltra, stack, flows, cross-chain, cW Mainnet, and off-chain integrations (from SMOM_DBIS_138_FULL_DEPLOYMENT_FLOW_MAP).', action: 'window.location.href=\'/chain138-command-center.html\';', href: '/chain138-command-center.html' },
{ title: 'Visual Command Center', icon: 'fa-satellite-dish', status: 'Live', badgeClass: 'badge-info', desc: 'Interactive Mermaid topology: Chain 138 hub, CCIP, Alltra, Stack A PMM, relay fleet, cW mesh, and off-chain integrations (CHAIN138_VISUAL_TOPOLOGY_SOURCE).', action: 'window.location.href=\'/topology\';', href: '/topology' },
{ title: 'DEX Tracker', icon: 'fa-chart-line', status: 'Live', badgeClass: 'badge-success', desc: 'Open liquidity discovery, PMM pool status, live route trees, and partner payload access points.', action: 'showRoutes();', href: '/routes' },
{ title: 'Node Tracker', icon: 'fa-server', status: 'Live', badgeClass: 'badge-success', desc: 'Inspect bridge balances, destination configuration, and operator-facing chain references from the live bridge monitoring panel.', action: 'showBridgeMonitoring();', href: '/bridge' },
{ title: 'Label Cloud', icon: 'fa-tags', status: 'Live', badgeClass: 'badge-success', desc: 'Browse labeled addresses, contracts, and address activity through the explorer address index.', action: 'showAddresses();', href: '/addresses' },
@@ -6925,67 +7020,106 @@
${a.creation_tx_hash ? `<div class="info-row"><div class="info-label">Contract created in</div><div class="info-value">${explorerTransactionLink(a.creation_tx_hash, escapeHtml(shortenHash(a.creation_tx_hash)), 'color: inherit; text-decoration: none;')} <button type="button" class="btn-copy" onclick="event.stopPropagation(); copyToClipboard('${escapeHtml(a.creation_tx_hash).replace(/'/g, "\\'")}', 'Copied');" aria-label="Copy"><i class="fas fa-copy"></i></button></div></div>` : ''}
${a.first_seen_at ? `<div class="info-row"><div class="info-label">First seen</div><div class="info-value">${escapeHtml(typeof a.first_seen_at === 'string' ? a.first_seen_at : new Date(a.first_seen_at).toISOString())}</div></div>` : ''}
${a.last_seen_at ? `<div class="info-row"><div class="info-label">Last seen</div><div class="info-value">${escapeHtml(typeof a.last_seen_at === 'string' ? a.last_seen_at : new Date(a.last_seen_at).toISOString())}</div></div>` : ''}
<div class="tabs" style="margin-top: 1.5rem;">
<button class="tab active" onclick="switchAddressTab('transactions', '${address}')" id="addrTabTxs" aria-selected="true">Transactions</button>
<button class="tab" onclick="switchAddressTab('tokens', '${address}')" id="addrTabTokens">Token Balances</button>
<button class="tab" onclick="switchAddressTab('internal', '${address}')" id="addrTabInternal">Internal Txns</button>
<button class="tab" onclick="switchAddressTab('nfts', '${address}')" id="addrTabNfts">NFTs</button>
${isContract ? '<button class="tab" onclick="switchAddressTab(\'contract\', \'' + address + '\')" id="addrTabContract">Contract (ABI / Bytecode)</button>' : ''}
<div class="tabs address-tabs" role="tablist" aria-label="Address details" style="margin-top: 1.5rem;">
<button type="button" class="tab active" role="tab" onclick="switchAddressTab('transactions', '${address}')" id="addrTabTxs" aria-selected="true" aria-controls="addressTabTransactions" tabindex="0">Transactions</button>
<button type="button" class="tab" role="tab" onclick="switchAddressTab('tokens', '${address}')" id="addrTabTokens" aria-selected="false" aria-controls="addressTabTokens" tabindex="-1">Token Balances</button>
<button type="button" class="tab" role="tab" onclick="switchAddressTab('internal', '${address}')" id="addrTabInternal" aria-selected="false" aria-controls="addressTabInternal" tabindex="-1">Internal Txns</button>
<button type="button" class="tab" role="tab" onclick="switchAddressTab('nfts', '${address}')" id="addrTabNfts" aria-selected="false" aria-controls="addressTabNfts" tabindex="-1">NFTs</button>
${isContract ? '<button type="button" class="tab" role="tab" onclick="switchAddressTab(\'contract\', \'' + address + '\')" id="addrTabContract" aria-selected="false" aria-controls="addressTabContract" tabindex="-1">Contract (ABI / Bytecode)</button>' : ''}
</div>
<div id="addressTabTransactions" class="address-tab-content card" style="margin-top: 1rem;">
<div id="addressTabTransactions" role="tabpanel" aria-labelledby="addrTabTxs" class="address-tab-content card" style="margin-top: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h3 style="margin: 0;">Recent Transactions</h3>
<button type="button" class="btn btn-primary" onclick="exportAddressTransactionsCSV('${address}')" style="padding: 0.5rem 1rem;"><i class="fas fa-file-csv"></i> Export CSV</button>
</div>
<div id="addressTransactions" class="loading">Loading transactions...</div>
</div>
<div id="addressTabTokens" class="address-tab-content card" style="margin-top: 1rem; display: none;">
<div id="addressTabTokens" role="tabpanel" aria-labelledby="addrTabTokens" class="address-tab-content card" style="margin-top: 1rem; display: none;" hidden>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h3 style="margin: 0;">Token Balances</h3>
<button type="button" class="btn btn-primary" onclick="exportAddressTokenBalancesCSV('${address}')" style="padding: 0.5rem 1rem;"><i class="fas fa-file-csv"></i> Export CSV</button>
</div>
<div id="addressTokenBalances" class="loading">Loading...</div>
</div>
<div id="addressTabInternal" class="address-tab-content card" style="margin-top: 1rem; display: none;">
<div id="addressTabInternal" role="tabpanel" aria-labelledby="addrTabInternal" class="address-tab-content card" style="margin-top: 1rem; display: none;" hidden>
<h3>Internal Transactions</h3>
<div id="addressInternalTxns" class="loading">Loading...</div>
</div>
<div id="addressTabNfts" class="address-tab-content card" style="margin-top: 1rem; display: none;">
<div id="addressTabNfts" role="tabpanel" aria-labelledby="addrTabNfts" class="address-tab-content card" style="margin-top: 1rem; display: none;" hidden>
<h3>NFT Inventory</h3>
<div id="addressNftInventory" class="loading">Loading...</div>
</div>
${isContract ? '<div id="addressTabContract" class="address-tab-content card" style="margin-top: 1rem; display: none;"><h3>Contract ABI & Bytecode</h3><div id="addressContractInfo" class="loading">Loading...</div></div>' : ''}
${isContract ? '<div id="addressTabContract" role="tabpanel" aria-labelledby="addrTabContract" class="address-tab-content card" style="margin-top: 1rem; display: none;" hidden><h3>Contract ABI & Bytecode</h3><div id="addressContractInfo" class="loading">Loading...</div></div>' : ''}
`;
function switchAddressTab(tabName, addr) {
document.querySelectorAll('.address-tab-content').forEach(function(el) { el.style.display = 'none'; });
document.querySelectorAll('.tabs .tab').forEach(function(t) { t.classList.remove('active'); });
if (tabName === 'transactions') {
document.getElementById('addressTabTransactions').style.display = 'block';
document.getElementById('addrTabTxs').classList.add('active');
} else if (tabName === 'tokens') {
document.getElementById('addressTabTokens').style.display = 'block';
document.getElementById('addrTabTokens').classList.add('active');
var tabMap = {
transactions: { tabId: 'addrTabTxs', panelId: 'addressTabTransactions' },
tokens: { tabId: 'addrTabTokens', panelId: 'addressTabTokens' },
internal: { tabId: 'addrTabInternal', panelId: 'addressTabInternal' },
nfts: { tabId: 'addrTabNfts', panelId: 'addressTabNfts' },
contract: { tabId: 'addrTabContract', panelId: 'addressTabContract' }
};
var active = tabMap[tabName];
document.querySelectorAll('.address-tabs .tab').forEach(function(tabButton) {
var isActive = active && tabButton.id === active.tabId;
tabButton.classList.toggle('active', isActive);
tabButton.setAttribute('aria-selected', isActive ? 'true' : 'false');
tabButton.setAttribute('tabindex', isActive ? '0' : '-1');
});
document.querySelectorAll('.address-tab-content').forEach(function(panel) {
var isActive = active && panel.id === active.panelId;
panel.style.display = isActive ? 'block' : 'none';
if (isActive) panel.removeAttribute('hidden');
else panel.setAttribute('hidden', '');
});
if (tabName === 'tokens') {
loadAddressTokenBalances(addr);
} else if (tabName === 'internal') {
document.getElementById('addressTabInternal').style.display = 'block';
document.getElementById('addrTabInternal').classList.add('active');
loadAddressInternalTxns(addr);
} else if (tabName === 'nfts') {
document.getElementById('addressTabNfts').style.display = 'block';
document.getElementById('addrTabNfts').classList.add('active');
loadAddressNftInventory(addr);
} else if (tabName === 'contract') {
var contractPanel = document.getElementById('addressTabContract');
var contractTab = document.getElementById('addrTabContract');
if (contractPanel && contractTab) {
contractPanel.style.display = 'block';
contractTab.classList.add('active');
loadAddressContractInfo(addr);
}
loadAddressContractInfo(addr);
}
}
window.switchAddressTab = switchAddressTab;
(function bindAddressTabsKeyboard(addr) {
var tablist = document.querySelector('.address-tabs[role="tablist"]');
if (!tablist || tablist.getAttribute('data-keyboard-bound') === '1') return;
tablist.setAttribute('data-keyboard-bound', '1');
var tabIdToName = {
addrTabTxs: 'transactions',
addrTabTokens: 'tokens',
addrTabInternal: 'internal',
addrTabNfts: 'nfts',
addrTabContract: 'contract'
};
tablist.addEventListener('keydown', function(e) {
var tabs = Array.prototype.slice.call(tablist.querySelectorAll('[role="tab"]'));
if (!tabs.length) return;
var currentIndex = -1;
for (var i = 0; i < tabs.length; i++) {
if (tabs[i].getAttribute('aria-selected') === 'true') {
currentIndex = i;
break;
}
}
if (currentIndex < 0) currentIndex = 0;
var nextIndex = currentIndex;
if (e.key === 'ArrowRight') nextIndex = (currentIndex + 1) % tabs.length;
else if (e.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') nextIndex = 0;
else if (e.key === 'End') nextIndex = tabs.length - 1;
else return;
e.preventDefault();
var nextTab = tabs[nextIndex];
var tabName = tabIdToName[nextTab.id];
if (!tabName) return;
switchAddressTab(tabName, addr);
nextTab.focus();
});
})(address);
var pend = window.__explorerPendingAddressInitialTab;
if (pend && pend.tab === 'contract' && pend.address === address.toLowerCase() && isContract) {
window.__explorerPendingAddressInitialTab = null;
@@ -7017,7 +7151,12 @@
const type = token.type || b.token_type || 'ERC-20';
return matchesExplorerFilter([symbol, contract, displayBalance, type].join(' '), filter);
}) : items;
let tbl = filterBar + '<table class="table"><thead><tr><th>Token</th><th>Contract</th><th>Balance</th><th>Type</th></tr></thead><tbody>';
const contractAddresses = filteredItems.map(function(b) {
const token = b.token || b;
return String(token.address?.hash || token.address || b.token_contract_address_hash || '').toLowerCase();
}).filter(function(a) { return /^0x[a-f0-9]{40}$/.test(a); });
const marketMap = await fetchTokenMarketBatch(contractAddresses);
let tbl = filterBar + '<table class="table"><thead><tr><th>Token</th><th>Contract</th><th>Balance</th><th>USD Value</th><th>Type</th></tr></thead><tbody>';
filteredItems.forEach(function(b) {
const token = b.token || b;
const contract = token.address?.hash || token.address || b.token_contract_address_hash || 'N/A';
@@ -7026,10 +7165,14 @@
const decimals = token.decimals != null ? token.decimals : 18;
const displayBalance = formatUnitsLocalized(balance, decimals, 6);
const type = token.type || b.token_type || 'ERC-20';
tbl += '<tr><td><a href="/tokens/' + encodeURIComponent(contract) + '">' + escapeHtml(symbol) + '</a></td><td>' + explorerAddressLink(contract, escapeHtml(shortenHash(contract)), 'color: inherit; text-decoration: none;') + '</td><td>' + escapeHtml(displayBalance) + '</td><td>' + escapeHtml(type) + '</td></tr>';
const contractKey = String(contract).toLowerCase();
const snapshot = marketMap[contractKey];
const usdLabel = tokenUsdLabel(snapshot, balance, decimals, symbol);
var cloneBadge = snapshot && snapshot.isCanonicalClone ? ' <span style="color:var(--warning); font-size:0.75rem;">clone</span>' : '';
tbl += '<tr><td><a href="/tokens/' + encodeURIComponent(contract) + '">' + escapeHtml(symbol) + '</a>' + cloneBadge + '</td><td>' + explorerAddressLink(contract, escapeHtml(shortenHash(contract)), 'color: inherit; text-decoration: none;') + '</td><td>' + escapeHtml(displayBalance) + '</td><td style="color:var(--text-light); font-size:0.9rem;">' + escapeHtml(usdLabel) + '</td><td>' + escapeHtml(type) + '</td></tr>';
});
if (filteredItems.length === 0) {
tbl += '<tr><td colspan="4" style="text-align:center; padding: 1rem;">No token balances match the current filter.</td></tr>';
tbl += '<tr><td colspan="5" style="text-align:center; padding: 1rem;">No token balances match the current filter.</td></tr>';
}
tbl += '</tbody></table>';
el.innerHTML = tbl;

View File

@@ -557,6 +557,23 @@
border-bottom-color: var(--primary);
font-weight: 600;
}
.explorer-list-pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.explorer-list-pagination__status {
color: var(--text-light);
font-size: 0.9rem;
}
.explorer-list-pagination__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.bridge-tab.active {
color: var(--bridge-blue);
border-bottom-color: var(--bridge-blue);
@@ -1890,7 +1907,7 @@
<button class="btn btn-secondary" onclick="showHome()" aria-label="Go back"><i class="fas fa-arrow-left" aria-hidden="true"></i> Back</button>
<h2 class="card-title"><i class="fas fa-sitemap" aria-hidden="true"></i> System topology</h2>
<div style="display:flex; gap:0.5rem; margin-left:auto; flex-wrap:wrap;">
<a class="btn btn-secondary" href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer">Command center</a>
<a class="btn btn-secondary" href="/topology">Command center</a>
<button type="button" class="btn btn-primary" onclick="if(window._showSystemTopology) window._showSystemTopology();" aria-label="Reload topology"><i class="fas fa-sync-alt" aria-hidden="true"></i> Reload</button>
</div>
</div>
@@ -1963,6 +1980,6 @@
</div>
</footer>
<script src="/explorer-spa.js?v=38"></script>
<script src="/explorer-spa.js?v=40"></script>
</body>
</html>

View File

@@ -2,16 +2,20 @@
## Mermaid (Visual Command Center)
`chain138-command-center.html` loads Mermaid from jsDelivr by default. If your explorer host blocks external script origins (CSP) or you need a fully offline doc path:
`chain138-command-center.html` loads Mermaid from **`/thirdparty/mermaid.min.js` first**. If the local bundle is missing, the page runtime loads jsDelivr as a fallback.
To vendor Mermaid for offline / CSP-locked hosts:
1. From repo root:
```bash
bash explorer-monorepo/scripts/vendor-mermaid-for-command-center.sh
```
2. Edit `chain138-command-center.html` and change the Mermaid `<script src="...">` line to:
```html
<script src="/thirdparty/mermaid.min.js"></script>
```
3. Deploy with `deploy-frontend-to-vmid5000.sh` — it copies `thirdparty/mermaid.min.js` when the file exists.
2. Deploy with `deploy-next-frontend-to-vmid5000.sh` — it copies `thirdparty/mermaid.min.js` when the file exists.
The minified file is gitignored (~3.3 MB); do not commit it.
Before deploy, refresh bundle metadata:
```bash
bash explorer-monorepo/scripts/refresh-chain138-command-center-meta.sh
```
The minified Mermaid file is gitignored (~3.3 MB); do not commit it.

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import Navbar from './Navbar'
import Footer from './Footer'
import ExplorerAgentTool from './ExplorerAgentTool'
import ExplorerDocumentHead from './ExplorerDocumentHead'
import { UiModeProvider } from './UiModeContext'
import { PostureGlossaryProvider } from './PostureGlossaryProvider'
@@ -9,6 +10,7 @@ export default function ExplorerChrome({ children }: { children: ReactNode }) {
return (
<UiModeProvider>
<PostureGlossaryProvider>
<ExplorerDocumentHead />
<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"

View File

@@ -0,0 +1,56 @@
'use client'
import Head from 'next/head'
import { useRouter } from 'next/router'
const BASE_TITLE = 'DBIS Explorer'
function resolveDocumentTitle(pathname: string, query: Record<string, string | string[] | undefined>): string {
if (pathname === '/') return `${BASE_TITLE} — Chain 138`
if (pathname === '/search') return `Search — ${BASE_TITLE}`
if (pathname === '/wallet') return `Wallet Tools — ${BASE_TITLE}`
if (pathname === '/protocols') return `Official Protocol Contracts — ${BASE_TITLE}`
if (pathname.startsWith('/protocols/')) {
const id = typeof query.id === 'string' ? query.id : pathname.split('/').pop() || 'Protocol'
return `${id} — Protocols — ${BASE_TITLE}`
}
if (pathname === '/pools') return `Pool Registry — ${BASE_TITLE}`
if (pathname.startsWith('/pools/')) return `Pool Detail — ${BASE_TITLE}`
if (pathname === '/liquidity') return `Liquidity — ${BASE_TITLE}`
if (pathname === '/operations') return `Operations — ${BASE_TITLE}`
if (pathname === '/docs') return `Documentation — ${BASE_TITLE}`
if (pathname === '/tokens') return `Tokens — ${BASE_TITLE}`
if (pathname.startsWith('/tokens/')) return `Token — ${BASE_TITLE}`
if (pathname === '/addresses') return `Addresses — ${BASE_TITLE}`
if (pathname.startsWith('/addresses/')) return `Address — ${BASE_TITLE}`
if (pathname === '/blocks') return `Blocks — ${BASE_TITLE}`
if (pathname === '/transactions') return `Transactions — ${BASE_TITLE}`
if (pathname.startsWith('/transactions/')) return `Transaction — ${BASE_TITLE}`
if (pathname === '/bridge') return `Bridge — ${BASE_TITLE}`
if (pathname === '/routes') return `Routes — ${BASE_TITLE}`
if (pathname === '/analytics') return `Analytics — ${BASE_TITLE}`
if (pathname === '/operator') return `Operator — ${BASE_TITLE}`
if (pathname === '/system') return `System — ${BASE_TITLE}`
if (pathname === '/weth') return `WETH — ${BASE_TITLE}`
if (pathname === '/watchlist') return `Watchlist — ${BASE_TITLE}`
if (pathname === '/access') return `Account Access — ${BASE_TITLE}`
const segment = pathname.split('/').filter(Boolean)[0]
if (segment) {
const label = segment.charAt(0).toUpperCase() + segment.slice(1)
return `${label}${BASE_TITLE}`
}
return BASE_TITLE
}
export default function ExplorerDocumentHead() {
const router = useRouter()
const pathname = router.pathname || '/'
const title = resolveDocumentTitle(pathname, router.query)
return (
<Head>
<title>{title}</title>
</Head>
)
}

View File

@@ -47,6 +47,7 @@ export default function Footer() {
<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="/protocols">Protocols</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>
@@ -87,9 +88,9 @@ export default function Footer() {
</p>
<p>
Command center:{' '}
<a className={footerLinkClass} href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer">
<Link className={footerLinkClass} href="/topology">
Chain 138 visual map
</a>
</Link>
</p>
<p className="text-xs leading-5 text-gray-500 dark:text-gray-500">
Questions about the explorer, chain metadata, route discovery, or liquidity access

View File

@@ -490,6 +490,7 @@ export default function Navbar() {
pathname.startsWith('/tokens') ||
pathname.startsWith('/analytics') ||
pathname.startsWith('/pools') ||
pathname.startsWith('/protocols') ||
pathname.startsWith('/watchlist')
const isOperationsActive =
pathname.startsWith('/bridge') ||
@@ -593,6 +594,7 @@ export default function Navbar() {
{ href: '/tokens', label: 'Tokens', description: 'Review curated assets, standards, and token detail pages.' },
{ href: '/analytics', label: 'Analytics', description: 'Open explorer-visible transaction and block activity summaries.' },
{ href: '/pools', label: 'Pools', description: 'Browse mission-control pool inventory and route-backed liquidity context.' },
{ href: '/protocols', label: 'Protocols', description: 'Review official upstream protocol contracts and production guardrails on Chain 138.' },
{ href: '/watchlist', label: 'Watchlist', description: 'Jump into tracked addresses and saved explorer entities.' },
],
[],
@@ -603,10 +605,11 @@ export default function Navbar() {
{ 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: '/protocols', label: 'Protocols', description: 'Official DODO, UniV2/V3, CCIP, and integration contracts on Chain 138.' },
{ href: '/system', label: 'System', description: 'Inspect topology, RPC capability, and public integration inventory.' },
{ 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 },
{ href: '/topology', label: 'Command Center', description: 'Open the visual topology and architecture map.' },
],
[],
)

View File

@@ -1,60 +1,97 @@
import { buildPaginationItems } from '@/utils/pagination'
interface PaginationControlsProps {
page: number
pageCount: number
onPageChange: (page: number) => void
label?: string
className?: string
disabled?: boolean
ariaLabel?: string
/** Known total pages for bounded pagination. Omit for cursor-style list pagination. */
pageCount?: number
/** Used when pageCount is omitted. */
hasNextPage?: boolean
}
const buttonClassName =
'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'
const activeButtonClassName = 'rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
export default function PaginationControls({
page,
pageCount,
hasNextPage = false,
onPageChange,
label = 'Rows',
className = '',
disabled = false,
ariaLabel,
}: PaginationControlsProps) {
if (pageCount <= 1) return null
const isBounded = typeof pageCount === 'number'
const boundedPageCount = pageCount ?? 1
const pages = Array.from({ length: pageCount }, (_, index) => index + 1)
if (isBounded && boundedPageCount <= 1) {
return null
}
if (!isBounded && page <= 1 && !hasNextPage) {
return null
}
const paginationItems = isBounded ? buildPaginationItems(page, boundedPageCount) : []
const canGoPrevious = page > 1
const canGoNext = isBounded ? page < boundedPageCount : hasNextPage
const navLabel = ariaLabel || `${label} pagination`
const statusText = isBounded ? `${label}: page ${page} of ${boundedPageCount}` : `${label}: page ${page}`
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>
<nav
className={`mt-4 flex flex-wrap items-center justify-between gap-3 ${className}`}
aria-label={navLabel}
>
<div className="text-sm text-gray-600 dark:text-gray-400">{statusText}</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"
disabled={disabled || !canGoPrevious}
className={buttonClassName}
>
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>
))}
{isBounded ? (
<ul className="flex flex-wrap items-center gap-2">
{paginationItems.map((item) =>
item.type === 'ellipsis' ? (
<li key={item.key} className="px-1 text-sm text-gray-500 dark:text-gray-400" aria-hidden="true">
</li>
) : (
<li key={item.page}>
<button
type="button"
onClick={() => onPageChange(item.page)}
disabled={disabled}
aria-current={item.page === page ? 'page' : undefined}
className={item.page === page ? activeButtonClassName : buttonClassName}
>
{item.page}
</button>
</li>
),
)}
</ul>
) : null}
<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"
onClick={() => onPageChange(isBounded ? Math.min(boundedPageCount, page + 1) : page + 1)}
disabled={disabled || !canGoNext}
className={buttonClassName}
>
Next
</button>
</div>
</div>
</nav>
)
}

View File

@@ -1,3 +1,5 @@
import { useCallback, useId } from 'react'
export interface SectionTab<T extends string> {
id: T
label: string
@@ -9,6 +11,21 @@ interface SectionTabsProps<T extends string> {
activeTab: T
onChange: (tab: T) => void
className?: string
idPrefix?: string
ariaLabel?: string
}
export function sectionTabPanelProps<T extends string>(
idPrefix: string,
tabId: T,
activeTab: T,
) {
return {
id: `${idPrefix}-panel-${tabId}`,
role: 'tabpanel' as const,
'aria-labelledby': `${idPrefix}-tab-${tabId}`,
hidden: activeTab !== tabId,
}
}
export default function SectionTabs<T extends string>({
@@ -16,29 +33,87 @@ export default function SectionTabs<T extends string>({
activeTab,
onChange,
className = '',
idPrefix,
ariaLabel = 'Sections',
}: SectionTabsProps<T>) {
const generatedId = useId().replace(/:/g, '')
const tabListPrefix = idPrefix || generatedId
const focusTab = useCallback(
(tabId: T) => {
const element = document.getElementById(`${tabListPrefix}-tab-${tabId}`)
element?.focus()
},
[tabListPrefix],
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const currentIndex = tabs.findIndex((tab) => tab.id === activeTab)
if (currentIndex < 0) {
return
}
let nextIndex = currentIndex
if (event.key === 'ArrowRight') {
nextIndex = (currentIndex + 1) % tabs.length
} else if (event.key === 'ArrowLeft') {
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length
} else if (event.key === 'Home') {
nextIndex = 0
} else if (event.key === 'End') {
nextIndex = tabs.length - 1
} else {
return
}
event.preventDefault()
const nextTab = tabs[nextIndex]
onChange(nextTab.id)
focusTab(nextTab.id)
},
[activeTab, focusTab, onChange, tabs],
)
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
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
role="tablist"
aria-label={ariaLabel}
className="flex gap-2 overflow-x-auto"
onKeyDown={handleKeyDown}
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
id={`${tabListPrefix}-tab-${tab.id}`}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={`${tabListPrefix}-panel-${tab.id}`}
tabIndex={isActive ? 0 : -1}
onClick={() => onChange(tab.id)}
className={
isActive
? '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={isActive ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
{tab.count.toLocaleString()}
</span>
) : null}
</button>
)
})}
</div>
</div>
)

View File

@@ -26,6 +26,7 @@ import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import {
formatCurrency,
@@ -252,6 +253,12 @@ export default function LiquidityOperationsPage({
href: `/api/v1/report/external-indexer-readiness?chainId=138`,
notes: 'One JSON posture for DefiLlama, CoinGecko, CoinMarketCap, and Dexscreener readiness.',
},
{
name: 'Curated pool registry',
method: 'GET',
href: `${tokenAggregationV1Base}/report/pool-registry?chainId=138`,
notes: 'DODO PMM + UniV2 pools with lpTokenType metadata; LP receipt tokens are not bridgeable.',
},
]
const copyEndpoint = async (endpoint: EndpointCard) => {
@@ -480,6 +487,20 @@ export default function LiquidityOperationsPage({
</Card>
</div>
<div className="mb-8">
<Card title="Curated LP registry (Chain 138)">
<LpPositionPanel chainId={138} title="Pool registry & LP scan" compact />
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
Connect a wallet on the{' '}
<Link href="/wallet" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
Wallet
</Link>{' '}
page to scan your LP shares and estimated USD NAV. LP tokens are chain-local bridge underlying c*/cW*
instead.
</p>
</Card>
</div>
<div className="mb-8">
<Card title="Explorer Access Points">
<div className="grid gap-4 md:grid-cols-2">

View File

@@ -991,34 +991,47 @@ export default function Home({
</Card>
</div>
<Card title="Quick links" className="mt-8">
<Card title="Institutional quick paths" 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.
Canonical discovery, compliance, and liquidity surfaces for institutional users mesh tokens, official
protocols, curated pools, and public JSON APIs.
</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 href="/search?q=cWUSDC" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Mesh search</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">c* cW* across chains</div>
</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 href="/protocols" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Official protocols</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Upstream contracts + guardrails</div>
</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 href="/pools" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Pool registry</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Live DODO PMM + UniV2 TVL</div>
</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 href="/wallet" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Wallet & MetaMask</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">13-chain token import</div>
</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 href="/liquidity" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Liquidity tools</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">LP policy bridge underlying, not LP</div>
</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 href="/token-aggregation/api/v1/report/official-protocols" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Public JSON APIs</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Protocols, pools, routes</div>
</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 href="/search" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Search</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Address, tx, mesh, pools</div>
</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 href="/docs" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Documentation</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">GRU, APIs, operations</div>
</Link>
<Link href="/bridge" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Bridge</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Relay posture + CCIP lanes</div>
</Link>
</div>
</Card>

View File

@@ -0,0 +1,256 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
import EntityBadge from '@/components/common/EntityBadge'
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
import {
fetchPoolRegistry,
type CuratedPoolRegistryEntry,
} from '@/services/api/liquidityPositions'
import { useUiMode } from '@/components/common/UiModeContext'
function formatUsd(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 1000 ? 0 : 2,
}).format(value)
}
function formatReserve(raw: string | undefined, symbol: string): string {
if (!raw) return 'Unavailable'
const decimals = symbol.toUpperCase().includes('BTC') ? 8 : 6
try {
const value = BigInt(raw)
const scale = 10n ** BigInt(decimals)
const whole = value / scale
const fraction = value % scale
const fractionText = fraction
.toString()
.padStart(decimals, '0')
.slice(0, 4)
.replace(/0+$/, '')
return fractionText
? `${whole.toLocaleString()}.${fractionText} ${symbol}`
: `${whole.toLocaleString()} ${symbol}`
} catch {
return 'Unavailable'
}
}
function isStableLikeSymbol(symbol: string): boolean {
const normalized = symbol.toUpperCase().replace(/^C/, '')
return ['USDT', 'USDC', 'EURC', 'EURT', 'GBPC', 'DAI', 'BUSD', 'TUSD', 'FRAX'].includes(normalized)
}
function stableReserveAsymmetryNote(
pool: CuratedPoolRegistryEntry,
): { severity: 'warning' | 'info'; message: string; deviationBps: number } | null {
if (pool.reserve0Usd == null || pool.reserve1Usd == null) return null
if (!isStableLikeSymbol(pool.baseSymbol) || !isStableLikeSymbol(pool.quoteSymbol)) return null
const legs = [pool.reserve0Usd, pool.reserve1Usd].filter((v) => v > 0)
if (legs.length < 2) return null
const min = Math.min(...legs)
const max = Math.max(...legs)
const deviationBps = Math.round(((max - min) / max) * 10000)
if (deviationBps < 150) return null
return {
severity: deviationBps >= 500 ? 'warning' : 'info',
deviationBps,
message: `Stable-pair reserves are asymmetric (${deviationBps} bps USD leg gap). PMM pools can hold unequal notionals while remaining tradable — do not assume 50/50 peg depth for trade sizing.`,
}
}
interface PoolDetailPageProps {
poolAddress: string
}
export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
const { mode } = useUiMode()
const [pools, setPools] = useState<CuratedPoolRegistryEntry[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
void fetchPoolRegistry()
.then((report) => {
if (!active) return
setPools(report?.pools ?? [])
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [])
const pool = useMemo(() => {
const lower = poolAddress.toLowerCase()
return pools.find(
(row) => row.poolAddress.toLowerCase() === lower || row.lpTokenAddress.toLowerCase() === lower,
)
}, [poolAddress, pools])
const pairLabel = pool ? `${pool.baseSymbol} / ${pool.quoteSymbol}` : poolAddress
const asymmetryNote = pool ? stableReserveAsymmetryNote(pool) : null
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<OperationsSurfaceNav />
<PageIntro
eyebrow="Curated pool registry"
title={loading ? 'Loading pool…' : pool ? pairLabel : 'Pool not in registry'}
description={
mode === 'guided'
? 'Chain-local liquidity pool from the curated DODO PMM + UniV2 registry. LP receipt tokens are not bridgeable — bridge underlying c*/cW* instead.'
: 'Curated pool detail · LP is chain-local only.'
}
actions={[
{ href: '/liquidity', label: 'Liquidity tools' },
{ href: '/pools', label: 'All pools' },
{ href: '/wallet', label: 'Scan LP positions' },
]}
/>
{loading ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Loading pool registry</p>
</Card>
) : null}
{!loading && !pool ? (
<Card title="Not in curated registry">
<p className="text-sm text-gray-600 dark:text-gray-400">
This address is not listed in the curated pool registry. It may still be a valid contract on Chain 138 or
another network open the address page for generic explorer data.
</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link href={`/addresses/${poolAddress}`} className="text-primary-600 hover:underline">
Open address page
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Back to search
</Link>
</div>
</Card>
) : null}
{pool ? (
<div className="space-y-6">
{asymmetryNote ? (
<Card title="Reserve asymmetry notice">
<p
className={
asymmetryNote.severity === 'warning'
? 'text-sm text-amber-800 dark:text-amber-200'
: 'text-sm text-gray-700 dark:text-gray-300'
}
>
{asymmetryNote.message}
</p>
</Card>
) : null}
<Card title="Pool summary">
<div className="flex flex-wrap gap-2">
<EntityBadge label={pool.venue.replace('_', ' ')} tone="info" />
<EntityBadge label={pool.lpTokenType.replace('_', ' ')} tone="neutral" />
<EntityBadge label={`chain ${pool.chainId}`} tone="warning" />
{pool.integrationStack ? (
<EntityBadge label={pool.integrationStack} tone="success" className="normal-case tracking-normal" />
) : null}
</div>
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-gray-500 dark:text-gray-400">Pool address</dt>
<dd className="mt-1">
<Address address={pool.poolAddress} truncate showCopy />
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">LP token</dt>
<dd className="mt-1">
<Address address={pool.lpTokenAddress} truncate showCopy />
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Base</dt>
<dd className="mt-1">
<Link href={`/tokens/${pool.baseAddress}`} className="text-primary-600 hover:underline">
{pool.baseSymbol}
</Link>
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Quote</dt>
<dd className="mt-1">
<Link href={`/tokens/${pool.quoteAddress}`} className="text-primary-600 hover:underline">
{pool.quoteSymbol}
</Link>
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Est. liquidity (USD)</dt>
<dd className="mt-1 font-semibold text-gray-900 dark:text-white">
{formatUsd(pool.totalLiquidityUsd)}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Base reserve</dt>
<dd className="mt-1 text-gray-700 dark:text-gray-300">
{formatReserve(pool.reserve0, pool.baseSymbol)}
{pool.reserve0Usd != null && pool.reserve0Usd > 0 ? (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
({formatUsd(pool.reserve0Usd)})
</span>
) : null}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Quote reserve</dt>
<dd className="mt-1 text-gray-700 dark:text-gray-300">
{formatReserve(pool.reserve1, pool.quoteSymbol)}
{pool.reserve1Usd != null && pool.reserve1Usd > 0 ? (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
({formatUsd(pool.reserve1Usd)})
</span>
) : null}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Liquidity source</dt>
<dd className="mt-1 text-gray-700 dark:text-gray-300">
{pool.liquiditySource ?? 'curated registry'}
{pool.liquidityAsOf ? (
<span className="block text-xs text-gray-500 dark:text-gray-400">
As of {new Date(pool.liquidityAsOf).toLocaleString()}
</span>
) : null}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Policy</dt>
<dd className="mt-1 text-gray-700 dark:text-gray-300">
LP shares are chain-local receipts not cross-chain mesh tokens.
</dd>
</div>
</dl>
</Card>
<LpPositionPanel
chainId={pool.chainId}
title="Your LP in this pool"
compact
hintAddresses={[pool.poolAddress, pool.lpTokenAddress]}
/>
</div>
) : null}
</div>
)
}

View File

@@ -4,6 +4,30 @@ 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'
import MultiChainWalletImport from '@/components/wallet/MultiChainWalletImport'
import MobileWalletContextBanner from '@/components/wallet/MobileWalletContextBanner'
import WalletFundedTokenListing from '@/components/wallet/WalletFundedTokenListing'
import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient'
import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain'
import {
isMobileWalletContext,
MOBILE_WALLET_BUTTON_CLASS,
resolveWalletEthereumProvider,
type EthereumProvider,
} from '@/utils/walletProviderEnv'
import { buildWatchAssetRpcRequest, runWatchAssetBatch } from '@/utils/walletWatchAsset'
import {
CHAIN138_PLACEHOLDER_GAS_SYMBOLS,
dedupeWalletWatchTokens,
isWalletWatchEligibleAddress,
} from '@/utils/walletWatchEligible'
import {
CHAIN138_NATIVE_ETH_LOGO,
formatFundedRowBalanceUsd,
fundedRowsToWatchCatalogTokens,
loadFundedWalletTokenListing,
type FundedWalletTokenRow,
} from '@/utils/walletFundedTokenListing'
export type WalletChain = {
chainId: string
@@ -117,6 +141,13 @@ export type FetchMetadata = {
lastModified?: string | null
}
type PendingWatchFlow = {
tokens: TokenListToken[]
label: string
nextIndex: number
totalAdded: number
}
interface AddToMetaMaskProps {
initialNetworks?: NetworksCatalog | null
initialTokenList?: TokenListCatalog | null
@@ -126,8 +157,10 @@ interface AddToMetaMaskProps {
initialCapabilitiesMeta?: FetchMetadata | null
}
type EthereumProvider = {
request: (args: { method: string; params?: unknown }) => Promise<unknown>
function chainParamsForWallet(chain: WalletChain) {
return toWalletAddEthereumChainParams(chain, {
preferSingleRpc: typeof window !== 'undefined' && isMobileWalletContext(),
})
}
const FALLBACK_CHAIN_138: WalletChain = {
@@ -135,7 +168,7 @@ const FALLBACK_CHAIN_138: WalletChain = {
chainIdDecimal: 138,
chainName: 'DeFi Oracle Meta Mainnet',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'],
rpcUrls: ['https://rpc-http-pub.d-bis.org'],
blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'],
iconUrls: [
'https://explorer.d-bis.org/api/v1/report/logo/chain-138',
@@ -353,13 +386,36 @@ export function AddToMetaMask({
const [metamaskConfigMeta, setMetamaskConfigMeta] = useState<FetchMetadata | null>(null)
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>([])
const [watchAssetProgress, setWatchAssetProgress] = useState<{ current: number; total: number } | null>(null)
const [balanceCheckProgress, setBalanceCheckProgress] = useState<{ current: number; total: number } | null>(null)
const [fundedTokenRows, setFundedTokenRows] = useState<FundedWalletTokenRow[]>([])
const [fundedListingWallet, setFundedListingWallet] = useState<string | null>(null)
const [fundedListingLoading, setFundedListingLoading] = useState(false)
const [fundedListingError, setFundedListingError] = useState<string | null>(null)
const [pendingWatchFlow, setPendingWatchFlow] = useState<PendingWatchFlow | null>(null)
const [providerTick, setProviderTick] = useState(0)
const ethereum = typeof window !== 'undefined'
? (window as unknown as { ethereum?: EthereumProvider }).ethereum
: undefined
const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext()
const resolveEthereum = (): EthereumProvider | undefined => {
void providerTick
return resolveWalletEthereumProvider(getActiveWalletConnectProvider())
}
const hasWalletProvider = typeof window !== 'undefined' && Boolean(resolveEthereum())
useEffect(() => {
if (typeof window === 'undefined') return undefined
const refresh = () => setProviderTick((value) => value + 1)
window.addEventListener('ethereum#initialized', refresh)
window.addEventListener('focus', refresh)
return () => {
window.removeEventListener('ethereum#initialized', refresh)
window.removeEventListener('focus', refresh)
}
}, [])
const apiBase = getApiBase().replace(/\/$/, '')
const tokenListUrl = `${apiBase}/api/v1/report/token-list?chainId=138`
const tokenListUrl = `${apiBase}/api/v1/report/token-list?chainId=138&wallet=1`
const networksUrl = `${apiBase}/api/config/networks`
const metamaskConfigUrl = `${apiBase}/api/v1/config/metamask?chainId=138`
const capabilitiesUrl = `${apiBase}/api/config/capabilities`
@@ -476,7 +532,10 @@ export function AddToMetaMask({
}, [])
const catalogTokens = useMemo(
() => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []),
() =>
(Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []).filter((token) =>
isWalletWatchEligibleAddress(token.address),
),
[tokenList],
)
@@ -502,27 +561,31 @@ export function AddToMetaMask({
)
const watchAssetTokens = useMemo(() => {
const endpointTokens = (metamaskConfig?.watchAssets || [])
.filter(isWatchAssetEntry)
.map(watchAssetToToken)
const endpointTokens = dedupeWalletWatchTokens(
(metamaskConfig?.watchAssets || [])
.filter(isWatchAssetEntry)
.map(watchAssetToToken)
.filter((token) => isWalletWatchEligibleAddress(token.address)),
)
if (endpointTokens.length > 0) return endpointTokens
return catalogTokens.filter((token) => token.chainId === 138)
return dedupeWalletWatchTokens(catalogTokens.filter((token) => token.chainId === 138))
}, [catalogTokens, metamaskConfig])
const addChain = async (chain: WalletChain) => {
setError(null)
setStatus(null)
const ethereum = resolveEthereum()
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
return
}
try {
await ethereum.request({
method: 'wallet_addEthereumChain',
params: [chain],
params: [chainParamsForWallet(chain)],
})
setStatus(`Added ${chain.chainName}. You can switch to it in your wallet.`)
} catch (e) {
@@ -536,8 +599,9 @@ export function AddToMetaMask({
}
const switchOrAddChain = async (chain: WalletChain) => {
const ethereum = resolveEthereum()
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
return false
}
@@ -558,7 +622,7 @@ export function AddToMetaMask({
try {
await ethereum.request({
method: 'wallet_addEthereumChain',
params: [chain],
params: [chainParamsForWallet(chain)],
})
return true
} catch (e) {
@@ -572,8 +636,9 @@ export function AddToMetaMask({
setError(null)
setStatus(null)
const ethereum = resolveEthereum()
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
setError('No wallet provider found. Snaps require MetaMask in a supported browser.')
return
}
@@ -602,6 +667,13 @@ export function AddToMetaMask({
}
}
const networkForToken = (token: TokenListToken): WalletChain | null => {
if (token.chainId === 138) return chains.chain138
if (token.chainId === 1) return chains.ethereum
if (token.chainId === 651940) return chains.allMainnet
return null
}
const watchToken = async (token: TokenListToken) => {
setError(null)
setStatus(null)
@@ -611,32 +683,118 @@ export function AddToMetaMask({
return
}
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
if (!isWalletWatchEligibleAddress(token.address)) {
setError(
`${token.symbol} uses a roadmap placeholder address on Chain 138 (not a live ERC-20). Remove it from MetaMask if it was added earlier, then use Add all Chain 138 tokens for live contracts such as cUSDT, cUSDC, and cBTC.`,
)
return
}
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,
},
},
})
const ethereum = resolveEthereum()
if (!ethereum) {
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
return
}
setStatus(added ? `Added ${token.symbol} to your wallet.` : `${token.symbol} request was dismissed.`)
const network = networkForToken(token)
if (network) {
const switched = await switchOrAddChain(network)
if (!switched) return
}
try {
const added = await ethereum.request(buildWatchAssetRpcRequest(token, mobileWalletContext))
setStatus(
added
? `Added ${token.symbol} on ${network?.chainName || `chain ${token.chainId}`}. Switch to that network in MetaMask to see balances.`
: `${token.symbol} request was dismissed.`,
)
} catch (e) {
const err = e as { message?: string }
setError(err.message || `Failed to add ${token.symbol}.`)
}
}
const watchAssetCatalogTokens = useMemo(
() =>
watchAssetTokens.map((token) => ({
chainId: token.chainId,
address: token.address,
symbol: token.symbol,
name: token.name,
decimals: token.decimals,
logoURI: token.logoURI,
})),
[watchAssetTokens],
)
const requestConnectedWalletAddress = async (): Promise<string | null> => {
const ethereum = resolveEthereum()
if (!ethereum) {
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
return null
}
const switched = await switchOrAddChain(chains.chain138)
if (!switched) return null
try {
const accounts = (await ethereum.request({ method: 'eth_requestAccounts' })) as string[]
return accounts[0] || null
} catch (e) {
const err = e as { message?: string }
setError(err.message || 'Could not connect a wallet account.')
return null
}
}
const refreshFundedTokenListing = async () => {
setFundedListingError(null)
setFundedListingLoading(true)
setBalanceCheckProgress(null)
try {
const walletAddress = await requestConnectedWalletAddress()
if (!walletAddress) {
setFundedTokenRows([])
setFundedListingWallet(null)
return
}
const ethereum = resolveEthereum()
if (!ethereum) return
const rows = await loadFundedWalletTokenListing(
ethereum,
walletAddress,
watchAssetCatalogTokens,
{
nativeLogoUri: CHAIN138_NATIVE_ETH_LOGO,
onProgress: (current, total) => setBalanceCheckProgress({ current, total }),
},
)
setFundedTokenRows(rows)
setFundedListingWallet(walletAddress)
setBalanceCheckProgress(null)
const erc20Count = rows.filter((row) => row.kind === 'erc20').length
setStatus(
rows.length > 0
? `Loaded ${rows.length} funded holding(s) for ${walletAddress.slice(0, 6)}${walletAddress.slice(-4)} (${erc20Count} ERC-20 + native ETH when present).`
: `No funded catalog tokens for ${walletAddress.slice(0, 6)}${walletAddress.slice(-4)}.`,
)
} catch (e) {
const err = e as { message?: string }
setFundedListingError(err.message || 'Failed to load funded token listing.')
setFundedTokenRows([])
setFundedListingWallet(null)
} finally {
setFundedListingLoading(false)
setBalanceCheckProgress(null)
}
}
const refreshMainnetCwusdc = async () => {
setError(null)
setStatus(null)
@@ -647,51 +805,140 @@ export function AddToMetaMask({
await watchToken(MAINNET_CWUSDC_TOKEN)
}
const watchTokensSequentially = async (tokens: TokenListToken[], label: string) => {
const watchTokensSequentially = async (
tokens: TokenListToken[],
label: string,
startIndex = 0,
priorAdded = 0,
) => {
setError(null)
setStatus(null)
if (startIndex === 0) {
setStatus(null)
setPendingWatchFlow(null)
}
setWatchAssetProgress(null)
setBalanceCheckProgress(null)
const ethereum = resolveEthereum()
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
return
}
const validTokens = tokens.filter(isTokenListToken)
const validTokens = dedupeWalletWatchTokens(
tokens.filter(isTokenListToken).filter((token) => isWalletWatchEligibleAddress(token.address)),
)
if (validTokens.length === 0) {
setError('No live Chain 138 token metadata is available for wallet_watchAsset right now.')
return
}
const switched = await switchOrAddChain(chains.chain138)
if (!switched) return
const batch = await runWatchAssetBatch(ethereum, validTokens, startIndex, {
mobile: mobileWalletContext,
onProgress: (current, total) => setWatchAssetProgress({ current, total }),
})
const totalAdded = priorAdded + batch.addedCount
setWatchAssetProgress(null)
if (batch.stoppedEarly) {
setError(batch.errorMessage || 'Token import stopped.')
setPendingWatchFlow(null)
setStatus(`${totalAdded} of ${validTokens.length} ${label} token requests were accepted before the flow stopped.`)
return
}
if (batch.nextIndex < validTokens.length) {
setPendingWatchFlow({
tokens: validTokens,
label,
nextIndex: batch.nextIndex,
totalAdded,
})
setStatus(
`${totalAdded} of ${validTokens.length} ${label} tokens added. Tap Continue for the next ${Math.min(validTokens.length - batch.nextIndex, 2)} prompt(s). On mobile, approve each wallet popup before it closes.`,
)
return
}
setPendingWatchFlow(null)
setStatus(
`${totalAdded} of ${validTokens.length} ${label} token requests were accepted. Native ETH appears automatically on DeFi Oracle Meta Mainnet (not via Add Token). Stay on Chain 138 with RPC https://rpc-http-pub.d-bis.org to see balances.`,
)
}
const continuePendingWatchFlow = async () => {
if (!pendingWatchFlow) return
const { tokens, label, nextIndex, totalAdded } = pendingWatchFlow
await watchTokensSequentially(tokens, label, nextIndex, totalAdded)
}
const watchTokensWithBalanceOnly = async () => {
setError(null)
setStatus(null)
setWatchAssetProgress(null)
setBalanceCheckProgress(null)
setFundedListingError(null)
const ethereum = resolveEthereum()
if (!ethereum) {
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
return
}
const candidates = watchAssetTokens.filter(isTokenListToken)
if (candidates.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)
setFundedListingLoading(true)
try {
const walletAddress = await requestConnectedWalletAddress()
if (!walletAddress) return
setStatus(`Checking ERC-20 balances and market prices for ${candidates.length} catalog tokens on Chain 138…`)
const rows = await loadFundedWalletTokenListing(
ethereum,
walletAddress,
watchAssetCatalogTokens,
{
nativeLogoUri: CHAIN138_NATIVE_ETH_LOGO,
onProgress: (current, total) => setBalanceCheckProgress({ current, total }),
},
)
setFundedTokenRows(rows)
setFundedListingWallet(walletAddress)
setBalanceCheckProgress(null)
const fundedTokens = fundedRowsToWatchCatalogTokens(rows)
if (fundedTokens.length === 0) {
const nativeRow = rows.find((row) => row.kind === 'native')
setStatus(
nativeRow
? `Native ETH: ${nativeRow.balanceLabel} (${formatFundedRowBalanceUsd(nativeRow)}). No ERC-20 balances among catalog tokens — nothing to add via EIP-747.`
: `No ERC-20 balances found among ${candidates.length} catalog tokens for ${walletAddress.slice(0, 6)}${walletAddress.slice(-4)}.`,
)
return
}
}
setWatchAssetProgress(null)
setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted by the wallet.`)
const symbols = fundedTokens.map((token) => token.symbol).join(', ')
setStatus(
`Found ${fundedTokens.length} ERC-20 token(s) with balance (${symbols}). Native ETH is listed above when present. Starting wallet prompts…`,
)
await watchTokensSequentially(fundedTokens as TokenListToken[], 'funded Chain 138')
} catch (e) {
const err = e as { message?: string }
setError(err.message || 'Failed to read token balances from Chain 138.')
setBalanceCheckProgress(null)
} finally {
setFundedListingLoading(false)
}
}
const copyText = async (value: string, label: string) => {
@@ -727,25 +974,38 @@ export function AddToMetaMask({
metadata directly to the wallet.
</p>
<div className="grid gap-3 md:grid-cols-3">
<MobileWalletContextBanner hasProvider={hasWalletProvider} />
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() =>
void switchOrAddChain(chains.chain138).then((ok) => {
if (ok) setStatus('Switched to DeFi Oracle Meta Mainnet. Native ETH and imported tokens show on this network.')
})
}
className={`rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Switch to Chain 138
</button>
<button
type="button"
onClick={() => addChain(chains.chain138)}
className="rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
className={`rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add Chain 138
</button>
<button
type="button"
onClick={() => addChain(chains.ethereum)}
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add Ethereum Mainnet
</button>
<button
type="button"
onClick={() => addChain(chains.allMainnet)}
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add ALL Mainnet
</button>
@@ -893,29 +1153,84 @@ export function AddToMetaMask({
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
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.
approval for each token, so the bulk actions below run as a guided sequence of wallet prompts. Bulk add
switches to DeFi Oracle Meta Mainnet first tokens imported while on another network will not show
balances on Chain 138. Native ETH is not added via this flow; it appears when you are on Chain 138 with a
working RPC. Use <span className="font-medium">Add tokens with balance only</span> to skip zero-balance and
undeployed catalog entries.
{mobileWalletContext
? ' On mobile, imports run two wallet prompts per tap — use Continue until finished.'
: null}
</p>
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50/80 p-3 text-sm leading-6 text-amber-950 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
MetaMask keeps <span className="font-medium">one row per token symbol</span> on a network. If a balance
disappears after import, remove duplicate custom tokens (especially second <code className="text-xs">cUSDC</code>{' '}
/ <code className="text-xs">cUSDT</code> rows or old gas placeholders like{' '}
{CHAIN138_PLACEHOLDER_GAS_SYMBOLS.slice(0, 3).join(', ')}), stay on{' '}
<span className="font-medium">DeFi Oracle Meta Mainnet</span>, then tap{' '}
<span className="font-medium">Add tokens with balance only</span> or{' '}
<span className="font-medium">Add featured tokens</span>.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{pendingWatchFlow ? (
<button
type="button"
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading}
onClick={() => void continuePendingWatchFlow()}
className={`rounded bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Continue ({pendingWatchFlow.totalAdded} added, {pendingWatchFlow.tokens.length - pendingWatchFlow.nextIndex} left)
</button>
) : null}
<button
type="button"
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading}
onClick={() => void watchTokensWithBalanceOnly()}
className={`rounded bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add tokens with balance only
</button>
<button
type="button"
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading || pendingWatchFlow !== null}
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"
className={`rounded bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add featured tokens
</button>
<button
type="button"
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading || pendingWatchFlow !== null}
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"
className={`rounded bg-gray-600 px-3 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add all Chain 138 tokens
</button>
<button
type="button"
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading}
onClick={() => void refreshFundedTokenListing()}
className={`rounded border border-emerald-600 px-3 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 dark:text-emerald-300 dark:hover:bg-emerald-950/30 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Refresh funded holdings
</button>
{balanceCheckProgress ? (
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
Balance check {balanceCheckProgress.current} of {balanceCheckProgress.total}
</span>
) : null}
{watchAssetProgress ? (
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
Prompt {watchAssetProgress.current} of {watchAssetProgress.total}
</span>
) : null}
</div>
<WalletFundedTokenListing
rows={fundedTokenRows}
walletAddress={fundedListingWallet}
loading={fundedListingLoading}
error={fundedListingError}
/>
<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>
@@ -940,7 +1255,7 @@ export function AddToMetaMask({
<button
type="button"
onClick={() => watchToken(token)}
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
className={`rounded bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add {token.symbol}
</button>
@@ -982,6 +1297,8 @@ export function AddToMetaMask({
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
<MultiChainWalletImport />
</div>
)
}

View File

@@ -0,0 +1,169 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import {
fetchLpPositions,
fetchPoolRegistry,
type LpPositionRow,
type PoolRegistryResponse,
} from '@/services/api/liquidityPositions'
import { chainLabel } from '@/utils/walletChainCatalog'
function formatUsd(value: number | null | undefined): string {
if (value == null || Number.isNaN(value)) return '—'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 1000 ? 0 : 2,
}).format(value)
}
function formatShare(value: number): string {
if (!Number.isFinite(value) || value <= 0) return '0%'
return `${(value * 100).toFixed(value < 0.01 ? 4 : 2)}%`
}
interface LpPositionPanelProps {
chainId?: number
address?: string | null
hintAddresses?: string[]
title?: string
compact?: boolean
}
export default function LpPositionPanel({
chainId = 138,
address,
hintAddresses = [],
title = 'LP positions',
compact = false,
}: LpPositionPanelProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [positions, setPositions] = useState<LpPositionRow[]>([])
const [totalUsd, setTotalUsd] = useState<number | null>(null)
const [registry, setRegistry] = useState<PoolRegistryResponse | null>(null)
const [notes, setNotes] = useState<string[]>([])
useEffect(() => {
void fetchPoolRegistry(chainId).then(setRegistry).catch(() => setRegistry(null))
}, [chainId])
useEffect(() => {
if (!address) {
setPositions([])
setTotalUsd(null)
setNotes([])
return
}
let active = true
setLoading(true)
setError(null)
void fetchLpPositions({ chainId, address, hintAddresses })
.then((report) => {
if (!active) return
if (!report) {
setError('LP position scan is unavailable right now.')
setPositions([])
setTotalUsd(null)
return
}
setPositions(report.positions)
setTotalUsd(report.totalEstimatedUsd)
setNotes(report.notes)
})
.catch(() => {
if (!active) return
setError('LP position scan failed.')
setPositions([])
setTotalUsd(null)
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [address, chainId, hintAddresses.join(',')])
return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 p-4 dark:border-gray-800 dark:bg-gray-900/40">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{title}</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Curated DODO PMM + UniV2 pools on {chainLabel(chainId)}
{registry ? ` · ${registry.count} pools in registry` : ''}
{address ? '' : ' · connect a wallet to scan LP shares'}
</div>
</div>
{totalUsd != null ? (
<div className="text-right">
<div className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Estimated LP NAV</div>
<div className="text-lg font-semibold text-gray-900 dark:text-white">{formatUsd(totalUsd)}</div>
</div>
) : null}
</div>
{loading ? (
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">Scanning on-chain LP balances</div>
) : null}
{error ? <div className="mt-4 text-sm text-red-600 dark:text-red-400">{error}</div> : null}
{!loading && !error && address && positions.length === 0 ? (
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
No active LP shares detected in the curated pool registry for this address.
</div>
) : null}
{positions.length > 0 ? (
<div className={`mt-4 space-y-3 ${compact ? '' : ''}`}>
{positions.map((position) => (
<div
key={position.poolAddress}
className="rounded-xl border border-gray-200 bg-white/80 px-4 py-3 dark:border-gray-700 dark:bg-black/10"
>
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="font-semibold text-gray-900 dark:text-white">
{position.pairLabel}{' '}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
({position.venue.replace('_', ' ')})
</span>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Pool {position.poolAddress.slice(0, 10)}{position.poolAddress.slice(-8)} · share{' '}
{formatShare(position.shareOfPool)} · balance {position.shareBalanceUnits}
</div>
</div>
<div className="mt-2 flex flex-col items-end gap-1">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{formatUsd(position.estimatedUsd)}
</div>
<Link href={`/pools/${position.poolAddress}`} className="text-xs text-primary-600 hover:underline">
Pool detail
</Link>
<Link href={`/liquidity`} className="text-xs text-primary-600 hover:underline">
Liquidity tools
</Link>
</div>
</div>
</div>
))}
</div>
) : null}
{notes.length > 0 ? (
<div className="mt-4 space-y-1 text-xs text-gray-500 dark:text-gray-400">
{notes.map((note) => (
<p key={note}>{note}</p>
))}
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import {
buildCoinbaseWalletDappUrl,
buildMetaMaskMobileDappUrl,
isMobileBrowser,
isWalletInAppBrowser,
MOBILE_WALLET_BUTTON_CLASS,
} from '@/utils/walletProviderEnv'
type MobileWalletContextBannerProps = {
hasProvider: boolean
}
export default function MobileWalletContextBanner({ hasProvider }: MobileWalletContextBannerProps) {
if (typeof window === 'undefined') return null
if (!isMobileBrowser() || isWalletInAppBrowser()) return null
const pageUrl = window.location.href
const metamaskUrl = buildMetaMaskMobileDappUrl(pageUrl)
const coinbaseUrl = buildCoinbaseWalletDappUrl(pageUrl)
return (
<div className="rounded-lg border border-sky-200 bg-sky-50/80 p-4 dark:border-sky-900/50 dark:bg-sky-950/30">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Mobile wallet browser</div>
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
{hasProvider
? 'You are in a mobile browser with a wallet provider. Add-chain and add-token buttons work best one or two tokens at a time — use Continue when prompted. If a prompt does not appear, unlock your wallet and stay on this tab.'
: 'Mobile Safari/Chrome do not expose a wallet extension. Open this page inside MetaMask or Coinbase Wallet, or connect with WalletConnect above, then use the add-chain and add-token buttons.'}
</p>
{!hasProvider ? (
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<a
href={metamaskUrl}
className={`inline-flex items-center justify-center rounded-lg bg-orange-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-orange-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Open in MetaMask
</a>
<a
href={coinbaseUrl}
className={`inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Open in Coinbase Wallet
</a>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,336 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
import type { TokenListToken, WalletChain } from '@/components/wallet/AddToMetaMask'
import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient'
import {
WALLET_FEATURED_SYMBOLS_BY_CHAIN,
WALLET_IMPORT_CHAIN_IDS,
chainLabel,
} from '@/utils/walletChainCatalog'
import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain'
import {
isMobileWalletContext,
MOBILE_WALLET_BUTTON_CLASS,
resolveWalletEthereumProvider,
} from '@/utils/walletProviderEnv'
import { runWatchAssetBatch } from '@/utils/walletWatchAsset'
import { dedupeWalletWatchTokens } from '@/utils/walletWatchEligible'
type WatchAssetEntry = {
type: 'ERC20'
options: {
address: string
symbol: string
decimals: number
image?: string
}
}
type MetaMaskChainPayload = {
chainId?: number
addEthereumChain?: WalletChain
watchAssets?: WatchAssetEntry[]
}
type ChainImportState = {
chainId: number
network: WalletChain | null
tokens: TokenListToken[]
loading: boolean
error: string | null
}
type PendingMultiChainFlow = {
chainId: number
tokens: TokenListToken[]
label: string
nextIndex: number
totalAdded: number
}
function isTokenListToken(value: unknown): value is TokenListToken {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<TokenListToken>
return (
typeof candidate.chainId === 'number' &&
typeof candidate.address === 'string' &&
typeof candidate.symbol === 'string' &&
typeof candidate.decimals === 'number'
)
}
function watchAssetToToken(chainId: number, entry: WatchAssetEntry): TokenListToken {
return {
chainId,
address: entry.options.address,
symbol: entry.options.symbol,
name: entry.options.symbol,
decimals: entry.options.decimals,
logoURI: entry.options.image,
}
}
function getApiBase() {
return resolveExplorerApiBase({
browserOrigin: '',
serverFallback: 'https://explorer.d-bis.org',
}).replace(/\/$/, '')
}
function chainParamsForWallet(chain: WalletChain) {
return toWalletAddEthereumChainParams(chain, {
preferSingleRpc: typeof window !== 'undefined' && isMobileWalletContext(),
})
}
export default function MultiChainWalletImport() {
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState<{ current: number; total: number; chainId: number } | null>(null)
const [chains, setChains] = useState<ChainImportState[]>([])
const [pendingFlow, setPendingFlow] = useState<PendingMultiChainFlow | null>(null)
const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext()
const resolveEthereum = () => resolveWalletEthereumProvider(getActiveWalletConnectProvider())
useEffect(() => {
let active = true
const apiBase = getApiBase()
async function loadChain(chainId: number): Promise<ChainImportState> {
try {
const [networkRes, metamaskRes] = await Promise.all([
fetch(`${apiBase}/api/config/networks`, { cache: 'no-store' }),
fetch(`${apiBase}/api/v1/config/metamask?chainId=${chainId}`, { cache: 'no-store' }),
])
const networksJson = networkRes.ok ? await networkRes.json() : null
const metamaskJson = metamaskRes.ok ? await metamaskRes.json() : null
const networkList = Array.isArray(networksJson?.chains) ? networksJson.chains : []
const network =
(metamaskJson as MetaMaskChainPayload | null)?.addEthereumChain ||
networkList.find((row: WalletChain) => row.chainIdDecimal === chainId) ||
null
const watchAssets = Array.isArray((metamaskJson as MetaMaskChainPayload | null)?.watchAssets)
? ((metamaskJson as MetaMaskChainPayload).watchAssets ?? [])
: []
let tokens = watchAssets.map((entry) => watchAssetToToken(chainId, entry))
if (tokens.length === 0) {
const listRes = await fetch(`${apiBase}/api/v1/report/token-list?chainId=${chainId}`, { cache: 'no-store' })
const listJson = listRes.ok ? await listRes.json() : null
tokens = (Array.isArray(listJson?.tokens) ? listJson.tokens : []).filter(isTokenListToken)
}
return { chainId, network, tokens, loading: false, error: null }
} catch (e) {
const message = e instanceof Error ? e.message : 'Failed to load chain metadata'
return { chainId, network: null, tokens: [], loading: false, error: message }
}
}
void (async () => {
const initial = WALLET_IMPORT_CHAIN_IDS.map((chainId) => ({
chainId,
network: null,
tokens: [],
loading: true,
error: null,
}))
if (active) setChains(initial)
const loaded = await Promise.all(WALLET_IMPORT_CHAIN_IDS.map((chainId) => loadChain(chainId)))
if (active) setChains(loaded)
})()
return () => {
active = false
}
}, [])
const featuredByChain = useMemo(() => {
return new Map(
chains.map((row) => {
const featuredSymbols = new Set(WALLET_FEATURED_SYMBOLS_BY_CHAIN[row.chainId] ?? [])
const featured = row.tokens.filter((token) => featuredSymbols.has(token.symbol))
return [row.chainId, featured.length > 0 ? featured : row.tokens.slice(0, 8)]
}),
)
}, [chains])
const switchOrAddChain = async (network: WalletChain) => {
const ethereum = resolveEthereum()
if (!ethereum) {
setError('No wallet provider found. Use MetaMask mobile in-app browser or WalletConnect.')
return false
}
try {
await ethereum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: network.chainId }] })
return true
} catch (e) {
const err = e as { code?: number; message?: string }
if (err.code !== 4902) {
setError(err.message || `Failed to switch to ${network.chainName}.`)
return false
}
}
try {
await ethereum.request({ method: 'wallet_addEthereumChain', params: [chainParamsForWallet(network)] })
return true
} catch (e) {
const err = e as { message?: string }
setError(err.message || `Failed to add ${network.chainName}.`)
return false
}
}
const watchTokensSequentially = async (
chainId: number,
tokens: TokenListToken[],
label: string,
startIndex = 0,
priorAdded = 0,
) => {
setError(null)
if (startIndex === 0) {
setStatus(null)
setPendingFlow(null)
}
setProgress(null)
const ethereum = resolveEthereum()
if (!ethereum) {
setError('No wallet provider found. Use MetaMask mobile in-app browser or WalletConnect.')
return
}
const row = chains.find((entry) => entry.chainId === chainId)
if (!row?.network) {
setError(`Network metadata for ${chainLabel(chainId)} is not available yet.`)
return
}
const switched = await switchOrAddChain(row.network)
if (!switched) return
const validTokens = dedupeWalletWatchTokens(tokens.filter(isTokenListToken))
if (validTokens.length === 0) {
setError(`No token metadata is available for ${chainLabel(chainId)}.`)
return
}
const batch = await runWatchAssetBatch(ethereum, validTokens, startIndex, {
mobile: mobileWalletContext,
onProgress: (current, total) => setProgress({ current, total, chainId }),
})
const totalAdded = priorAdded + batch.addedCount
setProgress(null)
if (batch.stoppedEarly) {
setError(batch.errorMessage || 'Token import stopped.')
setPendingFlow(null)
setStatus(`${totalAdded} of ${validTokens.length} ${label} requests were accepted before the flow stopped.`)
return
}
if (batch.nextIndex < validTokens.length) {
setPendingFlow({
chainId,
tokens: validTokens,
label,
nextIndex: batch.nextIndex,
totalAdded,
})
setStatus(
`${totalAdded} of ${validTokens.length} on ${chainLabel(chainId)}. Tap Continue on that chain card for the next mobile prompts.`,
)
return
}
setPendingFlow(null)
setStatus(`${totalAdded} of ${validTokens.length} ${label} token requests were accepted on ${chainLabel(chainId)}.`)
}
const continuePendingFlow = async () => {
if (!pendingFlow) return
const { chainId, tokens, label, nextIndex, totalAdded } = pendingFlow
await watchTokensSequentially(chainId, tokens, label, nextIndex, totalAdded)
}
return (
<div className="mt-6 space-y-4 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
<h2 className="text-lg font-semibold">Multi-chain token import</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Add canonical tokens on other supported networks. Each chain switches your wallet first, then runs sequential
EIP-747 <code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">wallet_watchAsset</code> prompts.
{mobileWalletContext ? ' On mobile, two prompts run per tap — use Continue on the chain card.' : null}
</p>
{pendingFlow ? (
<button
type="button"
onClick={() => void continuePendingFlow()}
className={`rounded bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Continue {chainLabel(pendingFlow.chainId)} ({pendingFlow.totalAdded} added, {pendingFlow.tokens.length - pendingFlow.nextIndex} left)
</button>
) : null}
<div className="grid gap-4 xl:grid-cols-2">
{chains.map((row) => {
const featured = featuredByChain.get(row.chainId) ?? []
return (
<div key={row.chainId} className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">{chainLabel(row.chainId)}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{row.loading
? 'Loading token metadata…'
: row.error
? row.error
: `${row.tokens.length} canonical tokens · ${featured.length} featured`}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={!row.network || row.loading}
onClick={() => row.network && void switchOrAddChain(row.network).then((ok) => ok && setStatus(`Switched to ${chainLabel(row.chainId)}.`))}
className={`rounded bg-gray-600 px-3 py-2 text-xs font-medium text-white hover:bg-gray-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Switch chain
</button>
<button
type="button"
disabled={row.loading || featured.length === 0 || pendingFlow !== null}
onClick={() => void watchTokensSequentially(row.chainId, featured, `featured ${chainLabel(row.chainId)}`)}
className={`rounded bg-primary-600 px-3 py-2 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add featured
</button>
<button
type="button"
disabled={row.loading || row.tokens.length === 0 || pendingFlow !== null}
onClick={() => void watchTokensSequentially(row.chainId, row.tokens, chainLabel(row.chainId))}
className={`rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-800 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add all
</button>
</div>
</div>
{progress?.chainId === row.chainId ? (
<div className="mt-3 text-xs text-gray-600 dark:text-gray-400">
Prompt {progress.current} of {progress.total}
</div>
) : null}
</div>
)
})}
</div>
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
</div>
)
}

View File

@@ -0,0 +1,116 @@
import type { FundedWalletTokenRow } from '@/utils/walletFundedTokenListing'
import {
formatFundedRowBalanceUsd,
formatFundedRowUnitPrice,
} from '@/utils/walletFundedTokenListing'
function formatLiquidityUsd(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return '—'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
type WalletFundedTokenListingProps = {
rows: FundedWalletTokenRow[]
walletAddress?: string | null
loading?: boolean
error?: string | null
}
export default function WalletFundedTokenListing({
rows,
walletAddress,
loading = false,
error = null,
}: WalletFundedTokenListingProps) {
const erc20Count = rows.filter((row) => row.kind === 'erc20').length
const hasNative = rows.some((row) => row.kind === 'native')
return (
<div className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50/40 p-4 dark:border-emerald-900/50 dark:bg-emerald-950/20">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Funded Chain 138 holdings</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Native ETH appears first with the canonical ETH logo. ERC-20 rows use explorer market-batch pricing (unit price,
balance USD, visible liquidity). MetaMask may still show &quot;-&quot; for custom tokens until upstream price
providers list Chain 138 assets.
</p>
{walletAddress ? (
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Wallet {walletAddress.slice(0, 6)}{walletAddress.slice(-4)} · {hasNative ? 'ETH + ' : ''}
{erc20Count} ERC-20{erc20Count === 1 ? '' : 's'} with balance
</p>
) : null}
{loading ? (
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">Loading on-chain balances and market prices</p>
) : null}
{error ? <p className="mt-3 text-sm text-red-700 dark:text-red-300">{error}</p> : null}
{!loading && rows.length === 0 && !error ? (
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
No funded catalog tokens found yet. Connect MetaMask and refresh this list.
</p>
) : null}
{rows.length > 0 ? (
<div className="mt-4 overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead>
<tr className="border-b border-emerald-200/80 text-xs uppercase tracking-wide text-gray-500 dark:border-emerald-900/60 dark:text-gray-400">
<th className="px-2 py-2 font-semibold">Token</th>
<th className="px-2 py-2 font-semibold">Balance</th>
<th className="px-2 py-2 font-semibold">Unit price</th>
<th className="px-2 py-2 font-semibold">Balance USD</th>
<th className="px-2 py-2 font-semibold">Liquidity</th>
<th className="px-2 py-2 font-semibold">MetaMask</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.kind === 'native' ? 'native-eth' : row.address!}
className="border-b border-emerald-100/80 dark:border-emerald-900/40"
>
<td className="px-2 py-3">
<div className="flex items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={row.logoURI}
alt=""
width={32}
height={32}
className="h-8 w-8 rounded-full border border-gray-200 bg-white object-contain dark:border-gray-700"
/>
<div>
<div className="font-semibold text-gray-900 dark:text-white">
{row.symbol}
{row.kind === 'native' ? (
<span className="ml-2 text-xs font-normal text-emerald-700 dark:text-emerald-300">
native
</span>
) : null}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{row.name}</div>
</div>
</div>
</td>
<td className="px-2 py-3 font-medium text-gray-900 dark:text-white">{row.balanceLabel}</td>
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatFundedRowUnitPrice(row)}</td>
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatFundedRowBalanceUsd(row)}</td>
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatLiquidityUsd(row.liquidityUsd)}</td>
<td className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400">
{row.kind === 'native' ? 'Automatic on Chain 138' : 'EIP-747 add'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
import type {
CapabilitiesCatalog,
FetchMetadata,
@@ -29,6 +30,7 @@ import {
toggleWatchlistEntry,
writeWatchlistToStorage,
} from '@/utils/watchlist'
import { MOBILE_WALLET_BUTTON_CLASS } from '@/utils/walletProviderEnv'
interface WalletPageProps {
initialNetworks?: NetworksCatalog | null
@@ -142,6 +144,11 @@ export default function WalletPage(props: WalletPageProps) {
? isWatchlistEntry(watchlistEntries, walletSession.address)
: false
const lpHintAddresses = useMemo(
() => tokenBalances.map((balance) => balance.token_address).filter(Boolean),
[tokenBalances],
)
const loadWalletSnapshot = useCallback(async (address: string) => {
const [infoResponse, transactionsResponse, balancesResponse, transfersResponse] = await Promise.all([
addressesApi.getSafe(138, address),
@@ -310,7 +317,7 @@ export default function WalletPage(props: WalletPageProps) {
type="button"
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"
className={`rounded-lg bg-primary-600 px-4 py-3 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 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
</button>
@@ -323,7 +330,7 @@ export default function WalletPage(props: WalletPageProps) {
? '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"
className={`inline-flex items-center justify-center rounded-lg border border-indigo-300 px-4 py-3 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 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
{connectingWalletConnect ? 'Opening WalletConnect…' : 'WalletConnect'}
</button>
@@ -518,6 +525,15 @@ export default function WalletPage(props: WalletPageProps) {
</div>
</div>
</div>
<div className="mt-6">
<LpPositionPanel
chainId={138}
address={walletSession.address}
hintAddresses={lpHintAddresses}
title="LP positions (Chain 138)"
/>
</div>
</div>
) : null}
</div>

View File

@@ -58,9 +58,8 @@ export const explorerFeaturePages = {
{
title: 'Visual command center',
description: 'Open the interactive topology map for Chain 138, CCIP, Alltra, and adjacent integrations.',
href: '/chain138-command-center.html',
href: '/topology',
label: 'Open command center',
external: true,
},
{
title: 'Routes & liquidity',
@@ -119,9 +118,8 @@ export const explorerFeaturePages = {
{
title: 'Visual command center',
description: 'Use the interactive topology map for contract placement, hub flow, and system context.',
href: '/chain138-command-center.html',
href: '/topology',
label: 'Open command center',
external: true,
},
{
title: 'Wallet tools',
@@ -211,9 +209,8 @@ export const explorerFeaturePages = {
{
title: 'Visual command center',
description: 'Open the graphical deployment and integration topology in a dedicated page.',
href: '/chain138-command-center.html',
href: '/topology',
label: 'Open command center',
external: true,
},
],
},
@@ -227,9 +224,8 @@ export const explorerFeaturePages = {
{
title: 'Visual command center',
description: 'Open the topology map for Chain 138, CCIP, Alltra, OP Stack, and service flows.',
href: '/chain138-command-center.html',
href: '/topology',
label: 'Open command center',
external: true,
},
{
title: 'Bridge monitoring',
@@ -297,9 +293,8 @@ export const explorerFeaturePages = {
{
title: 'Visual command center',
description: 'Open the dedicated interactive topology asset in a new tab.',
href: '/chain138-command-center.html',
href: '/topology',
label: 'Open command center',
external: true,
},
],
},
@@ -337,6 +332,11 @@ export const explorerOperationsSurfaces: ExplorerOperationsSurface[] = [
label: 'WETH',
description: 'Wrapped-asset references and bridge context.',
},
{
href: '/protocols',
label: 'Protocols',
description: 'Official Chain 138 deploy catalog (DODO, Uni, CCIP).',
},
{
href: '/pools',
label: 'Pools',
@@ -357,6 +357,11 @@ export const explorerOperationsSurfaces: ExplorerOperationsSurface[] = [
label: 'Operator',
description: 'Track 4 relay, route, and planner shortcuts.',
},
{
href: '/topology',
label: 'Command center',
description: 'Static Mermaid topology map (Chain 138 hub, CCIP, cW mesh).',
},
]
export const explorerPublicApiLinks = [

View File

@@ -19,6 +19,8 @@ import {
type ContractProfile,
} from '@/services/api/contracts'
import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format'
import { isEnsName, resolveEnsAddress } from '@/utils/ens'
import { resolveAddressFromRegistryEns } from '@/utils/web3IdentityRegistry'
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import PostureBadge from '@/components/common/PostureBadge'
@@ -30,14 +32,28 @@ import {
} from '@/utils/watchlist'
import PageIntro from '@/components/common/PageIntro'
import PaginationControls from '@/components/common/PaginationControls'
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
import SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs'
import { useDetailTabQuery } from '@/utils/useDetailTabQuery'
const ADDRESS_DETAIL_TABS_ID = 'address-detail'
import GruStandardsCard from '@/components/common/GruStandardsCard'
import ContractCodeWorkspace from '@/components/explorer/ContractCodeWorkspace'
import ContractVerificationCallout from '@/components/explorer/ContractVerificationCallout'
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { tokensApi } from '@/services/api/tokens'
import type { TokenListToken } from '@/services/api/config'
import { estimateNativeUsdValue, getNativeAssetDescriptor, getNativeAssetMarketSafe } from '@/services/api/nativeAssetPricing'
import {
buildCanonicalAddressSet,
isCanonicalTokenAddress,
} from '@/utils/canonicalTokens'
import {
estimateTokenBalanceUsd,
formatBalanceUsdLabel,
resolveTokenMarketForBalance,
} from '@/utils/tokenMarket'
function isValidAddress(value: string) {
return /^0x[a-fA-F0-9]{40}$/.test(value)
@@ -71,8 +87,9 @@ export default function AddressDetailPage() {
const [methodInputs, setMethodInputs] = useState<Record<string, string[]>>({})
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
const [nativeAssetPriceUsd, setNativeAssetPriceUsd] = useState<number | undefined>()
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>([])
const [transferHistoricalUsd, setTransferHistoricalUsd] = useState<Record<string, number>>({})
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)
@@ -131,6 +148,19 @@ export default function AddressDetailPage() {
}
}, [chainId, address])
useEffect(() => {
if (!router.isReady || !address || isValidAddressParam) return
if (!isEnsName(address)) return
void (async () => {
const fromRegistry = resolveAddressFromRegistryEns(address)
const resolved = fromRegistry || (await resolveEnsAddress(address))
if (resolved) {
await router.replace(`/addresses/${resolved}`)
}
})()
}, [address, isValidAddressParam, router])
useEffect(() => {
if (!router.isReady || !address) {
setLoading(router.isReady ? false : true)
@@ -202,6 +232,64 @@ export default function AddressDetailPage() {
}
}, [chainId])
useEffect(() => {
let active = true
tokensApi.listForSurface('catalog', chainId).then(({ ok, data }) => {
if (active) {
setCuratedTokens(ok ? data : [])
}
}).catch(() => {
if (active) {
setCuratedTokens([])
}
})
return () => {
active = false
}
}, [chainId])
const canonicalAddressSet = useMemo(() => buildCanonicalAddressSet(curatedTokens), [curatedTokens])
useEffect(() => {
let active = true
const transfersWithTimestamp = tokenTransfers
.filter((transfer) => transfer.timestamp && transfer.token_address)
.slice(0, 8)
if (transfersWithTimestamp.length === 0) {
setTransferHistoricalUsd({})
return () => {
active = false
}
}
void Promise.all(transfersWithTimestamp.map(async (transfer) => {
const key = `${transfer.token_address.toLowerCase()}:${transfer.timestamp}`
const result = await tokenAggregationApi.getPriceAtSafe(
chainId,
transfer.token_address,
transfer.timestamp as string,
)
if (!active || !result.ok || result.data?.priceUsd == null) {
return null
}
return [key, result.data.priceUsd] as const
})).then((rows) => {
if (!active) return
const next: Record<string, number> = {}
for (const row of rows) {
if (row) {
next[row[0]] = row[1]
}
}
setTransferHistoricalUsd(next)
})
return () => {
active = false
}
}, [chainId, tokenTransfers])
const watchlistAddress = normalizeWatchlistAddress(addressInfo?.address || address)
const isSavedToWatchlist = watchlistAddress
? isWatchlistEntry(watchlistEntries, watchlistAddress)
@@ -380,6 +468,9 @@ export default function AddressDetailPage() {
{gruMetadata ? <EntityBadge label="GRU" tone="success" /> : null}
{gruMetadata?.x402Ready ? <PostureBadge label="x402 ready" tone="info" /> : null}
{gruMetadata?.iso20022Ready ? <PostureBadge label="ISO-20022" tone="info" /> : null}
{balance.token_address && !isCanonicalTokenAddress(balance.token_address, canonicalAddressSet) && balance.token_symbol ? (
<EntityBadge label="non-canonical clone" tone="warning" />
) : null}
</div>
{balance.token_name && balance.token_symbol && (
<div className="text-xs text-gray-500 dark:text-gray-400">{balance.token_name}</div>
@@ -390,9 +481,20 @@ export default function AddressDetailPage() {
},
{
header: 'Balance',
accessor: (balance: AddressTokenBalance) => (
formatTokenAmount(balance.value, balance.token_decimals, balance.token_symbol)
),
accessor: (balance: AddressTokenBalance) => {
const market = resolveTokenMarketForBalance(balance, tokenMarkets)
const balanceUsd = market.pricingKind === 'lp-share'
? undefined
: estimateTokenBalanceUsd(balance.value, balance.token_decimals, market.priceUsd)
return (
<div className="space-y-1 text-sm">
<div>{formatTokenAmount(balance.value, balance.token_decimals, balance.token_symbol)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{formatBalanceUsdLabel(market.priceUsd, balanceUsd, { pricingKind: market.pricingKind })}
</div>
</div>
)
},
},
{
header: 'Supply',
@@ -405,12 +507,12 @@ export default function AddressDetailPage() {
{
header: 'Current Price',
accessor: (balance: AddressTokenBalance) => {
const market = tokenMarkets[balance.token_address.toLowerCase()]?.market
const market = resolveTokenMarketForBalance(balance, tokenMarkets)
return (
<div className="space-y-1 text-sm">
<div>{formatUsd(market?.priceUsd)}</div>
<div>{formatUsd(market.priceUsd)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Liq. {formatUsd(market?.liquidityUsd)}
Liq. {formatUsd(market.liquidityUsd)}
</div>
</div>
)
@@ -464,9 +566,34 @@ export default function AddressDetailPage() {
},
{
header: 'Amount',
accessor: (transfer: AddressTokenTransfer) => (
formatTokenAmount(transfer.value, transfer.token_decimals, transfer.token_symbol)
),
accessor: (transfer: AddressTokenTransfer) => {
const pseudoBalance: AddressTokenBalance = {
token_address: transfer.token_address,
token_decimals: transfer.token_decimals,
token_symbol: transfer.token_symbol,
value: transfer.value,
}
const market = resolveTokenMarketForBalance(pseudoBalance, tokenMarkets)
const historicalKey = transfer.timestamp
? `${transfer.token_address.toLowerCase()}:${transfer.timestamp}`
: ''
const historicalPrice = historicalKey ? transferHistoricalUsd[historicalKey] : undefined
const effectivePrice = historicalPrice ?? market.priceUsd
const amountUsd = market.pricingKind === 'lp-share'
? undefined
: estimateTokenBalanceUsd(transfer.value, transfer.token_decimals, effectivePrice)
return (
<div className="space-y-1 text-sm">
<div>{formatTokenAmount(transfer.value, transfer.token_decimals, transfer.token_symbol)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{formatBalanceUsdLabel(effectivePrice, amountUsd, {
pricingKind: market.pricingKind,
atTransfer: historicalPrice != null,
})}
</div>
</div>
)
},
},
{
header: 'When',
@@ -475,12 +602,18 @@ export default function AddressDetailPage() {
{
header: 'Current Price',
accessor: (transfer: AddressTokenTransfer) => {
const market = tokenMarkets[transfer.token_address.toLowerCase()]?.market
const pseudoBalance: AddressTokenBalance = {
token_address: transfer.token_address,
token_decimals: transfer.token_decimals,
token_symbol: transfer.token_symbol,
value: transfer.value,
}
const market = resolveTokenMarketForBalance(pseudoBalance, tokenMarkets)
return (
<div className="space-y-1 text-sm">
<div>{formatUsd(market?.priceUsd)}</div>
<div>{formatUsd(market.priceUsd)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Liq. {formatUsd(market?.liquidityUsd)}
Liq. {formatUsd(market.liquidityUsd)}
</div>
</div>
)
@@ -508,12 +641,21 @@ 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 tabs = useMemo<SectionTab<'contract' | 'balances' | 'transfers' | 'transactions'>[]>(
() => [
...(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 },
],
[addressInfo?.is_contract, tokenBalances.length, tokenTransfers.length, transactions.length],
)
const addressTabIds = useMemo(
() => tabs.map((tab) => tab.id),
[tabs],
)
const defaultAddressTab = addressInfo?.is_contract ? 'contract' : 'balances'
const { activeTab, setActiveTab } = useDetailTabQuery(addressTabIds, defaultAddressTab, address)
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))
@@ -534,7 +676,6 @@ export default function AddressDetailPage() {
setBalancePage(1)
setTransferPage(1)
setTransactionPage(1)
setActiveTab(addressInfo?.is_contract ? 'contract' : 'balances')
}, [address, addressInfo?.is_contract])
return (
@@ -603,7 +744,7 @@ export default function AddressDetailPage() {
<Card title="Address Information" className="mb-6">
<dl className="space-y-4">
<DetailRow label="Address">
<Address address={addressInfo.address} />
<Address address={addressInfo.address} label={addressInfo.label} showENS />
</DetailRow>
{addressInfo.balance && (
<DetailRow label="Coin Balance">
@@ -674,9 +815,20 @@ export default function AddressDetailPage() {
<ContractVerificationCallout address={addressInfo.address} verified={Boolean(addressInfo.is_verified)} />
) : null}
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
<SectionTabs
tabs={tabs}
activeTab={activeTab}
onChange={setActiveTab}
className="mb-6"
idPrefix={ADDRESS_DETAIL_TABS_ID}
ariaLabel="Address details"
/>
{activeTab === 'contract' && addressInfo.is_contract && (
<div
{...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'contract', activeTab)}
className={addressInfo.is_contract ? undefined : 'hidden'}
>
{addressInfo.is_contract ? (
<Card title="Contract Profile" className="mb-6">
<dl className="space-y-4">
<DetailRow label="Interaction Surface">
@@ -889,15 +1041,17 @@ export default function AddressDetailPage() {
)}
</dl>
</Card>
)}
) : null}
{activeTab === 'contract' && addressInfo.is_contract && contractProfile ? (
{contractProfile ? (
<ContractCodeWorkspace address={addressInfo.address} profile={contractProfile} />
) : null}
{activeTab === 'contract' && gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
{gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
</div>
{activeTab === 'balances' ? <Card title="Token Balances" className="mb-6">
<div {...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'balances', activeTab)}>
<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>
@@ -918,9 +1072,11 @@ export default function AddressDetailPage() {
onPageChange={setBalancePage}
label="Token balances"
/>
</Card> : null}
</Card>
</div>
{activeTab === 'transfers' ? <Card title="Recent Token Transfers" className="mb-6">
<div {...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'transfers', activeTab)}>
<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>
@@ -944,9 +1100,11 @@ export default function AddressDetailPage() {
onPageChange={setTransferPage}
label="Token transfers"
/>
</Card> : null}
</Card>
</div>
{activeTab === 'transactions' ? <Card title="Transactions">
<div {...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'transactions', activeTab)}>
<Card title="Transactions">
<Table
columns={transactionColumns}
data={pagedTransactions}
@@ -959,7 +1117,8 @@ export default function AddressDetailPage() {
onPageChange={setTransactionPage}
label="Transactions"
/>
</Card> : null}
</Card>
</div>
</>
)}
</div>

View File

@@ -8,6 +8,8 @@ import { readWatchlistFromStorage } from '@/utils/watchlist'
import PageIntro from '@/components/common/PageIntro'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeTransaction } from '@/services/api/blockscout'
import { isEnsName, resolveEnsAddress } from '@/utils/ens'
import { resolveAddressFromRegistryEns } from '@/utils/web3IdentityRegistry'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
@@ -20,6 +22,11 @@ function normalizeAddress(value: string) {
return /^0x[a-fA-F0-9]{40}$/.test(trimmed) ? trimmed : ''
}
function canOpenAddressQuery(value: string) {
const trimmed = value.trim()
return Boolean(normalizeAddress(trimmed) || isEnsName(trimmed))
}
interface AddressesPageProps {
initialRecentTransactions: Transaction[]
initialLatestBlocks: Array<{ number: number; timestamp: string }>
@@ -127,11 +134,31 @@ export default function AddressesPage({
return addresses
}, [recentTransactions])
const handleOpenAddress = (event: React.FormEvent) => {
const [opening, setOpening] = useState(false)
const handleOpenAddress = async (event: React.FormEvent) => {
event.preventDefault()
const normalized = normalizeAddress(query)
if (!normalized) return
router.push(`/addresses/${normalized}`)
const trimmed = query.trim()
if (!trimmed) return
const normalized = normalizeAddress(trimmed)
if (normalized) {
router.push(`/addresses/${normalized}`)
return
}
if (!isEnsName(trimmed)) return
setOpening(true)
try {
const fromRegistry = resolveAddressFromRegistryEns(trimmed)
const resolved = fromRegistry || (await resolveEnsAddress(trimmed))
if (resolved) {
router.push(`/addresses/${resolved}`)
}
} finally {
setOpening(false)
}
}
return (
@@ -164,19 +191,19 @@ export default function AddressesPage({
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="0x..."
placeholder="0x... or name.eth"
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<button
type="submit"
disabled={!normalizeAddress(query)}
disabled={!canOpenAddressQuery(query) || opening}
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Open address
{opening ? 'Resolving…' : 'Open address'}
</button>
</form>
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
Open any Chain 138 address directly, or jump into your saved watchlist below.
Open any Chain 138 address or resolve a mainnet `.eth` name, then jump into your saved watchlist below.
</p>
</Card>

View File

@@ -12,6 +12,8 @@ import { transactionsApi } from '@/services/api/transactions'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import PaginationControls from '@/components/common/PaginationControls'
import { useListPageQuery } from '@/utils/useListPageQuery'
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
@@ -51,7 +53,7 @@ export default function BlocksPage({
const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
const [loading, setLoading] = useState(initialBlocks.length === 0)
const [page, setPage] = useState(1)
const { page, setPage } = useListPageQuery()
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const loadBlocks = useCallback(async () => {
@@ -206,25 +208,17 @@ export default function BlocksPage({
</div>
)}
{showPagination && (
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={loading || page === 1}
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
>
Previous
</button>
<span className="px-3 py-2 text-sm sm:px-4">Page {page}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={loading || !canGoNext}
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</div>
)}
{showPagination ? (
<PaginationControls
page={page}
onPageChange={setPage}
label="Blocks"
ariaLabel="Blocks pagination"
disabled={loading}
hasNextPage={canGoNext}
className="mt-6"
/>
) : null}
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<Card title="Keep Exploring">

View File

@@ -30,6 +30,11 @@ const docsCards = [
href: '/docs/transaction-review',
description: 'See how the explorer scores transaction evidence quality, decode richness, asset posture, and counterparty traceability.',
},
{
title: 'Official protocol contracts',
href: '/protocols',
description: 'Chain 138 DODO, UniV2/V3, CCIP, Multicall3, and integration addresses from the official deploy registry.',
},
{
title: 'Liquidity access',
href: '/liquidity',
@@ -135,7 +140,7 @@ export default function DocsIndexPage() {
Support: <a href="mailto:support@d-bis.org" className="text-primary-600 hover:underline">support@d-bis.org</a>
</p>
<p>
Command center: <a href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">open visual map </a>
Command center: <Link href="/topology" className="text-primary-600 hover:underline">open visual map </Link>
</p>
</div>
</Card>

View File

@@ -0,0 +1,16 @@
'use client'
import { useRouter } from 'next/router'
import PoolDetailPage from '@/components/pools/PoolDetailPage'
export default function PoolAddressPage() {
const router = useRouter()
const raw = router.query.address
const address = typeof raw === 'string' ? raw : ''
if (!router.isReady) {
return null
}
return <PoolDetailPage poolAddress={address} />
}

View File

@@ -0,0 +1,174 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
import EntityBadge from '@/components/common/EntityBadge'
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
import { fetchOfficialProtocols, type OfficialProtocolRow, type ProtocolVerificationReport } from '@/services/api/officialProtocols'
export default function ProtocolDetailPage() {
const router = useRouter()
const protocolId = typeof router.query.id === 'string' ? router.query.id : ''
const [protocol, setProtocol] = useState<OfficialProtocolRow | null>(null)
const [forbidden, setForbidden] = useState<Array<{ id: string; description: string }>>([])
const [verification, setVerification] = useState<ProtocolVerificationReport | null>(null)
const [registryMeta, setRegistryMeta] = useState<{ schemaVersion?: string; updated?: string; generatedAt?: string }>({})
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!router.isReady || !protocolId) return
let active = true
void fetchOfficialProtocols(protocolId)
.then((report) => {
if (!active) return
setProtocol(report?.protocol ?? report?.protocols?.[0] ?? null)
setForbidden(report?.forbiddenProductionPatterns ?? [])
setVerification(report?.verification ?? null)
setRegistryMeta({
schemaVersion: report?.schemaVersion,
updated: report?.updated,
generatedAt: report?.generatedAt,
})
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [protocolId, router.isReady])
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<OperationsSurfaceNav />
<PageIntro
eyebrow="Official protocol"
title={protocol?.id ?? (protocolId || 'Protocol')}
description={
protocol?.upstreamRepo
? `Deployed from official upstream. ${protocol.contracts.length} on-chain addresses on Chain 138.`
: 'Chain 138 official protocol contract catalog.'
}
actions={[
{ href: '/protocols', label: 'All protocols' },
{ href: '/liquidity', label: 'Liquidity' },
{ href: `/token-aggregation/api/v1/report/official-protocols/${protocolId}`, label: 'JSON' },
]}
/>
{loading ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Loading</p>
</Card>
) : null}
{!loading && !protocol ? (
<Card title="Not found">
<Link href="/protocols" className="text-primary-600 hover:underline">
Back to protocols
</Link>
</Card>
) : null}
{protocol ? (
<div className="space-y-6">
{verification ? (
<Card title="On-chain verification">
<div className="flex flex-wrap gap-2">
<EntityBadge
label={verification.allVerified ? 'all contracts deployed' : 'review required'}
tone={verification.allVerified ? 'success' : 'warning'}
/>
<EntityBadge label={`chain ${verification.chainId}`} tone="neutral" />
{registryMeta.schemaVersion ? (
<EntityBadge label={`registry ${registryMeta.schemaVersion}`} tone="info" className="normal-case tracking-normal" />
) : null}
</div>
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
Live bytecode probe via Chain 138 RPC at {new Date(verification.verifiedAt).toLocaleString()}.
{registryMeta.updated ? ` Registry updated ${registryMeta.updated}.` : ''}
</p>
<div className="mt-4 space-y-2">
{verification.contracts.map((row) => (
<div key={row.address} className="flex flex-wrap items-center gap-2 text-sm">
<EntityBadge
label={row.status.replace('_', ' ')}
tone={row.status === 'verified' ? 'success' : 'warning'}
className="normal-case tracking-normal"
/>
<span className="font-medium text-gray-900 dark:text-white">{row.name}</span>
<span className="text-gray-500 dark:text-gray-400">
code {row.codeSize} bytes
{row.minCodeSize ? ` (min ${row.minCodeSize})` : ''}
</span>
</div>
))}
</div>
</Card>
) : null}
<Card title="Deployment metadata">
<div className="flex flex-wrap gap-2">
{protocol.requiredForProduction ? <EntityBadge label="required for production" tone="success" /> : null}
{protocol.classification ? (
<EntityBadge label={protocol.classification} tone="info" className="normal-case tracking-normal" />
) : null}
</div>
{protocol.upstreamRepo ? (
<p className="mt-3 text-sm">
Upstream:{' '}
<a href={protocol.upstreamRepo} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">
{protocol.upstreamRepo}
</a>
</p>
) : null}
{protocol.deployScripts?.length ? (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Deploy: {protocol.deployScripts.join(', ')}
</p>
) : null}
{protocol.integrationNotes ? (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{protocol.integrationNotes}</p>
) : null}
</Card>
<Card title="Contracts">
<div className="space-y-4">
{protocol.contracts.map((contract) => (
<div key={contract.name} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
<div className="font-semibold text-gray-900 dark:text-white">{contract.name}</div>
<div className="mt-2">
<Address address={contract.address} truncate showCopy />
</div>
<div className="mt-2 flex flex-wrap gap-2">
{contract.inventoryKey ? (
<EntityBadge label={contract.inventoryKey} tone="neutral" className="normal-case tracking-normal" />
) : null}
<Link href={`/addresses/${contract.address}`} className="text-xs text-primary-600 hover:underline">
Explorer address
</Link>
</div>
</div>
))}
</div>
</Card>
{forbidden.length > 0 ? (
<Card title="Forbidden in production">
<ul className="list-disc space-y-2 pl-5 text-sm text-gray-700 dark:text-gray-300">
{forbidden.slice(0, 5).map((row) => (
<li key={row.id}>
<span className="font-medium">{row.id}</span> {row.description}
</li>
))}
</ul>
</Card>
) : null}
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,96 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
import EntityBadge from '@/components/common/EntityBadge'
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
import { fetchOfficialProtocols, type OfficialProtocolsResponse } from '@/services/api/officialProtocols'
export default function ProtocolsIndexPage() {
const [report, setReport] = useState<OfficialProtocolsResponse | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
void fetchOfficialProtocols()
.then((data) => {
if (active) setReport(data)
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [])
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<OperationsSurfaceNav />
<PageIntro
eyebrow="Chain 138 infrastructure"
title="Official protocol contracts"
description="Production DODO, UniV2/V3, CCIP, Multicall3, and integration contracts deployed from official upstream sources — with forbidden pilot patterns called out."
actions={[
{ href: '/liquidity', label: 'Liquidity' },
{ href: '/docs', label: 'Explorer docs' },
{ href: '/token-aggregation/api/v1/report/official-protocols', label: 'JSON API' },
]}
/>
{loading ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Loading protocol registry</p>
</Card>
) : null}
{!loading && !report ? (
<Card title="Registry unavailable">
<p className="text-sm text-gray-600 dark:text-gray-400">
The official protocol registry API is temporarily unavailable.
</p>
</Card>
) : null}
{report ? (
<div className="space-y-6">
<Card title="Production guardrails">
<ul className="list-disc space-y-2 pl-5 text-sm text-gray-700 dark:text-gray-300">
{report.guardrails.slice(0, 4).map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</Card>
<Card title={`Protocols (${report.count})`}>
<div className="grid gap-4 md:grid-cols-2">
{report.protocols.map((protocol) => (
<Link
key={protocol.id}
href={`/protocols/${protocol.id}`}
className="block rounded-2xl border border-gray-200 px-4 py-4 hover:border-primary-300 dark:border-gray-700"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-gray-900 dark:text-white">{protocol.id}</span>
{protocol.requiredForProduction ? (
<EntityBadge label="production" tone="success" />
) : null}
{protocol.classification ? (
<EntityBadge label={protocol.classification} tone="neutral" className="normal-case tracking-normal" />
) : null}
</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{protocol.contracts.length} contract{protocol.contracts.length === 1 ? '' : 's'}
{protocol.upstreamRepo ? ' · official upstream' : ''}
</p>
</Link>
))}
</div>
</Card>
</div>
) : null}
</div>
)
}

View File

@@ -10,16 +10,44 @@ import EntityBadge from '@/components/common/EntityBadge'
import PostureBadge from '@/components/common/PostureBadge'
import {
inferDirectSearchTarget,
inferEnsSearchTarget,
inferTokenSearchTarget,
normalizeExplorerSearchResults,
searchWrappedTransportTokens,
shouldDeferChain138AddressJump,
suggestCuratedTokens,
type RawExplorerSearchItem,
type WrappedTransportRegistryRow,
} 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'
import {
externalChainExplorerUrl,
fetchCwRegistry,
flattenCwRegistry,
type WrappedTransportTokenRow,
} from '@/services/api/cwRegistry'
import {
fetchTokenMappingPairs,
getMeshDestinationChainIds,
resolveMeshFromHub,
resolveTokenMapping,
type TokenMappingPair,
} from '@/services/api/tokenMapping'
import {
buildChainNameMap,
buildMeshCounterpartRows,
inferMeshSeed,
type MeshCounterpartRow,
} from '@/utils/meshCounterparts'
import { fetchPolygonMapper, type PolygonMapperResponse } from '@/services/api/polygonMapper'
import { resolveEnsAddress, isEnsName } from '@/utils/ens'
import { resolveAddressFromRegistryEns } from '@/utils/web3IdentityRegistry'
import { searchCuratedPools, type PoolSearchRow } from '@/utils/poolSearch'
import { fetchPoolRegistry, type CuratedPoolRegistryEntry } from '@/services/api/liquidityPositions'
type SearchFilterMode = 'all' | 'gru' | 'x402' | 'wrapped'
@@ -55,6 +83,12 @@ export default function SearchPage({
const [savedQueries, setSavedQueries] = useState<string[]>([])
const [filterMode, setFilterMode] = useState<SearchFilterMode>('all')
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
const [wrappedRegistryRows, setWrappedRegistryRows] = useState<WrappedTransportTokenRow[]>([])
const [mappingPairs, setMappingPairs] = useState<TokenMappingPair[]>([])
const [meshRows, setMeshRows] = useState<MeshCounterpartRow[]>([])
const [meshLoading, setMeshLoading] = useState(false)
const [polygonMapper, setPolygonMapper] = useState<PolygonMapperResponse | null>(null)
const [poolRegistryRows, setPoolRegistryRows] = useState<CuratedPoolRegistryEntry[]>([])
const runSearch = async (rawQuery: string) => {
const trimmedQuery = rawQuery.trim()
@@ -109,6 +143,49 @@ export default function SearchPage({
}
}, [initialCuratedTokens])
useEffect(() => {
let active = true
void fetchCwRegistry()
.then((chains) => {
if (!active) return
setWrappedRegistryRows(flattenCwRegistry(chains))
})
.catch(() => {
if (active) setWrappedRegistryRows([])
})
return () => {
active = false
}
}, [])
useEffect(() => {
let active = true
void fetchPoolRegistry(138)
.then((report) => {
if (active) setPoolRegistryRows(report?.pools ?? [])
})
.catch(() => {
if (active) setPoolRegistryRows([])
})
return () => {
active = false
}
}, [])
useEffect(() => {
let active = true
void fetchTokenMappingPairs()
.then((pairs) => {
if (active) setMappingPairs(pairs)
})
.catch(() => {
if (active) setMappingPairs([])
})
return () => {
active = false
}
}, [])
useEffect(() => {
if (typeof window === 'undefined') return
try {
@@ -150,14 +227,34 @@ export default function SearchPage({
return next
})
const wrappedMatches = searchWrappedTransportTokens(trimmedQuery, wrappedRegistryRows)
const meshSeed = inferMeshSeed(trimmedQuery, curatedTokens, wrappedRegistryRows)
const tokenTarget = inferTokenSearchTarget(trimmedQuery, curatedTokens)
if (tokenTarget) {
if (tokenTarget && !meshSeed) {
void router.push(tokenTarget.href)
return
}
if (wrappedMatches.length === 1 && wrappedMatches[0].chainId === 138) {
void router.push(`/tokens/${wrappedMatches[0].address}`)
return
}
const ensRegistryTarget = inferEnsSearchTarget(trimmedQuery)
if (ensRegistryTarget) {
void router.push(ensRegistryTarget.href)
return
}
const resolvedEns = await resolveEnsAddress(trimmedQuery)
if (resolvedEns) {
void router.push(`/addresses/${resolvedEns}`)
return
}
const directTarget = inferDirectSearchTarget(trimmedQuery)
if (directTarget) {
if (directTarget && !shouldDeferChain138AddressJump(trimmedQuery, wrappedMatches)) {
void router.push(directTarget.href)
return
}
@@ -175,7 +272,25 @@ export default function SearchPage({
const trimmedQuery = query.trim()
const tokenTarget = inferTokenSearchTarget(trimmedQuery, curatedTokens)
const meshSeed = useMemo(
() => inferMeshSeed(trimmedQuery, curatedTokens, wrappedRegistryRows),
[curatedTokens, trimmedQuery, wrappedRegistryRows],
)
const chainNameById = useMemo(() => buildChainNameMap(wrappedRegistryRows), [wrappedRegistryRows])
const poolMatches = useMemo(
() => searchCuratedPools(trimmedQuery, poolRegistryRows),
[poolRegistryRows, trimmedQuery],
)
const wrappedMatches = useMemo(
() => searchWrappedTransportTokens(trimmedQuery, wrappedRegistryRows),
[trimmedQuery, wrappedRegistryRows],
)
const filteredWrappedMatches = useMemo(() => {
if (filterMode === 'all' || filterMode === 'wrapped') return wrappedMatches
return []
}, [filterMode, wrappedMatches])
const directTarget = inferDirectSearchTarget(trimmedQuery)
const deferAddressJump = shouldDeferChain138AddressJump(trimmedQuery, wrappedMatches)
const results = useMemo(
() => normalizeExplorerSearchResults(trimmedQuery, rawResults, curatedTokens),
[curatedTokens, rawResults, trimmedQuery],
@@ -197,6 +312,15 @@ export default function SearchPage({
blocks: filteredResults.filter((result) => result.type === 'block'),
other: filteredResults.filter((result) => !['token', 'address', 'transaction', 'block'].includes(result.type)),
}), [filteredResults])
const hasSupplementalMatches = useMemo(
() =>
meshRows.length > 0 ||
poolMatches.length > 0 ||
filteredWrappedMatches.length > 0 ||
curatedSuggestions.length > 0 ||
Boolean(meshSeed),
[curatedSuggestions.length, filteredWrappedMatches.length, meshRows.length, meshSeed, poolMatches.length],
)
const resultSections = [
{ label: 'Tokens', items: groupedResults.tokens },
{ label: 'Addresses', items: groupedResults.addresses },
@@ -205,6 +329,68 @@ export default function SearchPage({
{ label: 'Other', items: groupedResults.other },
]
useEffect(() => {
if (!meshSeed || !hasSearched) {
setMeshRows([])
setMeshLoading(false)
return
}
let active = true
setMeshLoading(true)
void (async () => {
let hubAddress = meshSeed.hubAddress
if (meshSeed.needsHubResolve && meshSeed.sourceChainId != null && meshSeed.sourceAddress) {
const toHub = await resolveTokenMapping(meshSeed.sourceChainId, meshSeed.hubChainId, meshSeed.sourceAddress)
hubAddress = toHub?.addressOnTarget ?? hubAddress
}
if (!hubAddress || !/^0x[a-fA-F0-9]{40}$/.test(hubAddress)) {
if (active) {
setMeshRows([])
setMeshLoading(false)
}
return
}
const destinationChainIds = getMeshDestinationChainIds(mappingPairs, meshSeed.hubChainId)
const resolvedByChain = await resolveMeshFromHub(meshSeed.hubChainId, hubAddress, destinationChainIds)
const rows = buildMeshCounterpartRows(meshSeed, resolvedByChain, wrappedRegistryRows, chainNameById)
if (active) {
setMeshRows(rows)
setMeshLoading(false)
}
})().catch(() => {
if (active) {
setMeshRows([])
setMeshLoading(false)
}
})
return () => {
active = false
}
}, [chainNameById, hasSearched, mappingPairs, meshSeed, wrappedRegistryRows])
useEffect(() => {
if (!meshSeed || !hasSearched) {
setPolygonMapper(null)
return
}
let active = true
void fetchPolygonMapper(trimmedQuery)
.then((report) => {
if (active) setPolygonMapper(report)
})
.catch(() => {
if (active) setPolygonMapper(null)
})
return () => {
active = false
}
}, [hasSearched, meshSeed, trimmedQuery])
useEffect(() => {
let active = true
@@ -233,11 +419,13 @@ export default function SearchPage({
title="Search"
description={
mode === 'guided'
? 'Search by address, transaction hash, block number, or token symbol. Direct identifiers can jump straight into detail pages, while broader terms fall back to indexed search.'
: 'Search address, tx hash, block, or token symbol. Direct identifiers jump straight to detail pages.'
? 'Search by address, transaction hash, block number, token symbol, or cW*/c* wrapped transport symbols. Mesh counterparts resolve across chains via token-mapping; Chain 138 uses indexed search.'
: 'Search tx / addr / block / token / mesh counterparts (token-mapping + cW registry).'
}
actions={[
{ href: '/tokens', label: 'Token shortcuts' },
{ href: '/protocols', label: 'Protocol contracts' },
{ href: '/pools', label: 'Pool registry' },
{ href: '/addresses', label: 'Browse addresses' },
{ href: '/watchlist', label: 'Open watchlist' },
]}
@@ -264,19 +452,25 @@ export default function SearchPage({
{!loading && error && (
<Card className="mb-6">
<p className="text-sm text-gray-600 dark:text-gray-400">{error}</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/tokens" className="text-primary-600 hover:underline">
Try token shortcuts
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Browse addresses
</Link>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
{meshRows.length > 0 || poolMatches.length > 0 || filteredWrappedMatches.length > 0
? 'Indexed Blockscout search is temporarily unavailable. Mesh, pool registry, and wrapped-token matches below are still available.'
: error}
</p>
{meshRows.length === 0 && poolMatches.length === 0 && filteredWrappedMatches.length === 0 ? (
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/tokens" className="text-primary-600 hover:underline">
Try token shortcuts
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Browse addresses
</Link>
</div>
) : null}
</Card>
)}
{!loading && tokenTarget && (
{!loading && tokenTarget && !meshSeed && (
<Card className="mb-6" title="Direct Token Match">
<p className="text-sm text-gray-600 dark:text-gray-400">
{mode === 'guided'
@@ -291,7 +485,213 @@ export default function SearchPage({
</Card>
)}
{!loading && !tokenTarget && directTarget && (
{!loading && !tokenTarget && directTarget && deferAddressJump && (
<Card title="Cross-network wrapped token match">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
This address is a wrapped transport token on another network not a Chain 138 address. Open it on the native explorer for that chain.
</p>
<div className="space-y-3">
{wrappedMatches
.filter((row) => row.matchReason === 'exact address')
.map((row) => {
const href = externalChainExplorerUrl(row.chainId, row.address)
return (
<div key={`${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={row.symbol} tone="info" />
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
<EntityBadge label={row.chainName} tone="warning" />
<EntityBadge label={row.matchReason} tone="success" className="normal-case tracking-normal" />
</div>
<div className="mt-2">
<Address address={row.address} truncate showCopy />
</div>
{href ? (
<a
href={href}
target={row.chainId === 138 ? undefined : '_blank'}
rel={row.chainId === 138 ? undefined : 'noopener noreferrer'}
className="mt-2 inline-block text-sm font-medium text-primary-600 hover:underline"
>
{row.chainId === 138 ? 'Open on Chain 138 →' : `Open on ${row.chainName} explorer →`}
</a>
) : null}
</div>
)
})}
</div>
</Card>
)}
{!loading && poolMatches.length > 0 && hasSearched && (
<Card title="Curated liquidity pools">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Matches from the DODO PMM + UniV2 pool registry on Chain 138. LP receipt tokens stay chain-local bridge
underlying c*/cW* instead.
</p>
<div className="space-y-3">
{poolMatches.slice(0, 12).map((row) => (
<Link
key={`${row.poolAddress}-${row.matchReason}`}
href={`/pools/${row.poolAddress}`}
className="block rounded-xl border border-gray-200 px-4 py-3 hover:border-primary-300 dark:border-gray-700"
>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={row.pairLabel} tone="info" />
<EntityBadge label={row.venue.replace('_', ' ')} tone="neutral" />
<EntityBadge label={row.matchReason} tone="success" className="normal-case tracking-normal" />
</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Pool <Address address={row.poolAddress} truncate showCopy={false} />
</div>
</Link>
))}
</div>
</Card>
)}
{!loading && meshSeed && (meshLoading || meshRows.length > 0) && (
<Card title="Mesh counterparts">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
{mode === 'guided'
? `Resolved ${meshSeed.hubSymbol} hub on Chain 138 and mapped ${meshSeed.wrappedSymbol} addresses across configured transport pairs via token-mapping.`
: `${meshSeed.hubSymbol}${meshSeed.wrappedSymbol} mesh (${meshSeed.matchReason}).`}
</p>
{polygonMapper?.officialPolygonTokenList?.upstreamRepo ? (
<p className="mb-4 text-xs text-gray-500 dark:text-gray-400">
Polygon (137) rows align with{' '}
<a
href={polygonMapper.officialPolygonTokenList.submissionDoc || polygonMapper.officialPolygonTokenList.upstreamRepo}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:underline"
>
official Polygon Token Mapper
</a>
{polygonMapper.match?.polygonAddress
? ` — matched ${polygonMapper.match.wrappedSymbol} at ${polygonMapper.match.polygonAddress.slice(0, 10)}`
: ''}
.
</p>
) : null}
{meshLoading ? (
<p className="text-sm text-gray-500 dark:text-gray-400">Resolving mesh addresses</p>
) : (
<div className="space-y-3">
{meshRows.map((row) => {
const href = externalChainExplorerUrl(row.chainId, row.address)
const content = (
<>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={row.symbol} tone="info" />
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
<EntityBadge label={row.chainName} tone="warning" />
<EntityBadge
label={row.role === 'hub' ? 'hub (138)' : row.mappedVia === 'token-mapping' ? 'mapped' : 'registry'}
tone={row.role === 'hub' ? 'success' : 'info'}
className="normal-case tracking-normal"
/>
{row.chainId === 137 && polygonMapper ? (
<EntityBadge label="Polygon mapper" tone="warning" className="normal-case tracking-normal" />
) : null}
</div>
<div className="mt-2">
<Address address={row.address} truncate showCopy />
</div>
</>
)
if (!href) {
return (
<div key={`mesh-${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
{content}
</div>
)
}
if (row.chainId === 138) {
return (
<Link
key={`mesh-${row.chainId}-${row.address}`}
href={href}
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
>
{content}
</Link>
)
}
return (
<a
key={`mesh-${row.chainId}-${row.address}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
>
{content}
</a>
)
})}
</div>
)}
</Card>
)}
{!loading && filteredWrappedMatches.length > 0 && meshRows.length === 0 && !meshLoading && (
<Card title="Wrapped transport tokens (cross-network)">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Matches from the live cW* registry across public networks. Chain 138 tokens open here; other chains link to native explorers.
</p>
<div className="space-y-3">
{filteredWrappedMatches.slice(0, 24).map((row) => {
const href = externalChainExplorerUrl(row.chainId, row.address)
const content = (
<>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={row.symbol} tone="info" />
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
<EntityBadge label={row.chainName} tone="warning" />
<EntityBadge label="cW registry" tone="success" />
<EntityBadge label={row.matchReason} tone="info" className="normal-case tracking-normal" />
</div>
<div className="mt-2">
<Address address={row.address} truncate showCopy />
</div>
</>
)
if (!href) {
return (
<div key={`${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
{content}
</div>
)
}
if (row.chainId === 138) {
return (
<Link
key={`${row.chainId}-${row.address}`}
href={href}
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
>
{content}
</Link>
)
}
return (
<a
key={`${row.chainId}-${row.address}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
>
{content}
</a>
)
})}
</div>
</Card>
)}
{!loading && !tokenTarget && directTarget && !deferAddressJump && (
<Card className="mb-6" title="Direct Match">
<p className="text-sm text-gray-600 dark:text-gray-400">
{mode === 'guided'
@@ -325,7 +725,7 @@ export default function SearchPage({
)}
{results.length > 0 && (
<Card title="Search Results">
<Card title="Indexed search results">
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-2">
{([
@@ -433,7 +833,17 @@ export default function SearchPage({
</Card>
)}
{!loading && hasSearched && !error && filteredResults.length === 0 && (
{!loading && hasSearched && hasSupplementalMatches && filteredResults.length === 0 && !error ? (
<Card className="mb-6" title="Registry matches">
<p className="text-sm text-gray-600 dark:text-gray-400">
Blockscout indexed search returned no hits for{' '}
<span className="font-medium text-gray-900 dark:text-white">{trimmedQuery}</span>, but curated mesh,
pool, or wrapped-token registry matches are shown below.
</p>
</Card>
) : null}
{!loading && hasSearched && !error && filteredResults.length === 0 && !hasSupplementalMatches && (
<Card title="No Results Found">
<p className="text-sm text-gray-600 dark:text-gray-400">
No explorer results matched <span className="font-medium text-gray-900 dark:text-white">{trimmedQuery}</span>
@@ -496,6 +906,23 @@ export const getServerSideProps: GetServerSideProps<SearchPageProps> = async (co
const initialQuery = typeof context.query.q === 'string' ? context.query.q.trim() : ''
const { tokens: initialCuratedTokens } = await fetchTokenListForSurface('catalog', 138)
const tokenTarget = initialQuery ? inferTokenSearchTarget(initialQuery, initialCuratedTokens) : null
if (tokenTarget) {
return { redirect: { destination: tokenTarget.href, permanent: false } }
}
const directTarget = initialQuery ? inferDirectSearchTarget(initialQuery) : null
if (directTarget?.kind === 'address') {
return { redirect: { destination: directTarget.href, permanent: false } }
}
if (initialQuery && isEnsName(initialQuery)) {
const resolved = await resolveEnsAddress(initialQuery)
if (resolved) {
return { redirect: { destination: `/addresses/${resolved}`, permanent: false } }
}
}
const shouldFetchSearch =
Boolean(initialQuery) &&
!inferTokenSearchTarget(initialQuery, initialCuratedTokens) &&

View File

@@ -15,7 +15,10 @@ 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 SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs'
import { useDetailTabQuery } from '@/utils/useDetailTabQuery'
const TOKEN_DETAIL_TABS_ID = 'token-detail'
import { formatTokenAmount, formatTimestamp } from '@/utils/format'
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
@@ -57,7 +60,6 @@ export default function TokenDetailPage() {
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)
@@ -194,13 +196,21 @@ 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 tabs = useMemo<SectionTab<'intelligence' | 'standards' | 'holders' | 'transfers' | 'liquidity'>[]>(
() => [
{ 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 },
],
[gruExplorerMetadata, gruProfile, holders.length, pools.length, transfers.length],
)
const tokenTabIds = useMemo(
() => tabs.map((tab) => tab.id),
[tabs],
)
const { activeTab, setActiveTab } = useDetailTabQuery(tokenTabIds, 'intelligence', address)
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))
@@ -218,7 +228,6 @@ export default function TokenDetailPage() {
)
useEffect(() => {
setActiveTab('intelligence')
setHolderPage(1)
setTransferPage(1)
setPoolPage(1)
@@ -407,9 +416,17 @@ export default function TokenDetailPage() {
<TokenSigningSurfaceCard address={token.address} contractProfile={contractProfile} />
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
<SectionTabs
tabs={tabs}
activeTab={activeTab}
onChange={setActiveTab}
className="mb-6"
idPrefix={TOKEN_DETAIL_TABS_ID}
ariaLabel="Token details"
/>
{activeTab === 'intelligence' ? <Card title="Token Intelligence">
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'intelligence', activeTab)}>
<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>
@@ -454,11 +471,13 @@ export default function TokenDetailPage() {
</div>
</div>
</div>
</Card> : null}
</Card>
</div>
{activeTab === 'standards' && gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'standards', activeTab)}>
{gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
{activeTab === 'standards' && gruExplorerMetadata ? (
{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">
@@ -504,7 +523,7 @@ export default function TokenDetailPage() {
</Card>
) : null}
{activeTab === 'standards' && gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? (
{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">
@@ -536,8 +555,10 @@ export default function TokenDetailPage() {
</div>
</Card>
) : null}
</div>
{activeTab === 'holders' ? <Card title="Top Holders">
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'holders', activeTab)}>
<Card title="Top Holders">
<Table
layout="tabular"
columns={holderColumns}
@@ -546,9 +567,11 @@ export default function TokenDetailPage() {
keyExtractor={(holder) => holder.address}
/>
<PaginationControls page={holderPage} pageCount={holderPageCount} onPageChange={setHolderPage} label="Holders" />
</Card> : null}
</Card>
</div>
{activeTab === 'transfers' ? <Card title="Recent Transfers">
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'transfers', activeTab)}>
<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>
@@ -569,9 +592,11 @@ export default function TokenDetailPage() {
keyExtractor={(transfer) => `${transfer.transaction_hash}-${transfer.value}-${transfer.from_address}`}
/>
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Transfers" />
</Card> : null}
</Card>
</div>
{activeTab === 'liquidity' ? <Card title="Related Liquidity">
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'liquidity', activeTab)}>
<Card title="Related Liquidity">
<Table
columns={poolColumns}
data={pagedPools}
@@ -579,7 +604,8 @@ export default function TokenDetailPage() {
keyExtractor={(pool) => pool.address}
/>
<PaginationControls page={poolPage} pageCount={poolPageCount} onPageChange={setPoolPage} label="Pools" />
</Card> : null}
</Card>
</div>
</div>
)}
</div>

View File

@@ -17,7 +17,10 @@ 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 SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs'
import { useDetailTabQuery } from '@/utils/useDetailTabQuery'
const TRANSACTION_DETAIL_TABS_ID = 'transaction-detail'
import { getGruCatalogPosture } from '@/services/api/gruCatalog'
import { assessTransactionCompliance } from '@/utils/transactionCompliance'
import MainnetAttestationPanel from '@/components/checkpoint/MainnetAttestationPanel'
@@ -79,7 +82,6 @@ export default function TransactionDetailPage() {
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
@@ -354,13 +356,21 @@ 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 tabs = useMemo<SectionTab<'evidence' | 'details' | 'transfers' | 'internal' | 'raw'>[]>(
() => [
{ 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' }] : []),
],
[internalCallCount, tokenTransferCount, transaction?.input_data],
)
const transactionTabIds = useMemo(
() => tabs.map((tab) => tab.id),
[tabs],
)
const { activeTab, setActiveTab } = useDetailTabQuery(transactionTabIds, 'evidence', hash)
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(
@@ -373,7 +383,6 @@ export default function TransactionDetailPage() {
)
useEffect(() => {
setActiveTab('evidence')
setTransferPage(1)
setInternalPage(1)
}, [hash])
@@ -513,9 +522,17 @@ export default function TransactionDetailPage() {
/>
) : null}
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
<SectionTabs
tabs={tabs}
activeTab={activeTab}
onChange={setActiveTab}
className="mb-6"
idPrefix={TRANSACTION_DETAIL_TABS_ID}
ariaLabel="Transaction details"
/>
{activeTab === 'evidence' && complianceAssessment ? (
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'evidence', activeTab)}>
{complianceAssessment ? (
<Card title="Transaction Evidence Matrix">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
@@ -541,9 +558,17 @@ export default function TransactionDetailPage() {
</div>
</div>
</Card>
) : null}
) : (
<Card title="Transaction Evidence Matrix">
<p className="text-sm text-gray-600 dark:text-gray-400">
Compliance evidence is unavailable for this transaction.
</p>
</Card>
)}
</div>
{activeTab === 'details' ? <Card title="Transaction Information">
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'details', activeTab)}>
<Card title="Transaction Information">
<dl className="space-y-4">
<DetailRow label="Hash">
<Address address={transaction.hash} />
@@ -607,10 +632,10 @@ export default function TransactionDetailPage() {
</DetailRow>
)}
</dl>
</Card> : null}
</Card>
{activeTab === 'details' && transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (
<Card title="Decoded Input">
{transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (
<Card title="Decoded Input" className="mt-6">
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
{transaction.decoded_input.method_call || transaction.decoded_input.method_id || 'Decoded call'}
@@ -637,8 +662,10 @@ export default function TransactionDetailPage() {
</div>
</Card>
)}
</div>
{activeTab === 'transfers' ? <Card title="Token Transfers">
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'transfers', activeTab)}>
<Card title="Token Transfers">
<Table
columns={tokenTransferColumns}
data={pagedTokenTransfers}
@@ -646,9 +673,11 @@ export default function TransactionDetailPage() {
keyExtractor={(transfer) => `${transfer.token_address}-${transfer.from_address}-${transfer.to_address}-${transfer.amount}`}
/>
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Token transfers" />
</Card> : null}
</Card>
</div>
{activeTab === 'internal' ? <Card title="Internal Transactions">
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'internal', activeTab)}>
<Card title="Internal Transactions">
<Table
columns={internalCallColumns}
data={pagedInternalCalls}
@@ -656,15 +685,18 @@ export default function TransactionDetailPage() {
keyExtractor={(call) => `${call.from_address}-${call.to_address || call.contract_address || 'unknown'}-${call.value}-${call.type || 'call'}`}
/>
<PaginationControls page={internalPage} pageCount={internalPageCount} onPageChange={setInternalPage} label="Internal calls" />
</Card> : null}
</Card>
</div>
{activeTab === 'raw' && transaction.input_data && (
{transaction.input_data ? (
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'raw', activeTab)}>
<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}
</pre>
</Card>
)}
</div>
) : null}
</div>
)}
</div>

View File

@@ -11,6 +11,8 @@ import { normalizeTransaction } from '@/services/api/blockscout'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import PaginationControls from '@/components/common/PaginationControls'
import { useListPageQuery } from '@/utils/useListPageQuery'
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
@@ -51,7 +53,7 @@ export default function TransactionsPage({
const pageSize = 20
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
const [loading, setLoading] = useState(initialTransactions.length === 0)
const [page, setPage] = useState(1)
const { page, setPage } = useListPageQuery()
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const activityContext = useMemo(
() =>
@@ -252,25 +254,17 @@ export default function TransactionsPage({
/>
)}
{showPagination && (
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={loading || page === 1}
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
>
Previous
</button>
<span className="px-3 py-2 text-sm sm:px-4">Page {page}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={loading || !canGoNext}
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</div>
)}
{showPagination ? (
<PaginationControls
page={page}
onPageChange={setPage}
label="Transactions"
ariaLabel="Transactions pagination"
disabled={loading}
hasNextPage={canGoNext}
className="mt-6"
/>
) : null}
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<Card title="Next Steps">

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/v1/report/token-list?chainId=138').catch(() => null),
fetchPublicJsonWithMeta<TokenListCatalog>('/api/v1/report/token-list?chainId=138&wallet=1').catch(() => null),
fetchPublicJsonWithMeta<CapabilitiesCatalog>('/api/config/capabilities').catch(() => null),
])

View File

@@ -60,6 +60,7 @@ export interface AddressTokenBalance {
value: string
holder_count?: number
total_supply?: string
exchange_rate?: string | number | null
}
export interface AddressTokenTransfer {

View File

@@ -67,6 +67,7 @@ export interface BlockscoutTokenRef {
type?: string | null
total_supply?: string | null
holders?: StringLike
exchange_rate?: StringLike
}
export interface BlockscoutTokenTransfer {
@@ -310,6 +311,7 @@ export function normalizeAddressTokenBalance(raw: {
value: raw.value || '0',
holder_count: raw.token?.holders != null ? toNumber(raw.token.holders) : undefined,
total_supply: raw.token?.total_supply || undefined,
exchange_rate: raw.token?.exchange_rate ?? null,
}
}

View File

@@ -0,0 +1,89 @@
import { getTokenAggregationApiBase } from '@/services/api/tokenAggregation'
export interface WrappedTransportTokenRow {
chainId: number
chainName: string
symbol: string
address: string
assetClass?: string
familyKey?: string
}
export interface CwRegistryChainRow {
chainId: number
chainIdText?: string
name: string
tokens: Array<{
symbol: string
address: string
assetClass?: string
familyKey?: string
}>
}
export interface CwRegistryResponse {
generatedAt: string
source: string
complete: boolean
chains: CwRegistryChainRow[]
}
export function externalChainExplorerUrl(chainId: number, address: string): string | undefined {
const normalized = address.trim()
if (!/^0x[a-fA-F0-9]{40}$/.test(normalized)) return undefined
switch (chainId) {
case 138:
return `/tokens/${normalized}`
case 1:
return `https://etherscan.io/token/${normalized}`
case 56:
return `https://bscscan.com/token/${normalized}`
case 137:
return `https://polygonscan.com/token/${normalized}`
case 100:
return `https://gnosisscan.io/token/${normalized}`
case 10:
return `https://optimistic.etherscan.io/token/${normalized}`
case 42161:
return `https://arbiscan.io/token/${normalized}`
case 8453:
return `https://basescan.org/token/${normalized}`
case 43114:
return `https://snowtrace.io/token/${normalized}`
case 25:
return `https://cronoscan.com/token/${normalized}`
case 42220:
return `https://celoscan.io/token/${normalized}`
case 1111:
return `https://scan.wemix.com/token/${normalized}`
case 651940:
return `https://alltra.global/address/${normalized}`
default:
return undefined
}
}
export async function fetchCwRegistry(): Promise<CwRegistryChainRow[]> {
const response = await fetch(`${getTokenAggregationApiBase()}/report/cw-registry`, { cache: 'no-store' })
if (!response.ok) return []
const body = (await response.json()) as CwRegistryResponse
return Array.isArray(body.chains) ? body.chains : []
}
export function flattenCwRegistry(chains: CwRegistryChainRow[]): WrappedTransportTokenRow[] {
const rows: WrappedTransportTokenRow[] = []
for (const chain of chains) {
for (const token of chain.tokens ?? []) {
if (!token.address?.startsWith('0x')) continue
rows.push({
chainId: chain.chainId,
chainName: chain.name || `Chain ${chain.chainId}`,
symbol: token.symbol,
address: token.address,
assetClass: token.assetClass,
familyKey: token.familyKey,
})
}
}
return rows
}

View File

@@ -0,0 +1,88 @@
import { getTokenAggregationApiBase } from '@/services/api/tokenAggregation'
export interface CuratedPoolRegistryEntry {
chainId: number
chainName: string
poolAddress: string
lpTokenAddress: string
lpTokenType: 'dodo_dvm' | 'univ2_pair'
venue: 'dodo_pmm' | 'uniswap_v2'
baseSymbol: string
quoteSymbol: string
baseAddress: string
quoteAddress: string
status?: string
role?: string
publicRoutingEnabled?: boolean
feeBps?: number
totalLiquidityUsd?: number
reserve0?: string
reserve1?: string
reserve0Usd?: number
reserve1Usd?: number
liquiditySource?: string
liquidityAsOf?: string
integrationStack?: string
}
export interface PoolRegistryResponse {
generatedAt: string
source: string
complete: boolean
count: number
pools: CuratedPoolRegistryEntry[]
}
export interface LpPositionRow {
chainId: number
poolAddress: string
lpTokenAddress: string
lpTokenType: 'dodo_dvm' | 'univ2_pair'
venue: string
pairLabel: string
shareBalanceRaw: string
shareBalanceUnits: string
shareOfPool: number
estimatedUsd: number | null
baseSymbol: string
quoteSymbol: string
status: 'active' | 'zero'
}
export interface LpPositionsResponse {
generatedAt: string
chainId: number
address: string
rpcUsed: boolean
poolsScanned: number
activePositions: number
totalEstimatedUsd: number | null
positions: LpPositionRow[]
notes: string[]
}
const base = () => getTokenAggregationApiBase()
export async function fetchPoolRegistry(chainId?: number): Promise<PoolRegistryResponse | null> {
const query = chainId ? `?chainId=${chainId}` : ''
const response = await fetch(`${base()}/report/pool-registry${query}`, { cache: 'no-store' })
if (!response.ok) return null
return (await response.json()) as PoolRegistryResponse
}
export async function fetchLpPositions(params: {
chainId: number
address: string
hintAddresses?: string[]
}): Promise<LpPositionsResponse | null> {
const search = new URLSearchParams({
chainId: String(params.chainId),
address: params.address,
})
if (params.hintAddresses?.length) {
search.set('hintAddresses', params.hintAddresses.join(','))
}
const response = await fetch(`${base()}/report/lp-positions?${search.toString()}`, { cache: 'no-store' })
if (!response.ok) return null
return (await response.json()) as LpPositionsResponse
}

View File

@@ -20,6 +20,10 @@ export async function getNativeAssetMarketSafe(
chainId: number,
): Promise<{ ok: boolean; data: TokenAggregationTokenSnapshot | null }> {
const descriptor = getNativeAssetDescriptor(chainId)
const batch = await tokenAggregationApi.getTokensByAddressSafe(chainId, [descriptor.pricingAddress])
if (batch.ok && batch.data[0]) {
return { ok: true, data: batch.data[0] }
}
return tokenAggregationApi.getTokenSafe(chainId, descriptor.pricingAddress)
}

View File

@@ -0,0 +1,68 @@
import { getTokenAggregationApiBase } from '@/services/api/tokenAggregation'
export interface OfficialProtocolContractRow {
name: string
address: string
inventoryKey?: string
minCodeSize?: number
notes?: string
}
export type ContractVerificationStatus = 'verified' | 'below_min' | 'no_code' | 'error'
export interface ContractVerificationRow {
name: string
address: string
codeSize: number
minCodeSize?: number
status: ContractVerificationStatus
}
export interface ProtocolVerificationReport {
verifiedAt: string
chainId: number
rpcUsed: boolean
allVerified: boolean
contracts: ContractVerificationRow[]
}
export interface OfficialProtocolRow {
id: string
classification?: string
requiredForProduction?: boolean
upstreamRepo?: string
upstreamSubmodule?: string
deployScripts?: string[]
verifyScripts?: string[]
deployMethod?: string
integrationNotes?: string
contracts: OfficialProtocolContractRow[]
}
export interface OfficialProtocolsResponse {
generatedAt: string
source: string
chainId: number
chainName?: string
policyDoc?: string
guardrails: string[]
forbiddenProductionPatterns: Array<{ id: string; description: string }>
count: number
protocols: OfficialProtocolRow[]
protocol?: OfficialProtocolRow
verification?: ProtocolVerificationReport
schemaVersion?: string
updated?: string
}
export async function fetchOfficialProtocols(protocolId?: string): Promise<OfficialProtocolsResponse | null> {
const base = `${getTokenAggregationApiBase()}/report/official-protocols`
const url = protocolId ? `${base}/${encodeURIComponent(protocolId)}` : base
try {
const response = await fetch(url, { cache: 'no-store' })
if (!response.ok) return null
return (await response.json()) as OfficialProtocolsResponse
} catch {
return null
}
}

View File

@@ -0,0 +1,59 @@
import { getTokenAggregationApiBase } from '@/services/api/tokenAggregation'
export interface PolygonMeshTokenRow {
hubSymbol: string
wrappedSymbol: string
decimals?: number
hub138Address?: string | null
mainnetRootAddress?: string
polygonAddress?: string
polygonExplorerUrl?: string | null
hub138ExplorerUrl?: string | null
mainnetExplorerUrl?: string | null
}
export interface PolygonMapperResponse {
chainId: number
hubChainId: number
meshTokens: PolygonMeshTokenRow[]
vipBridge?: {
bridgeAddress?: string
austdPolygon?: string
ausdtAll?: string
}
officialPolygonTokenList?: {
mappedApi?: string
upstreamRepo?: string
submissionDoc?: string
}
match?: PolygonMeshTokenRow
query?: string
}
export async function fetchPolygonMapper(query?: string): Promise<PolygonMapperResponse | null> {
const params = query?.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''
try {
const response = await fetch(`${getTokenAggregationApiBase()}/report/polygon-mapper${params}`, {
cache: 'no-store',
})
if (!response.ok) return null
return (await response.json()) as PolygonMapperResponse
} catch {
return null
}
}
export function polygonMapperRowForChain(
mapper: PolygonMapperResponse | null,
chainId: number,
address: string,
): PolygonMeshTokenRow | undefined {
if (!mapper || chainId !== 137) return undefined
const lower = address.toLowerCase()
return mapper.meshTokens?.find(
(row) =>
row.polygonAddress?.toLowerCase() === lower ||
row.hub138Address?.toLowerCase() === lower ||
row.mainnetRootAddress?.toLowerCase() === lower,
)
}

View File

@@ -5,6 +5,8 @@ export interface TokenAggregationMarketSnapshot {
volume24h?: number
liquidityUsd?: number
lastUpdated?: string | null
pricingKind?: 'spot' | 'lp-share'
isCanonicalClone?: boolean
}
export interface TokenAggregationTokenSnapshot {
@@ -68,6 +70,23 @@ export interface CheckpointTxAttestationSnapshot {
source?: string
}
interface RawTokenMarketBatchResponse {
chainId?: number
snapshots?: Array<{
address?: string
symbol?: string | null
name?: string | null
decimals?: number | string | null
priceUsd?: number | string | null
liquidityUsd?: number | string | null
volume24h?: number | string | null
lastUpdated?: string | null
source?: string | null
pricingKind?: 'spot' | 'lp-share' | null
isCanonicalClone?: boolean | null
}>
}
interface RawTokenAggregationTokenResponse {
token?: {
chainId?: number | string | null
@@ -200,6 +219,10 @@ function getTokenAggregationBase(): string {
return `${resolveExplorerApiBase()}/token-aggregation/api/v1`
}
export function getTokenAggregationApiBase(): string {
return getTokenAggregationBase()
}
export const tokenAggregationApi = {
getTokenSafe: async (chainId: number, address: string): Promise<{ ok: boolean; data: TokenAggregationTokenSnapshot | null }> => {
try {
@@ -223,11 +246,62 @@ export const tokenAggregationApi = {
return { ok: true, data: [] }
}
const results = await Promise.all(uniqueAddresses.map((address) => tokenAggregationApi.getTokenSafe(chainId, address)))
const data = results
.filter((result): result is { ok: true; data: TokenAggregationTokenSnapshot | null } => result.ok)
.map((result) => result.data)
.filter((snapshot): snapshot is TokenAggregationTokenSnapshot => Boolean(snapshot?.address))
try {
const batchResponse = await fetch(`${getTokenAggregationBase()}/tokens/market-batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'Cache-Control': 'no-cache',
},
body: JSON.stringify({ chainId, addresses: uniqueAddresses }),
})
if (batchResponse.ok) {
const batchRaw = (await batchResponse.json()) as RawTokenMarketBatchResponse
const data: TokenAggregationTokenSnapshot[] = (batchRaw.snapshots || [])
.flatMap((snapshot) => {
if (!snapshot.address) return []
const priceUsd = toNumber(snapshot.priceUsd)
const liquidityUsd = toNumber(snapshot.liquidityUsd)
const entry: TokenAggregationTokenSnapshot = {
chainId,
address: snapshot.address,
name: snapshot.name || undefined,
symbol: snapshot.symbol || undefined,
decimals: toNumber(snapshot.decimals),
market: priceUsd != null || liquidityUsd != null || snapshot.pricingKind
? {
priceUsd,
liquidityUsd,
volume24h: toNumber(snapshot.volume24h),
lastUpdated: snapshot.lastUpdated || null,
pricingKind: snapshot.pricingKind || undefined,
isCanonicalClone: snapshot.isCanonicalClone ?? undefined,
}
: null,
}
return [entry]
})
if (data.length > 0) {
return { ok: true, data }
}
}
} catch {
// Fall back to per-token requests below.
}
const chunkSize = 24
const data: TokenAggregationTokenSnapshot[] = []
for (let offset = 0; offset < uniqueAddresses.length; offset += chunkSize) {
const chunk = uniqueAddresses.slice(offset, offset + chunkSize)
const results = await Promise.all(chunk.map((address) => tokenAggregationApi.getTokenSafe(chainId, address)))
for (const result of results) {
if (result.ok && result.data?.address) {
data.push(result.data)
}
}
}
return { ok: data.length > 0, data }
},

View File

@@ -0,0 +1,92 @@
import { getTokenAggregationApiBase } from '@/services/api/tokenAggregation'
export interface TokenMappingPair {
fromChainId: number
toChainId: number
notes?: string
}
export interface TokenMappingPairsResponse {
pairs: TokenMappingPair[]
}
export interface TokenMappingResolveResponse {
fromChainId: number
toChainId: number
addressOnSource: string
addressOnTarget: string | null
activeTransportEligible?: boolean
gruTransportRuntimeReady?: boolean
gruTransportFamilyKey?: string | null
gruTransportCanonicalToken?: Record<string, unknown> | null
}
export async function fetchTokenMappingPairs(): Promise<TokenMappingPair[]> {
try {
const response = await fetch(`${getTokenAggregationApiBase()}/token-mapping/pairs`, { cache: 'no-store' })
if (!response.ok) return []
const body = (await response.json()) as TokenMappingPairsResponse
return Array.isArray(body.pairs) ? body.pairs : []
} catch {
return []
}
}
export async function resolveTokenMapping(
fromChainId: number,
toChainId: number,
address: string,
): Promise<TokenMappingResolveResponse | null> {
const normalized = address.trim()
if (!/^0x[a-fA-F0-9]{40}$/.test(normalized)) return null
try {
const params = new URLSearchParams({
fromChain: String(fromChainId),
toChain: String(toChainId),
address: normalized,
})
const response = await fetch(`${getTokenAggregationApiBase()}/token-mapping/resolve?${params}`, {
cache: 'no-store',
})
if (!response.ok) return null
return (await response.json()) as TokenMappingResolveResponse
} catch {
return null
}
}
export function getMeshDestinationChainIds(pairs: TokenMappingPair[], hubChainId = 138): number[] {
const ids = new Set<number>([hubChainId])
for (const pair of pairs) {
if (pair.fromChainId === hubChainId) ids.add(pair.toChainId)
if (pair.toChainId === hubChainId) ids.add(pair.fromChainId)
}
return [...ids].sort((left, right) => {
if (left === hubChainId) return -1
if (right === hubChainId) return 1
return left - right
})
}
export async function resolveMeshFromHub(
hubChainId: number,
hubAddress: string,
destinationChainIds: number[],
): Promise<Map<number, string>> {
const byChain = new Map<number, string>()
byChain.set(hubChainId, hubAddress)
await Promise.all(
destinationChainIds
.filter((chainId) => chainId !== hubChainId)
.map(async (chainId) => {
const resolved = await resolveTokenMapping(hubChainId, chainId, hubAddress)
const target = resolved?.addressOnTarget?.trim()
if (target && /^0x[a-fA-F0-9]{40}$/.test(target) && target !== '0x0000000000000000000000000000000000000000') {
byChain.set(chainId, target)
}
}),
)
return byChain
}

View File

@@ -75,3 +75,8 @@ export function getActiveWalletConnectSessionId(): string | null {
const session = activeProvider?.session
return typeof session?.topic === 'string' && session.topic ? session.topic : null
}
/** Use for EIP-747 / EIP-3085 when no injected `window.ethereum` (e.g. desktop without extension). */
export function getActiveWalletConnectProvider(): Awaited<ReturnType<typeof createEthereumProvider>> | null {
return activeProvider
}

147
frontend/src/utils/ens.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* Mainnet ENS resolution (forward + reverse) via JSON-RPC.
* Chain 138 has no native ENS — resolution always uses Ethereum mainnet.
*/
import { keccak256 } from 'js-sha3'
const MAINNET_RPC =
(typeof process !== 'undefined' && process.env.NEXT_PUBLIC_MAINNET_RPC_URL) ||
'https://ethereum.publicnode.com'
const ENS_REGISTRY = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e'
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
const CACHE_TTL_MS = 60 * 60 * 1000
const forwardCache = new Map<string, { value: string | null; at: number }>()
const reverseCache = new Map<string, { value: string | null; at: number }>()
function readCache<T>(map: Map<string, { value: T; at: number }>, key: string): T | undefined {
const hit = map.get(key)
if (!hit) return undefined
if (Date.now() - hit.at > CACHE_TTL_MS) {
map.delete(key)
return undefined
}
return hit.value
}
function writeCache<T>(map: Map<string, { value: T; at: number }>, key: string, value: T) {
map.set(key, { value, at: Date.now() })
}
async function rpcCall(method: string, params: unknown[]): Promise<string> {
const response = await fetch(MAINNET_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
})
const data = await response.json()
if (data.error) throw new Error(data.error.message || 'RPC error')
return data.result as string
}
function strip0x(hex: string): string {
return hex.startsWith('0x') ? hex.slice(2) : hex
}
function pad32(hex: string): string {
return hex.padStart(64, '0')
}
// Minimal namehash (ENS EIP-137)
function namehash(name: string): string {
let node = new Uint8Array(32)
if (name) {
const labels = name.split('.').reverse()
for (const label of labels) {
const labelHash = keccak256.arrayBuffer(new TextEncoder().encode(label))
const combined = new Uint8Array(64)
combined.set(node)
combined.set(new Uint8Array(labelHash), 32)
node = new Uint8Array(keccak256.arrayBuffer(combined))
}
}
return `0x${Array.from(node, (b) => b.toString(16).padStart(2, '0')).join('')}`
}
async function getResolver(node: string): Promise<string | null> {
const data = `0x0178b8bf${pad32(strip0x(node))}`
const result = await rpcCall('eth_call', [{ to: ENS_REGISTRY, data }, 'latest'])
if (!result || result === '0x' || result.length < 66) return null
const resolver = `0x${result.slice(-40)}`
if (resolver.toLowerCase() === ZERO_ADDRESS) return null
return resolver
}
export async function resolveEnsAddress(name: string): Promise<string | null> {
const normalized = name.trim().toLowerCase()
if (!normalized.endsWith('.eth')) return null
const cached = readCache(forwardCache, normalized)
if (cached !== undefined) return cached
try {
const node = namehash(normalized)
const resolver = await getResolver(node)
if (!resolver) {
writeCache(forwardCache, normalized, null)
return null
}
const data = `0x3b3b57de${pad32(strip0x(node))}`
const result = await rpcCall('eth_call', [{ to: resolver, data }, 'latest'])
if (!result || result.length < 66) {
writeCache(forwardCache, normalized, null)
return null
}
const address = `0x${result.slice(-40)}`
if (address.toLowerCase() === ZERO_ADDRESS) {
writeCache(forwardCache, normalized, null)
return null
}
writeCache(forwardCache, normalized, address)
return address
} catch {
writeCache(forwardCache, normalized, null)
return null
}
}
export async function resolveEnsName(address: string): Promise<string | null> {
const normalized = address.trim().toLowerCase()
if (!/^0x[a-f0-9]{40}$/.test(normalized)) return null
const cached = readCache(reverseCache, normalized)
if (cached !== undefined) return cached
try {
const reverseNode = namehash(`${normalized.slice(2)}.addr.reverse`)
const resolver = await getResolver(reverseNode)
if (!resolver) {
writeCache(reverseCache, normalized, null)
return null
}
const data = `0x691f3431${pad32(strip0x(reverseNode))}`
const result = await rpcCall('eth_call', [{ to: resolver, data }, 'latest'])
if (!result || result === '0x' || result.length <= 2) {
writeCache(reverseCache, normalized, null)
return null
}
const hex = strip0x(result)
const offset = parseInt(hex.slice(0, 64), 16) * 2
const len = parseInt(hex.slice(offset, offset + 64), 16)
const nameHex = hex.slice(offset + 64, offset + 64 + len * 2)
const name = new TextDecoder().decode(
new Uint8Array(nameHex.match(/.{1,2}/g)?.map((b) => parseInt(b, 16)) || []),
)
const value = name || null
writeCache(reverseCache, normalized, value)
return value
} catch {
writeCache(reverseCache, normalized, null)
return null
}
}
export function isEnsName(query: string): boolean {
const trimmed = query.trim().toLowerCase()
return trimmed.endsWith('.eth') && trimmed.length > 4 && !trimmed.includes(' ')
}

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { buildMeshCounterpartRows, inferMeshSeed } from './meshCounterparts'
const wrappedRows = [
{
chainId: 1,
chainName: 'Ethereum Mainnet',
symbol: 'cWUSDC',
address: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a',
},
{
chainId: 56,
chainName: 'BNB Chain',
symbol: 'cWUSDC',
address: '0x5355148C4740fcc3D7a96F05EdD89AB14851206b',
},
{
chainId: 138,
chainName: 'Chain 138',
symbol: 'cUSDC',
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
},
]
const curated = [
{
chainId: 138,
symbol: 'cUSDC',
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
},
]
describe('inferMeshSeed', () => {
it('detects hub symbol from curated tokens', () => {
expect(inferMeshSeed('cUSDC', curated, wrappedRows)).toMatchObject({
hubSymbol: 'cUSDC',
wrappedSymbol: 'cWUSDC',
hubAddress: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
needsHubResolve: false,
matchReason: 'hub symbol',
})
})
it('detects wrapped symbol and resolves hub from registry', () => {
expect(inferMeshSeed('cWUSDC', curated, wrappedRows)).toMatchObject({
hubSymbol: 'cUSDC',
wrappedSymbol: 'cWUSDC',
hubAddress: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
matchReason: 'wrapped symbol',
})
})
it('detects off-home wrapped address and flags hub resolve when hub unknown', () => {
const seed = inferMeshSeed('0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', [], wrappedRows)
expect(seed).toMatchObject({
hubSymbol: 'cUSDC',
sourceChainId: 1,
sourceAddress: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a',
needsHubResolve: false,
hubAddress: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
})
})
it('returns null for unrelated queries', () => {
expect(inferMeshSeed('random', curated, wrappedRows)).toBeNull()
})
})
describe('buildMeshCounterpartRows', () => {
it('merges token-mapping resolves with registry rows', () => {
const seed = inferMeshSeed('cUSDC', curated, wrappedRows)!
const resolved = new Map<number, string>([
[138, seed.hubAddress!],
[1, '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a'],
])
const chainNames = new Map<number, string>([
[138, 'Chain 138'],
[1, 'Ethereum Mainnet'],
[56, 'BNB Chain'],
])
const rows = buildMeshCounterpartRows(seed, resolved, wrappedRows, chainNames)
expect(rows.map((row) => row.chainId)).toEqual([138, 1, 56])
expect(rows[0].role).toBe('hub')
expect(rows.filter((row) => row.chainId === 56)).toHaveLength(1)
})
})

View File

@@ -0,0 +1,205 @@
import type { SearchTokenHint, WrappedTransportRegistryRow } from '@/utils/search'
const HUB_CHAIN_ID = 138
/** c* on hub ↔ cW* on destination — mirrors config/token-mapping-multichain.json */
export const HUB_TO_WRAPPED_SYMBOL: Record<string, string> = {
cUSDT: 'cWUSDT',
cUSDC: 'cWUSDC',
cEURC: 'cWEURC',
cEURT: 'cWEURT',
cGBPC: 'cWGBPC',
cGBPT: 'cWGBPT',
cAUDC: 'cWAUDC',
cJPYC: 'cWJPYC',
cCHFC: 'cWCHFC',
cCADC: 'cWCADC',
cXAUC: 'cWXAUC',
cXAUT: 'cWXAUT',
cBTC: 'cWBTC',
}
const WRAPPED_TO_HUB_SYMBOL = Object.fromEntries(
Object.entries(HUB_TO_WRAPPED_SYMBOL).map(([hub, wrapped]) => [wrapped.toLowerCase(), hub]),
) as Record<string, string>
const addressPattern = /^0x[a-f0-9]{40}$/i
export interface MeshSeed {
hubChainId: number
hubAddress?: string
hubSymbol: string
wrappedSymbol: string
sourceChainId?: number
sourceAddress?: string
matchReason: string
needsHubResolve: boolean
}
export interface MeshCounterpartRow {
chainId: number
chainName: string
symbol: string
address: string
role: 'hub' | 'wrapped' | 'registry'
mappedVia: 'token-mapping' | 'cw-registry' | 'curated'
}
function hubSymbolFor(rawSymbol: string): string | null {
const sym = rawSymbol.trim()
if (!sym) return null
const lower = sym.toLowerCase()
if (HUB_TO_WRAPPED_SYMBOL[sym]) return sym
return WRAPPED_TO_HUB_SYMBOL[lower] ?? null
}
function wrappedSymbolFor(hubSymbol: string): string {
return HUB_TO_WRAPPED_SYMBOL[hubSymbol] ?? hubSymbol
}
function findHubInCurated(hubSymbol: string, curatedTokens: SearchTokenHint[]): SearchTokenHint | undefined {
const lower = hubSymbol.toLowerCase()
return curatedTokens.find(
(token) => token.chainId === HUB_CHAIN_ID && token.symbol?.toLowerCase() === lower && token.address,
)
}
function findHubInRegistry(hubSymbol: string, rows: WrappedTransportRegistryRow[]): WrappedTransportRegistryRow | undefined {
const lower = hubSymbol.toLowerCase()
return rows.find((row) => row.chainId === HUB_CHAIN_ID && row.symbol.toLowerCase() === lower)
}
export function inferMeshSeed(
query: string,
curatedTokens: SearchTokenHint[] = [],
wrappedRows: WrappedTransportRegistryRow[] = [],
): MeshSeed | null {
const trimmed = query.trim()
if (!trimmed) return null
if (addressPattern.test(trimmed)) {
const lower = trimmed.toLowerCase()
const curated = curatedTokens.find(
(token) => token.chainId === HUB_CHAIN_ID && token.address?.toLowerCase() === lower,
)
if (curated?.address && curated.symbol) {
const hubSymbol = hubSymbolFor(curated.symbol) ?? curated.symbol
return {
hubChainId: HUB_CHAIN_ID,
hubAddress: curated.address,
hubSymbol,
wrappedSymbol: wrappedSymbolFor(hubSymbol),
sourceChainId: HUB_CHAIN_ID,
sourceAddress: curated.address,
matchReason: 'curated hub address',
needsHubResolve: false,
}
}
const registryHit = wrappedRows.find((row) => row.address.toLowerCase() === lower)
if (registryHit) {
const hubSym = hubSymbolFor(registryHit.symbol)
if (!hubSym) return null
const hubRow = findHubInRegistry(hubSym, wrappedRows) ?? findHubInCurated(hubSym, curatedTokens)
if (registryHit.chainId === HUB_CHAIN_ID) {
return {
hubChainId: HUB_CHAIN_ID,
hubAddress: registryHit.address,
hubSymbol: hubSym,
wrappedSymbol: wrappedSymbolFor(hubSym),
sourceChainId: HUB_CHAIN_ID,
sourceAddress: registryHit.address,
matchReason: 'registry hub address',
needsHubResolve: false,
}
}
return {
hubChainId: HUB_CHAIN_ID,
hubAddress: hubRow?.address,
hubSymbol: hubSym,
wrappedSymbol: wrappedSymbolFor(hubSym),
sourceChainId: registryHit.chainId,
sourceAddress: registryHit.address,
matchReason: 'registry wrapped address',
needsHubResolve: !hubRow?.address,
}
}
return null
}
const hubFromSymbol = hubSymbolFor(trimmed)
if (hubFromSymbol) {
const hubRow = findHubInRegistry(hubFromSymbol, wrappedRows) ?? findHubInCurated(hubFromSymbol, curatedTokens)
return {
hubChainId: HUB_CHAIN_ID,
hubAddress: hubRow?.address,
hubSymbol: hubFromSymbol,
wrappedSymbol: wrappedSymbolFor(hubFromSymbol),
matchReason: trimmed.toLowerCase() === hubFromSymbol.toLowerCase() ? 'hub symbol' : 'wrapped symbol',
needsHubResolve: !hubRow?.address,
}
}
return null
}
export function buildMeshCounterpartRows(
seed: MeshSeed,
resolvedByChain: Map<number, string>,
wrappedRows: WrappedTransportRegistryRow[],
chainNameById: Map<number, string>,
): MeshCounterpartRow[] {
const rows: MeshCounterpartRow[] = []
const seen = new Set<string>()
const push = (row: MeshCounterpartRow) => {
const key = `${row.chainId}:${row.address.toLowerCase()}`
if (seen.has(key)) return
seen.add(key)
rows.push(row)
}
for (const [chainId, address] of resolvedByChain.entries()) {
if (!/^0x[a-fA-F0-9]{40}$/.test(address)) continue
push({
chainId,
chainName: chainNameById.get(chainId) ?? `Chain ${chainId}`,
symbol: chainId === HUB_CHAIN_ID ? seed.hubSymbol : seed.wrappedSymbol,
address,
role: chainId === HUB_CHAIN_ID ? 'hub' : 'wrapped',
mappedVia: 'token-mapping',
})
}
for (const row of wrappedRows) {
const sym = row.symbol.toLowerCase()
if (sym !== seed.hubSymbol.toLowerCase() && sym !== seed.wrappedSymbol.toLowerCase()) continue
push({
chainId: row.chainId,
chainName: row.chainName || chainNameById.get(row.chainId) || `Chain ${row.chainId}`,
symbol: row.symbol,
address: row.address,
role: row.chainId === HUB_CHAIN_ID ? 'hub' : 'registry',
mappedVia: 'cw-registry',
})
}
return rows.sort((left, right) => {
if (left.chainId === HUB_CHAIN_ID) return -1
if (right.chainId === HUB_CHAIN_ID) return 1
return left.chainId - right.chainId
})
}
export function buildChainNameMap(wrappedRows: WrappedTransportRegistryRow[]): Map<number, string> {
const map = new Map<number, string>()
for (const row of wrappedRows) {
if (!map.has(row.chainId) && row.chainName) {
map.set(row.chainId, row.chainName)
}
}
map.set(HUB_CHAIN_ID, map.get(HUB_CHAIN_ID) ?? 'Chain 138')
return map
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { buildPaginationItems } from './pagination'
describe('buildPaginationItems', () => {
it('returns no items for a single page', () => {
expect(buildPaginationItems(1, 1)).toEqual([])
})
it('returns all pages when the range is small', () => {
expect(buildPaginationItems(2, 5)).toEqual([
{ type: 'page', page: 1 },
{ type: 'page', page: 2 },
{ type: 'page', page: 3 },
{ type: 'page', page: 4 },
{ type: 'page', page: 5 },
])
})
it('inserts ellipsis around the active page on large ranges', () => {
expect(buildPaginationItems(10, 20)).toEqual([
{ type: 'page', page: 1 },
{ type: 'ellipsis', key: 'ellipsis-1-9' },
{ type: 'page', page: 9 },
{ type: 'page', page: 10 },
{ type: 'page', page: 11 },
{ type: 'ellipsis', key: 'ellipsis-11-20' },
{ type: 'page', page: 20 },
])
})
})

View File

@@ -0,0 +1,43 @@
export type PaginationItem =
| { type: 'page'; page: number }
| { type: 'ellipsis'; key: string }
export function buildPaginationItems(
currentPage: number,
pageCount: number,
siblingCount = 1,
): PaginationItem[] {
if (pageCount <= 1) {
return []
}
const maxPagesWithoutEllipsis = siblingCount * 2 + 4
if (pageCount <= maxPagesWithoutEllipsis) {
return Array.from({ length: pageCount }, (_, index) => ({
type: 'page' as const,
page: index + 1,
}))
}
const pages = new Set<number>([1, pageCount])
for (let page = currentPage - siblingCount; page <= currentPage + siblingCount; page += 1) {
if (page >= 1 && page <= pageCount) {
pages.add(page)
}
}
const sortedPages = [...pages].sort((left, right) => left - right)
const items: PaginationItem[] = []
let previousPage = 0
for (const page of sortedPages) {
if (previousPage > 0 && page - previousPage > 1) {
items.push({ type: 'ellipsis', key: `ellipsis-${previousPage}-${page}` })
}
items.push({ type: 'page', page })
previousPage = page
}
return items
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { searchCuratedPools } from './poolSearch'
const pools = [
{
chainId: 138,
chainName: 'Chain 138',
poolAddress: '0x9e89bAe009adf128782E19e8341996c596ac40dC',
lpTokenAddress: '0x9e89bAe009adf128782E19e8341996c596ac40dC',
lpTokenType: 'dodo_dvm' as const,
venue: 'dodo_pmm' as const,
baseSymbol: 'cUSDT',
quoteSymbol: 'cUSDC',
baseAddress: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
quoteAddress: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
},
]
describe('searchCuratedPools', () => {
it('finds pool by address', () => {
const matches = searchCuratedPools('0x9e89bAe009adf128782E19e8341996c596ac40dC', pools)
expect(matches).toHaveLength(1)
expect(matches[0].matchReason).toBe('exact pool address')
})
it('finds pool by pair symbol', () => {
const matches = searchCuratedPools('cUSDT', pools)
expect(matches).toHaveLength(1)
expect(matches[0].pairLabel).toBe('cUSDT / cUSDC')
})
})

View File

@@ -0,0 +1,88 @@
import type { CuratedPoolRegistryEntry } from '@/services/api/liquidityPositions'
export interface PoolSearchRow extends CuratedPoolRegistryEntry {
pairLabel: string
matchReason: 'exact pool address' | 'exact lp token' | 'pair symbol' | 'symbol prefix'
}
const addressPattern = /^0x[a-f0-9]{40}$/i
function pairLabel(pool: CuratedPoolRegistryEntry): string {
return `${pool.baseSymbol} / ${pool.quoteSymbol}`
}
export function searchCuratedPools(query: string, pools: CuratedPoolRegistryEntry[] = []): PoolSearchRow[] {
const trimmed = query.trim()
if (!trimmed || pools.length === 0) return []
const lower = trimmed.toLowerCase()
const isAddress = addressPattern.test(trimmed)
const matches: PoolSearchRow[] = []
for (const pool of pools) {
const label = pairLabel(pool)
if (isAddress) {
if (
pool.poolAddress.toLowerCase() === lower ||
pool.lpTokenAddress.toLowerCase() === lower ||
pool.baseAddress.toLowerCase() === lower ||
pool.quoteAddress.toLowerCase() === lower
) {
matches.push({
...pool,
pairLabel: label,
matchReason:
pool.poolAddress.toLowerCase() === lower
? 'exact pool address'
: pool.lpTokenAddress.toLowerCase() === lower
? 'exact lp token'
: 'pair symbol',
})
}
continue
}
const symMatch =
pool.baseSymbol.toLowerCase() === lower ||
pool.quoteSymbol.toLowerCase() === lower ||
label.toLowerCase().replace(/\s+/g, '') === lower.replace(/\s+/g, '') ||
label.toLowerCase().includes(lower)
if (symMatch) {
matches.push({
...pool,
pairLabel: label,
matchReason:
pool.baseSymbol.toLowerCase() === lower || pool.quoteSymbol.toLowerCase() === lower
? 'pair symbol'
: 'symbol prefix',
})
}
}
const weight: Record<PoolSearchRow['matchReason'], number> = {
'exact pool address': 100,
'exact lp token': 90,
'pair symbol': 70,
'symbol prefix': 50,
}
return matches.sort((left, right) => {
const reasonCmp = weight[right.matchReason] - weight[left.matchReason]
if (reasonCmp !== 0) return reasonCmp
return (right.totalLiquidityUsd ?? 0) - (left.totalLiquidityUsd ?? 0)
})
}
export function findPoolByAddress(
address: string,
pools: CuratedPoolRegistryEntry[],
): CuratedPoolRegistryEntry | undefined {
const lower = address.trim().toLowerCase()
if (!addressPattern.test(lower)) return undefined
return pools.find(
(pool) =>
pool.poolAddress.toLowerCase() === lower ||
pool.lpTokenAddress.toLowerCase() === lower,
)
}

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
import { getPublicExplorerBase } from '@/utils/publicExplorer'
describe('getPublicExplorerBase', () => {
it('matches resolveExplorerApiBase with explorer.d-bis.org SSR fallback', () => {
expect(getPublicExplorerBase()).toBe(
resolveExplorerApiBase({
envValue: process.env.NEXT_PUBLIC_API_URL,
serverFallback: 'https://explorer.d-bis.org',
}),
)
})
it('does not default to blockscout.defi-oracle.io when env is empty on the server', () => {
expect(getPublicExplorerBase()).not.toBe('https://blockscout.defi-oracle.io')
})
})

View File

@@ -1,11 +1,18 @@
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
export interface PublicFetchMetadata {
source: string
lastModified: string | null
}
/** Production SSR fallback when NEXT_PUBLIC_API_URL is unset (same host as public explorer). */
const DEFAULT_PUBLIC_EXPLORER_BASE = 'https://explorer.d-bis.org'
export function getPublicExplorerBase(): string {
const configured = (process.env.NEXT_PUBLIC_API_URL || '').trim()
return configured || 'https://blockscout.defi-oracle.io'
return resolveExplorerApiBase({
envValue: process.env.NEXT_PUBLIC_API_URL,
serverFallback: DEFAULT_PUBLIC_EXPLORER_BASE,
})
}
export async function fetchPublicJson<T>(path: string): Promise<T> {

View File

@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
inferDirectSearchTarget,
inferEnsSearchTarget,
inferTokenSearchTarget,
normalizeExplorerSearchResults,
searchWrappedTransportTokens,
shouldDeferChain138AddressJump,
suggestCuratedTokens,
} from './search'
@@ -41,6 +44,14 @@ describe('inferDirectSearchTarget', () => {
expect(inferDirectSearchTarget('cUSDT')).toBeNull()
})
it('resolves defi-oracle.eth from identity registry', () => {
expect(inferEnsSearchTarget('defi-oracle.eth')).toEqual({
kind: 'address',
href: '/addresses/0x4A666F96fC8764181194447A7dFdb7d471b301C8',
label: 'Open defi-oracle.eth',
})
})
it('detects curated token symbols and addresses', () => {
expect(inferTokenSearchTarget('cUSDT', [
{ chainId: 138, symbol: 'cUSDT', address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22' },
@@ -152,3 +163,47 @@ describe('normalizeExplorerSearchResults', () => {
).toHaveLength(1)
})
})
describe('searchWrappedTransportTokens', () => {
const rows = [
{
chainId: 1,
chainName: 'Ethereum Mainnet',
symbol: 'cWUSDC',
address: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a',
},
{
chainId: 56,
chainName: 'BNB Chain',
symbol: 'cWUSDC',
address: '0x5355148C4740fcc3D7a96F05EdD89AB14851206b',
},
{
chainId: 138,
chainName: 'Chain 138',
symbol: 'cUSDC',
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
},
]
it('finds exact wrapped addresses on multiple chains', () => {
const matches = searchWrappedTransportTokens('0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', rows)
expect(matches).toHaveLength(1)
expect(matches[0]).toMatchObject({
chainId: 1,
symbol: 'cWUSDC',
matchReason: 'exact address',
})
})
it('finds symbol matches across networks', () => {
const matches = searchWrappedTransportTokens('cWUSDC', rows)
expect(matches.map((row) => row.chainId)).toEqual([1, 56])
})
it('defers chain 138 address jump for off-home wrapped matches', () => {
const matches = searchWrappedTransportTokens('0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', rows)
expect(shouldDeferChain138AddressJump('0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', matches)).toBe(true)
expect(shouldDeferChain138AddressJump('0xf22258f57794CC8E06237084b353Ab30fFfa640b', rows)).toBe(false)
})
})

View File

@@ -1,4 +1,10 @@
import { getGruCatalogPosture } from '@/services/api/gruCatalog'
import { isEnsName } from '@/utils/ens'
import {
getRegistryEntryByEns,
resolveAddressFromRegistryEns,
searchRegistryByQuery,
} from '@/utils/web3IdentityRegistry'
export type DirectSearchTarget =
| { kind: 'address'; href: string; label: string }
@@ -14,6 +20,19 @@ export interface SearchTokenHint {
tags?: string[]
}
export interface WrappedTransportRegistryRow {
chainId: number
chainName: string
symbol: string
address: string
assetClass?: string
familyKey?: string
}
export interface WrappedTransportSearchRow extends WrappedTransportRegistryRow {
matchReason: 'exact address' | 'exact symbol' | 'symbol prefix' | 'symbol contains'
}
export interface RawExplorerSearchItem {
type?: string | null
address?: string | null
@@ -52,6 +71,43 @@ const addressPattern = /^0x[a-f0-9]{40}$/i
const transactionHashPattern = /^0x[a-f0-9]{64}$/i
const blockNumberPattern = /^\d+$/
export function inferRegistrySearchTarget(query: string): DirectSearchTarget | null {
const trimmed = query.trim()
if (!trimmed) return null
const ensEntry = getRegistryEntryByEns(trimmed)
if (ensEntry) {
return {
kind: 'address',
href: `/addresses/${ensEntry.address}`,
label: `Open ${ensEntry.displayName}`,
}
}
const matches = searchRegistryByQuery(trimmed)
if (matches.length === 1 && matches[0].address.toLowerCase() !== `0x${'0'.repeat(40)}`) {
return {
kind: 'address',
href: `/addresses/${matches[0].address}`,
label: `Open ${matches[0].displayName}`,
}
}
return null
}
export function inferEnsSearchTarget(query: string): DirectSearchTarget | null {
const trimmed = query.trim()
if (!isEnsName(trimmed)) return null
const fromRegistry = resolveAddressFromRegistryEns(trimmed)
if (!fromRegistry) return null
return {
kind: 'address',
href: `/addresses/${fromRegistry}`,
label: `Open ${trimmed}`,
}
}
export function inferDirectSearchTarget(query: string): DirectSearchTarget | null {
const trimmed = query.trim()
if (!trimmed) {
@@ -82,9 +138,81 @@ export function inferDirectSearchTarget(query: string): DirectSearchTarget | nul
}
}
const registryTarget = inferRegistrySearchTarget(trimmed)
if (registryTarget) return registryTarget
const ensTarget = inferEnsSearchTarget(trimmed)
if (ensTarget) return ensTarget
return null
}
function wrappedSymbolScore(query: string, symbol: string): WrappedTransportSearchRow['matchReason'] | null {
const q = query.trim().toLowerCase()
const sym = symbol.trim().toLowerCase()
if (!q || !sym) return null
if (sym === q) return 'exact symbol'
if (sym.startsWith(q) && q.length >= 2) return 'symbol prefix'
if (q.length >= 3 && sym.includes(q)) return 'symbol contains'
return null
}
/** Search live cW* / gas mirror registry rows across all configured networks. */
export function searchWrappedTransportTokens(
query: string,
rows: WrappedTransportRegistryRow[] = [],
): WrappedTransportSearchRow[] {
const trimmed = query.trim()
if (!trimmed || rows.length === 0) return []
const lower = trimmed.toLowerCase()
const isAddress = addressPattern.test(trimmed)
const matches: WrappedTransportSearchRow[] = []
for (const row of rows) {
const address = row.address?.toLowerCase()
if (isAddress) {
if (address === lower) {
matches.push({ ...row, matchReason: 'exact address' })
}
continue
}
const reason = wrappedSymbolScore(trimmed, row.symbol)
if (reason) {
matches.push({ ...row, matchReason: reason })
}
}
const reasonWeight: Record<WrappedTransportSearchRow['matchReason'], number> = {
'exact address': 100,
'exact symbol': 90,
'symbol prefix': 70,
'symbol contains': 50,
}
return matches.sort((left, right) => {
const reasonCmp = reasonWeight[right.matchReason] - reasonWeight[left.matchReason]
if (reasonCmp !== 0) return reasonCmp
const chainCmp = left.chainId - right.chainId
if (chainCmp !== 0) return chainCmp
return left.symbol.localeCompare(right.symbol)
})
}
export function shouldDeferChain138AddressJump(
query: string,
wrappedRows: WrappedTransportSearchRow[],
): boolean {
const trimmed = query.trim()
if (!addressPattern.test(trimmed) || wrappedRows.length === 0) return false
const lower = trimmed.toLowerCase()
const offHome = wrappedRows.filter(
(row) => row.matchReason === 'exact address' && row.address.toLowerCase() === lower && row.chainId !== 138,
)
return offHome.length > 0
}
export function inferTokenSearchTarget(query: string, tokens: SearchTokenHint[] = []): DirectSearchTarget | null {
const trimmed = query.trim()
if (!trimmed) {

View File

@@ -0,0 +1,109 @@
import type { AddressTokenBalance } from '@/services/api/addresses'
import type { TokenAggregationMarketSnapshot, TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
export interface ResolvedTokenMarket {
priceUsd?: number
liquidityUsd?: number
source?: 'token-aggregation' | 'blockscout' | 'derived'
pricingKind?: 'spot' | 'lp-share'
isCanonicalClone?: boolean
}
export function isLikelyLpReceiptSymbol(symbol: string | undefined): boolean {
if (!symbol) return false
const upper = symbol.toUpperCase()
return upper.includes('DLP') || upper.endsWith('-LP') || upper.startsWith('LP-') || upper === 'LP'
}
export function formatBalanceUsdLabel(
priceUsd: number | undefined,
balanceUsd: number | undefined,
options?: { pricingKind?: 'spot' | 'lp-share'; atTransfer?: boolean },
): string {
if (options?.pricingKind === 'lp-share') {
return 'LP share — USD N/A'
}
if (balanceUsd != null) {
return options?.atTransfer ? `${formatUsd(balanceUsd)} (at transfer)` : `${formatUsd(balanceUsd)}`
}
if (priceUsd == null) {
return 'USD unavailable'
}
return 'USD unavailable'
}
function formatUsd(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
function parseUsd(value: string | number | null | undefined): number | undefined {
if (value == null) return undefined
const numeric = typeof value === 'number' ? value : Number(value)
return Number.isFinite(numeric) && numeric > 0 ? numeric : undefined
}
export function resolveTokenMarketForBalance(
balance: Pick<AddressTokenBalance, 'token_address' | 'exchange_rate' | 'token_symbol'>,
tokenMarkets: Record<string, TokenAggregationTokenSnapshot>,
): ResolvedTokenMarket {
const key = balance.token_address.toLowerCase()
const aggregationSnapshot = tokenMarkets[key]
const aggregationMarket: TokenAggregationMarketSnapshot | null | undefined = aggregationSnapshot?.market
const aggregationPrice = aggregationMarket?.priceUsd
const blockscoutPrice = parseUsd(balance.exchange_rate)
const pricingKind = aggregationMarket?.pricingKind
?? (isLikelyLpReceiptSymbol(balance.token_symbol ?? aggregationSnapshot?.symbol) ? 'lp-share' : 'spot')
const isCanonicalClone = aggregationMarket?.isCanonicalClone
if (aggregationPrice != null && aggregationPrice > 0) {
return {
priceUsd: aggregationPrice,
liquidityUsd: aggregationMarket?.liquidityUsd,
source: 'token-aggregation',
pricingKind,
isCanonicalClone,
}
}
if (blockscoutPrice != null) {
return {
priceUsd: blockscoutPrice,
liquidityUsd: aggregationMarket?.liquidityUsd,
source: 'blockscout',
pricingKind,
isCanonicalClone,
}
}
return {
priceUsd: undefined,
liquidityUsd: aggregationMarket?.liquidityUsd,
source: 'derived',
pricingKind,
isCanonicalClone,
}
}
export function estimateTokenBalanceUsd(
rawAmount: string,
decimals: number,
priceUsd: number | undefined,
): number | undefined {
if (priceUsd == null || !(priceUsd > 0) || !rawAmount) return undefined
try {
const raw = BigInt(rawAmount)
if (raw <= 0n) return 0
const scale = 10n ** BigInt(decimals)
const whole = raw / scale
const fraction = raw % scale
const amount = Number(whole) + Number(fraction) / Number(scale)
if (!Number.isFinite(amount)) return undefined
return amount * priceUsd
} catch {
return undefined
}
}

View File

@@ -0,0 +1,63 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
export function parseDetailTabQuery<T extends string>(
value: string | string[] | undefined,
validTabs: readonly T[],
defaultTab: T,
): T {
const raw = typeof value === 'string' ? value : Array.isArray(value) ? value[0] : undefined
if (raw && validTabs.includes(raw as T)) {
return raw as T
}
return defaultTab
}
export function useDetailTabQuery<T extends string>(
validTabs: readonly T[],
defaultTab: T,
resetKey?: string,
) {
const router = useRouter()
const [activeTab, setActiveTabState] = useState<T>(defaultTab)
const tabSet = useMemo(() => new Set(validTabs), [validTabs])
useEffect(() => {
if (!router.isReady) {
return
}
setActiveTabState(parseDetailTabQuery(router.query.tab, validTabs, defaultTab))
}, [defaultTab, resetKey, router.isReady, router.query.tab, validTabs])
useEffect(() => {
if (!tabSet.has(activeTab)) {
setActiveTabState(defaultTab)
}
}, [activeTab, defaultTab, tabSet])
const setActiveTab = useCallback(
(tab: T) => {
if (!tabSet.has(tab)) {
return
}
setActiveTabState(tab)
if (!router.isReady) {
return
}
const query = { ...router.query }
if (tab === defaultTab) {
delete query.tab
} else {
query.tab = tab
}
void router.replace({ pathname: router.pathname, query }, undefined, { shallow: true })
},
[defaultTab, router, tabSet],
)
return { activeTab, setActiveTab, isReady: router.isReady }
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { parsePageQuery } from './useListPageQuery'
import { parseDetailTabQuery } from './useDetailTabQuery'
describe('parsePageQuery', () => {
it('defaults to page 1', () => {
expect(parsePageQuery(undefined)).toBe(1)
expect(parsePageQuery('')).toBe(1)
expect(parsePageQuery('0')).toBe(1)
expect(parsePageQuery('abc')).toBe(1)
})
it('parses positive integers', () => {
expect(parsePageQuery('3')).toBe(3)
expect(parsePageQuery(['7'])).toBe(7)
})
})
describe('parseDetailTabQuery', () => {
it('falls back to the default tab', () => {
expect(parseDetailTabQuery(undefined, ['balances', 'transfers'] as const, 'balances')).toBe('balances')
expect(parseDetailTabQuery('unknown', ['balances', 'transfers'] as const, 'balances')).toBe('balances')
})
it('accepts valid tabs', () => {
expect(parseDetailTabQuery('transfers', ['balances', 'transfers'] as const, 'balances')).toBe('transfers')
})
})

View File

@@ -0,0 +1,47 @@
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
export function parsePageQuery(value: string | string[] | undefined): number {
const raw = typeof value === 'string' ? value : Array.isArray(value) ? value[0] : undefined
if (!raw) {
return 1
}
const parsed = parseInt(raw, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1
}
export function useListPageQuery() {
const router = useRouter()
const [page, setPageState] = useState(1)
useEffect(() => {
if (!router.isReady) {
return
}
setPageState(parsePageQuery(router.query.page))
}, [router.isReady, router.query.page])
const setPage = useCallback(
(nextPage: number) => {
const normalized = Math.max(1, Math.floor(nextPage) || 1)
setPageState(normalized)
if (!router.isReady) {
return
}
const query = { ...router.query }
if (normalized === 1) {
delete query.page
} else {
query.page = String(normalized)
}
void router.replace({ pathname: router.pathname, query }, undefined, { shallow: true })
},
[router],
)
return { page, setPage, isReady: router.isReady }
}

View File

@@ -0,0 +1,45 @@
/** EIP-3085 fields accepted by MetaMask `wallet_addEthereumChain` (strict subset). */
export type WalletAddEthereumChainParams = {
chainId: string
chainName: string
rpcUrls: string[]
nativeCurrency: { name: string; symbol: string; decimals: number }
blockExplorerUrls?: string[]
iconUrls?: string[]
}
type WalletChainLike = WalletAddEthereumChainParams & {
chainIdDecimal?: number
oracles?: unknown
shortName?: string
infoURL?: string
explorerApiUrl?: string
}
function httpsOnly(urls: string[] | undefined): string[] | undefined {
if (!urls?.length) return undefined
const filtered = urls.filter((url) => typeof url === 'string' && url.startsWith('https://'))
return filtered.length > 0 ? filtered : undefined
}
/** Strip `chainIdDecimal`, `oracles`, and other keys MetaMask rejects. */
export function toWalletAddEthereumChainParams(
chain: WalletChainLike,
options?: { preferSingleRpc?: boolean },
): WalletAddEthereumChainParams {
let rpcUrls = httpsOnly(chain.rpcUrls) ?? ['https://rpc-http-pub.d-bis.org']
if (options?.preferSingleRpc && rpcUrls.length > 1) {
rpcUrls = [rpcUrls[0]]
}
const out: WalletAddEthereumChainParams = {
chainId: chain.chainId,
chainName: chain.chainName,
rpcUrls,
nativeCurrency: chain.nativeCurrency,
}
const blockExplorerUrls = httpsOnly(chain.blockExplorerUrls)
if (blockExplorerUrls) out.blockExplorerUrls = blockExplorerUrls
const iconUrls = httpsOnly(chain.iconUrls)
if (iconUrls) out.iconUrls = iconUrls
return out
}

View File

@@ -0,0 +1,41 @@
/** Supported chains for explorer wallet import (matches token-aggregation networks.ts). */
export const WALLET_IMPORT_CHAIN_IDS = [
138, 1, 651940, 56, 137, 100, 10, 42161, 8453, 43114, 25, 42220, 1111,
] as const
export type WalletImportChainId = (typeof WALLET_IMPORT_CHAIN_IDS)[number]
export const WALLET_FEATURED_SYMBOLS_BY_CHAIN: Partial<Record<number, readonly string[]>> = {
138: ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT', 'WETH', 'LINK'],
1: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT', 'USDC', 'USDT', 'WETH'],
651940: ['cUSDC', 'cUSDT', 'AUSDC', 'AUSDT'],
56: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
137: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
100: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
10: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
42161: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
8453: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
43114: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
25: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
42220: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
1111: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
}
export function chainLabel(chainId: number): string {
const labels: Record<number, string> = {
138: 'Chain 138',
1: 'Ethereum',
651940: 'ALL Mainnet',
56: 'BSC',
137: 'Polygon',
100: 'Gnosis',
10: 'Optimism',
42161: 'Arbitrum',
8453: 'Base',
43114: 'Avalanche',
25: 'Cronos',
42220: 'Celo',
1111: 'Wemix',
}
return labels[chainId] ?? `Chain ${chainId}`
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
formatFundedRowBalanceUsd,
formatFundedRowUnitPrice,
sortFundedWalletTokenRows,
type FundedWalletTokenRow,
} from './walletFundedTokenListing'
describe('walletFundedTokenListing', () => {
it('sorts native ETH first then by balance USD', () => {
const rows: FundedWalletTokenRow[] = [
{
kind: 'erc20',
symbol: 'WETH',
name: 'Wrapped Ether',
address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
decimals: 18,
logoURI: 'https://example/weth.svg',
balanceRaw: '1000000000000000000',
balanceLabel: '1 WETH',
balanceUsd: 100,
metaMaskAddable: true,
},
{
kind: 'native',
symbol: 'ETH',
name: 'Ether',
address: null,
decimals: 18,
logoURI: 'https://example/eth.svg',
balanceRaw: '2000000000000000000',
balanceLabel: '2 ETH',
balanceUsd: 200,
metaMaskAddable: false,
},
]
const sorted = sortFundedWalletTokenRows(rows)
expect(sorted[0]?.kind).toBe('native')
expect(sorted[1]?.symbol).toBe('WETH')
})
it('formats USD labels', () => {
expect(formatFundedRowUnitPrice({ priceUsd: 1700.25 })).toContain('$')
expect(formatFundedRowBalanceUsd({ balanceUsd: 42.5 })).toContain('≈')
expect(formatFundedRowUnitPrice({ pricingKind: 'lp-share' })).toContain('LP share')
})
})

View File

@@ -0,0 +1,206 @@
import { getNativeAssetMarketSafe, estimateNativeUsdValue } from '@/services/api/nativeAssetPricing'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { formatTokenAmount } from '@/utils/format'
import { estimateTokenBalanceUsd } from '@/utils/tokenMarket'
import {
formatNativeEthFromWei,
listTokensWithNonZeroBalance,
readNativeBalanceRaw,
type EthereumRpcProvider,
} from '@/utils/walletTokenBalances'
export const CHAIN138_NATIVE_ETH_LOGO =
'https://explorer.d-bis.org/api/v1/report/logo/ETH?v=20260510'
export type FundedWalletCatalogToken = {
chainId: number
address: string
symbol: string
name: string
decimals: number
logoURI?: string
}
export type FundedWalletTokenRow = {
kind: 'native' | 'erc20'
symbol: string
name: string
address: string | null
decimals: number
logoURI: string
balanceRaw: string
balanceLabel: string
priceUsd?: number
balanceUsd?: number
liquidityUsd?: number
pricingKind?: string
marketSource?: string
metaMaskAddable: boolean
}
function formatListingUsd(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return 'USD unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
export function formatFundedRowUnitPrice(row: Pick<FundedWalletTokenRow, 'priceUsd' | 'pricingKind'>): string {
if (row.pricingKind === 'lp-share') return 'LP share — USD N/A'
if (row.priceUsd == null) return 'USD unavailable'
return formatListingUsd(row.priceUsd)
}
export function formatFundedRowBalanceUsd(row: Pick<FundedWalletTokenRow, 'balanceUsd' | 'pricingKind'>): string {
if (row.pricingKind === 'lp-share') return 'LP share — USD N/A'
if (row.balanceUsd == null) return 'USD unavailable'
return `${formatListingUsd(row.balanceUsd)}`
}
function marketMapFromSnapshots(snapshots: TokenAggregationTokenSnapshot[]): Map<string, TokenAggregationTokenSnapshot> {
const map = new Map<string, TokenAggregationTokenSnapshot>()
for (const snapshot of snapshots) {
if (snapshot.address) map.set(snapshot.address.toLowerCase(), snapshot)
}
return map
}
/** On-chain funded rows for native ETH + catalog ERC-20 entries with balance &gt; 0. */
export async function buildFundedWalletTokenRows(
provider: EthereumRpcProvider,
walletAddress: string,
catalogTokens: FundedWalletCatalogToken[],
options?: {
nativeLogoUri?: string
onProgress?: (current: number, total: number) => void
},
): Promise<FundedWalletTokenRow[]> {
const nativeLogoUri = options?.nativeLogoUri || CHAIN138_NATIVE_ETH_LOGO
const nativeWei = await readNativeBalanceRaw(provider, walletAddress)
const fundedErc20 = await listTokensWithNonZeroBalance(
provider,
walletAddress,
catalogTokens,
options?.onProgress,
)
const rows: FundedWalletTokenRow[] = []
if (nativeWei > 0n) {
rows.push({
kind: 'native',
symbol: 'ETH',
name: 'Ether',
address: null,
decimals: 18,
logoURI: nativeLogoUri,
balanceRaw: nativeWei.toString(),
balanceLabel: formatNativeEthFromWei(nativeWei),
metaMaskAddable: false,
})
}
for (const token of fundedErc20) {
rows.push({
kind: 'erc20',
symbol: token.symbol,
name: token.name,
address: token.address,
decimals: token.decimals,
logoURI: token.logoURI || CHAIN138_NATIVE_ETH_LOGO,
balanceRaw: token.balanceRaw,
balanceLabel: formatTokenAmount(token.balanceRaw, token.decimals, token.symbol, 6),
metaMaskAddable: true,
})
}
return rows
}
/** Attach token-aggregation spot prices and USD notionals to funded rows. */
export async function enrichFundedWalletTokenRowsWithMarket(
rows: FundedWalletTokenRow[],
chainId = 138,
): Promise<FundedWalletTokenRow[]> {
if (rows.length === 0) return rows
const erc20Addresses = rows
.filter((row) => row.kind === 'erc20' && row.address)
.map((row) => row.address as string)
const [nativeMarket, erc20Market] = await Promise.all([
getNativeAssetMarketSafe(chainId),
tokenAggregationApi.getTokensByAddressSafe(chainId, erc20Addresses),
])
const markets = marketMapFromSnapshots(erc20Market.ok ? erc20Market.data : [])
const nativePriceUsd = nativeMarket.ok ? nativeMarket.data?.market?.priceUsd : undefined
return rows.map((row) => {
if (row.kind === 'native') {
const balanceUsdText = estimateNativeUsdValue(row.balanceRaw, nativePriceUsd)
return {
...row,
priceUsd: nativePriceUsd,
balanceUsd: balanceUsdText != null ? Number(balanceUsdText) : undefined,
liquidityUsd: nativeMarket.data?.market?.liquidityUsd,
pricingKind: nativeMarket.data?.market?.pricingKind,
marketSource: nativeMarket.ok && nativeMarket.data?.market ? 'token-aggregation' : undefined,
}
}
const market = row.address ? markets.get(row.address.toLowerCase())?.market : undefined
const priceUsd = market?.priceUsd
const balanceUsd = estimateTokenBalanceUsd(row.balanceRaw, row.decimals, priceUsd)
return {
...row,
priceUsd,
balanceUsd,
liquidityUsd: market?.liquidityUsd,
pricingKind: market?.pricingKind,
marketSource: market ? 'token-aggregation' : undefined,
}
})
}
export function sortFundedWalletTokenRows(rows: FundedWalletTokenRow[]): FundedWalletTokenRow[] {
return [...rows].sort((left, right) => {
if (left.kind === 'native') return -1
if (right.kind === 'native') return 1
const leftUsd = left.balanceUsd ?? 0
const rightUsd = right.balanceUsd ?? 0
if (rightUsd !== leftUsd) return rightUsd - leftUsd
return left.symbol.localeCompare(right.symbol)
})
}
export function fundedRowsToWatchCatalogTokens(rows: FundedWalletTokenRow[]): FundedWalletCatalogToken[] {
return rows
.filter((row) => row.kind === 'erc20' && row.address && row.metaMaskAddable)
.map((row) => ({
chainId: 138,
address: row.address as string,
symbol: row.symbol,
name: row.name,
decimals: row.decimals,
logoURI: row.logoURI,
}))
}
export async function loadFundedWalletTokenListing(
provider: EthereumRpcProvider,
walletAddress: string,
catalogTokens: FundedWalletCatalogToken[],
options?: {
nativeLogoUri?: string
onProgress?: (current: number, total: number) => void
},
): Promise<FundedWalletTokenRow[]> {
const rows = await buildFundedWalletTokenRows(provider, walletAddress, catalogTokens, options)
const enriched = await enrichFundedWalletTokenRowsWithMarket(rows)
return sortFundedWalletTokenRows(enriched)
}

View File

@@ -0,0 +1,29 @@
import {
buildMetaMaskMobileDappUrl,
getWatchAssetBatchSize,
isMobileBrowser,
isWalletInAppBrowser,
} from '@/utils/walletProviderEnv'
describe('walletProviderEnv', () => {
it('detects mobile user agents', () => {
expect(isMobileBrowser('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)')).toBe(true)
expect(isMobileBrowser('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe(false)
})
it('detects wallet in-app browsers', () => {
expect(isWalletInAppBrowser('Mozilla/5.0 MetaMaskMobile')).toBe(true)
expect(isWalletInAppBrowser('Mozilla/5.0 Chrome/120')).toBe(false)
})
it('builds MetaMask mobile dapp links without protocol prefix', () => {
expect(buildMetaMaskMobileDappUrl('https://explorer.d-bis.org/wallet')).toBe(
'https://metamask.app.link/dapp/explorer.d-bis.org/wallet',
)
})
it('uses smaller watch-asset batches on mobile contexts', () => {
expect(getWatchAssetBatchSize(true)).toBe(2)
expect(getWatchAssetBatchSize(false)).toBe(Number.POSITIVE_INFINITY)
})
})

View File

@@ -0,0 +1,106 @@
/** Shared detection + provider resolution for mobile wallets and in-app browsers. */
export type EthereumProvider = {
request: (args: { method: string; params?: unknown }) => Promise<unknown>
isMetaMask?: boolean
providers?: EthereumProvider[]
}
/** Accept WalletConnect / injected providers with slightly different request typings. */
export function asWalletEthereumProvider(provider: unknown): EthereumProvider | undefined {
if (!provider || typeof provider !== 'object') return undefined
const candidate = provider as { request?: unknown }
if (typeof candidate.request !== 'function') return undefined
return provider as EthereumProvider
}
const MOBILE_UA_RE = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i
export function isMobileUserAgent(userAgent: string): boolean {
return MOBILE_UA_RE.test(userAgent)
}
/** True for phone/tablet browsers (not a guarantee of in-app wallet). */
export function isMobileBrowser(userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''): boolean {
return isMobileUserAgent(userAgent)
}
export function isWalletInAppBrowser(userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''): boolean {
const ua = userAgent.toLowerCase()
return (
ua.includes('metamask') ||
ua.includes('trust') ||
ua.includes('imtoken') ||
ua.includes('coinbase') ||
ua.includes('rainbow') ||
ua.includes('status') ||
ua.includes('phantom') ||
ua.includes('zerion')
)
}
export function isMobileWalletContext(userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''): boolean {
return isMobileBrowser(userAgent) || isWalletInAppBrowser(userAgent)
}
export function resolveInjectedEthereumProvider(
win: Window & { ethereum?: EthereumProvider } = typeof window !== 'undefined'
? (window as Window & { ethereum?: EthereumProvider })
: ({} as Window & { ethereum?: EthereumProvider }),
): EthereumProvider | undefined {
const eth = win.ethereum
if (!eth) return undefined
const providers = Array.isArray(eth.providers) ? eth.providers.filter(Boolean) : []
if (providers.length > 0) {
const metamask = providers.find((provider) => provider.isMetaMask)
return metamask || providers[0]
}
return eth
}
export function resolveWalletEthereumProvider(
walletConnectProvider?: unknown,
win: Window & { ethereum?: EthereumProvider } = typeof window !== 'undefined'
? (window as Window & { ethereum?: EthereumProvider })
: ({} as Window & { ethereum?: EthereumProvider }),
): EthereumProvider | undefined {
return resolveInjectedEthereumProvider(win) ?? asWalletEthereumProvider(walletConnectProvider)
}
/** MetaMask mobile deep link — opens the in-app dApp browser (required for EIP-747 on mobile Safari/Chrome). */
export function buildMetaMaskMobileDappUrl(pageUrl: string): string {
try {
const parsed = new URL(pageUrl)
const hostPath = `${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`
return `https://metamask.app.link/dapp/${hostPath.replace(/^\/+/, '')}`
} catch {
const stripped = pageUrl.replace(/^https?:\/\//i, '')
return `https://metamask.app.link/dapp/${stripped}`
}
}
export function buildCoinbaseWalletDappUrl(pageUrl: string): string {
return `https://go.cb-w.com/dapp?cb_url=${encodeURIComponent(pageUrl)}`
}
/** Delay between sequential wallet_watchAsset prompts — mobile wallets reject rapid-fire RPC. */
export function getWatchAssetInterPromptDelayMs(mobile = isMobileWalletContext()): number {
return mobile ? 650 : 120
}
/** Tokens per user gesture on mobile; desktop runs the full list in one click. */
export function getWatchAssetBatchSize(mobile = isMobileWalletContext()): number {
return mobile ? 2 : Number.POSITIVE_INFINITY
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = typeof window !== 'undefined' ? window.setTimeout : setTimeout
timer(resolve, ms)
})
}
export const MOBILE_WALLET_BUTTON_CLASS =
'min-h-[44px] touch-manipulation select-none active:scale-[0.98]'

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
encodeErc20BalanceOfCall,
filterTokensWithNonZeroBalance,
formatNativeEthFromWei,
parseHexBigInt,
} from './walletTokenBalances'
describe('walletTokenBalances', () => {
it('encodes balanceOf calldata', () => {
expect(
encodeErc20BalanceOfCall('0x4A666F96fC8764181194447A7dFdb7d471b301C8'),
).toBe(
'0x70a08231' +
'0000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c8',
)
})
it('parses hex bigint values', () => {
expect(parseHexBigInt('0x0')).toBe(0n)
expect(parseHexBigInt('0x1')).toBe(1n)
expect(parseHexBigInt('0x')).toBe(0n)
})
it('filters tokens with non-zero balance and skips undeployed contracts', async () => {
const tokens = [
{ address: '0x0000000000000000000000000000000000000001', symbol: 'A' },
{ address: '0x0000000000000000000000000000000000000002', symbol: 'B' },
{ address: '0x0000000000000000000000000000000000000003', symbol: 'C' },
]
const provider = {
request: async ({ method, params }: { method: string; params?: unknown }) => {
if (method === 'eth_getCode') {
const [addr] = params as [string]
if (addr.endsWith('0003')) return '0x'
return '0x6000'
}
if (method === 'eth_call') {
const [call] = params as [{ to: string }]
if (call.to.endsWith('0001')) return '0x0'
if (call.to.endsWith('0002')) return '0x2a'
return '0x0'
}
throw new Error(`unexpected ${method}`)
},
}
const funded = await filterTokensWithNonZeroBalance(
provider,
'0x4A666F96fC8764181194447A7dFdb7d471b301C8',
tokens,
)
expect(funded.map((token) => token.symbol)).toEqual(['B'])
})
it('formats native ETH from wei', () => {
expect(formatNativeEthFromWei(10n ** 18n)).toBe('1 ETH')
expect(formatNativeEthFromWei(1500000000000000000n)).toBe('1.5 ETH')
})
})

View File

@@ -0,0 +1,109 @@
export type EthereumRpcProvider = {
request: (args: { method: string; params?: unknown }) => Promise<unknown>
}
export type WalletBalanceToken = {
address: string
}
const BALANCE_OF_SELECTOR = '0x70a08231'
/** ERC-20 balanceOf(address) calldata for eth_call. */
export function encodeErc20BalanceOfCall(walletAddress: string): string {
const addr = walletAddress.toLowerCase().replace(/^0x/, '')
if (!/^[\da-f]{40}$/.test(addr)) {
throw new Error('invalid wallet address')
}
return BALANCE_OF_SELECTOR + addr.padStart(64, '0')
}
export function parseHexBigInt(value: unknown): bigint {
if (typeof value !== 'string' || !value.startsWith('0x')) return 0n
if (value === '0x') return 0n
return BigInt(value)
}
export async function hasContractCode(
provider: EthereumRpcProvider,
tokenAddress: string,
): Promise<boolean> {
const code = (await provider.request({
method: 'eth_getCode',
params: [tokenAddress, 'latest'],
})) as string
return typeof code === 'string' && code.length > 2
}
export async function readErc20BalanceRaw(
provider: EthereumRpcProvider,
tokenAddress: string,
walletAddress: string,
): Promise<bigint> {
const result = (await provider.request({
method: 'eth_call',
params: [{ to: tokenAddress, data: encodeErc20BalanceOfCall(walletAddress) }, 'latest'],
})) as string
return parseHexBigInt(result)
}
export type WalletBalanceTokenWithRaw<T extends WalletBalanceToken = WalletBalanceToken> = T & {
balanceRaw: string
}
/** Like filterTokensWithNonZeroBalance but retains raw balances for listings. */
export async function listTokensWithNonZeroBalance<T extends WalletBalanceToken>(
provider: EthereumRpcProvider,
walletAddress: string,
tokens: T[],
onProgress?: (current: number, total: number) => void,
): Promise<Array<WalletBalanceTokenWithRaw<T>>> {
const funded: Array<WalletBalanceTokenWithRaw<T>> = []
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index]
onProgress?.(index + 1, tokens.length)
const deployed = await hasContractCode(provider, token.address)
if (!deployed) continue
const balance = await readErc20BalanceRaw(provider, token.address, walletAddress)
if (balance > 0n) {
funded.push({ ...token, balanceRaw: balance.toString() })
}
}
return funded
}
export async function readNativeBalanceRaw(
provider: EthereumRpcProvider,
walletAddress: string,
): Promise<bigint> {
const result = (await provider.request({
method: 'eth_getBalance',
params: [walletAddress, 'latest'],
})) as string
return parseHexBigInt(result)
}
/** Keep catalog tokens whose on-chain ERC-20 balance is &gt; 0 (skip undeployed addresses). */
export async function filterTokensWithNonZeroBalance<T extends WalletBalanceToken>(
provider: EthereumRpcProvider,
walletAddress: string,
tokens: T[],
onProgress?: (current: number, total: number) => void,
): Promise<T[]> {
const funded = await listTokensWithNonZeroBalance(provider, walletAddress, tokens, onProgress)
return funded.map(({ balanceRaw: _ignored, ...token }) => token as unknown as T)
}
export function formatNativeEthFromWei(wei: bigint, maxFractionDigits = 4): string {
const whole = wei / 10n ** 18n
const fraction = wei % 10n ** 18n
if (fraction === 0n) return `${whole.toLocaleString()} ETH`
const fractionStr = fraction.toString().padStart(18, '0').replace(/0+$/, '')
const trimmed = fractionStr.slice(0, maxFractionDigits).replace(/0+$/, '')
if (!trimmed) return `${whole.toLocaleString()} ETH`
return `${whole.toLocaleString()}.${trimmed} ETH`
}

View File

@@ -0,0 +1,26 @@
import { buildWatchAssetRpcRequest } from '@/utils/walletWatchAsset'
describe('buildWatchAssetRpcRequest', () => {
const token = {
chainId: 138,
address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
symbol: 'cUSDT',
decimals: 6,
logoURI: 'https://example.com/cusdt.svg',
}
it('uses numeric chainId on Ethereum mainnet desktop', () => {
const req = buildWatchAssetRpcRequest({ ...token, chainId: 1 }, false)
expect(req.params.options.chainId).toBe(1)
})
it('uses hex chainId on Chain 138 desktop', () => {
const req = buildWatchAssetRpcRequest(token, false)
expect(req.params.options.chainId).toBe('0x8a')
})
it('uses hex chainId on mobile', () => {
const req = buildWatchAssetRpcRequest(token, true)
expect(req.params.options.chainId).toBe('0x8a')
})
})

View File

@@ -0,0 +1,102 @@
import {
getWatchAssetBatchSize,
getWatchAssetInterPromptDelayMs,
isMobileWalletContext,
sleep,
type EthereumProvider,
} from '@/utils/walletProviderEnv'
export type WatchAssetToken = {
chainId: number
address: string
symbol: string
decimals: number
logoURI?: string
}
export function buildWatchAssetRpcRequest(token: WatchAssetToken, mobile = isMobileWalletContext()) {
const options: {
address: string
symbol: string
decimals: number
image?: string
chainId?: number | string
} = {
address: token.address,
symbol: token.symbol,
decimals: token.decimals,
}
if (token.logoURI) {
options.image = token.logoURI
}
// Custom networks (Chain 138, ALL Mainnet, …) need explicit hex chainId or MetaMask may
// attach the token to the wrong network and balances disappear after refresh.
options.chainId =
token.chainId === 1 && !mobile ? token.chainId : `0x${token.chainId.toString(16)}`
return {
method: 'wallet_watchAsset' as const,
params: {
type: 'ERC20' as const,
options,
},
}
}
export type WatchAssetBatchResult = {
addedCount: number
nextIndex: number
stoppedEarly: boolean
errorMessage?: string
}
/**
* Process up to `batchSize` wallet_watchAsset prompts starting at `startIndex`.
* Call again from a fresh button click when `nextIndex < tokens.length` on mobile.
*/
export async function runWatchAssetBatch(
provider: EthereumProvider,
tokens: WatchAssetToken[],
startIndex: number,
options?: {
batchSize?: number
mobile?: boolean
onProgress?: (current: number, total: number) => void
},
): Promise<WatchAssetBatchResult> {
const mobile = options?.mobile ?? isMobileWalletContext()
const batchSize = options?.batchSize ?? getWatchAssetBatchSize(mobile)
const delayMs = getWatchAssetInterPromptDelayMs(mobile)
const endIndex = Math.min(tokens.length, startIndex + batchSize)
let addedCount = 0
for (let index = startIndex; index < endIndex; index += 1) {
const token = tokens[index]
options?.onProgress?.(index + 1, tokens.length)
if (index > startIndex && delayMs > 0) {
await sleep(delayMs)
}
try {
const added = await provider.request(buildWatchAssetRpcRequest(token, mobile))
if (added) addedCount += 1
} catch (e) {
const err = e as { message?: string }
return {
addedCount,
nextIndex: index,
stoppedEarly: true,
errorMessage: err.message || `Stopped while adding ${token.symbol}.`,
}
}
}
return {
addedCount,
nextIndex: endIndex,
stoppedEarly: false,
}
}

View File

@@ -0,0 +1,40 @@
import {
CHAIN138_PLACEHOLDER_GAS_SYMBOLS,
dedupeWalletWatchTokens,
isDeterministicPlaceholderAddress,
isWalletWatchEligibleAddress,
} from '@/utils/walletWatchEligible'
describe('walletWatchEligible', () => {
it('detects GRU transport placeholders on Chain 138', () => {
expect(isDeterministicPlaceholderAddress('0xcaaa00000000000000000000000000000000008a')).toBe(true)
expect(isDeterministicPlaceholderAddress('0x93E66202A11B1772E55407B32B44e5Cd8eda7f22')).toBe(false)
})
it('marks live canonical addresses as wallet-eligible', () => {
expect(isWalletWatchEligibleAddress('0xf22258f57794CC8E06237084b353Ab30fFfa640b')).toBe(true)
expect(isWalletWatchEligibleAddress('0xcaaa00000000000000000000000000000000008a')).toBe(false)
})
it('documents the nine gas-family placeholder symbols', () => {
expect(CHAIN138_PLACEHOLDER_GAS_SYMBOLS).toHaveLength(9)
})
it('dedupes wallet watch tokens by symbol preferring live deployments', () => {
const deduped = dedupeWalletWatchTokens([
{
chainId: 138,
symbol: 'cUSDC',
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
},
{
chainId: 138,
symbol: 'cUSDC',
address: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d',
extensions: { deploymentVersion: 'v2', deploymentStatus: 'staged' },
},
])
expect(deduped).toHaveLength(1)
expect(deduped[0]?.address).toBe('0xf22258f57794CC8E06237084b353Ab30fFfa640b')
})
})

View File

@@ -0,0 +1,55 @@
/** GRU transport placeholders (e.g. 0xcaaa…008a) — not deployed ERC-20 contracts. */
export function isDeterministicPlaceholderAddress(address: string): boolean {
const normalized = address.toLowerCase()
return /^0x[a-f0-9]{4}0{24,}[a-f0-9]{1,8}$/.test(normalized)
}
export function isWalletWatchEligibleAddress(address?: string | null): boolean {
if (!address) return false
return !isDeterministicPlaceholderAddress(address)
}
export type WalletWatchTokenLike = {
chainId?: number
address?: string
symbol?: string
extensions?: Record<string, unknown>
}
function walletWatchTokenRank(token: WalletWatchTokenLike): number {
let rank = 0
const extensions = token.extensions || {}
const status = String(extensions.deploymentStatus || '').toLowerCase()
const version = String(extensions.deploymentVersion || '').toLowerCase()
if (status === 'staged') rank += 100
if (version === 'v2') rank += 50
return rank
}
/** MetaMask stores one custom token row per symbol — keep the live contract address. */
export function dedupeWalletWatchTokens<T extends WalletWatchTokenLike>(tokens: T[]): T[] {
const bySymbol = new Map<string, T>()
for (const token of tokens) {
if (!isWalletWatchEligibleAddress(token.address)) continue
const key = String(token.symbol || '').trim().toLowerCase()
if (!key) continue
const existing = bySymbol.get(key)
if (!existing || walletWatchTokenRank(token) < walletWatchTokenRank(existing)) {
bySymbol.set(key, token)
}
}
return Array.from(bySymbol.values())
}
/** Gas-family roadmap symbols that use deterministic placeholder bindings on Chain 138. */
export const CHAIN138_PLACEHOLDER_GAS_SYMBOLS = [
'cAVAX',
'cBNB',
'cCELO',
'cCRO',
'cETH',
'cETHL2',
'cPOL',
'cWEMIX',
'cXDAI',
] as const

View File

@@ -0,0 +1,90 @@
import registryDoc from '../../../config/web3-identity-registry.v1.json'
export interface Web3IdentityEntry {
id: string
address: string
chainIds: number[]
roles: string[]
displayName: string
ens?: { primary: string; chainId: number } | null
identifiers?: Array<{ type: string; value: string; entityRef?: string; note?: string }>
explorer?: { blockscoutLabel?: string | null; tagTypes?: string[] }
eiLabel?: string | null
}
export interface Web3IdentityEntity {
id: string
displayName: string
lei: string
entityRef: string
roles: string[]
}
export interface Web3IdentityRegistry {
schemaVersion: string
entries: Web3IdentityEntry[]
entities?: Web3IdentityEntity[]
}
const registry = registryDoc as Web3IdentityRegistry
const byAddress = new Map<string, Web3IdentityEntry>()
const byEns = new Map<string, Web3IdentityEntry>()
const byId = new Map<string, Web3IdentityEntry>()
const byLei = new Map<string, Web3IdentityEntity>()
for (const entry of registry.entries) {
const lower = entry.address.toLowerCase()
if (lower === `0x${'0'.repeat(40)}`) continue
byAddress.set(lower, entry)
byId.set(entry.id, entry)
const ensName = entry.ens?.primary?.toLowerCase()
if (ensName) byEns.set(ensName, entry)
}
for (const entity of registry.entities ?? []) {
byLei.set(entity.lei, entity)
}
export function getRegistryEntryByAddress(address: string): Web3IdentityEntry | undefined {
return byAddress.get(address.toLowerCase())
}
export function getRegistryEntryByEns(name: string): Web3IdentityEntry | undefined {
return byEns.get(name.trim().toLowerCase())
}
export function getRegistryEntryById(id: string): Web3IdentityEntry | undefined {
return byId.get(id)
}
export function getEntityByLei(lei: string): Web3IdentityEntity | undefined {
return byLei.get(lei.trim().toUpperCase())
}
/** Registry blockscoutLabel or displayName — not live ENS. */
export function getKnownDisplayName(address: string): string | undefined {
const entry = getRegistryEntryByAddress(address)
if (!entry) return undefined
return entry.explorer?.blockscoutLabel || entry.displayName
}
export function resolveAddressFromRegistryEns(name: string): string | undefined {
return getRegistryEntryByEns(name)?.address
}
export function searchRegistryByQuery(query: string): Web3IdentityEntry[] {
const q = query.trim().toLowerCase()
if (!q) return []
return registry.entries.filter((entry) => {
if (entry.address.toLowerCase() === q) return true
if (entry.displayName.toLowerCase().includes(q)) return true
if (entry.ens?.primary.toLowerCase() === q) return true
if (entry.explorer?.blockscoutLabel?.toLowerCase().includes(q)) return true
return entry.identifiers?.some(
(id) => id.value?.toLowerCase() === q || id.value?.toLowerCase().includes(q),
)
})
}
export { registry as web3IdentityRegistry }

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Runs INSIDE VMID 5000. Idempotent nginx guard for Next.js /addresses/* and catch-all /.
# Installed to /usr/local/bin by explorer-monorepo/scripts/cron/install-explorer-cron.sh
# and proxmox/scripts/deployment/patch-explorer-nginx-next-routes.sh.
set -euo pipefail
SITE="${EXPLORER_NGINX_SITE:-/etc/nginx/sites-enabled/blockscout}"
AVAILABLE="${EXPLORER_NGINX_AVAILABLE:-/etc/nginx/sites-available/blockscout}"
BACKUP_DIR="${EXPLORER_NGINX_BACKUP_DIR:-/etc/nginx/sites-available/backups}"
PROBE_PATH="${EXPLORER_NGINX_PROBE_PATH:-/addresses/0x582b82fbf721348ee490487dc8d99846a687806d}"
MODE="${1:-repair}"
mkdir -p "$BACKUP_DIR"
for stray in /etc/nginx/sites-enabled/blockscout.bak.*; do
[ -e "$stray" ] || continue
mv "$stray" "$BACKUP_DIR/$(basename "$stray")"
done
verify_nginx_routes() {
local code
code="$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 5 \
-H 'Host: explorer.d-bis.org' \
-H 'X-Forwarded-Proto: https' \
"http://127.0.0.1${PROBE_PATH}" 2>/dev/null || echo '000')"
[ "$code" = "200" ]
}
apply_nginx_routes() {
if [ ! -f "$SITE" ]; then
echo "ensure-explorer-nginx-next-routes: missing $SITE" >&2
return 1
fi
cp "$SITE" "${BACKUP_DIR}/blockscout.bak.next-routes-$(date +%Y%m%d%H%M%S)"
python3 - <<'PY'
from pathlib import Path
site = Path("/etc/nginx/sites-enabled/blockscout")
text = site.read_text()
needle = "location ~ ^/(address|tx|"
replacement = "location ~ ^/(address|addresses|tx|"
if "address|addresses|" not in text:
if needle not in text:
raise SystemExit("nginx app-route regex anchor not found")
text = text.replace(needle, replacement, 1)
catch_all = '''
# Catch-all goes to the Next frontend after API/static exclusions.
location / {
if ($redirect_http_to_https = 1) { return 301 https://$host$request_uri; }
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_buffering off;
proxy_hide_header Cache-Control;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
'''
if "Catch-all goes to the Next frontend" not in text:
marker = "\n}\n\n\nmap $http_upgrade $connection_upgrade {"
if marker not in text:
marker = "\n}\n\nmap $http_upgrade $connection_upgrade {"
if marker not in text:
raise SystemExit("server block closing anchor not found")
text = text.replace(marker, catch_all + marker, 1)
site.write_text(text)
Path("/etc/nginx/sites-available/blockscout").write_text(text)
print("nginx: ensured /addresses route and Next.js catch-all")
PY
nginx -t
systemctl reload nginx
}
case "$MODE" in
check)
verify_nginx_routes
;;
repair)
if verify_nginx_routes; then
exit 0
fi
apply_nginx_routes
verify_nginx_routes
;;
force)
apply_nginx_routes
verify_nginx_routes
;;
*)
echo "Usage: $0 [check|repair|force]" >&2
exit 2
;;
esac

View File

@@ -13,6 +13,15 @@ log() { echo "$(date -Iseconds) $*" >> "$LOG" 2>/dev/null || true; }
# 1) Ensure PostgreSQL is running
docker start blockscout-postgres 2>/dev/null || true
# 1b) Keep explorer-config-api DATABASE_URL aligned with blockscout-postgres bridge IP
if [ -x /usr/local/bin/sync-explorer-config-api-database-url.sh ]; then
if /usr/local/bin/sync-explorer-config-api-database-url.sh >>"$LOG" 2>&1; then
:
else
log "sync-explorer-config-api-database-url failed"
fi
fi
# 2) Blockscout API health: if not 200, restart or start container
CODE=$(curl -sS -o /dev/null -w "%{http_code}" --connect-timeout 5 http://127.0.0.1:4000/api/v2/stats 2>/dev/null || echo "000")
if [ "$CODE" != "200" ]; then
@@ -35,6 +44,14 @@ if [ "$NGINX" != "active" ]; then
systemctl start nginx 2>>"$LOG" || true
fi
# 3b) Next.js route guard: /addresses/* must proxy to port 3000 (not nginx 404)
if [ -x /usr/local/bin/ensure-explorer-nginx-next-routes.sh ]; then
if ! /usr/local/bin/ensure-explorer-nginx-next-routes.sh check >>"$LOG" 2>&1; then
log "Explorer nginx /addresses route broken; repairing"
/usr/local/bin/ensure-explorer-nginx-next-routes.sh repair >>"$LOG" 2>&1 || log "ensure-explorer-nginx-next-routes repair failed"
fi
fi
# 4) Safe disk prune (only if env RUN_PRUNE=1 or first run on Sunday 3am - use cron for daily)
# Do NOT prune containers (would remove stopped Blockscout). Prune only unused images and build cache.
if [ "${RUN_PRUNE:-0}" = "1" ]; then

View File

@@ -31,16 +31,32 @@ fi
EXEC_PREFIX="pct exec $VMID --"
MAINTAIN_SCRIPT="/usr/local/bin/explorer-maintain.sh"
SYNC_DB_SCRIPT="/usr/local/bin/sync-explorer-config-api-database-url.sh"
SYNC_SRC="$REPO_ROOT/scripts/sync-explorer-config-api-database-url.sh"
NGINX_ENSURE_SRC="$SCRIPT_DIR/ensure-explorer-nginx-next-routes.sh"
NGINX_ENSURE_SCRIPT="/usr/local/bin/ensure-explorer-nginx-next-routes.sh"
echo "=============================================="
echo "Install explorer maintenance cron (VMID $VMID)"
echo "=============================================="
# Copy script into VM
# Copy scripts into VM
pct push $VMID "$SCRIPT_DIR/explorer-maintain.sh" "$MAINTAIN_SCRIPT"
$EXEC_PREFIX chmod +x "$MAINTAIN_SCRIPT"
echo "✅ Installed $MAINTAIN_SCRIPT"
if [ -f "$SYNC_SRC" ]; then
pct push $VMID "$SYNC_SRC" "$SYNC_DB_SCRIPT"
$EXEC_PREFIX chmod +x "$SYNC_DB_SCRIPT"
echo "✅ Installed $SYNC_DB_SCRIPT"
fi
if [ -f "$NGINX_ENSURE_SRC" ]; then
pct push $VMID "$NGINX_ENSURE_SRC" "$NGINX_ENSURE_SCRIPT"
$EXEC_PREFIX chmod +x "$NGINX_ENSURE_SCRIPT"
echo "✅ Installed $NGINX_ENSURE_SCRIPT"
fi
# Install crontab (append to existing)
$EXEC_PREFIX bash -c '(crontab -l 2>/dev/null | grep -v explorer-maintain | grep -v /usr/local/bin/explorer-maintain.sh || true; echo "# explorer-maintain"; echo "*/5 * * * * /usr/local/bin/explorer-maintain.sh >> /var/log/explorer-maintain.log 2>&1"; echo "15 3 * * * RUN_PRUNE=1 /usr/local/bin/explorer-maintain.sh >> /var/log/explorer-maintain.log 2>&1") | crontab -'
echo "✅ Cron installed:"

View File

@@ -32,6 +32,7 @@ STATIC_SYNC_FILES=(
"terms.html"
"acknowledgments.html"
"chain138-command-center.html"
"chain138-command-center.meta.json"
"favicon.ico"
"apple-touch-icon.png"
"explorer-spa.js"
@@ -105,6 +106,8 @@ acquire_build_lock() {
}
if [[ "${SKIP_BUILD:-0}" != "1" ]]; then
echo "== Refreshing command-center bundle metadata =="
bash "${REPO_ROOT}/scripts/refresh-chain138-command-center-meta.sh"
echo "== Building frontend =="
acquire_build_lock
rm -rf "${FRONTEND_ROOT}/.next"
@@ -214,6 +217,22 @@ echo "== Verification =="
run_in_vmid "systemctl is-active ${SERVICE_NAME}.service"
run_in_vmid "curl -fsS --max-time 5 http://127.0.0.1:${FRONTEND_PORT}/ | grep -qiE 'DBIS Explorer|Chain 138 Explorer by DBIS'"
echo "Service ${SERVICE_NAME} is running on 127.0.0.1:${FRONTEND_PORT}"
PATCH_NGINX="${WORKSPACE_ROOT}/proxmox/scripts/deployment/patch-explorer-nginx-next-routes.sh"
if [[ -f "${PATCH_NGINX}" ]]; then
echo ""
echo "== Ensure nginx proxies /addresses to Next.js =="
bash "${PATCH_NGINX}" || echo "WARN: nginx next-routes patch failed — run manually from proxmox repo"
fi
SMOKE_SCRIPT="${WORKSPACE_ROOT}/proxmox/scripts/verify/smoke-explorer-institutional-grade.sh"
if [[ -f "${SMOKE_SCRIPT}" ]]; then
echo ""
echo "== Institutional smoke gate =="
EXPLORER_BASE="${EXPLORER_BASE:-https://explorer.d-bis.org}" bash "${SMOKE_SCRIPT}" || {
echo "WARN: institutional smoke failed — see ${SMOKE_SCRIPT}" >&2
}
fi
echo ""
echo "Nginx follow-up:"
echo " Switch the explorer server block to proxy / and /_next/ to 127.0.0.1:${FRONTEND_PORT}"

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Refresh chain138-command-center.meta.json before deploy (bundle version + optional route-matrix stamp).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
META="${REPO_ROOT}/frontend/public/chain138-command-center.meta.json"
ROUTE_MATRIX="${REPO_ROOT}/config/aggregator-route-matrix.json"
python3 - "$META" "$ROUTE_MATRIX" <<'PY'
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
meta_path = Path(sys.argv[1])
route_matrix_path = Path(sys.argv[2])
now = datetime.now(timezone.utc)
route_updated = None
if route_matrix_path.is_file():
try:
data = json.loads(route_matrix_path.read_text(encoding="utf-8"))
route_updated = data.get("updated") or data.get("generatedAt")
except (json.JSONDecodeError, OSError):
pass
payload = {
"bundleVersion": now.strftime("%Y-%m-%d"),
"updatedAt": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"sourceDoc": "explorer-monorepo/docs/CHAIN138_VISUAL_TOPOLOGY_SOURCE.md",
"routeMatrixUpdated": route_updated,
}
meta_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print(f"Updated {meta_path}")
PY

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Sync explorer-config-api DATABASE_URL to the live blockscout-postgres container IP.
# Run inside VMID 5000 (or via pct exec). Safe to run from cron every 5 minutes.
set -euo pipefail
SERVICE="${EXPLORER_CONFIG_API_SERVICE:-explorer-config-api}"
DROPIN_DIR="/etc/systemd/system/${SERVICE}.service.d"
DROPIN_FILE="${DROPIN_DIR}/database.conf"
CONTAINER="${BLOCKSCOUT_POSTGRES_CONTAINER:-blockscout-postgres}"
DB_USER="${BLOCKSCOUT_DB_USER:-blockscout}"
DB_PASSWORD="${BLOCKSCOUT_DB_PASSWORD:-blockscout}"
DB_NAME="${BLOCKSCOUT_DB_NAME:-blockscout}"
if ! command -v docker >/dev/null 2>&1; then
echo "docker not available" >&2
exit 1
fi
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "postgres container not running: $CONTAINER" >&2
exit 1
fi
DB_IP="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CONTAINER" 2>/dev/null || true)"
if [ -z "$DB_IP" ]; then
echo "could not resolve IP for $CONTAINER" >&2
exit 1
fi
DESIRED_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_IP}:5432/${DB_NAME}?sslmode=disable"
CURRENT_URL=""
if [ -f "$DROPIN_FILE" ]; then
CURRENT_URL="$(grep -E '^Environment=DATABASE_URL=' "$DROPIN_FILE" | head -1 | sed 's/^Environment=DATABASE_URL=//' || true)"
fi
mkdir -p "$DROPIN_DIR"
if [ "$CURRENT_URL" = "$DESIRED_URL" ]; then
echo "DATABASE_URL already current ($DB_IP)"
exit 0
fi
cat > "$DROPIN_FILE" <<EOF
[Service]
Environment=DATABASE_URL=${DESIRED_URL}
EOF
chmod 600 "$DROPIN_FILE"
systemctl daemon-reload
systemctl restart "$SERVICE"
sleep 2
if ! systemctl is-active --quiet "$SERVICE"; then
echo "restart failed for $SERVICE" >&2
systemctl status "$SERVICE" --no-pager -l || true
exit 1
fi
echo "updated DATABASE_URL -> ${DB_IP} and restarted ${SERVICE}"

View File

@@ -169,7 +169,7 @@ else
fi
# 11) Visual Command Center contains expected explorer architecture content
if grep -qE 'Visual Command Center|Mission Control|mainnet cW mint corridor' /tmp/chain138-command-center.verify.$$ 2>/dev/null; then
if grep -qE 'Mission Control|mainnet cW mint corridor|Stack A|0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895|mainnet_weth' /tmp/chain138-command-center.verify.$$ 2>/dev/null; then
echo "$BASE_URL/chain138-command-center.html contains command-center content"
((PASS++)) || true
else
@@ -178,6 +178,27 @@ else
fi
rm -f /tmp/chain138-command-center.verify.$$
# 11b) Topology alias redirects to command center
TOPO_CODE="$(curl -sS -o /dev/null -w "%{http_code}" --connect-timeout 10 "$BASE_URL/topology" 2>/dev/null || echo 000)"
if [ "$TOPO_CODE" = "200" ] || [ "$TOPO_CODE" = "307" ] || [ "$TOPO_CODE" = "308" ]; then
echo "$BASE_URL/topology reachable ($TOPO_CODE)"
((PASS++)) || true
else
echo "$BASE_URL/topology returned $TOPO_CODE"
((FAIL++)) || true
fi
# 11c) Command center bundle metadata
META_CODE="$(curl -sS -o /tmp/chain138-command-center.meta.verify.$$ -w "%{http_code}" --connect-timeout 10 "$BASE_URL/chain138-command-center.meta.json" 2>/dev/null || echo 000)"
if [ "$META_CODE" = "200" ] && grep -q 'bundleVersion' /tmp/chain138-command-center.meta.verify.$$ 2>/dev/null; then
echo "$BASE_URL/chain138-command-center.meta.json returns bundle metadata"
((PASS++)) || true
else
echo "$BASE_URL/chain138-command-center.meta.json missing or invalid ($META_CODE)"
((FAIL++)) || true
fi
rm -f /tmp/chain138-command-center.meta.verify.$$
# 12) Mission Control SSE stream returns 200 with text/event-stream
MC_STREAM_HEADERS="/tmp/mission-control-stream.headers.$$"
MC_STREAM_BODY="/tmp/mission-control-stream.body.$$"