refactor: rename SolaceScanScout to Solace and update related configurations

- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation.
- Changed default base URL for Playwright tests and updated security headers to reflect the new branding.
- Enhanced README and API documentation to include new authentication endpoints and product access details.

This refactor aligns the project branding and improves clarity in the API documentation.
This commit is contained in:
defiQUG
2026-04-10 12:52:17 -07:00
parent 6eef6b07f6
commit 0972178cc5
160 changed files with 13274 additions and 1061 deletions

View File

@@ -0,0 +1,5 @@
import AccessManagementPage from '@/components/access/AccessManagementPage'
export default function AccessPage() {
return <AccessManagementPage />
}

View File

@@ -4,24 +4,53 @@ import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { addressesApi, AddressInfo, TransactionSummary } from '@/services/api/addresses'
import { formatWeiAsEth } from '@/utils/format'
import {
addressesApi,
AddressInfo,
AddressTokenBalance,
AddressTokenTransfer,
TransactionSummary,
} from '@/services/api/addresses'
import {
encodeMethodCalldata,
callSimpleReadMethod,
contractsApi,
type ContractMethod,
type ContractProfile,
} from '@/services/api/contracts'
import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format'
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import {
isWatchlistEntry,
readWatchlistFromStorage,
writeWatchlistToStorage,
normalizeWatchlistAddress,
} from '@/utils/watchlist'
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'
function isValidAddress(value: string) {
return /^0x[a-fA-F0-9]{40}$/.test(value)
}
export default function AddressDetailPage() {
const router = useRouter()
const address = typeof router.query.address === 'string' ? router.query.address : ''
const isValidAddressParam = address !== '' && isValidAddress(address)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
const [transactions, setTransactions] = useState<TransactionSummary[]>([])
const [tokenBalances, setTokenBalances] = useState<AddressTokenBalance[]>([])
const [tokenTransfers, setTokenTransfers] = useState<AddressTokenTransfer[]>([])
const [contractProfile, setContractProfile] = useState<ContractProfile | null>(null)
const [gruProfile, setGruProfile] = useState<GruStandardsProfile | null>(null)
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 [loading, setLoading] = useState(true)
const loadAddressInfo = useCallback(async () => {
@@ -29,22 +58,49 @@ export default function AddressDetailPage() {
const { ok, data } = await addressesApi.getSafe(chainId, address)
if (!ok) {
setAddressInfo(null)
setContractProfile(null)
return
}
setAddressInfo(data ?? null)
if (data?.is_contract) {
const contractResult = await contractsApi.getProfileSafe(address)
const resolvedContractProfile = contractResult.ok ? contractResult.data : null
setContractProfile(resolvedContractProfile)
const gruResult = await getGruStandardsProfileSafe({
address,
symbol: data?.token_contract?.symbol || data?.token_contract?.name || '',
tags: data?.tags || [],
contractProfile: resolvedContractProfile,
})
setGruProfile(gruResult.ok ? gruResult.data : null)
} else {
setContractProfile(null)
setGruProfile(null)
}
} catch (error) {
console.error('Failed to load address info:', error)
setAddressInfo(null)
setContractProfile(null)
setGruProfile(null)
}
}, [chainId, address])
const loadTransactions = useCallback(async () => {
try {
const { ok, data } = await addressesApi.getTransactionsSafe(chainId, address, 1, 20)
const [transactionsResult, balancesResult, transfersResult] = await Promise.all([
addressesApi.getTransactionsSafe(chainId, address, 1, 20),
addressesApi.getTokenBalancesSafe(address),
addressesApi.getTokenTransfersSafe(address, 1, 10),
])
const { ok, data } = transactionsResult
setTransactions(ok ? data : [])
setTokenBalances(balancesResult.ok ? balancesResult.data : [])
setTokenTransfers(transfersResult.ok ? transfersResult.data : [])
} catch (error) {
console.error('Failed to load transactions:', error)
setTransactions([])
setTokenBalances([])
setTokenTransfers([])
} finally {
setLoading(false)
}
@@ -59,9 +115,15 @@ export default function AddressDetailPage() {
}
return
}
if (!isValidAddressParam) {
setLoading(false)
setAddressInfo(null)
setTransactions([])
return
}
loadAddressInfo()
loadTransactions()
}, [address, loadAddressInfo, loadTransactions, router.isReady])
}, [address, isValidAddressParam, loadAddressInfo, loadTransactions, router.isReady])
useEffect(() => {
if (typeof window === 'undefined') {
@@ -98,6 +160,91 @@ export default function AddressDetailPage() {
})
}
const handleReadMethod = async (method: ContractMethod) => {
const values = methodInputs[method.signature] || method.inputs.map(() => '')
setMethodResults((current) => ({
...current,
[method.signature]: { loading: true },
}))
try {
const value = await callSimpleReadMethod(address, method, values)
setMethodResults((current) => ({
...current,
[method.signature]: { loading: false, value },
}))
} catch (error) {
setMethodResults((current) => ({
...current,
[method.signature]: {
loading: false,
error: error instanceof Error ? error.message : 'Read call failed',
},
}))
}
}
const handleMethodInputChange = (signature: string, index: number, value: string) => {
setMethodInputs((current) => {
const next = [...(current[signature] || [])]
next[index] = value
return {
...current,
[signature]: next,
}
})
}
const handleWriteMethod = async (method: ContractMethod) => {
const provider = typeof window !== 'undefined'
? (window as unknown as { ethereum?: { request: (args: { method: string; params?: unknown[] }) => Promise<unknown> } }).ethereum
: undefined
if (!provider) {
setMethodResults((current) => ({
...current,
[method.signature]: { loading: false, error: 'A wallet provider is required for write methods.' },
}))
return
}
const values = methodInputs[method.signature] || method.inputs.map(() => '')
setMethodResults((current) => ({
...current,
[method.signature]: { loading: true },
}))
try {
const data = encodeMethodCalldata(method, values)
const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as string[]
const from = accounts?.[0]
if (!from) {
throw new Error('No wallet account was returned by the provider.')
}
const txHash = await provider.request({
method: 'eth_sendTransaction',
params: [
{
from,
to: address,
data,
},
],
})
setMethodResults((current) => ({
...current,
[method.signature]: { loading: false, value: typeof txHash === 'string' ? txHash : 'Transaction submitted' },
}))
} catch (error) {
setMethodResults((current) => ({
...current,
[method.signature]: {
loading: false,
error: error instanceof Error ? error.message : 'Write call failed',
},
}))
}
}
const transactionColumns = [
{
header: 'Hash',
@@ -137,11 +284,138 @@ export default function AddressDetailPage() {
},
]
const tokenBalanceColumns = [
{
header: 'Token',
accessor: (balance: AddressTokenBalance) => {
const gruMetadata = getGruExplorerMetadata({
address: balance.token_address,
symbol: balance.token_symbol,
})
return (
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
{balance.token_address ? (
<Link href={`/tokens/${balance.token_address}`} className="text-primary-600 hover:underline">
{balance.token_symbol || balance.token_name || <Address address={balance.token_address} truncate showCopy={false} />}
</Link>
) : (
<span>{balance.token_symbol || balance.token_name || 'Token'}</span>
)}
{gruMetadata ? <EntityBadge label="GRU" tone="success" /> : null}
{gruMetadata?.x402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
{gruMetadata?.iso20022Ready ? <EntityBadge label="ISO-20022" tone="info" /> : null}
</div>
{balance.token_name && balance.token_symbol && (
<div className="text-xs text-gray-500 dark:text-gray-400">{balance.token_name}</div>
)}
</div>
)
},
},
{
header: 'Balance',
accessor: (balance: AddressTokenBalance) => (
formatTokenAmount(balance.value, balance.token_decimals, balance.token_symbol)
),
},
{
header: 'Supply',
accessor: (balance: AddressTokenBalance) => (
balance.total_supply
? formatTokenAmount(balance.total_supply, balance.token_decimals, balance.token_symbol)
: 'N/A'
),
},
]
const tokenTransferColumns = [
{
header: 'Token',
accessor: (transfer: AddressTokenTransfer) => {
const gruMetadata = getGruExplorerMetadata({
address: transfer.token_address,
symbol: transfer.token_symbol,
})
return (
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium text-gray-900 dark:text-white">{transfer.token_symbol || transfer.token_name || 'Token'}</div>
{gruMetadata ? <EntityBadge label="GRU" tone="success" /> : null}
{gruMetadata?.x402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
{gruMetadata?.iso20022Ready ? <EntityBadge label="ISO-20022" tone="info" /> : null}
{gruMetadata?.transportActiveVersion ? <EntityBadge label={`transport ${gruMetadata.transportActiveVersion}`} tone="warning" /> : null}
</div>
{transfer.token_address && (
<Link href={`/tokens/${transfer.token_address}`} className="text-primary-600 hover:underline">
<Address address={transfer.token_address} truncate showCopy={false} />
</Link>
)}
</div>
)
},
},
{
header: 'Direction',
accessor: (transfer: AddressTokenTransfer) =>
transfer.to_address.toLowerCase() === address.toLowerCase() ? 'Incoming' : 'Outgoing',
},
{
header: 'Counterparty',
accessor: (transfer: AddressTokenTransfer) => {
const incoming = transfer.to_address.toLowerCase() === address.toLowerCase()
const counterparty = incoming ? transfer.from_address : transfer.to_address
const label = incoming ? transfer.from_label : transfer.to_label
return (
<Link href={`/addresses/${counterparty}`} className="text-primary-600 hover:underline">
{label || <Address address={counterparty} truncate showCopy={false} />}
</Link>
)
},
},
{
header: 'Amount',
accessor: (transfer: AddressTokenTransfer) => (
formatTokenAmount(transfer.value, transfer.token_decimals, transfer.token_symbol)
),
},
{
header: 'When',
accessor: (transfer: AddressTokenTransfer) => formatTimestamp(transfer.timestamp),
},
]
const incomingTransactions = transactions.filter(
(tx) => tx.to_address?.toLowerCase() === address.toLowerCase()
).length
const outgoingTransactions = transactions.filter(
(tx) => tx.from_address.toLowerCase() === address.toLowerCase()
).length
const incomingTokenTransfers = tokenTransfers.filter(
(transfer) => transfer.to_address.toLowerCase() === address.toLowerCase()
).length
const outgoingTokenTransfers = tokenTransfers.filter(
(transfer) => transfer.from_address.toLowerCase() === address.toLowerCase()
).length
const gruBalanceCount = tokenBalances.filter((balance) =>
Boolean(getGruExplorerMetadata({ address: balance.token_address, symbol: balance.token_symbol })),
).length
const gruTransferCount = tokenTransfers.filter((transfer) =>
Boolean(getGruExplorerMetadata({ address: transfer.token_address, symbol: transfer.token_symbol })),
).length
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-6 text-3xl font-bold">
{addressInfo?.label || 'Address'}
</h1>
<PageIntro
eyebrow="Address Detail"
title={addressInfo?.label || 'Address'}
description="Inspect a Chain 138 address, move into related transactions, and save important counterparties into the shared explorer watchlist."
actions={[
{ href: '/addresses', label: 'All addresses' },
{ href: '/watchlist', label: 'Open watchlist' },
{ href: '/search', label: 'Search explorer' },
]}
/>
<div className="mb-6 flex flex-wrap gap-3 text-sm">
<Link href="/addresses" className="text-primary-600 hover:underline">
@@ -167,9 +441,29 @@ export default function AddressDetailPage() {
<Card className="mb-6">
<p className="text-sm text-gray-600 dark:text-gray-400">Loading address...</p>
</Card>
) : !isValidAddressParam ? (
<Card className="mb-6">
<p className="text-sm text-gray-600 dark:text-gray-400">Invalid address. Please use a full 42-character 0x-prefixed address.</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/addresses" className="text-primary-600 hover:underline">
Back to addresses
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search the explorer
</Link>
</div>
</Card>
) : !addressInfo ? (
<Card className="mb-6">
<p className="text-sm text-gray-600 dark:text-gray-400">Address not found.</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/addresses" className="text-primary-600 hover:underline">
Browse recent addresses
</Link>
<Link href="/watchlist" className="text-primary-600 hover:underline">
Open watchlist
</Link>
</div>
</Card>
) : (
<>
@@ -178,24 +472,333 @@ export default function AddressDetailPage() {
<DetailRow label="Address">
<Address address={addressInfo.address} />
</DetailRow>
{addressInfo.balance && (
<DetailRow label="Coin Balance">{formatWeiAsEth(addressInfo.balance)}</DetailRow>
)}
<DetailRow label="Watchlist">
{isSavedToWatchlist ? 'Saved for quick access' : 'Not saved yet'}
</DetailRow>
<DetailRow label="Verification">
<div className="flex flex-wrap gap-2">
<EntityBadge label={addressInfo.is_contract ? (addressInfo.is_verified ? 'verified' : 'contract') : 'eoa'} />
{contractProfile?.source_verified ? <EntityBadge label="source available" tone="success" /> : null}
{contractProfile?.abi_available ? <EntityBadge label="abi available" tone="info" /> : null}
{addressInfo.token_contract ? <EntityBadge label={addressInfo.token_contract.type || 'token'} tone="info" /> : null}
</div>
</DetailRow>
{addressInfo.token_contract && (
<DetailRow label="Token Contract">
<div className="space-y-2">
<div>
{addressInfo.token_contract.symbol || addressInfo.token_contract.name || 'Token contract'} · {addressInfo.token_contract.type || 'Token'}
</div>
<Link href={`/tokens/${addressInfo.token_contract.address}`} className="text-primary-600 hover:underline">
Open token detail
</Link>
</div>
</DetailRow>
)}
{addressInfo.tags.length > 0 && (
<DetailRow label="Tags" valueClassName="flex flex-wrap gap-2">
{addressInfo.tags.map((tag, i) => (
<span key={i} className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-sm">
{tag}
</span>
<EntityBadge key={i} label={tag} className="px-2 py-1 text-[11px]" />
))}
</DetailRow>
)}
<DetailRow label="Transactions">{addressInfo.transaction_count}</DetailRow>
<DetailRow label="Tokens">{addressInfo.token_count}</DetailRow>
<DetailRow label="Type">{addressInfo.is_contract ? 'Contract' : 'EOA'}</DetailRow>
<DetailRow label="Recent Activity">
{incomingTransactions} incoming / {outgoingTransactions} outgoing txs
</DetailRow>
{addressInfo.internal_transaction_count != null && (
<DetailRow label="Internal Calls">{addressInfo.internal_transaction_count}</DetailRow>
)}
{addressInfo.logs_count != null && (
<DetailRow label="Indexed Logs">{addressInfo.logs_count}</DetailRow>
)}
<DetailRow label="Token Flow">
{incomingTokenTransfers} incoming / {outgoingTokenTransfers} outgoing token transfers
{addressInfo.token_transfer_count != null ? ` · ${addressInfo.token_transfer_count} total indexed` : ''}
</DetailRow>
{addressInfo.creation_transaction_hash && (
<DetailRow label="Created In">
<Link href={`/transactions/${addressInfo.creation_transaction_hash}`} className="text-primary-600 hover:underline">
<Address address={addressInfo.creation_transaction_hash} truncate showCopy={false} />
</Link>
</DetailRow>
)}
</dl>
</Card>
{addressInfo.is_contract && (
<Card title="Contract Profile" className="mb-6">
<dl className="space-y-4">
<DetailRow label="Interaction Surface">
<div className="flex flex-wrap gap-2">
{contractProfile?.has_custom_methods_read ? <EntityBadge label="read methods" tone="success" /> : <EntityBadge label="read unknown" /> }
{contractProfile?.has_custom_methods_write ? <EntityBadge label="write methods" tone="warning" /> : <EntityBadge label="write unknown" /> }
</div>
</DetailRow>
<DetailRow label="Proxy Type">
{contractProfile?.proxy_type || 'Not reported'}
</DetailRow>
<DetailRow label="Source Status">
<div className="space-y-2">
<div>{contractProfile?.source_status_text || 'Verification metadata was not available from the public explorer.'}</div>
<div className="flex flex-wrap gap-2">
<EntityBadge
label={contractProfile?.source_verified ? 'verified source' : 'source unavailable'}
tone={contractProfile?.source_verified ? 'success' : 'warning'}
/>
<EntityBadge
label={contractProfile?.abi_available ? 'abi present' : 'abi unavailable'}
tone={contractProfile?.abi_available ? 'info' : 'warning'}
/>
</div>
</div>
</DetailRow>
<DetailRow label="Lifecycle">
{contractProfile?.is_self_destructed ? 'Self-destructed' : 'Active'}
</DetailRow>
{(contractProfile?.contract_name ||
contractProfile?.compiler_version ||
contractProfile?.license_type ||
contractProfile?.evm_version ||
contractProfile?.optimization_enabled != null) && (
<DetailRow label="Build Metadata">
<div className="space-y-2 text-sm text-gray-700 dark:text-gray-300">
{contractProfile?.contract_name ? <div>Name: {contractProfile.contract_name}</div> : null}
{contractProfile?.compiler_version ? <div>Compiler: {contractProfile.compiler_version}</div> : null}
{contractProfile?.license_type ? <div>License: {contractProfile.license_type}</div> : null}
{contractProfile?.evm_version ? <div>EVM target: {contractProfile.evm_version}</div> : null}
{contractProfile?.optimization_enabled != null ? (
<div>
Optimization: {contractProfile.optimization_enabled ? 'Enabled' : 'Disabled'}
{contractProfile.optimization_runs != null ? ` · ${contractProfile.optimization_runs} runs` : ''}
</div>
) : null}
</div>
</DetailRow>
)}
<DetailRow label="Implementations">
{contractProfile?.implementations && contractProfile.implementations.length > 0 ? (
<div className="space-y-2">
{contractProfile.implementations.map((implementation) => (
<Link key={implementation} href={`/addresses/${implementation}`} className="block text-primary-600 hover:underline">
<Address address={implementation} truncate showCopy={false} />
</Link>
))}
</div>
) : (
'No implementation addresses were reported.'
)}
</DetailRow>
{contractProfile?.constructor_arguments && (
<DetailRow label="Constructor Args">
<code className="block break-all rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.constructor_arguments}
</code>
</DetailRow>
)}
{contractProfile?.source_code_preview && (
<DetailRow label="Source Preview">
<code className="block max-h-56 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.source_code_preview}
</code>
</DetailRow>
)}
{contractProfile?.abi && (
<DetailRow label="ABI Preview">
<code className="block max-h-56 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.abi}
</code>
</DetailRow>
)}
{contractProfile?.read_methods && contractProfile.read_methods.length > 0 && (
<DetailRow label="Read Methods">
<div className="space-y-3">
{contractProfile.read_methods.slice(0, 8).map((method) => {
const methodState = methodResults[method.signature]
const supportsQuickCall = contractsApi.supportsSimpleReadCall(method)
const inputValues = methodInputs[method.signature] || method.inputs.map(() => '')
return (
<div key={method.signature} className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="font-mono text-sm text-gray-900 dark:text-white">{method.signature}</div>
<div className="mt-1 flex flex-wrap gap-2">
<EntityBadge label={method.stateMutability} tone="success" />
{method.outputs[0]?.type ? <EntityBadge label={`returns ${method.outputs[0].type}`} tone="info" className="normal-case tracking-normal" /> : null}
{method.inputs.length > 0 ? <EntityBadge label="inputs required" tone="warning" /> : null}
</div>
</div>
{supportsQuickCall ? (
<button
type="button"
onClick={() => void handleReadMethod(method)}
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
{methodState?.loading ? 'Calling...' : 'Call'}
</button>
) : (
<span className="text-xs text-gray-500 dark:text-gray-400">Use ABI externally for parameterized reads</span>
)}
</div>
{method.inputs.length > 0 ? (
<div className="mt-3 grid gap-2">
{method.inputs.map((input, index) => (
<label key={`${method.signature}-${index}`} className="block">
<span className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">
{input.name || `arg${index + 1}`} · {input.type}
</span>
<input
type="text"
value={inputValues[index] || ''}
onChange={(event) => handleMethodInputChange(method.signature, index, event.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
</label>
))}
</div>
) : null}
{methodState?.value ? (
<code className="mt-3 block break-all rounded bg-white p-2 text-xs text-gray-900 dark:bg-gray-950 dark:text-gray-100">
{methodState.value}
</code>
) : null}
{methodState?.error ? (
<div className="mt-3 text-xs text-red-600 dark:text-red-300">{methodState.error}</div>
) : null}
</div>
)
})}
{contractProfile.read_methods.length > 8 ? (
<div className="text-xs text-gray-500 dark:text-gray-400">
Showing the first 8 read methods here for sanity. Full ABI preview remains available below.
</div>
) : null}
</div>
</DetailRow>
)}
{contractProfile?.write_methods && contractProfile.write_methods.length > 0 && (
<DetailRow label="Write Methods">
<div className="space-y-2">
{contractProfile.write_methods.slice(0, 6).map((method) => (
<div key={method.signature} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="font-mono text-gray-900 dark:text-white">{method.signature}</div>
<div className="mt-2 flex flex-wrap gap-2">
<EntityBadge label={method.stateMutability} tone="warning" />
{method.inputs.length > 0 ? <EntityBadge label={`${method.inputs.length} inputs`} /> : null}
</div>
{method.inputs.length > 0 ? (
<div className="mt-3 grid gap-2">
{method.inputs.map((input, index) => (
<label key={`${method.signature}-${index}`} className="block">
<span className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">
{input.name || `arg${index + 1}`} · {input.type}
</span>
<input
type="text"
value={(methodInputs[method.signature] || [])[index] || ''}
onChange={(event) => handleMethodInputChange(method.signature, index, event.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
</label>
))}
</div>
) : null}
{contractsApi.supportsSimpleWriteCall(method) ? (
<div className="mt-3 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => void handleWriteMethod(method)}
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
{methodResults[method.signature]?.loading ? 'Awaiting wallet...' : 'Send with wallet'}
</button>
<code className="text-xs text-gray-500 dark:text-gray-400">
Wallet confirmation required
</code>
</div>
) : (
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
This method uses input types the explorer does not encode directly yet. Use the ABI preview with an external contract UI if needed.
</div>
)}
{methodResults[method.signature]?.value ? (
<code className="mt-3 block break-all rounded bg-white p-2 text-xs text-gray-900 dark:bg-gray-950 dark:text-gray-100">
{methodResults[method.signature]?.value}
</code>
) : null}
{methodResults[method.signature]?.error ? (
<div className="mt-3 text-xs text-red-600 dark:text-red-300">{methodResults[method.signature]?.error}</div>
) : null}
</div>
))}
<div className="text-xs text-gray-500 dark:text-gray-400">
Write methods are surfaced for inspection here, but actual state-changing execution still belongs behind a wallet-confirmed contract interaction flow.
</div>
</div>
</DetailRow>
)}
{contractProfile?.creation_bytecode && (
<DetailRow label="Creation Bytecode">
<code className="block break-all rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.creation_bytecode}
</code>
</DetailRow>
)}
{contractProfile?.deployed_bytecode && (
<DetailRow label="Runtime Bytecode">
<code className="block break-all rounded bg-gray-50 p-2 text-xs dark:bg-gray-950">
{contractProfile.deployed_bytecode}
</code>
</DetailRow>
)}
</dl>
</Card>
)}
{gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
<Card title="Token Balances" className="mb-6">
{gruBalanceCount > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>{gruBalanceCount} visible token balance{gruBalanceCount === 1 ? '' : 's'} look GRU-aware.</span>
<Link href="/docs/gru" className="text-primary-600 hover:underline">
GRU guide
</Link>
</div>
) : null}
<Table
columns={tokenBalanceColumns}
data={tokenBalances}
emptyMessage="No token balances were indexed for this address."
keyExtractor={(balance) => balance.token_address || `${balance.token_symbol}-${balance.value}`}
/>
</Card>
<Card title="Recent Token Transfers" className="mb-6">
{gruTransferCount > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>{gruTransferCount} recent transfer asset{gruTransferCount === 1 ? '' : 's'} carry GRU posture in the explorer.</span>
<Link href="/docs/gru" className="text-primary-600 hover:underline">
GRU guide
</Link>
<Link href="/docs/transaction-review" className="text-primary-600 hover:underline">
Transaction review guide
</Link>
</div>
) : null}
<Table
columns={tokenTransferColumns}
data={tokenTransfers}
emptyMessage="No token transfers were found for this address."
keyExtractor={(transfer) => `${transfer.transaction_hash}-${transfer.token_address}-${transfer.value}`}
/>
</Card>
<Card title="Transactions">
<Table
columns={transactionColumns}

View File

@@ -1,39 +1,65 @@
'use client'
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'
function normalizeAddress(value: string) {
const trimmed = value.trim()
return /^0x[a-fA-F0-9]{40}$/.test(trimmed) ? trimmed : ''
}
export default function AddressesPage() {
interface AddressesPageProps {
initialRecentTransactions: Transaction[]
}
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,
})),
),
) as Transaction[]
}
export default function AddressesPage({ initialRecentTransactions }: AddressesPageProps) {
const router = useRouter()
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [query, setQuery] = useState('')
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>([])
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
const [watchlist, setWatchlist] = useState<string[]>([])
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([])
}
})
transactionsApi.listSafe(chainId, 1, 20)
.then(({ ok, data }) => {
if (active && ok) {
setRecentTransactions(data)
}
})
.catch(() => {
if (active) {
setRecentTransactions([])
}
})
return () => {
active = false
}
}, [chainId])
}, [chainId, initialRecentTransactions])
useEffect(() => {
if (typeof window === 'undefined') {
@@ -74,7 +100,16 @@ export default function AddressesPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-6 text-3xl font-bold">Addresses</h1>
<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' },
]}
/>
<Card className="mb-6" title="Open An Address">
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
@@ -139,3 +174,17 @@ export default function AddressesPage() {
</div>
)
}
export const getServerSideProps: GetServerSideProps<AddressesPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null)
const initialRecentTransactions = Array.isArray(transactionsResult?.items)
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
: []
return {
props: {
initialRecentTransactions: serializeRecentTransactions(initialRecentTransactions),
},
}
}

