feat(explorer): API-driven CCIP route catalog on bridge page
Load destination bridge contracts from token-aggregation, add fallback polling, extend smoke tests, and document bridge routes client helper. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,9 @@ import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
import { bridgeRoutesApi, normalizeBridgeRouteEntries, type BridgeRoutesResponse } from '@/services/api/bridgeRoutes'
|
||||
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
|
||||
import { HOME_DASHBOARD_REFRESH_MS } from '@/utils/featuredTokens'
|
||||
|
||||
type FeedState = 'connecting' | 'live' | 'fallback'
|
||||
|
||||
@@ -146,6 +149,7 @@ export default function BridgeMonitoringPage({
|
||||
}) {
|
||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||
const [bridgeRoutes, setBridgeRoutes] = useState<BridgeRoutesResponse | null>(null)
|
||||
const [feedState, setFeedState] = useState<FeedState>(initialBridgeStatus ? 'fallback' : 'connecting')
|
||||
const page = explorerFeaturePages.bridge
|
||||
|
||||
@@ -196,6 +200,49 @@ export default function BridgeMonitoringPage({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
bridgeRoutesApi.getRoutesSafe().then(({ ok, data }) => {
|
||||
if (!cancelled && ok) {
|
||||
setBridgeRoutes(data)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (feedState !== 'fallback') return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const refreshSnapshot = async () => {
|
||||
try {
|
||||
const snapshot = await missionControlApi.getBridgeStatus()
|
||||
if (!cancelled) {
|
||||
setBridgeStatus(snapshot)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to refresh bridge monitoring snapshot:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createVisibilityAwarePoller({
|
||||
intervalMs: HOME_DASHBOARD_REFRESH_MS,
|
||||
task: refreshSnapshot,
|
||||
})
|
||||
}, [feedState])
|
||||
|
||||
const routeEntries = useMemo(
|
||||
() => normalizeBridgeRouteEntries(bridgeRoutes?.routes),
|
||||
[bridgeRoutes?.routes],
|
||||
)
|
||||
|
||||
const activityContext = useMemo(
|
||||
() =>
|
||||
summarizeChainActivity({
|
||||
@@ -407,6 +454,45 @@ export default function BridgeMonitoringPage({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{routeEntries.length > 0 ? (
|
||||
<Card title="CCIP route catalog" className="mb-8">
|
||||
<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}
|
||||
.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
<th className="py-2 pr-4">Bridge</th>
|
||||
<th className="py-2 pr-4">Destination</th>
|
||||
<th className="py-2">Contract</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{routeEntries.map((entry) => (
|
||||
<tr key={`${entry.bridge}-${entry.destination}`} className="border-b border-gray-100 last:border-0 dark:border-gray-800">
|
||||
<td className="py-2 pr-4 font-medium text-gray-900 dark:text-white">{entry.bridge}</td>
|
||||
<td className="py-2 pr-4 text-gray-700 dark:text-gray-300">{entry.destination}</td>
|
||||
<td className="py-2 font-mono text-xs text-gray-600 dark:text-gray-400">{entry.address}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
) : 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">
|
||||
|
||||
29
frontend/src/services/api/bridgeRoutes.test.ts
Normal file
29
frontend/src/services/api/bridgeRoutes.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeBridgeRouteEntries, type BridgeRouteCatalog } from './bridgeRoutes'
|
||||
|
||||
describe('bridgeRoutesApi helpers', () => {
|
||||
it('normalizes WETH9 and WETH10 route tables', () => {
|
||||
const routes: BridgeRouteCatalog = {
|
||||
weth9: {
|
||||
'Ethereum Mainnet (1)': '0x1111111111111111111111111111111111111111',
|
||||
},
|
||||
weth10: {
|
||||
'Base (8453)': '0x2222222222222222222222222222222222222222',
|
||||
},
|
||||
}
|
||||
|
||||
expect(normalizeBridgeRouteEntries(routes)).toEqual([
|
||||
{
|
||||
bridge: 'WETH10',
|
||||
destination: 'Base (8453)',
|
||||
address: '0x2222222222222222222222222222222222222222',
|
||||
},
|
||||
{
|
||||
bridge: 'WETH9',
|
||||
destination: 'Ethereum Mainnet (1)',
|
||||
address: '0x1111111111111111111111111111111111111111',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
55
frontend/src/services/api/bridgeRoutes.ts
Normal file
55
frontend/src/services/api/bridgeRoutes.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { getExplorerApiBase } from './blockscout'
|
||||
|
||||
export interface BridgeRouteCatalog {
|
||||
weth9: Record<string, string>
|
||||
weth10: Record<string, string>
|
||||
trustless?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface BridgeRoutesResponse {
|
||||
source?: string
|
||||
lastModified?: string
|
||||
updated?: string
|
||||
description?: string
|
||||
routes: BridgeRouteCatalog
|
||||
chain138Bridges?: Record<string, string>
|
||||
}
|
||||
|
||||
const bridgeRoutesBase = `${getExplorerApiBase()}/token-aggregation/api/v1/bridge`
|
||||
|
||||
export function normalizeBridgeRouteEntries(
|
||||
routes: BridgeRouteCatalog | null | undefined,
|
||||
): Array<{ bridge: 'WETH9' | 'WETH10'; destination: string; address: string }> {
|
||||
if (!routes) return []
|
||||
|
||||
const entries: Array<{ bridge: 'WETH9' | 'WETH10'; destination: string; address: string }> = []
|
||||
|
||||
for (const [destination, address] of Object.entries(routes.weth9 || {})) {
|
||||
if (address) entries.push({ bridge: 'WETH9', destination, address })
|
||||
}
|
||||
for (const [destination, address] of Object.entries(routes.weth10 || {})) {
|
||||
if (address) entries.push({ bridge: 'WETH10', destination, address })
|
||||
}
|
||||
|
||||
return entries.sort((left, right) =>
|
||||
left.destination.localeCompare(right.destination) || left.bridge.localeCompare(right.bridge),
|
||||
)
|
||||
}
|
||||
|
||||
export const bridgeRoutesApi = {
|
||||
getRoutesSafe: async (): Promise<{ ok: boolean; data: BridgeRoutesResponse | null }> => {
|
||||
try {
|
||||
const response = await fetch(`${bridgeRoutesBase}/routes`)
|
||||
if (!response.ok) {
|
||||
return { ok: false, data: null }
|
||||
}
|
||||
const payload = (await response.json()) as BridgeRoutesResponse
|
||||
if (!payload?.routes?.weth9 || !payload?.routes?.weth10) {
|
||||
return { ok: false, data: null }
|
||||
}
|
||||
return { ok: true, data: payload }
|
||||
} catch {
|
||||
return { ok: false, data: null }
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -31,4 +31,10 @@ test.describe('Explorer sprint smoke', () => {
|
||||
await expect(page.getByRole('heading', { name: /Operations Hub/i })).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText(/Extended Metamask dual-chain catalog/i).first()).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('bridge page loads CCIP route catalog', async ({ page }) => {
|
||||
await page.goto(`${EXPLORER_URL}/bridge`, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
||||
await expect(page.getByRole('heading', { name: /Bridge & Relay Monitoring/i })).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByText(/CCIP route catalog/i).first()).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user