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
282 lines
9.3 KiB
TypeScript
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: [] }
|
|
}
|
|
},
|
|
}
|