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
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:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
4
frontend/next-env.d.ts
vendored
4
frontend/next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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
16155
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
32
frontend/src/services/api/nativeAssetPricing.test.ts
Normal file
32
frontend/src/services/api/nativeAssetPricing.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
99
frontend/src/services/api/nativeAssetPricing.ts
Normal file
99
frontend/src/services/api/nativeAssetPricing.ts
Normal 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
|
||||
}
|
||||
159
frontend/src/services/api/tokenAggregation.ts
Normal file
159
frontend/src/services/api/tokenAggregation.ts
Normal 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}×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 }
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user