Commit 2cc75359 authored by Max Alekseenko's avatar Max Alekseenko

Merge branch 'main' into action-button

parents 6354b5db fdc8b808
......@@ -10,6 +10,7 @@ on:
type: choice
options:
- none
- base
- gnosis
- eth
- eth_sepolia
......
......@@ -339,6 +339,7 @@
"main",
"main.L2",
"localhost",
"base",
"gnosis",
"eth",
"eth_goerli",
......
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const apiHost = getEnvValue('NEXT_PUBLIC_METADATA_SERVICE_API_HOST');
const title = 'Address metadata';
const config: Feature<{ api: { endpoint: string; basePath: string } }> = (() => {
if (apiHost) {
return Object.freeze({
title,
isEnabled: true,
api: {
endpoint: apiHost,
basePath: '',
},
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
export { default as account } from './account';
export { default as addressVerification } from './addressVerification';
export { default as addressMetadata } from './addressMetadata';
export { default as adsBanner } from './adsBanner';
export { default as adsText } from './adsText';
export { default as beaconChain } from './beaconChain';
......
......@@ -10,6 +10,9 @@ const meta = Object.freeze({
imageUrl: app.baseUrl + (getExternalAssetFilePath('NEXT_PUBLIC_OG_IMAGE_URL') || defaultImageUrl),
enhancedDataEnabled: getEnvValue('NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED') === 'true',
},
seo: {
enhancedDataEnabled: getEnvValue('NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED') === 'true',
},
});
export default meta;
# Set of ENVs for Base network explorer
# https://base.blockscout.com/
# app configuration
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
# blockchain parameters
NEXT_PUBLIC_NETWORK_NAME=Base Mainnet
NEXT_PUBLIC_NETWORK_SHORT_NAME=Base
NEXT_PUBLIC_NETWORK_ID=8453
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_NETWORK_RPC_URL=https://mainnet.base.org/
# api configuration
NEXT_PUBLIC_API_HOST=base.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
# ui config
## homepage
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=linear-gradient(136.9deg,rgb(107,94,236)1.5%,rgb(0,82,255)56.84%,rgb(82,62,231)98.54%)
## sidebar
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/base.svg
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/base.svg
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/base-mainnet.json
NEXT_PUBLIC_OTHER_LINKS=[{'url':'https://base.drpc.org?ref=559183','text':'Public RPC'}]
## footer
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/base-mainnet.json
##views
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_VIEWS_NFT_MARKETPLACES=[{'name':'OpenSea','collection_url':'https://opensea.io/assets/ethereum/{hash}','instance_url':'https://opensea.io/assets/ethereum/{hash}/{id}','logo_url':'https://opensea.io/static/images/logos/opensea-logo.svg'},{'name':'LooksRare','collection_url':'https://looksrare.org/collections/{hash}','instance_url':'https://looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]
## misc
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'l2scan','baseUrl':'https://base.l2scan.co/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}},{'title':'Tenderly','baseUrl':'https://dashboard.tenderly.co','paths':{'tx':'/tx/base'}},{'title':'3xpl','baseUrl':'https://3xpl.com/','paths':{'tx':'/base/transaction','address':'/base/address'}}]
NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE=gradient_avatar
NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE=<p>BNS & DAppscout collaboration: <a href='https://base.blockscout.com/apps/base-name-service?utm_source=blockscout&utm_medium=bns_campaign' target='_blank'>earn BNS points</a> for purchasing .base domains! <a href='https://www.blog.blockscout.com/exploring-dappscout-on-base-with-basenameservice-bns?utm_source=blockscout&utm_medium=bns_campaign' target='_blank'>Check here for more details </a></p>
# app features
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xfd5c5dae7b69fe29e61d19b9943e688aa0f1be1e983c4fba8fe985f90ff69d5f
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_AUTH_URL=http://localhost:3000/login
NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_STATS_API_HOST=https://stats-l2-base-mainnet.k8s-prod-1.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_AD_BANNER_PROVIDER=hype
NEXT_PUBLIC_AD_BANNER_ADDITIONAL_PROVIDER=adbutler
NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP={'id':'728301','width':'728','height':'90'}
NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE={'id':'728301','width':'320','height':'100'}
NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens.services.blockscout.com
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_SWAP_BUTTON_URL=aerodrome
NEXT_PUBLIC_MARKETPLACE_ENABLED=true
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-categories/default.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-security-reports/default.json
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_METASUITES_ENABLED=true
NEXT_PUBLIC_ROLLUP_TYPE=optimistic
NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://bridge.base.org/withdraw
NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth.blockscout.com/
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-base.safe.global
#meta
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/base-mainnet.png
......@@ -55,3 +55,5 @@ NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
#meta
NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth.jpg?raw=true
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED=true
......@@ -60,6 +60,7 @@ NEXT_PUBLIC_AD_BANNER_PROVIDER=getit
NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED=true
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-sepolia.safe.global
NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata-test.k8s-dev.blockscout.com
#meta
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/sepolia-testnet.png
......@@ -19,8 +19,6 @@ NEXT_PUBLIC_NETWORK_RPC_URL=https://mainnet.optimism.io
# api configuration
NEXT_PUBLIC_API_HOST=optimism.blockscout.com
NEXT_PUBLIC_API_PORT=80
NEXT_PUBLIC_API_PROTOCOL=http
NEXT_PUBLIC_API_BASE_PATH=/
# ui config
......
......@@ -18,8 +18,6 @@ NEXT_PUBLIC_NETWORK_RPC_URL=https://zkevm-rpc.com
# api configuration
NEXT_PUBLIC_API_HOST=zkevm.blockscout.com
NEXT_PUBLIC_API_PORT=80
NEXT_PUBLIC_API_PROTOCOL=http
NEXT_PUBLIC_API_BASE_PATH=/
# ui config
......@@ -47,4 +45,4 @@ NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.co
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
# rollup
NEXT_PUBLIC_ROLLUP_TYPE=zkEvm
NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://polygon.blockscout.com
NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth.blockscout.com
......@@ -18,8 +18,6 @@ NEXT_PUBLIC_NETWORK_RPC_URL=https://mainnet.era.zksync.io
# api configuration
NEXT_PUBLIC_API_HOST=zksync.blockscout.com
NEXT_PUBLIC_API_PORT=80
NEXT_PUBLIC_API_PROTOCOL=http
NEXT_PUBLIC_API_BASE_PATH=/
# ui config
......
......@@ -21,6 +21,7 @@ export_envs_from_preset() {
"NEXT_PUBLIC_APP_HOST"
"NEXT_PUBLIC_APP_PORT"
"NEXT_PUBLIC_APP_ENV"
"NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL"
)
while IFS='=' read -r name value; do
......
......@@ -583,6 +583,7 @@ const schema = yup
NEXT_PUBLIC_VISUALIZE_API_BASE_PATH: yup.string(),
NEXT_PUBLIC_CONTRACT_INFO_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_NAME_SERVICE_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_METADATA_SERVICE_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_GRAPHIQL_TRANSACTION: yup.string().matches(regexp.HEX_REGEXP),
NEXT_PUBLIC_WEB3_WALLETS: yup
.mixed()
......@@ -603,6 +604,7 @@ const schema = yup
NEXT_PUBLIC_OG_DESCRIPTION: yup.string(),
NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED: yup.boolean(),
NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED: yup.boolean(),
NEXT_PUBLIC_SAFE_TX_SERVICE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(),
NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(),
......
......@@ -25,6 +25,7 @@ NEXT_PUBLIC_GAS_TRACKER_ENABLED=true
NEXT_PUBLIC_GAS_TRACKER_UNITS=['gwei']
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE='<a href="#">Hello</a>'
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://example.com
NEXT_PUBLIC_METASUITES_ENABLED=true
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
......@@ -41,6 +42,7 @@ NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_OG_DESCRIPTION='Hello world!'
NEXT_PUBLIC_OG_IMAGE_URL=https://example.com/image.png
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OTHER_LINKS=[{'url':'https://blockscout.com','text':'Blockscout'}]
NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE=true
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-mainnet.safe.global
......
......@@ -4,9 +4,9 @@ imagePullSecrets:
- name: regcred
config:
network:
id: 5
name: Göerli
shortname: Göerli
id: "11155111"
name: Sepolia
shortname: Sepolia
currency:
name: Ether
symbol: ETH
......@@ -54,11 +54,10 @@ blockscout:
cpu: "3"
# Blockscout environment variables
env:
BLOCKSCOUT_VERSION: v5.3.0-beta
ETHEREUM_JSONRPC_VARIANT: geth
HEART_BEAT_TIMEOUT: 30
SUBNETWORK: Ethereum
NETWORK: (Goerli)
NETWORK: (Sepolia)
NETWORK_ICON: _network_icon.html
LOGO: /images/goerli_logo.svg
TXS_STATS_DAYS_TO_COMPILE_AT_INIT: 1
......@@ -83,10 +82,10 @@ blockscout:
INDEXER_TOKEN_BALANCES_CONCURRENCY: 4
DISABLE_EXCHANGE_RATES: 'true'
DISABLE_INDEXER: 'false'
FIRST_BLOCK: '8739119'
LAST_BLOCK: '8739119'
TRACE_FIRST_BLOCK: '8739119'
TRACE_LAST_BLOCK: '8739119'
FIRST_BLOCK: '5780052'
LAST_BLOCK: '5780052'
TRACE_FIRST_BLOCK: '5780052'
TRACE_LAST_BLOCK: '5780052'
envFromSecret:
ETHEREUM_JSONRPC_TRACE_URL: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/ETHEREUM_JSONRPC_TRACE_URL
ETHEREUM_JSONRPC_HTTP_URL: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/ETHEREUM_JSONRPC_HTTP_URL
......@@ -102,7 +101,6 @@ blockscout:
ACCOUNT_CLOAK_KEY: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/ACCOUNT_CLOAK_KEY
SECRET_KEY_BASE: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SECRET_KEY_BASE
DATABASE_URL: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/DATABASE_URL
DATABASE_READ_ONLY_API_URL: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/DATABASE_READ_ONLY_API_URL
API_SENSITIVE_ENDPOINTS_KEY: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/API_SENSITIVE_ENDPOINTS_KEY
ACCOUNT_DATABASE_URL: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/ACCOUNT_DATABASE_URL
ACCOUNT_REDIS_URL: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/ACCOUNT_REDIS_URL
......@@ -141,11 +139,11 @@ frontend:
env:
# ui config
NEXT_PUBLIC_FEATURED_NETWORKS: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/eth-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS: "[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]"
NEXT_PUBLIC_FEATURED_NETWORKS: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/eth-sepolia.json
NEXT_PUBLIC_NETWORK_EXPLORERS: "[{'title':'Etherscan','baseUrl':'https://sepolia.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}, {'title':'Tenderly','baseUrl':'https://dashboard.tenderly.co','paths':{'tx':'/tx/sepolia'}} ]"
# network config
NEXT_PUBLIC_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/goerli.svg
NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/goerli.svg
NEXT_PUBLIC_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/sepolia.svg
NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/sepolia.svg
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE: validation
NEXT_PUBLIC_MARKETPLACE_ENABLED: true
......@@ -159,14 +157,14 @@ frontend:
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_NAME_SERVICE_API_HOST: https://bens-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_LOGOUT_URL: https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_NETWORK_RPC_URL: https://rpc.ankr.com/eth_goerli
NEXT_PUBLIC_NETWORK_RPC_URL: https://sepolia.drpc.org
NEXT_PUBLIC_HOMEPAGE_CHARTS: "['daily_txs','coin_price','market_cap']"
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/eth-goerli.json
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace-categories/default.json
#NEXT_PUBLIC_MARKETPLACE_CONFIG_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/eth-sepolia.json
#NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace-categories/default.json
NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']"
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
NEXT_PUBLIC_CONTRACT_CODE_IDES: "[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout=eth-goerli.blockscout.com','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]"
#NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
NEXT_PUBLIC_CONTRACT_CODE_IDES: "[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout=eth-sepolia.blockscout.com','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]"
NEXT_PUBLIC_SWAP_BUTTON_URL: uniswap
envFromSecret:
NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN
......
......@@ -59,9 +59,9 @@ frontend:
NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_NAME_SERVICE_API_HOST: https://bens-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_METADATA_SERVICE_API_HOST: https://metadata-test.k8s-dev.blockscout.com
NEXT_PUBLIC_AUTH_URL: https://blockscout-main.k8s-dev.blockscout.com
NEXT_PUBLIC_MARKETPLACE_ENABLED: true
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/eth-goerli.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM: https://airtable.com/shrqUAcjgGJ4jU88C
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM: https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL: https://gist.githubusercontent.com/maxaleks/ce5c7e3de53e8f5b240b88265daf5839/raw/328383c958a8f7ecccf6d50c953bcdf8ab3faa0a/security_reports_goerli_test.json
......
......@@ -49,6 +49,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
- [Transaction interpretation](ENVS.md#transaction-interpretation)
- [Verified tokens info](ENVS.md#verified-tokens-info)
- [Name service integration](ENVS.md#name-service-integration)
- [Metadata service integration](ENVS.md#metadata-service-integration)
- [Bridged tokens](ENVS.md#bridged-tokens)
- [Safe{Core} address tags](ENVS.md#safecore-address-tags)
- [SUAVE chain](ENVS.md#suave-chain)
......@@ -171,7 +172,7 @@ By default, the app has generic favicon. You can override this behavior by provi
### Meta
Settings for meta tags and OG tags
Settings for meta tags, OG tags and SEO
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
......@@ -179,6 +180,7 @@ Settings for meta tags and OG tags
| NEXT_PUBLIC_OG_DESCRIPTION | `string` | Custom OG description | - | - | `Blockscout is the #1 open-source blockchain explorer available today. 100+ chains and counting rely on Blockscout data availability, APIs, and ecosystem tools to support their networks.` |
| NEXT_PUBLIC_OG_IMAGE_URL | `string` | OG image url. Minimum image size is 200 x 20 pixels (recommended: 1200 x 600); maximum supported file size is 8 MB; 2:1 aspect ratio; supported formats: image/jpeg, image/gif, image/png | - | `static/og_placeholder.png` | `https://placekitten.com/1200/600` |
| NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED | `boolean` | Set to `true` to populate OG tags (title, description) with API data for social preview robot requests | - | `false` | `true` |
| NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED | `boolean` | Set to `true` to pre-render page titles (e.g Token page) on the server side and inject page h1-tag to the markup before it is sent to the browser. | - | `false` | `true` |
&nbsp;
......@@ -388,7 +390,7 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_ROLLUP_TYPE | `'optimistic' \| 'shibarium' \| 'zkEvm' \| 'zkSync' ` | Rollup chain type | Required | - | `'optimistic'` |
| NEXT_PUBLIC_ROLLUP_L1_BASE_URL | `string` | Blockscout base URL for L1 network | Required | - | `'http://eth-goerli.blockscout.com'` |
| NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL | `string` | URL for L2 -> L1 withdrawals | - | - | `https://app.optimism.io/bridge/withdraw` |
| NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL | `string` | URL for L2 -> L1 withdrawals | Required only for `optimistic` rollups | - | `https://app.optimism.io/bridge/withdraw` |
&nbsp;
......@@ -550,6 +552,16 @@ This feature allows resolving blockchain addresses using human-readable domain n
&nbsp;
### Metadata service integration
This feature allows name tags and other public tags for addresses.
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_METADATA_SERVICE_API_HOST | `string` | Metadata Service API endpoint url | Required | - | `https://metadata.services.blockscout.com` |
&nbsp;
### Data Availability
This feature enables views related to blob transactions (EIP-4844), such as the Blob Txns tab on the Transactions page and the Blob details page.
......
<svg viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="5" cy="5" r="5" fill="#D9DBE0"/>
<circle cx="5" cy="5" r="2.5" fill="#707886"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 11" fill="none">
<circle cx="5" cy="5.438" r="5" fill="#E2E8F0"/>
<circle cx="5" cy="5.438" r="2.5" fill="#4A5568"/>
<circle cx="5" cy="5.438" r="2.5" fill="#000" fill-opacity=".2"/>
</svg>
import type { AddressMetadataTag } from 'types/api/addressMetadata';
import type { AddressMetadataTagFormatted } from 'types/client/addressMetadata';
export default function parseMetaPayload(meta: AddressMetadataTag['meta']): AddressMetadataTagFormatted['meta'] {
try {
const parsedMeta = JSON.parse(meta || '');
if (typeof parsedMeta !== 'object' || parsedMeta === null || Array.isArray(parsedMeta)) {
throw new Error('Invalid JSON');
}
const result: AddressMetadataTagFormatted['meta'] = {};
if ('textColor' in parsedMeta && typeof parsedMeta.textColor === 'string') {
result.textColor = parsedMeta.textColor;
}
if ('bgColor' in parsedMeta && typeof parsedMeta.bgColor === 'string') {
result.bgColor = parsedMeta.bgColor;
}
if ('actionURL' in parsedMeta && typeof parsedMeta.actionURL === 'string') {
result.actionURL = parsedMeta.actionURL;
}
return result;
} catch (error) {
return null;
}
}
import { useQuery } from '@tanstack/react-query';
import type { AddressMetadataInfo } from 'types/api/addressMetadata';
import type { AddressMetadataInfoFormatted, AddressMetadataTagFormatted } from 'types/client/addressMetadata';
import config from 'configs/app';
import useApiFetch from 'lib/api/useApiFetch';
import { getResourceKey } from 'lib/api/useApiQuery';
import parseMetaPayload from './parseMetaPayload';
export default function useAddressMetadataInfoQuery(addresses: Array<string>) {
const apiFetch = useApiFetch();
const queryParams = {
addresses,
chainId: config.chain.id,
tagsLimit: '20',
};
const resource = 'address_metadata_info';
// TODO @tom2drum: Improve the typing here
// since we are formatting the API data in the select function here
// we cannot use the useApiQuery hook because of its current typing
// enhance useApiQuery so it can accept an API data and the formatted data types
return useQuery<AddressMetadataInfo, unknown, AddressMetadataInfoFormatted>({
queryKey: getResourceKey(resource, { queryParams }),
queryFn: async() => {
return apiFetch(resource, { queryParams }) as Promise<AddressMetadataInfo>;
},
enabled: addresses.length > 0 && config.features.addressMetadata.isEnabled,
select: (data) => {
const addresses = Object.entries(data.addresses)
.map(([ address, { tags, reputation } ]) => {
const formattedTags: Array<AddressMetadataTagFormatted> = tags.map((tag) => ({ ...tag, meta: parseMetaPayload(tag.meta) }));
return [ address.toLowerCase(), { tags: formattedTags, reputation } ] as const;
})
.reduce((result, item) => {
result[item[0]] = item[1];
return result;
}, {} as AddressMetadataInfoFormatted['addresses']);
return { addresses };
},
});
}
......@@ -32,6 +32,7 @@ import type {
AddressCoinBalanceHistoryChartOld,
} from 'types/api/address';
import type { AddressesResponse } from 'types/api/addresses';
import type { AddressMetadataInfo } from 'types/api/addressMetadata';
import type { TxBlobs, Blob } from 'types/api/blobs';
import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block';
import type { ChartMarketResponse, ChartSecondaryCoinPriceResponse, ChartTransactionResponse } from 'types/api/charts';
......@@ -98,7 +99,14 @@ import type { ValidatorsCountersResponse, ValidatorsFilters, ValidatorsResponse,
import type { VerifiedContractsSorting } from 'types/api/verifiedContracts';
import type { VisualizedContract } from 'types/api/visualization';
import type { WithdrawalsResponse, WithdrawalsCounters } from 'types/api/withdrawals';
import type { ZkEvmL2TxnBatch, ZkEvmL2TxnBatchesItem, ZkEvmL2TxnBatchesResponse, ZkEvmL2TxnBatchTxs } from 'types/api/zkEvmL2';
import type {
ZkEvmL2DepositsResponse,
ZkEvmL2TxnBatch,
ZkEvmL2TxnBatchesItem,
ZkEvmL2TxnBatchesResponse,
ZkEvmL2TxnBatchTxs,
ZkEvmL2WithdrawalsResponse,
} from 'types/api/zkEvmL2';
import type { ZkSyncBatch, ZkSyncBatchesResponse, ZkSyncBatchTxs } from 'types/api/zkSyncL2';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
import type { ArrayElement } from 'types/utils';
......@@ -237,6 +245,18 @@ export const RESOURCES = {
filterFields: [ 'name' as const, 'only_active' as const ],
},
// METADATA SERVICE
address_metadata_info: {
path: '/api/v1/metadata',
endpoint: getFeaturePayload(config.features.addressMetadata)?.api.endpoint,
basePath: getFeaturePayload(config.features.addressMetadata)?.api.basePath,
},
address_metadata_tag_search: {
path: '/api/v1/tags:search',
endpoint: getFeaturePayload(config.features.addressMetadata)?.api.endpoint,
basePath: getFeaturePayload(config.features.addressMetadata)?.api.basePath,
},
// VISUALIZATION
visualize_sol2uml: {
path: '/api/v1/solidity\\:visualize-contracts',
......@@ -588,43 +608,61 @@ export const RESOURCES = {
},
// optimistic L2
l2_deposits: {
optimistic_l2_deposits: {
path: '/api/v2/optimism/deposits',
filterFields: [],
},
l2_deposits_count: {
optimistic_l2_deposits_count: {
path: '/api/v2/optimism/deposits/count',
},
l2_withdrawals: {
optimistic_l2_withdrawals: {
path: '/api/v2/optimism/withdrawals',
filterFields: [],
},
l2_withdrawals_count: {
optimistic_l2_withdrawals_count: {
path: '/api/v2/optimism/withdrawals/count',
},
l2_output_roots: {
optimistic_l2_output_roots: {
path: '/api/v2/optimism/output-roots',
filterFields: [],
},
l2_output_roots_count: {
optimistic_l2_output_roots_count: {
path: '/api/v2/optimism/output-roots/count',
},
l2_txn_batches: {
optimistic_l2_txn_batches: {
path: '/api/v2/optimism/txn-batches',
filterFields: [],
},
l2_txn_batches_count: {
optimistic_l2_txn_batches_count: {
path: '/api/v2/optimism/txn-batches/count',
},
// zkEvm L2
zkevm_l2_deposits: {
path: '/api/v2/zkevm/deposits',
filterFields: [],
},
zkevm_l2_deposits_count: {
path: '/api/v2/zkevm/deposits/count',
},
zkevm_l2_withdrawals: {
path: '/api/v2/zkevm/withdrawals',
filterFields: [],
},
zkevm_l2_withdrawals_count: {
path: '/api/v2/zkevm/withdrawals/count',
},
zkevm_l2_txn_batches: {
path: '/api/v2/zkevm/batches',
filterFields: [],
......@@ -739,6 +777,12 @@ export const RESOURCES = {
path: '/api/v2/config/backend-version',
},
// CSV EXPORT
csv_export_token_holders: {
path: '/api/v2/tokens/:hash/holders/csv',
pathParams: [ 'hash' as const ],
},
// OTHER
api_v2_key: {
path: '/api/v2/key',
......@@ -802,9 +846,9 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'token_transfers' | 'token_holders' | 'token_inventory' | 'tokens' | 'tokens_bridged' |
'token_instance_transfers' | 'token_instance_holders' |
'verified_contracts' |
'l2_output_roots' | 'l2_withdrawals' | 'l2_txn_batches' | 'l2_deposits' |
'optimistic_l2_output_roots' | 'optimistic_l2_withdrawals' | 'optimistic_l2_txn_batches' | 'optimistic_l2_deposits' |
'shibarium_deposits' | 'shibarium_withdrawals' |
'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' |
'zkevm_l2_deposits' | 'zkevm_l2_withdrawals' | 'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' |
'zksync_l2_txn_batches' | 'zksync_l2_txn_batch_txs' |
'withdrawals' | 'address_withdrawals' | 'block_withdrawals' |
'watchlist' | 'private_tags_address' | 'private_tags_tx' |
......@@ -902,19 +946,16 @@ Q extends 'visualize_sol2uml' ? VisualizedContract :
Q extends 'contract_verification_config' ? SmartContractVerificationConfig :
Q extends 'withdrawals' ? WithdrawalsResponse :
Q extends 'withdrawals_counters' ? WithdrawalsCounters :
Q extends 'l2_output_roots' ? OptimisticL2OutputRootsResponse :
Q extends 'l2_withdrawals' ? OptimisticL2WithdrawalsResponse :
Q extends 'l2_deposits' ? OptimisticL2DepositsResponse :
Q extends 'l2_txn_batches' ? OptimisticL2TxnBatchesResponse :
Q extends 'l2_output_roots_count' ? number :
Q extends 'l2_withdrawals_count' ? number :
Q extends 'l2_deposits_count' ? number :
Q extends 'l2_txn_batches_count' ? number :
Q extends 'zkevm_l2_txn_batches' ? ZkEvmL2TxnBatchesResponse :
Q extends 'zkevm_l2_txn_batches_count' ? number :
Q extends 'zkevm_l2_txn_batch' ? ZkEvmL2TxnBatch :
Q extends 'zkevm_l2_txn_batch_txs' ? ZkEvmL2TxnBatchTxs :
Q extends 'optimistic_l2_output_roots' ? OptimisticL2OutputRootsResponse :
Q extends 'optimistic_l2_withdrawals' ? OptimisticL2WithdrawalsResponse :
Q extends 'optimistic_l2_deposits' ? OptimisticL2DepositsResponse :
Q extends 'optimistic_l2_txn_batches' ? OptimisticL2TxnBatchesResponse :
Q extends 'optimistic_l2_output_roots_count' ? number :
Q extends 'optimistic_l2_withdrawals_count' ? number :
Q extends 'optimistic_l2_deposits_count' ? number :
Q extends 'optimistic_l2_txn_batches_count' ? number :
Q extends 'config_backend_version' ? BackendVersionConfig :
Q extends 'address_metadata_info' ? AddressMetadataInfo :
never;
// !!! IMPORTANT !!!
// See comment above
......@@ -931,6 +972,14 @@ Q extends 'shibarium_withdrawals' ? ShibariumWithdrawalsResponse :
Q extends 'shibarium_deposits' ? ShibariumDepositsResponse :
Q extends 'shibarium_withdrawals_count' ? number :
Q extends 'shibarium_deposits_count' ? number :
Q extends 'zkevm_l2_deposits' ? ZkEvmL2DepositsResponse :
Q extends 'zkevm_l2_deposits_count' ? number :
Q extends 'zkevm_l2_withdrawals' ? ZkEvmL2WithdrawalsResponse :
Q extends 'zkevm_l2_withdrawals_count' ? number :
Q extends 'zkevm_l2_txn_batches' ? ZkEvmL2TxnBatchesResponse :
Q extends 'zkevm_l2_txn_batches_count' ? number :
Q extends 'zkevm_l2_txn_batch' ? ZkEvmL2TxnBatch :
Q extends 'zkevm_l2_txn_batch_txs' ? ZkEvmL2TxnBatchTxs :
Q extends 'zksync_l2_txn_batches' ? ZkSyncBatchesResponse :
Q extends 'zksync_l2_txn_batches_count' ? number :
Q extends 'zksync_l2_txn_batch' ? ZkSyncBatch :
......
import React, { createContext, useContext } from 'react';
import type { Route } from 'nextjs-routes';
import type { Props as PageProps } from 'nextjs/getServerSideProps';
type Props = {
......@@ -23,6 +24,6 @@ export function AppContextProvider({ children, pageProps }: Props) {
);
}
export function useAppContext() {
return useContext(AppContext);
export function useAppContext<Pathname extends Route['pathname'] = never>() {
return useContext<PageProps<Pathname>>(AppContext);
}
import { useRouter } from 'next/router';
import React from 'react';
import type { Address } from 'types/api/address';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import * as stubs from 'stubs/contract';
import ContractCode from 'ui/address/contract/ContractCode';
import ContractRead from 'ui/address/contract/ContractRead';
import ContractWrite from 'ui/address/contract/ContractWrite';
export default function useContractTabs(data: Address | undefined) {
const CONTRACT_TAB_IDS = [
'contract_code',
'read_contract',
'read_proxy',
'read_custom_methods',
'write_contract',
'write_proxy',
'write_custom_methods',
] as const;
interface ContractTab {
id: typeof CONTRACT_TAB_IDS[number];
title: string;
component: JSX.Element;
}
interface ReturnType {
tabs: Array<ContractTab>;
isLoading: boolean;
}
export default function useContractTabs(data: Address | undefined, isPlaceholderData: boolean): ReturnType {
const [ isQueryEnabled, setIsQueryEnabled ] = React.useState(false);
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
const isEnabled = Boolean(data?.hash) && !isPlaceholderData && CONTRACT_TAB_IDS.concat('contract' as never).includes(tab);
const enableQuery = React.useCallback(() => {
setIsQueryEnabled(true);
}, []);
const contractQuery = useApiQuery('contract', {
pathParams: { hash: data?.hash },
queryOptions: {
enabled: isEnabled && isQueryEnabled,
refetchOnMount: false,
placeholderData: data?.is_verified ? stubs.CONTRACT_CODE_VERIFIED : stubs.CONTRACT_CODE_UNVERIFIED,
},
});
const channel = useSocketChannel({
topic: `addresses:${ data?.hash?.toLowerCase() }`,
isDisabled: !isEnabled,
onJoin: enableQuery,
onSocketError: enableQuery,
});
return React.useMemo(() => {
return [
{ id: 'contact_code', title: 'Code', component: <ContractCode addressHash={ data?.hash }/> },
// this is not implemented in api yet
// data?.has_decompiled_code ?
// { id: 'contact_decompiled_code', title: 'Decompiled code', component: <div>Decompiled code</div> } :
// undefined,
data?.has_methods_read ?
{ id: 'read_contract', title: 'Read contract', component: <ContractRead/> } :
undefined,
data?.has_methods_read_proxy ?
{ id: 'read_proxy', title: 'Read proxy', component: <ContractRead/> } :
undefined,
data?.has_custom_methods_read ?
{ id: 'read_custom_methods', title: 'Read custom', component: <ContractRead/> } :
undefined,
data?.has_methods_write ?
{ id: 'write_contract', title: 'Write contract', component: <ContractWrite/> } :
undefined,
data?.has_methods_write_proxy ?
{ id: 'write_proxy', title: 'Write proxy', component: <ContractWrite/> } :
undefined,
data?.has_custom_methods_write ?
{ id: 'write_custom_methods', title: 'Write custom', component: <ContractWrite/> } :
undefined,
].filter(Boolean);
}, [ data ]);
return {
tabs: [
{
id: 'contract_code' as const,
title: 'Code',
component: <ContractCode contractQuery={ contractQuery } channel={ channel } addressHash={ data?.hash }/>,
},
contractQuery.data?.has_methods_read ?
{ id: 'read_contract' as const, title: 'Read contract', component: <ContractRead isLoading={ contractQuery.isPlaceholderData }/> } :
undefined,
contractQuery.data?.has_methods_read_proxy ?
{ id: 'read_proxy' as const, title: 'Read proxy', component: <ContractRead isLoading={ contractQuery.isPlaceholderData }/> } :
undefined,
contractQuery.data?.has_custom_methods_read ?
{ id: 'read_custom_methods' as const, title: 'Read custom', component: <ContractRead isLoading={ contractQuery.isPlaceholderData }/> } :
undefined,
contractQuery.data?.has_methods_write ?
{ id: 'write_contract' as const, title: 'Write contract', component: <ContractWrite isLoading={ contractQuery.isPlaceholderData }/> } :
undefined,
contractQuery.data?.has_methods_write_proxy ?
{ id: 'write_proxy' as const, title: 'Write proxy', component: <ContractWrite isLoading={ contractQuery.isPlaceholderData }/> } :
undefined,
contractQuery.data?.has_custom_methods_write ?
{ id: 'write_custom_methods' as const, title: 'Write custom', component: <ContractWrite isLoading={ contractQuery.isPlaceholderData }/> } :
undefined,
].filter(Boolean),
isLoading: contractQuery.isPlaceholderData,
};
}, [ contractQuery, channel, data?.hash ]);
}
......@@ -72,45 +72,45 @@ export default function useNavItems(): ReturnType {
icon: 'validator',
isActive: pathname === '/validators',
} : null;
const rollupDeposits = {
text: `Deposits (L1${ rightLineArrow }L2)`,
nextRoute: { pathname: '/deposits' as const },
icon: 'arrows/south-east',
isActive: pathname === '/deposits',
};
const rollupWithdrawals = {
text: `Withdrawals (L2${ rightLineArrow }L1)`,
nextRoute: { pathname: '/withdrawals' as const },
icon: 'arrows/north-east',
isActive: pathname === '/withdrawals',
};
const rollupTxnBatches = {
text: 'Txn batches',
nextRoute: { pathname: '/batches' as const },
icon: 'txn_batches',
isActive: pathname === '/batches',
};
const rollupOutputRoots = {
text: 'Output roots',
nextRoute: { pathname: '/output-roots' as const },
icon: 'output_roots',
isActive: pathname === '/output-roots',
};
const rollupFeature = config.features.rollup;
if (rollupFeature.isEnabled && rollupFeature.type === 'zkEvm') {
if (rollupFeature.isEnabled && (rollupFeature.type === 'optimistic' || rollupFeature.type === 'zkEvm')) {
blockchainNavItems = [
[
txs,
userOps,
blocks,
{
text: 'Txn batches',
nextRoute: { pathname: '/batches' as const },
icon: 'txn_batches',
isActive: pathname === '/batches' || pathname === '/batches/[number]',
},
].filter(Boolean),
[
topAccounts,
validators,
verifiedContracts,
ensLookup,
].filter(Boolean),
];
} else if (rollupFeature.isEnabled && rollupFeature.type === 'optimistic') {
blockchainNavItems = [
[
txs,
// eslint-disable-next-line max-len
{ text: `Deposits (L1${ rightLineArrow }L2)`, nextRoute: { pathname: '/deposits' as const }, icon: 'arrows/south-east', isActive: pathname === '/deposits' },
// eslint-disable-next-line max-len
{ text: `Withdrawals (L2${ rightLineArrow }L1)`, nextRoute: { pathname: '/withdrawals' as const }, icon: 'arrows/north-east', isActive: pathname === '/withdrawals' },
rollupDeposits,
rollupWithdrawals,
],
[
blocks,
// eslint-disable-next-line max-len
{ text: 'Txn batches', nextRoute: { pathname: '/batches' as const }, icon: 'txn_batches', isActive: pathname === '/batches' },
// eslint-disable-next-line max-len
{ text: 'Output roots', nextRoute: { pathname: '/output-roots' as const }, icon: 'output_roots', isActive: pathname === '/output-roots' },
],
rollupTxnBatches,
rollupFeature.type === 'optimistic' ? rollupOutputRoots : undefined,
].filter(Boolean),
[
userOps,
topAccounts,
......@@ -123,10 +123,8 @@ export default function useNavItems(): ReturnType {
blockchainNavItems = [
[
txs,
// eslint-disable-next-line max-len
{ text: `Deposits (L1${ rightLineArrow }L2)`, nextRoute: { pathname: '/deposits' as const }, icon: 'arrows/south-east', isActive: pathname === '/deposits' },
// eslint-disable-next-line max-len
{ text: `Withdrawals (L2${ rightLineArrow }L1)`, nextRoute: { pathname: '/withdrawals' as const }, icon: 'arrows/north-east', isActive: pathname === '/withdrawals' },
rollupDeposits,
rollupWithdrawals,
],
[
blocks,
......@@ -142,12 +140,7 @@ export default function useNavItems(): ReturnType {
txs,
userOps,
blocks,
{
text: 'Txn batches',
nextRoute: { pathname: '/batches' as const },
icon: 'txn_batches',
isActive: pathname === '/batches' || pathname === '/batches/[number]',
},
rollupTxnBatches,
].filter(Boolean),
[
topAccounts,
......
import type { TokenInfo } from 'types/api/token';
import type { Route } from 'nextjs-routes';
/* eslint-disable @typescript-eslint/indent */
export type ApiData<Pathname extends Route['pathname']> =
(
Pathname extends '/address/[hash]' ? { domain_name: string } :
Pathname extends '/token/[hash]' ? { symbol: string } :
Pathname extends '/token/[hash]' ? TokenInfo :
Pathname extends '/token/[hash]/instance/[id]' ? { symbol: string } :
Pathname extends '/apps/[id]' ? { app_name: string } :
never
......
......@@ -59,14 +59,8 @@ export const token: Address = {
creator_address_hash: '0x34A9c688512ebdB575e82C50c9803F6ba2916E72',
exchange_rate: null,
implementation_address: null,
has_custom_methods_read: false,
has_custom_methods_write: false,
has_decompiled_code: false,
has_logs: false,
has_methods_read: false,
has_methods_read_proxy: false,
has_methods_write: false,
has_methods_write_proxy: false,
has_token_transfers: true,
has_tokens: true,
has_validated_blocks: false,
......@@ -79,14 +73,8 @@ export const contract: Address = {
creation_tx_hash: '0xf2aff6501b632604c39978b47d309813d8a1bcca721864bbe86abf59704f195e',
creator_address_hash: '0x803ad3F50b9e1fF68615e8B053A186e1be288943',
exchange_rate: '0.04311',
has_custom_methods_read: false,
has_custom_methods_write: false,
has_decompiled_code: false,
has_logs: true,
has_methods_read: true,
has_methods_read_proxy: true,
has_methods_write: true,
has_methods_write_proxy: true,
has_token_transfers: false,
has_tokens: false,
has_validated_blocks: false,
......@@ -110,14 +98,8 @@ export const validator: Address = {
creation_tx_hash: null,
creator_address_hash: null,
exchange_rate: '0.00432018',
has_custom_methods_read: false,
has_custom_methods_write: false,
has_decompiled_code: false,
has_logs: false,
has_methods_read: false,
has_methods_read_proxy: false,
has_methods_write: false,
has_methods_write_proxy: false,
has_token_transfers: false,
has_tokens: false,
has_validated_blocks: true,
......
/* eslint-disable max-len */
import type { SmartContract } from 'types/api/contract';
export const verified: Partial<SmartContract> = {
export const verified: SmartContract = {
abi: [ { anonymous: false, inputs: [ { indexed: true, internalType: 'address', name: 'src', type: 'address' }, { indexed: true, internalType: 'address', name: 'guy', type: 'address' }, { indexed: false, internalType: 'uint256', name: 'wad', type: 'uint256' } ], name: 'Approval', type: 'event' } ],
can_be_visualized_via_sol2uml: true,
compiler_version: 'v0.5.16+commit.9c3226ce',
......@@ -32,9 +32,26 @@ export const verified: Partial<SmartContract> = {
],
language: 'solidity',
license_type: 'gnu_gpl_v3',
has_methods_read: true,
has_methods_read_proxy: false,
has_methods_write: true,
has_methods_write_proxy: false,
has_custom_methods_read: false,
has_custom_methods_write: false,
is_self_destructed: false,
is_verified_via_eth_bytecode_db: null,
is_changed_bytecode: null,
is_verified_via_sourcify: null,
is_fully_verified: null,
is_partially_verified: null,
sourcify_repo_url: null,
file_path: '',
additional_sources: [],
verified_twin_address_hash: null,
minimal_proxy_address_hash: null,
};
export const withMultiplePaths: Partial<SmartContract> = {
export const withMultiplePaths: SmartContract = {
...verified,
file_path: './simple_storage.sol',
additional_sources: [
......@@ -45,7 +62,7 @@ export const withMultiplePaths: Partial<SmartContract> = {
],
};
export const verifiedViaSourcify: Partial<SmartContract> = {
export const verifiedViaSourcify: SmartContract = {
...verified,
is_verified_via_sourcify: true,
is_fully_verified: false,
......@@ -53,36 +70,67 @@ export const verifiedViaSourcify: Partial<SmartContract> = {
sourcify_repo_url: 'https://repo.sourcify.dev/contracts//full_match/99/0x51891596E158b2857e5356DC847e2D15dFbCF2d0/',
};
export const verifiedViaEthBytecodeDb: Partial<SmartContract> = {
export const verifiedViaEthBytecodeDb: SmartContract = {
...verified,
is_verified_via_eth_bytecode_db: true,
};
export const withTwinAddress: Partial<SmartContract> = {
export const withTwinAddress: SmartContract = {
...verified,
is_verified: false,
verified_twin_address_hash: '0xa62744bee8646e237441cdbfdedd3458861748a8',
};
export const withProxyAddress: Partial<SmartContract> = {
export const withProxyAddress: SmartContract = {
...verified,
is_verified: false,
verified_twin_address_hash: '0xa62744bee8646e237441cdbfdedd3458861748a8',
minimal_proxy_address_hash: '0xa62744bee8646e237441cdbfdedd3458861748a8',
};
export const selfDestructed: Partial<SmartContract> = {
export const selfDestructed: SmartContract = {
...verified,
is_self_destructed: true,
};
export const withChangedByteCode: Partial<SmartContract> = {
export const withChangedByteCode: SmartContract = {
...verified,
is_changed_bytecode: true,
};
export const nonVerified: Partial<SmartContract> = {
export const nonVerified: SmartContract = {
is_verified: false,
creation_bytecode: 'creation_bytecode',
deployed_bytecode: 'deployed_bytecode',
is_self_destructed: false,
abi: null,
compiler_version: null,
evm_version: null,
optimization_enabled: null,
optimization_runs: null,
name: null,
verified_at: null,
is_verified_via_eth_bytecode_db: null,
is_changed_bytecode: null,
has_methods_read: false,
has_methods_read_proxy: false,
has_methods_write: false,
has_methods_write_proxy: false,
has_custom_methods_read: false,
has_custom_methods_write: false,
is_verified_via_sourcify: null,
is_fully_verified: null,
is_partially_verified: null,
sourcify_repo_url: null,
source_code: null,
constructor_args: null,
decoded_constructor_args: null,
can_be_visualized_via_sol2uml: null,
file_path: '',
additional_sources: [],
external_libraries: null,
verified_twin_address_hash: null,
minimal_proxy_address_hash: null,
language: null,
license_type: null,
};
import type { AddressMetadataInfo, AddressMetadataTag } from 'types/api/addressMetadata';
import { hash } from '../address/address';
export const nameTag1: AddressMetadataTag = {
slug: 'ethermineru',
name: 'Ethermine.ru',
tagType: 'name',
ordinal: 0,
meta: null,
};
export const genericTag1: AddressMetadataTag = {
slug: 'ethermine.ru',
name: 'Ethermine.ru',
tagType: 'generic',
ordinal: 0,
meta: null,
};
export const protocolTag1: AddressMetadataTag = {
slug: 'aerodrome',
name: 'Aerodrome',
tagType: 'protocol',
ordinal: 0,
meta: null,
};
export const baseInfo: AddressMetadataInfo = {
addresses: {
[hash]: {
tags: [ nameTag1, genericTag1, protocolTag1 ],
reputation: null,
},
},
};
import type { ZkEvmL2DepositsResponse } from 'types/api/zkEvmL2';
export const baseResponse: ZkEvmL2DepositsResponse = {
items: [
{
block_number: 19681943,
index: 182177,
l1_transaction_hash: '0x29074452f976064aca1ca5c6e7c82d890c10454280693e6eca0257ae000c8e85',
l2_transaction_hash: null,
symbol: 'DAI',
timestamp: '2022-04-18T11:08:11.000000Z',
value: '0.003',
},
{
block_number: 19681894,
index: 182176,
l1_transaction_hash: '0x0b7d58c0a6b4695ba28d99df928591fb931c812c0aab6d0093ff5040d2f9bc5e',
l2_transaction_hash: '0x210d9f70f411de1079e32a98473b04345a5ea6ff2340a8511ebc2df641274436',
symbol: 'ETH',
timestamp: '2022-04-18T10:58:23.000000Z',
value: '0.0046651390188845',
},
],
next_page_params: {
items_count: 50,
index: 1,
},
};
import type { ZkEvmL2TxnBatch } from 'types/api/zkEvmL2';
import type { ZkEvmL2TxnBatch, ZkEvmL2TxnBatchesResponse } from 'types/api/zkEvmL2';
export const txnBatchData: ZkEvmL2TxnBatch = {
acc_input_hash: '0x4bf88aabe33713b7817266d7860912c58272d808da7397cdc627ca53b296fad3',
......@@ -13,3 +13,28 @@ export const txnBatchData: ZkEvmL2TxnBatch = {
],
verify_tx_hash: '0x6f7eeaa0eb966e63d127bba6bf8f9046d303c2a1185b542f0b5083f682a0e87f',
};
export const txnBatchesData: ZkEvmL2TxnBatchesResponse = {
items: [
{
timestamp: '2023-06-01T14:46:48.000000Z',
status: 'Finalized',
verify_tx_hash: '0x48139721f792d3a68c3781b4cf50e66e8fc7dbb38adff778e09066ea5be9adb8',
sequence_tx_hash: '0x6aa081e8e33a085e4ec7124fcd8a5f7d36aac0828f176e80d4b70e313a11695b',
number: 5218590,
tx_count: 9,
},
{
timestamp: '2023-06-01T14:46:48.000000Z',
status: 'Unfinalized',
verify_tx_hash: null,
sequence_tx_hash: null,
number: 5218591,
tx_count: 9,
},
],
next_page_params: {
number: 5902834,
items_count: 50,
},
};
import type { ZkEvmL2WithdrawalsResponse } from 'types/api/zkEvmL2';
export const baseResponse: ZkEvmL2WithdrawalsResponse = {
items: [
{
block_number: 11722417,
index: 47040,
l1_transaction_hash: null,
l2_transaction_hash: '0x68c378e412e51553524545ef1d3f00f69496fb37827c0b3b7e0870d245970408',
symbol: 'ETH',
timestamp: '2022-04-18T09:20:37.000000Z',
value: '0.025',
},
{
block_number: 11722480,
index: 47041,
l1_transaction_hash: '0xbf76feb85b8b8f24dacb17f962dd359f82efc512928d7b11ffca92fb812ad6a5',
l2_transaction_hash: '0xfe3c168ac1751b8399f1e819f1d83ee4cf764128bc604d454abee29114dabf49',
symbol: 'ETH',
timestamp: '2022-04-18T09:23:45.000000Z',
value: '4',
},
],
next_page_params: {
items_count: 50,
index: 1,
},
};
import type { ZkEvmL2TxnBatchesResponse } from 'types/api/zkEvmL2';
export const txnBatchesData: ZkEvmL2TxnBatchesResponse = {
items: [
{
timestamp: '2023-06-01T14:46:48.000000Z',
status: 'Finalized',
verify_tx_hash: '0x48139721f792d3a68c3781b4cf50e66e8fc7dbb38adff778e09066ea5be9adb8',
sequence_tx_hash: '0x6aa081e8e33a085e4ec7124fcd8a5f7d36aac0828f176e80d4b70e313a11695b',
number: 5218590,
tx_count: 9,
},
{
timestamp: '2023-06-01T14:46:48.000000Z',
status: 'Unfinalized',
verify_tx_hash: null,
sequence_tx_hash: null,
number: 5218591,
tx_count: 9,
},
],
next_page_params: {
number: 5902834,
items_count: 50,
},
};
......@@ -16,6 +16,10 @@ export function walletConnect(): CspDev.DirectiveDescriptor {
'wss://relay.walletconnect.com',
'wss://www.walletlink.org',
],
'frame-ancestors': [
'*.walletconnect.org',
'*.walletconnect.com',
],
'img-src': [
KEY_WORDS.BLOB,
'*.walletconnect.com',
......
......@@ -67,7 +67,7 @@ export const verifiedAddresses: GetServerSideProps<Props> = async(context) => {
};
export const deposits: GetServerSideProps<Props> = async(context) => {
if (!(rollupFeature.isEnabled && (rollupFeature.type === 'optimistic' || rollupFeature.type === 'shibarium'))) {
if (!(rollupFeature.isEnabled && (rollupFeature.type === 'optimistic' || rollupFeature.type === 'shibarium' || rollupFeature.type === 'zkEvm'))) {
return {
notFound: true,
};
......@@ -79,7 +79,7 @@ export const deposits: GetServerSideProps<Props> = async(context) => {
export const withdrawals: GetServerSideProps<Props> = async(context) => {
if (
!config.features.beaconChain.isEnabled &&
!(rollupFeature.isEnabled && (rollupFeature.type === 'optimistic' || rollupFeature.type === 'shibarium'))
!(rollupFeature.isEnabled && (rollupFeature.type === 'optimistic' || rollupFeature.type === 'shibarium' || rollupFeature.type === 'zkEvm'))
) {
return {
notFound: true,
......
......@@ -16,7 +16,11 @@ const Deposits = dynamic(() => {
return import('ui/pages/ShibariumDeposits');
}
throw new Error('Withdrawals feature is not enabled.');
if (rollupFeature.isEnabled && rollupFeature.type === 'zkEvm') {
return import('ui/pages/ZkEvmL2Deposits');
}
throw new Error('Deposits feature is not enabled.');
}, { ssr: false });
const Page: NextPage = () => {
......
import type { GetServerSideProps, NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Route } from 'nextjs-routes';
......@@ -11,8 +10,7 @@ import fetchApi from 'nextjs/utils/fetchApi';
import config from 'configs/app';
import getQueryParamString from 'lib/router/getQueryParamString';
const Token = dynamic(() => import('ui/pages/Token'), { ssr: false });
import Token from 'ui/pages/Token';
const pathname: Route['pathname'] = '/token/[hash]';
......@@ -29,19 +27,18 @@ export default Page;
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx);
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse) {
const botInfo = detectBotRequest(ctx.req);
if (botInfo?.type === 'social_preview') {
if ('props' in baseResponse) {
if (
config.meta.seo.enhancedDataEnabled ||
(config.meta.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview')
) {
const tokenData = await fetchApi({
resource: 'token',
pathParams: { hash: getQueryParamString(ctx.query.hash) },
timeout: 1_000,
timeout: 500,
});
(await baseResponse.props).apiData = tokenData && tokenData.symbol ? {
symbol: tokenData.symbol,
} : null;
(await baseResponse.props).apiData = tokenData ?? null;
}
}
......
......@@ -17,6 +17,10 @@ const Withdrawals = dynamic(() => {
return import('ui/pages/ShibariumWithdrawals');
}
if (rollupFeature.isEnabled && rollupFeature.type === 'zkEvm') {
return import('ui/pages/ZkEvmL2Withdrawals');
}
if (beaconChainFeature.isEnabled) {
return import('ui/pages/BeaconChainWithdrawals');
}
......
......@@ -2,14 +2,16 @@ import { ChakraProvider } from '@chakra-ui/react';
import { GrowthBookProvider } from '@growthbook/growthbook-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { WagmiProvider } from 'wagmi';
import { http } from 'viem';
import { WagmiProvider, createConfig } from 'wagmi';
import { sepolia } from 'wagmi/chains';
import { mock } from 'wagmi/connectors';
import type { Props as PageProps } from 'nextjs/getServerSideProps';
import config from 'configs/app';
import { AppContextProvider } from 'lib/contexts/app';
import { SocketProvider } from 'lib/socket/context';
import wagmiConfig from 'lib/web3/wagmiConfig';
import * as app from 'playwright/utils/app';
import theme from 'theme';
......@@ -31,6 +33,20 @@ const defaultAppContext = {
},
};
const wagmiConfig = createConfig({
chains: [ sepolia ],
connectors: [
mock({
accounts: [
'0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
],
}),
],
transports: {
[sepolia.id]: http(),
},
});
const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props) => {
const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: {
......@@ -47,7 +63,7 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props
<SocketProvider url={ withSocket ? `ws://${ config.app.host }:${ app.socketPort }` : undefined }>
<AppContextProvider { ...appContext }>
<GrowthBookProvider>
<WagmiProvider config={ wagmiConfig! }>
<WagmiProvider config={ wagmiConfig }>
{ children }
</WagmiProvider>
</GrowthBookProvider>
......
......@@ -20,6 +20,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'shibarium' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
],
zkEvmRollup: [
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'zkEvm' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
],
bridgedTokens: [
[ 'NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS', '[{"id":"1","title":"Ethereum","short_title":"ETH","base_url":"https://eth.blockscout.com/token/"},{"id":"56","title":"Binance Smart Chain","short_title":"BSC","base_url":"https://bscscan.com/token/"},{"id":"99","title":"POA","short_title":"POA","base_url":"https://blockscout.com/poa/core/token/"}]' ],
[ 'NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES', '[{"type":"omni","title":"OmniBridge","short_title":"OMNI"},{"type":"amb","title":"Arbitrary Message Bridge","short_title":"AMB"}]' ],
......@@ -27,4 +31,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
userOps: [
[ 'NEXT_PUBLIC_HAS_USER_OPS', 'true' ],
],
hasContractAuditReports: [
[ 'NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS', 'true' ],
],
blockHiddenFields: [
[ 'NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS', '["burnt_fees", "total_reward", "nonce"]' ],
],
};
import type { TestFixture, Page } from '@playwright/test';
import * as textAdMock from 'mocks/ad/textAd';
export type MockTextAdFixture = () => Promise<void>;
const fixture: TestFixture<MockTextAdFixture, { page: Page }> = async({ page }, use) => {
await use(async() => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: JSON.stringify(textAdMock.duck),
}));
await page.route(textAdMock.duck.ad.thumbnail, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_s.jpg',
});
});
});
};
export default fixture;
/* eslint-disable no-console */
import { test as base } from '@playwright/experimental-ct-react';
import * as textAdMock from 'mocks/ad/textAd';
import * as injectMetaMaskProvider from './fixtures/injectMetaMaskProvider';
import * as mockApiResponse from './fixtures/mockApiResponse';
import * as mockAssetResponse from './fixtures/mockAssetResponse';
import * as mockConfigResponse from './fixtures/mockConfigResponse';
import * as mockEnvs from './fixtures/mockEnvs';
import * as mockFeatures from './fixtures/mockFeatures';
import * as mockTextAd from './fixtures/mockTextAd';
import * as render from './fixtures/render';
import * as socketServer from './fixtures/socketServer';
......@@ -21,6 +20,7 @@ interface Fixtures {
mockFeatures: mockFeatures.MockFeaturesFixture;
createSocket: socketServer.CreateSocketFixture;
injectMetaMaskProvider: injectMetaMaskProvider.InjectMetaMaskProvider;
mockTextAd: mockTextAd.MockTextAdFixture;
}
const test = base.extend<Fixtures>({
......@@ -30,11 +30,15 @@ const test = base.extend<Fixtures>({
mockConfigResponse: mockConfigResponse.default,
mockEnvs: mockEnvs.default,
mockFeatures: mockFeatures.default,
// FIXME: for some reason Playwright does not intercept requests to text ad provider when running multiple tests in parallel
// even if we have a global request interceptor (maybe it is related to service worker issue, maybe not)
// so we have to inject mockTextAd fixture in each test and mock the response where it is needed
mockTextAd: mockTextAd.default,
createSocket: socketServer.createSocket,
injectMetaMaskProvider: injectMetaMaskProvider.default,
});
test.beforeEach(async({ page }) => {
test.beforeEach(async({ page, mockTextAd }) => {
// debug
const isDebug = process.env.PWDEBUG === '1';
......@@ -56,16 +60,7 @@ test.beforeEach(async({ page }) => {
// with few exceptions:
// 1. mock text AD requests
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: JSON.stringify(textAdMock.duck),
}));
await page.route(textAdMock.duck.ad.thumbnail, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_s.jpg',
});
});
await mockTextAd();
});
export * from '@playwright/experimental-ct-react';
......
......@@ -47,12 +47,6 @@ export const viewsEnvs = {
},
};
export const UIEnvs = {
hasContractAuditReports: [
{ name: 'NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS', value: 'true' },
],
};
export const stabilityEnvs = [
{ name: 'NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS', value: '["top_accounts"]' },
{ name: 'NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS', value: '["value","fee_currency","gas_price","gas_fees","burnt_fees"]' },
......
......@@ -19,22 +19,16 @@ export const ADDRESS_INFO: Address = {
creation_tx_hash: null,
creator_address_hash: ADDRESS_HASH,
exchange_rate: null,
has_custom_methods_read: false,
has_custom_methods_write: false,
has_decompiled_code: false,
has_logs: true,
has_methods_read: false,
has_methods_read_proxy: false,
has_methods_write: false,
has_methods_write_proxy: false,
has_token_transfers: false,
has_tokens: false,
has_validated_blocks: false,
hash: ADDRESS_HASH,
implementation_address: null,
implementation_name: null,
is_contract: false,
is_verified: false,
is_contract: true,
is_verified: true,
name: 'ChainLink Token (goerli)',
token: TOKEN_INFO_ERC_20,
private_tags: [],
......
......@@ -7,6 +7,12 @@ export const CONTRACT_CODE_UNVERIFIED = {
creation_bytecode: '0x60806040526e',
deployed_bytecode: '0x608060405233',
is_self_destructed: false,
has_methods_read: true,
has_methods_read_proxy: true,
has_methods_write: true,
has_methods_write_proxy: true,
has_custom_methods_read: true,
has_custom_methods_write: true,
} as SmartContract;
export const CONTRACT_CODE_VERIFIED = {
......@@ -41,6 +47,12 @@ export const CONTRACT_CODE_VERIFIED = {
source_code: 'source_code',
verified_at: '2023-02-21T14:39:16.906760Z',
license_type: 'mit',
has_methods_read: true,
has_methods_read_proxy: true,
has_methods_write: true,
has_methods_write_proxy: true,
has_custom_methods_read: true,
has_custom_methods_write: true,
} as unknown as SmartContract;
export const VERIFIED_CONTRACT_INFO: VerifiedContract = {
......
import type { ZkEvmL2TxnBatch, ZkEvmL2TxnBatchesItem } from 'types/api/zkEvmL2';
import type { ZkEvmL2DepositsItem, ZkEvmL2TxnBatch, ZkEvmL2TxnBatchesItem, ZkEvmL2WithdrawalsItem } from 'types/api/zkEvmL2';
import { TX_HASH } from './tx';
export const ZKEVM_DEPOSITS_ITEM: ZkEvmL2DepositsItem = {
block_number: 19674901,
index: 181920,
l1_transaction_hash: '0xa74edfa5824a07a5f95ca1145140ed589df7f05bb17796bf18090b14c4566b5d',
l2_transaction_hash: '0x436d1c7ada270466ca0facdb96ecc22934d68d13b8a08f541b8df11b222967b5',
symbol: 'ETH',
timestamp: '2023-06-01T14:46:48.000000Z',
value: '0.13040262',
};
export const ZKEVM_WITHDRAWALS_ITEM: ZkEvmL2WithdrawalsItem = {
block_number: 11692968,
index: 47003,
l1_transaction_hash: '0x230cf46dabea287ac7d0ba83b8ea120bb83c1de58a81d34f44788f0459096c52',
l2_transaction_hash: '0x519d9f025ec47f08a48d708964d177189d2246ddf988686c481f5debcf097e34',
symbol: 'ETH',
timestamp: '2024-04-17T08:51:58.000000Z',
value: '110.35',
};
export const ZKEVM_L2_TXN_BATCHES_ITEM: ZkEvmL2TxnBatchesItem = {
timestamp: '2023-06-01T14:46:48.000000Z',
status: 'Finalized',
......
import { defineStyle, defineStyleConfig } from '@chakra-ui/styled-system';
import { mode, transparentize } from '@chakra-ui/theme-tools';
import { mode } from '@chakra-ui/theme-tools';
const baseStyle = defineStyle({
fontSize: 'xs',
......@@ -8,19 +8,25 @@ const baseStyle = defineStyle({
});
const variantSubtle = defineStyle((props) => {
const { colorScheme: c, theme } = props;
const darkBg = transparentize(`${ c }.200`, 0.16)(theme);
const { colorScheme: c } = props;
if (c === 'gray') {
return {
bg: mode('blackAlpha.100', 'whiteAlpha.400')(props),
color: mode('gray.600', 'gray.50')(props),
bg: mode('blackAlpha.50', 'whiteAlpha.100')(props),
color: mode('blackAlpha.800', 'whiteAlpha.800')(props),
};
}
if (c === 'gray-blue') {
return {
bg: mode('gray.100', 'gray.800')(props),
color: mode('blackAlpha.800', 'whiteAlpha.800')(props),
};
}
return {
bg: mode(`${ c }.50`, darkBg)(props),
color: mode(`${ c }.500`, `${ c }.200`)(props),
bg: mode(`${ c }.50`, `${ c }.800`)(props),
color: mode(`${ c }.500`, `${ c }.100`)(props),
};
});
......
......@@ -4,7 +4,7 @@ import React from 'react';
import TestApp from 'playwright/TestApp';
[ 'blue', 'gray', 'orange', 'green', 'purple', 'cyan', 'teal' ].forEach((colorScheme) => {
[ 'blue', 'gray', 'gray-blue', 'orange', 'green', 'purple', 'cyan', 'teal' ].forEach((colorScheme) => {
test(`${ colorScheme } color scheme +@dark-mode`, async({ mount }) => {
const component = await mount(
<TestApp>
......
......@@ -18,7 +18,7 @@ const variants = {
};
const sizes = {
md: definePartsStyle({
sm: definePartsStyle({
container: {
minH: 6,
minW: 6,
......@@ -48,7 +48,7 @@ const Tag = defineMultiStyleConfig({
variants,
sizes,
defaultProps: {
size: 'md',
size: 'sm',
variant: 'subtle',
colorScheme: 'gray',
},
......
......@@ -15,14 +15,8 @@ export interface Address extends UserTags {
ens_domain_name: string | null;
// TODO: if we are happy with tabs-counters method, should we delete has_something fields?
has_beacon_chain_withdrawals?: boolean;
has_custom_methods_read: boolean;
has_custom_methods_write: boolean;
has_decompiled_code: boolean;
has_logs: boolean;
has_methods_read: boolean;
has_methods_read_proxy: boolean;
has_methods_write: boolean;
has_methods_write_proxy: boolean;
has_token_transfers: boolean;
has_tokens: boolean;
has_validated_blocks: boolean;
......
export interface AddressMetadataInfo {
addresses: Record<string, {
tags: Array<AddressMetadataTag>;
reputation: number | null;
}>;
}
export type AddressMetadataTagType = 'name' | 'generic' | 'classifier' | 'information' | 'note' | 'protocol';
export interface AddressMetadataTag {
slug: string;
name: string;
tagType: AddressMetadataTagType;
ordinal: number;
meta: string | null;
}
......@@ -33,6 +33,14 @@ export interface SmartContract {
is_verified: boolean | null;
is_verified_via_eth_bytecode_db: boolean | null;
is_changed_bytecode: boolean | null;
has_methods_read: boolean;
has_methods_read_proxy: boolean;
has_methods_write: boolean;
has_methods_write_proxy: boolean;
has_custom_methods_read: boolean;
has_custom_methods_write: boolean;
// sourcify info >>>
is_verified_via_sourcify: boolean | null;
is_fully_verified: boolean | null;
......
import type { Transaction } from './transaction';
export type ZkEvmL2DepositsItem = {
block_number: number;
index: number;
l1_transaction_hash: string;
l2_transaction_hash: string | null;
timestamp: string;
value: string;
symbol: string;
}
export type ZkEvmL2DepositsResponse = {
items: Array<ZkEvmL2DepositsItem>;
next_page_params: {
items_count: number;
index: number;
};
}
export type ZkEvmL2WithdrawalsItem = {
block_number: number;
index: number;
l1_transaction_hash: string | null;
l2_transaction_hash: string;
timestamp: string;
value: string;
symbol: string;
}
export type ZkEvmL2WithdrawalsResponse = {
items: Array<ZkEvmL2WithdrawalsItem>;
next_page_params: {
items_count: number;
index: number;
};
}
export type ZkEvmL2TxnBatchesItem = {
number: number;
verify_tx_hash: string | null;
......
......@@ -8,4 +8,8 @@ export type CsvExportParams = {
type: 'logs';
filterType?: 'topic';
filterValue?: string;
} | {
type: 'holders';
filterType?: undefined;
filterValue?: undefined;
}
import type { AddressMetadataTagType } from 'types/api/addressMetadata';
export interface AddressMetadataInfoFormatted {
addresses: Record<string, {
tags: Array<AddressMetadataTagFormatted>;
reputation: number | null;
}>;
}
export interface AddressMetadataTagFormatted {
slug: string;
name: string;
tagType: AddressMetadataTagType;
ordinal: number;
meta: {
textColor?: string;
bgColor?: string;
actionURL?: string;
} | null;
}
......@@ -7,24 +7,29 @@ import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
interface Props {
tabs: Array<RoutedSubTab>;
addressHash?: string;
isLoading: boolean;
shouldRender?: boolean;
}
const TAB_LIST_PROPS = {
columnGap: 3,
};
const AddressContract = ({ tabs }: Props) => {
const AddressContract = ({ tabs, isLoading, shouldRender }: Props) => {
const fallback = React.useCallback(() => {
const noProviderTabs = tabs.filter(({ id }) => id === 'contact_code' || id.startsWith('read_'));
const noProviderTabs = tabs.filter(({ id }) => id === 'contract_code' || id.startsWith('read_'));
return (
<RoutedTabs tabs={ noProviderTabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>
<RoutedTabs tabs={ noProviderTabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS } isLoading={ isLoading }/>
);
}, [ tabs ]);
}, [ isLoading, tabs ]);
if (!shouldRender) {
return null;
}
return (
<Web3ModalProvider fallback={ fallback }>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS } isLoading={ isLoading }/>
</Web3ModalProvider>
);
};
......
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as addressMock from 'mocks/address/address';
import { contractAudits } from 'mocks/contract/audits';
import * as contractMock from 'mocks/contract/info';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import MockAddressPage from 'ui/address/testUtils/MockAddressPage';
import { test, expect } from 'playwright/lib';
import ContractCode from './ContractCode';
import ContractCode from './specs/ContractCode';
const addressHash = 'hash';
const CONTRACT_API_URL = buildApiUrl('contract', { hash: addressHash });
const CONTRACT_AUDITS_API_URL = buildApiUrl('contract_security_audits', { hash: addressHash });
const hooksConfig = {
router: {
query: { hash: addressHash },
query: { hash: addressMock.contract.hash, tab: 'contract_code' },
},
};
const test = base.extend<socketServer.SocketServerFixture>({
createSocket: socketServer.createSocket,
});
// FIXME
// test cases which use socket cannot run in parallel since the socket server always run on the same port
test.describe.configure({ mode: 'serial' });
test('full view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.withChangedByteCode),
}));
const ADDRESS_API_URL = buildApiUrl('address', { hash: addressHash });
await page.route(ADDRESS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(addressMock.contract),
}));
const PROXY_CONTRACT_API_URL = buildApiUrl('contract', { hash: addressMock.contract.implementation_address as string });
await page.route(PROXY_CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.withChangedByteCode),
}));
const component = await mount(
<TestApp>
<MockAddressPage>
<ContractCode addressHash={ addressHash } noSocket/>
</MockAddressPage>
</TestApp>,
{ hooksConfig },
);
let addressApiUrl: string;
await expect(component).toHaveScreenshot();
test.beforeEach(async({ mockApiResponse }) => {
addressApiUrl = await mockApiResponse('address', addressMock.contract, { pathParams: { hash: addressMock.contract.hash } });
});
test('verified with changed byte code socket', async({ mount, page, createSocket }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.verified),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
test('full view +@mobile +@dark-mode', async({ render, mockApiResponse, createSocket }) => {
await mockApiResponse('contract', contractMock.withChangedByteCode, { pathParams: { hash: addressMock.contract.hash } });
await mockApiResponse('contract', contractMock.withChangedByteCode, { pathParams: { hash: addressMock.contract.implementation_address as string } });
const component = await mount(
<TestApp withSocket>
<ContractCode addressHash={ addressHash }/>
</TestApp>,
{ hooksConfig },
);
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
await createSocket();
await expect(component).toHaveScreenshot();
});
test('verified with changed byte code socket', async({ render, mockApiResponse, createSocket }) => {
await mockApiResponse('contract', contractMock.verified, { pathParams: { hash: addressMock.contract.hash } });
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'addresses:' + addressHash.toLowerCase());
const channel = await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
socketServer.sendMessage(socket, channel, 'changed_bytecode', {});
await expect(component).toHaveScreenshot();
});
test('verified via lookup in eth_bytecode_db', async({ mount, page, createSocket }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.nonVerified),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
await mount(
<TestApp withSocket>
<ContractCode addressHash={ addressHash }/>
</TestApp>,
{ hooksConfig },
);
test('verified via lookup in eth_bytecode_db', async({ render, mockApiResponse, createSocket, page }) => {
const contractApiUrl = await mockApiResponse('contract', contractMock.nonVerified, { pathParams: { hash: addressMock.contract.hash } });
await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'addresses:' + addressHash.toLowerCase());
await page.waitForResponse(CONTRACT_API_URL);
const channel = await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await page.waitForResponse(contractApiUrl);
socketServer.sendMessage(socket, channel, 'smart_contract_was_verified', {});
const request = await page.waitForRequest(CONTRACT_API_URL);
const request = await page.waitForRequest(addressApiUrl);
expect(request).toBeTruthy();
});
test('verified with multiple sources', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.withMultiplePaths),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
await mount(
<TestApp>
<ContractCode addressHash={ addressHash } noSocket/>
</TestApp>,
{ hooksConfig },
);
test('verified with multiple sources', async({ render, page, mockApiResponse }) => {
await mockApiResponse('contract', contractMock.withMultiplePaths, { pathParams: { hash: addressMock.contract.hash } });
await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
const section = page.locator('section', { hasText: 'Contract source code' });
await expect(section).toHaveScreenshot();
......@@ -131,155 +73,67 @@ test('verified with multiple sources', async({ mount, page }) => {
await expect(section).toHaveScreenshot();
});
test('verified via sourcify', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.verifiedViaSourcify),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
await mount(
<TestApp>
<ContractCode addressHash={ addressHash } noSocket/>
</TestApp>,
{ hooksConfig },
);
test('verified via sourcify', async({ render, mockApiResponse, page }) => {
await mockApiResponse('contract', contractMock.verifiedViaSourcify, { pathParams: { hash: addressMock.contract.hash } });
await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 110 } });
});
test('verified via eth bytecode db', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.verifiedViaEthBytecodeDb),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
await mount(
<TestApp>
<ContractCode addressHash={ addressHash } noSocket/>
</TestApp>,
{ hooksConfig },
);
test('verified via eth bytecode db', async({ render, mockApiResponse, page }) => {
await mockApiResponse('contract', contractMock.verifiedViaEthBytecodeDb, { pathParams: { hash: addressMock.contract.hash } });
await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 110 } });
});
test('self destructed', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.selfDestructed),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
await mount(
<TestApp>
<ContractCode addressHash={ addressHash } noSocket/>
</TestApp>,
{ hooksConfig },
);
test('self destructed', async({ render, mockApiResponse, page }) => {
await mockApiResponse('contract', contractMock.selfDestructed, { pathParams: { hash: addressMock.contract.hash } });
await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
const section = page.locator('section', { hasText: 'Contract creation code' });
await expect(section).toHaveScreenshot();
});
test('with twin address alert +@mobile', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.withTwinAddress),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const component = await mount(
<TestApp>
<ContractCode addressHash={ addressHash } noSocket/>
</TestApp>,
{ hooksConfig },
);
test('with twin address alert +@mobile', async({ render, mockApiResponse }) => {
await mockApiResponse('contract', contractMock.withTwinAddress, { pathParams: { hash: addressMock.contract.hash } });
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
await expect(component.getByRole('alert')).toHaveScreenshot();
});
test('with proxy address alert +@mobile', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.withProxyAddress),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const component = await mount(
<TestApp>
<ContractCode addressHash={ addressHash } noSocket/>
</TestApp>,
{ hooksConfig },
);
test('with proxy address alert +@mobile', async({ render, mockApiResponse }) => {
await mockApiResponse('contract', contractMock.withProxyAddress, { pathParams: { hash: addressMock.contract.hash } });
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
await expect(component.getByRole('alert')).toHaveScreenshot();
});
test('non verified', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.nonVerified),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const component = await mount(
<TestApp>
<ContractCode addressHash={ addressHash } noSocket/>
</TestApp>,
{ hooksConfig },
);
test('non verified', async({ render, mockApiResponse }) => {
await mockApiResponse('contract', contractMock.nonVerified, { pathParams: { hash: addressMock.contract.hash } });
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
await expect(component).toHaveScreenshot();
});
test.describe('with audits feature', () => {
const withAuditsTest = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.UIEnvs.hasContractAuditReports) as any,
test.beforeEach(async({ mockEnvs }) => {
await mockEnvs(ENVS_MAP.hasContractAuditReports);
});
withAuditsTest('no audits', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.verified),
}));
await page.route(CONTRACT_AUDITS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ items: [] }),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const component = await mount(
<TestApp>
<ContractCode addressHash={ addressHash } noSocket/>
</TestApp>,
{ hooksConfig },
);
test('no audits', async({ render, mockApiResponse }) => {
await mockApiResponse('contract', contractMock.verified, { pathParams: { hash: addressMock.contract.hash } });
await mockApiResponse('contract_security_audits', { items: [] }, { pathParams: { hash: addressMock.contract.hash } });
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
await expect(component).toHaveScreenshot();
});
withAuditsTest('has audits', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.verified),
}));
await page.route(CONTRACT_AUDITS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractAudits),
}));
await page.route('https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/**', (route) => route.abort());
const component = await mount(
<TestApp>
<ContractCode addressHash={ addressHash } noSocket/>
</TestApp>,
{ hooksConfig },
);
test('has audits', async({ render, mockApiResponse }) => {
await mockApiResponse('contract', contractMock.verified, { pathParams: { hash: addressMock.contract.hash } });
await mockApiResponse('contract_security_audits', contractAudits, { pathParams: { hash: addressMock.contract.hash } });
const component = await render(<ContractCode/>, { hooksConfig }, { withSocket: true });
await expect(component).toHaveScreenshot();
});
......
import { Flex, Skeleton, Button, Grid, GridItem, Alert, Link, chakra, Box, useColorModeValue } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import type { Channel } from 'phoenix';
import React from 'react';
import type { SocketMessage } from 'lib/socket/types';
import type { Address as AddressInfo } from 'types/api/address';
import type { SmartContract } from 'types/api/contract';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import type { ResourceError } from 'lib/api/resources';
import { getResourceKey } from 'lib/api/useApiQuery';
import { CONTRACT_LICENSES } from 'lib/contracts/licenses';
import dayjs from 'lib/date/dayjs';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import * as stubs from 'stubs/contract';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import Hint from 'ui/shared/Hint';
......@@ -26,8 +28,8 @@ import ContractSourceCode from './ContractSourceCode';
type Props = {
addressHash?: string;
// prop for pw tests only
noSocket?: boolean;
contractQuery: UseQueryResult<SmartContract, ResourceError<unknown>>;
channel: Channel | undefined;
}
type InfoItemProps = {
......@@ -57,21 +59,13 @@ const InfoItem = chakra(({ label, content, hint, className, isLoading }: InfoIte
</GridItem>
));
const ContractCode = ({ addressHash, noSocket }: Props) => {
const [ isQueryEnabled, setIsQueryEnabled ] = React.useState(false);
const ContractCode = ({ addressHash, contractQuery, channel }: Props) => {
const [ isChangedBytecodeSocket, setIsChangedBytecodeSocket ] = React.useState<boolean>();
const queryClient = useQueryClient();
const addressInfo = queryClient.getQueryData<AddressInfo>(getResourceKey('address', { pathParams: { hash: addressHash } }));
const { data, isPlaceholderData, isError } = useApiQuery('contract', {
pathParams: { hash: addressHash },
queryOptions: {
enabled: Boolean(addressHash) && (noSocket || isQueryEnabled),
refetchOnMount: false,
placeholderData: addressInfo?.is_verified ? stubs.CONTRACT_CODE_VERIFIED : stubs.CONTRACT_CODE_UNVERIFIED,
},
});
const { data, isPlaceholderData, isError } = contractQuery;
const handleChangedBytecodeMessage: SocketMessage.AddressChangedBytecode['handler'] = React.useCallback(() => {
setIsChangedBytecodeSocket(true);
......@@ -86,14 +80,6 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
});
}, [ addressHash, queryClient ]);
const enableQuery = React.useCallback(() => setIsQueryEnabled(true), []);
const channel = useSocketChannel({
topic: `addresses:${ addressHash?.toLowerCase() }`,
isDisabled: !addressHash,
onJoin: enableQuery,
onSocketError: enableQuery,
});
useSocketMessage({
channel,
event: 'changed_bytecode',
......
......@@ -19,7 +19,11 @@ import ContractReadResult from './ContractReadResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useWatchAccount from './useWatchAccount';
const ContractRead = () => {
interface Props {
isLoading?: boolean;
}
const ContractRead = ({ isLoading }: Props) => {
const apiFetch = useApiFetch();
const account = useWatchAccount();
const router = useRouter();
......@@ -36,7 +40,7 @@ const ContractRead = () => {
from: account?.address,
},
queryOptions: {
enabled: Boolean(addressHash),
enabled: !isLoading,
},
});
......
......@@ -19,7 +19,11 @@ import ContractMethodForm from './methodForm/ContractMethodForm';
import useContractAbi from './useContractAbi';
import { getNativeCoinValue, prepareAbi } from './utils';
const ContractWrite = () => {
interface Props {
isLoading?: boolean;
}
const ContractWrite = ({ isLoading }: Props) => {
const { data: walletClient } = useWalletClient();
const { isConnected, chainId } = useAccount();
const { switchChainAsync } = useSwitchChain();
......@@ -37,7 +41,7 @@ const ContractWrite = () => {
is_custom_abi: isCustomAbi ? 'true' : 'false',
},
queryOptions: {
enabled: Boolean(addressHash),
enabled: !isLoading,
refetchOnMount: false,
},
});
......
import { useRouter } from 'next/router';
import useApiQuery from 'lib/api/useApiQuery';
import useContractTabs from 'lib/hooks/useContractTabs';
import getQueryParamString from 'lib/router/getQueryParamString';
const ContractCode = () => {
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const addressQuery = useApiQuery('address', { pathParams: { hash } });
const { tabs } = useContractTabs(addressQuery.data, false);
const content = tabs.find(({ id }) => id === 'contract_code')?.component;
return content ?? null;
};
export default ContractCode;
......@@ -39,7 +39,7 @@ export default function useContractAbi({ addressHash, isProxy, isCustomAbi }: Pa
pathParams: { hash: addressHash },
queryParams: { is_custom_abi: 'true' },
queryOptions: {
enabled: Boolean(addressInfo?.has_custom_methods_write),
enabled: Boolean(contractInfo?.has_custom_methods_write),
refetchOnMount: false,
},
});
......
......@@ -74,14 +74,8 @@ export default function useAddressQuery({ hash }: Params): AddressQuery {
creation_tx_hash: null,
exchange_rate: null,
ens_domain_name: null,
has_custom_methods_read: false,
has_custom_methods_write: false,
has_decompiled_code: false,
has_logs: false,
has_methods_read: false,
has_methods_read_proxy: false,
has_methods_write: false,
has_methods_write_proxy: false,
has_token_transfers: false,
has_tokens: false,
has_validated_blocks: false,
......
......@@ -21,9 +21,10 @@ interface Props {
filterType?: CsvExportParams['filterType'] | null;
filterValue?: CsvExportParams['filterValue'] | null;
fileNameTemplate: string;
exportType: CsvExportParams['type'] | undefined;
}
const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTemplate }: Props) => {
const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTemplate, exportType }: Props) => {
const formApi = useForm<FormFields>({
mode: 'onBlur',
defaultValues: {
......@@ -36,10 +37,10 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => {
try {
const url = buildUrl(resource, undefined, {
const url = buildUrl(resource, { hash } as never, {
address_id: hash,
from_period: data.from,
to_period: data.to,
from_period: exportType !== 'holders' ? data.from : null,
to_period: exportType !== 'holders' ? data.to : null,
filter_type: filterType,
filter_value: filterValue,
recaptcha_response: data.reCaptcha,
......@@ -56,11 +57,11 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
}
const blob = await response.blob();
downloadBlob(
blob,
`${ fileNameTemplate }_${ hash }_${ data.from }_${ data.to }
${ filterType && filterValue ? '_with_filter_type_' + filterType + '_value_' + filterValue : '' }.csv`,
);
const fileName = exportType === 'holders' ?
`${ fileNameTemplate }_${ hash }.csv` :
// eslint-disable-next-line max-len
`${ fileNameTemplate }_${ hash }_${ data.from }_${ data.to }${ filterType && filterValue ? '_with_filter_type_' + filterType + '_value_' + filterValue : '' }.csv`;
downloadBlob(blob, fileName);
} catch (error) {
toast({
......@@ -73,7 +74,7 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
});
}
}, [ fileNameTemplate, hash, resource, filterType, filterValue, toast ]);
}, [ resource, hash, exportType, filterType, filterValue, fileNameTemplate, toast ]);
return (
<FormProvider { ...formApi }>
......@@ -82,8 +83,8 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
onSubmit={ handleSubmit(onFormSubmit) }
>
<Flex columnGap={ 5 } rowGap={ 3 } flexDir={{ base: 'column', lg: 'row' }} alignItems={{ base: 'flex-start', lg: 'center' }} flexWrap="wrap">
<CsvExportFormField name="from" formApi={ formApi }/>
<CsvExportFormField name="to" formApi={ formApi }/>
{ exportType !== 'holders' && <CsvExportFormField name="from" formApi={ formApi }/> }
{ exportType !== 'holders' && <CsvExportFormField name="to" formApi={ formApi }/> }
<CsvExportFormReCaptcha formApi={ formApi }/>
</Flex>
<Button
......
import { Skeleton, chakra } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
const rollupFeature = config.features.rollup;
type Props = { item: ZkEvmL2DepositsItem; isLoading?: boolean };
const ZkEvmL2DepositsListItem = ({ item, isLoading }: Props) => {
if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') {
return null;
}
const timeAgo = dayjs(item.timestamp).fromNow();
return (
<ListItemMobileGrid.Container>
<ListItemMobileGrid.Label isLoading={ isLoading }>L1 block</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<BlockEntityL1
number={ item.block_number }
isLoading={ isLoading }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 600 }
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Index</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">
{ item.index }
</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L1 txn hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<TxEntityL1
isLoading={ isLoading }
hash={ item.l1_transaction_hash }
fontSize="sm"
lineHeight={ 5 }
truncation="constant_long"
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L2 txn hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
{ item.l2_transaction_hash ? (
<TxEntity
isLoading={ isLoading }
hash={ item.l2_transaction_hash }
fontSize="sm"
lineHeight={ 5 }
truncation="constant_long"
/>
) : (
<chakra.span color="text_secondary">
Pending Claim
</chakra.span>
) }
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Value</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">
{ BigNumber(item.value).toFormat() }
</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Token</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">
{ item.symbol }
</Skeleton>
</ListItemMobileGrid.Value>
</ListItemMobileGrid.Container>
);
};
export default ZkEvmL2DepositsListItem;
import { Table, Tbody, Th, Tr } from '@chakra-ui/react';
import React from 'react';
import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2';
import { default as Thead } from 'ui/shared/TheadSticky';
import ZkEvmL2DepositsTableItem from './ZkEvmL2DepositsTableItem';
type Props = {
items: Array<ZkEvmL2DepositsItem>;
top: number;
isLoading?: boolean;
}
const ZkEvmL2DepositsTable = ({ items, top, isLoading }: Props) => {
return (
<Table variant="simple" size="sm" style={{ tableLayout: 'auto' }} minW="950px">
<Thead top={ top }>
<Tr>
<Th>L1 block</Th>
<Th>Index</Th>
<Th>L1 txn hash</Th>
<Th>Age</Th>
<Th>L2 txn hash</Th>
<Th isNumeric>Value</Th>
<Th>Token</Th>
</Tr>
</Thead>
<Tbody>
{ items.map((item, index) => (
<ZkEvmL2DepositsTableItem key={ String(item.index) + (isLoading ? index : '') } item={ item } isLoading={ isLoading }/>
)) }
</Tbody>
</Table>
);
};
export default ZkEvmL2DepositsTable;
import { Td, Tr, Skeleton, chakra } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
const rollupFeature = config.features.rollup;
type Props = { item: ZkEvmL2DepositsItem; isLoading?: boolean };
const ZkEvmL2DepositsTableItem = ({ item, isLoading }: Props) => {
if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') {
return null;
}
const timeAgo = dayjs(item.timestamp).fromNow();
return (
<Tr>
<Td verticalAlign="middle">
<BlockEntityL1
number={ item.block_number }
isLoading={ isLoading }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 600 }
noIcon
/>
</Td>
<Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading }>
<span>{ item.index }</span>
</Skeleton>
</Td>
<Td verticalAlign="middle">
<TxEntityL1
isLoading={ isLoading }
hash={ item.l1_transaction_hash }
truncation="constant_long"
noIcon
fontSize="sm"
lineHeight={ 5 }
/>
</Td>
<Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ timeAgo }</span>
</Skeleton>
</Td>
<Td verticalAlign="middle">
{ item.l2_transaction_hash ? (
<TxEntity
isLoading={ isLoading }
hash={ item.l2_transaction_hash }
fontSize="sm"
lineHeight={ 5 }
truncation="constant_long"
noIcon
/>
) : (
<chakra.span color="text_secondary">
Pending Claim
</chakra.span>
) }
</Td>
<Td verticalAlign="middle" isNumeric>
<Skeleton isLoaded={ !isLoading } display="inline-block">
<span>{ BigNumber(item.value).toFormat() }</span>
</Skeleton>
</Td>
<Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } display="inline-block">
<span>{ item.symbol }</span>
</Skeleton>
</Td>
</Tr>
);
};
export default ZkEvmL2DepositsTableItem;
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { txnBatchesData } from 'mocks/zkevmL2txnBatches/zkevmL2txnBatches';
import { txnBatchesData } from 'mocks/zkEvm/txnBatches';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
......
......@@ -72,7 +72,7 @@ const AddressPageContent = () => {
const isSafeAddress = useIsSafeAddress(!addressQuery.isPlaceholderData && addressQuery.data?.is_contract ? hash : undefined);
const safeIconColor = useColorModeValue('black', 'white');
const contractTabs = useContractTabs(addressQuery.data);
const contractTabs = useContractTabs(addressQuery.data, addressQuery.isPlaceholderData);
const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData);
const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData;
......@@ -163,8 +163,8 @@ const AddressPageContent = () => {
return 'Contract';
},
component: <AddressContract tabs={ contractTabs }/>,
subTabs: contractTabs.map(tab => tab.id),
component: <AddressContract tabs={ contractTabs.tabs } shouldRender={ !isTabsLoading } isLoading={ contractTabs.isLoading }/>,
subTabs: contractTabs.tabs.map(tab => tab.id),
} : undefined,
].filter(Boolean);
}, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data, isTabsLoading ]);
......
......@@ -4,6 +4,7 @@ import React from 'react';
import * as blockMock from 'mocks/blocks/block';
import * as statsMock from 'mocks/stats/index';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import * as socketServer from 'playwright/fixtures/socketServer';
import { test, expect, devices } from 'playwright/lib';
import * as configs from 'playwright/utils/configs';
......@@ -21,6 +22,10 @@ const hooksConfig = {
// test cases which use socket cannot run in parallel since the socket server always run on the same port
test.describe.configure({ mode: 'serial' });
test.beforeEach(async({ mockTextAd }) => {
await mockTextAd();
});
test('base view +@dark-mode', async({ render, mockApiResponse }) => {
await mockApiResponse('blocks', blockMock.baseListResponse, { queryParams: { type: 'block' } });
await mockApiResponse('stats', statsMock.base);
......@@ -30,11 +35,8 @@ test('base view +@dark-mode', async({ render, mockApiResponse }) => {
await expect(component).toHaveScreenshot();
});
const hiddenFieldsTest = test.extend<{ context: BrowserContext }>({
context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields),
});
hiddenFieldsTest('hidden fields', async({ render, mockApiResponse }) => {
test('hidden fields', async({ render, mockApiResponse, mockEnvs }) => {
await mockEnvs(ENVS_MAP.blockHiddenFields);
await mockApiResponse('blocks', blockMock.baseListResponse, { queryParams: { type: 'block' } });
await mockApiResponse('stats', statsMock.base);
......
import { test, expect } from '@playwright/experimental-ct-react';
import { Box } from '@chakra-ui/react';
import React from 'react';
import * as addressMock from 'mocks/address/address';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as tokenMock from 'mocks/tokens/tokenInfo';
import { test, expect } from 'playwright/lib';
import * as configs from 'playwright/utils/configs';
import CsvExport from './CsvExport';
const ADDRESS_API_URL = buildApiUrl('address', { hash: addressMock.hash });
const hooksConfig = {
router: {
query: { address: addressMock.hash, type: 'transactions', filterType: 'address', filterValue: 'from' },
isReady: true,
},
};
test.beforeEach(async({ page }) => {
await page.route(ADDRESS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(addressMock.withName),
}));
});
test('base view +@mobile +@dark-mode', async({ render, page, mockApiResponse }) => {
const hooksConfig = {
router: {
query: { address: addressMock.hash, type: 'transactions', filterType: 'address', filterValue: 'from' },
},
};
await mockApiResponse('address', addressMock.validator, { pathParams: { hash: addressMock.hash } });
const component = await render(<Box sx={{ '.recaptcha': { w: '304px', h: '78px' } }}><CsvExport/></Box>, { hooksConfig });
test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await expect(component).toHaveScreenshot({
mask: [ page.locator('.recaptcha') ],
maskColor: configs.maskColor,
});
});
const component = await mount(
<TestApp>
<CsvExport/>
</TestApp>,
{ hooksConfig },
);
test('token holders', async({ render, page, mockApiResponse }) => {
const hooksConfig = {
router: {
query: { address: addressMock.hash, type: 'holders' },
},
};
await mockApiResponse('address', addressMock.token, { pathParams: { hash: addressMock.hash } });
await mockApiResponse('token', tokenMock.tokenInfo, { pathParams: { hash: addressMock.hash } });
await page.waitForResponse('https://www.google.com/recaptcha/api2/**');
const component = await render(<Box sx={{ '.recaptcha': { w: '304px', h: '78px' } }}><CsvExport/></Box>, { hooksConfig });
await expect(component).toHaveScreenshot({
mask: [ page.locator('.recaptcha') ],
......
......@@ -15,6 +15,7 @@ import { nbsp } from 'lib/html-entities';
import CsvExportForm from 'ui/csvExport/CsvExportForm';
import ContentLoader from 'ui/shared/ContentLoader';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
interface ExportTypeEntity {
......@@ -53,6 +54,11 @@ const EXPORT_TYPES: Record<CsvExportParams['type'], ExportTypeEntity> = {
fileNameTemplate: 'logs',
filterType: 'topic',
},
holders: {
text: 'holders',
resource: 'csv_export_token_holders',
fileNameTemplate: 'holders',
},
};
const isCorrectExportType = (type: string): type is CsvExportParams['type'] => Object.keys(EXPORT_TYPES).includes(type);
......@@ -75,6 +81,15 @@ const CsvExport = () => {
},
});
const tokenQuery = useApiQuery('token', {
pathParams: { hash: addressHash },
queryOptions: {
enabled: Boolean(addressHash) && exportTypeParam === 'holders',
},
});
const isLoading = addressQuery.isPending || (exportTypeParam === 'holders' && tokenQuery.isPending);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
......@@ -111,7 +126,7 @@ const CsvExport = () => {
const content = (() => {
throwOnResourceLoadError(addressQuery);
if (addressQuery.isPending) {
if (isLoading) {
return <ContentLoader/>;
}
......@@ -119,6 +134,7 @@ const CsvExport = () => {
<CsvExportForm
hash={ addressHash }
resource={ exportType.resource }
exportType={ isCorrectExportType(exportTypeParam) ? exportTypeParam : undefined }
filterType={ filterType }
filterValue={ filterValue }
fileNameTemplate={ exportType.fileNameTemplate }
......@@ -126,16 +142,33 @@ const CsvExport = () => {
);
})();
return (
<>
<PageTitle
title="Export data to CSV file"
backLink={ backLink }
/>
const description = (() => {
if (isLoading) {
return null;
}
if (exportTypeParam === 'holders') {
return (
<Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap">
<span>Export { exportType.text } for token </span>
<TokenEntity
token={ tokenQuery.data }
truncation={ isMobile ? 'constant' : 'dynamic' }
w="min-content"
noCopy
noSymbol
/>
<span> to CSV file. </span>
<span>Exports are limited to the last 10K { exportType.text }.</span>
</Flex>
);
}
return (
<Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap">
<span>Export { exportType.text } for address </span>
<AddressEntity
address={{ hash: addressHash, is_contract: true, implementation_name: null }}
address={ addressQuery.data }
truncation={ isMobile ? 'constant' : 'dynamic' }
noCopy
/>
......@@ -144,6 +177,16 @@ const CsvExport = () => {
<span>to CSV file. </span>
<span>Exports are limited to the last 10K { exportType.text }.</span>
</Flex>
);
})();
return (
<>
<PageTitle
title="Export data to CSV file"
backLink={ backLink }
/>
{ description }
{ content }
</>
);
......
......@@ -9,8 +9,8 @@ import * as configs from 'playwright/utils/configs';
import OptimisticL2Deposits from './OptimisticL2Deposits';
const DEPOSITS_API_URL = buildApiUrl('l2_deposits');
const DEPOSITS_COUNT_API_URL = buildApiUrl('l2_deposits_count');
const DEPOSITS_API_URL = buildApiUrl('optimistic_l2_deposits');
const DEPOSITS_COUNT_API_URL = buildApiUrl('optimistic_l2_deposits_count');
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
......
......@@ -14,9 +14,9 @@ import StickyPaginationWithText from 'ui/shared/StickyPaginationWithText';
const OptimisticL2Deposits = () => {
const { data, isError, isPlaceholderData, pagination } = useQueryWithPages({
resourceName: 'l2_deposits',
resourceName: 'optimistic_l2_deposits',
options: {
placeholderData: generateListStub<'l2_deposits'>(
placeholderData: generateListStub<'optimistic_l2_deposits'>(
L2_DEPOSIT_ITEM,
50,
{
......@@ -30,7 +30,7 @@ const OptimisticL2Deposits = () => {
},
});
const countersQuery = useApiQuery('l2_deposits_count', {
const countersQuery = useApiQuery('optimistic_l2_deposits_count', {
queryOptions: {
placeholderData: 1927029,
},
......
......@@ -14,8 +14,8 @@ const test = base.extend({
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
const OUTPUT_ROOTS_API_URL = buildApiUrl('l2_output_roots');
const OUTPUT_ROOTS_COUNT_API_URL = buildApiUrl('l2_output_roots_count');
const OUTPUT_ROOTS_API_URL = buildApiUrl('optimistic_l2_output_roots');
const OUTPUT_ROOTS_COUNT_API_URL = buildApiUrl('optimistic_l2_output_roots_count');
test('base view +@mobile', async({ mount, page }) => {
// test on mobile is flaky
......
......@@ -13,9 +13,9 @@ import StickyPaginationWithText from 'ui/shared/StickyPaginationWithText';
const OptimisticL2OutputRoots = () => {
const { data, isError, isPlaceholderData, pagination } = useQueryWithPages({
resourceName: 'l2_output_roots',
resourceName: 'optimistic_l2_output_roots',
options: {
placeholderData: generateListStub<'l2_output_roots'>(
placeholderData: generateListStub<'optimistic_l2_output_roots'>(
L2_OUTPUT_ROOTS_ITEM,
50,
{
......@@ -28,7 +28,7 @@ const OptimisticL2OutputRoots = () => {
},
});
const countersQuery = useApiQuery('l2_output_roots_count', {
const countersQuery = useApiQuery('optimistic_l2_output_roots_count', {
queryOptions: {
placeholderData: 50617,
},
......
......@@ -14,8 +14,8 @@ const test = base.extend({
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
const TXN_BATCHES_API_URL = buildApiUrl('l2_txn_batches');
const TXN_BATCHES_COUNT_API_URL = buildApiUrl('l2_txn_batches_count');
const TXN_BATCHES_API_URL = buildApiUrl('optimistic_l2_txn_batches');
const TXN_BATCHES_COUNT_API_URL = buildApiUrl('optimistic_l2_txn_batches_count');
test('base view +@mobile', async({ mount, page }) => {
// test on mobile is flaky
......
......@@ -14,9 +14,9 @@ import OptimisticL2TxnBatchesTable from 'ui/txnBatches/optimisticL2/OptimisticL2
const OptimisticL2TxnBatches = () => {
const { data, isError, isPlaceholderData, pagination } = useQueryWithPages({
resourceName: 'l2_txn_batches',
resourceName: 'optimistic_l2_txn_batches',
options: {
placeholderData: generateListStub<'l2_txn_batches'>(
placeholderData: generateListStub<'optimistic_l2_txn_batches'>(
L2_TXN_BATCHES_ITEM,
50,
{
......@@ -29,7 +29,7 @@ const OptimisticL2TxnBatches = () => {
},
});
const countersQuery = useApiQuery('l2_txn_batches_count', {
const countersQuery = useApiQuery('optimistic_l2_txn_batches_count', {
queryOptions: {
placeholderData: 5231746,
},
......
......@@ -14,8 +14,8 @@ const test = base.extend({
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
const WITHDRAWALS_API_URL = buildApiUrl('l2_withdrawals');
const WITHDRAWALS_COUNT_API_URL = buildApiUrl('l2_withdrawals_count');
const WITHDRAWALS_API_URL = buildApiUrl('optimistic_l2_withdrawals');
const WITHDRAWALS_COUNT_API_URL = buildApiUrl('optimistic_l2_withdrawals_count');
test('base view +@mobile', async({ mount, page }) => {
// test on mobile is flaky
......
......@@ -14,9 +14,9 @@ import OptimisticL2WithdrawalsTable from 'ui/withdrawals/optimisticL2/Optimistic
const OptimisticL2Withdrawals = () => {
const { data, isError, isPlaceholderData, pagination } = useQueryWithPages({
resourceName: 'l2_withdrawals',
resourceName: 'optimistic_l2_withdrawals',
options: {
placeholderData: generateListStub<'l2_withdrawals'>(
placeholderData: generateListStub<'optimistic_l2_withdrawals'>(
L2_WITHDRAWAL_ITEM,
50,
{
......@@ -29,7 +29,7 @@ const OptimisticL2Withdrawals = () => {
},
});
const countersQuery = useApiQuery('l2_withdrawals_count', {
const countersQuery = useApiQuery('optimistic_l2_withdrawals_count', {
queryOptions: {
placeholderData: 23700,
},
......
......@@ -6,7 +6,8 @@ import { test, expect } from 'playwright/lib';
import ShibariumDeposits from './ShibariumDeposits';
test('base view +@mobile', async({ render, mockApiResponse, mockEnvs }) => {
test('base view +@mobile', async({ render, mockApiResponse, mockEnvs, mockTextAd }) => {
await mockTextAd();
await mockEnvs(ENVS_MAP.shibariumRollup);
await mockApiResponse('shibarium_deposits', depositsData);
await mockApiResponse('shibarium_deposits_count', 3971111);
......
......@@ -6,12 +6,13 @@ import { test, expect } from 'playwright/lib';
import ShibariumWithdrawals from './ShibariumWithdrawals';
test('base view +@mobile', async({ render, mockApiResponse, mockEnvs }) => {
test('base view +@mobile', async({ render, mockApiResponse, mockEnvs, mockTextAd }) => {
// test on mobile is flaky
// my assumption is there is not enough time to calculate hashes truncation so component is unstable
// so I raised the test timeout to check if it helps
test.slow();
await mockTextAd();
await mockEnvs(ENVS_MAP.shibariumRollup);
await mockApiResponse('shibarium_withdrawals', withdrawalsData);
await mockApiResponse('shibarium_withdrawals_count', 397);
......
import React from 'react';
import config from 'configs/app';
import * as verifiedAddressesMocks from 'mocks/account/verifiedAddresses';
import { token as contract } from 'mocks/address/address';
import { tokenInfo, tokenCounters, bridgedTokenA } from 'mocks/tokens/tokenInfo';
......@@ -10,9 +11,12 @@ import * as configs from 'playwright/utils/configs';
import Token from './Token';
const hash = tokenInfo.address;
const chainId = config.chain.id;
const hooksConfig = {
router: {
query: { hash: '1', tab: 'token_transfers' },
query: { hash, tab: 'token_transfers' },
isReady: true,
},
};
......@@ -21,18 +25,19 @@ const hooksConfig = {
// test cases which use socket cannot run in parallel since the socket server always run on the same port
test.describe.configure({ mode: 'serial' });
test.beforeEach(async({ mockApiResponse }) => {
await mockApiResponse('token', tokenInfo, { pathParams: { hash: '1' } });
await mockApiResponse('address', contract, { pathParams: { hash: '1' } });
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash: '1' } });
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash: '1' } });
test.beforeEach(async({ mockApiResponse, mockTextAd }) => {
await mockApiResponse('token', tokenInfo, { pathParams: { hash } });
await mockApiResponse('address', contract, { pathParams: { hash } });
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash } });
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash } });
await mockTextAd();
});
test('base view', async({ render, page, createSocket }) => {
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`);
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await expect(component).toHaveScreenshot({
......@@ -42,13 +47,13 @@ test('base view', async({ render, page, createSocket }) => {
});
test('with verified info', async({ render, page, createSocket, mockApiResponse, mockAssetResponse }) => {
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId: '1', hash: '1' } });
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId, hash } });
await mockAssetResponse(tokenInfo.icon_url as string, './playwright/mocks/image_s.jpg');
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`);
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await page.getByRole('button', { name: /project info/i }).click();
......@@ -60,17 +65,24 @@ test('with verified info', async({ render, page, createSocket, mockApiResponse,
});
test('bridged token', async({ render, page, createSocket, mockApiResponse, mockAssetResponse, mockEnvs }) => {
const hash = bridgedTokenA.address;
const hooksConfig = {
router: {
query: { hash, tab: 'token_transfers' },
},
};
await mockEnvs(ENVS_MAP.bridgedTokens);
await mockApiResponse('token', bridgedTokenA, { pathParams: { hash: '1' } });
await mockApiResponse('address', contract, { pathParams: { hash: '1' } });
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash: '1' } });
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash: '1' } });
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId: '1', hash: '1' } });
await mockApiResponse('token', bridgedTokenA, { pathParams: { hash } });
await mockApiResponse('address', contract, { pathParams: { hash } });
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash } });
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash } });
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId, hash } });
await mockAssetResponse(tokenInfo.icon_url as string, './playwright/mocks/image_s.jpg');
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
const channel = await socketServer.joinChannel(socket, `tokens:${ hash.toLowerCase() }`);
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await expect(component).toHaveScreenshot({
......@@ -85,7 +97,7 @@ test.describe('mobile', () => {
test('base view', async({ render, page, createSocket }) => {
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`);
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await expect(component).toHaveScreenshot({
......@@ -95,12 +107,12 @@ test.describe('mobile', () => {
});
test('with verified info', async({ render, page, createSocket, mockApiResponse, mockAssetResponse }) => {
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId: '1', hash: '1' } });
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId, hash } });
await mockAssetResponse(tokenInfo.icon_url as string, './playwright/mocks/image_s.jpg');
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`);
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await expect(component).toHaveScreenshot({
......
import { Box, Flex, Tooltip } from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React, { useEffect } from 'react';
......@@ -10,7 +10,6 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import useContractTabs from 'lib/hooks/useContractTabs';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as metadata from 'lib/metadata';
......@@ -23,36 +22,33 @@ import * as tokenStubs from 'stubs/token';
import { getTokenHoldersStub } from 'stubs/token';
import { generateListStub } from 'stubs/utils';
import AddressContract from 'ui/address/AddressContract';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import TextAd from 'ui/shared/ad/TextAd';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags';
import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import TokenDetails from 'ui/token/TokenDetails';
import TokenHolders from 'ui/token/TokenHolders/TokenHolders';
import TokenInventory from 'ui/token/TokenInventory';
import TokenPageTitle from 'ui/token/TokenPageTitle';
import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer';
import TokenVerifiedInfo from 'ui/token/TokenVerifiedInfo';
import useTokenQuery from 'ui/token/useTokenQuery';
export type TokenTabs = 'token_transfers' | 'holders' | 'inventory';
const TABS_RIGHT_SLOT_PROPS = {
display: 'flex',
alignItems: 'center',
columnGap: 4,
};
const TokenPageContent = () => {
const [ isQueryEnabled, setIsQueryEnabled ] = React.useState(false);
const [ totalSupplySocket, setTotalSupplySocket ] = React.useState<number>();
const router = useRouter();
const isMobile = useIsMobile();
const appProps = useAppContext();
const scrollRef = React.useRef<HTMLDivElement>(null);
const hashString = getQueryParamString(router.query.hash);
......@@ -61,15 +57,9 @@ const TokenPageContent = () => {
const queryClient = useQueryClient();
const tokenQuery = useApiQuery('token', {
pathParams: { hash: hashString },
queryOptions: {
enabled: Boolean(router.query.hash),
placeholderData: tokenStubs.TOKEN_INFO_ERC_20,
},
});
const tokenQuery = useTokenQuery(hashString);
const contractQuery = useApiQuery('address', {
const addressQuery = useApiQuery('address', {
pathParams: { hash: hashString },
queryOptions: {
enabled: isQueryEnabled && Boolean(router.query.hash),
......@@ -114,12 +104,12 @@ const TokenPageContent = () => {
});
useEffect(() => {
if (tokenQuery.data && !tokenQuery.isPlaceholderData) {
metadata.update({ pathname: '/token/[hash]', query: { hash: tokenQuery.data.address } }, { symbol: tokenQuery.data.symbol ?? '' });
if (tokenQuery.data && !tokenQuery.isPlaceholderData && !config.meta.seo.enhancedDataEnabled) {
metadata.update({ pathname: '/token/[hash]', query: { hash: tokenQuery.data.address } }, tokenQuery.data);
}
}, [ tokenQuery.data, tokenQuery.isPlaceholderData ]);
const hasData = (tokenQuery.data && !tokenQuery.isPlaceholderData) && (contractQuery.data && !contractQuery.isPlaceholderData);
const hasData = (tokenQuery.data && !tokenQuery.isPlaceholderData) && (addressQuery.data && !addressQuery.isPlaceholderData);
const hasInventoryTab = tokenQuery.data?.type && NFT_TOKEN_TYPE_IDS.includes(tokenQuery.data.type);
const transfersQuery = useQueryWithPages({
......@@ -167,25 +157,29 @@ const TokenPageContent = () => {
},
});
const verifiedInfoQuery = useApiQuery('token_verified_info', {
pathParams: { hash: hashString, chainId: config.chain.id },
queryOptions: { enabled: Boolean(tokenQuery.data) && config.features.verifiedTokens.isEnabled },
});
const contractTabs = useContractTabs(contractQuery.data);
const isLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData;
const contractTabs = useContractTabs(addressQuery.data, addressQuery.isPlaceholderData);
const tabs: Array<RoutedTab> = [
hasInventoryTab ? {
id: 'inventory',
title: 'Inventory',
component: <TokenInventory inventoryQuery={ inventoryQuery } tokenQuery={ tokenQuery } ownerFilter={ ownerFilter }/>,
component: <TokenInventory inventoryQuery={ inventoryQuery } tokenQuery={ tokenQuery } ownerFilter={ ownerFilter } shouldRender={ !isLoading }/>,
} : undefined,
{ id: 'token_transfers', title: 'Token transfers', component: <TokenTransfer transfersQuery={ transfersQuery } token={ tokenQuery.data }/> },
{ id: 'holders', title: 'Holders', component: <TokenHolders token={ tokenQuery.data } holdersQuery={ holdersQuery }/> },
contractQuery.data?.is_contract ? {
{
id: 'token_transfers',
title: 'Token transfers',
component: <TokenTransfer transfersQuery={ transfersQuery } token={ tokenQuery.data } shouldRender={ !isLoading }/>,
},
{
id: 'holders',
title: 'Holders',
component: <TokenHolders token={ tokenQuery.data } holdersQuery={ holdersQuery } shouldRender={ !isLoading }/>,
},
addressQuery.data?.is_contract ? {
id: 'contract',
title: () => {
if (contractQuery.data?.is_verified) {
if (addressQuery.data?.is_verified) {
return (
<>
<span>Contract</span>
......@@ -196,8 +190,8 @@ const TokenPageContent = () => {
return 'Contract';
},
component: <AddressContract tabs={ contractTabs }/>,
subTabs: contractTabs.map(tab => tab.id),
component: <AddressContract tabs={ contractTabs.tabs } isLoading={ contractTabs.isLoading } shouldRender={ !isLoading }/>,
subTabs: contractTabs.tabs.map(tab => tab.id),
} : undefined,
].filter(Boolean);
......@@ -217,8 +211,6 @@ const TokenPageContent = () => {
pagination = inventoryQuery.pagination;
}
const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ tokenQuery.data.symbol })` : '';
const tabListProps = React.useCallback(({ isSticky, activeTabIndex }: { isSticky: boolean; activeTabIndex: number }) => {
if (isMobile) {
return { mt: 8 };
......@@ -232,101 +224,44 @@ const TokenPageContent = () => {
};
}, [ isMobile ]);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/tokens');
if (!hasGoBackLink) {
return;
const tabsRightSlot = React.useMemo(() => {
if (isMobile) {
return null;
}
return {
label: 'Back to tokens list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
const titleContentAfter = (
<>
{ verifiedInfoQuery.data?.tokenAddress && (
<Tooltip label={ `Information on this token has been verified by ${ config.chain.name }` }>
<Box boxSize={ 6 }>
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer"/>
</Box>
</Tooltip>
) }
<EntityTags
data={ contractQuery.data }
isLoading={ tokenQuery.isPlaceholderData || contractQuery.isPlaceholderData }
tagsBefore={ [
tokenQuery.data ? { label: tokenQuery.data?.type, display_name: tokenQuery.data?.type } : undefined,
config.features.bridgedTokens.isEnabled && tokenQuery.data?.is_bridged ?
{ label: 'bridged', display_name: 'Bridged', colorScheme: 'blue', variant: 'solid' } :
undefined,
] }
tagsAfter={
verifiedInfoQuery.data?.projectSector ?
[ { label: verifiedInfoQuery.data.projectSector, display_name: verifiedInfoQuery.data.projectSector } ] :
undefined
}
flexGrow={ 1 }
/>
</>
);
const isLoading = tokenQuery.isPlaceholderData || contractQuery.isPlaceholderData;
const titleSecondRow = (
<Flex alignItems="center" w="100%" minW={ 0 } columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
<AddressEntity
address={{ ...contractQuery.data, name: '' }}
isLoading={ isLoading }
fontFamily="heading"
fontSize="lg"
fontWeight={ 500 }
/>
{ !isLoading && tokenQuery.data && <AddressAddToWallet token={ tokenQuery.data } variant="button"/> }
<AddressQrCode address={ contractQuery.data } isLoading={ isLoading }/>
<AccountActionsMenu isLoading={ isLoading }/>
<Flex ml={{ base: 0, lg: 'auto' }} columnGap={ 2 } flexGrow={{ base: 1, lg: 0 }}>
<TokenVerifiedInfo verifiedInfoQuery={ verifiedInfoQuery }/>
<NetworkExplorers type="token" pathParam={ hashString } ml={{ base: 'auto', lg: 0 }}/>
</Flex>
</Flex>
);
return (
<>
{ tab === 'holders' && (
<AddressCsvExportLink
address={ hashString }
params={{ type: 'holders' }}
isLoading={ pagination?.isLoading }
/>
) }
{ pagination?.isVisible && <Pagination { ...pagination }/> }
</>
);
}, [ hashString, isMobile, pagination, tab ]);
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
title={ `${ tokenQuery.data?.name || 'Unnamed token' }${ tokenSymbolText }` }
isLoading={ isLoading }
backLink={ backLink }
beforeTitle={ tokenQuery.data ? (
<TokenEntity.Icon
token={ tokenQuery.data }
isLoading={ isLoading }
iconSize="lg"
/>
) : null }
contentAfter={ titleContentAfter }
secondRow={ titleSecondRow }
/>
<TokenPageTitle tokenQuery={ tokenQuery } addressQuery={ addressQuery }/>
<TokenDetails tokenQuery={ tokenQuery }/>
{ /* should stay before tabs to scroll up with pagination */ }
<Box ref={ scrollRef }></Box>
{ isLoading ?
<TabsSkeleton tabs={ tabs }/> :
(
<RoutedTabs
tabs={ tabs }
tabListProps={ tabListProps }
rightSlot={ !isMobile && pagination?.isVisible ? <Pagination { ...pagination }/> : null }
stickyEnabled={ !isMobile }
/>
) }
<RoutedTabs
tabs={ tabs }
tabListProps={ tabListProps }
rightSlot={ tabsRightSlot }
rightSlotProps={ TABS_RIGHT_SLOT_PROPS }
stickyEnabled={ !isMobile }
isLoading={ isLoading }
/>
</>
);
};
......
......@@ -7,6 +7,10 @@ import { test, expect } from 'playwright/lib';
import Tokens from './Tokens';
test.beforeEach(async({ mockTextAd }) => {
await mockTextAd();
});
test('base view +@mobile +@dark-mode', async({ render, mockApiResponse }) => {
const allTokens = {
items: [
......
......@@ -7,12 +7,13 @@ import Validators from './Validators';
const chainType = 'stability';
test('base view +@mobile', async({ render, mockApiResponse, mockEnvs }) => {
test('base view +@mobile', async({ render, mockApiResponse, mockEnvs, mockTextAd }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE', chainType ],
]);
await mockApiResponse('validators', validatorsMock.validatorsResponse, { pathParams: { chainType } });
await mockApiResponse('validators_counters', validatorsMock.validatorsCountersResponse, { pathParams: { chainType } });
await mockTextAd();
const component = await render(<Validators/>);
......
import React from 'react';
import * as depositsMock from 'mocks/zkEvm/deposits';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import ZkEvmL2Deposits from './ZkEvmL2Deposits';
test('base view +@mobile', async({ render, mockApiResponse, mockEnvs, mockTextAd }) => {
await mockTextAd();
await mockEnvs(ENVS_MAP.zkEvmRollup);
await mockApiResponse('zkevm_l2_deposits', depositsMock.baseResponse);
await mockApiResponse('zkevm_l2_deposits_count', 3971111);
const component = await render(<ZkEvmL2Deposits/>);
await expect(component).toHaveScreenshot();
});
import { Hide, Show, Skeleton } from '@chakra-ui/react';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import { rightLineArrow, nbsp } from 'lib/html-entities';
import { generateListStub } from 'stubs/utils';
import { ZKEVM_DEPOSITS_ITEM } from 'stubs/zkEvmL2';
import ZkEvmL2DepositsListItem from 'ui/deposits/zkEvmL2/ZkEvmL2DepositsListItem';
import ZkEvmL2DepositsTable from 'ui/deposits/zkEvmL2/ZkEvmL2DepositsTable';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import StickyPaginationWithText from 'ui/shared/StickyPaginationWithText';
const ZkEvmL2Deposits = () => {
const { data, isError, isPlaceholderData, pagination } = useQueryWithPages({
resourceName: 'zkevm_l2_deposits',
options: {
placeholderData: generateListStub<'zkevm_l2_deposits'>(
ZKEVM_DEPOSITS_ITEM,
50,
{ next_page_params: { items_count: 50, index: 1 } },
),
},
});
const countersQuery = useApiQuery('zkevm_l2_deposits_count', {
queryOptions: {
placeholderData: 1927029,
},
});
const content = data?.items ? (
<>
<Show below="lg" ssr={ false }>
{ data.items.map(((item, index) => (
<ZkEvmL2DepositsListItem
key={ String(item.index) + (isPlaceholderData ? index : '') }
isLoading={ isPlaceholderData }
item={ item }
/>
))) }
</Show>
<Hide below="lg" ssr={ false }>
<ZkEvmL2DepositsTable items={ data.items } top={ pagination.isVisible ? 80 : 0 } isLoading={ isPlaceholderData }/>
</Hide>
</>
) : null;
const text = (() => {
if (countersQuery.isError) {
return null;
}
return (
<Skeleton
isLoaded={ !countersQuery.isPlaceholderData }
display="inline-block"
>
A total of { countersQuery.data?.toLocaleString() } deposits found
</Skeleton>
);
})();
const actionBar = <StickyPaginationWithText text={ text } pagination={ pagination }/>;
return (
<>
<PageTitle title={ `Deposits (L1${ nbsp }${ rightLineArrow }${ nbsp }L2)` } withTextAd/>
<DataListDisplay
isError={ isError }
items={ data?.items }
emptyText="There are no deposits."
content={ content }
actionBar={ actionBar }
/>
</>
);
};
export default ZkEvmL2Deposits;
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import { txnBatchData } from 'mocks/zkevmL2txnBatches/zkevmL2txnBatch';
import { txnBatchData } from 'mocks/zkEvm/txnBatches';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
......
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { txnBatchesData } from 'mocks/zkevmL2txnBatches/zkevmL2txnBatches';
import { txnBatchesData } from 'mocks/zkEvm/txnBatches';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
......
import React from 'react';
import * as withdrawalsMock from 'mocks/zkEvm/withdrawals';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import ZkEvmL2Withdrawals from './ZkEvmL2Withdrawals';
test('base view +@mobile', async({ render, mockApiResponse, mockEnvs, mockTextAd }) => {
await mockTextAd();
await mockEnvs(ENVS_MAP.zkEvmRollup);
await mockApiResponse('zkevm_l2_withdrawals', withdrawalsMock.baseResponse);
await mockApiResponse('zkevm_l2_withdrawals_count', 3971111);
const component = await render(<ZkEvmL2Withdrawals/>);
await expect(component).toHaveScreenshot();
});
import { Hide, Show, Skeleton } from '@chakra-ui/react';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import { rightLineArrow, nbsp } from 'lib/html-entities';
import { generateListStub } from 'stubs/utils';
import { ZKEVM_WITHDRAWALS_ITEM } from 'stubs/zkEvmL2';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import StickyPaginationWithText from 'ui/shared/StickyPaginationWithText';
import ZkEvmL2WithdrawalsListItem from 'ui/withdrawals/zkEvmL2/ZkEvmL2WithdrawalsListItem';
import ZkEvmL2WithdrawalsTable from 'ui/withdrawals/zkEvmL2/ZkEvmL2WithdrawalsTable';
const ZkEvmL2Withdrawals = () => {
const { data, isError, isPlaceholderData, pagination } = useQueryWithPages({
resourceName: 'zkevm_l2_withdrawals',
options: {
placeholderData: generateListStub<'zkevm_l2_withdrawals'>(
ZKEVM_WITHDRAWALS_ITEM,
50,
{ next_page_params: { items_count: 50, index: 1 } },
),
},
});
const countersQuery = useApiQuery('zkevm_l2_withdrawals_count', {
queryOptions: {
placeholderData: 1927029,
},
});
const content = data?.items ? (
<>
<Show below="lg" ssr={ false }>
{ data.items.map(((item, index) => (
<ZkEvmL2WithdrawalsListItem
key={ String(item.index) + (isPlaceholderData ? index : '') }
isLoading={ isPlaceholderData }
item={ item }
/>
))) }
</Show>
<Hide below="lg" ssr={ false }>
<ZkEvmL2WithdrawalsTable items={ data.items } top={ pagination.isVisible ? 80 : 0 } isLoading={ isPlaceholderData }/>
</Hide>
</>
) : null;
const text = (() => {
if (countersQuery.isError) {
return null;
}
return (
<Skeleton
isLoaded={ !countersQuery.isPlaceholderData }
display="inline-block"
>
A total of { countersQuery.data?.toLocaleString() } withdrawals found
</Skeleton>
);
})();
const actionBar = <StickyPaginationWithText text={ text } pagination={ pagination }/>;
return (
<>
<PageTitle title={ `Withdrawals (L2${ nbsp }${ rightLineArrow }${ nbsp }L1)` } withTextAd/>
<DataListDisplay
isError={ isError }
items={ data?.items }
emptyText="There are no withdrawals."
content={ content }
actionBar={ actionBar }
/>
</>
);
};
export default ZkEvmL2Withdrawals;
......@@ -91,10 +91,12 @@ const TabsWithScroll = ({
lazyBehavior={ lazyBehavior }
>
<AdaptiveTabsList
// the easiest and most readable way to achieve correct tab's cut recalculation when screen is resized
// the easiest and most readable way to achieve correct tab's cut recalculation when
// - screen is resized or
// - tabs list is changed when API data is loaded
// is to do full re-render of the tabs list
// so we use screenWidth as a key for the TabsList component
key={ screenWidth }
// so we use screenWidth + tabIds as a key for the TabsList component
key={ screenWidth + '_' + tabsList.map((tab) => tab.id).join(':') }
tabs={ tabs }
tabListProps={ tabListProps }
rightSlot={ rightSlot }
......
......@@ -29,8 +29,6 @@ const StatusTag = ({ type, text, errorText, isLoading }: Props) => {
break;
case 'pending':
icon = 'status/pending';
// FIXME: it's not gray on mockups
// need to implement new color scheme or redefine colors here
colorScheme = 'gray';
break;
}
......
......@@ -6,7 +6,8 @@ import { test, expect } from 'playwright/lib';
import SearchBar from './SearchBar';
test.beforeEach(async({ mockAssetResponse, mockEnvs }) => {
test.beforeEach(async({ mockAssetResponse, mockEnvs, mockTextAd }) => {
await mockTextAd();
await mockAssetResponse(searchMock.token1.icon_url as string, './playwright/mocks/image_s.jpg');
await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'false' ],
......
......@@ -11,6 +11,7 @@ import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getCurrencyValue from 'lib/getCurrencyValue';
import useIsMounted from 'lib/hooks/useIsMounted';
import { TOKEN_COUNTERS } from 'stubs/token';
import type { TokenTabs } from 'ui/pages/Token';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
......@@ -25,6 +26,7 @@ interface Props {
const TokenDetails = ({ tokenQuery }: Props) => {
const router = useRouter();
const isMounted = useIsMounted();
const hash = router.query.hash?.toString();
const tokenCountersQuery = useApiQuery('token_counters', {
......@@ -66,6 +68,10 @@ const TokenDetails = ({ tokenQuery }: Props) => {
throwOnResourceLoadError(tokenQuery);
if (!isMounted) {
return null;
}
const {
exchange_rate: exchangeRate,
total_supply: totalSupply,
......
......@@ -4,6 +4,8 @@ import React from 'react';
import type { TokenInfo } from 'types/api/token';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DataListDisplay from 'ui/shared/DataListDisplay';
......@@ -16,17 +18,28 @@ import TokenHoldersTable from './TokenHoldersTable';
type Props = {
token?: TokenInfo;
holdersQuery: QueryWithPagesResult<'token_holders'>;
shouldRender?: boolean;
}
const TokenHoldersContent = ({ holdersQuery, token }: Props) => {
const TokenHoldersContent = ({ holdersQuery, token, shouldRender = true }: Props) => {
const isMobile = useIsMobile();
const isMounted = useIsMounted();
if (!isMounted || !shouldRender) {
return null;
}
if (holdersQuery.isError) {
return <DataFetchAlert/>;
}
const actionBar = isMobile && holdersQuery.pagination.isVisible && (
<ActionBar mt={ -6 }>
<AddressCsvExportLink
address={ token?.address }
params={{ type: 'holders' }}
isLoading={ holdersQuery.pagination.isLoading }
/>
<Pagination ml="auto" { ...holdersQuery.pagination }/>
</ActionBar>
);
......
......@@ -7,6 +7,7 @@ import type { TokenInfo } from 'types/api/token';
import type { ResourceError } from 'lib/api/resources';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
......@@ -20,15 +21,21 @@ type Props = {
inventoryQuery: QueryWithPagesResult<'token_inventory'>;
tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>;
ownerFilter?: string;
shouldRender?: boolean;
}
const TokenInventory = ({ inventoryQuery, tokenQuery, ownerFilter }: Props) => {
const TokenInventory = ({ inventoryQuery, tokenQuery, ownerFilter, shouldRender = true }: Props) => {
const isMobile = useIsMobile();
const isMounted = useIsMounted();
const resetOwnerFilter = React.useCallback(() => {
inventoryQuery.onFilterChange({});
}, [ inventoryQuery ]);
if (!isMounted || !shouldRender) {
return null;
}
const isActionBarHidden = !ownerFilter && !inventoryQuery.data?.items.length;
const ownerFilterComponent = ownerFilter && (
......
import { Box, Flex, Tooltip } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { Address } from 'types/api/address';
import type { TokenInfo } from 'types/api/token';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags';
import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle';
import TokenVerifiedInfo from './TokenVerifiedInfo';
interface Props {
tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>;
addressQuery: UseQueryResult<Address, ResourceError<unknown>>;
}
const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
const appProps = useAppContext();
const addressHash = !tokenQuery.isPlaceholderData ? (tokenQuery.data?.address || '') : '';
const verifiedInfoQuery = useApiQuery('token_verified_info', {
pathParams: { hash: addressHash, chainId: config.chain.id },
queryOptions: { enabled: Boolean(tokenQuery.data) && !tokenQuery.isPlaceholderData && config.features.verifiedTokens.isEnabled },
});
const isLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData || (
config.features.verifiedTokens.isEnabled ? verifiedInfoQuery.isPending : false
);
const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ tokenQuery.data.symbol })` : '';
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/tokens');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to tokens list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
const contentAfter = (
<>
{ verifiedInfoQuery.data?.tokenAddress && (
<Tooltip label={ `Information on this token has been verified by ${ config.chain.name }` }>
<Box boxSize={ 6 }>
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer"/>
</Box>
</Tooltip>
) }
<EntityTags
data={ addressQuery.data }
isLoading={ isLoading }
tagsBefore={ [
tokenQuery.data ? { label: tokenQuery.data?.type, display_name: tokenQuery.data?.type } : undefined,
config.features.bridgedTokens.isEnabled && tokenQuery.data?.is_bridged ?
{ label: 'bridged', display_name: 'Bridged', colorScheme: 'blue', variant: 'solid' } :
undefined,
] }
tagsAfter={
verifiedInfoQuery.data?.projectSector ?
[ { label: verifiedInfoQuery.data.projectSector, display_name: verifiedInfoQuery.data.projectSector } ] :
undefined
}
flexGrow={ 1 }
/>
</>
);
const secondRow = (
<Flex alignItems="center" w="100%" minW={ 0 } columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
<AddressEntity
address={{ ...addressQuery.data, name: '' }}
isLoading={ isLoading }
fontFamily="heading"
fontSize="lg"
fontWeight={ 500 }
/>
{ !isLoading && tokenQuery.data && <AddressAddToWallet token={ tokenQuery.data } variant="button"/> }
<AddressQrCode address={ addressQuery.data } isLoading={ isLoading }/>
<AccountActionsMenu isLoading={ isLoading }/>
<Flex ml={{ base: 0, lg: 'auto' }} columnGap={ 2 } flexGrow={{ base: 1, lg: 0 }}>
<TokenVerifiedInfo verifiedInfoQuery={ verifiedInfoQuery }/>
<NetworkExplorers type="token" pathParam={ addressHash } ml={{ base: 'auto', lg: 0 }}/>
</Flex>
</Flex>
);
return (
<PageTitle
title={ `${ tokenQuery.data?.name || 'Unnamed token' }${ tokenSymbolText }` }
isLoading={ tokenQuery.isPlaceholderData }
backLink={ backLink }
beforeTitle={ tokenQuery.data ? (
<TokenEntity.Icon
token={ tokenQuery.data }
isLoading={ tokenQuery.isPlaceholderData }
iconSize="lg"
/>
) : null }
contentAfter={ contentAfter }
secondRow={ secondRow }
/>
);
};
export default TokenPageTitle;
......@@ -7,6 +7,7 @@ import type { TokenInfo } from 'types/api/token';
import useGradualIncrement from 'lib/hooks/useGradualIncrement';
import useIsMobile from 'lib/hooks/useIsMobile';
import useIsMounted from 'lib/hooks/useIsMounted';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import ActionBar from 'ui/shared/ActionBar';
......@@ -21,10 +22,12 @@ type Props = {
transfersQuery: QueryWithPagesResult<'token_transfers'> | QueryWithPagesResult<'token_instance_transfers'>;
tokenId?: string;
token?: TokenInfo;
shouldRender?: boolean;
}
const TokenTransfer = ({ transfersQuery, tokenId, token }: Props) => {
const TokenTransfer = ({ transfersQuery, tokenId, token, shouldRender = true }: Props) => {
const isMobile = useIsMobile();
const isMounted = useIsMounted();
const router = useRouter();
const { isError, isPlaceholderData, data, pagination } = transfersQuery;
......@@ -55,8 +58,11 @@ const TokenTransfer = ({ transfersQuery, tokenId, token }: Props) => {
handler: handleNewTransfersMessage,
});
const content = data?.items ? (
if (!isMounted || !shouldRender) {
return null;
}
const content = data?.items ? (
<>
<Box display={{ base: 'none', lg: 'block' }}>
<TokenTransferTable
......
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import * as tokenStubs from 'stubs/token';
export default function useTokenQuery(hash: string) {
const { apiData } = useAppContext<'/token/[hash]'>();
return useApiQuery('token', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
placeholderData: tokenStubs.TOKEN_INFO_ERC_20,
initialData: apiData || undefined,
},
});
}
import { Skeleton, chakra } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { ZkEvmL2WithdrawalsItem } from 'types/api/zkEvmL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
const rollupFeature = config.features.rollup;
type Props = { item: ZkEvmL2WithdrawalsItem; isLoading?: boolean };
const ZkEvmL2WithdrawalsListItem = ({ item, isLoading }: Props) => {
if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') {
return null;
}
const timeAgo = dayjs(item.timestamp).fromNow();
return (
<ListItemMobileGrid.Container>
<ListItemMobileGrid.Label isLoading={ isLoading }>Block</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<BlockEntity
number={ item.block_number }
isLoading={ isLoading }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 600 }
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Index</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">
{ item.index }
</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L2 txn hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<TxEntity
isLoading={ isLoading }
hash={ item.l2_transaction_hash }
fontSize="sm"
lineHeight={ 5 }
truncation="constant_long"
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L1 txn hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
{ item.l1_transaction_hash ? (
<TxEntityL1
isLoading={ isLoading }
hash={ item.l1_transaction_hash }
fontSize="sm"
lineHeight={ 5 }
truncation="constant_long"
/>
) : (
<chakra.span color="text_secondary">
Pending Claim
</chakra.span>
) }
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Value</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">
{ BigNumber(item.value).toFormat() }
</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Token</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">
{ item.symbol }
</Skeleton>
</ListItemMobileGrid.Value>
</ListItemMobileGrid.Container>
);
};
export default ZkEvmL2WithdrawalsListItem;
import { Table, Tbody, Th, Tr } from '@chakra-ui/react';
import React from 'react';
import type { ZkEvmL2WithdrawalsItem } from 'types/api/zkEvmL2';
import { default as Thead } from 'ui/shared/TheadSticky';
import ZkEvmL2WithdrawalsTableItem from './ZkEvmL2WithdrawalsTableItem';
type Props = {
items: Array<ZkEvmL2WithdrawalsItem>;
top: number;
isLoading?: boolean;
}
const ZkEvmL2DepositsTable = ({ items, top, isLoading }: Props) => {
return (
<Table variant="simple" size="sm" style={{ tableLayout: 'auto' }} minW="950px">
<Thead top={ top }>
<Tr>
<Th>Block</Th>
<Th>Index</Th>
<Th>L2 txn hash</Th>
<Th>Age</Th>
<Th>L1 txn hash</Th>
<Th isNumeric>Value</Th>
<Th>Token</Th>
</Tr>
</Thead>
<Tbody>
{ items.map((item, index) => (
<ZkEvmL2WithdrawalsTableItem key={ String(item.index) + (isLoading ? index : '') } item={ item } isLoading={ isLoading }/>
)) }
</Tbody>
</Table>
);
};
export default ZkEvmL2DepositsTable;
import { Td, Tr, Skeleton, chakra } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { ZkEvmL2WithdrawalsItem } from 'types/api/zkEvmL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
const rollupFeature = config.features.rollup;
type Props = { item: ZkEvmL2WithdrawalsItem; isLoading?: boolean };
const ZkEvmL2WithdrawalsTableItem = ({ item, isLoading }: Props) => {
if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') {
return null;
}
const timeAgo = dayjs(item.timestamp).fromNow();
return (
<Tr>
<Td verticalAlign="middle">
<BlockEntity
number={ item.block_number }
isLoading={ isLoading }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 600 }
noIcon
/>
</Td>
<Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading }>
<span>{ item.index }</span>
</Skeleton>
</Td>
<Td verticalAlign="middle">
<TxEntity
isLoading={ isLoading }
hash={ item.l2_transaction_hash }
fontSize="sm"
lineHeight={ 5 }
truncation="constant_long"
noIcon
/>
</Td>
<Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ timeAgo }</span>
</Skeleton>
</Td>
<Td verticalAlign="middle">
{ item.l1_transaction_hash ? (
<TxEntityL1
isLoading={ isLoading }
hash={ item.l1_transaction_hash }
truncation="constant_long"
noIcon
fontSize="sm"
lineHeight={ 5 }
/>
) : (
<chakra.span color="text_secondary">
Pending Claim
</chakra.span>
) }
</Td>
<Td verticalAlign="middle" isNumeric>
<Skeleton isLoaded={ !isLoading } display="inline-block">
<span>{ BigNumber(item.value).toFormat() }</span>
</Skeleton>
</Td>
<Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } display="inline-block">
<span>{ item.symbol }</span>
</Skeleton>
</Td>
</Tr>
);
};
export default ZkEvmL2WithdrawalsTableItem;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment