diff --git a/schema/rich-contracts.json b/schema/rich-contracts.json index 11aea2d49..22bb118d5 100644 --- a/schema/rich-contracts.json +++ b/schema/rich-contracts.json @@ -125,12 +125,11 @@ "$id": "#/properties/networks", "type": "object", "title": "The networks schema", - "description": "A mapping of chains to addresses, for factory created contracts use 0x0 for all chains", + "description": "A mapping of chains to addresses. For factory-created or other cloned contracts use the sha256-hashed bytecode of the contract instead.", "default": {}, "examples": [ { "mainnet": "0x0b17cf48420400e1D71F8231d4a8e43B3566BB5B", - "mainnet": "0x0", "rinkeby": "0x0b17cf48420400e1D71F8231d4a8e43B3566BB5B", "gnosis": "0x0b17cf48420400e1D71F8231d4a8e43B3566BB5B", "arbitrum": "0x0b17cf48420400e1D71F8231d4a8e43B3566BB5B", diff --git a/src/Modules/Guilds/Hooks/useProposalCalls.ts b/src/Modules/Guilds/Hooks/useProposalCalls.ts index a0eb834c8..e54a78475 100644 --- a/src/Modules/Guilds/Hooks/useProposalCalls.ts +++ b/src/Modules/Guilds/Hooks/useProposalCalls.ts @@ -10,7 +10,7 @@ import { ZERO_HASH } from 'utils'; import useProposalMetadata from 'hooks/Guilds/useProposalMetadata'; import { useRichContractRegistry } from 'hooks/Guilds/contracts/useRichContractRegistry'; import { ERC20_APPROVE_SIGNATURE } from 'utils'; -import { useNetwork } from 'wagmi'; +import { useNetwork, useProvider } from 'wagmi'; import { getBigNumberPercentage } from 'utils/bnPercentage'; import { EMPTY_CALL } from 'Modules/Guilds/pages/CreateProposal'; @@ -27,6 +27,7 @@ const useProposalCalls = (guildId: string, proposalId: string) => { const { contracts } = useRichContractRegistry(); const { chain } = useNetwork(); const { t } = useTranslation(); + const provider = useProvider(); const theme = useTheme(); const [options, setOptions] = useState([]); @@ -102,7 +103,8 @@ const useProposalCalls = (guildId: string, proposalId: string) => { const { decodedCall: decodedApprovalCall } = await decodeCall( call?.approvalCall, contracts, - chain?.id + chain?.id, + provider ); // Avoid spreading unnecesary approvalCall; const { approvalCall, ...newCall } = call; @@ -145,7 +147,12 @@ const useProposalCalls = (guildId: string, proposalId: string) => { }) ); - return bulkDecodeCallsFromOptions(encodedOptions, contracts, chain?.id); + return bulkDecodeCallsFromOptions( + encodedOptions, + contracts, + chain?.id, + provider + ); } decodeOptions().then(options => // Return options putting default against-call last @@ -162,6 +169,7 @@ const useProposalCalls = (guildId: string, proposalId: string) => { theme, optionLabels, totalOptionsNum, + provider, ]); return { diff --git a/src/components/ActionsModal/ActionsModal.tsx b/src/components/ActionsModal/ActionsModal.tsx index e9edab28d..3f9dd41ad 100644 --- a/src/components/ActionsModal/ActionsModal.tsx +++ b/src/components/ActionsModal/ActionsModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { BigNumber } from 'ethers'; +import { BigNumber, utils } from 'ethers'; import { useTranslation } from 'react-i18next'; import { useTypedParams } from 'Modules/Guilds/Hooks/useTypedParams'; @@ -107,9 +107,11 @@ const ActionModal: React.FC = ({ ); } + const isCloneBytecodeHash = !utils.isAddress(contractId); return ( { onAddAction({ @@ -118,7 +120,7 @@ const ActionModal: React.FC = ({ decodedCall: { callType: SupportedAction.GENERIC_CALL, from: guildId, - to: contractId, + to: args._clonedContractAddress || contractId, function: contractInterface.getFunction(selectedFunction), value: BigNumber.from(0), args, diff --git a/src/components/ActionsModal/components/ParamsForm/FormElementRenderer.tsx b/src/components/ActionsModal/components/ParamsForm/FormElementRenderer.tsx index b68fb83ba..a584e6b18 100644 --- a/src/components/ActionsModal/components/ParamsForm/FormElementRenderer.tsx +++ b/src/components/ActionsModal/components/ParamsForm/FormElementRenderer.tsx @@ -3,7 +3,6 @@ import { useMemo } from 'react'; import moment, { Moment } from 'moment'; import { RegisterOptions } from 'react-hook-form'; -import { RichContractFunctionParam } from 'hooks/Guilds/contracts/useRichContractRegistry'; import { isAddress } from 'utils'; import { AddressInput } from 'components/primitives/Forms/AddressInput'; import { FormElementProps } from 'components/primitives/Forms/types'; @@ -16,17 +15,17 @@ import { DurationInput } from 'components/primitives/Forms/DurationInput'; import { SwaprPicker } from 'components/SwaprPicker'; interface FormElementRendererProps extends FormElementProps { - param: RichContractFunctionParam; + formElement: string; } const FormElementRenderer: React.FC = ({ - param, + formElement, value, onChange, ...remainingProps }) => { const FormElement: React.FC> = useMemo(() => { - switch (param.component) { + switch (formElement) { case 'address': return AddressInput; case 'integer': @@ -47,10 +46,10 @@ const FormElementRenderer: React.FC = ({ default: return Input; } - }, [param]); + }, [formElement]); const props = useMemo(() => { - switch (param.component) { + switch (formElement) { case 'date': return { isUTC: true, @@ -70,7 +69,7 @@ const FormElementRenderer: React.FC = ({ default: return {}; } - }, [param, value, onChange]); + }, [formElement, value, onChange]); return ( ; -export const getDefaultValidationsByFormElement = ( - param: RichContractFunctionParam -) => { +export const getDefaultValidationsByFormElement = (formElement: string) => { const validations: Validations = { required: 'This field is required.' }; - switch (param.component) { + switch (formElement) { case 'address': validations.validate = (value: string) => !!isAddress(value) || 'Invalid address.'; diff --git a/src/components/ActionsModal/components/ParamsForm/ParamsForm.tsx b/src/components/ActionsModal/components/ParamsForm/ParamsForm.tsx index d90dbb7c0..04156e9c5 100644 --- a/src/components/ActionsModal/components/ParamsForm/ParamsForm.tsx +++ b/src/components/ActionsModal/components/ParamsForm/ParamsForm.tsx @@ -11,6 +11,8 @@ import { RichContractFunction } from 'hooks/Guilds/contracts/useRichContractRegi import FormElementRenderer, { getDefaultValidationsByFormElement, } from './FormElementRenderer'; +import { useProvider } from 'wagmi'; +import { enc, SHA256 } from 'crypto-js'; const SubmitButton = styled(ActionsButton).attrs(() => ({ variant: 'primary', @@ -21,20 +23,72 @@ const SubmitButton = styled(ActionsButton).attrs(() => ({ interface ParamsFormProps { fn: RichContractFunction; + contractBytecodeHash?: string; defaultValues?: Record; onSubmit: (args: Record) => void; } const ParamsForm: React.FC = ({ fn, + contractBytecodeHash, defaultValues, onSubmit, }) => { const { control, handleSubmit } = useForm(); + const provider = useProvider(); + const getDefaultValidationsForClonedContract = () => { + const validations = getDefaultValidationsByFormElement('address'); + + if (typeof validations.validate === 'function') { + validations.validate = { default: validations.validate }; + } + + validations.validate = { + ...validations.validate, + isValidClone: async value => { + const btcode = await provider.getCode(value); + if (btcode === '0x') return "Contract doesn't exist."; + + const hashedBytecode = `0x${SHA256(btcode).toString(enc.Hex)}`; + + return ( + hashedBytecode === contractBytecodeHash || + 'Contract is of wrong type.' + ); + }, + }; + + return validations; + }; + return (
+ {contractBytecodeHash && ( + + Contract address to call + ( + <> + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + + )} + {fn.params.map(param => ( {param.description} @@ -42,11 +96,11 @@ const ParamsForm: React.FC = ({ name={param.name} control={control} defaultValue={defaultValues?.[param.name] || param.defaultValue} - rules={getDefaultValidationsByFormElement(param)} + rules={getDefaultValidationsByFormElement(param.component)} render={({ field, fieldState }) => ( <> diff --git a/src/hooks/Guilds/contracts/useDecodedCall.test.ts b/src/hooks/Guilds/contracts/useDecodedCall.test.ts index 09d34d553..86fc5126a 100644 --- a/src/hooks/Guilds/contracts/useDecodedCall.test.ts +++ b/src/hooks/Guilds/contracts/useDecodedCall.test.ts @@ -18,6 +18,7 @@ jest.mock('./useRichContractRegistry', () => ({ jest.mock('wagmi', () => ({ useNetwork: () => ({ chain: mockChain, chains: [mockChain] }), + useProvider: () => ({ getCode: jest.fn() }), })); describe('useDecodedCall', () => { diff --git a/src/hooks/Guilds/contracts/useDecodedCall.ts b/src/hooks/Guilds/contracts/useDecodedCall.ts index a892b3ad3..925e21508 100644 --- a/src/hooks/Guilds/contracts/useDecodedCall.ts +++ b/src/hooks/Guilds/contracts/useDecodedCall.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { utils } from 'ethers'; -import { useNetwork } from 'wagmi'; +import { useNetwork, useProvider } from 'wagmi'; import { RichContractData, useRichContractRegistry, @@ -24,6 +24,8 @@ import { ENS_UPDATE_CONTENT_SIGNATURE, } from 'utils'; import { lookUpContractWithSourcify } from 'utils/sourcify'; +import { Provider } from '@wagmi/core'; +import { enc, SHA256 } from 'crypto-js'; const knownSigHashes: Record = { @@ -113,7 +115,8 @@ const getContractFromKnownSighashes = (data: string) => { export const decodeCall = async ( call: Call, contracts: RichContractData[], - chainId: number + chainId: number, + provider?: Provider ) => { let decodedCall: DecodedCall = null; @@ -136,11 +139,24 @@ export const decodeCall = async ( } // Detect using the rich contract data registry. - const matchedRichContractData = contracts?.find( + let matchedRichContractData = contracts?.find( contract => contract.networks[chainId].toLocaleLowerCase() === call.to.toLocaleLowerCase() ); + + // Try to detect clone contracts + if (!matchedRichContractData && provider) { + const btcode = await provider.getCode(call.to); + const hashedBytecode = `0x${SHA256(btcode).toString(enc.Hex)}`; + + matchedRichContractData = contracts?.find( + contract => + contract.networks[chainId].toLocaleLowerCase() === + hashedBytecode.toLocaleLowerCase() + ); + } + let matchedContract = matchedRichContractData ? getContractInterfaceFromRichContractData(matchedRichContractData) : getContractFromKnownSighashes(call.data); @@ -184,13 +200,14 @@ export const decodeCall = async ( export const bulkDecodeCallsFromOptions = ( options: Option[], contracts: RichContractData[], - chainId: number + chainId: number, + provider?: Provider ) => { return Promise.all( options.map(async option => { const { actions } = option; const actionPromisesArray = actions.map( - async action => await decodeCall(action, contracts, chainId) + async action => await decodeCall(action, contracts, chainId, provider) ); const decodedActions = await Promise.all(actionPromisesArray); return { @@ -213,10 +230,11 @@ export const useDecodedCall = (call: Call) => { const isCancelled = useRef(false); const { chain } = useNetwork(); const { contracts } = useRichContractRegistry(); + const provider = useProvider(); useEffect(() => { if (call && !isCancelled.current) { - decodeCall(call, contracts, chain?.id).then(decodedData => + decodeCall(call, contracts, chain?.id, provider).then(decodedData => setDecodedCall(decodedData) ); } else if (!call) { @@ -225,7 +243,7 @@ export const useDecodedCall = (call: Call) => { return () => { isCancelled.current = true; }; - }, [call, contracts, chain]); + }, [call, contracts, chain, provider]); return ( decodedCall || {