Commit 70b2a69e authored by tom goriunov's avatar tom goriunov Committed by GitHub

Better contract implementation selector and new quick action buttons for...

Better contract implementation selector and new quick action buttons for contract method argument inputs (#2303)

* MUD system tab placeholder

* implement MUD system selector and fix proxy contract read/write

* add buttons to contract method arg input

* rollback changes of target contract address

* copy calldata button

* rollback changes of target contract address

* refactor contract details components

* add tabs to contract details

* migrate code to RoutedTabs

* add selector for source contract

* tests

* fix tests

* update screenshot

* update screenshots
parent b5d6ec96
...@@ -46,6 +46,7 @@ export const CONTRACT_CODE_VERIFIED = { ...@@ -46,6 +46,7 @@ export const CONTRACT_CODE_VERIFIED = {
remappings: [], remappings: [],
}, },
compiler_version: 'v0.8.7+commit.e28d00a7', compiler_version: 'v0.8.7+commit.e28d00a7',
constructor_args: '0000000000000000000000005c7bcd6e7de5423a257d81b442095a1a6ced35c5000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
creation_bytecode: '0x6080604052348', creation_bytecode: '0x6080604052348',
deployed_bytecode: '0x60806040', deployed_bytecode: '0x60806040',
evm_version: 'london', evm_version: 'london',
......
...@@ -15,6 +15,7 @@ test.use({ viewport: { width: 150, height: 350 } }); ...@@ -15,6 +15,7 @@ test.use({ viewport: { width: 150, height: 350 } });
{ variant: 'subtle', colorScheme: 'gray', states: [ 'default', 'hovered' ], withDarkMode: true }, { variant: 'subtle', colorScheme: 'gray', states: [ 'default', 'hovered' ], withDarkMode: true },
{ variant: 'hero', states: [ 'default', 'hovered' ], withDarkMode: true }, { variant: 'hero', states: [ 'default', 'hovered' ], withDarkMode: true },
{ variant: 'header', states: [ 'default', 'hovered', 'selected' ], withDarkMode: true }, { variant: 'header', states: [ 'default', 'hovered', 'selected' ], withDarkMode: true },
{ variant: 'radio_group', states: [ 'default', 'hovered', 'selected' ], withDarkMode: true },
].forEach(({ variant, colorScheme, withDarkMode, states }) => { ].forEach(({ variant, colorScheme, withDarkMode, states }) => {
test.describe(`variant ${ variant }${ colorScheme ? ` with ${ colorScheme } color scheme` : '' }${ withDarkMode ? ' +@dark-mode' : '' }`, () => { test.describe(`variant ${ variant }${ colorScheme ? ` with ${ colorScheme } color scheme` : '' }${ withDarkMode ? ' +@dark-mode' : '' }`, () => {
test('', async({ render }) => { test('', async({ render }) => {
......
...@@ -94,6 +94,42 @@ const variantOutline = defineStyle((props) => { ...@@ -94,6 +94,42 @@ const variantOutline = defineStyle((props) => {
}; };
}); });
const variantRadioGroup = defineStyle((props) => {
const outline = runIfFn(variantOutline, props);
const bgColor = mode('blue.50', 'gray.800')(props);
const selectedTextColor = mode('blue.700', 'gray.50')(props);
return {
...outline,
fontWeight: 500,
cursor: 'pointer',
bgColor: 'none',
borderColor: bgColor,
_hover: {
borderColor: bgColor,
color: 'link_hovered',
},
_active: {
bgColor: 'none',
},
// We have a special state for this button variant that serves as a popover trigger.
// When any items (filters) are selected in the popover, the button should change its background and text color.
// The last CSS selector is for redefining styles for the TabList component.
[`
&[data-selected=true],
&[data-selected=true][aria-selected=true]
`]: {
cursor: 'initial',
bgColor,
borderColor: bgColor,
color: selectedTextColor,
_hover: {
color: selectedTextColor,
},
},
};
});
const variantSimple = defineStyle((props) => { const variantSimple = defineStyle((props) => {
const outline = runIfFn(variantOutline, props); const outline = runIfFn(variantOutline, props);
...@@ -223,6 +259,7 @@ const variants = { ...@@ -223,6 +259,7 @@ const variants = {
subtle: variantSubtle, subtle: variantSubtle,
hero: variantHero, hero: variantHero,
header: variantHeader, header: variantHeader,
radio_group: variantRadioGroup,
}; };
const baseStyle = defineStyle({ const baseStyle = defineStyle({
......
...@@ -41,6 +41,33 @@ const variantOutline = definePartsStyle((props) => { ...@@ -41,6 +41,33 @@ const variantOutline = definePartsStyle((props) => {
}; };
}); });
const variantRadioGroup = definePartsStyle((props) => {
return {
tab: {
...Button.baseStyle,
...Button.variants?.radio_group(props),
_selected: Button.variants?.radio_group(props)?.[`
&[data-selected=true],
&[data-selected=true][aria-selected=true]
`],
borderRadius: 'none',
_notFirst: {
borderLeftWidth: 0,
},
'&[role="tab"]': {
_first: {
borderTopLeftRadius: 'base',
borderBottomLeftRadius: 'base',
},
_last: {
borderTopRightRadius: 'base',
borderBottomRightRadius: 'base',
},
},
},
};
});
const sizes = { const sizes = {
sm: definePartsStyle({ sm: definePartsStyle({
tab: Button.sizes?.sm, tab: Button.sizes?.sm,
...@@ -53,6 +80,7 @@ const sizes = { ...@@ -53,6 +80,7 @@ const sizes = {
const variants = { const variants = {
'soft-rounded': variantSoftRounded, 'soft-rounded': variantSoftRounded,
outline: variantOutline, outline: variantOutline,
radio_group: variantRadioGroup,
}; };
const Tabs = defineMultiStyleConfig({ const Tabs = defineMultiStyleConfig({
......
...@@ -2,8 +2,8 @@ import { useRouter } from 'next/router'; ...@@ -2,8 +2,8 @@ import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import useContractTabs from 'lib/hooks/useContractTabs';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import useContractTabs from 'ui/address/contract/useContractTabs';
import AddressContract from './AddressContract'; import AddressContract from './AddressContract';
......
This diff is collapsed.
import type { UseQueryResult } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import type { Channel } from 'phoenix';
import React from 'react';
import type { SocketMessage } from 'lib/socket/types';
import type { Address as AddressInfo } from 'types/api/address';
import type { AddressImplementation } from 'types/api/addressParams';
import type { SmartContract } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketMessage from 'lib/socket/useSocketMessage';
import * as stubs from 'stubs/contract';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import ContractDetailsAlerts from './alerts/ContractDetailsAlerts';
import ContractSourceAddressSelector from './ContractSourceAddressSelector';
import ContractDetailsInfo from './info/ContractDetailsInfo';
import useContractDetailsTabs from './useContractDetailsTabs';
const TAB_LIST_PROPS = { flexWrap: 'wrap', rowGap: 2 };
type Props = {
addressHash: string;
channel: Channel | undefined;
mainContractQuery: UseQueryResult<SmartContract, ResourceError>;
}
const ContractDetails = ({ addressHash, channel, mainContractQuery }: Props) => {
const router = useRouter();
const sourceAddress = getQueryParamString(router.query.source_address);
const queryClient = useQueryClient();
const addressInfo = queryClient.getQueryData<AddressInfo>(getResourceKey('address', { pathParams: { hash: addressHash } }));
const sourceItems: Array<AddressImplementation> = React.useMemo(() => {
const currentAddressItem = { address: addressHash, name: addressInfo?.name || 'Contract' };
if (!addressInfo || !addressInfo.implementations || addressInfo.implementations.length === 0) {
return [ currentAddressItem ];
}
return [
currentAddressItem,
...(addressInfo?.implementations.filter((item) => item.address !== addressHash && item.name) || []),
];
}, [ addressInfo, addressHash ]);
const [ selectedItem, setSelectedItem ] = React.useState(sourceItems.find((item) => item.address === sourceAddress) || sourceItems[0]);
const contractQuery = useApiQuery('contract', {
pathParams: { hash: selectedItem?.address },
queryOptions: {
enabled: Boolean(selectedItem?.address),
refetchOnMount: false,
placeholderData: addressInfo?.is_verified ? stubs.CONTRACT_CODE_VERIFIED : stubs.CONTRACT_CODE_UNVERIFIED,
},
});
const { data, isPlaceholderData, isError } = contractQuery;
const tabs = useContractDetailsTabs({ data, isLoading: isPlaceholderData, addressHash, sourceAddress: selectedItem.address });
const handleContractWasVerifiedMessage: SocketMessage.SmartContractWasVerified['handler'] = React.useCallback(() => {
queryClient.refetchQueries({
queryKey: getResourceKey('address', { pathParams: { hash: addressHash } }),
});
queryClient.refetchQueries({
queryKey: getResourceKey('contract', { pathParams: { hash: addressHash } }),
});
}, [ addressHash, queryClient ]);
useSocketMessage({
channel,
event: 'smart_contract_was_verified',
handler: handleContractWasVerifiedMessage,
});
if (isError) {
return <DataFetchAlert/>;
}
const addressSelector = sourceItems.length > 1 ? (
<ContractSourceAddressSelector
isLoading={ mainContractQuery.isPlaceholderData }
label="Source code"
items={ sourceItems }
selectedItem={ selectedItem }
onItemSelect={ setSelectedItem }
mr={{ lg: 8 }}
/>
) : null;
return (
<>
<ContractDetailsAlerts
data={ mainContractQuery.data }
isLoading={ mainContractQuery.isPlaceholderData }
addressHash={ addressHash }
channel={ channel }
/>
{ mainContractQuery.data?.is_verified && (
<ContractDetailsInfo
data={ mainContractQuery.data }
isLoading={ mainContractQuery.isPlaceholderData }
addressHash={ addressHash }
/>
) }
<RoutedTabs
tabs={ tabs }
isLoading={ isPlaceholderData }
variant="radio_group"
size="sm"
leftSlot={ addressSelector }
tabListProps={ TAB_LIST_PROPS }
/>
</>
);
};
export default ContractDetails;
import { Button, Skeleton } from '@chakra-ui/react';
import React from 'react';
import { route } from 'nextjs-routes';
interface Props {
isLoading: boolean;
addressHash: string;
isPartiallyVerified: boolean;
}
const ContractDetailsVerificationButton = ({ isLoading, addressHash, isPartiallyVerified }: Props) => {
if (isLoading) {
return (
<Skeleton
w="130px"
h={ 8 }
mr={ isPartiallyVerified ? 0 : 3 }
ml={ isPartiallyVerified ? 0 : 'auto' }
borderRadius="base"
flexShrink={ 0 }
/>
);
}
return (
<Button
size="sm"
mr={ isPartiallyVerified ? 0 : 3 }
ml={ isPartiallyVerified ? 0 : 'auto' }
flexShrink={ 0 }
as="a"
href={ route({ pathname: '/address/[hash]/contract-verification', query: { hash: addressHash } }) }
>
Verify & publish
</Button>
);
};
export default React.memo(ContractDetailsVerificationButton);
import { Flex, Select, Skeleton } from '@chakra-ui/react'; import { chakra, Flex, Select, Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
...@@ -13,6 +13,7 @@ export interface Item { ...@@ -13,6 +13,7 @@ export interface Item {
} }
interface Props { interface Props {
className?: string;
label: string; label: string;
selectedItem: Item; selectedItem: Item;
onItemSelect: (item: Item) => void; onItemSelect: (item: Item) => void;
...@@ -20,7 +21,7 @@ interface Props { ...@@ -20,7 +21,7 @@ interface Props {
isLoading?: boolean; isLoading?: boolean;
} }
const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLoading, label }: Props) => { const ContractSourceAddressSelector = ({ className, selectedItem, onItemSelect, items, isLoading, label }: Props) => {
const handleItemSelect = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => { const handleItemSelect = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
const nextOption = items.find(({ address }) => address === event.target.value); const nextOption = items.find(({ address }) => address === event.target.value);
...@@ -30,7 +31,7 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo ...@@ -30,7 +31,7 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo
}, [ items, onItemSelect ]); }, [ items, onItemSelect ]);
if (isLoading) { if (isLoading) {
return <Skeleton mb={ 6 } h={ 6 } w={{ base: '300px', lg: '500px' }}/>; return <Skeleton h={ 6 } w={{ base: '300px', lg: '500px' }} className={ className }/>;
} }
if (items.length === 0) { if (items.length === 0) {
...@@ -39,8 +40,8 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo ...@@ -39,8 +40,8 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo
if (items.length === 1) { if (items.length === 1) {
return ( return (
<Flex mb={ 6 } flexWrap="wrap" columnGap={ 3 } rowGap={ 2 }> <Flex flexWrap="wrap" columnGap={ 3 } rowGap={ 2 } className={ className }>
<span>{ label }</span> <chakra.span fontWeight={ 500 } fontSize="sm">{ label }</chakra.span>
<AddressEntity <AddressEntity
address={{ hash: items[0].address, is_contract: true, is_verified: true }} address={{ hash: items[0].address, is_contract: true, is_verified: true }}
/> />
...@@ -49,8 +50,8 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo ...@@ -49,8 +50,8 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo
} }
return ( return (
<Flex mb={ 6 } flexWrap="wrap" columnGap={ 3 } rowGap={ 2 } alignItems="center"> <Flex columnGap={ 3 } rowGap={ 2 } alignItems="center" className={ className }>
<span>{ label }</span> <chakra.span fontWeight={ 500 } fontSize="sm">{ label }</chakra.span>
<Select <Select
size="xs" size="xs"
value={ selectedItem.address } value={ selectedItem.address }
...@@ -76,4 +77,4 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo ...@@ -76,4 +77,4 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo
); );
}; };
export default React.memo(ContractSourceAddressSelector); export default React.memo(chakra(ContractSourceAddressSelector));
import { Flex, Select, Skeleton, Text, Tooltip } from '@chakra-ui/react'; import { Flex, Skeleton, Text, Tooltip } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { AddressImplementation } from 'types/api/addressParams';
import type { SmartContract } from 'types/api/contract'; import type { SmartContract } from 'types/api/contract';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import useApiQuery from 'lib/api/useApiQuery';
import * as stubs from 'stubs/contract';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import CodeEditor from 'ui/shared/monaco/CodeEditor'; import CodeEditor from 'ui/shared/monaco/CodeEditor';
...@@ -38,82 +35,34 @@ function getEditorData(contractInfo: SmartContract | undefined) { ...@@ -38,82 +35,34 @@ function getEditorData(contractInfo: SmartContract | undefined) {
]; ];
} }
interface SourceContractOption {
address: string;
label: string;
}
interface Props { interface Props {
address: string; data: SmartContract | undefined;
implementations?: Array<AddressImplementation>; sourceAddress: string;
isLoading?: boolean;
} }
export const ContractSourceCode = ({ address, implementations }: Props) => { export const ContractSourceCode = ({ data, isLoading, sourceAddress }: Props) => {
const options: Array<SourceContractOption> = React.useMemo(() => {
return [
{ label: 'Proxy', address },
...(implementations || [])
.filter((item) => item.name && item.address !== address)
.map(({ name, address }, item, array) => ({ address, label: array.length === 1 ? 'Implementation' : `Impl: ${ name }` })),
];
}, [ address, implementations ]);
const [ sourceContract, setSourceContract ] = React.useState<SourceContractOption>(options[0]);
const contractQuery = useApiQuery('contract', {
pathParams: { hash: sourceContract.address },
queryOptions: {
refetchOnMount: false,
placeholderData: stubs.CONTRACT_CODE_VERIFIED,
},
});
const editorData = React.useMemo(() => { const editorData = React.useMemo(() => {
return getEditorData(contractQuery.data); return getEditorData(data);
}, [ contractQuery.data ]); }, [ data ]);
const isLoading = contractQuery.isPlaceholderData;
const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
const nextOption = options.find(({ address }) => address === event.target.value);
if (nextOption) {
setSourceContract(nextOption);
}
}, [ options ]);
const heading = ( const heading = (
<Skeleton isLoaded={ !isLoading } fontWeight={ 500 }> <Skeleton isLoaded={ !isLoading } fontWeight={ 500 }>
<span>Contract source code</span> <span>Contract source code</span>
{ contractQuery.data?.language && { data?.language &&
<Text whiteSpace="pre" as="span" variant="secondary" textTransform="capitalize"> ({ contractQuery.data.language })</Text> } <Text whiteSpace="pre" as="span" variant="secondary" textTransform="capitalize"> ({ data.language })</Text> }
</Skeleton> </Skeleton>
); );
const select = options.length > 1 ? ( const externalLibraries = data?.external_libraries ?
<Select <ContractExternalLibraries data={ data.external_libraries } isLoading={ isLoading }/> :
size="xs"
value={ sourceContract.address }
onChange={ handleSelectChange }
w="auto"
maxW={{ lg: '200px', xl: '400px' }}
whiteSpace="nowrap"
textOverflow="ellipsis"
fontWeight={ 600 }
borderRadius="base"
>
{ options.map((option) => <option key={ option.address } value={ option.address }>{ option.label }</option>) }
</Select>
) : null;
const externalLibraries = contractQuery.data?.external_libraries ?
<ContractExternalLibraries data={ contractQuery.data.external_libraries } isLoading={ isLoading }/> :
null; null;
const diagramLink = contractQuery?.data?.can_be_visualized_via_sol2uml ? ( const diagramLink = data?.can_be_visualized_via_sol2uml ? (
<Tooltip label="Visualize contract code using Sol2Uml JS library"> <Tooltip label="Visualize contract code using Sol2Uml JS library">
<LinkInternal <LinkInternal
href={ route({ pathname: '/visualize/sol2uml', query: { address: sourceContract.address } }) } href={ route({ pathname: '/visualize/sol2uml', query: { address: sourceAddress } }) }
ml={{ base: '0', lg: 'auto' }} ml={{ base: '0', lg: 'auto' }}
isLoading={ isLoading } isLoading={ isLoading }
> >
...@@ -124,11 +73,11 @@ export const ContractSourceCode = ({ address, implementations }: Props) => { ...@@ -124,11 +73,11 @@ export const ContractSourceCode = ({ address, implementations }: Props) => {
</Tooltip> </Tooltip>
) : null; ) : null;
const ides = <ContractCodeIdes hash={ sourceContract.address } isLoading={ isLoading }/>; const ides = <ContractCodeIdes hash={ sourceAddress } isLoading={ isLoading }/>;
const copyToClipboard = contractQuery.data && editorData?.length === 1 ? ( const copyToClipboard = data && editorData?.length === 1 ? (
<CopyToClipboard <CopyToClipboard
text={ contractQuery.data.source_code } text={ data.source_code }
isLoading={ isLoading } isLoading={ isLoading }
ml={{ base: 'auto', lg: diagramLink ? '0' : 'auto' }} ml={{ base: 'auto', lg: diagramLink ? '0' : 'auto' }}
/> />
...@@ -146,13 +95,13 @@ export const ContractSourceCode = ({ address, implementations }: Props) => { ...@@ -146,13 +95,13 @@ export const ContractSourceCode = ({ address, implementations }: Props) => {
return ( return (
<CodeEditor <CodeEditor
key={ sourceContract.address } key={ sourceAddress }
data={ editorData } data={ editorData }
remappings={ contractQuery.data?.compiler_settings?.remappings } remappings={ data?.compiler_settings?.remappings }
libraries={ contractQuery.data?.external_libraries ?? undefined } libraries={ data?.external_libraries ?? undefined }
language={ contractQuery.data?.language ?? undefined } language={ data?.language ?? undefined }
mainFile={ editorData[0]?.file_path } mainFile={ editorData[0]?.file_path }
contractName={ contractQuery.data?.name ?? undefined } contractName={ data?.name ?? undefined }
/> />
); );
})(); })();
...@@ -161,7 +110,6 @@ export const ContractSourceCode = ({ address, implementations }: Props) => { ...@@ -161,7 +110,6 @@ export const ContractSourceCode = ({ address, implementations }: Props) => {
<section> <section>
<Flex alignItems="center" mb={ 3 } columnGap={ 3 } rowGap={ 2 } flexWrap="wrap"> <Flex alignItems="center" mb={ 3 } columnGap={ 3 } rowGap={ 2 } flexWrap="wrap">
{ heading } { heading }
{ select }
{ externalLibraries } { externalLibraries }
{ diagramLink } { diagramLink }
{ ides } { ides }
......
...@@ -2,19 +2,19 @@ import React from 'react'; ...@@ -2,19 +2,19 @@ import React from 'react';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
import ContractCodeProxyPattern from './ContractCodeProxyPattern'; import ContractDetailsAlertProxyPattern from './ContractDetailsAlertProxyPattern';
test('proxy type with link +@mobile', async({ render }) => { test('proxy type with link +@mobile', async({ render }) => {
const component = await render(<ContractCodeProxyPattern type="eip1167"/>); const component = await render(<ContractDetailsAlertProxyPattern type="eip1167"/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('proxy type with link but without description', async({ render }) => { test('proxy type with link but without description', async({ render }) => {
const component = await render(<ContractCodeProxyPattern type="master_copy"/>); const component = await render(<ContractDetailsAlertProxyPattern type="master_copy"/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('proxy type without link', async({ render }) => { test('proxy type without link', async({ render }) => {
const component = await render(<ContractCodeProxyPattern type="basic_implementation"/>); const component = await render(<ContractDetailsAlertProxyPattern type="basic_implementation"/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
import { Alert } from '@chakra-ui/react';
import React from 'react';
import type { SmartContract } from 'types/api/contract';
import LinkExternal from 'ui/shared/links/LinkExternal';
interface Props {
data: SmartContract | undefined;
}
const ContractDetailsAlertVerificationSource = ({ data }: Props) => {
if (data?.is_verified_via_eth_bytecode_db) {
return (
<Alert status="warning" whiteSpace="pre-wrap" flexWrap="wrap">
<span>This contract has been { data.is_partially_verified ? 'partially ' : '' }verified using </span>
<LinkExternal
href="https://docs.blockscout.com/about/features/ethereum-bytecode-database-microservice"
fontSize="md"
>
Blockscout Bytecode Database
</LinkExternal>
</Alert>
);
}
if (data?.is_verified_via_sourcify) {
return (
<Alert status="warning" whiteSpace="pre-wrap" flexWrap="wrap">
<span>This contract has been { data.is_partially_verified ? 'partially ' : '' }verified via Sourcify. </span>
{ data.sourcify_repo_url && <LinkExternal href={ data.sourcify_repo_url } fontSize="md">View contract in Sourcify repository</LinkExternal> }
</Alert>
);
}
return null;
};
export default React.memo(ContractDetailsAlertVerificationSource);
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as contractMock from 'mocks/contract/info';
import * as socketServer from 'playwright/fixtures/socketServer';
import { test, expect } from 'playwright/lib';
import ContractDetailsAlerts from './ContractDetailsAlerts.pwstory';
// FIXME
// test cases which use socket cannot run in parallel since the socket server always run on the same port
test.describe.configure({ mode: 'serial' });
test('verified with changed byte code socket', async({ render, createSocket }) => {
const props = {
data: contractMock.verified,
isLoading: false,
addressHash: addressMock.contract.hash,
};
const component = await render(<ContractDetailsAlerts { ...props }/>, undefined, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
socketServer.sendMessage(socket, channel, 'changed_bytecode', {});
await expect(component).toHaveScreenshot();
});
test('verified via sourcify', async({ render }) => {
const props = {
data: contractMock.verifiedViaSourcify,
isLoading: false,
addressHash: addressMock.contract.hash,
};
const component = await render(<ContractDetailsAlerts { ...props }/>, undefined, { withSocket: true });
await expect(component).toHaveScreenshot();
});
test('verified via eth bytecode db', async({ render }) => {
const props = {
data: contractMock.verifiedViaEthBytecodeDb,
isLoading: false,
addressHash: addressMock.contract.hash,
};
const component = await render(<ContractDetailsAlerts { ...props }/>, undefined, { withSocket: true });
await expect(component).toHaveScreenshot();
});
test('with twin address alert +@mobile', async({ render }) => {
const props = {
data: contractMock.withTwinAddress,
isLoading: false,
addressHash: addressMock.contract.hash,
};
const component = await render(<ContractDetailsAlerts { ...props }/>, undefined, { withSocket: true });
await expect(component).toHaveScreenshot();
});
import React from 'react';
import useSocketChannel from 'lib/socket/useSocketChannel';
import type { Props } from './ContractDetailsAlerts';
import ContractDetailsAlerts from './ContractDetailsAlerts';
const ContractDetailsAlertsPwStory = (props: Props) => {
const channel = useSocketChannel({
topic: `addresses:${ props.addressHash.toLowerCase() }`,
isDisabled: false,
});
return <ContractDetailsAlerts { ...props } channel={ channel }/>;
};
export default ContractDetailsAlertsPwStory;
import { chakra, Alert, Box, Flex, Skeleton } from '@chakra-ui/react';
import type { Channel } from 'phoenix';
import React from 'react';
import type { SocketMessage } from 'lib/socket/types';
import type { SmartContract } from 'types/api/contract';
import { route } from 'nextjs-routes';
import useSocketMessage from 'lib/socket/useSocketMessage';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import LinkExternal from 'ui/shared/links/LinkExternal';
import LinkInternal from 'ui/shared/links/LinkInternal';
import ContractDetailsVerificationButton from '../ContractDetailsVerificationButton';
import ContractDetailsAlertProxyPattern from './ContractDetailsAlertProxyPattern';
import ContractDetailsAlertVerificationSource from './ContractDetailsAlertVerificationSource';
export interface Props {
data: SmartContract | undefined;
isLoading: boolean;
addressHash: string;
channel?: Channel;
}
const ContractDetailsAlerts = ({ data, isLoading, addressHash, channel }: Props) => {
const [ isChangedBytecodeSocket, setIsChangedBytecodeSocket ] = React.useState<boolean>();
const handleChangedBytecodeMessage: SocketMessage.AddressChangedBytecode['handler'] = React.useCallback(() => {
setIsChangedBytecodeSocket(true);
}, [ ]);
useSocketMessage({
channel,
event: 'changed_bytecode',
handler: handleChangedBytecodeMessage,
});
return (
<Flex flexDir="column" rowGap={ 2 } mb={ 6 } _empty={{ display: 'none' }}>
{ data?.is_blueprint && (
<Box>
<span>This is an </span>
<LinkExternal href="https://eips.ethereum.org/EIPS/eip-5202">
ERC-5202 Blueprint contract
</LinkExternal>
</Box>
) }
{ data?.is_verified && (
<Skeleton isLoaded={ !isLoading }>
<Alert status="success" flexWrap="wrap" rowGap={ 3 } columnGap={ 5 }>
<span>Contract Source Code Verified ({ data.is_partially_verified ? 'Partial' : 'Exact' } Match)</span>
{
data.is_partially_verified ? (
<ContractDetailsVerificationButton
isLoading={ isLoading }
addressHash={ addressHash }
isPartiallyVerified
/>
) : null
}
</Alert>
</Skeleton>
) }
<ContractDetailsAlertVerificationSource data={ data }/>
{ (data?.is_changed_bytecode || isChangedBytecodeSocket) && (
<Alert status="warning">
Warning! Contract bytecode has been changed and does not match the verified one. Therefore, interaction with this smart contract may be risky.
</Alert>
) }
{ !data?.is_verified && data?.verified_twin_address_hash && (!data?.proxy_type || data.proxy_type === 'unknown') && (
<Alert status="warning" whiteSpace="pre-wrap" flexWrap="wrap">
<span>Contract is not verified. However, we found a verified contract with the same bytecode in Blockscout DB </span>
<AddressEntity
address={{ hash: data.verified_twin_address_hash, is_contract: true }}
truncation="constant"
fontSize="sm"
fontWeight="500"
/>
<chakra.span mt={ 1 }>All functions displayed below are from ABI of that contract. In order to verify current contract, proceed with </chakra.span>
<LinkInternal href={ route({ pathname: '/address/[hash]/contract-verification', query: { hash: addressHash } }) }>
Verify & Publish
</LinkInternal>
<span> page</span>
</Alert>
) }
{ data?.proxy_type && <ContractDetailsAlertProxyPattern type={ data.proxy_type }/> }
</Flex>
);
};
export default React.memo(ContractDetailsAlerts);
...@@ -9,7 +9,7 @@ import ContainerWithScrollY from 'ui/shared/ContainerWithScrollY'; ...@@ -9,7 +9,7 @@ import ContainerWithScrollY from 'ui/shared/ContainerWithScrollY';
import FormModal from 'ui/shared/FormModal'; import FormModal from 'ui/shared/FormModal';
import LinkExternal from 'ui/shared/links/LinkExternal'; import LinkExternal from 'ui/shared/links/LinkExternal';
import ContractSubmitAuditForm from './contractSubmitAuditForm/ContractSubmitAuditForm'; import ContractSubmitAuditForm from './ContractSubmitAuditForm';
type Props = { type Props = {
addressHash?: string; addressHash?: string;
......
import React from 'react';
import * as addressMock from 'mocks/address/address';
import { contractAudits } from 'mocks/contract/audits';
import * as contractMock from 'mocks/contract/info';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import ContractDetailsInfo from './ContractDetailsInfo';
test('with certified icon', async({ render }) => {
const props = {
data: contractMock.certified,
isLoading: false,
addressHash: addressMock.contract.hash,
};
const component = await render(<ContractDetailsInfo { ...props }/>);
await expect(component).toHaveScreenshot();
});
test('zkSync contract', async({ render, mockEnvs }) => {
await mockEnvs(ENVS_MAP.zkSyncRollup);
const props = {
data: contractMock.zkSync,
isLoading: false,
addressHash: addressMock.contract.hash,
};
const component = await render(<ContractDetailsInfo { ...props }/>);
await expect(component).toHaveScreenshot();
});
test.describe('with audits feature', () => {
test.beforeEach(async({ mockEnvs }) => {
await mockEnvs(ENVS_MAP.hasContractAuditReports);
});
test('no audits', async({ render, mockApiResponse }) => {
await mockApiResponse('contract_security_audits', { items: [] }, { pathParams: { hash: addressMock.contract.hash } });
const props = {
data: contractMock.verified,
isLoading: false,
addressHash: addressMock.contract.hash,
};
const component = await render(<ContractDetailsInfo { ...props }/>);
await expect(component).toHaveScreenshot();
});
test('has audits', async({ render, mockApiResponse }) => {
await mockApiResponse('contract_security_audits', contractAudits, { pathParams: { hash: addressMock.contract.hash } });
const props = {
data: contractMock.verified,
isLoading: false,
addressHash: addressMock.contract.hash,
};
const component = await render(<ContractDetailsInfo { ...props }/>);
await expect(component).toHaveScreenshot();
});
});
import { Flex, Grid } from '@chakra-ui/react';
import React from 'react';
import type { SmartContract } from 'types/api/contract';
import config from 'configs/app';
import { CONTRACT_LICENSES } from 'lib/contracts/licenses';
import dayjs from 'lib/date/dayjs';
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
import LinkExternal from 'ui/shared/links/LinkExternal';
import ContractSecurityAudits from '../audits/ContractSecurityAudits';
import ContractDetailsInfoItem from './ContractDetailsInfoItem';
const rollupFeature = config.features.rollup;
interface Props {
data: SmartContract;
isLoading: boolean;
addressHash: string;
}
const ContractDetailsInfo = ({ data, isLoading, addressHash }: Props) => {
const contractNameWithCertifiedIcon = data ? (
<Flex alignItems="center">
{ data.name }
{ data.certified && <ContractCertifiedLabel iconSize={ 5 } boxSize={ 5 } ml={ 2 }/> }
</Flex>
) : null;
const licenseLink = (() => {
if (!data?.license_type) {
return null;
}
const license = CONTRACT_LICENSES.find((license) => license.type === data.license_type);
if (!license || license.type === 'none') {
return null;
}
return (
<LinkExternal href={ license.url }>
{ license.label }
</LinkExternal>
);
})();
return (
<Grid templateColumns={{ base: '1fr', lg: '1fr 1fr' }} rowGap={ 4 } columnGap={ 6 } mb={ 8 }>
{ data.name && (
<ContractDetailsInfoItem
label="Contract name"
content={ contractNameWithCertifiedIcon }
isLoading={ isLoading }
/>
) }
{ data.compiler_version && (
<ContractDetailsInfoItem
label="Compiler version"
content={ data.compiler_version }
isLoading={ isLoading }
/>
) }
{ data.zk_compiler_version && (
<ContractDetailsInfoItem
label="ZK compiler version"
content={ data.zk_compiler_version }
isLoading={ isLoading }
/>
) }
{ data.evm_version && (
<ContractDetailsInfoItem
label="EVM version"
content={ data.evm_version }
textTransform="capitalize"
isLoading={ isLoading }
/>
) }
{ licenseLink && (
<ContractDetailsInfoItem
label="License"
content={ licenseLink }
hint="License type is entered manually during verification. The initial source code may contain a different license type than the one displayed."
isLoading={ isLoading }
/>
) }
{ typeof data.optimization_enabled === 'boolean' && (
<ContractDetailsInfoItem
label="Optimization enabled"
content={ data.optimization_enabled ? 'true' : 'false' }
isLoading={ isLoading }
/>
) }
{ data.optimization_runs !== null && (
<ContractDetailsInfoItem
label={ rollupFeature.isEnabled && rollupFeature.type === 'zkSync' ? 'Optimization mode' : 'Optimization runs' }
content={ String(data.optimization_runs) }
isLoading={ isLoading }
/>
) }
{ data.verified_at && (
<ContractDetailsInfoItem
label="Verified at"
content={ dayjs(data.verified_at).format('llll') }
wordBreak="break-word"
isLoading={ isLoading }
/>
) }
{ data.file_path && (
<ContractDetailsInfoItem
label="Contract file path"
content={ data.file_path }
wordBreak="break-word"
isLoading={ isLoading }
/>
) }
{ config.UI.hasContractAuditReports && (
<ContractDetailsInfoItem
label="Security audit"
content={ <ContractSecurityAudits addressHash={ addressHash }/> }
isLoading={ isLoading }
/>
) }
</Grid>
);
};
export default React.memo(ContractDetailsInfo);
import { chakra, useColorModeValue, Flex, GridItem, Skeleton } from '@chakra-ui/react';
import React from 'react';
import Hint from 'ui/shared/Hint';
interface Props {
label: string;
content: string | React.ReactNode;
className?: string;
isLoading: boolean;
hint?: string;
}
const ContractDetailsInfoItem = ({ label, content, className, isLoading, hint }: Props) => {
const hintIconColor = useColorModeValue('gray.600', 'gray.400');
return (
<GridItem display="flex" columnGap={ 6 } wordBreak="break-all" className={ className } alignItems="baseline">
<Skeleton isLoaded={ !isLoading } w="170px" flexShrink={ 0 } fontWeight={ 500 }>
<Flex alignItems="center">
{ label }
{ hint && (
<Hint
label={ hint }
ml={ 2 }
color={ hintIconColor }
tooltipProps={{ placement: 'bottom' }}
/>
) }
</Flex>
</Skeleton>
<Skeleton isLoaded={ !isLoading }>{ content }</Skeleton>
</GridItem>
);
};
export default React.memo(chakra(ContractDetailsInfoItem));
...@@ -7,10 +7,10 @@ import type { SmartContractMudSystemItem } from 'types/api/contract'; ...@@ -7,10 +7,10 @@ import type { SmartContractMudSystemItem } from 'types/api/contract';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import type { Item } from '../ContractSourceAddressSelector';
import ContractSourceAddressSelector from '../ContractSourceAddressSelector';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractMethods from './ContractMethods'; import ContractMethods from './ContractMethods';
import type { Item } from './ContractSourceAddressSelector';
import ContractSourceAddressSelector from './ContractSourceAddressSelector';
import { enrichWithMethodId, isMethod } from './utils'; import { enrichWithMethodId, isMethod } from './utils';
interface Props { interface Props {
...@@ -22,9 +22,9 @@ const ContractMethodsMudSystem = ({ items }: Props) => { ...@@ -22,9 +22,9 @@ const ContractMethodsMudSystem = ({ items }: Props) => {
const router = useRouter(); const router = useRouter();
const addressHash = getQueryParamString(router.query.hash); const addressHash = getQueryParamString(router.query.hash);
const contractAddress = getQueryParamString(router.query.source_address); const sourceAddress = getQueryParamString(router.query.source_address);
const [ selectedItem, setSelectedItem ] = React.useState(items.find((item) => item.address === contractAddress) || items[0]); const [ selectedItem, setSelectedItem ] = React.useState(items.find((item) => item.address === sourceAddress) || items[0]);
const systemInfoQuery = useApiQuery('contract_mud_system_info', { const systemInfoQuery = useApiQuery('contract_mud_system_info', {
pathParams: { hash: addressHash, system_address: selectedItem.address }, pathParams: { hash: addressHash, system_address: selectedItem.address },
...@@ -52,6 +52,7 @@ const ContractMethodsMudSystem = ({ items }: Props) => { ...@@ -52,6 +52,7 @@ const ContractMethodsMudSystem = ({ items }: Props) => {
selectedItem={ selectedItem } selectedItem={ selectedItem }
onItemSelect={ handleItemSelect } onItemSelect={ handleItemSelect }
label="System address" label="System address"
mb={ 6 }
/> />
<ContractMethods <ContractMethods
key={ selectedItem.address } key={ selectedItem.address }
......
...@@ -8,9 +8,9 @@ import type { AddressImplementation } from 'types/api/addressParams'; ...@@ -8,9 +8,9 @@ import type { AddressImplementation } from 'types/api/addressParams';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import ContractSourceAddressSelector from '../ContractSourceAddressSelector';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractMethods from './ContractMethods'; import ContractMethods from './ContractMethods';
import ContractSourceAddressSelector from './ContractSourceAddressSelector';
import { enrichWithMethodId, isReadMethod, isWriteMethod } from './utils'; import { enrichWithMethodId, isReadMethod, isWriteMethod } from './utils';
interface Props { interface Props {
...@@ -44,6 +44,7 @@ const ContractMethodsProxy = ({ type, implementations, isLoading: isInitialLoadi ...@@ -44,6 +44,7 @@ const ContractMethodsProxy = ({ type, implementations, isLoading: isInitialLoadi
onItemSelect={ setSelectedItem } onItemSelect={ setSelectedItem }
isLoading={ isInitialLoading } isLoading={ isInitialLoading }
label="Implementation address" label="Implementation address"
mb={ 6 }
/> />
<ContractMethods <ContractMethods
key={ selectedItem.address } key={ selectedItem.address }
......
import { Button, Tooltip } from '@chakra-ui/react';
import React from 'react';
import useAccount from 'lib/web3/useAccount';
interface Props {
onClick: (address: string) => void;
isDisabled?: boolean;
}
const ContractMethodAddressButton = ({ onClick, isDisabled }: Props) => {
const { address } = useAccount();
const handleClick = React.useCallback(() => {
address && onClick(address);
}, [ address, onClick ]);
return (
<Tooltip label={ !address ? 'Connect your wallet to enter your address.' : undefined }>
<Button
variant="subtle"
colorScheme="gray"
size="xs"
fontSize="normal"
fontWeight={ 500 }
ml={ 1 }
onClick={ handleClick }
isDisabled={ isDisabled || !address }
>
Self
</Button>
</Tooltip>
);
};
export default React.memo(ContractMethodAddressButton);
import { Box, Flex, FormControl, Input, InputGroup, InputRightElement, chakra, useColorModeValue } from '@chakra-ui/react'; import { Box, Button, Flex, FormControl, Input, InputGroup, InputRightElement, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { useController, useFormContext } from 'react-hook-form'; import { useController, useFormContext } from 'react-hook-form';
import { NumericFormat } from 'react-number-format'; import { NumericFormat } from 'react-number-format';
import type { ContractAbiItemInput } from '../types'; import type { ContractAbiItemInput } from '../types';
import { HOUR, SECOND } from 'lib/consts';
import ClearButton from 'ui/shared/ClearButton'; import ClearButton from 'ui/shared/ClearButton';
import ContractMethodAddressButton from './ContractMethodAddressButton';
import ContractMethodFieldLabel from './ContractMethodFieldLabel'; import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import ContractMethodMultiplyButton from './ContractMethodMultiplyButton'; import ContractMethodMultiplyButton from './ContractMethodMultiplyButton';
import useFormatFieldValue from './useFormatFieldValue'; import useFormatFieldValue from './useFormatFieldValue';
import useValidateField from './useValidateField'; import useValidateField from './useValidateField';
import { matchInt } from './utils'; import { matchInt } from './utils';
const TIMESTAMP_BUTTON_REGEXP = /time|deadline|expiration|expiry/i;
interface Props { interface Props {
data: ContractAbiItemInput; data: ContractAbiItemInput;
hideLabel?: boolean; hideLabel?: boolean;
...@@ -30,6 +34,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi ...@@ -30,6 +34,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
const isOptional = isOptionalProp || isNativeCoin; const isOptional = isOptionalProp || isNativeCoin;
const argTypeMatchInt = React.useMemo(() => matchInt(data.type), [ data.type ]); const argTypeMatchInt = React.useMemo(() => matchInt(data.type), [ data.type ]);
const hasTimestampButton = React.useMemo(() => TIMESTAMP_BUTTON_REGEXP.test(data.name || ''), [ data.name ]);
const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt }); const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt });
const format = useFormatFieldValue({ argType: data.type, argTypeMatchInt }); const format = useFormatFieldValue({ argType: data.type, argTypeMatchInt });
...@@ -56,9 +61,28 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi ...@@ -56,9 +61,28 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
const zeroes = Array(power).fill('0').join(''); const zeroes = Array(power).fill('0').join('');
const value = getValues(name); const value = getValues(name);
const newValue = format(value ? value + zeroes : '1' + zeroes); const newValue = format(value ? value + zeroes : '1' + zeroes);
setValue(name, newValue); setValue(name, newValue, { shouldValidate: true });
}, [ format, getValues, name, setValue ]); }, [ format, getValues, name, setValue ]);
const handleMaxIntButtonClick = React.useCallback(() => {
if (!argTypeMatchInt) {
return;
}
const newValue = format(argTypeMatchInt.max.toString());
setValue(name, newValue, { shouldValidate: true });
}, [ format, name, setValue, argTypeMatchInt ]);
const handleAddressButtonClick = React.useCallback((address: string) => {
const newValue = format(address);
setValue(name, newValue, { shouldValidate: true });
}, [ format, name, setValue ]);
const handleTimestampButtonClick = React.useCallback(() => {
const newValue = format(String(Math.floor((Date.now() + HOUR) / SECOND)));
setValue(name, newValue, { shouldValidate: true });
}, [ format, name, setValue ]);
const error = fieldState.error; const error = fieldState.error;
return ( return (
...@@ -90,11 +114,40 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi ...@@ -90,11 +114,40 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
isInvalid={ Boolean(error) } isInvalid={ Boolean(error) }
placeholder={ data.type } placeholder={ data.type }
autoComplete="off" autoComplete="off"
data-1p-ignore
bgColor={ inputBgColor } bgColor={ inputBgColor }
paddingRight={ hasMultiplyButton ? '120px' : '40px' } paddingRight={ hasMultiplyButton ? '120px' : '40px' }
/> />
<InputRightElement w="auto" right={ 1 }> <InputRightElement w="auto" right={ 1 } bgColor={ inputBgColor } h="calc(100% - 4px)" top="2px" borderRadius="base">
{ field.value !== undefined && field.value !== '' && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> } { field.value !== undefined && field.value !== '' && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
{ data.type === 'address' && <ContractMethodAddressButton onClick={ handleAddressButtonClick } isDisabled={ isDisabled }/> }
{ argTypeMatchInt && (hasTimestampButton ? (
<Button
variant="subtle"
colorScheme="gray"
size="xs"
fontSize="normal"
fontWeight={ 500 }
ml={ 1 }
onClick={ handleTimestampButtonClick }
isDisabled={ isDisabled }
>
Now+1h
</Button>
) : (
<Button
variant="subtle"
colorScheme="gray"
size="xs"
fontSize="normal"
fontWeight={ 500 }
ml={ 1 }
onClick={ handleMaxIntButtonClick }
isDisabled={ isDisabled }
>
Max
</Button>
)) }
{ hasMultiplyButton && <ContractMethodMultiplyButton onClick={ handleMultiplyButtonClick } isDisabled={ isDisabled }/> } { hasMultiplyButton && <ContractMethodMultiplyButton onClick={ handleMultiplyButtonClick } isDisabled={ isDisabled }/> }
</InputRightElement> </InputRightElement>
</InputGroup> </InputGroup>
......
...@@ -62,7 +62,7 @@ const data: SmartContractMethod = { ...@@ -62,7 +62,7 @@ const data: SmartContractMethod = {
// LITERALS // LITERALS
{ internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' }, { internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' },
{ internalType: 'address', name: 'recipient', type: 'address' }, { internalType: 'address', name: 'recipient', type: 'address' },
{ internalType: 'uint256', name: 'maximumFulfilled', type: 'uint256' }, { internalType: 'uint256', name: 'startTime', type: 'uint256' },
{ internalType: 'int8[]', name: 'criteriaProof', type: 'int8[]' }, { internalType: 'int8[]', name: 'criteriaProof', type: 'int8[]' },
], ],
method_id: '87201b41', method_id: '87201b41',
......
import { Box, Button, Flex, Tooltip, chakra } from '@chakra-ui/react'; import { Box, Button, Flex, Tooltip, chakra, useDisclosure } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import type { AbiFunction } from 'viem'; import { encodeFunctionData, type AbiFunction } from 'viem';
import type { FormSubmitHandler, FormSubmitResult, MethodCallStrategy, SmartContractMethod } from '../types'; import type { FormSubmitHandler, FormSubmitResult, MethodCallStrategy, SmartContractMethod } from '../types';
import config from 'configs/app'; import config from 'configs/app';
import { SECOND } from 'lib/consts';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
...@@ -43,17 +44,42 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props) ...@@ -43,17 +44,42 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props)
shouldUnregister: true, shouldUnregister: true,
}); });
const calldataButtonTooltip = useDisclosure();
const handleButtonClick = React.useCallback((event: React.MouseEvent) => { const handleButtonClick = React.useCallback((event: React.MouseEvent) => {
const callStrategy = event?.currentTarget.getAttribute('data-call-strategy'); const callStrategy = event?.currentTarget.getAttribute('data-call-strategy');
setCallStrategy(callStrategy as MethodCallStrategy); setCallStrategy(callStrategy as MethodCallStrategy);
callStrategyRef.current = callStrategy as MethodCallStrategy; callStrategyRef.current = callStrategy as MethodCallStrategy;
}, []);
if (callStrategy === 'copy_calldata') {
calldataButtonTooltip.onOpen();
window.setTimeout(() => {
calldataButtonTooltip.onClose();
}, SECOND);
}
}, [ calldataButtonTooltip ]);
const methodType = isReadMethod(data) ? 'read' : 'write'; const methodType = isReadMethod(data) ? 'read' : 'write';
const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => { const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => {
const args = transformFormDataToMethodArgs(formData); const args = transformFormDataToMethodArgs(formData);
if (callStrategyRef.current === 'copy_calldata') {
if (!('name' in data) || !data.name) {
return;
}
const callData = encodeFunctionData({
abi: [ data ],
functionName: data.name,
// since we have added additional input for native coin value
// we need to slice it off
args: args.slice(0, data.inputs.length),
});
await navigator.clipboard.writeText(callData);
return;
}
setResult(undefined); setResult(undefined);
setLoading(true); setLoading(true);
...@@ -166,6 +192,50 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props) ...@@ -166,6 +192,50 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props)
); );
})(); })();
const copyCallDataButton = (() => {
if (inputs.length === 0) {
return null;
}
if (inputs.length === 1) {
const [ input ] = inputs;
if ('fieldType' in input && input.fieldType === 'native_coin') {
return null;
}
}
const text = 'Copy calldata';
const buttonCallStrategy = 'copy_calldata';
const isDisabled = isLoading || !formApi.formState.isValid;
return (
<Tooltip
isDisabled={ isDisabled }
label="Copied"
closeDelay={ SECOND }
isOpen={ calldataButtonTooltip.isOpen }
onClose={ calldataButtonTooltip.onClose }
>
<Button
isLoading={ callStrategy === buttonCallStrategy && isLoading }
isDisabled={ isDisabled }
onClick={ handleButtonClick }
loadingText={ text }
variant="outline"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
ml={ 3 }
type="submit"
data-call-strategy={ buttonCallStrategy }
>
{ text }
</Button>
</Tooltip>
);
})();
return ( return (
<Box> <Box>
<FormProvider { ...formApi }> <FormProvider { ...formApi }>
...@@ -214,6 +284,7 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props) ...@@ -214,6 +284,7 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props)
</Flex> </Flex>
{ secondaryButton } { secondaryButton }
{ primaryButton } { primaryButton }
{ copyCallDataButton }
{ result && !isLoading && ( { result && !isLoading && (
<Button <Button
variant="simple" variant="simple"
...@@ -223,7 +294,7 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props) ...@@ -223,7 +294,7 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props)
ml={ 1 } ml={ 1 }
> >
<IconSvg name="repeat" boxSize={ 5 } mr={ 1 }/> <IconSvg name="repeat" boxSize={ 5 } mr={ 1 }/>
Reset Reset
</Button> </Button>
) } ) }
</chakra.form> </chakra.form>
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
ListItem, ListItem,
useDisclosure, useDisclosure,
Input, Input,
useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
...@@ -26,6 +27,8 @@ const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => { ...@@ -26,6 +27,8 @@ const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => {
const [ customValue, setCustomValue ] = React.useState<number>(); const [ customValue, setCustomValue ] = React.useState<number>();
const { isOpen, onToggle, onClose } = useDisclosure(); const { isOpen, onToggle, onClose } = useDisclosure();
const dividerColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const handleOptionClick = React.useCallback((event: React.MouseEvent) => { const handleOptionClick = React.useCallback((event: React.MouseEvent) => {
const id = Number((event.currentTarget as HTMLDivElement).getAttribute('data-id')); const id = Number((event.currentTarget as HTMLDivElement).getAttribute('data-id'));
if (!Object.is(id, NaN)) { if (!Object.is(id, NaN)) {
...@@ -60,6 +63,8 @@ const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => { ...@@ -60,6 +63,8 @@ const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => {
display="inline" display="inline"
onClick={ handleButtonClick } onClick={ handleButtonClick }
isDisabled={ isDisabled } isDisabled={ isDisabled }
borderBottomRightRadius={ 0 }
borderTopRightRadius={ 0 }
> >
{ times } { times }
<chakra.span>10</chakra.span> <chakra.span>10</chakra.span>
...@@ -73,11 +78,14 @@ const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => { ...@@ -73,11 +78,14 @@ const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => {
colorScheme="gray" colorScheme="gray"
size="xs" size="xs"
cursor="pointer" cursor="pointer"
ml={ 1 }
p={ 0 } p={ 0 }
onClick={ onToggle } onClick={ onToggle }
isActive={ isOpen } isActive={ isOpen }
isDisabled={ isDisabled } isDisabled={ isDisabled }
borderBottomLeftRadius={ 0 }
borderTopLeftRadius={ 0 }
borderLeftWidth="1px"
borderLeftColor={ dividerColor }
> >
<IconSvg <IconSvg
name="arrows/east-mini" name="arrows/east-mini"
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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