diff --git a/package-lock.json b/package-lock.json index 2a2c5d518b..5d094a20b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@emotion/styled": "^11.11.0", "@fuels/connectors": "^0.36.0", "@fuels/react": "^0.36.0", - "@guildxyz/types": "^1.10.43", + "@guildxyz/types": "^1.10.45", "@hcaptcha/react-hcaptcha": "^1.4.4", "@hookform/resolvers": "^3.3.4", "@lexical/code": "^0.12.0", @@ -5434,9 +5434,9 @@ } }, "node_modules/@guildxyz/types": { - "version": "1.10.43", - "resolved": "https://registry.npmjs.org/@guildxyz/types/-/types-1.10.43.tgz", - "integrity": "sha512-O7WfQlGqNqL49bi/yxNN2D10QmuIEyeckh/DsNDvvTctIKwA+mmQocpHQTUY72fqQGqkc8+juKsscIZScsu/tA==", + "version": "1.10.45", + "resolved": "https://registry.npmjs.org/@guildxyz/types/-/types-1.10.45.tgz", + "integrity": "sha512-3tZDh7dpfAAHxtruNkOHwtCr+PNNoYo1aG5+aX7vfoq+s5CR4PUKqfO9nnuKTDFmFci5pv+sciItTK+kcS0okg==", "license": "ISC", "dependencies": { "zod": "^3.22.4" diff --git a/package.json b/package.json index 91afe57d66..c6694d718b 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@emotion/styled": "^11.11.0", "@fuels/connectors": "^0.36.0", "@fuels/react": "^0.36.0", - "@guildxyz/types": "^1.10.43", + "@guildxyz/types": "^1.10.45", "@hcaptcha/react-hcaptcha": "^1.4.4", "@hookform/resolvers": "^3.3.4", "@lexical/code": "^0.12.0", diff --git a/src/requirements/WalletActivity/WalletActivityForm.tsx b/src/requirements/WalletActivity/WalletActivityForm.tsx index 99d820fbc6..a6bb9871ee 100644 --- a/src/requirements/WalletActivity/WalletActivityForm.tsx +++ b/src/requirements/WalletActivity/WalletActivityForm.tsx @@ -8,6 +8,8 @@ import { RequirementFormProps, RequirementType } from "requirements/types" import { SelectOption } from "types" import parseFromObject from "utils/parseFromObject" import { Chain } from "wagmiConfig/chains" +import CovalentContractCallCount from "./components/CovalentContractCallCount" +import CovalentContractCallCountRelative from "./components/CovalentContractCallCountRelative" import CovalentContractDeploy from "./components/CovalentContractDeploy" import CovalentContractDeployRelative from "./components/CovalentContractDeployRelative" import CovalentFirstTx from "./components/CovalentFirstTx" @@ -87,6 +89,16 @@ const walletActivityRequirementTypes: SelectOption[] = [ value: "COVALENT_TX_COUNT_RELATIVE", WalletActivityRequirement: CovalentTxCountRelative, }, + { + label: "Called a contract method", + value: "COVALENT_CONTRACT_CALL_COUNT", + WalletActivityRequirement: CovalentContractCallCount, + }, + { + label: "Called a contract method (relative)", + value: "COVALENT_CONTRACT_CALL_COUNT_RELATIVE", + WalletActivityRequirement: CovalentContractCallCountRelative, + }, ] const WalletActivityForm = ({ @@ -100,7 +112,6 @@ const WalletActivityForm = ({ } = useFormContext() const type = useWatch({ name: `${baseFieldPath}.type` }) - const chain = useWatch({ name: `${baseFieldPath}.chain` }) const isEditMode = !!field?.id const supportedRequirementTypes = walletActivityRequirementTypes @@ -156,10 +167,20 @@ const WalletActivityForm = ({ const resetFields = () => { resetField(`${baseFieldPath}.address`, { defaultValue: "" }) - resetField(`${baseFieldPath}.data.timestamps.minAmount`, { defaultValue: "" }) - resetField(`${baseFieldPath}.data.timestamps.maxAmount`, { defaultValue: "" }) - resetField(`${baseFieldPath}.data.txCount`, { defaultValue: "" }) resetField(`${baseFieldPath}.data.txValue`, { defaultValue: "" }) + + resetField(`${baseFieldPath}.data.method`, { + defaultValue: "", + }) + resetField(`${baseFieldPath}.data.inputs`, { + defaultValue: [], + }) + resetField(`${baseFieldPath}.data.txCount`, { + defaultValue: 1, + }) + resetField(`${baseFieldPath}.data.timestamps`, { + defaultValue: {}, + }) } const options = walletActivityRequirementTypes.filter((el) => @@ -168,30 +189,35 @@ const WalletActivityForm = ({ return ( - - - {chain && ( + + Type + + + + + {parseFromObject(errors, baseFieldPath)?.type?.message} + + + + {selected && ( <> - - Type - - - - - {parseFromObject(errors, baseFieldPath)?.type?.message} - - + {selected?.WalletActivityRequirement && ( diff --git a/src/requirements/WalletActivity/WalletActivityRequirement.tsx b/src/requirements/WalletActivity/WalletActivityRequirement.tsx index bd0e24acaf..6ebabaff58 100644 --- a/src/requirements/WalletActivity/WalletActivityRequirement.tsx +++ b/src/requirements/WalletActivity/WalletActivityRequirement.tsx @@ -1,8 +1,18 @@ +import { anchorVariants } from "@/components/ui/Anchor" +import { Button } from "@/components/ui/Button" +import { + Popover, + PopoverContent, + PopoverPortal, + PopoverTrigger, +} from "@/components/ui/Popover" import { IconProps } from "@phosphor-icons/react/dist/lib/types" import { + ArrowSquareOut, ArrowsLeftRight, Coins, FileText, + Function, Wallet, } from "@phosphor-icons/react/dist/ssr" import { BeforeAfterDates } from "components/[guild]/Requirements/components/DataBlockWithDate" @@ -15,6 +25,7 @@ import { useRequirementContext } from "components/[guild]/Requirements/component import { DataBlock } from "components/common/DataBlock" import { DataBlockWithCopy } from "components/common/DataBlockWithCopy" import { ForwardRefExoticComponent, RefAttributes } from "react" +import { Requirement as RequirementType } from "types" import formatRelativeTimeFromNow from "utils/formatRelativeTimeFromNow" import pluralize from "utils/pluralize" import shortenHex from "utils/shortenHex" @@ -31,6 +42,8 @@ const requirementIcons: Record< COVALENT_TX_COUNT_RELATIVE: ArrowsLeftRight, COVALENT_TX_VALUE: Coins, COVALENT_TX_VALUE_RELATIVE: Coins, + COVALENT_CONTRACT_CALL_COUNT: Function, + COVALENT_CONTRACT_CALL_COUNT_RELATIVE: Function, } type CovalentRequirementType = @@ -42,6 +55,8 @@ type CovalentRequirementType = | "COVALENT_TX_COUNT_RELATIVE" | "COVALENT_TX_VALUE" | "COVALENT_TX_VALUE_RELATIVE" + | "COVALENT_CONTRACT_CALL_COUNT" + | "COVALENT_CONTRACT_CALL_COUNT_RELATIVE" const WalletActivityRequirement = (props: RequirementProps): JSX.Element => { const requirement = useRequirementContext() @@ -207,6 +222,107 @@ const WalletActivityRequirement = (props: RequirementProps): JSX.Element => { ) } + case "COVALENT_CONTRACT_CALL_COUNT": + case "COVALENT_CONTRACT_CALL_COUNT_RELATIVE": { + const formattedMinAmount = formatRelativeTimeFromNow( + reqData.timestamps.minAmount + ) + + const formattedMaxAmount = formatRelativeTimeFromNow( + reqData.timestamps.maxAmount + ) + + const req = requirement as Extract< + RequirementType, + { + type: + | "COVALENT_CONTRACT_CALL_COUNT" + | "COVALENT_CONTRACT_CALL_COUNT_RELATIVE" + } + > + return ( + <> + {`Call the `} + + {shortenHex(req.address, 3)} + + {" contract's "} + {req.data.method} + {" method"} + + {req.data.txCount > 1 && {` ${req.data.txCount} times`}} + + {req.data.inputs.length > 0 && ( + <> + {" with "} + + + + + + + +
+ Inputs +
+ + + + + + + + + + + {req.data.inputs?.map((input) => ( + + + + + + ))} + +
Input paramOperationValue
{`${input.index + 1}. param`}{input.operator}{input.value}
+
+
+
+ + )} + + {req.type === "COVALENT_CONTRACT_CALL_COUNT" ? ( + + ) : ( + <> + {formattedMaxAmount && formattedMinAmount ? ( + <> + {" between the last "} + {formattedMinAmount} + {" - "} + {formattedMaxAmount} + + ) : formattedMinAmount ? ( + <> + {" in the last "} + {formattedMinAmount} + + ) : null} + + )} + + ) + } } })()} diff --git a/src/requirements/WalletActivity/components/ContractMethodInputsFieldArray.tsx b/src/requirements/WalletActivity/components/ContractMethodInputsFieldArray.tsx new file mode 100644 index 0000000000..546dbb683c --- /dev/null +++ b/src/requirements/WalletActivity/components/ContractMethodInputsFieldArray.tsx @@ -0,0 +1,246 @@ +import { Button } from "@/components/ui/Button" +import { + FormControl, + FormErrorMessage, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/Form" +import { Input } from "@/components/ui/Input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/Select" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/Tooltip" +import { cn } from "@/lib/utils" +import { Requirement } from "@guildxyz/types" +import { Plus, TrashSimple } from "@phosphor-icons/react/dist/ssr" +import { useMemo } from "react" +import { useFieldArray, useFormContext, useWatch } from "react-hook-form" +import { RequirementFormProps } from "requirements/types" +import { SelectOption } from "types" +import { useCovalentContractAbiMethods } from "../hooks/useCovalentContractAbiMethods" +import { CovalentContractCallCountChain } from "../types" +import { abiItemToFunctionSignature } from "../utils" + +type InputType = NonNullable< + Extract< + Requirement, + { + type: "COVALENT_CONTRACT_CALL_COUNT" | "COVALENT_CONTRACT_CALL_COUNT_RELATIVE" + } + >["data"]["inputs"] +>[number] + +// TODO: export this from the types package? +const OP_OPTIONS = [ + "equal", + "not_equal", + "greater", + "greater_or_equal", + "less", + "less_or_equal", + "array_last_equal", +] as const + +const OP_LABELS = { + equal: "Equal", + not_equal: "Not equal", + greater: "Greater than", + greater_or_equal: "Greater or equal", + less: "Less", + less_or_equal: "Less or equal", + array_last_equal: "Last item equals", +} satisfies Record<(typeof OP_OPTIONS)[number], string> + +export const ContractMethodInputsFieldArray = ({ + baseFieldPath, +}: RequirementFormProps) => { + const { control } = useFormContext() + + const chain: CovalentContractCallCountChain | undefined = useWatch({ + control, + name: `${baseFieldPath}.chain`, + }) + const address: string | undefined = useWatch({ + control, + name: `${baseFieldPath}.address`, + }) + + const { data, isValidating } = useCovalentContractAbiMethods(chain, address) + + const method: string | undefined = useWatch({ + control, + name: `${baseFieldPath}.data.method`, + }) + + const paramOptions = useMemo[] | undefined>(() => { + if (!method || !data) return undefined + const inputs = data.find( + (item) => abiItemToFunctionSignature(item) === method + )?.inputs + + if (!inputs) return undefined + + return inputs.map( + (input, index) => + ({ + label: input.name + ? `${input.name} ${input.type}` + : `${input.type} (index: ${index})`, + value: index, + }) satisfies SelectOption + ) + }, [data, method]) + + const { fields, append, remove } = useFieldArray({ + control, + name: `${baseFieldPath}.data.inputs`, + }) + + const addInputDisabledText = !method + ? "Specify a method first" + : method.trim().startsWith("0x") + ? "You can't specify inputs if method is defined as a raw bytes function signature" + : method.trim().endsWith("()") + ? "This method doesn't have any input parameters" + : undefined + + return ( + + Inputs + +
+ {fields.map(({ id }, index) => ( +
+
+
+ Param +
+ ( + + {paramOptions ? ( + + ) : ( + + )} + + )} + /> +
+ + ( + + + + )} + /> + + ( + + + + + )} + /> + + +
+ ))} + + + +
+ +
+
+ + +