View File

@@ -1,9 +1,102 @@
import dynamic from 'next/dynamic'
import type { GetServerSideProps } from 'next'
import AnalyticsOperationsPage from '@/components/explorer/AnalyticsOperationsPage'
import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout'
import {
normalizeExplorerStats,
normalizeTransactionTrend,
summarizeRecentTransactions,
type ExplorerRecentActivitySnapshot,
type ExplorerStats,
type ExplorerTransactionTrendPoint,
} from '@/services/api/stats'
import type { Block } from '@/services/api/blocks'
import type { Transaction } from '@/services/api/transactions'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { fetchPublicJson } from '@/utils/publicExplorer'
const AnalyticsOperationsPage = dynamic(() => import('@/components/explorer/AnalyticsOperationsPage'), {
ssr: false,
})
export default function AnalyticsPage() {
return <AnalyticsOperationsPage />
interface AnalyticsPageProps {
initialStats: ExplorerStats | null
initialTransactionTrend: ExplorerTransactionTrendPoint[]
initialActivitySnapshot: ExplorerRecentActivitySnapshot | null
initialBlocks: Block[]
initialTransactions: Transaction[]
initialBridgeStatus: MissionControlBridgeStatusResponse | null
}
function serializeBlocks(blocks: Block[]): Block[] {
return JSON.parse(
JSON.stringify(
blocks.map((block) => ({
chain_id: block.chain_id,
number: block.number,
hash: block.hash,
timestamp: block.timestamp,
miner: block.miner,
gas_used: block.gas_used,
gas_limit: block.gas_limit,
transaction_count: block.transaction_count,
})),
),
) as Block[]
}
function serializeTransactions(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,
value: transaction.value,
status: transaction.status ?? null,
contract_address: transaction.contract_address ?? null,
fee: transaction.fee ?? null,
})),
),
) as Transaction[]
}
export default function AnalyticsPage(props: AnalyticsPageProps) {
return <AnalyticsOperationsPage {...props} />
}
export const getServerSideProps: GetServerSideProps<AnalyticsPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [statsResult, trendResult, activityResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
fetchPublicJson('/api/v2/stats'),
fetchPublicJson('/api/v2/stats/charts/transactions'),
fetchPublicJson('/api/v2/main-page/transactions'),
fetchPublicJson('/api/v2/blocks?page=1&page_size=5'),
fetchPublicJson('/api/v2/transactions?page=1&page_size=5'),
fetchPublicJson('/explorer-api/v1/track1/bridge/status'),
])
return {
props: {
initialStats: statsResult.status === 'fulfilled' ? normalizeExplorerStats(statsResult.value as never) : null,
initialTransactionTrend:
trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value as never) : [],
initialActivitySnapshot:
activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value as never) : null,
initialBlocks:
blocksResult.status === 'fulfilled' && Array.isArray((blocksResult.value as { items?: unknown[] })?.items)
? serializeBlocks(
((blocksResult.value as { items?: unknown[] }).items || []).map((item) =>
normalizeBlock(item as never, chainId),
),
)
: [],
initialTransactions:
transactionsResult.status === 'fulfilled' && Array.isArray((transactionsResult.value as { items?: unknown[] })?.items)
? serializeTransactions(
((transactionsResult.value as { items?: unknown[] }).items || []).map((item) =>
normalizeTransaction(item as never, chainId),
),
)
: [],
initialBridgeStatus:
bridgeResult.status === 'fulfilled' ? (bridgeResult.value as MissionControlBridgeStatusResponse) : null,
},
}
}

View File

