Skip to content

Commit 7f1b769

Browse files
karooolisfrolic
andauthored
feat(explorer): resolve ENS (#3731)
Co-authored-by: Kevin Ingersoll <[email protected]>
1 parent 5fa416e commit 7f1b769

File tree

6 files changed

+168
-52
lines changed

6 files changed

+168
-52
lines changed

.changeset/beige-kings-eat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@latticexyz/explorer": patch
3+
---
4+
5+
Address input fields in the "Interact" tab now accept ENS names, which are automatically resolved to their underlying address.

packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/explore/utils/encodeFunctionArgs.ts

Lines changed: 0 additions & 10 deletions
This file was deleted.

packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/content/FunctionField.tsx

Lines changed: 15 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ import { ScrollIntoViewLink } from "../../../../../components/ScrollIntoViewLink
2929
import { useChain } from "../../../../../hooks/useChain";
3030
import { blockExplorerTransactionUrl } from "../../../../../utils/blockExplorerTransactionUrl";
3131
import { getFunctionElementId } from "../../../../../utils/getFunctionElementId";
32-
import { encodeFunctionArgs } from "../../explore/utils/encodeFunctionArgs";
32+
import { FunctionInput } from "./FunctionInput";
33+
import { encodeFunctionArgs } from "./encodeFunctionArgs";
3334

3435
export enum FunctionType {
3536
READ,
@@ -51,6 +52,7 @@ type DecodedEvent = {
5152
const formSchema = z.object({
5253
inputs: z.array(z.string()),
5354
value: z.string().optional(),
55+
resolvedAddresses: z.record(z.string().optional()).optional(),
5456
});
5557

5658
const getInputLabel = (input: AbiParameter): string => {
@@ -66,20 +68,9 @@ const getInputLabel = (input: AbiParameter): string => {
6668
return input.type;
6769
};
6870

69-
const getInputPlaceholder = (input: AbiParameter): string => {
70-
if (!("components" in input)) {
71-
return input.type;
72-
}
73-
74-
const componentsString = input.components.map(getInputLabel).join(", ");
75-
if (input.type === "tuple[]") {
76-
return `[${componentsString}][]`;
77-
}
78-
return `[${componentsString}]`;
79-
};
80-
8171
export function FunctionField({ systemId, worldAbi, functionAbi, useSearchParamsArgs }: Props) {
8272
const searchParams = useSearchParams();
73+
const { id: chainId } = useChain();
8374
const publicClient = usePublicClient();
8475
const operationType: FunctionType =
8576
functionAbi.stateMutability === "view" || functionAbi.stateMutability === "pure"
@@ -89,7 +80,6 @@ export function FunctionField({ systemId, worldAbi, functionAbi, useSearchParams
8980
const wagmiConfig = useConfig();
9081
const account = useAccount();
9182
const { worldAddress } = useParams();
92-
const { id: chainId } = useChain();
9383
const [isLoading, setIsLoading] = useState(false);
9484
const [result, setResult] = useState<string>();
9585
const [events, setEvents] = useState<DecodedEvent[]>();
@@ -103,6 +93,7 @@ export function FunctionField({ systemId, worldAbi, functionAbi, useSearchParams
10393
defaultValues: {
10494
inputs: useSearchParamsArgs ? JSON.parse(searchParams.get("args") || "[]") : [],
10595
value: useSearchParamsArgs ? searchParams.get("value") ?? "" : "",
96+
resolvedAddresses: {},
10697
},
10798
});
10899

@@ -150,14 +141,19 @@ export function FunctionField({ systemId, worldAbi, functionAbi, useSearchParams
150141
setIsLoading(true);
151142
let toastId;
152143

144+
const resolvedInputs = values.inputs.map((input, index) => {
145+
const resolvedAddress = form.getValues(`resolvedAddresses.${index}`);
146+
return resolvedAddress || input;
147+
});
148+
153149
try {
154150
if (operationType === FunctionType.READ) {
155151
const { data: result } = await publicClient.call({
156152
account: account.address,
157153
data: encodeFunctionData({
158154
abi: [...worldAbi, functionAbi],
159155
functionName: functionAbi.name,
160-
args: encodeFunctionArgs(values.inputs, functionAbi),
156+
args: encodeFunctionArgs(resolvedInputs, functionAbi),
161157
}),
162158
to: worldAddress as Address,
163159
});
@@ -171,7 +167,7 @@ export function FunctionField({ systemId, worldAbi, functionAbi, useSearchParams
171167
const encoded = encodeSystemCall({
172168
abi: [functionAbi],
173169
functionName: functionAbi.name,
174-
args: encodeFunctionArgs(values.inputs, functionAbi),
170+
args: encodeFunctionArgs(resolvedInputs, functionAbi),
175171
systemId,
176172
});
177173

@@ -188,7 +184,7 @@ export function FunctionField({ systemId, worldAbi, functionAbi, useSearchParams
188184
abi: worldAbi,
189185
address: worldAddress as Address,
190186
functionName: functionAbi.name,
191-
args: encodeFunctionArgs(values.inputs, functionAbi),
187+
args: encodeFunctionArgs(resolvedInputs, functionAbi),
192188
...(values.value && { value: BigInt(values.value) }),
193189
chainId,
194190
});
@@ -225,6 +221,7 @@ export function FunctionField({ systemId, worldAbi, functionAbi, useSearchParams
225221
wagmiConfig,
226222
worldAbi,
227223
worldAddress,
224+
form,
228225
],
229226
);
230227

@@ -259,31 +256,7 @@ export function FunctionField({ systemId, worldAbi, functionAbi, useSearchParams
259256
{functionAbi.inputs.length > 0 && (
260257
<div className="!mt-2 space-y-2">
261258
{functionAbi.inputs.map((input, index) => (
262-
<FormField
263-
key={index}
264-
control={form.control}
265-
name={`inputs.${index}`}
266-
render={({ field }) => (
267-
<FormItem className="flex items-center gap-4 space-y-0">
268-
{input.name && (
269-
<FormLabel className="shrink-0 pt-1 font-mono text-sm opacity-70">{input.name}</FormLabel>
270-
)}
271-
<div className="flex-1">
272-
<FormControl>
273-
<Input
274-
placeholder={getInputPlaceholder(input)}
275-
value={field.value}
276-
onChange={(evt) => {
277-
field.onChange(evt.target.value);
278-
}}
279-
className="font-mono text-sm"
280-
/>
281-
</FormControl>
282-
<FormMessage />
283-
</div>
284-
</FormItem>
285-
)}
286-
/>
259+
<FunctionInput key={index} input={input} index={index} />
287260
))}
288261

289262
{functionAbi.stateMutability === "payable" && (
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { CheckIcon, LoaderIcon } from "lucide-react";
2+
import { AbiParameter, isAddress } from "viem";
3+
import { useCallback, useEffect } from "react";
4+
import { useFormContext } from "react-hook-form";
5+
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "../../../../../../../components/ui/Form";
6+
import { Input } from "../../../../../../../components/ui/Input";
7+
import { useEnsAddress } from "./useEnsAddress";
8+
9+
type Props = {
10+
input: AbiParameter;
11+
index: number;
12+
};
13+
14+
const getInputPlaceholder = (input: AbiParameter): string => {
15+
if (!("components" in input)) {
16+
return input.type;
17+
}
18+
19+
const componentsString = input.components.map((c) => c.name || c.type).join(", ");
20+
if (input.type === "tuple[]") {
21+
return `[${componentsString}][]`;
22+
}
23+
return `[${componentsString}]`;
24+
};
25+
26+
export function FunctionInput({ input, index }: Props) {
27+
const form = useFormContext();
28+
const currentValue = form.watch(`inputs.${index}`);
29+
const resolvedAddress = form.watch(`resolvedAddresses.${index}`);
30+
const { data: ensAddress, isLoading: isEnsAddressLoading, error: ensAddressError } = useEnsAddress(currentValue);
31+
32+
useEffect(() => {
33+
if (ensAddress !== resolvedAddress) {
34+
form.setValue(`resolvedAddresses.${index}`, ensAddress);
35+
}
36+
}, [ensAddress, resolvedAddress, form, index]);
37+
38+
const handleChange = useCallback(
39+
(value: string) => {
40+
if (input.type !== "address") {
41+
form.setValue(`inputs.${index}`, value);
42+
return;
43+
}
44+
45+
if (isAddress(value)) {
46+
form.setValue(`inputs.${index}`, value);
47+
form.setValue(`resolvedAddresses.${index}`, value);
48+
return;
49+
}
50+
51+
form.setValue(`inputs.${index}`, value);
52+
form.setValue(`resolvedAddresses.${index}`, undefined);
53+
},
54+
[form, index, input.type],
55+
);
56+
57+
return (
58+
<FormField
59+
control={form.control}
60+
name={`inputs.${index}`}
61+
render={({ field }) => (
62+
<FormItem className="flex flex-col gap-2">
63+
<div className="flex items-start gap-4">
64+
{input.name && <FormLabel className="shrink-0 pt-2 font-mono text-sm opacity-70">{input.name}</FormLabel>}
65+
<div className="flex-1">
66+
<FormControl>
67+
<Input
68+
placeholder={getInputPlaceholder(input)}
69+
value={field.value}
70+
onChange={(evt) => handleChange(evt.target.value)}
71+
className="font-mono text-sm"
72+
/>
73+
</FormControl>
74+
<FormMessage />
75+
76+
{input.type === "address" && !isAddress(currentValue) && (
77+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
78+
{isEnsAddressLoading && (
79+
<>
80+
<LoaderIcon className="h-3 w-3 animate-spin" />
81+
<span>Resolving ENS name...</span>
82+
</>
83+
)}
84+
{ensAddressError && <span className="text-destructive">Failed to resolve ENS</span>}
85+
{resolvedAddress && !isEnsAddressLoading && (
86+
<span className="flex items-center gap-1 font-mono">
87+
{resolvedAddress} <CheckIcon className="h-4 w-4 text-green-500" />
88+
</span>
89+
)}
90+
</div>
91+
)}
92+
</div>
93+
</div>
94+
</FormItem>
95+
)}
96+
/>
97+
);
98+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { AbiFunction } from "viem";
2+
3+
export function encodeFunctionArgs(args: unknown[], inputs: AbiFunction): unknown[] {
4+
const encodedArgs = args.map((arg, i) => {
5+
const input = inputs.inputs[i];
6+
if (!input) return arg;
7+
8+
if (input.type === "tuple") return JSON.parse(arg as string);
9+
if (input.type === "bool") return arg === "true";
10+
11+
return arg;
12+
});
13+
14+
return encodedArgs;
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { createPublicClient, http } from "viem";
2+
import { getEnsAddress } from "viem/actions";
3+
import { mainnet } from "viem/chains";
4+
import { normalize } from "viem/ens";
5+
import { useQuery } from "@tanstack/react-query";
6+
7+
const mainnetClient = createPublicClient({
8+
chain: mainnet,
9+
transport: http(),
10+
});
11+
12+
function isValidEnsName(name: string): boolean {
13+
return name.includes(".");
14+
}
15+
16+
// This is a workaround implementation because wagmi's useEnsAddress hook
17+
// requires configuring mainnet in the wagmi config.
18+
export function useEnsAddress(name: string) {
19+
return useQuery({
20+
queryKey: ["ensAddress", name],
21+
queryFn: async () => {
22+
const normalizedName = normalize(name);
23+
const address = await getEnsAddress(mainnetClient, {
24+
name: normalizedName,
25+
});
26+
27+
if (!address) {
28+
throw new Error("Invalid ENS name");
29+
}
30+
31+
return address;
32+
},
33+
enabled: isValidEnsName(name),
34+
});
35+
}

0 commit comments

Comments
 (0)