- Introduced a new Diagnostics struct to capture transaction visibility state and activity state. - Updated BuildSnapshot function to return diagnostics alongside snapshot, completeness, and sampling. - Enhanced test cases to validate the new diagnostics data. - Updated frontend components to utilize the new diagnostics information for improved user feedback on freshness context. This change improves the observability of transaction activity and enhances the user experience by providing clearer insights into the freshness of data.
254 lines
9.4 KiB
TypeScript
254 lines
9.4 KiB
TypeScript
import type { GetServerSideProps } from 'next'
|
|
import Link from 'next/link'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { useRouter } from 'next/router'
|
|
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
|
import { transactionsApi, Transaction } from '@/services/api/transactions'
|
|
import { readWatchlistFromStorage } from '@/utils/watchlist'
|
|
import PageIntro from '@/components/common/PageIntro'
|
|
import { fetchPublicJson } from '@/utils/publicExplorer'
|
|
import { normalizeTransaction } from '@/services/api/blockscout'
|
|
import { summarizeChainActivity } from '@/utils/activityContext'
|
|
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
|
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
|
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
|
|
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
|
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
|
|
|
function normalizeAddress(value: string) {
|
|
const trimmed = value.trim()
|
|
return /^0x[a-fA-F0-9]{40}$/.test(trimmed) ? trimmed : ''
|
|
}
|
|
|
|
interface AddressesPageProps {
|
|
initialRecentTransactions: Transaction[]
|
|
initialLatestBlocks: Array<{ number: number; timestamp: string }>
|
|
initialStats: ExplorerStats | null
|
|
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
|
}
|
|
|
|
function serializeRecentTransactions(transactions: Transaction[]): Transaction[] {
|
|
return JSON.parse(
|
|
JSON.stringify(
|
|
transactions.map((transaction) => ({
|
|
hash: transaction.hash,
|
|
block_number: transaction.block_number,
|
|
from_address: transaction.from_address,
|
|
to_address: transaction.to_address ?? null,
|
|
created_at: transaction.created_at,
|
|
})),
|
|
),
|
|
) as Transaction[]
|
|
}
|
|
|
|
export default function AddressesPage({
|
|
initialRecentTransactions,
|
|
initialLatestBlocks,
|
|
initialStats,
|
|
initialBridgeStatus,
|
|
}: AddressesPageProps) {
|
|
const router = useRouter()
|
|
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
|
const [query, setQuery] = useState('')
|
|
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
|
|
const [watchlist, setWatchlist] = useState<string[]>([])
|
|
const activityContext = useMemo(
|
|
() =>
|
|
summarizeChainActivity({
|
|
blocks: initialLatestBlocks.map((block) => ({
|
|
chain_id: chainId,
|
|
number: block.number,
|
|
hash: '',
|
|
timestamp: block.timestamp,
|
|
miner: '',
|
|
transaction_count: 0,
|
|
gas_used: 0,
|
|
gas_limit: 0,
|
|
})),
|
|
transactions: recentTransactions,
|
|
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
|
|
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
|
|
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
|
diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null,
|
|
}),
|
|
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, recentTransactions],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (initialRecentTransactions.length > 0) {
|
|
setRecentTransactions(initialRecentTransactions)
|
|
return
|
|
}
|
|
|
|
let active = true
|
|
transactionsApi.listSafe(chainId, 1, 20)
|
|
.then(({ ok, data }) => {
|
|
if (active && ok) {
|
|
setRecentTransactions(data)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (active) {
|
|
setRecentTransactions([])
|
|
}
|
|
})
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [chainId, initialRecentTransactions])
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return
|
|
}
|
|
|
|
try {
|
|
setWatchlist(readWatchlistFromStorage(window.localStorage))
|
|
} catch {
|
|
setWatchlist([])
|
|
}
|
|
}, [])
|
|
|
|
const activeAddresses = useMemo(() => {
|
|
const seen = new Set<string>()
|
|
const addresses: string[] = []
|
|
|
|
for (const tx of recentTransactions) {
|
|
for (const candidate of [tx.from_address, tx.to_address]) {
|
|
if (!candidate) continue
|
|
const normalized = candidate.toLowerCase()
|
|
if (seen.has(normalized)) continue
|
|
seen.add(normalized)
|
|
addresses.push(candidate)
|
|
if (addresses.length >= 12) return addresses
|
|
}
|
|
}
|
|
|
|
return addresses
|
|
}, [recentTransactions])
|
|
|
|
const handleOpenAddress = (event: React.FormEvent) => {
|
|
event.preventDefault()
|
|
const normalized = normalizeAddress(query)
|
|
if (!normalized) return
|
|
router.push(`/addresses/${normalized}`)
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-6 sm:py-8">
|
|
<PageIntro
|
|
eyebrow="Address Discovery"
|
|
title="Addresses"
|
|
description="Open any Chain 138 address directly, revisit saved watchlist entries, or branch into recent activity discovered from indexed transactions."
|
|
actions={[
|
|
{ href: '/watchlist', label: 'Open watchlist' },
|
|
{ href: '/transactions', label: 'Recent transactions' },
|
|
{ href: '/search', label: 'Search explorer' },
|
|
]}
|
|
/>
|
|
|
|
<div className="mb-6">
|
|
<ActivityContextPanel context={activityContext} title="Recent Address Activity Context" />
|
|
<FreshnessTrustNote
|
|
className="mt-3"
|
|
context={activityContext}
|
|
stats={initialStats}
|
|
bridgeStatus={initialBridgeStatus}
|
|
scopeLabel="Recently active addresses are derived from the latest visible indexed transactions."
|
|
/>
|
|
</div>
|
|
|
|
<Card className="mb-6" title="Open An Address">
|
|
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="0x..."
|
|
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!normalizeAddress(query)}
|
|
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
Open address
|
|
</button>
|
|
</form>
|
|
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
|
Open any Chain 138 address directly, or jump into your saved watchlist below.
|
|
</p>
|
|
</Card>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<Card title="Saved Watchlist">
|
|
{watchlist.length === 0 ? (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
No saved addresses yet. Address detail pages let you add entries to the shared explorer watchlist.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{watchlist.map((entry) => (
|
|
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
|
|
<Address address={entry} showCopy={false} />
|
|
</Link>
|
|
))}
|
|
<div className="pt-2">
|
|
<Link href="/watchlist" className="text-sm text-primary-600 hover:underline">
|
|
Open the full watchlist →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
<Card title="Recently Active Addresses">
|
|
{activeAddresses.length === 0 ? (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
Recent address activity is unavailable in the latest visible transaction sample. You can still open an address directly above.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{activeAddresses.map((entry) => (
|
|
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
|
|
<Address address={entry} showCopy={false} />
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const getServerSideProps: GetServerSideProps<AddressesPageProps> = async () => {
|
|
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
|
const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([
|
|
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null),
|
|
fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null),
|
|
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
|
|
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
|
])
|
|
const initialRecentTransactions = Array.isArray(transactionsResult?.items)
|
|
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
|
|
: []
|
|
const initialLatestBlocks = Array.isArray(blocksResult?.items)
|
|
? blocksResult.items
|
|
.map((item) => ({
|
|
number: Number(item.height || 0),
|
|
timestamp: item.timestamp || '',
|
|
}))
|
|
.filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp)
|
|
: []
|
|
|
|
return {
|
|
props: {
|
|
initialRecentTransactions: serializeRecentTransactions(initialRecentTransactions),
|
|
initialLatestBlocks,
|
|
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
|
|
initialBridgeStatus: bridgeResult,
|
|
},
|
|
}
|
|
}
|