feat: explorer API, wallet, CCIP scripts, and config refresh

- Backend REST/gateway/track routes, analytics, Blockscout proxy paths.
- Frontend wallet and liquidity surfaces; MetaMask token list alignment.
- Deployment docs, verification scripts, address inventory updates.

Check: go build ./... under backend/ (pass).
Made-with: Cursor
This commit is contained in:
defiQUG
2026-04-07 23:22:12 -07:00
parent 4044fb07e1
commit bdae5a9f6e
224 changed files with 19671 additions and 3291 deletions

View File

@@ -1,9 +1,10 @@
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
import type { Block } from './blocks'
import type { Transaction } from './transactions'
import type { TransactionSummary } from './addresses'
export function getExplorerApiBase() {
return (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080').replace(/\/$/, '')
return resolveExplorerApiBase()
}
export async function fetchBlockscoutJson<T>(path: string): Promise<T> {

View File

@@ -11,7 +11,6 @@ function getApiKey(): string | null {
}
export const apiClient = createApiClient(
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
undefined,
getApiKey
)

View File

@@ -0,0 +1,61 @@
import { getExplorerApiBase } from './blockscout'
export interface NetworksConfigChain {
chainIdDecimal?: number
chainName?: string
shortName?: string
}
export interface NetworksConfigResponse {
defaultChainId?: number
chains?: NetworksConfigChain[]
}
export interface TokenListToken {
chainId?: number
symbol?: string
address?: string
name?: string
decimals?: number
logoURI?: string
}
export interface TokenListResponse {
tokens?: TokenListToken[]
}
export interface CapabilitiesResponse {
chainId?: number
chainName?: string
walletSupport?: {
walletAddEthereumChain?: boolean
walletWatchAsset?: boolean
}
http?: {
supportedMethods?: string[]
unsupportedMethods?: string[]
}
tracing?: {
supportedMethods?: string[]
unsupportedMethods?: string[]
}
}
async function fetchJson<T>(path: string): Promise<T> {
const response = await fetch(`${getExplorerApiBase()}${path}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return (await response.json()) as T
}
export const configApi = {
getNetworks: async (): Promise<NetworksConfigResponse> =>
fetchJson<NetworksConfigResponse>('/api/config/networks'),
getTokenList: async (): Promise<TokenListResponse> =>
fetchJson<TokenListResponse>('/api/config/token-list'),
getCapabilities: async (): Promise<CapabilitiesResponse> =>
fetchJson<CapabilitiesResponse>('/api/config/capabilities'),
}

View File

@@ -0,0 +1,105 @@
import type { TokenListToken } from './config'
import type { MissionControlLiquidityPool, RouteMatrixResponse, RouteMatrixRoute } from './routes'
import type { PlannerCapabilitiesResponse, PlannerProviderCapability } from './planner'
export const featuredLiquiditySymbols = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cEURT', 'WETH10']
export interface FeaturedLiquidityToken {
symbol: string
address: string
name?: string
}
export interface AggregatedLiquidityPool extends MissionControlLiquidityPool {
sourceSymbols: string[]
}
export function selectFeaturedLiquidityTokens(tokens: TokenListToken[] = []): FeaturedLiquidityToken[] {
const selected = new Map<string, FeaturedLiquidityToken>()
for (const symbol of featuredLiquiditySymbols) {
const token = tokens.find(
(entry) => entry.chainId === 138 && entry.symbol === symbol && typeof entry.address === 'string' && entry.address.trim().length > 0
)
if (token?.address) {
selected.set(symbol, {
symbol,
address: token.address,
name: token.name,
})
}
}
return Array.from(selected.values())
}
export function aggregateLiquidityPools(
records: Array<{ symbol: string; pools: MissionControlLiquidityPool[] }>
): AggregatedLiquidityPool[] {
const merged = new Map<string, AggregatedLiquidityPool>()
for (const record of records) {
for (const pool of record.pools || []) {
if (!pool.address) continue
const key = pool.address.toLowerCase()
const existing = merged.get(key)
if (existing) {
if (!existing.sourceSymbols.includes(record.symbol)) {
existing.sourceSymbols.push(record.symbol)
}
if ((pool.tvl || 0) > (existing.tvl || 0)) {
existing.tvl = pool.tvl
}
} else {
merged.set(key, {
...pool,
sourceSymbols: [record.symbol],
})
}
}
}
return Array.from(merged.values()).sort((left, right) => {
const tvlDelta = (right.tvl || 0) - (left.tvl || 0)
if (tvlDelta !== 0) return tvlDelta
return (left.address || '').localeCompare(right.address || '')
})
}
export function getRouteBackedPoolAddresses(routeMatrix: RouteMatrixResponse | null | undefined): string[] {
const addresses = new Set<string>()
for (const route of routeMatrix?.liveRoutes || []) {
for (const leg of route.legs || []) {
if (leg.poolAddress) {
addresses.add(leg.poolAddress.toLowerCase())
}
}
}
return Array.from(addresses)
}
export function getTopLiquidityRoutes(
routeMatrix: RouteMatrixResponse | null | undefined,
limit = 8
): RouteMatrixRoute[] {
const liveRoutes = routeMatrix?.liveRoutes || []
return [...liveRoutes]
.filter((route) => route.routeType === 'swap')
.sort((left, right) => {
const leftPools = (left.legs || []).filter((leg) => leg.poolAddress).length
const rightPools = (right.legs || []).filter((leg) => leg.poolAddress).length
if (leftPools !== rightPools) return rightPools - leftPools
return (left.label || left.routeId).localeCompare(right.label || right.routeId)
})
.slice(0, limit)
}
export function getLivePlannerProviders(
capabilities: PlannerCapabilitiesResponse | null | undefined
): PlannerProviderCapability[] {
return (capabilities?.providers || []).filter((provider) => provider.live)
}

View File

@@ -0,0 +1,252 @@
import { getExplorerApiBase } from './blockscout'
export interface MissionControlRelaySummary {
text: string
tone: 'normal' | 'warning' | 'danger'
items: MissionControlRelayItemSummary[]
}
export interface MissionControlRelayItemSummary {
key: string
label: string
status: string
text: string
tone: 'normal' | 'warning' | 'danger'
}
export interface MissionControlRelaySnapshot {
status?: string
service?: {
profile?: string
}
source?: {
chain_name?: string
bridge_filter?: string
}
destination?: {
chain_name?: string
relay_bridge?: string
relay_bridge_default?: string
}
queue?: {
size?: number
processed?: number
failed?: number
}
last_source_poll?: {
at?: string
ok?: boolean
logs_fetched?: number
}
}
export interface MissionControlRelayProbe {
ok?: boolean
body?: MissionControlRelaySnapshot
}
export interface MissionControlRelayPayload {
url_probe?: MissionControlRelayProbe
file_snapshot?: MissionControlRelaySnapshot
file_snapshot_error?: string
}
export interface MissionControlChainStatus {
status?: string
name?: string
head_age_sec?: number
latency_ms?: number
block_number?: string
}
export interface MissionControlBridgeStatusResponse {
data?: {
status?: string
checked_at?: string
chains?: Record<string, MissionControlChainStatus>
ccip_relay?: MissionControlRelayPayload
ccip_relays?: Record<string, MissionControlRelayPayload>
}
}
const missionControlRelayLabels: Record<string, string> = {
mainnet: 'Mainnet',
mainnet_weth: 'Mainnet WETH',
mainnet_cw: 'Mainnet cW',
bsc: 'BSC',
avax: 'Avalanche',
avalanche: 'Avalanche',
avax_cw: 'Avalanche cW',
avax_to_138: 'Avalanche -> 138',
}
function getMissionControlStreamUrl(): string {
return `${getExplorerApiBase()}/explorer-api/v1/mission-control/stream`
}
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`
}
export function summarizeMissionControlRelay(
response: MissionControlBridgeStatusResponse | null | undefined
): MissionControlRelaySummary | null {
const relays = getMissionControlRelays(response)
if (!relays) return null
const items = Object.entries(relays)
.map(([key, relay]): MissionControlRelayItemSummary | null => {
const label = getMissionControlRelayLabel(key)
if (relay.url_probe && relay.url_probe.ok === false) {
return {
key,
label,
status: 'down',
text: `${label}: probe failed`,
tone: 'danger',
}
}
if (relay.file_snapshot_error) {
return {
key,
label,
status: 'snapshot-error',
text: `${label}: snapshot error`,
tone: 'danger',
}
}
const snapshot = relay.url_probe?.body || relay.file_snapshot
if (!snapshot) {
return {
key,
label,
status: 'configured',
text: `${label}: probe configured`,
tone: 'normal',
}
}
const status = String(snapshot.status || 'unknown').toLowerCase()
const destination = snapshot.destination?.chain_name
const queueSize = snapshot.queue?.size
const pollAge = relativeAge(snapshot.last_source_poll?.at)
let text = `${label}: ${status}`
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)) {
tone = 'warning'
}
if (['degraded', 'stale', 'stopped', 'down'].includes(status)) {
tone = 'danger'
}
return { key, label, status, text, tone }
})
.filter((item): item is MissionControlRelayItemSummary => item !== null)
if (items.length === 0) return null
const tone: MissionControlRelaySummary['tone'] = items.some((item) => item.tone === 'danger')
? 'danger'
: items.some((item) => item.tone === 'warning')
? 'warning'
: 'normal'
const text =
items.length === 1
? items[0].text
: tone === 'normal'
? `${items.length} relay lanes operational`
: `Relay lanes need attention`
return { text, tone, items }
}
export function getMissionControlRelays(
response: MissionControlBridgeStatusResponse | null | undefined
): Record<string, MissionControlRelayPayload> | null {
return response?.data?.ccip_relays && Object.keys(response.data.ccip_relays).length > 0
? response.data.ccip_relays
: response?.data?.ccip_relay
? { mainnet: response.data.ccip_relay }
: null
}
export function getMissionControlRelayLabel(key: string): string {
return missionControlRelayLabels[key] || key.toUpperCase()
}
export const missionControlApi = {
getBridgeStatus: async (): Promise<MissionControlBridgeStatusResponse> => {
const response = await fetch(getMissionControlBridgeStatusUrl())
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return (await response.json()) as MissionControlBridgeStatusResponse
},
getRelaySummary: async (): Promise<MissionControlRelaySummary | null> => {
const json = await missionControlApi.getBridgeStatus()
return summarizeMissionControlRelay(json)
},
subscribeBridgeStatus: (
onStatus: (status: MissionControlBridgeStatusResponse) => void,
onError?: (error: unknown) => void
): (() => void) => {
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') {
onError?.(new Error('EventSource is not available in this environment'))
return () => {}
}
const eventSource = new window.EventSource(getMissionControlStreamUrl())
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as MissionControlBridgeStatusResponse
onStatus(payload)
} catch (error) {
onError?.(error)
}
}
eventSource.onerror = () => {
onError?.(new Error('Mission-control live stream connection lost'))
}
return () => {
eventSource.close()
}
},
subscribeRelaySummary: (
onSummary: (summary: MissionControlRelaySummary | null) => void,
onError?: (error: unknown) => void
): (() => void) => {
return missionControlApi.subscribeBridgeStatus(
(payload) => {
onSummary(summarizeMissionControlRelay(payload))
},
onError
)
},
}

