Commit c8f60016 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Display arrays of structs on Read/Write contract tabs (#1556)

* prototype

* change host for preview

* improve labels for nested arrays

* add field path

* bind fields to react form

* transform form fields into method args

* add styles for non-array args

* styles for tuple array

* style nested array inputs

* clear and multiply buttons for inputs

* show errors in inputs

* pass disabled state to inputs

* change direction of accordioin icon

* remove old code

* add asterix to required fields

* improvements and comments

* change styles

* highlight sections with errors

* refactoring field method name

* fix lable color

* fix ts

* preliminary tests

* better complex field labels

* improve tests

* remove outputs for write methods

* rollback review value
parent 9dbb2b0c
...@@ -132,6 +132,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -132,6 +132,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x01',
}, },
{ {
constant: false, constant: false,
...@@ -146,6 +147,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -146,6 +147,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: true, payable: true,
stateMutability: 'payable', stateMutability: 'payable',
type: 'function', type: 'function',
method_id: '0x02',
}, },
{ {
stateMutability: 'payable', stateMutability: 'payable',
...@@ -159,6 +161,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -159,6 +161,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x03',
}, },
{ {
constant: false, constant: false,
...@@ -173,6 +176,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -173,6 +176,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x04',
}, },
{ {
constant: false, constant: false,
...@@ -190,6 +194,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -190,6 +194,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x05',
}, },
{ {
constant: false, constant: false,
...@@ -208,5 +213,6 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -208,5 +213,6 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x06',
}, },
]; ];
...@@ -62,12 +62,11 @@ export interface SmartContractMethodBase { ...@@ -62,12 +62,11 @@ export interface SmartContractMethodBase {
type: 'function'; type: 'function';
payable: boolean; payable: boolean;
error?: string; error?: string;
}
export interface SmartContractReadMethod extends SmartContractMethodBase {
method_id: string; method_id: string;
} }
export type SmartContractReadMethod = SmartContractMethodBase;
export interface SmartContractWriteFallback { export interface SmartContractWriteFallback {
payable?: true; payable?: true;
stateMutability: 'payable'; stateMutability: 'payable';
...@@ -85,7 +84,7 @@ export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWr ...@@ -85,7 +84,7 @@ export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWr
export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod; export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod;
export interface SmartContractMethodInput { export interface SmartContractMethodInput {
internalType?: SmartContractMethodArgType; internalType?: string; // there could be any string, e.g "enum MyEnum"
name: string; name: string;
type: SmartContractMethodArgType; type: SmartContractMethodArgType;
components?: Array<SmartContractMethodInput>; components?: Array<SmartContractMethodInput>;
......
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<MethodFormFields>();
const arrayTypeMatch = argType.match(ARRAY_REGEXP);
const nativeCoinFieldBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const content = arrayTypeMatch ? (
<ContractMethodFieldArray
name={ fieldName }
argType={ arrayTypeMatch[1] as SmartContractMethodArgType }
size={ Number(arrayTypeMatch[2] || Infinity) }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isDisabled }
onChange={ onChange }
/>
) : (
<ContractMethodField
name={ fieldName }
argType={ argType }
placeholder={ argType }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isDisabled }
isOptional={ isOptional }
onChange={ onChange }
/>
);
const isNativeCoinField = fieldType === 'native_coin';
return (
<Flex
flexDir={{ base: 'column', lg: 'row' }}
columnGap={ 3 }
rowGap={{ base: 2, lg: 0 }}
bgColor={ isNativeCoinField ? nativeCoinFieldBgColor : undefined }
py={ isNativeCoinField ? 1 : undefined }
px={ isNativeCoinField ? '6px' : undefined }
mx={ isNativeCoinField ? '-6px' : undefined }
borderRadius="base"
>
<Box
position="relative"
fontWeight={ 500 }
lineHeight="20px"
py={{ lg: '6px' }}
fontSize="sm"
color={ isGrouped ? 'text_secondary' : 'initial' }
wordBreak="break-word"
w={{ lg: '250px' }}
flexShrink={ 0 }
>
{ argName }{ isOptional ? '' : '*' } ({ argType })
</Box>
{ content }
</Flex>
);
};
export default React.memo(ContractMethodCallableRow);
import {
Box,
FormControl,
Input,
InputGroup,
InputRightElement,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
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';
import ClearButton from 'ui/shared/ClearButton';
import ContractMethodFieldZeroes from './ContractMethodFieldZeroes';
import { INT_REGEXP, BYTES_REGEXP, getIntBoundaries, formatBooleanValue } from './utils';
interface Props {
name: string;
index?: number;
groupName?: string;
placeholder: string;
argType: SmartContractMethodArgType;
control: Control<MethodFormFields>;
setValue: UseFormSetValue<MethodFormFields>;
getValues: UseFormGetValues<MethodFormFields>;
isDisabled: boolean;
isOptional?: boolean;
onChange: () => void;
}
const ContractMethodField = ({ control, name, groupName, index, argType, placeholder, setValue, getValues, isDisabled, isOptional, onChange }: Props) => {
const ref = React.useRef<HTMLInputElement>(null);
const bgColor = useColorModeValue('white', 'black');
const handleClear = React.useCallback(() => {
setValue(name, '');
onChange();
ref.current?.focus();
}, [ name, onChange, setValue ]);
const handleAddZeroesClick = React.useCallback((power: number) => {
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, 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 bytesMatch = React.useMemo(() => {
return argType.match(BYTES_REGEXP);
}, [ argType ]);
const renderInput = React.useCallback((
{ field, formState }: { field: ControllerRenderProps<MethodFormFields>; formState: UseFormStateReturn<MethodFormFields> },
) => {
const error: FieldError | undefined = index !== undefined && groupName !== undefined ?
(formState.errors[groupName] as unknown as Array<FieldError>)?.[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;
return (
<Box w="100%">
<FormControl
id={ name }
isDisabled={ isDisabled }
>
<InputGroup size="xs">
<Input
{ ...field }
{ ...(intMatch ? {
as: NumericFormat,
thousandSeparator: ' ',
decimalScale: 0,
allowNegative: !intMatch.isUnsigned,
} : {}) }
ref={ ref }
isInvalid={ Boolean(error) }
required={ !isOptional }
placeholder={ placeholder }
paddingRight={ hasZerosControl ? '120px' : '40px' }
autoComplete="off"
bgColor={ bgColor }
/>
<InputRightElement w="auto" right={ 1 }>
{ typeof field.value === 'string' && field.value.replace('\n', '') && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
{ hasZerosControl && <ContractMethodFieldZeroes onClick={ handleAddZeroesClick } isDisabled={ isDisabled }/> }
</InputRightElement>
</InputGroup>
</FormControl>
{ error && <Box color="error" fontSize="sm" mt={ 1 }>{ error.message }</Box> }
</Box>
);
}, [ index, groupName, name, intMatch, isDisabled, isOptional, placeholder, bgColor, handleClear, handleAddZeroesClick ]);
const validate = React.useCallback((_value: string | Array<string> | 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 (
<Controller
name={ name }
control={ control }
render={ renderInput }
rules={{ required: isOptional ? false : 'Field is required', validate }}
/>
);
};
export default React.memo(ContractMethodField);
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<MethodFormFields>;
setValue: UseFormSetValue<MethodFormFields>;
getValues: UseFormGetValues<MethodFormFields>;
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<HTMLButtonElement>) => {
const itemIndex = event.currentTarget.getAttribute('data-index');
if (itemIndex) {
remove(Number(itemIndex));
}
}, [ remove ]);
return (
<Flex flexDir="column" rowGap={ 3 } w="100%">
{ fields.map((field, index, array) => {
return (
<Flex key={ field.id } columnGap={ 3 }>
<ContractMethodField
name={ `${ name }[${ index }]` }
groupName={ name }
index={ index }
argType={ argType }
placeholder={ argType }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isDisabled }
onChange={ onChange }
/>
{ array.length > 1 && size === Infinity && (
<IconButton
aria-label="remove"
data-index={ index }
variant="outline"
w="30px"
h="30px"
flexShrink={ 0 }
onClick={ handleRemoveButtonClick }
icon={ <IconSvg name="minus" boxSize={ 4 }/> }
isDisabled={ isDisabled }
/>
) }
{ index === array.length - 1 && size === Infinity && (
<IconButton
aria-label="add"
data-index={ index }
variant="outline"
w="30px"
h="30px"
flexShrink={ 0 }
onClick={ handleAddButtonClick }
icon={ <IconSvg name="plus" boxSize={ 4 }/> }
isDisabled={ isDisabled }
/>
) }
</Flex>
);
}) }
</Flex>
);
};
export default React.memo(ContractMethodFieldArray);
...@@ -45,50 +45,54 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -45,50 +45,54 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
return ( return (
<AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}> <AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}>
<Element as="h2" name={ 'method_id' in data ? `method_${ data.method_id }` : '' }> { ({ isExpanded }) => (
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left"> <>
{ 'method_id' in data && ( <Element as="h2" name={ 'method_id' in data ? `method_${ data.method_id }` : '' }>
<Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }> <AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left" as="div" cursor="pointer">
<Box { 'method_id' in data && (
boxSize={ 5 } <Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }>
color="text_secondary" <Box
_hover={{ color: 'link_hovered' }} boxSize={ 5 }
mr={ 2 } color="text_secondary"
onClick={ handleCopyLinkClick } _hover={{ color: 'link_hovered' }}
onMouseEnter={ onOpen } mr={ 2 }
onMouseLeave={ onClose } onClick={ handleCopyLinkClick }
> onMouseEnter={ onOpen }
<IconSvg name="link" boxSize={ 5 }/> onMouseLeave={ onClose }
>
<IconSvg name="link" boxSize={ 5 }/>
</Box>
</Tooltip>
) }
<Box as="span" fontWeight={ 500 } mr={ 1 }>
{ index + 1 }. { data.type === 'fallback' || data.type === 'receive' ? data.type : data.name }
</Box> </Box>
</Tooltip> { data.type === 'fallback' && (
) } <Hint
<Box as="span" fontWeight={ 500 } mr={ 1 }> label={
{ index + 1 }. { data.type === 'fallback' || data.type === 'receive' ? data.type : data.name } `The fallback function is executed on a call to the contract if none of the other functions match
</Box>
{ data.type === 'fallback' && (
<Hint
label={
`The fallback function is executed on a call to the contract if none of the other functions match
the given function signature, or if no data was supplied at all and there is no receive Ether function. the given function signature, or if no data was supplied at all and there is no receive Ether function.
The fallback function always receives data, but in order to also receive Ether it must be marked payable.` The fallback function always receives data, but in order to also receive Ether it must be marked payable.`
}/> }/>
) } ) }
{ data.type === 'receive' && ( { data.type === 'receive' && (
<Hint <Hint
label={ label={
`The receive function is executed on a call to the contract with empty calldata. `The receive function is executed on a call to the contract with empty calldata.
This is the function that is executed on plain Ether transfers (e.g. via .send() or .transfer()). This is the function that is executed on plain Ether transfers (e.g. via .send() or .transfer()).
If no such function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer. If no such function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer.
If neither a receive Ether nor a payable fallback function is present, If neither a receive Ether nor a payable fallback function is present,
the contract cannot receive Ether through regular transactions and throws an exception.` the contract cannot receive Ether through regular transactions and throws an exception.`
}/> }/>
) } ) }
<AccordionIcon/> <AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/>
</AccordionButton> </AccordionButton>
</Element> </Element>
<AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)"> <AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)">
{ renderContent(data, index, id) } { renderContent(data, index, id) }
</AccordionPanel> </AccordionPanel>
</>
) }
</AccordionItem> </AccordionItem>
); );
}; };
......
...@@ -37,7 +37,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -37,7 +37,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
await component.getByPlaceholder(/address/i).type('0xa113Ce24919C08a26C952E81681dAc861d6a2466'); await component.getByPlaceholder(/address/i).fill('0xa113Ce24919C08a26C952E81681dAc861d6a2466');
await component.getByText(/read/i).click(); await component.getByText(/read/i).click();
await component.getByText(/wei/i).click(); await component.getByText(/wei/i).click();
......
...@@ -14,9 +14,9 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; ...@@ -14,9 +14,9 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress'; import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodCallable from './ContractMethodCallable';
import ContractMethodConstant from './ContractMethodConstant'; import ContractMethodConstant from './ContractMethodConstant';
import ContractReadResult from './ContractReadResult'; import ContractReadResult from './ContractReadResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useWatchAccount from './useWatchAccount'; import useWatchAccount from './useWatchAccount';
const ContractRead = () => { const ContractRead = () => {
...@@ -40,7 +40,7 @@ const ContractRead = () => { ...@@ -40,7 +40,7 @@ const ContractRead = () => {
}, },
}); });
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array<string | Array<unknown>>) => { const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array<unknown>) => {
return apiFetch<'contract_method_query', SmartContractQueryMethodRead>('contract_method_query', { return apiFetch<'contract_method_query', SmartContractQueryMethodRead>('contract_method_query', {
pathParams: { hash: addressHash }, pathParams: { hash: addressHash },
queryParams: { queryParams: {
...@@ -72,11 +72,12 @@ const ContractRead = () => { ...@@ -72,11 +72,12 @@ const ContractRead = () => {
} }
return ( return (
<ContractMethodCallable <ContractMethodForm
key={ id + '_' + index } key={ id + '_' + index }
data={ item } data={ item }
onSubmit={ handleMethodFormSubmit } onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractReadResult } resultComponent={ ContractReadResult }
methodType="read"
/> />
); );
}, [ handleMethodFormSubmit ]); }, [ handleMethodFormSubmit ]);
......
...@@ -14,8 +14,8 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; ...@@ -14,8 +14,8 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress'; import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodCallable from './ContractMethodCallable';
import ContractWriteResult from './ContractWriteResult'; import ContractWriteResult from './ContractWriteResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useContractAbi from './useContractAbi'; import useContractAbi from './useContractAbi';
import { getNativeCoinValue, prepareAbi } from './utils'; import { getNativeCoinValue, prepareAbi } from './utils';
...@@ -39,12 +39,14 @@ const ContractWrite = () => { ...@@ -39,12 +39,14 @@ const ContractWrite = () => {
}, },
queryOptions: { queryOptions: {
enabled: Boolean(addressHash), enabled: Boolean(addressHash),
refetchOnMount: false,
}, },
}); });
const contractAbi = useContractAbi({ addressHash, isProxy, isCustomAbi }); const contractAbi = useContractAbi({ addressHash, isProxy, isCustomAbi });
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array<string | Array<unknown>>) => { // TODO @tom2drum maybe move this inside the form
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array<unknown>) => {
if (!isConnected) { if (!isConnected) {
throw new Error('Wallet is not connected'); throw new Error('Wallet is not connected');
} }
...@@ -66,21 +68,22 @@ const ContractWrite = () => { ...@@ -66,21 +68,22 @@ const ContractWrite = () => {
return { hash }; return { hash };
} }
const _args = 'stateMutability' in item && item.stateMutability === 'payable' ? args.slice(0, -1) : args;
const value = 'stateMutability' in item && item.stateMutability === 'payable' ? getNativeCoinValue(args[args.length - 1]) : undefined;
const methodName = item.name; const methodName = item.name;
if (!methodName) { if (!methodName) {
throw new Error('Method name is not defined'); throw new Error('Method name is not defined');
} }
const _args = args.slice(0, item.inputs.length);
const value = getNativeCoinValue(args[item.inputs.length]);
const abi = prepareAbi(contractAbi, item); const abi = prepareAbi(contractAbi, item);
const hash = await walletClient?.writeContract({ const hash = await walletClient?.writeContract({
args: _args, args: _args,
abi, abi,
functionName: methodName, functionName: methodName,
address: addressHash as `0x${ string }`, address: addressHash as `0x${ string }`,
value: value as undefined, value,
}); });
return { hash }; return { hash };
...@@ -88,12 +91,12 @@ const ContractWrite = () => { ...@@ -88,12 +91,12 @@ const ContractWrite = () => {
const renderItemContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => { const renderItemContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => {
return ( return (
<ContractMethodCallable <ContractMethodForm
key={ id + '_' + index } key={ id + '_' + index }
data={ item } data={ item }
onSubmit={ handleMethodFormSubmit } onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractWriteResult } resultComponent={ ContractWriteResult }
isWrite methodType="write"
/> />
); );
}, [ handleMethodFormSubmit ]); }, [ handleMethodFormSubmit ]);
......
import React from 'react'; import React from 'react';
import { useWaitForTransaction } from 'wagmi'; import { useWaitForTransaction } from 'wagmi';
import type { ResultComponentProps } from './methodForm/types';
import type { ContractMethodWriteResult } from './types'; import type { ContractMethodWriteResult } from './types';
import type { SmartContractWriteMethod } from 'types/api/contract';
import ContractWriteResultDumb from './ContractWriteResultDumb'; import ContractWriteResultDumb from './ContractWriteResultDumb';
interface Props { const ContractWriteResult = ({ result, onSettle }: ResultComponentProps<SmartContractWriteMethod>) => {
result: ContractMethodWriteResult;
onSettle: () => void;
}
const ContractWriteResult = ({ result, onSettle }: Props) => {
const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined; const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined;
const txInfo = useWaitForTransaction({ const txInfo = useWaitForTransaction({
hash: txHash, hash: txHash,
}); });
return <ContractWriteResultDumb result={ result } onSettle={ onSettle } txInfo={ txInfo }/>; return <ContractWriteResultDumb result={ result as ContractMethodWriteResult } onSettle={ onSettle } txInfo={ txInfo }/>;
}; };
export default React.memo(ContractWriteResult); export default React.memo(ContractWriteResult) as typeof ContractWriteResult;
import { IconButton, chakra } from '@chakra-ui/react';
import React from 'react';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
index: number;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
isDisabled?: boolean;
type: 'add' | 'remove';
className?: string;
}
const ContractMethodArrayButton = ({ className, type, index, onClick, isDisabled }: Props) => {
return (
<IconButton
className={ className }
aria-label={ type }
data-index={ index }
variant="outline"
w="20px"
h="20px"
flexShrink={ 0 }
onClick={ onClick }
icon={ <IconSvg name={ type === 'remove' ? 'minus' : 'plus' } boxSize={ 3 }/> }
isDisabled={ isDisabled }
/>
);
};
export default React.memo(chakra(ContractMethodArrayButton));
import { Accordion, AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import ContractMethodArrayButton from './ContractMethodArrayButton';
export interface Props {
label: string;
level: number;
children: React.ReactNode;
onAddClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onRemoveClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
index?: number;
isInvalid?: boolean;
}
const ContractMethodFieldAccordion = ({ label, level, children, onAddClick, onRemoveClick, index, isInvalid }: Props) => {
const bgColorLevel0 = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const bgColor = useColorModeValue('whiteAlpha.700', 'blackAlpha.700');
return (
<Accordion allowToggle w="100%" bgColor={ level === 0 ? bgColorLevel0 : bgColor } borderRadius="base">
<AccordionItem _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}>
{ ({ isExpanded }) => (
<>
<AccordionButton
as="div"
cursor="pointer"
px="6px"
py="6px"
wordBreak="break-all"
textAlign="left"
_hover={{ bgColor: 'inherit' }}
>
<AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/>
<Box fontSize="sm" lineHeight={ 5 } fontWeight={ 700 } mr="auto" ml={ 1 } color={ isInvalid ? 'error' : undefined }>
{ label }
</Box>
{ onRemoveClick && <ContractMethodArrayButton index={ index } onClick={ onRemoveClick } type="remove"/> }
{ onAddClick && <ContractMethodArrayButton index={ index } onClick={ onAddClick } type="add" ml={ 2 }/> }
</AccordionButton>
<AccordionPanel display="flex" flexDir="column" rowGap={ 1 } pl="18px" pr="6px">
{ children }
</AccordionPanel>
</>
) }
</AccordionItem>
</Accordion>
);
};
export default React.memo(ContractMethodFieldAccordion);
import { Box, Flex, FormControl, Input, InputGroup, InputRightElement, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import { NumericFormat } from 'react-number-format';
import type { SmartContractMethodInput } from 'types/api/contract';
import ClearButton from 'ui/shared/ClearButton';
import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import ContractMethodMultiplyButton from './ContractMethodMultiplyButton';
import useArgTypeMatchInt from './useArgTypeMatchInt';
import useValidateField from './useValidateField';
interface Props {
data: SmartContractMethodInput;
hideLabel?: boolean;
path: string;
className?: string;
isDisabled: boolean;
level: number;
}
const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDisabled, level }: Props) => {
const ref = React.useRef<HTMLInputElement>(null);
const isNativeCoin = data.fieldType === 'native_coin';
const isOptional = isNativeCoin;
const argTypeMatchInt = useArgTypeMatchInt({ argType: data.type });
const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt });
const { control, setValue, getValues } = useFormContext();
const { field, fieldState } = useController({ control, name, rules: { validate, required: isOptional ? false : 'Field is required' } });
const inputBgColor = useColorModeValue('white', 'black');
const nativeCoinRowBgColor = useColorModeValue('gray.100', 'gray.700');
const hasMultiplyButton = argTypeMatchInt && Number(argTypeMatchInt.power) >= 64;
const handleClear = React.useCallback(() => {
setValue(name, '');
ref.current?.focus();
}, [ name, setValue ]);
const handleMultiplyButtonClick = React.useCallback((power: number) => {
const zeroes = Array(power).fill('0').join('');
const value = getValues(name);
const newValue = value ? value + zeroes : '1' + zeroes;
setValue(name, newValue);
}, [ getValues, name, setValue ]);
const error = fieldState.error;
return (
<Flex
className={ className }
flexDir={{ base: 'column', md: 'row' }}
alignItems="flex-start"
columnGap={ 3 }
w="100%"
bgColor={ isNativeCoin ? nativeCoinRowBgColor : undefined }
borderRadius="base"
px="6px"
py={ isNativeCoin ? 1 : 0 }
>
{ !hideLabel && <ContractMethodFieldLabel data={ data } isOptional={ isOptional } level={ level }/> }
<FormControl isDisabled={ isDisabled }>
<InputGroup size="xs">
<Input
{ ...field }
{ ...(argTypeMatchInt ? {
as: NumericFormat,
thousandSeparator: ' ',
decimalScale: 0,
allowNegative: !argTypeMatchInt.isUnsigned,
} : {}) }
ref={ ref }
required={ !isOptional }
isInvalid={ Boolean(error) }
placeholder={ data.type }
autoComplete="off"
bgColor={ inputBgColor }
paddingRight={ hasMultiplyButton ? '120px' : '40px' }
/>
<InputRightElement w="auto" right={ 1 }>
{ typeof field.value === 'string' && field.value.replace('\n', '') && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
{ hasMultiplyButton && <ContractMethodMultiplyButton onClick={ handleMultiplyButtonClick } isDisabled={ isDisabled }/> }
</InputRightElement>
</InputGroup>
{ error && <Box color="error" fontSize="sm" lineHeight={ 5 } mt={ 1 }>{ error.message }</Box> }
</FormControl>
</Flex>
);
};
export default React.memo(chakra(ContractMethodFieldInput));
import { Flex } from '@chakra-ui/react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import type { SmartContractMethodInput, SmartContractMethodArgType } from 'types/api/contract';
import ContractMethodArrayButton from './ContractMethodArrayButton';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import { getFieldLabel } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput;
level: number;
basePath: string;
isDisabled: boolean;
}
const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRemoveClick, index: parentIndex, isDisabled }: Props) => {
const { formState: { errors } } = useFormContext();
const fieldsWithErrors = Object.keys(errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath));
const [ registeredIndices, setRegisteredIndices ] = React.useState([ 0 ]);
const handleAddButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
setRegisteredIndices((prev) => [ ...prev, prev[prev.length - 1] + 1 ]);
}, []);
const handleRemoveButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const itemIndex = event.currentTarget.getAttribute('data-index');
if (itemIndex) {
setRegisteredIndices((prev) => prev.filter((index) => index !== Number(itemIndex)));
}
}, [ ]);
const getItemData = (index: number) => {
const childrenType = data.type.slice(0, -2) as SmartContractMethodArgType;
const childrenInternalType = data.internalType?.slice(0, parentIndex !== undefined ? -4 : -2).replaceAll('struct ', '');
const namePostfix = childrenInternalType ? ' ' + childrenInternalType : '';
const nameParentIndex = parentIndex !== undefined ? `${ parentIndex + 1 }.` : '';
const nameIndex = index + 1;
return {
...data,
type: childrenType,
name: `#${ nameParentIndex + nameIndex }${ namePostfix }`,
};
};
const isNestedArray = data.type.includes('[][]');
if (isNestedArray) {
return (
<ContractMethodFieldAccordion
level={ level }
label={ getFieldLabel(data) }
isInvalid={ isInvalid }
>
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
return (
<ContractMethodFieldInputArray
key={ registeredIndex }
data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` }
level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
isDisabled={ isDisabled }
/>
);
}) }
</ContractMethodFieldAccordion>
);
}
const isTupleArray = data.type.includes('tuple');
if (isTupleArray) {
return (
<ContractMethodFieldAccordion
level={ level }
label={ getFieldLabel(data) }
onAddClick={ onAddClick }
onRemoveClick={ onRemoveClick }
index={ parentIndex }
isInvalid={ isInvalid }
>
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
return (
<ContractMethodFieldInputTuple
key={ registeredIndex }
data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` }
level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
isDisabled={ isDisabled }
/>
);
}) }
</ContractMethodFieldAccordion>
);
}
// primitive value array
return (
<Flex flexDir={{ base: 'column', md: 'row' }} alignItems="flex-start" columnGap={ 3 } px="6px">
<ContractMethodFieldLabel data={ data } level={ level }/>
<Flex flexDir="column" rowGap={ 1 } w="100%">
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
return (
<Flex key={ registeredIndex } alignItems="flex-start" columnGap={ 3 }>
<ContractMethodFieldInput
data={ itemData }
hideLabel
path={ `${ basePath }:${ index }` }
level={ level }
px={ 0 }
isDisabled={ isDisabled }
/>
{ registeredIndices.length > 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleRemoveButtonClick } type="remove" my="6px"/> }
{ index === registeredIndices.length - 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleAddButtonClick } type="add" my="6px"/> }
</Flex>
);
}) }
</Flex>
</Flex>
);
};
export default React.memo(ContractMethodFieldInputArray);
import React from 'react';
import { useFormContext } from 'react-hook-form';
import type { SmartContractMethodInput } from 'types/api/contract';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import { ARRAY_REGEXP, getFieldLabel } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput;
basePath: string;
level: number;
isDisabled: boolean;
}
const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...accordionProps }: Props) => {
const { formState: { errors } } = useFormContext();
const fieldsWithErrors = Object.keys(errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath));
return (
<ContractMethodFieldAccordion
{ ...accordionProps }
level={ level }
label={ getFieldLabel(data) }
isInvalid={ isInvalid }
>
{ data.components?.map((component, index) => {
if (component.components && component.type === 'tuple') {
return (
<ContractMethodFieldInputTuple
key={ index }
data={ component }
basePath={ `${ basePath }:${ index }` }
level={ level + 1 }
isDisabled={ isDisabled }
/>
);
}
const arrayMatch = component.type.match(ARRAY_REGEXP);
if (arrayMatch) {
const [ , itemType ] = arrayMatch;
return (
<ContractMethodFieldInputArray
key={ index }
data={ component }
basePath={ `${ basePath }:${ index }` }
level={ itemType === 'tuple' ? level + 1 : level }
isDisabled={ isDisabled }
/>
);
}
return (
<ContractMethodFieldInput
key={ index }
data={ component }
path={ `${ basePath }:${ index }` }
isDisabled={ isDisabled }
level={ level }
/>
);
}) }
</ContractMethodFieldAccordion>
);
};
export default React.memo(ContractMethodFieldInputTuple);
import { Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractMethodInput } from 'types/api/contract';
import { getFieldLabel } from './utils';
interface Props {
data: SmartContractMethodInput;
isOptional?: boolean;
level: number;
}
const ContractMethodFieldLabel = ({ data, isOptional, level }: Props) => {
const color = useColorModeValue('blackAlpha.600', 'whiteAlpha.600');
return (
<Box
w="250px"
fontSize="sm"
lineHeight={ 5 }
py="6px"
flexShrink={ 0 }
fontWeight={ 500 }
color={ level > 1 ? color : undefined }
>
{ getFieldLabel(data, !isOptional) }
</Box>
);
};
export default React.memo(ContractMethodFieldLabel);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import type { SmartContractWriteMethod } from 'types/api/contract';
import TestApp from 'playwright/TestApp';
import ContractMethodForm from './ContractMethodForm';
const onSubmit = () => Promise.resolve({ hash: '0x0000' as `0x${ string }` });
const resultComponent = () => null;
const data: SmartContractWriteMethod = {
inputs: [
// TUPLE
{
components: [
{ internalType: 'address', name: 'offerToken', type: 'address' },
{ internalType: 'uint256', name: 'offerIdentifier', type: 'uint256' },
{ internalType: 'enum BasicOrderType', name: 'basicOrderType', type: 'uint8' },
{
components: [
{ internalType: 'uint256', name: 'amount', type: 'uint256' },
{ internalType: 'address payable', name: 'recipient', type: 'address' },
],
internalType: 'struct AdditionalRecipient[]',
name: 'additionalRecipients',
type: 'tuple[]',
},
{ internalType: 'bytes', name: 'signature', type: 'bytes' },
],
internalType: 'struct BasicOrderParameters',
name: 'parameters',
type: 'tuple',
},
// NESTED ARRAY OF TUPLES
{
components: [
{
internalType: 'uint256',
name: 'orderIndex',
type: 'uint256',
},
{
internalType: 'uint256',
name: 'itemIndex',
type: 'uint256',
},
],
internalType: 'struct FulfillmentComponent[][]',
name: '',
type: 'tuple[][]',
},
// LITERALS
{ internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' },
{ internalType: 'address', name: 'recipient', type: 'address' },
{ internalType: 'uint256', name: 'maximumFulfilled', type: 'uint256' },
{ internalType: 'int8[]', name: 'criteriaProof', type: 'int8[]' },
],
method_id: '87201b41',
name: 'fulfillAvailableAdvancedOrders',
outputs: [
{ internalType: 'bool[]', name: '', type: 'bool[]' },
{
components: [
{
components: [
{ internalType: 'enum ItemType', name: 'itemType', type: 'uint8' },
{ internalType: 'address', name: 'token', type: 'address' },
{ internalType: 'uint256', name: 'identifier', type: 'uint256' },
{ internalType: 'uint256', name: 'amount', type: 'uint256' },
{ internalType: 'address payable', name: 'recipient', type: 'address' },
],
internalType: 'struct ReceivedItem',
name: 'item',
type: 'tuple',
},
{ internalType: 'address', name: 'offerer', type: 'address' },
{ internalType: 'bytes32', name: 'conduitKey', type: 'bytes32' },
],
internalType: 'struct Execution[]',
name: '',
type: 'tuple[]',
},
],
stateMutability: 'payable',
type: 'function',
payable: true,
constant: false,
};
test('base view +@mobile +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<ContractMethodForm<SmartContractWriteMethod>
data={ data }
onSubmit={ onSubmit }
resultComponent={ resultComponent }
methodType="write"
/>
</TestApp>,
);
// fill top level fields
await component.getByPlaceholder('address').last().fill('0x0000');
await component.getByPlaceholder('uint256').last().fill('42');
await component.getByRole('button', { name: '×' }).last().click();
await component.getByPlaceholder('bytes32').last().fill('aa');
await component.getByRole('button', { name: 'add' }).last().click();
await component.getByRole('button', { name: 'add' }).last().click();
await component.getByPlaceholder('int8', { exact: true }).first().fill('1');
await component.getByPlaceholder('int8', { exact: true }).last().fill('3');
// expand all sections
await component.getByText('parameters').click();
await component.getByText('additionalRecipients').click();
await component.getByText('#1 AdditionalRecipient').click();
await component.getByRole('button', { name: 'add' }).first().click();
await component.getByPlaceholder('uint256').nth(1).fill('42');
await component.getByPlaceholder('address').nth(1).fill('0xd789a607CEac2f0E14867de4EB15b15C9FFB5859');
await component.getByText('struct FulfillmentComponent[][]').click();
await component.getByRole('button', { name: 'add' }).nth(1).click();
await component.getByText('#1 FulfillmentComponent[]').click();
await component.getByText('#1.1 FulfillmentComponent').click();
await component.getByRole('button', { name: 'add' }).nth(1).click();
// submit form
await component.getByRole('button', { name: 'Write' }).click();
await expect(component).toHaveScreenshot();
});
import { Box, Button, chakra, Flex } from '@chakra-ui/react'; import { Box, Button, Flex, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import type { MethodFormFields, ContractMethodCallResult } from './types'; import type { ContractMethodCallResult } from '../types';
import type { SmartContractMethodInput, SmartContractMethod } from 'types/api/contract'; import type { ResultComponentProps } from './types';
import type { SmartContractMethod, SmartContractMethodInput } from 'types/api/contract';
import config from 'configs/app'; import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import ContractMethodCallableRow from './ContractMethodCallableRow'; import ContractMethodFieldInput from './ContractMethodFieldInput';
import { formatFieldValues, transformFieldsToArgs } from './utils'; import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
interface ResultComponentProps<T extends SmartContractMethod> { import ContractMethodFormOutputs from './ContractMethodFormOutputs';
item: T; import { ARRAY_REGEXP, transformFormDataToMethodArgs } from './utils';
result: ContractMethodCallResult<T>; import type { ContractMethodFormFields } from './utils';
onSettle: () => void;
}
interface Props<T extends SmartContractMethod> { interface Props<T extends SmartContractMethod> {
data: T; data: T;
onSubmit: (data: T, args: Array<string | Array<unknown>>) => Promise<ContractMethodCallResult<T>>; onSubmit: (data: T, args: Array<unknown>) => Promise<ContractMethodCallResult<T>>;
resultComponent: (props: ResultComponentProps<T>) => JSX.Element | null; resultComponent: (props: ResultComponentProps<T>) => JSX.Element | null;
isWrite?: boolean; methodType: 'read' | 'write';
} }
// groupName%groupIndex:inputName%inputIndex const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, methodType }: Props<T>) => {
const getFormFieldName = (input: { index: number; name: string }, group?: { index: number; name: string }) =>
`${ group ? `${ group.name }%${ group.index }:` : '' }${ input.name || 'input' }%${ input.index }`;
const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, isWrite }: Props<T>) => {
const [ result, setResult ] = React.useState<ContractMethodCallResult<T>>(); const [ result, setResult ] = React.useState<ContractMethodCallResult<T>>();
const [ isLoading, setLoading ] = React.useState(false); const [ isLoading, setLoading ] = React.useState(false);
const inputs: Array<SmartContractMethodInput> = React.useMemo(() => { const formApi = useForm<ContractMethodFormFields>({
return [ mode: 'all',
...('inputs' in data ? data.inputs : []), shouldUnregister: true,
...('stateMutability' in data && data.stateMutability === 'payable' ? [ {
name: `Send native ${ config.chain.currency.symbol || 'coin' }`,
type: 'uint256' as const,
internalType: 'uint256' as const,
fieldType: 'native_coin' as const,
} ] : []),
];
}, [ data ]);
const formApi = useForm<MethodFormFields>({
mode: 'onBlur',
}); });
const handleTxSettle = React.useCallback(() => { const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => {
setLoading(false); const args = transformFormDataToMethodArgs(formData);
}, []);
const handleFormChange = React.useCallback(() => {
result && setResult(undefined);
}, [ result ]);
const onFormSubmit: SubmitHandler<MethodFormFields> = React.useCallback(async(formData) => {
const formattedData = formatFieldValues(formData, inputs);
const args = transformFieldsToArgs(formattedData);
setResult(undefined); setResult(undefined);
setLoading(true); setLoading(true);
...@@ -76,11 +50,31 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -76,11 +50,31 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
}) })
.finally(() => { .finally(() => {
mixpanel.logEvent(mixpanel.EventTypes.CONTRACT_INTERACTION, { mixpanel.logEvent(mixpanel.EventTypes.CONTRACT_INTERACTION, {
'Method type': isWrite ? 'Write' : 'Read', 'Method type': methodType === 'write' ? 'Write' : 'Read',
'Method name': 'name' in data ? data.name : 'Fallback', 'Method name': 'name' in data ? data.name : 'Fallback',
}); });
}); });
}, [ inputs, onSubmit, data, isWrite ]); }, [ data, methodType, onSubmit ]);
const handleTxSettle = React.useCallback(() => {
setLoading(false);
}, []);
const handleFormChange = React.useCallback(() => {
result && setResult(undefined);
}, [ result ]);
const inputs: Array<SmartContractMethodInput> = React.useMemo(() => {
return [
...('inputs' in data ? data.inputs : []),
...('stateMutability' in data && data.stateMutability === 'payable' ? [ {
name: `Send native ${ config.chain.currency.symbol || 'coin' }`,
type: 'uint256' as const,
internalType: 'uint256' as const,
fieldType: 'native_coin' as const,
} ] : []),
];
}, [ data ]);
const outputs = 'outputs' in data && data.outputs ? data.outputs : []; const outputs = 'outputs' in data && data.outputs ? data.outputs : [];
...@@ -92,68 +86,23 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -92,68 +86,23 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
onSubmit={ formApi.handleSubmit(onFormSubmit) } onSubmit={ formApi.handleSubmit(onFormSubmit) }
onChange={ handleFormChange } onChange={ handleFormChange }
> >
<Flex <Flex flexDir="column" rowGap={ 3 } mb={ 6 } _empty={{ display: 'none' }}>
flexDir="column"
rowGap={ 3 }
mb={ 3 }
_empty={{ display: 'none' }}
>
{ inputs.map((input, index) => { { inputs.map((input, index) => {
const fieldName = getFormFieldName({ name: input.name, index }); if (input.components && input.type === 'tuple') {
return <ContractMethodFieldInputTuple key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>;
if (input.type === 'tuple' && input.components) {
return (
<React.Fragment key={ fieldName }>
{ index !== 0 && <><Box h={{ base: 0, lg: 3 }}/><div/></> }
<Box
fontWeight={ 500 }
lineHeight="20px"
py={{ lg: '6px' }}
fontSize="sm"
wordBreak="break-word"
>
{ input.name } ({ input.type })
</Box>
{ input.components.map((component, componentIndex) => {
const fieldName = getFormFieldName(
{ name: component.name, index: componentIndex },
{ name: input.name, index },
);
return (
<ContractMethodCallableRow
key={ fieldName }
fieldName={ fieldName }
argName={ component.name }
argType={ component.type }
isDisabled={ isLoading }
onChange={ handleFormChange }
isGrouped
/>
);
}) }
{ index !== inputs.length - 1 && <><Box h={{ base: 0, lg: 3 }}/><div/></> }
</React.Fragment>
);
} }
return ( const arrayMatch = input.type.match(ARRAY_REGEXP);
<ContractMethodCallableRow if (arrayMatch) {
key={ fieldName } return <ContractMethodFieldInputArray key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>;
fieldName={ fieldName } }
fieldType={ input.fieldType }
argName={ input.name } return <ContractMethodFieldInput key={ index } data={ input } path={ `${ index }` } isDisabled={ isLoading } level={ 0 }/>;
argType={ input.type }
isDisabled={ isLoading }
isOptional={ input.fieldType === 'native_coin' && inputs.length > 1 }
onChange={ handleFormChange }
/>
);
}) } }) }
</Flex> </Flex>
<Button <Button
isLoading={ isLoading } isLoading={ isLoading }
loadingText={ isWrite ? 'Write' : 'Read' } loadingText={ methodType === 'write' ? 'Write' : 'Read' }
variant="outline" variant="outline"
size="sm" size="sm"
flexShrink={ 0 } flexShrink={ 0 }
...@@ -161,29 +110,14 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -161,29 +110,14 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
px={ 4 } px={ 4 }
type="submit" type="submit"
> >
{ isWrite ? 'Write' : 'Read' } { methodType === 'write' ? 'Write' : 'Read' }
</Button> </Button>
</chakra.form> </chakra.form>
</FormProvider> </FormProvider>
{ !isWrite && outputs.length > 0 && ( { methodType === 'read' && <ContractMethodFormOutputs data={ outputs }/> }
<Flex mt={ 3 } fontSize="sm">
<IconSvg name="arrows/down-right" boxSize={ 5 } mr={ 1 }/>
<p>
{ outputs.map(({ type, name }, index) => {
return (
<>
<chakra.span fontWeight={ 500 }>{ name } </chakra.span>
<span>{ name ? `(${ type })` : type }</span>
{ index < outputs.length - 1 && <span>, </span> }
</>
);
}) }
</p>
</Flex>
) }
{ result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> } { result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> }
</Box> </Box>
); );
}; };
export default React.memo(ContractMethodCallable) as typeof ContractMethodCallable; export default React.memo(ContractMethodForm) as typeof ContractMethodForm;
import { Flex, chakra } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractMethodOutput } from 'types/api/contract';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
data: Array<SmartContractMethodOutput>;
}
const ContractMethodFormOutputs = ({ data }: Props) => {
if (data.length === 0) {
return null;
}
return (
<Flex mt={ 3 } fontSize="sm">
<IconSvg name="arrows/down-right" boxSize={ 5 } mr={ 1 }/>
<p>
{ data.map(({ type, name }, index) => {
return (
<>
<chakra.span fontWeight={ 500 }>{ name } </chakra.span>
<span>{ name ? `(${ type })` : type }</span>
{ index < data.length - 1 && <span>, </span> }
</>
);
}) }
</p>
</Flex>
);
};
export default React.memo(ContractMethodFormOutputs);
...@@ -21,7 +21,7 @@ interface Props { ...@@ -21,7 +21,7 @@ interface Props {
isDisabled?: boolean; isDisabled?: boolean;
} }
const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => { const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => {
const [ selectedOption, setSelectedOption ] = React.useState<number | undefined>(18); const [ selectedOption, setSelectedOption ] = React.useState<number | undefined>(18);
const [ customValue, setCustomValue ] = React.useState<number>(); const [ customValue, setCustomValue ] = React.useState<number>();
const { isOpen, onToggle, onClose } = useDisclosure(); const { isOpen, onToggle, onClose } = useDisclosure();
...@@ -78,7 +78,14 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => { ...@@ -78,7 +78,14 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => {
onClick={ onToggle } onClick={ onToggle }
isDisabled={ isDisabled } isDisabled={ isDisabled }
> >
<IconSvg name="arrows/east-mini" transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } boxSize={ 6 }/> <IconSvg
name="arrows/east-mini"
transitionDuration="fast"
transitionProperty="transform"
transitionTimingFunction="ease-in-out"
transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' }
boxSize={ 6 }
/>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<Portal> <Portal>
...@@ -126,4 +133,4 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => { ...@@ -126,4 +133,4 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => {
); );
}; };
export default React.memo(ContractMethodFieldZeroes); export default React.memo(ContractMethodMultiplyButton);
import type { ContractMethodCallResult } from '../types';
import type { SmartContractMethod } from 'types/api/contract';
export interface ResultComponentProps<T extends SmartContractMethod> {
item: T;
result: ContractMethodCallResult<T>;
onSettle: () => void;
}
import type { SmartContractMethodArgType } from 'types/api/contract';
import { INT_REGEXP, getIntBoundaries } from './utils';
interface Params {
argType: SmartContractMethodArgType;
}
export interface MatchInt {
isUnsigned: boolean;
power: string;
min: number;
max: number;
}
export default function useArgTypeMatchInt({ argType }: Params): MatchInt | null {
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: Boolean(isUnsigned), power, min, max };
}
import React from 'react';
import { getAddress, isAddress, isHex } from 'viem';
import type { SmartContractMethodArgType } from 'types/api/contract';
import type { MatchInt } from './useArgTypeMatchInt';
import { BYTES_REGEXP, formatBooleanValue } from './utils';
interface Params {
argType: SmartContractMethodArgType;
argTypeMatchInt: MatchInt | null;
isOptional: boolean;
}
export default function useValidateField({ isOptional, argType, argTypeMatchInt }: Params) {
const bytesMatch = React.useMemo(() => {
return argType.match(BYTES_REGEXP);
}, [ argType ]);
return React.useCallback((value: string | undefined) => {
if (!value) {
return isOptional ? true : '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 (argTypeMatchInt) {
const formattedValue = Number(value.replace(/\s/g, ''));
if (Object.is(formattedValue, NaN)) {
return 'Invalid integer format';
}
if (formattedValue > argTypeMatchInt.max || formattedValue < argTypeMatchInt.min) {
const lowerBoundary = argTypeMatchInt.isUnsigned ? '0' : `-1 * 2 ^ ${ Number(argTypeMatchInt.power) - 1 }`;
const upperBoundary = argTypeMatchInt.isUnsigned ? `2 ^ ${ argTypeMatchInt.power } - 1` : `2 ^ ${ Number(argTypeMatchInt.power) - 1 } - 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, argTypeMatchInt, bytesMatch ]);
}
import { transformFormDataToMethodArgs } from './utils';
describe('transformFormDataToMethodArgs', () => {
it('should transform form data to method args array', () => {
const formData = {
'1': '1',
'2': '2',
'0:1': '0:1',
'0:0:0': '0:0:0',
'0:0:1:0': '0:0:1:0',
'0:0:1:3': '0:0:1:3',
'0:0:2:1:0': '0:0:2:1:0',
'0:0:2:1:1': '0:0:2:1:1',
'0:0:2:2:0': '0:0:2:2:0',
'0:0:2:2:2': '0:0:2:2:2',
'0:0:2:5:3': '0:0:2:5:3',
'0:0:2:5:8': '0:0:2:5:8',
};
const result = transformFormDataToMethodArgs(formData);
expect(result).toEqual([
[
[
'0:0:0',
[
'0:0:1:0',
'0:0:1:3',
],
[
[
'0:0:2:1:0',
'0:0:2:1:1',
],
[
'0:0:2:2:0',
'0:0:2:2:2',
],
[
'0:0:2:5:3',
'0:0:2:5:8',
],
],
],
'0:1',
],
'1',
'2',
]);
});
});
import _set from 'lodash/set';
import type { SmartContractMethodInput } from 'types/api/contract';
export type ContractMethodFormFields = Record<string, string | undefined>;
export const INT_REGEXP = /^(u)?int(\d+)?$/i;
export const BYTES_REGEXP = /^bytes(\d+)?$/i;
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 formatBooleanValue = (value: string) => {
const formattedValue = value.toLowerCase();
switch (formattedValue) {
case 'true':
case '1': {
return 'true';
}
case 'false':
case '0': {
return 'false';
}
default:
return;
}
};
export function transformFormDataToMethodArgs(formData: ContractMethodFormFields) {
const result: Array<unknown> = [];
for (const field in formData) {
const value = formData[field];
if (value !== undefined) {
_set(result, field.replaceAll(':', '.'), value);
}
}
return filterOurEmptyItems(result);
}
function filterOurEmptyItems(array: Array<unknown>): Array<unknown> {
// The undefined value may occur in two cases:
// 1. When an optional form field is left blank by the user.
// The only optional field is the native coin value, which is safely handled in the form submit handler.
// 2. When the user adds and removes items from a field array.
// In this scenario, empty items need to be filtered out to maintain the correct sequence of arguments.
return array
.map((item) => Array.isArray(item) ? filterOurEmptyItems(item) : item)
.filter((item) => item !== undefined);
}
export function getFieldLabel(input: SmartContractMethodInput, isRequired?: boolean) {
const name = input.name || input.internalType || '<unnamed argument>';
return `${ name } (${ input.type })${ isRequired ? '*' : '' }`;
}
import type { SmartContractQueryMethodRead, SmartContractMethod } from 'types/api/contract'; import type { SmartContractQueryMethodRead, SmartContractMethod, SmartContractReadMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
...@@ -12,4 +12,4 @@ export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceEr ...@@ -12,4 +12,4 @@ export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceEr
export type ContractMethodWriteResult = Error | { hash: `0x${ string }` | undefined } | undefined; export type ContractMethodWriteResult = Error | { hash: `0x${ string }` | undefined } | undefined;
export type ContractMethodCallResult<T extends SmartContractMethod> = export type ContractMethodCallResult<T extends SmartContractMethod> =
T extends { method_id: string } ? ContractMethodReadResult : ContractMethodWriteResult; T extends SmartContractReadMethod ? ContractMethodReadResult : ContractMethodWriteResult;
import type { SmartContractMethodInput } from 'types/api/contract'; import { prepareAbi } from './utils';
import { prepareAbi, transformFieldsToArgs, formatFieldValues } from './utils';
describe('function prepareAbi()', () => { describe('function prepareAbi()', () => {
const commonAbi = [ const commonAbi = [
...@@ -48,6 +46,7 @@ describe('function prepareAbi()', () => { ...@@ -48,6 +46,7 @@ describe('function prepareAbi()', () => {
type: 'function' as const, type: 'function' as const,
constant: false, constant: false,
payable: true, payable: true,
method_id: '0x2e0e2d3e',
}; };
it('if there is only one method with provided name, does nothing', () => { it('if there is only one method with provided name, does nothing', () => {
...@@ -100,100 +99,3 @@ describe('function prepareAbi()', () => { ...@@ -100,100 +99,3 @@ describe('function prepareAbi()', () => {
expect(item).toEqual(commonAbi[2]); 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<SmartContractMethodInput> = [
{
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<SmartContractMethodInput> = [
{ 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' ],
]);
});
});
import type { Abi } from 'abitype'; import type { Abi } from 'abitype';
import _mapValues from 'lodash/mapValues';
import type { MethodArgType, MethodFormFields, MethodFormFieldsFormatted } from './types'; import type { SmartContractWriteMethod } from 'types/api/contract';
import type { SmartContractMethodArgType, SmartContractMethodInput, SmartContractWriteMethod } from 'types/api/contract';
export const INT_REGEXP = /^(u)?int(\d+)?$/i; export const getNativeCoinValue = (value: unknown) => {
if (typeof value !== 'string') {
export const BYTES_REGEXP = /^bytes(\d+)?$/i;
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 formatBooleanValue = (value: string) => {
const formattedValue = value.toLowerCase();
switch (formattedValue) {
case 'true':
case '1': {
return 'true';
}
case 'false':
case '0': {
return 'false';
}
default:
return;
}
};
export const getNativeCoinValue = (value: string | Array<unknown>) => {
const _value = Array.isArray(value) ? value[0] : value;
if (typeof _value !== 'string') {
return BigInt(0); return BigInt(0);
} }
return BigInt(_value); return BigInt(value);
}; };
interface ExtendedError extends Error {
detectedNetwork?: {
chain: number;
name: string;
};
reason?: string;
}
export function isExtendedError(error: unknown): error is ExtendedError {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as Record<string, unknown>).message === 'string'
);
}
export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi { export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi {
if ('name' in item) { if ('name' in item) {
const hasMethodsWithSameName = abi.filter((abiItem) => 'name' in abiItem ? abiItem.name === item.name : false).length > 1; const hasMethodsWithSameName = abi.filter((abiItem) => 'name' in abiItem ? abiItem.name === item.name : false).length > 1;
...@@ -91,107 +38,3 @@ export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi { ...@@ -91,107 +38,3 @@ export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi {
return abi; return abi;
} }
function getFieldType(fieldName: string, inputs: Array<SmartContractMethodInput>) {
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<string>;
}
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;
const isNestedTuple = type.includes('tuple');
if (isNestedArray || isNestedTuple) {
return parseArrayValue(value) || value;
}
return value;
}
export function formatFieldValues(formFields: MethodFormFields, inputs: Array<SmartContractMethodInput>) {
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<string, MethodArgType>,
[ 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<MethodArgType>)[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<string> ]>)
.sort((a, b) => a[0] - b[0])
.map(([ , value ]) => value);
return args;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment