From b7a03b012d2fa7eb9d380baf863e214752f95303 Mon Sep 17 00:00:00 2001 From: port <108868128+portdeveloper@users.noreply.github.com> Date: Thu, 3 Oct 2024 17:56:01 +0300 Subject: [PATCH] Use getsourcecode endpoint from etherscan api instead of anyabi+ proxy detection logic (#157) --- .github/workflows/test-app.yaml | 13 ++ packages/nextjs/.env.example | 8 +- packages/nextjs/hooks/useFetchContractAbi.ts | 43 ++---- .../pages/[contractAddress]/[network].tsx | 6 - packages/nextjs/pages/index.tsx | 2 +- .../nextjs/utils/abi-ninja/proxyContracts.ts | 135 ------------------ packages/nextjs/utils/abi.ts | 90 ++++++++---- packages/nextjs/utils/scaffold-eth/common.ts | 4 + .../nextjs/utils/scaffold-eth/networks.ts | 7 + 9 files changed, 106 insertions(+), 202 deletions(-) delete mode 100644 packages/nextjs/utils/abi-ninja/proxyContracts.ts diff --git a/.github/workflows/test-app.yaml b/.github/workflows/test-app.yaml index b9dbcbf1..6e887087 100644 --- a/.github/workflows/test-app.yaml +++ b/.github/workflows/test-app.yaml @@ -15,6 +15,19 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Create .env.local in nextjs + run: | + touch packages/nextjs/.env.local + echo NEXT_PUBLIC_GNOSIS_ETHERSCAN_API_KEY=${{ secrets.NEXT_PUBLIC_GNOSIS_ETHERSCAN_API_KEY }} >> packages/nextjs/.env.local + echo NEXT_PUBLIC_BSC_ETHERSCAN_API_KEY=${{ secrets.NEXT_PUBLIC_BSC_ETHERSCAN_API_KEY }} >> packages/nextjs/.env.local + echo NEXT_PUBLIC_HEIMDALL_URL=${{ secrets.NEXT_PUBLIC_HEIMDALL_URL }} >> packages/nextjs/.env.local + echo NEXT_PUBLIC_BASE_ETHERSCAN_API_KEY=${{ secrets.NEXT_PUBLIC_BASE_ETHERSCAN_API_KEY }} >> packages/nextjs/.env.local + echo NEXT_PUBLIC_SCROLL_ETHERSCAN_API_KEY=${{ secrets.NEXT_PUBLIC_SCROLL_ETHERSCAN_API_KEY }} >> packages/nextjs/.env.local + echo NEXT_PUBLIC_OPTIMISM_ETHERSCAN_API_KEY=${{ secrets.NEXT_PUBLIC_OPTIMISM_ETHERSCAN_API_KEY }} >> packages/nextjs/.env.local + echo NEXT_PUBLIC_MAINNET_ETHERSCAN_API_KEY=${{ secrets.NEXT_PUBLIC_MAINNET_ETHERSCAN_API_KEY }} >> packages/nextjs/.env.local + echo NEXT_PUBLIC_POLYGON_ETHERSCAN_API_KEY=${{ secrets.NEXT_PUBLIC_POLYGON_ETHERSCAN_API_KEY }} >> packages/nextjs/.env.local + echo NEXT_PUBLIC_ARBITRUM_ETHERSCAN_API_KEY=${{ secrets.NEXT_PUBLIC_ARBITRUM_ETHERSCAN_API_KEY }} >> packages/nextjs/.env.local + - name: Cypress run uses: cypress-io/github-action@v6 with: diff --git a/packages/nextjs/.env.example b/packages/nextjs/.env.example index 74956c51..daed05f3 100644 --- a/packages/nextjs/.env.example +++ b/packages/nextjs/.env.example @@ -12,11 +12,15 @@ NEXT_PUBLIC_MAINNET_ETHERSCAN_API_KEY= NEXT_PUBLIC_OPTIMISM_ETHERSCAN_API_KEY= +NEXT_PUBLIC_BASE_ETHERSCAN_API_KEY= +NEXT_PUBLIC_GNOSIS_ETHERSCAN_API_KEY= +NEXT_PUBLIC_ZKSYNC_ETHERSCAN_API_KEY= NEXT_PUBLIC_POLYGON_ETHERSCAN_API_KEY= NEXT_PUBLIC_ARBITRUM_ETHERSCAN_API_KEY= -NEXT_PUBLIC_ZKSYNC_ETHERSCAN_API_KEY= NEXT_PUBLIC_SCROLL_ETHERSCAN_API_KEY= -NEXT_PUBLIC_BASE_ETHERSCAN_API_KEY= +NEXT_PUBLIC_BSC_ETHERSCAN_API_KEY= + +NEXT_PUBLIC_HEIMDALL_URL= NEXT_PUBLIC_ALCHEMY_API_KEY= NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID= diff --git a/packages/nextjs/hooks/useFetchContractAbi.ts b/packages/nextjs/hooks/useFetchContractAbi.ts index 9c74f92e..4fa64a0b 100644 --- a/packages/nextjs/hooks/useFetchContractAbi.ts +++ b/packages/nextjs/hooks/useFetchContractAbi.ts @@ -1,55 +1,36 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { isAddress } from "viem"; -import { UsePublicClientReturnType } from "wagmi"; -import { fetchContractABIFromAnyABI, fetchContractABIFromEtherscan } from "~~/utils/abi"; -import { detectProxyTarget } from "~~/utils/abi-ninja/proxyContracts"; - -const ANYABI_TIMEOUT = 3000; +import { Address, isAddress } from "viem"; +import { fetchContractABIFromEtherscan } from "~~/utils/abi"; type FetchContractAbiParams = { contractAddress: string; chainId: number; - publicClient: UsePublicClientReturnType; disabled?: boolean; }; -const useFetchContractAbi = ({ contractAddress, chainId, publicClient, disabled = false }: FetchContractAbiParams) => { - const [implementationAddress, setImplementationAddress] = useState(null); +const useFetchContractAbi = ({ contractAddress, chainId, disabled = false }: FetchContractAbiParams) => { + const [implementationAddress, setImplementationAddress] = useState
(null); const fetchAbi = async () => { if (!isAddress(contractAddress)) { throw new Error("Invalid contract address"); } - let addressToUse: string = contractAddress; + const addressToUse: Address = contractAddress; try { - const implAddress = await detectProxyTarget(contractAddress, publicClient); - if (implAddress) { - setImplementationAddress(implAddress); - } - - addressToUse = implAddress || contractAddress; - - // Create a promise that resolves after ANYABI_TIMEOUT - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("AnyABI request timed out")), ANYABI_TIMEOUT); - }); + const { abi, implementation } = await fetchContractABIFromEtherscan(addressToUse, chainId); - // Race between the AnyABI fetch and the timeout - const abi = await Promise.race([fetchContractABIFromAnyABI(addressToUse, chainId), timeoutPromise]); + if (!abi) throw new Error("Got empty or undefined ABI from Etherscan"); - if (!abi) throw new Error("Got empty or undefined ABI from AnyABI"); + if (implementation && implementation !== "0x0000000000000000000000000000000000000000") { + setImplementationAddress(implementation); + } return { abi, address: addressToUse }; } catch (error) { - console.error("Error or timeout fetching ABI from AnyABI: ", error); - console.log("Falling back to Etherscan..."); - - const abiString = await fetchContractABIFromEtherscan(addressToUse, chainId); - const parsedAbi = JSON.parse(abiString); - - return { abi: parsedAbi, address: contractAddress }; + console.error("Error fetching ABI from Etherscan: ", error); + throw error; } }; diff --git a/packages/nextjs/pages/[contractAddress]/[network].tsx b/packages/nextjs/pages/[contractAddress]/[network].tsx index 6bd30a19..8802e0e4 100644 --- a/packages/nextjs/pages/[contractAddress]/[network].tsx +++ b/packages/nextjs/pages/[contractAddress]/[network].tsx @@ -4,7 +4,6 @@ import { useRouter } from "next/router"; import { GetServerSideProps } from "next"; import { ParsedUrlQuery } from "querystring"; import { Abi, Address, isAddress } from "viem"; -import { usePublicClient } from "wagmi"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { MetaHeader } from "~~/components/MetaHeader"; import { MiniHeader } from "~~/components/MiniHeader"; @@ -72,10 +71,6 @@ const ContractDetailPage = ({ addressFromUrl, chainIdFromUrl }: ServerSideProps) chains: state.chains, })); - const publicClient = usePublicClient({ - chainId: parseInt(network), - }); - const getNetworkName = (chainId: number) => { const chain = Object.values(chains).find(chain => chain.id === chainId); return chain ? chain.name : "Unknown Network"; @@ -89,7 +84,6 @@ const ContractDetailPage = ({ addressFromUrl, chainIdFromUrl }: ServerSideProps) } = useFetchContractAbi({ contractAddress, chainId: parseInt(network), - publicClient, disabled: contractAbi.length > 0, }); diff --git a/packages/nextjs/pages/index.tsx b/packages/nextjs/pages/index.tsx index 48bd960d..7c4f449d 100644 --- a/packages/nextjs/pages/index.tsx +++ b/packages/nextjs/pages/index.tsx @@ -49,7 +49,7 @@ const Home: NextPage = () => { error, isLoading: isFetchingAbi, implementationAddress, - } = useFetchContractAbi({ contractAddress: verifiedContractAddress, chainId: parseInt(network), publicClient }); + } = useFetchContractAbi({ contractAddress: verifiedContractAddress, chainId: parseInt(network) }); const isAbiAvailable = contractData?.abi && contractData.abi.length > 0; diff --git a/packages/nextjs/utils/abi-ninja/proxyContracts.ts b/packages/nextjs/utils/abi-ninja/proxyContracts.ts deleted file mode 100644 index 79306924..00000000 --- a/packages/nextjs/utils/abi-ninja/proxyContracts.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Address } from "viem"; -import { UsePublicClientReturnType } from "wagmi"; - -const EIP_1967_LOGIC_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" as const; -const EIP_1967_BEACON_SLOT = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50" as const; -const OPEN_ZEPPELIN_IMPLEMENTATION_SLOT = "0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3" as const; -const EIP_1822_LOGIC_SLOT = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7" as const; -const EIP_1167_BEACON_METHODS = [ - "0x5c60da1b00000000000000000000000000000000000000000000000000000000", - "0xda52571600000000000000000000000000000000000000000000000000000000", -] as const; -const EIP_897_INTERFACE = ["0x5c60da1b00000000000000000000000000000000000000000000000000000000"] as const; -const GNOSIS_SAFE_PROXY_INTERFACE = ["0xa619486e00000000000000000000000000000000000000000000000000000000"] as const; -const COMPTROLLER_PROXY_INTERFACE = ["0xbb82aa5e00000000000000000000000000000000000000000000000000000000"] as const; - -const readAddress = (value: string | undefined): Address => { - if (typeof value !== "string" || value === "0x") { - throw new Error(`Invalid address value: ${value}`); - } - const address = value.length === 66 ? "0x" + value.slice(-40) : value; - const zeroAddress = "0x" + "0".repeat(40); - if (address === zeroAddress) { - throw new Error("Empty address"); - } - return address as Address; -}; - -const EIP_1167_BYTECODE_PREFIX = "0x363d3d373d3d3d363d"; -const EIP_1167_BYTECODE_SUFFIX = "57fd5bf3"; - -export const parse1167Bytecode = (bytecode: unknown): Address => { - if (typeof bytecode !== "string" || !bytecode.startsWith(EIP_1167_BYTECODE_PREFIX)) { - throw new Error("Not an EIP-1167 bytecode"); - } - - // detect length of address (20 bytes non-optimized, 0 < N < 20 bytes for vanity addresses) - const pushNHex = bytecode.substring(EIP_1167_BYTECODE_PREFIX.length, EIP_1167_BYTECODE_PREFIX.length + 2); - // push1 ... push20 use opcodes 0x60 ... 0x73 - const addressLength = parseInt(pushNHex, 16) - 0x5f; - - if (addressLength < 1 || addressLength > 20) { - throw new Error("Not an EIP-1167 bytecode"); - } - - const addressFromBytecode = bytecode.substring( - EIP_1167_BYTECODE_PREFIX.length + 2, - EIP_1167_BYTECODE_PREFIX.length + 2 + addressLength * 2, // address length is in bytes, 2 hex chars make up 1 byte - ); - - const SUFFIX_OFFSET_FROM_ADDRESS_END = 22; - if ( - !bytecode - .substring(EIP_1167_BYTECODE_PREFIX.length + 2 + addressLength * 2 + SUFFIX_OFFSET_FROM_ADDRESS_END) - .startsWith(EIP_1167_BYTECODE_SUFFIX) - ) { - throw new Error("Not an EIP-1167 bytecode"); - } - - // padStart is needed for vanity addresses - return `0x${addressFromBytecode.padStart(40, "0")}` as Address; -}; - -export const detectProxyTarget = async (proxyAddress: Address, client: UsePublicClientReturnType) => { - if (!client) { - console.error("No client provided"); - return; - } - const detectUsingBytecode = async () => { - const bytecode = await client.getBytecode({ address: proxyAddress }); - return parse1167Bytecode(bytecode); - }; - - const detectUsingEIP1967LogicSlot = async () => { - const logicAddress = await client.getStorageAt({ address: proxyAddress, slot: EIP_1967_LOGIC_SLOT }); - return readAddress(logicAddress); - }; - - const detectUsingEIP1967BeaconSlot = async () => { - const beaconAddress = await client.getStorageAt({ address: proxyAddress, slot: EIP_1967_BEACON_SLOT }); - const resolvedBeaconAddress = readAddress(beaconAddress); - for (const method of EIP_1167_BEACON_METHODS) { - try { - const data = await client.call({ data: method as `0x${string}`, to: resolvedBeaconAddress }); - return readAddress(data.data); - } catch { - // Ignore individual beacon method call failures - } - } - throw new Error("Beacon method calls failed"); - }; - - const detectUsingOpenZeppelinSlot = async () => { - const implementationAddr = await client.getStorageAt({ - address: proxyAddress, - slot: OPEN_ZEPPELIN_IMPLEMENTATION_SLOT, - }); - const resolvedAddress = readAddress(implementationAddr); - return resolvedAddress; - }; - - const detectionMethods = [ - detectUsingBytecode, - detectUsingEIP1967LogicSlot, - detectUsingEIP1967BeaconSlot, - detectUsingOpenZeppelinSlot, - ]; - - try { - return await Promise.any(detectionMethods.map(method => method())); - } catch (primaryError) { - const detectUsingEIP1822LogicSlot = async () => { - const logicAddress = await client.getStorageAt({ address: proxyAddress, slot: EIP_1822_LOGIC_SLOT }); - return readAddress(logicAddress); - }; - - const detectUsingInterfaceCalls = async (data: `0x${string}`) => { - const { data: resultData } = await client.call({ data, to: proxyAddress }); - return readAddress(resultData); - }; - - const nextDetectionMethods = [ - detectUsingEIP1822LogicSlot, - () => detectUsingInterfaceCalls(EIP_897_INTERFACE[0]), - () => detectUsingInterfaceCalls(GNOSIS_SAFE_PROXY_INTERFACE[0]), - () => detectUsingInterfaceCalls(COMPTROLLER_PROXY_INTERFACE[0]), - ]; - - try { - return await Promise.any(nextDetectionMethods.map(method => method())); - } catch (finalError) { - console.error("All detection methods failed:", finalError); - return null; - } - } -}; diff --git a/packages/nextjs/utils/abi.ts b/packages/nextjs/utils/abi.ts index 610ec645..f2d36a97 100644 --- a/packages/nextjs/utils/abi.ts +++ b/packages/nextjs/utils/abi.ts @@ -1,41 +1,77 @@ -import { NETWORKS_EXTRA_DATA, getTargetNetworks } from "./scaffold-eth"; +import { NETWORKS_EXTRA_DATA } from "./scaffold-eth"; +import { isZeroAddress } from "./scaffold-eth/common"; +import { Address } from "viem"; +import * as chains from "viem/chains"; -export const fetchContractABIFromAnyABI = async (verifiedContractAddress: string, chainId: number) => { - const chain = getTargetNetworks().find(network => network.id === chainId); +const findChainById = (chainId: number): chains.Chain => { + const chainEntries = Object.entries(chains as Record); - if (!chain) throw new Error(`ChainId ${chainId} not found in supported networks`); + for (const [, chain] of chainEntries) { + if (chain.id === chainId) { + return chain; + } + } + + throw new Error(`No chain found with ID ${chainId}`); +}; - const url = `https://anyabi.xyz/api/get-abi/${chainId}/${verifiedContractAddress}`; +const getEtherscanApiKey = (chainId: number): string => { + const networkData = NETWORKS_EXTRA_DATA[chainId]; - const response = await fetch(url); - const data = await response.json(); - if (data.abi) { - return data.abi; - } else { - console.error("Could not fetch ABI from AnyABI:", data.error); - return; + if (!networkData || !networkData.etherscanApiKey) { + console.warn(`No API key found for chain ID ${chainId}`); + return ""; } + + return networkData.etherscanApiKey; }; -export const fetchContractABIFromEtherscan = async (verifiedContractAddress: string, chainId: number) => { - const chain = NETWORKS_EXTRA_DATA[chainId]; +export const fetchContractABIFromEtherscan = async (verifiedContractAddress: Address, chainId: number) => { + const chain = findChainById(chainId); - if (!chain || !chain.etherscanEndpoint) - throw new Error(`ChainId ${chainId} not found in supported etherscan networks`); + if (!chain || !chain.blockExplorers?.default?.apiUrl) { + throw new Error(`ChainId ${chainId} not found in supported networks or missing block explorer API URL`); + } - const apiKey = chain.etherscanApiKey ?? ""; + const apiKey = getEtherscanApiKey(chainId); const apiKeyUrlParam = apiKey.trim().length > 0 ? `&apikey=${apiKey}` : ""; - const url = `${chain.etherscanEndpoint}/api?module=contract&action=getabi&address=${verifiedContractAddress}${apiKeyUrlParam}`; - - const response = await fetch(url); - const data = await response.json(); - if (data.status === "1") { - return data.result; - } else { - console.error("Got non-1 status from Etherscan API", data); - if (data.result) throw new Error(data.result); - throw new Error("Got non-1 status from Etherscan API"); + + // First call to get source code and check for implementation + const sourceCodeUrl = `${chain.blockExplorers.default.apiUrl}?module=contract&action=getsourcecode&address=${verifiedContractAddress}${apiKeyUrlParam}`; + + const sourceCodeResponse = await fetch(sourceCodeUrl); + const sourceCodeData = await sourceCodeResponse.json(); + + if (sourceCodeData.status !== "1" || !sourceCodeData.result || sourceCodeData.result.length === 0) { + console.error("Error fetching source code from Etherscan:", sourceCodeData); + throw new Error("Failed to fetch source code from Etherscan"); + } + + const contractData = sourceCodeData.result[0]; + const implementation = contractData.Implementation || null; + + // If there's an implementation address, make a second call to get its ABI + if (implementation && !isZeroAddress(implementation)) { + const abiUrl = `${chain.blockExplorers.default.apiUrl}?module=contract&action=getabi&address=${implementation}${apiKeyUrlParam}`; + const abiResponse = await fetch(abiUrl); + const abiData = await abiResponse.json(); + + if (abiData.status === "1" && abiData.result) { + return { + abi: JSON.parse(abiData.result), + implementation, + }; + } else { + console.error("Error fetching ABI for implementation from Etherscan:", abiData); + throw new Error("Failed to fetch ABI for implementation from Etherscan"); + } } + + // If no implementation or failed to get implementation ABI, return original contract ABI + return { + abi: JSON.parse(contractData.ABI), + implementation, + }; }; export function parseAndCorrectJSON(input: string): any { diff --git a/packages/nextjs/utils/scaffold-eth/common.ts b/packages/nextjs/utils/scaffold-eth/common.ts index 22d031a3..967167b6 100644 --- a/packages/nextjs/utils/scaffold-eth/common.ts +++ b/packages/nextjs/utils/scaffold-eth/common.ts @@ -1,3 +1,7 @@ // To be used in JSON.stringify when a field might be bigint // https://wagmi.sh/react/faq#bigint-serialization export const replacer = (_key: string, value: unknown) => (typeof value === "bigint" ? value.toString() : value); + +export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +export const isZeroAddress = (address: string) => address === ZERO_ADDRESS; diff --git a/packages/nextjs/utils/scaffold-eth/networks.ts b/packages/nextjs/utils/scaffold-eth/networks.ts index 277f1eae..272ae9bf 100644 --- a/packages/nextjs/utils/scaffold-eth/networks.ts +++ b/packages/nextjs/utils/scaffold-eth/networks.ts @@ -51,6 +51,7 @@ const ARBITRUM_ETHERSCAN_API_KEY = process.env.NEXT_PUBLIC_ARBITRUM_ETHERSCAN_AP const ZKSYNC_ETHERSCAN_API_KEY = process.env.NEXT_PUBLIC_ZKSYNC_ETHERSCAN_API_KEY || ""; const BASE_ETHERSCAN_API_KEY = process.env.NEXT_PUBLIC_BASE_ETHERSCAN_API_KEY || ""; const SCROLL_ETHERSCAN_API_KEY = process.env.NEXT_PUBLIC_SCROLL_ETHERSCAN_API_KEY || ""; +const BSC_ETHERSCAN_API_KEY = process.env.NEXT_PUBLIC_BSC_ETHERSCAN_API_KEY || ""; export const NETWORKS_EXTRA_DATA: Record = { [chains.hardhat.id]: { @@ -137,6 +138,12 @@ export const NETWORKS_EXTRA_DATA: Record = { etherscanApiKey: SCROLL_ETHERSCAN_API_KEY, icon: "/scroll.svg", }, + [chains.bsc.id]: { + color: "#f0b90b", + etherscanEndpoint: "https://api.bscscan.com", + etherscanApiKey: BSC_ETHERSCAN_API_KEY, + icon: "/bsc.svg", + }, }; /**