diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9805da2..acf4ee3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,6 +29,7 @@ "@types/react-dom": "^18.2.18", "eslint": "^8.56.0", "eslint-config-next": "^15.5.15", + "playwright": "1.51.0", "typescript": "^5.3.3", "vitest": "^4.1.5" }, @@ -14578,6 +14579,53 @@ "integrity": "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.0.tgz", + "integrity": "sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.51.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.0.tgz", + "integrity": "sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index cb4c35f..6219ba2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "build": "next build", "build:check": "npm run lint && npm run type-check && npm run build", "smoke:routes": "node ./scripts/smoke-routes.mjs", + "smoke:scroll": "node ./scripts/smoke-scroll-height.mjs", "start": "PORT=${PORT:-3000} node ./scripts/start-standalone.mjs", "start:next": "next start", "lint": "eslint src libs next.config.js --ext .js,.jsx,.ts,.tsx", @@ -41,6 +42,7 @@ "@types/react-dom": "^18.2.18", "eslint": "^8.56.0", "eslint-config-next": "^15.5.15", + "playwright": "1.51.0", "typescript": "^5.3.3", "vitest": "^4.1.5" }, diff --git a/frontend/public/chain138-command-center.meta.json b/frontend/public/chain138-command-center.meta.json index 0ba3f3b..7782ce5 100644 --- a/frontend/public/chain138-command-center.meta.json +++ b/frontend/public/chain138-command-center.meta.json @@ -1,6 +1,6 @@ { - "bundleVersion": "2026-06-17", - "updatedAt": "2026-06-17T10:57:10Z", + "bundleVersion": "2026-06-22", + "updatedAt": "2026-06-22T22:33:55Z", "sourceDoc": "explorer-monorepo/docs/CHAIN138_VISUAL_TOPOLOGY_SOURCE.md", "routeMatrixUpdated": null } diff --git a/frontend/public/explorer-spa.js b/frontend/public/explorer-spa.js index d0e11b1..69e7c1c 100644 --- a/frontend/public/explorer-spa.js +++ b/frontend/public/explorer-spa.js @@ -2200,10 +2200,31 @@ return []; } + async function fetchChain138WatchAssets() { + if (window._chain138WatchAssetsCache) return window._chain138WatchAssetsCache; + try { + var res = await fetch('/api/v1/config/metamask?chainId=138', { cache: 'no-store' }); + if (res.ok) { + var data = await res.json(); + window._chain138WatchAssetsCache = data && Array.isArray(data.watchAssets) ? data.watchAssets : []; + return window._chain138WatchAssetsCache; + } + } catch (e) {} + return []; + } + async function resolveTokenLogoUri(address) { if (!address) return undefined; - var tokens = await fetchChain138ReportTokenList(); var normalized = String(address).toLowerCase(); + var watchAssets = await fetchChain138WatchAssets(); + for (var w = 0; w < watchAssets.length; w++) { + var entry = watchAssets[w]; + var watchAddr = entry && entry.options && entry.options.address ? String(entry.options.address).toLowerCase() : ''; + if (watchAddr === normalized && entry.options.image) { + return entry.options.image; + } + } + var tokens = await fetchChain138ReportTokenList(); for (var i = 0; i < tokens.length; i++) { var token = tokens[i]; if (token && token.address && String(token.address).toLowerCase() === normalized) { @@ -2223,12 +2244,13 @@ return; } try { + await switchToChain138(); var meta = await fetchTokenMetadataFromChain(address); if (!meta) { - if (typeof showToast === 'function') showToast('Could not read token from chain. Switch to the correct network and try again.', 'error'); + if (typeof showToast === 'function') showToast('Could not read token from chain. Switch to DeFi Oracle Meta Mainnet and try again.', 'error'); return; } - var useSymbol = (meta.symbol !== undefined && meta.symbol !== null) ? meta.symbol : 'TOKEN'; + var useSymbol = (meta.symbol !== undefined && meta.symbol !== null) ? meta.symbol : (symbol || 'TOKEN'); var useName = (meta.name !== undefined && meta.name !== null) ? meta.name : (name || ''); var useDecimals = (typeof meta.decimals === 'number') ? meta.decimals : (typeof decimals === 'number' ? decimals : 18); if (useSymbol === '' || (typeof useSymbol === 'string' && useSymbol.trim() === '')) { @@ -2236,22 +2258,24 @@ return; } var logoURI = await resolveTokenLogoUri(address); + var watchOptions = { + address: address, + symbol: (useSymbol !== undefined && useSymbol !== null) ? useSymbol : 'TOKEN', + decimals: useDecimals, + chainId: '0x8a' + }; + if (useName) watchOptions.name = useName; + if (logoURI) watchOptions.image = logoURI; var added = await window.ethereum.request({ method: 'wallet_watchAsset', params: { type: 'ERC20', - options: { - address: address, - symbol: (useSymbol !== undefined && useSymbol !== null) ? useSymbol : 'TOKEN', - decimals: useDecimals, - name: useName || undefined, - image: logoURI - } + options: watchOptions } }); if (typeof showToast === 'function') { var displaySym = useSymbol || symbol || 'Token'; - showToast(added ? (displaySym ? displaySym + ' added to wallet' : 'Token added to wallet') : 'Add token was cancelled', added ? 'success' : 'info'); + showToast(added ? (displaySym ? displaySym + ' added to wallet on Chain 138' : 'Token added to wallet on Chain 138') : 'Add token was cancelled', added ? 'success' : 'info'); } } catch (e) { var msg = (e && e.message) ? String(e.message) : ''; @@ -2356,7 +2380,7 @@ } async function switchToChain138() { - const chainId = '0x8A'; // 138 in hex + const chainId = '0x8a'; // 138 in hex — lowercase matches MetaMask/EIP-3085 payloads try { await window.ethereum.request({ method: 'wallet_switchEthereumChain', @@ -2367,19 +2391,25 @@ if (switchError.code === 4902) { try { var chainPayload = await fetchChain138AddEthereumChainPayload(); + var fallbackPayload = { + chainId: chainId, + chainName: 'DeFi Oracle Meta Mainnet', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18 + }, + rpcUrls: RPC_URLS.length > 0 ? RPC_URLS : [RPC_URL], + blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'], + iconUrls: [ + 'https://explorer.d-bis.org/api/v1/report/logo/chain-138', + 'https://explorer.d-bis.org/token-icons/chain-138.png', + 'https://explorer.d-bis.org/favicon.ico' + ] + }; await window.ethereum.request({ method: 'wallet_addEthereumChain', - params: [chainPayload || { - chainId, - chainName: 'DeFi Oracle Meta Mainnet', - nativeCurrency: { - name: 'Ether', - symbol: 'ETH', - decimals: 18 - }, - rpcUrls: RPC_URLS.length > 0 ? RPC_URLS : [RPC_URL], - blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'] - }], + params: [chainPayload || fallbackPayload], }); } catch (addError) { throw addError; diff --git a/frontend/scripts/smoke-routes.mjs b/frontend/scripts/smoke-routes.mjs index f3e1aca..ba4e168 100644 --- a/frontend/scripts/smoke-routes.mjs +++ b/frontend/scripts/smoke-routes.mjs @@ -4,15 +4,22 @@ const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/ const addressUnderTest = process.env.SMOKE_ADDRESS || '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506' const checks = [ - { path: '/', expectTexts: ['DBIS Explorer', 'Recent Blocks', 'Network overview'] }, + { path: '/', expectTexts: ['DBIS Explorer', 'Institutional quick paths'] }, + { path: '/bridge', expectTexts: ['Bridge'] }, + { path: '/routes', expectTexts: ['Routes'] }, + { path: '/operations', expectTexts: ['Operations'] }, + { path: '/system', expectTexts: ['System'] }, + { path: '/operator', expectTexts: ['Operator'] }, + { path: '/analytics', expectTexts: ['Analytics'] }, + { path: '/liquidity', expectTexts: ['Liquidity'] }, { path: '/blocks', expectTexts: ['Blocks'] }, { path: '/transactions', expectTexts: ['Transactions'] }, - { path: '/addresses', expectTexts: ['Addresses', 'Open An Address'] }, - { path: '/watchlist', expectTexts: ['Watchlist', 'Saved Addresses'] }, - { path: '/pools', expectTexts: ['Pools', 'Pool operation shortcuts'] }, - { path: '/liquidity', expectTexts: ['Liquidity', 'Explorer Access Points'] }, - { path: '/wallet', expectTexts: ['Wallet Tools', 'WalletConnect v2 posture'] }, - { path: '/tokens', expectTexts: ['Tokens', 'Find a token'] }, + { path: '/addresses', expectTexts: ['Addresses', 'Open address'] }, + { path: '/watchlist', expectTexts: ['Watchlist', 'Saved addresses'] }, + { path: '/pools', expectTexts: ['Pools'] }, + { path: '/wallet', expectTexts: ['Wallet Tools', 'WalletConnect'] }, + { path: '/access', expectTexts: ['Wallet Login', 'RPC Access'] }, + { path: '/tokens', expectTexts: ['Tokens'] }, { path: '/search', expectTexts: ['Search'], placeholder: 'Search by address, transaction hash, block number...' }, { path: `/addresses/${addressUnderTest}`, expectTexts: [], anyOfTexts: ['Back to addresses', 'Address not found'] }, ] @@ -26,11 +33,15 @@ async function hasShell(page) { .getByRole('link', { name: /Go to DBIS Explorer home|Go to explorer home/i }) .isVisible() .catch(() => false) - const supportText = await page.getByText(/Support:/i).isVisible().catch(() => false) - return homeLink && supportText + const dbisTitle = await page + .getByText(/DBIS Explorer/i) + .first() + .isVisible() + .catch(() => false) + return homeLink || dbisTitle } -async function waitForBodyText(page, snippets, timeoutMs = 15000) { +async function waitForBodyText(page, snippets, timeoutMs = 20000) { if (!snippets || snippets.length === 0) { return bodyText(page) } @@ -59,7 +70,7 @@ async function main() { try { const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }) - await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}) if (!response || !response.ok()) { console.error(`FAIL ${check.path}: HTTP ${response ? response.status() : 'no-response'}`) diff --git a/frontend/scripts/smoke-scroll-height.mjs b/frontend/scripts/smoke-scroll-height.mjs new file mode 100644 index 0000000..6acd15d --- /dev/null +++ b/frontend/scripts/smoke-scroll-height.mjs @@ -0,0 +1,90 @@ +import { chromium } from 'playwright' + +const baseUrl = (process.env.BASE_URL || 'https://explorer.d-bis.org').replace(/\/$/, '') +const viewportHeight = Number(process.env.SMOKE_VIEWPORT_HEIGHT || 720) +const maxViewportHeights = Number(process.env.SMOKE_MAX_VIEWPORT_HEIGHTS || 2.5) + +const routeLimits = new Map( + (process.env.SMOKE_SCROLL_ROUTE_LIMITS || ' /=4,/operator=4,/transactions=3') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [path, limit] = entry.split('=') + return [path, Number(limit)] + }), +) + +function limitForRoute(path) { + return routeLimits.get(path) ?? maxViewportHeights +} + +const routes = [ + '/', + '/bridge', + '/routes', + '/operations', + '/system', + '/operator', + '/analytics', + '/liquidity', + '/blocks', + '/transactions', + '/addresses', + '/wallet', + '/access', +] + +async function main() { + const browser = await chromium.launch({ headless: true }) + const page = await browser.newPage({ + viewport: { width: 1280, height: viewportHeight }, + }) + let failures = 0 + + for (const path of routes) { + const url = `${baseUrl}${path}` + + try { + const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }) + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}) + + if (!response || !response.ok()) { + console.error(`FAIL ${path}: HTTP ${response ? response.status() : 'no-response'}`) + failures += 1 + continue + } + + const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight) + const routeLimit = limitForRoute(path) + const limit = viewportHeight * routeLimit + + if (scrollHeight > limit) { + console.error( + `FAIL ${path}: scrollHeight ${scrollHeight}px exceeds ${routeLimit}× viewport (${Math.round(limit)}px)`, + ) + failures += 1 + continue + } + + console.log(`OK ${path} (${scrollHeight}px)`) + } catch (error) { + console.error(`FAIL ${path}: ${error instanceof Error ? error.message : String(error)}`) + failures += 1 + } + } + + await browser.close() + + if (failures > 0) { + process.exitCode = 1 + return + } + + console.log(`Scroll-height smoke passed for ${routes.length} routes at ${viewportHeight}px viewport`) +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)) + process.exitCode = 1 +}) diff --git a/frontend/src/components/access/AccessManagementPage.tsx b/frontend/src/components/access/AccessManagementPage.tsx index aef6afc..bc4cec6 100644 --- a/frontend/src/components/access/AccessManagementPage.tsx +++ b/frontend/src/components/access/AccessManagementPage.tsx @@ -1,7 +1,9 @@ -import { FormEvent, useCallback, useEffect, useState } from 'react' +import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { Card } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' +import DisclosureSection from '@/components/common/DisclosureSection' import EntityBadge from '@/components/common/EntityBadge' import { accessApi, @@ -13,6 +15,7 @@ import { type AccessUser, type WalletAccessSession, } from '@/services/api/access' +import { formatDate, formatInteger, formatTimestamp } from '@/utils/format' const ACCESS_SCOPE_OPTIONS = ['rpc:read', 'rpc:write', 'rpc:admin'] as const const OPERATOR_IDENTITIES = [ @@ -88,6 +91,18 @@ export default function AccessManagementPage() { const [createdKey, setCreatedKey] = useState('') const [message, setMessage] = useState('') const [error, setError] = useState('') + const [accessTab, setAccessTab] = useState<'products' | 'account' | 'admin'>('products') + + const accessTabs = useMemo((): SectionTab<'products' | 'account' | 'admin'>[] => { + const tabs: SectionTab<'products' | 'account' | 'admin'>[] = [ + { id: 'products', label: 'Products', count: products.length }, + { id: 'account', label: 'Account & keys' }, + ] + if (user?.is_admin) { + tabs.push({ id: 'admin', label: 'Admin' }) + } + return tabs + }, [products.length, user?.is_admin]) const clearSessionState = useCallback(() => { setUser(null) @@ -349,15 +364,15 @@ export default function AccessManagementPage() { OPERATOR_IDENTITIES.find((entry) => entry.slug === productSlug) return ( -
+
@@ -372,7 +387,17 @@ export default function AccessManagementPage() { ) : null} -
+ + + +
{products.map((product) => (
@@ -446,10 +471,13 @@ export default function AccessManagementPage() { ))}
+ -
-
- + +
+
+ +

Use a connected wallet for standard account sign-in, then access subscriptions, API keys, and managed RPC controls with the same authenticated session. @@ -467,7 +495,7 @@ export default function AccessManagementPage() { Your wallet address remains private within the access console. This session is treated as account sign-in, not a public identifier.

- Session expires {new Date(walletSession.expiresAt).toLocaleString()} + Session expires {formatTimestamp(walletSession.expiresAt)}
- - -
-
-
- ))} -
- ) : ( -

No subscriptions match the current review filter.

- )} -
- ) : null} - - {user?.is_admin ? ( - -
- - -
- {adminAuditEntries.length > 0 ? ( -
- {adminAuditEntries.map((entry) => ( -
-
- - - -
-
{entry.keyName || entry.apiKeyId}
-
- {new Date(entry.createdAt).toLocaleString()} - {entry.lastIp ? ` · ${entry.lastIp}` : ''} -
-
- ))} -
- ) : ( -

No recent validated RPC traffic matches the current filter.

- )} -
- ) : null}
@@ -758,11 +657,11 @@ export default function AccessManagementPage() { {key.approved ? : } {key.revoked ? : } - {key.expiresAt ? : } + {key.expiresAt ? : }
- Created {new Date(key.createdAt).toLocaleString()} - {key.lastUsedAt ? ` · Last used ${new Date(key.lastUsedAt).toLocaleString()}` : ' · Not used yet'} + Created {formatTimestamp(key.createdAt)} + {key.lastUsedAt ? ` · Last used ${formatTimestamp(key.lastUsedAt)}` : ' · Not used yet'}
{!key.revoked ? ( @@ -816,7 +715,7 @@ export default function AccessManagementPage() {
- {item.requests_used.toLocaleString()} requests used / {item.monthly_quota.toLocaleString()} monthly quota + {formatInteger(item.requests_used)} requests used / {formatInteger(item.monthly_quota)} monthly quota
))} @@ -851,7 +750,7 @@ export default function AccessManagementPage() {
{entry.keyName || entry.apiKeyId}
- {new Date(entry.createdAt).toLocaleString()} + {formatTimestamp(entry.createdAt)} {entry.lastIp ? ` · ${entry.lastIp}` : ''}
@@ -868,6 +767,114 @@ export default function AccessManagementPage() { + + + {user?.is_admin ? ( + +
+ +
+ +
+ {adminSubscriptions.length > 0 ? ( +
+ {adminSubscriptions.map((subscription) => ( +
+
+
+
{subscription.productSlug}
+
+ + + + {subscription.requiresApproval ? : null} +
+ +
+
+ + + +
+
+
+ ))} +
+ ) : ( +

No subscriptions match the current review filter.

+ )} +
+ +
+ + +
+ {adminAuditEntries.length > 0 ? ( +
+ {adminAuditEntries.map((entry) => ( +
+
+ + + +
+
{entry.keyName || entry.apiKeyId}
+
+ {formatTimestamp(entry.createdAt)} + {entry.lastIp ? ` · ${entry.lastIp}` : ''} +
+
+ ))} +
+ ) : ( +

No recent validated RPC traffic matches the current filter.

+ )} +
+
+
+ ) : null}
) } diff --git a/frontend/src/components/common/ActivityContextPanel.tsx b/frontend/src/components/common/ActivityContextPanel.tsx index ff6c867..84c4466 100644 --- a/frontend/src/components/common/ActivityContextPanel.tsx +++ b/frontend/src/components/common/ActivityContextPanel.tsx @@ -1,8 +1,9 @@ import Link from 'next/link' import { Card } from '@/libs/frontend-ui-primitives' import EntityBadge from '@/components/common/EntityBadge' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' import type { ChainActivityContext } from '@/utils/activityContext' -import { formatRelativeAge, formatTimestamp } from '@/utils/format' +import { formatInteger, formatTimestamp } from '@/utils/format' import { Explain, useUiMode } from './UiModeContext' function resolveTone(state: ChainActivityContext['state']): 'success' | 'warning' | 'neutral' { @@ -58,9 +59,14 @@ export default function ActivityContextPanel({ const { mode } = useUiMode() const tone = resolveTone(context.state) const dualTimelineLabel = - context.latest_block_timestamp && context.latest_transaction_timestamp - ? `${formatRelativeAge(context.latest_block_timestamp)} head · ${formatRelativeAge(context.latest_transaction_timestamp)} latest tx` - : 'Dual timeline unavailable' + context.latest_block_timestamp && context.latest_transaction_timestamp ? ( + <> + ·{' '} + + + ) : ( + 'Dual timeline unavailable' + ) return ( @@ -86,11 +92,11 @@ export default function ActivityContextPanel({ {context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'} {' '} - was {formatRelativeAge(context.latest_block_timestamp)}. Latest visible transaction was{' '} + was . Latest visible transaction was{' '} {context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'} {' '} - {formatRelativeAge(context.latest_transaction_timestamp)}.{' '} + .{' '} {mode === 'guided' ? 'Use the block gap and last non-empty block above to tell low activity apart from an indexing issue.' : dualTimelineLabel} @@ -104,11 +110,11 @@ export default function ActivityContextPanel({ {context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'} {' '} - was {formatRelativeAge(context.latest_block_timestamp)}. Latest visible transaction was{' '} + was . Latest visible transaction was{' '} {context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'} {' '} - {formatRelativeAge(context.latest_transaction_timestamp)}. + .
@@ -135,7 +141,7 @@ export default function ActivityContextPanel({ ) : null} {context.block_gap_to_latest_transaction != null ? ( - Block gap to latest visible transaction: {context.block_gap_to_latest_transaction.toLocaleString()} + Block gap to latest visible transaction: {formatInteger(context.block_gap_to_latest_transaction)} ) : null} {context.latest_transaction_timestamp ? ( diff --git a/frontend/src/components/common/ClientRelativeTime.tsx b/frontend/src/components/common/ClientRelativeTime.tsx new file mode 100644 index 0000000..6b8b67c --- /dev/null +++ b/frontend/src/components/common/ClientRelativeTime.tsx @@ -0,0 +1,57 @@ +'use client' + +import { useEffect, useState } from 'react' +import { formatRelativeAgeAt } from '@/utils/format' + +const PLACEHOLDER = '…' + +export default function ClientRelativeTime({ + value, + prefix = '', + suffix = '', + fallback = 'Unknown', + className, +}: { + value?: string | null + prefix?: string + suffix?: string + fallback?: string + className?: string +}) { + const [label, setLabel] = useState(PLACEHOLDER) + + useEffect(() => { + if (!value) { + setLabel(fallback) + return + } + const parsed = Date.parse(value) + if (!Number.isFinite(parsed)) { + setLabel(fallback) + return + } + const update = () => setLabel(formatRelativeAgeAt(Date.now(), value)) + update() + const id = window.setInterval(update, 30_000) + return () => window.clearInterval(id) + }, [value, fallback]) + + const content = label === PLACEHOLDER ? PLACEHOLDER : label + if (className) { + return ( + + {prefix} + {content} + {suffix} + + ) + } + + return ( + + {prefix} + {content} + {suffix} + + ) +} diff --git a/frontend/src/components/common/CompactMetricBar.tsx b/frontend/src/components/common/CompactMetricBar.tsx new file mode 100644 index 0000000..56578bf --- /dev/null +++ b/frontend/src/components/common/CompactMetricBar.tsx @@ -0,0 +1,29 @@ +'use client' + +export type CompactMetric = { + label: string + value: string +} + +export default function CompactMetricBar({ + metrics, + className = 'mb-4', +}: { + metrics: CompactMetric[] + className?: string +}) { + if (metrics.length === 0) return null + + return ( +
+ {metrics.map((metric) => ( + + {metric.label}:{' '} + {metric.value} + + ))} +
+ ) +} diff --git a/frontend/src/components/common/DisclosureSection.tsx b/frontend/src/components/common/DisclosureSection.tsx new file mode 100644 index 0000000..ae18cbb --- /dev/null +++ b/frontend/src/components/common/DisclosureSection.tsx @@ -0,0 +1,70 @@ +'use client' + +import { useId, useState, type ReactNode } from 'react' + +interface DisclosureSectionProps { + title: string + children: ReactNode + defaultOpen?: boolean + className?: string + headingClassName?: string + /** When true, content is always visible and the toggle is hidden (desktop layout). */ + forceOpen?: boolean + /** When true, keep accordion behavior on all breakpoints (no md:auto-expand). */ + alwaysCollapsible?: boolean +} + +export default function DisclosureSection({ + title, + children, + defaultOpen = false, + className = '', + headingClassName = '', + forceOpen = false, + alwaysCollapsible = false, +}: DisclosureSectionProps) { + const [open, setOpen] = useState(defaultOpen || forceOpen) + const panelId = useId().replace(/:/g, '') + const isOpen = forceOpen || open + + return ( +
+ {forceOpen ? ( +
+ {title} +
+ ) : ( + + )} + +
+ {!forceOpen && !alwaysCollapsible ? ( +
+ {title} +
+ ) : null} + {children} +
+
+ ) +} diff --git a/frontend/src/components/common/ExplorerFreshnessDisclosure.tsx b/frontend/src/components/common/ExplorerFreshnessDisclosure.tsx new file mode 100644 index 0000000..5a4b90f --- /dev/null +++ b/frontend/src/components/common/ExplorerFreshnessDisclosure.tsx @@ -0,0 +1,43 @@ +'use client' + +import type { ReactNode } from 'react' +import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' +import type { ExplorerStats } from '@/services/api/stats' +import type { ChainActivityContext } from '@/utils/activityContext' +import ActivityContextPanel from '@/components/common/ActivityContextPanel' +import DisclosureSection from '@/components/common/DisclosureSection' +import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' + +export default function ExplorerFreshnessDisclosure({ + context, + stats, + bridgeStatus, + scopeLabel, + activityTitle = 'Recency', + title = 'Index freshness', + className = 'mb-4', + children, +}: { + context: ChainActivityContext + stats?: ExplorerStats | null + bridgeStatus?: MissionControlBridgeStatusResponse | null + scopeLabel: string + activityTitle?: string + title?: string + className?: string + children?: ReactNode +}) { + return ( + + + + {children} + + ) +} diff --git a/frontend/src/components/common/Footer.tsx b/frontend/src/components/common/Footer.tsx index 75ade5f..d9a6202 100644 --- a/frontend/src/components/common/Footer.tsx +++ b/frontend/src/components/common/Footer.tsx @@ -1,4 +1,6 @@ import Link from 'next/link' +import DisclosureSection from '@/components/common/DisclosureSection' +import FooterLinkGroups from '@/components/common/FooterLinkGroups' import FooterPublicApiLinks from '@/components/common/FooterPublicApiLinks' const footerLinkClass = @@ -8,96 +10,53 @@ export default function Footer() { const year = new Date().getFullYear() return ( -
) diff --git a/frontend/src/components/pools/PoolDetailPage.tsx b/frontend/src/components/pools/PoolDetailPage.tsx index 8f4d844..9fdb6e9 100644 --- a/frontend/src/components/pools/PoolDetailPage.tsx +++ b/frontend/src/components/pools/PoolDetailPage.tsx @@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { Card, Address } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' +import SectionTabs, { TabPanel } from '@/components/common/SectionTabs' +import DisclosureSection from '@/components/common/DisclosureSection' import EntityBadge from '@/components/common/EntityBadge' import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav' import LpPositionPanel from '@/components/wallet/LpPositionPanel' @@ -11,7 +13,7 @@ import { fetchPoolRegistry, type CuratedPoolRegistryEntry, } from '@/services/api/liquidityPositions' -import { useUiMode } from '@/components/common/UiModeContext' +import { formatBigIntWhole, formatTimestamp } from '@/utils/format' function formatUsd(value: number | undefined): string { if (value == null || !Number.isFinite(value)) return 'Unavailable' @@ -36,8 +38,8 @@ function formatReserve(raw: string | undefined, symbol: string): string { .slice(0, 4) .replace(/0+$/, '') return fractionText - ? `${whole.toLocaleString()}.${fractionText} ${symbol}` - : `${whole.toLocaleString()} ${symbol}` + ? `${formatBigIntWhole(whole)}.${fractionText} ${symbol}` + : `${formatBigIntWhole(whole)} ${symbol}` } catch { return 'Unavailable' } @@ -71,9 +73,9 @@ interface PoolDetailPageProps { } export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) { - const { mode } = useUiMode() const [pools, setPools] = useState([]) const [loading, setLoading] = useState(true) + const [poolTab, setPoolTab] = useState<'summary' | 'lp'>('summary') useEffect(() => { let active = true @@ -101,20 +103,16 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) { const asymmetryNote = pool ? stableReserveAsymmetryNote(pool) : null return ( -
+
@@ -142,21 +140,36 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) { ) : null} {pool ? ( -
+ <> {asymmetryNote ? ( - -

- {asymmetryNote.message} -

-
+ + +

+ {asymmetryNote.message} +

+
+
) : null} + + +
@@ -229,7 +242,7 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) { {pool.liquiditySource ?? 'curated registry'} {pool.liquidityAsOf ? ( - As of {new Date(pool.liquidityAsOf).toLocaleString()} + As of {formatTimestamp(pool.liquidityAsOf)} ) : null} @@ -242,14 +255,17 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
+
+ -
+ + ) : null}
) diff --git a/frontend/src/components/wallet/AddToMetaMask.tsx b/frontend/src/components/wallet/AddToMetaMask.tsx index 83dd32e..f9040cd 100644 --- a/frontend/src/components/wallet/AddToMetaMask.tsx +++ b/frontend/src/components/wallet/AddToMetaMask.tsx @@ -1,6 +1,10 @@ 'use client' import { useEffect, useMemo, useState } from 'react' +import ListFilterBar from '@/components/common/ListFilterBar' +import DisclosureSection from '@/components/common/DisclosureSection' +import PaginationControls from '@/components/common/PaginationControls' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base' import { tokensApi } from '@/services/api/tokens' import { selectWalletFeaturedTokens } from '@/utils/featuredTokens' @@ -157,6 +161,17 @@ interface AddToMetaMaskProps { initialCapabilitiesMeta?: FetchMetadata | null } +const FEATURED_TOKENS_PAGE_SIZE = 5 + +type AddToMetaMaskTab = 'networks' | 'tokens' | 'other-chains' | 'advanced' + +const addToMetaMaskTabs: SectionTab[] = [ + { id: 'networks', label: 'Networks' }, + { id: 'tokens', label: 'Tokens' }, + { id: 'other-chains', label: 'Other chains' }, + { id: 'advanced', label: 'API & RPC' }, +] + function chainParamsForWallet(chain: WalletChain) { return toWalletAddEthereumChainParams(chain, { preferSingleRpc: typeof window !== 'undefined' && isMobileWalletContext(), @@ -393,6 +408,9 @@ export function AddToMetaMask({ const [fundedListingError, setFundedListingError] = useState(null) const [pendingWatchFlow, setPendingWatchFlow] = useState(null) const [providerTick, setProviderTick] = useState(0) + const [activeTab, setActiveTab] = useState('networks') + const [tokenQuery, setTokenQuery] = useState('') + const [tokenPage, setTokenPage] = useState(1) const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext() @@ -560,6 +578,35 @@ export function AddToMetaMask({ [catalogTokens, curatedTokens], ) + const filteredFeaturedTokens = useMemo(() => { + const normalizedQuery = tokenQuery.trim().toLowerCase() + if (!normalizedQuery) return featuredTokens + return featuredTokens.filter((token) => { + const haystack = [token.symbol, token.name, token.address, ...(token.tags || [])] + .join(' ') + .toLowerCase() + return haystack.includes(normalizedQuery) + }) + }, [featuredTokens, tokenQuery]) + + const featuredTokenPageCount = Math.max(1, Math.ceil(filteredFeaturedTokens.length / FEATURED_TOKENS_PAGE_SIZE)) + + const paginatedFeaturedTokens = useMemo(() => { + const safePage = Math.min(Math.max(tokenPage, 1), featuredTokenPageCount) + const start = (safePage - 1) * FEATURED_TOKENS_PAGE_SIZE + return filteredFeaturedTokens.slice(start, start + FEATURED_TOKENS_PAGE_SIZE) + }, [filteredFeaturedTokens, featuredTokenPageCount, tokenPage]) + + useEffect(() => { + setTokenPage(1) + }, [tokenQuery]) + + useEffect(() => { + if (tokenPage > featuredTokenPageCount) { + setTokenPage(featuredTokenPageCount) + } + }, [featuredTokenPageCount, tokenPage]) + const watchAssetTokens = useMemo(() => { const endpointTokens = dedupeWalletWatchTokens( (metamaskConfig?.watchAssets || []) @@ -964,214 +1011,96 @@ export function AddToMetaMask({ : capabilitiesUrl return ( -
+

Add to MetaMask

- The wallet tools now read the same explorer-served network catalog and token list that MetaMask can consume. - That keeps chain metadata, token metadata, and optional extensions aligned with the live explorer API instead of - relying on stale frontend-only defaults. MetaMask does not run built-in token detection on custom networks such - as Chain 138, so this page uses EIP-747 wallet_watchAsset prompts from the live MetaMask payload to add token - metadata directly to the wallet. + Add Chain 138 and token metadata from explorer-served catalogs via EIP-747 wallet prompts.

- + + tab.id === 'tokens' ? { ...tab, count: featuredTokens.length } : tab, + )} + activeTab={activeTab} + onChange={setActiveTab} + idPrefix="add-to-metamask" + ariaLabel="Wallet setup sections" + /> -
- - - - -
+ + -
-
Optional Chain 138 Open Snap
-

- This is not required for the production - wallet flow above. The normal production path is to add Chain 138, then add tokens through EIP-747 - wallet_watchAsset prompts. The optional Snap uses{' '} - only open Snap permissions (minimal - privileged APIs in the Snap itself).{' '} - Stable MetaMask still only installs npm - Snaps that appear on MetaMask's install allowlist; if install fails with "not on the allowlist", that is - an external MetaMask review gate rather than an explorer/network failure. Use{' '} - MetaMask Flask for development or apply - for allowlisting before using this with Stable MetaMask. The package on npm is{' '} - {CHAIN138_OPEN_SNAP_ID} - — publish from the repo with scripts/deployment/publish-chain138-open-snap.sh after{' '} - npm login. +

+ + + + +
+ + +

+ Not required for production wallet setup. Use MetaMask Flask or an allowlisted Stable build. Package:{' '} + {CHAIN138_OPEN_SNAP_ID} +

+ +
+ + + +

+ Import canonical tokens on other supported networks. Each chain switches your wallet first, then runs sequential + EIP-747 prompts. + {mobileWalletContext ? ' On mobile, use Continue between prompt batches.' : null}

- -
- -
-
-
Explorer-served MetaMask metadata
-
-

Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}

-

Chain 138 token entries: {tokenCount138}

-

EIP-747 watchAsset entries: {watchAssetTokens.length}

-

Networks source: {networksMeta?.source || 'unknown'}

-

Token list source: {tokenListMeta?.source || 'unknown'}

-

MetaMask payload source: {metamaskConfigMeta?.source || 'unknown'}

- {metadataKeywordString ?

Keywords: {metadataKeywordString}

: null} -
-
-
-

Networks config URL

- {networksUrl} -
- - - Open JSON - -
-
-
-

Capabilities URL

- {displayedCapabilitiesUrl} -
- - - Open JSON - -
-
-
-

EIP-747 MetaMask payload URL

- {metamaskConfigUrl} -
- - - Open JSON - -
-
-
-

Token list URL

- {tokenListUrl} -
- - - Open JSON - -
-
-
+
+
+ +
-
Chain 138 RPC capabilities
-

- This capability matrix documents what public RPC methods wallets can rely on today, what tracing the explorer - can use, and where MetaMask will still fall back because the public node does not expose every optional fee method. -

-
-

- RPC endpoint:{' '} - - {capabilities?.rpcUrl || 'using published explorer fallback'} - + +

+ Bulk add switches to DeFi Oracle Meta Mainnet first. Use{' '} + Add tokens with balance only to skip zero-balance catalog entries. + MetaMask keeps one row per token symbol on a network.

-

- Capabilities source:{' '} - - {capabilitiesMeta?.source || 'unknown'} - -

-

- HTTP methods: {supportedHTTPMethods.length > 0 ? supportedHTTPMethods.join(', ') : 'metadata unavailable'} -

-

- Missing wallet-facing methods:{' '} - {unsupportedHTTPMethods.length > 0 ? unsupportedHTTPMethods.join(', ') : 'none listed'} -

-

- Trace methods: {supportedTraceMethods.length > 0 ? supportedTraceMethods.join(', ') : 'metadata unavailable'} -

- {capabilities?.walletSupport?.notes?.map((note) => ( -

- {note} -

- ))} - {capabilities?.http?.notes?.map((note) => ( -

- {note} -

- ))} - {capabilitiesMeta?.lastModified ? ( -

- Last modified: {formatStableTimestamp(capabilitiesMeta.lastModified)} -

- ) : null} -
-
- -
-
Featured Chain 138 tokens
-

- These tokens come from the explorer MetaMask payload and use wallet_watchAsset so the wallet gets the same - symbol, decimals, image, and optional token metadata that the explorer publishes. MetaMask requires a user - approval for each token, so the bulk actions below run as a guided sequence of wallet prompts. Bulk add - switches to DeFi Oracle Meta Mainnet first — tokens imported while on another network will not show - balances on Chain 138. Native ETH is not added via this flow; it appears when you are on Chain 138 with a - working RPC. Use Add tokens with balance only to skip zero-balance and - undeployed catalog entries. - {mobileWalletContext - ? ' On mobile, imports run two wallet prompts per tap — use Continue until finished.' - : null} -

-

- MetaMask keeps one row per token symbol on a network. If a balance - disappears after import, remove duplicate custom tokens (especially second cUSDC{' '} - / cUSDT rows or old gas placeholders like{' '} - {CHAIN138_PLACEHOLDER_GAS_SYMBOLS.slice(0, 3).join(', ')}), stay on{' '} - DeFi Oracle Meta Mainnet, then tap{' '} - Add tokens with balance only or{' '} - Add featured tokens. -

-
+ +
{pendingWatchFlow ? (
-
-
- ))} + )) + )}
+
-
-
Ethereum Mainnet cWUSDC
-

- This refreshes the Mainnet cWUSDC custom asset metadata with the DBIS-hosted image URL. MetaMask fiat price - display still depends on MetaMask and upstream asset/price providers accepting the Mainnet listing. -

-
-
+ +
+
+
+ {MAINNET_CWUSDC_TOKEN.symbol}{' '} + ({MAINNET_CWUSDC_TOKEN.name}) +
+
{MAINNET_CWUSDC_TOKEN.address}
+
+ +
+
+ + + +
+
+
Explorer-served MetaMask metadata
+
+

Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}

+

Chain 138 token entries: {tokenCount138}

+

EIP-747 watchAsset entries: {watchAssetTokens.length}

+

Networks source: {networksMeta?.source || 'unknown'}

+

Token list source: {tokenListMeta?.source || 'unknown'}

+

MetaMask payload source: {metamaskConfigMeta?.source || 'unknown'}

+ {metadataKeywordString ?

Keywords: {metadataKeywordString}

: null} +
+
-
- {MAINNET_CWUSDC_TOKEN.symbol}{' '} - ({MAINNET_CWUSDC_TOKEN.name}) -
-
{MAINNET_CWUSDC_TOKEN.address}
-
- Ethereum Mainnet • Decimals: {MAINNET_CWUSDC_TOKEN.decimals} +

