diff --git a/frontend/libs/frontend-ui-primitives/Table.tsx b/frontend/libs/frontend-ui-primitives/Table.tsx index 1bd9f4a..0dbd830 100644 --- a/frontend/libs/frontend-ui-primitives/Table.tsx +++ b/frontend/libs/frontend-ui-primitives/Table.tsx @@ -12,6 +12,11 @@ interface TableProps { 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({ data, className, emptyMessage = 'No data available right now.', + layout = 'responsive', keyExtractor, }: TableProps) { if (data.length === 0) { @@ -36,9 +42,12 @@ export function Table({ ) } + const stackedClass = layout === 'tabular' ? 'hidden' : 'grid gap-3 md:hidden' + const tableWrapperClass = layout === 'tabular' ? 'overflow-x-auto' : 'hidden overflow-x-auto md:block' + return (
-
+
{data.map((row, rowIndex) => (
({ ))}
-
+
diff --git a/frontend/src/components/common/TokenSigningSurfaceCard.tsx b/frontend/src/components/common/TokenSigningSurfaceCard.tsx new file mode 100644 index 0000000..9463903 --- /dev/null +++ b/frontend/src/components/common/TokenSigningSurfaceCard.tsx @@ -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(null) + const [domainError, setDomainError] = useState(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 ( + +

+ 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. +

+
+ ) + } + + return ( + +
+ + + + + + +
+ + {domain ? : null} +
+

{ERC5267_EXPLANATION}

+ {domain ? ( +
+
+
Domain fields
+
+
fields {domain.fields}
+
name {domain.name || '—'}
+
version {domain.version || '—'}
+
chainId {domain.chainId}
+
+ verifyingContract + {domain.verifyingContract} +
+
+ salt + {domain.salt} +
+
+ extensions + {domain.extensionsSummary} +
+
+
+
+ ) : abiHasEip712Domain && domainError ? ( +

{domainError}

+ ) : !abiHasEip712Domain ? ( +

+ This contract’s verified ABI does not expose eip712Domain(). ERC-5267 introspection is unavailable from the explorer surface until the implementation adds it. +

+ ) : null} +
+ + + {standards + .filter((s) => s.id !== 'ERC-5267') + .map((s) => ( + + ))} + + + {verificationMeta.length > 0 ? ( + +
+ {verificationMeta.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
+ ) : ( + + No compiler or naming metadata was returned with this contract record. + + )} + + +
+ {standards.map((s) => ( +
+
+ {s.id} + +
+

{s.note}

+
+ ))} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/tokens/[address].tsx b/frontend/src/pages/tokens/[address].tsx index adb2b0b..64a7f89 100644 --- a/frontend/src/pages/tokens/[address].tsx +++ b/frontend/src/pages/tokens/[address].tsx @@ -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([]) const [pools, setPools] = useState([]) const [gruProfile, setGruProfile] = useState(null) + const [contractProfile, setContractProfile] = useState(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() {
Invalid token address. Please use a full 42-character 0x-prefixed address.

) : !token ? ( - -

Token details were not found for this address.

-
+
+ +

+ 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. +

+
+ +
) : (
- {token.name || 'Unknown'} - {token.symbol || 'Unknown'} + {token.name || provenance?.name || 'Unknown'} + {token.symbol || provenance?.symbol || 'Unknown'}
@@ -342,6 +356,8 @@ export default function TokenDetailPage() {
+ +
@@ -469,6 +485,7 @@ export default function TokenDetailPage() {
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 { + 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 + } +}