feat(explorer): dual-chain wallet metadata, native coin pricing, and UI refresh.
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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
11
.vscode/settings.json
vendored
Normal 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
23
backend/.vscode/settings.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": []}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
150
backend/api/rest/stats_coin_price.go
Normal file
150
backend/api/rest/stats_coin_price.go
Normal 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)
|
||||
}
|
||||
133
backend/api/rest/stats_coin_price_test.go
Normal file
133
backend/api/rest/stats_coin_price_test.go
Normal 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)
|
||||
}
|
||||
@@ -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": []}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
756
config/web3-identity-registry.v1.json
Normal file
756
config/web3-identity-registry.v1.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
35
docs/CHAIN138_VISUAL_TOPOLOGY_SOURCE.md
Normal file
35
docs/CHAIN138_VISUAL_TOPOLOGY_SOURCE.md
Normal 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`).
|
||||
@@ -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`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 -> 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 & 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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/public/chain138-command-center.meta.json
Normal file
6
frontend/public/chain138-command-center.meta.json
Normal 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
|
||||
}
|
||||
@@ -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, '"') + '">' +
|
||||
'<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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
18
frontend/public/thirdparty/README.md
vendored
18
frontend/public/thirdparty/README.md
vendored
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
56
frontend/src/components/common/ExplorerDocumentHead.tsx
Normal file
56
frontend/src/components/common/ExplorerDocumentHead.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.' },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
256
frontend/src/components/pools/PoolDetailPage.tsx
Normal file
256
frontend/src/components/pools/PoolDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
169
frontend/src/components/wallet/LpPositionPanel.tsx
Normal file
169
frontend/src/components/wallet/LpPositionPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
frontend/src/components/wallet/MobileWalletContextBanner.tsx
Normal file
49
frontend/src/components/wallet/MobileWalletContextBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
336
frontend/src/components/wallet/MultiChainWalletImport.tsx
Normal file
336
frontend/src/components/wallet/MultiChainWalletImport.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
frontend/src/components/wallet/WalletFundedTokenListing.tsx
Normal file
116
frontend/src/components/wallet/WalletFundedTokenListing.tsx
Normal 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 "-" 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
16
frontend/src/pages/pools/[address].tsx
Normal file
16
frontend/src/pages/pools/[address].tsx
Normal 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} />
|
||||
}
|
||||
174
frontend/src/pages/protocols/[id].tsx
Normal file
174
frontend/src/pages/protocols/[id].tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
frontend/src/pages/protocols/index.tsx
Normal file
96
frontend/src/pages/protocols/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface AddressTokenBalance {
|
||||
value: string
|
||||
holder_count?: number
|
||||
total_supply?: string
|
||||
exchange_rate?: string | number | null
|
||||
}
|
||||
|
||||
export interface AddressTokenTransfer {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
89
frontend/src/services/api/cwRegistry.ts
Normal file
89
frontend/src/services/api/cwRegistry.ts
Normal 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
|
||||
}
|
||||
88
frontend/src/services/api/liquidityPositions.ts
Normal file
88
frontend/src/services/api/liquidityPositions.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
68
frontend/src/services/api/officialProtocols.ts
Normal file
68
frontend/src/services/api/officialProtocols.ts
Normal 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
|
||||
}
|
||||
}
|
||||
59
frontend/src/services/api/polygonMapper.ts
Normal file
59
frontend/src/services/api/polygonMapper.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
92
frontend/src/services/api/tokenMapping.ts
Normal file
92
frontend/src/services/api/tokenMapping.ts
Normal 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
|
||||
}
|
||||
@@ -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
147
frontend/src/utils/ens.ts
Normal 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(' ')
|
||||
}
|
||||
86
frontend/src/utils/meshCounterparts.test.ts
Normal file
86
frontend/src/utils/meshCounterparts.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
205
frontend/src/utils/meshCounterparts.ts
Normal file
205
frontend/src/utils/meshCounterparts.ts
Normal 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
|
||||
}
|
||||
30
frontend/src/utils/pagination.test.ts
Normal file
30
frontend/src/utils/pagination.test.ts
Normal 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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
43
frontend/src/utils/pagination.ts
Normal file
43
frontend/src/utils/pagination.ts
Normal 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
|
||||
}
|
||||
31
frontend/src/utils/poolSearch.test.ts
Normal file
31
frontend/src/utils/poolSearch.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
88
frontend/src/utils/poolSearch.ts
Normal file
88
frontend/src/utils/poolSearch.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
18
frontend/src/utils/publicExplorer.test.ts
Normal file
18
frontend/src/utils/publicExplorer.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
109
frontend/src/utils/tokenMarket.ts
Normal file
109
frontend/src/utils/tokenMarket.ts
Normal 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
|
||||
}
|
||||
}
|
||||
63
frontend/src/utils/useDetailTabQuery.ts
Normal file
63
frontend/src/utils/useDetailTabQuery.ts
Normal 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 }
|
||||
}
|
||||
28
frontend/src/utils/useListPageQuery.test.ts
Normal file
28
frontend/src/utils/useListPageQuery.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
47
frontend/src/utils/useListPageQuery.ts
Normal file
47
frontend/src/utils/useListPageQuery.ts
Normal 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 }
|
||||
}
|
||||
45
frontend/src/utils/walletAddEthereumChain.ts
Normal file
45
frontend/src/utils/walletAddEthereumChain.ts
Normal 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
|
||||
}
|
||||
41
frontend/src/utils/walletChainCatalog.ts
Normal file
41
frontend/src/utils/walletChainCatalog.ts
Normal 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}`
|
||||
}
|
||||
48
frontend/src/utils/walletFundedTokenListing.test.ts
Normal file
48
frontend/src/utils/walletFundedTokenListing.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
206
frontend/src/utils/walletFundedTokenListing.ts
Normal file
206
frontend/src/utils/walletFundedTokenListing.ts
Normal 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 > 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)
|
||||
}
|
||||
29
frontend/src/utils/walletProviderEnv.test.ts
Normal file
29
frontend/src/utils/walletProviderEnv.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
106
frontend/src/utils/walletProviderEnv.ts
Normal file
106
frontend/src/utils/walletProviderEnv.ts
Normal 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]'
|
||||
62
frontend/src/utils/walletTokenBalances.test.ts
Normal file
62
frontend/src/utils/walletTokenBalances.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
109
frontend/src/utils/walletTokenBalances.ts
Normal file
109
frontend/src/utils/walletTokenBalances.ts
Normal 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 > 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`
|
||||
}
|
||||
26
frontend/src/utils/walletWatchAsset.test.ts
Normal file
26
frontend/src/utils/walletWatchAsset.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
102
frontend/src/utils/walletWatchAsset.ts
Normal file
102
frontend/src/utils/walletWatchAsset.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
40
frontend/src/utils/walletWatchEligible.test.ts
Normal file
40
frontend/src/utils/walletWatchEligible.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
55
frontend/src/utils/walletWatchEligible.ts
Normal file
55
frontend/src/utils/walletWatchEligible.ts
Normal 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
|
||||
90
frontend/src/utils/web3IdentityRegistry.ts
Normal file
90
frontend/src/utils/web3IdentityRegistry.ts
Normal 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 }
|
||||
103
scripts/cron/ensure-explorer-nginx-next-routes.sh
Executable file
103
scripts/cron/ensure-explorer-nginx-next-routes.sh
Executable 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
|
||||
@@ -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
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
35
scripts/refresh-chain138-command-center-meta.sh
Executable file
35
scripts/refresh-chain138-command-center-meta.sh
Executable 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
|
||||
58
scripts/sync-explorer-config-api-database-url.sh
Executable file
58
scripts/sync-explorer-config-api-database-url.sh
Executable 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}"
|
||||
@@ -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.$$"
|
||||
|
||||
Reference in New Issue
Block a user