From 4b747f0309860aa544f3b03a6f342795d30934c3 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Fri, 22 May 2026 17:58:27 -0700 Subject: [PATCH] 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 --- backend/api/rest/stats.go | 72 +++++++- backend/api/rest/stats_internal_test.go | 30 ++++ .../libs/frontend-api-client/api-base.test.ts | 9 + frontend/libs/frontend-api-client/api-base.ts | 24 ++- frontend/public/explorer-spa.js | 138 ++++++++++++--- frontend/public/index.html | 2 +- .../components/common/PaginationControls.tsx | 60 +++++++ .../src/components/common/SectionTabs.tsx | 45 +++++ .../explorer/LiquidityOperationsPage.tsx | 15 +- frontend/src/components/home/HomePage.tsx | 63 +++++-- .../src/components/wallet/AddToMetaMask.tsx | 39 +++-- frontend/src/components/wallet/WalletPage.tsx | 101 ++++++----- frontend/src/pages/addresses/[address].tsx | 81 +++++++-- frontend/src/pages/tokens/[address].tsx | 69 ++++++-- frontend/src/pages/tokens/index.tsx | 13 +- frontend/src/pages/transactions/[hash].tsx | 115 +++++++++++-- frontend/src/pages/wallet/index.tsx | 2 +- frontend/src/services/api/contracts.ts | 3 +- frontend/src/services/api/tokenAggregation.ts | 44 +++++ frontend/src/services/api/tokens.ts | 16 +- frontend/src/utils/featuredTokens.test.ts | 68 ++++++++ frontend/src/utils/featuredTokens.ts | 162 ++++++++++++++++++ scripts/check-explorer-health.sh | 25 +++ 23 files changed, 1030 insertions(+), 166 deletions(-) create mode 100644 frontend/src/components/common/PaginationControls.tsx create mode 100644 frontend/src/components/common/SectionTabs.tsx create mode 100644 frontend/src/utils/featuredTokens.test.ts create mode 100644 frontend/src/utils/featuredTokens.ts diff --git a/backend/api/rest/stats.go b/backend/api/rest/stats.go index 4c0ce7e..c26b787 100644 --- a/backend/api/rest/stats.go +++ b/backend/api/rest/stats.go @@ -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") diff --git a/backend/api/rest/stats_internal_test.go b/backend/api/rest/stats_internal_test.go index a36127a..381c524 100644 --- a/backend/api/rest/stats_internal_test.go +++ b/backend/api/rest/stats_internal_test.go @@ -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") +} diff --git a/frontend/libs/frontend-api-client/api-base.test.ts b/frontend/libs/frontend-api-client/api-base.test.ts index b64c95c..30b9072 100644 --- a/frontend/libs/frontend-api-client/api-base.test.ts +++ b/frontend/libs/frontend-api-client/api-base.test.ts @@ -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({ diff --git a/frontend/libs/frontend-api-client/api-base.ts b/frontend/libs/frontend-api-client/api-base.ts index d6ff0d1..a07ae51 100644 --- a/frontend/libs/frontend-api-client/api-base.ts +++ b/frontend/libs/frontend-api-client/api-base.ts @@ -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 } diff --git a/frontend/public/explorer-spa.js b/frontend/public/explorer-spa.js index e9e2e17..6a22d1e 100644 --- a/frontend/public/explorer-spa.js +++ b/frontend/public/explorer-spa.js @@ -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 'View on ' + escapeHtml(explorer.label) + ''; } - // 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 @@
CCIP Bridge Ecosystem
${sourceBridgeCount} source contracts / ${mainnetBridgeCount} mainnet contracts / ${routeBridgeCount} unique route contracts
- Cross-chain interoperability powered by Chainlink CCIP + Cross-chain interoperability powered by Chainlink CCIP. Route catalog source: ${escapeHtml(bridgeRoutesSource)}.
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 @@

Ethereum Mainnet Bridges

