feat(explorer): token signing surface card, ERC-5267 domain read, tabular top holders
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 15s

- Add TokenSigningSurfaceCard: ABI flags, eip712Domain eth_call decode, verification metadata
- Pass contract profile into GRU standards detection on token page
- Table layout=tabular for Top Holders column layout at all breakpoints
- Fallback provenance name/symbol; show signing card when token API empty
- eip712Domain.ts: decode ERC-5267 tuple return data

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-05-11 21:09:39 -07:00
parent 654933cb36
commit 64e78dad47
4 changed files with 400 additions and 9 deletions

View File

@@ -12,6 +12,11 @@ interface TableProps<T> {
data: T[]
className?: string
emptyMessage?: string
/**
* responsive: stacked cards below `md`, table at md+.
* tabular: always use columnar HTML table (holder lists, dense numeric tables).
*/
layout?: 'responsive' | 'tabular'
/** Stable key for each row (e.g. row => row.id or row => row.hash). Falls back to index if not provided. */
keyExtractor?: (row: T) => string | number
}
@@ -21,6 +26,7 @@ export function Table<T>({
data,
className,
emptyMessage = 'No data available right now.',
layout = 'responsive',
keyExtractor,
}: TableProps<T>) {
if (data.length === 0) {
@@ -36,9 +42,12 @@ export function Table<T>({
)
}
const stackedClass = layout === 'tabular' ? 'hidden' : 'grid gap-3 md:hidden'
const tableWrapperClass = layout === 'tabular' ? 'overflow-x-auto' : 'hidden overflow-x-auto md:block'
return (
<div className={clsx('space-y-3', className)}>
<div className="grid gap-3 md:hidden">
<div className={stackedClass}>
{data.map((row, rowIndex) => (
<div
key={keyExtractor ? keyExtractor(row) : rowIndex}
@@ -60,7 +69,7 @@ export function Table<T>({
))}
</div>
<div className="hidden overflow-x-auto md:block">
<div className={tableWrapperClass}>
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>

View File

@@ -0,0 +1,217 @@
import { useEffect, useMemo, useState } from 'react'
import { Card } from '@/libs/frontend-ui-primitives'
import { DetailRow } from '@/components/common/DetailRow'
import EntityBadge from '@/components/common/EntityBadge'
import type { ContractProfile } from '@/services/api/contracts'
import { fetchEip712DomainDecoded, type DecodedEip712Domain } from '@/services/api/eip712Domain'
function hasMethod(profile: ContractProfile | null | undefined, name: string): boolean {
if (!profile) return false
const all = [...(profile.read_methods || []), ...(profile.write_methods || [])]
return all.some((m) => m.name === name)
}
const ERC5267_EXPLANATION =
'ERC-5267 defines eip712Domain() so wallets and relayers can discover the EIP-712 signing domain without guessing types or replay parameters.'
export default function TokenSigningSurfaceCard({
address,
contractProfile,
}: {
address: string
contractProfile: ContractProfile | null
}) {
const [domain, setDomain] = useState<DecodedEip712Domain | null>(null)
const [domainError, setDomainError] = useState<string | null>(null)
const abiHasEip712Domain = hasMethod(contractProfile, 'eip712Domain')
useEffect(() => {
if (!abiHasEip712Domain) {
setDomain(null)
setDomainError(null)
return
}
let cancelled = false
setDomainError(null)
void (async () => {
try {
const decoded = await fetchEip712DomainDecoded(address)
if (!cancelled) {
setDomain(decoded)
if (!decoded) setDomainError('eip712Domain() is present in the ABI but the live call did not return decodable data (proxy, revert, or RPC).')
}
} catch (e) {
if (!cancelled) {
setDomain(null)
setDomainError(e instanceof Error ? e.message : 'Failed to read eip712Domain.')
}
}
})()
return () => {
cancelled = true
}
}, [address, abiHasEip712Domain])
const standards = useMemo(
() => [
{
id: 'ERC-20',
detected:
hasMethod(contractProfile, 'name') ||
hasMethod(contractProfile, 'symbol') ||
hasMethod(contractProfile, 'decimals') ||
hasMethod(contractProfile, 'totalSupply'),
note: 'Standard fungible token interface expected by explorers and wallets.',
},
{
id: 'EIP-712',
detected: hasMethod(contractProfile, 'DOMAIN_SEPARATOR') || abiHasEip712Domain,
note: 'Typed structured data hashing for signatures.',
},
{
id: 'ERC-2612',
detected: hasMethod(contractProfile, 'permit') || hasMethod(contractProfile, 'nonces'),
note: 'Permit-style allowance via signature.',
},
{
id: 'ERC-3009',
detected:
hasMethod(contractProfile, 'authorizationState') ||
hasMethod(contractProfile, 'transferWithAuthorization') ||
hasMethod(contractProfile, 'receiveWithAuthorization'),
note: 'Transfer authorization without prior allowance.',
},
{
id: 'ERC-5267',
detected: abiHasEip712Domain,
note: ERC5267_EXPLANATION,
},
],
[contractProfile, abiHasEip712Domain],
)
const verificationMeta = useMemo(() => {
if (!contractProfile) return []
const rows: { label: string; value: string }[] = []
if (contractProfile.contract_name) rows.push({ label: 'Verified name', value: contractProfile.contract_name })
if (contractProfile.compiler_version) rows.push({ label: 'Compiler', value: contractProfile.compiler_version })
if (contractProfile.license_type) rows.push({ label: 'License', value: contractProfile.license_type })
if (contractProfile.evm_version) rows.push({ label: 'EVM version', value: contractProfile.evm_version })
if (contractProfile.optimization_enabled != null) {
rows.push({
label: 'Optimization',
value: `${contractProfile.optimization_enabled ? 'On' : 'Off'}${contractProfile.optimization_runs != null ? ` · ${contractProfile.optimization_runs} runs` : ''}`,
})
}
if (contractProfile.source_status_text) rows.push({ label: 'Source status', value: contractProfile.source_status_text })
return rows
}, [contractProfile])
if (!contractProfile) {
return (
<Card title="Signing surface & verification metadata">
<p className="text-sm text-gray-600 dark:text-gray-400">
Contract ABI and verification metadata were not available. Open the contract address page after Blockscout indexes this token, or verify the contract on the explorer.
</p>
</Card>
)
}
return (
<Card title="Signing surface & verification metadata">
<dl className="space-y-4">
<DetailRow label="ABI coverage" valueClassName="flex flex-wrap gap-2">
<EntityBadge label={contractProfile.abi_available ? 'ABI available' : 'ABI unavailable'} tone={contractProfile.abi_available ? 'success' : 'warning'} />
<EntityBadge label={contractProfile.source_verified ? 'Source verified' : 'Source not verified'} tone={contractProfile.source_verified ? 'success' : 'warning'} />
</DetailRow>
<DetailRow label="ERC-5267 (EIP-712 domain introspection)" valueClassName="space-y-3">
<div className="flex flex-wrap gap-2">
<EntityBadge
label={abiHasEip712Domain ? 'eip712Domain() in ABI' : 'eip712Domain() not in ABI'}
tone={abiHasEip712Domain ? 'success' : 'warning'}
/>
{domain ? <EntityBadge label="Live domain decoded" tone="success" /> : null}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">{ERC5267_EXPLANATION}</p>
{domain ? (
<div className="grid gap-3 sm: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">Domain fields</div>
<dl className="mt-2 space-y-1.5 text-gray-900 dark:text-white">
<div><span className="text-gray-500 dark:text-gray-400">fields </span>{domain.fields}</div>
<div><span className="text-gray-500 dark:text-gray-400">name </span>{domain.name || '—'}</div>
<div><span className="text-gray-500 dark:text-gray-400">version </span>{domain.version || '—'}</div>
<div><span className="text-gray-500 dark:text-gray-400">chainId </span>{domain.chainId}</div>
<div className="break-all">
<span className="text-gray-500 dark:text-gray-400">verifyingContract </span>
{domain.verifyingContract}
</div>
<div className="break-all">
<span className="text-gray-500 dark:text-gray-400">salt </span>
{domain.salt}
</div>
<div className="break-all">
<span className="text-gray-500 dark:text-gray-400">extensions </span>
{domain.extensionsSummary}
</div>
</dl>
</div>
</div>
) : abiHasEip712Domain && domainError ? (
<p className="text-sm text-amber-700 dark:text-amber-300">{domainError}</p>
) : !abiHasEip712Domain ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
This contracts verified ABI does not expose eip712Domain(). ERC-5267 introspection is unavailable from the explorer surface until the implementation adds it.
</p>
) : null}
</DetailRow>
<DetailRow label="Related interfaces" valueClassName="flex flex-wrap gap-2">
{standards
.filter((s) => s.id !== 'ERC-5267')
.map((s) => (
<EntityBadge
key={s.id}
label={`${s.id} ${s.detected ? 'detected' : 'not detected'}`}
tone={s.detected ? 'success' : 'warning'}
className="normal-case tracking-normal"
/>
))}
</DetailRow>
{verificationMeta.length > 0 ? (
<DetailRow label="Verification metadata">
<div className="grid gap-3 sm:grid-cols-2">
{verificationMeta.map((row) => (
<div key={row.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">{row.label}</div>
<div className="mt-2 break-words text-gray-900 dark:text-white">{row.value}</div>
</div>
))}
</div>
</DetailRow>
) : (
<DetailRow label="Verification metadata">
<span className="text-sm text-gray-600 dark:text-gray-400">No compiler or naming metadata was returned with this contract record.</span>
</DetailRow>
)}
<DetailRow label="Interpretation">
<div className="space-y-3">
{standards.map((s) => (
<div key={s.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">
<span className="font-medium text-gray-900 dark:text-white">{s.id}</span>
<EntityBadge label={s.detected ? 'detected' : 'not detected'} tone={s.detected ? 'success' : 'warning'} />
</div>
<p className="mt-2 text-gray-600 dark:text-gray-400">{s.note}</p>
</div>
))}
</div>
</DetailRow>
</dl>
</Card>
)
}

View File

@@ -11,10 +11,12 @@ 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 TokenSigningSurfaceCard from '@/components/common/TokenSigningSurfaceCard'
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import { formatTokenAmount, formatTimestamp } from '@/utils/format'
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
import { contractsApi, type ContractProfile } from '@/services/api/contracts'
function isValidAddress(value: string) {
return /^0x[a-fA-F0-9]{40}$/.test(value)
@@ -50,17 +52,19 @@ export default function TokenDetailPage() {
const [transfers, setTransfers] = useState<AddressTokenTransfer[]>([])
const [pools, setPools] = useState<MissionControlLiquidityPool[]>([])
const [gruProfile, setGruProfile] = useState<GruStandardsProfile | null>(null)
const [contractProfile, setContractProfile] = useState<ContractProfile | null>(null)
const [loading, setLoading] = useState(true)
const loadToken = useCallback(async () => {
setLoading(true)
try {
const [tokenResult, provenanceResult, holdersResult, transfersResult, poolsResult] = await Promise.all([
const [tokenResult, provenanceResult, holdersResult, transfersResult, poolsResult, contractResult] = await Promise.all([
tokensApi.getSafe(address),
tokensApi.getProvenanceSafe(address),
tokensApi.getHoldersSafe(address, 1, 10),
tokensApi.getTransfersSafe(address, 1, 10),
tokensApi.getRelatedPoolsSafe(address),
contractsApi.getProfileSafe(address),
])
setToken(tokenResult.ok ? tokenResult.data : null)
@@ -68,11 +72,14 @@ export default function TokenDetailPage() {
setHolders(holdersResult.ok ? holdersResult.data : [])
setTransfers(transfersResult.ok ? transfersResult.data : [])
setPools(poolsResult.ok ? poolsResult.data : [])
const resolvedContractProfile = contractResult.ok ? contractResult.data : null
setContractProfile(resolvedContractProfile)
if (tokenResult.ok && tokenResult.data) {
const gruResult = await getGruStandardsProfileSafe({
address,
symbol: tokenResult.data.symbol,
tags: provenanceResult.ok ? provenanceResult.data?.tags || [] : [],
contractProfile: resolvedContractProfile,
})
setGruProfile(gruResult.ok ? gruResult.data : null)
} else {
@@ -85,6 +92,7 @@ export default function TokenDetailPage() {
setTransfers([])
setPools([])
setGruProfile(null)
setContractProfile(null)
} finally {
setLoading(false)
}
@@ -98,6 +106,7 @@ export default function TokenDetailPage() {
if (!isValidTokenAddress) {
setLoading(false)
setToken(null)
setContractProfile(null)
return
}
void loadToken()
@@ -274,7 +283,7 @@ export default function TokenDetailPage() {
<div className="container mx-auto px-4 py-6 sm:py-8">
<PageIntro
eyebrow="Token Detail"
title={token?.symbol || token?.name || 'Token'}
title={token?.symbol || token?.name || provenance?.symbol || provenance?.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' },
@@ -303,15 +312,20 @@ export default function TokenDetailPage() {
<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>
<p className="text-sm text-gray-600 dark:text-gray-400">
Token details were not found for this address from the token index APIs. If this is a contract, verification metadata and ERC-5267 may still be available below.
</p>
</Card>
<TokenSigningSurfaceCard address={address} contractProfile={contractProfile} />
</div>
) : (
<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="Name">{token.name || provenance?.name || 'Unknown'}</DetailRow>
<DetailRow label="Symbol">{token.symbol || provenance?.symbol || 'Unknown'}</DetailRow>
<DetailRow label="Address">
<Address address={token.address} />
</DetailRow>
@@ -342,6 +356,8 @@ export default function TokenDetailPage() {
</dl>
</Card>
<TokenSigningSurfaceCard address={token.address} contractProfile={contractProfile} />
<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">
@@ -469,6 +485,7 @@ export default function TokenDetailPage() {
<Card title="Top Holders">
<Table
layout="tabular"
columns={holderColumns}
data={holders}
emptyMessage="No holder data was available for this token."

View File

@@ -0,0 +1,148 @@
/**
* ERC-5267 — read eip712Domain() via eth_call and decode the standard return tuple:
* (bytes1 fields, string name, string version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] extensions)
*/
export interface DecodedEip712Domain {
fields: string
name: string
version: string
chainId: string
verifyingContract: string
salt: string
extensionsSummary: string
}
const EIP712_DOMAIN_SELECTOR = '84b0196e'
function hexToBytes(hex: string): Uint8Array {
const normalized = hex.replace(/^0x/i, '')
if (normalized.length % 2 !== 0) return new Uint8Array()
const out = new Uint8Array(normalized.length / 2)
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(normalized.slice(i * 2, i * 2 + 2), 16)
}
return out
}
function readWord(bytes: Uint8Array, wordIndex: number): Uint8Array {
const start = wordIndex * 32
return bytes.subarray(start, start + 32)
}
function wordToBigInt(word: Uint8Array): bigint {
let v = 0n
for (let i = 0; i < word.length; i++) {
v = (v << 8n) + BigInt(word[i] || 0)
}
return v
}
function readBytes1(word: Uint8Array): string {
return `0x${(word[31] ?? 0).toString(16).padStart(2, '0')}`
}
function readAddress(word: Uint8Array): string {
const slice = word.subarray(12, 32)
return `0x${Array.from(slice)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')}`
}
function readBytes32(word: Uint8Array): string {
return `0x${Array.from(word)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')}`
}
function readAbiString(bytes: Uint8Array, offset: number): { value: string; next: number } {
if (offset + 32 > bytes.length) return { value: '', next: offset }
const len = Number(wordToBigInt(bytes.subarray(offset, offset + 32)))
const start = offset + 32
const end = start + len
if (end > bytes.length) return { value: '', next: offset }
const raw = bytes.subarray(start, end)
const value = new TextDecoder().decode(raw).replace(/\u0000+$/g, '')
const padded = Math.ceil(len / 32) * 32
return { value, next: start + padded }
}
function readUint256Array(bytes: Uint8Array, offset: number): { summary: string; next: number } {
if (offset + 32 > bytes.length) return { summary: '[]', next: offset }
const n = Number(wordToBigInt(bytes.subarray(offset, offset + 32)))
if (!Number.isFinite(n) || n < 0 || n > 256) {
return { summary: `(length ${n})`, next: offset + 32 }
}
let pos = offset + 32
const parts: string[] = []
for (let i = 0; i < n; i++) {
if (pos + 32 > bytes.length) break
parts.push(wordToBigInt(bytes.subarray(pos, pos + 32)).toString())
pos += 32
}
const summary = n === 0 ? '[]' : `[${parts.join(', ')}]`
return { summary, next: pos }
}
export function decodeEip712DomainReturnData(resultHex: string): DecodedEip712Domain | null {
const bytes = hexToBytes(resultHex)
if (bytes.length < 32 * 7) return null
const fields = readBytes1(readWord(bytes, 0))
const nameOff = Number(wordToBigInt(readWord(bytes, 1)))
const versionOff = Number(wordToBigInt(readWord(bytes, 2)))
const chainId = wordToBigInt(readWord(bytes, 3)).toString()
const verifyingContract = readAddress(readWord(bytes, 4))
const salt = readBytes32(readWord(bytes, 5))
const extOff = Number(wordToBigInt(readWord(bytes, 6)))
if (!Number.isFinite(nameOff) || nameOff < 0 || nameOff >= bytes.length) return null
if (!Number.isFinite(versionOff) || versionOff < 0 || versionOff >= bytes.length) return null
const name = readAbiString(bytes, nameOff).value
const version = readAbiString(bytes, versionOff).value
const extensionsSummary =
Number.isFinite(extOff) && extOff >= 0 && extOff < bytes.length ? readUint256Array(bytes, extOff).summary : '[]'
return {
fields,
name,
version,
chainId,
verifyingContract,
salt,
extensionsSummary,
}
}
function getPublicRpcUrl(): string {
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_RPC_URL_138) {
return process.env.NEXT_PUBLIC_RPC_URL_138
}
return process.env.NEXT_PUBLIC_RPC_URL_138 || 'https://rpc-http-pub.d-bis.org'
}
export async function fetchEip712DomainDecoded(address: string): Promise<DecodedEip712Domain | null> {
const trimmed = address.trim()
if (!/^0x[a-fA-F0-9]{40}$/.test(trimmed)) return null
const data = `0x${EIP712_DOMAIN_SELECTOR}`
try {
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: trimmed, data }, 'latest'],
}),
})
if (!response.ok) return null
const payload = (await response.json()) as { result?: string; error?: { message?: string } }
if (payload.error?.message || !payload.result || payload.result === '0x') return null
return decodeEip712DomainReturnData(payload.result)
} catch {
return null
}
}