Skip to content

Commit f02d718

Browse files
authored
Merge pull request #2052 from kleros/feat/gated-dk-address-validation
feat(web): validation of the token address for ERC20/721/1155 types
2 parents 9401c9b + 3b59568 commit f02d718

File tree

5 files changed

+384
-24
lines changed

5 files changed

+384
-24
lines changed

web/src/context/NewDisputeContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export interface IGatedDisputeData {
6161
isERC1155: boolean;
6262
tokenGate: string;
6363
tokenId: string;
64+
isTokenGateValid?: boolean | null; // null = not validated, false = invalid, true = valid
6465
}
6566

6667
// Placeholder
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { useEffect, useState, useMemo } from "react";
2+
3+
import { useQuery } from "@tanstack/react-query";
4+
import { getContract, isAddress } from "viem";
5+
import { usePublicClient, useChainId } from "wagmi";
6+
7+
import { isUndefined } from "utils/index";
8+
9+
const ERC1155_ABI = [
10+
{
11+
inputs: [
12+
{
13+
internalType: "address",
14+
name: "account",
15+
type: "address",
16+
},
17+
{
18+
internalType: "uint256",
19+
name: "id",
20+
type: "uint256",
21+
},
22+
],
23+
name: "balanceOf",
24+
outputs: [
25+
{
26+
internalType: "uint256",
27+
name: "",
28+
type: "uint256",
29+
},
30+
],
31+
stateMutability: "view",
32+
type: "function",
33+
},
34+
] as const;
35+
36+
const ERC20_ERC721_ABI = [
37+
{
38+
inputs: [
39+
{
40+
internalType: "address",
41+
name: "account",
42+
type: "address",
43+
},
44+
],
45+
name: "balanceOf",
46+
outputs: [
47+
{
48+
internalType: "uint256",
49+
name: "",
50+
type: "uint256",
51+
},
52+
],
53+
stateMutability: "view",
54+
type: "function",
55+
},
56+
] as const;
57+
58+
interface UseTokenValidationParams {
59+
address?: string;
60+
enabled?: boolean;
61+
}
62+
63+
interface TokenValidationResult {
64+
isValidating: boolean;
65+
isValid: boolean | null;
66+
error: string | null;
67+
}
68+
69+
/**
70+
* Hook to validate if an address is a valid ERC20 or ERC721 token by attempting to call balanceOf(address)
71+
* @param address The address to validate
72+
* @param enabled Whether validation should be enabled
73+
* @returns Validation state including loading, result, and error
74+
*/
75+
export const useERC20ERC721Validation = ({
76+
address,
77+
enabled = true,
78+
}: UseTokenValidationParams): TokenValidationResult => {
79+
// We query the balance for a random non-zero address because many implementations revert on it
80+
return useTokenValidation({
81+
address,
82+
enabled,
83+
abi: ERC20_ERC721_ABI,
84+
contractCall: (contract) => contract.read.balanceOf(["0x0000000000000000000000000000000000001234"]),
85+
tokenType: "ERC-20 or ERC-721",
86+
});
87+
};
88+
89+
/**
90+
* Hook to validate if an address is a valid ERC1155 token by attempting to call balanceOf(address, tokenId)
91+
* @param address The address to validate
92+
* @param enabled Whether validation should be enabled
93+
* @returns Validation state including loading, result, and error
94+
*/
95+
export const useERC1155Validation = ({ address, enabled = true }: UseTokenValidationParams): TokenValidationResult => {
96+
// We query the balance for a random non-zero address because many implementations revert on it
97+
return useTokenValidation({
98+
address,
99+
enabled,
100+
abi: ERC1155_ABI,
101+
contractCall: (contract) => contract.read.balanceOf(["0x0000000000000000000000000000000000001234", 0]),
102+
tokenType: "ERC-1155",
103+
});
104+
};
105+
106+
/**
107+
* Generic hook for token contract validation
108+
*/
109+
const useTokenValidation = ({
110+
address,
111+
enabled = true,
112+
abi,
113+
contractCall,
114+
tokenType,
115+
}: UseTokenValidationParams & {
116+
abi: readonly any[];
117+
contractCall: (contract: any) => Promise<any>;
118+
tokenType: string;
119+
}): TokenValidationResult => {
120+
const publicClient = usePublicClient();
121+
const chainId = useChainId();
122+
const [debouncedAddress, setDebouncedAddress] = useState<string>();
123+
124+
// Debounce address changes to avoid excessive network calls
125+
useEffect(() => {
126+
const timer = setTimeout(() => {
127+
setDebouncedAddress(address);
128+
}, 500);
129+
130+
return () => clearTimeout(timer);
131+
}, [address]);
132+
133+
// Early validation - check format
134+
const isValidFormat = useMemo(() => {
135+
if (!debouncedAddress || debouncedAddress.trim() === "") return null;
136+
return isAddress(debouncedAddress);
137+
}, [debouncedAddress]);
138+
139+
// Contract validation query
140+
const {
141+
data: isValidContract,
142+
isLoading,
143+
error,
144+
} = useQuery({
145+
queryKey: [`${tokenType}-validation`, chainId, debouncedAddress],
146+
enabled: enabled && !isUndefined(publicClient) && Boolean(isValidFormat),
147+
staleTime: 300000, // Cache for 5 minutes
148+
retry: 1, // Only retry once to fail faster
149+
retryDelay: 1000, // Short retry delay
150+
queryFn: async () => {
151+
if (!publicClient || !debouncedAddress) {
152+
throw new Error("Missing required dependencies");
153+
}
154+
155+
try {
156+
const contract = getContract({
157+
address: debouncedAddress as `0x${string}`,
158+
abi,
159+
client: publicClient,
160+
});
161+
162+
// Execute the contract call specific to the token type
163+
await contractCall(contract);
164+
165+
return true;
166+
} catch {
167+
throw new Error(`Address does not implement ${tokenType} interface`);
168+
}
169+
},
170+
});
171+
172+
// Determine final validation state
173+
const isValid = useMemo(() => {
174+
if (!debouncedAddress || debouncedAddress.trim() === "") {
175+
return null;
176+
}
177+
178+
if (isValidFormat === false) {
179+
return false;
180+
}
181+
182+
if (isLoading) {
183+
return null; // Still validating
184+
}
185+
186+
return isValidContract === true;
187+
}, [debouncedAddress, isValidFormat, isLoading, isValidContract]);
188+
189+
const validationError = useMemo(() => {
190+
if (!debouncedAddress || debouncedAddress.trim() === "") {
191+
return null;
192+
}
193+
194+
if (isValidFormat === false) {
195+
return "Invalid Ethereum address format";
196+
}
197+
198+
if (error) {
199+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
200+
if (errorMessage.includes("not a contract")) {
201+
return "Address is not a contract";
202+
}
203+
if (errorMessage.includes(`does not implement ${tokenType}`)) {
204+
return `Not a valid ${tokenType} token address`;
205+
}
206+
return "Network error - please try again";
207+
}
208+
209+
return null;
210+
}, [debouncedAddress, isValidFormat, error, tokenType]);
211+
212+
return {
213+
isValidating: isLoading && enabled && !!debouncedAddress,
214+
isValid,
215+
error: validationError,
216+
};
217+
};