@@ -6,6 +6,8 @@ import { blocksApi, Block } from '@/services/api/blocks'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { DetailRow } from '@/components/common/DetailRow'
import PageIntro from '@/components/common/PageIntro'
import { formatTimestamp } from '@/utils/format'
export default function BlockDetailPage() {
const router = useRouter()
@@ -41,9 +43,22 @@ export default function BlockDetailPage() {
loadBlock()
}, [isValidBlock, loadBlock, router.isReady])
const gasUtilization = block && block.gas_limit > 0
? Math.round((block.gas_used / block.gas_limit) * 100)
: null
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-6 text-3xl font-bold">{block ? `Block #${block.number}` : 'Block'}</h1>
<PageIntro
eyebrow="Block Detail"
title={block ? `Block #${block.number}` : 'Block'}
description="Inspect a single Chain 138 block, then move into its related miner address, adjacent block numbers, or broader explorer search flows."
actions={[
{ href: '/blocks', label: 'All blocks' },
{ href: '/transactions', label: 'Recent transactions' },
{ href: '/search', label: 'Search explorer' },
]}
/>
<div className="mb-6 flex flex-wrap gap-3 text-sm">
<Link href="/blocks" className="text-primary-600 hover:underline">
@@ -68,10 +83,26 @@ export default function BlockDetailPage() {
) : !isValidBlock ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Invalid block number. Please use a valid block number from the URL.</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/blocks" className="text-primary-600 hover:underline">
Back to blocks
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search by block number
</Link>
</div>
</Card>
) : !block ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Block not found.</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/blocks" className="text-primary-600 hover:underline">
Browse recent blocks
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search the explorer
</Link>
</div>
</Card>
) : (
<Card title="Block Information">
@@ -80,7 +111,7 @@ export default function BlockDetailPage() {
<Address address={block.hash} />
</DetailRow>
<DetailRow label="Timestamp">
{new Date(block.timestamp).toLocaleString()}
{formatTimestamp(block.timestamp)}
</DetailRow>
<DetailRow label="Miner">
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
@@ -88,13 +119,18 @@ export default function BlockDetailPage() {
</Link>
</DetailRow>
<DetailRow label="Transactions">
<Link href="/transactions" className="text-primary-600 hover:underline">
<Link href={`/search?q=${block.number}`} className="text-primary-600 hover:underline">
{block.transaction_count}
</Link>
</DetailRow>
<DetailRow label="Gas Used">
{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}
</DetailRow>
{gasUtilization != null && (
<DetailRow label="Gas Utilization">
{gasUtilization}%
</DetailRow>
)}
</dl>
</Card>
)}

View File

@@ -1,14 +1,21 @@
'use client'
import type { GetServerSideProps } from 'next'
import { useCallback, useEffect, useState } from 'react'
import { blocksApi, Block } from '@/services/api/blocks'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import PageIntro from '@/components/common/PageIntro'
import { formatTimestamp } from '@/utils/format'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeBlock } from '@/services/api/blockscout'
export default function BlocksPage() {
interface BlocksPageProps {
initialBlocks: Block[]
}
export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
const pageSize = 20
const [blocks, setBlocks] = useState<Block[]>([])
const [loading, setLoading] = useState(true)
const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
const [loading, setLoading] = useState(initialBlocks.length === 0)
const [page, setPage] = useState(1)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
@@ -32,15 +39,29 @@ export default function BlocksPage() {
}, [chainId, page, pageSize])
useEffect(() => {
loadBlocks()
}, [loadBlocks])
if (page === 1 && initialBlocks.length > 0) {
setBlocks(initialBlocks)
setLoading(false)
return
}
void loadBlocks()
}, [initialBlocks, loadBlocks, page])
const showPagination = page > 1 || blocks.length > 0
const canGoNext = blocks.length === pageSize
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-6 text-3xl font-bold">Blocks</h1>
<PageIntro
eyebrow="Chain Activity"
title="Blocks"
description="Browse recent Chain 138 blocks, then pivot into transactions, addresses, and indexed search without falling into a dead end."
actions={[
{ href: '/transactions', label: 'Open transactions' },
{ href: '/addresses', label: 'Browse addresses' },
{ href: '/search', label: 'Search explorer' },
]}
/>
{loading ? (
<Card>
@@ -51,6 +72,14 @@ export default function BlocksPage() {
{blocks.length === 0 ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Recent blocks are unavailable right now.</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/transactions" className="text-primary-600 hover:underline">
Open recent transactions
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search by block number
</Link>
</div>
</Card>
) : (
blocks.map((block) => (
@@ -66,10 +95,16 @@ export default function BlocksPage() {
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
<Address address={block.hash} truncate showCopy={false} />
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Miner:{' '}
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
<Address address={block.miner} truncate showCopy={false} />
</Link>
</div>
</div>
<div className="text-left sm:text-right">
<div className="text-sm">
{new Date(block.timestamp).toLocaleString()}
{formatTimestamp(block.timestamp)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{block.transaction_count} transactions
@@ -101,6 +136,38 @@ export default function BlocksPage() {
</button>
</div>
)}
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<Card title="Keep Exploring">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Need a different entry point? Open transaction flow, search directly by block number, or jump into recently active addresses.
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/transactions" className="text-primary-600 hover:underline">
Transactions
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Addresses
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search
</Link>
</div>
</Card>
</div>
</div>
)
}
export const getServerSideProps: GetServerSideProps<BlocksPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const blocksResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null)
return {
props: {
initialBlocks: Array.isArray(blocksResult?.items)
? blocksResult.items.map((item) => normalizeBlock(item as never, chainId))
: [],
},
}
}

View File

@@ -1,9 +1,24 @@
import dynamic from 'next/dynamic'
import type { GetStaticProps } from 'next'
import BridgeMonitoringPage from '@/components/explorer/BridgeMonitoringPage'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { fetchPublicJson } from '@/utils/publicExplorer'
const BridgeMonitoringPage = dynamic(() => import('@/components/explorer/BridgeMonitoringPage'), {
ssr: false,
})
export default function BridgePage() {
return <BridgeMonitoringPage />
interface BridgePageProps {
initialBridgeStatus: MissionControlBridgeStatusResponse | null
}
export default function BridgePage(props: BridgePageProps) {
return <BridgeMonitoringPage {...props} />
}
export const getStaticProps: GetStaticProps<BridgePageProps> = async () => {
const bridgeResult = await fetchPublicJson<MissionControlBridgeStatusResponse>(
'/explorer-api/v1/track1/bridge/status'
).catch(() => null)
return {
props: {
initialBridgeStatus: bridgeResult,
},
}
}

View File

@@ -0,0 +1,134 @@
'use client'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
import EntityBadge from '@/components/common/EntityBadge'
export default function GruDocsPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
eyebrow="Explorer Documentation"
title="GRU Guide"
description="A user-facing summary of the GRU standards, transport posture, and x402 readiness model, with concrete places to inspect those signals on live token, address, and search pages."
actions={[
{ href: '/tokens', label: 'Browse tokens' },
{ href: '/search?q=cUSDC', label: 'Search cUSDC' },
{ href: '/search?q=cUSDT', label: 'Search cUSDT' },
]}
/>
<div className="space-y-6">
<Card title="What The Explorer Is Showing You">
<div className="space-y-4 text-sm text-gray-600 dark:text-gray-400">
<p>
The explorer now distinguishes between canonical GRU money surfaces on Chain 138 and wrapped transport assets used on public-chain bridge lanes.
It also highlights when a token looks ready for x402-style payment flows.
</p>
<p>
You can inspect these signals directly on live examples such as
{' '}<Link href="/tokens/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22" className="text-primary-600 hover:underline">cUSDT</Link>,
{' '}<Link href="/tokens/0xf22258f57794CC8E06237084b353Ab30fFfa640b" className="text-primary-600 hover:underline">cUSDC</Link>,
and related GRU-aware search results under
{' '}<Link href="/search?q=cUSDT" className="text-primary-600 hover:underline">search</Link>.
</p>
<p>
A practical verification path is: open a token page, confirm the GRU standards card, check the x402 and ISO-20022 posture badges,
inspect the sibling-network entries under <strong>Other Networks</strong>, and then pivot into a related transaction to see how
GRU-aware transfers are labeled in the transaction evidence flow.
</p>
<div className="flex flex-wrap gap-2">
<EntityBadge label="GRU" tone="success" />
<EntityBadge label="x402 ready" tone="info" />
<EntityBadge label="forward canonical" tone="success" />
<EntityBadge label="wrapped" tone="warning" />
</div>
</div>
</Card>
<Card title="Standards Summary">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="font-medium text-gray-900 dark:text-white">Base token profile</div>
<div className="mt-2 text-gray-600 dark:text-gray-400">
Canonical GRU v2 base tokens are expected to expose ERC-20, AccessControl, Pausable, EIP-712, ERC-2612, ERC-3009,
ERC-5267, deterministic storage namespacing, and governance/supervision metadata.
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 text-sm dark:border-gray-700 dark:bg-gray-900/40">
<div className="font-medium text-gray-900 dark:text-white">x402 readiness</div>
<div className="mt-2 text-gray-600 dark:text-gray-400">
In explorer terms, x402 readiness means the contract exposes an EIP-712 domain plus ERC-5267 domain introspection and
at least one signed payment surface such as ERC-2612 permit or ERC-3009 authorization transfers.
</div>
</div>
</div>
</Card>
<Card title="Example Explorer Surfaces">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="font-medium text-gray-900 dark:text-white">Token detail</div>
<div className="mt-2">
Open <Link href="/tokens/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22" className="text-primary-600 hover:underline">cUSDT</Link> or
{' '}<Link href="/tokens/0xf22258f57794CC8E06237084b353Ab30fFfa640b" className="text-primary-600 hover:underline">cUSDC</Link>
{' '}to inspect the GRU standards card, x402 posture, ISO-20022 posture, and sibling-network mappings.
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="font-medium text-gray-900 dark:text-white">Search</div>
<div className="mt-2">
Use <Link href="/search?q=cUSDT" className="text-primary-600 hover:underline">search for cUSDT</Link> to verify that direct token
matches and curated posture cues are visible on first paint.
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="font-medium text-gray-900 dark:text-white">Transactions</div>
<div className="mt-2">
Open any recent transfer from the token page and look for GRU-aware transfer badges and the transaction evidence matrix on the transaction detail page.
</div>
</div>
</div>
</Card>
<Card title="Chain 138 Practical Reading">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
A token can be forward-canonical and x402-ready even while older liquidity or transport lanes still run on a prior version.
That is why the explorer separates active liquidity posture from forward-canonical posture.
</p>
<p>
The most important live examples today are the USD family promotions where the V2 contracts are the preferred payment and future-canonical surface,
while some V1 liquidity still coexists operationally.
</p>
<p>
On token pages, look for the GRU standards card, x402 posture badges, ISO-20022 badges, and sibling-network references. On transaction pages,
look for GRU-aware transfer badges and the transaction evidence matrix.
</p>
</div>
</Card>
<Card title="Next Places To Look">
<div className="flex flex-wrap gap-3 text-sm">
<Link href="/search" className="text-primary-600 hover:underline">
Search the explorer
</Link>
<Link href="/tokens" className="text-primary-600 hover:underline">
Inspect token pages
</Link>
<Link href="/docs/transaction-review" className="text-primary-600 hover:underline">
Transaction review guide
</Link>
<Link href="/transactions" className="text-primary-600 hover:underline">
Check transaction transfers
</Link>
<Link href="/docs" className="text-primary-600 hover:underline">
General documentation
</Link>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,131 @@
'use client'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
const docsCards = [
{
title: 'GRU guide',
href: '/docs/gru',
description: 'Understand GRU standards, x402 readiness, wrapped transport posture, and forward-canonical versioning as surfaced by the explorer.',
},
{
title: 'Transaction evidence matrix',
href: '/docs/transaction-review',
description: 'See how the explorer scores transaction evidence quality, decode richness, asset posture, and counterparty traceability.',
},
{
title: 'Liquidity access',
href: '/liquidity',
description: 'Open the public liquidity and route access surface when you need execution context alongside the documentation.',
},
{
title: 'Operations hub',
href: '/operations',
description: 'Move into bridge monitoring, route coverage, command-center views, and other public operational surfaces.',
},
]
const policyLinks = [
{ label: 'Privacy policy', href: '/privacy.html' },
{ label: 'Terms of service', href: '/terms.html' },
{ label: 'Acknowledgments', href: '/acknowledgments.html' },
]
export default function DocsIndexPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
eyebrow="Explorer Documentation"
title="Documentation"
description="Use the explorers public guides, methodology notes, and adjacent operational references from one canonical docs surface."
actions={[
{ href: '/docs/gru', label: 'GRU guide' },
{ href: '/docs/transaction-review', label: 'Review matrix' },
{ href: '/operations', label: 'Operations hub' },
]}
/>
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<Card title="Explorer Guides">
<div className="grid gap-4 md:grid-cols-2">
{docsCards.map((item) => (
<div key={item.href} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="text-base font-semibold text-gray-900 dark:text-white">{item.title}</div>
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">{item.description}</p>
<div className="mt-4">
<Link href={item.href} className="text-primary-600 hover:underline">
Open guide
</Link>
</div>
</div>
))}
</div>
</Card>
<div className="space-y-6">
<Card title="Verify These Guides Live">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
The docs are meant to prove the explorer, not merely describe it. Each guide below links back into live Chain 138 pages where the
documented signals can be inspected directly.
</p>
<div className="flex flex-wrap gap-3">
<Link href="/search?q=cUSDT" className="text-primary-600 hover:underline">
Search cUSDT
</Link>
<Link href="/tokens/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22" className="text-primary-600 hover:underline">
Open cUSDT token page
</Link>
<Link href="/transactions" className="text-primary-600 hover:underline">
Browse recent transactions
</Link>
</div>
</div>
</Card>
<Card title="Operator & Domains">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
SolaceScan is the public Chain 138 explorer operated by DBIS / Defi Oracle. The explorer may be reached through
<code> blockscout.defi-oracle.io</code> or <code> explorer.d-bis.org</code>.
</p>
<p>
These domains are part of the same explorer and companion-tooling surface, including the Snap install path at
<code> /snap/</code>. Support and policy notices are handled through
<a href="mailto:support@d-bis.org" className="ml-1 text-primary-600 hover:underline">support@d-bis.org</a>.
</p>
</div>
</Card>
<Card title="Policies & Static Notes">
<div className="space-y-3 text-sm">
{policyLinks.map((item) => (
<div key={item.href}>
<a href={item.href} className="text-primary-600 hover:underline">
{item.label}
</a>
</div>
))}
</div>
</Card>
<Card title="Need Help?">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
The public explorer docs cover GRU posture, transaction review scoring, liquidity access, and navigation into the broader Chain 138 surfaces.
</p>
<p>
Support: <a href="mailto:support@d-bis.org" className="text-primary-600 hover:underline">support@d-bis.org</a>
</p>
<p>
Command center: <a href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">open visual map </a>
</p>
</div>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
'use client'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
const FACTORS = [
['Execution integrity', '25', 'Success/failure, receipt posture, and basic execution certainty.'],
['Decode clarity', '15', 'Whether the explorer can identify the method and its structured parameters.'],
['Counterparty traceability', '15', 'Visibility of sender, recipient or created contract, block, and timestamp anchoring.'],
['Asset posture', '20', 'Whether transferred assets look GRU-aware, x402-ready, and ISO-20022-aligned.'],
['Audit richness', '15', 'Presence of token transfers, internal calls, raw input, and decoded input.'],
['Exception hygiene', '10', 'Penalty when revert reasons or failed execution are visible.'],
]
export default function TransactionComplianceDocsPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
eyebrow="Explorer Documentation"
title="Transaction Evidence Matrix"
description="A practical explorer-side heuristic for scoring how well a transaction is evidenced, decoded, and aligned with GRU, x402, and ISO-20022 posture."
actions={[
{ href: '/transactions', label: 'Browse transactions' },
{ href: '/docs/gru', label: 'GRU guide' },
{ href: '/search?q=cUSDT', label: 'Search cUSDT' },
{ href: '/search', label: 'Search explorer' },
]}
/>
<div className="space-y-6">
<Card title="What This Score Means">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>
This is an explorer-visible review heuristic, not a legal, regulatory, or final compliance determination. It grades how much structured evidence
the explorer can see around a transaction and how well the transferred assets align with the repos GRU, x402, and ISO-20022 posture.
</p>
<p>
It is useful for triage, review, and operations. It should not be mistaken for a substitute for off-chain policy, regulated workflow approval,
or settlement-finality review.
</p>
<p>
A live example is available on transaction detail pages wherever the explorer has enough decoded context to score execution integrity, traceability,
asset posture, and audit richness.
</p>
<p>
The easiest way to verify the feature is to start from a live GRU token page such as
{' '}<Link href="/tokens/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22" className="text-primary-600 hover:underline">cUSDT</Link>,
open a recent transfer, and inspect the review card on the transaction detail page.
</p>
</div>
</Card>
<Card title="Scoring Factors">
<div className="space-y-3">
{FACTORS.map(([label, max, summary]) => (
<div key={label} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-gray-900 dark:text-white">{label}</div>
<div className="text-sm text-gray-600 dark:text-gray-400">{max} points</div>
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">{summary}</div>
</div>
))}
</div>
</Card>
<Card title="Grades">
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div><strong className="text-gray-900 dark:text-white">A:</strong> 90-100, very strong explorer-visible evidence quality.</div>
<div><strong className="text-gray-900 dark:text-white">B:</strong> 80-89, strong with minor evidence gaps.</div>
<div><strong className="text-gray-900 dark:text-white">C:</strong> 70-79, broadly understandable but with gaps.</div>
<div><strong className="text-gray-900 dark:text-white">D:</strong> 60-69, limited evidence comfort.</div>
<div><strong className="text-gray-900 dark:text-white">E:</strong> below 60, weak explorer-visible evidence quality.</div>
</div>
</Card>
<Card title="How To Verify It Live">
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<p>1. Open a token detail page for a GRU-tracked asset.</p>
<p>2. Follow one of the recent transfers into its transaction detail page.</p>
<p>3. Confirm the transaction page shows decoded execution context, transfer posture, and the transaction review card.</p>
<p>4. Use the GRU guide to cross-check why asset posture contributes to the score.</p>
</div>
</Card>
<Card title="Further Reading">
<div className="flex flex-wrap gap-3 text-sm">
<Link href="/docs/gru" className="text-primary-600 hover:underline">
GRU guide
</Link>
<Link href="/search?q=cUSDT" className="text-primary-600 hover:underline">
Search cUSDT
</Link>
<Link href="/docs" className="text-primary-600 hover:underline">
General documentation
</Link>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { default } from './transaction-compliance'

View File

@@ -12,7 +12,7 @@ export default function HomeAliasPage() {
return (
<main className="container mx-auto px-4 py-12">
<div className="mx-auto max-w-xl rounded-xl border border-gray-200 bg-white p-6 text-center shadow-sm dark:border-gray-700 dark:bg-gray-800">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Redirecting to SolaceScanScout</h1>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Redirecting to SolaceScan</h1>
<p className="mt-3 text-sm leading-7 text-gray-600 dark:text-gray-400">
The legacy <code className="rounded bg-gray-100 px-1 py-0.5 text-xs dark:bg-gray-900">/home</code> route now redirects to the main explorer landing page.
</p>

View File

@@ -0,0 +1,71 @@
import type { GetServerSideProps } from 'next'
import HomePage from '@/components/home/HomePage'
import { normalizeBlock } from '@/services/api/blockscout'
import {
normalizeExplorerStats,
normalizeTransactionTrend,
summarizeRecentTransactions,
type ExplorerRecentActivitySnapshot,
type ExplorerStats,
type ExplorerTransactionTrendPoint,
} from '@/services/api/stats'
import {
summarizeMissionControlRelay,
type MissionControlRelaySummary,
} from '@/services/api/missionControl'
import type { Block } from '@/services/api/blocks'
import { fetchPublicJson } from '@/utils/publicExplorer'
interface IndexPageProps {
initialStats: ExplorerStats | null
initialRecentBlocks: Block[]
initialTransactionTrend: ExplorerTransactionTrendPoint[]
initialActivitySnapshot: ExplorerRecentActivitySnapshot | null
initialRelaySummary: MissionControlRelaySummary | null
}
export default function IndexPage(props: IndexPageProps) {
return <HomePage {...props} />
}
export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [statsResult, blocksResult, trendResult, activityResult, bridgeResult] = await Promise.allSettled([
fetchPublicJson<{
total_blocks?: number | string | null
total_transactions?: number | string | null
total_addresses?: number | string | null
latest_block?: number | string | null
}>('/api/v2/stats'),
fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=10'),
fetchPublicJson<{ chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }> }>(
'/api/v2/stats/charts/transactions'
),
fetchPublicJson<
Array<{
status?: string | null
transaction_types?: string[] | null
gas_used?: number | string | null
fee?: { value?: string | number | null } | string | null
}>
>('/api/v2/main-page/transactions'),
fetchPublicJson('/explorer-api/v1/track1/bridge/status'),
])
return {
props: {
initialStats: statsResult.status === 'fulfilled' ? normalizeExplorerStats(statsResult.value) : null,
initialRecentBlocks:
blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items)
? blocksResult.value.items.map((item) => normalizeBlock(item as never, chainId))
: [],
initialTransactionTrend:
trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value) : [],
initialActivitySnapshot:
activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value) : null,
initialRelaySummary:
bridgeResult.status === 'fulfilled' ? summarizeMissionControlRelay(bridgeResult.value as never) : null,
},
}
}

View File

@@ -0,0 +1,84 @@
import type { GetServerSideProps } from 'next'
import LiquidityOperationsPage from '@/components/explorer/LiquidityOperationsPage'
import type { TokenListResponse } from '@/services/api/config'
import type { InternalExecutionPlanResponse, PlannerCapabilitiesResponse } from '@/services/api/planner'
import type { MissionControlLiquidityPool, RouteMatrixResponse } from '@/services/api/routes'
import { fetchPublicJson } from '@/utils/publicExplorer'
interface TokenPoolRecord {
symbol: string
pools: MissionControlLiquidityPool[]
}
interface LiquidityPageProps {
initialTokenList: TokenListResponse | null
initialRouteMatrix: RouteMatrixResponse | null
initialPlannerCapabilities: PlannerCapabilitiesResponse | null
initialInternalPlan: InternalExecutionPlanResponse | null
initialTokenPoolRecords: TokenPoolRecord[]
}
const featuredTokenSymbols = new Set(['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT'])
async function fetchPublicPostJson<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(`https://blockscout.defi-oracle.io${path}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return (await response.json()) as T
}
export default function LiquidityPage(props: LiquidityPageProps) {
return <LiquidityOperationsPage {...props} />
}
export const getServerSideProps: GetServerSideProps<LiquidityPageProps> = async () => {
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult] =
await Promise.all([
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson<PlannerCapabilitiesResponse>('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch(
() => null,
),
fetchPublicPostJson<InternalExecutionPlanResponse>('/token-aggregation/api/v2/routes/internal-execution-plan', {
sourceChainId: 138,
tokenIn: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
tokenOut: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
amountIn: '100000000000000000',
}).catch(() => null),
])
const featuredTokens = (tokenListResult?.tokens || []).filter(
(token) => token.chainId === 138 && typeof token.symbol === 'string' && featuredTokenSymbols.has(token.symbol),
)
const tokenPoolsResults = await Promise.all(
featuredTokens.map(async (token) => {
const response = await fetchPublicJson<{ pools?: MissionControlLiquidityPool[]; data?: { pools?: MissionControlLiquidityPool[] } }>(
`/explorer-api/v1/mission-control/liquidity/token/${token.address}/pools`,
).catch(() => null)
const pools = Array.isArray(response?.pools)
? response.pools
: Array.isArray(response?.data?.pools)
? response.data.pools
: []
return { symbol: token.symbol || token.address || 'unknown', pools }
}),
).catch(() => [] as TokenPoolRecord[])
return {
props: {
initialTokenList: tokenListResult,
initialRouteMatrix: routeMatrixResult,
initialPlannerCapabilities: plannerCapabilitiesResult,
initialInternalPlan: internalPlanResult,
initialTokenPoolRecords: tokenPoolsResults,
},
}
}

View File

@@ -1,9 +1,14 @@
import dynamic from 'next/dynamic'
import type { GetServerSideProps } from 'next'
const MoreOperationsPage = dynamic(() => import('@/components/explorer/MoreOperationsPage'), {
ssr: false,
})
export const getServerSideProps: GetServerSideProps = async () => {
return {
redirect: {
destination: '/operations',
permanent: true,
},
}
}
export default function MorePage() {
return <MoreOperationsPage />
return null
}

View File

@@ -0,0 +1,38 @@
import type { GetStaticProps } from 'next'
import OperationsHubPage from '@/components/explorer/OperationsHubPage'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import type { RouteMatrixResponse } from '@/services/api/routes'
import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse } from '@/services/api/config'
import { fetchPublicJson } from '@/utils/publicExplorer'
interface OperationsPageProps {
initialBridgeStatus: MissionControlBridgeStatusResponse | null
initialRouteMatrix: RouteMatrixResponse | null
initialNetworksConfig: NetworksConfigResponse | null
initialTokenList: TokenListResponse | null
initialCapabilities: CapabilitiesResponse | null
}
export default function OperationsPage(props: OperationsPageProps) {
return <OperationsHubPage {...props} />
}
export const getStaticProps: GetStaticProps<OperationsPageProps> = async () => {
const [bridgeResult, routesResult, networksResult, tokenListResult, capabilitiesResult] = await Promise.all([
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson<NetworksConfigResponse>('/api/config/networks').catch(() => null),
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
fetchPublicJson<CapabilitiesResponse>('/api/config/capabilities').catch(() => null),
])
return {
props: {
initialBridgeStatus: bridgeResult,
initialRouteMatrix: routesResult,
initialNetworksConfig: networksResult,
initialTokenList: tokenListResult,
initialCapabilities: capabilitiesResult,
},
}
}

View File

@@ -1,9 +1,36 @@
import dynamic from 'next/dynamic'
import type { GetServerSideProps } from 'next'
import OperatorOperationsPage from '@/components/explorer/OperatorOperationsPage'
import { fetchPublicJson } from '@/utils/publicExplorer'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import type { InternalExecutionPlanResponse, PlannerCapabilitiesResponse } from '@/services/api/planner'
import type { RouteMatrixResponse } from '@/services/api/routes'
const OperatorOperationsPage = dynamic(() => import('@/components/explorer/OperatorOperationsPage'), {
ssr: false,
})
export default function OperatorPage() {
return <OperatorOperationsPage />
interface OperatorPageProps {
initialBridgeStatus: MissionControlBridgeStatusResponse | null
initialRouteMatrix: RouteMatrixResponse | null
initialPlannerCapabilities: PlannerCapabilitiesResponse | null
initialInternalPlan: InternalExecutionPlanResponse | null
}
export default function OperatorPage(props: OperatorPageProps) {
return <OperatorOperationsPage {...props} />
}
export const getServerSideProps: GetServerSideProps<OperatorPageProps> = async () => {
const [bridgeStatus, routeMatrix, plannerCapabilities] = await Promise.all([
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson<PlannerCapabilitiesResponse>('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch(
() => null,
),
])
return {
props: {
initialBridgeStatus: bridgeStatus,
initialRouteMatrix: routeMatrix,
initialPlannerCapabilities: plannerCapabilities,
initialInternalPlan: null,
},
}
}

View File

@@ -1,9 +1,38 @@
import dynamic from 'next/dynamic'
import type { GetStaticProps } from 'next'
import RoutesMonitoringPage from '@/components/explorer/RoutesMonitoringPage'
import { fetchPublicJson } from '@/utils/publicExplorer'
import type {
ExplorerNetwork,
MissionControlLiquidityPool,
RouteMatrixResponse,
} from '@/services/api/routes'
const RoutesMonitoringPage = dynamic(() => import('@/components/explorer/RoutesMonitoringPage'), {
ssr: false,
})
export default function RoutesPage() {
return <RoutesMonitoringPage />
interface RoutesPageProps {
initialRouteMatrix: RouteMatrixResponse | null
initialNetworks: ExplorerNetwork[]
initialPools: MissionControlLiquidityPool[]
}
export default function RoutesPage(props: RoutesPageProps) {
return <RoutesMonitoringPage {...props} />
}
export const getStaticProps: GetStaticProps<RoutesPageProps> = async () => {
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
const [matrixResult, networksResult, poolsResult] = await Promise.all([
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson<{ networks?: ExplorerNetwork[] }>('/token-aggregation/api/v1/networks').catch(() => null),
fetchPublicJson<{ pools?: MissionControlLiquidityPool[] }>(
`/token-aggregation/api/v1/tokens/${canonicalLiquidityToken}/pools`,
).catch(() => null),
])
return {
props: {
initialRouteMatrix: matrixResult,
initialNetworks: networksResult?.networks || [],
initialPools: poolsResult?.pools || [],
},
revalidate: 60,
}
}

View File

@@ -1,89 +1,120 @@
'use client'
import { useEffect, useState } from 'react'
import type { GetServerSideProps } from 'next'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/router'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { getExplorerApiBase } from '@/services/api/blockscout'
import { inferDirectSearchTarget } from '@/utils/search'
import { configApi, type TokenListToken } from '@/services/api/config'
import EntityBadge from '@/components/common/EntityBadge'
import {
inferDirectSearchTarget,
inferTokenSearchTarget,
normalizeExplorerSearchResults,
suggestCuratedTokens,
type RawExplorerSearchItem,
} from '@/utils/search'
import PageIntro from '@/components/common/PageIntro'
import { fetchPublicJson } from '@/utils/publicExplorer'
interface SearchResult {
type: string
chain_id?: number
data: {
hash?: string
address?: string
number?: number
block_number?: number
}
score?: number
type SearchFilterMode = 'all' | 'gru' | 'x402' | 'wrapped'
interface SearchPageProps {
initialQuery: string
initialRawResults: RawExplorerSearchItem[]
initialCuratedTokens: TokenListToken[]
}
export default function SearchPage() {
export default function SearchPage({
initialQuery,
initialRawResults,
initialCuratedTokens,
}: SearchPageProps) {
const router = useRouter()
const initialQuery = typeof router.query.q === 'string' ? router.query.q : ''
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const routerQuery = typeof router.query.q === 'string' ? router.query.q : ''
const [query, setQuery] = useState(initialQuery)
const [rawResults, setRawResults] = useState<RawExplorerSearchItem[]>(initialRawResults)
const [loading, setLoading] = useState(false)
const [hasSearched, setHasSearched] = useState(false)
const [hasSearched, setHasSearched] = useState(Boolean(initialQuery.trim()))
const [error, setError] = useState<string | null>(null)
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>(initialCuratedTokens)
const [savedQueries, setSavedQueries] = useState<string[]>([])
const [filterMode, setFilterMode] = useState<SearchFilterMode>('all')
const runSearch = async (rawQuery: string) => {
const trimmedQuery = rawQuery.trim()
if (!trimmedQuery) {
setHasSearched(false)
setResults([])
setRawResults([])
setError(null)
return
}
setHasSearched(true)
setLoading(true)
setError(null)
setLoading(true)
setError(null)
try {
const response = await fetch(
`${getExplorerApiBase()}/api/v2/search?q=${encodeURIComponent(trimmedQuery)}`
`/api/v2/search?q=${encodeURIComponent(trimmedQuery)}`
)
const data = await response.json().catch(() => null)
if (!response.ok) {
setResults([])
setRawResults([])
setError('Search is temporarily unavailable right now.')
return
}
const normalizedResults = Array.isArray(data?.items)
? data.items.map((item: {
type?: string
address?: string
transaction_hash?: string
block_number?: number
priority?: number
}) => ({
type: item.type || 'unknown',
chain_id: 138,
data: {
hash: item.transaction_hash,
address: item.address,
number: item.block_number,
},
score: item.priority ?? 0,
}))
: []
setResults(normalizedResults)
setRawResults(Array.isArray(data?.items) ? data.items : [])
} catch (error) {
console.error('Search failed:', error)
setResults([])
setRawResults([])
setError('Search is temporarily unavailable right now.')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (initialCuratedTokens.length > 0) {
setCuratedTokens(initialCuratedTokens)
return
}
let active = true
configApi.getTokenList().then((response) => {
if (active) {
setCuratedTokens((response.tokens || []).filter((token) => token.chainId === 138))
}
}).catch(() => {
if (active) {
setCuratedTokens([])
}
})
return () => {
active = false
}
}, [initialCuratedTokens])
useEffect(() => {
if (typeof window === 'undefined') return
try {
const stored = window.localStorage.getItem('explorer_saved_queries')
const parsed = stored ? JSON.parse(stored) : []
setSavedQueries(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [])
} catch {
setSavedQueries([])
}
}, [])
useEffect(() => {
if (!router.isReady) return
if (!initialQuery.trim()) return
setQuery(initialQuery)
runSearch(initialQuery)
}, [initialQuery, router.isReady])
if (!routerQuery.trim()) return
if (routerQuery === initialQuery && initialRawResults.length > 0) {
setQuery(routerQuery)
setRawResults(initialRawResults)
setHasSearched(true)
return
}
setQuery(routerQuery)
void runSearch(routerQuery)
}, [initialQuery, initialRawResults, router.isReady, routerQuery])
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
@@ -92,6 +123,22 @@ export default function SearchPage() {
return
}
setSavedQueries((current) => {
const next = [trimmedQuery, ...current.filter((entry) => entry.toLowerCase() !== trimmedQuery.toLowerCase())].slice(0, 8)
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem('explorer_saved_queries', JSON.stringify(next))
} catch {}
}
return next
})
const tokenTarget = inferTokenSearchTarget(trimmedQuery, curatedTokens)
if (tokenTarget) {
void router.push(tokenTarget.href)
return
}
const directTarget = inferDirectSearchTarget(trimmedQuery)
if (directTarget) {
void router.push(directTarget.href)
@@ -110,11 +157,49 @@ export default function SearchPage() {
}
const trimmedQuery = query.trim()
const tokenTarget = inferTokenSearchTarget(trimmedQuery, curatedTokens)
const directTarget = inferDirectSearchTarget(trimmedQuery)
const results = useMemo(
() => normalizeExplorerSearchResults(trimmedQuery, rawResults, curatedTokens),
[curatedTokens, rawResults, trimmedQuery],
)
const filteredResults = useMemo(() => {
if (filterMode === 'gru') return results.filter((result) => result.is_gru_token)
if (filterMode === 'x402') return results.filter((result) => result.is_x402_ready)
if (filterMode === 'wrapped') return results.filter((result) => result.is_wrapped_transport)
return results
}, [filterMode, results])
const curatedSuggestions = useMemo(
() => suggestCuratedTokens(trimmedQuery, curatedTokens),
[curatedTokens, trimmedQuery],
)
const groupedResults = useMemo(() => ({
tokens: filteredResults.filter((result) => result.type === 'token'),
addresses: filteredResults.filter((result) => result.type === 'address'),
transactions: filteredResults.filter((result) => result.type === 'transaction'),
blocks: filteredResults.filter((result) => result.type === 'block'),
other: filteredResults.filter((result) => !['token', 'address', 'transaction', 'block'].includes(result.type)),
}), [filteredResults])
const resultSections = [
{ label: 'Tokens', items: groupedResults.tokens },
{ label: 'Addresses', items: groupedResults.addresses },
{ label: 'Transactions', items: groupedResults.transactions },
{ label: 'Blocks', items: groupedResults.blocks },
{ label: 'Other', items: groupedResults.other },
]
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-6 text-3xl font-bold">Search</h1>
<PageIntro
eyebrow="Explorer Lookup"
title="Search"
description="Search by address, transaction hash, block number, or token symbol. Direct identifiers can jump straight into detail pages, while broader terms fall back to indexed search."
actions={[
{ href: '/tokens', label: 'Token shortcuts' },
{ href: '/addresses', label: 'Browse addresses' },
{ href: '/watchlist', label: 'Open watchlist' },
]}
/>
<Card className="mb-6">
<form onSubmit={handleSearch} className="flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -138,10 +223,31 @@ export default function SearchPage() {
{!loading && error && (
<Card className="mb-6">
<p className="text-sm text-gray-600 dark:text-gray-400">{error}</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/tokens" className="text-primary-600 hover:underline">
Try token shortcuts
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Browse addresses
</Link>
</div>
</Card>
)}
{!loading && directTarget && (
{!loading && tokenTarget && (
<Card className="mb-6" title="Direct Token Match">
<p className="text-sm text-gray-600 dark:text-gray-400">
This matches a curated Chain 138 token, so you can go straight to the token detail page instead of sifting through generic search results.
</p>
<div className="mt-4">
<Link href={tokenTarget.href} className="text-primary-600 hover:underline">
{tokenTarget.label}
</Link>
</div>
</Card>
)}
{!loading && !tokenTarget && directTarget && (
<Card className="mb-6" title="Direct Match">
<p className="text-sm text-gray-600 dark:text-gray-400">
This looks like a direct explorer identifier. You can open it without waiting for indexed search results.
@@ -154,48 +260,199 @@ export default function SearchPage() {
</Card>
)}
{!loading && !tokenTarget && !directTarget && curatedSuggestions.length > 0 && (
<Card className="mb-6" title="Curated Suggestions">
<div className="space-y-3">
<p className="text-sm text-gray-600 dark:text-gray-400">
These listed Chain 138 assets are close to your query, which is often more useful than relying on a generic explorer result set alone.
</p>
<div className="flex flex-wrap gap-3">
{curatedSuggestions.map((token) => (
<Link key={token.address} href={`/tokens/${token.address}`} className="inline-flex items-center gap-2 rounded-full border border-gray-200 px-3 py-2 text-sm text-primary-600 hover:border-primary-300 hover:underline dark:border-gray-700">
<EntityBadge label="listed" tone="success" />
<span>{token.symbol || token.name || token.address}</span>
</Link>
))}
</div>
</div>
</Card>
)}
{results.length > 0 && (
<Card title="Search Results">
<div className="space-y-4">
{results.map((result, index) => (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-2">
{([
['all', 'All results'],
['gru', 'GRU'],
['x402', 'x402 ready'],
['wrapped', 'Wrapped'],
] as const).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() => setFilterMode(mode)}
className={`rounded-full border px-3 py-1.5 text-sm transition ${
filterMode === mode
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-950/40 dark:text-primary-200'
: 'border-gray-200 text-gray-600 hover:border-primary-300 hover:text-primary-600 dark:border-gray-700 dark:text-gray-300'
}`}
>
{label}
</button>
))}
<Link href="/docs/gru" className="ml-auto text-sm text-primary-600 hover:underline">
GRU guide
</Link>
</div>
{resultSections.map((section) =>
section.items.length > 0 ? (
<div key={section.label}>
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">
{section.label}
</div>
<div className="space-y-4">
{section.items.map((result, index) => (
<div key={result.data?.hash ?? result.data?.address ?? result.data?.number ?? index} className="border-b border-gray-200 pb-4 last:border-0 dark:border-gray-700">
{result.type === 'block' && result.data.number && (
<Link href={`/blocks/${result.data.number}`} className="inline-flex flex-col gap-1 text-primary-600 hover:underline">
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Block</span>
Block #{result.data.number}
<Link href={result.href || `/blocks/${result.data.number}`} className="inline-flex flex-col gap-2 text-primary-600 hover:underline">
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label="block" tone="neutral" />
</div>
<span className="font-medium text-gray-900 dark:text-white">Block #{result.data.number}</span>
</Link>
)}
{result.type === 'transaction' && result.data.hash && (
<Link href={`/transactions/${result.data.hash}`} className="inline-flex flex-col gap-1 text-primary-600 hover:underline">
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Transaction</span>
<Link href={result.href || `/transactions/${result.data.hash}`} className="inline-flex flex-col gap-2 text-primary-600 hover:underline">
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label="transaction" tone="neutral" />
</div>
<Address address={result.data.hash} truncate showCopy={false} />
</Link>
)}
{result.type === 'address' && result.data.address && (
<Link href={`/addresses/${result.data.address}`} className="inline-flex flex-col gap-1 text-primary-600 hover:underline">
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Address</span>
{(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>
)}
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500">
<span>Type: {result.type}</span>
<span>Chain: {result.chain_id ?? 138}</span>
<span>Score: {(result.score ?? 0).toFixed(2)}</span>
<span>Priority: {(result.score ?? 0).toFixed(2)}</span>
</div>
</div>
))}
))}
</div>
</div>
) : null,
)}
</div>
</Card>
)}
{!loading && hasSearched && !error && results.length === 0 && (
{!loading && hasSearched && !error && filteredResults.length === 0 && (
<Card title="No Results Found">
<p className="text-sm text-gray-600 dark:text-gray-400">
No explorer results matched <span className="font-medium text-gray-900 dark:text-white">{trimmedQuery}</span>.
No explorer results matched <span className="font-medium text-gray-900 dark:text-white">{trimmedQuery}</span>
{filterMode !== 'all' ? ` in the current ${filterMode} filter` : ''}.
Try a full address, transaction hash, token symbol, or block number.
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/tokens" className="text-primary-600 hover:underline">
Token shortcuts
</Link>
<Link href="/watchlist" className="text-primary-600 hover:underline">
Watchlist
</Link>
<Link href="/docs/gru" className="text-primary-600 hover:underline">
GRU guide
</Link>
</div>
</Card>
)}
{!loading && !hasSearched && (
<Card title="Popular Starting Points">
<div className="space-y-4">
<div className="flex flex-wrap gap-3 text-sm">
<Link href="/search?q=cUSDT" className="text-primary-600 hover:underline">
cUSDT
</Link>
<Link href="/search?q=cUSDC" className="text-primary-600 hover:underline">
cUSDC
</Link>
<Link href="/search?q=cXAUC" className="text-primary-600 hover:underline">
cXAUC
</Link>
<Link href="/search?q=USDT" className="text-primary-600 hover:underline">
USDT
</Link>
</div>
{savedQueries.length > 0 ? (
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">
Recent Searches
</div>
<div className="flex flex-wrap gap-3 text-sm">
{savedQueries.map((savedQuery) => (
<Link key={savedQuery} href={`/search?q=${encodeURIComponent(savedQuery)}`} className="text-primary-600 hover:underline">
{savedQuery}
</Link>
))}
</div>
</div>
) : null}
</div>
</Card>
)}
</div>
)
}
export const getServerSideProps: GetServerSideProps<SearchPageProps> = async (context) => {
const initialQuery = typeof context.query.q === 'string' ? context.query.q.trim() : ''
const tokenListResult = await fetchPublicJson<{ tokens?: TokenListToken[] }>('/api/config/token-list').catch(() => null)
const initialCuratedTokens = Array.isArray(tokenListResult?.tokens)
? tokenListResult.tokens.filter((token) => token.chainId === 138)
: []
const shouldFetchSearch =
Boolean(initialQuery) &&
!inferTokenSearchTarget(initialQuery, initialCuratedTokens) &&
!inferDirectSearchTarget(initialQuery)
const searchResult = shouldFetchSearch
? await fetchPublicJson<{ items?: RawExplorerSearchItem[] }>(
`/api/v2/search?q=${encodeURIComponent(initialQuery)}`,
).catch(() => null)
: null
return {
props: {
initialQuery,
initialRawResults: Array.isArray(searchResult?.items) ? searchResult.items : [],
initialCuratedTokens,
},
}
}

View File

@@ -1,9 +1,42 @@
import dynamic from 'next/dynamic'
import type { GetServerSideProps } from 'next'
import SystemOperationsPage from '@/components/explorer/SystemOperationsPage'
import { fetchPublicJson } from '@/utils/publicExplorer'
import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse } from '@/services/api/config'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import type { RouteMatrixResponse } from '@/services/api/routes'
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
const SystemOperationsPage = dynamic(() => import('@/components/explorer/SystemOperationsPage'), {
ssr: false,
})
export default function SystemPage() {
return <SystemOperationsPage />
interface SystemPageProps {
initialBridgeStatus: MissionControlBridgeStatusResponse | null
initialNetworksConfig: NetworksConfigResponse | null
initialTokenList: TokenListResponse | null
initialCapabilities: CapabilitiesResponse | null
initialRouteMatrix: RouteMatrixResponse | null
initialStats: ExplorerStats | null
}
export default function SystemPage(props: SystemPageProps) {
return <SystemOperationsPage {...props} />
}
export const getServerSideProps: GetServerSideProps<SystemPageProps> = async () => {
const [bridgeStatus, networksConfig, tokenList, capabilities, routeMatrix, stats] = await Promise.all([
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
fetchPublicJson<NetworksConfigResponse>('/api/config/networks').catch(() => null),
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
fetchPublicJson<CapabilitiesResponse>('/api/config/capabilities').catch(() => null),
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
fetchPublicJson('/api/v2/stats').then((value) => normalizeExplorerStats(value as never)).catch(() => null),
])
return {
props: {
initialBridgeStatus: bridgeStatus,
initialNetworksConfig: networksConfig,
initialTokenList: tokenList,
initialCapabilities: capabilities,
initialRouteMatrix: routeMatrix,
initialStats: stats,
},
}
}

View File

@@ -0,0 +1,504 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Address, Card, Table } from '@/libs/frontend-ui-primitives'
import { tokensApi, type TokenHolder, type TokenProfile, type TokenProvenance } from '@/services/api/tokens'
import type { AddressTokenTransfer } from '@/services/api/addresses'
import type { MissionControlLiquidityPool } from '@/services/api/routes'
import PageIntro from '@/components/common/PageIntro'
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import GruStandardsCard from '@/components/common/GruStandardsCard'
import { formatTokenAmount, formatTimestamp } from '@/utils/format'
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
function isValidAddress(value: string) {
return /^0x[a-fA-F0-9]{40}$/.test(value)
}
function toNumeric(value: string | number | null | undefined): number | null {
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 null
}
function formatUsd(value: string | number | null | undefined): string {
const numeric = toNumeric(value)
if (numeric == null) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: numeric >= 100 ? 0 : 2,
}).format(numeric)
}
export default function TokenDetailPage() {
const router = useRouter()
const address = typeof router.query.address === 'string' ? router.query.address : ''
const isValidTokenAddress = address !== '' && isValidAddress(address)
const [token, setToken] = useState<TokenProfile | null>(null)
const [provenance, setProvenance] = useState<TokenProvenance | null>(null)
const [holders, setHolders] = useState<TokenHolder[]>([])
const [transfers, setTransfers] = useState<AddressTokenTransfer[]>([])
const [pools, setPools] = useState<MissionControlLiquidityPool[]>([])
const [gruProfile, setGruProfile] = useState<GruStandardsProfile | null>(null)
const [loading, setLoading] = useState(true)
const loadToken = useCallback(async () => {
setLoading(true)
try {
const [tokenResult, provenanceResult, holdersResult, transfersResult, poolsResult] = await Promise.all([
tokensApi.getSafe(address),
tokensApi.getProvenanceSafe(address),
tokensApi.getHoldersSafe(address, 1, 10),
tokensApi.getTransfersSafe(address, 1, 10),
tokensApi.getRelatedPoolsSafe(address),
])
setToken(tokenResult.ok ? tokenResult.data : null)
setProvenance(provenanceResult.ok ? provenanceResult.data : null)
setHolders(holdersResult.ok ? holdersResult.data : [])
setTransfers(transfersResult.ok ? transfersResult.data : [])
setPools(poolsResult.ok ? poolsResult.data : [])
if (tokenResult.ok && tokenResult.data) {
const gruResult = await getGruStandardsProfileSafe({
address,
symbol: tokenResult.data.symbol,
tags: provenanceResult.ok ? provenanceResult.data?.tags || [] : [],
})
setGruProfile(gruResult.ok ? gruResult.data : null)
} else {
setGruProfile(null)
}
} catch {
setToken(null)
setProvenance(null)
setHolders([])
setTransfers([])
setPools([])
setGruProfile(null)
} finally {
setLoading(false)
}
}, [address])
useEffect(() => {
if (!router.isReady || !address) {
setLoading(router.isReady ? false : true)
return
}
if (!isValidTokenAddress) {
setLoading(false)
setToken(null)
return
}
void loadToken()
}, [address, isValidTokenAddress, loadToken, router.isReady])
const provenanceTags = useMemo(() => {
const tags = [...(provenance?.tags || [])]
if (provenance?.listed && !tags.includes('listed')) {
tags.unshift('listed')
}
return tags
}, [provenance])
const holderConcentration = useMemo(() => {
if (!token?.total_supply || holders.length === 0) return null
const topHolder = holders[0]
if (!topHolder) return null
const supply = BigInt(token.total_supply)
if (supply === 0n) return null
const topBalance = BigInt(topHolder.value || '0')
return Number((topBalance * 10000n) / supply) / 100
}, [holders, token?.total_supply])
const liquiditySummary = useMemo(() => {
const poolCount = pools.length
const totalTvl = pools.reduce((sum, pool) => sum + (pool.tvl || 0), 0)
return {
poolCount,
totalTvl,
}
}, [pools])
const transferFlowSummary = useMemo(() => {
const uniqueSenders = new Set(transfers.map((transfer) => transfer.from_address.toLowerCase())).size
const uniqueRecipients = new Set(transfers.map((transfer) => transfer.to_address.toLowerCase())).size
return {
sampleSize: transfers.length,
uniqueSenders,
uniqueRecipients,
}
}, [transfers])
const trustSummary = useMemo(() => {
const signals: string[] = []
if (provenance?.listed) signals.push('listed in the Chain 138 registry')
if (provenanceTags.includes('compliant')) signals.push('marked compliant')
if (provenanceTags.includes('bridge')) signals.push('bridge-linked asset')
if (liquiditySummary.poolCount > 0) signals.push(`${liquiditySummary.poolCount} related liquidity pool${liquiditySummary.poolCount === 1 ? '' : 's'}`)
if ((token?.holders || 0) > 0) signals.push(`${token?.holders?.toLocaleString()} indexed holders`)
return signals
}, [liquiditySummary.poolCount, provenance?.listed, provenanceTags, token?.holders])
const trustProfile = useMemo(() => {
if (provenance?.listed && provenanceTags.includes('compliant')) {
return {
label: 'high confidence',
tone: 'success' as const,
summary: 'Curated registry coverage plus standards and policy metadata make this one of the explorers better-understood assets.',
}
}
if (provenance?.listed || provenanceTags.includes('bridge') || liquiditySummary.poolCount > 0) {
return {
label: 'moderate confidence',
tone: 'info' as const,
summary: 'There are enough public signals to treat this as a known asset, but not enough to confuse that with blanket safety.',
}
}
return {
label: 'limited confidence',
tone: 'warning' as const,
summary: 'The explorer can see the token, but curated provenance and broader trust signals remain thin.',
}
}, [liquiditySummary.poolCount, provenance?.listed, provenanceTags])
const gruExplorerMetadata = useMemo(
() => getGruExplorerMetadata({ address: token?.address || address, symbol: token?.symbol }),
[address, token?.address, token?.symbol],
)
const holderColumns = [
{
header: 'Holder',
accessor: (holder: TokenHolder) => (
<Link href={`/addresses/${holder.address}`} className="text-primary-600 hover:underline">
{holder.label || <Address address={holder.address} truncate showCopy={false} />}
</Link>
),
},
{
header: 'Balance',
accessor: (holder: TokenHolder) => formatTokenAmount(holder.value, token?.decimals || holder.token_decimals, token?.symbol),
},
]
const transferColumns = [
{
header: 'Hash',
accessor: (transfer: AddressTokenTransfer) => (
<Link href={`/transactions/${transfer.transaction_hash}`} className="text-primary-600 hover:underline">
<Address address={transfer.transaction_hash} truncate showCopy={false} />
</Link>
),
},
{
header: 'Posture',
accessor: (transfer: AddressTokenTransfer) => {
const gruMetadata = getGruExplorerMetadata({
address: transfer.token_address,
symbol: transfer.token_symbol,
})
if (!gruMetadata) {
return <span className="text-gray-500 dark:text-gray-400">Generic token</span>
}
return (
<div className="flex flex-wrap gap-2">
<EntityBadge label="GRU" tone="success" />
{gruMetadata.x402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
{gruMetadata.iso20022Ready ? <EntityBadge label="ISO-20022" tone="info" /> : null}
</div>
)
},
},
{
header: 'From',
accessor: (transfer: AddressTokenTransfer) => (
<Link href={`/addresses/${transfer.from_address}`} className="text-primary-600 hover:underline">
{transfer.from_label || <Address address={transfer.from_address} truncate showCopy={false} />}
</Link>
),
},
{
header: 'To',
accessor: (transfer: AddressTokenTransfer) => (
<Link href={`/addresses/${transfer.to_address}`} className="text-primary-600 hover:underline">
{transfer.to_label || <Address address={transfer.to_address} truncate showCopy={false} />}
</Link>
),
},
{
header: 'Amount',
accessor: (transfer: AddressTokenTransfer) => formatTokenAmount(transfer.value, transfer.token_decimals, transfer.token_symbol),
},
{
header: 'When',
accessor: (transfer: AddressTokenTransfer) => formatTimestamp(transfer.timestamp),
},
]
const poolColumns = [
{
header: 'Pool',
accessor: (pool: MissionControlLiquidityPool) => (
<Link href={`/addresses/${pool.address}`} className="text-primary-600 hover:underline">
<Address address={pool.address} truncate showCopy={false} />
</Link>
),
},
{
header: 'DEX',
accessor: (pool: MissionControlLiquidityPool) => pool.dex || 'Unknown',
},
{
header: 'Pair',
accessor: (pool: MissionControlLiquidityPool) => `${pool.token0?.symbol || 'Token 0'} / ${pool.token1?.symbol || 'Token 1'}`,
},
{
header: 'TVL',
accessor: (pool: MissionControlLiquidityPool) => pool.tvl != null ? `$${Math.round(pool.tvl).toLocaleString()}` : 'N/A',
},
]
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
eyebrow="Token Detail"
title={token?.symbol || token?.name || 'Token'}
description="Inspect token supply, holders, transfers, and liquidity context with the sort of composure one normally has to borrow from a better explorer."
actions={[
{ href: '/tokens', label: 'Token index' },
{ href: '/liquidity', label: 'Liquidity access' },
{ href: '/search', label: 'Explorer search' },
]}
/>
<div className="mb-6 flex flex-wrap gap-3 text-sm">
<Link href="/tokens" className="text-primary-600 hover:underline">
Back to tokens
</Link>
{address && (
<Link href={`/addresses/${address}`} className="text-primary-600 hover:underline">
Open contract address
</Link>
)}
</div>
{!router.isReady || loading ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Loading token...</p>
</Card>
) : !isValidTokenAddress ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Invalid token address. Please use a full 42-character 0x-prefixed address.</p>
</Card>
) : !token ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Token details were not found for this address.</p>
</Card>
) : (
<div className="space-y-6">
<Card title="Token Overview">
<dl className="space-y-4">
<DetailRow label="Name">{token.name || 'Unknown'}</DetailRow>
<DetailRow label="Symbol">{token.symbol || 'Unknown'}</DetailRow>
<DetailRow label="Address">
<Address address={token.address} />
</DetailRow>
<DetailRow label="Type">{token.type || 'Unknown'}</DetailRow>
<DetailRow label="Decimals">{token.decimals}</DetailRow>
{token.total_supply && (
<DetailRow label="Total Supply">
{formatTokenAmount(token.total_supply, token.decimals, token.symbol)}
</DetailRow>
)}
{token.holders != null && (
<DetailRow label="Holders">{token.holders.toLocaleString()}</DetailRow>
)}
<DetailRow label="Provenance" valueClassName="flex flex-wrap gap-2">
{provenanceTags.length > 0 ? provenanceTags.map((tag) => (
<EntityBadge key={tag} label={tag} />
)) : <span className="text-gray-500">No curated provenance metadata yet</span>}
</DetailRow>
<DetailRow label="Listing">
{provenance?.listed ? 'Listed in the Chain 138 token registry' : 'Not present in the curated Chain 138 token registry'}
</DetailRow>
<DetailRow label="Trust Posture">
<div className="space-y-2">
<EntityBadge label={trustProfile.label} tone={trustProfile.tone} />
<div className="text-sm text-gray-600 dark:text-gray-400">{trustProfile.summary}</div>
</div>
</DetailRow>
</dl>
</Card>
<Card title="Token Intelligence">
<div className="grid gap-4 lg:grid-cols-2">
<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>24h volume: {formatUsd(token.volume_24h)}</div>
<div>Market cap: {formatUsd(token.circulating_market_cap)}</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">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Liquidity & Distribution</div>
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div>Related pools: {liquiditySummary.poolCount.toLocaleString()}</div>
<div>Total visible TVL: {formatUsd(liquiditySummary.totalTvl)}</div>
<div>Largest visible holder: {holderConcentration != null ? `${holderConcentration}% of supply` : '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">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Transfer Activity</div>
<div className="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div>Recent transfer sample: {transferFlowSummary.sampleSize.toLocaleString()}</div>
<div>Unique senders: {transferFlowSummary.uniqueSenders.toLocaleString()}</div>
<div>Unique recipients: {transferFlowSummary.uniqueRecipients.toLocaleString()}</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">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Trust Signals</div>
<div className="mt-3 flex flex-wrap gap-2">
{trustSummary.length > 0 ? trustSummary.map((signal) => (
<EntityBadge key={signal} label={signal} tone="info" className="normal-case tracking-normal" />
)) : (
<span className="text-sm text-gray-600 dark:text-gray-400">No strong trust signals are available yet beyond the base token profile.</span>
)}
</div>
</div>
</div>
</Card>
{gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
{gruExplorerMetadata ? (
<Card title="x402 And ISO-20022 Posture">
<div className="grid gap-4 lg:grid-cols-2">
<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">x402 readiness</div>
<div className="mt-3 flex flex-wrap gap-2">
<EntityBadge label={gruExplorerMetadata.x402Ready ? 'x402 ready' : 'x402 not ready'} tone={gruExplorerMetadata.x402Ready ? 'success' : 'warning'} />
{gruExplorerMetadata.x402PreferredVersion ? <EntityBadge label={`preferred ${gruExplorerMetadata.x402PreferredVersion}`} tone="info" /> : null}
{gruExplorerMetadata.canonicalForwardVersion ? <EntityBadge label={`forward ${gruExplorerMetadata.canonicalForwardVersion}`} tone="success" /> : null}
</div>
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
{gruExplorerMetadata.x402Ready
? 'This asset is modeled as payment-ready in the GRU explorer posture, meaning the preferred version exposes the signature and domain surfaces needed for x402-style settlement flows.'
: 'This asset is not currently marked as x402-ready in the local explorer intelligence layer.'}
</div>
</div>
<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">ISO-20022 and governance</div>
<div className="mt-3 flex flex-wrap gap-2">
<EntityBadge label={gruExplorerMetadata.iso20022Ready ? 'ISO-20022 aligned' : 'ISO-20022 unclear'} tone={gruExplorerMetadata.iso20022Ready ? 'success' : 'warning'} />
{gruExplorerMetadata.currencyCode ? <EntityBadge label={gruExplorerMetadata.currencyCode} tone="neutral" /> : null}
</div>
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
{gruExplorerMetadata.iso20022Ready
? 'The local GRU metadata for this asset treats it as part of the ISO-20022-aligned settlement model, with governance, supervision, disclosure, and reporting posture expected around the token surface.'
: 'The explorer does not currently have a strong ISO-20022 posture signal for this asset.'}
</div>
<div className="mt-3 flex flex-wrap gap-3 text-sm">
<Link href="/docs/gru" className="text-primary-600 hover:underline">
GRU guide
</Link>
<Link href="/docs/transaction-review" className="text-primary-600 hover:underline">
Transaction review guide
</Link>
<Link href="/docs" className="text-primary-600 hover:underline">
General docs
</Link>
</div>
</div>
</div>
</Card>
) : null}
{gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? (
<Card title="Other Networks">
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
These are sibling representations or settlement counterparts for the same GRU asset family on other networks, drawn from the local transport and mapping posture used by this workspace.
</p>
<div className="space-y-3">
{gruExplorerMetadata.otherNetworks.map((network) => (
<div key={`${network.chainId}-${network.symbol}-${network.address}`} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap items-center gap-2">
<EntityBadge label={network.chainName} tone="neutral" className="normal-case tracking-normal" />
<EntityBadge label={network.symbol} tone="info" />
<EntityBadge label={`chain ${network.chainId}`} tone="warning" />
</div>
<div className="mt-3 break-all text-sm text-gray-900 dark:text-white">{network.address}</div>
{network.notes ? <div className="mt-2 text-sm text-gray-600 dark:text-gray-400">{network.notes}</div> : null}
<div className="mt-3 flex flex-wrap gap-3 text-sm">
{network.explorerUrl ? (
<a href={network.explorerUrl} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">
Open network explorer
</a>
) : null}
<Link href={`/search?q=${encodeURIComponent(network.symbol)}`} className="text-primary-600 hover:underline">
Search symbol
</Link>
</div>
</div>
))}
</div>
</div>
</Card>
) : null}
<Card title="Top Holders">
<Table
columns={holderColumns}
data={holders}
emptyMessage="No holder data was available for this token."
keyExtractor={(holder) => holder.address}
/>
</Card>
<Card title="Recent Transfers">
{gruExplorerMetadata ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>
This token is tracked with GRU posture, so the transfer sample below can be read alongside its standards and transaction review guidance.
</span>
<Link href="/docs/gru" className="text-primary-600 hover:underline">
GRU guide
</Link>
<Link href="/docs/transaction-review" className="text-primary-600 hover:underline">
Transaction review guide
</Link>
</div>
) : null}
<Table
columns={transferColumns}
data={transfers}
emptyMessage="No recent token transfers were available."
keyExtractor={(transfer) => `${transfer.transaction_hash}-${transfer.value}-${transfer.from_address}`}
/>
</Card>
<Card title="Related Liquidity">
<Table
columns={poolColumns}
data={pools}
emptyMessage="No related liquidity pools were exposed through mission control for this token."
keyExtractor={(pool) => pool.address}
/>
</Card>
</div>
)}
</div>
)
}

View File

@@ -1,9 +1,13 @@
'use client'
import type { GetStaticProps } from 'next'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives'
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 { fetchPublicJson } from '@/utils/publicExplorer'
const quickSearches = [
{ label: 'cUSDT', description: 'Canonical bridged USDT liquidity and address results.' },
@@ -19,19 +23,63 @@ function normalizeAddress(value: string) {
return /^0x[a-fA-F0-9]{40}$/.test(trimmed) ? trimmed : ''
}
export default function TokensPage() {
interface TokensPageProps {
initialCuratedTokens: TokenListToken[]
}
export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
const router = useRouter()
const [query, setQuery] = useState('')
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>(initialCuratedTokens)
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault()
const normalized = normalizeAddress(query)
router.push(normalized ? `/addresses/${normalized}` : `/search?q=${encodeURIComponent(query.trim())}`)
router.push(normalized ? `/tokens/${normalized}` : `/search?q=${encodeURIComponent(query.trim())}`)
}
useEffect(() => {
if (initialCuratedTokens.length > 0) {
setCuratedTokens(initialCuratedTokens)
return
}
let active = true
tokensApi.listCuratedSafe(138).then(({ ok, data }) => {
if (active) {
setCuratedTokens(ok ? data : [])
}
}).catch(() => {
if (active) {
setCuratedTokens([])
}
})
return () => {
active = false
}
}, [initialCuratedTokens])
const featuredCuratedTokens = useMemo(() => {
const preferred = ['cUSDT', 'cUSDC', 'cXAUC', 'cXAUT', 'cEURT', 'USDT']
const selected = preferred
.map((symbol) => curatedTokens.find((token) => token.symbol === symbol))
.filter((token): token is TokenListToken => Boolean(token?.address))
return selected.length > 0 ? selected : curatedTokens.slice(0, 6)
}, [curatedTokens])
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Tokens</h1>
<PageIntro
eyebrow="Token Discovery"
title="Tokens"
description="Browse curated Chain 138 assets, open token contracts directly, and move into holders, transfers, liquidity, and provenance without pretending a search box is a complete token strategy."
actions={[
{ href: '/wallet', label: 'Wallet tools' },
{ href: '/liquidity', label: 'Liquidity access' },
{ href: '/search', label: 'Explorer search' },
]}
/>
<Card className="mb-6" title="Find A Token">
<form onSubmit={handleSubmit} className="flex flex-col gap-3 md:flex-row">
@@ -50,16 +98,19 @@ export default function TokensPage() {
Search
</button>
</form>
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
Contract addresses open dedicated token detail pages with holders, transfers, provenance, and liquidity context.
</p>
</Card>
<div className="grid gap-6 lg:grid-cols-3">
<Card title="Search Index">
<Card title="Curated Registry">
<p className="text-sm text-gray-600 dark:text-gray-400">
Search token symbols, contract addresses, transaction hashes, and block numbers from the explorer index.
Review listed Chain 138 assets with provenance tags such as compliant, wrapped, and bridge-aware before acting on a symbol match.
</p>
<div className="mt-4">
<Link href="/search" className="text-primary-600 hover:underline">
Open search
<Link href="/tokens" className="text-primary-600 hover:underline">
Browse curated tokens
</Link>
</div>
</Card>
@@ -85,6 +136,32 @@ export default function TokensPage() {
</Card>
</div>
<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>
))}
</div>
</Card>
</div>
<div className="mt-8">
<Card title="Common token searches">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@@ -104,3 +181,18 @@ export default function TokensPage() {
</div>
)
}
export const getStaticProps: GetStaticProps<TokensPageProps> = async () => {
const tokenListResult = await fetchPublicJson<{ tokens?: TokenListToken[] }>('/api/config/token-list').catch(() => null)
return {
props: {
initialCuratedTokens: Array.isArray(tokenListResult?.tokens)
? tokenListResult.tokens
.filter((token) => token.chainId === 138 && typeof token.address === 'string' && token.address.trim().length > 0)
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || right.name || ''))
: [],
},
revalidate: 300,
}
}

View File

@@ -2,37 +2,79 @@
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import { Card, Address, Table } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { transactionsApi, Transaction } from '@/services/api/transactions'
import { formatWeiAsEth } from '@/utils/format'
import {
transactionsApi,
Transaction,
TransactionInternalCall,
TransactionLookupDiagnostic,
TransactionTokenTransfer,
} from '@/services/api/transactions'
import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format'
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import PageIntro from '@/components/common/PageIntro'
import { getGruCatalogPosture } from '@/services/api/gruCatalog'
import { assessTransactionCompliance } from '@/utils/transactionCompliance'
function isValidTransactionHash(value: string) {
return /^0x[a-fA-F0-9]{64}$/.test(value)
}
export default function TransactionDetailPage() {
const router = useRouter()
const hash = typeof router.query.hash === 'string' ? router.query.hash : ''
const isValidHash = hash !== '' && isValidTransactionHash(hash)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [transaction, setTransaction] = useState<Transaction | null>(null)
const [internalCalls, setInternalCalls] = useState<TransactionInternalCall[]>([])
const [diagnostic, setDiagnostic] = useState<TransactionLookupDiagnostic | null>(null)
const [loading, setLoading] = useState(true)
const loadTransaction = useCallback(async () => {
setLoading(true)
try {
const { ok, data } = await transactionsApi.getSafe(chainId, hash)
const [{ ok, data }, internalResult] = await Promise.all([
transactionsApi.getSafe(chainId, hash),
transactionsApi.getInternalTransactionsSafe(hash),
])
if (!ok) {
setTransaction(null)
setInternalCalls([])
setDiagnostic(await transactionsApi.diagnoseMissing(chainId, hash))
return
}
setTransaction(data ?? null)
setInternalCalls(internalResult.ok ? internalResult.data : [])
setDiagnostic(null)
} catch (error) {
console.error('Failed to load transaction:', error)
setTransaction(null)
setInternalCalls([])
setDiagnostic(await transactionsApi.diagnoseMissing(chainId, hash))
} finally {
setLoading(false)
}
}, [chainId, hash])
const transactionNotFoundMessage = (() => {
if (!diagnostic) {
return 'Transaction not found.'
}
if (diagnostic.rpc_transaction_found && !diagnostic.rpc_receipt_found) {
return 'This hash was found on the Chain 138 public RPC, but it does not have a mined receipt yet and Blockscout has not indexed it.'
}
if (diagnostic.rpc_transaction_found && diagnostic.rpc_receipt_found && !diagnostic.explorer_indexed) {
return 'This hash exists on Chain 138, but Blockscout has not indexed it yet.'
}
return 'This hash was not found in Blockscout or in the Chain 138 public RPC, which usually means it belongs to a different network, was replaced, or never broadcast successfully.'
})()
useEffect(() => {
if (!router.isReady || !hash) {
setLoading(router.isReady ? false : true)
@@ -41,12 +83,129 @@ export default function TransactionDetailPage() {
}
return
}
if (!isValidHash) {
setLoading(false)
setTransaction(null)
return
}
loadTransaction()
}, [hash, loadTransaction, router.isReady])
}, [hash, isValidHash, loadTransaction, router.isReady])
const tokenTransferColumns = [
{
header: 'Token',
accessor: (transfer: TransactionTokenTransfer) => {
const gruPosture = getGruCatalogPosture({
symbol: transfer.token_symbol,
address: transfer.token_address,
})
return (
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium text-gray-900 dark:text-white">{transfer.token_symbol || transfer.token_name || 'Token'}</div>
{gruPosture?.isGru ? <EntityBadge label="GRU" tone="success" /> : null}
{gruPosture?.isX402Ready ? <EntityBadge label="x402 ready" tone="info" /> : null}
{gruPosture?.isWrappedTransport ? <EntityBadge label="wrapped" tone="warning" /> : null}
</div>
{transfer.token_address && (
<Link href={`/tokens/${transfer.token_address}`} className="text-primary-600 hover:underline">
<Address address={transfer.token_address} truncate showCopy={false} />
</Link>
)}
</div>
)},
},
{
header: 'From',
accessor: (transfer: TransactionTokenTransfer) => (
<Link href={`/addresses/${transfer.from_address}`} className="text-primary-600 hover:underline">
{transfer.from_label || <Address address={transfer.from_address} truncate showCopy={false} />}
</Link>
),
},
{
header: 'To',
accessor: (transfer: TransactionTokenTransfer) => (
<Link href={`/addresses/${transfer.to_address}`} className="text-primary-600 hover:underline">
{transfer.to_label || <Address address={transfer.to_address} truncate showCopy={false} />}
</Link>
),
},
{
header: 'Amount',
accessor: (transfer: TransactionTokenTransfer) => (
formatTokenAmount(transfer.amount, transfer.token_decimals, transfer.token_symbol)
),
},
]
const internalCallColumns = [
{
header: 'Type',
accessor: (call: TransactionInternalCall) => call.type || 'call',
},
{
header: 'From',
accessor: (call: TransactionInternalCall) => (
<Link href={`/addresses/${call.from_address}`} className="text-primary-600 hover:underline">
{call.from_label || <Address address={call.from_address} truncate showCopy={false} />}
</Link>
),
},
{
header: 'To',
accessor: (call: TransactionInternalCall) => {
const targetAddress = call.contract_address || call.to_address
const targetLabel = call.contract_label || call.to_label
if (!targetAddress) {
return <span className="text-gray-500">Unknown</span>
}
return (
<Link href={`/addresses/${targetAddress}`} className="text-primary-600 hover:underline">
{targetLabel || <Address address={targetAddress} truncate showCopy={false} />}
</Link>
)
},
},
{
header: 'Value',
accessor: (call: TransactionInternalCall) => formatWeiAsEth(call.value),
},
{
header: 'Status',
accessor: (call: TransactionInternalCall) => (
<span className={call.success === false ? 'text-red-600' : 'text-green-600'}>
{call.success === false ? (call.error || 'Failed') : 'Success'}
</span>
),
},
]
const gasUtilization = transaction?.gas_used != null && transaction.gas_limit > 0
? Math.round((transaction.gas_used / transaction.gas_limit) * 100)
: null
const tokenTransferCount = transaction?.token_transfers?.length || 0
const internalCallCount = internalCalls.length
const complianceAssessment = transaction
? assessTransactionCompliance({
transaction,
internalCalls,
tokenTransfers: transaction.token_transfers || [],
})
: null
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-6 text-3xl font-bold">Transaction</h1>
<PageIntro
eyebrow="Transaction Detail"
title="Transaction"
description="Inspect a single transaction and pivot into its block, counterparties, or a broader explorer search when you need more context."
actions={[
{ href: '/transactions', label: 'All transactions' },
{ href: '/blocks', label: 'Recent blocks' },
{ href: '/search', label: 'Search explorer' },
]}
/>
<div className="mb-6 flex flex-wrap gap-3 text-sm">
<Link href="/transactions" className="text-primary-600 hover:underline">
@@ -63,56 +222,232 @@ export default function TransactionDetailPage() {
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Loading transaction...</p>
</Card>
) : !isValidHash ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Invalid transaction hash. Please use a full 66-character 0x-prefixed hash.</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/transactions" className="text-primary-600 hover:underline">
Back to transactions
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search the explorer
</Link>
</div>
</Card>
) : !transaction ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Transaction not found.</p>
<p className="text-sm text-gray-600 dark:text-gray-400">{transactionNotFoundMessage}</p>
{diagnostic && (
<dl className="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<DetailRow label="Checked hash">{diagnostic.checked_hash}</DetailRow>
{diagnostic.latest_block_number != null && (
<DetailRow label="Latest Chain 138 block">#{diagnostic.latest_block_number}</DetailRow>
)}
<DetailRow label="Blockscout indexed">{diagnostic.explorer_indexed ? 'Yes' : 'No'}</DetailRow>
<DetailRow label="Public RPC transaction">{diagnostic.rpc_transaction_found ? 'Yes' : 'No'}</DetailRow>
<DetailRow label="Public RPC receipt">{diagnostic.rpc_receipt_found ? 'Yes' : 'No'}</DetailRow>
</dl>
)}
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/transactions" className="text-primary-600 hover:underline">
Browse recent transactions
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search the explorer
</Link>
</div>
</Card>
) : (
<Card title="Transaction Information">
<dl className="space-y-4">
<DetailRow label="Hash">
<Address address={transaction.hash} />
</DetailRow>
<DetailRow label="Block">
<Link href={`/blocks/${transaction.block_number}`} className="text-primary-600 hover:underline">
#{transaction.block_number}
</Link>
</DetailRow>
<DetailRow label="From">
<Link href={`/addresses/${transaction.from_address}`} className="text-primary-600 hover:underline">
<Address address={transaction.from_address} truncate showCopy={false} />
</Link>
</DetailRow>
{transaction.to_address && (
<DetailRow label="To">
<Link href={`/addresses/${transaction.to_address}`} className="text-primary-600 hover:underline">
<Address address={transaction.to_address} truncate showCopy={false} />
<div className="space-y-6">
<Card title="Execution Summary">
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
<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">Outcome</div>
<div className="mt-3 flex flex-wrap gap-2">
<EntityBadge
label={transaction.status === 1 ? 'success' : 'failed'}
tone={transaction.status === 1 ? 'success' : 'warning'}
/>
{transaction.contract_address ? <EntityBadge label="contract creation" tone="warning" /> : null}
{transaction.method ? <EntityBadge label={transaction.method} tone="info" /> : null}
</div>
</div>
<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>Gas used: {transaction.gas_used != null ? transaction.gas_used.toLocaleString() : 'Unavailable'}</div>
<div>Utilization: {gasUtilization != null ? `${gasUtilization}%` : '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">
<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>Token transfers: {tokenTransferCount.toLocaleString()}</div>
<div>Internal calls: {internalCallCount.toLocaleString()}</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">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Navigation</div>
<div className="mt-3 flex flex-wrap gap-3 text-sm">
<Link href={`/blocks/${transaction.block_number}`} className="text-primary-600 hover:underline">
Open block
</Link>
<Link href={`/addresses/${transaction.from_address}`} className="text-primary-600 hover:underline">
Open sender
</Link>
{transaction.contract_address ? (
<Link href={`/addresses/${transaction.contract_address}`} className="text-primary-600 hover:underline">
Open created contract
</Link>
) : null}
</div>
</div>
</div>
</Card>
{complianceAssessment ? (
<Card title="Transaction Evidence Matrix">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<EntityBadge label={`band ${complianceAssessment.grade}`} tone={complianceAssessment.score >= 80 ? 'success' : complianceAssessment.score >= 70 ? 'info' : 'warning'} />
<EntityBadge label={`score ${complianceAssessment.score}/100`} tone="info" />
<Link href="/docs/transaction-review" className="text-sm text-primary-600 hover:underline">
Scoring guide
</Link>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{complianceAssessment.summary}</p>
<div className="grid gap-3 lg:grid-cols-2">
{complianceAssessment.factors.map((factor) => (
<div key={factor.label} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-white">{factor.label}</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{factor.score}/{factor.maxScore}
</div>
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">{factor.summary}</div>
</div>
))}
</div>
</div>
</Card>
) : null}
<Card title="Transaction Information">
<dl className="space-y-4">
<DetailRow label="Hash">
<Address address={transaction.hash} />
</DetailRow>
<DetailRow label="Status">
<span className={transaction.status === 1 ? 'text-green-600' : 'text-red-600'}>
{transaction.status === 1 ? 'Success' : 'Failed'}
</span>
</DetailRow>
{transaction.method && (
<DetailRow label="Method">
<code className="rounded bg-gray-100 px-2 py-1 text-xs dark:bg-gray-900">{transaction.method}</code>
</DetailRow>
)}
<DetailRow label="Block">
<Link href={`/blocks/${transaction.block_number}`} className="text-primary-600 hover:underline">
#{transaction.block_number}
</Link>
</DetailRow>
)}
<DetailRow label="Value">{formatWeiAsEth(transaction.value)}</DetailRow>
<DetailRow label="Gas Price">
{transaction.gas_price != null ? `${transaction.gas_price / 1e9} Gwei` : 'N/A'}
</DetailRow>
{transaction.gas_used != null && (
<DetailRow label="Gas Used">
{transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()}
</DetailRow>
)}
<DetailRow label="Status">
<span className={transaction.status === 1 ? 'text-green-600' : 'text-red-600'}>
{transaction.status === 1 ? 'Success' : 'Failed'}
</span>
</DetailRow>
{transaction.contract_address && (
<DetailRow label="Contract Created">
<Link href={`/addresses/${transaction.contract_address}`} className="text-primary-600 hover:underline">
<Address address={transaction.contract_address} truncate showCopy={false} />
<DetailRow label="Timestamp">{formatTimestamp(transaction.created_at)}</DetailRow>
<DetailRow label="From">
<Link href={`/addresses/${transaction.from_address}`} className="text-primary-600 hover:underline">
<Address address={transaction.from_address} truncate showCopy={false} />
</Link>
</DetailRow>
)}
</dl>
</Card>
{transaction.to_address && (
<DetailRow label="To">
<Link href={`/addresses/${transaction.to_address}`} className="text-primary-600 hover:underline">
<Address address={transaction.to_address} truncate showCopy={false} />
</Link>
</DetailRow>
)}
<DetailRow label="Value">{formatWeiAsEth(transaction.value)}</DetailRow>
{transaction.fee && <DetailRow label="Fee">{formatWeiAsEth(transaction.fee)}</DetailRow>}
<DetailRow label="Gas Price">
{transaction.gas_price != null ? `${transaction.gas_price / 1e9} Gwei` : 'N/A'}
</DetailRow>
{transaction.gas_used != null && (
<DetailRow label="Gas Used">
{transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()}
</DetailRow>
)}
{transaction.revert_reason && (
<DetailRow label="Revert Reason">
<span className="text-red-600">{transaction.revert_reason}</span>
</DetailRow>
)}
{transaction.contract_address && (
<DetailRow label="Contract Created">
<Link href={`/addresses/${transaction.contract_address}`} className="text-primary-600 hover:underline">
<Address address={transaction.contract_address} truncate showCopy={false} />
</Link>
</DetailRow>
)}
</dl>
</Card>
{transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (
<Card title="Decoded Input">
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
{transaction.decoded_input.method_call || transaction.decoded_input.method_id || 'Decoded call'}
</p>
<dl className="space-y-3">
{transaction.decoded_input.parameters.map((parameter, index) => (
<DetailRow
key={`${parameter.name || parameter.type || 'parameter'}-${index}`}
label={parameter.name || `Param ${index + 1}`}
>
<div className="space-y-1">
{parameter.type && (
<div className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{parameter.type}</div>
)}
<pre className="overflow-x-auto whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-xs text-gray-800 dark:bg-gray-950 dark:text-gray-200">
{typeof parameter.value === 'string'
? parameter.value
: JSON.stringify(parameter.value, null, 2)}
</pre>
</div>
</DetailRow>
))}
</dl>
</div>
</Card>
)}
<Card title="Token Transfers">
<Table
columns={tokenTransferColumns}
data={transaction.token_transfers || []}
emptyMessage="No token transfers were indexed for this transaction."
keyExtractor={(transfer) => `${transfer.token_address}-${transfer.from_address}-${transfer.to_address}-${transfer.amount}`}
/>
</Card>
<Card title="Internal Transactions">
<Table
columns={internalCallColumns}
data={internalCalls}
emptyMessage="No internal transactions were exposed for this transaction."
keyExtractor={(call) => `${call.from_address}-${call.to_address || call.contract_address || 'unknown'}-${call.value}-${call.type || 'call'}`}
/>
</Card>
{transaction.input_data && (
<Card title="Raw Input Data">
<pre className="overflow-x-auto whitespace-pre-wrap break-all rounded-lg bg-gray-50 p-4 text-xs text-gray-800 dark:bg-gray-950 dark:text-gray-200">
{transaction.input_data}
</pre>
</Card>
)}
</div>
)}
</div>
)

View File

@@ -1,15 +1,42 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { GetServerSideProps } from 'next'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { transactionsApi, Transaction } from '@/services/api/transactions'
import { formatWeiAsEth } from '@/utils/format'
import EntityBadge from '@/components/common/EntityBadge'
import PageIntro from '@/components/common/PageIntro'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeTransaction } from '@/services/api/blockscout'
export default function TransactionsPage() {
interface TransactionsPageProps {
initialTransactions: Transaction[]
}
function serializeTransactionList(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,
value: transaction.value,
status: transaction.status ?? null,
contract_address: transaction.contract_address ?? null,
fee: transaction.fee ?? null,
token_transfers: Array.isArray(transaction.token_transfers)
? transaction.token_transfers.map((transfer) => ({ token_address: transfer.token_address }))
: [],
})),
),
) as Transaction[]
}
export default function TransactionsPage({ initialTransactions }: TransactionsPageProps) {
const pageSize = 20
const [transactions, setTransactions] = useState<Transaction[]>([])
const [loading, setLoading] = useState(true)
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
const [loading, setLoading] = useState(initialTransactions.length === 0)
const [page, setPage] = useState(1)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
@@ -27,8 +54,51 @@ export default function TransactionsPage() {
}, [chainId, page, pageSize])
useEffect(() => {
loadTransactions()
}, [loadTransactions])
if (page === 1 && initialTransactions.length > 0) {
setTransactions(initialTransactions)
setLoading(false)
return
}
void loadTransactions()
}, [initialTransactions, loadTransactions, page])
const transactionSummary = useMemo(() => {
const sampleSize = transactions.length
if (sampleSize === 0) {
return {
sampleSize: 0,
successRate: 0,
contractCreations: 0,
tokenTransferTransactions: 0,
averageFee: null as string | null,
}
}
const successes = transactions.filter((transaction) => transaction.status === 1).length
const contractCreations = transactions.filter((transaction) => Boolean(transaction.contract_address)).length
const tokenTransferTransactions = transactions.filter(
(transaction) => (transaction.token_transfers?.length || 0) > 0,
).length
const feeValues = transactions
.map((transaction) => {
if (!transaction.fee) return null
const numeric = Number(transaction.fee)
return Number.isFinite(numeric) ? numeric : null
})
.filter((value): value is number => value != null)
const averageFee =
feeValues.length > 0
? formatWeiAsEth(Math.round(feeValues.reduce((sum, value) => sum + value, 0) / feeValues.length).toString(), 6)
: null
return {
sampleSize,
successRate: Math.round((successes / sampleSize) * 100),
contractCreations,
tokenTransferTransactions,
averageFee,
}
}, [transactions])
const showPagination = page > 1 || transactions.length > 0
const canGoNext = transactions.length === pageSize
@@ -82,7 +152,46 @@ export default function TransactionsPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-6 text-3xl font-bold">Transactions</h1>
<PageIntro
eyebrow="Indexed Flow"
title="Transactions"
description="Review recent Chain 138 transactions and move directly into the linked block, address, search, and watchlist surfaces from here."
actions={[
{ href: '/blocks', label: 'Open blocks' },
{ href: '/addresses', label: 'Browse addresses' },
{ href: '/watchlist', label: 'Open watchlist' },
]}
/>
{!loading && transactions.length > 0 && (
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Sample Size</div>
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.sampleSize.toLocaleString()}</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">Transactions on the current explorer page.</div>
</Card>
<Card>
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Success Rate</div>
<div className="mt-2 flex items-center gap-2">
<span className="text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.successRate}%</span>
<EntityBadge label={transactionSummary.successRate >= 90 ? 'healthy' : 'mixed'} tone={transactionSummary.successRate >= 90 ? 'success' : 'warning'} />
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">Based on the visible recent transaction sample.</div>
</Card>
<Card>
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Contract Creations</div>
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.contractCreations.toLocaleString()}</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">New contracts created in the visible sample.</div>
</Card>
<Card>
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Avg Sample Fee</div>
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.averageFee || 'Unavailable'}</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Token-transfer txs: {transactionSummary.tokenTransferTransactions.toLocaleString()}
</div>
</Card>
</div>
)}
{loading ? (
<Card>
@@ -116,6 +225,39 @@ export default function TransactionsPage() {
</button>
</div>
)}
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<Card title="Next Steps">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Use the linked hashes above to inspect detail pages, or pivot into block production, address activity, and explorer-wide search.
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/blocks" className="text-primary-600 hover:underline">
Blocks
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Addresses
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Search
</Link>
</div>
</Card>
</div>
</div>
)
}
export const getServerSideProps: GetServerSideProps<TransactionsPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null)
const initialTransactions = Array.isArray(transactionsResult?.items)
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
: []
return {
props: {
initialTransactions: serializeTransactionList(initialTransactions),
},
}
}

View File

@@ -0,0 +1,41 @@
import type { GetServerSideProps } from 'next'
import WalletPage from '@/components/wallet/WalletPage'
import type {
CapabilitiesCatalog,
FetchMetadata,
NetworksCatalog,
TokenListCatalog,
} from '@/components/wallet/AddToMetaMask'
import { fetchPublicJsonWithMeta } from '@/utils/publicExplorer'
interface WalletRoutePageProps {
initialNetworks: NetworksCatalog | null
initialTokenList: TokenListCatalog | null
initialCapabilities: CapabilitiesCatalog | null
initialNetworksMeta: FetchMetadata | null
initialTokenListMeta: FetchMetadata | null
initialCapabilitiesMeta: FetchMetadata | null
}
export default function WalletRoutePage(props: WalletRoutePageProps) {
return <WalletPage {...props} />
}
export const getServerSideProps: GetServerSideProps<WalletRoutePageProps> = async () => {
const [networksResult, tokenListResult, capabilitiesResult] = await Promise.all([
fetchPublicJsonWithMeta<NetworksCatalog>('/api/config/networks').catch(() => null),
fetchPublicJsonWithMeta<TokenListCatalog>('/api/config/token-list').catch(() => null),
fetchPublicJsonWithMeta<CapabilitiesCatalog>('/api/config/capabilities').catch(() => null),
])
return {
props: {
initialNetworks: networksResult?.data || null,
initialTokenList: tokenListResult?.data || null,
initialCapabilities: capabilitiesResult?.data || null,
initialNetworksMeta: networksResult?.meta || null,
initialTokenListMeta: tokenListResult?.meta || null,
initialCapabilitiesMeta: capabilitiesResult?.meta || null,
},
}
}

View File

@@ -8,6 +8,7 @@ import {
writeWatchlistToStorage,
sanitizeWatchlistEntries,
} from '@/utils/watchlist'
import PageIntro from '@/components/common/PageIntro'
export default function WatchlistPage() {
const [entries, setEntries] = useState<string[]>([])
@@ -67,7 +68,16 @@ export default function WatchlistPage() {
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-6 text-3xl font-bold">Watchlist</h1>
<PageIntro
eyebrow="Saved Shortcuts"
title="Watchlist"
description="Keep frequently referenced Chain 138 addresses close at hand, then move back into address detail, search, or exported team handoff files from one place."
actions={[
{ href: '/addresses', label: 'Browse addresses' },
{ href: '/search', label: 'Search explorer' },
{ href: '/transactions', label: 'Recent transactions' },
]}
/>
<Card title="Saved Addresses">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">

View File

@@ -1,9 +1,33 @@
import dynamic from 'next/dynamic'
import type { GetStaticProps } from 'next'
import WethOperationsPage from '@/components/explorer/WethOperationsPage'
import { fetchPublicJson } from '@/utils/publicExplorer'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import type { InternalExecutionPlanResponse, PlannerCapabilitiesResponse } from '@/services/api/planner'
const WethOperationsPage = dynamic(() => import('@/components/explorer/WethOperationsPage'), {
ssr: false,
})
export default function WethPage() {
return <WethOperationsPage />
interface WethPageProps {
initialBridgeStatus: MissionControlBridgeStatusResponse | null
initialPlannerCapabilities: PlannerCapabilitiesResponse | null
initialInternalPlan: InternalExecutionPlanResponse | null
}
export default function WethPage(props: WethPageProps) {
return <WethOperationsPage {...props} />
}
export const getStaticProps: GetStaticProps<WethPageProps> = async () => {
const [bridgeStatus, plannerCapabilities] = await Promise.all([
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
fetchPublicJson<PlannerCapabilitiesResponse>('/token-aggregation/api/v2/providers/capabilities?chainId=138').catch(
() => null,
),
])
return {
props: {
initialBridgeStatus: bridgeStatus,
initialPlannerCapabilities: plannerCapabilities,
initialInternalPlan: null,
},
revalidate: 60,
}
}