From 04c78c8cc1e0b5c966f0185cedab0dab8589e5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= <4456749@users.noreply.github.com> Date: Thu, 14 Mar 2024 18:35:04 +0100 Subject: [PATCH 1/5] Detecting and transparently resolving proxy contracts --- hooks/useAbi.ts | 30 ++++++++++++++++---- utils/proxies.ts | 73 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 utils/proxies.ts diff --git a/hooks/useAbi.ts b/hooks/useAbi.ts index 67bec483..a9eb5bf3 100644 --- a/hooks/useAbi.ts +++ b/hooks/useAbi.ts @@ -6,19 +6,39 @@ import { useQuery } from "@tanstack/react-query"; import { isAddress } from "@/utils/evm"; import { PUB_CHAIN, PUB_ETHERSCAN_API_KEY } from "@/constants"; import { useAlerts } from "@/context/Alerts"; +import { getImplementation, isProxyContract } from "@/utils/proxies"; export const useAbi = (contractAddress: Address) => { const { addAlert } = useAlerts(); const publicClient = usePublicClient({ chainId: PUB_CHAIN.id }); + const { data: implementationAddress, isLoading: isLoadingImpl } = + useQuery
({ + queryKey: [contractAddress, !!publicClient], + queryFn: () => { + if (!contractAddress || !publicClient) return null; + + return isProxyContract(publicClient, contractAddress) + .then((isProxy) => { + if (!isProxy) return null; + return getImplementation(publicClient, contractAddress); + }) + .catch(() => null); + }, + }); + + const resolvedAddress = isAddress(implementationAddress) + ? implementationAddress + : contractAddress; + const { data: abi, isLoading, error, } = useQuery({ - queryKey: [contractAddress || "", !!publicClient], + queryKey: [resolvedAddress || "", !!publicClient], queryFn: () => { - if (!contractAddress || !isAddress(contractAddress) || !publicClient) { + if (!resolvedAddress || !isAddress(resolvedAddress) || !publicClient) { return Promise.resolve([]); } @@ -27,10 +47,10 @@ export const useAbi = (contractAddress: Address) => { }); return whatsabi - .autoload(contractAddress!, { + .autoload(resolvedAddress, { provider: publicClient, abiLoader, - followProxies: true, + followProxies: false, enableExperimentalMetadata: true, }) .then(({ abi }) => { @@ -76,7 +96,7 @@ export const useAbi = (contractAddress: Address) => { return { abi: abi ?? [], - isLoading, + isLoading: isLoading || isLoadingImpl, error, }; }; diff --git a/utils/proxies.ts b/utils/proxies.ts new file mode 100644 index 00000000..1455b160 --- /dev/null +++ b/utils/proxies.ts @@ -0,0 +1,73 @@ +import { + Address, + BaseError, + ContractFunctionRevertedError, + Hex, + PublicClient, + keccak256, + parseAbi, + toHex, +} from "viem"; +import { PUB_CHAIN } from "@/constants"; + +const proxyAbi1 = parseAbi([ + "function implementation() external view returns (address)", + "function proxiableUUID() external view returns (bytes32)", +]); +const STORAGE_SLOTS = [ + toEip1967Hash("eip1967.proxy.implementation"), + toFallbackEip1967Hash("org.zeppelinos.proxy.implementation"), +]; + +export function isProxyContract( + publicClient: PublicClient, + contractAddress: Address +) { + return publicClient + .simulateContract({ + address: contractAddress, + abi: proxyAbi1, + functionName: "implementation", + args: [], + chain: PUB_CHAIN, + }) + .then(() => true) + .catch((e: any) => { + if (!(e instanceof BaseError)) return false; + else if (!(e.cause instanceof ContractFunctionRevertedError)) + return false; + return true; + }); +} + +export async function getImplementation( + publicClient: PublicClient, + proxyAddress: Address +): Promise
{ + for (const slot of STORAGE_SLOTS) { + const res = await publicClient.getStorageAt({ + address: proxyAddress, + slot: slot as Hex, + }); + + if (!res) continue; + return ("0x" + res.replace(/^0x000000000000000000000000/, "")) as Address; + } + throw new Error("The contract does not appear to be a proxy"); +} + +// Helpers + +function toEip1967Hash(label: string): string { + const hash = keccak256(toHex(label)); + const bigNumber = BigInt(hash) - BigInt(1); + let hexResult = bigNumber.toString(16); + if (hexResult.length < 64) { + hexResult = "0".repeat(64 - hexResult.length) + hexResult; + } + return "0x" + hexResult; +} + +function toFallbackEip1967Hash(label: string): string { + return keccak256(toHex(label)); +} From 40b4b88c4cdd663fc0f18d25814a5d9637d14b49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= <4456749@users.noreply.github.com> Date: Thu, 14 Mar 2024 18:45:28 +0100 Subject: [PATCH 2/5] Handling snake case as well --- utils/case.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/case.ts b/utils/case.ts index 4c71170f..fdf5e2ca 100644 --- a/utils/case.ts +++ b/utils/case.ts @@ -1,7 +1,7 @@ export function decodeCamelCase(input?: string): string { if (!input || typeof input !== "string") return ""; - if (input.startsWith("_")) input = input.replace(/^_+/, ""); + input = input.replace(/_+/g, " ").trim(); return ( input .replace(/([a-z])([A-Z])/g, "$1 $2") From c2eed59d8688a60b424552faf6fa986176899878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= <4456749@users.noreply.github.com> Date: Fri, 15 Mar 2024 17:27:24 +0100 Subject: [PATCH 3/5] ABI resolution works on other chains + Etherscan --- components/input/function-selector.tsx | 39 ++++++++---- components/input/input-parameter-array.tsx | 2 +- .../input/input-parameter-tuple-array.tsx | 2 +- constants.ts | 2 +- hooks/useAbi.ts | 63 ++++++++++++++----- hooks/useMetadata.ts | 2 +- pages/globals.css | 12 ++++ utils/chains.ts | 23 +++++-- 8 files changed, 112 insertions(+), 33 deletions(-) diff --git a/components/input/function-selector.tsx b/components/input/function-selector.tsx index 3c4f1609..5faf959f 100644 --- a/components/input/function-selector.tsx +++ b/components/input/function-selector.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Hex, encodeFunctionData } from "viem"; -import { Button, InputText } from "@aragon/ods"; +import { AlertInline, Button, InputText } from "@aragon/ods"; import { AbiFunction } from "abitype"; import { Else, If, Then } from "@/components/if"; import { decodeCamelCase } from "@/utils/case"; @@ -91,7 +91,14 @@ export const FunctionSelector = ({ onClick={() => setSelectedAbiItem(fn)} className={`w-full text-left font-sm hover:bg-neutral-100 py-2 px-3 rounded-xl hover:cursor-pointer ${fn.name === selectedAbiItem?.name && "bg-neutral-100 font-semibold"}`} > - {decodeCamelCase(fn.name)} + + {decodeCamelCase(fn.name)} + + {decodeCamelCase(fn.name)} +
+ (read only) +
+
))} @@ -102,21 +109,31 @@ export const FunctionSelector = ({
-

+

{decodeCamelCase(selectedAbiItem?.name)}

-
-
- {/* Make titles smaller */} - + +
+ +
+
{selectedAbiItem?.inputs.map((paramAbi, i) => (
{value.map((item, i) => ( -
+
0 ? "mt-3" : ""} addon={(i + 1).toString()} diff --git a/components/input/input-parameter-tuple-array.tsx b/components/input/input-parameter-tuple-array.tsx index f636808e..358f3e7e 100644 --- a/components/input/input-parameter-tuple-array.tsx +++ b/components/input/input-parameter-tuple-array.tsx @@ -49,7 +49,7 @@ export const InputParameterTupleArray = ({ {values.map((_, i) => ( -
+

{abi.name diff --git a/constants.ts b/constants.ts index 05c6c49c..1cf3dd8a 100644 --- a/constants.ts +++ b/constants.ts @@ -22,7 +22,7 @@ export const PUB_DELEGATION_ANNOUNCEMENTS_START_BLOCK = BigInt( // Target chain export const PUB_CHAIN_NAME = (process.env.NEXT_PUBLIC_CHAIN_NAME ?? - "goerli") as ChainName; + "sepolia") as ChainName; export const PUB_CHAIN = getChain(PUB_CHAIN_NAME); // Network and services diff --git a/hooks/useAbi.ts b/hooks/useAbi.ts index a9eb5bf3..86d9028f 100644 --- a/hooks/useAbi.ts +++ b/hooks/useAbi.ts @@ -7,6 +7,9 @@ import { isAddress } from "@/utils/evm"; import { PUB_CHAIN, PUB_ETHERSCAN_API_KEY } from "@/constants"; import { useAlerts } from "@/context/Alerts"; import { getImplementation, isProxyContract } from "@/utils/proxies"; +import { ChainName } from "@/utils/chains"; + +const CHAIN_NAME = PUB_CHAIN.name.toLowerCase() as ChainName; export const useAbi = (contractAddress: Address) => { const { addAlert } = useAlerts(); @@ -14,7 +17,7 @@ export const useAbi = (contractAddress: Address) => { const { data: implementationAddress, isLoading: isLoadingImpl } = useQuery

({ - queryKey: [contractAddress, !!publicClient], + queryKey: ["proxy-check", contractAddress, !!publicClient], queryFn: () => { if (!contractAddress || !publicClient) return null; @@ -25,6 +28,11 @@ export const useAbi = (contractAddress: Address) => { }) .catch(() => null); }, + retry: 4, + refetchOnMount: false, + refetchOnReconnect: false, + retryOnMount: true, + staleTime: Infinity, }); const resolvedAddress = isAddress(implementationAddress) @@ -36,16 +44,13 @@ export const useAbi = (contractAddress: Address) => { isLoading, error, } = useQuery({ - queryKey: [resolvedAddress || "", !!publicClient], + queryKey: ["abi", resolvedAddress || "", !!publicClient], queryFn: () => { if (!resolvedAddress || !isAddress(resolvedAddress) || !publicClient) { return Promise.resolve([]); } - const abiLoader = new whatsabi.loaders.EtherscanABILoader({ - apiKey: PUB_ETHERSCAN_API_KEY, - }); - + const abiLoader = getEtherscanAbiLoader(); return whatsabi .autoload(resolvedAddress, { provider: publicClient, @@ -62,18 +67,17 @@ export const useAbi = (contractAddress: Address) => { name: (item as any).name ?? "(function)", inputs: item.inputs ?? [], outputs: item.outputs ?? [], - stateMutability: item.stateMutability ?? "payable", + stateMutability: item.stateMutability || "payable", type: item.type, }); } functionItems.sort((a, b) => { - if ( - ["pure", "view"].includes(a.stateMutability) && - ["pure", "view"].includes(b.stateMutability) - ) { - return 0; - } else if (["pure", "view"].includes(a.stateMutability)) return 1; - else if (["pure", "view"].includes(b.stateMutability)) return -1; + const a_RO = ["pure", "view"].includes(a.stateMutability); + const b_RO = ["pure", "view"].includes(b.stateMutability); + + if (a_RO === b_RO) return 0; + else if (a_RO) return 1; + else if (b_RO) return -1; return 0; }); return functionItems; @@ -100,3 +104,34 @@ export const useAbi = (contractAddress: Address) => { error, }; }; + +function getEtherscanAbiLoader() { + switch (CHAIN_NAME) { + case "mainnet": + return new whatsabi.loaders.EtherscanABILoader({ + apiKey: PUB_ETHERSCAN_API_KEY, + }); + case "polygon": + return new whatsabi.loaders.EtherscanABILoader({ + apiKey: PUB_ETHERSCAN_API_KEY, + baseURL: "https://api.polygonscan.com/api", + }); + case "arbitrum": + return new whatsabi.loaders.EtherscanABILoader({ + apiKey: PUB_ETHERSCAN_API_KEY, + baseURL: "https://api.arbiscan.io/api", + }); + case "sepolia": + return new whatsabi.loaders.EtherscanABILoader({ + apiKey: PUB_ETHERSCAN_API_KEY, + baseURL: "https://api-sepolia.etherscan.io/api", + }); + case "mumbai": + return new whatsabi.loaders.EtherscanABILoader({ + apiKey: PUB_ETHERSCAN_API_KEY, + baseURL: "https://api-mumbai.polygonscan.com/api", + }); + default: + throw new Error("Unknown chain"); + } +} diff --git a/hooks/useMetadata.ts b/hooks/useMetadata.ts index 5c95f6ef..3f06da9b 100644 --- a/hooks/useMetadata.ts +++ b/hooks/useMetadata.ts @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; export function useMetadata(ipfsUri?: string) { const { data, isLoading, isSuccess, error } = useQuery({ - queryKey: [ipfsUri || ""], + queryKey: ["ipfs", ipfsUri || ""], queryFn: () => { if (!ipfsUri) return Promise.resolve(""); diff --git a/pages/globals.css b/pages/globals.css index c4e68adc..16df5bfc 100644 --- a/pages/globals.css +++ b/pages/globals.css @@ -43,6 +43,18 @@ ol { color: var(--ods-color-neutral-600); } +/* Inline alerts */ +div.inline-flex.items-center.gap-x-2.rounded p { + font-size: 14px !important; + font-weight: 400; + opacity: 0.8; +} + +/* Smaller titles */ +label div p.leading-tight { + font-size: 1rem; +} + /* Dark mode snippet */ /* @media (prefers-color-scheme: dark) { diff --git a/utils/chains.ts b/utils/chains.ts index ab7266bb..7a988f46 100644 --- a/utils/chains.ts +++ b/utils/chains.ts @@ -1,6 +1,19 @@ -import { polygon, mainnet, sepolia, goerli, Chain } from "@wagmi/core/chains"; +import { + polygon, + mainnet, + sepolia, + arbitrum, + polygonMumbai, + Chain, +} from "@wagmi/core/chains"; -const chainNames = ["mainnet", "polygon", "sepolia", "goerli"] as const; +const chainNames = [ + "mainnet", + "polygon", + "sepolia", + "mumbai", + "arbitrum", +] as const; export type ChainName = (typeof chainNames)[number]; export function getChain(chainName: ChainName): Chain { @@ -9,10 +22,12 @@ export function getChain(chainName: ChainName): Chain { return mainnet; case "polygon": return polygon; + case "arbitrum": + return arbitrum; case "sepolia": return sepolia; - case "goerli": - return goerli; + case "mumbai": + return polygonMumbai; default: throw new Error("Unknown chain"); } From 2ba6f36ee51596236dba903d93bb6633e652e7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= <4456749@users.noreply.github.com> Date: Fri, 15 Mar 2024 17:34:34 +0100 Subject: [PATCH 4/5] Minor consistency edits --- .env.example | 8 ++++---- .github/workflows/app-build.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index abb61a11..6f2e78eb 100644 --- a/.env.example +++ b/.env.example @@ -6,11 +6,11 @@ NEXT_PUBLIC_DELEGATION_ANNOUNCEMENTS_START_BLOCK=10541166 # per chain # Plugin addresses NEXT_PUBLIC_TOKEN_VOTING_PLUGIN_ADDRESS=0x1276a2f2F8Ea2172FAD053562C2fa05966111A42 NEXT_PUBLIC_DELEGATION_CONTRACT_ADDRESS=0xb3b899F190Af7f5FEE002f6f823743Ba80b2FfA1 -NEXT_PUBLIC_DUAL_GOVERNANCE_PLUGIN_ADDRESS=0xa6796635D194442b4FAC81904E60E380cFE3eDAE # Goerli +NEXT_PUBLIC_DUAL_GOVERNANCE_PLUGIN_ADDRESS=0xa6796635D194442b4FAC81904E60E380cFE3eDAE # Network and services -NEXT_PUBLIC_CHAIN_NAME=goerli -NEXT_PUBLIC_WEB3_URL_PREFIX=https://eth-goerli.g.alchemy.com/v2/ +NEXT_PUBLIC_CHAIN_NAME=sepolia +NEXT_PUBLIC_WEB3_URL_PREFIX=https://eth-sepolia.g.alchemy.com/v2/ NEXT_PUBLIC_ALCHEMY_API_KEY="ALCHEMY KEY" NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID="YOUR WALLET CONNECT PROJECT ID" @@ -19,7 +19,7 @@ NEXT_PUBLIC_IPFS_API_KEY="..." NEXT_PUBLIC_ETHERSCAN_API_KEY="OPTIONAL: ETHERSCAN API" # PRIVATE (scripts) -DEPLOYMENT_TARGET_CHAIN_ID=goerli +DEPLOYMENT_TARGET_CHAIN_ID=sepolia DEPLOYMENT_WALLET_PRIVATE_KEY="0x..." DEPLOYMENT_ALCHEMY_API_KEY="..." DEPLOYMENT_WEB3_ENDPOINT="https://..." diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml index cab43437..3eebb8ed 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/app-build.yml @@ -21,7 +21,7 @@ jobs: NEXT_PUBLIC_TOKEN_VOTING_PLUGIN_ADDRESS: "0x1234567890123456789012345678901234567890" NEXT_PUBLIC_DELEGATION_CONTRACT_ADDRESS: "0x1234567890123456789012345678901234567890" NEXT_PUBLIC_DUAL_GOVERNANCE_PLUGIN_ADDRESS: "0x1234567890123456789012345678901234567890" - NEXT_PUBLIC_CHAIN_NAME: goerli + NEXT_PUBLIC_CHAIN_NAME: sepolia NEXT_PUBLIC_WEB3_URL_PREFIX: https://rpc/ NEXT_PUBLIC_ALCHEMY_API_KEY: x NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: x From c61fe00d2e5e0a532508a9f70828f908c7e0c7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= <4456749@users.noreply.github.com> Date: Fri, 15 Mar 2024 18:05:08 +0100 Subject: [PATCH 5/5] Show read only functions, just in case --- components/input/function-selector.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/components/input/function-selector.tsx b/components/input/function-selector.tsx index 5faf959f..305dc66e 100644 --- a/components/input/function-selector.tsx +++ b/components/input/function-selector.tsx @@ -75,9 +75,7 @@ export const FunctionSelector = ({ }; const functionAbiList = (abi || []).filter( - (item) => - item.type === "function" && - !["pure", "view"].includes(item.stateMutability) + (item) => item.type === "function" ); return (