diff --git a/src/@3rdweb-sdk/react/hooks/useEngine.ts b/src/@3rdweb-sdk/react/hooks/useEngine.ts index 312ecc91eb..43c669e9f6 100644 --- a/src/@3rdweb-sdk/react/hooks/useEngine.ts +++ b/src/@3rdweb-sdk/react/hooks/useEngine.ts @@ -1259,9 +1259,9 @@ export interface EngineContractSubscription { webhook?: EngineWebhook; createdAt: Date; processEventLogs: boolean; - filterEvents?: string[]; + filterEvents: string[]; processTransactionReceipts: boolean; - filterFunctions?: string[]; + filterFunctions: string[]; // Dummy field for the table. lastIndexedBlock: string; @@ -1289,7 +1289,11 @@ export function useEngineContractSubscription(instance: string) { export interface AddContractSubscriptionInput { chain: string; contractAddress: string; - webhookUrl?: string; + webhookUrl: string; + processEventLogs: boolean; + filterEvents: string[]; + processTransactionReceipts: boolean; + filterFunctions: string[]; } export function useEngineAddContractSubscription(instance: string) { diff --git a/src/components/engine/contract-subscription/add-contract-subscription-button.tsx b/src/components/engine/contract-subscription/add-contract-subscription-button.tsx index 44a88dec50..bc6e1cba6e 100644 --- a/src/components/engine/contract-subscription/add-contract-subscription-button.tsx +++ b/src/components/engine/contract-subscription/add-contract-subscription-button.tsx @@ -3,6 +3,8 @@ import { useEngineAddContractSubscription, } from "@3rdweb-sdk/react/hooks/useEngine"; import { + CheckboxGroup, + Collapse, Flex, FormControl, Icon, @@ -14,17 +16,28 @@ import { ModalFooter, ModalHeader, ModalOverlay, + Radio, + RadioGroup, + Spinner, Stack, UseDisclosureReturn, useDisclosure, } from "@chakra-ui/react"; import { NetworkDropdown } from "components/contract-components/contract-publish-form/NetworkDropdown"; -import { isAddress } from "ethers/lib/utils"; import { useTrack } from "hooks/analytics/useTrack"; +import { useContractAbiItems } from "hooks/useContractAbiItems"; import { useTxNotifications } from "hooks/useTxNotifications"; -import { useForm } from "react-hook-form"; +import { Dispatch, SetStateAction, useMemo, useState } from "react"; +import { UseFormReturn, useForm } from "react-hook-form"; import { AiOutlinePlusCircle } from "react-icons/ai"; -import { Button, Card, FormHelperText, FormLabel, Text } from "tw-components"; +import { + Button, + Card, + Checkbox, + FormHelperText, + FormLabel, + Text, +} from "tw-components"; interface AddContractSubscriptionButtonProps { instanceUrl: string; @@ -55,10 +68,14 @@ export const AddContractSubscriptionButton: React.FC< ); }; -export interface AddModalInput { - chainId: string; +interface AddContractSubscriptionForm { + chainId: number; contractAddress: string; - webhookUrl?: string; + webhookUrl: string; + processEventLogs: boolean; + filterEvents: string[]; + processTransactionReceipts: boolean; + filterFunctions: string[]; } const AddModal = ({ @@ -72,21 +89,32 @@ const AddModal = ({ useEngineAddContractSubscription(instanceUrl); const trackEvent = useTrack(); const { onSuccess, onError } = useTxNotifications( - "Contract Subscription created successfully.", - "Failed to create contract subscription.", + "Created Contract Subscription.", + "Failed to create Contract Subscription.", + ); + const [modalState, setModalState] = useState<"inputContract" | "inputData">( + "inputContract", ); - const form = useForm({ + const form = useForm({ defaultValues: { - chainId: "84532", + chainId: 84532, + processEventLogs: true, + filterEvents: [], + processTransactionReceipts: false, + filterFunctions: [], }, }); - const onSubmit = (data: AddModalInput) => { + const onSubmit = (data: AddContractSubscriptionForm) => { const input: AddContractSubscriptionInput = { - chain: data.chainId, + chain: data.chainId.toString(), contractAddress: data.contractAddress, - webhookUrl: data.webhookUrl?.trim() || undefined, + webhookUrl: data.webhookUrl.trim(), + processEventLogs: data.processEventLogs, + filterEvents: data.filterEvents, + processTransactionReceipts: data.processTransactionReceipts, + filterFunctions: data.filterFunctions, }; addContractSubscription(input, { @@ -125,80 +153,316 @@ const AddModal = ({ Add Contract Subscription - - - - Add a contract subscription to begin storing onchain data. - - - - - Chain - - form.setValue("chainId", val.toString()) - } - /> - - - - Contract Address - - - - - Webhook URL - - - Engine notifies your backend when event logs and transaction - receipts for this contract are detected. - - - - - - - - - - + {modalState === "inputContract" ? ( + + ) : modalState === "inputData" ? ( + + ) : null} ); }; -/** - * Returns a list of valid addresses from a comma or newline-separated string. - * - * Example: - * input: 0xa5B8492D8223D255dB279C7c3ebdA34Be5eC9D85,0x4Ff9aa707AE1eAeb40E581DF2cf4e14AffcC553d - * output: - * [ - * 0xa5B8492D8223D255dB279C7c3ebdA34Be5eC9D85, - * 0x4Ff9aa707AE1eAeb40E581DF2cf4e14AffcC553d, - * ] - */ -export const parseAddressListRaw = (raw: string): string[] | undefined => { - const addresses = raw - .split(/[,\n]/) - .map((entry) => entry.trim()) - .filter((entry) => isAddress(entry)); - return addresses.length > 0 ? addresses : undefined; +const ModalBodyInputContract = ({ + form, + setModalState, +}: { + form: UseFormReturn; + setModalState: Dispatch>; +}) => { + return ( + <> + + + + Add a contract subscription to process real-time onchain data. + + + + Chain + form.setValue("chainId", val)} + /> + + + + Contract Address + + + + + Webhook URL + + + Engine sends an HTTP request to your backend when new onchain data + for this contract is detected. + + + + + + + + + + ); +}; + +const ModalBodyInputData = ({ + form, + setModalState, +}: { + form: UseFormReturn; + setModalState: Dispatch>; +}) => { + const processEventLogsDisclosure = useDisclosure({ + defaultIsOpen: form.getValues("processEventLogs"), + }); + const processTransactionReceiptsDisclosure = useDisclosure({ + defaultIsOpen: form.getValues("processTransactionReceipts"), + }); + const [shouldFilterEvents, setShouldFilterEvents] = useState(false); + const [shouldFilterFunctions, setShouldFilterFunctions] = useState(false); + + const processEventLogs = form.watch("processEventLogs"); + const filterEvents = form.watch("filterEvents"); + const processTransactionReceipts = form.watch("processTransactionReceipts"); + const filterFunctions = form.watch("filterFunctions"); + + // Invalid states: + // - Neither logs nor receipts are selected. + // - Logs are selected but filtering on 0 event names. + // - Receipts are selected but filtering on 0 function names. + const isInputDataFormInvalid = + (!processEventLogs && !processTransactionReceipts) || + (processEventLogs && shouldFilterEvents && filterEvents.length === 0) || + (processTransactionReceipts && + shouldFilterFunctions && + filterFunctions.length === 0); + + return ( + <> + + + + Select the data type to process. +
+ Events logs are arbitrary data triggered by a smart contract call. +
+ Transaction receipts contain details about the blockchain execution. +
+ + + Processed Data + + + { + const { checked } = e.target; + form.setValue("processEventLogs", checked); + if (checked) { + processEventLogsDisclosure.onOpen(); + } else { + processEventLogsDisclosure.onClose(); + } + }} + > + Event Logs + + {/* Shows all/specific events if processing event logs */} + + + { + if (val === "true") { + setShouldFilterEvents(true); + } else { + setShouldFilterEvents(false); + form.setValue("filterEvents", []); + } + }} + > + + + All events + + + + Specific events{" "} + {!!filterEvents.length && + `(${filterEvents.length} selected)`} + + + {/* List event names to select */} + + + form.setValue("filterEvents", value) + } + /> + + + + + + + { + const { checked } = e.target; + form.setValue("processTransactionReceipts", checked); + if (checked) { + processTransactionReceiptsDisclosure.onOpen(); + } else { + processTransactionReceiptsDisclosure.onClose(); + } + }} + > + Transaction Receipts + + {/* Shows all/specific functions if processing transaction receipts */} + + + { + if (val === "true") { + setShouldFilterFunctions(true); + } else { + setShouldFilterFunctions(false); + form.setValue("filterFunctions", []); + } + }} + > + + + All functions + + + + Specific functions{" "} + {!!filterFunctions.length && + `(${filterFunctions.length} selected)`} + + + {/* List function names to select */} + + + form.setValue("filterFunctions", value) + } + /> + + + + + + + +
+
+ + + + + + + ); +}; + +const FilterSelector = ({ + abiItemType, + form, + filter, + setFilter, +}: { + abiItemType: "function" | "event"; + form: UseFormReturn; + filter: string[]; + setFilter: (value: string[]) => void; +}) => { + const abiItemsQuery = useContractAbiItems( + form.getValues("chainId"), + form.getValues("contractAddress") as `0x${string}`, + ); + + const filterNames = useMemo(() => { + switch (abiItemType) { + case "function": { + return abiItemsQuery.data.writeFunctions; + } + case "event": { + return abiItemsQuery.data.events; + } + default: { + return []; + } + } + }, [ + abiItemType, + abiItemsQuery.data.events, + abiItemsQuery.data.writeFunctions, + ]); + + return ( + + {abiItemsQuery.isLoading ? ( + + ) : filterNames.length === 0 ? ( + + Cannot resolve the contract definition. Filters are unavailable. + + ) : ( + + setFilter(selected)} + > + {filterNames.map((name) => ( + + {name} + + ))} + + + )} + + ); }; diff --git a/src/components/engine/contract-subscription/contract-subscriptions-table.tsx b/src/components/engine/contract-subscription/contract-subscriptions-table.tsx index 16f71d7286..d543fbb574 100644 --- a/src/components/engine/contract-subscription/contract-subscriptions-table.tsx +++ b/src/components/engine/contract-subscription/contract-subscriptions-table.tsx @@ -99,10 +99,87 @@ export const ContractSubscriptionTable: React.FC< }, }), columnHelper.accessor("webhook", { - header: "Webhook", + header: "Webhook URL", cell: (cell) => { const webhook = cell.getValue(); - return {webhook?.url}; + const url = webhook?.url ?? ""; + + return ( + + {url} + + ); + }, + }), + columnHelper.accessor("processEventLogs", { + header: "Filters", + cell: (cell) => { + const { + processEventLogs, + filterEvents, + processTransactionReceipts, + filterFunctions, + } = cell.row.original; + + return ( + + {/* Show logs + events */} + {processEventLogs && ( + + Logs: + {filterEvents.length === 0 ? ( + All + ) : ( + + {filterEvents.map((name) => ( + {name} + ))} + + } + bgColor="backgroundCardHighlight" + borderRadius="lg" + shouldWrapChildren + > + + {filterEvents.length} events + + + )} + + )} + + {/* Show receipts + functions */} + {processTransactionReceipts && ( + + Receipts: + {filterFunctions.length === 0 ? ( + All + ) : ( + + {filterFunctions.map((name) => ( + {name} + ))} + + } + bgColor="backgroundCardHighlight" + borderRadius="lg" + shouldWrapChildren + > + + {filterFunctions.length} functions + + + )} + + )} + + ); }, }), columnHelper.accessor("lastIndexedBlock", { @@ -314,6 +391,26 @@ const RemoveModal = ({ N/A )} + + + Filters + {contractSubscription.processEventLogs && ( + + Logs:{" "} + {contractSubscription.filterEvents.length === 0 + ? "All" + : contractSubscription.filterEvents.join(", ")} + + )} + {contractSubscription.processTransactionReceipts && ( + + Receipts:{" "} + {contractSubscription.filterFunctions.length === 0 + ? "All" + : contractSubscription.filterFunctions.join(", ")} + + )} + diff --git a/src/components/engine/tier-card.tsx b/src/components/engine/tier-card.tsx index 375621850f..15f47149cf 100644 --- a/src/components/engine/tier-card.tsx +++ b/src/components/engine/tier-card.tsx @@ -14,7 +14,7 @@ const ENGINE_TIER_CARD_CONFIG: Record = { name: "Standard", monthlyPriceUsd: 99, features: [ - "Isolated Server & Database", + "Isolated server & database", "APIs for contracts on 1700+ EVM chains", "Secured backend wallets", "Gas & nonce management", @@ -25,8 +25,8 @@ const ENGINE_TIER_CARD_CONFIG: Record = { monthlyPriceUsd: 299, features: [ "Autoscaling", - "Production-grade Server (High availability and redundancy)", - "Production-grade Database (High availability, Multi-AZ)", + "Production-grade server (high availability and redundancy)", + "Production-grade database (high availability, Multi-AZ)", "30-day database backups", "On-call monitoring from thirdweb", ], diff --git a/src/hooks/useContractAbiItems.ts b/src/hooks/useContractAbiItems.ts new file mode 100644 index 0000000000..7b0c145c5f --- /dev/null +++ b/src/hooks/useContractAbiItems.ts @@ -0,0 +1,62 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Abi } from "abitype"; +import { thirdwebClient } from "lib/thirdweb-client"; +import { Address, defineChain, getContract } from "thirdweb"; +import { resolveContractAbi } from "thirdweb/contract"; + +function dedupeAndSort(arr: string[]) { + return [...new Set(arr)].sort(); +} + +/** + * Returns read/write function and event names from a contract. + * + * @param chainId number + * @param address Address + * @returns readFunctions - List of read function names + * @returns writeFunctinos - List of write function names + * @returns events - List of event names + */ +export function useContractAbiItems(chainId: number, address: Address) { + return useQuery({ + queryKey: ["contract-abi-items", chainId, address], + initialData: { + readFunctions: [], + writeFunctions: [], + events: [], + }, + queryFn: async () => { + const chain = defineChain(chainId); + const contract = getContract({ + client: thirdwebClient, + chain, + address, + }); + + const abi = await resolveContractAbi(contract); + + const readFunctions: string[] = []; + const writeFunctions: string[] = []; + const events: string[] = []; + for (const abiItem of abi) { + if (abiItem.type === "function") { + if ( + abiItem.stateMutability === "pure" || + abiItem.stateMutability === "view" + ) { + readFunctions.push(abiItem.name); + } else { + writeFunctions.push(abiItem.name); + } + } else if (abiItem.type === "event") { + events.push(abiItem.name); + } + } + return { + readFunctions: dedupeAndSort(readFunctions), + writeFunctions: dedupeAndSort(writeFunctions), + events: dedupeAndSort(events), + }; + }, + }); +}