Improve explorer SSR, hydration, compaction, and smoke coverage.
Some checks failed
Validate Explorer / frontend (push) Failing after 14m45s
Deploy Explorer Live / deploy (push) Failing after 14m52s
Validate Explorer / smoke-e2e (push) Has been cancelled

Defer heavy getServerSideProps on home, operator, addresses, and wallet to cut TTFB; centralize locale-safe formatters with client-only relative times; add compact ops UX, bridge/operator relay pagination, and Playwright route/scroll smoke in deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-06-22 15:52:47 -07:00
parent 7e82b917f5
commit 0cb31cfa9d
74 changed files with 4274 additions and 2934 deletions

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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'}`)

View File

@@ -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
})

View File

@@ -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 (
<main className="container mx-auto px-4 py-6 sm:py-8">
<main className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Access Control"
title="Wallet Login, RPC Access & API Tokens"
description="Connect a wallet for standard account sign-in, manage authenticated access, issue API keys, and prepare subscription-gated RPC products for DBIS, ThirdWeb, and Alltra."
description="Wallet sign-in, API keys, and subscription-gated RPC products."
actions={[
{ href: '/wallet', label: 'Wallet tools' },
{ href: '/system', label: 'System status' },
{ href: '/search', label: 'Search explorer' },
{ href: '/wallet', label: 'Wallet' },
{ href: '/system', label: 'System' },
]}
/>
@@ -372,7 +387,17 @@ export default function AccessManagementPage() {
</Card>
) : null}
<div className="mb-8 grid gap-6 lg:grid-cols-3">
<SectionTabs
idPrefix="access"
ariaLabel="Access console sections"
tabs={accessTabs}
activeTab={accessTab}
onChange={setAccessTab}
className="mb-4"
/>
<TabPanel idPrefix="access" tabId="products" activeTab={accessTab}>
<div className="mb-4 grid gap-4 lg:grid-cols-3">
{products.map((product) => (
<Card key={product.slug} title={product.name}>
<div className="space-y-3 text-sm text-gray-700 dark:text-gray-300">
@@ -446,10 +471,13 @@ export default function AccessManagementPage() {
</Card>
))}
</div>
</TabPanel>
<div className="grid gap-6 lg:grid-cols-[1fr_1.2fr]">
<div className="space-y-6">
<Card title="Wallet Authentication">
<TabPanel idPrefix="access" tabId="account" activeTab={accessTab}>
<div className="grid gap-4 lg:grid-cols-[1fr_1.2fr]">
<div className="space-y-4">
<DisclosureSection title="Wallet authentication" defaultOpen={Boolean(walletSession)} alwaysCollapsible>
<Card>
<div className="space-y-4 text-sm text-gray-700 dark:text-gray-300">
<p>
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.
</p>
<div className="text-xs text-gray-500 dark:text-gray-400">
Session expires {new Date(walletSession.expiresAt).toLocaleString()}
Session expires {formatTimestamp(walletSession.expiresAt)}
</div>
<div className="flex flex-wrap gap-3">
<button
@@ -498,21 +526,24 @@ export default function AccessManagementPage() {
)}
</div>
</Card>
</DisclosureSection>
<Card title="Operator Identities">
<div className="space-y-4">
<DisclosureSection title="Operator identities" defaultOpen={false} alwaysCollapsible>
<Card>
<div className="space-y-3">
{OPERATOR_IDENTITIES.map((identity) => (
<div key={identity.slug} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div key={identity.slug} className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap gap-2">
<EntityBadge label={identity.label} tone="info" />
<EntityBadge label={identity.slug} />
<EntityBadge label={`vmid ${identity.vmid}`} tone="warning" />
</div>
<code className="mt-3 block break-all text-xs text-gray-700 dark:text-gray-300">{identity.address}</code>
<code className="mt-2 block break-all text-xs text-gray-700 dark:text-gray-300">{identity.address}</code>
</div>
))}
</div>
</Card>
</DisclosureSection>
<Card title={user ? `Signed in as ${user.username}` : 'Create or Access Account'}>
{user ? (
@@ -607,138 +638,6 @@ export default function AccessManagementPage() {
) : null}
</Card>
) : null}
{user?.is_admin ? (
<Card title="Pending Access Review">
<div className="mb-4 flex flex-wrap items-end gap-3">
<label className="block min-w-[12rem]">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription status</span>
<select
value={adminSubscriptionStatus}
onChange={(event) => setAdminSubscriptionStatus(event.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="revoked">Revoked</option>
<option value="">All statuses</option>
</select>
</label>
</div>
{adminSubscriptions.length > 0 ? (
<div className="space-y-4">
{adminSubscriptions.map((subscription) => (
<div key={subscription.id} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="font-semibold text-gray-900 dark:text-white">{subscription.productSlug}</div>
<div className="mt-2 flex flex-wrap gap-2">
<EntityBadge label={subscription.status} tone={subscription.status === 'active' ? 'success' : 'warning'} />
<EntityBadge label={subscription.tier} />
<EntityBadge label={`${subscription.monthlyQuota.toLocaleString()} quota`} tone="info" />
{subscription.requiresApproval ? <EntityBadge label="restricted product" tone="warning" /> : null}
</div>
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
Requested {new Date(subscription.createdAt).toLocaleString()}
{subscription.notes ? ` · ${subscription.notes}` : ''}
</div>
<label className="mt-3 block">
<span className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">Admin note</span>
<input
type="text"
value={adminActionNotes[subscription.id] || ''}
onChange={(event) =>
setAdminActionNotes((current) => ({
...current,
[subscription.id]: event.target.value,
}))
}
placeholder="Reason, approval scope, or operator note"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
</label>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void handleAdminSubscriptionAction(subscription.id, 'active')}
className="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700"
>
Approve
</button>
<button
type="button"
onClick={() => void handleAdminSubscriptionAction(subscription.id, 'suspended')}
className="rounded-lg border border-amber-300 px-3 py-2 text-sm font-medium text-amber-800 hover:bg-amber-50 dark:border-amber-800 dark:text-amber-300 dark:hover:bg-amber-950/20"
>
Suspend
</button>
<button
type="button"
onClick={() => void handleAdminSubscriptionAction(subscription.id, 'revoked')}
className="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950/20"
>
Revoke
</button>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">No subscriptions match the current review filter.</p>
)}
</Card>
) : null}
{user?.is_admin ? (
<Card title="Platform Audit Feed">
<div className="mb-4 grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Filter by product</span>
<select value={adminAuditProduct} onChange={(event) => void handleAdminAuditProductChange(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
<option value="">All products</option>
{products.map((product) => (
<option key={`audit-${product.slug}`} value={product.slug}>{product.name}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Entries shown</span>
<select
value={adminAuditLimit}
onChange={(event) => setAdminAuditLimit(event.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
</div>
{adminAuditEntries.length > 0 ? (
<div className="space-y-3">
{adminAuditEntries.map((entry) => (
<div key={`admin-audit-${entry.id}`} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={entry.productSlug || 'unscoped'} tone="info" />
<EntityBadge label={entry.methodName || 'unknown method'} />
<EntityBadge label={`${entry.requestCount} req`} />
</div>
<div className="mt-2 font-medium text-gray-900 dark:text-white">{entry.keyName || entry.apiKeyId}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{new Date(entry.createdAt).toLocaleString()}
{entry.lastIp ? ` · ${entry.lastIp}` : ''}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">No recent validated RPC traffic matches the current filter.</p>
)}
</Card>
) : null}
</div>
<Card title="Issued API Keys">
@@ -758,11 +657,11 @@ export default function AccessManagementPage() {
<EntityBadge label={`${key.requestsUsed}/${key.monthlyQuota || 0}`} />
{key.approved ? <EntityBadge label="approved" tone="success" /> : <EntityBadge label="pending" tone="warning" />}
{key.revoked ? <EntityBadge label="revoked" tone="warning" /> : <EntityBadge label="active" tone="success" />}
{key.expiresAt ? <EntityBadge label={`expires ${new Date(key.expiresAt).toLocaleDateString()}`} /> : <EntityBadge label="no expiry" />}
{key.expiresAt ? <EntityBadge label={`expires ${formatDate(key.expiresAt)}`} /> : <EntityBadge label="no expiry" />}
</div>
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
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'}
</div>
</div>
{!key.revoked ? (
@@ -816,7 +715,7 @@ export default function AccessManagementPage() {
<EntityBadge label={`${item.active_keys} active keys`} />
</div>
<div className="mt-2 text-gray-600 dark:text-gray-400">
{item.requests_used.toLocaleString()} requests used / {item.monthly_quota.toLocaleString()} monthly quota
{formatInteger(item.requests_used)} requests used / {formatInteger(item.monthly_quota)} monthly quota
</div>
</div>
))}
@@ -851,7 +750,7 @@ export default function AccessManagementPage() {
</div>
<div className="mt-2 font-medium text-gray-900 dark:text-white">{entry.keyName || entry.apiKeyId}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{new Date(entry.createdAt).toLocaleString()}
{formatTimestamp(entry.createdAt)}
{entry.lastIp ? ` · ${entry.lastIp}` : ''}
</div>
</div>
@@ -868,6 +767,114 @@ export default function AccessManagementPage() {
</div>
</Card>
</div>
</TabPanel>
{user?.is_admin ? (
<TabPanel idPrefix="access" tabId="admin" activeTab={accessTab}>
<div className="space-y-4">
<Card title="Pending access review">
<div className="mb-4 flex flex-wrap items-end gap-3">
<label className="block min-w-[12rem]">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription status</span>
<select
value={adminSubscriptionStatus}
onChange={(event) => setAdminSubscriptionStatus(event.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="revoked">Revoked</option>
<option value="">All statuses</option>
</select>
</label>
</div>
{adminSubscriptions.length > 0 ? (
<div className="space-y-3">
{adminSubscriptions.map((subscription) => (
<div key={subscription.id} className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="font-semibold text-gray-900 dark:text-white">{subscription.productSlug}</div>
<div className="mt-2 flex flex-wrap gap-2">
<EntityBadge label={subscription.status} tone={subscription.status === 'active' ? 'success' : 'warning'} />
<EntityBadge label={subscription.tier} />
<EntityBadge label={`${formatInteger(subscription.monthlyQuota)} quota`} tone="info" />
{subscription.requiresApproval ? <EntityBadge label="restricted product" tone="warning" /> : null}
</div>
<label className="mt-3 block">
<span className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">Admin note</span>
<input
type="text"
value={adminActionNotes[subscription.id] || ''}
onChange={(event) =>
setAdminActionNotes((current) => ({
...current,
[subscription.id]: event.target.value,
}))
}
placeholder="Reason, approval scope, or operator note"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
</label>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => void handleAdminSubscriptionAction(subscription.id, 'active')} className="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700">Approve</button>
<button type="button" onClick={() => void handleAdminSubscriptionAction(subscription.id, 'suspended')} className="rounded-lg border border-amber-300 px-3 py-2 text-sm font-medium text-amber-800 hover:bg-amber-50 dark:border-amber-800 dark:text-amber-300">Suspend</button>
<button type="button" onClick={() => void handleAdminSubscriptionAction(subscription.id, 'revoked')} className="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300">Revoke</button>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">No subscriptions match the current review filter.</p>
)}
</Card>
<Card title="Platform audit feed">
<div className="mb-4 grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Filter by product</span>
<select value={adminAuditProduct} onChange={(event) => void handleAdminAuditProductChange(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
<option value="">All products</option>
{products.map((product) => (
<option key={`audit-${product.slug}`} value={product.slug}>{product.name}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Entries shown</span>
<select value={adminAuditLimit} onChange={(event) => setAdminAuditLimit(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
</div>
{adminAuditEntries.length > 0 ? (
<div className="space-y-3">
{adminAuditEntries.map((entry) => (
<div key={`admin-audit-${entry.id}`} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={entry.productSlug || 'unscoped'} tone="info" />
<EntityBadge label={entry.methodName || 'unknown method'} />
<EntityBadge label={`${entry.requestCount} req`} />
</div>
<div className="mt-2 font-medium text-gray-900 dark:text-white">{entry.keyName || entry.apiKeyId}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(entry.createdAt)}
{entry.lastIp ? ` · ${entry.lastIp}` : ''}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">No recent validated RPC traffic matches the current filter.</p>
)}
</Card>
</div>
</TabPanel>
) : null}
</main>
)
}

View File

@@ -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 ? (
<>
<ClientRelativeTime value={context.latest_block_timestamp} suffix=" head" /> ·{' '}
<ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" latest tx" />
</>
) : (
'Dual timeline unavailable'
)
return (
<Card className="border border-sky-200 bg-sky-50/60 dark:border-sky-900/40 dark:bg-sky-950/20" title={title}>
@@ -86,11 +92,11 @@ export default function ActivityContextPanel({
<span className="font-semibold text-gray-900 dark:text-white">
{context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'}
</span>{' '}
was {formatRelativeAge(context.latest_block_timestamp)}. Latest visible transaction was{' '}
was <ClientRelativeTime value={context.latest_block_timestamp} suffix=" ago" />. Latest visible transaction was{' '}
<span className="font-semibold text-gray-900 dark:text-white">
{context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'}
</span>{' '}
{formatRelativeAge(context.latest_transaction_timestamp)}.{' '}
<ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />.{' '}
{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({
<span className="font-semibold text-gray-900 dark:text-white">
{context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'}
</span>{' '}
was {formatRelativeAge(context.latest_block_timestamp)}. Latest visible transaction was{' '}
was <ClientRelativeTime value={context.latest_block_timestamp} suffix=" ago" />. Latest visible transaction was{' '}
<span className="font-semibold text-gray-900 dark:text-white">
{context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'}
</span>{' '}
{formatRelativeAge(context.latest_transaction_timestamp)}.
<ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />.
</div>
</div>
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
@@ -135,7 +141,7 @@ export default function ActivityContextPanel({
) : null}
{context.block_gap_to_latest_transaction != null ? (
<span>
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)}
</span>
) : null}
{context.latest_transaction_timestamp ? (

View File

@@ -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 (
<span className={className} suppressHydrationWarning>
{prefix}
{content}
{suffix}
</span>
)
}
return (
<span suppressHydrationWarning>
{prefix}
{content}
{suffix}
</span>
)
}

View File

@@ -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 (
<div
className={`flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300 ${className}`}
>
{metrics.map((metric) => (
<span key={metric.label}>
<span className="text-gray-500 dark:text-gray-400">{metric.label}:</span>{' '}
<span className="font-medium text-gray-900 dark:text-white">{metric.value}</span>
</span>
))}
</div>
)
}

View File

@@ -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 (
<section className={className}>
{forceOpen ? (
<div className={`mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 ${headingClassName}`}>
{title}
</div>
) : (
<button
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setOpen((current) => !current)}
className={`flex w-full items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50/80 px-3 py-2.5 text-left text-sm font-semibold uppercase tracking-wide text-gray-700 transition hover:border-primary-300 dark:border-gray-800 dark:bg-gray-900/40 dark:text-gray-200 dark:hover:border-primary-700 ${alwaysCollapsible ? '' : 'md:hidden'} ${headingClassName}`}
>
<span>{title}</span>
<span aria-hidden="true" className="text-base text-gray-500 dark:text-gray-400">
{isOpen ? '' : '+'}
</span>
</button>
)}
<div
id={panelId}
className={
forceOpen
? ''
: alwaysCollapsible
? `${isOpen ? 'block' : 'hidden'} mt-3`
: `${isOpen ? 'block' : 'hidden'} mt-3 md:mt-0 md:block`
}
>
{!forceOpen && !alwaysCollapsible ? (
<div className="mb-3 hidden text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 md:block">
{title}
</div>
) : null}
{children}
</div>
</section>
)
}

View File

@@ -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 (
<DisclosureSection title={title} defaultOpen={false} alwaysCollapsible className={className}>
<ActivityContextPanel compact context={context} title={activityTitle} />
<FreshnessTrustNote
className="mt-2"
context={context}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel={scopeLabel}
/>
{children}
</DisclosureSection>
)
}

View File

@@ -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 (
<footer className="mt-auto border-t border-gray-200 dark:border-gray-700 bg-white/90 dark:bg-gray-900/90 backdrop-blur">
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="grid gap-4 sm:gap-6 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="text-base font-semibold text-gray-900 dark:text-white sm:text-lg">
DBIS Explorer
</div>
<p className="max-w-xl text-sm leading-6 text-gray-600 dark:text-gray-400">
Built on Blockscout for the DBIS Chain 138 explorer surface.
Explorer data is powered by Blockscout, Chain 138 RPC, and the companion MetaMask Snap.
</p>
<p className="max-w-xl text-xs leading-5 text-gray-500 dark:text-gray-500">
Primary public explorer access is served at <code>explorer.d-bis.org</code>.
<code> blockscout.defi-oracle.io</code> is the Blockscout companion domain for the same Chain 138 explorer surface.
</p>
<p className="text-xs text-gray-500 dark:text-gray-500">
© {year} DBIS. All rights reserved.
<footer className="mt-auto border-t border-gray-200 bg-white/90 backdrop-blur dark:border-gray-700 dark:bg-gray-900/90">
<div className="container mx-auto px-4 py-4 sm:py-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 shrink-0 lg:max-w-xs">
<div className="text-sm font-semibold text-gray-900 dark:text-white">DBIS Explorer</div>
<p className="mt-1 text-xs leading-5 text-gray-600 dark:text-gray-400">
Chain 138 explorer on Blockscout. Primary domain: <code>explorer.d-bis.org</code>.
</p>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-500">© {year} DBIS</p>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Resources
</div>
<ul className="space-y-2 text-sm">
<li><Link className={footerLinkClass} href="/search">Search</Link></li>
<li><Link className={footerLinkClass} href="/docs">Documentation</Link></li>
<li><Link className={footerLinkClass} href="/blocks">Blocks</Link></li>
<li><Link className={footerLinkClass} href="/transactions">Transactions</Link></li>
<li><Link className={footerLinkClass} href="/tokens">Tokens</Link></li>
<li><Link className={footerLinkClass} href="/addresses">Addresses</Link></li>
<li><Link className={footerLinkClass} href="/watchlist">Watchlist</Link></li>
<li><Link className={footerLinkClass} href="/access">Account access</Link></li>
<li><Link className={footerLinkClass} href="/wallet">Wallet tools</Link></li>
<li><Link className={footerLinkClass} href="/operations">Operations hub</Link></li>
<li><Link className={footerLinkClass} href="/bridge">Bridge</Link></li>
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
<li><Link className={footerLinkClass} href="/liquidity">Liquidity</Link></li>
<li><Link className={footerLinkClass} href="/pools">Pools</Link></li>
<li><Link className={footerLinkClass} href="/protocols">Protocols</Link></li>
<li><Link className={footerLinkClass} href="/analytics">Analytics</Link></li>
<li><Link className={footerLinkClass} href="/operator">Operator</Link></li>
<li><Link className={footerLinkClass} href="/system">System</Link></li>
<li><Link className={footerLinkClass} href="/weth">WETH</Link></li>
<li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li>
<li><a className={footerLinkClass} href="/terms.html">Terms of Service</a></li>
<li><a className={footerLinkClass} href="/acknowledgments.html">Acknowledgments</a></li>
</ul>
<div className="hidden min-w-0 flex-1 lg:block">
<FooterLinkGroups />
</div>
</div>
<DisclosureSection
title="Site navigation"
alwaysCollapsible
className="mt-3 lg:hidden"
>
<FooterLinkGroups />
</DisclosureSection>
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Public APIs
</div>
<FooterPublicApiLinks />
<p className="mt-3 text-xs leading-5 text-gray-500 dark:text-gray-500">
Read-only JSON endpoints on the public explorer domain. No API key required.
</p>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
<DisclosureSection title="Public APIs" alwaysCollapsible className="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
<FooterPublicApiLinks compact />
</DisclosureSection>
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Contact
</div>
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<DisclosureSection title="Contact" alwaysCollapsible className="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
<div className="space-y-1.5 text-sm text-gray-600 dark:text-gray-400">
<p>
Support:{' '}
<a className={footerLinkClass} href="mailto:support@d-bis.org">
support@d-bis.org
</a>
</p>
<p>
Snap site:{' '}
<Link className={footerLinkClass} href="/topology">
Chain 138 map
</Link>
{' · '}
<a className={footerLinkClass} href="/snap/" target="_blank" rel="noopener noreferrer">
/snap/ on the current explorer domain
Snap site
</a>
</p>
<p>
Command center:{' '}
<Link className={footerLinkClass} href="/topology">
Chain 138 visual map
</Link>
</p>
<p className="text-xs leading-5 text-gray-500 dark:text-gray-500">
Questions about the explorer, chain metadata, route discovery, or liquidity access
can be sent to the support mailbox above.
</p>
</div>
</div>
</DisclosureSection>
</div>
</div>
</footer>

View File

@@ -0,0 +1,46 @@
'use client'
import Link from 'next/link'
import { footerNavGroups, type FooterNavLink } from '@/data/footerNav'
const footerLinkClass =
'text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors'
function FooterNavLinkItem({ link }: { link: FooterNavLink }) {
if (link.external) {
return (
<li>
<a className={footerLinkClass} href={link.href}>
{link.label}
</a>
</li>
)
}
return (
<li>
<Link className={footerLinkClass} href={link.href}>
{link.label}
</Link>
</li>
)
}
export default function FooterLinkGroups() {
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-4">
{footerNavGroups.map((group) => (
<div key={group.id}>
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{group.title}
</div>
<ul className="space-y-1 text-sm">
{group.links.map((link) => (
<FooterNavLinkItem key={`${group.id}-${link.href}`} link={link} />
))}
</ul>
</div>
))}
</div>
)
}

View File

@@ -12,7 +12,7 @@ function absoluteApiUrl(href: string): string {
return `${window.location.origin}${href.startsWith('/') ? href : `/${href}`}`
}
export default function FooterPublicApiLinks() {
export default function FooterPublicApiLinks({ compact = false }: { compact?: boolean }) {
const [copiedHref, setCopiedHref] = useState<string | null>(null)
const copyUrl = async (href: string) => {
@@ -27,15 +27,23 @@ export default function FooterPublicApiLinks() {
}
return (
<ul className="space-y-3 text-sm">
<ul className={compact ? 'space-y-1.5 text-sm' : 'space-y-3 text-sm'}>
{explorerPublicApiLinks.map((link) => (
<li key={link.href}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<a className={footerLinkClass} href={link.href} target="_blank" rel="noopener noreferrer">
<a
className={footerLinkClass}
href={link.href}
target="_blank"
rel="noopener noreferrer"
title={compact ? link.description : undefined}
>
{link.label}
</a>
<p className="mt-0.5 text-xs leading-5 text-gray-500 dark:text-gray-500">{link.description}</p>
{!compact ? (
<p className="mt-0.5 text-xs leading-5 text-gray-500 dark:text-gray-500">{link.description}</p>
) : null}
</div>
<button
type="button"

View File

@@ -0,0 +1,68 @@
import type { ChainActivityContext } from '@/utils/activityContext'
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
import { formatInteger } from '@/utils/format'
export default function FreshnessDetail({
context,
diagnosticExplanation,
activityState,
}: {
context: ChainActivityContext
diagnosticExplanation?: string | null
activityState?: string | null
}) {
if (diagnosticExplanation) {
return <>{diagnosticExplanation}</>
}
if (activityState === 'quiet_chain') {
const latestNonEmptyBlock =
context.last_non_empty_block_number != null ? `#${formatInteger(context.last_non_empty_block_number)}` : 'unknown'
const blockGap =
context.block_gap_to_latest_transaction != null
? `${formatInteger(context.block_gap_to_latest_transaction)} blocks`
: 'unknown'
return (
<>
Quiet-chain signal: head blocks may be empty while the chain remains current. Block gap to latest visible
transaction: {blockGap}. Last non-empty block: {latestNonEmptyBlock}.
</>
)
}
if (context.transaction_visibility_unavailable) {
return <>Use chain-head visibility and the last non-empty block as the current trust anchors.</>
}
const latestNonEmptyBlock =
context.last_non_empty_block_number != null ? `#${formatInteger(context.last_non_empty_block_number)}` : 'unknown'
const blockGap =
context.block_gap_to_latest_transaction != null
? `${formatInteger(context.block_gap_to_latest_transaction)} blocks`
: null
if (context.head_is_idle) {
return (
<>
Latest visible transaction: <ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />. Block
gap: {blockGap || 'unknown'}. Last non-empty block: {latestNonEmptyBlock}.
</>
)
}
if (context.state === 'active') {
return (
<>
Latest visible transaction: <ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />.
Recent indexed activity remains close to the tip.
</>
)
}
return (
<>
Latest visible transaction: <ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />. Recent
head blocks may be quiet even while the chain remains current.
</>
)
}

View File

@@ -5,7 +5,7 @@ import {
resolveFreshnessSourceLabel,
summarizeFreshnessConfidence,
} from '@/utils/explorerFreshness'
import { formatRelativeAge } from '@/utils/format'
import FreshnessDetail from './FreshnessDetail'
import { useUiMode } from './UiModeContext'
function buildSummary(context: ChainActivityContext, activityState?: string | null) {
@@ -32,44 +32,6 @@ function buildSummary(context: ChainActivityContext, activityState?: string | nu
return 'Freshness context is based on the latest visible public explorer evidence.'
}
function buildDetail(context: ChainActivityContext, diagnosticExplanation?: string | null, activityState?: string | null) {
if (diagnosticExplanation) {
return diagnosticExplanation
}
if (activityState === 'quiet_chain') {
const latestNonEmptyBlock =
context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown'
const blockGap =
context.block_gap_to_latest_transaction != null
? `${context.block_gap_to_latest_transaction.toLocaleString()} blocks`
: 'unknown'
return `Quiet-chain signal: head blocks may be empty while the chain remains current. Block gap to latest visible transaction: ${blockGap}. Last non-empty block: ${latestNonEmptyBlock}.`
}
if (context.transaction_visibility_unavailable) {
return 'Use chain-head visibility and the last non-empty block as the current trust anchors.'
}
const latestTxAge = formatRelativeAge(context.latest_transaction_timestamp)
const latestNonEmptyBlock =
context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown'
const blockGap =
context.block_gap_to_latest_transaction != null
? `${context.block_gap_to_latest_transaction.toLocaleString()} blocks`
: null
if (context.head_is_idle) {
return `Latest visible transaction: ${latestTxAge}. Block gap: ${blockGap || 'unknown'}. Last non-empty block: ${latestNonEmptyBlock}.`
}
if (context.state === 'active') {
return `Latest visible transaction: ${latestTxAge}. Recent indexed activity remains close to the tip.`
}
return `Latest visible transaction: ${latestTxAge}. Recent head blocks may be quiet even while the chain remains current.`
}
function normalizeSentence(value?: string | null): string {
if (!value) return ''
return value.trim().replace(/[.\s]+$/, '')
@@ -120,8 +82,12 @@ export default function FreshnessTrustNote({
<div className={`rounded-2xl border border-gray-200 bg-white/80 px-4 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/40${normalizedClassName}`}>
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context, activityState)}</div>
<div className="mt-1 text-gray-600 dark:text-gray-400">
{normalizeSentence(buildDetail(context, diagnosticExplanation, activityState))}.{' '}
{scopeLabel ? `${normalizeSentence(scopeLabel)}. ` : ''}
<FreshnessDetail
context={context}
diagnosticExplanation={diagnosticExplanation}
activityState={activityState}
/>
. {scopeLabel ? `${normalizeSentence(scopeLabel)}. ` : ''}
{normalizeSentence(sourceLabel)}.
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">

View File

@@ -0,0 +1,45 @@
'use client'
import { formatInteger } from '@/utils/format'
interface ListFilterBarProps {
query: string
onQueryChange: (value: string) => void
placeholder?: string
resultCount?: number
totalCount?: number
className?: string
}
export default function ListFilterBar({
query,
onQueryChange,
placeholder = 'Filter…',
resultCount,
totalCount,
className = '',
}: ListFilterBarProps) {
const showCounts = typeof resultCount === 'number' && typeof totalCount === 'number'
return (
<div className={`flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between ${className}`}>
<label className="block min-w-0 flex-1">
<span className="sr-only">{placeholder}</span>
<input
type="search"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={placeholder}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder:text-gray-500"
/>
</label>
{showCounts ? (
<div className="shrink-0 text-sm text-gray-600 dark:text-gray-400">
{resultCount === totalCount
? `${formatInteger(totalCount)} items`
: `${formatInteger(resultCount)} of ${formatInteger(totalCount)}`}
</div>
) : null}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { formatRelativeAge, formatTimestamp } from '@/utils/format'
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
import { formatTimestamp } from '@/utils/format'
function formatSource(source?: string | null): string {
switch (source) {
@@ -26,14 +27,33 @@ export default function MarketEvidenceNote({
method?: string
compact?: boolean
}) {
const freshness = lastUpdated ? `${formatRelativeAge(lastUpdated)} (${formatTimestamp(lastUpdated)})` : 'timestamp unavailable'
const text = compact
? `Updated ${freshness} · ${formatSource(source)}`
: `Source: ${formatSource(source)}. Updated: ${freshness}. Method: ${method}`
return (
<p className={`${compact ? 'mt-1' : 'mt-3'} text-xs leading-5 text-gray-500 dark:text-gray-400`}>
{text}
{compact ? (
<>
Updated{' '}
{lastUpdated ? (
<>
<ClientRelativeTime value={lastUpdated} suffix={` (${formatTimestamp(lastUpdated)})`} />
</>
) : (
'timestamp unavailable'
)}{' '}
· {formatSource(source)}
</>
) : (
<>
Source: {formatSource(source)}. Updated:{' '}
{lastUpdated ? (
<>
<ClientRelativeTime value={lastUpdated} suffix={` (${formatTimestamp(lastUpdated)})`} />
</>
) : (
'timestamp unavailable'
)}
. Method: {method}
</>
)}
</p>
)
}

View File

@@ -2,8 +2,8 @@
import { Card } from '@/libs/frontend-ui-primitives'
import EntityBadge from '@/components/common/EntityBadge'
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
import type { MissionControlMode } from '@/services/api/missionControl'
import { formatRelativeAge } from '@/utils/format'
const REASON_LABELS: Record<string, string> = {
live_homepage_stream_not_attached: 'Live homepage stream is not attached; relay posture uses snapshot polling.',
@@ -57,7 +57,9 @@ export default function MissionDeliveryModePanel({
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={`mode ${mode.kind}`} tone={modeTone(mode.kind)} />
{mode.updated_at ? (
<span className="text-xs text-gray-600 dark:text-gray-400">Updated {formatRelativeAge(mode.updated_at)}</span>
<span className="text-xs text-gray-600 dark:text-gray-400">
Updated <ClientRelativeTime value={mode.updated_at} suffix=" ago" />
</span>
) : null}
</div>
{mode.reason ? (

View File

@@ -7,6 +7,7 @@ import { accessApi, institutionalTierLabels, type WalletAccessSession } from '@/
import BrandLockup from './BrandLockup'
import HeaderCommandPalette, { type HeaderCommandItem } from './HeaderCommandPalette'
import { useUiMode } from './UiModeContext'
import { formatTimestamp } from '@/utils/format'
type MenuItem = {
href?: string
@@ -430,7 +431,7 @@ function AccountButton({
const sessionSummary = getSessionSummary(walletSession)
const tierLabel = getAccessTier(walletSession)
const expiresLabel = walletSession.expiresAt
? new Date(walletSession.expiresAt).toLocaleString()
? formatTimestamp(walletSession.expiresAt)
: 'Session expiry unavailable'
return (
@@ -488,6 +489,7 @@ export default function Navbar() {
pathname.startsWith('/addresses')
const isDataActive =
pathname.startsWith('/tokens') ||
pathname.startsWith('/wallet') ||
pathname.startsWith('/analytics') ||
pathname.startsWith('/pools') ||
pathname.startsWith('/protocols') ||
@@ -592,6 +594,7 @@ export default function Navbar() {
const dataItems: MenuItem[] = useMemo(
() => [
{ href: '/tokens', label: 'Tokens', description: 'Review curated assets, standards, and token detail pages.' },
{ href: '/wallet', label: 'Wallet tools', description: 'Add Chain 138 to MetaMask and import tokens via EIP-747.' },
{ href: '/analytics', label: 'Analytics', description: 'Open explorer-visible transaction and block activity summaries.' },
{ href: '/pools', label: 'Pools', description: 'Browse mission-control pool inventory and route-backed liquidity context.' },
{ href: '/protocols', label: 'Protocols', description: 'Review official upstream protocol contracts and production guardrails on Chain 138.' },

View File

@@ -10,39 +10,53 @@ export default function PageIntro({
title,
description,
actions = [],
compact = false,
}: {
eyebrow?: string
title: string
description: string
actions?: PageIntroAction[]
compact?: boolean
}) {
return (
<section className="mb-5 border-b border-gray-200 pb-5 dark:border-gray-800 sm:mb-6 sm:pb-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<section
className={
compact
? 'mb-4 border-b border-gray-200 pb-3 dark:border-gray-800'
: 'mb-5 border-b border-gray-200 pb-5 dark:border-gray-800 sm:mb-6 sm:pb-6'
}
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
{eyebrow ? (
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary-700 dark:text-primary-300">
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary-700 dark:text-primary-300">
{eyebrow}
</div>
) : null}
<h1 className="text-2xl font-semibold tracking-normal text-gray-950 dark:text-white sm:text-3xl">{title}</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-400">
<h1 className={`font-semibold tracking-normal text-gray-950 dark:text-white ${compact ? 'text-xl sm:text-2xl' : 'text-2xl sm:text-3xl'}`}>
{title}
</h1>
<p
className={`mt-1.5 max-w-3xl text-sm text-gray-600 dark:text-gray-400 ${
compact ? 'line-clamp-2 leading-5' : 'leading-6'
}`}
>
{description}
</p>
</div>
{actions.length > 0 ? (
<div className="flex flex-wrap gap-2 lg:justify-end">
{actions.map((action) => (
<Link
key={`${action.href}-${action.label}`}
href={action.href}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:text-primary-300"
>
{action.label}
</Link>
))}
</div>
) : null}
{actions.length > 0 ? (
<div className="flex flex-wrap gap-2 lg:justify-end">
{actions.map((action) => (
<Link
key={`${action.href}-${action.label}`}
href={action.href}
className="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:text-primary-300"
>
{action.label}
</Link>
))}
</div>
) : null}
</div>
</section>
)

View File

@@ -1,4 +1,5 @@
import { useCallback, useId } from 'react'
import { useCallback, useId, type ReactNode } from 'react'
import { formatInteger } from '@/utils/format'
export interface SectionTab<T extends string> {
id: T
@@ -28,6 +29,33 @@ export function sectionTabPanelProps<T extends string>(
}
}
interface TabPanelProps<T extends string> {
idPrefix: string
tabId: T
activeTab: T
children: ReactNode
className?: string
}
/** Renders tab content only while active to keep pages short and avoid hidden-panel scroll. */
export function TabPanel<T extends string>({
idPrefix,
tabId,
activeTab,
children,
className = '',
}: TabPanelProps<T>) {
if (activeTab !== tabId) {
return null
}
return (
<div {...sectionTabPanelProps(idPrefix, tabId, activeTab)} className={className}>
{children}
</div>
)
}
export default function SectionTabs<T extends string>({
tabs,
activeTab,
@@ -78,7 +106,7 @@ export default function SectionTabs<T extends string>({
return (
<div
className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}
className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-2 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}
>
<div
role="tablist"
@@ -88,6 +116,10 @@ export default function SectionTabs<T extends string>({
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id
const accessibleLabel =
typeof tab.count === 'number'
? `${tab.label} (${formatInteger(tab.count)})`
: tab.label
return (
<button
@@ -97,6 +129,7 @@ export default function SectionTabs<T extends string>({
role="tab"
aria-selected={isActive}
aria-controls={`${tabListPrefix}-panel-${tab.id}`}
aria-label={accessibleLabel}
tabIndex={isActive ? 0 : -1}
onClick={() => onChange(tab.id)}
className={
@@ -107,8 +140,11 @@ export default function SectionTabs<T extends string>({
>
{tab.label}
{typeof tab.count === 'number' ? (
<span className={isActive ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
{tab.count.toLocaleString()}
<span
aria-hidden="true"
className={isActive ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}
>
{formatInteger(tab.count)}
</span>
) : null}
</button>

View File

@@ -1,7 +1,7 @@
import { Card } from '@/libs/frontend-ui-primitives'
import { useUiMode } from './UiModeContext'
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
import type { MissionControlSubsystemStatus } from '@/services/api/missionControl'
import { formatRelativeAge } from '@/utils/format'
function subsystemLabel(key: string): string {
const labels: Record<string, string> = {
@@ -107,7 +107,13 @@ export default function SubsystemPosturePanel({
</span>
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{subsystem.updated_at ? `Updated ${formatRelativeAge(subsystem.updated_at)}` : 'Update time unavailable'}
{subsystem.updated_at ? (
<>
Updated <ClientRelativeTime value={subsystem.updated_at} suffix=" ago" />
</>
) : (
'Update time unavailable'
)}
</div>
<div className="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
{compactMeta(subsystem)}

View File

@@ -17,16 +17,18 @@ import {
import { transactionsApi, type Transaction } from '@/services/api/transactions'
import { formatWeiAsEth } from '@/utils/format'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure'
import CompactMetricBar from '@/components/common/CompactMetricBar'
import DisclosureSection from '@/components/common/DisclosureSection'
import SectionTabs, { TabPanel } from '@/components/common/SectionTabs'
import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
import OperationsPageShell, {
MetricCard,
StatusBadge,
formatNumber,
relativeAge,
truncateMiddle,
} from './OperationsPageShell'
@@ -61,6 +63,7 @@ export default function AnalyticsOperationsPage({
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [loadingError, setLoadingError] = useState<string | null>(null)
const [analyticsTab, setAnalyticsTab] = useState<'trend' | 'blocks' | 'transactions'>('trend')
const page = explorerFeaturePages.analytics
useEffect(() => {
@@ -148,58 +151,36 @@ export default function AnalyticsOperationsPage({
</Card>
) : null}
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Analytics Freshness Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel="This page combines public stats, recent block samples, and indexed transactions."
/>
<ExplorerFreshnessDisclosure
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
title="Freshness & delivery"
activityTitle="Analytics recency"
scopeLabel="Stats, blocks, and indexed transactions on this page."
>
<MissionDeliveryModePanel className="mt-3" mode={bridgeStatus?.data?.mode} title="Analytics delivery mode" />
<SubsystemPosturePanel
className="mt-3"
subsystems={bridgeStatus?.data?.subsystems}
title="Analytics Subsystem Posture"
title="Analytics subsystem posture"
preferredKeys={['rpc_head', 'tx_index', 'stats_summary', 'freshness_queries']}
scopeLabel="These subsystem signals explain whether sparse analytics reflect quiet-chain conditions, partial transaction indexing, or stale summary generation."
scopeLabel="Subsystem signals for sparse analytics."
/>
</div>
</ExplorerFreshnessDisclosure>
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard
title="Total Blocks"
value={formatNumber(stats?.total_blocks)}
description="Current block count from the public Blockscout stats endpoint."
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
/>
<MetricCard
title="Transactions"
value={formatNumber(stats?.total_transactions)}
description="Total transactions currently indexed by the public explorer."
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
/>
<MetricCard
title="Addresses"
value={formatNumber(stats?.total_addresses)}
description="Known addresses from the public stats surface."
/>
<MetricCard
title="Chain Head"
value={chainStatus?.head_age_sec != null ? `${Math.round(chainStatus.head_age_sec)}s` : 'Unknown'}
description={
chainStatus?.latency_ms != null
? `RPC latency ${Math.round(chainStatus.latency_ms)}ms on Chain 138.`
: 'Latest public RPC head age from mission control.'
}
/>
<MetricCard
title="7d Avg Tx"
value={formatNumber(Math.round(sevenDayAverage))}
description="Average daily transactions over the latest seven charted days."
className="border border-violet-200 bg-violet-50/70 dark:border-violet-900/50 dark:bg-violet-950/20"
/>
<CompactMetricBar
metrics={[
{ label: 'Blocks', value: formatNumber(stats?.total_blocks) },
{ label: 'Tx', value: formatNumber(stats?.total_transactions) },
{ label: 'Addresses', value: formatNumber(stats?.total_addresses) },
{ label: 'Head', value: chainStatus?.head_age_sec != null ? `${Math.round(chainStatus.head_age_sec)}s` : '—' },
{ label: '7d avg', value: formatNumber(Math.round(sevenDayAverage)) },
]}
/>
<DisclosureSection title="Extended metrics" defaultOpen={false} alwaysCollapsible className="mb-6">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<MetricCard
title="Recent Success Rate"
value={activitySnapshot ? `${Math.round(activitySnapshot.success_rate * 100)}%` : 'Unknown'}
@@ -222,62 +203,66 @@ export default function AnalyticsOperationsPage({
description="Average gas utilization across the latest visible blocks."
className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20"
/>
</div>
</div>
</DisclosureSection>
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
<SectionTabs
idPrefix="analytics"
ariaLabel="Analytics views"
tabs={[
{ id: 'trend', label: 'Trend' },
{ id: 'blocks', label: 'Blocks', count: blocks.length },
{ id: 'transactions', label: 'Transactions', count: transactions.length },
]}
activeTab={analyticsTab}
onChange={setAnalyticsTab}
className="mb-4"
/>
<TabPanel idPrefix="analytics" tabId="trend" activeTab={analyticsTab}>
<Card title="Activity Trend">
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Peak Day</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{topDay ? formatNumber(topDay.transaction_count) : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{topDay?.date || 'No trend data yet'}</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Contract Creations</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{formatNumber(activitySnapshot?.contract_creations)}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Within the sampled recent transaction feed.</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Avg Sample Fee</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{activitySnapshot ? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6) : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Average fee from the recent public transaction sample.</div>
</div>
</div>
<CompactMetricBar
metrics={[
{ label: 'Peak day', value: topDay ? formatNumber(topDay.transaction_count) : '—' },
{ label: 'Creations', value: formatNumber(activitySnapshot?.contract_creations) },
{
label: 'Avg fee',
value: activitySnapshot
? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6)
: '—',
},
]}
/>
<DisclosureSection title="Sample breakdown" defaultOpen={false} alwaysCollapsible>
{activitySnapshot ? (
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token Transfer Share</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token transfers</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{Math.round(activitySnapshot.token_transfer_share * 100)}%
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions involving token transfers.</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Contract Call Share</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Contract calls</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{Math.round(activitySnapshot.contract_call_share * 100)}%
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions calling contracts.</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Creation Share</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Creations</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{Math.round(activitySnapshot.contract_creation_share * 100)}%
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions deploying contracts.</div>
</div>
</div>
) : null}
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">No sample breakdown available.</p>
)}
</DisclosureSection>
<div className="space-y-3">
<div className="space-y-2">
{trailingWindow.map((point) => {
const width = trendPeak > 0 ? Math.max(8, Math.round((point.transaction_count / trendPeak) * 100)) : 0
return (
@@ -298,9 +283,11 @@ export default function AnalyticsOperationsPage({
</div>
</div>
</Card>
</TabPanel>
<TabPanel idPrefix="analytics" tabId="blocks" activeTab={analyticsTab}>
<Card title="Recent Blocks">
<div className="space-y-4">
<div className="space-y-3">
{shouldExplainEmptyHeadBlocks(blocks, activityContext) ? (
<p className="rounded-xl border border-amber-200 bg-amber-50/70 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
@@ -309,22 +296,22 @@ export default function AnalyticsOperationsPage({
{blocks.map((block) => (
<div
key={block.hash}
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link href={`/blocks/${block.number}`} className="text-base font-semibold text-primary-600 hover:underline">
<Link href={`/blocks/${block.number}`} className="text-sm font-semibold text-primary-600 hover:underline">
Block {formatNumber(block.number)}
</Link>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{truncateMiddle(block.hash)} · miner{' '}
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{truncateMiddle(block.hash)} ·{' '}
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
{truncateMiddle(block.miner)}
</Link>
</div>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{formatNumber(block.transaction_count)} tx · {Math.round((block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0) * 100)}% gas · {relativeAge(block.timestamp)}
<div className="text-xs text-gray-600 dark:text-gray-400">
{formatNumber(block.transaction_count)} tx · {Math.round((block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0) * 100)}% gas · <ClientRelativeTime value={block.timestamp} suffix=" ago" />
</div>
</div>
</div>
@@ -334,20 +321,22 @@ export default function AnalyticsOperationsPage({
) : null}
</div>
</Card>
</TabPanel>
<TabPanel idPrefix="analytics" tabId="transactions" activeTab={analyticsTab}>
<Card title="Recent Transactions">
<div className="space-y-4">
<div className="space-y-3">
{transactions.map((transaction) => (
<div
key={transaction.hash}
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<Link href={`/transactions/${transaction.hash}`} className="text-base font-semibold text-primary-600 hover:underline">
<Link href={`/transactions/${transaction.hash}`} className="text-sm font-semibold text-primary-600 hover:underline">
{truncateMiddle(transaction.hash, 12, 10)}
</Link>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
Block{' '}
<Link href={`/blocks/${transaction.block_number}`} className="text-primary-600 hover:underline">
{formatNumber(transaction.block_number)}
@@ -366,23 +355,25 @@ export default function AnalyticsOperationsPage({
) : null}
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<StatusBadge
status={transaction.status === 1 ? 'success' : 'failed'}
tone={transaction.status === 1 ? 'normal' : 'danger'}
/>
<div className="text-sm text-gray-600 dark:text-gray-400">
{relativeAge(transaction.created_at)}
<div className="text-xs text-gray-600 dark:text-gray-400">
<ClientRelativeTime value={transaction.created_at} suffix=" ago" />
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
{transaction.method ? <StatusBadge status={transaction.method} tone="warning" /> : null}
{transaction.contract_address ? <StatusBadge status="contract creation" tone="warning" /> : null}
{transaction.token_transfers && transaction.token_transfers.length > 0 ? (
<StatusBadge status={`${transaction.token_transfers.length} token transfer${transaction.token_transfers.length === 1 ? '' : 's'}`} />
) : null}
</div>
{(transaction.method || transaction.contract_address || (transaction.token_transfers && transaction.token_transfers.length > 0)) ? (
<div className="mt-2 flex flex-wrap gap-1.5 text-xs">
{transaction.method ? <StatusBadge status={transaction.method} tone="warning" /> : null}
{transaction.contract_address ? <StatusBadge status="contract creation" tone="warning" /> : null}
{transaction.token_transfers && transaction.token_transfers.length > 0 ? (
<StatusBadge status={`${transaction.token_transfers.length} token transfer${transaction.token_transfers.length === 1 ? '' : 's'}`} />
) : null}
</div>
) : null}
</div>
))}
{transactions.length === 0 ? (
@@ -390,7 +381,7 @@ export default function AnalyticsOperationsPage({
) : null}
</div>
</Card>
</div>
</TabPanel>
</OperationsPageShell>
)
}

View File

@@ -12,8 +12,10 @@ import {
import { statsApi, type ExplorerStats } from '@/services/api/stats'
import { explorerFeaturePages } from '@/data/explorerOperations'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure'
import CompactMetricBar from '@/components/common/CompactMetricBar'
import DisclosureSection from '@/components/common/DisclosureSection'
import PaginationControls from '@/components/common/PaginationControls'
import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
@@ -23,6 +25,7 @@ import { HOME_DASHBOARD_REFRESH_MS } from '@/utils/featuredTokens'
import BridgeLaneHealthPanel from '@/components/explorer/BridgeLaneHealthPanel'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import OperationsActionGrid from './OperationsActionGrid'
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
type FeedState = 'connecting' | 'live' | 'fallback'
@@ -36,7 +39,7 @@ interface RelayLaneCard {
queueSize: number
processed: number
failed: number
lastPolled: string
lastPolledAt?: string | null
bridgeAddress: string
issueScope: string | null
issueMessage: string | null
@@ -46,18 +49,7 @@ interface RelayLaneCard {
}
const relayOrder = ['mainnet_cw', 'mainnet_weth', 'bsc', 'avax', 'avax_cw', 'avax_to_138']
function relativeAge(isoString?: string): string {
if (!isoString) return 'Unknown'
const parsed = Date.parse(isoString)
if (!Number.isFinite(parsed)) return 'Unknown'
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`
}
const BRIDGE_RELAY_PAGE_SIZE = 4
function shortAddress(value?: string): string {
if (!value) return 'Unspecified'
@@ -155,6 +147,7 @@ export default function BridgeMonitoringPage({
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [bridgeRoutes, setBridgeRoutes] = useState<BridgeRoutesResponse | null>(null)
const [feedState, setFeedState] = useState<FeedState>(initialBridgeStatus ? 'fallback' : 'connecting')
const [relayPage, setRelayPage] = useState(1)
const page = explorerFeaturePages.bridge
useEffect(() => {
@@ -285,7 +278,7 @@ export default function BridgeMonitoringPage({
queueSize: snapshot?.queue?.size ?? 0,
processed: snapshot?.queue?.processed ?? 0,
failed: snapshot?.queue?.failed ?? 0,
lastPolled: relativeAge(snapshot?.last_source_poll?.at),
lastPolledAt: snapshot?.last_source_poll?.at || null,
bridgeAddress:
snapshot?.destination?.relay_bridge_default ||
snapshot?.destination?.relay_bridge ||
@@ -305,118 +298,91 @@ export default function BridgeMonitoringPage({
})
}, [bridgeStatus])
useEffect(() => {
setRelayPage(1)
}, [relayLanes.length])
const relayPageCount = relayLanes.length ? Math.max(1, Math.ceil(relayLanes.length / BRIDGE_RELAY_PAGE_SIZE)) : 1
const visibleRelayLanes = relayLanes.slice(
(relayPage - 1) * BRIDGE_RELAY_PAGE_SIZE,
relayPage * BRIDGE_RELAY_PAGE_SIZE,
)
const chainStatus = bridgeStatus?.data?.chains?.['138']
const overallStatus = bridgeStatus?.data?.status || 'unknown'
const checkedAt = relativeAge(bridgeStatus?.data?.checked_at)
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-6 max-w-4xl sm:mb-8">
<div className="mb-3 inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">
<div className="container mx-auto px-4 py-4 sm:py-6">
<div className="mb-4 max-w-4xl">
<div className="mb-2 inline-flex rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-700 dark:border-sky-900/50 dark:bg-sky-950/30 dark:text-sky-200">
{page.eyebrow}
</div>
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
<h1 className="mb-2 text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
{page.title}
</h1>
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
{page.description}
</p>
</div>
{page.note ? (
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
{page.note}
</p>
</Card>
<DisclosureSection title="Bridge scope note" defaultOpen={false} alwaysCollapsible className="mb-4">
<Card className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
{page.note}
</p>
</Card>
</DisclosureSection>
) : null}
<OperationsSurfaceNav />
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Bridge Freshness Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel="Bridge relay posture is shown alongside the same explorer freshness model used on the homepage and core explorer routes"
/>
<ExplorerFreshnessDisclosure
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
title="Freshness & delivery"
activityTitle="Bridge recency"
scopeLabel="Bridge relay posture on this page."
>
<MissionDeliveryModePanel className="mt-3" mode={bridgeStatus?.data?.mode} title="Bridge delivery mode" />
<SubsystemPosturePanel
className="mt-3"
subsystems={bridgeStatus?.data?.subsystems}
title="Bridge Subsystem Posture"
title="Bridge subsystem posture"
preferredKeys={['rpc_head', 'tx_index', 'bridge_relay_monitoring', 'stats_summary', 'freshness_queries']}
scopeLabel="These bridge-facing subsystem signals show whether the limiting factor is public head visibility, transaction indexing, relay monitoring, or degraded freshness queries."
scopeLabel="Bridge-facing subsystem signals."
/>
</div>
</ExplorerFreshnessDisclosure>
<div className="mb-6 grid gap-4 lg:grid-cols-3">
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
Relay Fleet
</div>
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
{overallStatus}
</div>
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
{relayLanes.length} managed lanes visible
</div>
<div className="mt-2 text-xs font-medium uppercase tracking-wide text-sky-800/80 dark:text-sky-100/80">
Feed: {feedState === 'live' ? 'Live SSE' : feedState === 'fallback' ? 'Snapshot fallback' : 'Connecting'}
</div>
</Card>
<CompactMetricBar
metrics={[
{ label: 'Fleet', value: overallStatus },
{ label: 'Lanes', value: String(relayLanes.length) },
{ label: 'Chain 138', value: chainStatus?.status || 'unknown' },
{ label: 'Feed', value: feedState === 'live' ? 'live' : feedState === 'fallback' ? 'snapshot' : 'connecting' },
]}
/>
<Card className="border border-gray-200 dark:border-gray-700">
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
Chain 138 RPC
</div>
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
{chainStatus?.status || 'unknown'}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Head age: {chainStatus?.head_age_sec != null ? `${chainStatus.head_age_sec.toFixed(1)}s` : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Latency: {chainStatus?.latency_ms != null ? `${chainStatus.latency_ms}ms` : 'Unknown'}
</div>
</Card>
<Card className="border border-gray-200 dark:border-gray-700">
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
Last Check
</div>
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
{checkedAt}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Public status JSON and live stream are both active.
</div>
<div className="mt-4">
<ActionLink href="/explorer-api/v1/track1/bridge/status" label="Open status JSON" external />
</div>
</Card>
</div>
<div className="mb-8 grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
{relayLanes.map((lane) => (
<DisclosureSection title="Relay lane detail" defaultOpen={false} alwaysCollapsible className="mb-4">
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
{visibleRelayLanes.map((lane) => (
<Card key={lane.key} className={`border ${laneToneClasses(lane.status)}`}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-lg font-semibold text-gray-900 dark:text-white">
<div className="text-base font-semibold text-gray-900 dark:text-white">
{lane.label}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-400">
{`${lane.sourceChain} -> ${lane.destinationChain}`}
</div>
</div>
<div className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide ${statusPillClasses(lane.status)}`}>
<div className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${statusPillClasses(lane.status)}`}>
{lane.status}
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div>
<div className="text-gray-500 dark:text-gray-400">Profile</div>
<div className="font-medium text-gray-900 dark:text-white">{lane.profile}</div>
@@ -436,7 +402,7 @@ export default function BridgeMonitoringPage({
</div>
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
Last polled: {lane.lastPolled}
Last polled: <ClientRelativeTime value={lane.lastPolledAt} suffix=" ago" />
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Bridge: {shortAddress(lane.bridgeAddress)}
@@ -460,23 +426,38 @@ export default function BridgeMonitoringPage({
</Card>
))}
</div>
{relayLanes.length > BRIDGE_RELAY_PAGE_SIZE ? (
<PaginationControls
className="mt-4"
page={relayPage}
pageCount={relayPageCount}
onPageChange={setRelayPage}
label="Relay lanes"
ariaLabel="Bridge relay lane pagination"
/>
) : null}
</DisclosureSection>
<BridgeLaneHealthPanel laneHealth={bridgeStatus?.data?.bridge_lanes} />
<DisclosureSection title="Lane health panel" defaultOpen={false} alwaysCollapsible className="mb-4">
<BridgeLaneHealthPanel laneHealth={bridgeStatus?.data?.bridge_lanes} />
</DisclosureSection>
{routeEntries.length > 0 ? (
<Card title="CCIP route catalog" className="mb-8">
<DisclosureSection title="CCIP route catalog" defaultOpen={false} alwaysCollapsible className="mb-4">
<Card title="CCIP route catalog">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Destination bridge contracts from{' '}
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">
/token-aggregation/api/v1/bridge/routes
</code>
{bridgeRoutes?.source ? (
<>
{' '}
(source: {bridgeRoutes.source}
{bridgeRoutes.lastModified ? ` · updated ${relativeAge(bridgeRoutes.lastModified)}` : ''})
</>
) : null}
{bridgeRoutes?.lastModified ? (
<>
{' '}
(source: {bridgeRoutes.source} · updated <ClientRelativeTime value={bridgeRoutes.lastModified} suffix=" ago" />)
</>
) : bridgeRoutes?.source ? (
<> (source: {bridgeRoutes.source})</>
) : null}
. Gnosis, Cronos, Celo, and Wemix lanes are aligned to deployed CCIP receivers fund LINK on each remote bridge before live traffic.
</p>
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
@@ -508,9 +489,10 @@ export default function BridgeMonitoringPage({
</table>
</div>
</Card>
</DisclosureSection>
) : null}
<OperationsActionGrid actions={page.actions} />
<OperationsActionGrid actions={page.actions} className="mt-6" />
</div>
)
}

View File

@@ -1,5 +1,6 @@
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import DisclosureSection from '@/components/common/DisclosureSection'
import type { ExplorerFeatureAction, ExplorerFeaturePage } from '@/data/explorerOperations'
function ActionLink({ action }: { action: ExplorerFeatureAction }) {
@@ -23,44 +24,42 @@ function ActionLink({ action }: { action: ExplorerFeatureAction }) {
export default function FeatureLandingPage({ page }: { page: ExplorerFeaturePage }) {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-6 max-w-4xl sm:mb-8">
<div className="mb-3 inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">
<div className="container mx-auto px-4 py-4 sm:py-6">
<div className="mb-4 max-w-4xl">
<div className="mb-2 inline-flex rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-700 dark:border-sky-900/50 dark:bg-sky-950/30 dark:text-sky-200">
{page.eyebrow}
</div>
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
<h1 className="mb-2 text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
{page.title}
</h1>
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
{page.description}
</p>
</div>
{page.note ? (
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
{page.note}
</p>
</Card>
<DisclosureSection title="Scope note" defaultOpen={false} alwaysCollapsible className="mb-4">
<Card className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">{page.note}</p>
</Card>
</DisclosureSection>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
{page.actions.map((action) => (
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">
{action.title}
<DisclosureSection title="Related actions" defaultOpen={false} alwaysCollapsible>
<div className="grid gap-3 lg:grid-cols-2">
{page.actions.map((action) => (
<Card key={`${action.title}-${action.href}`} className="border border-gray-200 dark:border-gray-700">
<div className="flex h-full flex-col">
<div className="text-base font-semibold text-gray-900 dark:text-white">{action.title}</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">{action.description}</p>
<div className="mt-4">
<ActionLink action={action} />
</div>
</div>
<p className="mt-2 flex-1 text-sm leading-6 text-gray-600 dark:text-gray-400">
{action.description}
</p>
<div className="mt-4">
<ActionLink action={action} />
</div>
</div>
</Card>
))}
</div>
</Card>
))}
</div>
</DisclosureSection>
</div>
)
}

View File

@@ -20,18 +20,20 @@ import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse }
import { missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure'
import CompactMetricBar from '@/components/common/CompactMetricBar'
import PageIntro from '@/components/common/PageIntro'
import DisclosureSection from '@/components/common/DisclosureSection'
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
import {
formatCurrency,
formatNumber,
relativeAge,
truncateMiddle,
} from './OperationsPageShell'
@@ -83,6 +85,7 @@ export default function LiquidityOperationsPage({
const [loadingError, setLoadingError] = useState<string | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'pools' | 'routes' | 'api'>('pools')
useEffect(() => {
let cancelled = false
@@ -273,22 +276,27 @@ export default function LiquidityOperationsPage({
}
}
const liquidityTabs = useMemo<SectionTab<'pools' | 'routes' | 'api'>[]>(
() => [
{ id: 'pools', label: 'Pools', count: aggregatedPools.length },
{ id: 'routes', label: 'Routes', count: highlightedRoutes.length },
{ id: 'api', label: 'API & tools' },
],
[aggregatedPools.length, highlightedRoutes.length],
)
return (
<main className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-8 max-w-4xl">
<div className="mb-3 inline-flex rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">
Liquidity
</div>
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
Public liquidity, route discovery, and execution access points
</h1>
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
This page now reads the live explorer APIs instead of hardcoded pool snapshots. It pulls the
public route matrix, planner capabilities, and mission-control token pool inventory together
so integrators can inspect what Chain 138 is actually serving right now.
</p>
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
</div>
<main className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Liquidity"
title="Liquidity & routes"
description="Live route matrix, planner capabilities, and mission-control pool inventory for Chain 138."
actions={[
{ href: '/pools', label: 'Pools' },
{ href: '/routes', label: 'Routes' },
]}
/>
<OperationsSurfaceNav />
@@ -303,25 +311,34 @@ export default function LiquidityOperationsPage({
/>
) : null}
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Liquidity Freshness Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel="Liquidity inventory and planner posture are shown alongside the same explorer freshness model used on the homepage and core explorer routes"
/>
<ExplorerFreshnessDisclosure
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
title="Freshness & subsystems"
activityTitle="Liquidity recency"
scopeLabel="Liquidity inventory and planner posture on this page."
>
<SubsystemPosturePanel
className="mt-3"
subsystems={bridgeStatus?.data?.subsystems}
title="Liquidity Subsystem Posture"
title="Liquidity subsystem posture"
preferredKeys={['rpc_head', 'tx_index', 'stats_summary', 'freshness_queries']}
scopeLabel="Use these subsystem signals to tell whether sparse liquidity visibility is consistent with quiet-chain conditions, partial transaction indexing, or stale public summary data."
scopeLabel="Subsystem signals for liquidity visibility."
/>
</div>
</ExplorerFreshnessDisclosure>
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<CompactMetricBar
metrics={[
{ label: 'Pools', value: formatNumber(aggregatedPools.length) },
{ label: 'Routes', value: formatNumber(routeMatrix?.counts?.filteredLiveRoutes) },
{ label: 'Providers', value: formatNumber(livePlannerProviders.length) },
{ label: 'Planner', value: internalPlan?.plannerResponse?.decision || 'unknown' },
]}
/>
<DisclosureSection title="Metric details" defaultOpen={false} alwaysCollapsible className="mb-4">
<div className="mb-4 grid gap-3 md:grid-cols-2">
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
Live Pools
@@ -369,11 +386,22 @@ export default function LiquidityOperationsPage({
</div>
</Card>
</div>
</DisclosureSection>
<div className="mb-8 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<SectionTabs
tabs={liquidityTabs}
activeTab={activeTab}
onChange={setActiveTab}
idPrefix="liquidity-ops"
ariaLabel="Liquidity sections"
className="-mx-4 mb-4 px-4"
/>
<TabPanel idPrefix="liquidity-ops" tabId="pools" activeTab={activeTab}>
<div className="mb-4 grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<Card title="Live Pool Snapshot">
<div className="space-y-4">
{aggregatedPools.slice(0, 8).map((pool) => (
<div className="space-y-3">
{aggregatedPools.slice(0, 5).map((pool) => (
<div
key={pool.address}
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
@@ -429,7 +457,23 @@ export default function LiquidityOperationsPage({
</Card>
</div>
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
<DisclosureSection title="Curated LP registry" defaultOpen={false} alwaysCollapsible className="mb-4">
<Card title="Curated LP registry (Chain 138)">
<LpPositionPanel chainId={138} title="Pool registry & LP scan" compact />
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
Connect a wallet on the{' '}
<Link href="/wallet" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
Wallet
</Link>{' '}
page to scan your LP shares and estimated USD NAV. LP tokens are chain-local bridge underlying c*/cW*
instead.
</p>
</Card>
</DisclosureSection>
</TabPanel>
<TabPanel idPrefix="liquidity-ops" tabId="routes" activeTab={activeTab}>
<div className="mb-4 grid gap-6 lg:grid-cols-[1fr_1fr]">
<Card title="Top Route-Backed Liquidity Paths">
<div className="space-y-4">
{highlightedRoutes.map((route) => (
@@ -486,22 +530,10 @@ export default function LiquidityOperationsPage({
</div>
</Card>
</div>
</TabPanel>
<div className="mb-8">
<Card title="Curated LP registry (Chain 138)">
<LpPositionPanel chainId={138} title="Pool registry & LP scan" compact />
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
Connect a wallet on the{' '}
<Link href="/wallet" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
Wallet
</Link>{' '}
page to scan your LP shares and estimated USD NAV. LP tokens are chain-local bridge underlying c*/cW*
instead.
</p>
</Card>
</div>
<div className="mb-8">
<TabPanel idPrefix="liquidity-ops" tabId="api" activeTab={activeTab}>
<div className="mb-4">
<Card title="Explorer Access Points">
<div className="grid gap-4 md:grid-cols-2">
{endpointCards.map((endpoint) => (
@@ -567,7 +599,7 @@ export default function LiquidityOperationsPage({
view, and this page for the broader route and execution surfaces.
</p>
<p>
The live route matrix was updated {relativeAge(routeMatrix?.updated)}, and the current
The live route matrix was updated <ClientRelativeTime value={routeMatrix?.updated} suffix=" ago" />, and the current
route-backed pool set references {formatNumber(routeBackedPoolAddresses.length)} unique
pool addresses.
</p>
@@ -594,6 +626,7 @@ export default function LiquidityOperationsPage({
</div>
</Card>
</div>
</TabPanel>
</main>
)
}

View File

@@ -38,14 +38,16 @@ function ActionCard({ action }: { action: ExplorerFeatureAction }) {
export default function OperationsActionGrid({
actions,
title = 'Quick actions',
className = '',
}: {
actions: ExplorerFeatureAction[]
title?: string
className?: string
}) {
if (actions.length === 0) return null
return (
<>
<div className={className}>
<details className="group mb-6 rounded-2xl border border-gray-200 bg-gray-50/80 dark:border-gray-800 dark:bg-gray-900/40 md:hidden">
<summary className="cursor-pointer list-none px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white [&::-webkit-details-marker]:hidden">
<span className="flex items-center justify-between gap-3">
@@ -68,6 +70,6 @@ export default function OperationsActionGrid({
<ActionCard key={`${action.title}-${action.href}`} action={action} />
))}
</div>
</>
</div>
)
}

View File

@@ -8,28 +8,18 @@ import { getMissionControlRelays, missionControlApi, type MissionControlBridgeSt
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
import { useUiMode } from '@/components/common/UiModeContext'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import PageIntro from '@/components/common/PageIntro'
import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure'
import CompactMetricBar from '@/components/common/CompactMetricBar'
import DisclosureSection from '@/components/common/DisclosureSection'
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
import OperationsActionGrid from '@/components/explorer/OperationsActionGrid'
import BridgeLaneHealthPanel from '@/components/explorer/BridgeLaneHealthPanel'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
function relativeAge(isoString?: string): string {
if (!isoString) return 'Unknown'
const parsed = Date.parse(isoString)
if (!Number.isFinite(parsed)) return 'Unknown'
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 ActionLink({
href,
label,
@@ -192,119 +182,67 @@ export default function OperationsHubPage({
)
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-6 max-w-4xl sm:mb-8">
<div className="mb-3 inline-flex rounded-full border border-violet-200 bg-violet-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-violet-700">
{page.eyebrow}
</div>
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
{page.title}
</h1>
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
{page.description}
</p>
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
</div>
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow={page.eyebrow}
title={page.title}
description={page.description}
actions={[
{ href: '/bridge', label: 'Bridge' },
{ href: '/routes', label: 'Routes' },
]}
/>
{page.note ? (
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
<DisclosureSection title="Scope note" defaultOpen={false} alwaysCollapsible className="mb-4">
<p className="rounded-lg border border-amber-200 bg-amber-50/70 px-3 py-2 text-sm text-amber-950 dark:border-amber-900/50 dark:bg-amber-950/20 dark:text-amber-100">
{page.note}
</p>
</Card>
</DisclosureSection>
) : null}
<OperationsSurfaceNav />
{loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<Card className="mb-4 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>
</Card>
) : null}
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Operations Freshness Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel="This page reflects mission-control freshness, public bridge status, and explorer-served config surfaces."
/>
<ExplorerFreshnessDisclosure
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
title="Freshness & subsystems"
activityTitle="Operations recency"
scopeLabel="Mission-control freshness, bridge status, and explorer config surfaces."
>
<SubsystemPosturePanel
className="mt-3"
subsystems={bridgeStatus?.data?.subsystems}
title="Operations Subsystem Posture"
title="Subsystem posture"
preferredKeys={['rpc_head', 'tx_index', 'bridge_relay_monitoring', 'stats_summary', 'freshness_queries']}
scopeLabel="Operations posture is grounded in the same backend subsystem truth used for chain activity, route inventory, and bridge monitoring."
scopeLabel="Operations posture from backend subsystem truth."
/>
</div>
</ExplorerFreshnessDisclosure>
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
Bridge Fleet
</div>
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
{bridgeStatus?.data?.status || 'unknown'}
</div>
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
{relayCount} managed lanes · queue {totalQueue}
</div>
{laneSummary ? (
<div className="mt-1 text-sm text-gray-700 dark:text-gray-300">
{laneSummary.funded}/{laneSummary.total} config-ready funded
{laneSummary.unfunded > 0 ? ` · ${laneSummary.unfunded} unfunded` : ''}
{' · '}
{laneSummary.proofRecorded}/{laneSummary.total} proof-recorded
</div>
) : null}
</Card>
<CompactMetricBar
metrics={[
{ label: 'Bridge', value: bridgeStatus?.data?.status || 'unknown' },
{ label: 'Lanes', value: String(relayCount) },
{ label: 'Routes', value: String(routeMatrix?.counts?.filteredLiveRoutes ?? 0) },
{ label: 'Chains', value: String(networksConfig?.chains?.length ?? 0) },
]}
/>
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
Route Coverage
</div>
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
{routeMatrix?.counts?.filteredLiveRoutes ?? 0}
</div>
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
{routeMatrix?.counts?.liveSwapRoutes ?? 0} swaps · {routeMatrix?.counts?.liveBridgeRoutes ?? 0} bridges
</div>
</Card>
<Card className="border border-gray-200 dark:border-gray-700">
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
Wallet Surface
</div>
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
{networksConfig?.chains?.length ?? 0}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Chains · {(tokenList?.tokens || []).length} tokens across {tokenChainCoverage} networks
</div>
</Card>
<Card className="border border-gray-200 dark:border-gray-700">
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
RPC Capabilities
</div>
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
{capabilities?.http?.supportedMethods?.length ?? 0}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
HTTP methods · {capabilities?.tracing?.supportedMethods?.length ?? 0} tracing methods
</div>
</Card>
</div>
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
<DisclosureSection title="Operations snapshot & config" defaultOpen={false} alwaysCollapsible className="mb-4">
<Card title="Operations Snapshot">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-sm text-gray-500 dark:text-gray-400">Bridge checked</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{relativeAge(bridgeStatus?.data?.checked_at)}
<ClientRelativeTime value={bridgeStatus?.data?.checked_at} suffix=" ago" />
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{mode === 'guided' ? 'Public mission-control snapshot freshness.' : 'Mission-control freshness.'}
@@ -313,7 +251,7 @@ export default function OperationsHubPage({
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-sm text-gray-500 dark:text-gray-400">Route matrix updated</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{relativeAge(routeMatrix?.updated)}
<ClientRelativeTime value={routeMatrix?.updated} suffix=" ago" />
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{mode === 'guided' ? 'Token-aggregation route inventory timestamp.' : 'Route inventory timestamp.'}
@@ -385,9 +323,11 @@ export default function OperationsHubPage({
</div>
</div>
</Card>
</div>
</DisclosureSection>
<BridgeLaneHealthPanel laneHealth={bridgeStatus?.data?.bridge_lanes} />
<DisclosureSection title="Bridge lane health" defaultOpen={false} alwaysCollapsible className="mb-4">
<BridgeLaneHealthPanel laneHealth={bridgeStatus?.data?.bridge_lanes} />
</DisclosureSection>
<OperationsActionGrid actions={page.actions} />
</div>

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { Card } from '@/libs/frontend-ui-primitives'
import DisclosureSection from '@/components/common/DisclosureSection'
import type { ExplorerFeaturePage } from '@/data/explorerOperations'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import OperationsActionGrid from './OperationsActionGrid'
@@ -7,18 +8,6 @@ import OperationsTrackNote from './OperationsTrackNote'
export type StatusTone = 'normal' | 'warning' | 'danger'
export function relativeAge(isoString?: string): string {
if (!isoString) return 'Unknown'
const parsed = Date.parse(isoString)
if (!Number.isFinite(parsed)) return 'Unknown'
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`
}
export function formatNumber(value?: number | null): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return '0'
return new Intl.NumberFormat('en-US').format(value)
@@ -85,28 +74,38 @@ export function MetricCard({
export default function OperationsPageShell({
page,
children,
compact = true,
}: {
page: ExplorerFeaturePage
children: ReactNode
compact?: boolean
}) {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-6 max-w-4xl sm:mb-8">
<div className="mb-3 inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">
<div className={`container mx-auto px-4 ${compact ? 'py-4 sm:py-6' : 'py-6 sm:py-8'}`}>
<div className={`max-w-4xl ${compact ? 'mb-4' : 'mb-6 sm:mb-8'}`}>
<div className="mb-2 inline-flex rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-700 dark:border-sky-900/50 dark:bg-sky-950/30 dark:text-sky-200">
{page.eyebrow}
</div>
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
<h1 className={`mb-2 font-bold text-gray-900 dark:text-white ${compact ? 'text-2xl sm:text-3xl' : 'mb-3 text-3xl sm:text-4xl'}`}>
{page.title}
</h1>
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
<p className={`text-gray-600 dark:text-gray-400 ${compact ? 'text-sm leading-6' : 'text-base leading-7 sm:text-lg sm:leading-8'}`}>
{page.description}
</p>
</div>
{page.note ? (
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">{page.note}</p>
</Card>
compact ? (
<DisclosureSection title="Scope note" defaultOpen={false} alwaysCollapsible className="mb-4">
<Card className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">{page.note}</p>
</Card>
</DisclosureSection>
) : (
<Card className="mb-4 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">{page.note}</p>
</Card>
)
) : null}
{page.accessTrack && page.accessNote ? (
@@ -117,7 +116,13 @@ export default function OperationsPageShell({
{children}
<OperationsActionGrid actions={page.actions} />
{compact ? (
<DisclosureSection title="Related actions" defaultOpen={false} alwaysCollapsible className="mt-4">
<OperationsActionGrid actions={page.actions} />
</DisclosureSection>
) : (
<OperationsActionGrid actions={page.actions} />
)}
</div>
)
}

View File

@@ -9,11 +9,13 @@ import {
} from '@/services/api/missionControl'
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
import CompactMetricBar from '@/components/common/CompactMetricBar'
import PaginationControls from '@/components/common/PaginationControls'
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
import OperationsPageShell, {
MetricCard,
StatusBadge,
formatNumber,
relativeAge,
truncateMiddle,
} from './OperationsPageShell'
@@ -31,6 +33,8 @@ function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
return 'normal'
}
const OPERATOR_RELAY_PAGE_SIZE = 4
export default function OperatorOperationsPage({
initialBridgeStatus = null,
initialRouteMatrix = null,
@@ -42,6 +46,8 @@ export default function OperatorOperationsPage({
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
const [loadingError, setLoadingError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'relays' | 'readiness'>('relays')
const [relayPage, setRelayPage] = useState(1)
const page = explorerFeaturePages.operator
useEffect(() => {
@@ -84,16 +90,25 @@ export default function OperatorOperationsPage({
const relays = useMemo(() => getMissionControlRelays(bridgeStatus), [bridgeStatus])
const relayEntries = useMemo(() => Object.entries(relays || {}), [relays])
const totalQueue = useMemo(
() =>
relayEntries.reduce((sum, [, relay]) => {
const queueSize = relay.url_probe?.body?.queue?.size ?? relay.file_snapshot?.queue?.size ?? 0
return sum + queueSize
}, 0),
[relayEntries]
useEffect(() => {
setRelayPage(1)
}, [relayEntries.length])
const relayPageCount = relayEntries.length ? Math.max(1, Math.ceil(relayEntries.length / OPERATOR_RELAY_PAGE_SIZE)) : 1
const relayVisibleEntries = relayEntries.slice(
(relayPage - 1) * OPERATOR_RELAY_PAGE_SIZE,
relayPage * OPERATOR_RELAY_PAGE_SIZE,
)
const providers = plannerCapabilities?.providers || []
const liveProviders = providers.filter((provider) => provider.live)
const operatorTabs = useMemo<SectionTab<'relays' | 'readiness'>[]>(
() => [
{ id: 'relays', label: 'Relay lanes', count: relayEntries.length },
{ id: 'readiness', label: 'Readiness' },
],
[relayEntries.length],
)
return (
<OperationsPageShell page={page}>
@@ -103,39 +118,28 @@ export default function OperatorOperationsPage({
</Card>
) : null}
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard
title="Relay Fleet"
value={bridgeStatus?.data?.status || 'unknown'}
description={`${relayEntries.length} managed lanes · queue ${formatNumber(totalQueue)}`}
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
/>
<MetricCard
title="Live Routes"
value={formatNumber(routeMatrix?.counts?.filteredLiveRoutes)}
description={`${formatNumber(routeMatrix?.counts?.blockedOrPlannedRoutes)} planned or blocked routes remain in the matrix.`}
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
/>
<MetricCard
title="Planner Providers"
value={formatNumber(liveProviders.length)}
description={`${formatNumber(providers.length)} published providers in planner v2 capabilities.`}
/>
<MetricCard
title="Fallback Decision"
value={internalPlan?.plannerResponse?.decision || 'unknown'}
description={
internalPlan?.execution?.contractAddress
? `Execution contract ${truncateMiddle(internalPlan.execution.contractAddress)}`
: 'Latest internal execution plan posture.'
}
/>
</div>
<CompactMetricBar
metrics={[
{ label: 'Fleet', value: bridgeStatus?.data?.status || 'unknown' },
{ label: 'Live routes', value: formatNumber(routeMatrix?.counts?.filteredLiveRoutes) },
{ label: 'Providers', value: formatNumber(liveProviders.length) },
{ label: 'Plan', value: internalPlan?.plannerResponse?.decision || 'unknown' },
]}
/>
<div className="mb-8 grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<SectionTabs
tabs={operatorTabs}
activeTab={activeTab}
onChange={setActiveTab}
idPrefix="operator-ops"
ariaLabel="Operator telemetry sections"
className="-mx-4 mb-4 px-4"
/>
<TabPanel idPrefix="operator-ops" tabId="relays" activeTab={activeTab}>
<Card title="Managed Relay Lanes">
<div className="space-y-4">
{relayEntries.map(([key, relay]) => {
{relayVisibleEntries.map(([key, relay]) => {
const snapshot = relay.url_probe?.body || relay.file_snapshot
const status = snapshot?.status || 'unknown'
return (
@@ -155,7 +159,7 @@ export default function OperatorOperationsPage({
<StatusBadge status={status} tone={relayTone(status)} />
</div>
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
Last source poll {relativeAge(snapshot?.last_source_poll?.at)} · processed {formatNumber(snapshot?.queue?.processed ?? 0)}
Last source poll <ClientRelativeTime value={snapshot?.last_source_poll?.at} suffix=" ago" /> · processed {formatNumber(snapshot?.queue?.processed ?? 0)}
</div>
</div>
)
@@ -163,9 +167,21 @@ export default function OperatorOperationsPage({
{relayEntries.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">No relay lane data available.</p>
) : null}
{relayPageCount > 1 ? (
<PaginationControls
page={relayPage}
onPageChange={setRelayPage}
label="Relay lanes"
ariaLabel="Operator relay lane pagination"
hasNextPage={relayPage < relayPageCount}
className="pt-2"
/>
) : null}
</div>
</Card>
</TabPanel>
<TabPanel idPrefix="operator-ops" tabId="readiness" activeTab={activeTab}>
<Card title="Execution Readiness">
<div className="space-y-4">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -199,7 +215,7 @@ export default function OperatorOperationsPage({
</div>
</div>
</Card>
</div>
</TabPanel>
</OperationsPageShell>
)
}

View File

@@ -3,6 +3,12 @@
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import DisclosureSection from '@/components/common/DisclosureSection'
import CompactMetricBar from '@/components/common/CompactMetricBar'
import ListFilterBar from '@/components/common/ListFilterBar'
import PageIntro from '@/components/common/PageIntro'
import PaginationControls from '@/components/common/PaginationControls'
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
import { type TokenListResponse } from '@/services/api/config'
import { tokensApi } from '@/services/api/tokens'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
@@ -15,6 +21,26 @@ import {
import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } from '@/services/api/routes'
import { formatCurrency, formatNumber, truncateMiddle } from './OperationsPageShell'
const POOLS_PAGE_SIZE = 6
type PoolsPageTab = 'overview' | 'pools' | 'tokens'
function matchesPoolQuery(pool: { address?: string | null; token0?: { symbol?: string | null }; token1?: { symbol?: string | null }; dex?: string | null; sourceSymbols?: string[] }, query: string) {
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery) return true
const haystack = [
pool.address,
pool.token0?.symbol,
pool.token1?.symbol,
pool.dex,
...(pool.sourceSymbols || []),
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(normalizedQuery)
}
interface TokenPoolRecord {
symbol: string
pools: MissionControlLiquidityPool[]
@@ -25,6 +51,10 @@ export default function PoolsOperationsPage() {
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>([])
const [loadingError, setLoadingError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<PoolsPageTab>('pools')
const [poolQuery, setPoolQuery] = useState('')
const [poolPage, setPoolPage] = useState(1)
const [tokenQuery, setTokenQuery] = useState('')
useEffect(() => {
let cancelled = false
@@ -88,23 +118,63 @@ export default function PoolsOperationsPage() {
() => getRouteBackedPoolAddresses(routeMatrix),
[routeMatrix]
)
const topPools = aggregatedPools.slice(0, 9)
const filteredPools = useMemo(
() => aggregatedPools.filter((pool) => matchesPoolQuery(pool, poolQuery)),
[aggregatedPools, poolQuery],
)
const poolPageCount = Math.max(1, Math.ceil(filteredPools.length / POOLS_PAGE_SIZE))
const paginatedPools = useMemo(() => {
const safePage = Math.min(Math.max(poolPage, 1), poolPageCount)
const start = (safePage - 1) * POOLS_PAGE_SIZE
return filteredPools.slice(start, start + POOLS_PAGE_SIZE)
}, [filteredPools, poolPage, poolPageCount])
const filteredFeaturedTokens = useMemo(() => {
const normalizedQuery = tokenQuery.trim().toLowerCase()
if (!normalizedQuery) return featuredTokens
return featuredTokens.filter((token) => {
const haystack = [token.symbol, token.name, token.address].filter(Boolean).join(' ').toLowerCase()
return haystack.includes(normalizedQuery)
})
}, [featuredTokens, tokenQuery])
useEffect(() => {
setPoolPage(1)
}, [poolQuery])
useEffect(() => {
if (poolPage > poolPageCount) {
setPoolPage(poolPageCount)
}
}, [poolPage, poolPageCount])
const poolTabs = useMemo<SectionTab<PoolsPageTab>[]>(
() => [
{ id: 'overview', label: 'Overview' },
{ id: 'pools', label: 'Pool cards', count: aggregatedPools.length },
{ id: 'tokens', label: 'By token', count: featuredTokens.length },
],
[aggregatedPools.length, featuredTokens.length],
)
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-6 max-w-4xl sm:mb-8">
<div className="mb-3 inline-flex rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-primary-700">
Live Pool Inventory
</div>
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
Pools
</h1>
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
This page now summarizes the live pool inventory discovered through mission-control token
pool endpoints and cross-checks it against the current route matrix.
</p>
<TokenListSurfaceNote className="mt-3 text-sm text-gray-600 dark:text-gray-400" />
</div>
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Live Pool Inventory"
title="Pools"
description="Live pool inventory from mission-control endpoints, cross-checked against the route matrix."
actions={[
{ href: '/liquidity', label: 'Liquidity' },
{ href: '/routes', label: 'Routes' },
]}
/>
<DisclosureSection title="Token list surface note" defaultOpen={false} alwaysCollapsible className="mb-3">
<TokenListSurfaceNote className="text-sm text-gray-600 dark:text-gray-400" />
</DisclosureSection>
<OperationsSurfaceNav />
@@ -114,7 +184,27 @@ export default function PoolsOperationsPage() {
</Card>
) : null}
<div className="mb-8 grid gap-4 md:grid-cols-3">
<CompactMetricBar
metrics={[
{ label: 'Pools', value: formatNumber(aggregatedPools.length) },
{ label: 'Route-backed', value: formatNumber(routeBackedPoolAddresses.length) },
{ label: 'Featured', value: formatNumber(featuredTokens.length) },
{ label: 'With pools', value: formatNumber(tokenPoolRecords.filter((record) => record.pools.length > 0).length) },
]}
/>
<SectionTabs
tabs={poolTabs}
activeTab={activeTab}
onChange={setActiveTab}
idPrefix="pools-page"
ariaLabel="Pools sections"
className="-mx-4 mb-4 px-4"
/>
<TabPanel idPrefix="pools-page" tabId="overview" activeTab={activeTab}>
<DisclosureSection title="Pool inventory details" defaultOpen={false} alwaysCollapsible className="mb-4">
<div className="mb-4 grid gap-3 md:grid-cols-3">
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
Unique pools
@@ -147,14 +237,58 @@ export default function PoolsOperationsPage() {
</div>
</Card>
</div>
</DisclosureSection>
<div className="mb-8">
<Card title="Pool operation shortcuts" className="mb-4">
<div className="space-y-4 text-sm leading-6 text-gray-600 dark:text-gray-400">
<p>
The broader liquidity page now shows live route, planner, and pool access together.
</p>
<p>
The current route matrix publishes {formatNumber(routeMatrix?.counts?.liveSwapRoutes)} live
swap routes and {formatNumber(routeMatrix?.counts?.liveBridgeRoutes)} bridge routes.
</p>
<div className="flex flex-wrap gap-3">
<Link
href="/liquidity"
className="rounded-full bg-primary-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-primary-700"
>
Open liquidity access
</Link>
<Link
href="/routes"
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
>
Open routes page
</Link>
<Link
href="/wallet"
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
>
Open wallet tools
</Link>
</div>
</div>
</Card>
</TabPanel>
<TabPanel idPrefix="pools-page" tabId="pools" activeTab={activeTab}>
<div className="mb-6">
<Card title="Live Pool Cards">
<ListFilterBar
query={poolQuery}
onQueryChange={setPoolQuery}
placeholder="Filter pools by pair, DEX, address, or source token"
resultCount={filteredPools.length}
totalCount={aggregatedPools.length}
className="mb-4"
/>
<div className="grid gap-4 lg:grid-cols-3">
{topPools.map((pool) => (
<div
{paginatedPools.map((pool) => (
<Link
key={pool.address}
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
href={`/pools/${pool.address}`}
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 transition hover:border-primary-400 hover:bg-primary-50/40 dark:border-gray-700 dark:bg-gray-900/40 dark:hover:border-primary-700"
>
<div className="text-base font-semibold text-gray-900 dark:text-white">
{(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')}
@@ -168,76 +302,90 @@ export default function PoolsOperationsPage() {
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Seen from {pool.sourceSymbols.join(', ')}
</div>
</div>
</Link>
))}
{topPools.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">No live pools available right now.</p>
{paginatedPools.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400 lg:col-span-3">
{aggregatedPools.length === 0 ? 'No live pools available right now.' : 'No pools match your filter.'}
</p>
) : null}
</div>
<PaginationControls
page={poolPage}
pageCount={poolPageCount}
onPageChange={setPoolPage}
label="Pools"
ariaLabel="Pool cards pagination"
/>
</Card>
</div>
</TabPanel>
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
<TabPanel idPrefix="pools-page" tabId="tokens" activeTab={activeTab}>
<div>
<Card title="Featured Token Pool Counts">
<div className="space-y-4">
{featuredTokens.map((token) => {
<ListFilterBar
query={tokenQuery}
onQueryChange={setTokenQuery}
placeholder="Filter featured tokens"
resultCount={filteredFeaturedTokens.length}
totalCount={featuredTokens.length}
className="mb-4"
/>
<div className="space-y-3">
{filteredFeaturedTokens.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">No featured tokens match your filter.</p>
) : (
filteredFeaturedTokens.map((token) => {
const record = tokenPoolRecords.find((entry) => entry.symbol === token.symbol)
return (
<div
<DisclosureSection
key={token.address}
title={`${token.symbol} · ${formatNumber(record?.pools.length || 0)} pools`}
defaultOpen={token.symbol === 'cUSDT' || token.symbol === 'cUSDC'}
alwaysCollapsible
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-base font-semibold text-gray-900 dark:text-white">
{token.symbol}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{token.name || 'Unnamed token'}
</div>
</div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{formatNumber(record?.pools.length || 0)} pools
<div className="text-base font-semibold text-gray-900 dark:text-white">{token.symbol}</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{token.name || 'Unnamed token'}</div>
</div>
<Link
href={`/tokens/${token.address}`}
className="text-sm font-medium text-primary-600 hover:underline"
>
Open token
</Link>
</div>
</div>
{record && record.pools.length > 0 ? (
<div className="mt-4 grid gap-3 md:grid-cols-2">
{record.pools.map((pool) => (
<Link
key={pool.address}
href={`/pools/${pool.address}`}
className="rounded-xl border border-gray-200 bg-white/80 p-3 transition hover:border-primary-400 dark:border-gray-700 dark:bg-black/10"
>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{pool.dex || 'Unknown DEX'} · TVL {formatCurrency(pool.tvl)}
</div>
</Link>
))}
</div>
) : (
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">No live pools returned for this token yet.</p>
)}
</DisclosureSection>
)
})}
</div>
</Card>
<Card title="Pool operation shortcuts">
<div className="space-y-4 text-sm leading-6 text-gray-600 dark:text-gray-400">
<p>
The broader liquidity page now shows live route, planner, and pool access together.
</p>
<p>
The current route matrix publishes {formatNumber(routeMatrix?.counts?.liveSwapRoutes)} live
swap routes and {formatNumber(routeMatrix?.counts?.liveBridgeRoutes)} bridge routes.
</p>
<div className="flex flex-wrap gap-3">
<Link
href="/liquidity"
className="rounded-full bg-primary-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-primary-700"
>
Open liquidity access
</Link>
<Link
href="/routes"
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
>
Open routes page
</Link>
<Link
href="/wallet"
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
>
Open wallet tools
</Link>
</div>
})
)}
</div>
</Card>
</div>
</TabPanel>
</div>
)
}

View File

@@ -12,12 +12,15 @@ import {
import { missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import ExplorerFreshnessDisclosure from '@/components/common/ExplorerFreshnessDisclosure'
import CompactMetricBar from '@/components/common/CompactMetricBar'
import DisclosureSection from '@/components/common/DisclosureSection'
import SectionTabs, { TabPanel } from '@/components/common/SectionTabs'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import OperationsActionGrid from './OperationsActionGrid'
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
interface RoutesMonitoringPageProps {
initialRouteMatrix?: RouteMatrixResponse | null
@@ -29,18 +32,6 @@ interface RoutesMonitoringPageProps {
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
function relativeAge(isoString?: string): string {
if (!isoString) return 'Unknown'
const parsed = Date.parse(isoString)
if (!Number.isFinite(parsed)) return 'Unknown'
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 compactAddress(value?: string): string {
if (!value) return 'Unspecified'
if (value.length <= 14) return value
@@ -110,6 +101,7 @@ export default function RoutesMonitoringPage({
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [loadingError, setLoadingError] = useState<string | null>(null)
const [routesTab, setRoutesTab] = useState<'overview' | 'live' | 'backlog'>('overview')
const page = explorerFeaturePages.routes
useEffect(() => {
@@ -205,25 +197,27 @@ export default function RoutesMonitoringPage({
)
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-6 max-w-4xl sm:mb-8">
<div className="mb-3 inline-flex rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700">
<div className="container mx-auto px-4 py-4 sm:py-6">
<div className="mb-4 max-w-4xl">
<div className="mb-2 inline-flex rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200">
{page.eyebrow}
</div>
<h1 className="mb-3 text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">
<h1 className="mb-2 text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
{page.title}
</h1>
<p className="text-base leading-7 text-gray-600 dark:text-gray-400 sm:text-lg sm:leading-8">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
{page.description}
</p>
</div>
{page.note ? (
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
{page.note}
</p>
</Card>
<DisclosureSection title="Routes scope note" defaultOpen={false} alwaysCollapsible className="mb-4">
<Card className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
{page.note}
</p>
</Card>
</DisclosureSection>
) : null}
<OperationsSurfaceNav />
@@ -234,215 +228,178 @@ export default function RoutesMonitoringPage({
</Card>
) : null}
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Routes Freshness Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel="Route availability reflects the current public route matrix and the same explorer freshness model used on the core explorer pages"
/>
<ExplorerFreshnessDisclosure
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
title="Freshness & subsystems"
activityTitle="Routes recency"
scopeLabel="Route matrix freshness on this page."
>
<SubsystemPosturePanel
className="mt-3"
subsystems={bridgeStatus?.data?.subsystems}
title="Route Subsystem Posture"
title="Route subsystem posture"
preferredKeys={['rpc_head', 'tx_index', 'stats_summary', 'freshness_queries']}
scopeLabel="Route inventory is read against the same backend freshness signals used to explain whether route posture is limited by head visibility, transaction indexing, or stale summary data."
scopeLabel="Route inventory subsystem signals."
/>
</div>
</ExplorerFreshnessDisclosure>
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
Live Swap Routes
</div>
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
{routeMatrix?.counts?.liveSwapRoutes ?? liveRoutes.filter((route) => route.routeType === 'swap').length}
</div>
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
Planner-visible same-chain routes on Chain 138.
</div>
</Card>
<CompactMetricBar
metrics={[
{ label: 'Swaps', value: String(routeMatrix?.counts?.liveSwapRoutes ?? liveRoutes.filter((route) => route.routeType === 'swap').length) },
{ label: 'Bridges', value: String(routeMatrix?.counts?.liveBridgeRoutes ?? liveRoutes.filter((route) => route.routeType === 'bridge').length) },
{ label: 'Networks', value: String(networks.length) },
{ label: 'Pools', value: String(pools.length) },
]}
/>
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
Live Bridge Routes
</div>
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
{routeMatrix?.counts?.liveBridgeRoutes ?? liveRoutes.filter((route) => route.routeType === 'bridge').length}
</div>
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
Bridge routes exposed through the current route matrix.
</div>
</Card>
<SectionTabs
idPrefix="routes"
ariaLabel="Route views"
tabs={[
{ id: 'overview', label: 'Overview' },
{ id: 'live', label: 'Live routes', count: topRoutes.length },
{ id: 'backlog', label: 'Backlog', count: plannedRoutes.length },
]}
activeTab={routesTab}
onChange={setRoutesTab}
className="mb-4"
/>
<Card className="border border-gray-200 dark:border-gray-700">
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
Network Catalog
</div>
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
{networks.length}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Published networks available through the explorer config surface.
</div>
</Card>
<Card className="border border-gray-200 dark:border-gray-700">
<div className="text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
cUSDT Pool View
</div>
<div className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
{pools.length}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Live mission-control pools for the canonical cUSDT token.
</div>
</Card>
</div>
<div className="mb-8 grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<Card title="Route Matrix Summary">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-sm text-gray-500 dark:text-gray-400">Generated</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{relativeAge(routeMatrix?.generatedAt)}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Matrix version {routeMatrix?.version || 'unknown'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-sm text-gray-500 dark:text-gray-400">Updated Source</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{relativeAge(routeMatrix?.updated)}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{familyCount} partner families surfaced in live routes.
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-sm text-gray-500 dark:text-gray-400">Filtered Live Routes</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{routeMatrix?.counts?.filteredLiveRoutes ?? liveRoutes.length}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Includes swap and bridge lanes currently in the public matrix.
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-sm text-gray-500 dark:text-gray-400">Planned / Blocked</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{routeMatrix?.counts?.blockedOrPlannedRoutes ?? plannedRoutes.length}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Remaining lanes still waiting on pools, funding, or routing support.
</div>
</div>
</div>
</Card>
<Card title="Highlighted Networks">
<div className="space-y-3">
{highlightedNetworks.map((network) => (
<div
key={`${network.chainIdDecimal}-${network.chainName}`}
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
>
<div className="text-base font-semibold text-gray-900 dark:text-white">
{network.chainName || 'Unknown chain'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Chain ID {network.chainIdDecimal ?? 'Unknown'} · {network.shortName || 'n/a'}
</div>
</div>
))}
</div>
</Card>
</div>
<div className="mb-8 grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<Card title="Live Route Snapshot">
<div className="grid gap-4 lg:grid-cols-2">
{topRoutes.map((route) => (
<div
key={route.routeId}
className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-gray-900 dark:text-white">
{route.label || route.routeId}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{routeAssetPair(route)}
</div>
<TabPanel idPrefix="routes" tabId="overview" activeTab={routesTab}>
<DisclosureSection title="Matrix detail" defaultOpen={false} alwaysCollapsible className="mb-4">
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
<Card title="Route matrix summary">
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs text-gray-500 dark:text-gray-400">Generated</div>
<div className="mt-1 text-base font-semibold text-gray-900 dark:text-white">
<ClientRelativeTime value={routeMatrix?.generatedAt} suffix=" ago" />
</div>
<div className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
{route.routeType || 'route'}
<div className="mt-1 text-xs text-gray-600 dark:text-gray-400">
Matrix version {routeMatrix?.version || 'unknown'}
</div>
</div>
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
{`${route.fromChainId} -> ${route.toChainId} · ${route.hopCount ?? 0} hop${
(route.hopCount ?? 0) === 1 ? '' : 's'
}`}
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs text-gray-500 dark:text-gray-400">Updated</div>
<div className="mt-1 text-base font-semibold text-gray-900 dark:text-white">
<ClientRelativeTime value={routeMatrix?.updated} suffix=" ago" />
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{familyCount} partner families in live routes
</div>
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Protocols: {protocolList(route)}
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs text-gray-500 dark:text-gray-400">Live routes</div>
<div className="mt-1 text-base font-semibold text-gray-900 dark:text-white">
{routeMatrix?.counts?.filteredLiveRoutes ?? liveRoutes.length}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs text-gray-500 dark:text-gray-400">Planned / blocked</div>
<div className="mt-1 text-base font-semibold text-gray-900 dark:text-white">
{routeMatrix?.counts?.blockedOrPlannedRoutes ?? plannedRoutes.length}
</div>
</div>
</div>
))}
</div>
</Card>
<Card title="cUSDT Mission-Control Pools">
<div className="space-y-4">
{pools.map((pool) => (
<div
key={pool.address}
className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40"
>
<div className="text-base font-semibold text-gray-900 dark:text-white">
{(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{pool.dex || 'Unknown DEX'} · TVL {formatUsd(pool.tvl)}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Pool {compactAddress(pool.address)}
</div>
</Card>
<Card title="Highlighted networks">
<div className="space-y-2">
{highlightedNetworks.map((network) => (
<div
key={`${network.chainIdDecimal}-${network.chainName}`}
className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40"
>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{network.chainName || 'Unknown chain'}
</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-400">
Chain ID {network.chainIdDecimal ?? 'Unknown'} · {network.shortName || 'n/a'}
</div>
</div>
))}
</div>
))}
</Card>
</div>
</Card>
</div>
</DisclosureSection>
</TabPanel>
<div className="mb-8">
<Card title="Planned Route Backlog">
<div className="grid gap-4 lg:grid-cols-2">
{plannedRoutes.slice(0, 6).map((route) => (
<TabPanel idPrefix="routes" tabId="live" activeTab={routesTab}>
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
<Card title="Live route snapshot">
<div className="space-y-3">
{topRoutes.map((route) => (
<div
key={route.routeId}
className="rounded-xl border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800"
>
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{route.label || route.routeId}
</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-400">
{routeAssetPair(route)}
</div>
</div>
<div className="rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
{route.routeType || 'route'}
</div>
</div>
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
{`${route.fromChainId} -> ${route.toChainId} · ${route.hopCount ?? 0} hop${(route.hopCount ?? 0) === 1 ? '' : 's'}`}
{' · '}
{protocolList(route)}
</div>
</div>
))}
</div>
</Card>
<Card title="cUSDT pools">
<div className="space-y-3">
{pools.slice(0, 6).map((pool) => (
<div
key={pool.address}
className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40"
>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{(pool.token0?.symbol || '?') + ' / ' + (pool.token1?.symbol || '?')}
</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-400">
{pool.dex || 'Unknown DEX'} · TVL {formatUsd(pool.tvl)} · {compactAddress(pool.address)}
</div>
</div>
))}
</div>
</Card>
</div>
</TabPanel>
<TabPanel idPrefix="routes" tabId="backlog" activeTab={routesTab}>
<Card title="Planned route backlog">
<div className="grid gap-3 lg:grid-cols-2">
{plannedRoutes.slice(0, 8).map((route) => (
<div
key={route.routeId}
className="rounded-2xl border border-amber-200 bg-amber-50/70 p-4 dark:border-amber-900/50 dark:bg-amber-950/20"
className="rounded-xl border border-amber-200 bg-amber-50/70 p-3 dark:border-amber-900/50 dark:bg-amber-950/20"
>
<div className="text-base font-semibold text-gray-900 dark:text-white">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{route.routeId}
</div>
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">
<div className="mt-1 text-xs text-gray-700 dark:text-gray-300">
{(route.tokenInSymbols || []).join(' / ') || routeAssetPair(route)}
</div>
<div className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
<div className="mt-2 text-xs leading-5 text-gray-600 dark:text-gray-400">
{route.reason || 'Pending additional deployment or routing work.'}
</div>
</div>
))}
</div>
</Card>
</div>
</TabPanel>
<OperationsActionGrid actions={page.actions} />
<OperationsActionGrid actions={page.actions} className="mt-6" />
</div>
)
}

View File

@@ -6,11 +6,13 @@ import { tokensApi } from '@/services/api/tokens'
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
import CompactMetricBar from '@/components/common/CompactMetricBar'
import DisclosureSection from '@/components/common/DisclosureSection'
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
import OperationsPageShell, {
MetricCard,
StatusBadge,
formatNumber,
relativeAge,
} from './OperationsPageShell'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
@@ -38,6 +40,7 @@ export default function SystemOperationsPage({
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [loadingError, setLoadingError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'topology' | 'networks'>('topology')
const page = explorerFeaturePages.system
useEffect(() => {
@@ -90,9 +93,12 @@ export default function SystemOperationsPage({
const relays = useMemo(() => getMissionControlRelays(bridgeStatus), [bridgeStatus])
const chainStatus = bridgeStatus?.data?.chains?.['138']
const chainCoverage = useMemo(
() => new Set((tokenList?.tokens || []).map((token) => token.chainId).filter(Boolean)).size,
[tokenList]
const systemTabs = useMemo<SectionTab<'topology' | 'networks'>[]>(
() => [
{ id: 'topology', label: 'Topology' },
{ id: 'networks', label: 'Networks', count: networksConfig?.chains?.length ?? 0 },
],
[networksConfig?.chains?.length],
)
return (
@@ -103,33 +109,28 @@ export default function SystemOperationsPage({
</Card>
) : null}
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard
title="Published Networks"
value={formatNumber(networksConfig?.chains?.length)}
description={`Default chain ${networksConfig?.defaultChainId ?? 'unknown'} in wallet onboarding.`}
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
/>
<MetricCard
title="Relay Lanes"
value={formatNumber(relays ? Object.keys(relays).length : 0)}
description={`${bridgeStatus?.data?.status || 'unknown'} public bridge posture across managed lanes.`}
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
/>
<MetricCard
title="Token Coverage"
value={formatNumber((tokenList?.tokens || []).length)}
description={`${formatNumber(chainCoverage)} chain catalogs served through the public token list.`}
/>
<MetricCard
title="RPC Methods"
value={formatNumber(capabilities?.http?.supportedMethods?.length)}
description={`${formatNumber(capabilities?.tracing?.supportedMethods?.length)} tracing methods published.`}
/>
</div>
<TokenListSurfaceNote className="mb-6 text-xs text-gray-500 dark:text-gray-400" />
<CompactMetricBar
metrics={[
{ label: 'Networks', value: formatNumber(networksConfig?.chains?.length) },
{ label: 'Relays', value: formatNumber(relays ? Object.keys(relays).length : 0) },
{ label: 'Tokens', value: formatNumber((tokenList?.tokens || []).length) },
{ label: 'RPC methods', value: formatNumber(capabilities?.http?.supportedMethods?.length) },
]}
/>
<DisclosureSection title="Token list surface note" defaultOpen={false} alwaysCollapsible className="mb-4">
<TokenListSurfaceNote className="text-xs text-gray-500 dark:text-gray-400" />
</DisclosureSection>
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
<SectionTabs
tabs={systemTabs}
activeTab={activeTab}
onChange={setActiveTab}
idPrefix="system-ops"
ariaLabel="System inventory sections"
className="-mx-4 mb-4 px-4"
/>
<TabPanel idPrefix="system-ops" tabId="topology" activeTab={activeTab}>
<Card title="Topology Snapshot">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -147,7 +148,7 @@ export default function SystemOperationsPage({
{formatNumber(routeMatrix?.counts?.filteredLiveRoutes)} live routes
</div>
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
Updated {relativeAge(routeMatrix?.updated)} · {formatNumber(routeMatrix?.counts?.blockedOrPlannedRoutes)} planned or blocked
Updated <ClientRelativeTime value={routeMatrix?.updated} suffix=" ago" /> · {formatNumber(routeMatrix?.counts?.blockedOrPlannedRoutes)} planned or blocked
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -181,7 +182,9 @@ export default function SystemOperationsPage({
</div>
</div>
</Card>
</TabPanel>
<TabPanel idPrefix="system-ops" tabId="networks" activeTab={activeTab}>
<Card title="Network Inventory">
<div className="grid gap-3 sm:grid-cols-2">
{(networksConfig?.chains || []).map((chain) => (
@@ -202,7 +205,7 @@ export default function SystemOperationsPage({
) : null}
</div>
</Card>
</div>
</TabPanel>
</OperationsPageShell>
)
}

View File

@@ -8,12 +8,13 @@ import {
type MissionControlRelayPayload,
} from '@/services/api/missionControl'
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
import CompactMetricBar from '@/components/common/CompactMetricBar'
import DisclosureSection from '@/components/common/DisclosureSection'
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
import OperationsPageShell, {
MetricCard,
StatusBadge,
formatNumber,
relativeAge,
truncateMiddle,
} from './OperationsPageShell'
interface WethOperationsPageProps {
@@ -33,14 +34,6 @@ function relaySnapshot(relay: MissionControlRelayPayload | undefined) {
return relay?.url_probe?.body || relay?.file_snapshot
}
function relaySummary(snapshot: ReturnType<typeof relaySnapshot>) {
if (!snapshot) return 'destination unknown'
if (snapshot.status === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
return `Delivery paused · queue ${formatNumber(snapshot.queue?.size ?? 0)}`
}
return `Queue ${formatNumber(snapshot.queue?.size ?? 0)} · ${snapshot.destination?.chain_name || 'destination unknown'}`
}
export default function WethOperationsPage({
initialBridgeStatus = null,
initialPlannerCapabilities = null,
@@ -50,6 +43,7 @@ export default function WethOperationsPage({
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
const [loadingError, setLoadingError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'lanes' | 'providers'>('lanes')
const page = explorerFeaturePages.weth
useEffect(() => {
@@ -101,14 +95,24 @@ export default function WethOperationsPage({
[plannerCapabilities]
)
const wethTabs = useMemo<SectionTab<'lanes' | 'providers'>[]>(
() => [
{ id: 'lanes', label: 'Bridge lanes', count: 2 },
{ id: 'providers', label: 'Providers', count: wethProviders.length },
],
[wethProviders.length],
)
return (
<OperationsPageShell page={page}>
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
These WETH references are bridge-lane and public-network representation surfaces, not a claim that Ethereum mainnet WETH contracts are native Chain 138 assets.
Use this page to review wrapped-asset lane posture, counterpart contracts, and operational dependencies.
</p>
</Card>
<DisclosureSection title="WETH scope note" defaultOpen={false} alwaysCollapsible className="mb-4">
<Card className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
These WETH references are bridge-lane and public-network representation surfaces, not a claim that Ethereum mainnet WETH contracts are native Chain 138 assets.
Use this page to review wrapped-asset lane posture, counterpart contracts, and operational dependencies.
</p>
</Card>
</DisclosureSection>
{loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
@@ -116,37 +120,26 @@ export default function WethOperationsPage({
</Card>
) : null}
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard
title="Mainnet WETH Lane"
value={mainnetWeth?.status || 'unknown'}
description={relaySummary(mainnetWeth)}
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
/>
<MetricCard
title="Mainnet cW Lane"
value={mainnetCw?.status || 'unknown'}
description={relaySummary(mainnetCw)}
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
/>
<MetricCard
title="WETH Providers"
value={formatNumber(wethProviders.length)}
description="Providers that currently publish at least one WETH leg in planner v2."
/>
<MetricCard
title="Fallback Decision"
value={internalPlan?.plannerResponse?.decision || 'unknown'}
description={
internalPlan?.execution?.contractAddress
? `Execution contract ${truncateMiddle(internalPlan.execution.contractAddress)}`
: 'Current internal execution-plan posture for a WETH route.'
}
/>
</div>
<CompactMetricBar
metrics={[
{ label: 'WETH lane', value: mainnetWeth?.status || 'unknown' },
{ label: 'cW lane', value: mainnetCw?.status || 'unknown' },
{ label: 'Providers', value: formatNumber(wethProviders.length) },
{ label: 'Plan', value: internalPlan?.plannerResponse?.decision || 'unknown' },
]}
/>
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
<Card title="Mainnet Bridge Lanes">
<SectionTabs
tabs={wethTabs}
activeTab={activeTab}
onChange={setActiveTab}
idPrefix="weth-ops"
ariaLabel="WETH operations sections"
className="-mx-4 mb-4 px-4"
/>
<TabPanel idPrefix="weth-ops" tabId="lanes" activeTab={activeTab}>
<Card title="Mainnet Bridge Lanes" className="mb-4">
<div className="space-y-4">
{[
{ label: 'Mainnet WETH', snapshot: mainnetWeth },
@@ -166,13 +159,15 @@ export default function WethOperationsPage({
<StatusBadge status={snapshot?.status || 'unknown'} tone={relayTone(snapshot?.status)} />
</div>
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
Queue {formatNumber(snapshot?.queue?.size ?? 0)} · last poll {relativeAge(snapshot?.last_source_poll?.at)}
Queue {formatNumber(snapshot?.queue?.size ?? 0)} · last poll <ClientRelativeTime value={snapshot?.last_source_poll?.at} suffix=" ago" />
</div>
</div>
))}
</div>
</Card>
</TabPanel>
<TabPanel idPrefix="weth-ops" tabId="providers" activeTab={activeTab}>
<Card title="WETH Route-Ready Providers">
<div className="space-y-4">
{wethProviders.map((provider) => {
@@ -207,7 +202,7 @@ export default function WethOperationsPage({
) : null}
</div>
</Card>
</div>
</TabPanel>
</OperationsPageShell>
)
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives/Card'
import { Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { blocksApi, type Block } from '@/services/api/blocks'
import {
@@ -17,13 +17,15 @@ import {
} from '@/services/api/missionControl'
import { loadDashboardData } from '@/utils/dashboard'
import EntityBadge from '@/components/common/EntityBadge'
import { formatRelativeAge, formatTimestamp, formatWeiAsEth } from '@/utils/format'
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
import { formatInteger, formatTimestamp, formatWeiAsEth } from '@/utils/format'
import { transactionsApi, type Transaction } from '@/services/api/transactions'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import DisclosureSection from '@/components/common/DisclosureSection'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import MissionDeliveryModePanel from '@/components/common/MissionDeliveryModePanel'
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
import { Explain, useUiMode } from '@/components/common/UiModeContext'
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
@@ -37,6 +39,12 @@ import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
type HomeStats = ExplorerStats
const HOME_VISIBLE_TRANSACTIONS = 3
const HOME_VISIBLE_BLOCKS = 5
const HOME_PRICE_FEED_LIMIT = 4
type HomeActivityTab = 'transactions' | 'blocks'
interface HomePageProps {
initialStats?: HomeStats | null
initialRecentBlocks?: Block[]
@@ -99,7 +107,7 @@ function formatGasPriceGwei(value: number) {
return `${value.toFixed(3)} gwei`
}
function compactStatNote(guided: string, expert: string, mode: 'guided' | 'expert') {
function compactStatNote(guided: ReactNode, expert: ReactNode, mode: 'guided' | 'expert') {
return mode === 'guided' ? guided : expert
}
@@ -133,6 +141,7 @@ export default function Home({
const [missionExpanded, setMissionExpanded] = useState(false)
const [relayExpanded, setRelayExpanded] = useState(false)
const [statsDetailsExpanded, setStatsDetailsExpanded] = useState(false)
const [homeActivityTab, setHomeActivityTab] = useState<HomeActivityTab>('transactions')
const [relayPage, setRelayPage] = useState(1)
const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>(
initialRelaySummary || initialBridgeStatus ? 'fallback' : 'connecting'
@@ -148,7 +157,7 @@ export default function Home({
const response = await blocksApi.list({
chain_id: chainId,
page: 1,
page_size: 10,
page_size: HOME_VISIBLE_BLOCKS,
})
return response.data
},
@@ -376,7 +385,6 @@ export default function Home({
}
return parts.join(' · ')
})()
const snapshotAgeLabel = checkedAt ? formatRelativeAge(checkedAt) : 'Unknown'
const chainVisibilityState =
chainStatus?.head_age_sec != null
? chainStatus.head_age_sec <= 30
@@ -418,9 +426,11 @@ export default function Home({
...(bridgeStatus?.data?.sampling?.issues || {}),
...(stats?.sampling?.issues || {}),
})
const latestTransactionAgeLabel = activityContext.latest_transaction_timestamp
? formatRelativeAge(activityContext.latest_transaction_timestamp)
: 'Unknown'
const latestTransactionAgeLabel = activityContext.latest_transaction_timestamp ? (
<ClientRelativeTime value={activityContext.latest_transaction_timestamp} suffix=" ago" />
) : (
'Unknown'
)
const latestTransactionFreshness =
activityContext.latest_transaction_age_seconds == null
? 'Latest transaction freshness is unavailable.'
@@ -445,7 +455,7 @@ export default function Home({
)
const transactionsTodaySummary = formatObservabilityValue(
transactionsToday,
(value) => value.toLocaleString(),
(value) => formatInteger(value),
)
const networkUtilizationSummary =
networkUtilization == null
@@ -456,31 +466,34 @@ export default function Home({
const missionCollapsedSummary = relaySummary
? `${missionHeadline} · ${relayOperationalCount} operational`
: `${missionHeadline}${chainStatus?.status ? ` · chain 138 ${chainStatus.status}` : ''}`
const primaryMetricCards = [
const primaryMetricCards: Array<{ label: string; value: string; note: ReactNode }> = [
{
label: 'Latest Block',
value: latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable',
value: latestBlock != null ? formatInteger(latestBlock) : 'Unavailable',
note: activityContext.latest_block_timestamp
? compactStatNote(
`Head freshness ${formatRelativeAge(activityContext.latest_block_timestamp)}${blockCompleteness ? ` · ${blockCompleteness}` : ''}`,
formatRelativeAge(activityContext.latest_block_timestamp),
<>
Head freshness <ClientRelativeTime value={activityContext.latest_block_timestamp} suffix=" ago" />
{blockCompleteness ? ` · ${blockCompleteness}` : ''}
</>,
<ClientRelativeTime value={activityContext.latest_block_timestamp} suffix=" ago" />,
mode,
)
: compactStatNote('Head freshness unavailable.', 'Unavailable', mode),
},
{
label: 'Total Blocks',
value: stats ? stats.total_blocks.toLocaleString() : 'Unavailable',
value: stats ? formatInteger(stats.total_blocks) : 'Unavailable',
note: compactStatNote('Visible public explorer block count.', 'Explorer block count', mode),
},
{
label: 'Total Transactions',
value: stats ? stats.total_transactions.toLocaleString() : 'Unavailable',
value: stats ? formatInteger(stats.total_transactions) : 'Unavailable',
note: compactStatNote('Visible indexed explorer transaction count.', 'Indexed tx count', mode),
},
{
label: 'Total Addresses',
value: stats ? stats.total_addresses.toLocaleString() : 'Unavailable',
value: stats ? formatInteger(stats.total_addresses) : 'Unavailable',
note: compactStatNote('Current public explorer address count.', 'Address count', mode),
},
]
@@ -506,7 +519,7 @@ export default function Home({
note: compactStatNote(networkUtilizationSummary.note, networkUtilization != null ? 'Latest stats sample' : 'Unavailable', mode),
},
]
const activityMetricCards = [
const activityMetricCards: Array<{ label: string; value: string; note: ReactNode; detail: string }> = [
{
label: 'Latest Transaction',
value: activityContext.latest_transaction_block_number != null ? `#${activityContext.latest_transaction_block_number}` : 'Unknown',
@@ -516,7 +529,11 @@ export default function Home({
{
label: 'Last Non-Empty Block',
value: activityContext.last_non_empty_block_number != null ? `#${activityContext.last_non_empty_block_number}` : 'Unknown',
note: formatRelativeAge(activityContext.last_non_empty_block_timestamp),
note: activityContext.last_non_empty_block_timestamp ? (
<ClientRelativeTime value={activityContext.last_non_empty_block_timestamp} suffix=" ago" />
) : (
'Unknown'
),
detail:
activityContext.latest_transaction_block_number != null &&
activityContext.last_non_empty_block_number != null &&
@@ -528,11 +545,11 @@ export default function Home({
label: 'Block Gap',
value:
activityContext.block_gap_to_latest_transaction != null
? activityContext.block_gap_to_latest_transaction.toLocaleString()
? formatInteger(activityContext.block_gap_to_latest_transaction)
: 'Unknown',
note:
activityContext.block_gap_to_latest_transaction != null
? `${activityContext.block_gap_to_latest_transaction.toLocaleString()} blocks behind tip`
? `${formatInteger(activityContext.block_gap_to_latest_transaction)} blocks behind tip`
: 'Gap unavailable',
detail: 'Difference between the current tip and the latest visible transaction block.',
},
@@ -548,8 +565,28 @@ export default function Home({
}
}, [relayPage, relayPageCount])
const visibleRecentTransactions = useMemo(
() => recentTransactions.slice(0, HOME_VISIBLE_TRANSACTIONS),
[recentTransactions],
)
const visibleRecentBlocks = useMemo(
() => recentBlocks.slice(0, HOME_VISIBLE_BLOCKS),
[recentBlocks],
)
const visibleFeaturedPrices = useMemo(
() => featuredPrices.slice(0, HOME_PRICE_FEED_LIMIT),
[featuredPrices],
)
const homeActivityTabs = useMemo<SectionTab<HomeActivityTab>[]>(
() => [
{ id: 'transactions', label: 'Transactions', count: visibleRecentTransactions.length },
{ id: 'blocks', label: 'Blocks', count: visibleRecentBlocks.length },
],
[visibleRecentBlocks.length, visibleRecentTransactions.length],
)
return (
<main className="container mx-auto px-4 py-6 sm:py-8">
<main className="container mx-auto px-4 py-4 sm:py-6">
{(relaySummary || bridgeStatus) && (
<Card
className={`border shadow-sm ${relayToneClasses} ${missionExpanded ? 'mb-6' : 'mb-4 !p-2 sm:!p-2'}`}
@@ -633,9 +670,15 @@ export default function Home({
: 'Connecting'}
</div>
<div className="mt-1 text-sm opacity-80">
{`${statsGeneratedAt ? `Snapshot updated ${formatRelativeAge(statsGeneratedAt)}.` : `Snapshot updated ${snapshotAgeLabel}.`} ${
missionMode?.reason ? missionMode.reason.replaceAll('_', ' ') : snapshotReason
}`}
Snapshot updated{' '}
{statsGeneratedAt ? (
<ClientRelativeTime value={statsGeneratedAt} suffix="." />
) : checkedAt ? (
<ClientRelativeTime value={checkedAt} suffix="." />
) : (
'Unknown.'
)}{' '}
{missionMode?.reason ? missionMode.reason.replaceAll('_', ' ') : snapshotReason}
</div>
<div className="mt-2 text-xs opacity-75">
{snapshotScope}
@@ -722,7 +765,15 @@ export default function Home({
</div>
<EntityBadge label={resolveRelaySeverityLabel(item.status, item.tone)} tone={resolveRelayBadgeTone(item.status, item.tone)} />
</div>
<p className="mt-3 text-sm leading-6 opacity-90">{item.text}</p>
<p className="mt-3 text-sm leading-6 opacity-90">
{item.text}
{item.polledAt ? (
<>
{' '}
· polled <ClientRelativeTime value={item.polledAt} suffix=" ago" />
</>
) : null}
</p>
<p className="mt-2 text-xs opacity-75">
{getLaneImpactNote(item.key, resolveRelaySeverityLabel(item.status, item.tone))}
</p>
@@ -776,7 +827,7 @@ export default function Home({
{(relaySummary || bridgeStatus || stats) && (
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-gray-200 bg-white/80 px-4 py-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-950/60">
<EntityBadge label={`block ${latestBlock != null ? latestBlock.toLocaleString() : 'unknown'}`} tone="info" />
<EntityBadge label={`block ${latestBlock != null ? formatInteger(latestBlock) : 'unknown'}`} tone="info" />
{chainStatus?.status ? <EntityBadge label={`chain ${chainStatus.status}`} tone={chainStatus.status === 'operational' ? 'success' : 'warning'} /> : null}
{relaySummary ? <EntityBadge label={`${relayOperationalCount} relays ok`} tone="success" /> : null}
<EntityBadge label={relayFeedState === 'live' ? 'live feed' : relayFeedState === 'fallback' ? 'snapshot feed' : 'connecting'} tone={relayFeedState === 'live' ? 'success' : 'info'} />
@@ -785,35 +836,27 @@ export default function Home({
)}
{stats && (
<div className="mb-6 space-y-4">
<p className="text-sm font-semibold text-gray-900 dark:text-white">Network overview</p>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="mb-4 space-y-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
{primaryMetricCards.map((card) => (
<Card key={card.label}>
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
</Card>
<span key={card.label}>
<span className="text-gray-500 dark:text-gray-400">{card.label}:</span>{' '}
<span className="font-medium text-gray-900 dark:text-white">{card.value}</span>
</span>
))}
</div>
<MissionDeliveryModePanel className="mt-1" mode={missionMode} title="Homepage delivery mode" />
<ActivityContextPanel
context={activityContext}
title="Freshness Interpretation"
compact
/>
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel={
mode === 'guided'
? 'Homepage status combines chain freshness, transaction visibility, and mission-control posture.'
: 'Homepage freshness view aligns chain, transaction, and mission-control posture.'
}
/>
<DisclosureSection title="Freshness & delivery" defaultOpen={false} alwaysCollapsible>
<MissionDeliveryModePanel className="mt-1" mode={missionMode} title="Homepage delivery mode" />
<ActivityContextPanel context={activityContext} title="Freshness interpretation" compact />
<FreshnessTrustNote
className="mt-2"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel="Homepage chain, transaction, and mission-control posture."
/>
</DisclosureSection>
<button
type="button"
@@ -873,168 +916,165 @@ export default function Home({
</div>
)}
{featuredPrices.length > 0 ? (
<div className="mb-8">
<Card title="Live Price Feed">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{featuredPrices.map((token) => (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-800 dark:bg-gray-900/40"
>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{visibleFeaturedPrices.length > 0 ? (
<DisclosureSection title="Live price feed" defaultOpen={false} alwaysCollapsible className="mb-4">
<div className="grid gap-3 sm:grid-cols-2">
{visibleFeaturedPrices.map((token) => (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2.5 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-800 dark:bg-gray-900/40"
>
<div className="flex items-baseline justify-between gap-2">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{token.symbol || token.name || 'Token'}
</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{formatUsd(token.market?.priceUsd)}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Visible liquidity: {formatUsd(token.market?.liquidityUsd)}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{token.market?.lastUpdated ? `Updated ${formatRelativeAge(token.market.lastUpdated)}` : 'Update time unavailable'}
</div>
<MarketEvidenceNote lastUpdated={token.market?.lastUpdated} compact />
</Link>
))}
</div>
</Card>
</div>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Liq {formatUsd(token.market?.liquidityUsd)}
{token.market?.lastUpdated ? (
<>
{' '}
· <ClientRelativeTime value={token.market.lastUpdated} suffix=" ago" />
</>
) : null}
</div>
</Link>
))}
</div>
</DisclosureSection>
) : null}
{!stats && (
<Card className="mb-8">
<Card className="mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Live network stats are temporarily unavailable. Recent blocks and explorer tools are still available below.
</p>
</Card>
)}
<div className="mb-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="Recent Transactions">
{recentTransactions.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent transactions are unavailable right now.
</p>
) : (
<div className="space-y-2">
{recentTransactions.map((transaction) => (
<div key={transaction.hash} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link href={`/transactions/${transaction.hash}`} className="text-primary-600 hover:underline">
{transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
</Link>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Block #{transaction.block_number} · from{' '}
<Address address={transaction.from_address} truncate showCopy={false} />
{transaction.to_address ? (
<>
{' '} <Address address={transaction.to_address} truncate showCopy={false} />
</>
) : null}
<div className="mb-4">
<SectionTabs
tabs={homeActivityTabs}
activeTab={homeActivityTab}
onChange={setHomeActivityTab}
idPrefix="home-activity"
ariaLabel="Recent chain activity"
className="-mx-4 mb-3 px-4"
/>
<TabPanel idPrefix="home-activity" tabId="transactions" activeTab={homeActivityTab}>
<Card title="Recent transactions">
{visibleRecentTransactions.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent transactions are unavailable right now.
</p>
) : (
<div className="space-y-2">
{visibleRecentTransactions.map((transaction) => (
<div key={transaction.hash} className="flex flex-col gap-1 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link href={`/transactions/${transaction.hash}`} className="text-primary-600 hover:underline">
{transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
</Link>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
Block #{transaction.block_number} · {formatWeiAsEth(transaction.value, 4)}
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 sm:text-right">
{formatTimestamp(transaction.created_at)}
</div>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
<div>{formatWeiAsEth(transaction.value, 4)}</div>
<div className="text-xs">{formatTimestamp(transaction.created_at)}</div>
</div>
</div>
))}
</div>
)}
<div className="mt-4">
<Link href="/transactions" className="text-primary-600 hover:underline">
View all transactions
</Link>
</div>
</Card>
<Card title="Recent Blocks">
{recentBlocks.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent blocks are unavailable right now.
</p>
) : (
<div className="space-y-2">
{shouldExplainEmptyHeadBlocks(recentBlocks, activityContext) ? (
<p className="rounded-xl border border-amber-200 bg-amber-50/70 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
</p>
) : null}
{recentBlocks.map((block) => (
<div key={block.number} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link href={`/blocks/${block.number}`} className="text-primary-600 hover:underline">
Block #{block.number}
</Link>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Mined by{' '}
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
{block.miner.slice(0, 10)}...{block.miner.slice(-6)}
</Link>
</div>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
<div>{block.transaction_count} transactions</div>
<div className="text-xs">{formatTimestamp(block.timestamp)}</div>
</div>
))}
</div>
))}
</div>
)}
<div className="mt-4">
<Link href="/blocks" className="text-primary-600 hover:underline">
View all blocks
</Link>
</div>
</Card>
)}
<div className="mt-3">
<Link href="/transactions" className="text-sm text-primary-600 hover:underline">
All transactions
</Link>
</div>
</Card>
</TabPanel>
<TabPanel idPrefix="home-activity" tabId="blocks" activeTab={homeActivityTab}>
<Card title="Recent blocks">
{visibleRecentBlocks.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent blocks are unavailable right now.
</p>
) : (
<div className="space-y-2">
{shouldExplainEmptyHeadBlocks(visibleRecentBlocks, activityContext) ? (
<p className="rounded-lg border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
Recent head blocks are empty; use the latest transaction block for visible activity.
</p>
) : null}
{visibleRecentBlocks.map((block) => (
<div key={block.number} className="flex flex-col gap-1 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link href={`/blocks/${block.number}`} className="text-primary-600 hover:underline">
Block #{block.number}
</Link>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{block.transaction_count} txs ·{' '}
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
{block.miner.slice(0, 8)}{block.miner.slice(-4)}
</Link>
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 sm:text-right">
{formatTimestamp(block.timestamp)}
</div>
</div>
))}
</div>
)}
<div className="mt-3">
<Link href="/blocks" className="text-sm text-primary-600 hover:underline">
All blocks
</Link>
</div>
</Card>
</TabPanel>
</div>
<Card title="Institutional quick paths" className="mt-8">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Canonical discovery, compliance, and liquidity surfaces for institutional users mesh tokens, official
protocols, curated pools, and public JSON APIs.
<DisclosureSection title="Institutional quick paths" defaultOpen={false} alwaysCollapsible className="mt-4">
<p className="mb-3 text-sm text-gray-600 dark:text-gray-400">
Discovery, compliance, and liquidity surfaces for institutional users.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Link href="/search?q=cWUSDC" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Mesh search</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">c* cW* across chains</div>
<div className="flex flex-wrap gap-2 text-sm">
<Link href="/search?q=cWUSDC" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
Mesh search
</Link>
<Link href="/protocols" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Official protocols</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Upstream contracts + guardrails</div>
<Link href="/protocols" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
Protocols
</Link>
<Link href="/pools" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Pool registry</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Live DODO PMM + UniV2 TVL</div>
<Link href="/pools" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
Pools
</Link>
<Link href="/wallet" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Wallet & MetaMask</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">13-chain token import</div>
<Link href="/wallet" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
Wallet
</Link>
<Link href="/liquidity" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Liquidity tools</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">LP policy bridge underlying, not LP</div>
<Link href="/liquidity" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
Liquidity
</Link>
<Link href="/token-aggregation/api/v1/report/official-protocols" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Public JSON APIs</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Protocols, pools, routes</div>
<Link href="/token-aggregation/api/v1/report/official-protocols" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
Public APIs
</Link>
<Link href="/search" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Search</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Address, tx, mesh, pools</div>
<Link href="/search" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
Search
</Link>
<Link href="/docs" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Documentation</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">GRU, APIs, operations</div>
<Link href="/docs" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
Docs
</Link>
<Link href="/bridge" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Bridge</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Relay posture + CCIP lanes</div>
<Link href="/bridge" className="rounded-lg border border-gray-200 px-3 py-1.5 text-primary-600 hover:border-primary-300 dark:border-gray-800">
Bridge
</Link>
</div>
</Card>
</DisclosureSection>
</main>
)

View File

@@ -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<CuratedPoolRegistryEntry[]>([])
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 (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<OperationsSurfaceNav />
<PageIntro
compact
eyebrow="Curated pool registry"
title={loading ? 'Loading pool…' : pool ? pairLabel : 'Pool not in registry'}
description={
mode === 'guided'
? 'Chain-local liquidity pool from the curated DODO PMM + UniV2 registry. LP receipt tokens are not bridgeable — bridge underlying c*/cW* instead.'
: 'Curated pool detail · LP is chain-local only.'
}
description="DODO PMM + UniV2 registry · LP receipts are chain-local only."
actions={[
{ href: '/liquidity', label: 'Liquidity tools' },
{ href: '/liquidity', label: 'Liquidity' },
{ href: '/pools', label: 'All pools' },
{ href: '/wallet', label: 'Scan LP positions' },
]}
/>
@@ -142,21 +140,36 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
) : null}
{pool ? (
<div className="space-y-6">
<>
{asymmetryNote ? (
<Card title="Reserve asymmetry notice">
<p
className={
asymmetryNote.severity === 'warning'
? 'text-sm text-amber-800 dark:text-amber-200'
: 'text-sm text-gray-700 dark:text-gray-300'
}
>
{asymmetryNote.message}
</p>
</Card>
<DisclosureSection title="Reserve asymmetry notice" defaultOpen={false} alwaysCollapsible className="mb-4">
<Card>
<p
className={
asymmetryNote.severity === 'warning'
? 'text-sm text-amber-800 dark:text-amber-200'
: 'text-sm text-gray-700 dark:text-gray-300'
}
>
{asymmetryNote.message}
</p>
</Card>
</DisclosureSection>
) : null}
<SectionTabs
idPrefix="pool-detail"
ariaLabel="Pool detail sections"
tabs={[
{ id: 'summary', label: 'Summary' },
{ id: 'lp', label: 'Your LP' },
]}
activeTab={poolTab}
onChange={setPoolTab}
className="mb-4"
/>
<TabPanel idPrefix="pool-detail" tabId="summary" activeTab={poolTab}>
<Card title="Pool summary">
<div className="flex flex-wrap gap-2">
<EntityBadge label={pool.venue.replace('_', ' ')} tone="info" />
@@ -229,7 +242,7 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
{pool.liquiditySource ?? 'curated registry'}
{pool.liquidityAsOf ? (
<span className="block text-xs text-gray-500 dark:text-gray-400">
As of {new Date(pool.liquidityAsOf).toLocaleString()}
As of {formatTimestamp(pool.liquidityAsOf)}
</span>
) : null}
</dd>
@@ -242,14 +255,17 @@ export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
</div>
</dl>
</Card>
</TabPanel>
<TabPanel idPrefix="pool-detail" tabId="lp" activeTab={poolTab}>
<LpPositionPanel
chainId={pool.chainId}
title="Your LP in this pool"
compact
hintAddresses={[pool.poolAddress, pool.lpTokenAddress]}
/>
</div>
</TabPanel>
</>
) : null}
</div>
)

View File

@@ -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<AddToMetaMaskTab>[] = [
{ 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<string | null>(null)
const [pendingWatchFlow, setPendingWatchFlow] = useState<PendingWatchFlow | null>(null)
const [providerTick, setProviderTick] = useState(0)
const [activeTab, setActiveTab] = useState<AddToMetaMaskTab>('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 (
<div className="space-y-4 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
<div className="space-y-3 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
<h2 className="text-lg font-semibold">Add to MetaMask</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
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.
</p>
<MobileWalletContextBanner hasProvider={hasWalletProvider} />
<SectionTabs
tabs={addToMetaMaskTabs.map((tab) =>
tab.id === 'tokens' ? { ...tab, count: featuredTokens.length } : tab,
)}
activeTab={activeTab}
onChange={setActiveTab}
idPrefix="add-to-metamask"
ariaLabel="Wallet setup sections"
/>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() =>
void switchOrAddChain(chains.chain138).then((ok) => {
if (ok) setStatus('Switched to DeFi Oracle Meta Mainnet. Native ETH and imported tokens show on this network.')
})
}
className={`rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Switch to Chain 138
</button>
<button
type="button"
onClick={() => addChain(chains.chain138)}
className={`rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add Chain 138
</button>
<button
type="button"
onClick={() => addChain(chains.ethereum)}
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add Ethereum Mainnet
</button>
<button
type="button"
onClick={() => addChain(chains.allMainnet)}
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add ALL Mainnet
</button>
</div>
<TabPanel idPrefix="add-to-metamask" tabId="networks" activeTab={activeTab}>
<MobileWalletContextBanner hasProvider={hasWalletProvider} />
<div className="rounded-lg border border-amber-200 bg-amber-50/50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Optional Chain 138 Open Snap</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
This is <span className="font-medium text-gray-800 dark:text-gray-200">not required</span> 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{' '}
<span className="font-medium text-gray-800 dark:text-gray-200">only open Snap permissions</span> (minimal
privileged APIs in the Snap itself).{' '}
<span className="font-medium text-gray-800 dark:text-gray-200">Stable MetaMask</span> still only installs npm
Snaps that appear on MetaMask&apos;s install allowlist; if install fails with &quot;not on the allowlist&quot;, that is
an external MetaMask review gate rather than an explorer/network failure. Use{' '}
<span className="font-medium text-gray-800 dark:text-gray-200">MetaMask Flask</span> for development or apply
for allowlisting before using this with Stable MetaMask. The package on npm is{' '}
<code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">{CHAIN138_OPEN_SNAP_ID}</code>
publish from the repo with <code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">scripts/deployment/publish-chain138-open-snap.sh</code> after{' '}
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">npm login</code>.
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() =>
void switchOrAddChain(chains.chain138).then((ok) => {
if (ok) setStatus('Switched to DeFi Oracle Meta Mainnet. Native ETH and imported tokens show on this network.')
})
}
className={`rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Switch to Chain 138
</button>
<button
type="button"
onClick={() => addChain(chains.chain138)}
className={`rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add Chain 138
</button>
<button
type="button"
onClick={() => addChain(chains.ethereum)}
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add Ethereum Mainnet
</button>
<button
type="button"
onClick={() => addChain(chains.allMainnet)}
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add ALL Mainnet
</button>
</div>
<DisclosureSection title="Optional Chain 138 Open Snap" alwaysCollapsible className="mt-3">
<p className="text-sm text-gray-600 dark:text-gray-400">
Not required for production wallet setup. Use MetaMask Flask or an allowlisted Stable build. Package:{' '}
<code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">{CHAIN138_OPEN_SNAP_ID}</code>
</p>
<button
type="button"
onClick={() => void installOpenSnap()}
className="mt-3 rounded bg-amber-700 px-4 py-2 text-sm font-medium text-white hover:bg-amber-800"
>
Install Snap (Flask or allowlisted Stable)
</button>
</DisclosureSection>
</TabPanel>
<TabPanel idPrefix="add-to-metamask" tabId="other-chains" activeTab={activeTab}>
<p className="text-sm text-gray-600 dark:text-gray-400">
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}
</p>
<button
type="button"
onClick={() => void installOpenSnap()}
className="mt-3 rounded bg-amber-700 px-4 py-2 text-sm font-medium text-white hover:bg-amber-800"
>
Install Snap (Flask or allowlisted Stable)
</button>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Explorer-served MetaMask metadata</div>
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<p>Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}</p>
<p>Chain 138 token entries: {tokenCount138}</p>
<p>EIP-747 watchAsset entries: {watchAssetTokens.length}</p>
<p>Networks source: {networksMeta?.source || 'unknown'}</p>
<p>Token list source: {tokenListMeta?.source || 'unknown'}</p>
<p>MetaMask payload source: {metamaskConfigMeta?.source || 'unknown'}</p>
{metadataKeywordString ? <p>Keywords: {metadataKeywordString}</p> : null}
</div>
<div className="mt-4 space-y-3">
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Networks config URL</p>
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{networksUrl}</code>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => copyText(networksUrl, 'networks config URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Copy URL
</button>
<a href={networksUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Open JSON
</a>
</div>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Capabilities URL</p>
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{displayedCapabilitiesUrl}</code>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => copyText(displayedCapabilitiesUrl, 'capabilities URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Copy URL
</button>
<a href={displayedCapabilitiesUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Open JSON
</a>
</div>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">EIP-747 MetaMask payload URL</p>
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{metamaskConfigUrl}</code>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => copyText(metamaskConfigUrl, 'MetaMask payload URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Copy URL
</button>
<a href={metamaskConfigUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Open JSON
</a>
</div>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token list URL</p>
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{tokenListUrl}</code>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => copyText(tokenListUrl, 'token list URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Copy URL
</button>
<a href={tokenListUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Open JSON
</a>
</div>
</div>
</div>
<div className="mt-3">
<MultiChainWalletImport />
</div>
</TabPanel>
<TabPanel idPrefix="add-to-metamask" tabId="tokens" activeTab={activeTab}>
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Chain 138 RPC capabilities</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
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.
</p>
<div className="mt-4 space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
RPC endpoint:{' '}
<span className="font-medium text-gray-900 dark:text-white">
{capabilities?.rpcUrl || 'using published explorer fallback'}
</span>
<DisclosureSection title="Import help" alwaysCollapsible className="mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Bulk add switches to DeFi Oracle Meta Mainnet first. Use{' '}
<span className="font-medium">Add tokens with balance only</span> to skip zero-balance catalog entries.
MetaMask keeps one row per token symbol on a network.
</p>
<p>
Capabilities source:{' '}
<span className="font-medium text-gray-900 dark:text-white">
{capabilitiesMeta?.source || 'unknown'}
</span>
</p>
<p>
HTTP methods: {supportedHTTPMethods.length > 0 ? supportedHTTPMethods.join(', ') : 'metadata unavailable'}
</p>
<p>
Missing wallet-facing methods:{' '}
{unsupportedHTTPMethods.length > 0 ? unsupportedHTTPMethods.join(', ') : 'none listed'}
</p>
<p>
Trace methods: {supportedTraceMethods.length > 0 ? supportedTraceMethods.join(', ') : 'metadata unavailable'}
</p>
{capabilities?.walletSupport?.notes?.map((note) => (
<p key={note} className="text-xs">
{note}
</p>
))}
{capabilities?.http?.notes?.map((note) => (
<p key={note} className="text-xs">
{note}
</p>
))}
{capabilitiesMeta?.lastModified ? (
<p className="text-xs">
Last modified: {formatStableTimestamp(capabilitiesMeta.lastModified)}
</p>
) : null}
</div>
</div>
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Featured Chain 138 tokens</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
These tokens come from the explorer MetaMask payload and use wallet_watchAsset so the wallet gets the same
symbol, decimals, image, and optional token metadata that the explorer publishes. MetaMask requires a user
approval for each token, so the bulk actions below run as a guided sequence of wallet prompts. Bulk add
switches to DeFi Oracle Meta Mainnet first tokens imported while on another network will not show
balances on Chain 138. Native ETH is not added via this flow; it appears when you are on Chain 138 with a
working RPC. Use <span className="font-medium">Add tokens with balance only</span> to skip zero-balance and
undeployed catalog entries.
{mobileWalletContext
? ' On mobile, imports run two wallet prompts per tap — use Continue until finished.'
: null}
</p>
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50/80 p-3 text-sm leading-6 text-amber-950 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
MetaMask keeps <span className="font-medium">one row per token symbol</span> on a network. If a balance
disappears after import, remove duplicate custom tokens (especially second <code className="text-xs">cUSDC</code>{' '}
/ <code className="text-xs">cUSDT</code> rows or old gas placeholders like{' '}
{CHAIN138_PLACEHOLDER_GAS_SYMBOLS.slice(0, 3).join(', ')}), stay on{' '}
<span className="font-medium">DeFi Oracle Meta Mainnet</span>, then tap{' '}
<span className="font-medium">Add tokens with balance only</span> or{' '}
<span className="font-medium">Add featured tokens</span>.
</p>
<div className="mt-4 flex flex-wrap gap-2">
</DisclosureSection>
<div className="flex flex-wrap gap-2">
{pendingWatchFlow ? (
<button
type="button"
@@ -1231,74 +1160,193 @@ export function AddToMetaMask({
loading={fundedListingLoading}
error={fundedListingError}
/>
<ListFilterBar
className="mt-4"
query={tokenQuery}
onQueryChange={setTokenQuery}
placeholder="Filter featured tokens by symbol, name, or address"
resultCount={filteredFeaturedTokens.length}
totalCount={featuredTokens.length}
/>
<div className="mt-4 space-y-3">
{featuredTokens.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Featured token metadata is not available right now.</p>
) : featuredTokens.map((token) => (
<div key={token.address} className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{token.symbol} <span className="font-normal text-gray-500 dark:text-gray-400">({token.name})</span>
) : filteredFeaturedTokens.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">No featured tokens match your filter.</p>
) : (
paginatedFeaturedTokens.map((token) => (
<div key={token.address} className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{token.symbol} <span className="font-normal text-gray-500 dark:text-gray-400">({token.name})</span>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{token.address}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Decimals: {token.decimals}
{token.tags?.length ? ` • Tags: ${token.tags.join(', ')}` : ''}
</div>
{typeof token.extensions?.unitDescription === 'string' ? (
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{token.extensions.unitDescription}</div>
) : null}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{token.address}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Decimals: {token.decimals}
{token.tags?.length ? ` • Tags: ${token.tags.join(', ')}` : ''}
</div>
{typeof token.extensions?.unitDescription === 'string' ? (
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{token.extensions.unitDescription}</div>
) : null}
<button
type="button"
onClick={() => watchToken(token)}
className={`rounded bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add {token.symbol}
</button>
</div>
<button
type="button"
onClick={() => watchToken(token)}
className={`rounded bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add {token.symbol}
</button>
</div>
</div>
))}
))
)}
</div>
<PaginationControls
page={tokenPage}
pageCount={featuredTokenPageCount}
onPageChange={setTokenPage}
label="Featured tokens"
ariaLabel="Featured token pagination"
/>
</div>
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Ethereum Mainnet cWUSDC</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
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.
</p>
<div className="mt-4 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<DisclosureSection title="Ethereum Mainnet cWUSDC" alwaysCollapsible className="mt-4">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{MAINNET_CWUSDC_TOKEN.symbol}{' '}
<span className="font-normal text-gray-500 dark:text-gray-400">({MAINNET_CWUSDC_TOKEN.name})</span>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{MAINNET_CWUSDC_TOKEN.address}</div>
</div>
<button
type="button"
onClick={() => void refreshMainnetCwusdc()}
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
>
Refresh Mainnet cWUSDC
</button>
</div>
</DisclosureSection>
</TabPanel>
<TabPanel idPrefix="add-to-metamask" tabId="advanced" activeTab={activeTab}>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Explorer-served MetaMask metadata</div>
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<p>Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}</p>
<p>Chain 138 token entries: {tokenCount138}</p>
<p>EIP-747 watchAsset entries: {watchAssetTokens.length}</p>
<p>Networks source: {networksMeta?.source || 'unknown'}</p>
<p>Token list source: {tokenListMeta?.source || 'unknown'}</p>
<p>MetaMask payload source: {metamaskConfigMeta?.source || 'unknown'}</p>
{metadataKeywordString ? <p>Keywords: {metadataKeywordString}</p> : null}
</div>
<div className="mt-4 space-y-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{MAINNET_CWUSDC_TOKEN.symbol}{' '}
<span className="font-normal text-gray-500 dark:text-gray-400">({MAINNET_CWUSDC_TOKEN.name})</span>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{MAINNET_CWUSDC_TOKEN.address}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Ethereum Mainnet Decimals: {MAINNET_CWUSDC_TOKEN.decimals}
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Networks config URL</p>
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{networksUrl}</code>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => copyText(networksUrl, 'networks config URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Copy URL
</button>
<a href={networksUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Open JSON
</a>
</div>
</div>
<button
type="button"
onClick={() => void refreshMainnetCwusdc()}
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
>
Refresh Mainnet cWUSDC
</button>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Capabilities URL</p>
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{displayedCapabilitiesUrl}</code>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => copyText(displayedCapabilitiesUrl, 'capabilities URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Copy URL
</button>
<a href={displayedCapabilitiesUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Open JSON
</a>
</div>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">EIP-747 MetaMask payload URL</p>
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{metamaskConfigUrl}</code>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => copyText(metamaskConfigUrl, 'MetaMask payload URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Copy URL
</button>
<a href={metamaskConfigUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Open JSON
</a>
</div>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token list URL</p>
<code className="block break-all rounded bg-gray-100 p-2 text-xs dark:bg-gray-900">{tokenListUrl}</code>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => copyText(tokenListUrl, 'token list URL')} className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Copy URL
</button>
<a href={tokenListUrl} target="_blank" rel="noopener noreferrer" className="rounded bg-gray-100 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700">
Open JSON
</a>
</div>
</div>
</div>
</div>
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Chain 138 RPC capabilities</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
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.
</p>
<div className="mt-4 space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
RPC endpoint:{' '}
<span className="font-medium text-gray-900 dark:text-white">
{capabilities?.rpcUrl || 'using published explorer fallback'}
</span>
</p>
<p>
Capabilities source:{' '}
<span className="font-medium text-gray-900 dark:text-white">
{capabilitiesMeta?.source || 'unknown'}
</span>
</p>
<p>
HTTP methods: {supportedHTTPMethods.length > 0 ? supportedHTTPMethods.join(', ') : 'metadata unavailable'}
</p>
<p>
Missing wallet-facing methods:{' '}
{unsupportedHTTPMethods.length > 0 ? unsupportedHTTPMethods.join(', ') : 'none listed'}
</p>
<p>
Trace methods: {supportedTraceMethods.length > 0 ? supportedTraceMethods.join(', ') : 'metadata unavailable'}
</p>
{capabilities?.walletSupport?.notes?.map((note) => (
<p key={note} className="text-xs">
{note}
</p>
))}
{capabilities?.http?.notes?.map((note) => (
<p key={note} className="text-xs">
{note}
</p>
))}
{capabilitiesMeta?.lastModified ? (
<p className="text-xs">
Last modified: {formatStableTimestamp(capabilitiesMeta.lastModified)}
</p>
) : null}
</div>
</div>
</div>
</div>
</TabPanel>
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
<MultiChainWalletImport />
</div>
)
}

View File

@@ -0,0 +1,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<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [chainNetwork, setChainNetwork] = useState<WalletChain | null>(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 (
<div className={`space-y-2 ${className}`}>
<div className="flex flex-wrap gap-2">
{chainId === 138 ? (
<button
type="button"
onClick={() => void addChainOnly()}
className={`${buttonClass} ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add Chain 138
</button>
) : null}
<button
type="button"
onClick={() => void addToken()}
className={`${buttonClass} ${MOBILE_WALLET_BUTTON_CLASS}`}
aria-label={name ? `Add ${name} to wallet` : `Add ${symbol} to wallet`}
>
Add {symbol} to wallet
</button>
</div>
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
</div>
)
}

View File

@@ -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<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState<{ current: number; total: number; chainId: number } | null>(null)
const [chains, setChains] = useState<ChainImportState[]>([])
const [pendingFlow, setPendingFlow] = useState<PendingMultiChainFlow | null>(null)
const [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 (
<div className="mt-6 space-y-4 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
<h2 className="text-lg font-semibold">Multi-chain token import</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Add canonical tokens on other supported networks. Each chain switches your wallet first, then runs sequential
EIP-747 <code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">wallet_watchAsset</code> prompts.
{mobileWalletContext ? ' On mobile, two prompts run per tap — use Continue on the chain card.' : null}
</p>
<div className="space-y-3">
<ListFilterBar
query={chainQuery}
onQueryChange={setChainQuery}
placeholder="Filter chains by name or chain ID"
resultCount={visibleChains.length}
totalCount={chains.length}
/>
{pendingFlow ? (
{!chainQuery.trim() && !showAllChains && hiddenChainCount > 0 ? (
<button
type="button"
onClick={() => void continuePendingFlow()}
className={`rounded bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
onClick={() => setShowAllChains(true)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-200"
>
Continue {chainLabel(pendingFlow.chainId)} ({pendingFlow.totalAdded} added, {pendingFlow.tokens.length - pendingFlow.nextIndex} left)
Show {hiddenChainCount} more chain{hiddenChainCount === 1 ? '' : 's'}
</button>
) : null}
<div className="grid gap-4 xl:grid-cols-2">
{chains.map((row) => {
<div className="space-y-2">
{visibleChains.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">No chains match your filter.</p>
) : (
visibleChains.map((row) => {
const featured = featuredByChain.get(row.chainId) ?? []
return (
<div key={row.chainId} className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">{chainLabel(row.chainId)}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{row.loading
? 'Loading token metadata…'
: row.error
? row.error
: `${row.tokens.length} canonical tokens · ${featured.length} featured`}
</div>
<DisclosureSection
key={row.chainId}
title={`${chainLabel(row.chainId)} · ${row.loading ? '…' : row.tokens.length} tokens`}
defaultOpen={row.chainId === 138}
alwaysCollapsible
className="rounded-lg border border-gray-200 px-3 py-2 dark:border-gray-700"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-gray-500 dark:text-gray-400">
{row.loading
? 'Loading token metadata…'
: row.error
? row.error
: `${featured.length} featured · ${row.tokens.length} canonical`}
</div>
<div className="flex flex-wrap gap-2">
<button
@@ -320,15 +349,26 @@ export default function MultiChainWalletImport() {
</div>
</div>
{progress?.chainId === row.chainId ? (
<div className="mt-3 text-xs text-gray-600 dark:text-gray-400">
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
Prompt {progress.current} of {progress.total}
</div>
) : null}
</div>
</DisclosureSection>
)
})}
})
)}
</div>
{pendingFlow ? (
<button
type="button"
onClick={() => void continuePendingFlow()}
className={`rounded bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Continue {chainLabel(pendingFlow.chainId)} ({pendingFlow.totalAdded} added, {pendingFlow.tokens.length - pendingFlow.nextIndex} left)
</button>
) : null}
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
</div>

View File

@@ -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<WalletAccessSession | null>(null)
@@ -60,6 +67,30 @@ export default function WalletPage(props: WalletPageProps) {
const [recentAddressTransactions, setRecentAddressTransactions] = useState<TransactionSummary[]>([])
const [tokenBalances, setTokenBalances] = useState<AddressTokenBalance[]>([])
const [tokenTransfers, setTokenTransfers] = useState<AddressTokenTransfer[]>([])
const [activeTab, setActiveTab] = useState<WalletPageTab>('connect')
const walletTabs = useMemo<SectionTab<WalletPageTab>[]>(() => {
const tabs: SectionTab<WalletPageTab>[] = [
{ 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 (
<main className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-4 text-2xl font-bold sm:text-3xl">Wallet Tools</h1>
<p className="mb-6 text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base">
{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.'}
</p>
<WalletConnectPostureNote />
<div className="mb-6 rounded-2xl border border-sky-200 bg-sky-50/60 p-5 dark:border-sky-900/40 dark:bg-sky-950/20">
<main className="container mx-auto px-4 py-5 sm:py-6">
<PageIntro
compact
eyebrow="Chain 138"
title="Wallet Tools"
description={
mode === 'guided'
? 'Connect Chain 138 and Ethereum Mainnet, import tokens, and review your connected wallet from one place.'
: 'Explorer-served network and token metadata for Chain 138 wallet setup.'
}
actions={[
{ href: '/liquidity', label: 'Liquidity' },
{ href: '/routes', label: 'Routes' },
]}
/>
<SectionTabs
tabs={walletTabs}
activeTab={activeTab}
onChange={setActiveTab}
idPrefix="wallet-page"
ariaLabel="Wallet tools sections"
className="-mx-4 mb-4 px-4"
/>
<TabPanel idPrefix="wallet-page" tabId="connect" activeTab={activeTab}>
<DisclosureSection title="WalletConnect status" alwaysCollapsible className="mb-3">
<WalletConnectPostureNote />
</DisclosureSection>
<div className="rounded-2xl border border-sky-200 bg-sky-50/60 p-4 dark:border-sky-900/40 dark:bg-sky-950/20">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-lg font-semibold text-gray-900 dark:text-white">Wallet session</div>
@@ -261,7 +313,7 @@ export default function WalletPage(props: WalletPageProps) {
</div>
{walletSession?.expiresAt ? (
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Session expires {new Date(walletSession.expiresAt).toLocaleString()}
Session expires {formatTimestamp(walletSession.expiresAt)}
</div>
) : null}
</div>
@@ -355,9 +407,12 @@ export default function WalletPage(props: WalletPageProps) {
) : null}
</div>
</div>
</div>
</TabPanel>
<TabPanel idPrefix="wallet-page" tabId="snapshot" activeTab={activeTab}>
{walletSession ? (
<div className="mt-4 rounded-2xl border border-white/60 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
<div className="rounded-2xl border border-sky-200 bg-sky-50/60 p-5 dark:border-sky-900/40 dark:bg-sky-950/20">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
@@ -374,32 +429,15 @@ export default function WalletPage(props: WalletPageProps) {
</Link>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Transactions</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{addressInfo ? addressInfo.transaction_count.toLocaleString() : 'Unknown'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token Holdings</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{addressInfo ? addressInfo.token_count.toLocaleString() : 'Unknown'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Address Type</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{addressInfo ? (addressInfo.is_contract ? 'Contract' : 'EOA') : 'Unknown'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent Indexed Tx</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{recentAddressTransactions[0] ? `#${recentAddressTransactions[0].block_number}` : 'None visible'}
</div>
</div>
</div>
<CompactMetricBar
className="mt-4"
metrics={[
{ label: 'Tx', value: addressInfo ? formatInteger(addressInfo.transaction_count) : '—' },
{ label: 'Tokens', value: addressInfo ? formatInteger(addressInfo.token_count) : '' },
{ label: 'Type', value: addressInfo ? (addressInfo.is_contract ? 'Contract' : 'EOA') : '—' },
{ label: 'Latest block', value: recentAddressTransactions[0] ? `#${recentAddressTransactions[0].block_number}` : '—' },
]}
/>
<div className="mt-4 grid gap-3 lg:grid-cols-3">
{recentAddressTransactions.length === 0 ? (
@@ -407,7 +445,7 @@ export default function WalletPage(props: WalletPageProps) {
No recent indexed transactions are currently visible for this connected wallet.
</div>
) : (
recentAddressTransactions.map((transaction) => (
recentAddressTransactions.slice(0, 3).map((transaction) => (
<Link
key={transaction.hash}
href={`/transactions/${transaction.hash}`}
@@ -417,7 +455,7 @@ export default function WalletPage(props: WalletPageProps) {
{transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
</div>
<div className="mt-1 text-gray-600 dark:text-gray-400">
Block #{transaction.block_number.toLocaleString()}
Block #{formatInteger(transaction.block_number)}
</div>
</Link>
))
@@ -448,7 +486,7 @@ export default function WalletPage(props: WalletPageProps) {
No indexed token balances are currently visible for this wallet.
</div>
) : (
tokenBalances.map((balance) => (
tokenBalances.slice(0, 5).map((balance) => (
<Link
key={balance.token_address}
href={`/tokens/${balance.token_address}`}
@@ -489,7 +527,7 @@ export default function WalletPage(props: WalletPageProps) {
No recent token transfers are currently visible for this connected wallet.
</div>
) : (
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)}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Counterparty: {counterparty.slice(0, 6)}...{counterparty.slice(-4)} · {formatRelativeAge(transfer.timestamp)}
Counterparty: {counterparty.slice(0, 6)}...{counterparty.slice(-4)} · <ClientRelativeTime value={transfer.timestamp} suffix=" ago" />
</div>
</div>
<div className="flex flex-wrap gap-3 text-sm">
@@ -535,29 +573,23 @@ export default function WalletPage(props: WalletPageProps) {
/>
</div>
</div>
) : null}
</div>
<AddToMetaMask {...props} />
<div className="mt-6 rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
<Explain>
<>
Need swap and liquidity discovery too? Visit the{' '}
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
Liquidity
</Link>{' '}
page for live Chain 138 pools, route matrix links, partner payload templates, and the internal fallback execution plan endpoints.
</>
</Explain>
{mode === 'expert' ? (
<>
Liquidity and planner posture lives on the{' '}
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
Liquidity
</Link>{' '}
surface.
</>
) : null}
</div>
) : (
<div className="rounded-2xl border border-gray-200 bg-white p-5 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
Connect a wallet on the Connect tab to see balances, transfers, and LP positions here.
</div>
)}
</TabPanel>
<TabPanel idPrefix="wallet-page" tabId="setup" activeTab={activeTab}>
<AddToMetaMask {...props} />
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
Need swap and liquidity discovery?{' '}
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
Open Liquidity
</Link>
.
</p>
</TabPanel>
</main>
)
}

View File

@@ -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 },
],
},
]

View File

@@ -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 (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Address Detail"
title={addressInfo?.label || 'Address'}
description="Inspect a Chain 138 address, move into related transactions, and save important counterparties into the shared explorer watchlist."
title={addressInfo?.label || registryDisplayName || serverRegistryLabel || 'Address'}
description={
registryDisplayName || serverRegistryLabel
? `${registryDisplayName || serverRegistryLabel} · Chain 138 balances, activity, and contract tools.`
: 'Chain 138 address detail, balances, transfers, and transactions.'
}
actions={[
{ href: '/addresses', label: 'All addresses' },
{ href: '/watchlist', label: 'Open watchlist' },
{ href: '/search', label: 'Search explorer' },
{ href: '/watchlist', label: 'Watchlist' },
]}
/>
<div className="mb-6 flex flex-wrap gap-3 text-sm">
<div className="mb-4 flex flex-wrap gap-3 text-sm">
<Link href="/addresses" className="text-primary-600 hover:underline">
Back to addresses
</Link>
@@ -849,10 +856,7 @@ export default function AddressDetailPage() {
ariaLabel="Address details"
/>
<div
{...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'contract', activeTab)}
className={resolvedAddressInfo.is_contract ? undefined : 'hidden'}
>
<TabPanel idPrefix={ADDRESS_DETAIL_TABS_ID} tabId="contract" activeTab={activeTab}>
{resolvedAddressInfo.is_contract ? (
<Card title="Contract Profile" className="mb-6">
<dl className="space-y-4">
@@ -1097,9 +1101,9 @@ export default function AddressDetailPage() {
) : null}
{gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
</div>
</TabPanel>
<div {...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'balances', activeTab)}>
<TabPanel idPrefix={ADDRESS_DETAIL_TABS_ID} tabId="balances" activeTab={activeTab}>
<Card title="Token Balances" className="mb-6">
{gruBalanceCount > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
@@ -1128,9 +1132,9 @@ export default function AddressDetailPage() {
</>
)}
</Card>
</div>
</TabPanel>
<div {...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'transfers', activeTab)}>
<TabPanel idPrefix={ADDRESS_DETAIL_TABS_ID} tabId="transfers" activeTab={activeTab}>
<Card title="Recent Token Transfers" className="mb-6">
{gruTransferCount > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
@@ -1162,9 +1166,9 @@ export default function AddressDetailPage() {
</>
)}
</Card>
</div>
</TabPanel>
<div {...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'transactions', activeTab)}>
<TabPanel idPrefix={ADDRESS_DETAIL_TABS_ID} tabId="transactions" activeTab={activeTab}>
<Card title="Transactions">
{activityLoading ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Loading recent transactions...</p>
@@ -1185,9 +1189,18 @@ export default function AddressDetailPage() {
</>
)}
</Card>
</div>
</TabPanel>
</>
)}
</div>
)
}
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,
},
}
}

View File

@@ -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<Transaction[]>(initialRecentTransactions)
const [latestBlocks, setLatestBlocks] = useState(initialLatestBlocks)
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [watchlist, setWatchlist] = useState<string[]>([])
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<SectionTab<'watchlist' | 'recent'>[]>(
() => [
{ 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 (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Address Discovery"
title="Addresses"
description="Open any Chain 138 address directly, revisit saved watchlist entries, or branch into recent activity discovered from indexed transactions."
description="Open any Chain 138 address, resolve ENS names, or browse watchlist and recent activity."
actions={[
{ href: '/watchlist', label: 'Open watchlist' },
{ href: '/transactions', label: 'Recent transactions' },
{ href: '/search', label: 'Search explorer' },
{ href: '/watchlist', label: 'Watchlist' },
{ href: '/search', label: 'Search' },
]}
/>
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Recent Address Activity Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={initialStats}
bridgeStatus={initialBridgeStatus}
scopeLabel="Recently active addresses are derived from the latest visible indexed transactions."
/>
</div>
<ExplorerFreshnessDisclosure
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
activityTitle="Recent activity"
scopeLabel="Recently active addresses come from indexed transactions."
/>
<Card className="mb-6" title="Open An Address">
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
<Card className="mb-4" title="Open address">
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 sm:flex-row">
<input
type="text"
value={query}
onChange={(event) => 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"
/>
<button
type="submit"
disabled={!canOpenAddressQuery(query) || opening}
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{opening ? 'Resolving…' : 'Open address'}
{opening ? 'Resolving…' : 'Open'}
</button>
</form>
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
Open any Chain 138 address or resolve a mainnet `.eth` name, then jump into your saved watchlist below.
</p>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card title="Saved Watchlist">
<SectionTabs
tabs={addressListTabs}
activeTab={listTab}
onChange={setListTab}
idPrefix="addresses-list"
ariaLabel="Address lists"
className="-mx-4 mb-3 px-4"
/>
<TabPanel idPrefix="addresses-list" tabId="watchlist" activeTab={listTab}>
<Card title="Saved watchlist">
{watchlist.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
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.
</p>
) : (
<div className="space-y-3">
<div className="space-y-2">
{watchlist.map((entry) => (
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
<Address address={entry} showCopy={false} />
</Link>
))}
<div className="pt-2">
<Link href="/watchlist" className="text-sm text-primary-600 hover:underline">
Open the full watchlist
</Link>
</div>
<Link href="/watchlist" className="inline-block pt-2 text-sm text-primary-600 hover:underline">
Full watchlist
</Link>
</div>
)}
</Card>
</TabPanel>
<Card title="Recently Active Addresses">
<TabPanel idPrefix="addresses-list" tabId="recent" activeTab={listTab}>
<Card title="Recently active">
{activeAddresses.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
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.
</p>
) : (
<div className="space-y-3">
<div className="space-y-2">
{activeAddresses.map((entry) => (
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
<Address address={entry} showCopy={false} />
@@ -244,37 +287,16 @@ export default function AddressesPage({
</div>
)}
</Card>
</div>
</TabPanel>
</div>
)
}
export const getServerSideProps: GetServerSideProps<AddressesPageProps> = 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<Record<string, unknown>>('/api/v2/stats').catch(() => null),
fetchPublicJson<MissionControlBridgeStatusResponse>('/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<AddressesPageProps> = async () => ({
props: {
initialRecentTransactions: [],
initialLatestBlocks: [],
initialStats: null,
initialBridgeStatus: null,
},
})

View File

@@ -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 <AnalyticsOperationsPage {...props} />
}
export const getServerSideProps: GetServerSideProps<AnalyticsPageProps> = 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<AnalyticsPageProps> = async () => ({
props: {
initialStats: null,
initialTransactionTrend: [],
initialActivitySnapshot: null,
initialBlocks: [],
initialTransactions: [],
initialBridgeStatus: null,
},
})

View File

@@ -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<Block | null>(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<SectionTab<'summary' | 'transactions'>[]>(
() => [
{ id: 'summary', label: 'Summary' },
{ id: 'transactions', label: 'Transactions', count: block?.transaction_count ?? 0 },
],
[block?.transaction_count],
)
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Block Detail"
title={block ? `Block #${block.number}` : 'Block'}
description="Inspect a single Chain 138 block, then move into its related miner address, adjacent block numbers, or broader explorer search flows."
actions={[
{ href: '/blocks', label: 'All blocks' },
{ href: '/transactions', label: 'Recent transactions' },
{ href: '/search', label: 'Search explorer' },
]}
description="Chain 138 block detail, miner, adjacent blocks, and transactions."
actions={blockIntroActions}
/>
<div className="mb-6 flex flex-wrap gap-3 text-sm">
<Link href="/blocks" className="text-primary-600 hover:underline">
Back to blocks
</Link>
{block && block.number > 0 ? (
<Link href={`/blocks/${block.number - 1}`} className="text-primary-600 hover:underline">
Previous block
</Link>
) : null}
{block && (
<Link href={`/blocks/${block.number + 1}`} className="text-primary-600 hover:underline">
Next block
</Link>
)}
{block?.transaction_count ? (
<a href="#block-transactions" className="text-primary-600 hover:underline">
Open block transactions
</a>
) : null}
</div>
{!router.isReady || loading ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Loading block...</p>
@@ -186,6 +187,26 @@ export default function BlockDetailPage() {
</div>
</Card>
) : (
<>
<CompactMetricBar
metrics={[
{ label: 'Transactions', value: formatInteger(block.transaction_count) },
{ label: 'Gas used', value: `${gasUtilization ?? 0}%` },
{ label: 'Gas limit', value: formatInteger(block.gas_limit) },
{ label: 'Miner', value: `${block.miner.slice(0, 6)}${block.miner.slice(-4)}` },
]}
/>
<SectionTabs
tabs={blockTabs}
activeTab={activeTab}
onChange={setActiveTab}
idPrefix="block-detail"
ariaLabel="Block detail sections"
className="-mx-4 mb-4 px-4"
/>
<TabPanel idPrefix="block-detail" tabId="summary" activeTab={activeTab}>
<Card title="Block Information">
<dl className="space-y-4">
<DetailRow label="Hash">
@@ -200,12 +221,16 @@ export default function BlockDetailPage() {
</Link>
</DetailRow>
<DetailRow label="Transactions">
<a href="#block-transactions" className="text-primary-600 hover:underline">
<button
type="button"
onClick={() => setActiveTab('transactions')}
className="text-primary-600 hover:underline"
>
{block.transaction_count}
</a>
</button>
</DetailRow>
<DetailRow label="Gas Used">
{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}
{formatInteger(block.gas_used)} / {formatInteger(block.gas_limit)}
</DetailRow>
{gasUtilization != null && (
<DetailRow label="Gas Utilization">
@@ -214,13 +239,13 @@ export default function BlockDetailPage() {
)}
</dl>
</Card>
)}
</TabPanel>
{block && (
<Card title="Block Transactions" className="mt-6">
<TabPanel idPrefix="block-detail" tabId="transactions" activeTab={activeTab}>
<Card title="Block Transactions">
<div id="block-transactions" className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
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)}.
</p>
{transactionsLoading ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Loading block transactions...</p>
@@ -261,6 +286,8 @@ export default function BlockDetailPage() {
)}
</div>
</Card>
</TabPanel>
</>
)}
</div>
)

View File

@@ -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<Block[]>(initialBlocks)
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(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) => (
<Link href={`/blocks/${block.number}`} className="font-medium text-primary-600 hover:underline">
#{block.number}
</Link>
),
},
{
header: 'Age',
accessor: (block: Block) => (
<span className="text-sm text-gray-600 dark:text-gray-400">{formatTimestamp(block.timestamp)}</span>
),
},
{
header: 'Miner',
accessor: (block: Block) => (
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
<Address address={block.miner} truncate showCopy={false} />
</Link>
),
},
{
header: 'Txs',
accessor: (block: Block) => block.transaction_count,
},
]
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Chain Activity"
title="Blocks"
description="Browse recent Chain 138 blocks, then pivot into transactions, addresses, and indexed search without falling into a dead end."
description="Recent Chain 138 blocks with miner links and transaction counts."
actions={[
{ href: '/transactions', label: 'Open transactions' },
{ href: '/addresses', label: 'Browse addresses' },
{ href: '/search', label: 'Search explorer' },
{ href: '/transactions', label: 'Transactions' },
{ href: '/search', label: 'Search' },
]}
/>
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Block Production Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={initialStats}
bridgeStatus={initialBridgeStatus}
scopeLabel="This page focuses on recent visible head blocks."
/>
</div>
<ExplorerFreshnessDisclosure
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
activityTitle="Block production"
scopeLabel="Recent visible head blocks on this page."
/>
{shouldExplainEmptyHeadBlocks(blocks, activityContext) ? (
<p className="mb-4 rounded-lg border border-amber-200 bg-amber-50/70 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
Recent head blocks are empty; use the latest transaction block for visible activity.
</p>
) : null}
{loading ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Loading blocks...</p>
</Card>
) : (
<div className="space-y-4">
{shouldExplainEmptyHeadBlocks(blocks, activityContext) ? (
<Card className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/40 dark:bg-amber-950/20">
<p className="text-sm text-amber-900 dark:text-amber-100">
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
</p>
</Card>
) : null}
{blocks.length === 0 ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Recent blocks are unavailable right now.</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/transactions" className="text-primary-600 hover:underline">
Open recent transactions
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search by block number
</Link>
</div>
</Card>
) : (
blocks.map((block) => (
<Card key={block.number}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link
href={`/blocks/${block.number}`}
className="text-lg font-semibold text-primary-600 hover:text-primary-700"
>
Block #{block.number}
</Link>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
<Address address={block.hash} truncate showCopy={false} />
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Miner:{' '}
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
<Address address={block.miner} truncate showCopy={false} />
</Link>
</div>
</div>
<div className="text-left sm:text-right">
<div className="text-sm">
{formatTimestamp(block.timestamp)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{block.transaction_count} transactions
</div>
</div>
</div>
</Card>
))
)}
</div>
<Table
columns={columns}
data={paginatedBlocks}
emptyMessage="Recent blocks are unavailable right now."
keyExtractor={(block) => String(block.number)}
/>
)}
{showPagination ? (
@@ -219,48 +232,22 @@ export default function BlocksPage({
className="mt-6"
/>
) : null}
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<Card title="Keep Exploring">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Need a different entry point? Open transaction flow, search directly by block number, or jump into recently active addresses.
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/transactions" className="text-primary-600 hover:underline">
Transactions
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Addresses
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search
</Link>
</div>
</Card>
</div>
</div>
)
}
export const getServerSideProps: GetServerSideProps<BlocksPageProps> = 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<Record<string, unknown>>('/api/v2/stats').catch(() => null),
fetchPublicJson<MissionControlBridgeStatusResponse>('/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,
},
}
}

View File

@@ -7,8 +7,9 @@ import EntityBadge from '@/components/common/EntityBadge'
export default function GruDocsPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Explorer Documentation"
title="GRU Guide"
description="A user-facing summary of the GRU standards, bridge posture, public-network representations, and x402 readiness model, with concrete places to inspect those signals on live token, address, and search pages."

View File

@@ -3,6 +3,7 @@
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
import DisclosureSection from '@/components/common/DisclosureSection'
const docsCards = [
{
@@ -55,97 +56,54 @@ const policyLinks = [
export default function DocsIndexPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Explorer Documentation"
title="Documentation"
description="Use the explorers public guides, methodology notes, and adjacent operational references from one canonical docs surface."
description="Public guides, methodology notes, and operational references for Chain 138."
actions={[
{ href: '/docs/gru', label: 'GRU guide' },
{ href: '/docs/transaction-review', label: 'Review matrix' },
{ href: '/operations', label: 'Operations hub' },
{ href: '/operations', label: 'Operations' },
]}
/>
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<Card title="Explorer Guides">
<div className="grid gap-4 md:grid-cols-2">
{docsCards.map((item) => (
<div key={item.href} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-base font-semibold text-gray-900 dark:text-white">{item.title}</div>
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">{item.description}</p>
<div className="mt-4">
<Link href={item.href} className="text-primary-600 hover:underline">
Open guide
</Link>
</div>
</div>
))}
</div>
</Card>
<div className="space-y-6">
<Card title="Verify These Guides Live">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
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.
</p>
<div className="flex flex-wrap gap-3">
<Link href="/search?q=cUSDT" className="text-primary-600 hover:underline">
Search cUSDT
</Link>
<Link href="/tokens/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22" className="text-primary-600 hover:underline">
Open cUSDT token page
</Link>
<Link href="/transactions" className="text-primary-600 hover:underline">
Browse recent transactions
</Link>
</div>
</div>
</Card>
<Card title="Operator & Domains">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
DBIS Explorer is the public Chain 138 explorer operated by DBIS. Primary public access is served at
<code> explorer.d-bis.org</code>; <code> blockscout.defi-oracle.io</code> is the Blockscout companion domain.
</p>
<p>
These domains are part of the same explorer and companion-tooling surface, including the Snap install path at
<code> /snap/</code>. Support and policy notices are handled through
<a href="mailto:support@d-bis.org" className="ml-1 text-primary-600 hover:underline">support@d-bis.org</a>.
</p>
</div>
</Card>
<Card title="Policies & Static Notes">
<div className="space-y-3 text-sm">
{policyLinks.map((item) => (
<div key={item.href}>
<a href={item.href} className="text-primary-600 hover:underline">
{item.label}
</a>
</div>
))}
</div>
</Card>
<Card title="Need Help?">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
The public explorer docs cover GRU posture, transaction review scoring, liquidity access, and navigation into the broader Chain 138 surfaces.
</p>
<p>
Support: <a href="mailto:support@d-bis.org" className="text-primary-600 hover:underline">support@d-bis.org</a>
</p>
<p>
Command center: <Link href="/topology" className="text-primary-600 hover:underline">open visual map </Link>
</p>
</div>
</Card>
<Card title="Explorer guides" className="mb-4">
<div className="grid gap-3 sm:grid-cols-2">
{docsCards.map((item) => (
<Link
key={item.href}
href={item.href}
className="block rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-3 hover:border-primary-300 dark:border-gray-700 dark:bg-gray-900/40"
>
<div className="text-sm font-semibold text-primary-600">{item.title}</div>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-gray-600 dark:text-gray-400">{item.description}</p>
</Link>
))}
</div>
</div>
</Card>
<DisclosureSection title="Policies, domains & support" defaultOpen={false} alwaysCollapsible>
<div className="space-y-4 text-sm text-gray-600 dark:text-gray-400">
<p>
Primary domain <code>explorer.d-bis.org</code>; Blockscout companion <code>blockscout.defi-oracle.io</code>.
Support: <a href="mailto:support@d-bis.org" className="text-primary-600 hover:underline">support@d-bis.org</a>
</p>
<div className="flex flex-wrap gap-3">
{policyLinks.map((item) => (
<a key={item.href} href={item.href} className="text-primary-600 hover:underline">
{item.label}
</a>
))}
<Link href="/search?q=cUSDT" className="text-primary-600 hover:underline">
Verify cUSDT live
</Link>
<Link href="/topology" className="text-primary-600 hover:underline">
Command center map
</Link>
</div>
</div>
</DisclosureSection>
</div>
)
}

View File

@@ -8,8 +8,9 @@ import { postureGlossaryTerms } from '@/data/postureGlossary'
export default function PostureGlossaryDocsPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Explorer Documentation"
title="Posture glossary"
description="First-read explanations for institutional posture badges shown on token, address, transaction, and search surfaces."

View File

@@ -7,8 +7,9 @@ import { explorerPublicApiLinks } from '@/data/explorerOperations'
export default function PublicApiAccessDocsPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Explorer Documentation"
title="Public API access"
description="How integrators use read-only explorer APIs today, how managed RPC keys work on /access, and the planned path if public rate limits require API keys."

View File

@@ -15,8 +15,9 @@ const FACTORS = [
export default function TransactionComplianceDocsPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Explorer Documentation"
title="Transaction Evidence Matrix"
description="A practical explorer-side heuristic for scoring how well a transaction is evidenced, decoded, and aligned with GRU, x402, and ISO-20022 posture."

View File

@@ -1,23 +1,16 @@
import type { GetServerSideProps } from 'next'
import HomePage from '@/components/home/HomePage'
import { normalizeBlock } from '@/services/api/blockscout'
import {
normalizeTransactionTrend,
summarizeRecentTransactions,
type ExplorerRecentActivitySnapshot,
type ExplorerStats,
type ExplorerTransactionTrendPoint,
} from '@/services/api/stats'
import {
summarizeMissionControlRelay,
type MissionControlBridgeStatusResponse,
type MissionControlRelaySummary,
} from '@/services/api/missionControl'
import type { Block } from '@/services/api/blocks'
import type { Transaction } from '@/services/api/transactions'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
import { normalizeTransaction } from '@/services/api/blockscout'
interface IndexPageProps {
initialStats: ExplorerStats | null
@@ -33,67 +26,14 @@ export default function IndexPage(props: IndexPageProps) {
return <HomePage {...props} />
}
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<IndexPageProps> = 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<IndexPageProps> = async () => ({
props: {
initialStats: null,
initialRecentBlocks: [],
initialRecentTransactions: [],
initialTransactionTrend: [],
initialActivitySnapshot: null,
initialBridgeStatus: null,
initialRelaySummary: null,
},
})

View File

@@ -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<T>(path: string, body: unknown): Promise<T> {
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 <LiquidityOperationsPage {...props} />
}
export const getServerSideProps: GetServerSideProps<LiquidityPageProps> = async () => {
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult, truthContext] =
await Promise.all([
loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson<PlannerCapabilitiesResponse>('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch(
() => null,
),
fetchPublicPostJson<InternalExecutionPlanResponse>('/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<LiquidityPageProps> = async () => ({
props: {
initialTokenList: null,
initialRouteMatrix: null,
initialPlannerCapabilities: null,
initialInternalPlan: null,
initialTokenPoolRecords: [],
initialStats: null,
initialBridgeStatus: null,
},
})

View File

@@ -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 <OperatorOperationsPage {...props} />
}
export const getServerSideProps: GetServerSideProps<OperatorPageProps> = async () => {
const [bridgeStatus, routeMatrix, plannerCapabilities] = await Promise.all([
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson<PlannerCapabilitiesResponse>('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch(
() => null,
),
])
return {
props: {
initialBridgeStatus: bridgeStatus,
initialRouteMatrix: routeMatrix,
initialPlannerCapabilities: plannerCapabilities,
initialInternalPlan: null,
},
}
}
export const getServerSideProps: GetServerSideProps<OperatorPageProps> = async () => ({
props: {
initialBridgeStatus: null,
initialRouteMatrix: null,
initialPlannerCapabilities: null,
initialInternalPlan: null,
},
})

View File

@@ -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<ProtocolVerificationReport | null>(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 (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<OperationsSurfaceNav />
<PageIntro
compact
eyebrow="Official protocol"
title={protocol?.id ?? (protocolId || 'Protocol')}
description={
protocol?.upstreamRepo
? `Deployed from official upstream. ${protocol.contracts.length} on-chain addresses on Chain 138.`
: 'Chain 138 official protocol contract catalog.'
? `Official upstream · ${protocol.contracts.length} Chain 138 contracts.`
: 'Chain 138 official protocol catalog.'
}
actions={[
{ href: '/protocols', label: 'All protocols' },
{ href: '/liquidity', label: 'Liquidity' },
{ href: `/token-aggregation/api/v1/report/official-protocols/${protocolId}`, label: 'JSON' },
]}
/>
@@ -74,9 +78,22 @@ export default function ProtocolDetailPage() {
) : null}
{protocol ? (
<div className="space-y-6">
<>
<SectionTabs
idPrefix="protocol-detail"
ariaLabel="Protocol detail sections"
tabs={[
{ id: 'overview', label: 'Overview' },
{ id: 'contracts', label: 'Contracts', count: protocol.contracts.length },
]}
activeTab={protocolTab}
onChange={setProtocolTab}
className="mb-4"
/>
<TabPanel idPrefix="protocol-detail" tabId="overview" activeTab={protocolTab}>
{verification ? (
<Card title="On-chain verification">
<Card title="On-chain verification" className="mb-4">
<div className="flex flex-wrap gap-2">
<EntityBadge
label={verification.allVerified ? 'all contracts deployed' : 'review required'}
@@ -88,7 +105,7 @@ export default function ProtocolDetailPage() {
) : null}
</div>
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
Live bytecode probe via Chain 138 RPC at {new Date(verification.verifiedAt).toLocaleString()}.
Live bytecode probe via Chain 138 RPC at {formatTimestamp(verification.verifiedAt)}.
{registryMeta.updated ? ` Registry updated ${registryMeta.updated}.` : ''}
</p>
<div className="mt-4 space-y-2">
@@ -135,12 +152,28 @@ export default function ProtocolDetailPage() {
) : null}
</Card>
{forbidden.length > 0 ? (
<DisclosureSection title="Forbidden in production" defaultOpen={false} alwaysCollapsible className="mt-4">
<Card>
<ul className="list-disc space-y-2 pl-5 text-sm text-gray-700 dark:text-gray-300">
{forbidden.slice(0, 5).map((row) => (
<li key={row.id}>
<span className="font-medium">{row.id}</span> {row.description}
</li>
))}
</ul>
</Card>
</DisclosureSection>
) : null}
</TabPanel>
<TabPanel idPrefix="protocol-detail" tabId="contracts" activeTab={protocolTab}>
<Card title="Contracts">
<div className="space-y-4">
<div className="space-y-3">
{protocol.contracts.map((contract) => (
<div key={contract.name} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
<div key={contract.name} className="rounded-xl border border-gray-200 px-3 py-2.5 dark:border-gray-700">
<div className="font-semibold text-gray-900 dark:text-white">{contract.name}</div>
<div className="mt-2">
<div className="mt-1">
<Address address={contract.address} truncate showCopy />
</div>
<div className="mt-2 flex flex-wrap gap-2">
@@ -155,19 +188,8 @@ export default function ProtocolDetailPage() {
))}
</div>
</Card>
{forbidden.length > 0 ? (
<Card title="Forbidden in production">
<ul className="list-disc space-y-2 pl-5 text-sm text-gray-700 dark:text-gray-300">
{forbidden.slice(0, 5).map((row) => (
<li key={row.id}>
<span className="font-medium">{row.id}</span> {row.description}
</li>
))}
</ul>
</Card>
) : null}
</div>
</TabPanel>
</>
) : null}
</div>
)

View File

@@ -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 (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<OperationsSurfaceNav />
<PageIntro
compact
eyebrow="Chain 138 infrastructure"
title="Official protocol contracts"
description="Production DODO, UniV2/V3, CCIP, Multicall3, and integration contracts deployed from official upstream sources — with forbidden pilot patterns called out."
description="Production DODO, UniV2/V3, CCIP, Multicall3, and integration contracts from official upstream sources."
actions={[
{ href: '/liquidity', label: 'Liquidity' },
{ href: '/docs', label: 'Explorer docs' },
{ href: '/token-aggregation/api/v1/report/official-protocols', label: 'JSON API' },
]}
/>
@@ -55,17 +56,17 @@ export default function ProtocolsIndexPage() {
) : null}
{report ? (
<div className="space-y-6">
<Card title="Production guardrails">
<div className="space-y-4">
<DisclosureSection title="Production guardrails" defaultOpen={false} alwaysCollapsible>
<ul className="list-disc space-y-2 pl-5 text-sm text-gray-700 dark:text-gray-300">
{report.guardrails.slice(0, 4).map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</Card>
</DisclosureSection>
<Card title={`Protocols (${report.count})`}>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-2">
{report.protocols.map((protocol) => (
<Link
key={protocol.id}

View File

@@ -20,6 +20,10 @@ import {
type WrappedTransportRegistryRow,
} from '@/utils/search'
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, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { fetchTokenListForSurface } from '@/services/api/tokenListSurfaces'
import { useUiMode } from '@/components/common/UiModeContext'
@@ -50,6 +54,9 @@ import { searchCuratedPools, type PoolSearchRow } from '@/utils/poolSearch'
import { fetchPoolRegistry, type CuratedPoolRegistryEntry } from '@/services/api/liquidityPositions'
type SearchFilterMode = 'all' | 'gru' | 'x402' | 'wrapped'
type SearchResultsTab = 'indexed' | 'mesh' | 'pools' | 'wrapped'
const SEARCH_INDEXED_PAGE_SIZE = 4
interface SearchPageProps {
initialQuery: string
@@ -89,6 +96,8 @@ export default function SearchPage({
const [meshLoading, setMeshLoading] = useState(false)
const [polygonMapper, setPolygonMapper] = useState<PolygonMapperResponse | null>(null)
const [poolRegistryRows, setPoolRegistryRows] = useState<CuratedPoolRegistryEntry[]>([])
const [indexedPage, setIndexedPage] = useState(1)
const [activeSearchTab, setActiveSearchTab] = useState<SearchResultsTab>('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<SectionTab<SearchResultsTab>[]>(() => {
const tabs: SectionTab<SearchResultsTab>[] = []
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 (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Explorer Lookup"
title="Search"
description={
mode === 'guided'
? 'Search by address, transaction hash, block number, token symbol, or cW*/c* wrapped transport symbols. Mesh counterparts resolve across chains via token-mapping; Chain 138 uses indexed search.'
: 'Search tx / addr / block / token / mesh counterparts (token-mapping + cW registry).'
? 'Search by address, transaction, block, token, or mesh/cW* symbols.'
: 'Search tx / addr / block / token / mesh.'
}
actions={[
{ href: '/tokens', label: 'Token shortcuts' },
{ href: '/protocols', label: 'Protocol contracts' },
{ href: '/pools', label: 'Pool registry' },
{ href: '/addresses', label: 'Browse addresses' },
{ href: '/watchlist', label: 'Open watchlist' },
{ href: '/tokens', label: 'Tokens' },
{ href: '/pools', label: 'Pools' },
]}
/>
@@ -470,6 +533,25 @@ export default function SearchPage({
</Card>
)}
{!loading && hasSearched && searchTabs.length > 0 ? (
<>
<CompactMetricBar
metrics={[
{ label: 'Indexed', value: String(filteredResults.length) },
{ label: 'Mesh', value: String(meshRows.length) },
{ label: 'Pools', value: String(poolMatches.length) },
{ label: 'Wrapped', value: String(filteredWrappedMatches.length) },
]}
/>
<SectionTabs
tabs={searchTabs}
activeTab={activeSearchTab}
onChange={setActiveSearchTab}
idPrefix="search-results"
ariaLabel="Search result sections"
className="-mx-4 mb-4 px-4"
/>
<TabPanel idPrefix="search-results" tabId="indexed" activeTab={activeSearchTab}>
{!loading && tokenTarget && !meshSeed && (
<Card className="mb-6" title="Direct Token Match">
<p className="text-sm text-gray-600 dark:text-gray-400">
@@ -523,174 +605,6 @@ export default function SearchPage({
</Card>
)}
{!loading && poolMatches.length > 0 && hasSearched && (
<Card title="Curated liquidity pools">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Matches from the DODO PMM + UniV2 pool registry on Chain 138. LP receipt tokens stay chain-local bridge
underlying c*/cW* instead.
</p>
<div className="space-y-3">
{poolMatches.slice(0, 12).map((row) => (
<Link
key={`${row.poolAddress}-${row.matchReason}`}
href={`/pools/${row.poolAddress}`}
className="block rounded-xl border border-gray-200 px-4 py-3 hover:border-primary-300 dark:border-gray-700"
>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={row.pairLabel} tone="info" />
<EntityBadge label={row.venue.replace('_', ' ')} tone="neutral" />
<EntityBadge label={row.matchReason} tone="success" className="normal-case tracking-normal" />
</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Pool <Address address={row.poolAddress} truncate showCopy={false} />
</div>
</Link>
))}
</div>
</Card>
)}
{!loading && meshSeed && (meshLoading || meshRows.length > 0) && (
<Card title="Mesh counterparts">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
{mode === 'guided'
? `Resolved ${meshSeed.hubSymbol} hub on Chain 138 and mapped ${meshSeed.wrappedSymbol} addresses across configured transport pairs via token-mapping.`
: `${meshSeed.hubSymbol}${meshSeed.wrappedSymbol} mesh (${meshSeed.matchReason}).`}
</p>
{polygonMapper?.officialPolygonTokenList?.upstreamRepo ? (
<p className="mb-4 text-xs text-gray-500 dark:text-gray-400">
Polygon (137) rows align with{' '}
<a
href={polygonMapper.officialPolygonTokenList.submissionDoc || polygonMapper.officialPolygonTokenList.upstreamRepo}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:underline"
>
official Polygon Token Mapper
</a>
{polygonMapper.match?.polygonAddress
? ` — matched ${polygonMapper.match.wrappedSymbol} at ${polygonMapper.match.polygonAddress.slice(0, 10)}`
: ''}
.
</p>
) : null}
{meshLoading ? (
<p className="text-sm text-gray-500 dark:text-gray-400">Resolving mesh addresses</p>
) : (
<div className="space-y-3">
{meshRows.map((row) => {
const href = externalChainExplorerUrl(row.chainId, row.address)
const content = (
<>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={row.symbol} tone="info" />
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
<EntityBadge label={row.chainName} tone="warning" />
<EntityBadge
label={row.role === 'hub' ? 'hub (138)' : row.mappedVia === 'token-mapping' ? 'mapped' : 'registry'}
tone={row.role === 'hub' ? 'success' : 'info'}
className="normal-case tracking-normal"
/>
{row.chainId === 137 && polygonMapper ? (
<EntityBadge label="Polygon mapper" tone="warning" className="normal-case tracking-normal" />
) : null}
</div>
<div className="mt-2">
<Address address={row.address} truncate showCopy />
</div>
</>
)
if (!href) {
return (
<div key={`mesh-${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
{content}
</div>
)
}
if (row.chainId === 138) {
return (
<Link
key={`mesh-${row.chainId}-${row.address}`}
href={href}
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
>
{content}
</Link>
)
}
return (
<a
key={`mesh-${row.chainId}-${row.address}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
>
{content}
</a>
)
})}
</div>
)}
</Card>
)}
{!loading && filteredWrappedMatches.length > 0 && meshRows.length === 0 && !meshLoading && (
<Card title="Wrapped transport tokens (cross-network)">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Matches from the live cW* registry across public networks. Chain 138 tokens open here; other chains link to native explorers.
</p>
<div className="space-y-3">
{filteredWrappedMatches.slice(0, 24).map((row) => {
const href = externalChainExplorerUrl(row.chainId, row.address)
const content = (
<>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={row.symbol} tone="info" />
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
<EntityBadge label={row.chainName} tone="warning" />
<EntityBadge label="cW registry" tone="success" />
<EntityBadge label={row.matchReason} tone="info" className="normal-case tracking-normal" />
</div>
<div className="mt-2">
<Address address={row.address} truncate showCopy />
</div>
</>
)
if (!href) {
return (
<div key={`${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
{content}
</div>
)
}
if (row.chainId === 138) {
return (
<Link
key={`${row.chainId}-${row.address}`}
href={href}
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
>
{content}
</Link>
)
}
return (
<a
key={`${row.chainId}-${row.address}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
>
{content}
</a>
)
})}
</div>
</Card>
)}
{!loading && !tokenTarget && directTarget && !deferAddressJump && (
<Card className="mb-6" title="Direct Match">
<p className="text-sm text-gray-600 dark:text-gray-400">
@@ -724,7 +638,7 @@ export default function SearchPage({
</Card>
)}
{results.length > 0 && (
{filteredResults.length > 0 && (
<Card title="Indexed search results">
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-2">
@@ -751,7 +665,7 @@ export default function SearchPage({
GRU guide
</Link>
</div>
{resultSections.map((section) =>
{paginatedResultSections.map((section) =>
section.items.length > 0 ? (
<div key={section.label}>
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">
@@ -829,19 +743,197 @@ export default function SearchPage({
</div>
) : null,
)}
<PaginationControls
page={indexedPage}
pageCount={indexedPageCount}
onPageChange={setIndexedPage}
label="Indexed results"
ariaLabel="Indexed search pagination"
/>
</div>
</Card>
)}
{!loading && hasSearched && hasSupplementalMatches && filteredResults.length === 0 && !error ? (
<Card className="mb-6" title="Registry matches">
<Card className="mb-4" title="Registry matches">
<p className="text-sm text-gray-600 dark:text-gray-400">
Blockscout indexed search returned no hits for{' '}
<span className="font-medium text-gray-900 dark:text-white">{trimmedQuery}</span>, but curated mesh,
pool, or wrapped-token registry matches are shown below.
pool, or wrapped-token registry matches are shown in the other tabs.
</p>
</Card>
) : null}
</TabPanel>
<TabPanel idPrefix="search-results" tabId="pools" activeTab={activeSearchTab}>
<Card title="Curated liquidity pools">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Matches from the DODO PMM + UniV2 pool registry on Chain 138. LP receipt tokens stay chain-local bridge
underlying c*/cW* instead.
</p>
<div className="space-y-3">
{poolMatches.slice(0, 6).map((row) => (
<Link
key={`${row.poolAddress}-${row.matchReason}`}
href={`/pools/${row.poolAddress}`}
className="block rounded-xl border border-gray-200 px-4 py-3 hover:border-primary-300 dark:border-gray-700"
>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={row.pairLabel} tone="info" />
<EntityBadge label={row.venue.replace('_', ' ')} tone="neutral" />
<EntityBadge label={row.matchReason} tone="success" className="normal-case tracking-normal" />
</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Pool <Address address={row.poolAddress} truncate showCopy={false} />
</div>
</Link>
))}
</div>
</Card>
</TabPanel>
<TabPanel idPrefix="search-results" tabId="mesh" activeTab={activeSearchTab}>
<Card title="Mesh counterparts">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
{mode === 'guided'
? `Resolved ${meshSeed?.hubSymbol} hub on Chain 138 and mapped ${meshSeed?.wrappedSymbol} addresses across configured transport pairs via token-mapping.`
: `${meshSeed?.hubSymbol}${meshSeed?.wrappedSymbol} mesh (${meshSeed?.matchReason}).`}
</p>
{polygonMapper?.officialPolygonTokenList?.upstreamRepo ? (
<p className="mb-4 text-xs text-gray-500 dark:text-gray-400">
Polygon (137) rows align with{' '}
<a
href={polygonMapper.officialPolygonTokenList.submissionDoc || polygonMapper.officialPolygonTokenList.upstreamRepo}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:underline"
>
official Polygon Token Mapper
</a>
{polygonMapper.match?.polygonAddress
? ` — matched ${polygonMapper.match.wrappedSymbol} at ${polygonMapper.match.polygonAddress.slice(0, 10)}`
: ''}
.
</p>
) : null}
{meshLoading ? (
<p className="text-sm text-gray-500 dark:text-gray-400">Resolving mesh addresses</p>
) : (
<div className="space-y-3">
{meshRows.map((row) => {
const href = externalChainExplorerUrl(row.chainId, row.address)
const content = (
<>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={row.symbol} tone="info" />
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
<EntityBadge label={row.chainName} tone="warning" />
<EntityBadge
label={row.role === 'hub' ? 'hub (138)' : row.mappedVia === 'token-mapping' ? 'mapped' : 'registry'}
tone={row.role === 'hub' ? 'success' : 'info'}
className="normal-case tracking-normal"
/>
{row.chainId === 137 && polygonMapper ? (
<EntityBadge label="Polygon mapper" tone="warning" className="normal-case tracking-normal" />
) : null}
</div>
<div className="mt-2">
<Address address={row.address} truncate showCopy />
</div>
</>
)
if (!href) {
return (
<div key={`mesh-${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
{content}
</div>
)
}
if (row.chainId === 138) {
return (
<Link
key={`mesh-${row.chainId}-${row.address}`}
href={href}
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
>
{content}
</Link>
)
}
return (
<a
key={`mesh-${row.chainId}-${row.address}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
>
{content}
</a>
)
})}
</div>
)}
</Card>
</TabPanel>
<TabPanel idPrefix="search-results" tabId="wrapped" activeTab={activeSearchTab}>
<Card title="Wrapped transport tokens (cross-network)">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Matches from the live cW* registry across public networks. Chain 138 tokens open here; other chains link to native explorers.
</p>
<div className="space-y-3">
{filteredWrappedMatches.slice(0, 8).map((row) => {
const href = externalChainExplorerUrl(row.chainId, row.address)
const content = (
<>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={row.symbol} tone="info" />
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
<EntityBadge label={row.chainName} tone="warning" />
<EntityBadge label="cW registry" tone="success" />
<EntityBadge label={row.matchReason} tone="info" className="normal-case tracking-normal" />
</div>
<div className="mt-2">
<Address address={row.address} truncate showCopy />
</div>
</>
)
if (!href) {
return (
<div key={`${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
{content}
</div>
)
}
if (row.chainId === 138) {
return (
<Link
key={`${row.chainId}-${row.address}`}
href={href}
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:underline dark:border-gray-700"
>
{content}
</Link>
)
}
return (
<a
key={`${row.chainId}-${row.address}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:underline dark:border-gray-700"
>
{content}
</a>
)
})}
</div>
</Card>
</TabPanel>
</>
) : null}
{!loading && hasSearched && !error && filteredResults.length === 0 && !hasSupplementalMatches && (
<Card title="No Results Found">
@@ -865,6 +957,7 @@ export default function SearchPage({
)}
{!loading && !hasSearched && (
<DisclosureSection title="Popular starting points" defaultOpen={false} alwaysCollapsible>
<Card title="Popular Starting Points">
<div className="space-y-4">
<div className="flex flex-wrap gap-3 text-sm">
@@ -897,6 +990,7 @@ export default function SearchPage({
) : null}
</div>
</Card>
</DisclosureSection>
)}
</div>
)
@@ -923,21 +1017,10 @@ export const getServerSideProps: GetServerSideProps<SearchPageProps> = 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,
},
}

View File

@@ -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 <SystemOperationsPage {...props} />
}
export const getServerSideProps: GetServerSideProps<SystemPageProps> = async () => {
const [bridgeStatus, networksConfig, tokenList, capabilities, routeMatrix, stats] = await Promise.all([
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
fetchPublicJson<NetworksConfigResponse>('/api/config/networks').catch(() => null),
loadTokenListResponseForSurface('extended', 138).then((value) => value.response).catch(() => null),
fetchPublicJson<CapabilitiesResponse>('/api/config/capabilities').catch(() => null),
fetchPublicJson<RouteMatrixResponse>('/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<SystemPageProps> = async () => ({
props: {
initialBridgeStatus: null,
initialNetworksConfig: null,
initialTokenList: null,
initialCapabilities: null,
initialRouteMatrix: null,
initialStats: null,
},
})

View File

@@ -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 (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Token Detail"
title={token?.symbol || token?.name || provenance?.symbol || provenance?.name || 'Token'}
description="Inspect token supply, holders, transfers, and liquidity context with the sort of composure one normally has to borrow from a better explorer."
description="Supply, holders, transfers, standards, and liquidity on Chain 138."
actions={[
{ href: '/tokens', label: 'Token index' },
{ href: '/liquidity', label: 'Liquidity access' },
{ href: '/search', label: 'Explorer search' },
{ href: '/liquidity', label: 'Liquidity' },
]}
/>
<div className="mb-6 flex flex-wrap gap-3 text-sm">
<div className="mb-4 flex flex-wrap gap-3 text-sm">
<Link href="/tokens" className="text-primary-600 hover:underline">
Back to tokens
</Link>
@@ -402,6 +403,16 @@ export default function TokenDetailPage() {
</Card>
) : null}
<Card title="Token Overview">
<div className="mb-4 border-b border-gray-200 pb-4 dark:border-gray-700">
<AddTokenToWalletButton
address={token.address}
symbol={token.symbol || provenance?.symbol || 'TOKEN'}
decimals={token.decimals}
name={token.name || provenance?.name}
logoURI={provenance?.logoURI || token.icon_url || undefined}
chainId={138}
/>
</div>
<dl className="space-y-4">
<DetailRow label="Name">{token.name || provenance?.name || 'Unknown'}</DetailRow>
<DetailRow label="Symbol">{token.symbol || provenance?.symbol || 'Unknown'}</DetailRow>
@@ -434,7 +445,7 @@ export default function TokenDetailPage() {
</DetailRow>
)}
{token.holders != null && (
<DetailRow label="Holders">{token.holders.toLocaleString()}</DetailRow>
<DetailRow label="Holders">{formatInteger(token.holders)}</DetailRow>
)}
<DetailRow label="Provenance" valueClassName="flex flex-wrap gap-2">
{provenanceTags.length > 0 ? provenanceTags.map((tag) => (
@@ -464,7 +475,7 @@ export default function TokenDetailPage() {
ariaLabel="Token details"
/>
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'intelligence', activeTab)}>
<TabPanel idPrefix={TOKEN_DETAIL_TABS_ID} tabId="intelligence" activeTab={activeTab}>
<Card title="Token Intelligence">
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -486,7 +497,7 @@ export default function TokenDetailPage() {
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Liquidity & Distribution</div>
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div>Related pools: {liquiditySummary.poolCount.toLocaleString()}</div>
<div>Related pools: {formatInteger(liquiditySummary.poolCount)}</div>
<div>Total visible TVL: {formatUsd(liquiditySummary.totalTvl)}</div>
<div>Largest visible holder: {holderConcentration != null ? `${holderConcentration}% of supply` : 'Unavailable'}</div>
</div>
@@ -494,9 +505,9 @@ export default function TokenDetailPage() {
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Transfer Activity</div>
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div>Recent transfer sample: {transferFlowSummary.sampleSize.toLocaleString()}</div>
<div>Unique senders: {transferFlowSummary.uniqueSenders.toLocaleString()}</div>
<div>Unique recipients: {transferFlowSummary.uniqueRecipients.toLocaleString()}</div>
<div>Recent transfer sample: {formatInteger(transferFlowSummary.sampleSize)}</div>
<div>Unique senders: {formatInteger(transferFlowSummary.uniqueSenders)}</div>
<div>Unique recipients: {formatInteger(transferFlowSummary.uniqueRecipients)}</div>
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -511,9 +522,9 @@ export default function TokenDetailPage() {
</div>
</div>
</Card>
</div>
</TabPanel>
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'standards', activeTab)}>
<TabPanel idPrefix={TOKEN_DETAIL_TABS_ID} tabId="standards" activeTab={activeTab}>
{gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
{gruExplorerMetadata ? (
@@ -594,9 +605,9 @@ export default function TokenDetailPage() {
</div>
</Card>
) : null}
</div>
</TabPanel>
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'holders', activeTab)}>
<TabPanel idPrefix={TOKEN_DETAIL_TABS_ID} tabId="holders" activeTab={activeTab}>
<Card title="Top Holders">
<Table
layout="tabular"
@@ -607,9 +618,9 @@ export default function TokenDetailPage() {
/>
<PaginationControls page={holderPage} pageCount={holderPageCount} onPageChange={setHolderPage} label="Holders" />
</Card>
</div>
</TabPanel>
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'transfers', activeTab)}>
<TabPanel idPrefix={TOKEN_DETAIL_TABS_ID} tabId="transfers" activeTab={activeTab}>
<Card title="Recent Transfers">
{gruExplorerMetadata ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
@@ -632,9 +643,9 @@ export default function TokenDetailPage() {
/>
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Transfers" />
</Card>
</div>
</TabPanel>
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'liquidity', activeTab)}>
<TabPanel idPrefix={TOKEN_DETAIL_TABS_ID} tabId="liquidity" activeTab={activeTab}>
<Card title="Related Liquidity">
<Table
columns={poolColumns}
@@ -644,7 +655,7 @@ export default function TokenDetailPage() {
/>
<PaginationControls page={poolPage} pageCount={poolPageCount} onPageChange={setPoolPage} label="Pools" />
</Card>
</div>
</TabPanel>
</div>
)}
</div>

View File

@@ -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<TokensPageTab>('curated')
const [curatedPage, setCuratedPage] = useState(1)
const [indexedPage, setIndexedPage] = useState(1)
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>(initialCuratedTokens)
const [indexedTokens, setIndexedTokens] = useState<IndexedTokenListItem[]>([])
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<SectionTab<TokensPageTab>[]>(
() => [
{ 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 (
<div className="container mx-auto px-4 py-8">
<div className="container mx-auto px-4 py-5 sm:py-6">
<PageIntro
compact
eyebrow="Token Discovery"
title="Tokens"
description="Browse the canonical Chain 138 trading set, open token contracts directly, and review holders, transfers, liquidity, and provenance from the same institutional explorer surface."
description="Browse canonical Chain 138 tokens, open contracts, and review holders, transfers, and liquidity."
actions={[
{ href: '/wallet', label: 'Wallet tools' },
{ href: '/liquidity', label: 'Liquidity access' },
{ href: '/search', label: 'Explorer search' },
{ href: '/search', label: 'Search' },
]}
/>
<Card className="mb-5" title="Find a token">
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row">
<input
type="text"
value={query}
onChange={(event) => 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"
/>
<button
type="submit"
disabled={!query.trim()}
className="min-h-10 rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Search
</button>
</form>
</Card>
<SectionTabs
tabs={tokenTabs}
activeTab={activeTab}
onChange={setActiveTab}
idPrefix="tokens-page"
ariaLabel="Token list sections"
className="-mx-4 mb-4 px-4"
/>
<div className="mt-5">
<Card title="Curated Chain 138 tokens">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
{TOKEN_LIST_SURFACE_LABELS.catalog}. Showing {featuredCuratedTokens.length} featured tokens from the live report list.
</p>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{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 (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-950/50"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.symbol || token.name || 'Token'}</div>
<p className="mt-1 line-clamp-2 text-sm leading-5 text-gray-600 dark:text-gray-400">
{token.name || 'Listed in the Chain 138 token registry.'}
</p>
</div>
</div>
{market ? (
<div className="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
<div className="rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Price</div>
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.priceUsd)}</div>
</div>
<div className="rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Liquidity</div>
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.liquidityUsd)}</div>
</div>
<div className="col-span-2">
<MarketEvidenceNote lastUpdated={market.lastUpdated} compact />
</div>
</div>
) : null}
{token.tags && token.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{[...token.tags].sort((a, b) => tagPriority(a) - tagPriority(b)).slice(0, 3).map((tag) => (
<EntityBadge key={`${token.address}-${tag}`} label={tag} className="px-2 py-1 text-[11px]" />
))}
</div>
)}
</Link>
)
})}
</div>
<TabPanel idPrefix="tokens-page" tabId="curated" activeTab={activeTab}>
<Card title="Find a token">
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row">
<input
type="text"
value={lookupQuery}
onChange={(event) => 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"
/>
<button
type="submit"
disabled={!lookupQuery.trim()}
className="min-h-10 rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Search
</button>
</form>
</Card>
</div>
<Card className="mt-4" title="Curated Chain 138 tokens">
<ListFilterBar
query={listQuery}
onQueryChange={setListQuery}
placeholder="Filter curated tokens by symbol, name, or address"
resultCount={filteredCuratedTokens.length}
totalCount={featuredCuratedTokens.length}
className="mb-4"
/>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{paginatedCuratedTokens.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400 md:col-span-2 xl:col-span-3">
No curated tokens match your filter.
</p>
) : (
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 (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-950/50"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.symbol || token.name || 'Token'}</div>
<p className="mt-1 line-clamp-2 text-sm leading-5 text-gray-600 dark:text-gray-400">
{token.name || 'Listed in the Chain 138 token registry.'}
</p>
</div>
</div>
{market ? (
<div className="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
<div className="rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Price</div>
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.priceUsd)}</div>
</div>
<div className="rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Liquidity</div>
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.liquidityUsd)}</div>
</div>
<div className="col-span-2">
<MarketEvidenceNote lastUpdated={market.lastUpdated} compact />
</div>
</div>
) : null}
{token.tags && token.tags.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{[...token.tags].sort((a, b) => tagPriority(a) - tagPriority(b)).slice(0, 3).map((tag) => (
<EntityBadge key={`${token.address}-${tag}`} label={tag} className="px-2 py-1 text-[11px]" />
))}
</div>
) : null}
</Link>
)
})
)}
</div>
<PaginationControls
page={curatedPage}
pageCount={curatedPageCount}
onPageChange={setCuratedPage}
label="Curated tokens"
ariaLabel="Curated token pagination"
/>
</Card>
</TabPanel>
<div className="mt-5">
<TabPanel idPrefix="tokens-page" tabId="indexed" activeTab={activeTab}>
<Card title="Indexed tokens (Blockscout)">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Canonical registry tokens appear first. Non-canonical indexed duplicates are labeled so operators can distinguish curated assets from stray contract listings.
</p>
<ListFilterBar
query={listQuery}
onQueryChange={setListQuery}
placeholder="Filter indexed tokens on this page"
resultCount={filteredIndexedTokens.length}
totalCount={sortedIndexedTokens.length}
/>
{indexedLoading ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Loading indexed token feed</p>
) : sortedIndexedTokens.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Indexed token feed is temporarily unavailable.</p>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading indexed token feed</p>
) : filteredIndexedTokens.length === 0 ? (
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
{sortedIndexedTokens.length === 0
? 'Indexed token feed is temporarily unavailable.'
: 'No indexed tokens on this page match your filter.'}
</p>
) : (
<div className="grid gap-2">
{sortedIndexedTokens.map((token) => {
<div className="mt-4 grid gap-2">
{filteredIndexedTokens.map((token) => {
const canonical = isCanonicalTokenAddress(token.address, canonicalAddressSet)
return (
<Link
@@ -264,17 +353,25 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
<p className="mt-1 truncate text-xs text-gray-600 dark:text-gray-400">{token.name || token.address}</p>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{token.holders != null ? `${token.holders.toLocaleString()} holders` : 'Holders unavailable'}
{token.holders != null ? `${formatInteger(token.holders)} holders` : 'Holders unavailable'}
</div>
</Link>
)
})}
</div>
)}
<PaginationControls
page={indexedPage}
onPageChange={setIndexedPage}
label="Indexed tokens"
ariaLabel="Indexed token pagination"
disabled={indexedLoading}
hasNextPage={indexedHasNextPage}
/>
</Card>
</div>
</TabPanel>
<div className="mt-5">
<TabPanel idPrefix="tokens-page" tabId="shortcuts" activeTab={activeTab}>
<Card title="Common token searches">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{quickSearches.map((token) => (
@@ -289,7 +386,7 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
))}
</div>
</Card>
</div>
</TabPanel>
</div>
)
}

View File

@@ -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 (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Transaction Detail"
title="Transaction"
description="Inspect a single transaction and pivot into its block, counterparties, or a broader explorer search when you need more context."
title={transaction?.hash ? `${transaction.hash.slice(0, 10)}` : 'Transaction'}
description="Block, counterparties, token transfers, and internal calls."
actions={[
{ href: '/transactions', label: 'All transactions' },
{ href: '/blocks', label: 'Recent blocks' },
{ href: '/search', label: 'Search explorer' },
{ href: '/search', label: 'Search' },
...(transaction ? [{ href: `/blocks/${transaction.block_number}`, label: 'Open block' }] : []),
]}
/>
<div className="mb-6 flex flex-wrap gap-3 text-sm">
<Link href="/transactions" className="text-primary-600 hover:underline">
Back to transactions
</Link>
{(transaction?.hash || hash) && (
<Link href={`/search?q=${encodeURIComponent(transaction?.hash || hash)}`} className="text-primary-600 hover:underline">
Search this hash
</Link>
)}
</div>
{!router.isReady || loading ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Loading transaction...</p>
@@ -451,7 +443,17 @@ export default function TransactionDetailPage() {
</div>
</Card>
) : (
<div className="space-y-6">
<div className="space-y-4">
<CompactMetricBar
metrics={[
{ label: 'Outcome', value: transaction.status === 1 ? 'Success' : 'Failed' },
{ label: 'Gas', value: gasUtilization != null ? `${gasUtilization}%` : '—' },
{ label: 'Value', value: formatWeiAsEth(transaction.value) },
{ label: 'Block', value: `#${transaction.block_number}` },
]}
/>
<DisclosureSection title="Execution summary detail" defaultOpen={false} alwaysCollapsible>
<Card title="Execution Summary">
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -472,7 +474,7 @@ export default function TransactionDetailPage() {
Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'}
{nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
</div>
<div>Gas used: {transaction.gas_used != null ? transaction.gas_used.toLocaleString() : 'Unavailable'}</div>
<div>Gas used: {transaction.gas_used != null ? formatInteger(transaction.gas_used) : 'Unavailable'}</div>
<div>Utilization: {gasUtilization != null ? `${gasUtilization}%` : 'Unavailable'}</div>
</div>
</div>
@@ -491,8 +493,8 @@ export default function TransactionDetailPage() {
{checkpointTotalUsd != null ? ` — total ${formatUsd(Number(checkpointTotalUsd))}` : ''}
</div>
) : null}
<div>Token transfers: {tokenTransferCount.toLocaleString()}</div>
<div>Internal calls: {internalCallCount.toLocaleString()}</div>
<div>Token transfers: {formatInteger(tokenTransferCount)}</div>
<div>Internal calls: {formatInteger(internalCallCount)}</div>
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -513,25 +515,28 @@ export default function TransactionDetailPage() {
</div>
</div>
</Card>
</DisclosureSection>
{checkpointAttestation ? (
<DisclosureSection title="Mainnet attestation" defaultOpen={false} alwaysCollapsible>
<MainnetAttestationPanel
batchId={checkpointAttestation.batchId}
batchTotalUsd={checkpointAttestation.batchTotalUsd}
links={checkpointAttestation.etherscanLinks ?? []}
/>
</DisclosureSection>
) : null}
<SectionTabs
tabs={tabs}
activeTab={activeTab}
onChange={setActiveTab}
className="mb-6"
className="mb-4"
idPrefix={TRANSACTION_DETAIL_TABS_ID}
ariaLabel="Transaction details"
/>
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'evidence', activeTab)}>
<TabPanel idPrefix={TRANSACTION_DETAIL_TABS_ID} tabId="evidence" activeTab={activeTab}>
{complianceAssessment ? (
<Card title="Transaction Evidence Matrix">
<div className="space-y-4">
@@ -565,9 +570,9 @@ export default function TransactionDetailPage() {
</p>
</Card>
)}
</div>
</TabPanel>
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'details', activeTab)}>
<TabPanel idPrefix={TRANSACTION_DETAIL_TABS_ID} tabId="details" activeTab={activeTab}>
<Card title="Transaction Information">
<dl className="space-y-4">
<DetailRow label="Hash">
@@ -616,7 +621,7 @@ export default function TransactionDetailPage() {
</DetailRow>
{transaction.gas_used != null && (
<DetailRow label="Gas Used">
{transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()}
{formatInteger(transaction.gas_used)} / {formatInteger(transaction.gas_limit)}
</DetailRow>
)}
{transaction.revert_reason && (
@@ -662,9 +667,9 @@ export default function TransactionDetailPage() {
</div>
</Card>
)}
</div>
</TabPanel>
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'transfers', activeTab)}>
<TabPanel idPrefix={TRANSACTION_DETAIL_TABS_ID} tabId="transfers" activeTab={activeTab}>
<Card title="Token Transfers">
<Table
columns={tokenTransferColumns}
@@ -674,9 +679,9 @@ export default function TransactionDetailPage() {
/>
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Token transfers" />
</Card>
</div>
</TabPanel>
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'internal', activeTab)}>
<TabPanel idPrefix={TRANSACTION_DETAIL_TABS_ID} tabId="internal" activeTab={activeTab}>
<Card title="Internal Transactions">
<Table
columns={internalCallColumns}
@@ -686,16 +691,16 @@ export default function TransactionDetailPage() {
/>
<PaginationControls page={internalPage} pageCount={internalPageCount} onPageChange={setInternalPage} label="Internal calls" />
</Card>
</div>
</TabPanel>
{transaction.input_data ? (
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'raw', activeTab)}>
<TabPanel idPrefix={TRANSACTION_DETAIL_TABS_ID} tabId="raw" activeTab={activeTab}>
<Card title="Raw Input Data">
<pre className="overflow-x-auto whitespace-pre-wrap break-all rounded-lg bg-gray-50 p-4 text-xs text-gray-800 dark:bg-gray-950 dark:text-gray-200">
{transaction.input_data}
</pre>
</Card>
</div>
</TabPanel>
) : null}
</div>
)}

View File

@@ -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<Transaction[]>(initialTransactions)
const [latestBlocks, setLatestBlocks] = useState(initialLatestBlocks)
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [loading, setLoading] = useState(initialTransactions.length === 0)
const [filterTab, setFilterTab] = useState<TransactionFilterTab>('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<SectionTab<TransactionFilterTab>[]>(() => {
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 (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Indexed Flow"
title="Transactions"
description="Review recent Chain 138 transactions and move directly into the linked block, address, search, and watchlist surfaces from here."
description="Recent Chain 138 transactions with filters for status, contract creation, and token transfers."
actions={[
{ href: '/blocks', label: 'Open blocks' },
{ href: '/addresses', label: 'Browse addresses' },
{ href: '/watchlist', label: 'Open watchlist' },
{ href: '/blocks', label: 'Blocks' },
{ href: '/search', label: 'Search' },
]}
/>
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Transaction Recency Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={initialStats}
bridgeStatus={initialBridgeStatus}
scopeLabel="This page reflects the latest indexed visible transaction activity."
/>
</div>
<ExplorerFreshnessDisclosure
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
activityTitle="Recency"
scopeLabel="Indexed transaction activity on this page."
/>
{!loading && transactions.length > 0 && (
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Sample Size</div>
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.sampleSize.toLocaleString()}</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">Transactions on the current explorer page.</div>
</Card>
<Card>
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Success Rate</div>
<div className="mt-2 flex items-center gap-2">
<span className="text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.successRate}%</span>
<EntityBadge label={transactionSummary.successRate >= 90 ? 'healthy' : 'mixed'} tone={transactionSummary.successRate >= 90 ? 'success' : 'warning'} />
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">Based on the visible recent transaction sample.</div>
</Card>
<Card>
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Contract Creations</div>
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.contractCreations.toLocaleString()}</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">New contracts created in the visible sample.</div>
</Card>
<Card>
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Avg Sample Fee</div>
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.averageFee || 'Unavailable'}</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Token-transfer txs: {transactionSummary.tokenTransferTransactions.toLocaleString()}
</div>
</Card>
<SectionTabs
tabs={filterTabs}
activeTab={filterTab}
onChange={setFilterTab}
idPrefix="transactions-filter"
ariaLabel="Transaction filters"
className="-mx-4 mb-4 px-4"
/>
<ListFilterBar
query={filterQuery}
onQueryChange={setFilterQuery}
placeholder="Filter visible transactions by hash or address"
resultCount={paginatedTransactions.length}
totalCount={filteredTransactions.length}
className="mb-4"
/>
{!loading && paginatedTransactions.length > 0 && (
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
<span>
<span className="font-medium text-gray-900 dark:text-white">{transactionSummary.sampleSize}</span> on page
</span>
<span>
<span className="font-medium text-gray-900 dark:text-white">{transactionSummary.successRate}%</span> success
</span>
<span>
<span className="font-medium text-gray-900 dark:text-white">{transactionSummary.contractCreations}</span> contracts
</span>
<span>
<span className="font-medium text-gray-900 dark:text-white">{transactionSummary.tokenTransferTransactions}</span> with transfers
</span>
{transactionSummary.averageFee ? (
<span>
avg fee <span className="font-medium text-gray-900 dark:text-white">{transactionSummary.averageFee}</span>
</span>
) : null}
</div>
)}
@@ -248,8 +354,12 @@ export default function TransactionsPage({
) : (
<Table
columns={columns}
data={transactions}
emptyMessage="Recent transactions are unavailable right now."
data={paginatedTransactions}
emptyMessage={
filterTab === 'all' && !filterQuery.trim()
? 'Recent transactions are unavailable right now.'
: 'No transactions on this page match the current filter.'
}
keyExtractor={(tx) => tx.hash}
/>
)}
@@ -265,55 +375,25 @@ export default function TransactionsPage({
className="mt-6"
/>
) : null}
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<Card title="Next Steps">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Use the linked hashes above to inspect detail pages, or pivot into block production, address activity, and explorer-wide search.
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/blocks" className="text-primary-600 hover:underline">
Blocks
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Addresses
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search
</Link>
</div>
</Card>
</div>
</div>
)
}
export const getServerSideProps: GetServerSideProps<TransactionsPageProps> = 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<Record<string, unknown>>('/api/v2/stats').catch(() => null),
fetchPublicJson<MissionControlBridgeStatusResponse>('/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,
},
}
}

View File

@@ -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 <WalletPage {...props} />
}
export const getServerSideProps: GetServerSideProps<WalletRoutePageProps> = async () => {
const [networksResult, tokenListResult, capabilitiesResult] = await Promise.all([
fetchPublicJsonWithMeta<NetworksCatalog>('/api/config/networks').catch(() => null),
fetchPublicJsonWithMeta<TokenListCatalog>('/api/v1/report/token-list?chainId=138&wallet=1').catch(() => null),
fetchPublicJsonWithMeta<CapabilitiesCatalog>('/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<WalletRoutePageProps> = async () => ({
props: {
initialNetworks: null,
initialTokenList: null,
initialCapabilities: null,
initialNetworksMeta: null,
initialTokenListMeta: null,
initialCapabilitiesMeta: null,
},
})

View File

@@ -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<string[]>([])
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
const [snapshots, setSnapshots] = useState<Record<string, TrackedAddressSnapshot>>({})
@@ -178,53 +178,29 @@ export default function WatchlistPage() {
}
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<div className="container mx-auto px-4 py-4 sm:py-6">
<PageIntro
compact
eyebrow="Tracked Entities"
title="Watchlist"
description={
mode === 'guided'
? 'Track the addresses that matter most, review a quick explorer snapshot for each one, and move directly into address detail or recent indexed transactions from one shared workflow surface.'
: 'Track important addresses, review quick snapshots, and jump directly into explorer workflows.'
}
description="Track addresses, review quick snapshots, and jump into detail pages."
actions={[
{ href: '/addresses', label: 'Browse addresses' },
{ href: '/search', label: 'Search explorer' },
{ href: '/transactions', label: 'Recent transactions' },
{ href: '/addresses', label: 'Addresses' },
{ href: '/search', label: 'Search' },
]}
/>
<Card title="Tracked Entities Overview">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Tracked addresses</div>
<div className="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">{entries.length}</div>
</div>
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent indexed activity</div>
<div className="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">{trackedSummaries.withRecentTransactions}</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Entries with at least one visible recent transaction.</div>
</div>
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">EOAs / contracts</div>
<div className="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
{trackedSummaries.eoas} / {trackedSummaries.contracts}
</div>
</div>
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Visible tx volume</div>
<div className="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
{trackedSummaries.totalTransactions.toLocaleString()}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Aggregate indexed transaction count across tracked entries.</div>
</div>
</div>
<Explain>
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.
</Explain>
</Card>
<CompactMetricBar
className="mb-4"
metrics={[
{ label: 'Tracked', value: String(entries.length) },
{ label: 'With recent tx', value: String(trackedSummaries.withRecentTransactions) },
{ label: 'EOAs', value: String(trackedSummaries.eoas) },
{ label: 'Contracts', value: String(trackedSummaries.contracts) },
]}
/>
<Card title="Connected Wallet">
<DisclosureSection title="Connected wallet" defaultOpen={Boolean(walletSession)} alwaysCollapsible className="mb-4">
{walletSession ? (
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
@@ -261,9 +237,9 @@ export default function WatchlistPage() {
Connect a wallet from the <Link href="/wallet" className="text-primary-600 hover:underline">wallet tools page</Link> to pin your own address into tracked workflows.
</div>
)}
</Card>
</DisclosureSection>
<Card title="Saved Addresses">
<Card title="Saved addresses">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-gray-600 dark:text-gray-400">
{entries.length === 0
@@ -314,31 +290,13 @@ export default function WatchlistPage() {
<Link href={`/addresses/${entry}`} className="text-primary-600 hover:underline">
<Address address={entry} showCopy={false} />
</Link>
<div className="mt-2 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Type</div>
<div className="mt-1 text-sm text-gray-900 dark:text-white">
{info ? (info.is_contract ? 'Contract' : 'EOA') : loadingSnapshots ? 'Loading…' : 'Unknown'}
</div>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Indexed txs</div>
<div className="mt-1 text-sm text-gray-900 dark:text-white">
{info ? info.transaction_count.toLocaleString() : loadingSnapshots ? 'Loading…' : 'Unknown'}
</div>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token holdings</div>
<div className="mt-1 text-sm text-gray-900 dark:text-white">
{info ? info.token_count.toLocaleString() : loadingSnapshots ? 'Loading…' : 'Unknown'}
</div>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent visible tx</div>
<div className="mt-1 text-sm text-gray-900 dark:text-white">
{recentTransaction ? `#${recentTransaction.block_number.toLocaleString()}` : loadingSnapshots ? 'Loading…' : 'None visible'}
</div>
</div>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-gray-400">
<span>{info ? (info.is_contract ? 'Contract' : 'EOA') : loadingSnapshots ? '…' : 'Unknown'}</span>
<span>{info ? `${info.transaction_count} txs` : ''}</span>
<span>{info ? `${info.token_count} tokens` : ''}</span>
<span>
{recentTransaction ? `Latest #${recentTransaction.block_number}` : loadingSnapshots ? '' : 'No recent tx'}
</span>
</div>
</div>

View File

@@ -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)

View File

@@ -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 ??

View File

@@ -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')
})
})

View File

@@ -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
}

View File

@@ -1,3 +1,5 @@
import { formatBigIntWhole } from './format'
export type EthereumRpcProvider = {
request: (args: { method: string; params?: unknown }) => Promise<unknown>
}
@@ -100,10 +102,10 @@ export async function filterTokensWithNonZeroBalance<T extends WalletBalanceToke
export function formatNativeEthFromWei(wei: bigint, maxFractionDigits = 4): string {
const whole = wei / 10n ** 18n
const fraction = wei % 10n ** 18n
if (fraction === 0n) return `${whole.toLocaleString()} ETH`
if (fraction === 0n) return `${formatBigIntWhole(whole)} ETH`
const fractionStr = fraction.toString().padStart(18, '0').replace(/0+$/, '')
const trimmed = fractionStr.slice(0, maxFractionDigits).replace(/0+$/, '')
if (!trimmed) return `${whole.toLocaleString()} ETH`
return `${whole.toLocaleString()}.${trimmed} ETH`
if (!trimmed) return `${formatBigIntWhole(whole)} ETH`
return `${formatBigIntWhole(whole)}.${trimmed} ETH`
}

View File

@@ -0,0 +1,70 @@
import { vi } from 'vitest'
import { switchOrAddChain, watchTokenInWallet } from '@/utils/walletWatchToken'
import type { WalletChain } from '@/components/wallet/AddToMetaMask'
const chain138: 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'],
}
describe('walletWatchToken', () => {
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',
}),
},
})
})
})

View File

@@ -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<boolean> {
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.' }
}
}

View File

@@ -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}"