diff --git a/src/components/pages/evm/address/displays/ContractDisplay.tsx b/src/components/pages/evm/address/displays/ContractDisplay.tsx index 6e98daa1..032b5981 100644 --- a/src/components/pages/evm/address/displays/ContractDisplay.tsx +++ b/src/components/pages/evm/address/displays/ContractDisplay.tsx @@ -1,8 +1,10 @@ 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 { useSourcify } from "../../../../../hooks/useSourcify"; +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"; import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; @@ -12,6 +14,20 @@ 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)"; + default: + // RPC detection will refine Transparent vs UUPS; this is the safe default + return "EIP-1967 Transparent"; + } +} interface ContractDisplayProps { address: Address; @@ -44,12 +60,58 @@ 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, + data: contractVerifiedData, loading: sourcifyLoading, isVerified, - } = useSourcify(Number(networkId), addressHash, true); + source: verificationSource, + } = useContractVerification(Number(networkId), addressHash, true); + + // 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, + !!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()]; @@ -88,8 +150,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; @@ -119,7 +181,8 @@ const ContractDisplay: React.FC = ({ ], ); - logger.debug(contractData); + logger.debug("contract data", contractData); + logger.debug("implementation", implSourcifyData); return (
@@ -153,11 +216,12 @@ 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={implSourcifyData} + implIsVerified={implIsVerified} + sourcifyImplName={sourcifyImplName} + implCode={implCode} />
diff --git a/src/components/pages/evm/address/displays/ERC1155Display.tsx b/src/components/pages/evm/address/displays/ERC1155Display.tsx index ec5375d7..c4219985 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, @@ -53,12 +54,21 @@ const ERC1155Display: React.FC = ({ symbol?: string; } | null>(null); - // Fetch Sourcify data + // Fetch verified contract data (Sourcify → Etherscan fallback) const { - data: sourcifyData, + data: contractVerifiedData, 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(() => { @@ -180,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; @@ -271,11 +281,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 e1d093a9..8793e2ab 100644 --- a/src/components/pages/evm/address/displays/ERC20Display.tsx +++ b/src/components/pages/evm/address/displays/ERC20Display.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, @@ -54,12 +55,21 @@ const ERC20Display: React.FC = ({ totalSupply?: string; } | null>(null); - // Fetch Sourcify data + // Fetch verified contract data (Sourcify → Etherscan fallback) const { - data: sourcifyData, + data: contractVerifiedData, 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(() => { @@ -184,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; @@ -282,11 +292,9 @@ 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 e9387815..62e8932e 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, @@ -53,12 +54,21 @@ const ERC721Display: React.FC = ({ totalSupply?: string; } | null>(null); - // Fetch Sourcify data + // Fetch verified contract data (Sourcify → Etherscan fallback) const { - data: sourcifyData, + data: contractVerifiedData, 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(() => { @@ -161,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; @@ -253,11 +263,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/index.tsx b/src/components/pages/evm/address/index.tsx index 8e6fb562..09998173 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 { getNetworkById } from "../../../../config/networks"; @@ -33,9 +33,8 @@ export default function Address() { networkConfigData?.shortName || networkConfigData?.name || `Chain ${networkId}`; 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); // ENS resolution state @@ -68,6 +67,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) { @@ -121,12 +125,19 @@ export default function Address() { useEffect(() => { if (!address || !dataService) { setLoading(false); - setTypeLoading(false); 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); setError(null); // Use DataService to fetch address data with metadata support @@ -150,26 +161,26 @@ 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(() => { - // If detection fails, infer from already-fetched address code instead of forcing account - setAddressType(hasContractCode(addressData?.code) ? "contract" : "account"); - }) - .finally(() => { - setTypeLoading(false); + // Type detection failed — use the code we already have to distinguish + // contract from EOA rather than blindly defaulting to "account". + setAddressType(hasContractCode(addressData.code) ? "contract" : "account"); }); } else { - // No RPC available for type detection: still infer type from fetched address code - setAddressType(hasContractCode(addressData?.code) ? "contract" : "account"); - setTypeLoading(false); + // No RPC URL configured for type detection — derive type from pre-fetched code. + setAddressType(hasContractCode(addressData.code) ? "contract" : "account"); } }) .catch((err) => { setError(err.message || t("failedToFetchAddressData")); - setTypeLoading(false); }) .finally(() => setLoading(false)); }, [address, dataService, numericNetworkId, rpcUrls, t]); @@ -205,7 +216,11 @@ export default function Address() { ); } - if (loading || typeLoading) { + // 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 (
diff --git a/src/components/pages/evm/address/shared/ContractInfoCard.tsx b/src/components/pages/evm/address/shared/ContractInfoCard.tsx index b03d7aa1..d131e920 100644 --- a/src/components/pages/evm/address/shared/ContractInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractInfoCard.tsx @@ -1,13 +1,20 @@ 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 { VerificationSource } from "../../../../../hooks/useContractVerification"; +import type { SourcifyContractDetails } from "../../../../../hooks/useSourcify"; +import type { ProxyInfo } from "../../../../../utils/proxyDetection"; interface ContractData { name?: string; compilerVersion?: string; evmVersion?: string; + language?: string; + optimizerEnabled?: boolean; + optimizerRuns?: number; match?: "perfect" | "partial" | null; abi?: ABI[]; chainId?: string; @@ -20,8 +27,20 @@ interface ContractData { runtime_match?: string | null; files?: Array<{ name: string; path: string; content: string }>; sources?: Record; + runtimeBytecode?: { onchainBytecode?: string }; } +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; @@ -30,9 +49,18 @@ interface ContractInfoCardProps { hasVerifiedContract: boolean; sourcifyLoading: boolean; isLocalArtifact: boolean; - sourcifyUrl?: string; + verificationSource?: VerificationSource; + proxyInfo?: ProxyInfo | null; + implementationContractData?: SourcifyContractDetails | null; + 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"; + const ContractInfoCard: React.FC = ({ address, addressHash, @@ -41,35 +69,77 @@ const ContractInfoCard: React.FC = ({ hasVerifiedContract, sourcifyLoading, isLocalArtifact, - sourcifyUrl, + verificationSource, + proxyInfo, + implementationContractData, + implIsVerified = false, + sourcifyImplName, + implCode, }) => { 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"; - if (contractData?.match === "perfect") return "Perfect Match"; - if (contractData?.match === "partial") return "Partial Match"; - return null; - }; + // 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; + + // Only use implementation ABI when the implementation is actually verified + const hasImplAbi = !!( + implIsVerified && + 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; - const matchBadgeText = getMatchBadgeText(); + // 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; - // Prepare source files array 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")}
@@ -84,7 +154,27 @@ const ContractInfoCard: React.FC = ({ {t("verified")} - {matchBadgeText && {matchBadgeText}} + {isLocalArtifact && Local JSON} + {sourcifyTagUrl && ( + + Sourcify ↗ + + )} + {etherscanTagUrl && ( + + Etherscan ↗ + + )} ) : ( {t("notVerified")} @@ -92,11 +182,51 @@ const ContractInfoCard: React.FC = ({
- {/* Contract Name */} - {contractData?.name && ( + {/* Proxy Type */} + {proxyInfo && ( +
+ {t("proxyType")}: + {proxyInfo.type} +
+ )} + + {/* Implementation Address */} + {proxyInfo?.implementationAddress && ( +
+ {t("implementationAddress")}: + + + {proxyInfo.implementationAddress} + + {(implementationContractData?.name ?? sourcifyImplName) && ( + + {implementationContractData?.name ?? sourcifyImplName} + + )} + +
+ )} + + {/* Implementation not verified warning */} + {proxyInfo && !hasImplAbi && ( +
+ + + {t("implementationNotVerified")} + +
+ )} + + {/* Contract Name — fall back to implementation name (from full fetch or Sourcify's proxyResolution) */} + {(contractData?.name || implementationContractData?.name || sourcifyImplName) && (
{t("contractName")}: - {contractData.name} + + {contractData?.name ?? implementationContractData?.name ?? sourcifyImplName} +
)} @@ -118,25 +248,30 @@ const ContractInfoCard: React.FC = ({
)} - {/* Sourcify Link */} - {sourcifyUrl && ( + {/* Language */} + {contractData?.language && contractData.language !== "Solidity" && (
- Sourcify: + {t("language")}: + {contractData.language} +
+ )} + + {/* Optimizer */} + {contractData?.optimizerEnabled !== undefined && ( +
+ {t("optimizer")}: - - View on Sourcify ↗ - + {contractData.optimizerEnabled + ? t("optimizerEnabled", { runs: contractData.optimizerRuns ?? "?" }) + : t("optimizerDisabled")}
)} - {/* 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) ? (
+ +
+ )} + + {/* Contract Bytecode — switches with the active tab (impl vs proxy) */} + {activeBytecode && activeBytecode !== "0x" && (
{showBytecode && (
- {address.code} + {activeBytecode}
)}
@@ -195,7 +350,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 && ( )}
)} - )} + ) : null} - {/* Bytecode for unverified contracts */} - {!hasVerifiedContract && address.code && address.code !== "0x" && ( -
- - {showBytecode && ( -
- {address.code} -
- )} -
- )} + {/* Bytecode for unverified contracts — hidden when contract-details-section already shows it */} + {!hasVerifiedContract && + !(proxyInfo && hasImplAbi) && + address.code && + address.code !== "0x" && ( +
+ + {showBytecode && ( +
+ {address.code} +
+ )} +
+ )} ); }; 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/hooks/useProxyInfo.ts b/src/hooks/useProxyInfo.ts new file mode 100644 index 00000000..042906a2 --- /dev/null +++ b/src/hooks/useProxyInfo.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; +import { useDataService } from "./useDataService"; +import { type ProxyInfo, detectProxy } from "../utils/proxyDetection"; + +export function useProxyInfo( + address: string, + networkId: string, + bytecode: string, +): ProxyInfo | null { + const dataService = useDataService(Number(networkId)); + const [proxyInfo, setProxyInfo] = useState(null); + + useEffect(() => { + if (!address || !networkId || !bytecode || bytecode === "0x") return; + if (!dataService?.networkAdapter) return; + + detectProxy(address, dataService.networkAdapter, bytecode) + .then(setProxyInfo) + .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..41efc29f 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,82 @@ export interface SourcifyContractDetails extends SourcifyMatch { metadata?: any; // biome-ignore lint/suspicious/noExplicitAny: abi?: any[]; + proxyResolution?: SourcifyProxyResolution; + runtimeBytecode?: { onchainBytecode?: string }; +} + +/** 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; + runtimeBytecode?: { onchainBytecode?: string }; + // 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, + runtimeBytecode: raw.runtimeBytecode, + }; } -const SOURCIFY_API_BASE = "https://repo.sourcify.dev/contracts"; const SOURCIFY_API_V2_BASE = "https://sourcify.dev/server"; /** @@ -81,7 +163,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 +188,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 30b956da..0a20d135 100644 --- a/src/locales/en/address.json +++ b/src/locales/en/address.json @@ -194,5 +194,14 @@ "missingParam": "Please provide value for {{paramName}}", "invalidEthValue": "Invalid ETH value", "error": "Error: {{message}}" - } + }, + "proxyType": "Proxy Type", + "implementationAddress": "Implementation", + "implementationFunctions": "Implementation", + "proxyFunctions": "Proxy", + "implementationNotVerified": "Implementation not verified on Sourcify", + "language": "Language", + "optimizer": "Optimizer", + "optimizerEnabled": "Enabled ({{runs}} runs)", + "optimizerDisabled": "Disabled" } 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/address.json b/src/locales/es/address.json index d7870d38..0abd18a9 100644 --- a/src/locales/es/address.json +++ b/src/locales/es/address.json @@ -194,5 +194,14 @@ "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", + "implementationNotVerified": "Implementación no verificada en Sourcify", + "language": "Lenguaje", + "optimizer": "Optimizador", + "optimizerEnabled": "Activado ({{runs}} iteraciones)", + "optimizerDisabled": "Desactivado" } 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/address.json b/src/locales/ja/address.json index 0730f3a4..f5989356 100644 --- a/src/locales/ja/address.json +++ b/src/locales/ja/address.json @@ -193,5 +193,14 @@ "missingParam": "{{paramName}}の値を入力してください", "invalidEthValue": "無効なETH値です", "error": "エラー: {{message}}" - } + }, + "proxyType": "プロキシタイプ", + "implementationAddress": "実装", + "implementationFunctions": "実装", + "proxyFunctions": "プロキシ", + "implementationNotVerified": "実装はSourcifyで検証されていません", + "language": "言語", + "optimizer": "オプティマイザ", + "optimizerEnabled": "有効 ({{runs}} 回)", + "optimizerDisabled": "無効" } 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/address.json b/src/locales/pt-BR/address.json index 0c2eda01..ea026d06 100644 --- a/src/locales/pt-BR/address.json +++ b/src/locales/pt-BR/address.json @@ -193,5 +193,14 @@ "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", + "implementationNotVerified": "Implementação não verificada no Sourcify", + "language": "Linguagem", + "optimizer": "Otimizador", + "optimizerEnabled": "Ativado ({{runs}} execuções)", + "optimizerDisabled": "Desativado" } 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/address.json b/src/locales/zh/address.json index 3b897d34..a8d76c5f 100644 --- a/src/locales/zh/address.json +++ b/src/locales/zh/address.json @@ -193,5 +193,14 @@ "missingParam": "请提供 {{paramName}} 的值", "invalidEthValue": "ETH 值无效", "error": "错误:{{message}}" - } + }, + "proxyType": "代理类型", + "implementationAddress": "实现合约", + "implementationFunctions": "实现", + "proxyFunctions": "代理", + "implementationNotVerified": "实现合约未在Sourcify上验证", + "language": "语言", + "optimizer": "优化器", + "optimizerEnabled": "启用 ({{runs}} 次)", + "optimizerDisabled": "禁用" } 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/services/adapters/NetworkAdapter.ts b/src/services/adapters/NetworkAdapter.ts index 1dcb2f3b..ad7f1e85 100644 --- a/src/services/adapters/NetworkAdapter.ts +++ b/src/services/adapters/NetworkAdapter.ts @@ -213,6 +213,32 @@ 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"; + } + + /** + * Fetch the deployed bytecode (eth_getCode) for any address. + */ + async getCode(address: string): Promise { + const result = await this.getClient().getCode(address, "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/styles/components.css b/src/styles/components.css index d259c2d9..69c11b9e 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -6666,6 +6666,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); } @@ -6772,11 +6805,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; @@ -6800,6 +6836,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/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; diff --git a/src/utils/addressTypeDetection.ts b/src/utils/addressTypeDetection.ts index 73ef4977..0ca3ed51 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 @@ export 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 new file mode 100644 index 00000000..d727762d --- /dev/null +++ b/src/utils/proxyDetection.ts @@ -0,0 +1,131 @@ +import { logger } from "./logger"; + +export type ProxyType = + | "EIP-1967 Transparent" + | "EIP-1967 UUPS" + | "EIP-1967-Beacon" + | "EIP-1822" + | "EIP-1167" + | "Transparent (Legacy)"; + +export interface ProxyInfo { + type: ProxyType; + implementationAddress: string; +} + +/** 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 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") — 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 +const OZ_LEGACY_IMPL_SLOT = "0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3"; + +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"; + +/** + * 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 isNonZeroSlot(val: string): boolean { + return val !== "0x" && val !== ZERO_SLOT; +} + +export async function detectProxy( + address: string, + client: ProxyRpcClient, + bytecode: string, +): Promise { + // Check all storage slots in parallel to minimise latency + 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: 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) { + const hasAdmin = isNonZeroSlot(adminVal) || isNonZeroSlot(legacyAdminVal); + const type = hasAdmin ? "EIP-1967 Transparent" : "EIP-1967 UUPS"; + return { type, implementationAddress: addr }; + } + } + + // 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 }; + } + + // 3. EIP-1967 beacon proxy — resolve beacon then call implementation() + if (isNonZeroSlot(beaconVal)) { + const beaconAddr = slotToAddress(beaconVal); + 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. 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]}` }; + } + + return null; +}