Files
explorer-monorepo/frontend/src/services/api/tokens.ts
defiQUG 1aa81f454a
Some checks failed
phoenix-deploy Deploy failed: Command failed: bash scripts/deployment/phoenix-deploy-explorer-live-from-workspace.sh nginx: the configuration file /et
Deploy Explorer Live / deploy (push) Failing after 4m8s
feat(explorer): add live token/native pricing and legacy tx route compatibility
2026-04-25 23:45:07 -07:00

282 lines
9.3 KiB
TypeScript

import { fetchBlockscoutJson, normalizeAddressTokenTransfer, type BlockscoutTokenTransfer } from './blockscout'
import { configApi, type TokenListToken } from './config'
import { routesApi, type MissionControlLiquidityPool } from './routes'
import { tokenAggregationApi } from './tokenAggregation'
import type { AddressTokenTransfer } from './addresses'
export interface TokenProfile {
address: string
name?: string
symbol?: string
decimals: number
type?: string
total_supply?: string
holders?: number
exchange_rate?: string | number | null
icon_url?: string | null
circulating_market_cap?: string | number | null
volume_24h?: string | number | null
liquidity_usd?: string | number | null
market_updated_at?: string | null
price_source?: 'blockscout' | 'token-aggregation' | 'derived'
}
export interface TokenHolder {
address: string
label?: string
value: string
token_decimals: number
}
export interface TokenProvenance {
listed: boolean
chainId?: number
name?: string
symbol?: string
logoURI?: string
tags: string[]
}
function normalizeTokenProfile(raw: {
address: string
name?: string | null
symbol?: string | null
decimals?: string | number | null
type?: string | null
total_supply?: string | null
holders?: string | number | null
exchange_rate?: string | number | null
icon_url?: string | null
circulating_market_cap?: string | number | null
volume_24h?: string | number | null
liquidity_usd?: string | number | null
market_updated_at?: string | null
price_source?: 'blockscout' | 'token-aggregation' | 'derived'
}): TokenProfile {
return {
address: raw.address,
name: raw.name || undefined,
symbol: raw.symbol || undefined,
decimals: Number(raw.decimals || 0),
type: raw.type || undefined,
total_supply: raw.total_supply || undefined,
holders: raw.holders != null ? Number(raw.holders) : undefined,
exchange_rate: raw.exchange_rate ?? null,
icon_url: raw.icon_url ?? null,
circulating_market_cap: raw.circulating_market_cap ?? null,
volume_24h: raw.volume_24h ?? null,
liquidity_usd: raw.liquidity_usd ?? null,
market_updated_at: raw.market_updated_at ?? null,
price_source: raw.price_source || 'blockscout',
}
}
function computeMarketCap(totalSupply: string | undefined, decimals: number, priceUsd: number | undefined): number | null {
if (!totalSupply || priceUsd == null || !Number.isFinite(priceUsd)) {
return null
}
const supplyNumeric = Number(totalSupply)
if (!Number.isFinite(supplyNumeric) || Math.abs(supplyNumeric) > Number.MAX_SAFE_INTEGER) {
return null
}
const normalizedSupply = supplyNumeric / 10 ** decimals
if (!Number.isFinite(normalizedSupply)) {
return null
}
return normalizedSupply * priceUsd
}
function mergeTokenProfileWithAggregation(
blockscoutToken: TokenProfile | null,
aggregationToken: Awaited<ReturnType<typeof tokenAggregationApi.getTokenSafe>>['data'],
): TokenProfile | null {
if (!blockscoutToken && !aggregationToken) {
return null
}
const priceUsd = aggregationToken?.market?.priceUsd
const merged: TokenProfile = {
address: blockscoutToken?.address || aggregationToken?.address || '',
name: blockscoutToken?.name || aggregationToken?.name,
symbol: blockscoutToken?.symbol || aggregationToken?.symbol,
decimals: blockscoutToken?.decimals || aggregationToken?.decimals || 0,
type: blockscoutToken?.type,
total_supply: blockscoutToken?.total_supply || aggregationToken?.totalSupply,
holders: blockscoutToken?.holders,
exchange_rate: priceUsd ?? blockscoutToken?.exchange_rate ?? null,
icon_url: blockscoutToken?.icon_url ?? null,
circulating_market_cap:
blockscoutToken?.circulating_market_cap ??
computeMarketCap(blockscoutToken?.total_supply || aggregationToken?.totalSupply, blockscoutToken?.decimals || aggregationToken?.decimals || 0, priceUsd),
volume_24h: aggregationToken?.market?.volume24h ?? blockscoutToken?.volume_24h ?? null,
liquidity_usd: aggregationToken?.market?.liquidityUsd ?? blockscoutToken?.liquidity_usd ?? null,
market_updated_at: aggregationToken?.market?.lastUpdated ?? blockscoutToken?.market_updated_at ?? null,
price_source:
priceUsd != null
? 'token-aggregation'
: blockscoutToken?.exchange_rate != null
? 'blockscout'
: blockscoutToken?.circulating_market_cap == null && blockscoutToken?.volume_24h == null && priceUsd == null
? 'derived'
: blockscoutToken?.price_source || 'blockscout',
}
return merged.address ? merged : null
}
function normalizeTokenHolder(raw: {
address?: {
hash?: string | null
name?: string | null
label?: string | null
} | null
value?: string | null
token?: {
decimals?: string | number | null
} | null
}): TokenHolder {
return {
address: raw.address?.hash || '',
label: raw.address?.name || raw.address?.label || undefined,
value: raw.value || '0',
token_decimals: Number(raw.token?.decimals || 0),
}
}
async function getTokenListLookup(): Promise<Map<string, TokenListToken>> {
const response = await configApi.getTokenList()
const lookup = new Map<string, TokenListToken>()
for (const token of response.tokens || []) {
if (token.address) {
lookup.set(token.address.toLowerCase(), token)
}
}
return lookup
}
export const tokensApi = {
getSafe: async (address: string): Promise<{ ok: boolean; data: TokenProfile | null }> => {
try {
const [blockscoutResult, aggregationResult] = await Promise.allSettled([
fetchBlockscoutJson<{
address: string
name?: string | null
symbol?: string | null
decimals?: string | number | null
type?: string | null
total_supply?: string | null
holders?: string | number | null
exchange_rate?: string | number | null
icon_url?: string | null
circulating_market_cap?: string | number | null
volume_24h?: string | number | null
}>(`/api/v2/tokens/${address}`),
tokenAggregationApi.getTokenSafe(138, address),
])
const blockscoutToken =
blockscoutResult.status === 'fulfilled' ? normalizeTokenProfile(blockscoutResult.value) : null
const aggregationToken =
aggregationResult.status === 'fulfilled' && aggregationResult.value.ok ? aggregationResult.value.data : null
const merged = mergeTokenProfileWithAggregation(blockscoutToken, aggregationToken)
return { ok: merged != null, data: merged }
} catch {
return { ok: false, data: null }
}
},
getTransfersSafe: async (
address: string,
page = 1,
pageSize = 10
): Promise<{ ok: boolean; data: AddressTokenTransfer[] }> => {
try {
const params = new URLSearchParams({
page: page.toString(),
items_count: pageSize.toString(),
})
const raw = await fetchBlockscoutJson<{ items?: BlockscoutTokenTransfer[] }>(
`/api/v2/tokens/${address}/transfers?${params.toString()}`
)
return {
ok: true,
data: Array.isArray(raw.items) ? raw.items.map((item) => normalizeAddressTokenTransfer(item)) : [],
}
} catch {
return { ok: false, data: [] }
}
},
getHoldersSafe: async (
address: string,
page = 1,
pageSize = 10
): Promise<{ ok: boolean; data: TokenHolder[] }> => {
try {
const params = new URLSearchParams({
page: page.toString(),
items_count: pageSize.toString(),
})
const raw = await fetchBlockscoutJson<{ items?: Array<{
address?: { hash?: string | null; name?: string | null; label?: string | null } | null
value?: string | null
token?: { decimals?: string | number | null } | null
}> }>(`/api/v2/tokens/${address}/holders?${params.toString()}`)
return {
ok: true,
data: Array.isArray(raw.items) ? raw.items.map((item) => normalizeTokenHolder(item)) : [],
}
} catch {
return { ok: false, data: [] }
}
},
getProvenanceSafe: async (address: string): Promise<{ ok: boolean; data: TokenProvenance | null }> => {
try {
const lookup = await getTokenListLookup()
const token = lookup.get(address.toLowerCase())
if (!token) {
return { ok: true, data: { listed: false, tags: [] } }
}
return {
ok: true,
data: {
listed: true,
chainId: token.chainId,
name: token.name,
symbol: token.symbol,
logoURI: token.logoURI,
tags: token.tags || [],
},
}
} catch {
return { ok: false, data: null }
}
},
listCuratedSafe: async (chainId = 138): Promise<{ ok: boolean; data: TokenListToken[] }> => {
try {
const response = await configApi.getTokenList()
const data = (response.tokens || [])
.filter((token) => token.chainId === chainId && typeof token.address === 'string' && token.address.trim().length > 0)
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || right.name || ''))
return { ok: true, data }
} catch {
return { ok: false, data: [] }
}
},
getRelatedPoolsSafe: async (address: string): Promise<{ ok: boolean; data: MissionControlLiquidityPool[] }> => {
try {
const response = await routesApi.getTokenPools(address)
return { ok: true, data: response.pools || [] }
} catch {
return { ok: false, data: [] }
}
},
}