diff --git a/components/actions/action.tsx b/components/actions/action.tsx index 8549f4d7..ac4b8fdc 100644 --- a/components/actions/action.tsx +++ b/components/actions/action.tsx @@ -7,14 +7,15 @@ import { Action } from "@/utils/types"; import { useAction } from "@/hooks/useAction"; import { AbiFunction, + AbiParameter, Address, Hex, formatEther, toFunctionSignature, - toHex, } from "viem"; import { compactNumber } from "@/utils/numbers"; import { decodeCamelCase } from "@/utils/case"; +import { InputValue } from "@/utils/input-values"; type ActionCardProps = { action: Action; @@ -26,7 +27,9 @@ type CallParameterFieldType = | bigint | Address | Hex - | boolean; + | boolean + | CallParameterFieldType[] + | { [k: string]: CallParameterFieldType }; export const ActionCard = function ({ action, idx }: ActionCardProps) { const { isLoading, args, functionName, functionAbi } = useAction(action); @@ -158,22 +161,47 @@ const CallParameterField = ({ ); }; -function resolveValue(value: CallParameterFieldType, abiType?: string): string { - if (!abiType) return value.toString(); - else if (abiType === "address") { +function resolveValue( + value: CallParameterFieldType, + abi?: AbiParameter +): string { + if (!abi?.type) { + if (Array.isArray(value)) return value.join(", "); return value.toString(); - } else if (abiType === "bytes32") { - return toHex(value); - } else if (abiType.startsWith("uint") || abiType.startsWith("int")) { + } else if (abi.type === "tuple[]") { + const abiClone = Object.assign({}, { ...abi }); + abiClone.type = abiClone.type.replace(/\[\]$/, ""); + + const items = (value as any as any[]).map((item) => + resolveValue(item, abiClone) + ); + return items.join(", "); + } else if (abi.type === "tuple") { + const result = {} as Record; + const components: AbiParameter[] = (abi as any).components || []; + + for (let i = 0; i < components.length; i++) { + const k = components[i].name!; + result[k] = resolveValue((value as any)[k], components[i]); + } + + return getReadableJson(result); + } else if (abi.type.endsWith("[]")) { + return (value as any as any[]).join(", "); + } else if (abi.type === "address") { + return value as string; + } else if (abi.type === "bytes32") { + return value as string; + } else if (abi.type.startsWith("uint") || abi.type.startsWith("int")) { return value.toString(); - } else if (abiType.startsWith("bool")) { + } else if (abi.type.startsWith("bool")) { return value ? "Yes" : "No"; } return value.toString(); @@ -202,3 +230,9 @@ function resolveAddon( } return (idx + 1).toString(); } + +function getReadableJson(value: Record): string { + const items = Object.keys(value).map((k) => k + ": " + value[k]); + + return "{ " + items.join(", ") + " }"; +} diff --git a/components/input/function-call-form.tsx b/components/input/function-call-form.tsx new file mode 100644 index 00000000..2c82d626 --- /dev/null +++ b/components/input/function-call-form.tsx @@ -0,0 +1,70 @@ +import { FC, useState } from "react"; +import { Address, Hex } from "viem"; +import { AlertInline, InputText } from "@aragon/ods"; +import { PleaseWaitSpinner } from "@/components/please-wait"; +import { isAddress } from "@/utils/evm"; +import { Action } from "@/utils/types"; +import { Else, ElseIf, If, Then } from "@/components/if"; +import { useAbi } from "@/hooks/useAbi"; +import { FunctionSelector } from "./function-selector"; + +interface FunctionCallFormProps { + onAddAction: (action: Action) => any; +} +export const FunctionCallForm: FC = ({ + onAddAction, +}) => { + const [targetContract, setTargetContract] = useState(""); + const { abi, isLoading: loadingAbi } = useAbi(targetContract as Address); + + const actionEntered = (data: Hex, value: bigint) => { + onAddAction({ + to: targetContract, + value, + data, + }); + }; + + return ( +
+
+ setTargetContract(e.target.value || "")} + /> +
+ + +
+ +
+
+ +

Enter the address of the contract to interact with

+
+ + + + + + + + + +
+
+ ); +}; diff --git a/components/input/function-encoding-form.tsx b/components/input/function-encoding-form.tsx deleted file mode 100644 index f340581a..00000000 --- a/components/input/function-encoding-form.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { FC, useState } from "react"; -import { Address, Hex, encodeFunctionData } from "viem"; -import { Button, InputText } from "@aragon/ods"; -import { AbiFunction } from "abitype"; -import { PleaseWaitSpinner } from "@/components/please-wait"; -import { isAddress } from "@/utils/evm"; -import { Action } from "@/utils/types"; -import { Else, ElseIf, If, Then } from "@/components/if"; -import { useAbi } from "@/hooks/useAbi"; -import { decodeCamelCase } from "@/utils/case"; -import { useAlertContext } from "@/context/AlertContext"; - -interface FunctionEncodingFormProps { - onAddAction: (action: Action) => any; -} -export const FunctionEncodingForm: FC = ({ - onAddAction, -}) => { - const [targetContract, setTargetContract] = useState(""); - const { abi, isLoading: loadingAbi } = useAbi(targetContract as Address); - - const actionEntered = (data: Hex, value: bigint) => { - onAddAction({ - to: targetContract, - value, - data, - }); - }; - - return ( -
-
- setTargetContract(e.target.value || "")} - /> -
- - -
- -
-
- -

Enter the address of the contract to interact with

-
- -

The address of the contract is not valid

-
- -

The ABI of the contract is not publicly available

-
- - - -
-
- ); -}; - -const FunctionSelector = ({ - abi, - actionEntered, -}: { - abi: AbiFunction[]; - actionEntered: (calldata: Hex, value: bigint) => void; -}) => { - const { addAlert } = useAlertContext(); - const [selectedAbiItem, setSelectedAbiItem] = useState< - AbiFunction | undefined - >(); - const [abiInputValues, setAbiInputValues] = useState([]); - const [value, setValue] = useState(""); - - const onFunctionParameterChange = (idx: number, value: string) => { - const newInputValues = [...abiInputValues]; - newInputValues[idx] = value; - setAbiInputValues(newInputValues); - }; - - const onAddAction = () => { - // Validate params - if (!abi || !selectedAbiItem) return; - - let invalidParams = false; - if (!abi?.length) invalidParams = true; - else if (!selectedAbiItem?.name) invalidParams = true; - else if (selectedAbiItem.inputs.length !== abiInputValues.length) - invalidParams = true; - - for (const i in selectedAbiItem.inputs) { - const item = selectedAbiItem.inputs[i]; - if (hasTypeError(abiInputValues[i], item.type)) { - invalidParams = true; - break; - } - } - invalidParams = invalidParams || !/^[0-9]*$/.test(value); - - if (invalidParams) { - addAlert("Invalid parameters", { - description: "Check that the parameters you entered are correct", - type: "error", - }); - return; - } - - if (["pure", "view"].includes(selectedAbiItem.stateMutability)) { - addAlert("Read only function", { - description: "The action you have added will have no effect", - timeout: 11 * 1000, - }); - } - - try { - const booleanIdxs = selectedAbiItem.inputs - .map((inp, i) => (inp.type === "bool" ? i : -1)) - .filter((v) => v >= 0); - const args: any[] = [].concat(abiInputValues as any) as string[]; - for (const i of booleanIdxs) { - if (["false", "False", "no", "No"].includes(args[i])) args[i] = false; - else args[i] = true; - } - - const data = encodeFunctionData({ - abi, - functionName: selectedAbiItem.name, - args, - }); - actionEntered(data, BigInt(value ?? "0")); - - setAbiInputValues([]); - } catch (err) { - console.error(err); - addAlert("Invalid parameters", { - description: "Check that the parameters you entered are correct", - type: "error", - }); - return; - } - }; - - return ( -
- {/* Side bar */} -
-
    - {abi?.map((targetFunc, index) => ( -
  • setSelectedAbiItem(targetFunc)} - className={`w-full text-left font-sm hover:bg-neutral-100 py-2 px-3 rounded-xl hover:cursor-pointer ${targetFunc.name === selectedAbiItem?.name && "bg-neutral-100 font-semibold"}`} - > - {decodeCamelCase(targetFunc.name)} - -
    - (read only) -
    -
  • - ))} -
-
- {/* Form */} -
- - -
-
-

- {decodeCamelCase(selectedAbiItem?.name)} -

-
- -
-
- {/* Make titles smaller */} - - {selectedAbiItem?.inputs.map((argument, i) => ( -
- - onFunctionParameterChange(i, e.target.value) - } - /> -
- ))} - -
- setValue(e.target.value || "")} - /> -
-
-
-
- -

Select a function from the list

-
-
-
-
- ); -}; - -function hasTypeError(value: string, solidityType: string): boolean { - if (!value || !solidityType) return false; - - switch (solidityType) { - case "address": - return !/^0x[0-9a-fA-F]{40}$/.test(value); - case "bytes": - return value.length % 2 !== 0 || !/^0x[0-9a-fA-F]*$/.test(value); - case "string": - return false; - case "bool": - return ![ - "true", - "false", - "True", - "False", - "yes", - "no", - "Yes", - "No", - ].includes(value); - case "tuple": - return false; - } - if (solidityType.match(/^bytes[0-9]{1,2}$/)) { - return value.length % 2 !== 0 || !/^0x[0-9a-fA-F]+$/.test(value); - } else if (solidityType.match(/^uint[0-9]+$/)) { - return value.length % 2 !== 0 || !/^[0-9]*$/.test(value); - } else if (solidityType.match(/^int[0-9]+$/)) { - return value.length % 2 !== 0 || !/^-?[0-9]*$/.test(value); - } - return false; -} diff --git a/components/input/function-selector.tsx b/components/input/function-selector.tsx new file mode 100644 index 00000000..932d82af --- /dev/null +++ b/components/input/function-selector.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from "react"; +import { Hex, encodeFunctionData } from "viem"; +import { Button, InputText } from "@aragon/ods"; +import { AbiFunction } from "abitype"; +import { Else, If, Then } from "@/components/if"; +import { decodeCamelCase } from "@/utils/case"; +import { useAlertContext } from "@/context/AlertContext"; +import { InputParameter } from "./input-parameter"; +import { InputValue } from "@/utils/input-values"; + +interface IFunctionSelectorProps { + abi: AbiFunction[]; + actionEntered: (calldata: Hex, value: bigint) => void; +} +export const FunctionSelector = ({ + abi, + actionEntered, +}: IFunctionSelectorProps) => { + const { addAlert } = useAlertContext(); + const [selectedAbiItem, setSelectedAbiItem] = useState< + AbiFunction | undefined + >(); + const [inputValues, setInputValues] = useState([]); + const [value, setValue] = useState(""); + + useEffect(() => { + // Clean up if another function is selected + setInputValues([]); + }, [abi]); + + const onParameterChange = (paramIdx: number, value: InputValue) => { + const newInputValues = [...inputValues]; + newInputValues[paramIdx] = value; + setInputValues(newInputValues); + }; + + const onAddAction = () => { + if (!abi || !selectedAbiItem) return; + + // The values we have now are the result of + // validation having happened at the specific components + + for (let i = 0; i < selectedAbiItem.inputs.length; i++) { + if (inputValues[i] === null || inputValues[i] === undefined) { + return addAlert("Invalid parameters", { + description: + "Make sure that you have filled all the parameters and that they contain valid values", + type: "error", + }); + } + } + + try { + const data = encodeFunctionData({ + abi, + functionName: selectedAbiItem.name, + args: inputValues, + }); + actionEntered(data, BigInt(value ?? "0")); + + setInputValues([]); + + // Clean up the form + setSelectedAbiItem(undefined); + + addAlert("New action added"); + } catch (err) { + console.error(err); + addAlert("Invalid parameters", { + description: "Check that the parameters you entered are correct", + type: "error", + }); + return; + } + }; + + const functionAbiList = (abi || []).filter( + (item) => + item.type === "function" && + !["pure", "view"].includes(item.stateMutability) + ); + + return ( +
+ {/* Side bar */} +
+
    + {functionAbiList.map((fn, index) => ( +
  • setSelectedAbiItem(fn)} + className={`w-full text-left font-sm hover:bg-neutral-100 py-2 px-3 rounded-xl hover:cursor-pointer ${fn.name === selectedAbiItem?.name && "bg-neutral-100 font-semibold"}`} + > + {decodeCamelCase(fn.name)} +
  • + ))} +
+
+ {/* Form */} +
+ + +
+
+

+ {decodeCamelCase(selectedAbiItem?.name)} +

+
+ +
+
+ {/* Make titles smaller */} + + {selectedAbiItem?.inputs.map((paramAbi, i) => ( +
+ +
+ ))} + +
+ setValue(e.target.value || "")} + /> +
+
+
+
+ +

Select a function from the list

+
+
+
+
+ ); +}; diff --git a/components/input/input-parameter-array.tsx b/components/input/input-parameter-array.tsx new file mode 100644 index 00000000..91549ea6 --- /dev/null +++ b/components/input/input-parameter-array.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from "react"; +import { Button, InputText } from "@aragon/ods"; +import { AbiParameter } from "viem"; +import { decodeCamelCase } from "@/utils/case"; +import { + InputValue, + handleStringValue, + isValidStringValue, + readableTypeName, +} from "@/utils/input-values"; + +interface IInputParameterArrayProps { + abi: AbiParameter; + idx: number; + onChange: (paramIdx: number, value: InputValue) => any; +} + +export const InputParameterArray = ({ + abi, + idx, + onChange, +}: IInputParameterArrayProps) => { + const [value, setValue] = useState>([null]); + const baseType = abi.type.replace(/\[\]$/, ""); + + useEffect(() => { + // Clean up if another function is selected + setValue([null]); + }, [abi, idx]); + + const onItemChange = (i: number, newVal: string) => { + const newArray = ([] as Array).concat(value); + newArray[i] = newVal; + setValue(newArray); + + const transformedItems = newArray.map((item) => + handleStringValue(item || "", baseType) + ); + if (transformedItems.some((item) => item === null)) return; + + onChange(idx, transformedItems as InputValue[]); + }; + + const addMore = () => { + const newValue = [...value, null]; + setValue(newValue); + }; + + return ( +
+

+ {abi.name ? decodeCamelCase(abi.name) : "Parameter " + (idx + 1)} +

+ {value.map((item, i) => ( +
+ 0 ? "mt-3" : ""} + addon={(i + 1).toString()} + placeholder={ + baseType + ? readableTypeName(baseType) + : decodeCamelCase(abi.name) || "" + } + variant={ + item === null || isValidStringValue(item, baseType) + ? "default" + : "critical" + } + value={item || ""} + onChange={(e) => onItemChange(i, e.target.value)} + /> +
+ ))} +
+ +
+
+ ); +}; diff --git a/components/input/input-parameter-text.tsx b/components/input/input-parameter-text.tsx new file mode 100644 index 00000000..a666e381 --- /dev/null +++ b/components/input/input-parameter-text.tsx @@ -0,0 +1,61 @@ +import { InputText } from "@aragon/ods"; +import { AbiParameter } from "viem"; +import { decodeCamelCase } from "@/utils/case"; +import { + InputValue, + isValidStringValue, + handleStringValue, + readableTypeName, +} from "@/utils/input-values"; +import { useEffect, useState } from "react"; + +interface IInputParameterTextProps { + abi: AbiParameter; + idx: number; + onChange: (paramIdx: number, value: InputValue) => any; +} + +export const InputParameterText = ({ + abi, + idx, + onChange, +}: IInputParameterTextProps) => { + const [value, setValue] = useState(null); + + useEffect(() => { + // Clean up if another function is selected + setValue(null); + }, [abi, idx]); + + const handleValue = (val: string) => { + setValue(val); + + const parsedValue = handleStringValue(val, abi.type); + if (parsedValue === null) return; + + onChange(idx, parsedValue); + }; + + return ( +
+ handleValue(e.target.value)} + /> +
+ ); +}; diff --git a/components/input/input-parameter-tuple-array.tsx b/components/input/input-parameter-tuple-array.tsx new file mode 100644 index 00000000..f636808e --- /dev/null +++ b/components/input/input-parameter-tuple-array.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from "react"; +import { AlertInline, Button, Tag } from "@aragon/ods"; +import { AbiParameter } from "viem"; +import { InputValue } from "@/utils/input-values"; +import { InputParameterTuple } from "./input-parameter-tuple"; +import { decodeCamelCase } from "@/utils/case"; +import { Else, If, Then } from "../if"; + +interface IInputParameterTupleArrayProps { + abi: AbiParameter; + idx: number; + onChange: (paramIdx: number, value: Array>) => any; +} + +export const InputParameterTupleArray = ({ + abi, + idx, + onChange, +}: IInputParameterTupleArrayProps) => { + const [values, setValues] = useState< + Array | undefined> + >([undefined]); + + useEffect(() => { + // Clean up if another function is selected + setValues([undefined]); + }, [abi, idx]); + + const onItemChange = (i: number, newVal: Record) => { + const newValues = [...values]; + newValues[i] = newVal; + setValues(newValues); + + if (newValues.some((item) => !item)) return; + + onChange(idx, newValues as Array>); + }; + + const addMore = () => { + const newValues = [...values, undefined]; + setValues(newValues); + }; + + const components: AbiParameter[] = (abi as any).components || []; + const someMissingName = components.some((c) => !c.name); + + return ( +
+ + + {values.map((_, i) => ( +
+
+

+ {abi.name + ? decodeCamelCase(abi.name) + : "Parameter " + (idx + 1)} +

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

+ {abi.name ? decodeCamelCase(abi.name) : "Parameter " + (idx + 1)} +

+ +
+
+
+ ); +}; diff --git a/components/input/input-parameter-tuple.tsx b/components/input/input-parameter-tuple.tsx new file mode 100644 index 00000000..872f4340 --- /dev/null +++ b/components/input/input-parameter-tuple.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from "react"; +import { AbiParameter } from "viem"; +import { decodeCamelCase } from "@/utils/case"; +import { InputParameter } from "./input-parameter"; +import { InputValue } from "@/utils/input-values"; +import { Else, If, Then } from "../if"; +import { AlertInline } from "@aragon/ods"; + +interface IInputParameterTupleProps { + abi: AbiParameter; + idx: number; + onChange: (paramIdx: number, value: Record) => any; + hideTitle?: boolean; +} + +export const InputParameterTuple = ({ + abi, + idx, + onChange, + hideTitle, +}: IInputParameterTupleProps) => { + const [values, setValues] = useState>([]); + + useEffect(() => { + // Clean up if another function is selected + setValues([]); + }, [abi, idx]); + + const onItemChange = (i: number, newVal: InputValue) => { + const newValues = [...values]; + newValues[i] = newVal; + setValues(newValues); + + // Report up + const result: Record = {}; + + for (let i = 0; i < components.length; i++) { + // Skip if incomplete + if (newValues[i] === undefined || newValues[i] === null) return; + // Arrange as an object + result[components[i].name!] = newValues[i]!; + } + + onChange(idx, result); + }; + + const components: AbiParameter[] = (abi as any).components || []; + const someMissingName = components.some((c) => !c.name); + + return ( +
+ +

+ {abi.name ? decodeCamelCase(abi.name) : "Parameter " + (idx + 1)} +

+
+ + + +
+ {components.map((item, i) => ( +
+ +
+ ))} +
+
+ + + +
+
+ ); +}; diff --git a/components/input/input-parameter.tsx b/components/input/input-parameter.tsx new file mode 100644 index 00000000..07e62fd1 --- /dev/null +++ b/components/input/input-parameter.tsx @@ -0,0 +1,28 @@ +import { AbiParameter } from "viem"; +import { InputParameterTupleArray } from "./input-parameter-tuple-array"; +import { InputParameterTuple } from "./input-parameter-tuple"; +import { InputParameterArray } from "./input-parameter-array"; +import { InputParameterText } from "./input-parameter-text"; +import { InputValue } from "@/utils/input-values"; + +interface IInputParameterProps { + abi: AbiParameter; + idx: number; + onChange: (paramIdx: number, value: InputValue) => any; +} + +export const InputParameter = ({ + abi, + idx, + onChange, +}: IInputParameterProps) => { + if (abi.type === "tuple[]") { + return ; + } else if (abi.type.endsWith("[]")) { + return ; + } else if (abi.type === "tuple") { + return ; + } else { + return ; + } +}; diff --git a/components/input/withdrawal.tsx b/components/input/withdrawal.tsx index 53a230a0..f15dd3e5 100644 --- a/components/input/withdrawal.tsx +++ b/components/input/withdrawal.tsx @@ -1,6 +1,6 @@ import { Action } from "@/utils/types"; import { FC, useEffect, useState } from "react"; -import { InputText, InputNumber } from "@aragon/ods"; +import { InputText, InputNumber, AlertInline } from "@aragon/ods"; import { Address, parseEther } from "viem"; import { isAddress } from "@/utils/evm"; import { ElseIf, If, Then } from "../if"; @@ -34,12 +34,16 @@ const WithdrawalInput: FC = ({ setActions }) => { value={to} onChange={handleTo} /> - +

Enter the address to transfer to

- -

The address you entered is not valid

+ +
diff --git a/plugins/delegateAnnouncer/components/UserDelegateCard.tsx b/plugins/delegateAnnouncer/components/UserDelegateCard.tsx index 7f7c3946..d1e5b639 100644 --- a/plugins/delegateAnnouncer/components/UserDelegateCard.tsx +++ b/plugins/delegateAnnouncer/components/UserDelegateCard.tsx @@ -96,7 +96,7 @@ export const SelfDelegationProfileCard = ({ address, tokenAddress, message, load
- +