View File

@@ -0,0 +1,62 @@
import { getExplorerApiBase } from './blockscout'
export interface PlannerProviderPair {
tokenInSymbol?: string
tokenOutSymbol?: string
status?: string
}
export interface PlannerProviderCapability {
chainId?: number
provider?: string
executionMode?: string
live?: boolean
quoteLive?: boolean
executionLive?: boolean
supportedLegTypes?: string[]
pairs?: PlannerProviderPair[]
notes?: string[]
}
export interface PlannerCapabilitiesResponse {
providers?: PlannerProviderCapability[]
}
export interface InternalExecutionPlanResponse {
plannerResponse?: {
decision?: string
steps?: unknown[]
}
execution?: {
contractAddress?: string
}
}
const plannerBase = `${getExplorerApiBase()}/token-aggregation/api/v2`
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return (await response.json()) as T
}
export const plannerApi = {
getCapabilities: async (chainId = 138): Promise<PlannerCapabilitiesResponse> =>
fetchJson<PlannerCapabilitiesResponse>(`${plannerBase}/providers/capabilities?chainId=${chainId}`),
getInternalExecutionPlan: async (): Promise<InternalExecutionPlanResponse> =>
fetchJson<InternalExecutionPlanResponse>(`${plannerBase}/routes/internal-execution-plan`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
sourceChainId: 138,
tokenIn: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
tokenOut: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
amountIn: '100000000000000000',
}),
}),
}

