diff --git a/.changeset/fresh-chairs-obey.md b/.changeset/fresh-chairs-obey.md new file mode 100644 index 0000000000..133f5f2727 --- /dev/null +++ b/.changeset/fresh-chairs-obey.md @@ -0,0 +1,5 @@ +--- +"@latticexyz/explorer": patch +--- + +The functions in the Interact tab now display the emitted logs with the block explorer URL for the submitted transaction. diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/FunctionField.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/FunctionField.tsx index 5c99e7522a..4b429cd10b 100644 --- a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/FunctionField.tsx +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/FunctionField.tsx @@ -1,8 +1,12 @@ "use client"; -import { Coins, Eye, Send } from "lucide-react"; -import { Abi, AbiFunction } from "viem"; -import { useAccount } from "wagmi"; +import { Coins, ExternalLinkIcon, Eye, LoaderIcon, Send } from "lucide-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { toast } from "sonner"; +import { Abi, AbiFunction, Address, Hex, decodeEventLog } from "viem"; +import { useAccount, useConfig } from "wagmi"; +import { readContract, waitForTransactionReceipt, writeContract } from "wagmi/actions"; import { z } from "zod"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -12,7 +16,8 @@ import { Button } from "../../../../../../components/ui/Button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "../../../../../../components/ui/Form"; import { Input } from "../../../../../../components/ui/Input"; import { Separator } from "../../../../../../components/ui/Separator"; -import { useContractMutation } from "./useContractMutation"; +import { useChain } from "../../../../hooks/useChain"; +import { blockExplorerTransactionUrl } from "../../../../utils/blockExplorerTransactionUrl"; export enum FunctionType { READ, @@ -24,6 +29,11 @@ type Props = { functionAbi: AbiFunction; }; +type DecodedEvent = { + eventName: string | undefined; + args: readonly unknown[] | undefined; +}; + const formSchema = z.object({ inputs: z.array(z.string()), value: z.string().optional(), @@ -34,10 +44,16 @@ export function FunctionField({ worldAbi, functionAbi }: Props) { functionAbi.stateMutability === "view" || functionAbi.stateMutability === "pure" ? FunctionType.READ : FunctionType.WRITE; - const [result, setResult] = useState(null); const { openConnectModal } = useConnectModal(); - const mutation = useContractMutation({ worldAbi, functionAbi, operationType }); + const wagmiConfig = useConfig(); const account = useAccount(); + const { worldAddress } = useParams(); + const { id: chainId } = useChain(); + const [isLoading, setIsLoading] = useState(false); + const [result, setResult] = useState(); + const [events, setEvents] = useState(); + const [txHash, setTxHash] = useState(); + const txUrl = blockExplorerTransactionUrl({ hash: txHash, chainId }); const form = useForm>({ resolver: zodResolver(formSchema), @@ -51,74 +67,142 @@ export function FunctionField({ worldAbi, functionAbi }: Props) { return openConnectModal?.(); } - const mutationResult = await mutation.mutateAsync({ - inputs: values.inputs, - value: values.value, - }); + setIsLoading(true); + let toastId; + try { + if (operationType === FunctionType.READ) { + const result = await readContract(wagmiConfig, { + abi: worldAbi, + address: worldAddress as Address, + functionName: functionAbi.name, + args: values.inputs, + chainId, + }); + + setResult(JSON.stringify(result, null, 2)); + } else { + toastId = toast.loading("Transaction submitted"); + const txHash = await writeContract(wagmiConfig, { + abi: worldAbi, + address: worldAddress as Address, + functionName: functionAbi.name, + args: values.inputs, + ...(values.value && { value: BigInt(values.value) }), + chainId, + }); + setTxHash(txHash); - if (operationType === FunctionType.READ && "result" in mutationResult) { - setResult(JSON.stringify(mutationResult.result, null, 2)); + const receipt = await waitForTransactionReceipt(wagmiConfig, { hash: txHash }); + const events = receipt?.logs.map((log) => decodeEventLog({ ...log, abi: worldAbi })); + setEvents(events); + + toast.success(`Transaction successful with hash: ${txHash}`, { + id: toastId, + }); + } + } catch (error) { + console.error(error); + toast.error((error as Error).message || "Something went wrong. Please try again.", { + id: toastId, + }); + } finally { + setIsLoading(false); } } const inputsLabel = functionAbi?.inputs.map((input) => input.type).join(", "); return ( -
- -

- {functionAbi?.name} - {inputsLabel && ` (${inputsLabel})`} - - {functionAbi.stateMutability === "payable" && } - {(functionAbi.stateMutability === "view" || functionAbi.stateMutability === "pure") && ( - - )} - {functionAbi.stateMutability === "nonpayable" && } - -

- - {functionAbi?.inputs.map((input, index) => ( - ( - - {input.name} - - - - - - )} - /> - ))} - - {functionAbi.stateMutability === "payable" && ( - ( - - ETH value - - - - - - )} - /> - )} - - - - {result &&
{result}
} - - - - +
+
+ +

+ {functionAbi?.name} + {inputsLabel && ` (${inputsLabel})`} + + {functionAbi.stateMutability === "payable" && } + {(functionAbi.stateMutability === "view" || functionAbi.stateMutability === "pure") && ( + + )} + {functionAbi.stateMutability === "nonpayable" && } + +

+ + {functionAbi?.inputs.map((input, index) => ( + ( + + {input.name} + + + + + + )} + /> + ))} + + {functionAbi.stateMutability === "payable" && ( + ( + + ETH value + + + + + + )} + /> + )} + + + + + + {result &&
{result}
} + {events && ( +
+
    + {events.map((event, idx) => ( +
  • + {event.eventName && {event.eventName}:} + {event.args && ( +
      + {Object.entries(event.args).map(([key, value]) => ( +
    • + {key}:{" "} + {String(value)} +
    • + ))} +
    + )} + {idx < events.length - 1 && } +
  • + ))} +
+
+ )} + {txUrl && ( +
+ + View on block explorer + +
+ )} + + +
); } diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/InteractForm.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/InteractForm.tsx index 763a6e04d3..b32e3b38b0 100644 --- a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/InteractForm.tsx +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/InteractForm.tsx @@ -44,7 +44,7 @@ export function InteractForm() { {!isFetched && Array.from({ length: 10 }).map((_, index) => { return ( -
  • +
  • ); diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/useContractMutation.ts b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/useContractMutation.ts deleted file mode 100644 index 9411e055f1..0000000000 --- a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/useContractMutation.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { useParams } from "next/navigation"; -import { toast } from "sonner"; -import { Abi, AbiFunction, Hex } from "viem"; -import { useAccount, useConfig } from "wagmi"; -import { readContract, waitForTransactionReceipt, writeContract } from "wagmi/actions"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useChain } from "../../../../hooks/useChain"; -import { FunctionType } from "./FunctionField"; - -type UseContractMutationProps = { - worldAbi: Abi; - functionAbi: AbiFunction; - operationType: FunctionType; -}; - -export function useContractMutation({ worldAbi, functionAbi, operationType }: UseContractMutationProps) { - const { worldAddress } = useParams(); - const { id: chainId } = useChain(); - const queryClient = useQueryClient(); - const wagmiConfig = useConfig(); - const account = useAccount(); - - return useMutation({ - mutationFn: async ({ inputs, value }: { inputs: unknown[]; value?: string }) => { - if (operationType === FunctionType.READ) { - const result = await readContract(wagmiConfig, { - abi: worldAbi, - address: worldAddress as Hex, - functionName: functionAbi.name, - args: inputs, - chainId, - }); - - return { result }; - } else { - const txHash = await writeContract(wagmiConfig, { - abi: worldAbi, - address: worldAddress as Hex, - functionName: functionAbi.name, - args: inputs, - ...(value && { value: BigInt(value) }), - chainId, - }); - - const receipt = await waitForTransactionReceipt(wagmiConfig, { hash: txHash }); - - return { txHash, receipt }; - } - }, - onMutate: () => { - if (operationType === FunctionType.WRITE) { - const toastId = toast.loading("Transaction submitted"); - return { toastId }; - } - }, - onSuccess: (data, _, context) => { - if (operationType === FunctionType.WRITE && "txHash" in data) { - toast.success(`Transaction successful with hash: ${data.txHash}`, { - id: context?.toastId, - }); - } - - queryClient.invalidateQueries({ - queryKey: [ - "balance", - { - address: account, - chainId, - }, - ], - }); - }, - onError: (error: Error, _, context) => { - console.error("Error:", error); - toast.error(error.message || "Something went wrong. Please try again.", { - id: context?.toastId, - }); - }, - }); -} diff --git a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionTableRow.tsx b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionTableRow.tsx index 8d808b3430..ae6b2c544e 100644 --- a/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionTableRow.tsx +++ b/packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/observe/TransactionTableRow.tsx @@ -35,6 +35,7 @@ export function TransactionTableRow({ row }: { row: Row }) const status = data.status; const logs = data?.logs; const receipt = data?.receipt; + const calls = data?.calls.filter((call) => call.args && call.args.length > 0); return ( <> @@ -78,34 +79,42 @@ export function TransactionTableRow({ row }: { row: Row }) - -
    -

    Inputs

    + {calls.length > 0 ? ( + <> + +
    +

    Inputs

    - {data.calls.length > 0 ? ( -
    - {data.calls.map((call, idx) => ( -
    - {call.functionName}: - {call.args?.map((arg, argIdx) => ( -
    - arg {argIdx + 1}: - - {typeof arg === "object" && arg !== null ? JSON.stringify(arg, null, 2) : String(arg)} - -
    - ))} +
    + {calls.map((call, idx) => { + if (!call.args || call.args.length === 0) { + return null; + } - {call.value && call.value > 0n ? ( -
    value: {formatEther(call.value)} ETH
    - ) : null} -
    - ))} + return ( +
    + {call.functionName}: + {call.args?.map((arg, argIdx) => ( +
    + arg {argIdx + 1}: + + {typeof arg === "object" && arg !== null + ? JSON.stringify(arg, null, 2) + : String(arg)} + +
    + ))} + + {call.value && call.value > 0n ? ( +
    value: {formatEther(call.value)} ETH
    + ) : null} +
    + ); + })} +
    - ) : ( -

    No inputs

    - )} -
    + + ) : null} {data.error ? ( <>