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 bdae5a9f6e
commit f46bd213ba
160 changed files with 13274 additions and 1061 deletions

View File

@@ -1,13 +0,0 @@
import './globals.css'
import type { ReactNode } from 'react'
import ExplorerChrome from '@/components/common/ExplorerChrome'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<ExplorerChrome>{children}</ExplorerChrome>
</body>
</html>
)
}

View File

@@ -1,5 +0,0 @@
import LiquidityOperationsPage from '@/components/explorer/LiquidityOperationsPage'
export default function LiquidityPage() {
return <LiquidityOperationsPage />
}

View File

@@ -1,234 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives/Card'
import Link from 'next/link'
import { blocksApi, type Block } from '@/services/api/blocks'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
import {
missionControlApi,
type MissionControlRelaySummary,
} from '@/services/api/missionControl'
import { loadDashboardData } from '@/utils/dashboard'
type HomeStats = ExplorerStats
export default function Home() {
const [stats, setStats] = useState<HomeStats | null>(null)
const [recentBlocks, setRecentBlocks] = useState<Block[]>([])
const [relaySummary, setRelaySummary] = useState<MissionControlRelaySummary | null>(null)
const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>('connecting')
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const latestBlock = stats?.latest_block ?? recentBlocks[0]?.number ?? null
const loadDashboard = useCallback(async () => {
const dashboardData = await loadDashboardData({
loadStats: () => statsApi.get(),
loadRecentBlocks: async () => {
const response = await blocksApi.list({
chain_id: chainId,
page: 1,
page_size: 10,
})
return response.data
},
onError: (scope, error) => {
if (process.env.NODE_ENV !== 'production') {
console.warn(`Failed to load dashboard ${scope}:`, error)
}
},
})
setStats(dashboardData.stats)
setRecentBlocks(dashboardData.recentBlocks)
}, [chainId])
useEffect(() => {
loadDashboard()
}, [loadDashboard])
useEffect(() => {
let cancelled = false
const loadSnapshot = async () => {
try {
const summary = await missionControlApi.getRelaySummary()
if (!cancelled) {
setRelaySummary(summary)
}
} catch (error) {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to load mission control relay summary:', error)
}
}
}
loadSnapshot()
const unsubscribe = missionControlApi.subscribeRelaySummary(
(summary) => {
if (!cancelled) {
setRelaySummary(summary)
setRelayFeedState('live')
}
},
(error) => {
if (!cancelled) {
setRelayFeedState('fallback')
}
if (process.env.NODE_ENV !== 'production') {
console.warn('Mission control live stream update issue:', error)
}
}
)
return () => {
cancelled = true
unsubscribe()
}
}, [])
const relayToneClasses =
relaySummary?.tone === 'danger'
? 'border-red-200 bg-red-50 text-red-900 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100'
: relaySummary?.tone === 'warning'
? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100'
: 'border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-100'
return (
<main className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-6 sm:mb-8">
<h1 className="mb-2 text-3xl font-bold sm:text-4xl">SolaceScanScout</h1>
<p className="text-base text-gray-600 dark:text-gray-400 sm:text-lg">The Defi Oracle Meta Explorer</p>
</div>
{relaySummary && (
<Card className={`mb-6 border shadow-sm ${relayToneClasses}`}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-semibold uppercase tracking-wide opacity-80">Mission Control</div>
<div className="mt-1 text-sm sm:text-base">{relaySummary.text}</div>
<div className="mt-2 text-xs font-medium uppercase tracking-wide opacity-75">
Feed: {relayFeedState === 'live' ? 'Live SSE' : relayFeedState === 'fallback' ? 'Snapshot fallback' : 'Connecting'}
</div>
{relaySummary.items.length > 1 && (
<div className="mt-3 space-y-1 text-sm opacity-90">
{relaySummary.items.map((item) => (
<div key={item.key}>{item.text}</div>
))}
</div>
)}
</div>
<Link href="/explorer-api/v1/mission-control/stream" className="text-sm font-semibold underline-offset-4 hover:underline">
Open live stream
</Link>
</div>
</Card>
)}
{stats && (
<div className="mb-8 grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Latest Block</div>
<div className="text-xl font-bold sm:text-2xl">
{latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'}
</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Blocks</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_blocks.toLocaleString()}</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Transactions</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_transactions.toLocaleString()}</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Addresses</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_addresses.toLocaleString()}</div>
</Card>
</div>
)}
{!stats && (
<Card className="mb-8">
<p className="text-sm text-gray-600 dark:text-gray-400">
Live network stats are temporarily unavailable. Recent blocks and explorer tools are still available below.
</p>
</Card>
)}
<Card title="Recent Blocks">
{recentBlocks.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent blocks are unavailable right now.
</p>
) : (
<div className="space-y-2">
{recentBlocks.map((block) => (
<div key={block.number} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<Link href={`/blocks/${block.number}`} className="text-primary-600 hover:underline">
Block #{block.number}
</Link>
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
{block.transaction_count} transactions
</div>
</div>
))}
</div>
)}
<div className="mt-4">
<Link href="/blocks" className="text-primary-600 hover:underline">
View all blocks
</Link>
</div>
</Card>
<div className="mt-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="Liquidity & Routes">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Explore the public Chain 138 DODO PMM liquidity mesh, the canonical route matrix, and the
partner payload endpoints exposed through the explorer.
</p>
<div className="mt-4">
<Link href="/routes" className="text-primary-600 hover:underline">
Open routes and liquidity
</Link>
</div>
</Card>
<Card title="Wallet & Token Discovery">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Add Chain 138, Ethereum Mainnet, and ALL Mainnet to MetaMask, then use the explorer token
list URL so supported tokens appear automatically.
</p>
<div className="mt-4">
<Link href="/wallet" className="text-primary-600 hover:underline">
Open wallet tools
</Link>
</div>
</Card>
<Card title="Bridge & Relay Monitoring">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Open the public bridge monitoring surface for relay status, mission-control links, bridge trace tooling,
and the visual command center entry points.
</p>
<div className="mt-4">
<Link href="/bridge" className="text-primary-600 hover:underline">
Open bridge monitoring
</Link>
</div>
</Card>
<Card title="More Explorer Tools">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Surface the restored WETH utilities, analytics shortcuts, operator links, system topology views, and
other public tools that were previously hidden in the legacy explorer shell.
</p>
<div className="mt-4">
<Link href="/more" className="text-primary-600 hover:underline">
Open operations hub
</Link>
</div>
</Card>
</div>
</main>
)
}

View File