Networks config URL

+ {networksUrl} +
+ + + Open JSON +
- +
+

Capabilities URL

+ {displayedCapabilitiesUrl} +
+ + + Open JSON + +
+
+
+

EIP-747 MetaMask payload URL

+ {metamaskConfigUrl} +
+ + + Open JSON + +
+
+
+

Token list URL

+ {tokenListUrl} +
+ + + Open JSON + +
+
+
+
+ +
+
Chain 138 RPC capabilities
+

+ This capability matrix documents what public RPC methods wallets can rely on today, what tracing the explorer + can use, and where MetaMask will still fall back because the public node does not expose every optional fee method. +

+
+

+ RPC endpoint:{' '} + + {capabilities?.rpcUrl || 'using published explorer fallback'} + +

+

+ Capabilities source:{' '} + + {capabilitiesMeta?.source || 'unknown'} + +

+

+ HTTP methods: {supportedHTTPMethods.length > 0 ? supportedHTTPMethods.join(', ') : 'metadata unavailable'} +

+

+ Missing wallet-facing methods:{' '} + {unsupportedHTTPMethods.length > 0 ? unsupportedHTTPMethods.join(', ') : 'none listed'} +

+

+ Trace methods: {supportedTraceMethods.length > 0 ? supportedTraceMethods.join(', ') : 'metadata unavailable'} +

