Merge branch 'main' of github.com:blockscout/frontend into tom2drum/issue-1405
@@ -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 => ({
|
||||
|
||||
@@ -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 ]);
|
||||
|
||||
@@ -170,7 +170,6 @@ export default function useNavItems(): ReturnType {
|
||||
{
|
||||
text: 'Verify contract',
|
||||
nextRoute: { pathname: '/contract-verification' as const },
|
||||
icon: 'verify-contract',
|
||||
isActive: pathname.startsWith('/contract-verification'),
|
||||
},
|
||||
...config.UI.sidebar.otherLinks,
|
||||
|
||||
@@ -9,12 +9,12 @@ 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',
|
||||
outputs: [
|
||||
{ internalType: 'uint256', name: '', type: 'uint256' },
|
||||
{ internalType: 'uint256', name: 'amount', type: 'uint256' },
|
||||
],
|
||||
payable: false,
|
||||
stateMutability: 'view',
|
||||
@@ -97,7 +97,7 @@ export const read: Array<SmartContractReadMethod> = [
|
||||
export const readResultSuccess: SmartContractQueryMethodReadSuccess = {
|
||||
is_error: false,
|
||||
result: {
|
||||
names: [ 'uint256' ],
|
||||
names: [ 'amount' ],
|
||||
output: [
|
||||
{ type: 'uint256', value: '42' },
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -29,6 +29,9 @@ export const featureEnvs = {
|
||||
value: '[{"type":"omni","title":"OmniBridge","short_title":"OMNI"},{"type":"amb","title":"Arbitrary Message Bridge","short_title":"AMB"}]',
|
||||
},
|
||||
],
|
||||
txInterpretation: [
|
||||
{ name: 'NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER', value: 'blockscout' },
|
||||
],
|
||||
zkRollup: [
|
||||
{ name: 'NEXT_PUBLIC_IS_ZKEVM_L2_NETWORK', value: 'true' },
|
||||
{ name: 'NEXT_PUBLIC_L1_BASE_URL', value: 'https://localhost:3101' },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<SmartContractMethodInput>;
|
||||
fieldType?: 'native_coin';
|
||||
}
|
||||
|
||||
export interface SmartContractMethodOutput extends SmartContractMethodInput {
|
||||
@@ -97,10 +99,10 @@ export interface SmartContractMethodOutput extends SmartContractMethodInput {
|
||||
export interface SmartContractQueryMethodReadSuccess {
|
||||
is_error: false;
|
||||
result: {
|
||||
names: Array<string>;
|
||||
names: Array<string | [ string, Array<string> ]>;
|
||||
output: Array<{
|
||||
type: string;
|
||||
value: string;
|
||||
value: string | Array<unknown>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Box, Button, chakra, Flex, Text } from '@chakra-ui/react';
|
||||
import _fromPairs from 'lodash/fromPairs';
|
||||
import { Box, Button, chakra, Flex } from '@chakra-ui/react';
|
||||
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;
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -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);
|
||||
@@ -117,48 +84,99 @@ 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 }/>
|
||||
<Text>{ data.outputs.map(({ type }) => type).join(', ') }</Text>
|
||||
<p>
|
||||
{ data.outputs.map(({ type, name }, index) => {
|
||||
return (
|
||||
<>
|
||||
<chakra.span fontWeight={ 500 }>{ name } </chakra.span>
|
||||
<span>{ name ? `(${ type })` : type }</span>
|
||||
{ index < data.outputs.length - 1 && <span>, </span> }
|
||||
</>
|
||||
);
|
||||
}) }
|
||||
</p>
|
||||
</Flex>
|
||||
) }
|
||||
{ result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> }
|
||||
|
||||
@@ -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);
|
||||
@@ -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<MethodFormFields>;
|
||||
setValue: UseFormSetValue<MethodFormFields>;
|
||||
getValues: UseFormGetValues<MethodFormFields>;
|
||||
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<HTMLInputElement>(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 hasZerosControl = addZeroesAllowed(valueType);
|
||||
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;
|
||||
|
||||
const renderInput = React.useCallback(({ field }: { field: ControllerRenderProps<MethodFormFields> }) => {
|
||||
return (
|
||||
<FormControl
|
||||
id={ name }
|
||||
flexBasis={{ base: '100%', lg: 'calc((100% - 24px) / 3 - 65px)' }}
|
||||
w={{ base: '100%', lg: 'auto' }}
|
||||
flexGrow={ 1 }
|
||||
isDisabled={ isDisabled }
|
||||
>
|
||||
<InputGroup size="xs">
|
||||
<Input
|
||||
{ ...field }
|
||||
ref={ ref }
|
||||
placeholder={ placeholder }
|
||||
paddingRight={ hasZerosControl ? '120px' : '40px' }
|
||||
/>
|
||||
<InputRightElement w="auto" right={ 1 }>
|
||||
{ field.value && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
|
||||
{ hasZerosControl && <ContractMethodFieldZeroes onClick={ handleAddZeroesClick } isDisabled={ isDisabled }/> }
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
<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>
|
||||
);
|
||||
}, [ name, isDisabled, placeholder, hasZerosControl, handleClear, handleAddZeroesClick ]);
|
||||
}, [ 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 }}
|
||||
/>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<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);
|
||||
@@ -11,9 +11,10 @@ interface Props<T extends SmartContractMethod> {
|
||||
data: Array<T>;
|
||||
addressHash?: string;
|
||||
renderItemContent: (item: T, index: number, id: number) => React.ReactNode;
|
||||
tab: string;
|
||||
}
|
||||
|
||||
const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, addressHash, renderItemContent }: Props<T>) => {
|
||||
const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, addressHash, renderItemContent, tab }: Props<T>) => {
|
||||
const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(data.length === 1 ? [ 0 ] : []);
|
||||
const [ id, setId ] = React.useState(0);
|
||||
|
||||
@@ -79,6 +80,7 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address
|
||||
index={ index }
|
||||
addressHash={ addressHash }
|
||||
renderContent={ renderItemContent as (item: SmartContractMethod, index: number, id: number) => React.ReactNode }
|
||||
tab={ tab }
|
||||
/>
|
||||
)) }
|
||||
</Accordion>
|
||||
|
||||
@@ -16,9 +16,10 @@ interface Props<T extends SmartContractMethod> {
|
||||
id: number;
|
||||
addressHash?: string;
|
||||
renderContent: (item: T, index: number, id: number) => React.ReactNode;
|
||||
tab: string;
|
||||
}
|
||||
|
||||
const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, index, id, addressHash, renderContent }: Props<T>) => {
|
||||
const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, index, id, addressHash, renderContent, tab }: Props<T>) => {
|
||||
const url = React.useMemo(() => {
|
||||
if (!('method_id' in data)) {
|
||||
return '';
|
||||
@@ -28,11 +29,11 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ 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 = <T extends SmartContractMethod>({ data, ind
|
||||
<AccordionIcon/>
|
||||
</AccordionButton>
|
||||
</Element>
|
||||
<AccordionPanel pb={ 4 } px={ 0 }>
|
||||
<AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)">
|
||||
{ renderContent(data, index, id) }
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -28,7 +28,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
|
||||
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
<ContractRead addressHash={ addressHash }/>
|
||||
<ContractRead/>
|
||||
</TestApp>,
|
||||
{ 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();
|
||||
|
||||
|
||||
@@ -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 && <ContractCustomAbiAlert/> }
|
||||
{ account && <ContractConnectWallet/> }
|
||||
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> }
|
||||
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent }/>
|
||||
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -99,3 +99,34 @@ test('success', async({ mount }) => {
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test('complex success', async({ mount }) => {
|
||||
const result: ContractMethodReadResult = {
|
||||
is_error: false,
|
||||
result: {
|
||||
names: [
|
||||
[
|
||||
'data',
|
||||
[ 'totalSupply', 'owner', 'symbol' ],
|
||||
],
|
||||
'supports721',
|
||||
'page',
|
||||
],
|
||||
output: [
|
||||
{
|
||||
type: 'tuple[uint256,address,string]',
|
||||
value: [ 1000, '0xe150519ae293922cfe6217feba3add4726f5e851', 'AOC_INCUBATORS' ],
|
||||
},
|
||||
{ type: 'bool', value: 'true' },
|
||||
{ type: 'uint256[]', value: [ 1, 2, 3, 4, 5 ] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
|
||||
</TestApp>,
|
||||
);
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
||||
@@ -2,17 +2,55 @@ import { Alert, Box, chakra, useColorModeValue } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { ContractMethodReadResult } from './types';
|
||||
import type { SmartContractReadMethod } from 'types/api/contract';
|
||||
import type { SmartContractQueryMethodReadSuccess, SmartContractReadMethod } from 'types/api/contract';
|
||||
|
||||
import hexToUtf8 from 'lib/hexToUtf8';
|
||||
|
||||
const TUPLE_TYPE_REGEX = /\[(.+)\]/;
|
||||
|
||||
const ContractReadResultError = ({ children }: {children: React.ReactNode}) => {
|
||||
return (
|
||||
<Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm" wordBreak="break-word" whiteSpace="pre-wrap">
|
||||
{ children }
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps {
|
||||
output: SmartContractQueryMethodReadSuccess['result']['output'][0];
|
||||
name: SmartContractQueryMethodReadSuccess['result']['names'][0];
|
||||
}
|
||||
|
||||
const ContractReadResultItem = ({ output, name }: ItemProps) => {
|
||||
if (Array.isArray(name)) {
|
||||
const [ structName, argNames ] = name;
|
||||
const argTypes = output.type.match(TUPLE_TYPE_REGEX)?.[1].split(',');
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<chakra.span fontWeight={ 500 }> { structName }</chakra.span>
|
||||
<span> ({ output.type }) :</span>
|
||||
</p>
|
||||
{ argNames.map((argName, argIndex) => {
|
||||
return (
|
||||
<p key={ argName }>
|
||||
<chakra.span fontWeight={ 500 }> { argName }</chakra.span>
|
||||
<span>{ argTypes?.[argIndex] ? ` (${ argTypes[argIndex] })` : '' } : { String(output.value[argIndex]) }</span>
|
||||
</p>
|
||||
);
|
||||
}) }
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p>
|
||||
<span> </span>
|
||||
{ name && <chakra.span fontWeight={ 500 }>{ name } </chakra.span> }
|
||||
<span>({ output.type }) : { String(output.value) }</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -53,14 +91,12 @@ const ContractReadResult = ({ item, result, onSettle }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm">
|
||||
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm" whiteSpace="break-spaces" wordBreak="break-all">
|
||||
<p>
|
||||
[ <chakra.span fontWeight={ 600 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
|
||||
[ <chakra.span fontWeight={ 500 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
|
||||
</p>
|
||||
<p>[</p>
|
||||
{ result.result.output.map(({ type, value }, index) => (
|
||||
<chakra.p key={ index } whiteSpace="break-spaces" wordBreak="break-all"> { type }: { String(value) }</chakra.p>
|
||||
)) }
|
||||
{ result.result.output.map((output, index) => <ContractReadResultItem key={ index } output={ output } name={ result.result.names[index] }/>) }
|
||||
<p>]</p>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ test('base view +@mobile', async({ mount, page }) => {
|
||||
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
<ContractWrite addressHash={ addressHash }/>
|
||||
<ContractWrite/>
|
||||
</TestApp>,
|
||||
{ hooksConfig },
|
||||
);
|
||||
|
||||
@@ -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 && <ContractCustomAbiAlert/> }
|
||||
<ContractConnectWallet/>
|
||||
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> }
|
||||
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent }/>
|
||||
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -82,7 +82,6 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => {
|
||||
return (
|
||||
<Box
|
||||
fontSize="sm"
|
||||
pl={ 3 }
|
||||
mt={ 3 }
|
||||
alignItems="center"
|
||||
whiteSpace="pre-wrap"
|
||||
|
||||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.5 KiB |
@@ -2,7 +2,10 @@ import type { SmartContractQueryMethodRead, SmartContractMethod } from 'types/ap
|
||||
|
||||
import type { ResourceError } from 'lib/api/resources';
|
||||
|
||||
export type MethodFormFields = Record<string, string>;
|
||||
export type MethodFormFields = Record<string, string | Array<string>>;
|
||||
export type MethodFormFieldsFormatted = Record<string, MethodArgType>;
|
||||
|
||||
export type MethodArgType = string | boolean | Array<MethodArgType>;
|
||||
|
||||
export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceError;
|
||||
|
||||
|
||||
@@ -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<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' ],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
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 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 const getNativeCoinValue = (value: string | Array<unknown>) => {
|
||||
const _value = Array.isArray(value) ? value[0] : value;
|
||||
@@ -12,24 +46,6 @@ export const getNativeCoinValue = (value: string | Array<unknown>) => {
|
||||
return BigInt(_value);
|
||||
};
|
||||
|
||||
export const addZeroesAllowed = (valueType: string) => {
|
||||
if (valueType.includes('[')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const REGEXP = /^u?int(\d+)/i;
|
||||
|
||||
const match = valueType.match(REGEXP);
|
||||
const power = match?.[1];
|
||||
|
||||
if (power) {
|
||||
// show control for all inputs which allows to insert 10^18 or greater numbers
|
||||
return Number(power) >= 64;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
interface ExtendedError extends Error {
|
||||
detectedNetwork?: {
|
||||
chain: number;
|
||||
@@ -75,3 +91,106 @@ export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): 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;
|
||||
if (isNestedArray) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test, expect } from '@playwright/experimental-ct-react';
|
||||
import React from 'react';
|
||||
|
||||
import { txInterpretation } from 'mocks/txs/txInterpretation';
|
||||
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
|
||||
import TestApp from 'playwright/TestApp';
|
||||
import buildApiUrl from 'playwright/utils/buildApiUrl';
|
||||
import * as configs from 'playwright/utils/configs';
|
||||
|
||||
import TxSubHeading from './TxSubHeading';
|
||||
|
||||
const hash = '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193';
|
||||
|
||||
const TX_INTERPRETATION_API_URL = buildApiUrl('tx_interpretation', { hash });
|
||||
|
||||
test('no interpretation +@mobile', async({ mount }) => {
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
<TxSubHeading hash={ hash } hasTag={ false }/>
|
||||
</TestApp>,
|
||||
);
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
||||
const bsInterpretationTest = test.extend({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: contextWithEnvs(configs.featureEnvs.txInterpretation) as any,
|
||||
});
|
||||
|
||||
bsInterpretationTest('with interpretation +@mobile +@dark-mode', async({ mount, page }) => {
|
||||
await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(txInterpretation),
|
||||
}));
|
||||
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
<TxSubHeading hash={ hash } hasTag={ false }/>
|
||||
</TestApp>,
|
||||
);
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
||||
bsInterpretationTest('no interpretation', async({ mount, page }) => {
|
||||
await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({ data: { summaries: [] } }),
|
||||
}));
|
||||
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
<TxSubHeading hash={ hash } hasTag={ false }/>
|
||||
</TestApp>,
|
||||
);
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
@@ -27,11 +27,11 @@ const TxSubHeading = ({ hash, hasTag }: Props) => {
|
||||
});
|
||||
|
||||
const hasInterpretation = hasInterpretationFeature &&
|
||||
(txInterpretationQuery.isPlaceholderData || txInterpretationQuery.data?.data.summaries.length);
|
||||
(txInterpretationQuery.isPlaceholderData || Boolean(txInterpretationQuery.data?.data.summaries.length));
|
||||
|
||||
return (
|
||||
<Box display={{ base: 'block', lg: 'flex' }} alignItems="center" w="100%">
|
||||
{ hasInterpretationFeature && (
|
||||
{ hasInterpretation && (
|
||||
<Flex mr={{ base: 0, lg: 6 }} flexWrap="wrap" alignItems="center">
|
||||
<TxInterpretation
|
||||
summary={ txInterpretationQuery.data?.data.summaries[0] }
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -16,7 +16,7 @@ const TxDetailsActionsInterpretation = ({ hash, isTxDataLoading }: Props) => {
|
||||
const txInterpretationQuery = useApiQuery('tx_interpretation', {
|
||||
pathParams: { hash },
|
||||
queryOptions: {
|
||||
enabled: Boolean(hash),
|
||||
enabled: Boolean(hash) && !isTxDataLoading,
|
||||
placeholderData: TX_INTERPRETATION,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { test, expect } from '@playwright/experimental-ct-react';
|
||||
import React from 'react';
|
||||
|
||||
import { txInterpretation as txInterpretationMock } from 'mocks/txs/txInterpretation';
|
||||
import TestApp from 'playwright/TestApp';
|
||||
|
||||
import TxInterpretation from './TxInterpretation';
|
||||
|
||||
test('base view +@mobile +@dark-mode', async({ mount }) => {
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
<TxInterpretation summary={ txInterpretationMock.data.summaries[0] } isLoading={ false }/>
|
||||
</TestApp>,
|
||||
);
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
@@ -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"
|
||||
|
||||