From 847cfeb48b7245f37cf11004426c9c8d38ddb133 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Fri, 22 May 2026 21:12:21 -0700 Subject: [PATCH] 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 --- .../explorer/BridgeMonitoringPage.tsx | 86 +++++++++++++++++++ .../src/services/api/bridgeRoutes.test.ts | 29 +++++++ frontend/src/services/api/bridgeRoutes.ts | 55 ++++++++++++ scripts/e2e-sprint-smoke.spec.ts | 6 ++ 4 files changed, 176 insertions(+) create mode 100644 frontend/src/services/api/bridgeRoutes.test.ts create mode 100644 frontend/src/services/api/bridgeRoutes.ts diff --git a/frontend/src/components/explorer/BridgeMonitoringPage.tsx b/frontend/src/components/explorer/BridgeMonitoringPage.tsx index ea7c803..4dbc8cf 100644 --- a/frontend/src/components/explorer/BridgeMonitoringPage.tsx +++ b/frontend/src/components/explorer/BridgeMonitoringPage.tsx @@ -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(initialBridgeStatus) const [stats, setStats] = useState(initialStats) + const [bridgeRoutes, setBridgeRoutes] = useState(null) const [feedState, setFeedState] = useState(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({ ))} + {routeEntries.length > 0 ? ( + +

+ Destination bridge contracts from{' '} + + /token-aggregation/api/v1/bridge/routes + + {bridgeRoutes?.source ? ( + <> + {' '} + (source: {bridgeRoutes.source} + {bridgeRoutes.lastModified ? ` ยท updated ${relativeAge(bridgeRoutes.lastModified)}` : ''}) + + ) : null} + . +

+
+ + + + + + + + + + {routeEntries.map((entry) => ( + + + + + + ))} + +
BridgeDestinationContract
{entry.bridge}{entry.destination}{entry.address}
+
+
+ ) : null} +
{page.actions.map((action) => ( diff --git a/frontend/src/services/api/bridgeRoutes.test.ts b/frontend/src/services/api/bridgeRoutes.test.ts new file mode 100644 index 0000000..9a06173 --- /dev/null +++ b/frontend/src/services/api/bridgeRoutes.test.ts @@ -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', + }, + ]) + }) +}) diff --git a/frontend/src/services/api/bridgeRoutes.ts b/frontend/src/services/api/bridgeRoutes.ts new file mode 100644 index 0000000..9289711 --- /dev/null +++ b/frontend/src/services/api/bridgeRoutes.ts @@ -0,0 +1,55 @@ +import { getExplorerApiBase } from './blockscout' + +export interface BridgeRouteCatalog { + weth9: Record + weth10: Record + trustless?: Record +} + +export interface BridgeRoutesResponse { + source?: string + lastModified?: string + updated?: string + description?: string + routes: BridgeRouteCatalog + chain138Bridges?: Record +} + +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 } + } + }, +} diff --git a/scripts/e2e-sprint-smoke.spec.ts b/scripts/e2e-sprint-smoke.spec.ts index b39fe8d..5fe1bdc 100644 --- a/scripts/e2e-sprint-smoke.spec.ts +++ b/scripts/e2e-sprint-smoke.spec.ts @@ -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 }) + }) })