- ${sourceBridgeCount} source contracts / ${mainnetBridgeCount} mainnet contracts / ${routeBridgeCount} unique route contracts + Relay + CCIP hubs + cW L2
-
+
-
CCIPWETH9Bridge
+
CCIPRelayBridge (138→1 WETH9)
- ${WETH9_BRIDGE_MAINNET} + ${WETH9_BRIDGE_MAINNET_RELAY}
- +
+
CCIPWETH9Bridge (hub)
+
+ ${WETH9_BRIDGE_MAINNET_HUB}
@@ -5531,8 +5608,17 @@
${WETH10_BRIDGE_MAINNET}
- +
+
CWMultiTokenBridge (cW*)
+
+ ${CW_BRIDGE_MAINNET} +
+
+
+
CW L1 bridge (Chain 138)
+
+ ${explorerAddressLink(CW_L1_BRIDGE_138, escapeHtml(CW_L1_BRIDGE_138), 'color: inherit; text-decoration: none; font-size: 0.9rem;')}
diff --git a/frontend/public/index.html b/frontend/public/index.html index 003c9d6..7dbce06 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1645,7 +1645,7 @@

Cross-Chain Bridging

Both WETH9 and WETH10 can be bridged to other chains using the CCIP bridge contracts:

diff --git a/frontend/src/components/common/PaginationControls.tsx b/frontend/src/components/common/PaginationControls.tsx new file mode 100644 index 0000000..dc9306a --- /dev/null +++ b/frontend/src/components/common/PaginationControls.tsx @@ -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 ( +
+
+ {label}: page {page} of {pageCount} +
+
+ + {pages.map((candidate) => ( + + ))} + +
+
+ ) +} diff --git a/frontend/src/components/common/SectionTabs.tsx b/frontend/src/components/common/SectionTabs.tsx new file mode 100644 index 0000000..644171f --- /dev/null +++ b/frontend/src/components/common/SectionTabs.tsx @@ -0,0 +1,45 @@ +export interface SectionTab { + id: T + label: string + count?: number +} + +interface SectionTabsProps { + tabs: SectionTab[] + activeTab: T + onChange: (tab: T) => void + className?: string +} + +export default function SectionTabs({ + tabs, + activeTab, + onChange, + className = '', +}: SectionTabsProps) { + return ( +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/components/explorer/LiquidityOperationsPage.tsx b/frontend/src/components/explorer/LiquidityOperationsPage.tsx index a70d6c4..f56ea22 100644 --- a/frontend/src/components/explorer/LiquidityOperationsPage.tsx +++ b/frontend/src/components/explorer/LiquidityOperationsPage.tsx @@ -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({
@@ -363,7 +374,7 @@ export default function LiquidityOperationsPage({
diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index db9cf33..80848a6 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -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 | 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 diff --git a/frontend/src/components/wallet/AddToMetaMask.tsx b/frontend/src/components/wallet/AddToMetaMask.tsx index 906f825..9a74d91 100644 --- a/frontend/src/components/wallet/AddToMetaMask.tsx +++ b/frontend/src/components/wallet/AddToMetaMask.tsx @@ -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 MetaMask’s install allowlist. */ const CHAIN138_OPEN_SNAP_ID = 'npm:chain138-open-snap' as const @@ -351,6 +351,7 @@ export function AddToMetaMask({ ) const [metamaskConfig, setMetamaskConfig] = useState(null) const [metamaskConfigMeta, setMetamaskConfigMeta] = useState(null) + const [curatedTokens, setCuratedTokens] = useState([]) 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() - 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 || []) diff --git a/frontend/src/components/wallet/WalletPage.tsx b/frontend/src/components/wallet/WalletPage.tsx index 401dbc2..54ca02f 100644 --- a/frontend/src/components/wallet/WalletPage.tsx +++ b/frontend/src/components/wallet/WalletPage.tsx @@ -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 | 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 (
diff --git a/frontend/src/pages/addresses/[address].tsx b/frontend/src/pages/addresses/[address].tsx index 481ec4f..485d84c 100644 --- a/frontend/src/pages/addresses/[address].tsx +++ b/frontend/src/pages/addresses/[address].tsx @@ -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>({}) const [nativeAssetPriceUsd, setNativeAssetPriceUsd] = useState() 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[] = [ + ...(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 (
@@ -633,7 +668,9 @@ export default function AddressDetailPage() { - {addressInfo.is_contract && ( + + + {activeTab === 'contract' && addressInfo.is_contract && (
@@ -848,13 +885,13 @@ export default function AddressDetailPage() { )} - {addressInfo.is_contract && contractProfile ? ( + {activeTab === 'contract' && addressInfo.is_contract && contractProfile ? ( ) : null} - {gruProfile ?
: null} + {activeTab === 'contract' && gruProfile ?
: null} - + {activeTab === 'balances' ? {gruBalanceCount > 0 ? (
{gruBalanceCount} visible token balance{gruBalanceCount === 1 ? '' : 's'} look GRU-aware. @@ -865,13 +902,19 @@ export default function AddressDetailPage() { ) : null} balance.token_address || `${balance.token_symbol}-${balance.value}`} /> - + + : null} - + {activeTab === 'transfers' ? {gruTransferCount > 0 ? (
{gruTransferCount} recent transfer asset{gruTransferCount === 1 ? '' : 's'} carry GRU posture in the explorer. @@ -885,20 +928,32 @@ export default function AddressDetailPage() { ) : null}
`${transfer.transaction_hash}-${transfer.token_address}-${transfer.value}`} /> - + + : null} - + {activeTab === 'transactions' ?
tx.hash} /> - + + : null} )} diff --git a/frontend/src/pages/tokens/[address].tsx b/frontend/src/pages/tokens/[address].tsx index 64a7f89..47420f0 100644 --- a/frontend/src/pages/tokens/[address].tsx +++ b/frontend/src/pages/tokens/[address].tsx @@ -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(null) const [contractProfile, setContractProfile] = useState(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[] = [ + { 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() { - + + + {activeTab === 'intelligence' ?
Market Context
@@ -403,11 +441,11 @@ export default function TokenDetailPage() {
-
+
: null} - {gruProfile ? : null} + {activeTab === 'standards' && gruProfile ? : null} - {gruExplorerMetadata ? ( + {activeTab === 'standards' && gruExplorerMetadata ? (
@@ -450,7 +488,7 @@ export default function TokenDetailPage() { ) : null} - {gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? ( + {activeTab === 'standards' && gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? (

@@ -483,17 +521,18 @@ export default function TokenDetailPage() { ) : null} - + {activeTab === 'holders' ?

holder.address} /> - + + : null} - + {activeTab === 'transfers' ? {gruExplorerMetadata ? (
@@ -509,20 +548,22 @@ export default function TokenDetailPage() { ) : null}
`${transfer.transaction_hash}-${transfer.value}-${transfer.from_address}`} /> - + + : null} - + {activeTab === 'liquidity' ?
pool.address} /> - + + : null} )} diff --git a/frontend/src/pages/tokens/index.tsx b/frontend/src/pages/tokens/index.tsx index 3cf8d4e..73aab2c 100644 --- a/frontend/src/pages/tokens/index.tsx +++ b/frontend/src/pages/tokens/index.tsx @@ -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 diff --git a/frontend/src/pages/transactions/[hash].tsx b/frontend/src/pages/transactions/[hash].tsx index 543a98a..bfbd0af 100644 --- a/frontend/src/pages/transactions/[hash].tsx +++ b/frontend/src/pages/transactions/[hash].tsx @@ -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(null) const [historicalTokenPrices, setHistoricalTokenPrices] = useState>({}) const [historicalNativePrice, setHistoricalNativePrice] = useState(null) + const [checkpointAttestation, setCheckpointAttestation] = useState(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 (
-
{totalUsd != null ? formatUsd(totalUsd) : 'Unavailable'}
+
{totalUsd != null && Number.isFinite(totalUsd) ? formatUsd(totalUsd) : 'Unavailable'}
- Unit price: {formatUsd(historicalPrice?.priceUsd)} + Unit price: {checkpointLine?.valueUsd != null ? 'checkpoint leaf' : formatUsd(historicalPrice?.priceUsd)}
- Source: {formatHistoricalPriceSource(historicalPrice?.source)} + Source: {checkpointLine ? `checkpoint (${priceSource || 'enriched'})` : formatHistoricalPriceSource(priceSource)}
) @@ -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[] = [ + { 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 (
@@ -406,7 +473,13 @@ export default function TransactionDetailPage() { {nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
Transfer-time native price: {historicalNativePrice?.priceUsd != null ? `${formatUsd(historicalNativePrice.priceUsd)} per ${nativeAssetSymbol}` : 'Unavailable'}
-
Pricing source: {formatHistoricalPriceSource(historicalNativePrice?.source)}
+
Pricing source: {checkpointLeaf ? `checkpoint mirror (${checkpointLeaf.priceSource || 'enriched'})` : formatHistoricalPriceSource(historicalNativePrice?.source)}
+ {checkpointAttestation ? ( +
+ Checkpoint batch #{checkpointAttestation.batchId} + {checkpointTotalUsd != null ? ` — total ${formatUsd(Number(checkpointTotalUsd))}` : ''} +
+ ) : null}
Token transfers: {tokenTransferCount.toLocaleString()}
Internal calls: {internalCallCount.toLocaleString()}
@@ -430,7 +503,9 @@ export default function TransactionDetailPage() { - {complianceAssessment ? ( + + + {activeTab === 'evidence' && complianceAssessment ? (
@@ -458,7 +533,7 @@ export default function TransactionDetailPage() { ) : null} - + {activeTab === 'details' ?
@@ -522,9 +597,9 @@ export default function TransactionDetailPage() { )}
-
+
: null} - {transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && ( + {activeTab === 'details' && transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (

@@ -553,25 +628,27 @@ export default function TransactionDetailPage() { )} - + {activeTab === 'transfers' ?

`${transfer.token_address}-${transfer.from_address}-${transfer.to_address}-${transfer.amount}`} /> - + + : null} - + {activeTab === 'internal' ?
`${call.from_address}-${call.to_address || call.contract_address || 'unknown'}-${call.value}-${call.type || 'call'}`} /> - + + : null} - {transaction.input_data && ( + {activeTab === 'raw' && transaction.input_data && (
                 {transaction.input_data}
diff --git a/frontend/src/pages/wallet/index.tsx b/frontend/src/pages/wallet/index.tsx
index 5e92fef..a434268 100644
--- a/frontend/src/pages/wallet/index.tsx
+++ b/frontend/src/pages/wallet/index.tsx
@@ -24,7 +24,7 @@ export default function WalletRoutePage(props: WalletRoutePageProps) {
 export const getServerSideProps: GetServerSideProps = async () => {
   const [networksResult, tokenListResult, capabilitiesResult] = await Promise.all([
     fetchPublicJsonWithMeta('/api/config/networks').catch(() => null),
-    fetchPublicJsonWithMeta('/api/config/token-list').catch(() => null),
+    fetchPublicJsonWithMeta('/api/v1/report/token-list?chainId=138').catch(() => null),
     fetchPublicJsonWithMeta('/api/config/capabilities').catch(() => null),
   ])
 
diff --git a/frontend/src/services/api/contracts.ts b/frontend/src/services/api/contracts.ts
index 0b8e7d4..4e25551 100644
--- a/frontend/src/services/api/contracts.ts
+++ b/frontend/src/services/api/contracts.ts
@@ -98,7 +98,8 @@ interface ABIEntry {
 }
 
 async function fetchCompatJson(params: URLSearchParams): Promise {
-  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}`)
   }
diff --git a/frontend/src/services/api/tokenAggregation.ts b/frontend/src/services/api/tokenAggregation.ts
index 80e1198..91c8649 100644
--- a/frontend/src/services/api/tokenAggregation.ts
+++ b/frontend/src/services/api/tokenAggregation.ts
@@ -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,
diff --git a/frontend/src/services/api/tokens.ts b/frontend/src/services/api/tokens.ts
index 65e2d81..0bef994 100644
--- a/frontend/src/services/api/tokens.ts
+++ b/frontend/src/services/api/tokens.ts
@@ -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: [] }
diff --git a/frontend/src/utils/featuredTokens.test.ts b/frontend/src/utils/featuredTokens.test.ts
new file mode 100644
index 0000000..a56a00a
--- /dev/null
+++ b/frontend/src/utils/featuredTokens.test.ts
@@ -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',
+    ])
+  })
+})
diff --git a/frontend/src/utils/featuredTokens.ts b/frontend/src/utils/featuredTokens.ts
new file mode 100644
index 0000000..4e3def6
--- /dev/null
+++ b/frontend/src/utils/featuredTokens.ts
@@ -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
+}
+
+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,
+): 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 },
+): 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 },
+): 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()
+  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 {
+  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]
+}
diff --git a/scripts/check-explorer-health.sh b/scripts/check-explorer-health.sh
index 51d06e8..9c7fe2f 100755
--- a/scripts/check-explorer-health.sh
+++ b/scripts/check-explorer-health.sh
@@ -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