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:
@@ -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> {
|
||||
|
||||
@@ -11,7 +11,6 @@ function getApiKey(): string | null {
|
||||
}
|
||||
|
||||
export const apiClient = createApiClient(
|
||||
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
|
||||
undefined,
|
||||
getApiKey
|
||||
)
|
||||
|
||||
|
||||
61
frontend/src/services/api/config.ts
Normal file
61
frontend/src/services/api/config.ts
Normal 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'),
|
||||
}
|
||||
105
frontend/src/services/api/liquidity.ts
Normal file
105
frontend/src/services/api/liquidity.ts
Normal 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)
|
||||
}
|
||||
252
frontend/src/services/api/missionControl.ts
Normal file
252
frontend/src/services/api/missionControl.ts
Normal 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
|
||||
)
|
||||
},
|
||||
}
|
||||
62
frontend/src/services/api/planner.ts
Normal file
62
frontend/src/services/api/planner.ts
Normal 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',
|
||||
}),
|
||||
}),
|
||||
}
|
||||
95
frontend/src/services/api/routes.ts
Normal file
95
frontend/src/services/api/routes.ts
Normal 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`
|
||||
),
|
||||
}
|
||||
35
frontend/src/services/api/stats.test.ts
Normal file
35
frontend/src/services/api/stats.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
46
frontend/src/services/api/stats.ts
Normal file
46
frontend/src/services/api/stats.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user