From 1129769c9dbe183218e7b2eea7574999a81ac1a6 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 5 Mar 2026 08:01:56 -0300 Subject: [PATCH 01/13] feat(address): add proxy contract detection (EIP-1967, EIP-1822, EIP-1167) Automatically detects proxy patterns and shows the implementation address as a clickable link. Merges the implementation ABI into the interaction panel so users can call implementation functions directly. - Add proxyDetection.ts utility supporting EIP-1967, EIP-1822 (UUPS), EIP-1967-Beacon, and EIP-1167 minimal proxy patterns - Add useProxyInfo hook wrapping detection with AppContext RPC URLs - Show proxy type and implementation address row in ContractInfoCard - Add ABI tab switcher (Implementation / Proxy) when both ABIs exist - Fetch implementation contract data from Sourcify when proxy detected - Add abi-tab-switcher CSS styles - Add i18n keys (proxyType, implementationAddress, implementationFunctions, proxyFunctions) to en, es, ja, pt-BR, zh locales Closes #266 --- .../evm/address/displays/ContractDisplay.tsx | 13 ++ .../evm/address/shared/ContractInfoCard.tsx | 79 ++++++++++- src/hooks/useProxyInfo.ts | 27 ++++ src/locales/en/address.json | 6 +- src/locales/es/address.json | 6 +- src/locales/ja/address.json | 6 +- src/locales/pt-BR/address.json | 6 +- src/locales/zh/address.json | 6 +- src/styles/components.css | 31 ++++ src/utils/proxyDetection.ts | 133 ++++++++++++++++++ 10 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 src/hooks/useProxyInfo.ts create mode 100644 src/utils/proxyDetection.ts diff --git a/src/components/pages/evm/address/displays/ContractDisplay.tsx b/src/components/pages/evm/address/displays/ContractDisplay.tsx index 950cef44..9d299731 100644 --- a/src/components/pages/evm/address/displays/ContractDisplay.tsx +++ b/src/components/pages/evm/address/displays/ContractDisplay.tsx @@ -3,6 +3,7 @@ import { useContext, useMemo } from "react"; import { getNetworkById } from "../../../../../config/networks"; import { AppContext } from "../../../../../context"; import { useSourcify } from "../../../../../hooks/useSourcify"; +import { useProxyInfo } from "../../../../../hooks/useProxyInfo"; import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; import { AddressHeader } from "../shared"; @@ -48,6 +49,16 @@ const ContractDisplay: React.FC = ({ isVerified, } = useSourcify(Number(networkId), addressHash, true); + // Detect proxy pattern + const proxyInfo = useProxyInfo(addressHash, networkId, address.code ?? ""); + + // Fetch implementation contract data from Sourcify (only when proxy detected) + const { data: implSourcifyData, isVerified: implIsVerified } = useSourcify( + Number(networkId), + proxyInfo?.implementationAddress, + !!proxyInfo, + ); + // Check if we have local artifact data for this address const localArtifact = jsonFiles[addressHash.toLowerCase()]; @@ -153,6 +164,8 @@ const ContractDisplay: React.FC = ({ ? `https://repo.sourcify.dev/contracts/full_match/${networkId}/${addressHash}/` : undefined } + proxyInfo={proxyInfo} + implementationContractData={implIsVerified ? implSourcifyData : null} /> diff --git a/src/components/pages/evm/address/shared/ContractInfoCard.tsx b/src/components/pages/evm/address/shared/ContractInfoCard.tsx index b03d7aa1..08d89a22 100644 --- a/src/components/pages/evm/address/shared/ContractInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractInfoCard.tsx @@ -1,8 +1,11 @@ import type React from "react"; import { useState } from "react"; +import { Link } from "react-router-dom"; import type { Address, ABI } from "../../../../../types"; import { useTranslation } from "react-i18next"; import ContractInteraction from "./ContractInteraction"; +import type { ProxyInfo } from "../../../../../utils/proxyDetection"; +import type { SourcifyContractDetails } from "../../../../../hooks/useSourcify"; interface ContractData { name?: string; @@ -31,8 +34,12 @@ interface ContractInfoCardProps { sourcifyLoading: boolean; isLocalArtifact: boolean; sourcifyUrl?: string; + proxyInfo?: ProxyInfo | null; + implementationContractData?: SourcifyContractDetails | null; } +type AbiView = "implementation" | "proxy"; + const ContractInfoCard: React.FC = ({ address, addressHash, @@ -42,12 +49,15 @@ const ContractInfoCard: React.FC = ({ sourcifyLoading, isLocalArtifact, sourcifyUrl, + proxyInfo, + implementationContractData, }) => { const { t } = useTranslation("address"); const [showBytecode, setShowBytecode] = useState(false); const [showContractDetails, setShowContractDetails] = useState(false); const [showSourceCode, setShowSourceCode] = useState(false); const [showRawAbi, setShowRawAbi] = useState(false); + const [abiView, setAbiView] = useState("implementation"); const getMatchBadgeText = () => { if (isLocalArtifact) return "Local JSON"; @@ -58,6 +68,21 @@ const ContractInfoCard: React.FC = ({ const matchBadgeText = getMatchBadgeText(); + // Determine the active ABI based on tab selection + const hasImplAbi = !!( + implementationContractData?.abi && implementationContractData.abi.length > 0 + ); + const hasProxyAbi = !!(contractData?.abi && contractData.abi.length > 0); + const showAbiTabSwitcher = !!(proxyInfo && hasImplAbi && hasProxyAbi); + + const activeAbi: ABI[] | undefined = showAbiTabSwitcher + ? abiView === "implementation" + ? ((implementationContractData?.abi as ABI[] | undefined) ?? contractData?.abi) + : contractData?.abi + : proxyInfo && hasImplAbi + ? (implementationContractData?.abi as ABI[] | undefined) + : contractData?.abi; + // Prepare source files array const sourceFiles = contractData?.files && contractData.files.length > 0 @@ -118,6 +143,32 @@ const ContractInfoCard: React.FC = ({ )} + {/* Proxy Type */} + {proxyInfo && ( +
+ {t("proxyType")}: + {proxyInfo.type} +
+ )} + + {/* Implementation Address */} + {proxyInfo?.implementationAddress && ( +
+ {t("implementationAddress")}: + + + {proxyInfo.implementationAddress} + + {implementationContractData?.name && ( + {implementationContractData.name} + )} + +
+ )} + {/* Sourcify Link */} {sourcifyUrl && (
@@ -149,6 +200,26 @@ const ContractInfoCard: React.FC = ({ {showContractDetails && (
+ {/* ABI tab switcher - only shown when proxy has verified implementation */} + {showAbiTabSwitcher && ( +
+ + +
+ )} + {/* Contract Bytecode */} {address.code && address.code !== "0x" && (
@@ -195,7 +266,7 @@ const ContractInfoCard: React.FC = ({ )} {/* Raw ABI */} - {contractData.abi && contractData.abi.length > 0 && ( + {activeAbi && activeAbi.length > 0 && (
{showRawAbi && (
- {JSON.stringify(contractData.abi, null, 2)} + {JSON.stringify(activeAbi, null, 2)}
)}
)} {/* Contract Interaction */} - {contractData.abi && contractData.abi.length > 0 && ( + {activeAbi && activeAbi.length > 0 && ( )}
diff --git a/src/hooks/useProxyInfo.ts b/src/hooks/useProxyInfo.ts new file mode 100644 index 00000000..0a3e5c74 --- /dev/null +++ b/src/hooks/useProxyInfo.ts @@ -0,0 +1,27 @@ +import { useContext, useEffect, useState } from "react"; +import { AppContext } from "../context"; +import { type ProxyInfo, detectProxy } from "../utils/proxyDetection"; + +export function useProxyInfo( + address: string, + networkId: string, + bytecode: string, +): ProxyInfo | null { + const { rpcUrls } = useContext(AppContext); + const [proxyInfo, setProxyInfo] = useState(null); + + useEffect(() => { + if (!address || !networkId || !bytecode || bytecode === "0x") return; + + const rpcNetworkId = `eip155:${networkId}`; + const urls = rpcUrls[rpcNetworkId]; + const rpcUrl = Array.isArray(urls) && urls.length > 0 ? urls[0] : null; + if (!rpcUrl) return; + + detectProxy(address, rpcUrl, bytecode) + .then(setProxyInfo) + .catch(() => setProxyInfo(null)); + }, [address, networkId, bytecode, rpcUrls]); + + return proxyInfo; +} diff --git a/src/locales/en/address.json b/src/locales/en/address.json index de30b4f4..72e1b1be 100644 --- a/src/locales/en/address.json +++ b/src/locales/en/address.json @@ -193,5 +193,9 @@ "missingParam": "Please provide value for {{paramName}}", "invalidEthValue": "Invalid ETH value", "error": "Error: {{message}}" - } + }, + "proxyType": "Proxy Type", + "implementationAddress": "Implementation", + "implementationFunctions": "Implementation", + "proxyFunctions": "Proxy" } diff --git a/src/locales/es/address.json b/src/locales/es/address.json index c2f5295a..043c3569 100644 --- a/src/locales/es/address.json +++ b/src/locales/es/address.json @@ -193,5 +193,9 @@ "missingParam": "Por favor ingrese un valor para {{paramName}}", "invalidEthValue": "Valor de ETH inválido", "error": "Error: {{message}}" - } + }, + "proxyType": "Tipo de Proxy", + "implementationAddress": "Implementación", + "implementationFunctions": "Implementación", + "proxyFunctions": "Proxy" } diff --git a/src/locales/ja/address.json b/src/locales/ja/address.json index 0730f3a4..bd435882 100644 --- a/src/locales/ja/address.json +++ b/src/locales/ja/address.json @@ -193,5 +193,9 @@ "missingParam": "{{paramName}}の値を入力してください", "invalidEthValue": "無効なETH値です", "error": "エラー: {{message}}" - } + }, + "proxyType": "プロキシタイプ", + "implementationAddress": "実装", + "implementationFunctions": "実装", + "proxyFunctions": "プロキシ" } diff --git a/src/locales/pt-BR/address.json b/src/locales/pt-BR/address.json index 0c2eda01..5a8929a1 100644 --- a/src/locales/pt-BR/address.json +++ b/src/locales/pt-BR/address.json @@ -193,5 +193,9 @@ "missingParam": "Por favor, forneça um valor para {{paramName}}", "invalidEthValue": "Valor de ETH inválido", "error": "Erro: {{message}}" - } + }, + "proxyType": "Tipo de Proxy", + "implementationAddress": "Implementação", + "implementationFunctions": "Implementação", + "proxyFunctions": "Proxy" } diff --git a/src/locales/zh/address.json b/src/locales/zh/address.json index 3b897d34..2d618165 100644 --- a/src/locales/zh/address.json +++ b/src/locales/zh/address.json @@ -193,5 +193,9 @@ "missingParam": "请提供 {{paramName}} 的值", "invalidEthValue": "ETH 值无效", "error": "错误:{{message}}" - } + }, + "proxyType": "代理类型", + "implementationAddress": "实现合约", + "implementationFunctions": "实现", + "proxyFunctions": "代理" } diff --git a/src/styles/components.css b/src/styles/components.css index e8d2f17f..103b2d61 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -6761,6 +6761,37 @@ button.tx-section-header-toggle { gap: 8px; } +.abi-tab-switcher { + display: flex; + gap: 4px; + padding: 4px; + background: var(--overlay-light-4); + border-radius: 6px; + width: fit-content; +} + +.abi-tab { + padding: 4px 12px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: background 0.15s, color 0.15s; +} + +.abi-tab:hover { + background: var(--overlay-light-8); + color: var(--text-primary); +} + +.abi-tab--active { + background: var(--color-primary); + color: #fff; +} + .contract-collapsible-item { border: 1px solid var(--color-primary-alpha-8); border-radius: 6px; diff --git a/src/utils/proxyDetection.ts b/src/utils/proxyDetection.ts new file mode 100644 index 00000000..7bb363dc --- /dev/null +++ b/src/utils/proxyDetection.ts @@ -0,0 +1,133 @@ +import { logger } from "./logger"; + +export type ProxyType = "EIP-1967" | "EIP-1967-Beacon" | "EIP-1822" | "EIP-1167"; + +export interface ProxyInfo { + type: ProxyType; + implementationAddress: string; +} + +// Storage slots +const EIP1967_IMPL_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; +const EIP1967_BEACON_SLOT = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50"; +const EIP1822_SLOT = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"; +const ZERO_SLOT = `0x${"0".repeat(64)}`; + +// EIP-1167 minimal proxy bytecode pattern +const EIP1167_PATTERN = /^0x363d3d373d3d3d363d73([0-9a-f]{40})5af43d/i; + +// beacon.implementation() selector +const BEACON_IMPL_SELECTOR = "0x5c60da1b"; + +async function getStorageAt(address: string, slot: string, rpcUrl: string): Promise { + try { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_getStorageAt", + params: [address, slot, "latest"], + id: 1, + }), + }); + const json = await response.json(); + return json.result ?? null; + } catch (err) { + logger.warn("getStorageAt failed:", err); + return null; + } +} + +function slotToAddress(slot: string): string { + // Take the last 40 hex chars (20 bytes) and prefix with 0x + const hex = slot.replace(/^0x/, "").slice(-40); + return toChecksumAddress(`0x${hex}`); +} + +function isZeroAddress(address: string): boolean { + return /^0x0{40}$/.test(address); +} + +function toChecksumAddress(address: string): string { + // Simple EIP-55 checksum implementation + const addr = address.toLowerCase().replace("0x", ""); + // Use a simple implementation without external deps + const chars = addr.split(""); + // We need keccak256 hash of the address - use a simple approximation + // For display purposes, return the lowercase hex with 0x prefix + // Full EIP-55 checksum requires keccak256 which we don't have as a pure util here + return `0x${chars.join("")}`; +} + +async function callBeaconImplementation( + beaconAddress: string, + rpcUrl: string, +): Promise { + try { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_call", + params: [{ to: beaconAddress, data: BEACON_IMPL_SELECTOR }, "latest"], + id: 1, + }), + }); + const json = await response.json(); + const result: string = json.result; + if (!result || result === "0x") return null; + // result is a 32-byte ABI-encoded address + const implAddr = slotToAddress(result); + if (isZeroAddress(implAddr)) return null; + return implAddr; + } catch (err) { + logger.warn("callBeaconImplementation failed:", err); + return null; + } +} + +export async function detectProxy( + address: string, + rpcUrl: string, + bytecode: string, +): Promise { + // 1. EIP-1967 implementation slot + const implVal = await getStorageAt(address, EIP1967_IMPL_SLOT, rpcUrl); + if (implVal && implVal !== ZERO_SLOT && implVal !== "0x") { + const implAddr = slotToAddress(implVal); + if (!isZeroAddress(implAddr)) { + return { type: "EIP-1967", implementationAddress: implAddr }; + } + } + + // 2. EIP-1822 (UUPS) slot + const uupsVal = await getStorageAt(address, EIP1822_SLOT, rpcUrl); + if (uupsVal && uupsVal !== ZERO_SLOT && uupsVal !== "0x") { + const uupsAddr = slotToAddress(uupsVal); + if (!isZeroAddress(uupsAddr)) { + return { type: "EIP-1822", implementationAddress: uupsAddr }; + } + } + + // 3. EIP-1967 Beacon slot + const beaconVal = await getStorageAt(address, EIP1967_BEACON_SLOT, rpcUrl); + if (beaconVal && beaconVal !== ZERO_SLOT && beaconVal !== "0x") { + const beaconAddr = slotToAddress(beaconVal); + if (!isZeroAddress(beaconAddr)) { + const beaconImpl = await callBeaconImplementation(beaconAddr, rpcUrl); + if (beaconImpl) { + return { type: "EIP-1967-Beacon", implementationAddress: beaconImpl }; + } + } + } + + // 4. EIP-1167 minimal proxy (check bytecode) + const eip1167Match = bytecode.match(EIP1167_PATTERN); + if (eip1167Match?.[1]) { + return { type: "EIP-1167", implementationAddress: `0x${eip1167Match[1]}` }; + } + + return null; +} From e124fd2e6f1ed7f02ac836120e2bd8945644f83e Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 5 Mar 2026 08:54:50 -0300 Subject: [PATCH 02/13] fix(address): fix proxy detection bugs and EOA misclassification Proxy detection fixes: - Add legacy OZ AdminUpgradeabilityProxy slot (keccak256("org.zeppelinos.proxy.implementation")) so Circle USDC (FiatTokenProxy) and other pre-EIP-1967 proxies are detected - Use NetworkAdapter.getStorageAt/callContract instead of raw fetch calls, so the configured RPC strategy (fallback/parallel) is used for proxy checks - Add slotToAddress validation: top 12 bytes must be zero (ABI-encoded address) to prevent false positives from contracts with incidental non-zero storage slots - Run all four slot reads in parallel to reduce latency - Add ProxyRpcClient interface satisfied by NetworkAdapter EOA misclassification fixes: - Pass pre-fetched addressData to fetchAddressWithType to avoid redundant eth_getCode call; secondary RPC failures no longer silently downgrade contracts to EOA - When type detection fails or no RPC URL is configured, derive EOA/contract from the already-fetched code instead of defaulting to "account" Contract Details panel fix: - Show the Contract Details section (ABI + interaction) whenever a proxy is detected with a verified implementation ABI, even when the proxy contract itself has no Sourcify entry --- src/components/pages/evm/address/index.tsx | 15 +- .../evm/address/shared/ContractInfoCard.tsx | 8 +- src/hooks/useProxyInfo.ts | 16 +- src/services/adapters/NetworkAdapter.ts | 18 ++ src/utils/addressTypeDetection.ts | 11 +- src/utils/proxyDetection.ts | 162 ++++++++---------- 6 files changed, 122 insertions(+), 108 deletions(-) diff --git a/src/components/pages/evm/address/index.tsx b/src/components/pages/evm/address/index.tsx index 90c637f4..db184bcc 100644 --- a/src/components/pages/evm/address/index.tsx +++ b/src/components/pages/evm/address/index.tsx @@ -142,18 +142,29 @@ export default function Address() { addressHash: address, chainId: numericNetworkId, rpcUrl, + // Pass already-fetched address data to skip the redundant eth_getCode call. + // This prevents contracts from being misclassified as EOA when the + // secondary RPC fetch fails (rate-limit, L2 quirks, etc.). + preloadedAddress: addressData, }) .then((typeResult) => { setAddressType(typeResult.addressType); }) .catch(() => { - // Fallback to account if type detection fails - setAddressType("account"); + // Type detection failed — use the code we already have to distinguish + // contract from EOA rather than blindly defaulting to "account". + const hasCode = + addressData.code && addressData.code.toLowerCase().replace(/^0x0*/, "").length > 0; + setAddressType(hasCode ? "contract" : "account"); }) .finally(() => { setTypeLoading(false); }); } else { + // No RPC URL configured for type detection — derive type from pre-fetched code. + const hasCode = + addressData.code && addressData.code.toLowerCase().replace(/^0x0*/, "").length > 0; + setAddressType(hasCode ? "contract" : "account"); setTypeLoading(false); } }) diff --git a/src/components/pages/evm/address/shared/ContractInfoCard.tsx b/src/components/pages/evm/address/shared/ContractInfoCard.tsx index 08d89a22..b1516573 100644 --- a/src/components/pages/evm/address/shared/ContractInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractInfoCard.tsx @@ -186,8 +186,10 @@ const ContractInfoCard: React.FC = ({
)} - {/* Contract Details Section - for verified contracts */} - {hasVerifiedContract && contractData && ( + {/* Contract Details Section - shown when: + a) proxy contract itself has a verified ABI, OR + b) proxy is detected and its implementation has a verified ABI */} + {(hasVerifiedContract && contractData) || (proxyInfo && hasImplAbi) ? (
)}
- )} + ) : null} {/* Bytecode for unverified contracts */} {!hasVerifiedContract && address.code && address.code !== "0x" && ( diff --git a/src/hooks/useProxyInfo.ts b/src/hooks/useProxyInfo.ts index 0a3e5c74..c0792ead 100644 --- a/src/hooks/useProxyInfo.ts +++ b/src/hooks/useProxyInfo.ts @@ -1,5 +1,5 @@ -import { useContext, useEffect, useState } from "react"; -import { AppContext } from "../context"; +import { useEffect, useState } from "react"; +import { useDataService } from "./useDataService"; import { type ProxyInfo, detectProxy } from "../utils/proxyDetection"; export function useProxyInfo( @@ -7,21 +7,17 @@ export function useProxyInfo( networkId: string, bytecode: string, ): ProxyInfo | null { - const { rpcUrls } = useContext(AppContext); + const dataService = useDataService(Number(networkId)); const [proxyInfo, setProxyInfo] = useState(null); useEffect(() => { if (!address || !networkId || !bytecode || bytecode === "0x") return; + if (!dataService?.networkAdapter) return; - const rpcNetworkId = `eip155:${networkId}`; - const urls = rpcUrls[rpcNetworkId]; - const rpcUrl = Array.isArray(urls) && urls.length > 0 ? urls[0] : null; - if (!rpcUrl) return; - - detectProxy(address, rpcUrl, bytecode) + detectProxy(address, dataService.networkAdapter, bytecode) .then(setProxyInfo) .catch(() => setProxyInfo(null)); - }, [address, networkId, bytecode, rpcUrls]); + }, [address, networkId, bytecode, dataService]); return proxyInfo; } diff --git a/src/services/adapters/NetworkAdapter.ts b/src/services/adapters/NetworkAdapter.ts index 1dcb2f3b..ffc809d1 100644 --- a/src/services/adapters/NetworkAdapter.ts +++ b/src/services/adapters/NetworkAdapter.ts @@ -213,6 +213,24 @@ export abstract class NetworkAdapter { */ abstract getNetworkStats(): Promise>; + /** + * Get storage value at a given slot for an address. + * Used for proxy detection (EIP-1967, EIP-1822, etc.) + */ + async getStorageAt(address: string, slot: string): Promise { + const result = await this.getClient().getStorageAt(address, slot, "latest"); + return result.data ?? "0x"; + } + + /** + * Execute a low-level eth_call. + * Used for beacon proxy implementation() calls during proxy detection. + */ + async callContract(to: string, data: string): Promise { + const result = await this.getClient().execute("eth_call", [{ to, data }, "latest"]); + return result.data ?? "0x"; + } + /** * Get gas prices with tiers (Low/Average/High) using eth_feeHistory * @returns Gas price tiers and base fee diff --git a/src/utils/addressTypeDetection.ts b/src/utils/addressTypeDetection.ts index b02b8cd6..1b0e2c10 100644 --- a/src/utils/addressTypeDetection.ts +++ b/src/utils/addressTypeDetection.ts @@ -208,6 +208,9 @@ export interface FetchAddressWithTypeOptions { addressHash: string; chainId: number; rpcUrl: string; + /** Pre-fetched address data. When provided, skips the initial eth_getCode/eth_getBalance + * RPC calls and uses this data directly for the EOA/contract check. */ + preloadedAddress?: Address; } export interface AddressWithType { @@ -265,10 +268,12 @@ function hasContractCode(code: string | null | undefined): boolean { export async function fetchAddressWithType( options: FetchAddressWithTypeOptions, ): Promise { - const { addressHash, chainId, rpcUrl } = options; + const { addressHash, chainId, rpcUrl, preloadedAddress } = options; - // Step 1: Fetch basic address data (balance, code, txCount) - const address = await fetchAddressData(addressHash, rpcUrl); + // Step 1: Use pre-fetched address data if available, otherwise fetch it. + // Using pre-loaded data avoids a redundant eth_getCode call and prevents + // contracts from being misclassified as EOA when the secondary RPC call fails. + const address = preloadedAddress ?? (await fetchAddressData(addressHash, rpcUrl)); // Step 2: Check for EIP-7702 delegation (EOA with delegated code) if (isEIP7702Delegation(address.code)) { diff --git a/src/utils/proxyDetection.ts b/src/utils/proxyDetection.ts index 7bb363dc..43fc44f6 100644 --- a/src/utils/proxyDetection.ts +++ b/src/utils/proxyDetection.ts @@ -1,16 +1,33 @@ import { logger } from "./logger"; -export type ProxyType = "EIP-1967" | "EIP-1967-Beacon" | "EIP-1822" | "EIP-1167"; +export type ProxyType = + | "EIP-1967" + | "EIP-1967-Beacon" + | "EIP-1822" + | "EIP-1167" + | "Transparent (Legacy)"; export interface ProxyInfo { type: ProxyType; implementationAddress: string; } -// Storage slots +/** Minimal RPC interface required for proxy detection. Satisfied by NetworkAdapter. */ +export interface ProxyRpcClient { + getStorageAt(address: string, slot: string): Promise; + callContract(to: string, data: string): Promise; +} + +// EIP-1967 implementation slot: keccak256("eip1967.proxy.implementation") - 1 const EIP1967_IMPL_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; +// EIP-1967 beacon slot: keccak256("eip1967.proxy.beacon") - 1 const EIP1967_BEACON_SLOT = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50"; +// EIP-1822 (UUPS) slot: keccak256("PROXIABLE") const EIP1822_SLOT = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"; +// Legacy OpenZeppelin AdminUpgradeabilityProxy: keccak256("org.zeppelinos.proxy.implementation") +// Used by Circle USDC (FiatTokenProxy) and other pre-EIP-1967 OZ proxies +const OZ_LEGACY_IMPL_SLOT = "0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3"; + const ZERO_SLOT = `0x${"0".repeat(64)}`; // EIP-1167 minimal proxy bytecode pattern @@ -19,111 +36,76 @@ const EIP1167_PATTERN = /^0x363d3d373d3d3d363d73([0-9a-f]{40})5af43d/i; // beacon.implementation() selector const BEACON_IMPL_SELECTOR = "0x5c60da1b"; -async function getStorageAt(address: string, slot: string, rpcUrl: string): Promise { - try { - const response = await fetch(rpcUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_getStorageAt", - params: [address, slot, "latest"], - id: 1, - }), - }); - const json = await response.json(); - return json.result ?? null; - } catch (err) { - logger.warn("getStorageAt failed:", err); - return null; - } -} - -function slotToAddress(slot: string): string { - // Take the last 40 hex chars (20 bytes) and prefix with 0x - const hex = slot.replace(/^0x/, "").slice(-40); - return toChecksumAddress(`0x${hex}`); -} - -function isZeroAddress(address: string): boolean { - return /^0x0{40}$/.test(address); +/** + * Extract a 20-byte address from a 32-byte storage slot value. + * Solidity stores addresses right-aligned (left-padded with zeros). + * Returns null if the slot value doesn't look like a packed address + * (i.e. if the upper 12 bytes are not all zero — a valid ABI-encoded + * address always has leading zeros in those bytes). + */ +function slotToAddress(slot: string): string | null { + const hex = slot.replace(/^0x/, ""); + if (hex.length !== 64) return null; + // Upper 12 bytes (24 hex chars) must be zero for a valid address slot + const prefix = hex.slice(0, 24); + if (!/^0+$/.test(prefix)) return null; + const addr = `0x${hex.slice(24)}`; + if (/^0x0{40}$/.test(addr)) return null; // zero address + return addr; } -function toChecksumAddress(address: string): string { - // Simple EIP-55 checksum implementation - const addr = address.toLowerCase().replace("0x", ""); - // Use a simple implementation without external deps - const chars = addr.split(""); - // We need keccak256 hash of the address - use a simple approximation - // For display purposes, return the lowercase hex with 0x prefix - // Full EIP-55 checksum requires keccak256 which we don't have as a pure util here - return `0x${chars.join("")}`; -} - -async function callBeaconImplementation( - beaconAddress: string, - rpcUrl: string, -): Promise { - try { - const response = await fetch(rpcUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_call", - params: [{ to: beaconAddress, data: BEACON_IMPL_SELECTOR }, "latest"], - id: 1, - }), - }); - const json = await response.json(); - const result: string = json.result; - if (!result || result === "0x") return null; - // result is a 32-byte ABI-encoded address - const implAddr = slotToAddress(result); - if (isZeroAddress(implAddr)) return null; - return implAddr; - } catch (err) { - logger.warn("callBeaconImplementation failed:", err); - return null; - } +function isNonZeroSlot(val: string): boolean { + return val !== "0x" && val !== ZERO_SLOT; } export async function detectProxy( address: string, - rpcUrl: string, + client: ProxyRpcClient, bytecode: string, ): Promise { - // 1. EIP-1967 implementation slot - const implVal = await getStorageAt(address, EIP1967_IMPL_SLOT, rpcUrl); - if (implVal && implVal !== ZERO_SLOT && implVal !== "0x") { - const implAddr = slotToAddress(implVal); - if (!isZeroAddress(implAddr)) { - return { type: "EIP-1967", implementationAddress: implAddr }; - } + // Check all storage slots in parallel to minimise latency + const [implVal, uupsVal, beaconVal, legacyVal] = await Promise.all([ + client.getStorageAt(address, EIP1967_IMPL_SLOT).catch(() => "0x"), + client.getStorageAt(address, EIP1822_SLOT).catch(() => "0x"), + client.getStorageAt(address, EIP1967_BEACON_SLOT).catch(() => "0x"), + client.getStorageAt(address, OZ_LEGACY_IMPL_SLOT).catch(() => "0x"), + ]); + + // 1. EIP-1967 transparent proxy + if (isNonZeroSlot(implVal)) { + const addr = slotToAddress(implVal); + if (addr) return { type: "EIP-1967", implementationAddress: addr }; } - // 2. EIP-1822 (UUPS) slot - const uupsVal = await getStorageAt(address, EIP1822_SLOT, rpcUrl); - if (uupsVal && uupsVal !== ZERO_SLOT && uupsVal !== "0x") { - const uupsAddr = slotToAddress(uupsVal); - if (!isZeroAddress(uupsAddr)) { - return { type: "EIP-1822", implementationAddress: uupsAddr }; - } + // 2. EIP-1822 UUPS proxy + if (isNonZeroSlot(uupsVal)) { + const addr = slotToAddress(uupsVal); + if (addr) return { type: "EIP-1822", implementationAddress: addr }; } - // 3. EIP-1967 Beacon slot - const beaconVal = await getStorageAt(address, EIP1967_BEACON_SLOT, rpcUrl); - if (beaconVal && beaconVal !== ZERO_SLOT && beaconVal !== "0x") { + // 3. EIP-1967 beacon proxy — resolve beacon then call implementation() + if (isNonZeroSlot(beaconVal)) { const beaconAddr = slotToAddress(beaconVal); - if (!isZeroAddress(beaconAddr)) { - const beaconImpl = await callBeaconImplementation(beaconAddr, rpcUrl); - if (beaconImpl) { - return { type: "EIP-1967-Beacon", implementationAddress: beaconImpl }; + if (beaconAddr) { + try { + const result = await client.callContract(beaconAddr, BEACON_IMPL_SELECTOR); + if (result && result !== "0x") { + const implAddr = slotToAddress(result); + if (implAddr) return { type: "EIP-1967-Beacon", implementationAddress: implAddr }; + } + } catch (err) { + logger.warn("beacon implementation() call failed:", err); } } } - // 4. EIP-1167 minimal proxy (check bytecode) + // 4. Legacy OZ AdminUpgradeabilityProxy (pre-EIP-1967) — used by Circle USDC + if (isNonZeroSlot(legacyVal)) { + const addr = slotToAddress(legacyVal); + if (addr) return { type: "Transparent (Legacy)", implementationAddress: addr }; + } + + // 5. EIP-1167 minimal proxy — detect via bytecode pattern const eip1167Match = bytecode.match(EIP1167_PATTERN); if (eip1167Match?.[1]) { return { type: "EIP-1167", implementationAddress: `0x${eip1167Match[1]}` }; From 7dc7dfd354f63c5b441d972c74c51fec5e3a62cf Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 5 Mar 2026 09:22:27 -0300 Subject: [PATCH 03/13] fix(address): add proxy detection to ERC20Display ERC20 tokens that are also proxies (e.g. USDC/FiatTokenProxy) now detect the proxy pattern and pass proxyInfo + implementationContractData to ContractInfoCard, enabling the implementation ABI tab switcher. --- .../pages/evm/address/displays/ERC20Display.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/pages/evm/address/displays/ERC20Display.tsx b/src/components/pages/evm/address/displays/ERC20Display.tsx index eb64cf6f..13bc8425 100644 --- a/src/components/pages/evm/address/displays/ERC20Display.tsx +++ b/src/components/pages/evm/address/displays/ERC20Display.tsx @@ -3,6 +3,7 @@ import { useContext, useEffect, useMemo, useState } from "react"; import { getNetworkById } from "../../../../../config/networks"; import { AppContext } from "../../../../../context"; import { useSourcify } from "../../../../../hooks/useSourcify"; +import { useProxyInfo } from "../../../../../hooks/useProxyInfo"; import { fetchToken, getAssetUrl, @@ -58,6 +59,14 @@ const ERC20Display: React.FC = ({ isVerified, } = useSourcify(Number(networkId), addressHash, true); + // Detect proxy pattern and fetch implementation contract data + const proxyInfo = useProxyInfo(addressHash, networkId, address.code ?? ""); + const { data: implSourcifyData, isVerified: implIsVerified } = useSourcify( + Number(networkId), + proxyInfo?.implementationAddress, + !!proxyInfo, + ); + // Fetch token metadata from explorer-metadata useEffect(() => { fetchToken(Number(networkId), addressHash) @@ -283,6 +292,8 @@ const ERC20Display: React.FC = ({ ? `https://repo.sourcify.dev/contracts/full_match/${networkId}/${addressHash}/` : undefined } + proxyInfo={proxyInfo} + implementationContractData={implIsVerified ? implSourcifyData : null} /> From 3df3c58a9370e6c7502220a847bff1cd7a81296f Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 5 Mar 2026 09:34:02 -0300 Subject: [PATCH 04/13] fix(address): show warning when proxy implementation is not verified on Sourcify When a proxy is detected but its implementation contract is not verified on Sourcify (e.g. Aave V3 Pool on Optimism), show a clear note explaining why no implementation ABI tab is available. --- .../pages/evm/address/shared/ContractInfoCard.tsx | 8 ++++++++ src/locales/en/address.json | 3 ++- src/locales/es/address.json | 3 ++- src/locales/ja/address.json | 3 ++- src/locales/pt-BR/address.json | 3 ++- src/locales/zh/address.json | 3 ++- 6 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/components/pages/evm/address/shared/ContractInfoCard.tsx b/src/components/pages/evm/address/shared/ContractInfoCard.tsx index b1516573..b30fcf4b 100644 --- a/src/components/pages/evm/address/shared/ContractInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractInfoCard.tsx @@ -169,6 +169,14 @@ const ContractInfoCard: React.FC = ({ )} + {/* Implementation not verified warning */} + {proxyInfo && !hasImplAbi && ( +
+ + {t("implementationNotVerified")} +
+ )} + {/* Sourcify Link */} {sourcifyUrl && (
diff --git a/src/locales/en/address.json b/src/locales/en/address.json index 72e1b1be..efc0b5d0 100644 --- a/src/locales/en/address.json +++ b/src/locales/en/address.json @@ -197,5 +197,6 @@ "proxyType": "Proxy Type", "implementationAddress": "Implementation", "implementationFunctions": "Implementation", - "proxyFunctions": "Proxy" + "proxyFunctions": "Proxy", + "implementationNotVerified": "Implementation not verified on Sourcify" } diff --git a/src/locales/es/address.json b/src/locales/es/address.json index 043c3569..41be7c14 100644 --- a/src/locales/es/address.json +++ b/src/locales/es/address.json @@ -197,5 +197,6 @@ "proxyType": "Tipo de Proxy", "implementationAddress": "Implementación", "implementationFunctions": "Implementación", - "proxyFunctions": "Proxy" + "proxyFunctions": "Proxy", + "implementationNotVerified": "Implementación no verificada en Sourcify" } diff --git a/src/locales/ja/address.json b/src/locales/ja/address.json index bd435882..9f4aa6b2 100644 --- a/src/locales/ja/address.json +++ b/src/locales/ja/address.json @@ -197,5 +197,6 @@ "proxyType": "プロキシタイプ", "implementationAddress": "実装", "implementationFunctions": "実装", - "proxyFunctions": "プロキシ" + "proxyFunctions": "プロキシ", + "implementationNotVerified": "実装はSourcifyで検証されていません" } diff --git a/src/locales/pt-BR/address.json b/src/locales/pt-BR/address.json index 5a8929a1..665c50d5 100644 --- a/src/locales/pt-BR/address.json +++ b/src/locales/pt-BR/address.json @@ -197,5 +197,6 @@ "proxyType": "Tipo de Proxy", "implementationAddress": "Implementação", "implementationFunctions": "Implementação", - "proxyFunctions": "Proxy" + "proxyFunctions": "Proxy", + "implementationNotVerified": "Implementação não verificada no Sourcify" } diff --git a/src/locales/zh/address.json b/src/locales/zh/address.json index 2d618165..ce60c3e3 100644 --- a/src/locales/zh/address.json +++ b/src/locales/zh/address.json @@ -197,5 +197,6 @@ "proxyType": "代理类型", "implementationAddress": "实现合约", "implementationFunctions": "实现", - "proxyFunctions": "代理" + "proxyFunctions": "代理", + "implementationNotVerified": "实现合约未在Sourcify上验证" } From de2a68846de5f6a54273bcb58ebd68f8a895414b Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 5 Mar 2026 09:35:00 -0300 Subject: [PATCH 05/13] fix(address): show proxy type and implementation address before contract details --- .../evm/address/shared/ContractInfoCard.tsx | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/components/pages/evm/address/shared/ContractInfoCard.tsx b/src/components/pages/evm/address/shared/ContractInfoCard.tsx index b30fcf4b..99571fed 100644 --- a/src/components/pages/evm/address/shared/ContractInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractInfoCard.tsx @@ -117,32 +117,6 @@ const ContractInfoCard: React.FC = ({
- {/* Contract Name */} - {contractData?.name && ( -
- {t("contractName")}: - {contractData.name} -
- )} - - {/* Compiler Version */} - {contractData?.compilerVersion && ( -
- {t("compiler")}: - - {contractData.compilerVersion} - -
- )} - - {/* EVM Version */} - {contractData?.evmVersion && ( -
- {t("evmVersion")}: - {contractData.evmVersion} -
- )} - {/* Proxy Type */} {proxyInfo && (
@@ -177,6 +151,32 @@ const ContractInfoCard: React.FC = ({
)} + {/* Contract Name */} + {contractData?.name && ( +
+ {t("contractName")}: + {contractData.name} +
+ )} + + {/* Compiler Version */} + {contractData?.compilerVersion && ( +
+ {t("compiler")}: + + {contractData.compilerVersion} + +
+ )} + + {/* EVM Version */} + {contractData?.evmVersion && ( +
+ {t("evmVersion")}: + {contractData.evmVersion} +
+ )} + {/* Sourcify Link */} {sourcifyUrl && (
From c7f27a7df29a7ed78899fac1b0e61fa56bd96bae Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 5 Mar 2026 09:36:17 -0300 Subject: [PATCH 06/13] fix(address): use correct route format for implementation address link --- src/components/pages/evm/address/shared/ContractInfoCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/pages/evm/address/shared/ContractInfoCard.tsx b/src/components/pages/evm/address/shared/ContractInfoCard.tsx index 99571fed..f731e020 100644 --- a/src/components/pages/evm/address/shared/ContractInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractInfoCard.tsx @@ -131,7 +131,7 @@ const ContractInfoCard: React.FC = ({ {t("implementationAddress")}: {proxyInfo.implementationAddress} From 5a23487b23244bde7e7a319e1bc34ac3e86a263d Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 5 Mar 2026 13:32:59 -0300 Subject: [PATCH 07/13] feat(address): add Etherscan as parallel contract verification source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useEtherscan hook fetching from Etherscan V2 API (single key, 60+ chains) - Add useContractVerification hook running Sourcify and Etherscan simultaneously; source is an array — empty means unverified, ["sourcify"], ["etherscan"], or both - Replace useSourcify calls in ContractDisplay, ERC20Display, ERC721Display, ERC1155Display with useContractVerification; add proxy detection to ERC721/ERC1155 - Show Sourcify and Etherscan as linked tag badges on the status row in ContractInfoCard, following the same style as Kleros tags (PR #277) - Compute verification URLs internally in ContractInfoCard (correct partial_match path for Sourcify; per-chain explorer URL for Etherscan); remove sourcifyUrl prop - Add Etherscan API key field to Settings (after Alchemy, before AI keys) - Add etherscan key to ApiKeys type and all 5 locale files - Fix double separator between EVM Version and Contract Details rows --- .../evm/address/displays/ContractDisplay.tsx | 17 +- .../evm/address/displays/ERC1155Display.tsx | 24 ++- .../evm/address/displays/ERC20Display.tsx | 15 +- .../evm/address/displays/ERC721Display.tsx | 24 ++- .../evm/address/shared/ContractInfoCard.tsx | 79 +++++--- src/components/pages/settings/index.tsx | 40 ++++ src/hooks/useContractVerification.ts | 53 ++++++ src/hooks/useEtherscan.ts | 178 ++++++++++++++++++ src/locales/en/settings.json | 5 + src/locales/es/settings.json | 5 + src/locales/ja/settings.json | 5 + src/locales/pt-BR/settings.json | 5 + src/locales/zh/settings.json | 5 + src/styles/components.css | 38 +++- src/types/index.ts | 1 + 15 files changed, 428 insertions(+), 66 deletions(-) create mode 100644 src/hooks/useContractVerification.ts create mode 100644 src/hooks/useEtherscan.ts diff --git a/src/components/pages/evm/address/displays/ContractDisplay.tsx b/src/components/pages/evm/address/displays/ContractDisplay.tsx index 9d299731..d194e485 100644 --- a/src/components/pages/evm/address/displays/ContractDisplay.tsx +++ b/src/components/pages/evm/address/displays/ContractDisplay.tsx @@ -2,7 +2,7 @@ import type React from "react"; import { useContext, useMemo } from "react"; import { getNetworkById } from "../../../../../config/networks"; import { AppContext } from "../../../../../context"; -import { useSourcify } from "../../../../../hooks/useSourcify"; +import { useContractVerification } from "../../../../../hooks/useContractVerification"; import { useProxyInfo } from "../../../../../hooks/useProxyInfo"; import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; @@ -42,18 +42,19 @@ const ContractDisplay: React.FC = ({ const networkName = network?.name ?? "Unknown Network"; const networkCurrency = network?.currency ?? "ETH"; - // Fetch Sourcify data + // Fetch verified contract data (Sourcify → Etherscan fallback) const { data: sourcifyData, loading: sourcifyLoading, isVerified, - } = useSourcify(Number(networkId), addressHash, true); + source: verificationSource, + } = useContractVerification(Number(networkId), addressHash, true); // Detect proxy pattern const proxyInfo = useProxyInfo(addressHash, networkId, address.code ?? ""); - // Fetch implementation contract data from Sourcify (only when proxy detected) - const { data: implSourcifyData, isVerified: implIsVerified } = useSourcify( + // Fetch implementation contract data (Sourcify → Etherscan fallback) + const { data: implSourcifyData, isVerified: implIsVerified } = useContractVerification( Number(networkId), proxyInfo?.implementationAddress, !!proxyInfo, @@ -159,11 +160,7 @@ const ContractDisplay: React.FC = ({ hasVerifiedContract={hasVerifiedContract} sourcifyLoading={sourcifyLoading} isLocalArtifact={!!parsedLocalData && !isVerified} - sourcifyUrl={ - sourcifyData - ? `https://repo.sourcify.dev/contracts/full_match/${networkId}/${addressHash}/` - : undefined - } + verificationSource={verificationSource} proxyInfo={proxyInfo} implementationContractData={implIsVerified ? implSourcifyData : null} /> diff --git a/src/components/pages/evm/address/displays/ERC1155Display.tsx b/src/components/pages/evm/address/displays/ERC1155Display.tsx index d370b8f5..5e6ba95e 100644 --- a/src/components/pages/evm/address/displays/ERC1155Display.tsx +++ b/src/components/pages/evm/address/displays/ERC1155Display.tsx @@ -2,7 +2,8 @@ import type React from "react"; import { useContext, useEffect, useMemo, useState } from "react"; import { getNetworkById } from "../../../../../config/networks"; import { AppContext } from "../../../../../context"; -import { useSourcify } from "../../../../../hooks/useSourcify"; +import { useContractVerification } from "../../../../../hooks/useContractVerification"; +import { useProxyInfo } from "../../../../../hooks/useProxyInfo"; import { fetchToken, getAssetUrl, @@ -50,12 +51,21 @@ const ERC1155Display: React.FC = ({ symbol?: string; } | null>(null); - // Fetch Sourcify data + // Fetch verified contract data (Sourcify → Etherscan fallback) const { data: sourcifyData, loading: sourcifyLoading, isVerified, - } = useSourcify(Number(networkId), addressHash, true); + source: verificationSource, + } = useContractVerification(Number(networkId), addressHash, true); + + // Detect proxy pattern and fetch implementation contract data + const proxyInfo = useProxyInfo(addressHash, networkId, address.code ?? ""); + const { data: implSourcifyData, isVerified: implIsVerified } = useContractVerification( + Number(networkId), + proxyInfo?.implementationAddress, + !!proxyInfo, + ); // Fetch token metadata from explorer-metadata useEffect(() => { @@ -267,11 +277,9 @@ const ERC1155Display: React.FC = ({ hasVerifiedContract={hasVerifiedContract} sourcifyLoading={sourcifyLoading} isLocalArtifact={!!parsedLocalData && !isVerified} - sourcifyUrl={ - sourcifyData - ? `https://repo.sourcify.dev/contracts/full_match/${networkId}/${addressHash}/` - : undefined - } + verificationSource={verificationSource} + proxyInfo={proxyInfo} + implementationContractData={implIsVerified ? implSourcifyData : null} />
diff --git a/src/components/pages/evm/address/displays/ERC20Display.tsx b/src/components/pages/evm/address/displays/ERC20Display.tsx index 13bc8425..a92e6bb5 100644 --- a/src/components/pages/evm/address/displays/ERC20Display.tsx +++ b/src/components/pages/evm/address/displays/ERC20Display.tsx @@ -2,7 +2,7 @@ import type React from "react"; import { useContext, useEffect, useMemo, useState } from "react"; import { getNetworkById } from "../../../../../config/networks"; import { AppContext } from "../../../../../context"; -import { useSourcify } from "../../../../../hooks/useSourcify"; +import { useContractVerification } from "../../../../../hooks/useContractVerification"; import { useProxyInfo } from "../../../../../hooks/useProxyInfo"; import { fetchToken, @@ -52,16 +52,17 @@ const ERC20Display: React.FC = ({ totalSupply?: string; } | null>(null); - // Fetch Sourcify data + // Fetch verified contract data (Sourcify → Etherscan fallback) const { data: sourcifyData, loading: sourcifyLoading, isVerified, - } = useSourcify(Number(networkId), addressHash, true); + source: verificationSource, + } = useContractVerification(Number(networkId), addressHash, true); // Detect proxy pattern and fetch implementation contract data const proxyInfo = useProxyInfo(addressHash, networkId, address.code ?? ""); - const { data: implSourcifyData, isVerified: implIsVerified } = useSourcify( + const { data: implSourcifyData, isVerified: implIsVerified } = useContractVerification( Number(networkId), proxyInfo?.implementationAddress, !!proxyInfo, @@ -287,11 +288,7 @@ const ERC20Display: React.FC = ({ hasVerifiedContract={hasVerifiedContract} sourcifyLoading={sourcifyLoading} isLocalArtifact={!!parsedLocalData && !isVerified} - sourcifyUrl={ - sourcifyData - ? `https://repo.sourcify.dev/contracts/full_match/${networkId}/${addressHash}/` - : undefined - } + verificationSource={verificationSource} proxyInfo={proxyInfo} implementationContractData={implIsVerified ? implSourcifyData : null} /> diff --git a/src/components/pages/evm/address/displays/ERC721Display.tsx b/src/components/pages/evm/address/displays/ERC721Display.tsx index 10905af5..e78be1e4 100644 --- a/src/components/pages/evm/address/displays/ERC721Display.tsx +++ b/src/components/pages/evm/address/displays/ERC721Display.tsx @@ -2,7 +2,8 @@ import type React from "react"; import { useContext, useEffect, useMemo, useState } from "react"; import { getNetworkById } from "../../../../../config/networks"; import { AppContext } from "../../../../../context"; -import { useSourcify } from "../../../../../hooks/useSourcify"; +import { useContractVerification } from "../../../../../hooks/useContractVerification"; +import { useProxyInfo } from "../../../../../hooks/useProxyInfo"; import { fetchToken, getAssetUrl, @@ -50,12 +51,21 @@ const ERC721Display: React.FC = ({ totalSupply?: string; } | null>(null); - // Fetch Sourcify data + // Fetch verified contract data (Sourcify → Etherscan fallback) const { data: sourcifyData, loading: sourcifyLoading, isVerified, - } = useSourcify(Number(networkId), addressHash, true); + source: verificationSource, + } = useContractVerification(Number(networkId), addressHash, true); + + // Detect proxy pattern and fetch implementation contract data + const proxyInfo = useProxyInfo(addressHash, networkId, address.code ?? ""); + const { data: implSourcifyData, isVerified: implIsVerified } = useContractVerification( + Number(networkId), + proxyInfo?.implementationAddress, + !!proxyInfo, + ); // Fetch token metadata from explorer-metadata useEffect(() => { @@ -249,11 +259,9 @@ const ERC721Display: React.FC = ({ hasVerifiedContract={hasVerifiedContract} sourcifyLoading={sourcifyLoading} isLocalArtifact={!!parsedLocalData && !isVerified} - sourcifyUrl={ - sourcifyData - ? `https://repo.sourcify.dev/contracts/full_match/${networkId}/${addressHash}/` - : undefined - } + verificationSource={verificationSource} + proxyInfo={proxyInfo} + implementationContractData={implIsVerified ? implSourcifyData : null} /> diff --git a/src/components/pages/evm/address/shared/ContractInfoCard.tsx b/src/components/pages/evm/address/shared/ContractInfoCard.tsx index f731e020..187bbd4f 100644 --- a/src/components/pages/evm/address/shared/ContractInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractInfoCard.tsx @@ -4,8 +4,9 @@ import { Link } from "react-router-dom"; import type { Address, ABI } from "../../../../../types"; import { useTranslation } from "react-i18next"; import ContractInteraction from "./ContractInteraction"; -import type { ProxyInfo } from "../../../../../utils/proxyDetection"; +import type { VerificationSource } from "../../../../../hooks/useContractVerification"; import type { SourcifyContractDetails } from "../../../../../hooks/useSourcify"; +import type { ProxyInfo } from "../../../../../utils/proxyDetection"; interface ContractData { name?: string; @@ -25,6 +26,17 @@ interface ContractData { sources?: Record; } +const ETHERSCAN_EXPLORERS: Record = { + 1: "https://etherscan.io", + 42161: "https://arbiscan.io", + 10: "https://optimistic.etherscan.io", + 8453: "https://basescan.org", + 56: "https://bscscan.com", + 137: "https://polygonscan.com", + 11155111: "https://sepolia.etherscan.io", + 97: "https://testnet.bscscan.com", +}; + interface ContractInfoCardProps { address: Address; addressHash: string; @@ -33,7 +45,7 @@ interface ContractInfoCardProps { hasVerifiedContract: boolean; sourcifyLoading: boolean; isLocalArtifact: boolean; - sourcifyUrl?: string; + verificationSource?: VerificationSource; proxyInfo?: ProxyInfo | null; implementationContractData?: SourcifyContractDetails | null; } @@ -48,7 +60,7 @@ const ContractInfoCard: React.FC = ({ hasVerifiedContract, sourcifyLoading, isLocalArtifact, - sourcifyUrl, + verificationSource, proxyInfo, implementationContractData, }) => { @@ -59,14 +71,16 @@ const ContractInfoCard: React.FC = ({ const [showRawAbi, setShowRawAbi] = useState(false); const [abiView, setAbiView] = useState("implementation"); - const getMatchBadgeText = () => { - if (isLocalArtifact) return "Local JSON"; - if (contractData?.match === "perfect") return "Perfect Match"; - if (contractData?.match === "partial") return "Partial Match"; - return null; - }; - - const matchBadgeText = getMatchBadgeText(); + // Compute verification source URLs + const sourcifyMatchPath = contractData?.match === "partial" ? "partial_match" : "full_match"; + const sourcifyTagUrl = verificationSource?.includes("sourcify") + ? `https://repo.sourcify.dev/contracts/${sourcifyMatchPath}/${networkId}/${addressHash}/` + : null; + const etherscanBase = ETHERSCAN_EXPLORERS[Number(networkId)]; + const etherscanTagUrl = + verificationSource?.includes("etherscan") && etherscanBase + ? `${etherscanBase}/address/${addressHash}#code` + : null; // Determine the active ABI based on tab selection const hasImplAbi = !!( @@ -109,7 +123,27 @@ const ContractInfoCard: React.FC = ({ {t("verified")} - {matchBadgeText && {matchBadgeText}} + {isLocalArtifact && Local JSON} + {sourcifyTagUrl && ( + + Sourcify ↗ + + )} + {etherscanTagUrl && ( + + Etherscan ↗ + + )} ) : ( {t("notVerified")} @@ -147,7 +181,9 @@ const ContractInfoCard: React.FC = ({ {proxyInfo && !hasImplAbi && (
- {t("implementationNotVerified")} + + {t("implementationNotVerified")} +
)} @@ -177,23 +213,6 @@ const ContractInfoCard: React.FC = ({ )} - {/* Sourcify Link */} - {sourcifyUrl && ( -
- Sourcify: - - - View on Sourcify ↗ - - -
- )} - {/* Contract Details Section - shown when: a) proxy contract itself has a verified ABI, OR b) proxy is detected and its implementation has a verified ABI */} diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 2efa70ba..8fab390c 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -105,6 +105,7 @@ const Settings: React.FC = () => { const [localApiKeys, setLocalApiKeys] = useState({ infura: settings.apiKeys?.infura || "", alchemy: settings.apiKeys?.alchemy || "", + etherscan: settings.apiKeys?.etherscan || "", groq: settings.apiKeys?.groq || "", openai: settings.apiKeys?.openai || "", anthropic: settings.apiKeys?.anthropic || "", @@ -114,6 +115,7 @@ const Settings: React.FC = () => { const [showApiKeys, setShowApiKeys] = useState({ infura: false, alchemy: false, + etherscan: false, groq: false, openai: false, anthropic: false, @@ -493,6 +495,7 @@ const Settings: React.FC = () => { apiKeys: { infura: localApiKeys.infura || undefined, alchemy: localApiKeys.alchemy || undefined, + etherscan: localApiKeys.etherscan || undefined, groq: localApiKeys.groq || undefined, openai: localApiKeys.openai || undefined, anthropic: localApiKeys.anthropic || undefined, @@ -779,6 +782,43 @@ const Settings: React.FC = () => { + +
+
+ {t("apiKeys.etherscan.name")} + + {t("apiKeys.etherscan.getKey")} → + +
+
+ + setLocalApiKeys((prev) => ({ ...prev, etherscan: e.target.value })) + } + placeholder={t("apiKeys.etherscan.placeholder")} + /> + +
+
{/* AI Provider API Keys */} diff --git a/src/hooks/useContractVerification.ts b/src/hooks/useContractVerification.ts new file mode 100644 index 00000000..3a4936b1 --- /dev/null +++ b/src/hooks/useContractVerification.ts @@ -0,0 +1,53 @@ +import { useSettings } from "../context/SettingsContext"; +import { useEtherscan } from "./useEtherscan"; +import type { SourcifyContractDetails } from "./useSourcify"; +import { useSourcify } from "./useSourcify"; + +export type VerificationSource = ("sourcify" | "etherscan")[]; + +export interface ContractVerificationResult { + data: SourcifyContractDetails | null; + loading: boolean; + isVerified: boolean; + source: VerificationSource; +} + +/** + * Unified contract verification hook. + * Fetches Sourcify and Etherscan simultaneously (when a key is configured). + * Sourcify data takes priority when available (more canonical/trustless). + * source is an array of the providers that verified the contract; empty = not verified. + */ +export function useContractVerification( + networkId: number, + address: string | undefined, + enabled: boolean = true, +): ContractVerificationResult { + const { settings } = useSettings(); + const hasEtherscanKey = !!settings.apiKeys?.etherscan; + + const { + data: sourcifyData, + loading: sourcifyLoading, + isVerified: sourcifyVerified, + } = useSourcify(networkId, address, enabled); + + // Run Etherscan in parallel whenever a key is configured + const { + data: etherscanData, + loading: etherscanLoading, + isVerified: etherscanVerified, + } = useEtherscan(networkId, address, enabled && hasEtherscanKey); + + const loading = sourcifyLoading || etherscanLoading; + const source: VerificationSource = [ + ...(sourcifyVerified ? (["sourcify"] as const) : []), + ...(etherscanVerified ? (["etherscan"] as const) : []), + ]; + const isVerified = source.length > 0; + + // Sourcify data takes priority; fall back to Etherscan data + const data = (sourcifyVerified && sourcifyData) || (etherscanVerified && etherscanData) || null; + + return { data, loading, isVerified, source }; +} diff --git a/src/hooks/useEtherscan.ts b/src/hooks/useEtherscan.ts new file mode 100644 index 00000000..fc8a51c3 --- /dev/null +++ b/src/hooks/useEtherscan.ts @@ -0,0 +1,178 @@ +import { useEffect, useState } from "react"; +import { useSettings } from "../context/SettingsContext"; +import { logger } from "../utils/logger"; +import type { SourcifyContractDetails } from "./useSourcify"; + +const ETHERSCAN_V2_API = "https://api.etherscan.io/v2/api"; + +interface EtherscanSourceResult { + SourceCode: string; + ABI: string; + ContractName: string; + CompilerVersion: string; + OptimizationUsed: string; + Runs: string; + ConstructorArguments: string; + EVMVersion: string; + Library: string; + LicenseType: string; + Proxy: string; + Implementation: string; + SwarmSource: string; +} + +interface StandardJsonSources { + sources?: Record; +} + +/** + * Parse Etherscan SourceCode field into SourcifyContractDetails files array. + * Handles three formats: + * 1. Plain Solidity string + * 2. Double-brace JSON "{{...}}" (standard JSON input) + * 3. Single-brace JSON "{...}" (standard JSON input) + */ +function parseSourceFiles( + sourceCode: string, + contractName: string, +): { name: string; path: string; content: string }[] { + if (!sourceCode) return []; + + // Try double-brace format first (Etherscan wraps standard JSON in {{ ... }}) + const doubleBrace = sourceCode.startsWith("{{") && sourceCode.endsWith("}}"); + const singleBrace = !doubleBrace && sourceCode.startsWith("{"); + + if (doubleBrace || singleBrace) { + try { + const jsonStr = doubleBrace ? sourceCode.slice(1, -1) : sourceCode; + const parsed = JSON.parse(jsonStr) as StandardJsonSources; + if (parsed.sources && typeof parsed.sources === "object") { + return Object.entries(parsed.sources).map(([path, src]) => ({ + name: path.split("/").pop() ?? path, + path, + content: src.content ?? "", + })); + } + } catch { + // Fall through to treat as plain source + } + } + + // Plain Solidity source + const fileName = `${contractName || "Contract"}.sol`; + return [{ name: fileName, path: fileName, content: sourceCode }]; +} + +/** + * Hook to fetch verified contract data from Etherscan V2 API. + * Returns the same shape as useSourcify for drop-in compatibility. + * Only fetches when `enabled` is true AND an Etherscan API key is configured. + */ +export function useEtherscan( + networkId: number, + address: string | undefined, + enabled: boolean, +): { data: SourcifyContractDetails | null; loading: boolean; isVerified: boolean } { + const { settings } = useSettings(); + const apiKey = settings.apiKeys?.etherscan; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [isVerified, setIsVerified] = useState(false); + + useEffect(() => { + if (!enabled || !address || !networkId || !apiKey) { + setData(null); + setIsVerified(false); + setLoading(false); + return; + } + + const controller = new AbortController(); + + const fetchData = async () => { + setLoading(true); + try { + const params = new URLSearchParams({ + chainid: String(networkId), + module: "contract", + action: "getsourcecode", + address, + apikey: apiKey, + }); + const url = `${ETHERSCAN_V2_API}?${params.toString()}`; + const response = await fetch(url, { signal: controller.signal }); + + if (!response.ok) { + setData(null); + setIsVerified(false); + return; + } + + const json = await response.json(); + + // Etherscan returns status "0" when not verified or error + if (json.status !== "1" || !Array.isArray(json.result) || json.result.length === 0) { + setData(null); + setIsVerified(false); + return; + } + + const result = json.result[0] as EtherscanSourceResult; + + // "Contract source code not verified" means not verified + if ( + typeof result === "string" || + result.ABI === "Contract source code not verified" || + !result.ABI || + result.ABI === "" + ) { + setData(null); + setIsVerified(false); + return; + } + + // Parse ABI + // biome-ignore lint/suspicious/noExplicitAny: ABI items can be any shape + let abi: any[] | undefined; + try { + abi = JSON.parse(result.ABI) as typeof abi; + } catch { + abi = undefined; + } + + const files = parseSourceFiles(result.SourceCode, result.ContractName); + const evmVersion = + result.EVMVersion && result.EVMVersion !== "Default" ? result.EVMVersion : undefined; + + const contractDetails: SourcifyContractDetails = { + name: result.ContractName || undefined, + compilerVersion: result.CompilerVersion || undefined, + evmVersion, + abi, + files, + match: "perfect", + creation_match: null, + runtime_match: null, + chainId: String(networkId), + address, + }; + + setData(contractDetails); + setIsVerified(true); + } catch (err) { + if ((err as Error)?.name === "AbortError") return; + logger.error("Error fetching Etherscan data:", err); + setData(null); + setIsVerified(false); + } finally { + setLoading(false); + } + }; + + fetchData(); + return () => controller.abort(); + }, [networkId, address, enabled, apiKey]); + + return { data, loading, isVerified }; +} diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 34dfeb5d..57e955bc 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -65,6 +65,11 @@ "getKey": "Get Key", "placeholder": "Enter your Alchemy API key" }, + "etherscan": { + "name": "Etherscan", + "getKey": "Get Key", + "placeholder": "Enter your Etherscan API key" + }, "toggleHide": "Hide API key", "toggleShow": "Show API key", "aiTitle": "AI Analysis Keys", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 3c6a2068..05613dad 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -65,6 +65,11 @@ "getKey": "Obtener Key", "placeholder": "Ingresá tu API key de Alchemy" }, + "etherscan": { + "name": "Etherscan", + "getKey": "Obtener Key", + "placeholder": "Ingresá tu API key de Etherscan" + }, "toggleHide": "Ocultar API key", "toggleShow": "Mostrar API key", "aiTitle": "Claves de Análisis IA", diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index 38bd6313..7cb825e2 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -65,6 +65,11 @@ "getKey": "キーを取得", "placeholder": "AlchemyのAPIキーを入力" }, + "etherscan": { + "name": "Etherscan", + "getKey": "キーを取得", + "placeholder": "EtherscanのAPIキーを入力" + }, "toggleHide": "APIキーを非表示", "toggleShow": "APIキーを表示", "aiTitle": "AI分析キー", diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index ed5184e5..ced77f80 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -65,6 +65,11 @@ "getKey": "Obter Chave", "placeholder": "Insira sua chave de API do Alchemy" }, + "etherscan": { + "name": "Etherscan", + "getKey": "Obter Chave", + "placeholder": "Insira sua chave de API do Etherscan" + }, "toggleHide": "Ocultar chave de API", "toggleShow": "Mostrar chave de API", "aiTitle": "Chaves de Análise de IA", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 9fc352a3..d6f3431c 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -65,6 +65,11 @@ "getKey": "获取密钥", "placeholder": "输入您的 Alchemy API 密钥" }, + "etherscan": { + "name": "Etherscan", + "getKey": "获取密钥", + "placeholder": "输入您的 Etherscan API 密钥" + }, "toggleHide": "隐藏 API 密钥", "toggleShow": "显示 API 密钥", "aiTitle": "AI 分析密钥", diff --git a/src/styles/components.css b/src/styles/components.css index 103b2d61..d7fa3aa0 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -6627,6 +6627,39 @@ button.tx-section-header-toggle { margin-left: 4px; } +.verification-source-tag { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid; + text-decoration: none; + margin-left: 4px; +} + +.verification-source-tag--sourcify { + color: #2e8b57; + background: rgba(46, 139, 87, 0.1); + border-color: rgba(46, 139, 87, 0.3); +} + +.verification-source-tag--sourcify:hover { + background: rgba(46, 139, 87, 0.2); +} + +.verification-source-tag--etherscan { + color: #1a56db; + background: rgba(26, 86, 219, 0.1); + border-color: rgba(26, 86, 219, 0.3); +} + +.verification-source-tag--etherscan:hover { + background: rgba(26, 86, 219, 0.2); +} + .contract-not-verified { color: var(--text-secondary); } @@ -6733,11 +6766,14 @@ button.tx-section-header-toggle { ========================================================================== */ .contract-details-section { - margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-primary-alpha-8); } +.account-card-row + .contract-details-section { + border-top: none; +} + .contract-details-toggle { display: flex; align-items: center; diff --git a/src/types/index.ts b/src/types/index.ts index 2abb8c9c..b54277c2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -421,6 +421,7 @@ export type RpcUrlsContextType = Record; export interface ApiKeys { infura?: string; alchemy?: string; + etherscan?: string; groq?: string; openai?: string; anthropic?: string; From e8cff75bb60f20728c67164d0bbc5d28f1d8edf1 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Fri, 6 Mar 2026 18:31:53 -0300 Subject: [PATCH 08/13] fix(address): improve proxy detection and contract name display - Distinguish EIP-1967 Transparent vs UUPS by checking admin slots (both EIP-1967 admin and legacy OZ admin for Aave V2 compatibility) - Pass implSourcifyData unconditionally so unverified impl names still show; gate ABI interaction separately via implIsVerified prop - Fall back to implementation contract name when proxy itself is unverified - Fix contract name flickering by keeping display components mounted during background re-fetches (only show loader when addressData is not yet loaded) - Remove unused hasContractCode import --- .../evm/address/displays/ContractDisplay.tsx | 3 +- src/components/pages/evm/address/index.tsx | 4 +-- .../evm/address/shared/ContractInfoCard.tsx | 16 +++++++---- src/utils/proxyDetection.ts | 28 +++++++++++++++---- 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/components/pages/evm/address/displays/ContractDisplay.tsx b/src/components/pages/evm/address/displays/ContractDisplay.tsx index 7d30d8cf..4c1c2987 100644 --- a/src/components/pages/evm/address/displays/ContractDisplay.tsx +++ b/src/components/pages/evm/address/displays/ContractDisplay.tsx @@ -166,7 +166,8 @@ const ContractDisplay: React.FC = ({ isLocalArtifact={!!parsedLocalData && !isVerified} verificationSource={verificationSource} proxyInfo={proxyInfo} - implementationContractData={implIsVerified ? implSourcifyData : null} + implementationContractData={implSourcifyData} + implIsVerified={implIsVerified} /> diff --git a/src/components/pages/evm/address/index.tsx b/src/components/pages/evm/address/index.tsx index eae97b28..2206b985 100644 --- a/src/components/pages/evm/address/index.tsx +++ b/src/components/pages/evm/address/index.tsx @@ -8,7 +8,7 @@ import { useKlerosTag } from "../../../../hooks/useKlerosTag"; import { useProviderSelection } from "../../../../hooks/useProviderSelection"; import { ENSService } from "../../../../services/ENS/ENSService"; import type { Address as AddressData, AddressType, DataWithMetadata } from "../../../../types"; -import { fetchAddressWithType, hasContractCode } from "../../../../utils/addressTypeDetection"; +import { fetchAddressWithType } from "../../../../utils/addressTypeDetection"; import Loader from "../../../common/Loader"; import { AccountDisplay, @@ -206,7 +206,7 @@ export default function Address() { ); } - if (loading || typeLoading) { + if ((loading || typeLoading) && !addressData) { return (
diff --git a/src/components/pages/evm/address/shared/ContractInfoCard.tsx b/src/components/pages/evm/address/shared/ContractInfoCard.tsx index 187bbd4f..a5563d51 100644 --- a/src/components/pages/evm/address/shared/ContractInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractInfoCard.tsx @@ -48,6 +48,7 @@ interface ContractInfoCardProps { verificationSource?: VerificationSource; proxyInfo?: ProxyInfo | null; implementationContractData?: SourcifyContractDetails | null; + implIsVerified?: boolean; } type AbiView = "implementation" | "proxy"; @@ -63,6 +64,7 @@ const ContractInfoCard: React.FC = ({ verificationSource, proxyInfo, implementationContractData, + implIsVerified = false, }) => { const { t } = useTranslation("address"); const [showBytecode, setShowBytecode] = useState(false); @@ -82,9 +84,11 @@ const ContractInfoCard: React.FC = ({ ? `${etherscanBase}/address/${addressHash}#code` : null; - // Determine the active ABI based on tab selection + // Only use implementation ABI when the implementation is actually verified const hasImplAbi = !!( - implementationContractData?.abi && implementationContractData.abi.length > 0 + implIsVerified && + implementationContractData?.abi && + implementationContractData.abi.length > 0 ); const hasProxyAbi = !!(contractData?.abi && contractData.abi.length > 0); const showAbiTabSwitcher = !!(proxyInfo && hasImplAbi && hasProxyAbi); @@ -187,11 +191,13 @@ const ContractInfoCard: React.FC = ({
)} - {/* Contract Name */} - {contractData?.name && ( + {/* Contract Name — fall back to implementation name for unverified proxies */} + {(contractData?.name || implementationContractData?.name) && (
{t("contractName")}: - {contractData.name} + + {contractData?.name ?? implementationContractData?.name} +
)} diff --git a/src/utils/proxyDetection.ts b/src/utils/proxyDetection.ts index 43fc44f6..d727762d 100644 --- a/src/utils/proxyDetection.ts +++ b/src/utils/proxyDetection.ts @@ -1,7 +1,8 @@ import { logger } from "./logger"; export type ProxyType = - | "EIP-1967" + | "EIP-1967 Transparent" + | "EIP-1967 UUPS" | "EIP-1967-Beacon" | "EIP-1822" | "EIP-1167" @@ -20,9 +21,15 @@ export interface ProxyRpcClient { // EIP-1967 implementation slot: keccak256("eip1967.proxy.implementation") - 1 const EIP1967_IMPL_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; +// EIP-1967 admin slot: keccak256("eip1967.proxy.admin") - 1 +// Non-zero → Transparent Proxy; zero with impl set → UUPS +const EIP1967_ADMIN_SLOT = "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103"; +// Legacy OZ admin slot: keccak256("org.zeppelinos.proxy.admin") +// Used by Aave V2 and other proxies that mix EIP-1967 impl slot with older admin slot +const OZ_LEGACY_ADMIN_SLOT = "0x10d6a54a4754c8869d6886b5f5d7fbfa5b4522237ea5c60d11bc4e7a1ff9390b"; // EIP-1967 beacon slot: keccak256("eip1967.proxy.beacon") - 1 const EIP1967_BEACON_SLOT = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50"; -// EIP-1822 (UUPS) slot: keccak256("PROXIABLE") +// EIP-1822 (UUPS) slot: keccak256("PROXIABLE") — legacy UUPS, pre-OZ v4 const EIP1822_SLOT = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"; // Legacy OpenZeppelin AdminUpgradeabilityProxy: keccak256("org.zeppelinos.proxy.implementation") // Used by Circle USDC (FiatTokenProxy) and other pre-EIP-1967 OZ proxies @@ -64,20 +71,29 @@ export async function detectProxy( bytecode: string, ): Promise { // Check all storage slots in parallel to minimise latency - const [implVal, uupsVal, beaconVal, legacyVal] = await Promise.all([ + const [implVal, adminVal, legacyAdminVal, uupsVal, beaconVal, legacyVal] = await Promise.all([ client.getStorageAt(address, EIP1967_IMPL_SLOT).catch(() => "0x"), + client.getStorageAt(address, EIP1967_ADMIN_SLOT).catch(() => "0x"), + client.getStorageAt(address, OZ_LEGACY_ADMIN_SLOT).catch(() => "0x"), client.getStorageAt(address, EIP1822_SLOT).catch(() => "0x"), client.getStorageAt(address, EIP1967_BEACON_SLOT).catch(() => "0x"), client.getStorageAt(address, OZ_LEGACY_IMPL_SLOT).catch(() => "0x"), ]); - // 1. EIP-1967 transparent proxy + // 1. EIP-1967: distinguish Transparent vs UUPS via admin slots. + // Modern UUPS (OZ v4+) uses the EIP-1967 impl slot but has no admin slot. + // Transparent proxies have a non-zero admin (EIP-1967 or legacy OZ admin slot). + // e.g. Aave V2 uses EIP-1967 impl slot + legacy OZ admin slot. if (isNonZeroSlot(implVal)) { const addr = slotToAddress(implVal); - if (addr) return { type: "EIP-1967", implementationAddress: addr }; + if (addr) { + const hasAdmin = isNonZeroSlot(adminVal) || isNonZeroSlot(legacyAdminVal); + const type = hasAdmin ? "EIP-1967 Transparent" : "EIP-1967 UUPS"; + return { type, implementationAddress: addr }; + } } - // 2. EIP-1822 UUPS proxy + // 2. Legacy EIP-1822 UUPS slot (pre-OZ v4, stores impl at keccak256("PROXIABLE")) if (isNonZeroSlot(uupsVal)) { const addr = slotToAddress(uupsVal); if (addr) return { type: "EIP-1822", implementationAddress: addr }; From 91b5f8d42b1581c2b9c9ac80bf1c60c5dfa685f3 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Fri, 6 Mar 2026 18:38:56 -0300 Subject: [PATCH 09/13] fix(address): prevent display component remount on background re-fetches Initialize addressType as null and reset it on each new fetch. The loader is now shown until both addressData and addressType are determined (first load only). Subsequent re-fetches from dataService reference changes no longer unmount the active display component, preserving all hook state (proxy detection, Sourcify data, contract name, etc.). --- src/components/pages/evm/address/index.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/pages/evm/address/index.tsx b/src/components/pages/evm/address/index.tsx index 2206b985..07f134a5 100644 --- a/src/components/pages/evm/address/index.tsx +++ b/src/components/pages/evm/address/index.tsx @@ -28,7 +28,7 @@ export default function Address() { const numericNetworkId = Number(networkId) || 1; const { rpcUrls } = useContext(AppContext); const [addressData, setAddressData] = useState(null); - const [addressType, setAddressType] = useState("account"); + const [addressType, setAddressType] = useState(null); const [loading, setLoading] = useState(true); const [typeLoading, setTypeLoading] = useState(true); const [error, setError] = useState(null); @@ -122,6 +122,7 @@ export default function Address() { setLoading(true); setTypeLoading(true); + setAddressType(null); setError(null); // Use DataService to fetch address data with metadata support @@ -206,7 +207,11 @@ export default function Address() { ); } - if ((loading || typeLoading) && !addressData) { + // Show loader only until both the address data *and* the type are determined for + // the first time. Background re-fetches (e.g. dataService reference change) must + // not unmount the display component — that would reset all child hook state + // (proxy detection, Sourcify data, etc.) and cause visible flicker. + if (!addressData || addressType === null) { return (
From 5d433428f746e4f185437d60fcc22c3ad442794c Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 9 Mar 2026 09:45:00 -0300 Subject: [PATCH 10/13] fix(address): map Sourcify V2 response and leverage proxyResolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useSourcify: add SourcifyV2Raw internal type and mapV2Response() to correctly extract compilation fields (name, compilerVersion, evmVersion, language, optimizer) from the nested 'compilation' object returned by V2 API — these were previously undefined because the raw JSON was assigned directly without mapping - useSourcify: normalize match values ('exact_match' -> 'perfect') - useSourcify: add proxyResolution field (isProxy, proxyType, implementations) - useSourcify: remove dead code — SOURCIFY_API_BASE (V1 URL) and useSourcifyFiles hook (never imported anywhere) - ContractDisplay: merge Sourcify proxyResolution with RPC detection; Sourcify provides the implementation address reliably, RPC provides accurate Transparent vs UUPS distinction - ContractDisplay: expose sourcifyImplName from proxyResolution so the implementation contract name is available immediately without waiting for a second verification fetch - ContractDisplay: rename sourcifyData -> contractVerifiedData (also in ERC20/721/1155Display) to reflect it may come from Etherscan fallback - ContractInfoCard: add language, optimizerEnabled, optimizerRuns fields and display rows; use sourcifyImplName as immediate name fallback - index.tsx: only reset addressType when navigating to a new address (use prevAddressRef); keeps display component mounted on background re-fetches, preserving all hook state - useProxyInfo: keep last known proxyInfo on RPC failure instead of resetting to null, preventing verified impl data from disappearing - i18n: add language, optimizer, optimizerEnabled, optimizerDisabled keys in all 5 locales --- .../evm/address/displays/ContractDisplay.tsx | 52 +++++- .../evm/address/displays/ERC1155Display.tsx | 6 +- .../evm/address/displays/ERC20Display.tsx | 6 +- .../evm/address/displays/ERC721Display.tsx | 6 +- src/components/pages/evm/address/index.tsx | 17 +- .../evm/address/shared/ContractInfoCard.tsx | 38 +++- src/hooks/useProxyInfo.ts | 6 +- src/hooks/useSourcify.ts | 165 +++++++++--------- src/locales/en/address.json | 6 +- src/locales/es/address.json | 6 +- src/locales/ja/address.json | 6 +- src/locales/pt-BR/address.json | 6 +- src/locales/zh/address.json | 6 +- 13 files changed, 213 insertions(+), 113 deletions(-) diff --git a/src/components/pages/evm/address/displays/ContractDisplay.tsx b/src/components/pages/evm/address/displays/ContractDisplay.tsx index 4c1c2987..8044d47d 100644 --- a/src/components/pages/evm/address/displays/ContractDisplay.tsx +++ b/src/components/pages/evm/address/displays/ContractDisplay.tsx @@ -13,6 +13,21 @@ import ContractInfoCards from "../shared/ContractInfoCards"; import { logger } from "../../../../../utils"; import { compactContractDataForAI } from "../../../../common/AIAnalysis/aiContext"; import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; +import type { ProxyInfo, ProxyType } from "../../../../../utils/proxyDetection"; + +/** Map Sourcify V2 proxyType string to our ProxyType enum. */ +function mapSourcifyProxyType(sourcifyType: string | undefined): ProxyType { + switch (sourcifyType) { + case "EIP1167Proxy": + return "EIP-1167"; + case "ZeppelinOSProxy": + return "Transparent (Legacy)"; + case "EIP1967Proxy": + default: + // RPC detection will refine Transparent vs UUPS; this is the safe default + return "EIP-1967 Transparent"; + } +} interface ContractDisplayProps { address: Address; @@ -47,16 +62,35 @@ const ContractDisplay: React.FC = ({ // Fetch verified contract data (Sourcify → Etherscan fallback) const { - data: sourcifyData, + data: contractVerifiedData, loading: sourcifyLoading, isVerified, source: verificationSource, } = useContractVerification(Number(networkId), addressHash, true); - // Detect proxy pattern - const proxyInfo = useProxyInfo(addressHash, networkId, address.code ?? ""); - - // Fetch implementation contract data (Sourcify → Etherscan fallback) + // RPC-based proxy detection — provides accurate Transparent vs UUPS distinction + const rpcProxyInfo = useProxyInfo(addressHash, networkId, address.code ?? ""); + + // Merge Sourcify proxy resolution (reliable impl address + name) with RPC detection (accurate type). + // Sourcify is preferred for the implementation address; RPC is preferred for the proxy type. + // For unverified contracts Sourcify has no data, so RPC detection is the only source. + const proxyInfo = useMemo((): ProxyInfo | null => { + const sp = contractVerifiedData?.proxyResolution; + const implAddr = sp?.implementations?.[0]?.address; + if (sp?.isProxy && implAddr) { + return { + // If RPC also detected it, use its more accurate type; otherwise fall back to Sourcify's type + type: rpcProxyInfo?.type ?? mapSourcifyProxyType(sp.proxyType), + implementationAddress: implAddr, + }; + } + return rpcProxyInfo; + }, [contractVerifiedData, rpcProxyInfo]); + + // Implementation name from Sourcify's proxy resolution (available without a second fetch) + const sourcifyImplName = contractVerifiedData?.proxyResolution?.implementations?.[0]?.name; + + // Fetch implementation contract data for ABI + source code (Sourcify → Etherscan fallback) const { data: implSourcifyData, isVerified: implIsVerified } = useContractVerification( Number(networkId), proxyInfo?.implementationAddress, @@ -100,8 +134,8 @@ const ContractDisplay: React.FC = ({ // Use local artifact data if available and sourcify is not verified const contractData = useMemo( - () => (isVerified && sourcifyData ? sourcifyData : parsedLocalData), - [isVerified, sourcifyData, parsedLocalData], + () => (isVerified && contractVerifiedData ? contractVerifiedData : parsedLocalData), + [isVerified, contractVerifiedData, parsedLocalData], ); const hasVerifiedContract = isVerified || !!parsedLocalData; @@ -131,7 +165,8 @@ const ContractDisplay: React.FC = ({ ], ); - logger.debug(contractData); + logger.debug("contract data", contractData); + logger.debug("implementation", implSourcifyData); return (
@@ -168,6 +203,7 @@ const ContractDisplay: React.FC = ({ proxyInfo={proxyInfo} implementationContractData={implSourcifyData} implIsVerified={implIsVerified} + sourcifyImplName={sourcifyImplName} />
diff --git a/src/components/pages/evm/address/displays/ERC1155Display.tsx b/src/components/pages/evm/address/displays/ERC1155Display.tsx index 6825a1d5..c4219985 100644 --- a/src/components/pages/evm/address/displays/ERC1155Display.tsx +++ b/src/components/pages/evm/address/displays/ERC1155Display.tsx @@ -56,7 +56,7 @@ const ERC1155Display: React.FC = ({ // Fetch verified contract data (Sourcify → Etherscan fallback) const { - data: sourcifyData, + data: contractVerifiedData, loading: sourcifyLoading, isVerified, source: verificationSource, @@ -190,8 +190,8 @@ const ERC1155Display: React.FC = ({ }, [localArtifact, networkId, addressHash]); const contractData = useMemo( - () => (isVerified && sourcifyData ? sourcifyData : parsedLocalData), - [isVerified, sourcifyData, parsedLocalData], + () => (isVerified && contractVerifiedData ? contractVerifiedData : parsedLocalData), + [isVerified, contractVerifiedData, parsedLocalData], ); const hasVerifiedContract = isVerified || !!parsedLocalData; diff --git a/src/components/pages/evm/address/displays/ERC20Display.tsx b/src/components/pages/evm/address/displays/ERC20Display.tsx index 1eb4dfd6..8793e2ab 100644 --- a/src/components/pages/evm/address/displays/ERC20Display.tsx +++ b/src/components/pages/evm/address/displays/ERC20Display.tsx @@ -57,7 +57,7 @@ const ERC20Display: React.FC = ({ // Fetch verified contract data (Sourcify → Etherscan fallback) const { - data: sourcifyData, + data: contractVerifiedData, loading: sourcifyLoading, isVerified, source: verificationSource, @@ -194,8 +194,8 @@ const ERC20Display: React.FC = ({ }, [localArtifact, networkId, addressHash]); const contractData = useMemo( - () => (isVerified && sourcifyData ? sourcifyData : parsedLocalData), - [isVerified, sourcifyData, parsedLocalData], + () => (isVerified && contractVerifiedData ? contractVerifiedData : parsedLocalData), + [isVerified, contractVerifiedData, parsedLocalData], ); const hasVerifiedContract = isVerified || !!parsedLocalData; diff --git a/src/components/pages/evm/address/displays/ERC721Display.tsx b/src/components/pages/evm/address/displays/ERC721Display.tsx index 71b73985..62e8932e 100644 --- a/src/components/pages/evm/address/displays/ERC721Display.tsx +++ b/src/components/pages/evm/address/displays/ERC721Display.tsx @@ -56,7 +56,7 @@ const ERC721Display: React.FC = ({ // Fetch verified contract data (Sourcify → Etherscan fallback) const { - data: sourcifyData, + data: contractVerifiedData, loading: sourcifyLoading, isVerified, source: verificationSource, @@ -171,8 +171,8 @@ const ERC721Display: React.FC = ({ }, [localArtifact, networkId, addressHash]); const contractData = useMemo( - () => (isVerified && sourcifyData ? sourcifyData : parsedLocalData), - [isVerified, sourcifyData, parsedLocalData], + () => (isVerified && contractVerifiedData ? contractVerifiedData : parsedLocalData), + [isVerified, contractVerifiedData, parsedLocalData], ); const hasVerifiedContract = isVerified || !!parsedLocalData; diff --git a/src/components/pages/evm/address/index.tsx b/src/components/pages/evm/address/index.tsx index 07f134a5..4edcd0a2 100644 --- a/src/components/pages/evm/address/index.tsx +++ b/src/components/pages/evm/address/index.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useParams } from "react-router-dom"; import { AppContext } from "../../../../context"; @@ -63,6 +63,11 @@ export default function Address() { const klerosTag = useKlerosTag(address, numericNetworkId); + // Track the last address for which type detection completed, so background + // re-fetches (e.g. dataService reference change) don't reset the type and + // unmount the active display component. + const prevAddressRef = useRef(undefined); + // Resolve ENS name to address useEffect(() => { if (!isEnsName || !addressParam) { @@ -120,9 +125,17 @@ export default function Address() { return; } + // Reset display state only when navigating to a different address. + // Background re-fetches triggered by dataService reference changes must + // not reset the type — doing so unmounts the display component and clears + // all its child hook state (proxy detection, Sourcify data, etc.). + if (address !== prevAddressRef.current) { + prevAddressRef.current = address; + setAddressType(null); + } + setLoading(true); setTypeLoading(true); - setAddressType(null); setError(null); // Use DataService to fetch address data with metadata support diff --git a/src/components/pages/evm/address/shared/ContractInfoCard.tsx b/src/components/pages/evm/address/shared/ContractInfoCard.tsx index a5563d51..e28739dc 100644 --- a/src/components/pages/evm/address/shared/ContractInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractInfoCard.tsx @@ -12,6 +12,9 @@ interface ContractData { name?: string; compilerVersion?: string; evmVersion?: string; + language?: string; + optimizerEnabled?: boolean; + optimizerRuns?: number; match?: "perfect" | "partial" | null; abi?: ABI[]; chainId?: string; @@ -49,6 +52,8 @@ interface ContractInfoCardProps { proxyInfo?: ProxyInfo | null; implementationContractData?: SourcifyContractDetails | null; implIsVerified?: boolean; + /** Implementation contract name from Sourcify's proxyResolution — available immediately without a second fetch. */ + sourcifyImplName?: string; } type AbiView = "implementation" | "proxy"; @@ -65,6 +70,7 @@ const ContractInfoCard: React.FC = ({ proxyInfo, implementationContractData, implIsVerified = false, + sourcifyImplName, }) => { const { t } = useTranslation("address"); const [showBytecode, setShowBytecode] = useState(false); @@ -174,8 +180,10 @@ const ContractInfoCard: React.FC = ({ > {proxyInfo.implementationAddress} - {implementationContractData?.name && ( - {implementationContractData.name} + {(implementationContractData?.name ?? sourcifyImplName) && ( + + {implementationContractData?.name ?? sourcifyImplName} + )}
@@ -191,12 +199,12 @@ const ContractInfoCard: React.FC = ({
)} - {/* Contract Name — fall back to implementation name for unverified proxies */} - {(contractData?.name || implementationContractData?.name) && ( + {/* Contract Name — fall back to implementation name (from full fetch or Sourcify's proxyResolution) */} + {(contractData?.name || implementationContractData?.name || sourcifyImplName) && (
{t("contractName")}: - {contractData?.name ?? implementationContractData?.name} + {contractData?.name ?? implementationContractData?.name ?? sourcifyImplName}
)} @@ -219,6 +227,26 @@ const ContractInfoCard: React.FC = ({
)} + {/* Language */} + {contractData?.language && contractData.language !== "Solidity" && ( +
+ {t("language")}: + {contractData.language} +
+ )} + + {/* Optimizer */} + {contractData?.optimizerEnabled !== undefined && ( +
+ {t("optimizer")}: + + {contractData.optimizerEnabled + ? t("optimizerEnabled", { runs: contractData.optimizerRuns ?? "?" }) + : t("optimizerDisabled")} + +
+ )} + {/* Contract Details Section - shown when: a) proxy contract itself has a verified ABI, OR b) proxy is detected and its implementation has a verified ABI */} diff --git a/src/hooks/useProxyInfo.ts b/src/hooks/useProxyInfo.ts index c0792ead..042906a2 100644 --- a/src/hooks/useProxyInfo.ts +++ b/src/hooks/useProxyInfo.ts @@ -16,7 +16,11 @@ export function useProxyInfo( detectProxy(address, dataService.networkAdapter, bytecode) .then(setProxyInfo) - .catch(() => setProxyInfo(null)); + .catch(() => { + // Keep the last known proxyInfo on failure (e.g. RPC error on re-fetch). + // Resetting to null would disable the implementation contract fetch and + // cause verified contract data to disappear from the UI. + }); }, [address, networkId, bytecode, dataService]); return proxyInfo; diff --git a/src/hooks/useSourcify.ts b/src/hooks/useSourcify.ts index 41afb7e6..691d5e71 100644 --- a/src/hooks/useSourcify.ts +++ b/src/hooks/useSourcify.ts @@ -10,10 +10,19 @@ export interface SourcifyMatch { verifiedAt?: string; } +export interface SourcifyProxyResolution { + isProxy: boolean; + proxyType?: string; + implementations?: { address: string; name?: string }[]; +} + export interface SourcifyContractDetails extends SourcifyMatch { name?: string; compilerVersion?: string; evmVersion?: string; + language?: string; + optimizerEnabled?: boolean; + optimizerRuns?: number; files?: { name: string; path: string; @@ -29,9 +38,79 @@ export interface SourcifyContractDetails extends SourcifyMatch { metadata?: any; // biome-ignore lint/suspicious/noExplicitAny: abi?: any[]; + proxyResolution?: SourcifyProxyResolution; +} + +/** Raw shape returned by Sourcify V2 API (?fields=all). */ +interface SourcifyV2Raw { + match?: string; + creationMatch?: string; + runtimeMatch?: string; + chainId?: string; + address?: string; + verifiedAt?: string; + sources?: Record; + // biome-ignore lint/suspicious/noExplicitAny: ABI items can be any shape + abi?: any[]; + compilation?: { + name?: string; + compilerVersion?: string; + language?: string; + compilerSettings?: { + evmVersion?: string; + optimizer?: { + enabled?: boolean; + runs?: number; + }; + }; + }; + proxyResolution?: { + isProxy: boolean; + proxyType?: string; + implementations?: { address: string; name?: string }[]; + }; +} + +function normalizeMatch(raw?: string): "perfect" | "partial" | null { + if (!raw) return null; + if (raw === "exact_match" || raw === "perfect") return "perfect"; + if (raw.includes("partial")) return "partial"; + return null; +} + +function mapV2Response(raw: SourcifyV2Raw): SourcifyContractDetails { + const compilation = raw.compilation; + const settings = compilation?.compilerSettings; + + const sources = raw.sources ?? undefined; + const files = sources + ? Object.entries(sources).map(([path, src]) => ({ + name: path.split("/").pop() ?? path, + path, + content: src.content ?? "", + })) + : undefined; + + return { + name: compilation?.name, + compilerVersion: compilation?.compilerVersion, + evmVersion: settings?.evmVersion, + language: compilation?.language, + optimizerEnabled: settings?.optimizer?.enabled, + optimizerRuns: settings?.optimizer?.runs, + abi: raw.abi, + sources, + files, + match: normalizeMatch(raw.match), + creation_match: normalizeMatch(raw.creationMatch), + runtime_match: normalizeMatch(raw.runtimeMatch), + chainId: raw.chainId ?? "", + address: raw.address ?? "", + verifiedAt: raw.verifiedAt, + proxyResolution: raw.proxyResolution, + }; } -const SOURCIFY_API_BASE = "https://repo.sourcify.dev/contracts"; const SOURCIFY_API_V2_BASE = "https://sourcify.dev/server"; /** @@ -81,7 +160,8 @@ export const useSourcify = ( return; } - const contractData: SourcifyContractDetails = await response.json(); + const raw = (await response.json()) as SourcifyV2Raw; + const contractData = mapV2Response(raw); setData(contractData); setIsVerified(!!contractData.match); } catch (err) { @@ -105,87 +185,6 @@ export const useSourcify = ( }; }; -/** - * Hook to fetch contract source files from Sourcify - * @param networkId - The network ID - * @param address - The contract address - * @param matchType - Type of match to fetch: 'full_match' | 'partial_match' - * @param enabled - Whether to fetch data (default: true) - */ -export const useSourcifyFiles = ( - networkId: number, - address: string | undefined, - matchType: "full_match" | "partial_match" = "full_match", - enabled: boolean = true, -) => { - const [files, setFiles] = useState<{ name: string; content: string; path: string }[]>([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!enabled || !address || !networkId) { - return; - } - - const fetchFiles = async () => { - setLoading(true); - setError(null); - - try { - // Fetch the file tree first (Sourcify API uses chainId in URL) - const treeUrl = `${SOURCIFY_API_BASE}/${matchType}/${networkId}/${address}/`; - const treeResponse = await fetch(treeUrl); - - if (!treeResponse.ok) { - throw new Error("Files not found"); - } - - const treeText = await treeResponse.text(); - - // Parse the directory listing (this is a simplified parser) - // In production, you might want to use the API v2 files endpoint instead - const fileMatches = treeText.match(/href="([^"]+)"/g); - - if (fileMatches) { - const fileList = fileMatches - .map((match) => match.match(/href="([^"]+)"/)?.[1]) - .filter((file): file is string => !!file && !file.endsWith("/")); - - // Fetch each file content - const fileContents = await Promise.all( - fileList.map(async (fileName) => { - const fileUrl = `${treeUrl}${fileName}`; - const fileResponse = await fetch(fileUrl); - const content = await fileResponse.text(); - return { - name: fileName, - path: fileName, - content, - }; - }), - ); - - setFiles(fileContents); - } - } catch (err) { - logger.error("Error fetching Sourcify files:", err); - setError(err instanceof Error ? err.message : "Unknown error occurred"); - setFiles([]); - } finally { - setLoading(false); - } - }; - - fetchFiles(); - }, [networkId, address, matchType, enabled]); - - return { - files, - loading, - error, - }; -}; - /** * Utility function to check if a contract is verified on Sourcify * @param networkId - The network ID diff --git a/src/locales/en/address.json b/src/locales/en/address.json index 20467f00..0a20d135 100644 --- a/src/locales/en/address.json +++ b/src/locales/en/address.json @@ -199,5 +199,9 @@ "implementationAddress": "Implementation", "implementationFunctions": "Implementation", "proxyFunctions": "Proxy", - "implementationNotVerified": "Implementation not verified on Sourcify" + "implementationNotVerified": "Implementation not verified on Sourcify", + "language": "Language", + "optimizer": "Optimizer", + "optimizerEnabled": "Enabled ({{runs}} runs)", + "optimizerDisabled": "Disabled" } diff --git a/src/locales/es/address.json b/src/locales/es/address.json index 054b0a57..0abd18a9 100644 --- a/src/locales/es/address.json +++ b/src/locales/es/address.json @@ -199,5 +199,9 @@ "implementationAddress": "Implementación", "implementationFunctions": "Implementación", "proxyFunctions": "Proxy", - "implementationNotVerified": "Implementación no verificada en Sourcify" + "implementationNotVerified": "Implementación no verificada en Sourcify", + "language": "Lenguaje", + "optimizer": "Optimizador", + "optimizerEnabled": "Activado ({{runs}} iteraciones)", + "optimizerDisabled": "Desactivado" } diff --git a/src/locales/ja/address.json b/src/locales/ja/address.json index 9f4aa6b2..f5989356 100644 --- a/src/locales/ja/address.json +++ b/src/locales/ja/address.json @@ -198,5 +198,9 @@ "implementationAddress": "実装", "implementationFunctions": "実装", "proxyFunctions": "プロキシ", - "implementationNotVerified": "実装はSourcifyで検証されていません" + "implementationNotVerified": "実装はSourcifyで検証されていません", + "language": "言語", + "optimizer": "オプティマイザ", + "optimizerEnabled": "有効 ({{runs}} 回)", + "optimizerDisabled": "無効" } diff --git a/src/locales/pt-BR/address.json b/src/locales/pt-BR/address.json index 665c50d5..ea026d06 100644 --- a/src/locales/pt-BR/address.json +++ b/src/locales/pt-BR/address.json @@ -198,5 +198,9 @@ "implementationAddress": "Implementação", "implementationFunctions": "Implementação", "proxyFunctions": "Proxy", - "implementationNotVerified": "Implementação não verificada no Sourcify" + "implementationNotVerified": "Implementação não verificada no Sourcify", + "language": "Linguagem", + "optimizer": "Otimizador", + "optimizerEnabled": "Ativado ({{runs}} execuções)", + "optimizerDisabled": "Desativado" } diff --git a/src/locales/zh/address.json b/src/locales/zh/address.json index ce60c3e3..a8d76c5f 100644 --- a/src/locales/zh/address.json +++ b/src/locales/zh/address.json @@ -198,5 +198,9 @@ "implementationAddress": "实现合约", "implementationFunctions": "实现", "proxyFunctions": "代理", - "implementationNotVerified": "实现合约未在Sourcify上验证" + "implementationNotVerified": "实现合约未在Sourcify上验证", + "language": "语言", + "optimizer": "优化器", + "optimizerEnabled": "启用 ({{runs}} 次)", + "optimizerDisabled": "禁用" } From c0a19d5821462bef26c4d423205fae16a7fd4383 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 9 Mar 2026 10:35:06 -0300 Subject: [PATCH 11/13] fix(address): fix source files, bytecode, and impl bytecode in proxy contract tabs - Derive sourceFiles from activeSourceData (same as activeABI) so implementation source code shows when on the Implementation tab - Suppress bottom bytecode section when contract-details-section already renders bytecode (prevents duplication for unverified proxy with verified implementation) - Add runtimeBytecode field to SourcifyContractDetails and map it from Sourcify V2 API response so bytecode switches with active tab - Add NetworkAdapter.getCode() for direct eth_getCode calls - Fetch implementation bytecode via RPC in ContractDisplay as fallback when Sourcify runtimeBytecode is unavailable (Etherscan-only contracts) --- .../evm/address/displays/ContractDisplay.tsx | 20 ++++++++- .../evm/address/shared/ContractInfoCard.tsx | 42 ++++++++++++++----- src/hooks/useSourcify.ts | 3 ++ src/services/adapters/NetworkAdapter.ts | 8 ++++ 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/components/pages/evm/address/displays/ContractDisplay.tsx b/src/components/pages/evm/address/displays/ContractDisplay.tsx index 8044d47d..74ebd8be 100644 --- a/src/components/pages/evm/address/displays/ContractDisplay.tsx +++ b/src/components/pages/evm/address/displays/ContractDisplay.tsx @@ -1,8 +1,9 @@ import type React from "react"; -import { useContext, useMemo } from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { getNetworkById } from "../../../../../config/networks"; import { AppContext } from "../../../../../context"; import { useContractVerification } from "../../../../../hooks/useContractVerification"; +import { useDataService } from "../../../../../hooks/useDataService"; import { useProxyInfo } from "../../../../../hooks/useProxyInfo"; import type { KlerosTag } from "../../../../../services/KlerosService"; import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; @@ -97,6 +98,22 @@ const ContractDisplay: React.FC = ({ !!proxyInfo, ); + // Fetch implementation bytecode via RPC — needed when Sourcify data doesn't include runtimeBytecode + // (e.g. Etherscan-only verified contracts) + const dataService = useDataService(Number(networkId)); + const [implCode, setImplCode] = useState(undefined); + useEffect(() => { + const implAddr = proxyInfo?.implementationAddress; + if (!implAddr || !dataService?.networkAdapter) { + setImplCode(undefined); + return; + } + dataService.networkAdapter + .getCode(implAddr) + .then((code) => setImplCode(code && code !== "0x" ? code : undefined)) + .catch(() => setImplCode(undefined)); + }, [proxyInfo?.implementationAddress, dataService]); + // Check if we have local artifact data for this address const localArtifact = jsonFiles[addressHash.toLowerCase()]; @@ -204,6 +221,7 @@ const ContractDisplay: React.FC = ({ implementationContractData={implSourcifyData} implIsVerified={implIsVerified} sourcifyImplName={sourcifyImplName} + implCode={implCode} /> diff --git a/src/components/pages/evm/address/shared/ContractInfoCard.tsx b/src/components/pages/evm/address/shared/ContractInfoCard.tsx index e28739dc..79268361 100644 --- a/src/components/pages/evm/address/shared/ContractInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractInfoCard.tsx @@ -27,6 +27,7 @@ interface ContractData { runtime_match?: string | null; files?: Array<{ name: string; path: string; content: string }>; sources?: Record; + runtimeBytecode?: { onchainBytecode?: string }; } const ETHERSCAN_EXPLORERS: Record = { @@ -54,6 +55,8 @@ interface ContractInfoCardProps { implIsVerified?: boolean; /** Implementation contract name from Sourcify's proxyResolution — available immediately without a second fetch. */ sourcifyImplName?: string; + /** Implementation bytecode fetched via RPC — fallback when Sourcify data lacks runtimeBytecode. */ + implCode?: string; } type AbiView = "implementation" | "proxy"; @@ -71,6 +74,7 @@ const ContractInfoCard: React.FC = ({ implementationContractData, implIsVerified = false, sourcifyImplName, + implCode, }) => { const { t } = useTranslation("address"); const [showBytecode, setShowBytecode] = useState(false); @@ -107,18 +111,36 @@ const ContractInfoCard: React.FC = ({ ? (implementationContractData?.abi as ABI[] | undefined) : contractData?.abi; - // Prepare source files array + // Derive source files from the same data source as the active ABI: + // - tab switcher active + impl tab → implementation source files + // - tab switcher active + proxy tab → proxy source files + // - no tab switcher, proxy with verified impl → implementation source files + // - otherwise → proxy (contractData) source files + const activeSourceData = + showAbiTabSwitcher + ? abiView === "implementation" + ? implementationContractData + : contractData + : proxyInfo && hasImplAbi + ? implementationContractData + : contractData; + const sourceFiles = - contractData?.files && contractData.files.length > 0 - ? contractData.files - : contractData?.sources - ? Object.entries(contractData.sources).map(([path, source]) => ({ + activeSourceData?.files && activeSourceData.files.length > 0 + ? activeSourceData.files + : activeSourceData?.sources + ? Object.entries(activeSourceData.sources).map(([path, source]) => ({ name: path, path: path, content: source.content || "", })) : []; + // Bytecode: prefer Sourcify's runtimeBytecode; fall back to RPC-fetched bytecode per tab + const activeBytecode = + activeSourceData?.runtimeBytecode?.onchainBytecode ?? + (activeSourceData === contractData ? address.code : implCode); + return (
{t("contractInfo")}
@@ -283,8 +305,8 @@ const ContractInfoCard: React.FC = ({
)} - {/* Contract Bytecode */} - {address.code && address.code !== "0x" && ( + {/* Contract Bytecode — switches with the active tab (impl vs proxy) */} + {activeBytecode && activeBytecode !== "0x" && (
{showBytecode && (
- {address.code} + {activeBytecode}
)}
@@ -360,8 +382,8 @@ const ContractInfoCard: React.FC = ({ ) : null} - {/* Bytecode for unverified contracts */} - {!hasVerifiedContract && address.code && address.code !== "0x" && ( + {/* Bytecode for unverified contracts — hidden when contract-details-section already shows it */} + {!hasVerifiedContract && !(proxyInfo && hasImplAbi) && address.code && address.code !== "0x" && (
- {showBytecode && ( -
- {address.code} -
- )} -
- )} + {!hasVerifiedContract && + !(proxyInfo && hasImplAbi) && + address.code && + address.code !== "0x" && ( +
+ + {showBytecode && ( +
+ {address.code} +
+ )} +
+ )} ); };