feat(explorer): add live token/native pricing and legacy tx route compatibility
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

This commit is contained in:
defiQUG
2026-04-25 23:45:07 -07:00
parent 1b5cebf505
commit 1aa81f454a
25 changed files with 11664 additions and 5517 deletions

View File

@@ -520,7 +520,7 @@ func (s *Server) HandleMissionControlBridgeTrace(w http.ResponseWriter, r *http.
"from_registry": fromLabel,
"to": toAddr,
"to_registry": toLabel,
"blockscout_url": publicBase + "/tx/" + strings.ToLower(tx),
"blockscout_url": publicBase + "/transactions/" + strings.ToLower(tx),
"source": source,
}
if registryLoadErr != nil && len(reg) == 0 {

View File

@@ -177,7 +177,7 @@ func TestHandleMissionControlBridgeTraceLabelsFromRegistry(t *testing.T) {
require.Equal(t, strings.ToLower(toAddr), out.Data["to"])
require.Equal(t, "CHAIN138_SOURCE_BRIDGE", out.Data["from_registry"])
require.Equal(t, "CHAIN138_DEST_BRIDGE", out.Data["to_registry"])
require.Equal(t, "https://explorer.example.org/tx/0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", out.Data["blockscout_url"])
require.Equal(t, "https://explorer.example.org/transactions/0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", out.Data["blockscout_url"])
}
func TestHandleMissionControlBridgeTraceFallsBackToAddressInventoryLabels(t *testing.T) {

View File

@@ -0,0 +1,21 @@
DROP INDEX IF EXISTS idx_swap_events_token1_price;
DROP INDEX IF EXISTS idx_swap_events_token0_price;
DROP INDEX IF EXISTS idx_swap_events_chain_tx_log;
CREATE UNIQUE INDEX IF NOT EXISTS idx_swap_events_unique_log
ON swap_events (
chain_id,
pool_address,
COALESCE(transaction_hash, ''),
COALESCE(log_index, -1)
);
ALTER TABLE IF EXISTS swap_events
DROP COLUMN IF EXISTS to_address,
DROP COLUMN IF EXISTS sender,
DROP COLUMN IF EXISTS token1_price_usd,
DROP COLUMN IF EXISTS token0_price_usd,
DROP COLUMN IF EXISTS price_usd,
DROP COLUMN IF EXISTS amount1_out,
DROP COLUMN IF EXISTS amount0_out,
DROP COLUMN IF EXISTS amount1_in,
DROP COLUMN IF EXISTS amount0_in;

View File

@@ -0,0 +1,27 @@
-- Migration: Add per-token USD price columns to swap_events
-- Description: Aligns lightweight swap_events schema with token-aggregation writer and
-- enables historical OHLCV generation to derive token-specific candles
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS amount0_in NUMERIC(78, 0) DEFAULT 0;
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS amount1_in NUMERIC(78, 0) DEFAULT 0;
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS amount0_out NUMERIC(78, 0) DEFAULT 0;
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS amount1_out NUMERIC(78, 0) DEFAULT 0;
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS price_usd NUMERIC(30, 8);
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS token0_price_usd NUMERIC(30, 8);
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS token1_price_usd NUMERIC(30, 8);
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS sender VARCHAR(42);
ALTER TABLE IF EXISTS swap_events ADD COLUMN IF NOT EXISTS to_address VARCHAR(42);
CREATE INDEX IF NOT EXISTS idx_swap_events_token0_price
ON swap_events (chain_id, token0_address, timestamp DESC)
WHERE token0_price_usd IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_swap_events_token1_price
ON swap_events (chain_id, token1_address, timestamp DESC)
WHERE token1_price_usd IS NOT NULL;
DROP INDEX IF EXISTS idx_swap_events_unique_log;
DROP INDEX IF EXISTS idx_swap_events_chain_tx_log;
CREATE UNIQUE INDEX IF NOT EXISTS idx_swap_events_chain_tx_log
ON swap_events (chain_id, transaction_hash, log_index);

View File

@@ -7,11 +7,11 @@ Wants=network.target
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/solacescanscout/frontend/current
WorkingDirectory=/opt/solacescanscout/frontend/current/explorer-monorepo/frontend
Environment=NODE_ENV=production
Environment=HOSTNAME=127.0.0.1
Environment=PORT=3000
ExecStart=/usr/bin/node /opt/solacescanscout/frontend/current/server.js
ExecStart=/usr/bin/node /opt/solacescanscout/frontend/current/explorer-monorepo/frontend/server.js
Restart=always
RestartSec=5
StandardOutput=journal

View File

@@ -224,7 +224,7 @@ User → chainlist.org → Search "DBIS" → Click "Add to MetaMask"
```
User → MetaMask → Click "View on Explorer"
→ MetaMask opens: https://explorer.d-bis.org/tx/{hash}
→ MetaMask opens: https://explorer.d-bis.org/transactions/{hash}
→ Blockscout displays transaction details
→ Blockscout API provides the data
```
@@ -285,4 +285,3 @@ User → MetaMask → View Token Balance
**Last Updated**: 2025-12-24
**Status**: Analysis Complete

View File

@@ -1,5 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,9 +1,17 @@
const path = require('path')
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
outputFileTracingRoot: path.resolve(__dirname, '..', '..'),
async redirects() {
return [
{
source: '/tx/:hash',
destination: '/transactions/:hash',
permanent: true,
},
{
source: '/more',
destination: '/operations',

16155
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@
"smoke:routes": "node ./scripts/smoke-routes.mjs",
"start": "PORT=${PORT:-3000} node ./scripts/start-standalone.mjs",
"start:next": "next start",
"lint": "next lint",
"lint": "eslint src libs next.config.js --ext .js,.jsx,.ts,.tsx",
"type-check": "tsc --noEmit -p tsconfig.check.json",
"test": "npm run lint && npm run type-check",
"test:unit": "vitest run"
@@ -22,12 +22,12 @@
"dependencies": {
"@tanstack/react-query": "^5.14.2",
"autoprefixer": "^10.4.16",
"axios": "^1.6.2",
"axios": "^1.15.2",
"clsx": "^2.0.0",
"date-fns": "^3.0.6",
"js-sha3": "^0.9.3",
"next": "^14.0.4",
"postcss": "^8.4.32",
"next": "^15.5.15",
"postcss": "^8.5.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^3.3.6",
@@ -35,11 +35,16 @@
},
"devDependencies": {
"@types/node": "^20.10.5",
"@types/prop-types": "^15.7.15",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"eslint": "^8.56.0",
"eslint-config-next": "^14.0.4",
"eslint-config-next": "^15.5.15",
"typescript": "^5.3.3",
"vitest": "^1.6.1"
"vitest": "^4.1.5"
},
"overrides": {
"esbuild": "^0.28.0",
"postcss": "^8.5.10"
}
}

View File

@@ -7,7 +7,20 @@ import process from 'node:process'
const projectRoot = process.cwd()
const standaloneRoot = path.join(projectRoot, '.next', 'standalone')
const standaloneNextRoot = path.join(standaloneRoot, '.next')
const standaloneServer = path.join(standaloneRoot, 'server.js')
function resolveStandaloneServer() {
const directServer = path.join(standaloneRoot, 'server.js')
if (existsSync(directServer)) {
return { serverPath: directServer, appRoot: standaloneRoot }
}
const nestedServer = path.join(standaloneRoot, 'explorer-monorepo', 'frontend', 'server.js')
if (existsSync(nestedServer)) {
return { serverPath: nestedServer, appRoot: path.dirname(nestedServer) }
}
return { serverPath: directServer, appRoot: standaloneRoot }
}
async function copyIfPresent(sourcePath, destinationPath) {
if (!existsSync(sourcePath)) {
@@ -19,15 +32,16 @@ async function copyIfPresent(sourcePath, destinationPath) {
}
async function main() {
if (!existsSync(standaloneServer)) {
const { serverPath, appRoot } = resolveStandaloneServer()
if (!existsSync(serverPath)) {
console.error('Standalone server build is missing. Run `npm run build` first.')
process.exit(1)
}
await copyIfPresent(path.join(projectRoot, '.next', 'static'), path.join(standaloneNextRoot, 'static'))
await copyIfPresent(path.join(projectRoot, 'public'), path.join(standaloneRoot, 'public'))
await copyIfPresent(path.join(projectRoot, 'public'), path.join(appRoot, 'public'))
const child = spawn(process.execPath, [standaloneServer], {
const child = spawn(process.execPath, [serverPath], {
stdio: 'inherit',
env: process.env,
})

View File

@@ -23,6 +23,7 @@ import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { Explain, useUiMode } from '@/components/common/UiModeContext'
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
type HomeStats = ExplorerStats
@@ -92,6 +93,15 @@ function compactStatNote(guided: string, expert: string, mode: 'guided' | 'exper
return mode === 'guided' ? guided : expert
}
function formatUsd(value: number | undefined) {
if (value == null || !Number.isFinite(value)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
export default function Home({
initialStats = null,
initialRecentBlocks = [],
@@ -109,6 +119,7 @@ export default function Home({
const [activitySnapshot, setActivitySnapshot] = useState<ExplorerRecentActivitySnapshot | null>(initialActivitySnapshot)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [relaySummary, setRelaySummary] = useState<MissionControlRelaySummary | null>(initialRelaySummary)
const [featuredPrices, setFeaturedPrices] = useState<TokenAggregationTokenSnapshot[]>([])
const [missionExpanded, setMissionExpanded] = useState(false)
const [relayExpanded, setRelayExpanded] = useState(false)
const [relayPage, setRelayPage] = useState(1)
@@ -166,6 +177,29 @@ export default function Home({
}
}, [])
useEffect(() => {
let cancelled = false
tokenAggregationApi.getTokensByAddressSafe(138, [
'0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
'0xf22258f57794CC8E06237084b353Ab30fFfa640b',
'0x290e52a8819A4fBd0714e517225429AA2B70EC6B',
'0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
]).then(({ data }) => {
if (!cancelled) {
setFeaturedPrices(data)
}
}).catch((error) => {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to load featured token prices:', error)
}
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
let cancelled = false
@@ -738,6 +772,35 @@ export default function Home({
</div>
)}
{featuredPrices.length > 0 ? (
<div className="mb-8">
<Card title="Live Price Feed">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{featuredPrices.map((token) => (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-800 dark:bg-gray-900/40"
>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{token.symbol || token.name || 'Token'}
</div>
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
{formatUsd(token.market?.priceUsd)}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Visible liquidity: {formatUsd(token.market?.liquidityUsd)}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{token.market?.lastUpdated ? `Updated ${formatRelativeAge(token.market.lastUpdated)}` : 'Update time unavailable'}
</div>
</Link>
))}
</div>
</Card>
</div>
) : null}
<div className="mb-8">
<ActivityContextPanel
context={activityContext}

View File

@@ -31,11 +31,24 @@ import PageIntro from '@/components/common/PageIntro'
import GruStandardsCard from '@/components/common/GruStandardsCard'
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { estimateNativeUsdValue, getNativeAssetDescriptor, getNativeAssetMarketSafe } from '@/services/api/nativeAssetPricing'
function isValidAddress(value: string) {
return /^0x[a-fA-F0-9]{40}$/.test(value)
}
function formatUsd(value: string | number | undefined): string {
if (value == null) return 'Unavailable'
const numeric = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numeric)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: numeric >= 100 ? 0 : 2,
}).format(numeric)
}
export default function AddressDetailPage() {
const router = useRouter()
const address = typeof router.query.address === 'string' ? router.query.address : ''
@@ -51,6 +64,8 @@ export default function AddressDetailPage() {
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
const [methodResults, setMethodResults] = useState<Record<string, { loading: boolean; value?: string; error?: string }>>({})
const [methodInputs, setMethodInputs] = useState<Record<string, string[]>>({})
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
const [nativeAssetPriceUsd, setNativeAssetPriceUsd] = useState<number | undefined>()
const [loading, setLoading] = useState(true)
const loadAddressInfo = useCallback(async () => {
@@ -137,6 +152,46 @@ export default function AddressDetailPage() {
}
}, [])
useEffect(() => {
let active = true
const tokenAddresses = [
...(addressInfo?.token_contract?.address ? [addressInfo.token_contract.address] : []),
...tokenBalances.map((balance) => balance.token_address),
...tokenTransfers.map((transfer) => transfer.token_address),
].filter((candidate, index, values): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 && values.indexOf(candidate) === index)
tokenAggregationApi.getTokensByAddressSafe(chainId, tokenAddresses).then(({ data }) => {
if (!active) return
setTokenMarkets(Object.fromEntries(data.map((snapshot) => [snapshot.address.toLowerCase(), snapshot])))
}).catch(() => {
if (active) {
setTokenMarkets({})
}
})
return () => {
active = false
}
}, [addressInfo?.token_contract?.address, chainId, tokenBalances, tokenTransfers])
useEffect(() => {
let active = true
getNativeAssetMarketSafe(chainId).then(({ data }) => {
if (!active) return
setNativeAssetPriceUsd(data?.market?.priceUsd)
}).catch(() => {
if (active) {
setNativeAssetPriceUsd(undefined)
}
})
return () => {
active = false
}
}, [chainId])
const watchlistAddress = normalizeWatchlistAddress(addressInfo?.address || address)
const isSavedToWatchlist = watchlistAddress
? isWatchlistEntry(watchlistEntries, watchlistAddress)
@@ -272,7 +327,17 @@ export default function AddressDetailPage() {
},
{
header: 'Value',
accessor: (tx: TransactionSummary) => formatWeiAsEth(tx.value),
accessor: (tx: TransactionSummary) => {
const nativeValueUsd = estimateNativeUsdValue(tx.value, nativeAssetPriceUsd)
return (
<div className="space-y-1 text-sm">
<div>{formatWeiAsEth(tx.value)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{nativeValueUsd != null ? `Current USD: ${formatUsd(nativeValueUsd)}` : 'Current USD unavailable'}
</div>
</div>
)
},
},
{
header: 'Status',
@@ -327,6 +392,20 @@ export default function AddressDetailPage() {
: 'N/A'
),
},
{
header: 'Current Price',
accessor: (balance: AddressTokenBalance) => {
const market = tokenMarkets[balance.token_address.toLowerCase()]?.market
return (
<div className="space-y-1 text-sm">
<div>{formatUsd(market?.priceUsd)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Liq. {formatUsd(market?.liquidityUsd)}
</div>
</div>
)
},
},
]
const tokenTransferColumns = [
@@ -383,6 +462,20 @@ export default function AddressDetailPage() {
header: 'When',
accessor: (transfer: AddressTokenTransfer) => formatTimestamp(transfer.timestamp),
},
{
header: 'Current Price',
accessor: (transfer: AddressTokenTransfer) => {
const market = tokenMarkets[transfer.token_address.toLowerCase()]?.market
return (
<div className="space-y-1 text-sm">
<div>{formatUsd(market?.priceUsd)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Liq. {formatUsd(market?.liquidityUsd)}
</div>
</div>
)
},
},
]
const incomingTransactions = transactions.filter(
@@ -403,6 +496,8 @@ export default function AddressDetailPage() {
const gruTransferCount = tokenTransfers.filter((transfer) =>
Boolean(getGruExplorerMetadata({ address: transfer.token_address, symbol: transfer.token_symbol })),
).length
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
const nativeBalanceUsd = estimateNativeUsdValue(addressInfo?.balance, nativeAssetPriceUsd)
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
@@ -473,8 +568,14 @@ export default function AddressDetailPage() {
<Address address={addressInfo.address} />
</DetailRow>
{addressInfo.balance && (
<DetailRow label="Coin Balance">{formatWeiAsEth(addressInfo.balance)}</DetailRow>
<DetailRow label="Coin Balance">
{formatWeiAsEth(addressInfo.balance)}
{nativeBalanceUsd != null ? ` (${formatUsd(nativeBalanceUsd)})` : ''}
</DetailRow>
)}
<DetailRow label="Current Native Asset Price">
{nativeAssetPriceUsd != null ? `${formatUsd(nativeAssetPriceUsd)} per ${nativeAssetSymbol}` : 'Unavailable'}
</DetailRow>
<DetailRow label="Watchlist">
{isSavedToWatchlist ? 'Saved for quick access' : 'Not saved yet'}
</DetailRow>

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'next/router'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { configApi, type TokenListToken } from '@/services/api/config'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import EntityBadge from '@/components/common/EntityBadge'
import {
inferDirectSearchTarget,
@@ -24,6 +25,15 @@ interface SearchPageProps {
initialCuratedTokens: TokenListToken[]
}
function formatUsd(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
export default function SearchPage({
initialQuery,
initialRawResults,
@@ -40,6 +50,7 @@ export default function SearchPage({
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>(initialCuratedTokens)
const [savedQueries, setSavedQueries] = useState<string[]>([])
const [filterMode, setFilterMode] = useState<SearchFilterMode>('all')
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
const runSearch = async (rawQuery: string) => {
const trimmedQuery = rawQuery.trim()
@@ -190,6 +201,27 @@ export default function SearchPage({
{ label: 'Other', items: groupedResults.other },
]
useEffect(() => {
let active = true
const tokenAddresses = filteredResults
.filter((result) => result.type === 'token' && typeof result.data.address === 'string' && result.data.address.trim().length > 0)
.map((result) => result.data.address as string)
tokenAggregationApi.getTokensByAddressSafe(138, tokenAddresses).then(({ data }) => {
if (!active) return
setTokenMarkets(Object.fromEntries(data.map((snapshot) => [snapshot.address.toLowerCase(), snapshot])))
}).catch(() => {
if (active) {
setTokenMarkets({})
}
})
return () => {
active = false
}
}, [filteredResults])
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
@@ -341,30 +373,43 @@ export default function SearchPage({
</Link>
)}
{(result.type === 'address' || result.type === 'token') && result.data.address && (
<Link
href={result.href || (result.type === 'token' ? `/tokens/${result.data.address}` : `/addresses/${result.data.address}`)}
className="inline-flex flex-col gap-2 text-primary-600 hover:underline"
>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge
label={result.type === 'token' ? 'token' : 'address'}
tone={result.type === 'token' ? 'success' : 'neutral'}
/>
{result.symbol && <EntityBadge label={result.symbol} tone="info" />}
{result.token_type && <EntityBadge label={result.token_type} tone="warning" />}
{result.is_curated_token && <EntityBadge label="listed" tone="success" />}
{result.is_gru_token && <EntityBadge label="GRU" tone="success" />}
{result.is_x402_ready && <EntityBadge label="x402 ready" tone="info" />}
{result.is_wrapped_transport && <EntityBadge label="wrapped" tone="warning" />}
{result.currency_code ? <EntityBadge label={result.currency_code} tone="neutral" /> : null}
{result.match_reason ? <EntityBadge label={result.match_reason} tone="info" className="normal-case tracking-normal" /> : null}
{result.matched_tags?.map((tag) => <EntityBadge key={tag} label={tag} />)}
</div>
<span className="font-medium text-gray-900 dark:text-white">
{result.name || result.symbol || result.label}
</span>
<Address address={result.data.address} truncate showCopy={false} />
</Link>
(() => {
const market = result.type === 'token'
? tokenMarkets[result.data.address.toLowerCase()]?.market
: null
return (
<Link
href={result.href || (result.type === 'token' ? `/tokens/${result.data.address}` : `/addresses/${result.data.address}`)}
className="inline-flex flex-col gap-2 text-primary-600 hover:underline"
>
<div className="flex flex-wrap items-center gap-2">
<EntityBadge
label={result.type === 'token' ? 'token' : 'address'}
tone={result.type === 'token' ? 'success' : 'neutral'}
/>
{result.symbol && <EntityBadge label={result.symbol} tone="info" />}
{result.token_type && <EntityBadge label={result.token_type} tone="warning" />}
{result.is_curated_token && <EntityBadge label="listed" tone="success" />}
{result.is_gru_token && <EntityBadge label="GRU" tone="success" />}
{result.is_x402_ready && <EntityBadge label="x402 ready" tone="info" />}
{result.is_wrapped_transport && <EntityBadge label="wrapped" tone="warning" />}
{result.currency_code ? <EntityBadge label={result.currency_code} tone="neutral" /> : null}
{result.match_reason ? <EntityBadge label={result.match_reason} tone="info" className="normal-case tracking-normal" /> : null}
{result.matched_tags?.map((tag) => <EntityBadge key={tag} label={tag} />)}
</div>
<span className="font-medium text-gray-900 dark:text-white">
{result.name || result.symbol || result.label}
</span>
<Address address={result.data.address} truncate showCopy={false} />
{market ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
<span>Live price: {formatUsd(market.priceUsd)}</span>
<span>Visible liquidity: {formatUsd(market.liquidityUsd)}</span>
</div>
) : null}
</Link>
)
})()
)}
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500">
<span>Type: {result.type}</span>

View File

@@ -346,9 +346,12 @@ export default function TokenDetailPage() {
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Market Context</div>
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div>Indicative price: {formatUsd(token.exchange_rate)}</div>
<div>Current price: {formatUsd(token.exchange_rate)}</div>
<div>24h volume: {formatUsd(token.volume_24h)}</div>
<div>Market cap: {formatUsd(token.circulating_market_cap)}</div>
<div>Visible liquidity: {formatUsd(token.liquidity_usd)}</div>
<div>Valuation source: {token.price_source === 'token-aggregation' ? 'live token aggregation' : token.price_source || 'unavailable'}</div>
<div>Market snapshot: {token.market_updated_at ? formatTimestamp(token.market_updated_at) : 'Unavailable'}</div>
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">

View File

@@ -7,6 +7,7 @@ import PageIntro from '@/components/common/PageIntro'
import EntityBadge from '@/components/common/EntityBadge'
import { tokensApi } from '@/services/api/tokens'
import type { TokenListToken } from '@/services/api/config'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import { fetchPublicJson } from '@/utils/publicExplorer'
const quickSearches = [
@@ -27,10 +28,20 @@ interface TokensPageProps {
initialCuratedTokens: TokenListToken[]
}
function formatUsd(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
const router = useRouter()
const [query, setQuery] = useState('')
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>(initialCuratedTokens)
const [featuredMarkets, setFeaturedMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault()
@@ -68,6 +79,28 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
return selected.length > 0 ? selected : curatedTokens.slice(0, 6)
}, [curatedTokens])
useEffect(() => {
let active = true
const featuredAddresses = featuredCuratedTokens
.map((token) => token.address)
.filter((address): address is string => typeof address === 'string' && address.trim().length > 0)
tokenAggregationApi.getTokensByAddressSafe(138, featuredAddresses).then(({ data }) => {
if (!active) return
const next = Object.fromEntries(data.map((snapshot) => [snapshot.address.toLowerCase(), snapshot]))
setFeaturedMarkets(next)
}).catch(() => {
if (active) {
setFeaturedMarkets({})
}
})
return () => {
active = false
}
}, [featuredCuratedTokens])
return (
<div className="container mx-auto px-4 py-8">
<PageIntro
@@ -139,25 +172,36 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
<div className="mt-8">
<Card title="Curated Chain 138 tokens">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{featuredCuratedTokens.map((token) => (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-700"
>
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.symbol || token.name || 'Token'}</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{token.name || 'Listed in the Chain 138 token registry.'}
</p>
{token.tags && token.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{token.tags.slice(0, 3).map((tag) => (
<EntityBadge key={`${token.address}-${tag}`} label={tag} className="px-2 py-1 text-[11px]" />
))}
</div>
)}
</Link>
))}
{featuredCuratedTokens
.filter((token): token is TokenListToken & { address: string } => typeof token.address === 'string' && token.address.trim().length > 0)
.map((token) => {
const market = featuredMarkets[token.address.toLowerCase()]?.market
return (
<Link
key={token.address}
href={`/tokens/${token.address}`}
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-700"
>
<div className="text-sm font-semibold text-gray-900 dark:text-white">{token.symbol || token.name || 'Token'}</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{token.name || 'Listed in the Chain 138 token registry.'}
</p>
{market ? (
<div className="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-300">
<div>Live price: {formatUsd(market.priceUsd)}</div>
<div>Visible liquidity: {formatUsd(market.liquidityUsd)}</div>
</div>
) : null}
{token.tags && token.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{token.tags.slice(0, 3).map((tag) => (
<EntityBadge key={`${token.address}-${tag}`} label={tag} className="px-2 py-1 text-[11px]" />
))}
</div>
)}
</Link>
)
})}
</div>
</Card>
</div>

View File

@@ -17,11 +17,47 @@ import EntityBadge from '@/components/common/EntityBadge'
import PageIntro from '@/components/common/PageIntro'
import { getGruCatalogPosture } from '@/services/api/gruCatalog'
import { assessTransactionCompliance } from '@/utils/transactionCompliance'
import { tokenAggregationApi, type TokenAggregationHistoricalPriceSnapshot } from '@/services/api/tokenAggregation'
import { estimateNativeUsdValue, estimateTokenUsdValue, getNativeAssetDescriptor, getNativeAssetPriceAtSafe } from '@/services/api/nativeAssetPricing'
function isValidTransactionHash(value: string) {
return /^0x[a-fA-F0-9]{64}$/.test(value)
}
function formatUsd(value: string | number | undefined): string {
if (value == null) return 'Unavailable'
const numeric = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numeric)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: numeric >= 100 ? 0 : 2,
}).format(numeric)
}
function formatHistoricalPriceSource(source: string | undefined): string {
switch (source) {
case 'ohlcv_5m':
return 'historical OHLCV 5m'
case 'ohlcv_15m':
return 'historical OHLCV 15m'
case 'ohlcv_1h':
return 'historical OHLCV 1h'
case 'ohlcv_4h':
return 'historical OHLCV 4h'
case 'ohlcv_24h':
return 'historical OHLCV 24h'
case 'coingecko_history':
return 'historical CoinGecko market'
case 'current_market_fallback':
return 'current market fallback'
case 'canonical_fallback':
return 'canonical fallback'
default:
return 'unavailable'
}
}
export default function TransactionDetailPage() {
const router = useRouter()
const hash = typeof router.query.hash === 'string' ? router.query.hash : ''
@@ -31,6 +67,8 @@ export default function TransactionDetailPage() {
const [transaction, setTransaction] = useState<Transaction | null>(null)
const [internalCalls, setInternalCalls] = useState<TransactionInternalCall[]>([])
const [diagnostic, setDiagnostic] = useState<TransactionLookupDiagnostic | null>(null)
const [historicalTokenPrices, setHistoricalTokenPrices] = useState<Record<string, TokenAggregationHistoricalPriceSnapshot>>({})
const [historicalNativePrice, setHistoricalNativePrice] = useState<TokenAggregationHistoricalPriceSnapshot | null>(null)
const [loading, setLoading] = useState(true)
const loadTransaction = useCallback(async () => {
@@ -91,6 +129,62 @@ export default function TransactionDetailPage() {
loadTransaction()
}, [hash, isValidHash, loadTransaction, router.isReady])
useEffect(() => {
let active = true
const tokenAddresses = (transaction?.token_transfers || [])
.map((transfer) => transfer.token_address)
.filter((candidate, index, values): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 && values.indexOf(candidate) === index)
if (!transaction?.created_at || tokenAddresses.length === 0) {
setHistoricalTokenPrices({})
return () => {
active = false
}
}
Promise.all(tokenAddresses.map((address) => tokenAggregationApi.getPriceAtSafe(chainId, address, transaction.created_at))).then((results) => {
if (!active) return
const snapshots = results
.filter((result): result is { ok: true; data: TokenAggregationHistoricalPriceSnapshot | null } => result.ok)
.map((result) => result.data)
.filter((snapshot): snapshot is TokenAggregationHistoricalPriceSnapshot => Boolean(snapshot?.tokenAddress))
setHistoricalTokenPrices(Object.fromEntries(snapshots.map((snapshot) => [snapshot.tokenAddress.toLowerCase(), snapshot])))
}).catch(() => {
if (active) {
setHistoricalTokenPrices({})
}
})
return () => {
active = false
}
}, [chainId, transaction?.created_at, transaction?.token_transfers])
useEffect(() => {
let active = true
if (!transaction?.created_at) {
setHistoricalNativePrice(null)
return () => {
active = false
}
}
getNativeAssetPriceAtSafe(chainId, transaction.created_at).then(({ data }) => {
if (!active) return
setHistoricalNativePrice(data)
}).catch(() => {
if (active) {
setHistoricalNativePrice(null)
}
})
return () => {
active = false
}
}, [chainId, transaction?.created_at])
const tokenTransferColumns = [
{
header: 'Token',
@@ -137,6 +231,24 @@ export default function TransactionDetailPage() {
formatTokenAmount(transfer.amount, transfer.token_decimals, transfer.token_symbol)
),
},
{
header: 'Transfer-Time Value',
accessor: (transfer: TransactionTokenTransfer) => {
const historicalPrice = historicalTokenPrices[transfer.token_address.toLowerCase()]
const totalUsd = estimateTokenUsdValue(transfer.amount, transfer.token_decimals, historicalPrice?.priceUsd)
return (
<div className="space-y-1 text-sm">
<div>{totalUsd != null ? formatUsd(totalUsd) : 'Unavailable'}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Unit price: {formatUsd(historicalPrice?.priceUsd)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Source: {formatHistoricalPriceSource(historicalPrice?.source)}
</div>
</div>
)
},
},
]
const internalCallColumns = [
@@ -186,6 +298,9 @@ export default function TransactionDetailPage() {
: null
const tokenTransferCount = transaction?.token_transfers?.length || 0
const internalCallCount = internalCalls.length
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
const nativeValueUsd = estimateNativeUsdValue(transaction?.value, historicalNativePrice?.priceUsd)
const nativeFeeUsd = estimateNativeUsdValue(transaction?.fee, historicalNativePrice?.priceUsd)
const complianceAssessment = transaction
? assessTransactionCompliance({
transaction,
@@ -275,7 +390,10 @@ export default function TransactionDetailPage() {
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Gas & Fees</div>
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div>Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'}</div>
<div>
Fee: {transaction.fee ? formatWeiAsEth(transaction.fee) : 'Unavailable'}
{nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
</div>
<div>Gas used: {transaction.gas_used != null ? transaction.gas_used.toLocaleString() : 'Unavailable'}</div>
<div>Utilization: {gasUtilization != null ? `${gasUtilization}%` : 'Unavailable'}</div>
</div>
@@ -283,7 +401,12 @@ export default function TransactionDetailPage() {
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Value Movement</div>
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div>Native value: {formatWeiAsEth(transaction.value)}</div>
<div>
Native value: {formatWeiAsEth(transaction.value)}
{nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
</div>
<div>Transfer-time native price: {historicalNativePrice?.priceUsd != null ? `${formatUsd(historicalNativePrice.priceUsd)} per ${nativeAssetSymbol}` : 'Unavailable'}</div>
<div>Pricing source: {formatHistoricalPriceSource(historicalNativePrice?.source)}</div>
<div>Token transfers: {tokenTransferCount.toLocaleString()}</div>
<div>Internal calls: {internalCallCount.toLocaleString()}</div>
</div>
@@ -368,8 +491,16 @@ export default function TransactionDetailPage() {
</Link>
</DetailRow>
)}
<DetailRow label="Value">{formatWeiAsEth(transaction.value)}</DetailRow>
{transaction.fee && <DetailRow label="Fee">{formatWeiAsEth(transaction.fee)}</DetailRow>}
<DetailRow label="Value">
{formatWeiAsEth(transaction.value)}
{nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
</DetailRow>
{transaction.fee && (
<DetailRow label="Fee">
{formatWeiAsEth(transaction.fee)}
{nativeFeeUsd != null ? ` (${formatUsd(nativeFeeUsd)})` : ''}
</DetailRow>
)}
<DetailRow label="Gas Price">
{transaction.gas_price != null ? `${transaction.gas_price / 1e9} Gwei` : 'N/A'}
</DetailRow>

View File

@@ -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()
})
})

View File

@@ -0,0 +1,99 @@
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from './tokenAggregation'
interface NativeAssetDescriptor {
symbol: string
pricingAddress: string
}
const NATIVE_ASSET_BY_CHAIN_ID: Record<number, NativeAssetDescriptor> = {
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<ReturnType<typeof tokenAggregationApi.getPriceAtSafe>>['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
}

View File

@@ -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}&timestamp=${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 }
}
},
}

View File

@@ -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')
})

View File

@@ -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<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
@@ -94,7 +160,8 @@ async function getTokenListLookup(): Promise<Map<string, TokenListToken>> {
export const tokensApi = {
getSafe: async (address: string): Promise<{ ok: boolean; data: TokenProfile | null }> => {
try {
const raw = await fetchBlockscoutJson<{
const [blockscoutResult, aggregationResult] = await Promise.allSettled([
fetchBlockscoutJson<{
address: string
name?: string | null
symbol?: string | null
@@ -106,8 +173,17 @@ export const tokensApi = {
icon_url?: string | null
circulating_market_cap?: string | number | null
volume_24h?: string | number | null
}>(`/api/v2/tokens/${address}`)
return { ok: true, data: normalizeTokenProfile(raw) }
}>(`/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 }
}

View File

@@ -33,7 +33,7 @@ if [ -n "${1:-}" ] && [[ "$1" =~ ^0x[0-9a-fA-F]{64}$ ]]; then
TX_HASH="$1"
echo "=== Checking Transaction: $TX_HASH ==="
echo ""
echo "Explorer URL: $EXPLORER_URL/tx/$TX_HASH"
echo "Explorer URL: $EXPLORER_URL/transactions/$TX_HASH"
echo ""
# Try to get receipt via RPC
@@ -84,7 +84,7 @@ if [ -n "${1:-}" ] && [[ "$1" =~ ^0x[0-9a-fA-F]{64}$ ]]; then
else
echo "⚠ Transaction not found in RPC (may be pending or not yet indexed)"
echo ""
echo "Check on explorer: $EXPLORER_URL/tx/$TX_HASH"
echo "Check on explorer: $EXPLORER_URL/transactions/$TX_HASH"
fi
else
# Check recent transactions for account
@@ -125,6 +125,6 @@ echo ""
echo "For detailed transaction information, please visit:"
echo " Account: $EXPLORER_URL/address/$ACCOUNT"
if [ -n "${TX_HASH:-}" ]; then
echo " Transaction: $EXPLORER_URL/tx/$TX_HASH"
echo " Transaction: $EXPLORER_URL/transactions/$TX_HASH"
fi
echo ""

View File

@@ -24,6 +24,7 @@ RELEASE_ID="$(date +%Y%m%d_%H%M%S)"
TMP_DIR="$(mktemp -d)"
ARCHIVE_NAME="solacescanscout-next-${RELEASE_ID}.tar"
BUILD_LOCK_DIR="${FRONTEND_ROOT}/.next-build-lock"
STANDALONE_ROOT="${FRONTEND_ROOT}/.next/standalone"
STATIC_SYNC_FILES=(
"index.html"
"docs.html"
@@ -110,16 +111,31 @@ if [[ "${SKIP_BUILD:-0}" != "1" ]]; then
echo ""
fi
if [[ ! -f "${FRONTEND_ROOT}/.next/standalone/server.js" ]]; then
APP_RELATIVE_DIR="."
APP_SERVER_PATH="${STANDALONE_ROOT}/server.js"
if [[ ! -f "${APP_SERVER_PATH}" ]]; then
ALT_SERVER_PATH="$(find "${STANDALONE_ROOT}" -path '*/server.js' -print | head -n 1 || true)"
if [[ -n "${ALT_SERVER_PATH}" ]]; then
APP_SERVER_PATH="${ALT_SERVER_PATH}"
APP_RELATIVE_DIR="$(dirname "${ALT_SERVER_PATH#${STANDALONE_ROOT}/}")"
fi
fi
if [[ ! -f "${APP_SERVER_PATH}" ]]; then
echo "Missing standalone server build. Run \`npm run build\` in ${FRONTEND_ROOT} first." >&2
exit 1
fi
STAGE_DIR="${TMP_DIR}/stage"
mkdir -p "${STAGE_DIR}/.next"
cp -R "${FRONTEND_ROOT}/.next/standalone/." "$STAGE_DIR/"
cp -R "${FRONTEND_ROOT}/.next/static" "${STAGE_DIR}/.next/static"
cp -R "${FRONTEND_ROOT}/public" "${STAGE_DIR}/public"
APP_STAGE_DIR="${STAGE_DIR}"
if [[ "${APP_RELATIVE_DIR}" != "." ]]; then
APP_STAGE_DIR="${STAGE_DIR}/${APP_RELATIVE_DIR}"
fi
mkdir -p "${APP_STAGE_DIR}/.next"
cp -R "${STANDALONE_ROOT}/." "$STAGE_DIR/"
cp -R "${FRONTEND_ROOT}/.next/static" "${APP_STAGE_DIR}/.next/static"
cp -R "${FRONTEND_ROOT}/public" "${APP_STAGE_DIR}/public"
tar -C "$STAGE_DIR" -cf "${TMP_DIR}/${ARCHIVE_NAME}" .
cp "$SERVICE_TEMPLATE" "${TMP_DIR}/${SERVICE_NAME}.service"

View File

@@ -271,7 +271,7 @@ if echo "$SEND_TX" | grep -qE "transactionHash"; then
log_info " Recipient: $DEPLOYER"
log_info ""
log_info "You can monitor the transaction at:"
log_info " https://explorer.d-bis.org/tx/$TX_HASH"
log_info " https://explorer.d-bis.org/transactions/$TX_HASH"
log_info ""
log_success "Process completed successfully!"
log_info ""