+ {capabilities?.walletSupport?.notes?.map((note) => ( +

+ {note} +

+ ))} + {capabilities?.http?.notes?.map((note) => ( +

+ {note} +

+ ))} + {capabilitiesMeta?.lastModified ? ( +

+ Last modified: {formatStableTimestamp(capabilitiesMeta.lastModified)} +

+ ) : null}
-
+
{status ?

{status}

: null} {error ?

{error}

: null} - -
) } diff --git a/frontend/src/components/wallet/AddTokenToWalletButton.tsx b/frontend/src/components/wallet/AddTokenToWalletButton.tsx new file mode 100644 index 0000000..7ebdda4 --- /dev/null +++ b/frontend/src/components/wallet/AddTokenToWalletButton.tsx @@ -0,0 +1,201 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import type { WalletChain } from '@/components/wallet/AddToMetaMask' +import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient' +import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain' +import { + isMobileWalletContext, + MOBILE_WALLET_BUTTON_CLASS, + resolveWalletEthereumProvider, +} from '@/utils/walletProviderEnv' +import { isWalletWatchEligibleAddress } from '@/utils/walletWatchEligible' +import { watchTokenInWallet } from '@/utils/walletWatchToken' + +const FALLBACK_CHAIN_138: WalletChain = { + chainId: '0x8a', + chainIdDecimal: 138, + chainName: 'DeFi Oracle Meta Mainnet', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: ['https://rpc-http-pub.d-bis.org'], + blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'], + iconUrls: [ + 'https://explorer.d-bis.org/api/v1/report/logo/chain-138', + 'https://explorer.d-bis.org/token-icons/chain-138.png', + 'https://explorer.d-bis.org/favicon.ico', + ], +} + +type AddTokenToWalletButtonProps = { + address: string + symbol: string + decimals: number + name?: string + logoURI?: string + chainId?: number + variant?: 'primary' | 'secondary' + className?: string +} + +export default function AddTokenToWalletButton({ + address, + symbol, + decimals, + name, + logoURI, + chainId = 138, + variant = 'primary', + className = '', +}: AddTokenToWalletButtonProps) { + const [status, setStatus] = useState(null) + const [error, setError] = useState(null) + const [chainNetwork, setChainNetwork] = useState(null) + const [providerTick, setProviderTick] = useState(0) + + useEffect(() => { + if (typeof window === 'undefined') return undefined + const refresh = () => setProviderTick((value) => value + 1) + window.addEventListener('ethereum#initialized', refresh) + window.addEventListener('focus', refresh) + return () => { + window.removeEventListener('ethereum#initialized', refresh) + window.removeEventListener('focus', refresh) + } + }, []) + + useEffect(() => { + let active = true + const apiBase = window.location.origin.replace(/\/+$/, '') + void fetch(`${apiBase}/api/v1/config/metamask?chainId=${chainId}`, { cache: 'no-store' }) + .then((res) => (res.ok ? res.json() : null)) + .then((payload) => { + if (!active) return + if (payload?.addEthereumChain) { + setChainNetwork(payload.addEthereumChain as WalletChain) + } + }) + .catch(() => { + if (active) setChainNetwork(null) + }) + return () => { + active = false + } + }, [chainId]) + + const network = useMemo(() => { + if (chainId === 138) return chainNetwork || FALLBACK_CHAIN_138 + return chainNetwork + }, [chainId, chainNetwork]) + + const resolveEthereum = () => { + void providerTick + return resolveWalletEthereumProvider(getActiveWalletConnectProvider()) + } + + const addChainOnly = async () => { + setError(null) + setStatus(null) + if (!network) { + setError('Network metadata is unavailable right now.') + return + } + const ethereum = resolveEthereum() + if (!ethereum) { + setError('No wallet provider found. Connect MetaMask, open this page in a wallet browser, or use WalletConnect on /wallet.') + return + } + try { + await ethereum.request({ + method: 'wallet_addEthereumChain', + params: [ + toWalletAddEthereumChainParams(network, { + preferSingleRpc: isMobileWalletContext(), + }), + ], + }) + setStatus(`Added ${network.chainName}.`) + } catch (e) { + const err = e as { code?: number; message?: string } + if (err.code === 4902) { + setStatus(`Added ${network.chainName}.`) + return + } + setError(err.message || `Failed to add ${network.chainName}.`) + } + } + + const addToken = async () => { + setError(null) + setStatus(null) + + if (!isWalletWatchEligibleAddress(address)) { + setError(`${symbol} uses a roadmap placeholder address on Chain 138. Use a live canonical token from the token index instead.`) + return + } + + if (!network) { + setError('Network metadata is unavailable right now.') + return + } + + const ethereum = resolveEthereum() + if (!ethereum) { + setError('No wallet provider found. Connect MetaMask, open this page in a wallet browser, or use WalletConnect on /wallet.') + return + } + + const result = await watchTokenInWallet( + ethereum, + { + chainId, + address, + symbol, + decimals, + logoURI, + }, + network, + ) + + if (!result.ok) { + setError(result.error || 'Could not add token to wallet.') + return + } + + setStatus( + result.added + ? `Added ${symbol} on ${network.chainName}. Stay on that network to see balances.` + : `${symbol} request was dismissed.`, + ) + } + + const buttonClass = + variant === 'primary' + ? 'rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50' + : 'rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-100 dark:hover:bg-gray-900' + + return ( +
+
+ {chainId === 138 ? ( + + ) : null} + +
+ {status ?

{status}

: null} + {error ?

{error}

: null} +
+ ) +} diff --git a/frontend/src/components/wallet/MultiChainWalletImport.tsx b/frontend/src/components/wallet/MultiChainWalletImport.tsx index 9d049d8..e682665 100644 --- a/frontend/src/components/wallet/MultiChainWalletImport.tsx +++ b/frontend/src/components/wallet/MultiChainWalletImport.tsx @@ -1,6 +1,8 @@ 'use client' import { useEffect, useMemo, useState } from 'react' +import DisclosureSection from '@/components/common/DisclosureSection' +import ListFilterBar from '@/components/common/ListFilterBar' import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base' import type { TokenListToken, WalletChain } from '@/components/wallet/AddToMetaMask' import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient' @@ -85,12 +87,16 @@ function chainParamsForWallet(chain: WalletChain) { }) } +const PRIMARY_IMPORT_CHAIN_IDS = new Set([138, 1]) + export default function MultiChainWalletImport() { const [status, setStatus] = useState(null) const [error, setError] = useState(null) const [progress, setProgress] = useState<{ current: number; total: number; chainId: number } | null>(null) const [chains, setChains] = useState([]) const [pendingFlow, setPendingFlow] = useState(null) + const [chainQuery, setChainQuery] = useState('') + const [showAllChains, setShowAllChains] = useState(false) const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext() const resolveEthereum = () => resolveWalletEthereumProvider(getActiveWalletConnectProvider()) @@ -157,6 +163,22 @@ export default function MultiChainWalletImport() { ) }, [chains]) + const filteredChains = useMemo(() => { + const normalizedQuery = chainQuery.trim().toLowerCase() + if (!normalizedQuery) return chains + return chains.filter((row) => { + const label = chainLabel(row.chainId).toLowerCase() + return label.includes(normalizedQuery) || String(row.chainId).includes(normalizedQuery) + }) + }, [chainQuery, chains]) + + const visibleChains = useMemo(() => { + if (chainQuery.trim() || showAllChains) return filteredChains + return filteredChains.filter((row) => PRIMARY_IMPORT_CHAIN_IDS.has(row.chainId)) + }, [chainQuery, filteredChains, showAllChains]) + + const hiddenChainCount = Math.max(0, filteredChains.length - visibleChains.length) + const switchOrAddChain = async (network: WalletChain) => { const ethereum = resolveEthereum() if (!ethereum) { @@ -258,39 +280,46 @@ export default function MultiChainWalletImport() { } return ( -
-

Multi-chain token import

-

- Add canonical tokens on other supported networks. Each chain switches your wallet first, then runs sequential - EIP-747 wallet_watchAsset prompts. - {mobileWalletContext ? ' On mobile, two prompts run per tap — use Continue on the chain card.' : null} -

+
+ - {pendingFlow ? ( + {!chainQuery.trim() && !showAllChains && hiddenChainCount > 0 ? ( ) : null} -
- {chains.map((row) => { +
+ {visibleChains.length === 0 ? ( +

No chains match your filter.

+ ) : ( + visibleChains.map((row) => { const featured = featuredByChain.get(row.chainId) ?? [] return ( -
-
-
-
{chainLabel(row.chainId)}
-
- {row.loading - ? 'Loading token metadata…' - : row.error - ? row.error - : `${row.tokens.length} canonical tokens · ${featured.length} featured`} -
+ +
+
+ {row.loading + ? 'Loading token metadata…' + : row.error + ? row.error + : `${featured.length} featured · ${row.tokens.length} canonical`}
{progress?.chainId === row.chainId ? ( -
+
Prompt {progress.current} of {progress.total}
) : null} -
+ ) - })} + }) + )}
+ {pendingFlow ? ( + + ) : null} + {status ?

{status}

: null} {error ?

{error}

: null}
diff --git a/frontend/src/components/wallet/WalletPage.tsx b/frontend/src/components/wallet/WalletPage.tsx index b1e6961..9ee8dbe 100644 --- a/frontend/src/components/wallet/WalletPage.tsx +++ b/frontend/src/components/wallet/WalletPage.tsx @@ -11,7 +11,7 @@ import WalletConnectPostureNote from '@/components/wallet/WalletConnectPostureNo import { connectAndAuthenticateWalletConnect, getActiveWalletConnectSessionId, isWalletConnectClientReady, loadWalletConnectConfig } from '@/services/wallet/walletConnectClient' import { registerWalletConnectSession } from '@/services/api/walletConnect' import Link from 'next/link' -import { Explain, useUiMode } from '@/components/common/UiModeContext' +import { useUiMode } from '@/components/common/UiModeContext' import { accessApi, type WalletAccessSession } from '@/services/api/access' import EntityBadge from '@/components/common/EntityBadge' import { @@ -23,7 +23,8 @@ import { } from '@/services/api/addresses' import { WALLET_SNAPSHOT_REFRESH_MS } from '@/utils/featuredTokens' import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh' -import { formatRelativeAge, formatTokenAmount } from '@/utils/format' +import { formatInteger, formatTimestamp, formatTokenAmount } from '@/utils/format' +import ClientRelativeTime from '@/components/common/ClientRelativeTime' import { isWatchlistEntry, readWatchlistFromStorage, @@ -31,6 +32,10 @@ import { writeWatchlistToStorage, } from '@/utils/watchlist' import { MOBILE_WALLET_BUTTON_CLASS } from '@/utils/walletProviderEnv' +import PageIntro from '@/components/common/PageIntro' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' +import DisclosureSection from '@/components/common/DisclosureSection' interface WalletPageProps { initialNetworks?: NetworksCatalog | null @@ -47,6 +52,8 @@ function shortAddress(value?: string | null): string { return `${value.slice(0, 6)}...${value.slice(-4)}` } +type WalletPageTab = 'connect' | 'snapshot' | 'setup' + export default function WalletPage(props: WalletPageProps) { const { mode } = useUiMode() const [walletSession, setWalletSession] = useState(null) @@ -60,6 +67,30 @@ export default function WalletPage(props: WalletPageProps) { const [recentAddressTransactions, setRecentAddressTransactions] = useState([]) const [tokenBalances, setTokenBalances] = useState([]) const [tokenTransfers, setTokenTransfers] = useState([]) + const [activeTab, setActiveTab] = useState('connect') + + const walletTabs = useMemo[]>(() => { + const tabs: SectionTab[] = [ + { id: 'connect', label: 'Connect' }, + { id: 'setup', label: 'Wallet setup' }, + ] + if (walletSession) { + tabs.splice(1, 0, { id: 'snapshot', label: 'My wallet' }) + } + return tabs + }, [walletSession]) + + useEffect(() => { + if (activeTab === 'snapshot' && !walletSession) { + setActiveTab('connect') + } + }, [activeTab, walletSession]) + + useEffect(() => { + if (walletSession?.address) { + setActiveTab((current) => (current === 'connect' ? 'snapshot' : current)) + } + }, [walletSession?.address]) useEffect(() => { if (typeof window === 'undefined') return @@ -154,7 +185,7 @@ export default function WalletPage(props: WalletPageProps) { addressesApi.getSafe(138, address), addressesApi.getTransactionsSafe(138, address, 1, 3), addressesApi.getTokenBalancesSafe(address), - addressesApi.getTokenTransfersSafe(address, 1, 4), + addressesApi.getTokenTransfersSafe(address, 1, 2), ]) setAddressInfo(infoResponse.ok ? infoResponse.data : null) @@ -176,7 +207,7 @@ export default function WalletPage(props: WalletPageProps) { return 0 } }) - .slice(0, 4) + .slice(0, 3) : [], ) setTokenTransfers(transfersResponse.ok ? transfersResponse.data : []) @@ -222,15 +253,36 @@ export default function WalletPage(props: WalletPageProps) { }, [loadWalletSnapshot, walletSession?.address]) return ( -
-

Wallet Tools

-

- {mode === 'guided' - ? 'Use the explorer-served network catalog, token list, and capability metadata to connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets.' - : 'Use explorer-served network and token metadata to connect Chain 138 and Ethereum Mainnet wallets.'} -

- -
+
+ + + + + + + + +
Wallet session
@@ -261,7 +313,7 @@ export default function WalletPage(props: WalletPageProps) {
{walletSession?.expiresAt ? (
- Session expires {new Date(walletSession.expiresAt).toLocaleString()} + Session expires {formatTimestamp(walletSession.expiresAt)}
) : null}
@@ -355,9 +407,12 @@ export default function WalletPage(props: WalletPageProps) { ) : null}
+
+ + {walletSession ? ( -
+
@@ -374,32 +429,15 @@ export default function WalletPage(props: WalletPageProps) {
-
-
-
Transactions
-
- {addressInfo ? addressInfo.transaction_count.toLocaleString() : 'Unknown'} -
-
-
-
Token Holdings
-
- {addressInfo ? addressInfo.token_count.toLocaleString() : 'Unknown'} -
-
-
-
Address Type
-
- {addressInfo ? (addressInfo.is_contract ? 'Contract' : 'EOA') : 'Unknown'} -
-
-
-
Recent Indexed Tx
-
- {recentAddressTransactions[0] ? `#${recentAddressTransactions[0].block_number}` : 'None visible'} -
-
-
+
{recentAddressTransactions.length === 0 ? ( @@ -407,7 +445,7 @@ export default function WalletPage(props: WalletPageProps) { No recent indexed transactions are currently visible for this connected wallet.
) : ( - recentAddressTransactions.map((transaction) => ( + recentAddressTransactions.slice(0, 3).map((transaction) => (
- Block #{transaction.block_number.toLocaleString()} + Block #{formatInteger(transaction.block_number)}
)) @@ -448,7 +486,7 @@ export default function WalletPage(props: WalletPageProps) { No indexed token balances are currently visible for this wallet.
) : ( - tokenBalances.map((balance) => ( + tokenBalances.slice(0, 5).map((balance) => ( ) : ( - tokenTransfers.map((transfer) => { + tokenTransfers.slice(0, 5).map((transfer) => { const incoming = transfer.to_address.toLowerCase() === walletSession.address.toLowerCase() const counterparty = incoming ? transfer.from_address : transfer.to_address return ( @@ -506,7 +544,7 @@ export default function WalletPage(props: WalletPageProps) { {formatTokenAmount(transfer.value, transfer.token_decimals, transfer.token_symbol, 6)}
- Counterparty: {counterparty.slice(0, 6)}...{counterparty.slice(-4)} · {formatRelativeAge(transfer.timestamp)} + Counterparty: {counterparty.slice(0, 6)}...{counterparty.slice(-4)} ·
@@ -535,29 +573,23 @@ export default function WalletPage(props: WalletPageProps) { />
- ) : null} -
- -
- - <> - Need swap and liquidity discovery too? Visit the{' '} - - Liquidity - {' '} - page for live Chain 138 pools, route matrix links, partner payload templates, and the internal fallback execution plan endpoints. - - - {mode === 'expert' ? ( - <> - Liquidity and planner posture lives on the{' '} - - Liquidity - {' '} - surface. - - ) : null} -
+ ) : ( +
+ Connect a wallet on the Connect tab to see balances, transfers, and LP positions here. +
+ )} + + + + +

+ Need swap and liquidity discovery?{' '} + + Open Liquidity + + . +

+
) } diff --git a/frontend/src/data/footerNav.ts b/frontend/src/data/footerNav.ts new file mode 100644 index 0000000..1e4dd97 --- /dev/null +++ b/frontend/src/data/footerNav.ts @@ -0,0 +1,60 @@ +export type FooterNavLink = { + href: string + label: string + external?: boolean +} + +export type FooterNavGroup = { + id: string + title: string + links: FooterNavLink[] +} + +export const footerNavGroups: FooterNavGroup[] = [ + { + id: 'explore', + title: 'Explore', + links: [ + { href: '/search', label: 'Search' }, + { href: '/blocks', label: 'Blocks' }, + { href: '/transactions', label: 'Transactions' }, + { href: '/tokens', label: 'Tokens' }, + { href: '/addresses', label: 'Addresses' }, + { href: '/watchlist', label: 'Watchlist' }, + ], + }, + { + id: 'data', + title: 'Data & tools', + links: [ + { href: '/docs', label: 'Documentation' }, + { href: '/wallet', label: 'Wallet tools' }, + { href: '/access', label: 'Account access' }, + { href: '/analytics', label: 'Analytics' }, + { href: '/weth', label: 'WETH' }, + ], + }, + { + id: 'operations', + title: 'Operations', + links: [ + { href: '/operations', label: 'Operations hub' }, + { href: '/bridge', label: 'Bridge' }, + { href: '/routes', label: 'Routes' }, + { href: '/liquidity', label: 'Liquidity' }, + { href: '/pools', label: 'Pools' }, + { href: '/protocols', label: 'Protocols' }, + { href: '/operator', label: 'Operator' }, + { href: '/system', label: 'System' }, + ], + }, + { + id: 'legal', + title: 'Legal', + links: [ + { href: '/privacy.html', label: 'Privacy Policy', external: true }, + { href: '/terms.html', label: 'Terms of Service', external: true }, + { href: '/acknowledgments.html', label: 'Acknowledgments', external: true }, + ], + }, +] diff --git a/frontend/src/pages/addresses/[address].tsx b/frontend/src/pages/addresses/[address].tsx index 5d7f5fd..71788e5 100644 --- a/frontend/src/pages/addresses/[address].tsx +++ b/frontend/src/pages/addresses/[address].tsx @@ -1,6 +1,5 @@ -'use client' - import { useCallback, useEffect, useMemo, useState } from 'react' +import type { GetServerSideProps } from 'next' import { useRouter } from 'next/router' import { Card, Table, Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' @@ -20,7 +19,7 @@ import { } from '@/services/api/contracts' import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format' import { isEnsName, resolveEnsAddress } from '@/utils/ens' -import { resolveAddressFromRegistryEns } from '@/utils/web3IdentityRegistry' +import { resolveAddressFromRegistryEns, getKnownDisplayName } from '@/utils/web3IdentityRegistry' import { DetailRow } from '@/components/common/DetailRow' import EntityBadge from '@/components/common/EntityBadge' import PostureBadge from '@/components/common/PostureBadge' @@ -32,7 +31,7 @@ import { } from '@/utils/watchlist' import PageIntro from '@/components/common/PageIntro' import PaginationControls from '@/components/common/PaginationControls' -import SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { useDetailTabQuery } from '@/utils/useDetailTabQuery' const ADDRESS_DETAIL_TABS_ID = 'address-detail' @@ -75,10 +74,14 @@ function isContractWriteExecutionEnabled() { return value === '1' || value === 'true' || value === 'yes' } -export default function AddressDetailPage() { +export default function AddressDetailPage({ serverRegistryLabel = null }: { serverRegistryLabel?: string | null }) { const router = useRouter() const address = typeof router.query.address === 'string' ? router.query.address : '' const isValidAddressParam = address !== '' && isValidAddress(address) + const registryDisplayName = useMemo( + () => (isValidAddressParam ? getKnownDisplayName(address) : undefined), + [address, isValidAddressParam], + ) const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const contractWriteExecutionEnabled = isContractWriteExecutionEnabled() @@ -703,19 +706,23 @@ export default function AddressDetailPage() { const resolvedAddressInfo = addressInfo as AddressInfo return ( -
+
-
+
Back to addresses @@ -849,10 +856,7 @@ export default function AddressDetailPage() { ariaLabel="Address details" /> -
+ {resolvedAddressInfo.is_contract ? (
@@ -1097,9 +1101,9 @@ export default function AddressDetailPage() { ) : null} {gruProfile ?
: null} -
+ -
+ {gruBalanceCount > 0 ? (
@@ -1128,9 +1132,9 @@ export default function AddressDetailPage() { )} -
+
-
+ {gruTransferCount > 0 ? (
@@ -1162,9 +1166,9 @@ export default function AddressDetailPage() { )} -
+
-
+ {activityLoading ? (

Loading recent transactions...

@@ -1185,9 +1189,18 @@ export default function AddressDetailPage() { )}
-
+ )}
) } + +export const getServerSideProps: GetServerSideProps<{ serverRegistryLabel: string | null }> = async (context) => { + const address = typeof context.params?.address === 'string' ? context.params.address : '' + return { + props: { + serverRegistryLabel: getKnownDisplayName(address) ?? null, + }, + } +} diff --git a/frontend/src/pages/addresses/index.tsx b/frontend/src/pages/addresses/index.tsx index cbfde53..6af6967 100644 --- a/frontend/src/pages/addresses/index.tsx +++ b/frontend/src/pages/addresses/index.tsx @@ -6,14 +6,15 @@ import { Card, Address } from '@/libs/frontend-ui-primitives' import { transactionsApi, Transaction } from '@/services/api/transactions' import { readWatchlistFromStorage } from '@/utils/watchlist' import PageIntro from '@/components/common/PageIntro' +import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { fetchPublicJson } from '@/utils/publicExplorer' -import { normalizeTransaction } from '@/services/api/blockscout' +import { statsApi } from '@/services/api/stats' +import { missionControlApi } from '@/services/api/missionControl' import { isEnsName, resolveEnsAddress } from '@/utils/ens' import { resolveAddressFromRegistryEns } from '@/utils/web3IdentityRegistry' import { summarizeChainActivity } from '@/utils/activityContext' -import ActivityContextPanel from '@/components/common/ActivityContextPanel' -import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' -import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats' +import type { ExplorerStats } from '@/services/api/stats' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' @@ -34,20 +35,6 @@ interface AddressesPageProps { initialBridgeStatus: MissionControlBridgeStatusResponse | null } -function serializeRecentTransactions(transactions: Transaction[]): Transaction[] { - return JSON.parse( - JSON.stringify( - transactions.map((transaction) => ({ - hash: transaction.hash, - block_number: transaction.block_number, - from_address: transaction.from_address, - to_address: transaction.to_address ?? null, - created_at: transaction.created_at, - })), - ), - ) as Transaction[] -} - export default function AddressesPage({ initialRecentTransactions, initialLatestBlocks, @@ -58,11 +45,14 @@ export default function AddressesPage({ const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const [query, setQuery] = useState('') const [recentTransactions, setRecentTransactions] = useState(initialRecentTransactions) + const [latestBlocks, setLatestBlocks] = useState(initialLatestBlocks) + const [stats, setStats] = useState(initialStats) + const [bridgeStatus, setBridgeStatus] = useState(initialBridgeStatus) const [watchlist, setWatchlist] = useState([]) const activityContext = useMemo( () => summarizeChainActivity({ - blocks: initialLatestBlocks.map((block) => ({ + blocks: latestBlocks.map((block) => ({ chain_id: chainId, number: block.number, hash: '', @@ -73,12 +63,12 @@ export default function AddressesPage({ gas_limit: 0, })), transactions: recentTransactions, - latestBlockNumber: initialLatestBlocks[0]?.number ?? null, - latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null, - freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus), - diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null, + latestBlockNumber: latestBlocks[0]?.number ?? null, + latestBlockTimestamp: latestBlocks[0]?.timestamp ?? null, + freshness: resolveEffectiveFreshness(stats, bridgeStatus), + diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null, }), - [chainId, initialBridgeStatus, initialLatestBlocks, initialStats, recentTransactions], + [chainId, bridgeStatus, latestBlocks, stats, recentTransactions], ) useEffect(() => { @@ -104,6 +94,47 @@ export default function AddressesPage({ } }, [chainId, initialRecentTransactions]) + useEffect(() => { + if (initialStats && initialBridgeStatus && initialLatestBlocks.length > 0) { + setStats(initialStats) + setBridgeStatus(initialBridgeStatus) + setLatestBlocks(initialLatestBlocks) + return + } + + let active = true + + Promise.allSettled([ + statsApi.get().catch(() => null), + missionControlApi.getBridgeStatus().catch(() => null), + fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>( + '/api/v2/blocks?page=1&page_size=3', + ).catch(() => null), + ]).then(([statsResult, bridgeResult, blocksResult]) => { + if (!active) return + if (statsResult.status === 'fulfilled' && statsResult.value) { + setStats(statsResult.value) + } + if (bridgeResult.status === 'fulfilled' && bridgeResult.value) { + setBridgeStatus(bridgeResult.value) + } + if (blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items)) { + setLatestBlocks( + blocksResult.value.items + .map((item) => ({ + number: Number(item.height || 0), + timestamp: item.timestamp || '', + })) + .filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp), + ) + } + }) + + return () => { + active = false + } + }, [initialBridgeStatus, initialLatestBlocks, initialStats]) + useEffect(() => { if (typeof window === 'undefined') { return @@ -127,7 +158,7 @@ export default function AddressesPage({ if (seen.has(normalized)) continue seen.add(normalized) addresses.push(candidate) - if (addresses.length >= 12) return addresses + if (addresses.length >= 8) return addresses } } @@ -135,6 +166,15 @@ export default function AddressesPage({ }, [recentTransactions]) const [opening, setOpening] = useState(false) + const [listTab, setListTab] = useState<'watchlist' | 'recent'>('watchlist') + + const addressListTabs = useMemo[]>( + () => [ + { id: 'watchlist', label: 'Watchlist', count: watchlist.length }, + { id: 'recent', label: 'Recently active', count: activeAddresses.length }, + ], + [activeAddresses.length, watchlist.length], + ) const handleOpenAddress = async (event: React.FormEvent) => { event.preventDefault() @@ -162,80 +202,83 @@ export default function AddressesPage({ } return ( -
+
-
- - -
+ - -
+ + setQuery(event.target.value)} - placeholder="0x... or name.eth" - className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500" + placeholder="0x… or name.eth" + className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900" /> -

- Open any Chain 138 address or resolve a mainnet `.eth` name, then jump into your saved watchlist below. -

-
- + + + + {watchlist.length === 0 ? (

- No saved addresses yet. Address detail pages let you add entries to the shared explorer watchlist. + No saved addresses yet. Add entries from address detail pages.

) : ( -
+
{watchlist.map((entry) => (
))} -
- - Open the full watchlist → - -
+ + Full watchlist → +
)} + - + + {activeAddresses.length === 0 ? (

- Recent address activity is unavailable in the latest visible transaction sample. You can still open an address directly above. + No recent address activity in the visible transaction sample.

) : ( -
+
{activeAddresses.map((entry) => (
@@ -244,37 +287,16 @@ export default function AddressesPage({
)} -
+
) } -export const getServerSideProps: GetServerSideProps = async () => { - const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138') - const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([ - fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null), - fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null), - fetchPublicJson>('/api/v2/stats').catch(() => null), - fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null), - ]) - const initialRecentTransactions = Array.isArray(transactionsResult?.items) - ? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId)) - : [] - const initialLatestBlocks = Array.isArray(blocksResult?.items) - ? blocksResult.items - .map((item) => ({ - number: Number(item.height || 0), - timestamp: item.timestamp || '', - })) - .filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp) - : [] - - return { - props: { - initialRecentTransactions: serializeRecentTransactions(initialRecentTransactions), - initialLatestBlocks, - initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null, - initialBridgeStatus: bridgeResult, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialRecentTransactions: [], + initialLatestBlocks: [], + initialStats: null, + initialBridgeStatus: null, + }, +}) diff --git a/frontend/src/pages/analytics/index.tsx b/frontend/src/pages/analytics/index.tsx index b55f0ca..63870a2 100644 --- a/frontend/src/pages/analytics/index.tsx +++ b/frontend/src/pages/analytics/index.tsx @@ -1,18 +1,9 @@ import type { GetServerSideProps } from 'next' import AnalyticsOperationsPage from '@/components/explorer/AnalyticsOperationsPage' -import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout' -import { - normalizeTransactionTrend, - summarizeRecentTransactions, - type ExplorerRecentActivitySnapshot, - type ExplorerStats, - type ExplorerTransactionTrendPoint, -} from '@/services/api/stats' +import type { ExplorerRecentActivitySnapshot, ExplorerStats, ExplorerTransactionTrendPoint } from '@/services/api/stats' import type { Block } from '@/services/api/blocks' import type { Transaction } from '@/services/api/transactions' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' -import { fetchPublicJson } from '@/utils/publicExplorer' -import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext' interface AnalyticsPageProps { initialStats: ExplorerStats | null @@ -23,79 +14,17 @@ interface AnalyticsPageProps { initialBridgeStatus: MissionControlBridgeStatusResponse | null } -function serializeBlocks(blocks: Block[]): Block[] { - return JSON.parse( - JSON.stringify( - blocks.map((block) => ({ - chain_id: block.chain_id, - number: block.number, - hash: block.hash, - timestamp: block.timestamp, - miner: block.miner, - gas_used: block.gas_used, - gas_limit: block.gas_limit, - transaction_count: block.transaction_count, - })), - ), - ) as Block[] -} - -function serializeTransactions(transactions: Transaction[]): Transaction[] { - return JSON.parse( - JSON.stringify( - transactions.map((transaction) => ({ - hash: transaction.hash, - block_number: transaction.block_number, - from_address: transaction.from_address, - to_address: transaction.to_address ?? null, - value: transaction.value, - status: transaction.status ?? null, - contract_address: transaction.contract_address ?? null, - fee: transaction.fee ?? null, - })), - ), - ) as Transaction[] -} - export default function AnalyticsPage(props: AnalyticsPageProps) { return } -export const getServerSideProps: GetServerSideProps = async () => { - const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138') - const [truthContextResult, trendResult, activityResult, blocksResult, transactionsResult] = await Promise.allSettled([ - fetchExplorerTruthContext(), - fetchPublicJson('/api/v2/stats/charts/transactions'), - fetchPublicJson('/api/v2/main-page/transactions'), - fetchPublicJson('/api/v2/blocks?page=1&page_size=5'), - fetchPublicJson('/api/v2/transactions?page=1&page_size=5'), - ]) - - return { - props: { - initialStats: truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialStats : null, - initialTransactionTrend: - trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value as never) : [], - initialActivitySnapshot: - activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value as never) : null, - initialBlocks: - blocksResult.status === 'fulfilled' && Array.isArray((blocksResult.value as { items?: unknown[] })?.items) - ? serializeBlocks( - ((blocksResult.value as { items?: unknown[] }).items || []).map((item) => - normalizeBlock(item as never, chainId), - ), - ) - : [], - initialTransactions: - transactionsResult.status === 'fulfilled' && Array.isArray((transactionsResult.value as { items?: unknown[] })?.items) - ? serializeTransactions( - ((transactionsResult.value as { items?: unknown[] }).items || []).map((item) => - normalizeTransaction(item as never, chainId), - ), - ) - : [], - initialBridgeStatus: - truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialBridgeStatus : null, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialStats: null, + initialTransactionTrend: [], + initialActivitySnapshot: null, + initialBlocks: [], + initialTransactions: [], + initialBridgeStatus: null, + }, +}) diff --git a/frontend/src/pages/blocks/[number].tsx b/frontend/src/pages/blocks/[number].tsx index a140044..244a64f 100644 --- a/frontend/src/pages/blocks/[number].tsx +++ b/frontend/src/pages/blocks/[number].tsx @@ -6,8 +6,10 @@ import { blocksApi, Block } from '@/services/api/blocks' import { Card, Address, Table } from '@/libs/frontend-ui-primitives' import Link from 'next/link' import { DetailRow } from '@/components/common/DetailRow' +import CompactMetricBar from '@/components/common/CompactMetricBar' import PageIntro from '@/components/common/PageIntro' -import { formatTimestamp, formatWeiAsEth } from '@/utils/format' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' +import { formatInteger, formatTimestamp, formatWeiAsEth } from '@/utils/format' import { type Transaction } from '@/services/api/transactions' import { useBlockTransactions } from '@/hooks/useBlockTransactions' @@ -20,6 +22,7 @@ export default function BlockDetailPage() { const [block, setBlock] = useState(null) const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState<'summary' | 'transactions'>('summary') const { transactions: blockTransactions, @@ -123,40 +126,38 @@ export default function BlockDetailPage() { ? 'No indexed block transactions were returned for this page yet.' : 'This block does not contain any indexed transactions.' + const blockIntroActions = useMemo(() => { + const actions = [ + { href: '/blocks', label: 'All blocks' }, + { href: '/transactions', label: 'Transactions' }, + ] + if (block && block.number > 0) { + actions.push({ href: `/blocks/${block.number - 1}`, label: 'Previous' }) + } + if (block) { + actions.push({ href: `/blocks/${block.number + 1}`, label: 'Next' }) + } + return actions + }, [block]) + + const blockTabs = useMemo[]>( + () => [ + { id: 'summary', label: 'Summary' }, + { id: 'transactions', label: 'Transactions', count: block?.transaction_count ?? 0 }, + ], + [block?.transaction_count], + ) + return ( -
+
-
- - Back to blocks - - {block && block.number > 0 ? ( - - Previous block - - ) : null} - {block && ( - - Next block - - )} - {block?.transaction_count ? ( - - Open block transactions - - ) : null} -
- {!router.isReady || loading ? (

Loading block...

@@ -186,6 +187,26 @@ export default function BlockDetailPage() {
) : ( + <> + + + + +
@@ -200,12 +221,16 @@ export default function BlockDetailPage() { - + - {block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()} + {formatInteger(block.gas_used)} / {formatInteger(block.gas_limit)} {gasUtilization != null && ( @@ -214,13 +239,13 @@ export default function BlockDetailPage() { )}
- )} +
- {block && ( - + +

- This section shows the exact indexed transaction set for block #{block.number.toLocaleString()}, independent of generic explorer search. + Indexed transaction set for block #{formatInteger(block.number)}.

{transactionsLoading ? (

Loading block transactions...

@@ -261,6 +286,8 @@ export default function BlockDetailPage() { )}
+
+ )}
) diff --git a/frontend/src/pages/blocks/index.tsx b/frontend/src/pages/blocks/index.tsx index 5891376..95ace13 100644 --- a/frontend/src/pages/blocks/index.tsx +++ b/frontend/src/pages/blocks/index.tsx @@ -1,22 +1,22 @@ import type { GetServerSideProps } from 'next' import { useCallback, useEffect, useMemo, useState } from 'react' import { blocksApi, Block } from '@/services/api/blocks' -import { Card, Address } from '@/libs/frontend-ui-primitives' +import { Card, Table, Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' import PageIntro from '@/components/common/PageIntro' +import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure' import { formatTimestamp } from '@/utils/format' import { fetchPublicJson } from '@/utils/publicExplorer' import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout' import type { Transaction } from '@/services/api/transactions' import { transactionsApi } from '@/services/api/transactions' import { summarizeChainActivity } from '@/utils/activityContext' -import ActivityContextPanel from '@/components/common/ActivityContextPanel' -import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' import PaginationControls from '@/components/common/PaginationControls' import { useListPageQuery } from '@/utils/useListPageQuery' -import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats' +import type { ExplorerStats } from '@/services/api/stats' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness' +import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext' interface BlocksPageProps { initialBlocks: Block[] @@ -49,9 +49,11 @@ export default function BlocksPage({ initialStats, initialBridgeStatus, }: BlocksPageProps) { - const pageSize = 20 + const pageSize = 15 const [blocks, setBlocks] = useState(initialBlocks) const [recentTransactions, setRecentTransactions] = useState(initialRecentTransactions) + const [stats, setStats] = useState(initialStats) + const [bridgeStatus, setBridgeStatus] = useState(initialBridgeStatus) const [loading, setLoading] = useState(initialBlocks.length === 0) const { page, setPage } = useListPageQuery() const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') @@ -108,8 +110,35 @@ export default function BlocksPage({ } }, [chainId, initialRecentTransactions]) - const showPagination = page > 1 || blocks.length > 0 - const canGoNext = blocks.length === pageSize + useEffect(() => { + if (initialStats && initialBridgeStatus) { + setStats(initialStats) + setBridgeStatus(initialBridgeStatus) + return + } + + let active = true + fetchExplorerTruthContext() + .then((context) => { + if (!active) return + setStats((current) => current ?? context.initialStats) + setBridgeStatus((current) => current ?? context.initialBridgeStatus) + }) + .catch(() => undefined) + + return () => { + active = false + } + }, [initialStats, initialBridgeStatus]) + + const paginatedBlocks = useMemo(() => { + const start = (page - 1) * pageSize + return blocks.slice(start, start + pageSize) + }, [blocks, page, pageSize]) + + const showPagination = blocks.length > pageSize || page > 1 + const canGoNext = page * pageSize < blocks.length + const activityContext = useMemo( () => summarizeChainActivity({ @@ -117,95 +146,79 @@ export default function BlocksPage({ transactions: recentTransactions, latestBlockNumber: blocks[0]?.number ?? null, latestBlockTimestamp: blocks[0]?.timestamp ?? null, - freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus), - diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null, + freshness: resolveEffectiveFreshness(stats, bridgeStatus), + diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null, }), - [blocks, initialBridgeStatus, initialStats, recentTransactions], + [blocks, bridgeStatus, stats, recentTransactions], ) + const columns = [ + { + header: 'Block', + accessor: (block: Block) => ( + + #{block.number} + + ), + }, + { + header: 'Age', + accessor: (block: Block) => ( + {formatTimestamp(block.timestamp)} + ), + }, + { + header: 'Miner', + accessor: (block: Block) => ( + +
+ + ), + }, + { + header: 'Txs', + accessor: (block: Block) => block.transaction_count, + }, + ] + return ( -
+
-
- - -
+ + + {shouldExplainEmptyHeadBlocks(blocks, activityContext) ? ( +

+ Recent head blocks are empty; use the latest transaction block for visible activity. +

+ ) : null} {loading ? (

Loading blocks...

) : ( -
- {shouldExplainEmptyHeadBlocks(blocks, activityContext) ? ( - -

- Recent head blocks are currently empty; use the latest transaction block for recent visible activity. -

-
- ) : null} - {blocks.length === 0 ? ( - -

Recent blocks are unavailable right now.

-
- - Open recent transactions → - - - Search by block number → - -
-
- ) : ( - blocks.map((block) => ( - -
-
- - Block #{block.number} - -
-
-
-
- Miner:{' '} - -
- -
-
-
-
- {formatTimestamp(block.timestamp)} -
-
- {block.transaction_count} transactions -
-
-
-
- )) - )} -
+ String(block.number)} + /> )} {showPagination ? ( @@ -219,48 +232,22 @@ export default function BlocksPage({ className="mt-6" /> ) : null} - -
- -

- Need a different entry point? Open transaction flow, search directly by block number, or jump into recently active addresses. -

-
- - Transactions → - - - Addresses → - - - Search → - -
-
-
) } export const getServerSideProps: GetServerSideProps = async () => { const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138') - const [blocksResult, transactionsResult, statsResult, bridgeResult] = await Promise.all([ - fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null), - fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5').catch(() => null), - fetchPublicJson>('/api/v2/stats').catch(() => null), - fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null), - ]) + const blocksResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=15').catch(() => null) return { props: { initialBlocks: Array.isArray(blocksResult?.items) ? blocksResult.items.map((item) => normalizeBlock(item as never, chainId)) : [], - initialRecentTransactions: Array.isArray(transactionsResult?.items) - ? serializeTransactions(transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))) - : [], - initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null, - initialBridgeStatus: bridgeResult, + initialRecentTransactions: [], + initialStats: null, + initialBridgeStatus: null, }, } } diff --git a/frontend/src/pages/docs/gru.tsx b/frontend/src/pages/docs/gru.tsx index 63c57c7..a0f58c5 100644 --- a/frontend/src/pages/docs/gru.tsx +++ b/frontend/src/pages/docs/gru.tsx @@ -7,8 +7,9 @@ import EntityBadge from '@/components/common/EntityBadge' export default function GruDocsPage() { return ( -
+
+
-
- -
- {docsCards.map((item) => ( -
-
{item.title}
-

{item.description}

-
- - Open guide → - -
-
- ))} -
-
- -
- -
-

- The docs are meant to prove the explorer, not merely describe it. Each guide below links back into live Chain 138 pages where the - documented signals can be inspected directly. -

-
- - Search cUSDT → - - - Open cUSDT token page → - - - Browse recent transactions → - -
-
-
- - -
-

- DBIS Explorer is the public Chain 138 explorer operated by DBIS. Primary public access is served at - explorer.d-bis.org; blockscout.defi-oracle.io is the Blockscout companion domain. -

-

- These domains are part of the same explorer and companion-tooling surface, including the Snap install path at - /snap/. Support and policy notices are handled through - support@d-bis.org. -

-
-
- - -
- {policyLinks.map((item) => ( - - ))} -
-
- - -
-

- The public explorer docs cover GRU posture, transaction review scoring, liquidity access, and navigation into the broader Chain 138 surfaces. -

-

- Support: support@d-bis.org -

-

- Command center: open visual map → -

-
-
+ +
+ {docsCards.map((item) => ( + +
{item.title}
+

{item.description}

+ + ))}
-
+ + + +
+

+ Primary domain explorer.d-bis.org; Blockscout companion blockscout.defi-oracle.io. + Support: support@d-bis.org +

+
+ {policyLinks.map((item) => ( + + {item.label} + + ))} + + Verify cUSDT live + + + Command center map + +
+
+
) } diff --git a/frontend/src/pages/docs/posture-glossary.tsx b/frontend/src/pages/docs/posture-glossary.tsx index 0d26488..695b35e 100644 --- a/frontend/src/pages/docs/posture-glossary.tsx +++ b/frontend/src/pages/docs/posture-glossary.tsx @@ -8,8 +8,9 @@ import { postureGlossaryTerms } from '@/data/postureGlossary' export default function PostureGlossaryDocsPage() { return ( -
+
+
+
} -function serializeTransactions(transactions: Transaction[]): Transaction[] { - return JSON.parse( - JSON.stringify( - transactions.map((transaction) => ({ - hash: transaction.hash, - block_number: transaction.block_number, - from_address: transaction.from_address, - to_address: transaction.to_address ?? null, - value: transaction.value, - status: transaction.status ?? null, - contract_address: transaction.contract_address ?? null, - fee: transaction.fee ?? null, - created_at: transaction.created_at, - })), - ), - ) as Transaction[] -} - -export const getServerSideProps: GetServerSideProps = async () => { - const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138') - - const [truthContextResult, blocksResult, transactionsResult, trendResult, activityResult] = await Promise.allSettled([ - fetchExplorerTruthContext(), - fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=10'), - fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5'), - fetchPublicJson<{ chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }> }>( - '/api/v2/stats/charts/transactions' - ), - fetchPublicJson< - Array<{ - status?: string | null - transaction_types?: string[] | null - gas_used?: number | string | null - fee?: { value?: string | number | null } | string | null - }> - >('/api/v2/main-page/transactions'), - ]) - - return { - props: { - initialStats: truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialStats : null, - initialRecentBlocks: - blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items) - ? blocksResult.value.items.map((item) => normalizeBlock(item as never, chainId)) - : [], - initialRecentTransactions: - transactionsResult.status === 'fulfilled' && Array.isArray(transactionsResult.value?.items) - ? serializeTransactions( - transactionsResult.value.items.map((item) => normalizeTransaction(item as never, chainId)), - ) - : [], - initialTransactionTrend: - trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value) : [], - initialActivitySnapshot: - activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value) : null, - initialBridgeStatus: - truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialBridgeStatus : null, - initialRelaySummary: - truthContextResult.status === 'fulfilled' && truthContextResult.value.initialBridgeStatus - ? summarizeMissionControlRelay(truthContextResult.value.initialBridgeStatus as never) - : null, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialStats: null, + initialRecentBlocks: [], + initialRecentTransactions: [], + initialTransactionTrend: [], + initialActivitySnapshot: null, + initialBridgeStatus: null, + initialRelaySummary: null, + }, +}) diff --git a/frontend/src/pages/liquidity/index.tsx b/frontend/src/pages/liquidity/index.tsx index 34e992e..7342e79 100644 --- a/frontend/src/pages/liquidity/index.tsx +++ b/frontend/src/pages/liquidity/index.tsx @@ -5,9 +5,6 @@ import type { InternalExecutionPlanResponse, PlannerCapabilitiesResponse } from import type { MissionControlLiquidityPool, RouteMatrixResponse } from '@/services/api/routes' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import type { ExplorerStats } from '@/services/api/stats' -import { fetchPublicJson } from '@/utils/publicExplorer' -import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext' -import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces' interface TokenPoolRecord { symbol: string @@ -24,70 +21,18 @@ interface LiquidityPageProps { initialBridgeStatus: MissionControlBridgeStatusResponse | null } -const featuredTokenSymbols = new Set(['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT']) - -async function fetchPublicPostJson(path: string, body: unknown): Promise { - const response = await fetch(`https://blockscout.defi-oracle.io${path}`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify(body), - }) - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) - } - return (await response.json()) as T -} - export default function LiquidityPage(props: LiquidityPageProps) { return } -export const getServerSideProps: GetServerSideProps = async () => { - const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult, truthContext] = - await Promise.all([ - loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null), - fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null), - fetchPublicJson('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch( - () => null, - ), - fetchPublicPostJson('/token-aggregation/api/v2/routes/internal-execution-plan', { - sourceChainId: 138, - tokenIn: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', - tokenOut: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1', - amountIn: '100000000000000000', - }).catch(() => null), - fetchExplorerTruthContext(), - ]) - - const featuredTokens = (tokenListResult?.tokens || []).filter( - (token) => token.chainId === 138 && typeof token.symbol === 'string' && featuredTokenSymbols.has(token.symbol), - ) - - const tokenPoolsResults = await Promise.all( - featuredTokens.map(async (token) => { - const response = await fetchPublicJson<{ pools?: MissionControlLiquidityPool[]; data?: { pools?: MissionControlLiquidityPool[] } }>( - `/explorer-api/v1/mission-control/liquidity/token/${token.address}/pools`, - ).catch(() => null) - const pools = Array.isArray(response?.pools) - ? response.pools - : Array.isArray(response?.data?.pools) - ? response.data.pools - : [] - return { symbol: token.symbol || token.address || 'unknown', pools } - }), - ).catch(() => [] as TokenPoolRecord[]) - - return { - props: { - initialTokenList: tokenListResult, - initialRouteMatrix: routeMatrixResult, - initialPlannerCapabilities: plannerCapabilitiesResult, - initialInternalPlan: internalPlanResult, - initialTokenPoolRecords: tokenPoolsResults, - initialStats: truthContext.initialStats, - initialBridgeStatus: truthContext.initialBridgeStatus, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialTokenList: null, + initialRouteMatrix: null, + initialPlannerCapabilities: null, + initialInternalPlan: null, + initialTokenPoolRecords: [], + initialStats: null, + initialBridgeStatus: null, + }, +}) diff --git a/frontend/src/pages/operator/index.tsx b/frontend/src/pages/operator/index.tsx index c1df2b2..5136e6a 100644 --- a/frontend/src/pages/operator/index.tsx +++ b/frontend/src/pages/operator/index.tsx @@ -1,6 +1,5 @@ import type { GetServerSideProps } from 'next' import OperatorOperationsPage from '@/components/explorer/OperatorOperationsPage' -import { fetchPublicJson } from '@/utils/publicExplorer' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import type { InternalExecutionPlanResponse, PlannerCapabilitiesResponse } from '@/services/api/planner' import type { RouteMatrixResponse } from '@/services/api/routes' @@ -16,21 +15,11 @@ export default function OperatorPage(props: OperatorPageProps) { return } -export const getServerSideProps: GetServerSideProps = async () => { - const [bridgeStatus, routeMatrix, plannerCapabilities] = await Promise.all([ - fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null), - fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null), - fetchPublicJson('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch( - () => null, - ), - ]) - - return { - props: { - initialBridgeStatus: bridgeStatus, - initialRouteMatrix: routeMatrix, - initialPlannerCapabilities: plannerCapabilities, - initialInternalPlan: null, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialBridgeStatus: null, + initialRouteMatrix: null, + initialPlannerCapabilities: null, + initialInternalPlan: null, + }, +}) diff --git a/frontend/src/pages/protocols/[id].tsx b/frontend/src/pages/protocols/[id].tsx index 82f4fa1..aa1d6ce 100644 --- a/frontend/src/pages/protocols/[id].tsx +++ b/frontend/src/pages/protocols/[id].tsx @@ -5,9 +5,12 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { Card, Address } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' +import SectionTabs, { TabPanel } from '@/components/common/SectionTabs' +import DisclosureSection from '@/components/common/DisclosureSection' import EntityBadge from '@/components/common/EntityBadge' import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav' import { fetchOfficialProtocols, type OfficialProtocolRow, type ProtocolVerificationReport } from '@/services/api/officialProtocols' +import { formatTimestamp } from '@/utils/format' export default function ProtocolDetailPage() { const router = useRouter() @@ -17,6 +20,7 @@ export default function ProtocolDetailPage() { const [verification, setVerification] = useState(null) const [registryMeta, setRegistryMeta] = useState<{ schemaVersion?: string; updated?: string; generatedAt?: string }>({}) const [loading, setLoading] = useState(true) + const [protocolTab, setProtocolTab] = useState<'overview' | 'contracts'>('overview') useEffect(() => { if (!router.isReady || !protocolId) return @@ -42,20 +46,20 @@ export default function ProtocolDetailPage() { }, [protocolId, router.isReady]) return ( -
+
@@ -74,9 +78,22 @@ export default function ProtocolDetailPage() { ) : null} {protocol ? ( -
+ <> + + + {verification ? ( - +

- Live bytecode probe via Chain 138 RPC at {new Date(verification.verifiedAt).toLocaleString()}. + Live bytecode probe via Chain 138 RPC at {formatTimestamp(verification.verifiedAt)}. {registryMeta.updated ? ` Registry updated ${registryMeta.updated}.` : ''}

@@ -135,12 +152,28 @@ export default function ProtocolDetailPage() { ) : null} + {forbidden.length > 0 ? ( + + +
    + {forbidden.slice(0, 5).map((row) => ( +
  • + {row.id} — {row.description} +
  • + ))} +
+
+
+ ) : null} + + + -
+
{protocol.contracts.map((contract) => ( -
+
{contract.name}
-
+
@@ -155,19 +188,8 @@ export default function ProtocolDetailPage() { ))}
- - {forbidden.length > 0 ? ( - -
    - {forbidden.slice(0, 5).map((row) => ( -
  • - {row.id} — {row.description} -
  • - ))} -
-
- ) : null} -
+ + ) : null}
) diff --git a/frontend/src/pages/protocols/index.tsx b/frontend/src/pages/protocols/index.tsx index 01507df..6349f12 100644 --- a/frontend/src/pages/protocols/index.tsx +++ b/frontend/src/pages/protocols/index.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import Link from 'next/link' import { Card } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' +import DisclosureSection from '@/components/common/DisclosureSection' import EntityBadge from '@/components/common/EntityBadge' import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav' import { fetchOfficialProtocols, type OfficialProtocolsResponse } from '@/services/api/officialProtocols' @@ -27,15 +28,15 @@ export default function ProtocolsIndexPage() { }, []) return ( -
+
@@ -55,17 +56,17 @@ export default function ProtocolsIndexPage() { ) : null} {report ? ( -
- +
+
    {report.guardrails.slice(0, 4).map((line) => (
  • {line}
  • ))}
- +
-
+
{report.protocols.map((protocol) => ( (null) const [poolRegistryRows, setPoolRegistryRows] = useState([]) + const [indexedPage, setIndexedPage] = useState(1) + const [activeSearchTab, setActiveSearchTab] = useState('indexed') const runSearch = async (rawQuery: string) => { const trimmedQuery = rawQuery.trim() @@ -329,6 +338,62 @@ export default function SearchPage({ { label: 'Other', items: groupedResults.other }, ] + const indexedPageCount = Math.max(1, Math.ceil(filteredResults.length / SEARCH_INDEXED_PAGE_SIZE)) + const paginatedFilteredResults = useMemo(() => { + const safePage = Math.min(Math.max(indexedPage, 1), indexedPageCount) + const start = (safePage - 1) * SEARCH_INDEXED_PAGE_SIZE + return filteredResults.slice(start, start + SEARCH_INDEXED_PAGE_SIZE) + }, [filteredResults, indexedPage, indexedPageCount]) + const paginatedGroupedResults = useMemo( + () => ({ + tokens: paginatedFilteredResults.filter((result) => result.type === 'token'), + addresses: paginatedFilteredResults.filter((result) => result.type === 'address'), + transactions: paginatedFilteredResults.filter((result) => result.type === 'transaction'), + blocks: paginatedFilteredResults.filter((result) => result.type === 'block'), + other: paginatedFilteredResults.filter((result) => !['token', 'address', 'transaction', 'block'].includes(result.type)), + }), + [paginatedFilteredResults], + ) + const paginatedResultSections = useMemo( + () => [ + { label: 'Tokens', items: paginatedGroupedResults.tokens }, + { label: 'Addresses', items: paginatedGroupedResults.addresses }, + { label: 'Transactions', items: paginatedGroupedResults.transactions }, + { label: 'Blocks', items: paginatedGroupedResults.blocks }, + { label: 'Other', items: paginatedGroupedResults.other }, + ], + [paginatedGroupedResults], + ) + const searchTabs = useMemo[]>(() => { + const tabs: SectionTab[] = [] + if (filteredResults.length > 0) tabs.push({ id: 'indexed', label: 'Indexed', count: filteredResults.length }) + if (meshSeed && (meshLoading || meshRows.length > 0)) tabs.push({ id: 'mesh', label: 'Mesh', count: meshRows.length }) + if (poolMatches.length > 0 && hasSearched) tabs.push({ id: 'pools', label: 'Pools', count: poolMatches.length }) + if (filteredWrappedMatches.length > 0 && meshRows.length === 0 && !meshLoading) { + tabs.push({ id: 'wrapped', label: 'Wrapped', count: filteredWrappedMatches.length }) + } + return tabs + }, [ + filteredResults.length, + filteredWrappedMatches.length, + hasSearched, + meshLoading, + meshRows.length, + meshSeed, + poolMatches.length, + ]) + + useEffect(() => { + setIndexedPage(1) + }, [trimmedQuery, filterMode]) + + useEffect(() => { + if (searchTabs.length === 0) return + if (!searchTabs.some((tab) => tab.id === activeSearchTab)) { + setActiveSearchTab(searchTabs[0].id) + } + }, [activeSearchTab, searchTabs]) + useEffect(() => { if (!meshSeed || !hasSearched) { setMeshRows([]) @@ -413,21 +478,19 @@ export default function SearchPage({ }, [filteredResults]) return ( -
+
@@ -470,6 +533,25 @@ export default function SearchPage({ )} + {!loading && hasSearched && searchTabs.length > 0 ? ( + <> + + + {!loading && tokenTarget && !meshSeed && (

@@ -523,174 +605,6 @@ export default function SearchPage({ )} - {!loading && poolMatches.length > 0 && hasSearched && ( - -

- Matches from the DODO PMM + UniV2 pool registry on Chain 138. LP receipt tokens stay chain-local — bridge - underlying c*/cW* instead. -

-
- {poolMatches.slice(0, 12).map((row) => ( - -
- - - -
-
- Pool
-
- - ))} -
-
- )} - - {!loading && meshSeed && (meshLoading || meshRows.length > 0) && ( - -

- {mode === 'guided' - ? `Resolved ${meshSeed.hubSymbol} hub on Chain 138 and mapped ${meshSeed.wrappedSymbol} addresses across configured transport pairs via token-mapping.` - : `${meshSeed.hubSymbol} ↔ ${meshSeed.wrappedSymbol} mesh (${meshSeed.matchReason}).`} -

- {polygonMapper?.officialPolygonTokenList?.upstreamRepo ? ( -

- Polygon (137) rows align with{' '} - - official Polygon Token Mapper - - {polygonMapper.match?.polygonAddress - ? ` — matched ${polygonMapper.match.wrappedSymbol} at ${polygonMapper.match.polygonAddress.slice(0, 10)}…` - : ''} - . -

- ) : null} - {meshLoading ? ( -

Resolving mesh addresses…

- ) : ( -
- {meshRows.map((row) => { - const href = externalChainExplorerUrl(row.chainId, row.address) - const content = ( - <> -
- - - - - {row.chainId === 137 && polygonMapper ? ( - - ) : null} -
-
-
-
- - ) - if (!href) { - return ( -
- {content} -
- ) - } - if (row.chainId === 138) { - return ( - - {content} - - ) - } - return ( - - {content} - - ) - })} -
- )} -
- )} - - {!loading && filteredWrappedMatches.length > 0 && meshRows.length === 0 && !meshLoading && ( - -

- Matches from the live cW* registry across public networks. Chain 138 tokens open here; other chains link to native explorers. -

-
- {filteredWrappedMatches.slice(0, 24).map((row) => { - const href = externalChainExplorerUrl(row.chainId, row.address) - const content = ( - <> -
- - - - - -
-
-
-
- - ) - if (!href) { - return ( -
- {content} -
- ) - } - if (row.chainId === 138) { - return ( - - {content} - - ) - } - return ( - - {content} - - ) - })} -
-
- )} - {!loading && !tokenTarget && directTarget && !deferAddressJump && (

@@ -724,7 +638,7 @@ export default function SearchPage({ )} - {results.length > 0 && ( + {filteredResults.length > 0 && (

@@ -751,7 +665,7 @@ export default function SearchPage({ GRU guide →
- {resultSections.map((section) => + {paginatedResultSections.map((section) => section.items.length > 0 ? (
@@ -829,19 +743,197 @@ export default function SearchPage({
) : null, )} +
)} {!loading && hasSearched && hasSupplementalMatches && filteredResults.length === 0 && !error ? ( - +

Blockscout indexed search returned no hits for{' '} {trimmedQuery}, but curated mesh, - pool, or wrapped-token registry matches are shown below. + pool, or wrapped-token registry matches are shown in the other tabs.

) : null} + + + + +

+ Matches from the DODO PMM + UniV2 pool registry on Chain 138. LP receipt tokens stay chain-local — bridge + underlying c*/cW* instead. +

+
+ {poolMatches.slice(0, 6).map((row) => ( + +
+ + + +
+
+ Pool
+
+ + ))} +
+
+
+ + + +

+ {mode === 'guided' + ? `Resolved ${meshSeed?.hubSymbol} hub on Chain 138 and mapped ${meshSeed?.wrappedSymbol} addresses across configured transport pairs via token-mapping.` + : `${meshSeed?.hubSymbol} ↔ ${meshSeed?.wrappedSymbol} mesh (${meshSeed?.matchReason}).`} +

+ {polygonMapper?.officialPolygonTokenList?.upstreamRepo ? ( +

+ Polygon (137) rows align with{' '} + + official Polygon Token Mapper + + {polygonMapper.match?.polygonAddress + ? ` — matched ${polygonMapper.match.wrappedSymbol} at ${polygonMapper.match.polygonAddress.slice(0, 10)}…` + : ''} + . +

+ ) : null} + {meshLoading ? ( +

Resolving mesh addresses…

+ ) : ( +
+ {meshRows.map((row) => { + const href = externalChainExplorerUrl(row.chainId, row.address) + const content = ( + <> +
+ + + + + {row.chainId === 137 && polygonMapper ? ( + + ) : null} +
+
+
+
+ + ) + if (!href) { + return ( +
+ {content} +
+ ) + } + if (row.chainId === 138) { + return ( + + {content} + + ) + } + return ( + + {content} + + ) + })} +
+ )} +
+
+ + + +

+ Matches from the live cW* registry across public networks. Chain 138 tokens open here; other chains link to native explorers. +

+
+ {filteredWrappedMatches.slice(0, 8).map((row) => { + const href = externalChainExplorerUrl(row.chainId, row.address) + const content = ( + <> +
+ + + + + +
+
+
+
+ + ) + if (!href) { + return ( +
+ {content} +
+ ) + } + if (row.chainId === 138) { + return ( + + {content} + + ) + } + return ( + + {content} + + ) + })} +
+
+
+ + ) : null} {!loading && hasSearched && !error && filteredResults.length === 0 && !hasSupplementalMatches && ( @@ -865,6 +957,7 @@ export default function SearchPage({ )} {!loading && !hasSearched && ( +
@@ -897,6 +990,7 @@ export default function SearchPage({ ) : null}
+ )}
) @@ -923,21 +1017,10 @@ export const getServerSideProps: GetServerSideProps = async (co } } - const shouldFetchSearch = - Boolean(initialQuery) && - !inferTokenSearchTarget(initialQuery, initialCuratedTokens) && - !inferDirectSearchTarget(initialQuery) - - const searchResult = shouldFetchSearch - ? await fetchPublicJson<{ items?: RawExplorerSearchItem[] }>( - `/api/v2/search?q=${encodeURIComponent(initialQuery)}`, - ).catch(() => null) - : null - return { props: { initialQuery, - initialRawResults: Array.isArray(searchResult?.items) ? searchResult.items : [], + initialRawResults: [], initialCuratedTokens, }, } diff --git a/frontend/src/pages/system/index.tsx b/frontend/src/pages/system/index.tsx index acb6fb4..10f4bc9 100644 --- a/frontend/src/pages/system/index.tsx +++ b/frontend/src/pages/system/index.tsx @@ -1,11 +1,9 @@ import type { GetServerSideProps } from 'next' import SystemOperationsPage from '@/components/explorer/SystemOperationsPage' -import { fetchPublicJson } from '@/utils/publicExplorer' -import { loadTokenListResponseForSurface } from '@/services/api/tokenListSurfaces' import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse } from '@/services/api/config' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import type { RouteMatrixResponse } from '@/services/api/routes' -import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats' +import type { ExplorerStats } from '@/services/api/stats' interface SystemPageProps { initialBridgeStatus: MissionControlBridgeStatusResponse | null @@ -20,24 +18,13 @@ export default function SystemPage(props: SystemPageProps) { return } -export const getServerSideProps: GetServerSideProps = async () => { - const [bridgeStatus, networksConfig, tokenList, capabilities, routeMatrix, stats] = await Promise.all([ - fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null), - fetchPublicJson('/api/config/networks').catch(() => null), - loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null), - fetchPublicJson('/api/config/capabilities').catch(() => null), - fetchPublicJson('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null), - fetchPublicJson('/api/v2/stats').then((value) => normalizeExplorerStats(value as never)).catch(() => null), - ]) - - return { - props: { - initialBridgeStatus: bridgeStatus, - initialNetworksConfig: networksConfig, - initialTokenList: tokenList, - initialCapabilities: capabilities, - initialRouteMatrix: routeMatrix, - initialStats: stats, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialBridgeStatus: null, + initialNetworksConfig: null, + initialTokenList: null, + initialCapabilities: null, + initialRouteMatrix: null, + initialStats: null, + }, +}) diff --git a/frontend/src/pages/tokens/[address].tsx b/frontend/src/pages/tokens/[address].tsx index 0d98f7a..b217719 100644 --- a/frontend/src/pages/tokens/[address].tsx +++ b/frontend/src/pages/tokens/[address].tsx @@ -14,12 +14,13 @@ import PostureBadge from '@/components/common/PostureBadge' import GruStandardsCard from '@/components/common/GruStandardsCard' import TokenSigningSurfaceCard from '@/components/common/TokenSigningSurfaceCard' import MarketEvidenceNote from '@/components/common/MarketEvidenceNote' +import AddTokenToWalletButton from '@/components/wallet/AddTokenToWalletButton' import PaginationControls from '@/components/common/PaginationControls' -import SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { useDetailTabQuery } from '@/utils/useDetailTabQuery' const TOKEN_DETAIL_TABS_ID = 'token-detail' -import { formatTokenAmount, formatTimestamp } from '@/utils/format' +import { formatInteger, formatTokenAmount, formatTimestamp } from '@/utils/format' import { useDisplayCurrency } from '@/components/common/DisplayCurrencyContext' import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru' import { getGruExplorerMetadata } from '@/services/api/gruExplorerData' @@ -165,7 +166,7 @@ export default function TokenDetailPage() { if (provenanceTags.includes('compliant')) signals.push('marked compliant') if (provenanceTags.includes('bridge')) signals.push('bridge-linked asset') if (liquiditySummary.poolCount > 0) signals.push(`${liquiditySummary.poolCount} related liquidity pool${liquiditySummary.poolCount === 1 ? '' : 's'}`) - if ((token?.holders || 0) > 0) signals.push(`${token?.holders?.toLocaleString()} indexed holders`) + if ((token?.holders || 0) > 0) signals.push(`${formatInteger(token?.holders ?? 0)} indexed holders`) return signals }, [liquiditySummary.poolCount, provenance?.listed, provenanceTags, token?.holders]) @@ -342,24 +343,24 @@ export default function TokenDetailPage() { }, { header: 'TVL', - accessor: (pool: MissionControlLiquidityPool) => pool.tvl != null ? `$${Math.round(pool.tvl).toLocaleString()}` : 'N/A', + accessor: (pool: MissionControlLiquidityPool) => pool.tvl != null ? `$${formatInteger(Math.round(pool.tvl))}` : 'N/A', }, ] return ( -
+
-
+
Back to tokens @@ -402,6 +403,16 @@ export default function TokenDetailPage() { ) : null} +
+ +
{token.name || provenance?.name || 'Unknown'} {token.symbol || provenance?.symbol || 'Unknown'} @@ -434,7 +445,7 @@ export default function TokenDetailPage() { )} {token.holders != null && ( - {token.holders.toLocaleString()} + {formatInteger(token.holders)} )} {provenanceTags.length > 0 ? provenanceTags.map((tag) => ( @@ -464,7 +475,7 @@ export default function TokenDetailPage() { ariaLabel="Token details" /> -
+
@@ -486,7 +497,7 @@ export default function TokenDetailPage() {
Liquidity & Distribution
-
Related pools: {liquiditySummary.poolCount.toLocaleString()}
+
Related pools: {formatInteger(liquiditySummary.poolCount)}
Total visible TVL: {formatUsd(liquiditySummary.totalTvl)}
Largest visible holder: {holderConcentration != null ? `${holderConcentration}% of supply` : 'Unavailable'}
@@ -494,9 +505,9 @@ export default function TokenDetailPage() {
Transfer Activity
-
Recent transfer sample: {transferFlowSummary.sampleSize.toLocaleString()}
-
Unique senders: {transferFlowSummary.uniqueSenders.toLocaleString()}
-
Unique recipients: {transferFlowSummary.uniqueRecipients.toLocaleString()}
+
Recent transfer sample: {formatInteger(transferFlowSummary.sampleSize)}
+
Unique senders: {formatInteger(transferFlowSummary.uniqueSenders)}
+
Unique recipients: {formatInteger(transferFlowSummary.uniqueRecipients)}
@@ -511,9 +522,9 @@ export default function TokenDetailPage() {
-
+ -
+ {gruProfile ? : null} {gruExplorerMetadata ? ( @@ -594,9 +605,9 @@ export default function TokenDetailPage() {
) : null} -
+
-
+
- + -
+ {gruExplorerMetadata ? (
@@ -632,9 +643,9 @@ export default function TokenDetailPage() { /> -
+
-
+
- + )} diff --git a/frontend/src/pages/tokens/index.tsx b/frontend/src/pages/tokens/index.tsx index 2c7715c..baef5fb 100644 --- a/frontend/src/pages/tokens/index.tsx +++ b/frontend/src/pages/tokens/index.tsx @@ -1,11 +1,14 @@ import type { GetStaticProps } from 'next' import Link from 'next/link' import { useRouter } from 'next/router' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Card } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' import EntityBadge from '@/components/common/EntityBadge' +import ListFilterBar from '@/components/common/ListFilterBar' import MarketEvidenceNote from '@/components/common/MarketEvidenceNote' +import PaginationControls from '@/components/common/PaginationControls' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { tokensApi, type IndexedTokenListItem } from '@/services/api/tokens' import type { TokenListToken } from '@/services/api/config' import { @@ -14,8 +17,9 @@ import { sortIndexedTokensCanonicalFirst, } from '@/utils/canonicalTokens' import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation' -import { fetchTokenListForSurface, TOKEN_LIST_SURFACE_LABELS } from '@/services/api/tokenListSurfaces' +import { fetchTokenListForSurface } from '@/services/api/tokenListSurfaces' import { selectCuratedFeaturedTokens } from '@/utils/featuredTokens' +import { formatInteger } from '@/utils/format' const quickSearches = [ { label: 'cUSDT', description: 'Canonical compliant USD treasury / government bond liquidity and address results.' }, @@ -26,11 +30,23 @@ const quickSearches = [ { label: 'USDT', description: 'Native-side USDT routes and address discovery.' }, ] +const CURATED_PAGE_SIZE = 6 +const INDEXED_PAGE_SIZE = 12 + +type TokensPageTab = 'curated' | 'indexed' | 'shortcuts' + function normalizeAddress(value: string) { const trimmed = value.trim() return /^0x[a-fA-F0-9]{40}$/.test(trimmed) ? trimmed : '' } +function matchesTokenQuery(token: { symbol?: string | null; name?: string | null; address?: string | null }, query: string) { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) return true + const haystack = [token.symbol, token.name, token.address].filter(Boolean).join(' ').toLowerCase() + return haystack.includes(normalizedQuery) +} + interface TokensPageProps { initialCuratedTokens: TokenListToken[] } @@ -59,7 +75,11 @@ function tagPriority(tag: string) { export default function TokensPage({ initialCuratedTokens }: TokensPageProps) { const router = useRouter() - const [query, setQuery] = useState('') + const [lookupQuery, setLookupQuery] = useState('') + const [listQuery, setListQuery] = useState('') + const [activeTab, setActiveTab] = useState('curated') + const [curatedPage, setCuratedPage] = useState(1) + const [indexedPage, setIndexedPage] = useState(1) const [curatedTokens, setCuratedTokens] = useState(initialCuratedTokens) const [indexedTokens, setIndexedTokens] = useState([]) const [indexedLoading, setIndexedLoading] = useState(true) @@ -67,8 +87,8 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) { const handleSubmit = (event: React.FormEvent) => { event.preventDefault() - const normalized = normalizeAddress(query) - router.push(normalized ? `/tokens/${normalized}` : `/search?q=${encodeURIComponent(query.trim())}`) + const normalized = normalizeAddress(lookupQuery) + router.push(normalized ? `/tokens/${normalized}` : `/search?q=${encodeURIComponent(lookupQuery.trim())}`) } useEffect(() => { @@ -104,26 +124,50 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) { [canonicalAddressSet, indexedTokens], ) - useEffect(() => { - let active = true + const filteredCuratedTokens = useMemo( + () => featuredCuratedTokens.filter((token) => matchesTokenQuery(token, listQuery)), + [featuredCuratedTokens, listQuery], + ) + + const filteredIndexedTokens = useMemo( + () => sortedIndexedTokens.filter((token) => matchesTokenQuery(token, listQuery)), + [listQuery, sortedIndexedTokens], + ) + + const curatedPageCount = Math.max(1, Math.ceil(filteredCuratedTokens.length / CURATED_PAGE_SIZE)) + const paginatedCuratedTokens = useMemo(() => { + const safePage = Math.min(Math.max(curatedPage, 1), curatedPageCount) + const start = (safePage - 1) * CURATED_PAGE_SIZE + return filteredCuratedTokens.slice(start, start + CURATED_PAGE_SIZE) + }, [curatedPage, curatedPageCount, filteredCuratedTokens]) + + const loadIndexedTokens = useCallback(async (page: number) => { setIndexedLoading(true) - - tokensApi.listIndexedSafe(1, 50).then(({ ok, data }) => { - if (!active) return + try { + const { ok, data } = await tokensApi.listIndexedSafe(page, INDEXED_PAGE_SIZE) setIndexedTokens(ok ? data : []) + } catch { + setIndexedTokens([]) + } finally { setIndexedLoading(false) - }).catch(() => { - if (active) { - setIndexedTokens([]) - setIndexedLoading(false) - } - }) - - return () => { - active = false } }, []) + useEffect(() => { + void loadIndexedTokens(indexedPage) + }, [indexedPage, loadIndexedTokens]) + + useEffect(() => { + setCuratedPage(1) + setIndexedPage(1) + }, [listQuery]) + + useEffect(() => { + if (curatedPage > curatedPageCount) { + setCuratedPage(curatedPageCount) + } + }, [curatedPage, curatedPageCount]) + useEffect(() => { let active = true @@ -146,103 +190,148 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) { } }, [featuredCuratedTokens]) + const tokenTabs = useMemo[]>( + () => [ + { id: 'curated', label: 'Curated', count: featuredCuratedTokens.length }, + { id: 'indexed', label: 'Indexed', count: sortedIndexedTokens.length }, + { id: 'shortcuts', label: 'Quick searches', count: quickSearches.length }, + ], + [featuredCuratedTokens.length, sortedIndexedTokens.length], + ) + + const indexedHasNextPage = indexedTokens.length === INDEXED_PAGE_SIZE + return ( -
+
- -
- setQuery(event.target.value)} - placeholder="Token symbol, name, or contract address" - className="min-h-10 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-950" - /> - - -
+ -
- -

- {TOKEN_LIST_SURFACE_LABELS.catalog}. Showing {featuredCuratedTokens.length} featured tokens from the live report list. -

-
- {featuredCuratedTokens - .filter((token): token is TokenListToken & { address: string } => typeof token.address === 'string' && token.address.trim().length > 0) - .map((token) => { - const market = featuredMarkets[token.address.toLowerCase()]?.market - return ( - -
-
-
{token.symbol || token.name || 'Token'}
-

- {token.name || 'Listed in the Chain 138 token registry.'} -

-
-
- {market ? ( -
-
-
Price
-
{formatUsd(market.priceUsd)}
-
-
-
Liquidity
-
{formatUsd(market.liquidityUsd)}
-
-
- -
-
- ) : null} - {token.tags && token.tags.length > 0 && ( -
- {[...token.tags].sort((a, b) => tagPriority(a) - tagPriority(b)).slice(0, 3).map((tag) => ( - - ))} -
- )} - - ) - })} -
+ + +
+ setLookupQuery(event.target.value)} + placeholder="Token symbol, name, or contract address" + className="min-h-10 flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-950" + /> + +
-
+ + +
+ {paginatedCuratedTokens.length === 0 ? ( +

+ No curated tokens match your filter. +

+ ) : ( + paginatedCuratedTokens + .filter((token): token is TokenListToken & { address: string } => typeof token.address === 'string' && token.address.trim().length > 0) + .map((token) => { + const market = featuredMarkets[token.address.toLowerCase()]?.market + return ( + +
+
+
{token.symbol || token.name || 'Token'}
+

+ {token.name || 'Listed in the Chain 138 token registry.'} +

+
+
+ {market ? ( +
+
+
Price
+
{formatUsd(market.priceUsd)}
+
+
+
Liquidity
+
{formatUsd(market.liquidityUsd)}
+
+
+ +
+
+ ) : null} + {token.tags && token.tags.length > 0 ? ( +
+ {[...token.tags].sort((a, b) => tagPriority(a) - tagPriority(b)).slice(0, 3).map((tag) => ( + + ))} +
+ ) : null} + + ) + }) + )} +
+ +
+ -
+ -

- Canonical registry tokens appear first. Non-canonical indexed duplicates are labeled so operators can distinguish curated assets from stray contract listings. -

+ {indexedLoading ? ( -

Loading indexed token feed…

- ) : sortedIndexedTokens.length === 0 ? ( -

Indexed token feed is temporarily unavailable.

+

Loading indexed token feed…

+ ) : filteredIndexedTokens.length === 0 ? ( +

+ {sortedIndexedTokens.length === 0 + ? 'Indexed token feed is temporarily unavailable.' + : 'No indexed tokens on this page match your filter.'} +

) : ( -
- {sortedIndexedTokens.map((token) => { +
+ {filteredIndexedTokens.map((token) => { const canonical = isCanonicalTokenAddress(token.address, canonicalAddressSet) return ( {token.name || token.address}

- {token.holders != null ? `${token.holders.toLocaleString()} holders` : 'Holders unavailable'} + {token.holders != null ? `${formatInteger(token.holders)} holders` : 'Holders unavailable'}
) })}
)} +
-
+ -
+
{quickSearches.map((token) => ( @@ -289,7 +386,7 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) { ))}
-
+
) } diff --git a/frontend/src/pages/transactions/[hash].tsx b/frontend/src/pages/transactions/[hash].tsx index bd2ccc6..8ad2194 100644 --- a/frontend/src/pages/transactions/[hash].tsx +++ b/frontend/src/pages/transactions/[hash].tsx @@ -11,13 +11,15 @@ import { TransactionLookupDiagnostic, TransactionTokenTransfer, } from '@/services/api/transactions' -import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format' +import { formatInteger, formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format' import { DetailRow } from '@/components/common/DetailRow' import EntityBadge from '@/components/common/EntityBadge' import PostureBadge from '@/components/common/PostureBadge' import PageIntro from '@/components/common/PageIntro' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import DisclosureSection from '@/components/common/DisclosureSection' import PaginationControls from '@/components/common/PaginationControls' -import SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs' +import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { useDetailTabQuery } from '@/utils/useDetailTabQuery' const TRANSACTION_DETAIL_TABS_ID = 'transaction-detail' @@ -388,29 +390,19 @@ export default function TransactionDetailPage() { }, [hash]) return ( -
+
-
- - Back to transactions - - {(transaction?.hash || hash) && ( - - Search this hash - - )} -
- {!router.isReady || loading ? (

Loading transaction...

@@ -451,7 +443,17 @@ export default function TransactionDetailPage() {
) : ( -
+
+ + +
@@ -472,7 +474,7 @@ export default function TransactionDetailPage() { Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'} {nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
-
Gas used: {transaction.gas_used != null ? transaction.gas_used.toLocaleString() : 'Unavailable'}
+
Gas used: {transaction.gas_used != null ? formatInteger(transaction.gas_used) : 'Unavailable'}
Utilization: {gasUtilization != null ? `${gasUtilization}%` : 'Unavailable'}
@@ -491,8 +493,8 @@ export default function TransactionDetailPage() { {checkpointTotalUsd != null ? ` — total ${formatUsd(Number(checkpointTotalUsd))}` : ''}
) : null} -
Token transfers: {tokenTransferCount.toLocaleString()}
-
Internal calls: {internalCallCount.toLocaleString()}
+
Token transfers: {formatInteger(tokenTransferCount)}
+
Internal calls: {formatInteger(internalCallCount)}
@@ -513,25 +515,28 @@ export default function TransactionDetailPage() {
+ {checkpointAttestation ? ( + + ) : null} -
+ {complianceAssessment ? (
@@ -565,9 +570,9 @@ export default function TransactionDetailPage() {

)} -
+
-
+
@@ -616,7 +621,7 @@ export default function TransactionDetailPage() { {transaction.gas_used != null && ( - {transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()} + {formatInteger(transaction.gas_used)} / {formatInteger(transaction.gas_limit)} )} {transaction.revert_reason && ( @@ -662,9 +667,9 @@ export default function TransactionDetailPage() {
)} -
+ -
+
- + -
+
- + {transaction.input_data ? ( -
+
                 {transaction.input_data}
               
-
+ ) : null} )} diff --git a/frontend/src/pages/transactions/index.tsx b/frontend/src/pages/transactions/index.tsx index c3374bd..7207b71 100644 --- a/frontend/src/pages/transactions/index.tsx +++ b/frontend/src/pages/transactions/index.tsx @@ -4,18 +4,19 @@ import { Card, Table, Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' import { transactionsApi, Transaction } from '@/services/api/transactions' import { formatWeiAsEth } from '@/utils/format' -import EntityBadge from '@/components/common/EntityBadge' import PageIntro from '@/components/common/PageIntro' import { fetchPublicJson } from '@/utils/publicExplorer' import { normalizeTransaction } from '@/services/api/blockscout' import { summarizeChainActivity } from '@/utils/activityContext' -import ActivityContextPanel from '@/components/common/ActivityContextPanel' -import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure' import PaginationControls from '@/components/common/PaginationControls' +import ListFilterBar from '@/components/common/ListFilterBar' +import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs' import { useListPageQuery } from '@/utils/useListPageQuery' -import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats' +import type { ExplorerStats } from '@/services/api/stats' import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' +import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext' interface TransactionsPageProps { initialTransactions: Transaction[] @@ -44,21 +45,55 @@ function serializeTransactionList(transactions: Transaction[]): Transaction[] { ) as Transaction[] } +type TransactionFilterTab = 'all' | 'success' | 'failed' | 'contracts' | 'transfers' + +function matchesTransactionQuery(transaction: Transaction, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) return true + const fields = [ + transaction.hash, + transaction.from_address, + transaction.to_address || '', + transaction.contract_address || '', + ] + return fields.some((field) => field.toLowerCase().includes(normalizedQuery)) +} + +function matchesTransactionFilter(transaction: Transaction, filter: TransactionFilterTab): boolean { + switch (filter) { + case 'success': + return transaction.status === 1 + case 'failed': + return transaction.status != null && transaction.status !== 1 + case 'contracts': + return Boolean(transaction.contract_address) + case 'transfers': + return (transaction.token_transfers?.length || 0) > 0 + default: + return true + } +} + export default function TransactionsPage({ initialTransactions, initialLatestBlocks, initialStats, initialBridgeStatus, }: TransactionsPageProps) { - const pageSize = 20 + const pageSize = 15 const [transactions, setTransactions] = useState(initialTransactions) + const [latestBlocks, setLatestBlocks] = useState(initialLatestBlocks) + const [stats, setStats] = useState(initialStats) + const [bridgeStatus, setBridgeStatus] = useState(initialBridgeStatus) const [loading, setLoading] = useState(initialTransactions.length === 0) + const [filterTab, setFilterTab] = useState('all') + const [filterQuery, setFilterQuery] = useState('') const { page, setPage } = useListPageQuery() const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const activityContext = useMemo( () => summarizeChainActivity({ - blocks: initialLatestBlocks.map((block) => ({ + blocks: latestBlocks.map((block) => ({ chain_id: chainId, number: block.number, hash: '', @@ -69,12 +104,12 @@ export default function TransactionsPage({ gas_limit: 0, })), transactions, - latestBlockNumber: initialLatestBlocks[0]?.number ?? null, - latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null, - freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus), - diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null, + latestBlockNumber: latestBlocks[0]?.number ?? null, + latestBlockTimestamp: latestBlocks[0]?.timestamp ?? null, + freshness: resolveEffectiveFreshness(stats, bridgeStatus), + diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null, }), - [chainId, initialBridgeStatus, initialLatestBlocks, initialStats, transactions], + [chainId, bridgeStatus, latestBlocks, stats, transactions], ) const loadTransactions = useCallback(async () => { @@ -99,8 +134,59 @@ export default function TransactionsPage({ void loadTransactions() }, [initialTransactions, loadTransactions, page]) + useEffect(() => { + if (initialLatestBlocks.length > 0 && initialStats && initialBridgeStatus) { + setLatestBlocks(initialLatestBlocks) + setStats(initialStats) + setBridgeStatus(initialBridgeStatus) + return + } + + let active = true + Promise.all([ + fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>( + '/api/v2/blocks?page=1&page_size=3', + ).catch(() => null), + fetchExplorerTruthContext(), + ]) + .then(([blocksResult, truthContext]) => { + if (!active) return + if (initialLatestBlocks.length === 0 && Array.isArray(blocksResult?.items)) { + setLatestBlocks( + blocksResult.items + .map((item) => ({ + number: Number(item.height || 0), + timestamp: item.timestamp || '', + })) + .filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp), + ) + } + setStats((current) => current ?? truthContext.initialStats) + setBridgeStatus((current) => current ?? truthContext.initialBridgeStatus) + }) + .catch(() => undefined) + + return () => { + active = false + } + }, [initialBridgeStatus, initialLatestBlocks, initialStats]) + + useEffect(() => { + setPage(1) + }, [filterTab, filterQuery, setPage]) + + const filteredTransactions = useMemo( + () => transactions.filter((transaction) => matchesTransactionFilter(transaction, filterTab) && matchesTransactionQuery(transaction, filterQuery)), + [filterQuery, filterTab, transactions], + ) + + const paginatedTransactions = useMemo(() => { + const start = (page - 1) * pageSize + return filteredTransactions.slice(start, start + pageSize) + }, [filteredTransactions, page, pageSize]) + const transactionSummary = useMemo(() => { - const sampleSize = transactions.length + const sampleSize = paginatedTransactions.length if (sampleSize === 0) { return { sampleSize: 0, @@ -111,12 +197,12 @@ export default function TransactionsPage({ } } - const successes = transactions.filter((transaction) => transaction.status === 1).length - const contractCreations = transactions.filter((transaction) => Boolean(transaction.contract_address)).length - const tokenTransferTransactions = transactions.filter( + const successes = paginatedTransactions.filter((transaction) => transaction.status === 1).length + const contractCreations = paginatedTransactions.filter((transaction) => Boolean(transaction.contract_address)).length + const tokenTransferTransactions = paginatedTransactions.filter( (transaction) => (transaction.token_transfers?.length || 0) > 0, ).length - const feeValues = transactions + const feeValues = paginatedTransactions .map((transaction) => { if (!transaction.fee) return null const numeric = Number(transaction.fee) @@ -135,10 +221,23 @@ export default function TransactionsPage({ tokenTransferTransactions, averageFee, } + }, [paginatedTransactions]) + + const filterTabs = useMemo[]>(() => { + const countFor = (filter: TransactionFilterTab) => + transactions.filter((transaction) => matchesTransactionFilter(transaction, filter)).length + + return [ + { id: 'all', label: 'All', count: transactions.length }, + { id: 'success', label: 'Success', count: countFor('success') }, + { id: 'failed', label: 'Failed', count: countFor('failed') }, + { id: 'contracts', label: 'Contract creation', count: countFor('contracts') }, + { id: 'transfers', label: 'Token transfers', count: countFor('transfers') }, + ] }, [transactions]) - const showPagination = page > 1 || transactions.length > 0 - const canGoNext = transactions.length === pageSize + const showPagination = filteredTransactions.length > pageSize || page > 1 + const canGoNext = page * pageSize < filteredTransactions.length const columns = [ { @@ -188,56 +287,63 @@ export default function TransactionsPage({ ] return ( -
+
-
- - -
+ - {!loading && transactions.length > 0 && ( -
- -
Sample Size
-
{transactionSummary.sampleSize.toLocaleString()}
-
Transactions on the current explorer page.
-
- -
Success Rate
-
- {transactionSummary.successRate}% - = 90 ? 'healthy' : 'mixed'} tone={transactionSummary.successRate >= 90 ? 'success' : 'warning'} /> -
-
Based on the visible recent transaction sample.
-
- -
Contract Creations
-
{transactionSummary.contractCreations.toLocaleString()}
-
New contracts created in the visible sample.
-
- -
Avg Sample Fee
-
{transactionSummary.averageFee || 'Unavailable'}
-
- Token-transfer txs: {transactionSummary.tokenTransferTransactions.toLocaleString()} -
-
+ + + + + {!loading && paginatedTransactions.length > 0 && ( +
+ + {transactionSummary.sampleSize} on page + + + {transactionSummary.successRate}% success + + + {transactionSummary.contractCreations} contracts + + + {transactionSummary.tokenTransferTransactions} with transfers + + {transactionSummary.averageFee ? ( + + avg fee {transactionSummary.averageFee} + + ) : null}
)} @@ -248,8 +354,12 @@ export default function TransactionsPage({ ) : (
tx.hash} /> )} @@ -265,55 +375,25 @@ export default function TransactionsPage({ className="mt-6" /> ) : null} - -
- -

- Use the linked hashes above to inspect detail pages, or pivot into block production, address activity, and explorer-wide search. -

-
- - Blocks → - - - Addresses → - - - Search → - -
-
-
) } export const getServerSideProps: GetServerSideProps = async () => { const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138') - const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([ - fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null), - fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null), - fetchPublicJson>('/api/v2/stats').catch(() => null), - fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null), - ]) + const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=15').catch( + () => null, + ) const initialTransactions = Array.isArray(transactionsResult?.items) ? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId)) : [] - const initialLatestBlocks = Array.isArray(blocksResult?.items) - ? blocksResult.items - .map((item) => ({ - number: Number(item.height || 0), - timestamp: item.timestamp || '', - })) - .filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp) - : [] return { props: { initialTransactions: serializeTransactionList(initialTransactions), - initialLatestBlocks, - initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null, - initialBridgeStatus: bridgeResult, + initialLatestBlocks: [], + initialStats: null, + initialBridgeStatus: null, }, } } diff --git a/frontend/src/pages/wallet/index.tsx b/frontend/src/pages/wallet/index.tsx index d132a41..1a789ec 100644 --- a/frontend/src/pages/wallet/index.tsx +++ b/frontend/src/pages/wallet/index.tsx @@ -6,7 +6,6 @@ import type { NetworksCatalog, TokenListCatalog, } from '@/components/wallet/AddToMetaMask' -import { fetchPublicJsonWithMeta } from '@/utils/publicExplorer' interface WalletRoutePageProps { initialNetworks: NetworksCatalog | null @@ -21,21 +20,13 @@ export default function WalletRoutePage(props: WalletRoutePageProps) { return } -export const getServerSideProps: GetServerSideProps = async () => { - const [networksResult, tokenListResult, capabilitiesResult] = await Promise.all([ - fetchPublicJsonWithMeta('/api/config/networks').catch(() => null), - fetchPublicJsonWithMeta('/api/v1/report/token-list?chainId=138&wallet=1').catch(() => null), - fetchPublicJsonWithMeta('/api/config/capabilities').catch(() => null), - ]) - - return { - props: { - initialNetworks: networksResult?.data || null, - initialTokenList: tokenListResult?.data || null, - initialCapabilities: capabilitiesResult?.data || null, - initialNetworksMeta: networksResult?.meta || null, - initialTokenListMeta: tokenListResult?.meta || null, - initialCapabilitiesMeta: capabilitiesResult?.meta || null, - }, - } -} +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { + initialNetworks: null, + initialTokenList: null, + initialCapabilities: null, + initialNetworksMeta: null, + initialTokenListMeta: null, + initialCapabilitiesMeta: null, + }, +}) diff --git a/frontend/src/pages/watchlist/index.tsx b/frontend/src/pages/watchlist/index.tsx index ec97d2a..f1eb110 100644 --- a/frontend/src/pages/watchlist/index.tsx +++ b/frontend/src/pages/watchlist/index.tsx @@ -4,7 +4,8 @@ import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { Card, Address } from '@/libs/frontend-ui-primitives' import PageIntro from '@/components/common/PageIntro' -import { Explain, useUiMode } from '@/components/common/UiModeContext' +import CompactMetricBar from '@/components/common/CompactMetricBar' +import DisclosureSection from '@/components/common/DisclosureSection' import { accessApi, type WalletAccessSession } from '@/services/api/access' import { addressesApi, type AddressInfo, type TransactionSummary } from '@/services/api/addresses' import { @@ -29,7 +30,6 @@ function shortAddress(value?: string | null): string { } export default function WatchlistPage() { - const { mode } = useUiMode() const [entries, setEntries] = useState([]) const [walletSession, setWalletSession] = useState(null) const [snapshots, setSnapshots] = useState>({}) @@ -178,53 +178,29 @@ export default function WatchlistPage() { } return ( -
+
- -
-
-
Tracked addresses
-
{entries.length}
-
-
-
Recent indexed activity
-
{trackedSummaries.withRecentTransactions}
-
Entries with at least one visible recent transaction.
-
-
-
EOAs / contracts
-
- {trackedSummaries.eoas} / {trackedSummaries.contracts} -
-
-
-
Visible tx volume
-
- {trackedSummaries.totalTransactions.toLocaleString()} -
-
Aggregate indexed transaction count across tracked entries.
-
-
- - This view keeps tracked-address shortcuts and quick explorer evidence in one place so Guided mode can explain what each entity represents while Expert mode stays denser. - -
+ - + {walletSession ? (
@@ -261,9 +237,9 @@ export default function WatchlistPage() { Connect a wallet from the wallet tools page to pin your own address into tracked workflows.
)} - + - +

{entries.length === 0 @@ -314,31 +290,13 @@ export default function WatchlistPage() {

-
-
-
Type
-
- {info ? (info.is_contract ? 'Contract' : 'EOA') : loadingSnapshots ? 'Loading…' : 'Unknown'} -
-
-
-
Indexed txs
-
- {info ? info.transaction_count.toLocaleString() : loadingSnapshots ? 'Loading…' : 'Unknown'} -
-
-
-
Token holdings
-
- {info ? info.token_count.toLocaleString() : loadingSnapshots ? 'Loading…' : 'Unknown'} -
-
-
-
Recent visible tx
-
- {recentTransaction ? `#${recentTransaction.block_number.toLocaleString()}` : loadingSnapshots ? 'Loading…' : 'None visible'} -
-
+
+ {info ? (info.is_contract ? 'Contract' : 'EOA') : loadingSnapshots ? '…' : 'Unknown'} + {info ? `${info.transaction_count} txs` : ''} + {info ? `${info.token_count} tokens` : ''} + + {recentTransaction ? `Latest #${recentTransaction.block_number}` : loadingSnapshots ? '' : 'No recent tx'} +
diff --git a/frontend/src/services/api/missionControl.ts b/frontend/src/services/api/missionControl.ts index 7f4ace8..562cf3d 100644 --- a/frontend/src/services/api/missionControl.ts +++ b/frontend/src/services/api/missionControl.ts @@ -13,6 +13,7 @@ export interface MissionControlRelayItemSummary { status: string text: string tone: 'normal' | 'warning' | 'danger' + polledAt?: string | null } export interface MissionControlRelaySnapshot { @@ -162,17 +163,6 @@ function getMissionControlBridgeStatusUrl(): string { return `${getExplorerApiBase()}/explorer-api/v1/track1/bridge/status` } -function relativeAge(isoString?: string): string { - if (!isoString) return '' - const parsed = Date.parse(isoString) - if (!Number.isFinite(parsed)) return '' - const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000)) - if (seconds < 60) return `${seconds}s ago` - const minutes = Math.round(seconds / 60) - if (minutes < 60) return `${minutes}m ago` - const hours = Math.round(minutes / 60) - return `${hours}h ago` -} function describeRelayStatus(snapshot: MissionControlRelaySnapshot, status: string): string { if (snapshot.last_error?.scope === 'bridge_inventory') { @@ -239,12 +229,10 @@ export function summarizeMissionControlRelay( const statusLabel = describeRelayStatus(snapshot, status) const destination = snapshot.destination?.chain_name const queueSize = snapshot.queue?.size - const pollAge = relativeAge(snapshot.last_source_poll?.at) let text = `${label}: ${statusLabel}` if (destination) text += ` -> ${destination}` if (queueSize != null) text += ` · queue ${queueSize}` - if (pollAge) text += ` · polled ${pollAge}` let tone: MissionControlRelaySummary['tone'] = 'normal' if (['paused', 'starting'].includes(status)) { @@ -254,7 +242,14 @@ export function summarizeMissionControlRelay( tone = 'danger' } - return { key, label, status, text, tone } + return { + key, + label, + status, + text, + tone, + polledAt: snapshot.last_source_poll?.at || null, + } }) .filter((item): item is MissionControlRelayItemSummary => item !== null) diff --git a/frontend/src/utils/activityContext.ts b/frontend/src/utils/activityContext.ts index dab0a00..45c2718 100644 --- a/frontend/src/utils/activityContext.ts +++ b/frontend/src/utils/activityContext.ts @@ -22,11 +22,6 @@ function sortDescending(values: number[]): number[] { return [...values].sort((left, right) => right - left) } -function toTimestamp(value?: string | null): number | null { - if (!value) return null - const parsed = Date.parse(value) - return Number.isFinite(parsed) ? parsed : null -} export function summarizeChainActivity(input: { blocks?: Block[] @@ -71,13 +66,7 @@ export function summarizeChainActivity(input: { const transactionVisibilityUnavailable = freshness?.latest_indexed_transaction.source === 'unavailable' || freshness?.latest_indexed_transaction.completeness === 'unavailable' - const latestTransactionAgeSeconds = - freshness?.latest_indexed_transaction.age_seconds ?? - (() => { - const timestamp = toTimestamp(latestTransactionTimestamp) - if (timestamp == null) return null - return Math.max(0, Math.round((Date.now() - timestamp) / 1000)) - })() + const latestTransactionAgeSeconds = freshness?.latest_indexed_transaction.age_seconds ?? null const gap = freshness?.latest_non_empty_block.distance_from_head ?? diagnostics?.tx_lag_blocks ?? diff --git a/frontend/src/utils/format.test.ts b/frontend/src/utils/format.test.ts index 6d5d477..7b5415b 100644 --- a/frontend/src/utils/format.test.ts +++ b/frontend/src/utils/format.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import { formatWeiAsEth } from './format' +import { + formatBigIntWhole, + formatInteger, + formatRelativeAgeAt, + formatTimestamp, + formatWeiAsEth, +} from './format' describe('formatWeiAsEth', () => { it('formats zero and whole ETH values without losing precision', () => { @@ -13,3 +19,32 @@ describe('formatWeiAsEth', () => { expect(formatWeiAsEth('9007199254740993')).toBe('0.009 ETH') }) }) + +describe('formatInteger', () => { + it('uses en-US grouping for finite numbers', () => { + expect(formatInteger(1234567)).toBe('1,234,567') + expect(formatInteger(null)).toBe('0') + }) +}) + +describe('formatBigIntWhole', () => { + it('groups bigint digits with commas', () => { + expect(formatBigIntWhole(1234567890123456789n)).toBe('1,234,567,890,123,456,789') + expect(formatBigIntWhole(-42n)).toBe('-42') + }) +}) + +describe('formatTimestamp', () => { + it('uses fixed en-US locale formatting', () => { + const formatted = formatTimestamp('2026-01-15T12:30:00.000Z') + expect(formatted).toMatch(/1\/15\/2026/) + expect(formatted).toMatch(/PM|AM/) + }) +}) + +describe('formatRelativeAgeAt', () => { + it('computes relative age from a fixed reference time', () => { + const reference = Date.parse('2026-06-21T12:00:00.000Z') + expect(formatRelativeAgeAt(reference, '2026-06-21T11:59:30.000Z')).toBe('30s ago') + }) +}) diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index 32b4d62..da1ab17 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -60,20 +60,44 @@ export function formatTokenAmount(value: string | number | null | undefined, dec return symbol ? `${formatted} ${symbol}` : formatted } -export function formatTimestamp(value?: string | null): string { - if (!value) return 'Unknown' - const date = new Date(value) +const TIMESTAMP_LOCALE = 'en-US' +const TIMESTAMP_OPTIONS: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + hour12: true, +} +const DATE_OPTIONS: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'numeric', + day: 'numeric', +} +const INTEGER_FORMAT = new Intl.NumberFormat(TIMESTAMP_LOCALE) + +export function formatTimestamp(value?: string | number | Date | null): string { + if (value == null) return 'Unknown' + const date = value instanceof Date ? value : new Date(value) if (Number.isNaN(date.getTime())) { - return value + return typeof value === 'string' ? value : 'Unknown' } - return date.toLocaleString() + return date.toLocaleString(TIMESTAMP_LOCALE, TIMESTAMP_OPTIONS) } -export function formatRelativeAge(value?: string | null): string { +export function formatDate(value?: string | number | Date | null): string { + if (value == null) return 'Unknown' + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) return 'Unknown' + return date.toLocaleDateString(TIMESTAMP_LOCALE, DATE_OPTIONS) +} + +export function formatRelativeAgeAt(referenceMs: number, value?: string | null): string { if (!value) return 'Unknown' const parsed = Date.parse(value) if (!Number.isFinite(parsed)) return 'Unknown' - const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000)) + const seconds = Math.max(0, Math.round((referenceMs - parsed) / 1000)) if (seconds < 60) return `${seconds}s ago` const minutes = Math.round(seconds / 60) if (minutes < 60) return `${minutes}m ago` @@ -82,3 +106,20 @@ export function formatRelativeAge(value?: string | null): string { const days = Math.round(hours / 24) return `${days}d ago` } + +/** Client-only helper; prefer ClientRelativeTime in render paths to avoid hydration mismatch. */ +export function formatRelativeAge(value?: string | null): string { + return formatRelativeAgeAt(Date.now(), value) +} + +export function formatInteger(value?: number | null): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return '0' + return INTEGER_FORMAT.format(value) +} + +export function formatBigIntWhole(value: bigint): string { + const negative = value < 0n + const digits = (negative ? -value : value).toString() + const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + return negative ? `-${grouped}` : grouped +} diff --git a/frontend/src/utils/walletTokenBalances.ts b/frontend/src/utils/walletTokenBalances.ts index 7433a4d..7b5886c 100644 --- a/frontend/src/utils/walletTokenBalances.ts +++ b/frontend/src/utils/walletTokenBalances.ts @@ -1,3 +1,5 @@ +import { formatBigIntWhole } from './format' + export type EthereumRpcProvider = { request: (args: { method: string; params?: unknown }) => Promise } @@ -100,10 +102,10 @@ export async function filterTokensWithNonZeroBalance { + it('switches when chain already exists', async () => { + const request = vi.fn().mockResolvedValue(null) + const ok = await switchOrAddChain({ request }, chain138) + expect(ok).toBe(true) + expect(request).toHaveBeenCalledWith({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x8a' }], + }) + }) + + it('adds chain when wallet reports 4902', async () => { + const request = vi + .fn() + .mockRejectedValueOnce({ code: 4902 }) + .mockResolvedValueOnce(null) + const ok = await switchOrAddChain({ request }, chain138) + expect(ok).toBe(true) + expect(request).toHaveBeenNthCalledWith(2, { + method: 'wallet_addEthereumChain', + params: [ + expect.objectContaining({ + chainId: '0x8a', + chainName: 'DeFi Oracle Meta Mainnet', + }), + ], + }) + }) + + it('watchTokenInWallet passes hex chainId for Chain 138 tokens', async () => { + const request = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(true) + const result = await watchTokenInWallet( + { request }, + { + chainId: 138, + address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b', + symbol: 'cUSDC', + decimals: 6, + }, + chain138, + ) + expect(result).toEqual({ ok: true, added: true }) + expect(request).toHaveBeenNthCalledWith(2, { + method: 'wallet_watchAsset', + params: { + type: 'ERC20', + options: expect.objectContaining({ + chainId: '0x8a', + symbol: 'cUSDC', + }), + }, + }) + }) +}) diff --git a/frontend/src/utils/walletWatchToken.ts b/frontend/src/utils/walletWatchToken.ts new file mode 100644 index 0000000..c4c2685 --- /dev/null +++ b/frontend/src/utils/walletWatchToken.ts @@ -0,0 +1,64 @@ +import type { WalletChain } from '@/components/wallet/AddToMetaMask' +import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain' +import { + buildWatchAssetRpcRequest, + type WatchAssetToken, +} from '@/utils/walletWatchAsset' +import { + isMobileWalletContext, + type EthereumProvider, +} from '@/utils/walletProviderEnv' + +export async function switchOrAddChain( + ethereum: EthereumProvider, + chain: WalletChain, +): Promise { + try { + await ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: chain.chainId }], + }) + return true + } catch (e) { + const err = e as { code?: number } + if (err.code !== 4902) return false + } + + try { + await ethereum.request({ + method: 'wallet_addEthereumChain', + params: [ + toWalletAddEthereumChainParams(chain, { + preferSingleRpc: isMobileWalletContext(), + }), + ], + }) + return true + } catch { + return false + } +} + +export async function watchTokenInWallet( + ethereum: EthereumProvider, + token: WatchAssetToken, + chain: WalletChain, +): Promise<{ ok: boolean; added?: boolean; error?: string }> { + const switched = await switchOrAddChain(ethereum, chain) + if (!switched) { + return { ok: false, error: `Failed to switch to ${chain.chainName}. Add the network first, then retry.` } + } + + try { + const added = await ethereum.request( + buildWatchAssetRpcRequest(token, isMobileWalletContext()), + ) + return { ok: true, added: Boolean(added) } + } catch (e) { + const err = e as { code?: number; message?: string } + if (err.code === 4001 || /rejected|denied|not been authorized/i.test(err.message || '')) { + return { ok: false, error: 'Wallet request was rejected. Approve the popup to add the token.' } + } + return { ok: false, error: err.message || 'Could not add token to wallet.' } + } +} diff --git a/scripts/deploy-next-frontend-to-vmid5000.sh b/scripts/deploy-next-frontend-to-vmid5000.sh index 3ee379c..773e828 100755 --- a/scripts/deploy-next-frontend-to-vmid5000.sh +++ b/scripts/deploy-next-frontend-to-vmid5000.sh @@ -233,6 +233,27 @@ if [[ -f "${SMOKE_SCRIPT}" ]]; then echo "WARN: institutional smoke failed — see ${SMOKE_SCRIPT}" >&2 } fi + +SMOKE_ROUTES="${FRONTEND_ROOT}/scripts/smoke-routes.mjs" +SMOKE_SCROLL="${FRONTEND_ROOT}/scripts/smoke-scroll-height.mjs" +if [[ -f "${SMOKE_ROUTES}" ]] && [[ "${EXPLORER_SKIP_ROUTE_SMOKE:-}" != "1" ]]; then + echo "" + echo "== Playwright route smoke (optional) ==" + if (cd "${FRONTEND_ROOT}" && npm exec -- playwright install chromium >/dev/null 2>&1 && BASE_URL="${EXPLORER_BASE:-https://explorer.d-bis.org}" npm run smoke:routes); then + echo "Route smoke: PASS" + else + echo "WARN: route smoke skipped or failed — set EXPLORER_SKIP_ROUTE_SMOKE=1 to silence; run npm install in frontend first" >&2 + fi +fi +if [[ -f "${SMOKE_SCROLL}" ]] && [[ "${EXPLORER_SKIP_SCROLL_SMOKE:-}" != "1" ]] && [[ "${EXPLORER_SKIP_ROUTE_SMOKE:-}" != "1" ]]; then + echo "" + echo "== Playwright scroll-height smoke (optional) ==" + if (cd "${FRONTEND_ROOT}" && BASE_URL="${EXPLORER_BASE:-https://explorer.d-bis.org}" npm run smoke:scroll); then + echo "Scroll-height smoke: PASS" + else + echo "WARN: scroll-height smoke failed — set EXPLORER_SKIP_SCROLL_SMOKE=1 to silence" >&2 + fi +fi echo "" echo "Nginx follow-up:" echo " Switch the explorer server block to proxy / and /_next/ to 127.0.0.1:${FRONTEND_PORT}"