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';
......
import { Flex, Skeleton, Button, Grid, GridItem, Alert, chakra, Box, useColorModeValue } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
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 { SmartContract } from 'types/api/contract';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import { getResourceKey } from 'lib/api/useApiQuery';
import { CONTRACT_LICENSES } from 'lib/contracts/licenses';
import dayjs from 'lib/date/dayjs';
import useSocketMessage from 'lib/socket/useSocketMessage';
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import Hint from 'ui/shared/Hint';
import LinkExternal from 'ui/shared/links/LinkExternal';
import LinkInternal from 'ui/shared/links/LinkInternal';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
import ContractCodeProxyPattern from './ContractCodeProxyPattern';
import ContractSecurityAudits from './ContractSecurityAudits';
import ContractSourceCode from './ContractSourceCode';
type Props = {
addressHash?: string;
contractQuery: UseQueryResult<SmartContract, ResourceError<unknown>>;
channel: Channel | undefined;
}
type InfoItemProps = {
label: string;
content: string | React.ReactNode;
className?: string;
isLoading: boolean;
hint?: string;
}
const InfoItem = chakra(({ label, content, hint, className, isLoading }: InfoItemProps) => (
<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={ useColorModeValue('gray.600', 'gray.400') }
tooltipProps={{ placement: 'bottom' }}
/>
) }
</Flex>
</Skeleton>
<Skeleton isLoaded={ !isLoading }>{ content }</Skeleton>
</GridItem>
));
const rollupFeature = config.features.rollup;
const ContractCode = ({ addressHash, contractQuery, channel }: Props) => {
const [ isChangedBytecodeSocket, setIsChangedBytecodeSocket ] = React.useState<boolean>();
const queryClient = useQueryClient();
const addressInfo = queryClient.getQueryData<AddressInfo>(getResourceKey('address', { pathParams: { hash: addressHash } }));
const { data, isPlaceholderData, isError } = contractQuery;
const handleChangedBytecodeMessage: SocketMessage.AddressChangedBytecode['handler'] = React.useCallback(() => {
setIsChangedBytecodeSocket(true);
}, [ ]);
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: 'changed_bytecode',
handler: handleChangedBytecodeMessage,
});
useSocketMessage({
channel,
event: 'smart_contract_was_verified',
handler: handleContractWasVerifiedMessage,
});
if (isError) {
return <DataFetchAlert/>;
}
const canBeVerified = !data?.is_self_destructed && !data?.is_verified;
const verificationButton = isPlaceholderData ? (
<Skeleton
w="130px"
h={ 8 }
mr={ data?.is_partially_verified ? 0 : 3 }
ml={ data?.is_partially_verified ? 0 : 'auto' }
borderRadius="base"
flexShrink={ 0 }
/>
) : (
<Button
size="sm"
mr={ data?.is_partially_verified ? 0 : 3 }
ml={ data?.is_partially_verified ? 0 : 'auto' }
flexShrink={ 0 }
as="a"
href={ route({ pathname: '/address/[hash]/contract-verification', query: { hash: addressHash || '' } }) }
>
Verify & publish
</Button>
);
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>
);
})();
const constructorArgs = (() => {
if (!data?.decoded_constructor_args) {
return data?.constructor_args;
}
const decoded = data.decoded_constructor_args
.map(([ value, { name, type } ], index) => {
const valueEl = type === 'address' ? (
<AddressEntity
address={{ hash: value }}
noIcon
display="inline-flex"
maxW="100%"
/>
) : <span>{ value }</span>;
return (
<Box key={ index }>
<span>Arg [{ index }] { name || '' } ({ type }): </span>
{ valueEl }
</Box>
);
});
return (
<>
<span>{ data.constructor_args }</span>
<br/><br/>
{ decoded }
</>
);
})();
const verificationAlert = (() => {
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;
})();
const contractNameWithCertifiedIcon = data?.is_verified ? (
<Flex alignItems="center">
{ data.name }
{ data.certified && <ContractCertifiedLabel iconSize={ 5 } boxSize={ 5 } ml={ 2 }/> }
</Flex>
) : null;
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={ !isPlaceholderData }>
<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 ? verificationButton : null }
</Alert>
</Skeleton>
) }
{ verificationAlert }
{ (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 && <ContractCodeProxyPattern type={ data.proxy_type }/> }
</Flex>
{ data?.is_verified && (
<Grid templateColumns={{ base: '1fr', lg: '1fr 1fr' }} rowGap={ 4 } columnGap={ 6 } mb={ 8 }>
{ data.name && <InfoItem label="Contract name" content={ contractNameWithCertifiedIcon } isLoading={ isPlaceholderData }/> }
{ data.compiler_version && <InfoItem label="Compiler version" content={ data.compiler_version } isLoading={ isPlaceholderData }/> }
{ data.zk_compiler_version && <InfoItem label="ZK compiler version" content={ data.zk_compiler_version } isLoading={ isPlaceholderData }/> }
{ data.evm_version && <InfoItem label="EVM version" content={ data.evm_version } textTransform="capitalize" isLoading={ isPlaceholderData }/> }
{ licenseLink && (
<InfoItem
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={ isPlaceholderData }
/>
) }
{ typeof data.optimization_enabled === 'boolean' &&
<InfoItem label="Optimization enabled" content={ data.optimization_enabled ? 'true' : 'false' } isLoading={ isPlaceholderData }/> }
{ data.optimization_runs !== null && (
<InfoItem
label={ rollupFeature.isEnabled && rollupFeature.type === 'zkSync' ? 'Optimization mode' : 'Optimization runs' }
content={ String(data.optimization_runs) }
isLoading={ isPlaceholderData }
/>
) }
{ data.verified_at &&
<InfoItem label="Verified at" content={ dayjs(data.verified_at).format('llll') } wordBreak="break-word" isLoading={ isPlaceholderData }/> }
{ data.file_path && <InfoItem label="Contract file path" content={ data.file_path } wordBreak="break-word" isLoading={ isPlaceholderData }/> }
{ config.UI.hasContractAuditReports && (
<InfoItem
label="Security audit"
content={ <ContractSecurityAudits addressHash={ addressHash }/> }
isLoading={ isPlaceholderData }
/>
) }
</Grid>
) }
<Flex flexDir="column" rowGap={ 6 }>
{ constructorArgs && (
<RawDataSnippet
data={ constructorArgs }
title="Constructor Arguments"
textareaMaxHeight="200px"
isLoading={ isPlaceholderData }
/>
) }
{ data?.source_code && addressHash && (
<ContractSourceCode
address={ addressHash }
implementations={ addressInfo?.implementations || undefined }
/>
) }
{ data?.compiler_settings ? (
<RawDataSnippet
data={ JSON.stringify(data.compiler_settings, undefined, 4) }
title="Compiler Settings"
textareaMaxHeight="200px"
isLoading={ isPlaceholderData }
/>
) : null }
{ data?.abi && (
<RawDataSnippet
data={ JSON.stringify(data.abi, undefined, 4) }
title="Contract ABI"
textareaMaxHeight="200px"
isLoading={ isPlaceholderData }
/>
) }
{ data?.creation_bytecode && (
<RawDataSnippet
data={ data.creation_bytecode }
title="Contract creation code"
rightSlot={ canBeVerified ? verificationButton : null }
beforeSlot={ data.is_self_destructed ? (
<Alert status="info" whiteSpace="pre-wrap" mb={ 3 }>
Contracts that self destruct in their constructors have no contract code published and cannot be verified.
Displaying the init data provided of the creating transaction.
</Alert>
) : null }
textareaMaxHeight="200px"
isLoading={ isPlaceholderData }
/>
) }
{ data?.deployed_bytecode && (
<RawDataSnippet
data={ data.deployed_bytecode }
title="Deployed ByteCode"
rightSlot={ !data?.creation_bytecode && canBeVerified ? verificationButton : null }
textareaMaxHeight="200px"
isLoading={ isPlaceholderData }
/>
) }
</Flex>
</>
);
};
export default ContractCode;
import React from 'react'; import React from 'react';
import * as addressMock from 'mocks/address/address'; import * as addressMock from 'mocks/address/address';
import { contractAudits } from 'mocks/contract/audits';
import * as contractMock from 'mocks/contract/info'; import * as contractMock from 'mocks/contract/info';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import * as socketServer from 'playwright/fixtures/socketServer'; import * as socketServer from 'playwright/fixtures/socketServer';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config';
import ContractCode from './specs/ContractCode'; import ContractDetails from './specs/ContractDetails';
const hooksConfig = { const hooksConfig = {
router: { router: {
...@@ -28,30 +27,72 @@ test.beforeEach(async({ mockApiResponse, page }) => { ...@@ -28,30 +27,72 @@ test.beforeEach(async({ mockApiResponse, page }) => {
addressApiUrl = await mockApiResponse('address', addressMock.contract, { pathParams: { hash: addressMock.contract.hash } }); addressApiUrl = await mockApiResponse('address', addressMock.contract, { pathParams: { hash: addressMock.contract.hash } });
}); });
test('full view +@mobile +@dark-mode', async({ render, mockApiResponse, createSocket }) => { test.describe('full view', () => {
await mockApiResponse('contract', contractMock.withChangedByteCode, { pathParams: { hash: addressMock.contract.hash } }); test.beforeEach(async({ mockApiResponse }) => {
await mockApiResponse('contract', contractMock.withChangedByteCode, { pathParams: { hash: addressMock.contract.implementations?.[0].address as string } }); await mockApiResponse('contract', contractMock.withChangedByteCode, { pathParams: { hash: addressMock.contract.hash } });
await mockApiResponse('contract', contractMock.withChangedByteCode, { pathParams: { hash: addressMock.contract.implementations?.[0].address as string } });
});
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true }); test('source code +@dark-mode', async({ render, createSocket }) => {
await createSocket(); const hooksConfig = {
router: {
query: { hash: addressMock.contract.hash, tab: 'contract_source_code' },
},
};
const component = await render(<ContractDetails/>, { hooksConfig }, { withSocket: true });
await createSocket();
await expect(component).toHaveScreenshot();
});
await expect(component).toHaveScreenshot(); test('compiler', async({ render, createSocket }) => {
}); const hooksConfig = {
router: {
query: { hash: addressMock.contract.hash, tab: 'contract_compiler' },
},
};
const component = await render(<ContractDetails/>, { hooksConfig }, { withSocket: true });
await createSocket();
await expect(component).toHaveScreenshot();
});
test('verified with changed byte code socket', async({ render, mockApiResponse, createSocket }) => { test('abi', async({ render, createSocket }) => {
await mockApiResponse('contract', contractMock.verified, { pathParams: { hash: addressMock.contract.hash } }); const hooksConfig = {
router: {
query: { hash: addressMock.contract.hash, tab: 'contract_abi' },
},
};
const component = await render(<ContractDetails/>, { hooksConfig }, { withSocket: true });
await createSocket();
await expect(component).toHaveScreenshot();
});
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true }); test('bytecode', async({ render, createSocket }) => {
const socket = await createSocket(); const hooksConfig = {
const channel = await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase()); router: {
socketServer.sendMessage(socket, channel, 'changed_bytecode', {}); query: { hash: addressMock.contract.hash, tab: 'contract_bytecode' },
},
};
const component = await render(<ContractDetails/>, { hooksConfig }, { withSocket: true });
await createSocket();
await expect(component).toHaveScreenshot();
});
});
await expect(component).toHaveScreenshot(); test.describe('mobile view', () => {
test.use({ viewport: pwConfig.viewport.mobile });
test('source code', async({ render, createSocket, mockApiResponse }) => {
await mockApiResponse('contract', contractMock.withChangedByteCode, { pathParams: { hash: addressMock.contract.hash } });
await mockApiResponse('contract', contractMock.withChangedByteCode, { pathParams: { hash: addressMock.contract.implementations?.[0].address as string } });
const component = await render(<ContractDetails/>, { hooksConfig }, { withSocket: true });
await createSocket();
await expect(component).toHaveScreenshot();
});
}); });
test('verified via lookup in eth_bytecode_db', async({ render, mockApiResponse, createSocket, page }) => { test('verified via lookup in eth_bytecode_db', async({ render, mockApiResponse, createSocket, page }) => {
const contractApiUrl = await mockApiResponse('contract', contractMock.nonVerified, { pathParams: { hash: addressMock.contract.hash } }); const contractApiUrl = await mockApiResponse('contract', contractMock.nonVerified, { pathParams: { hash: addressMock.contract.hash } });
await render(<ContractCode/>, { hooksConfig }, { withSocket: true }); await render(<ContractDetails/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket(); const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase()); const channel = await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
...@@ -64,7 +105,7 @@ test('verified via lookup in eth_bytecode_db', async({ render, mockApiResponse, ...@@ -64,7 +105,7 @@ test('verified via lookup in eth_bytecode_db', async({ render, mockApiResponse,
test('verified with multiple sources', async({ render, page, mockApiResponse }) => { test('verified with multiple sources', async({ render, page, mockApiResponse }) => {
await mockApiResponse('contract', contractMock.withMultiplePaths, { pathParams: { hash: addressMock.contract.hash } }); await mockApiResponse('contract', contractMock.withMultiplePaths, { pathParams: { hash: addressMock.contract.hash } });
await render(<ContractCode/>, { hooksConfig }, { withSocket: true }); await render(<ContractDetails/>, { hooksConfig }, { withSocket: true });
const section = page.locator('section', { hasText: 'Contract source code' }); const section = page.locator('section', { hasText: 'Contract source code' });
await expect(section).toHaveScreenshot(); await expect(section).toHaveScreenshot();
...@@ -76,83 +117,55 @@ test('verified with multiple sources', async({ render, page, mockApiResponse }) ...@@ -76,83 +117,55 @@ test('verified with multiple sources', async({ render, page, mockApiResponse })
await expect(section).toHaveScreenshot(); await expect(section).toHaveScreenshot();
}); });
test('verified via sourcify', async({ render, mockApiResponse, page }) => {
await mockApiResponse('contract', contractMock.verifiedViaSourcify, { pathParams: { hash: addressMock.contract.hash } });
await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 110 } });
});
test('verified via eth bytecode db', async({ render, mockApiResponse, page }) => {
await mockApiResponse('contract', contractMock.verifiedViaEthBytecodeDb, { pathParams: { hash: addressMock.contract.hash } });
await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 110 } });
});
test('self destructed', async({ render, mockApiResponse, page }) => { test('self destructed', async({ render, mockApiResponse, page }) => {
const hooksConfig = {
router: {
query: { hash: addressMock.contract.hash, tab: 'contract_bytecode' },
},
};
await mockApiResponse('contract', contractMock.selfDestructed, { pathParams: { hash: addressMock.contract.hash } }); await mockApiResponse('contract', contractMock.selfDestructed, { pathParams: { hash: addressMock.contract.hash } });
await render(<ContractCode/>, { hooksConfig }, { withSocket: true }); await render(<ContractDetails/>, { hooksConfig }, { withSocket: true });
const section = page.locator('section', { hasText: 'Contract creation code' }); const section = page.locator('section', { hasText: 'Contract creation code' });
await expect(section).toHaveScreenshot(); await expect(section).toHaveScreenshot();
}); });
test('with twin address alert +@mobile', async({ render, mockApiResponse }) => {
await mockApiResponse('contract', contractMock.withTwinAddress, { pathParams: { hash: addressMock.contract.hash } });
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
await expect(component.getByRole('alert')).toHaveScreenshot();
});
test('with proxy address alert +@mobile', async({ render, mockApiResponse }) => {
await mockApiResponse('contract', contractMock.withProxyAddress, { pathParams: { hash: addressMock.contract.hash } });
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
await expect(component.getByRole('alert')).toHaveScreenshot();
});
test('with certified icon +@mobile', async({ render, mockApiResponse, page }) => {
await mockApiResponse('contract', contractMock.certified, { pathParams: { hash: addressMock.contract.hash } });
await render(<ContractCode/>, { hooksConfig });
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 120 } });
});
test('non verified', async({ render, mockApiResponse }) => { test('non verified', async({ render, mockApiResponse }) => {
await mockApiResponse('contract', contractMock.nonVerified, { pathParams: { hash: addressMock.contract.hash } }); await mockApiResponse('contract', contractMock.nonVerified, { pathParams: { hash: addressMock.contract.hash } });
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true }); const component = await render(<ContractDetails/>, { hooksConfig }, { withSocket: true });
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('zkSync contract', async({ render, mockApiResponse, page, mockEnvs }) => { test('implementation info', async({ render, mockApiResponse }) => {
await mockEnvs(ENVS_MAP.zkSyncRollup); const hooksConfig = {
await mockApiResponse('contract', contractMock.zkSync, { pathParams: { hash: addressMock.contract.hash } }); router: {
await render(<ContractCode/>, { hooksConfig }, { withSocket: true }); query: { hash: addressMock.contract.hash, tab: 'contract_compiler' },
},
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); };
});
const implementationName = addressMock.contract.implementations?.[0].name as string;
test.describe('with audits feature', () => { const implementationAddress = addressMock.contract.implementations?.[0].address as string;
const implementationContract = {
test.beforeEach(async({ mockEnvs }) => { ...contractMock.verified,
await mockEnvs(ENVS_MAP.hasContractAuditReports); compiler_settings: {
}); evmVersion: 'london',
libraries: {},
test('no audits', async({ render, mockApiResponse }) => { metadata: {
await mockApiResponse('contract', contractMock.verified, { pathParams: { hash: addressMock.contract.hash } }); bytecodeHash: 'ipfs',
await mockApiResponse('contract_security_audits', { items: [] }, { pathParams: { hash: addressMock.contract.hash } }); useLiteralContent: false,
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true }); },
optimizer: {
await expect(component).toHaveScreenshot(); enabled: true,
}); runs: 1000000,
},
},
};
await mockApiResponse('contract', contractMock.verified, { pathParams: { hash: addressMock.contract.hash } });
await mockApiResponse('contract', implementationContract, { pathParams: { hash: implementationAddress } });
test('has audits', async({ render, mockApiResponse }) => { const component = await render(<ContractDetails/>, { hooksConfig }, { withSocket: true });
await mockApiResponse('contract', contractMock.verified, { pathParams: { hash: addressMock.contract.hash } }); await component.getByRole('combobox').selectOption(implementationName);
await mockApiResponse('contract_security_audits', contractAudits, { pathParams: { hash: addressMock.contract.hash } });
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
});
}); });
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"
......
...@@ -3,7 +3,7 @@ import type { AbiFunction, AbiFallback, AbiReceive } from 'abitype'; ...@@ -3,7 +3,7 @@ import type { AbiFunction, AbiFallback, AbiReceive } from 'abitype';
export type ContractAbiItemInput = AbiFunction['inputs'][number] & { fieldType?: 'native_coin' }; export type ContractAbiItemInput = AbiFunction['inputs'][number] & { fieldType?: 'native_coin' };
export type MethodType = 'read' | 'write' | 'all'; export type MethodType = 'read' | 'write' | 'all';
export type MethodCallStrategy = 'read' | 'write' | 'simulate'; export type MethodCallStrategy = 'read' | 'write' | 'simulate' | 'copy_calldata';
export type ResultViewMode = 'preview' | 'result'; export type ResultViewMode = 'preview' | 'result';
export type SmartContractMethodCustomFields = { method_id: string } | { is_invalid: boolean }; export type SmartContractMethodCustomFields = { method_id: string } | { is_invalid: boolean };
......
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
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';
const ContractCode = () => { import useContractTabs from '../useContractTabs';
const ContractDetails = () => {
const router = useRouter(); const router = useRouter();
const hash = getQueryParamString(router.query.hash); const hash = getQueryParamString(router.query.hash);
const addressQuery = useApiQuery('address', { pathParams: { hash } }); const addressQuery = useApiQuery('address', { pathParams: { hash } });
...@@ -13,4 +14,4 @@ const ContractCode = () => { ...@@ -13,4 +14,4 @@ const ContractCode = () => {
return content ?? null; return content ?? null;
}; };
export default ContractCode; export default ContractDetails;
import { Alert, Box, Flex } from '@chakra-ui/react';
import React from 'react';
import type { SmartContract } from 'types/api/contract';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
import ContractDetailsVerificationButton from './ContractDetailsVerificationButton';
import ContractSourceCode from './ContractSourceCode';
import type { CONTRACT_DETAILS_TAB_IDS } from './utils';
interface Tab {
id: typeof CONTRACT_DETAILS_TAB_IDS[number];
title: string;
component: React.ReactNode;
}
interface Props {
data: SmartContract | undefined;
isLoading: boolean;
addressHash: string;
sourceAddress: string;
}
export default function useContractDetailsTabs({ data, isLoading, addressHash, sourceAddress }: Props): Array<Tab> {
const constructorArgs = React.useMemo(() => {
if (!data?.decoded_constructor_args) {
return data?.constructor_args;
}
const decoded = data.decoded_constructor_args
.map(([ value, { name, type } ], index) => {
const valueEl = type === 'address' ? (
<AddressEntity
address={{ hash: value }}
noIcon
display="inline-flex"
maxW="100%"
/>
) : <span>{ value }</span>;
return (
<Box key={ index }>
<span>Arg [{ index }] { name || '' } ({ type }): </span>
{ valueEl }
</Box>
);
});
return (
<>
<span>{ data.constructor_args }</span>
<br/><br/>
{ decoded }
</>
);
}, [ data?.decoded_constructor_args, data?.constructor_args ]);
const canBeVerified = !data?.is_self_destructed && !data?.is_verified;
return React.useMemo(() => {
const verificationButton = (
<ContractDetailsVerificationButton
isLoading={ isLoading }
addressHash={ addressHash }
isPartiallyVerified={ Boolean(data?.is_partially_verified) }
/>
);
return [
(constructorArgs || data?.source_code) ? {
id: 'contract_source_code' as const,
title: 'Code',
component: (
<Flex flexDir="column" rowGap={ 6 }>
{ constructorArgs && (
<RawDataSnippet
data={ constructorArgs }
title="Constructor Arguments"
textareaMaxHeight="200px"
isLoading={ isLoading }
/>
) }
{ data?.source_code && (
<ContractSourceCode
data={ data }
isLoading={ isLoading }
sourceAddress={ sourceAddress }
/>
) }
</Flex>
),
} : undefined,
data?.compiler_settings ? {
id: 'contract_compiler' as const,
title: 'Compiler',
component: (
<RawDataSnippet
data={ JSON.stringify(data.compiler_settings, undefined, 4) }
title="Compiler Settings"
textareaMaxHeight="600px"
isLoading={ isLoading }
/>
),
} : undefined,
data?.abi ? {
id: 'contract_abi' as const,
title: 'ABI',
component: (
<RawDataSnippet
data={ JSON.stringify(data.abi, undefined, 4) }
title="Contract ABI"
textareaMaxHeight="600px"
isLoading={ isLoading }
/>
),
} : undefined,
(data?.creation_bytecode || data?.deployed_bytecode) ? {
id: 'contract_bytecode' as const,
title: 'ByteCode',
component: (
<Flex flexDir="column" rowGap={ 6 }>
{ data?.creation_bytecode && (
<RawDataSnippet
data={ data.creation_bytecode }
title="Contract creation code"
rightSlot={ canBeVerified ? verificationButton : null }
beforeSlot={ data.is_self_destructed ? (
<Alert status="info" whiteSpace="pre-wrap" mb={ 3 }>
Contracts that self destruct in their constructors have no contract code published and cannot be verified.
Displaying the init data provided of the creating transaction.
</Alert>
) : null }
textareaMaxHeight="300px"
isLoading={ isLoading }
/>
) }
{ data?.deployed_bytecode && (
<RawDataSnippet
data={ data.deployed_bytecode }
title="Deployed ByteCode"
rightSlot={ !data?.creation_bytecode && canBeVerified ? verificationButton : null }
textareaMaxHeight="300px"
isLoading={ isLoading }
/>
) }
</Flex>
),
} : undefined,
].filter(Boolean);
}, [ isLoading, addressHash, data, constructorArgs, sourceAddress, canBeVerified ]);
}
...@@ -8,7 +8,7 @@ import * as cookies from 'lib/cookies'; ...@@ -8,7 +8,7 @@ import * as cookies from 'lib/cookies';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketChannel from 'lib/socket/useSocketChannel';
import * as stubs from 'stubs/contract'; import * as stubs from 'stubs/contract';
import ContractCode from 'ui/address/contract/ContractCode'; import ContractDetails from 'ui/address/contract/ContractDetails';
import ContractMethodsCustom from 'ui/address/contract/methods/ContractMethodsCustom'; import ContractMethodsCustom from 'ui/address/contract/methods/ContractMethodsCustom';
import ContractMethodsMudSystem from 'ui/address/contract/methods/ContractMethodsMudSystem'; import ContractMethodsMudSystem from 'ui/address/contract/methods/ContractMethodsMudSystem';
import ContractMethodsProxy from 'ui/address/contract/methods/ContractMethodsProxy'; import ContractMethodsProxy from 'ui/address/contract/methods/ContractMethodsProxy';
...@@ -16,23 +16,14 @@ import ContractMethodsRegular from 'ui/address/contract/methods/ContractMethodsR ...@@ -16,23 +16,14 @@ import ContractMethodsRegular from 'ui/address/contract/methods/ContractMethodsR
import { divideAbiIntoMethodTypes } from 'ui/address/contract/methods/utils'; import { divideAbiIntoMethodTypes } from 'ui/address/contract/methods/utils';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
const CONTRACT_TAB_IDS = [ import type { CONTRACT_MAIN_TAB_IDS } from './utils';
'contract_code', import { CONTRACT_DETAILS_TAB_IDS, CONTRACT_TAB_IDS } from './utils';
'read_contract',
'read_contract_rpc',
'read_proxy',
'read_custom_methods',
'write_contract',
'write_contract_rpc',
'write_proxy',
'write_custom_methods',
'mud_system',
] as const;
interface ContractTab { interface ContractTab {
id: typeof CONTRACT_TAB_IDS[number]; id: typeof CONTRACT_MAIN_TAB_IDS[number];
title: string; title: string;
component: JSX.Element; component: JSX.Element;
subTabs?: Array<string>;
} }
interface ReturnType { interface ReturnType {
...@@ -101,10 +92,11 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder ...@@ -101,10 +92,11 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder
return React.useMemo(() => { return React.useMemo(() => {
return { return {
tabs: [ tabs: [
{ data?.hash && {
id: 'contract_code' as const, id: 'contract_code' as const,
title: 'Code', title: 'Code',
component: <ContractCode contractQuery={ contractQuery } channel={ channel } addressHash={ data?.hash }/>, component: <ContractDetails mainContractQuery={ contractQuery } channel={ channel } addressHash={ data.hash }/>,
subTabs: CONTRACT_DETAILS_TAB_IDS as unknown as Array<string>,
}, },
methods.read.length > 0 && { methods.read.length > 0 && {
id: 'read_contract' as const, id: 'read_contract' as const,
......
export const CONTRACT_MAIN_TAB_IDS = [
'contract_code',
'read_contract',
'read_contract_rpc',
'read_proxy',
'read_custom_methods',
'write_contract',
'write_contract_rpc',
'write_proxy',
'write_custom_methods',
'mud_system',
] as const;
export const CONTRACT_DETAILS_TAB_IDS = [
'contract_source_code',
'contract_compiler',
'contract_abi',
'contract_bytecode',
] as const;
export const CONTRACT_TAB_IDS = (CONTRACT_MAIN_TAB_IDS as unknown as Array<string>).concat(CONTRACT_DETAILS_TAB_IDS as unknown as Array<string>);
...@@ -11,7 +11,6 @@ import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery ...@@ -11,7 +11,6 @@ import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import useAddressProfileApiQuery from 'lib/hooks/useAddressProfileApiQuery'; import useAddressProfileApiQuery from 'lib/hooks/useAddressProfileApiQuery';
import useContractTabs from 'lib/hooks/useContractTabs';
import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; import useIsSafeAddress from 'lib/hooks/useIsSafeAddress';
import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText'; import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
...@@ -33,6 +32,8 @@ import AddressTokenTransfers from 'ui/address/AddressTokenTransfers'; ...@@ -33,6 +32,8 @@ import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs'; import AddressTxs from 'ui/address/AddressTxs';
import AddressUserOps from 'ui/address/AddressUserOps'; import AddressUserOps from 'ui/address/AddressUserOps';
import AddressWithdrawals from 'ui/address/AddressWithdrawals'; import AddressWithdrawals from 'ui/address/AddressWithdrawals';
import useContractTabs from 'ui/address/contract/useContractTabs';
import { CONTRACT_TAB_IDS } from 'ui/address/contract/utils';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton'; import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressMetadataAlert from 'ui/address/details/AddressMetadataAlert'; import AddressMetadataAlert from 'ui/address/details/AddressMetadataAlert';
import AddressQrCode from 'ui/address/details/AddressQrCode'; import AddressQrCode from 'ui/address/details/AddressQrCode';
...@@ -249,7 +250,7 @@ const AddressPageContent = () => { ...@@ -249,7 +250,7 @@ const AddressPageContent = () => {
isLoading={ contractTabs.isLoading } isLoading={ contractTabs.isLoading }
/> />
), ),
subTabs: contractTabs.tabs.map(tab => tab.id), subTabs: CONTRACT_TAB_IDS,
} : undefined, } : undefined,
].filter(Boolean); ].filter(Boolean);
}, [ }, [
......
...@@ -10,7 +10,6 @@ import type { RoutedTab } from 'ui/shared/Tabs/types'; ...@@ -10,7 +10,6 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery'; import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import useContractTabs from 'lib/hooks/useContractTabs';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as metadata from 'lib/metadata'; import * as metadata from 'lib/metadata';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
...@@ -23,6 +22,8 @@ import { getTokenHoldersStub } from 'stubs/token'; ...@@ -23,6 +22,8 @@ import { getTokenHoldersStub } from 'stubs/token';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
import AddressContract from 'ui/address/AddressContract'; import AddressContract from 'ui/address/AddressContract';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink'; import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import useContractTabs from 'ui/address/contract/useContractTabs';
import { CONTRACT_TAB_IDS } from 'ui/address/contract/utils';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
...@@ -191,7 +192,7 @@ const TokenPageContent = () => { ...@@ -191,7 +192,7 @@ const TokenPageContent = () => {
return 'Contract'; return 'Contract';
}, },
component: <AddressContract tabs={ contractTabs.tabs } isLoading={ contractTabs.isLoading } shouldRender={ !isLoading }/>, component: <AddressContract tabs={ contractTabs.tabs } isLoading={ contractTabs.isLoading } shouldRender={ !isLoading }/>,
subTabs: contractTabs.tabs.map(tab => tab.id), subTabs: CONTRACT_TAB_IDS,
} : undefined, } : undefined,
].filter(Boolean); ].filter(Boolean);
......
...@@ -37,7 +37,7 @@ const AdaptiveTabsList = (props: Props) => { ...@@ -37,7 +37,7 @@ const AdaptiveTabsList = (props: Props) => {
return [ ...props.tabs, menuButton ]; return [ ...props.tabs, menuButton ];
}, [ props.tabs ]); }, [ props.tabs ]);
const { tabsCut, tabsRefs, listRef, rightSlotRef } = useAdaptiveTabs(tabsList, isMobile); const { tabsCut, tabsRefs, listRef, rightSlotRef, leftSlotRef } = useAdaptiveTabs(tabsList, isMobile);
const isSticky = useIsSticky(listRef, 5, props.stickyEnabled); const isSticky = useIsSticky(listRef, 5, props.stickyEnabled);
useScrollToActiveTab({ activeTabIndex: props.activeTabIndex, listRef, tabsRefs, isMobile, isLoading: props.isLoading }); useScrollToActiveTab({ activeTabIndex: props.activeTabIndex, listRef, tabsRefs, isMobile, isLoading: props.isLoading });
...@@ -80,6 +80,7 @@ const AdaptiveTabsList = (props: Props) => { ...@@ -80,6 +80,7 @@ const AdaptiveTabsList = (props: Props) => {
props.tabListProps) props.tabListProps)
} }
> >
{ props.leftSlot && <Box ref={ leftSlotRef } { ...props.leftSlotProps }> { props.leftSlot } </Box> }
{ tabsList.slice(0, props.isLoading ? 5 : Infinity).map((tab, index) => { { tabsList.slice(0, props.isLoading ? 5 : Infinity).map((tab, index) => {
if (!tab.id) { if (!tab.id) {
if (props.isLoading) { if (props.isLoading) {
......
...@@ -14,13 +14,27 @@ interface Props extends ThemingProps<'Tabs'> { ...@@ -14,13 +14,27 @@ interface Props extends ThemingProps<'Tabs'> {
tabListProps?: ChakraProps | (({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => ChakraProps); tabListProps?: ChakraProps | (({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => ChakraProps);
rightSlot?: React.ReactNode; rightSlot?: React.ReactNode;
rightSlotProps?: ChakraProps; rightSlotProps?: ChakraProps;
leftSlot?: React.ReactNode;
leftSlotProps?: ChakraProps;
stickyEnabled?: boolean; stickyEnabled?: boolean;
className?: string; className?: string;
onTabChange?: (index: number) => void; onTabChange?: (index: number) => void;
isLoading?: boolean; isLoading?: boolean;
} }
const RoutedTabs = ({ tabs, tabListProps, rightSlot, rightSlotProps, stickyEnabled, className, onTabChange, isLoading, ...themeProps }: Props) => { const RoutedTabs = ({
tabs,
tabListProps,
rightSlot,
rightSlotProps,
leftSlot,
leftSlotProps,
stickyEnabled,
className,
onTabChange,
isLoading,
...themeProps
}: Props) => {
const router = useRouter(); const router = useRouter();
const tabIndex = useTabIndexFromQuery(tabs); const tabIndex = useTabIndexFromQuery(tabs);
const tabsRef = useRef<HTMLDivElement>(null); const tabsRef = useRef<HTMLDivElement>(null);
...@@ -59,6 +73,8 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, rightSlotProps, stickyEnabl ...@@ -59,6 +73,8 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, rightSlotProps, stickyEnabl
<TabsWithScroll <TabsWithScroll
tabs={ tabs } tabs={ tabs }
tabListProps={ tabListProps } tabListProps={ tabListProps }
leftSlot={ leftSlot }
leftSlotProps={ leftSlotProps }
rightSlot={ rightSlot } rightSlot={ rightSlot }
rightSlotProps={ rightSlotProps } rightSlotProps={ rightSlotProps }
stickyEnabled={ stickyEnabled } stickyEnabled={ stickyEnabled }
......
...@@ -43,6 +43,8 @@ const TabsMenu = ({ tabs, tabsCut, isActive, styles, onItemClick, buttonRef, act ...@@ -43,6 +43,8 @@ const TabsMenu = ({ tabs, tabsCut, isActive, styles, onItemClick, buttonRef, act
<Popover isLazy placement="bottom-end" key="more" isOpen={ isOpen } onClose={ onClose } onOpen={ onOpen } closeDelay={ 0 }> <Popover isLazy placement="bottom-end" key="more" isOpen={ isOpen } onClose={ onClose } onOpen={ onOpen } closeDelay={ 0 }>
<PopoverTrigger> <PopoverTrigger>
<Button <Button
as="div"
role="button"
variant="ghost" variant="ghost"
isActive={ isOpen || isActive } isActive={ isOpen || isActive }
ref={ buttonRef } ref={ buttonRef }
......
...@@ -22,6 +22,8 @@ export interface Props extends ThemingProps<'Tabs'> { ...@@ -22,6 +22,8 @@ export interface Props extends ThemingProps<'Tabs'> {
tabListProps?: ChakraProps | (({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => ChakraProps); tabListProps?: ChakraProps | (({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => ChakraProps);
rightSlot?: React.ReactNode; rightSlot?: React.ReactNode;
rightSlotProps?: ChakraProps; rightSlotProps?: ChakraProps;
leftSlot?: React.ReactNode;
leftSlotProps?: ChakraProps;
stickyEnabled?: boolean; stickyEnabled?: boolean;
onTabChange?: (index: number) => void; onTabChange?: (index: number) => void;
defaultTabIndex?: number; defaultTabIndex?: number;
...@@ -35,6 +37,8 @@ const TabsWithScroll = ({ ...@@ -35,6 +37,8 @@ const TabsWithScroll = ({
tabListProps, tabListProps,
rightSlot, rightSlot,
rightSlotProps, rightSlotProps,
leftSlot,
leftSlotProps,
stickyEnabled, stickyEnabled,
onTabChange, onTabChange,
defaultTabIndex, defaultTabIndex,
...@@ -102,6 +106,8 @@ const TabsWithScroll = ({ ...@@ -102,6 +106,8 @@ const TabsWithScroll = ({
key={ isLoading + '_' + screenWidth + '_' + tabsList.map((tab) => tab.id).join(':') } key={ isLoading + '_' + screenWidth + '_' + tabsList.map((tab) => tab.id).join(':') }
tabs={ tabs } tabs={ tabs }
tabListProps={ tabListProps } tabListProps={ tabListProps }
leftSlot={ leftSlot }
leftSlotProps={ leftSlotProps }
rightSlot={ rightSlot } rightSlot={ rightSlot }
rightSlotProps={ rightSlotProps } rightSlotProps={ rightSlotProps }
stickyEnabled={ stickyEnabled } stickyEnabled={ stickyEnabled }
......
...@@ -9,10 +9,12 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab | MenuButton>, dis ...@@ -9,10 +9,12 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab | MenuButton>, dis
const [ tabsRefs, setTabsRefs ] = React.useState<Array<React.RefObject<HTMLButtonElement>>>([]); const [ tabsRefs, setTabsRefs ] = React.useState<Array<React.RefObject<HTMLButtonElement>>>([]);
const listRef = React.useRef<HTMLDivElement>(null); const listRef = React.useRef<HTMLDivElement>(null);
const rightSlotRef = React.useRef<HTMLDivElement>(null); const rightSlotRef = React.useRef<HTMLDivElement>(null);
const leftSlotRef = React.useRef<HTMLDivElement>(null);
const calculateCut = React.useCallback(() => { const calculateCut = React.useCallback(() => {
const listWidth = listRef.current?.getBoundingClientRect().width; const listWidth = listRef.current?.getBoundingClientRect().width;
const rightSlotWidth = rightSlotRef.current?.getBoundingClientRect().width || 0; const rightSlotWidth = rightSlotRef.current?.getBoundingClientRect().width || 0;
const leftSlotWidth = leftSlotRef.current?.getBoundingClientRect().width || 0;
const tabWidths = tabsRefs.map((tab) => tab.current?.getBoundingClientRect().width); const tabWidths = tabsRefs.map((tab) => tab.current?.getBoundingClientRect().width);
const menuWidth = tabWidths[tabWidths.length - 1]; const menuWidth = tabWidths[tabWidths.length - 1];
...@@ -33,11 +35,11 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab | MenuButton>, dis ...@@ -33,11 +35,11 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab | MenuButton>, dis
if (index === array.length - 1) { if (index === array.length - 1) {
// last element // last element
if (result.accWidth + item < listWidth - rightSlotWidth) { if (result.accWidth + item < listWidth - rightSlotWidth - leftSlotWidth) {
return { visibleNum: result.visibleNum + 1, accWidth: result.accWidth + item }; return { visibleNum: result.visibleNum + 1, accWidth: result.accWidth + item };
} }
} else { } else {
if (result.accWidth + item + menuWidth < listWidth - rightSlotWidth) { if (result.accWidth + item + menuWidth < listWidth - rightSlotWidth - leftSlotWidth) {
return { visibleNum: result.visibleNum + 1, accWidth: result.accWidth + item }; return { visibleNum: result.visibleNum + 1, accWidth: result.accWidth + item };
} }
} }
...@@ -67,6 +69,7 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab | MenuButton>, dis ...@@ -67,6 +69,7 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab | MenuButton>, dis
tabsRefs, tabsRefs,
listRef, listRef,
rightSlotRef, rightSlotRef,
leftSlotRef,
}; };
}, [ tabsCut, tabsRefs ]); }, [ tabsCut, tabsRefs ]);
} }
import { ButtonGroup, Button, Flex, useRadio, useRadioGroup, useColorModeValue } from '@chakra-ui/react'; import { ButtonGroup, Button, Flex, useRadio, useRadioGroup } from '@chakra-ui/react';
import type { UseRadioProps } from '@chakra-ui/react'; import type { UseRadioProps } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
...@@ -20,35 +20,17 @@ type RadioButtonProps = UseRadioProps & RadioItemProps; ...@@ -20,35 +20,17 @@ type RadioButtonProps = UseRadioProps & RadioItemProps;
const RadioButton = (props: RadioButtonProps) => { const RadioButton = (props: RadioButtonProps) => {
const { getInputProps, getRadioProps } = useRadio(props); const { getInputProps, getRadioProps } = useRadio(props);
const buttonColor = useColorModeValue('blue.50', 'gray.800');
const checkedTextColor = useColorModeValue('blue.700', 'gray.50');
const input = getInputProps(); const input = getInputProps();
const checkbox = getRadioProps(); const checkbox = getRadioProps();
const styleProps = {
flex: 1,
variant: 'outline',
fontWeight: 500,
cursor: props.isChecked ? 'initial' : 'pointer',
borderColor: buttonColor,
backgroundColor: props.isChecked ? buttonColor : 'none',
_hover: {
borderColor: buttonColor,
...(props.isChecked ? {} : { color: 'link_hovered' }),
},
_active: {
backgroundColor: 'none',
},
...(props.isChecked ? { color: checkedTextColor } : {}),
};
if (props.onlyIcon) { if (props.onlyIcon) {
return ( return (
<Button <Button
as="label" as="label"
aria-label={ props.title } aria-label={ props.title }
{ ...styleProps } variant="radio_group"
data-selected={ props.isChecked }
> >
<input { ...input }/> <input { ...input }/>
<Flex <Flex
...@@ -64,7 +46,8 @@ const RadioButton = (props: RadioButtonProps) => { ...@@ -64,7 +46,8 @@ const RadioButton = (props: RadioButtonProps) => {
<Button <Button
as="label" as="label"
leftIcon={ props.icon ? <IconSvg name={ props.icon } boxSize={ 5 } mr={ -1 }/> : undefined } leftIcon={ props.icon ? <IconSvg name={ props.icon } boxSize={ 5 } mr={ -1 }/> : undefined }
{ ...styleProps } variant="radio_group"
data-selected={ props.isChecked }
> >
<input { ...input }/> <input { ...input }/>
<Flex <Flex
......
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