Merge branch 'main' of github.com:blockscout/frontend into custom-skin

This commit is contained in:
tom
2023-02-21 22:57:23 -05:00
143 changed files with 2110 additions and 524 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
+2
View File
@@ -49,11 +49,13 @@ 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__
+3 -1
View File
@@ -80,6 +80,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
@@ -126,8 +127,9 @@ The app instance could be customized by passing following variables to NodeJS en
| --- | --- | --- | --- |
| NEXT_PUBLIC_SENTRY_DSN | `string` *(optional)* | Client key for your Sentry.io app | `<secret>` |
| SENTRY_CSP_REPORT_URI | `string` *(optional)* | URL for sending CSP-reports to your Sentry.io app | `<secret>` |
| NEXT_PUBLIC_AUTH0_CLIENT_ID | `string` *(optional)* | Client id for [Auth0](https://auth0.com/) provider | `<secret>` |
| NEXT_PUBLIC_AUTH0_CLIENT_ID | `string` | Client id for [Auth0](https://auth0.com/) provider | `<secret>` |
| 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
+6
View File
@@ -127,6 +127,12 @@ 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) || '',
},
});
export default config;
+3 -1
View File
@@ -9,8 +9,10 @@ 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
NEXT_PUBLIC_STATS_API_HOST=https://stats-test.aws-k8s.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=http://94.131.100.174:8050
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.aws-k8s.blockscout.com
File diff suppressed because one or more lines are too long
+4
View File
@@ -491,6 +491,8 @@ frontend:
- "/token"
- "/accounts"
- "/visualize"
- "/api-docs"
- "/csv-export"
resources:
limits:
@@ -571,5 +573,7 @@ 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
NEXT_PUBLIC_IS_TESTNET:
_default: true
+4
View File
@@ -327,6 +327,8 @@ frontend:
- "/tokens"
- "/accounts"
- "/visualize"
- "/api-docs"
- "/csv-export"
resources:
limits:
@@ -408,5 +410,7 @@ 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
NEXT_PUBLIC_IS_TESTNET:
_default: true
+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

