Skip to content

Commit

Permalink
Merge pull request #1413 from blockscout/tom2drum/issue-1035
Browse files Browse the repository at this point in the history
contract interaction: validate args inputs
  • Loading branch information
tom2drum authored Jan 10, 2024
2 parents a3d1398 + d72af8e commit 596ad55
Show file tree
Hide file tree
Showing 34 changed files with 719 additions and 172 deletions.
3 changes: 3 additions & 0 deletions jest/setup.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import dotenv from 'dotenv';
import { TextEncoder, TextDecoder } from 'util';

import fetchMock from 'jest-fetch-mock';

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 => ({
Expand Down
12 changes: 6 additions & 6 deletions lib/hooks/useContractTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,22 @@ export default function useContractTabs(data: Address | undefined) {
// { id: 'contact_decompiled_code', title: 'Decompiled code', component: <div>Decompiled code</div> } :
// undefined,
data?.has_methods_read ?
{ id: 'read_contract', title: 'Read contract', component: <ContractRead addressHash={ data?.hash }/> } :
{ id: 'read_contract', title: 'Read contract', component: <ContractRead/> } :
undefined,
data?.has_methods_read_proxy ?
{ id: 'read_proxy', title: 'Read proxy', component: <ContractRead addressHash={ data?.hash } isProxy/> } :
{ id: 'read_proxy', title: 'Read proxy', component: <ContractRead/> } :
undefined,
data?.has_custom_methods_read ?
{ id: 'read_custom_methods', title: 'Read custom', component: <ContractRead addressHash={ data?.hash } isCustomAbi/> } :
{ id: 'read_custom_methods', title: 'Read custom', component: <ContractRead/> } :
undefined,
data?.has_methods_write ?
{ id: 'write_contract', title: 'Write contract', component: <ContractWrite addressHash={ data?.hash }/> } :
{ id: 'write_contract', title: 'Write contract', component: <ContractWrite/> } :
undefined,
data?.has_methods_write_proxy ?
{ id: 'write_proxy', title: 'Write proxy', component: <ContractWrite addressHash={ data?.hash } isProxy/> } :
{ id: 'write_proxy', title: 'Write proxy', component: <ContractWrite/> } :
undefined,
data?.has_custom_methods_write ?
{ id: 'write_custom_methods', title: 'Write custom', component: <ContractWrite addressHash={ data?.hash } isCustomAbi/> } :
{ id: 'write_custom_methods', title: 'Write custom', component: <ContractWrite/> } :
undefined,
].filter(Boolean);
}, [ data ]);
Expand Down
2 changes: 1 addition & 1 deletion mocks/contract/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const read: Array<SmartContractReadMethod> = [
{
constant: true,
inputs: [
{ internalType: 'address', name: '', type: 'address' },
{ internalType: 'address', name: 'wallet', type: 'address' },
],
method_id: '70a08231',
name: 'FLASHLOAN_PREMIUM_TOTAL',
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion theme/components/Alert/Alert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
6 changes: 4 additions & 2 deletions types/api/contract.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -88,6 +88,8 @@ export interface SmartContractMethodInput {
internalType?: SmartContractMethodArgType;
name: string;
type: SmartContractMethodArgType;
components?: Array<SmartContractMethodInput>;
fieldType?: 'native_coin';
}

export interface SmartContractMethodOutput extends SmartContractMethodInput {
Expand Down
176 changes: 92 additions & 84 deletions ui/address/contract/ContractMethodCallable.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends SmartContractMethod> {
item: T;
Expand All @@ -25,42 +26,9 @@ interface Props<T extends SmartContractMethod> {
isWrite?: boolean;
}

const getFieldName = (name: string | undefined, index: number): string => name || String(index);

const sortFields = (data: Array<SmartContractMethodInput>) => ([ 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<SmartContractMethodInput>) => ([ 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 = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, isWrite }: Props<T>) => {

Expand All @@ -71,15 +39,16 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ 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<MethodFormFields>({
defaultValues: _fromPairs(inputs.map(({ name }, index) => [ getFieldName(name, index), '' ])),
const formApi = useForm<MethodFormFields>({
mode: 'onBlur',
});

const handleTxSettle = React.useCallback(() => {
Expand All @@ -91,10 +60,8 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
}, [ result ]);

const onFormSubmit: SubmitHandler<MethodFormFields> = 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);
Expand All @@ -117,46 +84,87 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,

return (
<Box>
<chakra.form
noValidate
display="flex"
columnGap={ 3 }
flexDir={{ base: 'column', lg: 'row' }}
rowGap={ 2 }
alignItems={{ base: 'flex-start', lg: 'center' }}
onSubmit={ handleSubmit(onFormSubmit) }
flexWrap="wrap"
onChange={ handleFormChange }
>
{ inputs.map(({ type, name }, index) => {
const fieldName = getFieldName(name, index);
return (
<ContractMethodField
key={ fieldName }
name={ fieldName }
valueType={ type }
placeholder={ `${ name }(${ type })` }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isLoading }
onChange={ handleFormChange }
/>
);
}) }
<Button
isLoading={ isLoading }
loadingText={ isWrite ? 'Write' : 'Query' }
variant="outline"
size="sm"
flexShrink={ 0 }
type="submit"
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ formApi.handleSubmit(onFormSubmit) }
onChange={ handleFormChange }
>
{ isWrite ? 'Write' : 'Query' }
</Button>
</chakra.form>
<Flex
flexDir="column"
rowGap={ 3 }
mb={ 3 }
_empty={{ display: 'none' }}
>
{ inputs.map((input, index) => {
const fieldName = getFormFieldName({ name: input.name, index });

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 (
<ContractMethodCallableRow
key={ fieldName }
fieldName={ fieldName }
fieldType={ input.fieldType }
argName={ input.name }
argType={ input.type }
isDisabled={ isLoading }
isOptional={ input.fieldType === 'native_coin' && inputs.length > 1 }
onChange={ handleFormChange }
/>
);
}) }
</Flex>
<Button
isLoading={ isLoading }
loadingText={ isWrite ? 'Write' : 'Read' }
variant="outline"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
type="submit"
>
{ isWrite ? 'Write' : 'Read' }
</Button>
</chakra.form>
</FormProvider>
{ 'outputs' in data && !isWrite && data.outputs.length > 0 && (
<Flex mt={ 3 }>
<Flex mt={ 3 } fontSize="sm">
<IconSvg name="arrows/down-right" boxSize={ 5 } mr={ 1 }/>
<p>
{ data.outputs.map(({ type, name }, index) => {
Expand Down
84 changes: 84 additions & 0 deletions ui/address/contract/ContractMethodCallableRow.tsx
Original file line number Diff line number Diff line change
@@ -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<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);
Loading

0 comments on commit 596ad55

Please sign in to comment.