diff --git a/stubs/contract.ts b/stubs/contract.ts index e821440284..92b86ac2e7 100644 --- a/stubs/contract.ts +++ b/stubs/contract.ts @@ -46,6 +46,7 @@ export const CONTRACT_CODE_VERIFIED = { remappings: [], }, compiler_version: 'v0.8.7+commit.e28d00a7', + constructor_args: '0000000000000000000000005c7bcd6e7de5423a257d81b442095a1a6ced35c5000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', creation_bytecode: '0x6080604052348', deployed_bytecode: '0x60806040', evm_version: 'london', diff --git a/theme/components/Button/Button.pw.tsx b/theme/components/Button/Button.pw.tsx index e8b15075a7..a0c400120f 100644 --- a/theme/components/Button/Button.pw.tsx +++ b/theme/components/Button/Button.pw.tsx @@ -15,6 +15,7 @@ test.use({ viewport: { width: 150, height: 350 } }); { variant: 'subtle', colorScheme: 'gray', states: [ 'default', 'hovered' ], withDarkMode: true }, { variant: 'hero', states: [ 'default', 'hovered' ], withDarkMode: true }, { variant: 'header', states: [ 'default', 'hovered', 'selected' ], withDarkMode: true }, + { variant: 'radio_group', states: [ 'default', 'hovered', 'selected' ], withDarkMode: true }, ].forEach(({ variant, colorScheme, withDarkMode, states }) => { test.describe(`variant ${ variant }${ colorScheme ? ` with ${ colorScheme } color scheme` : '' }${ withDarkMode ? ' +@dark-mode' : '' }`, () => { test('', async({ render }) => { diff --git a/theme/components/Button/Button.ts b/theme/components/Button/Button.ts index e9482b8fbc..b43731816e 100644 --- a/theme/components/Button/Button.ts +++ b/theme/components/Button/Button.ts @@ -94,6 +94,42 @@ const variantOutline = defineStyle((props) => { }; }); +const variantRadioGroup = defineStyle((props) => { + const outline = runIfFn(variantOutline, props); + const bgColor = mode('blue.50', 'gray.800')(props); + const selectedTextColor = mode('blue.700', 'gray.50')(props); + + return { + ...outline, + fontWeight: 500, + cursor: 'pointer', + bgColor: 'none', + borderColor: bgColor, + _hover: { + borderColor: bgColor, + color: 'link_hovered', + }, + _active: { + bgColor: 'none', + }, + // We have a special state for this button variant that serves as a popover trigger. + // When any items (filters) are selected in the popover, the button should change its background and text color. + // The last CSS selector is for redefining styles for the TabList component. + [` + &[data-selected=true], + &[data-selected=true][aria-selected=true] + `]: { + cursor: 'initial', + bgColor, + borderColor: bgColor, + color: selectedTextColor, + _hover: { + color: selectedTextColor, + }, + }, + }; +}); + const variantSimple = defineStyle((props) => { const outline = runIfFn(variantOutline, props); @@ -223,6 +259,7 @@ const variants = { subtle: variantSubtle, hero: variantHero, header: variantHeader, + radio_group: variantRadioGroup, }; const baseStyle = defineStyle({ diff --git a/theme/components/Button/__screenshots__/Button.pw.tsx_dark-color-mode_variant-radio-group-dark-mode-1.png b/theme/components/Button/__screenshots__/Button.pw.tsx_dark-color-mode_variant-radio-group-dark-mode-1.png new file mode 100644 index 0000000000..8648ebecee Binary files /dev/null and b/theme/components/Button/__screenshots__/Button.pw.tsx_dark-color-mode_variant-radio-group-dark-mode-1.png differ diff --git a/theme/components/Button/__screenshots__/Button.pw.tsx_default_variant-radio-group-dark-mode-1.png b/theme/components/Button/__screenshots__/Button.pw.tsx_default_variant-radio-group-dark-mode-1.png new file mode 100644 index 0000000000..d26e938ccc Binary files /dev/null and b/theme/components/Button/__screenshots__/Button.pw.tsx_default_variant-radio-group-dark-mode-1.png differ diff --git a/theme/components/Tabs.ts b/theme/components/Tabs.ts index e73668761a..b07ade5e11 100644 --- a/theme/components/Tabs.ts +++ b/theme/components/Tabs.ts @@ -41,6 +41,33 @@ const variantOutline = definePartsStyle((props) => { }; }); +const variantRadioGroup = definePartsStyle((props) => { + return { + tab: { + ...Button.baseStyle, + ...Button.variants?.radio_group(props), + _selected: Button.variants?.radio_group(props)?.[` + &[data-selected=true], + &[data-selected=true][aria-selected=true] + `], + borderRadius: 'none', + _notFirst: { + borderLeftWidth: 0, + }, + '&[role="tab"]': { + _first: { + borderTopLeftRadius: 'base', + borderBottomLeftRadius: 'base', + }, + _last: { + borderTopRightRadius: 'base', + borderBottomRightRadius: 'base', + }, + }, + }, + }; +}); + const sizes = { sm: definePartsStyle({ tab: Button.sizes?.sm, @@ -53,6 +80,7 @@ const sizes = { const variants = { 'soft-rounded': variantSoftRounded, outline: variantOutline, + radio_group: variantRadioGroup, }; const Tabs = defineMultiStyleConfig({ diff --git a/ui/address/AddressContract.pwstory.tsx b/ui/address/AddressContract.pwstory.tsx index c558dd9ca2..b088e93fbc 100644 --- a/ui/address/AddressContract.pwstory.tsx +++ b/ui/address/AddressContract.pwstory.tsx @@ -2,8 +2,8 @@ import { useRouter } from 'next/router'; import React from 'react'; import useApiQuery from 'lib/api/useApiQuery'; -import useContractTabs from 'lib/hooks/useContractTabs'; import getQueryParamString from 'lib/router/getQueryParamString'; +import useContractTabs from 'ui/address/contract/useContractTabs'; import AddressContract from './AddressContract'; diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_collections-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_collections-dark-mode-1.png index 79c7e3e819..743da2862d 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_collections-dark-mode-1.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_collections-dark-mode-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_nfts-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_nfts-dark-mode-1.png index ac0c8b5935..2490632952 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_nfts-dark-mode-1.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_nfts-dark-mode-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_collections-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_collections-dark-mode-1.png index bbf9ed9894..2c9f8d372c 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_collections-dark-mode-1.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_collections-dark-mode-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-collections-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-collections-1.png index b9975934e7..f2bce20aea 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-collections-1.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-collections-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-nfts-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-nfts-1.png index fd147da3b8..50963d545a 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-nfts-1.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-nfts-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_nfts-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_nfts-dark-mode-1.png index 93b1606fa8..013acf9a15 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_nfts-dark-mode-1.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_nfts-dark-mode-1.png differ diff --git a/ui/address/contract/ContractCode.pw.tsx b/ui/address/contract/ContractCode.pw.tsx deleted file mode 100644 index 179f483513..0000000000 --- a/ui/address/contract/ContractCode.pw.tsx +++ /dev/null @@ -1,158 +0,0 @@ -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 * as socketServer from 'playwright/fixtures/socketServer'; -import { test, expect } from 'playwright/lib'; - -import ContractCode from './specs/ContractCode'; - -const hooksConfig = { - router: { - query: { hash: addressMock.contract.hash, tab: 'contract_code' }, - }, -}; - -// 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' }); - -let addressApiUrl: string; - -test.beforeEach(async({ mockApiResponse, page }) => { - await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => { - route.abort(); - }); - addressApiUrl = await mockApiResponse('address', addressMock.contract, { pathParams: { hash: addressMock.contract.hash } }); -}); - -test('full view +@mobile +@dark-mode', async({ render, mockApiResponse, createSocket }) => { - 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(, { hooksConfig }, { withSocket: true }); - await createSocket(); - - await expect(component).toHaveScreenshot(); -}); - -test('verified with changed byte code socket', async({ render, mockApiResponse, createSocket }) => { - await mockApiResponse('contract', contractMock.verified, { pathParams: { hash: addressMock.contract.hash } }); - - const component = await render(, { hooksConfig }, { 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 lookup in eth_bytecode_db', async({ render, mockApiResponse, createSocket, page }) => { - const contractApiUrl = await mockApiResponse('contract', contractMock.nonVerified, { pathParams: { hash: addressMock.contract.hash } }); - await render(, { hooksConfig }, { withSocket: true }); - - const socket = await createSocket(); - const channel = await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase()); - await page.waitForResponse(contractApiUrl); - socketServer.sendMessage(socket, channel, 'smart_contract_was_verified', {}); - const request = await page.waitForRequest(addressApiUrl); - - expect(request).toBeTruthy(); -}); - -test('verified with multiple sources', async({ render, page, mockApiResponse }) => { - await mockApiResponse('contract', contractMock.withMultiplePaths, { pathParams: { hash: addressMock.contract.hash } }); - await render(, { hooksConfig }, { withSocket: true }); - - const section = page.locator('section', { hasText: 'Contract source code' }); - await expect(section).toHaveScreenshot(); - - await page.getByRole('button', { name: 'View external libraries' }).click(); - await expect(section).toHaveScreenshot(); - - await page.getByRole('button', { name: 'Open source code in IDE' }).click(); - await expect(section).toHaveScreenshot(); -}); - -test('verified via sourcify', async({ render, mockApiResponse, page }) => { - await mockApiResponse('contract', contractMock.verifiedViaSourcify, { pathParams: { hash: addressMock.contract.hash } }); - await render(, { 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(, { hooksConfig }, { withSocket: true }); - - await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 110 } }); -}); - -test('self destructed', async({ render, mockApiResponse, page }) => { - await mockApiResponse('contract', contractMock.selfDestructed, { pathParams: { hash: addressMock.contract.hash } }); - await render(, { hooksConfig }, { withSocket: true }); - - const section = page.locator('section', { hasText: 'Contract creation code' }); - 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(, { 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(, { 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(, { hooksConfig }); - - await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 120 } }); -}); - -test('non verified', async({ render, mockApiResponse }) => { - await mockApiResponse('contract', contractMock.nonVerified, { pathParams: { hash: addressMock.contract.hash } }); - const component = await render(, { hooksConfig }, { withSocket: true }); - - await expect(component).toHaveScreenshot(); -}); - -test('zkSync contract', async({ render, mockApiResponse, page, mockEnvs }) => { - await mockEnvs(ENVS_MAP.zkSyncRollup); - await mockApiResponse('contract', contractMock.zkSync, { pathParams: { hash: addressMock.contract.hash } }); - await render(, { hooksConfig }, { withSocket: true }); - - await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); -}); - -test.describe('with audits feature', () => { - - test.beforeEach(async({ mockEnvs }) => { - await mockEnvs(ENVS_MAP.hasContractAuditReports); - }); - - test('no audits', async({ render, mockApiResponse }) => { - await mockApiResponse('contract', contractMock.verified, { pathParams: { hash: addressMock.contract.hash } }); - await mockApiResponse('contract_security_audits', { items: [] }, { pathParams: { hash: addressMock.contract.hash } }); - const component = await render(, { hooksConfig }, { withSocket: true }); - - await expect(component).toHaveScreenshot(); - }); - - test('has audits', async({ render, mockApiResponse }) => { - await mockApiResponse('contract', contractMock.verified, { pathParams: { hash: addressMock.contract.hash } }); - await mockApiResponse('contract_security_audits', contractAudits, { pathParams: { hash: addressMock.contract.hash } }); - const component = await render(, { hooksConfig }, { withSocket: true }); - - await expect(component).toHaveScreenshot(); - }); -}); diff --git a/ui/address/contract/ContractCode.tsx b/ui/address/contract/ContractCode.tsx deleted file mode 100644 index a75f3a37fc..0000000000 --- a/ui/address/contract/ContractCode.tsx +++ /dev/null @@ -1,347 +0,0 @@ -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>; - 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) => ( - - - - { label } - { hint && ( - - ) } - - - { content } - -)); - -const rollupFeature = config.features.rollup; - -const ContractCode = ({ addressHash, contractQuery, channel }: Props) => { - const [ isChangedBytecodeSocket, setIsChangedBytecodeSocket ] = React.useState(); - - const queryClient = useQueryClient(); - const addressInfo = queryClient.getQueryData(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 ; - } - - const canBeVerified = !data?.is_self_destructed && !data?.is_verified; - - const verificationButton = isPlaceholderData ? ( - - ) : ( - - ); - - 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 ( - - { license.label } - - ); - })(); - - 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' ? ( - - ) : { value }; - return ( - - Arg [{ index }] { name || '' } ({ type }): - { valueEl } - - ); - }); - - return ( - <> - { data.constructor_args } -

- { decoded } - - ); - })(); - - const verificationAlert = (() => { - if (data?.is_verified_via_eth_bytecode_db) { - return ( - - This contract has been { data.is_partially_verified ? 'partially ' : '' }verified using - - Blockscout Bytecode Database - - - ); - } - - if (data?.is_verified_via_sourcify) { - return ( - - This contract has been { data.is_partially_verified ? 'partially ' : '' }verified via Sourcify. - { data.sourcify_repo_url && View contract in Sourcify repository } - - ); - } - - return null; - })(); - - const contractNameWithCertifiedIcon = data?.is_verified ? ( - - { data.name } - { data.certified && } - - ) : null; - - return ( - <> - - { data?.is_blueprint && ( - - This is an - - ERC-5202 Blueprint contract - - - ) } - { data?.is_verified && ( - - - Contract Source Code Verified ({ data.is_partially_verified ? 'Partial' : 'Exact' } Match) - { data.is_partially_verified ? verificationButton : null } - - - ) } - { verificationAlert } - { (data?.is_changed_bytecode || isChangedBytecodeSocket) && ( - - Warning! Contract bytecode has been changed and does not match the verified one. Therefore, interaction with this smart contract may be risky. - - ) } - { !data?.is_verified && data?.verified_twin_address_hash && (!data?.proxy_type || data.proxy_type === 'unknown') && ( - - Contract is not verified. However, we found a verified contract with the same bytecode in Blockscout DB - - All functions displayed below are from ABI of that contract. In order to verify current contract, proceed with - - Verify & Publish - - page - - ) } - { data?.proxy_type && } - - { data?.is_verified && ( - - { data.name && } - { data.compiler_version && } - { data.zk_compiler_version && } - { data.evm_version && } - { licenseLink && ( - - ) } - { typeof data.optimization_enabled === 'boolean' && - } - { data.optimization_runs !== null && ( - - ) } - { data.verified_at && - } - { data.file_path && } - { config.UI.hasContractAuditReports && ( - } - isLoading={ isPlaceholderData } - /> - ) } - - ) } - - { constructorArgs && ( - - ) } - { data?.source_code && addressHash && ( - - ) } - { data?.compiler_settings ? ( - - ) : null } - { data?.abi && ( - - ) } - { data?.creation_bytecode && ( - - 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. - - ) : null } - textareaMaxHeight="200px" - isLoading={ isPlaceholderData } - /> - ) } - { data?.deployed_bytecode && ( - - ) } - - - ); -}; - -export default ContractCode; diff --git a/ui/address/contract/ContractDetails.pw.tsx b/ui/address/contract/ContractDetails.pw.tsx new file mode 100644 index 0000000000..3f533710ae --- /dev/null +++ b/ui/address/contract/ContractDetails.pw.tsx @@ -0,0 +1,171 @@ +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 * as pwConfig from 'playwright/utils/config'; + +import ContractDetails from './specs/ContractDetails'; + +const hooksConfig = { + router: { + query: { hash: addressMock.contract.hash, tab: 'contract_code' }, + }, +}; + +// 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' }); + +let addressApiUrl: string; + +test.beforeEach(async({ mockApiResponse, page }) => { + await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => { + route.abort(); + }); + addressApiUrl = await mockApiResponse('address', addressMock.contract, { pathParams: { hash: addressMock.contract.hash } }); +}); + +test.describe('full view', () => { + test.beforeEach(async({ 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 } }); + }); + + test('source code +@dark-mode', async({ render, createSocket }) => { + const hooksConfig = { + router: { + query: { hash: addressMock.contract.hash, tab: 'contract_source_code' }, + }, + }; + const component = await render(, { hooksConfig }, { withSocket: true }); + await createSocket(); + await expect(component).toHaveScreenshot(); + }); + + test('compiler', async({ render, createSocket }) => { + const hooksConfig = { + router: { + query: { hash: addressMock.contract.hash, tab: 'contract_compiler' }, + }, + }; + const component = await render(, { hooksConfig }, { withSocket: true }); + await createSocket(); + await expect(component).toHaveScreenshot(); + }); + + test('abi', async({ render, createSocket }) => { + const hooksConfig = { + router: { + query: { hash: addressMock.contract.hash, tab: 'contract_abi' }, + }, + }; + const component = await render(, { hooksConfig }, { withSocket: true }); + await createSocket(); + await expect(component).toHaveScreenshot(); + }); + + test('bytecode', async({ render, createSocket }) => { + const hooksConfig = { + router: { + query: { hash: addressMock.contract.hash, tab: 'contract_bytecode' }, + }, + }; + const component = await render(, { hooksConfig }, { withSocket: true }); + await createSocket(); + 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(, { hooksConfig }, { withSocket: true }); + await createSocket(); + await expect(component).toHaveScreenshot(); + }); +}); + +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 } }); + await render(, { hooksConfig }, { withSocket: true }); + + const socket = await createSocket(); + const channel = await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase()); + await page.waitForResponse(contractApiUrl); + socketServer.sendMessage(socket, channel, 'smart_contract_was_verified', {}); + const request = await page.waitForRequest(addressApiUrl); + + expect(request).toBeTruthy(); +}); + +test('verified with multiple sources', async({ render, page, mockApiResponse }) => { + await mockApiResponse('contract', contractMock.withMultiplePaths, { pathParams: { hash: addressMock.contract.hash } }); + await render(, { hooksConfig }, { withSocket: true }); + + const section = page.locator('section', { hasText: 'Contract source code' }); + await expect(section).toHaveScreenshot(); + + await page.getByRole('button', { name: 'View external libraries' }).click(); + await expect(section).toHaveScreenshot(); + + await page.getByRole('button', { name: 'Open source code in IDE' }).click(); + await expect(section).toHaveScreenshot(); +}); + +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 render(, { hooksConfig }, { withSocket: true }); + + const section = page.locator('section', { hasText: 'Contract creation code' }); + await expect(section).toHaveScreenshot(); +}); + +test('non verified', async({ render, mockApiResponse }) => { + await mockApiResponse('contract', contractMock.nonVerified, { pathParams: { hash: addressMock.contract.hash } }); + const component = await render(, { hooksConfig }, { withSocket: true }); + + await expect(component).toHaveScreenshot(); +}); + +test('implementation info', async({ render, mockApiResponse }) => { + const hooksConfig = { + router: { + query: { hash: addressMock.contract.hash, tab: 'contract_compiler' }, + }, + }; + + const implementationName = addressMock.contract.implementations?.[0].name as string; + const implementationAddress = addressMock.contract.implementations?.[0].address as string; + const implementationContract = { + ...contractMock.verified, + compiler_settings: { + evmVersion: 'london', + libraries: {}, + metadata: { + bytecodeHash: 'ipfs', + useLiteralContent: false, + }, + optimizer: { + enabled: true, + runs: 1000000, + }, + }, + }; + await mockApiResponse('contract', contractMock.verified, { pathParams: { hash: addressMock.contract.hash } }); + await mockApiResponse('contract', implementationContract, { pathParams: { hash: implementationAddress } }); + + const component = await render(, { hooksConfig }, { withSocket: true }); + await component.getByRole('combobox').selectOption(implementationName); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/address/contract/ContractDetails.tsx b/ui/address/contract/ContractDetails.tsx new file mode 100644 index 0000000000..1dfc68b72d --- /dev/null +++ b/ui/address/contract/ContractDetails.tsx @@ -0,0 +1,123 @@ +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; +} + +const ContractDetails = ({ addressHash, channel, mainContractQuery }: Props) => { + const router = useRouter(); + const sourceAddress = getQueryParamString(router.query.source_address); + + const queryClient = useQueryClient(); + const addressInfo = queryClient.getQueryData(getResourceKey('address', { pathParams: { hash: addressHash } })); + + const sourceItems: Array = 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 ; + } + + const addressSelector = sourceItems.length > 1 ? ( + + ) : null; + + return ( + <> + + { mainContractQuery.data?.is_verified && ( + + ) } + + + ); +}; + +export default ContractDetails; diff --git a/ui/address/contract/ContractDetailsVerificationButton.tsx b/ui/address/contract/ContractDetailsVerificationButton.tsx new file mode 100644 index 0000000000..129f251f02 --- /dev/null +++ b/ui/address/contract/ContractDetailsVerificationButton.tsx @@ -0,0 +1,39 @@ +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 ( + + ); + } + return ( + + ); +}; + +export default React.memo(ContractDetailsVerificationButton); diff --git a/ui/address/contract/methods/ContractSourceAddressSelector.tsx b/ui/address/contract/ContractSourceAddressSelector.tsx similarity index 71% rename from ui/address/contract/methods/ContractSourceAddressSelector.tsx rename to ui/address/contract/ContractSourceAddressSelector.tsx index f8a2853f5f..31fd271f42 100644 --- a/ui/address/contract/methods/ContractSourceAddressSelector.tsx +++ b/ui/address/contract/ContractSourceAddressSelector.tsx @@ -1,4 +1,4 @@ -import { Flex, Select, Skeleton } from '@chakra-ui/react'; +import { chakra, Flex, Select, Skeleton } from '@chakra-ui/react'; import React from 'react'; import { route } from 'nextjs-routes'; @@ -13,6 +13,7 @@ export interface Item { } interface Props { + className?: string; label: string; selectedItem: Item; onItemSelect: (item: Item) => void; @@ -20,7 +21,7 @@ interface Props { isLoading?: boolean; } -const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLoading, label }: Props) => { +const ContractSourceAddressSelector = ({ className, selectedItem, onItemSelect, items, isLoading, label }: Props) => { const handleItemSelect = React.useCallback((event: React.ChangeEvent) => { const nextOption = items.find(({ address }) => address === event.target.value); @@ -30,7 +31,7 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo }, [ items, onItemSelect ]); if (isLoading) { - return ; + return ; } if (items.length === 0) { @@ -39,8 +40,8 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo if (items.length === 1) { return ( - - { label } + + { label } @@ -49,8 +50,8 @@ const ContractSourceAddressSelector = ({ selectedItem, onItemSelect, items, isLo } return ( - - { label } + + { label } - { options.map((option) => ) } - - ) : null; - - const externalLibraries = contractQuery.data?.external_libraries ? - : + const externalLibraries = data?.external_libraries ? + : null; - const diagramLink = contractQuery?.data?.can_be_visualized_via_sol2uml ? ( + const diagramLink = data?.can_be_visualized_via_sol2uml ? ( @@ -124,11 +73,11 @@ export const ContractSourceCode = ({ address, implementations }: Props) => { ) : null; - const ides = ; + const ides = ; - const copyToClipboard = contractQuery.data && editorData?.length === 1 ? ( + const copyToClipboard = data && editorData?.length === 1 ? ( @@ -146,13 +95,13 @@ export const ContractSourceCode = ({ address, implementations }: Props) => { return ( ); })(); @@ -161,7 +110,6 @@ export const ContractSourceCode = ({ address, implementations }: Props) => {
{ heading } - { select } { externalLibraries } { diagramLink } { ides } diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_dark-color-mode_full-view-mobile-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_dark-color-mode_full-view-mobile-dark-mode-1.png deleted file mode 100644 index 3db7025fd4..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_dark-color-mode_full-view-mobile-dark-mode-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_full-view-mobile-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_full-view-mobile-dark-mode-1.png deleted file mode 100644 index 2108d45306..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_full-view-mobile-dark-mode-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-via-eth-bytecode-db-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-via-eth-bytecode-db-1.png deleted file mode 100644 index 3582332da3..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-via-eth-bytecode-db-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-via-sourcify-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-via-sourcify-1.png deleted file mode 100644 index b797261a7a..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-via-sourcify-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-changed-byte-code-socket-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-changed-byte-code-socket-1.png deleted file mode 100644 index 241191634a..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-changed-byte-code-socket-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-1.png deleted file mode 100644 index bb3d1162ac..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-2.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-2.png deleted file mode 100644 index faed372ebc..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-2.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-3.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-3.png deleted file mode 100644 index 7d9285311e..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_verified-with-multiple-sources-3.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-has-audits-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-has-audits-1.png deleted file mode 100644 index 8a0b09c4ea..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-has-audits-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-no-audits-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-no-audits-1.png deleted file mode 100644 index 1601567f2c..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-audits-feature-no-audits-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-certified-icon-mobile-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-certified-icon-mobile-1.png deleted file mode 100644 index 9d405d4a33..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-certified-icon-mobile-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-proxy-address-alert-mobile-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-proxy-address-alert-mobile-1.png deleted file mode 100644 index a73642ee31..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-proxy-address-alert-mobile-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-twin-address-alert-mobile-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-twin-address-alert-mobile-1.png deleted file mode 100644 index 39196cf219..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_with-twin-address-alert-mobile-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_zkSync-contract-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_zkSync-contract-1.png deleted file mode 100644 index 62b1522862..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_zkSync-contract-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_full-view-mobile-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_full-view-mobile-dark-mode-1.png deleted file mode 100644 index 3f5ad28058..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_full-view-mobile-dark-mode-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_with-certified-icon-mobile-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_with-certified-icon-mobile-1.png deleted file mode 100644 index 964b95edd1..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_with-certified-icon-mobile-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_with-proxy-address-alert-mobile-1.png b/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_with-proxy-address-alert-mobile-1.png deleted file mode 100644 index 5cccb20269..0000000000 Binary files a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_with-proxy-address-alert-mobile-1.png and /dev/null differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_dark-color-mode_full-view-source-code-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_dark-color-mode_full-view-source-code-dark-mode-1.png new file mode 100644 index 0000000000..d5b3fd8417 Binary files /dev/null and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_dark-color-mode_full-view-source-code-dark-mode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-abi-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-abi-1.png new file mode 100644 index 0000000000..e95cf672f7 Binary files /dev/null and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-abi-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-bytecode-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-bytecode-1.png new file mode 100644 index 0000000000..d23ffb336f Binary files /dev/null and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-bytecode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-compiler-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-compiler-1.png new file mode 100644 index 0000000000..86101f81ab Binary files /dev/null and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-compiler-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-source-code-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-source-code-dark-mode-1.png new file mode 100644 index 0000000000..eb47124dde Binary files /dev/null and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_full-view-source-code-dark-mode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_implementation-info-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_implementation-info-1.png new file mode 100644 index 0000000000..89a3ba5a9f Binary files /dev/null and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_implementation-info-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_mobile-view-source-code-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_mobile-view-source-code-1.png new file mode 100644 index 0000000000..58edaeecc6 Binary files /dev/null and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_mobile-view-source-code-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_non-verified-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_non-verified-1.png similarity index 100% rename from ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_non-verified-1.png rename to ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_non-verified-1.png diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_self-destructed-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_self-destructed-1.png similarity index 100% rename from ui/address/contract/__screenshots__/ContractCode.pw.tsx_default_self-destructed-1.png rename to ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_self-destructed-1.png diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_verified-with-multiple-sources-1.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_verified-with-multiple-sources-1.png new file mode 100644 index 0000000000..ff1e2c3f22 Binary files /dev/null and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_verified-with-multiple-sources-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_verified-with-multiple-sources-2.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_verified-with-multiple-sources-2.png new file mode 100644 index 0000000000..f8797dce50 Binary files /dev/null and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_verified-with-multiple-sources-2.png differ diff --git a/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_verified-with-multiple-sources-3.png b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_verified-with-multiple-sources-3.png new file mode 100644 index 0000000000..a858f0dd54 Binary files /dev/null and b/ui/address/contract/__screenshots__/ContractDetails.pw.tsx_default_verified-with-multiple-sources-3.png differ diff --git a/ui/address/contract/ContractCodeProxyPattern.pw.tsx b/ui/address/contract/alerts/ContractDetailsAlertProxyPattern.pw.tsx similarity index 54% rename from ui/address/contract/ContractCodeProxyPattern.pw.tsx rename to ui/address/contract/alerts/ContractDetailsAlertProxyPattern.pw.tsx index a2e97768c0..e6e4134db4 100644 --- a/ui/address/contract/ContractCodeProxyPattern.pw.tsx +++ b/ui/address/contract/alerts/ContractDetailsAlertProxyPattern.pw.tsx @@ -2,19 +2,19 @@ import React from 'react'; import { test, expect } from 'playwright/lib'; -import ContractCodeProxyPattern from './ContractCodeProxyPattern'; +import ContractDetailsAlertProxyPattern from './ContractDetailsAlertProxyPattern'; test('proxy type with link +@mobile', async({ render }) => { - const component = await render(); + const component = await render(); await expect(component).toHaveScreenshot(); }); test('proxy type with link but without description', async({ render }) => { - const component = await render(); + const component = await render(); await expect(component).toHaveScreenshot(); }); test('proxy type without link', async({ render }) => { - const component = await render(); + const component = await render(); await expect(component).toHaveScreenshot(); }); diff --git a/ui/address/contract/ContractCodeProxyPattern.tsx b/ui/address/contract/alerts/ContractDetailsAlertProxyPattern.tsx similarity index 100% rename from ui/address/contract/ContractCodeProxyPattern.tsx rename to ui/address/contract/alerts/ContractDetailsAlertProxyPattern.tsx diff --git a/ui/address/contract/alerts/ContractDetailsAlertVerificationSource.tsx b/ui/address/contract/alerts/ContractDetailsAlertVerificationSource.tsx new file mode 100644 index 0000000000..292a1f7b7e --- /dev/null +++ b/ui/address/contract/alerts/ContractDetailsAlertVerificationSource.tsx @@ -0,0 +1,39 @@ +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 ( + + This contract has been { data.is_partially_verified ? 'partially ' : '' }verified using + + Blockscout Bytecode Database + + + ); + } + + if (data?.is_verified_via_sourcify) { + return ( + + This contract has been { data.is_partially_verified ? 'partially ' : '' }verified via Sourcify. + { data.sourcify_repo_url && View contract in Sourcify repository } + + ); + } + + return null; +}; + +export default React.memo(ContractDetailsAlertVerificationSource); diff --git a/ui/address/contract/alerts/ContractDetailsAlerts.pw.tsx b/ui/address/contract/alerts/ContractDetailsAlerts.pw.tsx new file mode 100644 index 0000000000..52fd35fe4c --- /dev/null +++ b/ui/address/contract/alerts/ContractDetailsAlerts.pw.tsx @@ -0,0 +1,59 @@ +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(, 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(, 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(, 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(, undefined, { withSocket: true }); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/address/contract/alerts/ContractDetailsAlerts.pwstory.tsx b/ui/address/contract/alerts/ContractDetailsAlerts.pwstory.tsx new file mode 100644 index 0000000000..e7fea90322 --- /dev/null +++ b/ui/address/contract/alerts/ContractDetailsAlerts.pwstory.tsx @@ -0,0 +1,17 @@ +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 ; +}; + +export default ContractDetailsAlertsPwStory; diff --git a/ui/address/contract/alerts/ContractDetailsAlerts.tsx b/ui/address/contract/alerts/ContractDetailsAlerts.tsx new file mode 100644 index 0000000000..ed040c94a1 --- /dev/null +++ b/ui/address/contract/alerts/ContractDetailsAlerts.tsx @@ -0,0 +1,92 @@ +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(); + + const handleChangedBytecodeMessage: SocketMessage.AddressChangedBytecode['handler'] = React.useCallback(() => { + setIsChangedBytecodeSocket(true); + }, [ ]); + + useSocketMessage({ + channel, + event: 'changed_bytecode', + handler: handleChangedBytecodeMessage, + }); + + return ( + + { data?.is_blueprint && ( + + This is an + + ERC-5202 Blueprint contract + + + ) } + { data?.is_verified && ( + + + Contract Source Code Verified ({ data.is_partially_verified ? 'Partial' : 'Exact' } Match) + { + data.is_partially_verified ? ( + + ) : null + } + + + ) } + + { (data?.is_changed_bytecode || isChangedBytecodeSocket) && ( + + Warning! Contract bytecode has been changed and does not match the verified one. Therefore, interaction with this smart contract may be risky. + + ) } + { !data?.is_verified && data?.verified_twin_address_hash && (!data?.proxy_type || data.proxy_type === 'unknown') && ( + + Contract is not verified. However, we found a verified contract with the same bytecode in Blockscout DB + + All functions displayed below are from ABI of that contract. In order to verify current contract, proceed with + + Verify & Publish + + page + + ) } + { data?.proxy_type && } + + ); +}; + +export default React.memo(ContractDetailsAlerts); diff --git a/ui/address/contract/__screenshots__/ContractCodeProxyPattern.pw.tsx_default_proxy-type-with-link-but-without-description-1.png b/ui/address/contract/alerts/__screenshots__/ContractDetailsAlertProxyPattern.pw.tsx_default_proxy-type-with-link-but-without-description-1.png similarity index 100% rename from ui/address/contract/__screenshots__/ContractCodeProxyPattern.pw.tsx_default_proxy-type-with-link-but-without-description-1.png rename to ui/address/contract/alerts/__screenshots__/ContractDetailsAlertProxyPattern.pw.tsx_default_proxy-type-with-link-but-without-description-1.png diff --git a/ui/address/contract/__screenshots__/ContractCodeProxyPattern.pw.tsx_default_proxy-type-with-link-mobile-1.png b/ui/address/contract/alerts/__screenshots__/ContractDetailsAlertProxyPattern.pw.tsx_default_proxy-type-with-link-mobile-1.png similarity index 100% rename from ui/address/contract/__screenshots__/ContractCodeProxyPattern.pw.tsx_default_proxy-type-with-link-mobile-1.png rename to ui/address/contract/alerts/__screenshots__/ContractDetailsAlertProxyPattern.pw.tsx_default_proxy-type-with-link-mobile-1.png diff --git a/ui/address/contract/__screenshots__/ContractCodeProxyPattern.pw.tsx_default_proxy-type-without-link-1.png b/ui/address/contract/alerts/__screenshots__/ContractDetailsAlertProxyPattern.pw.tsx_default_proxy-type-without-link-1.png similarity index 100% rename from ui/address/contract/__screenshots__/ContractCodeProxyPattern.pw.tsx_default_proxy-type-without-link-1.png rename to ui/address/contract/alerts/__screenshots__/ContractDetailsAlertProxyPattern.pw.tsx_default_proxy-type-without-link-1.png diff --git a/ui/address/contract/__screenshots__/ContractCodeProxyPattern.pw.tsx_mobile_proxy-type-with-link-mobile-1.png b/ui/address/contract/alerts/__screenshots__/ContractDetailsAlertProxyPattern.pw.tsx_mobile_proxy-type-with-link-mobile-1.png similarity index 100% rename from ui/address/contract/__screenshots__/ContractCodeProxyPattern.pw.tsx_mobile_proxy-type-with-link-mobile-1.png rename to ui/address/contract/alerts/__screenshots__/ContractDetailsAlertProxyPattern.pw.tsx_mobile_proxy-type-with-link-mobile-1.png diff --git a/ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_default_verified-via-eth-bytecode-db-1.png b/ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_default_verified-via-eth-bytecode-db-1.png new file mode 100644 index 0000000000..26e63d6237 Binary files /dev/null and b/ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_default_verified-via-eth-bytecode-db-1.png differ diff --git a/ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_default_verified-via-sourcify-1.png b/ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_default_verified-via-sourcify-1.png new file mode 100644 index 0000000000..8b724d0b27 Binary files /dev/null and b/ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_default_verified-via-sourcify-1.png differ diff --git a/ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_default_verified-with-changed-byte-code-socket-1.png b/ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_default_verified-with-changed-byte-code-socket-1.png new file mode 100644 index 0000000000..72e319a01d Binary files /dev/null and b/ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_default_verified-with-changed-byte-code-socket-1.png differ diff --git a/ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_default_with-twin-address-alert-mobile-1.png b/ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_default_with-twin-address-alert-mobile-1.png new file mode 100644 index 0000000000..ad8070659c Binary files /dev/null and b/ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_default_with-twin-address-alert-mobile-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_with-twin-address-alert-mobile-1.png b/ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_mobile_with-twin-address-alert-mobile-1.png similarity index 100% rename from ui/address/contract/__screenshots__/ContractCode.pw.tsx_mobile_with-twin-address-alert-mobile-1.png rename to ui/address/contract/alerts/__screenshots__/ContractDetailsAlerts.pw.tsx_mobile_with-twin-address-alert-mobile-1.png diff --git a/ui/address/contract/ContractSecurityAudits.tsx b/ui/address/contract/audits/ContractSecurityAudits.tsx similarity index 95% rename from ui/address/contract/ContractSecurityAudits.tsx rename to ui/address/contract/audits/ContractSecurityAudits.tsx index ce6e2fc9b3..983ddb9bb6 100644 --- a/ui/address/contract/ContractSecurityAudits.tsx +++ b/ui/address/contract/audits/ContractSecurityAudits.tsx @@ -9,7 +9,7 @@ import ContainerWithScrollY from 'ui/shared/ContainerWithScrollY'; import FormModal from 'ui/shared/FormModal'; import LinkExternal from 'ui/shared/links/LinkExternal'; -import ContractSubmitAuditForm from './contractSubmitAuditForm/ContractSubmitAuditForm'; +import ContractSubmitAuditForm from './ContractSubmitAuditForm'; type Props = { addressHash?: string; diff --git a/ui/address/contract/contractSubmitAuditForm/ContractSubmitAuditForm.pw.tsx b/ui/address/contract/audits/ContractSubmitAuditForm.pw.tsx similarity index 100% rename from ui/address/contract/contractSubmitAuditForm/ContractSubmitAuditForm.pw.tsx rename to ui/address/contract/audits/ContractSubmitAuditForm.pw.tsx diff --git a/ui/address/contract/contractSubmitAuditForm/ContractSubmitAuditForm.tsx b/ui/address/contract/audits/ContractSubmitAuditForm.tsx similarity index 100% rename from ui/address/contract/contractSubmitAuditForm/ContractSubmitAuditForm.tsx rename to ui/address/contract/audits/ContractSubmitAuditForm.tsx diff --git a/ui/address/contract/contractSubmitAuditForm/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png b/ui/address/contract/audits/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png similarity index 100% rename from ui/address/contract/contractSubmitAuditForm/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png rename to ui/address/contract/audits/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png diff --git a/ui/address/contract/contractSubmitAuditForm/fields/AuditComment.tsx b/ui/address/contract/audits/fields/AuditComment.tsx similarity index 100% rename from ui/address/contract/contractSubmitAuditForm/fields/AuditComment.tsx rename to ui/address/contract/audits/fields/AuditComment.tsx diff --git a/ui/address/contract/contractSubmitAuditForm/fields/AuditCompanyName.tsx b/ui/address/contract/audits/fields/AuditCompanyName.tsx similarity index 100% rename from ui/address/contract/contractSubmitAuditForm/fields/AuditCompanyName.tsx rename to ui/address/contract/audits/fields/AuditCompanyName.tsx diff --git a/ui/address/contract/contractSubmitAuditForm/fields/AuditProjectName.tsx b/ui/address/contract/audits/fields/AuditProjectName.tsx similarity index 100% rename from ui/address/contract/contractSubmitAuditForm/fields/AuditProjectName.tsx rename to ui/address/contract/audits/fields/AuditProjectName.tsx diff --git a/ui/address/contract/contractSubmitAuditForm/fields/AuditProjectUrl.tsx b/ui/address/contract/audits/fields/AuditProjectUrl.tsx similarity index 100% rename from ui/address/contract/contractSubmitAuditForm/fields/AuditProjectUrl.tsx rename to ui/address/contract/audits/fields/AuditProjectUrl.tsx diff --git a/ui/address/contract/contractSubmitAuditForm/fields/AuditReportDate.tsx b/ui/address/contract/audits/fields/AuditReportDate.tsx similarity index 100% rename from ui/address/contract/contractSubmitAuditForm/fields/AuditReportDate.tsx rename to ui/address/contract/audits/fields/AuditReportDate.tsx diff --git a/ui/address/contract/contractSubmitAuditForm/fields/AuditReportUrl.tsx b/ui/address/contract/audits/fields/AuditReportUrl.tsx similarity index 100% rename from ui/address/contract/contractSubmitAuditForm/fields/AuditReportUrl.tsx rename to ui/address/contract/audits/fields/AuditReportUrl.tsx diff --git a/ui/address/contract/contractSubmitAuditForm/fields/AuditSubmitterEmail.tsx b/ui/address/contract/audits/fields/AuditSubmitterEmail.tsx similarity index 100% rename from ui/address/contract/contractSubmitAuditForm/fields/AuditSubmitterEmail.tsx rename to ui/address/contract/audits/fields/AuditSubmitterEmail.tsx diff --git a/ui/address/contract/contractSubmitAuditForm/fields/AuditSubmitterIsOwner.tsx b/ui/address/contract/audits/fields/AuditSubmitterIsOwner.tsx similarity index 100% rename from ui/address/contract/contractSubmitAuditForm/fields/AuditSubmitterIsOwner.tsx rename to ui/address/contract/audits/fields/AuditSubmitterIsOwner.tsx diff --git a/ui/address/contract/contractSubmitAuditForm/fields/AuditSubmitterName.tsx b/ui/address/contract/audits/fields/AuditSubmitterName.tsx similarity index 100% rename from ui/address/contract/contractSubmitAuditForm/fields/AuditSubmitterName.tsx rename to ui/address/contract/audits/fields/AuditSubmitterName.tsx diff --git a/ui/address/contract/info/ContractDetailsInfo.pw.tsx b/ui/address/contract/info/ContractDetailsInfo.pw.tsx new file mode 100644 index 0000000000..273c62aaba --- /dev/null +++ b/ui/address/contract/info/ContractDetailsInfo.pw.tsx @@ -0,0 +1,63 @@ +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(); + + 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(); + + 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(); + + 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(); + + await expect(component).toHaveScreenshot(); + }); +}); diff --git a/ui/address/contract/info/ContractDetailsInfo.tsx b/ui/address/contract/info/ContractDetailsInfo.tsx new file mode 100644 index 0000000000..ef35d0a320 --- /dev/null +++ b/ui/address/contract/info/ContractDetailsInfo.tsx @@ -0,0 +1,128 @@ +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 ? ( + + { data.name } + { data.certified && } + + ) : 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 ( + + { license.label } + + ); + })(); + + return ( + + { data.name && ( + + ) } + { data.compiler_version && ( + + ) } + { data.zk_compiler_version && ( + + ) } + { data.evm_version && ( + + ) } + { licenseLink && ( + + ) } + { typeof data.optimization_enabled === 'boolean' && ( + + ) } + { data.optimization_runs !== null && ( + + ) } + { data.verified_at && ( + + ) } + { data.file_path && ( + + ) } + { config.UI.hasContractAuditReports && ( + } + isLoading={ isLoading } + /> + ) } + + ); +}; + +export default React.memo(ContractDetailsInfo); diff --git a/ui/address/contract/info/ContractDetailsInfoItem.tsx b/ui/address/contract/info/ContractDetailsInfoItem.tsx new file mode 100644 index 0000000000..ab4e0f10ce --- /dev/null +++ b/ui/address/contract/info/ContractDetailsInfoItem.tsx @@ -0,0 +1,36 @@ +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 ( + + + + { label } + { hint && ( + + ) } + + + { content } + + ); +}; + +export default React.memo(chakra(ContractDetailsInfoItem)); diff --git a/ui/address/contract/info/__screenshots__/ContractDetailsInfo.pw.tsx_default_with-audits-feature-has-audits-1.png b/ui/address/contract/info/__screenshots__/ContractDetailsInfo.pw.tsx_default_with-audits-feature-has-audits-1.png new file mode 100644 index 0000000000..5fe6b22040 Binary files /dev/null and b/ui/address/contract/info/__screenshots__/ContractDetailsInfo.pw.tsx_default_with-audits-feature-has-audits-1.png differ diff --git a/ui/address/contract/info/__screenshots__/ContractDetailsInfo.pw.tsx_default_with-audits-feature-no-audits-1.png b/ui/address/contract/info/__screenshots__/ContractDetailsInfo.pw.tsx_default_with-audits-feature-no-audits-1.png new file mode 100644 index 0000000000..6a379d6805 Binary files /dev/null and b/ui/address/contract/info/__screenshots__/ContractDetailsInfo.pw.tsx_default_with-audits-feature-no-audits-1.png differ diff --git a/ui/address/contract/info/__screenshots__/ContractDetailsInfo.pw.tsx_default_with-certified-icon-1.png b/ui/address/contract/info/__screenshots__/ContractDetailsInfo.pw.tsx_default_with-certified-icon-1.png new file mode 100644 index 0000000000..3fbbad4733 Binary files /dev/null and b/ui/address/contract/info/__screenshots__/ContractDetailsInfo.pw.tsx_default_with-certified-icon-1.png differ diff --git a/ui/address/contract/info/__screenshots__/ContractDetailsInfo.pw.tsx_default_zkSync-contract-1.png b/ui/address/contract/info/__screenshots__/ContractDetailsInfo.pw.tsx_default_zkSync-contract-1.png new file mode 100644 index 0000000000..184d32de77 Binary files /dev/null and b/ui/address/contract/info/__screenshots__/ContractDetailsInfo.pw.tsx_default_zkSync-contract-1.png differ diff --git a/ui/address/contract/methods/ContractMethodsMudSystem.tsx b/ui/address/contract/methods/ContractMethodsMudSystem.tsx index ff8ef75c2b..87515bfa80 100644 --- a/ui/address/contract/methods/ContractMethodsMudSystem.tsx +++ b/ui/address/contract/methods/ContractMethodsMudSystem.tsx @@ -7,10 +7,10 @@ import type { SmartContractMudSystemItem } from 'types/api/contract'; import useApiQuery from 'lib/api/useApiQuery'; import getQueryParamString from 'lib/router/getQueryParamString'; +import type { Item } from '../ContractSourceAddressSelector'; +import ContractSourceAddressSelector from '../ContractSourceAddressSelector'; import ContractConnectWallet from './ContractConnectWallet'; import ContractMethods from './ContractMethods'; -import type { Item } from './ContractSourceAddressSelector'; -import ContractSourceAddressSelector from './ContractSourceAddressSelector'; import { enrichWithMethodId, isMethod } from './utils'; interface Props { @@ -22,9 +22,9 @@ const ContractMethodsMudSystem = ({ items }: Props) => { const router = useRouter(); const addressHash = getQueryParamString(router.query.hash); - const contractAddress = getQueryParamString(router.query.source_address); + const sourceAddress = getQueryParamString(router.query.source_address); - const [ selectedItem, setSelectedItem ] = React.useState(items.find((item) => item.address === contractAddress) || items[0]); + const [ selectedItem, setSelectedItem ] = React.useState(items.find((item) => item.address === sourceAddress) || items[0]); const systemInfoQuery = useApiQuery('contract_mud_system_info', { pathParams: { hash: addressHash, system_address: selectedItem.address }, @@ -52,6 +52,7 @@ const ContractMethodsMudSystem = ({ items }: Props) => { selectedItem={ selectedItem } onItemSelect={ handleItemSelect } label="System address" + mb={ 6 } /> void; + isDisabled?: boolean; +} + +const ContractMethodAddressButton = ({ onClick, isDisabled }: Props) => { + const { address } = useAccount(); + + const handleClick = React.useCallback(() => { + address && onClick(address); + }, [ address, onClick ]); + + return ( + + + + ); +}; + +export default React.memo(ContractMethodAddressButton); diff --git a/ui/address/contract/methods/form/ContractMethodFieldInput.tsx b/ui/address/contract/methods/form/ContractMethodFieldInput.tsx index 118f5be8b4..0c30c36262 100644 --- a/ui/address/contract/methods/form/ContractMethodFieldInput.tsx +++ b/ui/address/contract/methods/form/ContractMethodFieldInput.tsx @@ -1,18 +1,22 @@ -import { Box, Flex, FormControl, Input, InputGroup, InputRightElement, chakra, useColorModeValue } from '@chakra-ui/react'; +import { Box, Button, Flex, FormControl, Input, InputGroup, InputRightElement, chakra, useColorModeValue } from '@chakra-ui/react'; import React from 'react'; import { useController, useFormContext } from 'react-hook-form'; import { NumericFormat } from 'react-number-format'; import type { ContractAbiItemInput } from '../types'; +import { HOUR, SECOND } from 'lib/consts'; import ClearButton from 'ui/shared/ClearButton'; +import ContractMethodAddressButton from './ContractMethodAddressButton'; import ContractMethodFieldLabel from './ContractMethodFieldLabel'; import ContractMethodMultiplyButton from './ContractMethodMultiplyButton'; import useFormatFieldValue from './useFormatFieldValue'; import useValidateField from './useValidateField'; import { matchInt } from './utils'; +const TIMESTAMP_BUTTON_REGEXP = /time|deadline|expiration|expiry/i; + interface Props { data: ContractAbiItemInput; hideLabel?: boolean; @@ -30,6 +34,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi const isOptional = isOptionalProp || isNativeCoin; const argTypeMatchInt = React.useMemo(() => matchInt(data.type), [ data.type ]); + const hasTimestampButton = React.useMemo(() => TIMESTAMP_BUTTON_REGEXP.test(data.name || ''), [ data.name ]); const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt }); const format = useFormatFieldValue({ argType: data.type, argTypeMatchInt }); @@ -56,9 +61,28 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi const zeroes = Array(power).fill('0').join(''); const value = getValues(name); const newValue = format(value ? value + zeroes : '1' + zeroes); - setValue(name, newValue); + setValue(name, newValue, { shouldValidate: true }); }, [ format, getValues, name, setValue ]); + const handleMaxIntButtonClick = React.useCallback(() => { + if (!argTypeMatchInt) { + return; + } + + const newValue = format(argTypeMatchInt.max.toString()); + setValue(name, newValue, { shouldValidate: true }); + }, [ format, name, setValue, argTypeMatchInt ]); + + const handleAddressButtonClick = React.useCallback((address: string) => { + const newValue = format(address); + setValue(name, newValue, { shouldValidate: true }); + }, [ format, name, setValue ]); + + const handleTimestampButtonClick = React.useCallback(() => { + const newValue = format(String(Math.floor((Date.now() + HOUR) / SECOND))); + setValue(name, newValue, { shouldValidate: true }); + }, [ format, name, setValue ]); + const error = fieldState.error; return ( @@ -90,11 +114,40 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi isInvalid={ Boolean(error) } placeholder={ data.type } autoComplete="off" + data-1p-ignore bgColor={ inputBgColor } paddingRight={ hasMultiplyButton ? '120px' : '40px' } /> - + { field.value !== undefined && field.value !== '' && } + { data.type === 'address' && } + { argTypeMatchInt && (hasTimestampButton ? ( + + ) : ( + + )) } { hasMultiplyButton && } diff --git a/ui/address/contract/methods/form/ContractMethodForm.pw.tsx b/ui/address/contract/methods/form/ContractMethodForm.pw.tsx index 5f27845759..d385156328 100644 --- a/ui/address/contract/methods/form/ContractMethodForm.pw.tsx +++ b/ui/address/contract/methods/form/ContractMethodForm.pw.tsx @@ -62,7 +62,7 @@ const data: SmartContractMethod = { // LITERALS { internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' }, { internalType: 'address', name: 'recipient', type: 'address' }, - { internalType: 'uint256', name: 'maximumFulfilled', type: 'uint256' }, + { internalType: 'uint256', name: 'startTime', type: 'uint256' }, { internalType: 'int8[]', name: 'criteriaProof', type: 'int8[]' }, ], method_id: '87201b41', diff --git a/ui/address/contract/methods/form/ContractMethodForm.tsx b/ui/address/contract/methods/form/ContractMethodForm.tsx index f63c8a9ba5..fba5cd0b38 100644 --- a/ui/address/contract/methods/form/ContractMethodForm.tsx +++ b/ui/address/contract/methods/form/ContractMethodForm.tsx @@ -1,12 +1,13 @@ -import { Box, Button, Flex, Tooltip, chakra } from '@chakra-ui/react'; +import { Box, Button, Flex, Tooltip, chakra, useDisclosure } from '@chakra-ui/react'; import React from 'react'; import type { SubmitHandler } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form'; -import type { AbiFunction } from 'viem'; +import { encodeFunctionData, type AbiFunction } from 'viem'; import type { FormSubmitHandler, FormSubmitResult, MethodCallStrategy, SmartContractMethod } from '../types'; import config from 'configs/app'; +import { SECOND } from 'lib/consts'; import * as mixpanel from 'lib/mixpanel/index'; import IconSvg from 'ui/shared/IconSvg'; @@ -43,17 +44,42 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props) shouldUnregister: true, }); + const calldataButtonTooltip = useDisclosure(); + const handleButtonClick = React.useCallback((event: React.MouseEvent) => { const callStrategy = event?.currentTarget.getAttribute('data-call-strategy'); setCallStrategy(callStrategy as MethodCallStrategy); callStrategyRef.current = callStrategy as MethodCallStrategy; - }, []); + + if (callStrategy === 'copy_calldata') { + calldataButtonTooltip.onOpen(); + window.setTimeout(() => { + calldataButtonTooltip.onClose(); + }, SECOND); + } + }, [ calldataButtonTooltip ]); const methodType = isReadMethod(data) ? 'read' : 'write'; const onFormSubmit: SubmitHandler = React.useCallback(async(formData) => { const args = transformFormDataToMethodArgs(formData); + if (callStrategyRef.current === 'copy_calldata') { + if (!('name' in data) || !data.name) { + return; + } + + const callData = encodeFunctionData({ + abi: [ data ], + functionName: data.name, + // since we have added additional input for native coin value + // we need to slice it off + args: args.slice(0, data.inputs.length), + }); + await navigator.clipboard.writeText(callData); + return; + } + setResult(undefined); setLoading(true); @@ -166,6 +192,50 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props) ); })(); + const copyCallDataButton = (() => { + if (inputs.length === 0) { + return null; + } + + if (inputs.length === 1) { + const [ input ] = inputs; + if ('fieldType' in input && input.fieldType === 'native_coin') { + return null; + } + } + + const text = 'Copy calldata'; + const buttonCallStrategy = 'copy_calldata'; + const isDisabled = isLoading || !formApi.formState.isValid; + + return ( + + + + ); + })(); + return ( @@ -214,6 +284,7 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props) { secondaryButton } { primaryButton } + { copyCallDataButton } { result && !isLoading && ( ) } diff --git a/ui/address/contract/methods/form/ContractMethodMultiplyButton.tsx b/ui/address/contract/methods/form/ContractMethodMultiplyButton.tsx index 7c443996b4..4bd208d43d 100644 --- a/ui/address/contract/methods/form/ContractMethodMultiplyButton.tsx +++ b/ui/address/contract/methods/form/ContractMethodMultiplyButton.tsx @@ -9,6 +9,7 @@ import { ListItem, useDisclosure, Input, + useColorModeValue, } from '@chakra-ui/react'; import React from 'react'; @@ -26,6 +27,8 @@ const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => { const [ customValue, setCustomValue ] = React.useState(); const { isOpen, onToggle, onClose } = useDisclosure(); + const dividerColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200'); + const handleOptionClick = React.useCallback((event: React.MouseEvent) => { const id = Number((event.currentTarget as HTMLDivElement).getAttribute('data-id')); if (!Object.is(id, NaN)) { @@ -60,6 +63,8 @@ const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => { display="inline" onClick={ handleButtonClick } isDisabled={ isDisabled } + borderBottomRightRadius={ 0 } + borderTopRightRadius={ 0 } > { times } 10 @@ -73,11 +78,14 @@ const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => { colorScheme="gray" size="xs" cursor="pointer" - ml={ 1 } p={ 0 } onClick={ onToggle } isActive={ isOpen } isDisabled={ isDisabled } + borderBottomLeftRadius={ 0 } + borderTopLeftRadius={ 0 } + borderLeftWidth="1px" + borderLeftColor={ dividerColor } > { +import useContractTabs from '../useContractTabs'; + +const ContractDetails = () => { const router = useRouter(); const hash = getQueryParamString(router.query.hash); const addressQuery = useApiQuery('address', { pathParams: { hash } }); @@ -13,4 +14,4 @@ const ContractCode = () => { return content ?? null; }; -export default ContractCode; +export default ContractDetails; diff --git a/ui/address/contract/useContractDetailsTabs.tsx b/ui/address/contract/useContractDetailsTabs.tsx new file mode 100644 index 0000000000..89dbf2c474 --- /dev/null +++ b/ui/address/contract/useContractDetailsTabs.tsx @@ -0,0 +1,156 @@ +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 { + + 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' ? ( + + ) : { value }; + return ( + + Arg [{ index }] { name || '' } ({ type }): + { valueEl } + + ); + }); + + return ( + <> + { data.constructor_args } +

+ { decoded } + + ); + }, [ data?.decoded_constructor_args, data?.constructor_args ]); + + const canBeVerified = !data?.is_self_destructed && !data?.is_verified; + + return React.useMemo(() => { + const verificationButton = ( + + ); + + return [ + (constructorArgs || data?.source_code) ? { + id: 'contract_source_code' as const, + title: 'Code', + component: ( + + { constructorArgs && ( + + ) } + { data?.source_code && ( + + ) } + + ), + } : undefined, + + data?.compiler_settings ? { + id: 'contract_compiler' as const, + title: 'Compiler', + component: ( + + ), + } : undefined, + + data?.abi ? { + id: 'contract_abi' as const, + title: 'ABI', + component: ( + + ), + } : undefined, + + (data?.creation_bytecode || data?.deployed_bytecode) ? { + id: 'contract_bytecode' as const, + title: 'ByteCode', + component: ( + + { data?.creation_bytecode && ( + + 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. + + ) : null } + textareaMaxHeight="300px" + isLoading={ isLoading } + /> + ) } + { data?.deployed_bytecode && ( + + ) } + + ), + } : undefined, + ].filter(Boolean); + }, [ isLoading, addressHash, data, constructorArgs, sourceAddress, canBeVerified ]); +} diff --git a/lib/hooks/useContractTabs.tsx b/ui/address/contract/useContractTabs.tsx similarity index 92% rename from lib/hooks/useContractTabs.tsx rename to ui/address/contract/useContractTabs.tsx index b6408ee7dc..91f2f718d8 100644 --- a/lib/hooks/useContractTabs.tsx +++ b/ui/address/contract/useContractTabs.tsx @@ -8,7 +8,7 @@ import * as cookies from 'lib/cookies'; import getQueryParamString from 'lib/router/getQueryParamString'; import useSocketChannel from 'lib/socket/useSocketChannel'; 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 ContractMethodsMudSystem from 'ui/address/contract/methods/ContractMethodsMudSystem'; import ContractMethodsProxy from 'ui/address/contract/methods/ContractMethodsProxy'; @@ -16,23 +16,14 @@ import ContractMethodsRegular from 'ui/address/contract/methods/ContractMethodsR import { divideAbiIntoMethodTypes } from 'ui/address/contract/methods/utils'; import ContentLoader from 'ui/shared/ContentLoader'; -const CONTRACT_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; +import type { CONTRACT_MAIN_TAB_IDS } from './utils'; +import { CONTRACT_DETAILS_TAB_IDS, CONTRACT_TAB_IDS } from './utils'; interface ContractTab { - id: typeof CONTRACT_TAB_IDS[number]; + id: typeof CONTRACT_MAIN_TAB_IDS[number]; title: string; component: JSX.Element; + subTabs?: Array; } interface ReturnType { @@ -101,10 +92,11 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder return React.useMemo(() => { return { tabs: [ - { + data?.hash && { id: 'contract_code' as const, title: 'Code', - component: , + component: , + subTabs: CONTRACT_DETAILS_TAB_IDS as unknown as Array, }, methods.read.length > 0 && { id: 'read_contract' as const, diff --git a/ui/address/contract/utils.ts b/ui/address/contract/utils.ts new file mode 100644 index 0000000000..a489229497 --- /dev/null +++ b/ui/address/contract/utils.ts @@ -0,0 +1,21 @@ +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).concat(CONTRACT_DETAILS_TAB_IDS as unknown as Array); diff --git a/ui/pages/Address.tsx b/ui/pages/Address.tsx index 191f77e607..854a364e63 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -11,7 +11,6 @@ import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery import useApiQuery from 'lib/api/useApiQuery'; import { useAppContext } from 'lib/contexts/app'; import useAddressProfileApiQuery from 'lib/hooks/useAddressProfileApiQuery'; -import useContractTabs from 'lib/hooks/useContractTabs'; import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText'; import getQueryParamString from 'lib/router/getQueryParamString'; @@ -33,6 +32,8 @@ import AddressTokenTransfers from 'ui/address/AddressTokenTransfers'; import AddressTxs from 'ui/address/AddressTxs'; import AddressUserOps from 'ui/address/AddressUserOps'; 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 AddressMetadataAlert from 'ui/address/details/AddressMetadataAlert'; import AddressQrCode from 'ui/address/details/AddressQrCode'; @@ -249,7 +250,7 @@ const AddressPageContent = () => { isLoading={ contractTabs.isLoading } /> ), - subTabs: contractTabs.tabs.map(tab => tab.id), + subTabs: CONTRACT_TAB_IDS, } : undefined, ].filter(Boolean); }, [ diff --git a/ui/pages/Token.tsx b/ui/pages/Token.tsx index 9c1e1d415f..9c3fc28b88 100644 --- a/ui/pages/Token.tsx +++ b/ui/pages/Token.tsx @@ -10,7 +10,6 @@ import type { RoutedTab } from 'ui/shared/Tabs/types'; import config from 'configs/app'; import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery'; -import useContractTabs from 'lib/hooks/useContractTabs'; import useIsMobile from 'lib/hooks/useIsMobile'; import * as metadata from 'lib/metadata'; import getQueryParamString from 'lib/router/getQueryParamString'; @@ -23,6 +22,8 @@ import { getTokenHoldersStub } from 'stubs/token'; import { generateListStub } from 'stubs/utils'; import AddressContract from 'ui/address/AddressContract'; 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 IconSvg from 'ui/shared/IconSvg'; import Pagination from 'ui/shared/pagination/Pagination'; @@ -191,7 +192,7 @@ const TokenPageContent = () => { return 'Contract'; }, component: , - subTabs: contractTabs.tabs.map(tab => tab.id), + subTabs: CONTRACT_TAB_IDS, } : undefined, ].filter(Boolean); diff --git a/ui/shared/Tabs/AdaptiveTabsList.tsx b/ui/shared/Tabs/AdaptiveTabsList.tsx index fbaddf5119..664648dd57 100644 --- a/ui/shared/Tabs/AdaptiveTabsList.tsx +++ b/ui/shared/Tabs/AdaptiveTabsList.tsx @@ -37,7 +37,7 @@ const AdaptiveTabsList = (props: Props) => { return [ ...props.tabs, menuButton ]; }, [ 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); useScrollToActiveTab({ activeTabIndex: props.activeTabIndex, listRef, tabsRefs, isMobile, isLoading: props.isLoading }); @@ -80,6 +80,7 @@ const AdaptiveTabsList = (props: Props) => { props.tabListProps) } > + { props.leftSlot && { props.leftSlot } } { tabsList.slice(0, props.isLoading ? 5 : Infinity).map((tab, index) => { if (!tab.id) { if (props.isLoading) { diff --git a/ui/shared/Tabs/RoutedTabs.tsx b/ui/shared/Tabs/RoutedTabs.tsx index cfc7036bd3..32c98f1f9c 100644 --- a/ui/shared/Tabs/RoutedTabs.tsx +++ b/ui/shared/Tabs/RoutedTabs.tsx @@ -14,13 +14,27 @@ interface Props extends ThemingProps<'Tabs'> { tabListProps?: ChakraProps | (({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => ChakraProps); rightSlot?: React.ReactNode; rightSlotProps?: ChakraProps; + leftSlot?: React.ReactNode; + leftSlotProps?: ChakraProps; stickyEnabled?: boolean; className?: string; onTabChange?: (index: number) => void; 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 tabIndex = useTabIndexFromQuery(tabs); const tabsRef = useRef(null); @@ -59,6 +73,8 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, rightSlotProps, stickyEnabl