Multichain explorer v2 (#3259)

* Support redirect to a correct view from search

* show domains on address page

* migrate to new API version to display address domains

* another api update

* new design for chain widget on home page + tests

* chain widget styles for mobile devices

* ecosystems page

* update types

* add ecosystems to navigation

* address portfolio: pt.1

* portfolio tokens list

* filter portfolio by chain

* skip redirect when navigating to search result by clicking on "show all results" link

* net worth block design

* add filter input to chain select and fix tokens table

* calculate address portfolio

* adjust card logic

* support custom token types in multichain explorer

* handle case when new worth is 0

* add text filter to tokens list

* fixes

* demo envs

* portfolio tests

* ecosystems test

* change signature for getTokenTypes function

* fix adding token to the wallet from the list

* lazy load all address domains

* show contract creator and implementations info

* fix tests

* add poor reputation tokens toggle

* tweaks

* dim celo native tokens

* disable env validator

* fix tests

* remove tokens with 0 usd value from top tokens

* fixes

* preserve selected chain when switching tabs on address page

* add hints to stats widgets on home page

* update envs for demo

* fix portfolio cards and home page

* update styles for contract creator and implementations

* fix select issues

* add chains from cluster to network menu

* hide csv export link on empty logs page

* update screenshots

* rollback select fix

* update cut length calculation for portfolio cards

* fix chain select

* update screenshots

* fix tests

* more fixes

* add sorting to ecosystems table

* fix test

* fixes

* fix loading state in ecosystems page

* review fixes

* fix tests
This commit is contained in:
tom goriunov
2026-02-25 10:45:09 +01:00
committed by GitHub
parent c527fc6aa5
commit ebbee2ba57
188 changed files with 3403 additions and 1769 deletions
-1
View File
@@ -357,7 +357,6 @@
"neon_devnet",
"numine",
"optimism",
"optimism_interop_0",
"optimism_sepolia",
"optimism_superchain",
"polygon",
-54
View File
@@ -1,54 +0,0 @@
# Set of ENVs for OP Interop Alpha 0 network explorer
# https://optimism-interop-alpha-0.blockscout.com
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=optimism_interop_0"
# 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
# Instance ENVs
NEXT_PUBLIC_AD_BANNER_ENABLE_SPECIFY=true
NEXT_PUBLIC_AD_BANNER_PROVIDER=none
NEXT_PUBLIC_AD_TEXT_PROVIDER=none
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol','drops','blockscoutbadges','gitpoap','efp','etherscore','webacy','humanpassport','trustblock','smartmuv','humanode','deepdao']
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=optimism-interop-alpha-0.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
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_FAULT_PROOF_ENABLED=true
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/optimism.json
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['linear-gradient(90deg, rgb(232, 52, 53) 0%, rgb(139, 28, 232) 100%)'],'text_color':['rgb(255, 255, 255)']}
NEXT_PUBLIC_INTEROP_ENABLED=true
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_MIXPANEL_CONFIG_OVERRIDES={"record_sessions_percent": 0.5,"record_heatmap_data": true}
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/optimism.svg
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/optimism.svg
NEXT_PUBLIC_NETWORK_ID=420120000
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/optimism.svg
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/optimism.svg
NEXT_PUBLIC_NETWORK_NAME=OP Interop Alpha 0
NEXT_PUBLIC_NETWORK_RPC_URL=https://interop-alpha-0.optimism.io
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_PUZZLE_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/capyPuzzleBadge
NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth-sepolia.blockscout.com/
NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://bridge.interop-alpha-0.optimism.io
NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED=false
NEXT_PUBLIC_ROLLUP_TYPE=optimistic
NEXT_PUBLIC_STATS_API_BASE_PATH=/stats-service
NEXT_PUBLIC_STATS_API_HOST=https://optimism-interop-alpha-0.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_WEB3_WALLETS=['token_pocket', 'metamask']
+10 -16
View File
@@ -8,32 +8,26 @@ NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST=https://multichain-aggregator.k8s-dev.blockscout.com
NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST=http://multichain-search-stats.k8s-dev.blockscout.com
NEXT_PUBLIC_MULTICHAIN_ENABLED=true
NEXT_PUBLIC_MULTICHAIN_CLUSTER=interop
# NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST=https://multichain-aggregator.k8s-dev.blockscout.com
# NEXT_PUBLIC_MULTICHAIN_CLUSTER=interop
# NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST=http://multichain-search-stats.k8s-dev.blockscout.com
NEXT_PUBLIC_MULTICHAIN_AGGREGATOR_API_HOST=https://multichain-aggregator.services.blockscout.com
NEXT_PUBLIC_MULTICHAIN_CLUSTER=multichain
NEXT_PUBLIC_MULTICHAIN_STATS_API_HOST=https://multichain-stats.k8s-prod-3.blockscout.com
SKIP_ENVS_VALIDATION=false
SKIP_ENVS_VALIDATION=true
NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG=[{'name': 'zerion', 'url_template': 'https://app.zerion.io/{address}/overview?utm_source=blockscout&utm_medium=address', 'logo': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-logos/zerion.svg'}]
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap']
NEXT_PUBLIC_HOMEPAGE_STATS=['total_txs','wallet_addresses']
NEXT_PUBLIC_API_DOCS_TABS=[]
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/optimism-mainnet.json
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/optimism.json
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs', 'coin_price', 'market_cap', 'secondary_coin_price']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['linear-gradient(90deg, rgb(232, 52, 53) 0%, rgb(139, 28, 232) 100%)'],'text_color':['rgb(255, 255, 255)']}
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/optimism-superchain.svg
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/optimism-superchain.svg
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/optimism-superchain.svg
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/optimism-superchain-dark.svg
NEXT_PUBLIC_NETWORK_NAME=OP Superchain
NEXT_PUBLIC_NETWORK_SHORT_NAME=OP Superchain
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/optimism-mainnet.png
NEXT_PUBLIC_NETWORK_NAME=Blockscout
NEXT_PUBLIC_GAS_TRACKER_ENABLED=false
NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS=true
NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS=true
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=false
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_IS_TESTNET=false
NEXT_PUBLIC_USE_NEXT_JS_PROXY=true
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_ADVANCED_FILTER_ENABLED=false
+5
View File
@@ -11,6 +11,11 @@ const outputFile = resolvePath(outputDir, 'llms.txt');
function run() {
try {
if(config.features.opSuperchain.isEnabled){
console.log('⏭️ Skipping llms.txt generation for multichain explorer');
return;
}
console.log('🌀 Generating llms.txt...');
const chainName = config.chain.name ?? '';
+4
View File
@@ -0,0 +1,4 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.547 21.698c.65.545 1.38.996 2.171 1.334l-.165.54-.466 1.363A10.743 10.743 0 0 1 7.57 22.6l1.026-.22.056-.01.054-.018.078-.026.047-.015.047-.02a1.46 1.46 0 0 0 .668-.594Zm10.952.2c.346.587.84 1.077 1.428 1.42a10.723 10.723 0 0 1-2.716 1.616l-.467-1.363-.166-.54a8.72 8.72 0 0 0 1.92-1.133Zm5.225-8.712c.1.59.157 1.196.157 1.814 0 .46-.034.914-.09 1.36a3.96 3.96 0 0 0-1.657-.458l-.205-.004c-.031 0-.063 0-.094.002a8.816 8.816 0 0 0-.028-2.017l1.917-.697Zm-21.22.453c.443.235.935.389 1.458.442l.051.004.3.016h.053l.095-.001a8.846 8.846 0 0 0-.015 1.597l-1.973.421a10.835 10.835 0 0 1 .031-2.48Zm7.05-7.21.165.538A8.723 8.723 0 0 0 9.796 8.1 4.002 4.002 0 0 0 8.369 6.68c.815-.665 1.73-1.211 2.718-1.615l.466 1.365Zm7.657-1.364a10.734 10.734 0 0 1 3.088 1.933l-1.473.536c-.226.083-.42.216-.573.382a8.72 8.72 0 0 0-1.675-.95l.166-.536.467-1.365ZM16.842 21.766a.63.63 0 0 1 .525.268l.046.076.006.013.026.065.004.009.003.008.496 1.599.55 1.588.025.1a.626.626 0 0 1-.26.615l-1.386.966-1.353 1.003a.632.632 0 0 1-.664.054l-.087-.053-1.352-1.004-1.387-.966a.624.624 0 0 1-.234-.715l.549-1.588.497-1.599.006-.017.027-.065.006-.013a.628.628 0 0 1 .571-.344l1.692.024 1.694-.024Zm-1.664 1.682-.018.002h-.021l-.02-.002-.908-.013-.268.864-.002.004-.012.037v.002l-.296.854.747.52.004.001.03.023.002.002.732.543.733-.544.002-.001.031-.023.003-.002.745-.52-.294-.853v-.002l-.012-.037-.002-.004-.27-.864-.906.013Zm8.795-6.707c1.732 0 3.142 1.392 3.142 3.113v.01l-.015.298-.001.01c-.162 1.571-1.502 2.795-3.126 2.795h-.01l-.3-.015h-.01c-1.583-.16-2.822-1.485-2.822-3.098 0-1.72 1.409-3.112 3.142-3.113Zm-16.575-.377c.281-.003.539.19.601.477l.855 3.957.001.01.001.01.01.08.001.01v.011a.606.606 0 0 1-.365.559l-.009.004-.01.003-.078.025-.01.004-.01.002-3.996.846-.022.004-.082.007-.009.001h-.008a.612.612 0 0 1-.563-.362l-.004-.009-.003-.01-.025-.077-.003-.01-.002-.01-.855-3.955a.606.606 0 0 1 .47-.719l3.994-.846.011-.002h.01l.082-.01H7.398ZM23.973 18.4c-.814 0-1.47.652-1.47 1.453 0 .8.656 1.455 1.47 1.455s1.471-.654 1.471-1.455-.657-1.453-1.471-1.453Zm-19.303.223.408 1.895 1.918-.406-.408-1.894-1.918.405Zm20.435-11.67c.279-.04.56.116.662.392l1.397 3.8.003.01.003.011.02.08.002.008.001.01a.606.606 0 0 1-.284.605l-.008.005-.008.004-.074.035-.01.005-.01.004-3.839 1.384-.01.003-.009.002-.08.02-.01.003-.011.002a.612.612 0 0 1-.606-.282l-.006-.009-.004-.01-.035-.072-.004-.009-.004-.01-1.398-3.801a.604.604 0 0 1 .364-.776l3.84-1.383.008-.003.01-.002.079-.02.01-.004.011-.002Zm-18.783.079c1.733 0 3.142 1.391 3.143 3.112v.01l-.016.298v.01c-.162 1.571-1.502 2.795-3.127 2.795h-.01l-.3-.015-.01-.002c-1.582-.159-2.82-1.483-2.82-3.096 0-1.72 1.408-3.112 3.14-3.112Zm0 1.658c-.813 0-1.47.653-1.47 1.454l.008.148c.076.732.7 1.306 1.462 1.307.814 0 1.472-.654 1.472-1.455l-.008-.149a1.466 1.466 0 0 0-1.464-1.305Zm16.396.876.668 1.82 1.843-.663-.67-1.82-1.841.663ZM14.859 1.87a.632.632 0 0 1 .665.054l1.358 1.008 1.382.962c.227.16.325.45.234.715l-.55 1.589-.496 1.598-.003.009-.004.009-.026.064-.003.006-.004.007a.63.63 0 0 1-.57.345l-1.694-.027-1.692.027a.63.63 0 0 1-.571-.345l-.003-.006-.003-.007-.027-.064-.006-.018-.497-1.598-.55-1.59a.624.624 0 0 1 .235-.714l1.382-.962 1.357-1.008.086-.054Zm-.443 2.39-.002.001-.03.023-.004.002-.747.518.296.855.012.039.002.003.268.864.916-.013H15.17l.914.013.27-.864.002-.003.012-.038.294-.856-.745-.518-.003-.002-.031-.023h-.002l-.733-.546-.732.545Z" fill="currentColor"/>
<path d="M15.849 11.905a1.3 1.3 0 0 0 .739.307l1.11.089.09 1.111a1.3 1.3 0 0 0 .307.74l.722.848-.722.849a1.3 1.3 0 0 0-.295.637l-.012.102-.09 1.11-1.11.09a1.3 1.3 0 0 0-.66.243l-.08.064-.848.722-.849-.722-.08-.064a1.3 1.3 0 0 0-.659-.243l-1.111-.09-.09-1.11a1.3 1.3 0 0 0-.306-.74L11.182 15l.723-.849a1.3 1.3 0 0 0 .307-.739l.089-1.111 1.111-.09a1.3 1.3 0 0 0 .74-.306l.848-.723.849.723Z" stroke="currentColor" stroke-width="1.8"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+27 -3
View File
@@ -1,6 +1,6 @@
import type { ApiResource } from '../types';
import type * as multichain from '@blockscout/multichain-aggregator-types';
import type { AddressTokensResponse, TokensResponse } from 'types/client/multichain-aggregator';
import type { AddressTokensFilter, AddressTokensResponse, TokensResponse } from 'types/client/multichain-aggregator';
export const MULTICHAIN_AGGREGATOR_API_RESOURCES = {
address: {
@@ -11,17 +11,33 @@ export const MULTICHAIN_AGGREGATOR_API_RESOURCES = {
path: '/addresses/:hash/tokens',
pathParams: [ 'hash' as const ],
paginated: true,
filterFields: [ 'chain_id' as const, 'type' as const ],
filterFields: [ 'chain_id' as const, 'type' as const, 'query' as const ],
},
address_domains: {
path: '/addresses/:hash/domains',
pathParams: [ 'hash' as const ],
paginated: true,
},
address_portfolio: {
path: '/addresses/:hash/portfolio',
pathParams: [ 'hash' as const ],
filterFields: [ 'chain_id' as const, 'query' as const ],
},
tokens: {
path: '/tokens',
filterFields: [ 'chain_id' as const, 'type' as const, 'query' as const ],
paginated: true,
},
domain_protocols: {
path: '/domain-protocols',
},
quick_search: {
path: '/search\\:quick',
filterFields: [ 'q' as const ],
},
search_check_redirect: {
path: '/search\\:check-redirect',
},
search_addresses: {
path: '/search/addresses',
filterFields: [ 'q' as const, 'chain_id' as const ],
@@ -57,6 +73,9 @@ export const MULTICHAIN_AGGREGATOR_API_RESOURCES = {
filterFields: [ 'q' as const, 'chain_id' as const ],
paginated: true,
},
chain_metrics: {
path: '/chain-metrics',
},
} satisfies Record<string, ApiResource>;
export type MultichainAggregatorApiResourceName = `multichainAggregator:${ keyof typeof MULTICHAIN_AGGREGATOR_API_RESOURCES }`;
@@ -65,8 +84,12 @@ export type MultichainAggregatorApiResourceName = `multichainAggregator:${ keyof
export type MultichainAggregatorApiResourcePayload<R extends MultichainAggregatorApiResourceName> =
R extends 'multichainAggregator:address' ? multichain.GetAddressResponse :
R extends 'multichainAggregator:address_tokens' ? AddressTokensResponse :
R extends 'multichainAggregator:address_portfolio' ? multichain.GetAddressPortfolioResponse :
R extends 'multichainAggregator:address_domains' ? multichain.LookupAddressDomainsResponse :
R extends 'multichainAggregator:tokens' ? TokensResponse :
R extends 'multichainAggregator:domain_protocols' ? multichain.ListDomainProtocolsResponse :
R extends 'multichainAggregator:quick_search' ? multichain.ClusterQuickSearchResponse :
R extends 'multichainAggregator:search_check_redirect' ? multichain.CheckRedirectResponse :
R extends 'multichainAggregator:search_addresses' ? multichain.SearchAddressesResponse :
R extends 'multichainAggregator:search_blocks' ? multichain.SearchBlocksResponse :
R extends 'multichainAggregator:search_block_numbers' ? multichain.SearchBlockNumbersResponse :
@@ -74,12 +97,13 @@ R extends 'multichainAggregator:search_transactions' ? multichain.SearchTransact
R extends 'multichainAggregator:search_tokens' ? multichain.SearchTokensResponse :
R extends 'multichainAggregator:search_nfts' ? multichain.SearchNftsResponse :
R extends 'multichainAggregator:search_domains' ? multichain.SearchDomainsResponse :
R extends 'multichainAggregator:chain_metrics' ? multichain.ListChainMetricsResponse :
never;
/* eslint-enable @stylistic/indent */
/* eslint-disable @stylistic/indent */
export type MultichainAggregatorApiPaginationFilters<R extends MultichainAggregatorApiResourceName> =
R extends 'multichainAggregator:address_tokens' ? Partial<multichain.ListAddressTokensRequest> :
R extends 'multichainAggregator:address_tokens' ? AddressTokensFilter :
R extends 'multichainAggregator:tokens' ? Partial<multichain.ListClusterTokensRequest> :
never;
/* eslint-enable @stylistic/indent */
+1
View File
@@ -25,6 +25,7 @@ export enum NAMES {
HIDE_ADD_TO_WALLET_BUTTON = 'hide_add_to_wallet_button',
UUID = 'uuid',
SHOW_SCAM_TOKENS = 'show_scam_tokens',
SHOW_POOR_REPUTATION_TOKENS = 'show_poor_reputation_tokens',
APP_PROFILE = 'app_profile',
TABLE_VIEW_ON_MOBILE = 'table_view_on_mobile',
}
+6
View File
@@ -271,6 +271,12 @@ export default function useNavItems(): ReturnType {
icon: 'navigation/chain_stats',
isActive: pathname.startsWith('/stats'),
},
config.features.opSuperchain.isEnabled && {
text: 'Ecosystems',
nextRoute: { pathname: '/ecosystems' as const },
icon: 'navigation/ecosystems',
isActive: pathname.startsWith('/ecosystems'),
},
megaEthFeature.isEnabled && megaEthFeature.socketUrl.metrics && {
text: 'Uptime',
nextRoute: { pathname: '/uptime' as const },
+1
View File
@@ -83,6 +83,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/chain/[chain_slug]/token/[hash]/instance/[id]': 'Regular page',
'/chain/[chain_slug]/tx/[hash]': 'Regular page',
'/chain/[chain_slug]/visualize/sol2uml': 'Regular page',
'/ecosystems': 'Root page',
// service routes, added only to make typescript happy
'/login': 'Regular page',
+1
View File
@@ -86,6 +86,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/chain/[chain_slug]/token/[hash]/instance/[id]': DEFAULT_TEMPLATE,
'/chain/[chain_slug]/tx/[hash]': DEFAULT_TEMPLATE,
'/chain/[chain_slug]/visualize/sol2uml': DEFAULT_TEMPLATE,
'/ecosystems': DEFAULT_TEMPLATE,
// service routes, added only to make typescript happy
'/login': DEFAULT_TEMPLATE,
+1
View File
@@ -87,6 +87,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/chain/[chain_slug]/token/[hash]/instance/[id]': '%network_name% token NFT instance',
'/chain/[chain_slug]/tx/[hash]': '%network_name% transaction %hash% details',
'/chain/[chain_slug]/visualize/sol2uml': '%network_name% Solidity UML diagram',
'/ecosystems': '%network_name% ecosystems',
// service routes, added only to make typescript happy
'/login': '%network_name% login',
+1
View File
@@ -81,6 +81,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/chain/[chain_slug]/token/[hash]/instance/[id]': 'Chain token NFT instance',
'/chain/[chain_slug]/tx/[hash]': 'Chain transaction details',
'/chain/[chain_slug]/visualize/sol2uml': 'Chain Solidity UML diagram',
'/ecosystems': 'Ecosystems',
// service routes, added only to make typescript happy
'/login': 'Login',
-6
View File
@@ -1,6 +0,0 @@
import multichainConfig from 'configs/multichain';
export default function getCurrencySymbol() {
const nativeCurrency = multichainConfig()?.chains[0]?.app_config.chain.currency;
return nativeCurrency?.symbol;
}
+26 -11
View File
@@ -1,4 +1,5 @@
import type { NFTTokenType, TokenType } from 'types/api/token';
import type { ClusterChainConfig } from 'types/multichain';
import config from 'configs/app';
@@ -11,24 +12,38 @@ export const NFT_TOKEN_TYPES: Record<NFTTokenType, string> = {
'ERC-404': `${ tokenStandardName }-404`,
};
export const TOKEN_TYPES: Record<string, string> = {
'ERC-20': `${ tokenStandardName }-20`,
...additionalTokenTypes.reduce((result, item) => {
result[item.id] = item.name;
return result;
}, {} as Record<string, string>),
...NFT_TOKEN_TYPES,
export const getTokenTypes = (nftOnly: boolean, chainConfig: Array<ClusterChainConfig['app_config']> | ClusterChainConfig['app_config'] = config) => {
if (nftOnly) {
return NFT_TOKEN_TYPES;
}
const chainConfigs = Array.isArray(chainConfig) ? chainConfig : [ chainConfig ];
return {
'ERC-20': `${ tokenStandardName }-20`,
...chainConfigs
.map((chainConfig) => chainConfig.chain.additionalTokenTypes)
.flat()
.reduce((result, item) => {
result[item.id] = item.name;
return result;
}, {} as Record<string, string>),
...NFT_TOKEN_TYPES,
};
};
export const NFT_TOKEN_TYPE_IDS: Array<NFTTokenType> = Object.keys(NFT_TOKEN_TYPES) as Array<NFTTokenType>;
export const TOKEN_TYPE_IDS = Object.keys(TOKEN_TYPES);
export function getTokenTypeName(typeId: string) {
return TOKEN_TYPES[typeId] || typeId;
export function getTokenTypeName(typeId: string, chainConfig?: Array<ClusterChainConfig['app_config']> | ClusterChainConfig['app_config']) {
if (typeId === 'NATIVE') {
return 'Native token';
}
const tokenTypes = getTokenTypes(false, chainConfig);
return tokenTypes[typeId as keyof typeof tokenTypes] || typeId;
}
export function isFungibleTokenType(typeId: TokenType): boolean {
return typeId === 'ERC-20' || additionalTokenTypes.some((item) => item.id === typeId);
return typeId === 'ERC-20' || typeId === 'NATIVE' || additionalTokenTypes.some((item) => item.id === typeId);
}
export function hasTokenTransferValue(typeId: TokenType) {
+6 -2
View File
@@ -27,12 +27,16 @@ function getParams(chainConfig: typeof config): AddEthereumChainParameter {
};
}
export default function useAddChain() {
interface Params {
chainConfig?: typeof config;
}
export default function useAddChain(params?: Params) {
const { data: { wallet, provider } = {} } = useProvider();
const { trackUsage } = useRewardsActivity();
const multichainContext = useMultichainContext();
const chainConfig = multichainContext?.chain.app_config || config;
const chainConfig = params?.chainConfig || multichainContext?.chain.app_config || config;
return React.useCallback(async() => {
if (!wallet || !provider) {
+6 -2
View File
@@ -14,11 +14,15 @@ function getParams(chainConfig: typeof config): { chainId: string } {
return { chainId: getHexadecimalChainId(Number(chainConfig.chain.id)) };
}
export default function useSwitchChain() {
interface Params {
chainConfig?: typeof config;
}
export default function useSwitchChain(params?: Params) {
const { data: { wallet, provider } = {} } = useProvider();
const multichainContext = useMultichainContext();
const chainConfig = multichainContext?.chain.app_config ?? config;
const chainConfig = params?.chainConfig || multichainContext?.chain.app_config || config;
return React.useCallback(() => {
if (!wallet || !provider) {
+8 -3
View File
@@ -1,16 +1,21 @@
import { get } from 'es-toolkit/compat';
import React from 'react';
import type config from 'configs/app';
import getErrorObj from 'lib/errors/getErrorObj';
import useAddChain from './useAddChain';
import useProvider from './useProvider';
import useSwitchChain from './useSwitchChain';
export default function useSwitchOrAddChain() {
interface Params {
chainConfig?: typeof config;
}
export default function useSwitchOrAddChain(params?: Params) {
const { data: { wallet, provider } = {} } = useProvider();
const addChain = useAddChain();
const switchChain = useSwitchChain();
const addChain = useAddChain(params);
const switchChain = useSwitchChain(params);
return React.useCallback(async() => {
if (!wallet || !provider) {
+1
View File
@@ -23,6 +23,7 @@ export async function middleware(req: NextRequest) {
middlewares.colorTheme(req, res);
middlewares.addressFormat(req, res);
middlewares.scamTokens(req, res);
middlewares.poorReputationTokens(req, res);
const end = Date.now();
+78
View File
@@ -0,0 +1,78 @@
import type * as multichain from '@blockscout/multichain-aggregator-types';
import { chainA, chainB, chainC, chainD, chainE } from './chains';
export const domainProtocols: Array<multichain.ProtocolInfo> = [
{
id: 'goose',
short_name: 'GOOSE',
title: 'Goose Name Service',
description: 'The Goose Name Service is a decentralized naming system for the Goose blockchain.',
deployment_blockscout_base_url: 'https://blockscout.com',
tld_list: [ 'goose' ],
icon_url: 'https://goose.com/icon.jpg',
docs_url: 'https://goose.com/docs',
},
{
id: 'duck',
short_name: 'DUCK',
title: 'Duck Name Service',
description: 'The Duck Name Service is a decentralized naming system for the Duck blockchain.',
deployment_blockscout_base_url: 'https://blockscout.com',
tld_list: [ 'duck', 'quack' ],
icon_url: 'https://duck.com/icon.jpg',
docs_url: 'https://duck.com/docs',
},
];
export const addressA: multichain.GetAddressResponse = {
hash: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
chain_infos: {
[chainA.id]: {
coin_balance: '33298149965862412288',
is_contract: false,
is_verified: false,
contract_name: undefined,
},
[chainB.id]: {
coin_balance: '1836931848855642237',
is_contract: true,
is_verified: false,
contract_name: undefined,
},
[chainC.id]: {
coin_balance: '0',
is_contract: true,
is_verified: true,
contract_name: 'Test Contract',
},
[chainD.id]: {
coin_balance: '0',
is_contract: false,
is_verified: false,
contract_name: undefined,
},
[chainE.id]: {
coin_balance: '0',
is_contract: false,
is_verified: false,
contract_name: undefined,
},
},
has_tokens: true,
has_interop_message_transfers: false,
coin_balance: '35135081814718054525',
exchange_rate: '123.455',
domains: [
{ name: 'ga-ga-ga.goose', protocol: domainProtocols[0] },
{ name: 'quack-quack.duck', protocol: domainProtocols[1] },
],
};
export const addressDomainsA: multichain.LookupAddressDomainsResponse = {
items: [
{ name: 'ga-ga-ga.goose', protocol: domainProtocols[0] },
{ name: 'quack-quack.duck', protocol: domainProtocols[1] },
],
next_page_params: undefined,
};
+129
View File
@@ -0,0 +1,129 @@
import type { ClusterChainConfig } from 'types/multichain';
export const chainA = {
slug: 'op-mainnet',
name: 'OP Mainnet',
id: '420',
logo: 'https://example.com/logo_s.png',
explorer_url: 'https://op-mainnet.com',
app_config: {
app: {
baseUrl: 'https://op-mainnet.com',
},
chain: {
currency: {
name: 'Ether',
symbol: 'ETH',
decimals: 18,
},
rpcUrls: [
'https://rpc.op-mainnet.com',
],
additionalTokenTypes: [ ],
},
apis: {
general: {
host: 'localhost',
protocol: 'http',
port: '4003',
endpoint: 'http://localhost:4003',
socketEndpoint: 'ws://localhost:3200',
},
},
UI: {
views: {
address: {},
},
navigation: {
icon: {},
},
},
features: {
csvExport: {
isEnabled: true,
reCaptcha: { siteKey: 'xxx' },
},
advancedFilter: {
isEnabled: true,
},
web3Wallet: {
isEnabled: true,
wallets: [ 'metamask' ],
},
rollup: {
isEnabled: true,
type: 'optimistic',
stageIndex: '1',
parentChain: {
baseUrl: 'https://eth-mainnet.com',
},
},
},
},
} as unknown as ClusterChainConfig;
export const chainB = {
...chainA,
id: '421',
name: 'OP Testnet',
logo: 'https://example.com/logo_md.png',
slug: 'op-testnet',
explorer_url: 'https://op-testnet.com',
app_config: {
...chainA.app_config,
chain: {
...chainA.app_config?.chain,
isTestnet: true,
},
app: {
baseUrl: 'https://op-testnet.com',
},
apis: {
general: {
...chainA.app_config?.apis?.general,
port: '4004',
endpoint: 'http://localhost:4004',
},
},
},
} as ClusterChainConfig;
export const chainC = {
...chainA,
id: '422',
name: 'OP Devnet',
slug: 'op-devnet',
explorer_url: 'https://op-devnet.com',
logo: undefined,
app_config: {
...chainA.app_config,
chain: {
...chainA.app_config?.chain,
isTestnet: true,
},
app: {
baseUrl: 'https://op-devnet.com',
},
apis: {
general: {
...chainA.app_config?.apis?.general,
port: '4005',
endpoint: 'http://localhost:4005',
},
},
},
} as ClusterChainConfig;
export const chainD = {
...chainC,
id: '423',
name: 'Yellow rubber duck quack-quack-quack chain',
slug: 'yellow-rubber-duck-chain',
};
export const chainE = {
...chainC,
id: '424',
name: 'White goose',
slug: 'white-goose',
};
+40
View File
@@ -0,0 +1,40 @@
import type * as multichain from '@blockscout/multichain-aggregator-types';
import { chainA, chainB, chainC } from './chains';
export const chainMetrics: multichain.ListChainMetricsResponse = {
items: [
{
chain_id: chainA.id,
active_accounts: {
current_full_week: '1000',
previous_full_week: '900',
wow_diff_percent: '10',
},
tps: '1000',
new_addresses: {
current_full_week: '500',
previous_full_week: '600',
wow_diff_percent: '-20',
},
},
{
chain_id: chainB.id,
active_accounts: undefined,
tps: '42',
new_addresses: {
current_full_week: '200',
previous_full_week: '10',
wow_diff_percent: '900',
},
},
{
chain_id: chainC.id,
active_accounts: {
current_full_week: '100',
previous_full_week: '100',
wow_diff_percent: '0',
},
},
],
};
-206
View File
@@ -1,206 +0,0 @@
import type * as multichain from '@blockscout/multichain-aggregator-types';
import type { AddressTokenItem } from 'types/client/multichain-aggregator';
import type { ClusterChainConfig } from 'types/multichain';
export const chainDataA = {
slug: 'op-mainnet',
name: 'OP Mainnet',
id: '420',
logo: 'https://example.com/logo_s.png',
explorer_url: 'https://op-mainnet.com',
app_config: {
app: {
baseUrl: 'https://op-mainnet.com',
},
chain: {
currency: {
name: 'Ether',
symbol: 'ETH',
decimals: 18,
},
rpcUrls: [
'https://rpc.op-mainnet.com',
],
},
apis: {
general: {
host: 'localhost',
protocol: 'http',
port: '4003',
endpoint: 'http://localhost:4003',
socketEndpoint: 'ws://localhost:3200',
},
},
features: {
csvExport: {
isEnabled: true,
reCaptcha: { siteKey: 'xxx' },
},
advancedFilter: {
isEnabled: true,
},
},
},
} as ClusterChainConfig;
export const chainDataB = {
...chainDataA,
id: '421',
name: 'OP Testnet',
logo: 'https://example.com/logo_md.png',
slug: 'op-testnet',
explorer_url: 'https://op-testnet.com',
app_config: {
...chainDataA.app_config,
app: {
baseUrl: 'https://op-testnet.com',
},
apis: {
general: {
...chainDataA.app_config?.apis?.general,
port: '4004',
endpoint: 'http://localhost:4004',
},
},
},
} as ClusterChainConfig;
export const chainDataC = {
...chainDataA,
id: '422',
name: 'OP Devnet',
slug: 'op-devnet',
explorer_url: 'https://op-devnet.com',
logo: undefined,
app_config: {
...chainDataA.app_config,
app: {
baseUrl: 'https://op-devnet.com',
},
apis: {
general: {
...chainDataA.app_config?.apis?.general,
port: '4005',
endpoint: 'http://localhost:4005',
},
},
},
} as ClusterChainConfig;
export const addressA: multichain.GetAddressResponse = {
hash: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
chain_infos: {
'420': {
coin_balance: '33298149965862412288',
is_contract: false,
is_verified: false,
contract_name: undefined,
},
'421': {
coin_balance: '1836931848855642237',
is_contract: true,
is_verified: false,
contract_name: undefined,
},
'422': {
coin_balance: '0',
is_contract: true,
is_verified: true,
contract_name: 'Test Contract',
},
},
has_tokens: true,
has_interop_message_transfers: false,
coin_balance: '35135081814718054525',
exchange_rate: '123.455',
};
export const tokenA: AddressTokenItem = {
token: {
address_hash: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0',
circulating_market_cap: '10.01',
decimals: '6',
holders_count: '1250',
icon_url: null,
name: 'USDT',
symbol: 'USDT',
total_supply: '284529257047384793',
type: 'ERC-20',
exchange_rate: '123.456',
chain_infos: {
'420': {
holders_count: '1250',
total_supply: '184529257047384791',
is_verified: true,
contract_name: 'TestnetERC20',
},
'421': {
holders_count: '1250',
total_supply: '100000000000000002',
is_verified: true,
contract_name: 'TestnetERC20',
},
},
reputation: null,
},
token_instance: null,
token_id: null,
value: '9038000000',
chain_values: {
'420': '1038000000',
'421': '8000000000',
},
};
export const searchAddressesA: multichain.GetAddressResponse = {
hash: '0x0000000002C5fE54822a1eD058AE2F937Fd42769',
chain_infos: {
'420': {
coin_balance: '0',
is_contract: false,
is_verified: false,
contract_name: undefined,
},
},
has_tokens: false,
has_interop_message_transfers: false,
coin_balance: '0',
exchange_rate: '123.456',
};
export const searchAddressesB: multichain.GetAddressResponse = {
hash: '0x00883b68A6EcF2ea3D47BD735E5125a0B7625B53',
chain_infos: {
'420': {
coin_balance: '0',
is_contract: true,
is_verified: true,
contract_name: 'USDT',
},
},
has_tokens: false,
has_interop_message_transfers: false,
coin_balance: '0',
exchange_rate: '123.456',
};
export const searchTokenA: multichain.AggregatedTokenInfo = {
address_hash: '0x94b008aA00579c1307B0EF2c499aD98a8ce58e58',
circulating_market_cap: '162513452583.22',
decimals: '6',
holders_count: '1148927',
icon_url: undefined,
name: 'Tether USD',
symbol: 'USDT',
total_supply: '1000000',
type: 'ERC-20' as multichain.TokenType,
exchange_rate: '1.00',
chain_infos: {
'420': {
holders_count: '1148927',
total_supply: '1000000',
is_verified: false,
contract_name: undefined,
},
},
};
+21
View File
@@ -0,0 +1,21 @@
import type * as multichain from '@blockscout/multichain-aggregator-types';
import { chainA, chainB, chainC } from './chains';
export const base: multichain.GetAddressPortfolioResponse = {
portfolio: {
total_value: (128147.328 + 73107.04593748 + 17675.3 + 217.17267).toFixed(4),
chain_values: {
[chainA.id]: (128147.328 + 73107.04593748).toFixed(4),
[chainB.id]: 17675.3.toFixed(4),
[chainC.id]: 217.17267.toFixed(4),
},
},
};
export const zero: multichain.GetAddressPortfolioResponse = {
portfolio: {
total_value: '0.0000',
chain_values: {},
},
};
+58
View File
@@ -0,0 +1,58 @@
import type * as multichain from '@blockscout/multichain-aggregator-types';
import { chainA } from './chains';
export const searchAddressesA: multichain.GetAddressResponse = {
hash: '0x0000000002C5fE54822a1eD058AE2F937Fd42769',
chain_infos: {
[chainA.id]: {
coin_balance: '0',
is_contract: false,
is_verified: false,
contract_name: undefined,
},
},
has_tokens: false,
has_interop_message_transfers: false,
coin_balance: '0',
exchange_rate: '123.456',
domains: [],
};
export const searchAddressesB: multichain.GetAddressResponse = {
hash: '0x00883b68A6EcF2ea3D47BD735E5125a0B7625B53',
chain_infos: {
[chainA.id]: {
coin_balance: '0',
is_contract: true,
is_verified: true,
contract_name: 'USDT',
},
},
has_tokens: false,
has_interop_message_transfers: false,
coin_balance: '0',
exchange_rate: '123.456',
domains: [],
};
export const searchTokenA: multichain.AggregatedTokenInfo = {
address_hash: '0x94b008aA00579c1307B0EF2c499aD98a8ce58e58',
circulating_market_cap: '162513452583.22',
decimals: '6',
holders_count: '1148927',
icon_url: undefined,
name: 'Tether USD',
symbol: 'USDT',
total_supply: '1000000',
type: 'ERC-20' as multichain.TokenType,
exchange_rate: '1.00',
chain_infos: {
[chainA.id]: {
holders_count: '1148927',
total_supply: '1000000',
is_verified: false,
contract_name: undefined,
},
},
};
+72
View File
@@ -0,0 +1,72 @@
import type * as multichain from '@blockscout/multichain-aggregator-types';
import type * as stats from '@blockscout/stats-types';
import { averageGasPrice } from 'mocks/stats/line';
import { chainA, chainB, chainC } from './chains';
export const mainPageStats: stats.MainPageMultichainStats = {
total_multichain_txns: {
id: 'totalMultichainTxns',
value: '741682908',
title: 'Total transactions',
units: undefined,
description: 'Number of transactions across all chains in the cluster',
},
total_multichain_addresses: {
id: 'totalMultichainAddresses',
value: '153519638',
title: 'Total addresses',
units: undefined,
description: 'Number of addresses across all chains in the cluster',
},
new_txns_multichain_window: {
chart: averageGasPrice.chart,
info: {
id: 'newTxnsMultichainWindow',
title: 'New transactions',
description: 'Number of new transactions across all chains in the cluster',
resolutions: [ 'DAY' ],
},
},
yesterday_txns_multichain: {
id: 'yesterdayTxnsMultichain',
value: '1026175',
title: 'Yesterday txns',
units: undefined,
description: 'Number of transactions yesterday (0:00 - 23:59 UTC) across all chains in the cluster',
},
};
export const chainMetrics: multichain.ListChainMetricsResponse = {
items: [
{
chain_id: chainA.id,
active_accounts: {
current_full_week: '1000',
previous_full_week: '900',
wow_diff_percent: '10',
},
},
{
chain_id: chainB.id,
active_accounts: undefined,
tps: '42',
new_addresses: {
current_full_week: '200',
previous_full_week: '210',
wow_diff_percent: '-10',
},
},
{
chain_id: chainC.id,
active_accounts: undefined,
tps: '3.21',
daily_transactions: {
current_full_week: '3343480',
previous_full_week: '3343480',
wow_diff_percent: '0',
},
},
],
};
+115
View File
@@ -0,0 +1,115 @@
import type { AddressTokenItem } from 'types/client/multichain-aggregator';
import { chainA, chainB, chainC } from './chains';
export const tokenAA: AddressTokenItem = {
token: {
address_hash: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0',
circulating_market_cap: '10.01',
decimals: '6',
holders_count: '1250',
icon_url: null,
name: 'USDT',
symbol: 'USDT',
total_supply: '284529257047384793',
type: 'ERC-20',
exchange_rate: '123.456',
chain_infos: {
[chainA.id]: {
holders_count: '1250',
total_supply: '184529257047384791',
is_verified: true,
contract_name: 'TestnetERC20',
},
},
reputation: null,
},
token_instance: null,
token_id: null,
value: '1038000000',
chain_values: {
[chainA.id]: '1038000000',
},
};
export const tokenAB: AddressTokenItem = {
token: {
address_hash: '0x0000000000000000000000000000000000000000',
circulating_market_cap: '274743626781.043515906',
decimals: '18',
holders_count: null,
icon_url: 'https://coin-images.coingecko.com/coins/images/279/small/ethereum.png?1696501628',
name: 'Ether',
symbol: 'ETH',
total_supply: null,
type: 'NATIVE',
exchange_rate: '2276.37',
chain_infos: {
[chainA.id]: {
holders_count: undefined,
total_supply: undefined,
is_verified: false,
contract_name: undefined,
},
},
reputation: null,
},
token_id: null,
value: '32115625288281011210',
chain_values: {
[chainA.id]: '32115625288281011210',
},
token_instance: null,
};
export const tokenBA: AddressTokenItem = {
...tokenAA,
token: {
...tokenAA.token,
exchange_rate: '420.42',
chain_infos: {
[chainB.id]: {
holders_count: '5021',
total_supply: '100000000000000002',
is_verified: true,
contract_name: 'TestnetERC20',
},
},
},
chain_values: {
[chainB.id]: '42042000',
},
value: '42042000',
};
export const tokenCA: AddressTokenItem = {
...tokenBA,
token: {
...tokenBA.token,
symbol: 'DUCK',
name: 'Duckie Duck Duck',
exchange_rate: '242.11',
chain_infos: {
[chainC.id]: {
holders_count: '1250',
total_supply: '100000000000000002',
is_verified: true,
contract_name: 'Duck Duck Goose',
},
},
},
value: '897000',
chain_values: {
[chainC.id]: '897000',
},
};
export const tokenDA: AddressTokenItem = {
...tokenCA,
token: {
...tokenCA.token,
symbol: 'GOOSE',
name: 'Mega Goose',
exchange_rate: null,
},
};
+1
View File
@@ -2,4 +2,5 @@ export { account } from './account';
export { default as colorTheme } from './colorTheme';
export { default as addressFormat } from './addressFormat';
export { default as scamTokens } from './scamTokens';
export { default as poorReputationTokens } from './poorReputationTokens';
export { default as appProfile } from './appProfile';
@@ -0,0 +1,14 @@
import type { NextRequest, NextResponse } from 'next/server';
import config from 'configs/app';
import * as cookiesLib from 'lib/cookies';
export default function poorReputationTokensMiddleware(req: NextRequest, res: NextResponse) {
if (config.features.opSuperchain.isEnabled) {
const showPoorReputationTokensCookie = req.cookies.get(cookiesLib.NAMES.SHOW_POOR_REPUTATION_TOKENS);
if (!showPoorReputationTokensCookie) {
res.cookies.set(cookiesLib.NAMES.SHOW_POOR_REPUTATION_TOKENS, 'false', { path: '/' });
}
}
}
+1
View File
@@ -56,6 +56,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/csv-export">
| StaticRoute<"/deposits">
| StaticRoute<"/dispute-games">
| StaticRoute<"/ecosystems">
| DynamicRoute<"/epochs/[number]", { "number": string }>
| StaticRoute<"/epochs">
| DynamicRoute<"/essential-dapps/[id]", { "id": string }>
+1 -1
View File
@@ -45,7 +45,7 @@
"dependencies": {
"@blockscout/bens-types": "1.4.1",
"@blockscout/interchain-indexer-types": "0.0.10",
"@blockscout/multichain-aggregator-types": "1.6.3-alpha.3",
"@blockscout/multichain-aggregator-types": "2.1.2",
"@blockscout/points-types": "1.4.0-alpha.1",
"@blockscout/stats-types": "2.11.1",
"@blockscout/tac-operation-lifecycle-types": "0.0.1-alpha.6",
+19
View File
@@ -0,0 +1,19 @@
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
const OpSuperchainEcosystems = dynamic(() => import('ui/optimismSuperchain/ecosystems/OpSuperchainEcosystems'), { ssr: false });
const Page: NextPage = () => {
return (
<PageNextJs pathname="/ecosystems">
<OpSuperchainEcosystems/>
</PageNextJs>
);
};
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps/main';
@@ -1,18 +1,18 @@
import type { TestFixture, Page } from '@playwright/test';
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
import * as chainDataMock from 'mocks/multichain/chains';
export type MockEssentialDappsChainsConfigFixture = () => Promise<void>;
const fixture: TestFixture<MockEssentialDappsChainsConfigFixture, { page: Page }> = async({ page }, use) => {
await use(async() => {
await page.evaluate(([ opSuperchainMock ]) => {
await page.evaluate(([ chainDataMock ]) => {
window.__essentialDappsChains = {
chains: [
opSuperchainMock.chainDataA,
chainDataMock.chainA,
],
};
}, [ opSuperchainMock ]);
}, [ chainDataMock ]);
});
};
+8 -6
View File
@@ -1,20 +1,22 @@
import type { TestFixture, Page } from '@playwright/test';
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
import * as chainDataMock from 'mocks/multichain/chains';
export type MockMultichainConfigFixture = () => Promise<void>;
const fixture: TestFixture<MockMultichainConfigFixture, { page: Page }> = async({ page }, use) => {
await use(async() => {
await page.evaluate(([ opSuperchainMock ]) => {
await page.evaluate(([ chainDataMock ]) => {
window.__multichainConfig = {
chains: [
opSuperchainMock.chainDataA,
opSuperchainMock.chainDataB,
opSuperchainMock.chainDataC,
chainDataMock.chainA,
chainDataMock.chainB,
chainDataMock.chainC,
chainDataMock.chainD,
chainDataMock.chainE,
],
};
}, [ opSuperchainMock ]);
}, [ chainDataMock ]);
});
};
+1
View File
@@ -113,6 +113,7 @@
| "navigation/custom_abi"
| "navigation/deposits"
| "navigation/dex_tracker"
| "navigation/ecosystems"
| "navigation/games"
| "navigation/gas_tracker"
| "navigation/hot_contracts"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

+33
View File
@@ -17,6 +17,19 @@ export const ADDRESS: multichain.GetAddressResponse = {
has_interop_message_transfers: false,
coin_balance: '1000000000000000000000000',
exchange_rate: '123.456',
domains: [],
};
export const ADDRESS_PORTFOLIO: multichain.GetAddressPortfolioResponse = {
portfolio: {
total_value: '370373.20',
chain_values: {
'420120000': '1000000000000000000000000',
'100': '9.58848343977132465600',
'130': '56.45557826318489510620',
'137': '54.3384235217602434',
},
},
};
export const TOKEN: AddressTokenItem = {
@@ -71,3 +84,23 @@ export const HOMEPAGE_STATS: stats.MainPageMultichainStats = {
description: 'Number of transactions yesterday (0:00 - 23:59 UTC) across all chains in the cluster',
},
};
export const CHAIN_METRICS: multichain.ChainMetrics = {
chain_id: '11155111',
tps: '5.32',
new_addresses: {
current_full_week: '16450',
previous_full_week: '156832',
wow_diff_percent: '-89.51',
},
daily_transactions: {
current_full_week: '3343480',
previous_full_week: '4228309',
wow_diff_percent: '-20.93',
},
active_accounts: {
current_full_week: '124564',
previous_full_week: '287092',
wow_diff_percent: '-56.61',
},
};
+8 -3
View File
@@ -65,15 +65,20 @@ interface CollapsibleListProps<T> extends FlexProps {
renderItem: (item: T, index: number) => React.ReactNode;
triggerProps?: LinkProps;
cutLength?: number;
text?: [string, string];
text?: [React.ReactNode, React.ReactNode];
defaultExpanded?: boolean;
}
export const CollapsibleList = <T,>(props: CollapsibleListProps<T>) => {
const CUT_LENGTH = 3;
const { items, renderItem, triggerProps, cutLength = CUT_LENGTH, text: textProp, ...rest } = props;
const { items, renderItem, triggerProps, cutLength = CUT_LENGTH, text: textProp, defaultExpanded = false, ...rest } = props;
const [ isExpanded, setIsExpanded ] = React.useState(false);
const [ isExpanded, setIsExpanded ] = React.useState(defaultExpanded);
React.useEffect(() => {
setIsExpanded(defaultExpanded);
}, [ defaultExpanded ]);
const handleToggle = React.useCallback(() => {
setIsExpanded((flag) => !flag);
+26 -5
View File
@@ -100,22 +100,43 @@ export const LinkBox = ChakraLinkBox;
export const LinkOverlay = React.forwardRef<HTMLAnchorElement, LinkProps>(
function LinkOverlay(props, ref) {
const { chakra, next } = splitProps(props);
const { children, external, href, ...rest } = chakra;
const { children, external, loading, href, noIcon, iconColor, ...rest } = chakra;
if (external) {
// Strip UTM parameters from external links if in private mode
const processedHref = typeof href === 'string' ? stripUtmParams(href) : href;
return (
<ChakraLinkOverlay ref={ ref } href={ processedHref } target="_blank" rel="noopener noreferrer" { ...rest }>
{ children }
<ChakraLinkOverlay
ref={ ref }
href={ loading ? undefined : processedHref }
target="_blank"
rel="noopener noreferrer"
{ ...rest }
>
{ (children || (!noIcon && href)) && (
<Skeleton display="inline-flex" alignItems="center" loading={ loading } maxW="100%" h="100%">
{ children }
{ !noIcon && href && <LinkExternalIcon color={ iconColor }/> }
</Skeleton>
) }
</ChakraLinkOverlay>
);
}
const content = children ? (
<Skeleton display="inline-flex" alignItems="center" loading={ loading } maxW="100%" h="100%">
{ children }
</Skeleton>
) : null;
return (
<ChakraLinkOverlay ref={ ref } asChild={ Boolean(next.href) } { ...rest }>
{ next.href ? <NextLink { ...next }>{ children }</NextLink> : children }
<ChakraLinkOverlay
ref={ ref }
asChild={ Boolean(next.href) }
{ ...rest }
>
{ next.href ? <NextLink { ...next }>{ content }</NextLink> : content }
</ChakraLinkOverlay>
);
},
+15 -6
View File
@@ -19,6 +19,7 @@ export interface SelectOption<Value extends string = string> {
renderLabel?: () => React.ReactNode;
value: Value;
icon?: React.ReactNode;
afterElement?: React.ReactNode;
};
export interface SelectControlProps extends ChakraSelect.ControlProps {
@@ -241,11 +242,13 @@ export interface SelectProps extends SelectRootProps {
loading?: boolean;
errorText?: string;
contentProps?: SelectContentProps;
contentHeader?: React.ReactNode;
itemFilter?: (item: SelectOption) => boolean;
mode?: ViewMode;
}
export const Select = React.forwardRef<HTMLDivElement, SelectProps>((props, ref) => {
const { collection, placeholder, portalled = true, loading, errorText, contentProps, mode, ...rest } = props;
const { collection, placeholder, portalled = true, loading, errorText, contentProps, contentHeader, itemFilter, mode, ...rest } = props;
return (
<SelectRoot
ref={ ref }
@@ -263,11 +266,17 @@ export const Select = React.forwardRef<HTMLDivElement, SelectProps>((props, ref)
/>
</SelectControl>
<SelectContent portalled={ portalled } { ...contentProps }>
{ collection.items.map((item: SelectOption) => (
<SelectItem item={ item } key={ item.value }>
{ item.renderLabel ? item.renderLabel() : item.label }
</SelectItem>
)) }
{ contentHeader }
{ collection.items
.filter(itemFilter ?? (() => true))
.map((item: SelectOption) => (
<React.Fragment key={ item.value }>
<SelectItem item={ item }>
{ item.renderLabel ? item.renderLabel() : item.label }
</SelectItem>
{ item.afterElement }
</React.Fragment>
)) }
</SelectContent>
</SelectRoot>
);
@@ -62,8 +62,7 @@ export const TruncatedTextTooltip = React.memo(({ children, label, placement, in
onMouseEnter?: () => void;
onMouseLeave?: () => void;
};
const handleClick = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
const handleClick = React.useCallback(() => {
onToggle();
}, [ onToggle ]);
+15 -1
View File
@@ -1,7 +1,21 @@
import { defineSlotRecipe } from '@chakra-ui/react';
export const recipe = defineSlotRecipe({
slots: [ 'root', 'trigger', 'indicatorGroup', 'indicator', 'content', 'item', 'control', 'itemText', 'itemGroup', 'itemGroupLabel', 'label', 'valueText' ],
slots: [
'root',
'trigger',
'indicatorGroup',
'indicator',
'content',
'item',
'control',
'itemText',
'itemGroup',
'itemGroupLabel',
'label',
'valueText',
'positioner',
],
base: {
root: {
display: 'flex',
+2 -2
View File
@@ -29,7 +29,7 @@ export const recipe = defineSlotRecipe({
},
valueText: {
verticalAlign: 'baseline',
fontWeight: 'semibold',
fontWeight: '500',
letterSpacing: 'normal',
fontFeatureSettings: 'initial',
fontVariantNumeric: 'initial',
@@ -95,7 +95,7 @@ export const recipe = defineSlotRecipe({
},
defaultVariants: {
size: 'md',
size: 'sm',
orientation: 'horizontal',
},
});
+1 -1
View File
@@ -20,7 +20,7 @@ const PRESETS = {
neon_devnet: 'https://neon-devnet.blockscout.com',
numine: 'https://numine.blockscout.com',
optimism: 'https://optimism.blockscout.com',
optimism_interop_0: 'https://optimism-interop-alpha-0.blockscout.com',
optimism_superchain: 'https://superchain.k8s-dev.blockscout.com',
optimism_sepolia: 'https://optimism-sepolia.blockscout.com',
polygon: 'https://polygon.blockscout.com',
rari_testnet: 'https://rari-testnet.cloud.blockscout.com',
+2 -5
View File
@@ -1,7 +1,5 @@
import { TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
import type { AddressParam } from './addressParams';
import type { TokenInfo } from './token';
import type { TokenInfo, TokenType } from './token';
export const ADVANCED_FILTER_ADDRESS_RELATION = [ 'or', 'and' ] as const;
export type AddressRelation = typeof ADVANCED_FILTER_ADDRESS_RELATION[number];
@@ -26,8 +24,7 @@ export type AdvancedFilterParams = {
token_contract_symbols_to_exclude?: Array<string>;
};
export const ADVANCED_FILTER_TYPES = [ 'coin_transfer', ...TOKEN_TYPE_IDS, 'contract_creation', 'contract_interaction' ] as const;
export type AdvancedFilterType = typeof ADVANCED_FILTER_TYPES[number];
export type AdvancedFilterType = 'coin_transfer' | 'contract_creation' | 'contract_interaction' | TokenType;
export const ADVANCED_FILTER_AGES = [ '1h', '24h', '7d', '1m', '3m', '6m' ] as const;
export type AdvancedFilterAge = typeof ADVANCED_FILTER_AGES[number];
+16
View File
@@ -32,6 +32,13 @@ export interface TokensResponse extends Omit<multichain.ListClusterTokensRespons
items: Array<AggregatedTokenInfo>;
}
export interface AddressTokensFilter {
type?: string;
chain_id?: string;
query?: string;
include_poor_reputation_tokens?: boolean;
}
// types for quick search results
export type QuickSearchResultBlock = {
type: 'block';
@@ -89,3 +96,12 @@ QuickSearchResultTransaction |
QuickSearchResultAddress |
QuickSearchResultToken |
QuickSearchResultDomain;
export interface ChainMetricsSorting {
sort: 'active_accounts' | 'daily_transactions' | 'new_addresses' | 'tps';
order: 'asc' | 'desc';
}
export type ChainMetricsSortingField = ChainMetricsSorting['sort'];
export type ChainMetricsSortingValue = `${ ChainMetricsSortingField }-${ ChainMetricsSorting['order'] }` | 'default';
+6 -2
View File
@@ -2,13 +2,13 @@ import { pickBy } from 'es-toolkit';
import React from 'react';
import type { AddressFromToFilter } from 'types/api/address';
import { ADVANCED_FILTER_TYPES } from 'types/api/advancedFilter';
import type { TokenType } from 'types/api/token';
import type { ClusterChainConfig } from 'types/multichain';
import config from 'configs/app';
import { useMultichainContext } from 'lib/contexts/multichain';
import useIsInitialLoading from 'lib/hooks/useIsInitialLoading';
import { getAdvancedFilterTypes } from 'ui/advancedFilter/constants';
import AdvancedFilterLink from 'ui/shared/links/AdvancedFilterLink';
interface Props {
@@ -29,10 +29,14 @@ const AddressAdvancedFilterLink = ({ isLoading, address, typeFilter, directionFi
return null;
}
const advancedFilterTypes = getAdvancedFilterTypes(chainConfig);
const queryParams = pickBy({
to_address_hashes_to_include: !directionFilter || directionFilter === 'to' ? [ address ] : undefined,
from_address_hashes_to_include: !directionFilter || directionFilter === 'from' ? [ address ] : undefined,
transaction_types: typeFilter.length > 0 ? typeFilter : ADVANCED_FILTER_TYPES.filter((type) => type !== 'coin_transfer'),
transaction_types: typeFilter.length > 0 ?
typeFilter :
advancedFilterTypes.filter((type) => type.id !== 'coin_transfer').map((type) => type.id),
}, (value) => value !== undefined);
const routeParams = (chainData ? { chain: chainData } : undefined) ?? multichainContext;
+1 -5
View File
@@ -52,7 +52,7 @@ const AddressContract = ({ addressData, isLoading = false, hasMudTab, ...rest }:
const contractTabs = useContractTabs({
addressData,
isEnabled: isQueryEnabled,
isEnabled: isQueryEnabled && !isLoading,
hasMudTab,
channel,
});
@@ -82,10 +82,6 @@ const AddressContract = ({ addressData, isLoading = false, hasMudTab, ...rest }:
useSocketMessage({ channel, event: 'smart_contract_was_verified', handler: handleContractWasVerifiedMessage });
useSocketMessage({ channel, event: 'smart_contract_was_not_verified', handler: handleContractWasNotVerifiedMessage });
if (isLoading) {
return null;
}
const rightSlot = autoVerificationStatus && !contractTabs.isPartiallyVerified ?
<ContractAutoVerificationStatus status={ autoVerificationStatus } mode={ isMobile && contractTabs.tabs.length > 1 ? 'tooltip' : 'inline' }/> :
null;
@@ -3,6 +3,7 @@ import React from 'react';
import type { TokenType } from 'types/api/token';
import { useMultichainContext } from 'lib/contexts/multichain';
import useIsMobile from 'lib/hooks/useIsMobile';
import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
@@ -31,6 +32,7 @@ interface Props {
const TokenTransfersLocal = ({ query, filters, addressHash, onTypeFilterChange, onAddressFilterChange, overloadCount }: Props) => {
const { isError, isPlaceholderData, data, pagination } = query;
const isMobile = useIsMobile();
const multichainContext = useMultichainContext();
const { showSocketAlert, newItemsCount } = useAddressTokenTransfersSocket({
filters,
@@ -88,6 +90,7 @@ const TokenTransfersLocal = ({ query, filters, addressHash, onTypeFilterChange,
onAddressFilterChange={ onAddressFilterChange }
defaultAddressFilter={ filters.filter }
isLoading={ query.isPlaceholderData }
chainConfig={ multichainContext?.chain?.app_config }
/>
<AddressAdvancedFilterLink
isLoading={ isPlaceholderData }
@@ -58,7 +58,7 @@ const Address3rdPartyWidgetCard = ({ name, config, address, isLoading }: Props)
</>
) : (
<>
<LinkOverlay href={ url } external onClick={ handleClick }/>
<LinkOverlay href={ url } external onClick={ handleClick } noIcon/>
<Skeleton loading={ isDataLoading } minW="88px" alignSelf="flex-start">
{ data ? (
<Text
+23
View File
@@ -1,3 +1,4 @@
import { Grid } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import type { Channel } from 'phoenix';
@@ -9,6 +10,7 @@ import type { SmartContract } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import { useMultichainContext } from 'lib/contexts/multichain';
import getQueryParamString from 'lib/router/getQueryParamString';
import * as stubs from 'stubs/contract';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
@@ -17,6 +19,8 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractDetailsAlerts from './alerts/ContractDetailsAlerts';
import ContractSourceAddressSelector from './ContractSourceAddressSelector';
import ContractDetailsInfo from './info/ContractDetailsInfo';
import ContractDetailsInfoCreator from './info/ContractDetailsInfoCreator';
import ContractDetailsInfoImplementations from './info/ContractDetailsInfoImplementations';
import useContractDetailsTabs from './useContractDetailsTabs';
const TAB_LIST_PROPS = { flexWrap: 'wrap', rowGap: 2 };
@@ -31,6 +35,7 @@ type Props = {
const ContractDetails = ({ addressData, channel, mainContractQuery }: Props) => {
const router = useRouter();
const sourceAddress = getQueryParamString(router.query.source_address);
const multichainContext = useMultichainContext();
const sourceItems: Array<AddressImplementation> = React.useMemo(() => {
const currentAddressDefaultName = addressData?.proxy_type === 'eip7702' ? 'Current address' : 'Current contract';
@@ -95,6 +100,24 @@ const ContractDetails = ({ addressData, channel, mainContractQuery }: Props) =>
addressData={ addressData }
/>
) }
{ !mainContractQuery.data?.is_verified && multichainContext && (
<Grid templateColumns={{ base: 'auto 1fr', lg: 'auto 1fr auto 1fr' }} rowGap={ 4 } columnGap={ 3 } mb={ 8 } _empty={{ display: 'none' }}>
{ addressData.creator_address_hash && addressData.creation_transaction_hash && (
<ContractDetailsInfoCreator
addressHash={ addressData.creator_address_hash }
txHash={ addressData.creation_transaction_hash }
creationStatus={ addressData.creation_status }
isLoading={ mainContractQuery.isPlaceholderData }
/>
) }
{ addressData.implementations && addressData.implementations.length > 0 && !mainContractQuery.isPlaceholderData && (
<ContractDetailsInfoImplementations
implementations={ addressData.implementations }
proxyType={ addressData.proxy_type }
/>
) }
</Grid>
) }
<RoutedTabs
tabs={ tabs }
isLoading={ isPlaceholderData }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 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: 48 KiB

After

Width:  |  Height:  |  Size: 49 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: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

@@ -1,4 +1,4 @@
import { Flex, Grid, Text } from '@chakra-ui/react';
import { Flex, Grid } from '@chakra-ui/react';
import React from 'react';
import type { Address } from 'types/api/address';
@@ -9,14 +9,12 @@ import { useMultichainContext } from 'lib/contexts/multichain';
import { CONTRACT_LICENSES } from 'lib/contracts/licenses';
import { Link } from 'toolkit/chakra/link';
import { getGitHubOwnerAndRepo } from 'ui/contractVerification/utils';
import ContainerWithScrollY from 'ui/shared/ContainerWithScrollY';
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ContractCreationStatus from 'ui/shared/statusTag/ContractCreationStatus';
import Time from 'ui/shared/time/Time';
import ContractSecurityAudits from '../audits/ContractSecurityAudits';
import ContractDetailsInfoCreator from './ContractDetailsInfoCreator';
import ContractDetailsInfoImplementations from './ContractDetailsInfoImplementations';
import ContractDetailsInfoItem from './ContractDetailsInfoItem';
const rollupFeature = config.features.rollup;
@@ -74,7 +72,21 @@ const ContractDetailsInfo = ({ data, isLoading, addressData }: Props) => {
const isStylusContract = data.language === 'stylus_rust';
return (
<Grid templateColumns={{ base: '1fr', lg: '1fr 1fr' }} rowGap={ 4 } columnGap={ 6 } mb={ 8 }>
<Grid templateColumns={{ base: 'auto 1fr', lg: 'auto 1fr auto 1fr' }} rowGap={ 4 } columnGap={ 3 } mb={ 8 }>
{ addressData.creator_address_hash && addressData.creation_transaction_hash && multichainContext && (
<ContractDetailsInfoCreator
addressHash={ addressData.creator_address_hash }
txHash={ addressData.creation_transaction_hash }
creationStatus={ addressData.creation_status }
isLoading={ isLoading }
/>
) }
{ addressData.implementations && addressData.implementations.length > 0 && multichainContext && !isLoading && (
<ContractDetailsInfoImplementations
implementations={ addressData.implementations }
proxyType={ addressData.proxy_type }
/>
) }
{ data.name && (
<ContractDetailsInfoItem
label="Contract name"
@@ -83,46 +95,6 @@ const ContractDetailsInfo = ({ data, isLoading, addressData }: Props) => {
{ contractNameWithCertifiedIcon }
</ContractDetailsInfoItem>
) }
{ multichainContext && addressData.creator_address_hash && addressData.creation_transaction_hash && (
<ContractDetailsInfoItem
label="Creator"
isLoading={ isLoading }
>
<Flex alignItems="center" flexWrap="wrap">
<AddressEntity
address={{ hash: addressData.creator_address_hash }}
truncation="constant"
noIcon
/>
<Text whiteSpace="pre" color="text.secondary"> at txn </Text>
<TxEntity hash={ addressData.creation_transaction_hash } truncation="constant" noIcon/>
{ addressData.creation_status && <ContractCreationStatus status={ addressData.creation_status } ml={{ base: 0, lg: 2 }}/> }
</Flex>
</ContractDetailsInfoItem>
) }
{ !isLoading && multichainContext && addressData.implementations && addressData.implementations.length > 0 && (
<ContractDetailsInfoItem
label={ `${ addressData.proxy_type === 'eip7702' ? 'Delegated to' : `Implementation${ addressData.implementations.length > 1 ? 's' : '' }` }` }
isLoading={ isLoading }
contentProps={{ maxW: 'calc(100% - 194px)', position: 'relative' }}
>
<ContainerWithScrollY gradientHeight={ 48 } maxH="200px">
{ addressData.implementations.map((item) => (
<AddressEntity
key={ item.address_hash }
address={{
hash: item.address_hash,
filecoin: { robust: item.filecoin_robust_address },
name: item.name,
is_contract: true,
}}
isLoading={ isLoading }
noIcon
/>
)) }
</ContainerWithScrollY>
</ContractDetailsInfoItem>
) }
{ data.compiler_version && (
<ContractDetailsInfoItem
label="Compiler version"
@@ -0,0 +1,40 @@
import { Text, Flex } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractCreationStatus } from 'types/api/contract';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ContractCreationStatus from 'ui/shared/statusTag/ContractCreationStatus';
import ContractDetailsInfoItem from './ContractDetailsInfoItem';
interface Props {
addressHash: string;
txHash: string;
creationStatus: SmartContractCreationStatus | null;
isLoading: boolean;
}
const ContractDetailsInfoCreator = ({ addressHash, txHash, creationStatus, isLoading }: Props) => {
return (
<ContractDetailsInfoItem
label="Creator"
isLoading={ isLoading }
contentProps={{ gridColumn: { lg: '2 / span 3' } }}
>
<Flex alignItems="center" flexWrap="wrap" columnGap={ 2 } rowGap={ 2 }>
<AddressEntity
address={{ hash: addressHash }}
truncation="constant"
noIcon
/>
<Text whiteSpace="pre" color="text.secondary">at txn</Text>
<TxEntity hash={ txHash } truncation="constant" noIcon/>
{ creationStatus && <ContractCreationStatus status={ creationStatus }/> }
</Flex>
</ContractDetailsInfoItem>
);
};
export default React.memo(ContractDetailsInfoCreator);
@@ -0,0 +1,40 @@
import React from 'react';
import type { AddressImplementation } from 'types/api/addressParams';
import type { SmartContractProxyType } from 'types/api/contract';
import ContainerWithScrollY from 'ui/shared/ContainerWithScrollY';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import ContractDetailsInfoItem from './ContractDetailsInfoItem';
interface Props {
implementations: Array<AddressImplementation>;
proxyType?: SmartContractProxyType;
}
const ContractDetailsInfoImplementations = ({ implementations, proxyType }: Props) => {
return (
<ContractDetailsInfoItem
label={ `${ proxyType === 'eip7702' ? 'Delegated to' : `Implementation${ implementations.length > 1 ? 's' : '' }` }` }
contentProps={{ gridColumn: { lg: '2 / span 3' }, position: 'relative' }}
>
<ContainerWithScrollY gradientHeight={ 48 } maxH="200px" w="100%">
{ implementations.map((item) => (
<AddressEntity
key={ item.address_hash }
address={{
hash: item.address_hash,
filecoin: { robust: item.filecoin_robust_address },
name: item.name,
is_contract: true,
}}
noIcon
/>
)) }
</ContainerWithScrollY>
</ContractDetailsInfoItem>
);
};
export default React.memo(ContractDetailsInfoImplementations);
@@ -1,5 +1,5 @@
import type { BoxProps } from '@chakra-ui/react';
import { chakra, Flex, GridItem } from '@chakra-ui/react';
import { chakra, Flex } from '@chakra-ui/react';
import React from 'react';
import { Skeleton } from 'toolkit/chakra/skeleton';
@@ -8,23 +8,22 @@ import { Hint } from 'toolkit/components/Hint/Hint';
interface Props {
label: string;
children: React.ReactNode;
className?: string;
isLoading: boolean;
isLoading?: boolean;
hint?: string;
contentProps?: BoxProps;
}
const ContractDetailsInfoItem = ({ label, children, className, isLoading, hint, contentProps }: Props) => {
const ContractDetailsInfoItem = ({ label, children, isLoading, hint, contentProps }: Props) => {
return (
<GridItem display="flex" columnGap={ 6 } wordBreak="break-all" className={ className } alignItems="baseline" maxW="100%" overflow="hidden">
<Skeleton loading={ isLoading } w="170px" flexShrink={ 0 } fontWeight={ 500 }>
<>
<Skeleton loading={ isLoading } flexShrink={ 0 } fontWeight={ 500 }>
<Flex alignItems="center">
{ label }
{ hint && <Hint label={ hint } ml={ 2 }/> }
</Flex>
</Skeleton>
<Skeleton loading={ isLoading } { ...contentProps }>{ children }</Skeleton>
</GridItem>
<Skeleton loading={ isLoading } wordBreak="break-all" maxW="100%" overflow="hidden" { ...contentProps }>{ children }</Skeleton>
</>
);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+17 -2
View File
@@ -42,7 +42,7 @@ export default function useContractTabs({ addressData, isEnabled, hasMudTab, cha
const contractQuery = useApiQuery('general:contract', {
pathParams: { hash: addressData?.hash },
queryOptions: {
enabled: isEnabled,
enabled: isEnabled && addressData?.is_contract,
refetchOnMount: false,
placeholderData: addressData?.is_verified ? stubs.CONTRACT_CODE_VERIFIED : stubs.CONTRACT_CODE_UNVERIFIED,
},
@@ -52,7 +52,7 @@ export default function useContractTabs({ addressData, isEnabled, hasMudTab, cha
const mudSystemsQuery = useApiQuery('general:mud_systems', {
pathParams: { hash: addressData?.hash },
queryOptions: {
enabled: Boolean(isEnabled && hasMudTab),
enabled: Boolean(isEnabled && hasMudTab && addressData?.is_contract),
refetchOnMount: false,
placeholderData: stubs.MUD_SYSTEMS,
},
@@ -63,6 +63,21 @@ export default function useContractTabs({ addressData, isEnabled, hasMudTab, cha
}, [ addressData?.hash, addressData?.implementations ]);
return React.useMemo(() => {
if (!addressData?.is_contract) {
return {
tabs: [
{
id: 'contract_code' as const,
title: 'Code',
component: <p>This address is not a contract on this chain.</p>,
},
],
isLoading: false,
isPartiallyVerified: undefined,
};
}
return {
tabs: [
addressData && {
@@ -18,8 +18,8 @@ type Props = {
item: MultichainProviderConfigParsed;
addressHash: string;
onClick?: () => void;
isFirst: boolean;
isLast: boolean;
isFirst?: boolean;
isLast?: boolean;
};
const AddressMultichainButton = ({ item, addressHash, onClick, isFirst, isLast }: Props) => {
+1 -1
View File
@@ -136,7 +136,7 @@ const AddressEnsDomains = ({ query, addressHash, mainDomainName }: Props) => {
</Link>
) }
{ mainDomain && (
<chakra.span fontSize="xs" mt={ -1 }>
<chakra.span textStyle="xs" mt={ -1 }>
*A domain name is not necessarily held by a person popularly associated with the name
</chakra.span>
) }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+4 -1
View File
@@ -21,9 +21,10 @@ interface Props {
enabled?: boolean;
addressHash: string;
isMultichain?: boolean;
chainIds?: Array<string>;
}
export default function useAddressNftQuery({ scrollRef, enabled = true, addressHash, isMultichain }: Props) {
export default function useAddressNftQuery({ scrollRef, enabled = true, addressHash, isMultichain, chainIds }: Props) {
const router = useRouter();
const displayTypeCookie = cookies.get(cookies.NAMES.ADDRESS_NFT_DISPLAY_TYPE, useAppContext().cookies);
@@ -40,6 +41,7 @@ export default function useAddressNftQuery({ scrollRef, enabled = true, addressH
},
filters: { type: tokenTypes },
isMultichain,
chainIds,
});
const nftsQuery = useQueryWithPages({
@@ -52,6 +54,7 @@ export default function useAddressNftQuery({ scrollRef, enabled = true, addressH
},
filters: { type: tokenTypes },
isMultichain,
chainIds,
});
const onDisplayTypeChange = React.useCallback((val: string) => {
+28 -8
View File
@@ -5,9 +5,11 @@ import type { AddressFromToFilter } from 'types/api/address';
import { AddressFromToFilterValues } from 'types/api/address';
import type { TokenType } from 'types/api/token';
import multichainConfig from 'configs/multichain';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import { TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
import getQueryParamString from 'lib/router/getQueryParamString';
import { getTokenTypes } from 'lib/token/tokenTypes';
import { getTokenTransfersStub } from 'stubs/token';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
@@ -16,9 +18,25 @@ export type Filters = {
filter: AddressFromToFilter | undefined;
};
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPE_IDS);
const getAddressFilterValue = (getFilterValueFromQuery<AddressFromToFilter>).bind(null, AddressFromToFilterValues);
const getFilters = (query: Record<string, string | Array<string> | undefined>, chainIds?: Array<string>) => {
const chainIdParam = getQueryParamString(query.chain_id);
const filterParam = getQueryParamString(query.filter);
const typeParam = getQueryParamString(query.type);
const currentChainId = chainIdParam && chainIds?.includes(chainIdParam) ? chainIdParam : chainIds?.[0];
const chainConfig = multichainConfig()?.chains.find(chain => chain.id === currentChainId);
return {
filter: getAddressFilterValue(filterParam),
type: getFilterValuesFromQuery(
Object.keys(getTokenTypes(false, chainConfig?.app_config)),
typeParam,
) || [],
};
};
interface Props {
currentAddress: string;
enabled?: boolean;
@@ -29,12 +47,14 @@ interface Props {
export default function useAddressTokenTransfersQuery({ currentAddress, enabled, isMultichain, chainIds }: Props) {
const router = useRouter();
const [ filters, setFilters ] = React.useState<Filters>(
{
type: getTokenFilterValue(router.query.type) || [],
filter: getAddressFilterValue(router.query.filter),
},
);
const [ filters, setFilters ] = React.useState<Filters>(getFilters(router.query, chainIds));
React.useEffect(() => {
if (enabled) {
setFilters(getFilters(router.query, chainIds));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ enabled ]);
const query = useQueryWithPages({
resourceName: 'general:address_token_transfers',
-14
View File
@@ -1,5 +1,4 @@
import BigNumber from 'bignumber.js';
import { mapValues } from 'es-toolkit';
import type { AddressTokenBalance } from 'types/api/address';
@@ -143,17 +142,4 @@ export const getTokensTotalInfo = (data: TokenSelectData): TokensTotalInfo => {
return { usd, num, isOverflow };
};
export const getTokensTotalInfoByChain = (data: TokenSelectData, chainIds: Array<string>) => {
return chainIds.reduce((result, chainId) => {
const filteredData = mapValues(data, (item) => ({
...item,
items: item.items.filter((item) => item.chain_values?.[chainId]),
}));
result[chainId] = getTokensTotalInfo(filteredData);
return result;
}, {} as Record<string, TokensTotalInfo>);
};
const usdValueReducer = (result: BigNumber, item: TokenEnhancedData) => !item.usd ? result : result.plus(BigNumber(item.usd));
+5 -3
View File
@@ -2,6 +2,7 @@ import { Flex } from '@chakra-ui/react';
import React from 'react';
import type { AdvancedFilterResponseItem } from 'types/api/advancedFilter';
import type { ClusterChainConfig } from 'types/multichain';
import config from 'configs/app';
import { isConfidentialTokenType } from 'lib/token/tokenTypes';
@@ -17,20 +18,21 @@ import AssetValue from 'ui/shared/value/AssetValue';
import ConfidentialValue from 'ui/shared/value/ConfidentialValue';
import NativeCoinValue from 'ui/shared/value/NativeCoinValue';
import { ADVANCED_FILTER_TYPES } from './constants';
import { getAdvancedFilterTypes } from './constants';
type Props = {
item: AdvancedFilterResponseItem;
column: ColumnsIds;
isLoading?: boolean;
chainConfig?: ClusterChainConfig['app_config'];
};
const ItemByColumn = ({ item, column, isLoading }: Props) => {
const ItemByColumn = ({ item, column, isLoading, chainConfig }: Props) => {
switch (column) {
case 'tx_hash':
return <TxEntity truncation="constant" hash={ item.hash } isLoading={ isLoading } noIcon fontWeight={ 700 }/>;
case 'type': {
const type = ADVANCED_FILTER_TYPES.find(t => t.id === item.type);
const type = getAdvancedFilterTypes(chainConfig).find(t => t.id === item.type);
if (!type) {
return null;
}
+27 -27
View File
@@ -1,7 +1,8 @@
import type { TokenInfo } from 'types/api/token';
import type { ClusterChainConfig } from 'types/multichain';
import config from 'configs/app';
import { TOKEN_TYPE_IDS, TOKEN_TYPES } from 'lib/token/tokenTypes';
import { getTokenTypes } from 'lib/token/tokenTypes';
export type ColumnsIds = 'tx_hash' | 'type' | 'method' | 'age' | 'from' | 'or_and' | 'to' | 'amount' | 'asset' | 'fee';
@@ -67,32 +68,31 @@ export const TABLE_COLUMNS: Array<TxTableColumn> = [
},
] as const;
export const ADVANCED_FILTER_TYPES = [
{
id: 'coin_transfer',
name: 'Coin Transfer',
},
...TOKEN_TYPE_IDS.map(id => ({
id,
name: `${ TOKEN_TYPES[id] } Transfer`,
})),
{
id: 'contract_creation',
name: 'Contract Creation',
},
{
id: 'contract_interaction',
name: 'Contract Interaction',
},
] as const;
export const ADVANCED_FILTER_TYPES_WITH_ALL = [
{
id: 'all',
name: 'All',
},
...ADVANCED_FILTER_TYPES,
];
export const getAdvancedFilterTypes = (chainConfig?: Array<ClusterChainConfig['app_config']> | ClusterChainConfig['app_config'], withAll = false) => {
return [
...(withAll ? [ {
id: 'all',
name: 'All',
} ] : []),
{
id: 'coin_transfer',
name: 'Coin Transfer',
},
...Object.entries(getTokenTypes(false, chainConfig))
.map(([ id, name ]) => ({
id,
name: `${ name } Transfer`,
})),
{
id: 'contract_creation',
name: 'Contract Creation',
},
{
id: 'contract_interaction',
name: 'Contract Interaction',
},
];
};
export const NATIVE_TOKEN = {
name: config.chain.currency.name || '',
+9 -2
View File
@@ -4,10 +4,11 @@ import React from 'react';
import type { AdvancedFilterParams, AdvancedFilterType } from 'types/api/advancedFilter';
import { useMultichainContext } from 'lib/contexts/multichain';
import { Checkbox, CheckboxGroup } from 'toolkit/chakra/checkbox';
import TableColumnFilter from 'ui/shared/filters/TableColumnFilter';
import { ADVANCED_FILTER_TYPES_WITH_ALL } from '../constants';
import { getAdvancedFilterTypes } from '../constants';
const RESET_VALUE = 'all';
@@ -21,6 +22,8 @@ type Props = {
const TypeFilter = ({ value = [ RESET_VALUE ], handleFilterChange }: Props) => {
const [ currentValue, setCurrentValue ] = React.useState<Array<AdvancedFilterType | typeof RESET_VALUE>>([ ...value ]);
const multichainContext = useMultichainContext();
const handleChange = React.useCallback((value: Array<string>) => {
setCurrentValue((prev) => {
if (value.length === 0) {
@@ -43,6 +46,10 @@ const TypeFilter = ({ value = [ RESET_VALUE ], handleFilterChange }: Props) => {
handleFilterChange(FILTER_PARAM, value);
}, [ handleFilterChange, currentValue ]);
const advancedFilterTypes = React.useMemo(() => {
return getAdvancedFilterTypes(multichainContext?.chain?.app_config, true);
}, [ multichainContext?.chain?.app_config ]);
return (
<TableColumnFilter
title="Transaction type"
@@ -54,7 +61,7 @@ const TypeFilter = ({ value = [ RESET_VALUE ], handleFilterChange }: Props) => {
>
<Flex display="flex" flexDir="column" rowGap={ 3 }>
<CheckboxGroup value={ currentValue } onValueChange={ handleChange } orientation="vertical">
{ ADVANCED_FILTER_TYPES_WITH_ALL.map(type => (
{ advancedFilterTypes.map(type => (
<Checkbox
key={ type.id }
value={ type.id }
+5 -3
View File
@@ -1,11 +1,12 @@
import { castArray } from 'es-toolkit/compat';
import type { AdvancedFilterAge, AdvancedFilterParams } from 'types/api/advancedFilter';
import type { ClusterChainConfig } from 'types/multichain';
import dayjs from 'lib/date/dayjs';
import { HOUR, DAY, MONTH } from 'toolkit/utils/consts';
import { ADVANCED_FILTER_TYPES } from './constants';
import { getAdvancedFilterTypes } from './constants';
export function getDurationFromAge(age: AdvancedFilterAge) {
switch (age) {
@@ -55,7 +56,7 @@ const filterParamNames: Record<keyof AdvancedFilterParams, string> = {
transaction_types: 'Type',
};
export function getFilterTags(filters: AdvancedFilterParams) {
export function getFilterTags(filters: AdvancedFilterParams, chainConfig?: ClusterChainConfig['app_config']) {
const filtersToShow = { ...filters };
if (filtersToShow.age) {
filtersToShow.age_from = undefined;
@@ -77,7 +78,8 @@ export function getFilterTags(filters: AdvancedFilterParams) {
break;
}
case 'transaction_types': {
valueStr = castArray(value).map(i => ADVANCED_FILTER_TYPES.find(t => t.id === i)?.name).filter(Boolean).join(', ');
const advancedFilterTypes = getAdvancedFilterTypes(chainConfig);
valueStr = castArray(value).map(i => advancedFilterTypes.find(t => t.id === i)?.name).filter(Boolean).join(', ');
break;
}
case 'token_contract_address_hashes_to_exclude': {
+1 -2
View File
@@ -2,7 +2,6 @@ import type { TimeChartData, TimeChartDataItem, TimeChartItemRaw, TimeChartItem
import type { ChainIndicatorId } from 'types/homepage';
import config from 'configs/app';
import getCurrencySymbol from 'lib/multichain/getCurrencySymbol';
import { sortByDateDesc } from 'ui/shared/chart/utils';
const CHART_ITEMS: Record<ChainIndicatorId, Pick<TimeChartDataItem, 'name' | 'valueFormatter'>> = {
@@ -15,7 +14,7 @@ const CHART_ITEMS: Record<ChainIndicatorId, Pick<TimeChartDataItem, 'name' | 'va
valueFormatter: (x: number) => x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }),
},
coin_price: {
name: `${ getCurrencySymbol() || config.chain.currency.symbol } price`,
name: `${ config.features.opSuperchain.isEnabled ? 'ETH' : config.chain.currency.symbol } price`,
valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }),
},
secondary_coin_price: {
@@ -27,6 +27,7 @@ const MarketplaceAppCardLink = ({ url, external, id, title, onClick, className }
className={ className }
external={ external }
onClick={ handleClick }
noIcon
>
{ title }
</LinkOverlay>
@@ -1,12 +1,12 @@
import React from 'react';
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
import * as chainDataMock from 'mocks/multichain/chains';
import { test, expect } from 'playwright/lib';
import Revoke from './Revoke';
const ESSENTIAL_DAPPS_CONFIG = JSON.stringify({
revoke: { chains: [ opSuperchainMock.chainDataA.id ] },
revoke: { chains: [ chainDataMock.chainA.id ] },
});
test('base view +@dark-mode +@mobile', async({ render, mockEnvs, mockEssentialDappsChainsConfig, mockAssetResponse }) => {
@@ -15,7 +15,7 @@ test('base view +@dark-mode +@mobile', async({ render, mockEnvs, mockEssentialDa
[ 'NEXT_PUBLIC_MARKETPLACE_ESSENTIAL_DAPPS_CONFIG', ESSENTIAL_DAPPS_CONFIG ],
]);
await mockEssentialDappsChainsConfig();
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(chainDataMock.chainA.logo as string, './playwright/mocks/image_s.jpg');
const component = await render(<Revoke/>);
@@ -2,7 +2,7 @@ import React from 'react';
import { hash as addressHash } from 'mocks/address/address';
import * as revokeMock from 'mocks/essentialDapps/revoke';
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
import * as chainDataMock from 'mocks/multichain/chains';
import type { TestFnArgs } from 'playwright/lib';
import { test, expect } from 'playwright/lib';
@@ -11,7 +11,7 @@ import Content from './Content';
test('base view +@dark-mode +@mobile', async({ render, mockAssetResponse }: TestFnArgs) => {
const props = {
selectedChain: opSuperchainMock.chainDataA,
selectedChain: chainDataMock.chainA,
searchAddress: addressHash,
isAddressMatch: false,
coinBalanceQuery: {
@@ -23,7 +23,7 @@ test('base view +@dark-mode +@mobile', async({ render, mockAssetResponse }: Test
};
await mockAssetResponse(revokeMock.allowances[0].tokenIcon as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_md.jpg');
await mockAssetResponse(chainDataMock.chainA.logo as string, './playwright/mocks/image_md.jpg');
const component = await render(<Content { ...props }/>);
@@ -1,13 +1,16 @@
import React from 'react';
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
import * as addressMock from 'mocks/multichain/address';
import * as chainDataMock from 'mocks/multichain/chains';
import * as portfolioMock from 'mocks/multichain/portfolio';
import * as tokensMock from 'mocks/multichain/tokens';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config';
import OpSuperchainAddress from './OpSuperchainAddress';
const CURRENT_ADDRESS = opSuperchainMock.addressA.hash;
const CURRENT_ADDRESS = addressMock.addressA.hash;
const hooksConfig = {
router: {
@@ -21,26 +24,21 @@ test('base view', async({ mockApiResponse, render, page, mockMultichainConfig, m
await mockEnvs(ENVS_MAP.opSuperchain);
await mockTextAd();
await mockApiResponse('multichainAggregator:address', opSuperchainMock.addressA, { pathParams: { hash: CURRENT_ADDRESS } });
await mockApiResponse('multichainAggregator:address', addressMock.addressA, { pathParams: { hash: CURRENT_ADDRESS } });
await mockApiResponse('multichainAggregator:address_domains', addressMock.addressDomainsA, { pathParams: { hash: CURRENT_ADDRESS } });
await mockApiResponse('multichainAggregator:address_portfolio', portfolioMock.base, {
pathParams: { hash: CURRENT_ADDRESS },
queryParams: { include_poor_reputation_tokens: false },
});
await mockApiResponse('multichainAggregator:address_tokens', {
items: [ opSuperchainMock.tokenA ],
next_page_params: undefined,
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-20' } });
await mockApiResponse('multichainAggregator:address_tokens', {
items: [ ],
next_page_params: undefined,
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-721' } });
await mockApiResponse('multichainAggregator:address_tokens', {
items: [ ],
next_page_params: undefined,
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-1155' } });
await mockApiResponse('multichainAggregator:address_tokens', {
items: [ ],
next_page_params: undefined,
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-404' } });
items: [ tokensMock.tokenAA, tokensMock.tokenAB, tokensMock.tokenBA, tokensMock.tokenCA, tokensMock.tokenDA ],
next_page_params: { page_token: '1', page_size: 10 },
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-20,NATIVE', include_poor_reputation_tokens: false } });
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(opSuperchainMock.chainDataB.logo as string, './playwright/mocks/image_md.jpg');
await mockAssetResponse(chainDataMock.chainA.logo as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(chainDataMock.chainB.logo as string, './playwright/mocks/image_md.jpg');
await mockAssetResponse(addressMock.domainProtocols[0].icon_url as string, './playwright/mocks/goose.png');
await mockAssetResponse(addressMock.domainProtocols[1].icon_url as string, './playwright/mocks/duck.png');
const component = await render(
<OpSuperchainAddress/>,
@@ -51,10 +49,4 @@ test('base view', async({ mockApiResponse, render, page, mockMultichainConfig, m
mask: [ page.locator(pwConfig.adsBannerSelector) ],
maskColor: pwConfig.maskColor,
});
await component.getByText('By chain').nth(0).click();
await expect(page.locator('div[data-scope="popover"][data-part="content"]').nth(0)).toHaveScreenshot();
await component.getByText('By chain').nth(1).click();
await expect(page.locator('div[data-scope="popover"][data-part="content"]').nth(0)).toHaveScreenshot();
});
@@ -16,17 +16,20 @@ import AddressQrCode from 'ui/address/details/AddressQrCode';
import ClusterChainsPopover from 'ui/optimismSuperchain/components/ClusterChainsPopover';
import TextAd from 'ui/shared/ad/TextAd';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
import OpSuperchainAddressEnsDomains from './header/OpSuperchainAddressEnsDomains';
import OpSuperchainAddressCoinBalanceHistory from './OpSuperchainAddressCoinBalanceHistory';
import OpSuperchainAddressContract from './OpSuperchainAddressContract';
import OpSuperchainAddressDetails from './OpSuperchainAddressDetails';
import OpSuperchainAddressInternalTxs from './OpSuperchainAddressInternalTxs';
import OpSuperchainAddressLogs from './OpSuperchainAddressLogs';
import OpSuperchainAddressTokens, { ADDRESS_OP_SUPERCHAIN_TOKENS_TAB_IDS } from './OpSuperchainAddressTokens';
import OpSuperchainAddressPortfolio from './OpSuperchainAddressPortfolio';
import OpSuperchainAddressTokenTransfers, { ADDRESS_OP_SUPERCHAIN_TOKEN_TRANSFERS_TAB_IDS } from './OpSuperchainAddressTokenTransfers';
import OpSuperchainAddressTxs, { ADDRESS_OP_SUPERCHAIN_TXS_TAB_IDS } from './OpSuperchainAddressTxs';
const TABS_PRESERVED_PARAMS = [ 'chain_id' ];
const OpSuperchainAddress = () => {
const router = useRouter();
@@ -57,9 +60,9 @@ const OpSuperchainAddress = () => {
const tabs: Array<TabItemRegular> = React.useMemo(() => {
return [
{
id: 'index',
title: 'Details',
component: <OpSuperchainAddressDetails addressHash={ checkSummedHash } data={ addressQuery.data } isLoading={ isLoading }/>,
id: 'portfolio',
title: 'Portfolio',
component: <OpSuperchainAddressPortfolio addressData={ addressQuery.data } isLoading={ isLoading }/>,
},
isContractSomewhere && {
id: 'contract',
@@ -79,12 +82,6 @@ const OpSuperchainAddress = () => {
component: <OpSuperchainAddressTokenTransfers addressData={ addressQuery.data } isLoading={ isLoading }/>,
subTabs: ADDRESS_OP_SUPERCHAIN_TOKEN_TRANSFERS_TAB_IDS,
},
addressQuery.data?.has_tokens && {
id: 'tokens',
title: 'Tokens',
component: isLoading ? null : <OpSuperchainAddressTokens addressData={ addressQuery.data }/>,
subTabs: ADDRESS_OP_SUPERCHAIN_TOKENS_TAB_IDS,
},
{
id: 'internal_txs',
title: 'Internal txns',
@@ -105,6 +102,17 @@ const OpSuperchainAddress = () => {
const titleSecondRow = (
<Flex alignItems="center" w="100%" columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
{ addressQuery.data?.domains?.[0] && (
<EnsEntity
domain={ addressQuery.data?.domains[0].name }
protocol={ addressQuery.data?.domains[0].protocol }
isLoading={ isLoading }
variant="subheading"
noLink
mr={ 1 }
maxW="300px"
/>
) }
<AddressEntity
address={{
...addressQuery.data,
@@ -124,6 +132,11 @@ const OpSuperchainAddress = () => {
/>
<AddressQrCode hash={ checkSummedHash } isLoading={ isLoading }/>
<Box ml="auto"/>
<OpSuperchainAddressEnsDomains
mainDomain={ addressQuery.data?.domains?.[0] }
isLoading={ isLoading }
hash={ checkSummedHash }
/>
<ClusterChainsPopover addressHash={ checkSummedHash } data={ addressQuery.data } isLoading={ isLoading }/>
</Flex>
);
@@ -136,7 +149,7 @@ const OpSuperchainAddress = () => {
isLoading={ isLoading }
secondRow={ titleSecondRow }
/>
<RoutedTabs tabs={ tabs } isLoading={ isLoading }/>
<RoutedTabs tabs={ tabs } isLoading={ isLoading } preservedParams={ TABS_PRESERVED_PARAMS }/>
</>
);
};
@@ -6,16 +6,18 @@ import multichainConfig from 'configs/multichain';
import getSocketUrl from 'lib/api/getSocketUrl';
import useApiQuery from 'lib/api/useApiQuery';
import { MultichainProvider } from 'lib/contexts/multichain';
import useIsMobile from 'lib/hooks/useIsMobile';
import useRoutedChainSelect from 'lib/multichain/useRoutedChainSelect';
import { SocketProvider } from 'lib/socket/context';
import { ADDRESS_INFO } from 'stubs/address';
import AddressContract from 'ui/address/AddressContract';
import ChainSelect from 'ui/optimismSuperchain/components/ChainSelect';
import getContractChainIds from './getContractChainIds';
import getAvailableChainIds from './getAvailableChainIds';
const LEFT_SLOT_PROPS = {
mr: 6,
flexShrink: 0,
};
const QUERY_PRESERVED_PARAMS = [ 'tab', 'hash' ];
@@ -26,8 +28,9 @@ interface Props {
}
const OpSuperchainAddressContract = ({ addressHash, isLoading, data }: Props) => {
const chainIds = React.useMemo(() => getContractChainIds(data), [ data ]);
const chainIds = React.useMemo(() => getAvailableChainIds(data), [ data ]);
const chainSelect = useRoutedChainSelect({ persistedParams: QUERY_PRESERVED_PARAMS, isLoading, chainIds });
const isMobile = useIsMobile();
const chain = React.useMemo(() => {
return multichainConfig()?.chains.find(({ id }) => id === chainSelect.value?.[0]);
@@ -48,11 +51,12 @@ const OpSuperchainAddressContract = ({ addressHash, isLoading, data }: Props) =>
onValueChange={ chainSelect.onValueChange }
loading={ isLoading }
chainIds={ chainIds }
mode={ isMobile ? 'compact' : 'default' }
/>
);
return (
<MultichainProvider chainId={ chainSelect.value?.[0] }>
<MultichainProvider key={ chainSelect.value?.[0] } chainId={ chainSelect.value?.[0] }>
<SocketProvider url={ getSocketUrl(chain?.app_config) }>
<AddressContract
addressData={ addressQuery.data }
@@ -1,162 +0,0 @@
import React from 'react';
import type * as multichain from '@blockscout/multichain-aggregator-types';
import { route } from 'nextjs/routes';
import multichainConfig from 'configs/multichain';
import getCurrencySymbol from 'lib/multichain/getCurrencySymbol';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import * as DetailedInfo from 'ui/shared/DetailedInfo/DetailedInfo';
import DetailedInfoSponsoredItem from 'ui/shared/DetailedInfo/DetailedInfoSponsoredItem';
import ChainIcon from 'ui/shared/externalChains/ChainIcon';
import OpSuperchainAddressCoinBalance from './details/OpSuperchainAddressCoinBalance';
import OpSuperchainAddressContractName from './details/OpSuperchainAddressContractName';
import OpSuperchainAddressNetWorth from './details/OpSuperchainAddressNetWorth';
import OpSuperchainTokenSelect from './tokens/OpSuperchainTokenSelect';
import useFetchTokens from './tokens/useFetchTokens';
interface Props {
data: multichain.GetAddressResponse | undefined;
addressHash: string;
isLoading: boolean;
}
const OpSuperchainAddressDetails = ({ data, addressHash, isLoading }: Props) => {
const chains = multichainConfig()?.chains;
const activeChainsIds = Object.keys(data?.chain_infos ?? {});
const activeChains = chains?.filter((chain) => activeChainsIds.includes(String(chain.id))) ?? [];
const currencySymbol = getCurrencySymbol();
const tokensInfo = useFetchTokens({ hash: addressHash, enabled: data?.has_tokens && !isLoading });
if (!data && !isLoading) {
return null;
}
const isContract = Object.values(data?.chain_infos ?? {}).some((chainInfo) => chainInfo.is_contract);
return (
<DetailedInfo.Container templateColumns={{ base: 'minmax(0, 1fr)', lg: 'auto minmax(0, 1fr)' }} >
{ (isLoading || activeChains.length > 0) && (
<>
<DetailedInfo.ItemLabel
hint="Chains this address has interacted with"
isLoading={ isLoading }
>
Chain{ activeChains.length > 1 ? 's' : '' }
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue columnGap={ 3 } multiRow>
{ activeChains.map((chain) => (
<Link
key={ chain.id }
href={ route({
pathname: '/address/[hash]',
query: {
hash: addressHash,
utm_source: 'multichain-explorer',
utm_medium: 'address',
},
}, { chain, external: true }) }
external
loading={ isLoading }
display="flex"
alignItems="center"
color="text.primary"
_hover={{ color: 'link.primary.hover' }}
>
<ChainIcon data={ chain } mr={ 2 }/>
<span>{ chain.name }</span>
</Link>
)) }
</DetailedInfo.ItemValue>
</>
) }
{ isContract && (
<>
<DetailedInfo.ItemLabel
hint="The name found in the source code of the Contract"
isLoading={ isLoading }
>
Contract name
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<OpSuperchainAddressContractName data={ data } isLoading={ isLoading }/>
</DetailedInfo.ItemValue>
</>
) }
<DetailedInfo.ItemLabel
hint={ `${ currencySymbol } balance` }
isLoading={ isLoading }
>
{ currencySymbol } balance
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<OpSuperchainAddressCoinBalance data={ data } isLoading={ isLoading }/>
</DetailedInfo.ItemValue>
{ data?.has_tokens && (
<>
<DetailedInfo.ItemLabel
hint="All tokens in the account and total value"
isLoading={ isLoading }
>
Tokens
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<OpSuperchainTokenSelect isLoading={ isLoading || tokensInfo.isPending } isError={ tokensInfo.isError } data={ tokensInfo.data }/>
</DetailedInfo.ItemValue>
</>
) }
<DetailedInfo.ItemLabel
hint="Total net worth in USD of native coin and all tokens for the address"
isLoading={ isLoading }
>
Net worth
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<OpSuperchainAddressNetWorth
addressData={ data }
tokensData={ tokensInfo.data }
isLoading={ isLoading || Boolean(tokensInfo.isPending && data?.has_tokens) }
isError={ tokensInfo.isError }
/>
</DetailedInfo.ItemValue>
<DetailedInfo.ItemLabel
hint="Number of transactions related to this address"
isLoading={ isLoading }
>
Transactions
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue whiteSpace="pre-wrap">
<Skeleton loading={ isLoading }>
<Link href={ route({ pathname: '/address/[hash]', query: { hash: addressHash, tab: 'txs_local' } }) }>View by chain</Link>
</Skeleton>
</DetailedInfo.ItemValue>
<DetailedInfo.ItemLabel
hint="Number of transfers to/from this address"
isLoading={ isLoading }
>
Transfers
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue whiteSpace="pre-wrap">
<Skeleton loading={ isLoading }>
<Link href={ route({ pathname: '/address/[hash]', query: { hash: addressHash, tab: 'token_transfers_local' } }) }>View by chain</Link>
</Skeleton>
</DetailedInfo.ItemValue>
<DetailedInfoSponsoredItem isLoading={ isLoading }/>
</DetailedInfo.Container>
);
};
export default React.memo(OpSuperchainAddressDetails);
@@ -5,6 +5,7 @@ import type * as multichain from '@blockscout/multichain-aggregator-types';
import multichainConfig from 'configs/multichain';
import { MultichainProvider } from 'lib/contexts/multichain';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import { LOG } from 'stubs/log';
import { generateListStub } from 'stubs/utils';
@@ -26,6 +27,7 @@ interface Props {
const OpSuperchainAddressLogs = ({ addressData, isLoading }: Props) => {
const router = useRouter();
const chainIds = React.useMemo(() => getAvailableChainIds(addressData), [ addressData ]);
const isMobile = useIsMobile();
const hash = getQueryParamString(router.query.hash);
const { data, isPlaceholderData, isError, pagination, chainValue, onChainValueChange } = useQueryWithPages({
@@ -50,20 +52,23 @@ const OpSuperchainAddressLogs = ({ addressData, isLoading }: Props) => {
}, [ chainValue ]);
const actionBar = (
<ActionBar mt={ -6 } showShadow>
<ActionBar mt={ -6 }>
<ChainSelect
value={ chainValue }
onValueChange={ onChainValueChange }
chainIds={ chainIds }
loading={ isLoading }
mode={ isMobile ? 'compact' : 'default' }
/>
<AddressCsvExportLink
address={ hash }
isLoading={ pagination.isLoading }
params={{ type: 'logs' }}
ml={{ base: 2, lg: 'auto' }}
chainData={ chainData }
/>
{ (data?.items.length ?? 0) > 0 && (
<AddressCsvExportLink
address={ hash }
isLoading={ pagination.isLoading }
params={{ type: 'logs' }}
ml={{ base: 2, lg: 'auto' }}
chainData={ chainData }
/>
) }
<Pagination ml={{ base: 'auto', lg: 8 }} { ...pagination }/>
</ActionBar>
);
@@ -84,6 +89,7 @@ const OpSuperchainAddressLogs = ({ addressData, isLoading }: Props) => {
itemsNum={ data?.items?.length }
emptyText="There are no logs for this address."
showActionBarIfEmpty
showActionBarIfError
actionBar={ actionBar }
>
<MultichainProvider chainId={ chainValue?.[0] }>
@@ -0,0 +1,56 @@
import { Box } from '@chakra-ui/react';
import type { Locator } from '@playwright/test';
import React from 'react';
import * as tokensMock from 'mocks/address/tokens';
import * as addressMock from 'mocks/multichain/address';
import * as chainDataMock from 'mocks/multichain/chains';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config';
import OpSuperchainAddressPortfolio from './OpSuperchainAddressPortfolio';
const CURRENT_ADDRESS = '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859';
test.beforeEach(async({ mockMultichainConfig, mockEnvs }) => {
await mockMultichainConfig();
await mockEnvs(ENVS_MAP.opSuperchain);
});
test.describe('nfts', () => {
const hooksConfig = {
router: {
query: { hash: CURRENT_ADDRESS, tab: 'portfolio_nfts' },
},
};
let component: Locator;
test.beforeEach(async({ render, mockApiResponse, mockAssetResponse }) => {
await mockApiResponse(
'general:address_collections',
{ ...tokensMock.collections, items: [ tokensMock.collections.items[2] ] },
{ pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: [] }, chainConfig: chainDataMock.chainA },
);
await mockAssetResponse(chainDataMock.chainA.logo as string, './playwright/mocks/image_s.jpg');
component = await render(
<Box pt={{ base: 0, lg: '30px' }}>
<OpSuperchainAddressPortfolio addressData={ addressMock.addressA } isLoading={ false }/>
</Box>,
{ hooksConfig },
);
});
test('base view', async() => {
await expect(component).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: pwConfig.viewport.mobile });
test('base view', async() => {
await expect(component).toHaveScreenshot();
});
});
});
@@ -8,24 +8,19 @@ import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
import { MultichainProvider } from 'lib/contexts/multichain';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TOKEN } from 'stubs/optimismSuperchain';
import { generateListStub } from 'stubs/utils';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import AddressCollections from 'ui/address/tokens/AddressCollections';
import AddressNftDisplayTypeRadio from 'ui/address/tokens/AddressNftDisplayTypeRadio';
import AddressNFTs from 'ui/address/tokens/AddressNFTs';
import AddressNftTypeFilter from 'ui/address/tokens/AddressNftTypeFilter';
import ERC20Tokens from 'ui/address/tokens/ERC20Tokens';
import useAddressNftQuery from 'ui/address/tokens/useAddressNftQuery';
import ChainSelect from 'ui/optimismSuperchain/components/ChainSelect';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import getAvailableChainIds from './getAvailableChainIds';
import OpSuperchainTokenBalances from './tokens/OpSuperchainTokenBalances';
import useChainSelectErc20 from './useChainSelectErc20';
import OpSuperchainAddressPortfolioTokens from './portfolio/OpSuperchainAddressPortfolioTokens';
export const ADDRESS_OP_SUPERCHAIN_TOKENS_TAB_IDS = [ 'tokens_erc20' as const, 'tokens_nfts' as const ];
export const ADDRESS_OP_PORTFOLIO_TAB_IDS = [ 'portfolio_tokens' as const, 'portfolio_nfts' as const ];
const TABS_RIGHT_SLOT_PROPS = {
display: 'flex',
justifyContent: { base: 'flex-end', lg: 'flex-start' },
@@ -35,53 +30,42 @@ const TABS_RIGHT_SLOT_PROPS = {
const TAB_LIST_PROPS = {
marginBottom: 0,
pt: 6,
pb: 3,
pb: 2,
marginTop: -6,
};
const TABS_PRESERVED_PARAMS = [ 'chain_id' ];
interface Props {
addressData: multichain.GetAddressResponse | undefined;
isLoading: boolean;
}
const OpSuperchainAddressTokens = ({ addressData }: Props) => {
const chainIds = React.useMemo(() => getAvailableChainIds(addressData), [ addressData ]);
const OpSuperchainAddressPortfolio = ({ addressData, isLoading }: Props) => {
const scrollRef = React.useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
const router = useRouter();
const tab = getQueryParamString(router.query.tab) as typeof ADDRESS_OP_SUPERCHAIN_TOKENS_TAB_IDS[number] | 'tokens' | undefined;
const tab = getQueryParamString(router.query.tab) as typeof ADDRESS_OP_PORTFOLIO_TAB_IDS[number] | 'portfolio' | undefined;
const hash = getQueryParamString(router.query.hash);
const [ chainSelectErc20Value, setChainSelectErc20Value ] = useChainSelectErc20({ chainIds });
const chainId = chainSelectErc20Value?.filter(Boolean);
const erc20Query = useQueryWithPages({
resourceName: 'multichainAggregator:address_tokens',
pathParams: { hash },
filters: chainId?.length ? { type: 'ERC-20', chain_id: chainId.includes('all') ? undefined : chainId.filter(Boolean) } : { type: 'ERC-20' },
scrollRef,
options: {
enabled: (tab === 'tokens' || tab === 'tokens_erc20'),
placeholderData: generateListStub<'multichainAggregator:address_tokens'>(TOKEN, 10, { next_page_params: undefined }),
},
});
const chainIds = React.useMemo(() => getAvailableChainIds(addressData), [ addressData ]);
const { nftsQuery, collectionsQuery, displayType: nftDisplayType, tokenTypes: nftTokenTypes, onDisplayTypeChange, onTokenTypesChange } = useAddressNftQuery({
scrollRef,
enabled: tab === 'tokens_nfts',
enabled: !isLoading && tab === 'portfolio_nfts' && chainIds.length > 0,
addressHash: hash,
isMultichain: true,
chainIds,
});
const handelChainSelectErc20ValueChange = React.useCallback(({ value }: { value: Array<string> }) => {
erc20Query.onFilterChange({ chain_id: value.includes('all') ? undefined : value.filter(Boolean) });
setChainSelectErc20Value(value);
}, [ setChainSelectErc20Value, erc20Query ]);
const handleChainChange = React.useCallback((chainId: string | null) => {
const chainValue = chainId ? [ chainId ] : [ chainIds[0] ];
nftsQuery.onChainValueChange({ value: chainValue });
collectionsQuery.onChainValueChange({ value: chainValue });
}, [ nftsQuery, collectionsQuery, chainIds ]);
const hasActiveFilters = (() => {
if (tab === 'tokens_nfts') {
if (tab === 'portfolio_nfts') {
return Boolean(nftTokenTypes?.length);
}
@@ -89,13 +73,14 @@ const OpSuperchainAddressTokens = ({ addressData }: Props) => {
})();
const rightSlot = (() => {
if (tab === 'tokens_nfts') {
if (tab === 'portfolio_nfts') {
const query = nftDisplayType === 'list' ? nftsQuery : collectionsQuery;
const chainSelect = (
<ChainSelect
loading={ query.pagination.isLoading }
value={ query.chainValue }
onValueChange={ query.onChainValueChange }
chainIds={ chainIds }
/>
);
@@ -117,37 +102,16 @@ const OpSuperchainAddressTokens = ({ addressData }: Props) => {
</>
);
}
return (
<>
<ChainSelect
loading={ erc20Query.pagination.isLoading }
value={ chainSelectErc20Value }
onValueChange={ handelChainSelectErc20ValueChange }
chainIds={ chainIds }
withAllOption
/>
{ erc20Query.pagination.isVisible && !isMobile && <Pagination { ...erc20Query.pagination } ml="auto"/> }
</>
);
})();
const tabs: Array<TabItemRegular> = [
{
id: 'tokens_erc20',
title: 'ERC-20',
component: (
<ERC20Tokens
items={ erc20Query.data?.items }
isLoading={ erc20Query.isPlaceholderData }
pagination={ erc20Query.pagination }
isError={ erc20Query.isError }
top={ erc20Query.pagination.isVisible ? 68 : 0 }
/>
),
id: 'portfolio_tokens',
title: 'Tokens',
component: <OpSuperchainAddressPortfolioTokens addressData={ addressData } isLoading={ isLoading } onChainChange={ handleChainChange }/>,
},
{
id: 'tokens_nfts',
id: 'portfolio_nfts',
title: 'NFT',
component: nftDisplayType === 'list' ? (
<MultichainProvider chainId={ nftsQuery.chainValue?.[0] }>
@@ -163,7 +127,6 @@ const OpSuperchainAddressTokens = ({ addressData }: Props) => {
return (
<>
<OpSuperchainTokenBalances/>
<Box ref={ scrollRef }/>
<RoutedTabs
variant="secondary"
@@ -173,9 +136,10 @@ const OpSuperchainAddressTokens = ({ addressData }: Props) => {
rightSlotProps={ TABS_RIGHT_SLOT_PROPS }
listProps={ isMobile ? undefined : TAB_LIST_PROPS }
stickyEnabled={ !isMobile }
preservedParams={ TABS_PRESERVED_PARAMS }
/>
</>
);
};
export default React.memo(OpSuperchainAddressTokens);
export default React.memo(OpSuperchainAddressPortfolio);
@@ -3,7 +3,8 @@ import type { Locator } from '@playwright/test';
import React from 'react';
import * as countersMock from 'mocks/address/counters';
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
import * as addressMock from 'mocks/multichain/address';
import * as chainDataMock from 'mocks/multichain/chains';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
@@ -29,18 +30,18 @@ test.describe('local transfers', () => {
await mockApiResponse(
'general:address_token_transfers',
{ items: [ tokenTransferMock.erc20 ], next_page_params: DEFAULT_PAGINATION },
{ pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: [] }, chainConfig: opSuperchainMock.chainDataA },
{ pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: [] }, chainConfig: chainDataMock.chainA },
);
await mockApiResponse(
'general:address_counters',
countersMock.forAddress,
{ pathParams: { hash: CURRENT_ADDRESS }, chainConfig: opSuperchainMock.chainDataA },
{ pathParams: { hash: CURRENT_ADDRESS }, chainConfig: chainDataMock.chainA },
);
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(chainDataMock.chainA.logo as string, './playwright/mocks/image_s.jpg');
component = await render(
<Box pt={{ base: '134px', lg: 6 }}>
<OpSuperchainAddressTokenTransfers addressData={ opSuperchainMock.addressA } isLoading={ false }/>
<OpSuperchainAddressTokenTransfers addressData={ addressMock.addressA } isLoading={ false }/>
</Box>,
{ hooksConfig },
{ withSocket: true },
@@ -21,6 +21,7 @@ import useAddressCountersQuery from 'ui/address/utils/useAddressCountersQuery';
import ChainSelect from 'ui/optimismSuperchain/components/ChainSelect';
import Pagination from 'ui/shared/pagination/Pagination';
import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter';
import { getTokenFilterValue } from 'ui/tokens/utils';
import ListCounterText from '../components/ListCounterText';
import getAvailableChainIds from './getAvailableChainIds';
@@ -71,6 +72,18 @@ const OpSuperchainAddressTokenTransfers = ({ addressData, isLoading }: Props) =>
chain: chainData,
});
const handleChainValueChange = React.useCallback(({ value }: { value: Array<string> }) => {
transfersQueryLocal.query.onChainValueChange({ value });
const chainConfig = multichainConfig()?.chains.find(chain => chain.id === value[0]);
const tokenTypes = getTokenFilterValue(router.query.type, chainConfig?.app_config);
if (tokenTypes) {
const chainsTokenTypes = transfersQueryLocal.filters.type.filter(type => tokenTypes.includes(type));
if (chainsTokenTypes.length < transfersQueryLocal.filters.type.length) {
transfersQueryLocal.onTypeFilterChange(chainsTokenTypes);
}
}
}, [ transfersQueryLocal, router.query.type ]);
const countersText = (() => {
if (isLocalTab) {
return (
@@ -96,7 +109,7 @@ const OpSuperchainAddressTokenTransfers = ({ addressData, isLoading }: Props) =>
<ChainSelect
loading={ transfersQueryLocal.query.pagination.isLoading }
value={ transfersQueryLocal.query.chainValue }
onValueChange={ transfersQueryLocal.query.onChainValueChange }
onValueChange={ handleChainValueChange }
chainIds={ chainIds }
/>
);
@@ -111,11 +124,12 @@ const OpSuperchainAddressTokenTransfers = ({ addressData, isLoading }: Props) =>
<TokenTransferFilter
defaultTypeFilters={ transfersQueryLocal.filters.type }
onTypeFilterChange={ transfersQueryLocal.onTypeFilterChange }
appliedFiltersNum={ transfersQueryLocal.filters.type.length }
appliedFiltersNum={ transfersQueryLocal.filters.type.length + (transfersQueryLocal.filters.filter ? 1 : 0) }
withAddressFilter
onAddressFilterChange={ transfersQueryLocal.onAddressFilterChange }
defaultAddressFilter={ transfersQueryLocal.filters.filter }
isLoading={ transfersQueryLocal.query.isPlaceholderData }
chainConfig={ chainData?.app_config }
/>
{ chainSelect }
{ countersText }
@@ -1,114 +0,0 @@
import type { Locator } from '@playwright/test';
import React from 'react';
import * as tokensMock from 'mocks/address/tokens';
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
import * as statsMock from 'mocks/stats';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config';
import OpSuperchainAddressTokens from './OpSuperchainAddressTokens';
const CURRENT_ADDRESS = '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859';
test.beforeEach(async({ mockApiResponse, mockMultichainConfig, mockEnvs, page }) => {
await mockApiResponse('multichainAggregator:address', opSuperchainMock.addressA, { pathParams: { hash: CURRENT_ADDRESS } });
await mockApiResponse('multichainAggregator:address_tokens', {
items: [ opSuperchainMock.tokenA ],
next_page_params: undefined,
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-20' } });
await mockApiResponse('multichainAggregator:address_tokens', {
items: [ ],
next_page_params: undefined,
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-721' } });
await mockApiResponse('multichainAggregator:address_tokens', {
items: [ ],
next_page_params: undefined,
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-1155' } });
await mockApiResponse('multichainAggregator:address_tokens', {
items: [ ],
next_page_params: undefined,
}, { pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: 'ERC-404' } });
await page.route('https://eth.blockscout.com/api/v2/stats', (route) => route.fulfill({
status: 200,
json: { ...statsMock.base, coin_image: null },
}));
await mockMultichainConfig();
await mockEnvs(ENVS_MAP.opSuperchain);
});
test.describe('tokens', () => {
const hooksConfig = {
router: {
query: { hash: CURRENT_ADDRESS, tab: 'tokens_erc20' },
},
};
let component: Locator;
test.beforeEach(async({ render, mockAssetResponse }) => {
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(opSuperchainMock.chainDataB.logo as string, './playwright/mocks/image_md.jpg');
component = await render(
<OpSuperchainAddressTokens addressData={ opSuperchainMock.addressA }/>,
{ hooksConfig },
);
});
test('base view', async({ page }) => {
await expect(component).toHaveScreenshot();
await component.getByText('By chain').nth(0).click();
await expect(page.locator('div[data-scope="popover"][data-part="content"]').first()).toHaveScreenshot();
await component.getByText('By chain').nth(1).click();
await expect(page.locator('div[data-scope="popover"][data-part="content"]').first()).toHaveScreenshot();
await component.getByText('By chain').nth(2).click();
await expect(page.locator('div[data-scope="popover"][data-part="content"]').first()).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: pwConfig.viewport.mobile });
test('base view', async() => {
await expect(component).toHaveScreenshot();
});
});
});
test.describe('nfts', () => {
const hooksConfig = {
router: {
query: { hash: CURRENT_ADDRESS, tab: 'tokens_nfts' },
},
};
let component: Locator;
test.beforeEach(async({ render, mockApiResponse, mockAssetResponse }) => {
await mockApiResponse(
'general:address_collections',
{ ...tokensMock.collections, items: [ tokensMock.collections.items[2] ] },
{ pathParams: { hash: CURRENT_ADDRESS }, queryParams: { type: [] }, chainConfig: opSuperchainMock.chainDataA },
);
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_s.jpg');
component = await render(
<OpSuperchainAddressTokens addressData={ opSuperchainMock.addressA }/>,
{ hooksConfig },
);
});
test('base view', async() => {
await expect(component).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: pwConfig.viewport.mobile });
test('base view', async() => {
await expect(component).toHaveScreenshot();
});
});
});
@@ -3,7 +3,8 @@ import type { Locator } from '@playwright/test';
import React from 'react';
import * as countersMock from 'mocks/address/counters';
import * as opSuperchainMock from 'mocks/multichain/opSuperchain';
import * as addressMock from 'mocks/multichain/address';
import * as chainDataMock from 'mocks/multichain/chains';
import * as txMock from 'mocks/txs/tx';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
@@ -29,18 +30,18 @@ test.describe('local txs', () => {
await mockApiResponse(
'general:address_txs',
{ items: [ txMock.base ], next_page_params: DEFAULT_PAGINATION },
{ pathParams: { hash: CURRENT_ADDRESS }, chainConfig: opSuperchainMock.chainDataA },
{ pathParams: { hash: CURRENT_ADDRESS }, chainConfig: chainDataMock.chainA },
);
await mockApiResponse(
'general:address_counters',
countersMock.forContract,
{ pathParams: { hash: CURRENT_ADDRESS }, chainConfig: opSuperchainMock.chainDataA },
{ pathParams: { hash: CURRENT_ADDRESS }, chainConfig: chainDataMock.chainA },
);
await mockAssetResponse(opSuperchainMock.chainDataA.logo as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(chainDataMock.chainA.logo as string, './playwright/mocks/image_s.jpg');
component = await render(
<Box pt={{ base: '134px', lg: 6 }}>
<OpSuperchainAddressTxs addressData={ opSuperchainMock.addressA } isLoading={ false }/>
<OpSuperchainAddressTxs addressData={ addressMock.addressA } isLoading={ false }/>
</Box>,
{ hooksConfig },
{ withSocket: true },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

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