-
Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'}
+
+ Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'}
+ {nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
+
Gas used: {transaction.gas_used != null ? transaction.gas_used.toLocaleString() : 'Unavailable'}
Utilization: {gasUtilization != null ? `${gasUtilization}%` : 'Unavailable'}
@@ -283,7 +401,12 @@ export default function TransactionDetailPage() {
Value Movement
-
Native value: {formatWeiAsEth(transaction.value)}
+
+ Native value: {formatWeiAsEth(transaction.value)}
+ {nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
+
+
Transfer-time native price: {historicalNativePrice?.priceUsd != null ? `${formatUsd(historicalNativePrice.priceUsd)} per ${nativeAssetSymbol}` : 'Unavailable'}
+
Pricing source: {formatHistoricalPriceSource(historicalNativePrice?.source)}
Token transfers: {tokenTransferCount.toLocaleString()}
Internal calls: {internalCallCount.toLocaleString()}
@@ -368,8 +491,16 @@ export default function TransactionDetailPage() {
)}
-
{formatWeiAsEth(transaction.value)}
- {transaction.fee &&
{formatWeiAsEth(transaction.fee)}}
+
+ {formatWeiAsEth(transaction.value)}
+ {nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
+
+ {transaction.fee && (
+
+ {formatWeiAsEth(transaction.fee)}
+ {nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
+
+ )}
{transaction.gas_price != null ? `${transaction.gas_price / 1e9} Gwei` : 'N/A'}
diff --git a/frontend/src/services/api/nativeAssetPricing.test.ts b/frontend/src/services/api/nativeAssetPricing.test.ts
new file mode 100644
index 0000000..20f48a5
--- /dev/null
+++ b/frontend/src/services/api/nativeAssetPricing.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+
+import { estimateNativeUsdValue, estimateTokenUsdValue, getNativeAssetDescriptor } from './nativeAssetPricing'
+
+describe('nativeAssetPricing', () => {
+ it('resolves the chain 138 native asset descriptor', () => {
+ expect(getNativeAssetDescriptor(138)).toEqual({
+ symbol: 'ETH',
+ pricingAddress: '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
+ })
+ })
+
+ it('estimates USD values from wei using the live asset price', () => {
+ expect(estimateNativeUsdValue('970000000000000', 2490)).toBe('2.4153')
+ expect(estimateNativeUsdValue('1000000000000000000', 2490)).toBe('2490')
+ })
+
+ it('returns undefined when pricing inputs are unavailable', () => {
+ expect(estimateNativeUsdValue(undefined, 2490)).toBeUndefined()
+ expect(estimateNativeUsdValue('970000000000000', undefined)).toBeUndefined()
+ expect(estimateNativeUsdValue('not-a-number', 2490)).toBeUndefined()
+ })
+
+ it('estimates token USD values using token decimals', () => {
+ expect(estimateTokenUsdValue('1000000', 6, 1)).toBe('1')
+ expect(estimateTokenUsdValue('250000000', 8, 2)).toBe('5')
+ })
+
+ it('preserves precision for large raw balances', () => {
+ expect(estimateNativeUsdValue('123456789012345678901234567890', 2316.7203872128002)).toBeTruthy()
+ })
+})
diff --git a/frontend/src/services/api/nativeAssetPricing.ts b/frontend/src/services/api/nativeAssetPricing.ts
new file mode 100644
index 0000000..ee3e952
--- /dev/null
+++ b/frontend/src/services/api/nativeAssetPricing.ts
@@ -0,0 +1,99 @@
+import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from './tokenAggregation'
+
+interface NativeAssetDescriptor {
+ symbol: string
+ pricingAddress: string
+}
+
+const NATIVE_ASSET_BY_CHAIN_ID: Record
= {
+ 138: {
+ symbol: 'ETH',
+ pricingAddress: '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
+ },
+}
+
+export function getNativeAssetDescriptor(chainId: number): NativeAssetDescriptor {
+ return NATIVE_ASSET_BY_CHAIN_ID[chainId] || { symbol: 'ETH', pricingAddress: NATIVE_ASSET_BY_CHAIN_ID[138].pricingAddress }
+}
+
+export async function getNativeAssetMarketSafe(
+ chainId: number,
+): Promise<{ ok: boolean; data: TokenAggregationTokenSnapshot | null }> {
+ const descriptor = getNativeAssetDescriptor(chainId)
+ return tokenAggregationApi.getTokenSafe(chainId, descriptor.pricingAddress)
+}
+
+export async function getNativeAssetPriceAtSafe(
+ chainId: number,
+ timestamp: string,
+): Promise<{ ok: boolean; data: Awaited>['data'] }> {
+ const descriptor = getNativeAssetDescriptor(chainId)
+ return tokenAggregationApi.getPriceAtSafe(chainId, descriptor.pricingAddress, timestamp)
+}
+
+function decimalToScaledInteger(value: number, scale: number): { scaled: bigint; scale: bigint } | null {
+ if (!Number.isFinite(value)) {
+ return null
+ }
+
+ const normalized = value.toFixed(scale)
+ const negative = normalized.startsWith('-')
+ const unsigned = negative ? normalized.slice(1) : normalized
+ const [whole, fraction = ''] = unsigned.split('.')
+
+ try {
+ const scaled = BigInt(whole + fraction.padEnd(scale, '0'))
+ return {
+ scaled: negative ? -scaled : scaled,
+ scale: 10n ** BigInt(scale),
+ }
+ } catch {
+ return null
+ }
+}
+
+function formatScaledUsd(
+ rawAmount: string,
+ tokenDecimals: number,
+ priceUsd: number,
+ priceScale = 8,
+ outputScale = 6,
+): string | undefined {
+ if (!rawAmount || !Number.isFinite(priceUsd) || tokenDecimals < 0) {
+ return undefined
+ }
+
+ try {
+ const amount = BigInt(rawAmount)
+ const parsedPrice = decimalToScaledInteger(priceUsd, priceScale)
+ if (!parsedPrice) {
+ return undefined
+ }
+
+ const numerator = amount * parsedPrice.scaled * (10n ** BigInt(outputScale))
+ const denominator = (10n ** BigInt(tokenDecimals)) * parsedPrice.scale
+ const rounded = (numerator + (denominator / 2n)) / denominator
+ const divisor = 10n ** BigInt(outputScale)
+ const whole = rounded / divisor
+ const fraction = (rounded % divisor).toString().padStart(outputScale, '0').replace(/0+$/, '')
+
+ return fraction ? `${whole.toString()}.${fraction}` : whole.toString()
+ } catch {
+ return undefined
+ }
+}
+
+export function estimateNativeUsdValue(
+ valueWei: string | null | undefined,
+ priceUsd: number | undefined,
+): string | undefined {
+ return valueWei && priceUsd != null ? formatScaledUsd(valueWei, 18, priceUsd) : undefined
+}
+
+export function estimateTokenUsdValue(
+ rawAmount: string | null | undefined,
+ decimals: number,
+ priceUsd: number | undefined,
+): string | undefined {
+ return rawAmount && priceUsd != null ? formatScaledUsd(rawAmount, decimals, priceUsd) : undefined
+}
diff --git a/frontend/src/services/api/tokenAggregation.ts b/frontend/src/services/api/tokenAggregation.ts
new file mode 100644
index 0000000..6512aac
--- /dev/null
+++ b/frontend/src/services/api/tokenAggregation.ts
@@ -0,0 +1,159 @@
+import { resolveExplorerApiBase } from '../../../libs/frontend-api-client/api-base'
+
+export interface TokenAggregationMarketSnapshot {
+ priceUsd?: number
+ volume24h?: number
+ liquidityUsd?: number
+ lastUpdated?: string | null
+}
+
+export interface TokenAggregationTokenSnapshot {
+ chainId: number
+ address: string
+ name?: string
+ symbol?: string
+ decimals?: number
+ totalSupply?: string
+ market?: TokenAggregationMarketSnapshot | null
+}
+
+export interface TokenAggregationHistoricalPriceSnapshot {
+ chainId: number
+ tokenAddress: string
+ requestedTimestamp: string
+ effectiveTimestamp?: string
+ priceUsd?: number
+ source?: string
+}
+
+interface RawTokenAggregationTokenResponse {
+ token?: {
+ chainId?: number | string | null
+ address?: string | null
+ name?: string | null
+ symbol?: string | null
+ decimals?: number | string | null
+ totalSupply?: string | null
+ market?: {
+ priceUsd?: number | string | null
+ volume24h?: number | string | null
+ liquidityUsd?: number | string | null
+ lastUpdated?: string | null
+ } | null
+ } | null
+}
+
+interface RawTokenAggregationHistoricalPriceResponse {
+ chainId?: number | string | null
+ tokenAddress?: string | null
+ requestedTimestamp?: string | null
+ effectiveTimestamp?: string | null
+ priceUsd?: number | string | null
+ source?: string | null
+}
+
+function toNumber(value: number | string | null | undefined): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value)) return value
+ if (typeof value === 'string' && value.trim() !== '') {
+ const parsed = Number(value)
+ if (Number.isFinite(parsed)) return parsed
+ }
+ return undefined
+}
+
+function normalizeTokenSnapshot(raw: RawTokenAggregationTokenResponse): TokenAggregationTokenSnapshot | null {
+ const token = raw.token
+ if (!token?.address) {
+ return null
+ }
+
+ return {
+ chainId: toNumber(token.chainId) ?? 138,
+ address: token.address,
+ name: token.name || undefined,
+ symbol: token.symbol || undefined,
+ decimals: toNumber(token.decimals),
+ totalSupply: token.totalSupply || undefined,
+ market: token.market
+ ? {
+ priceUsd: toNumber(token.market.priceUsd),
+ volume24h: toNumber(token.market.volume24h),
+ liquidityUsd: toNumber(token.market.liquidityUsd),
+ lastUpdated: token.market.lastUpdated || null,
+ }
+ : null,
+ }
+}
+
+function normalizeHistoricalPriceSnapshot(
+ raw: RawTokenAggregationHistoricalPriceResponse,
+): TokenAggregationHistoricalPriceSnapshot | null {
+ if (!raw.tokenAddress || !raw.requestedTimestamp) {
+ return null
+ }
+
+ return {
+ chainId: toNumber(raw.chainId) ?? 138,
+ tokenAddress: raw.tokenAddress,
+ requestedTimestamp: raw.requestedTimestamp,
+ effectiveTimestamp: raw.effectiveTimestamp || undefined,
+ priceUsd: toNumber(raw.priceUsd),
+ source: raw.source || undefined,
+ }
+}
+
+function getTokenAggregationBase(): string {
+ return `${resolveExplorerApiBase()}/token-aggregation/api/v1`
+}
+
+export const tokenAggregationApi = {
+ getTokenSafe: async (chainId: number, address: string): Promise<{ ok: boolean; data: TokenAggregationTokenSnapshot | null }> => {
+ try {
+ const response = await fetch(`${getTokenAggregationBase()}/tokens/${address}?chainId=${chainId}`)
+ if (!response.ok) {
+ return { ok: false, data: null }
+ }
+ const raw = (await response.json()) as RawTokenAggregationTokenResponse
+ return { ok: true, data: normalizeTokenSnapshot(raw) }
+ } catch {
+ return { ok: false, data: null }
+ }
+ },
+
+ getTokensByAddressSafe: async (
+ chainId: number,
+ addresses: string[],
+ ): Promise<{ ok: boolean; data: TokenAggregationTokenSnapshot[] }> => {
+ const uniqueAddresses = [...new Set(addresses.map((address) => address.trim()).filter(Boolean))]
+ if (uniqueAddresses.length === 0) {
+ return { ok: true, data: [] }
+ }
+
+ const results = await Promise.all(uniqueAddresses.map((address) => tokenAggregationApi.getTokenSafe(chainId, address)))
+ const data = results
+ .filter((result): result is { ok: true; data: TokenAggregationTokenSnapshot | null } => result.ok)
+ .map((result) => result.data)
+ .filter((snapshot): snapshot is TokenAggregationTokenSnapshot => Boolean(snapshot?.address))
+
+ return { ok: data.length > 0, data }
+ },
+
+ getPriceAtSafe: async (
+ chainId: number,
+ address: string,
+ timestamp: string,
+ ): Promise<{ ok: boolean; data: TokenAggregationHistoricalPriceSnapshot | null }> => {
+ try {
+ const response = await fetch(
+ `${getTokenAggregationBase()}/tokens/${address}/price-at?chainId=${chainId}×tamp=${encodeURIComponent(timestamp)}`
+ )
+ if (!response.ok) {
+ return { ok: false, data: null }
+ }
+ const raw = (await response.json()) as RawTokenAggregationHistoricalPriceResponse
+ return { ok: true, data: normalizeHistoricalPriceSnapshot(raw) }
+ } catch {
+ return { ok: false, data: null }
+ }
+ },
+}
diff --git a/frontend/src/services/api/tokens.test.ts b/frontend/src/services/api/tokens.test.ts
index 89da8c2..7253a31 100644
--- a/frontend/src/services/api/tokens.test.ts
+++ b/frontend/src/services/api/tokens.test.ts
@@ -20,6 +20,25 @@ describe('tokensApi', () => {
total_supply: '1000',
}),
})
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ token: {
+ chainId: 138,
+ address: '0xtoken',
+ symbol: 'cUSDT',
+ name: 'Tether USD (Compliant)',
+ decimals: 6,
+ totalSupply: '1000',
+ market: {
+ priceUsd: 1,
+ volume24h: 2500,
+ liquidityUsd: 500000,
+ lastUpdated: '2026-04-26T01:00:00.000Z',
+ },
+ },
+ }),
+ })
.mockResolvedValueOnce({
ok: true,
json: async () => ({
@@ -61,6 +80,10 @@ describe('tokensApi', () => {
expect(token.ok).toBe(true)
expect(token.data?.symbol).toBe('cUSDT')
+ expect(token.data?.exchange_rate).toBe(1)
+ expect(token.data?.volume_24h).toBe(2500)
+ expect(token.data?.liquidity_usd).toBe(500000)
+ expect(token.data?.price_source).toBe('token-aggregation')
expect(holders.data[0].label).toBe('Treasury')
expect(transfers.data[0].token_symbol).toBe('cUSDT')
})
diff --git a/frontend/src/services/api/tokens.ts b/frontend/src/services/api/tokens.ts
index 9d0a1c5..65e2d81 100644
--- a/frontend/src/services/api/tokens.ts
+++ b/frontend/src/services/api/tokens.ts
@@ -1,6 +1,7 @@
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 {
@@ -15,6 +16,9 @@ export interface TokenProfile {
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 {
@@ -45,6 +49,9 @@ function normalizeTokenProfile(raw: {
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,
@@ -58,9 +65,68 @@ function normalizeTokenProfile(raw: {
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>['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
@@ -94,7 +160,8 @@ async function getTokenListLookup(): Promise