From f1fe7c9c7160ee700a464cbe54545955fa03d801 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Wed, 23 Jul 2025 01:55:23 +0200 Subject: [PATCH 1/4] feat(web): validation of the token address for ERC20/721/1155 types --- web/src/context/NewDisputeContext.tsx | 1 + web/src/hooks/useTokenAddressValidation.ts | 220 ++++++++++++++++++ .../Resolver/NavigationButtons/NextButton.tsx | 16 +- web/src/pages/Resolver/Parameters/Court.tsx | 149 +++++++++++- 4 files changed, 380 insertions(+), 6 deletions(-) create mode 100644 web/src/hooks/useTokenAddressValidation.ts 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..10275025b --- /dev/null +++ b/web/src/hooks/useTokenAddressValidation.ts @@ -0,0 +1,220 @@ +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) && + !isUndefined(debouncedAddress) && + debouncedAddress.trim() !== "" && + isValidFormat === true, + 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..2530281bd 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 true; // No token address provided, so valid + + // 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..805213253 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 #ccc; + border-top-color: #007bff; + animation: spin 1s linear infinite; + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + `; + } + + if ($isValid === true) { + return css` + background-color: #28a745; + color: white; + &::after { + content: "✓"; + } + `; + } + + if ($isValid === false) { + return css` + background-color: #dc3545; + color: white; + &::after { + content: "✗"; + } + `; + } + + return css` + display: none; + `; + }} +`; + +const ValidationMessage = styled.small<{ $isError?: boolean }>` + color: ${({ $isError }) => ($isError ? "#dc3545" : "#28a745")}; + font-size: 14px; + font-style: italic; + font-weight: normal; +`; + +const StyledFieldWithValidation = styled(StyledField)<{ $isValid?: boolean | null }>` + > input { + border-color: ${({ $isValid }) => { + if ($isValid === true) return "#28a745"; + if ($isValid === false) return "#dc3545"; + 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, 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`} + + + )} Date: Tue, 5 Aug 2025 20:32:54 +0530 Subject: [PATCH 2/4] chore: refactors --- web/src/hooks/useTokenAddressValidation.ts | 7 +------ web/src/pages/Resolver/Parameters/Court.tsx | 18 +++++++++--------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/web/src/hooks/useTokenAddressValidation.ts b/web/src/hooks/useTokenAddressValidation.ts index 10275025b..e3233ef80 100644 --- a/web/src/hooks/useTokenAddressValidation.ts +++ b/web/src/hooks/useTokenAddressValidation.ts @@ -141,12 +141,7 @@ const useTokenValidation = ({ error, } = useQuery({ queryKey: [`${tokenType}-validation`, chainId, debouncedAddress], - enabled: - enabled && - !isUndefined(publicClient) && - !isUndefined(debouncedAddress) && - debouncedAddress.trim() !== "" && - isValidFormat === true, + 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 diff --git a/web/src/pages/Resolver/Parameters/Court.tsx b/web/src/pages/Resolver/Parameters/Court.tsx index 805213253..4e0756c19 100644 --- a/web/src/pages/Resolver/Parameters/Court.tsx +++ b/web/src/pages/Resolver/Parameters/Court.tsx @@ -112,8 +112,8 @@ const ValidationIcon = styled.div<{ $isValid?: boolean | null; $isValidating?: b ${({ $isValidating, $isValid }) => { if ($isValidating) { return css` - border: 2px solid #ccc; - border-top-color: #007bff; + border: 2px solid ${({ theme }) => theme.stroke}; + border-top-color: ${({ theme }) => theme.primaryBlue}; animation: spin 1s linear infinite; @keyframes spin { @@ -126,7 +126,7 @@ const ValidationIcon = styled.div<{ $isValid?: boolean | null; $isValidating?: b if ($isValid === true) { return css` - background-color: #28a745; + background-color: ${({ theme }) => theme.success}; color: white; &::after { content: "✓"; @@ -136,7 +136,7 @@ const ValidationIcon = styled.div<{ $isValid?: boolean | null; $isValidating?: b if ($isValid === false) { return css` - background-color: #dc3545; + background-color: ${({ theme }) => theme.error}; color: white; &::after { content: "✗"; @@ -151,7 +151,7 @@ const ValidationIcon = styled.div<{ $isValid?: boolean | null; $isValidating?: b `; const ValidationMessage = styled.small<{ $isError?: boolean }>` - color: ${({ $isError }) => ($isError ? "#dc3545" : "#28a745")}; + color: ${({ $isError, theme }) => ($isError ? theme.error : theme.success)}; font-size: 14px; font-style: italic; font-weight: normal; @@ -159,9 +159,9 @@ const ValidationMessage = styled.small<{ $isError?: boolean }>` const StyledFieldWithValidation = styled(StyledField)<{ $isValid?: boolean | null }>` > input { - border-color: ${({ $isValid }) => { - if ($isValid === true) return "#28a745"; - if ($isValid === false) return "#dc3545"; + border-color: ${({ $isValid, theme }) => { + if ($isValid === true) return theme.success; + if ($isValid === false) return theme.error; return "inherit"; }}; } @@ -327,7 +327,7 @@ const Court: React.FC = () => { {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`} From 7c74b70f7408570a295c85ae207f66017c1ca05a Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Wed, 6 Aug 2025 00:13:57 +0100 Subject: [PATCH 3/4] fix: validation should fail if token gate address is empty --- web/src/pages/Resolver/NavigationButtons/NextButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/Resolver/NavigationButtons/NextButton.tsx b/web/src/pages/Resolver/NavigationButtons/NextButton.tsx index 2530281bd..61d7e9228 100644 --- a/web/src/pages/Resolver/NavigationButtons/NextButton.tsx +++ b/web/src/pages/Resolver/NavigationButtons/NextButton.tsx @@ -22,7 +22,7 @@ const NextButton: React.FC = ({ nextRoute }) => { if (!disputeData.disputeKitData || disputeData.disputeKitData.type !== "gated") return true; const gatedData = disputeData.disputeKitData as IGatedDisputeData; - if (!gatedData?.tokenGate?.trim()) return true; // No token address provided, so valid + 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; From d78e2128efe196213e9ccac10104cbb5322b15cd Mon Sep 17 00:00:00 2001 From: TurbanCoder <51452848+tractorss@users.noreply.github.com> Date: Wed, 6 Aug 2025 13:09:59 +0530 Subject: [PATCH 4/4] Update web/src/pages/Resolver/Parameters/Court.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- web/src/pages/Resolver/Parameters/Court.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/Resolver/Parameters/Court.tsx b/web/src/pages/Resolver/Parameters/Court.tsx index 4e0756c19..b74b22073 100644 --- a/web/src/pages/Resolver/Parameters/Court.tsx +++ b/web/src/pages/Resolver/Parameters/Court.tsx @@ -240,7 +240,7 @@ const Court: React.FC = () => { }); } } - }, [isValidToken, isGatedDisputeKit, disputeData, setDisputeData]); + }, [isValidToken, isGatedDisputeKit, disputeData.disputeKitData, setDisputeData]); const handleCourtChange = (courtId: string) => { if (disputeData.courtId !== courtId) {