diff --git a/jest/setup.ts b/jest/setup.ts index c371af3c56..6ccc67ad4c 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -1,4 +1,5 @@ import dotenv from 'dotenv'; +import { TextEncoder, TextDecoder } from 'util'; import fetchMock from 'jest-fetch-mock'; @@ -6,6 +7,8 @@ fetchMock.enableMocks(); const envs = dotenv.config({ path: './configs/envs/.env.jest' }); +Object.assign(global, { TextDecoder, TextEncoder }); + Object.defineProperty(window, 'matchMedia', { writable: true, value: jest.fn().mockImplementation(query => ({ diff --git a/lib/hooks/useContractTabs.tsx b/lib/hooks/useContractTabs.tsx index 5736495c71..ca02f16ddd 100644 --- a/lib/hooks/useContractTabs.tsx +++ b/lib/hooks/useContractTabs.tsx @@ -15,22 +15,22 @@ export default function useContractTabs(data: Address | undefined) { // { id: 'contact_decompiled_code', title: 'Decompiled code', component:
Decompiled code
} : // undefined, data?.has_methods_read ? - { id: 'read_contract', title: 'Read contract', component: } : + { id: 'read_contract', title: 'Read contract', component: } : undefined, data?.has_methods_read_proxy ? - { id: 'read_proxy', title: 'Read proxy', component: } : + { id: 'read_proxy', title: 'Read proxy', component: } : undefined, data?.has_custom_methods_read ? - { id: 'read_custom_methods', title: 'Read custom', component: } : + { id: 'read_custom_methods', title: 'Read custom', component: } : undefined, data?.has_methods_write ? - { id: 'write_contract', title: 'Write contract', component: } : + { id: 'write_contract', title: 'Write contract', component: } : undefined, data?.has_methods_write_proxy ? - { id: 'write_proxy', title: 'Write proxy', component: } : + { id: 'write_proxy', title: 'Write proxy', component: } : undefined, data?.has_custom_methods_write ? - { id: 'write_custom_methods', title: 'Write custom', component: } : + { id: 'write_custom_methods', title: 'Write custom', component: } : undefined, ].filter(Boolean); }, [ data ]); diff --git a/mocks/contract/methods.ts b/mocks/contract/methods.ts index d9114114fa..745c19770a 100644 --- a/mocks/contract/methods.ts +++ b/mocks/contract/methods.ts @@ -9,7 +9,7 @@ export const read: Array = [ { constant: true, inputs: [ - { internalType: 'address', name: '', type: 'address' }, + { internalType: 'address', name: 'wallet', type: 'address' }, ], method_id: '70a08231', name: 'FLASHLOAN_PREMIUM_TOTAL', diff --git a/package.json b/package.json index 395abadc60..adfe8e8319 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "react-identicons": "^1.2.5", "react-intersection-observer": "^9.5.2", "react-jazzicon": "^1.0.4", + "react-number-format": "^5.3.1", "react-scroll": "^1.8.7", "swagger-ui-react": "^5.9.0", "use-font-face-observer": "^1.2.1", diff --git a/theme/components/Alert/Alert.ts b/theme/components/Alert/Alert.ts index 2360b71a52..c8dfa125b1 100644 --- a/theme/components/Alert/Alert.ts +++ b/theme/components/Alert/Alert.ts @@ -13,7 +13,7 @@ function getBg(props: StyleFunctionProps) { const { theme, colorScheme: c } = props; const darkBg = transparentize(`${ c }.200`, 0.16)(theme); return { - light: `colors.${ c }.100`, + light: `colors.${ c }.${ c === 'red' ? '50' : '100' }`, dark: darkBg, }; } diff --git a/types/api/contract.ts b/types/api/contract.ts index 7c11167b1a..dd66d0380f 100644 --- a/types/api/contract.ts +++ b/types/api/contract.ts @@ -1,6 +1,6 @@ -import type { Abi } from 'abitype'; +import type { Abi, AbiType } from 'abitype'; -export type SmartContractMethodArgType = 'address' | 'uint256' | 'bool' | 'string' | 'bytes' | 'bytes32' | 'bytes32[]'; +export type SmartContractMethodArgType = AbiType; export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable'; export interface SmartContract { @@ -88,6 +88,8 @@ export interface SmartContractMethodInput { internalType?: SmartContractMethodArgType; name: string; type: SmartContractMethodArgType; + components?: Array; + fieldType?: 'native_coin'; } export interface SmartContractMethodOutput extends SmartContractMethodInput { diff --git a/ui/address/contract/ContractMethodCallable.tsx b/ui/address/contract/ContractMethodCallable.tsx index 62be6cda60..2009317ffc 100644 --- a/ui/address/contract/ContractMethodCallable.tsx +++ b/ui/address/contract/ContractMethodCallable.tsx @@ -1,16 +1,17 @@ import { Box, Button, chakra, Flex } from '@chakra-ui/react'; -import _fromPairs from 'lodash/fromPairs'; import React from 'react'; import type { SubmitHandler } from 'react-hook-form'; -import { useForm } from 'react-hook-form'; +import { useForm, FormProvider } from 'react-hook-form'; import type { MethodFormFields, ContractMethodCallResult } from './types'; import type { SmartContractMethodInput, SmartContractMethod } from 'types/api/contract'; +import config from 'configs/app'; import * as mixpanel from 'lib/mixpanel/index'; import IconSvg from 'ui/shared/IconSvg'; -import ContractMethodField from './ContractMethodField'; +import ContractMethodCallableRow from './ContractMethodCallableRow'; +import { formatFieldValues, transformFieldsToArgs } from './utils'; interface ResultComponentProps { item: T; @@ -25,42 +26,9 @@ interface Props { isWrite?: boolean; } -const getFieldName = (name: string | undefined, index: number): string => name || String(index); - -const sortFields = (data: Array) => ([ a ]: [string, string], [ b ]: [string, string]): 1 | -1 | 0 => { - const fieldNames = data.map(({ name }, index) => getFieldName(name, index)); - const indexA = fieldNames.indexOf(a); - const indexB = fieldNames.indexOf(b); - - if (indexA > indexB) { - return 1; - } - - if (indexA < indexB) { - return -1; - } - - return 0; -}; - -const castFieldValue = (data: Array) => ([ key, value ]: [ string, string ], index: number) => { - if (data[index].type.includes('[')) { - return [ key, parseArrayValue(value) ]; - } - return [ key, value ]; -}; - -const parseArrayValue = (value: string) => { - try { - const parsedResult = JSON.parse(value); - if (Array.isArray(parsedResult)) { - return parsedResult; - } - throw new Error('Not an array'); - } catch (error) { - return ''; - } -}; +// groupName%groupIndex:inputName%inputIndex +const getFormFieldName = (input: { index: number; name: string }, group?: { index: number; name: string }) => + `${ group ? `${ group.name }%${ group.index }:` : '' }${ input.name || 'input' }%${ input.index }`; const ContractMethodCallable = ({ data, onSubmit, resultComponent: ResultComponent, isWrite }: Props) => { @@ -71,15 +39,16 @@ const ContractMethodCallable = ({ data, onSubmit, return [ ...('inputs' in data ? data.inputs : []), ...('stateMutability' in data && data.stateMutability === 'payable' ? [ { - name: 'value', + name: `Send native ${ config.chain.currency.symbol }`, type: 'uint256' as const, internalType: 'uint256' as const, + fieldType: 'native_coin' as const, } ] : []), ]; }, [ data ]); - const { control, handleSubmit, setValue, getValues } = useForm({ - defaultValues: _fromPairs(inputs.map(({ name }, index) => [ getFieldName(name, index), '' ])), + const formApi = useForm({ + mode: 'onBlur', }); const handleTxSettle = React.useCallback(() => { @@ -91,10 +60,8 @@ const ContractMethodCallable = ({ data, onSubmit, }, [ result ]); const onFormSubmit: SubmitHandler = React.useCallback(async(formData) => { - const args = Object.entries(formData) - .sort(sortFields(inputs)) - .map(castFieldValue(inputs)) - .map(([ , value ]) => value); + const formattedData = formatFieldValues(formData, inputs); + const args = transformFieldsToArgs(formattedData); setResult(undefined); setLoading(true); @@ -117,46 +84,87 @@ const ContractMethodCallable = ({ data, onSubmit, return ( - - { inputs.map(({ type, name }, index) => { - const fieldName = getFieldName(name, index); - return ( - - ); - }) } - - + + { inputs.map((input, index) => { + const fieldName = getFormFieldName({ name: input.name, index }); + + if (input.type === 'tuple' && input.components) { + return ( + + { index !== 0 && <>
} + + { input.name } ({ input.type }) + + { input.components.map((component, componentIndex) => { + const fieldName = getFormFieldName( + { name: component.name, index: componentIndex }, + { name: input.name, index }, + ); + + return ( + + ); + }) } + { index !== inputs.length - 1 && <>
} + + ); + } + + return ( + 1 } + onChange={ handleFormChange } + /> + ); + }) } + + + + { 'outputs' in data && !isWrite && data.outputs.length > 0 && ( - +

{ data.outputs.map(({ type, name }, index) => { diff --git a/ui/address/contract/ContractMethodCallableRow.tsx b/ui/address/contract/ContractMethodCallableRow.tsx new file mode 100644 index 0000000000..2cc01d7861 --- /dev/null +++ b/ui/address/contract/ContractMethodCallableRow.tsx @@ -0,0 +1,84 @@ +import { Box, Flex, useColorModeValue } from '@chakra-ui/react'; +import React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import type { MethodFormFields } from './types'; +import type { SmartContractMethodArgType, SmartContractMethodInput } from 'types/api/contract'; + +import ContractMethodField from './ContractMethodField'; +import ContractMethodFieldArray from './ContractMethodFieldArray'; +import { ARRAY_REGEXP } from './utils'; + +interface Props { + fieldName: string; + fieldType?: SmartContractMethodInput['fieldType']; + argName: string; + argType: SmartContractMethodArgType; + onChange: () => void; + isDisabled: boolean; + isGrouped?: boolean; + isOptional?: boolean; +} + +const ContractMethodCallableRow = ({ argName, fieldName, fieldType, argType, onChange, isDisabled, isGrouped, isOptional }: Props) => { + const { control, getValues, setValue } = useFormContext(); + const arrayTypeMatch = argType.match(ARRAY_REGEXP); + const nativeCoinFieldBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.100'); + + const content = arrayTypeMatch ? ( + + ) : ( + + ); + + const isNativeCoinField = fieldType === 'native_coin'; + + return ( + + + { argName }{ isOptional ? '' : '*' } ({ argType }) + + { content } + + ); +}; + +export default React.memo(ContractMethodCallableRow); diff --git a/ui/address/contract/ContractMethodField.tsx b/ui/address/contract/ContractMethodField.tsx index d85deeaebb..47837114f6 100644 --- a/ui/address/contract/ContractMethodField.tsx +++ b/ui/address/contract/ContractMethodField.tsx @@ -1,12 +1,16 @@ import { + Box, FormControl, Input, InputGroup, InputRightElement, + useColorModeValue, } from '@chakra-ui/react'; import React from 'react'; -import type { Control, ControllerRenderProps, UseFormGetValues, UseFormSetValue } from 'react-hook-form'; +import type { Control, ControllerRenderProps, FieldError, UseFormGetValues, UseFormSetValue, UseFormStateReturn } from 'react-hook-form'; import { Controller } from 'react-hook-form'; +import { NumericFormat } from 'react-number-format'; +import { isAddress, isHex, getAddress } from 'viem'; import type { MethodFormFields } from './types'; import type { SmartContractMethodArgType } from 'types/api/contract'; @@ -14,21 +18,25 @@ import type { SmartContractMethodArgType } from 'types/api/contract'; import ClearButton from 'ui/shared/ClearButton'; import ContractMethodFieldZeroes from './ContractMethodFieldZeroes'; -import { addZeroesAllowed } from './utils'; +import { INT_REGEXP, BYTES_REGEXP, getIntBoundaries, formatBooleanValue } from './utils'; interface Props { + name: string; + index?: number; + groupName?: string; + placeholder: string; + argType: SmartContractMethodArgType; control: Control; setValue: UseFormSetValue; getValues: UseFormGetValues; - placeholder: string; - name: string; - valueType: SmartContractMethodArgType; isDisabled: boolean; + isOptional?: boolean; onChange: () => void; } -const ContractMethodField = ({ control, name, valueType, placeholder, setValue, getValues, isDisabled, onChange }: Props) => { +const ContractMethodField = ({ control, name, groupName, index, argType, placeholder, setValue, getValues, isDisabled, isOptional, onChange }: Props) => { const ref = React.useRef(null); + const bgColor = useColorModeValue('white', 'black'); const handleClear = React.useCallback(() => { setValue(name, ''); @@ -37,47 +45,147 @@ const ContractMethodField = ({ control, name, valueType, placeholder, setValue, }, [ name, onChange, setValue ]); const handleAddZeroesClick = React.useCallback((power: number) => { - const value = getValues()[name]; + const value = groupName && index !== undefined ? getValues()[groupName][index] : getValues()[name]; const zeroes = Array(power).fill('0').join(''); const newValue = value ? value + zeroes : '1' + zeroes; setValue(name, newValue); onChange(); - }, [ getValues, name, onChange, setValue ]); + }, [ getValues, groupName, index, name, onChange, setValue ]); + + const intMatch = React.useMemo(() => { + const match = argType.match(INT_REGEXP); + if (!match) { + return null; + } + + const [ , isUnsigned, power = '256' ] = match; + const [ min, max ] = getIntBoundaries(Number(power), Boolean(isUnsigned)); + + return { isUnsigned, power, min, max }; + }, [ argType ]); - const hasZerosControl = addZeroesAllowed(valueType); + const bytesMatch = React.useMemo(() => { + return argType.match(BYTES_REGEXP); + }, [ argType ]); + + const renderInput = React.useCallback(( + { field, formState }: { field: ControllerRenderProps; formState: UseFormStateReturn }, + ) => { + const error: FieldError | undefined = index !== undefined && groupName !== undefined ? + (formState.errors[groupName] as unknown as Array)?.[index] : + formState.errors[name]; + + // show control for all inputs which allows to insert 10^18 or greater numbers + const hasZerosControl = intMatch && Number(intMatch.power) >= 64; - const renderInput = React.useCallback(({ field }: { field: ControllerRenderProps }) => { return ( - - - - - { field.value && } - { hasZerosControl && } - - - + + + + + + { typeof field.value === 'string' && field.value.replace('\n', '') && } + { hasZerosControl && } + + + + { error && { error.message } } + ); - }, [ name, isDisabled, placeholder, hasZerosControl, handleClear, handleAddZeroesClick ]); + }, [ index, groupName, name, intMatch, isDisabled, isOptional, placeholder, bgColor, handleClear, handleAddZeroesClick ]); + + const validate = React.useCallback((_value: string | Array | undefined) => { + if (typeof _value === 'object' || !_value) { + return; + } + + const value = _value.replace('\n', ''); + + if (!value && !isOptional) { + return 'Field is required'; + } + + if (argType === 'address') { + if (!isAddress(value)) { + return 'Invalid address format'; + } + + // all lowercase addresses are valid + const isInLowerCase = value === value.toLowerCase(); + if (isInLowerCase) { + return true; + } + + // check if address checksum is valid + return getAddress(value) === value ? true : 'Invalid address checksum'; + } + + if (intMatch) { + const formattedValue = Number(value.replace(/\s/g, '')); + + if (Object.is(formattedValue, NaN)) { + return 'Invalid integer format'; + } + + if (formattedValue > intMatch.max || formattedValue < intMatch.min) { + const lowerBoundary = intMatch.isUnsigned ? '0' : `-1 * 2 ^ ${ Number(intMatch.power) / 2 }`; + const upperBoundary = intMatch.isUnsigned ? `2 ^ ${ intMatch.power } - 1` : `2 ^ ${ Number(intMatch.power) / 2 } - 1`; + return `Value should be in range from "${ lowerBoundary }" to "${ upperBoundary }" inclusively`; + } + + return true; + } + + if (argType === 'bool') { + const formattedValue = formatBooleanValue(value); + if (formattedValue === undefined) { + return 'Invalid boolean format. Allowed values: 0, 1, true, false'; + } + } + + if (bytesMatch) { + const [ , length ] = bytesMatch; + + if (!isHex(value)) { + return 'Invalid bytes format'; + } + + if (length) { + const valueLengthInBytes = value.replace('0x', '').length / 2; + return valueLengthInBytes !== Number(length) ? `Value should be ${ length } bytes in length` : true; + } + + return true; + } + + return true; + }, [ isOptional, argType, intMatch, bytesMatch ]); return ( - ); }; diff --git a/ui/address/contract/ContractMethodFieldArray.tsx b/ui/address/contract/ContractMethodFieldArray.tsx new file mode 100644 index 0000000000..35018b2f7f --- /dev/null +++ b/ui/address/contract/ContractMethodFieldArray.tsx @@ -0,0 +1,106 @@ +import { Flex, IconButton } from '@chakra-ui/react'; +import React from 'react'; +import type { Control, UseFormGetValues, UseFormSetValue } from 'react-hook-form'; +import { useFieldArray } from 'react-hook-form'; + +import type { MethodFormFields } from './types'; +import type { SmartContractMethodArgType } from 'types/api/contract'; + +import IconSvg from 'ui/shared/IconSvg'; + +import ContractMethodField from './ContractMethodField'; + +interface Props { + name: string; + size: number; + argType: SmartContractMethodArgType; + control: Control; + setValue: UseFormSetValue; + getValues: UseFormGetValues; + isDisabled: boolean; + onChange: () => void; +} + +const ContractMethodFieldArray = ({ control, name, setValue, getValues, isDisabled, argType, onChange, size }: Props) => { + const { fields, append, remove } = useFieldArray({ + name: name as never, + control, + }); + + React.useEffect(() => { + if (fields.length === 0) { + if (size === Infinity) { + append(''); + } else { + for (let i = 0; i < size - 1; i++) { + // a little hack to append multiple empty fields in the array + // had to adjust code in ContractMethodField as well + append('\n'); + } + } + } + + }, [ fields.length, append, size ]); + + const handleAddButtonClick = React.useCallback(() => { + append(''); + }, [ append ]); + + const handleRemoveButtonClick = React.useCallback((event: React.MouseEvent) => { + const itemIndex = event.currentTarget.getAttribute('data-index'); + if (itemIndex) { + remove(Number(itemIndex)); + } + }, [ remove ]); + + return ( + + { fields.map((field, index, array) => { + return ( + + + { array.length > 1 && size === Infinity && ( + } + isDisabled={ isDisabled } + /> + ) } + { index === array.length - 1 && size === Infinity && ( + } + isDisabled={ isDisabled } + /> + ) } + + ); + }) } + + ); +}; + +export default React.memo(ContractMethodFieldArray); diff --git a/ui/address/contract/ContractMethodsAccordion.tsx b/ui/address/contract/ContractMethodsAccordion.tsx index f0bc297987..b1e4c4bf6b 100644 --- a/ui/address/contract/ContractMethodsAccordion.tsx +++ b/ui/address/contract/ContractMethodsAccordion.tsx @@ -11,9 +11,10 @@ interface Props { data: Array; addressHash?: string; renderItemContent: (item: T, index: number, id: number) => React.ReactNode; + tab: string; } -const ContractMethodsAccordion = ({ data, addressHash, renderItemContent }: Props) => { +const ContractMethodsAccordion = ({ data, addressHash, renderItemContent, tab }: Props) => { const [ expandedSections, setExpandedSections ] = React.useState>(data.length === 1 ? [ 0 ] : []); const [ id, setId ] = React.useState(0); @@ -79,6 +80,7 @@ const ContractMethodsAccordion = ({ data, address index={ index } addressHash={ addressHash } renderContent={ renderItemContent as (item: SmartContractMethod, index: number, id: number) => React.ReactNode } + tab={ tab } /> )) } diff --git a/ui/address/contract/ContractMethodsAccordionItem.tsx b/ui/address/contract/ContractMethodsAccordionItem.tsx index 5f0c462272..d69222cdad 100644 --- a/ui/address/contract/ContractMethodsAccordionItem.tsx +++ b/ui/address/contract/ContractMethodsAccordionItem.tsx @@ -16,9 +16,10 @@ interface Props { id: number; addressHash?: string; renderContent: (item: T, index: number, id: number) => React.ReactNode; + tab: string; } -const ContractMethodsAccordionItem = ({ data, index, id, addressHash, renderContent }: Props) => { +const ContractMethodsAccordionItem = ({ data, index, id, addressHash, renderContent, tab }: Props) => { const url = React.useMemo(() => { if (!('method_id' in data)) { return ''; @@ -28,11 +29,11 @@ const ContractMethodsAccordionItem = ({ data, ind pathname: '/address/[hash]', query: { hash: addressHash ?? '', - tab: 'read_contract', + tab, }, hash: data.method_id, }); - }, [ addressHash, data ]); + }, [ addressHash, data, tab ]); const { hasCopied, onCopy } = useClipboard(url, 1000); const { isOpen, onOpen, onClose } = useDisclosure(); @@ -85,7 +86,7 @@ const ContractMethodsAccordionItem = ({ data, ind - + { renderContent(data, index, id) } diff --git a/ui/address/contract/ContractRead.pw.tsx b/ui/address/contract/ContractRead.pw.tsx index 71fe9fcc3c..d540c1c07e 100644 --- a/ui/address/contract/ContractRead.pw.tsx +++ b/ui/address/contract/ContractRead.pw.tsx @@ -28,7 +28,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { const component = await mount( - + , { hooksConfig }, ); @@ -37,8 +37,8 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { await expect(component).toHaveScreenshot(); - await component.getByPlaceholder(/address/i).type('address-hash'); - await component.getByText(/query/i).click(); + await component.getByPlaceholder(/address/i).type('0xa113Ce24919C08a26C952E81681dAc861d6a2466'); + await component.getByText(/read/i).click(); await component.getByText(/wei/i).click(); diff --git a/ui/address/contract/ContractRead.tsx b/ui/address/contract/ContractRead.tsx index 488889c7ba..88330196ea 100644 --- a/ui/address/contract/ContractRead.tsx +++ b/ui/address/contract/ContractRead.tsx @@ -1,10 +1,12 @@ import { Alert, Flex } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; import React from 'react'; import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'types/api/contract'; import useApiFetch from 'lib/api/useApiFetch'; import useApiQuery from 'lib/api/useApiQuery'; +import getQueryParamString from 'lib/router/getQueryParamString'; import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion'; import ContentLoader from 'ui/shared/ContentLoader'; import DataFetchAlert from 'ui/shared/DataFetchAlert'; @@ -17,15 +19,15 @@ import ContractMethodConstant from './ContractMethodConstant'; import ContractReadResult from './ContractReadResult'; import useWatchAccount from './useWatchAccount'; -interface Props { - addressHash?: string; - isProxy?: boolean; - isCustomAbi?: boolean; -} - -const ContractRead = ({ addressHash, isProxy, isCustomAbi }: Props) => { +const ContractRead = () => { const apiFetch = useApiFetch(); const account = useWatchAccount(); + const router = useRouter(); + + const tab = getQueryParamString(router.query.tab); + const addressHash = getQueryParamString(router.query.hash); + const isProxy = tab === 'read_proxy'; + const isCustomAbi = tab === 'read_custom_methods'; const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_read_proxy' : 'contract_methods_read', { pathParams: { hash: addressHash }, @@ -96,7 +98,7 @@ const ContractRead = ({ addressHash, isProxy, isCustomAbi }: Props) => { { isCustomAbi && } { account && } { isProxy && } - + ); }; diff --git a/ui/address/contract/ContractWrite.pw.tsx b/ui/address/contract/ContractWrite.pw.tsx index d0ccc75e73..bf3007d2e0 100644 --- a/ui/address/contract/ContractWrite.pw.tsx +++ b/ui/address/contract/ContractWrite.pw.tsx @@ -23,7 +23,7 @@ test('base view +@mobile', async({ mount, page }) => { const component = await mount( - + , { hooksConfig }, ); diff --git a/ui/address/contract/ContractWrite.tsx b/ui/address/contract/ContractWrite.tsx index bbd513545d..b199b99291 100644 --- a/ui/address/contract/ContractWrite.tsx +++ b/ui/address/contract/ContractWrite.tsx @@ -1,3 +1,4 @@ +import { useRouter } from 'next/router'; import React from 'react'; import { useAccount, useWalletClient, useNetwork, useSwitchNetwork } from 'wagmi'; @@ -5,6 +6,7 @@ import type { SmartContractWriteMethod } from 'types/api/contract'; import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; +import getQueryParamString from 'lib/router/getQueryParamString'; import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion'; import ContentLoader from 'ui/shared/ContentLoader'; import DataFetchAlert from 'ui/shared/DataFetchAlert'; @@ -17,18 +19,19 @@ import ContractWriteResult from './ContractWriteResult'; import useContractAbi from './useContractAbi'; import { getNativeCoinValue, prepareAbi } from './utils'; -interface Props { - addressHash?: string; - isProxy?: boolean; - isCustomAbi?: boolean; -} - -const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => { +const ContractWrite = () => { const { data: walletClient } = useWalletClient(); const { isConnected } = useAccount(); const { chain } = useNetwork(); const { switchNetworkAsync } = useSwitchNetwork(); + const router = useRouter(); + + const tab = getQueryParamString(router.query.tab); + const addressHash = getQueryParamString(router.query.hash); + const isProxy = tab === 'write_proxy'; + const isCustomAbi = tab === 'write_custom_methods'; + const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_write_proxy' : 'contract_methods_write', { pathParams: { hash: addressHash }, queryParams: { @@ -112,7 +115,7 @@ const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => { { isCustomAbi && } { isProxy && } - + ); }; diff --git a/ui/address/contract/ContractWriteResultDumb.tsx b/ui/address/contract/ContractWriteResultDumb.tsx index 42702c490c..6e734f93a1 100644 --- a/ui/address/contract/ContractWriteResultDumb.tsx +++ b/ui/address/contract/ContractWriteResultDumb.tsx @@ -82,7 +82,6 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => { return ( ; +export type MethodFormFields = Record>; +export type MethodFormFieldsFormatted = Record; + +export type MethodArgType = string | boolean | Array; export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceError; diff --git a/ui/address/contract/utils.test.ts b/ui/address/contract/utils.test.ts index 135e765f88..da20410583 100644 --- a/ui/address/contract/utils.test.ts +++ b/ui/address/contract/utils.test.ts @@ -1,4 +1,6 @@ -import { prepareAbi } from './utils'; +import type { SmartContractMethodInput } from 'types/api/contract'; + +import { prepareAbi, transformFieldsToArgs, formatFieldValues } from './utils'; describe('function prepareAbi()', () => { const commonAbi = [ @@ -98,3 +100,100 @@ describe('function prepareAbi()', () => { expect(item).toEqual(commonAbi[2]); }); }); + +describe('function formatFieldValues()', () => { + const formFields = { + '_tx%0:nonce%0': '1 000 000 000 000 000 000', + '_tx%0:sender%1': '0xB375d4150A853482f25E3922A4C64c6C4fF6Ae3c', + '_tx%0:targets%2': [ + '1', + 'true', + ], + '_l2OutputIndex%1': '0xaeff', + '_paused%2': '0', + '_withdrawalProof%3': [ + '0x0f', + '0x02', + ], + }; + + const inputs: Array = [ + { + components: [ + { internalType: 'uint256', name: 'nonce', type: 'uint256' }, + { internalType: 'address', name: 'sender', type: 'address' }, + { internalType: 'bool[]', name: 'targets', type: 'bool[]' }, + ], + internalType: 'tuple', + name: '_tx', + type: 'tuple', + }, + { internalType: 'bytes32', name: '_l2OutputIndex', type: 'bytes32' }, + { + internalType: 'bool', + name: '_paused', + type: 'bool', + }, + { + internalType: 'bytes32[]', + name: '_withdrawalProof', + type: 'bytes32[]', + }, + ]; + + it('converts values to correct format', () => { + const result = formatFieldValues(formFields, inputs); + expect(result).toEqual({ + '_tx%0:nonce%0': '1000000000000000000', + '_tx%0:sender%1': '0xB375d4150A853482f25E3922A4C64c6C4fF6Ae3c', + '_tx%0:targets%2': [ + true, + true, + ], + '_l2OutputIndex%1': '0xaeff', + '_paused%2': false, + '_withdrawalProof%3': [ + '0x0f', + '0x02', + ], + }); + }); + + it('converts nested array string representation to correct format', () => { + const formFields = { + '_withdrawalProof%0': '[ [ 1 ], [ 2, 3 ], [ 4 ]]', + }; + const inputs: Array = [ + { internalType: 'uint[][]', name: '_withdrawalProof', type: 'uint[][]' }, + ]; + const result = formatFieldValues(formFields, inputs); + + expect(result).toEqual({ + '_withdrawalProof%0': [ [ 1 ], [ 2, 3 ], [ 4 ] ], + }); + }); +}); + +describe('function transformFieldsToArgs()', () => { + it('groups struct and array fields', () => { + const formFields = { + '_paused%2': 'primitive_1', + '_l2OutputIndex%1': 'primitive_0', + '_tx%0:nonce%0': 'struct_0', + '_tx%0:sender%1': 'struct_1', + '_tx%0:target%2': [ 'struct_2_0', 'struct_2_1' ], + '_withdrawalProof%3': [ + 'array_0', + 'array_1', + ], + }; + + const args = transformFieldsToArgs(formFields); + expect(args).toEqual([ + [ 'struct_0', 'struct_1', [ 'struct_2_0', 'struct_2_1' ] ], + 'primitive_0', + 'primitive_1', + [ 'array_0', 'array_1' ], + ]); + }); +}); diff --git a/ui/address/contract/utils.ts b/ui/address/contract/utils.ts index c0b01b6c94..dad1c293f9 100644 --- a/ui/address/contract/utils.ts +++ b/ui/address/contract/utils.ts @@ -1,33 +1,49 @@ import type { Abi } from 'abitype'; +import _mapValues from 'lodash/mapValues'; -import type { SmartContractWriteMethod } from 'types/api/contract'; +import type { MethodArgType, MethodFormFields, MethodFormFieldsFormatted } from './types'; +import type { SmartContractMethodArgType, SmartContractMethodInput, SmartContractWriteMethod } from 'types/api/contract'; -export const getNativeCoinValue = (value: string | Array) => { - const _value = Array.isArray(value) ? value[0] : value; +export const INT_REGEXP = /^(u)?int(\d+)?$/i; - if (typeof _value !== 'string') { - return BigInt(0); - } +export const BYTES_REGEXP = /^bytes(\d+)?$/i; - return BigInt(_value); +export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/; + +export const getIntBoundaries = (power: number, isUnsigned: boolean) => { + const maxUnsigned = 2 ** power; + const max = isUnsigned ? maxUnsigned - 1 : maxUnsigned / 2 - 1; + const min = isUnsigned ? 0 : -maxUnsigned / 2; + return [ min, max ]; }; -export const addZeroesAllowed = (valueType: string) => { - if (valueType.includes('[')) { - return false; - } +export const formatBooleanValue = (value: string) => { + const formattedValue = value.toLowerCase(); + + switch (formattedValue) { + case 'true': + case '1': { + return 'true'; + } - const REGEXP = /^u?int(\d+)/i; + case 'false': + case '0': { + return 'false'; + } + + default: + return; + } +}; - const match = valueType.match(REGEXP); - const power = match?.[1]; +export const getNativeCoinValue = (value: string | Array) => { + const _value = Array.isArray(value) ? value[0] : value; - if (power) { - // show control for all inputs which allows to insert 10^18 or greater numbers - return Number(power) >= 64; + if (typeof _value !== 'string') { + return BigInt(0); } - return false; + return BigInt(_value); }; interface ExtendedError extends Error { @@ -75,3 +91,106 @@ export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi { return abi; } + +function getFieldType(fieldName: string, inputs: Array) { + const chunks = fieldName.split(':'); + + if (chunks.length === 1) { + const [ , index ] = chunks[0].split('%'); + return inputs[Number(index)].type; + } else { + const group = chunks[0].split('%'); + const input = chunks[1].split('%'); + + return inputs[Number(group[1])].components?.[Number(input[1])].type; + } +} + +function parseArrayValue(value: string) { + try { + const parsedResult = JSON.parse(value); + if (Array.isArray(parsedResult)) { + return parsedResult as Array; + } + throw new Error('Not an array'); + } catch (error) { + return ''; + } +} + +function castValue(value: string, type: SmartContractMethodArgType) { + if (type === 'bool') { + return formatBooleanValue(value) === 'true'; + } + + const intMatch = type.match(INT_REGEXP); + if (intMatch) { + return value.replaceAll(' ', ''); + } + + const isNestedArray = (type.match(/\[/g) || []).length > 1; + if (isNestedArray) { + return parseArrayValue(value) || value; + } + + return value; +} + +export function formatFieldValues(formFields: MethodFormFields, inputs: Array) { + const formattedFields = _mapValues(formFields, (value, key) => { + const type = getFieldType(key, inputs); + + if (!type) { + return value; + } + + if (Array.isArray(value)) { + const arrayMatch = type.match(ARRAY_REGEXP); + + if (arrayMatch) { + return value.map((item) => castValue(item, arrayMatch[1] as SmartContractMethodArgType)); + } + + return value; + } + + return castValue(value, type); + }); + + return formattedFields; +} + +export function transformFieldsToArgs(formFields: MethodFormFieldsFormatted) { + const unGroupedFields = Object.entries(formFields) + .reduce(( + result: Record, + [ key, value ]: [ string, MethodArgType ], + ) => { + const chunks = key.split(':'); + + if (chunks.length > 1) { + const groupKey = chunks[0]; + const [ , fieldIndex ] = chunks[1].split('%'); + + if (result[groupKey] === undefined) { + result[groupKey] = []; + } + + (result[groupKey] as Array)[Number(fieldIndex)] = value; + return result; + } + + result[key] = value; + return result; + }, {}); + + const args = (Object.entries(unGroupedFields) + .map(([ key, value ]) => { + const [ , index ] = key.split('%'); + return [ Number(index), value ]; + }) as Array<[ number, string | Array ]>) + .sort((a, b) => a[0] - b[0]) + .map(([ , value ]) => value); + + return args; +} diff --git a/yarn.lock b/yarn.lock index af49821c77..ad97646b1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13950,6 +13950,13 @@ react-jazzicon@^1.0.4: dependencies: mersenne-twister "^1.1.0" +react-number-format@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/react-number-format/-/react-number-format-5.3.1.tgz#840c257da9cb4b248990d8db46e4d23e8bac67ff" + integrity sha512-qpYcQLauIeEhCZUZY9jXZnnroOtdy3jYaS1zQ3M1Sr6r/KMOBEIGNIb7eKT19g2N1wbYgFgvDzs19hw5TrB8XQ== + dependencies: + prop-types "^15.7.2" + react-redux@^8.1.2: version "8.1.3" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.1.3.tgz#4fdc0462d0acb59af29a13c27ffef6f49ab4df46"