feat(explorer): dynamic feeds, wallet SSR alignment, and detail pagination

Align wallet SSR with report token-list, dedupe featured v1 tokens, refresh home and wallet snapshots on a 60s cadence, and drive vanilla SPA chain add/watch from API metadata. Add shared pagination/tabs for address, token, and transaction pages, extend token aggregation helpers, and harden stats API with tests and health checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-05-22 17:58:27 -07:00
parent ca1394c579
commit 4b747f0309
23 changed files with 1030 additions and 166 deletions

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/explorer/backend/api/freshness"
"github.com/jackc/pgx/v5"
)
type explorerStats struct {
@@ -34,6 +35,14 @@ type explorerGasPrices struct {
type statsQueryFunc = freshness.QueryRowFunc
type statsErrorRow struct {
err error
}
func (r statsErrorRow) Scan(dest ...any) error {
return r.err
}
func queryNullableFloat64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*float64, error) {
var value sql.NullFloat64
if err := queryRow(ctx, query, args...).Scan(&value); err != nil {
@@ -191,23 +200,72 @@ func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc
return stats, nil
}
func loadExplorerStatsFallback(ctx context.Context, chainID int, cause error) explorerStats {
rpcURL := strings.TrimSpace(os.Getenv("RPC_URL"))
now := time.Now().UTC()
queryErr := fmt.Errorf("blockscout database unavailable")
if cause != nil {
queryErr = cause
}
queryRow := func(context.Context, string, ...any) pgx.Row {
return statsErrorRow{err: queryErr}
}
snapshot, completeness, sampling, diagnostics, err := freshness.BuildSnapshot(
ctx,
chainID,
queryRow,
func(ctx context.Context) (*freshness.Reference, error) {
return freshness.ProbeChainHead(ctx, rpcURL)
},
now,
nil,
nil,
)
if err != nil {
if sampling.Issues == nil {
sampling.Issues = map[string]string{}
}
sampling.Issues["fallback_freshness"] = err.Error()
}
if sampling.Issues == nil {
sampling.Issues = map[string]string{}
}
if cause != nil {
sampling.Issues["stats_database"] = cause.Error()
}
stats := explorerStats{
Freshness: snapshot,
Completeness: completeness,
Sampling: sampling,
Diagnostics: diagnostics,
}
if snapshot.ChainHead.BlockNumber != nil {
stats.LatestBlock = *snapshot.ChainHead.BlockNumber
}
return stats
}
// handleStats handles GET /api/v2/stats
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w)
return
}
if !s.requireDB(w) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stats, err := loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer stats are temporarily unavailable")
return
var stats explorerStats
if s.db == nil {
stats = loadExplorerStatsFallback(ctx, s.chainID, fmt.Errorf("database pool is not configured"))
} else {
var err error
stats, err = loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
if err != nil {
stats = loadExplorerStatsFallback(ctx, s.chainID, err)
}
}
w.Header().Set("Content-Type", "application/json")

View File

@@ -136,3 +136,33 @@ func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "query total transactions")
}
func TestLoadExplorerStatsFallbackUsesRPCHead(t *testing.T) {
rpc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Method string `json:"method"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
w.Header().Set("Content-Type", "application/json")
switch req.Method {
case "eth_blockNumber":
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x4d2"}`))
case "eth_getBlockByNumber":
ts := time.Now().Add(-3 * time.Second).Unix()
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x` + strconv.FormatInt(ts, 16) + `"}}`))
default:
http.Error(w, `{"jsonrpc":"2.0","id":1,"error":{"message":"unsupported"}}`, http.StatusBadRequest)
}
}))
defer rpc.Close()
t.Setenv("RPC_URL", rpc.URL)
stats := loadExplorerStatsFallback(context.Background(), 138, errors.New("database down"))
require.Equal(t, int64(1234), stats.LatestBlock)
require.NotNil(t, stats.Freshness.ChainHead.BlockNumber)
require.Equal(t, int64(1234), *stats.Freshness.ChainHead.BlockNumber)
require.Equal(t, freshness.CompletenessUnavailable, stats.Completeness.TransactionsFeed)
require.Contains(t, stats.Sampling.Issues, "stats_database")
require.Contains(t, stats.Sampling.Issues["latest_indexed_block"], "database down")
}

View File

