diff --git a/web/src/context/NewDisputeContext.tsx b/web/src/context/NewDisputeContext.tsx index 52abb696d..5fc109cef 100644 --- a/web/src/context/NewDisputeContext.tsx +++ b/web/src/context/NewDisputeContext.tsx @@ -61,6 +61,7 @@ export interface IGatedDisputeData { isERC1155: boolean; tokenGate: string; tokenId: string; + isTokenGateValid?: boolean | null; // null = not validated, false = invalid, true = valid } // Placeholder diff --git a/web/src/hooks/useTokenAddressValidation.ts b/web/src/hooks/useTokenAddressValidation.ts new file mode 100644 index 000000000..e3233ef80 --- /dev/null +++ b/web/src/hooks/useTokenAddressValidation.ts @@ -0,0 +1,215 @@ +import { useEffect, useState, useMemo } from "react"; + +import { useQuery } from "@tanstack/react-query"; +import { getContract, isAddress } from "viem"; +import { usePublicClient, useChainId } from "wagmi"; + +import { isUndefined } from "utils/index"; + +const ERC1155_ABI = [ + { + inputs: [ + { + internalType: "address", + name: "account", + type: "address", + }, + { + internalType: "uint256", + name: "id", + type: "uint256", + }, + ], + name: "balanceOf", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, +] as const; + +const ERC20_ERC721_ABI = [ + { + inputs: [ + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "balanceOf", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, +] as const; + +interface UseTokenValidationParams { + address?: string; + enabled?: boolean; +} + +interface TokenValidationResult { + isValidating: boolean; + isValid: boolean | null; + error: string | null; +} + +/** + * Hook to validate if an address is a valid ERC20 or ERC721 token by attempting to call balanceOf(address) + * @param address The address to validate + * @param enabled Whether validation should be enabled + * @returns Validation state including loading, result, and error + */ +export const useERC20ERC721Validation = ({ + address, + enabled = true, +}: UseTokenValidationParams): TokenValidationResult => { + return useTokenValidation({ + address, + enabled, + abi: ERC20_ERC721_ABI, + contractCall: (contract) => contract.read.balanceOf(["0x0000000000000000000000000000000000000000"]), + tokenType: "ERC-20 or ERC-721", + }); +}; + +/** + * Hook to validate if an address is a valid ERC1155 token by attempting to call balanceOf(address, tokenId) + * @param address The address to validate + * @param enabled Whether validation should be enabled + * @returns Validation state including loading, result, and error + */ +export const useERC1155Validation = ({ address, enabled = true }: UseTokenValidationParams): TokenValidationResult => { + return useTokenValidation({ + address, + enabled, + abi: ERC1155_ABI, + contractCall: (contract) => contract.read.balanceOf(["0x0000000000000000000000000000000000000000", 0]), + tokenType: "ERC-1155", + }); +}; + +/** + * Generic hook for token contract validation + */ +const useTokenValidation = ({ + address, + enabled = true, + abi, + contractCall, + tokenType, +}: UseTokenValidationParams & { + abi: readonly any[]; + contractCall: (contract: any) => Promise; + tokenType: string; +}): TokenValidationResult => { + const publicClient = usePublicClient(); + const chainId = useChainId(); + const [debouncedAddress, setDebouncedAddress] = useState(); + + // Debounce address changes to avoid excessive network calls + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedAddress(address); + }, 500); + + return () => clearTimeout(timer); + }, [address]); + + // Early validation - check format + const isValidFormat = useMemo(() => { + if (!debouncedAddress || debouncedAddress.trim() === "") return null; + return isAddress(debouncedAddress); + }, [debouncedAddress]); + + // Contract validation query + const { + data: isValidContract, + isLoading, + error, + } = useQuery({ + queryKey: [`${tokenType}-validation`, chainId, debouncedAddress], + enabled: enabled && !isUndefined(publicClient) && Boolean(isValidFormat), + staleTime: 300000, // Cache for 5 minutes + retry: 1, // Only retry once to fail faster + retryDelay: 1000, // Short retry delay + queryFn: async () => { + if (!publicClient || !debouncedAddress) { + throw new Error("Missing required dependencies"); + } + + try { + const contract = getContract({ + address: debouncedAddress as `0x${string}`, + abi, + client: publicClient, + }); + + // Execute the contract call specific to the token type + await contractCall(contract); + + return true; + } catch { + throw new Error(`Address does not implement ${tokenType} interface`); + } + }, + }); + + // Determine final validation state + const isValid = useMemo(() => { + if (!debouncedAddress || debouncedAddress.trim() === "") { + return null; + } + + if (isValidFormat === false) { + return false; + } + + if (isLoading) { + return null; // Still validating + } + + return isValidContract === true; + }, [debouncedAddress, isValidFormat, isLoading, isValidContract]); + + const validationError = useMemo(() => { + if (!debouncedAddress || debouncedAddress.trim() === "") { + return null; + } + + if (isValidFormat === false) { + return "Invalid Ethereum address format"; + } + + if (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + if (errorMessage.includes("not a contract")) { + return "Address is not a contract"; + } + if (errorMessage.includes(`does not implement ${tokenType}`)) { + return `Not a valid ${tokenType} token address`; + } + return "Network error - please try again"; + } + + return null; + }, [debouncedAddress, isValidFormat, error, tokenType]); + + return { + isValidating: isLoading && enabled && !!debouncedAddress, + isValid, + error: validationError, + }; +}; diff --git a/web/src/pages/Resolver/NavigationButtons/NextButton.tsx b/web/src/pages/Resolver/NavigationButtons/NextButton.tsx index e6d51f8bf..61d7e9228 100644 --- a/web/src/pages/Resolver/NavigationButtons/NextButton.tsx +++ b/web/src/pages/Resolver/NavigationButtons/NextButton.tsx @@ -4,7 +4,8 @@ import { useLocation, useNavigate } from "react-router-dom"; import { Button } from "@kleros/ui-components-library"; -import { useNewDisputeContext } from "context/NewDisputeContext"; +import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; + import { isEmpty } from "src/utils"; interface INextButton { @@ -16,6 +17,17 @@ const NextButton: React.FC = ({ nextRoute }) => { const { disputeData, isPolicyUploading } = useNewDisputeContext(); const location = useLocation(); + // Check gated dispute kit validation status + const isGatedTokenValid = React.useMemo(() => { + if (!disputeData.disputeKitData || disputeData.disputeKitData.type !== "gated") return true; + + const gatedData = disputeData.disputeKitData as IGatedDisputeData; + if (!gatedData?.tokenGate?.trim()) return false; // No token address provided, so invalid + + // If token address is provided, it must be validated as valid ERC20 + return gatedData.isTokenGateValid === true; + }, [disputeData.disputeKitData]); + //checks if each answer is filled in const areVotingOptionsFilled = disputeData.question !== "" && @@ -29,7 +41,7 @@ const NextButton: React.FC = ({ nextRoute }) => { const isButtonDisabled = (location.pathname.includes("/resolver/title") && !disputeData.title) || (location.pathname.includes("/resolver/description") && !disputeData.description) || - (location.pathname.includes("/resolver/court") && !disputeData.courtId) || + (location.pathname.includes("/resolver/court") && (!disputeData.courtId || !isGatedTokenValid)) || (location.pathname.includes("/resolver/jurors") && !disputeData.arbitrationCost) || (location.pathname.includes("/resolver/voting-options") && !areVotingOptionsFilled) || (location.pathname.includes("/resolver/notable-persons") && !areAliasesValidOrEmpty) || diff --git a/web/src/pages/Resolver/Parameters/Court.tsx b/web/src/pages/Resolver/Parameters/Court.tsx index b82fbb133..b74b22073 100644 --- a/web/src/pages/Resolver/Parameters/Court.tsx +++ b/web/src/pages/Resolver/Parameters/Court.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useEffect } from "react"; import styled, { css } from "styled-components"; import { AlertMessage, Checkbox, DropdownCascader, DropdownSelect, Field } from "@kleros/ui-components-library"; @@ -7,6 +7,7 @@ import { DisputeKits } from "consts/index"; import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; import { rootCourtToItems, useCourtTree } from "hooks/queries/useCourtTree"; import { useDisputeKitAddressesAll } from "hooks/useDisputeKitAddresses"; +import { useERC20ERC721Validation, useERC1155Validation } from "hooks/useTokenAddressValidation"; import { isUndefined } from "utils/index"; import { useSupportedDisputeKits } from "queries/useSupportedDisputeKits"; @@ -86,6 +87,86 @@ const StyledCheckbox = styled(Checkbox)` )} `; +const ValidationContainer = styled.div` + width: 84vw; + display: flex; + align-items: left; + gap: 8px; + margin-top: 8px; + ${landscapeStyle( + () => css` + width: ${responsiveSize(442, 700, 900)}; + ` + )} +`; + +const ValidationIcon = styled.div<{ $isValid?: boolean | null; $isValidating?: boolean }>` + width: 16px; + height: 16px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + + ${({ $isValidating, $isValid }) => { + if ($isValidating) { + return css` + border: 2px solid ${({ theme }) => theme.stroke}; + border-top-color: ${({ theme }) => theme.primaryBlue}; + animation: spin 1s linear infinite; + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + `; + } + + if ($isValid === true) { + return css` + background-color: ${({ theme }) => theme.success}; + color: white; + &::after { + content: "✓"; + } + `; + } + + if ($isValid === false) { + return css` + background-color: ${({ theme }) => theme.error}; + color: white; + &::after { + content: "✗"; + } + `; + } + + return css` + display: none; + `; + }} +`; + +const ValidationMessage = styled.small<{ $isError?: boolean }>` + color: ${({ $isError, theme }) => ($isError ? theme.error : theme.success)}; + font-size: 14px; + font-style: italic; + font-weight: normal; +`; + +const StyledFieldWithValidation = styled(StyledField)<{ $isValid?: boolean | null }>` + > input { + border-color: ${({ $isValid, theme }) => { + if ($isValid === true) return theme.success; + if ($isValid === false) return theme.error; + return "inherit"; + }}; + } +`; + const Court: React.FC = () => { const { disputeData, setDisputeData } = useNewDisputeContext(); const { data: courtTree } = useCourtTree(); @@ -120,6 +201,47 @@ const Court: React.FC = () => { return options?.gated ?? false; }, [disputeKitOptions, selectedDisputeKitId]); + // Token validation for token gate address (conditional based on ERC1155 checkbox) + const tokenGateAddress = (disputeData.disputeKitData as IGatedDisputeData)?.tokenGate ?? ""; + const isERC1155 = (disputeData.disputeKitData as IGatedDisputeData)?.isERC1155 ?? false; + const validationEnabled = isGatedDisputeKit && !!tokenGateAddress.trim(); + + const { + isValidating: isValidatingERC20, + isValid: isValidERC20, + error: validationErrorERC20, + } = useERC20ERC721Validation({ + address: tokenGateAddress, + enabled: validationEnabled && !isERC1155, + }); + + const { + isValidating: isValidatingERC1155, + isValid: isValidERC1155, + error: validationErrorERC1155, + } = useERC1155Validation({ + address: tokenGateAddress, + enabled: validationEnabled && isERC1155, + }); + + // Combine validation results based on token type + const isValidating = isERC1155 ? isValidatingERC1155 : isValidatingERC20; + const isValidToken = isERC1155 ? isValidERC1155 : isValidERC20; + const validationError = isERC1155 ? validationErrorERC1155 : validationErrorERC20; + + // Update validation state in dispute context + useEffect(() => { + if (isGatedDisputeKit && disputeData.disputeKitData) { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + if (currentData.isTokenGateValid !== isValidToken) { + setDisputeData({ + ...disputeData, + disputeKitData: { ...currentData, isTokenGateValid: isValidToken }, + }); + } + } + }, [isValidToken, isGatedDisputeKit, disputeData.disputeKitData, setDisputeData]); + const handleCourtChange = (courtId: string) => { if (disputeData.courtId !== courtId) { setDisputeData({ ...disputeData, courtId, disputeKitId: undefined }); @@ -144,7 +266,11 @@ const Court: React.FC = () => { const currentData = disputeData.disputeKitData as IGatedDisputeData; setDisputeData({ ...disputeData, - disputeKitData: { ...currentData, tokenGate: event.target.value }, + disputeKitData: { + ...currentData, + tokenGate: event.target.value, + isTokenGateValid: null, // Reset validation state when address changes + }, }); }; @@ -152,7 +278,11 @@ const Court: React.FC = () => { const currentData = disputeData.disputeKitData as IGatedDisputeData; setDisputeData({ ...disputeData, - disputeKitData: { ...currentData, isERC1155: event.target.checked }, + disputeKitData: { + ...currentData, + isERC1155: event.target.checked, + isTokenGateValid: null, // Reset validation state when token type changes + }, }); }; @@ -187,12 +317,23 @@ const Court: React.FC = () => { )} {isGatedDisputeKit && ( <> - + {tokenGateAddress.trim() !== "" && ( + + + + {isValidating && `Validating ${isERC1155 ? "ERC-1155" : "ERC-20 or ERC-721"} token...`} + {validationError && validationError} + {isValidToken === true && `Valid ${isERC1155 ? "ERC-1155" : "ERC-20 or ERC-721"} token`} + + + )}