Merge branch 'main' of github.com:blockscout/frontend into csv-download

This commit is contained in:
tom
2023-02-21 22:27:59 -05:00
187 changed files with 2362 additions and 706 deletions
+2
View File
@@ -1,2 +1,4 @@
NEXT_PUBLIC_SENTRY_DSN=xxx
SENTRY_CSP_REPORT_URI=xxx
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
+5
View File
@@ -48,9 +48,14 @@ NEXT_PUBLIC_API_PROTOCOL=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_PROTOCOL__
NEXT_PUBLIC_API_PORT=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_PORT__
NEXT_PUBLIC_STATS_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_STATS_API_HOST__
NEXT_PUBLIC_VISUALIZE_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_VISUALIZE_API_HOST__
NEXT_PUBLIC_API_SPEC_URL=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_SPEC_URL__
# external services config
NEXT_PUBLIC_SENTRY_DSN=__PLACEHOLDER_FOR_NEXT_PUBLIC_SENTRY_DSN__
NEXT_PUBLIC_AUTH0_CLIENT_ID=__PLACEHOLDER_FOR_NEXT_PUBLIC_AUTH0_CLIENT_ID__
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=__PLACEHOLDER_FOR_NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID__
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=__PLACEHOLDER_FOR_NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY__
# l2 config
NEXT_PUBLIC_IS_L2_NETWORK=__PLACEHOLDER_FOR_NEXT_PUBLIC_IS_L2_NETWORKL__
NEXT_PUBLIC_L1_BASE_URL=__PLACEHOLDER_FOR_NEXT_PUBLIC_L1_BASE_URL__
+1 -1
View File
@@ -251,7 +251,7 @@
"presentation": {
"reveal": "always",
"panel": "shared",
"close": true,
"close": false,
"revealProblems": "onProblem",
"focus": true,
},
+8 -1
View File
@@ -46,7 +46,7 @@ The app instance could be customized by passing following variables to NodeJS en
| NEXT_PUBLIC_NETWORK_SHORT_NAME | `string` *(optional)* | Used for SEO attributes (page title and description) | `OoG` |
| NEXT_PUBLIC_NETWORK_TYPE | `string` *(optional)* | Network type (used for matching pre-defined assets, e.g network logo and icon, which are stored in the project). See all possible values here | `xdai_mainnet` |
| NEXT_PUBLIC_NETWORK_ID | `number` | Chain id, see [https://chainlist.org/](https://chainlist.org/) for the reference | `99` |
| NEXT_PUBLIC_NETWORK_RPC_URL | `string` | Chain server RPC url, see [https://chainlist.org/](https://chainlist.org/) for the reference | `https://core.poa.network` |
| NEXT_PUBLIC_NETWORK_RPC_URL | `string` *(optional)* | Chain server RPC url, see [https://chainlist.org/](https://chainlist.org/) for the reference. If not provided, some functionality of the explorer, related to smart contracts interaction and third-party apps integration, will be unavailable | `https://core.poa.network` |
| NEXT_PUBLIC_NETWORK_CURRENCY_NAME | `string` | Network currency name | `Ether` |
| NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL | `string` | Network currency symbol | `ETH` |
| NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS | `string` | Network currency decimals | `18` |
@@ -79,6 +79,7 @@ The app instance could be customized by passing following variables to NodeJS en
| NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME | `boolean` *(optional)* | Set to false if average block time is useless for the network | `true` |
| NEXT_PUBLIC_DOMAIN_WITH_AD | `string` *(optional)* | The domain on which we display ads | `blockscout.com` |
| NEXT_PUBLIC_AD_ADBUTLER_ON | `boolean` *(optional)* | Set to true to show Adbutler banner instead of Coinzilla banner | `false` |
| NEXT_PUBLIC_API_SPEC_URL | `string` *(optional)* | Spec to be displayed on api-docs page | `https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml` |
### App configuration
| Variable | Type | Description | Default value
@@ -129,6 +130,12 @@ The app instance could be customized by passing following variables to NodeJS en
| NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID | `string` | Project id for [WalletConnect](https://docs.walletconnect.com/2.0/web3modal/react/installation#obtain-project-id) integration | `<secret>` |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `string` | Site key for [reCAPTCHA](https://developers.google.com/recaptcha) service | `<secret>` |
### L2 configuration
| Variable | Type | Description | Default value
| --- | --- | --- | --- |
| NEXT_PUBLIC_IS_L2_NETWORK | `boolean` *(optional)* | Set to true for L2 solutions (Optimism Bedrock based) | false |
| NEXT_PUBLIC_L1_BASE_URL | `string` *(optional)* | Base Blockscout URL for L1 network | `'http://eth-goerli.blockscout.com'` |
### Marketplace app configuration properties
| Property | Type | Description | Example value
+7
View File
@@ -104,6 +104,10 @@ const config = Object.freeze({
socket: apiHost ? `wss://${ apiHost }` : 'wss://blockscout.com',
basePath: stripTrailingSlash(getEnvValue(process.env.NEXT_PUBLIC_API_BASE_PATH) || ''),
},
L2: {
isL2Network: getEnvValue(process.env.NEXT_PUBLIC_IS_L2_NETWORK) === 'true',
L1BaseUrl: getEnvValue(process.env.NEXT_PUBLIC_L1_BASE_URL),
},
statsApi: {
endpoint: getEnvValue(process.env.NEXT_PUBLIC_STATS_API_HOST),
basePath: '',
@@ -122,6 +126,9 @@ const config = Object.freeze({
walletConnect: {
projectId: getEnvValue(process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID),
},
apiDoc: {
specUrl: getEnvValue(process.env.NEXT_PUBLIC_API_SPEC_URL),
},
reCaptcha: {
siteKey: getEnvValue(process.env.NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY) || '',
},
+2
View File
@@ -9,6 +9,8 @@ NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_BLOCKSCOUT_VERSION=v4.1.7-beta
NEXT_PUBLIC_FOOTER_GITHUB_LINK=https://github.com/blockscout/blockscout
NEXT_PUBLIC_FOOTER_TWITTER_LINK=https://www.twitter.com/blockscoutcom
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
# api config
NEXT_PUBLIC_API_HOST=blockscout.com
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -2
View File
@@ -40,6 +40,10 @@ blockscout:
_default: ENC[AES256_GCM,data:mTY6sjNKZ+VEvH47eyyoUHt//beWvuxyreu+1WrmMvkdkwR6jgXnSXh+1pTuiG8e3it96Egxraz0hjZxlkY9kSmE91/dfoqKTns=,iv:op8OuOXXeBmmUnVkCL14j4HrfjHHoN3n2XZqLdg03Mw=,tag:bbFQYi0Aa1sUdfoUjuQ/+w==,type:str]
ACCOUNT_AUTH0_LOGOUT_URL:
_default: ENC[AES256_GCM,data:IZFfi6pn+hy7g0wnEtP9TYHH1fNiC2gqgRHVdgm4C9smPerEvS0pq9dBwVY=,iv:BxbSInFQ6GE2loTv+IzdYr25PlyzdWZI1wdT6r+uvBg=,tag:psujCU372k59OGmlRdH9Fg==,type:str]
RE_CAPTCHA_SECRET_KEY:
_default: ENC[AES256_GCM,data:e9jfs4PwY+UPk0EuXZxERylKUFSUo6zsAz8oly3YwwybipP+A5rSBQ==,iv:TvrD/a+6+11IGDVFLUCn8U+H3v1YfIRrcndOVdt/taI=,tag:OAFVIznkeeeVX9UsigfP4A==,type:str]
RE_CAPTCHA_CLIENT_KEY:
_default: ENC[AES256_GCM,data:6GPj8CQddvUofViL26AIBsVeMaYyrQ1Mh0kYUJ0HiIJc0otDewUc2Q==,iv:1LR4GBdSqN6ciKNCRI67d8oPWsTXnVI55hPSMvLL4hE=,tag:58pE8s9X7+eMOCY6bl4Z5w==,type:str]
scVerifier:
environment:
SMART_CONTRACT_VERIFIER__SOLIDITY__FETCHER__S3__ACCESS_KEY:
@@ -134,8 +138,8 @@ sops:
azure_kv: []
hc_vault: []
age: []
lastmodified: "2023-02-14T08:03:14Z"
mac: ENC[AES256_GCM,data:B2AkQb4I83dP3UUitRCcrUfzm3nWmcknIUoMWHyYaG9jasnccbr8zZatYdpbvKFcELVTtjhYk6ly5Sx7+6sk2PZm6o7dN3yHG5lSWmnZqNXkwo42GIk/F6vzDdLutZsu8HH8pWHd9y5R272CIPOOh4+Ur0OtwiGgj3Bp1od76qM=,iv:j7aIPflH0FsYhE/iylvBh5nDmVdghhxAFvaeXlR560k=,tag:/oe6OeitIHaZ4TgM7w/0pg==,type:str]
lastmodified: "2023-02-19T10:34:54Z"
mac: ENC[AES256_GCM,data:c7Nlguw+82tpgNAclx6XgXQip+gHh37Wrb9pq0MQ+bgp3LmfWpNDYYtxvdkIxMPNYsLxQOvw9Kc26qYWY1qDggmkHFvIeImCScjxBmrjSAf1K61lk0NIQ7AnEVh7ahipAopUO7y/0ogcjyjJCGs/QPRg9yelsONhCIjRkeUi7mQ=,iv:Y5tWWJJCqdNujHczKnouk4TiHBwMBezKhKCmvWeL9Kc=,tag:hIjUobEooUAQuKwHFraryw==,type:str]
pgp:
- created_at: "2022-09-14T13:42:28Z"
enc: |
+3
View File
@@ -491,6 +491,7 @@ frontend:
- "/token"
- "/accounts"
- "/visualize"
- "/api-docs"
- "/csv-export"
resources:
@@ -572,3 +573,5 @@ frontend:
_default: https://rpc.ankr.com/eth_goerli
NEXT_PUBLIC_HOMEPAGE_CHARTS:
_default: "['daily_txs','coin_price','market_cup']"
NEXT_PUBLIC_API_SPEC_URL:
_default: https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
+3
View File
@@ -327,6 +327,7 @@ frontend:
- "/tokens"
- "/accounts"
- "/visualize"
- "/api-docs"
- "/csv-export"
resources:
@@ -409,3 +410,5 @@ frontend:
_default: https://rpc.ankr.com/eth_goerli
NEXT_PUBLIC_NETWORK_EXPLORERS:
_default: "[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/ethereum/goerli/transaction','address':'/ethereum/ethereum/goerli/address'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address'}}]"
NEXT_PUBLIC_API_SPEC_URL:
_default: https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
+4
View File
@@ -0,0 +1,4 @@
<svg viewBox="0 0 51 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.693 5.058c.498-.57 1.172-.891 1.875-.891h15.91c.351 0 .688.16.937.446l9.28 10.657c.249.285.388.672.388 1.076v24.36c0 .807-.279 1.581-.776 2.152-.498.571-1.172.892-1.875.892H13.568c-.703 0-1.377-.32-1.875-.892-.497-.57-.776-1.345-.776-2.153V7.212c0-.808.28-1.582.776-2.154Zm17.235 2.154h-15.36v33.493h23.864V16.977l-8.504-9.765Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.556 5a1.39 1.39 0 0 1 1.389 1.389v8.333h8.333a1.389 1.389 0 0 1 0 2.778h-9.722a1.389 1.389 0 0 1-1.39-1.389V6.39a1.39 1.39 0 0 1 1.39-1.389ZM22.46 25.151a1.326 1.326 0 0 0-1.875 1.875l3.04 3.04-3.04 3.04a1.326 1.326 0 0 0 1.875 1.875l3.04-3.04 3.04 3.04a1.326 1.326 0 0 0 1.875-1.875l-3.04-3.04 3.04-3.04a1.326 1.326 0 0 0-1.875-1.875l-3.04 3.04-3.04-3.04Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 921 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 30 30">
<path fill="currentColor" fill-rule="evenodd" d="M14.688 4.75c-5.482 0-9.938 4.456-9.938 9.938 0 5.481 4.456 9.937 9.938 9.937 5.481 0 9.937-4.456 9.937-9.938 0-5.481-4.456-9.937-9.938-9.937Zm3.499 5.781a14.655 14.655 0 0 0-1.813-3.855 8.151 8.151 0 0 1 5.359 3.855h-3.546Zm-7.219 0H7.644a8.16 8.16 0 0 1 5.094-3.787 14.079 14.079 0 0 0-1.77 3.787Zm5.39 0h-3.56a12.705 12.705 0 0 1 1.78-3.382 12.64 12.64 0 0 1 1.78 3.382ZM6.5 14.687c0-.831.131-1.65.369-2.406h3.692a14.62 14.62 0 0 0-.013 4.737l-3.695.027a7.864 7.864 0 0 1-.353-2.358Zm5.83 2.327c-.3-1.57-.3-3.176 0-4.733h4.496c.3 1.555.3 3.147.028 4.705l-4.524.028Zm6.295-.047a14.364 14.364 0 0 0-.029-4.686h3.91c.238.756.369 1.575.369 2.406 0 .783-.116 1.542-.313 2.252l-3.937.028Zm-2.246 5.73a14.004 14.004 0 0 0 1.843-3.98l3.596-.026a8.21 8.21 0 0 1-5.439 4.005Zm-1.802-.472a12.283 12.283 0 0 1-1.8-3.462l3.613-.026a12.946 12.946 0 0 1-1.813 3.488Zm-1.84.406a8.208 8.208 0 0 1-5.128-3.837l3.328-.027c.394 1.347 1 2.657 1.8 3.864Z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#top-accounts_svg__a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.585 2.973a3.412 3.412 0 0 0-3.412 3.412v17.23a3.412 3.412 0 0 0 3.412 3.412h17.23a.95.95 0 0 0 0-1.9H7.585a1.512 1.512 0 1 1 0-3.023h17.23a.95.95 0 0 0 .934-1.126.954.954 0 0 0 .016-.176V4.662c0-.933-.756-1.689-1.688-1.689H7.585ZM6.073 6.385c0-.835.677-1.512 1.512-1.512h16.28v15.33H7.585a3.4 3.4 0 0 0-1.512.353V6.385Zm8.897 1.4a3.013 3.013 0 1 0 0 6.026 3.013 3.013 0 0 0 0-6.026Zm-1.024 3.013a1.024 1.024 0 1 1 2.048 0 1.024 1.024 0 0 1-2.048 0Zm1.025 3.097c-2.316 0-4.3 1.303-5.319 3.243a.446.446 0 0 0 .395.654h1.281a.446.446 0 0 0 .366-.19 3.972 3.972 0 0 1 3.277-1.718c1.33 0 2.52.685 3.268 1.723a.446.446 0 0 0 .362.185h1.292a.446.446 0 0 0 .393-.659c-1.01-1.866-2.983-3.238-5.315-3.238Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="top-accounts_svg__a">
<path fill="#fff" transform="translate(3 3)" d="M0 0h24v24H0z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#verified_svg__a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4 15a8.4 8.4 0 1 1-16.8 0 8.4 8.4 0 0 1 16.8 0Zm1.6 0c0 5.523-4.477 10-10 10S5 20.523 5 15 9.477 5 15 5s10 4.477 10 10Zm-5.895-3.706a.916.916 0 1 1 1.295 1.295l-6.022 6.022a1.05 1.05 0 0 1-1.485 0l-3.2-3.199a.915.915 0 0 1 1.296-1.295l2.257 2.258a.55.55 0 0 0 .778 0l5.081-5.081Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="verified_svg__a">
<path fill="#fff" transform="translate(5 5)" d="M0 0h20v20H0z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 629 B

+12 -13
View File
@@ -16,22 +16,21 @@ const MAIN_DOMAINS = [ `*.${ appConfig.host }`, appConfig.host ];
// eslint-disable-next-line no-restricted-properties
const REPORT_URI = process.env.SENTRY_CSP_REPORT_URI;
function getNetworksExternalAssets() {
function getNetworksExternalAssetsHosts() {
const icons = featuredNetworks
.filter(({ icon }) => typeof icon === 'string')
.map(({ icon }) => new URL(icon as string));
.map(({ icon }) => new URL(icon as string).host);
const logo = appConfig.network.logo ? new URL(appConfig.network.logo) : undefined;
const logo = appConfig.network.logo ? new URL(appConfig.network.logo).host : undefined;
return logo ? icons.concat(logo) : icons;
}
function getMarketplaceAppsOrigins() {
return appConfig.marketplaceAppList.map(({ url }) => url);
}
function getMarketplaceAppsLogosOrigins() {
return appConfig.marketplaceAppList.map(({ logo }) => new URL(logo));
function getMarketplaceAppsHosts() {
return {
frames: appConfig.marketplaceAppList.map(({ url }) => new URL(url).host),
logos: appConfig.marketplaceAppList.map(({ logo }) => new URL(logo).host),
};
}
// we cannot use lodash/uniq in middleware code since it calls new Set() and it'is causing an error in Nextjs
@@ -46,7 +45,7 @@ function unique(array: Array<string | undefined>) {
}
function makePolicyMap() {
const networkExternalAssets = getNetworksExternalAssets();
const marketplaceAppsHosts = getMarketplaceAppsHosts();
return {
'default-src': [
@@ -135,10 +134,10 @@ function makePolicyMap() {
'avatars.githubusercontent.com', // github avatars
// network assets
...networkExternalAssets.map((url) => url.host),
...getNetworksExternalAssetsHosts(),
// marketplace apps logos
...getMarketplaceAppsLogosOrigins().map((url) => url.host),
...marketplaceAppsHosts.logos,
// ad
'servedbyadbutler.com',
@@ -172,7 +171,7 @@ function makePolicyMap() {
],
'frame-src': [
...getMarketplaceAppsOrigins(),
...marketplaceAppsHosts.frames,
// ad
'request-global.czilladx.com',
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
import type { Address } from 'types/api/address';
import notEmpty from 'lib/notEmpty';
import ContractCode from 'ui/address/contract/ContractCode';
import ContractRead from 'ui/address/contract/ContractRead';
import ContractWrite from 'ui/address/contract/ContractWrite';
export default function useContractTabs(data: Address | undefined) {
return React.useMemo(() => {
return [
{ id: 'contact_code', title: 'Code', component: <ContractCode addressHash={ data?.hash }/> },
// this is not implemented in api yet
// data?.has_decompiled_code ?
// { id: 'contact_decompiled_code', title: 'Decompiled code', component: <div>Decompiled code</div> } :
// undefined,
data?.has_methods_read ?
{ id: 'read_contract', title: 'Read contract', component: <ContractRead addressHash={ data?.hash }/> } :
undefined,
data?.has_methods_read_proxy ?
{ id: 'read_proxy', title: 'Read proxy', component: <ContractRead addressHash={ data?.hash } isProxy/> } :
undefined,
data?.has_custom_methods_read ?
{ id: 'read_custom_methods', title: 'Read custom', component: <ContractRead addressHash={ data?.hash } isCustomAbi/> } :
undefined,
data?.has_methods_write ?
{ id: 'write_contract', title: 'Write contract', component: <ContractWrite addressHash={ data?.hash }/> } :
undefined,
data?.has_methods_write_proxy ?
{ id: 'write_proxy', title: 'Write proxy', component: <ContractWrite addressHash={ data?.hash } isProxy/> } :
undefined,
data?.has_custom_methods_write ?
{ id: 'write_custom_methods', title: 'Write custom', component: <ContractWrite addressHash={ data?.hash } isCustomAbi/> } :
undefined,
].filter(notEmpty);
}, [ data ]);
}
-8
View File
@@ -14,14 +14,6 @@ export default function useGetCsrfToken() {
const url = buildUrl('csrf');
const apiResponse = await fetch(url, { credentials: 'include' });
const csrfFromHeader = apiResponse.headers.get('x-bs-account-csrf');
// eslint-disable-next-line no-console
console.log('>>> RESPONSE HEADERS <<<');
// eslint-disable-next-line no-console
console.table([ {
'content-length': apiResponse.headers.get('content-length'),
'x-bs-account-csrf': csrfFromHeader,
} ]);
return csrfFromHeader ? { token: csrfFromHeader } : undefined;
}
+43 -8
View File
@@ -7,14 +7,16 @@ import abiIcon from 'icons/ABI.svg';
import apiKeysIcon from 'icons/API.svg';
import appsIcon from 'icons/apps.svg';
import blocksIcon from 'icons/block.svg';
// import gearIcon from 'icons/gear.svg';
import gearIcon from 'icons/gear.svg';
import globeIcon from 'icons/globe-b.svg';
import privateTagIcon from 'icons/privattags.svg';
import profileIcon from 'icons/profile.svg';
import publicTagIcon from 'icons/publictags.svg';
import statsIcon from 'icons/stats.svg';
import tokensIcon from 'icons/token.svg';
import topAccountsIcon from 'icons/top-accounts.svg';
import transactionsIcon from 'icons/transactions.svg';
import walletIcon from 'icons/wallet.svg';
// import verifiedIcon from 'icons/verified.svg';
import watchlistIcon from 'icons/watchlist.svg';
import notEmpty from 'lib/notEmpty';
@@ -26,31 +28,64 @@ export interface NavItem {
isNewUi?: boolean;
}
export interface NavGroupItem extends Omit<NavItem, 'nextRoute'> {
subItems: Array<NavItem>;
}
interface ReturnType {
mainNavItems: Array<NavItem>;
mainNavItems: Array<NavItem | NavGroupItem>;
accountNavItems: Array<NavItem>;
profileItem: NavItem;
}
export function isGroupItem(item: NavItem | NavGroupItem): item is NavGroupItem {
return 'subItems' in item;
}
export default function useNavItems(): ReturnType {
const isMarketplaceFilled = appConfig.marketplaceAppList.length > 0;
const isMarketplaceFilled = appConfig.marketplaceAppList.length > 0 && appConfig.network.rpcUrl;
const hasAPIDocs = appConfig.apiDoc.specUrl;
const router = useRouter();
const pathname = router.pathname;
return React.useMemo(() => {
const mainNavItems = [
const blockchainNavItems: Array<NavItem> = [
{ text: 'Top accounts', nextRoute: { pathname: '/accounts' as const }, icon: topAccountsIcon, isActive: pathname === '/accounts', isNewUi: true },
{ text: 'Blocks', nextRoute: { pathname: '/blocks' as const }, icon: blocksIcon, isActive: pathname.startsWith('/block'), isNewUi: true },
{ text: 'Transactions', nextRoute: { pathname: '/txs' as const }, icon: transactionsIcon, isActive: pathname.startsWith('/tx'), isNewUi: true },
// eslint-disable-next-line max-len
// { text: 'Verified contracts', nextRoute: { pathname: '/verified_contracts' as const }, icon: verifiedIcon, isActive: pathname === '/verified_contracts', isNewUi: false },
];
const otherNavItems: Array<NavItem> = [
hasAPIDocs ? {
text: 'API documentation',
nextRoute: { pathname: '/api-docs' as const },
// FIXME: need icon for this item
icon: topAccountsIcon,
isActive: pathname === '/api-docs',
isNewUi: true,
} : null,
].filter(notEmpty);
const mainNavItems = [
{
text: 'Blockchain',
icon: globeIcon,
isActive: blockchainNavItems.some(item => item.isActive),
isNewUi: true,
subItems: blockchainNavItems,
},
{ text: 'Tokens', nextRoute: { pathname: '/tokens' as const }, icon: tokensIcon, isActive: pathname.startsWith('/token'), isNewUi: true },
{ text: 'Accounts', nextRoute: { pathname: '/accounts' as const }, icon: walletIcon, isActive: pathname === '/accounts', isNewUi: true },
isMarketplaceFilled ?
{ text: 'Apps', nextRoute: { pathname: '/apps' as const }, icon: appsIcon, isActive: pathname.startsWith('/app'), isNewUi: true } : null,
{ text: 'Charts & stats', nextRoute: { pathname: '/stats' as const }, icon: statsIcon, isActive: pathname === '/stats', isNewUi: true },
// there should be custom site sections like Stats, Faucet, More, etc but never an 'other'
// examples https://explorer-edgenet.polygon.technology/ and https://explorer.celo.org/
// at this stage custom menu items is under development, we will implement it later
// { text: 'Other', url: link('other'), icon: gearIcon, isActive: pathname === 'other' },
otherNavItems.length > 0 ?
{ text: 'Other', icon: gearIcon, isActive: otherNavItems.some(item => item.isActive), subItems: otherNavItems } : null,
].filter(notEmpty);
const accountNavItems = [
@@ -87,5 +122,5 @@ export default function useNavItems(): ReturnType {
text: 'My profile', nextRoute: { pathname: '/auth/profile' as const }, icon: profileIcon, isActive: pathname === '/auth/profile', isNewUi: true };
return { mainNavItems, accountNavItems, profileItem };
}, [ isMarketplaceFilled, pathname ]);
}, [ hasAPIDocs, isMarketplaceFilled, pathname ]);
}
+15
View File
@@ -0,0 +1,15 @@
import type { GetServerSideProps } from 'next';
import appConfig from 'configs/app/config';
import type { Props } from 'lib/next/getServerSideProps';
import { getServerSideProps as getServerSidePropsBase } from 'lib/next/getServerSideProps';
export const getServerSideProps: GetServerSideProps<Props> = async(args) => {
if (!appConfig.L2.isL2Network) {
return {
notFound: true,
};
}
return getServerSidePropsBase(args);
};
+3 -1
View File
@@ -36,8 +36,10 @@ export function middleware(req: NextRequest) {
/**
* Configure which routes should pass through the Middleware.
* Exclude all `_next` urls.
*/
export const config = {
matcher: [ '/', '/:notunderscore((?!_next).+)' ],
// matcher: [
// '/((?!.*\\.|api\\/|node-api\\/).*)', // exclude all static + api + node-api routes
// ],
};
+12 -2
View File
@@ -1,3 +1,8 @@
const withTM = require('next-transpile-modules')([
'react-syntax-highlighter',
'swagger-client',
'swagger-ui-react',
]);
const withRoutes = require('nextjs-routes/config')({
outDir: 'types',
});
@@ -7,7 +12,7 @@ const headers = require('./configs/nextjs/headers');
const redirects = require('./configs/nextjs/redirects');
const rewrites = require('./configs/nextjs/rewrites');
const moduleExports = {
const moduleExports = withTM({
include: path.resolve(__dirname, 'icons'),
reactStrictMode: true,
webpack(config, { webpack }) {
@@ -34,6 +39,11 @@ const moduleExports = {
redirects,
headers,
output: 'standalone',
};
api: {
// disable body parser since we use next.js api only for local development and as a proxy
// otherwise it is impossible to upload large files (over 1Mb)
bodyParser: false,
},
});
module.exports = withRoutes(moduleExports);
+4
View File
@@ -12,6 +12,7 @@
"dev:local": "./node_modules/.bin/dotenv -e ./configs/envs/.env.localhost -e ./configs/envs/.env.secrets next dev | ./node_modules/.bin/pino-pretty",
"dev:poa_core": "./node_modules/.bin/dotenv -e ./configs/envs/.env.poa_core -e ./configs/envs/.env.common -e ./configs/envs/.env.secrets next dev | ./node_modules/.bin/pino-pretty",
"dev:goerli": "./node_modules/.bin/dotenv -e ./configs/envs/.env.goerli -e ./configs/envs/.env.common -e ./configs/envs/.env.secrets next dev | ./node_modules/.bin/pino-pretty",
"dev:goerli:l2test": "./node_modules/.bin/dotenv -e ./configs/envs/.env.goerli.l2test -e ./configs/envs/.env.common -e ./configs/envs/.env.secrets next dev | ./node_modules/.bin/pino-pretty",
"build": "next build",
"build:docker": "docker build --build-arg GIT_COMMIT_SHA=$(git rev-parse HEAD) -t blockscout ./",
"start": "next start",
@@ -69,6 +70,7 @@
"react-identicons": "^1.2.5",
"react-jazzicon": "^1.0.4",
"react-scroll": "^1.8.7",
"swagger-ui-react": "^4.15.5",
"use-font-face-observer": "^1.2.1",
"wagmi": "^0.10.6"
},
@@ -85,6 +87,7 @@
"@types/qrcode": "^1.5.0",
"@types/react": "18.0.9",
"@types/react-dom": "18.0.5",
"@types/swagger-ui-react": "^4.11.0",
"@types/react-google-recaptcha": "^2.1.5",
"@types/ws": "^8.5.3",
"@typescript-eslint/eslint-plugin": "^5.27.0",
@@ -101,6 +104,7 @@
"jest-environment-jsdom": "^29.2.1",
"lint-staged": ">=10",
"mockdate": "^3.0.5",
"next-transpile-modules": "^10.0.0",
"playwright": "^1.28.0",
"svgo": "^2.8.0",
"ts-jest": "^29.0.3",
+35
View File
@@ -0,0 +1,35 @@
import type { NextPage, GetServerSideProps } from 'next';
import Head from 'next/head';
import React from 'react';
import appConfig from 'configs/app/config';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import { getServerSideProps as getServerSidePropsBase } from 'lib/next/getServerSideProps';
import type { Props } from 'lib/next/getServerSideProps';
import SwaggerUI from 'ui/apiDocs/SwaggerUI';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
const AppsPage: NextPage = () => {
const networkTitle = getNetworkTitle();
return (
<Page>
<PageTitle text="API Documentation"/>
<Head><title>{ `API for the ${ networkTitle }` }</title></Head>
<SwaggerUI/>
</Page>
);
};
export default AppsPage;
export const getServerSideProps: GetServerSideProps<Props> = async(args) => {
if (!appConfig.apiDoc.specUrl) {
return {
notFound: true,
};
}
return getServerSidePropsBase(args);
};
+2
View File
@@ -4,6 +4,7 @@ import { WebSocketServer } from 'ws';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import type { NewBlockSocketResponse } from 'types/api/block';
import type { SmartContractVerificationResponse } from 'types/api/contract';
type ReturnType = () => Promise<WebSocket>;
@@ -59,6 +60,7 @@ export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_bal
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'transaction', payload: { transaction: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'pending_transaction', payload: { pending_transaction: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'new_block', payload: NewBlockSocketResponse): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'verification_result', payload: SmartContractVerificationResponse): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: string, payload: unknown): void {
socket.send(JSON.stringify([
...channel,
+9
View File
@@ -0,0 +1,9 @@
*,
*::after,
*::before {
transition-delay: 0s !important;
transition-duration: 0s !important;
animation-delay: -0.0001s !important;
animation-duration: 0s !important;
animation-play-state: paused !important;
}
+1
View File
@@ -1,4 +1,5 @@
import './fonts.css';
import './index.css';
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
import _defaultsDeep from 'lodash/defaultsDeep';
import MockDate from 'mockdate';
+3
View File
@@ -20,6 +20,9 @@ const semanticTokens = {
_dark: 'red.300',
},
},
shadows: {
action_bar: '0 4px 4px -4px rgb(0 0 0 / 10%), 0 2px 4px -4px rgb(0 0 0 / 6%)',
},
};
export default semanticTokens;
+1 -1
View File
@@ -42,7 +42,7 @@ export interface TokenInstance {
image_url: string | null;
animation_url: string | null;
external_app_url: string | null;
metadata: unknown;
metadata: Record<string, unknown> | null;
owner: AddressParam;
token: TokenInfo;
}
+1
View File
@@ -16,6 +16,7 @@ declare module "nextjs-routes" {
| DynamicRoute<"/address/[hash]", { "hash": string }>
| StaticRoute<"/api/csrf">
| StaticRoute<"/api/proxy">
| StaticRoute<"/api-docs">
| DynamicRoute<"/apps/[id]", { "id": string }>
| StaticRoute<"/apps">
| StaticRoute<"/auth/auth0">
+2 -2
View File
@@ -76,7 +76,7 @@ const AddressBlocksValidated = ({ scrollRef }: Props) => {
return (
<>
<Hide below="lg" ssr={ false }>
<SkeletonTable columns={ [ '17%', '17%', '16%', '25%', '25%' ] }/>
<SkeletonTable columns={ [ '17%', '17%', '16%', '25%', '25%' ] } isLong/>
</Hide>
<Show below="lg" ssr={ false }>
<SkeletonList/>
@@ -125,7 +125,7 @@ const AddressBlocksValidated = ({ scrollRef }: Props) => {
return (
<Box>
{ query.isPaginationVisible && (
<ActionBar mt={ -6 }>
<ActionBar mt={ -6 } showShadow={ query.isLoading }>
<Pagination ml="auto" { ...query.pagination }/>
</ActionBar>
) }
+48 -34
View File
@@ -17,60 +17,74 @@ import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
interface Props {
tabs: Array<RoutedSubTab>;
addressHash?: string;
}
export const currentChain: Chain = {
id: Number(appConfig.network.id),
name: appConfig.network.name || '',
network: appConfig.network.name || '',
nativeCurrency: {
decimals: appConfig.network.currency.decimals,
name: appConfig.network.currency.name || '',
symbol: appConfig.network.currency.symbol || '',
},
rpcUrls: {
'default': {
http: [ appConfig.network.rpcUrl || '' ],
},
},
blockExplorers: {
'default': {
name: 'Blockscout',
url: appConfig.baseUrl,
},
},
};
const { wagmiClient, ethereumClient } = (() => {
try {
const currentChain: Chain = {
id: Number(appConfig.network.id),
name: appConfig.network.name || '',
network: appConfig.network.name || '',
nativeCurrency: {
decimals: appConfig.network.currency.decimals,
name: appConfig.network.currency.name || '',
symbol: appConfig.network.currency.symbol || '',
},
rpcUrls: {
'default': {
http: [ appConfig.network.rpcUrl || '' ],
},
},
blockExplorers: {
'default': {
name: 'Blockscout',
url: appConfig.baseUrl,
},
},
};
const chains = [ currentChain ];
const chains = [ currentChain ];
const { provider } = configureChains(chains, [
walletConnectProvider({ projectId: appConfig.walletConnect.projectId || '' }),
]);
const wagmiClient = createClient({
autoConnect: true,
connectors: modalConnectors({ appName: 'web3Modal', chains }),
provider,
});
const { provider } = configureChains(chains, [
walletConnectProvider({ projectId: appConfig.walletConnect.projectId || '' }),
]);
const wagmiClient = createClient({
autoConnect: true,
connectors: modalConnectors({ appName: 'web3Modal', chains }),
provider,
});
const ethereumClient = new EthereumClient(wagmiClient, chains);
const ethereumClient = new EthereumClient(wagmiClient, chains);
return { wagmiClient, ethereumClient };
} catch (error) {
return { wagmiClient: undefined, ethereumClient: undefined };
}
})();
const TAB_LIST_PROPS = {
columnGap: 3,
};
const AddressContract = ({ tabs }: Props) => {
const AddressContract = ({ addressHash, tabs }: Props) => {
const modalZIndex = useToken<string>('zIndices', 'modal');
const web3ModalTheme = useColorModeValue('light', 'dark');
const noProviderTabs = React.useMemo(() => tabs.filter(({ id }) => id === 'contact_code'), [ tabs ]);
if (!wagmiClient || !ethereumClient) {
return <RoutedTabs tabs={ noProviderTabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>;
}
return (
<WagmiConfig client={ wagmiClient }>
<ContractContextProvider>
<ContractContextProvider addressHash={ addressHash }>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>
</ContractContextProvider>
<Web3Modal
projectId={ appConfig.walletConnect.projectId }
ethereumClient={ ethereumClient }
themeZIndex={ Number(modalZIndex) }
themeMode={ useColorModeValue('light', 'dark') }
themeMode={ web3ModalTheme }
themeBackground="themeColor"
/>
</WagmiConfig>
+7
View File
@@ -11,6 +11,7 @@ import * as countersMock from 'mocks/address/counters';
import * as tokensMock from 'mocks/address/tokens';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import insertAdPlaceholder from 'playwright/utils/insertAdPlaceholder';
import AddressDetails from './AddressDetails';
import MockAddressPage from './testUtils/MockAddressPage';
@@ -44,6 +45,8 @@ test('contract +@mobile', async({ mount, page }) => {
{ hooksConfig },
);
await insertAdPlaceholder(page);
await expect(component).toHaveScreenshot();
});
@@ -82,6 +85,8 @@ test('token', async({ mount, page }) => {
{ hooksConfig },
);
await insertAdPlaceholder(page);
await expect(component).toHaveScreenshot();
});
@@ -102,5 +107,7 @@ test('validator +@mobile', async({ mount, page }) => {
{ hooksConfig },
);
await insertAdPlaceholder(page);
await expect(component).toHaveScreenshot();
});
+3 -1
View File
@@ -16,6 +16,7 @@ import AddressLink from 'ui/shared/address/AddressLink';
import AddressHeadingInfo from 'ui/shared/AddressHeadingInfo';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import LinkExternal from 'ui/shared/LinkExternal';
import LinkInternal from 'ui/shared/LinkInternal';
@@ -92,7 +93,7 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
<Text fontSize="sm">Verify with other explorers</Text>
{ explorers.map((explorer) => {
const url = new URL(explorer.paths.address + '/' + addressHash, explorer.baseUrl);
return <LinkExternal key={ explorer.baseUrl } title={ explorer.title } href={ url.toString() }/>;
return <LinkExternal key={ explorer.baseUrl } href={ url.toString() }>{ explorer.title }</LinkExternal>;
}) }
</Flex>
) }
@@ -193,6 +194,7 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
</LinkInternal>
</DetailsInfoItem>
) }
<DetailsSponsoredItem/>
</Grid>
</Box>
);
+30 -26
View File
@@ -9,13 +9,13 @@ import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import { apos } from 'lib/html-entities';
import getQueryParamString from 'lib/router/getQueryParamString';
import AddressIntTxsSkeletonDesktop from 'ui/address/internals/AddressIntTxsSkeletonDesktop';
import AddressIntTxsSkeletonMobile from 'ui/address/internals/AddressIntTxsSkeletonMobile';
import AddressIntTxsTable from 'ui/address/internals/AddressIntTxsTable';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Pagination from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import AddressCsvExportLink from './AddressCsvExportLink';
import AddressTxsFilter from './AddressTxsFilter';
@@ -43,29 +43,33 @@ const AddressInternalTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivE
onFilterChange({ filter: newVal });
}, [ onFilterChange ]);
if (isLoading) {
const content = (() => {
if (isError) {
return <DataFetchAlert/>;
}
if (isLoading) {
return (
<>
<Show below="lg" ssr={ false }>
<SkeletonList/>
</Show>
<Hide below="lg" ssr={ false }>
<SkeletonTable columns={ [ '15%', '15%', '10%', '20%', '20%', '20%' ] } isLong/>
</Hide>
</>
);
}
if (data.items.length === 0 && !filterValue) {
return <Text as="span">There are no internal transactions for this address.</Text>;
}
if (data.items.length === 0) {
return <EmptySearchResult text={ `Couldn${ apos }t find any transaction that matches your query.` }/>;
}
return (
<>
<Show below="lg" ssr={ false }><AddressIntTxsSkeletonMobile/></Show>
<Hide below="lg" ssr={ false }><AddressIntTxsSkeletonDesktop/></Hide>
</>
);
}
if (isError) {
return <DataFetchAlert/>;
}
if (data.items.length === 0 && !filterValue) {
return <Text as="span">There are no internal transactions for this address.</Text>;
}
let content;
if (data.items.length === 0) {
content = <EmptySearchResult text={ `Couldn${ apos }t find any transaction that matches your query.` }/>;
} else {
content = (
<>
<Show below="lg" ssr={ false }>
<AddressIntTxsList data={ data.items } currentAddress={ hash }/>
@@ -75,11 +79,11 @@ const AddressInternalTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivE
</Hide>
</>
);
}
})();
return (
<>
<ActionBar mt={ -6 } justifyContent="left">
<ActionBar mt={ -6 } justifyContent="left" showShadow={ isLoading }>
<AddressTxsFilter
defaultFilter={ filterValue }
onFilterChange={ handleFilterChange }
+1 -1
View File
@@ -25,7 +25,7 @@ const AddressLogs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>
}
const bar = isPaginationVisible ? (
<ActionBar mt={ -6 }>
<ActionBar mt={ -6 } showShadow>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
) : null;
+2 -2
View File
@@ -169,7 +169,7 @@ const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLD
return (
<>
<Hide below="lg" ssr={ false }>
<SkeletonTable columns={ [ '44px', '185px', '160px', '25%', '25%', '25%', '25%' ] }/>
<SkeletonTable columns={ [ '44px', '185px', '160px', '25%', '25%', '25%', '25%' ] } isLong/>
</Hide>
<Show below="lg" ssr={ false }>
<SkeletonList/>
@@ -253,7 +253,7 @@ const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLD
<>
{ isMobile && tokenFilterComponent }
{ !isActionBarHidden && (
<ActionBar mt={ -6 }>
<ActionBar mt={ -6 } showShadow={ isLoading }>
{ !isMobile && tokenFilterComponent }
{ !tokenFilter && (
<TokenTransferFilter
+3 -1
View File
@@ -138,7 +138,7 @@ const AddressTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}
return (
<>
{ !isMobile && (
<ActionBar mt={ -6 }>
<ActionBar mt={ -6 } showShadow={ addressTxsQuery.isLoading }>
{ filter }
{ currentAddress && <AddressCsvExportLink address={ currentAddress } type="transactions" ml="auto"/> }
{ addressTxsQuery.isPaginationVisible && <Pagination { ...addressTxsQuery.pagination } ml={ 8 }/> }
@@ -152,6 +152,8 @@ const AddressTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}
showSocketInfo={ addressTxsQuery.pagination.page === 1 }
socketInfoAlert={ socketAlert }
socketInfoNum={ newItemsCount }
top={ 80 }
hasLongSkeleton
/>
</>
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

@@ -43,6 +43,10 @@ const AddressCoinBalanceHistory = ({ query }: Props) => {
return <DataFetchAlert/>;
}
if (query.data.items.length === 0 && !query.isPaginationVisible) {
return <span>There is no coin balance history for this address</span>;
}
return (
<>
<Hide below="lg" ssr={ false }>
+7 -7
View File
@@ -23,7 +23,7 @@ test('verified with changed byte code +@mobile +@dark-mode', async({ mount, page
const component = await mount(
<TestApp>
<ContractCode/>
<ContractCode addressHash={ addressHash }/>
</TestApp>,
{ hooksConfig },
);
@@ -39,7 +39,7 @@ test('verified with multiple sources +@mobile', async({ mount, page }) => {
await mount(
<TestApp>
<ContractCode/>
<ContractCode addressHash={ addressHash }/>
</TestApp>,
{ hooksConfig },
);
@@ -57,7 +57,7 @@ test('verified via sourcify', async({ mount, page }) => {
await mount(
<TestApp>
<ContractCode/>
<ContractCode addressHash={ addressHash }/>
</TestApp>,
{ hooksConfig },
);
@@ -73,7 +73,7 @@ test('self destructed', async({ mount, page }) => {
await mount(
<TestApp>
<ContractCode/>
<ContractCode addressHash={ addressHash }/>
</TestApp>,
{ hooksConfig },
);
@@ -90,7 +90,7 @@ test('with twin address alert +@mobile', async({ mount, page }) => {
const component = await mount(
<TestApp>
<ContractCode/>
<ContractCode addressHash={ addressHash }/>
</TestApp>,
{ hooksConfig },
);
@@ -106,7 +106,7 @@ test('with proxy address alert +@mobile', async({ mount, page }) => {
const component = await mount(
<TestApp>
<ContractCode/>
<ContractCode addressHash={ addressHash }/>
</TestApp>,
{ hooksConfig },
);
@@ -122,7 +122,7 @@ test('non verified', async({ mount, page }) => {
const component = await mount(
<TestApp>
<ContractCode/>
<ContractCode addressHash={ addressHash }/>
</TestApp>,
{ hooksConfig },
);
+10 -9
View File
@@ -1,11 +1,9 @@
import { Flex, Skeleton, Button, Grid, GridItem, Text, Alert, Link, chakra, Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { route } from 'nextjs-routes';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import dayjs from 'lib/date/dayjs';
import getQueryParamString from 'lib/router/getQueryParamString';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
@@ -16,6 +14,10 @@ import RawDataSnippet from 'ui/shared/RawDataSnippet';
import ContractSourceCode from './ContractSourceCode';
type Props = {
addressHash?: string;
}
const InfoItem = chakra(({ label, value, className }: { label: string; value: string; className?: string }) => (
<GridItem display="flex" columnGap={ 6 } wordBreak="break-all" className={ className }>
<Text w="170px" flexShrink={ 0 } fontWeight={ 500 }>{ label }</Text>
@@ -23,10 +25,7 @@ const InfoItem = chakra(({ label, value, className }: { label: string; value: st
</GridItem>
));
const ContractCode = () => {
const router = useRouter();
const addressHash = getQueryParamString(router.query.hash);
const ContractCode = ({ addressHash }: Props) => {
const { data, isLoading, isError } = useApiQuery('contract', {
pathParams: { hash: addressHash },
queryOptions: {
@@ -62,7 +61,7 @@ const ContractCode = () => {
ml="auto"
mr={ 3 }
as="a"
href={ route({ pathname: '/address/[hash]/contract_verification', query: { hash: addressHash } }) }
href={ route({ pathname: '/address/[hash]/contract_verification', query: { hash: addressHash || '' } }) }
>
Verify & publish
</Button>
@@ -115,7 +114,7 @@ const ContractCode = () => {
{ data.is_verified_via_sourcify && (
<Alert status="warning" whiteSpace="pre-wrap" flexWrap="wrap">
<span>This contract has been { data.is_partially_verified ? 'partially ' : '' }verified via Sourcify. </span>
{ data.sourcify_repo_url && <LinkExternal href={ data.sourcify_repo_url } title="View contract in Sourcify repository" fontSize="md"/> }
{ data.sourcify_repo_url && <LinkExternal href={ data.sourcify_repo_url } fontSize="md">View contract in Sourcify repository</LinkExternal> }
</Alert>
) }
{ data.is_changed_bytecode && (
@@ -131,7 +130,9 @@ const ContractCode = () => {
<AddressLink type="address" hash={ data.verified_twin_address_hash } truncation="constant" ml={ 2 }/>
</Address>
<chakra.span mt={ 1 }>All functions displayed below are from ABI of that contract. In order to verify current contract, proceed with </chakra.span>
<LinkInternal href={ route({ pathname: '/address/[hash]/contract_verification', query: { hash: addressHash } }) }>Verify & Publish</LinkInternal>
<LinkInternal href={ route({ pathname: '/address/[hash]/contract_verification', query: { hash: addressHash || '' } }) }>
Verify & Publish
</LinkInternal>
<span> page</span>
</Alert>
) }
@@ -35,50 +35,55 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, renderC
setId((id) => id + 1);
}, []);
if (data.length === 0) {
return null;
}
return (
<Accordion allowMultiple position="relative" onChange={ handleAccordionStateChange } index={ expandedSections }>
{ data.map((item, index) => {
return (
<AccordionItem key={ index } as="section" _first={{ borderTopWidth: '0', '.chakra-accordion__button': { pr: '150px' } }}>
<h2>
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left">
<Box as="span" fontFamily="heading" fontWeight={ 500 } fontSize="lg" mr={ 1 }>
{ index + 1 }. { item.type === 'fallback' || item.type === 'receive' ? item.type : item.name }
</Box>
{ item.type === 'fallback' && (
<Hint
label={
`The fallback function is executed on a call to the contract if none of the other functions match
<>
<Flex mb={ 3 }>
<Box fontWeight={ 500 }>Contract information</Box>
<Link onClick={ handleExpandAll } ml="auto">{ expandedSections.length === data.length ? 'Collapse' : 'Expand' } all</Link>
<Link onClick={ handleReset } ml={ 3 }>Reset</Link>
</Flex>
<Accordion allowMultiple position="relative" onChange={ handleAccordionStateChange } index={ expandedSections }>
{ data.map((item, index) => {
return (
<AccordionItem key={ index } as="section" _first={{ borderTopWidth: '0' }}>
<h2>
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left">
<Box as="span" fontWeight={ 500 } mr={ 1 }>
{ index + 1 }. { item.type === 'fallback' || item.type === 'receive' ? item.type : item.name }
</Box>
{ item.type === 'fallback' && (
<Hint
label={
`The fallback function is executed on a call to the contract if none of the other functions match
the given function signature, or if no data was supplied at all and there is no receive Ether function.
The fallback function always receives data, but in order to also receive Ether it must be marked payable.`
}/>
) }
{ item.type === 'receive' && (
<Hint
label={
`The receive function is executed on a call to the contract with empty calldata.
}/>
) }
{ item.type === 'receive' && (
<Hint
label={
`The receive function is executed on a call to the contract with empty calldata.
This is the function that is executed on plain Ether transfers (e.g. via .send() or .transfer()).
If no such function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer.
If neither a receive Ether nor a payable fallback function is present,
the contract cannot receive Ether through regular transactions and throws an exception.`
}/>
) }
<AccordionIcon/>
</AccordionButton>
</h2>
<AccordionPanel pb={ 4 } px={ 0 }>
{ renderContent(item, index, id) }
</AccordionPanel>
</AccordionItem>
);
}) }
{ data.length > 0 && (
<Flex columnGap={ 3 } position="absolute" top={ 0 } right={ 0 } py={ 3 } lineHeight="27px">
<Link onClick={ handleExpandAll }>{ expandedSections.length === data.length ? 'Collapse' : 'Expand' } all</Link>
<Link onClick={ handleReset }>Reset</Link>
</Flex>
) }
</Accordion>
}/>
) }
<AccordionIcon/>
</AccordionButton>
</h2>
<AccordionPanel pb={ 4 } px={ 0 }>
{ renderContent(item, index, id) }
</AccordionPanel>
</AccordionItem>
);
}) }
</Accordion>
</>
);
};
+2 -2
View File
@@ -28,7 +28,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
const component = await mount(
<TestApp>
<ContractRead/>
<ContractRead addressHash={ addressHash }/>
</TestApp>,
{ hooksConfig },
);
@@ -57,7 +57,7 @@ test('error result', async({ mount, page }) => {
const component = await mount(
<TestApp>
<ContractRead/>
<ContractRead addressHash={ addressHash }/>
</TestApp>,
{ hooksConfig },
);
+2 -6
View File
@@ -1,5 +1,4 @@
import { Alert, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import { useAccount } from 'wagmi';
@@ -7,7 +6,6 @@ import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'type
import useApiFetch from 'lib/api/useApiFetch';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
@@ -20,17 +18,15 @@ import ContractMethodConstant from './ContractMethodConstant';
import ContractReadResult from './ContractReadResult';
interface Props {
addressHash?: string;
isProxy?: boolean;
isCustomAbi?: boolean;
}
const ContractRead = ({ isProxy, isCustomAbi }: Props) => {
const router = useRouter();
const ContractRead = ({ addressHash, isProxy, isCustomAbi }: Props) => {
const apiFetch = useApiFetch();
const { address: userAddress } = useAccount();
const addressHash = getQueryParamString(router.query.hash);
const { data, isLoading, isError } = useApiQuery(isProxy ? 'contract_methods_read_proxy' : 'contract_methods_read', {
pathParams: { hash: addressHash },
queryParams: {
+1 -1
View File
@@ -23,7 +23,7 @@ test('base view +@mobile', async({ mount, page }) => {
const component = await mount(
<TestApp>
<ContractWrite/>
<ContractWrite addressHash={ addressHash }/>
</TestApp>,
{ hooksConfig },
);
+2 -6
View File
@@ -1,5 +1,4 @@
import _capitalize from 'lodash/capitalize';
import { useRouter } from 'next/router';
import React from 'react';
import { useAccount, useSigner } from 'wagmi';
@@ -7,7 +6,6 @@ import type { SmartContractWriteMethod } from 'types/api/contract';
import config from 'configs/app/config';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
@@ -21,14 +19,12 @@ import ContractWriteResult from './ContractWriteResult';
import { getNativeCoinValue, isExtendedError } from './utils';
interface Props {
addressHash?: string;
isProxy?: boolean;
isCustomAbi?: boolean;
}
const ContractWrite = ({ isProxy, isCustomAbi }: Props) => {
const router = useRouter();
const addressHash = getQueryParamString(router.query.hash);
const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => {
const { data: signer } = useSigner();
const { isConnected } = useAccount();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+2 -4
View File
@@ -1,6 +1,5 @@
import { useQueryClient } from '@tanstack/react-query';
import type { Contract } from 'ethers';
import { useRouter } from 'next/router';
import React from 'react';
import { useContract, useProvider, useSigner } from 'wagmi';
@@ -9,6 +8,7 @@ import type { Address } from 'types/api/address';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
type ProviderProps = {
addressHash?: string;
children: React.ReactNode;
}
@@ -24,13 +24,11 @@ const ContractContext = React.createContext<TContractContext>({
custom: null,
});
export function ContractContextProvider({ children }: ProviderProps) {
const router = useRouter();
export function ContractContextProvider({ addressHash, children }: ProviderProps) {
const provider = useProvider();
const { data: signer } = useSigner();
const queryClient = useQueryClient();
const addressHash = router.query.hash?.toString();
const { data: contractInfo } = useApiQuery('contract', {
pathParams: { hash: addressHash },
queryOptions: {
+1 -1
View File
@@ -20,7 +20,7 @@ const AddressNameInfo = ({ data }: Props) => {
hint="Token name and symbol"
>
<LinkInternal href={ route({ pathname: '/token/[hash]', query: { hash: data.token.address } }) }>
{ data.token.name }{ symbol }
{ data.token.name || 'Unnamed token' }{ symbol }
</LinkInternal>
</DetailsInfoItem>
);
@@ -1,11 +0,0 @@
import React from 'react';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
const TxInternalsSkeletonDesktop = () => {
return (
<SkeletonTable columns={ [ '15%', '15%', '10%', '20%', '20%', '20%' ] }/>
);
};
export default TxInternalsSkeletonDesktop;
@@ -1,48 +0,0 @@
import { Skeleton, SkeletonCircle, Flex, Box } from '@chakra-ui/react';
import React from 'react';
const TxInternalsSkeletonMobile = () => {
return (
<Box>
{ Array.from(Array(2)).map((item, index) => (
<Flex
key={ index }
rowGap={ 3 }
flexDirection="column"
paddingY={ 6 }
borderTopWidth="1px"
borderColor="divider"
_last={{
borderBottomWidth: '1px',
}}
>
<Flex h={ 6 }>
<Skeleton w="100px" mr={ 2 }/>
<Skeleton w="90px"/>
</Flex>
<Flex h={ 6 }>
<Skeleton w="100%" mr={ 2 }/>
<Skeleton w="60px"/>
</Flex>
<Flex h={ 6 }>
<Skeleton w="70px" mr={ 2 }/>
<Skeleton w="30px"/>
</Flex>
<Flex h={ 6 }>
<SkeletonCircle size="6" mr={ 2 } flexShrink={ 0 }/>
<Skeleton flexGrow={ 1 } mr={ 3 }/>
<Skeleton w={ 6 } mr={ 3 }/>
<SkeletonCircle size="6" mr={ 2 } flexShrink={ 0 }/>
<Skeleton flexGrow={ 1 } mr={ 3 }/>
</Flex>
<Flex h={ 6 }>
<Skeleton w="70px" mr={ 2 }/>
<Skeleton w="30px"/>
</Flex>
</Flex>
)) }
</Box>
);
};
export default TxInternalsSkeletonMobile;
+75
View File
@@ -0,0 +1,75 @@
/* eslint-disable @typescript-eslint/naming-convention */
const SwaggerUIReact = dynamic(() => import('swagger-ui-react'), {
loading: () => <Spinner/>,
ssr: false,
});
import { Box, Spinner, useColorModeValue } from '@chakra-ui/react';
import dynamic from 'next/dynamic';
import React from 'react';
import appConfig from 'configs/app/config';
import 'swagger-ui-react/swagger-ui.css';
const DEFAULT_SERVER = 'blockscout.com/poa/core';
const NeverShowInfoPlugin = () => {
return {
components: {
SchemesContainer: () => null,
ServersContainer: () => null,
InfoContainer: () => null,
},
};
};
const SwaggerUI = () => {
const swaggerStyle = {
'.scheme-container, .opblock-tag': {
display: 'none',
},
'.swagger-ui': {
color: useColorModeValue('blackAlpha.800', 'whiteAlpha.800'),
},
'.swagger-ui .opblock-summary-control:focus': {
outline: 'none',
},
// eslint-disable-next-line max-len
'.swagger-ui .opblock .opblock-summary-path, .swagger-ui .opblock .opblock-summary-description, .swagger-ui div, .swagger-ui p, .swagger-ui h5, .swagger-ui .response-col_links, .swagger-ui h4, .swagger-ui table thead tr th, .swagger-ui table thead tr td, .swagger-ui .parameter__name, .swagger-ui .parameter__type, .swagger-ui .response-col_status, .swagger-ui .tab li, .swagger-ui .opblock .opblock-section-header h4': {
color: 'unset',
},
'.swagger-ui input': {
color: 'blackAlpha.800',
},
'.swagger-ui .opblock .opblock-section-header': {
background: useColorModeValue('whiteAlpha.800', 'blackAlpha.800'),
},
'.swagger-ui .response-col_description__inner p, .swagger-ui .parameters-col_description p': {
margin: 0,
},
'.swagger-ui .wrapper': {
padding: 0,
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const reqInterceptor = React.useCallback((req: any) => {
if (!req.loadSpec) {
req.url = req.url.replace(DEFAULT_SERVER, appConfig.api.host);
}
return req;
}, []);
return (
<Box sx={ swaggerStyle }>
<SwaggerUIReact
url={ appConfig.apiDoc.specUrl }
plugins={ [ NeverShowInfoPlugin ] }
requestInterceptor={ reqInterceptor }
/>
</Box>
);
};
export default SwaggerUI;
+7 -7
View File
@@ -118,7 +118,7 @@ const BlockDetails = () => {
hint="The number of transactions in the block"
>
<LinkInternal href={ route({ pathname: '/block/[height]', query: { height, tab: 'txs' } }) }>
{ data.tx_count } transactions
{ data.tx_count } transaction{ data.tx_count === 1 ? '' : 's' }
</LinkInternal>
</DetailsInfoItem>
<DetailsInfoItem
@@ -274,18 +274,18 @@ const BlockDetails = () => {
<DetailsInfoItem
title="Difficulty"
hint={ `Block difficulty for ${ validatorTitle }, used to calibrate block generation time` }
whiteSpace="normal"
wordBreak="break-all"
>
{ BigNumber(data.difficulty).toFormat() }
<Box whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ BigNumber(data.difficulty).toFormat() }/>
</Box>
</DetailsInfoItem>
<DetailsInfoItem
title="Total difficulty"
hint="Total difficulty of the chain until this block"
whiteSpace="normal"
wordBreak="break-all"
>
{ BigNumber(data.total_difficulty).toFormat() }
<Box whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ BigNumber(data.total_difficulty).toFormat() }/>
</Box>
</DetailsInfoItem>
{ sectionGap }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 94 KiB

@@ -3,6 +3,7 @@ import React from 'react';
import type { SmartContractVerificationConfig } from 'types/api/contract';
import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp';
import ContractVerificationForm from './ContractVerificationForm';
@@ -34,6 +35,7 @@ const formConfig: SmartContractVerificationConfig = {
'sourcify',
'multi-part',
'vyper-code',
'vyper-multi-part',
],
vyper_compiler_versions: [
'v0.3.7+commit.6020b8bb',
@@ -60,8 +62,9 @@ test('flatten source code method +@dark-mode +@mobile', async({ mount, page }) =
{ hooksConfig },
);
await page.getByText(/flattened source code/i).click();
await page.getByText(/optimization enabled/i).click();
await component.getByLabel(/verification method/i).focus();
await component.getByLabel(/verification method/i).type('solidity');
await page.getByRole('button', { name: /flattened source code/i }).click();
await page.getByText(/add contract libraries/i).click();
await page.locator('button[aria-label="add"]').click();
@@ -76,28 +79,61 @@ test('standard input json method', async({ mount, page }) => {
{ hooksConfig },
);
await page.getByText(/via standard/i).click();
await component.getByLabel(/verification method/i).focus();
await component.getByLabel(/verification method/i).type('solidity');
await page.getByRole('button', { name: /standard json input/i }).click();
await expect(component).toHaveScreenshot();
});
test('sourcify method +@dark-mode +@mobile', async({ mount, page }) => {
const component = await mount(
<TestApp>
<ContractVerificationForm config={ formConfig } hash={ hash }/>
</TestApp>,
{ hooksConfig },
);
test.describe('sourcify', () => {
const testWithSocket = test.extend<socketServer.SocketServerFixture>({
createSocket: socketServer.createSocket,
});
testWithSocket.describe.configure({ mode: 'serial', timeout: 20_000 });
await page.getByText(/via sourcify/i).click();
await page.getByText(/upload files/i).click();
await page.locator('input[name="sources"]').setInputFiles([
'./playwright/mocks/file_mock_1.json',
'./playwright/mocks/file_mock_2.json',
'./playwright/mocks/file_mock_with_very_long_name.json',
]);
testWithSocket('with multiple contracts +@mobile', async({ mount, page, createSocket }) => {
const component = await mount(
<TestApp withSocket>
<ContractVerificationForm config={ formConfig } hash={ hash }/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
await component.getByLabel(/verification method/i).focus();
await component.getByLabel(/verification method/i).type('solidity');
await page.getByRole('button', { name: /sourcify/i }).click();
await page.getByText(/drop files/i).click();
await page.locator('input[name="sources"]').setInputFiles([
'./playwright/mocks/file_mock_1.json',
'./playwright/mocks/file_mock_2.json',
'./playwright/mocks/file_mock_with_very_long_name.json',
]);
await expect(component).toHaveScreenshot();
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, `addresses:${ hash.toLowerCase() }`);
await page.getByRole('button', { name: /verify/i }).click();
socketServer.sendMessage(socket, channel, 'verification_result', {
status: 'error',
errors: {
// eslint-disable-next-line max-len
files: [ 'Detected 5 contracts (ERC20, IERC20, IERC20Metadata, Context, MockERC20), but can only verify 1 at a time. Please choose a main contract and click Verify again.' ],
},
});
await component.getByLabel(/contract name/i).focus();
await component.getByLabel(/contract name/i).type('e');
const contractNameOption = page.getByRole('button', { name: /MockERC20/i });
await expect(contractNameOption).toBeVisible();
await expect(component).toHaveScreenshot();
});
});
test('multi-part files method', async({ mount, page }) => {
@@ -108,7 +144,9 @@ test('multi-part files method', async({ mount, page }) => {
{ hooksConfig },
);
await page.getByText(/via multi-part files/i).click();
await component.getByLabel(/verification method/i).focus();
await component.getByLabel(/verification method/i).type('solidity');
await page.getByRole('button', { name: /multi-part files/i }).click();
await expect(component).toHaveScreenshot();
});
@@ -121,7 +159,24 @@ test('vyper contract method', async({ mount, page }) => {
{ hooksConfig },
);
await page.getByText(/vyper contract/i).click();
await component.getByLabel(/verification method/i).focus();
await component.getByLabel(/verification method/i).type('vyper');
await page.getByRole('button', { name: /contract/i }).click();
await expect(component).toHaveScreenshot();
});
test('vyper multi-part method', async({ mount, page }) => {
const component = await mount(
<TestApp>
<ContractVerificationForm config={ formConfig } hash={ hash }/>
</TestApp>,
{ hooksConfig },
);
await component.getByLabel(/verification method/i).focus();
await component.getByLabel(/verification method/i).type('vyper');
await page.getByRole('button', { name: /multi-part files/i }).click();
await expect(component).toHaveScreenshot();
});
@@ -1,4 +1,4 @@
import { Button, chakra } from '@chakra-ui/react';
import { Button, chakra, useUpdateEffect } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
@@ -20,7 +20,7 @@ import ContractVerificationSourcify from './methods/ContractVerificationSourcify
import ContractVerificationStandardInput from './methods/ContractVerificationStandardInput';
import ContractVerificationVyperContract from './methods/ContractVerificationVyperContract';
import ContractVerificationVyperMultiPartFile from './methods/ContractVerificationVyperMultiPartFile';
import { prepareRequestBody, formatSocketErrors } from './utils';
import { prepareRequestBody, formatSocketErrors, DEFAULT_VALUES } from './utils';
const METHOD_COMPONENTS = {
'flattened-code': <ContractVerificationFlattenSourceCode/>,
@@ -40,11 +40,9 @@ interface Props {
const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Props) => {
const formApi = useForm<FormFields>({
mode: 'onBlur',
defaultValues: {
method: methodFromQuery,
},
defaultValues: methodFromQuery ? DEFAULT_VALUES[methodFromQuery] : undefined,
});
const { control, handleSubmit, watch, formState, setError } = formApi;
const { control, handleSubmit, watch, formState, setError, reset } = formApi;
const submitPromiseResolver = React.useRef<(value: unknown) => void>();
const apiFetch = useApiFetch();
@@ -56,7 +54,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
try {
await apiFetch('contract_verification_via', {
pathParams: { method: data.method, hash },
pathParams: { method: data.method.value, hash: hash.toLowerCase() },
fetchParams: {
method: 'POST',
body,
@@ -109,7 +107,10 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
variant: 'subtle',
isClosable: true,
});
}, [ formState.isSubmitting, toast ]);
// callback should not change when form is submitted
// otherwise it will resubscribe to channel, but we don't want that since in that case we might miss verification result message
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ toast ]);
const channel = useSocketChannel({
topic: `addresses:${ hash.toLowerCase() }`,
@@ -124,7 +125,15 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
});
const method = watch('method');
const content = METHOD_COMPONENTS[method] || null;
const content = METHOD_COMPONENTS[method?.value] || null;
const methodValue = method?.value;
useUpdateEffect(() => {
if (methodValue) {
reset(DEFAULT_VALUES[methodValue]);
}
// !!! should run only when method is changed
}, [ methodValue ]);
return (
<FormProvider { ...formApi }>
@@ -134,8 +143,8 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
>
<ContractVerificationFieldMethod
control={ control }
isDisabled={ Boolean(method) }
methods={ config.verification_options }
isDisabled={ formState.isSubmitting }
/>
{ content }
{ Boolean(method) && (
@@ -15,8 +15,8 @@ const ContractVerificationMethod = ({ title, children }: Props) => {
return (
<section ref={ ref }>
<Text variant="secondary" mt={ 12 } mb={ 5 } fontSize="sm">{ title }</Text>
<Grid columnGap="30px" rowGap={{ base: 2, lg: 4 }} templateColumns={{ base: '1fr', lg: 'minmax(auto, 680px) minmax(0, 340px)' }}>
<Text fontWeight={ 500 } fontSize="lg" fontFamily="heading" mt={ 12 } mb={ 5 }>{ title }</Text>
<Grid columnGap="30px" rowGap={{ base: 2, lg: 5 }} templateColumns={{ base: '1fr', lg: 'minmax(0, 680px) minmax(0, 340px)' }}>
{ children }
</Grid>
</section>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 101 KiB

@@ -34,7 +34,6 @@ const ContractVerificationFieldAutodetectArgs = () => {
name="autodetect_constructor_args"
control={ control }
render={ renderControl }
defaultValue={ true }
/>
</ContractVerificationFormRow>
{ !isOn && <ContractVerificationFieldConstructorArgs/> }
@@ -41,7 +41,6 @@ const ContractVerificationFieldCode = ({ isVyper }: Props) => {
control={ control }
render={ renderControl }
rules={{ required: true }}
defaultValue=""
/>
{ isVyper ? null : (
<>
@@ -1,4 +1,4 @@
import { Code } from '@chakra-ui/react';
import { chakra, Checkbox, Code } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
@@ -20,26 +20,30 @@ interface Props {
}
const ContractVerificationFieldCompiler = ({ isVyper }: Props) => {
const { formState, control } = useFormContext<FormFields>();
const [ isNightly, setIsNightly ] = React.useState(false);
const { formState, control, getValues, resetField } = useFormContext<FormFields>();
const isMobile = useIsMobile();
const queryClient = useQueryClient();
const config = queryClient.getQueryData<SmartContractVerificationConfig>(getResourceKey('contract_verification_config'));
const handleCheckboxChange = React.useCallback(() => {
if (isNightly) {
const field = getValues('compiler');
field?.value.includes('nightly') && resetField('compiler', { defaultValue: null });
}
setIsNightly(prev => !prev);
}, [ getValues, isNightly, resetField ]);
const options = React.useMemo(() => (
(isVyper ? config?.vyper_compiler_versions : config?.solidity_compiler_versions)?.map((option) => ({ label: option, value: option })) || []
), [ config?.solidity_compiler_versions, config?.vyper_compiler_versions, isVyper ]);
const loadOptions = React.useCallback(async(inputValue: string) => {
return options
.filter(({ label }) => {
if (!inputValue) {
return !label.toLowerCase().includes('nightly');
}
return label.toLowerCase().includes(inputValue.toLowerCase());
})
.filter(({ label }) => !inputValue || label.toLowerCase().includes(inputValue.toLowerCase()))
.filter(({ label }) => isNightly ? true : !label.includes('nightly'))
.slice(0, OPTIONS_LIMIT);
}, [ options ]);
}, [ isNightly, options ]);
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'compiler'>}) => {
const error = 'compiler' in formState.errors ? formState.errors.compiler : undefined;
@@ -61,20 +65,32 @@ const ContractVerificationFieldCompiler = ({ isVyper }: Props) => {
return (
<ContractVerificationFormRow>
<Controller
name="compiler"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
<>
{ !isVyper && (
<Checkbox
size="lg"
mb={ 2 }
onChange={ handleCheckboxChange }
isDisabled={ formState.isSubmitting }
>
Include nightly builds
</Checkbox>
) }
<Controller
name="compiler"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
</>
{ isVyper ? null : (
<>
<span>The compiler version is specified in </span>
<chakra.div mt={{ base: 0, lg: 8 }}>
<span >The compiler version is specified in </span>
<Code color="text_secondary">pragma solidity X.X.X</Code>
<span>. Use the compiler version rather than the nightly build. If using the Solidity compiler, run </span>
<Code color="text_secondary">solc —version</Code>
<span> to check.</span>
</>
</chakra.div>
) }
</ContractVerificationFormRow>
);
@@ -0,0 +1,76 @@
import { useUpdateEffect } from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { useFormContext, Controller } from 'react-hook-form';
import type { FormFields } from '../types';
import type { Option } from 'ui/shared/FancySelect/types';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
const SOURCIFY_ERROR_REGEXP = /\(([^()]*)\)/;
const ContractVerificationFieldContractIndex = () => {
const [ options, setOptions ] = React.useState<Array<Option>>([]);
const { formState, control, watch } = useFormContext<FormFields>();
const isMobile = useIsMobile();
const sources = watch('sources');
const sourcesError = 'sources' in formState.errors ? formState.errors.sources?.message : undefined;
useUpdateEffect(() => {
if (!sourcesError) {
return;
}
const matchResult = sourcesError.match(SOURCIFY_ERROR_REGEXP);
const parsedMethods = matchResult?.[1].split(',');
if (!Array.isArray(parsedMethods) || parsedMethods.length === 0) {
return;
}
const newOptions = parsedMethods.map((option, index) => ({ label: option, value: String(index + 1) }));
setOptions(newOptions);
}, [ sourcesError ]);
useUpdateEffect(() => {
setOptions([]);
}, [ sources ]);
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'contract_index'>}) => {
const error = 'contract_index' in formState.errors ? formState.errors.contract_index : undefined;
return (
<FancySelect
{ ...field }
options={ options }
size={ isMobile ? 'md' : 'lg' }
placeholder="Contract name"
isDisabled={ formState.isSubmitting }
error={ error }
isRequired
isAsync={ false }
/>
);
}, [ formState.errors, formState.isSubmitting, isMobile, options ]);
if (options.length === 0) {
return null;
}
return (
<ContractVerificationFormRow>
<Controller
name="contract_index"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
</ContractVerificationFormRow>
);
};
export default React.memo(ContractVerificationFieldContractIndex);
@@ -1,4 +1,4 @@
import { Checkbox } from '@chakra-ui/react';
import { Checkbox, useUpdateEffect } from '@chakra-ui/react';
import React from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
@@ -8,13 +8,21 @@ import ContractVerificationFormRow from '../ContractVerificationFormRow';
import ContractVerificationFieldLibraryItem from './ContractVerificationFieldLibraryItem';
const ContractVerificationFieldLibraries = () => {
const { formState, control } = useFormContext<FormFields>();
const { formState, control, getValues } = useFormContext<FormFields>();
const { fields, append, remove, insert } = useFieldArray({
name: 'libraries',
control,
});
const [ isEnabled, setIsEnabled ] = React.useState(fields.length > 0);
const value = getValues('libraries');
useUpdateEffect(() => {
if (!value || value.length === 0) {
setIsEnabled(false);
}
}, [ value ]);
const handleCheckboxChange = React.useCallback(() => {
if (!isEnabled) {
append({ name: '', address: '' });
@@ -1,8 +1,4 @@
import {
RadioGroup,
Radio,
Stack,
Text,
Link,
Icon,
chakra,
@@ -14,16 +10,23 @@ import {
PopoverBody,
useColorModeValue,
DarkMode,
useBoolean,
ListItem,
OrderedList,
Grid,
Box,
} from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps, Control } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { FormFields } from '../types';
import type { SmartContractVerificationMethod, SmartContractVerificationConfig } from 'types/api/contract';
import type { SmartContractVerificationConfig, SmartContractVerificationMethod } from 'types/api/contract';
import infoIcon from 'icons/info.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
import { METHOD_LABELS } from '../utils';
interface Props {
control: Control<FormFields>;
@@ -32,91 +35,93 @@ interface Props {
}
const ContractVerificationFieldMethod = ({ control, isDisabled, methods }: Props) => {
const [ isPopoverOpen, setIsPopoverOpen ] = useBoolean();
const tooltipBg = useColorModeValue('gray.700', 'gray.900');
const isMobile = useIsMobile();
const renderItem = React.useCallback((method: SmartContractVerificationMethod) => {
const options = React.useMemo(() => methods.map((method) => ({
value: method,
label: METHOD_LABELS[method],
})), [ methods ]);
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'method'>}) => {
return (
<FancySelect
{ ...field }
options={ options }
size={ isMobile ? 'md' : 'lg' }
placeholder="Verification method (compiler type)"
isDisabled={ isDisabled }
isRequired
isAsync={ false }
/>
);
}, [ isDisabled, isMobile, options ]);
const renderPopoverListItem = React.useCallback((method: SmartContractVerificationMethod) => {
switch (method) {
case 'flattened-code':
return 'Via flattened source code';
return <ListItem>Verification through flattened source code.</ListItem>;
case 'multi-part':
return <ListItem>Verification of multi-part Solidity files.</ListItem>;
case 'sourcify':
return <ListItem>Verification through <Link href="https://sourcify.dev/" target="_blank">Sourcify</Link>.</ListItem>;
case 'standard-input':
return (
<>
<span>Via standard </span>
<ListItem>
<span>Verification using </span>
<Link
href={ isDisabled ? undefined : 'https://docs.soliditylang.org/en/latest/using-the-compiler.html#input-description' }
href="https://docs.soliditylang.org/en/latest/using-the-compiler.html#input-description"
target="_blank"
cursor={ isDisabled ? 'not-allowed' : 'pointer' }
>
Input JSON
Standard input JSON
</Link>
</>
<span> file.</span>
</ListItem>
);
case 'sourcify':
return (
<>
<span>Via sourcify: sources and metadata JSON file</span>
<Popover trigger="hover" isLazy isOpen={ isDisabled ? false : isPopoverOpen } onOpen={ setIsPopoverOpen.on } onClose={ setIsPopoverOpen.off }>
case 'vyper-code':
return <ListItem>Verification of Vyper contract.</ListItem>;
case 'vyper-multi-part':
return <ListItem>Verification of multi-part Vyper files.</ListItem>;
}
}, []);
return (
<section>
<Grid columnGap="30px" rowGap={{ base: 2, lg: 4 }} templateColumns={{ base: '1fr', lg: 'minmax(auto, 680px) minmax(0, 340px)' }}>
<div>
<Box mb={ 5 }>
<chakra.span fontWeight={ 500 } fontSize="lg" fontFamily="heading">
Currently, Blockscout supports { methods.length } contract verification methods
</chakra.span>
<Popover trigger="hover" isLazy placement={ isMobile ? 'bottom-end' : 'right-start' } offset={ [ -8, 8 ] }>
<PopoverTrigger>
<chakra.span cursor={ isDisabled ? 'not-allowed' : 'pointer' } display="inline-block" verticalAlign="middle" h="24px" ml={ 1 }>
<chakra.span display="inline-block" ml={ 1 } cursor="pointer" verticalAlign="middle" h="22px">
<Icon as={ infoIcon } boxSize={ 5 } color="link" _hover={{ color: 'link_hovered' }}/>
</chakra.span>
</PopoverTrigger>
<Portal>
<PopoverContent bgColor={ tooltipBg }>
<PopoverContent bgColor={ tooltipBg } w={{ base: '300px', lg: '380px' }}>
<PopoverArrow bgColor={ tooltipBg }/>
<PopoverBody color="white">
<DarkMode>
<div>
<span>Verification through </span>
<Link href="https://sourcify.dev/" target="_blank">Sourcify</Link>
</div>
<div>
<span>a) if smart-contract already verified on Sourcify, it will automatically fetch the data from the </span>
<Link href="https://repo.sourcify.dev/" target="_blank">repo</Link>
</div>
<div>
b) otherwise you will be asked to upload source files and JSON metadata file(s).
</div>
<span>Currently, Blockscout supports { methods.length } methods:</span>
<OrderedList>
{ methods.map(renderPopoverListItem) }
</OrderedList>
</DarkMode>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
</>
);
case 'multi-part':
return 'Via multi-part files';
case 'vyper-code':
return 'Vyper contract';
case 'vyper-multi-part':
return 'Via multi-part Vyper files';
default:
break;
}
}, [ isDisabled, isPopoverOpen, setIsPopoverOpen.off, setIsPopoverOpen.on, tooltipBg ]);
const renderRadioGroup = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'method'>}) => {
return (
<RadioGroup defaultValue="add" colorScheme="blue" isDisabled={ isDisabled } isFocusable={ !isDisabled } { ...field } >
<Stack spacing={ 4 }>
{ methods.map((method) => {
return <Radio key={ method } value={ method } size="lg">{ renderItem(method) }</Radio>;
}) }
</Stack>
</RadioGroup>
);
}, [ isDisabled, methods, renderItem ]);
return (
<section>
<Text variant="secondary" fontSize="sm" mb={ 5 }>Smart-contract verification method</Text>
<Controller
name="method"
control={ control }
render={ renderRadioGroup }
/>
</Box>
<Controller
name="method"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
</div>
</Grid>
</section>
);
};
@@ -41,7 +41,6 @@ const ContractVerificationFieldName = ({ hint }: Props) => {
control={ control }
render={ renderControl }
rules={{ required: true }}
defaultValue=""
/>
{ hint ? <span>{ hint }</span> : (
<>
@@ -1,4 +1,4 @@
import { FormControl, Input } from '@chakra-ui/react';
import { Flex, Input } from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';
@@ -6,7 +6,6 @@ import { Controller, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import CheckboxInput from 'ui/shared/CheckboxInput';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
@@ -14,56 +13,59 @@ const ContractVerificationFieldOptimization = () => {
const [ isEnabled, setIsEnabled ] = React.useState(true);
const { formState, control } = useFormContext<FormFields>();
const error = 'optimization_runs' in formState.errors ? formState.errors.optimization_runs : undefined;
const handleCheckboxChange = React.useCallback(() => {
setIsEnabled(prev => !prev);
}, []);
const renderCheckboxControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'is_optimization_enabled'>}) => (
<CheckboxInput<FormFields, 'is_optimization_enabled'>
text="Optimization enabled"
field={ field }
onChange={ handleCheckboxChange }
isDisabled={ formState.isSubmitting }
/>
<Flex flexShrink={ 0 }>
<CheckboxInput<FormFields, 'is_optimization_enabled'>
text="Optimization enabled"
field={ field }
onChange={ handleCheckboxChange }
isDisabled={ formState.isSubmitting }
/>
</Flex>
), [ formState.isSubmitting, handleCheckboxChange ]);
const renderInputControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'optimization_runs'>}) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} isRequired>
<Input
{ ...field }
required
isDisabled={ formState.isSubmitting }
autoComplete="off"
type="number"
/>
<InputPlaceholder text="Optimization runs"/>
</FormControl>
<Input
{ ...field }
required
isDisabled={ formState.isSubmitting }
autoComplete="off"
type="number"
placeholder="Optimization runs"
size="xs"
minW="100px"
maxW="200px"
flexShrink={ 1 }
isInvalid={ Boolean(error) }
/>
);
}, [ formState.isSubmitting ]);
}, [ error, formState.isSubmitting ]);
return (
<>
<ContractVerificationFormRow>
<ContractVerificationFormRow>
<Flex columnGap={ 5 } h={{ base: 'auto', lg: '32px' }}>
<Controller
name="is_optimization_enabled"
control={ control }
render={ renderCheckboxControl }
defaultValue={ true }
/>
</ContractVerificationFormRow>
{ isEnabled && (
<ContractVerificationFormRow>
{ isEnabled && (
<Controller
name="optimization_runs"
control={ control }
render={ renderInputControl }
rules={{ required: true }}
defaultValue="200"
/>
</ContractVerificationFormRow>
) }
</>
) }
</Flex>
</ContractVerificationFormRow>
);
};
@@ -1,4 +1,4 @@
import { Text, Button, Box, chakra, Flex } from '@chakra-ui/react';
import { Text, Button, Box, Flex } from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps, FieldPathValue, ValidateResult } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';
@@ -6,7 +6,7 @@ import { Controller, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import { Mb } from 'lib/consts';
// import DragAndDropArea from 'ui/shared/forms/DragAndDropArea';
import DragAndDropArea from 'ui/shared/forms/DragAndDropArea';
import FieldError from 'ui/shared/forms/FieldError';
import FileInput from 'ui/shared/forms/FileInput';
import FileSnippet from 'ui/shared/forms/FileSnippet';
@@ -19,11 +19,10 @@ interface Props {
fileTypes: Array<FileTypes>;
multiple?: boolean;
title: string;
className?: string;
hint: string;
}
const ContractVerificationFieldSources = ({ fileTypes, multiple, title, className, hint }: Props) => {
const ContractVerificationFieldSources = ({ fileTypes, multiple, title, hint }: Props) => {
const { setValue, getValues, control, formState, clearErrors } = useFormContext<FormFields>();
const error = 'sources' in formState.errors ? formState.errors.sources : undefined;
@@ -42,11 +41,28 @@ const ContractVerificationFieldSources = ({ fileTypes, multiple, title, classNam
}, [ getValues, clearErrors, setValue ]);
const renderUploadButton = React.useCallback(() => {
return (
<div>
<Text fontWeight={ 500 } color="text_secondary" mb={ 3 }>{ title }</Text>
<Button size="sm" variant="outline">
Drop file{ multiple ? 's' : '' } or click here
</Button>
</div>
);
}, [ multiple, title ]);
const renderFiles = React.useCallback((files: Array<File>) => {
const errorList = fileError?.message?.split(';');
return (
<Box display="grid" gridTemplateColumns={{ base: 'minmax(0, 1fr)', lg: 'minmax(0, 1fr) minmax(0, 1fr)' }} columnGap={ 3 } rowGap={ 3 }>
<Box
display="grid"
gridTemplateColumns={{ base: 'minmax(0, 1fr)', lg: 'minmax(0, 1fr) minmax(0, 1fr)' }}
columnGap={ 3 }
rowGap={ 3 }
w="100%"
>
{ files.map((file, index) => (
<Box key={ file.name + file.lastModified + index }>
<FileSnippet
@@ -55,42 +71,36 @@ const ContractVerificationFieldSources = ({ fileTypes, multiple, title, classNam
onRemove={ handleFileRemove }
index={ index }
isDisabled={ formState.isSubmitting }
error={ errorList?.[index] }
/>
{ errorList?.[index] && <FieldError message={ errorList?.[index] } mt={ 1 } px={ 3 }/> }
</Box>
)) }
</Box>
);
}, [ formState.isSubmitting, handleFileRemove, fileError ]);
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'sources'>}) => (
<>
<FileInput<FormFields, 'sources'> accept={ fileTypes.join(',') } multiple={ multiple } field={ field }>
{ () => (
<Flex
flexDir="column"
alignItems="flex-start"
rowGap={ 2 }
w="100%"
display={ field.value && field.value.length > 0 && !multiple ? 'none' : 'block' }
mb={ field.value && field.value.length > 0 ? 2 : 0 }
>
<Button
variant="outline"
size="sm"
// mb={ 2 }
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'sources'>}) => {
const hasValue = field.value && field.value.length > 0;
return (
<>
<FileInput<FormFields, 'sources'> accept={ fileTypes.join(',') } multiple={ multiple } field={ field }>
{ ({ onChange }) => (
<Flex
flexDir="column"
alignItems="flex-start"
rowGap={ 2 }
w="100%"
>
Upload file{ multiple ? 's' : '' }
</Button>
{ /* design is not ready */ }
{ /* <DragAndDropArea onDrop={ onChange }/> */ }
</Flex>
) }
</FileInput>
{ field.value && field.value.length > 0 && renderFiles(field.value) }
{ commonError?.message && <FieldError message={ commonError.type === 'required' ? 'Field is required' : commonError.message }/> }
</>
), [ fileTypes, commonError, multiple, renderFiles ]);
<DragAndDropArea onDrop={ onChange } p={{ base: 3, lg: 6 }} isDisabled={ formState.isSubmitting }>
{ hasValue ? renderFiles(field.value) : renderUploadButton() }
</DragAndDropArea>
</Flex>
) }
</FileInput>
{ commonError?.message && <FieldError message={ commonError.type === 'required' ? 'Field is required' : commonError.message }/> }
</>
);
}, [ fileTypes, multiple, commonError, formState.isSubmitting, renderFiles, renderUploadButton ]);
const validateFileType = React.useCallback(async(value: FieldPathValue<FormFields, 'sources'>): Promise<ValidateResult> => {
if (Array.isArray(value)) {
@@ -114,30 +124,34 @@ const ContractVerificationFieldSources = ({ fileTypes, multiple, title, classNam
return true;
}, []);
const validateQuantity = React.useCallback(async(value: FieldPathValue<FormFields, 'sources'>): Promise<ValidateResult> => {
if (!multiple && Array.isArray(value) && value.length > 1) {
return 'You can upload only one file';
}
return true;
}, [ multiple ]);
const rules = React.useMemo(() => ({
required: true,
validate: {
file_type: validateFileType,
file_size: validateFileSize,
quantity: validateQuantity,
},
}), [ validateFileSize, validateFileType ]);
}), [ validateFileSize, validateFileType, validateQuantity ]);
return (
<>
<ContractVerificationFormRow >
<Text fontWeight={ 500 } className={ className } mt={ 4 }>{ title }</Text>
</ContractVerificationFormRow>
<ContractVerificationFormRow>
<Controller
name="sources"
control={ control }
render={ renderControl }
rules={ rules }
/>
{ hint ? <span>{ hint }</span> : null }
</ContractVerificationFormRow>
</>
<ContractVerificationFormRow>
<Controller
name="sources"
control={ control }
render={ renderControl }
rules={ rules }
/>
{ hint ? <span>{ hint }</span> : null }
</ContractVerificationFormRow>
);
};
export default React.memo(chakra(ContractVerificationFieldSources));
export default React.memo(ContractVerificationFieldSources);
@@ -12,9 +12,9 @@ import ContractVerificationFieldOptimization from '../fields/ContractVerificatio
const ContractVerificationFlattenSourceCode = () => {
return (
<ContractVerificationMethod title="New Solidity/Yul Smart Contract Verification">
<ContractVerificationFieldIsYul/>
<ContractVerificationMethod title="Contract verification via Solidity (fattened source code)">
<ContractVerificationFieldName/>
<ContractVerificationFieldIsYul/>
<ContractVerificationFieldCompiler/>
<ContractVerificationFieldEvmVersion/>
<ContractVerificationFieldOptimization/>
@@ -11,7 +11,7 @@ const FILE_TYPES = [ '.sol' as const, '.yul' as const ];
const ContractVerificationMultiPartFile = () => {
return (
<ContractVerificationMethod title="New Solidity/Yul Smart Contract Verification">
<ContractVerificationMethod title="Contract verification via Solidity (multi-part files)">
<ContractVerificationFieldCompiler/>
<ContractVerificationFieldEvmVersion/>
<ContractVerificationFieldOptimization/>
@@ -1,19 +1,21 @@
import React from 'react';
import ContractVerificationMethod from '../ContractVerificationMethod';
import ContractVerificationFieldContractIndex from '../fields/ContractVerificationFieldContractIndex';
import ContractVerificationFieldSources from '../fields/ContractVerificationFieldSources';
const FILE_TYPES = [ '.json' as const, '.sol' as const ];
const ContractVerificationSourcify = () => {
return (
<ContractVerificationMethod title="New Smart Contract Verification">
<ContractVerificationMethod title="Contract verification via Solidity (Sourcify)">
<ContractVerificationFieldSources
fileTypes={ FILE_TYPES }
multiple
title="Sources and Metadata JSON" mt={ 0 }
title="Sources and Metadata JSON"
hint="Upload all Solidity contract source files and JSON metadata file(s) created during contract compilation."
/>
<ContractVerificationFieldContractIndex/>
</ContractVerificationMethod>
);
};
@@ -10,7 +10,7 @@ const FILE_TYPES = [ '.json' as const ];
const ContractVerificationStandardInput = () => {
return (
<ContractVerificationMethod title="New Smart Contract Verification">
<ContractVerificationMethod title="Contract verification via Solidity (standard JSON input) ">
<ContractVerificationFieldName/>
<ContractVerificationFieldCompiler/>
<ContractVerificationFieldSources
@@ -8,7 +8,7 @@ import ContractVerificationFieldName from '../fields/ContractVerificationFieldNa
const ContractVerificationVyperContract = () => {
return (
<ContractVerificationMethod title="New Vyper Smart Contract Verification">
<ContractVerificationMethod title="Contract verification via Vyper (contract)">
<ContractVerificationFieldName hint="Must match the name specified in the code."/>
<ContractVerificationFieldCompiler isVyper/>
<ContractVerificationFieldCode isVyper/>
@@ -9,7 +9,7 @@ const FILE_TYPES = [ '.vy' as const ];
const ContractVerificationVyperMultiPartFile = () => {
return (
<ContractVerificationMethod title="New Vyper Smart Contract Verification">
<ContractVerificationMethod title="Contract verification via Vyper (multi-part files)">
<ContractVerificationFieldCompiler isVyper/>
<ContractVerificationFieldEvmVersion isVyper/>
<ContractVerificationFieldSources
+14 -6
View File
@@ -1,11 +1,18 @@
import type { SmartContractVerificationMethod } from 'types/api/contract';
import type { Option } from 'ui/shared/FancySelect/types';
export interface ContractLibrary {
name: string;
address: string;
}
interface MethodOption {
label: string;
value: SmartContractVerificationMethod;
}
export interface FormFieldsFlattenSourceCode {
method: 'flattened-code';
method: MethodOption;
is_yul: boolean;
name: string;
compiler: Option;
@@ -19,7 +26,7 @@ export interface FormFieldsFlattenSourceCode {
}
export interface FormFieldsStandardInput {
method: 'standard-input';
method: MethodOption;
name: string;
compiler: Option;
sources: Array<File>;
@@ -28,12 +35,13 @@ export interface FormFieldsStandardInput {
}
export interface FormFieldsSourcify {
method: 'sourcify';
method: MethodOption;
sources: Array<File>;
contract_index?: Option;
}
export interface FormFieldsMultiPartFile {
method: 'multi-part';
method: MethodOption;
compiler: Option;
evm_version: Option;
is_optimization_enabled: boolean;
@@ -43,7 +51,7 @@ export interface FormFieldsMultiPartFile {
}
export interface FormFieldsVyperContract {
method: 'vyper-code';
method: MethodOption;
name: string;
compiler: Option;
code: string;
@@ -51,7 +59,7 @@ export interface FormFieldsVyperContract {
}
export interface FormFieldsVyperMultiPartFile {
method: 'vyper-multi-part';
method: MethodOption;
compiler: Option;
evm_version: Option;
sources: Array<File>;

Some files were not shown because too many files have changed in this diff Show More