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 = {
remappings: [],
},
compiler_version: 'v0.8.7+commit.e28d00a7',
constructor_args: '0000000000000000000000005c7bcd6e7de5423a257d81b442095a1a6ced35c5000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
creation_bytecode: '0x6080604052348',
deployed_bytecode: '0x60806040',
evm_version: 'london',
......
......@@ -15,6 +15,7 @@ test.use({ viewport: { width: 150, height: 350 } });
{ variant: 'subtle', colorScheme: 'gray', states: [ 'default', 'hovered' ], withDarkMode: true },
{ variant: 'hero', states: [ 'default', 'hovered' ], withDarkMode: true },
{ variant: 'header', states: [ 'default', 'hovered', 'selected' ], withDarkMode: true },
{ variant: 'radio_group', states: [ 'default', 'hovered', 'selected' ], withDarkMode: true },
].forEach(({ variant, colorScheme, withDarkMode, states }) => {
test.describe(`variant ${ variant }${ colorScheme ? ` with ${ colorScheme } color scheme` : '' }${ withDarkMode ? ' +@dark-mode' : '' }`, () => {
test('', async({ render }) => {
......
......@@ -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 outline = runIfFn(variantOutline, props);
......@@ -223,6 +259,7 @@ const variants = {
subtle: variantSubtle,
hero: variantHero,
header: variantHeader,
radio_group: variantRadioGroup,
};
const baseStyle = defineStyle({
......
......@@ -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 = {
sm: definePartsStyle({
tab: Button.sizes?.sm,
......@@ -53,6 +80,7 @@ const sizes = {
const variants = {
'soft-rounded': variantSoftRounded,
outline: variantOutline,
radio_group: variantRadioGroup,
};
const Tabs = defineMultiStyleConfig({
......
......@@ -2,8 +2,8 @@ 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 useContractTabs from 'ui/address/contract/useContractTabs';
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 { route } from 'nextjs-routes';
......@@ -13,6 +13,7 @@ export interface Item {
}
interface Props {
className?: string;
label: string;
selectedItem: Item;
onItemSelect: (item: Item) => void;
......@@ -20,7 +21,7 @@ interface Props {
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 nextOption = items.find(({ address }) => address === event.target.value);
......@@ -30,7 +31,7 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo
}, [ items, onItemSelect ]);
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) {
......@@ -39,8 +40,8 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo
if (items.length === 1) {
return (
<Flex mb={ 6 } flexWrap="wrap" columnGap={ 3 } rowGap={ 2 }>
<span>{ label }</span>
<Flex flexWrap="wrap" columnGap={ 3 } rowGap={ 2 } className={ className }>
<chakra.span fontWeight={ 500 } fontSize="sm">{ label }</chakra.span>
<AddressEntity
address={{ hash: items[0].address, is_contract: true, is_verified: true }}
/>
......@@ -49,8 +50,8 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo
}
return (
<Flex mb={ 6 } flexWrap="wrap" columnGap={ 3 } rowGap={ 2 } alignItems="center">
<span>{ label }</span>
<Flex columnGap={ 3 } rowGap={ 2 } alignItems="center" className={ className }>
<chakra.span fontWeight={ 500 } fontSize="sm">{ label }</chakra.span>
<Select
size="xs"
value={ selectedItem.address }
......@@ -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 type { AddressImplementation } from 'types/api/addressParams';
import type { SmartContract } from 'types/api/contract';
import { route } from 'nextjs-routes';
import useApiQuery from 'lib/api/useApiQuery';
import * as stubs from 'stubs/contract';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import LinkInternal from 'ui/shared/links/LinkInternal';
import CodeEditor from 'ui/shared/monaco/CodeEditor';
......@@ -38,82 +35,34 @@ function getEditorData(contractInfo: SmartContract | undefined) {
];
}
interface SourceContractOption {
address: string;
label: string;
}
interface Props {
address: string;
implementations?: Array<AddressImplementation>;
data: SmartContract | undefined;
sourceAddress: string;
isLoading?: boolean;
}
export const ContractSourceCode = ({ address, implementations }: 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,
},
});
export const ContractSourceCode = ({ data, isLoading, sourceAddress }: Props) => {
const editorData = React.useMemo(() => {
return getEditorData(contractQuery.data);
}, [ contractQuery.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 ]);
return getEditorData(data);
}, [ data ]);
const heading = (
<Skeleton isLoaded={ !isLoading } fontWeight={ 500 }>
<span>Contract source code</span>
{ contractQuery.data?.language &&
<Text whiteSpace="pre" as="span" variant="secondary" textTransform="capitalize"> ({ contractQuery.data.language })</Text> }
{ data?.language &&
<Text whiteSpace="pre" as="span" variant="secondary" textTransform="capitalize"> ({ data.language })</Text> }
</Skeleton>
);
const select = options.length > 1 ? (
<Select
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 }/> :
const externalLibraries = data?.external_libraries ?
<ContractExternalLibraries data={ data.external_libraries } isLoading={ isLoading }/> :
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">
<LinkInternal
href={ route({ pathname: '/visualize/sol2uml', query: { address: sourceContract.address } }) }
href={ route({ pathname: '/visualize/sol2uml', query: { address: sourceAddress } }) }
ml={{ base: '0', lg: 'auto' }}
isLoading={ isLoading }
>
......@@ -124,11 +73,11 @@ export const ContractSourceCode = ({ address, implementations }: Props) => {
</Tooltip>
) : 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
text={ contractQuery.data.source_code }
text={ data.source_code }
isLoading={ isLoading }
ml={{ base: 'auto', lg: diagramLink ? '0' : 'auto' }}
/>
......@@ -146,13 +95,13 @@ export const ContractSourceCode = ({ address, implementations }: Props) => {
return (
<CodeEditor
key={ sourceContract.address }
key={ sourceAddress }
data={ editorData }
remappings={ contractQuery.data?.compiler_settings?.remappings }
libraries={ contractQuery.data?.external_libraries ?? undefined }
language={ contractQuery.data?.language ?? undefined }
remappings={ data?.compiler_settings?.remappings }
libraries={ data?.external_libraries ?? undefined }
language={ data?.language ?? undefined }
mainFile={ editorData[0]?.file_path }
contractName={ contractQuery.data?.name ?? undefined }
contractName={ data?.name ?? undefined }
/>
);
})();
......@@ -161,7 +110,6 @@ export const ContractSourceCode = ({ address, implementations }: Props) => {
<section>
<Flex alignItems="center" mb={ 3 } columnGap={ 3 } rowGap={ 2 } flexWrap="wrap">
{ heading }
{ select }
{ externalLibraries }
{ diagramLink }
{ ides }
......
......@@ -2,19 +2,19 @@ import React from 'react';
import { test, expect } from 'playwright/lib';
import ContractCodeProxyPattern from './ContractCodeProxyPattern';
import ContractDetailsAlertProxyPattern from './ContractDetailsAlertProxyPattern';
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();
});
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();
});
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();
});
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';
import FormModal from 'ui/shared/FormModal';
import LinkExternal from 'ui/shared/links/LinkExternal';
import ContractSubmitAuditForm from './contractSubmitAuditForm/ContractSubmitAuditForm';
import ContractSubmitAuditForm from './ContractSubmitAuditForm';
type Props = {
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';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import type { Item } from '../ContractSourceAddressSelector';
import ContractSourceAddressSelector from '../ContractSourceAddressSelector';
import ContractConnectWallet from './ContractConnectWallet';
import ContractMethods from './ContractMethods';
import type { Item } from './ContractSourceAddressSelector';
import ContractSourceAddressSelector from './ContractSourceAddressSelector';
import { enrichWithMethodId, isMethod } from './utils';
interface Props {
......@@ -22,9 +22,9 @@ const ContractMethodsMudSystem = ({ items }: Props) => {
const router = useRouter();
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', {
pathParams: { hash: addressHash, system_address: selectedItem.address },
......@@ -52,6 +52,7 @@ const ContractMethodsMudSystem = ({ items }: Props) => {
selectedItem={ selectedItem }
onItemSelect={ handleItemSelect }
label="System address"
mb={ 6 }
/>
<ContractMethods
key={ selectedItem.address }
......
......@@ -8,9 +8,9 @@ import type { AddressImplementation } from 'types/api/addressParams';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractSourceAddressSelector from '../ContractSourceAddressSelector';
import ContractConnectWallet from './ContractConnectWallet';
import ContractMethods from './ContractMethods';
import ContractSourceAddressSelector from './ContractSourceAddressSelector';
import { enrichWithMethodId, isReadMethod, isWriteMethod } from './utils';
interface Props {
......@@ -44,6 +44,7 @@ const ContractMethodsProxy = ({ type, implementations, isLoading: isInitialLoadi
onItemSelect={ setSelectedItem }
isLoading={ isInitialLoading }
label="Implementation address"
mb={ 6 }
/>
<ContractMethods
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 { useController, useFormContext } from 'react-hook-form';
import { NumericFormat } from 'react-number-format';
import type { ContractAbiItemInput } from '../types';
import { HOUR, SECOND } from 'lib/consts';
import ClearButton from 'ui/shared/ClearButton';
import ContractMethodAddressButton from './ContractMethodAddressButton';
import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import ContractMethodMultiplyButton from './ContractMethodMultiplyButton';
import useFormatFieldValue from './useFormatFieldValue';
import useValidateField from './useValidateField';
import { matchInt } from './utils';
const TIMESTAMP_BUTTON_REGEXP = /time|deadline|expiration|expiry/i;
interface Props {
data: ContractAbiItemInput;
hideLabel?: boolean;
......@@ -30,6 +34,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
const isOptional = isOptionalProp || isNativeCoin;
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 format = useFormatFieldValue({ argType: data.type, argTypeMatchInt });
......@@ -56,9 +61,28 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
const zeroes = Array(power).fill('0').join('');
const value = getValues(name);
const newValue = format(value ? value + zeroes : '1' + zeroes);
setValue(name, newValue);
setValue(name, newValue, { shouldValidate: true });
}, [ 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;
return (
......@@ -90,11 +114,40 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
isInvalid={ Boolean(error) }
placeholder={ data.type }
autoComplete="off"
data-1p-ignore
bgColor={ inputBgColor }
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 }/> }
{ 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 }/> }
</InputRightElement>
</InputGroup>
......
......@@ -62,7 +62,7 @@ const data: SmartContractMethod = {
// LITERALS
{ internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' },
{ internalType: 'address', name: 'recipient', type: 'address' },
{ internalType: 'uint256', name: 'maximumFulfilled', type: 'uint256' },
{ internalType: 'uint256', name: 'startTime', type: 'uint256' },
{ internalType: 'int8[]', name: 'criteriaProof', type: 'int8[]' },
],
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 type { SubmitHandler } 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 config from 'configs/app';
import { SECOND } from 'lib/consts';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
......@@ -43,17 +44,42 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props)
shouldUnregister: true,
});
const calldataButtonTooltip = useDisclosure();
const handleButtonClick = React.useCallback((event: React.MouseEvent) => {
const callStrategy = event?.currentTarget.getAttribute('data-call-strategy');
setCallStrategy(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 onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(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);
setLoading(true);
......@@ -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 (
<Box>
<FormProvider { ...formApi }>
......@@ -214,6 +284,7 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props)
</Flex>
{ secondaryButton }
{ primaryButton }
{ copyCallDataButton }
{ result && !isLoading && (
<Button
variant="simple"
......@@ -223,7 +294,7 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props)
ml={ 1 }
>
<IconSvg name="repeat" boxSize={ 5 } mr={ 1 }/>
Reset
Reset
</Button>
) }
</chakra.form>
......
......@@ -9,6 +9,7 @@ import {
ListItem,
useDisclosure,
Input,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
......@@ -26,6 +27,8 @@ const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => {
const [ customValue, setCustomValue ] = React.useState<number>();
const { isOpen, onToggle, onClose } = useDisclosure();
const dividerColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const handleOptionClick = React.useCallback((event: React.MouseEvent) => {
const id = Number((event.currentTarget as HTMLDivElement).getAttribute('data-id'));
if (!Object.is(id, NaN)) {
......@@ -60,6 +63,8 @@ const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => {
display="inline"
onClick={ handleButtonClick }
isDisabled={ isDisabled }
borderBottomRightRadius={ 0 }
borderTopRightRadius={ 0 }
>
{ times }
<chakra.span>10</chakra.span>
......@@ -73,11 +78,14 @@ const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => {
colorScheme="gray"
size="xs"
cursor="pointer"
ml={ 1 }
p={ 0 }
onClick={ onToggle }
isActive={ isOpen }
isDisabled={ isDisabled }
borderBottomLeftRadius={ 0 }
borderTopLeftRadius={ 0 }
borderLeftWidth="1px"
borderLeftColor={ dividerColor }
>
<IconSvg
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