@@ -11,6 +11,15 @@ describe('resolveExplorerApiBase', () => {
).toBe('https://blockscout.defi-oracle.io')
})
it('uses browser HTTPS origin when an explicit same-host HTTP value is present', () => {
expect(
resolveExplorerApiBase({
envValue: 'http://explorer.d-bis.org',
browserOrigin: 'https://explorer.d-bis.org',
})
).toBe('https://explorer.d-bis.org')
})
it('falls back to same-origin in the browser when env is empty', () => {
expect(
resolveExplorerApiBase({

View File

@@ -4,19 +4,35 @@ function normalizeApiBase(value: string | null | undefined): string {
return (value || '').trim().replace(/\/$/, '')
}
function preferBrowserOriginForSameHost(explicitBase: string, browserOrigin: string): string {
if (!explicitBase || !browserOrigin) return explicitBase
try {
const explicitUrl = new URL(explicitBase)
const browserUrl = new URL(browserOrigin)
if (explicitUrl.hostname === browserUrl.hostname && explicitUrl.protocol !== browserUrl.protocol) {
return browserOrigin
}
} catch {
return explicitBase
}
return explicitBase
}
export function resolveExplorerApiBase(options: {
envValue?: string | null
browserOrigin?: string | null
serverFallback?: string
} = {}): string {
const explicitBase = normalizeApiBase(options.envValue ?? process.env.NEXT_PUBLIC_API_URL ?? '')
if (explicitBase) {
return explicitBase
}
const browserOrigin = normalizeApiBase(
options.browserOrigin ?? (typeof window !== 'undefined' ? window.location.origin : '')
)
if (explicitBase) {
return preferBrowserOriginForSameHost(explicitBase, browserOrigin)
}
if (browserOrigin) {
return browserOrigin
}

View File

@@ -2100,6 +2100,47 @@
}
}
async function fetchChain138AddEthereumChainPayload() {
if (window._chain138AddEthereumChainCache) return window._chain138AddEthereumChainCache;
try {
var res = await fetch('/api/v1/config/metamask?chainId=138', { cache: 'no-store' });
if (res.ok) {
var data = await res.json();
if (data && data.addEthereumChain) {
window._chain138AddEthereumChainCache = data.addEthereumChain;
return window._chain138AddEthereumChainCache;
}
}
} catch (e) {}
return null;
}
async function fetchChain138ReportTokenList() {
if (window._chain138TokenListCache) return window._chain138TokenListCache;
try {
var res = await fetch('/api/v1/report/token-list?chainId=138', { cache: 'no-store' });
if (res.ok) {
var data = await res.json();
window._chain138TokenListCache = data && Array.isArray(data.tokens) ? data.tokens : [];
return window._chain138TokenListCache;
}
} catch (e) {}
return [];
}
async function resolveTokenLogoUri(address) {
if (!address) return undefined;
var tokens = await fetchChain138ReportTokenList();
var normalized = String(address).toLowerCase();
for (var i = 0; i < tokens.length; i++) {
var token = tokens[i];
if (token && token.address && String(token.address).toLowerCase() === normalized) {
return token.logoURI || undefined;
}
}
return undefined;
}
async function addTokenToWallet(address, symbol, decimals, name) {
if (!address || !/^0x[a-fA-F0-9]{40}$/i.test(address)) {
if (typeof showToast === 'function') showToast('Invalid token address', 'error');
@@ -2122,6 +2163,7 @@
if (typeof showToast === 'function') showToast('This token has no symbol on-chain. Add it manually in MetaMask: use this contract address and set symbol to WETH.', 'info');
return;
}
var logoURI = await resolveTokenLogoUri(address);
var added = await window.ethereum.request({
method: 'wallet_watchAsset',
params: {
@@ -2131,7 +2173,7 @@
symbol: (useSymbol !== undefined && useSymbol !== null) ? useSymbol : 'TOKEN',
decimals: useDecimals,
name: useName || undefined,
image: undefined
image: logoURI
}
}
});
@@ -2252,18 +2294,19 @@
// If chain doesn't exist, add it
if (switchError.code === 4902) {
try {
var chainPayload = await fetchChain138AddEthereumChainPayload();
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [{
params: [chainPayload || {
chainId,
chainName: 'Chain 138',
chainName: 'DeFi Oracle Meta Mainnet',
nativeCurrency: {
name: 'ETH',
name: 'Ether',
symbol: 'ETH',
decimals: 18
},
rpcUrls: RPC_URLS.length > 0 ? RPC_URLS : [RPC_URL],
blockExplorerUrls: [window.location.origin || 'https://blockscout.defi-oracle.io']
blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io']
}],
});
} catch (addError) {
@@ -5314,17 +5357,26 @@
const WETH9_BRIDGE_138 = '0xcacfd227A040002e49e2e01626363071324f820a';
const WETH10_BRIDGE_138 = '0xe0E93247376aa097dB308B92e6Ba36bA015535D0';
// Ethereum Mainnet Bridge Contracts
const WETH9_BRIDGE_MAINNET = '0xc9901ce2Ddb6490FAA183645147a87496d8b20B6';
// Ethereum Mainnet bridge references (relay release + CCIP hubs)
const WETH9_BRIDGE_MAINNET_RELAY = '0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939';
const WETH9_BRIDGE_MAINNET_HUB = '0xc9901ce2Ddb6490FAA183645147a87496d8b20B6';
const WETH10_BRIDGE_MAINNET = '0x04E1e22B0D41e99f4275bd40A50480219bc9A223';
const CW_BRIDGE_MAINNET = '0x2bF74583206A49Be07E0E8A94197C12987AbD7B5';
const CW_L1_BRIDGE_138 = '0x152ed3e9912161b76bdfd368d0c84b7c31c10de7';
const explorerLinks = {
'BSC (56)': { label: 'BscScan', baseUrl: 'https://bscscan.com/address/' },
'BNB Chain (56)': { label: 'BscScan', baseUrl: 'https://bscscan.com/address/' },
'Polygon (137)': { label: 'PolygonScan', baseUrl: 'https://polygonscan.com/address/' },
'Avalanche (43114)': { label: 'Avalanche Explorer', baseUrl: 'https://subnets.avax.network/c-chain/address/' },
'Avalanche C-Chain (43114)': { label: 'Avalanche Explorer', baseUrl: 'https://subnets.avax.network/c-chain/address/' },
'Base (8453)': { label: 'BaseScan', baseUrl: 'https://basescan.org/address/' },
'Arbitrum (42161)': { label: 'Arbiscan', baseUrl: 'https://arbiscan.io/address/' },
'Arbitrum One (42161)': { label: 'Arbiscan', baseUrl: 'https://arbiscan.io/address/' },
'Optimism (10)': { label: 'Optimistic Etherscan', baseUrl: 'https://optimistic.etherscan.io/address/' },
'Gnosis (100)': { label: 'GnosisScan', baseUrl: 'https://gnosisscan.io/address/' },
'Cronos (25)': { label: 'Cronoscan', baseUrl: 'https://cronoscan.com/address/' },
'Celo (42220)': { label: 'CeloScan', baseUrl: 'https://celoscan.io/address/' },
'Ethereum Mainnet (1)': { label: 'Etherscan', baseUrl: 'https://etherscan.io/address/' }
};
function renderExplorerLink(chainLabel, address) {
@@ -5334,27 +5386,49 @@
return '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer" style="color: var(--primary); white-space: nowrap;">View on ' + escapeHtml(explorer.label) + '</a>';
}
// Bridge routes configuration
const routes = {
// Bridge routes — prefer live token-aggregation catalog; static fallback matches config/bridge-routes-chain138-default.json
var routes = {
weth9: {
'BSC (56)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4',
'Ethereum Mainnet (1)': WETH9_BRIDGE_MAINNET_RELAY,
'BNB Chain (56)': '0x886C6A4ABC064dbf74E7caEc460b7eeC31F1b78C',
'Polygon (137)': '0xF7736443f02913e7e0773052103296CfE1637448',
'Avalanche (43114)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4',
'Avalanche C-Chain (43114)': '0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F',
'Base (8453)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4',
'Arbitrum (42161)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
'Arbitrum One (42161)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
'Optimism (10)': '0x6e94e53F73893b2a6784Df663920D31043A6dE07',
'Ethereum Mainnet (1)': WETH9_BRIDGE_MAINNET
'Gnosis (100)': '0x4ab39b5BaB7b463435209A9039bd40Cf241F5a82',
'Cronos (25)': '0x3Cc23d086fCcbAe1e5f3FE2bA4A263E1D27d8Cab',
'Celo (42220)': '0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04'
},
weth10: {
'BSC (56)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
'Ethereum Mainnet (1)': WETH10_BRIDGE_MAINNET,
'BNB Chain (56)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
'Polygon (137)': '0x0CA60e6f8589c540200daC9D9Cb27BC2e48eE66A',
'Avalanche (43114)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
'Avalanche C-Chain (43114)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
'Base (8453)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
'Arbitrum (42161)': '0x73376eB92c16977B126dB9112936A20Fa0De3442',
'Arbitrum One (42161)': '0x73376eB92c16977B126dB9112936A20Fa0De3442',
'Optimism (10)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4',
'Ethereum Mainnet (1)': WETH10_BRIDGE_MAINNET
'Gnosis (100)': '0xC15ACdBAC59B3C7Cb4Ea4B3D58334A4b143B4b44',
'Cronos (25)': '0x73376eB92c16977B126dB9112936A20Fa0De3442',
'Celo (42220)': '0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08'
}
};
var bridgeRoutesSource = 'static-fallback';
try {
var routesResp = await fetch(TOKEN_AGGREGATION_API_BASE + '/v1/bridge/routes', { credentials: 'omit' });
if (routesResp.ok) {
var routesPayload = await routesResp.json();
if (routesPayload && routesPayload.routes && routesPayload.routes.weth9) {
routes = routesPayload.routes;
bridgeRoutesSource = routesPayload.source || 'token-aggregation';
if (routesPayload.chain138Bridges && routesPayload.chain138Bridges.weth9) {
// no-op: source addresses already set above from canonical constants
}
}
}
} catch (routesErr) {
console.warn('Bridge routes API unavailable, using static catalog', routesErr);
}
const sourceBridgeCount = 2;
const mainnetBridgeCount = 2;
const routeBridgeCount = new Set([
@@ -5372,7 +5446,7 @@
<div class="chain-name"><i class="fas fa-network-wired"></i> CCIP Bridge Ecosystem</div>
<div class="badge badge-info" style="display:inline-flex; margin-top:0.65rem; white-space:nowrap;">${sourceBridgeCount} source contracts / ${mainnetBridgeCount} mainnet contracts / ${routeBridgeCount} unique route contracts</div>
<div style="color: var(--text-light); margin-bottom: 1rem;">
Cross-chain interoperability powered by Chainlink CCIP
Cross-chain interoperability powered by Chainlink CCIP. Route catalog source: <code>${escapeHtml(bridgeRoutesSource)}</code>.
</div>
<div class="badge badge-warning" style="display:inline-flex; white-space:normal; text-align:left;">
Arbitrum remains route-blocked on the current Mainnet hub leg. The latest Mainnet -> Arbitrum WETH9 send reverted before any bridge event was emitted, so treat Arbitrum as unavailable until that hub path is repaired.
@@ -5514,16 +5588,19 @@
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-ethereum"></i> Ethereum Mainnet Bridges</h3>
<span class="badge badge-info" style="white-space: nowrap;">${sourceBridgeCount} source contracts / ${mainnetBridgeCount} mainnet contracts / ${routeBridgeCount} unique route contracts</span>
<span class="badge badge-info" style="white-space: nowrap;">Relay + CCIP hubs + cW L2</span>
</div>
<div class="chain-info" style="grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem;">
<div class="chain-info" style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem;">
<div class="chain-stat" style="background: var(--light); padding: 1rem; border-radius: 8px;">
<div class="chain-stat-label" style="font-weight: 600; margin-bottom: 0.5rem;">CCIPWETH9Bridge</div>
<div class="chain-stat-label" style="font-weight: 600; margin-bottom: 0.5rem;">CCIPRelayBridge (138→1 WETH9)</div>
<div class="chain-stat-value">
<span class="hash" onclick="window.open('https://etherscan.io/address/${WETH9_BRIDGE_MAINNET}', '_blank', 'noopener,noreferrer')" style="cursor: pointer; font-size: 0.9rem;">${WETH9_BRIDGE_MAINNET}</span>
<span class="hash" onclick="window.open('https://etherscan.io/address/${WETH9_BRIDGE_MAINNET_RELAY}', '_blank', 'noopener,noreferrer')" style="cursor: pointer; font-size: 0.9rem;">${WETH9_BRIDGE_MAINNET_RELAY}</span>
</div>
<div style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-light);">
<a href="https://etherscan.io/address/${WETH9_BRIDGE_MAINNET}" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">View on Etherscan</a>
</div>
<div class="chain-stat" style="background: var(--light); padding: 1rem; border-radius: 8px;">
<div class="chain-stat-label" style="font-weight: 600; margin-bottom: 0.5rem;">CCIPWETH9Bridge (hub)</div>
<div class="chain-stat-value">
<span class="hash" onclick="window.open('https://etherscan.io/address/${WETH9_BRIDGE_MAINNET_HUB}', '_blank', 'noopener,noreferrer')" style="cursor: pointer; font-size: 0.9rem;">${WETH9_BRIDGE_MAINNET_HUB}</span>
</div>
</div>
<div class="chain-stat" style="background: var(--light); padding: 1rem; border-radius: 8px;">
@@ -5531,8 +5608,17 @@
<div class="chain-stat-value">
<span class="hash" onclick="window.open('https://etherscan.io/address/${WETH10_BRIDGE_MAINNET}', '_blank', 'noopener,noreferrer')" style="cursor: pointer; font-size: 0.9rem;">${WETH10_BRIDGE_MAINNET}</span>
</div>
<div style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-light);">
<a href="https://etherscan.io/address/${WETH10_BRIDGE_MAINNET}" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">View on Etherscan</a>
</div>
<div class="chain-stat" style="background: var(--light); padding: 1rem; border-radius: 8px;">
<div class="chain-stat-label" style="font-weight: 600; margin-bottom: 0.5rem;">CWMultiTokenBridge (cW*)</div>
<div class="chain-stat-value">
<span class="hash" onclick="window.open('https://etherscan.io/address/${CW_BRIDGE_MAINNET}', '_blank', 'noopener,noreferrer')" style="cursor: pointer; font-size: 0.9rem;">${CW_BRIDGE_MAINNET}</span>
</div>
</div>
<div class="chain-stat" style="background: var(--light); padding: 1rem; border-radius: 8px;">
<div class="chain-stat-label" style="font-weight: 600; margin-bottom: 0.5rem;">CW L1 bridge (Chain 138)</div>
<div class="chain-stat-value">
${explorerAddressLink(CW_L1_BRIDGE_138, escapeHtml(CW_L1_BRIDGE_138), 'color: inherit; text-decoration: none; font-size: 0.9rem;')}
</div>
</div>
</div>

View File

@@ -1645,7 +1645,7 @@
<h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">Cross-Chain Bridging</h4>
<p>Both WETH9 and WETH10 can be bridged to other chains using the CCIP bridge contracts:</p>
<ul style="margin-left: 2rem; margin-top: 0.5rem;">
<li><strong>WETH9 Bridge:</strong> <span class="hash">0x971cD9D156f193df8051E48043C476e53ECd4693</span></li>
<li><strong>WETH9 Bridge:</strong> <a class="hash" href="/addresses/0xcacfd227A040002e49e2e01626363071324f820a" onclick="event.preventDefault(); showAddressDetail('0xcacfd227A040002e49e2e01626363071324f820a')" style="color: inherit; text-decoration: none;">0xcacfd227A040002e49e2e01626363071324f820a</a></li>
<li><strong>WETH10 Bridge:</strong> <span class="hash">0xe0E93247376aa097dB308B92e6Ba36bA015535D0</span></li>
</ul>
</div>

View File

@@ -0,0 +1,60 @@
interface PaginationControlsProps {
page: number
pageCount: number
onPageChange: (page: number) => void
label?: string
className?: string
}
export default function PaginationControls({
page,
pageCount,
onPageChange,
label = 'Rows',
className = '',
}: PaginationControlsProps) {
if (pageCount <= 1) return null
const pages = Array.from({ length: pageCount }, (_, index) => index + 1)
return (
<div className={`mt-4 flex flex-wrap items-center justify-between gap-3 ${className}`}>
<div className="text-sm text-gray-600 dark:text-gray-400">
{label}: page {page} of {pageCount}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
>
Previous
</button>
{pages.map((candidate) => (
<button
key={candidate}
type="button"
onClick={() => onPageChange(candidate)}
aria-current={candidate === page ? 'page' : undefined}
className={
candidate === page
? 'rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
: 'rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
}
>
{candidate}
</button>
))}
<button
type="button"
onClick={() => onPageChange(Math.min(pageCount, page + 1))}
disabled={page >= pageCount}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
>
Next
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
export interface SectionTab<T extends string> {
id: T
label: string
count?: number
}
interface SectionTabsProps<T extends string> {
tabs: SectionTab<T>[]
activeTab: T
onChange: (tab: T) => void
className?: string
}
export default function SectionTabs<T extends string>({
tabs,
activeTab,
onChange,
className = '',
}: SectionTabsProps<T>) {
return (
<div className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}>
<div className="flex gap-2 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={
activeTab === tab.id
? 'whitespace-nowrap rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
: 'whitespace-nowrap rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
}
>
{tab.label}
{typeof tab.count === 'number' ? (
<span className={activeTab === tab.id ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
{tab.count.toLocaleString()}
</span>
) : null}
</button>
))}
</div>
</div>
)
}

View File

@@ -198,6 +198,11 @@ export default function LiquidityOperationsPage({
}),
[bridgeStatus, stats],
)
const liquidityInventoryUpdatedAt =
stats?.sampling?.stats_generated_at ||
stats?.freshness?.chain_head?.timestamp ||
routeMatrix?.generatedAt ||
routeMatrix?.updated
const insightLines = useMemo(
() => [
@@ -234,6 +239,12 @@ export default function LiquidityOperationsPage({
href: `/explorer-api/v1/mission-control/liquidity/token/${featuredTokens[0]?.address || '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'}/pools`,
notes: 'Cached public pool inventory for a specific Chain 138 token.',
},
{
name: 'External indexer readiness',
method: 'GET',
href: `/api/v1/report/external-indexer-readiness?chainId=138`,
notes: 'One JSON posture for DefiLlama, CoinGecko, CoinMarketCap, and Dexscreener readiness.',
},
]
const copyEndpoint = async (endpoint: EndpointCard) => {
@@ -321,7 +332,7 @@ export default function LiquidityOperationsPage({
</div>
<MarketEvidenceNote
source="mission-control"
lastUpdated={routeMatrix?.updated}
lastUpdated={liquidityInventoryUpdatedAt}
method="Route matrix, provider capabilities, and mission-control pool inventory are reconciled for visible public liquidity only."
compact
/>
@@ -363,7 +374,7 @@ export default function LiquidityOperationsPage({
</div>
<MarketEvidenceNote
source="mission-control"
lastUpdated={routeMatrix?.updated}
lastUpdated={liquidityInventoryUpdatedAt}
method="Pool TVL is the visible mission-control value for discovered route-backed liquidity."
compact
/>

View File

@@ -25,6 +25,12 @@ import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import { Explain, useUiMode } from '@/components/common/UiModeContext'
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { tokensApi } from '@/services/api/tokens'
import {
HOME_DASHBOARD_REFRESH_MS,
HOME_PRICE_FEED_REFRESH_MS,
resolveHomePriceFeedAddresses,
} from '@/utils/featuredTokens'
type HomeStats = ExplorerStats
@@ -156,8 +162,30 @@ export default function Home({
)
}, [chainId])
const loadFeaturedPrices = useCallback(async () => {
const [curatedResult, reportResult] = await Promise.all([
tokensApi.listCuratedSafe(chainId),
tokensApi.listReportSafe(chainId),
])
const addresses = resolveHomePriceFeedAddresses(
curatedResult.ok ? curatedResult.data : [],
reportResult.ok ? reportResult.data : [],
)
const { data } = await tokenAggregationApi.getTokensByAddressSafe(chainId, addresses)
setFeaturedPrices(data)
}, [chainId])
useEffect(() => {
loadDashboard()
const timer = window.setInterval(() => {
void loadDashboard()
}, HOME_DASHBOARD_REFRESH_MS)
return () => {
window.clearInterval(timer)
}
}, [loadDashboard])
useEffect(() => {
@@ -180,26 +208,33 @@ export default function Home({
useEffect(() => {
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
tokenAggregationApi.getTokensByAddressSafe(138, [
'0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
'0xf22258f57794CC8E06237084b353Ab30fFfa640b',
'0x290e52a8819A4fBd0714e517225429AA2B70EC6B',
'0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
]).then(({ data }) => {
if (!cancelled) {
setFeaturedPrices(data)
const refreshFeaturedPrices = async () => {
try {
if (!cancelled) {
await loadFeaturedPrices()
}
} catch (error) {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to load featured token prices:', error)
}
} finally {
if (!cancelled) {
timer = setTimeout(() => {
void refreshFeaturedPrices()
}, HOME_PRICE_FEED_REFRESH_MS)
}
}
}).catch((error) => {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to load featured token prices:', error)
}
})
}
void refreshFeaturedPrices()
return () => {
cancelled = true
if (timer) clearTimeout(timer)
}
}, [])
}, [loadFeaturedPrices])
useEffect(() => {
let cancelled = false

View File

@@ -2,6 +2,8 @@
import { useEffect, useMemo, useState } from 'react'
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
import { tokensApi } from '@/services/api/tokens'
import { selectWalletFeaturedTokens } from '@/utils/featuredTokens'
export type WalletChain = {
chainId: string
@@ -185,8 +187,6 @@ const MAINNET_CWUSDC_TOKEN: TokenListToken = {
},
}
const FEATURED_TOKEN_SYMBOLS = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT']
/** npm-published Snap using open Snap permissions only; stable MetaMask still requires MetaMasks install allowlist. */
const CHAIN138_OPEN_SNAP_ID = 'npm:chain138-open-snap' as const
@@ -351,6 +351,7 @@ export function AddToMetaMask({
)
const [metamaskConfig, setMetamaskConfig] = useState<MetaMaskConfig | null>(null)
const [metamaskConfigMeta, setMetamaskConfigMeta] = useState<FetchMetadata | null>(null)
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>([])
const [watchAssetProgress, setWatchAssetProgress] = useState<{ current: number; total: number } | null>(null)
const ethereum = typeof window !== 'undefined'
@@ -456,6 +457,24 @@ export function AddToMetaMask({
}
}, [capabilitiesUrl, metamaskConfigUrl, networksUrl, staticCapabilitiesUrl, tokenListUrl])
useEffect(() => {
let active = true
tokensApi.listCuratedSafe(138).then(({ ok, data }) => {
if (active) {
setCuratedTokens(ok ? (data as TokenListToken[]) : [])
}
}).catch(() => {
if (active) {
setCuratedTokens([])
}
})
return () => {
active = false
}
}, [])
const catalogTokens = useMemo(
() => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []),
[tokenList],
@@ -477,18 +496,10 @@ export function AddToMetaMask({
}
}, [metamaskConfig, networks])
const featuredTokens = useMemo(() => {
const tokenMap = new Map<string, TokenListToken>()
for (const token of catalogTokens) {
if (token.chainId !== 138) continue
if (!FEATURED_TOKEN_SYMBOLS.includes(token.symbol)) continue
tokenMap.set(token.symbol, token)
}
return FEATURED_TOKEN_SYMBOLS
.map((symbol) => tokenMap.get(symbol))
.filter((token): token is TokenListToken => !!token)
}, [catalogTokens])
const featuredTokens = useMemo(
() => selectWalletFeaturedTokens(catalogTokens, curatedTokens) as TokenListToken[],
[catalogTokens, curatedTokens],
)
const watchAssetTokens = useMemo(() => {
const endpointTokens = (metamaskConfig?.watchAssets || [])

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import type {
CapabilitiesCatalog,
FetchMetadata,
@@ -17,6 +17,7 @@ import {
type AddressTokenTransfer,
type TransactionSummary,
} from '@/services/api/addresses'
import { WALLET_SNAPSHOT_REFRESH_MS } from '@/utils/featuredTokens'
import { formatRelativeAge, formatTokenAmount } from '@/utils/format'
import {
isWatchlistEntry,
@@ -110,8 +111,42 @@ export default function WalletPage(props: WalletPageProps) {
? isWatchlistEntry(watchlistEntries, walletSession.address)
: false
const loadWalletSnapshot = useCallback(async (address: string) => {
const [infoResponse, transactionsResponse, balancesResponse, transfersResponse] = await Promise.all([
addressesApi.getSafe(138, address),
addressesApi.getTransactionsSafe(138, address, 1, 3),
addressesApi.getTokenBalancesSafe(address),
addressesApi.getTokenTransfersSafe(address, 1, 4),
])
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
setTokenBalances(
balancesResponse.ok
? [...balancesResponse.data]
.filter((balance) => {
try {
return BigInt(balance.value || '0') > 0n
} catch {
return Boolean(balance.value)
}
})
.sort((left, right) => {
try {
return Number(BigInt(right.value || '0') - BigInt(left.value || '0'))
} catch {
return 0
}
})
.slice(0, 4)
: [],
)
setTokenTransfers(transfersResponse.ok ? transfersResponse.data : [])
}, [])
useEffect(() => {
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
if (!walletSession?.address) {
setAddressInfo(null)
@@ -123,50 +158,34 @@ export default function WalletPage(props: WalletPageProps) {
}
}
Promise.all([
addressesApi.getSafe(138, walletSession.address),
addressesApi.getTransactionsSafe(138, walletSession.address, 1, 3),
addressesApi.getTokenBalancesSafe(walletSession.address),
addressesApi.getTokenTransfersSafe(walletSession.address, 1, 4),
])
.then(([infoResponse, transactionsResponse, balancesResponse, transfersResponse]) => {
if (cancelled) return
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
setTokenBalances(
balancesResponse.ok
? [...balancesResponse.data]
.filter((balance) => {
try {
return BigInt(balance.value || '0') > 0n
} catch {
return Boolean(balance.value)
}
})
.sort((left, right) => {
try {
return Number(BigInt(right.value || '0') - BigInt(left.value || '0'))
} catch {
return 0
}
})
.slice(0, 4)
: [],
)
setTokenTransfers(transfersResponse.ok ? transfersResponse.data : [])
})
.catch(() => {
if (cancelled) return
setAddressInfo(null)
setRecentAddressTransactions([])
setTokenBalances([])
setTokenTransfers([])
})
const refreshSnapshot = async () => {
try {
if (!cancelled) {
await loadWalletSnapshot(walletSession.address)
}
} catch {
if (!cancelled) {
setAddressInfo(null)
setRecentAddressTransactions([])
setTokenBalances([])
setTokenTransfers([])
}
} finally {
if (!cancelled) {
timer = setTimeout(() => {
void refreshSnapshot()
}, WALLET_SNAPSHOT_REFRESH_MS)
}
}
}
void refreshSnapshot()
return () => {
cancelled = true
if (timer) clearTimeout(timer)
}
}, [walletSession?.address])
}, [loadWalletSnapshot, walletSession?.address])
return (
<main className="container mx-auto px-4 py-6 sm:py-8">

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
@@ -28,6 +28,8 @@ import {
normalizeWatchlistAddress,
} from '@/utils/watchlist'
import PageIntro from '@/components/common/PageIntro'
import PaginationControls from '@/components/common/PaginationControls'
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
import GruStandardsCard from '@/components/common/GruStandardsCard'
import ContractCodeWorkspace from '@/components/explorer/ContractCodeWorkspace'
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
@@ -68,6 +70,11 @@ export default function AddressDetailPage() {
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
const [nativeAssetPriceUsd, setNativeAssetPriceUsd] = useState<number | undefined>()
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'contract' | 'balances' | 'transfers' | 'transactions'>('balances')
const [balancePage, setBalancePage] = useState(1)
const [transferPage, setTransferPage] = useState(1)
const [transactionPage, setTransactionPage] = useState(1)
const pageSize = 8
const loadAddressInfo = useCallback(async () => {
try {
@@ -499,6 +506,34 @@ export default function AddressDetailPage() {
).length
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
const nativeBalanceUsd = estimateNativeUsdValue(addressInfo?.balance, nativeAssetPriceUsd)
const tabs: SectionTab<typeof activeTab>[] = [
...(addressInfo?.is_contract ? [{ id: 'contract' as const, label: 'Contract' }] : []),
{ id: 'balances', label: 'Balances', count: tokenBalances.length },
{ id: 'transfers', label: 'Transfers', count: tokenTransfers.length },
{ id: 'transactions', label: 'Transactions', count: transactions.length },
]
const balancePageCount = Math.max(1, Math.ceil(tokenBalances.length / pageSize))
const transferPageCount = Math.max(1, Math.ceil(tokenTransfers.length / pageSize))
const transactionPageCount = Math.max(1, Math.ceil(transactions.length / pageSize))
const pagedTokenBalances = useMemo(
() => tokenBalances.slice((balancePage - 1) * pageSize, balancePage * pageSize),
[balancePage, tokenBalances],
)
const pagedTokenTransfers = useMemo(
() => tokenTransfers.slice((transferPage - 1) * pageSize, transferPage * pageSize),
[transferPage, tokenTransfers],
)
const pagedTransactions = useMemo(
() => transactions.slice((transactionPage - 1) * pageSize, transactionPage * pageSize),
[transactionPage, transactions],
)
useEffect(() => {
setBalancePage(1)
setTransferPage(1)
setTransactionPage(1)
setActiveTab(addressInfo?.is_contract ? 'contract' : 'balances')
}, [address, addressInfo?.is_contract])
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
@@ -633,7 +668,9 @@ export default function AddressDetailPage() {
</dl>
</Card>
{addressInfo.is_contract && (
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
{activeTab === 'contract' && addressInfo.is_contract && (
<Card title="Contract Profile" className="mb-6">
<dl className="space-y-4">
<DetailRow label="Interaction Surface">
@@ -848,13 +885,13 @@ export default function AddressDetailPage() {
</Card>
)}
{addressInfo.is_contract && contractProfile ? (
{activeTab === 'contract' && addressInfo.is_contract && contractProfile ? (
<ContractCodeWorkspace address={addressInfo.address} profile={contractProfile} />
) : null}
{gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
{activeTab === 'contract' && gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
<Card title="Token Balances" className="mb-6">
{activeTab === 'balances' ? <Card title="Token Balances" className="mb-6">
{gruBalanceCount > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>{gruBalanceCount} visible token balance{gruBalanceCount === 1 ? '' : 's'} look GRU-aware.</span>
@@ -865,13 +902,19 @@ export default function AddressDetailPage() {
) : null}
<Table
columns={tokenBalanceColumns}
data={tokenBalances}
data={pagedTokenBalances}
emptyMessage="No token balances were indexed for this address."
keyExtractor={(balance) => balance.token_address || `${balance.token_symbol}-${balance.value}`}
/>
</Card>
<PaginationControls
page={balancePage}
pageCount={balancePageCount}
onPageChange={setBalancePage}
label="Token balances"
/>
</Card> : null}
<Card title="Recent Token Transfers" className="mb-6">
{activeTab === 'transfers' ? <Card title="Recent Token Transfers" className="mb-6">
{gruTransferCount > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>{gruTransferCount} recent transfer asset{gruTransferCount === 1 ? '' : 's'} carry GRU posture in the explorer.</span>
@@ -885,20 +928,32 @@ export default function AddressDetailPage() {
) : null}
<Table
columns={tokenTransferColumns}
data={tokenTransfers}
data={pagedTokenTransfers}
emptyMessage="No token transfers were found for this address."
keyExtractor={(transfer) => `${transfer.transaction_hash}-${transfer.token_address}-${transfer.value}`}
/>
</Card>
<PaginationControls
page={transferPage}
pageCount={transferPageCount}
onPageChange={setTransferPage}
label="Token transfers"
/>
</Card> : null}
<Card title="Transactions">
{activeTab === 'transactions' ? <Card title="Transactions">
<Table
columns={transactionColumns}
data={transactions}
data={pagedTransactions}
emptyMessage="No recent transactions were found for this address."
keyExtractor={(tx) => tx.hash}
/>
</Card>
<PaginationControls
page={transactionPage}
pageCount={transactionPageCount}
onPageChange={setTransactionPage}
label="Transactions"
/>
</Card> : null}
</>
)}
</div>

View File

@@ -13,6 +13,8 @@ import EntityBadge from '@/components/common/EntityBadge'
import GruStandardsCard from '@/components/common/GruStandardsCard'
import TokenSigningSurfaceCard from '@/components/common/TokenSigningSurfaceCard'
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import PaginationControls from '@/components/common/PaginationControls'
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
import { formatTokenAmount, formatTimestamp } from '@/utils/format'
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
@@ -54,6 +56,11 @@ 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)
const pageSize = 8
const loadToken = useCallback(async () => {
setLoading(true)
@@ -186,6 +193,35 @@ export default function TokenDetailPage() {
() => getGruExplorerMetadata({ address: token?.address || address, symbol: token?.symbol }),
[address, token?.address, token?.symbol],
)
const tabs: SectionTab<typeof activeTab>[] = [
{ id: 'intelligence', label: 'Intelligence' },
...(gruProfile || gruExplorerMetadata ? [{ id: 'standards' as const, label: 'Standards' }] : []),
{ id: 'holders', label: 'Holders', count: holders.length },
{ id: 'transfers', label: 'Transfers', count: transfers.length },
{ id: 'liquidity', label: 'Liquidity', count: pools.length },
]
const holderPageCount = Math.max(1, Math.ceil(holders.length / pageSize))
const transferPageCount = Math.max(1, Math.ceil(transfers.length / pageSize))
const poolPageCount = Math.max(1, Math.ceil(pools.length / pageSize))
const pagedHolders = useMemo(
() => holders.slice((holderPage - 1) * pageSize, holderPage * pageSize),
[holderPage, holders],
)
const pagedTransfers = useMemo(
() => transfers.slice((transferPage - 1) * pageSize, transferPage * pageSize),
[transferPage, transfers],
)
const pagedPools = useMemo(
() => pools.slice((poolPage - 1) * pageSize, poolPage * pageSize),
[poolPage, pools],
)
useEffect(() => {
setActiveTab('intelligence')
setHolderPage(1)
setTransferPage(1)
setPoolPage(1)
}, [address])
const holderColumns = [
{
@@ -358,7 +394,9 @@ export default function TokenDetailPage() {
<TokenSigningSurfaceCard address={token.address} contractProfile={contractProfile} />
<Card title="Token Intelligence">
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
{activeTab === 'intelligence' ? <Card title="Token Intelligence">
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Market Context</div>
@@ -403,11 +441,11 @@ export default function TokenDetailPage() {
</div>
</div>
</div>
</Card>
</Card> : null}
{gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
{activeTab === 'standards' && gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
{gruExplorerMetadata ? (
{activeTab === 'standards' && gruExplorerMetadata ? (
<Card title="x402 And ISO-20022 Posture">
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -450,7 +488,7 @@ export default function TokenDetailPage() {
</Card>
) : null}
{gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? (
{activeTab === 'standards' && gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? (
<Card title="Other Networks">
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
@@ -483,17 +521,18 @@ export default function TokenDetailPage() {
</Card>
) : null}
<Card title="Top Holders">
{activeTab === 'holders' ? <Card title="Top Holders">
<Table
layout="tabular"
columns={holderColumns}
data={holders}
data={pagedHolders}
emptyMessage="No holder data was available for this token."
keyExtractor={(holder) => holder.address}
/>
</Card>
<PaginationControls page={holderPage} pageCount={holderPageCount} onPageChange={setHolderPage} label="Holders" />
</Card> : null}
<Card title="Recent Transfers">
{activeTab === 'transfers' ? <Card title="Recent Transfers">
{gruExplorerMetadata ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>
@@ -509,20 +548,22 @@ export default function TokenDetailPage() {
) : null}
<Table
columns={transferColumns}
data={transfers}
data={pagedTransfers}
emptyMessage="No recent token transfers were available."
keyExtractor={(transfer) => `${transfer.transaction_hash}-${transfer.value}-${transfer.from_address}`}
/>
</Card>
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Transfers" />
</Card> : null}
<Card title="Related Liquidity">
{activeTab === 'liquidity' ? <Card title="Related Liquidity">
<Table
columns={poolColumns}
data={pools}
data={pagedPools}
emptyMessage="No related liquidity pools were exposed through mission control for this token."
keyExtractor={(pool) => pool.address}
/>
</Card>
<PaginationControls page={poolPage} pageCount={poolPageCount} onPageChange={setPoolPage} label="Pools" />
</Card> : null}
</div>
)}
</div>

View File

@@ -10,6 +10,7 @@ import { tokensApi } from '@/services/api/tokens'
import type { TokenListToken } from '@/services/api/config'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { selectCuratedFeaturedTokens } from '@/utils/featuredTokens'
const quickSearches = [
{ label: 'cUSDT', description: 'Canonical compliant USD treasury / government bond liquidity and address results.' },
@@ -84,14 +85,10 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
}
}, [initialCuratedTokens])
const featuredCuratedTokens = useMemo(() => {
const preferred = ['cUSDT', 'cUSDC', 'cXAUC', 'cXAUT', 'cEURT', 'USDT']
const selected = preferred
.map((symbol) => curatedTokens.find((token) => token.symbol === symbol))
.filter((token): token is TokenListToken => Boolean(token?.address))
return selected.length > 0 ? selected : curatedTokens.slice(0, 6)
}, [curatedTokens])
const featuredCuratedTokens = useMemo(
() => selectCuratedFeaturedTokens(curatedTokens) as TokenListToken[],
[curatedTokens],
)
useEffect(() => {
let active = true

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { Card, Address, Table } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
@@ -15,9 +15,15 @@ import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/form
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import PageIntro from '@/components/common/PageIntro'
import PaginationControls from '@/components/common/PaginationControls'
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
import { getGruCatalogPosture } from '@/services/api/gruCatalog'
import { assessTransactionCompliance } from '@/utils/transactionCompliance'
import { tokenAggregationApi, type TokenAggregationHistoricalPriceSnapshot } from '@/services/api/tokenAggregation'
import {
tokenAggregationApi,
type CheckpointTxAttestationSnapshot,
type TokenAggregationHistoricalPriceSnapshot,
} from '@/services/api/tokenAggregation'
import { estimateNativeUsdValue, estimateTokenUsdValue, getNativeAssetDescriptor, getNativeAssetPriceAtSafe } from '@/services/api/nativeAssetPricing'
function isValidTransactionHash(value: string) {
@@ -69,7 +75,12 @@ export default function TransactionDetailPage() {
const [diagnostic, setDiagnostic] = useState<TransactionLookupDiagnostic | null>(null)
const [historicalTokenPrices, setHistoricalTokenPrices] = useState<Record<string, TokenAggregationHistoricalPriceSnapshot>>({})
const [historicalNativePrice, setHistoricalNativePrice] = useState<TokenAggregationHistoricalPriceSnapshot | null>(null)
const [checkpointAttestation, setCheckpointAttestation] = useState<CheckpointTxAttestationSnapshot | null>(null)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'evidence' | 'details' | 'transfers' | 'internal' | 'raw'>('evidence')
const [transferPage, setTransferPage] = useState(1)
const [internalPage, setInternalPage] = useState(1)
const pageSize = 8
const loadTransaction = useCallback(async () => {
setLoading(true)
@@ -185,6 +196,25 @@ export default function TransactionDetailPage() {
}
}, [chainId, transaction?.created_at])
useEffect(() => {
let active = true
if (!isValidHash) {
setCheckpointAttestation(null)
return () => {
active = false
}
}
tokenAggregationApi.getCheckpointAttestationSafe(hash).then(({ ok, data }) => {
if (!active) return
setCheckpointAttestation(ok && data?.included ? data : null)
}).catch(() => {
if (active) setCheckpointAttestation(null)
})
return () => {
active = false
}
}, [hash, isValidHash])
const tokenTransferColumns = [
{
header: 'Token',
@@ -234,16 +264,24 @@ export default function TransactionDetailPage() {
{
header: 'Transfer-Time Value',
accessor: (transfer: TransactionTokenTransfer) => {
const want = transfer.token_address.toLowerCase()
const checkpointLine = checkpointAttestation?.leaf?.transfers?.find((line) => {
const addr = (line.tokenAddress || line.token || '').toLowerCase()
return addr === want
})
const historicalPrice = historicalTokenPrices[transfer.token_address.toLowerCase()]
const totalUsd = estimateTokenUsdValue(transfer.amount, transfer.token_decimals, historicalPrice?.priceUsd)
const totalUsd = checkpointLine?.valueUsd != null
? Number(checkpointLine.valueUsd)
: estimateTokenUsdValue(transfer.amount, transfer.token_decimals, historicalPrice?.priceUsd)
const priceSource = checkpointLine?.priceSource ?? historicalPrice?.source
return (
<div className="space-y-1 text-sm">
<div>{totalUsd != null ? formatUsd(totalUsd) : 'Unavailable'}</div>
<div>{totalUsd != null && Number.isFinite(totalUsd) ? formatUsd(totalUsd) : 'Unavailable'}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Unit price: {formatUsd(historicalPrice?.priceUsd)}
Unit price: {checkpointLine?.valueUsd != null ? 'checkpoint leaf' : formatUsd(historicalPrice?.priceUsd)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Source: {formatHistoricalPriceSource(historicalPrice?.source)}
Source: {checkpointLine ? `checkpoint (${priceSource || 'enriched'})` : formatHistoricalPriceSource(priceSource)}
</div>
</div>
)
@@ -299,8 +337,14 @@ export default function TransactionDetailPage() {
const tokenTransferCount = transaction?.token_transfers?.length || 0
const internalCallCount = internalCalls.length
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
const nativeValueUsd = estimateNativeUsdValue(transaction?.value, historicalNativePrice?.priceUsd)
const checkpointLeaf = checkpointAttestation?.leaf
const checkpointNativeUsd = checkpointLeaf?.nativeValueUsd ?? checkpointLeaf?.valueUsd
const parsedCheckpointNativeUsd = checkpointNativeUsd != null ? Number(checkpointNativeUsd) : null
const nativeValueUsd = parsedCheckpointNativeUsd != null && Number.isFinite(parsedCheckpointNativeUsd)
? parsedCheckpointNativeUsd
: estimateNativeUsdValue(transaction?.value, historicalNativePrice?.priceUsd)
const nativeFeeUsd = estimateNativeUsdValue(transaction?.fee, historicalNativePrice?.priceUsd)
const checkpointTotalUsd = checkpointLeaf?.totalTransfersUsd ?? checkpointLeaf?.valueUsd
const complianceAssessment = transaction
? assessTransactionCompliance({
transaction,
@@ -308,6 +352,29 @@ export default function TransactionDetailPage() {
tokenTransfers: transaction.token_transfers || [],
})
: null
const tabs: SectionTab<typeof activeTab>[] = [
{ id: 'evidence', label: 'Evidence' },
{ id: 'details', label: 'Details' },
{ id: 'transfers', label: 'Transfers', count: tokenTransferCount },
{ id: 'internal', label: 'Internal', count: internalCallCount },
...(transaction?.input_data ? [{ id: 'raw' as const, label: 'Raw input' }] : []),
]
const transferPageCount = Math.max(1, Math.ceil((transaction?.token_transfers?.length || 0) / pageSize))
const internalPageCount = Math.max(1, Math.ceil(internalCalls.length / pageSize))
const pagedTokenTransfers = useMemo(
() => (transaction?.token_transfers || []).slice((transferPage - 1) * pageSize, transferPage * pageSize),
[transaction?.token_transfers, transferPage],
)
const pagedInternalCalls = useMemo(
() => internalCalls.slice((internalPage - 1) * pageSize, internalPage * pageSize),
[internalCalls, internalPage],
)
useEffect(() => {
setActiveTab('evidence')
setTransferPage(1)
setInternalPage(1)
}, [hash])
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
@@ -406,7 +473,13 @@ export default function TransactionDetailPage() {
{nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
</div>
<div>Transfer-time native price: {historicalNativePrice?.priceUsd != null ? `${formatUsd(historicalNativePrice.priceUsd)} per ${nativeAssetSymbol}` : 'Unavailable'}</div>
<div>Pricing source: {formatHistoricalPriceSource(historicalNativePrice?.source)}</div>
<div>Pricing source: {checkpointLeaf ? `checkpoint mirror (${checkpointLeaf.priceSource || 'enriched'})` : formatHistoricalPriceSource(historicalNativePrice?.source)}</div>
{checkpointAttestation ? (
<div>
Checkpoint batch #{checkpointAttestation.batchId}
{checkpointTotalUsd != null ? ` — total ${formatUsd(Number(checkpointTotalUsd))}` : ''}
</div>
) : null}
<div>Token transfers: {tokenTransferCount.toLocaleString()}</div>
<div>Internal calls: {internalCallCount.toLocaleString()}</div>
</div>
@@ -430,7 +503,9 @@ export default function TransactionDetailPage() {
</div>
</Card>
{complianceAssessment ? (
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
{activeTab === 'evidence' && complianceAssessment ? (
<Card title="Transaction Evidence Matrix">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
@@ -458,7 +533,7 @@ export default function TransactionDetailPage() {
</Card>
) : null}
<Card title="Transaction Information">
{activeTab === 'details' ? <Card title="Transaction Information">
<dl className="space-y-4">
<DetailRow label="Hash">
<Address address={transaction.hash} />
@@ -522,9 +597,9 @@ export default function TransactionDetailPage() {
</DetailRow>
)}
</dl>
</Card>
</Card> : null}
{transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (
{activeTab === 'details' && transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (
<Card title="Decoded Input">
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
@@ -553,25 +628,27 @@ export default function TransactionDetailPage() {
</Card>
)}
<Card title="Token Transfers">
{activeTab === 'transfers' ? <Card title="Token Transfers">
<Table
columns={tokenTransferColumns}
data={transaction.token_transfers || []}
data={pagedTokenTransfers}
emptyMessage="No token transfers were indexed for this transaction."
keyExtractor={(transfer) => `${transfer.token_address}-${transfer.from_address}-${transfer.to_address}-${transfer.amount}`}
/>
</Card>
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Token transfers" />
</Card> : null}
<Card title="Internal Transactions">
{activeTab === 'internal' ? <Card title="Internal Transactions">
<Table
columns={internalCallColumns}
data={internalCalls}
data={pagedInternalCalls}
emptyMessage="No internal transactions were exposed for this transaction."
keyExtractor={(call) => `${call.from_address}-${call.to_address || call.contract_address || 'unknown'}-${call.value}-${call.type || 'call'}`}
/>
</Card>
<PaginationControls page={internalPage} pageCount={internalPageCount} onPageChange={setInternalPage} label="Internal calls" />
</Card> : null}
{transaction.input_data && (
{activeTab === 'raw' && transaction.input_data && (
<Card title="Raw Input Data">
<pre className="overflow-x-auto whitespace-pre-wrap break-all rounded-lg bg-gray-50 p-4 text-xs text-gray-800 dark:bg-gray-950 dark:text-gray-200">
{transaction.input_data}

View File

@@ -24,7 +24,7 @@ export default function WalletRoutePage(props: WalletRoutePageProps) {
export const getServerSideProps: GetServerSideProps<WalletRoutePageProps> = async () => {
const [networksResult, tokenListResult, capabilitiesResult] = await Promise.all([
fetchPublicJsonWithMeta<NetworksCatalog>('/api/config/networks').catch(() => null),
fetchPublicJsonWithMeta<TokenListCatalog>('/api/config/token-list').catch(() => null),
fetchPublicJsonWithMeta<TokenListCatalog>('/api/v1/report/token-list?chainId=138').catch(() => null),
fetchPublicJsonWithMeta<CapabilitiesCatalog>('/api/config/capabilities').catch(() => null),
])

View File

@@ -98,7 +98,8 @@ interface ABIEntry {
}
async function fetchCompatJson<T>(params: URLSearchParams): Promise<T> {
const response = await fetch(`${getExplorerApiBase()}/api?${params.toString()}`)
const base = typeof window !== 'undefined' ? '' : getExplorerApiBase()
const response = await fetch(`${base}/api/?${params.toString()}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}

View File

@@ -26,6 +26,33 @@ export interface TokenAggregationHistoricalPriceSnapshot {
source?: string
}
export interface CheckpointTransferUsdLine {
tokenAddress?: string
token?: string
tokenSymbol?: string
symbol?: string
amountRaw?: string
decimals?: number
valueUsd?: string
priceSource?: string
}
export interface CheckpointTxAttestationSnapshot {
txHash: string
included: boolean
batchId: string
batchTotalUsd?: string
leaf: {
valueUsd?: string
nativeValueUsd?: string
tokenValueUsd?: string
totalTransfersUsd?: string
priceSource?: string
transfers?: CheckpointTransferUsdLine[]
} | null
source?: string
}
interface RawTokenAggregationTokenResponse {
token?: {
chainId?: number | string | null
@@ -190,6 +217,23 @@ export const tokenAggregationApi = {
return { ok: data.length > 0, data }
},
getCheckpointAttestationSafe: async (
txHash: string,
): Promise<{ ok: boolean; data: CheckpointTxAttestationSnapshot | null }> => {
try {
const response = await fetch(
`${getTokenAggregationBase()}/checkpoint/tx/${txHash}/attestation`,
)
if (!response.ok) {
return { ok: false, data: null }
}
const raw = (await response.json()) as CheckpointTxAttestationSnapshot
return { ok: true, data: raw }
} catch {
return { ok: false, data: null }
}
},
getPriceAtSafe: async (
chainId: number,
address: string,

View File

@@ -263,7 +263,21 @@ export const tokensApi = {
const response = await configApi.getTokenList()
const data = (response.tokens || [])
.filter((token) => token.chainId === chainId && typeof token.address === 'string' && token.address.trim().length > 0)
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || right.name || ''))
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || left.name || ''))
return { ok: true, data }
} catch {
return { ok: false, data: [] }
}
},
listReportSafe: async (chainId = 138): Promise<{ ok: boolean; data: TokenListToken[] }> => {
try {
const response = await fetchBlockscoutJson<{ tokens?: TokenListToken[] }>(
`/api/v1/report/token-list?chainId=${chainId}`,
)
const data = (response.tokens || [])
.filter((token) => token.chainId === chainId && typeof token.address === 'string' && token.address.trim().length > 0)
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || left.name || ''))
return { ok: true, data }
} catch {
return { ok: false, data: [] }

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import {
pickBestTokenForSymbol,
resolveHomePriceFeedAddresses,
selectWalletFeaturedTokens,
} from './featuredTokens'
describe('featuredTokens', () => {
it('prefers canonical v1 cUSDC over staged v2 when both share a symbol', () => {
const tokens = [
{
chainId: 138,
symbol: 'cUSDC',
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
decimals: 6,
},
{
chainId: 138,
symbol: 'cUSDC',
address: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d',
decimals: 6,
extensions: { deploymentVersion: 'v2', deploymentStatus: 'staged' },
},
]
expect(pickBestTokenForSymbol(tokens, 'cUSDC')?.address).toBe('0xf22258f57794CC8E06237084b353Ab30fFfa640b')
})
it('selects wallet featured tokens using curated address preference', () => {
const curated = [
{
chainId: 138,
symbol: 'cUSDT',
address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
decimals: 6,
},
]
const catalog = [
...curated,
{
chainId: 138,
symbol: 'cUSDT',
address: '0x9FBfab33882Efe0038DAa608185718b772EE5660',
decimals: 6,
extensions: { deploymentVersion: 'v2', deploymentStatus: 'staged' },
},
{
chainId: 138,
symbol: 'USDT',
address: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
decimals: 6,
},
]
const featured = selectWalletFeaturedTokens(catalog, curated)
expect(featured[0]?.address).toBe('0x93E66202A11B1772E55407B32B44e5Cd8eda7f22')
expect(featured.some((token) => token.symbol === 'USDT')).toBe(true)
})
it('falls back to hardcoded home price feed addresses when catalogs are empty', () => {
expect(resolveHomePriceFeedAddresses([], [])).toEqual([
'0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
'0xf22258f57794CC8E06237084b353Ab30fFfa640b',
'0x290E52a8819A4fBd0714E517225429AA2B70EC6b',
'0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
])
})
})

View File

@@ -0,0 +1,162 @@
export const CHAIN138_ID = 138
export const CURATED_FEATURED_SYMBOLS = ['cUSDT', 'cUSDC', 'cXAUC', 'cXAUT', 'cEURT', 'USDT'] as const
export const WALLET_FEATURED_SYMBOLS = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT'] as const
export const HOME_EXTRA_PRICE_SYMBOLS = ['WETH'] as const
export const FALLBACK_HOME_PRICE_FEED_ADDRESSES = [
'0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
'0xf22258f57794CC8E06237084b353Ab30fFfa640b',
'0x290E52a8819A4fBd0714E517225429AA2B70EC6b',
'0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
] as const
export const HOME_DASHBOARD_REFRESH_MS = 60_000
export const HOME_PRICE_FEED_REFRESH_MS = 60_000
export const WALLET_SNAPSHOT_REFRESH_MS = 60_000
export interface FeaturedTokenCandidate {
chainId?: number
symbol?: string
address?: string
name?: string
decimals?: number
logoURI?: string
tags?: string[]
extensions?: Record<string, unknown>
}
function normalizeSymbol(symbol?: string): string {
return (symbol || '').trim()
}
function normalizeAddressKey(address?: string): string {
return (address || '').trim().toLowerCase()
}
function isValidAddress(address?: string): address is string {
return typeof address === 'string' && /^0x[a-fA-F0-9]{40}$/.test(address.trim())
}
export function isStagedFeaturedToken(token: FeaturedTokenCandidate): boolean {
const extensions = token.extensions || {}
const deploymentVersion = String(extensions.deploymentVersion || '').toLowerCase()
const deploymentStatus = String(extensions.deploymentStatus || '').toLowerCase()
return deploymentVersion === 'v2' || deploymentStatus === 'staged'
}
export function featuredTokenRank(
token: FeaturedTokenCandidate,
curatedAddressKeys?: ReadonlySet<string>,
): number {
let rank = 0
if (isStagedFeaturedToken(token)) rank += 100
const addressKey = normalizeAddressKey(token.address)
if (curatedAddressKeys && addressKey && !curatedAddressKeys.has(addressKey)) {
rank += 10
}
return rank
}
export function pickBestTokenForSymbol(
tokens: FeaturedTokenCandidate[],
symbol: string,
options?: { chainId?: number; curatedAddressKeys?: ReadonlySet<string> },
): FeaturedTokenCandidate | null {
const chainId = options?.chainId ?? CHAIN138_ID
const normalizedSymbol = normalizeSymbol(symbol)
const matches = tokens.filter(
(token) =>
token.chainId === chainId &&
normalizeSymbol(token.symbol) === normalizedSymbol &&
isValidAddress(token.address),
)
if (matches.length === 0) return null
return [...matches].sort(
(left, right) =>
featuredTokenRank(left, options?.curatedAddressKeys) -
featuredTokenRank(right, options?.curatedAddressKeys),
)[0]
}
export function selectFeaturedTokensBySymbol(
tokens: FeaturedTokenCandidate[],
preferredSymbols: readonly string[],
options?: { chainId?: number; limit?: number; curatedAddressKeys?: ReadonlySet<string> },
): FeaturedTokenCandidate[] {
const selected = preferredSymbols
.map((symbol) => pickBestTokenForSymbol(tokens, symbol, options))
.filter((token): token is FeaturedTokenCandidate & { address: string } => Boolean(token?.address))
const deduped: FeaturedTokenCandidate[] = []
const seen = new Set<string>()
for (const token of selected) {
const key = normalizeAddressKey(token.address)
if (seen.has(key)) continue
seen.add(key)
deduped.push(token)
}
if (deduped.length > 0) {
return options?.limit ? deduped.slice(0, options.limit) : deduped
}
const fallback = tokens
.filter((token) => (options?.chainId ?? CHAIN138_ID) === token.chainId && isValidAddress(token.address))
.sort(
(left, right) =>
featuredTokenRank(left, options?.curatedAddressKeys) -
featuredTokenRank(right, options?.curatedAddressKeys),
)
return options?.limit ? fallback.slice(0, options.limit) : fallback.slice(0, 6)
}
export function buildCuratedAddressKeys(tokens: FeaturedTokenCandidate[]): Set<string> {
return new Set(
tokens
.filter((token) => token.chainId === CHAIN138_ID && isValidAddress(token.address))
.map((token) => normalizeAddressKey(token.address)),
)
}
export function selectCuratedFeaturedTokens(curatedTokens: FeaturedTokenCandidate[]): FeaturedTokenCandidate[] {
return selectFeaturedTokensBySymbol(curatedTokens, CURATED_FEATURED_SYMBOLS, { chainId: CHAIN138_ID })
}
export function selectWalletFeaturedTokens(
catalogTokens: FeaturedTokenCandidate[],
curatedTokens: FeaturedTokenCandidate[] = [],
): FeaturedTokenCandidate[] {
const curatedAddressKeys = buildCuratedAddressKeys(curatedTokens)
return selectFeaturedTokensBySymbol(catalogTokens, WALLET_FEATURED_SYMBOLS, {
chainId: CHAIN138_ID,
curatedAddressKeys: curatedAddressKeys.size > 0 ? curatedAddressKeys : undefined,
})
}
export function selectHomePriceFeedTokens(
curatedTokens: FeaturedTokenCandidate[],
reportTokens: FeaturedTokenCandidate[] = [],
): FeaturedTokenCandidate[] {
const curatedAddressKeys = buildCuratedAddressKeys(curatedTokens)
const pool = reportTokens.length > 0 ? reportTokens : curatedTokens
return selectFeaturedTokensBySymbol(pool, [...CURATED_FEATURED_SYMBOLS, ...HOME_EXTRA_PRICE_SYMBOLS], {
chainId: CHAIN138_ID,
curatedAddressKeys: curatedAddressKeys.size > 0 ? curatedAddressKeys : undefined,
})
}
export function resolveHomePriceFeedAddresses(
curatedTokens: FeaturedTokenCandidate[],
reportTokens: FeaturedTokenCandidate[] = [],
): string[] {
const fromCatalog = selectHomePriceFeedTokens(curatedTokens, reportTokens)
.map((token) => token.address)
.filter(isValidAddress)
if (fromCatalog.length > 0) return fromCatalog
return [...FALLBACK_HOME_PRICE_FEED_ADDRESSES]
}

View File

@@ -201,6 +201,31 @@ for url in external_roots:
except Exception as exc:
print(f"ERR {url} [{exc}]")
print("\n== Native ETH USD (token-aggregation WETH proxy) ==")
weth = "0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
try:
cg = session.get(
"https://api.coingecko.com/api/v3/simple/price",
params={"ids": "ethereum", "vs_currencies": "usd"},
timeout=15,
).json()
cg_usd = float(cg["ethereum"]["usd"])
ta = session.get(
f"{base}/token-aggregation/api/v1/tokens/{weth}",
params={"chainId": 138},
timeout=20,
).json()
ta_usd = float(ta["token"]["market"]["priceUsd"])
delta_pct = abs(ta_usd - cg_usd) / cg_usd * 100.0
layer = ta.get("token", {}).get("pricing", {}).get("sourceLayer", "")
print(f" coingecko=${cg_usd:.2f} explorer=${ta_usd:.2f} delta={delta_pct:.2f}% layer={layer}")
if abs(ta_usd - 2490.0) < 0.01:
mark_failure(" ETH price still at stale repo snapshot $2490")
if delta_pct > 5.0:
mark_failure(f" ETH price drift {delta_pct:.2f}% exceeds 5% vs CoinGecko")
except Exception as exc:
mark_failure(f" native ETH price check failed: {exc}")
if failed:
sys.exit(1)
PY