Commit 8b429238 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Contract interaction improvements (#1875)

* add contract method id tag

* add form submit type

* refactor types for SmartConstract and make new component for Contract ABI methods

* move contract method form inside ABI folder

* handle form submit and display result

* clean-up

* change copied text

* handle case when blockchain interaction is not configured

* tests and screenshots update

* fix bug wit WEI checkbox
parent 825a3e7f
import type { UseAccountReturnType } from 'wagmi';
import { useAccount } from 'wagmi';
import config from 'configs/app';
function useAccountFallback(): UseAccountReturnType {
return {
address: undefined,
addresses: undefined,
chain: undefined,
chainId: undefined,
connector: undefined,
isConnected: false,
isConnecting: false,
isDisconnected: true,
isReconnecting: false,
status: 'disconnected',
};
}
const hook = config.features.blockchainInteraction.isEnabled ? useAccount : useAccountFallback;
export default hook;
import type {
SmartContractQueryMethodReadError,
SmartContractQueryMethodReadSuccess,
SmartContractQueryMethodError,
SmartContractQueryMethodSuccess,
SmartContractReadMethod,
SmartContractWriteMethod,
} from 'types/api/contract';
......@@ -94,7 +94,7 @@ export const read: Array<SmartContractReadMethod> = [
},
];
export const readResultSuccess: SmartContractQueryMethodReadSuccess = {
export const readResultSuccess: SmartContractQueryMethodSuccess = {
is_error: false,
result: {
names: [ 'amount' ],
......@@ -104,7 +104,7 @@ export const readResultSuccess: SmartContractQueryMethodReadSuccess = {
},
};
export const readResultError: SmartContractQueryMethodReadError = {
export const readResultError: SmartContractQueryMethodError = {
is_error: true,
result: {
message: 'Some shit happened',
......
......@@ -18,6 +18,7 @@ import theme from 'theme';
export type Props = {
children: React.ReactNode;
withSocket?: boolean;
withWalletClient?: boolean;
appContext?: {
pageProps: PageProps;
};
......@@ -47,7 +48,20 @@ const wagmiConfig = createConfig({
},
});
const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props) => {
const WalletClientProvider = ({ children, withWalletClient }: { children: React.ReactNode; withWalletClient?: boolean }) => {
if (withWalletClient) {
return (
<WagmiProvider config={ wagmiConfig }>
{ children }
</WagmiProvider>
);
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{ children }</>;
};
const TestApp = ({ children, withSocket, withWalletClient = true, appContext = defaultAppContext }: Props) => {
const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: {
queries: {
......@@ -63,9 +77,9 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props
<SocketProvider url={ withSocket ? `ws://${ config.app.host }:${ app.socketPort }` : undefined }>
<AppContextProvider { ...appContext }>
<GrowthBookProvider>
<WagmiProvider config={ wagmiConfig }>
<WalletClientProvider withWalletClient={ withWalletClient }>
{ children }
</WagmiProvider>
</WalletClientProvider>
</GrowthBookProvider>
</AppContextProvider>
</SocketProvider>
......
......@@ -37,4 +37,7 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
blockHiddenFields: [
[ 'NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS', '["burnt_fees", "total_reward", "nonce"]' ],
],
noWalletClient: [
[ 'NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID', '' ],
],
};
import type { Abi, AbiType } from 'abitype';
import type { Abi, AbiType, AbiFallback, AbiFunction, AbiReceive } from 'abitype';
export type SmartContractMethodArgType = AbiType;
export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable';
......@@ -78,49 +78,19 @@ export interface SmartContractExternalLibrary {
name: string;
}
export interface SmartContractMethodBase {
inputs: Array<SmartContractMethodInput>;
outputs?: Array<SmartContractMethodOutput>;
constant: boolean;
name: string;
stateMutability: SmartContractMethodStateMutability;
type: 'function';
payable: boolean;
error?: string;
export type SmartContractMethodOutputValue = string | boolean | object;
export type SmartContractMethodOutput = AbiFunction['outputs'][number] & { value?: SmartContractMethodOutputValue };
export type SmartContractMethodBase = Omit<AbiFunction, 'outputs'> & {
method_id: string;
}
outputs: Array<SmartContractMethodOutput>;
constant?: boolean;
error?: string;
};
export type SmartContractReadMethod = SmartContractMethodBase;
export interface SmartContractWriteFallback {
payable?: true;
stateMutability: 'payable';
type: 'fallback';
}
export interface SmartContractWriteReceive {
payable?: true;
stateMutability: 'payable';
type: 'receive';
}
export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWriteFallback | SmartContractWriteReceive;
export type SmartContractWriteMethod = SmartContractMethodBase | AbiFallback | AbiReceive;
export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod;
export interface SmartContractMethodInput {
internalType?: string; // there could be any string, e.g "enum MyEnum"
name: string;
type: SmartContractMethodArgType;
components?: Array<SmartContractMethodInput>;
fieldType?: 'native_coin';
}
export interface SmartContractMethodOutput extends SmartContractMethodInput {
value?: string | boolean | object;
}
export interface SmartContractQueryMethodReadSuccess {
export interface SmartContractQueryMethodSuccess {
is_error: false;
result: {
names: Array<string | [ string, Array<string> ]>;
......@@ -131,7 +101,7 @@ export interface SmartContractQueryMethodReadSuccess {
};
}
export interface SmartContractQueryMethodReadError {
export interface SmartContractQueryMethodError {
is_error: true;
result: {
code: number;
......@@ -147,7 +117,7 @@ export interface SmartContractQueryMethodReadError {
};
}
export type SmartContractQueryMethodRead = SmartContractQueryMethodReadSuccess | SmartContractQueryMethodReadError;
export type SmartContractQueryMethod = SmartContractQueryMethodSuccess | SmartContractQueryMethodError;
// VERIFICATION
......
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as contractInfoMock from 'mocks/contract/info';
import * as contractMethodsMock from 'mocks/contract/methods';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import * as socketServer from 'playwright/fixtures/socketServer';
import { test, expect } from 'playwright/lib';
import AddressContract from './AddressContract.pwstory';
const hash = addressMock.contract.hash;
test.beforeEach(async({ mockApiResponse }) => {
await mockApiResponse('address', addressMock.contract, { pathParams: { hash } });
await mockApiResponse('contract', contractInfoMock.verified, { pathParams: { hash } });
await mockApiResponse('contract_methods_read', contractMethodsMock.read, { pathParams: { hash }, queryParams: { is_custom_abi: 'false' } });
await mockApiResponse('contract_methods_write', contractMethodsMock.write, { pathParams: { hash }, queryParams: { is_custom_abi: 'false' } });
});
test.describe('ABI functionality', () => {
test('read', async({ render, createSocket }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'read_contract' },
},
};
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeVisible();
await component.getByText('FLASHLOAN_PREMIUM_TOTAL').click();
await expect(component.getByRole('button', { name: 'Read' })).toBeVisible();
});
test('read, no wallet client', async({ render, createSocket, mockEnvs }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'read_contract' },
},
};
await mockEnvs(ENVS_MAP.noWalletClient);
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true, withWalletClient: false });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeHidden();
await component.getByText('FLASHLOAN_PREMIUM_TOTAL').click();
await expect(component.getByRole('button', { name: 'Read' })).toBeVisible();
});
test('write', async({ render, createSocket }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'write_contract' },
},
};
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeVisible();
await component.getByText('setReserveInterestRateStrategyAddress').click();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Simulate' })).toBeEnabled();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Write' })).toBeEnabled();
await component.getByText('pause').click();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeHidden();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Write' })).toBeEnabled();
});
test('write, no wallet client', async({ render, createSocket, mockEnvs }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'write_contract' },
},
};
await mockEnvs(ENVS_MAP.noWalletClient);
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true, withWalletClient: false });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeHidden();
await component.getByText('setReserveInterestRateStrategyAddress').click();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Simulate' })).toBeEnabled();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Write' })).toBeDisabled();
await component.getByText('pause').click();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeHidden();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Write' })).toBeDisabled();
});
});
import { useRouter } from 'next/router';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import useContractTabs from 'lib/hooks/useContractTabs';
import getQueryParamString from 'lib/router/getQueryParamString';
import AddressContract from './AddressContract';
const AddressContractPwStory = () => {
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const addressQuery = useApiQuery('address', { pathParams: { hash } });
const { tabs } = useContractTabs(addressQuery.data, false);
return <AddressContract tabs={ tabs } shouldRender={ true } isLoading={ false }/>;
};
export default AddressContractPwStory;
......@@ -3,7 +3,6 @@ import React from 'react';
import type { RoutedSubTab } from 'ui/shared/Tabs/types';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
interface Props {
tabs: Array<RoutedSubTab>;
......@@ -16,21 +15,12 @@ const TAB_LIST_PROPS = {
};
const AddressContract = ({ tabs, isLoading, shouldRender }: Props) => {
const fallback = React.useCallback(() => {
const noProviderTabs = tabs.filter(({ id }) => id === 'contract_code' || id.startsWith('read_'));
return (
<RoutedTabs tabs={ noProviderTabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS } isLoading={ isLoading }/>
);
}, [ isLoading, tabs ]);
if (!shouldRender) {
return null;
}
return (
<Web3ModalProvider fallback={ fallback }>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS } isLoading={ isLoading }/>
</Web3ModalProvider>
);
};
......
import { Accordion, Box, Flex, Link } from '@chakra-ui/react';
import _range from 'lodash/range';
import React from 'react';
import { scroller } from 'react-scroll';
import type { SmartContractMethod } from 'types/api/contract';
import type { MethodType, ContractAbi as TContractAbi } from './types';
import ContractMethodsAccordionItem from './ContractMethodsAccordionItem';
import ContractAbiItem from './ContractAbiItem';
import useFormSubmit from './useFormSubmit';
import useScrollToMethod from './useScrollToMethod';
interface Props<T extends SmartContractMethod> {
data: Array<T>;
addressHash?: string;
renderItemContent: (item: T, index: number, id: number) => React.ReactNode;
interface Props {
data: TContractAbi;
addressHash: string;
tab: string;
methodType: MethodType;
}
const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, addressHash, renderItemContent, tab }: Props<T>) => {
const ContractAbi = ({ data, addressHash, tab, methodType }: Props) => {
const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(data.length === 1 ? [ 0 ] : []);
const [ id, setId ] = React.useState(0);
React.useEffect(() => {
const hash = window.location.hash.replace('#', '');
useScrollToMethod(data, setExpandedSections);
if (!hash) {
return;
}
const index = data.findIndex((item) => 'method_id' in item && item.method_id === hash);
if (index > -1) {
scroller.scrollTo(`method_${ hash }`, {
duration: 500,
smooth: true,
offset: -100,
});
setExpandedSections([ index ]);
}
}, [ data ]);
const handleFormSubmit = useFormSubmit({ addressHash, tab });
const handleAccordionStateChange = React.useCallback((newValue: Array<number>) => {
setExpandedSections(newValue);
......@@ -73,14 +60,15 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address
</Flex>
<Accordion allowMultiple position="relative" onChange={ handleAccordionStateChange } index={ expandedSections }>
{ data.map((item, index) => (
<ContractMethodsAccordionItem
<ContractAbiItem
key={ index }
data={ item }
id={ id }
index={ index }
addressHash={ addressHash }
renderContent={ renderItemContent as (item: SmartContractMethod, index: number, id: number) => React.ReactNode }
tab={ tab }
onSubmit={ handleFormSubmit }
methodType={ methodType }
/>
)) }
</Accordion>
......@@ -88,4 +76,4 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address
);
};
export default React.memo(ContractMethodsAccordion) as typeof ContractMethodsAccordion;
export default React.memo(ContractAbi);
import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box, Tooltip, useClipboard, useDisclosure } from '@chakra-ui/react';
import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Alert, Box, Flex, Tooltip, useClipboard, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import { Element } from 'react-scroll';
import type { SmartContractMethod } from 'types/api/contract';
import type { FormSubmitHandler, MethodType, ContractAbiItem as TContractAbiItem } from './types';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import Tag from 'ui/shared/chakra/Tag';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import Hint from 'ui/shared/Hint';
import IconSvg from 'ui/shared/IconSvg';
interface Props<T extends SmartContractMethod> {
data: T;
import ContractAbiItemConstant from './ContractAbiItemConstant';
import ContractMethodForm from './form/ContractMethodForm';
import { getElementName } from './useScrollToMethod';
interface Props {
data: TContractAbiItem;
index: number;
id: number;
addressHash?: string;
renderContent: (item: T, index: number, id: number) => React.ReactNode;
addressHash: string;
tab: string;
onSubmit: FormSubmitHandler;
methodType: MethodType;
}
const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, index, id, addressHash, renderContent, tab }: Props<T>) => {
const ContractAbiItem = ({ data, index, id, addressHash, tab, onSubmit, methodType }: Props) => {
const url = React.useMemo(() => {
if (!('method_id' in data)) {
return '';
......@@ -43,11 +50,40 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
onCopy();
}, [ onCopy ]);
const handleCopyMethodIdClick = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
}, []);
const content = (() => {
if ('error' in data && data.error) {
return <Alert status="error" fontSize="sm" wordBreak="break-word">{ data.error }</Alert>;
}
const hasConstantOutputs = 'outputs' in data && data.outputs.some(({ value }) => value !== undefined && value !== null);
if (hasConstantOutputs) {
return (
<Flex flexDir="column" rowGap={ 1 }>
{ data.outputs.map((output, index) => <ContractAbiItemConstant key={ index } data={ output }/>) }
</Flex>
);
}
return (
<ContractMethodForm
key={ id + '_' + index }
data={ data }
onSubmit={ onSubmit }
methodType={ methodType }
/>
);
})();
return (
<AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}>
{ ({ isExpanded }) => (
<>
<Element as="h2" name={ 'method_id' in data ? `method_${ data.method_id }` : '' }>
<Element as="h2" name={ 'method_id' in data ? getElementName(data.method_id) : '' }>
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left" as="div" cursor="pointer">
{ 'method_id' in data && (
<Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }>
......@@ -85,11 +121,17 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
the contract cannot receive Ether through regular transactions and throws an exception.`
}/>
) }
{ 'method_id' in data && (
<>
<Tag>{ data.method_id }</Tag>
<CopyToClipboard text={ `${ data.name } (${ data.method_id })` } onClick={ handleCopyMethodIdClick }/>
</>
) }
<AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/>
</AccordionButton>
</Element>
<AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)">
{ renderContent(data, index, id) }
{ content }
</AccordionPanel>
</>
) }
......@@ -97,4 +139,4 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
);
};
export default React.memo(ContractMethodsAccordionItem);
export default React.memo(ContractAbiItem);
......@@ -4,12 +4,14 @@ import type { ChangeEvent } from 'react';
import React from 'react';
import { getAddress } from 'viem';
import type { SmartContractMethodOutput } from 'types/api/contract';
import type { ContractAbiItemOutput } from './types';
import { WEI } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import { matchInt } from './form/utils';
function castValueToString(value: number | string | boolean | object | bigint | undefined): string {
switch (typeof value) {
case 'string':
......@@ -28,13 +30,15 @@ function castValueToString(value: number | string | boolean | object | bigint |
}
interface Props {
data: SmartContractMethodOutput;
data: ContractAbiItemOutput;
}
const ContractMethodStatic = ({ data }: Props) => {
const ContractAbiItemConstant = ({ data }: Props) => {
const [ value, setValue ] = React.useState<string>(castValueToString(data.value));
const [ label, setLabel ] = React.useState(currencyUnits.wei.toUpperCase());
const intMatch = matchInt(data.type);
const handleCheckboxChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
const initialValue = castValueToString(data.value);
......@@ -63,9 +67,9 @@ const ContractMethodStatic = ({ data }: Props) => {
return (
<Flex flexDir={{ base: 'column', lg: 'row' }} columnGap={ 2 } rowGap={ 2 }>
{ content }
{ (data.type.includes('int256') || data.type.includes('int128')) && <Checkbox onChange={ handleCheckboxChange }>{ label }</Checkbox> }
{ Number(intMatch?.power) >= 128 && <Checkbox onChange={ handleCheckboxChange }>{ label }</Checkbox> }
</Flex>
);
};
export default ContractMethodStatic;
export default ContractAbiItemConstant;
......@@ -3,7 +3,7 @@ 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 type { ContractAbiItemInput } from '../types';
import ClearButton from 'ui/shared/ClearButton';
......@@ -14,7 +14,7 @@ import useValidateField from './useValidateField';
import { matchInt } from './utils';
interface Props {
data: SmartContractMethodInput;
data: ContractAbiItemInput;
hideLabel?: boolean;
path: string;
className?: string;
......
......@@ -2,7 +2,7 @@ import { Flex } from '@chakra-ui/react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import type { SmartContractMethodInput } from 'types/api/contract';
import type { ContractAbiItemInput } from '../types';
import ContractMethodArrayButton from './ContractMethodArrayButton';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
......@@ -13,7 +13,7 @@ import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import { getFieldLabel, matchArray, transformDataForArrayItem } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput;
data: ContractAbiItemInput;
level: number;
basePath: string;
isDisabled: boolean;
......
import React from 'react';
import { useFormContext } from 'react-hook-form';
import type { SmartContractMethodInput } from 'types/api/contract';
import type { ContractAbiItemInput } from '../types';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
......@@ -10,7 +10,7 @@ import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import { getFieldLabel, matchArray } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput;
data: ContractAbiItemInput;
basePath: string;
level: number;
isDisabled: boolean;
......@@ -21,6 +21,10 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
const fieldsWithErrors = Object.keys(errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath));
if (!('components' in data)) {
return null;
}
return (
<ContractMethodFieldAccordion
{ ...accordionProps }
......@@ -29,7 +33,7 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
isInvalid={ isInvalid }
>
{ data.components?.map((component, index) => {
if (component.components && component.type === 'tuple') {
if ('components' in component && component.type === 'tuple') {
return (
<ContractMethodFieldInputTuple
key={ index }
......
import { Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractMethodInput } from 'types/api/contract';
import type { ContractAbiItemInput } from '../types';
import { getFieldLabel } from './utils';
interface Props {
data: SmartContractMethodInput;
data: ContractAbiItemInput;
isOptional?: boolean;
level: number;
}
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import type { SmartContractWriteMethod } from 'types/api/contract';
import type { ContractAbiItem } from '../types';
import TestApp from 'playwright/TestApp';
import ContractMethodForm from './ContractMethodForm';
const onSubmit = () => Promise.resolve({ hash: '0x0000' as `0x${ string }` });
const resultComponent = () => null;
const onSubmit = () => Promise.resolve({ source: 'wallet_client' as const, result: { hash: '0x0000' as `0x${ string }` } });
const data: SmartContractWriteMethod = {
const data: ContractAbiItem = {
inputs: [
// TUPLE
{
......@@ -102,10 +101,9 @@ test('base view +@mobile +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<ContractMethodForm<SmartContractWriteMethod>
<ContractMethodForm
data={ data }
onSubmit={ onSubmit }
resultComponent={ resultComponent }
methodType="write"
/>
</TestApp>,
......
import { Box, Button, Flex, chakra } from '@chakra-ui/react';
import { Box, Button, Flex, Tooltip, chakra } from '@chakra-ui/react';
import _mapValues from 'lodash/mapValues';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { AbiFunction } from 'viem';
import type { ContractMethodCallResult } from '../types';
import type { ResultComponentProps } from './types';
import type { SmartContractMethod, SmartContractMethodInput } from 'types/api/contract';
import type { FormSubmitHandler, FormSubmitResult, MethodCallStrategy, MethodType, ContractAbiItem } from '../types';
import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index';
......@@ -15,30 +14,38 @@ import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodFormOutputs from './ContractMethodFormOutputs';
import ContractMethodOutputs from './ContractMethodOutputs';
import ContractMethodResult from './ContractMethodResult';
import { getFieldLabel, matchArray, transformFormDataToMethodArgs } from './utils';
import type { ContractMethodFormFields } from './utils';
interface Props<T extends SmartContractMethod> {
data: T;
onSubmit: (data: T, args: Array<unknown>) => Promise<ContractMethodCallResult<T>>;
resultComponent: (props: ResultComponentProps<T>) => JSX.Element | null;
methodType: 'read' | 'write';
interface Props {
data: ContractAbiItem;
onSubmit: FormSubmitHandler;
methodType: MethodType;
}
const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, methodType }: Props<T>) => {
const ContractMethodForm = ({ data, onSubmit, methodType }: Props) => {
const [ result, setResult ] = React.useState<ContractMethodCallResult<T>>();
const [ result, setResult ] = React.useState<FormSubmitResult>();
const [ isLoading, setLoading ] = React.useState(false);
const [ callStrategy, setCallStrategy ] = React.useState<MethodCallStrategy>();
const callStrategyRef = React.useRef(callStrategy);
const formApi = useForm<ContractMethodFormFields>({
mode: 'all',
shouldUnregister: true,
});
const handleButtonClick = React.useCallback((event: React.MouseEvent) => {
const callStrategy = event?.currentTarget.getAttribute('data-call-strategy');
setCallStrategy(callStrategy as MethodCallStrategy);
callStrategyRef.current = callStrategy as MethodCallStrategy;
}, []);
const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => {
// The API used for reading from contracts expects all values to be strings.
const formattedData = methodType === 'read' ?
const formattedData = callStrategyRef.current === 'api' ?
_mapValues(formData, (value) => value !== undefined ? String(value) : undefined) :
formData;
const args = transformFormDataToMethodArgs(formattedData);
......@@ -46,12 +53,15 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
setResult(undefined);
setLoading(true);
onSubmit(data, args)
onSubmit(data, args, callStrategyRef.current)
.then((result) => {
setResult(result);
})
.catch((error) => {
setResult(error?.error || error?.data || (error?.reason && { message: error.reason }) || error);
setResult({
source: callStrategyRef.current ?? 'wallet_client',
result: error?.error || error?.data || (error?.reason && { message: error.reason }) || error,
});
setLoading(false);
})
.finally(() => {
......@@ -70,9 +80,9 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
result && setResult(undefined);
}, [ result ]);
const inputs: Array<SmartContractMethodInput> = React.useMemo(() => {
const inputs: AbiFunction['inputs'] = React.useMemo(() => {
return [
...('inputs' in data ? data.inputs : []),
...('inputs' in data && data.inputs ? data.inputs : []),
...('stateMutability' in data && data.stateMutability === 'payable' ? [ {
name: `Send native ${ config.chain.currency.symbol || 'coin' }`,
type: 'uint256' as const,
......@@ -84,6 +94,28 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
const outputs = 'outputs' in data && data.outputs ? data.outputs : [];
const callStrategies = (() => {
switch (methodType) {
case 'read': {
return { primary: 'api', secondary: undefined };
}
case 'write': {
return {
primary: config.features.blockchainInteraction.isEnabled ? 'wallet_client' : undefined,
secondary: 'outputs' in data && Boolean(data.outputs?.length) ? 'api' : undefined,
};
}
default: {
return { primary: undefined, secondary: undefined };
}
}
})();
// eslint-disable-next-line max-len
const noWalletClientText = 'Blockchain interaction is not available at the moment since WalletConnect is not configured for this application. Please contact the service maintainer to make necessary changes in the service configuration.';
return (
<Box>
<FormProvider { ...formApi }>
......@@ -94,12 +126,18 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
>
<Flex flexDir="column" rowGap={ 3 } mb={ 6 } _empty={{ display: 'none' }}>
{ inputs.map((input, index) => {
if (input.components && input.type === 'tuple') {
return <ContractMethodFieldInputTuple key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>;
const props = {
data: input,
basePath: `${ index }`,
isDisabled: isLoading,
level: 0,
};
if ('components' in input && input.components && input.type === 'tuple') {
return <ContractMethodFieldInputTuple key={ index } { ...props }/>;
}
const arrayMatch = matchArray(input.type);
if (arrayMatch) {
if (arrayMatch.isNested) {
const fieldsWithErrors = Object.keys(formApi.formState.errors);
......@@ -112,19 +150,41 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
label={ getFieldLabel(input) }
isInvalid={ isInvalid }
>
<ContractMethodFieldInputArray data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>
<ContractMethodFieldInputArray { ...props }/>
</ContractMethodFieldAccordion>
);
}
return <ContractMethodFieldInputArray key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>;
return <ContractMethodFieldInputArray key={ index } { ...props }/>;
}
return <ContractMethodFieldInput key={ index } data={ input } path={ `${ index }` } isDisabled={ isLoading } level={ 0 }/>;
return <ContractMethodFieldInput key={ index } { ...props } path={ `${ index }` }/>;
}) }
</Flex>
{ callStrategies.secondary && (
<Button
isLoading={ callStrategy === callStrategies.secondary && isLoading }
isDisabled={ isLoading }
onClick={ handleButtonClick }
loadingText="Simulate"
variant="outline"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
mr={ 3 }
type="submit"
data-call-strategy={ callStrategies.secondary }
>
Simulate
</Button>
) }
<Tooltip label={ !callStrategies.primary ? noWalletClientText : undefined } maxW="300px">
<Button
isLoading={ isLoading }
isLoading={ callStrategy === callStrategies.primary && isLoading }
isDisabled={ isLoading || !callStrategies.primary }
onClick={ handleButtonClick }
loadingText={ methodType === 'write' ? 'Write' : 'Read' }
variant="outline"
size="sm"
......@@ -132,13 +192,15 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
width="min-content"
px={ 4 }
type="submit"
data-call-strategy={ callStrategies.primary }
>
{ methodType === 'write' ? 'Write' : 'Read' }
</Button>
</Tooltip>
</chakra.form>
</FormProvider>
{ methodType === 'read' && <ContractMethodFormOutputs data={ outputs }/> }
{ result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> }
{ 'outputs' in data && Boolean(data.outputs?.length) && <ContractMethodOutputs data={ outputs }/> }
{ result && <ContractMethodResult abiItem={ data } result={ result } onSettle={ handleTxSettle }/> }
</Box>
);
};
......
import { Flex, chakra } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractMethodOutput } from 'types/api/contract';
import type { AbiFunction } from 'viem';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
data: Array<SmartContractMethodOutput>;
data: AbiFunction['outputs'];
}
const ContractMethodFormOutputs = ({ data }: Props) => {
const ContractMethodOutputs = ({ data }: Props) => {
if (data.length === 0) {
return null;
}
......@@ -32,4 +31,4 @@ const ContractMethodFormOutputs = ({ data }: Props) => {
);
};
export default React.memo(ContractMethodFormOutputs);
export default React.memo(ContractMethodOutputs);
import React from 'react';
import type { FormSubmitResult, ContractAbiItem } from '../types';
import ContractMethodResultApi from './ContractMethodResultApi';
import ContractMethodResultWalletClient from './ContractMethodResultWalletClient';
interface Props {
abiItem: ContractAbiItem;
result: FormSubmitResult;
onSettle: () => void;
}
const ContractMethodResult = ({ result, abiItem, onSettle }: Props) => {
switch (result.source) {
case 'api':
return <ContractMethodResultApi item={ abiItem } result={ result.result } onSettle={ onSettle }/>;
case 'wallet_client':
return <ContractMethodResultWalletClient result={ result.result } onSettle={ onSettle }/>;
default: {
return null;
}
}
};
export default React.memo(ContractMethodResult);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import type { ContractMethodReadResult } from './types';
import type { FormSubmitResultApi } from '../types';
import * as contractMethodsMock from 'mocks/contract/methods';
import TestApp from 'playwright/TestApp';
import { test, expect } from 'playwright/lib';
import ContractReadResult from './ContractReadResult';
import ContractMethodResultApi from './ContractMethodResultApi';
const item = contractMethodsMock.read[0];
const onSettle = () => Promise.resolve();
test.use({ viewport: { width: 500, height: 500 } });
test('default error', async({ mount }) => {
const result: ContractMethodReadResult = {
test('default error', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
error: 'I am an error',
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('error with code', async({ mount }) => {
const result: ContractMethodReadResult = {
test('error with code', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
message: 'I am an error',
code: -32017,
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('raw error', async({ mount }) => {
const result: ContractMethodReadResult = {
test('raw error', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
raw: '49276d20616c7761797320726576657274696e67207769746820616e206572726f72',
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('complex error', async({ mount }) => {
const result: ContractMethodReadResult = {
test('complex error', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
method_call: 'SomeCustomError(address addr, uint256 balance)',
......@@ -74,34 +61,26 @@ test('complex error', async({ mount }) => {
],
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('success', async({ mount }) => {
const result: ContractMethodReadResult = {
test('success', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: false,
result: {
names: [ 'address' ],
output: [ { type: 'address', value: '0x0000000000000000000000000000000000000000' } ],
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('complex success', async({ mount }) => {
const result: ContractMethodReadResult = {
test('complex success', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: false,
result: {
names: [
......@@ -122,11 +101,7 @@ test('complex success', async({ mount }) => {
],
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
import { Box, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { ContractAbiItem, FormSubmitResultApi } from '../types';
import hexToUtf8 from 'lib/hexToUtf8';
import ContractMethodResultApiError from './ContractMethodResultApiError';
import ContractMethodResultApiItem from './ContractMethodResultApiItem';
interface Props {
item: ContractAbiItem;
result: FormSubmitResultApi['result'];
onSettle: () => void;
}
const ContractMethodResultApi = ({ item, result, onSettle }: Props) => {
const resultBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
React.useEffect(() => {
onSettle();
}, [ onSettle ]);
if ('status' in result) {
return <ContractMethodResultApiError>{ result.statusText }</ContractMethodResultApiError>;
}
if (result instanceof Error) {
return <ContractMethodResultApiError>{ result.message }</ContractMethodResultApiError>;
}
if (result.is_error) {
if ('error' in result.result) {
return <ContractMethodResultApiError>{ result.result.error }</ContractMethodResultApiError>;
}
if ('message' in result.result) {
return <ContractMethodResultApiError>[{ result.result.code }] { result.result.message }</ContractMethodResultApiError>;
}
if ('raw' in result.result) {
return <ContractMethodResultApiError>{ `Revert reason: ${ hexToUtf8(result.result.raw) }` }</ContractMethodResultApiError>;
}
if ('method_id' in result.result) {
return <ContractMethodResultApiError>{ JSON.stringify(result.result, undefined, 2) }</ContractMethodResultApiError>;
}
return <ContractMethodResultApiError>Something went wrong.</ContractMethodResultApiError>;
}
return (
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm" whiteSpace="break-spaces" wordBreak="break-all">
<p>
[ <chakra.span fontWeight={ 500 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
</p>
<p>[</p>
{ result.result.output.map((output, index) => <ContractMethodResultApiItem key={ index } output={ output } name={ result.result.names[index] }/>) }
<p>]</p>
</Box>
);
};
export default React.memo(ContractMethodResultApi);
import { Alert } from '@chakra-ui/react';
import React from 'react';
interface Props {
children: React.ReactNode;
}
const ContractMethodResultApiError = ({ children }: Props) => {
return (
<Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm" wordBreak="break-word" whiteSpace="pre-wrap">
{ children }
</Alert>
);
};
export default React.memo(ContractMethodResultApiError);
import { chakra } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractQueryMethodSuccess } from 'types/api/contract';
const TUPLE_TYPE_REGEX = /\[(.+)\]/;
interface Props {
output: SmartContractQueryMethodSuccess['result']['output'][0];
name: SmartContractQueryMethodSuccess['result']['names'][0];
}
const ContractMethodResultApiItem = ({ output, name }: Props) => {
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>
);
};
export default React.memo(ContractMethodResultApiItem);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import { test, expect } from 'playwright/lib';
import ContractWriteResultDumb from './ContractWriteResultDumb';
import type { PropsDumb } from './ContractMethodResultWalletClient';
import { ContractMethodResultWalletClientDumb } from './ContractMethodResultWalletClient';
test('loading', async({ mount }) => {
test('loading', async({ render }) => {
const props = {
txInfo: {
status: 'pending' as const,
error: null,
},
} as PropsDumb['txInfo'],
result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
},
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
await expect(component).toHaveScreenshot();
});
test('success', async({ mount }) => {
test('success', async({ render }) => {
const props = {
txInfo: {
status: 'success' as const,
error: null,
},
} as PropsDumb['txInfo'],
result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
},
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
await expect(component).toHaveScreenshot();
});
test('error +@mobile', async({ mount }) => {
test('error +@mobile', async({ render }) => {
const props = {
txInfo: {
status: 'error' as const,
......@@ -55,39 +45,29 @@ test('error +@mobile', async({ mount }) => {
// eslint-disable-next-line max-len
message: 'missing revert data in call exception; Transaction reverted without a reason string [ See: https://links.ethers.org/v5-errors-CALL_EXCEPTION ]',
} as Error,
},
} as PropsDumb['txInfo'],
result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
},
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
await expect(component).toHaveScreenshot();
});
test('error in result', async({ mount }) => {
test('error in result', async({ render }) => {
const props = {
txInfo: {
status: 'idle' as const,
error: null,
},
} as unknown as PropsDumb['txInfo'],
result: {
message: 'wallet is not connected',
} as Error,
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
await expect(component).toHaveScreenshot();
});
import { Box, chakra, Spinner } from '@chakra-ui/react';
import { chakra, Spinner, Box } from '@chakra-ui/react';
import React from 'react';
import type { UseWaitForTransactionReceiptReturnType } from 'wagmi';
import { useWaitForTransactionReceipt } from 'wagmi';
import type { ContractMethodWriteResult } from './types';
import type { FormSubmitResultWalletClient } from '../types';
import { route } from 'nextjs-routes';
import LinkInternal from 'ui/shared/LinkInternal';
interface Props {
result: ContractMethodWriteResult;
result: FormSubmitResultWalletClient['result'];
onSettle: () => void;
txInfo: {
status: 'loading' | 'success' | 'error' | 'idle' | 'pending';
error: Error | null;
};
}
const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => {
const ContractMethodResultWalletClient = ({ result, onSettle }: Props) => {
const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined;
const txInfo = useWaitForTransactionReceipt({
hash: txHash,
});
return <ContractMethodResultWalletClientDumb result={ result } onSettle={ onSettle } txInfo={ txInfo }/>;
};
export interface PropsDumb {
result: FormSubmitResultWalletClient['result'];
onSettle: () => void;
txInfo: UseWaitForTransactionReceiptReturnType;
}
export const ContractMethodResultWalletClientDumb = ({ result, onSettle, txInfo }: PropsDumb) => {
const txHash = result && 'hash' in result ? result.hash : undefined;
React.useEffect(() => {
......@@ -93,4 +106,4 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => {
);
};
export default React.memo(ContractWriteResultDumb);
export default React.memo(ContractMethodResultWalletClient);
import React from 'react';
import type { SmartContractMethodArgType } from 'types/api/contract';
import type { MatchInt } from './utils';
interface Params {
argType: SmartContractMethodArgType;
argType: string;
argTypeMatchInt: MatchInt | null;
}
......
import React from 'react';
import { getAddress, isAddress, isHex } from 'viem';
import type { SmartContractMethodArgType } from 'types/api/contract';
import type { MatchInt } from './utils';
import { BYTES_REGEXP } from './utils';
interface Params {
argType: SmartContractMethodArgType;
argType: string;
argTypeMatchInt: MatchInt | null;
isOptional: boolean;
}
......
import _set from 'lodash/set';
import type { SmartContractMethodArgType, SmartContractMethodInput } from 'types/api/contract';
import type { ContractAbiItemInput } from '../types';
export type ContractMethodFormFields = Record<string, string | boolean | undefined>;
......@@ -11,22 +11,22 @@ export const BYTES_REGEXP = /^bytes(\d+)?$/i;
export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/;
export interface MatchArray {
itemType: SmartContractMethodArgType;
itemType: string;
size: number;
isNested: boolean;
}
export const matchArray = (argType: SmartContractMethodArgType): MatchArray | null => {
export const matchArray = (argType: string): MatchArray | null => {
const match = argType.match(ARRAY_REGEXP);
if (!match) {
return null;
}
const [ , itemType, size ] = match;
const isNested = Boolean(matchArray(itemType as SmartContractMethodArgType));
const isNested = Boolean(matchArray(itemType));
return {
itemType: itemType as SmartContractMethodArgType,
itemType,
size: size ? Number(size) : Infinity,
isNested,
};
......@@ -39,7 +39,7 @@ export interface MatchInt {
max: bigint;
}
export const matchInt = (argType: SmartContractMethodArgType): MatchInt | null => {
export const matchInt = (argType: string): MatchInt | null => {
const match = argType.match(INT_REGEXP);
if (!match) {
return null;
......@@ -51,9 +51,9 @@ export const matchInt = (argType: SmartContractMethodArgType): MatchInt | null =
return { isUnsigned: Boolean(isUnsigned), power, min, max };
};
export const transformDataForArrayItem = (data: SmartContractMethodInput, index: number): SmartContractMethodInput => {
export const transformDataForArrayItem = (data: ContractAbiItemInput, index: number): ContractAbiItemInput => {
const arrayMatchType = matchArray(data.type);
const arrayMatchInternalType = data.internalType ? matchArray(data.internalType as SmartContractMethodArgType) : null;
const arrayMatchInternalType = data.internalType ? matchArray(data.internalType) : null;
const childrenInternalType = arrayMatchInternalType?.itemType.replaceAll('struct ', '');
const postfix = childrenInternalType ? ' ' + childrenInternalType : '';
......@@ -97,7 +97,7 @@ function filterOurEmptyItems(array: Array<unknown>): Array<unknown> {
.filter((item) => item !== undefined);
}
export function getFieldLabel(input: SmartContractMethodInput, isRequired?: boolean) {
export function getFieldLabel(input: ContractAbiItemInput, isRequired?: boolean) {
const name = input.name || input.internalType || '<unnamed argument>';
return `${ name } (${ input.type })${ isRequired ? '*' : '' }`;
}
import type { AbiFunction } from 'abitype';
import type { SmartContractMethod, SmartContractMethodOutput, SmartContractQueryMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
export type ContractAbiItemInput = AbiFunction['inputs'][number] & { fieldType?: 'native_coin' };
export type ContractAbiItemOutput = SmartContractMethodOutput;
export type ContractAbiItem = SmartContractMethod;
export type ContractAbi = Array<ContractAbiItem>;
export type MethodType = 'read' | 'write';
export type MethodCallStrategy = 'api' | 'wallet_client';
export interface FormSubmitResultApi {
source: 'api';
result: SmartContractQueryMethod | ResourceError | Error;
}
export interface FormSubmitResultWalletClient {
source: 'wallet_client';
result: Error | { hash: `0x${ string }` | undefined } | undefined;
}
export type FormSubmitResult = FormSubmitResultApi | FormSubmitResultWalletClient;
export type FormSubmitHandler = (item: ContractAbiItem, args: Array<unknown>, submitType: MethodCallStrategy | undefined) => Promise<FormSubmitResult>;
import React from 'react';
import type { FormSubmitResult } from './types';
import type { SmartContractQueryMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import useAccount from 'lib/web3/useAccount';
interface Params {
methodId: string;
args: Array<unknown>;
isProxy: boolean;
isCustomAbi: boolean;
addressHash: string;
}
export default function useCallMethodApi(): (params: Params) => Promise<FormSubmitResult> {
const apiFetch = useApiFetch();
const { address } = useAccount();
return React.useCallback(async({ addressHash, isCustomAbi, isProxy, args, methodId }) => {
try {
const response = await apiFetch<'contract_method_query', SmartContractQueryMethod>('contract_method_query', {
pathParams: { hash: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
},
fetchParams: {
method: 'POST',
body: {
args,
method_id: methodId,
contract_type: isProxy ? 'proxy' : 'regular',
from: address,
},
},
});
return {
source: 'api',
result: response,
};
} catch (error) {
return {
source: 'api',
result: error as (Error | ResourceError),
};
}
}, [ address, apiFetch ]);
}
import React from 'react';
import type { Abi } from 'viem';
import { useAccount, useWalletClient, useSwitchChain } from 'wagmi';
import type { ContractAbiItem, FormSubmitResult } from './types';
import config from 'configs/app';
import { getNativeCoinValue } from './utils';
interface Params {
item: ContractAbiItem;
args: Array<unknown>;
addressHash: string;
}
export default function useCallMethodWalletClient(): (params: Params) => Promise<FormSubmitResult> {
const { data: walletClient } = useWalletClient();
const { isConnected, chainId } = useAccount();
const { switchChainAsync } = useSwitchChain();
return React.useCallback(async({ args, item, addressHash }) => {
if (!isConnected) {
throw new Error('Wallet is not connected');
}
if (!walletClient) {
throw new Error('Wallet Client is not defined');
}
if (chainId && String(chainId) !== config.chain.id) {
await switchChainAsync?.({ chainId: Number(config.chain.id) });
}
if (item.type === 'receive' || item.type === 'fallback') {
const value = getNativeCoinValue(args[0]);
const hash = await walletClient.sendTransaction({
to: addressHash as `0x${ string }` | undefined,
value,
});
return { source: 'wallet_client', result: { hash } };
}
const methodName = item.name;
if (!methodName) {
throw new Error('Method name is not defined');
}
const _args = args.slice(0, item.inputs.length);
const value = getNativeCoinValue(args[item.inputs.length]);
const hash = await walletClient.writeContract({
args: _args,
// Here we provide the ABI as an array containing only one item from the submitted form.
// This is a workaround for the issue with the "viem" library.
// It lacks a "method_id" field to uniquely identify the correct method and instead attempts to find a method based on its name.
// But the name is not unique in the contract ABI and this behavior in the "viem" could result in calling the wrong method.
// See related issues:
// - https://github.com/blockscout/frontend/issues/1032,
// - https://github.com/blockscout/frontend/issues/1327
abi: [ item ] as Abi,
functionName: methodName,
address: addressHash as `0x${ string }`,
value,
});
return { source: 'wallet_client', result: { hash } };
}, [ chainId, isConnected, switchChainAsync, walletClient ]);
}
import React from 'react';
import type { FormSubmitHandler } from './types';
import config from 'configs/app';
import useCallMethodApi from './useCallMethodApi';
import useCallMethodWalletClient from './useCallMethodWalletClient';
interface Params {
tab: string;
addressHash: string;
}
function useFormSubmit({ tab, addressHash }: Params): FormSubmitHandler {
const callMethodApi = useCallMethodApi();
const callMethodWalletClient = useCallMethodWalletClient();
return React.useCallback(async(item, args, strategy) => {
switch (strategy) {
case 'api': {
if (!('method_id' in item)) {
throw new Error('Method ID is not defined');
}
return callMethodApi({
args,
methodId: item.method_id,
addressHash,
isCustomAbi: tab === 'read_custom_methods' || tab === 'write_custom_methods',
isProxy: tab === 'read_proxy' || tab === 'write_proxy',
});
}
case 'wallet_client': {
return callMethodWalletClient({ args, item, addressHash });
}
default: {
throw new Error(`Unknown call strategy "${ strategy }"`);
}
}
}, [ addressHash, callMethodApi, callMethodWalletClient, tab ]);
}
function useFormSubmitFallback({ tab, addressHash }: Params): FormSubmitHandler {
const callMethodApi = useCallMethodApi();
return React.useCallback(async(item, args, strategy) => {
switch (strategy) {
case 'api': {
if (!('method_id' in item)) {
throw new Error('Method ID is not defined');
}
return callMethodApi({
args,
methodId: item.method_id,
addressHash,
isCustomAbi: tab === 'read_custom_methods' || tab === 'write_custom_methods',
isProxy: tab === 'read_proxy' || tab === 'write_proxy',
});
}
default: {
throw new Error(`Unknown call strategy "${ strategy }"`);
}
}
}, [ addressHash, callMethodApi, tab ]);
}
const hook = config.features.blockchainInteraction.isEnabled ? useFormSubmit : useFormSubmitFallback;
export default hook;
import React from 'react';
import { scroller } from 'react-scroll';
import type { ContractAbi } from './types';
export const getElementName = (id: string) => `method_${ id }`;
export default function useScrollToMethod(data: ContractAbi, onScroll: (indices: Array<number>) => void) {
React.useEffect(() => {
const id = window.location.hash.replace('#', '');
if (!id) {
return;
}
const index = data.findIndex((item) => 'method_id' in item && item.method_id === id);
if (index > -1) {
scroller.scrollTo(getElementName(id), {
duration: 500,
smooth: true,
offset: -100,
});
onScroll([ index ]);
}
}, [ data, onScroll ]);
}
export const getNativeCoinValue = (value: unknown) => {
if (typeof value !== 'string') {
return BigInt(0);
}
return BigInt(value);
};
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 config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import useAccount from 'lib/web3/useAccount';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractAbi from './ABI/ContractAbi';
import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodConstant from './ContractMethodConstant';
import ContractReadResult from './ContractReadResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useWatchAccount from './useWatchAccount';
interface Props {
isLoading?: boolean;
}
const ContractRead = ({ isLoading }: Props) => {
const apiFetch = useApiFetch();
const account = useWatchAccount();
const { address } = useAccount();
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
......@@ -37,55 +30,13 @@ const ContractRead = ({ isLoading }: Props) => {
pathParams: { hash: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
from: account?.address,
from: address,
},
queryOptions: {
enabled: !isLoading,
},
});
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array<unknown>) => {
return apiFetch<'contract_method_query', SmartContractQueryMethodRead>('contract_method_query', {
pathParams: { hash: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
},
fetchParams: {
method: 'POST',
body: {
args,
method_id: item.method_id,
contract_type: isProxy ? 'proxy' : 'regular',
from: account?.address,
},
},
});
}, [ account?.address, addressHash, apiFetch, isCustomAbi, isProxy ]);
const renderItemContent = React.useCallback((item: SmartContractReadMethod, index: number, id: number) => {
if (item.error) {
return <Alert status="error" fontSize="sm" wordBreak="break-word">{ item.error }</Alert>;
}
if (item.outputs?.some(({ value }) => value !== undefined && value !== null)) {
return (
<Flex flexDir="column" rowGap={ 1 }>
{ item.outputs.map((output, index) => <ContractMethodConstant key={ index } data={ output }/>) }
</Flex>
);
}
return (
<ContractMethodForm
key={ id + '_' + index }
data={ item }
onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractReadResult }
methodType="read"
/>
);
}, [ handleMethodFormSubmit ]);
if (isError) {
return <DataFetchAlert/>;
}
......@@ -101,9 +52,9 @@ const ContractRead = ({ isLoading }: Props) => {
return (
<>
{ isCustomAbi && <ContractCustomAbiAlert/> }
{ account && <ContractConnectWallet/> }
{ config.features.blockchainInteraction.isEnabled && <ContractConnectWallet/> }
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/>
<ContractAbi data={ data } addressHash={ addressHash } tab={ tab } methodType="read"/>
</>
);
};
......
import { Alert, Box, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { ContractMethodReadResult } from './types';
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 {
item: SmartContractReadMethod;
result: ContractMethodReadResult;
onSettle: () => void;
}
const ContractReadResult = ({ item, result, onSettle }: Props) => {
const resultBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
React.useEffect(() => {
onSettle();
}, [ onSettle ]);
if ('status' in result) {
return <ContractReadResultError>{ result.statusText }</ContractReadResultError>;
}
if (result.is_error) {
if ('error' in result.result) {
return <ContractReadResultError>{ result.result.error }</ContractReadResultError>;
}
if ('message' in result.result) {
return <ContractReadResultError>[{ result.result.code }] { result.result.message }</ContractReadResultError>;
}
if ('raw' in result.result) {
return <ContractReadResultError>{ `Revert reason: ${ hexToUtf8(result.result.raw) }` }</ContractReadResultError>;
}
if ('method_id' in result.result) {
return <ContractReadResultError>{ JSON.stringify(result.result, undefined, 2) }</ContractReadResultError>;
}
return <ContractReadResultError>Something went wrong.</ContractReadResultError>;
}
return (
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm" whiteSpace="break-spaces" wordBreak="break-all">
<p>
[ <chakra.span fontWeight={ 500 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
</p>
<p>[</p>
{ result.result.output.map((output, index) => <ContractReadResultItem key={ index } output={ output } name={ result.result.names[index] }/>) }
<p>]</p>
</Box>
);
};
export default React.memo(ContractReadResult);
import { useRouter } from 'next/router';
import React from 'react';
import { useAccount, useWalletClient, useSwitchChain } from 'wagmi';
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';
import ContractAbi from './ABI/ContractAbi';
import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress';
import ContractWriteResult from './ContractWriteResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useContractAbi from './useContractAbi';
import { getNativeCoinValue, prepareAbi } from './utils';
interface Props {
isLoading?: boolean;
}
const ContractWrite = ({ isLoading }: Props) => {
const { data: walletClient } = useWalletClient();
const { isConnected, chainId } = useAccount();
const { switchChainAsync } = useSwitchChain();
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
......@@ -46,63 +35,6 @@ const ContractWrite = ({ isLoading }: Props) => {
},
});
const contractAbi = useContractAbi({ addressHash, isProxy, isCustomAbi });
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array<unknown>) => {
if (!isConnected) {
throw new Error('Wallet is not connected');
}
if (chainId && String(chainId) !== config.chain.id) {
await switchChainAsync?.({ chainId: Number(config.chain.id) });
}
if (!contractAbi) {
throw new Error('Something went wrong. Try again later.');
}
if (item.type === 'receive' || item.type === 'fallback') {
const value = getNativeCoinValue(args[0]);
const hash = await walletClient?.sendTransaction({
to: addressHash as `0x${ string }` | undefined,
value,
});
return { hash };
}
const methodName = item.name;
if (!methodName) {
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 hash = await walletClient?.writeContract({
args: _args,
abi,
functionName: methodName,
address: addressHash as `0x${ string }`,
value,
});
return { hash };
}, [ isConnected, chainId, contractAbi, walletClient, addressHash, switchChainAsync ]);
const renderItemContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => {
return (
<ContractMethodForm
key={ id + '_' + index }
data={ item }
onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractWriteResult }
methodType="write"
/>
);
}, [ handleMethodFormSubmit ]);
if (isError) {
return <DataFetchAlert/>;
}
......@@ -118,9 +50,9 @@ const ContractWrite = ({ isLoading }: Props) => {
return (
<>
{ isCustomAbi && <ContractCustomAbiAlert/> }
<ContractConnectWallet/>
{ config.features.blockchainInteraction.isEnabled && <ContractConnectWallet/> }
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/>
<ContractAbi data={ data } addressHash={ addressHash } tab={ tab } methodType="write"/>
</>
);
};
......
import React from 'react';
import { useWaitForTransactionReceipt } from 'wagmi';
import type { ResultComponentProps } from './methodForm/types';
import type { ContractMethodWriteResult } from './types';
import type { SmartContractWriteMethod } from 'types/api/contract';
import ContractWriteResultDumb from './ContractWriteResultDumb';
const ContractWriteResult = ({ result, onSettle }: ResultComponentProps<SmartContractWriteMethod>) => {
const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined;
const txInfo = useWaitForTransactionReceipt({
hash: txHash,
});
return <ContractWriteResultDumb result={ result as ContractMethodWriteResult } onSettle={ onSettle } txInfo={ txInfo }/>;
};
export default React.memo(ContractWriteResult) as typeof ContractWriteResult;
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 { SmartContractQueryMethodRead, SmartContractMethod, SmartContractReadMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
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;
export type ContractMethodWriteResult = Error | { hash: `0x${ string }` | undefined } | undefined;
export type ContractMethodCallResult<T extends SmartContractMethod> =
T extends SmartContractReadMethod ? ContractMethodReadResult : ContractMethodWriteResult;
import { useQueryClient } from '@tanstack/react-query';
import type { Abi } from 'abitype';
import React from 'react';
import type { Address } from 'types/api/address';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
interface Params {
addressHash?: string;
isProxy?: boolean;
isCustomAbi?: boolean;
}
export default function useContractAbi({ addressHash, isProxy, isCustomAbi }: Params): Abi | undefined {
const queryClient = useQueryClient();
const { data: contractInfo } = useApiQuery('contract', {
pathParams: { hash: addressHash },
queryOptions: {
enabled: Boolean(addressHash),
refetchOnMount: false,
},
});
const addressInfo = queryClient.getQueryData<Address>(getResourceKey('address', {
pathParams: { hash: addressHash },
}));
const { data: proxyInfo } = useApiQuery('contract', {
pathParams: { hash: addressInfo?.implementation_address || '' },
queryOptions: {
enabled: Boolean(addressInfo?.implementation_address),
refetchOnMount: false,
},
});
const { data: customInfo } = useApiQuery('contract_methods_write', {
pathParams: { hash: addressHash },
queryParams: { is_custom_abi: 'true' },
queryOptions: {
enabled: Boolean(contractInfo?.has_custom_methods_write),
refetchOnMount: false,
},
});
return React.useMemo(() => {
if (isProxy) {
return proxyInfo?.abi ?? undefined;
}
if (isCustomAbi) {
return customInfo as Abi;
}
return contractInfo?.abi ?? undefined;
}, [ contractInfo?.abi, customInfo, isCustomAbi, isProxy, proxyInfo?.abi ]);
}
import { watchAccount, getAccount } from '@wagmi/core';
import React from 'react';
import type { Config } from 'wagmi';
import { useConfig } from 'wagmi';
export function getWalletAccount(config: Config) {
try {
return getAccount(config);
} catch (error) {
return null;
}
}
export default function useWatchAccount() {
const config = useConfig();
const [ account, setAccount ] = React.useState(getWalletAccount(config));
React.useEffect(() => {
if (!account) {
return;
}
return watchAccount(config, {
onChange(account) {
setAccount(account);
},
});
}, [ account, config ]);
return account;
}
import { prepareAbi } from './utils';
describe('function prepareAbi()', () => {
const commonAbi = [
{
inputs: [
{ internalType: 'address', name: '_pool', type: 'address' },
{ internalType: 'address', name: '_token', type: 'address' },
{ internalType: 'uint256', name: '_denominator', type: 'uint256' },
],
stateMutability: 'nonpayable' as const,
type: 'constructor' as const,
},
{
anonymous: false,
inputs: [
{ indexed: false, internalType: 'uint256[]', name: 'indices', type: 'uint256[]' },
],
name: 'CompleteDirectDepositBatch',
type: 'event' as const,
},
{
inputs: [
{ internalType: 'address', name: '_fallbackUser', type: 'address' },
{ internalType: 'string', name: '_zkAddress', type: 'string' },
],
name: 'directNativeDeposit',
outputs: [
{ internalType: 'uint256', name: '', type: 'uint256' },
],
stateMutability: 'payable' as const,
type: 'function' as const,
},
];
const method = {
inputs: [
{ internalType: 'address' as const, name: '_fallbackUser', type: 'address' as const },
{ internalType: 'string' as const, name: '_zkAddress', type: 'string' as const },
],
name: 'directNativeDeposit',
outputs: [
{ internalType: 'uint256' as const, name: '', type: 'uint256' as const },
],
stateMutability: 'payable' as const,
type: 'function' as const,
constant: false,
payable: true,
method_id: '0x2e0e2d3e',
};
it('if there is only one method with provided name, does nothing', () => {
const abi = prepareAbi(commonAbi, method);
expect(abi).toHaveLength(commonAbi.length);
});
it('if there are two or more methods with the same name and inputs length, filters out those which input types are not matched', () => {
const abi = prepareAbi([
...commonAbi,
{
inputs: [
{ internalType: 'address', name: '_fallbackUser', type: 'address' },
{ internalType: 'bytes', name: '_rawZkAddress', type: 'bytes' },
],
name: 'directNativeDeposit',
outputs: [
{ internalType: 'uint256', name: '', type: 'uint256' },
],
stateMutability: 'payable',
type: 'function',
},
], method);
expect(abi).toHaveLength(commonAbi.length);
const item = abi.find((item) => 'name' in item ? item.name === method.name : false);
expect(item).toEqual(commonAbi[2]);
});
it('if there are two or more methods with the same name and different inputs length, filters out those which inputs are not matched', () => {
const abi = prepareAbi([
...commonAbi,
{
inputs: [
{ internalType: 'address', name: '_fallbackUser', type: 'address' },
],
name: 'directNativeDeposit',
outputs: [
{ internalType: 'uint256', name: '', type: 'uint256' },
],
stateMutability: 'payable',
type: 'function',
},
], method);
expect(abi).toHaveLength(commonAbi.length);
const item = abi.find((item) => 'name' in item ? item.name === method.name : false);
expect(item).toEqual(commonAbi[2]);
});
});
import type { Abi } from 'abitype';
import type { SmartContractWriteMethod } from 'types/api/contract';
export const getNativeCoinValue = (value: unknown) => {
if (typeof value !== 'string') {
return BigInt(0);
}
return BigInt(value);
};
export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi {
if ('name' in item) {
const hasMethodsWithSameName = abi.filter((abiItem) => 'name' in abiItem ? abiItem.name === item.name : false).length > 1;
if (hasMethodsWithSameName) {
return abi.filter((abiItem) => {
if (!('name' in abiItem)) {
return true;
}
if (abiItem.name !== item.name) {
return true;
}
if (abiItem.inputs.length !== item.inputs.length) {
return false;
}
return abiItem.inputs.every(({ name, type }) => {
const itemInput = item.inputs.find((input) => input.name === name);
return Boolean(itemInput) && itemInput?.type === type;
});
});
}
}
return abi;
}
......@@ -7,9 +7,10 @@ export interface Props {
text: string;
className?: string;
isLoading?: boolean;
onClick?: (event: React.MouseEvent) => void;
}
const CopyToClipboard = ({ text, className, isLoading }: Props) => {
const CopyToClipboard = ({ text, className, isLoading, onClick }: Props) => {
const { hasCopied, onCopy } = useClipboard(text, 1000);
const [ copied, setCopied ] = useState(false);
// have to implement controlled tooltip because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107
......@@ -24,6 +25,11 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => {
}
}, [ hasCopied ]);
const handleClick = React.useCallback((event: React.MouseEvent) => {
onCopy();
onClick?.(event);
}, [ onClick, onCopy ]);
if (isLoading) {
return <Skeleton boxSize={ 5 } className={ className } borderRadius="sm" flexShrink={ 0 } ml={ 2 } display="inline-block"/>;
}
......@@ -39,7 +45,7 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => {
variant="simple"
display="inline-block"
flexShrink={ 0 }
onClick={ onCopy }
onClick={ handleClick }
className={ className }
onMouseEnter={ onOpen }
onMouseLeave={ onClose }
......
......@@ -52,8 +52,11 @@ const TabsWithScroll = ({
}, [ tabs ]);
const handleTabChange = React.useCallback((index: number) => {
if (isLoading) {
return;
}
onTabChange ? onTabChange(index) : setActiveTabIndex(index);
}, [ onTabChange ]);
}, [ isLoading, onTabChange ]);
useEffect(() => {
if (defaultTabIndex !== undefined) {
......
import { Flex, chakra } from '@chakra-ui/react';
import dynamic from 'next/dynamic';
import React from 'react';
import { useAccount } from 'wagmi';
import useIsMobile from 'lib/hooks/useIsMobile';
import Web3ModalProvider from '../Web3ModalProvider';
import useAccount from 'lib/web3/useAccount';
const GetitAdPlugin = dynamic(() => import('getit-sdk').then(module => module.GetitAdPlugin), { ssr: false });
const GETIT_API_KEY = 'ZmGXVvwYUAW4yXL8RzWQHNKmpSyQmt3TDXsXUxqFqXPdoaiSSFyca3BOyunDcWdyOwTkX3UVVQel28qbjoOoWPxYVpPdNzbUNkAHyFyJX7Lk9TVcPDZKTQmwHlSMzO3a';
const GetitBannerContent = ({ address, className }: { address?: string; className?: string }) => {
const GetitBanner = ({ className }: { className?: string }) => {
const isMobile = Boolean(useIsMobile());
const { address } = useAccount();
return (
<Flex className={ className } h="90px">
......@@ -27,22 +26,4 @@ const GetitBannerContent = ({ address, className }: { address?: string; classNam
);
};
const GetitBannerWithWalletAddress = ({ className }: { className?: string }) => {
const { address } = useAccount();
return <GetitBannerContent address={ address } className={ className }/>;
};
const GetitBanner = ({ className }: { className?: string }) => {
const fallback = React.useCallback(() => {
return <GetitBannerContent className={ className }/>;
}, [ className ]);
return (
<Web3ModalProvider fallback={ fallback }>
<GetitBannerWithWalletAddress className={ className }/>
</Web3ModalProvider>
);
};
export default chakra(GetitBanner);
......@@ -2,15 +2,22 @@ import { Flex, chakra } from '@chakra-ui/react';
import { Banner, setWalletAddresses } from '@hypelab/sdk-react';
import Script from 'next/script';
import React from 'react';
import { useAccount } from 'wagmi';
import Web3ModalProvider from '../Web3ModalProvider';
import useAccount from 'lib/web3/useAccount';
import { hypeInit } from './hypeBannerScript';
const DESKTOP_BANNER_SLUG = 'b1559fc3e7';
const MOBILE_BANNER_SLUG = '668ed80a9e';
const HypeBannerContent = ({ className }: { className?: string }) => {
const HypeBanner = ({ className }: { className?: string }) => {
const { address } = useAccount();
React.useEffect(() => {
if (address) {
setWalletAddresses([ address ]);
}
}, [ address ]);
return (
<>
......@@ -28,28 +35,4 @@ const HypeBannerContent = ({ className }: { className?: string }) => {
);
};
const HypeBannerWithWalletAddress = ({ className }: { className?: string }) => {
const { address } = useAccount();
React.useEffect(() => {
if (address) {
setWalletAddresses([ address ]);
}
}, [ address ]);
return <HypeBannerContent className={ className }/>;
};
const HypeBanner = ({ className }: { className?: string }) => {
const fallback = React.useCallback(() => {
return <HypeBannerContent className={ className }/>;
}, [ className ]);
return (
<Web3ModalProvider fallback={ fallback }>
<HypeBannerWithWalletAddress className={ className }/>
</Web3ModalProvider>
);
};
export default chakra(HypeBanner);
import { Divider, Flex, Skeleton, VStack } from '@chakra-ui/react';
import React from 'react';
import Tag from 'ui/shared/chakra/Tag';
interface Props {
methodId: string;
methodCall: string;
isLoading?: boolean;
}
const Item = ({ label, text, isLoading }: { label: string; text: string; isLoading?: boolean}) => {
const Item = ({ label, children, isLoading }: { label: string; children: React.ReactNode; isLoading?: boolean}) => {
return (
<Flex
columnGap={ 5 }
......@@ -19,7 +21,7 @@ const Item = ({ label, text, isLoading }: { label: string; text: string; isLoadi
<Skeleton fontWeight={ 600 } w={{ base: 'auto', lg: '80px' }} flexShrink={ 0 } isLoaded={ !isLoading }>
{ label }
</Skeleton >
<Skeleton isLoaded={ !isLoading } whiteSpace="pre-wrap">{ text }</Skeleton>
{ children }
</Flex>
);
};
......@@ -32,8 +34,12 @@ const LogDecodedInputDataHeader = ({ methodId, methodCall, isLoading }: Props) =
fontSize="sm"
lineHeight={ 5 }
>
<Item label="Method id" text={ methodId } isLoading={ isLoading }/>
<Item label="Call" text={ methodCall } isLoading={ isLoading }/>
<Item label="Method id" isLoading={ isLoading }>
<Tag isLoading={ isLoading }>{ methodId }</Tag>
</Item>
<Item label="Call" isLoading={ isLoading }>
<Skeleton isLoaded={ !isLoading } whiteSpace="pre-wrap">{ methodCall }</Skeleton>
</Item>
</VStack>
);
};
......
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