@@ -0,0 +1,873 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
import EntityBadge from '@/components/common/EntityBadge'
import {
accessApi,
type AccessAPIKeyRecord,
type AccessAuditEntry,
type AccessProduct,
type AccessSubscription,
type AccessUsageSummary,
type AccessUser,
type WalletAccessSession,
} from '@/services/api/access'
const ACCESS_SCOPE_OPTIONS = ['rpc:read', 'rpc:write', 'rpc:admin'] as const
const OPERATOR_IDENTITIES = [
{
slug: 'thirdweb-rpc',
label: 'ThirdWeb',
vmid: 2103,
address: '0xB2dEA0e264ddfFf91057A3415112e57A1a5Eac14',
},
{
slug: 'alltra-rpc',
label: 'Alltra/HYBX',
vmid: 2102,
address: '0xaf6e3444AEaf7855cf41b557C94A96dc7fcF49C1',
},
{
slug: 'core-rpc',
label: 'DBIS',
vmid: 2101,
address: '0x4A666F96fC8764181194447A7dFdb7d471b301C8',
},
] as const
function Field({
label,
value,
onChange,
type = 'text',
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
}) {
return (
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</span>
<input
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
</label>
)
}
export default function AccessManagementPage() {
const [products, setProducts] = useState<AccessProduct[]>([])
const [user, setUser] = useState<AccessUser | null>(null)
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
const [connectingWallet, setConnectingWallet] = useState(false)
const [apiKeys, setApiKeys] = useState<AccessAPIKeyRecord[]>([])
const [subscriptions, setSubscriptions] = useState<AccessSubscription[]>([])
const [usage, setUsage] = useState<AccessUsageSummary[]>([])
const [auditEntries, setAuditEntries] = useState<AccessAuditEntry[]>([])
const [adminSubscriptions, setAdminSubscriptions] = useState<AccessSubscription[]>([])
const [adminAuditEntries, setAdminAuditEntries] = useState<AccessAuditEntry[]>([])
const [auditLimit, setAuditLimit] = useState('20')
const [adminAuditLimit, setAdminAuditLimit] = useState('50')
const [adminSubscriptionStatus, setAdminSubscriptionStatus] = useState('pending')
const [adminAuditProduct, setAdminAuditProduct] = useState('')
const [adminActionNotes, setAdminActionNotes] = useState<Record<string, string>>({})
const [email, setEmail] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [apiKeyName, setAPIKeyName] = useState('Core RPC key')
const [apiKeyTier, setAPIKeyTier] = useState('pro')
const [apiKeyProduct, setAPIKeyProduct] = useState('thirdweb-rpc')
const [apiKeyExpiresDays, setAPIKeyExpiresDays] = useState('30')
const [apiKeyMonthlyQuota, setAPIKeyMonthlyQuota] = useState('')
const [apiKeyScopes, setAPIKeyScopes] = useState<string[]>(['rpc:read', 'rpc:write'])
const [createdKey, setCreatedKey] = useState('')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const clearSessionState = useCallback(() => {
setUser(null)
setApiKeys([])
setSubscriptions([])
setUsage([])
setAuditEntries([])
setAdminSubscriptions([])
setAdminAuditEntries([])
}, [])
const syncWalletSession = useCallback(() => {
setWalletSession(accessApi.getStoredWalletSession())
}, [])
const loadAdminData = useCallback(async (
isAdmin: boolean,
nextSubscriptionStatus = adminSubscriptionStatus,
nextAuditProduct = adminAuditProduct,
nextAuditLimit = Number(adminAuditLimit),
) => {
if (!isAdmin) {
setAdminSubscriptions([])
setAdminAuditEntries([])
return
}
const [adminResponse, adminAuditResponse] = await Promise.all([
accessApi.listAdminSubscriptions(nextSubscriptionStatus).catch(() => ({ subscriptions: [] })),
accessApi.listAdminAudit(nextAuditLimit, nextAuditProduct).catch(() => ({ entries: [] })),
])
setAdminSubscriptions(adminResponse.subscriptions || [])
setAdminAuditEntries(adminAuditResponse.entries || [])
}, [adminAuditLimit, adminAuditProduct, adminSubscriptionStatus])
const loadSignedInData = useCallback(async () => {
const [me, keys, usageResponse, auditResponse] = await Promise.all([
accessApi.getMe(),
accessApi.listAPIKeys(),
accessApi.getUsage().catch(() => ({ usage: [] })),
accessApi.listAudit(Number(auditLimit)).catch(() => ({ entries: [] })),
])
setUser(me.user)
setSubscriptions(me.subscriptions || [])
setApiKeys(keys.api_keys || [])
setUsage(usageResponse.usage || [])
setAuditEntries(auditResponse.entries || [])
await loadAdminData(Boolean(me.user?.is_admin))
}, [auditLimit, loadAdminData])
const loadAccessData = useCallback(async () => {
const productResponse = await accessApi.listProducts()
setProducts(productResponse.products || [])
syncWalletSession()
const token = accessApi.getStoredAccessToken()
if (!token) {
clearSessionState()
return
}
try {
await loadSignedInData()
} catch {
accessApi.clearSession()
clearSessionState()
}
}, [clearSessionState, loadSignedInData, syncWalletSession])
useEffect(() => {
void loadAccessData()
}, [loadAccessData])
useEffect(() => {
syncWalletSession()
window.addEventListener('storage', syncWalletSession)
window.addEventListener('explorer-access-session-changed', syncWalletSession)
return () => {
window.removeEventListener('storage', syncWalletSession)
window.removeEventListener('explorer-access-session-changed', syncWalletSession)
}
}, [syncWalletSession])
useEffect(() => {
if (!user) return
void accessApi
.listAudit(Number(auditLimit))
.then((response) => setAuditEntries(response.entries || []))
.catch(() => {})
}, [auditLimit, user])
useEffect(() => {
if (!user?.is_admin) return
void loadAdminData(true)
}, [adminSubscriptionStatus, adminAuditLimit, adminAuditProduct, loadAdminData, user?.is_admin])
useEffect(() => {
if (apiKeyProduct === 'core-rpc') {
setAPIKeyScopes((current) =>
current.includes('rpc:admin') ? current : [...current, 'rpc:admin'],
)
} else {
setAPIKeyScopes((current) => current.filter((scope) => scope !== 'rpc:admin'))
}
}, [apiKeyProduct])
const handleRegister = async (event: FormEvent) => {
event.preventDefault()
setError('')
setMessage('')
try {
const response = await accessApi.register(email, username, password)
setUser(response.user)
setMessage('Account created. You can now issue API keys for managed RPC access.')
await loadSignedInData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed')
}
}
const handleLogin = async (event: FormEvent) => {
event.preventDefault()
setError('')
setMessage('')
try {
const response = await accessApi.login(email, password)
setUser(response.user)
await loadSignedInData()
setMessage('Signed in successfully.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
}
}
const handleCreateAPIKey = async (event: FormEvent) => {
event.preventDefault()
setError('')
setMessage('')
setCreatedKey('')
try {
const response = await accessApi.createAPIKey({
name: apiKeyName,
tier: apiKeyTier,
productSlug: apiKeyProduct,
expiresDays: apiKeyExpiresDays === 'never' ? 0 : Number(apiKeyExpiresDays || 0),
monthlyQuota: apiKeyMonthlyQuota.trim() ? Number(apiKeyMonthlyQuota) : undefined,
scopes: apiKeyScopes,
})
setCreatedKey(response.api_key)
setMessage('API key created. This is the only time the plaintext key will be shown.')
await loadSignedInData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create API key')
}
}
const handleRotate = async (key: AccessAPIKeyRecord) => {
setError('')
setMessage('')
setCreatedKey('')
try {
const response = await accessApi.createAPIKey({
name: key.name.replace(/\s+\[[^\]]+\]$/, ''),
tier: key.tier,
productSlug: key.productSlug,
monthlyQuota: key.monthlyQuota,
scopes: key.scopes,
})
await accessApi.revokeAPIKey(key.id)
setCreatedKey(response.api_key)
setMessage('API key rotated. The old key has been revoked and the new plaintext key is shown below once.')
await loadSignedInData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to rotate API key')
}
}
const handleRevoke = async (id: string) => {
setError('')
setMessage('')
try {
await accessApi.revokeAPIKey(id)
setMessage('API key revoked.')
await loadSignedInData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to revoke API key')
}
}
const handleSignOut = () => {
accessApi.clearSession()
clearSessionState()
setCreatedKey('')
setMessage('Signed out.')
}
const handleWalletConnect = async () => {
setError('')
setMessage('')
try {
setConnectingWallet(true)
const session = await accessApi.connectWalletSession()
setWalletSession(session)
await loadSignedInData()
setMessage('Wallet connected. Account sign-in is active and authenticated explorer access is now available.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Wallet connection failed')
} finally {
setConnectingWallet(false)
}
}
const handleWalletDisconnect = () => {
accessApi.clearWalletSession()
syncWalletSession()
clearSessionState()
setCreatedKey('')
setMessage('Wallet session disconnected.')
}
const handleRequestSubscription = async (productSlug: string, tier: string) => {
setError('')
setMessage('')
try {
await accessApi.requestSubscription(productSlug, tier)
await loadSignedInData()
setMessage('Access request saved.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to request access')
}
}
const handleAdminSubscriptionAction = async (subscriptionId: string, status: string) => {
setError('')
setMessage('')
try {
await accessApi.updateAdminSubscription(subscriptionId, status, adminActionNotes[subscriptionId] || '')
await loadSignedInData()
setAdminActionNotes((current) => ({ ...current, [subscriptionId]: '' }))
setMessage(`Subscription ${status === 'active' ? 'approved' : status}.`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update subscription')
}
}
const getSubscriptionForProduct = (productSlug: string) =>
subscriptions.find((subscription) => subscription.productSlug === productSlug)
const handleScopeToggle = (scope: string) => {
setAPIKeyScopes((current) =>
current.includes(scope) ? current.filter((entry) => entry !== scope) : [...current, scope],
)
}
const handleAdminAuditProductChange = async (value: string) => {
setAdminAuditProduct(value)
}
const getOperatorIdentity = (productSlug: string) =>
OPERATOR_IDENTITIES.find((entry) => entry.slug === productSlug)
return (
<main className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
eyebrow="Access Control"
title="Wallet Login, RPC Access & API Tokens"
description="Connect a wallet for standard account sign-in, manage authenticated access, issue API keys, and prepare subscription-gated RPC products for DBIS, ThirdWeb, and Alltra."
actions={[
{ href: '/wallet', label: 'Wallet tools' },
{ href: '/system', label: 'System status' },
{ href: '/search', label: 'Search explorer' },
]}
/>
{message ? (
<Card className="mb-6 border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
<p className="text-sm text-emerald-900 dark:text-emerald-100">{message}</p>
</Card>
) : null}
{error ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm text-red-900 dark:text-red-100">{error}</p>
</Card>
) : null}
<div className="mb-8 grid gap-6 lg:grid-cols-3">
{products.map((product) => (
<Card key={product.slug} title={product.name}>
<div className="space-y-3 text-sm text-gray-700 dark:text-gray-300">
<div className="flex flex-wrap gap-2">
<EntityBadge label={product.provider} tone="info" />
<EntityBadge label={`vmid ${product.vmid}`} />
<EntityBadge label={product.default_tier} tone="success" />
<EntityBadge label={product.billing_model} tone="warning" />
{product.requires_approval ? <EntityBadge label="approval required" tone="warning" /> : <EntityBadge label="self-service" tone="success" />}
</div>
<p>{product.description}</p>
<div>
<div className="font-semibold text-gray-900 dark:text-white">HTTP</div>
<code className="break-all text-xs">{product.http_url}</code>
</div>
{product.ws_url ? (
<div>
<div className="font-semibold text-gray-900 dark:text-white">WS</div>
<code className="break-all text-xs">{product.ws_url}</code>
</div>
) : null}
<div>
<div className="font-semibold text-gray-900 dark:text-white">Use cases</div>
<div className="mt-2 flex flex-wrap gap-2">
{product.use_cases.map((item) => (
<EntityBadge key={item} label={item} className="normal-case tracking-normal" />
))}
</div>
</div>
{getOperatorIdentity(product.slug) ? (
<div>
<div className="font-semibold text-gray-900 dark:text-white">Primary operator / deployer</div>
<div className="mt-2 flex flex-wrap gap-2">
<EntityBadge label={getOperatorIdentity(product.slug)?.label || product.provider} tone="info" />
<EntityBadge label={`vmid ${getOperatorIdentity(product.slug)?.vmid || product.vmid}`} />
</div>
<code className="mt-2 block break-all text-xs">{getOperatorIdentity(product.slug)?.address}</code>
</div>
) : null}
{user ? (
<div className="border-t border-gray-200 pt-3 dark:border-gray-700">
{(() => {
const subscription = getSubscriptionForProduct(product.slug)
if (subscription) {
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<EntityBadge label={subscription.status} tone={subscription.status === 'active' ? 'success' : 'warning'} />
<EntityBadge label={subscription.tier} />
<EntityBadge label={`${subscription.requestsUsed}/${subscription.monthlyQuota || 0}`} tone="info" />
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{subscription.notes || 'Subscription record present.'}
</div>
</div>
)
}
return (
<button
type="button"
onClick={() => void handleRequestSubscription(product.slug, product.default_tier)}
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
{product.requires_approval ? 'Request access' : 'Activate access'}
</button>
)
})()}
</div>
) : null}
</div>
</Card>
))}
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_1.2fr]">
<div className="space-y-6">
<Card title="Wallet Authentication">
<div className="space-y-4 text-sm text-gray-700 dark:text-gray-300">
<p>
Use a connected wallet for standard account sign-in, then access subscriptions, API keys, and managed RPC controls with the same authenticated session.
</p>
{walletSession ? (
<div className="space-y-3 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 gap-2">
<EntityBadge label="wallet sign-in active" tone="success" />
<EntityBadge label={walletSession.track} tone="info" />
{walletSession.permissions.map((permission) => (
<EntityBadge key={permission} label={permission} className="normal-case tracking-normal" />
))}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Your wallet address remains private within the access console. This session is treated as account sign-in, not a public identifier.
</p>
<div className="text-xs text-gray-500 dark:text-gray-400">
Session expires {new Date(walletSession.expiresAt).toLocaleString()}
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleWalletDisconnect}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
>
Disconnect wallet
</button>
<Link href="/wallet" className="rounded-lg border border-primary-300 px-4 py-2 text-sm font-medium text-primary-700 hover:bg-primary-50 dark:border-primary-800 dark:text-primary-300 dark:hover:bg-primary-950/20">
Open wallet tools
</Link>
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed border-primary-300 bg-primary-50/60 p-4 dark:border-primary-700/50 dark:bg-primary-950/20">
<div className="mb-3 text-sm text-primary-900 dark:text-primary-100">
No wallet session is active. Connect a browser wallet to sign in to your account and unlock the access-management plane.
</div>
<button
type="button"
onClick={() => void handleWalletConnect()}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
>
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
</button>
</div>
)}
</div>
</Card>
<Card title="Operator Identities">
<div className="space-y-4">
{OPERATOR_IDENTITIES.map((identity) => (
<div key={identity.slug} 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 gap-2">
<EntityBadge label={identity.label} tone="info" />
<EntityBadge label={identity.slug} />
<EntityBadge label={`vmid ${identity.vmid}`} tone="warning" />
</div>
<code className="mt-3 block break-all text-xs text-gray-700 dark:text-gray-300">{identity.address}</code>
</div>
))}
</div>
</Card>
<Card title={user ? `Signed in as ${user.username}` : 'Create or Access Account'}>
{user ? (
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
{user.email}
</p>
<button
type="button"
onClick={handleSignOut}
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
>
Sign out
</button>
</div>
) : (
<div className="space-y-6">
<form onSubmit={handleRegister} className="space-y-3">
<Field label="Email" value={email} onChange={setEmail} type="email" />
<Field label="Username" value={username} onChange={setUsername} />
<Field label="Password" value={password} onChange={setPassword} type="password" />
<button type="submit" className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700">
Register
</button>
</form>
<form onSubmit={handleLogin} className="space-y-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<Field label="Email" value={email} onChange={setEmail} type="email" />
<Field label="Password" value={password} onChange={setPassword} type="password" />
<button type="submit" className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white">
Sign in
</button>
</form>
</div>
)}
</Card>
{user ? (
<Card title="Create API Key">
<form onSubmit={handleCreateAPIKey} className="space-y-3">
<Field label="Key name" value={apiKeyName} onChange={setAPIKeyName} />
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tier</span>
<select value={apiKeyTier} onChange={(event) => setAPIKeyTier(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">
<option value="free">free</option>
<option value="pro">pro</option>
<option value="enterprise">enterprise</option>
</select>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Product</span>
<select value={apiKeyProduct} onChange={(event) => setAPIKeyProduct(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">
{products.map((product) => (
<option key={product.slug} value={product.slug}>{product.name}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Expiry</span>
<select value={apiKeyExpiresDays} onChange={(event) => setAPIKeyExpiresDays(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">
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="365">365 days</option>
<option value="never">No expiry</option>
</select>
</label>
<Field label="Monthly quota override (optional)" value={apiKeyMonthlyQuota} onChange={setAPIKeyMonthlyQuota} />
<div>
<span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Scopes</span>
<div className="flex flex-wrap gap-2">
{ACCESS_SCOPE_OPTIONS.map((scope) => (
<label key={scope} className="inline-flex items-center gap-2 rounded-full border border-gray-300 px-3 py-2 text-sm dark:border-gray-700">
<input
type="checkbox"
checked={apiKeyScopes.includes(scope)}
onChange={() => handleScopeToggle(scope)}
/>
<span>{scope}</span>
</label>
))}
</div>
</div>
<button type="submit" className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700">
Issue key
</button>
</form>
{createdKey ? (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-950/20">
<div className="mb-2 text-sm font-semibold text-amber-900 dark:text-amber-100">Plaintext API key</div>
<code className="block break-all text-xs text-amber-900 dark:text-amber-100">{createdKey}</code>
</div>
) : null}
</Card>
) : null}
{user?.is_admin ? (
<Card title="Pending Access Review">
<div className="mb-4 flex flex-wrap items-end gap-3">
<label className="block min-w-[12rem]">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription status</span>
<select
value={adminSubscriptionStatus}
onChange={(event) => setAdminSubscriptionStatus(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"
>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="revoked">Revoked</option>
<option value="">All statuses</option>
</select>
</label>
</div>
{adminSubscriptions.length > 0 ? (
<div className="space-y-4">
{adminSubscriptions.map((subscription) => (
<div key={subscription.id} 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-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="font-semibold text-gray-900 dark:text-white">{subscription.productSlug}</div>
<div className="mt-2 flex flex-wrap gap-2">
<EntityBadge label={subscription.status} tone={subscription.status === 'active' ? 'success' : 'warning'} />
<EntityBadge label={subscription.tier} />
<EntityBadge label={`${subscription.monthlyQuota.toLocaleString()} quota`} tone="info" />
{subscription.requiresApproval ? <EntityBadge label="restricted product" tone="warning" /> : null}
</div>
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
Requested {new Date(subscription.createdAt).toLocaleString()}
{subscription.notes ? ` · ${subscription.notes}` : ''}
</div>
<label className="mt-3 block">
<span className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">Admin note</span>
<input
type="text"
value={adminActionNotes[subscription.id] || ''}
onChange={(event) =>
setAdminActionNotes((current) => ({
...current,
[subscription.id]: event.target.value,
}))
}
placeholder="Reason, approval scope, or operator note"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
</label>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void handleAdminSubscriptionAction(subscription.id, 'active')}
className="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700"
>
Approve
</button>
<button
type="button"
onClick={() => void handleAdminSubscriptionAction(subscription.id, 'suspended')}
className="rounded-lg border border-amber-300 px-3 py-2 text-sm font-medium text-amber-800 hover:bg-amber-50 dark:border-amber-800 dark:text-amber-300 dark:hover:bg-amber-950/20"
>
Suspend
</button>
<button
type="button"
onClick={() => void handleAdminSubscriptionAction(subscription.id, 'revoked')}
className="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950/20"
>
Revoke
</button>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">No subscriptions match the current review filter.</p>
)}
</Card>
) : null}
{user?.is_admin ? (
<Card title="Platform Audit Feed">
<div className="mb-4 grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Filter by product</span>
<select value={adminAuditProduct} onChange={(event) => void handleAdminAuditProductChange(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">
<option value="">All products</option>
{products.map((product) => (
<option key={`audit-${product.slug}`} value={product.slug}>{product.name}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Entries shown</span>
<select
value={adminAuditLimit}
onChange={(event) => setAdminAuditLimit(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"
>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
</div>
{adminAuditEntries.length > 0 ? (
<div className="space-y-3">
{adminAuditEntries.map((entry) => (
<div key={`admin-audit-${entry.id}`} 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="flex flex-wrap items-center gap-2">
<EntityBadge label={entry.productSlug || 'unscoped'} tone="info" />
<EntityBadge label={entry.methodName || 'unknown method'} />
<EntityBadge label={`${entry.requestCount} req`} />
</div>
<div className="mt-2 font-medium text-gray-900 dark:text-white">{entry.keyName || entry.apiKeyId}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{new Date(entry.createdAt).toLocaleString()}
{entry.lastIp ? ` · ${entry.lastIp}` : ''}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">No recent validated RPC traffic matches the current filter.</p>
)}
</Card>
) : null}
</div>
<Card title="Issued API Keys">
{user ? (
apiKeys.length > 0 ? (
<div className="space-y-4">
{apiKeys.map((key) => (
<div key={key.id} 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-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="font-semibold text-gray-900 dark:text-white">{key.name}</div>
<div className="mt-2 flex flex-wrap gap-2">
<EntityBadge label={key.tier} tone="success" />
{key.productSlug ? <EntityBadge label={key.productSlug} tone="info" /> : null}
<EntityBadge label={`${key.rateLimitPerSecond}/s`} tone="info" />
<EntityBadge label={`${key.rateLimitPerMinute}/min`} />
<EntityBadge label={`${key.requestsUsed}/${key.monthlyQuota || 0}`} />
{key.approved ? <EntityBadge label="approved" tone="success" /> : <EntityBadge label="pending" tone="warning" />}
{key.revoked ? <EntityBadge label="revoked" tone="warning" /> : <EntityBadge label="active" tone="success" />}
{key.expiresAt ? <EntityBadge label={`expires ${new Date(key.expiresAt).toLocaleDateString()}`} /> : <EntityBadge label="no expiry" />}
</div>
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
Created {new Date(key.createdAt).toLocaleString()}
{key.lastUsedAt ? ` · Last used ${new Date(key.lastUsedAt).toLocaleString()}` : ' · Not used yet'}
</div>
</div>
{!key.revoked ? (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void handleRotate(key)}
className="rounded-lg border border-primary-300 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-50 dark:border-primary-800 dark:text-primary-300 dark:hover:bg-primary-950/20"
>
Rotate
</button>
<button
type="button"
onClick={() => void handleRevoke(key.id)}
className="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950/20"
>
Revoke
</button>
</div>
) : null}
</div>
{key.scopes.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{key.scopes.map((scope) => (
<EntityBadge key={`${key.id}-${scope}`} label={scope} className="normal-case tracking-normal" />
))}
</div>
) : null}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">No API keys issued yet.</p>
)
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">
Sign in to issue and manage RPC access keys for Core, Thirdweb, and Alltra products.
</p>
)}
<div className="mt-6 border-t border-gray-200 pt-4 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400">
Billing, quotas, and paywalls can be layered onto this access plane next. The current slice establishes identity, product discovery, and key lifecycle management.
</div>
{user && usage.length > 0 ? (
<div className="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<div className="mb-3 text-sm font-semibold text-gray-900 dark:text-white">Usage Summary</div>
<div className="space-y-3">
{usage.map((item) => (
<div key={item.product_slug} 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="flex flex-wrap items-center gap-2">
<EntityBadge label={item.product_slug} tone="info" />
<EntityBadge label={`${item.active_keys} active keys`} />
</div>
<div className="mt-2 text-gray-600 dark:text-gray-400">
{item.requests_used.toLocaleString()} requests used / {item.monthly_quota.toLocaleString()} monthly quota
</div>
</div>
))}
</div>
</div>
) : null}
{user ? (
<div className="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<div className="mb-3 flex flex-wrap items-end justify-between gap-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Recent API Activity</div>
<label className="block min-w-[10rem]">
<span className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">Entries shown</span>
<select
value={auditLimit}
onChange={(event) => setAuditLimit(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"
>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
</label>
</div>
{auditEntries.length > 0 ? (
<div className="space-y-3">
{auditEntries.map((entry) => (
<div key={`audit-${entry.id}`} 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="flex flex-wrap items-center gap-2">
<EntityBadge label={entry.productSlug || 'unscoped'} tone="info" />
<EntityBadge label={entry.methodName || 'unknown method'} />
<EntityBadge label={`${entry.requestCount} req`} />
</div>
<div className="mt-2 font-medium text-gray-900 dark:text-white">{entry.keyName || entry.apiKeyId}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{new Date(entry.createdAt).toLocaleString()}
{entry.lastIp ? ` · ${entry.lastIp}` : ''}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-600 dark:text-gray-400">No API usage has been logged yet for this account.</p>
)}
</div>
) : null}
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/wallet" className="text-primary-600 hover:underline">Wallet </Link>
<Link href="/system" className="text-primary-600 hover:underline">System </Link>
</div>
</Card>
</div>
</main>
)
}

View File

@@ -0,0 +1,52 @@
import clsx from 'clsx'
function toneClasses(tone: 'neutral' | 'success' | 'warning' | 'info') {
switch (tone) {
case 'success':
return 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200'
case 'warning':
return 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200'
case 'info':
return 'border-sky-200 bg-sky-50 text-sky-800 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-200'
default:
return 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300'
}
}
export function getEntityBadgeTone(tag: string): 'neutral' | 'success' | 'warning' | 'info' {
const normalized = tag.toLowerCase()
if (normalized === 'compliant' || normalized === 'listed' || normalized === 'verified') {
return 'success'
}
if (normalized === 'wrapped') {
return 'warning'
}
if (normalized === 'bridge' || normalized === 'canonical' || normalized === 'official') {
return 'info'
}
return 'neutral'
}
export default function EntityBadge({
label,
tone,
className,
}: {
label: string
tone?: 'neutral' | 'success' | 'warning' | 'info'
className?: string
}) {
const resolvedTone = tone || getEntityBadgeTone(label)
return (
<span
className={clsx(
'rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-wide',
toneClasses(resolvedTone),
className,
)}
>
{label}
</span>
)
}

View File

@@ -0,0 +1,178 @@
'use client'
import { FormEvent, useMemo, useState } from 'react'
import { usePathname } from 'next/navigation'
import { getExplorerApiBase } from '@/services/api/blockscout'
interface AgentMessage {
role: 'assistant' | 'user'
content: string
}
const QUICK_PROMPTS = [
'Explain this page',
'Summarize the chain status',
'Help me inspect a contract',
'Find likely navigation issues',
] as const
export default function ExplorerAgentTool() {
const pathname = usePathname() ?? '/'
const [open, setOpen] = useState(false)
const [input, setInput] = useState('')
const [submitting, setSubmitting] = useState(false)
const [messages, setMessages] = useState<AgentMessage[]>([
{
role: 'assistant',
content:
'Explorer AI Agent Tool is ready. I can explain this page, summarize what you are looking at, and help investigate transactions, contracts, routes, and system surfaces.',
},
])
const pageContext = useMemo(
() => ({
path: pathname,
view: 'explorer',
}),
[pathname],
)
const sendMessage = async (content: string) => {
const trimmed = content.trim()
if (!trimmed || submitting) return
const nextMessages: AgentMessage[] = [...messages, { role: 'user', content: trimmed }]
setMessages(nextMessages)
setInput('')
setSubmitting(true)
try {
const response = await fetch(`${getExplorerApiBase()}/api/v1/ai/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: nextMessages,
pageContext,
}),
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(payload?.error?.message || `HTTP ${response.status}`)
}
const reply =
payload?.message?.content ||
payload?.reply ||
'The agent did not return a readable reply.'
setMessages((current) => [...current, { role: 'assistant', content: String(reply) }])
} catch (error) {
setMessages((current) => [
...current,
{
role: 'assistant',
content:
error instanceof Error
? `Agent tool is temporarily unavailable: ${error.message}`
: 'Agent tool is temporarily unavailable.',
},
])
} finally {
setSubmitting(false)
}
}
const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
await sendMessage(input)
}
return (
<div className="fixed bottom-5 right-5 z-40 flex max-w-[calc(100vw-1.5rem)] flex-col items-end gap-3">
{open ? (
<section className="w-[min(24rem,calc(100vw-1.5rem))] overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-start justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Explorer AI Agent Tool</h2>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Page-aware guidance for the explorer. Helpful, read-only, and designed for quick investigation.
</p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
Close
</button>
</div>
<div className="flex flex-wrap gap-2 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
{QUICK_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => void sendMessage(prompt)}
className="rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 dark:border-primary-500/30 dark:bg-primary-500/10 dark:text-primary-300 dark:hover:bg-primary-500/20"
>
{prompt}
</button>
))}
</div>
<div className="max-h-[22rem] space-y-3 overflow-y-auto px-4 py-3">
{messages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={`rounded-2xl px-3 py-2 text-sm ${
message.role === 'assistant'
? 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100'
: 'ml-6 bg-primary-600 text-white'
}`}
>
{message.content}
</div>
))}
</div>
<form onSubmit={handleSubmit} className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
<label className="block">
<span className="sr-only">Ask the explorer agent</span>
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
rows={3}
placeholder="Ask about this page, a transaction, a token, or an access-control flow."
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-gray-700 dark:bg-gray-950 dark:text-white"
/>
</label>
<div className="mt-3 flex items-center justify-between gap-3">
<p className="text-xs text-gray-500 dark:text-gray-400">Current view: {pathname}</p>
<button
type="submit"
disabled={submitting || !input.trim()}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? 'Thinking…' : 'Send'}
</button>
</div>
</form>
</section>
) : null}
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="inline-flex items-center gap-2 rounded-full bg-primary-600 px-4 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-primary-700"
aria-expanded={open}
>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-white/15">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-4l-4 4v-4Z" />
</svg>
</span>
Agent Tool
</button>
</div>
)
}

View File

@@ -1,12 +1,22 @@
import type { ReactNode } from 'react'
import Navbar from './Navbar'
import Footer from './Footer'
import ExplorerAgentTool from './ExplorerAgentTool'
export default function ExplorerChrome({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-primary-600 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white"
>
Skip to content
</a>
<Navbar />
<div className="flex-1">{children}</div>
<div id="main-content" className="flex-1">
{children}
</div>
<ExplorerAgentTool />
<Footer />
</div>
)

View File

@@ -12,15 +12,18 @@ export default function Footer() {
<div className="grid gap-4 sm:gap-6 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
<div className="text-base font-semibold text-gray-900 dark:text-white sm:text-lg">
SolaceScanScout
SolaceScan
</div>
<p className="max-w-xl text-sm leading-6 text-gray-600 dark:text-gray-400">
Built from Blockscout foundations and Solace Bank Group PLC frontend
work. Explorer data is powered by Blockscout, Chain 138 RPC, and the
companion MetaMask Snap.
Built on Blockscout for the DBIS / Defi Oracle Chain 138 explorer surface.
Explorer data is powered by Blockscout, Chain 138 RPC, and the companion MetaMask Snap.
</p>
<p className="max-w-xl text-xs leading-5 text-gray-500 dark:text-gray-500">
Public explorer access may appear under <code>blockscout.defi-oracle.io</code> or <code>explorer.d-bis.org</code>.
Both domains belong to the same DBIS / Defi Oracle explorer surface.
</p>
<p className="text-xs text-gray-500 dark:text-gray-500">
© {year} Solace Bank Group PLC. All rights reserved.
© {year} DBIS / Defi Oracle. All rights reserved.
</p>
</div>
@@ -29,11 +32,12 @@ export default function Footer() {
Resources
</div>
<ul className="space-y-2 text-sm">
<li><a className={footerLinkClass} href="/docs.html">Documentation</a></li>
<li><Link className={footerLinkClass} href="/search">Search</Link></li>
<li><Link className={footerLinkClass} href="/docs">Documentation</Link></li>
<li><Link className={footerLinkClass} href="/bridge">Bridge Monitoring</Link></li>
<li><Link className={footerLinkClass} href="/liquidity">Liquidity Access</Link></li>
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
<li><Link className={footerLinkClass} href="/more">More Tools</Link></li>
<li><Link className={footerLinkClass} href="/operations">Operations Hub</Link></li>
<li><Link className={footerLinkClass} href="/addresses">Addresses</Link></li>
<li><Link className={footerLinkClass} href="/watchlist">Watchlist</Link></li>
<li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li>
@@ -55,8 +59,8 @@ export default function Footer() {
</p>
<p>
Snap site:{' '}
<a className={footerLinkClass} href="https://explorer.d-bis.org/snap/" target="_blank" rel="noopener noreferrer">
explorer.d-bis.org/snap/
<a className={footerLinkClass} href="/snap/" target="_blank" rel="noopener noreferrer">
/snap/ on the current explorer domain
</a>
</p>
<p>

View File

@@ -0,0 +1,214 @@
import { Card } from '@/libs/frontend-ui-primitives'
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import type { GruStandardsProfile } from '@/services/api/gru'
import Link from 'next/link'
const STANDARD_EXPLANATIONS: Record<string, string> = {
'ERC-20': 'Base fungible-token surface for wallets, DEXs, explorers, and accounting systems.',
AccessControl: 'Role-governed administration for mint, burn, pause, and supervised operations.',
Pausable: 'Emergency intervention surface for freezing activity during incidents or policy actions.',
'EIP-712': 'Typed signing domain for structured off-chain approvals and payment flows.',
'ERC-2612': 'Permit support for signature-based approvals without a separate on-chain approve transaction.',
'ERC-3009': 'Authorization-based transfer model for signed payment flows without prior allowances.',
'ERC-5267': 'Discoverable EIP-712 domain introspection so wallets and relayers can inspect the signing domain cleanly.',
IeMoneyToken: 'Repo-native eMoney token methodology for issuance and redemption semantics.',
DeterministicStorageNamespace: 'Stable namespace for upgrade-aware policy, registry, and audit resolution.',
JurisdictionAndSupervisionMetadata: 'Governance, supervisory, disclosure, and reporting metadata required by the GRU operating model.',
}
function formatDuration(seconds: number | null): string | null {
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return null
const units = [
{ label: 'day', value: 86400 },
{ label: 'hour', value: 3600 },
{ label: 'minute', value: 60 },
]
const parts: string[] = []
let remaining = Math.floor(seconds)
for (const unit of units) {
if (remaining >= unit.value) {
const count = Math.floor(remaining / unit.value)
remaining -= count * unit.value
parts.push(`${count} ${unit.label}${count === 1 ? '' : 's'}`)
}
if (parts.length === 2) break
}
if (parts.length === 0) {
return `${remaining} second${remaining === 1 ? '' : 's'}`
}
return parts.join(' ')
}
export default function GruStandardsCard({
profile,
title = 'GRU v2 Standards',
}: {
profile: GruStandardsProfile
title?: string
}) {
const detectedCount = profile.standards.filter((standard) => standard.detected).length
const requiredCount = profile.standards.filter((standard) => standard.required).length
const missingRequired = profile.standards.filter((standard) => standard.required && !standard.detected)
const noticePeriod = formatDuration(profile.minimumUpgradeNoticePeriodSeconds)
const recommendations = [
missingRequired.length > 0
? `Review the live contract ABI and deployment against the GRU v2 base-token matrix before treating this asset as fully canonical.`
: `The live contract exposes the full required GRU v2 base-token surface currently checked by the explorer.`,
profile.wrappedTransport
? 'This looks like a wrapped transport asset, so confirm the corresponding bridge lane and reserve-verifier posture in addition to the token ABI.'
: 'This looks like a canonical GRU asset, so the next meaningful checks are reserve, governance, and transport activation beyond the token interface itself.',
profile.x402Ready
? 'This contract appears ready for x402-style payment flows because the explorer can see the required signature and domain surfaces.'
: 'This contract does not currently look x402-ready from the live explorer surface; verify EIP-712, ERC-5267, and permit or authorization flow exposure before using it as a payment rail.',
profile.forwardCanonical === true
? 'This version is marked forward-canonical, so it should be treated as the preferred successor surface even if older liquidity or transport versions still coexist.'
: profile.forwardCanonical === false
? 'This version is not forward-canonical, which usually means it is legacy, staged, or transport-only relative to the intended primary canonical surface.'
: 'Forward-canonical posture is not directly detectable on this contract, so rely on the transport overlay and deployment records before making promotion assumptions.',
profile.legacyAliasSupport
? 'Legacy alias support is exposed, which is useful during version cutovers and explorer/search reconciliation.'
: 'Legacy alias support is not visible from the current explorer contract surface, so name/version migration may need registry or deployment-record cross-checks.',
'Use the repo standards references to reconcile any missing surface with the intended GRU profile and rollout phase.',
]
return (
<Card title={title}>
<dl className="space-y-4">
<DetailRow label="Profile">
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<EntityBadge label={profile.profileId} tone="info" className="normal-case tracking-normal" />
<EntityBadge
label={profile.wrappedTransport ? 'wrapped transport' : 'canonical GRU'}
tone={profile.wrappedTransport ? 'warning' : 'success'}
/>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{detectedCount} of {requiredCount} required base-token standards are currently detectable from the live contract surface.
</div>
</div>
</DetailRow>
<DetailRow label="Standards" valueClassName="flex flex-wrap gap-2">
{profile.standards.map((standard) => (
<EntityBadge
key={standard.id}
label={standard.detected ? `${standard.id} detected` : `${standard.id} missing`}
tone={standard.detected ? 'success' : 'warning'}
className="normal-case tracking-normal"
/>
))}
</DetailRow>
<DetailRow label="Transport Posture">
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<EntityBadge
label={profile.x402Ready ? 'x402 ready' : 'x402 not ready'}
tone={profile.x402Ready ? 'success' : 'warning'}
/>
<EntityBadge
label={
profile.forwardCanonical === true
? 'forward canonical'
: profile.forwardCanonical === false
? 'not forward canonical'
: 'forward canonical unknown'
}
tone={
profile.forwardCanonical === true
? 'success'
: profile.forwardCanonical === false
? 'warning'
: 'info'
}
/>
<EntityBadge
label={profile.legacyAliasSupport ? 'legacy aliases exposed' : 'no alias surface'}
tone={profile.legacyAliasSupport ? 'info' : 'warning'}
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div 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="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Settlement posture</div>
<div className="mt-2 text-gray-900 dark:text-white">
{profile.wrappedTransport
? 'This contract presents itself like a wrapped public-transport asset instead of the canonical Chain 138 money surface.'
: 'This contract presents itself like the canonical Chain 138 GRU money surface instead of a wrapped transport mirror.'}
</div>
</div>
<div 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="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Upgrade notice</div>
<div className="mt-2 text-gray-900 dark:text-white">
{noticePeriod
? `${noticePeriod} (${profile.minimumUpgradeNoticePeriodSeconds} seconds)`
: 'No readable minimum upgrade notice period was detected from the current explorer surface.'}
</div>
</div>
<div 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="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Version posture</div>
<div className="mt-2 text-gray-900 dark:text-white">
{profile.activeVersion || profile.forwardVersion
? `Active liquidity/transport version: ${profile.activeVersion || 'unknown'}; preferred forward version: ${profile.forwardVersion || 'unknown'}.`
: 'No explicit active-versus-forward version posture is available from the local GRU catalog yet.'}
</div>
</div>
</div>
</div>
</DetailRow>
<DetailRow label="Interpretation">
<div className="space-y-3">
{profile.standards.map((standard) => (
<div key={`${standard.id}-explanation`} 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="flex flex-wrap items-center gap-2">
<div className="font-medium text-gray-900 dark:text-white">{standard.id}</div>
<EntityBadge label={standard.detected ? 'detected' : 'missing'} tone={standard.detected ? 'success' : 'warning'} />
</div>
<div className="mt-2 text-gray-600 dark:text-gray-400">
{STANDARD_EXPLANATIONS[standard.id] || 'GRU-specific standard surfaced by the repo standards profile.'}
</div>
</div>
))}
</div>
</DetailRow>
{profile.metadata.length > 0 ? (
<DetailRow label="Metadata">
<div className="space-y-3">
{profile.metadata.map((field) => (
<div key={field.label} 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="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{field.label}</div>
<div className="mt-2 break-all text-gray-900 dark:text-white">{field.value}</div>
</div>
))}
</div>
</DetailRow>
) : null}
<DetailRow label="References">
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div><Link href="/docs/gru" className="text-primary-600 hover:underline">Explorer GRU guide</Link></div>
<div>Canonical profile: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">{profile.profileId}</code></div>
<div>Repo standards matrix: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">docs/04-configuration/GRU_C_STAR_V2_STANDARDS_MATRIX_AND_IMPLEMENTATION_PLAN.md</code></div>
<div>Machine-readable profile: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">config/gru-standards-profile.json</code></div>
<div>Transport overlay: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">config/gru-transport-active.json</code></div>
<div>x402 support note: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">docs/04-configuration/CHAIN138_X402_TOKEN_SUPPORT.md</code></div>
<div>Chain 138 readiness guide: <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">docs/04-configuration/GRU_V2_CHAIN138_READINESS.md</code></div>
</div>
</DetailRow>
<DetailRow label="Recommendations">
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
{recommendations.map((item) => (
<div key={item} className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
{item}
</div>
))}
</div>
</DetailRow>
</dl>
</Card>
)
}

View File

@@ -1,44 +1,96 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useId, useRef, useState } from 'react'
import { accessApi, type WalletAccessSession } from '@/services/api/access'
const navLink = 'text-gray-700 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors'
const navLinkActive = 'text-primary-600 dark:text-primary-400 font-medium'
const navItemBase =
'rounded-xl px-3 py-2 text-[15px] font-medium transition-all duration-150'
const navLink =
`${navItemBase} text-gray-700 dark:text-gray-300 hover:bg-primary-50 hover:text-primary-700 dark:hover:bg-gray-700/70 dark:hover:text-primary-300`
const navLinkActive =
`${navItemBase} bg-primary-50 text-primary-700 shadow-sm ring-1 ring-primary-100 dark:bg-primary-500/10 dark:text-primary-300 dark:ring-primary-500/20`
function NavDropdown({
label,
icon,
active,
children,
}: {
label: string
icon: React.ReactNode
active?: boolean
children: React.ReactNode
}) {
const [open, setOpen] = useState(false)
const wrapperRef = useRef<HTMLDivElement | null>(null)
const menuId = useId()
useEffect(() => {
if (!open) return
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null
if (!target || !wrapperRef.current?.contains(target)) {
setOpen(false)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('touchstart', handlePointerDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('touchstart', handlePointerDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open])
return (
<div
ref={wrapperRef}
className="relative"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onBlurCapture={(event) => {
const nextTarget = event.relatedTarget as Node | null
if (nextTarget && wrapperRef.current?.contains(nextTarget)) {
return
}
setOpen(false)
}}
>
<button
type="button"
className={`flex items-center gap-1.5 px-3 py-2 rounded-md ${navLink}`}
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 ${active && !open ? navLinkActive : navLink}`}
onClick={() => setOpen((value) => !value)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setOpen(true)
}
}}
aria-expanded={open}
aria-haspopup="true"
aria-controls={menuId}
>
{icon}
<span>{label}</span>
<svg className={`w-3.5 h-3.5 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden>
<svg className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<ul
className="absolute left-0 top-full mt-1 min-w-[200px] rounded-lg bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50"
id={menuId}
className="absolute left-0 top-full z-50 mt-1 min-w-[220px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
role="menu"
>
{children}
@@ -59,7 +111,8 @@ function DropdownItem({
children: React.ReactNode
external?: boolean
}) {
const className = `flex items-center gap-2 px-4 py-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 ${navLink}`
const className =
'flex items-center gap-2 px-4 py-2.5 text-gray-700 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-primary-400'
if (external) {
return (
<li role="none">
@@ -81,30 +134,91 @@ function DropdownItem({
}
export default function Navbar() {
const router = useRouter()
const pathname = usePathname() ?? ''
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [exploreOpen, setExploreOpen] = useState(false)
const [toolsOpen, setToolsOpen] = useState(false)
const [dataOpen, setDataOpen] = useState(false)
const [operationsOpen, setOperationsOpen] = useState(false)
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
const [connectingWallet, setConnectingWallet] = useState(false)
const isExploreActive =
pathname === '/' ||
pathname.startsWith('/blocks') ||
pathname.startsWith('/transactions') ||
pathname.startsWith('/addresses')
const isDataActive =
pathname.startsWith('/tokens') ||
pathname.startsWith('/pools') ||
pathname.startsWith('/analytics') ||
pathname.startsWith('/watchlist')
const isOperationsActive =
pathname.startsWith('/bridge') ||
pathname.startsWith('/routes') ||
pathname.startsWith('/liquidity') ||
pathname.startsWith('/operations') ||
pathname.startsWith('/operator') ||
pathname.startsWith('/system') ||
pathname.startsWith('/weth')
const isDocsActive = pathname.startsWith('/docs')
const isAccessActive = pathname.startsWith('/access')
useEffect(() => {
const syncWalletSession = () => {
setWalletSession(accessApi.getStoredWalletSession())
}
syncWalletSession()
window.addEventListener('storage', syncWalletSession)
window.addEventListener('explorer-access-session-changed', syncWalletSession)
return () => {
window.removeEventListener('storage', syncWalletSession)
window.removeEventListener('explorer-access-session-changed', syncWalletSession)
}
}, [])
const handleAccessClick = async () => {
if (walletSession) {
router.push('/access')
setMobileMenuOpen(false)
return
}
try {
setConnectingWallet(true)
await accessApi.connectWalletSession()
router.push('/access')
setMobileMenuOpen(false)
} catch (error) {
console.error('Wallet connect failed', error)
router.push('/access')
setMobileMenuOpen(false)
} finally {
setConnectingWallet(false)
}
}
const toggleMobileMenu = () => {
setMobileMenuOpen((open) => {
const nextOpen = !open
if (!nextOpen) {
setExploreOpen(false)
setToolsOpen(false)
setDataOpen(false)
setOperationsOpen(false)
}
return nextOpen
})
}
return (
<nav className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<nav className="border-b border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="container mx-auto px-4">
<div className="flex h-14 items-center justify-between sm:h-16">
<div className="flex min-w-0 items-center gap-3 md:gap-8">
<div className="flex min-w-0 items-center gap-3 md:gap-6">
<Link
href="/"
className="group inline-flex max-w-[calc(100vw-5rem)] flex-col rounded-xl px-2 py-1.5 text-base font-bold text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-gray-700/70 sm:max-w-none sm:px-3 sm:py-2 sm:text-xl"
className="group inline-flex max-w-[calc(100vw-5rem)] flex-col rounded-xl px-2 py-1.5 text-base font-bold text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-gray-700/70 sm:max-w-none sm:px-2.5 sm:py-1.5 sm:text-[1.05rem]"
onClick={() => setMobileMenuOpen(false)}
aria-label="Go to explorer home"
>
@@ -116,58 +230,87 @@ export default function Navbar() {
</span>
<span className="min-w-0 truncate">
<span className="sm:hidden">SolaceScan</span>
<span className="hidden sm:inline">SolaceScanScout</span>
<span className="hidden sm:inline">SolaceScan</span>
</span>
</span>
<span className="mt-0.5 hidden text-xs font-normal text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200 sm:block">
The Defi Oracle Meta Explorer
<span className="mt-0.5 hidden text-[0.78rem] font-normal text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200 sm:block">
Chain 138 Explorer by DBIS
</span>
</Link>
<div className="hidden md:flex items-center gap-1">
<div className="hidden items-center gap-1.5 md:flex">
<NavDropdown
label="Explore"
icon={<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0h.5a2.5 2.5 0 002.5-2.5V3.935M12 12a2 2 0 104 0 2 2 0 00-4 0z" /></svg>}
active={isExploreActive}
icon={<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0h.5a2.5 2.5 0 002.5-2.5V3.935M12 12a2 2 0 104 0 2 2 0 00-4 0z" /></svg>}
>
<DropdownItem href="/" icon={<span className="text-gray-400"></span>}>Home</DropdownItem>
<DropdownItem href="/blocks" icon={<span className="text-gray-400"></span>}>Blocks</DropdownItem>
<DropdownItem href="/transactions" icon={<span className="text-gray-400"></span>}>Transactions</DropdownItem>
<DropdownItem href="/addresses" icon={<span className="text-gray-400"></span>}>Addresses</DropdownItem>
</NavDropdown>
<Link
href="/search"
className={`hidden md:inline-flex items-center ${pathname.startsWith('/search') ? navLinkActive : navLink}`}
>
Search
</Link>
<NavDropdown
label="Data"
active={isDataActive}
icon={<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7h16M4 12h16M4 17h10" /></svg>}
>
<DropdownItem href="/tokens">Tokens</DropdownItem>
<DropdownItem href="/analytics">Analytics</DropdownItem>
<DropdownItem href="/pools">Pools</DropdownItem>
<DropdownItem href="/watchlist">Watchlist</DropdownItem>
</NavDropdown>
<Link
href="/docs"
className={`hidden md:inline-flex items-center ${isDocsActive ? navLinkActive : navLink}`}
>
Docs
</Link>
<NavDropdown
label="Operations"
active={isOperationsActive}
icon={<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>}
>
<DropdownItem href="/operations">Operations Hub</DropdownItem>
<DropdownItem href="/bridge">Bridge</DropdownItem>
<DropdownItem href="/routes">Routes</DropdownItem>
<DropdownItem href="/liquidity">Liquidity</DropdownItem>
<DropdownItem href="/system">System</DropdownItem>
<DropdownItem href="/operator">Operator</DropdownItem>
<DropdownItem href="/weth">WETH</DropdownItem>
<DropdownItem href="/chain138-command-center.html" external>Command Center</DropdownItem>
</NavDropdown>
<Link
href="/wallet"
className={`hidden md:inline-flex items-center px-3 py-2 rounded-md ${pathname.startsWith('/wallet') ? navLinkActive : navLink}`}
className={`hidden md:inline-flex items-center ${pathname.startsWith('/wallet') ? navLinkActive : navLink}`}
>
Wallet
</Link>
<NavDropdown
label="Tools"
icon={<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>}
<button
type="button"
onClick={() => void handleAccessClick()}
className={`hidden md:inline-flex items-center ${isAccessActive ? navLinkActive : navLink}`}
>
<DropdownItem href="/search">Search</DropdownItem>
<DropdownItem href="/tokens">Tokens</DropdownItem>
<DropdownItem href="/pools">Pools</DropdownItem>
<DropdownItem href="/watchlist">Watchlist</DropdownItem>
<DropdownItem href="/wallet">Wallet</DropdownItem>
<DropdownItem href="/liquidity">Liquidity</DropdownItem>
<DropdownItem href="/bridge">Bridge</DropdownItem>
<DropdownItem href="/routes">Routes</DropdownItem>
<DropdownItem href="/more">More</DropdownItem>
<DropdownItem href="/chain138-command-center.html" external>Command Center</DropdownItem>
</NavDropdown>
{connectingWallet ? 'Connecting…' : walletSession ? 'Access' : 'Connect Wallet'}
</button>
</div>
</div>
<div className="flex items-center md:hidden">
<button
type="button"
className="p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
className="rounded-md p-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
onClick={toggleMobileMenu}
aria-expanded={mobileMenuOpen}
aria-label="Toggle menu"
>
{mobileMenuOpen ? (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
) : (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>
)}
</button>
</div>
@@ -175,40 +318,62 @@ export default function Navbar() {
{mobileMenuOpen && (
<div className="border-t border-gray-200 py-2 pb-3 dark:border-gray-700 md:hidden">
<div className="flex flex-col gap-1">
<Link href="/" className={`px-3 py-2.5 rounded-md ${pathname === '/' ? navLinkActive : navLink}`} onClick={() => setMobileMenuOpen(false)}>Home</Link>
<Link href="/" className={pathname === '/' ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Home</Link>
<Link href="/search" className={pathname.startsWith('/search') ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Search</Link>
<div className="relative">
<button type="button" className={`flex items-center justify-between w-full px-3 py-2.5 rounded-md ${navLink}`} onClick={() => setExploreOpen((o) => !o)} aria-expanded={exploreOpen}>
<button type="button" className={`flex w-full items-center justify-between ${navLink}`} onClick={() => setExploreOpen((value) => !value)} aria-expanded={exploreOpen}>
<span>Explore</span>
<svg className={`w-4 h-4 transition-transform ${exploreOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
<svg className={`h-4 w-4 transition-transform ${exploreOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
{exploreOpen && (
<ul className="pl-4 mt-1 space-y-0.5">
<li><Link href="/blocks" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Blocks</Link></li>
<li><Link href="/transactions" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Transactions</Link></li>
<li><Link href="/addresses" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Addresses</Link></li>
<ul className="mt-1 space-y-0.5 pl-4">
<li><Link href="/blocks" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Blocks</Link></li>
<li><Link href="/transactions" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Transactions</Link></li>
<li><Link href="/addresses" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Addresses</Link></li>
</ul>
)}
</div>
<div className="relative">
<button type="button" className={`flex items-center justify-between w-full px-3 py-2.5 rounded-md ${navLink}`} onClick={() => setToolsOpen((o) => !o)} aria-expanded={toolsOpen}>
<span>Tools</span>
<svg className={`w-4 h-4 transition-transform ${toolsOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
<button type="button" className={`flex w-full items-center justify-between ${navLink}`} onClick={() => setDataOpen((value) => !value)} aria-expanded={dataOpen}>
<span>Data</span>
<svg className={`h-4 w-4 transition-transform ${dataOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
{toolsOpen && (
<ul className="pl-4 mt-1 space-y-0.5">
<li><Link href="/search" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Search</Link></li>
<li><Link href="/tokens" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Tokens</Link></li>
<li><Link href="/pools" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Pools</Link></li>
<li><Link href="/watchlist" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Watchlist</Link></li>
<li><Link href="/wallet" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Wallet</Link></li>
<li><Link href="/liquidity" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Liquidity</Link></li>
<li><Link href="/bridge" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Bridge</Link></li>
<li><Link href="/routes" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Routes</Link></li>
<li><Link href="/more" className={`block px-3 py-2 rounded-md ${navLink}`} onClick={() => setMobileMenuOpen(false)}>More</Link></li>
<li><a href="/chain138-command-center.html" className={`block px-3 py-2 rounded-md ${navLink}`} target="_blank" rel="noopener noreferrer" onClick={() => setMobileMenuOpen(false)}>Command Center</a></li>
{dataOpen && (
<ul className="mt-1 space-y-0.5 pl-4">
<li><Link href="/tokens" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Tokens</Link></li>
<li><Link href="/analytics" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Analytics</Link></li>
<li><Link href="/pools" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Pools</Link></li>
<li><Link href="/watchlist" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Watchlist</Link></li>
</ul>
)}
</div>
<Link href="/docs" className={isDocsActive ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Docs</Link>
<div className="relative">
<button type="button" className={`flex w-full items-center justify-between ${navLink}`} onClick={() => setOperationsOpen((value) => !value)} aria-expanded={operationsOpen}>
<span>Operations</span>
<svg className={`h-4 w-4 transition-transform ${operationsOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
{operationsOpen && (
<ul className="mt-1 space-y-0.5 pl-4">
<li><Link href="/operations" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Operations Hub</Link></li>
<li><Link href="/bridge" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Bridge</Link></li>
<li><Link href="/routes" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Routes</Link></li>
<li><Link href="/liquidity" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Liquidity</Link></li>
<li><Link href="/system" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>System</Link></li>
<li><Link href="/operator" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Operator</Link></li>
<li><Link href="/weth" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>WETH</Link></li>
<li><a href="/chain138-command-center.html" className={`block rounded-md px-3 py-2 ${navLink}`} target="_blank" rel="noopener noreferrer" onClick={() => setMobileMenuOpen(false)}>Command Center</a></li>
</ul>
)}
</div>
<Link href="/wallet" className={pathname.startsWith('/wallet') ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Wallet</Link>
<button
type="button"
className={`w-full text-left ${isAccessActive ? navLinkActive : navLink}`}
onClick={() => void handleAccessClick()}
>
{connectingWallet ? 'Connecting wallet…' : walletSession ? 'Access' : 'Connect wallet'}
</button>
</div>
</div>
)}

View File

@@ -0,0 +1,45 @@
import Link from 'next/link'
export interface PageIntroAction {
href: string
label: string
}
export default function PageIntro({
eyebrow,
title,
description,
actions = [],
}: {
eyebrow?: string
title: string
description: string
actions?: PageIntroAction[]
}) {
return (
<div className="mb-6 rounded-3xl border border-gray-200 bg-white/90 p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800/80 sm:mb-8 sm:p-6">
{eyebrow ? (
<div className="mb-3 inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-300">
{eyebrow}
</div>
) : null}
<h1 className="text-3xl font-bold text-gray-900 dark:text-white sm:text-4xl">{title}</h1>
<p className="mt-3 max-w-4xl text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base">
{description}
</p>
{actions.length > 0 ? (
<div className="mt-5 flex flex-wrap gap-3">
{actions.map((action) => (
<Link
key={`${action.href}-${action.label}`}
href={action.href}
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-200 dark:hover:text-primary-300"
>
{action.label}
</Link>
))}
</div>
) : null}
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { explorerFeaturePages } from '@/data/explorerOperations'
import { blocksApi, type Block } from '@/services/api/blocks'
import {
@@ -7,8 +8,14 @@ import {
type MissionControlBridgeStatusResponse,
type MissionControlChainStatus,
} from '@/services/api/missionControl'
import { statsApi, type ExplorerStats } from '@/services/api/stats'
import {
statsApi,
type ExplorerRecentActivitySnapshot,
type ExplorerStats,
type ExplorerTransactionTrendPoint,
} from '@/services/api/stats'
import { transactionsApi, type Transaction } from '@/services/api/transactions'
import { formatWeiAsEth } from '@/utils/format'
import OperationsPageShell, {
MetricCard,
StatusBadge,
@@ -17,6 +24,15 @@ import OperationsPageShell, {
truncateMiddle,
} from './OperationsPageShell'
interface AnalyticsOperationsPageProps {
initialStats?: ExplorerStats | null
initialTransactionTrend?: ExplorerTransactionTrendPoint[]
initialActivitySnapshot?: ExplorerRecentActivitySnapshot | null
initialBlocks?: Block[]
initialTransactions?: Transaction[]
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
}
function getChainStatus(bridgeStatus: MissionControlBridgeStatusResponse | null): MissionControlChainStatus | null {
const chains = bridgeStatus?.data?.chains
if (!chains) return null
@@ -24,11 +40,20 @@ function getChainStatus(bridgeStatus: MissionControlBridgeStatusResponse | null)
return firstChain || null
}
export default function AnalyticsOperationsPage() {
const [stats, setStats] = useState<ExplorerStats | null>(null)
const [blocks, setBlocks] = useState<Block[]>([])
const [transactions, setTransactions] = useState<Transaction[]>([])
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
export default function AnalyticsOperationsPage({
initialStats = null,
initialTransactionTrend = [],
initialActivitySnapshot = null,
initialBlocks = [],
initialTransactions = [],
initialBridgeStatus = null,
}: AnalyticsOperationsPageProps) {
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [transactionTrend, setTransactionTrend] = useState<ExplorerTransactionTrendPoint[]>(initialTransactionTrend)
const [activitySnapshot, setActivitySnapshot] = useState<ExplorerRecentActivitySnapshot | null>(initialActivitySnapshot)
const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.analytics
@@ -36,8 +61,10 @@ export default function AnalyticsOperationsPage() {
let cancelled = false
const load = async () => {
const [statsResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
const [statsResult, trendResult, snapshotResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
statsApi.get(),
statsApi.getTransactionTrend(),
statsApi.getRecentActivitySnapshot(),
blocksApi.list({ chain_id: 138, page: 1, page_size: 5 }),
transactionsApi.list(138, 1, 5),
missionControlApi.getBridgeStatus(),
@@ -46,15 +73,17 @@ export default function AnalyticsOperationsPage() {
if (cancelled) return
if (statsResult.status === 'fulfilled') setStats(statsResult.value)
if (trendResult.status === 'fulfilled') setTransactionTrend(trendResult.value)
if (snapshotResult.status === 'fulfilled') setActivitySnapshot(snapshotResult.value)
if (blocksResult.status === 'fulfilled') setBlocks(blocksResult.value.data)
if (transactionsResult.status === 'fulfilled') setTransactions(transactionsResult.value.data)
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
const failedCount = [statsResult, blocksResult, transactionsResult, bridgeResult].filter(
const failedCount = [statsResult, trendResult, snapshotResult, blocksResult, transactionsResult, bridgeResult].filter(
(result) => result.status === 'rejected'
).length
if (failedCount === 4) {
if (failedCount === 6) {
setLoadingError('Analytics data is temporarily unavailable from the public explorer APIs.')
}
}
@@ -71,6 +100,27 @@ export default function AnalyticsOperationsPage() {
}, [])
const chainStatus = useMemo(() => getChainStatus(bridgeStatus), [bridgeStatus])
const trailingWindow = useMemo(() => transactionTrend.slice(0, 7), [transactionTrend])
const sevenDayAverage = useMemo(() => {
if (trailingWindow.length === 0) return 0
const total = trailingWindow.reduce((sum, point) => sum + point.transaction_count, 0)
return total / trailingWindow.length
}, [trailingWindow])
const topDay = useMemo(() => {
if (trailingWindow.length === 0) return null
return trailingWindow.reduce((best, point) => (point.transaction_count > best.transaction_count ? point : best))
}, [trailingWindow])
const averageGasUtilization = useMemo(() => {
if (blocks.length === 0) return 0
return blocks.reduce((sum, block) => {
const ratio = block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0
return sum + ratio
}, 0) / blocks.length
}, [blocks])
const trendPeak = useMemo(
() => trailingWindow.reduce((max, point) => Math.max(max, point.transaction_count), 0),
[trailingWindow],
)
return (
<OperationsPageShell page={page}>
@@ -107,9 +157,111 @@ export default function AnalyticsOperationsPage() {
: 'Latest public RPC head age from mission control.'
}
/>
<MetricCard
title="7d Avg Tx"
value={formatNumber(Math.round(sevenDayAverage))}
description="Average daily transactions over the latest seven charted days."
className="border border-violet-200 bg-violet-50/70 dark:border-violet-900/50 dark:bg-violet-950/20"
/>
<MetricCard
title="Recent Success Rate"
value={activitySnapshot ? `${Math.round(activitySnapshot.success_rate * 100)}%` : 'Unknown'}
description="Success rate across the public main-page transaction sample."
/>
<MetricCard
title="Failure Rate"
value={activitySnapshot ? `${Math.round(activitySnapshot.failure_rate * 100)}%` : 'Unknown'}
description="The complement to the recent success rate in the visible sample."
className="border border-rose-200 bg-rose-50/70 dark:border-rose-900/50 dark:bg-rose-950/20"
/>
<MetricCard
title="Avg Gas Used"
value={activitySnapshot ? formatNumber(Math.round(activitySnapshot.average_gas_used)) : 'Unknown'}
description="Average gas used in the recent sampled transactions."
/>
<MetricCard
title="Avg Block Gas"
value={`${Math.round(averageGasUtilization * 100)}%`}
description="Average gas utilization across the latest visible blocks."
className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20"
/>
</div>
<div className="mb-8 grid gap-6 lg:grid-cols-[1fr_1fr]">
<Card title="Activity Trend">
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<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">Peak Day</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{topDay ? formatNumber(topDay.transaction_count) : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{topDay?.date || 'No trend data yet'}</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">Contract Creations</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{formatNumber(activitySnapshot?.contract_creations)}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Within the sampled recent transaction feed.</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">Avg Sample Fee</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{activitySnapshot ? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6) : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Average fee from the recent public transaction sample.</div>
</div>
</div>
{activitySnapshot ? (
<div className="grid gap-3 sm:grid-cols-3">
<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">Token Transfer Share</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{Math.round(activitySnapshot.token_transfer_share * 100)}%
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions involving token transfers.</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">Contract Call Share</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{Math.round(activitySnapshot.contract_call_share * 100)}%
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions calling contracts.</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">Creation Share</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{Math.round(activitySnapshot.contract_creation_share * 100)}%
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Sampled transactions deploying contracts.</div>
</div>
</div>
) : null}
<div className="space-y-3">
{trailingWindow.map((point) => {
const width = trendPeak > 0 ? Math.max(8, Math.round((point.transaction_count / trendPeak) * 100)) : 0
return (
<div key={point.date}>
<div className="mb-1 flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span>{point.date}</span>
<span>{formatNumber(point.transaction_count)} tx</span>
</div>
<div className="h-2 rounded-full bg-gray-200 dark:bg-gray-800">
<div className="h-2 rounded-full bg-primary-600" style={{ width: `${width}%` }} />
</div>
</div>
)
})}
{trailingWindow.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Trend data is temporarily unavailable.</p>
) : null}
</div>
</div>
</Card>
<Card title="Recent Blocks">
<div className="space-y-4">
{blocks.map((block) => (
@@ -119,15 +271,18 @@ export default function AnalyticsOperationsPage() {
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-base font-semibold text-gray-900 dark:text-white">
<Link href={`/blocks/${block.number}`} className="text-base font-semibold text-primary-600 hover:underline">
Block {formatNumber(block.number)}
</div>
</Link>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{truncateMiddle(block.hash)} · miner {truncateMiddle(block.miner)}
{truncateMiddle(block.hash)} · miner{' '}
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
{truncateMiddle(block.miner)}
</Link>
</div>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{formatNumber(block.transaction_count)} tx · {relativeAge(block.timestamp)}
{formatNumber(block.transaction_count)} tx · {Math.round((block.gas_limit > 0 ? block.gas_used / block.gas_limit : 0) * 100)}% gas · {relativeAge(block.timestamp)}
</div>
</div>
</div>
@@ -147,11 +302,26 @@ export default function AnalyticsOperationsPage() {
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-base font-semibold text-gray-900 dark:text-white">
<Link href={`/transactions/${transaction.hash}`} className="text-base font-semibold text-primary-600 hover:underline">
{truncateMiddle(transaction.hash, 12, 10)}
</div>
</Link>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Block {formatNumber(transaction.block_number)} · from {truncateMiddle(transaction.from_address)}
Block{' '}
<Link href={`/blocks/${transaction.block_number}`} className="text-primary-600 hover:underline">
{formatNumber(transaction.block_number)}
</Link>
{' '}· from{' '}
<Link href={`/addresses/${transaction.from_address}`} className="text-primary-600 hover:underline">
{truncateMiddle(transaction.from_address)}
</Link>
{transaction.to_address ? (
<>
{' '}· to{' '}
<Link href={`/addresses/${transaction.to_address}`} className="text-primary-600 hover:underline">
{truncateMiddle(transaction.to_address)}
</Link>
</>
) : null}
</div>
</div>
<div className="flex items-center gap-3">
@@ -164,6 +334,13 @@ export default function AnalyticsOperationsPage() {
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
{transaction.method ? <StatusBadge status={transaction.method} tone="warning" /> : null}
{transaction.contract_address ? <StatusBadge status="contract creation" tone="warning" /> : null}
{transaction.token_transfers && transaction.token_transfers.length > 0 ? (
<StatusBadge status={`${transaction.token_transfers.length} token transfer${transaction.token_transfers.length === 1 ? '' : 's'}`} />
) : null}
</div>
</div>
))}
{transactions.length === 0 ? (

View File

@@ -100,9 +100,13 @@ function ActionLink({
)
}
export default function BridgeMonitoringPage() {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
const [feedState, setFeedState] = useState<FeedState>('connecting')
export default function BridgeMonitoringPage({
initialBridgeStatus = null,
}: {
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
}) {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [feedState, setFeedState] = useState<FeedState>(initialBridgeStatus ? 'fallback' : 'connecting')
const page = explorerFeaturePages.bridge
useEffect(() => {

View File

@@ -30,21 +30,55 @@ interface TokenPoolRecord {
pools: MissionControlLiquidityPool[]
}
interface EndpointCard {
name: string
method: string
href: string
notes: string
}
interface LiquidityOperationsPageProps {
initialTokenList?: TokenListResponse | null
initialRouteMatrix?: RouteMatrixResponse | null
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
initialInternalPlan?: InternalExecutionPlanResponse | null
initialTokenPoolRecords?: TokenPoolRecord[]
}
function routePairLabel(routeId: string, routeLabel: string, tokenIn?: string, tokenOut?: string): string {
return [tokenIn, tokenOut].filter(Boolean).join(' / ') || routeLabel || routeId
}
export default function LiquidityOperationsPage() {
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>([])
export default function LiquidityOperationsPage({
initialTokenList = null,
initialRouteMatrix = null,
initialPlannerCapabilities = null,
initialInternalPlan = null,
initialTokenPoolRecords = [],
}: LiquidityOperationsPageProps) {
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>(initialTokenPoolRecords)
const [loadingError, setLoadingError] = useState<string | null>(null)
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
if (
initialTokenList &&
initialRouteMatrix &&
initialPlannerCapabilities &&
initialInternalPlan &&
initialTokenPoolRecords.length > 0
) {
return () => {
cancelled = true
}
}
const load = async () => {
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult] =
await Promise.allSettled([
@@ -102,7 +136,13 @@ export default function LiquidityOperationsPage() {
return () => {
cancelled = true
}
}, [])
}, [
initialInternalPlan,
initialPlannerCapabilities,
initialRouteMatrix,
initialTokenList,
initialTokenPoolRecords,
])
const featuredTokens = useMemo(
() => selectFeaturedLiquidityTokens(tokenList?.tokens || []),
@@ -139,7 +179,7 @@ export default function LiquidityOperationsPage() {
[routeMatrix, aggregatedPools.length, featuredTokens.length, livePlannerProviders.length, internalPlan?.plannerResponse?.decision, routeBackedPoolAddresses.length]
)
const endpointCards = [
const endpointCards: EndpointCard[] = [
{
name: 'Canonical route matrix',
method: 'GET',
@@ -166,6 +206,18 @@ export default function LiquidityOperationsPage() {
},
]
const copyEndpoint = async (endpoint: EndpointCard) => {
try {
await navigator.clipboard.writeText(endpoint.href)
setCopiedEndpoint(endpoint.name)
window.setTimeout(() => {
setCopiedEndpoint((current) => (current === endpoint.name ? null : current))
}, 1500)
} catch {
setCopiedEndpoint(null)
}
}
return (
<main className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-8 max-w-4xl">
@@ -258,9 +310,16 @@ export default function LiquidityOperationsPage() {
</div>
))}
{aggregatedPools.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
No live pool inventory is available right now.
</p>
<div className="rounded-2xl border border-amber-200 bg-amber-50/70 p-4 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-900 dark:text-amber-100">
Mission-control pool inventory is currently empty, but the live route matrix still references{' '}
{formatNumber(routeBackedPoolAddresses.length)} pool-backed legs across{' '}
{formatNumber(routeMatrix?.counts?.filteredLiveRoutes)} published live routes.
</p>
<p className="mt-2 text-sm leading-6 text-amber-900/80 dark:text-amber-100/80">
Use the highlighted route-backed paths below and the public route matrix endpoint while pool inventory catches up.
</p>
</div>
) : null}
</div>
</Card>
@@ -339,12 +398,9 @@ export default function LiquidityOperationsPage() {
<Card title="Explorer Access Points">
<div className="grid gap-4 md:grid-cols-2">
{endpointCards.map((endpoint) => (
<a
<div
key={endpoint.href}
href={endpoint.href}
target="_blank"
rel="noopener noreferrer"
className="rounded-2xl border border-gray-200 bg-white p-5 transition hover:border-primary-400 hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-base font-semibold text-gray-900 dark:text-white">{endpoint.name}</div>
@@ -356,7 +412,24 @@ export default function LiquidityOperationsPage() {
{endpoint.href}
</div>
<div className="mt-3 text-sm leading-6 text-gray-600 dark:text-gray-400">{endpoint.notes}</div>
</a>
<div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
onClick={() => void copyEndpoint(endpoint)}
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
>
{copiedEndpoint === endpoint.name ? 'Copied' : 'Copy endpoint'}
</button>
{endpoint.name === 'Mission-control token pools' ? (
<Link
href="/pools"
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
>
Open pools page
</Link>
) : null}
</div>
</div>
))}
</div>
</Card>
@@ -404,12 +477,12 @@ export default function LiquidityOperationsPage() {
>
Open wallet tools
</Link>
<a
href="/docs.html"
<Link
href="/docs"
className="rounded-full border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
>
Explorer docs
</a>
</Link>
</div>
</div>
</Card>

View File

@@ -45,14 +45,28 @@ function ActionLink({
)
}
export default function MoreOperationsPage() {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(null)
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(null)
interface OperationsHubPageProps {
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
initialRouteMatrix?: RouteMatrixResponse | null
initialNetworksConfig?: NetworksConfigResponse | null
initialTokenList?: TokenListResponse | null
initialCapabilities?: CapabilitiesResponse | null
}
export default function OperationsHubPage({
initialBridgeStatus = null,
initialRouteMatrix = null,
initialNetworksConfig = null,
initialTokenList = null,
initialCapabilities = null,
}: OperationsHubPageProps) {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(initialNetworksConfig)
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(initialCapabilities)
const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.more
const page = explorerFeaturePages.operations
useEffect(() => {
let cancelled = false

View File

@@ -17,6 +17,13 @@ import OperationsPageShell, {
truncateMiddle,
} from './OperationsPageShell'
interface OperatorOperationsPageProps {
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
initialRouteMatrix?: RouteMatrixResponse | null
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
initialInternalPlan?: InternalExecutionPlanResponse | null
}
function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
const normalized = String(status || 'unknown').toLowerCase()
if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger'
@@ -24,11 +31,16 @@ function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
return 'normal'
}
export default function OperatorOperationsPage() {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
export default function OperatorOperationsPage({
initialBridgeStatus = null,
initialRouteMatrix = null,
initialPlannerCapabilities = null,
initialInternalPlan = null,
}: OperatorOperationsPageProps) {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.operator

View File

@@ -200,7 +200,7 @@ export default function PoolsOperationsPage() {
</div>
</Card>
<Card title="Liquidity Shortcuts">
<Card title="Pool operation shortcuts">
<div className="space-y-4 text-sm leading-6 text-gray-600 dark:text-gray-400">
<p>
The broader liquidity page now shows live route, planner, and pool access together.

View File

@@ -10,6 +10,12 @@ import {
type RouteMatrixResponse,
} from '@/services/api/routes'
interface RoutesMonitoringPageProps {
initialRouteMatrix?: RouteMatrixResponse | null
initialNetworks?: ExplorerNetwork[]
initialPools?: MissionControlLiquidityPool[]
}
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
function relativeAge(isoString?: string): string {
@@ -80,10 +86,14 @@ function ActionLink({
)
}
export default function RoutesMonitoringPage() {
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
const [networks, setNetworks] = useState<ExplorerNetwork[]>([])
const [pools, setPools] = useState<MissionControlLiquidityPool[]>([])
export default function RoutesMonitoringPage({
initialRouteMatrix = null,
initialNetworks = [],
initialPools = [],
}: RoutesMonitoringPageProps) {
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [networks, setNetworks] = useState<ExplorerNetwork[]>(initialNetworks)
const [pools, setPools] = useState<MissionControlLiquidityPool[]>(initialPools)
const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.routes
@@ -389,7 +399,7 @@ export default function RoutesMonitoringPage() {
<ActionLink
href={action.href}
label={action.label}
external={'external' in action ? action.external : undefined}
external={Boolean((action as { external?: boolean }).external)}
/>
</div>
</div>

View File

@@ -12,13 +12,29 @@ import OperationsPageShell, {
relativeAge,
} from './OperationsPageShell'
export default function SystemOperationsPage() {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(null)
const [tokenList, setTokenList] = useState<TokenListResponse | null>(null)
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(null)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(null)
const [stats, setStats] = useState<ExplorerStats | null>(null)
interface SystemOperationsPageProps {
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
initialNetworksConfig?: NetworksConfigResponse | null
initialTokenList?: TokenListResponse | null
initialCapabilities?: CapabilitiesResponse | null
initialRouteMatrix?: RouteMatrixResponse | null
initialStats?: ExplorerStats | null
}
export default function SystemOperationsPage({
initialBridgeStatus = null,
initialNetworksConfig = null,
initialTokenList = null,
initialCapabilities = null,
initialRouteMatrix = null,
initialStats = null,
}: SystemOperationsPageProps) {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(initialNetworksConfig)
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(initialCapabilities)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.system

View File

@@ -16,6 +16,12 @@ import OperationsPageShell, {
truncateMiddle,
} from './OperationsPageShell'
interface WethOperationsPageProps {
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
initialInternalPlan?: InternalExecutionPlanResponse | null
}
function relayTone(status?: string): 'normal' | 'warning' | 'danger' {
const normalized = String(status || 'unknown').toLowerCase()
if (['degraded', 'stale', 'stopped', 'down'].includes(normalized)) return 'danger'
@@ -27,10 +33,14 @@ function relaySnapshot(relay: MissionControlRelayPayload | undefined) {
return relay?.url_probe?.body || relay?.file_snapshot
}
export default function WethOperationsPage() {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(null)
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(null)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(null)
export default function WethOperationsPage({
initialBridgeStatus = null,
initialPlannerCapabilities = null,
initialInternalPlan = null,
}: WethOperationsPageProps) {
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
const [loadingError, setLoadingError] = useState<string | null>(null)
const page = explorerFeaturePages.weth
@@ -85,6 +95,13 @@ export default function WethOperationsPage() {
return (
<OperationsPageShell page={page}>
<Card className="mb-6 border border-amber-200 bg-amber-50/70 dark:border-amber-900/50 dark:bg-amber-950/20">
<p className="text-sm leading-6 text-amber-950 dark:text-amber-100">
These WETH references are bridge and transport surfaces, not a claim that Ethereum mainnet WETH contracts are native Chain 138 assets.
Use this page to review wrapped-asset lane posture, counterpart contracts, and operational dependencies.
</p>
</Card>
{loadingError ? (
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
<p className="text-sm leading-6 text-red-900 dark:text-red-100">{loadingError}</p>

View File

@@ -0,0 +1,427 @@
import { useCallback, useEffect, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives/Card'
import Link from 'next/link'
import { blocksApi, type Block } from '@/services/api/blocks'
import {
statsApi,
type ExplorerRecentActivitySnapshot,
type ExplorerStats,
type ExplorerTransactionTrendPoint,
} from '@/services/api/stats'
import {
missionControlApi,
type MissionControlRelaySummary,
} from '@/services/api/missionControl'
import { loadDashboardData } from '@/utils/dashboard'
import EntityBadge from '@/components/common/EntityBadge'
import { formatTimestamp, formatWeiAsEth } from '@/utils/format'
type HomeStats = ExplorerStats
interface HomePageProps {
initialStats?: HomeStats | null
initialRecentBlocks?: Block[]
initialTransactionTrend?: ExplorerTransactionTrendPoint[]
initialActivitySnapshot?: ExplorerRecentActivitySnapshot | null
initialRelaySummary?: MissionControlRelaySummary | null
}
export default function Home({
initialStats = null,
initialRecentBlocks = [],
initialTransactionTrend = [],
initialActivitySnapshot = null,
initialRelaySummary = null,
}: HomePageProps) {
const [stats, setStats] = useState<HomeStats | null>(initialStats)
const [recentBlocks, setRecentBlocks] = useState<Block[]>(initialRecentBlocks)
const [transactionTrend, setTransactionTrend] = useState<ExplorerTransactionTrendPoint[]>(initialTransactionTrend)
const [activitySnapshot, setActivitySnapshot] = useState<ExplorerRecentActivitySnapshot | null>(initialActivitySnapshot)
const [relaySummary, setRelaySummary] = useState<MissionControlRelaySummary | null>(initialRelaySummary)
const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>(
initialRelaySummary ? 'fallback' : 'connecting'
)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const latestBlock = stats?.latest_block ?? recentBlocks[0]?.number ?? null
const loadDashboard = useCallback(async () => {
const dashboardData = await loadDashboardData({
loadStats: () => statsApi.get(),
loadRecentTransactionTrend: () => statsApi.getTransactionTrend(),
loadRecentBlocks: async () => {
const response = await blocksApi.list({
chain_id: chainId,
page: 1,
page_size: 10,
})
return response.data
},
onError: (scope, error) => {
if (process.env.NODE_ENV !== 'production') {
console.warn(`Failed to load dashboard ${scope}:`, error)
}
},
})
setStats((current) => dashboardData.stats ?? current)
setRecentBlocks((current) => (dashboardData.recentBlocks.length > 0 ? dashboardData.recentBlocks : current))
setTransactionTrend((current) =>
(dashboardData.recentTransactionTrend || []).length > 0 ? dashboardData.recentTransactionTrend : current,
)
}, [chainId])
useEffect(() => {
loadDashboard()
}, [loadDashboard])
useEffect(() => {
let cancelled = false
statsApi.getRecentActivitySnapshot().then((snapshot) => {
if (!cancelled) {
setActivitySnapshot(snapshot)
}
}).catch((error) => {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to load recent activity snapshot:', error)
}
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
let cancelled = false
const loadSnapshot = async () => {
try {
const summary = await missionControlApi.getRelaySummary()
if (!cancelled) {
setRelaySummary(summary)
}
} catch (error) {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to load mission control relay summary:', error)
}
}
}
loadSnapshot()
const unsubscribe = missionControlApi.subscribeRelaySummary(
(summary) => {
if (!cancelled) {
setRelaySummary(summary)
setRelayFeedState('live')
}
},
(error) => {
if (!cancelled) {
setRelayFeedState('fallback')
}
if (process.env.NODE_ENV !== 'production') {
console.warn('Mission control live stream update issue:', error)
}
}
)
return () => {
cancelled = true
unsubscribe()
}
}, [])
const relayToneClasses =
relaySummary?.tone === 'danger'
? 'border-red-200 bg-red-50 text-red-900 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100'
: relaySummary?.tone === 'warning'
? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100'
: 'border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-100'
const latestTrendPoint = transactionTrend[0] || null
const peakTrendPoint = transactionTrend.reduce<ExplorerTransactionTrendPoint | null>(
(best, point) => (!best || point.transaction_count > best.transaction_count ? point : best),
null,
)
const relayAttentionCount = relaySummary?.items.filter((item) => item.tone !== 'normal').length || 0
const relayOperationalCount = relaySummary?.items.filter((item) => item.tone === 'normal').length || 0
const relayPrimaryItems = relaySummary?.items.slice(0, 6) || []
return (
<main className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-6 sm:mb-8">
<h1 className="mb-2 text-3xl font-bold sm:text-4xl">SolaceScan</h1>
<p className="text-base text-gray-600 dark:text-gray-400 sm:text-lg">Chain 138 Explorer by DBIS</p>
</div>
{relaySummary && (
<Card className={`mb-6 border shadow-sm ${relayToneClasses}`}>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-3xl">
<div className="text-sm font-semibold uppercase tracking-[0.22em] opacity-75">Mission Control</div>
<div className="mt-2 text-xl font-semibold sm:text-2xl">
{relaySummary.tone === 'danger'
? 'Relay lanes need attention'
: relaySummary.tone === 'warning'
? 'Relay lanes are degraded'
: 'Relay lanes are operational'}
</div>
<p className="mt-2 text-sm leading-6 opacity-90 sm:text-base">
{relaySummary.text}. This surface summarizes the public relay posture in a compact operator-friendly format.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<EntityBadge
label={relayFeedState === 'live' ? 'live sse' : relayFeedState === 'fallback' ? 'snapshot fallback' : 'connecting'}
tone={relayFeedState === 'fallback' ? 'warning' : relayFeedState === 'connecting' ? 'info' : 'success'}
/>
<EntityBadge
label={relaySummary.tone === 'danger' ? 'attention needed' : relaySummary.tone === 'warning' ? 'degraded' : 'operational'}
tone={relaySummary.tone === 'danger' ? 'warning' : relaySummary.tone === 'warning' ? 'info' : 'success'}
/>
<EntityBadge label={`${relayOperationalCount} operational`} tone="success" />
<EntityBadge label={`${relayAttentionCount} flagged`} tone={relayAttentionCount > 0 ? 'warning' : 'info'} />
</div>
</div>
<div className="grid min-w-[220px] gap-3 sm:grid-cols-2 lg:w-[290px] lg:grid-cols-1">
<div className="rounded-2xl border border-white/40 bg-white/50 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide opacity-70">Live Feed</div>
<div className="mt-2 text-lg font-semibold">
{relayFeedState === 'live' ? 'Streaming' : relayFeedState === 'fallback' ? 'Snapshot mode' : 'Connecting'}
</div>
<div className="mt-1 text-sm opacity-80">
{relayFeedState === 'live'
? 'Receiving named mission-control events.'
: relayFeedState === 'fallback'
? 'Using the latest available snapshot.'
: 'Negotiating the event stream.'}
</div>
</div>
<div className="flex flex-col gap-2">
<Link
href="/operations"
className="inline-flex items-center justify-center rounded-xl bg-gray-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-black dark:bg-white dark:text-gray-900 dark:hover:bg-gray-100"
>
Open operations hub
</Link>
<Link
href="/explorer-api/v1/mission-control/stream"
className="inline-flex items-center justify-center rounded-xl border border-current/20 px-4 py-2.5 text-sm font-semibold hover:bg-white/40 dark:hover:bg-black/10"
>
Open live stream
</Link>
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{relayPrimaryItems.map((item) => (
<div
key={item.key}
className="rounded-2xl border border-white/40 bg-white/55 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold">{item.label}</div>
<div className="mt-1 text-xs uppercase tracking-wide opacity-70">{item.status}</div>
</div>
<EntityBadge
label={item.tone === 'danger' ? 'flagged' : item.tone === 'warning' ? 'degraded' : 'live'}
tone={item.tone === 'danger' ? 'warning' : item.tone === 'warning' ? 'info' : 'success'}
/>
</div>
<p className="mt-3 text-sm leading-6 opacity-90">{item.text}</p>
</div>
))}
</div>
{relaySummary.items.length > relayPrimaryItems.length ? (
<div className="text-sm opacity-80">
Showing {relayPrimaryItems.length} of {relaySummary.items.length} relay lanes. The live stream and operations hub carry the fuller view.
</div>
) : null}
</div>
</Card>
)}
{stats && (
<div className="mb-8 grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Latest Block</div>
<div className="text-xl font-bold sm:text-2xl">
{latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'}
</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Blocks</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_blocks.toLocaleString()}</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Transactions</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_transactions.toLocaleString()}</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Addresses</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_addresses.toLocaleString()}</div>
</Card>
</div>
)}
{!stats && (
<Card className="mb-8">
<p className="text-sm text-gray-600 dark:text-gray-400">
Live network stats are temporarily unavailable. Recent blocks and explorer tools are still available below.
</p>
</Card>
)}
<Card title="Recent Blocks">
{recentBlocks.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent blocks are unavailable right now.
</p>
) : (
<div className="space-y-2">
{recentBlocks.map((block) => (
<div key={block.number} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link href={`/blocks/${block.number}`} className="text-primary-600 hover:underline">
Block #{block.number}
</Link>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Mined by{' '}
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
{block.miner.slice(0, 10)}...{block.miner.slice(-6)}
</Link>
</div>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 sm:text-right">
<div>{block.transaction_count} transactions</div>
<div className="text-xs">{formatTimestamp(block.timestamp)}</div>
</div>
</div>
))}
</div>
)}
<div className="mt-4">
<Link href="/blocks" className="text-primary-600 hover:underline">
View all blocks
</Link>
</div>
</Card>
<div className="mt-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="Activity Pulse">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
A concise public view of chain activity, index coverage, and recent execution patterns.
</p>
<div className="mt-4 grid gap-3 sm: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">Latest Daily Volume</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{latestTrendPoint ? latestTrendPoint.transaction_count.toLocaleString() : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{latestTrendPoint?.date || 'Trend feed unavailable'}</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">Recent Success Rate</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{activitySnapshot ? `${Math.round(activitySnapshot.success_rate * 100)}%` : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{activitySnapshot ? `${activitySnapshot.sample_size} sampled transactions` : 'Recent activity snapshot unavailable'}
</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">Avg Recent Fee</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{activitySnapshot ? formatWeiAsEth(Math.round(activitySnapshot.average_fee_wei).toString(), 6) : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Average fee from the recent public sample.</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">Peak Charted Day</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{peakTrendPoint ? peakTrendPoint.transaction_count.toLocaleString() : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">{peakTrendPoint?.date || 'No trend data yet'}</div>
</div>
</div>
<div className="mt-4">
<Link href="/analytics" className="text-primary-600 hover:underline">
Open full analytics
</Link>
</div>
</Card>
<Card title="Explorer Shortcuts">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Go directly to the explorer surfaces that provide the strongest operational and discovery context.
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<Link href="/search" className="text-primary-600 hover:underline">
Search
</Link>
<Link href="/transactions" className="text-primary-600 hover:underline">
Transactions
</Link>
<Link href="/tokens" className="text-primary-600 hover:underline">
Tokens
</Link>
<Link href="/addresses" className="text-primary-600 hover:underline">
Addresses
</Link>
<Link href="/analytics" className="text-primary-600 hover:underline">
Analytics
</Link>
</div>
</Card>
<Card title="Liquidity & Routes">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Explore the public Chain 138 DODO PMM liquidity mesh, the canonical route matrix, and the
partner payload endpoints exposed through the explorer.
</p>
<div className="mt-4">
<Link href="/routes" className="text-primary-600 hover:underline">
Open routes and liquidity
</Link>
</div>
</Card>
<Card title="Wallet & Token Discovery">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Add Chain 138, Ethereum Mainnet, and ALL Mainnet to MetaMask, then use the explorer token
list URL so supported tokens appear automatically.
</p>
<div className="mt-4">
<Link href="/wallet" className="text-primary-600 hover:underline">
Open wallet tools
</Link>
</div>
</Card>
<Card title="Bridge & Relay Monitoring">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Open the public bridge monitoring surface for relay status, mission-control links, bridge trace tooling,
and the visual command center entry points.
</p>
<div className="mt-4">
<Link href="/bridge" className="text-primary-600 hover:underline">
Open bridge monitoring
</Link>
</div>
</Card>
<Card title="Operations Hub">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Open the public operations surface for wrapped-asset references, analytics shortcuts, operator links,
system topology views, and other Chain 138 support tools.
</p>
<div className="mt-4">
<Link href="/operations" className="text-primary-600 hover:underline">
Open operations hub
</Link>
</div>
</Card>
</div>
</main>
)
}

View File

@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from 'react'
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
type WalletChain = {
export type WalletChain = {
chainId: string
chainIdDecimal?: number
chainName: string
@@ -20,7 +20,7 @@ type WalletChain = {
explorerApiUrl?: string
}
type TokenListToken = {
export type TokenListToken = {
chainId: number
address: string
name: string
@@ -31,7 +31,7 @@ type TokenListToken = {
extensions?: Record<string, unknown>
}
type NetworksCatalog = {
export type NetworksCatalog = {
name?: string
version?: {
major?: number
@@ -42,7 +42,7 @@ type NetworksCatalog = {
chains?: WalletChain[]
}
type TokenListCatalog = {
export type TokenListCatalog = {
name?: string
version?: {
major?: number
@@ -53,7 +53,7 @@ type TokenListCatalog = {
tokens?: TokenListToken[]
}
type CapabilitiesCatalog = {
export type CapabilitiesCatalog = {
name?: string
version?: {
major?: number
@@ -84,11 +84,20 @@ type CapabilitiesCatalog = {
}
}
type FetchMetadata = {
export type FetchMetadata = {
source?: string | null
lastModified?: string | null
}
interface AddToMetaMaskProps {
initialNetworks?: NetworksCatalog | null
initialTokenList?: TokenListCatalog | null
initialCapabilities?: CapabilitiesCatalog | null
initialNetworksMeta?: FetchMetadata | null
initialTokenListMeta?: FetchMetadata | null
initialCapabilitiesMeta?: FetchMetadata | null
}
type EthereumProvider = {
request: (args: { method: string; params?: unknown }) => Promise<unknown>
}
@@ -99,7 +108,7 @@ const FALLBACK_CHAIN_138: WalletChain = {
chainName: 'DeFi Oracle Meta Mainnet',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'],
blockExplorerUrls: ['https://explorer.d-bis.org'],
blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'],
iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'],
shortName: 'dbis',
infoURL: 'https://explorer.d-bis.org',
@@ -139,7 +148,7 @@ const FALLBACK_CAPABILITIES_138: CapabilitiesCatalog = {
name: 'Chain 138 RPC Capabilities',
version: { major: 1, minor: 1, patch: 0 },
timestamp: '2026-03-28T00:00:00Z',
generatedBy: 'SolaceScanScout',
generatedBy: 'SolaceScan',
chainId: 138,
chainName: 'DeFi Oracle Meta Mainnet',
rpcUrl: 'https://rpc-http-pub.d-bis.org',
@@ -211,19 +220,39 @@ function isCapabilitiesCatalog(value: unknown): value is CapabilitiesCatalog {
function getApiBase() {
return resolveExplorerApiBase({
serverFallback: 'https://explorer.d-bis.org',
serverFallback: 'https://blockscout.defi-oracle.io',
})
}
export function AddToMetaMask() {
export function AddToMetaMask({
initialNetworks = null,
initialTokenList = null,
initialCapabilities = null,
initialNetworksMeta = null,
initialTokenListMeta = null,
initialCapabilitiesMeta = null,
}: AddToMetaMaskProps) {
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [networks, setNetworks] = useState<NetworksCatalog | null>(null)
const [tokenList, setTokenList] = useState<TokenListCatalog | null>(null)
const [capabilities, setCapabilities] = useState<CapabilitiesCatalog | null>(null)
const [networksMeta, setNetworksMeta] = useState<FetchMetadata | null>(null)
const [tokenListMeta, setTokenListMeta] = useState<FetchMetadata | null>(null)
const [capabilitiesMeta, setCapabilitiesMeta] = useState<FetchMetadata | null>(null)
const [networks, setNetworks] = useState<NetworksCatalog | null>(initialNetworks)
const [tokenList, setTokenList] = useState<TokenListCatalog | null>(initialTokenList)
const [capabilities, setCapabilities] = useState<CapabilitiesCatalog | null>(
initialCapabilities || FALLBACK_CAPABILITIES_138,
)
const [networksMeta, setNetworksMeta] = useState<FetchMetadata | null>(initialNetworksMeta)
const [tokenListMeta, setTokenListMeta] = useState<FetchMetadata | null>(initialTokenListMeta)
const [capabilitiesMeta, setCapabilitiesMeta] = useState<FetchMetadata | null>(
initialCapabilitiesMeta ||
(initialCapabilities
? {
source: 'explorer-api',
lastModified: initialCapabilities.timestamp || null,
}
: {
source: 'frontend-fallback',
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
}),
)
const ethereum = typeof window !== 'undefined'
? (window as unknown as { ethereum?: EthereumProvider }).ethereum
@@ -251,7 +280,7 @@ export function AddToMetaMask() {
})
const json = response.ok ? await response.json() : null
const meta: FetchMetadata = {
source: response.headers.get('X-Config-Source'),
source: response.headers.get('X-Config-Source') || 'explorer-api',
lastModified: response.headers.get('Last-Modified'),
}
return { json, meta }
@@ -296,15 +325,17 @@ export function AddToMetaMask() {
setCapabilitiesMeta(resolvedCapabilities.meta)
} catch {
if (!active) return
setNetworks(null)
setTokenList(null)
setCapabilities(FALLBACK_CAPABILITIES_138)
setNetworksMeta(null)
setTokenListMeta(null)
setCapabilitiesMeta({
source: 'frontend-fallback',
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
})
setNetworks((current) => current)
setTokenList((current) => current)
setCapabilities((current) => current || FALLBACK_CAPABILITIES_138)
setNetworksMeta((current) => current)
setTokenListMeta((current) => current)
setCapabilitiesMeta((current) =>
current || {
source: 'frontend-fallback',
lastModified: FALLBACK_CAPABILITIES_138.timestamp || null,
},
)
} finally {
if (active) {
timer = setTimeout(() => {

View File

@@ -1,14 +1,29 @@
import type {
CapabilitiesCatalog,
FetchMetadata,
NetworksCatalog,
TokenListCatalog,
} from '@/components/wallet/AddToMetaMask'
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
import Link from 'next/link'
export default function WalletPage() {
interface WalletPageProps {
initialNetworks?: NetworksCatalog | null
initialTokenList?: TokenListCatalog | null
initialCapabilities?: CapabilitiesCatalog | null
initialNetworksMeta?: FetchMetadata | null
initialTokenListMeta?: FetchMetadata | null
initialCapabilitiesMeta?: FetchMetadata | null
}
export default function WalletPage(props: WalletPageProps) {
return (
<main className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-4 text-2xl font-bold sm:text-3xl">Wallet & MetaMask</h1>
<p className="mb-6 text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base">
Connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets. Use the token list URL so tokens and oracles are discoverable.
</p>
<AddToMetaMask />
<AddToMetaMask {...props} />
<div className="mt-6 rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
Need swap and liquidity discovery too? Visit the{' '}
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">

View File

@@ -15,7 +15,7 @@ export interface ExplorerFeaturePage {
}
const legacyNote =
'These tools were restored in the legacy explorer asset first. The live Next explorer now exposes them here so they are reachable from the public UI without falling back to hidden static routes.'
'These pages collect the public monitoring, route, wallet, and topology surfaces that support Chain 138 operations and investigation.'
export const explorerFeaturePages = {
bridge: {
@@ -72,7 +72,7 @@ export const explorerFeaturePages = {
eyebrow: 'Route Coverage',
title: 'Routes, Pools, and Execution Access',
description:
'Surface the route matrix, live pool inventory, public liquidity endpoints, and bridge-adjacent execution paths that were previously only visible in the legacy explorer shell.',
'Surface the route matrix, live pool inventory, public liquidity endpoints, and bridge-adjacent execution paths from one public explorer surface.',
note: legacyNote,
actions: [
{
@@ -88,11 +88,10 @@ export const explorerFeaturePages = {
label: 'Open pools page',
},
{
title: 'Liquidity mission-control example',
description: 'Open a live mission-control liquidity lookup for a canonical Chain 138 token.',
href: '/explorer-api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools',
label: 'Open liquidity JSON',
external: true,
title: 'Pools inventory',
description: 'Open the live pools page instead of dropping into a raw backend response.',
href: '/pools',
label: 'Open pools inventory',
},
{
title: 'Bridge monitoring',
@@ -103,7 +102,7 @@ export const explorerFeaturePages = {
{
title: 'Operations hub',
description: 'Open the consolidated page for WETH utilities, analytics, operator shortcuts, and system views.',
href: '/more',
href: '/operations',
label: 'Open operations hub',
},
],
@@ -137,7 +136,7 @@ export const explorerFeaturePages = {
{
title: 'Operations hub',
description: 'Return to the larger operations landing page for adjacent route, analytics, and system shortcuts.',
href: '/more',
href: '/operations',
label: 'Open operations hub',
},
],
@@ -180,7 +179,7 @@ export const explorerFeaturePages = {
eyebrow: 'Operator Shortcuts',
title: 'Operator Panel Shortcuts',
description:
'Expose the public operational shortcuts that were restored in the legacy explorer for bridge checks, route validation, liquidity entry points, and documentation.',
'Expose the public operational shortcuts for bridge checks, route validation, liquidity entry points, and documentation.',
note: legacyNote,
actions: [
{
@@ -203,10 +202,9 @@ export const explorerFeaturePages = {
},
{
title: 'Explorer docs',
description: 'Use the static documentation landing page for explorer-specific reference material.',
href: '/docs.html',
description: 'Open the canonical explorer documentation hub for GRU guidance, transaction evidence notes, and public reference material.',
href: '/docs',
label: 'Open docs',
external: true,
},
{
title: 'Visual command center',
@@ -239,24 +237,23 @@ export const explorerFeaturePages = {
},
{
title: 'Explorer docs',
description: 'Open the documentation landing page for static reference material shipped with the explorer.',
href: '/docs.html',
description: 'Open the canonical explorer documentation hub for public reference material and guide pages.',
href: '/docs',
label: 'Open docs',
external: true,
},
{
title: 'Operations hub',
description: 'Return to the consolidated operations landing page for adjacent public tools.',
href: '/more',
href: '/operations',
label: 'Open operations hub',
},
],
},
more: {
operations: {
eyebrow: 'Operations Hub',
title: 'More Explorer Tools',
title: 'Operations Hub',
description:
'This hub exposes the restored public tools that were previously buried in the legacy explorer shell: bridge monitoring, routes, WETH utilities, analytics shortcuts, operator links, and topology views.',
'This hub exposes the public operational surfaces for bridge monitoring, routes, wrapped-asset references, analytics shortcuts, operator links, and topology views.',
note: legacyNote,
actions: [
{

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,
}
}

View File

@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { accessApi } from './access'
describe('accessApi', () => {
beforeEach(() => {
vi.restoreAllMocks()
const store = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value)
},
removeItem: (key: string) => {
store.delete(key)
},
})
vi.stubGlobal('window', {
localStorage: globalThis.localStorage,
location: { origin: 'https://explorer.example.org' },
dispatchEvent: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})
})
it('stores the session token on login', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
user: {
id: 'user-1',
email: 'ops@example.org',
username: 'ops',
},
token: 'jwt-token',
expires_at: '2026-04-16T00:00:00Z',
}),
}),
)
const result = await accessApi.login('ops@example.org', 'secret-password')
expect(result.token).toBe('jwt-token')
expect(accessApi.getStoredAccessToken()).toBe('jwt-token')
})
it('sends scope, expiry, and quota fields when creating an API key', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
api_key: 'ek_test',
}),
})
vi.stubGlobal('fetch', fetchMock)
window.localStorage.setItem('explorer_access_token', 'jwt-token')
await accessApi.createAPIKey({
name: 'Thirdweb key',
tier: 'pro',
productSlug: 'thirdweb-rpc',
expiresDays: 30,
monthlyQuota: 250000,
scopes: ['rpc:read', 'rpc:write'],
})
const [, init] = fetchMock.mock.calls[0]
expect(init?.method).toBe('POST')
expect(init?.headers).toBeTruthy()
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer jwt-token')
expect(JSON.parse(String(init?.body))).toEqual({
name: 'Thirdweb key',
tier: 'pro',
product_slug: 'thirdweb-rpc',
expires_days: 30,
monthly_quota: 250000,
scopes: ['rpc:read', 'rpc:write'],
})
})
it('requests admin audit with limit and product filters', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
entries: [],
}),
})
vi.stubGlobal('fetch', fetchMock)
window.localStorage.setItem('explorer_access_token', 'jwt-token')
await accessApi.listAdminAudit(50, 'thirdweb-rpc')
const [url, init] = fetchMock.mock.calls[0]
expect(String(url)).toContain('/explorer-api/v1/access/admin/audit?limit=50&product=thirdweb-rpc')
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer jwt-token')
})
it('requests user audit with the selected entry limit', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
entries: [],
}),
})
vi.stubGlobal('fetch', fetchMock)
window.localStorage.setItem('explorer_access_token', 'jwt-token')
await accessApi.listAudit(10)
const [url, init] = fetchMock.mock.calls[0]
expect(String(url)).toContain('/explorer-api/v1/access/audit?limit=10')
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer jwt-token')
})
it('requests admin subscriptions with the selected status filter', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
subscriptions: [],
}),
})
vi.stubGlobal('fetch', fetchMock)
window.localStorage.setItem('explorer_access_token', 'jwt-token')
await accessApi.listAdminSubscriptions('suspended')
const [url, init] = fetchMock.mock.calls[0]
expect(String(url)).toContain('/explorer-api/v1/access/admin/subscriptions?status=suspended')
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer jwt-token')
})
it('creates a wallet nonce and stores the returned wallet session', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
nonce: 'nonce-123',
address: '0x4A666F96fC8764181194447A7dFdb7d471b301C8',
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
token: 'wallet-jwt',
expires_at: '2026-04-16T00:00:00Z',
track: 'wallet',
permissions: ['access'],
}),
})
vi.stubGlobal('fetch', fetchMock)
const nonceResponse = await accessApi.createWalletNonce('0x4A666F96fC8764181194447A7dFdb7d471b301C8')
const session = await accessApi.authenticateWallet(
'0x4A666F96fC8764181194447A7dFdb7d471b301C8',
'0xsigned',
nonceResponse.nonce,
)
expect(String(fetchMock.mock.calls[0][0])).toContain('/explorer-api/v1/auth/nonce')
expect(String(fetchMock.mock.calls[1][0])).toContain('/explorer-api/v1/auth/wallet')
expect(session.token).toBe('wallet-jwt')
expect(accessApi.getStoredWalletSession()?.address).toBe('0x4A666F96fC8764181194447A7dFdb7d471b301C8')
expect(accessApi.getStoredAccessToken()).toBe('wallet-jwt')
})
})

View File

@@ -0,0 +1,321 @@
import { getExplorerApiBase } from './blockscout'
declare global {
interface Window {
ethereum?: {
request: (args: { method: string; params?: unknown[] | object }) => Promise<unknown>
}
}
}
export interface AccessUser {
id: string
email: string
username: string
is_admin?: boolean
}
export interface AccessSession {
user: AccessUser
token: string
expires_at: string
}
export interface WalletAccessSession {
token: string
expiresAt: string
track: string
permissions: string[]
address: string
}
export interface AccessProduct {
slug: string
name: string
provider: string
vmid: number
http_url: string
ws_url?: string
default_tier: string
requires_approval: boolean
billing_model: string
description: string
use_cases: string[]
management_features: string[]
}
export interface AccessAPIKeyRecord {
id: string
name: string
tier: string
productSlug: string
scopes: string[]
monthlyQuota: number
requestsUsed: number
approved: boolean
approvedAt?: string | null
rateLimitPerSecond: number
rateLimitPerMinute: number
lastUsedAt?: string | null
expiresAt?: string | null
revoked: boolean
createdAt: string
}
export interface CreateAccessAPIKeyRequest {
name: string
tier: string
productSlug: string
expiresDays?: number
monthlyQuota?: number
scopes?: string[]
}
export interface AccessSubscription {
id: string
productSlug: string
tier: string
status: string
monthlyQuota: number
requestsUsed: number
requiresApproval: boolean
approvedAt?: string | null
approvedBy?: string | null
notes?: string | null
createdAt: string
}
export interface AccessUsageSummary {
product_slug: string
active_keys: number
requests_used: number
monthly_quota: number
}
export interface AccessAuditEntry {
id: number
apiKeyId: string
keyName: string
productSlug: string
methodName: string
requestCount: number
lastIp?: string | null
createdAt: string
}
const ACCESS_TOKEN_STORAGE_KEY = 'explorer_access_token'
const WALLET_SESSION_STORAGE_KEY = 'explorer_wallet_session'
const ACCESS_SESSION_EVENT = 'explorer-access-session-changed'
const ACCESS_API_PREFIX = '/explorer-api/v1'
function getStoredAccessToken(): string {
if (typeof window === 'undefined') return ''
return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY) || getStoredWalletSession()?.token || ''
}
function setStoredAccessToken(token: string) {
if (typeof window === 'undefined') return
if (token) {
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, token)
} else {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY)
}
window.dispatchEvent(new Event(ACCESS_SESSION_EVENT))
}
function getStoredWalletSession(): WalletAccessSession | null {
if (typeof window === 'undefined') return null
const raw = window.localStorage.getItem(WALLET_SESSION_STORAGE_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as WalletAccessSession
} catch {
window.localStorage.removeItem(WALLET_SESSION_STORAGE_KEY)
return null
}
}
function setStoredWalletSession(session: WalletAccessSession | null) {
if (typeof window === 'undefined') return
if (session) {
window.localStorage.setItem(WALLET_SESSION_STORAGE_KEY, JSON.stringify(session))
} else {
window.localStorage.removeItem(WALLET_SESSION_STORAGE_KEY)
}
window.dispatchEvent(new Event(ACCESS_SESSION_EVENT))
}
function buildWalletMessage(nonce: string) {
return `Sign this message to authenticate with SolaceScan.\n\nNonce: ${nonce}`
}
async function fetchWalletJson<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers || {})
headers.set('Content-Type', 'application/json')
const response = await fetch(`${getExplorerApiBase()}${path}`, {
...init,
headers,
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(payload?.error?.message || `HTTP ${response.status}`)
}
return payload as T
}
async function fetchJson<T>(path: string, init?: RequestInit, includeAuth = false): Promise<T> {
const headers = new Headers(init?.headers || {})
headers.set('Content-Type', 'application/json')
if (includeAuth) {
const token = getStoredAccessToken()
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
}
const response = await fetch(`${getExplorerApiBase()}${path}`, {
...init,
headers,
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(payload?.error?.message || `HTTP ${response.status}`)
}
return payload as T
}
export const accessApi = {
getStoredAccessToken,
getStoredWalletSession,
clearSession() {
setStoredAccessToken('')
},
clearWalletSession() {
setStoredWalletSession(null)
},
async register(email: string, username: string, password: string): Promise<AccessSession> {
const response = await fetchJson<AccessSession>(`${ACCESS_API_PREFIX}/auth/register`, {
method: 'POST',
body: JSON.stringify({ email, username, password }),
})
setStoredAccessToken(response.token)
return response
},
async login(email: string, password: string): Promise<AccessSession> {
const response = await fetchJson<AccessSession>(`${ACCESS_API_PREFIX}/auth/login`, {
method: 'POST',
body: JSON.stringify({ email, password }),
})
setStoredAccessToken(response.token)
return response
},
async createWalletNonce(address: string): Promise<{ nonce: string; address: string }> {
return fetchWalletJson<{ nonce: string; address: string }>(`${ACCESS_API_PREFIX}/auth/nonce`, {
method: 'POST',
body: JSON.stringify({ address }),
})
},
async authenticateWallet(address: string, signature: string, nonce: string): Promise<WalletAccessSession> {
const response = await fetchWalletJson<{
token: string
expires_at: string
track: string
permissions: string[]
}>(`${ACCESS_API_PREFIX}/auth/wallet`, {
method: 'POST',
body: JSON.stringify({ address, signature, nonce }),
})
const session: WalletAccessSession = {
token: response.token,
expiresAt: response.expires_at,
track: response.track,
permissions: response.permissions || [],
address,
}
setStoredWalletSession(session)
return session
},
async connectWalletSession(): Promise<WalletAccessSession> {
if (typeof window === 'undefined' || typeof window.ethereum === 'undefined') {
throw new Error('No EOA wallet detected. Please open the explorer with a browser wallet installed.')
}
const accounts = (await window.ethereum.request({
method: 'eth_requestAccounts',
})) as string[]
const address = accounts?.[0]
if (!address) {
throw new Error('Wallet connection was cancelled.')
}
const nonceResponse = await accessApi.createWalletNonce(address)
const message = buildWalletMessage(nonceResponse.nonce)
const signature = (await window.ethereum.request({
method: 'personal_sign',
params: [message, address],
})) as string
return accessApi.authenticateWallet(address, signature, nonceResponse.nonce)
},
async getMe(): Promise<{ user: AccessUser; subscriptions?: AccessSubscription[] }> {
return fetchJson<{ user: AccessUser; subscriptions?: AccessSubscription[] }>(`${ACCESS_API_PREFIX}/access/me`, undefined, true)
},
async listProducts(): Promise<{ products: AccessProduct[]; note?: string }> {
return fetchJson<{ products: AccessProduct[]; note?: string }>(`${ACCESS_API_PREFIX}/access/products`)
},
async listAPIKeys(): Promise<{ api_keys: AccessAPIKeyRecord[] }> {
return fetchJson<{ api_keys: AccessAPIKeyRecord[] }>(`${ACCESS_API_PREFIX}/access/api-keys`, undefined, true)
},
async createAPIKey(request: CreateAccessAPIKeyRequest): Promise<{ api_key: string; record?: AccessAPIKeyRecord }> {
return fetchJson<{ api_key: string; record?: AccessAPIKeyRecord }>(`${ACCESS_API_PREFIX}/access/api-keys`, {
method: 'POST',
body: JSON.stringify({
name: request.name,
tier: request.tier,
product_slug: request.productSlug,
expires_days: request.expiresDays,
monthly_quota: request.monthlyQuota,
scopes: request.scopes,
}),
}, true)
},
async revokeAPIKey(id: string): Promise<{ revoked: boolean; api_key_id: string }> {
return fetchJson<{ revoked: boolean; api_key_id: string }>(`${ACCESS_API_PREFIX}/access/api-keys/${id}`, {
method: 'POST',
}, true)
},
async listSubscriptions(): Promise<{ subscriptions: AccessSubscription[] }> {
return fetchJson<{ subscriptions: AccessSubscription[] }>(`${ACCESS_API_PREFIX}/access/subscriptions`, undefined, true)
},
async requestSubscription(productSlug: string, tier: string): Promise<{ subscription: AccessSubscription }> {
return fetchJson<{ subscription: AccessSubscription }>(`${ACCESS_API_PREFIX}/access/subscriptions`, {
method: 'POST',
body: JSON.stringify({ product_slug: productSlug, tier }),
}, true)
},
async getUsage(): Promise<{ usage: AccessUsageSummary[] }> {
return fetchJson<{ usage: AccessUsageSummary[] }>(`${ACCESS_API_PREFIX}/access/usage`, undefined, true)
},
async listAudit(limit = 20): Promise<{ entries: AccessAuditEntry[] }> {
return fetchJson<{ entries: AccessAuditEntry[] }>(`${ACCESS_API_PREFIX}/access/audit?limit=${encodeURIComponent(limit)}`, undefined, true)
},
async listAdminSubscriptions(status = 'pending'): Promise<{ subscriptions: AccessSubscription[] }> {
const suffix = status ? `?status=${encodeURIComponent(status)}` : ''
return fetchJson<{ subscriptions: AccessSubscription[] }>(`${ACCESS_API_PREFIX}/access/admin/subscriptions${suffix}`, undefined, true)
},
async listAdminAudit(limit = 50, productSlug = ''): Promise<{ entries: AccessAuditEntry[] }> {
const params = new URLSearchParams()
params.set('limit', String(limit))
if (productSlug) params.set('product', productSlug)
return fetchJson<{ entries: AccessAuditEntry[] }>(`${ACCESS_API_PREFIX}/access/admin/audit?${params.toString()}`, undefined, true)
},
async updateAdminSubscription(subscriptionId: string, status: string, notes = ''): Promise<{ subscription: AccessSubscription }> {
return fetchJson<{ subscription: AccessSubscription }>(`${ACCESS_API_PREFIX}/access/admin/subscriptions`, {
method: 'POST',
body: JSON.stringify({
subscription_id: subscriptionId,
status,
notes,
}),
}, true)
},
}

View File

@@ -1,14 +1,38 @@
import { ApiResponse } from './client'
import { fetchBlockscoutJson, normalizeTransactionSummary } from './blockscout'
import {
type BlockscoutTokenRef,
fetchBlockscoutJson,
normalizeAddressInfo,
normalizeAddressTokenBalance,
normalizeAddressTokenTransfer,
normalizeTransactionSummary,
} from './blockscout'
export interface AddressInfo {
address: string
chain_id: number
transaction_count: number
token_count: number
token_transfer_count?: number
internal_transaction_count?: number
logs_count?: number
is_contract: boolean
is_verified: boolean
has_token_transfers: boolean
has_tokens: boolean
balance?: string
creation_transaction_hash?: string
label?: string
tags: string[]
token_contract?: {
address: string
symbol?: string
name?: string
decimals?: number
type?: string
total_supply?: string
holders?: number
}
}
export interface AddressTransactionsParams {
@@ -27,14 +51,54 @@ export interface TransactionSummary {
status?: number
}
export interface AddressTokenBalance {
token_address: string
token_name?: string
token_symbol?: string
token_type?: string
token_decimals: number
value: string
holder_count?: number
total_supply?: string
}
export interface AddressTokenTransfer {
transaction_hash: string
block_number: number
timestamp?: string
from_address: string
from_label?: string
to_address: string
to_label?: string
token_address: string
token_name?: string
token_symbol?: string
token_decimals: number
value: string
type?: string
}
export const addressesApi = {
get: async (chainId: number, address: string): Promise<ApiResponse<AddressInfo>> => {
const [raw, counters] = await Promise.all([
fetchBlockscoutJson<{
hash: string
coin_balance?: string | null
is_contract: boolean
is_verified?: boolean
has_token_transfers?: boolean
has_tokens?: boolean
creation_transaction_hash?: string | null
name?: string | null
token?: { symbol?: string | null } | null
token?: {
address?: string | null
symbol?: string | null
name?: string | null
decimals?: string | number | null
type?: string | null
total_supply?: string | null
holders?: string | number | null
} | null
public_tags?: Array<{ label?: string; display_name?: string; name?: string } | string>
private_tags?: Array<{ label?: string; display_name?: string; name?: string } | string>
watchlist_names?: string[]
@@ -42,31 +106,12 @@ export const addressesApi = {
fetchBlockscoutJson<{
transactions_count?: number
token_balances_count?: number
token_transfers_count?: number
internal_transactions_count?: number
logs_count?: number
}>(`/api/v2/addresses/${address}/tabs-counters`),
])
const tags = [
...(raw.public_tags || []),
...(raw.private_tags || []),
...(raw.watchlist_names || []),
]
.map((tag) => {
if (typeof tag === 'string') return tag
return tag.display_name || tag.label || tag.name || ''
})
.filter(Boolean)
return {
data: {
address: raw.hash,
chain_id: chainId,
transaction_count: Number(counters.transactions_count || 0),
token_count: Number(counters.token_balances_count || 0),
is_contract: !!raw.is_contract,
label: raw.name || raw.token?.symbol || undefined,
tags,
},
}
return { data: normalizeAddressInfo(raw, counters, chainId) }
},
/** Use when you need to check response.ok before setting state (avoids treating 4xx/5xx body as data). */
getSafe: async (chainId: number, address: string): Promise<{ ok: boolean; data: AddressInfo | null }> => {
@@ -110,4 +155,38 @@ export const addressesApi = {
const data = Array.isArray(raw?.items) ? raw.items.map((item) => normalizeTransactionSummary(item as never)) : []
return { data }
},
getTokenBalancesSafe: async (address: string): Promise<{ ok: boolean; data: AddressTokenBalance[] }> => {
try {
const raw = await fetchBlockscoutJson<Array<{ token?: BlockscoutTokenRef | null; value?: string | null }>>(
`/api/v2/addresses/${address}/token-balances`
)
return {
ok: true,
data: Array.isArray(raw) ? raw.map((item) => normalizeAddressTokenBalance(item)) : [],
}
} catch {
return { ok: false, data: [] }
}
},
getTokenTransfersSafe: async (
address: string,
page = 1,
pageSize = 10
): Promise<{ ok: boolean; data: AddressTokenTransfer[] }> => {
try {
const params = new URLSearchParams({
page: page.toString(),
items_count: pageSize.toString(),
})
const raw = await fetchBlockscoutJson<{ items?: unknown[] }>(
`/api/v2/addresses/${address}/token-transfers?${params.toString()}`
)
return {
ok: true,
data: Array.isArray(raw?.items) ? raw.items.map((item) => normalizeAddressTokenTransfer(item as never)) : [],
}
} catch {
return { ok: false, data: [] }
}
},
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import {
normalizeAddressInfo,
normalizeAddressTokenBalance,
normalizeAddressTokenTransfer,
normalizeTransaction,
} from './blockscout'
describe('blockscout normalization helpers', () => {
it('normalizes richer transaction details including decoded input and token transfers', () => {
const transaction = normalizeTransaction({
hash: '0xabc',
block_number: 10,
from: { hash: '0xfrom' },
to: { hash: '0xto' },
value: '1000000000000000000',
gas_limit: 21000,
gas_used: 21000,
gas_price: 123,
status: 'ok',
timestamp: '2026-04-09T00:00:00.000000Z',
method: '0xa9059cbb',
revert_reason: null,
transaction_tag: 'Transfer',
fee: { value: '21000' },
decoded_input: {
method_call: 'transfer(address,uint256)',
method_id: '0xa9059cbb',
parameters: [{ name: 'to', type: 'address', value: '0xto' }],
},
token_transfers: [
{
from: { hash: '0xfrom' },
to: { hash: '0xto' },
token: {
address: '0xtoken',
symbol: 'TKN',
name: 'Token',
decimals: '6',
},
total: {
decimals: '6',
value: '5000000',
},
},
],
}, 138)
expect(transaction.method).toBe('0xa9059cbb')
expect(transaction.transaction_tag).toBe('Transfer')
expect(transaction.decoded_input?.method_call).toBe('transfer(address,uint256)')
expect(transaction.token_transfers).toHaveLength(1)
expect(transaction.token_transfers?.[0].token_symbol).toBe('TKN')
})
it('normalizes address balances and trust signals', () => {
const info = normalizeAddressInfo({
hash: '0xaddr',
coin_balance: '123',
is_contract: true,
is_verified: true,
has_token_transfers: true,
has_tokens: true,
creation_transaction_hash: '0xcreate',
name: 'Treasury',
token: {
address: '0xtoken',
symbol: 'cUSDT',
name: 'Tether USD (Compliant)',
decimals: '6',
type: 'ERC-20',
total_supply: '1000',
holders: '10',
},
public_tags: [{ label: 'Core' }],
private_tags: [],
watchlist_names: ['Ops'],
}, {
transactions_count: '4',
token_balances_count: '2',
token_transfers_count: '8',
internal_transactions_count: '6',
logs_count: '9',
}, 138)
expect(info.balance).toBe('123')
expect(info.is_verified).toBe(true)
expect(info.tags).toEqual(['Core', 'Ops'])
expect(info.creation_transaction_hash).toBe('0xcreate')
expect(info.token_contract?.symbol).toBe('cUSDT')
expect(info.internal_transaction_count).toBe(6)
})
it('normalizes address token balances and transfers', () => {
const balance = normalizeAddressTokenBalance({
token: {
address: '0xtoken',
name: 'Stable',
symbol: 'STBL',
decimals: '6',
holders: '11',
total_supply: '1000000',
},
value: '1000',
})
const transfer = normalizeAddressTokenTransfer({
transaction_hash: '0xtx',
block_number: 9,
from: { hash: '0xfrom', name: 'Sender' },
to: { hash: '0xto', name: 'Receiver' },
token: {
address: '0xtoken',
symbol: 'STBL',
name: 'Stable',
decimals: '6',
},
total: {
decimals: '6',
value: '1000',
},
timestamp: '2026-04-09T00:00:00.000000Z',
})
expect(balance.holder_count).toBe(11)
expect(balance.token_symbol).toBe('STBL')
expect(transfer.from_label).toBe('Sender')
expect(transfer.to_label).toBe('Receiver')
expect(transfer.value).toBe('1000')
})
})

View File

@@ -1,7 +1,12 @@
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
import type { Block } from './blocks'
import type { Transaction } from './transactions'
import type { TransactionSummary } from './addresses'
import type {
AddressInfo,
AddressTokenBalance,
AddressTokenTransfer,
TransactionSummary,
} from './addresses'
export function getExplorerApiBase() {
return resolveExplorerApiBase()
@@ -16,18 +21,94 @@ export async function fetchBlockscoutJson<T>(path: string): Promise<T> {
}
type HashLike = string | { hash?: string | null } | null | undefined
type StringLike = string | number | null | undefined
export function extractHash(value: HashLike): string {
if (!value) return ''
return typeof value === 'string' ? value : value.hash || ''
}
export function extractLabel(value: unknown): string {
if (!value || typeof value !== 'object') return ''
const candidate = value as {
name?: string | null
label?: string | null
display_name?: string | null
symbol?: string | null
}
return candidate.name || candidate.label || candidate.display_name || candidate.symbol || ''
}
function toNumber(value: unknown): number {
if (typeof value === 'number') return value
if (typeof value === 'string' && value.trim() !== '') return Number(value)
return 0
}
function toNullableNumber(value: unknown): number | undefined {
if (value == null) return undefined
const numeric = toNumber(value)
return Number.isFinite(numeric) ? numeric : undefined
}
export interface BlockscoutAddressRef {
hash?: string | null
name?: string | null
label?: string | null
is_contract?: boolean
is_verified?: boolean
}
export interface BlockscoutTokenRef {
address?: string | null
name?: string | null
symbol?: string | null
decimals?: StringLike
type?: string | null
total_supply?: string | null
holders?: StringLike
}
export interface BlockscoutTokenTransfer {
block_hash?: string
block_number?: StringLike
from?: BlockscoutAddressRef | null
to?: BlockscoutAddressRef | null
log_index?: StringLike
method?: string | null
timestamp?: string | null
token?: BlockscoutTokenRef | null
total?: {
decimals?: StringLike
value?: string | null
} | null
transaction_hash?: string
type?: string | null
}
export interface BlockscoutDecodedInput {
method_call?: string | null
method_id?: string | null
parameters?: Array<{
name?: string | null
type?: string | null
value?: unknown
}>
}
export interface BlockscoutInternalTransaction {
from?: BlockscoutAddressRef | null
to?: BlockscoutAddressRef | null
created_contract?: BlockscoutAddressRef | null
success?: boolean | null
error?: string | null
result?: string | null
timestamp?: string | null
transaction_hash?: string | null
type?: string | null
value?: string | null
}
interface BlockscoutBlock {
hash: string
height: number | string
@@ -54,6 +135,13 @@ interface BlockscoutTransaction {
raw_input?: string | null
timestamp: string
created_contract?: HashLike
fee?: { value?: string | null } | string | null
method?: string | null
revert_reason?: string | null
transaction_tag?: string | null
decoded_input?: BlockscoutDecodedInput | null
token_transfers?: BlockscoutTokenTransfer[] | null
actions?: unknown[] | null
}
function normalizeStatus(raw: BlockscoutTransaction): number {
@@ -95,6 +183,39 @@ export function normalizeTransaction(raw: BlockscoutTransaction, chainId: number
input_data: raw.raw_input || undefined,
contract_address: extractHash(raw.created_contract) || undefined,
created_at: raw.timestamp,
fee: typeof raw.fee === 'string' ? raw.fee : raw.fee?.value || undefined,
method: raw.method || undefined,
revert_reason: raw.revert_reason || undefined,
transaction_tag: raw.transaction_tag || undefined,
decoded_input: raw.decoded_input
? {
method_call: raw.decoded_input.method_call || undefined,
method_id: raw.decoded_input.method_id || undefined,
parameters: Array.isArray(raw.decoded_input.parameters)
? raw.decoded_input.parameters.map((parameter) => ({
name: parameter.name || undefined,
type: parameter.type || undefined,
value: parameter.value,
}))
: [],
}
: undefined,
token_transfers: Array.isArray(raw.token_transfers)
? raw.token_transfers.map((transfer) => ({
block_number: toNullableNumber(transfer.block_number),
from_address: extractHash(transfer.from),
from_label: extractLabel(transfer.from),
to_address: extractHash(transfer.to),
to_label: extractLabel(transfer.to),
token_address: transfer.token?.address || '',
token_name: transfer.token?.name || undefined,
token_symbol: transfer.token?.symbol || undefined,
token_decimals: toNullableNumber(transfer.token?.decimals) ?? toNullableNumber(transfer.total?.decimals) ?? 18,
amount: transfer.total?.value || '0',
type: transfer.type || undefined,
timestamp: transfer.timestamp || undefined,
}))
: [],
}
}
@@ -108,3 +229,104 @@ export function normalizeTransactionSummary(raw: BlockscoutTransaction): Transac
status: normalizeStatus(raw),
}
}
interface BlockscoutAddress {
hash: string
coin_balance?: string | null
is_contract?: boolean
is_verified?: boolean
name?: string | null
public_tags?: Array<{ label?: string; display_name?: string; name?: string } | string>
private_tags?: Array<{ label?: string; display_name?: string; name?: string } | string>
watchlist_names?: string[]
has_token_transfers?: boolean
has_tokens?: boolean
creation_transaction_hash?: string | null
token?: BlockscoutTokenRef | null
}
export function normalizeAddressInfo(
raw: BlockscoutAddress,
counters: {
transactions_count?: number | string
token_balances_count?: number | string
token_transfers_count?: number | string
internal_transactions_count?: number | string
logs_count?: number | string
},
chainId: number,
): AddressInfo {
const tags = [
...(raw.public_tags || []),
...(raw.private_tags || []),
...(raw.watchlist_names || []),
]
.map((tag) => {
if (typeof tag === 'string') return tag
return tag.display_name || tag.label || tag.name || ''
})
.filter(Boolean)
return {
address: raw.hash,
chain_id: chainId,
transaction_count: Number(counters.transactions_count || 0),
token_count: Number(counters.token_balances_count || 0),
token_transfer_count: Number(counters.token_transfers_count || 0),
internal_transaction_count: Number(counters.internal_transactions_count || 0),
logs_count: Number(counters.logs_count || 0),
is_contract: !!raw.is_contract,
is_verified: !!raw.is_verified,
has_token_transfers: !!raw.has_token_transfers,
has_tokens: !!raw.has_tokens,
balance: raw.coin_balance || undefined,
creation_transaction_hash: raw.creation_transaction_hash || undefined,
label: raw.name || raw.token?.symbol || undefined,
tags,
token_contract: raw.token?.address
? {
address: raw.token.address,
symbol: raw.token.symbol || undefined,
name: raw.token.name || undefined,
decimals: toNullableNumber(raw.token.decimals),
type: raw.token.type || undefined,
total_supply: raw.token.total_supply || undefined,
holders: toNullableNumber(raw.token.holders),
}
: undefined,
}
}
export function normalizeAddressTokenBalance(raw: {
token?: BlockscoutTokenRef | null
value?: string | null
}): AddressTokenBalance {
return {
token_address: raw.token?.address || '',
token_name: raw.token?.name || undefined,
token_symbol: raw.token?.symbol || undefined,
token_type: raw.token?.type || undefined,
token_decimals: toNullableNumber(raw.token?.decimals) ?? 18,
value: raw.value || '0',
holder_count: raw.token?.holders != null ? toNumber(raw.token.holders) : undefined,
total_supply: raw.token?.total_supply || undefined,
}
}
export function normalizeAddressTokenTransfer(raw: BlockscoutTokenTransfer): AddressTokenTransfer {
return {
transaction_hash: raw.transaction_hash || '',
block_number: toNullableNumber(raw.block_number) ?? 0,
timestamp: raw.timestamp || undefined,
from_address: extractHash(raw.from),
from_label: extractLabel(raw.from),
to_address: extractHash(raw.to),
to_label: extractLabel(raw.to),
token_address: raw.token?.address || '',
token_name: raw.token?.name || undefined,
token_symbol: raw.token?.symbol || undefined,
token_decimals: toNullableNumber(raw.token?.decimals) ?? toNullableNumber(raw.total?.decimals) ?? 18,
value: raw.total?.value || '0',
type: raw.type || undefined,
}
}

View File

@@ -18,6 +18,7 @@ export interface TokenListToken {
name?: string
decimals?: number
logoURI?: string
tags?: string[]
}
export interface TokenListResponse {

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { callSimpleReadMethod, contractsApi } from './contracts'
describe('contractsApi', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('normalizes contract profile metadata safely', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
has_custom_methods_read: true,
has_custom_methods_write: false,
proxy_type: 'eip1967',
is_self_destructed: false,
implementations: [{ address: '0ximpl1' }, '0ximpl2'],
creation_bytecode: '0x' + 'a'.repeat(120),
deployed_bytecode: '0x' + 'b'.repeat(120),
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: '1',
message: 'OK',
result: '[{"type":"function","name":"symbol","stateMutability":"view","inputs":[],"outputs":[{"type":"string"}]}]',
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
status: '1',
message: 'OK',
result: [
{
ContractName: 'MockToken',
CompilerVersion: 'v0.8.24+commit',
OptimizationUsed: '1',
Runs: '200',
EVMVersion: 'paris',
LicenseType: 'MIT',
ConstructorArguments: '0x' + 'c'.repeat(120),
SourceCode: 'contract MockToken {}',
},
],
}),
}),
)
const result = await contractsApi.getProfileSafe('0xcontract')
expect(result.ok).toBe(true)
expect(result.data?.has_custom_methods_read).toBe(true)
expect(result.data?.proxy_type).toBe('eip1967')
expect(result.data?.implementations).toEqual(['0ximpl1', '0ximpl2'])
expect(result.data?.creation_bytecode?.endsWith('...')).toBe(true)
expect(result.data?.source_verified).toBe(true)
expect(result.data?.abi_available).toBe(true)
expect(result.data?.contract_name).toBe('MockToken')
expect(result.data?.optimization_enabled).toBe(true)
expect(result.data?.optimization_runs).toBe(200)
expect(result.data?.constructor_arguments?.endsWith('...')).toBe(true)
expect(result.data?.abi).toContain('"symbol"')
expect(result.data?.read_methods.map((method) => method.signature)).toContain('symbol()')
})
it('calls a simple zero-arg read method through public RPC', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
result:
'0x' +
'0000000000000000000000000000000000000000000000000000000000000020' +
'0000000000000000000000000000000000000000000000000000000000000004' +
'5445535400000000000000000000000000000000000000000000000000000000',
}),
}),
)
const value = await callSimpleReadMethod('0xcontract', {
name: 'name',
signature: 'name()',
stateMutability: 'view',
inputs: [],
outputs: [{ name: '', type: 'string' }],
})
expect(value).toBe('TEST')
})
})

View File

@@ -0,0 +1,406 @@
import { getExplorerApiBase, fetchBlockscoutJson } from './blockscout'
import { keccak_256 } from 'js-sha3'
export interface ContractMethodParam {
name: string
type: string
}
export interface ContractMethod {
name: string
signature: string
stateMutability: string
inputs: ContractMethodParam[]
outputs: ContractMethodParam[]
}
export interface ContractMethodExecutionResult {
value: string
}
export interface ContractProfile {
has_custom_methods_read: boolean
has_custom_methods_write: boolean
proxy_type?: string
is_self_destructed?: boolean
implementations: string[]
creation_bytecode?: string
deployed_bytecode?: string
source_verified: boolean
abi_available: boolean
contract_name?: string
compiler_version?: string
optimization_enabled?: boolean
optimization_runs?: number
evm_version?: string
license_type?: string
constructor_arguments?: string
abi?: string
source_code_preview?: string
source_status_text?: string
read_methods: ContractMethod[]
write_methods: ContractMethod[]
}
function truncateHex(value?: string | null, maxLength = 66): string | undefined {
if (!value) return undefined
if (value.length <= maxLength) return value
return `${value.slice(0, maxLength)}...`
}
function truncateText(value?: string | null, maxLength = 400): string | undefined {
if (!value) return undefined
if (value.length <= maxLength) return value
return `${value.slice(0, maxLength)}...`
}
interface ContractCompatibilityAbiResponse {
status?: string | null
message?: string | null
result?: string | null
}
interface ContractCompatibilitySourceRecord {
Address?: string
ContractName?: string
CompilerVersion?: string
OptimizationUsed?: string | number
Runs?: string | number
EVMVersion?: string
LicenseType?: string
ConstructorArguments?: string
SourceCode?: string
ABI?: string
}
interface ContractCompatibilitySourceResponse {
status?: string | null
message?: string | null
result?: ContractCompatibilitySourceRecord[] | null
}
interface ABIEntry {
type?: string
name?: string
stateMutability?: string
constant?: boolean
inputs?: Array<{ name?: string; type?: string }>
outputs?: Array<{ name?: string; type?: string }>
}
async function fetchCompatJson<T>(params: URLSearchParams): Promise<T> {
const response = await fetch(`${getExplorerApiBase()}/api?${params.toString()}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json() as Promise<T>
}
function normalizeBooleanFlag(value: string | number | null | undefined): boolean | undefined {
if (value == null || value === '') return undefined
if (typeof value === 'number') return value === 1
return value === '1' || value.toLowerCase() === 'true'
}
function normalizeNumber(value: string | number | null | undefined): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
function parseABI(abiString?: string): ContractMethod[] {
if (!abiString) return []
try {
const parsed = JSON.parse(abiString) as ABIEntry[]
if (!Array.isArray(parsed)) return []
return parsed
.filter((entry) => entry.type === 'function' && entry.name)
.map((entry) => {
const inputs = Array.isArray(entry.inputs)
? entry.inputs.map((input) => ({
name: input.name || '',
type: input.type || 'unknown',
}))
: []
const outputs = Array.isArray(entry.outputs)
? entry.outputs.map((output) => ({
name: output.name || '',
type: output.type || 'unknown',
}))
: []
return {
name: entry.name || 'unknown',
signature: `${entry.name || 'unknown'}(${inputs.map((input) => input.type).join(',')})`,
stateMutability:
entry.stateMutability ||
(entry.constant || outputs.length > 0 ? 'view' : 'nonpayable'),
inputs,
outputs,
}
})
} catch {
return []
}
}
function isReadMethod(method: ContractMethod): boolean {
return method.stateMutability === 'view' || method.stateMutability === 'pure'
}
function isSupportedInputType(type: string): boolean {
return ['address', 'bool', 'string', 'uint256', 'uint8', 'bytes32'].includes(type)
}
function isSupportedOutputType(type: string): boolean {
return ['address', 'bool', 'string', 'uint256', 'uint8', 'bytes32', 'bytes'].includes(type)
}
function supportsSimpleReadCall(method: ContractMethod): boolean {
return (
method.outputs.length === 1 &&
method.inputs.every((input) => isSupportedInputType(input.type)) &&
method.outputs.every((output) => isSupportedOutputType(output.type))
)
}
function supportsSimpleWriteCall(method: ContractMethod): boolean {
return !isReadMethod(method) && method.inputs.every((input) => isSupportedInputType(input.type))
}
function getPublicRpcUrl(): string {
return process.env.NEXT_PUBLIC_RPC_URL_138 || 'https://rpc-http-pub.d-bis.org'
}
function decodeDynamicString(wordData: string, offset: number): string {
const lengthHex = wordData.slice(offset, offset + 64)
const length = parseInt(lengthHex || '0', 16)
const start = offset + 64
const end = start + length * 2
const contentHex = wordData.slice(start, end)
if (!contentHex) return ''
const bytes = contentHex.match(/.{1,2}/g) || []
return bytes
.map((byte) => String.fromCharCode(parseInt(byte, 16)))
.join('')
.replace(/\u0000+$/g, '')
}
function validateAndEncodeInput(type: string, value: string): { head: string; tail?: string } {
const trimmed = value.trim()
switch (type) {
case 'address': {
if (!/^0x[a-fA-F0-9]{40}$/.test(trimmed)) {
throw new Error('Address inputs must be full 0x-prefixed addresses.')
}
return { head: trimmed.slice(2).toLowerCase().padStart(64, '0') }
}
case 'bool':
if (!['true', 'false', '1', '0'].includes(trimmed.toLowerCase())) {
throw new Error('Boolean inputs must be true/false or 1/0.')
}
return { head: (trimmed === 'true' || trimmed === '1' ? '1' : '0').padStart(64, '0') }
case 'uint256':
case 'uint8': {
if (!/^\d+$/.test(trimmed)) {
throw new Error('Unsigned integer inputs must be non-negative decimal numbers.')
}
return { head: BigInt(trimmed).toString(16).padStart(64, '0') }
}
case 'bytes32': {
if (!/^0x[a-fA-F0-9]{64}$/.test(trimmed)) {
throw new Error('bytes32 inputs must be 32-byte 0x-prefixed hex values.')
}
return { head: trimmed.slice(2).toLowerCase() }
}
case 'string': {
const contentHex = Array.from(new TextEncoder().encode(trimmed))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
const paddedContent = contentHex.padEnd(Math.ceil(contentHex.length / 64) * 64 || 64, '0')
const lengthHex = contentHex.length / 2
return {
head: '',
tail:
BigInt(lengthHex).toString(16).padStart(64, '0') +
paddedContent,
}
}
default:
throw new Error(`Unsupported input type ${type}`)
}
}
export function encodeMethodCalldata(method: ContractMethod, values: string[]): string {
if (values.length !== method.inputs.length) {
throw new Error('Method input count does not match the provided values.')
}
const selector = keccak_256(method.signature).slice(0, 8)
const encodedInputs = method.inputs.map((input, index) => validateAndEncodeInput(input.type, values[index] || ''))
let dynamicOffsetWords = method.inputs.length * 32
const heads = encodedInputs.map((encoded) => {
if (encoded.tail != null) {
const head = BigInt(dynamicOffsetWords).toString(16).padStart(64, '0')
dynamicOffsetWords += encoded.tail.length / 2
return head
}
return encoded.head
})
const tails = encodedInputs
.filter((encoded) => encoded.tail != null)
.map((encoded) => encoded.tail || '')
.join('')
return `0x${selector}${heads.join('')}${tails}`
}
function decodeSimpleOutput(outputType: string, data: string): string {
const normalized = data.replace(/^0x/i, '')
if (!normalized) return 'No data returned'
switch (outputType) {
case 'address':
return `0x${normalized.slice(24, 64)}`
case 'bool':
return BigInt(`0x${normalized.slice(0, 64)}`) === 0n ? 'false' : 'true'
case 'string': {
const offset = Number(BigInt(`0x${normalized.slice(0, 64)}`) * 2n)
return decodeDynamicString(normalized, offset)
}
case 'bytes32':
return `0x${normalized.slice(0, 64)}`
case 'bytes': {
const offset = Number(BigInt(`0x${normalized.slice(0, 64)}`) * 2n)
const length = parseInt(normalized.slice(offset + 64, offset + 128) || '0', 16)
const start = offset + 128
return `0x${normalized.slice(start, start + length * 2)}`
}
default:
if (outputType.startsWith('uint') || outputType.startsWith('int')) {
return BigInt(`0x${normalized.slice(0, 64)}`).toString()
}
return `0x${normalized}`
}
}
export async function callSimpleReadMethod(address: string, method: ContractMethod, values: string[] = []): Promise<string> {
if (!supportsSimpleReadCall(method)) {
throw new Error('Only simple read methods with supported input and output types are supported in this explorer surface.')
}
const data = encodeMethodCalldata(method, values)
const response = await fetch(getPublicRpcUrl(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'eth_call',
params: [
{
to: address,
data,
},
'latest',
],
}),
})
if (!response.ok) {
throw new Error(`RPC HTTP ${response.status}`)
}
const payload = (await response.json()) as { result?: string; error?: { message?: string } }
if (payload.error?.message) {
throw new Error(payload.error.message)
}
const result = payload.result || '0x'
return decodeSimpleOutput(method.outputs[0]?.type || 'bytes', result)
}
export const contractsApi = {
getProfileSafe: async (address: string): Promise<{ ok: boolean; data: ContractProfile | null }> => {
try {
const [raw, abiResponse, sourceResponse] = await Promise.all([
fetchBlockscoutJson<{
has_custom_methods_read?: boolean
has_custom_methods_write?: boolean
proxy_type?: string | null
is_self_destructed?: boolean | null
implementations?: Array<{ address?: string | null } | string>
creation_bytecode?: string | null
deployed_bytecode?: string | null
}>(`/api/v2/smart-contracts/${address}`),
fetchCompatJson<ContractCompatibilityAbiResponse>(
new URLSearchParams({
module: 'contract',
action: 'getabi',
address,
}),
).catch(() => null),
fetchCompatJson<ContractCompatibilitySourceResponse>(
new URLSearchParams({
module: 'contract',
action: 'getsourcecode',
address,
}),
).catch(() => null),
])
const sourceRecord = Array.isArray(sourceResponse?.result) ? sourceResponse?.result[0] : undefined
const abiString =
abiResponse?.status === '1' && abiResponse.result && abiResponse.result !== 'Contract source code not verified'
? abiResponse.result
: sourceRecord?.ABI && sourceRecord.ABI !== 'Contract source code not verified'
? sourceRecord.ABI
: undefined
const sourceCode = sourceRecord?.SourceCode
const parsedMethods = parseABI(abiString)
const sourceVerified = Boolean(
abiString ||
(sourceCode && sourceCode.trim().length > 0) ||
(sourceRecord?.ContractName && sourceRecord.ContractName.trim().length > 0),
)
const sourceStatusText = abiResponse?.message || sourceResponse?.message || (sourceVerified ? 'Verified source available' : 'Contract source code not verified')
return {
ok: true,
data: {
has_custom_methods_read: !!raw.has_custom_methods_read,
has_custom_methods_write: !!raw.has_custom_methods_write,
proxy_type: raw.proxy_type || undefined,
is_self_destructed: raw.is_self_destructed ?? undefined,
implementations: Array.isArray(raw.implementations)
? raw.implementations
.map((entry) => typeof entry === 'string' ? entry : entry.address || '')
.filter(Boolean)
: [],
creation_bytecode: truncateHex(raw.creation_bytecode),
deployed_bytecode: truncateHex(raw.deployed_bytecode),
source_verified: sourceVerified,
abi_available: Boolean(abiString),
contract_name: sourceRecord?.ContractName || undefined,
compiler_version: sourceRecord?.CompilerVersion || undefined,
optimization_enabled: normalizeBooleanFlag(sourceRecord?.OptimizationUsed),
optimization_runs: normalizeNumber(sourceRecord?.Runs),
evm_version: sourceRecord?.EVMVersion || undefined,
license_type: sourceRecord?.LicenseType || undefined,
constructor_arguments: truncateHex(sourceRecord?.ConstructorArguments, 90),
abi: truncateText(abiString, 1200),
source_code_preview: truncateText(sourceCode, 1200),
source_status_text: sourceStatusText || undefined,
read_methods: parsedMethods.filter(isReadMethod),
write_methods: parsedMethods.filter((method) => !isReadMethod(method)),
},
}
} catch {
return { ok: false, data: null }
}
},
supportsSimpleReadCall,
supportsSimpleWriteCall,
}

View File

@@ -0,0 +1,216 @@
import type { ContractMethod, ContractProfile } from './contracts'
import { callSimpleReadMethod } from './contracts'
import { getGruCatalogPosture } from './gruCatalog'
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
const ZERO_BYTES32 = '0x0000000000000000000000000000000000000000000000000000000000000000'
export interface GruStandardStatus {
id: string
required: boolean
detected: boolean
}
export interface GruMetadataField {
label: string
value: string
}
export interface GruStandardsProfile {
isGruSurface: boolean
wrappedTransport: boolean
forwardCanonical: boolean | null
legacyAliasSupport: boolean
x402Ready: boolean
minimumUpgradeNoticePeriodSeconds: number | null
activeVersion?: string
forwardVersion?: string
profileId: string
standards: GruStandardStatus[]
metadata: GruMetadataField[]
}
const GRU_PROFILE_ID = 'gru-c-star-v2-transport-and-payment'
const STANDARD_DEFINITIONS = [
{ id: 'ERC-20', required: true },
{ id: 'AccessControl', required: true },
{ id: 'Pausable', required: true },
{ id: 'EIP-712', required: true },
{ id: 'ERC-2612', required: true },
{ id: 'ERC-3009', required: true },
{ id: 'ERC-5267', required: true },
{ id: 'IeMoneyToken', required: true },
{ id: 'DeterministicStorageNamespace', required: true },
{ id: 'JurisdictionAndSupervisionMetadata', required: true },
] as const
function method(signature: string, outputType: string, inputTypes: string[] = []): ContractMethod {
const name = signature.split('(')[0]
return {
name,
signature,
stateMutability: 'view',
inputs: inputTypes.map((type, index) => ({ name: `arg${index + 1}`, type })),
outputs: [{ name: '', type: outputType }],
}
}
function hasMethod(profile: ContractProfile | null | undefined, name: string): boolean {
const allMethods = [...(profile?.read_methods || []), ...(profile?.write_methods || [])]
return allMethods.some((entry) => entry.name === name)
}
async function readOptional(address: string, contractMethod: ContractMethod, values: string[] = []): Promise<string | null> {
try {
const value = await callSimpleReadMethod(address, contractMethod, values)
return value
} catch {
return null
}
}
function looksLikeGruToken(symbol?: string | null, tags?: string[] | null): boolean {
const normalizedSymbol = (symbol || '').toUpperCase()
if (normalizedSymbol.startsWith('CW') || normalizedSymbol.startsWith('C')) return true
const normalizedTags = (tags || []).map((tag) => tag.toLowerCase())
return normalizedTags.includes('compliant') || normalizedTags.includes('wrapped') || normalizedTags.includes('bridge')
}
function parseOptionalNumber(value: string | null): number | null {
if (!value || !/^\d+$/.test(value)) return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
export async function getGruStandardsProfileSafe(input: {
address: string
symbol?: string | null
tags?: string[] | null
contractProfile?: ContractProfile | null
}): Promise<{ ok: boolean; data: GruStandardsProfile | null }> {
const { address, symbol, tags, contractProfile } = input
const [
currencyCode,
versionTag,
assetId,
assetVersionId,
governanceProfileId,
supervisionProfileId,
storageNamespace,
primaryJurisdiction,
regulatoryDisclosureURI,
reportingURI,
minimumUpgradeNoticePeriod,
wrappedTransport,
forwardCanonical,
paused,
domainSeparator,
nonces,
authorizationState,
defaultAdminRole,
] = await Promise.all([
readOptional(address, method('currencyCode()(string)', 'string')),
readOptional(address, method('versionTag()(string)', 'string')),
readOptional(address, method('assetId()(bytes32)', 'bytes32')),
readOptional(address, method('assetVersionId()(bytes32)', 'bytes32')),
readOptional(address, method('governanceProfileId()(bytes32)', 'bytes32')),
readOptional(address, method('supervisionProfileId()(bytes32)', 'bytes32')),
readOptional(address, method('storageNamespace()(bytes32)', 'bytes32')),
readOptional(address, method('primaryJurisdiction()(string)', 'string')),
readOptional(address, method('regulatoryDisclosureURI()(string)', 'string')),
readOptional(address, method('reportingURI()(string)', 'string')),
readOptional(address, method('minimumUpgradeNoticePeriod()(uint256)', 'uint256')),
readOptional(address, method('wrappedTransport()(bool)', 'bool')),
readOptional(address, method('forwardCanonical()(bool)', 'bool')),
readOptional(address, method('paused()(bool)', 'bool')),
readOptional(address, method('DOMAIN_SEPARATOR()(bytes32)', 'bytes32')),
readOptional(address, method('nonces(address)(uint256)', 'uint256', ['address']), [ZERO_ADDRESS]),
readOptional(address, method('authorizationState(address,bytes32)(bool)', 'bool', ['address', 'bytes32']), [ZERO_ADDRESS, ZERO_BYTES32]),
readOptional(address, method('DEFAULT_ADMIN_ROLE()(bytes32)', 'bytes32')),
])
const hasErc20Shape =
Boolean(symbol) ||
hasMethod(contractProfile, 'name') ||
hasMethod(contractProfile, 'symbol') ||
hasMethod(contractProfile, 'decimals') ||
hasMethod(contractProfile, 'totalSupply')
const detectedMap: Record<string, boolean> = {
'ERC-20': hasErc20Shape,
AccessControl: defaultAdminRole != null || hasMethod(contractProfile, 'grantRole') || hasMethod(contractProfile, 'hasRole'),
Pausable: paused != null || hasMethod(contractProfile, 'paused'),
'EIP-712': domainSeparator != null || hasMethod(contractProfile, 'DOMAIN_SEPARATOR'),
'ERC-2612': nonces != null || hasMethod(contractProfile, 'permit') || hasMethod(contractProfile, 'nonces'),
'ERC-3009': authorizationState != null || hasMethod(contractProfile, 'authorizationState'),
'ERC-5267': hasMethod(contractProfile, 'eip712Domain'),
IeMoneyToken: currencyCode != null || versionTag != null,
DeterministicStorageNamespace: storageNamespace != null,
JurisdictionAndSupervisionMetadata:
governanceProfileId != null ||
supervisionProfileId != null ||
primaryJurisdiction != null ||
regulatoryDisclosureURI != null ||
reportingURI != null ||
minimumUpgradeNoticePeriod != null ||
wrappedTransport != null,
}
const isGruSurface =
looksLikeGruToken(symbol, tags) ||
Boolean(currencyCode) ||
Boolean(versionTag) ||
Boolean(assetId) ||
Boolean(governanceProfileId) ||
Boolean(storageNamespace)
if (!isGruSurface) {
return { ok: true, data: null }
}
const x402Ready = Boolean(detectedMap['EIP-712'] && detectedMap['ERC-5267'] && (detectedMap['ERC-2612'] || detectedMap['ERC-3009']))
const minimumUpgradeNoticePeriodSeconds = parseOptionalNumber(minimumUpgradeNoticePeriod)
const legacyAliasSupport = hasMethod(contractProfile, 'legacyAliases')
const catalogPosture = getGruCatalogPosture({ symbol, address, tags })
const metadata: GruMetadataField[] = [
currencyCode ? { label: 'Currency Code', value: currencyCode } : null,
versionTag ? { label: 'Version Tag', value: versionTag } : null,
assetId ? { label: 'Asset ID', value: assetId } : null,
assetVersionId ? { label: 'Asset Version ID', value: assetVersionId } : null,
governanceProfileId ? { label: 'Governance Profile', value: governanceProfileId } : null,
supervisionProfileId ? { label: 'Supervision Profile', value: supervisionProfileId } : null,
storageNamespace ? { label: 'Storage Namespace', value: storageNamespace } : null,
primaryJurisdiction ? { label: 'Primary Jurisdiction', value: primaryJurisdiction } : null,
regulatoryDisclosureURI ? { label: 'Disclosure URI', value: regulatoryDisclosureURI } : null,
reportingURI ? { label: 'Reporting URI', value: reportingURI } : null,
minimumUpgradeNoticePeriod ? { label: 'Upgrade Notice Period', value: `${minimumUpgradeNoticePeriod} seconds` } : null,
wrappedTransport != null ? { label: 'Wrapped Transport', value: wrappedTransport } : null,
forwardCanonical != null ? { label: 'Forward Canonical', value: forwardCanonical } : null,
legacyAliasSupport ? { label: 'Legacy Alias Support', value: 'true' } : null,
{ label: 'x402 Readiness', value: x402Ready ? 'true' : 'false' },
].filter(Boolean) as GruMetadataField[]
return {
ok: true,
data: {
isGruSurface: true,
wrappedTransport: wrappedTransport === 'true',
forwardCanonical: forwardCanonical === 'true' ? true : forwardCanonical === 'false' ? false : null,
legacyAliasSupport,
x402Ready,
minimumUpgradeNoticePeriodSeconds,
activeVersion: catalogPosture?.activeVersion,
forwardVersion: catalogPosture?.forwardVersion,
profileId: GRU_PROFILE_ID,
standards: STANDARD_DEFINITIONS.map((entry) => ({
id: entry.id,
required: entry.required,
detected: Boolean(detectedMap[entry.id]),
})),
metadata,
},
}
}

View File

@@ -0,0 +1,116 @@
export interface GruCatalogPosture {
isGru: boolean
isWrappedTransport: boolean
isX402Ready: boolean
isForwardCanonical: boolean
currencyCode?: string
activeVersion?: string
forwardVersion?: string
}
interface GruCatalogEntry extends GruCatalogPosture {
symbol: string
addresses?: string[]
}
const GRU_X402_READY_SYMBOLS = new Set([
'CAUDC',
'CCADC',
'CCHFC',
'CEURC',
'CEURT',
'CGBPC',
'CGBPT',
'CJPYC',
'CUSDC',
'CUSDT',
'CXAUC',
'CXAUT',
])
const GRU_CATALOG: GruCatalogEntry[] = [
{
symbol: 'cUSDC',
currencyCode: 'USD',
isGru: true,
isWrappedTransport: false,
isX402Ready: true,
isForwardCanonical: true,
activeVersion: 'v1',
forwardVersion: 'v2',
addresses: [
'0xf22258f57794cc8e06237084b353ab30fffa640b',
'0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d',
],
},
{
symbol: 'cUSDT',
currencyCode: 'USD',
isGru: true,
isWrappedTransport: false,
isX402Ready: true,
isForwardCanonical: true,
activeVersion: 'v1',
forwardVersion: 'v2',
addresses: [
'0x93e66202a11b1772e55407b32b44e5cd8eda7f22',
'0x9fbfab33882efe0038daa608185718b772ee5660',
],
},
...['cAUDC', 'cCADC', 'cCHFC', 'cEURC', 'cEURT', 'cGBPC', 'cGBPT', 'cJPYC', 'cXAUC', 'cXAUT'].map((symbol) => ({
symbol,
currencyCode: symbol.slice(1, 4).replace('XAU', 'XAU'),
isGru: true,
isWrappedTransport: false,
isX402Ready: true,
isForwardCanonical: true,
forwardVersion: 'v2',
})),
]
function normalizeSymbol(symbol?: string | null): string {
return (symbol || '').trim().toUpperCase()
}
function normalizeAddress(address?: string | null): string {
return (address || '').trim().toLowerCase()
}
export function getGruCatalogPosture(input: {
symbol?: string | null
address?: string | null
tags?: string[] | null
}): GruCatalogPosture | null {
const symbol = normalizeSymbol(input.symbol)
const address = normalizeAddress(input.address)
const tags = (input.tags || []).map((tag) => tag.toLowerCase())
const matched = GRU_CATALOG.find((entry) => {
if (symbol && normalizeSymbol(entry.symbol) === symbol) return true
if (address && entry.addresses?.includes(address)) return true
return false
})
if (matched) {
return {
isGru: true,
isWrappedTransport: matched.isWrappedTransport,
isX402Ready: matched.isX402Ready,
isForwardCanonical: matched.isForwardCanonical,
currencyCode: matched.currencyCode,
activeVersion: matched.activeVersion,
forwardVersion: matched.forwardVersion,
}
}
const looksWrapped = symbol.startsWith('CW')
const looksGru = looksWrapped || symbol.startsWith('C') || tags.includes('compliant') || tags.includes('wrapped') || tags.includes('bridge')
if (!looksGru) return null
return {
isGru: true,
isWrappedTransport: looksWrapped || tags.includes('wrapped') || tags.includes('bridge'),
isX402Ready: GRU_X402_READY_SYMBOLS.has(symbol),
isForwardCanonical: GRU_X402_READY_SYMBOLS.has(symbol) && !looksWrapped,
}
}

View File

@@ -0,0 +1,183 @@
export interface GruNetworkLink {
chainId: number
chainName: string
symbol: string
address: string
notes?: string
explorerUrl?: string
}
export interface GruExplorerMetadata {
currencyCode?: string
iso20022Ready: boolean
x402Ready: boolean
activeVersion?: string
transportActiveVersion?: string
x402PreferredVersion?: string
canonicalForwardVersion?: string
canonicalForwardAddress?: string
otherNetworks: GruNetworkLink[]
}
interface GruExplorerEntry extends GruExplorerMetadata {
symbol: string
addresses: string[]
}
function chainExplorerUrl(chainId: number, address: string): string | undefined {
switch (chainId) {
case 1:
return `https://etherscan.io/address/${address}`
case 651940:
return `https://alltra.global/address/${address}`
case 56:
return `https://bscscan.com/address/${address}`
case 100:
return `https://gnosisscan.io/address/${address}`
case 137:
return `https://polygonscan.com/address/${address}`
default:
return undefined
}
}
function networkLink(chainId: number, chainName: string, symbol: string, address: string, notes?: string): GruNetworkLink {
return {
chainId,
chainName,
symbol,
address,
notes,
explorerUrl: chainExplorerUrl(chainId, address),
}
}
const GRU_EXPLORER_ENTRIES: GruExplorerEntry[] = [
{
symbol: 'cUSDC',
addresses: [
'0xf22258f57794cc8e06237084b353ab30fffa640b',
'0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d',
],
currencyCode: 'USD',
iso20022Ready: true,
x402Ready: true,
activeVersion: 'v1',
transportActiveVersion: 'v1',
x402PreferredVersion: 'v2',
canonicalForwardVersion: 'v2',
canonicalForwardAddress: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d',
otherNetworks: [
networkLink(651940, 'ALL Mainnet (Alltra)', 'AUSDC', '0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881', 'Primary Alltra-native origin counterpart.'),
networkLink(1, 'Ethereum Mainnet', 'USDC', '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 'Native Ethereum settlement counterpart.'),
networkLink(1, 'Ethereum Mainnet', 'cWUSDC', '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', 'GRU wrapped transport representation on Ethereum.'),
networkLink(56, 'BNB Chain', 'USDC', '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', 'Native BNB Chain settlement counterpart.'),
networkLink(56, 'BNB Chain', 'cWUSDC', '0x5355148C4740fcc3D7a96F05EdD89AB14851206b', 'GRU wrapped transport representation on BNB Chain.'),
networkLink(137, 'Polygon', 'USDC', '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', 'Native Polygon settlement counterpart.'),
networkLink(137, 'Polygon', 'cWUSDC', '0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4', 'GRU wrapped transport representation on Polygon.'),
networkLink(100, 'Gnosis', 'USDC', '0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83', 'Native Gnosis settlement counterpart.'),
networkLink(100, 'Gnosis', 'cWUSDC', '0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4', 'GRU wrapped transport representation on Gnosis.'),
],
},
{
symbol: 'cUSDT',
addresses: [
'0x93e66202a11b1772e55407b32b44e5cd8eda7f22',
'0x9fbfab33882efe0038daa608185718b772ee5660',
],
currencyCode: 'USD',
iso20022Ready: true,
x402Ready: true,
activeVersion: 'v1',
transportActiveVersion: 'v1',
x402PreferredVersion: 'v2',
canonicalForwardVersion: 'v2',
canonicalForwardAddress: '0x9FBfab33882Efe0038DAa608185718b772EE5660',
otherNetworks: [
networkLink(651940, 'ALL Mainnet (Alltra)', 'AUSDT', '0x015B1897Ed5279930bC2Be46F661894d219292A6', 'Primary Alltra-native origin counterpart.'),
networkLink(1, 'Ethereum Mainnet', 'USDT', '0xdAC17F958D2ee523a2206206994597C13D831ec7', 'Native Ethereum settlement counterpart.'),
networkLink(1, 'Ethereum Mainnet', 'cWUSDT', '0xaF5017d0163ecb99D9B5D94e3b4D7b09Af44D8AE', 'GRU wrapped transport representation on Ethereum.'),
networkLink(56, 'BNB Chain', 'USDT', '0x55d398326f99059fF775485246999027B3197955', 'Native BNB Chain settlement counterpart.'),
networkLink(56, 'BNB Chain', 'cWUSDT', '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB', 'GRU wrapped transport representation on BNB Chain.'),
networkLink(137, 'Polygon', 'USDT', '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', 'Native Polygon settlement counterpart.'),
networkLink(137, 'Polygon', 'cWUSDT', '0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF', 'GRU wrapped transport representation on Polygon.'),
networkLink(100, 'Gnosis', 'USDT', '0x4ECaBa5870353805a9F068101A40E0f32ed605C6', 'Native Gnosis settlement counterpart.'),
networkLink(100, 'Gnosis', 'cWUSDT', '0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF', 'GRU wrapped transport representation on Gnosis.'),
],
},
{
symbol: 'cEURC',
addresses: ['0x8085961f9cf02b4d800a3c6d386d31da4b34266a', '0x243e6581dc8a98d98b92265858b322b193555c81'],
currencyCode: 'EUR',
iso20022Ready: true,
x402Ready: true,
activeVersion: 'v2',
x402PreferredVersion: 'v2',
canonicalForwardVersion: 'v2',
canonicalForwardAddress: '0x243e6581Dc8a98d98B92265858b322b193555C81',
otherNetworks: [
networkLink(56, 'BNB Chain', 'cWEURC', '0x50b073d0D1D2f002745cb9FC28a057d5be84911c', 'GRU wrapped transport representation on BNB Chain.'),
networkLink(137, 'Polygon', 'cWEURC', '0x3CD9ee18db7ad13616FCC1c83bC6098e03968E66', 'GRU wrapped transport representation on Polygon.'),
networkLink(100, 'Gnosis', 'cWEURC', '0x25603ae4bff0b71d637b3573d1b6657f5f6d17ef', 'GRU wrapped transport representation on Gnosis.'),
],
},
{
symbol: 'cEURT',
addresses: ['0xdf4b71c61e5912712c1bdd451416b9ac26949d72', '0x2bafa83d8ff8bae9505511998987d0659791605b'],
currencyCode: 'EUR',
iso20022Ready: true,
x402Ready: true,
activeVersion: 'v2',
x402PreferredVersion: 'v2',
canonicalForwardVersion: 'v2',
canonicalForwardAddress: '0x2bAFA83d8fF8BaE9505511998987D0659791605B',
otherNetworks: [
networkLink(56, 'BNB Chain', 'cWEURT', '0x1ED9E491A5eCd53BeF21962A5FCE24880264F63f', 'GRU wrapped transport representation on BNB Chain.'),
networkLink(137, 'Polygon', 'cWEURT', '0xBeF5A0Bcc0E77740c910f197138cdD90F98d2427', 'GRU wrapped transport representation on Polygon.'),
networkLink(100, 'Gnosis', 'cWEURT', '0x8e54c52d34a684e22865ac9f2d7c27c30561a7b9', 'GRU wrapped transport representation on Gnosis.'),
],
},
...[
['cGBPC', 'GBP', '0x003960f16d9d34f2e98d62723b6721fb92074ad2', '0x707508D223103f5D2d9EFBc656302c9d48878b29'],
['cGBPT', 'GBP', '0x350f54e4d23795f86a9c03988c7135357ccad97c', '0xee17c18E10E55ce23F7457D018aAa2Fb1E64B281'],
['cAUDC', 'AUD', '0xd51482e567c03899eece3cae8a058161fd56069d', '0xfb37aFd415B70C5cEDc9bA58a72D517207b769Bb'],
['cJPYC', 'JPY', '0xee269e1226a334182aace90056ee4ee5cc8a6770', '0x2c751bBE4f299b989b3A8c333E0A966cdcA6Fd98'],
['cCHFC', 'CHF', '0x873990849dda5117d7c644f0af24370797c03885', '0x60B7FB8e0DD0Be8595AD12Fe80AE832861Be747c'],
['cCADC', 'CAD', '0x54dbd40cf05e15906a2c21f600937e96787f5679', '0xe799033c87fE0CE316DAECcefBE3134CC74b76a9'],
['cXAUC', 'XAU', '0x290e52a8819a4fbd0714e517225429aa2b70ec6b', '0xF0F0F81bE3D033D8586bAfd2293e37eE2f615647'],
['cXAUT', 'XAU', '0x94e408e26c6fd8f4ee00b54df19082fda07dc96e', '0x89477E982847023aaB5C3492082cd1bB4b1b9Ef1'],
].map(([symbol, currencyCode, v1Address, v2Address]) => ({
symbol,
addresses: [v1Address.toLowerCase(), v2Address.toLowerCase()],
currencyCode,
iso20022Ready: true,
x402Ready: true,
activeVersion: 'v2',
x402PreferredVersion: 'v2',
canonicalForwardVersion: 'v2',
canonicalForwardAddress: v2Address,
otherNetworks: [],
})),
]
function normalizeAddress(address?: string | null): string {
return (address || '').trim().toLowerCase()
}
function normalizeSymbol(symbol?: string | null): string {
return (symbol || '').trim().toUpperCase()
}
export function getGruExplorerMetadata(input: {
address?: string | null
symbol?: string | null
}): GruExplorerMetadata | null {
const address = normalizeAddress(input.address)
const symbol = normalizeSymbol(input.symbol)
const matched = GRU_EXPLORER_ENTRIES.find((entry) => {
if (address && entry.addresses.includes(address)) return true
if (symbol && entry.symbol.toUpperCase() === symbol) return true
return false
})
return matched || null
}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest'
import { missionControlApi } from './missionControl'
class FakeEventSource {
static instances: FakeEventSource[] = []
onmessage: ((event: MessageEvent<string>) => void) | null = null
onerror: (() => void) | null = null
listeners = new Map<string, Set<(event: MessageEvent<string>) => void>>()
constructor(public readonly url: string) {
FakeEventSource.instances.push(this)
}
addEventListener(event: string, handler: (event: MessageEvent<string>) => void) {
const existing = this.listeners.get(event) || new Set()
existing.add(handler)
this.listeners.set(event, existing)
}
removeEventListener(event: string, handler: (event: MessageEvent<string>) => void) {
this.listeners.get(event)?.delete(handler)
}
close() {}
emit(event: string, data: unknown) {
const payload = { data: JSON.stringify(data) } as MessageEvent<string>
for (const handler of this.listeners.get(event) || []) {
handler(payload)
}
}
}
describe('missionControlApi.subscribeBridgeStatus', () => {
it('subscribes to the named mission-control SSE event', () => {
const originalWindow = globalThis.window
const fakeWindow = {
EventSource: FakeEventSource as unknown as typeof EventSource,
location: {
origin: 'https://explorer.example.org',
},
} as Window & typeof globalThis
// @ts-expect-error test shim
globalThis.window = fakeWindow
const onStatus = vi.fn()
const unsubscribe = missionControlApi.subscribeBridgeStatus(onStatus)
const instance = FakeEventSource.instances.at(-1)
expect(instance).toBeTruthy()
instance?.emit('mission-control', { data: { status: 'operational' } })
expect(onStatus).toHaveBeenCalledWith({ data: { status: 'operational' } })
unsubscribe()
globalThis.window = originalWindow
})
})

View File

@@ -220,7 +220,7 @@ export const missionControlApi = {
const eventSource = new window.EventSource(getMissionControlStreamUrl())
eventSource.onmessage = (event) => {
const handleMessage = (event: MessageEvent<string>) => {
try {
const payload = JSON.parse(event.data) as MissionControlBridgeStatusResponse
onStatus(payload)
@@ -229,11 +229,15 @@ export const missionControlApi = {
}
}
eventSource.addEventListener('mission-control', handleMessage)
eventSource.onmessage = handleMessage
eventSource.onerror = () => {
onError?.(new Error('Mission-control live stream connection lost'))
}
return () => {
eventSource.removeEventListener('mission-control', handleMessage)
eventSource.close()
}
},

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { normalizeMissionControlLiquidityPools } from './routes'
describe('normalizeMissionControlLiquidityPools', () => {
it('accepts the nested backend proxy shape', () => {
expect(
normalizeMissionControlLiquidityPools({
data: {
count: 2,
pools: [
{ address: '0x1', dex: 'DODO' },
{ address: '0x2', dex: 'Uniswap' },
],
},
})
).toEqual({
count: 2,
pools: [
{ address: '0x1', dex: 'DODO' },
{ address: '0x2', dex: 'Uniswap' },
],
})
})
it('keeps working with a flat legacy shape', () => {
expect(
normalizeMissionControlLiquidityPools({
pools: [{ address: '0xabc', dex: 'DODO' }],
})
).toEqual({
count: 1,
pools: [{ address: '0xabc', dex: 'DODO' }],
})
})
})

View File

@@ -67,9 +67,42 @@ export interface MissionControlLiquidityPool {
}
export interface MissionControlLiquidityPoolsResponse {
count?: number
pools?: MissionControlLiquidityPool[]
}
interface RawMissionControlLiquidityPoolsResponse {
count?: number
pools?: MissionControlLiquidityPool[]
data?: {
count?: number
pools?: MissionControlLiquidityPool[]
}
}
export function normalizeMissionControlLiquidityPools(
raw: RawMissionControlLiquidityPoolsResponse | null | undefined
): MissionControlLiquidityPoolsResponse {
if (!raw) {
return { count: 0, pools: [] }
}
const nested = raw.data
const pools = Array.isArray(raw.pools)
? raw.pools
: Array.isArray(nested?.pools)
? nested.pools
: []
const count = typeof raw.count === 'number'
? raw.count
: typeof nested?.count === 'number'
? nested.count
: pools.length
return { count, pools }
}
const tokenAggregationBase = `${getExplorerApiBase()}/token-aggregation/api/v1`
const missionControlBase = `${getExplorerApiBase()}/explorer-api/v1/mission-control`
@@ -89,7 +122,9 @@ export const routesApi = {
fetchJson<RouteMatrixResponse>(`${tokenAggregationBase}/routes/matrix?includeNonLive=true`),
getTokenPools: async (tokenAddress: string): Promise<MissionControlLiquidityPoolsResponse> =>
fetchJson<MissionControlLiquidityPoolsResponse>(
`${missionControlBase}/liquidity/token/${tokenAddress}/pools`
normalizeMissionControlLiquidityPools(
await fetchJson<RawMissionControlLiquidityPoolsResponse>(
`${missionControlBase}/liquidity/token/${tokenAddress}/pools`
)
),
}

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { normalizeExplorerStats } from './stats'
import {
normalizeExplorerStats,
normalizeTransactionTrend,
summarizeRecentTransactions,
} from './stats'
describe('normalizeExplorerStats', () => {
it('normalizes the local explorer stats shape', () => {
@@ -32,4 +36,49 @@ describe('normalizeExplorerStats', () => {
latest_block: null,
})
})
it('normalizes transaction trend chart data', () => {
expect(
normalizeTransactionTrend({
chart_data: [
{ date: '2026-04-08', transaction_count: '2' },
{ date: '2026-04-07', transaction_count: 101 },
],
}),
).toEqual([
{ date: '2026-04-08', transaction_count: 2 },
{ date: '2026-04-07', transaction_count: 101 },
])
})
it('summarizes recent activity metrics from main-page transactions', () => {
expect(
summarizeRecentTransactions([
{
status: 'ok',
transaction_types: ['contract_call', 'token_transfer'],
gas_used: '100',
fee: { value: '200' },
},
{
status: 'error',
transaction_types: ['contract_creation'],
gas_used: '300',
fee: { value: '400' },
},
]),
).toEqual({
success_rate: 0.5,
failure_rate: 0.5,
average_gas_used: 200,
average_fee_wei: 300,
contract_creations: 1,
token_transfer_txs: 1,
contract_calls: 1,
contract_creation_share: 0.5,
token_transfer_share: 0.5,
contract_call_share: 0.5,
sample_size: 2,
})
})
})

View File

@@ -7,6 +7,25 @@ export interface ExplorerStats {
latest_block: number | null
}
export interface ExplorerTransactionTrendPoint {
date: string
transaction_count: number
}
export interface ExplorerRecentActivitySnapshot {
success_rate: number
failure_rate: number
average_gas_used: number
average_fee_wei: number
contract_creations: number
token_transfer_txs: number
contract_calls: number
contract_creation_share: number
token_transfer_share: number
contract_call_share: number
sample_size: number
}
interface RawExplorerStats {
total_blocks?: number | string | null
total_transactions?: number | string | null
@@ -34,6 +53,67 @@ export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
}
}
export function normalizeTransactionTrend(raw: {
chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }>
}): ExplorerTransactionTrendPoint[] {
return Array.isArray(raw.chart_data)
? raw.chart_data.map((entry) => ({
date: entry.date || '',
transaction_count: toNumber(entry.transaction_count),
}))
: []
}
export function summarizeRecentTransactions(
raw: Array<{
status?: string | null
transaction_types?: string[] | null
gas_used?: number | string | null
fee?: { value?: string | number | null } | string | null
}>,
): ExplorerRecentActivitySnapshot {
if (!Array.isArray(raw) || raw.length === 0) {
return {
success_rate: 0,
failure_rate: 0,
average_gas_used: 0,
average_fee_wei: 0,
contract_creations: 0,
token_transfer_txs: 0,
contract_calls: 0,
contract_creation_share: 0,
token_transfer_share: 0,
contract_call_share: 0,
sample_size: 0,
}
}
const sampleSize = raw.length
const successes = raw.filter((transaction) => ['ok', 'success', '1'].includes((transaction.status || '').toLowerCase())).length
const totalGasUsed = raw.reduce((sum, transaction) => sum + toNumber(transaction.gas_used), 0)
const totalFeeWei = raw.reduce((sum, transaction) => {
const feeValue = typeof transaction.fee === 'string' ? transaction.fee : transaction.fee?.value
return sum + toNumber(feeValue)
}, 0)
const contractCreations = raw.filter((transaction) => transaction.transaction_types?.includes('contract_creation')).length
const tokenTransferTxs = raw.filter((transaction) => transaction.transaction_types?.includes('token_transfer')).length
const contractCalls = raw.filter((transaction) => transaction.transaction_types?.includes('contract_call')).length
return {
success_rate: successes / sampleSize,
failure_rate: (sampleSize - successes) / sampleSize,
average_gas_used: totalGasUsed / sampleSize,
average_fee_wei: totalFeeWei / sampleSize,
contract_creations: contractCreations,
token_transfer_txs: tokenTransferTxs,
contract_calls: contractCalls,
contract_creation_share: contractCreations / sampleSize,
token_transfer_share: tokenTransferTxs / sampleSize,
contract_call_share: contractCalls / sampleSize,
sample_size: sampleSize,
}
}
export const statsApi = {
get: async (): Promise<ExplorerStats> => {
const response = await fetch(`${getExplorerApiBase()}/api/v2/stats`)
@@ -43,4 +123,25 @@ export const statsApi = {
const json = (await response.json()) as RawExplorerStats
return normalizeExplorerStats(json)
},
getTransactionTrend: async (): Promise<ExplorerTransactionTrendPoint[]> => {
const response = await fetch(`${getExplorerApiBase()}/api/v2/stats/charts/transactions`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const json = (await response.json()) as { chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }> }
return normalizeTransactionTrend(json)
},
getRecentActivitySnapshot: async (): Promise<ExplorerRecentActivitySnapshot> => {
const response = await fetch(`${getExplorerApiBase()}/api/v2/main-page/transactions`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const json = (await response.json()) as Array<{
status?: string | null
transaction_types?: string[] | null
gas_used?: number | string | null
fee?: { value?: string | number | null } | string | null
}>
return summarizeRecentTransactions(json)
},
}

View File

@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { tokensApi } from './tokens'
describe('tokensApi', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('normalizes a token profile, holders, and transfers safely', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
address: '0xtoken',
symbol: 'cUSDT',
name: 'Tether USD (Compliant)',
decimals: '6',
holders: '37',
total_supply: '1000',
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
items: [
{
address: { hash: '0xholder', name: 'Treasury' },
value: '500',
token: { decimals: '6' },
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
items: [
{
transaction_hash: '0xtx',
block_number: 1,
from: { hash: '0xfrom' },
to: { hash: '0xto' },
token: {
address: '0xtoken',
symbol: 'cUSDT',
name: 'Tether USD (Compliant)',
decimals: '6',
},
total: { decimals: '6', value: '100' },
},
],
}),
})
vi.stubGlobal('fetch', fetchMock)
const token = await tokensApi.getSafe('0xtoken')
const holders = await tokensApi.getHoldersSafe('0xtoken')
const transfers = await tokensApi.getTransfersSafe('0xtoken')
expect(token.ok).toBe(true)
expect(token.data?.symbol).toBe('cUSDT')
expect(holders.data[0].label).toBe('Treasury')
expect(transfers.data[0].token_symbol).toBe('cUSDT')
})
it('builds provenance and curated token lists from the token list config', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
tokens: [
{
chainId: 138,
address: '0xlisted',
symbol: 'cUSDT',
name: 'Tether USD (Compliant)',
tags: ['compliant', 'bridge'],
},
{
chainId: 1,
address: '0xother',
symbol: 'OTHER',
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
tokens: [
{
chainId: 138,
address: '0xlisted',
symbol: 'cUSDT',
name: 'Tether USD (Compliant)',
tags: ['compliant', 'bridge'],
},
],
}),
})
vi.stubGlobal('fetch', fetchMock)
const provenance = await tokensApi.getProvenanceSafe('0xlisted')
const curated = await tokensApi.listCuratedSafe(138)
expect(provenance.ok).toBe(true)
expect(provenance.data?.listed).toBe(true)
expect(provenance.data?.tags).toEqual(['compliant', 'bridge'])
expect(curated.data).toHaveLength(1)
expect(curated.data[0].symbol).toBe('cUSDT')
})
})

View File

@@ -0,0 +1,205 @@
import { fetchBlockscoutJson, normalizeAddressTokenTransfer, type BlockscoutTokenTransfer } from './blockscout'
import { configApi, type TokenListToken } from './config'
import { routesApi, type MissionControlLiquidityPool } from './routes'
import type { AddressTokenTransfer } from './addresses'
export interface TokenProfile {
address: string
name?: string
symbol?: string
decimals: number
type?: string
total_supply?: string
holders?: number
exchange_rate?: string | number | null
icon_url?: string | null
circulating_market_cap?: string | number | null
volume_24h?: string | number | null
}
export interface TokenHolder {
address: string
label?: string
value: string
token_decimals: number
}
export interface TokenProvenance {
listed: boolean
chainId?: number
name?: string
symbol?: string
logoURI?: string
tags: string[]
}
function normalizeTokenProfile(raw: {
address: string
name?: string | null
symbol?: string | null
decimals?: string | number | null
type?: string | null
total_supply?: string | null
holders?: string | number | null
exchange_rate?: string | number | null
icon_url?: string | null
circulating_market_cap?: string | number | null
volume_24h?: string | number | null
}): TokenProfile {
return {
address: raw.address,
name: raw.name || undefined,
symbol: raw.symbol || undefined,
decimals: Number(raw.decimals || 0),
type: raw.type || undefined,
total_supply: raw.total_supply || undefined,
holders: raw.holders != null ? Number(raw.holders) : undefined,
exchange_rate: raw.exchange_rate ?? null,
icon_url: raw.icon_url ?? null,
circulating_market_cap: raw.circulating_market_cap ?? null,
volume_24h: raw.volume_24h ?? null,
}
}
function normalizeTokenHolder(raw: {
address?: {
hash?: string | null
name?: string | null
label?: string | null
} | null
value?: string | null
token?: {
decimals?: string | number | null
} | null
}): TokenHolder {
return {
address: raw.address?.hash || '',
label: raw.address?.name || raw.address?.label || undefined,
value: raw.value || '0',
token_decimals: Number(raw.token?.decimals || 0),
}
}
async function getTokenListLookup(): Promise<Map<string, TokenListToken>> {
const response = await configApi.getTokenList()
const lookup = new Map<string, TokenListToken>()
for (const token of response.tokens || []) {
if (token.address) {
lookup.set(token.address.toLowerCase(), token)
}
}
return lookup
}
export const tokensApi = {
getSafe: async (address: string): Promise<{ ok: boolean; data: TokenProfile | null }> => {
try {
const raw = await fetchBlockscoutJson<{
address: string
name?: string | null
symbol?: string | null
decimals?: string | number | null
type?: string | null
total_supply?: string | null
holders?: string | number | null
exchange_rate?: string | number | null
icon_url?: string | null
circulating_market_cap?: string | number | null
volume_24h?: string | number | null
}>(`/api/v2/tokens/${address}`)
return { ok: true, data: normalizeTokenProfile(raw) }
} catch {
return { ok: false, data: null }
}
},
getTransfersSafe: async (
address: string,
page = 1,
pageSize = 10
): Promise<{ ok: boolean; data: AddressTokenTransfer[] }> => {
try {
const params = new URLSearchParams({
page: page.toString(),
items_count: pageSize.toString(),
})
const raw = await fetchBlockscoutJson<{ items?: BlockscoutTokenTransfer[] }>(
`/api/v2/tokens/${address}/transfers?${params.toString()}`
)
return {
ok: true,
data: Array.isArray(raw.items) ? raw.items.map((item) => normalizeAddressTokenTransfer(item)) : [],
}
} catch {
return { ok: false, data: [] }
}
},
getHoldersSafe: async (
address: string,
page = 1,
pageSize = 10
): Promise<{ ok: boolean; data: TokenHolder[] }> => {
try {
const params = new URLSearchParams({
page: page.toString(),
items_count: pageSize.toString(),
})
const raw = await fetchBlockscoutJson<{ items?: Array<{
address?: { hash?: string | null; name?: string | null; label?: string | null } | null
value?: string | null
token?: { decimals?: string | number | null } | null
}> }>(`/api/v2/tokens/${address}/holders?${params.toString()}`)
return {
ok: true,
data: Array.isArray(raw.items) ? raw.items.map((item) => normalizeTokenHolder(item)) : [],
}
} catch {
return { ok: false, data: [] }
}
},
getProvenanceSafe: async (address: string): Promise<{ ok: boolean; data: TokenProvenance | null }> => {
try {
const lookup = await getTokenListLookup()
const token = lookup.get(address.toLowerCase())
if (!token) {
return { ok: true, data: { listed: false, tags: [] } }
}
return {
ok: true,
data: {
listed: true,
chainId: token.chainId,
name: token.name,
symbol: token.symbol,
logoURI: token.logoURI,
tags: token.tags || [],
},
}
} catch {
return { ok: false, data: null }
}
},
listCuratedSafe: async (chainId = 138): Promise<{ ok: boolean; data: TokenListToken[] }> => {
try {
const response = await configApi.getTokenList()
const data = (response.tokens || [])
.filter((token) => token.chainId === chainId && typeof token.address === 'string' && token.address.trim().length > 0)
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || right.name || ''))
return { ok: true, data }
} catch {
return { ok: false, data: [] }
}
},
getRelatedPoolsSafe: async (address: string): Promise<{ ok: boolean; data: MissionControlLiquidityPool[] }> => {
try {
const response = await routesApi.getTokenPools(address)
return { ok: true, data: response.pools || [] }
} catch {
return { ok: false, data: [] }
}
},
}

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { transactionsApi } from './transactions'
describe('transactionsApi.diagnoseMissing', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('distinguishes missing explorer and missing rpc results', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({ message: 'Not found' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ result: null }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ result: null }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ result: '0x39fb85' }),
})
vi.stubGlobal('fetch', fetchMock)
const diagnostic = await transactionsApi.diagnoseMissing(
138,
'0x14d7259e6e75bbcd8979dc9e19f3001b0f328bbcdb730937f8cc2e6a52f26da6'
)
expect(diagnostic.checked_hash).toBe('0x14d7259e6e75bbcd8979dc9e19f3001b0f328bbcdb730937f8cc2e6a52f26da6')
expect(diagnostic.chain_id).toBe(138)
expect(diagnostic.explorer_indexed).toBe(false)
expect(diagnostic.rpc_transaction_found).toBe(false)
expect(diagnostic.rpc_receipt_found).toBe(false)
expect(diagnostic.latest_block_number).toBeTypeOf('number')
expect(diagnostic.rpc_url).toBe('https://rpc-http-pub.d-bis.org')
})
it('reports when rpc can still see a transaction the explorer has not indexed', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({ message: 'Not found' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ result: { hash: '0xabc' } }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ result: null }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ result: '0x10' }),
})
vi.stubGlobal('fetch', fetchMock)
const diagnostic = await transactionsApi.diagnoseMissing(138, '0xabc')
expect(diagnostic.explorer_indexed).toBe(false)
expect(diagnostic.rpc_transaction_found).toBe(true)
expect(diagnostic.rpc_receipt_found).toBe(false)
expect(diagnostic.latest_block_number).toBe(16)
})
})

View File

@@ -1,5 +1,6 @@
import { apiClient, ApiResponse } from './client'
import { fetchBlockscoutJson, normalizeTransaction } from './blockscout'
import { ApiResponse } from './client'
import { fetchBlockscoutJson, normalizeTransaction, type BlockscoutInternalTransaction } from './blockscout'
import { resolveExplorerApiBase } from '../../../libs/frontend-api-client/api-base'
export interface Transaction {
chain_id: number
@@ -19,6 +20,165 @@ export interface Transaction {
input_data?: string
contract_address?: string
created_at: string
fee?: string
method?: string
revert_reason?: string
transaction_tag?: string
decoded_input?: {
method_call?: string
method_id?: string
parameters: Array<{
name?: string
type?: string
value?: unknown
}>
}
token_transfers?: TransactionTokenTransfer[]
}
export interface TransactionTokenTransfer {
block_number?: number
from_address: string
from_label?: string
to_address: string
to_label?: string
token_address: string
token_name?: string
token_symbol?: string
token_decimals: number
amount: string
type?: string
timestamp?: string
}
export interface TransactionInternalCall {
from_address: string
from_label?: string
to_address?: string
to_label?: string
contract_address?: string
contract_label?: string
type?: string
value: string
success?: boolean
error?: string
result?: string
timestamp?: string
}
export interface TransactionLookupDiagnostic {
checked_hash: string
chain_id: number
explorer_indexed: boolean
rpc_transaction_found: boolean
rpc_receipt_found: boolean
latest_block_number?: number
rpc_url?: string
}
const CHAIN_138_PUBLIC_RPC_URL = 'https://rpc-http-pub.d-bis.org'
function resolvePublicRpcUrl(chainId: number): string | null {
if (chainId !== 138) {
return null
}
const envValue = (process.env.NEXT_PUBLIC_CHAIN_138_RPC_URL || '').trim()
return envValue || CHAIN_138_PUBLIC_RPC_URL
}
async function fetchJsonWithStatus<T>(input: RequestInfo | URL, init?: RequestInit): Promise<{ ok: boolean; status: number; data: T | null }> {
const response = await fetch(input, init)
let data: T | null = null
try {
data = (await response.json()) as T
} catch {
data = null
}
return { ok: response.ok, status: response.status, data }
}
async function fetchRpcResult<T>(rpcUrl: string, method: string, params: unknown[]): Promise<T | null> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 6000)
try {
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method,
params,
}),
signal: controller.signal,
})
if (!response.ok) {
return null
}
const payload = (await response.json()) as { result?: T | null }
return payload.result ?? null
} catch {
return null
} finally {
clearTimeout(timeout)
}
}
async function diagnoseMissingTransaction(chainId: number, hash: string): Promise<TransactionLookupDiagnostic> {
const diagnostic: TransactionLookupDiagnostic = {
checked_hash: hash,
chain_id: chainId,
explorer_indexed: false,
rpc_transaction_found: false,
rpc_receipt_found: false,
}
const explorerLookup = await fetchJsonWithStatus<unknown>(`${resolveExplorerApiBase()}/api/v2/transactions/${hash}`)
diagnostic.explorer_indexed = explorerLookup.ok
const rpcUrl = resolvePublicRpcUrl(chainId)
if (!rpcUrl) {
return diagnostic
}
diagnostic.rpc_url = rpcUrl
const [transactionResult, receiptResult, latestBlockHex] = await Promise.all([
fetchRpcResult<string | Record<string, unknown>>(rpcUrl, 'eth_getTransactionByHash', [hash]),
fetchRpcResult<string | Record<string, unknown>>(rpcUrl, 'eth_getTransactionReceipt', [hash]),
fetchRpcResult<string>(rpcUrl, 'eth_blockNumber', []),
])
diagnostic.rpc_transaction_found = transactionResult != null
diagnostic.rpc_receipt_found = receiptResult != null
if (typeof latestBlockHex === 'string' && latestBlockHex.startsWith('0x')) {
diagnostic.latest_block_number = parseInt(latestBlockHex, 16)
}
return diagnostic
}
function normalizeInternalTransactions(items: BlockscoutInternalTransaction[] | null | undefined): TransactionInternalCall[] {
if (!Array.isArray(items)) {
return []
}
return items.map((item) => ({
from_address: item.from?.hash || '',
from_label: item.from?.name || item.from?.label || undefined,
to_address: item.to?.hash || undefined,
to_label: item.to?.name || item.to?.label || undefined,
contract_address: item.created_contract?.hash || undefined,
contract_label: item.created_contract?.name || item.created_contract?.label || undefined,
type: item.type || undefined,
value: item.value || '0',
success: item.success ?? undefined,
error: item.error || undefined,
result: item.result || undefined,
timestamp: item.timestamp || undefined,
}))
}
export const transactionsApi = {
@@ -35,6 +195,20 @@ export const transactionsApi = {
return { ok: false, data: null }
}
},
diagnoseMissing: async (chainId: number, hash: string): Promise<TransactionLookupDiagnostic> => {
return diagnoseMissingTransaction(chainId, hash)
},
getInternalTransactionsSafe: async (hash: string): Promise<{ ok: boolean; data: TransactionInternalCall[] }> => {
try {
const raw = await fetchBlockscoutJson<{ items?: BlockscoutInternalTransaction[] } | BlockscoutInternalTransaction[]>(
`/api/v2/transactions/${hash}/internal-transactions`
)
const items = Array.isArray(raw) ? raw : raw.items
return { ok: true, data: normalizeInternalTransactions(items) }
} catch {
return { ok: false, data: [] }
}
},
list: async (chainId: number, page: number, pageSize: number): Promise<ApiResponse<Transaction[]>> => {
const params = new URLSearchParams({
page: page.toString(),

View File

@@ -1,25 +1,29 @@
import type { Block } from '@/services/api/blocks'
import type { ExplorerStats } from '@/services/api/stats'
import type { ExplorerStats, ExplorerTransactionTrendPoint } from '@/services/api/stats'
export interface DashboardData {
stats: ExplorerStats | null
recentBlocks: Block[]
recentTransactionTrend: ExplorerTransactionTrendPoint[]
}
export interface DashboardLoaders {
loadStats: () => Promise<ExplorerStats>
loadRecentBlocks: () => Promise<Block[]>
onError?: (scope: 'stats' | 'blocks', error: unknown) => void
loadRecentTransactionTrend?: () => Promise<ExplorerTransactionTrendPoint[]>
onError?: (scope: 'stats' | 'blocks' | 'trend', error: unknown) => void
}
export async function loadDashboardData({
loadStats,
loadRecentBlocks,
loadRecentTransactionTrend,
onError,
}: DashboardLoaders): Promise<DashboardData> {
const [statsResult, recentBlocksResult] = await Promise.allSettled([
const [statsResult, recentBlocksResult, recentTransactionTrendResult] = await Promise.allSettled([
loadStats(),
loadRecentBlocks(),
loadRecentTransactionTrend ? loadRecentTransactionTrend() : Promise.resolve([]),
])
if (statsResult.status === 'rejected') {
@@ -30,8 +34,14 @@ export async function loadDashboardData({
onError?.('blocks', recentBlocksResult.reason)
}
if (recentTransactionTrendResult.status === 'rejected') {
onError?.('trend', recentTransactionTrendResult.reason)
}
return {
stats: statsResult.status === 'fulfilled' ? statsResult.value : null,
recentBlocks: recentBlocksResult.status === 'fulfilled' ? recentBlocksResult.value : [],
recentTransactionTrend:
recentTransactionTrendResult.status === 'fulfilled' ? recentTransactionTrendResult.value : [],
}
}

View File

@@ -28,3 +28,43 @@ export function formatWeiAsEth(value: string, fractionDigits = 4): string {
return '0 ETH'
}
}
export function formatUnits(value: string | number | null | undefined, decimals = 18, fractionDigits = 4): string {
try {
const safeDecimals = Math.max(0, Math.min(36, decimals))
const normalizedDigits = Math.max(0, Math.min(12, fractionDigits))
const amount = BigInt(typeof value === 'number' ? value.toString() : value || '0')
const divisor = 10n ** BigInt(safeDecimals)
const whole = amount / divisor
const fraction = amount % divisor
if (normalizedDigits === 0 || safeDecimals === 0) {
return whole.toString()
}
const scale = 10n ** BigInt(Math.max(0, safeDecimals - normalizedDigits))
const truncatedFraction = fraction / scale
const paddedFraction = truncatedFraction
.toString()
.padStart(Math.min(normalizedDigits, safeDecimals), '0')
.replace(/0+$/, '')
return paddedFraction ? `${whole.toString()}.${paddedFraction}` : whole.toString()
} catch {
return '0'
}
}
export function formatTokenAmount(value: string | number | null | undefined, decimals = 18, symbol?: string | null, fractionDigits = 4): string {
const formatted = formatUnits(value, decimals, fractionDigits)
return symbol ? `${formatted} ${symbol}` : formatted
}
export function formatTimestamp(value?: string | null): string {
if (!value) return 'Unknown'
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return date.toLocaleString()
}

View File

@@ -0,0 +1,32 @@
export interface PublicFetchMetadata {
source: string
lastModified: string | null
}
export function getPublicExplorerBase(): string {
const configured = (process.env.NEXT_PUBLIC_API_URL || '').trim()
return configured || 'https://blockscout.defi-oracle.io'
}
export async function fetchPublicJson<T>(path: string): Promise<T> {
const response = await fetch(`${getPublicExplorerBase()}${path}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return (await response.json()) as T
}
export async function fetchPublicJsonWithMeta<T>(path: string): Promise<{ data: T; meta: PublicFetchMetadata }> {
const response = await fetch(`${getPublicExplorerBase()}${path}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return {
data: (await response.json()) as T,
meta: {
source: response.headers.get('X-Config-Source') || 'explorer-api',
lastModified: response.headers.get('Last-Modified'),
},
}
}

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { inferDirectSearchTarget } from './search'
import {
inferDirectSearchTarget,
inferTokenSearchTarget,
normalizeExplorerSearchResults,
suggestCuratedTokens,
} from './search'
describe('inferDirectSearchTarget', () => {
it('detects addresses and normalizes the prefix', () => {
@@ -35,4 +40,115 @@ describe('inferDirectSearchTarget', () => {
it('returns null for generic text', () => {
expect(inferDirectSearchTarget('cUSDT')).toBeNull()
})
it('detects curated token symbols and addresses', () => {
expect(inferTokenSearchTarget('cUSDT', [
{ chainId: 138, symbol: 'cUSDT', address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22' },
])).toEqual({
kind: 'token',
href: '/tokens/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
label: 'Open token (cUSDT)',
})
expect(inferTokenSearchTarget('0x93e66202a11b1772e55407b32b44e5cd8eda7f22', [
{ chainId: 138, symbol: 'cUSDT', address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22' },
])).toEqual({
kind: 'token',
href: '/tokens/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
label: 'Open token (cUSDT)',
})
})
})
describe('normalizeExplorerSearchResults', () => {
it('keeps token metadata and prefers token identity over duplicate address results', () => {
const results = normalizeExplorerSearchResults(
'0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
[
{
type: 'token',
address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
name: 'Tether USD (Compliant)',
symbol: 'cUSDT',
token_type: 'ERC-20',
priority: 2,
},
{
type: 'address',
address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
name: 'Tether USD (Compliant)',
priority: 0,
},
],
[
{
chainId: 138,
address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
symbol: 'cUSDT',
name: 'Tether USD (Compliant)',
},
],
)
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
type: 'token',
href: '/tokens/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
label: 'Token · cUSDT',
name: 'Tether USD (Compliant)',
symbol: 'cUSDT',
token_type: 'ERC-20',
is_curated_token: true,
match_reason: 'exact curated token address',
})
})
it('sorts exact token matches ahead of weaker results', () => {
const results = normalizeExplorerSearchResults(
'USDT',
[
{
type: 'address',
address: '0xabc0000000000000000000000000000000000000',
name: 'Treasury address',
priority: 5,
},
{
type: 'token',
address: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
name: 'Tether USD (Chain 138)',
symbol: 'USDT',
token_type: 'ERC-20',
priority: 2,
},
],
)
expect(results[0]).toMatchObject({
type: 'token',
symbol: 'USDT',
href: '/tokens/0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
})
})
it('suggests curated tokens by partial symbol, name, or tag', () => {
expect(
suggestCuratedTokens('gold', [
{
chainId: 138,
address: '0x1',
symbol: 'cXAUC',
name: 'Gold Reserve Unit',
tags: ['listed', 'gold'],
},
{
chainId: 138,
address: '0x2',
symbol: 'USDT',
name: 'Tether USD',
tags: ['listed'],
},
]),
).toHaveLength(1)
})
})

View File

@@ -1,7 +1,52 @@
import { getGruCatalogPosture } from '@/services/api/gruCatalog'
export type DirectSearchTarget =
| { kind: 'address'; href: string; label: string }
| { kind: 'transaction'; href: string; label: string }
| { kind: 'block'; href: string; label: string }
| { kind: 'token'; href: string; label: string }
export interface SearchTokenHint {
chainId?: number
symbol?: string
address?: string
name?: string
tags?: string[]
}
export interface RawExplorerSearchItem {
type?: string | null
address?: string | null
block_number?: number | string | null
transaction_hash?: string | null
priority?: number | null
name?: string | null
symbol?: string | null
token_type?: string | null
}
export interface ExplorerSearchResult {
type: string
chain_id: number
data: {
hash?: string
address?: string
number?: number
}
score: number
href?: string
label: string
name?: string
symbol?: string
token_type?: string
is_curated_token?: boolean
is_gru_token?: boolean
is_x402_ready?: boolean
is_wrapped_transport?: boolean
currency_code?: string
match_reason?: string
matched_tags?: string[]
}
const addressPattern = /^0x[a-f0-9]{40}$/i
const transactionHashPattern = /^0x[a-f0-9]{64}$/i
@@ -39,3 +84,226 @@ export function inferDirectSearchTarget(query: string): DirectSearchTarget | nul
return null
}
export function inferTokenSearchTarget(query: string, tokens: SearchTokenHint[] = []): DirectSearchTarget | null {
const trimmed = query.trim()
if (!trimmed) {
return null
}
const lower = trimmed.toLowerCase()
const matched = tokens.find((token) => {
if (token.chainId !== 138) return false
return token.address?.toLowerCase() === lower || token.symbol?.toLowerCase() === lower
})
if (!matched?.address) {
return null
}
return {
kind: 'token',
href: `/tokens/${matched.address}`,
label: `Open token${matched.symbol ? ` (${matched.symbol})` : ''}`,
}
}
function normalizeNumber(value: string | number | null | undefined): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
if (Number.isFinite(parsed)) {
return parsed
}
}
return undefined
}
function getTypeWeight(type: string): number {
switch (type) {
case 'token':
return 40
case 'transaction':
return 30
case 'address':
return 20
case 'block':
return 10
default:
return 0
}
}
function getExactnessBoost(query: string, item: RawExplorerSearchItem): number {
const trimmed = query.trim().toLowerCase()
if (!trimmed) {
return 0
}
const candidates = [
item.address?.toLowerCase(),
item.transaction_hash?.toLowerCase(),
item.symbol?.toLowerCase(),
item.name?.toLowerCase(),
normalizeNumber(item.block_number)?.toString(),
].filter((value): value is string => Boolean(value))
return candidates.includes(trimmed) ? 1000 : 0
}
function getCuratedMatchReason(query: string, token?: SearchTokenHint): string | undefined {
if (!token) return undefined
const trimmed = query.trim().toLowerCase()
if (!trimmed) return undefined
if (token.address?.toLowerCase() === trimmed) return 'exact curated token address'
if (token.symbol?.toLowerCase() === trimmed) return 'exact curated token symbol'
if (token.name?.toLowerCase() === trimmed) return 'exact curated token name'
if (token.symbol?.toLowerCase().includes(trimmed)) return 'symbol match'
if (token.name?.toLowerCase().includes(trimmed)) return 'name match'
if (token.tags?.some((tag) => tag.toLowerCase().includes(trimmed))) return 'tag match'
return undefined
}
function getHref(type: string, item: RawExplorerSearchItem, curatedToken: SearchTokenHint | undefined): string | undefined {
if ((type === 'token' || curatedToken) && item.address) {
return `/tokens/${item.address}`
}
if (type === 'address' && item.address) {
return `/addresses/${item.address}`
}
if (type === 'transaction' && item.transaction_hash) {
return `/transactions/${item.transaction_hash}`
}
const blockNumber = normalizeNumber(item.block_number)
if (type === 'block' && blockNumber != null) {
return `/blocks/${blockNumber}`
}
return undefined
}
function getLabel(type: string, item: RawExplorerSearchItem, curatedToken: SearchTokenHint | undefined): string {
if ((type === 'token' || curatedToken) && item.symbol) {
return `Token${item.symbol ? ` · ${item.symbol}` : ''}`
}
if ((type === 'token' || curatedToken) && item.name) {
return 'Token'
}
switch (type) {
case 'transaction':
return 'Transaction'
case 'block':
return 'Block'
case 'address':
return 'Address'
default:
return 'Search Result'
}
}
function getDeduplicationKey(type: string, item: RawExplorerSearchItem): string {
if ((type === 'token' || type === 'address') && item.address) {
return `entity:${item.address.toLowerCase()}`
}
if (type === 'transaction' && item.transaction_hash) {
return `tx:${item.transaction_hash.toLowerCase()}`
}
const blockNumber = normalizeNumber(item.block_number)
if (type === 'block' && blockNumber != null) {
return `block:${blockNumber}`
}
return `${type}:${item.address || item.transaction_hash || item.block_number || item.name || item.symbol || 'unknown'}`
}
export function normalizeExplorerSearchResults(
query: string,
items: RawExplorerSearchItem[] = [],
tokens: SearchTokenHint[] = [],
): ExplorerSearchResult[] {
const curatedLookup = new Map<string, SearchTokenHint>()
for (const token of tokens) {
if (token.chainId !== 138 || !token.address) continue
curatedLookup.set(token.address.toLowerCase(), token)
}
const deduped = new Map<string, ExplorerSearchResult & { _ranking: number }>()
for (const item of items) {
const type = item.type || 'unknown'
const blockNumber = normalizeNumber(item.block_number)
const curatedToken = item.address ? curatedLookup.get(item.address.toLowerCase()) : undefined
const normalizedType = type === 'address' && curatedToken ? 'token' : type
const gruPosture =
normalizedType === 'token' || normalizedType === 'address'
? getGruCatalogPosture({
symbol: item.symbol || curatedToken?.symbol,
address: item.address,
tags: curatedToken?.tags,
})
: null
const ranking =
getExactnessBoost(query, item) +
(item.priority ?? 0) * 10 +
getTypeWeight(normalizedType) +
(curatedToken ? 15 : 0) +
(gruPosture?.isGru ? 8 : 0) +
(gruPosture?.isX402Ready ? 5 : 0)
const result: ExplorerSearchResult & { _ranking: number } = {
type: normalizedType,
chain_id: 138,
data: {
hash: item.transaction_hash || undefined,
address: item.address || undefined,
number: blockNumber,
},
score: item.priority ?? 0,
href: getHref(normalizedType, item, curatedToken),
label: getLabel(normalizedType, item, curatedToken),
name: item.name || curatedToken?.name || undefined,
symbol: item.symbol || curatedToken?.symbol || undefined,
token_type: item.token_type || undefined,
is_curated_token: Boolean(curatedToken),
is_gru_token: gruPosture?.isGru || false,
is_x402_ready: gruPosture?.isX402Ready || false,
is_wrapped_transport: gruPosture?.isWrappedTransport || false,
currency_code: gruPosture?.currencyCode,
match_reason:
getCuratedMatchReason(query, curatedToken) ||
(getExactnessBoost(query, item) > 0 ? 'exact match' : undefined),
matched_tags: curatedToken?.tags?.filter((tag) => tag.toLowerCase().includes(query.trim().toLowerCase())) || [],
_ranking: ranking,
}
const key = getDeduplicationKey(normalizedType, item)
const existing = deduped.get(key)
if (!existing || result._ranking > existing._ranking) {
deduped.set(key, result)
}
}
return Array.from(deduped.values())
.sort((left, right) => right._ranking - left._ranking)
.map(({ _ranking, ...result }) => result)
}
export function suggestCuratedTokens(query: string, tokens: SearchTokenHint[] = []): SearchTokenHint[] {
const trimmed = query.trim().toLowerCase()
if (!trimmed) return []
return tokens
.filter((token) => token.chainId === 138)
.filter((token) =>
token.symbol?.toLowerCase().includes(trimmed) ||
token.name?.toLowerCase().includes(trimmed) ||
token.tags?.some((tag) => tag.toLowerCase().includes(trimmed)),
)
.sort((left, right) => {
const leftExact = left.symbol?.toLowerCase() === trimmed || left.name?.toLowerCase() === trimmed
const rightExact = right.symbol?.toLowerCase() === trimmed || right.name?.toLowerCase() === trimmed
if (leftExact !== rightExact) return leftExact ? -1 : 1
return (left.symbol || left.name || '').localeCompare(right.symbol || right.name || '')
})
.slice(0, 5)
}

View File

@@ -0,0 +1,128 @@
import type { Transaction, TransactionInternalCall, TransactionTokenTransfer } from '@/services/api/transactions'
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
export interface TransactionComplianceFactor {
label: string
score: number
maxScore: number
summary: string
}
export interface TransactionComplianceAssessment {
score: number
grade: string
summary: string
factors: TransactionComplianceFactor[]
}
function scoreToGrade(score: number): string {
if (score >= 90) return 'A'
if (score >= 80) return 'B'
if (score >= 70) return 'C'
if (score >= 60) return 'D'
return 'E'
}
export function assessTransactionCompliance(input: {
transaction: Transaction
internalCalls: TransactionInternalCall[]
tokenTransfers: TransactionTokenTransfer[]
}): TransactionComplianceAssessment {
const { transaction, internalCalls, tokenTransfers } = input
const executionScore = transaction.status === 1 ? 25 : transaction.status === 0 ? 6 : 12
const decodeScore = transaction.decoded_input?.parameters?.length ? 15 : transaction.method ? 10 : 3
const traceabilityScore =
(transaction.from_address ? 5 : 0) +
(transaction.to_address || transaction.contract_address ? 5 : 0) +
(transaction.block_number ? 3 : 0) +
(transaction.created_at ? 2 : 0)
const gruTransfers = tokenTransfers.filter((transfer) =>
Boolean(getGruExplorerMetadata({ address: transfer.token_address, symbol: transfer.token_symbol })),
)
const x402Transfers = tokenTransfers.filter((transfer) =>
Boolean(getGruExplorerMetadata({ address: transfer.token_address, symbol: transfer.token_symbol })?.x402Ready),
)
const isoTransfers = tokenTransfers.filter((transfer) =>
Boolean(getGruExplorerMetadata({ address: transfer.token_address, symbol: transfer.token_symbol })?.iso20022Ready),
)
const assetPostureScore =
tokenTransfers.length === 0
? 10
: Math.min(
20,
Math.round(
((gruTransfers.length * 0.45 + x402Transfers.length * 0.3 + isoTransfers.length * 0.25) / tokenTransfers.length) * 20,
),
)
const auditRichnessScore = Math.min(
15,
(tokenTransfers.length > 0 ? 6 : 0) +
(internalCalls.length > 0 ? 4 : 0) +
(transaction.input_data ? 3 : 0) +
(transaction.decoded_input?.parameters?.length ? 2 : 0),
)
const exceptionHygieneScore = transaction.revert_reason ? 0 : transaction.status === 1 ? 10 : 4
const factors: TransactionComplianceFactor[] = [
{
label: 'Execution integrity',
score: executionScore,
maxScore: 25,
summary: transaction.status === 1 ? 'Transaction executed successfully.' : 'Execution failed or remains weakly evidenced.',
},
{
label: 'Decode clarity',
score: decodeScore,
maxScore: 15,
summary:
transaction.decoded_input?.parameters?.length
? 'Decoded method parameters are available.'
: transaction.method
? 'A method label is available, but structured parameters are thin.'
: 'Method decoding is minimal.',
},
{
label: 'Counterparty traceability',
score: traceabilityScore,
maxScore: 15,
summary: 'Based on visible sender, recipient or created contract, block anchoring, and timestamp context.',
},
{
label: 'Asset posture',
score: assetPostureScore,
maxScore: 20,
summary:
tokenTransfers.length === 0
? 'No token transfers were indexed, so asset-policy scoring stays neutral.'
: `${gruTransfers.length}/${tokenTransfers.length} transfer assets look GRU-aware, ${x402Transfers.length} look x402-ready, and ${isoTransfers.length} look ISO-20022-aligned.`,
},
{
label: 'Audit richness',
score: auditRichnessScore,
maxScore: 15,
summary: 'Based on token transfers, internal calls, raw input visibility, and decoded input availability.',
},
{
label: 'Exception hygiene',
score: exceptionHygieneScore,
maxScore: 10,
summary: transaction.revert_reason ? 'A revert reason is present, which lowers the evidence score.' : 'No explicit revert reason is present.',
},
]
const score = factors.reduce((sum, factor) => sum + factor.score, 0)
const grade = scoreToGrade(score)
const summary =
score >= 85
? 'Strong explorer-visible evidence with good execution and audit context.'
: score >= 70
? 'Reasonable evidence quality, but some audit or standards signals are missing.'
: 'Limited evidence quality from the explorer-visible signals alone.'
return { score, grade, summary, factors }
}