Merge pull request #2960 from blockscout/release/v2-3-0

Fixes for release v2.3.0
This commit is contained in:
tom goriunov
2025-08-27 11:37:54 +02:00
committed by GitHub
47 changed files with 489 additions and 160 deletions
+1
View File
@@ -14,6 +14,7 @@ on:
- arbitrum_nova
- arbitrum_sepolia
- base
- celo
- celo_alfajores
- garnet
- gnosis
+1
View File
@@ -14,6 +14,7 @@ on:
- arbitrum_nova
- arbitrum_sepolia
- base
- celo
- celo_alfajores
- garnet
- gnosis
+1
View File
@@ -364,6 +364,7 @@
"arbitrum_sepolia",
"base",
"blackfort_testnet",
"celo",
"celo_alfajores",
"garnet",
"gnosis",
+2 -1
View File
@@ -4,12 +4,13 @@ import { getEnvValue } from '../utils';
const title = 'Celo chain';
const config: Feature<{ }> = (() => {
const config: Feature<{ nativeTokenAddress?: string }> = (() => {
if (getEnvValue('NEXT_PUBLIC_CELO_ENABLED') === 'true') {
return Object.freeze({
title,
isEnabled: true,
nativeTokenAddress: getEnvValue('NEXT_PUBLIC_CELO_NATIVE_TOKEN_ADDRESS'),
});
}
+64
View File
@@ -0,0 +1,64 @@
# Set of ENVs for Celo Mainnet network explorer
# https://celo.blockscout.com
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=celo"
# Local ENVs
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_CELO_NATIVE_TOKEN_ADDRESS=0x471EcE3750Da237f93B8E339c536989b8978a438
# Instance ENVs
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol', 'efp', 'webacy', 'deepdao', 'humanpassport', 'bankless', 'blockscoutbadges']
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/widgets/config.json
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=celo.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CELO_ENABLED=true
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS=[{'text':'Swapscout','icon':'swap','dappId':'swapscout'},{'text':'Revokescout','icon':'integration/partial','dappId':'revokescout'}]
NEXT_PUBLIC_DEX_POOLS_ENABLED=true
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/celo.json
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_GAS_TRACKER_ENABLED=false
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xa214ff978b8535434d5806d8e9d0d5f8c946905fbba1def121014e7af36c1e8f
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['rgba(252, 255, 82, 1)'],'text_color':['rgba(0, 0, 0, 1)']}
NEXT_PUBLIC_HOMEPAGE_STATS=['total_blocks','average_block_time','total_txs','wallet_addresses','current_epoch']
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL=https://gist.githubusercontent.com/0xdeval/b27a4aecaad513fa033e37430a4f9a47/raw/3a2fa70068ea27c3e6d58dc4cdbeb732968d62f3/revokescout-banner.html
NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL=https://revoke.blockscout.com?utm_source=blockscout&utm_medium=celo
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-categories/default.json
NEXT_PUBLIC_MARKETPLACE_ENABLED=true
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES=['/apps']
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=CELO
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=CELO
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/celo-icon-light.svg
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/celo-icon-dark.svg
NEXT_PUBLIC_NETWORK_ID=42220
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/celo-logo-light.svg
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/celo-logo-dark.svg
NEXT_PUBLIC_NETWORK_MULTIPLE_GAS_CURRENCIES=true
NEXT_PUBLIC_NETWORK_NAME=Celo Mainnet
NEXT_PUBLIC_NETWORK_RPC_URL=https://forno.celo.org
NEXT_PUBLIC_NETWORK_SHORT_NAME=Mainnet
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/celo.png
NEXT_PUBLIC_PUZZLE_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/capyPuzzleBadge
NEXT_PUBLIC_STATS_API_BASE_PATH=/stats-service
NEXT_PUBLIC_STATS_API_HOST=https://celo.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS=['burnt_fees']
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
+10
View File
@@ -371,6 +371,16 @@ const celoSchema = yup
.object()
.shape({
NEXT_PUBLIC_CELO_ENABLED: yup.boolean(),
NEXT_PUBLIC_CELO_NATIVE_TOKEN_ADDRESS: yup
.string()
.min(42)
.max(42)
.matches(regexp.HEX_REGEXP_WITH_0X)
.when('NEXT_PUBLIC_CELO_ENABLED', {
is: (value: boolean) => value,
then: (schema) => schema,
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_CELO_NATIVE_TOKEN_ADDRESS can only be used if NEXT_PUBLIC_CELO_ENABLED is set to \'true\''),
}),
});
const apiDocsScheme = yup
+2 -1
View File
@@ -1 +1,2 @@
NEXT_PUBLIC_CELO_ENABLED=true
NEXT_PUBLIC_CELO_ENABLED=true
NEXT_PUBLIC_CELO_NATIVE_TOKEN_ADDRESS=0x471EcE3750Da237f93B8E339c536989b8978a438
+1
View File
@@ -793,6 +793,7 @@ For blockchains that use the Celo platform. _Note_, that once the Celo mainnet b
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_CELO_ENABLED | `boolean` | Indicates that it is a Celo-based chain. | - | - | `true` | v1.37.0+ |
| NEXT_PUBLIC_CELO_NATIVE_TOKEN_ADDRESS | `string` | The address of the CELO ERC-20 token. Used to exclude its balance from the net worth value of user tokens. | - | - | `0x471EcE3750Da237f93B8E339c536989b8978a438` | v2.3.0+ |
&nbsp;
+22 -20
View File
@@ -8,7 +8,7 @@ import config from 'configs/app';
import * as cookies from 'lib/cookies';
import type * as metadata from 'lib/metadata';
import detectBotRequest from '../utils/detectBotRequest';
import { isLikelyHumanBrowser, isKnownBotRequest } from '../utils/checkRealBrowser';
const adBannerFeature = config.features.adsBanner;
@@ -45,28 +45,30 @@ Promise<GetServerSidePropsResult<Props<Pathname>>> => {
}
const isTrackingDisabled = process.env.DISABLE_TRACKING === 'true';
const isBot = Boolean(detectBotRequest(req));
if (!isTrackingDisabled && !isBot) {
if (!isTrackingDisabled) {
const isRealUser = isLikelyHumanBrowser(req) && !isKnownBotRequest(req);
if (isRealUser) {
// log pageview
const hostname = req.headers.host;
const timestamp = new Date().toISOString();
const chainId = process.env.NEXT_PUBLIC_NETWORK_ID;
const chainName = process.env.NEXT_PUBLIC_NETWORK_NAME;
const publicRPC = process.env.NEXT_PUBLIC_NETWORK_RPC_URL;
const hostname = req.headers.host;
const timestamp = new Date().toISOString();
const chainId = process.env.NEXT_PUBLIC_NETWORK_ID;
const chainName = process.env.NEXT_PUBLIC_NETWORK_NAME;
const publicRPC = process.env.NEXT_PUBLIC_NETWORK_RPC_URL;
fetch('https://monitor.blockscout.com/count', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hostname,
timestamp,
chainId,
chainName,
publicRPC,
uuid,
}),
});
fetch('https://monitor.blockscout.com/count', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hostname,
timestamp,
chainId,
chainName,
publicRPC,
uuid,
}),
});
}
}
return {
+80
View File
@@ -0,0 +1,80 @@
import type { GetServerSidePropsContext } from 'next';
const GENERIC_BOT_MARKERS = [
'bot',
'spider',
'crawler',
'httpclient',
'headlesschrome',
'phantomjs',
'puppeteer',
'playwright',
'node-fetch',
'axios',
'curl',
'wget',
'python-requests',
'go-http-client',
'libwww-perl',
'okhttp',
'postman',
'insomnia',
];
function toLower(input: string | null | undefined): string {
return (input || '').toLowerCase();
}
function isGenericBotUA(userAgent: string): boolean {
const ua = toLower(userAgent);
return GENERIC_BOT_MARKERS.some((m) => ua.includes(m));
}
function hasBrowserHeuristics(req: GetServerSidePropsContext['req']): boolean {
const acceptLanguage = req.headers['accept-language'];
const secChUa = req.headers['sec-ch-ua'];
const secFetchSite = req.headers['sec-fetch-site'];
const secFetchMode = req.headers['sec-fetch-mode'];
const upgradeInsecure = req.headers['upgrade-insecure-requests'];
const hasLang = Boolean(acceptLanguage);
const hasSecHeaders = Boolean(secChUa || secFetchSite || secFetchMode || upgradeInsecure);
return hasLang && hasSecHeaders;
}
export function isLikelyHumanBrowser(req: GetServerSidePropsContext['req']): boolean {
const userAgent = req.headers['user-agent'];
if (!userAgent) return false;
if (isGenericBotUA(userAgent)) return false;
if (!hasBrowserHeuristics(req)) return false;
// Inclusive check for common engines + brands across desktop and mobile
const hasEngineToken = /applewebkit|gecko/i.test(userAgent);
const hasKnownBrowserBrand = /chrome|safari|firefox|crios|fxios|edg|opr|samsungbrowser|vivaldi|whale|duckduckgo/i.test(userAgent);
return hasEngineToken && hasKnownBrowserBrand;
}
export function isKnownBotRequest(req: GetServerSidePropsContext['req']): boolean {
const ua = toLower(req.headers['user-agent']);
if (!ua) return false;
// Social preview bots
if (ua.includes('twitter')) return true;
if (ua.includes('facebook')) return true;
if (ua.includes('telegram')) return true;
if (ua.includes('slack')) return true;
// Search engine bots
if (ua.includes('googlebot')) return true;
if (ua.includes('bingbot')) return true;
if (ua.includes('yahoo')) return true;
if (ua.includes('duckduck')) return true;
// Generic markers
if (isGenericBotUA(ua)) return true;
return false;
}
+1
View File
@@ -113,6 +113,7 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
],
celo: [
[ 'NEXT_PUBLIC_CELO_ENABLED', 'true' ],
[ 'NEXT_PUBLIC_CELO_NATIVE_TOKEN_ADDRESS', '0x471EcE3750Da237f93B8E339c536989b8978a438' ],
],
opSuperchain: [
[ 'NEXT_PUBLIC_OP_SUPERCHAIN_ENABLED', 'true' ],
+1
View File
@@ -7,6 +7,7 @@ const PRESETS = {
arbitrum_sepolia: 'https://arbitrum-sepolia.blockscout.com',
base: 'https://base.blockscout.com',
blackfort_testnet: 'https://blackfort-testnet.blockscout.com',
celo: 'https://celo.blockscout.com',
celo_alfajores: 'https://celo-alfajores.blockscout.com',
eth: 'https://eth.blockscout.com',
eth_goerli: 'https://eth-goerli.blockscout.com',
+23
View File
@@ -5,6 +5,7 @@ import type { AddressTokensResponse } from 'types/api/address';
import * as addressMock from 'mocks/address/address';
import * as tokensMock from 'mocks/address/tokens';
import * as tokenInfo from 'mocks/tokens/tokenInfo';
import * as tokenInstance from 'mocks/tokens/tokenInstance';
import * as socketServer from 'playwright/fixtures/socketServer';
import { test, expect, devices } from 'playwright/lib';
@@ -268,3 +269,25 @@ test.describe('update balances via socket', () => {
await expect(component).toHaveScreenshot();
});
});
test('native token', async({ render, mockEnvs }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_CELO_ENABLED', 'true' ],
[ 'NEXT_PUBLIC_CELO_NATIVE_TOKEN_ADDRESS', tokenInfo.tokenInfoERC20c.address_hash ],
]);
const hooksConfig = {
router: {
query: { hash: ADDRESS_HASH, tab: 'tokens_erc20' },
isReady: true,
},
};
const component = await render(
<Box pt={{ base: '134px', lg: 6 }}>
<AddressTokens/>
</Box>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 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: 78 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

@@ -1,4 +1,4 @@
import { Flex, Text, chakra, Separator, Box } from '@chakra-ui/react';
import { Flex, Text, chakra, Separator } from '@chakra-ui/react';
import { useCallback } from 'react';
import type { Address3rdPartyWidget } from 'types/views/address';
@@ -125,25 +125,15 @@ const Address3rdPartyWidgetCard = ({ name, config, address, isLoading }: Props)
flexDirection="column"
p={ 3 }
cursor={ isLoading ? 'default' : 'pointer' }
position="relative"
zIndex={ 0 }
borderRadius="md"
border="1px solid"
borderColor={ isLoading ? { _light: 'blackAlpha.50', _dark: 'whiteAlpha.100' } : 'transparent' }
bgColor={ isLoading ? 'transparent' : { _light: 'blackAlpha.50', _dark: 'whiteAlpha.100' } }
transition="border-color 0.2s ease-in-out"
_hover={ isLoading ? {} : {
borderColor: { _light: 'blackAlpha.200', _dark: 'whiteAlpha.200' },
} }
>
<Box
aria-hidden
position="absolute"
inset={ 0 }
borderRadius="md"
border="1px solid"
borderColor={ isLoading ? { _light: 'theme.stats.bg._light', _dark: 'theme.stats.bg._dark' } : 'transparent' }
bgColor={ isLoading ? 'transparent' : { _light: 'theme.stats.bg._light', _dark: 'theme.stats.bg._dark' } }
transform="scale(1)"
transition="transform 0.2s ease-in-out, border-color 0.2s ease-in-out"
zIndex={ -1 }
_groupHover={{
transform: 'scale(1.02)',
borderColor: { _light: 'blackAlpha.50', _dark: 'whiteAlpha.100' },
}}
/>
{ content }
</LinkBox>
);
+20 -1
View File
@@ -3,7 +3,7 @@ import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as tokensMock from 'mocks/address/tokens';
import { tokenInfoERC20a } from 'mocks/tokens/tokenInfo';
import { tokenInfoERC20c, tokenInfoERC20a } from 'mocks/tokens/tokenInfo';
import { test, expect, devices } from 'playwright/lib';
import MockAddressPage from 'ui/address/testUtils/MockAddressPage';
@@ -126,3 +126,22 @@ test('long values', async({ render, page, mockApiResponse }) => {
await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA });
});
test('native token', async({ render, mockEnvs, page }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_CELO_ENABLED', 'true' ],
[ 'NEXT_PUBLIC_CELO_NATIVE_TOKEN_ADDRESS', tokenInfoERC20c.address_hash ],
]);
await render(
<MockAddressPage>
<Flex>
<TokenSelect/>
</Flex>
</MockAddressPage>,
{ hooksConfig },
);
await page.getByRole('button', { name: /select/i }).click();
await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA });
});
+17 -2
View File
@@ -4,19 +4,25 @@ import React from 'react';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import getCurrencyValue from 'lib/getCurrencyValue';
import { Link } from 'toolkit/chakra/link';
import NativeTokenTag from 'ui/shared/celo/NativeTokenTag';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import TruncatedValue from 'ui/shared/TruncatedValue';
import type { TokenEnhancedData } from '../utils/tokenUtils';
const celoFeature = config.features.celo;
interface Props {
data: TokenEnhancedData;
}
const TokenSelectItem = ({ data }: Props) => {
const isNativeToken = celoFeature.isEnabled && data.token.address_hash.toLowerCase() === celoFeature.nativeTokenAddress?.toLowerCase();
const secondRow = (() => {
switch (data.token.type) {
case 'ERC-20': {
@@ -93,13 +99,22 @@ const TokenSelectItem = ({ data }: Props) => {
noCopy
noLink
fontWeight={ 700 }
width="auto"
mr={ 2 }
/>
{ isNativeToken && <NativeTokenTag mr={ 2 }/> }
{ data.usd && (
<TruncatedValue value={ `$${ data.usd.toFormat(2) }` } fontWeight={ 700 } minW="120px" ml="auto" textAlign="right"/>
<TruncatedValue
value={ `$${ data.usd.toFormat(2) }` }
fontWeight={ 700 }
minW="120px"
ml="auto"
textAlign="right"
color={ isNativeToken ? 'text.secondary' : undefined }
/>
) }
</Flex>
<Flex alignItems="center" justifyContent="space-between" w="100%" whiteSpace="nowrap">
<Flex alignItems="center" justifyContent="space-between" w="100%" whiteSpace="nowrap" color={ isNativeToken ? 'text.secondary' : undefined }>
{ secondRow }
</Flex>
</Link>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+9 -1
View File
@@ -3,13 +3,17 @@ import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import config from 'configs/app';
import getCurrencyValue from 'lib/getCurrencyValue';
import { Skeleton } from 'toolkit/chakra/skeleton';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import NativeTokenTag from 'ui/shared/celo/NativeTokenTag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
const celoFeature = config.features.celo;
type Props = AddressTokenBalance & { isLoading: boolean };
const ERC20TokensListItem = ({ token, value, isLoading }: Props) => {
@@ -19,16 +23,20 @@ const ERC20TokensListItem = ({ token, value, isLoading }: Props) => {
usd: tokenValue,
} = getCurrencyValue({ value: value, exchangeRate: token.exchange_rate, decimals: token.decimals, accuracy: 8, accuracyUsd: 2 });
const isNativeToken = celoFeature.isEnabled && token.address_hash.toLowerCase() === celoFeature.nativeTokenAddress?.toLowerCase();
return (
<ListItemMobile rowGap={ 2 }>
<Flex alignItems="center" width="100%">
<Flex alignItems="center" width="100%" columnGap={ 2 }>
<TokenEntity
token={ token }
isLoading={ isLoading }
noCopy
jointSymbol
fontWeight="700"
width="auto"
/>
{ isNativeToken && <NativeTokenTag/> }
</Flex>
<Flex alignItems="center" pl={ 8 }>
<AddressEntity
+21 -11
View File
@@ -1,17 +1,21 @@
import { Flex } from '@chakra-ui/react';
import { Flex, HStack } from '@chakra-ui/react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import config from 'configs/app';
import getCurrencyValue from 'lib/getCurrencyValue';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { TableCell, TableRow } from 'toolkit/chakra/table';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import NativeTokenTag from 'ui/shared/celo/NativeTokenTag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
type Props = AddressTokenBalance & { isLoading: boolean };
const celoFeature = config.features.celo;
const ERC20TokensTableItem = ({
token,
value,
@@ -23,16 +27,22 @@ const ERC20TokensTableItem = ({
usd: tokenValue,
} = getCurrencyValue({ value: value, exchangeRate: token.exchange_rate, decimals: token.decimals, accuracy: 8, accuracyUsd: 2 });
const isNativeToken = celoFeature.isEnabled && token.address_hash.toLowerCase() === celoFeature.nativeTokenAddress?.toLowerCase();
return (
<TableRow role="group" >
<TableCell verticalAlign="middle">
<TokenEntity
token={ token }
isLoading={ isLoading }
noCopy
jointSymbol
fontWeight="700"
/>
<HStack gap={ 2 }>
<TokenEntity
token={ token }
isLoading={ isLoading }
noCopy
jointSymbol
fontWeight="700"
width="auto"
/>
{ isNativeToken && <NativeTokenTag/> }
</HStack>
</TableCell>
<TableCell verticalAlign="middle">
<Flex alignItems="center" width="150px" justifyContent="space-between">
@@ -46,17 +56,17 @@ const ERC20TokensTableItem = ({
</Flex>
</TableCell>
<TableCell isNumeric verticalAlign="middle">
<Skeleton loading={ isLoading } display="inline-block">
<Skeleton loading={ isLoading } display="inline-block" color={ isNativeToken ? 'text.secondary' : undefined }>
{ token.exchange_rate && `$${ Number(token.exchange_rate).toLocaleString() }` }
</Skeleton>
</TableCell>
<TableCell isNumeric verticalAlign="middle">
<Skeleton loading={ isLoading } display="inline">
<Skeleton loading={ isLoading } display="inline" color={ isNativeToken ? 'text.secondary' : undefined }>
{ tokenQuantity }
</Skeleton>
</TableCell>
<TableCell isNumeric verticalAlign="middle">
<Skeleton loading={ isLoading } display="inline">
<Skeleton loading={ isLoading } display="inline" color={ isNativeToken ? 'text.secondary' : undefined }>
{ tokenValue && `$${ tokenValue }` }
</Skeleton>
</TableCell>
+7 -1
View File
@@ -3,9 +3,15 @@ import BigNumber from 'bignumber.js';
import type { AddressTokenBalance } from 'types/api/address';
import type { TokenType } from 'types/api/token';
import config from 'configs/app';
import sumBnReducer from 'lib/bigint/sumBnReducer';
import { ZERO } from 'toolkit/utils/consts';
const celoFeature = config.features.celo;
const isNativeToken = (token: TokenEnhancedData) =>
celoFeature.isEnabled && token.token.address_hash.toLowerCase() === celoFeature.nativeTokenAddress?.toLowerCase();
export type TokenEnhancedData = AddressTokenBalance & {
usd?: BigNumber ;
};
@@ -94,7 +100,7 @@ export const calculateUsdValue = (data: AddressTokenBalance): TokenEnhancedData
export const getTokensTotalInfo = (data: TokenSelectData) => {
const usd = Object.values(data)
.map(({ items }) => items.reduce(usdValueReducer, ZERO))
.map(({ items }) => items.filter((item) => !isNativeToken(item)).reduce(usdValueReducer, ZERO))
.reduce(sumBnReducer, ZERO);
const num = Object.values(data)
+6 -1
View File
@@ -26,9 +26,14 @@ export const REST_API_SECTIONS = [
url: feature.coreApiSwaggerUrl,
requestInterceptor: (req: SwaggerRequest) => {
const DEFAULT_SERVER = 'blockscout.com/poa/core';
const DEFAULT_SERVER_NEW = 'eth.blockscout.com';
if (!req.loadSpec) {
const newUrl = new URL(req.url.replace(DEFAULT_SERVER, config.apis.general.host));
const newUrl = new URL(
req.url
.replace(DEFAULT_SERVER, config.apis.general.host)
.replace(DEFAULT_SERVER_NEW, config.apis.general.host),
);
newUrl.protocol = config.apis.general.protocol + ':';
+1 -2
View File
@@ -9,7 +9,6 @@ import EthRpcApi from 'ui/apiDocs/EthRpcApi';
import GraphQL from 'ui/apiDocs/GraphQL';
import RestApi from 'ui/apiDocs/RestApi';
import RpcApi from 'ui/apiDocs/RpcApi';
import { REST_API_SECTIONS } from 'ui/apiDocs/utils';
import PageTitle from 'ui/shared/Page/PageTitle';
const feature = config.features.apiDocs;
@@ -17,7 +16,7 @@ const feature = config.features.apiDocs;
const ApiDocs = () => {
const tabs: Array<TabItemRegular> = [
{ id: 'rest_api', title: 'REST API', component: <RestApi/>, count: REST_API_SECTIONS.length },
{ id: 'rest_api', title: 'REST API', component: <RestApi/> },
{ id: 'eth_rpc_api', title: 'ETH RPC API', component: <EthRpcApi/> },
{ id: 'rpc_api', title: 'RPC API endpoints', component: <RpcApi/> },
{ id: 'graphql_api', title: 'GraphQL API', component: <GraphQL/> },
+5
View File
@@ -3,6 +3,7 @@ import React from 'react';
import type { TokenType } from 'types/api/token';
import { apos } from 'toolkit/utils/htmlEntities';
import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PopoverFilter from 'ui/shared/filters/PopoverFilter';
@@ -61,6 +62,10 @@ const TokenTransfers = () => {
itemsNum={ query.data?.items.length }
emptyText="There are no token transfers."
actionBar={ actionBar }
filterProps={{
hasActiveFilters: Boolean(typeFilter.length),
emptyFilteredText: `Couldn${ apos }t find any token transfer that matches your query.`,
}}
>
{ content }
</DataListDisplay>
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
import config from 'configs/app';
import { Link } from 'toolkit/chakra/link';
import { Tag, type TagProps } from 'toolkit/chakra/tag';
import { Tooltip } from 'toolkit/chakra/tooltip';
const feature = config.features.celo;
interface Props extends TagProps {}
const NativeTokenTag = (props: Props) => {
const handleLinkClick = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
event.stopPropagation();
}, []);
if (!feature.isEnabled || !feature.nativeTokenAddress) {
return null;
}
const tooltipContent = (
<>
<span>This ERC-20 token represents the native CELO balance for this address and isn’t counted twice. </span>
<Link href="https://docs.celo.org/what-is-celo/using-celo/protocol/celo-token" external onClick={ handleLinkClick }>
Learn more
</Link>
</>
);
return (
<Tooltip content={ tooltipContent } interactive>
<Tag { ...props }>Native token</Tag>
</Tooltip>
);
};
export default React.memo(NativeTokenTag);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

@@ -11,7 +11,7 @@ import NavigationPromoBannerContent from './NavigationPromoBannerContent';
const PROMO_BANNER_CLOSED_HASH_KEY = 'nav-promo-banner-closed-hash';
const promoBanner = config.UI.navigation.promoBanner;
const isHorizontalNavigation = config.UI.navigation.layout === 'horizontal';
const isHorizontal = config.UI.navigation.layout === 'horizontal';
type Props = {
isCollapsed?: boolean;
@@ -20,6 +20,7 @@ type Props = {
const NavigationPromoBanner = ({ isCollapsed }: Props) => {
const isMobile = useIsMobile();
const isXLScreen = useBreakpointValue({ base: false, xl: true });
const isHorizontalNavigation = isHorizontal && !isMobile;
const [ isShown, setIsShown ] = useState(false);
const [ promoBannerHash, setPromoBannerHash ] = useState('');
@@ -98,10 +99,11 @@ const NavigationPromoBanner = ({ isCollapsed }: Props) => {
color={{ _light: 'gray.300', _dark: 'gray.600' }}
bgColor="bg.primary"
borderBottomLeftRadius="sm"
borderTopRightRadius="sm"
position="absolute"
top="0"
right="0"
display="none"
display={ isMobile ? 'block' : 'none' }
/>
</Box>
</Tooltip>
+110 -76
View File
@@ -26,84 +26,118 @@ const NetworkMenuContent = ({ items, tabs }: Props) => {
setValue(value as NetworkGroup);
}, []);
const content = !items || items.length === 0 ? (
<>
<Flex alignItems="center">
<Flex h="32px" w="105px" bgColor={{ base: 'blackAlpha.50', _dark: 'whiteAlpha.50' }} borderRadius="base" px={ 4 } py={ 2 }>
<Skeleton loading h="16px" w="100%"/>
</Flex>
<Skeleton loading h="16px" w="68px" mx={ 4 }/>
<Skeleton loading h="16px" w="45px" mx={ 4 }/>
</Flex>
<Flex mt={ 3 } flexDir="column" rowGap={ 2 }>
<Flex mx={ 3 } my={ 2 } alignItems="center">
<Skeleton loading h="30px" w="30px" borderRadius="full"/>
<Skeleton loading h="16px" w="120px" ml={ 3 }/>
</Flex>
<Flex mx={ 3 } my={ 2 } alignItems="center">
<Skeleton loading h="30px" w="30px" borderRadius="full"/>
<Skeleton loading h="16px" w="180px" ml={ 3 }/>
</Flex>
<Flex mx={ 3 } my={ 2 } alignItems="center">
<Skeleton loading h="30px" w="30px" borderRadius="full"/>
<Skeleton loading h="16px" w="150px" ml={ 3 }/>
</Flex>
</Flex>
</>
) : (
<TabsRoot
variant="segmented"
width="full"
fitted
size="sm"
lazyMount
value={ value }
onValueChange={ handleTabChange }
>
{ tabs.length > 1 && (
<TabsList mb={ 2 } width="full">
{ tabs.map((tab) => (
<TabsTrigger
key={ tab }
textTransform="capitalize"
value={ tab }
const content = (() => {
if (!items || items.length === 0) {
return (
<>
<Flex alignItems="center">
<Flex h="32px" w="105px" bgColor={{ base: 'blackAlpha.50', _dark: 'whiteAlpha.50' }} borderRadius="base" px={ 4 } py={ 2 }>
<Skeleton loading h="16px" w="100%"/>
</Flex>
<Skeleton loading h="16px" w="68px" mx={ 4 }/>
<Skeleton loading h="16px" w="45px" mx={ 4 }/>
</Flex>
<Flex mt={ 3 } flexDir="column" rowGap={ 2 }>
<Flex mx={ 3 } my={ 2 } alignItems="center">
<Skeleton loading h="30px" w="30px" borderRadius="full"/>
<Skeleton loading h="16px" w="120px" ml={ 3 }/>
</Flex>
<Flex mx={ 3 } my={ 2 } alignItems="center">
<Skeleton loading h="30px" w="30px" borderRadius="full"/>
<Skeleton loading h="16px" w="180px" ml={ 3 }/>
</Flex>
<Flex mx={ 3 } my={ 2 } alignItems="center">
<Skeleton loading h="30px" w="30px" borderRadius="full"/>
<Skeleton loading h="16px" w="150px" ml={ 3 }/>
</Flex>
</Flex>
</>
);
}
if (tabs.length === 1) {
return (
<VStack as="ul" gap={ 1 } alignItems="stretch" overflowY="scroll" maxH="516px">
{ items
.filter((network) => network.group === tabs[0])
.map((network) => (
<NetworkMenuLink
key={ network.title }
{ ...network }
/>
)) }
{ config.UI.featuredNetworks.allLink && (
<Link
href={ config.UI.featuredNetworks.allLink }
external
noIcon
variant="secondary"
my={ 2 }
px={ 2 }
fontSize="xs"
>
{ tab }
</TabsTrigger>
View all chains
</Link>
) }
</VStack>
);
}
return (
<TabsRoot
variant="segmented"
width="full"
fitted
size="sm"
lazyMount
value={ value }
onValueChange={ handleTabChange }
>
{ tabs.length > 1 && (
<TabsList mb={ 2 } width="full">
{ tabs.map((tab) => (
<TabsTrigger
key={ tab }
textTransform="capitalize"
value={ tab }
>
{ tab }
</TabsTrigger>
)) }
</TabsList>
) }
<Box>
{ tabs.map((tab) => (
<TabsContent key={ tab } value={ tab } p={ 0 }>
<VStack as="ul" gap={ 1 } alignItems="stretch" overflowY="scroll" maxH="516px">
{ items
.filter((network) => network.group === tab)
.map((network) => (
<NetworkMenuLink
key={ network.title }
{ ...network }
/>
)) }
{ config.UI.featuredNetworks.allLink && (
<Link
href={ config.UI.featuredNetworks.allLink }
external
noIcon
variant="secondary"
my={ 2 }
px={ 2 }
fontSize="xs"
>
View all chains
</Link>
) }
</VStack>
</TabsContent>
)) }
</TabsList>
) }
<Box>
{ tabs.map((tab) => (
<TabsContent key={ tab } value={ tab } p={ 0 }>
<VStack as="ul" gap={ 1 } alignItems="stretch" overflowY="scroll" maxH="516px">
{ items
.filter((network) => network.group === tab)
.map((network) => (
<NetworkMenuLink
key={ network.title }
{ ...network }
/>
)) }
{ config.UI.featuredNetworks.allLink && (
<Link
href={ config.UI.featuredNetworks.allLink }
external
noIcon
variant="secondary"
my={ 2 }
px={ 2 }
fontSize="xs"
>
View all chains
</Link>
) }
</VStack>
</TabsContent>
)) }
</Box>
</TabsRoot>
);
</Box>
</TabsRoot>
);
})();
return (
<PopoverContent w="290px" maxH="unset">
+2
View File
@@ -2,6 +2,7 @@ import { Flex, Separator, Box } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import { CONTENT_MAX_WIDTH } from 'ui/shared/layout/utils';
import DeFiDropdown from './DeFiDropdown';
import NetworkMenu from './NetworkMenu';
@@ -18,6 +19,7 @@ const TopBar = () => {
m="0 auto"
justifyContent="space-between"
alignItems="center"
maxW={ `${ CONTENT_MAX_WIDTH }px` }
>
{ !config.features.opSuperchain.isEnabled ? <TopBarStats/> : <div/> }
<Flex alignItems="center">
+11 -8
View File
@@ -14,14 +14,17 @@ interface Props {
const TokenHoldersList = ({ data, token, isLoading }: Props) => {
return (
<Box>
{ data.map((item, index) => (
<TokenHoldersListItem
key={ item.address.hash + (isLoading ? index : '') }
token={ token }
holder={ item }
isLoading={ isLoading }
/>
)) }
{ data.map((item, index) => {
const tokenId = 'token_id' in item ? item.token_id : null;
return (
<TokenHoldersListItem
key={ item.address.hash + tokenId + (isLoading ? index : '') }
token={ token }
holder={ item }
isLoading={ isLoading }
/>
);
}) }
</Box>
);
};
@@ -6,6 +6,7 @@ import type { TokenHolder, TokenInfo } from 'types/api/token';
import { Skeleton } from 'toolkit/chakra/skeleton';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TruncatedValue from 'ui/shared/TruncatedValue';
import Utilization from 'ui/shared/Utilization/Utilization';
interface Props {
@@ -33,9 +34,7 @@ const TokenHoldersListItem = ({ holder, token, isLoading }: Props) => {
<>
<ListItemMobileGrid.Label isLoading={ isLoading }>ID#</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton loading={ isLoading } display="inline-block">
{ holder.token_id }
</Skeleton>
<TruncatedValue value={ holder.token_id } isLoading={ isLoading } w="100%"/>
</ListItemMobileGrid.Value>
</>
) }
+6 -3
View File
@@ -24,9 +24,12 @@ const TokenHoldersTable = ({ data, token, top, isLoading }: Props) => {
</TableRow>
</TableHeaderSticky>
<TableBody>
{ data.map((item, index) => (
<TokenHoldersTableItem key={ item.address.hash + (isLoading ? index : '') } holder={ item } token={ token } isLoading={ isLoading }/>
)) }
{ data.map((item, index) => {
const tokenId = 'token_id' in item ? item.token_id : null;
return (
<TokenHoldersTableItem key={ item.address.hash + tokenId + (isLoading ? index : '') } holder={ item } token={ token } isLoading={ isLoading }/>
);
}) }
</TableBody>
</TableRoot>
);
@@ -6,6 +6,7 @@ import type { TokenHolder, TokenInfo } from 'types/api/token';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { TableCell, TableRow } from 'toolkit/chakra/table';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TruncatedValue from 'ui/shared/TruncatedValue';
import Utilization from 'ui/shared/Utilization/Utilization';
type Props = {
@@ -29,9 +30,7 @@ const TokenTransferTableItem = ({ holder, token, isLoading }: Props) => {
</TableCell>
{ (token.type === 'ERC-1155' || token.type === 'ERC-404') && 'token_id' in holder && (
<TableCell verticalAlign="middle">
<Skeleton loading={ isLoading } display="inline-block">
{ 'token_id' in holder && holder.token_id }
</Skeleton>
<TruncatedValue value={ holder.token_id } isLoading={ isLoading } w="100%"/>
</TableCell>
) }
<TableCell verticalAlign="middle" isNumeric>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+1 -1
View File
@@ -22,7 +22,7 @@ const TxDetails = ({ txQuery, tacOperationQuery }: Props) => {
return (
<>
<TestnetWarning mb={ 6 } isLoading={ txQuery.isPlaceholderData }/>
<TestnetWarning mb={{ base: 3, lg: 6 }} isLoading={ txQuery.isPlaceholderData }/>
<TxInfo
data={ txQuery.data }
tacOperations={ tacOperationQuery?.data?.items }
+1 -1
View File
@@ -152,7 +152,7 @@ const TxDetailsDegraded = ({ hash, txQuery }: Props) => {
return (
<>
<Flex rowGap={ 2 } mb={ 6 } flexDir="column">
<Flex rowGap={{ base: 1, lg: 2 }} mb={{ base: 3, lg: 6 }} flexDir="column">
<TestnetWarning isLoading={ query.isPlaceholderData }/>
{ originalError?.status !== 404 && <ServiceDegradationWarning isLoading={ query.isPlaceholderData }/> }
</Flex>
+3 -3
View File
@@ -141,9 +141,9 @@ const TxInfo = ({ data, tacOperations, isLoading, socketStatus }: Props) => {
{ config.features.metasuites.isEnabled && (
<>
<Box display="none" id="meta-suites__tx-info-label" data-status={ data.status } data-ready={ !isLoading }/>
<Box display="none" id="meta-suites__tx-info-value"/>
<DetailedInfo.ItemDivider display="none" id="meta-suites__details-info-item-divider"/>
<Box display="none" as="p" id="meta-suites__tx-info-label" data-status={ data.status } data-ready={ !isLoading }/>
<Box display="none" as="p" id="meta-suites__tx-info-value"/>
<DetailedInfo.ItemDivider display="none" as="p" id="meta-suites__details-info-item-divider"/>
</>
) }
+5
View File
@@ -7,6 +7,7 @@ import type { Transaction, TransactionsSortingField, TransactionsSortingValue }
import type { PaginationParams } from 'ui/shared/pagination/types';
import useIsMobile from 'lib/hooks/useIsMobile';
import { apos } from 'toolkit/utils/htmlEntities';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
@@ -119,6 +120,10 @@ const TxsContent = ({
itemsNum={ itemsWithTranslation?.length }
emptyText="There are no transactions."
actionBar={ actionBar }
filterProps={{
hasActiveFilters: Boolean(filterValue),
emptyFilteredText: `Couldn${ apos }t find any transaction that matches your query.`,
}}
>
{ content }
</DataListDisplay>