+2 -3
View File
@@ -13,10 +13,9 @@ export default function fetchFactory(
// first arg can be only a string
// FIXME migrate to RequestInfo later if needed
return function fetch(url: string, init?: RequestInit): Promise<Response> {
const incomingContentType = _req.headers['content-type'];
const headers = {
accept: 'application/json',
'content-type': incomingContentType?.match(/^multipart\/form-data/) ? incomingContentType : 'application/json',
accept: _req.headers['accept'] || 'application/json',
'content-type': _req.headers['content-type'] || 'application/json',
cookie: `${ cookies.NAMES.API_TOKEN }=${ _req.cookies[cookies.NAMES.API_TOKEN] }`,
};
+9
View File
@@ -351,6 +351,15 @@ export const RESOURCES = {
old_api: {
path: '/api',
},
csv_export_txs: {
path: '/transactions-csv',
},
csv_export_internal_txs: {
path: '/internal-transactions-csv',
},
csv_export_token_transfers: {
path: '/token-transfers-csv',
},
};
export type ResourceName = keyof typeof RESOURCES;
+23 -14
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': [
@@ -97,6 +96,11 @@ function makePolicyMap() {
'servedbyadbutler.com',
'\'sha256-wMOeDjJaOTjCfNjluteV+tSqHW547T89sgxd8W6tQJM=\'',
'\'sha256-FcyIn1h7zra8TVnnRhYrwrplxJW7dpD5TV7kP2AG/kI=\'',
// reCAPTCHA from google
'https://www.google.com/recaptcha/api.js',
'https://www.gstatic.com',
'\'sha256-FDyPg8CqqIpPAfGVKx1YeKduyLs0ghNYWII21wL+7HM=\'',
],
'style-src': [
@@ -130,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',
@@ -150,7 +154,7 @@ function makePolicyMap() {
KEY_WORDS.DATA,
// google fonts
'*.gstatic.com',
'fonts.gstatic.com',
'fonts.googleapis.com',
],
@@ -167,10 +171,15 @@ function makePolicyMap() {
],
'frame-src': [
...getMarketplaceAppsOrigins(),
...marketplaceAppsHosts.frames,
// ad
'request-global.czilladx.com',
// reCAPTCHA from google
// 'https://www.google.com/',
'https://www.google.com/recaptcha/api2/anchor',
'https://www.google.com/recaptcha/api2/bframe',
],
...(REPORT_URI ? {
+10
View File
@@ -0,0 +1,10 @@
export default function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
-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;
}
+16 -3
View File
@@ -7,8 +7,8 @@ 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 globeIcon from 'icons/globe-b.svg';
// import gearIcon from 'icons/gear.svg';
import privateTagIcon from 'icons/privattags.svg';
import profileIcon from 'icons/profile.svg';
import publicTagIcon from 'icons/publictags.svg';
@@ -44,6 +44,7 @@ export function isGroupItem(item: NavItem | NavGroupItem): item is NavGroupItem
export default function useNavItems(): ReturnType {
const isMarketplaceFilled = appConfig.marketplaceAppList.length > 0 && appConfig.network.rpcUrl;
const hasAPIDocs = appConfig.apiDoc.specUrl;
const router = useRouter();
const pathname = router.pathname;
@@ -57,6 +58,17 @@ export default function useNavItems(): ReturnType {
// { 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',
@@ -72,7 +84,8 @@ export default function useNavItems(): ReturnType {
// 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 = [
@@ -109,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 ]);
}
+3 -8
View File
@@ -1,5 +1,7 @@
import { unparse } from 'papaparse';
import downloadBlob from 'lib/downloadBlob';
export default function saveAsCSV(headerRows: Array<string>, dataRows: Array<Array<string>>, filename: string) {
const csv = unparse([
headerRows,
@@ -8,12 +10,5 @@ export default function saveAsCSV(headerRows: Array<string>, dataRows: Array<Arr
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.click();
link.remove();
URL.revokeObjectURL(url);
downloadBlob(blob, filename);
}
+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);
+5
View File
@@ -65,10 +65,12 @@
"react": "18.2.0",
"react-ace": "^10.1.0",
"react-dom": "18.2.0",
"react-google-recaptcha": "^2.1.0",
"react-hook-form": "^7.33.1",
"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,8 @@
"@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",
"dotenv-cli": "^6.0.0",
@@ -100,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);
};
+15 -6
View File
@@ -1,13 +1,22 @@
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import CsvExport from 'ui/pages/CsvExport';
const CsvExportPage: NextPage = () => {
return null;
const title = getNetworkTitle();
return (
<>
<Head>
<title>{ title }</title>
</Head>
<CsvExport/>
</>
);
};
export default CsvExportPage;
export async function getServerSideProps() {
return {
notFound: true,
};
}
export { getServerSideProps } from 'lib/next/getServerSideProps';
+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,
+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
View File
@@ -0,0 +1 @@
export type CsvExportType = 'transactions' | 'internal-transactions' | 'token-transfers';
+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>
) }
+7 -4
View File
@@ -1,13 +1,16 @@
import { chakra, Icon, Link, Tooltip, Hide } from '@chakra-ui/react';
import { chakra, Icon, Tooltip, Hide } from '@chakra-ui/react';
import { route } from 'nextjs-routes';
import React from 'react';
import type { CsvExportType } from 'types/client/address';
import svgFileIcon from 'icons/files/csv.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import LinkInternal from 'ui/shared/LinkInternal';
interface Props {
address: string;
type: 'transactions' | 'internal-transactions' | 'token-transfers';
type: CsvExportType;
className?: string;
}
@@ -16,7 +19,7 @@ const AddressCsvExportLink = ({ className, address, type }: Props) => {
return (
<Tooltip isDisabled={ !isMobile } label="Download CSV">
<Link
<LinkInternal
className={ className }
display="inline-flex"
alignItems="center"
@@ -26,7 +29,7 @@ const AddressCsvExportLink = ({ className, address, type }: Props) => {
>
<Icon as={ svgFileIcon } boxSize={{ base: '30px', lg: 6 }}/>
<Hide ssr={ false } below="lg"><chakra.span ml={ 1 }>Download CSV</chakra.span></Hide>
</Link>
</LinkInternal>
</Tooltip>
);
};
+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();
});
+2
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';
@@ -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 }>
+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>;
+129 -32
View File
@@ -1,6 +1,15 @@
import type { FieldPath, ErrorOption } from 'react-hook-form';
import type { ContractLibrary, FormFields } from './types';
import type {
ContractLibrary,
FormFields,
FormFieldsFlattenSourceCode,
FormFieldsMultiPartFile,
FormFieldsSourcify,
FormFieldsStandardInput,
FormFieldsVyperContract,
FormFieldsVyperMultiPartFile,
} from './types';
import type { SmartContractVerificationMethod, SmartContractVerificationError } from 'types/api/contract';
import type { Params as FetchParams } from 'lib/hooks/useFetch';
@@ -14,6 +23,83 @@ export const SUPPORTED_VERIFICATION_METHODS: Array<SmartContractVerificationMeth
'vyper-multi-part',
];
export const METHOD_LABELS: Record<SmartContractVerificationMethod, string> = {
'flattened-code': 'Solidity (Flattened source code)',
'standard-input': 'Solidity (Standard JSON input)',
sourcify: 'Solidity (Sourcify)',
'multi-part': 'Solidity (Multi-part files)',
'vyper-code': 'Vyper (Contract)',
'vyper-multi-part': 'Vyper (Multi-part files)',
};
export const DEFAULT_VALUES = {
'flattened-code': {
method: {
value: 'flattened-code' as const,
label: METHOD_LABELS['flattened-code'],
},
is_yul: false,
name: '',
compiler: null,
evm_version: null,
is_optimization_enabled: true,
optimization_runs: '200',
code: '',
autodetect_constructor_args: true,
constructor_args: '',
libraries: [],
},
'standard-input': {
method: {
value: 'standard-input' as const,
label: METHOD_LABELS['standard-input'],
},
name: '',
compiler: null,
sources: [],
autodetect_constructor_args: true,
constructor_args: '',
},
sourcify: {
method: {
value: 'sourcify' as const,
label: METHOD_LABELS.sourcify,
},
sources: [],
},
'multi-part': {
method: {
value: 'multi-part' as const,
label: METHOD_LABELS['multi-part'],
},
compiler: null,
evm_version: null,
is_optimization_enabled: true,
optimization_runs: 200,
sources: [],
libraries: [],
},
'vyper-code': {
method: {
value: 'vyper-code' as const,
label: METHOD_LABELS['vyper-code'],
},
name: '',
compiler: null,
code: '',
constructor_args: '',
},
'vyper-multi-part': {
method: {
value: 'vyper-multi-part' as const,
label: METHOD_LABELS['vyper-multi-part'],
},
compiler: null,
evm_version: null,
sources: [],
},
};
export function isValidVerificationMethod(method?: string): method is SmartContractVerificationMethod {
return method && SUPPORTED_VERIFICATION_METHODS.includes(method as SmartContractVerificationMethod) ? true : false;
}
@@ -34,68 +120,79 @@ export function sortVerificationMethods(methodA: SmartContractVerificationMethod
}
export function prepareRequestBody(data: FormFields): FetchParams['body'] {
switch (data.method) {
switch (data.method.value) {
case 'flattened-code': {
const _data = data as FormFieldsFlattenSourceCode;
return {
compiler_version: data.compiler?.value,
source_code: data.code,
is_optimization_enabled: data.is_optimization_enabled,
is_yul_contract: data.is_yul,
optimization_runs: data.optimization_runs,
contract_name: data.name,
libraries: reduceLibrariesArray(data.libraries),
evm_version: data.evm_version?.value,
autodetect_constructor_args: data.autodetect_constructor_args,
constructor_args: data.constructor_args,
compiler_version: _data.compiler?.value,
source_code: _data.code,
is_optimization_enabled: _data.is_optimization_enabled,
is_yul_contract: _data.is_yul,
optimization_runs: _data.optimization_runs,
contract_name: _data.name,
libraries: reduceLibrariesArray(_data.libraries),
evm_version: _data.evm_version?.value,
autodetect_constructor_args: _data.autodetect_constructor_args,
constructor_args: _data.constructor_args,
};
}
case 'standard-input': {
const _data = data as FormFieldsStandardInput;
const body = new FormData();
body.set('compiler_version', data.compiler?.value);
body.set('contract_name', data.name);
body.set('autodetect_constructor_args', String(Boolean(data.autodetect_constructor_args)));
body.set('constructor_args', data.constructor_args);
addFilesToFormData(body, data.sources);
body.set('compiler_version', _data.compiler?.value);
body.set('contract_name', _data.name);
body.set('autodetect_constructor_args', String(Boolean(_data.autodetect_constructor_args)));
body.set('constructor_args', _data.constructor_args);
addFilesToFormData(body, _data.sources);
return body;
}
case 'sourcify': {
const _data = data as FormFieldsSourcify;
const body = new FormData();
addFilesToFormData(body, data.sources);
addFilesToFormData(body, _data.sources);
_data.contract_index && body.set('chosen_contract_index', _data.contract_index.value);
return body;
}
case 'multi-part': {
const body = new FormData();
body.set('compiler_version', data.compiler?.value);
body.set('evm_version', data.evm_version?.value);
body.set('is_optimization_enabled', String(Boolean(data.is_optimization_enabled)));
data.is_optimization_enabled && body.set('optimization_runs', data.optimization_runs);
const _data = data as FormFieldsMultiPartFile;
const libraries = reduceLibrariesArray(data.libraries);
const body = new FormData();
body.set('compiler_version', _data.compiler?.value);
body.set('evm_version', _data.evm_version?.value);
body.set('is_optimization_enabled', String(Boolean(_data.is_optimization_enabled)));
_data.is_optimization_enabled && body.set('optimization_runs', _data.optimization_runs);
const libraries = reduceLibrariesArray(_data.libraries);
libraries && body.set('libraries', JSON.stringify(libraries));
addFilesToFormData(body, data.sources);
addFilesToFormData(body, _data.sources);
return body;
}
case 'vyper-code': {
const _data = data as FormFieldsVyperContract;
return {
compiler_version: data.compiler?.value,
source_code: data.code,
contract_name: data.name,
constructor_args: data.constructor_args,
compiler_version: _data.compiler?.value,
source_code: _data.code,
contract_name: _data.name,
constructor_args: _data.constructor_args,
};
}
case 'vyper-multi-part': {
const _data = data as FormFieldsVyperMultiPartFile;
const body = new FormData();
body.set('compiler_version', data.compiler?.value);
body.set('evm_version', data.evm_version?.value);
addFilesToFormData(body, data.sources);
body.set('compiler_version', _data.compiler?.value);
body.set('evm_version', _data.evm_version?.value);
addFilesToFormData(body, _data.sources);
return body;
}
+96
View File
@@ -0,0 +1,96 @@
import { Button, chakra, Flex } from '@chakra-ui/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { FormFields } from './types';
import buildUrl from 'lib/api/buildUrl';
import type { ResourceName } from 'lib/api/resources';
import dayjs from 'lib/date/dayjs';
import downloadBlob from 'lib/downloadBlob';
import useToast from 'lib/hooks/useToast';
import CsvExportFormField from './CsvExportFormField';
import CsvExportFormReCaptcha from './CsvExportFormReCaptcha';
interface Props {
hash: string;
resource: ResourceName;
fileNameTemplate: string;
}
const CsvExportForm = ({ hash, resource, fileNameTemplate }: Props) => {
const formApi = useForm<FormFields>({
mode: 'onBlur',
defaultValues: {
from: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
to: dayjs().format('YYYY-MM-DD'),
},
});
const { handleSubmit, formState } = formApi;
const toast = useToast();
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => {
try {
const url = buildUrl(resource, undefined, {
address_id: hash,
from_period: data.from,
to_period: data.to,
recaptcha_response: data.reCaptcha,
});
const response = await fetch(url, {
headers: {
'content-type': 'application/octet-stream',
},
});
if (!response.ok) {
throw new Error();
}
const blob = await response.blob();
downloadBlob(blob, `${ fileNameTemplate }_${ hash }_${ data.from }_${ data.to }.csv`);
} catch (error) {
toast({
position: 'top-right',
title: 'Error',
description: (error as Error)?.message || 'Something went wrong. Try again later.',
status: 'error',
variant: 'subtle',
isClosable: true,
});
}
}, [ fileNameTemplate, hash, resource, toast ]);
return (
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ handleSubmit(onFormSubmit) }
>
<Flex columnGap={ 5 } rowGap={ 3 } flexDir={{ base: 'column', lg: 'row' }} alignItems={{ base: 'flex-start', lg: 'center' }}>
<CsvExportFormField name="from" formApi={ formApi }/>
<CsvExportFormField name="to" formApi={ formApi }/>
<CsvExportFormReCaptcha formApi={ formApi }/>
</Flex>
<Button
variant="solid"
size="lg"
type="submit"
mt={ 8 }
isLoading={ formState.isSubmitting }
loadingText="Download"
isDisabled={ !formState.isValid }
>
Download
</Button>
</chakra.form>
</FormProvider>
);
};
export default React.memo(CsvExportForm);
+69
View File
@@ -0,0 +1,69 @@
import { FormControl, Input } from '@chakra-ui/react';
import _capitalize from 'lodash/capitalize';
import React from 'react';
import type { ControllerRenderProps, UseFormReturn } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { FormFields } from './types';
import dayjs from 'lib/date/dayjs';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
formApi: UseFormReturn<FormFields>;
name: 'from' | 'to';
}
const CsvExportFormField = ({ formApi, name }: Props) => {
const { formState, control, getValues, trigger } = formApi;
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'from' | 'to'>}) => {
const error = field.name in formState.errors ? formState.errors[field.name] : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }} maxW={{ base: 'auto', lg: '220px' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(error) }
type="date"
isDisabled={ formState.isSubmitting }
autoComplete="off"
max={ dayjs().format('YYYY-MM-DD') }
/>
<InputPlaceholder text={ _capitalize(field.name) } error={ error }/>
</FormControl>
);
}, [ formState.errors, formState.isSubmitting ]);
const validate = React.useCallback((newValue: string) => {
if (name === 'from') {
const toValue = getValues('to');
if (toValue && dayjs(newValue) > dayjs(toValue)) {
return 'Incorrect date';
}
if (formState.errors.to) {
trigger('to');
}
} else {
const fromValue = getValues('from');
if (fromValue && dayjs(fromValue) > dayjs(newValue)) {
return 'Incorrect date';
}
if (formState.errors.from) {
trigger('from');
}
}
}, [ formState.errors.from, formState.errors.to, getValues, name, trigger ]);
return (
<Controller
name={ name }
control={ control }
render={ renderControl }
rules={{ required: true, validate }}
/>
);
};
export default React.memo(CsvExportFormField);
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import ReCaptcha from 'react-google-recaptcha';
import type { UseFormReturn } from 'react-hook-form';
import type { FormFields } from './types';
import appConfig from 'configs/app/config';
interface Props {
formApi: UseFormReturn<FormFields>;
}
const CsvExportFormReCaptcha = ({ formApi }: Props) => {
const ref = React.useRef<ReCaptcha>(null);
React.useEffect(() => {
formApi.register('reCaptcha', { required: true, shouldUnregister: true });
return () => {
formApi.unregister('reCaptcha');
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
ref.current?.reset();
formApi.trigger('reCaptcha');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ formApi.formState.submitCount ]);
const handleReCaptchaChange = React.useCallback((token: string | null) => {
if (token) {
formApi.clearErrors('reCaptcha');
formApi.setValue('reCaptcha', token, { shouldValidate: true });
}
}, [ formApi ]);
const handleReCaptchaExpire = React.useCallback(() => {
formApi.resetField('reCaptcha');
formApi.setError('reCaptcha', { type: 'required' });
}, [ formApi ]);
return (
<ReCaptcha
ref={ ref }
sitekey={ appConfig.reCaptcha.siteKey }
onChange={ handleReCaptchaChange }
onExpired={ handleReCaptchaExpire }
/>
);
};
export default CsvExportFormReCaptcha;
+5
View File
@@ -0,0 +1,5 @@
export interface FormFields {
from: string;
to: string;
reCaptcha: string;
}
+2 -2
View File
@@ -26,7 +26,7 @@ type Props = {
tx: Transaction;
}
const LatestBlocksItem = ({ tx }: Props) => {
const LatestTxsItem = ({ tx }: Props) => {
const dataTo = tx.to ? tx.to : tx.created_contract;
const timeAgo = useTimeAgoIncrement(tx.timestamp || '0', true);
@@ -126,4 +126,4 @@ const LatestBlocksItem = ({ tx }: Props) => {
);
};
export default LatestBlocksItem;
export default React.memo(LatestTxsItem);
+8 -4
View File
@@ -20,7 +20,6 @@ import AddressLogs from 'ui/address/AddressLogs';
import AddressTokens from 'ui/address/AddressTokens';
import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs';
import AdBanner from 'ui/shared/ad/AdBanner';
import TextAd from 'ui/shared/ad/TextAd';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
@@ -51,10 +50,15 @@ const AddressPageContent = () => {
});
const tags = [
addressQuery.data?.is_contract ? { label: 'contract', display_name: 'Contract' } : { label: 'eoa', display_name: 'EOA' },
addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined,
addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined,
...(addressQuery.data?.private_tags || []),
...(addressQuery.data?.public_tags || []),
...(addressQuery.data?.watchlist_names || []),
].map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
]
.filter(notEmpty)
.map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const contractTabs = useContractTabs(addressQuery.data);
@@ -97,7 +101,7 @@ const AddressPageContent = () => {
return (
<Page>
<TextAd mb={ 6 }/>
{ addressQuery.isLoading ? <Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/> : <TextAd mb={ 6 }/> }
{ addressQuery.isLoading ? (
<Skeleton h={ 10 } w="260px" mb={ 6 }/>
) : (
@@ -109,10 +113,10 @@ const AddressPageContent = () => {
/>
) }
<AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/>
<AdBanner mt={{ base: 6, lg: 8 }} justifyContent="center"/>
{ /* should stay before tabs to scroll up whith pagination */ }
<Box ref={ tabsScrollRef }></Box>
{ addressQuery.isLoading ? <SkeletonTabs/> : content }
{ !addressQuery.isLoading && !addressQuery.isError && <Box h={{ base: 0, lg: '40vh' }}/> }
</Page>
);
};
+2 -5
View File
@@ -6,7 +6,6 @@ import type { SmartContractVerificationConfigRaw, SmartContractVerificationMetho
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import isBrowser from 'lib/isBrowser';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractVerificationForm from 'ui/contractVerification/ContractVerificationForm';
import { isValidVerificationMethod, sortVerificationMethods } from 'ui/contractVerification/utils';
@@ -21,9 +20,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
const ContractVerification = () => {
const appProps = useAppContext();
const isInBrowser = isBrowser();
const referrer = isInBrowser ? window.document.referrer : appProps.referrer;
const hasGoBackLink = referrer && referrer.includes('/address');
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
@@ -91,7 +88,7 @@ const ContractVerification = () => {
<Page>
<PageTitle
text="New smart contract verification"
backLinkUrl={ hasGoBackLink ? referrer : undefined }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to contract"
/>
{ hash && (
+98
View File
@@ -0,0 +1,98 @@
import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { CsvExportType } from 'types/client/address';
import type { ResourceName } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import useIsMobile from 'lib/hooks/useIsMobile';
import CsvExportForm from 'ui/csvExport/CsvExportForm';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
interface ExportTypeEntity {
text: string;
resource: ResourceName;
fileNameTemplate: string;
}
const EXPORT_TYPES: Record<CsvExportType, ExportTypeEntity> = {
transactions: {
text: 'transactions',
resource: 'csv_export_txs',
fileNameTemplate: 'transactions',
},
'internal-transactions': {
text: 'internal transactions',
resource: 'csv_export_internal_txs',
fileNameTemplate: 'internal_transactions',
},
'token-transfers': {
text: 'token transfers',
resource: 'csv_export_token_transfers',
fileNameTemplate: 'token_transfers',
},
};
const isCorrectExportType = (type: string): type is CsvExportType => Object.keys(EXPORT_TYPES).includes(type);
const CsvExport = () => {
const router = useRouter();
const isMobile = useIsMobile();
const appProps = useAppContext();
const addressHash = router.query.address?.toString() || '';
const exportType = router.query.type?.toString() || '';
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const addressQuery = useApiQuery('address', {
pathParams: { hash: addressHash },
queryOptions: {
enabled: Boolean(addressHash),
},
});
if (!isCorrectExportType(exportType) || !addressHash || addressQuery.error?.status === 400) {
throw Error('Not found', { cause: { status: 404 } });
}
const content = (() => {
if (addressQuery.isError) {
return <DataFetchAlert/>;
}
if (addressQuery.isLoading) {
return <ContentLoader/>;
}
return <CsvExportForm hash={ addressHash } resource={ EXPORT_TYPES[exportType].resource } fileNameTemplate={ EXPORT_TYPES[exportType].fileNameTemplate }/>;
})();
return (
<Page>
<PageTitle
text="Export data to CSV file"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to address"
/>
<Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap">
<span>Export { EXPORT_TYPES[exportType].text } for address </span>
<Address>
<AddressIcon address={{ hash: addressHash, is_contract: true, implementation_name: null }}/>
<AddressLink hash={ addressHash } type="address" ml={ 2 } truncation={ isMobile ? 'constant' : 'dynamic' }/>
</Address>
<span> to CSV file</span>
</Flex>
{ content }
</Page>
);
};
export default CsvExport;
+8 -1
View File
@@ -2,6 +2,7 @@ import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import { useAppContext } from 'lib/appContext';
import useIsMobile from 'lib/hooks/useIsMobile';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
@@ -13,12 +14,18 @@ import Sol2UmlDiagram from 'ui/sol2uml/Sol2UmlDiagram';
const Sol2Uml = () => {
const router = useRouter();
const isMobile = useIsMobile();
const appProps = useAppContext();
const addressHash = router.query.address?.toString() || '';
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
return (
<Page>
<PageTitle text="Solidity UML diagram"/>
<PageTitle
text="Solidity UML diagram"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to address"
/>
<Flex mb={ 10 }>
<span>For contract</span>
<Address ml={ 3 }>
+25 -8
View File
@@ -1,4 +1,4 @@
import { Skeleton, Box, Flex, SkeletonCircle, Icon } from '@chakra-ui/react';
import { Skeleton, Box, Flex, SkeletonCircle, Icon, Tag } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React, { useEffect } from 'react';
@@ -13,7 +13,6 @@ import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import notEmpty from 'lib/notEmpty';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
import AddressContract from 'ui/address/AddressContract';
import AdBanner from 'ui/shared/ad/AdBanner';
import TextAd from 'ui/shared/ad/TextAd';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
@@ -141,13 +140,29 @@ const TokenPageContent = () => {
const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ trimTokenSymbol(tokenQuery.data.symbol) })` : '';
const tabListProps = React.useCallback(({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => {
if (isMobile) {
return { mt: 8 };
}
return {
mt: 3,
py: 5,
marginBottom: 0,
boxShadow: activeTabIndex === 2 && isSticky ? 'action_bar' : 'none',
};
}, [ isMobile ]);
return (
<Page>
{ tokenQuery.isLoading ? (
<Flex alignItems="center" mb={ 6 }>
<SkeletonCircle w={ 6 } h={ 6 } mr={ 3 }/>
<Skeleton w="500px" h={ 10 }/>
</Flex>
<>
<Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/>
<Flex alignItems="center" mb={ 6 }>
<SkeletonCircle w={ 6 } h={ 6 } mr={ 3 }/>
<Skeleton w="500px" h={ 10 }/>
</Flex>
</>
) : (
<>
<TextAd mb={ 6 }/>
@@ -158,23 +173,25 @@ const TokenPageContent = () => {
additionalsLeft={ (
<TokenLogo hash={ tokenQuery.data?.address } name={ tokenQuery.data?.name } boxSize={ 6 }/>
) }
additionalsRight={ <Tag>{ tokenQuery.data?.type }</Tag> }
/>
</>
) }
<TokenContractInfo tokenQuery={ tokenQuery }/>
<TokenDetails tokenQuery={ tokenQuery }/>
<AdBanner mt={{ base: 6, lg: 8 }} justifyContent="center"/>
{ /* should stay before tabs to scroll up whith pagination */ }
<Box ref={ scrollRef }></Box>
{ tokenQuery.isLoading || contractQuery.isLoading ? <SkeletonTabs/> : (
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? { mt: 8 } : { mt: 3, py: 5, marginBottom: 0 } }
tabListProps={ tabListProps }
rightSlot={ !isMobile && hasPagination ? <Pagination { ...(pagination as PaginationProps) }/> : null }
stickyEnabled={ !isMobile }
/>
) }
{ !tokenQuery.isLoading && !tokenQuery.isError && <Box h={{ base: 0, lg: '40vh' }}/> }
</Page>
);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 45 KiB

+6 -2
View File
@@ -7,12 +7,13 @@ import useIsSticky from 'lib/hooks/useIsSticky';
type Props = {
children: React.ReactNode;
className?: string;
showShadow?: boolean;
}
const TOP_UP = 106;
const TOP_DOWN = 0;
const ActionBar = ({ children, className }: Props) => {
const ActionBar = ({ children, className, showShadow }: Props) => {
const ref = React.useRef<HTMLDivElement>(null);
const scrollDirection = useScrollDirection();
const isSticky = useIsSticky(ref, TOP_UP + 5);
@@ -36,7 +37,10 @@ const ActionBar = ({ children, className }: Props) => {
transitionProperty="top,box-shadow,background-color,color"
transitionDuration="normal"
zIndex={{ base: 'sticky2', lg: 'docked' }}
boxShadow={{ base: isSticky ? 'md' : 'none', lg: 'none' }}
boxShadow={{
base: isSticky ? 'md' : 'none',
lg: isSticky && showShadow ? 'action_bar' : 'none',
}}
ref={ ref }
>
{ children }
+29
View File
@@ -0,0 +1,29 @@
import { GridItem } from '@chakra-ui/react';
import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile';
import AdBanner from 'ui/shared/ad/AdBanner';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
const DetailsSponsoredItem = () => {
const isMobile = useIsMobile();
if (isMobile) {
return (
<GridItem mt={ 5 }>
<AdBanner justifyContent="center"/>
</GridItem>
);
}
return (
<DetailsInfoItem
title="Sponsored"
hint="Sponsored banner advertisement"
>
<AdBanner/>
</DetailsInfoItem>
);
};
export default React.memo(DetailsSponsoredItem);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

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