Skip to content

Commit f1fe7c9

Browse files
committed
feat(web): validation of the token address for ERC20/721/1155 types
1 parent b5fff19 commit f1fe7c9

File tree

4 files changed

+380
-6
lines changed

4 files changed

+380
-6
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: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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+
return useTokenValidation({
80+
address,
81+
enabled,
82+
abi: ERC20_ERC721_ABI,
83+
contractCall: (contract) => contract.read.balanceOf(["0x0000000000000000000000000000000000000000"]),
84+
tokenType: "ERC-20 or ERC-721",
85+
});
86+
};
87+
88+
/**
89+
* Hook to validate if an address is a valid ERC1155 token by attempting to call balanceOf(address, tokenId)
90+
* @param address The address to validate
91+
* @param enabled Whether validation should be enabled
92+
* @returns Validation state including loading, result, and error
93+
*/
94+
export const useERC1155Validation = ({ address, enabled = true }: UseTokenValidationParams): TokenValidationResult => {
95+
return useTokenValidation({
96+
address,
97+
enabled,
98+
abi: ERC1155_ABI,
99+
contractCall: (contract) => contract.read.balanceOf(["0x0000000000000000000000000000000000000000", 0]),
100+
tokenType: "ERC-1155",
101+
});
102+
};
103+
104+
/**
105+
* Generic hook for token contract validation
106+
*/
107+
const useTokenValidation = ({
108+
address,
109+
enabled = true,
110+
abi,
111+
contractCall,
112+
tokenType,
113+
}: UseTokenValidationParams & {
114+
abi: readonly any[];
115+
contractCall: (contract: any) => Promise<any>;
116+
tokenType: string;
117+
}): TokenValidationResult => {
118+
const publicClient = usePublicClient();
119+
const chainId = useChainId();
120+
const [debouncedAddress, setDebouncedAddress] = useState<string>();
121+
122+
// Debounce address changes to avoid excessive network calls
123+
useEffect(() => {
124+
const timer = setTimeout(() => {
125+
setDebouncedAddress(address);
126+
}, 500);
127+
128+
return () => clearTimeout(timer);
129+
}, [address]);
130+
131+
// Early validation - check format
132+
const isValidFormat = useMemo(() => {
133+
if (!debouncedAddress || debouncedAddress.trim() === "") return null;
134+
return isAddress(debouncedAddress);
135+
}, [debouncedAddress]);
136+
137+
// Contract validation query
138+
const {
139+
data: isValidContract,
140+
isLoading,
141+
error,
142+
} = useQuery({
143+
queryKey: [`${tokenType}-validation`, chainId, debouncedAddress],
144+
enabled:
145+
enabled &&
146+
!isUndefined(publicClient) &&
147+
!isUndefined(debouncedAddress) &&
148+
debouncedAddress.trim() !== "" &&
149+
isValidFormat === true,
150+
staleTime: 300000, // Cache for 5 minutes
151+
retry: 1, // Only retry once to fail faster
152+
retryDelay: 1000, // Short retry delay
153+
queryFn: async () => {
154+
if (!publicClient || !debouncedAddress) {
155+
throw new Error("Missing required dependencies");
156+
}
157+
158+
try {
159+
const contract = getContract({
160+
address: debouncedAddress as `0x${string}`,
161+
abi,
162+
client: publicClient,
163+
});
164+
165+
// Execute the contract call specific to the token type
166+
await contractCall(contract);
167+
168+
return true;
169+
} catch {
170+
throw new Error(`Address does not implement ${tokenType} interface`);
171+
}
172+
},
173+
});
174+
175+
// Determine final validation state
176+
const isValid = useMemo(() => {
177+
if (!debouncedAddress || debouncedAddress.trim() === "") {
178+
return null;
179+
}
180+
181+
if (isValidFormat === false) {
182+
return false;
183+
}
184+
185+
if (isLoading) {
186+
return null; // Still validating
187+
}
188+
189+
return isValidContract === true;
190+
}, [debouncedAddress, isValidFormat, isLoading, isValidContract]);
191+
192+
const validationError = useMemo(() => {
193+
if (!debouncedAddress || debouncedAddress.trim() === "") {
194+
return null;
195+
}
196+
197+
if (isValidFormat === false) {
198+
return "Invalid Ethereum address format";
199+
}
200+
201+
if (error) {
202+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
203+
if (errorMessage.includes("not a contract")) {
204+
return "Address is not a contract";
205+
}
206+
if (errorMessage.includes(`does not implement ${tokenType}`)) {
207+
return `Not a valid ${tokenType} token address`;
208+
}
209+
return "Network error - please try again";
210+
}
211+
212+
return null;
213+
}, [debouncedAddress, isValidFormat, error, tokenType]);
214+
215+
return {
216+
isValidating: isLoading && enabled && !!debouncedAddress,
217+
isValid,
218+
error: validationError,
219+
};
220+
};

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

Lines changed: 14 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 true; // No token address provided, so valid
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,7 @@ 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") && (!disputeData.courtId || !isGatedTokenValid)) ||
3345
(location.pathname.includes("/resolver/jurors") && !disputeData.arbitrationCost) ||
3446
(location.pathname.includes("/resolver/voting-options") && !areVotingOptionsFilled) ||
3547
(location.pathname.includes("/resolver/notable-persons") && !areAliasesValidOrEmpty) ||

0 commit comments

Comments
 (0)