diff --git a/package.json b/package.json index e702e4e..1836547 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "matchstick-as": "0.5.0" }, "dependencies": { + "@apollo/client": "^3.10.1", "@aztec/accounts": "^0.35.1", "@aztec/aztec.js": "^0.35.1", "@aztec/noir-contracts.js": "^0.35.1", @@ -55,6 +56,7 @@ "@types/node": "^20.12.7", "@usedapp/core": "^1.2.13", "ethers": "^6.12.0", + "graphql": "^16.8.1", "react-toastify": "^10.0.5", "typescript": "^5.4.5", "validator": "^13.11.0", diff --git a/packages/nextjs/app/games/_components/QueryGames.tsx b/packages/nextjs/app/games/_components/QueryGames.tsx new file mode 100644 index 0000000..3e1a7ce --- /dev/null +++ b/packages/nextjs/app/games/_components/QueryGames.tsx @@ -0,0 +1,76 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { ApolloClient, InMemoryCache, gql } from "@apollo/client"; + +interface Game { + gameId: string; + players: string[]; + fighterIds: string[]; + state: string; +} + +interface QueryData { + games: Game[]; +} + +interface QueryError { + message: string; +} + +export function QueryGames() { + const [games, setGames] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const APIURL = "https://api.studio.thegraph.com/query/72991/scrollfighter/version/latest"; + + const tokensQuery = gql` + query { + games(first: 5) { + gameId + players + fighterIds + state + } + } + `; + + const client = new ApolloClient({ + uri: APIURL, + cache: new InMemoryCache(), + }); + + useEffect(() => { + client + .query({ + query: tokensQuery, + }) + .then(response => { + setGames(response.data.games); + setLoading(false); + }) + .catch(err => { + console.error("Error fetching data: ", err); + setError(err); + setLoading(false); + }); + }, []); + + if (loading) return
Loading...
; + if (error) return
Error fetching data: {error.message}
; + + return ( +
+

Games

+ {games.map(game => ( +
+

Game ID: {game.gameId}

+

Players: {game.players.join(", ")}

+

Fighter IDs: {game.fighterIds.join(", ")}

+

State: {game.state}

+
+ ))} +
+ ); +} diff --git a/packages/nextjs/app/games/_components/contract/ContractInput.tsx b/packages/nextjs/app/games/_components/contract/ContractInput.tsx new file mode 100644 index 0000000..766431e --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/ContractInput.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { Dispatch, SetStateAction } from "react"; +import { Tuple } from "./Tuple"; +import { TupleArray } from "./TupleArray"; +import { AbiParameter } from "abitype"; +import { + AddressInput, + Bytes32Input, + BytesInput, + InputBase, + IntegerInput, + IntegerVariant, +} from "~~/components/scaffold-eth"; +import { AbiParameterTuple } from "~~/utils/scaffold-eth/contract"; + +type ContractInputProps = { + setForm: Dispatch>>; + form: Record | undefined; + stateObjectKey: string; + paramType: AbiParameter; +}; + +/** + * Generic Input component to handle input's based on their function param type + */ +export const ContractInput = ({ setForm, form, stateObjectKey, paramType }: ContractInputProps) => { + const inputProps = { + name: stateObjectKey, + value: form?.[stateObjectKey], + placeholder: paramType.name ? `${paramType.type} ${paramType.name}` : paramType.type, + onChange: (value: any) => { + setForm(form => ({ ...form, [stateObjectKey]: value })); + }, + }; + + const renderInput = () => { + switch (paramType.type) { + case "address": + return ; + case "bytes32": + return ; + case "bytes": + return ; + case "string": + return ; + case "tuple": + return ( + + ); + default: + // Handling 'int' types and 'tuple[]' types + if (paramType.type.includes("int") && !paramType.type.includes("[")) { + return ; + } else if (paramType.type.startsWith("tuple[")) { + return ( + + ); + } else { + return ; + } + } + }; + + return ( +
+
+ {paramType.name && {paramType.name}} + {paramType.type} +
+ {renderInput()} +
+ ); +}; diff --git a/packages/nextjs/app/games/_components/contract/ContractReadMethods.tsx b/packages/nextjs/app/games/_components/contract/ContractReadMethods.tsx new file mode 100644 index 0000000..f269fa9 --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/ContractReadMethods.tsx @@ -0,0 +1,43 @@ +import { Abi, AbiFunction } from "abitype"; +import { ReadOnlyFunctionForm } from "~~/app/debug/_components/contract"; +import { Contract, ContractName, GenericContract, InheritedFunctions } from "~~/utils/scaffold-eth/contract"; + +export const ContractReadMethods = ({ deployedContractData }: { deployedContractData: Contract }) => { + if (!deployedContractData) { + return null; + } + + const functionsToDisplay = ( + ((deployedContractData.abi || []) as Abi).filter(part => part.type === "function") as AbiFunction[] + ) + .filter(fn => { + const isQueryableWithParams = + (fn.stateMutability === "view" || fn.stateMutability === "pure") && fn.inputs.length > 0; + return isQueryableWithParams; + }) + .map(fn => { + return { + fn, + inheritedFrom: ((deployedContractData as GenericContract)?.inheritedFunctions as InheritedFunctions)?.[fn.name], + }; + }) + .sort((a, b) => (b.inheritedFrom ? b.inheritedFrom.localeCompare(a.inheritedFrom) : 1)); + + if (!functionsToDisplay.length) { + return <>No read methods; + } + + return ( + <> + {functionsToDisplay.map(({ fn, inheritedFrom }) => ( + + ))} + + ); +}; diff --git a/packages/nextjs/app/games/_components/contract/ContractUI.tsx b/packages/nextjs/app/games/_components/contract/ContractUI.tsx new file mode 100644 index 0000000..31fcc7f --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/ContractUI.tsx @@ -0,0 +1,104 @@ +"use client"; + +// @refresh reset +import { useReducer } from "react"; +import { ContractReadMethods } from "./ContractReadMethods"; +import { ContractVariables } from "./ContractVariables"; +import { ContractWriteMethods } from "./ContractWriteMethods"; +import { Address, Balance } from "~~/components/scaffold-eth"; +import { useDeployedContractInfo, useNetworkColor } from "~~/hooks/scaffold-eth"; +import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork"; +import { ContractName } from "~~/utils/scaffold-eth/contract"; + +type ContractUIProps = { + contractName: ContractName; + className?: string; +}; + +/** + * UI component to interface with deployed contracts. + **/ +export const ContractUI = ({ contractName, className = "" }: ContractUIProps) => { + const [refreshDisplayVariables, triggerRefreshDisplayVariables] = useReducer(value => !value, false); + const { targetNetwork } = useTargetNetwork(); + const { data: deployedContractData, isLoading: deployedContractLoading } = useDeployedContractInfo(contractName); + const networkColor = useNetworkColor(); + + if (deployedContractLoading) { + return ( +
+ +
+ ); + } + + if (!deployedContractData) { + return ( +

+ {`No contract found by the name of "${contractName}" on chain "${targetNetwork.name}"!`} +

+ ); + } + + return ( +
+
+
+
+
+
+ {contractName} +
+
+ Balance: + +
+
+
+ {targetNetwork && ( +

+ Network:{" "} + {targetNetwork.name} +

+ )} +
+
+ +
+
+
+
+
+
+
+

Read

+
+
+
+ +
+
+
+
+
+
+
+

Write

+
+
+
+ +
+
+
+
+
+
+ ); +}; diff --git a/packages/nextjs/app/games/_components/contract/ContractVariables.tsx b/packages/nextjs/app/games/_components/contract/ContractVariables.tsx new file mode 100644 index 0000000..9d25782 --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/ContractVariables.tsx @@ -0,0 +1,50 @@ +import { DisplayVariable } from "./DisplayVariable"; +import { Abi, AbiFunction } from "abitype"; +import { Contract, ContractName, GenericContract, InheritedFunctions } from "~~/utils/scaffold-eth/contract"; + +export const ContractVariables = ({ + refreshDisplayVariables, + deployedContractData, +}: { + refreshDisplayVariables: boolean; + deployedContractData: Contract; +}) => { + if (!deployedContractData) { + return null; + } + + const functionsToDisplay = ( + (deployedContractData.abi as Abi).filter(part => part.type === "function") as AbiFunction[] + ) + .filter(fn => { + const isQueryableWithNoParams = + (fn.stateMutability === "view" || fn.stateMutability === "pure") && fn.inputs.length === 0; + return isQueryableWithNoParams; + }) + .map(fn => { + return { + fn, + inheritedFrom: ((deployedContractData as GenericContract)?.inheritedFunctions as InheritedFunctions)?.[fn.name], + }; + }) + .sort((a, b) => (b.inheritedFrom ? b.inheritedFrom.localeCompare(a.inheritedFrom) : 1)); + + if (!functionsToDisplay.length) { + return <>No contract variables; + } + + return ( + <> + {functionsToDisplay.map(({ fn, inheritedFrom }) => ( + + ))} + + ); +}; diff --git a/packages/nextjs/app/games/_components/contract/ContractWriteMethods.tsx b/packages/nextjs/app/games/_components/contract/ContractWriteMethods.tsx new file mode 100644 index 0000000..ee703a6 --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/ContractWriteMethods.tsx @@ -0,0 +1,49 @@ +import { Abi, AbiFunction } from "abitype"; +import { WriteOnlyFunctionForm } from "~~/app/debug/_components/contract"; +import { Contract, ContractName, GenericContract, InheritedFunctions } from "~~/utils/scaffold-eth/contract"; + +export const ContractWriteMethods = ({ + onChange, + deployedContractData, +}: { + onChange: () => void; + deployedContractData: Contract; +}) => { + if (!deployedContractData) { + return null; + } + + const functionsToDisplay = ( + (deployedContractData.abi as Abi).filter(part => part.type === "function") as AbiFunction[] + ) + .filter(fn => { + const isWriteableFunction = fn.stateMutability !== "view" && fn.stateMutability !== "pure"; + return isWriteableFunction; + }) + .map(fn => { + return { + fn, + inheritedFrom: ((deployedContractData as GenericContract)?.inheritedFunctions as InheritedFunctions)?.[fn.name], + }; + }) + .sort((a, b) => (b.inheritedFrom ? b.inheritedFrom.localeCompare(a.inheritedFrom) : 1)); + + if (!functionsToDisplay.length) { + return <>No write methods; + } + + return ( + <> + {functionsToDisplay.map(({ fn, inheritedFrom }, idx) => ( + + ))} + + ); +}; diff --git a/packages/nextjs/app/games/_components/contract/DisplayVariable.tsx b/packages/nextjs/app/games/_components/contract/DisplayVariable.tsx new file mode 100644 index 0000000..bf7fe3f --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/DisplayVariable.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect } from "react"; +import { InheritanceTooltip } from "./InheritanceTooltip"; +import { displayTxResult } from "./utilsDisplay"; +import { Abi, AbiFunction } from "abitype"; +import { Address } from "viem"; +import { useReadContract } from "wagmi"; +import { ArrowPathIcon } from "@heroicons/react/24/outline"; +import { useAnimationConfig } from "~~/hooks/scaffold-eth"; +import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork"; +import { getParsedError, notification } from "~~/utils/scaffold-eth"; + +type DisplayVariableProps = { + contractAddress: Address; + abiFunction: AbiFunction; + refreshDisplayVariables: boolean; + inheritedFrom?: string; + abi: Abi; +}; + +export const DisplayVariable = ({ + contractAddress, + abiFunction, + refreshDisplayVariables, + abi, + inheritedFrom, +}: DisplayVariableProps) => { + const { targetNetwork } = useTargetNetwork(); + + const { + data: result, + isFetching, + refetch, + error, + } = useReadContract({ + address: contractAddress, + functionName: abiFunction.name, + abi: abi, + chainId: targetNetwork.id, + query: { + retry: false, + }, + }); + + const { showAnimation } = useAnimationConfig(result); + + useEffect(() => { + refetch(); + }, [refetch, refreshDisplayVariables]); + + useEffect(() => { + if (error) { + const parsedError = getParsedError(error); + notification.error(parsedError); + } + }, [error]); + + return ( +
+
+

{abiFunction.name}

+ + +
+
+
+
+ {displayTxResult(result)} +
+
+
+
+ ); +}; diff --git a/packages/nextjs/app/games/_components/contract/InheritanceTooltip.tsx b/packages/nextjs/app/games/_components/contract/InheritanceTooltip.tsx new file mode 100644 index 0000000..9825520 --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/InheritanceTooltip.tsx @@ -0,0 +1,14 @@ +import { InformationCircleIcon } from "@heroicons/react/20/solid"; + +export const InheritanceTooltip = ({ inheritedFrom }: { inheritedFrom?: string }) => ( + <> + {inheritedFrom && ( + + + )} + +); diff --git a/packages/nextjs/app/games/_components/contract/ReadOnlyFunctionForm.tsx b/packages/nextjs/app/games/_components/contract/ReadOnlyFunctionForm.tsx new file mode 100644 index 0000000..9ccc37f --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/ReadOnlyFunctionForm.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { InheritanceTooltip } from "./InheritanceTooltip"; +import { Abi, AbiFunction } from "abitype"; +import { Address } from "viem"; +import { useReadContract } from "wagmi"; +import { + ContractInput, + displayTxResult, + getFunctionInputKey, + getInitialFormState, + getParsedContractFunctionArgs, + transformAbiFunction, +} from "~~/app/debug/_components/contract"; +import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork"; +import { getParsedError, notification } from "~~/utils/scaffold-eth"; + +type ReadOnlyFunctionFormProps = { + contractAddress: Address; + abiFunction: AbiFunction; + inheritedFrom?: string; + abi: Abi; +}; + +export const ReadOnlyFunctionForm = ({ + contractAddress, + abiFunction, + inheritedFrom, + abi, +}: ReadOnlyFunctionFormProps) => { + const [form, setForm] = useState>(() => getInitialFormState(abiFunction)); + const [result, setResult] = useState(); + const { targetNetwork } = useTargetNetwork(); + + const { isFetching, refetch, error } = useReadContract({ + address: contractAddress, + functionName: abiFunction.name, + abi: abi, + args: getParsedContractFunctionArgs(form), + chainId: targetNetwork.id, + query: { + enabled: false, + retry: false, + }, + }); + + useEffect(() => { + if (error) { + const parsedError = getParsedError(error); + notification.error(parsedError); + } + }, [error]); + + const transformedFunction = transformAbiFunction(abiFunction); + const inputElements = transformedFunction.inputs.map((input, inputIndex) => { + const key = getFunctionInputKey(abiFunction.name, input, inputIndex); + return ( + { + setResult(undefined); + setForm(updatedFormValue); + }} + form={form} + stateObjectKey={key} + paramType={input} + /> + ); + }); + + return ( +
+