{addInputDisabledText}

+
+
+
+
+ ) +} diff --git a/src/requirements/WalletActivity/components/CovalentContractCallCount.tsx b/src/requirements/WalletActivity/components/CovalentContractCallCount.tsx new file mode 100644 index 0000000000..ed264b584a --- /dev/null +++ b/src/requirements/WalletActivity/components/CovalentContractCallCount.tsx @@ -0,0 +1,18 @@ +import AbsoluteMinMaxTimeFormControls from "components/common/AbsoluteMinMaxTimeFormControls" +import { RequirementFormProps } from "requirements/types" +import { CovalentContractCallFields } from "./CovalentContractCallFields" + +const CovalentContractCallCount = ({ baseFieldPath }: RequirementFormProps) => ( + <> + + + + +) + +export default CovalentContractCallCount diff --git a/src/requirements/WalletActivity/components/CovalentContractCallCountRelative.tsx b/src/requirements/WalletActivity/components/CovalentContractCallCountRelative.tsx new file mode 100644 index 0000000000..f9ec485db8 --- /dev/null +++ b/src/requirements/WalletActivity/components/CovalentContractCallCountRelative.tsx @@ -0,0 +1,20 @@ +import RelativeMinMaxTimeFormControls from "components/common/RelativeMinMaxTimeFormControls" +import { RequirementFormProps } from "requirements/types" +import { CovalentContractCallFields } from "./CovalentContractCallFields" + +const CovalentContractCallCountRelative = ({ + baseFieldPath, +}: RequirementFormProps) => ( + <> + + + + +) + +export default CovalentContractCallCountRelative diff --git a/src/requirements/WalletActivity/components/CovalentContractCallFields.tsx b/src/requirements/WalletActivity/components/CovalentContractCallFields.tsx new file mode 100644 index 0000000000..d5b5a4afa6 --- /dev/null +++ b/src/requirements/WalletActivity/components/CovalentContractCallFields.tsx @@ -0,0 +1,144 @@ +import { + FormControl, + FormErrorMessage, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/Form" +import { Input } from "@/components/ui/Input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/Select" +import { Skeleton } from "@/components/ui/Skeleton" +import { useFormContext, useWatch } from "react-hook-form" +import { RequirementFormProps } from "requirements/types" +import { ADDRESS_REGEX } from "utils/guildCheckout/constants" +import { useCovalentContractAbiMethods } from "../hooks/useCovalentContractAbiMethods" +import { CovalentContractCallCountChain } from "../types" +import { abiItemToFunctionSignature } from "../utils" +import { ContractMethodInputsFieldArray } from "./ContractMethodInputsFieldArray" +import TxCountFormControl from "./TxCountFormControl" + +export const CovalentContractCallFields = ({ + baseFieldPath, +}: RequirementFormProps) => { + const { control, resetField, getValues } = useFormContext() + + const chain: CovalentContractCallCountChain | undefined = useWatch({ + control, + name: `${baseFieldPath}.chain`, + }) + const contractAddress: string | undefined = useWatch({ + control, + name: `${baseFieldPath}.address`, + }) + + const { data, isValidating } = useCovalentContractAbiMethods( + chain, + contractAddress + ) + + const methodOptions = data?.map((item) => ({ + label: abiItemToFunctionSignature(item, "PARAM_NAMES"), + value: abiItemToFunctionSignature(item), + })) + + return ( + <> + ( + + Contract address + { + resetField(`${baseFieldPath}.data.method`, { + defaultValue: "", + }) + resetField(`${baseFieldPath}.data.inputs`, { + defaultValue: [], + }) + resetField(`${baseFieldPath}.data.txCount`, { + defaultValue: 1, + }) + resetField(`${baseFieldPath}.data.timestamps`, { + defaultValue: {}, + }) + field.onChange(e) + }} + /> + + + )} + /> + + ( + + Contract method + {isValidating ? ( + + ) : !methodOptions ? ( + + ) : ( + + )} + + + )} + /> + + + + + + ) +} diff --git a/src/requirements/WalletActivity/hooks/useCovalentContractAbiMethods.ts b/src/requirements/WalletActivity/hooks/useCovalentContractAbiMethods.ts new file mode 100644 index 0000000000..7c3b295c38 --- /dev/null +++ b/src/requirements/WalletActivity/hooks/useCovalentContractAbiMethods.ts @@ -0,0 +1,61 @@ +import useSWRImmutable from "swr/immutable" +import fetcher from "utils/fetcher" +import { Abi, AbiFunction, AbiStateMutability } from "viem" +import { CovalentContractCallCountChain } from "../types" + +type AbiWriteFunction = AbiFunction & { + stateMutability: Extract +} + +const isContractWriteFunction = (f: Abi[number]): f is AbiWriteFunction => { + return ( + f.type === "function" && + (f.stateMutability === "payable" || f.stateMutability === "nonpayable") + ) +} + +const fetchContractMethods = async ( + baseUrl: string, + address: string +): Promise => { + let contract: { + proxy_type: string | null + implementations: { + address: string + name: string | null + }[] + abi: Abi + } = await fetcher(`${baseUrl}/smart-contracts/${address}`) + + const implementationAddress = contract.implementations.at(-1)?.address + + if (!!contract.proxy_type && implementationAddress) { + contract = await fetcher(`${baseUrl}/smart-contracts/${implementationAddress}`) + } + + if (!contract.abi) + return Promise.reject({ + error: "Couldn't fetch contract ABI", + }) + + return contract.abi.filter((item) => isContractWriteFunction(item)) +} + +const CONTRACT_METHOD_FETCHERS = { + INK: (address: string) => + fetchContractMethods("https://explorer.inkonchain.com/api/v2", address), + INK_SEPOLIA: (address: string) => + fetchContractMethods("https://explorer-sepolia.inkonchain.com/api/v2", address), +} satisfies Record< + CovalentContractCallCountChain, + (address: string) => ReturnType +> + +export const useCovalentContractAbiMethods = ( + chain: CovalentContractCallCountChain | undefined, + address: string | undefined +) => + useSWRImmutable( + chain && address ? ["covalent-abi-methods", chain, address] : null, + ([_, _chain, _address]) => CONTRACT_METHOD_FETCHERS[_chain](_address) + ) diff --git a/src/requirements/WalletActivity/types.ts b/src/requirements/WalletActivity/types.ts new file mode 100644 index 0000000000..c9f55d2d39 --- /dev/null +++ b/src/requirements/WalletActivity/types.ts @@ -0,0 +1,6 @@ +import { Requirement } from "types" + +export type CovalentContractCallCountChain = Extract< + Requirement, + { type: "COVALENT_CONTRACT_CALL_COUNT" | "COVALENT_CONTRACT_CALL_COUNT_RELATIVE" } +>["chain"] diff --git a/src/requirements/WalletActivity/utils.ts b/src/requirements/WalletActivity/utils.ts new file mode 100644 index 0000000000..6a710a3d97 --- /dev/null +++ b/src/requirements/WalletActivity/utils.ts @@ -0,0 +1,11 @@ +import { AbiFunction } from "viem" + +export const abiItemToFunctionSignature = ( + item: AbiFunction, + mode: "PARAM_TYPES" | "PARAM_NAMES" = "PARAM_TYPES" +) => { + if (mode === "PARAM_NAMES") + return `${item.name}(${item.inputs.map((input) => `${input.name ? `${input.name} ` : ""}${input.type}`).join(", ")})` + + return `${item.name}(${item.inputs.map((input) => input.type).join(", ")})` +} diff --git a/src/requirements/requirementDisplayComponents.ts b/src/requirements/requirementDisplayComponents.ts index be6f75044b..6045db14b5 100644 --- a/src/requirements/requirementDisplayComponents.ts +++ b/src/requirements/requirementDisplayComponents.ts @@ -46,6 +46,12 @@ export const REQUIREMENT_DISPLAY_COMPONENTS = { COVALENT_TX_COUNT_RELATIVE: dynamic( () => import("requirements/WalletActivity/WalletActivityRequirement") ), + COVALENT_CONTRACT_CALL_COUNT: dynamic( + () => import("requirements/WalletActivity/WalletActivityRequirement") + ), + COVALENT_CONTRACT_CALL_COUNT_RELATIVE: dynamic( + () => import("requirements/WalletActivity/WalletActivityRequirement") + ), ALCHEMY_TX_VALUE: dynamic( () => import("requirements/WalletActivity/WalletActivityRequirement") ), diff --git a/src/requirements/requirementFormComponents.ts b/src/requirements/requirementFormComponents.ts index 84029e2328..431493c930 100644 --- a/src/requirements/requirementFormComponents.ts +++ b/src/requirements/requirementFormComponents.ts @@ -36,6 +36,12 @@ export const REQUIREMENT_FORM_COMPONENTS = { COVALENT_TX_COUNT_RELATIVE: dynamic( () => import("requirements/WalletActivity/WalletActivityForm") ), + COVALENT_CONTRACT_CALL_COUNT: dynamic( + () => import("requirements/WalletActivity/WalletActivityForm") + ), + COVALENT_CONTRACT_CALL_COUNT_RELATIVE: dynamic( + () => import("requirements/WalletActivity/WalletActivityForm") + ), ALCHEMY_TX_VALUE: dynamic( () => import("requirements/WalletActivity/WalletActivityForm") ), diff --git a/src/requirements/requirements.ts b/src/requirements/requirements.ts index 728e447614..cb9faa16fd 100644 --- a/src/requirements/requirements.ts +++ b/src/requirements/requirements.ts @@ -61,6 +61,8 @@ export const REQUIREMENTS_DATA = [ "COVALENT_CONTRACT_DEPLOY_RELATIVE", "COVALENT_TX_COUNT", "COVALENT_TX_COUNT_RELATIVE", + "COVALENT_CONTRACT_CALL_COUNT", + "COVALENT_CONTRACT_CALL_COUNT_RELATIVE", ], isNegatable: true, }, diff --git a/src/v2/components/ui/Select.tsx b/src/v2/components/ui/Select.tsx index 57df70c339..0ab0099636 100644 --- a/src/v2/components/ui/Select.tsx +++ b/src/v2/components/ui/Select.tsx @@ -21,7 +21,7 @@ const SelectTrigger = forwardRef< className={cn( inputVariants({ className: [ - "flex w-full items-center justify-between [&>span]:line-clamp-1", + "flex w-full items-center justify-between [&>span]:truncate", className, ], }) @@ -30,7 +30,7 @@ const SelectTrigger = forwardRef< > {children} - + ))