View File

@@ -0,0 +1,95 @@
import { getExplorerApiBase } from './blockscout'
export interface RouteMatrixLeg {
protocol?: string
executor?: string
poolAddress?: string
}
export interface RouteMatrixRoute {
routeId: string
status?: string
label?: string
routeType?: string
fromChainId?: number
toChainId?: number
tokenInSymbol?: string
tokenOutSymbol?: string
assetSymbol?: string
hopCount?: number
aggregatorFamilies?: string[]
notes?: string[]
reason?: string
tokenInSymbols?: string[]
legs?: RouteMatrixLeg[]
}
export interface RouteMatrixCounts {
liveSwapRoutes?: number
liveBridgeRoutes?: number
blockedOrPlannedRoutes?: number
filteredLiveRoutes?: number
}
export interface RouteMatrixResponse {
generatedAt?: string
updated?: string
version?: string
homeChainId?: number
liveRoutes?: RouteMatrixRoute[]
blockedOrPlannedRoutes?: RouteMatrixRoute[]
counts?: RouteMatrixCounts
}
export interface ExplorerNetwork {
chainIdDecimal?: number
chainName?: string
shortName?: string
}
export interface NetworksResponse {
version?: string
source?: string
lastModified?: string
networks?: ExplorerNetwork[]
}
export interface MissionControlLiquidityPool {
address: string
dex?: string
token0?: {
symbol?: string
}
token1?: {
symbol?: string
}
tvl?: number
}
export interface MissionControlLiquidityPoolsResponse {
pools?: MissionControlLiquidityPool[]
}
const tokenAggregationBase = `${getExplorerApiBase()}/token-aggregation/api/v1`
const missionControlBase = `${getExplorerApiBase()}/explorer-api/v1/mission-control`
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return (await response.json()) as T
}
export const routesApi = {
getNetworks: async (): Promise<NetworksResponse> =>
fetchJson<NetworksResponse>(`${tokenAggregationBase}/networks`),
getRouteMatrix: async (): Promise<RouteMatrixResponse> =>
fetchJson<RouteMatrixResponse>(`${tokenAggregationBase}/routes/matrix?includeNonLive=true`),
getTokenPools: async (tokenAddress: string): Promise<MissionControlLiquidityPoolsResponse> =>
fetchJson<MissionControlLiquidityPoolsResponse>(
`${missionControlBase}/liquidity/token/${tokenAddress}/pools`
),
}

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { normalizeExplorerStats } from './stats'
describe('normalizeExplorerStats', () => {
it('normalizes the local explorer stats shape', () => {
expect(
normalizeExplorerStats({
total_blocks: 12,
total_transactions: 34,
total_addresses: 56,
latest_block: 78,
})
).toEqual({
total_blocks: 12,
total_transactions: 34,
total_addresses: 56,
latest_block: 78,
})
})
it('normalizes the public Blockscout stats shape with string counts and no latest block', () => {
expect(
normalizeExplorerStats({
total_blocks: '2784760',
total_transactions: '15788',
total_addresses: '376',
})
).toEqual({
total_blocks: 2784760,
total_transactions: 15788,
total_addresses: 376,
latest_block: null,
})
})
})

View File

@@ -0,0 +1,46 @@
import { getExplorerApiBase } from './blockscout'
export interface ExplorerStats {
total_blocks: number
total_transactions: number
total_addresses: number
latest_block: number | null
}
interface RawExplorerStats {
total_blocks?: number | string | null
total_transactions?: number | string | null
total_addresses?: number | string | null
latest_block?: number | string | null
}
function toNumber(value: number | string | null | undefined): number {
if (typeof value === 'number') return value
if (typeof value === 'string' && value.trim() !== '') return Number(value)
return 0
}
export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
const latestBlockValue = raw.latest_block
return {
total_blocks: toNumber(raw.total_blocks),
total_transactions: toNumber(raw.total_transactions),
total_addresses: toNumber(raw.total_addresses),
latest_block:
latestBlockValue == null || latestBlockValue === ''
? null
: toNumber(latestBlockValue),
}
}
export const statsApi = {
get: async (): Promise<ExplorerStats> => {
const response = await fetch(`${getExplorerApiBase()}/api/v2/stats`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const json = (await response.json()) as RawExplorerStats
return normalizeExplorerStats(json)
},
}