Merge pull request #1940 from blockscout/fe-1860
add certified conract label
@@ -18,7 +18,7 @@ NEXT_PUBLIC_NETWORK_RPC_URL=https://eth-sepolia.public.blastapi.io
|
||||
NEXT_PUBLIC_IS_TESTNET=true
|
||||
|
||||
# api configuration
|
||||
NEXT_PUBLIC_API_HOST=eth-sepolia.blockscout.com
|
||||
NEXT_PUBLIC_API_HOST=eth-sepolia.k8s-dev.blockscout.com
|
||||
NEXT_PUBLIC_API_BASE_PATH=/
|
||||
|
||||
# ui config
|
||||
|
||||
|
Before Width: | Height: | Size: 752 B After Width: | Height: | Size: 752 B |
@@ -51,6 +51,11 @@ export const verified: SmartContract = {
|
||||
minimal_proxy_address_hash: null,
|
||||
};
|
||||
|
||||
export const certified: SmartContract = {
|
||||
...verified,
|
||||
certified: true,
|
||||
};
|
||||
|
||||
export const withMultiplePaths: SmartContract = {
|
||||
...verified,
|
||||
file_path: './simple_storage.sol',
|
||||
|
||||
@@ -35,6 +35,7 @@ export const contract2: VerifiedContract = {
|
||||
watchlist_names: [],
|
||||
ens_domain_name: null,
|
||||
},
|
||||
certified: true,
|
||||
coin_balance: '9078234570352343999',
|
||||
compiler_version: 'v0.3.1+commit.0463ea4c',
|
||||
has_constructor_args: true,
|
||||
|
||||
@@ -96,6 +96,15 @@ export const contract1: SearchResultAddressOrContract = {
|
||||
url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
|
||||
};
|
||||
|
||||
export const contract2: SearchResultAddressOrContract = {
|
||||
address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
|
||||
name: 'Super utko',
|
||||
type: 'contract' as const,
|
||||
is_smart_contract_verified: true,
|
||||
certified: true,
|
||||
url: '/address/0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
|
||||
};
|
||||
|
||||
export const label1: SearchResultLabel = {
|
||||
address: '0xb64a30399f7F6b0C154c2E7Af0a3ec7B0A5b131a',
|
||||
name: 'utko',
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
| "brands/safe"
|
||||
| "brands/solidity_scan"
|
||||
| "burger"
|
||||
| "certified"
|
||||
| "check"
|
||||
| "clock-light"
|
||||
| "clock"
|
||||
@@ -146,7 +147,6 @@
|
||||
| "user_op_slim"
|
||||
| "user_op"
|
||||
| "validator"
|
||||
| "verified_token"
|
||||
| "verified"
|
||||
| "verify-contract"
|
||||
| "wallet"
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface SmartContract {
|
||||
minimal_proxy_address_hash: string | null;
|
||||
language: string | null;
|
||||
license_type: SmartContractLicenseType | null;
|
||||
certified?: boolean;
|
||||
}
|
||||
|
||||
export type SmartContractDecodedConstructorArg = [
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SmartContractLicenseType } from './contract';
|
||||
|
||||
export interface VerifiedContract {
|
||||
address: AddressParam;
|
||||
certified?: boolean;
|
||||
coin_balance: string;
|
||||
compiler_version: string;
|
||||
language: 'vyper' | 'yul' | 'solidity';
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface SearchResultAddressOrContract {
|
||||
name: string | null;
|
||||
address: string;
|
||||
is_smart_contract_verified: boolean;
|
||||
certified?: true;
|
||||
url?: string; // not used by the frontend, we build the url ourselves
|
||||
ens_info?: {
|
||||
address_hash: string;
|
||||
|
||||
@@ -109,6 +109,13 @@ test('with proxy address alert +@mobile', async({ render, mockApiResponse }) =>
|
||||
await expect(component.getByRole('alert')).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test('with certified icon +@mobile', async({ render, mockApiResponse, page }) => {
|
||||
await mockApiResponse('contract', contractMock.certified, { pathParams: { hash: addressMock.contract.hash } });
|
||||
await render(<ContractCode/>, { hooksConfig });
|
||||
|
||||
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 120 } });
|
||||
});
|
||||
|
||||
test('non verified', async({ render, mockApiResponse }) => {
|
||||
await mockApiResponse('contract', contractMock.nonVerified, { pathParams: { hash: addressMock.contract.hash } });
|
||||
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
@@ -195,6 +196,13 @@ const ContractCode = ({ addressHash, contractQuery, channel }: Props) => {
|
||||
return null;
|
||||
})();
|
||||
|
||||
const contractNameWithCertifiedIcon = data?.is_verified ? (
|
||||
<Flex alignItems="center">
|
||||
{ data.name }
|
||||
{ data.certified && <ContractCertifiedLabel iconSize={ 5 } boxSize={ 5 } ml={ 2 }/> }
|
||||
</Flex>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex flexDir="column" rowGap={ 2 } mb={ 6 } _empty={{ display: 'none' }}>
|
||||
@@ -248,7 +256,7 @@ const ContractCode = ({ addressHash, contractQuery, channel }: Props) => {
|
||||
</Flex>
|
||||
{ data?.is_verified && (
|
||||
<Grid templateColumns={{ base: '1fr', lg: '1fr 1fr' }} rowGap={ 4 } columnGap={ 6 } mb={ 8 }>
|
||||
{ data.name && <InfoItem label="Contract name" content={ data.name } isLoading={ isPlaceholderData }/> }
|
||||
{ data.name && <InfoItem label="Contract name" content={ contractNameWithCertifiedIcon } isLoading={ isPlaceholderData }/> }
|
||||
{ data.compiler_version && <InfoItem label="Compiler version" content={ data.compiler_version } isLoading={ isPlaceholderData }/> }
|
||||
{ data.evm_version && <InfoItem label="EVM version" content={ data.evm_version } textTransform="capitalize" isLoading={ isPlaceholderData }/> }
|
||||
{ licenseLink && (
|
||||
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -42,7 +42,7 @@ test('search by address hash +@mobile', async({ render, mockApiResponse }) => {
|
||||
},
|
||||
};
|
||||
const data = {
|
||||
items: [ searchMock.address1 ],
|
||||
items: [ searchMock.address1, searchMock.contract2 ],
|
||||
next_page_params: null,
|
||||
};
|
||||
await mockApiResponse('search', data, { queryParams: { q: searchMock.address1.address } });
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
@@ -11,6 +11,7 @@ import highlightText from 'lib/highlightText';
|
||||
import * as mixpanel from 'lib/mixpanel/index';
|
||||
import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
|
||||
import { ADDRESS_REGEXP } from 'lib/validations/address';
|
||||
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
|
||||
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
|
||||
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
|
||||
@@ -69,7 +70,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
|
||||
textOverflow="ellipsis"
|
||||
/>
|
||||
</LinkInternal>
|
||||
{ data.is_verified_via_admin_panel && <IconSvg name="verified_token" boxSize={ 4 } ml={ 1 } color="green.500"/> }
|
||||
{ data.is_verified_via_admin_panel && <IconSvg name="certified" boxSize={ 4 } ml={ 1 } color="green.500"/> }
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -346,16 +347,21 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
|
||||
const expiresText = data.ens_info?.expiry_date ? ` (expires ${ dayjs(data.ens_info.expiry_date).fromNow() })` : '';
|
||||
|
||||
return addressName ? (
|
||||
<>
|
||||
<span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? xss(addressName) : highlightText(addressName, searchTerm) }}/>
|
||||
{ data.ens_info &&
|
||||
(
|
||||
<Flex alignItems="center">
|
||||
<Text
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? xss(addressName) : highlightText(addressName, searchTerm) }}/>
|
||||
{ data.ens_info && (
|
||||
data.ens_info.names_count > 1 ?
|
||||
<chakra.span color="text_secondary"> ({ data.ens_info.names_count > 39 ? '40+' : `+${ data.ens_info.names_count - 1 }` })</chakra.span> :
|
||||
<chakra.span color="text_secondary">{ expiresText }</chakra.span>
|
||||
)
|
||||
}
|
||||
</>
|
||||
) }
|
||||
</Text>
|
||||
{ data.certified && <ContractCertifiedLabel iconSize={ 5 } boxSize={ 5 } ml={ 1 }/> }
|
||||
</Flex>
|
||||
) :
|
||||
null;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import highlightText from 'lib/highlightText';
|
||||
import * as mixpanel from 'lib/mixpanel/index';
|
||||
import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
|
||||
import { ADDRESS_REGEXP } from 'lib/validations/address';
|
||||
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
|
||||
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
|
||||
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
|
||||
@@ -24,6 +25,7 @@ import LinkExternal from 'ui/shared/LinkExternal';
|
||||
import LinkInternal from 'ui/shared/LinkInternal';
|
||||
import type { SearchResultAppItem } from 'ui/shared/search/utils';
|
||||
import { getItemCategory, searchItemTitles } from 'ui/shared/search/utils';
|
||||
|
||||
interface Props {
|
||||
data: SearchResultItem | SearchResultAppItem;
|
||||
searchTerm: string;
|
||||
@@ -69,7 +71,7 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
|
||||
dangerouslySetInnerHTML={{ __html: highlightText(name, searchTerm) }}
|
||||
/>
|
||||
</LinkInternal>
|
||||
{ data.is_verified_via_admin_panel && <IconSvg name="verified_token" boxSize={ 4 } ml={ 1 } color="green.500"/> }
|
||||
{ data.is_verified_via_admin_panel && <IconSvg name="certified" boxSize={ 4 } ml={ 1 } color="green.500"/> }
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td fontSize="sm" verticalAlign="middle">
|
||||
@@ -128,14 +130,24 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
|
||||
</Td>
|
||||
{ addressName && (
|
||||
<Td colSpan={ 2 } fontSize="sm" verticalAlign="middle">
|
||||
<span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? xss(addressName) : highlightText(addressName, searchTerm) }}/>
|
||||
{ data.ens_info &&
|
||||
(
|
||||
data.ens_info.names_count > 1 ?
|
||||
<chakra.span color="text_secondary"> ({ data.ens_info.names_count > 39 ? '40+' : `+${ data.ens_info.names_count - 1 }` })</chakra.span> :
|
||||
<chakra.span color="text_secondary">{ expiresText }</chakra.span>
|
||||
)
|
||||
}
|
||||
<Flex alignItems="center">
|
||||
<Text
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: shouldHighlightHash ? xss(addressName) : highlightText(addressName, searchTerm) }}/>
|
||||
{ data.ens_info && (
|
||||
data.ens_info.names_count > 1 ? (
|
||||
<chakra.span color="text_secondary">
|
||||
{ data.ens_info.names_count > 39 ? '40+' : `+${ data.ens_info.names_count - 1 }` }
|
||||
</chakra.span>
|
||||
) :
|
||||
<chakra.span color="text_secondary">{ expiresText }</chakra.span>
|
||||
) }
|
||||
</Text>
|
||||
{ data.certified && <ContractCertifiedLabel iconSize={ 5 } boxSize={ 5 } mx={ 1 }/> }
|
||||
</Flex>
|
||||
</Td>
|
||||
) }
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Box, Tooltip, chakra } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import IconSvg from './IconSvg';
|
||||
|
||||
type Props = {
|
||||
iconSize: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ContractCertifiedLabel = ({ iconSize, className }: Props) => {
|
||||
return (
|
||||
<Tooltip label="This contract has been certified by the chain developers">
|
||||
<Box className={ className }>
|
||||
<IconSvg name="certified" color="green.500" boxSize={ iconSize } cursor="pointer"/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default chakra(ContractCertifiedLabel);
|
||||
@@ -32,7 +32,7 @@ const DefaultView = () => {
|
||||
|
||||
const contentAfter = (
|
||||
<>
|
||||
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer"/>
|
||||
<IconSvg name="certified" color="green.500" boxSize={ 6 } cursor="pointer"/>
|
||||
<EntityTags
|
||||
tags={ [
|
||||
{ slug: 'example', name: 'Example label', tagType: 'custom' },
|
||||
|
||||
@@ -28,7 +28,7 @@ const LongNameAndManyTags = () => {
|
||||
|
||||
const contentAfter = (
|
||||
<>
|
||||
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer" flexShrink={ 0 }/>
|
||||
<IconSvg name="certified" color="green.500" boxSize={ 6 } cursor="pointer" flexShrink={ 0 }/>
|
||||
<EntityTags
|
||||
tags={ [
|
||||
{ slug: 'example', name: 'Example with long name', tagType: 'custom' },
|
||||
|
||||
@@ -30,6 +30,7 @@ test('search by token name +@mobile +@dark-mode', async({ render, page, mockApi
|
||||
test('search by contract name +@mobile +@dark-mode', async({ render, page, mockApiResponse }) => {
|
||||
const apiUrl = await mockApiResponse('quick_search', [
|
||||
searchMock.contract1,
|
||||
searchMock.contract2,
|
||||
searchMock.address2,
|
||||
], { queryParams: { q: 'o' } });
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SearchResultAddressOrContract } from 'types/api/search';
|
||||
import dayjs from 'lib/date/dayjs';
|
||||
import highlightText from 'lib/highlightText';
|
||||
import { ADDRESS_REGEXP } from 'lib/validations/address';
|
||||
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
|
||||
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
|
||||
|
||||
@@ -34,21 +35,22 @@ const SearchBarSuggestAddress = ({ data, isMobile, searchTerm }: Props) => {
|
||||
const expiresText = data.ens_info?.expiry_date ? ` (expires ${ dayjs(data.ens_info.expiry_date).fromNow() })` : '';
|
||||
|
||||
const nameEl = addressName && (
|
||||
<Text
|
||||
variant="secondary"
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
>
|
||||
<chakra.span fontWeight={ 500 } dangerouslySetInnerHTML={{ __html: highlightText(addressName, searchTerm) }}/>
|
||||
{ data.ens_info &&
|
||||
(
|
||||
<Flex alignItems="center">
|
||||
<Text
|
||||
variant="secondary"
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
>
|
||||
<chakra.span fontWeight={ 500 } dangerouslySetInnerHTML={{ __html: highlightText(addressName, searchTerm) }}/>
|
||||
{ data.ens_info && (
|
||||
data.ens_info.names_count > 1 ?
|
||||
<span> ({ data.ens_info.names_count > 39 ? '40+' : `+${ data.ens_info.names_count - 1 }` })</span> :
|
||||
<span>{ expiresText }</span>
|
||||
)
|
||||
}
|
||||
</Text>
|
||||
) }
|
||||
</Text>
|
||||
{ data.certified && <ContractCertifiedLabel boxSize={ 5 } iconSize={ 5 } ml={ 1 }/> }
|
||||
</Flex>
|
||||
);
|
||||
const addressEl = <HashStringShortenDynamic hash={ data.address } isTooltipDisabled/>;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ interface Props {
|
||||
|
||||
const SearchBarSuggestToken = ({ data, isMobile, searchTerm }: Props) => {
|
||||
const icon = <TokenEntity.Icon token={{ ...data, type: data.token_type }}/>;
|
||||
const verifiedIcon = <IconSvg name="verified_token" boxSize={ 4 } color="green.500" ml={ 1 }/>;
|
||||
const verifiedIcon = <IconSvg name="certified" boxSize={ 4 } color="green.500" ml={ 1 }/>;
|
||||
const name = (
|
||||
<Text
|
||||
fontWeight={ 700 }
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 27 KiB |
@@ -98,7 +98,7 @@ const TokenPageTitle = ({ tokenQuery, addressQuery, hash }: Props) => {
|
||||
{ verifiedInfoQuery.data?.tokenAddress && (
|
||||
<Tooltip label={ `Information on this token has been verified by ${ config.chain.name }` }>
|
||||
<Box boxSize={ 6 }>
|
||||
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer"/>
|
||||
<IconSvg name="certified" color="green.500" boxSize={ 6 } cursor="pointer"/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) }
|
||||
|
||||
@@ -8,6 +8,7 @@ import config from 'configs/app';
|
||||
import { CONTRACT_LICENSES } from 'lib/contracts/licenses';
|
||||
import dayjs from 'lib/date/dayjs';
|
||||
import { currencyUnits } from 'lib/units';
|
||||
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
|
||||
import CopyToClipboard from 'ui/shared/CopyToClipboard';
|
||||
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
import HashStringShorten from 'ui/shared/HashStringShorten';
|
||||
@@ -36,12 +37,15 @@ const VerifiedContractsListItem = ({ data, isLoading }: Props) => {
|
||||
return (
|
||||
<ListItemMobile rowGap={ 3 }>
|
||||
<Flex w="100%">
|
||||
<AddressEntity
|
||||
isLoading={ isLoading }
|
||||
address={ data.address }
|
||||
query={{ tab: 'contract' }}
|
||||
noCopy
|
||||
/>
|
||||
<Flex alignItems="center" overflow="hidden">
|
||||
<AddressEntity
|
||||
isLoading={ isLoading }
|
||||
address={ data.address }
|
||||
query={{ tab: 'contract' }}
|
||||
noCopy
|
||||
/>
|
||||
{ data.certified && <ContractCertifiedLabel iconSize={ 5 } boxSize={ 5 } mx={ 2 }/> }
|
||||
</Flex>
|
||||
<Skeleton isLoaded={ !isLoading } color="text_secondary" ml="auto">
|
||||
<HashStringShorten hash={ data.address.hash } isTooltipDisabled/>
|
||||
</Skeleton>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { VerifiedContract } from 'types/api/contracts';
|
||||
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 CopyToClipboard from 'ui/shared/CopyToClipboard';
|
||||
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
import HashStringShorten from 'ui/shared/HashStringShorten';
|
||||
@@ -34,13 +35,15 @@ const VerifiedContractsTableItem = ({ data, isLoading }: Props) => {
|
||||
return (
|
||||
<Tr>
|
||||
<Td>
|
||||
<AddressEntity
|
||||
address={ data.address }
|
||||
isLoading={ isLoading }
|
||||
query={{ tab: 'contract' }}
|
||||
noCopy
|
||||
mt={ 1 }
|
||||
/>
|
||||
<Flex alignItems="center" mt={ 1 }>
|
||||
<AddressEntity
|
||||
address={ data.address }
|
||||
isLoading={ isLoading }
|
||||
query={{ tab: 'contract' }}
|
||||
noCopy
|
||||
/>
|
||||
{ data.certified && <ContractCertifiedLabel iconSize={ 5 } boxSize={ 5 } ml={ 2 }/> }
|
||||
</Flex>
|
||||
<Flex alignItems="center" ml={ 7 }>
|
||||
<Skeleton isLoaded={ !isLoading } color="text_secondary" my={ 1 }>
|
||||
<HashStringShorten hash={ data.address.hash } isTooltipDisabled/>
|
||||
|
||||