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 }