+ {abiFunction.name} + +

+ {inputElements} +
+
+ {result !== null && result !== undefined && ( +
+

Result:

+
{displayTxResult(result)}
+
+ )} +
+ +
+
+ ); +}; diff --git a/packages/nextjs/app/games/_components/contract/Tuple.tsx b/packages/nextjs/app/games/_components/contract/Tuple.tsx new file mode 100644 index 0000000..0e3175d --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/Tuple.tsx @@ -0,0 +1,44 @@ +import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { ContractInput } from "./ContractInput"; +import { getFunctionInputKey, getInitalTupleFormState } from "./utilsContract"; +import { replacer } from "~~/utils/scaffold-eth/common"; +import { AbiParameterTuple } from "~~/utils/scaffold-eth/contract"; + +type TupleProps = { + abiTupleParameter: AbiParameterTuple; + setParentForm: Dispatch>>; + parentStateObjectKey: string; + parentForm: Record | undefined; +}; + +export const Tuple = ({ abiTupleParameter, setParentForm, parentStateObjectKey }: TupleProps) => { + const [form, setForm] = useState>(() => getInitalTupleFormState(abiTupleParameter)); + + useEffect(() => { + const values = Object.values(form); + const argsStruct: Record = {}; + abiTupleParameter.components.forEach((component, componentIndex) => { + argsStruct[component.name || `input_${componentIndex}_`] = values[componentIndex]; + }); + + setParentForm(parentForm => ({ ...parentForm, [parentStateObjectKey]: JSON.stringify(argsStruct, replacer) })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(form, replacer)]); + + return ( +
+
+ +
+

{abiTupleParameter.internalType}

+
+
+ {abiTupleParameter?.components?.map((param, index) => { + const key = getFunctionInputKey(abiTupleParameter.name || "tuple", param, index); + return ; + })} +
+
+
+ ); +}; diff --git a/packages/nextjs/app/games/_components/contract/TupleArray.tsx b/packages/nextjs/app/games/_components/contract/TupleArray.tsx new file mode 100644 index 0000000..1eb23c2 --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/TupleArray.tsx @@ -0,0 +1,139 @@ +import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { ContractInput } from "./ContractInput"; +import { getFunctionInputKey, getInitalTupleArrayFormState } from "./utilsContract"; +import { replacer } from "~~/utils/scaffold-eth/common"; +import { AbiParameterTuple } from "~~/utils/scaffold-eth/contract"; + +type TupleArrayProps = { + abiTupleParameter: AbiParameterTuple & { isVirtual?: true }; + setParentForm: Dispatch>>; + parentStateObjectKey: string; + parentForm: Record | undefined; +}; + +export const TupleArray = ({ abiTupleParameter, setParentForm, parentStateObjectKey }: TupleArrayProps) => { + const [form, setForm] = useState>(() => getInitalTupleArrayFormState(abiTupleParameter)); + const [additionalInputs, setAdditionalInputs] = useState>([ + abiTupleParameter.components, + ]); + + const depth = (abiTupleParameter.type.match(/\[\]/g) || []).length; + + useEffect(() => { + // Extract and group fields based on index prefix + const groupedFields = Object.keys(form).reduce((acc, key) => { + const [indexPrefix, ...restArray] = key.split("_"); + const componentName = restArray.join("_"); + if (!acc[indexPrefix]) { + acc[indexPrefix] = {}; + } + acc[indexPrefix][componentName] = form[key]; + return acc; + }, {} as Record>); + + let argsArray: Array> = []; + + Object.keys(groupedFields).forEach(key => { + const currentKeyValues = Object.values(groupedFields[key]); + + const argsStruct: Record = {}; + abiTupleParameter.components.forEach((component, componentIndex) => { + argsStruct[component.name || `input_${componentIndex}_`] = currentKeyValues[componentIndex]; + }); + + argsArray.push(argsStruct); + }); + + if (depth > 1) { + argsArray = argsArray.map(args => { + return args[abiTupleParameter.components[0].name || "tuple"]; + }); + } + + setParentForm(parentForm => { + return { ...parentForm, [parentStateObjectKey]: JSON.stringify(argsArray, replacer) }; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(form, replacer)]); + + const addInput = () => { + setAdditionalInputs(previousValue => { + const newAdditionalInputs = [...previousValue, abiTupleParameter.components]; + + // Add the new inputs to the form + setForm(form => { + const newForm = { ...form }; + abiTupleParameter.components.forEach((component, componentIndex) => { + const key = getFunctionInputKey( + `${newAdditionalInputs.length - 1}_${abiTupleParameter.name || "tuple"}`, + component, + componentIndex, + ); + newForm[key] = ""; + }); + return newForm; + }); + + return newAdditionalInputs; + }); + }; + + const removeInput = () => { + // Remove the last inputs from the form + setForm(form => { + const newForm = { ...form }; + abiTupleParameter.components.forEach((component, componentIndex) => { + const key = getFunctionInputKey( + `${additionalInputs.length - 1}_${abiTupleParameter.name || "tuple"}`, + component, + componentIndex, + ); + delete newForm[key]; + }); + return newForm; + }); + setAdditionalInputs(inputs => inputs.slice(0, -1)); + }; + + return ( +
+
+ +
+

{abiTupleParameter.internalType}

+
+
+ {additionalInputs.map((additionalInput, additionalIndex) => ( +
+ + {depth > 1 ? `${additionalIndex}` : `tuple[${additionalIndex}]`} + +
+ {additionalInput.map((param, index) => { + const key = getFunctionInputKey( + `${additionalIndex}_${abiTupleParameter.name || "tuple"}`, + param, + index, + ); + return ( + + ); + })} +
+
+ ))} +
+ + {additionalInputs.length > 0 && ( + + )} +
+
+
+
+ ); +}; diff --git a/packages/nextjs/app/games/_components/contract/TxReceipt.tsx b/packages/nextjs/app/games/_components/contract/TxReceipt.tsx new file mode 100644 index 0000000..87e74f5 --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/TxReceipt.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import { CopyToClipboard } from "react-copy-to-clipboard"; +import { TransactionReceipt } from "viem"; +import { CheckCircleIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline"; +import { displayTxResult } from "~~/app/debug/_components/contract"; + +export const TxReceipt = ( + txResult: string | number | bigint | Record | TransactionReceipt | undefined, +) => { + const [txResultCopied, setTxResultCopied] = useState(false); + + return ( +
+
+ {txResultCopied ? ( +
+
+ +
+ Transaction Receipt +
+
+
{displayTxResult(txResult)}
+
+
+
+ ); +}; diff --git a/packages/nextjs/app/games/_components/contract/WriteOnlyFunctionForm.tsx b/packages/nextjs/app/games/_components/contract/WriteOnlyFunctionForm.tsx new file mode 100644 index 0000000..b8e8f84 --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/WriteOnlyFunctionForm.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { InheritanceTooltip } from "./InheritanceTooltip"; +import { Abi, AbiFunction } from "abitype"; +import { Address, TransactionReceipt } from "viem"; +import { useAccount, useWaitForTransactionReceipt, useWriteContract } from "wagmi"; +import { + ContractInput, + TxReceipt, + getFunctionInputKey, + getInitialFormState, + getParsedContractFunctionArgs, + transformAbiFunction, +} from "~~/app/debug/_components/contract"; +import { IntegerInput } from "~~/components/scaffold-eth"; +import { useTransactor } from "~~/hooks/scaffold-eth"; +import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork"; + +type WriteOnlyFunctionFormProps = { + abi: Abi; + abiFunction: AbiFunction; + onChange: () => void; + contractAddress: Address; + inheritedFrom?: string; +}; + +export const WriteOnlyFunctionForm = ({ + abi, + abiFunction, + onChange, + contractAddress, + inheritedFrom, +}: WriteOnlyFunctionFormProps) => { + const [form, setForm] = useState>(() => getInitialFormState(abiFunction)); + const [txValue, setTxValue] = useState(""); + const { chain } = useAccount(); + const writeTxn = useTransactor(); + const { targetNetwork } = useTargetNetwork(); + const writeDisabled = !chain || chain?.id !== targetNetwork.id; + + const { data: result, isPending, writeContractAsync } = useWriteContract(); + + const handleWrite = async () => { + if (writeContractAsync) { + try { + const makeWriteWithParams = () => + writeContractAsync({ + address: contractAddress, + functionName: abiFunction.name, + abi: abi, + args: getParsedContractFunctionArgs(form), + value: BigInt(txValue), + }); + await writeTxn(makeWriteWithParams); + onChange(); + } catch (e: any) { + console.error("⚡️ ~ file: WriteOnlyFunctionForm.tsx:handleWrite ~ error", e); + } + } + }; + + const [displayedTxResult, setDisplayedTxResult] = useState(); + const { data: txResult } = useWaitForTransactionReceipt({ + hash: result, + }); + useEffect(() => { + setDisplayedTxResult(txResult); + }, [txResult]); + + // TODO use `useMemo` to optimize also update in ReadOnlyFunctionForm + const transformedFunction = transformAbiFunction(abiFunction); + const inputs = transformedFunction.inputs.map((input, inputIndex) => { + const key = getFunctionInputKey(abiFunction.name, input, inputIndex); + return ( + { + setDisplayedTxResult(undefined); + setForm(updatedFormValue); + }} + form={form} + stateObjectKey={key} + paramType={input} + /> + ); + }); + const zeroInputs = inputs.length === 0 && abiFunction.stateMutability !== "payable"; + + return ( +
+
+

+ {abiFunction.name} + +

+ {inputs} + {abiFunction.stateMutability === "payable" ? ( +
+
+ payable value + wei +
+ { + setDisplayedTxResult(undefined); + setTxValue(updatedTxValue); + }} + placeholder="value (wei)" + /> +
+ ) : null} +
+ {!zeroInputs && ( +
+ {displayedTxResult ? : null} +
+ )} +
+ +
+
+
+ {zeroInputs && txResult ? ( +
+ +
+ ) : null} +
+ ); +}; diff --git a/packages/nextjs/app/games/_components/contract/index.tsx b/packages/nextjs/app/games/_components/contract/index.tsx new file mode 100644 index 0000000..83833d8 --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/index.tsx @@ -0,0 +1,8 @@ +export * from "./ContractInput"; +export * from "./ContractUI"; +export * from "./DisplayVariable"; +export * from "./ReadOnlyFunctionForm"; +export * from "./TxReceipt"; +export * from "./utilsContract"; +export * from "./utilsDisplay"; +export * from "./WriteOnlyFunctionForm"; diff --git a/packages/nextjs/app/games/_components/contract/utilsContract.tsx b/packages/nextjs/app/games/_components/contract/utilsContract.tsx new file mode 100644 index 0000000..023efe8 --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/utilsContract.tsx @@ -0,0 +1,149 @@ +import { AbiFunction, AbiParameter } from "abitype"; +import { AbiParameterTuple } from "~~/utils/scaffold-eth/contract"; + +/** + * Generates a key based on function metadata + */ +const getFunctionInputKey = (functionName: string, input: AbiParameter, inputIndex: number): string => { + const name = input?.name || `input_${inputIndex}_`; + return functionName + "_" + name + "_" + input.internalType + "_" + input.type; +}; + +const isJsonString = (str: string) => { + try { + JSON.parse(str); + return true; + } catch (e) { + return false; + } +}; + +// Recursive function to deeply parse JSON strings, correctly handling nested arrays and encoded JSON strings +const deepParseValues = (value: any): any => { + if (typeof value === "string") { + if (isJsonString(value)) { + const parsed = JSON.parse(value); + return deepParseValues(parsed); + } else { + // It's a string but not a JSON string, return as is + return value; + } + } else if (Array.isArray(value)) { + // If it's an array, recursively parse each element + return value.map(element => deepParseValues(element)); + } else if (typeof value === "object" && value !== null) { + // If it's an object, recursively parse each value + return Object.entries(value).reduce((acc: any, [key, val]) => { + acc[key] = deepParseValues(val); + return acc; + }, {}); + } + + // Handle boolean values represented as strings + if (value === "true" || value === "1" || value === "0x1" || value === "0x01" || value === "0x0001") { + return true; + } else if (value === "false" || value === "0" || value === "0x0" || value === "0x00" || value === "0x0000") { + return false; + } + + return value; +}; + +/** + * parses form input with array support + */ +const getParsedContractFunctionArgs = (form: Record) => { + return Object.keys(form).map(key => { + const valueOfArg = form[key]; + + // Attempt to deeply parse JSON strings + return deepParseValues(valueOfArg); + }); +}; + +const getInitialFormState = (abiFunction: AbiFunction) => { + const initialForm: Record = {}; + if (!abiFunction.inputs) return initialForm; + abiFunction.inputs.forEach((input, inputIndex) => { + const key = getFunctionInputKey(abiFunction.name, input, inputIndex); + initialForm[key] = ""; + }); + return initialForm; +}; + +const getInitalTupleFormState = (abiTupleParameter: AbiParameterTuple) => { + const initialForm: Record = {}; + if (abiTupleParameter.components.length === 0) return initialForm; + + abiTupleParameter.components.forEach((component, componentIndex) => { + const key = getFunctionInputKey(abiTupleParameter.name || "tuple", component, componentIndex); + initialForm[key] = ""; + }); + return initialForm; +}; + +const getInitalTupleArrayFormState = (abiTupleParameter: AbiParameterTuple) => { + const initialForm: Record = {}; + if (abiTupleParameter.components.length === 0) return initialForm; + abiTupleParameter.components.forEach((component, componentIndex) => { + const key = getFunctionInputKey("0_" + abiTupleParameter.name || "tuple", component, componentIndex); + initialForm[key] = ""; + }); + return initialForm; +}; + +const adjustInput = (input: AbiParameterTuple): AbiParameter => { + if (input.type.startsWith("tuple[")) { + const depth = (input.type.match(/\[\]/g) || []).length; + return { + ...input, + components: transformComponents(input.components, depth, { + internalType: input.internalType || "struct", + name: input.name, + }), + }; + } else if (input.components) { + return { + ...input, + components: input.components.map(value => adjustInput(value as AbiParameterTuple)), + }; + } + return input; +}; + +const transformComponents = ( + components: readonly AbiParameter[], + depth: number, + parentComponentData: { internalType?: string; name?: string }, +): AbiParameter[] => { + // Base case: if depth is 1 or no components, return the original components + if (depth === 1 || !components) { + return [...components]; + } + + // Recursive case: wrap components in an additional tuple layer + const wrappedComponents: AbiParameter = { + internalType: `${parentComponentData.internalType || "struct"}`.replace(/\[\]/g, "") + "[]".repeat(depth - 1), + name: `${parentComponentData.name || "tuple"}`, + type: `tuple${"[]".repeat(depth - 1)}`, + components: transformComponents(components, depth - 1, parentComponentData), + }; + + return [wrappedComponents]; +}; + +const transformAbiFunction = (abiFunction: AbiFunction): AbiFunction => { + return { + ...abiFunction, + inputs: abiFunction.inputs.map(value => adjustInput(value as AbiParameterTuple)), + }; +}; + +export { + getFunctionInputKey, + getInitialFormState, + getParsedContractFunctionArgs, + getInitalTupleFormState, + getInitalTupleArrayFormState, + transformAbiFunction, +}; diff --git a/packages/nextjs/app/games/_components/contract/utilsDisplay.tsx b/packages/nextjs/app/games/_components/contract/utilsDisplay.tsx new file mode 100644 index 0000000..f5d2129 --- /dev/null +++ b/packages/nextjs/app/games/_components/contract/utilsDisplay.tsx @@ -0,0 +1,56 @@ +import { ReactElement } from "react"; +import { TransactionBase, TransactionReceipt, formatEther, isAddress } from "viem"; +import { Address } from "~~/components/scaffold-eth"; +import { replacer } from "~~/utils/scaffold-eth/common"; + +type DisplayContent = + | string + | number + | bigint + | Record + | TransactionBase + | TransactionReceipt + | undefined + | unknown; + +export const displayTxResult = ( + displayContent: DisplayContent | DisplayContent[], + asText = false, +): string | ReactElement | number => { + if (displayContent == null) { + return ""; + } + + if (typeof displayContent === "bigint") { + try { + const asNumber = Number(displayContent); + if (asNumber <= Number.MAX_SAFE_INTEGER && asNumber >= Number.MIN_SAFE_INTEGER) { + return asNumber; + } else { + return "Ξ" + formatEther(displayContent); + } + } catch (e) { + return "Ξ" + formatEther(displayContent); + } + } + + if (typeof displayContent === "string" && isAddress(displayContent)) { + return asText ? displayContent :
; + } + + if (Array.isArray(displayContent)) { + const mostReadable = (v: DisplayContent) => + ["number", "boolean"].includes(typeof v) ? v : displayTxResultAsText(v); + const displayable = JSON.stringify(displayContent.map(mostReadable), replacer); + + return asText ? ( + displayable + ) : ( + {displayable.replaceAll(",", ",\n")} + ); + } + + return JSON.stringify(displayContent, replacer, 2); +}; + +const displayTxResultAsText = (displayContent: DisplayContent) => displayTxResult(displayContent, true); diff --git a/packages/nextjs/app/games/page.tsx b/packages/nextjs/app/games/page.tsx new file mode 100644 index 0000000..53fc374 --- /dev/null +++ b/packages/nextjs/app/games/page.tsx @@ -0,0 +1,23 @@ +import { QueryGames } from "./_components/QueryGames"; +import type { NextPage } from "next"; +import { getMetadata } from "~~/utils/scaffold-eth/getMetadata"; + +export const metadata = getMetadata({ + title: "Debug Contracts", + description: "Debug your deployed 🏗 Scaffold-ETH 2 contracts in an easy way", +}); + +const Debug: NextPage = () => { + return ( + <> +
+

Games overview

+

+ +

+
+ + ); +}; + +export default Debug; diff --git a/yarn.lock b/yarn.lock index cd64bca..065c317 100644 --- a/yarn.lock +++ b/yarn.lock @@ -50,6 +50,43 @@ __metadata: languageName: node linkType: hard +"@apollo/client@npm:^3.10.1": + version: 3.10.1 + resolution: "@apollo/client@npm:3.10.1" + dependencies: + "@graphql-typed-document-node/core": ^3.1.1 + "@wry/caches": ^1.0.0 + "@wry/equality": ^0.5.6 + "@wry/trie": ^0.5.0 + graphql-tag: ^2.12.6 + hoist-non-react-statics: ^3.3.2 + optimism: ^0.18.0 + prop-types: ^15.7.2 + rehackt: ^0.1.0 + response-iterator: ^0.2.6 + symbol-observable: ^4.0.0 + ts-invariant: ^0.10.3 + tslib: ^2.3.0 + zen-observable-ts: ^1.2.5 + peerDependencies: + graphql: ^15.0.0 || ^16.0.0 + graphql-ws: ^5.5.5 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + subscriptions-transport-ws: ^0.9.0 || ^0.11.0 + peerDependenciesMeta: + graphql-ws: + optional: true + react: + optional: true + react-dom: + optional: true + subscriptions-transport-ws: + optional: true + checksum: d54adac095ad182b863d3f899d68de13eb9b0da2c77cbfb3a8be8aad8aa2a1fe83ce69dfd036d015a2d68f8f8422d7dc1d90d78a4267377030207cbfe74e7781 + languageName: node + linkType: hard + "@ardatan/relay-compiler@npm:12.0.0": version: 12.0.0 resolution: "@ardatan/relay-compiler@npm:12.0.0" @@ -6772,6 +6809,51 @@ __metadata: languageName: node linkType: hard +"@wry/caches@npm:^1.0.0": + version: 1.0.1 + resolution: "@wry/caches@npm:1.0.1" + dependencies: + tslib: ^2.3.0 + checksum: 9e89aa8e9e08577b2e4acbe805f406b141ae49c2ac4a2e22acf21fbee68339fa0550e0dee28cf2158799f35bb812326e80212e49e2afd169f39f02ad56ae4ef4 + languageName: node + linkType: hard + +"@wry/context@npm:^0.7.0": + version: 0.7.4 + resolution: "@wry/context@npm:0.7.4" + dependencies: + tslib: ^2.3.0 + checksum: 9bc8c30a31f9c7d36b616e89daa9280c03d196576a4f9fef800e9bd5de9434ba70216322faeeacc7ef1ab95f59185599d702538114045df729a5ceea50aef4e2 + languageName: node + linkType: hard + +"@wry/equality@npm:^0.5.6": + version: 0.5.7 + resolution: "@wry/equality@npm:0.5.7" + dependencies: + tslib: ^2.3.0 + checksum: 892f262fae362df80f199b12658ea6966949539d4a3a50c1acf00d94a367d673a38f8efa1abcb726ae9e5cc5e62fce50c540c70f797b7c8a2c4308b401dfd903 + languageName: node + linkType: hard + +"@wry/trie@npm:^0.4.3": + version: 0.4.3 + resolution: "@wry/trie@npm:0.4.3" + dependencies: + tslib: ^2.3.0 + checksum: 106e021125cfafd22250a6631a0438a6a3debae7bd73f6db87fe42aa0757fe67693db0dfbe200ae1f60ba608c3e09ddb8a4e2b3527d56ed0a7e02aa0ee4c94e1 + languageName: node + linkType: hard + +"@wry/trie@npm:^0.5.0": + version: 0.5.0 + resolution: "@wry/trie@npm:0.5.0" + dependencies: + tslib: ^2.3.0 + checksum: 92aeea34152bd8485184236fe328d3d05fc98ee3b431d82ee60cf3584dbf68155419c3d65d0ff3731b204ee79c149440a9b7672784a545afddc8d4342fbf21c9 + languageName: node + linkType: hard + "JSONStream@npm:1.3.2": version: 1.3.2 resolution: "JSONStream@npm:1.3.2" @@ -12470,7 +12552,7 @@ __metadata: languageName: node linkType: hard -"graphql-tag@npm:^2.11.0": +"graphql-tag@npm:^2.11.0, graphql-tag@npm:^2.12.6": version: 2.12.6 resolution: "graphql-tag@npm:2.12.6" dependencies: @@ -12539,7 +12621,7 @@ __metadata: languageName: node linkType: hard -"graphql@npm:^16.6.0": +"graphql@npm:^16.6.0, graphql@npm:^16.8.1": version: 16.8.1 resolution: "graphql@npm:16.8.1" checksum: 8d304b7b6f708c8c5cc164b06e92467dfe36aff6d4f2cf31dd19c4c2905a0e7b89edac4b7e225871131fd24e21460836b369de0c06532644d15b461d55b1ccc0 @@ -12895,7 +12977,7 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.3.1": +"hoist-non-react-statics@npm:^3.3.1, hoist-non-react-statics@npm:^3.3.2": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" dependencies: @@ -16628,6 +16710,18 @@ __metadata: languageName: node linkType: hard +"optimism@npm:^0.18.0": + version: 0.18.0 + resolution: "optimism@npm:0.18.0" + dependencies: + "@wry/caches": ^1.0.0 + "@wry/context": ^0.7.0 + "@wry/trie": ^0.4.3 + tslib: ^2.3.0 + checksum: d6ed6a90b05ee886dadfe556c7a30227c66843f51278e51eb843977a6a9368b6c50297fcc63fa514f53d8a5a58f8ddc8049c2356bd4ffac32f8961bcb806254d + languageName: node + linkType: hard + "optionator@npm:^0.8.1": version: 0.8.3 resolution: "optionator@npm:0.8.3" @@ -17436,7 +17530,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.8.1": +"prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -18024,6 +18118,21 @@ __metadata: languageName: node linkType: hard +"rehackt@npm:^0.1.0": + version: 0.1.0 + resolution: "rehackt@npm:0.1.0" + peerDependencies: + "@types/react": "*" + react: "*" + peerDependenciesMeta: + "@types/react": + optional: true + react: + optional: true + checksum: 2c3bcd72524bf47672640265e79cba785e0e6837b9b385ccb0a3ea7d00f55a439d9aed3e0ae71e991d88e0d4b2b3158457c92e75fff5ebf99cd46e280068ddeb + languageName: node + linkType: hard + "relay-runtime@npm:12.0.0": version: 12.0.0 resolution: "relay-runtime@npm:12.0.0" @@ -18271,6 +18380,13 @@ __metadata: languageName: node linkType: hard +"response-iterator@npm:^0.2.6": + version: 0.2.6 + resolution: "response-iterator@npm:0.2.6" + checksum: b0db3c0665a0d698d65512951de9623c086b9c84ce015a76076d4bd0bf733779601d0b41f0931d16ae38132fba29e1ce291c1f8e6550fc32daaa2dc3ab4f338d + languageName: node + linkType: hard + "restore-cursor@npm:^3.1.0": version: 3.1.0 resolution: "restore-cursor@npm:3.1.0" @@ -18526,6 +18642,7 @@ __metadata: version: 0.0.0-use.local resolution: "se-2@workspace:." dependencies: + "@apollo/client": ^3.10.1 "@aztec/accounts": ^0.35.1 "@aztec/aztec.js": ^0.35.1 "@aztec/noir-contracts.js": ^0.35.1 @@ -18540,6 +18657,7 @@ __metadata: "@types/web3": ^1.2.2 "@usedapp/core": ^1.2.13 ethers: ^6.12.0 + graphql: ^16.8.1 husky: ^8.0.1 lint-staged: ^13.0.3 matchstick-as: 0.5.0 @@ -19527,6 +19645,13 @@ __metadata: languageName: node linkType: hard +"symbol-observable@npm:^4.0.0": + version: 4.0.0 + resolution: "symbol-observable@npm:4.0.0" + checksum: 212c7edce6186634d671336a88c0e0bbd626c2ab51ed57498dc90698cce541839a261b969c2a1e8dd43762133d47672e8b62e0b1ce9cf4157934ba45fd172ba8 + languageName: node + linkType: hard + "sync-request@npm:6.1.0, sync-request@npm:^6.0.0": version: 6.1.0 resolution: "sync-request@npm:6.1.0" @@ -19942,6 +20067,15 @@ __metadata: languageName: node linkType: hard +"ts-invariant@npm:^0.10.3": + version: 0.10.3 + resolution: "ts-invariant@npm:0.10.3" + dependencies: + tslib: ^2.1.0 + checksum: bb07d56fe4aae69d8860e0301dfdee2d375281159054bc24bf1e49e513fb0835bf7f70a11351344d213a79199c5e695f37ebbf5a447188a377ce0cd81d91ddb5 + languageName: node + linkType: hard + "ts-morph@npm:12.0.0": version: 12.0.0 resolution: "ts-morph@npm:12.0.0" @@ -20041,7 +20175,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.5.0, tslib@npm:^2.5.2, tslib@npm:^2.6.1, tslib@npm:^2.6.2, tslib@npm:~2.6.0": +"tslib@npm:^2.0.0, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.5.0, tslib@npm:^2.5.2, tslib@npm:^2.6.1, tslib@npm:^2.6.2, tslib@npm:~2.6.0": version: 2.6.2 resolution: "tslib@npm:2.6.2" checksum: 329ea56123005922f39642318e3d1f0f8265d1e7fcb92c633e0809521da75eeaca28d2cf96d7248229deb40e5c19adf408259f4b9640afd20d13aecc1430f3ad @@ -21924,6 +22058,22 @@ __metadata: languageName: node linkType: hard +"zen-observable-ts@npm:^1.2.5": + version: 1.2.5 + resolution: "zen-observable-ts@npm:1.2.5" + dependencies: + zen-observable: 0.8.15 + checksum: 3b707b7a0239a9bc40f73ba71b27733a689a957c1f364fabb9fa9cbd7d04b7c2faf0d517bf17004e3ed3f4330ac613e84c0d32313e450ddaa046f3350af44541 + languageName: node + linkType: hard + +"zen-observable@npm:0.8.15": + version: 0.8.15 + resolution: "zen-observable@npm:0.8.15" + checksum: b7289084bc1fc74a559b7259faa23d3214b14b538a8843d2b001a35e27147833f4107590b1b44bf5bc7f6dfe6f488660d3a3725f268e09b3925b3476153b7821 + languageName: node + linkType: hard + "zksync-web3@npm:^0.14.3": version: 0.14.4 resolution: "zksync-web3@npm:0.14.4"