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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import LiquidityOperationsPage from '@/components/explorer/LiquidityOperationsPage'
|
||||
|
||||
export default function LiquidityPage() {
|
||||
return <LiquidityOperationsPage />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
873
frontend/src/components/access/AccessManagementPage.tsx
Normal file
873
frontend/src/components/access/AccessManagementPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
frontend/src/components/common/EntityBadge.tsx
Normal file
52
frontend/src/components/common/EntityBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
178
frontend/src/components/common/ExplorerAgentTool.tsx
Normal file
178
frontend/src/components/common/ExplorerAgentTool.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
214
frontend/src/components/common/GruStandardsCard.tsx
Normal file
214
frontend/src/components/common/GruStandardsCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
45
frontend/src/components/common/PageIntro.tsx
Normal file
45
frontend/src/components/common/PageIntro.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
427
frontend/src/components/home/HomePage.tsx
Normal file
427
frontend/src/components/home/HomePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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">
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
5
frontend/src/pages/access/index.tsx
Normal file
5
frontend/src/pages/access/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AccessManagementPage from '@/components/access/AccessManagementPage'
|
||||
|
||||
export default function AccessPage() {
|
||||
return <AccessManagementPage />
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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))
|
||||
: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
134
frontend/src/pages/docs/gru.tsx
Normal file
134
frontend/src/pages/docs/gru.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
frontend/src/pages/docs/index.tsx
Normal file
131
frontend/src/pages/docs/index.tsx
Normal 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 explorer’s 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>
|
||||
)
|
||||
}
|
||||
103
frontend/src/pages/docs/transaction-compliance.tsx
Normal file
103
frontend/src/pages/docs/transaction-compliance.tsx
Normal 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 repo’s 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>
|
||||
)
|
||||
}
|
||||
1
frontend/src/pages/docs/transaction-review.tsx
Normal file
1
frontend/src/pages/docs/transaction-review.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './transaction-compliance'
|
||||
@@ -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>
|
||||
|
||||
71
frontend/src/pages/index.tsx
Normal file
71
frontend/src/pages/index.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
84
frontend/src/pages/liquidity/index.tsx
Normal file
84
frontend/src/pages/liquidity/index.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
38
frontend/src/pages/operations/index.tsx
Normal file
38
frontend/src/pages/operations/index.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
504
frontend/src/pages/tokens/[address].tsx
Normal file
504
frontend/src/pages/tokens/[address].tsx
Normal 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 explorer’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
41
frontend/src/pages/wallet/index.tsx
Normal file
41
frontend/src/pages/wallet/index.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
170
frontend/src/services/api/access.test.ts
Normal file
170
frontend/src/services/api/access.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
321
frontend/src/services/api/access.ts
Normal file
321
frontend/src/services/api/access.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
@@ -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: [] }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
131
frontend/src/services/api/blockscout.test.ts
Normal file
131
frontend/src/services/api/blockscout.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface TokenListToken {
|
||||
name?: string
|
||||
decimals?: number
|
||||
logoURI?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface TokenListResponse {
|
||||
|
||||
98
frontend/src/services/api/contracts.test.ts
Normal file
98
frontend/src/services/api/contracts.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
406
frontend/src/services/api/contracts.ts
Normal file
406
frontend/src/services/api/contracts.ts
Normal 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,
|
||||
}
|
||||
216
frontend/src/services/api/gru.ts
Normal file
216
frontend/src/services/api/gru.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
116
frontend/src/services/api/gruCatalog.ts
Normal file
116
frontend/src/services/api/gruCatalog.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
183
frontend/src/services/api/gruExplorerData.ts
Normal file
183
frontend/src/services/api/gruExplorerData.ts
Normal 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
|
||||
}
|
||||
60
frontend/src/services/api/missionControl.test.ts
Normal file
60
frontend/src/services/api/missionControl.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
35
frontend/src/services/api/routes.test.ts
Normal file
35
frontend/src/services/api/routes.test.ts
Normal 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' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
115
frontend/src/services/api/tokens.test.ts
Normal file
115
frontend/src/services/api/tokens.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
205
frontend/src/services/api/tokens.ts
Normal file
205
frontend/src/services/api/tokens.ts
Normal 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: [] }
|
||||
}
|
||||
},
|
||||
}
|
||||
75
frontend/src/services/api/transactions.test.ts
Normal file
75
frontend/src/services/api/transactions.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 : [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
32
frontend/src/utils/publicExplorer.ts
Normal file
32
frontend/src/utils/publicExplorer.ts
Normal 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'),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
128
frontend/src/utils/transactionCompliance.ts
Normal file
128
frontend/src/utils/transactionCompliance.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user