web/src/pages/Resolver/NavigationButtons/NextButton.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { useLocation, useNavigate } from "react-router-dom";
44

55
import { Button } from "@kleros/ui-components-library";
66

7-
import { useNewDisputeContext } from "context/NewDisputeContext";
7+
import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext";
8+
89
import { isEmpty } from "src/utils";
910

1011
interface INextButton {
@@ -16,6 +17,17 @@ const NextButton: React.FC<INextButton> = ({ nextRoute }) => {
1617
const { disputeData, isPolicyUploading } = useNewDisputeContext();
1718
const location = useLocation();
1819

20+
// Check gated dispute kit validation status
21+
const isGatedTokenValid = React.useMemo(() => {
22+
if (!disputeData.disputeKitData || disputeData.disputeKitData.type !== "gated") return true;
23+
24+
const gatedData = disputeData.disputeKitData as IGatedDisputeData;
25+
if (!gatedData?.tokenGate?.trim()) return false; // No token address provided, so invalid
26+
27+
// If token address is provided, it must be validated as valid ERC20
28+
return gatedData.isTokenGateValid === true;
29+
}, [disputeData.disputeKitData]);
30+
1931
//checks if each answer is filled in
2032
const areVotingOptionsFilled =
2133
disputeData.question !== "" &&
@@ -29,7 +41,8 @@ const NextButton: React.FC<INextButton> = ({ nextRoute }) => {
2941
const isButtonDisabled =
3042
(location.pathname.includes("/resolver/title") && !disputeData.title) ||
3143
(location.pathname.includes("/resolver/description") && !disputeData.description) ||
32-
(location.pathname.includes("/resolver/court") && !disputeData.courtId) ||
44+
(location.pathname.includes("/resolver/court") &&
45+
(!disputeData.courtId || !isGatedTokenValid || !disputeData.disputeKitId)) ||
3346
(location.pathname.includes("/resolver/jurors") && !disputeData.arbitrationCost) ||
3447
(location.pathname.includes("/resolver/voting-options") && !areVotingOptionsFilled) ||
3548
(location.pathname.includes("/resolver/notable-persons") && !areAliasesValidOrEmpty) ||

0 commit comments

Comments
 (0)